wandres.dev
TRAITS DEL SISTEMA · Clone, Copy, Debug, Default

Hash y Default: claves de mapa y valores por defecto

Hash produce la huella que convierte un tipo en clave de un HashMap, y su contrato con Eq es una ley que tú no puedes romper. Default da un valor inicial razonable a golpe de derive. Dos traits humildes sobre los que se apoyan las colecciones y medio ecosistema.

⏱ 18 min

Para meter un tipo como clave de un HashMap no basta con poder compararlo: hace falta poder resumirlo en una huella numérica, y de eso se encarga Hash. Para pedir “una instancia con valores sensatos” sin rellenar todos los campos a mano, existe Default. Son dos traits modestos comparados con los del resto del nivel, pero sostienen las colecciones asociativas y aparecen por todo el ecosistema. Y Hash esconde un contrato con Eq que, si lo rompes, corrompe tus mapas en silencio.

🎯 Al terminar esta lección sabrás
  • Derivar Hash para usar un tipo como clave de HashMap y HashSet.
  • Entender el contrato entre Eq y Hash y por qué debes derivarlos juntos.
  • Distinguir Hash, que aporta los bytes, de Hasher, que calcula la huella.
  • Dar valores por defecto con #[derive(Default)], en structs y en enums.

Hash: la huella para las claves

Un HashMap guarda cada clave en un bucket elegido a partir de una huella numérica de esa clave. Para poder calcular esa huella, la clave debe implementar Hash. La combinación exigida a toda clave es Eq + Hash.

use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash)]
struct ClaveGeo {
    pais: String,
    ciudad: String,
}

fn main() {
    let mut poblacion = HashMap::new();
    poblacion.insert(
        ClaveGeo { pais: String::from("ES"), ciudad: String::from("Madrid") },
        3_300_000,
    );
    let clave = ClaveGeo { pais: String::from("ES"), ciudad: String::from("Madrid") };
    assert_eq!(poblacion.get(&clave), Some(&3_300_000));
}

Hash se deriva y, como todos los derivables estructurales, funciona campo a campo: la huella del struct combina las huellas de sus campos. Por eso ClaveGeo puede derivar Hash: String ya lo implementa.

El contrato Eq–Hash

Aquí vive la ley más importante del trait, y es fácil de enunciar: si dos valores son iguales, deben tener la misma huella. Formalmente, a == b obliga a que hash(a) == hash(b).

La razón es el funcionamiento del mapa. Para buscar una clave, el HashMap calcula su huella, va al bucket correspondiente y allí compara con ==. Si dos claves iguales produjeran huellas distintas, irían a buckets distintos, y buscar una clave que insertaste devolvería None: el valor está ahí, pero el mapa lo busca en el sitio equivocado. No es undefined behavior, pero sí una corrupción lógica silenciosa, de las que cuestan horas de depuración.

Por eso la regla práctica es tajante: deriva Eq y Hash juntos, o impleméntalos juntos con sumo cuidado, pero nunca uno a mano y el otro derivado. El derive los mantiene consistentes por construcción, porque ambos recorren los mismos campos en el mismo orden.

⚠️
Eq y Hash inconsistentes: un mapa que pierde lo que guardaste

Si implementas PartialEq a mano para que ignore un campo (por ejemplo, comparar usuarios solo por su id) pero derivas Hash, que sí mira todos los campos, rompes el contrato: dos usuarios “iguales” con distinto nombre tendrán huellas distintas. El HashMap los tratará como claves diferentes y tu lógica fallará de forma intermitente e imposible de reproducir. Si personalizas uno, personaliza el otro para que miren exactamente los mismos campos.

Hash frente a Hasher: quién aporta los bytes y quién calcula

Un matiz de diseño precioso: Hash no calcula ninguna huella. Lo que hace su método hash es alimentar con bytes a un Hasher genérico que se le pasa. El Hasher es quien mezcla esos bytes en un número final.

use std::hash::{Hash, Hasher};

struct Punto { x: i32, y: i32 }

impl Hash for Punto {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.x.hash(state);   // aporto los bytes de x al hasher
        self.y.hash(state);   // ...y los de y; NO calculo el numero final
    }
}

Esta separación tiene consecuencias reales. Hash responde a “¿qué bytes representan este valor?”; Hasher responde a “¿cómo mezclo bytes en una huella?”. Como están desacopladas, std puede usar por defecto el algoritmo SipHash —resistente a ataques de colisión deliberados que podrían degradar un HashMap a listas—, y tú puedes cambiar a un Hasher más rápido como el de FxHashMap o ahash sin tocar ni una línea de los impl Hash de tus tipos. La representación de tus datos y el algoritmo de mezcla evolucionan por separado.

Default: el valor por defecto

Default responde a “dame una instancia con valores razonables”. Se deriva, y da a cada campo su propio valor por defecto: 0 para enteros, false para bool, cadena vacía para String, vector vacío para Vec, None para Option.

#[derive(Default, Debug)]
struct Config {
    host: String,   // ""
    puerto: u16,    // 0
    reintentos: u8, // 0
    tls: bool,      // false
}

fn main() {
    let c = Config::default();
    // ..Default::default() rellena el resto tras fijar solo lo que te importa
    let d = Config { puerto: 8080, tls: true, ..Default::default() };
    println!("{c:?}\n{d:?}");
}

La sintaxis ..Default::default() es idiomática: fijas los campos que te importan y dejas que el resto tome su valor por defecto. Default requiere que todos los campos sean Default, y sostiene patrones de todo el lenguaje: unwrap_or_default, mem::take, y el patrón builder.

ℹ️
Default en enums necesita marcar la variante

Un enum no tiene un “por defecto” evidente, así que al derivar Default debes señalar cuál es con el atributo #[default] sobre una variante sin datos. Es estable desde hace tiempo y muy usado en configuración.

#[derive(Default, Debug)]
enum Modo {
    #[default]
    Lectura,
    Escritura,
}
Hash separa el qué del cómo, y Default niega que exista un cero universal

Estos dos traits humildes encierran dos lecciones de diseño que reaparecen por todo Rust. La primera vive en la escisión Hash / Hasher. La ingenuidad sería que un tipo calculara su hash, devolviendo un u64; entonces el algoritmo de hashing quedaría soldado a cada tipo, y cambiarlo —por seguridad, por velocidad— exigiría reescribir el mundo. Rust invierte la relación: el tipo solo declara qué bytes lo representan alimentando a un Hasher abstracto, y el algoritmo de mezcla es una pieza intercambiable que decide la colección, no el dato. Es el principio de separar la política del mecanismo llevado a un trait, y por eso puedes endurecer todos tus mapas contra ataques de colisión, o acelerarlos, cambiando una sola línea en la construcción del HashMap sin auditar un solo impl Hash. La segunda lección vive en Default. Muchos lenguajes imponen un “valor cero” en cada tipo: el 0, el nil, el struct con todo a cero, inyectado por el lenguaje quieras o no, lo que produce el bug perpetuo de confundir “no inicializado” con “cero legítimo”. Default en Rust es lo contrario: es opt-in —el tipo elige tenerlo o no— y semántico —tú decides qué es razonable, incluso qué variante de un enum es la de partida—. Y no promete ser un elemento neutro ni una identidad algebraica; promete solo “un punto de partida sensato elegido por el autor”. Rust se niega tanto a soldar el algoritmo de hash a tus tipos como a inventarte un cero universal a tus espaldas: en ambos casos, la decisión que otros lenguajes toman por ti vuelve a tus manos, explícita y localizada.

📝
Lo esencial de Hash y Default

Hash produce la huella que convierte un tipo en clave; toda clave necesita Eq + Hash, y el contrato inviolable es que valores iguales tengan huellas iguales —por eso se derivan juntos—. Hash aporta los bytes, Hasher calcula el número, y esa separación deja cambiar el algoritmo sin tocar tus tipos. Default da un valor de partida razonable campo a campo, se combina con ..Default::default(), y en enums exige marcar la variante con #[default].

⚔️ Claves y puntos de partida
  1. Define struct Coord { x: i32, y: i32 }, deriva PartialEq, Eq, Hash y úsala como clave de un HashMap<Coord, String>. Inserta y recupera un valor.
  2. Implementa PartialEq a mano para que compare solo x, pero deriva Hash. Inserta dos Coord con el mismo x y distinto y y observa cómo el mapa se comporta de forma inconsistente. Explica qué ley rompiste.
  3. Deriva Default para struct Ajustes { nivel: u8, nombre: String, activo: bool } y crea una instancia con ..Default::default() fijando solo nivel.
  4. Convierte un enum Estado { Inactivo, Activo } en Default marcando la variante de partida con #[default].
  5. Explica con tus palabras la diferencia entre Hash y Hasher, y por qué esa separación permite cambiar de algoritmo sin tocar tus tipos.