• Non ci sono risultati.

Nascondere l’implementazione

Nel documento Pensare in C++, seconda ed. Volume 1 (pagine 174-189)

Fase 5: Evoluzione

5: Nascondere l’implementazione

Una tipica libreria C contiene una struct ed alcune funzioni associate per agire su essa. Si è visto come il C++ prenda funzioni che sono associate concettualmente e le renda associate letteralmente

mettendo le dichiarazioni della funzione dentro lo scope della struct, cambiando il modo in cui le funzioni vengono chiamate per una struct, eliminando il passaggio dell'indirizzo di struttura come primo argomento ed aggiungendo un nuovo tipo di nome al programma ( quindi non si deve creare un typedef per la struct).

Tutto ciò aiuta ad organizzare il proprio codice e lo rende più facile da scrivere e leggere. Tuttavia ci sono altri problemi quando si creano librerie in C++, specialmente

problematiche riguardanti il controllo e la sicurezza. Questo capitolo prende in esame il problema dei limiti nelle strutture.

Fissare i limiti

In qualsiasi relazione è importante avere dei limiti che sono rispettati da tutte le parti coinvolte. Quando si crea una libreria, si stabilisce una relazione con il programmatore

client che usa quella libreria per costruire un'applicazione o un'altra libreria.

In una struct del C, come per la maggior parte delle cose in C, non ci sono regole. I programmatori client possono fare qualsiasi cosa vogliono e non c'è modo di forzare nessun particolare comportamento. Per esempio, nell'ultimo capitolo anche se si capisce l'importanza delle funzioni chiamate inizialize() e cleanup(), il programmatore client ha l'opzione di non chiamare quelle funzioni ( osserveremo un miglior approccio nel prossimo capitolo). E anche se davvero si preferirebbe che il programmatore client non manipolasse direttamente alcuni dei membri della nostra struct, in C non c'è modo di prevenirlo. Ogni cosa è palese al mondo.

Ci sono due ragioni del perchè si deve controllare l'accesso ai membri. La prima serve a tenere lontane le mani del programmatore client dalle cose che non devono essere toccate, parti che sono necessarie al funzionamento interno dei tipi di dato, ma non parti

dell'interfaccia di cui il programmatore client ha bisogno per risolvere i propri problemi. Questo è davvero un servizio ai programmatori client perchè essi possono facilmente capire cos'è importante per loro e cosa possono ignorare.

La seconda ragione per il controllo d'accesso è permettere al progettista della libreria di cambiare la struttura interna senza preoccuparsi di come influenzerà il programmatore client. Nell'esempio dello Stack nell'ultimo capitolo, si può volere allocare la memoria in grandi blocchi, per rapidità, invece di creare spazio ogni volta che un elemento viene aggiunto. Se l'interfaccia e l'implementazione sono chiaramente separate e protette, si può ottenere ciò richiedendo solo un nuovo link dal programmatore client.

Il controllo d'accesso del C++

Il C++ introduce tre nuove parole riservate per fissare i limiti in una struttura: public,

private e protected. Il loro uso e il loro significato sono molto semplici. Questi access

specifiers (specificificatori di accesso) sono usati solo in una dichiarazione di struttura e

cambiano i limiti per tutte le dichiarazioni che li seguono. In qualsiasi momento si usa un specificatore d'accesso, esso deve essere seguito dai due punti.

Per public s'intende che tutte le dichiarazioni dei membri sono disponibili a tutti. I membri public sono come quelli dello struct. Per esempio, le seguenti dichiarazioni

struct sono identiche:

//: C05:Public.cpp

// Public è come la struct del C struct A { int i; char j; float f; void func(); }; void A::func() {} struct B { public: int i; char j; float f; void func(); }; void B::func() {} int main() { A a; B b; a.i = b.i = 1; a.j = b.j = 'c'; a.f = b.f = 3.14159; a.func(); b.func(); } ///:~

La parola chiave private, invece, significa che nessuno eccetto noi può accedere a quel membro, il creatore del tipo, dentro i membri funzione di quel tipo. private è un muro di mattoni tra noi e il programmatore client, se qualcuno prova ad accedere al membro

private, avrà un errore di compilazione. In struct B nell'esempio sopra, si potrebbe voler

nascondere porzioni della rappresentazione ( cioè il membro data) , accessibile solo per noi: //: C05:Private.cpp // Fissare i limiti struct B { private: char j; float f; public:

int i; void func(); }; void B::func() { i = 0; j = '0'; f = 0.0; }; int main() { B b;

b.i = 1; // OK, public

//! b.j = '1'; // vietato, private //! b.f = 1.0; // vietato, private

} ///:~

Sebbene func() può accedere a qualsiasi membro di B ( poichè func() è un membro di B, in questo modo ha automaticamente il permesso), un' ordinaria funzione globale come

main() non può. Naturalmente, neanche le funzioni membro delle altre strutture.

Solamente le funzioni che sono chiaramente dichiarate nella dichiarazione della struttura (il "contratto") possono avere accesso ai membri private.

Non c'è nessun ordine richiesto per gli specificatori d'accesso e possono apparire pù di una volta. Essi influenzano tutti i membri dichiarati dopo di loro e prima del prossimo

specificatore d'accesso.

protected

L'ultimo specificatore d'accesso è protected. Esso funziona come private, con un' eccezione che in realtà non può essere spiegata ora: le strutture "ereditate" ( le quali non possono accedere a membri private ) hanno il permesso di accedere ai membri

protected. Questo diverrà più chiaro nel capitolo 14 quando l'ereditarietà verrà

introdotta. Per il momento si consideri protected come private.

Friends

Cosa succede se si dà permesso di accesso ad una funzione che non è un membro della struttura corrente? Ciò si ottiene dichiarando quella funzione friend dentro la

dichiarazione della struttura. È importante che la dichiarazione avvenga dentro la dichiarazione della struttura perchè si deve poter ( e anche il compilatore) leggere la dichiarazione della struttura e vedere tutte le dimensioni ed il comportamento dei tipi di dato. Una regola molto importante in tutte le relazione è: " Chi può accedere alla mia

implementazione privata?".

La classe controlla quale codice ha accesso ai suoi membri. Non c'è un modo magico di intrufolarsi dal di fuori se non si è un friend; non si può dichiarare una nuova classe e dire: "Ciao, io sono un amico di Bob!" ed aspettarsi di vedere i membri private e

protected di Bob.

Si può dichiarare una funzione globale come un friend e si può dichiarare anche una funzione membro di un'altra struttura o perfino una struttura intera come un friend. Ecco qui un esempio:

//: C05:Friend.cpp

// Friend permette un accesso speciale

// Dichiarazione (specificazione di tipo incompleta) struct X; struct Y { void f(X*); }; struct X { // Definizione private: int i; public: void inizializza();

friend void g(X*, int); // friend globale

friend void Y::f(X*); // Struct membro friend

friend struct Z; // L'intera struct è un friend

friend void h(); }; void X::inizializza() { i = 0; } void g(X* x, int i) { x->i = i; } void Y::f(X* x) { x->i = 47; } struct Z { private: int j; public: void inizializza(); void g(X* x); }; void Z::inizializza() { j = 99; } void Z::g(X* x) { x->i += j; } void h() { X x;

x.i = 100; // manipulazione diretta del dato

} int main() { X x; Z z; z.g(&x); } ///:~

struct Y ha una funzione membro f() che modificherà un oggetto del tipo X. Questo è un

po' un rompicapo perchè il compilatore del C++ richiede di dichiarare ogni cosa prima di riferirsi a ciò, quindi la struct Y deve essere dichiarata prima degli stessi membri come un

friend nel struct X. Ma per essere dichiarata Y::f(X*), deve essere prima dichiarata la struct X!

Ecco qui la soluzione. Si noti che Y::f(X*) prende l'indirizzo di un oggetto X. Questo è critico perchè il compilatore sa sempre come passare un indirizzo, il quale è di lunghezza fissa indifferente all'oggetto che è passato, anche se non ha tutte le informazioni circa la lunghezza del tipo. Se si prova a passare l'intero oggetto, comunque, il compilatore deve vedere l'intera struttura definizione di X per conoscere la lunghezza e come passarla, prima di permettere di far dichiarare una funzione come Y::g(X).

Passando un'indirizzo di un X, il compilatore permette di fare una specificazione di tipo

incompleta di X prima di dichiarare Y::f(X*). Ciò avviene nella dichiarazione: struct X;

Questa semplice dichiarazione dice al compilatore che c'è una struct con quel nome, quindi è giusto riferirsi ad essa se non c'è bisogno di conoscere altro che il nome.

Ora, in struct X, la funzione Y::f(X*) può essere dichiarata come un friend senza nessun problema. Se si provava a dichiararla prima il compilatore avrebbe visto la piena

specificazione per Y e avrebbe segnalato un errore. Questo è una caratteristica per assicurare consistenza ed eliminare i bachi.

Notare le altre due funzioni friend. La prima dichiara una funzione ordinaria globale g() come un friend. Ma g() non è stata precedentemente dichiarata globale! Risulta che

friend può essere usato in questo modo per dichiarare simultaneamente la funzione e

darle uno stato friend. Ciò si estende alle intere strutture:

friend struct Z;

è una specificazione di tipo incompleta per Z e dà all'intera struttura lo stato friend.

Friends nidificati

Usare una struttura nidificata non dà automaticamente l'accesso ai membri private. Per compiere ciò, si deve seguire una particolare forma: primo, dichiarare( senza definire ) la struttura nidificata, dopo la si dichiara come un friend, ed infine si definisce la struttura. La definizione della struttura deve essere separata dalla dichiarazione di friend, altrimenti sarebbe vista dal compilatore come un non membro. Ecco qui un esempio:

//: C05:NestFriend.cpp // friend nidificati

#include <iostream>

#include <cstring> // memset() using namespace std;

struct Contenitore { private: int a[sz]; public: void inizializza(); struct Puntatore;

friend struct Puntatore; struct Puntatore { private: Contenitore* h; int* p; public: void inizializza(Holder* h);

// per muoversi nel vettore

void prossimo(); void precedente(); void primo(); void ultimo(); // accedere ai valori: int leggi();

void imposta(int i); };

};

void Contenitore::inizializza() { memset(a, 0, sz * sizeof(int)); } void Contenitore::Puntatore::inizializza(Holder* rv) { h = rv; p = rv->a; } void Contenitore::Puntatore::prossimo() { if(p < &(h->a[sz - 1])) p++; } void Contenitore::Puntatore::precedente() { if(p > &(h->a[0])) p--; } void Contenitore::Puntatore::primo() { p = &(h->a[0]); } void Contenitore::Puntatore::ultimo() { p = &(h->a[sz - 1]); } int Contenitore::Puntatore::leggi() { return *p; }

void Contenitore::Puntatore::imposta(int i) { *p = i; } int main() { Contenitore h; Contenitore::Puntatore hp, hp2; int i; h.inizializza();

hp.inizializza(&h); hp2.inizializza(&h); for(i = 0; i < sz; i++) { hp.imposta(i); hp.prossimo(); } hp.primo(); hp2.ultimo(); for(i = 0; i < sz; i++) { cout << "hp = " << hp.leggi() << ", hp2 = " << hp2.leggi() << endl; hp.prossimo(); hp2.precedente(); } } ///:~

Una volta dichiarato Puntatore è concesso l'accesso ai membri private di Contenitore scrivendo:

friend struct Puntatore;

La struct Contenitore contiene un vettore di int e di Puntatore che permette loro l'accesso. Poichè Puntatore è fortemente associato con Contenitore, è ragionevole farne un membro della struttura del Contenitore. Ma poichè Puntatore è una classe separata dal Contenitore, si può farne più di uno in main() ed usarlo per scegliere differenti parti del vettore. Puntatore è una struttura invece di puntatori C grezzi, quindi si può garantire che punteranno sempre dentro Contenitore.

La funzione della libreria Standard C memset() ( in <cstring> ) è usata per convenienza nel programma di sopra. Essa imposta tutta la memoria ad un indirizzo di partenza ( il primo argomento ) un particolare valore ( il secondo argomento ) per n byte dopo

l'indirizzo di partenza ( n è il terzo argomento ). Naturalmente, si potrebbe semplicemente usare un ciclo, ma memset() è disponibile, testato con successo ( così è meno probabile che si introduca un errore) e probabilmente più efficiente di quando lo si codifica a mano.

E' puro?

La definizione di classe dà una segno di verifica, quindi si può vedere guardando la classe quali funzioni hanno il permesso di modificare le parti private della classe. Se una funzione è un friend, significa che non è un membro, ma si vuole dare un permesso di modificare comunque dati private e deve essere elencata nella definizione della classe così che chiunque possa vedere che è una delle funzioni privilegiate.

Il C++ è un linguaggio orientato a oggetti ibrido, non un puro, e friend fu aggiunto per aggirare i problemi pratici che sorgevano.

Layout dell'oggetto

Il capitolo 4 afferma che una struct scritta per un compilatore C e poi compilata col C++ rimarrebbe immutata. Ciò è riferito al layout dell'oggetto della struct, cioè, dove lo spazio per le variabili individuali è posizionato nella memoria allocata per l'oggetto. Se il

compilatore del C++ cambia il layout della struct del C, allora verrebbe corrotto qualsiasi codice C scritto in base alla conoscenza delle posizioni delle variabili nello struct.

Quando si iniziano ad usare gli specificatori d'accesso, tuttavia, ci si sposta completamente nel regno del C++ e le cose cambiano un po'. Con un particolare "blocco di accesso" (un gruppo di dichiarazioni delimitate dagli specificatori d'accesso ) è garantito che le variabili siano posizionate in modo contiguo, come in C. Tuttavia i blocchi d'accesso possono non apparire nell'oggetto nell'ordine in cui si dichiarano. Sebbene il compilatore posizionerà di solito i blocchi esattamente come si vedono, non c'è alcuna regola su ciò, perchè una

particolare architettura di macchina e/o ambiente operativo forse può avere un esplicito supporto per il private e protected che potrebbe richiedere che questi blocchi siano posti in locazioni speciali di memoria. La specifica del linguaggio non vuole restringere questa possibilità.

Gli specificatori d'accesso sono parte della struttura e non influenzano gli oggetti creati dalla struttura. Tutte le informazioni degli specificatori d'accesso scompaiono prima che il programma giri; generalmente durante la compilazione. In un programma funzionante, gli oggetti diventano " regioni di memoria" e niente più. Se veramente lo si vuole, si possono violare tutte le regole ed accedere alla memoria direttamente, come si può farlo in C. Il C++ non è progettato per preventivarci dal fare cose poco saggie. Esso ci fornisce solamente di una alternativa più facile e molto desiderata.

In generale, non è una buona idea dipendere da qualcosa che è un' implementazione specifica quando si sta scrivendo un programma. Quando si devono avere dipendenze di specifiche di implementazione, le si racchiudano dentro una struttura

cosicchè qualsiasi modifica per la portabilità è concentrata in un solo posto.

La classe

Il controllo d'accesso è spesso detto occultamento dell'implementazione. Includere

funzioni dentro le strutture ( ciò è spesso detto incapsulazione[36]), produce un tipo di

dato con caratteristiche e comportamenti, ma l'accesso di controllo pone limiti con quel tipo di dato, per due importanti ragioni. La prima è stabilire cosa il programmatore client può e non può usare. Si può costruire un proprio meccanismo interno nella struttura senza preoccuparsi che il programmatore client penserà che questi meccanismi sono parte

dell'interfaccia che dovrebbero essere usati.

Questa problematica porta direttamente alla seconda ragione, che riguarda la separazione dell'interfaccia dall'implementazione. Se la struttura viene usata in un insieme di

programmi, ma i programmatori client non possono fare nient'altro che mandare messagi all'interfaccia pubblica, allora si può cambiare tutto ciò che è private senza richiedere modifiche al codice.

L'incapsulamento ed il controllo d'accesso, presi insieme, sono qualcosa di più che una

struttura descrive una classe di oggetti come se si descriverebbe una classe di pesci o una classe di uccelli: ogni oggetto appartenente a questa classe condividerà le stesse

caratteristiche e comportamenti. Ecco cosa è diventata una dichiarazione di struttura, una descrizione del modo in cui tutti gli oggetti di questo tipo appariranno e si comporteranno. Nell'originale linguaggio OOP, Simula-67, la parola chiave class fu usata per descrivere un nuovo tipo di dato. Ciò apparentemente ispirò Stroustrup a scegliere la stessa parola chiave per il C++, per enfatizzare che questo era il punto focale dell'intero linguaggio: la creazione di nuovi tipi di dato che sono qualcosa in più che le struct del C con funzioni. Ciò

certamente sembra una adeguata giustificazione per una nuova parola chiave.

Tuttavia l'uso di una class nel C++ si avvicina ad essere una parola chiave non necessaria. E' identica alla parola chiave struct assolutamente in ogni aspetto eccetto uno: class è per default private, mentre struct è public. Ecco qui due strutture che producono lo stesso risultato:

//: C05:Class.cpp

// Similitudini tra struct e class struct A { private: int i, j, k; public: int f(); void g(); }; int A::f() { return i + j + k; } void A::g() { i = j = k = 0; }

// Identici risultati sono prodotti con: class B { int i, j, k; public: int f(); void g(); }; int B::f() { return i + j + k; } void B::g() { i = j = k = 0; } int main() { A a; B b; a.f(); a.g(); b.f(); b.g();

} ///:~

La classe è il concetto fondamentale OOP in C++. È una delle parole chiave che non sarà indicata in grassetto in questo libro, diventa noiso vederla ripetuta. Il passaggio alle classi è così importante che sospetto che Stroustrup avrebbe preferito eliminare struct, ma il bisogno di compatibilità con il codice esistente non lo ha permesso.

Molti preferiscono uno stile di creazione di classi che è più simile alla struct che alla classe, perchè si può non usare il comportamento private della classe per default iniziando con public:

class X { public: void funzione_di_interfaccia(); private: void funzione_privata(); int rappresentazione_interna; };

La logica dietro ciò sta nel fatto che il lettore è interessato a vedere prima i membri più importanti, poi può ignorare tutto ciò che è private. Infatti, le sole ragioni per cui tutti gli altri membri devono essere dichiarati nella classe sono dovute al fatto che così il

compilatore conosce la grandezza degli oggetti e li può allocare correttamente e quindi garantire consistenza.

Gli esempi di questo libro, comunque, porrà i membri private per prima :

class X { void private_function(); int internal_representation; public: void interface_function(); };

qualcuno persino arricchisce i propri nomi nomi privati:

class Y {

public: void f();

private:

int mX; // nome "Self-decorated"

};

Poichè mX è già nascosto nello scope di Y, la m ( sta per "membro" ) non è

necessaria.Tuttavia, nei progetti con molte variabili globali ( cosa da evitare, ma a volte è inevitabile nei progetti esistenti), è di aiuto poter distinguere all'interno di una definizione di funzione membro quale dato è globale e quale è un membro.

Modificare Stash per usare il controllo d'accesso

Ha senso riprendere l'esempio del Capitolo 4 e modificarlo per usare le classi ed il controllo d'accesso. Si noti la porzione d'interfaccia del programmatore client è ora

chiaramente distinguibile, quindi non c'è possibilità da parte del programmatore client di manipolare una parte della classe che non dovrebbe.

//: C05:Stash.h

// Convertita per usare il controllo d'accesso

#ifndef STASH_H #define STASH_H

class Stash {

int size; // Dimensione di ogni spazio

int quantity; // Numero dello spazio libero

int next; // prossimo spazio libero // array di byte allocato dinamicamente:

unsigned char* storage; void inflate(int increase);

public:

void initialize(int size); void cleanup();

int add(void* element); void* fetch(int index); int count();

};

#endif // STASH_H ///:~

La funzione inflate() è stata resa private perchè è usata solo dalla funzione add() ed è in questo modo parte della implementazione sottostante, non dell' interfaccia. Ciò significa che, in seguito, si può cambiare l'implementazione sottostante per usare un differente sistema per la gestione della memoria.

Tranne l'include del file, l'header di sopra è l'uncia cosa che è stata cambiata per questo esempio. Il file di implementanzione ed il file di test sono gli stessi.

Modificare Stack per usare il controllo d'accesso

Come secondo esempio, ecco qui Stack trasformato in una classe. Ora la struttura

nidificata data è private, cosa non male perchè assicura che il programmatore client non dovrà mai guardarla e non dipenderà dalla rappresentazione interna di Stack:

//: C05:Stack2.h

// struct nidificate tramite linked list

#ifndef STACK2_H #define STACK2_H class Stack { struct Link { void* data; Link* next;

void initialize(void* dat, Link* nxt); }* head;

public:

void push(void* dat); void* peek(); void* pop(); void cleanup(); }; #endif // STACK2_H ///:~

Come prima, l'implementazione non cambia e quindi non è ripetuta qui. Anche il test è identico. L'unica cosa che è stata cambiata è la robustezza dell'interfaccia della classe. Il valore reale del controllo d'accesso è impedire di oltrepassare i limiti durante lo sviluppo. Infatti, il compilatore è l'unico che conosce il livello di protezione dei membri della classe. Nessuna informazione del controllo di accesso arriva al linker, tutti i controlli di protezione sono fatti dal compilatore.

Si noti che l'interfaccia presentata al programmatore client è adesso veramente quella di uno stack di tipo push-down. È implementato come una linked list, ma la si può cambiare senza influenzare il programmatore client che interagisce con essa.

Gestire le classi

Il controllo d'accesso in C++ permette di separare l'interfaccia dall'implementazione, ma l'occultamento dell'implementazione è solamente parziale. Il compilatore deve vedere ancora le dichiarazioni di tutte le parti di un oggetto per crearlo e manipolarlo

correttamente. Si potrebbe immaginare un linguaggio di programmazione che richiede solo un'interfaccia pubblica di un oggetto e permette di nascondere l'implementazione privata, ma il C++ esegue la maggior parte dei controlli sul tipo staticamente ( a tempo di compilazione) . Ciò significa che si saprà subito se c'è un errore. Significa anche che il proprio programma è più efficiente. Tuttavia, includere l'implementazione privata ha due effetti: l'implementazione è visibile anche se non è accessibile e può richiedere un inutile ricompilazione.

Nascondere l'implementazione

Nel documento Pensare in C++, seconda ed. Volume 1 (pagine 174-189)

Documenti correlati