Retos y aprendizajes en el desarrollo de CSVSC

csvsc es una biblioteca (y framework) para escribir procesadores de archivos CSV. La idea es que defines la entrada, las transformaciones y la salida de forma muy compacta y CSVSC se encarga de hacer las tareas pesadas.

Aunque las primeras líneas las escribí en python por ser el lenguaje en el que más me sentía cómodo en ese momento pronto me di cuenta de que me estaba quedando corto, y que podía sacar ventaja de las características de tipado estático y fuerte que Rust ofrece.

Aquí les platico un poco de lo que aprendí en el proceso.

Desafíos y aprendizajes

Partiendo de la implementación original de la biblioteca en Python la reescritura en Rust fue un gran reto. En un lenguaje tan dinámico se puede uno tomar muchísimas licencias que permiten escribir rápido el código a costa de desempeño o correctitud. El primer reto fue lo que vamos a llamar jaripeo de tipos (del inglés type rodeo).

Jaripeo de tipos

Una cosa que hice fue diseñar CSVSC alrededor de un trait, RowStream, un poco en analogía con el trait Iterator de la biblioteca estándar, de manera que pudiera implementar ese trait en algunas estructuras y añadirle métodos y lograr llamadas encadenadas que describieran el flujo de la información y las sucesivas transformaciones que se le van realizando:

let mut chain = InputStream::from_paths( .. )

    // Esto utiliza una estructura `FilterCol`
    .filter_col(/* .. */).unwrap()

    // Esta es una estructura `Add`
    .add(/* .. */).unwrap()

    // Y bueno, ya entendiste la idea
    .group(/* .. */)

    // ...
    .flush(/* .. */).unwrap()

    .into_iter();

while let Some(item) = chain.next() {
    item.unwrap();
}

Cada nueva transformación consume la anterior de forma genérica, tiene que ser así para poder encadenarlas en cualquier orden arbitrario y eso quiere decir que tengo que restringir los tipos a cumplir ciertas propiedades.

El jaripeo de tipos consiste en este juego salvaje con el compilador en el que tu crees que tus tipos de datos ya satisfacen todas las cotas necesarias y el compilador cree que no. Cuando además estás diseñando una biblioteca puedes encontrarte quieriendo hacer algo como:

error[E0277]: the trait bound `Olmo: DaPeras` is not satisfied
  --> cosa.rs:11:9
   |
5  | fn foo<D>(a: D)
   |    --- required by a bound in this
6  | where
7  |     D: DaPeras,
   |        ------- required by this bound in `foo`
...
11 |     foo(Olmo { });
   |         ^^^^^^^^ the trait `DaPeras` is not implemented for `Olmo`

error: aborting due to previous error

Los traits son la onda

¿Cómo le hace uno para alcanzar el dinamismo de Python en un lenguaje tan estático como Rust?

Usando traits.

En vez de utilizar tipos concretos como campos en muchas de las estructuras que usé en la biblioteca utilicé traits para hacer las estructuras genéricas sobre su entrada o sobre una parte de su comportamiento. Por ejemplo para agrupar los registros por algún criterio la función .group() de RowStream toma como primer argumento el criterio de agrupación, que es genérico sobre un tipo G

fn group<F, R, G>(
    self,
    grouping: G,
    f: F,
) -> Group<Self, F, G> {
    // ...
}

y éste a su vez está restringido a cumplir con el trait GroupCriteria. Ya en el código puedes hacer cosas como agrupar por columnas específicas:

.group(["region", "month"], |row_stream| {
    // ...
})

o agrupar usando un criterio completamente arbitrario con un closure:

.group(GroupBy::closure(|headers, row| {
    // ..
}), |row_stream| {
    // ...
})

Y para lograr esto lo único que tuve que hacer fue implementar el trait GroupCriteria para los tipos nativos [&str; N] y para mi struct GroupBy. ¿No es esto maravilloso?

Diseño de la API

Como tenía un poco el tiempo encima, lo que hice primero fue construir sobre la idea o metáfora del stream de filas y tratar de hacer funcionar la biblioteca para mi, que era creador y usuario, sin ponerle mucha atención a los detalles. Esto causó que la primera API de la biblioteca fuera horrible y necesitara de muchísimos structs para ser utilizada y de esos structs había que conocer muchos campos y sus funcionamientos internos.

En la última ocasión que tuve la oportunidad de usar mi biblioteca para un proyecto me decidí a darle una API más bonita y ergonómica, porque además ya había olvidado cómo chingáus se utilizaba y la documentación que había escrito era escasa. Para esto seguí estos tres sencillos pasos que me gustan como una metodología de diseño de APIs por lo menos en Rust.

  1. Escribe código que use la biblioteca, aunque no compile, pero que refleje cómo quieres usarla. Aquí se pueden explorar ideas sobre ergonomía, traits, genéricos y patrones.
  2. Haz feliz al compilador. En esta parte va a haber muchos unimplemented!() quizá, pero solo se trata de que el código compile. Quizá se descarten algunas ideas del paso anterior por imposibles o por incómodas. Aquí suceden los ajustes finales del diseño. Quizá comenzar a escribir algunas pruebas unitarias de los casos que se va uno imaginando que sean truculentos sea buena idea.
  3. Haz que funcione. No más unimplemented!(). Es momento de escribir las pruebas que faltan y hacer que pasen. Ya no hay trampas ni trucos, pero tampoco hay mucho más que pelear con el compilador. La batalla está ganada, ya solo queda el papeleo.

Lo que sigue

Aun no estoy completamente satisfecho con el funcionamiento interno de la biblioteca, me parece que depende mucho de la estructura del crate csv y eso podría estar limitando explorar otras posibilidades, como establecer un tipo para una columna en alguna parte del proceso y mantenerlo en las subsecuentes.

También falta explorar el poder tener entrada y salida desde otros formatos, con lo cual csvsc pasaría a ser más bien un framework de procesamiento de datos que una biblioteca exclusiva para csv.