Lambdas, range-for y C++ moderno
Funciones anónimas, captura de variables y las comodidades que cambiaron C++
Una lambda es una función que se escribe en el lugar donde se usa y que puede llevarse consigo variables del entorno. Aquí verás cómo capturarlas, cómo pasarlas a los algoritmos y qué partes de C++ moderno hacen el código más corto sin volverlo opaco.
- Escribir lambdas con parámetros, retorno deducido y captura explícita
- Distinguir captura por valor de captura por referencia y explicar sus riesgos
- Usar lambdas con sort, count_if y transform
- Elegir entre auto, std::function y una función normal
- Recorrer contenedores con range-for, structured bindings y nullptr
Antes necesitas: Iteradores y algoritmos STL · Paso por valor, referencia, sobrecarga y recursión
En esta lección
- ¿Qué es una lambda?
- ¿Para qué sirven las lambdas?
- Cómo funciona por dentro: el cierre y la captura
- Sintaxis: parámetros, captura, mutable y std::function
- Ejemplo básico: una lambda con estado
- Ejemplo intermedio: lambdas en algoritmos
- Caso práctico: range-for, structured bindings y auto
- Visualización: captura por valor y por referencia
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Hasta ahora, para pasar una operación a un algoritmo tenías que escribir una función aparte, muchas veces a varias pantallas de distancia del lugar donde se usaba. Una lambda resuelve ese problema: es una función anónima que se define en la misma expresión y que puede recordar variables del ámbito donde aparece.
La sintaxis parece extraña la primera vez, pero son solo tres piezas: corchetes para la captura, paréntesis para los parámetros y llaves para el cuerpo. [](int x) { return x * x; } se lee «una función que recibe un entero y devuelve su cuadrado».
¿Qué es una lambda?
Una lambda es azúcar para una clase con un operator(). El compilador crea un tipo único, sin nombre, con los miembros que hayas capturado y un método de llamada con el cuerpo que escribiste. Ese objeto se llama cierre (closure) y se puede guardar en una variable, pasar a una función o devolver desde otra.
La diferencia con una función normal es precisamente esa: la lambda puede llevar estado. Una función libre no recuerda nada entre llamadas salvo que uses globales; un cierre guarda copias o referencias de las variables que capturaste, y cada lambda tiene su propio estado.
¿Para qué sirven las lambdas?
- Personalizar algoritmos sin escribir funciones sueltas: el criterio de
sort, el predicado decount_ifo la operación detransformquedan junto a su uso. - Capturar contexto: ordenar por una nota de corte, filtrar por un umbral o acumular en una variable local sin crear parámetros extra.
- Diferir trabajo: guardar una operación en un
std::functiony ejecutarla después, algo habitual en callbacks y sistemas de eventos. - Evitar duplicación: una lambda pequeña reutilizada en varias llamadas del mismo ámbito.
- Escribir código genérico más simple: una lambda genérica
[](const auto& x) { ... }sirve para varios tipos sin declarar una plantilla.
Cómo funciona por dentro: el cierre y la captura
Capturar es decidir qué variables del entorno forman parte del objeto. Con [x] el cierre guarda una copia del valor que x tenía al construirse; con [&x] guarda una referencia a la variable original. La diferencia no es de estilo: cambia qué ocurre cuando el original se modifica o deja de existir.
La copia se hace una sola vez, al crear la lambda, no en cada llamada. Por eso una captura por valor no ve los cambios posteriores. La referencia sí los ve, pero exige que la variable siga viva mientras la lambda pueda ejecutarse. Si devuelves una lambda que captura por referencia una local, tendrás una referencia colgante.
Por defecto, el operator() de la lambda es const: no puede modificar las copias capturadas. La palabra mutable quita ese const y permite cambiar el estado interno del cierre. Es útil para contadores y acumuladores, pero conviene usarla con intención: el estado es propio de la lambda, no de la variable original.
Sintaxis: parámetros, captura, mutable y std::function
La forma completa es [captura](parámetros) -> retorno { cuerpo }. El retorno casi nunca se escribe: el compilador lo deduce de la primera return. Los parámetros pueden ser concretos, o auto para una lambda genérica.
| Captura | Qué guarda | Cuándo usarla |
|---|---|---|
[] | Nada | La lambda no necesita el entorno |
[x] | Una copia de x | El valor debe sobrevivir a la variable original |
[&x] | Una referencia a x | Necesitas leer o modificar el original |
[=] | Copia todo lo que se use | Cómodo, pero oculta qué depende de qué |
[&] | Referencia a todo lo que se use | Bucles cortos y de vida corta |
[this] | El puntero al objeto actual | Dentro de un método de una clase |
#include <functional>
#include <iostream>
int main() {
auto cuadrado = [](int x) { return x * x; };
std::cout << cuadrado(5) << '\n'; // 25
int base = 10;
auto sumar_base = [base](int x) { return base + x; };
std::cout << sumar_base(5) << '\n'; // 15
std::function<int(int)> operacion; // vacia, como un puntero nulo
if (!operacion) {
std::cout << "sin operacion\n";
}
operacion = cuadrado;
std::cout << operacion(4) << '\n'; // 16
return 0;
}std::function es un envoltorio que puede guardar cualquier callable con una firma concreta: una lambda, un puntero a función o un objeto con operator(). Se puede declarar vacío y comprobar con if (!operacion), igual que un puntero con nullptr. Su coste es una posible reserva en el heap y una llamada indirecta; úsalo cuando necesites heterogeneidad o una firma estable en una interfaz, no en bucles internos.
Ejemplo básico: una lambda con estado
#include <iostream>
int main() {
int contador = 0;
auto siguiente = [contador]() mutable {
++contador;
return contador;
};
std::cout << siguiente() << ' ' << siguiente() << '\n'; // 1 2
std::cout << contador << '\n'; // 0
return 0;
}int contador = 0;es la variable del ámbito exterior.[contador]copia el 0 dentro del cierre. La lambda tiene su propiocontador, independiente del exterior.mutablepermite modificar esa copia; sin él,++contadorno compila porque eloperator()esconst.- Cada llamada incrementa la copia interna: la primera devuelve 1 y la segunda 2. El
contadororiginal sigue en 0, como muestra la última línea.
Ejemplo intermedio: lambdas en algoritmos
Aquí está el uso que más verás: pasar el criterio al algoritmo. count_if recibe un predicado y sort un comparador. Ambos aceptan lambdas, así que la regla queda escrita en la misma línea y puede capturar el umbral.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> notas{7, 3, 9, 5, 8, 6};
const int aprobado = 6;
const auto cuantos = std::count_if(notas.begin(), notas.end(),
[aprobado](int n) { return n >= aprobado; });
std::sort(notas.begin(), notas.end(),
[](int a, int b) { return a > b; });
std::cout << "aprobados: " << cuantos << '\n'; // 4
for (int n : notas) {
std::cout << n << ' '; // 9 8 7 6 5 3
}
std::cout << '\n';
return 0;
}El comparador [](int a, int b) { return a > b; } invierte el orden: sort coloca antes los elementos para los que el comparador devuelve true. Si escribes a >= b, el comparador deja de ser un orden estricto y el comportamiento de sort no está garantizado. Esa es una fuente real de fallos intermitentes.
Caso práctico: range-for, structured bindings y auto
C++ moderno reduce el ruido alrededor de los contenedores. auto evita repetir tipos largos, range-for recorre sin índices y los structured bindings descomponen un pair o un struct en nombres con significado. Juntos hacen que el recorrido se lea como una frase.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Modelo {
std::string nombre;
double error;
};
int main() {
std::vector<Modelo> modelos{
{"lineal", 0.42},
{"arbol", 0.31},
{"knn", 0.38},
};
std::sort(modelos.begin(), modelos.end(),
[](const Modelo& a, const Modelo& b) { return a.error < b.error; });
for (const auto& [nombre, error] : modelos) {
std::cout << nombre << ' ' << error << '\n';
}
return 0;
}La salida es arbol 0.31, knn 0.38, lineal 0.42, en orden de error ascendente. const auto& evita copiar cada Modelo y el const impide modificarlo por accidente. Los nombres nombre y error son alias de los miembros, no copias: si el bucle modificara el vector, se vería reflejado. Para copiar de verdad usarías auto [nombre, error].
auto también sirve para punteros: auto* p = buscar(modelos); deduce el tipo apuntado y deja explícito que esperas un puntero. Cuando no hay resultado, devuelve nullptr, no NULL ni 0. nullptr tiene tipo propio y no se confunde con un entero, lo que evita ambigüedades en la sobrecarga.
Visualización: captura por valor y por referencia
Las dos lambdas se crean antes de modificar x, pero no se comportan igual. Avanza por los pasos y fíjate en cuándo se copia el valor y cuándo se lee la variable original.
int x = 1;auto por_valor = [x]() { return x; };auto por_ref = [&x]() { return x; };x = 5;std::cout << por_valor() << ' ' << por_ref() << '\n';x vale 1 en el stack.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
- Devolver una lambda que captura por referencia una local: al salir de la función, la referencia queda colgante. Captura por valor si la lambda va a sobrevivir al ámbito.
- Esperar que
[x]vea cambios posteriores: la copia se hizo al construir el cierre. Si necesitas el valor actual, captura por referencia. - Olvidar
mutable: modificar una copia capturada sinmutabledaassignment of read-only variable. Añádelo solo si el estado interno es intencional. - Usar
[&]en un callback asíncrono: el hilo o la tarea puede ejecutarse cuando las locales ya no existen. Captura explícitamente lo que necesites, por valor. - Guardar una lambda sin captura en
std::functioninnecesariamente: si solo la usas en el momento,autoevita la indirección y la posible reserva. - Confundir
nullptrconNULLo0: usanullptrpara punteros; en una sobrecarga conintychar*,NULLpuede elegir la versión equivocada.
std::function<int()> crear() {
int local = 7;
return [&local]() { return local; };
} // local muere al volverstd::function<int()> crear() {
int local = 7;
return [local]() { return local; };
}Una referencia capturada no prolonga la vida de la variable. Al regresar de `crear`, el stack frame se libera y la lambda apunta a memoria que ya no es suya. La copia cuesta unos bytes y elimina el problema.
Buenas prácticas
- Captura de forma explícita (
[umbral],[&total]) en lugar de[=]o[&]: el lector ve de qué depende la lambda. - Usa
autopara guardar la lambda; reservastd::functionpara interfaces, contenedores heterogéneos y callbacks almacenados. - Mantén las lambdas cortas. Si ocupan más de cinco o seis líneas, una función con nombre se lee mejor y se puede probar.
- En range-for, usa
const auto¶ no copiar yauto&solo si vas a modificar los elementos. - Devuelve
nullptry comprueba conif (p); no devuelvas referencias a locales ni punteros a memoria liberada. - Si la lambda se ejecuta en otro hilo, captura por valor y asegúrate de que los objetos copiados son seguros de compartir.
- Prefiere un comparador que cumpla orden estricto (
<o>), nunca<=o>=.
notas = [7, 3, 9, 5, 8, 6]
aprobado = 6
cuantos = len([n for n in notas if n >= aprobado])
notas.sort(reverse=True)
print(cuantos, notas)std::vector<int> notas{7, 3, 9, 5, 8, 6};
const int aprobado = 6;
auto cuantos = std::count_if(notas.begin(), notas.end(),
[aprobado](int n) { return n >= aprobado; });
std::sort(notas.begin(), notas.end(),
[](int a, int b) { return a > b; });En Python la lambda también existe, pero para filtrar se suele usar una comprensión. En C++ la lambda aparece donde el algoritmo necesita una función; la captura `[aprobado]` sustituye al cierre que en Python hace la comprensión de forma implícita.
Ejercicios
Filtrar, transformar y sumar
std::vector<int> datos{1, 2, 3, 4, 5, 6};, usa std::transform con una lambda para elevar al cuadrado solo los pares en un vector nuevo, y luego suma el resultado con std::accumulate. Muestra el vector y la suma. No escribas bucles manuales.Pista
Puedes usar std::copy_if para quedarte con los pares y después std::transform, o una sola lambda dentro de transform. Para la suma, std::accumulate(v.begin(), v.end(), 0).
Ranking de alumnos
struct Alumno { std::string nombre; double nota; }; y crea un vector con cuatro alumnos. Ordénalo por nota de mayor a menor y, en caso de empate, por nombre alfabético. Imprime el ranking con structured bindings y cuenta cuántos aprobaron con nota mayor o igual que 6 usando count_if.Pista
El comparador recibe dos const Alumno&. Para el empate: if (a.nota != b.nota) return a.nota > b.nota; return a.nombre < b.nombre;.
std::vector<int> v{4, 1, 7, 3}; int limite = 3; auto supera = [](int x) { return x > limite; // debe ver el valor actual }; limite = 5; auto total = std::count_if(v.begin(), v.end(), ); std::cout << total; // 1
Si capturas por valor, la lambda conserva el 3 y contarías dos elementos; necesitas la referencia. El segundo hueco es el nombre del predicado.
Soluciones explicadas
En el primer ejercicio, la clave es que copy_if y transform no asignan memoria por su cuenta: tú decides el contenedor de salida. back_inserter convierte una inserción en un iterador de salida, y por eso funciona aunque pares empiece vacío. La lambda de transform no captura nada porque su cálculo solo depende del argumento.
En el segundo, el comparador debe ser una relación de orden: si a y b tienen la misma nota, compara nombres; si ambos coinciden, devuelve false en las dos direcciones. Escribir solo a.nota > b.nota también compila, pero el orden de los empates queda indefinido entre ejecuciones, algo que complica las pruebas. Los structured bindings y const auto& son la forma habitual de recorrer resultados sin copiar.
Resumen
- Una lambda es un objeto con
operator(); su tipo lo genera el compilador y es único. [x]copia en el momento de crear el cierre;[&x]referencia la variable original.mutablepermite modificar las copias capturadas; no afecta a la variable exterior.- Las lambdas son el criterio natural de
sort,count_if,transformyfind_if. autoguarda la lambda sin coste;std::functionpermite firmas estables y callbacks heterogéneos.range-forconconst auto&y structured bindings simplifica el recorrido de contenedores.nullptres la forma correcta de expresar «sin puntero»; evitaNULLy0.
Evaluación
¿Qué imprime este código? int x = 2; auto f = [x]() { return x; }; x = 9; std::cout << f();
¿Cuándo es preferible std::function frente a auto para guardar una lambda?
¿Por qué es peligroso return [&local]() { return local; }; desde una función?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.