Salta al contingut
Llegat fiber

Implementació de classes

Com s'escriu per dins una classe: fitxers .hh i .cc, atributs privats, l'invariant de la representació, operacions auxiliars i com ampliar un TAD sense trencar-lo. I com es compila tot junt.

Conceptes clau

  • Fitxers .hh i .cc, Classe::metode
  • Invariant de la representació
  • Operacions privades auxiliars
  • Ampliar un TAD: dins o fora de la classe
  • Compilació separada i Makefile

Per què?

Fins ara has fet servir classes des de fora: saps què fa cada mètode i ja està. Ara et toca l’altra cara: escriure la classe. Tens una especificació (la interfície) i has de decidir com guardar les dades i com fer que cada mètode compleixi el que promet.

Ho farem amb la classe Fraccio del capítol anterior.

Les fases

  1. Triar la representació: quins atributs tindrà cada objecte. Per a la Fraccio, dos enters, _num i _den.
  2. Escriure l’invariant de la representació: quines propietats compliran sempre aquests atributs (ara ho veuràs).
  3. Implementar cada mètode, públic i privat, aprofitant l’invariant i mantenint-lo.

Dos fitxers

Una classe es reparteix en dos fitxers:

  • Fraccio.hh: la declaració de la classe. La part public amb la capçalera i l’especificació de cada operació; la part private amb els atributs i les capçaleres de les operacions privades.
  • Fraccio.cc: el codi de tots els mètodes, públics i privats.

L’ideal seria que els atributs no sortissin al .hh, perquè formen part de la implementació. Però C++ els hi demana: per crear un objecte, el compilador ha de saber quant ocupa. Com que són private, ningú de fora no els pot fer servir igualment.

El fitxer .hh

#ifndef FRACCIO_HH
#define FRACCIO_HH

class Fraccio {
public:
    // Pre: cert. Post: la fracció 0/1.
    Fraccio();

    // Pre: den != 0. Post: la fracció num/den.
    Fraccio(int num, int den);

    // Pre: cert. Post: el paràmetre implícit val el que valia més f.
    void suma(const Fraccio& f);

    // ... (la resta de mètodes públics)

private:
    int _num, _den;
    // Invariant de la representació: _den > 0 i mcd(_num, _den) = 1.

    // Pre: _den != 0. Post: _num/_den és la mateixa fracció i compleix l'invariant.
    void simplifica();
};

#endif

Les tres línies de #ifndef, #define i #endif són una guarda d’inclusió. Si un programa inclou Fraccio.hh dues vegades (per exemple, directament i a través d’un altre .hh), sense guarda el compilador veuria la classe definida dues vegades i donaria error. Amb la guarda, la segona vegada FRACCIO_HH ja està definit i se salta tot el fitxer. Tot .hh ha de portar guarda.

El fitxer .cc

#include "Fraccio.hh"

Fraccio::Fraccio() {
    _num = 0;
    _den = 1;
}

Fraccio::Fraccio(int num, int den) {
    _num = num;
    _den = den;
    simplifica();
}

void Fraccio::suma(const Fraccio& f) {
    _num = _num * f._den + f._num * _den;
    _den = _den * f._den;
    simplifica();
}

int Fraccio::numerador() const {
    return _num;
}

bool Fraccio::es_igual(const Fraccio& f) const {
    return _num == f._num and _den == f._den;
}
  • Cada mètode porta davant Fraccio::, que diu de quina classe és. Sense això, seria una funció normal, sense paràmetre implícit.
  • Dins d’un mètode, _num és l’atribut del paràmetre implícit, i f._num és l’atribut de l’objecte f. Un mètode de Fraccio pot tocar els atributs privats de qualsevol Fraccio, no només del paràmetre implícit: el private protegeix la classe dels de fora, no els objectes els uns dels altres.
  • El const de les consultores s’ha de repetir al .cc. Si no hi és, el compilador creu que és un mètode diferent.

L’invariant de la representació

Amb dos enters es poden representar coses que no són fraccions vàlides (_den = 0) i també la mateixa fracció de moltes maneres (1/21/2, 2/42/4, −3/−6-3/-6…). Per evitar-ho, decidim que els atributs compliran sempre una propietat:

L’invariant és un compromís en dues direccions:

  • Cada constructora l’ha d’establir, i cada modificadora l’ha de mantenir. Per això Fraccio(num, den), suma, multiplica i inverteix acaben cridant simplifica().
  • Cada mètode el pot suposar en començar. Per això es_igual és tan curt: si totes dues fraccions estan simplificades i amb el denominador positiu, són iguals si i només si tenen els mateixos atributs. Sense l’invariant, hauria de comparar num⋅f.den=f.num⋅den_num \cdot f._den = f._num \cdot _den.
constructorainvariantmodificadorainvariantconsultora
Les constructores deixen l'objecte complint l'invariant; les modificadores el reben complint-lo i el tornen complint-lo. Les consultores el poden suposar i no el canvien.

L’invariant s’escriu com a comentari al .hh, al costat dels atributs. És per a qui implementa la classe, no per a qui la fa servir.

Operacions privades

simplifica() és a la part private: no és una operació del TAD, sinó una eina que fan servir els altres mètodes. Les operacions privades:

  • eviten repetir codi (aquí, el mateix codi de simplificar a quatre mètodes);
  • poden treballar amb objectes que temporalment no compleixen l’invariant (abans de simplificar, la fracció no el compleix). Per això no han de ser públiques: des de fora, un objecte sempre ha de complir-lo.
void Fraccio::simplifica() {
    if (_den < 0) {  // el signe, al numerador
        _num = -_num;
        _den = -_den;
    }
    int d = mcd(_num, _den);
    _num /= d;
    _den /= d;
}

Operadors

C++ et deixa definir què volen dir ==, <, <<… amb els teus tipus. Així pots escriure if (a == b) en lloc de if (a.es_igual(b)), o cout << f.

// Dins de la classe: un mètode, com qualsevol altre.
bool operator==(const Fraccio& f) const;

// Fora de la classe: l'objecte de l'esquerra és un ostream, no una Fraccio.
// Pre: cert. Post: escriu f a os en format num/den (o només num si den = 1).
ostream& operator<<(ostream& os, const Fraccio& f) {
    os << f.numerador();
    if (f.denominador() != 1) os << '/' << f.denominador();
    return os;
}

L’operator<< retorna el mateix ostream perquè es puguin encadenar: cout << a << ' ' << b. No és imprescindible definir operadors, però el codi que fa servir la classe queda més clar.

L’operator<< és una funció normal: al .hh (fora de la classe) hi poses només la capçalera, ostream& operator<<(ostream& os, const Fraccio& f);, i el codi va al .cc. Tots dos fitxers necessiten #include <iostream>.

Ampliar un TAD

Tens la Fraccio feta i provada, i un altre programa necessita una operació nova: elevar una fracció a una potència. Hi ha dues maneres de fer-ho.

Solució 1: modificar la classe. Afegeixes void eleva(int n); a la interfície i l’implementes a Fraccio.cc, amb accés directe a _num i _den.

Solució 2: una operació de fora. Fas un mòdul nou, per exemple OperacionsFraccio, amb funcions que només fan servir la interfície pública:

// Pre: n >= 0. Post: retorna f elevada a n.
Fraccio potencia(const Fraccio& f, int n) {
    Fraccio r(1, 1);
    for (int i = 0; i < n; ++i) r.multiplica(f);
    return r;
}
Solució 1: dins de la classe Solució 2: fora de la classe
Accés a la representació Sí: pot ser més eficient. No: només operacions públiques.
Cal tocar Fraccio? Sí: s’ha de tornar a provar, i tots els que la fan servir s’han de recompilar. No: la classe no canvia.
Si canvia la representació S’ha de reescriure. Continua funcionant.
Quan triar-la L’operació és pròpia del tipus i la faran servir molts programes, o només es pot fer bé amb accés als atributs. L’operació és d’un programa concret, o es pot fer bé amb les operacions que ja hi ha.

En el diagrama modular, la solució 2 afegeix un mòdul nou que fa servir Fraccio, i els programes que necessiten la potència fan servir aquest mòdul nou.

Compilar diversos fitxers

Cada .cc es compila per separat en un fitxer objecte (.o), i després s’enllacen tots els .o en un executable:

g++ -c Fraccio.cc        # Fraccio.cc → Fraccio.o
g++ -c main.cc           # main.cc → main.o
g++ -o programa.exe main.o Fraccio.o   # enllaça

Si després canvies només main.cc, n’hi ha prou de recompilar main.o i tornar a enllaçar. En un programa amb 50 fitxers, això estalvia molt temps. Els .hh no es compilen mai sols: s’inclouen dins dels .cc.

Fer-ho a mà és pesat. Un Makefile diu de quins fitxers depèn cada .o i com es fa; l’ordre make només recompila el que ha canviat (ho tens explicat amb calma al capítol Makefile). Aquest és el de la botiga del primer capítol:

OPCIONS = -std=c++17 -Wall -Wextra

all: botiga.exe

botiga.exe: main.o Producte.o Cistella.o
	g++ -o botiga.exe main.o Producte.o Cistella.o

main.o: main.cc Producte.hh Cistella.hh
	g++ $(OPCIONS) -c main.cc

Cistella.o: Cistella.cc Cistella.hh Producte.hh
	g++ $(OPCIONS) -c Cistella.cc

Producte.o: Producte.cc Producte.hh
	g++ $(OPCIONS) -c Producte.cc

clean:
	rm -f *.o botiga.exe

Cada regla té la forma objectiu: dependències, i a sota l’ordre per fer-lo, que ha de començar amb un tabulador (no amb espais). Fixa’t que Cistella.o depèn de Producte.hh: si canvies la interfície de Producte, Cistella s’ha de recompilar. make fa el primer objectiu (all) i make clean esborra els fitxers generats.

Errors habituals

Exercicis

ExerciciTroba els errors

Aquest Fraccio.cc té tres errors. Troba’ls.

#include "Fraccio.hh"

int Fraccio::denominador() {
    return _den;
}

void multiplica(const Fraccio& f) {
    _num *= f._num;
    _den *= f._den;
    simplifica();
}

void Fraccio::inverteix() {
    int aux = _num;
    _num = _den;
    _den = aux;
}
Mostra la solució
  1. denominador() sense const. Al .hh és int denominador() const;. Sense el const, el compilador diu que no troba cap declaració que hi encaixi.
  2. multiplica sense Fraccio::. És una funció normal: _num, _den i simplifica no hi existeixen.
  3. inverteix() no manté l’invariant. Si la fracció era −2/3-2/3, després queda 3/−23/-2: el denominador és negatiu. Cal cridar simplifica() al final (que posa el signe al numerador). No és un error de compilació, però és el més greu: el programa compila i dona resultats incorrectes.

ExerciciL'invariant del rellotge

Recorda el Rellotge dels TAD representat amb un sol enter _minuts, els minuts des de les 00:00.

  1. Quin és l’invariant de la representació?
  2. Aquesta implementació d’avanca el manté? Si no, arregla-la.
// Pre: minuts >= 0. Post: el rellotge marca aquests minuts més.
void Rellotge::avanca(int minuts) {
    _minuts = _minuts + minuts;
}
  1. Gràcies a l’invariant, quin mètode queda més simple?
Mostra la solució
  1. 0≤minuts<14400 \le _minuts < 14401440 (un dia té 24⋅60=144024 \cdot 60 = 1440 minuts).
  2. No. Si són les 23:00 (minuts=1380_minuts = 13801380) i avances 120 minuts, queda 1500, fora de l’invariant. Llavors hora() (que fa _minuts / 60) retornaria 25. Arreglat:
void Rellotge::avanca(int minuts) {
    _minuts = (_minuts + minuts) % 1440;
}
  1. hora() pot ser return _minuts / 60; i abans_que(r) pot ser return _minuts < r._minuts;, sense fer cap mòdul: l’invariant garanteix que els valors ja estan dins del dia.

ExerciciImplementa una classe

Implementa la classe Interval, que representa un interval tancat [a,b][a, b] d’enters, amb aquesta especificació. Escriu l’invariant de la representació i fes-lo servir.

class Interval {
public:
    Interval(int x, int y);   // Post: l'interval del més petit de x i y al més gran.
    int inici() const;        // Post: l'extrem esquerre.
    int fi() const;           // Post: l'extrem dret.
    int longitud() const;     // Post: quants enters conté.
    bool conte(int x) const;  // Post: si x és dins de l'interval.
    bool es_solapa(const Interval& i) const;  // Post: si tenen algun enter en comú.
    void uneix(const Interval& i);  // Post: el p.i. és el menor interval que conté
                                    //       el p.i. original i i.
};

Per exemple, Interval(7, 3) és [3,7][3, 7], té longitud 5, i unit amb [9,12][9, 12] dona [3,12][3, 12].

Mostra la solució

Representació: dos enters _a i _b. Invariant: a≤b_a \le _b. La constructora l’estableix (posa el més petit a _a), i uneix el manté perquè només pot fer _a més petit i _b més gran.

class Interval {
    ...
private:
    int _a, _b;
    // Invariant de la representació: _a <= _b.
};
Interval::Interval(int x, int y) {
    if (x <= y) {
        _a = x;
        _b = y;
    }
    else {
        _a = y;
        _b = x;
    }
}

int Interval::inici() const {
    return _a;
}

int Interval::fi() const {
    return _b;
}

int Interval::longitud() const {
    return _b - _a + 1;  // gràcies a l'invariant, mai no és negativa
}

bool Interval::conte(int x) const {
    return _a <= x and x <= _b;
}

bool Interval::es_solapa(const Interval& i) const {
    return _a <= i._b and i._a <= _b;
}

void Interval::uneix(const Interval& i) {
    if (i._a < _a) _a = i._a;
    if (i._b > _b) _b = i._b;
}

es_solapa és la part més delicada. Dos intervals no es toquen si un acaba abans que l’altre comenci: b<i.a_b < i._a o i.b<ai._b < _a. Negant-ho (llei de De Morgan): es toquen si a≤i.b_a \le i._b i i.a≤bi._a \le _b.

ExerciciDins o fora?

Vols afegir a la Fraccio una operació per saber si una fracció és més petita que una altra. Escriu-la com a solució 2 (fora de la classe, en un mòdul OperacionsFraccio). Per què es pot fer bé sense accedir als atributs?

Mostra la solució
// Pre: cert. Post: retorna si a és més petita que b.
bool mes_petita(const Fraccio& a, const Fraccio& b) {
    // Els denominadors són positius: p/q < r/s si i només si p*s < r*q.
    return a.numerador() * b.denominador() < b.numerador() * a.denominador();
}

Es pot fer bé des de fora perquè numerador() i denominador() donen tota la informació de la fracció, i l’especificació de denominador() garanteix que és positiu. Això importa: si un denominador fos negatiu, multiplicar en creu giraria la desigualtat. Per exemple, −1/2<−1/3-1/2 < -1/3 perquè −1⋅3=−3<−1⋅2=−2-1 \cdot 3 = -3 < -1 \cdot 2 = -2.

L’avantatge: Fraccio no canvia, no s’ha de tornar a provar, i si algun dia canvia la representació, mes_petita continua funcionant.

ExerciciEscriu el Makefile

Un programa té els fitxers main.cc, Jugador.hh, Jugador.cc, Partida.hh i Partida.cc. Partida fa servir Jugador, i el main fa servir tots dos. Escriu-ne el Makefile, amb un objectiu clean.

Mostra la solució
OPCIONS = -std=c++17 -Wall -Wextra

all: programa.exe

programa.exe: main.o Jugador.o Partida.o
	g++ -o programa.exe main.o Jugador.o Partida.o

main.o: main.cc Jugador.hh Partida.hh
	g++ $(OPCIONS) -c main.cc

Partida.o: Partida.cc Partida.hh Jugador.hh
	g++ $(OPCIONS) -c Partida.cc

Jugador.o: Jugador.cc Jugador.hh
	g++ $(OPCIONS) -c Jugador.cc

clean:
	rm -f *.o programa.exe

La clau és posar bé les dependències: cada .o depèn del seu .cc i de tots els .hh que inclou, directament o no. Si oblides posar Jugador.hh a les dependències de Partida.o i canvies la classe Jugador, make no recompilarà Partida.o i l’executable pot fallar de maneres molt estranyes. I recorda: les ordres comencen amb un tabulador.