Single responsibility principle
Open/closed principle
Liskov substitution principle
Interface segregation principle
Dependency inversion principle
Czym jest SOLID?
SOLID to akronim, który odnosi się do pięciu zasad programowania obiektowego. Zasady te opracował Robert C. Martin i są one uważane za wzorzec dobrej praktyki w projektowaniu oprogramowania. Zastosowanie tych zasad w projektowaniu oprogramowania pomaga zwiększyć jego czytelność, zrozumiałość, modyfikowalność i elastyczność.
Single responsibility principle (Zasada jednej odpowiedzialności) mówi, że każdy obiekt w programie powinien mieć tylko jedną odpowiedzialność i powinien być odpowiedzialny za tylko jedną część systemu. Innymi słowy, każdy obiekt powinien być odpowiedzialny za wykonywanie tylko jednego typu operacji, a zmiana w jego funkcjonalności powinna mieć wpływ tylko na niego samego.
Dzięki tej zasadzie łatwiej jest utrzymać system w czystości, ponieważ każdy obiekt jest odpowiedzialny tylko za jedną rzecz, a zmiany w jego funkcjonalności są ograniczone do jego samego. To również ułatwia testowanie i utrzymanie kodu, ponieważ każdy obiekt jest mniej złożony i łatwiejszy do zrozumienia.
Przykładowa Klasa bez zastosowania zasady jednej odpowiedzialności:
public class Employee:
public class Employee { private String name; private String lastName; private String email; private double salary; public Employee(String name, String lastName, String email, double salary) { this.name = name; this.lastName = lastName; this.email = email; this.salary = salary; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getEmail() { return email; } public double getSalary() { return salary; } public void setSalary(double salary) { this.salary = salary; } public void setEmail(String email) { this.email = email; } public void textValidation(){ //kod } }
Klasa ta ma za zadanie przechowywać właściwości naszego utworzonego pracownika ale jednocześnie implementuje metodę walidująca tekst (może ona sprawdzać przykładowo czy tekst nie posiada znaków specjalnych). Wydaje się, że jest to dobre rozwiązanie natomiast taka metoda może być potrzebna w innym obszarze naszego programu a to by oznaczało, że kod musiałby zostać powtórzony. Żeby uniknąć takiej sytuacji wystarczy że wydzielimy metodę „textValidation” do osobnej klasy:
Przykładowa Klasa z zastosowaniem zasady jednej odpowiedzialności:
public class ValidateTool:
public class ValidateTool { public void textValidation(String text){ //kod } }
Teraz nasza klasa „Employee” faktycznie spełnia jedną odpowiedzialność:
public class Employee:
public class Employee { private String name; private String lastName; private String email; private double salary; public Employee(String name, String lastName, String email, double salary) { this.name = name; this.lastName = lastName; this.email = email; this.salary = salary; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getEmail() { return email; } public double getSalary() { return salary; } public void setSalary(double salary) { this.salary = salary; } public void setEmail(String email) { this.email = email; } }
Open/closed principle (Zasada otwarte-zamknięte) mówi, że klasy i moduły powinny być otwarte na rozszerzania, ale zamknięte na modyfikacje. Innymi słowy, powinny być projektowane tak, aby można było dodawać nowe funkcjonalności, nie modyfikując już istniejącego kodu.
Dzięki tej zasadzie kod jest bardziej elastyczny i łatwiejszy do rozszerzenia, a jednocześnie mniej podatny na błędy, ponieważ nie wymaga modyfikacji już istniejącego kodu. To również ułatwia utrzymanie kodu i testowanie, ponieważ istniejące funkcjonalności są mniej narażone na zmiany.
Przykład bez zastosowania zasady otwarte-zamknięte:
public enum PaymentType:
public enum PaymentType { BLIK, CASH, CREDITCARD; }
public class Website:
public class Website { public void payment(PaymentType paymentType) { switch(paymentType) { case BLIK: System.out.println("Płacę Blikiem..."); break; case CASH: System.out.println("Płacę gotówką..."); break; case CREDITCARD: System.out.println("Płacę kartą..."); } } }
Na pierwszy rzut oka wszystko wygląda w porządku, natomiast jeśli chcielibyśmy dodać kolejną metodę płatności np. Bitcoinem to jesteśmy zmuszeni zmodyfikować naszą już istniejącą metodę „payment” w klasie „Website” co nie pokrywa się z zasadą otwarte-zamknięte. Taka modyfikacja mogłaby wpłynąć na poprawne działanie naszej aplikacji. Rozwiązanie tego problemu jest wyjątkowe proste, wystarczy że zastosujemy tutaj polimorfizm. Utwórzmy interfejs „PaymentMethod” który będzie posiadał abstrakcyjną metodę „payment” oraz stwórzmy klasy dla naszych płatności które będą implementowały ten interfejs i nadpisywały odpowiednio jego metodę. Teraz wystarczy, że w naszej metodzie „payment” w klasie „Website” w argumencie przekażemy referencje do naszego interfejsu a w ciele wywołamy metodę „payment()”. Jeśli teraz chcielibyśmy dodać kolejną metodę płatności to wystarczy, że utwórzmy dla niej klasę która będzie implementowała interfejs „PaymentMethod”, dzięki takiemu rozwiązaniu nie będziemy ingerować w metodę z klasy „Website” ale będziemy mogli rozszerzać jej działanie.
Przykład z zastosowaniem zasady otwarte-zamknięte:
public interface PaymentMethod:
public interface PaymentMethod { void payment(); }
public class Blik:
public class Blik implements PaymentMethod{ @Override public void payment() { System.out.println("Płacę blikiem..."); } }
public class Cash:
public class Cash implements PaymentMethod{ @Override public void payment() { System.out.println("Płacę gotówką..."); } }
public class CreditCard:
public class CreditCard implements PaymentMethod{ @Override public void payment() { System.out.println("Płacę kartą..."); } }
public class Website:
public class Website { public void payment(PaymentMethod paymentMethod){ paymentMethod.payment(); } }
Liskov substitution principle (Zasada podstawienia Liskov) funkcje które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.
Dzięki tej zasadzie, programiści mogą łatwiej rozszerzać i dostosowywać kod, bez obawy o zmianę jego poprawności. Jest to szczególnie ważne w dużych i skomplikowanych systemach, gdzie utrzymanie i rozwijanie kodu jest często niezbędne.
Przykład bez zastosowania zasady podstawiania Liskov:
public class MediaPlayer:
public class MediaPlayer { public void playVideo(){ System.out.println("Wyswietlam obraz"); } public void playAudio(){ System.out.println("Odtwarzam dźwięk"); } }
public class Tv:
public class Tv extends MediaPlayer{ }
Co w przypadku gdybyśmy chcieli utworzyć dodatkowo klasę „Radio”? Jak wiadomo radio z reguły nie wyświetla obrazu jedynie odtwarza dźwięk. Aby temu zapobiec wystarczy, że trochę zmodyfikujemy strukturę naszej aplikacji.
public class MediaPlayer:
public class MediaPlayer { public void playAudio(){ System.out.println("Odtwarzam dźwięk"); } }
public class VideoMediaPlayer:
public class VideoMediaPlayer extends MediaPlayer{ public void playVideo(){ System.out.println("Wyswietlam obraz"); } }
public class Tv:
public class Tv extends VideoMediaPlayer{ }
public class Radio:
public class Radio extends MediaPlayer{ }
Wystarczy, że dodaliśmy nową klasę „VideoMediaPlayer” która dziedziczy po „MediaPlayer” i implementuje metodę „playVideo”. Dzięki tej operacji nie musimy wyrzucać wyjątku albo pozostawiać pustej metody co jest bardzo złym pomysłem. Jeśli klasy potomne będą się zachowywać inaczej niż klasa nadrzędna to oznacza, że złamaliśmy zasadę podstawiania Liskov.
Interface segregation principle (Zasada segregacji interfejsów) zgodnie z tą zasadą, nie powinniśmy być zmuszeni do implementowania elementów interfejsu, których nie używamy. Interfejs powinien być dzielony na mniejsze, bardziej szczegółowe i skoncentrowane na konkretnych funkcjach, aby umożliwić implementację tylko tego, co jest nam potrzebne. Dzięki temu kod jest bardziej elastyczny i łatwiejszy do utrzymania.
Zasada ta zachęca do tworzenia wielu prostych i skoncentrowanych interfejsów zamiast kilku dużych i skomplikowanych.
Przykład bez zastosowania zasady segregacji interfejsów:
public interface Vehicle:
public interface Vehicle { void drive(); void swim(); void fly(); }
public class Car:
public class Car implements Vehicle{ @Override public void drive() { System.out.println("Jadę"); } @Override public void swim() { //? } @Override public void fly() { //? } }
public class Boat:
public class Boat implements Vehicle{ @Override public void drive() { //? } @Override public void swim() { System.out.println("Płyne"); } @Override public void fly() { //?? } }
public class Plane:
public class Plane implements Vehicle{ @Override public void drive() { //?? } @Override public void swim() { //?? } @Override public void fly() { System.out.println("Lecę"); } }
Na pierwszy rzut oka widać, że takie rozwiązanie nie jest właściwe. Klasy posiadają niepotrzebne metody które trzeba obsłużyć. Rozwiązanie jest nader proste, wystarczy jeden rozbudowany interfejs podzielić na wiele mniejszych:
public interface DrivingVehicle:
public interface DrivingVehicle { void drive(); }
public interface FloatingVehicle:
public interface FloatingVehicle { void swim(); }
public interface FlyingVehicle:
public interface FlyingVehicle { void fly(); }
public class Car:
public class Car implements DrivingVehicle{ @Override public void drive() { System.out.println("Jadę"); } }
public class Boat:
public class Boat implements FloatingVehicle{ @Override public void swim() { System.out.println("Płyne"); } }
public class Plane:
public class Plane implements FlyingVehicle{ @Override public void fly() { System.out.println("Lecę"); } }
Jeśli chcielibyśmy utworzyć pojazd który mógłby np. latać i jeździć to wystarczy, że będzie on implementował po dwóch interfejsach „DrivingVehicle” oraz „FlyingVehicle”.
Dependency inversion principle (Zasada odwrócenia zależności) zgodnie z tą zasadą, moduły wysokiego poziomu nie powinny być zależne od modułów niskiego poziomu, ale powinny być zależne od abstrakcji. Abstrakcje powinny być implementowane przez moduły niskiego poziomu. Dzięki temu kod jest bardziej elastyczny i łatwiejszy do utrzymania, ponieważ zmiany w implementacji modułów niskiego poziomu nie wpływają na moduły wysokiego poziomu.
Zasada ta zachęca do tworzenia architektury opartej na abstrakcjach i uniezależnieniu się od szczegółów implementacji.
Przykład bez użycia zasady odwrócenia zależności:
public class Post:
public class Post { public String title; public String content; public Post(String title, String content) { this.title = title; this.content = content; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } }
public class PostClient:
public class PostClient { public void addPost(Post post) { //code //add to database //code } }
public class PostService:
public class PostService { PostManager postManager = new PostManager(); public void addPost(Post post){ postManager.addPost(post); } }
Przykład z użyciem zasady odwrócenia zależności:
public class Post:
public class Post { public String title; public String content; public Post(String title, String content) { this.title = title; this.content = content; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } }
public interface PostRepository:
public interface PostRepository { void addPost(Post post); }
public class PostClient:
public class PostClient implements PostRepository{ @Override public void addPost(Post post) { //code //add to database //code } }
public class PostService:
public class PostService { private final PostRepository postRepository; public PostService(PostRepository postRepository) { this.postRepository = postRepository; } public void addPost(Post post){ postRepository.addPost(post); } }
W zrefaktoryzowanym kodzie warstwa abstrakcji jest dodawana za pośrednictwem interfejsu, z wykorzystaniem wstrzykiwania zależności. Takie rozwiązanie jest znacznie elastyczniejsze.