Haz que tus tipos se sientan integrados
Ya sabes que std::string te permite escribir a + b para concatenar y cout << s para imprimir. Eso no son trucos especiales del compilador: son funciones corrientes con nombres curiosos. La sobrecarga de operadores es la característica que permite que tus clases se enchufen a la misma sintaxis, de modo que un tipo Vector2 o Money se pueda sumar, comparar e imprimir exactamente igual que un int.
El mecanismo es sencillo una vez que lo ves: una expresión como a + b es una abreviatura. El compilador la reescribe como una llamada a una función llamada operator+ y busca una que coincida con los tipos de los operandos. Define esa función para tu clase y a + b empieza a funcionar de repente. En realidad esto es una forma especializada de sobrecarga de funciones: se aplican las mismas reglas de resolución de nombres, solo que con nombres con forma de operador.
Fíjate en que la función recibe ambos operandos por const&: la aritmética no debería modificar sus entradas, y las referencias evitan copias. Devuelve un nuevo Vector2 por valor: p + q debe producir un resultado nuevo sin tocar p ni q, igual que 2 + 3 no cambia el 2.
Miembro frente a no miembro
Hay dos lugares donde definir un operador: como miembro de la clase o como función libre (no miembro). Como miembro, el operando izquierdo es el this implícito, así que un operador binario recibe solo un parámetro explícito:
El const después de la lista de parámetros importa: a + b no debería modificar a, así que el miembro se marca como const. Usa la forma miembro para operadores que están intrínsecamente ligados al operando izquierdo y no necesitan conversiones sobre él: +=, [], (), -> y operadores unarios como -x o ++x.
El inconveniente de los miembros: el operando izquierdo no se puede convertir. Con el operator+ miembro de arriba, a + 50 funciona (50 se convierte a Money para el lado derecho), pero 50 + a no compila: el operando izquierdo 50 es un int, y no puedes añadir una función miembro a int. Un operador no miembro soluciona esto porque ambos operandos son parámetros explícitos y ambos se pueden convertir:
Regla práctica: haz que los operadores binarios simétricos (+, ==, *) sean no miembros para que las conversiones funcionen en ambos lados; haz que los operadores que deben modificar el operando izquierdo o que están ligados a él (+=, [], =) sean miembros.
Sobrecargar el operador de flujo
El operador que más se sobrecarga, con diferencia, es << para imprimir. No puedes hacerlo miembro de tu clase, porque el operando izquierdo es un std::ostream (como cout), no tu tipo, y ostream no te pertenece. Así que siempre es un no miembro que recibe el flujo por referencia no constante y lo devuelve:
Dos detalles hacen que esto funcione. El flujo se pasa y se devuelve por referencia (ostream&): los flujos no se pueden copiar, y devolver el mismo flujo es lo que te permite encadenar cout << "p = " << p << "\n". Cada << devuelve el flujo para que el siguiente << tenga algo a lo que enlazarse. Si olvidas el return os;, el encadenamiento se rompe.
Operadores de comparación
Para comparar tus objetos con ==, < y similares, sobrecarga los operadores de comparación. Antes de C++20 escribías cada uno a mano; el detalle clave es que operator< debe devolver un bool y definir un orden consistente:
Escribir las seis comparaciones (==, !=, <, <=, >, >=) a mano es tedioso y propenso a errores. C++20 añadió el operador de comparación de tres vías <=> (la "nave espacial"). Establecerlo por defecto junto con == genera todas las comparaciones por ti:
= default le indica al compilador que compare los miembros en el orden de declaración, que es exactamente el orden lexicográfico que escribirías a mano. Prefiere esto en compiladores modernos.
El operador de asignación y sus trampas
operator= (asignación por copia) es especial: el compilador genera uno por ti, y para clases sencillas ese valor por defecto es correcto. Solo necesitas escribir el tuyo cuando tu clase gestiona un recurso - memoria cruda, un descriptor de archivo - donde una copia miembro a miembro sería incorrecta. La firma canónica devuelve *this por referencia para que las asignaciones se puedan encadenar (a = b = c):
En esta función tan corta viven dos trampas. La primera, la comprobación de autoasignación if (this == &other): sin ella, a = a haría delete[] data y luego leería de other.data, que acaba de liberarse - comportamiento indefinido. La segunda, el orden importa: en una versión hecha a mano no debes borrar el búfer antiguo antes de haber copiado de forma segura el nuevo (una implementación real a menudo reserva primero, o usa el idiom copy-and-swap, de modo que una reserva fallida deja el objeto intacto).
Una pega más general: no sobrecargues operadores de formas sorprendentes. Un operator+ que modifica en secreto su operando izquierdo, o un operator== que no es simétrico, confundirán a todos los lectores y romperán el código de la biblioteca estándar que asume los significados habituales. Sobrecarga operadores solo cuando la operación sea genuinamente "tipo suma" o "tipo igualdad" para tu tipo.
Siguiente: Especificadores de acceso
Fíjate en que todos los ejemplos mantuvieron sus miembros de datos private y expusieron el comportamiento a través de una pequeña superficie pública: constructores, operadores y unos cuantos métodos. Esa frontera entre lo que es visible para el mundo exterior y lo que queda oculto dentro de la clase la controlan los especificadores de acceso: public, private y protected. A continuación veremos exactamente qué permite cada uno, por qué los datos private con métodos públicos son la opción por defecto para un buen encapsulamiento y cómo encaja protected en la herencia.
Preguntas frecuentes
¿Qué es la sobrecarga de operadores en C++?
La sobrecarga de operadores te permite definir qué significan operadores integrados como +, == o << para tus propios tipos. Escribes una función con un nombre especial - operator+, operator==, etc. - y el compilador la llama cada vez que el operador aparece con operandos de tu clase. Así es como string + string concatena y cout << obj imprime un objeto personalizado.
¿Los operadores deben ser funciones miembro o no miembro (friend) en C++?
Usa una función miembro cuando el operando izquierdo es tu propia clase y no necesita conversiones (p. ej. +=, [], ()). Usa una función no miembro (a menudo friend) cuando el operando izquierdo puede ser un tipo integrado o cuando quieres conversiones simétricas en ambos lados; esto es obligatorio para operator<< porque el operando izquierdo es un std::ostream, no tu clase.
¿Qué operadores de C++ no se pueden sobrecargar?
No puedes sobrecargar :: (resolución de ámbito), . (acceso a miembro), .* (acceso por puntero a miembro), ?: (ternario) ni sizeof. Tampoco puedes inventar operadores nuevos ni cambiar la aridad o la precedencia de un operador - + siempre es binario con la misma precedencia tanto si suma int como tu Vector2.