Los programadores discuten constantemente sobre qué lenguaje es el mejor. Una vez comparamos C y Pascal, pero pasó el tiempo. Las batallas de / y /C# ya quedaron atrás. Cada idioma tiene sus pros y sus contras, por eso los comparamos. Lo ideal sería ampliar los idiomas para adaptarlos a nuestras propias necesidades. Los programadores han tenido esta oportunidad durante mucho tiempo. Conocemos diferentes formas de metaprogramar, es decir, crear programas para crear programas. Incluso las macros triviales en C le permiten generar grandes fragmentos de código a partir de pequeñas descripciones. Sin embargo, estas macros son poco fiables, limitadas y poco expresivas. Las lenguas modernas tienen medios de extensión mucho más expresivos. Uno de estos lenguajes es Kotlin. Python Ruby Java La definición de un lenguaje de dominio específico. Un es un lenguaje desarrollado específicamente para un área temática específica, a diferencia de los lenguajes de propósito general como Java, C#, C++ y otros. Esto significa que es más fácil, más conveniente y más expresivo describir las tareas del área temática, pero al mismo tiempo es inconveniente y poco práctico para resolver tareas cotidianas, es decir, no es un lenguaje universal. Como ejemplo de DSL, puede utilizar el lenguaje de expresión regular. El área temática de las expresiones regulares es el formato de las cadenas. lenguaje de dominio específico (DSL) Para comprobar que la cadena cumple con el formato, basta con utilizar una biblioteca que implemente soporte para expresiones regulares: private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); } Si verifica que la cadena cumpla con el formato especificado en un lenguaje universal, por ejemplo, Java, obtendrá el siguiente código: private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); } El código anterior es más difícil de leer que las expresiones regulares, es más fácil cometer errores y más complicado realizar cambios. Otros ejemplos comunes de DSL son HTML, CSS, SQL, UML y BPMN (los dos últimos usan notación gráfica). Los DSL no solo los utilizan los desarrolladores, sino también los evaluadores y los especialistas no informáticos. Tipos de ADSL Los DSL se dividen en dos tipos: externos e internos. Los lenguajes tienen su propia sintaxis y no dependen del lenguaje de programación universal en el que se implementa su soporte. DSL externos Pros y contras de los DSL externos: 🟢 Generación de código en diferentes idiomas / bibliotecas listas para usar 🟢 Más opciones para configurar su sintaxis 🔴 Uso de herramientas especializadas: ANTLR, yacc, lex 🔴 A veces es difícil describir la gramática. 🔴 No hay soporte para IDE, necesitas escribir tus complementos se basan en un lenguaje de programación universal específico (lenguaje anfitrión). Es decir, con la ayuda de herramientas estándar del idioma anfitrión, se crean bibliotecas que permiten escribir de forma más compacta. Como ejemplo, considere el enfoque de Fluent API. Los DSL internos Pros y contras de los DSL internos: 🟢 Utiliza las expresiones del idioma anfitrión como base. 🟢 Es fácil incorporar DSL en el código en los idiomas anfitriones y viceversa 🟢 No requiere generación de código 🟢 Se puede depurar como una subrutina en el idioma anfitrión 🔴 Posibilidades limitadas a la hora de configurar la sintaxis. Un ejemplo de la vida real Recientemente, en la empresa nos enfrentamos a la necesidad de crear nuestro DSL. Nuestro producto tiene implementada la funcionalidad de aceptación de compra. Este módulo es un minimotor de BPM (Business Process Management). Los procesos de negocio suelen representarse gráficamente. Por ejemplo, la notación BPMN a continuación muestra un proceso que consiste en ejecutar la Tarea 1 y luego ejecutar la Tarea 2 y la Tarea 3 en paralelo. Para nosotros era importante poder crear procesos de negocio mediante programación, incluida la construcción dinámica de una ruta, la configuración de los ejecutantes para las etapas de aprobación, el establecimiento de la fecha límite para la ejecución de la etapa, etc. Para hacer esto, primero intentamos resolver este problema usando la API Fluent. acercarse. Luego llegamos a la conclusión de que establecer rutas de aceptación utilizando Fluent API todavía resulta engorroso y nuestro equipo consideró la opción de crear su propio DSL. Investigamos cómo se vería la ruta de aceptación en un DSL externo y un DSL interno basado en Kotlin (porque nuestro código de producto está escrito en Java y ). Kotlin ADSL externo: acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed ADSL interno: acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } } A excepción de las llaves, ambas opciones son casi iguales. Por lo tanto, se decidió no perder tiempo y esfuerzo en desarrollar un DSL externo, sino crear un DSL interno. Implementación de la estructura básica del DSL. Comencemos a desarrollar un modelo de objetos. interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } } lambdas Primero, veamos la clase . Está diseñado para almacenar una colección de elementos de ruta y se utiliza para representar el diagrama completo, así como bloques . AcceptanceContext parallel Los métodos y toman una lambda con un receptor como parámetro. addStep parallel Una lambda con un receptor es una forma de definir una expresión lambda que tiene acceso a un objeto receptor específico. Dentro del cuerpo del literal de función, el objeto receptor pasado a una llamada se convierte en un implícito, de modo que puede acceder a los miembros de ese objeto receptor sin ningún calificador adicional, o acceder al objeto receptor usando una expresión . this this Además, si el último argumento de una llamada a un método es lambda, entonces lambda se puede colocar fuera del paréntesis. Es por eso que en nuestro DSL podemos escribir un código como el siguiente: parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } Esto equivale a un código sin azúcar sintáctico: parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) }) Lambdas con receptores y Lambda fuera del paréntesis son características de Kotlin que son particularmente útiles cuando se trabaja con DSL. Declaración de objeto Ahora veamos la de la entidad. es un objeto. En Kotlin, una declaración de objeto es una forma de definir un singleton, una clase con una sola instancia. Entonces, la declaración de objeto define tanto la clase como su instancia única al mismo tiempo. acceptance acceptance "invocar" la sobrecarga del operador Además, el operador está sobrecargado para el objeto . El operador es una función especial que puedes definir en tus clases. Cuando invocas una instancia de una clase como si fuera una función, se llama a la función del operador . Esto le permite tratar objetos como funciones y llamarlos de manera similar a una función. invoke accreditation invoke invoke Tenga en cuenta que el parámetro del método también es una lambda con un receptor. Ahora podemos definir una ruta de aceptación… invoke val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } } ...y recorrerlo val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext Agregar detalles Funciones infijas Echa un vistazo a este código addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... } Podemos implementar esto de la siguiente manera: enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS) La clase nos permite configurar varios posibles ejecutores de tareas. En definimos la función infija . Una función infija es un tipo especial de función que le permite llamarla usando una notación infija más natural. ExecutorCondition ExecutorCondition or Sin utilizar esta característica del lenguaje, tendríamos que escribir así: addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... } Las funciones infijas también se utilizan para establecer el estado requerido del protocolo y la hora con una zona horaria. enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) } Funciones de extensión es una función de extensión. En Kotlin, las funciones de extensión le permiten agregar nuevas funciones a clases existentes sin modificar su código fuente. Esta característica es particularmente útil cuando desea aumentar la funcionalidad de clases sobre las que no tiene control, como clases de bibliotecas estándar o externas. String.timezone Uso en el DSL: addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... } Aquí es un objeto receptor, en el que se llama a la función de extensión , y es el parámetro. Se accede al objeto receptor mediante palabra clave. "2022-12-08 08:00" timezone PST this Sobrecarga del operador La siguiente característica de Kotlin que utilizamos en nuestro DSL es la sobrecarga de operadores. Ya hemos considerado la sobrecarga del operador . En Kotlin, puedes sobrecargar otros operadores, incluidos los aritméticos. invoke addStep { ... +canChange } Aquí el operador unario está sobrecargado. A continuación se muestra el código que implementa esto: + class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } } Toque final Ahora podemos describir rutas de aceptación en nuestro DSL. Sin embargo, los usuarios de DSL deben estar protegidos de posibles errores. Por ejemplo, en la versión actual, el siguiente código es aceptable: val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } } dentro de parece extraño, ¿no? Averigüemos por qué este código se compila correctamente sin errores. Como se mencionó anteriormente, los métodos y toman una lambda con un receptor como parámetro, y se puede acceder a un objeto receptor mediante palabra clave. Entonces podemos reescribir el código anterior así: addStep addStep acceptance#invoke AcceptanceContext#addStep this val acceptanceRoute = acceptance { this@acceptance.addStep { this@addStep.executor = HEAD_OF_DEPARTMENT this@addStep.duration = days(7) this@addStep.protocol shouldBe signed this@acceptance.addStep { executor = FINANCE_DEPARTMENT } } } Ahora puede ver que se llama en ambas ocasiones. Especialmente para estos casos, Kotlin tiene una anotación . Puede utilizar para definir anotaciones personalizadas. No se puede acceder a los receptores marcados con la misma anotación uno dentro del otro. this@acceptance.addStep DslMarker @DslMarker @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... } ahora el codigo val acceptanceRoute = acceptance { addStep { ... addStep { ... } } } no se compilará debido a un error 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary Enlaces A continuación se muestran enlaces a la documentación oficial de Kotlin sobre las características del lenguaje que se consideraron en este artículo: Literales de funciones con receptor https://kotlinlang.org/docs/lambdas.html#function-literals-with-receiver Pasando lambdas finales https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas Declaraciones de objetos https://kotlinlang.org/docs/object-declarations.html#object-declarations-overview Sobrecarga del operador https://kotlinlang.org/docs/operator-overloading.html Notación infija https://kotlinlang.org/docs/functions.html#infix-notation Funciones de extensión https://kotlinlang.org/docs/extensions.html#extension-functions Control de alcance: @DslMarker https://kotlinlang.org/docs/type-safe-builders.html#scope-control-dslmarker Conclusión Los lenguajes de dominio específico ofrecen un medio poderoso para mejorar la productividad, reducir errores y mejorar la colaboración al proporcionar una forma especializada y expresiva de modelar y resolver problemas dentro de un dominio específico. Kotlin es un lenguaje de programación moderno con muchas características y azúcar sintáctica, por lo que es excelente como lenguaje anfitrión para DSL interno.