Rozdział 14. Projektowanie przy użyciu obiektów

15 stycznia 2022
1 gwiadka2 gwiazdki3 gwiazdki4 gwiazdki5 gwiazdek

Uruchamiamy w naszych głowach procesy myślenia obiektowego. Robimy to dlatego, bo zależy nam na tym, aby budowa naszych systemów była jak najprostsza. Poza tym chodzi też o słynne wśród programistów „kreatywne lenistwo”. Krótko mówiąc, staramy się przestrzegać następującej zasady:

„Odkładaj wszelką trudną pracę jak najdłużej się da, a najlepiej wciśnij ją komuś innemu”.

Obiekty umożliwiają trzymanie się tej zasady. Wróćmy do naszego konta bankowego. Jak wiadomo, musimy mieć możliwość wykonywania na nim pewnych operacji:

  • wpłacanie pieniędzy,
  • wypłacanie pieniędzy,
  • sprawdzanie salda,
  • drukowanie bilansu,
  • zmiana adresu właściciela,
  • drukowanie adresu właściciela,
  • zmiana stanu konta,
  • sprawdzanie stanu konta,
  • zmiana limitu debetu,
  • sprawdzenie limitu debetu.

W podejściu obiektowym nie powiemy „Musimy wykonać te operacje na koncie bankowym”, tylko odwrócimy kota ogonem, trochę tak, jak to kiedyś zrobił prezydent Kennedy w Ameryce:

„Drodzy Amerykanie, zamiast myśleć, co Wasz kraj może zrobić dla Was, zastanówcie się, co Wy możecie zrobić dla kraju” (wielkie brawa).

My nie wykonujemy operacji na koncie bankowym. Zamiast tego, prosimy je, aby robiło wszystko za nas. Można powiedzieć, że budowę architektury naszej aplikacji bankowej oprzemy na określeniu grupy obiektów, które będą reprezentować informacje oraz którym wyznaczymy zestawy operacji, jakie powinny być w stanie realizować. Najlepsze w tym jest to, że kiedy już określimy, co dane konto powinno robić, to implementację tych funkcji możemy zlecić komuś innemu.

Jeśli nasza specyfikacja będzie prawidłowa i zostanie poprawnie zaimplementowana, to nie będziemy musieli się zbytnio zastanawiać, jak dokładnie ona wygląda — możemy założyć ręce za głowę i przyjąć wszystkie pochwały na siebie.

To prowadzi nas do dwóch często przywoływanych w tej książce pojęć: metadane i testowanie. Metadane to ta część obiektu, która określa, jakie działania powinien on wykonywać. A kiedy już określimy jego zbiór operacji, następnym krokiem jest znalezienie sposobu na ich przetestowanie.

W tej części zaimplementujemy obiekt, który będzie miał niektóre operacje prawdziwego konta bankowego.

Złota myśl programisty: Nie pozwalaj na wszystko

Pamiętaj, że pewnych rzeczy z obiektami kont bankowych nie powinno dać się zrobić. Na przykład numer konta jest jego identyfikatorem i nigdy nie może się zmienić. Wystarczy więc nie implementować funkcji służącej do jego zmiany. W trakcie projektowania klasy obiektów tyle samo uwagi należy poświęcić zarówno temu, co powinno być możliwe, jak i temu, co powinno być niemożliwe. Niektóre operacje możemy nawet poddać specjalnej kontroli, tzn. zaimplementować w obiekcie mechanizm rejestracji tego, co zostało zrobione. To ułatwia znajdowanie przyczyn błędów, jeśli coś pójdzie nie po naszej myśli.

Dane w obiektach

O naszym koncie bankowym możemy myśleć pod kątem tego, co chcemy, aby dla nas robiło. Jego projektowanie zaczniemy więc od określenia wszystkich elementów danych, jakie chcemy w nim przechowywać. Dla uproszczenia na razie zajmiemy się tylko przechowywaniem informacji na temat salda konta. To wystarczy do przedstawienia wszystkich potrzebnych technik bez wprowadzania zbędnych komplikacji.

[/box]
class Account
{
 
public decimal Balance;
}

Powyższa klasa Account zawiera składową, w której będziemy przechowywać saldo konta bankowego. Składowe klasy służące do przechowywania danych nazywają się własnościami. Składowej reprezentującej saldo konta nadałem typ decimal, ponieważ jest specjalnie przeznaczony do przechowywania wartości finansowych.

Wiemy już, że każdy element danych w klasie jest jej składową przechowywaną jako jej część. Kiedy tworzę obiekt klasy, otrzymuję w nim pełny zestaw jej składowych. Pokazałem już, że tworzenie obiektów klasy i ustawianie wartości składowych jest bardzo łatwe:

Account RobsAccount; RobsAccount = new Account(); RobsAccount.Balance = 99;

Tak da się zrobić, ponieważ wszystkie składowe tego obiektu są publiczne, co znaczy, że są dostępne dla każdego. W związku z tym każdy programista piszący aplikację może zrobić coś takiego:

RobsAccount.Balance = 0;

No i po pieniążkach… Jeśli chcemy zapobiec takim sytuacjom, musimy włączyć ochronę danych przechowywanych w naszych obiektach.

Ochrona składowych w obiektach

Aby nasze obiekty nadawały się do czegokolwiek, musimy nauczyć się chronić przechowywane w nich dane. Najlepiej by było, gdybyśmy mogli przejąć kontrolę w przypadku, gdy ktoś próbuje zmienić jakąś wartość w naszym obiekcie, i powstrzymać go, jeśli coś nam się nie spodoba. Technika ta nazywa się hermetyzacją. Wszystkie ważne dane w obiekcie powinny być ukryte, abyśmy mieli pełną kontrolę nad tym, co się z nimi dzieje. Jest to kluczowy element mojego podejścia do programowania defensywnego, które sprowadza się do tego, że bez względu na wszystko inne, moja część programu nie zawiedzie.

W naszym programie chcemy na przykład dopilnować, aby nigdy nie zmieniono jakiegokolwiek salda w sposób, nad którym nie mamy kontroli. W związku z tym naszym pierwszym krokiem jest uniemożliwienie manipulowania wartością salda z zewnątrz obiektu:

class Account
{
private decimal balance;
}

Teraz własność balance jest prywatna. To znaczy, że publiczny dostęp do niej został wyłączony i nikt z zewnątrz już nie ma do niej dostępu. Jeśli napiszę takie wyrażenie:

RobsAccount.balance = 0;

to kompilator zgłosi następujący błąd:

PrivateDemo.cs(13,3): error CS0122: 'PrivateMembers.Account.balance' is inaccessible due to its protection level

Wartość składowej balance jest teraz ukryta wewnątrz obiektu i świat zewnętrzny nie ma do niej dostępu.

Modyfikowanie składowych prywatnych

Wiem, co teraz myślisz. „Po co tworzyć prywatną składową, skoro nie można jej modyfikować?”. Dziękuję za uwagę. Tak naprawdę to mogę zmieniać jej wartość, ale tylko za pomocą kodu znajdującego się w klasie. Spójrz na ten program:

class Account
{
    private decimal balance = 0;

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

class Bank
{
    public static void Main ()
    {
    Account RobsAccount; RobsAccount = new Account();
    if ( RobsAccount.WithdrawFunds (5) )
    {
        Console.WriteLine ( "Pieniądze pobrane" );
    }
    else
    {
        Console.WriteLine ( "Za mało środków" ) ;
    }
  }
}

Przykład 31. Pobieranie pieniędzy przy niewystarczającej ilości środków

Ten program tworzy konto i próbuje pobrać z niego pięć złotych. Oczywiście nie uda mu się, ponieważ saldo początkowe mojego konta wynosi zero, ale na jego przykładzie pokazałem, jak zapewnić dostęp do składowych w obiekcie reprezentującym konto. Metoda WithdrawFunds jest składową klasy Account, w związku z czym ma dostęp do jej prywatnych składowych.

Złota myśl programisty: Metadane a składowe i metody

Już od paru ładnych zdań nie wspominałem metadanych, więc najwyższa pora to zmienić. Metadane o moim systemie bankowym, które zbieram, będą decydować o sposobie zapewniania przeze mnie dostępu do składowych moich klas. To w jaki sposób chronię wartość salda w powyższym kodzie jest w zgodzie z oczekiwaniem klientów, którzy chcieliby, aby ich saldo było bezpieczne.

Metody publiczne

Może zauważyliście, że metoda WithdrawFunds jest publiczna. To znaczy, że można ją wywoływać spoza klasy. Nie ma innego wyjścia, jeśli chcemy dać możliwość korzystania z naszych obiektów przez wywoływanie znajdujących się w nich metod. Oto garść ogólnych zasad:

  • dane składowe (tzn. składowe przechowujące dane) klasy powinny być opatrzone modyfikatorem dostępu private;
  • metody składowe (tzn. elementy, które coś robią) powinny być opatrzone kwalifikatorem dostępu public.

Oczywiście w pewnych sytuacjach te zasady można złamać. Jeśli nie obchodzi Was los jakiejś składowej i chcecie, aby Wasz program działał jak najszybciej, to możecie uczynić ją publiczną. Jeśli piszecie metodę, która jest używana wyłącznie wewnątrz klasy do wykonywania jakichś specjalnych tajnych zadań, to możecie uczynić ją prywatną.

Złota myśl programisty: Stosuj konwencje kodowania, aby pokazać, które elementy są prywatne

Jeśli dokładnie przyglądacie się moim fragmentom kodu (a polecam to robić, bo warto), to zauważyliście, że nazwy publicznych składowych zaczynam od wielkiej litery (na przykład WithdrawFunds to nazwa metody służącej do pobierania pieniędzy z konta). Natomiast nazwy składowych prywatnych zaczynam od małej litery — np. do przechowywania salda konta służy składowa o nazwie balance. To ułatwia czytanie mojego kodu, ponieważ sama nazwa składowej wskazuje, czy jest ona publiczna, czy prywatna. Ta sama zasada dotyczy także zmiennych w blokach. Wszystkie one (włącznie z wszechobecną nazwą i) zaczynają się od małej litery.

Niektórym to nie wystarcza i takie osoby umieszczają przedrostek m_ przed nazwami zmiennych, które są składowymi klasy. Zmienna balance u nich nazywałaby się m_balance, co ułatwia odróżnienie składowych klasy. Ja tego nie robię, ponieważ moim zdaniem zwykła nazwa zmiennej jest już wystarczająca, ale opinie są podzielone. Najważniejsze jest to, aby wszyscy członkowie zespołu programistycznego stosowali tę samą konwencję. Większość firm programistycznych tworzy specjalne dokumenty zawierające opis konwencji pisania kodu, których powinni przestrzegać pracujący dla nich programiści.

Kompletna klasa Account

Teraz możemy stworzyć kompletną klasę konta bankowego kontrolującą dostęp do salda:

public class Account
{
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ższa klasa konta bankowego zachowuje się bardzo przyzwoicie. Utworzyłem trzy metody, za pomocą których mogę wchodzić w interakcję z obiektem reprezentującym konto. Mogę wpłacać pieniądze, sprawdzać ile mam pieniędzy oraz je pobierać:

Account test = new Account(); test.PayInFunds(50);

Po wykonaniu tych instrukcji na koncie testowym powinno być 50 funtów. Jeśli nie ma tyle, to znaczy, że mój program zawiera błąd. Metoda GetBalance to tzw. akcesor, ponieważ pozwala na dostęp do danych w obiekcie. Mogę napisać kod, który przetestuje działanie tych metod:

Account test = new Account(); test.PayInFunds(50);
if ( test.GetBalance() != 50 )
{
 

}
else
{

}
Console.WriteLine ( „Test wpłaty zakończony niepowodzeniem.” ); Console.WriteLine ( „Test wpłaty udany.” );

Przykład 32. Testowanie klasy Account

Teraz mój program sam się testuje w tym sensie, że robi coś, a potem sprawdza rezultat swojego działania. Oczywiście nadal muszę odczytywać wyniki wszystkich testów, co jest żmudne. W dalszej części pokażę Wam testy jednostkowe, które znacznie ułatwiają życie.

Złota myśl programisty: Uruchamiaj alarm, kiedy test się nie uda

Powyższy test jest dobry, ale nie idealny. Jedynie drukuje krótki komunikat informujący o wyniku. Programista musi ją przeczytać, a jeśli ją przeoczy, to może pomyśleć, że jego kod jest w porządku. Moje testy liczą znalezione błędy. Jeśli znajdą chociaż jeden, drukują wielki czerwony napis dobitnie informujący, że wydarzyło się coś złego. Niektóre zespoły programistyczne podłączają do swoich systemów testowych syreny i stroboskopy, aby uniemożliwić przeoczenie choćby jednego błędu. Potem sprawdzają, który programista jest odpowiedzialny za daną sytuację i zmuszają go do stawiania wszystkim kawy przez cały tydzień.

Programowanie oparte na testach

Uwielbiam podejście do programowania oparte na testach. Jeśli napiszę jakikolwiek nowy fragment kodu, to możecie być pewni, że zastosuję podejście testowe. Technika ta rozwiązuje trzy problemy:

  1. Testy nie są wykonywane dopiero na zakończenie projektu. To najgorszy czas na testowanie, ponieważ może wymagać użycia kodu, który został napisany pewien czas temu. Jeśli błędy wystąpią w starym kodzie, trzeba będzie stracić sporo czasu na przypomnienie sobie, o co w nim chodzi. O wiele lepiej jest testować kod na bieżąco, kiedy doskonale wiemy, jak powinien działać.
  2. Na wczesnym etapie realizacji projektu możecie pisać kod, który może być przydatny później. Wiele projektów upada tylko dlatego, że programiści przystępują do kodowania zanim dogłębnie zrozumieją postawione przed nimi zadanie. Rozpoczęcie pracy od napisania testów to doskonały sposób na to, aby upewnić się, że wszystko zostało zrozumiane. Ponadto jest duża szansa na to, że napisane przez nas testy kiedyś się przydadzą.
  3. Kiedy poprawiamy błąd w programie, musimy mieć możliwość upewnienia się, że te poprawki nie spowodują powstania błędu w innej części programu (jedną z najczęstszych przyczyn powstawania nowych błędów jest poprawianie innych). Jeśli zaimplementujesz automatyczny system wykonywania testów po każdej poprawce, to unikniesz takich niespodzianek.

Dlatego gorąco zachęcam do programowania z pomocą testów. Podziękujecie mi później.

Złota myśl programisty: Niektóre rzeczy trudno jest przetestować

Programowanie na podstawie testów to bardzo dobre podejście, ale nie rozwiązuje wszystkich problemów. W przypadku niektórych rodzajów programów trudno jest zastosować to podejście. Do tej grupy zaliczają się wszystkie aplikacje mające interfejs, w którym użytkownik wpisuje polecenia i otrzymuje odpowiedzi, ponieważ o wiele łatwiej jest wysłać coś do programu niż sprawdzić, co program zrobił w odpowiedzi. Ja z tym problemem radzę sobie w ten sposób, że interfejs użytkownika czynię bardzo cienką warstwą, która znajduje się nad żądaniami do obiektów wykonujących pracę. W przypadku naszego konta bankowego kod implementacji interfejsu użytkownika pozwalającego klientowi wpisać kwotę wypłaty będzie bardzo prosty i będzie wprost połączony z metodami udostępnianymi przez moje obiekty. Dopóki moje obiekty będą przechodzić testy, mogę mieć dużą pewność, że interfejs użytkownika też jest poprawny.

Innym bardzo trudnym do testowania w ten sposób rodzajem programu są wszelkie gry. Wynika to głównie z faktu, że nie da się zaprojektować testu sprawdzającego jakość rozgrywki. Jakiekolwiek działania zaradcze możemy podjąć dopiero, gdy ktoś nam powie, że „Gra jest za trudna, bo nie da się przejść bossa na końcu poziomu bez utraty życia”. Jedynym wyjściem w takiej sytuacji jest bardzo wyraźne określenie celu testów (aby testerzy wiedzieli, na co zwracać uwagę) i wprowadzenie mechanizmów pozwalających na łatwą zmianę wartości mających wpływ na przebieg gry. Przykładowo łatwo powinno dać się zmieniać siłę rażenia pocisków, którymi obcy ostrzeliwują nasz statek kosmiczny, oraz szybkość poruszania się wszystkich obiektów w grze.

Elementy statyczne

Jak na razie wszystkie składowe naszej klasy należą do obiektów tej klasy. To znaczy, że każdy obiekt klasy Account ma własną składową balance. Istnieje jednak też możliwość tworzenia składowych należących do klasy, tzn. nie przypisanych do żadnego obiektu.

Statyczne składowe klasy

Do tworzenia składowych należących nie do obiektów, tylko do samej klasy, służy słowo kluczowe static.

Koniecznie musicie zrozumieć znaczenie tego słowa w programach C#. Jest ono bardzo często używane we wszelkiego rodzaju programach:

class AccountTest
{
public static void Main ()
{
Account test = new Account(); test.PayInFunds (50);
Console.WriteLine ("Balance:" + test.GetBalance());
}
}

Klasa AccountTest zawiera statyczną metodę składową o nazwie Main. Wiemy, że metoda o takiej nazwie służy do uruchamiania programu. Należy ona do klasy AccountTest. Gdybym utworzył pięćdziesiąt obiektów klasy AccountTest, to wszystkie korzystałyby z tej samej metody Main. W języku C# słowo kluczowe static oznacza, że składowa należy do klasy, a nie do jej konkretnego obiektu.

Aby użyć metody Main nie muszę tworzyć obiektu klasy AccountTest. Wykorzystuję to do uruchamiania programu. W momencie jego uruchamiania nie ma jeszcze żadnych obiektów, więc ta metoda musi się być dostępna, bo inaczej nic nie da się zrobić. Słowo „statyczny” nie oznacza, że czegoś nie można zmieniać.

Statycznych składowych klasy można używać dokładnie tak samo, jak wszystkich innych. Statyczna może być zarówno składowa do przechowywania danych, jak i metoda.

Posługiwanie się statycznymi danymi składowymi klasy

Do wyjaśnienia tej kwestii posłużę się przykładem statycznej zmiennej składowej. Powiedzmy, że nasze konta bankowe powinny mieć określoną stopę oprocentowania. Klient poprosił nas o dodanie do klasy konta składowej reprezentującej stopę oprocentowania. Aby spełnić jego prośbę, dodamy do klasy odpowiednią składową:

public class Account
{
public decimal Balance;
public decimal InterestRateCharged;
}

Teraz mogę tworzyć konta oraz ustawiać ich salda i stawki oprocentowania. (Oczywiście gdyby wszystko miało wyglądać idealnie, to te składowe powinny być prywatne, powinniśmy dodać odpowiednie metody itd., ale na razie pomijam nieistotne szczegóły dla uproszczenia).

Account RobsAccount = new Account(); RobsAccount.Balance = 100;
RobsAccount.InterestRateCharged = 10;

Szkopuł w tym, że stopa procentowa ma być wspólna dla wszystkich kont. Jeśli się zmieni, to dla wszystkich kont jednocześnie. A to znaczy, że trzeba będzie przejrzeć wszystkie konta po kolei i dokonać w nich zmiany. To byłoby potwornie żmudne, a gdyby któreś z kont umknęło mojej uwadze, to mogłoby być także bardzo kosztowne.

Rozwiązaniem tego problemu jest uczynienie składowej reprezentującej stopę oprocentowania statyczną:

public class Account
{
public decimal Balance;
public static decimal InterestRateCharged;
}

Od tej pory stopa oprocentowania należy do klasy, a nie do jakiegokolwiek konkretnego obiektu. To pociąga za sobą konieczność zmiany sposobu odwoływania się do niej:

Account RobsAccount = new Account(); RobsAccount.Balance = 100;
Account.InterestRateCharged = 10;

To jest składowa klasy, więc w odwołaniach do niej muszę używać nazwy klasy, a nie referencji do obiektu.

Złota myśl programisty: Statyczne zmienne składowe są przydatne i niebezpieczne

Podczas zbierania metadanych dotyczących projektu należy zastanowić się, które elementy powinny być statyczne. Do grupy tej można zaliczyć różnego rodzaju limity — na przykład największa dopuszczalna liczba lat klienta. Kiedy może być konieczna zmiana tej wartości i lepiej żeby nie było trzeba zmieniać wszystkich obiektów w programie.

Jednak, jakby powiedział wujek Spidermana — „W parze z wielką mocą idzie wielka odpowiedzialność”. Dlatego trzeba bardzo uważać na to, w jaki sposób zezwala się na dostęp do statycznych zmiennych składowych. Przecież zmiana jednej z nich będzie miała wpływ na Wasz cały system. Dlatego takie zmienne zawsze powinny być prywatne i modyfikowane wyłącznie za pomocą wywołań metod.

Posługiwanie się statycznymi metodami klas

Metody też mogą być statyczne. Od dawna posługujemy się statyczną metodą Main, ale można też tworzyć inne. Możemy na przykład napisać metodę decydującą, czy dana osoba może założyć konto bankowe. Jako kryteria mogłaby ona przyjmować wiek i wysokość dochodów. Po ich zbadaniu zwracałaby prawdę lub fałsz, zależnie od tego, czy spełniają warunki, czy nie.

public bool AccountAllowed ( decimal income, int age )
{
if ( ( income >= 10000 ) && ( age >= 18 ) )
{
return true;
}
else {
return false;
}
}

Powyższa metoda sprawdza wiek i wysokość dochodów osoby, która chce założyć konto. Kryteria, które należy spełnić to wiek przynajmniej 18 lat i przynajmniej 10000 złotych dochodu. Szkopuł w tym, że nie wywołamy tej metody, dopóki nie utworzymy obiektu klasy Account. Rozwiązaniem tego problemu jest utworzenie metody statycznej:

public static bool AccountAllowed ( decimal income, int age )
{
if ( ( income >= 10000 ) && ( age >= 18 ) )
{
 

}
else
{

}
}
return true; return false;

Teraz ta metoda należy do klasy, a nie do konkretnego obiektu i można ją wywołać z pomocą nazwy klasy:

if ( Account.AccountAllowed ( 25000, 21 ) )
{
Console.WriteLine ( "Zezwolono na utworzenie konta." );
}

Dzięki temu nie musiałem tworzyć obiektu konta, aby sprawdzić, czy klient spełnia warunki do jego utworzenia.

Dane składowe w metodach statycznych

Metoda AccountAllowed jest w porządku, ale ma wpisane na stałe wartości dotyczące wieku i wysokości dochodów. Nie jest więc zbyt elastyczna, ale można to zmienić:

public class Account
{
private decimal minIncome = 10000; private int minAge = 18;

public static bool AccountAllowed(decimal income, int age)
{
if ( ( income >= minIncome) && ( age >= minAge) )
{
return true;
}
else
{
return false;
}
}
}

Taka konstrukcja jest lepsza, ponieważ limity wieku i wysokości dochodów są określane za pomocą składowych klasy. Tylko że powyższa klasa nie przejdzie kompilacji:

AccountManagement.cs(19,21): error CS0120: An object reference is required for the nonstatic field, method, or property 'Account.minIncome'

AccountManagement.cs(19,43): error CS0120: An object reference is required for the nonstatic field, method, or property 'Account.minAge'

Jak zwykle kompilator dokładnie nas informuje, co jest źle, ale takim językiem, że i tak nie wiemy, o co chodzi. Wiadomość od kompilatora przetłumaczona na ludzki język brzmi: „statyczna metoda używa składowej klasy, która nie jest statyczna”.

Jeśli to nie pomogło, to co powiecie na to: składowe minIncome i minAge są przechowywane w egzemplarzach klasy Account. Natomiast statyczną metodę można wywołać bez użycia obiektu (ponieważ jest częścią klasy). Kompilator jest niezadowolony, ponieważ w tej sytuacji metoda nie miałaby składowych do przetwarzania. Rozwiązaniem tego problemu jest uczynienie statycznymi także składowych określających przychód i wiek:

public class Account
{
private static decimal minIncome; private static int minAge;

public static bool AccountAllowed(decimal income, int age)
{
if ( ( income >= minIncome) && ( age >= minAge) )
{
return true;
}
else
{
return false;
}
}
}

Przykład kodu 33. Użycie metody AccountAllowed

Gdyby się nad tym zastanowić, to ma to sens. Wartości limitów nie powinny być przechowywane w poszczególnych obiektach klasy, ponieważ są takie same dla wszystkich, a więc najlepszym wyjściem jest uczynienie ich składowymi statycznymi.

Złota myśl programisty: Ze statycznych metod składowych można tworzyć biblioteki

Czasami w programie potrzebna jest biblioteka metod, które wykonują pewne zadania. W systemie języka C# znajduje się na przykład ogromna liczba metod reprezentujących różne funkcje matematyczne, takie jak sinus i cosinus. Uczynienie ich metodami statycznymi jest jak najbardziej sensowne, ponieważ interesują nas właśnie tylko one, a nie obiekty jakiejkolwiek klasy. Podczas pracy nad własnym systemem zawsze musisz się zastanowić, w jaki sposób będziesz udostępniać takie metody na własny użytek.

Zapiski bankiera: Statyczne informacje bankowe

W naszym banku słowo kluczowe static pozwala rozwiązać problemy następującego rodzaju:

Statyczna zmienna składowa: kierownik chce mieć możliwość ustawiania stawki procentowej dla wszystkich kont klientów na raz. Wystarczy utworzyć jedną statyczną składową w klasie Account, która będzie wykorzystywana we wszystkich egzemplarzach tej klasy. A ponieważ ta wartość jest tylko jedna, jej modyfikacja będzie powodowała zmianę stawki procentowej na wszystkich kontach. Jeśli jakaś wartość ma być taka sama dla wszystkich klas (innym przykładem są wszelkie ograniczenia wartości), to zawsze najlepiej uczynić ją statyczną. Schody zaczynają się, gdy kierownik powie coś w stylu — „Aha, ale konta dla pięciolatków mają mieć inną stawkę oprocentowania niż normalne”. W takiej sytuacji nie użyjemy składowej statycznej, ponieważ w niektórych obiektach będziemy przechowywać inną wartość.

Statyczna metoda składowa: kierownik mówi, że potrzebuje metody umożliwiającej sprawdzenie, czy dana osoba może mieć konto. Nie mogę uczynić tej metody częścią egzemplarza klasy Account, ponieważ chcę mieć możliwość jej wywoływania zanim taki obiekt zostanie utworzony. Muszę ją zdefiniować jako metodę statyczną, aby móc ją wywoływać bez użycia obiektu.

Tworzenie obiektów

Wiecie już, że do tworzenia obiektów służy słowo kluczowe new:

test = new Account();

Gdyby uważnie przyjrzeć się temu fragmentowi kodu, można stwierdzić, że trochę przypomina wywołanie metody.

I tak rzeczywiście jest. Kiedy jest tworzony egzemplarz danej klasy w języku C#, system wywołuje jej metodę zwaną konstruktorem. Konstruktor jest specjalną składową klasy, która służy do tworzenia odpowiednio skonfigurowanych obiektów tej klasy. Jedną z najważniejszych zasad w języku C# jest to, że każda klasa musi mieć metodę konstrukcyjną, która jest wywoływana podczas tworzenia jej egzemplarza.

Teraz możesz sobie pomyśleć — „Ale chwileczkę, przecież stworzyliśmy już kilka obiektów i za żadnym razem nie trzeba było bawić się z żadnym konstruktorem”. To dlatego, że w tej kwestii dla odmiany kompilator C# jest uprzejmy. Zamiast krzyczeć na nas za to, że nie dostarczyliśmy metody konstrukcyjnej, tworzy domyślny konstruktor i korzysta z niego.

Pewnie pomyślisz, że to dziwne, że kompilator nie posyła programiście wiązanki, gdy ten zapomni czegoś dostarczyć, ale w tym przypadku on po prostu rozwiązuje pewien problem, nie informując cię o tym. Można na to spojrzeć na dwa sposoby:

Kompilator jest dobry: kompilator próbuje ułatwić nam życie.

Kompilator jest zły: kompilator wie, że jeśli teraz automatycznie coś za nas doda, to później będziemy cierpieć z powodu nierozumienia, skąd to się wzięło.

Sami zdecydujcie, która z tych interpretacji bardziej wam odpowiada.

Konstruktor domyślny

Metoda konstrukcyjna ma taką samą nazwę, jak klasa, ale nic nie zwraca. Jej wywołanie następuje w chwili wykonania operacji new. Jeśli nie dostarczycie konstruktora (jeszcze tego nie zrobiliśmy), to kompilator utworzy go za was.

public class Account
{
public Account()
{
}
}

Tak wygląda konstruktor domyślny. Jest metodą publiczną, więc jest dostępny dla klas zewnętrznych, które mogą chcieć utworzyć egzemplarz tej klasy. Nie przyjmuje żadnych parametrów. Kiedy tworzę własny konstruktor, to kompilator zakłada, że wiem, co robię i nie tworzy już swojego domyślnego konstruktora. Przez to mogą być problemy, o czym piszę nieco dalej.

Nasz własny konstruktor

Dla zabawy możemy utworzyć konstruktor, który tylko drukuje informację, że został wywołany:

public class Account
{
public Account()
{
Console.WriteLine ("Właśnie utworzyliśmy konto.");
}
}

Ten konstruktor nie jest zbyt konstruktywny, ale przynajmniej daje nam znać, kiedy został wywołany. To znaczy, że kiedy program wykona ten wiersz kodu:

robsAccount = new Account();

na ekranie pojawi się następująca informacja:

Właśnie utworzyliśmy konto.

Oczywiście drukowanie takich informacji nie byłoby zbyt miłym dodatkiem do programu do prawdziwego użytku, ale nam pozwala zrozumieć, jak działa cały ten proces.

Przekazywanie informacji do konstruktora

Dobrze jest móc przejąć kontrolę nad procesem tworzenia obiektu klasy Account, ale jeszcze lepiej byłoby móc przekazywać informacje do niego podczas jego tworzenia. Powiedzmy na przykład, że podczas tworzenia konta chcę mieć możliwość ustawienia nazwiska, adresu i salda początkowego właściciela. Innymi słowy, chcę robić coś takiego:

robsAccount = new Account( "Rob Miles", "Hull", 0 );

Ta instrukcja spowodowałaby utworzenie nowego konta oraz ustawienie nazwiska na Rob Miles, adresu na Hull i salda początkowego na zero. Pewnie ucieszy was fakt, że utworzenie metody konstrukcyjnej przyjmującej takie parametry do konfiguracji składowych klasy jest bardzo łatwe:

class Account
{
// prywatne dane składowe private string name; private string address; private decimal balance;

// konstruktor
public Account (string inName, string inAddress, decimal inBalance)
{
name = inName; address = inAddress; balance = inBalance;
}
}

Konstruktor pobiera wartości przekazane w parametrach i konfiguruje przy ich użyciu składowe właśnie tworzonego egzemplarza klasy Account. Pod tym względem zachowuje się dokładnie, jak każda inna metoda.

class Bank
{
public static void Main()
{
Account robsAccount;
robsAccount = new Account("Rob", "Robs House",
1000000);
}
}

Przykład kodu 34. Użycie własnego konstruktora

Powyższy kod utworzyłby konto dla Roba z saldem w wysokości 1000000 złotych. Chciałbym.

Zauważcie, że dodanie takiego konstruktora do klasy ma jeden poważny skutek:

od tej pory egzemplarz klasy można utworzyć tylko za jego pomocą, tzn. aby utworzyć obiekt klasy Account, muszę podać nazwisko, adres i saldo początkowe. Jeśli spróbuję zrobić coś takiego:

robsAccount = new Account();

to kompilator stanie się nieprzyjemny i zgłosi błąd:

AccountTest.cs(9,27): error CS1501: No overload for method 'Account' takes '0' arguments

Chodzi mu o to, że w klasie nie ma konstruktora bez parametrów. Innymi słowy, kompilator dostarcza konstruktor domyślny tylko wtedy, gdy programista nie dostarczy własnego.

Jeśli będziecie używać konstruktora domyślnego przez pewien czas, a potem zdefiniujecie własny, to możecie nieźle się zdziwić Kompilator przestanie dostarczać konstruktor domyślny, przez co nasz program przestanie się kompilować. W takiej sytuacji pozostaje tylko odnaleźć i zmodyfikować wszystkie wywołania konstruktora domyślnego lub utworzenie własnego konstruktora domyślnego. Oczywiście tak naprawdę nie musicie tego robić, bo tak dobrze zaprojektowaliście swój program od początku, że taki problem was nie dotknął. Tak jak i mnie, khm.

Przeciążanie konstruktorów

Przeciążanie to bardzo ciekawe słowo. W serialu Star Trek przeciążeniu ulegały silniki statku kosmicznego w co drugim odcinku. Natomiast w języku C# przeciążenie oznacza:

„że dana metoda ma taką samą nazwę, jak inna, ale różni się od niej zestawem parametrów”.

Kompilatorowi nie przeszkadza przeciążanie metod, ponieważ parametry wywołania pozwalają mu zorientować się, o którą wersję chodzi programiście w danym przypadku. W odniesieniu do konstruktorów to oznacza, że można umożliwić tworzenie obiektów klasy na kilka różnych sposobów. Na przykład wiele tworzonych kont będzie miało zerowe saldo początkowe, tzn. będzie puste. Takie konto powinniśmy móc utworzyć w taki sposób:

robsAccount = new Account("Rob Miles", "Hull");

Opuściłem wartość salda, ponieważ chcę użyć „domyślnej” wartości zero. Jeśli kompilator napotka w waszym kodzie coś takiego, to poszuka metody konstrukcyjnej mającej tylko dwa parametry łańcuchowe. Ta metoda może wyglądać tak:

public Account (string inName, string inAddress)
{
name = inName; address = inAddress; balance = 0;
}

Przeciążanie nazwy metody

Przeciążyć można praktycznie każdą metodę w klasie. Taka możliwość przydaje się na przykład, gdy może być kilka wersji czegoś, np. sposobów zapisu daty transakcji:

SetDate ( int year, int month, int day ) SetDate ( int year, int julianDate ) SetDate ( string dateInMMDDYY )

Wywołanie

SetDate (2005, 7, 23); 

zostałoby skojarzone z wersją metody przyjmującą trzy parametry całkowitoliczbowe i ta właśnie zostałaby wykonana.

Porządek wśród konstruktorów

Jeśli klasa Account będzie miała wiele metod konstrukcyjnych, programiście może zacząć się wszystko mylić:

public Account (string inName, string inAddress, decimal inBalance)
{
name = inName; address = inAddress; balance = inBalance;
}

public Account (string inName, string inAddress)
{
name = inName; address = inAddress; balance = 0;
}

public Account (string inName)
{
name = inName;
address = "Nie podano"; balance = 0;
}

Utworzyłem trzy konstruktory w klasie Account. Pierwszemu należy podać wszystkie informacje. W drugim nie ma obowiązku określania salda, w którym to przypadku zostanie ono ustawione na 0. Trzeci dodatkowo nie wymaga adresu, który w razie potrzeby ustawia na wartość domyślną „Nie podano”.

Aby zdefiniować te trzy konstruktory, musiałem powtórzyć spore fragmenty kodu. A dobry programista brzydzi się powielaniem kodu i uważa to za niepotrzebną dodatkową pracę. Najgorsze, że bardzo łatwo się to robi. Wystarczy skopiować blok tekstu w edytorze i wkleić go radośnie gdzie tylko się nam spodoba. Jednak nie należy tego robić. Bo to złe. Kiedy okaże się, że trzeba coś zmienić w takim fragmencie kodu, trzeba będzie znaleźć i zmodyfikować każdą kopię.

Zmiany w kodzie wprowadza się częściej niż myślicie i wcale nie tylko dlatego, że trzeba usunąć jakiś błąd. Często wymagają tego na przykład zmiany w dokumentacji technicznej. Dlatego w języku C# stworzono mechanizm pozwalający na wywoływanie konstruktorów w konstruktorach. Spójrzcie:

public Account (string inName, string inAddress, decimal inBalance)
{
name = inName; address = inAddress; balance = inBalance;
}
public Account ( string inName, string inAddress ) : this (inName, inAddress, 0 )
{
}

public Account ( string inName ) : this (inName, "Nie podano", 0 )
{
}

Słowo kluczowe this oznacza „inny konstruktor w tej klasie”. Jak widać powyżej, wyróżnione fragmenty kodu są wywołaniami pierwszego konstruktora. Przekazują one przekazane przez nas parametry, wraz z wartościami domyślnymi, które utworzyliśmy, do „właściwego” konstruktora. W efekcie wartości są przekazywane do obiektu tylko przez jedną metodę konstrukcyjną, która jest wywoływana przez wszystkie pozostałe.

Ciekawą rzeczą w składni tych wywołań jest to, że wywołanie konstruktora następuje przed miejscem, w którym znajduje się jego treść. Ma to nawet miejsce całkiem poza jego blokiem. To ma sens, ponieważ dokładnie odzwierciedla to, co się dzieje. Konstruktor this jest wykonywany zanim nastąpi wejście do tego drugiego konstruktora. W powyższym kodzie treść konstruktora może być nawet pusta, ponieważ wywołanie this wszystko załatwia.

class Bank
{
public static void Main()
{
const int MAX_CUST = 100;
Account[] Accounts = new Account[MAX_CUST]; Accounts[0] = new Account("Rob", "Robs House",
1000000);
Accounts[1] = new Account("Jim", "Jims House"); Accounts[2] = new Account("Fred");
}
}

Przykład kodu 35. Przeciążone konstruktory

Powyższy przykład przedstawia prawidłowe przeciążenie konstruktora. Zostaje utworzona tablica referencji typu Account (o nazwie Accounts), której trzy pierwsze elementy zostają ustawione na obiekty klasy Account. Pierwszy z nich wskazuje konto Roba, które ma podany pełny adres i saldo początkowe w wysokości 1000000 złotych. Drugi element (o numerze 1) odnosi się do konta Jima, które ma podany adres i domyślne saldo wynoszące zero. Trzeci element (o numerze 2) odnosi się do konta Freda, które ma domyślny adres („Nie podano”) i domyślne saldo wynoszące zero.

Złota myśl programisty Tworzenie obiektów należy planować

Sposób tworzenia obiektów powinien być dokładnie zaplanowany w trakcie pisania programu. Powinno się utworzyć jeden konstruktor „główny” reprezentujący najbardziej kompletną metodę tworzenia obiektów. Następnie należy utworzyć pozostałe konstruktory wykorzystujące ten główny dzięki użyciu słowa kluczowego this.

Konstruktor nie może zawieść

W filmach o Jamesie Bondzie zawsze jest moment, w którym ktoś mówi agentowi 007, że los całego świata spoczywa w jego rękach. Porażka nie wchodzi w grę. Podobnie jest z konstruktorami. One nie mogą zawieść. I to jest problem:

Kiedykolwiek wcześniej pisaliśmy jakąś metodę, zawsze sprawdzaliśmy, czy prawidłowo działa, aby nie powodowała uszkodzenia stanu obiektu. Na przykład próby pobrania ujemnej kwoty z konta bankowego powinny być blokowane.

Kontrolę nad naszymi obiektami sprawujemy właśnie po to, aby żaden użytkownik nie mógł wprowadzić ich w nieprawidłowy stan. Jeśli spróbujecie zrobić coś głupiego w wywołaniu metody, ta nie powinna na to pozwolić i powinna zwrócić informację, że wykonanie danej czynności było niemożliwe.

Innymi słowy, podczas tworzenia metody modyfikującej dane w obiekcie zawsze powinniśmy zadbać o to, aby ta zmiana była poprawna. Na przykład poniższe wywołanie powinniśmy zablokować:

RobsAccount.PayInFunds (1234567890);

Na koncie będzie określony górny limit kwoty jednorazowej wpłaty, dlatego w tym przypadku metoda PayInFunds nie wykona operacji. A co zrobić w poniższym przypadku?

RobsAccount = new Account ("Rob", "Hull", 1234567890); 

Konstruktory tak samo jak James Bond nie mogą zawieść. Cokolwiek się stanie podczas wywoływania konstruktora, musi on zakończyć operację, której wynikiem będzie nowy obiekt.

To stwarza problem. Wygląda na to, że możemy postawić szlaban przed bezsensownymi wartościami w każdym miejscu, tylko nie w najważniejszym, czyli w trakcie tworzenia obiektu.

Złota myśl programisty: obsługa błędów jest trudna

To po raz kolejny przywodzi nas do ważnego spostrzeżenia na temat programowania. Napisanie kodu, który wykonuje określone zadanie, jest zazwyczaj łatwe. Natomiast napisanie kodu obsługującego we właściwy sposób wszystkie możliwe błędy jest zazwyczaj znacznie trudniejsze. Takie już życie programisty, że znacznie więcej czasu spędza (lub powinien spędzać) na myśleniu o tym, co może się nie udać, niż o tym, jak prawidłowo coś powinno działać.

Konstruktory i wyjątki

W tej chwili jedynym możliwym rozwiązaniem jest zgłoszenie wyjątku przez konstruktor, gdy coś mu się nie spodoba. To znaczy, że korzystający z niego użytkownik musi przechwytywać ten wyjątek podczas tworzenia obiektów, co wcale nie jest takie złe. Sprytniejsze rozwiązanie polega na wywoływaniu przez konstruktor metod ustawiających dla wszystkich własności, które zostaną mu przekazane, a jeśli któraś z nich zwróci błąd, to konstruktor w tym miejscu powinien zgłosić wyjątek:

public Account (string inName, string inAddress)
{
if ( SetName ( inName ) == false ) {
throw new Exception ( "Nieprawidłowe nazwisko " + inName) ;
}
if ( SetAddress ( inAddress) == false ) {
throw new Exception ( "Nieprawidłowy adres" + inAddress) ;
}
}

Jeśli spróbujemy utworzyć konto z nieprawidłowym nazwiskiem, konstruktor zgłosi wyjątek, czyli zrobi to, o co nam chodzi. Jedyną wadą tego rozwiązania jest to, że jeśli adres też będzie nieprawidłowy, to użytkownik metody dowie się o tym dopiero wtedy, gdy poprawi nazwisko i ponownie wywoła konstruktor.

Bardzo nie lubię, kiedy dzieje przydarza mi się coś takiego podczas używania jakiegoś programu. Może to się zdarzyć podczas wypełniania formularza internetowego. Źle wpisuję nazwisko, więc wyświetla się stosowna informacja. Więc je poprawiam, a wtedy program wyskakuje z błędem w adresie. Wolałbym, aby wszystkie błędy były pokazywane od razu. Da się to zrobić, ale za cenę zwiększenia poziomu skomplikowania kodu.


public Account(string inName, string inAddress, decimal inBalance)
{
string errorMessage = "";

if (SetBalance(inBalance)==false)
errorMessage = errorMessage + "Nieprawidłowe saldo: " +
inBalance;

if (SetName(inName) == false)
{
errorMessage = errorMessage + "Nieprawidłowe nazwisko: " + inName;
}

if (SetAddress(inAddress) == false)
{
errorMessage = errorMessage + " Nieprawidłowy adres: " +
inAddress;
}

if (errorMessage != "")
{
throw new Exception("Błąd tworzenia konta "
+ errorMessage);
}
}

Przykład kodu 36. Błąd konstruktora

Ta wersja konstruktora tworzy wiadomość zawierającą informacje o wszystkich znalezionych błędach w konstrukcji konta. Każdy znaleziony błąd zostaje dodany do wiadomości, po czym całość jest umieszczana w wyjątku i przedstawiana wywołującemu.

Złota myśl programisty: pamiętaj o kwestiach międzynarodowych

Powyższy kod tworzy wiadomość tekstową, którą wysyła użytkownikowi, gdy wystąpi jakiś błąd. To bardzo dobrze. Tylko że, jeśli zainstalujemy ten program we francuskiej filii banku, to możemy mieć problem. Na etapie określania wymogów należy ustalić, czy planowane jest tłumaczenie programu na różne języki. Jeśli tak, to należy wybrać sposób przechowywania i wybierania odpowiednich komunikatów. Na szczęście istnieją biblioteki C#, które to ułatwiają.

Zapiski bankiera: tworzenie konta

Sprawy związane z konstruktorem klasy nie dotyczą bezpośrednio specyfikacji konta bankowego. To jest kwestia tego, jak dana specyfikacja jest zaimplementowana, a nie tego, co dany system ma robić.

Jeśli więc kierownik powie, że klient wypełnia formularz, podaje imię i nazwisko oraz adres i na tej podstawie zostaje utworzone nowe konto, to od razu wiemy, jakie parametry powinny być przekazywane do konstruktora.

Autor: Rob Miles

Tłumaczenie: Joanna Liana

Dyskusja

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *