Ir al contenido
  1. Posts/

Los traits también son gramáticas: una idea de diseño que no me soltaba

·8 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida

En la serie Todo CLI, me pasé un capítulo entero diseñando un único trait: TaskRepository. Cuatro métodos. Ningún detalle de implementación. En ese momento lo conté como trabajo de arquitectura: definir un contrato que separara lo que la aplicación necesita de cómo la infraestructura lo resuelve.

los-traits-también-son-gramáticas-del-diseño-de-contratos-a-la-sintaxis-formal-img-35.png

Eso era cierto, pero no era toda la historia.

Visto con un poco de distancia, cada decisión en esas cuatro firmas también era una decisión sobre lo que puede expresarse en la frontera entre aplicación y persistencia. Y cuando lo vi así, ya no pude dejar de verlo: eso es exactamente lo que hace una gramática.

Antes de llegar ahí, conviene dejar muy explícita la separación entre contrato e implementación.

Contrato versus implementación
#

los-traits-también-son-gramáticas-del-diseño-de-contratos-a-la-sintaxis-formal-img-36.svg

El contrato es el trait. Dice qué necesita la aplicación: cuatro operaciones con tipos precisos, sin mencionar JSON, archivos, rutas ni serde. Es la forma de la dependencia, expresada en términos de dominio.

La implementación es el adaptador. Decide cómo cumplir ese contrato con una tecnología concreta. En el proyecto Todo CLI hay dos:

  • InMemoryTaskRepository, un HashMap<Uuid, Task> en memoria. Rápido, determinista y sin efectos secundarios. Ideal para tests.
  • JsonFileTaskRepository, que lee y escribe JSON en disco usando serde_json y el crate directories para rutas multiplataforma. Este es el adaptador de producción.

Ambos implementan el mismo trait. La capa de aplicación no sabe, ni necesita saber, con cuál está hablando.

Esta separación te da tres cosas muy prácticas:

  1. Intercambiabilidad. Puedes cambiar JSON por SQLite, una API remota o cualquier almacenamiento futuro sin tocar la lógica de negocio. Solo añades un nuevo impl TaskRepository.
  2. Testabilidad. Los tests usan el adaptador in-memory: rápido, reproducible y sin limpiar el sistema de archivos. Producción usa el adaptador JSON. El código de aplicación sigue siendo el mismo.
  3. Errores acotados. El contrato define RepoResult como tipo de error en la frontera. Cada adaptador traduce sus fallos internos, como I/O, serialización o permisos, a ese tipo. La cadena de errores queda limpia: RepoError -> ApplicationError::Repository -> CliError::Application, sin unwrap() ni panic!().

Ese encuadre arquitectónico me sigue pareciendo correcto. Pero últimamente me interesa aún más el siguiente paso.

Las firmas como reglas de producción
#

Mira el trait otra vez:

pub trait TaskRepository {
    fn save(&mut self, task: Task) -> RepoResult<()>;
    fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>>;
    fn find_by_id(&self, id: Uuid) -> RepoResult<Option<Task>>;
    fn delete(&mut self, id: Uuid) -> RepoResult<bool>;
}

Cuatro métodos. Cuatro formas posibles de conversación entre la aplicación y la persistencia. Si entrecierras un poco los ojos, esto es una gramática. No una gramática para cadenas, sino una gramática para interacciones. Los métodos son las reglas de producción. Los tipos en las firmas son los terminales y no terminales. Juntos, definen el vocabulario de lo que puede decirse a través de esa frontera.

Cuando elegí Option<Task> para find_by_id en lugar de Result<Task, NotFoundError>, estaba tomando una decisión gramatical: “no encontrado” no es una oración de error en este lenguaje, es una palabra válida. Cuando elegí bool para delete en lugar de Option<Task>, decidí que la eliminación respondiera con una señal de sí o no, no con un eco completo del valor borrado. Eso no son detalles de implementación. Es parte de la sintaxis del contrato.

La brecha que ya sabíamos que existía
#

En ese mismo capítulo, definí RepoError:

#[derive(Debug, Error)]
pub enum RepoError {
    #[error("Repository internal error: {error}")]
    InternalError { error: String },
}

Ya lo había señalado entonces: una sola variante, con un payload String libre. Intencionalmente genérico. Intencionalmente impreciso. Suficiente para el alcance del proyecto, pero claramente un trade-off.

Desde la perspectiva de la gramática, ese String es justo donde se ve la grieta. RepoError::InternalError { error: String } es sintácticamente válido para cualquier cadena. Un fallo de I/O, un error de serialización, un problema de permisos, o "colorless green ideas sleep furiously". La gramática acepta todos esos casos. No puede distinguir un mensaje útil de uno sin sentido.

Es la misma brecha que aparece con algo como OrderStatus::Shipped { tracking: String::new() }. El sistema de tipos dice que la forma es válida. El dominio dice que el contenido no tiene sentido. La gramática controla la estructura, no el significado.

los-traits-también-son-gramáticas-del-diseño-de-contratos-a-la-sintaxis-formal-img-37.svg

Las implementaciones como semántica
#

Si el trait es la gramática, las implementaciones son la semántica. Son las que asignan significado a las formas que la gramática permite. La gramática te da oraciones válidas. La semántica te dice qué hacen esas oraciones.

Pensemos en save. El trait dice: “esta operación toma un Task por valor y devuelve RepoResult<()>.” Esa es la forma sintáctica. No dice nada sobre qué ocurre realmente con la tarea. Puede escribirse en disco, quedarse en memoria, enviarse por red o descartarse en silencio. La gramática permite todas esas posibilidades. El impl es el que elige.

InMemoryTaskRepository le da a save un significado: insertar la tarea en un HashMap indexado por ID. Si ese ID ya existe, se sobreescribe. Sin I/O, sin serialización. El efecto es inmediato y el estado solo vive mientras el proceso existe.

// Semántica A: inserción en memoria
impl TaskRepository for InMemoryTaskRepository {
    fn save(&mut self, task: Task) -> RepoResult<()> {
        self.cache.insert(task.task_id(), task);
        Ok(())
    }
}

JsonFileTaskRepository le da a save otro significado: leer el archivo JSON actual desde disco, deserializarlo en un Vec<Task>, insertar o reemplazar la tarea por posición, serializar otra vez el vector completo y escribirlo de vuelta de forma atómica. Misma firma, save(&mut self, task: Task) -> RepoResult<()>, significado operativo muy distinto.

// Semántica B: ciclo lectura-modificación-escritura en disco
impl TaskRepository for JsonFileTaskRepository {
    fn save(&mut self, task: Task) -> RepoResult<()> {
        let mut tasks_file = self.read_task_file()?;

        if let Some(index) = tasks_file
            .tasks
            .iter()
            .position(|stored| stored.task_id() == task.task_id())
        {
            tasks_file.tasks[index] = task;
        } else {
            tasks_file.tasks.push(task);
        }

        self.write_tasks_file(&tasks_file)
    }
}

los-traits-también-son-gramáticas-del-diseño-de-contratos-a-la-sintaxis-formal-img-38.svg

Misma oración, interpretación distinta. Esa es la relación entre sintaxis y semántica en pequeño. La gramática genera save(task). La implementación in-memory la interpreta como una inserción en un HashMap. La implementación JSON la interpreta como un ciclo de lectura, modificación y escritura en disco. Ambas cumplen el trait. Ambas son válidas bajo la gramática. Pero está claro que no significan lo mismo.

Por eso creo que esta separación importa por algo más que prolijidad arquitectónica. Cuando el trait es la gramática y el impl es la semántica, puedes cambiar el significado sin cambiar el lenguaje. Puedes pasar de in-memory a JSON a SQLite, y la aplicación sigue hablando con las mismas oraciones: save, list, find_by_id, delete.

Y ahí aparece el puente con la serie Sintaxis y Semántica: cuando empiezas a ver los tipos como sintaxis y las implementaciones como interpretación, el salto desde un puerto de repositorio en Rust hasta las ideas de lenguajes formales deja de parecer tan grande.

Los traits como gramáticas de frontera
#

Esta es la conexión que quería hacer explícita. Un trait en Rust no es solo una interfaz en el sentido OO. También es una gramática para una frontera entre módulos. Define:

  • Qué operaciones son expresables, mediante los métodos.
  • Qué entradas acepta cada operación, mediante los tipos de parámetros.
  • Qué salidas puede producir cada operación, mediante los tipos de retorno.
  • Qué efectos son visibles, mediante la distinción entre &self y &mut self.

Y como cualquier gramática, tiene límites. Define qué formas son posibles, no lo que esas formas significan. Un trait no puede imponer que save se llame antes de delete. No puede imponer que list devuelva resultados consistentes con llamadas previas a save. No puede imponer que el String dentro de RepoError lleve información diagnóstica útil. Esas son restricciones semánticas. El trait te da sintaxis, no significado.

Los mejores diseños de traits intentan empujar más corrección hacia la gramática. Elegir Option<Task> en lugar de Result<Task, NotFoundError> no es cosmético. Quita del lenguaje una clase de errores sin sentido. Elegir TaskQuery en lugar de booleanos sueltos reduce el espacio de entradas válidas. Cada decisión ayuda a achicar la distancia entre lo que puede decirse y lo que debería decirse.

Pero esa brecha nunca desaparece del todo, y no pasa nada. En la práctica, me parece más honesto nombrarla, ya sea como deuda técnica o como frontera deliberada de diseño, que fingir que el trait cubre más de lo que realmente cubre.

Un hilo que merece la pena seguir
#

Si esta manera de mirar el problema te hace clic, el siguiente paso natural es la serie Sintaxis y Semántica. Esa serie arranca con la jerarquía de Chomsky, mapea reglas de producción BNF a enums de Rust y sealed traits de Scala, y sigue la misma tensión de fondo: la distancia entre lo sintácticamente válido y lo semánticamente significativo.

La parte curiosa es que todo este post salió de volver a mirar un trait que ya tenía escrito y darme cuenta de que estaba diciendo más cosas de las que yo creía.

Al principio pensé que solo estaba trazando una frontera arquitectónica limpia. Unas semanas después, se hizo evidente que también estaba definiendo un lenguaje pequeño, con su propia gramática, sus propios límites y su propio espacio para el sinsentido.

Y, siendo honesto, creo que esa es una de las razones por las que sigo volviendo a Rust. Tiene esa capacidad de convertir decisiones pequeñas de diseño en ideas más grandes si te quedas el tiempo suficiente mirándolas.