Lección 3 de 8: control y funciones en C++. Un programa casi nunca va en línea recta: decide, repite y se divide en partes. Esta lección cubre if, switch y los bucles, incluido el for de rango, las funciones y cómo reciben sus argumentos (por valor o por referencia), la sobrecarga, cómo devolver varios valores, la recursión y la pila de llamadas, las lambdas y los espacios de nombres, junto con los avisos de g++ que evitan los errores más típicos.
Decidir: if y else
#include <print>
int main()
{
int temperatura = 31;
if (temperatura >= 30) {
std::println("Hace calor");
} else if (temperatura >= 15) {
std::println("Se está bien");
} else {
std::println("Hace frío");
}
int edad = 17;
bool con_permiso = true;
if (edad >= 18 || con_permiso) {
std::println("Puede pasar");
}
if (!(edad >= 18) && !con_permiso) {
std::println("No puede pasar");
}
}salidaHace calor Puede pasar
Los operadores de comparación son ==, !=, <, <=, > y >=, y los lógicos, && (y), || (o) y ! (no). Los dos primeros son de cortocircuito: si con la parte izquierda ya se sabe el resultado, la derecha ni se evalúa. Eso permite escribir con seguridad cosas como if (divisor != 0 && total / divisor > 10). Como en C, un número distinto de cero cuenta como verdadero en un if, aunque en C++ lo normal es comparar con un bool.
El error que te ahorra: = en lugar de ==
#include <print>
int main()
{
int intentos = 0;
if (intentos = 3) {
std::println("Último intento");
}
}g++: avisoasignar.cpp: In function 'int main()':
asignar.cpp:6:18: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
6 | if (intentos = 3) {
| ~~~~~~~~~^~~intentos = 3 no compara: asigna 3 y vale 3, que cuenta como verdadero, así que el if entra siempre. g++ avisa con -Wall; si alguna vez quieres asignar dentro de una condición a propósito, pon paréntesis dobles para que se vea.
if con inicializador
Desde C++17, un if puede declarar una variable antes de la condición, separada por punto y coma. La variable solo existe dentro del if y de su else, así que no se queda por ahí ocupando un nombre:
#include <print>
#include <string>
int main()
{
std::string frase = "Aprender C++ lleva su tiempo";
if (auto pos = frase.find("C++"); pos != std::string::npos) {
std::println("Encontrado en la posición {}", pos);
} else {
std::println("No aparece");
}
}salidaEncontrado en la posición 9
std::string::npos es el valor especial que devuelve find cuando no encuentra nada.
Elegir entre muchos casos: switch
#include <print>
enum class Nivel { bajo, medio, alto };
int main()
{
Nivel nivel = Nivel::medio;
switch (nivel) {
case Nivel::bajo:
std::println("Todo tranquilo");
break;
case Nivel::medio:
std::println("Atención");
break;
case Nivel::alto:
std::println("¡Alarma!");
break;
}
int dia = 6;
switch (dia) {
case 6:
case 7:
std::println("Fin de semana");
break;
default:
std::println("Día laborable");
}
}salidaAtención Fin de semana
switch compara un entero o una enumeración con valores fijos. enum class crea un tipo con un conjunto cerrado de valores, que se escriben con su nombre delante (Nivel::medio); es mejor que usar números sueltos, porque no se confunden con enteros. Cada case termina con break; si no, la ejecución cae en el siguiente. Juntar casos sin código entre ellos, como el 6 y el 7, es la forma correcta de aprovecharlo.
El error que te ahorra: el break que falta
#include <print>
int main()
{
int opcion = 1;
switch (opcion) {
case 1:
std::println("Guardar");
case 2:
std::println("Borrar todo");
break;
}
}g++: avisosin_break.cpp: In function 'int main()':
sin_break.cpp:8:21: warning: this statement may fall through [-Wimplicit-fallthrough=]
8 | std::println("Guardar");
| ~~~~~~~~~~~~^~~~~~~~~~~
sin_break.cpp:9:5: note: here
9 | case 2:
| ^~~~salidaGuardar Borrar todo
Elegir «Guardar» también borra todo. -Wextra activa -Wimplicit-fallthrough, que avisa de cada caída. Si de verdad quieres caer, dilo con [[fallthrough]]; justo antes del case siguiente, y el aviso desaparece.
Con un enum class, g++ además avisa si el switch se olvida de algún valor, porque sabe cuáles son todos:
enum class Color { rojo, verde, azul };
int codigo(Color c)
{
switch (c) {
case Color::rojo: return 1;
case Color::verde: return 2;
}
return 0;
}
int main()
{
return codigo(Color::azul);
}g++: avisoswitch_falta.cpp: In function 'int codigo(Color)':
switch_falta.cpp:5:12: warning: enumeration value 'azul' not handled in switch [-Wswitch]
5 | switch (c) {
| ^Repetir: while, do y for
#include <print>
int main()
{
int cuenta = 3;
while (cuenta > 0) {
std::print("{}... ", cuenta);
--cuenta;
}
std::println("¡ya!");
for (int i = 0; i < 5; ++i) {
std::print("{} ", i * i);
}
std::println("");
int n = 0;
do {
++n;
} while (n < 0);
std::println("do entra al menos una vez: n = {}", n);
}salida3... 2... 1... ¡ya! 0 1 4 9 16 do entra al menos una vez: n = 1
while repite mientras la condición sea verdadera. for junta en una línea las tres partes típicas: el valor inicial, la condición y el paso. do ... while comprueba al final, así que el cuerpo se ejecuta al menos una vez. break sale del bucle y continue salta a la siguiente vuelta.
El for de rango
Lo más habitual en C++ moderno es recorrer todos los elementos de algo (una lista, una cadena, un contenedor) sin índices, con el for de rango:
#include <print>
#include <string>
#include <vector>
int main()
{
std::vector<int> notas = {7, 9, 4, 10, 6};
int suma = 0;
for (int nota : notas) {
suma += nota;
}
std::println("Media: {:.1f}", static_cast<double>(suma) / notas.size());
for (char letra : std::string("hola")) {
std::print("{} ", letra);
}
std::println("");
for (int& nota : notas) {
nota += 1;
}
for (int nota : notas) {
if (nota < 6) continue;
std::print("{} ", nota);
}
std::println("");
for (int i : {1, 2, 3}) {
std::print("[{}]", i);
}
std::println("");
}salidaMedia: 7.2 h o l a 8 10 11 7 [1][2][3]
for (int nota : notas) se lee «para cada nota de notas». La variable es una copia de cada elemento; si quieres modificarlos, usa una referencia, int&, como en el tercer bucle, que sube un punto a todas las notas. std::vector es la lista de tamaño variable de C++, y la vemos a fondo en la lección 4.
Funciones
#include <print>
double area_rectangulo(double base, double altura);
int main()
{
std::println("{}", area_rectangulo(3.0, 4.5));
std::println("{}", area_rectangulo(2, 2));
}
double area_rectangulo(double base, double altura)
{
return base * altura;
}salida13.5 4
Una función tiene un tipo de retorno, un nombre, unos parámetros con su tipo y un cuerpo. Si no devuelve nada, su tipo es void. C++ exige que una función esté declarada antes de usarse. Por eso la primera línea, el prototipo, solo anuncia la función, y la definición completa puede ir después de main. En programas grandes, los prototipos van en archivos de cabecera, como veremos en la lección 8. Fíjate también en area_rectangulo(2, 2): los enteros se convierten solos a double.
El error que te ahorra: una función que no siempre devuelve
#include <print>
int signo(int n)
{
if (n > 0) return 1;
if (n < 0) return -1;
}
int main()
{
std::println("{}", signo(5));
}g++: avisosin_return.cpp: In function 'int signo(int)':
sin_return.cpp:7:1: warning: control reaches end of non-void function [-Wreturn-type]
7 | }
| ^Si n vale 0, la función llega al final sin return. En C++ eso es comportamiento indefinido: con optimizaciones, el programa puede hacer cualquier cosa, incluso seguir ejecutando la función que venga detrás en la memoria. Trata este aviso como un error.
Paso por valor y por referencia
Por omisión, una función recibe copias de sus argumentos. Para que trabaje con la variable original hay que pasarla por referencia, con & detrás del tipo:
#include <print>
#include <string>
void sumar_por_valor(int x) { x += 5; }
void sumar_por_referencia(int& x) { x += 5; }
int contar_vocales(const std::string& texto)
{
int n = 0;
for (char c : texto) {
if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') ++n;
}
return n;
}
int main()
{
int puntos = 10;
sumar_por_valor(puntos);
std::println("Por valor: {}", puntos);
sumar_por_referencia(puntos);
std::println("Por referencia: {}", puntos);
std::string libro = "en un lugar de la mancha de cuyo nombre no quiero acordarme";
std::println("Vocales: {}", contar_vocales(libro));
}salidaPor valor: 10 Por referencia: 15 Vocales: 22
La regla práctica es sencilla. Los tipos pequeños (int, double, char, bool) se pasan por valor. Lo que la función debe modificar se pasa por referencia, T&. Y los objetos grandes que solo hay que leer, como un std::string o un std::vector, se pasan por referencia a const, const T&: no se copian y la función no puede cambiarlos por error.
Devolver varios valores
Una función devuelve un solo valor, pero ese valor puede ser una pareja o una estructura. Con los enlaces estructurados (C++17) se recogen sus partes con nombre, que es lo que hacía el auto [resto, error] de la lección 1:
#include <print>
#include <utility>
std::pair<int, int> dividir(int dividendo, int divisor)
{
return {dividendo / divisor, dividendo % divisor};
}
struct Estadisticas {
int minimo;
int maximo;
double media;
};
Estadisticas resumir(int a, int b, int c)
{
int menor = std::min({a, b, c});
int mayor = std::max({a, b, c});
return {menor, mayor, (a + b + c) / 3.0};
}
int main()
{
auto [cociente, resto] = dividir(17, 5);
std::println("17 / 5 = {} y sobran {}", cociente, resto);
auto [minimo, maximo, media] = resumir(4, 9, 2);
std::println("mínimo {}, máximo {}, media {:.2f}", minimo, maximo, media);
}salida17 / 5 = 3 y sobran 2 mínimo 2, máximo 9, media 5.00
Sobrecarga y argumentos por omisión
En C++ varias funciones pueden llamarse igual si sus parámetros son distintos: es la sobrecarga, y el compilador elige la que encaja con los argumentos. Y un parámetro puede tener un valor por omisión, que se usa si no se pasa:
#include <print>
#include <string>
void mostrar(int n) { std::println("Entero: {}", n); }
void mostrar(double d) { std::println("Decimal: {:.2f}", d); }
void mostrar(const std::string& s) { std::println("Texto: {}", s); }
std::string saludo(const std::string& nombre, const std::string& forma = "Hola")
{
return forma + ", " + nombre;
}
int main()
{
mostrar(42);
mostrar(3.14159);
mostrar(std::string("C++"));
std::println("{}", saludo("Ana"));
std::println("{}", saludo("Ana", "Buenos días"));
}salidaEntero: 42 Decimal: 3.14 Texto: C++ Hola, Ana Buenos días, Ana
Los parámetros con valor por omisión tienen que ir al final. La sobrecarga es cómoda, pero no hay que abusar: si dos funciones hacen cosas distintas, mejor que tengan nombres distintos.
Recursión y la pila de llamadas
Una función puede llamarse a sí misma. Es la recursión, y funciona siempre que haya un caso que termine sin volver a llamarse:
#include <print>
long long factorial(int n)
{
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
int main()
{
std::println("{}", factorial(3));
std::println("{}", factorial(20));
}salida6 2432902008176640000
factorial apila un marco con su propia n; al llegar al caso base, los resultados vuelven hacia abajo. Esquema: decodigo.com.
Cada llamada guarda sus variables locales en un marco de la pila, una zona de memoria que crece al llamar y se recoge al volver. La pila es pequeña (8 MB por omisión en Linux, 1 MB en Windows), así que una recursión demasiado profunda la agota. Con factorial(21) ya no cabe en un long long: el resultado se desborda, como vimos en la lección 2.
Ojo con la seguridad: una recursión que controla quien te ataca
Si la profundidad de una recursión depende de un dato que viene de fuera (un JSON con objetos anidados, una expresión con paréntesis, un árbol de carpetas), alguien puede mandar un dato hecho a propósito para que la recursión no acabe hasta agotar la pila. El programa se cae: es una denegación de servicio, y han tenido fallos así analizadores de JSON y XML muy usados. AddressSanitizer lo detecta y dice dónde:
#include <print>
int profundidad(int nivel)
{
char marco[256] = {};
marco[0] = static_cast<char>(nivel);
if (nivel == 0) return marco[0];
return profundidad(nivel - 1) + marco[0];
}
int main()
{
int niveles = 1'000'000;
std::println("{}", profundidad(niveles));
}salida con error==170==ERROR: AddressSanitizer: stack-overflow on address 0x7ffd85182ff8 (pc 0x7e354bcf7c6a bp 0x7ffd85183050 sp 0x7ffd85183000 T0)
…
#3 0x00000040465a in profundidad(int) /curso/profunda.cpp:4
#4 0x000000404828 in profundidad(int) /curso/profunda.cpp:8
#5 0x000000404828 in profundidad(int) /curso/profunda.cpp:8
…
SUMMARY: AddressSanitizer: stack-overflow /curso/profunda.cpp:4 in profundidad(int)La forma segura es poner un límite a la profundidad y rechazar lo que lo supere, o reescribir la función con un bucle y una pila propia en el montón, que puede crecer mucho más. Más en las rarezas.
No ignorar lo que devuelve: [[nodiscard]]
Si una función devuelve algo importante, como un código de error o un valor que la función no guarda en ningún otro sitio, ignorarlo casi siempre es un fallo. El atributo [[nodiscard]] hace que el compilador avise a quien lo ignore:
#include <print>
#include <string>
[[nodiscard]] bool guardar(const std::string& archivo)
{
return !archivo.empty();
}
int main()
{
guardar("datos.txt");
if (!guardar("")) {
std::println("No se pudo guardar");
}
}g++: avisonodiscard.cpp: In function 'int main()':
nodiscard.cpp:11:12: warning: ignoring return value of 'bool guardar(const std::string&)', declared with attribute 'nodiscard' [-Wunused-result]
11 | guardar("datos.txt");
| ~~~~~~~^~~~~~~~~~~~~
nodiscard.cpp:4:20: note: declared here
4 | [[nodiscard]] bool guardar(const std::string& archivo)
| ^~~~~~~Muchas funciones de la biblioteca estándar ya lo llevan: g++ avisa, por ejemplo, si llamas a v.empty() pensando que vacía el vector, cuando solo pregunta si está vacío.
Lambdas: funciones sin nombre
Una lambda es una función que se escribe en el mismo sitio donde se usa, sin darle nombre. Se guarda en una variable con auto o se pasa directamente a otra función. Entre los corchetes del principio va la captura: qué variables de alrededor puede usar.
#include <algorithm>
#include <print>
#include <vector>
int main()
{
auto doble = [](int x) { return x * 2; };
std::println("{}", doble(21));
int limite = 5;
auto mayor_que_limite = [limite](int x) { return x > limite; };
std::println("{} {}", mayor_que_limite(3), mayor_que_limite(8));
int llamadas = 0;
auto contar = [&llamadas]() { ++llamadas; };
contar();
contar();
std::println("Llamadas: {}", llamadas);
std::vector<int> v = {4, 12, 7, 1, 9};
auto cuantos = std::count_if(v.begin(), v.end(), [limite](int x) { return x > limite; });
std::println("{} números mayores que {}", cuantos, limite);
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
for (int x : v) std::print("{} ", x);
std::println("");
}salida42 false true Llamadas: 2 3 números mayores que 5 12 9 7 4 1
[limite] captura limite por valor: la lambda guarda su propia copia. [&llamadas] lo captura por referencia, así que la lambda modifica la variable de fuera. [] no captura nada. Donde más se usan es en los algoritmos de la biblioteca estándar: std::count_if cuenta los elementos que cumplen la condición, y std::sort ordena con el criterio que le digas (aquí, de mayor a menor). Los algoritmos son parte de la lección 4.
El error que te ahorra: devolver una referencia a una variable local
#include <print>
#include <string>
const std::string& saludo()
{
std::string texto = "Hola";
return texto;
}
int main()
{
std::println("{}", saludo());
}g++: avisoref_local.cpp: In function 'const std::string& saludo()':
ref_local.cpp:7:12: warning: reference to local variable 'texto' returned [-Wreturn-local-addr]
7 | return texto;
| ^~~~~
ref_local.cpp:6:17: note: declared here
6 | std::string texto = "Hola";
| ^~~~~Al terminar la función, texto se destruye, y la referencia queda apuntando a memoria que ya no existe: una referencia colgante. g++ lo detecta en este caso tan claro, pero no en todos. La solución aquí es devolver por valor, std::string saludo(), que en C++ moderno no cuesta una copia. La lección 6 vuelve sobre esto.
Espacios de nombres
En un programa grande es fácil que dos partes usen el mismo nombre para cosas distintas. Los espacios de nombres los separan, igual que la biblioteca estándar mete todo en std:
#include <print>
namespace geometria {
constexpr double pi = 3.14159265358979;
double area_circulo(double r) { return pi * r * r; }
}
namespace finanzas {
double area_circulo(double) { return 0; }
}
int main()
{
std::println("{:.2f}", geometria::area_circulo(2));
std::println("{}", finanzas::area_circulo(2));
using geometria::pi;
std::println("{:.4f}", pi);
}salida12.57 0 3.1416
Se accede con nombre::cosa, y using geometria::pi; permite escribir solo pi a partir de ahí. Verás mucho using namespace std; al principio de los programas, para no escribir std::. En un ejercicio pequeño no pasa nada, pero en un archivo de cabecera o en un proyecto grande trae de golpe miles de nombres y provoca choques difíciles de entender. En este curso escribimos siempre std::.
Resumen
- ParaEscribeRecuerda
- Decidir
if (a && b) ... else ...==compara,=asigna - Variable solo para el if
if (auto x = f(); x > 0)C++17 - Muchos casos
switchconenum classbreako[[fallthrough]] - Recorrer todo
for (const auto& x : v)auto¶ modificar - Pasar algo grande
const std::string& ssin copiar y sin poder cambiarlo - Modificar el argumento
int& xreferencia - Devolver dos cosas
auto [a, b] = f();std::pairo unstruct - Avisar si se ignora
[[nodiscard]]para errores y resultados - Función en el sitio
[captura](int x) { ... }por valor si vive más
Retos
Reto 1. Escribe una función bool es_primo(int n) y úsala para imprimir los primos menores que 30.
Ver la solución
#include <print>
bool es_primo(int n)
{
if (n < 2) return false;
for (int d = 2; d * d <= n; ++d) {
if (n % d == 0) return false;
}
return true;
}
int main()
{
for (int n = 1; n < 30; ++n) {
if (es_primo(n)) std::print("{} ", n);
}
std::println("");
}salida2 3 5 7 11 13 17 19 23 29
Basta con probar divisores hasta la raíz cuadrada de n: si n tuviera un divisor mayor, tendría también uno menor, que ya habríamos encontrado. La condición d * d <= n evita calcular la raíz.
Reto 2. El máximo común divisor de dos números se calcula con el algoritmo de Euclides: mcd(a, b) es a si b es 0, y si no, mcd(b, a % b). Escríbelo de forma recursiva y calcula mcd(1071, 462). Después compáralo con std::gcd, de <numeric>.
Ver la solución
#include <numeric>
#include <print>
int mcd(int a, int b)
{
if (b == 0) return a;
return mcd(b, a % b);
}
int main()
{
std::println("{}", mcd(1071, 462));
std::println("{}", std::gcd(1071, 462));
}salida21 21
1071 y 462 se reducen a 462 y 147, luego a 147 y 21 y por último a 21 y 0. La biblioteca estándar ya trae std::gcd desde C++17: antes de escribir un algoritmo clásico, mira si ya existe.
Reto 3. ¿Qué escribe este programa? Piénsalo antes de mirar: void f(int a, int& b) { a = 1; b = 2; } y en main, int x = 0, y = 0; f(x, y); std::println("{} {}", x, y);
Ver la solución
#include <print>
void f(int a, int& b)
{
a = 1;
b = 2;
}
int main()
{
int x = 0, y = 0;
f(x, y);
std::println("{} {}", x, y);
}salida0 2
a es una copia de x, así que cambiarla no afecta a x, que sigue a 0. b es una referencia a y, y cambiarla cambia y. Y g++ da una pista al compilar: avisa de que a a se le da un valor que nadie usa, es decir, de que ese cambio no sirve para nada.
g++: avisoreto3.cpp: In function 'void f(int, int&)':
reto3.cpp:3:12: warning: parameter 'a' set but not used [-Wunused-but-set-parameter]
3 | void f(int a, int& b)
| ~~~~^Todo el código de esta lección se compiló con GCC 15.3 (C++23, -std=c++23 -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con g++ --version: hace falta GCC 14 o más nuevo (o Clang 19 con libstdc++ 14) para std::print y el resto de C++23.
