Lenguaje de programación Jam
(rapha.land)- Jam es un lenguaje en etapa previa a v1.0 que busca combinar seguridad, baja curva de aprendizaje y alto rendimiento sin GC, manteniendo la sensación de uso inmediata de los lenguajes de la familia C
- Su base son las mutable value semantics y un sistema de drop al estilo Rust, donde el compilador maneja ownership, borrow y limpieza automática sin exponer referencias ni sintaxis de lifetime en el código del usuario
- Su modelo de inicialización evita tanto
undefinedcomo la inicialización implícita en cero, y trata la inicialización diferida y los out-parameters conMaybe(T)y análisis deunsafeAssumeInit() exportexpone funciones de Jam con C ABI y las structs de Jam están diseñadas para tener un layout compatible con C, con la idea de reducir la necesidad de shimsunsafeo anotacionesrepr- El compilador sigue en fase de bootstrap, implementado en C++, aún no es público, y planean liberarlo como open source después de crear 108 proyectos distintos en Jam
La posición de lenguaje que busca Jam
- Jam sigue antes de v1.0, y aunque los mecanismos descritos hoy funcionan en el compilador, los detalles pueden cambiar antes de estabilizarse
- El objetivo es crear un lenguaje seguro que reduzca las clases de bugs de C, manteniendo una sensación de lenguaje de la familia C que sea fácil de entender de inmediato, como Go, Zig o C moderno
- El diseño gira en torno a dos ejes
- Mutable value semantics de Racordon, Abrahams et al. 2022
- El drop system de Rust
- Parte de la idea de que los equipos reales tienen distintos niveles de experiencia y que los miembros menos experimentados tienen más probabilidades de equivocarse, así que el lenguaje debe impedir más errores antes de llegar al code review
Diferencias frente a Rust, Zig y C++
- Rust tiene una filosofía de seguridad fuerte, pero la distancia entre “puedo usar Rust más o menos” y “soy productivo con Rust” puede ser grande, y la curva de aprendizaje puede ser una carga para el equipo
- Zig se acerca más a un lenguaje tipo C con una superficie pequeña y un modelo mental inmediato, pero no es un lenguaje seguro a nivel de lenguaje
- No fuerza a nivel de lenguaje la prevención de uninitialized read, manual cleanup ni use-after-free
- Los proyectos grandes en producción hechos en Zig o C++ dependen mucho de herramientas de verificación como Valgrind, AddressSanitizer y fuzzing
- En la era de la IA, consideran que gran parte del código de producción será escrito o al menos borradorizado por herramientas, y que el cuello de botella se mueve de code writing a code review
- El volumen de código aumenta pero la superficie de revisión se mantiene plana, así que el compilador debe atrapar más bugs
Sistema automático de drop
- En Jam, cada binding posee su valor, y cuando un binding de un tipo con drop sale de scope, el compilador genera la llamada a drop
- El tipo de ejemplo
Filedeclarafn drop(self: mut File), y enuseFile()solo se escribeconst f: File = { fd: 7 };- No hay cleanup explícito,
deferni marcas de fin de lifetime - En LLVM IR se genera
call void @__drop_File(ptr %1)justo antes deret
- No hay cleanup explícito,
- El nombre con mangling
__drop_Fileevita colisiones entre funciones drop de distintos tipos a nivel de LLVM self: mut Filese baja a un parámetro puntero, y el call site pasa directamente la dirección del binding- En Zig hay que escribir explícitamente
defer f.deinit()para hacer el mismo cleanup- Si se quita esa línea, la llamada a deinit también desaparece del IR
- Una fuga de file descriptor ocurre cuando el programador olvida hacer el cleanup
- El RAII de C++ también ejecuta automáticamente el destructor al salir del scope, pero Jam adopta el modelo de drop simple de Rust
- Busca evitar complejidades de C++ como rule of 0/3/5, virtual destructor, constructor exception, destructor exception,
std::exit,std::abort,longjmpy señales - Jam define una función drop por tipo y la ejecuta en todas las salidas de scope
- Busca evitar complejidades de C++ como rule of 0/3/5, virtual destructor, constructor exception, destructor exception,
Inicialización y Maybe(T)
- En Jam no existe el valor
undefined, y no se puede declarar un binding sin valor- Todo
varyconstrequiere un initializer real - Las structs calculan primero los valores de sus fields, luego se crean con un struct literal y después se asignan al binding
- Todo
- Zig permite
var f: File = undefined; return f.fd;, y en runtime eso puede leer basura del stack- En Debug mode se rellena con
0xaapara hacer visible el misuse - En Release mode se vuelve bytes arbitrarios
- En Debug mode se rellena con
- Go inicializa en cero todos los
varpara evitar leer basura, pero eso tiene el costo de escribir el patrón cero incluso en fields que se sobrescribirán enseguida - Jam evita tanto
undefinedcomo el cero implícito - Para inicialización diferida y out-parameters usa
Maybe(T)empty()crea un slot cuyos contents aún no tienen significadowrite()llena el slotunsafeAssumeInit()extrae el valor
- Un lint pass rastrea si el slot fue escrito, y si el analizador no puede probar la inicialización, rechaza la llamada a
unsafeAssumeInit()con compile error- El prefijo
unsafequeda como ancla para que revisores humanos o de IA puedan encontrarlo con grep
- El prefijo
Scope exit, return, break y continue
- El compilador rastrea una drop scope stack y hace push de un nuevo scope en cada límite de bloque léxico
- Cuando termina un bloque o justo antes de salir por una rama, emite los drops de los bindings de ese scope
- Los bindings dentro de
if,else, brazos dematch,whiley cuerpos deforse dropean al final de ese bloque - Un
returndentro de un bloque anidado hace drop de los scopes activos, del más interno al más externo, antes delretreal breakycontinuehacen drop de los scopes abiertos dentro del cuerpo del loop antes de ir a la salida del loop o a la siguiente iteración
- Los bindings dentro de
- En el ejemplo de nested break,
outerse dropea al final de la iteración 0, y en la ruta del break de la iteración 1 se dropean primeroinnery luegoouter
Parameter mode y eliminación de referencias first-class
- En una llamada de función, si un binding se dropea lo determina el parameter mode
- El modo por defecto es un borrow de solo lectura
- El callee puede leer el valor y el binding del caller sigue inicializado
- No ocurre drop al volver de la llamada
mutes un borrow exclusivo de lectura y escritura- El binding del caller sigue inicializado después de la llamada
- Solo
moveconsume el valor- El callee recibe ownership y lo dropea al final del callee
- El binding del caller queda Uninit después de la llamada y leerlo produce compile error
- No hay marcador en el call site; la forma
f(x)es igual en todos los modos - Jam no tiene first-class reference type
- No se puede guardar un borrow en una variable, retornarlo ni almacenarlo en un field de una struct
- Los parameter borrows solo existen durante el call-frame y expiran al retornar la llamada
- No hacen falta lifetime annotations porque no existe un lifetime al cual adjuntarlas
- La API de colecciones también mantiene forma de valor
v[i] = xse desazucara av.setAt(i, x)let y = v[i]usa el getterv.at(i)para devolver el elemento como valor
- El call-site exclusivity check revisa si hay solapamiento de paths en el borrow set generado por los argumentos
swap(p.x, p.y)está bien porque son subpaths disjuntosmoveX(p, p.x)da error porquepyp.xse solapan
C ABI y FFI
- El ABI nativo de Rust es inestable, así que al cruzar un límite de distribución hay que volver a codificar en forma C
- Dereferenciar raw pointers es
unsafe - El ownership se transfiere manualmente con
Box::into_rawyBox::from_raw - Para pasar una struct por valor se requieren anotaciones separadas como
#[repr(C)] - Herramientas como
cbindgenyabi_stableexisten para reducir el trabajo manual en esa frontera
- Dereferenciar raw pointers es
- Jam considera que, al no tener first-class references, lifetimes ni niche-packed layout, un valor de Jam conserva forma de valor hasta el fondo
- Las structs de Jam ya están diseñadas para tener un layout compatible con C
exportexpone funciones de Jam con nombres planos sin mangling y calling convention de Cexport fn counterAdd(c: mut Counter, n: i64) i64puede llamarse desde C comoint64_t counterAdd(Counter *c, int64_t n);- El parámetro
mut Counterse baja aCounter *sobre almacenamiento poseído por el caller
- El cuerpo de la función del lado de Jam sigue siendo Jam normal, así que continúan aplicando las reglas de drop, análisis de inicialización y exclusividad en el call site
- Para llamar hacia C se declara la signature de C con
extern- Una función
externsigue el C ABI de forma literal - La machinery de parameter modes no se aplica fuera de esa frontera
- Se puede pasar un buffer a C como raw pointer, y Jam no verifica qué hace C con ese puntero
- Una función
- Lo que Jam busca ofrecer es que el lado Jam siga siendo safe by default, y que al exponer una librería de Jam por C ABI no haga falta crear un API mirror
unsafeseparado ni una shim layer
Pattern matching
- El
matchde Jam usa la formaPattern Blocky no emplea=>- El scrutinee usa paréntesis, como en
match (opcode) _es el brazo catch-all- Los brazos se evalúan de arriba hacia abajo, first-match, sin fallthrough implícito
- El scrutinee usa paréntesis, como en
- El dispatcher de opcodes de un emulador de Game Boy es uno de los casos de uso principales
- La forma es despachar 256 opcodes base y 256 opcodes prefijo
- También soporta enum payload matching
- El patrón de variant hace match del tag y liga los payload fields a locals frescos dentro del brazo
- El compilador verifica exhaustiveness sobre el conjunto de variants
- Si se agrega una variant nueva, cualquier match que no la maneje deja de compilar
matchtambién funciona como expresión- Cada bloque de brazo produce el valor de su trailing expression
- Todos los brazos deben producir el mismo type
- El match debe ser exhaustivo
- Internamente, todo
matchse compila pasando por un pipeline de árbol de decisión basado en Luc Maranget 2008- Las cascadas de literales enteros pueden ser convertidas por LLVM
simplifycfgenswitchy jump tables cuando resulta rentable
- Las cascadas de literales enteros pueden ser convertidas por LLVM
Diseño de compile time
- El pipeline de compilación de Rust pasa por varios IR y etapas de análisis
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- La resolución de traits es un problema de búsqueda y el borrow checking es análisis de regiones a nivel de función completa
- La monomorphization aumenta el volumen de código antes de LLVM
- El pipeline de Jam está diseñado para ser más corto
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- Usa un solo IR tipado: JIR
- JIR ya está tipado desde que lo crea AstGen
- Jam considera que no tiene comptime-as-values que obligue a un lowering sin tipos
- La colocación de drop, el check de init-before-use y la regla de exclusividad en el call site se resuelven con pases locales de dataflow sobre JIR
- Como cada binding tiene anotación de tipo, consideran menor la carga de type inference global y de búsqueda abierta de traits
- AST y JIR son estructuras de datos planas
- Empaquetan nodos pequeños de tamaño fijo en arrays contiguos
- Usan índices en vez de punteros, y guardan payloads grandes en side pools
- Así el compilador recorre arrays amigables con caché en lugar de seguir árboles alocados en heap
- En el backend, LLVM domina el tiempo de optimización en release build
- Planean usar Cranelift para debug build y LLVM para release build
- Cranelift está en el roadmap, pero aún no está terminado
- El compilador actual está en fase de bootstrap con implementación en C++, y todavía no hay benchmarks de build time dignos de citar
- Las afirmaciones sobre compile time son claims de diseño, no resultados medidos
Runtime performance y ejemplos
- El objetivo es que Jam iguale el rendimiento de Rust y Zig
- Jam no tiene GC, runtime de memoria administrada ni headers por asignación
- El codegen produce LLVM IR directo
- Aún no consideran que haya alcanzado el nivel de Rust y Zig
- Rust y Zig llevan mucho tiempo trabajando en intrinsics específicos por target, hints de auto-vectorization, containers conscientes del allocator, tuning de hot paths y tuning de passes de LLVM
- Jam necesitará el mismo tipo de trabajo para cerrar el último 10~30%
- En los workloads medidos hasta ahora, creen que la diferencia no es de “otra clase”, sino de un small constant factor
- Hay un demo de Tetris para terminal escrito en Jam
Plan de publicación y trabajo pendiente
- Jam todavía no es público
- El compilador existe y funciona, pero aún no está listo para una liberación más amplia
- Para el uso diario siguen trabajando en lo siguiente
- stable surface
- package manager
- LSP
- formatter
- el resto del tooling
- Quedan temas que planean tratar en textos separados
- sistema de parameter modes
- regla de exclusividad
- genéricos
- el comptime de Jam
- standard library
- sistemas de allocator
- modelo de panic
- exploración de MLIR para el pipeline de codegen de GPU
- trabajo de Rust ABI para FFI
- Cranelift
- camino hacia un compilador self-hosted
- El plan open source es publicar Jam después de crear 108 proyectos distintos en Jam
- El número 108 es un milestone arbitrario tomado de los 108 Stars of Destiny de Suikoden 2
- Por ahora se ha entregado a un grupo pequeño de usuarios, y planean ampliar el acceso cuando el tooling esté más maduro
- Se puede pedir early access en la beta list de jamlang.org
1 comentarios
Comentarios en Lobste.rs
Este tipo de texto generado por LLM hace algo con lo que los ingenieros, especialmente los más jóvenes, deben tener cuidado: sustituir datos cuantitativos por prosa cualitativa y verosímil
Persuadir con una historia es más fácil, tanto para quien escribe como para quien lee, que reunir y analizar cifras sólidas. Al cerebro humano le gustan las historias, y las historias funcionan mejor cuando son simples y prolijas. Los datos reales suelen reflejar un mundo complejo, con tantos matices como uno esté dispuesto a mirar
Basta compararlo con esta entrada cuantitativa de blog sobre profiling del compilador de Rust, escrita por un colaborador de rustc
Un buen texto técnico puede y debe incluir ambas cosas cuando corresponde, pero no debe perder de vista lo que realmente tiene que transmitir. Después de dirigir una organización grande de trabajo de aseguramiento, entendí lo difícil que es escribir sobre tecnología, y hay que tener cuidado con cuánto puede empeorar este problema a medida que se facilita la accesibilidad a los LLM
¿La diferencia clave con Zig es que tiene
dropy no tieneundefined, un componente específico que se usa mal con facilidad?No hay
undefinedy todos los valores deben inicializarse, peroMaybe(T).empty()devuelve un valor cuyo contenido “todavía no tiene sentido”, y si justo después se llama aunsafeAssumeInit(), parece que devolvería un valor basura. Entonces no es seguridad en el sentido de Rust, donde el compilador trataunsafecomo una contaminación que requiere ununsafe { .. }explícitoEl ejemplo que muestra la seguridad y la función
dropes este código:Si no lo estoy viendo mal, ¿esto no es inseguro? Dejando de lado la asignación manual del descriptor de archivo, llama a
close(7)y luego devuelve7. Como no hay seguimiento de vida útil, el usuario no tiene forma de expresar que la vida útil del descriptor de archivo terminó antes de queuseFile()devolvieraEn el ejemplo de ABI, cuando
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }pasa a serint64_t counterAdd(Counter *c, int64_t n);, ¿cómo se expresa sicpuede serNULLo no? Rust tiene una ABI definida para esto, y se puede escribir tantoextern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64comoextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64La versión en Rust tampoco necesita
unsafe. Se puede definir la API con referencias. Irónicamente, el único lugar donde podría necesitarseunsafeen Rust moderno es algo como#[unsafe(no_mangle)], es decir#[no_mangle]; pero por algún motivo el ejemplo está armado para que el lado de Rust use punteros sin procesarTambién este ejemplo de más adelante:
¿No debería haber un
unsafeen alguna parte? Comosnprintfrecibe punteros sin procesar, siguiendo la directriz mencionada antes de que las operacionesunsafedeberían poder encontrarse por nombre, parecería que debería haber algo comounsafeSnprintfy una redefinición del símbolo“Una pista honesta: en la línea
externestás hablando con C, y las reglas de C ganan”, mmm.as_raw_fd()de Rust, y ahí existe el mismo problema de seguridadEsto malinterpreta la estabilidad FFI de la biblioteca estándar de Rust. Las referencias compartidas, las referencias mutables,
Boxy susOptiontienen todas una ABI definida y estable. Por eso todo el procedimientoBox::into_raw/from_rawdel ejemplo es innecesarioLas vidas útiles directamente no existen a nivel binario. Si se elige definir una ABI estable para un enum, se desactiva la optimización de niches
La razón por la que la mayoría de los tipos no definen una ABI estable es que muchas veces no se quiere una ABI estable, porque eso impediría cambiar la implementación interna del tipo
No entiendo esta decisión. Hay una gran diferencia entre “lanzar” algo incompleto y simplemente publicar el código fuente. Si de todos modos vas a hacerlo más adelante, ¿qué daño hay en hacerlo público mientras construyes el proyecto?
La ventaja es que las personas a las que les guste la dirección podrían probarlo por sí mismas y quizás hasta contribuir. Claro que, como estamos en “la era de la IA”, no está claro si esas contribuciones serían una ganancia neta. También permite entender mejor qué está construyendo la gente y evaluar los argumentos sobre por qué es excelente. Si no se puede hacer eso, el proyecto se vuelve mucho menos interesante
Además, hay personas que no usan ninguna de esas herramientas. Incluso mi equipo actual no logra ponerse de acuerdo ni siquiera para adoptar un formateador automático, pero por lo demás es excelente. Así que postergar la publicación mientras se crean esas herramientas no cambia demasiado
La gente sigue intentando crear un “Rust sin lifetimes molestos” y sigue fallando. Otro comentario abordó una de las formas de fallo: el problema de devolver parte de un valor al que se le hizo
dropsurge porque no se pueden devolver referencias. Otro problema clásico es este:Hay tres respuestas:
Hay buenas razones para elegir cualquiera de las tres, pero Jam parece querer ser la opción 1, como Rust, mientras que en la práctica parece ser la 2 por su semántica de valores. Si eso significa que todo se copia, probablemente impida escribir estructuras de datos seguras y eficientes
En particular, si se descarta el verificador de préstamos, se vuelve mucho más difícil soportar tipos asignados en la pila sin introducir varias pistas. Por ejemplo, copiar al prestar; tanto Inko como Swift hacen esto
Al ver la referencia del lenguaje, no hay referencias, pero sí punteros
mutyconst, y no pude encontrar nada sobre su seguridadUna gran parte de lo que hace que Zig sea Zig es que no tiene RAII, y Rust es el verificador de préstamos. Pero no tengo muy claro quién necesita realmente el punto al que llegan estas decisiones de diseño: “RAII sin referencias”
Aun así, creo que hay espacio para experimentar en este nicho, y me parecen bien esos intentos. Solo que no creo que este sea el enfoque
La dirección en la que he estado pensando últimamente es una combinación del
comptimede Zig, permisos de referencia parecidos a los de Pony, tratar los lifetimes como valores en tiempo de compilación y hacer branding de los lifetimes en los asignadoresLo que espero es sumar seguridad de referencias a la estrategia de asignadores de Zig y obtener lifetimes que casi no requieran anotaciones
Me gustan los lenguajes nuevos, pero no me gusta que todo termine siendo un frontend de LLVM. Sé que los backends son difíciles, pero a veces quisiera que también hubiera otras opciones
Suena casi como Swift