Rozdział 17. Metody i klasy abstrakcyjne

20 maja 2022
1 gwiadka2 gwiazdki3 gwiazdki4 gwiazdki5 gwiazdek

Aktualnie technikę przesłaniania wykorzystujemy do modyfikowania sposobu działania metod w klasach potomnych. Ale to nie jest jedyne zastosowanie tej techniki. Za jej pomocą mogę wymusić określony zbiór zachowań w odniesieniu do elementów hierarchii klas. Jeśli konto musi wykonywać pewne czynności, to możemy uczynić je abstrakcyjnymi i zmusić klasy potomne do ich rzeczywistej implementacji.

Na przykład, w przypadku aplikacji bankowej możemy napisać metodę tworzącą ostrzeżenie dla klienta, gdy ten przekroczy stan swojego konta. Taka wiadomość powinna być inna dla każdego typu konta (nie będziemy pisać takim samym językiem do małego dziecka, co do starszego). To znaczy, że w chwili tworzenia systemu kont bankowych wiemy, że ta metoda jest potrzebna, ale nie wiemy dokładnie, jak powinna działać w różnych sytuacjach.

Wprawdzie moglibyśmy utworzyć „standardową” metodę w klasie CustomerAccount i zlecić programistom jej przesłonięcie w konkretnych przypadkach, ale nie mielibyśmy gwarancji, że się nas posłuchają.

Dlatego w języku C# dodano możliwość oznaczania metod słowem kluczowym abstract. Oznacza ono, że w tej klasie ta metoda nie ma konkretnej implementacji, ale będzie ją miała w klasie potomnej:

public abstract class Account
{
public abstract string RudeLetterString();
}

Obecność metody abstrakcyjnej w nowej klasie Account oznacza, że sama ta klasa także jest abstrakcyjna (i musi zostać tak oznaczona). Nie da się utworzyć obiektu klasy abstrakcyjnej. Gdyby się nad tym zastanowić, to ma to sens. Obiekt klasy Account nie wiedziałby, co zrobić, gdyby ktoś wywołał jego metodę RudeLetterString.

Klasę abstrakcyjną można traktować jako coś w rodzaju szablonu. Jeśli chcecie utworzyć obiekt klasy bazującej na klasie abstrakcyjnej, to musicie dostarczyć implementacje wszystkich abstrakcyjnych metod znajdujących się w klasie nadrzędnej.

Klasy abstrakcyjne i interfejsy

Możecie odnieść wrażenie, że klasa abstrakcyjna przypomina z wyglądu interfejs. To trafne spostrzeżenie, ponieważ interfejs także przedstawia listę zakupów, tzn. metod, które klasa powinna dostarczyć. Jednak klasy abstrakcyjne różnią się od interfejsów tym, że mogą zawierać zarówno zaimplementowane jak i abstrakcyjne metody. To jest korzystne, ponieważ nie musicie wielokrotnie implementować tych samych metod w każdym z komponentów, które implementują dany interfejs.

Należy tylko pamiętać, że klasa może mieć tylko jednego rodzica, a więc może zgarniać zachowania tylko jednej klasy. Jeśli chcecie także implementować interfejsy, to możecie być zmuszeni do powtarzania implementacji metod.

W tym miejscu przydałby się jakiś praktyczny przykład. W przypadku naszego konta bankowego możemy wyróżnić dwa typy zachowań:

  • takie, które muszą dostarczać wszystkie typy kont (na przykład PayInFunds i GetBalance)
  • takie, które każdy typ konta powinien wykonywać w specyficzny dla siebie sposób (na przykład WithdrawFunds i RudeLetterString)

Sztuka polega na tym, aby wszystkie metody z pierwszej kategorii umieścić w klasie nadrzędnej. Natomiast metody z drugiej kategorii powinny być abstrakcyjne. To prowadzi nas do skonstruowania następującej klasy:

public interface IAccount
{
void PayInFunds ( decimal amount ); bool WithdrawFunds ( decimal amount ); decimal GetBalance ();
string RudeLetterString();
}

public abstract class Account : IAccount
{
private decimal balance = 0;

public abstract string RudeLetterString();

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

public decimal GetBalance ()
{
return balance;
}

public void PayInFunds ( decimal amount )
{
balance = balance + amount;
}
}

public class CustomerAccount : Account
{
public override string RudeLetterString()
{
return "Przekroczono saldo konta." ;
}
}

public class BabyAccount : Account
{
public override bool WithdrawFunds ( decimal amount )
{
if (amount > 10)
{
return false;
}
return base.WithdrawFunds(amount);
}
public override string RudeLetterString()
{
return "Powiedz tacie, że masz za mało pieniędzy.";
}
}

Przykład kodu 41. Użycie metody abstrakcyjnej

Ten kod odwdzięcza się za staranne zaprojektowanie. Zwróćcie uwagę, że wszystkie zachowania wspólne dla wszystkich kont przeniosłem do klasy nadrzędnej Account. Następnie, tam gdzie była taka potrzeba, dodałem specjalne metody do klas potomnych. Zauważcie też, że nie ruszałem interfejsu. Zostawiłem go, ponieważ mimo że stworzyłem abstrakcyjną strukturę, to nadal chcę móc myśleć o obiektach w kategoriach ich bycia kontami, a nie konkretnymi typami.

Jeśli utworzymy nowe rodzaje kont oparte na nadrzędnej klasie Account, to każda nowa klasa będzie musiała zawierać własną wersję metody RudeLetterString.

Odwołania do klas abstrakcyjnych

Odwołania do klas abstrakcyjnych nie różnią się od odwołań do interfejsów. Odwołanie do klasy Account może dotyczyć dowolnej klasy, która ją rozszerza. Czasami się to przydaje, ponieważ dany obiekt możemy traktować po prostu jako konto, a nie jako konto konkretnie typu BabyAccount.

Zdecydowanie jednak wolę, kiedy odwołania do bytów abstrakcyjnych, takich jak konta, są realizowane poprzez ich interfejsy.

Projektowanie z użyciem obiektów i komponentów

W tej chwili macie już szeroką wiedzę na temat narzędzi umożliwiających projektowanie dużych systemów programowych. Jeśli umiecie posługiwać się interfejsami i klasami abstrakcyjnymi, to jesteście na dobrej drodze do zrobienia kariery programistycznej. Ogólnie mówiąc:

Interfejs: umożliwia określenie zestawu zachowań (tzn. metod) do zaimplementowania przez komponent. Każdy komponent implementujący dany interfejs można traktować jako obiekt typu reprezentowanego przez ten interfejs. Konkretnym przykładem interfejsu jest np. IPrintHardCopy. Takiej funkcjonalności potrzebuje wiele elementów mojego systemu bankowego, więc dobrym pomysłem jest stworzenie interfejsu nakazującego ich implementację w odpowiedni sposób dla danego przypadku. Potem drukarka wydrukuje, co jej się każe. Interfejsy umożliwiają opisywanie zachowań, które powinny być zaimplementowane przez komponent. Kiedy komponent implementuje interfejs, można go traktować jako komponent o określonej funkcjonalności. Obiekty mogą implementować więcej niż jeden interfejs, dzięki czemu mogą prezentować się w systemie na różne sposoby.

Abstrakcja: umożliwia utworzenie klasy nadrzędnej, która jest jakby szablonem do tworzenia kolejnych klas na jej podstawie. Jeśli chcecie utworzyć zestaw powiązanych ze sobą elementów, na przykład różne rodzaje konta bankowego, takie jak do obsługi karty kredytowej, depozytowe, rachunek bieżący itd., to najlepszym rozwiązaniem będzie utworzenie klasy nadrzędnej zawierającej metody abstrakcyjne i nieabstrakcyjne. Klasy potomne mogą używać metod z klasy nadrzędnej i przesłaniać te, które w każdej konkretnej klasie muszą być inne.

Odwołania za pośrednictwem interfejsów

Warto podkreślić, że nawet jeśli użyjecie abstrakcyjnej klasy nadrzędnej, to moim zdaniem i tak powinniście korzystać z interfejsów, aby odwoływać się do samych obiektów danych. Daje to pewien stopień elastyczności, który można praktycznie wykorzystać.

Zapiski bankiera: Jak dobrze korzystać z interfejsów i komponentów abstrakcyjnych

Jeśli wasz bank przejmie inny bank i dojdzie do udostępnienia danych kont, to możecie potrzebować sposobu na ich wykorzystanie. Jeśli konta tego drugiego banku też będą komponentami programowymi (a powinny), to wystarczy zaimplementować wymagane interfejsy po każdej stronie, aby systemy zaczęły się wzajemnie rozumieć. Innymi słowy, drugi bank musi utworzyć metody zawarte w interfejsie IAccount, zaimplementować ten interfejs w swoich obiektach kont (jakkolwiek się nazywają) i gotowe — od tej pory będziemy mogli używać ich kont.

To byłoby o wiele trudniejsze, gdyby cały mój system był zbudowany na bazie nadrzędnej klasy Account, ponieważ klasy drugiego banku w ogóle by nie pasowały do tej hierarchii.

Nie panikujcie

To zaawansowane sprawy. Nie przejmujcie się, jeśli nie wszystko łapiecie. Te elementy języka C# są związane z procesem projektowania programu, który jest bardzo skomplikowany. Najważniejsze, aby zapamiętać, że te wszystkie składniki służą jednemu celowi:

tworzeniu programów składających się z bezpiecznych i wymiennych komponentów.

Interfejsy opisują, co potrafi robić każdy komponent. Natomiast hierarchie klas umożliwiają wielokrotne wykorzystanie kodu wewnątrz tych komponentów. I tyle.

Autor: Rob Miles

Tłumaczenie: Łukasz Piwko

Dyskusja

Twój adres e-mail nie zostanie opublikowany.