Table of Contents

Articol 02 - Struct vs. Class. Constructori

Obiective

În urma parcurgerii acestui articol studentul va:

Clase

Formal am făcut deja primii pași mai sus pentru a implementa o clasă în C++, utilizând keyword-ul struct.

Totuși, ce înseamnă o clasă? Nu trebuie decât să ne gândim la ce am făcut mai sus:

Cu această adăugare menționată, putem să ne referim la ceea ce înseamnă o clasă, respectiv un obiect.

Ne referim la o clasă ca fiind o amprentă (blueprint) sau descriere generală. Un obiect sau o instanță a clasei este o variabilă concretă ce se conformează descrierii clasei.

Vom numi clasă tipul de date definit de struct complex sau class complex și obiect o instanțiere (o alocare dinamică sau locală) a tipului de date.

Când discutăm despre tipul de date complex ne referim la clasă. Când discutăm despre variabila number ne referim la un obiect, o instanță a clasei.

Keyword-ul "class" vs. "struct"

Și totuși, C++ adăugă keyword-ul class. Care este diferența între class și struct? Iată cum definim complet clasa de mai sus, separând antetul de implementare și de programul principal.

complex.h
class Complex {
    double re;
    double im;
 
    Complex conjugate(); 
};
complex.cc
#include "complex.h"
Complex Complex::conjugate() {
    Complex conjugate;
    conjugate.re = this->re;
    conjugate.im = -(this->im);
 
    return conjugate;
}
main.cc
#include <stdio.h>
#include "complex.h"
 
int main() {
    Complex number;
    number.re = 2;
    number.im = 4;
 
    printf("%.2lf %.2lf\n", number.re, number.im);
 
    return 0;
}

Specificatori de acces

Am observat mesajul de eroare în urma compilării fișierelor de mai sus.

Astfel, singura diferență folosirea celor două keyword-uri este nivelul implicit de vizibilitate a metodelor și atributelor.

Membri precedați de label-ul private pot fi folosiți numai în interiorul clasei, în cadrul metodelor acesteia. Ei nu pot fi citiți sau modificați din afara clasei.

Iată cum puteam remedia soluția:

complex.h
class Complex {
public:
    double re;
    double im;
 
    Complex conjugate();
};

Constructori și destructori

Studiați codul de mai jos.

complex.h
class Complex {
public:
    // Constructor
    Complex(double re, double im);
 
    // Destructor
    ~Complex();
 
    double getRe();
    double getIm();
 
    Complex conjugate(); 
 
private:
    double re;
    double im;
};
complex.cc
#include "complex.h"
Complex::Complex(double re, double im) {
    this->re = re;
    this->im = im;
}
 
Complex::~Complex() {
}
 
Complex Complex::conjugate() {
    Complex conjugat(re, -im);
    return conjugat;
}
 
double Complex::getRe() {
    return re;
}
 
double Complex::getIm() {
    return im;
}
main.cc
#include <stdio.h>
#include "complex.h"
 
int main() {
    Complex number(2, 3);
    printf("%lf %lf\n", number.getRe(), number.getIm());
 
    return 0;
}

Constructor

Observăm două bucăți din cod în mod special:

Complex::Complex(double re, double im);

Linia de mai sus nu are tip returnat, spre deosebire de celelalte linii. Acesta este constructorul clasei, care este apelat în momentul alocării unui obiect.

Ce operații sunt uzuale în constructor?

A doua bucată observată este:

Complex numar(2, 3);

Până acum nu ați mai alocat astfel structurile. Ce se întâmplă în spate este exact ceea ce intuiți: este apelat constructorul obiectului și se execută instrucțiunile acestuia pentru variabila numar (reprezentată ca pointer prin this, direct în interiorul constructorului).

În constructorul definit mai sus, tot ceea ce se întâmplă este să se inițializeze membri. Pentru asta, C++ vă pune la dispoziție o sintaxă simplă:

Complex::Complex(double real, double imaginar) :
    re(real),
    im(imaginar) {
}

Cei doi constructori sunt identici ca funcționalitate.

Copy-constructor

Reprezintă un tip de constructor special care se folosește când se dorește/este necesară o copie a unui obiect existent. Dacă nu este declarat, se va genera unul default de către compilator.

Poate avea unul din următoarele prototipuri

Când se apelează?

1) Apel explicit

explicit_copy_constructor_call.cpp
MyClass m;
MyClass x = MyClass(m); /* apel explicit al copy-constructor-ului */

2) Transfer prin valoare ca argument într-o funcție

call_by_value.cpp
void f(MyClass obj);
...
MyClass o;
f(o); /* se apelează copy-constructor */

3) Transfer prin valoare ca return al unei funcții

return_by_value.cpp
MyClass f()
{
    MyClass a;
    return a; /* se apelează copy-constructor */
}

4) La inițializarea unei variabile declarate pe aceeași linie

init.cpp
MyClass m;
MyClass x = m; /* se apelează copy-constructor */

Destructor

Așa cum probabil ați observat, constructorul este apelat în mod explicit de către voi. Destructorul însă, în cazul de mai sus, este apelat implicit la terminarea blocului care realizează dealocărea automată a obiectului.

Un destructor nu are parametri și se declară în interiorul clasei astfel:

~Complex();

Dacă în constructor sau în interiorul clasei ați fi alocat memorie, cel mai probabil în destructor ați fi făcut curat și ați fi apelat free pe membrul respectiv.

Rule of Three

Reprezintă un concept de must do pentru C++. Astfel:

Dacă programatorul și-a declarat/definit unul dintre destructor, operator de assignment sau copy-constructor, trebuie să îi declare/definească și pe ceilalți 2

Explicație: dacă funcționalitatea vreunuia dintre cei 3 se vrea mai specială decât cea oferită default, atunci mai mult ca sigur se dorește schimbarea funcționalității default și pentru ceilalți 2 rămași.

rule_of_3.cpp
class Complex
{
    private:
        int re;
        int im;
    public:
        Complex()
        {
            re = 0;
            im = 0;
            printf("constructor default\n");
        }
 
        Complex(const Complex& c)
        {
            re = c.re;
            im = c.im;
            printf("copy contructor\n");
        }
 
        void operator=(const Complex& c)
        {
            re = c.re;
            im = c.im;
            printf("assignment operator\n");
        }
        ~Complex ()
        {
            printf("destructor\n");
         }
};

Clase/metode prietene

Așa cum am văzut în primul articol, fiecare membru al clasei poate avea 3 specificatori de acces:

Alegerea specificatorilor se face în special în funcție de ce funcționalitate vrem să exportăm din clasa respectivă.

Dacă vrem să accesăm datele private/protejate din afara clasei, avem următoarele opțiuni:

O funcție prieten are următoarele proprietăți:

O clasă prieten are următoarele proprietăți:

De asemenea, dacă clasa A este considerată prieten cu clasa B, nu înseamnă că si clasa B este considerată prieten cu clasa A. Nici tranzitivitatea nu este valabilă în relaţia de prietenie dintre clase.

Exemplu:

class Complex{
 
private:
    int re;
    int im;
public:
    int GetRe();
    int GetIm();
    friend double ComplexModul(Complex c);   //am declarat fct ComplexModul ca prieten
    friend class Polinom;   //Acum clasa Polinom care acces deplin la membrii **re** și **im**
};
 
double ComplexModul(Complex c)
{
   return sqrt(c.re*c.re+c.im*c.im);  //are voie, intrucat e prietena
}

Supraîncarcarea operatorilor

Un mecanism specific C++ este supraîncarcarea operatorilor, prin care programatorul poate asocia noi semnificaţii operatorilor deja existenţi. De exemplu, dacă dorim ca două numere complexe să fie adunate, în C trebuie să scriem funcții specifice, nenaturale. În C++ putem scrie foarte ușor:

Complex a(2,3);
Complex b(4,5);
Complex c=a+b; //operatorul + a fost supraîncarcat pentru a aduna două numere complexe

Acest lucru este posibil, întrucât un operator este văzut ca o funcție, cu declarația:

   tip_rezultat operator#(listă_argumente);

Așadar pentru a supraîncărca un operator pentru o anumită clasă, este necesar să declarăm funcția următoare în corpul acesteia:

   tip_rezultat operator#(listă_argumente);

Există câteva restricții cu privire la supraîncarcare:

Operatori supraîncărcaţi ca funcţii prieten

Un operator binar va fi reprezentat printr-o funcţie nemembră cu două argumente, iar un operator unar, printr-o funcţie nemembră cu un singur argument.

Utilizarea unui operator binar sub forma a#b este interpretată ca operator#(a,b).

Argumentele sunt clase sau referinţe constante la clase.

Supraîncărcarea operatorilor << şi >>

În C++, orice dispozitiv de I/O este văzut drept un stream, așadar operațiile de I/O sunt operații cu stream-uri, care se definesc în felul următor:

Acești operatori pot fi supraîncărcați pentru o clasă pentru a defini operații de I/O direct pe obiectele clasei.

Supraîncărcarea se poate efectua folosind funcții friend utilizând următoarea sintaxă:

istream& operator>> (istream& f, clasa & ob);        //Acum pot scrie in >> ob
ostream& operator<< (ostream& f, const clasa & ob);  //Acum pot scrie out << ob

Operatorii » și « întorc fluxul original, pentru a scrie înlănțuiri de tipul f»ob1»ob2.

Funcţiile operator pentru supraîncărcarea operatorilor de I/O le vom declara ca funcţii prieten al clasei care interacţionează cu fluxul.

Complex.h
#include <iostream>
 
class Complex
{
public:
    double re;
    double im;
 
    Complex(double real=0, double imag=0): re(real), im(imag) {};
 
    //supraîncărcarea  operatorilor +, - ca functii de tip "friend" 
    friend Complex operator+(const Complex& s, const Complex& d);
    friend Complex operator-(const Complex& s, const Complex& d);
 
    //funcţii operator pentru supraîncărcarea operatorilor de intrare/ieşire  
    //declarate ca funcţii de tip "friend"   
    friend std::ostream& operator<< (std::ostream& out, const Complex& z);
    friend std::istream& operator>> (std::istream& is, Complex& z);
};
Complex.cpp
#include "complex.h"
 
Complex operator+(const Complex& s, const Complex& d){
  return Complex(s.re+d.re,s.im+d.im);
}
 
Complex operator-(const Complex& s, const Complex& d){
  return Complex(s.re-d.re,s.im-d.im);
}
 
std::ostream& operator<<(std::ostream& out, const Complex& z){
   out << "(" << z.re << "," << z.im << ")"<< std::endl;
   return out;
}
 
std::istream& operator>>(std::istream& is, Complex& z){
  is >> z.re >> z.im;
  return is;
}
main.cpp
#include "complex.h"
 
int main() {
 Complex a(1,1), b(-1,2);
 std::cout << "A: " << a << "B: " << b;
 std::cout << "A+B: " << (a+b);
 std::cin >> b;
 std::cout << "B: " << b;
 a=b;
 std::cout << "A: " << a << "B: " << b;
}

Operatori supraîncărcaţi ca funcţii membre

Funcţiilor membru li se transmite un argument implicit this (adresa obiectului curent), motiv pentru care un operator binar poate fi implementat printr-o funcţie membru nestatică cu un singur argument.

Operatorii sunt interpretați în modul următor:

Complex.h
#include <iostream>
 
class Complex
{
public:
    double re;
    double im;
 
    Complex(double real, double imag): re(real), im(imag) {};
 
    //operatori supraîncărcaţi ca funcţii membre
    Complex operator+(const Complex& d);
    Complex operator-(const Complex& d);
    Complex& operator+=(const Complex& d);
 
    friend std::ostream& operator<< (std::ostream& out, const Complex& z);
    friend std::istream& operator>> (std::istream& is, Complex& z);
};
Complex.cpp
#include "complex.h"
 
Complex Complex::operator+(const Complex& d){
  return Complex(re+d.re, im+d.im);
}
 
Complex Complex::operator-(const Complex& d){
  return Complex(re-d.re, im-d.im);
}
 
Complex& Complex::operator+=(const Complex& d){
  re+=d.re;
  im+=d.im;
  return *this;
}
 
std::ostream& operator<<(std::ostream& out, const Complex& z){
   out << "(" << z.re << "," << z.im << ")"<< std::endl;
   return out;
}
 
std::istream& operator>>(std::istream& is, Complex& z){
  is >> z.re >> z.im;
  return is;
}

Supraîncărcarea operatorului de atribuire

Așa cum am amintit mai sus, majoritatea operatorilor pot fi supraîncărcați. O atenție importantă trebuie acordată operatorului de atribuire, dacă nu este supraîncărcat, realizează o copiere membru cu membru.

Pentru obiectele care nu conţin date alocate dinamic la iniţializare, atribuirea prin copiere membru cu membru funcţionează corect, motiv pentru care nu se supraîncarcă operatorul de atribuire.

Pentru clasele ce conţin date alocate dinamic, copierea membru cu membru, executată în mod implicit la atribuire conduce la copierea pointerilor la datele alocate dinamic, în loc de a copia datele.

Operatorul de atribuire poate fi redefinit numai ca funcţie membră, el fiind legat de obiectul din stânga operatorului =, motiv pentru care va întoarce o referinţă la obiect.

String.h
class String{
  char* s;
  int n; // lungimea sirului
 
  public:
	String();			
	String(const char* p);	
	String(const String& r);
	~String();			
	String& operator=(const String& d);
	String& operator=(const char* p);
};
String.cpp
#include "String.h"
#include <string.h>
 
String& String::operator=(const String& d){
  if(this != &d){    //evitare autoatribuire
    if(s)            //curatire
      delete [] s;
    n=d.n;           //copiere
    s=new char[n+1];
    strcpy(s, d.s);
  }
  return *this;      //intoarce referinta la obiectul modificat
}
 
String& String::operator=(const char* p){
  if(s)
      delete [] s;
  n=strlen(p);
  s=new char[n+1];
  strcpy(s, p);
  return *this;
}

Referințe

In C++ există două modalități de a lucra cu adrese de memorie:

Referinţa poate fi privită ca un pointer constant inteligent, a cărui iniţializare este forţată de către compilator (la definire) şi care este dereferenţiat automat.

Semantic, referințele reprezintă aliasuri ale unor variabile existente. La crearea unei referinţe, aceasta trebuie iniţializată cu adresa unui obiect (nu cu o valoare constantă).

Sintaxa pentru declararea unei referințe este:

  tip& referinta = valoare;

Exemplu:

    int x=1, y=2;
    int& rx = x; //referinta
    rx = 4; //modificarea variabilei prin referinta
    rx = 15; //modificarea variabilei prin referinta
    rx =y; //atribuirea are ca efect copierea continutului 
           //din y in x si nu modificarea adresei referintei

Spre deosebire de pointeri:

Referinţele se folosesc:

Motivul pentru aceste tipuri de utilizări este unul destul de simplu: când se transmit parametrii funcțiilor, se copiază conținutul variabilelor transmise pe stivă, lucru destul de costisitor. Prin transmiterea de referințe, nu se mai copiază nimic, așadar intrarea sau ieșirea dintr-o funcție sunt mult mai putin costisitoare.

Keyword const

În C++, există mai multe întrebuințări ale cuvântului cheie const:

Pentru a specifica, un obiect a cărui valoare nu poate fi modificată, const se poate folosi în următoarele feluri:

Orice obiect constant poate apela doar funcții declarate constante. O funcție constantă se declară folosind sintaxa:

     void fct_nu_modifica_obiect() const; //am utilizat cuvântul cheie const
                       //dupa declarația funcției fct_nu_modifica_obiect

Această declaratie a functiei garantează faptul că obiectul pentru care va fi apelată nu se va modifica.

Regula de bază a apelării membrilor de tip funcție ai claselor este:

Exemple:

//declarație
class Complex
{
private:
    int re;
    int im;
public:
    Complex();
    int GetRe() const;
    int GetIm() const;
    void SetRe(int re);
    void SetIm(int im);
};
 
 
//apelare
Complex c1;
const Complex c2;
c1.GetRe();   //corect
c1.SetRe(5);  //corect
c2.GetRe();   //corect
c2.SetRe(5);  //incorect

Funcții care returnează referințe

Pentru clasa Complex, definim funcţiile care asigură accesul la partea reală, respectiv imaginară a unui număr complex:

 double getRe(){ return re; }
 double getIm(){ return im; }

Dacă am dori modificarea părţii reale a unui număr complex printr-o atribuire de forma:

 z.getRe()=2.;

constatăm că funcţia astfel definită nu poate apărea în partea stângă a unei atribuiri.

Acest neajuns se remediază impunând funcţiei să returneze o referinţă la obiect, adică:

 double& getRe(){ return re; }

Codul de mai sus returnează o referință către membrul re al obiectului Complex z, așadar orice atribuire efectuată asupra acestui câmp va fi vizibilă și în obiect.

Bibliografie