C++ bez cholesterolu

Sekcje dostępu

Teoria enkapsulacji

Enkapsulacja jest to narzędzie umożliwiające nadawanie (czy też właściwszym określeniem ze względu na to, jak struktury były dotychczas przezentowane, byłoby "zabieranie") praw dostępu do składowych struktury. Posiadamy zatem trzy sekcje: prywatną, publiczną i chronioną (ustanawiane przez `private:', `public:' i `protected:').

Zanim jednak wspomnę o samym ustanawianiu sekcji dostępu, wspomnę o jeszcze jednej rzeczy. Powszechnie mówi się, że w C++ do definiowania klas stosuje się słowo class. Słowo to jednak tak naprawdę prawie niczym nie różni się od struct. Istnieją między nimi jedynie dwie różnice, związane właśnie z sekcjami dostępu. Pierwsza to taka, że domyślna sekcja dostępu wewnątrz struktury jest 'public', a wewnątrz klasy - 'private'. Podobnie jest przy dziedziczeniu: domyślnie elementy odziedziczone w przypadku struktury przechodzą do sekcji publicznej, a w przypadku klasy - do prywatnej. Słowo class wiąże się zresztą z zaszłościami historycznymi C++, w którym pierwotnie struktury nie mogły posiadać metod, ani uczestniczyć w hierarchiach (a jedynie klasy).

Na razie zajmę się jedynie sekcją prywatną i publiczną. Jeśli zatem zadeklarujemy sobie klasę:

class Klocek {
  int a, b;
  Klocek( int x, int y ): a( x ), b( x ) {}
};

To próbę utworzenia obiektu:

Klocek k( 2, 5 );

kompilator nawet przy najszczerszych chęciach zmuszony będzie odrzucić. Jak wspomniałem, domyślna sekcja w class jest prywatna i w efekcie obowiązuje ona również konstruktor klasy. Zatem aby umożliwić dostęp zewnętrza do środka klasy, należy ustanowić sekcję publiczną.

class Klocek {
  int a, b;
  public:
  Klocek( int x, int y ): a( x ), b( x ) {}
};

Mimo, że obiekt klasy Klocek możemy już zadeklarować, to i tak nie można z niego korzystać. Zauważ, że pola a i b nadal pozostają w sekcji prywatnej. Wydaje się, że najsensowniej jest "odsłonić" te pola, jednak choć w tym przypadku można to zrobić, to jednak przy bardziej skomplikowanych klasach byłoby to wielce niepożądane. Można zatem ustanowić metody, które będą dawały dostęp do tych pól. Jest to zresztą o tyle lepsze rozwiązanie, że można:

class Klocek {
  int a, b;
  public:
  Klocek( int x, int y ): a( x ), b( x ) {}
  void Set( int x, int y ) { a = x; b = y; }
  int A() { return a; }
  int B() { return b; }
};

Enkapsulacja w dziedziczeniu

Enkapsulacja oczywiście nie sprowadza się tylko do ustanawiania sekcji w klasie. Podczas dziedziczenia również możemy określić, do której z sekcji mają przechodzić odziedziczone elementy (oczywiście w razie dziedziczenia prywatnego można potem wybrane metody przez samą deklarację upublicznić). Na przykład:

struct A: B, private C
{
};

Tutaj, klasa B stanie się klasą dziedziczoną publicznie, ale C już prywatnie. To ma parę konsekwencji. Mianowicie, wszystkie deklaracje z C będą w sekcji prywatnej klasy A (a zatem, nie będą dostępne z zewnątrz). Po drugie, klasy A nie można niejawnie konwertować na C (dziedziczenie prywatne w szczególności stosuje się tylko i wyłącznie w celu "zawłaszczenia" klasy C).

Wspominałem wcześniej o polach i metodach statycznych. Zauważ, że te elementy mogą również być umieszczane w sekcji prywatnej lub publicznej. Pozwala to na stworzenie czegoś w rodzaju "Funkcji lokalnej" klasy, a do pola "dzielonego" mają dostęp wtedy tylko metody danej klasy (jest to więc takie pole "wspólne dla obiektów" danej klasy).

Jeśli ktoś oczywiście nie doszedł do tego sam, informuję od razu, że składowe umieszczone w sekcji prywatnej klasy bazowej nie są dostępne także dla klasy pochodnej. Jest to zgodne z teorią hierarchizacji, bowiem klasa powinna mieć swoje własne "wnętrze", ale powinna je zasłonić dla "ogólnego widoku", udostępniając jedynie odpowiednie metody pozwalające ją odpowiednio obsługiwać. Umożliwia to zatem odpowiednie hierarchizowanie pojęć pod każdym względem i decydować o "poziomowości" operacji.

Jednak przywiązywanie zbyt wielkiej wagi do tych kwestii może się okazać zgubne. Jeśli bowiem mielibyśmy np. 3 poziomy dziedziczenia, to trzeba by było w każdej klasie pochodnej umieścić deklaracje upubliczniające wybrane metody. Przy bardziej złożonych hierarchiach oznacza to po prostu jeden wielki koszmar. Poziomów w hierarchii może być jednak więcej, poza tym typów wyjściowych może być jeszcze dużo, dużo więcej, one z kolei mogą mieć dużo metod i tak dalej. A zachowanie jakichś bardzo ścisłych reguł podczas dziedziczenia nie zawsze prowadzi do polepszenia jakości oprogramowania (programista, jak każdy inżynier, musi te kwestie umieć wyważyć). Sensowne ich używanie prowadzi do tego na pewno (gdyby nie prowadziło, to przecież nikomu by to nie było potrzebne), ale nadużywanie pociąga za sobą wręcz odwrotne skutki. Nie chcę zatem sugerować, że nie należy stosować metod weryfikacji podczas tworzenia hierarchii klas, ale też nie można dać się zwariować i stosować wszelkiej możliwej ideologii w praktyce.

Narzędzia do warunkowego naruszania enkapsulacji

"Zakapsułowaną" klasę odziedziczoną możemy sobie wyobrazić jak taką - małą czy dużą - twierdzę, do której dostęp z zewnątrz ograniczony i zminimalizowany do koniecznych potrzeb. Na niektóre elementy dostępu trzeba mieć jeszcze zezwolenie, trzeba spełnić określone warunki itd. Przy podziale składowych na publiczne i prywatne, można określić jedynie obszar "wnętrza" i "zewnętrza", przy czym zewnętrze jest zawsze zewnętrzem, niezależnie od tego, czy taka twierdza w danej sytuacji jest samodzielną jednostką, czy pod-twierdzą innej twierdzy; jest to jakby taka "totalna nieufność". Jest to może słuszne ideologicznie (i nawet nie stanowi o jakichkolwiek stratach w szybkości!), jednak - zakładając że mamy twierdzę zewnętrzną, która jest nad-twierdzą dla wewnętrznej - twierdza wewnętrzna w efekcie może albo pozwolić twierdzy zewnętrznej na wszystko, na co pozwala normalnie nawet pozostałym (co jest zazwyczaj niebezpieczne), albo nie pozwolić na nic, kontrolując ściśle wszystko, co z twierdzą wewnętrzną miałoby mieć kontakt. Można co prawda - jak radziliby puryści obiektowi - robić klasy od razu z odpowiednim przeznaczeniem (uniemożliwiając tworzenie obiektów klas, które się przeznacza do bycia podstawowymi) i dziedziczyć tylko prywatnie (wtedy "zewnętrze" klasy podstawowej będzie i tak tylko zewnętrzem dostępnym dla klasy pochodnej). To jednak uniemożliwia tworzenie "przyczłapów" w postaci różnych "prywatnych rozszerzeń i dostosowań", które jednak nie są w programowaniu takim czymś znów wyjątkowym (w C++ zresztą dość częstym, bo C++ pozwala je dobrze wspierać). Często użytkownik chciałby móc wykorzystać jakąś klasę biblioteczną, której autor jakoś nie przewidział w takim zastosowaniu.

Istnieje zatem trzecia sekcja, chroniona (protected). Jest to sekcja jakby "z ograniczonym zaufaniem" i - jak to określają puryści obiektowi - godzi w ustalenia teorii obiektowej (teoretycy mają to do siebie, że wciąż dążą do określenia reguł wszelkich ogólnych przypadków zapominając jednak, że w miarę uogólniania pojęć trudniej się je rozumie, a koszty stosowania takiej teorii rosną w porywach nawet w tempie wykładniczym). Nie znaczy to oczywiście, że łata ona jakąś dziurę teorii obiektowej w C++ (bo tak szczerze mówiąc, nie wiem, jak owi "puryści obiektowi" wpadli na to, że to stanowi jakąś dziurę). Inna sprawa, że jest ona rzeczą całkowicie normalną tak naprawdę: do tej sekcji należą te elementy, których używanie ma właśnie zmusić użytkownika do tego, by w tym celu utworzył klasę pochodną.

Sekcja chroniona (protected) pozwala klasie bazowej określić pewną grupę składowych, które będą zasłonięte przed wszelkim zewnętrzem, ale dostępne dla klas dziedziczących. Są to więc metody, których nie powinno się wykonywać na obiekcie danej klasy, ale które mogą być użyteczne dla klasy dziedziczącej. Jak również pola, do których nie powinno się zaglądać, ale które można by udostępnić klasie pochodnej, aby uprościć ich obsługę. Można np. stworzyć jedną klasę, której sensem istnienia jest jedynie bycie częścią wspólną innych klas (żeby nie pisać każdej z nich "od nowa"). Może w takim wypadku umieścić również konstruktory w sekcji chronionej, dzięki czemu wszelkie klasy dziedziczące po niej mogą z nich skorzystać, ale obiektów tej klasy utworzyć się nie da. Jedna rzecz co do protected jest oczywista: C++ zakłada, że ktoś, kto się bierze za wyprowadzanie nowych klas ma trochę pojęcia o samych metodach hierarchicznych i obiektowych. Problem tylko w tym, że istnieje sporo bibliotek (np. MFC), które dostarczają klas podstawowych i praktycznie wymaga się bezwzględnie wyprowadzenia sobie nowych klas, żeby ich używać (jest to jednak klasyczny przykład "pisania Smalltalkiem w C++").

Spójrz na przykład. Jedna klasa jest wektorem indeksowanym od 0 do n, a druga od x do y (wszystko całkowite). Zwróć uwagę, że klasie IRangedVector jest potrzebny dostęp do składowej `t', żeby się do niego po prostu łatwiej dobrać.

class IVector {
  protected:
  int* t;
  int size;

  public:

  IVector( size_t s )
    : size(s),t( new int[s]) {}
  int& operator[]( size_t i ) {
    return t[i];
  }
  ~IVector() { delete [] t; }
};

class IRangedVector: IVector {
  int base;

  public:
  IRangedVector( size_t from, size_t to )
    : IVector( to - from + 1 ), base( from )

  int& operator[]( size_t i ) {
    return t[i - base];
  }
};

Jest jednak oczywiste, że:

Klasa IRangedVector wykorzystywała `t' tylko do odczytu, co było możliwe przez operator []. Nie udostępnia on jednak zapisu do `t'; stwórzmy zatem klasę, która takie możliwości wykorzystuje:

class IScalableVector: IVector {
  public:
  IScalableVector( size_t s ): Vector( s ) {}

  // tylko deklaracja upubliczniająca
  int& operator[]( size_t );

  void Resize( size_t ns ) {
    // Lamerskie rozwiązanie :*)
    int* nt = new int [ns];
    copy_n( t, min( size, ns ), nt );
    delete t;
    t = nt;
  }
};

Jak widać, nie da się tego zrobić inaczej, niż przez protected (ewentualnie inaczej zorganizować hierarchię klas). Przecież nie można upubliczniać pola `t' tylko po to, żeby jakaś klasa dziedzicząca miała prawo do zapisu.

Zauważ też, że metody mają z góry ustalony sposób ich wywoływania. Zatem postać wywołania metody na rzecz obiektu ma składniowo postać x.Metoda(), a w przypadku operatorów dwuargumentowych obiekt musi być pierwszym argumentem. Mało tego: żądanie enkapsulacji dla operacji w takim przypadku jest równoznaczne z wymuszeniem odpowiedniej składni ich używania!

Oczywiście przesadzam. Można przecież zrobić sobie funkcję (o nagłówku takim, jaki nam się bardziej podoba) jako wrapper, który będzie z kolei wywoływał odpowiednią metodę (o nagłówku, który nam się nie podoba). Jednak - jak w poprzednim przypadku - wymagałoby to za każdym razem robienia sporej ilości wrapperów. I chociaż właśnie jedną z mocnych stron C++ jest możliwość robienia wrapperów, to jednak jeśli będziemy musieli określić choćby po jednym wrapperze dla 80% operacji, to czas napisania takiej klasy wydłuży się nam mniej więcej - w odwrotnej proporcji do długości tych właściwych funkcji - na 120 do 180 procent pierwotnego założenia.

Ale to jeszcze nie wszystko. Załóżmy, że dana operacja powinna mieć dostęp do składowych prywatnych dwóch niepokrewnych klas. Co wtedy? Udostępnić pośrednie metody? Przy takich konfliktach właśnie zazwyczaj trudno jest określić, która klasa dla której ma być klasą nadrzędną (na to też trzeba poświęcić czas, a czas z kolei to... wiadomo co), tzn. która ma przetworzyć jedną część danych i wytworzyć daną pośrednią. Gorzej: ponieważ to są dwie niepokrewne klasy, to trzeba by robić metodę jednej klasy, która zrobi pół jednej procedury, drugiej klasy, która zrobi drugie pół procedury, a wszystko połączyć przy pomocy jakichś pośrednich danych, które będą - zauważ - wystawione na zewnątrz! W taki sposób nie tylko dajemy dostęp do danych, które powinny być zasłonięte, ale na dodatek robi się dwie metody, które "robią kawałek czegoś", co jest pogwałceniem porządnego programowania proceduralnego.

W takich sytuacjach przyda się coś, co się nazywa "zaprzyjaźnianiem". Wygląda to tak:

class X {
  ...
  // zaprzyjaźnienie funkcji
  friend void f( X* );
  // zaprzyjaźnienie klasy
  friend class Y;
};

Zaprzyjaźnienie jest niestety terminem trochę nieprecyzyjnym; deklaracje te bowiem oznaczają, że funkcja `f' i wszystkie metody z klasy `Y' mają dostęp do składowych prywatnych i chronionych klasy `X'. Należy jednak przy stosowaniu tego narzędzia zachować dalece posuniętą ostrożność! Klasy należy zaprzyjaźniać tylko wtedy, jeśli rzeczywiście klasa powinna być z inną "stowarzyszona". Zaprzyjaźnianie z kolei zewnętrznych funkcji ma sens tylko w przypadku operatorów; staraj się nie robić zaprzyjaźnianych funkcji. Pamiętaj też, że zaprzyjaźnianie nie tylko pozwala dowolnie operować obiektem przekazanym jako argument do funkcji, ale również utworzonym jako zmienna lokalna w funkcji (czy w ogóle jakimkolwiek sposobem w tej funkcji uzyskana). Oczywiście niech te zasady nie będą ważniejsze od zasady spójności funkcji z programowania proceduralnego.