MySQL Java JavaScript PHP Python HTML-CSS C-sharp C++ Go

Idioma de copiere și înlocuire

Când trebuie să modificăm starea unui sau mai multor obiecte, iar pe parcursul modificărilor pot apărea erori, se poate folosi idiomul de copiere și înlocuire (copy-and-swap idiom) pentru a crea un cod rezistent la erori. Esența acestei idiome constă în următoarea secvență de pași:

  • Creăm o copie a obiectului (lor)
  • Modificăm copia. În acest timp, obiectele originale rămân neschimbate
  • Dacă toate modificările au avut loc cu succes, înlocuim obiectul original cu copia modificată. Dacă apare o eroare în timpul modificării copiei, obiectul original nu este înlocuit

În general, această idiomă este folosită în funcții și, un caz mai comun, dar nu unic, de utilizare este operatorul de atribuire. Într-un caz general, acest lucru arată astfel:

// operatorul de atribuire pentru o clasă Copyable
Copyable& operator=(const Copyable& obj) {
    Copyable copy{obj}; // creăm o copie prin constructorul de copiere
    swap(copy);         // schimbăm valorile între copie și obiectul original
    return *this;
}
// o funcție pentru schimbarea valorilor
void swap(Copyable& copy) noexcept;

În funcția operatorului de atribuire, mai întâi se creează o copie temporară a obiectului atribuit. Dacă copia este creată cu succes, obiectul curent (this) și copia schimbă conținutul lor printr-o funcție swap().

Funcția swap() poate fi implementată fie ca funcție externă, fie ca funcție membru al clasei (în exemplul de mai sus presupunem că aceasta este implementată în interiorul clasei). Funcția swap() este marcată cu noexcept, ceea ce înseamnă că nu va arunca excepții. Astfel, singurul loc unde poate apărea o excepție este funcția de copiere (constructorul de copiere) al obiectului. Dacă copierea nu reușește, controlul nu ajunge la execuția funcției swap.

Stabilitatea în fața excepțiilor se realizează astfel încât operatorul de atribuire să nu conțină un punct unde generarea unei excepții ar putea duce la scurgeri de memorie. Implementarea de mai sus este, de asemenea, rezistentă la atribuirea obiectului la sine însuși (a = a), însă are costuri suplimentare din cauza faptului că și copia temporară este creată chiar și în acest caz. Costurile suplimentare pot fi eliminate printr-o verificare suplimentară:

// operatorul de atribuire pentru o clasă Copyable
Copyable& operator=(const Copyable& obj) {
    Copyable copy{obj};     // creăm o copie prin constructorul de copiere
    if(this != &obj)        // dacă nu este același obiect
        swap(copy);         // schimbăm valorile între copie și obiectul original
    return *this;
}
// o funcție pentru schimbarea valorilor
void swap(Copyable& copy) noexcept;

Să luăm un exemplu al implementării acestui principiu. Dar mai întâi să vedem ce problemă poate rezolva această idiomă. Să presupunem că avem următoarea clasă:

template <typename T>
class Array
{
public:
    Array(unsigned size) : data{ new T[size] }, size{size} {}  // alocăm memorie
    ~Array() { delete[] data;}          // eliberăm memoria 
 
    // operatorul de atribuire
    Array<T>& operator=(const Array& array)
    {
        if (&array != this)
        {
            delete[] data;      // eliberăm memoria
            size = array.size;
            data = new T[size];          // alocăm memorie - poate apărea std::bad_alloc
            // copiem valorile
            for (unsigned i{}; i < size; ++i)
                data[i] = array.data[i];  // poate apărea o excepție în funcție de tipul T
        }
        return *this;
    }
    // operatorul de indexare pentru accesul la elemente
    T& operator[](unsigned index) { return data[index]; }
    unsigned getSize() const {return size;}
private:
    T* data;         // datele stocate
    unsigned size;  // dimensiunea array-ului
};

Aici, șablonul clasei Array primește un anumit dimensiune și folosește această dimensiune pentru a aloca memorie dinamică pentru un tablou. În destructor, memoria dinamică este eliberată.

Array(unsigned size) : data{ new T[size] }, size{size} {}  // alocăm memorie
~Array() { delete[] data;}          // eliberăm memoria

În funcția operatorului de atribuire, trebuie să atribuim valorile obiectului parametrului obiectului curent. Pentru aceasta, eliberăm memoria alocată anterior și alocăm din nou memorie pentru un nou tablou. În acest caz, totul pare în regulă, deoarece memoria este eliberată. Dar să vedem cum se face alocarea memoriei:

data = new T[size];

Trebuie menționat că operatorul new[] aruncă excepția std::bad_alloc dacă, dintr-un anumit motiv, nu se poate aloca memorie. De exemplu, atunci când trebuie să alocăm memorie pentru un tablou foarte mare, care nu încapă în memoria disponibilă.

Dacă operatorul new[] nu poate aloca noua memorie, pointerul data devine un așa-numit pointer suspendat — un pointer care pointează către memoria eliberată. Așadar, chiar dacă gestionăm excepția std::bad_alloc, obiectul Array va deveni inutilizabil. Și, în momentul în care se va apela destructorul, vom avea un eșec.

Mai departe, în ciclu, atribuim valorile elementelor array-ului:

data[i] = array.data[i];

În acest caz, unui element de tipul T i se atribuie o valoare de tipul T. Totuși, T poate reprezenta orice tip, iar acest tip trebuie să sprijine operatorul de atribuire. Însă, acest operator de atribuire ar putea conține o logică proprie care ar putea genera excepții.

Acum, să modificăm codul aplicând idiomul de copiere și înlocuire:

#include <iostream>

template <typename T>
class Array
{
public:
    Array(unsigned size) : data{ new T[size] }, size{size} {}  // alocăm memorie
    ~Array() { delete[] data;}          // eliberăm memoria 
 
    Array(const Array& array) : Array{array.size}
    {
        for (unsigned i {}; i < size; ++i)
            data[i] = array.data[i];
    }
    // operatorul de atribuire
    Array<T>& operator=(const Array& other)
    {
        Array<T> copy{ other };     // apelăm constructorul de copiere
        swap(copy);                 // schimbăm valorile
        return *this;
    }

    // operatorul de indexare pentru accesul la elemente
    T& operator[](unsigned index) { return data[index]; }

    // funcția de schimbare a valorilor
    void swap(Array& other) noexcept
    {
        std::swap(data, other.data);    // schimbăm cei doi pointeri
        std::swap(size, other.size);    // schimbăm dimensiunile
    }
    unsigned getSize() const {return size;}
private:
    T* data;         // datele stocate
    unsigned size;  // dimensiunea array-ului
};

int main()
{   
    const unsigned count {5};   // numărul de elemente
    Array<int> values{count};     // creăm obiectul
  
    // atribuim valorile elementelor array-ului
    for (unsigned i {}; i < count; ++i)
    {
        values[i] = i;
    }
    Array<int> numbers{0};
    numbers = values;     // folosim operatorul de atribuire
    // afișăm elementele din obiectul `numbers` pe consolă
    for (unsigned i {}; i < numbers.getSize(); ++i)
    {
        std::cout << numbers[i] << "\t";
    }
    std::cout << std::endl;
}

În acest caz, am adăugat constructorul de copiere, care, pentru a nu repeta logica alocării memoriei, apelează constructorul obișnuit și copiază valorile în obiectul curent.

Array(const Array& array) : Array{array.size}
{
    for (unsigned i {}; i < size; ++i)
        data[i] = array.data[i];
}

Astfel, obținem o copie a obiectului curent.

Pentru schimbarea valorilor, am implementat funcția swap:

void swap(Array& other) noexcept
{
    std::swap(data, other.data);    // schimbăm cei doi pointeri
    std::swap(size, other.size);    // schimbăm dimensiunile
}

În funcția operatorului de atribuire, aplicăm constructorul de copiere și funcția swap:

Array<T>& operator=(const Array& other)
{
    Array<T> copy{ other };     // apelăm constructorul de copiere
    swap(copy);                 // schimbăm valorile
    return *this;
}

Mai departe, în funcția main, putem crea un obiect Array și să-l atribuim altui obiect:

Array<int> numbers{0};
numbers = values;     // folosim operatorul de atribuire

Ieșirea în consolă va fi:

0       1       2       3       4

Deși această metodă este adesea utilizată în instrucțiunile de atribuire, ea poate fi utilizată și în alte situații în care este necesar să se efectueze o modificare rezistentă la excepții a unui obiect. Și principiul va fi mereu același. Mai întâi, copiați obiectul care trebuie modificat. În continuare, efectuăm modificări asupra obiectului copiat. Și dacă totul merge bine, facem schimb de valori între obiectul țintă și obiectul copiat.