Elementi kontrole kopiranja

Svaki puta kada definiramo novi tip tada eksplicitno ili implicitno definiramo njegovo ponašanje prilikom kopiranja, pridruživanja i pri destrukciji. To činimo definiranjem sljedećih članova klase:

  • konstruktor kopiranjem (copy-constructor),

  • konstruktor premještanjem (move-constructor, C++11),

  • operator pridruživanja kopiranjem (copy-assignment operator),

  • operator pridruživanja premještanjem (move-assignment operator, C++11),

  • destruktor.

Konstruktor kopiranjem (CCtor) je konstruktor koji uzima jedan parametar tipa (konstantna) referenca na tip klase (u kojoj se CCtor nalazi). Koristi se eksplicitno kada se objekt konstruira i inicijalizira objektom istog tipa, te implicitno kod prijenosa parametara funkciji i u return naredbi.

Konstruktor premještanjem (MCtor) djeluje u istim situacijama kao i CCtor, ali samo na desnim vrijednostima.

Operator pridruživanja kopiranjem (OP-C) objektu s lijeve strane znaka jednakosti pridružuje vrijednost objekta s desne strane kopiranjem. Objekt na desnoj strani ostaje netaknut.

Operator pridruživanja premještanjem (OP-P) objektu s lijeve strane znaka jednakosti pridružuje vrijednost objekta s desne strane. Objekt na desnoj strani predaje svoje resurse objektu na lijevoj strani i ostaje dobar samo za destrukciju.

Destruktor se poziva automatski kada objekt izlazi iz dosega ili kad se dinamički alociran objekt briše iz memorije pomoću delete.

Budući da objekt bilo kojeg tipa mora imati mogućnost kopiranja prevodilac će sintetizirati neke elemente kontrole kopiranja (CCtor, MCtor, OP-C, OP-M i destruktor) ako ih sami ne definiramo.

Konstruktor kopiranjem

Konstruktor kopiranjem ili kraće konstrutor kopije (copy-constructor, CCtor) je konstruktor koji kreira objekt kopiranjem objekta istog tipa. Kao argument uzima referencu na objekt istog tipa.

Napomena Ako u klasi ne definiramo copy constructor, a u kodu se javlja potreba za njim, prevodilac će ga sintetizirati.

Kako djeluje sintetizirani CCtor?

Sintetizirani CCtor će izvršiti kopiranje svih nestatičkih varijabli članica tako što će

  • kod ugrađenih tipova kopirati varijablu;

  • kod članica tipa klase pozvati njihov CCtor da obavi kopiranje (definicija je dakle rekurzivna);

  • Ako je polje varijabla članica onda će CCtora obaviti kopiranje polja član-po-član.

Pozivanje CCtora

Poziva u ovim situacijama:

  • Eksplicitna ili implicitna inicijalizacija jednog objekta objektom istog tipa.

  • Kopiranje objekta da bi se predao kao argument funkciji.

  • Kopiranje objekta da bi bio vraćen iz funkcije (kroz return naredbu).

  • Inicijalizacija elementa u sekvencijalnom spremniku.

  • Inicijalizacija elemenata polja iz liste inicijalizatora.

Pogledajmo primjer jednostavne klase MagicNo koja se može zadovoljiti sa kontrolom kopiranja koju sintetizira prevodilac.

Primjer:

Jednostavna klasa koja definira samo konstruktor. Konstruktor kopiranjem je sintetizirao prevodilac.

class MagicNo{
    public:
       MagicNo(const std::string& name, const int& value):
           data_name(name), data(value) {}
     ....
    private:
       std::string data_name;
       int         data;
};
//    ....
MagicNo no1("Magic number", 117); // Poziv konstruktoru
MagicNo no2(no1);                 // Poziv konstruktoru kopije
MagicNo no3 = no2;                // Poziv konstruktoru kopije

Nakon definicije klase definirali smo tri objekta tipa MagicNo. Prvi je konstruiran konstruktorom koji uzima dva parametra. Drugi i treći su konstruirani CCtorom; no2 je kopija no1, a no3 je kopija no2.

Definicija CCtora

Definirajmo u klasi MagicNo jedan CCtor koji se ponaša isto kao i sintetizirani, te jedan konstruktor koji uzima jedan argument tipa int i koji se može koristiti u implicitnim konverzijama:

#include <string>
#include <iostream>
#include <vector>


class MagicNo{
    public:
       MagicNo(const int& value):data_name("NoName"), data(value) // Ctor1
         {std::cout << "Ctor1"<<std::endl;}

       MagicNo(const std::string& name, const int& value):         // Ctor2
           data_name(name), data(value) { std::cout << "Ctor2"<<std::endl;}

       MagicNo(const MagicNo& obj);  // CCtor
    private:
       std::string data_name;
       int         data;
};
// u .cpp datoteci
 MagicNo::MagicNo(const MagicNo& obj):
     data_name(obj.data_name), data(obj.data)
{
     std::cout << "CCtor"<< std::endl;
}

Prijenos parametara po vrijednosti

  • Prijenos argumenta tipa klase "po vrijednosti" vrši se tako što CCtor napravi kopiju stvarnog argumenta i taj se privremeni objekt predaje funkciji. Nakon što izađemo iz dosega funkcije privremeni objekt se uništava i pri tome se zove destruktor klase.

  • Kod vraćanja povratne vrijednosti u return naredbi "po vrijednosti" postupak je analogan: prvo CCtor kreira jedan privremeni objekt koji se vraća pozivatelju funkcije primjenom operatora pridruživanja, a onda se privremeni objekt uništava.

Primjer: Imamo funkciju:

MagicNo f(MagicNo A)
{
   std::cout << "Unutar funkcije f " << std::endl;
   return A;
}

i poziv:

no1 = f(no2);

Dešava se sljedeće:

  • no2 se kopira u lokanu varijablu A pomoću CCtor-a;

  • U return naredbi lokalna varijabla A se kopira pomoću CCtor-a u privremeni bezimeni objekt;

  • Privremeni bezimeni objekt se pomoću operatora pridruživanja (=) pridružuje varijabli no1;

  • Dva privremena objekta se uništavaju pozivima destruktoru.

Optimizacija prevodioca (copy elision)

Što se dešava u ovoj liniji koda?

MagicNo no4 = 3

Prvo se poziva Ctor s jedim argumentom (3) i kreira se privremeni objekt. Zatim se poziva CCtor koji kreira no4. U ovakvoj situaciji prevodioc može izbjeći konstrukciju privremenog objekta i konstruirati no4 direktno pomoću poziva Ctor-u s jednim argumentom. Prevodioci to redovito rade. Efekt je kao da smo napisali:

 MagicNo no4(3);

Primjer. U čemu je razlika ako pozovemo funkciju

MagicNo f(MagicNo A);

na ovaj način:

no1 = f(no2);

ili na ovaj način:

MagicNo no5 = f(no2);
  • U drugom slučaju će elizija kopiranja (copy elision) dozvoliti da je izbjegne konstrukcija privremenog objekta u return naredbi i da se povratna vrijednost direktno konstruira u no5.

Napomena g++ ima opciju -fno-elide-constructors pomoću koje se optimizacija konstruktora može spriječiti.
Napomena U osnovi kad se vraća lokalni objekt u return naredbi kao u prethodnom primjeru prevodilac može izbjeći korištenje konstruktora kopije i direktno konstruirati objekt u memoriji rezerviranoj za dolazni objekt (no ta optimizacija ovisi o prevodiocu).
Napomena U standardu C++-17 elizija kopiranja je postala obavezna u ovakvim situacijama i više nije optimizacija prevodioca.

Prijenos parametra po referenci

Želimo li izbjeći nepotrebne pozive CCtoru i destruktoru treba parametar funkciji predati "po referenci". Budući da se stvarni argument pri tome ne kopira nema niti poziva CCtoru i Dtoru. Ako definiramo

MagicNo g(const MagicNo& A)
{ std::cout << "Unutar funkcije g " << std::endl; return A;}

Sada kod

std::cout << "Pozivam f" << std::endl;
no2 = f(no1);
std::cout << "f gotov" << std::endl;
std::cout << "Pozivam g" << std::endl;
no2 = g(no1);
std::cout << "g gotov" << std::endl;

daje

Pozivam f
CCtor
Unutar funkcije f
CCtor
operator =
Dtor
Dtor
f gotov
Pozivam g
Unutar funkcije g
CCtor
operator =
Dtor
g gotov

Kopiranje povratne vrijednosti

Kad se radi o povratnoj vrijednosti ovakva optimizacija nije uvijek moguća. Treba zapamtiti sljedeće pravilo:

Napomena Funkcija nikad ne smije vratiti pokazivač ili referencu na lokalnu varijablu.

Inicijalizacija spremnika

  • Ako pri inicijalizaciji spremnika zadamo samo broj elemenata, onda se za inicijalizaciju svakog pojedinog člana spremnika koristi defaultni konstruktor.

  • Ako sve elemente inicijaliziramo jednim zadanim elementom, onda se poziva CCtor.

std::cout << "vector s tri elementa:" << std::endl;
std::vector<MagicNo> niz1(3,no1);
std::cout << "vector s tri elementa + konverzija:" << std::endl;
std::vector<MagicNo> niz2(3,4);
std::cout << "polje s tri elementa:" << std::endl;
MagicNo niz3[] ={ no1, no2, no3};

Onemogućavanje kopiranja

Postoje situacije kada ne želimo dozvoliti kopiranje. Objekt tada ne možemo po vrijednosti predati funkciji niti ga iz nje vratiti. Takvi objekti su na primjer streamovi iz iostream biblioteke.

Onemogućavanje kopiranja je vrlo jednostavno. Imamo dva načina:

  • Novi način (C++11): CCtor treba proglasiti obrisanim pomoću ključne riječi delete:

class B{
  public:
    B() : x(0) {}
    B(B const &) = delete;  // CCtor
  private:
    int x;
};

Prevodilac će sada javiti grešku (use of deleted function) ukoliko eksplicitno ili implicitno pukušamo pozvati CCtor.

  • CCtor treba staviti u privatni dio klase i ne definirati ga.

class A{
  public:
    A() : x(0) {}
  private:
    int x;
    A(A const &);  // CCtor
};

Prevodilac ili linker će sada javiti grešku ako dođe do poziva CCtora.

  • Specifikacija =delete se može koristiti na bilo kojoj funkciji, a ne samo na konstruktorima. Ne treba ju, naravno, nikad koristiti na destruktoru.

Kopiranje klase s pokazivačkom varijablom članicom

Kopiranje postaje problematično u slučaju kada klasa sadrži pokazivač na dnamički alociranu memoriju. Pogledajmo klasu,

class Dangle{
    public:
        Dangle(){ p= new int(0); }
        ~Dangle() { delete p; } // Destruktor. Dealokacija memorije.
    private:
        int * p;
};

Resurse koje instance klase trebaju osiguravamo i inicijaliziramo u konstruktoru, a oslobađamo u destruktoru. To je opći princip konstrukcije klasa i naziva se RAII (Resource Acquisition Is Initialization).

Zamislimo sada situaciju u kojoj kreiramo novi objekt tipa Dangle iz već postojećeg, pomoću sintetiziranog CCtora:

void f()
{
    Dangle a;
    Dangle b = a; // Pozovi sintetizirani CCtor
}

Nakon što oba objekta izađu iz svog dosega pozivaju se njihovi destruktori, koji pozivaju operator delete na pokazivaču objekta. Tu dolazi do dvostrukog pozivanja operatora delete na istom pokazivaču što predstavlja sigurnu grešku!

Kako ispravno konstruirati takve klase pokazat ćemo malo kasnije.

Operator pridruživanja - kopiranjem

Operator pridruživanja mora biti funkcija članica klase. On se brine za pridruživanje objekata dane klase. Definira se tako da uzima jedan parametar koji je objekt na desnoj strani naredbe pridruživanja (desni operand); taj je parametar prirodno konstantna referenca na tip klase. Povratna vrijednost je referenca na tip klase i ona odgovara lijevom operandu — onom kojem se pridružuje. Vrijedi pravilo:

Napomena Ako u klasi ne definiramo operator pridruživanja, a u kodu se javlja potreba za njim, prevodilac će ga sintetizirati.

Sintetizirani OP vrši pridruživanje član po član na način kompatibilan sa sintetiziranim CCtorom. Svi ugrađeni tipovi bit će kopirani; polja će biti kopirana član-po-član kao i u CCtoru. Članice korisničkog tipa pridružuju se pozivanjem operatora pridruživanja iz njihove klase. Taj može biti eksplicitno definiran ili sintetiziran.

Primjer: Ovdje klasi MagicNo dodajemo operator pridruživanja koji djeluje kao sintetizirani OP.

Nastavak

class MagicNo{
    public:
       MagicNo(const int& value):data_name("NoName"),
                                 data(value) // Ctor1
         {std::cout << "Ctor1"<<std::endl;}

       MagicNo(const std::string& name, const int& value): // Ctor2
           data_name(name), data(value)
         { std::cout << "Ctor2"<<std::endl;}

       MagicNo(const MagicNo& obj);  // CCtor

       MagicNo& operator=(const MagicNo& obj);   // OP
    private:
       std::string data_name;
       int           data;
};

// u .cpp datoteci
MagicNo& MagicNo::operator=(const MagicNo& obj)
{
     data_name = obj.data_name;
     data = obj.data;
     std::cout << "operator = "<< std::endl;
     return *this;
}

Sintaksa

Sintaksa poziva operatora pridruživanja je prirodna. U liniji

no1 = no2;

vrijednost objekta no2 pridružuje se objektu no1. Ta je sintaksa samo pokrata za ekvivalentnu sintaksu:

no1.operator=(no2);

OP vraća nekonstantnu referencu

MagicNo no1("Number 1", 117);
MagicNo no2("Number 2", 63);
MagicNo no3("Number 3", 249);
//     ....
(no2 = no1) = no3;

Zadatak: Što bi se desilo u gornjem kodu da smo OP definirali tako da vraća objekt umjesto reference?

MagicNo operator=(const MagicNo& obj);   // OP, GREŠKA

Prevodilac može odbiti sintetizirati OP

Pravilo je da će prevodilac sintetizirati OP ako ga nismo definirali. To pravilo ima iznimaka, tj. postoje situacije u kojima će prevodilac odbiti sintetizirati OP za nas. To će se desiti u situacijama kada ponašanje sintetiziranog OP nije jednoznačno.

Tri situacije u kojima će prevodilac odbiti sintetizirati OP su sljedeće:

  • klasa ima članicu koja je referenca na objekt ili

  • klasa ima konstantnu varijablu članicu ili

  • klasa ima članicu koja ima nedostupni (privatni) ili obrisani OP.

C++11: U gornjim situacijama prevodilac će sintetizirati obrisani OP. Ukupni efekt je isti, jedino što će prilikom pokušaja korištenja OP prevodilac javiti da je OP obrisan (deleted).

Slično pravilo vrijedi i za defaultni konstruktor: Sintetizirani defaultni konstruktor će biti obrisan u sljedećim situacijama:

  • Klasa ima varijablu članicu s obrisanim ili nedostupnim dodijeljenim konstruktorom;

  • Klasa ima članicu koja je referenca ali nije inicijalizirana u klasi.

  • Klasa ima konstantnu članicu koja nema dodijeljeni konstruktor i nije inicijalizirana unutar klase.

Konstruktor kopije će biti sintetiziran kao obrisan samo ako klasa ima članicu koja ima nedostupan/obrisan CCtor pa se ne može kopirati.

Destruktor će biti sintetiziran kao obrisan samo ako klasa ima članicu koja ima nedostupan/obrisan destruktor.

OP i CCtor dolaze u paru

Ako je zadovoljavajući sintetizirani CCtor, onda je zadovoljavajući i sintetizirani OP, i obratno. Kada moramo definirati svoj OP onda redovito moramo definirati i CCtor, i obratno.

Pogledajmo na kraju kako bi trebalo korigirati klasu Dangle da postane ispravna:

class NoDangle{
    public:
       NoDangle() : p(new int(0)) {}
       NoDangle(const NoDangle& rhs) : p(new int(*rhs.p)) {}
       NoDangle& operator=(NoDangle &rhs){
           if(rhs.p != p) {
               delete p;
               p = new int(*rhs.p);
           }
           return *this;
       }
       ~NoDangle() { delete p; }
    private:
        int * p;
};
  • Operator pridruživanja kopira vrijednost na koju pokazivač pokazuje, a ne sam pokazivač (duboko kopiranje).

  • Operator pridruživanja mora brinuti o trivijanom pridruživanju ( a=a ).

Destruktor

Destruktor je posebna funkcija članica klase koja se poziva automatski kod destrukcije objekta. Zadatak destruktora je osloboditi resurse koje je objekt rezervirao (file handles, socets, itd.), osloboditi dinamički alociranu memoriju itd.

  • Destruktor ima isto ime kao i klasa kojem prethodi tilda (~). Nema povratne vrijednosti i ne uzima parametre.

Kada se poziva destruktor?

  • Kada je doseg objekta lokalan destruktor se poziva u trenutku kada objekt izlazi iz svog dosega.

  • Ako je objekt kreiran pomoću operatora new, destruktor će biti pozvan kad se pozove operator delete.

Važno je uočiti da se destruktor ne poziva kada referenca ili pokazivač izlazi iz dosega. To se dešava samo kada objekt (a ne referenca) izlazi iz dosega i kada se pozove delete na pokazivaču.

Spremnici kao što su polja i STL spremnici uništavaju se kada izlaze iz dosega. Pri tome, ako su im članovi tipa klase, onda se poziva destruktor na svakom članu spremnika, u redosljedu suprotnom od onog kojim su elementi konstruirani. Isti postupak se dešava s dinamički alociranim poljima na kojima je pozvan delete [].

Sintetizirani destruktor

Napomena Ako ne definiramo vlastiti destruktor prevodilac će uvijek sintetizirati destruktor za nas.

Sintetizirani destruktor uništava nestatičke objekte u poretku suprotnom od onog u kojem su konstruirani, a to znači u poretku suprotnom od onog kojim su u klasi deklarirani. Na svakom članu tipa klase poziva njegov destruktor.

Pisanje vlastitog destruktora

  • Klase koje ne alociraju resurse (memoriju i druge resurse) ne moraju definirati svoj destruktor. Sintetizirani će biti dovoljan da obavi posao.

  • Pravilo trojice: ako imamo potrebu napisati eksplicitni destruktor, onda moramo napisati i CCtor i OP.

Primjer (ovdje nemamo potrebu za destruktorom, samo ilustriramo sintaksu):

Nastavak

class MagicNo{
    public:
       MagicNo(const int& value):data_name("NoName"), data(value) // Ctor1
         {std::cout << "Ctor1"<<std::endl;}

       MagicNo(const std::string& name, const int& value):         // Ctor2
           data_name(name), data(value) { std::cout << "Ctor2"<<std::endl;}

       MagicNo(const MagicNo& obj);  // CCtor

       MagicNo& operator=(const MagicNo& obj);

       ~MagicNo(){ std::cout << "Dtor"<<std::endl;}

    private:
       std::string data_name;
       int        data;
};

Prazna klasa nije prazna

Ako definiramo praznu klasu

class Empty {};

dobivamo klasu oblika:

class Empty{
    Empty(){...}
    Empty(const Empty& rhs){...}
    ~Empty(){...}
    Empty& operator=(const Empty& rhs){...}
};

Primjer: Vektor varijabilne duljine: predložak klase Vec

Predložak Vec<T> sadržava dinamički alociran vektor proizvoljne duljine. Polje alociramo dinamički u konstruktoru, a dealociramo ga u destruktoru. Imamo još jednu dodatnu varijablu mime koja može predstavljati ime fizičke veličine koju vektor drži.

template <typename T>
class Vect
{
    public:
        using  Index =  unsigned int;

        explicit Vect(Index n = 0, T v=0.0, std::string ime="");
        ~Vect(){ delete [] mdata; std::cout << "Dtor" << std::endl; }

        Vect(const Vect& v);                            // CCtor
        Vect& operator=(const Vect& v);                 // OP

        T operator[](Index i) const { return mdata[i]; }
        T& operator[](Index i) { return mdata[i]; }

        Index size() const { return msize; }
        T two_norm() const;
        std::string get_name() const { return mime; }

    private:
        Index   msize;
        T *mdata;
        std::string  mime;
};

Dealokacija

Dealokacija memorije se dešava kod poziva destruktora, dakle kada objekt izlazi iz dosega ili kada se pozove delete na dinamički alociranom objektu.

int main()
{
    Vec<double> x(5,2.0);
    Vec<double> * px = new Vect(1024,1.9);

    delete px; // Poziv destruktor iz klase Vect.
    // x izlazi iz dosega -> poziv destruktora iz klase Vect.
    return EXIT_SUCCESS;
}

Destruktor je dan u samoj klasi (i stoga je implicitno inline):

~Vect(){ delete [] mdata; std::cout << "Dtor" << std::endl; }

Konstruktor kopije

Dizajn konstruktora kopije posve je izravan. Alociramo novu memoriju za vektor i kopiramo članove iz danog vektora.

template <typename T>
Vect<T>::Vect(const Vect& v): msize(v.size()), mdata(new T[v.size()]), mime(v.get_name())
{
    std::cerr << "C-Ctor"<<std::endl;
    for(Index i=0; i < msize; ++i)
        mdata[i]=v.mdata[i];
}

Operator pridruživanja

Kod operatora pridruživanja moramo paziti na dvije stvari:

  • Da dealociramo memoriju vektora na lijevoj strani.

  • Da dobro tretiramo samopridruživanje.

template <typename T>
Vect<T>& Vect<T>::operator=(const Vect& v)
{
    std::cerr << "OP"<<std::endl;
    if(mdata != v.mdata) // Vektori su jednaki ako pokazuju na istu memorijsku lokaciju
    {
        delete [] mdata;
        mdata = new T[v.msize]; // bad_alloc ako ne uspije
        msize = v.msize;
        mime = v.mime;
        for(Index i=0; i < msize; ++i)
            mdata[i]=v.mdata[i];
    }
    return *this;
}

Zadatak. Implementirajte sve ostale metode iz klase Vec<T>. Optimizirajte operator pridruživanja tako da ne dealocira memoriju ako to nije nužno.

Valgrind

  • Zaboravimo li smjestiti dealokaciju u destruktor imat ćemo problem gubitka memorije (eng. memory leak). Dinamički alocirana memorija ne oslobađa se u trenutku kada gubimo pristup do nje već ostaje alocirana do kraja izvršavanja programa.

Gubitak memorije predstavlja ozbiljan problem u C++ programima. Stoga su razvijeni različiti alati koji ga mogu detektirati. U Linux okruženju može se koristiti valgrind program http://www.valgrind.org/. Izvršavanje programa (recimo a.out) vrši se "kroz" valgrind koji prati izvršavanje programa i izvještava o greškama u radu s memorijom:

valgrind --tool=memcheck --leak-check=yes ./a.out

Pretpostavimo da smo iz klase Vect uklonili destruktor. Dio poruke koju valgrind daje mogla bi izgledati ovako:

==4128== 8,272 bytes in 3 blocks are definitely lost in loss record 1 of 1
==4128==    at 0x4024B9C: operator new[](unsigned) (vg_replace_malloc.c:195)
==4128==    by 0x804921C: Vect::Vect(int, double, std::string) (in /home/jurak/mytext/{cxx}/klase-pr/a.out)
==4128==    by 0x8048B5A: main (in /home/jurak/mytext/{cxx}/klase-pr/a.out)
==4128==
==4128== LEAK SUMMARY:
==4128==    definitely lost: 8,272 bytes in 3 blocks.
==4128==      possibly lost: 0 bytes in 0 blocks.
==4128==    still reachable: 0 bytes in 0 blocks.
==4128==         suppressed: 0 bytes in 0 blocks.

Vidimo da valgrind detektira klasu koja je alocirala memoriju koja nije dealocorana.

Konstrukcija i pridruživanje premještanjem - problem

  • Problem. Kopiranje podataka koje vrše CCtor i OP je nepotrebno (i stoga neefikasno) u slučaju kada se objekt koji se kopira neposredno nakon toga uništava. Tada je efikasnije (i prirodnije) preuzeti resurse tog objekta umjesto da ih se kopira jer će na objektu ionako u sljedećem koraku biti pozvan destruktor. Posao preuzimanja resursa obavljaju konstruktor kopije premještanjem i operator pridruživanja premještanjem.

Primjer 1. Napravimo prvo mali test program koji mjeri (posredno) vrijeme potrebno za kreiranje i kopiranje lokalnog objekta.

#include <chrono>  // za mjerenje vremena
#include "vector.h"

// Funkcija koja vraća lokalnu kopiju
template <typename T>
Vec<T> make_square(Vec<T> const & in) {
    Vec<T> y(in.size());
    for(typename Vec<T>::index i = 0; i < in.size(); ++i)
       { y[i] = in[i]*in[i]; }
    return y;
}

using namespace std::chrono;

int main()
{
  Vec<double> x(500000,2.0), y;

  auto start = system_clock::now(); // Pokreni sat.
  y = make_square(x);
  auto stop = system_clock::now();  // Ponovo startanje sata

  auto duration = duration_cast<microseconds>( stop - start); // vremenska razlika
  std::cout << "Time for the call = " << duration.count()
            << " micro sec" << std::endl; // u mikrosekundama

  return 0;
}

Rezultat izvršavanja je:

Ctor
Ctor
Ctor
OP
Dtor
Time for the call = 6898 micro sec
Dtor
Dtor

Ovdje je došlo do elizije CCtora u return naredbi.

Napomena. Neefikasnost ovog koda je u pozivu operatoru pridruživanja koji kopira vektor y, lokalno definiran unutar metode make_square, u vektor y definiran unutar metode main. Vektor y iz metode make_square je iskopiran član po član u vektor y unutar metode main, a zatim je uništen prolazom destruktora. Prirodnije je da vektor y unutar metode main jednostavno preuzme dinamički alociranu memoriju vektora y iz metode make_square jer se na taj način izbjegava nepotrebna alokacija i dealokacija memorije.

Nastavak

  • Nepotrebno kopiranje se dešava i u STL spremnicima kao što je std::vector koji dinamički povećavaju svoju veličinu kada je to potrebno. Ako u std::vector pokušamo ubaciti element kada u njemu više nema slobodnih mjesta on će alocirati novu memoriju dovoljne veličine i prekopirati već postojeće element u nju. To kopiranje je neefikasno i može se zamijeniti premještanjem.

Primjer 2: Proučite broj poziva konstruktoru kopije i destruktoru u sljedećem kodu:

Vect<double> a1(3,4.0), a2(3,2.0), a3(4,1.0), a4(4,-1.0);
std::vector<Vect<double>> collection;

std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a1);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a2);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a3);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a4);
std::cout << "capacity = " << collection.capacity() << std::endl;

Rezultat:

Ctor
Ctor
Ctor
Ctor
capacity = 0
Ubaci element:
C-Ctor
capacity = 1
Ubaci element:
C-Ctor
C-Ctor
Dtor
capacity = 2
Ubaci element:
C-Ctor
C-Ctor
C-Ctor
Dtor
Dtor
capacity = 4
Ubaci element:
C-Ctor
capacity = 4
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor

Objašnjenje. Vektor počinje svoj život kao prazan. Kad ubacimo prvi element u njega on alocira memoriju za jedan element i kopira element u svoju memoriju (poziv C-Ctoru). Kada ubacujemo drugi element za njega nema mjesta u vektoru. Tada std::vector alocira memoriju za dva objekta, kopira prvi element na novu memorijsku lokaciju i zatim kopira novoubačeni element iza prvog elementa. Prvi element na staroj lokaciji se uništava pozivom destruktora. Kod ubacivanja trećeg elementa ponovo nema dovoljno mjesta u vektoru i postupak se ponavlja. Sada se alocira memorija za 4 elemenata na novoj lokaciji, prva dva elementa se kopiraju sa stare lokacije i ubacuje se treći element. Prva dva elementa na staroj lokaciji se uništavaju prolazom destruktora. Sada za četvrti element imamo slobodno mjesto pa pri njegovom ubacivanju nema nove realokacije. Jasno je da su u slučaju objekata (Vect) koji dinamički alociraju memoriju sva ta silna kopiranja nepotrebna i neefikasna.

Konstruktor kopije premještanjem i operator pridruživanje premještanjem

Neefikasnost u prehodna dva primjera (i drugim) možemo izbjeći dodavanjem dva nova operatora svakom tipu kod kojeg premještanje može biti efikasnije od kopiranja.

  • Konstruktor kopije premještanjem (eng. move constructor).

  • Operator pridruživanje premještanjem (eng. move assignment operator).

Obje ove funkcije uzimaju argument tipa desne reference što znači da djeluju na privremenim bezimenim objektima koji su na kraju svog životnog vijeka i stoga se njihovi resursi mogu preuzeti.

Puna kontrola kopiranja u klasi Vect ima ovaj oblik:

template <typename T>
class Vec
{
    public:
        using  Index = std::size_t;

        explicit Vect(Index n = 0, T v=0.0, std::string ime="");

        ~Vec(){  std::cerr << "Dtor"<<std::endl;  delete [] mdata;}

        Vec(const Vec& v);                            // CCtor
        Vec(Vec && v) noexcept;                       // MCtor

        Vec& operator=(const Vec& v);                 // OP
        Vec& operator=(Vec && v) noexcept;            // MOP

       // ...
};
  • Konstruktor kopije premještanjem uzima nekonstantnu referencu na desnu vrijednost. To znači da će prevodilac:

    • Pozvati konstruktor kopije kopiranjem ako kopira lijevu vrijednost;

    • Pozvati konstruktor kopije premještanjem ako kopira desnu vrijednost.

  • Operator pridruživanja kopiranjem ili premještanjem selektira se na isti način, ovisno o tome je li na desnoj strani lijeva ili desna vrijednost.

  • Da bi se mogli efikasno koristiti sa spremnicima konstruktor kopije i operator pridruživanja premještanjem moraju biti označeni noexcept — što znači da ne izbacuju izuzetke.

Konstruktor kopije premještanjem: Implementacija

  • Novokreirani vektor preuzima resurse vektora kojeg kopira preuzimanjem.

  • Objekte (kao npr. std::string) preuzimamo pomoću std::move funkcije.

  • Objekt s desne strane mora ostati u stanju pogodnom za prolaz destruktora (pokazivač obavezno na nullptr).

template <typename T>
Vect<T>::Vect(Vect && v) noexcept : msize(v.msize), mdata(v.mdata), mime(std::move(v.mime))
{
    std::cerr << "M-Ctor"<<std::endl;
    v.msize = 0;
    v.mdata = nullptr;
}

Operator pridruživanje premještanjem: Implementacija

  • Resursi se preuzimaju na isti način kao i u konstruktoru.

  • Moramo paziti na samopridruživanje zbog koda oblika: x = std::move(x);

  • Objekt s desne strane mora ostati u stanju pogodnom za prolaz destruktora (pokazivač obavezno na nullptr).

template <typename T>
Vect<T>& Vect<T>::operator=(Vect && v) noexcept
{
    std::cerr << "M-OP"<<std::endl;
    if(this != &v) // samopridruživanje je moguće ako se koristi std::move
    {
        delete [] mdata;
        mdata = v.mdata;
        msize = v.msize;
        mime = std::move(v.mime);
        v.mdata = nullptr;
        v.msize = 0;
    }
    return *this;
}

Primjer 1. Isti kao i ranije:

#include <chrono>  // za mjerenje vremena

template <typename T>
Vec<T> make_square(Vec<T> const & in)
{
    Vec<T> y(in.size());
    for(typename Vec<T>::index i = 0; i < in.size(); ++i)
       { y[i] = in[i]*in[i]; }
    return y;
}

using namespace std::chrono;

int main()
{
  Vec<double> x(500000,2.0), y;

  auto start = system_clock::now(); // Pokreni sat.
  y = make_square(x);
  auto duration = duration_cast<microseconds>(
                                      system_clock::now() - start
                                              ); // vremenska razlika
  std::cout << "Time for the call = " << duration.count()<< " micro sec" << std::endl; // u milisekundama

  return EXIT_SUCCESS;
}

Rezultat izvršavanja je:

Ctor
Ctor
Ctor
M-OP
Dtor
Time for the call = 5111 micro sec
Dtor
Dtor

Budući da tip koji vraćamo iz funkcije make_square ima operator pridruživanja premještanjem on je iskorišten umjesto operatora pridruživanja kopiranjem. Time smo uštedjeli dealokaciju i alokaciju 500000 elemenata tipa double.

Primjer 2. Spremanje u std::vector: U programu

Vect<double> a1(3,4.0), a2(3,2.0), a3(4,1.0), a4(4,-1.0);
std::vector<Vect<double>> collection;

std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a1);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a2);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a3);
std::cout << "capacity = " << collection.capacity() << std::endl;
std::cout << "Ubaci element:\n";
collection.push_back(a4);
std::cout << "capacity = " << collection.capacity() << std::endl;

sada dobivamo sljedeći rezultat iz kojeg je vidljivo da vektor vrši realokaciju pomoću konstruktora kopije premještanjem:

Ctor
Ctor
Ctor
Ctor
capacity = 0
Ubaci element:
C-Ctor
capacity = 1
Ubaci element:
C-Ctor
M-Ctor
Dtor
capacity = 2
Ubaci element:
C-Ctor
M-Ctor
M-Ctor
Dtor
Dtor
capacity = 4
Ubaci element:
C-Ctor
capacity = 4
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor
Dtor

Iz ispisa vidimo da pri premještanju starih elemenata na nove lokacije std::vector koristi operator pridruživanja premještanjem i na taj način štedi na alokaciji i dealokaciji memorije.

Napomena: std::vector garantira da polazni vektor neće biti promijenjen ako realokacija elemenata ne uspije. To je lako osigurati s kopiranjem jer kopiranje ne mijenja polaznu vrijednost. Da bi se moglo osigurati s premještanjem, konstruktor kopije premještanjem i operator pridruživanja premještanjem moraju garantirati da neće izbaciti izuzetak. Ukoliko nema takve garancije std::vector će pri realokaciji koristiti konstruktor kopije kopiranjem i operator pridruživanja kopiranjem. Zbog toga operatori premještanjem moraju biti deklarirani noexcept ukoliko želimo da budu efikasno iskorišteni.

Zaključci, sintetizirano premještanje:

  • Mehanizam koji omogućava kontrolu kopiranja kopiranjem-premještanjem je preopterećenje funkcija u odnosu na lijevu i desnu referencu. Pri tome uvijek kombiniramo funkciju koja uzima konstantnu lijevu referencu i nekonstantnu desnu referencu.

  • Ako konstruktor kopije ili operator pridruživanja ne postoje u verziji s premještanjem koristi se verzija s kopiranjem (jer se konstantna lijeva referenca može inicijalizirati desnom vrijednošću).

  • I sve druge funkcije se mogu preopteretiti po smislu reference (lijeva/desna), posebno i druge članice klase pored CCtor-a i OP.

  • std::move treba koristiti vrlo racionalno je o stanju objekta nakon što je pomaknut znamo samo da je dobar za destrukciju.

  • Prevodilac sintetizira konstruktor i OP premještanjem samo ako klasa ne definira niti jedan član kontrole kopiranja i ako sve varijable članice mogu biti konstruirane premještanjem odnosno pridružene premještanjem.

  • Ako eksplicitno zatražimo sintetiziranje operacija premještanjem pomoću = default, a prevodilac nije u mogućnosti premjestiti članove klase, operacija premještanjem će biti definirana deleted.

Još o povratnoj vrijednosti funkcije

  • Ima li smisla definirati funkciju tako da vraća desnu referencu?

Vect &&  f(Vect & x){
    Vect y = x.scale(2);
    return std::move(y);
}

Vect z = f(x);       // Greška
Vect && z1 = f(x);   // Greška
  • Ako funkcija vraća referencu na lokalnu varijablu to je greška (kao u slučaju da vraća lijevu referencu). Lokalni objekt je uništen prije povratka u pozivni program.

Jedina mogućnost takvog koda je ovog tipa:

Vect &&  g(Vect && x){
        x.scale(2);
        return std::move(x);
}
x = g(std::move(x));  // OK
  • Treba li koristiti std::move() pri povratu lokalnog objekta?

Vect f(Vect & x){
     Vect y = x.scale(2);
     return std::move(y);
}

Vect z = f(x);
  • Odgovor je NE. Prevodilac ima pravo primijeniti operator kopiranja premještanjem na lokalnu varijablu i std::move() je suvišan. Štoviše, std::move() će spriječiti optimizaciju povratne vrijednosti koja može ponekad eliminrati i kopiranje i premještanje.

Klase s pokazivačima

Problem

Programski jezik C++ nema sakupljača smeća (eng. garbage collector) kao neki drugi jezici (npr. Java) i stoga programer mora brinuti o dealokaciji dinamički alocirane memorije. Sljedeći primjer pokazuje jednu factory metodu. To je metoda koja dinamički alocira objekt i vraća pokazivač na njega.

class BFC{
    public:
        explicit BFC(int a = 0) : aa(a) {}
        int get() const { return aa; }
        void set(int a) { aa=a; }
        // .......
        ~BFC() { cout << "BFC(" << get() << ") is dead."<< endl; }
    private:
        int aa;
        // ....
};
BFC * factoryBFC(int i)
{
    // ...
    // Dinamički alociramo objekt
    BFC * pbfc = new BFC(i);
    // ...
    // Vraćamo pokazivač na dinamički alociran objekt
    return pbfc;
}

Problem - moramo brinuti o dealokaciji

Odgovornost je pozivnog programa da dealocira memoriju kada je gotov s objektom koji je dobio od factory metode.

void f()
{
    BFC *a = factoryBFC(1);
    BFC *b = factoryBFC(2);
    // ....
    cout << "a = " << a->get() << endl;
    cout << "b = " << b->get() << endl;

    // Dealociram memoriju alociranu u factoryBFC
    delete a;
    // Zaboravio uništiti b ! -- memory leak
}
  • Dinamičko alociranje objekata je često te je potrebno naći sustavno rješenje za dealokaciju.

  • Rješenje problema: Pokazivač treba smjestiti unutar klase čiji destruktor poziva delete na pokazivaču. Kada takav objekt izađe iz dosega automatski se poziva destruktor koji dealocira objekt na koji pokazivač referira. Na taj način ne moramo brinuti o dealokaciji memorije.

Napomena. Ovo rješenje je primjer programskog principa koji se naziva Resource Acquisition Is Initialization (RAII). Prema tom principu objekt prikuplja potrebne resurse u konstruktoru, a oslobađa ih u destruktoru, što automatizira oslobađanje resursa.

unique_ptr<T>

Standardna biblioteka nam nudi klasu unique_ptr<T> koju možemo koristiti za sprečavanje gubljenja memorije. Dekarirana je u zaglavlju <memory>.

Klasu koristimo na sljedeći način: Ako imamo pokazivač na tip T za koji želimo da se automatski dealocira pri izlasku iz dosega predajemo ga konstruktoru klase unique_ptr<T>.

Objekt klase unique_ptr<T> koristimo na isti način kao i pokazivač. To je moguće stoga što je klasa unique_ptr<T> tzv. pokazivački objekt, odnosno u njoj su preopterećeni operatori * i ->.

void f()
{
    unique_ptr<BFC> a( factoryBFC(1) );
    unique_ptr<BFC> b( factoryBFC(2) );
    // ....
    cout << "a = " << a->get() << endl;
    cout << "b = " << b->get() << endl;

    // Dealokacija memorije je sada automatska
}

Na izlasku iz funkcije f() više ne moramo brinuti o dealokaciji memorije. Nju će obaviti destruktor klase unique_ptr<BFC> koji poziva delete na pokazivaču.

Pametni pokazivač

Objekt koji možemo koristiti kao pokazivač (dereferencirati ga i primijeniti operator → na njega) naziva se pametni pokazivač. Pametan je jer obično ima dodatne sposobnosti koje pokazivači nemaju. Kažemo još da su pametni pokazivači objekti sa semantikom pokazivača.

unique_ptr<T> i kopiranje

Klasa unique_ptr<T> implementira koncept ekskluzivnog vlasništva nad objektom.

  • Može postojati samo samo jedan unique_ptr<T> koji drži zadani pokazivač na T.

To se postiže time što unique_ptr<T> nema konstruktora kopije kopiranjem niti operatora pridruživanja kopiranjem.

Konstrukcija

  • Dodijeljeni konstruktor kreira neinicijalizirani pametni pokazivač (onaj koji drži nullptr).

  • Operator konverzije u bool dozvoljava ispitivanje je li objekt prazan ili drži netrivijalni pokazivač.

std::unique_ptr<int> up1, up11(nullptr);  // objekti bez pokazivača
std::unique_ptr<int> up2(new int(4));

std::cout << "*up2 = " << *up2 << "\n";

if(up1)
    std::cout << "*up1 = " << *up1 << "\n";
else
    std::cout << "up1 je prazan.\n";
  • Ako se ivrši premještanje pametnog pokazivača, pokazivač na desnoj strani prepušta resurs pokazivaču na lijevoj strani. Pokazivač na lijevoj strani prijue toga oslobađa svoj resurse (poziva delete na svom pokazivaču).

up1 = std::move(up2);
assert(up2 == nullptr);

Ekvivalentan kod je

up1.reset(up2.release());
assert(up2 == nullptr);

Imamo sljedeće metode:

Poziv Značenje

a.get()

Vraća pokazivač koji a drži.

a.release()

Vraća pokazivač koji drži a i interno postavlja svoj pokazivač na nul-pokazivač. Objekt a se time odrekao vlasništva koje mora prihvatiti drugi objekt (varijabla ili pametni pokazivač).

a.reset()

Briše objekt na koji a pokazuje.

a.reset(p)

Briše objekt na koji a pokazuje i inicijalizira svoj pokazivač sa p.

unique_ptr<T> i funkcije

  • Funkcija koja uzima std::unique_ptr<> po vrijednosti preuzima vlasništvo nad objektom. Pri pozivu moramo koristiti std::move().

  • Funkcija koja samo manipulira sa objektom može to raditi kroz goli pokazivač. Treba samo slijediti konvenciju da goli pokazivač indicira da funkcija nije vlasnik objekta.

  • Funkcija koja kreira i vraća std::unique_ptr<> odriče se vlasništva nad kreiranim objektom.

// Funkcija konzumira i uništava UP.
void f(std::unique_ptr<int> pi){
        std::cout << "pi drži adresu " <<  pi.get() << "\n";
}

// Funkcija prima goli pokazivač, što znači da NIJE vlasnik objekta.
void f(int *pi){
        std::cout << "pi drži adresu " <<  pi << "\n";
}

// Funkcija se odriče vlasništva nad UPom.
std::unique_ptr<int> g(int n){
          std::unique_ptr<int>  pn(new int(n));
          return pn;
}

Te funkcije koristimo na ovaj način:

f(std::move(up1));  // umjesto CCtora koristimo M-Ctor
assert(up1 == nullptr);

f(up2.get());  // predaj goli pokazivač
assert(*up2 == 5);

up2 = g(5);
assert(*up2 == 5);

unique_ptr<T> kao članica klase

Običan pokazivač na dinamički alociranu memoriju unutar klase ima ovaj nedostatak:

  • Ako je izuzetak izbačen u konstruktoru prije no što je cijeli objekt izgrađen, destruktor se ne poziva. Pozivaju se destruktori samo onih članica klase koje su izgrađene.

unique_ptr<T> kao članica klase umjesto golog pokazivača garantira da će memorija biti dealocirana i ako je izbačen izuzetak u konstruktoru. Problem je tada sljedeći:

  • Ako u klasi držimo unique_ptr<T> moramo implementirati kopiranje i pridruživanje.

class X{
        std::unique_ptr<int>  pi;
        std::unique_ptr<char> pc;
public:
        X(int i, char c) : pi(new int(i)), pc(new char(c)) {}
        X(X const & x) : pi(new int(*x.pi)), pc(new char(*x.pc)) {}
        X & operator=(X const & x){
                *pi = *x.pi; // duboko kopiranje
                *pc = *x.pc;
                return *this;
        }
        // destruktor nije potreban
};

unique_ptr<T> i polja

  • Destruktor u unique_ptr<T> poziva delete operator, a ne delete [] operator. Stoga se ne koristi sa dinamički alociranim poljima.

  • Kada želimo imati unique_ptr koji pokazuje na dinamički alocirano polje treba koristiti klasu unique_ptr<T[]>. Tadada možemo koristiti operator uglatih zagrada ([]), ali ne postoje operatori * i ->. Na primjer:

std::unique_ptr<double[]> polje(new double[5]);
polje[3] = 17.0;
std::cout << "Adresa  polja = " << polje.get()
          << "; polje[3] = " << polje[3] << std::endl;

unique_ptr<T>. Završne napomene

Klasa unique_ptr<T> ima dodatni argument predloška koji ima dodijeljenu vrijednost.

namespace std {
// primary template:
template <typename T, typename D = default_delete<T>>
class unique_ptr
{
public:
...
T& operator*() const;
T* operator->() const noexcept;
...
};
}

Klasa default_delete<T> poziva delete na pokazivaču. Zadavanjem parametra D to je ponašanje moguće izmijeniti.

  • unique_ptr<T> nam garantira da nikad neće postojati drugi pametni pokazivač koji drži isti pokazivač. Taj je pokazivač jedinstven i garantirana je dealokacija memorije kad izađe iz dosega. unique_ptr<T> implementira vlasničku semantiku.

  • Klasa unique_ptr<T> uvedena je u standardnu biblioteku standardizacijom iz 2011. godine. Prije toga je istu ulogu vršila klasa auto_ptr<T> koja i danas postoji u standardnoj biblioteci, no ne treba ju koristiti.

  • Objekte tipa unique_ptr<T> možemo stavljati u STL spremnike, za razliku od auto_ptr<T> objekata koje ne možemo.

Brojanje referenci

Ponekad, radi efikasnosti, imamo više pokazivača koji referiraju na isti objekt. Kada trebamo novu kopiju samo kopiramo pokazivač.

Kada je takvo ponašanje poželjno onda trebamo riješiti problem višestruke dealokacije objekta. Ponovo ćemo pokazivač zatvoriti u jednu klasu no u destruktoru klase nećemo nužno pozivati delete na pokazivaču. Tek kada posljednji pokazivač na objekt izlazi iz dosega, i ne ostaje niti jedan koji referira na dani objekt, zovemo delete.

Tehnika koja nam omogućava držanje više pametnih pokazivača s adresom istog objekta zove se brojanje referenci i implementirana je u ovoj klasi:

class SmartBFC{
    public:
        SmartBFC(BFC *p) : ptr(p), cnt(new int(1)) {}
        SmartBFC(const SmartBFC & orig): ptr(orig.ptr), cnt(orig.cnt)
                                       { ++*cnt; }
        SmartBFC& operator=(const SmartBFC&);
        ~SmartBFC(){ if(--*cnt == 0) { delete ptr; delete cnt; } }

        // Pomoćne funkcije
        const BFC * get() const { return ptr; }
        int         use_count() const { return *cnt; }
    private:
         BFC * ptr;
         int * cnt;
};

Naša implementacija dinamički alocira brojač referenci:

brojanje_ref.png
  • Klasa SmartBFC sadrži pokazivač ptr na objekt tipa BFC i pokazivač cnt na brojač instanci klase SmartBFC koje pokazuju na isti objekt;

  • Konstruktor uzima pokazivač na (dinamički alociran) objekt tipa BFC i njime inicijalizira svoj pokazivač. Ujedno na hrpi kreira objekt tipa int koji inicijalizira jedinicom te inicijalizira cnt s pokazivačem na njega. Nakon što konstruktor završi brojač referenci je postavljen na jedinicu.

Napomena: Klasa SmartBFC nije pametni pokazivač jer ne implementira semantiku pokazivača. Te ćemo detalje popraviti naknadno.

Brojanje referenci - konstruktor kopiranjem

Kopiranje SmartBFC objekta:

cctor_br_ref.png
SmartBFC(const SmartBFC & orig): ptr(orig.ptr), cnt(orig.cnt) { ++*cnt; }
  • Konstruktor kopije jednostavno kopira pokazivače i povećava brojač referenci za jedan.

~SmartBFC(){ if(--*cnt == 0) { delete ptr; delete cnt; } }
  • Destruktor smanjuje brojač za jedan i ako je umanjeni brojač nakon toga jedanak nuli (to znači da nema više niti jednog SmartBFC objekta koji pokazuje na dani BFC objekt) dealocira i objekt na koji pokazuje i brojač. Ako nakon smanjivanja brojača on nije jednak nuli destruktor ne radi ništa. U tom slučaju ima još SmartBFC objekata koji pokazuju na promatrani objekt i koriste brojač.

const BFC * get() const { return ptr; }
int         use_count() const { return *cnt; }
  • Pomoćna funkcija get() nam omogućava da dohvatimo pokazivač na pohranjeni objekt. Funkcija use_count() daje brojač "referenci" na objekt.

Brojanje referenci - operator pridruživanja

Implementacija operatora pridruživanja:

op_br_ref.png
SmartBFC& SmartBFC::operator=(const SmartBFC& rhs)
{
     ++*rhs.cnt;
     if(--*cnt == 0) { delete ptr; delete cnt; }
     ptr = rhs.ptr;
     cnt = rhs.cnt;

     return *this;
}

Ovdje, da bismo se zaštitili od "samopridruživanja" prvo povećavamo brojač desne strane, a onda smanjujemo brojač lijeve strane. Dealokaciju objekta na lijevoj strani vršimo jedino ako je umanjeni brojač jednak nuli. Nakon toga kopiramo pokazivače. Uočimo da će kod pridruživanja oblika a=a doći prvo do povećanja, a zatim smanjenja brojača, te se tako izbjegava moguća dealokacije objekta.

Primjena

int main()
{
    SmartBFC spt1(new BFC(13));
    cout << "cnt (u spt1) =" << spt1.use_count() << endl; // 1
    SmartBFC spt2 = spt1; // CCtor
    cout << "cnt (u spt1) = " << spt1.use_count() << endl; // 2

    cout <<   spt1.get()->get() << endl;

    SmartBFC spt3(new BFC(33));
    spt1 = spt3;

    return 0;
}

Primjena operatora -> kao u ovom slučaju

cout <<   spt1.get()->get() << endl;

vrlo je nepraktična, no preopterećenje operatora -> će nam omogućiti da koristimo prirodnu sintaksu spt1->get(), kao da je spt1 pokazivač.

Napomena. U standardnoj biblioteci brojanje referenci je implementirano klasom shared_ptr<T>.

std::shared_ptr<T>

std::shared_ptr<T> je pametni pokazivač koji implementira brojanje referenci. Koristimo ga kada želimo imati više pokazivača koji pokazuju na isti element i brinu o njegovom vijeku trajanja. Definiran je u zaglavlju <memory>.

Konstrukcija

  • Konstruktor uzima pokazivač čiji postaje vlasnik.

  • Dodijeljeni konstruktor kreira prazan objekt (pokazivač na objekt je nullptr).

  • std::shared_ptr<T> ima konstruktore kopije i operator pridruživanja koji implementiraju brojanje referenci.

  • std::shared_ptr<T> je moguće konstruirati preuzimanjem resursa std::unique_ptr<T> objekta.

  • Funkcija std::make_shared<T> kreira pametni pokazivač na zadanu vrijednost. Interno kreira objekt pomoću operatora new. Svoje argumente predaje konstruktoru objekta.

#include <memory>
#include <iostream>

int main()
{
   std::shared_ptr<int> p1(new int(5));
   std::unique_ptr<int> u1(new int(2));
   std::shared_ptr<int> p2(std::move(u1) ); // inicijaliziraj shared pomoću
                                            // unique
   std::shared_ptr<int> p3{p1};  // CCtor

   p2 = p3;  // OP

   std::cout << p1.use_count() << std::endl;  // daje 3
   std::cout << std::boolalpha << p1.unique() << std::endl;  // false
   std::cout << *p1 << " : " << *p2 << " : " << *p3 << std::endl;
   std::cout << "adresa = " << p1.get() << std::endl;

 // Ako imao samo vrijednost koju želimo pohraniti u
   // shared_ptr koristimo make_shared.
   std::shared_ptr<int> p4 = std::make_shared<int>(11);
   std::cout << *p4 <<  std::endl;
   std::cout << "adresa = " << p4.get() << std::endl;
   return 0;
}

Neke od metoda klase std::shared_ptr<T> su iste kao i kod klase std::unique_ptr<T>:

Poziv Značenje

a.get()

Vraća pokazivač koji a drži.

a.unique()

Vraća true ako use_count() vraća 1 odnosno false u suprotnom.

a.use_count()

Vraća broj referenci, odnosno broj std::shared_ptr<T> objekata koji drže isti pokazivač.

a.reset()

Odriče se vlasništva nad objektom.

a.reset(ptr)

Objekt kojeg je do sada držao zamijenjuje s ptr.

  • std::shared_ptr<T> drži pokazivač na dinamički alociran objekt. Pogrešno mu je dati adresu objekta koji nije dinamički alociran! Ako imamo samo objekt, a želimo ga kontrolirati pomoću std::shared_ptr<T> objekta, treba koristiti funkciju std::make_shared<T> (iz zaglavlja <memory>).

  • Destruktor klase std::shared_ptr<T> poziva operatir delete na pokazivaču koji drži.

  • Metoda release() ne postoji.

std::shared_ptr<T> i polja

Ako pokazivač na polje želimo čuvati u std::shared_ptr<T> objekt, tada pri konstrukciji trebamo zadati deleter.

 std::shared_ptr<int>  spv(new int[10], [](int *p) { delete [] p; });
   for(std::size_t i=0; i<10; ++i)
       spv.get()[i] = i;
  • Deleter je funkcija koja uzima pokazivač na tip i ne vraća ništa.

  • Za razliku od std::unique_ptr<T> ne možemo pisati std::shared_ptr<T[]> već moramo zadati deleter eksplicitno. Sučelje klase se ne mijenja i nemamo operatora indeksiranja.

std::shared_ptr<T> i cirkularna zavisnost

Brojanje referenci uvodi mogućnost cirkularne zavisnosti.

  • Objekt A drži pokazivač na objekt B dok objekt B drži pokazivač na objekt A. U tom slučaju nikad neće doći do dealokacije memorije.

Primjer. Klasa koja drži pametne pokazivače:

class Employee{
    public:
        std::string name;
        std::shared_ptr<Employee> head;
        std::vector<std::shared_ptr<Employee>> staff;

        Employee(std::string const & name_,
                 std::shared_ptr<Employee> head_ = nullptr) : name(name_),
                                                              head(head_)
        {}
        ~Employee(){ std::cout << name << " izbrisan\n"; }
        // nema potrebe za ručnom dealokacijom
     // ...
};

Funkcija za kreiranje instance klase:

std::shared_ptr<Employee> init(std::string const & name){
        std::shared_ptr<Employee> boss(new Employee(name + " boss"));
        std::shared_ptr<Employee> dept_boss(new Employee(name, boss));
        boss->staff.push_back(dept_boss);
        return dept_boss;
}

U glavnom programu …

int main()
{
   // ...
   std::shared_ptr<Employee> dboss = init("Damir");
   std::cout << dboss->name << " use_count = " << dboss.use_count() << std::endl;

   // Do dealokacije neće nikad doći!
   return 0;
}

Da ne dolazi do dealokacije možemo provjeriti pomoću valgrind-a

valgrind --tool=memcheck --leak-check=yes ./a.out

std::weak_ptr<T>

Klasa weak_ptr<T> omogućava dijeljenje dinamički alociranog objekta bez vlasništva nad objektom.

  • weak_ptr<T> najčešće kreiramo iz shared_ptr<T> objekta. Oni tada pokazuju na isti objekt. Dodijeljeni konstruktor kreira prazan pokazivač (onaj koji drži nullptr).

  • Metoda use_count() nikad ne broji weak_ptr<T> objekte koji drže pokazivač na objekt; ona broji samo shared_ptr<T> objekte.

  • weak_ptr<T> nikad ne dealocira svoj objekt.

std::shared_ptr<int>  sp1(new int(7));
std::shared_ptr<int>  sp2(sp1);
std::weak_ptr<int> wp1(sp2);

std::cout << wp1.use_count() << std::endl;  // 2

if(wp1.expired())
    std::cout << "wp1 drži nullptr.\n";
else
    std::cout << "wp1 pokazuje na objekt.\n";

Klasa weak_ptr<T> ima minimalno sučelje i ne može direktno dohvatiti objekt na koji pokazuje (niti za čitanje niti za pisanje). Da bi mogla dohvatiti objekt prvo mora kreirati std::shared_ptr<T> pomoću lock() metode.

Poziv Značenje

a.use_count()

Vraća broj referenci, odnosno broj std::shared_ptr<T> objekata koji drže isti pokazivač.

a.expired()

Vraća true ako je prazan. Isto što i a.use_count() == 0, ali efikasnije.

a.lock()

Vraća std::shared_ptr<T> koji sadrži isti pokazivač.

Na primjer (nastavak),

auto a =wp1.lock();
*a = 17;
std::cout << wp1.use_count() << std::endl;  // 3
std::cout << *wp1.lock() << std::endl;  // 17

weak_ptr<T> je namijenjen i za korištenje u situacijama kada nadživi objekt na koji pokazuje. Stoga prije uptrebe metode lock() treba uvijek ispitati objekt sa expired() metodom.

std::weakptr<T> i rješenje cirkularne zavisnosti

Korištenjem klase std::weak_ptr<Employee> prekida se cirkularna zavisnost i objekti se normalno dealociraju.

// std::weak_ptr<Employee> iprekida cirkularnu zavisnost.
class Employee{
    public:
        std::string name;
        std::shared_ptr<Employee> head;
        //std::vector<std::shared_ptr<Employee>> staff;
        std::vector<std::weak_ptr<Employee>> staff;

        Employee(std::string const & name_,
                 std::shared_ptr<Employee> head_ = nullptr) : name(name_),
                                                              head(head_)
        {}
        ~Employee(){ std::cout << name << " izbrisan\n"; }
         // nema potrebe za ručnom dealokacijom
};

Alokatori

Svi spremnici imaju jedan dodatni parametar predloška, tzv. alokator. Na primjer, std::vector ima deklaraciju:

template<
    class T,
    class Allocator = std::allocator<T>
> class vector;

Alokator brine o alokaciji i dealokaciji memorije. Operatori new i delete se ne koriste neposredno zbog ovog "nedostatka":

  • Operator new kombinira alokaciju memorije i konstrukciju objekta u njoj.

  • Operator delete kombinira destrukciju objekta i dealokaciju memorije,

Ta svojstva ovih operatora bi dovela do gubitka efikasnosti kod klasa kao što je std::vector<T> koje alociraju više memorije no što drže elemenata. Bez alokatora sva bi memorija morala biti konstruirana (s dodijeljenim konstruktorom).

Alokator iz zaglavlja <memory> nam omogućava da razdvojimo fazu alokacije memorije od konstrukcije objekta u memoriji te, isto tako fazu destrukcije objekta i dealokaciju memorije. Dodijeljeni alokator iz standardne biblioteke je std::allocator<T>. Korisnici mogu pisati vlastite alokatore ako za tim imaju potrebe.

Ovdje su prikazane metode alokatora:

Poziv Značenje

allocator<T> a

Definira alokator a koji alocira memoriju za tip T.

a.allocate(n)

Alocira neinicijaliziranu memoriju za n objekata tip T. Vraća pokazivač na alociranu memoriju.

a.deallocate(p,n)

Dealocira memoriju koja sadrži prostor za n objekata tip T i na koju pokazuje pokazivač p. Pokazivač p mora biti dobiven od allocate i n mora biti korišten u pozivu metode allocate. Objekti koji su eventualno konstruirani u toj memoriji moraju biti uništeni metodom destroy() prije poziva deallocate.

a.construct(p, args)

p mora biti pokazivač tipa T na neinicijaliziranu memoriju; args su argumenti za konstruktor tipa T koji se koriste da bi se na mjestu na koje pokazuje p konstruirao objekt tipa T.

a.destroy(p)

p mora biti pokazivač tipa T. Pozovi destruktor na objektu na koji pokazuje p. Objekt mora prije toga biti konstruiran na tom mjestu.

Korisne mogu biti još dvije metode iz memory zaglavlja;

Poziv Značenje

uninitialized_copy(b,e,b2)

Kopira elemente iz raspona danog s iteratorima b i e u neinicijaliziranu memoriju na koju pokazuje b2, koji mora pokazivati na dovoljnu količinu memorije.

uninitialized_fill(b,e,t)

Konstruira objekte u neinicijaliziranoj memoriji omeđenoj s iteratorima b i e kopirajući element t.

Postoje i copy_n i fill_n verzije tih funkcija koje uzimaju polazni iterator i broj elemenata.