STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

22
ISTITUTO ANGIOY - CARBONIA Programmazione Orientata agli Oggetti e C++ Prof. Gianfranco Ciaschetti

Transcript of STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

Page 1: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

ISTITUTO ANGIOY - CARBONIA

Programmazione Orientata agli Oggetti e C++

Prof. Gianfranco Ciaschetti

Page 2: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

1. Introduzione e prime nozioni di base

Finora abbiamo usato il C++ come linguaggio di programmazione per il computer. Il linguaggio C,

da cui il C++ proviene, è un linguaggio di programmazione di tipo procedurale, nel senso che un

programma scritto in C è costituito da una sequenza di istruzioni, eventualmente organizzate in

sottoprogrammi. Si procede, dunque, secondo il punto di vista del computer: esso sa fare alcune

cose, e noi gli diciamo cosa fare usando un linguaggio che esso sa interpretare, grazie all’aiuto del

compilatore per la traduzione (dal linguaggio di programmazione al linguaggio macchina).

Il C++, però è anche altro. Il linguaggio C originario nel C++ è stato arricchito di altri concetti che

permettono un modo diverso di programmare: programmare per oggetti.

Nella Programmazione Orientata agli Oggetti (che in inglese prende in nome di Object Oriented

Programming, o OOP), e in particolare nel linguaggio C++, le cose cambiano notevolmente.

Il C++ è un linguaggio dichiarativo, nel senso che un programma, piuttosto che da una sequenza

d’istruzioni come in C, è composto da un insieme di oggetti che sono in relazione tra di essi. Ogni

oggetto possiede degli attributi (dei dati) che ne descrivono le caratteristiche e/o lo stato, e

possiede inoltre dei metodi (delle funzioni) che permettono agli altri oggetti di interagire con esso.

Facciamo un esempio: l’oggetto cane potrebbe essere descritto dagli attributi nome, razza, età che

ne descrivono le caratteristiche, e dagli attributi posizione, umore che ne descrivono lo stato (ad

esempio, posizione potrebbe essere una variabile che può assumere i valori “seduto”, “accucciato”,

“normale”, “sollevato”), e dai metodi da_la_zampa( ), abbaia( ). Certo, un cane nella realtà è molto

di più di questo, ma noi scegliamo (è solo un esempio) che ai nostri fini ci interessano solo questi

attributi e questi metodi. Inoltre, potremmo non avere bisogno di includere nel nostro programma

anche un oggetto guinzaglio e un oggetto collare, che riteniamo poco importanti. Infine, ci potrebbe

essere un oggetto padrone, anch’esso con dei propri attributi e metodi, che chiede al proprio cane di

dare la zampa, cioè, invoca il metodo da_la_zampa( ) dell’oggetto cane.

Programmare in C++ significa allora osservare la realtà (detta dominio dell’applicazione), e

rappresentarla (o modellarla) con una serie di oggetti in qualche modo collegati tra loro.

L’attività di modellazione a oggetti prende il nome di Object Oriented Analysis (OOA) ed è una

fase molto delicata che richiede inevitabilmente un processo di astrazione: quali oggetti

rappresentare? E di ogni oggetto, quali attributi e metodi includere? Osserviamo che non tutti i

dettagli sono importanti ai nostri fini, ma solo alcuni. Facciamo un esempio: supponiamo di dover

creare un programma per la gestione dei dati degli studenti dell’Istituto. Sicuramente avremo

bisogno di definire un oggetto studente, specificandone i dati anagrafici e la classe, ma non il colore

dei capelli o i suoi hobby o il numero di compagni che ha, perché questi ultimi dati non sono

rilevanti ai fini del programma che andiamo a scrivere per gestire gli studenti della scuola. Un

oggetto-studente nella realtà ha tantissimi dati che lo descrivono, ma solo alcuni di questi ci servono

nell’applicazione che andiamo a sviluppare. Quindi, scegliamo di usare solo quelli rilevanti.

In generale, quindi, occorre concentrarsi su quegli aspetti della realtà che riteniamo significativi per

i nostri scopi. Si noti che realizzare un modello troppo semplice (pochi oggetti con pochi dati) può

risultare poco utile, mentre realizzare un modello troppo complesso (tanti oggetti con tanti dati) può

dar luogo a un programma difficilmente gestibile. Occorre fare, quindi, un compromesso che sia il

migliore possibile.

ESEMPIO:

OGGETTO: bicchiere

ATTRIBUTI: forma, capacità, colore, materiale, pieno_o_vuoto

METODI: riempi( ), svuota( )

Page 3: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

ESEMPIO:

OGGETTO: motore

ATTRIBUTI: cilindrata, alimentazione, num_cilindri, num_valvole

METODI: accendi( ), spegni( )

Oltre al C++, che è l’estensione a oggetti del linguaggio C, citiamo altri esempi famosi di linguaggi

di programmazione dichiarativi, cioè orientati agli oggetti:

- Simula67 (il primo)

- Smalltalk (il puro: tutti gli altri linguaggi derivano – in qualche modo – da un linguaggio

procedurale)

- Java (multipiattaforma, grazie alla macchina virtuale su cui si appoggia, adatto per

applicazioni su internet lato client)

- Delphi (evoluzione a oggetti del Turbo Pascal)

- PHP (per applicazioni web lato server)

- C# e VisualBasic.NET (evoluzioni di C++ e VisualBasic, rispettivamente, per applicazioni

web lato server).

Oltre al C, invece, sono linguaggi procedurali “classici” linguaggi come il Basic, il Cobol, il Pascal,

il Fortran. Esistono poi altri tipi di linguaggi che non sono né procedurali né dichiarativi, come i

linguaggi funzionali (Lisp, Logo, Caml), o i linguaggi logici (Prolog, Mercury), ma non fanno parte

di questa trattazione.

Tornando alla nostra OOP, il paradigma di programmazione “a oggetti” nasce dall’esigenza di

“modularizzare” il software (ricordate la scomposizione di un programma in funzioni?) facendo in

modo che ogni modulo sia autonomo e indipendente dagli altri.

Ricordando i record (cioè le struct in linguaggio C), gli oggetti o più propriamente le classi di

oggetti del C++ nascono perché si vuole dotare le struct di funzionalità, cioè non solo descrivere

come è fatto un record, ma anche definire cosa sa fare e come interagisce con le altre strutture.

In pratica, la OOP nasce perché si vogliono mettere delle funzioni dentro le struct.

2. Modellazione a oggetti: UML

Abbiamo detto che i linguaggi object oriented sono dichiarativi: programmare significa “dichiarare”

quali sono gli oggetti e quali sono le relazioni tra essi. Prima di fare questo nel linguaggio C++, è

opportuno disegnare un modello a oggetti, che descrive quali sono gli oggetti e quali le relazioni tra

essi.

Un tale modello prende il nome di “Modello a oggetti” o “Diagramma a oggetti” o “Diagramma

E/R” (dove E/R sta per entità/relazioni), ed è scritto usando un linguaggio di rappresentazione di

tipo grafico. Tra i linguaggi per rappresentare uno schema E/R, il più diffuso e quello che useremo

noi è l’UML (Unified Modeling Language).

ATTENZIONE: perché ora, parlando di diagramma E/R, parliamo di entità e relazioni tra entità, e

non di oggetti e relazioni tra oggetti? Cos’è un’entità? Un’entità del diagramma rappresenta una

classe di oggetti, tutti con stesse caratteristiche, e per questo motivo, basta rappresentarla una sola

volta per tutti gli oggetti “simili” nel diagramma.

Ad esempio, l’entità cane può rappresentare un insieme di oggetti di tipo cane, come il mio cane, il

tuo cane, quel cagnolino che sta all’ingresso della scuola, ecc.

Page 4: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

Un particolare cane, invece, come il mio cane, il tuo cane, quel cane di fronte a noi, è un oggetto,

detto anche istanza della classe.

Per rendere più chiaro il concetto, facciamo un parallelo col linguaggio C: un’entità descritta in

UML, che corrisponde a una classe di oggetti definita in C++, è come un tipo di dati in C: descrive

solo come è fatto un oggetto, ma non può memorizzarne nessuno. Un oggetto, invece, ha una sua

propria rappresentazione in memoria, e può quindi essere assimilato a una variabile in linguaggio C.

ESEMPIO:

LINGUAGGIO C/C++ LINUGAGGIO C/C++ LINGUAGGIO C++

int a;

/* int = tipo */

/* a = variabile */

struct cane {…};

struct cane c;

/* struct = tipo */

/* c = variabile */

class cane {…};

cane miocane;

/* classe = tipo */

/* miocane = variabile */

la variabile a permette di

memorizzare un dato di tipo

int.

la variabile c permette di

memorizzare un record di

tipo cane.

la variabile miocane permette di

memorizzare un oggetto di tipo

cane.

Il linguaggio di modellazione UML prevede di disegnare ogni entità in un box, come nell’esempio

che segue:

Nell’esempio, viene descritta l’entità BICCHIERE, che definisce quali sono gli attributi e i metodi

degli oggetti di questa classe. Ogni oggetto di tipo BICCHIERE avrà queste caratteristiche, e quindi

disporrà degli attributi forma, capacita, colore, materiale, pieno_o_vuoto, e i metodi riempi( ) e

svuota( ). Se abbiamo due oggetti B1 e B2 di tipo BICCHIERE, l’oggetto B1 avrà una sua forma,

una sua capacità, ecc., e altrettanto avrà l’oggetto B2.

In UML, per ogni entità, vengono descritti il suo nome, la lista degli attributi, e la lista dei

metodi. Si noti che occorre specificare per ogni attributo di che tipo di dato si tratta, e per ogni

metodo la dichiarazione o il prototipo della funzione, ossia, la firma seguita dal punto e virgola. Il

corpo delle funzioni, cioè cosa fa il metodo, verrà indicato solo a un grado di dettaglio maggiore,

ossia quando andremo a trasformare il diagramma in un programma in C++.

Si noti anche che agli attributi è stato dato un segno -, mentre ai metodi è stato dato un segno +.

Queste sono le regole di visibilità, che spiegheremo meglio più avanti parlando di incapsulamento.

Per il momento, possiamo brevemente introdurre questo concetto dicendo che gli attributi di un

oggetto sono suoi dati privati, cioè personali, e non dovrebbero poter essere accessibili dall’esterno.

Ad esempio, se io chiedo il nome all’oggetto di nome Pinco Pallino (istanza della classe Studente,

ad esempio), non sto accedendo direttamente alla sua informazione (che è riservata, c’è la privacy,

Page 5: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

non posso prendere dal portafogli la carta di identità di Pinco Pallino e leggere il suo nome), ma sto

invocando il suo metodo dimmiNome( ). L’oggetto che si chiama Pinco Pallino potrebbe

rispondermi con il suo nome vero, o con un altro nome, sono affari suoi.

Per tale motivo, gli attributi sono solitamente dichiarati di tipo private, ossia resi accessibili solo

all’oggetto stesso. Invece, i metodi sono dichiarati solitamente di tipo public perché devono poter

essere visti all’esterno e invocati da altri oggetti, come il metodo dimmiNome( ). I metodi, in

generale, sono pubblici perché sono quelli grazie ai quali l’oggetto interagisce con altri oggetti.

Tuttavia, sebbene questa è buona pratica, non è una regola generale, possono esserci delle

eccezioni. Un oggetto potrebbe avere un metodo privato che serve solo a lui e non ad altri oggetti,

come ad esempio il metodo pensa( ). Sta all’analista decidere, tra le diverse funzionalità da dare a

un oggetto, quali rendere visibili all’esterno e quali definire come private.

In un diagramma UML, oltre alle entità che abbiamo visto corrispondere a classi di oggetti,

abbiamo detto che compaiono anche le relazioni tra entità. Esse possono essere di diverso tipo:

a) relazioni gerarchiche ISA (letteralmente, is a in inglese)

servono per specificare che un’entità è un’altra entità.

Ad esempio: un bicchiere è (is a) un recipiente

Le relazioni ISA servono a stabilire gerarchie di oggetti, nelle quali si possono generalizzare

(come nel caso del recipiente) o specializzare (come nel caso del bicchiere) oggetti.

b) relazioni di aggregazione HAS (letteralmente, has in inglese)

servono per specificare che un’entità ha (contiene) un’altra entità.

Ad esempio: un’automobile ha (has) un motore (…e anche altri oggetti…)

Le relazioni HAS servono a definire aggregazioni di oggetti, ossia a indicare che un oggetto

è composto da altri oggetti. ATTENZIONE: non interpretare il verbo has in senso letterario.

Le relazioni HAS servono solo per definire aggregazioni, e non situazioni come quella in cui

“Mario has una fidanzata” oppure “Giovanni has i capelli lunghi”.

Page 6: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

c) relazioni di associazione

servono per specificare generiche associazioni per legare tra loro diverse entità.

Ad esempio: un impiegato lavora in un reparto

I nomi delle associazioni solitamente rappresentano verbi in forma attiva.

Negli ultimi due tipi di relazione possono comparire anche delle molteplicità, che indicano quanti

oggetti sono implicati nella relazione. Ad esempio, nella relazione HAS tra automobile e motore c’è

una molteplicità 0..1 per l’automobile, e una molteplicità 1 per il motore, per indicare che ogni

motore è associato a nessun automobile (se non è montato ancora su un’auto) oppure a una sola

automobile, mentre ogni automobile è associata sempre a un motore (quello su essa montato).

Sempre a titolo di esempio, tra impiegato e reparto c’è una molteplicità 1..n per l’impiegato e una

molteplicità 1 per il reparto (ogni impiegato lavora in un reparto, ogni reparto ha uno o più

impiegati).

Le possibili molteplicità sono:

0..1 relazione facoltativa con un solo oggetto

0..n relazione facoltativa con nessuno o più oggetti (numero generico)

1 relazione obbligatoria con un solo oggetto

1..n oppure n relazione obbligatoria con più oggetti (numero generico)

k relazione con esattamente k oggetti (con k determinato)

Nelle relazioni ISA solitamente non viene specificata la molteplicità, dato che è sempre la stessa per

tutte le relazioni di questo tipo: essa è 1 dalla parte della entità “padre” (quella che generalizza), 0..1

0..1 1

Page 7: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

dalla parte dell’entità “figlia” (quella che specializza). Ad esempio, tra le entità bicchiere e

recipiente dell’esempio di sopra, possiamo dire che un bicchiere è sempre un recipiente

(molteplicità obbligatoria 1 dalla parte dell’entità recipiente), mentre un recipiente può essere un

bicchiere o no (molteplicità facoltativa 0..1 dalla parte dell’entità bicchiere).

Per concludere questo paragrafo, diciamo che quando si disegna un diagramma UML si dovrebbe

stare attenti a disegnare solo linee orizzontali e verticali, cercando di non farle sovrapporre troppo,

per non rendere il diagramma illeggibile.

3. Dal diagramma E/R al programma in C++ Una volta disegnato il diagramma E/R, si ottiene il programma in C++ traducendo innanzitutto ogni

entità in una classe. Ad esempio, se riprendiamo l’entità BICCHIERE descritta due pagine fa, essa

viene tradotta in C++ nel seguente modo:

class Bicchiere { private: char forma[20]; float capacita; char colore[10]; char materiale[20]; int pieno_o_vuoto; public: void riempi(); void svuota(); };

Il programma C++ sarà allora costituito, innanzitutto, da un insieme di dichiarazioni di classe, una

per ogni entità del diagramma E/R. Ogni definizione di classe andrebbe inserita (ma si può anche

non farlo, a scapito però di una minore modularità del codice) in un opportuno file di interfaccia o

header file, che solitamente si chiama come la classe e ha estensione .h (a indicare, appunto, che si

tratta di un header file). Ad esempio, la definizione della classe Bicchiere andrà messa nel file

bicchiere.h. Tale file sarà reso visibile agli altri oggetti tramite la direttiva #include.

Si noti che, come in UML, anche qui nel file di interfaccia che contiene la definizione della classe,

non compare il corpo dei metodi. In base al principio di incapsulamento, che vedremo nel paragrafo

7.2, esso andrà messo in un file separato. Nulla vieta di fare in modo diverso, e inserire anche il

corpo delle funzioni nel file .h (e infatti faremo così nei primi esempi), ma vedremo poi che è

meglio di no.

Una volta tradotte le entità del diagramma E/R nelle classi in C++, bisogna tradurre in C++ anche le

diverse relazioni tra le entità.

- Le relazioni ISA, come vedremo tra poco nel paragrafo 7.1 parlando di ereditarietà, si

realizzano in C++ definendo una classe (la classe “figlia”, quella che specializza) come

classe derivata da un’altra classe (la classe “padre”, quella che generalizza).

- Le relazioni HAS si realizzano in C++ inserendo un oggetto o un puntatore all’oggetto come

attributo di un altro oggetto. Ad esempio, nella classe Automobile andremo a inserire un

attributo costituito da un oggetto (o un puntatore a un oggetto) della classe Motore. Nel

prossimo paragrafo, quando parleremo di creazione di oggetti, vedremo come fare.

- Le relazioni di associazione generica vengono tradotte in C++ facendo interagire tra loro gli

oggetti: un oggetto richiama i metodi di un altro oggetto. Vedremo come farlo nel paragrafo

5, quando parleremo di accesso agli attributi e ai metodi di un oggetto.

Page 8: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

4. Creazione e distruzione di oggetti

Come abbiamo già detto, la definizione di una classe non ci dà la possibilità di memorizzare degli

oggetti, piuttosto, definisce come essi sono fatti.

Una volta definita una classe in C++, come è possibile creare istanze (oggetti) della classe? Per

quanto riguarda la creazione, esiste la possibilità di creare due tipi di oggetti:

- oggetti statici

- oggetti dinamici

Gli oggetti statici vengono creati semplicemente dichiarando una variabile del tipo definito dalla

classe, sono memorizzati in un’area di memoria che si chiama stack, ed esistono finché è in

esecuzione il sottoprogramma nel quale sono dichiarati, esattamente come le variabili locali delle

funzioni in C.

Gli oggetti dinamici, invece, vengono creati con un’istruzione new, sono allocati in un’aria di

memoria chiamata heap, e restano in memoria finché non sono esplicitamente cancellati con

un’istruzione delete. L’istruzione new crea un nuovo oggetto istanza della classe, e restituisce

l’indirizzo di memoria dell’oggetto creato, che deve essere salvato in una variabile di tipo puntatore.

ESEMPIO:

Data, nel file bicchiere.h, la seguente definizione di classe:

class Bicchiere { … };

creiamo, ad esempio nel main, un oggetto statico e un oggetto dinamico della classe Bicchiere (non

è detto, in generale, che la creazione di oggetti debba avvenire per forza nel main)

#include "bicchiere.h" int main() { Bicchiere b; /* oggetto statico della classe Bicchiere */ /* l’oggetto b “vive” per tutta la durata del main */ Bicchiere *pb = new Bicchiere; /* oggetto dinamico della classe Bicchiere */

/* l’oggetto creato dalla new, il cui indirizzo è salvato nella variabile pb di tipo puntatore a bicchiere, resta in memoria finché non viene esplicitamente cancellato */

... ... delete pb; /* cancellazione dell’oggetto puntato da pb */ } /* cancellazione dell’oggetto b al termine del sottoprogramma */

ESEMPIO:

Data, nel file cane.h la seguente definizione di classe:

class Cane { ... };

creiamo, nel main, due oggetti statici e due oggetti dinamici della classe Cane

Page 9: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

#include "cane.h" int main() { Cane c1, c2; /* oggetti statici della classe cane */

/* gli oggetti c1 e c2 “vivono” per tutta la durata del sottoprogramma dove sono dichiarati */

... Cane *pc1, *pc2; /* dichiarazione di variabili di tipo puntatore a Cane */ pc1 = new Cane; /* primo oggetto dinamico della classe Cane, il suo

indirizzo è memorizzato nel puntatore pc1 */

pc2 = new Cane; /* secondo oggetto dinamico della classe Cane, il suo indirizzo è memorizzato nel puntatore pc2 */

... delete pc1; /* cancellazione dell’oggetto puntato da pc1 */ delete pc2; /* cancellazione dell’oggetto puntato da pc1 */ }

5. Accesso agli attributi e ai metodi di un oggetto

Per accedere agli attributi e ai metodi di un oggetto statico in C++ si usa l’operatore . (punto),

esattamente come si fa per accedere ai campi di un record (una struct) in C.

Per accedere agli attributi e ai metodi di un oggetto dinamico in C++ si usa l’operatore -> (freccia).

ESEMPIO:

Riprendendo l’esempio precedente, supponiamo di avere due oggetti della classe Bicchiere:

l’oggetto statico b e l’oggetto dinamico il cui indirizzo è memorizzato nel puntatore pb, creati nel

seguente modo:

Bicchiere b; Bicchiere *pb = new Bicchiere;

OGGETTO STATICO: Per accedere, ad esempio, all’attributo forma dell’oggetto b, scriveremo b.forma

OGGETTO DINAMICO: Per accedere, ad esempio, all’attributo forma dell’oggetto puntato da pb,

scriveremo pb->forma

La stessa notazione si usa anche per i metodi:

OGGETTO STATICO: Per invocare, ad esempio il metodo svuota( ) dell’oggetto b scriveremo b.svuota( )

OGGETTO DINAMICO: Per invocare, ad esempio, il metodo svuota( ) dell’oggetto pb scriveremo

pb->svuota( ).

Page 10: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

6. Metodi costruttori e metodi distruttori Quando si crea un nuovo oggetto, è possibile eseguire contestualmente delle operazioni

“collaterali”, come l’inizializzazione dell’oggetto, o l’aggiornamento di un contatore del numero di

oggetti creati, o la visualizzazione di un messaggio, o qualsiasi altra operazione vogliamo fare al

momento della creazione dell’oggetto. Un po’ come, quando nasce un bambino, viene registrato il

suo nome all’anagrafe, si appende il nastro alla porta, ecc.

Questo è possibile definendo nella classe uno o più metodi costruttori, che vengono invocati

all’atto della creazione dell’oggetto. I metodi costruttori:

- hanno lo stesso nome della classe

- non hanno un tipo di ritorno

- possono essere più di uno purché abbiano differente firma

- sono invocati automaticamente all’atto della creazione di un oggetto

ESEMPIO (solo a titolo di esempio, inseriamo il corpo dei metodi all’interno della definizione della

classe. Vedremo successivamente che, invece, il corpo delle funzioni va messo da un’altra parte).

class Punto { private: float coordx; float coordy; public: Punto() { // metodo costruttore senza parametri: coordx = 0; coordy = 0 // inizializza un punto nell’origine }

Punto(float x, float y) { // metodo costruttore con due parametri coordx = x; // di tipo float: inizializza un punto coordy = y // nelle coordinate passate come parametri }

};

METODO COSTRUTTORE ALLA CREAZIONE DI UN OGGETTO STATICO:

Nella creazione di un oggetto statico della classe Punto, avendo a disposizione due diversi metodi

costruttori (uno senza parametri e uno con parametri), possiamo scegliere tra le seguenti tre

istruzioni

Punto p1; // crea un oggetto e invoca implicitamente il costruttore vuoto Punto p1(); // crea un oggetto e invoca esplicitamente il costruttore vuoto Punto p1(4,5); // crea un oggetto e invoca esplicitamente il costruttore con

// parametri (che inizializza l’oggetto alle coordinate 4 e 5) */

Se invece non è stato definito nessun metodo costruttore, ovviamente non ne parte nessuno e

possiamo creare un oggetto statico della classe Punto solo nel primo modo, e cioé:

Punto p1; // crea un oggetto statico della classe Punto

Page 11: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

METODO COSTRUTTORE ALLA CREAZIONE DI UN OGGETTO DINAMICO:

Allo stesso modo, quando creiamo un oggetto dinamico della classe Punto, abbiamo tre possibili

scelte:

Punto *p2 = new Punto; // crea un oggetto dinamico e invoca implicitamente il // costruttore vuoto

Punto *p2 = new Punto(); // crea un oggetto dinamico e invoca esplicitamente // Il costruttore vuoto

Punto *p2 = new Punto(4,7); // crea un oggetto dinamico e invoca // esplicitamente il costruttore con parametri

Come per gli oggetti statici, se non è stato definito nessun metodo costruttore della classe è

possibile creare un oggetto dinamico solo nel primo modo.

Oltre ai metodi costruttori, è possibile definire dei metodi distruttori della classe, che vengono

eseguiti automaticamente al momento della cancellazione di un oggetto. Servono per, ad esempio,

decrementare un contatore degli oggetti, oppure per visualizzare sul monitor un messaggio che

indica la avvenuta cancellazione, o per notificare la cancellazione ad altri oggetti.

I metodi distruttori:

- hanno lo stesso nome della classe, preceduto dal simbolo ~

- non hanno un tipo di ritorno

- ci può essere un solo distruttore per ogni classe (senza parametri)

- sono invocati automaticamente con un’istruzione delete

Ad esempio, supponiamo di avere una variabile globale num_punti che memorizza il numero di

oggetti creati della classe Punto, e supponiamo di aver definito un metodo distruttore come segue

(anche qui, solo per chiarezza di esposizione, inseriamo il corpo dei metodi all’interno della

definizione della classe):

class Punto { ... public: ... ~Punto() {num_punti--}; /* metodo distruttore */ }

Quando viene eliminato un oggetto della classe punto (cioè quando termina l’attivazione del

sottoprogramma nel quale è dichiarato l’oggetto statico, oppure viene eseguita una delete per un

oggetto dinamico), la variabile num_punti viene decrementata di un’unità.

APPROFONDIMENTO: Copy constructor

È possibile creare un oggetto come copia di un altro oggetto invocando un costruttore copia.

ESEMPIO:

// file punto.h class Punto { ...

Page 12: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

Punto(const Punto &p) { // costruttore copia: inizializza il punto come il coordx=p.coordx; // parametro coordy=p.coordy; };

// file main.cpp Punto p1(2,3); p1.DoveSono(); // visualizza le coordinate x=2, y=3 del punto p1

Punto p2(p1); // crea un punto p2 come copia di p1 p2.DoveSono(); // visualizza le coordinate x=2, y=3 del punto p2

7. Caratteristiche della programmazione a oggetti

Le principali caratteristiche della programmazione a oggetti, che ne fanno uno strumento

estremamente potente e versatile, oltre all’aspetto dichiarativo illustrato in precedenza, sono

l’ereditarietà, l’incapsulamento e il polimorfismo.

7.1 EREDITARIETÀ

Per ereditarietà si intende la possibilità di definire una classe di oggetti (detta sottoclasse) come

derivata da un’altra classe (detta superclasse), in una relazione gerarchica di tipo ISA.

La sottoclasse eredita tutti gli attributi e i metodi della superclasse, e in più può averne di propri.

La sottoclasse risulta dunque essere una specializzazione della superclasse, che quindi risulta essere

una generalizzazione della sottoclasse.

Il meccanismo dell’ereditarietà permette di risparmiare molto tempo nella programmazione, ad

esempio includendo in una superclasse tutti gli attributi e i metodi comuni a diverse classi di

oggetti.

ESEMPIO

Class Impiegato Class Docente: Impiegato Class Tecnico: Impiegato

{ { {

private: private: private:

char nome[20]; char materia[20]; char lab [5];

char cognome[20]; }; };

int eta;

};

Le classi Docente e Tecnico sono dichiarate come sottoclassi della superclasse Impiegato. Come

tali, ereditano tutti gli attributi della superclasse, quindi, ogni docente avrà gli attributi nome,

cognome, eta e materia, mentre ogni tecnico avrà gli attributi nome, cognome, eta e lab.

È sempre conveniente definire delle superclassi qualora si voglia non ripetere la definizione di certi

attributi o metodi.

APPROFONDIMENTO: Metodi costruttori ed ereditarietà: i metodi costruttori non sono ereditati,

ma vengono invocati tutti i metodi costruttori vuoti (senza parametri) della “gerarchia”, a partire

da quello più in alto, se sono definiti. Solo al livello più basso può partire un diverso metodo

costruttore con i parametri. Ad esempio, se abbiamo in relazione ISA le classi

Impiegato Docente DocenteLaboratorio

quando creiamo un oggetto della classe DocenteLaboratorio, partiranno nell’ordine:

1) il metodo costruttore vuoto della classe Impiegato (se definito)

Page 13: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

2) il metodo costruttore vuoto della classe Docente (se definito)

3) il metodo costruttore della classe DocenteLaboratorio (se definito, implicito o esplicito, con

parametri o senza)

APPROFONDIMENTO: Metodi distruttori ed ereditarietà: i metodi distruttori non sono ereditati,

ma vengono invocati tutti i metodi distruttori della “gerarchia”, a partire da quello più in basso, se

sono definiti. Ad esempio, se abbiamo in relazione ISA le classi

Impiegato Docente DocenteLaboratorio

quando viene cancellato un oggetto della classe DocenteLaboratorio, partiranno nell’ordine:

1) il metodo distruttore della classe DocenteLaboratorio (se definito)

2) il metodo distruttore della classe Docente (se definito)

3) il metodo distruttore della classe Impiegato (se definito)

7.2 INCAPSULAMENTO

Per incapsulamento si intende la possibilità di proteggere i dati di un oggetto, per evitare errori di

programmazione, e rendere più modulare la scrittura dei programmi, in modo da avere così un

codice maggiormente robusto e riusabile.

L’incapsulamento permette ad altri oggetti di interagire con un dato oggetto solo attraverso i suoi

metodi.

L’incapsulamento si ottiene in due modi:

1. Regole di visibilità e Metodi Get e Set

2. Separando interfaccia e implementazione dei metodi, ossia scrivendo solo la

firma – insieme con la definizione della classe - in un file .h (header, interfaccia)

e il corpo in un file .cpp (implementazione).

Regole di visibilità:

Abbiamo già visto le parole chiave private e public. Ricordiamo che un attributo o un metodo

definito come private è visibile solo all’oggetto stesso, mentre un attributo o un metodo definito

come public è visibile a tutti. Oltre a queste, possiamo usare anche le parole chiave protected e

static.

- Un attributo definito come protected può essere visto solo dalle sottoclassi, ossia da tutti gli

oggetti che sono in relazione ISA con l’oggetto stesso. Ad esempio, se ricordiamo la

relazione ISA tra Bicchiere e Recipiente, e supponendo di avere l’oggetto b della classe

Bicchiere e l’oggetto r della classe Recipiente, b può accedere agli attributi di r.

- Un attributo definito come static si dice anche variabile di classe (e non di istanza), in

quanto ne esisterà una sola copia per tutti gli oggetti della classe, a differenza degli altri

attributi dei quali ne abbiamo uno per ogni oggetto della classe (che infatti non si chiamano

variabili di classe, ma variabili di istanza). Un attributo static è visibile solo a tutti gli

oggetti della classe, a meno che non venga definito esplicitamente come pubblico.

Page 14: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

APPROFONDIMENTO: Una variabile definita come static è una variabile che non viene deallocata

al termine dell’esecuzione del sottoprogramma nel quale è dichiarata. Viene spesso usata nelle

funzioni ricorsive che devono accedere sempre alla stessa zona di memoria, anziché allocare nuova

memoria per le variabili locali a ogni chiamata.

APPROFONDIMENTO: le regole di visibilità definiscono anche le regole per l’ereditarietà di

attributi e metodi. In particolare, se gli attributi sono definiti come private nella superclasse, essi

non vengono ereditati nella sottoclasse! Per permettere l’ereditarietà, occorre definirli invece come

protected. Inoltre, perché avvenga l’ereditarietà, occorre che una sottoclasse sia derivata

pubblicamente dalla superclasse, nel seguente modo:

class A : public B { };

Metodi Get e Set:

Per poter accedere dall’esterno alle variabili di istanza, cioè agli attributi di un oggetto, essendo

queste dichiarate come private, occorre in genere definire dei metodi Get che, una volta invocati,

permettono all’oggetto di rendere noti all’esterno i propri dati. Allo stesso modo, occorre (solo

quando ce n’è bisogno) definire dei metodi Set per poter modificare i dati di un oggetto

dall’esterno. Riprendendo l’esempio della classe Punto, possiamo definire i seguenti metodi (di

nuovo, includiamo il corpo dei metodi nella definizione della classe solo per facilità di esposizione):

class Punto { private: … attributi… public: … eventuali costruttori e distruttore … … eventuali altri metodi … float GetX(){ return coordx;} /* restituisce la coordinata x del punto */ float GetY(){ return coordy;} /* restituisce la coordinata y del punto */ void SetX (float newx) { coordx = newx; /* modifica la coordinata x del punto */ } void SetY (float newy) { coordy = newy; /* modifica la coordinata y del punto */ } };

APPROFONDIMENTO: Funzioni friend

Una funzione definita come friend all’interno della classe è una funzione esterna alla quale

permettiamo l’accesso agli attributi e ai metodi privati della classe. È utile, ad esempio,

nell’overloading degli operatori di confronto (li vedremo più avanti).

ESEMPIO:

// file punto.h class Punto { ... friend void Ruota(Punto *p); // dichiara la funzione esterna come friend

Page 15: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

// file main.cpp void Ruota(punto *p) { // funzione esterna alla classe for(i=coordx; i>=-coordx; i--) { cout << "x=" << i << "y=" << circle(i) << endl; // equazione del cerchio }; int main() { Punto *apoint = new Punto; Ruota(apoint);

}

APPROFONDIMENTO: Il puntatore this

Per evitare ambiguità di identificatore, ci si può riferire all’oggetto “corrente” con il puntatore this,

il quale significa letteralmente “questo oggetto”.

ESEMPIO:

// metodo set per l’attributo coordx della classe punto

void SetX(float coordx) {

this->coordx=coordx; // assegna il parametro all’attributo membro dell’oggetto

}

Separazione tra interfaccia e implementazione:

Si tratta, come abbiamo detto più volte fino a questo punto, di includere solo la firma dei metodi

insieme alla definizione della classe, in modo che altri oggetti possano vedere solo cosa un oggetto

sa fare, ma non come sa farlo.

Come già detto, la definizione della classe con la firma dei metodi (cosa l’oggetto sa fare) viene

posta in un file di intestazione (header) con estensione .h con lo stesso nome della classe, che verrà

reso visibile agli altri oggetti con la direttiva include. Il nome del file uguale a quello della classe

non è necessario, ma utile per avere il programma più modulare e manutenibile.

Il corpo dei metodi della classe, cioè come viene svolta una certa funzionalità, invece, viene posto

in un file di implementazione con estensione .cpp, che non verrà reso visibile all’esterno, che ha

anch’esso lo stesso nome della classe. Anche qui, non è indispensabile avere lo stesso nome della

classe come nome del file, ma è preferibile per i motivi esposti sopra.

La separazione tra interfaccia e implementazione permette una maggior protezione dei dati: ogni

oggetto è visto come un modulo autonomo: solo lui conosce i propri dati (gli attributi) e come

esegue le proprie funzionalità (il corpo dei metodi). Gli oggetti “esterni” possono interagire con lui

solo attraverso i suoi metodi pubblici, di cui devono conoscere solo la firma, e cioè:

- Come si chiama il metodo, per poterlo invocare

- Quali parametri accetta, per sapere cosa passargli

- Cosa restituisce

Facciamo un esempio: se qualche oggetto, della stessa classe o di altre classi, vuole accedere

all’attributo nome di un oggetto u1 della classe Uomo,

- Non può accedere direttamente all’attributo – che è privato – invocando u1.nome oppure

u1->nome (a seconda che l’oggetto sia statico o dinamico)

Page 16: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

- Può accedere al metodo pubblico u1.GetNome(), perché ne conosce l’esistenza, se include il

file uomo.h con la direttiva #include "nome.h". A questo punto, u1 esegue il suo metodo

GetNome() e risponde, ma non è detto che debba per forza dire il suo nome: cosa accade

all’interno del corpo del metodo GetNome()sono fatti suoi! Egli potrebbe rispondere con il

suo vero nome, o con qualsiasi altra cosa. Il "come" è nel file di implementazione .cpp

La separazione tra interfaccia e implementazione permette inoltre una maggior riusabilità e facilità

di mantenimento del programma: è possibile, infatti, cambiare in futuro solo l’implementazione di

un metodo, senza dover ricompilare l’intero programma, ma solo il file .cpp che lo contiene, mentre

tutto il resto continua a funzionare così com’è.

Un programma in C++ sarà allora composto da:

- un file di progetto, con estensione .dev in DevCpp, o con estensione .cbp in CodeBlocks

- un insieme di file di interfaccia o header files, con estensione .h, contenenti le definizioni

delle classi con la firma dei metodi

- un insieme di file di implementazione, con estensione .cpp, contenenti le definizioni delle

classi con il corpo dei metodi

- un file main.cpp con il main del programma

Riprendendo l’esempio della classe Punto, scriveremo la definizione della classe nel file punto.h:

class Punto { private: float coordx; float coordy; public: Punto(); Punto(float x, float y);

float GetX(); float GetY(); void SetX(float newx); void SetY(float newy);

};

e l’implementazione dei metodi nel file punto.cpp

#include "punto.h"

Punto::Punto() // classe Punto, implementazione del metodo Punto() {

coordx = 0; coordy = 0 };

Punto::Punto(float x, float y) // classe Punto, implementazione del { // metodo Punto(float x, float y) coordx = x; coordy = y; };

float Punto::GetX() // classe Punto, implementazione del metodo GetX() { return coordx; };

Page 17: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

float Punto::GetY() // classe Punto, implementazione del metodo GetY() { return coordy; };

void Punto::SetX(float newx) // classe Punto, implementazione del metodo { // SetX() coordx = newx; };

void Punto::SetY(float newy) // classe Punto, implementazione del metodo { // SetY() coordy = newy; };

Tutti gli oggetti che dovranno interagire con oggetti della classe Punto dovranno, inevitabilmente,

conoscere la definizione della classe Punto, e quindi includere il file punto.h

Ovviamente, anche il file di implementazione punto.cpp dovrà conoscere la definizione della classe,

e quindi dovrà avere la stessa direttiva per includere l’header file.

Si noti, nel file di implementazione, l’uso dell’operatore di visibilità (anche detto operatore di

portata o scope operator) :: per evidenziare che si sta implementando un metodo definito in una

classe.

APPROFONDIMENTO: Inclusioni transitive

Le inclusioni dei file godono della proprietà transitiva. Ciò significa che, ad esempio, in una

gerarchia ISA come quella seguente

Impiegato Docente DocenteLaboratorio

possiamo inserire:

1) #include "docentelaboratorio.h" nel file main.cpp

2) #include "docente.h" nel file docentelaboratorio.h

3) #include "impiegato.h" nel file docente.h

e vedere, in questo modo, tutte e tre le classi nel main.

APPROFONDIMENTO: Definizione condizionale di classe

Può accadere che si incorra nell’errore "redefinition of class... " generato dal compilatore,

quando un file con la definizione di una classe viene incluso due volte. Ad esempio, se abbiamo:

Impiegato Docente

Impiegato Tecnico

e scriviamo le seguenti inclusioni

1) #include "docente.h" nel file main.cpp

2) #include "tecnico.h" nel file main.cpp

3) #include "impiegato.h" nel file docente.h

4) #include "impiegato.h" nel file tecnico.h

il file impiegato.h viene incluso due volte. Per evitare l’errore, possiamo usare la definizione

condizionale di classe, che evita di ridefinire la classe se la definizione è già stata incontrata una

prima volta.

Page 18: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

#ifndef IMPIEGATO_H #define IMPIEGATO_H class Impiegato { ... }; #endif

7.3 POLIMORFISMO

Si intende per polimorfismo la possibilità di avere più versioni dello stesso metodo per oggetti di

classi diverse. Un esempio è quello che accade anche in C con l’operatore di divisione / che

applicato a operandi di tipo intero esegue la divisione intera (quoziente), mentre applicato a

operandi di tipo reale (float o double) esegue la divisione con virgola (5/2 restituisce 2, mentre

5.0/2.0 restituisce 2.5). L’utilità di avere funzioni polimorfe sta nel fatto che non dobbiamo

preoccuparci di dover assegnare nomi diversi a metodi simili tra loro, e risulta molto utile se usata

insieme all’ereditarietà.

La ridefinizione di un metodo viene chiamata generalmente overloading, mentre nel caso in cui una

sottoclasse ridefinisca un metodo della superclasse si parla di overriding.

Il polimorfismo si può ottenere in diversi modi:

7.3.1 DEFINENDO UNA FUNZIONE POLIMORFA (OVERLOADING)

È il caso di una funzione che si comporta in modo diverso su diversi tipi di dati.

ESEMPIO:

// prodotto tra interi int prodotto(int a, int b) { cout << "interi" << endl; return a*b; } // prodotto tra float float prodotto(float a, float b) { cout << "reali" << endl; return a*b; } int main() { cout << prodotto(2,3) << endl; cout << prodotto(3.14,1.5) << endl;

}

7.3.2 DEFINENDO UNA STESSA FUNZIONE IN DUE DIVERSI NAMESPACE

È il caso di una funzione definita due volte con stessa firma, ma le sue due definizioni compaiono in

due diversi namespace, e vengono invocati esplicitando l’uno o l’altro.

Page 19: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

ESEMPIO: namespace decimale { void stampa(int numero) { cout << numero << endl; } }

namespace binario { void stampa(int numero) { dectobin(numero); } }

int main() { … if(scelta=="decimale") decimale::stampa(num); else binario::stampa(num);

}

Come sappiamo già fare per lo spazio dei nomi standard, possiamo anche evitare di usare

l’operatore di visibilità :: nel seguente modo:

using namespace binario; stampa(num); // stampa il numero in binario

APPROFONDIMENTO: L’operatore di visibilità ::

Notando che nel definire l’implementazione di un metodo usiamo l’operatore di visibilità :: proprio come quando dobbiamo accedere a un namespace, possiamo dedurre che:

LA DEFINIZIONE DI UNA CLASSE DEFINISCE UN NAMESPACE CON IL NOME DELLA

CLASSE!

7.3.3 RIDEFINENDO UNA FUNZIONE IN UNA SOTTOCLASSE (OVERRIDING)

Un metodo di una superclasse, sebbene ereditato dalla sottoclasse, può in essa essere ridefinito

(overrided). Questo è utile quando il metodo deve comportarsi in modo diverso per un oggetto della

sottoclasse che, in generale, ha attributi in più che lo specializzano rispetto alla superclasse.

ESEMPIO:

class Punto { float coordx; float coordy; ... void dovesono() { cout << coordx << "," << coordy << endl; } };

class Punto3D : public Punto { float coordz; ... void dovesono() { cout << coordx << "," << coordy << "," << coordz << endl; } };

Supponendo di avere i seguenti oggetti:

Punto p; Punto3D p3d;

Page 20: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

L’invocazione del metodo p.dovesono() provoca l’esecuzione del metodo della superclasse,

mentre l’invocazione del metodo p3d.dovesono() provoca l’esecuzione del metodo della

sottoclasse.

APPROFONDIMENTO: Late binding

Nel C++ l’associazione tra puntatori e oggetti puntati, in caso di ereditarietà, avviene a run-time

(durante l’esecuzione del programma) e non a compile-time (durante la compilazione).

Questa caratteristica prende il nome di late binding, e permette, tra l’altro, di associare un puntatore

alla superclasse a un oggetto della sottoclasse.

ESEMPIO:

class Quadrilatero : public Poligono {...}; Quadrilatero q; Poligono *p = &q; // OK: un puntatore a un oggetto della superclasse può

// puntare a un oggetto della sottoclasse (l’associazione al // tipo avviene a run-time) */

Poligono p; Quadrilatero *q = &p; // ERRORE: un puntatore a un oggetto della sottoclasse

// non può puntare a un oggetto della superclasse (se ne // accorge già il compilatore) */

APPROFONDIMENTO: Metodi virtuali

Un metodo della superclasse può essere definito virtual quando bisogna avere una sua

implementazione solo per le sottoclassi. Questo è molto utile quando un puntatore alla superclasse

punta a un oggetto della sottoclasse, e vogliamo che venga eseguito il metodo della sottoclasse.

ESEMPIO:

// file poligono.h class Poligono { ... virtual float Area() { } // implementazione vuota per il metodo virtuale };

// file quadrato.h class Quadrato : public Poligono { ... float Area() { return lato*lato; } };

// file rombo.h class Rombo : public Poligono { ... float Area() { return d1*d2/2; } };

// file main.cpp Poligono *poli[2]; poli[0] = new Quadrato(10); // poli[0] punta a un oggetto di tipo Quadrato poli[1] = new Rombo(3,5); // poli[1] punta a un oggetto di tipo Rombo for(i=0; i<2; i++) cout << poli[i]->Area() << endl; // la clausola virtual nella superclasse fa

// sì che venga eseguito il corrispondente // metodo della sottoclasse.

// in assenza della clausola virtual, partirebbe il metodo della superclasse

Page 21: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

7.3.4 OVERLOADING DI OPERATORI

In C++ è possibile ridefinire (o sovraccaricare, overload) il comportamento degli operatori del

linguaggio, perché in effetti essi sono delle vere e proprie funzioni e come tali possono essere

riscritte.

NOTA BENE: tutti gli operatori possono essere overloaded!

(aritmetici, booleani, assegnamento, incremento, operatore ::, operatore &, operatore *, ecc.)

A titolo di esempio, vediamo come è possibile ridefinire gli operatori di assegnamento = per

assegnare un oggetto a un altro oggetto, e l’operatore di confronto == per confrontare due oggetti.

Overloading dell’operatore di assegnamento =

Vogliamo ridefinire, ad esempio, l’operatore di assegnamento = per assegnare un array di caratteri a

un altro, proprio come fa la funzione strcpy della libreria string.h. Inizialmente, creiamo una classe

e degli oggetti che contengano la stringa

// file stringa.h class Stringa { char s[20]; public: Stringa operator= (const char *source); // overload dell’operatore = void stampa(); // visualizza la stringa };

// file stringa.cpp Stringa Stringa::operator= (const char *source) // la stringa da copiare è il { // parametro, la stringa for(int i=0; source[i]!='\0'; i++) // restituita è la copia this->s[i] = source[i]; } void Stringa::stampa() { cout << s << endl; // visualizza la stringa }

// file main.cpp Stringa s1,s2; s1 = “Peppino”; // uso dell’operatore = overloaded s2 = s1; // uso dell’operatore = overloaded s1.stampa(); // visualizza “Peppino” s2.stampa(); // visualizza “Peppino”

NOTA BENE: Perché possa avvenire l’assegnamento, occorre che la classe abbia un solo attributo

da assegnare! Se gli attributi sono più di uno, l’overloading dell’assegnamento non funziona.

Overloading dell’operatore di confronto ==

L’overloading dell’operatore di confronto non richiede il vincolo di un solo attributo, perché il

confronto dà luogo a un valore booleano che possiamo combinare con altri valori booleani in

espressioni logiche più complesse. Quindi potremmo, ad esempio, definire uguali tra loro due

oggetti appartenenti alla classe NumeroComplesso solo se hanno stessa parte intera e stessa parte

immaginaria. Per fare questo, abbiamo bisogno di dichiarare il metodo overloaded come funzione

friend, quindi esterna alla classe.

Nel seguente esempio, vediamo l’overloading dell’operatore di assegnamento = e dell’operatore di

confronto ==

Page 22: STITUTO ANGIOY CARBONIA Programmazione Orientata agli ...

// file A.h class A { private: int n; public: void stampa(); A operator=(const int x); // overload di = friend bool operator==(const A &a1, const A &a2); // overload di == };

// file A.cpp A A::operator= (const int x) // overload dell’operatore = { n = x; }

void A::stampa()

{

cout << n << endl; // visualizza l’attributo n

}

// file main.cpp bool operator==(const A &a1, const A &a2) // overload dell’operatore == { return (a1.n == a2.n); } int main() { A s1,s2; s1 = 3; // operatore di assegnamento overloaded s2 = 8; // operatore di assegnamento overloaded if(s1==s2) // operatore di confronto overloaded cout << "uguali" << endl; else cout << "diversi" << endl;