Un campo denso de cajones de encofrado de madera, escaleras y andamios que suben en bloques escalonados al pie de una pared rocosa de cañón, con pequeñas figuras de obreros arriba
Series · Concrete

Lo que Concrete empeora

Las restricciones de Concrete tienen costos reales. La limpieza lineal es verbosa, las closures con capturas ocultas no existen y el ecosistema todavía está verde. Esto es lo que el lenguaje realmente hace más difícil.

9 min de lectura

Sobre la imagen Colar la represa en bloques separados, cada uno con sus propios encofrados y caños de refrigeración, era lento y caro, y era el precio de un hormigón que no se agrietara al enfriarse. La limpieza verbosa y las comodidades que le faltan a Concrete son esa clase de costo, pagado a propósito. Encofrado de madera para las columnas de la represa Hoover, mirando aguas arriba desde el lado de Nevada, julio de 1933. Foto: fotógrafo desconocido del U.S. Bureau of Reclamation, dominio público, vía Wikimedia Commons (National Archives 293931).

Traducción automática del original en inglés, todavía sin revisar. Leer el original.

Nota de la serie: esta es la entrega sobre las concesiones (tradeoffs) en la serie Concrete. Para los fundamentos, empezá por Por qué existe Concrete. Para la demo más práctica, leé Cuando el compilador es el oráculo.

Los artículos anteriores de esta serie sostuvieron que las restricciones de diseño de Concrete valen la pena. Las capabilities (capacidades) explícitas hacen que el código sea auditable. Los tipos lineales evitan fugas de recursos en tiempo de compilación. Que no haya comportamiento oculto significa que el compilador puede informar lo que tu programa realmente hace. Creo todo eso. Pero hace suficiente tiempo que escribo código en Concrete como para saber dónde muerden las restricciones, y no fui lo bastante honesto sobre eso en público.

Este artículo es sobre lo que Concrete empeora. No en teoría, no como un abstracto “es más estricto”. Código concreto que es más feo, más largo o más penoso de escribir en Concrete que en Rust o Zig. Si estás evaluando si estas concesiones valen la pena para tu dominio, merecés ver el costo de entrada.

#La limpieza lineal es verbosa y repetitiva

En Rust, RAII se encarga de la limpieza (cleanup) de recursos. Abrís un archivo, lo usás, y cuando termina el scope el compilador inserta una llamada a Drop. Nunca escribís la limpieza. Tres recursos, cero líneas de limpieza:

fn process(path: &str) -> Result<Report, Error> {
    let config = File::open("config.toml")?;
    let input = File::open(path)?;
    let mut output = File::create("report.txt")?;

    let settings = parse_config(config);
    let data = read_all(input);
    let report = analyze(&settings, &data);
    write!(output, "{}", report)?;
    Ok(report)
}

En Concrete, cada recurso necesita limpieza explícita. Los valores con dueño tienen que consumirse exactamente una vez. Si te olvidás, el programa no compila. Ese es el punto, pero así es como se ve:

fn process(path: &String) with(File, Alloc) -> Result<Report, Error> {
    let config = open("config.toml")?
    defer destroy(config)

    let input = open(path)?
    defer destroy(input)

    let output = create("report.txt")?
    defer destroy(output)

    let settings = parse_config(&config)
    defer destroy(settings)

    let data = read_all(&input)
    defer destroy(data)

    let report = analyze(&settings, &data)
    write(&mut output, &report)?

    Ok(report)
}

Seis líneas de defer destroy. La lógica de la función es la misma, pero la mitad de las líneas son ceremonia de limpieza. El código real de Concrete se ve así cuando trabajás con varios recursos. La proporción empeora a medida que las funciones se vuelven más complejas.

Es tentador decir “bueno, al menos podés ver cada punto de limpieza”. Es cierto. Es la razón por la que funciona el reporte de asignaciones de memoria (allocation), la razón por la que los auditores pueden seguir los lifetimes de los recursos sin leer la implementación de cada tipo, la razón por la que el experimento del oráculo pudo identificar mecánicamente asignaciones innecesarias. Pero cuando estás escribiendo el código, sentís el peso.

En Rust, confiás en que Drop corre al salir del scope y seguís adelante. En Concrete, pensás en el orden de destrucción, tipeás defer destroy para cada binding con dueño, y de vez en cuando te quedás mirando una función preguntándote si hay forma de factorizar la ceremonia. Normalmente no la hay.

El peor caso son los caminos de error. Si una función abre el recurso A, después intenta abrir el recurso B y falla, la propagación del error con ? ejecuta la limpieza diferida de A. Esa parte funciona. Pero si necesitás limpieza condicional, con distintos caminos que son dueños de distintos subconjuntos de recursos, el checker de linealidad te obliga a manejar cada caso explícitamente. El Drop de Rust maneja esto de forma invisible. Concrete te hace escribirlo.

Creo que la concesión es la correcta para los dominios a los que apunta Concrete. Pero ya no lo describo como “más molesto de escribir” como si fuera una incomodidad menor. Es un costo ergonómico sustancial que pagás en cada función que maneja recursos.

#No tener closures con capturas ocultas perjudica la composición

En Rust, filtrar una lista es una línea:

let active: Vec<_> = users.iter().filter(|u| u.is_active()).collect();

En Concrete no hay closures en el sentido de Rust o JavaScript. No hay lambdas con capturas invisibles. Escribís una función con nombre y la pasás:

fn is_active(user: &User) -> Bool {
    return user.active
}

let active: Vec<User> = filter<User>(&users, is_active) with(Alloc)

Esto está bien para is_active. Es un predicado con significado que merece un nombre. ¿Pero qué pasa con filtrar por un umbral que cambia?

En Rust:

let expensive: Vec<_> = items.iter().filter(|i| i.price > threshold).collect();

La closure captura threshold del scope que la rodea. Una línea, y es obvio lo que hace.

En Concrete no podés capturar implícitamente. La función que le pasás a filter solo puede usar sus argumentos. Si necesita el umbral, lo tenés que decir: pasarlo como otro argumento, escribir una función auxiliar especializada, o pasar un puntero a función explícito con un valor de contexto explícito. El trabajo reciente sobre valores invocables hace que esa última opción sea mucho más usable que cuando se escribió este artículo por primera vez. Igual no se siente como una closure de Rust, y ese es el punto. El contexto es visible. Las capabilities del callback son visibles. El compilador puede ver la forma de lo que estás haciendo.

La justificación es real. Las closures son capturas ocultas. Una closure que captura una referencia mutable es aliasing implícito. Una closure que captura un valor con dueño es un move implícito. Una closure que captura por clone es una asignación implícita. En Concrete, todo el flujo de datos es visible: los argumentos de la función entran, los valores de retorno salen. Nada se pasa de contrabando a través de un entorno capturado.

Pero la expresividad tiene un piso. Por debajo de ese piso, el código deja de ser claro y empieza a ser burocrático. Transformaciones de datos simples, cadenas de map/filter/reduce, patrones de callbacks, handlers de eventos: todo eso es natural con closures y más pesado cuando el estado del callback tiene que ser explícito. Concrete todavía está por debajo del piso ergonómico para esta clase de problemas.

La respuesta que adoptó Concrete no es meter las closures de vuelta por la ventana. Son los callbacks ligados (bound callbacks): un puntero a función explícito más un contexto explícito, con las capabilities en el tipo del callback y con los borrows acotados impedidos de escaparse. Es menos agradable mientras escribís el código, pero mucho más fácil de auditar después. Preserva lo que a Concrete más le importa: nada de flujo de datos oculto.

#El ecosistema que falta

Si probás Concrete hoy, te vas a chocar con paredes que no tienen nada que ver con el diseño del lenguaje.

No hay gestor de paquetes. Las dependencias son manuales. El formateador ya existe, pero todavía es joven comparado con rustfmt o zig fmt. No hay LSP, así que tu editor te da poco: nada de autocompletado maduro, nada de errores semánticos en línea, nada de go-to-definition. La biblioteca estándar tiene más de 30 módulos, lo que suena a mucho hasta que necesitás algo que no cubre y te das cuenta de que lo estás escribiendo desde cero o llamando a C por FFI.

Rust tiene crates.io, cargo, rustfmt, rust-analyzer y una biblioteca para casi cualquier cosa. Zig tiene un gestor de paquetes y un ecosistema que crece. Concrete tiene un compilador, un runner de tests, un formateador y herramientas tempranas de auditoría y pruebas.

Este es un problema de madurez, no de diseño. El compilador funciona. El lenguaje es real. Pero la infraestructura alrededor, la que hace que un lenguaje sea vivible para el laburo diario, está en sus comienzos. Si elegís Concrete para un proyecto hoy, te estás anotando para construir parte de esa infraestructura vos mismo, o para esperar.

No voy a hacer de cuenta que esto no importa. Las herramientas no son secundarias al diseño del lenguaje. Un formateador inmaduro igual deja el estilo y la integración con el editor sintiéndose ásperos. Un lenguaje sin LSP significa ciclos de feedback más lentos. Un lenguaje sin gestor de paquetes significa que manejar dependencias es trabajo manual. No son lujos. Son la diferencia entre un lenguaje que podés recomendar y uno que usás solo.

El plan es construir el resto. El LSP es la próxima gran deuda de calidad de vida. Un gestor de paquetes está más lejos. Pero los planes no son herramientas, y prefiero ser honesto sobre lo que existe hoy antes que dejar que alguien descubra los huecos después de haberse comprometido.

#El costo y la recompensa vienen de la misma fuente

Cada punto de dolor de este artículo se remonta a las mismas decisiones de diseño que hacen posibles las fortalezas de Concrete.

La limpieza lineal es verbosa porque cada lifetime de recurso es explícito. Esa explicitud es la razón por la que funciona el reporte de asignaciones, por la que el compilador te puede decir exactamente dónde ocurre la asignación y a través de qué cadena de llamadas.

No tener closures con capturas ocultas duele porque elimina un patrón de composición natural. Esa eliminación es la razón por la que todo el flujo de datos es visible, por la que las capabilities de los callbacks se pueden seguir a través del grafo de llamadas, por la que la superficie de prueba no está contaminada por capturas invisibles.

El ecosistema que falta es el costo de construir un lenguaje nuevo en lugar de extender uno existente. Esa independencia es la razón por la que la gramática es LL(1), por la que las capabilities están incorporadas desde el principio, por la que el compilador puede ser un oráculo en lugar de un portero.

No son concesiones separadas; son la misma vista desde distintos ángulos. No podés tener los reportes sin la verbosidad. No podés tener el grafo de llamadas rastreable sin hacer explícito el estado de los callbacks. No podés tener un lenguaje diseñado para el razonamiento por máquinas sin arrancar de cero.

Para firmware, fronteras de seguridad, políticas criptográficas y componentes críticos para la seguridad, sigo creyendo que la concesión es la correcta. Los artículos anteriores de esta serie explican por qué. Este explica cuánto cuesta. Las dos cosas son ciertas al mismo tiempo.

Escrito con un LLM, como todo lo de este sitio. Las ideas y los errores son míos. Cómo escribo.