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

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

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.

Comparativa de tiempos por tarea entre Python 3 y Punk; barras más cortas son más rápidas

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.