Linguagem C++ / Módulo 1: Introdução e Ecossistema / Build Systems
Build Systems
O que este capítulo é (e o que não é)
Make, Ninja e CMake são ferramentas profundas — cada uma daria um curso inteiro só para si. Não é isso que vamos fazer aqui. O objetivo deste capítulo é bem mais modesto: apresentar essas três ferramentas, explicar para que cada uma serve e onde estão os seus limites, para que você reconheça o nome, entenda o papel de cada peça e saiba do que se trata quando encontrar um Makefile ou um CMakeLists.txt em um projeto.
Isso é suficiente por dois motivos práticos. Primeiro, porque hoje você tem modelos de IA capazes de escrever um CMakeLists.txt ou um Makefile funcional a partir de uma descrição do seu projeto — o que importa é você entender o que está sendo gerado e por quê, não decorar a sintaxe de cada ferramenta. Segundo, porque as IDEs modernas (que vamos ver no próximo capítulo) escondem boa parte dessa complexidade atrás de botões e menus — mas escondem, não eliminam. Quando você clica em "Build" no VS Code, CLion e QtCreator, é um desses sistemas de build que está rodando por trás. Entender o que existe ali embaixo evita que você fique perdido quando algo sai do esperado.
Por que o compilador não é suficiente
Em um projeto com um único arquivo .cpp, chamar o compilador diretamente funciona bem:
g++ -std=c++20 main.cpp -o programaMas projetos reais raramente têm um único arquivo. Considere um projeto pequeno, com três arquivos: main.cpp, utils.cpp e matematica.cpp, cada um com o seu header correspondente. Compilar tudo de uma vez ainda é possível com uma única chamada ao g++:
g++ -std=c++20 main.cpp utils.cpp matematica.cpp -o programaO primeiro problema é que a lista de arquivos no comando cresce junto com o projeto. Em um projeto com cinquenta arquivos .cpp, esse comando vira uma linha enorme, fácil de errar e chata de manter atualizada toda vez que um arquivo novo é adicionado.
O segundo problema é mais sério e não aparece nesse comando único: toda vez que você roda esse g++, ele recompila os três arquivos do zero, mesmo que só o utils.cpp tenha mudado.
Para evitar isso manualmente, você precisaria compilar cada arquivo separadamente até o estágio de objeto, e só depois linkar todos juntos:
g++ -std=c++20 -c main.cpp -o main.o
g++ -std=c++20 -c utils.cpp -o utils.o
g++ -std=c++20 -c matematica.cpp -o matematica.o
g++ main.o utils.o matematica.o -o programaA flag -c instrui o g++ a parar depois de gerar o arquivo objeto (.o), sem linkar. Agora, se só o utils.cpp mudar, bastaria recompilar aquele arquivo e relinkar:
g++ -std=c++20 -c utils.cpp -o utils.o
g++ main.o utils.o matematica.o -o programaIsso resolve o problema de performance, mas cria um novo: agora você precisa lembrar, manualmente, quais arquivos mudaram. Em um projeto de três arquivos isso é viável. Em um projeto de cinquenta, não é.
É para resolver esses dois problemas — a lista de comandos que cresce sem controle, e a necessidade de saber manualmente o que recompilar — que existem os sistemas de build: ferramentas que descrevem o que precisa ser compilado, com quais dependências, e recompilam apenas o que mudou.
O pipeline de compilação
Antes de falar sobre as ferramentas, vale entender o que acontece entre o seu .cpp e o executável final.
Do lado da compilação em si, o g++ esconde várias etapas internas — pré-processamento das diretivas #include/#define, tradução do código para Assembly, e a montagem desse Assembly em código de máquina. O resultado de tudo isso é o arquivo objeto (.o no Linux, .obj no Windows) que você já viu sendo gerado com a flag -c no exemplo anterior: um arquivo com o código de máquina daquele módulo, mas ainda com referências não resolvidas para o que está definido em outros arquivos. Isso acontece de forma independente para cada .cpp — é por isso que dá para compilar vários arquivos em paralelo, cada núcleo do processador cuidando de um deles.
A etapa que junta tudo é a linkagem (linking): o linker pega todos os arquivos objeto e resolve as referências cruzadas entre eles, produzindo um único executável. Em C++, a linkagem é onde a maior parte dos erros mais misteriosos acontece. Entender que a linkagem é uma etapa separada da compilação ajuda bastante a diagnosticar esses problemas: um erro de compilação aponta para uma linha específica do seu código; um erro de linkagem aponta para um símbolo que não foi encontrado em lugar nenhum.
É também na linkagem que entram as bibliotecas — código de terceiros (ou seu, de outros projetos) que você não escreveu naquele momento, mas que o seu programa usa. Existem duas formas de uma biblioteca ser incorporada ao seu executável, e a diferença entre elas importa na prática:
Bibliotecas estáticas (.a no Linux, .lib no Windows) têm o próprio código copiado para dentro do seu executável durante a linkagem. O executável final fica maior, mas roda sozinho — não depende de mais nada estar instalado na máquina onde ele for executado. Foi exatamente isso que vimos no capítulo sobre compiladores: quando o g++ linka a libstdc++ de forma estática, o código da biblioteca padrão do C++ passa a fazer parte do seu binário.
Bibliotecas dinâmicas (.so no Linux, .dll no Windows, .dylib no macOS) não são copiadas. O executável guarda apenas uma referência à biblioteca, que precisa estar presente e acessível no sistema no momento em que o programa for executado — não só quando ele for compilado.
Um exemplo prático que você talvez já tenha usado, mesmo sem perceber: quando um jogo ou aplicativo no Windows reclama que está faltando um arquivo como vcruntime140.dll ou msvcp140.dll, e pede para instalar o "Microsoft Visual C++ Redistributable", é exatamente isso — o programa foi compilado linkando dinamicamente as bibliotecas de runtime do C++ da Microsoft, e espera encontrá-las instaladas no sistema operacional em tempo de execução. Se essas .dlls não estiverem lá (ou estiverem em uma versão incompatível), o programa nem chega a abrir, mesmo tendo compilado e linkado perfeitamente na máquina de quem o distribuiu.
Esse é justamente o trade-off: a vantagem da linkagem dinâmica é o executável ficar menor e várias aplicações poderem compartilhar a mesma cópia da biblioteca carregada em memória — é por isso que instalar um único "Redistributable" resolve o problema para vários programas diferentes de uma vez. A desvantagem é essa dependência externa: o programa só funciona se o ambiente de destino tiver exatamente o que ele espera encontrar.
Essa escolha entre estática e dinâmica é uma das decisões mais comuns ao configurar um sistema de build, e vamos revisitá-la quando falarmos de CMake mais adiante neste capítulo.
Make
O Make é o sistema de build mais antigo ainda em uso — existe desde os anos 70, criado na Bell Labs, e você provavelmente já teve contato com ele ao utilizar a linguagem C. Ele lê um arquivo chamado Makefile que descreve as regras de compilação.
CXX = g++
CXXFLAGS = -std=c++20 -Wall -Wextra
programa: main.o utils.o
$(CXX) -o programa main.o utils.o
main.o: main.cpp utils.h
$(CXX) $(CXXFLAGS) -c main.cpp
utils.o: utils.cpp utils.h
$(CXX) $(CXXFLAGS) -c utils.cpp
.PHONY: clean
clean:
rm -f *.o programaVale entender cada parte desse Makefile com calma, porque a lógica se repete em qualquer Makefile que você encontrar depois.
As duas primeiras linhas definem variáveis. CXX guarda o comando do compilador que vamos usar — aqui, g++ — e CXXFLAGS guarda as flags que vão junto em toda chamada de compilação. Definir isso como variável, em vez de repetir g++ -std=c++20 -Wall -Wextra em cada regra, significa que trocar de compilador ou ajustar uma flag exige mudar uma linha só, não cada ocorrência espalhada pelo arquivo.
O resto do arquivo é uma lista de regras, e cada regra segue o mesmo formato: um alvo, dois-pontos, as dependências desse alvo, e — na linha de baixo, começando com um tab — o comando que gera esse alvo a partir das dependências.
Faz sentido ler o Makefile de baixo para cima, começando pelas dependências mais simples. A regra utils.o: utils.cpp utils.h diz: "o arquivo utils.o depende do utils.cpp e do utils.h — se qualquer um dos dois for mais novo que o utils.o (ou se o utils.o ainda não existir), rode o comando abaixo para gerá-lo". O comando é $(CXX) $(CXXFLAGS) -c utils.cpp, que expande para g++ -std=c++20 -Wall -Wextra -c utils.cpp — exatamente a mesma chamada que fizemos manualmente na seção anterior para gerar um arquivo objeto. A regra main.o segue a mesma lógica, com suas próprias dependências.
Só depois de main.o e utils.o existirem é que a regra programa: main.o utils.o pode rodar, linkando os dois objetos no executável final. É por isso que o Make consegue saber o que recompilar: ele compara a data de modificação de cada arquivo com a dos seus alvos, e só refaz o que está desatualizado. Se você mudar só o utils.cpp, rodar make de novo vai recompilar apenas utils.o e relinkar programa — sem tocar em main.o.
Por fim, a regra clean não gera nenhum arquivo — ela existe só para rodar um comando (rm -f *.o programa, que apaga os arquivos objeto e o executável). Regras assim, que não correspondem a um arquivo de verdade, precisam ser declaradas com .PHONY, avisando ao Make que clean é um nome de comando, não um arquivo que ele deve procurar no disco.
Para compilar:
makePara limpar os arquivos gerados:
make cleanRepare que o comando dentro de clean é rm -f, um comando do shell Unix. Isso funciona perfeitamente no Linux e no macOS, mas não existe no cmd do Windows — lá, o equivalente seria del /f. Esse é justamente um dos limites do Make que vamos ver a seguir: um Makefile escrito pensando em um sistema operacional carrega, embutido nele, comandos que só fazem sentido naquele sistema.
O Make funciona bem para projetos pequenos e médios no Linux e macOS. O outro limite aparece quando o projeto cresce: com dezenas de arquivos, cada um com suas próprias dependências, o Makefile vira uma lista longa e repetitiva de regras manuais para manter atualizada.
Outro ponto prático: por padrão, o Make compila um arquivo por vez, usando um único núcleo do processador. Para usar todos os núcleos disponíveis, é necessário passar a flag -j manualmente:
make -j$(nproc) # Linux: usa todos os núcleos disponíveis
make -j8 # Ou especifique o número diretamenteO Makefile que não mostramos
O Makefile do exemplo anterior foi simplificado de propósito, para focar no essencial: alvo, dependência, comando. Mas o Make é uma ferramenta bem mais poderosa do que esse exemplo deixa transparecer — vale ver rapidamente até onde ela vai, para comparar com o Ninja de forma mais completa daqui a pouco.
O Make suporta condicionais, que mudam o comportamento da build dependendo do sistema operacional ou de variáveis de ambiente. É a forma "certa" de resolver o problema que vimos com o rm -f do make clean, que não funciona no cmd do Windows:
ifeq ($(OS),Windows_NT)
RM = del /f
else
RM = rm -f
endifO ifeq compara duas strings — aqui, o valor da variável de ambiente OS com o texto Windows_NT — e executa um bloco ou outro dependendo do resultado, fechando com endif. OS é definida automaticamente pelo próprio Windows como Windows_NT; no Linux e macOS ela normalmente não existe, então a condição falha e cai no else. Com isso, a variável RM passa a guardar o comando de remoção correto para o sistema em que o Make está rodando, e a regra clean do nosso exemplo anterior poderia usar $(RM) em vez de rm -f fixo, funcionando em diferentes sistemas operacionais.
O Make também tem funções embutidas, como a função wildcard, que gera a lista de arquivos automaticamente em vez de você listar cada um à mão:
FONTES = $(wildcard *.cpp)
OBJETOS = $(FONTES:.cpp=.o)A primeira linha roda a função wildcard, que varre o diretório atual e retorna todos os arquivos que casam com o padrão *.cpp — no nosso projeto de exemplo, isso resultaria em FONTES = main.cpp utils.cpp matematica.cpp, sem precisar escrever esses três nomes manualmente. Se um arquivo novo for adicionado ao projeto, FONTES já passa a incluí-lo automaticamente na próxima vez que o Make rodar.
A segunda linha usa uma sintaxe chamada substituição de sufixo: $(FONTES:.cpp=.o) pega a lista que está em FONTES e troca a terminação .cpp de cada item por .o. Ou seja, a partir de main.cpp utils.cpp matematica.cpp, a variável OBJETOS passa a valer main.o utils.o matematica.o — a lista de arquivos objeto que o projeto precisa gerar, derivada automaticamente da lista de fontes, sem repetir os nomes uma segunda vez.
Sozinhas, essas duas linhas não compilam nada — elas só constroem as listas de nomes. O que falta é algo que diga ao Make como transformar cada .cpp da lista FONTES no .o correspondente, sem precisar de uma regra escrita à mão para main.o, outra para utils.o, outra para matematica.o.
É para isso que existem as regras implícitas: regras genéricas que já vêm embutidas no Make, cobrindo transformações comuns. Você não precisa entender os detalhes de como elas funcionam; o que importa aqui é saber que elas existem, e que uma delas resolve exatamente esse "compile qualquer .cpp no .o de mesmo nome", sem que você escreva nenhuma regra extra para isso.
Juntando wildcard, a substituição de sufixo e a regra implícita de compilação, o Makefile inteiro do nosso projeto de três arquivos fica assim:
CXX = g++
CXXFLAGS = -std=c++20 -Wall -Wextra
FONTES = $(wildcard *.cpp)
OBJETOS = $(FONTES:.cpp=.o)
programa: $(OBJETOS)
$(CXX) -o programa $(OBJETOS)
.PHONY: clean
clean:
rm -f *.o programaNão há mais nenhuma regra explicando como gerar main.o ou utils.o — a regra implícita do Make cuida disso sozinha. Se você adicionar um quarto arquivo .cpp ao projeto, esse Makefile nem precisa ser tocado: a função wildcard encontra o arquivo novo, e a regra implícita sabe compilá-lo.
Não precisamos nos aprofundar na sintaxe do Make — não é preciso decorar como funciona um wildcard, uma substituição de sufixo ou uma regra implícita por dentro. O que importa reter é bem mais simples: você já é capaz de escrever um Makefile básico como o de algumas seções atrás, com alvo, dependência e comando, e isso é suficiente para acompanhar o curso. Inclusive, mais adiante vamos conhecer o CMake, uma ferramenta capaz de gerar o Makefile para você — outro motivo para não se preocupar em memorizar essa sintaxe agora.
O que vale carregar dessa seção é uma ideia, não uma sintaxe: tudo que vimos — variáveis, condicionais, funções, regras implícitas — só existe porque o Make faz um trabalho de verificação nos bastidores toda vez que você roda o comando make. Ele precisa varrer o disco, montar listas de arquivos, decidir qual regra se aplica a cada um, e comparar datas de modificação para saber o que já está atualizado. Nada disso é gratuito: é processamento real, que acontece antes de qualquer g++ ser chamado, e que se repete do zero a cada execução. É justamente esse custo computacional que o Ninja, uma alternativa ao Make se recusa a pagar.
Ninja
O Ninja foi criado em 2012 por um engenheiro do Google com um objetivo específico: ser o sistema de build mais rápido possível. A estratégia para isso foi radical: tirar do formato do arquivo tudo que exige interpretação. Sem condicionais, sem funções, sem regras implícitas — só uma lista de comandos.
O arquivo que o Ninja lê se chama build.ninja. Para o mesmo projeto de três arquivos que usamos no Makefile, ele se pareceria com isto:
rule compile
command = g++ -std=c++20 -Wall -Wextra -c $in -o $out
rule link
command = g++ $in -o $out
build main.o: compile main.cpp
build utils.o: compile utils.cpp
build matematica.o: compile matematica.cpp
build programa: link main.o utils.o matematica.oNão é coincidência que seja parecido com o nosso primeiro Makefile, aquele sem wildcard, sem condicional, sem regra implícita — só alvo, dependência e comando. Um build.ninja desse tamanho também poderia ser escrito à mão, sem grande esforço, exatamente como fizemos com o Makefile simples.
A diferença não está no tamanho do arquivo para um projeto pequeno — está no que cada ferramenta permite fazer quando o projeto cresce. O Make oferece o caminho para crescer sem virar uma lista gigante escrita à mão: wildcard, condicionais, regras implícitas. É justamente essa flexibilidade, como vimos, que tem um custo em desempenho — o Make precisa interpretar tudo isso toda vez que roda. O Ninja não oferece esse caminho: não existe ifeq, não existe wildcard, a única opção é nomear explicitamente cada arquivo de entrada e saída. Se o projeto tiver duzentos arquivos .cpp, o build.ninja precisa ter duzentas linhas, uma por uma.
Em compensação, é essa mesma rigidez que faz o Ninja ser mais rápido: como o arquivo já chega pronto, sem nada para resolver, ele não perde tempo interpretando nada — só lê a arquivo e executa os comandos. Some a isso o fato de que ele distribui a compilação entre todos os núcleos do processador por padrão (o Make só faz isso se você passar -j manualmente), e o resultado é uma compilação sensivelmente mais rápida em projetos grandes.
Para rodar, basta chamar o comando ninja na pasta onde está o build.ninja — ele já sabe qual arquivo procurar pelo nome padrão:
ninjaVale colocar essa velocidade em perspectiva, porque ela não é grátis, nem sempre necessária. Em um projeto pequeno, de três ou quatro arquivos, a diferença de tempo entre Make e Ninja é irrelevante — os dois compilam tão rápido que ninguém percebe a diferença. É só em projetos grandes, com centenas de arquivos, que o desempenho passa a importar de verdade: ali, um build que demora minutos em vez de segundos afeta o dia a dia de quem trabalha no projeto.
O problema é que é justamente nesse cenário — projeto grande, onde a velocidade do Ninja faria diferença — que escrever o build.ninja à mão se torna inviável. Gerar duzentas linhas repetitivas, uma para cada arquivo, sem wildcard e sem nenhum outro atalho, não é um trabalho razoável para uma pessoa fazer manualmente. O Ninja não tem uma saída própria para esse impasse: ele foi desenhado para ser rápido e simples de executar, não para ser prático de escrever.
É exatamente aqui que entra o CMake: ele resolve o problema que nem o Make nem o Ninja resolvem sozinhos. Em vez de escolher entre a flexibilidade lenta do Make e a rigidez rápida do Ninja, o CMake gera o arquivo de build automaticamente a partir de uma descrição de mais alto nível do seu projeto — e ele é capaz de gerar tanto um Makefile quanto um build.ninja otimizados, prontos para uso, sem que você escreva uma linha sequer de nenhum dos dois.
CMake
O CMake é a camada acima do Make e do Ninja: ele não compila nada diretamente, mas gera os arquivos de build para o sistema que você escolher. Você descreve o projeto em um arquivo chamado CMakeLists.txt, e o CMake converte essa descrição em um Makefile, ou em um build.ninja.
Vale reforçar por que isso importa, voltando ao problema que já vimos com o make clean. No Makefile do início deste capítulo, a regra clean usava rm -f *.o programa — um comando que só existe no Linux e no macOS. Para essa mesma regra funcionar também no Windows sem recorrer a um ifeq, precisaríamos manter dois Makefiles separados: um com rm -f *.o programa para Linux/macOS, e outro com del /f *.o programa.exe para Windows — cada um do jeito que o shell daquele sistema entende, sem nenhuma forma de escrever um comando único que sirva para os dois.
O CMake resolve esse problema de um jeito diferente: em vez de você escrever comandos específicos de cada sistema operacional, você descreve a intenção ("remover os artefatos de build") em uma linguagem própria, e é o próprio CMake quem gera o Makefile (ou o build.ninja) já adaptado ao sistema onde ele está rodando — com rm no Linux e no macOS, com os comandos equivalentes no Windows. O mesmo CMakeLists.txt, sem nenhuma mudança, produz o arquivo de build correto em cada plataforma. É por isso que o CMake se tornou o padrão para projetos C++.
CMakeLists.txt básico
cmake_minimum_required(VERSION 3.20)
project(MeuProjeto VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(programa
main.cpp
utils.cpp
utils.h
)Repare como esse arquivo é bem mais curto do que o Makefile ou o build.ninja equivalentes, e isso não é acaso: o CMakeLists.txt descreve o que o projeto é, não como compilá-lo passo a passo — essa tradução fica por conta do CMake.
cmake_minimum_required(VERSION 3.20) declara a versão mínima do CMake necessária para interpretar esse arquivo, já que a sintaxe e os recursos disponíveis mudam entre versões — é uma boa prática travar isso explicitamente, para não depender de um comportamento que só existe em versões mais novas.
project(MeuProjeto VERSION 1.0) dá um nome ao projeto e registra a versão dele. Mas esse comando faz bem mais do que parece: é ele quem dispara, nos bastidores, todo o processo de detecção de compilador. A partir dele, o CMake procura no sistema um compilador C e C++ funcional — testando variáveis de ambiente, o PATH, e um conjunto de nomes e localizações conhecidas para cada sistema operacional —, e roda uma série de compilações de teste, pequenas e descartáveis, só para confirmar que o compilador encontrado realmente funciona e para descobrir suas características (versão, padrões suportados, etc.). Só depois desse processo o CMake sabe se vai gerar um Makefile chamando g++, clang++ ou cl.exe (do MSVC).
As duas linhas com set(CMAKE_CXX_STANDARD ...) definem qual padrão da linguagem usar — aqui, C++20 — e exigem que ele seja respeitado à risca (CMAKE_CXX_STANDARD_REQUIRED ON). Sem essa segunda linha, se o compilador não suportar C++20, o CMake tentaria usar o padrão mais próximo disponível, silenciosamente; com ela, a configuração falha com um erro claro, em vez de compilar com um padrão diferente do que você pediu.
Por fim, add_executable(programa ...) é o comando que efetivamente descreve o produto final: cria um executável chamado programa, construído a partir dos arquivos main.cpp, utils.cpp e utils.h listados em seguida. Repare que não há nenhuma menção a main.o ou utils.o, nem a qual arquivo depende de qual header — isso é exatamente o tipo de detalhe que o CMake resolve sozinho ao gerar o Makefile ou o build.ninja final, e que teríamos que escrever manualmente no Makefile do início do capítulo.
Para compilar esse projeto, usando o CMake com Ninja como backend, o fluxo é:
mkdir build && cd build
cmake -G Ninja ..
cmake --build .
./programaVale entender cada linha, porque ela vai se repetir em praticamente todo projeto CMake que você encontrar.
mkdir build && cd build cria uma pasta chamada build e entra nela. Essa pasta vai concentrar tudo que o CMake gerar — o build.ninja, os arquivos objeto, o executável final. Ela fica separada do código-fonte de propósito: se algo der errado, ou se você quiser recomeçar do zero, basta apagar a pasta build inteira, sem tocar em nenhum arquivo do projeto. É também por isso que essa pasta normalmente entra no .gitignore — o que está dentro dela é gerado, não é código que precisa ser versionado.
cmake -G Ninja .. é o comando de configuração, e é o mais importante dos quatro. A flag -G Ninja diz ao CMake qual gerador usar — nesse caso, produzir um build.ninja em vez de um Makefile. O .. no final é o caminho para o CMakeLists.txt: como estamos dentro da pasta build, .. aponta para a pasta anterior, onde está o arquivo de verdade. Rodar esse comando é o que dispara todo aquele processo que vimos na explicação do project(): o CMake procura um compilador funcional no sistema, roda as compilações de teste, e — se tudo correr bem — deixa pronto, dentro da pasta build, o build.ninja já configurado com o compilador encontrado e as flags necessárias. Nada é compilado ainda nessa etapa; ela só prepara o terreno.
A vantagem de o CMake fazer essa detecção sozinho é você não precisar se preocupar com qual compilador está instalado, ou lembrar do nome exato do binário em cada sistema operacional. Mas às vezes você quer decidir isso manualmente — por exemplo, se tiver g++-11, g++-13 e clang++ instalados, e quiser garantir que o CMake use o segundo. Isso é feito passando a variável CMAKE_CXX_COMPILER junto com o comando de configuração, substituindo a detecção automática:
cmake -G Ninja -DCMAKE_CXX_COMPILER=g++-13 ..O mesmo vale para o compilador de C, com CMAKE_C_COMPILER. Uma vez configurada dessa forma, a escolha fica registrada na pasta build — não é preciso repetir a flag a cada nova compilação, só quando a pasta build for recriada do zero.
Com a configuração pronta, cmake --build . é o comando que efetivamente compila o projeto: ele chama o Ninja nos bastidores, que por sua vez lê o build.ninja gerado e roda os comandos de compilação e linkagem. É essa a etapa que, de fato, recompila só o que mudou — rodar cmake --build . de novo depois de uma alteração pontual em utils.cpp só recompila aquele arquivo e relinka o executável, sem refazer a configuração.
Por fim, ./programa executa o binário gerado, que fica na própria pasta build.
Debug e Release
Compilar um programa "para desenvolver" e compilar esse mesmo programa "para distribuir" são duas coisas com objetivos opostos, e o CMake tem um mecanismo dedicado a alternar entre elas: a variável CMAKE_BUILD_TYPE.
# Build de debug (sem otimização, com informações de debug)
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ..
# Build de release (otimizado, sem informações de debug)
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..Por trás dessa única flag, o CMake muda duas coisas na chamada do compilador.
A primeira é o nível de otimização. Um build Debug compila com a flag -O0 — sem nenhuma otimização —, enquanto um build Release usa algo como a flag -O2, ativando uma série de transformações do compilador (eliminar código morto, embutir funções pequenas no lugar de chamá-las, reordenar instruções) para deixar o executável final mais rápido. O motivo de desativar isso durante o desenvolvimento é que otimização e depuração competem entre si: se o compilador embutiu uma função ou eliminou uma variável que "não era mais necessária", o debugger não tem mais como parar naquela linha ou mostrar aquele valor — o código que está rodando não corresponde mais, linha a linha, ao código que você escreveu.
A segunda são as informações de debug, controlados por uma flag como -g. Esses símbolos são metadados extras, embutidos no próprio executável, que mapeiam cada instrução de volta para a linha de código-fonte e o nome de variável correspondentes. Sem eles, um debugger como o gdb consegue rodar o programa, mas não consegue te dizer em qual linha do seu .cpp ele está parado, nem qual era o nome da variável que você quer inspecionar. Um build Release tipicamente não utiliza essas flags, porque eles aumentam o tamanho do executável sem trazer nenhum benefício para quem só vai rodar o programa, não depurá-lo.
Na prática, isso significa que um build Debug é mais lento de executar (mas mais fácil de investigar quando algo dá errado) e um build Release é mais rápido de executar (mas praticamente opaco para um debugger). É por isso que se desenvolve em Debug e se distribui em Release.
Um detalhe importante: o CMake não recompila o mesmo build alternando o tipo a cada cmake --build. A escolha de CMAKE_BUILD_TYPE acontece na etapa de configuração, e fica presa àquela pasta build. Por isso é comum manter pastas separadas — build-debug e build-release, por exemplo — cada uma configurada uma vez com o tipo correspondente, em vez de reconfigurar a mesma pasta toda hora.
CMake moderno: targets e propriedades
No CMakeLists.txt que escrevemos antes, o padrão da linguagem foi definido assim:
cmake_minimum_required(VERSION 3.20)
project(MeuProjeto VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(programa
main.cpp
utils.cpp
utils.h
)Repare que set(CMAKE_CXX_STANDARD 20) não menciona programa em nenhum momento — ela define uma variável global, que vale para todo o CMakeLists.txt a partir dali. Enquanto o projeto tem um único executável, isso não causa nenhum problema. Mas assim que o projeto cresce e passa a ter, por exemplo, um segundo executável que precisa de um padrão C++ diferente essa variável global se torna um problema, ela vale igual para tudo, sem distinção entre os diferentes projetos que o mesmo CMakeLists.txt está construindo.
A partir da versão 3.x, o CMake resolve isso com o conceito de targets: em vez de configurar variáveis globais que afetam o arquivo inteiro, você anexa cada configuração diretamente ao alvo de build que ela diz respeito — o próprio programa criado por add_executable, por exemplo. A lista de arquivos-fonte continua sendo declarada do mesmo jeito, um a um, dentro do add_executable; o que muda é que agora as flags de compilação, o padrão da linguagem e os diretórios de include ficam presos àquele target específico, em vez de valerem globalmente:
add_executable(programa main.cpp utils.cpp utils.h)
# Define o padrão C++ para esse target específico
target_compile_features(programa PRIVATE cxx_std_20)
# Adiciona diretórios de include
target_include_directories(programa PRIVATE include/)
# Adiciona flags de compilação
target_compile_options(programa PRIVATE -Wall -Wextra)É esse mesmo conceito que permite resolver um problema prático de qualquer projeto com múltiplos executáveis pequenos: em vez de criar (e manter) um CMakeLists.txt inteiro do zero para cada um deles, um único CMakeLists.txt pode declarar um target por executável, todos compartilhando a mesma base de configuração quando fizer sentido.
Isso só é possível porque as configurações estão presas a targets, e não a variáveis globais soltas em cada arquivo. Um CMakeLists.txt pode registrar dezenas de targets diferentes — um para cada pequeno programa ou exercício —, cada um com as próprias flags específicas quando necessário, mas todos herdando a base comum de um só lugar.
Um exemplo simplificado ajuda a visualizar isso. Imagine três exercícios de um curso, cada um usando um padrão diferente de C++ — um deles, inclusive, para demonstrar um recurso que só existe a partir do C++20:
cmake_minimum_required(VERSION 3.20)
project(CursoCpp)
# Exercício do módulo de introdução, usando C++17
add_executable(hello-world modulo-1/hello-world.cpp)
target_compile_features(hello-world PRIVATE cxx_std_17)
# Exercício do módulo de sintaxe, usando C++20
add_executable(auto-e-lambdas modulo-2/auto-e-lambdas.cpp)
target_compile_features(auto-e-lambdas PRIVATE cxx_std_20)
# Exercício usando um recurso específico do C++23
add_executable(ranges-e-views modulo-5/ranges-e-views.cpp)
target_compile_features(ranges-e-views PRIVATE cxx_std_23)Repare que target_compile_features é chamado uma vez para cada target, e cada chamada é completamente independente das outras. Isso significa que hello-world compila com C++17, auto-e-lambdas com C++20 e ranges-e-views com C++23, tudo dentro do mesmo CMakeLists.txt, sem que a escolha de um afete a compilação dos outros dois. Se essa configuração fosse feita com a variável global CMAKE_CXX_STANDARD, como no exemplo anterior, não haveria como diferenciar os três — o padrão definido valeria para todos os targets do arquivo.
Ao rodar cmake --build . nesse projeto, os três executáveis são gerados de uma vez: hello-world, auto-e-lambdas e ranges-e-views, cada um na sua própria versão da linguagem.
Essa é uma desvantagem de reunir vários exercícios em um único CMakeLists.txt: por padrão, cmake --build . compila todos os targets registrados, mesmo que você só queira testar o exercício do capítulo em que está agora. Em um repositório de curso, com dezenas de capítulos, isso significa recompilar (ou pelo menos verificar) dezenas de executáveis só para rodar um.
Mas existe uma solução simples que não envolve abrir mão de um CMakeLists.txt único — pedir ao CMake para compilar apenas o target que interessa, usando a flag --target:
cmake --build . --target auto-e-lambdasEsse comando compila (ou recompila, se algo mudou) só o auto-e-lambdas, ignorando os outros dois targets do exemplo. hello-world e ranges-e-views continuam declarados no mesmo CMakeLists.txt, prontos para ser compilados quando for a vez deles, mas nenhum trabalho é feito neles enquanto você não pedir. É esse recurso — build seletivo por target — que faz o modelo de "um repositório, um CMakeLists.txt, muitos exercícios" funcionar na prática, sem que cada código do repositório deixe a build inteira mais lenta para quem só quer rodar um exemplo específico.
O que fica para você levar deste capítulo
Para os exemplos iniciais do curso, vamos começar com chamadas diretas ao g++, onde os programas são simples e de arquivo único. Conforme os projetos crescerem, vamos introduzir o CMake com Ninja como backend — é a combinação que você vai encontrar na grande maioria dos projetos C++ modernos.
Você não precisa dominar o Make para seguir o curso, mas vai encontrar Makefile em projetos mais antigos e em bibliotecas que ainda não migraram para o CMake.
Ficou faltando um tópico que vai vir a ser importante: como usar, com o CMake, bibliotecas que não fazem parte da biblioteca padrão do C++ — instaladas no seu sistema operacional, ou de terceiros que você baixa de algum lugar. Teremos um capítulo dedicado só a esse assunto. Mas diferente deste aqui, ele é um capítulo que recomendo pular por enquanto: vá direto para o capítulo de IDEs, e volte para o de bibliotecas externas quando precisar usar uma pela primeira vez — só nesse momento esse conhecimento vai ter uso prático.