AulaML Python · C++
C++ intermedio · 46 min · Funciones

Paso por valor, referencia, sobrecarga y recursión

Elegir cómo viaja un argumento y cómo una función se llama a sí misma

Pasar un argumento por valor copia; por referencia, comparte la variable original. Aprenderás a elegir entre valor, referencia, const& y puntero, a sobrecargar funciones sin crear ambigüedades y a escribir recursión con caso base y pila de llamadas controlada.

Lección 2 de 2 del módulo
Al terminar esta lección podrás
  • Distinguir paso por valor, por referencia y por puntero en el código
  • Usar `const&` para leer datos grandes sin copiarlos
  • Sobrecargar funciones y reconocer una llamada ambigua
  • Escribir una función recursiva con caso base y caso recursivo
  • Seguir la pila de llamadas de una recursión paso a paso

Antes necesitas: Funciones: declaración, parámetros y retorno

En esta lección
  1. ¿Qué es el paso de argumentos?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: valor frente a referencia
  6. Ejemplo intermedio: sobrecarga y ambigüedad
  7. Caso práctico: recursión con caso base
  8. Visualización: la pila de una recursión
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

En la lección anterior viste que un parámetro es una copia del argumento. Esa regla es segura, pero costosa para un objeto grande y a veces inútil: si quieres que la función cambie tu variable, una copia no sirve. C++ ofrece varias formas de pasar datos, y elegir bien es una decisión de diseño, no un detalle de sintaxis.

¿Qué es el paso de argumentos?

Pasar un argumento es decidir qué recibe la función cuando la llamas. En C++ esa decisión se toma en la firma: el tipo del parámetro determina si hay copia, si hay un alias a la variable original o si se pasa una dirección.

  • Por valor (int x): la función recibe una copia independiente. Modificarla no afecta al original.
  • Por referencia (int& x): el parámetro es otro nombre para la misma variable. Modificarlo la cambia.
  • Por referencia constante (const int& x): alias de solo lectura, sin copia. No puedes modificarlo.
  • Por puntero (int* p): recibe una dirección; puede ser nullptr y puede reasignarse.

La sobrecarga permite que varias funciones compartan nombre si difieren en el número o el tipo de parámetros. La recursión es una función que se llama a sí misma con un problema más pequeño. Las tres ideas se juntan aquí porque todas dependen de la firma y de la pila de llamadas.

¿Para qué sirve?

  • Evitar copias costosas: una matriz de un millón de elementos se pasa como const Matrix&, no por valor.
  • Modificar el original: una función que incrementa un contador o intercambia dos valores necesita referencia.
  • Expresar intención: const& dice «solo leo»; int& dice «voy a escribir»; el valor dice «trabajo con una copia».
  • Nombrar igual operaciones parecidas: area(lado), area(ancho, alto) y area(radio) sin inventar sufijos.
  • Resolver problemas recursivos: recorrer árboles, dividir y conquistar, calcular factoriales o máximos comunes divisores.

En Machine Learning, una función que recibe una matriz la recibe como const& para no duplicar memoria. La recursión aparece en árboles, búsqueda binaria y definiciones matemáticas; en producción se prefiere iteración si la profundidad puede ser grande.

¿Cómo funciona por dentro?

Con paso por valor, el argumento se copia dentro del marco de la función. La copia ocupa su propio espacio y desaparece al volver. Con paso por referencia, el compilador no copia el objeto: guarda una dirección interna y trata el nombre del parámetro como otro nombre del argumento. Por eso una escritura a través de la referencia es una escritura en la variable original.

Una referencia debe inicializarse al declararse y no puede quedar sin apuntar a nada: no existe una referencia nula. Tampoco puede cambiar de destino. Un puntero sí puede ser nullptr o apuntar a otra parte, y por eso se usa cuando «no hay valor» es un estado posible.

La sobrecarga se resuelve en compilación. El compilador compara el número y los tipos de los argumentos con cada firma candidata, prefiere la coincidencia exacta y luego las promociones. Si dos candidatas requieren conversiones igual de buenas, la llamada es ambigua y el programa no compila.

En una recursión, cada llamada crea un marco nuevo. Los marcos se apilan hasta que se alcanza el caso base, que devuelve sin llamar otra vez. A partir de ahí se desapilan en orden inverso, multiplicando o combinando los resultados. Si el caso base nunca se alcanza, la pila crece hasta desbordarse.

Sintaxis

El & forma parte del tipo cuando está junto al nombre del parámetro. En la llamada no se escribe nada especial para una referencia; para un puntero, se pasa la dirección con &. El const puede ir antes del tipo o después, pero el significado que importa es que el dato apuntado no se modifica.

CPP sintaxis.cpp
#include <iostream>
#include <string>

int porValor(int x) {
    return x + 1;
}

void porReferencia(int& x) {
    ++x;
}

void porPuntero(int* x) {
    ++(*x);
}

void porConstRef(const std::string& texto) {
    std::cout << "longitud: " << texto.size() << '\n';
}

int main() {
    int a = 1;
    int b = 1;

    a = porValor(a);        // a sigue siendo 1 hasta la asignacion
    porReferencia(b);       // modifica b
    porPuntero(&b);         // modifica b otra vez
    porConstRef("hola");    // no copia la cadena

    std::cout << a << ' ' << b << '\n';
    return 0;
}
FormaCopiaModifica el originalPuede ser nula
int xSíNoNo
int& xNoSíNo
const int& xNoNoNo
int* xNo (copia la dirección)Sí, con *xSí, nullptr

Las cuatro formas conviven en el mismo programa. porConstRef recibe el literal "hola" sin construir una copia de la cadena: el parámetro es una referencia a un objeto temporal que vive durante la llamada.

Ejemplo básico: valor frente a referencia

Dos funciones con el mismo cuerpo lógico, x = 100, producen efectos distintos según cómo reciben el parámetro. Compara a con b al final: el valor de a no cambió, el de b sí.

CPP valor_referencia.cpp
#include <iostream>

int porValor(int x) {
    x = 100;
    return x;
}

void porReferencia(int& x) {
    x = 100;
}

int main() {
    int a = 1;
    int b = 1;

    int copia = porValor(a);
    porReferencia(b);

    std::cout << "a = " << a << '\n';
    std::cout << "copia = " << copia << '\n';
    std::cout << "b = " << b << '\n';
    return 0;
}
  1. porValor(a) copia el 1 en x; x = 100 modifica la copia y return entrega 100.
  2. copia guarda ese 100, pero a sigue valiendo 1 porque nunca se tocó.
  3. porReferencia(b) no copia: x es otro nombre de b.
  4. x = 100 escribe en la celda de b; al volver, b vale 100.

La salida es a = 1, copia = 100 y b = 100. Si porValor no devolviera el valor, su escritura se perdería por completo. Y si porReferencia recibiera un literal como porReferencia(5), no compilaría: una referencia a int no puede enlazar con una constante temporal.

Ejemplo intermedio: sobrecarga y ambigüedad

Tres funciones llamadas area calculan superficies distintas. El nombre es el mismo porque la operación conceptual también lo es; lo que cambia es qué figura recibe. El compilador elige la versión por el número y el tipo de argumentos.

CPP sobrecarga.cpp
#include <iostream>

int area(int lado) {
    return lado * lado;
}

int area(int ancho, int alto) {
    return ancho * alto;
}

double area(double radio) {
    return 3.141592653589793 * radio * radio;
}

int main() {
    std::cout << area(4) << '\n';       // cuadrado
    std::cout << area(3, 5) << '\n';    // rectangulo
    std::cout << area(2.5) << '\n';     // circulo
    return 0;
}

area(4) elige la versión de un int y devuelve 16. area(3, 5) elige la de dos enteros y devuelve 15. area(2.5) elige la de double y devuelve 19.635. Si escribieras area(2L), el argumento sería long y ninguna versión coincide exactamente: convertir a int o a double son conversiones igual de válidas, así que la llamada sería ambigua.

La sobrecarga solo debe usarse cuando todas las versiones hacen lo mismo con tipos distintos. Si una versión imprime y otra calcula, el nombre compartido engaña. Tampoco puedes sobrecargar cambiando solo el tipo de retorno: el compilador no puede decidir qué versión llamar mirando el resultado.

Caso práctico: recursión con caso base

La potencia se define recursivamente: base^0 = 1 y base^n = base * base^(n-1). El máximo común divisor usa el algoritmo de Euclides: mcd(a, 0) = a y mcd(a, b) = mcd(b, a % b). En ambos casos, el caso base corta la cadena de llamadas.

CPP recursion.cpp
#include <iostream>

long long potencia(int base, int exponente) {
    if (exponente == 0) {
        return 1;
    }
    return base * potencia(base, exponente - 1);
}

int mcd(int a, int b) {
    if (b == 0) {
        return a;
    }
    return mcd(b, a % b);
}

int main() {
    std::cout << "2^10 = " << potencia(2, 10) << '\n';
    std::cout << "mcd(48, 18) = " << mcd(48, 18) << '\n';
    return 0;
}

potencia(2, 10) encadena once marcos: exponentes 10, 9, ..., 0. Al llegar a cero devuelve 1 y cada marco multiplica por 2 al desapilarse, hasta 1024. mcd(48, 18) sigue la cadena 18, 12, 6, 0 y devuelve 6. La salida es 2^10 = 1024 y mcd(48, 18) = 6.

Visualización: la pila de una recursión

Observa cómo la pila crece hasta el caso base y después se vacía. Cada marco tiene su propio n: no hay una sola variable compartida. El resultado se construye al volver, de dentro hacia fuera.

Ejecución visual ·factorial(3) con tres marcos
1 / 8
1 #include <iostream>
2
3 int factorial(int n) {
4 if (n <= 1) {
5 return 1;
6 }
7 return n * factorial(n - 1);
8 }
9
10 int main() {
11 std::cout << factorial(3) << '\n';
12 return 0;
13 }
main empieza a evaluar la expresión. Todavía no hay ningún marco de factorial.
Pila de llamadas
main()
Variables

Sin variables en este paso.

Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

  • Ambigüedad de sobrecarga: void f(long) y void f(double) con f(3) dejan al compilador sin una conversión mejor. Convierte explícitamente: f(3.0).
  • Devolver una referencia a una variable local: la variable muere al volver y la referencia queda colgando. El compilador suele avisar con -Wreturn-local-addr.
  • Intentar modificar un const&: x = 5 dentro de la función no compila. Si necesitas escribir, quita el const; si no, la protección está haciendo su trabajo.
  • Recursión sin caso base: return n * factorial(n - 1); sin comprobar n desborda la pila y el programa aborta.
  • Caso base inalcanzable: si el argumento no se acerca al caso base, la recursión no termina aunque exista un if.
  • Sobrecargar solo por el retorno: int f() y double f() no pueden convivir; la llamada no aporta información para elegir.

Los errores de referencia aparecen lejos de su causa: una función devuelve una referencia válida en apariencia y el fallo ocurre tres llamadas después. Compila con -Wall -Wextra y reduce el caso a la llamada mínima que falla.

Buenas prácticas

  • Pasa por valor los tipos pequeños (int, double, bool) y los que la función va a modificar de todos modos.
  • Pasa por const& los objetos grandes que solo se leen: cadenas, vectores, matrices.
  • Pasa por & solo cuando la función deba modificar el argumento, y que el nombre lo sugiera (actualizar, normalizar).
  • Reserva punteros para datos opcionales o estructuras enlazadas; si el dato siempre existe, una referencia expresa mejor el contrato.
  • En recursión, escribe el caso base primero y comprueba que cada llamada reduce el problema.
  • No sobrecargues si las versiones tienen comportamientos distintos: un nombre distinto documenta mejor la diferencia.
Modificar un argumento
Python
PYTHON modificar.py
def intento(x):
    x = 100

a = 1
intento(a)
print(a)   # 1
C++
CPP modificar.cpp
#include <iostream>

void modificar(int& x) {
    x = 100;
}

int main() {
    int a = 1;
    modificar(a);
    std::cout << a << '\n';   // 100
    return 0;
}

En Python el nombre se reasigna dentro de la función y el entero original no cambia, porque los enteros son inmutables. En C++ una referencia permite escribir en la variable del llamador, y la firma lo declara con `&`. La diferencia no es de capacidad sino de explicitud: el llamador ve si su dato puede cambiar.

Ejercicios

Ejercicio 1

Intercambio con referencias

Escribe void intercambiar(int& a, int& b) que intercambie los valores de dos variables. Llámala desde main con dos enteros, imprime antes y después, y comprueba que el cambio se conserva al volver. No uses std::swap.
Pista

Guarda uno de los valores en una variable temporal antes de sobrescribirlo. Con referencias no necesitas * ni & en la llamada.

Ejercicio 2

Suma de dígitos recursiva

Escribe int sumaDigitos(int n) que devuelva la suma de los dígitos de un número positivo. Debe ser recursiva: el caso base es un número de un solo dígito y el caso recursivo suma el último dígito con la suma del resto. Prueba con 407 y con 9999.
Pista

El último dígito es n % 10; el resto es n / 10. Con n < 10 puedes devolver n directamente.

Completar código Completa la firma por referencia
void duplicar(int x) {
    x  2;
}

int main() {
    int a = 7;
    duplicar(a);
    std::cout << a << '\n';   // 14
}

El parámetro debe ser un alias y la operación debe escribir en él.

Soluciones explicadas

En el intercambio, la referencia evita punteros y hace que la llamada se lea como una operación sobre las variables. La temporal es obligatoria: si asignas a = b antes de guardar a, pierdes el primer valor. Podrías usar std::swap, pero escribirlo a mano muestra por qué el paso por referencia importa.

En la suma de dígitos, el caso base devuelve el propio dígito cuando ya no hay más. El caso recursivo combina el último dígito con el resultado del resto, y cada llamada reduce el número en un dígito. Con 407 se apilan tres marcos y se desapilan sumando 4, 0 y 7 en orden inverso: el resultado es 11.

En el ejercicio de completar, el & y el *= son dos caras del mismo contrato: el primero dice que la función puede escribir en el argumento, el segundo ejerce esa escritura. Si cambiaras *= por =, el resultado sería 2 en lugar de 14, y el & quedaría como una promesa incumplida.

Resumen

  • Por valor se copia; por referencia se comparte la variable original.
  • const& evita la copia y prohíbe la modificación: es la forma habitual de leer objetos grandes.
  • Una referencia no puede ser nula ni cambiar de destino; un puntero sí.
  • La sobrecarga elige la función por número y tipo de argumentos, en compilación.
  • Si dos candidatas exigen conversiones equivalentes, la llamada es ambigua.
  • Toda recursión necesita un caso base y un avance que lo alcance.
  • Cada llamada recursiva apila un marco; el resultado se construye al desapilar.

Evaluación

Evaluación 1

¿Qué firma permite modificar la variable del llamador sin copiarla?

Evaluación 2

Con void f(long) y void f(double), ¿qué ocurre al llamar f(3)?

Evaluación 3

¿Qué falta si una función recursiva compila pero el programa aborta con un desbordamiento de pila?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.