Zasady S.O.L.I.D.
S.O.L.I.D. to akronim, który zaproponował słynny Amerykański programista Robert C. Martin. Kryje się pod nim pięć zasad, podpowiadających jak pisać dobry kod zorientowany obiektowo. Samo słówko „solid” jest równocześnie grą słów, które można przetłumaczyć jako solidny, konkretny, mocny. W niniejszym artykule postaram się wyjaśnić (na przykładach) poszczególne zasady.
- S – Samodzielny (zasada pojedynczej odpowiedzialności)
- O – Otwarty (zasada otwarty/zamknięty)
- L – Liskov Barbara (zasada podstawienia Liskov)
- I – Interfejsy (zasada segregacji interfejsów)
- D – oDwrócenie zależności (zasada odwrócenia zależności)
6. Przydatne pojęcia
7. Podsumowanie
*Uwaga: Chociaż te zasady mogą dotyczyć różnych języków programowania, to przykładowy kod zawarty w tym artykule będzie korzystał z języka java.
**Uwaga: Zakładam też, że czytelnik posiada chociaż podstawową wiedzę programistyczną ze szczególnym uwzględnieniem zagadnień z programowania obiektowego.
Zasada pojedynczej odpowiedzialności lub S jak Samodzielny – każda klasa powinna być odpowiedzialna za jedną konkretną rzecz.
Zasada ta sprowadza się do tego, że dana klasa powinna mieć jedną odpowiedzialność. Tę jedną funkcjonalność, którą realizuje. Klasy, które implementują wyłącznie jedną odpowiedzialność nie są bezpośrednio związane (ang. coupled) z inną funkcjonalnością.
Zły kod:
Mamy taką oto klasę:
public class Student {
private String firstName;
private String lastName;
private int age;
public long calculateFee() { }
public String reportAttendance() { }
public void saveDetails() { }
}
Zauważmy, że jest ona jest odpowiedzialna nie tylko za przechowywanie danych o studentach, ale także za naliczanie opłat, zgłaszanie obecności i zapisywanie ich danych w bazie danych. Więc klasa ’Student’ ma więcej niż tylko jedną funkcjonalność, co jest naruszeniem zasady pojedynczej odpowiedzialności.
Aby to naprawić, przeniesiemy różne funkcjonalności do różnych klas:
public class Student {
private String firstName;
private String lastName;
private int age;
}
public static class FeeCalculator {
public long calculateFee(Student s) { }
}
public static class AttendanceCalculator {
public String reportAttendance(Student s) { }
}
public static class StudentInfo {
public void saveDetails(Student s) { }
}
Dzięki takiemu rozdzieleniu określonych funkcjonalności każda zmiana w logice, np. naliczania opłat, nie prowadzi do modyfikacji naszej głównej klasy ’Student’.
Zasada otwarty/zamknięty lub O jak Otwarty – każda klasa powinna być otwarta na rozbudowę, ale zamknięta na modyfikacje.
Każda klasa powinna być napisana w taki sposób, aby możliwe było dodawanie nowych funkcjonalności bez konieczności jej modyfikacji. Innymi słowy, programista nie powinien zmieniać już istniejącego kodu aplikacji (czyli go modyfikować), a raczej rozszerzać go o nowe funkcjonalności (rozbudowywać go), dodając nowe klasy i metody. Można powiedzieć, że sprowadza się to do świadomego użycia mechanizmów programowania obiektowego – kompozycji, dziedziczenia, polimorfizmu czy modyfikatorów dostępu.
Zły kod:
Powiedzmy, że mamy aplikację liczącą pola figur geometrycznych. Może ona wyglądać, np. tak:
public class Square {
public int A;
}
public class Rectangle {
public int A;
public int B;
}
public class AreaCalculator {
public int area(Object shape) {
int result = 0;
if (shape instanceof Square square) {
result = square.A * square.A;
return result;
}
if (shape instanceof Rectangle rectangle) {
result = rectangle.A * rectangle.B;
return result;
}
return result;
}
}
Teraz przetestujmy naszą aplikację:
public class Launcher {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
int result = calculator.area(new Square(5));
System.out.println(result);
}
}
Otrzymaliśmy poprawny wynik:
25
Process finished with exit code 0
Zauważmy jednak, że dodanie jakiejkolwiek nowej figury, wiąże się z koniecznością modyfikacji istniejącej klasy. A to jest sprzeczne z zasadą otwarty/zamknięty. Poprawimy nasz kod używając, np. polimorfizmu. Ale aby móc to zrobić, musimy najpierw dodać nową abstrakcyjną klasę ’Shape’. Zawiera ona tylko jedną metodę, którą później nadpiszemy – wtedy każda klasa reprezentująca figurę będzie sama wyliczać swoje pole.
abstract class Shape {
public abstract int Area();
}
public class Square extends Shape {
public int A;
@Override
public int Area() {
return A * A;
}
}
public class Rectangle extends Shape {
public int A;
public int B;
@Override
public int Area() {
return A * B;
}
}
public class AreaCalculator {
public int Area(Shape shape) {
return shape.Area();
}
}
public class Launcher {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
int result = calculator.Area(new Square(5));
System.out.println(result);
}
}
Teraz dodanie nowej figury nie będzie wymagało od nas ingerencji w już istniejącym kodzie. Dodatkowo poprawiła się przejrzystość naszej aplikacji.
Zasada podstawienia Liskov lub L jak Liskov Barbara – w miejscu klasy podstawowej można użyć dowolnej klasy pochodnej (jej podklasy).
Jej nazwa pochodzi od nazwiska amerykańskiej programistki Barbary Liskov. Zasada ta mówi, że nasz kod powinien współpracować poprawnie z klasą podstawową, jak i wszystkimi jej podklasami. Jest ona niejako rozszerzeniem zasady otwarty/zamknięty, a jej zastosowanie do mechanizmu dziedziczenia pozwala na dostarczenie alternatywnej implementacji danej funkcjonalności bez zakłócania działania naszego programu.
Zły kod:
Stwórzmy sobie klasę ’MediaPlayer’, która będzie miała dwie metody – odtwarzanie dźwięku i wideo:
public class MediaPlayer {
public void playAudio() { }
public void playVideo() { }
}
Następnie dodajmy trzy podklasy odpowiadające trzem konkretnym odtwarzaczom multimedialnym:
public class VlcMediaPlayer extends MediaPlayer {
public void playAudio() {
System.out.println("Vlc: Playing audio...");
}
public void playVideo() {
System.out.println("Vlc: Playing video...");
}
}
public class MediaPlayerClassic extends MediaPlayer {
public void playAudio() {
System.out.println("MPC: Playing audio...");
}
public void playVideo() {
System.out.println("MPC: Playing video...");
}
}
public class WinampMediaPlayer extends MediaPlayer {
public void playAudio() {
System.out.println("Winamp: Playing audio...");
}
public void playVideo() {
System.out.println("Play video is not supported in Winamp player!");
}
}
Przetestujmy naszą aplikację:
public class ClientTestProgram {
public static void main(String[] args) {
List<MediaPlayer> allPlayers = new ArrayList<MediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.add(new WinampMediaPlayer());
allPlayers.forEach(MediaPlayer::playVideo);
}
}
Po uruchomieniu otrzymamy:
Vlc: Playing video...
MPC: Playing video...
Play video is not supported in Winamp player!
Process finished with exit code 0
Co jest zgodne z oczekiwaniami, ponieważ wiemy, że Winamp potrafi odtwarzać tylko muzykę i dźwięki. Występuje tutaj zjawisko źle przemyślanego mechanizmu dziedziczenia. Mamy tu więc wyraźne naruszenie zasady podstawienia Liskov.
Poprawmy nasz kod:
public class MediaPlayer { }
Stworzymy nowe klasy tylko do odtwarzania wideo i audio:
public class VideoMediaPlayer extends MediaPlayer {
public void playVideo() { }
}
public class AudioMediaPlayer extends MediaPlayer {
public void playAudio() { }
}
Teraz niewielkie zmiany czekają również nasze 3 podklasy:
public class VlcMediaPlayer extends VideoMediaPlayer { }
public class MediaPlayerClassic extends VideoMediaPlayer { }
public class WinampMediaPlayer extends AudioMediaPlayer { }
Przetestujmy teraz naszą aplikację:
public class ClientTestProgram {
public static void main(String[] args) {
List<VideoMediaPlayer> allPlayers = new ArrayList<VideoMediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.add(new WinampMediaPlayer()); // this line would give us error as can't add audio player in list of video players
allPlayers.forEach(VideoMediaPlayer::playVideo);
}
}
Zauważmy, że nasza lista zawiera teraz inny typ danych – ’VideoMediaPlayer’ zamiast ’MediaPlayer’. Sygnalizujemy w ten sposób, że nasza lista zawiera programy, potrafiące odtwarzać pliki wideo. Próbując dodać Winampa do naszej listy od razu dostaniemy błąd. Jeśli chcemy, możemy w naszej klasie dodać drugą listę, zawierającą programy do odtwarzania muzyki. Wtedy nasza klasa może wyglądać np. tak:
public class ClientTestProgram {
public static void main(String[] args) {
List<VideoMediaPlayer> allPlayers = new ArrayList<VideoMediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.forEach(VideoMediaPlayer::playVideo);
// Create list of audio players
List<AudioMediaPlayer> audioPlayers = new ArrayList<AudioMediaPlayer>();
audioPlayers.add(new WinampMediaPlayer());
// Play audio in all players
audioPlayers.forEach(AudioMediaPlayer::playAudio);
}
}
Powyższy przykład idealnie przestrzega zasady podstawienia Liskov.
Zasada segregacji interfejsów lub I jak Interfejsy – interfejsy powinny być konkretne i jak najmniejsze.
Interfejsy powinny być odpowiednio zdefiniowane, a jeżeli są dość rozbudowane, to należy podzielić je na mniejsze. W ten sposób zapewniamy, że implementacja klas będzie dotyczyć tylko tych metod, które dana klasa faktycznie używa. Innymi słowy – nie powinniśmy zmuszać klas do wprowadzania zależności od interfejsów, których nie używają. Cel zasady segregacji interfejsów jest podobny do zasady pojedynczej odpowiedzialności.
Zły kod:
Stwórzmy interfejs o nazwie ’Conversion’, który będzie posiadał trzy metody:
public interface Conversion {
void intToDouble();
void intToChar();
void charToString();
}
Powiedzmy, że ten interfejs używamy w dwudziestu innych klasach naszej aplikacji. Możemy bezpiecznie założyć, że klasy te nie wykorzystują jego wszystkich 3 metod. Jest to wyraźne złamanie zasady segregacji interfejsów. Rozwiązaniem będzie rozdzielenie naszego interfejsu ’Conversion’ na trzy mniejsze:
public interface ConvertIntToDouble {
void intToDouble();
}
public interface ConvertIntToChar {
void intToChar();
}
public interface ConvertCharToString {
void charToString();
}
Teraz możemy korzystać tylko z metody, której faktycznie potrzebujemy. Powiedzmy, że jedna z naszych klas potrzebuje zamiany integer na char, a potem char na String – skorzystamy wtedy tylko z metod ’intToChar()’ oraz ’charToString()’ implementując odpowiednie interfejsy:
public class DataTypeConversion implements ConvertIntToChar, ConvertCharToString {
@Override
public void charToString() { }
@Override
public void intToChar() { }
}
Dzięki rozdzieleniu naszego interfejsu na mniejsze, zmiana jednej z ich metod nie pociąga za sobą zmian w każdej z 20 wspomnianych wcześniej klasach.
Zasada odwrócenia zależności lub D jak oDwrócenie zależności – wszystkie zależności powinny w jak największym stopniu zależeć od abstrakcji a nie od konkretnego typu.
Klasy podstawowe nie powinny zależeć od klas pochodnych (podklas), ale zarówno jedne jak i drugie powinny zależeć od abstrakcji. Abstrakcje nie powinny zależeć od szczegółów – to szczegóły powinny zależeć od abstrakcji. Wobec tego powinniśmy używać abstrakcji (klas abstrakcyjnych i interfejsów) zamiast konkretnych implementacji. Dzięki temu zmniejszamy powiązanie pomiędzy klasami (ang. tight coupling) co jest głównym celem stosowania tej zasady.
Zły kod:
Stwórzmy sobie klasę ’WindowsMachine’. Do pracy na komputerze z systemem windows potrzebujemy klawiatury oraz monitora, więc tworzymy odpowiednie klasy i dodajemy ich instancje do naszego konstruktora. Wygląda to mniej więcej tak:
public class WindowsMachine {
private StandardKeyboard keyboard;
private Monitor monitor;
public WindowsMachine() {
keyboard = new StandardKeyboard();
monitor = new Monitor();
}
}
public class StandardKeyboard { }
public class Monitor { }
Warto zaznaczyć, że powyższy kod będzie działał, jednakże deklarując ’StandardKeyboard’ i ’Monitor’ za pomocą słowa kluczowego „new”, ściśle powiązaliśmy ze sobą te trzy klasy. Co nie tylko utrudnia testowanie naszej klasy ’WindowsMachine’, ale straciliśmy także możliwość zamiany naszych klas ’StandardKeyboard’ i ’Monitor’ na inne (gdybyśmy potrzebowali zmiany, np. klawiatury na jakiś niestandardowy model).
Poprawimy teraz nasz kod, dodając bardziej ogólny interfejs klawiatury (oddzieli to nasze klasy):
public interface Keyboard { }
Nasza klasa ’WindowsMachine’ wygląda teraz tak:
public class WindowsMachine {
private Keyboard keyboard;
private Monitor monitor;
public WindowsMachine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
W powyższym kodzie użyliśmy wzorca wstrzykiwania zależności (ang. dependency injection), aby ułatwić dodanie zależności ’Keyboard’ do klasy ’WindowsMachine’. Zmodyfikujemy również naszą klasę ’StandardKeyboard’, implementując nowo stworzony interfejs klawiatury, tak aby nadawał się do wstrzykiwania do naszej klasy ’WindowsMachine’:
public class StandardKeyboard implements Keyboard { }
Teraz nasze klasy są oddzielone i komunikują się za pomocą abstrakcji klawiatury. Jeśli chcemy, możemy łatwo zmienić typ klawiatury w naszej maszynie z inną implementacją interfejsu. Możemy zastosować tę samą zasadę dla klasy ’Monitor’.
Przydatne pojęcia:
- Polimorfizm – inaczej wielopostaciowość, to zapisywanie jednej funkcji (metody) pod różnymi postaciami. Oznacza to, że dzięki niemu możemy obsługiwać różne typy w różny sposób, bez ich znajomości.
- Dziedziczenie – to rodzaj relacji pomiędzy dwoma klasami, która pozwala jednemu z nich dziedziczyć kod drugiego. Dzięki niemu można budować hierarchię między klasami. Innymi słowy – obiekt może przejąć metody i właściwości innego obiektu.
- Wzorzec wstrzykiwania zależności – pozwala automatycznie wstrzykiwać instancje klas przez konstruktor, zamiast tworzyć je słowem kluczowym „new”.
- Abstrakcja – nie interesują nas szczegóły danej implementacji, ale jej funkcjonalności. Jako przykład abstrakcji rozważmy odtwarzacz DVD. Konsumentów odtwarzacza zazwyczaj wcale nie interesuje to, jak on działa z technicznego punktu widzenia i co się dzieje w jego środku. Interesuje ich tylko to, jak go włączyć, wyłączyć, zatrzymać, itd.
Podsumowanie
Zasady S.O.L.I.D. są niezłą bazą dla każdego początkującego programisty – pozwalają m. in. na zmniejszenie zależności między blokami kodu, a nasz system jest skalowalny i łatwiejszy w utrzymaniu. Trzeba oczywiście pamiętać, że mogą zdarzyć się sytuacje, gdzie złamanie niektórych zasad ma sens. Zwłaszcza w rozbudowanych projektach nie zawsze wszystkich zasad da się idealnie przestrzegać, jednak powinniśmy dążyć do poprawy jakości kodu i wdrażania zasad S.O.L.I.D. jeśli jest to tylko możliwe. Choćby z oczywistego powodu – każdy programista rozumie to, co sam napisał. Jednak problem pojawia się wtedy, gdy jesteśmy zmuszeni przesiąść się do nieswojego projektu, i pisać w kodzie, którego nigdy wcześniej nie widzieliśmy. W takim przypadku bardzo pomaga, gdy system nad którym pracujemy przestrzega zasad S.O.L.I.D.