Punk: anatomía de un lenguaje
Punk es un lenguaje de programación que me inventé y que compila a ejecutables nativos: tú escribes Punk, mi compilador —escrito en Python— hace el trabajo por debajo y sale un binario rápido, sin máquina virtual ni intérprete de por medio. No es una herramienta para usar en serio (voy por la versión 0.94); es un sitio donde replico, con mis manos, cómo decide un compilador moderno la vida y muerte de la memoria. Un telescopio para mirar el interior de las máquinas. Catalejo, mi planetario, está escrito en Punk.
Lo que de verdad me quita el sueño no es cómo imprimo algo, sino
quién es el dueño de cada valor y quién lo libera. Punk no tiene
recolector de basura, pero tampoco te obliga a escribir free a mano:
lo decide el compilador, en tiempo de compilación, con un modelo
de ownership implícito al estilo del String de Rust —préstamo
por defecto, move al escapar y .clone() explícito— sobre un
análisis de escape. Esto es lo que hay dentro.
El primer programa
entry es por donde arranca todo (el main).
IO::writeln imprime. Hasta aquí, nada que no hayas visto — la gracia
está debajo del capó.
use IO
entry {
IO::writeln("Hola, mundo")
} El sistema de tipos
Tipado estático y nominal, donde cada primitivo mapea
1:1 al tipo nativo de la máquina —lo que ves es lo que se
ejecuta, sin magia ni boxing escondido—. Todo es inmutable por
defecto: una variable es mut solo si la vas a mutar, y
el checker se queja si declaras mut y nunca la cambias. Encima de
los primitivos construyes record, enum,
arena y refine, con genéricos e invariantes, pero el
cimiento siempre es transparente: el tipo que escribes y lo que corre por
debajo son lo mismo a un paso de distancia.
Subprogramas: una taxonomía
No hay un único «function»: la forma de declararlo es su contrato. Eliges el comportamiento al elegir cómo lo escribes.
fn nombre(p :T) -> :R es pura: retorna valor,
sin IO, sin ref, sin mutar estado externo, y el checker rechaza
cualquier efecto (pure es alias).
nombre(p :T) sin keyword es un bare proc con efectos: puede hacer IO y recibir parámetros
ref, y el parser lo reconoce por el token que sigue al nombre.
[recv] nombre(p :T) es un transform: un método
de record que muta su receptor —el [v] es el identificador, no
hay keyword method—.
predicate nombre = expr es azúcar para una fn -> :Bool
sin parámetros, que se llama sin () en el call site
(rule es alias).
entry es el punto de entrada, el
main del programa: no admite loops directos, la iteración va en
un subprograma que entry llama.
Los tipos básicos
La base se agrupa en cuatro familias. Números: enteros con
signo (:Z) y sin signo (:N), reales
(:R) y complejos (:C). Verdad y
caracteres: el booleano (:Bool, con literales
.t. / .f., nunca true/false),
el carácter ASCII (:Ch) y el codepoint Unicode
(:Rune). Texto: cadenas UTF-8
(:Str). Y la ausencia: el opcional de tres
estados (:T? — valor, nil o error). Eso es toda la base;
el resto se construye encima.
Ownership implícito
La regla de oro: en cada punto del programa, exactamente un scope es dueño de cada objeto del heap. El transpilador sabe quién es y emite el free en el punto correcto. Sin keyword move, sin Box, sin lifetimes anotados.
Tipos valor vs. referencia
Todo tipo se clasifica antes de implementarse. Si cabe en un struct de tamaño fijo (:Z, :R, tuplas, structs sin punteros) es un tipo de valor y se copia. Si contiene un puntero al heap (:Str, :Seq, sets…) es de referencia y nunca se pasa por valor.
Préstamo por defecto, move al escapar
x := y sobre un agregado con campo dueño es un préstamo si x no escapa: comparten el heap, ambos vivos, coste cero. Si x escapa y y ya no se usa, es un move limpio. Si x escapa y y sigue vivo, es error de compilación —te sugiere .clone()—. Nunca un move silencioso.
:Str es siempre una vista
Un :Str escalar nunca es dueño: es un const char* prestado. El dueño es el origen —el array, el campo o el productor que lo creó—. Por eso x := a[0] lee, no destruye ni invalida el origen. Es lo que hace viable el análisis de escape sin un borrow checker completo.
.clone() explícito
La única forma de obtener dos copias profundas independientes. Un lenguaje estable no aloca a tus espaldas: la duplicación de un campo dueño es siempre visible en el código. Es exactamente la salida que sugiere el error del caso anterior.
Análisis de escape
El compilador determina si un valor «se escapa» de su scope (se retorna, se pasa por ref, se guarda en algo que sobrevive) y según eso decide pila o montón, y quién libera. En caso de duda, no libera: un leak acotado es preferible a una corrupción silenciosa.
defer (LIFO)
defer expr se ejecuta al salir del scope, en orden inverso, incluso en returns tempranos. Limpieza determinista sin try/finally.
soldier (RAII)
soldier (f = IO::TextFile = "datos.txt") { ... } abre un recurso y garantiza su cierre al final del bloque, en orden inverso si son varios.
El pool, como respaldo
Para los temporales que el runtime crea (strings de operaciones, líneas de archivo) hay un bump allocator: alocar y liberar son O(1) y no fragmenta. Es un detalle de implementación por debajo del modelo de ownership, no el modelo en sí.
Tipos compuestos
Cómo se construyen estructuras de datos propias. El record es,
además, mi forma de hacer OOP: nada de clases ni herencia, sino métodos por
receptor y composición, muy al estilo de Go.
record
Estructura con campos tipados, y mi puerta a la OOP al estilo de Go: no hay clases ni herencia, solo records con métodos colgados por receptor ([v] muta, lectura presta) y composición —metes un record dentro de otro en lugar de heredar—. Inmutable por defecto (opt-in con mut campo) o todo mutable (record mut, opt-out con ~campo). Soporta genéricos record Vec(:T), invariantes law, y «record update» Tipo{base | campo: val}.
record Persona { nombre :Str mut edad :Z }
record mut Vec2 { x :R y :R ~id :Z }
record Rango { lo :Z hi :Z law lo <= hi } enum
Dos formas: entero (iterable, mapea a #define) o ADT / unión etiquetada cuando al menos una variante lleva datos. No se mezclan en el mismo enum.
enum Dir { Norte=0 | Sur=180 | Este=90 }
enum Figura { Circulo(:R) | Rect(:R, :R) | Punto }
f := Circulo(5.0) arena
Bloque de constantes con arena allocator. Todos los campos inmutables; el acceso es Tipo.campo y compila a static const en C. Puede usar comptime como valor.
arena Fisica {
G :R = 6.674e-11
PI :R = 3.14159265358979
} refine
Alias nominal de tipo, opcionalmente con predicado o conjunto finito. Literales se chequean en compilación; expresiones, con guard en runtime. Dos refines con la misma base no son compatibles.
refine Metros as :Z
refine Nota as :{n:Z | n >= 0 && n <= 100}
refine Cara as :{1,2,3,4,5,6} Control de flujo
Un loop que es while, for clásico e infinito a la
vez; match que toma la primera rama y when que las evalúa
todas; y constructos que en otros lenguajes no existen.
if (y como expresión)
Paréntesis opcionales. Sirve como expresión (etiqueta := if x > 0 { "pos" } else { "neg" }), admite inicializador (if x := f(); x > 0) y narrowing de opcionales (if v? { ... }).
loop
Un solo keyword para todo: loop {} infinito, loop cond {} while, loop ({mut i:=0} i<n {i++}) {} for clásico. break expr lo convierte en expresión; else corre si terminó sin break.
for
Sobre colecciones, rangos (0..9 inclusivo, 0..10< exclusivo, 0..20..2 con paso), con índice (for x, i in col), por referencia (for ref x), con filtro (where), sobre mapas (for (k,v) in m) y anidado (for i,j in 1..3, 1..3).
match
Toma la PRIMERA rama que coincide: valores, rangos, strings, enums y ADT con binding de payload. Sin discriminante (superif) evalúa condiciones booleanas. Sirve como expresión.
when
El complemento de match: evalúa TODAS las ramas cuya condición sea verdadera. else terminal corre solo si ninguna lo fue.
jump / mark
goto estructurado. El checker pre-recolecta etiquetas y detecta saltos a etiquetas inexistentes o hacia adelante.
block
Sub-bloque con nombre, inline en C (mismo stack frame, cero overhead). Ve las variables del host; las suyas son invisibles fuera. Se invoca sin ().
cortocircuito como sentencia
cond || sentencia ejecuta si cond es falsa; cond && sentencia, si es verdadera. Idiomático para guards: x > 0 || return -1.
Contratos
Design by Contract integrado: pre/post son parte de la
firma. Un fallo de precondición es un bug del llamador (aborta); uno de
postcondición, del implementador. Con rescue el fallo se vuelve un
error recuperable. law es un invariante que se verifica en cada
construcción del record.
fn raiz(x :R) r :R
pre x >= 0.0 ;; precondición — fallo = bug del llamador (abort)
post r >= 0.0 ;; postcondición — requiere retorno nombrado
{ r = Math::sqrt(x) }
;; pre recuperable: retorna Err en lugar de abortar
fn dividir(a :R, b :R) -> :R
pre b != 0.0 rescue
{ a / b }
;; invariante de tipo, verificada en cada construcción
record Rango { lo :Z hi :Z law lo <= hi } Colecciones
No una «lista» genérica, sino cada estructura clásica con su implementación real y nombrada — porque la implementación es parte de la lección. Busca por tipo, familia u operación.
18 colecciones
-
:Seq(:T)dinámica array dinámico (ArrayList) .add .pop .insert .remove -
:Stack(:T)dinámica pila LIFO .push .pop .peek .pop? -
:Queue(:T)dinámica cola FIFO .enqueue .dequeue .front -
:Deque(:T)dinámica cola doble .pushFront .pushBack .popFront .popBack -
:Bag(:T)dinámica multiset .add .remove .count -
:Heap(:T)dinámica min-heap (binario) .push .pop .peek .pop? -
:MaxHeap(:T)dinámica max-heap .push .pop .peek -
:DynMatrix(:T)dinámica matriz dinámica en heap dot matmul transpose -
:HashMap(:K,:V)mapa probing lineal con tombstones .set .get .get? -
:ChainHash(:K,:V)mapa encadenamiento con listas .set .get -
:RobinHoodHash(:K,:V)mapa Robin Hood (backward shift) .set .get -
:TreeMap(:K,:V)mapa AVL — iteración ordenada .set .get for ordenado -
:TreeSet(:T)conjunto AVL — O(log n), ascendente .add .contains .remove -
:SortedSet(:T)conjunto array ordenado + binaria .add .contains -
:ChainSet(:T)conjunto encadenamiento .add .contains -
:LinearProbingSet(:T)conjunto probing lineal .add .contains -
:RobinHoodSet(:T)conjunto Robin Hood .add .contains -
:ArraySet(:T)conjunto array naive — O(n), baseline .add .contains
La cara densa
Dónde Punk sintetiza mucho en poco: lambdas, orden superior, operadores Unicode, pipe, comprensiones y azúcar sintáctica.
Lambdas con captura
λ(x :Z) :Z { x * factor } — usada inline captura por valor las variables inmutables del entorno (jamás las mut, lo que elimina el bug de aliasing de closures).
HOF sobre colecciones
.map .filter .reduce .fold .find .partition .any .all .none .enumerate, más agregados .sum .avg .min .max .product y .min_by/.max_by.
Operadores Unicode
Conjuntos: ∩ ∪ ∖ △ ⊆ ⊇ ∈ ∉. Lógicos: ¬ ∧ ∨ ⊕. Y ÷ (división entera), λ (lambda) — alias de sus formas ASCII.
Pipe |>
datos |> normalizar |> escalar(2.0) |> redondear — pasa el valor izquierdo como primer argumento, leído de izquierda a derecha en vez de paréntesis anidados.
Comprensiones
Seq{ x*x | x <- nums, x % 2 != 0 } construye una colección sin loop. El prefijo fija la semántica (Stack, SortedSet…); varios generadores = producto cartesiano.
Interpolación $"..."
say($"π = {pi:.8f}") se reescribe a say("π = {:.8f}", pi) antes del parser. Admite expresiones, especificadores de formato y {{ }} literales.
Destructuring
{x, y} := punto extrae campos de un record por nombre; (si, no) := lista.partition(...) desempaca una tupla anónima; _ descarta.
Reducción N-aria
+(1, 2, 3) ≡ 1 + 2 + 3; *(lista) reduce la colección entera. Un operador (o su alias verbal suma) en posición de función con ≥1 argumento.
Iteración implícita
dibujar(color | i <- 0..99) llama el proc en cada paso del iterable — azúcar para un loop de un solo proc.
Escape hatch a C
Como transpila a C, puedes bajar al metal cuando lo necesites: inyectar C verbatim o usar punteros crudos. Sin red de seguridad — la responsabilidad es tuya.
;; bloque de sentencias C verbatim
unsafe c {
printf("debug raw: %d\n", valor);
}
;; expresión C inline en cualquier posición
x := c!("(int)my_c_function(ptr, 42)")
;; punteros (solo en contextos unsafe / integración con C)
p :Ptr(:Z) = &x
v := *p
*p <- 42 Biblioteca estándar
Módulos disponibles, siempre con prefijo (Math::sqrt(x)).
MathMath::AlgMath::ConstIOStrCliRegexStatsDistTopoColorJsonRayLibAstroPDFPPMSVGTimePhys::ConstBio::ConstChem::ConstCodes::Const Glosario de palabras reservadas
El vocabulario completo del lenguaje. Busca por palabra, familia o descripción.
31 entradas
-
if / elsecondicional; rama alternativa control -
loopbucle; sin condición = infinito control -
foriteración sobre colección o rango control -
returnretorno de valor o salida temprana control -
breaksale del bucle actual (break expr = valor) control -
nextsiguiente iteración (continue) control -
jump / marksalto incondicional y su etiqueta destino control -
deferejecuta al salir del scope (LIFO) control -
whenevalúa todas las ramas verdaderas control -
match / otherswitch que toma la primera coincidencia control -
entrypunto de entrada del programa (main) estructura -
moddeclaración de módulo / submódulo estructura -
fn / purefunción pura que retorna valor subprograma -
predicate / rulepredicado booleano puro subprograma -
recordtipo compuesto (struct) datos -
extendagrega métodos a un tipo existente datos -
refinealias de tipo, opcionalmente con predicado datos -
enumenumeración (entero o ADT) datos -
lawinvariante de tipo datos -
arenabloque de constantes con arena allocator datos -
mutvariable o campo mutable scope -
refpaso por referencia (puntero gestionado) scope -
privdeclaración privada al módulo scope -
given / produceparámetros y retorno nombrado en forma bloque contrato -
pre / postprecondición y postcondición contrato -
rescueconvierte un fallo de contrato en error recuperable contrato -
soldier / guardgestión automática de cierre de recurso (RAII) recursos -
useimporta un módulo o funciones específicas módulos -
comptimeevalúa una fn pura en tiempo de transpilación meta -
unsafe c / c!inyecta C verbatim (sentencias o expresión) meta -
.t. / .f. / .nil. / .inf.verdadero, falso, nulo, infinito literales
Lo que cuesta en el reloj
Todo lo anterior es teoría hasta que algo se ejecuta. Decidir la propiedad en compilación y no arrastrar recolector ni metadatos en runtime tiene un precio que se cobra a favor: lo medí poniendo las mismas tareas, escritas dos veces, a competir contra CPython 3.
16 micro-benchmarks, mismo algoritmo en cada lado, tiempo de pared. Barra corta = más rápido.
Dónde se abre el abismo
El cómputo puro es donde el binario nativo se despega: en
numeric la diferencia es de casi dos órdenes de magnitud
(0.01 s frente a 0.92 s), y encode baja de 8.45 s
a 0.32 s. Sin intérprete de por medio, un bucle apretado es
exactamente un bucle apretado.
Dónde se estrecha
En las operaciones de cadena cortas —split, trim,
join— Punk sigue por delante, pero el margen se reduce a una
fracción de segundo: ahí pesa más el coste fijo de arrancar y leer que el
trabajo en sí.
Las dos con asterisco
fasta y translate mastican un archivo de 2 M
de líneas, y su tiempo lo dicta el disco, no el lenguaje. Las dejo en el
gráfico por honestidad, pero no las leas como mérito del compilador: ahí
ambos esperan al I/O.
No es un duelo serio contra Python —Punk es de un solo usuario, yo— sino la prueba de que el modelo de memoria no se queda en lo bonito: se nota cuando el programa corre.