Resumo
Até aqui, cada programa do curso cabia em um só arquivo. Um programa de mil linhas já não se lê de uma vez, e suas funções merecem servir a outros programas. Esta aula ensina a dividi-lo: um cabeçalho que declara o que um módulo oferece, um arquivo de implementação que o define e um main.cpp que o usa. Apresenta as guardas de inclusão; a regra de uma definição, que explica os dois erros clássicos do ligador e o sentido moderno de inline; a compilação separada, que recompila só o que mudou; e termina com uma biblioteca própria, feita de funções de aulas anteriores.
Objetivos de aprendizagem
- Dividir um programa em um cabeçalho com declarações, um arquivo de implementação e um
main.cpp, e compilá-lo. - Escrever guardas de inclusão e explicar que problema resolvem; reconhecer
#pragma oncecomo uma extensão que todos os compiladores aceitam, mas que o padrão não contempla. - Explicar a regra de uma definição e diagnosticar suas violações a partir da mensagem do ligador (“multiple definition”, “undefined reference”).
- Compilar separadamente com
-ce ligar os objetos, explicando o que se recompila quando muda cada arquivo.
Interface e implementação
A modularidade
Uma função com contrato, como as da aula Projetar com funções, já separa o que promete, escrito em sua declaração e em seu contrato, de como o cumpre, escrito em seu corpo; quem a chama precisa só do primeiro. Se isso é assim, nada impede guardar ambas as partes em arquivos distintos. Um módulo é um grupo de funções (e dos tipos que usam) com um propósito comum; sua interface é o que oferece a quem o usa, e sua implementação, o modo como o faz. Em C++, a interface se escreve em um arquivo de cabeçalho (extensão .hpp, ou .h, herdada de C) e a implementação em um arquivo .cpp de mesmo nome.
Disso se seguem três vantagens. Entende-se por partes: para usar o módulo, basta ler seu cabeçalho. Compila-se por partes: uma mudança na implementação não obriga a recompilar quem só usa a interface. E testa-se por partes, contra cada contrato, como ensinará a aula Testar. O curso LaTeX para a Redação Acadêmica divide do mesmo modo um documento longo, em sua aula “Documentos longos”.
Três arquivos
O módulo de geometria plana deste exemplo oferece três funções; duas delas vêm da aula 5.3. O cabeçalho só as declara, cada uma com seu contrato. As três linhas que começam com # serão explicadas na seção seguinte; por enquanto basta saber que todo cabeçalho as leva.
// geometry.hpp: interface do módulo de geometria plana.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP// Área do círculo de raio radius.// Pré-condição: radius >= 0.// Pós-condição: devolve a área do círculo de raio radius.double circleArea(double radius);// Se três comprimentos são os lados de um triângulo.// Pré-condição: nenhuma.// Pós-condição: devolve true se e somente se existe um triângulo de lados a, b e c.bool isTriangle(double a, double b, double c);// Área do triângulo de lados a, b e c.// Pré-condição: isTriangle(a, b, c).// Pós-condição: devolve a área do triângulo de lados a, b e c (fórmula de Heron).double triangleArea(double a, double b, double c);#endifO arquivo de implementação inclui seu próprio cabeçalho e define as funções. Os cabeçalhos da biblioteca padrão de que precisa (<cmath> para a raiz, <numbers> para ) são incluídos aqui e não no cabeçalho, porque quem usa o módulo não precisa deles.
// geometry.cpp: implementação do módulo de geometria plana.#include "geometry.hpp"#include <cmath>#include <numbers>double circleArea(double radius){ return std::numbers::pi * radius * radius;}bool isTriangle(double a, double b, double c){ return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}double triangleArea(double a, double b, double c){ const double s{(a + b + c) / 2}; return std::sqrt(s * (s - a) * (s - b) * (s - c));}Por último, main.cpp inclui o cabeçalho e usa o módulo, sem ver uma só linha de sua implementação:
#include <print>#include "geometry.hpp"int main(){ std::println("Círculo de raio 2: {:.4f}", circleArea(2.0)); std::println("Há triângulo de lados 3, 4, 5? {}", isTriangle(3.0, 4.0, 5.0)); std::println("Sua área: {}", triangleArea(3.0, 4.0, 5.0)); std::println("E de lados 1, 2, 3? {}", isTriangle(1.0, 2.0, 3.0));}Círculo de raio 2: 12.5664Há triângulo de lados 3, 4, 5? trueSua área: 6E de lados 1, 2, 3? falseAs aspas de #include "geometry.hpp" procuram o arquivo antes de tudo na pasta do arquivo que o inclui, e os sinais de menor e maior de #include <print>, nas do sistema: as aspas são para os arquivos próprios. O arquivo geometry.cpp inclui seu próprio cabeçalho em primeiro lugar, de modo que, se ao cabeçalho faltasse uma inclusão de que ele mesmo precisa, o erro apareceria ao compilar o módulo e não no arquivo de outra pessoa. E o triângulo de lados 1, 2 e 3 não existe (), de modo que a pré-condição de triangleArea obriga a perguntá-lo antes.
Para compilar, o comando recebe os dois arquivos .cpp; o cabeçalho não é nomeado, porque entra em cada um deles por #include. As opções do curso são guardadas em uma variável do terminal, FLAGS, para não repeti-las em cada comando; $FLAGS as recupera. Assim se escreve no Linux e no macOS; no PowerShell as variáveis se escrevem de outro modo, e convém escrever as opções completas ou compilar pelo editor (seção “Compilação separada”).
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./mainCírculo de raio 2: 12.5664Há triângulo de lados 3, 4, 5? trueSua área: 6E de lados 1, 2, 3? falseA figura retoma a da aula Programas, compiladores e C++ para este projeto.
Demonstração
O programador escreve três arquivos: um cabeçalho e dois arquivos
.cpp. Só os.cppsão entregues ao compilador.O pré-processador copia
geometry.hppem cada arquivo que o inclui. Há, portanto, duas cópias das declarações, uma em cada unidade de tradução, e isso é correto: uma função pode ser declarada quantas vezes se quiser.Cada unidade é compilada separadamente em seu arquivo-objeto. Ao compilar
main.cpp, o compilador não olhageometry.cpp: a declaração lhe basta para verificar os tipos de cada chamada.O ligador une os dois objetos e a biblioteca padrão, e resolve cada chamada de
main.oacircleAreacom a definição que está emgeometry.o.
Um erro frequente é incluir o arquivo de implementação, #include "geometry.cpp". Se além disso ele é nomeado no comando, suas funções ficam definidas duas vezes e a ligação fracassa, pela regra que a seção “A regra de uma definição” explicará; se não é nomeado, main.cpp volta a ser um só texto, que se recompila inteiro a cada mudança. Os arquivos .cpp são compilados; os cabeçalhos são incluídos.
Carregando o conteúdo…