
Autor: 17.11.2023
Reguły SOLID w programowaniu
W programowaniu czasami możesz spotkać się z popularnym akronimem SOLID. W tym artykule wyjaśnimy jego znaczenie oraz rolę, jaką pełni w programowaniu obiektowym. Użyjemy przykładów w języku Java, jednak wiedza zawarta w artykule powinna być uniwersalna i znajdzie zastosowanie w większości popularnych języków.
Rozwińmy skrót SOLID
SOLID są to ogólne zasady dobrego programowania obiektowego zaproponowane przez Wujka Boba (Robert C. Martin). Mamy do dyspozycji 5 zasad. Każda zasada to jeden znak z akronimu SOLID.
- S czyli Single responsibility principle | Zasada jednej odpowiedzialności (SRP)
- O czyli Open / closed principle | Zasada otwarte-zamknięte (OCP)
- L czyli Liskov substitution principle | Zasada podstawienia Liskov (LSP)
- I czyli Interface segregation principle | Zasada segregacji interfejsów (ISP)
- D czyli Dependency inversion principle | Zasada odwracania zależności (DIP)
Super! Teraz po kolei przyjrzymy się tym pięciu regułom.
Single responsibility principle
Pierwsza zasada SOLID zabrania tworzyć klas “Bogów”. Klasa “Bóg” to taka klasa, która robi dosłownie wszystko. Nie możemy dopuścić w swoim kodzie do sytuacji, aby klasa miała zbyt wiele odpowiedzialności.
Przygotowaliśmy dla ciebie fragment kodu w języku Java. Spójrz na klasę Point:
class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void draw() {
System.out.printf("x = %d, y = %d \n", this.x, this.y);
}
}
Klasa Point przechowuje informację o punkcie oraz jest odpowiedzialna za wyrysowanie punktu. Zwróć uwagę, że klasa już na tym etapie posiada zbyt wiele odpowiedzialności. Postaramy się rozbić naszą klasę na dwie, które będą elastyczne i samodzielne.
Rozdzielamy klasę
class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
class PointDrawer {
public void draw(Point point) {
System.out.printf("x = %d, y = %d \n", point.getX(), point.getY());
}
}
Postąpiliśmy zgodnie z pierwszą zasadą SOLID i rozdzieliliśmy klasę Point na Point oraz PointDrawer. Klasa Point odpowiada na punkty a z kolei klasa PointDrawer odpowiada za wyświetlanie punktów.
Każda klasa jest odpowiedzialna za dokładnie jedną rzecz, więc unikniemy tworzenia klasy typu “Bóg”.
Open / closed principle
Druga zasada SOLID mówi o tym, że nasz kod powinien być otwarty na rozszerzenia, a zamknięty na modyfikacje. Aby upewnić się czy twój kod jest zgodny z drugą zasadą SOLID postaraj się odpowiedzieć na pytanie: czy jesteśmy w stanie dodać nową funkcjonalność bez zmiany już istniejącego kodu?
Popatrz na przykład:
class Triangle {
public void calculateArea() {
System.out.println("triangle - calculate area");
}
}
class Square {
public void calculateArea() {
System.out.println("square - calculate area");
}
}
class AreaCalculator {
public static void calculate(Object shape) {
if(shape instanceof Triangle) {
((Triangle) shape).calculateArea();
} else if(shape instanceof Square) {
((Square) shape).calculateArea();
}
}
}
Zaprezentowany kod nie jest zgodny z drugą zasadą SOLID. Dlaczego? Dlatego, że implementacja kolejnej klasy (np. Rectangle) powoduje wymuszone zmiany w klasie AreaCalculator. Funkcjonalność należy dodać w taki sposób, aby nie zmieniać istniejących klas. Aby unikną takich modyfikacji wystarczy w języku Java wykorzystać klasę abstrakcyjną.
Spójrz na poprawną implementację.
abstract class Shape {
abstract public void calculateArea();
}
class Triangle extends Shape {
public void calculateArea() {
System.out.println("triangle - calculate area");
}
}
class Square extends Shape {
public void calculateArea() {
System.out.println("square - calculate area");
}
}
class AreaCalculator {
public static void calculate(Shape shape) {
shape.calculateArea();
}
}
Dzięki zastosowaniu klasy abstrakcyjnej Shape nasz kod stał się uniwersalny. Aby uniknąć problemu z modyfikacją klasy AreaCalculator wystarczy dodać kolejną klasę (np. Rectangle), która odziedziczy klasę abstrakcyjną Shape.
Mechanizm dziedziczenia to naprawdę potężna broń. Stosując tą technikę możesz tworzyć bardzo uniwersalny kod zgodny z 2 zasadą SOLID czyli OCP.
Liskov substitution principle
Pani Barbara Liskov jest jedną z doświadczonych kobiet z branży IT pracujących na MIT. Od jej nazwiska została nazwana trzecia zasada SOLID czyli LSP. Jest to jedna z najbardziej pokręconych zasad: 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.
O co w tym wszystkim chodzi?
Tłumacząc na język ludzki: W miejscu klasy bazowej możemy użyć dowolnej klasy pochodnej. Należy pamiętać, że w mechanizmie dziedziczenia nie powinno dojść do sytuacji, że klasy pochodne nadpisują metody znajdujące się w klasie bazowej - powinny je ewentualnie rozszerzać.
Spójrz na poniższy kod:
class CoffeeMachine {
public void makeCoffee() {
this.prepareCoffee();
}
public void prepareCoffee() {
System.out.println("Prepare coffee");
}
}
class SugarCoffeeMachine extends CoffeeMachine {
public void makeCoffee() {
System.out.println("Add sugar");
}
}
Powyższy kod jest oczywiście niezgodny z trzecią zasadą SOLID. Klasa SugarCoffeeMachine dziedziczy po klasie CoffeeMachine. Klasa SugarCoffeMachine dodaje nową implementację dla metody makeCoffee(). Jest to niezgodne z zasadą Liskov. Funkcjonalność metody powinna być rozszerzeniem metody znajdującej się w klasie nadrzędnej. Klasa SugarCoffeeMachine ma za zadanie uzupełnić cukier do naszej kawy. Na ten moment działa to tak, że nie uwzględniamy kawy w klasie SugarCoffeeMachine. Należy to uwzględnić, aby trzecia zasada SOLID nie została złamana.
Poprawna implementacja powinna wyglądać następująco.
class CoffeeMachine {
public void makeCoffee() {
this.prepareCoffee();
}
public void prepareCoffee() {
System.out.println("Prepare coffee");
}
}
class SugarCoffeeMachine extends CoffeeMachine {
public void makeCoffee() {
super.makeCoffee();
System.out.println("Add sugar");
}
}
Zwróć uwagę, że klasa SugarCoffeeMachine zawiera metodę makeCoffee(), która jest uzupełnieniem identycznej metody w nadrzędnej klasie. Kawa potrzebuje cukru, ale klasa, która dziedziczy powinna również umożliwiać przygotowanie samej kawy. Co nam po samym cukrze skoro nie przygotujemy kawy?
Interface segregation principle
Do tego prawa można przypisać następujące słowa: “Co jest do wszystkiego, to jest do niczego”. Staraj się nie tworzyć bardzo rozbudowanych interfejsów. Lepiej jest stosować mniejsze interfejsy, które będą bardziej elastyczne.
interface Printer {
void toPdf();
void toCsv();
void toXls();
}
class GeneralPrinter implements Printer {
@Override
public void toPdf() {
System.out.println("Print pdf");
}
@Override
public void toCsv() {
}
@Override
public void toXls() {
}
}
Powyższy przykład łamie czwartą zasadę SOLID. Mamy do dyspozycji rozbudowany interfejs, który jest problematyczny. Implementując taki interfejs zmuszamy się do obsługi wielu typów wydruku: PDF, CSV, XLS. Chcielibyśmy być elastyczni, więc spójrz w jaki sposób można zaimplementować taki interfejs zgodnie z czwartą zasadą SOLID, czyli ISP.
interface PdfPrinter {
void toPdf();
}
interface CsvPrinter {
void toCsv();
}
interface XlsPrinter {
void toXls();
}
class GeneralPrinter implements PdfPrinter {
@Override
public void toPdf() {
System.out.println("Print pdf");
}
}
Rozbiliśmy obszerny interfejs na kilka mniejszych. Dzięki takiemu zachowaniu nie musimy wykorzystywać ogromnego interfejsu. Korzystamy z minimum implementacji, a to jest bardzo pożądane.
Dependency inversion principle
Ostatnią zasadą akronimu SOLID jest zasada odwracania zależności. Polega ona na tym, aby nie uzależniać modułów wysokiego poziomu od modułów niskiego poziomu. W tej zasadzie powinniśmy się uniezależnić od konkretnej implementacji. O co w tym wszystkim chodzi? Spójrzmy na praktyczny przykład.
class Runner {
public void training() {
}
}
class Coach {
private Runner runner;
public Coach(Runner runner) {
this.runner = runner;
}
public void manageSportsman() {
this.runner.training();
}
}
W powyższej implementacji trener zajmuje się trenowaniem biegacza. Zadaniem trenera jest zarządzanie sportowcem i motywowanie go do treningu. Wybaczcie, ale nasz trener jest troszkę ograniczony, potrafi trenować tylko biegaczy. Co się wydarzy, gdy trener będzie posiadał uprawnienia do trenowania w dziedzinach: Skok w dal lub pływanie? Obsługa kolejnych zawodników przysporzy nam wiele problemów. Klasa Coach będzie musiała być zmodyfikowana i dodatkowo uzależniamy się od pozostałych klas.
Aby się uniezależnić od specyficznej implementacji wystarczy zastosować abstrakcję. W takim razie zmienimy naszą implementację.
abstract class Sportsman {
abstract void training();
}
class Runner extends Sportsman {
public void training() {
}
}
class Coach {
private Sportsman sportsman;
public Coach(Sportsman sportsman) {
this.sportsman = sportsman;
}
public void manageSportsman() {
this.sportsman.training();
}
}
Dodaliśmy klasę sportowca, która jest abstrakcyjna. Dzięki takiej klasy uniezależniamy się od typu sportowca. Nasz trener jest w stanie trenować każdego sportowca zgodnie z własnymi uprawnieniami. Czy to nie jest piękne? Klasa Coach została zaimplementowana zgodnie z ostatnią zasadą SOLID (DIP).
Podsumowanie
Mamy nadzieję, że po lekturze artykułu znasz już znaczenie akronimu SOLID oraz pięciu reguł, które się na niego składają.