Lección 8 de 8: un proyecto completo en C++. Para terminar, un programa real de principio a fin: un resumen de gastos a partir de archivos CSV, con varios módulos, errores con std::expected, compilado con CMake y con pruebas automáticas. Verás tres fallos de verdad que tuvo este proyecto: una referencia colgante que solo vio AddressSanitizer, un uso tras mover que encontró clang-tidy y una conversión que paró el compilador. Y, al final, cómo compilarlo para producción con las protecciones de GCC.
Qué vamos a construir
Un programa de terminal, gastos, que lee uno o varios archivos CSV con gastos y dice cuánto se gastó en cada categoría. Es pequeño, pero tiene todo lo de un programa real en C++: varios archivos, tipos propios, gestión de errores sin excepciones con std::expected, opciones en la línea de órdenes, un sistema de compilación, pruebas automáticas y una forma de compilarlo para producción. El archivo de entrada es así:
fecha,categoria,importe 2026-10-01,comida,12.50 2026-10-01,transporte,30 2026-10-03,comida,8 2026-10-04,casa,1250.00 2026-10-07,ocio,45.90 2026-10-09,comida,23.75 2026-10-12,transporte,15.5 2026-10-15,casa,320.40 2026-10-20,ocio,18 2026-10-28,comida,61.30
Y así se usa:
./build/gastos gastos.csv
salidacasa $1,570.40 comida $105.55 ocio $63.90 transporte $45.50 TOTAL $1,785.35
El proyecto tiene tres partes:
gasto.hppygasto.cpp: convierten una línea del CSV en unGasto, o en un mensaje de error.resumen.hppyresumen.cpp: una clase que acumula los gastos por categoría y los ordena.main.cpp: abre los archivos, lee línea a línea, informa de los errores e imprime el resumen.
Además, pruebas.cpp con las pruebas automáticas y CMakeLists.txt, que explica a CMake cómo compilar todo.
Leer un gasto: gasto.hpp y gasto.cpp
#pragma once
#include <expected>
#include <string>
#include <string_view>
struct Gasto {
std::string fecha;
std::string categoria;
long long centavos;
};
// "12.50" -> 1250. Acepta 0, 1 o 2 decimales; nada de signos ni letras.
std::expected<long long, std::string> leer_importe(std::string_view texto);
// Una línea del CSV: fecha,categoria,importe
std::expected<Gasto, std::string> leer_gasto(std::string_view linea);La cabecera solo declara: qué es un Gasto y qué funciones hay. #pragma once evita que se incluya dos veces en el mismo archivo; no es del estándar, pero lo entienden GCC, Clang y MSVC, y ha sustituido casi del todo a las guardas con #ifndef. Las dos funciones devuelven std::expected (C++23): o el valor, o un std::string con el error. Quien las llama no puede usar el resultado sin preguntar antes si salió bien.
El importe se guarda en centavos, como entero, y no en un double. Los números con decimales del ordenador son binarios: 0.1 no se puede representar exacto, y sumar muchos importes acumula errores de redondeo. Con enteros, 12.50 son 1250 centavos y las sumas son exactas. Es lo que hace cualquier programa que maneje dinero.
#include "gasto.hpp"
#include <charconv>
#include <limits>
#include <vector>
namespace {
std::vector<std::string_view> partir(std::string_view linea, char separador)
{
std::vector<std::string_view> campos;
std::size_t inicio = 0;
while (true) {
std::size_t fin = linea.find(separador, inicio);
campos.push_back(linea.substr(inicio, fin - inicio));
if (fin == std::string_view::npos) {
return campos;
}
inicio = fin + 1;
}
}
// Convierte todo el texto en un entero no negativo, o nada.
bool a_entero(std::string_view texto, long long& valor)
{
const char* fin = texto.data() + texto.size();
// from_chars recibe el final del texto: no necesita el '\0' que echa en falta clang-tidy.
// NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage)
auto [donde, error] = std::from_chars(texto.data(), fin, valor);
return error == std::errc{} && donde == fin && valor >= 0;
}
} // namespace
std::expected<long long, std::string> leer_importe(std::string_view texto)
{
std::size_t punto = texto.find('.');
std::string_view enteros = texto.substr(0, punto);
std::string_view decimales;
if (punto != std::string_view::npos) {
decimales = texto.substr(punto + 1);
}
long long pesos = 0;
long long centavos = 0;
if (enteros.empty() || decimales.size() > 2 || !a_entero(enteros, pesos)
|| (!decimales.empty() && !a_entero(decimales, centavos))) {
return std::unexpected("importe no válido: \"" + std::string(texto) + "\"");
}
if (decimales.size() == 1) {
centavos *= 10;
}
if (pesos > (std::numeric_limits<long long>::max() - centavos) / 100) {
return std::unexpected("importe demasiado grande: \"" + std::string(texto) + "\"");
}
return pesos * 100 + centavos;
}
std::expected<Gasto, std::string> leer_gasto(std::string_view linea)
{
if (linea.ends_with('\r')) { // archivos guardados en Windows
linea.remove_suffix(1);
}
auto campos = partir(linea, ',');
if (campos.size() != 3) {
return std::unexpected("se esperaban 3 campos y hay " + std::to_string(campos.size()));
}
auto importe = leer_importe(campos[2]);
if (!importe) {
return std::unexpected(importe.error());
}
return Gasto{std::string(campos[0]), std::string(campos[1]), *importe};
}Lo más interesante:
- El
namespacesin nombre hace quepartirya_enterosolo existan dentro de este archivo, como elstaticde las funciones de C. Así no chocan con otra función que se llame igual en otro módulo. partirdevuelvestd::string_view: vistas sobre la línea original, sin copiar nada. Es seguro porque las vistas se usan mientras la línea sigue viva; cuando hace falta guardar el texto, elGastolo copia enstd::string. En este mismo proyecto veremos qué pasa cuando no se respeta esa regla.std::from_chars(C++17) convierte texto en número sin excepciones, sin reservar memoria y sin depender del idioma del sistema. Comprobamos que no hubo error y que consumió todo el texto:12xno es un número.- La comprobación de desbordamiento antes de
pesos * 100: un importe de 20 cifras desbordaría ellong long, y en C++ el desbordamiento de un entero con signo es comportamiento indefinido. La prueba del número enorme lo vigila. - El
'\r'final: un CSV guardado en Windows termina cada línea en\r\n, ystd::getlinesolo quita el\n. Sin esa línea, el último campo sería12.50\ry no se aceptaría como importe.
Acumular: la clase Resumen
#pragma once
#include "gasto.hpp"
#include <functional>
#include <map>
#include <string>
#include <utility>
#include <vector>
class Resumen {
public:
void anadir(const Gasto& gasto);
long long total() const { return total_; }
// Las categorías, de más a menos gasto.
std::vector<std::pair<std::string, long long>> por_categoria() const;
private:
std::map<std::string, long long, std::less<>> categorias_;
long long total_ = 0;
};
// 123456789 centavos -> "$1,234,567.89"
std::string formatear(long long centavos);#include "resumen.hpp"
#include <algorithm>
#include <format>
void Resumen::anadir(const Gasto& gasto)
{
categorias_[gasto.categoria] += gasto.centavos;
total_ += gasto.centavos;
}
std::vector<std::pair<std::string, long long>> Resumen::por_categoria() const
{
std::vector<std::pair<std::string, long long>> filas(categorias_.begin(), categorias_.end());
std::ranges::sort(filas, [](const auto& a, const auto& b) {
if (a.second != b.second) {
return a.second > b.second;
}
return a.first < b.first;
});
return filas;
}
std::string formatear(long long centavos)
{
std::string enteros = std::to_string(centavos / 100);
for (auto i = static_cast<long long>(enteros.size()) - 3; i > 0; i -= 3) {
enteros.insert(static_cast<std::size_t>(i), ",");
}
return std::format("${}.{:02}", enteros, centavos % 100);
}std::map<std::string, long long, std::less<>>: constd::less<>el mapa es transparente y permite buscar con unstd::string_viewo unconst char*sin construir unstd::stringtemporal.categorias_[gasto.categoria] += ...: eloperator[]destd::mapcrea la entrada con 0 si no existe, que aquí es justo lo que queremos.std::ranges::sortordena de más a menos gasto y, a igualdad, alfabéticamente, para que la salida sea siempre la misma.formatearpone las comas de los miles a mano.std::formatpuede hacerlo con{:L}y unstd::locale, pero entonces el resultado depende de la configuración del sistema.
El programa principal
#include "gasto.hpp"
#include "resumen.hpp"
#include <cstdio>
#include <exception>
#include <fstream>
#include <print>
#include <string>
namespace {
int ejecutar(int argc, char* argv[])
{
if (argc < 2) {
std::println(stderr, "uso: gastos ARCHIVO.csv [ARCHIVO.csv...]");
return 2;
}
Resumen resumen;
int errores = 0;
for (int i = 1; i < argc; ++i) {
std::string ruta = argv[i];
std::ifstream archivo(ruta);
if (!archivo) {
std::println(stderr, "{}: no se puede abrir", ruta);
++errores;
continue;
}
std::string linea;
int numero = 0;
while (std::getline(archivo, linea)) {
++numero;
if (numero == 1 || linea.empty()) { // la cabecera y las líneas en blanco
continue;
}
auto gasto = leer_gasto(linea);
if (!gasto) {
std::println(stderr, "{}:{}: {}", ruta, numero, gasto.error());
++errores;
continue;
}
resumen.anadir(*gasto);
}
}
for (const auto& [categoria, centavos] : resumen.por_categoria()) {
std::println("{:<14}{:>12}", categoria, formatear(centavos));
}
std::println("{:<14}{:>12}", "TOTAL", formatear(resumen.total()));
return errores == 0 ? 0 : 1;
}
} // namespace
int main(int argc, char* argv[])
{
try {
return ejecutar(argc, argv);
} catch (const std::exception& e) { // sin memoria, error al escribir la salida...
std::fprintf(stderr, "gastos: %s\n", e.what());
return 3;
}
}Cada error se informa con el archivo y la línea, al estilo de los compiladores, y el programa sigue con el resto: un CSV con una línea mal no debería impedir ver el resumen de las demás. El código de salida dice si todo fue bien (0), si hubo errores en los datos (1) o si se llamó mal al programa (2). El try de main lo explicamos al hablar de clang-tidy.
./build/gastos errores.csv no_existe.csv
salida con errorerrores.csv:3: se esperaban 3 campos y hay 2 errores.csv:4: importe no válido: "1.999" no_existe.csv: no se puede abrir comida $9.99 TOTAL $9.99 rc 1
CMake: el sistema de compilación
Con tres archivos todavía se puede compilar a mano con g++, pero en cuanto el proyecto crece hace falta algo que sepa qué recompilar, con qué opciones y en qué orden. En C++ el estándar de facto es CMake: no compila nada por sí mismo, sino que genera los archivos para la herramienta de compilación de cada sistema (Makefiles en Linux, proyectos de Visual Studio en Windows, Ninja…). Así un mismo proyecto se compila igual en todas partes, y los editores como VS Code, CLion o Visual Studio lo abren directamente.
cmake_minimum_required(VERSION 3.25)
project(gastos LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # compile_commands.json, para clang-tidy
option(GASTOS_SANITIZAR "Compilar con AddressSanitizer y UBSan" OFF)
# El código del programa, en una biblioteca que comparten el ejecutable y las pruebas.
add_library(gastos_lib gasto.cpp resumen.cpp)
target_include_directories(gastos_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(gastos_lib PUBLIC -Wall -Wextra -Wpedantic -Wconversion -Werror)
if(GASTOS_SANITIZAR)
target_compile_options(gastos_lib PUBLIC -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(gastos_lib PUBLIC -fsanitize=address,undefined)
endif()
add_executable(gastos main.cpp)
target_link_libraries(gastos PRIVATE gastos_lib)
enable_testing()
add_executable(pruebas pruebas.cpp)
target_link_libraries(pruebas PRIVATE gastos_lib)
add_test(NAME pruebas COMMAND pruebas)
add_test(NAME archivo_de_ejemplo COMMAND gastos ${CMAKE_CURRENT_SOURCE_DIR}/gastos.csv)CMAKE_CXX_STANDARD 23conREQUIREDy sin extensiones equivale a-std=c++23, y falla al configurar si el compilador no lo soporta.add_library: el código del programa va en una biblioteca que comparten el ejecutable y las pruebas, para no compilarlo dos veces ni probar algo distinto de lo que se entrega.target_compile_options(... PUBLIC ...): los avisos se activan para la biblioteca y, por serPUBLIC, para todo lo que la usa. Es el CMake «moderno»: las opciones se atan a objetivos concretos y no a variables globales.option(GASTOS_SANITIZAR ...): un interruptor que se elige al configurar, como veremos.CMAKE_EXPORT_COMPILE_COMMANDScreacompile_commands.json, la lista de órdenes exactas de compilación que necesitan clang-tidy y los editores.
Se trabaja siempre fuera del código fuente, en una carpeta aparte (build), que se puede borrar sin miedo:
cmake -S . -B build
salida-- The CXX compiler identification is GNU 15.3.0 -- Detecting CXX compiler ABI info -- Detecting CXX compiler ABI info - done -- Check for working CXX compiler: /usr/local/bin/c++ - skipped -- Detecting CXX compile features -- Detecting CXX compile features - done -- Configuring done (0.2s) -- Generating done (0.0s) -- Build files have been written to: /tmp/gastos/build
cmake --build build
salida[ 14%] Building CXX object CMakeFiles/gastos_lib.dir/gasto.cpp.o [ 28%] Building CXX object CMakeFiles/gastos_lib.dir/resumen.cpp.o [ 42%] Linking CXX static library libgastos_lib.a [ 42%] Built target gastos_lib [ 57%] Building CXX object CMakeFiles/gastos.dir/main.cpp.o [ 71%] Linking CXX executable gastos [ 71%] Built target gastos [ 85%] Building CXX object CMakeFiles/pruebas.dir/pruebas.cpp.o [100%] Linking CXX executable pruebas [100%] Built target pruebas
Avisos como errores
Con -Werror, cualquier aviso detiene la compilación. Parece exagerado, pero es la única forma de que los avisos no se acumulen hasta que nadie los lee. -Wconversion, además, avisa de las conversiones que pueden perder información. Si en gasto.cpp alguien guarda la posición del punto en un int:
int punto = texto.find('.');salida con error/tmp/v2/gasto.cpp:37:27: error: conversion from 'std::basic_string_view<char>::size_type' {aka 'long unsigned int'} to 'int' may change value [-Werror=conversion]
37 | int punto = texto.find('.');
| ~~~~~~~~~~^~~~~
/tmp/v2/gasto.cpp:40:15: error: comparison of integer expressions of different signedness: 'int' and 'const std::basic_string_view<char>::size_type' {aka 'const long unsigned int'} [-Werror=sign-compare]
40 | if (punto != std::string_view::npos) {
| ~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~
cc1plus: all warnings being treated as errors
…
rc 2find devuelve un size_t de 64 bits sin signo, y cuando no encuentra nada devuelve npos, el número más grande posible: guardado en un int se convierte en -1, y la comparación con npos de unas líneas más abajo, que el compilador también señala, dejaría de funcionar. Dos errores de verdad, encontrados sin ejecutar nada.
Pruebas con ctest
C++ no trae un sistema de pruebas en la biblioteca estándar. Los proyectos grandes usan GoogleTest, Catch2 o doctest, que se añaden a CMake en un par de líneas. Para no depender de nada externo, aquí basta una función:
#include "gasto.hpp"
#include "resumen.hpp"
#include <print>
#include <source_location>
#include <string_view>
namespace {
int comprobaciones = 0;
int fallos = 0;
void comprobar(bool correcto, std::string_view que,
std::source_location donde = std::source_location::current())
{
++comprobaciones;
if (!correcto) {
++fallos;
std::println(stderr, "{}:{}: falla: {}", donde.file_name(), donde.line(), que);
}
}
void prueba_importes()
{
comprobar(leer_importe("12.50") == 1250, "12.50 son 1250 centavos");
comprobar(leer_importe("7") == 700, "7 son 700 centavos");
comprobar(leer_importe("0.5") == 50, "0.5 son 50 centavos");
comprobar(!leer_importe("1.234"), "tres decimales no valen");
comprobar(!leer_importe("-3"), "los negativos no valen");
comprobar(!leer_importe("12,50"), "la coma decimal no vale");
comprobar(!leer_importe(""), "vacío no vale");
comprobar(!leer_importe("99999999999999999999"), "un número enorme no vale");
}
void prueba_lineas()
{
auto g = leer_gasto("2026-10-01,comida,12.50");
comprobar(g && g->categoria == "comida" && g->centavos == 1250, "una línea normal");
comprobar(leer_gasto("2026-10-01,comida,12.50\r").has_value(), "una línea de Windows");
auto mal = leer_gasto("2026-10-01,comida");
comprobar(!mal && mal.error() == "se esperaban 3 campos y hay 2", "faltan campos");
}
Resumen de_ejemplo()
{
Resumen r;
r.anadir({"2026-10-01", "comida", 1250});
r.anadir({"2026-10-02", "transporte", 3000});
r.anadir({"2026-10-03", "comida", 800});
return r;
}
void prueba_resumen()
{
auto filas = de_ejemplo().por_categoria();
comprobar(filas.size() == 2, "dos categorías");
comprobar(filas[0].first == "transporte" && filas[0].second == 3000, "primero, la que más gasta");
comprobar(filas[1].first == "comida" && filas[1].second == 2050, "comida suma 20.50");
comprobar(de_ejemplo().total() == 5050, "el total");
}
void prueba_formato()
{
comprobar(formatear(123456789) == "$1,234,567.89", "separador de miles");
comprobar(formatear(5) == "$0.05", "menos de un peso");
comprobar(formatear(100000) == "$1,000.00", "mil exactos");
}
} // namespace
int main()
{
prueba_importes();
prueba_lineas();
prueba_resumen();
prueba_formato();
std::println("{} comprobaciones, {} fallos", comprobaciones, fallos);
return fallos == 0 ? 0 : 1;
}std::source_location (C++20) es la versión moderna de __FILE__ y __LINE__: como valor por omisión de un parámetro, recoge el archivo y la línea de quien llama, así que el mensaje de fallo señala la comprobación exacta sin necesidad de macros. add_test registra dos pruebas en CMake: el programa de pruebas y una ejecución de gastos con el archivo de ejemplo, que falla si el código de salida no es 0. ctest las ejecuta:
ctest --test-dir build --output-on-failure
salidaInternal ctest changing into directory: /tmp/gastos/build
Test project /tmp/gastos/build
Start 1: pruebas
1/2 Test #1: pruebas .......................... Passed 0.00 sec
Start 2: archivo_de_ejemplo
2/2 Test #2: archivo_de_ejemplo ............... Passed 0.00 sec
100% tests passed, 0 tests failed out of 2
Total Test time (real) = 0.01 secY la misma prueba con los sanitizadores, en una segunda carpeta de compilación. El código no cambia: solo se configura con la opción encendida.
cmake -S . -B build-san -DGASTOS_SANITIZAR=ON cmake --build build-san ctest --test-dir build-san --output-on-failure
salidaInternal ctest changing into directory: /tmp/gastos/build-san
Test project /tmp/gastos/build-san
Start 1: pruebas
1/2 Test #1: pruebas .......................... Passed 0.01 sec
Start 2: archivo_de_ejemplo
2/2 Test #2: archivo_de_ejemplo ............... Passed 0.01 sec
100% tests passed, 0 tests failed out of 2
Total Test time (real) = 0.03 secLo que las pruebas encontraron
La primera versión de Resumen::por_categoria no copiaba los nombres de las categorías: devolvía vistas sobre las claves del mapa, para ahorrarse las copias.
std::vector<std::pair<std::string_view, long long>> por_categoria() const;
El programa funcionaba perfectamente, y las pruebas pasaban:
salida1/2 Test #1: pruebas .......................... Passed 0.00 sec … 2/2 Test #2: archivo_de_ejemplo ............... Passed 0.00 sec … 100% tests passed, 0 tests failed out of 2 … rc 0
Pero con los sanitizadores, la prueba del resumen se detuvo en seco:
salida con error1/2 Test #1: pruebas ..........................***Failed 0.09 sec
…
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x705c4b9e00c0 at pc 0x73ec4d283ad7 bp 0x7fff1434f900 sp 0x7fff1434f0c0
READ of size 10 at 0x705c4b9e00c0 thread T0
…
#5 0x000000408022 in (anonymous namespace)::prueba_resumen() (/tmp/v1/build-san/pruebas+0x408022)
…
freed by thread T0 here:
…
#8 0x00000041536d in Resumen::~Resumen() (/tmp/v1/build-san/pruebas+0x41536d)
#9 0x000000407e63 in (anonymous namespace)::prueba_resumen() (/tmp/v1/build-san/pruebas+0x407e63)
…
previously allocated by thread T0 here:
…
#7 0x000000484da4 in Resumen::anadir(Gasto const&) (/tmp/v1/build-san/pruebas+0x484da4)
…
#9 0x000000407e3b in (anonymous namespace)::prueba_resumen() (/tmp/v1/build-san/pruebas+0x407e3b)
…
50% tests passed, 1 tests failed out of 2
…
rc 8El informe se lee de abajo arriba. La memoria se reservó en Resumen::anadir, al crear la entrada del mapa; se liberó en Resumen::~Resumen, dentro de prueba_resumen; y después, en la misma función, se leyó. La línea culpable es esta:
auto filas = de_ejemplo().por_categoria();
de_ejemplo() devuelve un Resumen temporal, que se destruye al terminar esa línea, y con él su mapa. Las vistas de filas apuntan a textos que ya no existen: son referencias colgantes. En main no pasaba porque el Resumen vive hasta el final, y sin sanitizador la prueba pasaba porque la memoria liberada aún conservaba los textos. Bastaba con que otra reserva reutilizara esa memoria para que el programa imprimiera basura, o algo peor. std::string_view es muy útil, pero no es dueño de nada: devolverlo desde una función que no garantiza cuánto vive el texto es uno de los errores más típicos del C++ moderno. La corrección es devolver std::string, y la prueba se quedó para que no vuelva.
Análisis estático con clang-tidy
Los sanitizadores solo ven los caminos que se ejecutan. clang-tidy lee el código sin ejecutarlo y busca cientos de patrones peligrosos o sospechosos, agrupados por familias: bugprone- (fallos probables), clang-analyzer- (recorre todos los caminos posibles), performance-, modernize-… En una versión anterior de main.cpp, que guardaba las rutas antes de procesarlas, encontró esto:
#include "gasto.hpp"
#include "resumen.hpp"
#include <fstream>
#include <print>
#include <string>
#include <utility>
#include <vector>
int main(int argc, char* argv[])
{
std::vector<std::string> rutas;
for (int i = 1; i < argc; ++i) {
std::string ruta = argv[i];
rutas.push_back(std::move(ruta));
if (!ruta.ends_with(".csv")) {
std::println(stderr, "aviso: {} no parece un CSV", ruta);
}
}
Resumen resumen;
for (const auto& ruta : rutas) {
std::ifstream archivo(ruta);
std::string linea;
while (std::getline(archivo, linea)) {
if (auto gasto = leer_gasto(linea)) {
resumen.anadir(*gasto);
}
}
}
std::println("TOTAL {}", formatear(resumen.total()));
}clang-tidy/tmp/antes/main.cpp:16:14: warning: 'ruta' used after it was moved [bugprone-use-after-move]
16 | if (!ruta.ends_with(".csv")) {
| ^
/tmp/antes/main.cpp:15:15: note: move occurred here
15 | rutas.push_back(std::move(ruta));
| ^Después de std::move(ruta), ruta queda en un estado «válido pero sin especificar»: en la práctica, casi siempre vacío. La comprobación del .csv se hacía sobre una cadena vacía y el aviso no salía nunca. El compilador no dice nada, porque usar un objeto movido es legal. La corrección es comprobar antes de mover, o no mover.
Sobre la versión siguiente, con todas las comprobaciones de las familias bugprone, clang-analyzer y performance, clang-tidy dio dos avisos más:
clang-tidy/tmp/v3/gasto.cpp:27:49: warning: result of a `data()` call may not be null terminated, provide size information to the callee to prevent potential issues [bugprone-suspicious-stringview-data-usage]
27 | auto [donde, error] = std::from_chars(texto.data(), fin, valor);
| ~~~~~~^~~~
/tmp/v3/main.cpp:8:5: warning: an exception may be thrown in function 'main' which should not throw exceptions [bugprone-exception-escape]
8 | int main(int argc, char* argv[])
| ^- Una excepción puede escapar de
main. Es cierto:std::printlnostd::stringpueden lanzar, por ejemplo, si se acaba la memoria o falla la escritura, y una excepción que sale demainllama astd::terminate, que aborta sin explicación. Por eso la versión final mueve el trabajo aejecutary la envuelve en untryque informa y sale con el código 3. data()puede no terminar en'\0'. Es un aviso útil en general, porque pasarvista.data()a una función de C que espera una cadena terminada en nulo es un fallo clásico. Pero aquí es un falso positivo:from_charsrecibe también el final del texto. Se silencia solo esa línea, con un comentarioNOLINTNEXTLINEque explica por qué.
Con los dos cambios, el análisis queda limpio:
clang-tidy -p build --checks='-*,bugprone-*,clang-analyzer-*,performance-*,-bugprone-easily-swappable-parameters' main.cpp gasto.cpp resumen.cpp
salida[1/3] Processing file /tmp/gastos/main.cpp. 30732 warnings generated. [2/3] Processing file /tmp/gastos/gasto.cpp. 46219 warnings generated. [3/3] Processing file /tmp/gastos/resumen.cpp. 78122 warnings generated. rc 0
Los «warnings generated» son de las cabeceras de la biblioteca estándar, que clang-tidy analiza y no muestra. Hemos desactivado bugprone-easily-swappable-parameters, que avisa de cualquier función con dos parámetros seguidos del mismo tipo y en un proyecto real solo hace ruido. Configurar un analizador es también decidir qué avisos no aportan, y anotar los falsos positivos en el código en lugar de apagar la comprobación entera.
Valgrind: ninguna fuga
valgrind --leak-check=full ./build/gastos gastos.csv
salida==1== HEAP SUMMARY: ==1== in use at exit: 0 bytes in 0 blocks ==1== total heap usage: 45 allocs, 45 frees, 88,242 bytes allocated … ==1== All heap blocks were freed -- no leaks are possible … ==1== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Ni un solo new ni un delete en todo el proyecto, y ni una fuga: std::string, std::vector, std::map y std::ifstream liberan su memoria y cierran sus archivos solos al salir de su ámbito. Es RAII, la idea que recorre todo el curso, y la gran diferencia con el mismo tipo de proyecto en C, donde cada malloc necesitaba su free en todos los caminos de error.
Compilar para producción
Para desarrollar, -g y los sanitizadores. Para el ejecutable que se entrega, optimización y las protecciones del compilador, que no corrigen fallos pero los hacen mucho más difíciles de explotar. Desde GCC 14 basta una opción, -fhardened:
g++ -std=c++23 -O2 -fhardened -o gastos main.cpp gasto.cpp resumen.cpp readelf -h gastos | grep Type readelf -d gastos | grep -E 'BIND_NOW|FLAGS_1' nm gastos | grep stack_chk
salida Type: DYN (Position-Independent Executable file)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
U __stack_chk_fail@GLIBC_2.4El ejecutable es un PIE (se carga en una dirección distinta cada vez), con las tablas de saltos a las bibliotecas resueltas al arrancar y de solo lectura (BIND_NOW y RELRO), y con el protector de pila (__stack_chk_fail). En C++, -fhardened activa además algo muy valioso:
g++ -std=c++23 -O2 -fhardened -dM -E -x c++ /dev/null | grep -E '_GLIBCXX_ASSERTIONS|_FORTIFY_SOURCE'
salida#define _FORTIFY_SOURCE 3 #define _GLIBCXX_ASSERTIONS 1
_GLIBCXX_ASSERTIONS enciende las comprobaciones baratas de la biblioteca estándar de GCC: un vector[i] fuera de rango, un front() de un contenedor vacío o un std::optional vacío desreferenciado abortan el programa con un mensaje claro, en lugar de leer memoria ajena. Cuesta muy poco rendimiento y cierra de golpe una de las fuentes de fallos de seguridad más comunes en C++. Desde GCC 15 se activan solas al compilar sin optimizar, así que mientras desarrollas ya las tienes; el riesgo es la compilación con -O2 para producción, que las apaga si no las pides. Si tu compilador no tiene -fhardened, el equivalente aproximado es -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS -fstack-protector-strong -fstack-clash-protection -fcf-protection -fPIE -pie -Wl,-z,relro,-z,now. En CMake, lo natural es una configuración de compilación aparte para la versión de entrega.
Ojo con la seguridad: una lista para tu propio proyecto
Es el resumen del curso, aplicado: compila con -Wall -Wextra -Wconversion y trata los avisos como errores; ejecuta las pruebas también con -fsanitize=address,undefined (y con thread si hay hilos); pasa clang-tidy en cada cambio, idealmente en la integración continua; no uses new ni delete a mano, sino contenedores y punteros inteligentes; desconfía de std::string_view, std::span y las referencias que salen de una función; y entrega con -fhardened o, al menos, con _GLIBCXX_ASSERTIONS. Para el código que lee datos de fuera, el siguiente paso es el fuzzing con libFuzzer o AFL++. Todo esto, fallo por fallo, en Seguridad en C++.
Retos
Reto 1. Añade a main.cpp una opción --mes 2026-10 que solo cuente los gastos de ese mes. Usa std::string_view y starts_with. ¿Qué prueba escribirías primero?
Ver la solución
Basta con comparar el prefijo de la fecha: if (!mes.empty() && !gasto->fecha.starts_with(mes)) continue;. La primera prueba debería ser la del límite: que 2026-1 no cuente los gastos de 2026-10 ni de 2026-11 como si fueran de enero. Con starts_with sí los contaría, así que hay que exigir que --mes tenga exactamente el formato AAAA-MM. Escribir la prueba antes destapa ese error de diseño antes de que exista.
Reto 2. leer_importe("1."), con el punto pero sin decimales, ¿se acepta? ¿Y ".5"? Escribe las pruebas antes de mirar el código.
Ver la solución
"1." se acepta como 100 centavos: los decimales están vacíos y el código solo los lee si hay alguno. ".5" se rechaza, porque la parte entera está vacía. ¿Es lo que quieres? Las dos decisiones son defendibles, pero tienen que ser decisiones, no casualidades. Las pruebas son el sitio para dejarlas escritas.
Reto 3. Configura una tercera carpeta con -DCMAKE_BUILD_TYPE=Release y compara el tamaño del ejecutable con el de build. ¿Por qué cambia tanto?
Ver la solución
Sin tipo de compilación, CMake no añade opciones de optimización ni de depuración. Con Release añade -O3 -DNDEBUG: el optimizador inserta funciones pequeñas en quien las llama (inlining) y elimina código muerto, y el resultado suele ser más pequeño y mucho más rápido. Debug añade -g, con la información de depuración, que puede multiplicar el tamaño. Ojo con NDEBUG: desactiva los assert, así que nunca pongas en un assert algo que el programa necesite hacer.
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.
