Notxor tiene un blog

Defenestrando la vida

Nim, un lenguaje compilado

Notxor
2023-02-28

Hace un tiempo ya escribí en este blog mi tendencia y gusto por los lenguajes interpretados. Suele ser así, pero siempre hace falta tener a mano algún lenguaje de esos compilados que te saquen de un apuro. Y porque en la caja de herramientas no sólo debe haber llaves inglesas, también son necesarias otras, con sus ventajas e inconvenientes, porque lamentablemente, después de todos estos años, no se ha inventado aún el lenguaje definitivo. Aunque sí veo a legiones de fans de tal o cual lenguaje comportarse como si el suyo lo fuera. Ahora hay que hacerlo todo en [......]1. Me estoy yendo por las ramas, en resumen, vamos a lo que importa: llevo unos días, semanas,usando el lenguaje Nim y vengo a contártelo. Preparamos Emacs para utilizarlo y comenzamos.

Antes de nada, ¿qué es Nim?: Pues es un lenguaje de programación con características muy apetecibles, según su web:

¿Es todo bueno? No..., bueno no sé. Me he encontrado raro, hay cosas que no terminan de encajarme. Quizá soy muy cuadriculado o quizá necesito más tiempo para acostumbrarme. Pero antes de entrar en detalles, vamos con procurarnos el entorno de trabajo.

Trabajar en Emacs

Sí, como era de esperar hay paquete para trabajar con Nim en Emacs, se llama nim-mode... vamos por lo fácil: el código.

(use-package nim-mode            ; Modo mayor para NIM
  :defer t)
(use-package flycheck-nim        ; Comprobación del código al vuelo
  :defer t)
(use-package flycheck-nimsuggest ; Comprobación de código utilizando nimsuggest
  :defer t)
(use-package ob-nim              ; Paquete para ~org-babel~
  :defer t)
(add-hook 'nim-mode-hook 'nimsuggest-mode)

Nada complicado de entender, simplemente tener los paquetes en el sistema y añadir un hook para que active el modo nimsuggest-mode cuando se entre en un archivo nim. nimsuggest es una aplicación que viene con el propio Nim y que, como su nombre indica, hace sugerencias al código, tanto para el autocompletado como para la corrección de errores. Los paquetes de Emacs se sirven de él para realizar su trabajo. Sin embargo, en la misma documentación dicen que estos paquetes están en versión alfa y es posible que generen errores al utilizarlos. Yo, de momento, no me he encontrado mayor problema, pero es posible que con otra instalación sí los dé. Si notáis que se enlentece la respuesta de Emacs o se comporta de manera extraña al abrir archivos de Nim, pues tendréis ese bug. De todas formas es buena idea ir guardando el trabajo cada poco, no vayas a hacer tres módulos y 40 funciones, se te congele el editor y pierdas el trabajo de todo el día.

Intenté también instalar lsp pero me da un error al compilar el nimlsp y no he podido solucionarlo. Luego contaré cómo funciona el gestor de paquetes de Nim, que es similar a los de otros lenguajes.

Las teclas que más he utilizado han sido:

  • M-.: Para saltar a la definición de algún elemento (procedimiento, variable, tipo...).
  • M-,: Para regresar al punto desde el que saltaste con la tecla anterior.
  • C-M-i: Llamar a company-nimsuggest para el autocompletado. Normalmente lo hace de manera automática.
  • C-c C-c: compila y ejecuta un buffer de Nim.
  • C-c C-d: llama a nimsuggest-show-doc, al hacerlo sobre un módulo de la librería general, abre un buffer de nombre *nim-doc* con la documentación de dicha librería.

Otro de los servicios que proporciona nim-mode es una pequeña ayuda o documentación corta que muestra en el minibuffer cuando situamos el cursor sobre algún símbolo o llamada.

Pequeña introducción a Nim

Primero un clásico hola mundo algo más complejo que el habitual:

import system
const                # Más sobre variables y constantes más adelante
  a: int = 11
  b: int = 4
  c: float = 6.75
  d: float = 2.25

echo "a = ", a
echo "b = ", b
echo "--------------------"
echo "a + b = ", a + b
echo "a - b = ", a - b
echo "a * b = ", a * b
echo "a / b = ", a / b
echo "a div b = ", a div b
echo "a mod b = ", a mod b
echo "--------------------"
echo "c + d = ", c + d
echo "c - d = ", c - d
echo "c * d = ", c * d
echo "c / d = ", c / d
echo "--------------------"
echo "Hola Mundo!"
echo "--------------------"
echo "Eĥoŝanĝo ĉiuĵaŭde"

Apreciaciones sobre el código anterior:

  1. No hay una función main explícita. Ese código se está comportando como si fuera un lenguaje de script, pero es compilado.
  2. El indentado de los bloques de código, muy al estilo Python. Se puede apreciar en el bloque de definición de constantes.
  3. Se declaran las variables con sus tipos. Se puede dejar al compilador que suponga qué tipo es, pero algunas veces por el valor no es suficiente. Por ejemplo:

    let
      enteroPequeño: int8 = 4
      enteroGrande: int64 = 4
      sinSignoPeque: uint8 = 4
      sinSignoGrande: uint64 = 4
    

    Esas cuatro variables, aunque todas contengan \(4\), son de diferentes tipos.

  4. Los comentarios comienzan con el símbolo #. Además se pueden añadir también comentarios que la herramienta de construcción de documentación extraerá del código con ##. También se pueden realizar comentarios de múltiples líneas encerrándolas entre dos marcas #[ y ]#, o su equivalente para añadir documentación ##[ y ]##.
  5. La importación de módulos se hace con import y el nombre del módulo. En el ejemplo se podría haber obviado, pues importa un módulo que ya está importado por defecto. Todo constructo público que se encuentre en el módulo será importado directamente.

La compilación y ejecución es sencilla, en el buffer de edición pulsa C-c C-c2. Si eres más de línea de comandos o esperas algún error, lo mismo prefieres utilizar la consola de texto:

$ nim c -r holamundo.nim
Hint: used config file '/etc/nim/nim.cfg' [Conf]
Hint: used config file '/etc/nim/config.nims' [Conf]
.........................................................
Hint:  [Link]
Hint: gc: refc; opt: none (DEBUG BUILD, `-d:release` generates faster code)
26666 lines; 0.221s; 31.68MiB peakmem; proj: /home/notxor/proyectos/nim-pruebas/holamundo.nim; out: /home/notxor/proyectos/nim-pruebas/holamundo [SuccessX]
Hint: /home/notxor/proyectos/nim-pruebas/holamundo  [Exec]
a = 11
b = 4
--------------------
a + b = 15
a - b = 7
a * b = 44
a / b = 2.75
a div b = 2
a mod b = 3
--------------------
c + d = 9.0
c - d = 4.5
c * d = 15.1875
c / d = 3.0
--------------------
Hola Mundo!
--------------------
Eĥoŝanĝo ĉiuĵaŭde

Declaración de variables y constantes

La definición de variables y contantes cuenta con tres palabras clave:

  • const: Establece valores que no pueden variar durante la ejecución del programa. El valor debe ser conocido o se debe poder establecer en tiempo de compilación.
  • let: Establece valores que no pueden variar durante la ejecución del programa. No es necesario conocer el valor al compilar y se puede calcular en tiempo de ejecución, pero una vez establecido el valor, este no puede variar.
  • var: Define una variable que admite cambios durante la ejecución de nuestro programa.

Por ver cómo funcionan estos tres tipos de definiciones veamos un ejemplo:

import std/unicode
import std/sequtils
import std/base64

const
  cadena: string = "Eĥoŝanĝo ĉiuĵaŭde"

proc codificar(): string
proc decodificar(): string

let
  encoded: string = codificar()
  decoded: string = decodificar()

var
  runas: seq[Rune]

proc codificar(): string =
  result = encode(cadena)

proc decodificar(): string =
  return decode(encoded)

proc convertirRunas(c: string): seq[Rune] =
  return toRunes(c)

runas = convertirRunas(cadena)

echo cadena
echo runas    # un seq[Rune] y se muestra exactamente como la string

echo("Codificado: ", encoded)
echo("Decodificado: ", decoded)

Vemos que cadena está definida como constante. Luego, entre el bloque const y el bloque let, se encuentran las cabeceras de dos procedimientos que se definen más tarde. Deben estar adelantadas porque las utilizamos en el bloque let antes de que el compilador sepa de su existencia. ¿Podría haber evitado estas llamadas? Por supuesto, pero así no aprendemos :-þ

En el bloque var se define un tipo un tanto raro seq[Rune], que es una secuencia de Rune. Un Rune es un tipo definido en el módulo unicode, que en resumen es un int16 que representa un carácter unicode. Como se puede ver en el ejemplo se puede utilizar de manera similar a una cadena.

Si observas la definición del procedimiento codificar verás que en lugar de devolver el valor con return se utiliza una asignación a result. Ésta es una variable automágica que está presente en todos los procedimientos y que se puede utilizar dentro del cuerpo del mismo para componer una respuesta válida. Es decir, podemos hacer que un procedimiento devuelva un valor sin un return específico.

Tipos complejos y referencias

También puedes utilizar punteros (o referencias) a las variables utilizando la palabra clave ref. Vamos a poner un ejemplo con un tipo compuesto.

type
  Persona = ref object
    nombre: string
    edad: int

var
  fulanito: Persona

# El orden de los campos se puede cambiar
fulanito = Persona(edad: 37, nombre: "Fulanito de tal")

echo "Nombre: ", fulanito.nombre
echo "Edad: ", $fulanito.edad

fulanito.edad += 3
doAssert fulanito.edad == 40

# Conversión a cadena y concatenación
echo "Edad cambiada: " & $fulanito.edad

proc ponNombre(p: Persona, nombre: string) =
  p.nombre = nombre

fulanito.ponNombre("Pepe")
echo "Ahora se llama: ", fulanito.nombre

var
  menganito: Persona

new(menganito)
ponNombre(menganito, "Menganito")
doAssert menganito.nombre == "Menganito"
echo menganito[]

Se puede observar que en el bloque type se define una referencia a objeto Persona. Pongo objeto en cursiva porque no es en realidad un objeto como se entiende para la programación orientada a objetos. Está más cercano a lo que sería una struct en C/C++. Después definimos un procedimiento que toma como argumentos un tipo Persona y una cadena. Puesto que Persona es en realidad una referencia, el argumento se puede modificar dentro del procedimiento. Algo que no termina de gustarme, pero que es posible hacer para acercar un poco la POO a través de azúcar sintáctico:

fulanito.ponNombre("Pepe")

Pero el procedimiento está definido, aparentemente para:

ponNombre(fulanito, "Pepe")

Y es cierto, la definición es la segunda, pero el lenguaje nos proporciona un poco de azúcar sintáctico para poder escribirlo de la primera forma. Hay que fijarse que es sólo azúcar, porque en realidad, la función no está dentro del tipo. Para que lo estuviera debería tener un campo de tipo proc. También se podría haber solicitado espacio en memoria para la variable con new o manejando las funciones internas alloc, dealloc, o realloc, que les sonarán a los programadores de C y que están definidas en el módulo system.

La última línea de código:

echo menganito[]

Puede llamar la atención. Lo que hace la notación [] es desreferenciar un objeto.

Aunque no sea 100% un lenguaje orientado a objetos, también nos permite utilizar un poco de herencia entre los tipos:

type
  Persona = ref object of RootObj
    nombre*: string
    edad: int

  Estudiante = ref object of Persona # Student inherits from Person
    notas*: seq[float]

var
  jaimito: Estudiante
  persona: Persona

assert(jaimito of Estudiante)

jaimito = Estudiante(nombre: "Jaimito", edad: 7, notas: @[2.3, 0.1])
echo jaimito[]

Un vistazo general a otras características

El siguiente bloque de código presenta por encima algunas otras características del lenguaje, cómo hacer referencias, sobrecarga de funciones, derreferenciar variables, construir operadores, comprobar tipos, utilización de pragma... un bloque completito para lo corto que es:

type
  Humano = object
    nombre: string
    edad: int

var
  juanito, zutanito: ref Humano

var
  ejemplo = Humano(nombre: "Pepito", edad: 30)

# Procedimiento para mostrar información. Sólo lo hará si la variabl
# `debug` declarada con anterioridad está a `true`. Utiliza el pragma
# `inline` para decirle al compilador que declare y compile la función
# de esa manera
var debug = false
proc log(mensaje: string) {.inline.} =
  if debug: stdout.writeLine(mensaje) # no sólo de echo vive el Hombre

# Crea una referencia a un objeto Humano a partir de datos
proc crearHumano(nombre: string, edad: int): ref Humano =
  result = new(Humano)
  result.nombre = nombre
  result.edad = edad

# Crea una referencia a un objeto Humano a partir de un objeto
proc crearHumano(quien: Humano): ref Humano =
  result = new(Humano)
  result.nombre = quien.nombre
  result.edad = quien.edad

log("Esto no debería mostrarse por pantalla.")

juanito = crearHumano("Juanito", 25)
zutanito = crearHumano(ejemplo)

proc `$`(quien: ref Humano): string =
  "Referencia a: (nombre: " & quien.nombre & ", edad: " & $quien.edad & ")"

when isMainModule:
  debug = true    # Activamos la información de salida con el procedimiento `log`

  echo ejemplo  # Humano de ejemplo para copiarlo en una referencia

  if zutanito is Humano:
    log("Zutanito tiene los valores: " & $zutanito)

  if juanito is ref Humano:
    log("Juanito tiene los valores: " & $juanito)

  # Derreferenciar los Humanos para mostrarlos o asignarlos
  echo juanito[]
  echo zutanito[]
  # Nombres de variables camelCase y snake_case intercambiables
  var otraVezJuanito: Humano = juanito[]
  echo otra_vez_juanito

Para construir operadores se utiliza la sintaxis `operador`. En Nim para convertir una variable en una cadena se utiliza el operador $ y en el código lo define para el tipo ref Humano. A partir de ahí se puede utilizar con cualquier objeto de ese tipo para ver su contenido. Se puede observar que la comprobación de tipos se realiza con el operador is.

Los pragma proporcionan información adicional al compilador. Hay muchos definidos y se pueden definir más. Tienen la estructura {.nombre.}, en el código anterior se utiliza {.inline.} para decirle al compilador que esa función debería compilarse como una función inline de C.

Por último, una característica extraña es que los nombres de las variables son intercambiables entre camelCase3 y snake_case de manera que puedes escribirlas como mejor te parezca. Lo cual no deja de ser sorprendente y extraño. Algo a lo que te tienes que acostumbrar pero que si tienes un estilo definido y trabajas solo, puedes obviar.

Metaprogramación

La amplitud que proporciona un artículo, de tamaño razonable, no permite extendernos mucho sobre la metaprogramación, que se realiza fundamentalmente de tres maneras: generics, templates y macros.

Las funciones genéricas son funciones que se escriben para ser utilizadas con distintos tipos de datos. Se suele utilizar el tipo genérico T para identificarlos. Por ejemplo:

proc loQueSea[T](i: T): string =
  "El valor de i es: " & $i

echo loQueSea(42)

Los templates lo que hacen es crear plantillas. Lo que hace el ejemplo anterior sería una template pero utilizando también tipos genéricos. Por poner un ejemplo sencillo sin utilizar tipos genéricos podríamos definir un operador, tal que:

template `!=`(a, b: untyped): untyped =
  not (a == b)

assert(5 != 6) # reescribe el código a assert(not (5 == 6))

Y por último los macros son capaces de generar código al estilo de como funcionan los macros de Lisp. En el caso de Lisp el código generado era una cadena que se pueda pasar al intérprete del lenguaje, en el caso de Nim debe generarse un árbol sintáctico. Por ejemplo, a efectos de ver cómo es un árbol sintáctico y cómo se puede representar, copio aquí un ejemplo sencillo tomado del tutorial de la página del lenguaje:

import std/macros

macro myAssert(arg: untyped): untyped =
  echo arg.treeREpr

let a = 1
let b = 2

myAssert(a != b)

Los macros pueden recibir como argumentos bloques de código para hacerlos aún más potentes. Pero toda potencia implica también un poco de responsabilidad y si cualquier funcionalidad puede ser implementada a través de templates o generics, es mejor utilizas éstas y evitar el uso de macros en el código.

Por último, aunque los macros pueden iniciar procesos externos en la shell, no pueden llamar a funciones de C, excepto a las que ya contiene el compilador.

Conclusiones

Nim me ha parecido un lenguaje lleno de posibilidades. Encuentro extrañas algunas decisiones de diseño, como el asunto de la equivalencia de nombres de variables y funciones, entre el camel case y el snake case y que se pueda llamar estaVariable o esta_variable indistintamente, dentro del código.

Es un lenguaje muy expresivo y aunque no puedo decir que lo domine, todos los ejemplos que he probado a hacer, especialmente para ir mostrando características mediante código para este artículo, han funcionado muy bien.

He tenido que dejar en el tintero muchas cosas, como la compilación cruzada entre sistemas, la capacidad de utilizar como objetivo procesadores concretos, poder embeber código en ensamblador, etc.

Quizá necesite afianzarlo más con algún proyecto de esos sencillos que me gusta afrontar para ponerme ante el reto de aprender un nuevo lenguaje con más profundidad. Como hice el raytracer en erlang o la implementación del algoritmo de cifrado RSA en Tcl. En estos días, un amigo me sugirió que hiciera algo sobre ajedrez otra de esas cosas que me gustan... así que a lo mejor le hago caso y os cansino un poco con ello.

Footnotes:

1

Ponga aquí el lenguaje que más le guste: Rust, Go, Python, Ruby, Haskell...

2

Si habéis instalado el paquete ob-nim podéis ejecutarlo dentro de un bloque de código en org-mode y obtener el resultado pulsando también C-c C-c.

3

Observa que es camelCase y no PascalCase.

Categoría: nim 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 esta cuenta de Mastodon, también en esta otra cuenta de Mastodon y en Diaspora con el nick de Notxor.

Si usas habitualmente XMPP (si no, te recomiendo que lo hagas), puedes encontrar también un pequeño grupo en el siguiente enlace: notxor-tiene-un-blog@salas.suchat.org

Disculpen las molestias.