Vai al contenuto
Topos Uranos

Riepilogo

Finora ogni programma del corso stava in un solo file. Un programma di mille righe non si legge più tutto d’un fiato, e le sue funzioni meritano di servire ad altri programmi. Questa lezione insegna a dividerlo: un header che dichiara ciò che un modulo offre, un file di implementazione che lo definisce e un main.cpp che lo usa. Presenta le guardie di inclusione; la regola della definizione unica, che spiega i due errori classici del linker e il senso moderno di inline; la compilazione separata, che ricompila solo ciò che è cambiato; e si chiude con una libreria propria, fatta di funzioni di lezioni precedenti.

Obiettivi di apprendimento

  1. Dividere un programma in un header con dichiarazioni, un file di implementazione e un main.cpp, e compilarlo.
  2. Scrivere guardie di inclusione e spiegare quale problema risolvono; riconoscere in #pragma once un’estensione che tutti i compilatori accettano, ma che lo standard non contempla.
  3. Spiegare la regola della definizione unica e diagnosticarne le violazioni a partire dal messaggio del linker («multiple definition», «undefined reference»).
  4. Compilare separatamente con -c e collegare gli oggetti, spiegando che cosa si ricompila quando cambia ciascun file.

Interfaccia e implementazione

La modularità

Una funzione con contratto, come quelle della lezione Progettare con le funzioni, separa già ciò che promette, scritto nella sua dichiarazione e nel suo contratto, dal modo in cui lo mantiene, scritto nel suo corpo; chi la chiama ha bisogno solo della prima parte. Se è così, nulla impedisce di conservare le due parti in file distinti. Un modulo è un gruppo di funzioni (e dei tipi che usano) con uno scopo comune; la sua interfaccia è ciò che offre a chi lo usa, e la sua implementazione, il modo in cui lo fa. In C++ l’interfaccia si scrive in un header (file di intestazione, con estensione .hpp, o .h, ereditata dal C) e l’implementazione in un file .cpp con lo stesso nome.

Ne seguono tre vantaggi. Si comprende per parti: per usare il modulo basta leggerne l’header. Si compila per parti: un cambiamento nell’implementazione non obbliga a ricompilare chi usa solo l’interfaccia. E si testa per parti, rispetto a ciascun contratto, come insegnerà la lezione Testare. Il corso LaTeX per la Scrittura Accademica divide allo stesso modo un documento lungo, nella sua lezione «Documenti lunghi».

Tre file

Il modulo di geometria piana di questo esempio offre tre funzioni; due di esse vengono dalla lezione 5.3. L’header si limita a dichiararle, ciascuna con il suo contratto. Le tre righe che cominciano con # saranno spiegate nella sezione seguente; per ora basta sapere che ogni header le contiene.

geometry.hpp
// geometry.hpp: interfaccia del modulo di geometria piana.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP​// Area del cerchio di raggio radius.// Precondizione: radius >= 0.// Postcondizione: restituisce l'area del cerchio di raggio radius.double circleArea(double radius);​// Se tre lunghezze sono i lati di un triangolo.// Precondizione: nessuna.// Postcondizione: restituisce true se e solo se esiste un triangolo di lati a, b e c.bool isTriangle(double a, double b, double c);​// Area del triangolo di lati a, b e c.// Precondizione: isTriangle(a, b, c).// Postcondizione: restituisce l'area del triangolo di lati a, b e c (formula di Erone).double triangleArea(double a, double b, double c);​#endif

Il file di implementazione include il proprio header e definisce le funzioni. Gli header della libreria standard di cui ha bisogno (<cmath> per la radice, <numbers> per π\pi) si includono qui e non nell’header, perché chi usa il modulo non ne ha bisogno.

geometry.cpp
// geometry.cpp: implementazione del modulo di geometria piana.#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));}

Infine, main.cpp include l’header e usa il modulo, senza vedere una sola riga della sua implementazione:

Programma in C++
#include <print>​#include "geometry.hpp"​int main(){    std::println("Cerchio di raggio 2: {:.4f}", circleArea(2.0));    std::println("Esiste un triangolo di lati 3, 4, 5? {}", isTriangle(3.0, 4.0, 5.0));    std::println("La sua area: {}", triangleArea(3.0, 4.0, 5.0));    std::println("E di lati 1, 2, 3? {}", isTriangle(1.0, 2.0, 3.0));}
Output
Cerchio di raggio 2: 12.5664Esiste un triangolo di lati 3, 4, 5? trueLa sua area: 6E di lati 1, 2, 3? false

Le virgolette di #include "geometry.hpp" cercano il file anzitutto nella cartella del file che lo include, e le parentesi angolari di #include <print>, in quelle del sistema: le virgolette sono per i file propri. Il file geometry.cpp include il proprio header per primo, in modo che, se all’header mancasse un’inclusione di cui esso stesso ha bisogno, l’errore apparirebbe compilando il modulo e non nel file di un’altra persona. E il triangolo di lati 1, 2 e 3 non esiste (3=1+23 = 1 + 2), sicché la precondizione di triangleArea obbliga a chiederlo prima.

Per compilare, il comando riceve i due file .cpp; l’header non si nomina, perché entra in ciascuno di essi tramite #include. Le opzioni del corso si conservano in una variabile del terminale, FLAGS, per non ripeterle in ogni comando; $FLAGS le recupera. Così si scrive in Linux e in macOS; in PowerShell le variabili si scrivono in altro modo, e conviene scrivere le opzioni per intero o compilare dall’editor (sezione «Compilazione separata»).

Terminale
$ 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$ ./mainCerchio di raggio 2: 12.5664Esiste un triangolo di lati 3, 4, 5? trueLa sua area: 6E di lati 1, 2, 3? false
EsempioIl percorso di tre file

La figura riprende quella della lezione Programmi, compilatori e C++ per questo progetto.

Dimostrazione

  1. Il programmatore scrive tre file: un header e due file .cpp. Solo i .cpp si consegnano al compilatore.

  2. Il preprocessore copia geometry.hpp in ogni file che lo include. Ci sono, quindi, due copie delle dichiarazioni, una in ciascuna unità di traduzione, ed è corretto: una funzione può essere dichiarata quante volte si vuole.

  3. Ogni unità si compila separatamente nel suo file oggetto. Compilando main.cpp, il compilatore non guarda geometry.cpp: la dichiarazione gli basta per controllare i tipi di ogni chiamata.

  4. Il linker unisce i due oggetti e la libreria standard, e risolve ogni chiamata di main.o a circleArea con la definizione che si trova in geometry.o.

Un errore frequente è includere il file di implementazione, #include "geometry.cpp". Se inoltre lo si nomina nel comando, le sue funzioni risultano definite due volte e il collegamento fallisce, per la regola che spiegherà la sezione «La regola della definizione unica»; se non lo si nomina, main.cpp torna a essere un solo testo, che si ricompila per intero a ogni cambiamento. I file .cpp si compilano; gli header si includono.