Salta al contenuto
Note per Studenti C++ - scope, namespace e librerie

C++ - scope, namespace e librerie

In questa pagina 4

Due temi legati dall'idea di nomi: dove un nome è valido (scope e namespace) e come si condividono le dichiarazioni tra file e librerie (header, include guard, linking statico e dinamico). Prima si sono visti la compilazione di un file in C++ - introduzione, compilazione e linkingC++ è un linguaggio compilato e standard che dà controllo totale sulla macchina (basso livello) e strumenti di astrazione (alto livello); un programma parte da main, usa gli stream (std::cout << ...) e le direttive del preprocessore (#include); il passaggio da sorgente a eseguibile ha tre fasi, preprocessore (espande #include e macro), compilatore (controlla sintassi e tipi e produce un file oggetto in codice macchina) e linker (unisce i file oggetto e le librerie risolvendo i nomi); gli errori di sintassi e di tipo sono del compilatore, i simboli dichiarati ma non definiti (undefined reference) sono del linker; si compila da riga di comando con g++ -o eseguibile sorgente.cpp -lLibreria.C++ - introduzione, compilazione e linking → e la differenza fra dichiarazione e definizione in C++ - tipi, variabili, espressioni e dichiarazioniun tipo definisce contenuto, intervallo di valori e operazioni; un oggetto è una regione di memoria con un tipo, il valore è la configurazione di bit letta in base al tipo, una variabile è un oggetto con un nome; inizializzazione (dare il primo valore) e assegnamento (cambiare il valore) sono diversi; le conversioni verso un tipo più capiente sono sicure, quelle verso un tipo meno capiente (narrowing, double->int, int->char) non lo sono e il compilatore le segnala di rado, ma la notazione con graffe {} le vieta; un'espressione calcola un valore da operandi e operatori, un lvalue è ciò che può stare a sinistra di =, constexpr vuole un valore noto in compilazione mentre const solo un valore che non cambia; gli operatori con side effect (++, --, +=) non vanno mischiati con altri usi della stessa variabile nella stessa espressione perché l'ordine di valutazione non è definito; una dichiarazione introduce un nome e un tipo, una definizione fornisce anche l'entità (memoria per una variabile, corpo per una funzione).C++ - tipi, variabili, espressioni e dichiarazioni →; la durata delle variabili in memoria (diversa dallo scope) è in C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →.

Lo scope

Definizione. Lo scope è una regione del testo di un programma. Ogni nome è dichiarato in uno scope ed è valido (in scope) dal punto della dichiarazione fino alla fine di quello scope.

Obiettivo: rendere i nomi locali ("Locality is good") per evitare interferenze (clash). Per questo i nomi sono tanto più descrittivi quanto più grande è il loro scope. Non va confuso con la durata (storage duration): lo scope dice dove nel testo il nome è accessibile, la durata dice per quanto tempo l'oggetto esiste in memoria. Una variabile static locale esce dallo scope a fine funzione ma resta in memoria (C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →).

I tipi di scope

scope dove note
globale (di file) fuori da ogni altro scope funzioni e tipi spesso; le variabili globali vanno usate con cautela
di classe il testo dentro una classe i membri sono visibili in tutta la classe, anche se dichiarati dopo
locale dentro un blocco { } o nella lista degli argomenti di una funzione dal punto di dichiarazione alla }
di statement dentro un'istruzione come il for la variabile del for non esiste dopo il ciclo
di namespace uno scope con un nome, dentro lo scope globale o un altro namespace strumento nuovo, vedi sotto

Un unico programma con un esempio per tipo:

scope_tipi.cppcpp
#include <iostream>

int global_counter = 100;            // scope GLOBALE (di file): visibile ovunque sotto

namespace Geometria {                // scope di NAMESPACE: uno scope con nome
    constexpr double pi = 3.14159;
    double area_cerchio(double r) { return pi * r * r; }
}

class Contatore {                    // scope di CLASSE: il testo dentro la classe
public:
    void incrementa() { ++valore; }  // valore e' visibile qui, anche se dichiarato dopo
    int leggi() const { return valore; }
private:
    int valore = 0;
};

int somma(int a, int b)              // a e b: scope LOCALE (lista degli argomenti)
{
    int risultato = a + b;           // scope locale: dal punto di dichiarazione alla }
    return risultato;
}

int main()
{
    int x = 5;                       // locale a main
    {                                // un blocco crea uno scope annidato
        int x_interno = x * 2;       // esiste solo qui dentro
        std::cout << "blocco: x_interno = " << x_interno << '\n';
    }
    // std::cout << x_interno;       // errore: x_interno non e' piu' in scope

    for (int i = 0; i < 3; ++i)      // scope di STATEMENT: i vive solo nel for
        global_counter += i;
    // std::cout << i;               // errore: i non esiste fuori dal for

    Contatore c;
    c.incrementa();
    c.incrementa();

    std::cout << "somma = " << somma(2, 3) << '\n';
    std::cout << "global_counter = " << global_counter << '\n';
    std::cout << "contatore = " << c.leggi() << '\n';
    std::cout << "area = " << Geometria::area_cerchio(1.0) << '\n';  // nome qualificato
    return 0;
}
blocco: x_interno = 10
somma = 5
global_counter = 103
contatore = 2
area = 3.14159

Le righe commentate come errori (x_interno, i fuori dal for) non compilerebbero: il nome è già uscito dallo scope.

Variabili globali

Le funzioni sono spesso dichiarate e definite nello scope globale. Per le variabili lo scope globale è problematico: la variabile è accessibile ovunque nel file (scope di file) e ogni funzione può modificarla, senza protezione né incapsulamento; il debug diventa quasi impossibile in programmi veri, e il codice è meno espressivo, perché i dati "passano" nascosti invece che come argomenti. Si usano per "semplificare" il passaggio dei dati: è una cattiva ragione. Meglio parametri e valori di ritorno (C++ - funzioni, reference e passaggio dei parametriuna funzione ha parametri formali (0 o più, input) e un valore di ritorno (0 o 1, output); in C++ gli argomenti si passano per valore, cioè il parametro è una variabile locale inizializzata con una copia dell'argomento, e modificarla non cambia l'originale; per oggetti grandi (vector, immagini) la copia costa, e si passa per reference (T&), un alias dell'oggetto del chiamante che permette lettura e scrittura, oppure per const reference (const T&), alias in sola lettura verificata dal compilatore, che accetta anche literal (oggetto temporaneo); regola: valore per oggetti piccoli, const T& per grandi da non modificare, T& solo se la funzione deve davvero modificare o produrre più di un output; il passaggio converte l'argomento nel tipo del parametro come in un'inizializzazione e un cast esplicito (static_cast) rende visibile l'intenzione.C++ - funzioni, reference e passaggio dei parametri →).

Scope annidati

Uno scope può stare dentro un altro. Casi, dal più utile al meno:

annidamento giudizio
funzioni in classi (void C::f()) il caso più frequente e utile
blocchi in blocchi (if dentro for), cicli annidati inevitabili, ma attenzione ai blocchi troppo complessi; l'indentazione è essenziale
classi in classi (classe annidata, come Date::Invalid) raramente necessario
classi in funzioni (classi locali) da evitare: se serve, la funzione è probabilmente troppo lunga
funzioni in funzioni illegali in C++

Un nome interno può nascondere (shadow) un nome esterno uguale; l'operatore :: senza prefisso raggiunge il nome globale.

scope_ombra.cppcpp
#include <iostream>

int x = 1;                    // globale

int main()
{
    std::cout << "x globale = " << x << '\n';
    int x = 2;                // locale: NASCONDE la globale (shadowing)
    std::cout << "x locale  = " << x << '\n';
    {
        int x = 3;            // ancora piu' interno: nasconde quella di main
        std::cout << "x interna = " << x << '\n';
        std::cout << "x globale con :: = " << ::x << '\n';  // operatore di scope senza nome = globale
    }
    std::cout << "di nuovo x locale = " << x << '\n';
    return 0;
}
x globale = 1
x locale  = 2
x interna = 3
x globale con :: = 1
di nuovo x locale = 2

Nascondere nomi è lecito ma pericoloso: un x in più nel blocco interno cambia silenziosamente quale variabile si usa.

Namespace

Funzioni e classi permettono di definire entità senza conflitti di nomi dentro di sé: le funzioni raggruppano righe in un blocco, le classi raggruppano funzioni, dati e tipi in un tipo. Il namespace è uno scope dedicato a organizzare classi, funzioni, dati e tipi dentro uno spazio di nomi senza definire un tipo. È utilissimo per nomi probabilmente usati da molte librerie: Text, Line, Matrix, Color, Shape.

namespace1.cppcpp
#include <iostream>
#include <string>

namespace Graph_lib {                // la libreria grafica...
    struct Text { std::string s; };
    struct Line { int x1, y1, x2, y2; };
}

namespace TextLib {                  // ...e una libreria di testo, con gli stessi nomi
    class Text { public: std::string contenuto; };
    class Line { public: std::string riga; };
}

int main()
{
    Graph_lib::Text t1 {"etichetta"};      // fully qualified name: namespace::membro
    TextLib::Text t2;
    t2.contenuto = "paragrafo";            // nessun conflitto fra i due Text
    std::cout << t1.s << ' ' << t2.contenuto << '\n';

    using std::string;                     // using declaration: solo il nome string
    using std::cout;
    string nome = "Ada";
    cout << "ciao " << nome << '\n';

    using namespace Graph_lib;             // using directive: tutti i nomi di Graph_lib
    Line l {0, 0, 3, 4};                   // Line di Graph_lib, senza prefisso
    cout << "dx = " << l.x2 - l.x1 << '\n';
    return 0;
}
etichetta paragrafo
ciao Ada
dx = 3
  • Ora le classi si chiamano Graph_lib::Text e TextLib::Text: la forma namespace::membro è il nome qualificato (fully qualified name). Non c'è conflitto per Text e Line.
  • Il prefisso appesantisce il codice (std::string, std::cout). Due scorciatoie:
costrutto effetto quando
using declaration using std::string; porta nello scope un solo nome comoda e sicura, anche in un blocco
using directive using namespace std; porta tutti i nomi del namespace solo per namespace molto noti (std) e mai negli header: chi include l'header si ritrova i nomi importati senza volerlo e può avere conflitti

Perché non negli header? L'header viene incluso in molti .cpp; una using namespace lì dentro "contamina" tutti. Tutta la libreria standard sta nel namespace std.

In sintesi: lo scope dice dove un nome è valido, il namespace è uno degli strumenti (non l'unico) per gestire gli scope.

Librerie e header file

Molte funzioni che usiamo non le abbiamo scritte noi (sqrt(), << su cout): servono librerie, e in C++ le si importa attraverso i file header.

L'header visto da chi lo usa

Definizione. Un header file è un insieme di dichiarazioni (alcune delle quali definizioni) di entità, utilizzabili da chi lo include. Esempio: math.h dichiara double sqrt(double x);.

Due forme di inclusione, che differiscono per il percorso di ricerca:

forma per ricerca
#include <iostream> header di sistema nei percorsi di sistema
#include "my_header.h" header definiti dall'utente prima nel percorso locale, poi in quello di sistema

Si può anche includere con un percorso: #include "opencv2/core.h". Il preprocessore copia il testo dell'header al posto della riga, quindi file1.cpp e file2.cpp che includono math.h conoscono entrambi la dichiarazione di sqrt.

L'header visto da chi lo scrive

Un progetto grande ha molte dichiarazioni e molte definizioni: conviene raggrupparle e spostarle in file diversi. Si ottiene più di un file sorgente e degli header. L'esempio della funzione f:

contenuto dove
dichiarazione int f(int i); header my_func.h
definizione int f(int i) { return i + 2; } file sorgente my_func.cpp
chiamata i = f(i); main.cpp, che include my_func.h

Il programma completo a tre file è in C++ - introduzione, compilazione e linkingC++ è un linguaggio compilato e standard che dà controllo totale sulla macchina (basso livello) e strumenti di astrazione (alto livello); un programma parte da main, usa gli stream (std::cout << ...) e le direttive del preprocessore (#include); il passaggio da sorgente a eseguibile ha tre fasi, preprocessore (espande #include e macro), compilatore (controlla sintassi e tipi e produce un file oggetto in codice macchina) e linker (unisce i file oggetto e le librerie risolvendo i nomi); gli errori di sintassi e di tipo sono del compilatore, i simboli dichiarati ma non definiti (undefined reference) sono del linker; si compila da riga di comando con g++ -o eseguibile sorgente.cpp -lLibreria.C++ - introduzione, compilazione e linking →. Un file sorgente può includere più header, e gli header possono includerne altri (book.h include date.h). Questo crea due problemi: l'inclusione doppia (se main.cpp include sia date.h sia book.h, date.h arriva due volte) e le inclusioni cicliche (a.h include b.h e viceversa).

Include guard

Ogni header deve essere protetto dalle include guard, direttive del preprocessore:

cpp
#ifndef RATIONAL_H     // se RATIONAL_H non e' ancora definito...
#define RATIONAL_H     // ...definiscilo e includi il contenuto
// ... dichiarazioni ...
#endif // RATIONAL_H   // fine

La prima volta che il preprocessore incontra l'header, il nome non esiste: lo definisce e copia il contenuto. Le volte dopo il nome esiste, quindi salta tutto fino a #endif. Alcuni compilatori accettano anche #pragma once, ma non è standard. Senza guard, l'errore è nella compilazione, non nel linker:

t/g/s.h:1:8: error: redefinition of 'Punto'
t/g/main.cpp:1:10: note: 't/g/s.h' included multiple times, additional include site here

Esempio: il progetto seguente ha main.cpp che include date.h e anche book.h, che a sua volta include date.h; funziona solo grazie alle guard.

date.hcpp
#ifndef DATE_H
#define DATE_H

struct Date {
    int y, m, d;
};

int giorni_nel_mese(const Date& dt);   // dichiarazione: la definizione e' in date.cpp

#endif // DATE_H
date.cppcpp
#include "date.h"

int giorni_nel_mese(const Date& dt)
{
    switch (dt.m) {
    case 2: return (dt.y % 4 == 0 && (dt.y % 100 != 0 || dt.y % 400 == 0)) ? 29 : 28;
    case 4: case 6: case 9: case 11: return 30;
    default: return 31;
    }
}
book.hcpp
#ifndef BOOK_H
#define BOOK_H

#include "date.h"                // book.h dipende da date.h

struct Book {
    const char* titolo;
    Date uscita;
};

#endif // BOOK_H
main.cppcpp
#include <iostream>
#include "date.h"                // date.h e' incluso qui...
#include "book.h"                // ...e di nuovo da book.h: le guard evitano il doppione

int main()
{
    Book b {"Programmazione in C++", {2024, 2, 1}};
    std::cout << b.titolo << ": il mese ha " << giorni_nel_mese(b.uscita) << " giorni\n";
    return 0;
}

Compilato con g++ -o prog main.cpp date.cpp (cioè linkando i due file oggetto), stampa:

Programmazione in C++: il mese ha 29 giorni

Da sorgente a eseguibile con più file

Preprocessore, compilatore e linker agiscono ora su più file: ogni .cpp diventa un file oggetto da solo (con i suoi header copiati), e il linker unisce tutti gli oggetti nell'eseguibile.

main.cpp + my_func.h  --> main.o      --\
                                         +--> linker --> eseguibile
my_func.cpp + my_func.h --> my_func.o --/

Lo stesso meccanismo funziona quando la libreria e il codice che la usa appartengono a progetti diversi: spesso del codice di terzi non arriva il .cpp ma un file precompilato più l'header. Nel caso minimo (HelloWorld.cpp) la "libreria" è quella della standard library: l'header iostream dichiara, il linker aggiunge il codice precompilato della libreria standard (libstdc++).

Librerie statiche e dinamiche

Che cosa succede se molti programmi linkano la stessa libreria?

linking statico (libreria statica) linking dinamico (libreria dinamica)
copie della libreria una copia dentro ogni eseguibile una sola copia sul sistema, condivisa a run-time
eseguibile self-contained, ma più grande; non si può "spezzare" dopo più piccolo, ma la libreria deve essere presente all'esecuzione
spazio su disco molto (N eseguibili, N copie) poco
aggiornare la libreria ricompilare e rilinkare ogni eseguibile si ricompila la libreria senza toccare gli eseguibili
nomi .a (Linux), .lib (Windows) .so shared object (Linux), .dll dynamic linking library (Windows)
adatta a distribuire software monolitico librerie usate da molti programmi

Attenzione al tranello classico dei quiz: le DLL sono di Windows e gli .so di Linux, non il contrario; le librerie dinamiche riducono lo spazio occupato e non producono eseguibili self-contained.

Domande d'esame

1. Che cos'è lo scope? Elencare i tipi di scope con un esempio per ciascuno. Traccia. Regione di testo in cui un nome è valido, dalla dichiarazione alla fine dello scope. Globale (variabile fuori da tutto), di classe (membri di class Contatore), locale (blocco { } o parametri di funzione), di statement (variabile del for), di namespace (namespace Geometria { ... }); vedi il programma scope_tipi. Non confondere con la durata: una variabile static locale è fuori scope ma ancora in memoria.

2. Cosa fanno le include guard e perché servono? Sono lo stesso che #pragma once? Traccia. Direttive del preprocessore (#ifndef, #define, #endif) che fanno copiare il contenuto dell'header una volta sola per unità di traduzione, evitando la doppia definizione di funzioni e classi (e le inclusioni cicliche). Non servono al linker: lavorano prima della compilazione. #pragma once ha lo stesso scopo ma non è standard.

3. Confrontare librerie statiche e dinamiche. Traccia. Tabella sopra: statiche = copiate in ogni eseguibile, self-contained, eseguibili grandi; dinamiche = una sola copia condivisa, risparmio di disco, ricompilabili senza toccare gli eseguibili, devono essere presenti a run-time (.so Linux, .dll Windows).

4. Perché non scrivere using namespace std; in un header? Traccia. L'header è incluso in molti file: importerebbe tutti i nomi di std ovunque, con rischio di conflitti con i nomi di chi lo include.

Versione ripasso

cpp
int global_counter = 100;                // globale
namespace Geometria { constexpr double pi = 3.14159; }   // namespace
class Contatore { int valore = 0; public: void incrementa() { ++valore; } };  // classe
int somma(int a, int b) { int r = a + b; return r; }     // locale (a, b, r)
int main() { for (int i = 0; i < 3; ++i) global_counter += i;   // i: statement
             { int interno = 1; } /* interno non esiste piu' */ }
  • Variabili globali: accessibili e modificabili da ogni funzione, nessun incapsulamento, debug difficile: evitarle, passare i dati per argomento.
  • Annidamenti: funzioni in classi (utile), blocchi (inevitabile), classi in classi (raro), classi in funzioni (da evitare), funzioni in funzioni: illegali. Un nome interno nasconde quello esterno; ::x = la x globale (dal programma: globale 1, locale 2, interna 3, ::x = 1).
  • Namespace = scope con nome, senza creare un tipo; evita conflitti (Graph_lib::Text e TextLib::Text). Nome qualificato: namespace::membro. using declaration using std::string; (un nome); using directive using namespace std; (tutti: solo per std, mai negli header).
  • Header file = dichiarazioni utilizzabili da chi lo include. #include <...> = di sistema; #include "..." = del progetto (prima percorso locale). Progetti grandi: dichiarazioni nel .h, definizioni nel .cpp, chiamata in main.cpp.
  • Include guard (non #pragma once, che non è standard):
cpp
#ifndef RATIONAL_H
#define RATIONAL_H
// dichiarazioni
#endif // RATIONAL_H

Evitano la doppia inclusione e quella ciclica; senza di esse: error: redefinition of 'Punto'.

  • Compilazione multi-file: ogni .cpp →\to .o da solo (header copiati), il linker unisce gli .o e le librerie; g++ -o prog main.cpp date.cpp. Librerie di terzi: header + file precompilato.
  • Statiche (.a/.lib): copiate in ogni eseguibile, self-contained, eseguibili grandi, per cambiarle si rilinka tutto. Dinamiche (.so Linux, .dll Windows): una sola copia condivisa, risparmio di spazio, ricompilabili senza toccare gli eseguibili, devono essere presenti a run-time.
  • Esempio di progetto a più file: date.h (struct Date + dichiarazione giorni_nel_mese), date.cpp (definizione), book.h (include date.h, struct Book), main.cpp (include date.h e book.h: date.h arriva due volte, le guard lo rendono innocuo). Uscita: Programmazione in C++: il mese ha 29 giorni.
  • Namespace, esempio: Graph_lib::Text e TextLib::Text convivono; using std::string; porta un nome; using namespace Graph_lib; li porta tutti (Line l {0,0,3,4};).
  • Esempio di scope in un programma: int global_counter = 100; (globale) modificato da un for (int i = 0; i < 3; ++i) global_counter += i; →\to 103; class Contatore con valore privato (scope di classe), namespace Geometria { constexpr double pi; area_cerchio } chiamato come Geometria::area_cerchio(1.0) →\to 3.14159; somma(int a, int b) con risultato locale.
  • Risposte d'esame: scope = regione di testo in cui un nome è valido; elencare i cinque tipi con un esempio ciascuno; include guard = direttive del preprocessore che fanno includere l'header una volta sola (non servono al linker); statiche vs dinamiche = tabella sopra; using namespace negli header vietato perché contamina chi li include.
  • Errori tipici: scambiare .so e .dll; dire che le dinamiche sono self-contained; dire che le include guard servono al linker; mettere using namespace in un header; usare una variabile fuori dal suo blocco; definire una funzione dentro un'altra; confondere scope e durata.
  • Domande d'esame su questa nota: tipi di scope con esempio: Esercizio - I tipi di scope in C++ (secondo appello 2021-22, teoria); scope e durata della memorizzazione: Esercizio - Scope e durata della memorizzazione (terzo appello 2022-23, teoria); quiz su include guard e librerie dinamiche: Esercizio - Quiz su include guard, librerie dinamiche, explicit e function object (primo appello 2021-22, quiz).
  • Comandi e messaggi da ricordare: senza guard error: redefinition of 'Punto'; compilazione a due passi g++ -c main.cpp, g++ -c date.cpp, g++ -o prog main.o date.o; con più file g++ -o prog main.cpp date.cpp.

Esercizi su questo argomento

Teoria collegata