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
}
}
public class Dealer
{
Deck deck;
public Card DealCard(Player player)
{
//rozdanie jednej karty dla gracza
}
}
Wskazówka – użycie regionów
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.
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ń.
- Jeżeli metody klas mają inne parametry. Interfejsy klas są nie spójne.
public class DeckNa powyższym przykładzie interfejsy klasy bazowej i pochodnej nie zgadzają się. DoubleDeck przyjmuje dodatkowo parametr bool przez co łamie zasadę LSP.
{
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();
- Jeżeli metody nie mają logicznego sens
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”.
public interface IOneCardMasterGame
{
void AddPlayer(Player player);
void RemovePlayer(Player player);
void SetPlayerLimit(int number);
void PerformGame();
Player GetWinner();
IList<Player> GetGameState();
int GetPlayerCount();
}
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