LISP y familia
LISP es el segundo lenguaje de programación más antiguo en activo. Como precursor de los lenguajes funcionales no ha pasado de moda. Me han preguntado por él y su familia y yo he contestado «muy bien. ¡Gracias!». Pero, hay muchos LISP, innumerables. En su día hubo tantos y tan diversos que tuvieron que crear la especificación commond lisp. Además es un lenguaje que ha inspirado otros muchos (a todos, o la mayoría de, los funcionales) y tiene también descendientes, de los que Scheme y Clojure son miembros destacados. Si hoy traigo este artículo es porque me preguntaron sobre LISP, sobre Scheme, sobre las similitudes, las diferencias y sobre la utilidad de aprenderlo o no.
Si eres usuario de Emacs tienes LISP muy cerca. Seguro que ya has tenido que meterte en el fichero de configuración a cambiar cosas. Quizá te haya asustado la gran cantidad de paréntesis que se cierran al final de algunas formas. Veamos un ejemplo1:
(defun notxor-blog-get-tags (post-filename) "Extract the `#+filetags:` from POST-FILENAME as list of strings." (let ((case-fold-search t)) (with-temp-buffer (insert-file-contents post-filename) (goto-char (point-min)) (if (search-forward-regexp "^\\#\\+filetags:[ ]*:\\(.*\\):$" nil t) (split-string (match-string 1) ":") (if (search-forward-regexp "^\\#\\+filetags:[ ]*\\(.+\\)$" nil t) (split-string (match-string 1)))))))
Si vemos el final se juntan 7 paréntesis de cierre y hay gente que puede perderse con tantos. Si en lugar de ser paréntesis fueran llaves, como ocurre muchas veces en otros lenguajes, la queja puede ser menor. Normalmente, en esos lenguajes se los ordena de la siguiente manera:
(defun notxor-blog-get-tags (post-filename) "Extract the `#+filetags:` from POST-FILENAME as list of strings." (let ((case-fold-search t)) (with-temp-buffer (insert-file-contents post-filename) (goto-char (point-min)) (if (search-forward-regexp "^\\#\\+filetags:[ ]*:\\(.*\\):$" nil t) (split-string (match-string 1) ":") (if (search-forward-regexp "^\\#\\+filetags:[ ]*\\(.+\\)$" nil t) (split-string (match-string 1) ) ) ) ) ) )
Este formato también es correcto sintácticamente, sin embargo, añade unas cuantas líneas prácticamente vacías al final de la definición que, por lo menos a quienes usamos estos lenguajes habitualmente, nos resultan antiestéticas. Si eres programador y te pagan por líneas de código producido, o al peso, olvídate de la estética.
En la última parte del artículo muestro algunos ejemplos de código equivalente, o que hace lo mismo, si lo quieres expresar así, con el objetivo de mostrar algunos aspectos que me parecen remarcables para hablar de las similitudes y diferencias de los lenguajes herederos de LISP.
Si me preguntas por qué creo que LISP y los lenguajes que se han derivado de él, son tan potentes, mi respuesta es por las macros. Ya he hablado en este blog sobre macros de LISP y no voy a darle muchas más vueltas. Una definición corta de macro podría ser que es una expresión que se transforma en código LISP. Quizá se vea más claro con un ejemplo simple, como hacer una instrucción que incremente variables:
(defmacro inc (var) (list 'setq var (list '+ 1 var))) (defvar prueba-variable 2) (inc prueba-variable) ;; => 3 (inc 2) ;; => ERROR
Esa expresión del macro se transformará en tiempo de compilación en la siguiente:
(setq prueba-variable (+ 1 prueba-variable))
En el segundo caso dará error pues se transformará en:
(setq 2 (+ 1 2))
Los Scheme suelen utilizar la expresión define-sintax
en lugar de
la defmacro
, pero el objetivo es similar y el nombre en Scheme es
toda una declaración de intenciones.
La potencia de esto es que los sistemas basados en LISP, como Emacs o AutoCAD, terminan convirtiéndose en un LISP disfrazado de otra cosa, como un editor de texto o un editor gráfico. Lo mismo ocurre los sistemas que se basen en Scheme. Generando su propia sintaxis a través de las macros se pueden crear lenguajes específicos para un ámbito de problemas. Hace, por ejemplo, que se puedan tener librerías como CLOS2 que transforma un lenguaje funcional permitiendo el soporte para POO.
Funcionamiento básico
El lenguaje LISP es uno de esos lenguajes cuya sintaxis la puedes
aprender en 10 minutos y tardar una vida en comprender todas sus
implicaciones. Básicamente todo se basa en las listas, una lista es
lo que haya dentro de unos paréntesis, desde una lista vacía '()
a
cualquier otro largo. La maquinaria de LISP, por defecto, intenta
ejecutar el primer elemento tomando como argumentos el resto de
elementos. Ese simple hecho hace que, en estos lenguajes, los datos
sean código y el código sea datos.
Como también hay veces en que no quieres que se ejecute el primer
elemento, porque estás trabajando con una lista de datos pura, se
utiliza la función quote
para entrecomillar la función:
(quote (1 2 3 4)) ;; es equivalente a '(1 2 3 4)
Decidir cuándo utilizar el apóstrofe o no depende de si quieres que la liste se ejecute o se interprete como datos puros. Si quieres que algún elemento de una lista de datos, sea interpretado, se utiliza la coma. Por ejemplo, en Scheme:
'(1 2 ,(- 5 2) 4) ;; '(1 2 (unquote (- 5 2)) 4)
Generalmente, el unquote
se utiliza dentro de la definición de
macros.
Otro aspecto importante, que lo diferencia de otros lenguajes es que las operaciones matemáticas son también como cualquier otra función. En este caso no existen los operadores, son funciones y como tales se utilizan como cualquier función. La precedencia de operadores no tiene sentido, pues como listas que son se determina el orden operando desde la más interna a la más externa, como en cualquier grupo de listas anidadas.
Hace un tiempo hice un minicurso de emacs-lisp
que puedes encontrar
en este mismo blog. Allí, en los primeros artículos hablo de las cosas
básicas con más detenimiento. Aunque está enfocado al uso específico
en Emacs, es aplicable, en gran medida, a otros sabores de LISP.
Similitudes y diferencias
Obviadas las macros en el código, puesto que estaríamos en un nivel más profundo de lo que serían estos lenguajes. El resto de comparaciones están relacionadas con la maquinaria que hay debajo.
Una diferencia importante entre LISP y Scheme es que el primero
tiene dos espacios de nombres independientes para variables y
funciones y Scheme sólo dispone de uno para todo. Eso hace que
LISP utilice defvar
para definir variables y defun
para definir
funciones, mientras que Scheme haga ambas cosas con define
.
También en este artículo se menciona Clojure como lenguaje heredero
de LISP. También cuenta con diferentes espacios de nombres para
variables y funciones. En su caso se utilizan las funciones def
para
variables y defn
para funciones. En su caso, está utilizando la
máquina virtual de Java, lo que le proporciona otra serie de
herramientas y matices que explota el lenguaje. Se podría hablar casi
de un híbrido, pues se trabaja con la JVM con una sintaxis similar a
la de LISP. Tiene otras funcionalidades, entre la que puede ser
utilizado para exportar a Javascript para la programación web.
Pero tampoco voy a meterme en más explicaciones sobre Clojure.
Además de estas diferencias, nos encontramos con algunas herramientas
que distinguen entre mayúsculas y minúsculas y otras que no. Veamos un
ejemplo en emacs-lisp
:
(defvar Estado 0) (defvar estado 2) Estado ;; => 0 estado ;; => 2
En algunos otros LISP se produciría un error porque ambas variables
se convertirían internamente en ESTADO
y por tanto chocarían en el
espacio de nombres.
Variables
Las variables tanto en LISP como en Scheme pueden modificarse
utilizando la función de asignación set
en LISP y la función
set!
en Scheme. Sin embargo, en Clojure, por defecto, las
variables no pueden modificarse, a no ser que se especifique en el
código con ^dynamic
y por convención se encierra el nombre mediante
asteriscos para que quien lea el código sepa directamente que esa
variable puede cambiar su valor a lo largo del tiempo de ejecución.
Funciones
Como dije antes las funciones son listas. La diferencia es que entre los Scheme y los LISP se definen en un espacio de nombres general o en uno específico. La consecuencia directa es que en LISP puedes encontrar que bajo una misma denominación se encuentra una función pero también puede haber una variable.
Recuerda, siempre se intenta ejecutar el primer elemento de la lista utilizando el resto de elementos como parámetros. No hay más secreto. Llamar a un función es colocar su nombre como primer elemento de una lista.
Compilación y REPL
La mayoría de los LISP y derivados pueden compilarse, bien para
generar binarios del sistema, o bien para generar bytecode
intermedio, también pueden utilizarse desde scripts interpretados o,
por último, desde su propio prompt REPL. Sin embargo, algunos de
éstos están más pensados para ser compilados, otros para ser más
interactivos y otros para ser más de automatización por
scripts. Aunque más adelante muestro un pequeño estudio de
velocidades no debes quedarte con el dato puro. Todas las pruebas se
han realizado utilizando las herramientas para script de cada una de
ellas. Evidentemente, en esas pruebas tienen ventaja los lenguajes
diseñados para ello. Otros como Clojure tienen el problema añadido
de que deben cargar la máquina de Java cuando funciona para
script, en una aplicación en funcionamiento con la máquina de Java
ya cargada, será tan veloz como lo sería Java y por tanto, su
rendimiento es mucho mejor cuando se compila a jar
. Commond LISP
está pensado para aplicaciones más pesadas y ser compilado. Los
Scheme son más de script, aunque en el caso de Racket también
tenga su compilador: raco
. En fin, que tienes múltiples opciones de
generar tus aplicaciones y, según para qué las quieras, te puedes
decantar por una herramienta u otra.
Funciones lambda
En la mayoría de casos la forma de generar una función sin nombre es
utilizar la forma (lambda ...)
, salvo en Clojure que se utiliza la
forma (fn ...)
. En el caso de los Scheme, podemos observar que las
dos siguientes formas son equivalentes y la primera es un poco de
azúcar sintáctico para la segunda:
(define (mul x y) (* x y)) (mul 2 3) ;; => 6 (define mul2 (lambda (x y) (* x y))) (mul 2 3) ;; => 6
Resultados
Por otro lado, los resultados de los scripts son similares pero no
idénticos en todos los casos. Si corres el código de cada ejemplo,
verás que la mayoría de ellos da un resultado preciso de las
operaciones en el formato numerador/denominador
, mientras que
Emacs da el resultado numérico decimal.
En las pruebas realizadas se calcularon 5 ensayos con cada herramienta y en la siguiente tabla encontramos los resultados medios de cada uno de los tiempos de ejecución.
Lenguaje | real | user | sys |
---|---|---|---|
emacs lisp | 0.224 | 0.180 | 0.041 |
commond lisp SBCL | 0.396 | 0.378 | 0.019 |
scheme guile | 0.291 | 0.667 | 0.039 |
scheme racket | 0.257 | 0.222 | 0.036 |
clojure | 0.861 | 0.873 | 0.117 |
La ejecución de cada uno de los scripts se realizó con los siguientes comandos:
Emacs LISP:
> emacs --script emacs.el 4999950000
Commond LISP SBCL:
> sbcl --script sbcl.lisp 5688945777431137355938334570000/1137800533491555986667
Guile:
> guile scheme.scm 5689059557484486511537001270000/1137800533491555986667
Racket:
> racket racket.rkt 5689059557484486511537001270000/1137800533491555986667
Clojure:
clojure -M clojure.clj -5689002665857759799939/5689002667457779933335
No tengo muy claro a qué se debe la discrepancia en el resultado en este caso. Estoy convencido de que tiene que ver con la forma de declarar variables y gestionarlas. El cambio en el código más importante es que sólo es válido el valor de una variable dinámica dentro del bloque
binding
, por eso elprint
se ha desplazado a la función principal. Seguramente, además, el error será mío y necesitará seguramente otro acumulador intermedio, pero en el intento de mantener el código lo más parecido entre unos y otros chismáticos, ha hecho que a Clojure se le atragante el código que se hizo primero paraemacs-lisp
.
Conclusiones
En fin, espero que esto te sirva para apreciar las diferencias y semejanzas de los lenguajes de la familia de LISP. Siendo una comparativa rápida, ya puedes hacerte una idea de lo básico.
En cuanto a la utilidad no sabría decir. Desde luego que yo le encuentro utilidad, tengo a estas alturas mucho código metido en mi Emacs para hacer mis cosas y todas las semanas me encuentro metiendo más código o reformando el que ya tengo. Pero bueno, ¿de qué te sirve un destornillador si nunca pones o quitas tornillos?
Código de pruebas
emacs-lisp
Código
(defun ochok (k) "Define una función que devuelve el valor de multiplicar por 8 K." (* 8 k)) (defvar calculo-total 0 "Define una variable.") (defun termino (k) "Realiza algunos cálculos con K." (let ((n (ochok k))) (- (/ 4 (+ n 1)) (/ 2 (+ n 4)) (/ 1 (+ n 5)) (/ 1 (+ n 6))))) (defun f-lambda (cuenta) "Muestra como se usa una función lambda y se la aplica CUENTA." ((lambda (x y) (* x y )) (termino cuenta) 12)) (defun calcular (veces) "Repite un número de VECES las mismas operaciones." (dotimes (n veces) (setq calculo-total (+ n calculo-total (f-lambda veces))))) (calcular 100000) (print calculo-total)
Tiempos
Ensayo | real | user | sys |
---|---|---|---|
1 | 0.223 | 0.183 | 0.037 |
2 | 0.223 | 0.171 | 0.049 |
3 | 0.222 | 0.167 | 0.052 |
4 | 0.228 | 0.201 | 0.026 |
5 | 0.225 | 0.180 | 0.042 |
Media | 0.224 | 0.180 | 0.041 |
Commond Lisp - SBCL
Código
(defun ochok (k) "Define una función que devuelve el valor de multiplicar por 8 K." (* 8 k)) (defvar calculo-total 0 "Define una variable.") (defun termino (k) "Realiza algunos cálculos con K." (let ((n (ochok k))) (- (/ 4 (+ n 1)) (/ 2 (+ n 4)) (/ 1 (+ n 5)) (/ 1 (+ n 6))))) (defun f-lambda (cuenta) "Muestra como se usa una función lambda y se la aplica CUENTA." ((lambda (x y) (* x y )) (termino cuenta) 12)) (defun calcular (veces) "Repite un número de VECES las mismas operaciones." (dotimes (n veces) (setq calculo-total (+ n calculo-total (f-lambda veces))))) (calcular 100000) (print calculo-total)
Tiempos
Ensayo | real | user | sys |
---|---|---|---|
1 | 0.396 | 0.372 | 0.024 |
2 | 0.399 | 0.382 | 0.017 |
3 | 0.396 | 0.379 | 0.017 |
4 | 0.395 | 0.371 | 0.024 |
5 | 0.396 | 0.384 | 0.012 |
Media | 0.396 | 0.378 | 0.019 |
Scheme guile
Código
(define (ochok k) "Define una función que devuelve el valor de multiplicar por 8 K." (* 8 k)) ;; Define una variable global (define calculo-total 0 "Una variable global para acumular el resultado.") (define (termino k) "Realiza algunos cálculos con K." (let ((n (ochok k))) (- (/ 4 (+ n 1)) (/ 2 (+ n 4)) (/ 1 (+ n 5)) (/ 1 (+ n 6))))) (define (f-lambda cuenta) "Muestra como se usa una función lambda y se la aplica CUENTA." ((lambda (x y) (* x y )) (termino cuenta) 12)) (define (calcular veces) "Repite un número de VECES las mismas operaciones." (let loop ((n veces)) (unless (zero? n) (set! calculo-total (+ n calculo-total (f-lambda veces))) (loop (- n 1))))) (calcular 100000) (display calculo-total)
Tiempos
Ensayo | real | user | sys |
---|---|---|---|
1 | 0.293 | 0.720 | 0.009 |
2 | 0.296 | 0.663 | 0.047 |
3 | 0.281 | 0.632 | 0.064 |
4 | 0.297 | 0.648 | 0.049 |
5 | 0.286 | 0.673 | 0.027 |
Media | 0.291 | 0.667 | 0.039 |
Scheme racket
Código
#lang racket/base (define (ochok k) "Define una función que devuelve el valor de multiplicar por 8 K." (* 8 k)) ;; Define una variable global (define calculo-total 0) (define (termino k) "Realiza algunos cálculos con K." (let ((n (ochok k))) (- (/ 4 (+ n 1)) (/ 2 (+ n 4)) (/ 1 (+ n 5)) (/ 1 (+ n 6))))) (define (f-lambda cuenta) "Muestra como se usa una función lambda y se la aplica CUENTA." ((lambda (x y) (* x y )) (termino cuenta) 12)) (define (calcular veces) "Repite un número de VECES las mismas operaciones." (let loop ((n veces)) (unless (zero? n) (set! calculo-total (+ n calculo-total (f-lambda veces))) (loop (- n 1))))) (calcular 100000) (display calculo-total)
Tiempos racket
Ensayo | real | user | sys |
---|---|---|---|
1 | 0.263 | 0.243 | 0.021 |
2 | 0.250 | 0.202 | 0.048 |
3 | 0.254 | 0.214 | 0.041 |
4 | 0.262 | 0.226 | 0.036 |
5 | 0.256 | 0.223 | 0.032 |
Media | 0.257 | 0.222 | 0.036 |
Clojure
Código
(defn ochok [k] "Define una función que devuelve el valor de multiplicar por 8 K." (* 8 k)) ;; Define una variable global y dinámica (def ^:dynamic *calculo-total* 0) (defn termino [k] "Realiza algunos cálculos con K." (let [n (ochok k)] (- (/ 4 (+ n 1)) (/ 2 (+ n 4)) (/ 1 (+ n 5)) (/ 1 (+ n 6))))) (defn f-lambda [cuenta] "Muestra como se usa una función lambda y se la aplica CUENTA." ((fn [x y] (* x y )) (termino cuenta) 12)) (defn calcular [veces] "Repite un número de VECES las mismas operaciones." (binding [*calculo-total* 0] (loop [n veces] (if (< n 0) (set! *calculo-total* (+ n *calculo-total* (f-lambda veces))) (recur (- n 1)))) (print *calculo-total*))) (calcular 100000)
Tiempos
Ensayo | real | user | sys |
---|---|---|---|
1 | 0.881 | 2.122 | 0.096 |
2 | 0.851 | 2.072 | 0.136 |
3 | 0.870 | 0.052 | 0.111 |
4 | 0.848 | 0.058 | 0.131 |
5 | 0.853 | 0.063 | 0.113 |
Media | 0.861 | 0.873 | 0.117 |
Comentarios