Sobrecarga de operadores: cada símbolo es un trait
Cada operador de Rust es azúcar sobre un método de un trait de `std::ops`: `a + b` es `Add::add(a, b)`, incluso para los enteros. Implementas el trait y tu tipo responde al operador; el tipo asociado `Output` y el parámetro `Rhs` permiten operar entre tipos distintos. No hay aritmética privilegiada ni operadores inventables: el catálogo es fijo y está conectado al sistema de traits.
En Rust no existe la aritmética privilegiada. Cuando escribes a + b, el compilador lo traduce a Add::add(a, b); cuando escribes c[i], a *c.index(i). Cada operador es azúcar sintáctico sobre un método de un trait del módulo std::ops, y eso incluye a los enteros: 1 + 2 es una llamada a <i32 as Add>::add. La consecuencia es directa y poderosa: si implementas el trait adecuado, tu propio tipo responde al operador exactamente igual que un número. Lo que no puedes es inventar operadores nuevos —el catálogo es cerrado—, y esa restricción es deliberada.
- Entender que cada operador es azúcar sobre un método de un trait de
std::ops. - Implementar
Addpara un tipo propio y sumar con+. - Usar el tipo asociado
Outputy el parámetroRhspara operar entre tipos distintos. - Sobrecargar la indexación con
Indexy conocer los límites de la sobrecarga.
El operador es un método de un trait
Cada símbolo tiene detrás un trait y un método: + es Add::add, * es Mul::mul, el - unario es Neg::neg, [] es Index::index, y == es PartialEq::eq (que viste en el nivel 16). Sobrecargar un operador es, literalmente, escribir un impl de ese trait para tu tipo.
flowchart LR E[Expresion a mas b] --> D[Desazucarado por el compilador] D --> M[Llamada Add add de a y b] M --> O[Devuelve el tipo asociado Output] style E fill:#89b4fa,color:#11111b style M fill:#a6e3a1,color:#11111b style O fill:#cba6f7,color:#11111b
No hay magia en el + de los enteros que no puedas replicar: i32 implementa Add en la biblioteca estándar igual que lo harás tú. El operador es una interfaz uniforme, no un caso especial cableado en el compilador.
Implementar Add para un tipo propio
Definamos un vector de dos componentes y enseñémosle a sumar. El trait Add tiene un tipo asociado Output —el tipo del resultado— y un parámetro genérico Rhs (right-hand side) que por defecto es Self:
pub trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
Fíjate en que add consume self y rhs por valor. Para nuestro Vec2, que es Copy, eso no cuesta nada:
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, rhs: Vec2) -> Vec2 {
Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // azucar de Add::add(a, b)
assert_eq!(c, Vec2 { x: 4.0, y: 6.0 });
}
Output y Rhs: operar entre tipos distintos
El parámetro Rhs y el tipo Output existen para que el operando derecho y el resultado no tengan por qué ser tu tipo. Multiplicar un vector por un escalar f64 es el caso canónico:
use std::ops::Mul;
impl Mul<f64> for Vec2 {
type Output = Vec2;
fn mul(self, escalar: f64) -> Vec2 {
Vec2 { x: self.x * escalar, y: self.y * escalar }
}
}
// ahora Vec2 { x: 1.0, y: 2.0 } * 3.0 == Vec2 { x: 3.0, y: 6.0 }
Cada operador compuesto tiene su propio trait: += es AddAssign, cuyo método recibe &mut self y muta en el sitio en lugar de producir un valor nuevo:
use std::ops::AddAssign;
impl AddAssign for Vec2 {
fn add_assign(&mut self, rhs: Vec2) {
self.x += rhs.x;
self.y += rhs.y;
}
}
Add::add toma self por valor, así que a + b mueve ambos operandos si el tipo no es Copy. Para tipos que poseen heap suele implementarse también Add sobre referencias —impl Add<&Vec2> for &Vec2— para poder escribir &a + &b sin mover ni clonar. La biblioteca estándar lo hace para los enteros de precisión arbitraria y las cadenas; es un patrón que verás en crates numéricos como nalgebra.
Index: personalizar la indexación
Index conecta el operador [] con tu tipo. Su método devuelve una referencia al elemento, y el compilador inserta el * automáticamente: c[i] es azúcar de *c.index(i).
use std::ops::Index;
struct Semana { dias: [String; 7] }
impl Index<usize> for Semana {
type Output = String;
fn index(&self, i: usize) -> &String {
&self.dias[i]
}
}
// semana[0] es *semana.index(0)
Conviene fijar un límite: no todo es sobrecargable. Los operadores de cortocircuito && y ||, la asignación =, el acceso a campo ., el operador ? y el rango .. no tienen trait público que puedas implementar. Y, sobre todo, no puedes inventar operadores: el catálogo de std::ops es cerrado. Rust prefiere que un lector nunca tenga que adivinar la precedencia o la asociatividad de un símbolo desconocido.
La idea que hay que interiorizar es que en Rust el + de tu Vec2 y el + de un i32 son el mismo mecanismo, no dos mundos separados. No existe una aritmética incorporada para los tipos primitivos y otra, de segunda, para los tuyos: i32 implementa Add exactamente igual que tú, y el compilador desazucara 1 + 2 en <i32 as Add>::add(1, 2) con la misma regla con la que desazucara a + b para tu vector. Esto convierte los operadores en polimorfismo ad hoc de primera clase: una función genérica acotada por T: Add<Output = T> funciona sin cambios para enteros, flotantes y tu tipo, porque todos hablan el mismo trait. C++ también deja sobrecargar operadores, pero como funciones sueltas sin un trait que las unifique, de modo que el código genérico no puede nombrar “las cosas que se pueden sumar”; en Rust sí, y esa es la base de todo el ecosistema numérico. Y hay un guardián silencioso: la regla del huérfano, la misma coherencia que gobierna cualquier trait, impide que dos crates den dos significados incompatibles a + para los mismos tipos. Por eso el catálogo es fijo y cerrado: no para limitarte, sino para garantizar que + significa una sola cosa, con una sola precedencia, en todo programa Rust que exista. La sobrecarga de operadores no es un rincón cosmético del lenguaje, sino la aplicación directa de su tesis central —las capacidades viven en los traits— al terreno de la sintaxis.
Cada operador es azúcar sobre un método de un trait de std::ops: + es Add::add, [] es Index::index, y así con todo el catálogo. Implementas el trait y tu tipo responde al operador; el tipo asociado Output fija el resultado y el parámetro Rhs permite operar contra otro tipo. Los operadores compuestos como += tienen su trait AddAssign con &mut self. No puedes sobrecargar &&, ||, =, ., ? ni .., ni inventar operadores nuevos: el catálogo es cerrado a propósito.
- Define
struct Complejo { re: f64, im: f64 }e implementaAddpara sumar dos números complejos. Comprueba conassert_eq!derivandoPartialEqyDebug. - Implementa
Mul<f64> for Complejopara escalar por un real, y luegoMul for Complejopara el producto complejo. Observa cómo el mismo*sirve para ambos gracias aRhs. - Añade
Negpara que-cniegue ambas componentes, yAddAssignpara quec += otrofuncione en el sitio. - Crea
struct Matriz { datos: Vec<f64>, cols: usize }e implementaIndex<(usize, usize)>para acceder conm[(fila, col)]. Explica dónde inserta el compilador el*. - Escribe con tus palabras por qué
1 + 2ya + bpara tu tipo son el mismo mecanismo, y qué papel juega la regla del huérfano en que+signifique una sola cosa.