Nim, un lenguaje compilado
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:
- Eficiente:
- Genera ejecutables nativos para Windows, GNU/Linux, macOS y BSD; sin dependencias externas de librerías o máquinas virtuales.
- Fuertemente tipado.
- Gestión de memoria determinista inspirada en C++ y Rust y adecuada para sistemas embebidos y tiempo real.
- No sólo genera ejecutables, también podemos darle salida a otros lenguajes como C, C++, ObjectiveC, JavasCript.
- Expresivo:
- Gran variedad de tipos que ya son habituales en todos los lenguajes modernos: listas, tuplas, diccionarios, tipos (objetos), secuencias, cadenas...
- Tan orientado a objetos como funcional, aunque se define como lenguaje procedural, puedes utilizar el estilo de programación que más te guste.
- Macros, templates y generics te permiten definir nuevos comandos y aspectos del lenguaje para ajustarlo al problema que necesitas resolver.
¿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 acompany-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 animsuggest-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:
- No hay una función
main
explícita. Ese código se está comportando como si fuera un lenguaje de script, pero es compilado. - El indentado de los bloques de código, muy al estilo Python. Se puede apreciar en el bloque de definición de constantes.
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.
- 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]##
. - 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-c
2. 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.
Comentarios