Cómo usar CMake con C++: Una Guía Completa para el Desarrollo Profesional

Esta guía completa desmitifica CMake, la herramienta fundamental para el desarrollo profesional de proyectos C++.

Cómo usar CMake con C++: Una Guía Completa para el Desarrollo Profesional
Photo by Nikhil Mitra / Unsplash

Cuando nos adentramos en el desarrollo de proyectos C++ del mundo real, pronto descubrimos que la complejidad trasciende un simple archivo main.cpp. Es en este momento cuando nos encontramos con CMake, una herramienta fundamental que transformará nuestro enfoque del desarrollo. En esta guía completa, desmitificaremos CMake paso a paso, con ejemplos prácticos que nos permitirán dominarlo profesionalmente, prestando especial atención a las pruebas unitarias, una práctica esencial en el desarrollo de software de calidad.

Conceptos Fundamentales: Comprendiendo CMake

Consideremos el proceso de desarrollo como la construcción de un edificio complejo. Así como un arquitecto necesita planos detallados antes de comenzar la construcción, nosotros necesitamos un sistema que gestione la complejidad de nuestros proyectos C++. CMake cumple precisamente esta función.

Debemos entender que CMake no constituye un compilador en sí mismo, sino que opera como un generador de sistemas de construcción. Su función principal consiste en leer archivos de configuración que escribimos (los archivos CMakeLists.txt) y generar los archivos de proyecto específicos que las herramientas de nuestro sistema comprenden. En Linux, genera los tradicionales Makefiles; en Windows, puede crear proyectos compatibles con Visual Studio.

La ventaja fundamental radica en que escribimos las instrucciones una única vez, y CMake se encarga de que nuestro proyecto se compile y ejecute en diferentes sistemas operativos sin modificaciones adicionales. Esta capacidad multiplataforma ha posicionado a CMake como el estándar en la comunidad C++.

CMake nos proporciona las siguientes capacidades esenciales:

Gestión de dependencias: Cuando nuestro código requiere bibliotecas externas, CMake puede localizarlas automáticamente en el sistema o descargarlas y configurarlas según nuestras especificaciones.

Organización estructural: Nos ayuda a estructurar proyectos de manera lógica y ordenada, dividiéndolos en bibliotecas, ejecutables y módulos bien definidos.

Automatización de la compilación: Eliminamos la necesidad de comandos de compilación complejos y manuales, reduciendo todo el proceso a instrucciones simples y claras.

Integración de pruebas: CMake ofrece soporte nativo para integrar y ejecutar pruebas unitarias, permitiéndonos verificar la calidad del código de forma automatizada.

Estructura del Proyecto: Presentando sugar-cli-cpp

Para ilustrar estos conceptos, trabajaremos con un proyecto práctico denominado sugar-cli-cpp, diseñado para convertir valores de glucosa en sangre entre diferentes unidades de medida (mmol/L a mg/dL), una funcionalidad útil en el ámbito médico.

Examinemos la estructura organizativa de nuestro proyecto:

/home/javier/Projects/sugar-cli/sugar-cli-cpp/
├───.gitignore
├───CMakeLists.txt
├───build/
├───src/
│   ├───conversion.cpp
│   ├───main.cpp
│   └───include/
│       └───conversion.hpp
└───test/
    ├───CMakeLists.txt
    └───test_conversion.cpp

CMakeLists.txt (raíz): El archivo principal que contiene la configuración global del proyecto.

src/: Directorio que alberga el código fuente principal.

  • main.cpp: Punto de entrada de la aplicación.
  • conversion.cpp: Implementación de la lógica de conversión.
  • include/conversion.hpp: Declaraciones de las funciones de conversión.

test/: Directorio dedicado a las pruebas unitarias.

  • CMakeLists.txt: Configuración específica para el entorno de pruebas.
  • test_conversion.cpp: Implementación de las pruebas unitarias.

build/: Directorio de construcción donde CMake y el compilador generan todos los archivos intermedios y finales, manteniendo el código fuente organizado.

Análisis del Archivo CMakeLists.txt Principal

Procedamos a examinar detalladamente el contenido del archivo CMakeLists.txt principal y comprender cada elemento:

cmake_minimum_required(VERSION 3.10)
project(sugar_cli_cpp)

Establecemos la versión mínima de CMake requerida para garantizar la compatibilidad. La directiva project define el nombre de nuestro proyecto, estableciendo el contexto para todas las configuraciones posteriores.

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

Configuramos el estándar C++17 para nuestro proyecto. La segunda línea hace esta configuración obligatoria: si el compilador no soporta C++17, CMake terminará el proceso con un error claro.

Creación de Bibliotecas Modulares

La modularización representa un principio fundamental en el desarrollo profesional. En lugar de concentrar toda la funcionalidad en un ejecutable monolítico, organizamos el código en bibliotecas reutilizables y bien definidas.

add_library(sugar_cli_core STATIC src/conversion.cpp)

La instrucción add_library crea una biblioteca denominada sugar_cli_core. La especificación STATIC indica que el código de esta biblioteca se enlazará estáticamente con nuestro programa final durante la compilación. Esta aproximación resulta más simple y robusta para proyectos de tamaño moderado. Especificamos src/conversion.cpp como el archivo fuente de esta biblioteca.

target_include_directories(sugar_cli_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src/include)

Esta directiva es crucial para la correcta compilación del proyecto. Para que otros archivos puedan utilizar las funciones de sugar_cli_core, necesitan acceso a sus declaraciones contenidas en los archivos de cabecera (.hpp). target_include_directories instruye al compilador para incluir la carpeta src/include en la lista de directorios de búsqueda de cabeceras. La especificación PUBLIC significa que esta información se propaga a cualquier objetivo que se enlace con sugar_cli_core.

Construcción del Ejecutable Principal

Con nuestra biblioteca definida, procedemos a crear el ejecutable que constituirá la interfaz de usuario de nuestra aplicación:

add_executable(sugar-cli-cpp src/main.cpp)

add_executable define un ejecutable denominado sugar-cli-cpp que se construye a partir del código fuente en src/main.cpp.

target_link_libraries(sugar-cli-cpp PRIVATE sugar_cli_core)

Nuestro ejecutable debe utilizar la funcionalidad encapsulada en sugar_cli_core. target_link_libraries establece esta dependencia, vinculando el ejecutable con nuestra biblioteca. La especificación PRIVATE indica que esta dependencia es interna al ejecutable y no necesita ser expuesta a otros componentes.

Integración del Sistema de Pruebas

Para mantener una arquitectura limpia y bien organizada, delegamos la configuración de las pruebas a un archivo CMake específico:

add_subdirectory(test)

Esta directiva instruye a CMake para procesar las configuraciones contenidas en test/CMakeLists.txt, permitiendo una separación clara entre la configuración principal y la configuración de pruebas.

Implementación de Pruebas Unitarias con GoogleTest

El desarrollo de software profesional requiere verificación sistemática de la funcionalidad. Las pruebas unitarias constituyen la metodología estándar para esta verificación, examinando componentes individuales de nuestro código de forma aislada.

Utilizaremos GoogleTest, ampliamente reconocido como el framework de pruebas unitarias más robusto y completo para C++.

Gestión Automatizada de Dependencias con FetchContent

Tradicionalmente, la gestión de dependencias externas representaba una complejidad significativa. CMake resuelve esta problemática mediante el módulo FetchContent, que automatiza completamente el proceso de descarga y configuración de dependencias.

Examinemos esta implementación en nuestro CMakeLists.txt principal:

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG        release-1.12.1
)
FetchContent_MakeAvailable(googletest)

include(FetchContent): Activa la funcionalidad de gestión automática de dependencias.

FetchContent_Declare: Declaramos nuestra dependencia especificando un nombre de referencia (googletest), la URL del repositorio Git y, crucialmente, una etiqueta específica (release-1.12.1). Esta especificación garantiza reproducibilidad y estabilidad en las compilaciones.

FetchContent_MakeAvailable: Ejecuta la descarga, configuración y preparación de la dependencia. CMake verifica si la dependencia ya está disponible localmente; si no es así, la descarga automáticamente y la prepara para su uso.

Configuración del Entorno de Pruebas

Analicemos ahora el contenido de test/CMakeLists.txt:

enable_testing()

Esta directiva activa CTest, el sistema integrado de ejecución de pruebas de CMake, habilitando todas las funcionalidades relacionadas con pruebas unitarias.

add_executable(run_tests test_conversion.cpp)

Creamos un ejecutable específico para nuestras pruebas unitarias, denominado run_tests, construido a partir del archivo test_conversion.cpp.

target_link_libraries(run_tests PRIVATE sugar_cli_core GTest::gtest_main)

Nuestro ejecutable de pruebas requiere enlazarse con dos componentes esenciales:

  1. sugar_cli_core: La biblioteca que contiene el código a examinar.
  2. GTest::gtest_main: La biblioteca principal de GoogleTest que proporciona toda la infraestructura de ejecución de pruebas, incluyendo la función main del programa.
target_include_directories(run_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/include)

Garantizamos que el código de pruebas tenga acceso a los archivos de cabecera de nuestra biblioteca.

include(GoogleTest)
gtest_discover_tests(run_tests)

Incluimos el módulo especializado de CMake para GoogleTest. La función gtest_discover_tests realiza un análisis automático de nuestro ejecutable run_tests, identifica todas las pruebas definidas mediante las macros de GoogleTest y las registra automáticamente en CTest.

Implementación de Pruebas Unitarias

Examinemos la implementación práctica de las pruebas en test/test_conversion.cpp:

#include <gtest/gtest.h>
#include <conversion.hpp>

TEST(ConversionTest, MmolToMgdl_1_1_is_20) {
    double input_mmol = 1.1;
    int want = 20;
    int get = mmol_to_mgdl(input_mmol);

    ASSERT_EQ(get, want);
}

TEST(ConversionTest, MmolToMgdl_10_6_is_191)
{
    double input_mmol = 10.6;
    int want = 191;
    int get = mmol_to_mgdl(input_mmol);
    
    ASSERT_EQ(get, want);
}

La estructura de una prueba unitaria con GoogleTest sigue un patrón claro y consistente:

Inclusión de dependencias: Incluimos gtest/gtest.h para la infraestructura de pruebas y conversion.hpp para las funciones a examinar.

Definición de pruebas: Utilizamos la macro TEST(GrupoDeTest, NombreDeLaPrueba) para definir cada prueba individual. Esta organización facilita la gestión de conjuntos extensos de pruebas.

Implementación de la prueba: Seguimos el patrón "Arrange, Act, Assert":

  • Arrange (Preparación): Establecemos las condiciones iniciales y los valores esperados.
  • Act (Acción): Ejecutamos la función que deseamos probar.
  • Assert (Verificación): Comprobamos que el resultado coincide con nuestras expectativas.

La macro ASSERT_EQ verifica la igualdad entre dos valores. Si la verificación falla, la prueba se detiene inmediatamente y proporciona información detallada sobre el fallo, incluyendo los valores esperados y obtenidos.

Nuestro conjunto de pruebas cubre múltiples escenarios: valores típicos, casos límite, el valor cero y condiciones de error como números negativos. Esta cobertura integral es fundamental para garantizar la robustez de nuestro código.

Proceso de Compilación y Ejecución

Procedamos ahora a la implementación práctica de todo el sistema que hemos configurado:

Preparación del Entorno de Compilación

mkdir build
cd build

Creamos un directorio dedicado para la compilación, manteniendo separados los archivos fuente de los archivos generados durante el proceso de construcción.

Generación del Sistema de Construcción

cmake ..

Ejecutamos CMake para generar el sistema de construcción específico para nuestro entorno. CMake analiza nuestro sistema, detecta las herramientas disponibles y descarga las dependencias necesarias.

Compilación del Proyecto

cmake --build .

Este comando multiplataforma instruye a CMake para ejecutar el proceso de compilación, construyendo tanto la aplicación principal como el ejecutable de pruebas.

Ejecución de la Aplicación

./src/sugar-cli-cpp

El ejecutable principal se encuentra en el subdirectorio src dentro de nuestro directorio de construcción.

Ejecución de las Pruebas Unitarias

ctest

CTest ejecuta automáticamente todas las pruebas registradas, proporcionando un informe detallado de los resultados:

Test project /home/javier/Projects/sugar-cli/sugar-cli-cpp/build
      Start 1: ConversionTest.MmolToMgdl_1_1_is_20
1/10 Test #1: ConversionTest.MmolToMgdl_1_1_is_20 ...   Passed    0.00 sec
      Start 2: ConversionTest.MmolToMgdl_10_6_is_191
2/10 Test #2: ConversionTest.MmolToMgdl_10_6_is_191 ...   Passed    0.00 sec
...
      Start 10: ConversionTest.MmolToMgdl_Negative_is_0
10/10 Test #10: ConversionTest.MmolToMgdl_Negative_is_0 ...   Passed    0.00 sec

100% tests passed, 0 tests failed out of 10

Total Test time (real) =   0.01 sec

Reflexiones Profesionales y Perspectivas Futuras

CMake representa mucho más que una herramienta de compilación; constituye la base para el desarrollo profesional de software C++. Su enfoque basado en objetivos y la gestión explícita de dependencias escalable desde proyectos pequeños hasta sistemas complejos de gran envergadura.

La integración de pruebas unitarias desde las primeras etapas del desarrollo, facilitada por herramientas como GoogleTest y CMake, no constituye un lujo opcional sino una inversión fundamental en la calidad y mantenibilidad del código. Este enfoque nos proporciona la confianza necesaria para refactorizar, optimizar y expandir nuestros sistemas sin comprometer la funcionalidad existente.

A medida que continuamos nuestro desarrollo profesional, reconocemos que CMake no representa un obstáculo sino una guía fundamental para construir software robusto, portable y mantenible. La inversión en comprender y dominar estas herramientas se traduce directamente en mayor productividad y código de mayor calidad.

Nuestro proyecto sugar-cli-cpp ilustra los principios fundamentales, pero las capacidades de CMake se extienden mucho más allá: gestión de múltiples bibliotecas, integración con sistemas de integración continua, configuración de instaladores multiplataforma y mucho más. Este conocimiento fundamental nos proporciona la base sólida para explorar estas capacidades avanzadas según las necesidades de nuestros proyectos futuros.

Read more