piątek, 2 grudnia 2011

Techniki pracy z kodem – Zarządzanie złożonością

Zarządzanie złożonością to problem, z którym styka się każdy programista. Przez większość czasu pracujemy nad wcześniej napisanym kodem dodając nowe funkcjonalności lub poprawiamy błędy niż piszemy nowy kod. Z czasem liczba wykorzystywanych klas i obiektów rośnie. Jeżeli kod napisany jest niedbale, bez zwracania uwagi na jego strukturę, złożoność to prowadzi to do kodu, który nie jest odporny na zmiany, ciężko przewidzieć efekt zmian w kodzie i nie można wykorzystywać ponownie napisanych elementów. Z drugiej strony dobre zarządzanie złożonością gwarantuje nam, że napisany kod łatwo adaptuje się do przyszłych zmian, jest elastyczny i łatwiejszy w utrzymaniu. U podstaw każdego kodu, który spełnia powyższe warunki leżą zasady znane pod akronimem S.O.L.I.D.

1. Zasada jednej odpowiedzialności (Single Responsibility Principle)

2. Zasada otwarty/zamknięty (Open/Closed Principle)

3. Zasada podstawienia Liskov (Liskov Substitution Principle)

4. Zasada separacji interfejsów (Interface Segregation Principle)

5. Zasada odwrócenia zależności (Dependency Inversion Principle)

Zasada jednej odpowiedzialności

Zasada pojedynczej odpowiedzialności mówi, że klasa powinna mieć jeden i tylko jeden powód do zmiany. Odpowiedzialność definiujemy jako zestaw logicznie połączonych ze sobą metod i zmiennych. Kiedy zmieniają się wymagania dotyczące działania kodu odbijają się one w zmianie odpowiedzialności wśród klas. Im klasa jest odpowiedzialna za więcej rzeczy tym więcej potencjalnych zmian może sprawić, że będziemy musieli zmieniać napisany kod. Osiągniemy to poprzez sprawdzanie czy interfejs klasy (metoda, property) jest spójny z nazwą klasy, w przeciwnym wypadku należy zastanowić się nad przeniesieniem metody do innej klasy.

Efektem stosowanie się do zasady pojedynczej odpowiedzialności są projekty, w których klasy są małe i odpowiedzialne za ściśle określone zadania. Liczba używanych klas może wzrosnąć dość znacznie, ale nie jest to tak poważny problem jak złe zarządzanie złożonością i można go rozwiązać używając przestrzeni nazw i folderów do organizowania plików. Dodatkowo projekt jest łatwiejszy do zrozumienia oraz prostszy w utrzymaniu.

Często pojawiającym się pytaniem jest czy w tym wypadku klasa może mieć tylko jedną metodę? Odpowiedzią jest oczywiście: nie. Klasa może mieć więcej metod jednak wszystkie powinny skupiać się nad jednym konceptem. Należy również pamiętać, że ludzie mogą rozdzielać odpowiedzialności w różny sposób.

Przykład: Załóżmy, że mamy do napisania grę karcianą. Zasady gry są proste: każdy z graczy dostaje losowo jedną kartę z talii. Gracz, który otrzyma najwyższą kartę wygrywa. Definiujemy następujące klasy.

   public class Card
{
//reprezentacja karty
}

public class Player
{
public string Name { get; set; }
public List<Card> Hand { get; set; }
}

public class Deck
{
List<Card> cards;

public void ShuffleCards()
{
//tasowanie kart
}

public Card DealCard(Player player)
{
//rozdanie jednej karty dla gracza
}

}
Klasa Deck zawiera listę obiektów Card i zawiera dwie metody: do tasowania kart i rozdawania. Na pierwszy rzut oka wszystko wydaje się dość proste i oczywiste. Jednak klasa Deck łamie zasadę pojedynczej odpowiedzialności (jak się później okaże nie tylko tą). Metoda DealCard nie powinna znajdować się klasie Deck. Klasa Deck odpowiedzialna jest za przechowywanie informacji o kartach oraz za ich rozdawania. O wiele lepszym rozwiązaniem jest umieszczenie metody DealCard w osobnej klasie.
   public class Dealer
{
Deck deck;

public Card DealCard(Player player)
{
//rozdanie jednej karty dla gracza
}
}
W ten sposób rozdzielamy odpowiedzialności i ograniczamy zakres ewentualnych zmian do minimum. Klasa Deck nie musi być zmieniana jeżeli będziemy musieli zmienić sposób rozdawania kart.

Wskazówka – użycie regionów

Jeżeli w klasie lub metodzie do organizacji kodu używamy regionów to najprawdopodobniej łamiemy zasadę jednej odpowiedzialności.

Zasada otwarty/zamknięty

Stosując się do tej zasady powinniśmy projektować aplikację w taki sposób, żeby móc dodawać nowe funkcjonalności bez konieczności modyfikacji istniejącego kodu. Można to osiągnąć poprzez izolowanie obszarów, w których mogą wystąpić zmiany. Nie należy jednak popadać w skrajność i nie izolować się na każdą możliwą zmianę, a na te najbardziej prawdopodobne.

Klasa Dealer narusza tą zasadę. Co w przypadku, gdy będziemy chcieli rozdać 2 karty dla każdego gracza lub dowolną ilość kart? Za każdym razem, gdy będziemy chcieli zmienić rodzaj rozdawania (mając na uwadze inne gry karciane) musimy zmieniać kod w już stworzonej klasie. Rozwiązujemy ten problem poprzez wyizolowanie kodu, który potencjalnie będzie się często zmieniać. Osiągamy to poprzez abstrakcję i/lub wykorzystanie interfejsów.

   public class Dealer
{
IDealCards dealCards;
Deck deck;

public Card DealCards(List<Player> players)
{
dealCards.DealCards(players);
}

}

interface IDealCards
{
void DealCards(List<Player> players);
}

Tworzymy interfejs IDealCards z jedną metodą odpowiedzialną za rozdanie kart dla listy graczy. Teraz w prosty sposób możemy tworzyć nowe obiekty implementujące ten interfejs i bez konieczności zmiany kodu w klasie Dealer zmieniać rodzaj sposobu rozdawanych kart.


public class TexasHoldemDeal : IDealCards
{

public void DealCards(List<Player> players)
{
//rozdanie kart do Texas Holdem
}
}

public class PotLimitOmaha : IDealCards
{

public void DealCards(List<Player> players)
{
//rozdanie kart do Pot Limit Omaha
}
}

Zasada podstawienia Liskov

Zasada podstawienia Liskov mówi o kontraktach/interfejsach klas. Jej użycie przydaje się przy wyborze sposobu rozszerzenia funkcjonalności klasy: dziedziczenia lub kompozycji.

Zasada podstawienia Liskov mówi, że sposób korzystania z klasy potomnej powinien być analogiczny do wywoływania klasy bazowej. Inaczej mówiąc powinniśmy być w stanie zawsze użyć klasy bazowej zamiast konkretnej implementacji i wciąż otrzymywać poprawny rezultat. Problemy z łamaniem tej zasady mają miejsce w przypadku tworzenia rozbudowanej hierarchii klas. By ich unikać trzeba zwracać uwagę, żeby jedynie rozszerzać działanie metod klas bazowych oraz dbać o poprawne modelowanie zagadnień.

Problem przy nie stosowaniu zasady podstawienia Liskov pojawia się w momencie, gdy kod myśli, że wywołuję metodę typu A, a w rzeczywistości wywołuje metodę typu B. Gdzie B:A. Zasada może być złamana na dwa sposoby:
  • Jeżeli metody klas mają inne parametry. Interfejsy klas są nie spójne.
    public class Deck
{
List<Card> cards;

public void ShuffleCards()
{
//tasowanie kart
}
}

public class DoubleDeck : Deck
{
List<Card> cards;

public void ShuffleCards(bool shuffleDecksSeparately)
{
//tasowanie kart
}
}

//wywołanie metody
Deck deck = new DoubleDeck();
deck.ShuffleCards();
Na powyższym przykładzie interfejsy klasy bazowej i pochodnej nie zgadzają się. DoubleDeck przyjmuje dodatkowo parametr bool przez co łamie zasadę LSP.
  • Jeżeli metody nie mają logicznego sens
Obrazuje to pewnie znany wszystkim przykład z klasą Rectangle i Square. Square dziedziczy po Rectangle. Klasa Rectangle ma dwie metody SetWidth(int width) oraz SetLenght(int length). Jak łatwo się domyślić w klasie Square działanie tych metod nie ma sensu ponieważ zmiana jednej warunkuje zmianę drugiej łamiąc w ten sposób LSP na drugi możliwy sposób. Przykład.

Czy to oznacza, że klasa musi mieć dokładnie takie same metody jak bazowa? Czy może mieć więcej metod?

Nie, ale interfejsy oraz logiczne działanie metod występujących w hierarchii dziedziczenia powinna być zgodna. Z tą wiedzą, oceniając zgodność naszych klas z zasadą LSP możemy stwierdzić kiedy kompozycja jest bardziej właściwym mechanizmem do rozszerzania funkcjonalności klas, a kiedy jest nim dziedziczenie.

Zasada separacji interfejsów

Zasada mówi żeby tworzone przez programistę interfejsy były odpowiedzialne za jak najmniejsza funkcjonalność. Użytkownik chcąc zaimplementować taki interfejs nie powinien pisać metod, których nie potrzebuje. Jeśli znajdują się w nim niepotrzebne metody to wtedy nazywamy go interfejsem “fat” lub “polluted”.

Do zobrazowania na czym polega segregacja interfejsów posłużę się lekko zmodyfikowanym przykładem interfejsu z artykułu Hands on Mocking.
public interface IOneCardMasterGame
{
void AddPlayer(Player player);
void RemovePlayer(Player player);
void SetPlayerLimit(int number);
void PerformGame();
Player GetWinner();
IList<Player> GetGameState();
int GetPlayerCount();
}
Za co odpowiedzialny jest ten interfejs? Na pierwszy rzut oka widać, że ma za dużo odpowiedzialności. Jest odpowiedzialny zarządzanie graczami, wykonywaniem gry oraz pobieraniem stanu. Zasada separacji interfejsów jest konsekwencją zasady pojedynczej odpowiedzialności. Bardziej elastycznym rozwiązaniem było by rozbicie go na 3 osobne interfejsy.

public interface IOneCardMasterGame
{
void PerformGame();
}

public interface IPlayers
{
void AddPlayer(Player player);
void RemovePlayer(Player player);
int GetPlayerCount();
}

public interface IGameSettings
{
void SetPlayerLimit(int number);
Player GetWinner();
IList<Player> GetGameState();
}

Oczywiście są to rozważania ogólne i rozwiązanie mogłoby wyglądać zupełnie inaczej w zależności od wymagań i założeń programu oraz pod pewnymi warunkami wykorzystanie pierwotnego mogłoby być uzasadnione. Jest to jednak sprawa drugorzędna. Należy jednak zwrócić uwagę na przewagę drugiego rozwiązania. Interfejsy odpowiedzialne są za mniejsze, lepiej sprecyzowane role. Ponowne wykorzystanie interfejsów jest teraz bardziej prawdopodobne.

Zasada odwrócenia zależności

Stosując zasadę odwrócenia zależności piszemy kod w taki sposób żeby:

· Kod z warstw wyższego poziomu nie zależał od kodu z niższych warstw. Obie warstwy powinny zależeć od abstrakcji.

· Abstrakcje nie powinny zależeć od konkretnej implementacji.

· Implementacje powinny zależeć od abstrakcji.

Rozważmy poniższy przykład:

public class CardGame
{
List<Player> players;
Deck deck;
Dealer dealer;

public CardGame()
{
deck = new Deck();
players = new List<Player>();
dealer = new Dealer();
}

public void Start()
{
//rozpoczęcie gry
}
}

Stworzyliśmy klasę CardGame, w której zdefiniowaliśmy listę graczy, talię do gry i dealera odpowiedzialnego za rozdawania kart. Problem polega na tym, że klasa CardGame jest ściśle powiązana z klasami Deck, Player oraz Dealer. Jest zależna od ich implementacji. Żeby to naprawić musimy zmienić naszą klasę w następujący sposób:

public class CardGame
{
List<Player> players;
Deck deck;
Dealer dealer;

public CardGame(Deck deck, List<Player> players, Dealer dealer)
{
this.deck = deck;
this.players = players;
this.dealer = dealer;
}

public void Start()
{
//rozpoczęcie gry
}
}

Należy zauważyć tutaj jedną kluczową zmianę. Klasa CardGame dalej używa wszystkie klasy, ale nie jest odpowiedzialna za ich tworzenie. Odpowiedzialność za tworzenie i przekazywanie odpowiednich obiektów przeniesiona jest w inne miejsce – najczęściej odpowiedzialne są za to konstrukcyjne wzorce projektowe takie jak Fabryka lub IOC.

0 komentarze:

Prześlij komentarz

 
Design by Free WordPress Themes | Bloggerized by Lasantha - Premium Blogger Themes | Online Project management