Código de escritura de código: una introducción a la teoría y práctica de la metaprogramación moderna
Publicado: 2022-07-22Cada vez que pienso en la mejor manera de explicar las macros, recuerdo un programa de Python que escribí cuando comencé a programar. No pude organizarlo como quería. Tuve que llamar a varias funciones ligeramente diferentes y el código se volvió engorroso. Lo que estaba buscando, aunque entonces no lo sabía, era metaprogramación .
metaprogramación (sust.)
Cualquier técnica por la cual un programa puede tratar el código como datos.
Podemos construir un ejemplo que demuestre los mismos problemas que enfrenté con mi proyecto de Python imaginando que estamos construyendo el back-end de una aplicación para dueños de mascotas. Usando las herramientas en una biblioteca, pet_sdk
, escribimos Python para ayudar a los dueños de mascotas a comprar comida para gatos:
Después de confirmar que el código funciona, pasamos a implementar la misma lógica para dos tipos más de mascotas (pájaros y perros). También añadimos una función para reservar citas con el veterinario:
Sería bueno condensar la lógica repetitiva de Snippet 2 en un bucle, por lo que nos dispusimos a reescribir el código. Rápidamente nos damos cuenta de que, debido a que cada función tiene un nombre diferente, no podemos determinar cuál (por ejemplo, book_bird_appointment
, book_cat_appointment
) llamar en nuestro bucle:
Imaginemos una versión turboalimentada de Python en la que podamos escribir programas que generen automáticamente el código final que queremos, uno en el que podamos manipular nuestro programa de manera flexible, fácil y fluida como si fuera una lista, datos en un archivo o cualquier otro. otro tipo de datos común o entrada de programa:
Este es un ejemplo de una macro , disponible en lenguajes como Rust, Julia o C, por nombrar algunos, pero no en Python.
Este escenario es un gran ejemplo de cómo podría ser útil escribir un programa que pueda modificar y manipular su propio código. Este es precisamente el atractivo de las macros, y es una de las muchas respuestas a una pregunta más importante: ¿Cómo podemos hacer que un programa introspeccione su propio código, tratándolo como datos, y luego actúe sobre esa introspección?
En términos generales, todas las técnicas que pueden lograr tal introspección caen bajo el término general "metaprogramación". La metaprogramación es un subcampo rico en el diseño de lenguajes de programación y se remonta a un concepto importante: el código como datos.
Reflexión: En defensa de Python
Puede señalar que, aunque es posible que Python no brinde soporte para macros, ofrece muchas otras formas de escribir este código. Por ejemplo, aquí usamos el método isinstance()
para identificar la clase de la que nuestra variable animal
es una instancia y llamar a la función apropiada:
A este tipo de metaprogramación lo llamamos reflexión , y volveremos a él más adelante. El código del Fragmento 5 sigue siendo un poco engorroso pero más fácil de escribir para un programador que el del Fragmento 2, en el que repetimos la lógica para cada animal enumerado.
Desafío
Con el método getattr
, modifique el código anterior para llamar dinámicamente a las funciones order_*_food
y book_*_appointment
apropiadas. Podría decirse que esto hace que el código sea menos legible, pero si conoce bien Python, vale la pena pensar en cómo podría usar getattr
en lugar de la función isinstance
y simplificar el código.
Homoiconicidad: la importancia de Lisp
Algunos lenguajes de programación, como Lisp, llevan el concepto de metaprogramación a otro nivel a través de la homoiconicidad .
homoiconicidad (sust.)
La propiedad de un lenguaje de programación por la que no hay distinción entre el código y los datos sobre los que opera un programa.
Lisp, creado en 1958, es el lenguaje homoicónico más antiguo y el segundo lenguaje de programación de alto nivel más antiguo. Obteniendo su nombre de "LISt Processor", Lisp fue una revolución en la informática que moldeó profundamente la forma en que se usan y programan las computadoras. Es difícil exagerar cuán fundamental y distintivamente Lisp influyó en la programación.
Emacs está escrito en Lisp, que es el único lenguaje de programación que es hermoso. Neal Stephenson
Lisp se creó solo un año después de FORTRAN, en la era de las tarjetas perforadas y las computadoras militares que llenaban una habitación. Sin embargo, los programadores todavía usan Lisp hoy para escribir aplicaciones nuevas y modernas. El creador principal de Lisp, John McCarthy, fue un pionero en el campo de la IA. Durante muchos años, Lisp fue el lenguaje de la IA, y los investigadores valoraron la capacidad de reescribir dinámicamente su propio código. La investigación de IA de hoy se centra en redes neuronales y modelos estadísticos complejos, en lugar de ese tipo de código de generación lógica. Sin embargo, la investigación realizada sobre IA usando Lisp, especialmente la investigación realizada en los años 60 y 70 en MIT y Stanford, creó el campo tal como lo conocemos, y su enorme influencia continúa.
El advenimiento de Lisp expuso a los primeros programadores a las posibilidades computacionales prácticas de cosas como la recursividad, las funciones de orden superior y las listas enlazadas por primera vez. También demostró el poder de un lenguaje de programación basado en las ideas del cálculo lambda.
Estas nociones provocaron una explosión en el diseño de lenguajes de programación y, como dijo Edsger Dijkstra, uno de los nombres más importantes de la informática, " [...] ayudaron a varios de nuestros congéneres más talentosos a pensar cosas que antes eran imposibles".
Este ejemplo muestra un programa Lisp simple (y su equivalente en la sintaxis de Python más familiar) que define una función "factorial" que calcula recursivamente el factorial de su entrada y llama a esa función con la entrada "7":
Ceceo | Pitón |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Código como datos
A pesar de ser una de las innovaciones más impactantes y consecuentes de Lisp, la homoiconicidad, a diferencia de la recursividad y muchos otros conceptos de los que Lisp fue pionero, no llegó a la mayoría de los lenguajes de programación actuales.
La siguiente tabla compara funciones homoicónicas que devuelven código tanto en Julia como en Lisp. Julia es un lenguaje homoicónico que, en muchos aspectos, se parece a los lenguajes de alto nivel con los que puede estar familiarizado (p. ej., Python, Ruby).
La pieza clave de la sintaxis en cada ejemplo es su carácter de comillas . Julia usa :
(dos puntos) para citar, mientras que Lisp usa '
(comillas simples):
julia | Ceceo |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
En ambos ejemplos, la comilla junto a la expresión principal ( (x + 1)
o (+ x 1)
) la transforma del código que habría sido evaluado directamente en una expresión abstracta que podemos manipular. La función devuelve código, no una cadena o datos. Si tuviéramos que llamar a nuestra función y escribir print(function_that_returns_code())
, Julia imprimiría el código en forma de cadena como x+1
(y el equivalente es cierto para Lisp). Por el contrario, sin :
(o '
en Lisp), obtendríamos un error de que x
no estaba definida.
Volvamos a nuestro ejemplo de Julia y ampliémoslo:
La función eval
se puede usar para ejecutar el código que generamos desde otra parte del programa. Tenga en cuenta que el valor impreso se basa en la definición de la variable x
. Si intentáramos eval
nuestro código generado en un contexto donde x
no estaba definido, obtendríamos un error.
La homoiconicidad es un tipo poderoso de metaprogramación, capaz de desbloquear paradigmas de programación novedosos y complejos en los que los programas pueden adaptarse sobre la marcha, generando código para adaptarse a problemas específicos de dominio o nuevos formatos de datos encontrados.
Tomemos el caso de WolframAlpha, donde el Wolfram Language homoicónico puede generar código para adaptarse a una increíble variedad de problemas. Puede preguntarle a WolframAlpha: "¿Cuál es el PIB de la ciudad de Nueva York dividido por la población de Andorra?" y, notablemente, recibir una respuesta lógica.
Parece poco probable que a alguien se le ocurra incluir este cálculo oscuro y sin sentido en una base de datos, pero Wolfram usa metaprogramación y un gráfico de conocimiento ontológico para escribir código sobre la marcha para responder a esta pregunta.
Es importante comprender la flexibilidad y el poder que brindan Lisp y otros lenguajes homoicónicos. Antes de profundizar más, consideremos algunas de las opciones de metaprogramación a su disposición:
Definición | Ejemplos | notas | |
---|---|---|---|
homoiconicidad | Una característica del lenguaje en la que el código es información de "primera clase". Dado que no hay separación entre el código y los datos, los dos se pueden usar indistintamente. |
| Aquí, Lisp incluye otros lenguajes de la familia Lisp, como Scheme, Racket y Clojure. |
macros | Una declaración, función o expresión que toma código como entrada y devuelve código como salida. |
| (Consulte la siguiente nota sobre las macros de C). |
Directivas de preprocesador (o precompilador) | Un sistema que toma un programa como entrada y, basado en declaraciones incluidas en el código, devuelve una versión modificada del programa como salida. |
| Las macros de C se implementan usando el sistema de preprocesador de C, pero los dos son conceptos separados. La diferencia conceptual clave entre las macros de C (en las que usamos la directiva de preprocesador #define ) y otras formas de directivas de preprocesador de C (p. ej., #if y #ifndef ) es que usamos las macros para generar código mientras usamos otras directivas que no son #define . directivas de preprocesador para compilar condicionalmente otro código. Los dos están estrechamente relacionados en C y en algunos otros lenguajes, pero son diferentes tipos de metaprogramación. |
Reflexión | La capacidad de un programa para examinar, modificar e introspeccionar su propio código. |
| La reflexión puede ocurrir en tiempo de compilación o en tiempo de ejecución. |
Genéricos | La capacidad de escribir código que sea válido para varios tipos diferentes o que se pueda usar en múltiples contextos pero que se almacene en un solo lugar. Podemos definir los contextos en los que el código es válido de forma explícita o implícita. | Genéricos estilo plantilla:
Polimorfismo paramétrico:
| La programación genérica es un tema más amplio que la metaprogramación genérica, y la línea entre los dos no está bien definida. En opinión de este autor, un sistema de tipo paramétrico solo cuenta como metaprogramación si está en un lenguaje de tipo estático. |
Veamos algunos ejemplos prácticos de homoiconicidad, macros, directivas de preprocesador, reflexión y genéricos escritos en varios lenguajes de programación:
Las macros (como la del Snippet 11) se están volviendo populares nuevamente en una nueva generación de lenguajes de programación. Para desarrollarlos con éxito, debemos considerar un tema clave: la higiene.
Macros higiénicas y antihigiénicas
¿Qué significa que el código sea "higiénico" o "antihigiénico"? Para aclarar, veamos una macro de Rust, instanciada por macro_rules!
función. Como su nombre lo indica, macro_rules!
genera código basado en las reglas que definimos. En este caso, nombramos nuestra macro my_macro
y la regla es "Crear la línea de código let x = $n
", donde n
es nuestra entrada:
Cuando expandimos nuestra macro (ejecutando una macro para reemplazar su invocación con el código que genera), esperaríamos obtener lo siguiente:
Aparentemente, nuestra macro ha redefinido la variable x
para que sea igual a 3, por lo que podemos esperar razonablemente que el programa imprima 3
. De hecho, imprime 5
! ¿Sorprendido? En Rust, macro_rules!
es higiénico con respecto a los identificadores, por lo que no "capturaría" identificadores fuera de su alcance. En este caso, el identificador era x
. Si hubiera sido capturado por la macro, habría sido igual a 3.
higiene (sust.)
Una propiedad que garantiza que la expansión de una macro no capturará identificadores u otros estados fuera del alcance de la macro. Las macros y los macrosistemas que no proporcionan esta propiedad se denominan antihigiénicos .
La higiene en las macros es un tema algo controvertido entre los desarrolladores. Los defensores insisten en que sin higiene, es demasiado fácil modificar sutilmente el comportamiento de su código por accidente. Imagine una macro que es significativamente más compleja que Snippet 13 utilizada en código complejo con muchas variables y otros identificadores. ¿Qué pasa si esa macro usa una de las mismas variables que su código y no se da cuenta?
No es inusual que un desarrollador use una macro de una biblioteca externa sin haber leído el código fuente. Esto es especialmente común en los lenguajes más nuevos que ofrecen compatibilidad con macros (p. ej., Rust y Julia):
Esta macro antihigiénica en C captura el website
del identificador y cambia su valor. Por supuesto, la captura de identificadores no es maliciosa. Es simplemente una consecuencia accidental del uso de macros.
Entonces, las macros higiénicas son buenas y las macros antihigiénicas son malas, ¿verdad? Desafortunadamente, no es tan simple. Se puede argumentar que las macros higiénicas nos limitan. A veces, la captura de identificadores es útil. Repasemos el Fragmento 2, donde usamos pet_sdk
para proporcionar servicios para tres tipos de mascotas. Nuestro código original comenzó así:
Recordará que Snippet 3 fue un intento de condensar la lógica repetitiva de Snippet 2 en un ciclo inclusivo. Pero que tal si nuestro código depende de los identificadores cats
y dogs
, y quisiéramos escribir algo como lo siguiente:
El fragmento 16 es un poco simple, por supuesto, pero imagine un caso en el que querríamos que una macro escribiera el 100 % de una parte determinada del código. Las macros higiénicas podrían ser limitantes en tal caso.
Si bien el macro debate higiénico versus antihigiénico puede ser complejo, la buena noticia es que no es uno en el que deba tomar una postura. El idioma que está utilizando determina si sus macros serán higiénicas o antihigiénicas, así que tenga esto en cuenta cuando utilice macros.
macros modernas
Las macros están teniendo un momento ahora. Durante mucho tiempo, el enfoque de los lenguajes de programación imperativos modernos se alejó de las macros como parte central de su funcionalidad, evitándolas en favor de otros tipos de metaprogramación.
Los lenguajes que se les enseñaba a los nuevos programadores en las escuelas (por ejemplo, Python y Java) les decían que todo lo que necesitaban era reflexión y genéricos.
Con el tiempo, a medida que esos lenguajes modernos se hicieron populares, las macros se asociaron con la intimidante sintaxis del preprocesador C y C++, si es que los programadores las conocían.
Sin embargo, con la llegada de Rust y Julia, la tendencia ha vuelto a las macros. Rust y Julia son dos lenguajes modernos, accesibles y ampliamente utilizados que han redefinido y popularizado el concepto de macros con algunas ideas nuevas e innovadoras. Esto es especialmente emocionante en Julia, que parece estar a punto de tomar el lugar de Python y R como un lenguaje versátil fácil de usar y con "baterías incluidas".
Cuando vimos pet_sdk
por primera vez a través de nuestras gafas "TurboPython", lo que realmente queríamos era algo como Julia. Reescribamos Snippet 2 en Julia, usando su homoiconicidad y algunas de las otras herramientas de metaprogramación que ofrece:
Analicemos el Fragmento 17:
- Iteramos a través de tres tuplas. El primero de ellos es
("cat", :clean_litterbox)
, por lo que la variablepet
se asigna a"cat"
y la variablecare_fn
se asigna al símbolo entrecomillado:clean_litterbox
. - Usamos la función
Meta.parse
para convertir una cadena en unaExpression
, para que podamos evaluarla como código. En este caso, queremos usar el poder de la interpolación de cadenas, donde podemos poner una cadena dentro de otra, para definir qué función llamar. - Usamos la función
eval
para ejecutar el código que estamos generando.@eval begin… end
es otra forma de escribireval(...)
para evitar volver a escribir el código. Dentro del bloque@eval
hay un código que estamos generando y ejecutando dinámicamente.
El sistema de metaprogramación de Julia realmente nos libera para expresar lo que queremos de la manera que lo queremos. Podríamos haber usado varios otros enfoques, incluida la reflexión (como Python en Snippet 5). También podríamos haber escrito una función de macro que genera explícitamente el código para un animal específico, o podríamos haber generado el código completo como una cadena y utilizar Meta.parse
o cualquier combinación de esos métodos.
Más allá de Julia: otros sistemas modernos de metaprogramación
Julia es quizás uno de los ejemplos más interesantes y convincentes de un sistema macro moderno, pero no es, de ninguna manera, el único. Rust también ha sido fundamental para traer macros frente a los programadores una vez más.
En Rust, las macros aparecen de manera mucho más central que en Julia, aunque no lo exploraremos completamente aquí. Por un montón de razones, no puedes escribir Rust idiomático sin usar macros. En Julia, sin embargo, podría optar por ignorar por completo la homoiconicidad y el sistema macro.
Como consecuencia directa de esa centralidad, el ecosistema de Rust realmente ha adoptado las macros. Los miembros de la comunidad han creado algunas bibliotecas, pruebas de concepto y funciones increíblemente geniales con macros, incluidas herramientas que pueden serializar y deserializar datos, generar automáticamente SQL o incluso convertir anotaciones dejadas en el código a otro lenguaje de programación, todo generado en código en tiempo de compilación.
Si bien la metaprogramación de Julia puede ser más expresiva y libre, Rust es probablemente el mejor ejemplo de un lenguaje moderno que eleva la metaprogramación, ya que se presenta en gran medida en todo el lenguaje.
Un ojo al futuro
Ahora es un momento increíble para interesarse en los lenguajes de programación. Hoy, puedo escribir una aplicación en C++ y ejecutarla en un navegador web o escribir una aplicación en JavaScript para ejecutarla en una computadora de escritorio o teléfono. Las barreras de entrada nunca han sido más bajas, y los nuevos programadores tienen información al alcance de la mano como nunca antes.
En este mundo de elección y libertad del programador, tenemos cada vez más el privilegio de utilizar lenguajes ricos y modernos, que seleccionan características y conceptos de la historia de la informática y lenguajes de programación anteriores. Es emocionante ver las macros recogidas y desempolvadas en esta ola de desarrollo. No puedo esperar a ver qué harán los desarrolladores de una nueva generación cuando Rust y Julia les presenten las macros. Recuerde, "código como datos" es más que un eslogan. Es una ideología central a tener en cuenta cuando se habla de metaprogramación en cualquier comunidad en línea o entorno académico.
'Codificar como datos' es más que un eslogan.
Los 64 años de historia de la metaprogramación han sido parte integral del desarrollo de la programación tal como la conocemos hoy. Si bien las innovaciones y la historia que exploramos son solo una esquina de la saga de la metaprogramación, ilustran el sólido poder y la utilidad de la metaprogramación moderna.