BA Barış Akın~/blog

·18 min de lectura·48 lecturas

Cobra: un lenguaje de scripting con tres motores, sin GIL y con compilación nativa

Un recorrido completo por Cobra, un lenguaje escrito en Go: bloques cerrados con end, structs y records, decoradores, paralelismo real sin GIL, canales, una biblioteca estándar rica, un LSP y un depurador. Cómo funcionan el intérprete tree-walking, la VM de bytecode y un compilador nativo que supera a PyPy.

Cobra: un lenguaje de scripting con tres motores, sin GIL y con compilación nativa

Cobra es un lenguaje de scripting de propósito general que escribí en Go. Cierra los bloques con end en lugar de usar la sangría, se distribuye como un único binario estático y puede ejecutar el mismo programa por tres caminos distintos: un intérprete que recorre el árbol (tree-walking), una VM de bytecode y un compilador nativo que va directamente a código máquina. No hay GIL; las tareas async se ejecutan de verdad en todos los núcleos a la vez. A su alrededor hay un servidor LSP, un formateador, una extensión para VS Code, un framework de pruebas, un framework web al estilo de Django y una biblioteca numérica similar a NumPy.

Este artículo recorre todo el lenguaje: sintaxis, tipos de datos, funciones, objetos, errores, concurrencia, la biblioteca estándar, las herramientas y, por último, los detalles internos y el rendimiento. Ejecuté todos los ejemplos con Cobra 0.13.0 en los dos motores; la salida que se muestra es la salida real. El sitio del lenguaje está en cobralang.baltavista.com.

¿Por qué otro lenguaje?

Construí Cobra capa a capa: primero el lexer, luego un parser de Pratt, después un evaluador tree-walking, a continuación un compilador de bytecode y una VM de pila y, por último, el compilador nativo. Desde el principio hubo una regla: cada característica llega a los dos motores, y se comprueba que ambos se comportan de forma idéntica. Las pruebas y los benchmarks exigen que los dos motores produzcan una salida idéntica byte a byte; si difieren, ni siquiera se publican los números.

Lo que buscaba:

  • Scripts sin instalación: copiar un archivo y ejecutarlo. Sin venv ni gestor de paquetes.
  • Paralelismo real: el trabajo intensivo en CPU debería ser realmente más rápido al repartirse entre núcleos.
  • Integrable y legible: el lenguaje entero debería seguir siendo lo bastante pequeño como para leerlo y entenderlo de principio a fin.
  • Velocidad cuando hace falta: compilar el mismo código fuente de forma nativa y superar a los runtimes con JIT en trabajo numérico.

Cobra en sesenta segundos

let name = "Cobra"
let langs = ["go", "rill", "cobra"]
let ages = {"ada": 36, "linus": 28}

def greet(who, times)
    let out = []
    for i in range(times)
        out.push("hello ${who} #${i + 1}")
    end
    return out
end

for line in greet(name, 2)
    print(line)
end

let squares = map(def(x) -> x * x, [1, 2, 3, 4])
print(squares, squares[-1], squares[1:3])
print(filter(def(n) -> n % 2 == 0, range(10)))
let a, b = 1, 2
a, b = b, a
print(a, b, 7 / 2, float(7) / 2)
for k in ages
    print(k, ages[k])
end
print("  a,b  ".strip().split(","))

Salida:

hello Cobra #1
hello Cobra #2
[1, 4, 9, 16] 16 [4, 9]
[0, 2, 4, 6, 8]
2 1 3 3.5
ada 36
linus 28
["a", "b"]

Las reglas son pocas y claras:

  • Un salto de línea termina una sentencia; no hay punto y coma.
  • Los bloques se abren con una palabra clave como def, if, elif, else, while, for, struct o try, y se cierran con end. La sangría es puramente estética; una mala sangría nunca rompe un programa.
  • # inicia un comentario que llega hasta el final de la línea.
  • Los operadores lógicos son palabras: and, or, not. El valor vacío es null.
  • Las expresiones y los argumentos de llamada dentro de ( [ { pueden ocupar varias líneas.

Variables y tipos

let define un nombre nuevo en el ámbito actual; un simple x = ... actualiza uno existente. Asignar a un nombre no definido es un error, así que una errata nunca crea en silencio una variable nueva. const define una vinculación inmutable, pero la inmutabilidad es de la vinculación, no del valor: tras const xs = [1], xs = ... está prohibido, mientras que xs.push(2) es válido.

Tipos: enteros, flotantes, cadenas, booleanos, null, listas, diccionarios y funciones de primera clase. Algunos detalles:

  • Mezclar enteros y flotantes promueve a flotante: 1 + 2.5 es 3.5, 1 == 1.0 es verdadero.
  • / entre dos enteros es división entera: 7 / 2 es 3. Para un resultado fraccionario, float(7) / 2.
  • Valores falsos: false, null, 0, "", [], {}.
  • and y or evalúan en cortocircuito y devuelven el operando decisivo: 0 or "x" es "x".
  • La asignación múltiple convierte un intercambio en una línea: a, b = b, a. El lado derecho se evalúa por completo antes de vincular nada.
  • += -= *= /= %= funcionan con variables, destinos indexados (xs[i] += 1) y campos (self.n += 1).

Colecciones y cadenas

Las listas son tipos por referencia; let b = a no copia, apunta a la misma lista. Los índices negativos cuentan desde el final (l[-1]), + concatena, == compara en profundidad. Métodos: push, pop, contains, find, count, reverse, sort.

El slicing funciona como el seq[low:high:step] de Python: xs[:2], xs[1:], xs[::-1]. Los límites fuera de rango se ajustan. Con listas devuelve una lista nueva, con cadenas una cadena basada en runas (segura para UTF-8) y con un range otro rango perezoso.

Los diccionarios conservan el orden de inserción. Las claves pueden ser cadenas, enteros, booleanos o null. Leer una clave inexistente con d[k] es un error; se comprueba con has o se usa get(k, default). Métodos: keys, values, items, has, get, del.

Las cadenas son inmutables e indexables. Métodos como upper, lower, strip, split, join, replace, startswith, find y count se pueden encadenar. ${...} inserta una expresión en una cadena; dentro cabe cualquier expresión, incluso una interpolación anidada. Las cadenas con triples comillas sirven para texto de varias líneas.

La ordenación puede recibir un comparador, y el módulo fmt muestra los números correctamente en pantalla:

import "fmt"
import "json"
let rows = json.parse("[{\"name\": \"keyboard\", \"price\": 1299}, {\"name\": \"mouse\", \"price\": 349.5}]")
rows.sort(def(a, b) -> a["price"] < b["price"])
for r in rows
    print(fmt.pad(r["name"], 8, " "), fmt.group(r["price"], 2))
end
   mouse 349.50
keyboard 1,299.00

sort(less) es una ordenación estable, así que los elementos iguales conservan su orden de entrada y las ordenaciones por varias claves se componen ejecutando una tras otra. fmt.group y math.round redondean las mitades al par (redondeo bancario) y redondean a través del texto decimal en lugar de la coma flotante binaria, de modo que aquí no se producen errores traicioneros como el de 0.145 * 100.

Funciones, closures y decoradores

Las funciones son valores de primera clase y capturan el ámbito en el que se definen. Las funciones anónimas de una sola expresión usan la forma de flecha: def(x) -> x * 2. Para un cuerpo de varias líneas existe la forma de bloque def(x) ... end. map, filter, reduce, zip, enumerate, any y all vienen integradas.

Los decoradores se colocan encima de un def, y @d significa name = d(name). Es la forma natural de registrar rutas web:

let routes = {}

def route(path)
    return def(fn)
        routes[path] = fn
        return fn
    end
end

@route("/")
def home(req)
    return "home page"
end

@route("/hello")
def hello(req)
    return "hello ${req["name"]}"
end

for path in routes
    print(path, "->", routes[path]({"name": "ada"}))
end
/ -> home page
/hello -> hello ada

Los decoradores se apilan; el más cercano al def se aplica primero. También funcionan con async def.

Objetos: struct, herencia, record, contract

Las clases en Cobra se definen con struct (se acepta class como alias). init es el constructor y self es implícito en los cuerpos de los métodos. La herencia simple usa <, y super alcanza el método del padre. El cuerpo de un struct también puede contener constantes de clase (const), propiedades que se leen como campos (get), ganchos de asignación (set) y métodos estáticos (static def).

Un record es un struct inmutable que se compara por valor: dos records con campos iguales son ==, y with devuelve una copia nueva con algunos campos sustituidos.

struct Shape
    def init(name)
        self.name = name
    end
    def describe()
        return "${self.name}: area ${self.area()}"
    end
end

struct Circle < Shape
    const PI = 3.14159
    def init(r)
        super.init("circle")
        self.r = r
    end
    def area()
        return Circle.PI * self.r * self.r
    end
end

struct Rect < Shape
    def init(w, h)
        super.init("rectangle")
        self.w = w
        self.h = h
    end
    def area()
        return self.w * self.h
    end
    get square()
        return self.w == self.h
    end
end

record Point
    def init(x, y)
        self.x = x
        self.y = y
    end
end

for s in [Circle(2), Rect(3, 3)]
    print(s.describe())
end
print(Rect(2, 5).square)
let p = Point(1, 2)
print(p == Point(1, 2), p.with({"x": 9}))
try
    p.x = 5
catch err
    print("error:", err)
end
circle: area 12.56636
rectangle: area 9
false
true Point{x: 9, y: 2}
error: line 49: cannot modify immutable record Point

El self.area() que se llama dentro de Shape.describe está definido en la subclase; los métodos se despachan dinámicamente según el tipo real de la instancia. Un contract es una interfaz ligera: enumera los nombres de métodos requeridos, e implements(x, Drawable) indica si un valor los define todos.

Errores: try, catch, finally

throw puede lanzar cualquier valor, y se captura tal cual. Los errores en tiempo de ejecución se capturan como mensajes que incluyen un número de línea. finally se ejecuta siempre, y return, break y continue atraviesan los bloques try sin alterarse.

def divide(a, b)
    if b == 0
        throw "division by zero"
    end
    return a / b
end
try
    print(divide(10, 2))
    print(divide(1, 0))
catch e
    print("caught:", e)
finally
    print("finally always runs")
end
@memo
def x()
end

El @memo del final se deja sin definir a propósito. Justo aquí es donde los dos motores difieren: el intérprete tree-walking ejecuta el programa hasta esa línea y se detiene ahí, mientras que la VM detecta el nombre no definido al compilar y no ejecuta ni una sola línea. El mensaje de error y el número de línea son los mismos en ambos:

Cobra en la terminal: dos motores, paralelismo y compilación nativa

Las variables no definidas no son lo único que se detecta en tiempo de compilación: la asignación a funciones integradas, un break fuera de un bucle y la reasignación de un const se rechazan antes de que se ejecute nada.

Concurrencia: async, await y paralelismo sin GIL

Llamar a una función definida con async def devuelve inmediatamente una tarea, y su cuerpo se ejecuta en su propio hilo del sistema operativo. await task espera el resultado, y await [t1, t2, ...] los reúne todos, con los resultados en orden. Un error lanzado en una tarea se vuelve a lanzar en el await.

Sin GIL, el trabajo intensivo en CPU se acelera de verdad. Hagamos el mismo cálculo primero en secuencia y luego con cuatro tareas:

import "time"
import "fmt"

def work(n)
    let s = 0
    let i = 0
    while i < n
        s = s + i % 7
        i = i + 1
    end
    return s
end

async def job(n)
    return work(n)
end

let t0 = time.clock()
let seq = [work(5000000), work(5000000), work(5000000), work(5000000)]
let t1 = time.clock()
let par = await [job(5000000), job(5000000), job(5000000), job(5000000)]
let t2 = time.clock()
print(seq == par)
print("sequential ${fmt.decimal(t1 - t0, 3)}s, parallel ${fmt.decimal(t2 - t1, 3)}s")

En una máquina Apple de la serie M con 10 núcleos, la VM imprimió sequential 0.584s, parallel 0.186s: unas 3 veces, sin ningún lock, ni pool, ni sintaxis adicional en el código. pmap(fn, list) es la forma más corta de la misma idea: la misma llamada que map, pero los elementos se reparten entre los núcleos y los resultados conservan su orden de entrada.

El paralelismo deja en manos del programador la sincronización del estado compartido. Para ello hay herramientas integradas:

  • Mutex() y RWMutex(): lock, unlock, try_lock, y rlock, runlock en el lado del lector.
  • Channel() y Channel(n): canales sin búfer o con búfer de n posiciones como en Go; send, recv, close, try_send, try_recv.
  • select(ops): espera en varios canales a la vez y continúa con el que esté listo; si se le da un caso por defecto, no espera.
let ch = Channel(4)
let done = Channel()

async def producer()
    for i in range(5)
        ch.send(i * i)
    end
    ch.close()
end

async def consumer()
    let total = 0
    let v = ch.recv()
    while v != null
        total += v
        v = ch.recv()
    end
    done.send(total)
end

producer()
consumer()
print("total:", done.recv())
print(pmap(def(x) -> x * 10, [1, 2, 3]))
total: 30
[10, 20, 30]

El ámbito global y la caché de módulos están sincronizados internamente, así que las tareas que tocan globales distintas siempre son seguras. Un pánico recuperable en una tarea con condición de carrera (por ejemplo, un índice fuera de rango en una lista compartida) se convierte en un error capturable "data race in task" en lugar de hacer caer el programa. Es una red de seguridad, no un sustituto de un lock: como en Go, modificar un diccionario compartido sin uno es un error fatal.

Módulos y la biblioteca estándar

import "lib/utils.cobra" ejecuta el archivo una vez, lo guarda en caché y vincula un objeto de módulo utils; as le da otro nombre. Los ciclos de importación son errores, y los módulos son de solo lectura desde fuera.

Los módulos nativos integrados están escritos en Go y los comparten ambos motores. Todos sus fallos se pueden capturar:

módulocontenido
fslectura, escritura, añadido, lectura por líneas, operaciones con directorios
math, randomfunciones matemáticas, números aleatorios con semilla
fmtnúmeros como texto con decimales fijos, agrupado y con relleno
jsonparse que conserva el orden de las claves, stringify con sangría
timereloj, temporizador monotónico, format y parse al estilo strftime
reexpresiones regulares de tiempo lineal con sintaxis RE2
httpcliente (get, post, request) y servidor (serve), streaming SSE
proc, netsubprocesos, sockets TCP, TLS
crypto, encodingsha256, hmac, hash de contraseñas PBKDF2, base64, hex
os, runtimeargumentos, entorno, estadísticas de GC y de memoria
testun framework de pruebas unitarias integrado

Un servidor HTTP son unas pocas líneas incluso sin framework:

import "http"
import "json"

def handler(req)
    if req["path"] == "/hello"
        let name = req["query"].get("name", "world")
        return {"headers": {"Content-Type": "application/json"}, "body": json.stringify({"hello": name})}
    end
    return {"status": 404, "body": "not found"}
end

http.serve(":8099", handler)

curl "localhost:8099/hello?name=ada" devuelve {"hello":"ada"}. El diccionario de la petición llega con el método, la ruta, la query, las cabeceras, el cuerpo, la dirección del cliente y los archivos enviados como multipart/form-data. Los manejadores se ejecutan en paralelo, una goroutine por petición.

Las pruebas también viven en el lenguaje:

import "test"

@test.it
def addition()
    test.assert_eq(2 + 2, 4)
end

@test.it
def deep_equality()
    test.assert_eq([1, {"a": [2]}], [1, {"a": [2]}])
end

@test.it
def raises()
    test.assert_raises(def() -> 1 / 0)
end

let summary = test.run()
ok    addition
ok    deep_equality
ok    raises

3 passed, 0 failed, 3 total

Herramientas

Un binario, todos los modos:

cobra app.cobra                  # tree-walking interpreter
cobra --vm app.cobra             # bytecode VM
cobra --native app.cobra         # compile to machine code and run (cached)
cobra build app.cobra            # app.cobrac bytecode file
cobra build --exe app.cobra      # self-contained file with the VM embedded
cobra build --native app.cobra   # standalone native executable
cobra                            # REPL (multi-line, keeps state)
cobra fmt -w app.cobra           # source formatter
cobra watch app.cobra            # re-run when the file changes
cobra lsp                        # language server
cobra dap                        # debugger (Debug Adapter Protocol)
  • El REPL existe en ambos motores; mientras se escribe un bloque def o if abierto, espera el resto con un prompt de continuación ...
  • El servidor LSP ofrece diagnósticos en vivo a partir del parser y el compilador reales: errores de sintaxis y variables no definidas mientras se escribe, autocompletado, documentación al pasar el cursor, símbolos del documento e ir a la definición.
  • La extensión de VS Code aporta resaltado de sintaxis, sangría consciente de los bloques, snippets, autocompletado de miembros de módulos y una vista de esquema; inicia el LSP por sí misma cuando cobra está en el PATH.
  • El depurador: cobra dap ofrece a los editores puntos de interrupción, ejecución paso a paso, pila de llamadas e inspección de variables mediante el Debug Adapter Protocol. --ast y --tokens imprimen lo que ven el parser y el lexer, y los errores en tiempo de ejecución vienen con un stack trace.
  • Plugins de Go: import "name.so" carga en tiempo de ejecución un plugin escrito en Go y lo usa como un módulo.

Los binarios de las versiones son estáticos y sin dependencias, compilados para macOS (arm64, amd64), Linux (amd64, arm64) y Windows.

Detalles internos: un front end, tres motores

La arquitectura de Cobra: lexer, parser, AST y tres motores

El lexer convierte el código fuente en tokens, y el parser de Pratt convierte los tokens en un AST. A partir de ahí el camino se divide en tres, pero el comportamiento de los valores (el paquete object) y la biblioteca estándar (el paquete eval) son compartidos por todos. Por eso un método de lista o una llamada a json.parse no puede comportarse de forma distinta en los dos motores.

El intérprete tree-walking evalúa directamente los nodos del AST. No tiene coste de preparación, lo que lo hace ideal para el REPL y los scripts cortos.

La VM de bytecode ejecuta la batería de benchmarks unas 4 veces más rápido. Lo que la hace rápida, aproximadamente por orden de impacto:

  • Arenas de reserva de memoria por VM para flotantes e instancias de structs. Es seguro porque no hay GIL y las arenas pertenecen a una sola VM.
  • Un bucle de despacho con variables en registros: el puntero de instrucción, el puntero de pila y la pila de valores viven en variables locales y solo se sincronizan alrededor de las llamadas y de las rutas de error. El bytecode termina con OpHalt, así que el bucle no necesita comprobación de límites.
  • Once superinstrucciones fusionadas que condensan los patrones más frecuentes en un solo despacho: comparar y saltar, como while i < n; calcular y almacenar, como i = i + 1; lecturas de local.field; indexación list[local].
  • Rutas rápidas en línea para la aritmética de enteros y flotantes, e internado de enteros pequeños (de -1024 a 131071).
  • Marcos de llamada que viven en un array reservado de antemano: cero reservas de memoria por llamada.
  • Una tabla hash propia de direccionamiento abierto para diccionarios con claves de cadena, con los hashes cacheados en los objetos de cadena.
  • Las búsquedas en la tabla de líneas se sacaron por completo de la ruta caliente; los mensajes de error calculan su número de línea solo cuando hace falta.

El compilador nativo realiza inferencia de tipos sobre el programa completo. Cobra tiene tipado dinámico, pero los programas reales son en su mayoría estables en cuanto a tipos: una variable siempre contiene un entero, una lista siempre contiene flotantes. Allí donde el compilador puede demostrarlo, emite enteros y flotantes sin empaquetar, structs planos y slices y mapas nativos, usando la toolchain de Go como generador de código. Se verifica que la salida es idéntica byte a byte a la de la VM, hasta en el formato y el redondeo de los flotantes.

La compilación nativa cubre el núcleo estable en tipos del lenguaje: funciones, structs con init y métodos, listas, diccionarios, cadenas, bucles y math. Un programa que use closures, async, try/catch, herencia, records u otros módulos se marca como "no compilable de forma nativa", con cada construcción que lo impide nombrada; ese programa se ejecuta sin cambios en la VM. Generar un archivo nativo requiere Go en la máquina de compilación; ejecutarlo no requiere nada (en mi máquina es un archivo Mach-O estático de unos 2 MB).

Rendimiento

Comparación de la compilación nativa y los dos motores

El gráfico superior muestra las mediciones publicadas en el sitio del lenguaje: los benchmarks n-body, binary-trees, spectral-norm, mandelbrot y word-frequency están escritos de forma idéntica en Cobra y en Python, y todos los runtimes imprimen la misma salida. Cobra nativo termina en 0,28 segundos en total, por delante del JIT de trazas de PyPy (0,44 segundos); CPython 3.14 necesita 5,99 segundos para la misma batería.

El gráfico inferior compara los dos motores en seis microbenchmarks: en llamadas recursivas (fib) la VM es más de 10 veces más rápida, 4,1 veces en total.

Para ser sincero, la VM no supera a CPython en todo. Para este artículo medí yo mismo un bucle de mandelbrot de 600×600 píxeles y 200 iteraciones: el intérprete tree-walking tardó 16,5 segundos, la VM 2,5 segundos, CPython 3.14 1,7 segundos y Cobra compilado de forma nativa 0,07 segundos (una vez hecha y cacheada la compilación). Con mucha aritmética de flotantes y condiciones de bucle complejas, se nota el coste del empaquetado (boxing) de la VM; la herramienta adecuada para ese tipo de trabajo es, de todos modos, --native. El verdadero terreno de Cobra son los scripts rápidos y sin instalación, el scripting integrado y un runtime lo bastante pequeño como para entenderlo de principio a fin.

Ecosistema: cobranum y Venom

cobranum es una biblioteca de arrays numéricos al estilo de NumPy construida sobre Gonum, o sobre Apple Accelerate (BLAS/LAPACK) cuando se compila con cgo en macOS. Ofrece arrays densos de 1 y 2 dimensiones, operaciones vectorizadas, broadcasting, reducciones y álgebra lineal. El libro la trata con ejemplos científicos como mínimos cuadrados, PCA, PageRank, integración numérica y ecuaciones diferenciales:

El modelo depredador-presa de Lotka-Volterra con cobranum

Venom es un framework web inspirado en Django y escrito íntegramente en Cobra: enrutamiento mediante decoradores, un ORM sobre OxiDB, un motor de plantillas con herencia, middleware, sesiones, archivos estáticos y protección CSRF. Como Cobra prefiere las funciones fábrica a la herencia de clases, un modelo sale de una fábrica:

import "venom/router.cobra"
import "venom/response.cobra"
import "venom/orm.cobra"

let route = router.route
let db    = orm.connect("127.0.0.1", 4444)
let User  = orm.model(db, "users").ensure()

@route("GET", "/users/<name>")
def show_user(req)
    let user = User.get({"name": req["params"]["name"]})
    if user == null
        return response.not_found("no such user")
    end
    return response.ok(user)
end

router.serve(":8000")

La conexión con OxiDB pasa por el plugin oxidb.so escrito en Go. En la misma familia hay también un ORM al estilo de Entity Framework para el motor SQL de OxiDB (oxisqlorm).

El libro

Todo el lenguaje se trata en dos libros, en turco y en inglés: de los fundamentos al control de flujo, de las colecciones a la orientación a objetos, de la concurrencia a los detalles internos y cobranum. La edición en inglés añade además un capítulo sobre Venom. Ambos son gratuitos en PDF:

Cómo probarlo

Los binarios, la extensión de VS Code, cobranum y los libros están en cobralang.baltavista.com. En macOS (Apple Silicon) el camino más corto es:

curl -LO https://cobralang.baltavista.com/releases/cobra-0.13.0-darwin-arm64
chmod +x cobra-0.13.0-darwin-arm64 && mv cobra-0.13.0-darwin-arm64 /usr/local/bin/cobra
cobra --version

Después basta con abrir un archivo y escribir print("hello").

BA
Barış Akın

Arquitecto de soluciones de software con más de 25 años de experiencia. FinTech y sistemas distribuidos; creador de OxiDB, Rill y Cobra.