PhantomData: llevar información de tipo sin ocupar memoria
Un parámetro genérico que no aparece en ningún campo no compila, y a veces el compilador necesita saber que posees un T aunque no lo guardes. PhantomData es un marcador de tamaño cero que reintroduce esa información de tipo: satisface la regla de uso, dirige el drop check, fija la varianza y decide Send y Sync, todo sin costar un solo byte.
A veces necesitas un parámetro de tipo que no guardas. Un identificador que sea de usuario o de producto según a qué apunte, aunque por dentro sea el mismo u64. Un envoltorio sobre un puntero crudo que posea lógicamente un T sin tener un campo T. Rust, sin embargo, no te deja declarar un parámetro genérico y no usarlo: es un error de compilación. Y aunque te dejara, el compilador necesitaría saber cosas sobre ese T —si lo posees, cómo varía, si es seguro compartirlo entre hilos— que no puede adivinar de la nada. PhantomData<T> resuelve ambos problemas a la vez: es un campo fantasma, de tamaño cero, que le dice al compilador compórtate como si aquí hubiera un T, sin que lo haya.
- Explicar el error
E0392y cómoPhantomData<T>satisface la regla de uso de un parámetro. - Construir marcadores de tipo distintos y sin coste: identificadores tipados y unidades.
- Señalar la propiedad de un
Tal drop check en tipos con punteros crudos. - Elegir la forma de
PhantomDataque fija la varianza deseada sobreT.
El parámetro que no aparece
Declara un struct genérico cuyo parámetro no toque ningún campo y el compilador te frena en seco:
struct Marca<T> {
nombre: String,
}
// error[E0392]: parameter `T` is never used
// help: consider removing `T`, or using a marker such as `PhantomData`
La regla no es un capricho. El compilador deduce propiedades de un tipo genérico —su varianza, qué debe vivir tanto como él, si es Send— a partir de cómo usa cada parámetro en sus campos. Un parámetro que no aparece en ninguno no tiene esas propiedades definidas, y Rust prefiere rechazarlo a inventarlas. La cura es un campo que use T sin ocupar espacio:
use std::marker::PhantomData;
struct Marca<T> {
nombre: String,
_tipo: PhantomData<T>, // usa T; ocupa cero bytes
}
PhantomData<T> es un tipo de tamaño cero: size_of::<PhantomData<T>>() vale 0 para cualquier T. Añadirlo no cambia la disposición de Marca<T> en memoria —sigue pesando lo que una String— pero satisface la regla de uso y, sobre todo, le entrega al compilador la información de tipo que necesita.
use std::marker::PhantomData;
use std::mem::size_of;
struct Marca<T>(String, PhantomData<T>);
fn main() {
assert_eq!(size_of::<PhantomData<u64>>(), 0);
assert_eq!(size_of::<Marca<f64>>(), size_of::<String>()); // el marcador no pesa
}
Marcadores con significado
El uso más inmediato es fabricar tipos distintos que comparten representación. Un identificador numérico que no debe confundirse con otro según a qué entidad apunte:
use std::marker::PhantomData;
struct Id<T> {
valor: u64,
_tipo: PhantomData<T>,
}
impl<T> Id<T> {
fn nuevo(valor: u64) -> Id<T> {
Id { valor, _tipo: PhantomData }
}
}
struct Usuario;
struct Producto;
fn buscar_usuario(id: Id<Usuario>) { /* ... */ }
fn main() {
let u: Id<Usuario> = Id::nuevo(1);
let p: Id<Producto> = Id::nuevo(1);
buscar_usuario(u); // OK
// buscar_usuario(p); // ERROR: se esperaba Id<Usuario>, no Id<Producto>
}
Id<Usuario> e Id<Producto> son, en memoria, un u64 idéntico; en el sistema de tipos, universos separados. Pasar el identificador de un usuario donde se espera el de un producto no es un fallo que descubras en pruebas: no compila. Y como el marcador no pesa, Id<Usuario> ocupa exactamente lo mismo que un u64. La misma técnica da unidades: un Cantidad<Metros> y un Cantidad<Segundos> sobre un f64 que el compilador se niega a sumar entre sí.
Un newtype como struct Metros(f64) crea un tipo distinto para un significado. PhantomData generaliza la idea: un solo struct Cantidad<U> genera una familia de tipos distintos, uno por cada marcador U, compartiendo el mismo código y la misma representación. Ganas la seguridad de tipos separados sin escribir un struct por unidad y sin pagar un byte por la distinción.
Propiedad, drop check y varianza
PhantomData hace más que rellenar un hueco: le comunica al compilador tres cosas sutiles sobre T.
Propiedad y drop check. Un puntero crudo *const T no implica que poseas el T al que apunta; para el compilador, un struct con solo un *const T no es responsable de ningún T. Si en realidad lo posees —lo construyes y lo liberas, como hace Vec— debes declararlo, o el drop check razonará mal sobre los tiempos de vida:
use std::marker::PhantomData;
struct MiCaja<T> {
ptr: *const T, // un puntero crudo NO implica propiedad
_propiedad: PhantomData<T>, // le decimos que SI poseemos un T
}
Con PhantomData<T>, el compilador trata MiCaja<T> como si contuviera un T y exige que cualquier referencia dentro de ese T viva tanto como la caja. Sin él podrías liberar antes de tiempo y leer memoria muerta en el Drop.
Varianza. La varianza describe cómo se relacionan Envoltorio<A> y Envoltorio<B> cuando A es subtipo de B —en la práctica, cuando un tiempo de vida dura más que otro—. La forma exacta de PhantomData que elijas fija esa relación:
PhantomData<T>es covariante enT: lo habitual, unTque dura más encaja donde se espera uno que dura menos.PhantomData<fn(T)>es contravariante enT: útil cuandoTsolo aparece como entrada.PhantomData<fn(T) -> T>yPhantomData<*mut T>son invariantes:Tdebe coincidir exactamente, sin flexibilidad.
La invarianza importa cuando un T mal emparejado abriría un agujero de seguridad; por eso Cell<T> y &mut T son invariantes. PhantomData es la palanca con la que un tipo unsafe reclama la varianza correcta.
Auto-traits. PhantomData<T> es Send si y solo si T lo es, y Sync si y solo si T lo es. Marcar tu tipo con él propaga automáticamente esas garantías de hilos desde T, sin que escribas una línea de más.
flowchart TB p[Parametro T sin campo que lo use] --> e[Error E0392 parametro nunca usado] e --> f[PhantomData de T lo reintroduce sin ocupar memoria] f --> a[Regla de uso satisfecha] f --> b[Propiedad senalada al drop check] f --> c[Varianza y auto traits derivados de T] style f fill:#cba6f7,color:#11111b style a fill:#a6e3a1,color:#11111b style b fill:#89b4fa,color:#11111b style c fill:#f9e2af,color:#11111b
Lo revelador de PhantomData es que separa por completo dos cosas que solemos ver pegadas: la información de tipo y los datos en memoria. Estamos acostumbrados a que un parámetro genérico T venga acompañado de bytes —un Vec<T> guarda T de verdad, ocupa espacio, se mueve, se copia—. PhantomData demuestra que la información de tipo puede viajar sola, sin un solo byte que la sostenga, y sin embargo pesar en cada decisión del compilador. Piénsalo como un canal de comunicación con el verificador: por él le mandas hechos —este identificador es de usuario, este envoltorio posee un T, esta unidad es metros, esta varianza es invariante— que gobiernan qué programas acepta, sin que ninguno de esos hechos exista en tiempo de ejecución. Es la encarnación más pura de la idea del nivel. En la primera lección dijimos que los tipos son proposiciones; PhantomData es la prueba de que puedes portar una proposición sin portar dato alguno, un teorema sin materia. Los teóricos llaman a esto phantom types, tipos fantasma, y el nombre es afortunado: como un fantasma, PhantomData no tiene cuerpo —cero bytes— pero atraviesa las paredes de tu programa imponiendo reglas. Un Id<Usuario> y un Id<Producto> son indistinguibles para la CPU y abismalmente distintos para el compilador, y esa distancia entre lo que la máquina ve y lo que el tipo afirma es exactamente el espacio donde vive la programación a nivel de tipos. Cuando interiorizas que puedes esculpir ese espacio a voluntad —crear tantos tipos distintos como marcadores quieras, todos gratis en ejecución— dejas de pensar los genéricos como contenedores de datos y empiezas a pensarlos como portadores de significado. Ese cambio de mirada es la puerta a los tres patrones que vienen: typestate, const generics y traits sellados descansan todos sobre información de tipo que no cuesta nada mover.
Un parámetro genérico que no aparece en ningún campo provoca E0392; PhantomData<T> —un tipo de tamaño cero— satisface la regla de uso sin ocupar memoria. Sirve para fabricar tipos distintos con la misma representación —identificadores tipados, unidades— a coste cero, y para comunicarle al compilador propiedades de T que no puede inferir: que lo posees, de cara al drop check; con qué varianza; y si es Send o Sync. Es información de tipo que viaja sin datos: la esencia de programar a nivel de tipos.
- Reproduce el error
E0392con unstructgenérico que no use su parámetro y arréglalo conPhantomData; comprueba consize_ofque el tamaño no cambia. - Implementa
Id<T>y creaId<Usuario>eId<Producto>; escribe una función que solo acepteId<Usuario>y observa el error al pasarle el otro. - Diseña
Cantidad<U>sobre unf64con marcadoresMetrosySegundos; permite sumar dosCantidad<Metros>pero no un metro con un segundo. - Explica, con un puntero crudo, por qué un tipo que posee un
TnecesitaPhantomData<T>para el drop check y qué podría salir mal sin él. - Da un caso en que querrías
PhantomData<fn(T) -> T>—invarianza— en lugar dePhantomData<T>—covarianza— y relaciónalo con la seguridad.