Ilu z nas wracając do starego kodu (napisanego wczoraj/ tydzień temu/ miesiąc temu) krzywi się patrząc na bałagan, który po sobie pozostawił? W zasadzie prościej będzie chyba zapytać komu się to nie zdarza. Otóż amerykańscy naukowcy znaleźli na to sposób. No dobra może nie amerykańscy, ale skrót jest z angielskiego – SOLID, rozkłada się on na pięć czynników, a każdy z nich jest znowu jakimś skrótem.
O – (OCP) Open / Closed Principle
L – (LSP) Liskov Substitution Principle
I – (ISP) Interface Segregation Principle
D – (DIP) Dependency Inversion Principle
O co chodzi z tymi regułami wchodzącymi w skład SOLID? Jest to pięć zasad mówiących o tym jak tworzyć kod, który będzie łatwiej utrzymywać, rozszerzać, czytać, a problem głodu na świecie zniknie. Pitu pitu, już pokazuje przykłady, które będą proste, czasem wręcz naiwne, chodzi o ukazanie idei, nie rozwiązanie jakiegoś rzeczywistego problemu.
Single Resposibility Principle – wszyscy piszą że “Nigdy nie powinno być więcej niż jednego powodu do modyfikacji”. Strasznie mnie irytuje taki zapis, bo można napisać “klasa jest odpowiedzialna tylko za jedną logiczną funkcjonalność”. Skoro służy do mierzenia, to nie służy do zapisywania. Jeśli ma konwertować wartości z jednego formatu na inny, to nie powinna ich wysłać do innych części aplikacji. etc., itd., itp.
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: Consolas, “Courier New”, Courier, Monospace;
background-color: #ffffff;
max-height: 300px;
overflow: auto;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #a31515; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
1: public class BadHardwareDevice
2: {
3: int hardwareReadingLevel;
4: void DisplayHardwareLevelAlarm()
5: {
6: Console.WriteLine("Current value of {0} is dangerous!", hardwareReadingLevel);
7: }
8:
9: int GetHardwareReadings()
10: {
11: // reads data from device into hardwareReagingLevel
12: // return value from that reading
13: return hardwareReadingLevel;
14: }
15: }
Prosta klasa, ale zajmuje się ona dwiema rzeczami jednocześnie; odczytuje wartość z urządzenia (GetHardwareReadings) oraz wypisuje alarm na konsolę (DisplayHardwareLevelAlarm).
Kod można zmodyfikować na przykład w taki sposób:
1: public class BetterHardwareDeviceConsoleLogger
2: {
3: void DisplayHardwareLevelAlarm(int aLevel)
4: {
5: Console.WriteLine("Current value of {0} is dangerous!", aLevel);
6: }
7: }
8:
9: public class BetterHardwareDeviceReader
10: {
11: int hardwareReadingLevel;
12: int GetHardwareReadings()
13: {
14: // reads data from device into hardwareReagingLevel
15: // return value from that reading
16: return hardwareReadingLevel;
17: }
18: }
Teraz klasa odczytu zajmuje się tylko odczytem, natomiast klasa logująca alarm tylko wypisaniem na konsolę. Dodatkowo jeśli będzie potrzeba wysłania zapisu gdzieś indziej niż na konsolę nie będzie to miało wpływu na działanie innych komponentów. Bez modyfikacji już istniejących klas można stworzyć klasę BetterHardwareDeviceNetworkLogger i wysłać informację o poziomie wartości w świat.
Open / Close Principle – wszyscy piszą, że klasa powinna być otwarta na rozszerzenia, a zamknięta na modyfikacje. I bądź mądry. Już nadciągam z chłopską odsieczą i rozumowaniem. Klasa powinna być tak napisana, aby nie trzeba było jej aktualizować gdy pojawi się nowa implementacja np. klasy logującej. Rozszerzenie funkcjonalne aplikacji powinno zostać zaimplementowane poza klasą, ona sama natomiast może z niej skorzystać. Jak zawsze bez kodu ciężko jest tłumaczyć:
1: public class BadLogger
2: {
3: public void LogMessage(string aMessage, BadLogTarger aTarget)
4: {
5: switch (aTarget)
6: {
7: case BadLogTarger.ConLog:
8: // write to console
9: break;
10: case BadLogTarger.NetLog:
11: // write to network
12: break;
13: case BadLogTarger.DevNullLog:
14: // ignore writing
15: break;
16: }
17: }
18: }
19:
20: public enum BadLogTarger
21: {
22: ConLog,
23: NetLog,
24: DevNullLog,
25: }
Tutaj aby dodać logowanie np. do pliku należy dodać kolejnego enuma (przewińcie przykładowy kod na dół), a następnie rozszerzyć metodą LogMessage o odpowiedniego case….. dużo roboty. Może lepiej będzie zrobić coś takiego:
1: public class BetterLogger
2: {
3: ILogger logger;
4: public BetterLogger(ILogger aLogger)
5: {
6: this.logger = aLogger;
7: }
8:
9: public void LogMessage( string aMessage )
10: {
11: logger.LogMessage(aMessage);
12: }
13: }
14:
15: public interface ILogger
16: {
17: void LogMessage(string aMessage);
18: }
19:
20: public class ConLogger: ILogger
21: {
22: void ILogger.LogMessage(string aMessage)
23: {
24: // log to console
25:
26: }
27: }
28:
29: public class NetLogger : ILogger
30: {
31: public void LogMessage(string aMessage)
32: {
33: // log to network
34: }
35: }
36:
37: public class DevNullLogger : ILogger
38: {
39: public void LogMessage(string aMessage)
40: {
41: // log to network
42: }
43: }
Ojej więcej kodu. Tak tak, ale teraz obczaj to (miły czytelniku), po dodaniu nowego logera (np. FileLoger) implementuje się metodą LogMessage, a następnie przekazują taką klasę do BetterLogger, której nie trzeba już modyfikować. Wszystko działa tak jak poprzednio, a jest nowa funkcjonalność. Klasa jest otwarta na rozszerzenia, ale zamknięta na zmiany – voila.
Liskov Substitution Principle – wszyscy strasznie komplikują opis. Mam proste wytłumaczenie tej zasady (proste bo po co komplikować, albo proste bo nie zrozumiałem zasady), jeśli dziedziczysz lub implementujesz pewien interfejs, wszystkie klasy pochodne powinny zachowywać się w podobny (logicznie podobny) sposób. Tak aby obiekt wykorzystujący referencję (wskaźnik) do klasy bazowej, mógł spokojnie wykorzystać funkcjonalność klas bardziej pochodnych i nie zostać zaskoczonym przez nietypowym zachowaniem (patrz kod poniżej). Dodatkowym założeniem jest, że parametry akceptowane przez klasy pochodne mogą być mniej restrykcyjne niż w klasie bazowej (może obsłużyć więcej przypadków), natomiast wartości zwracana mogą być takie same lub mniejsze (nie można zwrócić czegoś, co nie jest określone przez klasa bazowa). Dygresja: Przykład z prostokątem i kwadratem dla mnie ma sens i jest poprawny, nie wiem dlaczego jest uznawany za błędną implementacje, jeśli ktoś potrafi to wytłumaczyć proszę o kontakt.
1: public interface BadISettings
2: {
3: void Save();
4: void Load();
5: }
6:
7: public class BadUserSettings : BadISettings
8: {
9: public void Save()
10: {
11: // save user settings
12: }
13:
14: public void Load()
15: {
16: // load user settings
17: }
18: }
19:
20: public class BadApplicationSettings : BadISettings
21: {
22: public void Save()
23: {
24: // throw InvalidOperationException
25: // cannot overwrite application settings
26: }
27:
28: public void Load()
29: {
30: // load application settings
31: }
32: }
Co jest nie tak? Otóż ustawienia aplikacja nie chętnie się zapisują. Co wpływa na możliwość ich wykorzystania przez interfejs BadISettings. Interfejs nie definiuje nigdzie, że będzie rzucał wyjątkiem, poza tym nie definiuje on metody tylko po to żeby jej nie wspierać. Jednym z rozwiązań jest coś takiego:
1: public interface BetterIReadSettings
2: {
3: void Read();
4: }
5:
6: public interface BetterIWritableSettings
7: {
8: void Save();
9: }
10:
11: public class BetterUserSettings : BetterIReadSettings, BetterIWritableSettings
12: {
13: void Read()
14: {
15: // read settings
16: }
17:
18: void Save()
19: {
20: // save settings
21: }
22: }
23:
24: public class BetterApplicationSettings : BetterIReadSettings
25: {
26: public void Read()
27: {
28: // load application settings
29: }
30: }
//Pozdrawiam czułe oko Jacka
Teraz można spokojnie wykorzystać wspólny interfejs BetterIWritableSettings bez obaw że coś się wywali lub wyleci w powietrze. A BetterIReadableSettings z jedną metodą? A czy jest w tym coś złego? Zerknij na SRP.
Interface Segregation Principle – różnie o tym piszą, akurat nie ma informacji w rodzimym języku. Klienci naszej klasy nie powinni mieć dostępu do elementów z których nie korzystają. Będzie lepiej jeśli nawet nie będzie o nich wiedzieć. Klient powinien dostać tylko tyle ile potrzebuje, nic więcej. Jak zwykle abstrakcja i interfejsy są jor friend.
1: public class BadPerson
2: {
3: public string FirstName;
4: public string LastName;
5: public string EmailAddress;
6: public string PhoneNumber;
7: public string Address;
8: }
9:
10: public class BadEmailer
11: {
12: public void SendEmail( BadPerson aBadPerson )
13: {
14: // send email using just email address
15: }
16: }
17:
18: public class PhoneCaller
19: {
20: public void PhoneCall( BadPerson aBadPerson )
21: {
22: // make phone call using just phone number
23: }
24: }
25:
26: public class LetterSender
27: {
28: public void SendLetter (BadPerson aBadPerson )
29: {
30: // send letter using firstname, lastname and an address
31: }
32: }
Tutaj widać że klasa BadPerson agreguje wszystko co dotyczy jakiegoś człowieczka, co dał się wbić w internet za kubek kawy ;). Następnie wszystkie te informacje są przekazywane do innych obiektów, który wykorzystują tylko część z tych danych. Kto wie co one tak naprawdę robią z resztą z nich? Czy nie lepiej zamienić to na coś takiego:
1: public interface BetterIEmailable
2: {
3: string EmailAddress{get;set;}
4: }
5:
6: public interface BetterIPhoneable
7: {
8: string PhoneNumber{get;set;}
9: }
10:
11: public interface BetterILetterable
12: {
13: string FirstName{get;set;}
14: string LastName{get;set;}
15: string Address{get;set;}
16: }
17:
18: public class BetterPerson : BetterIEmailable, BetterILetterable, BetterIPhoneable
19: {
20: public string EmailAddress { get; set; }
21: public string PhoneNumber { get; set; }
22: public string FirstName { get; set; }
23: public string LastName { get; set; }
24: public string Address { get; set; }
25: }
26:
27: public class BetterEmailer
28: {
29: public void SendEmail(BetterIEmailable aBetterPerson)
30: {
31: // send email using just email address
32: }
33: }
34:
35: public class BetterSender
36: {
37: public void SendLetter(BetterILetterable aBetterPerson)
38: {
39: // send letter using firstname, lastname and an address
40: }
41: }
42:
43: public class BetterCaller
44: {
45: public void PhoneCall(BetterIPhoneable aBetterPerson)
46: {
47: // make phone call using just phone number and first name
48: }
49: }
Teraz każdy dostaje tylko to czego potrzebuje. Nie zaistnieje problem, w którym klasa wysyłająca pocztę elektroniczną zacznie zapisywać dane teleadresowe, podczas gdy miała wysłać tylko email. Wiecie rozumiecie.
Dependency Inversion Principle – mówi o tym, aby powiązania pomiędzy klasami były luźne. Klasy nie powinny zależeć od właściwej implementacji, a działać na wcześniej zdefiniowanym interfejsie. Dzięki temu prościej jest rozszerzać funkcjonalność lub całkowicie ją zmieniać, oczywiście w ramach interfejsu i zasady LSP.
1: public class BadButton
2: {
3: public BadButton( BadLamp aLamp)
4: {
5: Lamp = aLamp;
6: }
7:
8: public void Click()
9: {
10: this.Lamp.Switch();
11: }
12:
13: public BadLamp Lamp { get; private set; }
14: }
15:
16: public class BadLamp
17: {
18: readonly BadButton button;
19: public BadLamp()
20: {
21: this.button = new BadButton(this);
22: }
23:
24: public void Switch()
25: {
26: // switch the light on/off
27: }
28: }
Przykład powyżej to wyjątkowo mocne powiązanie klasy BadButton oraz BadLamp. Nie będą one współpracować z żadną inną klasą niż te zdefiniowane, chyba że dziedziczące po BadLamp, natomiast już na pewno nie zaistnieje możliwość zmiany guzika na inny. Oczywiście istnieje lepsze rozwiązanie:
1: public interface BetterIButtonClient
2: {
3: void Switch();
4: }
5:
6: public interface BetterIButton
7: {
8: void Click();
9: }
10:
11: public class BetterButton : BetterIButton
12: {
13: BetterIButtonClient client;
14: public BetterButton(BetterIButtonClient aClient)
15: {
16: client = aClient;
17: }
18:
19: public void Click()
20: {
21: this.client.Switch();
22: }
23: }
24:
25: public class FancyBetterButton : BetterIButton
26: {
27: BetterIButtonClient client;
28: public FancyBetterButton(BetterIButtonClient aClient)
29: {
30: client = aClient;
31: }
32:
33: public void Click()
34: {
35: this.client.Switch();
36: }
37: }
38:
39: public class BetterLamp : BetterIButtonClient
40: {
41: BetterIButton betterButton;
42: public BetterLamp(BetterIButton aBetterButton)
43: {
44: betterButton = aBetterButton;
45: }
46:
47: public void Switch()
48: {
49: // switch the light on/off
50: }
51: }
Co dostajemy? Otóż button już nie musi działać tylko i wyłącznie z lampą, zadziała także z pralką, lodówką i innym AGD o ile sprzęt będzie obsługiwać interfejs BetterIButtonClient i prostą metodą Switch. A dalej widać, że lampa także może być już bardziej fikuśna, nie zależy ona już tylko od jednego rodzaju Buttona. W dodatku to nie ona jest odpowiedzialna za decydowanie z którego skorzysta (od siebie dodałem idee: DI). Dzięki temu fabryka lamp może w prosty sposób przerzucić się na nowszy model z wykorzystaniem FancyBetterButton-ów. Widać tu wpływy idei OCP. Taki kod prościej jest wykorzytać w innym projektach, ponieważ nie jest on mocno powiązany z bezpośrednią implementacją klasy guzików czy innych obiektów, zdefiniowanych z projekcie.
Podczas pisania kodu należy przede wszystkim korzystać z G.Ł.O.W.Y. to jest najważniejsza zasada! Dopiero później warto podpierać się różnymi ideami i regułami, które proponują inni. Zasady są po to aby je łamać, a nic tak nie uczy człowieka jak popełniane błędy. Zachęcam do łamania zasad SOLID, a potem sprawdzenia czy miało to sens i podzielenia się tym doświadczeniem ze mną i innymi 😉
Podczas wpisu sporą część (bardzo dużą część wiedzy) oparłem na
[1] http://www.blackwasp.co.uk/SOLIDPrinciples.aspx
[2] www.wikipedia.org polska i angielska wersja
[3] http://www.objectmentor.com Tutaj trzeba się porządnie naszukać aby znaleźć materiały
Szczególnie polecam punkt pierwszy.
Uwagi jak zawsze chętnie przyjmę.
ps. Jutro do pracy
ps2. C# jest czytelny, nawet dla nie programujących w C#.
ps3. Link do źródełek ze wszystkimi zasadami https://www.sugarsync.com/pf/D6056980_3239101_969599
Świetna robota:) Jak zwykle zaoszczędziłeś mi trochę czasu, który pewnie zmarnuje na jakieś pierdoły.
Zdecydowanie nie stosuje się do wszystkich zasad SOLID. Biorę sobie jak ze szwedzkiego stołu to co mi pasuje i na miarę moich możliwości.
Przeraża mnie już samo projektowanie aplikacji tak aby jej kod stosował te zasady. Czas pokaże:)
Uważam że nie warto tworzyć kodu od razu w pełni kompatybilnego z SOLID, należy zrobić tak aby działał. I przy okazji był kompatybilny z SOLID. Tak samo jest ze wzorcami, piszesz kod, który ma spełniać jakiś wymagania, potem go poprawiasz i jeśli widzisz, że zastosowanie przypomina jakiś wzorzec, możesz go (kod) do tego przybliżyć. Trzeba być tęgą głową, aby od razu stworzyć aplikację zgodną ze wszystkimi zasadami, wg. najlepszych wzorców i bez błędów. Wiem tylko, że mi jeszcze bardzo daleko do takiego poziomu.
Polecam lekturę:
"Agile Principles, Patterns, and Practices in C#" @ Martin
Autor świetnie wyjaśnia SOLID, jego użycie i nadużycie. Nie mogę teraz zacytować, ale zasada o której pisze jest prosta – Nie stosujemy żadnego wzorca do puki nie jest to niezbędne – w innym przypadku jest to nadużycie które jest równie niebezpieczne jak każdy inny programistyczny grzech 🙂 Przyjmujemy pierwsza kule i zmieniamy nasz kod w taki sposób żeby nie było strachu o każdą następna. Np. w pierwszym momencie piszemy BadLogger, mamy tylko jedną implementację, nie musimy wstrzykiwać żadnych zależności, pisać interfejsu etc. Ale w momencie w którym zachodzi potrzeba dodania nowej implementacji, refaktoryzujemy kod wykorzystując dostępne wzorce, tak, żeby można było rozszerzać naszą klasę bez bólu 🙂
Jestem jak najbardziej za ewolucją kodu, piszesz aby zadziałało, potem poprawiasz. Nie zmienia to faktu, że znając pewne dobre zasady i praktyki, od razu możesz pisać w taki sposób, aby było dobrze.
Hej,
zawsze dobrze się czyta tekst zawierający własne przemyślenia. Jest o czym dyskutować 🙂
Interfejs BetterIButton w Dependency Inversion Principle można by nazwać IBetterClickable (interfejs, zawiera jedynie metodę Click).
Czy BetterApplicationSettings nie powinna implementować BetterIReadSettings skoro wcześniej sugerujsz że ustawień aplikacji nie można nadpisywać?
Uważam że Single Resposibility Principle jest dobrze użyta gdy do ogólnego opisu klasy wystarcza jedno zdanie.
Open / Close Principle kojarzy mi się bezpośrednio z Delegation pattern http://en.wikipedia.org/wiki/Delegation_pattern
Znalazłem wyjaśnienie dla przykładu z prostokątem i kwadratem dla Liskov Substitution Principle http://www.oodesign.com/liskov-s-substitution-principle.html i http://en.wikipedia.org/wiki/Liskov_substitution_principle#A_typical_violation Rzeczywiście jest tu problem z kwadratem i niezmiennikiem długości boków. Zastanawiam się jednak czy to jest dobry przykład na naruszenie Liskov Substitution Principle.
Jak zawsze polecam C2, http://c2.com/cgi/wiki?PrinciplesOfObjectOrientedDesign
PS tekst napisany Helveticą jest mądrzejszy ale tylko z obrazkiem w tle 😉
Poprawiłem BetterApplicationSettings – oczywiście miałeś racje 🙂
No właśnie ten kwadrat i prostokąt dla LSP jest takim słabym przykładem.
Dzięki za C2, może po przeczytaniu skopiuje do siebie z własnymi uwagami 😉
Znowu "Agile Principles, Patterns, and Practices in C#" @ Martin dobrze wyjaśnia błędny przykład kwadrat – prostokąt 🙂
Liskov Substitution jest bardzo wrażliwe na kontekst:
Np. jeżeli kwadrat zawiera następujący kod:
public int A
{
set
{
a=value;
b=a;
}
}
Krótko mówiąc ustawiając wartość jednego z boków ustawiamy drugi na tą samą wartość. Wszystko jest ok, implementacja jest jak najbardziej poprawna, nie naruszamy LSP…do momentu aż nie napiszemy czegoś takiego:
Prostokat prostokat = new Kwadrat()
prostokat.A=5;
prostokat.B=10;
Assert.AreEqual(15,prostokat.A + prostokat.B);
Assert.AreEqual(50,prostokat.Pole);
Krótko mówiąc, jeżeli nasza aplikacja (kontekst) nie ma powyższego założenia, to wszystko jest ok. Problem z tym przykładem jest taki, że logicznie rzecz biorąc widząc klasę Prostokąt możemy z góry oczekiwać powyższego zachowania 🙂 Po detale odsyłam do lektury 😉
Z kontekstem staje się on sensowny. Dzięki za Martina zlókam książkę.