© 2023 – 2026, Pau Fernández

PRO2

PRO2

Avaluació
Professorat
Pràctica
•

Classes

•

TADs

•

Sovint podem associar certes funcions a una tupla

•

La forma d'expressar que certes funcions estan associades a una tupla és convertir-les en mètodes

•

Tot mètode té un paràmetre implícit, que és la tupla sobre la qual s'ha cridat

•

Si un mètode és const, promet no modificar el paràmetre implícit

•

Un TAD (tipus abstracte de dades) és el conjunt de la tupla i les seves operacions

•

Separació d'un programa en diferents fitxers

•

Per organitzar un programa, separem els TADs en fitxers o mòduls

•

Membres privats

•

Per reforçar la modularitat, ocultem els detalls interns d'un TAD

•

Una classe és una tupla a on podem decidir quins membres són públics o privats

•

Quan un membre d'una classe és private, accedir-lo fora del TAD resulta en un error de compilació

•

Constructors

•

La privacitat dels membres de dades requereix un mètode especial per inicialitzar: el constructor

•

Una classe pot tenir més d'un constructor

•

Hi ha un constructor especial per crear un objecte a partir d'un altre: el constructor de còpia

•

Treballar amb classes és el primer pas de l'orientació a objectes

Classes

TADs

Sovint podem associar certes funcions a una tupla

Disposem del programa següent:

#include <iostream>
using namespace std;

struct Rellotge {
    int hores, minuts, segons;
};

bool rellotge_llegeix(Rellotge& r) {
    char _;  // per llegir el caràcter ':'
    return bool(cin >> r.hores >> _ >> r.minuts >> _ >> r.segons);
}

void escriu_XX(int x) { // utilitat per mostrar en dos caràcters
    cout << (x < 10 ? "0" : "") << x;
}

void rellotge_escriu(const Rellotge& r) {
    escriu_XX(r.hores);
    cout << ":";
    escriu_XX(r.minuts);
    cout << ":";
    escriu_XX(r.segons);
}

void rellotge_avansa(Rellotge& r, int s) {
    r.segons += s;
    if (r.segons >= 60) {
        r.minuts += r.segons / 60;
        r.segons %= 60;
        if (r.minuts >= 60) {
            r.hores += r.minuts / 60;
            r.minuts %= 60;
            if (r.hores >= 24) {
                r.hores %= 24;
            }
        }
    }
}

int  {
     segons;
    Rellotge r;
     ((r)) {
        cin >> segons;
        (r, segons);
        (r);
        cout << endl;
    }
}

main
()
int
while
rellotge_llegeix
rellotge_avansa
rellotge_escriu

que llegeix una seqüència de rellotges i els mostra després de sumar-hi un número de segons a cadascun. (L'operador %= és com += per al mòdul)

El tipus Rellotge té, doncs, a més de les seves dades, una sèrie d'operacions associades que permeten modificar el rellotge mantenint la seva consistència.

La forma d'expressar que certes funcions estan associades a una tupla és convertir-les en mètodes

En comptes de tenir la tupla i les funcions separades (quan en realitat són part de la mateixa cosa), es poden declarar les funcions com a membres de la tupla. Això les converteix en mètodes.

struct Rellotge {
    int hores, minuts, segons;

    bool llegeix();
    void escriu() const;
    void avansa(int segons);
};

És a dir, declarem un tipus nou, Rellotge, que no només té les dades necessàries sinó que també té les operacions que es poden fer amb el Rellotge. Ara, les operacions llegeix, escriu i avansa ja no són funcions, són mètodes.

En el pas de funcions a mètodes hi ha hagut els següents canvis:

  1. Es posa només la capçalera dels mètodes dins de la tupla.

  2. Hem tret el prefix rellotge_ dels noms de les funcions.

  3. Ha desaparegut el paràmetre de tipus Rellotge a totes les operacions.

  4. Si el Rellotge que es rebia com a paràmetre era const, ara aquest const apareix al final de la declaració del mètode (en particular, al mètode escriu).

Donat que a dins la tupla només hi ha les declaracions, cal també fer canvis a les implementacions:

bool Rellotge::llegeix() {
    char _;  // per llegir el caràcter ':'
    return bool(cin >> hores >> _ >> minuts >> _ >> segons);
}

void escriu_XX(int x) {
    cout << (x < 10 ? "0" : "") << x;
}

void Rellotge::escriu() const {
    escriu_XX(hores);
    cout << ":";
    escriu_XX(minuts);
    cout << ":";
    escriu_XX(segons);
}

void Rellotge::avansa(int s) {
    segons += s;
    if (segons >= 60) {
        minuts += segons / 60;
        segons %= 60;
        if (minuts >= 60) {
            hores += minuts / 60;
            minuts %= 60;
            if (hores >= 24) {
                hores %= 24;
            }
        }
    }
}

A les implementacions, hi ha els següents canvis:

  1. Tots els mètodes tenen el prefix Rellotge::. Els quatre punts :: volen dir "pertany a". És a dir, els mètodes pertanyen a la tupla Rellotge.

  2. A la implementació, s'accedeix al hores, minuts i segons directament, donat que no hi ha cap tupla Rellotge com a paràmetre.

  3. Si un mètode té un const al final, la implementació també el té.

El programa principal també canvia, i potser és el que millor demostra el perquè dels canvis:

int main() {
    int segons;
    Rellotge r;
    while (r.llegeix()) {
        cin >> segons;
        r.avansa(segons);
        r.escriu();
        cout << endl;
    }
}

El canvi en el programa principal és que quan cridem un mètode, el cridem com un membre de la tupla Rellotge, amb un ., tal com faríem amb els camps hores, minuts, i segons. Això explica que el paràmetre r hagi desaparegut de dins dels parèntesis en els 3 mètodes, i també que haguem tret el prefix rellotge_. Donat que r és un rellotge, escriure r.avansa ja implica que el que volem avançar és el rellotge r.

Tot mètode té un paràmetre implícit, que és la tupla sobre la qual s'ha cridat

Així doncs, els mètodes del Rellotge només es poden cridar si tenim una variable de tipus Rellotge, i per tant, tots els mètodes poden suposar tranquilament que sempre tenen un Rellotge amb el que poder treballar.

Aquest rellotge omni-present és la variable r que està a l'esquerra de totes les crides

r.llegeix()
r.avansa(segons)
r.escriu()

En les funcions originals, aquest paràmetre era explícit, perquè no eren mètodes. Però com a mètodes, llegeix, avansa i escriu reben el rellotge igualment, tot i que de forma implícita. Per això se sol dir que el Rellotge és el paràmetre implícit.

Això també explica que poguem accedir als camps del Rellotge (hores, minuts, i segons) directament (i sense haver de posar un .) dins la implementació d'un mètode. Perquè sabent que hi ha un paràmetre implícit que és un Rellotge, no hi pot haver dubte sobre quina és la tupla amb la que s'està treballant.

Si un mètode és const, promet no modificar el paràmetre implícit

En particular, la funció original escriu rebia el paràmetre Rellotge amb un const

void escriu(const Rellotge& r) { ... }

perquè escriure un rellotge no requereix modificar-lo. Per poder dir que el paràmetre implícit és const, al no tenir-lo explícit en els paràmetres, hem de posar el const al final de la declaració del mètode

struct Rellotge {
    ...
    void escriu() const; // <-- El paràmetre implícit és `const`
};

Un TAD (tipus abstracte de dades) és el conjunt de la tupla i les seves operacions

Així doncs, un Tipus Abstracte de Dades (TAD) és el resultat d'agrupar:

  1. Una tupla (un struct amb certs camps de dades).
  2. Els mètodes necessaris per manipular-la de manera que es mantingui la consistència.

C++ ens permet expressar aquesta agrupació mitjançant els mètodes, que són les operacions disponibles per treballar amb certa tupla.

Separació d'un programa en diferents fitxers

Per organitzar un programa, separem els TADs en fitxers o mòduls

Quan els TADs creixen, es fa necessari separar-los del programa principal. I si hi ha molts TADS, cadascun pot estar en un fitxer diferent.

Per separar el TAD Rellotge explicat, faríem 3 fitxers: rellotge.hh, rellotge.cc i main.cc.

A rellotge.hh aniria només la declaració de la tupla amb els seus mètodes, i amb una protecció de doble include:

#ifndef RELLOTGE_H
#define RELLOTGE_H

struct Rellotge {
    int hores, minuts, segons;

    bool llegeix();
    void escriu() const;

    void avansa(int segons);
};

#endif

A rellotge.cc hi van només les implementacions dels mètodes de Rellotge i per declarar la tupla Rellotge només cal fer un #include local del fitxer rellotge.hh:

#include "rellotge.hh" // <-- Declaració de Rellotge

#include <iostream>
using namespace std;

bool Rellotge::llegeix() {
    char _;  // per llegir el caràcter ':'
    return bool(cin >> hores >> _ >> minuts >> _ >> segons);
}

void escriu_XX(int x) {
    cout << (x < 10 ? "0" : "") << x;
}

void Rellotge::escriu() const {
    escriu_XX(hores);
    cout << ":";
    escriu_XX(minuts);
    cout << ":";
    escriu_XX(segons);
}

void Rellotge::avansa(int s) {
    segons += s;
    if (segons >= 60) {
        minuts += segons / 60;
        segons %= 60;
        if (minuts >= 60) {
            hores += minuts / 60;
            minuts %= 60;
            if (hores >= 24) {
                hores %= 24;
            }
        }
    }
}

El fitxer main.cc només necessita el programa principal, però també inclou rellotge.hh perquè necessita la declaració de Rellotge:

#include "rellotge.hh" // Declaració de Rellotge
#include <iostream>
using namespace std;

int main() {
    int segons;
    Rellotge r;
    while (r.llegeix()) {
        cin >> segons;
        r.avansa(segons);
        r.escriu();
        cout << endl;
    }
}

Així doncs, hi ha realment 2 fitxers de codi: rellotge.cc i main.cc, però per no haver de repetir la declaració de Rellotge, que es necessita en els dos, la posem en el fitxer rellotge.hh i fem #include "rellotge.hh" en els dos fitxers. (Les cometes dobles li diuen a C++ que el fitxer rellotge.hh és local, o sigui que es troba en el mateix directori que el fitxer .cc.)

Al laboratori es veurà com compilar un programa que es composa de més d'un fitxer

Membres privats

Per reforçar la modularitat, ocultem els detalls interns d'un TAD

Els TADs estan per garantir la consistència d'un tipus com un Rellotge. No volem que en un moment donat aparegui un Rellotge amb les hores a 43 o -15, o bé els minuts a 74239 o els segons a -5698398. Els mètodes són les funcions "responsables" de garantir aquesta consistència, ja que els hem implementat amb cura de no cometre errors.

Però per garantir la consistència en la totalitat d'un programa, caldria que només els mètodes de Rellotge poguessin manipular rellotges. Perquè si no és així, quan surti un error, no podrem saber quina part del codi del programa és culpable de l'error. Si assegurem que només els mètodes de Rellotge poden modificar un rellotge, llavors, quan aparegui un error, estarem segurs que l'error només el poden haver comès els mètodes de Rellotge.

Per tant, hem d'ocultar totalment els detalls de com els Rellotges estan implementats a la resta del programa, per assegurar que ni tan sols és possible accedir-hi, i per tant tenir la garantia que no apareixen errors derivats d'un ús incorrecte dels rellotges en el futur.

Per fer això, C++ permet indicar que certs membres d'una classe són privats, que vol dir que només els altres membres de la tupla poden accedir-hi. Que un membre (ja sigui un camp de dades o un mètode) sigui privat vol dir que el programa en general no hi té accés.

Una classe és una tupla a on podem decidir quins membres són públics o privats

El pas a membres privats és el que converteix una tupla en una "classe". El primer pas és substituir struct per class:

class Rellotge {
    ...
};

Per defecte, en una classe, tots els membres són privats, per tant aquest canvi és insuficient per aconseguir el que volíem. Cal posar les directives public: o private: en punts concrets de la declaració de Rellotge:

class Rellotge {
private:
    int hores, minuts, segons;
public:
    bool llegeix();
    void escriu() const;
    void avansa(int segons);
};

Les dues marques public: i private: afecten als membres declarats a sota, fins que no aparegui alguna altra marca que ho contradigui. En particular, en la declaració que hem fet, private: afecta als camps de dades hores, minuts, i segons, i public: afecta a tots els mètodes.

Quan un membre d'una classe és private, accedir-lo fora del TAD resulta en un error de compilació

Amb la nova declaració tots els fitxers del programa que facin servir el Rellotge (mitjançant l'#include "rellotge.hh"), produiran un error de compilació si intenten fer el següent:

Rellotge r;
r.hores = 25;      // ERROR: `hores`  és privat
r.minuts = 1007;   // ERROR: `minuts` és privat
r.segons = -90922; // ERROR: `segons` és privat

La garantia de consistència és doncs, que el compilador es negarà a crear l'executable perquè violar la privacitat d'una classe és un error de compilació.

Constructors

La privacitat dels membres de dades requereix un mètode especial per inicialitzar: el constructor

De resultes de la privacitat, però, tenim una mica més de feina. Perquè ara mateix, per culpa de la privacitat, no podem inicialitzar els Rellotges, donat que els membres hores, minuts, i segons són privats! Hem volgut garantir la consistència dels rellotges i per tant, ara apareix el fet que inicialitzar un Rellotge forma part de les operacions que haurien de garantir-ne la consistència, i no podem deixar-li això a qualsevol!

El que falta, precisament, és un mètode especial que s'encarregui de la inicialització i que sigui responsable de deixar un rellotge en un estat correcte per començar. El mètode que inicialitza una variable d'una classe com Rellotge és especial i es diu el constructor.

El que caracteritza el constructor és

  1. Que el seu nom coincideix amb el nom de la classe.
  2. Que no té tipus de retorn (ni tan sols void), perquè no retorna res.
  3. No es crida explícitament com un mètode més, el crida C++ quan es declara una variable de la classe.

Per exemple, podem declarar un constructor per al Rellotge així:

class Rellotge {
    ...
public:
    // Afegim això deixant la resta com estava
    Rellotge(int h, int m, int s);
    ...
};

i després cal implementar-lo al fitxer rellotge.cc:

Rellotge::Rellotge(int h, int m, int s) {
    hores = h;
    minuts = m;
    segons = s;
}

Un cop fet això, en el programa principal podem crear Rellotges amb:

Rellotge r1(0, 0, 0);    // les 00:00:00
Rellotge r2(23, 59, 59); // les 23:59:59

Les dues línies declaren variables r1 i r2, però en comptes d'haver-hi un = per inicialitzar, es posen paràntesis per posar els paràmetres del constructor (és una crida indirecta), que C++ cridarà automàticament.

Una classe pot tenir més d'un constructor

Per flexibilitzar la inicialització, C++ ens permet declarar i implementar més d'un constructor, ja que si per exemple fem:

Rellotge r3;

i no posem paràmetres, quins seran els valors inicials de hores, minuts i segons?

Una declaració a on no es crida cap constructor en realitat en crida un, anomenat constructor per defecte, que C++ considera que és el que no rep cap paràmetre (perquè la inicialització no aporta cap dada inicial).

En el cas del Rellotge, farem que el constructor per defecte inicialitzi un Rellotge amb zeros a les hores, minuts i segons.

class Rellotge {
 private:
    ...
 public:
    ...
    Rellotge(); // <-- Constructor per defecte
    ...
};

La implementació és:

Rellotge::Rellotge() {
    hores = 0;
    minuts = 0;
    segons = 0;
}

Hi ha un constructor especial per crear un objecte a partir d'un altre: el constructor de còpia

A part del constructor per defecte i del constructor amb paràmetres, hi ha un constructor especial que C++ utilitza quan volem crear un objecte a partir d'un altre objecte del mateix tipus:

Rellotge r1(10, 30, 0);
Rellotge r2(r1);  // <-- Constructor de còpia

La declaració de r2 crea un nou Rellotge que és una còpia de r1. El constructor que s'encarrega d'això es diu constructor de còpia, i es declara rebent una referència constant a un objecte del mateix tipus:

class Rellotge {
    ...
public:
    ...
    Rellotge(const Rellotge& altre);  // Constructor de còpia
};

La seva implementació simplement copia els camps:

Rellotge::Rellotge(const Rellotge& altre) {
    hores = altre.hores;
    minuts = altre.minuts;
    segons = altre.segons;
}

Si no declarem cap constructor de còpia, C++ en genera un automàticament que copia tots els membres un per un. En molts casos, doncs, no cal escriure'l explícitament. Però convé saber que existeix, perquè hi ha situacions (que veurem més endavant) en què cal implementar-lo manualment.

Treballar amb classes és el primer pas de l'orientació a objectes

La programació orientada a objectes (POO) té 3 principis principals, el primer dels quals és l'encapsulació (el fet de posar les coses en "càpsules" o "caixes"), que és el que hem aconseguit agrupant les tuples i els mètodes i ocultant certs membres per fer-los privats.

La terminologia que es fa servir en la POO inclou els 3 conceptes següents:

  1. Els mètodes, que són les operacions del TAD subjacent, que es posen com a membres de la classe.

  2. Les classes, que són tuples que introdueixen la privacitat per garantir la consistència.

  3. Els objectes, que són les variables que tenen com a tipus la classe en qüestió (en el nostre cas, els Rellotges).