Osobiście uważam, że w miarę rozwijania swoich umiejętności programistycznych programista uczy się patrzeć na problemy na coraz to wyższym poziomie abstrakcji. Czyli nabiera umiejętności przyjmowania coraz większego dystansu. Poczyniliśmy już taki postęp:
- reprezentacja wartości przez nazwane miejsca (zmienne)
- tworzenie akcji operujących na zmiennych (instrukcje i bloki)
- dawanie możliwości wykonywania działań fragmentom kodu, którym nadaliśmy nazwy. Działania te możemy wykonywać wielokrotnie oraz możemy je wykorzystać w procesie projektowania (metody)
- tworzenie bytów zawierających zmienne składowe jako własności i metody składowe jako akcje (obiekty)
Zamiast zastanawiać się na początku projektu, jak ma wyglądać reprezentacja konta oraz co dokładnie powinno ono robić, po prostu mówimy, że tu jest potrzebne konto i przechodzimy do innych zadań. Potem wrócimy do tego i zajmiemy się tym bardziej szczegółowo w odniesieniu do wymagań, jakie musi spełniać klasa Account
.
Następnym etapem jest przyjęcie jeszcze ogólniejszej perspektywy, aby wyrazić rozwiązanie w kategoriach komponentów i interfejsów. W tej sekcji poznacie różnicę między obiektem i komponentem oraz dowiecie się, jak projektować systemy przy ich użyciu.
Komponenty i sprzęt
Zanim zajmiemy się sprawami z programistycznego punktu widzenia, dobrze nam zrobi łyk wiedzy na ten temat od strony sprzętowej. Zapewne wiecie, że niektóre komponenty komputera, który macie w domu, nie są do niego przymocowane na stałe. Na przykład karta graficzna zazwyczaj jest osobnym urządzeniem podłączonym do płyty głównej. To dobrze, ponieważ to znaczy, że w każdej chwili mogę kupić nową kartę graficzną i podłączyć ją do mojego komputera, aby zwiększyć jego wydajność.
Aby to sprawnie działało, producenci płyt głównych muszą ustalić z producentami kart graficznych jeden interfejs służący do łączenia tych urządzeń. Ma on formę obszernego dokumentu zawierającego szczegółowy opis połączeń komponentów, obsługi poszczególnych typów sygnałów itd. Do każdej płyty mającej gniazdo spełniające warunki tej normy można podłączyć kartę graficzną.
Zatem z punktu widzenia sprzętu możemy używać komponentów, ponieważ utworzyliśmy standardowe interfejsy, które dokładnie opisują interakcje między nimi.
Komponenty programowe są dokładnie takie same.
Do czego są nam potrzebne komponenty programowe?
Na razie możecie nie dostrzegać żadnych korzyści z tworzenia komponentów. Kiedy tworzymy system, określamy z jakich części się ma składać i co powinny one robić, po czym je tworzymy. Na tym etapie nie jest jasne, do czego mogłyby być potrzebne komponenty.
Projekt systemu bez komponentów można porównać do komputera z kartą graficzną wbudowaną na stałe w płytę główną. Nie ma możliwości jej ulepszenia, ponieważ jest „przylutowana” do urządzenia.
Niestety teraz w naszym systemie bankowym sytuacja właśnie tak wygląda, gdybyśmy chcieli utworzyć różne rodzaje klasy konta bankowego. Kierownictwo może na przykład poprosić nas o utworzenie klasy o nazwie BabyAccount
, którego właściciel będzie mógł jednorazowo wypłacić maksymalnie 20 złotych. Taka prośba może się pojawić nawet dopiero po zainstalowaniu i wdrożeniu systemu do użytku.
Jeśli wszystko w nim będzie powiązane na stałe, to takie modyfikacje będą niemożliwe. Jeśli natomiast będziemy opisywać obiekty na podstawie ich interfejsów, to będziemy mogli użyć czegokolwiek, co zachowuje się jak konto.
Komponenty i interfejsy
W tym miejscu pragnę podkreślić, że nie mówimy o interfejsie użytkownika naszego programu. Interfejs użytkownika to element programu, który pozwala mu na jego obsługę. Najczęściej spotykane interfejsy użytkownika to interfejs tekstowy (użytkownik wpisuje polecenia i otrzymuje odpowiedzi) i graficzny (użytkownik klika „przyciski” na ekranie za pomocą myszy).
Z kolei interfejs określa tylko, jak komponent programowy może być wykorzystywany przez inny komponent programowy. Lepiej nie próbujcie na egzaminie z języka C# opowiadać, że interfejs składa się z okien i przycisków. Pała murowana.
Interfejsy i budowa programu
W takim razie pracę powinniśmy zacząć nie od wyznaczania klas, tylko od opisu ich interfejsów, tzn. określenia tego, co mają robić. W języku C# takie informacje wyrażamy właśnie za pomocą konstrukcji zwanej interfejsem. Interfejs to po prostu zbiór metod.
Poniżej znajduje pierwsza wersja interfejsu konta w naszym banku:
public interface IAccount
{
void PayInFunds ( decimal amount ); bool WithdrawFunds ( decimal amount ); decimal GetBalance ();
}
Ten kod oznacza, że interfejs IAccount
zawiera trzy metody. Jedna obsługuje wpłacanie pieniędzy, druga — wypłacanie, a trzecia — zwraca saldo konta. Jeśli chodzi o podstawową obsługę salda, to niczego więcej nam nie trzeba. Zwróćcie uwagę, że w interfejsie nie ma nic na temat tego, jak dane czynności należy wykonać, a jedynie, co powinno zostać zrobione. Interfejsy zapisuje się w takich samych plikach źródłowych, jak klasy i tak samo się je kompiluje. Zawierają one pewien zestaw metod dotyczących określonego zadania lub roli. W tym przypadku są to wymogi, jakie musi spełnić klasa, aby została uznana za konto bankowe.
Złota myśl programisty: nazwy interfejsów zaczynają się od litery I
Wiecie już, że istnieją specjalne zbiory wytycznych zawierające także informacje na temat standardowego nadawania nazw zmiennym, zwane konwencjami kodowania. Istnieje też konwencja mówiąca o tym, że nazwa interfejsu powinna zaczynać się od litery I
. Jeśli ją zignorujecie, to wasze programy nie będą miały problemu z kompilacją, ponieważ kompilator nie ma zdania na temat konwencji nazewniczych, ale możecie słabo spać w nocy. I bardzo dobrze.
Implementacja interfejsu w C#
Interfejs staje się ciekawy, kiedy implementujemy go w klasie. Implementowanie interfejsu jest jak zawieranie umowy między dostawcą zasobów i ich konsumentem. Jeśli klasa implementuje interfejs, to ogłasza, że ma odpowiednią implementację dla każdej zawartej w nim metody.
Jeśli chodzi o konto bankowe, to zamierzam utworzyć klasę implementującą interfejs, aby można było o niej myśleć jako o komponencie niezależnie od tego, czym naprawdę jest.
public class CustomerAccount : IAccount
{
private decimal balance = 0;
public bool WithdrawFunds (decimal amount)
{
if (balance < amount)
{
return false;
}
balance = balance - amount; return true;
}
public void PayInFunds ( decimal amount )
{
balance = balance + amount;
}
public decimal GetBalance ()
{
return balance;
}
}
Powyższy kod niewiele różni się od kodu poprzedniej klasy konta. Jedyna różnica znajduje się w pierwszym wierszu:
public class CustomerAccount : IAccount
{
...
Wyróżniony fragment tego wiersza to miejsce, w którym programista informuje kompilator, że ta klasa implementuje interfejs IAccount
. To znaczy, że ta klasa zawiera konkretne wersje wszystkich metod opisanych w tym interfejsie. Jeśli klasa nie będzie zawierała metody wymaganej przez interfejs, to kompilator zgłosi błąd:
error CS0535: 'AccountManagement.CustomerAccount' does not implement interface member 'AccountManagement.IAccount.PayInFunds(decimal)'
W tym przypadku zapomniałem o metodzie PayInFunds
, więc kompilator mi o niej przypomniał.
Odwołania do interfejsów
Po skompilowaniu klasy CustomerAccount
otrzymujemy coś, na co można patrzeć w dwojaki sposób:
- jako
CustomerAccount
(tym właśnie jest) - jako
IAccount
(to potrafi robić)
Ludzie ciągle to robią. O mnie można myśleć na najróżniejsze sposoby:
- Osoba Rob Miles (tym właśnie jestem)
- Wykładowca uniwersytecki (to umiem robić)
Jeśli będziecie o mnie myśleć jako o wykładowcy, to możecie używać interfejsu zawierającego na przykład metodę GiveLecture
. Tych samych metod możecie używać także w odniesieniu do innych wykładowców (czyli osób implementujących dany interfejs). Z punktu widzenia uniwersytetu, który zatrudnia wielu wymiennych wykładowców, o wiele praktyczniej jest myśleć o mnie jako o wykładowcy niż osobie o imieniu i nazwisku Rob Miles.
Zatem interfejsy pozwalają nam zmienić sposób myślenia o klasach z tego, czym są na to, co potrafią robić. W odniesieniu do naszego banku to oznacza, że obiektami będziemy posługiwać się na warunkach interfejsu IAccount
(zbiór funkcji konta), a nie klasy CustomerAccount (konkretna klasa konta).
W języku C# oznacza to, że musimy mieć możliwość tworzenia zmiennych referencyjnych, które odnoszą się do obiektów w kategoriach interfejsów, które implementują, a nie konkretnego typu, który reprezentują. Powiem wam, że to nic trudnego:
IAccount account = new CustomerAccount(); account.PayInFunds(50);
Console.WriteLine("Saldo: " + account.GetBalance());
Przykład kodu 37. Prosty interfejs
Zmienna account
może odnosić się do obiektów implementujących interfejs IAccount
. Kompilator sprawdzi, czy klasa CustomerAccount
go implementuje i jeśli tak, to kod przejdzie kompilację.
Należy pamiętać, że nigdy nie powstanie egzemplarz interfejsu IAccount
. Z jego pomocą możemy tylko odwoływać się do obiektów posiadających określone w nim funkcje (tzn. zawierających wymagane metody).
Tak samo jest w prawdziwym życiu. Fizycznie nie istnieje coś takiego jak „wykładowca”, tylko duża grupa ludzi, o których można powiedzieć, że mają tę konkretną umiejętność lub pełnią tę rolę.
Używanie interfejsów
Dzięki interfejsom nasz system jest znacznie łatwiejszy do rozszerzania. Mogę utworzyć klasę BabyAccount
implementującą interfejs IAccount
. Będzie ona implementowała wszystkie wymagane metody, ale będą one działały odrobinę inaczej, ponieważ tym razem chcemy, aby wszystkie wypłaty w kwocie powyżej 20 złotych były blokowane:
public class BabyAccount : IAccount
{
private decimal balance = 0;
public bool WithdrawFunds (decimal amount)
{
if (amount > 10)
{
return false;
}
if (balance < amount)
{
return false;
}
balance = balance - amount; return true;
}
public void PayInFunds ( decimal amount )
{
balance = balance + amount;
}
public decimal GetBalance ()
{
return balance;
}
}
Zaletą takiego komponentu jest to, że nie musimy zmieniać wszystkich klas, które go używają. Kiedy tworzymy obiekt konta, wystarczy że zapytamy, czy ma to być konto standardowe, czy dla dziecka. Następnie reszta systemu może korzystać z takiego obiektu bez przejmowania się szczegółami. Oczywiście będziemy musieli utworzyć parę testów, które dadzą nam pewność, że rzeczywiście nie da się w żaden sposób wypłacić więcej niż 20 zł na raz. Ale posługiwanie się nowym rodzajem konta w naszym istniejącym systemie jest bardzo łatwe.
To znaczy, że możemy tworzyć komponenty używane w ten sam sposób, ale mające różne zachowania, odpowiednie dla typu reprezentowanego przez siebie przedmiotu.
class Bank
{
const int MAX_CUST = 100;
public static void Main()
{
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 38. Użycie komponentów
W powyższym przykładzie pokazałem, jak to może wyglądać w praktyce. Tablica accounts
może przechowywać referencje do dowolnego obiektu implementującego interfejs IAccount
. Zaliczają się do nich obiekty klas CustomerAccount
i BabyAccount
. Pierwszy element tablicy (o numerze 0
) jest przypisany do obiektu klasy CustomerAccount
, a drugi (o numerze 1
) — do obiektu klasy BabyAccount
. Kiedy są wywoływane metody tych obiektów, użyte zostają te należące do odpowiedniego typu. To znaczy, że ostatnie wywołanie metody WithdrawFunds
się nie powiedzie, mimo że na koncie znajduje się wystarczająco dużo środków, ponieważ dziecku nie można wypłacić więcej niż 20 zł.
Implementacja kilku interfejsów
Komponent może implementować dowolną liczbę interfejsów. Interfejs IAccount
umożliwia patrzenie na komponent wyłącznie pod kątem jego możliwości zachowywania się jak konto bankowe. Ja jednak mogę chcieć traktować taki komponent na różne sposoby. Bank na przykład będzie chciał mieć możliwość wydrukowania konta na papierze.
Pewnie sobie myślicie, że w takim razie wystarczy dodać metodę drukującą do interfejsu IAccount
. Byłoby to dobrym pomysłem, gdybym nigdy w życiu nie chciał drukować nic innego niż konta bankowe. A przecież jest jeszcze wiele innych rzeczy do wydrukowania, na przykład ponaglenia, oferty specjalne itd. Każdy z tych elementów będzie zaimplementowany w kontekście komponentu udostępniającego określony interfejs (na przykład IWarning
, ISpecialOffer
). Nie chcę być zmuszony do definiowania metody drukującej w każdym z nich, tylko wolałbym mieć możliwość patrzenia na obiekt pod kątem jego zdolności do drukowania.
To jest bardzo łatwe. Tworzę taki interfejs:
public interface IPrintToPaper
{
void DoPrint();
}
Od tej pory każdy obiekt implementujący interfejs IPrintToPaper
będzie zawierał metodę DoPrint
i będzie można myśleć o nim a kategorii zdolności do drukowania.
Klasa może implementować dowolną liczbę interfejsów. Każdy z nich jest innym możliwym sposobem odnoszenia się do jej obiektów.
public class BabyAccount : IAccount, IPrintToPaper
{
...
To znaczy, że egzemplarz klasy BabyAccount
zachowuje się jak konto i dodatkowo zawiera metodę DoPrint
, za pomocą której można go wydrukować.
Projektowanie przy użyciu interfejsów
Jeśli będziecie prawidłowo posługiwać się techniką „abstrakcji”, to wasz proces tworzenia systemu powinien przebiegać mniej więcej tak:
- Zgromadzenie jak największej ilości metadanych na temat problemu — co jest ważne dla klienta, jakie wartości powinny być reprezentowane i przetwarzane oraz ich zakres
- Identyfikacja klas, które będą potrzebne do reprezentacji komponentów składowych problemu
- Identyfikacja działań (metod) i wartości (własności), które te komponenty powinny dostarczać
- Umieszczenie tych składników w interfejsach dla każdego z komponentów
- Wybór sposobu testowania tych wartości i działań
- Implementacja komponentów i ich testowanie na bieżąco
Znaczną część tej pracy można i powinno się wykonać na papierze, zanim napisze się choćby jeden wiersz kodu. Istnieją także narzędzia graficzne do rysowania formalnych schematów przedstawiających te informacje. Cała dziedzina inżynierii oprogramowania opiera się na takim sposobie pracy.
Powinniście też już zauważyć, że interfejsy dobrze nadają się do podczepiania testów. Jeśli masz określony zestaw podstawowych zachowań, które każde konto powinno udostępniać (na przykład wpłata pieniędzy, która zawsze powoduje zwiększenie salda konta), to możesz napisać testy powiązane z interfejsem IAccount
i służące do testowania dowolnego komponentu bankowego, jaki zostanie utworzony.
Złota myśl programisty: interfejsy to tylko obietnice
Interfejs jest nie tyle wiążącą umową, co obietnicą. To że klasa zawiera metodę o nazwie PayInFunds
nie znaczy jeszcze, że będzie ona wpłacać środki na konto. To tylko znaczy, że w klasie znajduje się metoda o takiej właśnie nazwie. W języku C# nie ma mechanizmu, który mógłby was zmusić do zaimplementowania określonego zachowania w metodzie. To kwestia waszego zaufania do programisty, który utworzył klasę, z której korzystacie i jakości waszych testów. Czasami nawet wykorzystujemy tę swobodę w dobrym celu podczas budowy programu. Tworzymy komponenty „na niby”, które implementują dany interfejs, ale nie zawierają rzeczywistych zachowań jako takich.
Mechanizm interfejsów daje nam szerokie możliwości w zakresie tworzenia i uzgadniania ze sobą komponentów. To znaczy, że kiedy już określimy, co nasza klasa konta bankowego powinna zawierać, możemy przejść do zastanowienia się, co konta powinny robić. To już jest szczegół specyfikacji. Po stworzeniu interfejsu dla komponentu możemy zacząć myśleć w kategoriach, co dany komponent ma robić, a nie jak dokładnie ma to robić.
Na przykład, kierownik powiedział nam, że każde konto bankowe musi mieć numer. To bardzo ważna wartość, ponieważ zostanie ustalona raz i już nigdy się nie zmieni przez cały okres istnienia konta. Dwa konta nigdy nie mogą mieć takiego samego numeru.
Z punktu widzenia projektowania interfejsu, to oznacza, że numer konta będzie ustawiany w chwili jego tworzenia i oraz że klasa konta będzie zawierała metodę pozwalającą go sprawdzić (natomiast nie będzie metody służącej do ustawiania numeru konta).
Nie obchodzi nas, co dokładnie robi metoda GetAccountNumber
, jeśli zawsze zwraca nam odpowiednią wartość dla wybranego konta. Ten wymóg możemy wyrazić w formie interfejsu implementowanego przez klasę konta.
interface IAccount
{
int GetAccountNumber();
}
Ta metoda zwraca liczbę całkowitą, która jest numerem konta dla tego egzemplarza. Umieszczając ją w interfejsie, możemy powiedzieć, że konto musi dostarczać tę wartość, ale nie określamy, jak dokładnie ma to robić. Do tego właśnie służą interfejsy w systemie. Określają, że potrzebne są określone zachowania, ale nie informują, jak konkretnie mają one być wykonywane. Aby bardziej szczegółowo określić zachowanie metody, muszę dodać komentarze.
Potrzeba tworzenia takich rzeczy, jak numery kont, które muszę być niepowtarzalne w skali światowej, doprowadziła do powstania w bibliotekach C# zbioru metod służących do tworzenia tzw. globalnie niepowtarzalnych identyfikatorów (ang. Globally Unique Identifier — GUID). Są to porcje danych tworzone na podstawie daty, godziny i pewnych informacji dotyczących komputera. Każdy GUID jest niepowtarzalny w skali świata. Możemy ich użyć w naszym konstruktorze Account
do tworzenia niepowtarzalnych numerów kont.