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

Costruttore di spostamento

Lezione pratica che spiega cos'è, a cosa serve, come funziona e come si usa il costruttore di spostamento nel linguaggio di programmazione C++.
Lezione pratica che spiega cos'è, a cosa serve, come funziona e come si usa il costruttore di spostamento nel linguaggio di programmazione C++.
Link copiato negli appunti

Nella lezione precedente C++ abbiamo familiarizzato con la copia di oggetti in C++, che utilizzata in modo intensivo, causa il degrado delle prestazioni generali del programma. Per contenere i rischi, lo standard del linguaggio prevede che i compilatori mettano in atto delle strategie di ottimizzazione. C'è tuttavia da considerare che anche il nostro stile di programmazione influenza la capacità del compilatore di ottimizzare.

Consapevole di ciò, la commissione di esperti che cura lo standard ha approvato una serie di modifiche che dotano il programmatore di strumenti sintattici ad hoc per evitare la copia, soprattutto quando essa riguarda oggetti temporanei o rvalue, ma non solo.

Come funziona il move constructor

Il costruttore di spostamento (move constructor), disponibile a partire dallo standard c++11, è uno di questi.

Costruire un oggetto per spostamento è concettualmente semplice:

  • significa popolare le risorse ed i dati membro in esso contenuti sottraendoli ad un altro oggetto dello stesso tipo, piuttosto che creandone una copia;
  • L'oggetto da cui sono stati prelevati i dati, rimane in uno stato indeterminato. Lo standard prescrive delle tecniche di buona programmazione per lasciare tale oggetto in uno stato simile a quello successivo alla prima inizializzazione, in grado cioè di essere riassegnato ed eventualmente riusato.

Rvalue reference, accedere agli oggetti temporanei

Prima di scendere in dettagli sintattici, cerchiamo di capire come si accede ad un oggetto temporaneo, ad esempio il risultato di un'espressione, che vogliamo spostare in un altro oggetto dello stesso tipo.

Con il termine accedere intendiamo "dereferenziare". Infatti, un valore temporaneo è per definizione un'entità non referenziabile, in quanto non residente in memoria centrale. Quindi come si sposta un oggetto che non ha un nome e nemmeno un indirizzo di memoria?

La risposta consiste in un nuovo concetto, appositamente introdotto nello standard per risolvere questo problema: il riferimento ad un temporaneo o rvalue reference.

Un rvalue reference è rappresentato sintatticamente nella forma <tipo>&& ed è un artificio del linguaggio che consente di riconoscere a tempo di compilazione se un oggetto è temporaneo o no.

Se è possibile capire questo, allora è semplice decidere quando è il caso di fare una copia di un oggetto o spostare i dati di un temporaneo in un altro oggetto, senza il pericolo di generare delle incongruenze.

Al fine di supportare questa politica, le regole sintattiche del C++ impongono che un rvalue reference possa essere associato soltanto ad un rvalue, come lo stesso nome suggerisce.

Tuttavia non dobbiamo confondere ciò a cui un rvalue reference è associabile, con ciò che un rvalue reference è. Infatti, un rvalue reference è tecnicamente un'entità dereferenziabile, ed in quanto tale può essere modificata, può cioè comparire a sinistra dell'operatore di assegnamento, e pertanto è un lvalue.

L'analisi del listato seguente può essere utile per chiarire ulteriormente la differenza tra lvalue, rvalue, reference e rvalue reference:

#include <iostream>
int main()
{
int a = 1; // a è un lvalue, mentre 1 è un rvalue
//int&& b = a; // errore: un rvalue reference non può essere associato ad un lvalue
int& b = a; // ok: b è una reference ad un lvalue
b = 2; // e modificando b modificheremo anche a.
const int& c = 3; // ok: un const reference può essere associato ad un rvalue
//c = 2; // ma non può essere modificato!
int&& d = a + c; // ok: un rvalue reference può essere associato ad un temporaneo
d = d +1; // e può anche essere modificato, perchè è un lvalue!
std::cout << "d: " << d << std::endl;
return 0;
}

Se un rvalue reference non fosse un lvalue, l'istruzione:

d = d +1;

genererebbe un errore di compilazione, al pari di quelle commentate.

std::move

Consideriamo che:

  1. il costruttore di spostamento accetta una rvalue reference del tipo della classe in cui esso è definito;
  2. la sua implementazione consiste nell'assegnare le risorse di questa rvalue reference all'oggetto in costruzione.

Se un rvalue reference è di fatto un lvalue, il problema della distinzione tra temporaneo e lvalue, quindi della copia o dello spostamento, non si ripresenta forse per ogni assegnamento di queste risorse o membri di classe?

Purtroppo sì: sebbene un oggetto possa essere inizializzato per spostamento, mediante una chiamata all'apposito costruttore, ciò non si propaga automaticamente anche ai suoi dati membro. Per risolvere questo problema, lo standard definisce una funzione nello header <utility> che ha il solo scopo di convertire il suo unico argomento in un rvalue reference: std::move.

Non lasciamoci indurre in confusione dal nome della funzione, che purtroppo è poco rappresentativo; std::move infatti non sposta nulla, semplicemente forza il compilatore ad interpretare il suo argomento come un rvalue reference.

Il costruttore di spostamento

Abbiamo adesso tutti gli strumenti per definire ed implementare correttamente il costruttore di spostamento. Il seguente frammento di codice ne riporta la dichiarazione nella classe Point2D, vista in precedenza, cui è stato aggiunto inoltre un nuovo dato membro, label, di tipo stringa. Questo nuovo dato membro consente di mettere in evidenza il modo corretto di implementare il costruttore di spostamento nel caso generico in cui i dati membro non siano di tipo primitivo, ma oggetti.

// point2d.h
#ifndef POINT_2D_H
#define POINT_2D_H
#include <string>
namespace Geometry {
class Point2D;
}
class Geometry::Point2D
{
public:
Point2D(); // costruttore di default
Point2D(double xValue, double yValue); // costruttore sovraccaricato
Point2D(const Point2D& other); // costruttore di copia
Point2D(Point2D&& other); // costruttore di spostamento
double X();
void setX(double value);
double Y();
void setY(double value);
std::string Label();
void setLabel(std::string value);
double distanceFrom(Point2D other);
private:
double x;
double y;
std::string label;
};
#endif // POINT_2D_H

L'implementazione del costruttore di spostamento è riportata nel listato seguente; per brevità, si tralasciano i metodi getter/setter per il membro label, e le modifiche apportate agli altri costruttori:

// point2d.cpp
#include "point2d.h"
#include <utility>
...
Geometry::Point2D::Point2D(Point2D&& other) :
x(other.x),
y(other.y),
label(std::move(other.label))
{
// ripristino di other
other.x = 0;
other.y = 0;
other.label = "";
}
...

La sintassi usata qui per la definizione del costruttore di spostamento è quella degli inizializzatori di dato membro (member initializer), cioè un elenco separato da virgola di elementi del tipo <nome_membro>(valore), che precede il corpo della funzione.

Osserviamo che mentre i tipi primitivi possono essere inizializzati semplicemente a partire da un valore, per dati membro di tipo composto, cioè oggetti, è necessario forzare l'invocazione del costruttore di spostamento in maniera esplicita mediante l'uso di std::move.

L'uso di std::move è sempre lecito, anche nel caso di classi prive di un costruttore di spostamento. In queste circostanze infatti, al suo posto verrebbe invocato il costruttore di copia, poichè la rvalue reference passata come argomento verrebbe covertita in const reference.

Possiamo quindi vedere il costruttore di spostamento come un overloading del costruttore di copia ottimizzato per l'applicazione su valori temporanei, nel senso che non sono necessarie allocazioni di memoria per i dati membro in quanto esse sono già state effettuate per la creazione del temporaneo.

Per quanto riguarda la selezione dell'uno o dell'altro da parte del compilatore, abbiamo visto nella lezione precedente che il costruttore di copia, se definito con l'uso del qualificatore const, è in grado di accettare come argomento indifferentemente un rvalue o un lvalue.

In virtù delle regole sintattiche definite in precedenza, il costruttore di spostamento può invece accettare come argomento soltanto temporanei. Il compilatore, se entrambi i costruttori sono definiti, privilegia sempre quello di spostamento ogni qualvolta è necessario costruire un oggetto a partire da un temporaneo.

Questa regola si estende anche a tutte le altre funzioni sovraccaricate: viene sempre privilegiata una funzione i cui parametri sono dichiarati come rvalue references rispetto a quelle che accettano lvalue (il che include anche il caso di const reference).

Il fatto di ripristinare lo stato dell'oggetto temporaneo a condizioni simili a quelle della sua inizializzazione è importante soprattutto quando i dati membro sono puntatori o reference. Infatti, il temporaneo usato come origine dello spostamento verrà distrutto comunque una volta terminato il suo ambito di visibilità, e si deve evitare che il distruttore possa, ad esempio, deallocare con una delete un'area di memoria ancora puntata dall'oggetto che ha ricevuto quel puntatore per spostamento.

Il costruttore di spostamento, come gli altri e salvo alcune eccezioni, è generato automaticamente dal compilatore se la nostra classe non ne definisce uno. L'introduzione di questa modifica nello standard consente, teoricamente, di migliorare le prestazioni di un'applicazione scritta in C++ semplicemente ricompilandone i sorgenti usando un compilatore che supporti c++11 o successivi.

L'operatore di assegnamento di spostamento

Analogamente a quanto visto per la copia, anche per lo spostamento è previsto un operatore di assegnamento di spostamento che viene invocato quando ad un oggetto viene riassegnato un valore per spostamento in una fase successiva alla sua dichiarazione, come mostrato nell'esempio seguente:

Point2D a(1, 1); // invoca il costruttore Point2D(double, double)
a = Point2D(3, 3); // invoca l'operatore di assegnamento di spostamento

Nel frammento seguente riportiamo la definizione di questo operatore come membro per la classe Point2D:

// point2d.h
...
Point2D& operator=(Point2D&& other); // operatore di assegnamento di spostamento definito nella sezione pubblica
...
// point2d.cpp
...
Geometry::Point2D& Geometry::Point2D::operator=(Point2D&& other)
{
// se necessario, clean-up delle risorse dell'oggetto cui si sta assegnando (this)
// spostamento
x = other.x;
y = other.y;
label = std::move(other.label);
// ripristino di other
other.x = 0;
other.y = 0;
other.label = "";
return *this;
}
...

La firma del costruttore e dell'operatore di spostamento, nonché la loro implementazione, presentano le medesime analogie discusse per la copia, ed anche questo operatore viene generato automaticamente dal compilatore qualora non sia presente nella definizione della classe e salvo casi particolari.

Tuttavia, sussiste una differenza tra i due, poiché l'operatore di assegnamento di spostamento opera su istanze già "costruite". Nel caso in cui la nostra istanza di classe presenti delle risorse che richiedano di essere gestite esplicitamente in fase di rilascio (lock su file, mutex o oggetti allocati dinamicamente), l'implementazione dell'operatore di assegnamento deve tenerne conto. Ciò si traduce in un codice di clean-up molto simile a quello che si avrebbe nel distruttore della classe.

Analogamente a quanto discusso per la copia, è necessario definire sia il costruttore che l'operatore di assegnamento per garantire la coerenza della semantica dello spostamento, ed è una pratica di buona programmazione quella di limitare l'implementazione di questi metodi alle sole istruzioni che servono per spostare dati da un oggetto, evitando effetti collaterali.

Ti consigliamo anche