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 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,structotry, y se cierran conend. 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 esnull. - 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.5es3.5,1 == 1.0es verdadero. /entre dos enteros es división entera:7 / 2es3. Para un resultado fraccionario,float(7) / 2.- Valores falsos:
false,null,0,"",[],{}. andyorevalú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:

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()yRWMutex():lock,unlock,try_lock, yrlock,runlocken el lado del lector.Channel()yChannel(n): canales sin búfer o con búfer denposiciones 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ódulo | contenido |
|---|---|
fs | lectura, escritura, añadido, lectura por líneas, operaciones con directorios |
math, random | funciones matemáticas, números aleatorios con semilla |
fmt | números como texto con decimales fijos, agrupado y con relleno |
json | parse que conserva el orden de las claves, stringify con sangría |
time | reloj, temporizador monotónico, format y parse al estilo strftime |
re | expresiones regulares de tiempo lineal con sintaxis RE2 |
http | cliente (get, post, request) y servidor (serve), streaming SSE |
proc, net | subprocesos, sockets TCP, TLS |
crypto, encoding | sha256, hmac, hash de contraseñas PBKDF2, base64, hex |
os, runtime | argumentos, entorno, estadísticas de GC y de memoria |
test | un 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
defoifabierto, 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
cobraestá en el PATH. - El depurador:
cobra dapofrece 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.--asty--tokensimprimen 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

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, comoi = i + 1; lecturas delocal.field; indexaciónlist[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

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:

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").