wandres.dev
PROGRAMACIÓN A NIVEL DE TIPOS · el sistema de tipos como lenguaje

Const generics: tipos parametrizados por valores

Los genéricos no solo abstraen sobre tipos, también sobre valores constantes: `const N: usize` convierte el tamaño de un array en parte del tipo. Con ello dos vectores de longitudes distintas son tipos distintos, la multiplicación de matrices cuadra sus dimensiones en compilación, y afloran los límites de estable: qué tipos valen como parámetro y por qué la aritmética `[T; N + 1]` aún no compila sin generic_const_exprs.

⏱ 18 min

Un genérico ordinario abstrae sobre tipos: Vec<T> funciona para cualquier T. Pero hay una segunda clase de genéricos que abstrae sobre valores: números, bool o char fijados en compilación. Se llaman const generics, y su forma es const N: usize. Con ellos, el tamaño de un array deja de ser un dato de ejecución y pasa a formar parte del tipo: [f64; 3] y [f64; 4] son tipos distintos, tan distintos como i32 y String. Esto desbloquea algo que antes exigía macros o unsafe: estructuras de tamaño fijo cuyas dimensiones el compilador comprueba —y hace cuadrar— por ti.

🎯 Al terminar esta lección sabrás
  • Parametrizar un tipo por un valor con const N: usize y usarlo como longitud de un array.
  • Escribir funciones y bloques impl genéricos sobre una constante.
  • Comprobar la compatibilidad dimensional —matrices que cuadran— en tiempo de compilación.
  • Conocer los tipos admitidos como parámetro const y la frontera de lo estable.

Genéricos sobre valores

La sintaxis añade const N: usize a la lista de parámetros, y a partir de ahí N es un valor utilizable donde se espera una constante, señaladamente como longitud de un array:

struct Vector<const N: usize> {
    datos: [f64; N],
}

impl<const N: usize> Vector<N> {
    fn ceros() -> Vector<N> {
        Vector { datos: [0.0; N] }
    }
    fn dimension(&self) -> usize {
        N                       // la constante es accesible como valor
    }
}

fn main() {
    let a = Vector::<3>::ceros();   // tipo Vector<3>
    let b = Vector::<4>::ceros();   // tipo Vector<4>, distinto
    assert_eq!(a.dimension(), 3);
    // let _ = a == b;              // ni siquiera son el mismo tipo
}

Vector<3> y Vector<4> son tipos separados con disposiciones de memoria distintas —24 y 32 bytes—. El valor N habita el tipo: es información conocida en compilación, no un campo que se lea en ejecución.

use std::mem::size_of;

fn main() {
    assert_eq!(size_of::<Vector<3>>(), 24);   // 3 por 8 bytes
    assert_eq!(size_of::<Vector<4>>(), 32);   // 4 por 8 bytes, y es otro tipo
}

Que el tamaño viva en el tipo tiene una consecuencia práctica enorme: puedes tener arrays en la pila, sin asignar en el montón, con la longitud garantizada por el compilador. Donde antes recurrías a un Vec —longitud dinámica, un puntero y una comprobación de límites en cada acceso— ahora usas [T; N], con el tamaño fijo y conocido, y a menudo el optimizador elimina las comprobaciones de límites porque puede probarlas.

El tamaño en el tipo: matrices que cuadran

Lo mismo vale para funciones. Una que sume dos arrays exige, por su firma, que ambos midan lo mismo:

fn suma<const N: usize>(a: [i32; N], b: [i32; N]) -> [i32; N] {
    let mut r = [0; N];
    let mut i = 0;
    while i < N {
        r[i] = a[i] + b[i];
        i += 1;
    }
    r
}

fn main() {
    let _ = suma([1, 2, 3], [4, 5, 6]);   // OK, ambos [i32; 3]
    // let _ = suma([1, 2, 3], [4, 5]);   // ERROR: los tamanos N no coinciden
}

El desajuste de longitudes no es un panic: es que no existe un N que satisfaga la firma con [i32; 3] y [i32; 2] a la vez. La joya de la corona es la multiplicación de matrices, donde la dimensión interna debe casar:

struct Matriz<const R: usize, const C: usize> {
    datos: [[f64; C]; R],
}

impl<const R: usize, const K: usize> Matriz<R, K> {
    // R por K multiplicada por K por C da R por C: las dimensiones cuadran en el tipo
    fn por<const C: usize>(&self, otra: &Matriz<K, C>) -> Matriz<R, C> {
        let mut datos = [[0.0; C]; R];
        for i in 0..R {
            for j in 0..C {
                for k in 0..K {
                    datos[i][j] += self.datos[i][k] * otra.datos[k][j];
                }
            }
        }
        Matriz { datos }
    }
}

Multiplicar una Matriz<2, 3> por una Matriz<3, 4> compila y da una Matriz<2, 4>; multiplicarla por una Matriz<5, 4> no compila, porque la K de la izquierda —3— no es la K de la derecha —5—. El álgebra lineal impone que las dimensiones internas coincidan, y aquí esa regla la vigila el compilador, gratis, antes de ejecutar una sola multiplicación.

flowchart LR
a[Matriz R por K] --> m[Producto]
b[Matriz K por C] --> m
k[La dimension interna K debe coincidir o no compila] --> m
m --> r[Matriz R por C]
style m fill:#cba6f7,color:#11111b
style r fill:#a6e3a1,color:#11111b
style k fill:#f38ba8,color:#11111b

Lo que puedes y lo que aún no

Los const generics tienen fronteras nítidas, y conocerlas evita frustración.

Qué tipos valen como parámetro. En estable, un parámetro const debe ser de un tipo entero, bool o char. No puedes aún parametrizar por un tipo propio —un enum o un struct— sin la feature inestable adt_const_params.

Qué puedes calcular con ellos. Puedes pasar una constante y usarla como longitud, [T; N], pero no operar con ella en posición de tipo. Esto todavía no compila en estable:

// Requiere la feature inestable generic_const_exprs
// fn empujar<const N: usize>(a: [i32; N], x: i32) -> [i32; N + 1] {
//     // ... construir un array de tamano N + 1 ...
// }

La aritmética de constantes en los tipos —N + 1, R * C— vive tras generic_const_exprs, aún inestable por los problemas que plantea al comprobador. Por eso el ejemplo de matrices se diseñó con cuidado para no calcular: Matriz<R, K> por Matriz<K, C> da Matriz<R, C> reutilizando los parámetros tal cual, sin sumarlos ni multiplicarlos. Cuando en estable necesites arrays de tamaño derivado, el recurso habitual es introducir el tamaño como otro parámetro const independiente, o apoyarte en crates como typenum, que codifican los números como tipos.

Const generics es la tajada de tipos dependientes que Rust se atrevió a cortar

Lo que estás tocando aquí es la frontera entre los sistemas de tipos convencionales y los tipos dependientes, uno de los territorios más profundos de la teoría de la programación. Un tipo dependiente es, literalmente, un tipo que depende de un valor. En lenguajes como Idris, Agda o Lean, Vec n es el tipo de los vectores de longitud exactamente n, y el compilador puede demostrar que concatenar un Vec n con un Vec m produce un Vec (n + m), o que acceder a la posición i de un Vec n es seguro siempre que i sea menor que n, todo verificado antes de ejecutar. Esa potencia permite codificar en el tipo prácticamente cualquier invariante imaginable, y con ella escribir programas que, si compilan, son correctos por construcción. Pero el precio es brutal: escribir tipos se convierte en escribir demostraciones matemáticas, la inferencia se rinde y a menudo debes guiar al compilador de la mano, paso a paso, como quien redacta una prueba en un cuaderno. Ese coste saca a esos lenguajes del terreno de la programación práctica y los lleva al de los asistentes de demostración. Rust tomó una decisión de ingeniería deliberada y astuta: admitir dependencia de valores constantes conocidos en compilación —no de valores de ejecución cualesquiera— y solo de tipos simples. Es una tajada fina de los tipos dependientes, la justa para que el tamaño de un array viva en el tipo y las dimensiones de una matriz cuadren solas, sin convertir Rust en un demostrador de teoremas. Y la frontera de generic_const_exprs marca con precisión el punto donde esa tajada se vuelve peligrosa: en cuanto permites aritmética arbitraria sobre constantes en los tipos, la igualdad de tipos deja de ser fácil de decidir —¿es N * 2 el mismo tipo que N + N? ¿Y N + M el mismo que M + N?— y el comprobador necesita razonar sobre álgebra, con todo lo que eso arrastra en complejidad y en mensajes de error incomprensibles. Que ese terreno siga inestable años después no es pereza del equipo de Rust: es el reconocimiento sobrio de que cada gramo de poder dependiente que añades cuesta oro en complejidad del compilador y en previsibilidad para quien programa. Los const generics son, así, un retrato del temperamento del lenguaje: coge de la teoría de tipos justo lo que rinde en la práctica y deja el resto, respetuosamente, al otro lado de la puerta.

📝
Lo esencial de los const generics

Los const generics parametrizan tipos y funciones por valores constantes, con la forma const N: usize; su uso canónico es llevar el tamaño de un array al tipo, de modo que [T; N] distinga longitudes. Habilitan estructuras de tamaño fijo y comprobación dimensional en compilación: la multiplicación de matrices cuadra sus dimensiones internas o no compila. En estable, los parámetros const se limitan a enteros, bool y char, y no se puede hacer aritmética con ellos en los tipos —[T; N + 1] exige la inestable generic_const_exprs—. Son una porción medida de los tipos dependientes, elegida para no sacrificar la previsibilidad del compilador.

⚔️ Lleva el valor al tipo
  1. Define Vector<const N: usize> con un array [f64; N], un constructor ceros y un método dimension; crea un Vector<3> y un Vector<4> y comprueba que no son intercambiables.
  2. Escribe fn suma<const N: usize>(a: [i32; N], b: [i32; N]) -> [i32; N] y observa el error al pasar arrays de longitudes distintas.
  3. Implementa Matriz<R, C> y un método por que multiplique Matriz<R, K> por Matriz<K, C>; explica por qué el desajuste de la dimensión interna es un error de compilación.
  4. Investiga qué tipos se admiten como parámetro const en estable y da un ejemplo que requiera adt_const_params.
  5. Explica por qué [T; N + 1] no compila en estable, qué feature lo habilitaría y qué problema teórico plantea la aritmética de constantes en los tipos.