CSVSC y los const generics

Hace nada más que dos días se publicó oficialmente la versión 1.51 del lenguaje de programación Rust, y con ella la liberación de una de las pocas características que he estado esperando con ansias desde que me di cuenta de que la necesitaba.

Para dar un poco más de contexto hay que saber que en mi crate csvsc, que permite construir procesadores de archivos csv de forma trivial, hay una característica para agrupar filas por ciertos criterios para después calcular algunas estadísticas como máximos, mínimos, promedios etcétera. La función se ve más o menos así:

// agrupa filas por la columna name
.group(["name"], |row_stream| {
    // Calcula algunos agregados, qué se yo
})

En la última revisión que hice de esta característica decidí que quería que fuera particularmente cómoda de usar, de manera que filtrar por una o varias columnas no debería tener mayor fricción, así como agrupar por un criterio arbitrario.

Para lograrlo se me ocurrió este esquema de dos parámetros, el primero que sería el criterio de agrupación y el segundo que sería un closure con las acciones a realizar sobre las filas agrupadas.

fn group<C, F>(criterio: C, closure: F) -> impl RowStream {
    // some rust magic
}

La pregunta aquí es ¿Cuál debería ser la cota sobre C para lograr mi objetivo? ¿Existe dentro de los tipos nativos de Rust una cota que me permita recibir slices, arreglos de cualquier tamaño y closures?

La triste respuesta es que no. Arreglos, slices y closures son cosas bastante diferentes y no proveen una interfaz común abstraída dentro de un trait que me permita darle una buena cota a C para lograr mi objetivo. Sin embargo, lo que sí puedo hacer es inventar mi propio trait y usarlo como cota para C. Luego implementarlo para los tipos foráneos [&str], [&str; N] y closures y pum! Magia para todos.

Contento con mi solución fui corriendo a escribir mi trait:

pub trait GroupCriteria {
    /// Compute the hash of a given row as u64.
    fn group_for(&self, headers: &Headers, row: &Row) -> u64;
}

Y a implementarlo para slices de strings:

impl GroupCriteria for [&str] {
    fn group_for(&self, headers: &Headers, row: &Row) -> u64 {
        let mut hasher = DefaultHasher::new();

        self
            .iter()
            .filter_map(|col| headers.get_field(row, col))
            .for_each(|c| c.hash(&mut hasher));

        hasher.finish()
    }
}

entonces con la función group() y adjacent_group() de RowStream las cotas quedan así:

fn group<F, R, G>(grouping: G, f: F) -> impl RowStream
where
    F: Fn(MockStream<vec::IntoIter<RowResult>>) -> R,
    R: RowStream,
    G: GroupCriteria,
{
    // magic
}

Y entonces sí, llegamos al primer segmento de código de esta entrada, que se ve así:

// agrupa filas por la columna name
.group(&["name"], |row_stream| {
    // Calcula algunos agregados, qué se yo
})

Excepto que si se fija uno con cuidado hay un caracter extra en el criterio de agrupación, un símbolo raro que no tiene ningún otro propósito que el de satisfacer la cota G: GroupCriteria dado el impl que acababa de hacer. Se trata del & en &["name"]. Tiene que estar ahí pues si no está entonces no se trata de un slice, sino de un arreglo de un solo elemento, y para este tipo no hice ninǵun impl.

Entonces voy corriendo al código y me precipito a escribir el impl obvio:

impl GroupCriteria for [&str; 1] {
    fn group_for(&self, headers: &Headers, row: &Row) -> u64 {
        GroupCriteria::group_for(&self[..], headers, row)
    }
}

Con lo cual ya podría regresar a mi código y quitar ese horrible signo de & que me estaba molestando en primer lugar. Aunque seguramente ya a estas alturas estás sospechando el problema real.

En efecto, mi nuevo impl funciona para arreglos de tamaño 1, pero si quiero filtrar por dos columnas necesito hacer el impl para [&str; 2] y así y así.

Claro que una solución factible era escribir una macro que hiciera el impl para arreglos, digamos, de tamaños 1 al 32 (como por mucho tiempo existieron en el crate std de rust), pero esta solución nunca me hizo feliz. Investigué un poco sobre cómo se hacían este tipo de trucos en la biblioteca estándar y noté que de hecho ya se utilizaban const generics en nighly, aprendí sobre su uso e inmediatamente entendí que eran lo que quería.

Como no me gustaría hacer de rust nightly un requisito para usar mi create (en parte por experiencia propia con rocket) lo que decidí fue esperar pacientemente a que este feature se estabilizara. Al fin que hasta fecha tenía: iba a salir en rust 1.51.

Const generics al rescate

¿Y cómo me ayuda este bicho? Const generics es una característica que permite hacer código genérico sobre los valores de un tipo. Con los generics pelones podía hacer código genérico sobre varios tipos, pero hasta ahora no sobre los valores de algún tipo en particular.

Si recordamos mi problema de implementar un trait sobre arreglos de diferentes tamaños, aun en el escenario en el que escribía una macro habría tenido que limitarme a arreglos de ciertos tamaños, de lo contrario habría tenido que correr la macro para arreglos de todos los tamaños y eso tomaría muchísimo tiempo de compilación, pues hay muchísimos tamaños de arreglos (entre 2³² y 2⁶⁴ según la plataforma) dados por el tipo usize que se usa para indexarlos.

Con const generics se puede hacer código genérico sobre los valores de un tipo, por ejemplo los valores de usize que se usan para determinar el tamaño de un arreglo. Entonces puedo escribir:

impl<const N: usize> GroupCriteria for [&str; N] {
    fn group_for(&self, headers: &Headers, row: &Row) -> u64 {
        GroupCriteria::group_for(&self[..], headers, row)
    }
}

Y el impl sería mágicamente válido para [&str; 3] como para [&str; 412]. Y entonces sí, sin símbolos extra, se puede usar el código que mencioné al princio:

.group(["country", "month", "altitude"], ...)

¿Y los closures apá?

Que bueno que preguntas, porque de hecho hasta este preciso momento pensé que no se podía (lo había intentado, jugando al jaripeo de tipos con mis impls, y fracasado miserablemente). Mientras escribo esta entrada se me ha ocurrido volver a intentarlo y ¿qué creen? Lo logré. Así quedó el impl.

impl<H, F> GroupCriteria for F
where
    F: Fn(&Headers, &Row) -> H,
    H: Hash,
{
    fn group_for(&self, headers: &Headers, row: &Row) -> u64 {
        let mut hasher = DefaultHasher::new();

        let hashable = (self)(headers, row);

        hashable.hash(&mut hasher);

        hasher.finish()
    }
}

Y ahora, ante la incredulidad de sus ojos, puedo pasar como primer argumento de la función .group() tanto un slice (&[&str]) como un arreglo de cualquier tamaño ([&str; N]) como un closure alv (Fn(&Headers, &Row) -> Hash). Así se ve agrupar por un criterio arbitrario usando un closure:

.group(|headers: &Headers, row: &Row| {
    // Haz magia aquí para decidir el grupo al que pertenece esta fila
}, |row_stream| {
    // Haz magia con todas las filas de un mismo grupo
})

Y tan tan.