Dr. Carlo Pescio
Una Backdoor di Friendship

Pubblicato su C++ Informer No. 3, Febbraio 1998

Progettare una buona classe e' difficile. Idealmente, l'interfaccia di ogni classe dovrebbe nascondere i dettagli implementativi, esponendo invece una astrazione dei servizi offerti dagli oggetti. L'interfaccia dovrebbe essere resiliente, ovvero, modifiche anche importanti all'implementazione dovrebbero avere una influenza minima (per non dire nulla) sull'interfaccia pubblica della classe.
Naturalmente, una classe la cui interfaccia esponga troppi dettagli implementativi (direttamente o tramite metodi get/set) non sara' affatto resiliente. Non a caso nel mio corso di Systematic OOD insegno tre tecniche diverse per ottenere oggetti "piu' autonomi", e ne sto attualmente studiando una quarta.
Tuttavia, la progettazione di una classe e' un processo iterativo: non di rado ci si rende conto di alcuni problemi di riusabilita' solo a posteriori, quando qualcuno cerca realmente di riusare la classe. Se ad esempio "chiudiamo" troppo l'interfaccia alla ricerca dell'information hiding, rischiamo di renderne impossibile l'utilizzo in situazioni che si trovano "al confine" di quanto avevamo previsto.
Se siamo ancora in fase di sviluppo, il problema e' limitato: possiamo intervenire sull'interfaccia della classe e migliorarla. Ma via via che la nostra classe viene riusata (e se e' fatta bene verra' riusata), l'impatto di ogni modifica all'interfaccia sara' sempre meno accettabile.
In uno scenario simile, potrebbe essere utile introdurre una "porta secondaria" per arginare i possibili problemi di un'interfaccia troppo chiusa. La porta secondaria non deve essere lasciata aperta: tanto varrebbe esporre direttamente i dati della classe. Tuttavia dovrebbe essere possibile per chiunque, in modo cosciente e non accidentale, aprire la porta secondaria ed accedere direttamente ai dettagli implementativi della classe.
In tal modo ne consentiamo l'utilizzo anche in situazioni non previste, sotto la responsabilita' di chi la usa e senza necessita' di modificare l'interfaccia per accomodare i desideri snaturati :-) di tutti gli utilizzatori.
Ovviamente, se la modifica ha senso dovremo poi trovare il momento giusto per introdurla nell'interfaccia, ma qui si esce dal C++ e si entra nella gestione dei progetti, un argomento che rimando volentieri ad un'altra sede.

Friend non basta (o no?)
Il C++ fornisce gia' un meccanismo per consentire l'accesso ai dati privati (ed ovviamente protetti e pubblici) di una classe A ad un'altra classe B; e' sufficiente che A dichiari B come friend:

class A
  {
  friend class B ;
  // ...
  } ;
Ovviamente questa soluzione non va bene per i nostri fini. Siccome e' la classe A a dichiarare i suoi friend, se una nuova classe C vuole accedere ai dati privati di A occorre modificare l'interfaccia di A, che e' esattamente quello che volevamo evitare.
A nulla vale il ricorso all'ereditarieta' (ovvero derivare C da B) o alla composizione di friendship (ovvero dichiarare C come friend di B). La friendship e' una proprieta' non transitiva [1], quindi C non ha modo di ottenere gli stessi privilegi che A garantisce a B.
Naturalmente un vero programmatore non si ferma qui, e trova sempre una scappatoia: di seguito vi faro' vedere due trucchi non molto noti ed apparentemente funzionanti. E' importante dire fin da subito che simili trucchi funzionano con molti compilatori ma *vanno contro lo standard ANSI/ISO*. Piu' avanti vedremo una tecnica diversa che rispetta pienamente lo standard; vale comunque la pena di conoscere le versioni "sbagliate", sia perche' vi puo' capitare tra la mani codice che le utilizza, sia per capire cosa non va e quindi evitare di scrivere codice analogo.

Friend puo' bastare, se il compilatore non controlla
Se anziche' dichiarare una classe friend dichiariamo una funzione friend come segue:

//----------
// A.h

class A
  {
  friend void backdoor( A& a, void* p ) ;
  // ...
  } ;
Possiamo pensare di definire la funzione dovunque ci interessi, anche in modi diversi. Ovviamente dobbiamo evitare problemi di linking, quindi abbiamo due possibilita': definire la funzione come inline o come static.
Vediamo il primo caso:
//----------
// C1.cpp

// la classe C1 vuole accedere ai dati privati di A

#include "A.h"
#include "C1.h"

void C1 :: f( A& a )
  {
  // f vuole accedere ai dati privati di a
  backdoor( a, this ) ;
  }

inline void backdoor( A& a, void* p )
  {
  C1* c = static_cast< C1* >( p ) ;
  // ora posso accedere ai dati di a,
  // e se e' il caso passarli a c
  // ...
  }


//----------
// C2.cpp

// come C1.cpp, ma backdoor e' definita in modo diverso!

Siccome le funzioni inline vengono (appunto) espanse in linea, il programmatore "assume" che non vi sara' alcun conflitto tra la "backdoor" definita in C1.cpp e quella definita in C2.cpp. Entrambe possono accedere ai dati privati di A, facendo cose diverse e garantendo l'accesso ad A all'oggetto che le chiama passando se stesso come secondo parametro.
Analogamente, si puo' pensare che definendo backdoor come static in C1.cpp e C2.cpp non si avra' collisione fra le due, e tutto continuera' a funzionare a dovere.
In realta', entrambi gli approcci funzionano su molti compilatori ma violano lo standard, in modo anche piuttosto sottile.
Iniziamo dal primo caso (inline), che e' peraltro il piu' semplice. Esso va infatti contro la cosiddetta One Definition Rule (punto 3.2 dello standard) che dice esplicitamente, nel caso di una funzione inline definita in unita' di compilazione diverse:
- ogni definizione deve consistere della stessa sequenza di token.
- in ogni definizione, tutti i nomi devono essere risolti alla stessa entita' [...]
Nel nostro caso, nessuna delle due condizioni e' normalmente rispettata. Peraltro lo standard non richiede una diagnostica per questo codice, che e' quindi estremamente pericoloso: comportamento indefinito, nessun messaggio dal compilatore.
Il secondo caso e' ancora piu' sottile (e ci suggerira' fra breve una prima scappatoia). In realta' sostituendo static ad inline il codice di cui sopra non dovrebbe essere compilato (anche se di fatto lo sara' su molti compilatori). In base al punto 11.4.3 dello standard, poiche' in C1.cpp backdoor non e' ancora stata dichiarata quando A.h viene incluso, la dichiarazione friend all'interno di A forza backdoor ad avere extern linkage, e quindi la stessa funzione non puo' poi essere definita static impunemente. Di fatto, ben pochi compilatori se ne accorgono, almeno per ora. Il C++ e' molto complicato anche per chi scrive compilatori.

Friend basta, ma sino a quando?
Quanto sopra mi ha suggerito una soluzione che sfrutta quello che ritengo un buco nello standard. Se modifichiamo C1.cpp come segue:

//----------
// C1.cpp

// la classe C1 vuole accedere ai dati privati di A

class A ;
static void backdoor( A& a, void* p ) ;

#include "A.h"
#include "C1.h"

void C1 :: f( A& a )
  {
  // f vuole accedere ai dati privati di a
  backdoor( a, this ) ;
  }

static void backdoor( A& a, void* p )
  {
  C1* c = static_cast< C1* >( p ) ;
  // ora posso accedere ai dati di a,
  // e se e' il caso passarli a c
  // ...
  }
Tutto funziona nel pieno rispetto dello standard. Il trucco sta nel dichiarare (non definire) backdoor prima di includere A.h. In questo caso, sempre secondo il punto 11.4.3, la dichiarazione friend mantiene lo stesso linkage gia' specificato (static). La dichiarazione seguente e' quindi lecita, e non va neppure contro la One Definition Rule, perche' la funzione e' statica.
In realta', probabilmente "manca un pezzo" alla One Definition Rule, e nessuno se ne e' accorto. La ODR non stabilisce che una funzione dichiarata friend debba essere risolta alla stessa entita' in unita' di compilazione diverse. Questo implica la legalita' del codice di cui sopra, ma non potrei certo garantire che alla prossima revisione dello standard (presumibilmente nel 2008) la ODR non venga aggiornata, rendendo il codice di cui sopra improvvisamente illegale.

Template, sempre loro...
Veniamo cosi' alla versione finale, che funziona, rispetta lo standard, e non sfruttando nessun possibile buco rimarra' valida anche negli anni a venire. In compenso, per ora non funziona con molti compilatori, perche' richiede il supporto per i member template ed i friend template. Per garantire una backdoor alle altre classi, dichiariamo A come segue:

//----------
// A.h

class A
  {
  template< class T > friend void backdoor( A& a, void* p ) ;
  // ...
  } ;
La classe A definisce la funzione friend come template. Se la nostra classe C1 vuole accedere ai dati privati di A, dovra' essere strutturata sul modello seguente:

//----------
// C1.h

class C1
  {
  // ...
  private :
    class A_BackDoorKey {} ;
  } ;

//----------
// C1.cpp

template<> void backdoor<C1::A_BackDoorKey>( A& a, void* p )
  {
  // qui abbiamo accesso ai dati di A
  }

// esempio di chiamata:
void C1 :: f( A& a )
  {
  backdoor<A_BackDoorKey>( a, this ) ;
  }
Osserviamo che C1 definisce una classe "chiave" privata, e poi specializza il template su tale classe. Poiche' la classe privata di C1 e' ovviamente unica, non andiamo contro la ODR, ed otteniamo l'accesso ai dati privati di A.
In realta' una volta passati ai template possiamo anche eliminare il void*, ed il relativo static cast, in favore di una soluzione completamente type safe:

//----------
// A.h

class A
  {
  template< class K, class T > 
  friend void backdoor( A& a, T* p ) ;
  // ...
  } ;

//----------
// C1.h

class C1
  {
  // ...
  private :
    class A_BackDoorKey {} ;
  } ;

//----------
// C1.cpp

template<> void backdoor<C1::A_BackDoorKey, C1>( A& a, C1* p )
  {
  // qui abbiamo accesso ai dati di A
  }

Conclusioni e chiavi di lettura
Vi sono almeno tre chiavi di lettura per quanto sopra.

La prima e' che il C++ e' molto complesso, tanto che per alcuni errori non e' richiesta diagnostica, e che per non sbagliare e' necessaria una conoscenza molto approfondita dello standard, visto che sicuramente non ci si puo' fidare del compilatore.
La seconda e' che, proprio nella sua complessita', il C++ ci offre gli strumenti per ottenere soluzioni praticamente perfette anche a problemi complicati, come fornire una backdoor per consentire usi non previsti (ma intenzionali: difficile scrivere quanto sopra per sbaglio) delle nostre classi. In fondo, una delle differenze tra i linguaggi giocattolo dei puristi ed i linguaggi complicati ma real-world come il C++ e' proprio la capacita' di questi ultimi di adattarsi ad esigenze reali, come quella di non modificare troppo frequentemente l'interfaccia di una classe, mantenere la classe "chiusa" per gli utilizzatori normali, e lasciarla aperta per i casi di emergenza.
La terza e' che se definite una funzione friend template, indirettamente state aprendo una backdoor nella vostra classe. Se lo state facendo intenzionalmente, nessun problema. Se invece e' un indesiderato effetto collaterale, potete parzialmente proteggervi spostando tutto cio' che non prevedete possa servire alle funzioni friend "regolari" in una seconda classe, utilizzata tramite puntatori opachi, come spiegato in [1] per la tecnica di isolamento per data wrapping.

Bibliografia
[1] Carlo Pescio, "C++ Manuale di Stile", Edizioni Infomedia, 1995.

Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza, progettazione e formazione in ambito internazionale. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi, nonché dei testi "C++ Manuale di Stile" ed "UML Manuale di Stile". Laureato in Scienze dell'Informazione, è membro dell'ACM, dell'IEEE e dell'IEEE Technical Council on Software Engineering.