Una guía de Scala para desarrolladores de Java: creación de una calculadora, parte 1
Resumen Los lenguajes específicos de dominio se han convertido en un tema candente. Muchos lenguajes funcionales son populares principalmente porque pueden usarse para construir lenguajes específicos de dominio. En el primer artículo de la serie Developer's Guide to Scala, Ted Neward se propone construir una calculadora DSL simple para demostrar el poder de los lenguajes funcionales en la construcción de DSL externos. Examina una nueva característica de Scala, la clase de caso, y revisa una. Potente coincidencia de patrones de funciones
Después del artículo del mes pasado, recibí algunas quejas/comentarios más de que los ejemplos que he usado hasta ahora en esta serie no abordan ningún problema sustancial. Tiene sentido usar algunos pequeños. Ejemplos en las primeras etapas del aprendizaje de un nuevo idioma. Los lectores querrán ver algunos ejemplos más realistas para comprender el dominio profundo y el poder del idioma y sus ventajas. Es por eso que en el artículo de este mes haremos un ejercicio de construcción en dos partes. un lenguaje de dominio específico (DSL): este artículo utiliza un pequeño lenguaje de calculadora como ejemplo
Acerca de esta serie
Ted Neward analizará Scala en profundidad contigo Programación Idiomas En esta nueva serie de DeveloperWorks, aprenderá más sobre Sacla y verá las características del lenguaje Scala en acción. El código Scala y el código Java se mostrarán uno al lado del otro cuando se comparen. Nada de lo que se encuentra en la programación Java está directamente relacionado y esa es la belleza de. Escala! Si puedes hacerlo con código Java, ¿por qué molestarte en aprender Scala?
Lenguaje específico del dominio
Quizás no puedas (o no tengas tiempo) para manejar la presión de tu gerente de proyecto, así que deja Voy directo al grano. Para decirlo sin rodeos, los lenguajes específicos de dominio no son más que un intento (nuevamente) de poner la funcionalidad de una aplicación en el lugar que corresponde: en manos del usuario.
Al definir un nuevo usuario que pueda entenderlo y usarlo directamente, los programadores de lenguaje de texto se liberan de la molestia de lidiar constantemente con solicitudes de UI y mejoras de funciones, y esto también permite a los usuarios crear sus propios scripts y otras herramientas para crear nuevos comportamientos para las aplicaciones. Aunque este ejemplo puede ser un poco arriesgado (y puede generar algunos correos electrónicos de queja), aún así diría que el ejemplo más exitoso de un DSL es el lenguaje Office Excel de Microsoft para expresar varios cálculos y contenidos de celdas de hojas de cálculo. algunos incluso consideran que el propio SQL es un DSL, pero esta vez un lenguaje diseñado para interactuar con bases de datos relacionales (imagínese cómo sería si un programador obtuviera datos de Oracle a través de llamadas API tradicionales de lectura()/escritura()). p>
El DSL creado aquí es un lenguaje de calculadora simple para obtener y evaluar expresiones matemáticas. De hecho, el objetivo aquí es crear un lenguaje pequeño que permita al usuario ingresar expresiones algebraicas relativamente simples y luego este código para evaluarlo. y producir un resultado para mantener las cosas simples y claras, el lenguaje no admitirá muchas de las funciones que sí admiten las calculadoras completas, pero no quiero ni quiero limitar su uso a la enseñanza: el lenguaje debe ser. suficientemente extensible. Esto significa que el lenguaje debe ser fácilmente extensible y encapsulado para que los lectores puedan usarlo como el núcleo de un lenguaje más poderoso sin cambiar completamente el lenguaje.
p>
El tema de DSL es muy amplio. Su riqueza y amplitud no se pueden describir en un párrafo de este artículo. Los lectores que quieran saber más sobre DSL pueden consultar el progreso continuo de Martin Fowler que se enumera al final de este artículo. Los libros prestan especial atención a la discusión entre DSL internos y externos. Scala, con su sintaxis flexible y características poderosas, es el lenguaje más poderoso para construir DSL internos y externos.
En otras palabras (en última instancia, The. el objetivo es permitir a los clientes escribir código para lograr los siguientes objetivos
Objetivo DSL de la calculadora de manifiesto
// Esto es Java usando la cadena de la calculadora s = (( * ) ) ; = tedneward calcdsl Calculadora evalua(s); Sistema fuera println( Obtuvimos el resultado); // Debería ser
No completaremos todas las discusiones en un artículo, pero lo haremos en este artículo. Puedes aprender parte del contenido y completar todo el contenido en el siguiente artículo
Desde una perspectiva de implementación y diseño, puede comenzar construyendo un analizador basado en cadenas para construir algo que pueda seleccionar cada carácter y calcularlo dinámicamente. Analizadores Esto es muy tentador, pero solo funciona para lenguajes más simples y no se escala muy bien. Si el objetivo del lenguaje es lograr una escalabilidad simple, tomemos un momento para pensar en cómo hacerlo antes de profundizar en la implementación. Lenguaje de diseño
Según las partes más esenciales de esas teorías básicas de compilación, se puede saber que el funcionamiento básico de un procesador de lenguaje (incluidos el intérprete y el compilador) consta de al menos dos etapas.
●? El analizador se utiliza para obtener el texto de entrada y convertirlo en un árbol de sintaxis abstracta (AST). El generador de código (en el caso de un compilador) se utiliza para obtener el AST y generar el código de bytes requerido. consulta desde él. Se utiliza un valorador (en el caso de un intérprete) para tomar el AST y calcular lo que encuentra dentro del AST. Tener el AST le permite optimizar el árbol de resultados hasta cierto punto. entonces te das cuenta de esto. La razón de la distinción anterior se vuelve aún más obvia con una calculadora. Es posible que deseemos examinar cuidadosamente la expresión para descubrir dónde podemos truncar una parte completa de la expresión, como en una expresión de multiplicación donde está el operando. (lo que indica que, independientemente de los otros operandos, el resultado de cualquier operación será Las clases tienen algunas propiedades que las hacen muy adecuadas para construir AST
Clases de caso
Antes de profundizar en la definición de AST , permítame describir brevemente qué es una clase de caso. Una clase de caso es lo que utiliza Scala. Un mecanismo conveniente para que el programador cree una clase con ciertos valores predeterminados asumidos. Por ejemplo, al escribir el siguiente listado. usa la clase case para persona
case class Person(first :String last:String age:Int){}
El compilador Scala no solo puede generar el constructor esperado como lo esperamos - el compilador de Scala también puede generar toString igual a() en el sentido normal () y hashCode() implementa el hecho de que esta clase de caso es trivial (es decir, no tiene otra clase).
miembros) Por lo tanto, el contenido de las llaves después de la declaración de clase de caso es opcional
Lista La lista de clases más corta del mundo
clase de caso Persona (primero: Cadena último: Edad de cadena : Int)
Esto se verifica fácilmente a través de nuestro viejo amigo javap
¡Lista del generador de códigos sagrados Batman!
C:\Projects\Exploration\Scalagt; javap PersonCompilado de case scala public class Person extiende java lang Objeto implementa scala ScalaObject scala Producto java io Serializable{ public Person(java lang String java lang String public int); java lang Object productElement(int); public int productArity(); public java lang String productPrefix(); public int hashCode(); ); public int age(); public java lang String last(); public java lang String first()
Como puede ver, suceden muchas cosas con las clases de casos que generalmente no son causadas. por clases tradicionales. Esto se debe a que las clases de casos se usan junto con la coincidencia de patrones de Scala (que se analizó brevemente en los tipos de colección).
Usar clases de casos es algo diferente a usar clases tradicionales. Esto se debe a que generalmente lo son. no pasan por clases tradicionales Construidas con nueva sintaxis, de hecho generalmente se crean a través de un método de fábrica con el mismo nombre que la clase
¿Listas que no usan nueva sintaxis?
Aplicación de objeto{? def main(args: Array[String]): Unit =? { val ted = Person(Ted Neward }}
Es posible que la clase de caso en sí no be more Las clases tradicionales son interesantes o diferentes, pero hay una diferencia importante al usarlas. El código generado por las clases de casos prefiere las ecuaciones bit a bit (biise) en comparación con las ecuaciones de referencia. Por lo tanto, el siguiente código es muy útil para los programadores de Java. sorpresas interesantes
Lista Esta no es la clase anterior
objeto App{? def main(args: Array[String]): Unidad = { val ted = Persona( Ted Neward ) val ted = Persona( Ted Neward ) val amanda = Persona( Amanda Laucher ) Sistema fuera println( ted == amanda: ? (if (ted == amand
a) Sí más No )) Sistema fuera println( ted == ted: ? (if (ted == ted) Sí más No )) Sistema fuera println( ted == ted : ? (if (ted == ted ) Sí más No ))? }}/*C:\Projects\Exploration\Scalagt; scala Appted == amanda: Noted == ted: Yested == ted: Yes*/
El verdadero valor de la clase case Los lectores de esta serie sobre coincidencia de patrones pueden revisar la coincidencia de patrones (consulte el segundo artículo de esta serie sobre varias construcciones de control en Scala). La coincidencia de patrones es similar al switch/case de Java, excepto que sus capacidades y funciones son más potentes. La coincidencia de patrones no es solo la capacidad de verificar el valor de una construcción de coincidencia para realizar coincidencias de valores con comodines locales (algo así como valores predeterminados locales). También se pueden vincular valores de coincidencias de prueba. a variables locales o incluso coincidir con criterios de coincidencia. El tipo en sí también puede coincidir
Con la coincidencia de patrones de clase de caso, tiene funciones más poderosas como se muestra en la lista
Esta lista es. no el cambio anterior
Case class Person(first: String last: String age: Int object App{? def main(args: Array[String]): Unit = { val ted = Person( Ted Neward) val amanda = Persona(Amanda Laucher) Sistema fuera println(proceso(ted)) Sistema fuera println(proceso(amanda) }? def proceso(p : Persona) = { El procesamiento de p revela que (p coincide { ? case Persona(_ _ a) si un gt; =gt; ciertamente son viejos ? case Persona(_ Neward _) =gt; primero último tiene ageInYears años? case _ = gt; No tengo idea de qué hacer con esta persona } }}/*C:\Projects\Exploration\Scalagt; Scala AppProcessing Person (Ted Neward) revela que ciertamente son La antigua persona de procesamiento (Amanda Laucher) revela que Amanda Laucher tiene años */
Hay muchas operaciones sucediendo en la lista. Entendamos lentamente lo que está sucediendo y luego volvamos a la calculadora para ver cómo. para aplicarlo.
Ellos
Primero, toda la expresión de coincidencia está entre paréntesis. Esto no es un requisito de la sintaxis de coincidencia de patrones, pero es así porque concateno los resultados de la expresión de coincidencia de patrones de acuerdo con el prefijo anterior. (Recuerde que en los lenguajes funcionales todo es una expresión)
En segundo lugar, hay dos comodines en la expresión del primer caso (el carácter subrayado es el comodín), lo que significa que la coincidencia será Los dos campos en la persona coincidente se obtiene cualquier valor, pero se introduce una variable local en una página. El valor en edad estará vinculado a esta variable local. Este caso solo es posible si también se proporciona la expresión protectora (seguida de ella). tendrá éxito, pero solo para la primera Persona. Esto no sucederá para la segunda. La expresión del segundo caso usa un comodín en la parte del nombre de la Persona pero usa la cadena constante Neward en la parte del apellido para hacer coincidir. parte de edad a coincidir
Dado que la primera persona ya ha sido coincidente con el caso anterior y la segunda persona no tiene el apellido Neward, la coincidencia no se activará para ninguna persona (excepto para la persona (Michael Neward ) pasará al segundo caso porque la cláusula de protección en el primer caso falla)
El tercer ejemplo muestra un uso común de coincidencia de patrones, a veces llamado extracción. En esta extracción, los valores en el objeto coincidente p. durante el procedimiento se extraen en variables locales (primero, último y ageInYears) para su uso dentro del bloque de casos. La última expresión de caso es el valor predeterminado del caso ordinario. Solo se usa cuando las otras expresiones de caso lo son. Solo se activará si. no tiene éxito
Después de comprender brevemente la clase de caso y la coincidencia de patrones, volvamos a la tarea de crear la calculadora AST
Calculadora AST
p>Primero Por encima de todo, el AST de la calculadora debe tener un tipo base común porque las expresiones matemáticas generalmente se componen de subexpresiones. Puede verlo fácilmente a través de (*). En este ejemplo, la subexpresión (*) será el operando correcto de la operación. p>
De hecho, esta expresión proporciona tres tipos de AST
●? ¿Expresión base? ¿Tipo de número que lleva un valor constante? ¿Lleva operador aritmético y binario con dos operandos? Considere que la aritmética también permite utilizar el operador unario como operador de negación (signo menos) para convertir un valor de un número positivo a un número negativo. Entonces podemos presentar la siguiente AST básica
Calculadora de manifiesto AST. (src/calc scala)
paquete tedneward calcdsl{? privado[calcdsl] clase abstracta Expr? privado[calcdsl]? número de clase de caso (valor: Doble) extiende Expr privado[calcdsl]? (operador: String arg: Expr) extiende Expr? privado[calcdsl]? clase de caso BinaryOp(operador: String izquierda: Expr derecha: Expr)?
r}
Tenga en cuenta que la declaración del paquete pone todo esto en un solo paquete (tedneward calcdsl) y que la declaración del modificador de acceso antes de cada clase indica que otros miembros o subpaquetes del paquete pueden acceder al paquete. La razón por la que debe prestar atención a esto es porque necesita tener una serie de calculadoras de prueba JUnit que puedan probar este código. El cliente real no necesariamente necesita ver el AST, por lo que las pruebas unitarias están escritas como un subpaquete de. tedneward calcdsl
Prueba de calculadora de manifiesto (testsrc/calctest scala)
paquete tedneward calcdsl test{? class CalcTest? { import junit _ Assert _ @Test def ASTTest = {? Número( )? operador) }? }}
Hasta ahora todo bien, tenemos un AST
Piénselo de nuevo, usamos cuatro líneas de código Scala para construir una jerarquía de tipos que representa una expresión matemática de colecciones de profundidad arbitraria. de expresiones (por supuesto, estas expresiones matemáticas son simples pero aún útiles) Esto no es nada comparado con la capacidad de Scala para hacer la programación de objetos más fácil y más expresiva (no te preocupes, el poder real aún está por llegar)
Siguiente Necesitamos una función de evaluación que tome el AST y encuentre su valor numérico. Con el poder de la coincidencia de patrones, escribir una función de este tipo es muy sencillo
Calculadora de listas (src/calc scala)
paquete tedneward calcdsl{? // ? objeto Calc? { def evaluar(e: Expr): Doble = {? e partido { número de caso(x) =gt x caso UnaryOp( x) =gt ; )) case BinaryOp( x x ) =gt; (evaluar(x ) evaluar(x )) case BinaryOp( x x ) =gt; (evaluar(x ) evaluar(x )) case BinaryOp( * x x ) = gt; x) * evaluar(x)) case BinaryOp( / x x ) =gt; (evaluar(x ) / evaluar(x ))? que cada caso en la coincidencia de patrón debe evaluarse con un valor Doble, lo cual no es difícil, los números simplemente devuelven su valor contenedor, pero para los casos restantes (hay dos operadores) también debemos calcular los operandos antes de realizar las operaciones necesarias (negatividad, suma). , resta, etc.). Como se ve a menudo en los lenguajes funcionales, se usa la recursividad, por lo que solo necesitamos
Simplemente llame a evaluar() en cada operando antes de realizar la operación general.
La mayoría de los programadores orientados a objetos comprometidos argumentarían que la idea de realizar operaciones fuera de los distintos operadores es simplemente incorrecta. Esta idea es claramente errónea. Viola los principios de encapsulación y polimorfismo. Hablando francamente, esto ni siquiera vale la pena discutirlo. Claramente viola el principio de encapsulación, al menos en el sentido tradicional.
Aquí hay una cosa que debemos considerar. La pregunta es ¿dónde exactamente encapsulamos el código? Tenga en cuenta que las clases AST no son visibles fuera del paquete y que a los clientes (eventualmente) solo se les pasará una representación de cadena de la expresión que desean evaluar. Solo las pruebas unitarias funcionan directamente con las clases de casos AST
. Pero esto no quiere decir que toda encapsulación sea inútil u obsoleta, de hecho, por el contrario, intenta convencernos de que existen muchos otros métodos de diseño que funcionan bien además de los métodos con los que estamos familiarizados en el campo de objetos. No se olvide de Scala y de objetos específicos y funcionales. A veces, Expr necesita adjuntar comportamientos adicionales a sí mismo y a sus subclases (como el método toString para obtener un buen resultado). En este caso, estos métodos se pueden agregar fácilmente a Expr y a objetos. -Combinación orientada Proporciona otra opción. Ni los programadores funcionales ni los programadores de objetos ignorarán la otra mitad del método de diseño y considerarán cómo combinar los dos para lograr algunos efectos interesantes.
Desde una perspectiva de diseño, parece que algunos. otras opciones son problemáticas, como usar cadenas para transportar operadores. Existe la posibilidad de que pequeños errores tipográficos eventualmente conduzcan a resultados incorrectos. En el código de producción, es posible usar (quizás deba usar) enumeraciones en lugar de cadenas. de cadenas, significa que potencialmente podemos abrir operadores para permitir llamar a funciones más complejas (como abs sin cos tan, etc.) e incluso funciones definidas por el usuario. Estas funciones son difíciles de admitir mediante métodos basados en enumeraciones.
No existe un método de toma de decisiones adecuado para todos los diseños e implementaciones y debes aceptar las consecuencias bajo tu propia responsabilidad
Pero hay un pequeño truco interesante que se puede utilizar aquí. Ciertas expresiones matemáticas. se puede simplificar (potencialmente) optimiza la evaluación de expresiones (lo que demuestra la utilidad de los AST)
● ¿Cualquier operando agregado se puede reducir a un operando distinto de cero? Cualquier operación de multiplicación ¿Cualquier número puede ser? simplificado a un operando distinto de cero. Cualquier operando de multiplicación se puede simplificar a cero.
Eso no es todo. Entonces introducimos un paso llamado simplificar () que se realiza antes de la evaluación. /p>
Calculadora de lista (src/calc scala)
def simplificar(e: Expr): Expr = {? e match { // La doble negación devuelve el valor original case UnaryOp( UnaryOp( x )) =gt; x // Positivo devuelve el valor original case UnaryOp( x) =gt; // Multiplicar x por devuelve el valor original case BinaryOp( * x Number( )) = x // Multiplicar por x devuelve el valor original case BinaryOp( * Number( ) x) =gt x // Multiplicar x por devuelve cero case Bina;
ryOp( * x Número( )) =gt; Número( ) // Multiplicar por x devuelve cero case BinaryOp( * Número( ) x) =gt; Número( ) // Dividir x por devuelve el valor original case BinaryOp( / x; Número( )) =gt; x // Agregar x devuelve el valor original case BinaryOp( x Número( )) =gt // Agregar a x devuelve el valor original case BinaryOp( Número( ) x) =gt; // Nada más no se puede simplificar (todavía) case _ =gt; e? } }
Aún así, debemos prestar atención a cómo utilizar las funciones de coincidencia constante y vinculación de variables de la coincidencia de patrones para facilitar la escritura. expresiones fáciles El único cambio en evaluar() es incluir una llamada simplificada antes de la evaluación
Calculadora de manifiesto (src/calc scala)
def evaluar(e: Expr): Double = {? simplificar(e) coincidir { número de caso(x) =gt; x caso UnaryOp( x) =gt; (evaluar(x)) caso BinaryOp( x x ) =gt; BinaryOp( x x ) =gt; (evaluar(x ) evaluar(x )) case BinaryOp( * x x ) =gt; (evaluar(x ) * evaluar(x )) case BinaryOp( / x x ) =gt; ) /evaluar(x ))? } }
¿Se puede simplificar aún más? ¿Observa cómo simplifica solo el nivel más bajo del árbol? Si tenemos un BinaryOp que contiene BinaryOp (*Number() Number()) y Number(), entonces el BinaryOp interno se puede simplificar a Number(), pero también lo hará el BinaryOp externo. Esto se debe a que en este momento el BinaryOp externo. Un operando es cero
De repente caí en la enfermedad ocupacional del escritor, así que quiero dejar que los lectores la definan. En realidad, es solo para agregar un poco de diversión si los lectores están dispuestos a hacerlo. envíame sus implementaciones, lo pondré en el análisis de código del próximo artículo. Habrá dos unidades de prueba que probarán esta situación y fallarán de inmediato. Tu tarea (si eliges aceptarla) es hacerlas. pruebas - y cualquier otra prueba siempre y cuando Esta prueba requiera un grado arbitrario de anidamiento BinaryOp y UnaryOp - Pasa
Conclusión
Obviamente, aún no he terminado y todavía hay trabajo de análisis por hacer. hacer, pero la calculadora AST ha tomado forma y no necesitamos hacer grandes cambios. Podemos agregar otras operaciones para ejecutar el AST sin mucho código (siguiendo el patrón Visitante de Gang of Four) y
Ya existe algún código de trabajo para realizar el cálculo en sí (si el cliente está dispuesto a crear el código para la evaluación por nosotros) lishixinzhi/Article/program/Java/hx/201311/25735