Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial
  • Lezione 18 di 93
  • livello avanzato
Indice lezioni

Copia di oggetti

Che cos'è il costruttore di copia in C++: dalle regole sintattiche di base, alle ottimizzazioni del compilatore e le buone regole di programmazione.
Che cos'è il costruttore di copia in C++: dalle regole sintattiche di base, alle ottimizzazioni del compilatore e le buone regole di programmazione.
Link copiato negli appunti

In questa lezione tratteremo una delle componenti più critiche per l'efficienza e la correttezza formale di un programma scritto in linguaggio C++, la copia di oggetti, di cui avevamo accennato a proposito della differenza tra passaggio per valore e per riferimento.

Lo standard prevede uno strumento apposito per effettuare la copia degli oggetti, il costruttore di copia, che condivide le stesse particolarità della firma di un costruttore ordinario, ma si caratterizza per il fatto che il primo dei suoi parametri è una reference ad un oggetto della stessa classe, mentre i parametri aggiuntivi, se presenti, sono opzionali.

Inoltre, come per il costruttore di default, il compilatore provvede alla generazione automatica di un costruttore di copia, qualora esso non sia già presente nella classe.

Il frammento seguente riporta la dichiarazione e la implementazione del costruttore di copia per la classe Point2D, che abbiamo già visto nelle lezioni precedenti.

// point2d.h
...
Point2D(const Point2D& other); // costruttore di copia (dentro la sezione pubblica della classe)
...
// point2d.cpp
...
Geometry::Point2D::Point2D(const Point2D& other)
{
    x = other.X();
    y = other.Y();
}
...

La parola chiave const che decora il parametro del costruttore di copia è uno dei qualificatori del linguaggio, con molteplici usi e valenze che verranno trattati in maniera approfondita nelle lezioni successive. Per il momento possiamo limitarci a dire che l'applicazione ad una variabile reference ricade in uno dei suoi casi d'uso straordinari: un valore temporaneo (o rvalue) che venga associato ad una const reference può sopravvivere al di là dell'istruzione in cui esso è contenuto.

Ciò significa che è possibile usare il costruttore di copia non soltanto su lvalue, cioè entità che possono essere dereferenziate, ma anche sul risultato di espressioni, come mostrato nell'esempio seguente:

#include "point2d.h"
using Geometry::Point2D;
int main()
{
    Point2D p1(10, 10); // chiama il costruttore Point2D(double, double)
    Point2D p2(p1); // ok, chiama il costruttore di copia
    Point2D p3(Point2D(10, 10)); // ok solo se il costruttore di copia accetta
    // un valore temporaneo, cioè una const reference.
    return 0;
}

La variabile p3 è l'unica cui viene assegnato il risultato di un'espressione, cioè un valore temporaneo senza nome. Se il qualificatore const venisse omesso nel costruttore di copia della classe Point2D, la definizione di p3 produrrebbe un errore di compilazione. Tuttavia, per lo standard C++, un costruttore di copia che accetta una reference non-const è altrettanto valido, sebbene ciò comporti le limitazioni di cui sopra.

Semantica del costruttore di copia

Il costruttore di copia qui proposto per la classe Point2D, non è per niente differente da quello che il compilatore avrebbe generato per noi. Infatti, la semantica che viene applicata per il costruttore di copia generato automaticamente è quella della copia membro a membro.

Quando, però, una classe contiene dati membro che sono puntatori o reference, la copia membro a membro di un oggetto può non essere sufficiente.

In questo caso, si dice che la copia membro a membro è una copia "superficiale" (shallow copy). Un'istanza della classe ed una sua shallow copy, sono infatti legate, poichè una modifica fatta ad un dato membro putatore o reference di una si riflette automaticamente nell'altra, con conseguenze che possono portare anche all'instabilità del programma.

In questi casi è quindi importante implementare il costruttore di copia correttamente, eliminando eventuali dipendenze tra un oggetto e le sue copie. Un esempio di questo approccio è mostrato nel listato seguente:

// a.h
class A {
    public:
        A();
        ~A();
        A(const A& other);
    private:
        int m;
        int *ptr;
    };
// a.cpp
...
A::A(const A& other)
{
    m = other.m;
    // shallow copy
    // ptr = other.ptr;
    // deep copy
    ptr = new int;
    if (other.ptr != nullptr)
        *ptr = *(other.ptr);
}

La riga di codice commentata mostra come verrebbe effettuata una shallow copy, mentre le righe successive implementano una deep copy (copia "profonda"): viene prima allocata la memoria per il dato membro ptr, e successivamente in essa viene copiato il valore puntato da other.ptr.

L'operatore di assegnamento di copia

Quando si parla di copia, a parte il costruttore, c'è un altro costrutto da tenere in considerazione: l'operatore di assegnamento di copia =.

Questo operatore entra in gioco tutte le volte che ad un oggetto viene riassegnato un valore in una fase successiva alla sua dichiarazione, come nell'esempio seguente:

Point2D a(1, 1); // invoca il costruttore Point2D(double, double)
Point2D b(2, 2); // come sopra
a = b; // invoca l'operatore di assegnamento di copia

Nelle lezioni successive vedremo che gli operatori sono assimilabili a funzioni che operano su argomenti di tipo ben preciso e restituiscono un risultato. In quanto tali, anche essi possono essere sovraccaricati.

Per lo scopo di questa lezione, ci limiteremo alla definizione di questo operatore come membro di classe, come illustrato nel frammento seguente per la classe Point2D:

// point2d.h
...
Point2D& operator=(const Point2D& other); // operatore di assegnamento di copia definito nella sezione pubblica
...
// point2d.cpp
...
Geometry::Point2D& Geometry::Point2D::operator=(const Point2D& other)
{
    x = other.x;
    y = other.y;
    return *this;
}
...

La firma dell'operatore di assegnamento di copia presenta delle analogie con quella del costruttore di copia, e la sua implementazione, a meno dell'istruzione return *this; dovrebbe essere identica a quella del costruttore. Inoltre, anche l'operatore di assegnamento viene generato automaticamente dal compilatore, qualora la nostra classe non ne definisca uno e salvo casi particolari.

La parola chiave this è una parala riservata del linguaggio la cui semantica è quella di un puntatore all'oggetto in uso, cioè quello il cui operatore di assegnamento di copia viene invocato. Gli altri usi di this, ed il modo in cui esso è implementato saranno oggetto delle lezione successive.

Sebbene costruttore e operatore di assegnamento di copia siano pressochè identici nell'implementazione, è bene osservare che entrambi devono essere definiti quando, per una corretta implementazione della semantica della copia di una classe, non sono sufficienti i metodi generati dal compilatore.

RVO e elisione di copia

La copia di oggetti è uno dei meccanismi alla base di molte funzionalità del linguaggio C++: assegnazione e costruzione di copie, passaggio per valore dei parametri e copia del valore di ritorno di una funzione, algoritmi generici su strutture dati, eccetera.

Tuttavia l'uso eccessivo della copia è anche una delle critiche più pesanti che è stata rivolta al C++: cosa succede quando dobbiamo iterare la stessa funzione, ad esempio in un ciclo che scandisce una lista, o un altra struttura dati, con migliaia o milioni di oggetti complessi?

La copia di un oggetto richiede tempo e risorse (memoria, cicli del processore), ed in certi casi può anche rivelarsi non necessaria. Eppure a volte, di tutte le istruzioni eseguite da una funzione, è la copia di oggetti l'operazione più costosa. La commissione di esperti che redige e revisiona lo standard del liguaggio ha pertanto cercato nel tempo di introdurre una serie di accorgimenti che riguardano l'implementazione dei compilatori per C++, al fine di dare una soluzione a questi problemi.

Una delle fasi che portano alla creazione di un eseguibile è l'ottimizzazione del codice. Essa è tipicamente una fase successiva a quella della compilazione, non necessaria in senso stretto, ma che molto spesso produce dei vantaggi prestazionali per i programmi. Il processo di ottimizzazione, infatti, consente di trasformare un programma in uno equivalente in termini algoritmici, migliorandone l'efficienza.

Tra le tante modalità di ottimizzazione, lo standard C++ ne prescrive due in particolare che possono essere applicate ogni volta che viene invocato il costruttore di copia.

Il caso più comune è quello dell'ottimizzazione del valore di ritorno di una funzione o RVO (return value optimization).

L'ambito di visibilità del valore di ritorno di una funzione infatti è limitato al corpo della funzione stessa, pertanto quando assegnamo ad una variabile il valore di ritorno di una funzione, in realtà il valore dovrebbe essere copiato nel contesto della chiamata a funzione. RVO è un'ottimizzazione che consiste nell'alterare la firma della funzione di modo da includere un parametro addizionale passato per riferimento, da usare al posto del valore di ritorno.

Un esempio del risultato di un'ottimizzazione di questo tipo è riportato nel listato seguente:

// funzione scritta dal programmatore per il calcolo del punto medio di un segmento
Point2D middlePoint(Point2D a, Point2D b)
{
    Point2D result;
    result.setX((a.X() + b.X()) / 2.0);
    result.setY((a.Y() + b.Y()) / 2.0);
    return result;
}
...
Point2D c = middlePoint(Point2D(1, 1), Point2D(1, 2));
// codice equivalente a seguito dell'ottimizzazione RVO operata dal compilatore
void middlePoint(Point2D a, Point2D b, Point2D& result)
{
    result.setX((a.X() + b.X()) / 2.0);
    result.setY((a.Y() + b.Y()) / 2.0);
}
...
Point2D c;
middlePoint(Point2D(1, 1), Point2D(1, 2), c);

RVO è un tipo di ottimizzazione che dipende anche dal modo in cui scriviamo il nostro codice. Se la funzione presenta più di un possibile valore di ritorno, ad esempio dentro una condizione if-else, il compilatore potrebbe non essere in grado di ottimizzare il codice.

La seconda forma di ottimizzazione è l'elisione della copia (copy elision), un tipo di ottimizazzione che viene apportata dal compilatore ogni qualvolta è in gioco un valore temporaneo: perchè sprecare risorse nel costruire un valore temporaneo al solo scopo di copiarlo in una variabile dello stesso tipo e distruggerlo immediatamente dopo?

Abbiamo già visto un caso in cui avviene una elisione di copia: ricordate infatti la variabile p3 discussa in precedenza?

Point2D p3(Point2D(10, 10)); // ok solo se il costruttore di copia accetta
// un valore temporaneo, cioè una const reference.

In un caso del genere, la maggior parte dei compilatori per C++ ottimizzerà la copia del temporaneo Point2D(10, 10) in una invocazione diretta al costruttore della classe, esattamente come se avessimo scritto:

Point2D p3(10, 10);

In realtà, quindi, nel listato precedente è altamente probabile che il costruttore di copia venga invocato solo per inizializzare la variabile p2.

Resta però il fatto che rimuovendo il qualificatore const dalla firma del costruttore di copia, il compilatore riscontrerà un errore nell'inizializzazione di p3, anche se in raltà nessuna copia verrà fatta. Perchè?

Perchè l'ottimizzazione è un'operazione accessoria che avviene in una fase successiva alla compilazione. Pertanto è necessario, che il nostro programma risulti corretto dal un punto di vista sintattico anche se, il compilatore, mediante il processo di ottimizzazione, è libero di alterare singole istruzioni a patto di preservare la loro valenza semantica.

Questa soluzione però ha un effetto collaterale: cosa accade infatti se la nostra implementazione del costruttore di copia, oltre che copiare, svolge altre operazioni? L'elisione della copia in questo caso non sarebbe una ottimizzazione trasparente, ma modificherebbe sostanzialmente il comportamento del programma.

La risposta data dalla commissione è: sì può succedere.

C++ è un linguaggio complesso, le cui regole semantiche sono ben lontane dalla linearità del C. Pertanto laddove le regole sintattiche del linguaggio confliggono con le ottimizzazioni del compilatore, dando origine a possibili ambiguità, l'unica forma di prevenzione è attenersi alle regole di buona programmazione: il costruttore di copia deve limitarsi a copiare e niente altro.

Se applicheremo sempre questa regola, demandando ogni altra operazione a funzioni membro specifiche, le ottimizzazioni del compilatore saranno effettivamente utili nel rendere più performante il nostro codice.

Ti consigliamo anche