Notxor tiene un blog

Defenestrando la vida

LISP y familia

Notxor
2023-01-19

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:

  1. Emacs LISP:

    > emacs --script emacs.el
    
    4999950000
    
  2. Commond LISP SBCL:

    > sbcl --script sbcl.lisp
    
    5688945777431137355938334570000/1137800533491555986667 
    
  3. Guile:

    > guile scheme.scm
    5689059557484486511537001270000/1137800533491555986667
    
  4. Racket:

    > racket racket.rkt
    5689059557484486511537001270000/1137800533491555986667
    
  5. 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 el print 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 para emacs-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

Footnotes:

1

El ejemplo está sacado de una función del código que mantiene este sitio estático.

2

Common Lisp Object System es una librería estándar de LISP que permite la Programación Orientada a Objetos.

Categoría: lisp elisp emacs

Comentarios

Debido a algunos ataques mailintencionados a través de la herramienta de comentarios, he decidido no proporcionar dicha opción en el Blog. Si alguien quiere comentar algo, me puede encontrar en Mastodon y en Diaspora con el nick de Notxor.

También se ha abierto un grupo en Telegram (enlace de invitación al grupo), para usuarios de esa red.

Disculpen las molestias.