Rozdział 16. Dziedziczenie

> Dodaj do ulubionych

Dziedziczenie to kolejny kreatywny sposób na realizację lenistwa. Technika ta umożliwia wybieranie zachowań z klas i tworzenie na ich podstawie nowych o zmodyfikowanej funkcjonalności. W tym aspekcie dziedziczenie można traktować jako mechanizm tzw. wielokrotnego wykorzystania kodu. Można je też wykorzystywać na etapie projektowania programu, który składa się z pewnej grupy powiązanych ze sobą obiektów.

Dziedziczenie umożliwia na wybieranie do klasy zachowań z klasy, która jest jej rodzicem. Implementację interfejsu przez klasę można traktować jako jej oświadczenie, że ma określony zestaw zachowań. Jeśli klasa jest potomkiem określonej klasy nadrzędnej, to znaczy, że ma zbiór zachowań, ponieważ odziedziczyła je po swoim rodzicu. Krótko mówiąc:

Interfejs: „Potrafię robić te rzeczy, bo powiedziałam, że potrafię”.

Dziedziczenie: „Potrafię robić te rzeczy, bo mój rodzic potrafi”.

Rozszerzanie klasy nadrzędnej

Przykład wykorzystania dziedziczenia możecie zobaczyć w naszym projekcie konta bankowego. Zauważyliśmy już, że konto BabyAccount musi zachowywać się jak CustomerAccount, tylko powinno mieć zmodyfikowaną metodę obsługującą wypłacanie pieniędzy. Posiadacze zwykłych kont mogą wypłacać dowolne kwoty. Posiadacze kont dziecięcych mogą pobierać maksymalnie 50 zł na raz.

W kategoriach projektowych rozwiązaliśmy ten problem za pomocą interfejsów. Oddzielając wykonawcę czynności od jej opisu (czyli tego, co reprezentuje interfejs), możemy oprzeć cały nasz system bankowy na interfejsie IAccount, a następnie doraźnie dodawać konta o innych zachowaniach. Możemy nawet tworzyć całkiem nowe konta w dowolnym czasie po wdrożeniu systemu. Będą one prawidłowo działać z innymi, ponieważ będą się prawidłowo zachowywać (tzn. będą implementować interfejs).

To jednak robi się nużące podczas pisania programu. Musimy utworzyć klasę BabyAccount, której kod w znacznej mierze pokrywa się z kodem klasy CustomerAccount. Możecie pomyśleć, że to żaden problem, bo przecież wystarczy skopiować odpowiednie fragmenty w edytorze tekstu programu. Ale:

Złota myśl programisty: Kopiowania fragmentów kodu to zło

Wciąż zdarza mi się popełniać błędy podczas pisania programów. Pewnie myślicie, że po tylu latach w tej branży powinienem wszystko robić poprawnie za pierwszym razem. Jest odwrotnie. Na dodatek wiele z błędów, które popełniam, wiąże się z nieprawidłowym kopiowaniem bloków kodu. Napiszę jakiś kod i stwierdzę, że potrzebuję czegoś podobnego, ale nie identycznego, w innym miejscu programu. Wtedy kopiuję blok kodu. Następnie zmieniam większość, ale nie całość, nowego kodu i stwierdzam, że mój program działa nieprawidłowo.

Starajcie się tego unikać. Dobry programista każdy fragment kodu pisze tylko raz. Jeśli chcesz użyć czegoś więcej niż raz, zamień to w metodę.

Powinniśmy wziąć wszystkie zachowania z klasy CustomerAccount i zmienić tylko tę jedną metodę, która ma działać inaczej. Okazuje się, że w języku C# można coś takiego zrobić dzięki wykorzystaniu dziedziczenia. Przy tworzeniu klasy BabyAccount mogę powiedzieć kompilatorowi, że bazuje ona na klasie CustomerAccount:

public class BabyAccount : CustomerAccount,IAccount
{
}

Najważniejsza tutaj jest wyróżniona część kodu za nazwą klasy. Umieściłem tam nazwę klasy, którą rozszerza klasa BabyAccount. To znaczy, że klasa BabyAccount potrafi robić wszystko to, co klasa CustomerAccount.

Teraz mogę pisać taki kod:

BabyAccount b = new BabyAccount(); b.PayInFunds(50);

To jest możliwe, ponieważ choć klasa BabyAccount nie zawiera metody PayInFunds, ma ją klasa nadrzędna. To znaczy, że w tym przypadku używana jest metoda PayInFunds z klasy CustomerAccount.

W takim razie obiekty klasy BabyAccount potrafią robić wszystko to, co obiekty jej klasy nadrzędnej. W tej chwili klasa BabyAccount nie ma żadnych własnych zachowań, tylko same odziedziczone po rodzicu.

Przesłanianie metod

Wiemy już, że możemy tworzyć nowe klasy na bazie istniejących. Teraz chcielibyśmy dowiedzieć się, jak zmienić zachowanie interesującej nas metody, a konkretnie jak wymienić metodę WithdrawFunds na nową. Ta czynność nazywa się przesłanianiem metody. W klasie BabyAccount możemy to zrobić następująco:

public class BabyAccount : CustomerAccount,IAccount
{
public override bool WithdrawFunds (decimal amount)
{
if (amount > 10)
{
return false;
}
if (balance < amount)
{
return false;
}
balance = balance - amount; return true;
}
}

Słowo kluczowe override oznacza, że wolimy użyć tej metody zamiast metody o takiej samej nazwie z klasy nadrzędnej. W związku z tym w takim kodzie, jak ten:

BabyAccount b = new BabyAccount(); b.PayInFunds(50); b.WithdrawFunds(5);

wywołanie PayInFunds spowoduje użycie metody z klasy nadrzędnej (ponieważ ta nie została przesłonięta), natomiast wywołanie metody WithdrawFunds spowoduje użycie wersji z klasy BabyAccount.

Metody wirtualne

Aby przesłonięcie zadziałało, musimy zrobić jeszcze jedną rzecz. Kompilator C# musi wiedzieć, czy dana metoda będzie przesłaniana. Wynika to z tego, że przesłoniętą metodę musi wywoływać w odrobinę inny sposób niż „normalną”. Innymi słowy, powyższy kod nie da się prawidłowo skompilować, ponieważ nie poinformowano kompilatora, że metoda WithDrawFunds może zostać przesłonięta w klasach potomnych.

Aby dopełnić wszystkich wymogów formalnych przesłonięcia, muszę zmienić moją deklarację metody w klasie CustomerAccount.

public class CustomerAccount : IAccount
{

private decimal balance = 0;

public virtual bool WithdrawFunds ( decimal amount )
{
if (balance < amount)
{
return false;
}
balance = balance - amount; return true;
}

}

Słowo kluczowe virtual oznacza — „Mogę potrzebować innej wersji tej metody w klasie potomnej”. Nie musicie tego robić, ale dodatek tego słowa kluczowego Wam to umożliwia.

To sprawia, że słowa kluczowe override i virtual są czymś w rodzaju nierozłącznej pary. Słowo kluczowe virtual oznacza, że dana metoda może być przesłaniana, a słowo kluczowe override służy do definiowania nowej wersji tej metody.

Ochrona danych w hierarchii klas

Okazuje się, że powyższy kod wciąż jeszcze nie zadziała. Wiąże się to z tym, że wartość salda w klasie CustomerAccount jest prywatna. Tak ją zdefiniowaliśmy, aby inne klasy nie miały do niej dostępu i nie mogły jej zmieniać.

Tylko że to jest zbyt surowe ograniczenie, które uniemożliwia modyfikowanie tej wartości klasie BabyAccount. Do rozwiązania tego problemu w języku C# istnieje mniej restrykcyjny modyfikator dostępu o nazwie protected. Opatrzone nim składowe są widoczne w klasach rozszerzających klasę nadrzędną. Innymi słowy metody w klasie BabyAccount „widzą” i mogą używać chronionej składowej, ponieważ znajdują się w tej samej hierarchii klas, co klasa zawierająca tę składową.

Hierarchia klas jest niczym drzewo rodzinne. Każda klasa ma rodzica i może robić wszystko to, co ten rodzic. Ponadto ma dostęp do wszystkich składowych chronionych swojej klasy nadrzędnej.

public class CustomerAccount : IAccount
{
protected decimal balance = 0;

.....
}

Niechętnie to robię, ponieważ balance jest niezwykle ważną wartością i wolałbym, żeby nikt spoza klasy CustomerAccount jej nie używał. Na razie jednakta zmiana umożliwi działanie programu. Później pokażę Wam lepsze sposoby na załatwienie tej sprawy.

IAccount[] accounts = new IAccount[MAX_CUST];

accounts[0] = new CustomerAccount(); accounts[0].PayInFunds(50);
Console.WriteLine("Saldo: " + accounts[0].GetBalance());

accounts[1] = new BabyAccount(); accounts[1].PayInFunds(20);
Console.WriteLine("Saldo: " + accounts[1].GetBalance());

if (accounts[0].WithdrawFunds(20))
{
Console.WriteLine("Wypłata OK");
}
if (accounts[1].WithdrawFunds(20))
{
Console.WriteLine("Wypłata OK");
}

Przykład kodu 39. Wykorzystanie dziedziczenia

Powyższy kod ilustruje sposoby tworzenia i użycia różnych typów kont — klasa BabyAccount została utworzona na bazie nadrzędnego typu CustomerAccount. Jeśli masz wrażenie deja vu, to pewnie dlatego, że to jest dokładnie taki sam kod, jak w poprzednim przykładzie. Kod wykorzystujący te obiekty będzie działał dokładnie tak samo, jak zawsze, natomiast same obiekty będą zachowywały się odrobinę inaczej, dzięki czemu program stanie się mniejszy (co nas za bardzo nie martwi) i łatwiejszy do debugowania (ponieważ jakikolwiek błąd w którejś ze wspólnych metod wymaga naprawy tylko w jednym miejscu).

Zapiski bankiera: Przesłanianie dla pożytku i przyjemności

Możliwość przesłaniania metod to bardzo cenny element funkcjonalności języka programowania. Dzięki niemu możemy tworzyć bardziej ogólne klasy (takie jak CustomerAccount) i następnie dostosowywać je do specyficznych potrzeb (na przykład BabyAccount). Oczywiście takie działania powinny być zaplanowane już na etapie projektowania programu. A to oznacza, że powinniście zgromadzić więcej metadanych od klienta, aby zdecydować, które elementy funkcjonalności będą wymagały modyfikacji w późniejszym czasie. Metodę WithDrawFunds uczynilibyśmy wirtualną, ponieważ kierownik powiedziałby nam, że chce mieć możliwość różnicowania sposobów wypłacania pieniędzy. A my zapisalibyśmy to w specyfikacji.

Metoda bazowa

Pamiętajcie, że programiści są leniwi z zasady i zawsze szukają sposobu, aby tylko raz napisać kod rozwiązujący dany problem. W takim razie wygląda na to, że łamiemy własne zasady, ponieważ metoda WithDrawFunds klasy BabyAccount zawiera cały kod metody z klasy nadrzędnej.

Już pisałem, że mi się to nie podoba, ponieważ zmusza nas do udzielenia większych uprawnień dostępu do wartości balance niż byśmy chcieli. Na szczęście projektanci języka C# pomyśleli o tym i stworzyli mechanizm umożliwiający wywołanie metody bazowej w metodzie, która ją przesłania.

W tym przypadku słowo „bazowa” oznacza odwołanie do tego, co zostało przesłonięte. Z pomocą tego mechanizmu mogę znacznie uprościć metodę WithDrawFunds w mojej klasie BabyAccount:

public class BabyAccount : CustomerAccount, IAccount
{
public override bool WithdrawFunds (decimal amount)
{
if (amount > 10)
{
return false;
}
return base.WithdrawFunds(amount);
}
}

Przykład kodu 40. Przesłonięcie metody bazowej

W ostatnim wierszu metody WithDrawFunds znajduje się wywołanie jej pierwowzoru — metody WithDrawFunds z klasy nadrzędnej, tzn. tej, którą ta metoda przesłania. Koniecznie musicie zrozumieć, co i dlaczego ja tu robię:

  • Nie chcę pisać dwa razy tego samego kodu
  • Nie chcę, aby wartość balance była widoczna na zewnątrz klasy CustomerAccount.

Użycie słowa kluczowego base do wywołania przesłoniętej metody rozwiązuje oba te problemy w bardzo elegancki sposób. Jako że ta metoda zwraca w wyniku wartość bool, mogę wysłać cokolwiek od niej dostanę. Dzięki tej zmianie mogę z powrotem uczynić wartość balance prywatną w klasie CustomerAccount, ponieważ nie jest ona modyfikowana na zewnątrz.

Warto zwrócić uwagę na jeszcze inne konsekwencje wprowadzonych tu zmian. Jeśli będę chciał naprawić błąd w zachowaniu metody WithDrawFunds, to wystarczy zrobić to tylko raz, w klasie najwyższego poziomu, a poprawka będzie obecna we wszystkich klasach, które z niej korzystają.

Zamiana metody

To może wydawać się dziwne, ale jeśli dobrze się zastanowisz, to zrozumiesz, że ma sens. Kiedy pobawisz się trochę z językiem C#, to odkryjesz, że tak naprawdę wcale nie potrzebujesz słowa kluczowego virtual, aby przesłonić metodę. Jeśli je opuszczę (i to samo zrobię ze słowem override), program nadal będzie dobrze działał.

To wynika z tego, że w tej sytuacji nie odbywa się żadne przesłonięcie. Po prostu stworzyliśmy nową wersję metody (kompilator C# nawet powiadomi Was, że powinniście to zaznaczyć za pomocą słowa kluczowego new):

public class BabyAccount : CustomerAccount,IAccount
{
public new bool WithdrawFunds (decimal amount)
{
if (amount > 10)
{
return false;
}
if (balance < amount)
{
return false;
}
balance = balance - amount; return true;
}
}

Złota myśl programisty: Nie zamieniaj metod

Jestem zdecydowanie przeciwny zamianie metod zamiast ich przesłaniania. Jeśli zamierzasz pozwolić programistom tworzyć specjalne wersje klas w taki sposób, to o wiele lepszym rozwiązaniem jest stosowanie techniki przesłaniania, która ułatwia zapanowanie nad przesłoniętym kodem. W ogóle zastanawiam się, po co ja o tym pisałem.

Uniemożliwianie przesłaniania

Przesłanianie to technika dająca duże możliwości. Dzięki niej programista może stworzyć nową klasę zawierającą wszystkie zachowania klasy nadrzędnej i odpowiednio je zmodyfikować. To pozwala na stosowanie metody projektowania, w myśl której im klasa jest niżej w hierarchii „drzewa rodzinnego”, tym jest bardziej specyficzna.

Tylko że przesłanianie/zamiana nie zawsze są pożądane. Weźmy na przykład metodę GetBalance. Jej nigdy nie będzie trzeba wymieniać. A mimo to jakiś niesubordynowany programista może napisać własną i przesłonić lub zamienić nią metodę z klasy nadrzędnej:

public new decimal GetBalance ()
{
return 1000000;
}

To bankowy odpowiednik butelki piwa, która nigdy nie robi się pusta. Niezależnie od tego, ile gotówki pobierzemy, ta metoda zawsze będzie pokazywać, że na koncie jest milion złotych!

Ten przebiegły programista może umieścić tę metodę w klasie, aby dać sobie możliwość robienia nieograniczonych zakupów. To znaczy, że musimy mieć możliwość oznaczania niektórych metod jako „nieprzesłanialnych. W języku C# służy do tego słowo kluczowe sealed, które oznacza – „Tej metody nie można przesłaniać”.

Niestety, aby skorzystać z tej opcji, trzeba się trochę namęczyć. Zasady są takie, że zapieczętować (and. seal) można tylko metodę przesłaniającą (czyli nie możemy tego zrobić z metodą wirtualną GetBalance w klasie CustomerAccount), a poza tym jakaś wredna osoba zawsze może zamienić zapieczętowaną metodę w klasie nadrzędnej na niezapieczętowaną metodę o takiej samej nazwie w klasie potomnej.

Innym zastosowaniem słowa kluczowego sealed, które ma trochę większy potencjał praktyczny, jest uniemożliwienie rozszerzania klasy, tzn. sprawienie, że nie będzie mogła być używana jako baza do utworzenia innej klasy.

public sealed class BabyAccount : CustomerAccount,IAccount
{
.....
}

Teraz kompilator nie pozwoli na użycie klasy BabyAccount jako bazy do utworzenia kolejnego typu konta.

Zapiski bankiera: Chroń swój kod

Jeśli chodzi o aplikację bankową, klient niespecjalnie będzie się przejmował tym, w jaki sposób używasz słowa kluczowego sealed w swoich programach. Jego będzie interesowało to, czy Twój kod działa prawidłowo. Pracując w tej branży musicie pogodzić się z faktem, że nie wszyscy użytkownicy waszych komponentów będą mili i przyjaźnie nastawieni. W związku z tym podczas projektowania programu zawsze dobrze się zastanówcie, czy dane metody powinny być wirtualne oraz zawsze stosujcie pieczętowanie, jeśli jest taka możliwość.

Na tym poziomie nauki programowania chyba nie ma sensu rozwodzić się nad zawiłościami tego typu, więc nie przejmujcie się, jeśli nie wszystko to do was trafia. Wystarczy zapamiętać, że podczas tworzenia programu jest jeszcze jeden czynnik ryzyka, który trzeba wziąć pod uwagę.

Konstruktory i hierarchie

Konstruktor jest metodą, która kontroluje proces tworzenia obiektu. Z jej pomocą programista może ustawić początkowe wartości w obiekcie:

robsAccount = new BabyAccount("Rob Miles", 100);

W tym przypadku chcemy utworzyć dla dziecka o nazwisku Rob Miles nowe konto z saldem początkowym 100 zł.

Ten kod zadziała tylko, jeśli klasa BabyAccount będzie miała konstruktor przyjmujący łańcuch jako pierwszy parametr i wartość dziesiętną jako drugi.

Pewnie myślicie sobie, że mógłbym napisać taki konstruktor:

public BabyAccount (string inName, decimal inBalance)
{
name = inName; balance = inBalance;
}

Tylko że klasa BabyAccount jest rozszerzeniem klasy CustomerAccount. Innymi słowy, aby utworzyć obiekt klasy BabyAccount, program musi utworzyć obiekt klasy CustomerAccount.

W takim razie powinniśmy zacząć od dodania konstruktora do klasy CustomerAccount, która ustawia imię i nazwisko oraz saldo konta klienta:

public CustomerAccount (string inName, decimal inBalance) :
base ( inName, inBalance)
{
// ustawianie imienia i nazwiska oraz salda
}

Mając konstruktor w klasie nadrzędnej, możemy napisać konstruktor w klasie potomnej, a następnie możemy go wywołać. Do wywoływania konstruktora klasy nadrzędnej służy słowo kluczowe base.

Słowa kluczowego base używa się tak samo, jak this do wywoływania innego konstruktora tej samej klasy. Powyższy konstruktor zakłada, że klasa Account, dla której klasa CustomerAccount jest potomkiem, zawiera konstruktor przyjmujący dwa parametry — pierwszy jest łańcuchem, a drugi jest wartością dziesiętną.

Łańcuchy konstruktorów

Przy projektowaniu konstruktorów i hierarchii klas należy pamiętać, że do utworzenia obiektu klasy potomnej potrzebne jest uprzednie utworzenie egzemplarza klasy nadrzędnej. To znaczy, że konstruktor z klasy nadrzędnej musi zostać wywołany przed konstruktorem z klasy potomnej. Innymi słowy, aby utworzyć obiekt klasy BabyAccount, najpierw musisz utworzyć obiekt klasy CustomerAccount. To prowadzi do konieczności tworzenia tzw. łańcuchów konstruktorów. Programista musi dopilnować, aby na każdym poziomie procesu tworzenia został wywołany konstruktor klasy należącej do tego poziomu.

To ilustruje problem, jaki możecie napotkać z hierarchiami klas. Są dość delikatne i trzeba się z nimi obchodzić jak z jajkiem. Zmiany w jednej części hierarchii klas mogą pociągać za sobą konieczność dokonania zmian w innych częściach. W powyższym przypadku wpadliśmy na sprytny pomysł, jak udoskonalić proces tworzenia obiektów klasy BabyAccount, co skończyło się koniecznością wprowadzenia zmian także w klasie CustomerAccount.

Złota myśl programisty: Zaplanuj proces korukcji obiektów swoich klas

Sposób tworzenia obiektów swoich klas w systemie powinniście mieć zaplanowany z góry. Jest to element ogólnej architektury budowanego systemu. Czasami myślę sobie o tych rzeczach jak o filarach, na których opierają się stropy i dach budynku. Informują one programistów odpowiedzialnych za implementację o tym, jak tworzyć te komponenty. Oczywiście te projekty powinny być spisane i dostępne dla zespołu programistycznego.

Autor: Rob Miles

Tłumaczenie: Łukasz Piwko