SOLID

CZYM SĄ ZASADY SOLID?

Zasady SOLID to pięć podstawowych zasad podpowiadających jak pisać dobry kod zorientowany obiektowo. Zaproponował je słynny Amerykański programista
Robert Martin. Jest on także jednym z twórców manifestu zwinnego programowania Agile.

“S” – Single Responsibility Principle

Zasada pojedynczej odpowiedzialności – Z definicji jest to po prostu zasada pojedynczej odpowiedzialności: Każdy moduł, klasa lub funkcja w programie komputerowym powinna odpowiadać za pojedynczą część funkcjonalności tego programu i powinna zawierać tę część. Wszystkie usługi tego modułu, klasy lub funkcji powinny być ściśle powiązane z tą odpowiedzialnością. Przeanalizujmy poniższy kod który nie przestrzega tej zasady:

class Osoba
{
public string Imie { get; set; }
public string Nazwisko { get; set; }
public string Miejscowosc { get; set; }
public string Ulica { get; set; }
public int NumerDomu { get; set; }

public Osoba (string imie, string nazwisko, string miejscowosc)
{
Imie = imie;
Nazwisko = nazwisko;
Miejscowosc = miejscowosc;
}
}

Powyższa klasa zawiera w sobie konstruktor gdzie do utworzenia obiektu klasy Osoba musimy podać również miejscowość a nie powinno leżeć to w obowiązku typu Osoba. Czy są jeszcze błędy ? – tak. Klasa Osoba nie powinna zawierać atrybutów, które nie są z nią powiązane jak miejscowość, ulica czy numer domu. W przyszłości ktoś będzie musiał tę klasę refaktoryzować lub powielać kod chcąc przechować sam adres zamieszkania. Będzie lepiej wyodrębnić tutaj dwie klasy:

class Adres
{
public string Miejscowosc { get; set; }
public string Ulica { get; set; }
public int NumerDomu { get; set; }
}
class Osoba
{
public string Imie { get; set; }
public string Nazwisko { get; set; }
public Adres adresOsoby { get; set; }

public Osoba (string imie, string nazwisko)
{
Imie = imie;
Nazwisko = nazwisko;
}
}

Stosując się do zasady pojedynczej odpowiedzialności wyodrębniamy pola adresu do osobnej klasy, dzięki czemu, każda z klas rozwiązuje teraz tylko jeden problem, a zmiana w jednej z nich nie powoduje konieczności wprowadzania zmian w drugiej klasie. Nie miej wrażenia, że zbytnie rozdrabnianie kodu jest złe. Nawet w tak trywialnym przypadku jak klasa Osoba, lepiej jest wyodrębnić pola związane z adresem do osobnej klasy.

“O” – Open/Close Principle

Zasada Otwarty / Zamknięty – Podmioty programowania (klasy, moduły, funkcje, itd.) powinne być otwarte na rozszerzenie ale zamknię te na modyfikacje. Zasada ta powinna być zawsze rozwijana do postaci “otwarty na rozbudowę, zamknięty na modyfikacje”. Jest to praktycznie jej cała i kompletna definicja. Jest to bardzo ważna zasada, szczególnie w dużych projektach, nad którymi pracuje wielu programistów. Każdą klasę powinniśmy pisać tak, aby możliwe było dodawanie nowych funkcjonalności, bez konieczności jej modyfikacji. Modyfikacja jest surowo zabroniona, ponieważ zmiana deklaracji jakiejkolwiek metody może spowodować awarię systemu w innym miejscu. Zasada ta jest szczególnie ważna dla twórców wtyczek i bibliotek programistycznych. Istnieje pewna zależność, im bardziej trzymamy się zasady pojedynczej odpowiedzialności, tym bardziej musimy dbać o zasadę otwarty na rozbudowę / zamknięty na modyfikacje. Rozważmy przykład:

public class Pozdrawiacz {

String rodzajPozdrowienia;
public String pozdrow() {

if (this.rodzajPozdrowienia == "formalne") {
return "Dzien dobry";
}
else if (this.rodzajPozdrowienia == "kolezenskie") {
return "Czesc bracie";
}
else if (this.rodzajPozdrowienia == "romantyczne") {
return "Czesc kochanie";
}
else {
return "Czesc";
}
}
public void ustawRodzajPozdrowienia(String rodzajPozdrowienia) {
this.rodzajPozdrowienia = rodzajPozdrowienia;
}
}

public class WyslijPozdrowienie {

public static void main(String[] args){

Pozdrawiacz pozdrawiacz = new Pozdrawiacz();
pozdrawiacz.ustawRodzajPozdrowienia("formalne");
System.out.println(pozdrawiacz.pozdrow());
}
}

W powyższym przykładzie dodanie nowego rodzaju pozdrowienia do klasy Pozdrawiacz wiąże się z koniecznością modyfikacji tej klasy. Jest to ewidentne złamanie zasady otwarty/zamknięty, ponieważ klasa nie jest otwarta na rozbudowę. Musimy dokonać modyfikacji tej klasy za każdym razem dodając nowy warunek.
Rozważmy teraz poniższy kod, w szczególności klasę Pozdrawiacz:

public interface Pozdrowienie {
String pozdrow();
}
public class Pozdrawiacz {

private Pozdrowienie pozdrowienie;
public Pozdrawiacz(Pozdrowienie pozdrowienie) {
this.pozdrowienie = pozdrowienie;
}
public String pozdrowienie() {
return this.pozdrowienie.pozdrow();
}
}
public class PozdrowienieFormalne implements Pozdrowienie {

public String pozdrow() {
return "Dzien dobry";
}
}
public class PozdrowienieKolezenskie implements Pozdrowienie {

public String pozdrow() {

return "Czesc bracie";
}
}
public class PozdrowienieRomantyczne implements Pozdrowienie {

public String pozdrow() {
return "Czesc kochanie!";
}
}
public class WyslijPozdrowienie {

public static void main(String[] args){

Pozdrawiacz pozdrawiacz = new Pozdrawiacz(new PozdrowienieFormalne());
System.out.println(pozdrawiacz.pozdrowienie());
}
}

Klasa Pozdrawiacz w tym przypadku bardzo nam sie uprościła. Zauważ, że teraz, aby dodać kolejny rodzaj pozdrowienia, nie musimy wcale nic zmienić w tej klasie. Możemy po prostu dodać kolejną klasę reprezentującą nowy rodzaj pozdrowienia. W ten sposób rozszerzymy naszą aplikacje o kolejną funkcjonalność a nie modyfikujemy już istniejącej.

“L” – Liskov Substitution Principle

Zasada Substytucji Liskov – Podklasy powinny spełniać oczekiwania klientów uzyskujących dostęp do obiektów podklas poprzez referencje typu super. nie tylko pod względem bezpieczeństwa składniowego (takiego jak brak „błędów-nie-znalezionych-metod”), ale także pod względem poprawności zachowania. Jeżeli posiadamy klasę bazową Zwierze, po której dziedziczą dwie klasy: Pies i Kot, to jakakolwiek funkcja przyjmująca w parametrze typ Zwierze, powinna obsłużyć także instancję Pies oraz Kot. Jeżeli potrzebne są sprawdzenia dodatkowych warunków, lub co gorsza rzucenie wyjątku w zależności od typu klasy, to jest to złamanie zasady Liskov. Popatrzmy na poniższy przykład:

abstract class Zwierze{
public abstract void ustawImie ();
public abstract void biegnij ();
}
public class Pies extends Zwierze {
@Override
public void ustawImie() { System.out.println (“Piesek Reksio”); }
@Override
public void biegnij() { System.out.println (“Reksio biegnie”); }
}
public class Kot extends Zwierze {
@Override
public void ustawImie() { System.out.println (“Kot Mruczek”); }
@Override
public void biegnij() { System.out.println (“Mruczek biegnie”); }
}
public class Ryba extends Zwierze {
@Override
public void ustawImie() { System.out.println (“Zlota Ryba”); }
@Override
public void biegnij() {
throw new ExecutionControl.NotImplementedException (“Ryba nie biega”); }
}
public class Zwierzaki {

public static void main (String[] args){

List<Zwierze> zwierzeta = new ArrayList<Zwierze>();
zwierzeta.add(new Pies());
zwierzeta.add(new Kot());
zwierzeta.add(new Ryba());

zwierzeta.forEach(Zwierze::ustawImie); // dziala dla wszystkich klas z listy
zwierzeta.forEach(Zwierze::biegnij); // nie zadziala dla ryby
}
}

W powyższym przykładzie utworzyliśmy abstrakcję Zwierze jednak występuje tutaj zjawisko źle przemyślanego mechanizmu dziedziczenia. Ryba jest zwierzęciem, ale została obarczona implementacją metody biegnij() znajdującej się w klasie bazowej. Ryba jak to ryba, biegać nie potrafi i jest to złamanie zasady podstawienia Liskov. Dziedziczenie należy zaplanować inaczej, tak aby każda klasa pochodna mogła wykorzystać funkcje klasy bazowej. Zapiszmy zatem klasy według poniższego schematu:

abstract class Zwierze {
abstract void ustawImie();
}
interface ZwierzetaChodzace {
void biegnij();
}
interface ZwierzetaPlywajace {
void plyn();
}
public class Pies extends Zwierze implements ZwierzetaChodzace {
@Override
public void ustawImie() {
System.out.println (“Piesek Reksio”);
}
@Override
public void biegnij() {
System.out.println (“Reksio biegnie”);
}
}
public class Kot extends Zwierze implements ZwierzetaChodzace {
@Override
public void ustawImie() {
System.out.println (“Kot Mruczek”);
}
@Override
public void biegnij() {
System.out.println (“Mruczek biegnie”);
}
}
public class Ryba extends Zwierze implements ZwierzetaPlywajace {
@Override
public void ustawImie() {
System.out.println (“Zlota Ryba”);
}
@Override
public void plyn() {
System.out.println (“Zlota Ryba plynie”);
}
}
public class Zwierzaki {
public static void main (String[] args){

List<Zwierze> zwierzeta = new ArrayList<Zwierze>();
zwierzeta.add(new Pies());
zwierzeta.add(new Kot());
zwierzeta.add(new Ryba());
zwierzeta.forEach(Zwierze::ustawImie); // dziala dla wszystkich klas z listy typu Zwierzeta

List<ZwierzetaChodzace> zwierzetaChodzace = new ArrayList<ZwierzetaChodzace>();
zwierzetaChodzace.add (new Pies());
zwierzetaChodzace.add (new Kot());
zwierzetaChodzace.forEach(ZwierzetaChodzace::biegnij); // dziala dla wszystkihc klas z listy typu ZwierzetaChodzace
}
}

W tej sytuacji wyodrębniliśmy cechy wspólne do osobnych interfejsow, które mogą być wykorzystane w razie potrzeby i unikamy sytuacji gdzie jakaś klasa podrzędna dziedziczy metodę która nie może być przez nią zrealizowana.

Warunki wstępne w substytucji Liskov– (ang. PRECONDITIONS)
Warunki wstępne każdej podklasy nie mogą być trudniejsze (bardziej wymagające) od warunków wstępnych klasy nadrzędnej. Mogą natomiast być łatwiejsze
(mniej wymagające) od warunków wstępnych klasy nadrzędnej. Jeżeli posiadamy klasę bazową Rodzic, po której dziedziczy klasa Dziecko, to jakakolwiek funkcja przyjmująca w parametrze typ Rodzic, powinna obsłużyć także instancję Dziecko. Jeżeli potrzebne są sprawdzenia dodatkowych warunków, lub co gorsza rzucenie wyjątku w zależności od typu klasy,to jest to złamanie zasady Liskov. Popatrzmy na poniższy przykład:

public class Rodzic {

public double obliczCeneKsiazki (String tytul, double cenaKsiazki) throws IOexception {

if(tytul.isEmpty ()) {
throw new IOException ();
}
double cenaKsiazkiZDostawa = cenaKsiazki + 5;
return cenaKsiazkiZDostawa;
}
}

public class Test{

public static void main (String[] args) {

double cenaSprzedazy = new Rodzic ().obliczCeneKsiazki (“Ksiazka”, 20);
System.out.println(cenaSprzedazy);
}
}

Jeżeli utworzymy klasę Dziecko która dziedziczy po klasie Rodzic, oraz zmienimy warunek wstępny (Precondition) – utrudnimy jego realizację, bądź innymi słowy – zaostrzymy ten warunek, to zauważymy że dla obiektów klasy Dziecko przy nazwie książki zaczynającej się na literę K zostanie wyrzucony wyjątek natomiast dla przypadku obiektu klasy Rodzic, tego wyjątku nie będzie. Uniemożliwia to użycie klasy Dziecko dla tej samej metody typu Rodzic:

public class Dziecko extends Rodzic {

public double obliczCeneKsiazki (String tytul, double cenaKsiazki) throws IOexception {

if (tytul.isEmpty() || tytul.startsWith(“K”)) {
throw new IOException();
}
double cenaKsiazkiZDostawa = cenaKsiazki + 5;
return cenaKsiazkiZDostawa;
}
}
public class Test{
public static void main (String[] args) {

double cenaSprzedazy = new Rodzic().obliczCeneKsiazki (“Ksiazka”, 20); //bez wyjątku
double cenaSprzedazy = new Dziecko().obliczCeneKsiazki (“Ksiazka”, 20); //jest wyjątek
System.out.println(cenaSprzedazy);
}
}

Sytuacja taka nie jest pożądana gdyż dla tych samych metod powinniśmy mieć możliwość zamiennego stosowania obiektów obu tych klas. Jeśli natomiast w klasie Dziecko warunek wstępny będzie ułatwiony w realizacji, tzn. zmniejszymy jego wymagania, to jest to zachowanie dozwolone jak poniżej:

public class Rodzic {

public double obliczCeneKsiazki (String tytul, double cenaKsiazki) throws IOexception {

if ( tytul.isEmpty() || tytul.startsWith (“K”)) ) {
throw new IOException();
}
double cenaKsiazkiZDostawa = cenaKsiazki + 5;
return cenaKsiazkiZDostawa;
}
}

public class Dziecko extends Rodzic {
public double obliczCeneKsiazki (String tytul, double cenaKsiazki) throws IOexception {

if (tytul.isEmpty()){
throw new IOException();
}
double cenaKsiazkiZDostawa = cenaKsiazki + 5;
return cenaKsiazkiZDostawa;
}
}

 

Warunki końcowe w substytucji Liskov – (ang. POSTCONDITIONS)
Sytuacja jest odwrotna jeśli chodzi o warunki końcowe w podklasach, mianowicie:
Warunki końcowe każdej podklasy nie mogą być łatwiejsze (mniej wymagające) od warunków końcowych klasy nadrzędnej. Mogą natomiast być trudniejsze
(bardziej wymagające) od warunków końcowych klasy nadrzędnej.

W dobrze zaplanowanym mechanizmie dziedziczenia, klasy pochodne nie powinny nadpisywać metod klas bazowych. Mogą je ewentualnie rozszerzać,
wywołując metodę z klasy bazowej (np. poprzez słowo kluczowe będącym wskaźnikiem na klasę bazową).

“I” – Interface Segregation Principle

Zasada segregacji interfejsów – mówi aby nie tworzyć interfejsów z metodami, których nie wszystkie klasy korzystające z interfejsu będą używać. Interfejsy powinny być konkretne i jak najmniejsze. Do tworzenia typu bazowego przeważnie lepiej użyć klasy abstrakcyjnej. Może ona opisywać konkretny typ, zawierać odpowiednie atrybuty oraz metody, którymi następnie obarcza wszystkie klasy pochodne. Klasa bazowa definiuje model biznesowy, który potrzebujemy. Interfejs natomiast jest bezstanowy, nie powinien definiować modelu biznesowego. Interfejs powinien zapewniać kontrakt, informujący programistę o zachowaniach danego typu. Popatrzmy na przykładowy kod:

public interface Zawodnik {

void rozgrzewkaPrzedStartem();
void plywanie();
void skokWZwyz();
void skokOTyczce();
}

public class MichalKaras implements Zawodnik {

@Override
public void rozgrzewkaPrzedStartem() {
System.out.println("Michal rozpoczal rozgrzewke przed startem");
}
@Override
public void plywanie() {
System.out.println("Michal rozpoczal konkurencje plywania");
}
@Override
public void skokWZwyz() {
}
@Override
public void skokOTyczce() {
}
}

Jak widzimy Michał jest zawodnikiem pływania i nie będzie potrzebował metod do obługi skoku o tyczce czy skoku w zwyż. Dlatego lepiej będzie podzielić główny interfejs Zawodnik i wyodrębnić poszczególne konkurencje do osobnych interfejsów. Główny problem w powyższym przykładzie polega na tym, że używamy ogólnego “grubego” interfejsu, zamiast mniejszych z odpowiedzialnością ograniczoną do konkretnego zadania. Z uwzględnieniem zasady segregacji interfejsów możemy zapisać jak poniżej:

public interface Zawodnik {

void rozgrzewkaPrzedStartem();
}
public interface KonkurencjaPlywanie {
void plywanie();
}
public interface SkokOTyczce{
void skokOTyczce();
}
public interface SkokWZwyz{
void skokWZwyz();
}
public class MichalKaras implements Zawodnik, KonkurencjaPlywanie {

@Override
public void rozgrzewkaPrzedStartem() {
System.out.println("Michal rozpoczal rozgrzewke przed startem");
}
@Override
public void plywanie() {
System.out.println("Michal rozpoczal konkurencje plywania");
}
}

Dzięki podzieleniu interfejsu na mniejsze, utrzymujemy porządek w interfejsie polimorficznym typu. Dzięki temu typy pochodne nie są związane kontraktami, które nie są im potrzebne. Łamanie zasady segregacji interfejsów prowadzi do sytuacji, kiedy iterując po liście typów bazowych ze wspólnym interfejsem polimorficznym rzucony zostaje wyjątek, ponieważ któraś z klas nie implementuje metody rozbudowanego interfejsu.

“D” – Dependency Inversion Principle

Zasada Odwrócenia Zależności – moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji. Wszystkie zależności powinny w jak największym stopniu zależeć od abstrakcji a nie od konkretnego typu. Popatrzmy na poniższy przykład:

public class ProgramistaBackEnd () {
public void programuj () {
System.out.println(“Programista PHP w akcji…”);
}
}

public class ProgramistaFrontEnd () {
public void programuj () {
System.out.println(“Programista JavaScript w akcji…”);
}
}

public class Projekt () {

private ProgramistaBackEnd programistaBackEnd;
private ProgramistaFrontEnd programistaFrontEnd;

public Projekt () {
this.programistaBackEnd = new ProgramistaBackEnd ();
this.programistaFrontEnd = new ProgramistaFrontEnd ();

}
public void rozpocznijProgramowanie (){
programistaFrontEnd.programuj ();
programistaBackEnd.programuj ();
}
}

Powyższy kod nie jest optymalny, ponieważ klasa Projekt jest związana bezpośrednio z obiektami programistow. O wiele lepiej wprowadzić dodatkowy typ abstrakcyjny, aby bazować na czymś bardziej ogólnym. Dzięki takiemu zabiegowi zmniejszamy powiązanie pomiędzy klasami (ang. tight coupling) co jest głównym celem stosowania tej zasady. Moglibyśmy zapisać:

public interface Programista {
void programuj ();
}

public class ProgramistaBackEnd implements Programista {
@Overrite
void programuj () {
programujKodWPHP ();
}
private void programujKodWPHP() {
System.out.println (“Programista PHP w akcji…”);
}
}

public class ProgramistaFrontEnd implements Programista {
@Overrite
void programuj() {
programujKodWJavaScript();
}
private void programujKodWJavaScript() {
System.out.println (“Programista JavaScript w akcji…”);
}
}

public class Projekt {

private List<Programista> programisci;

public Projekt (List<Programista> programisci) {
this.programisci = programisci;
}

public void rozpocznijProgramowanie (){

programisci.forEach (
programista -> programista.programuj ()
);
}
}

Najważniejszą rzeczą jaką można zapamiętać jest to, że zasada odwrócenia zależności pomaga nam w większym stopniu pracować na abstrakcji.

Podsumowanie

Pisząc kod osobiście generalnie wszystko się rozumie, nawet gdyby kod był złej jakości. Każdy, po prostu, rozumie to co sam napisał. Prawdziwy problem pojawia się, gdy obca osoba jest zmuszona przesiąść się do nieswojego projektu, i pisać w kodzie, którego nigdy wcześniej niewidziała. Wtedy bardzo pomaga to, że:

  • Zamiast jednej klasy zawierającej 2000 linii kodu jest 20 małych klas, z której każda jest odpowiedzialna za jedną, małą, konkretną rzecz (zasada pojedynczej odpowiedzialności)
  • Autor klasy przewidział poszerzenie funkcjonalności jego klasy bez konieczności przerabiania jej kodu np. poprzez mechanizm dziedziczenia i polimorfizmu (zasada otwarty/zamknięty)
  • Korzystając z klas pochodnych mamy pewność, że implementują one wszystkie metody klas bazowych i nie musimy tego sprawdzać (zasada liskov substitution)
  • System zbudowany jest z małych interfejsów (często tylko z jedną metodą), dzięki czemu jesteśmy w stanie zaimplementować w nowo dopisanej przez nas klasie 2 interfejsy których potrzebujemy i ani jednego więcej, bez niepotrzebnych metod (interface segregation)
  • Poprzedni programista używał typów abstrakcyjnych tam gdzie to tylko możliwe (np. w parametrach funkcji) więc możemy przekazać do funkcji każdą kolekcję implementującą interfejs Programista a nie tylko listę. A interfejsy znajdujące się w projekcie mają sens i mogą zostać użyte, a nie są tylko sztuką dla sztuki, bo po co nam interfejsy, jeżeli wszystkie funkcje przyjmują jako parametry typy pochodne (dependency inversion)

Mam nadzieje, że po przeczytaniu tego artykułu zrozumienie zasad SOLID będzie dla Ciebie łatwiejsze. Jest to dobry krok w drodze do pisania czystszego kodu.

Leave a Comment

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *