Zasady SOLID w programowaniu obiektowym

Eryk Schubert | Rozwój oprogramowania | 17.05.2023

Z pewnością każdy programista choć raz zastanawiał się nad tym, jak sprawić, aby jego kod był bardziej czytelny, a tworzony system – skalowalny i elastyczny. Z pomocą przychodzą zasady SOLID, które stanowią świetną podstawę w projektowaniu i implementacji systemów informatycznych. Dzięki nim nie musimy wymyślać wszystkiego od nowa, ponieważ zasady te mają sprawdzone narzędzia, które pozwalają nam na tworzenie optymalnego kodu.

Po pierwsze, czysty kod

Pisanie czystego kodu (ang. clean code) powinno być kluczowym elementem w pracy każdego programisty. To właśnie czysty kod pozwala nam utrzymywać nasze systemy w dobrym stanie, zapewniając łatwość w utrzymaniu i rozwoju oraz większą elastyczność. Dlaczego jeszcze dbanie o czysty kod jest tak ważne? Według raportu „The state of software code”, 40% developerów przyznaje, że najbardziej frustruje ich wykrywanie i naprawiane błędów w kodzie, co nieraz może przyczyniać się do przepracowania, a nawet wypalenia zawodowego. Nie wspominając o opóźnieniach w rozwoju oprogramowania.

Czysty kod to taki, który jest czytelny, łatwy do zrozumienia, modyfikacji i testowania. Łatwość w zrozumieniu kodu jest szczególnie ważna w przypadku większych projektów, gdzie nad tymi samymi plikami pracuje zwykle wiele osób. Zwłaszcza w tego typu projektach powinniśmy dążyć do tego, aby pisany przez nas kod był przejrzysty i klarowny, nawet dla osób, które nie pracowały nad danym projektem od samego początku. Tu z pomocą przychodzą zasady znane jako SOLID.

Czym jest SOLID w programowaniu?

SOLID to zestaw pięciu zasad, które zostały opracowane przez Roberta C. Martina, znanego jako „Uncle Bob”, w latach 90. XX wieku. Dzięki tym zasadom otrzymaliśmy narzędzia, które nie tylko pomagają w projektowaniu i implementacji systemów, ale także umożliwiają ich utrzymywanie, rozwój i skalowanie. Korzystanie z tych zasad często pozwala znacząco skrócić czas pracy nad projektem oraz zmniejszyć ilość błędów, co przekłada się na zwiększenie efektywności i jakości samego kodu.

Najważniejsze zasady SOLID

W tym artykule o SOLID omówię zasady, na których opiera się filozofia pisania czystego kodu. SOLID to skrótowiec utworzony od pięciu zasad projektowania obiektowego, których celem jest ułatwienie modyfikowania, skalowania i utrzymywania kodu. 

Single Responsibility Principle 

Pierwszą z zasad SOLID jest zasada pojedynczej odpowiedzialności (ang. Single Responsibility Principle – SPR). Zgodnie z nią klasa powinna mieć tylko jedną odpowiedzialność, czyli powinna być odpowiedzialna tylko za jedną rzecz. O ile nie można jednoznacznie stwierdzić, która zasada SOLID jest najważniejsza, ponieważ każda zasada wpływa na jakość kodu w różny sposób, o tyle wielu programistów uważa, że to właśnie zasada pojedynczej odpowiedzialności jest absolutnym minimum, które każdy z nas powinien stosować w swoim kodzie. 

Open/Closed Principle 

Drugą ważną zasadą jest zasada otwarte/zamknięte (ang. Open/Closed Principle – OCP). Według tej zasady klasa powinna być otwarta na rozszerzanie, ale jednocześnie zamknięta na modyfikację. To oznacza, że gdy chcemy dodać nową funkcjonalność do naszej aplikacji, nie powinniśmy modyfikować istniejącego kodu, a jedynie dodawać nowy. Dzięki temu zmiany w jednej części systemu nie spowodują awarii w innych jego częściach. 

Liskov Substitution Principle 

Kolejną zasadą jest zasada podstawienia Liskov (ang. Liskov Substitution Principle – LSP). Zasada ta określa, że obiekty typu pochodnego powinny być zawsze zastępowalne przez obiekty typu bazowego. To oznacza, że klasa dziedzicząca powinna być w pełni zgodna z klasą, po której dziedziczy. Dzięki tej zasadzie możemy uniknąć błędów wynikających z niezgodności między klasami, a także ułatwić sobie przyszły rozwój aplikacji. 

Interface Segregation Principle 

Czwartą zasadą jest zasada segregacji interfejsów (ang. Interface Segregation Principle – ISP). Według niej interfejsy powinny być małe i jednorodne, a klienci powinni korzystać tylko z tych, których potrzebują. Oznacza to, że klasa powinna implementować tylko te metody, których potrzebuje, a więc interfejs nie powinien zmuszać jej do implementacji pozostałych metod. To pozwala uniknąć skomplikowania kodu. 

Dependency Inversion Principle 

Piątą i ostatnią zasadą SOLID jest zasada odwrócenia zależności (ang. Dependency Inversion Principle – DIP). Zgodnie z nią zależności między klasami powinny być odwrócone. Oznacza to, że klasy powinny zależeć od abstrakcji, a nie od konkretnych implementacji. Dzięki temu kod jest łatwiejszy do testowania i modyfikowania.  

Dlaczego zasady SOLID są tak ważne?  

Podsumowując, zasady SOLID są kluczowe dla tworzenia skalowalnych, elastycznych i łatwych w utrzymaniu aplikacji. Wymagają one od nas abstrakcyjnego myślenia, projektowania i wdrażania czytelnego kodu. Właściwe stosowanie zasad SOLID pozwala uniknąć licznych problemów, takich jak trudność we wprowadzaniu zmian i testowaniu czy nieczytelność i niejednoznaczność kodu. 

W kolejnych rozdziałach skupię się na każdej z zasad SOLID, rozwinę ich znaczenie i omówię przykłady, dzięki czemu będziemy w stanie lepiej zrozumieć, jak można stosować je w praktyce. 

2021.01.05 JPro cover - Zasady SOLID w programowaniu obiektowym

Który framework frontendowy jest najlepszy?

PRZECZYTAJ ARTYKUŁ

SOLID – przykłady z wykorzystaniem Angulara  

Zasada pojedynczej odpowiedzialności 

Zasada pojedynczej odpowiedzialności ma na celu poprawę jakości kodu i ułatwienie jego rozwijania. Zgodnie z nią każdy element kodu powinien mieć tylko jedną odpowiedzialność, a zmiany w jednej funkcjonalności nie powinny wpływać na pozostałe. Innymi słowy, klasa, funkcja czy moduł powinny robić tylko to, do czego zostały stworzone i nie powinny mieć dodatkowych zadań, które nie są związane z ich główną odpowiedzialnością.  

Zastosowanie zasady pojedynczej odpowiedzialności przynosi wiele korzyści. Przede wszystkim ułatwia utrzymywanie i rozwijanie kodu, ponieważ kod staje się bardziej modularny i łatwiej jest zidentyfikować miejsca, w których należy dokonać zmian. Ponadto, dzięki temu, że każdy element ma tylko jedną odpowiedzialność, łatwiej jest go testować i staje się on dużo bardziej zrozumiały dla innych programistów. 

W jaki sposób możemy rozpoznać, że zasada pojedynczej odpowiedzialności nie jest spełniona? Często nie jest łatwo zidentyfikować miejsce, w którym kończy się odpowiedzialność jednego komponentu, a zaczyna drugiego. Z pomocą przychodzi reguła wykorzystująca spójnik “i”. Jeśli opisując odpowiedzialność danego elementu użyjemy słowa “i”, możemy potraktować to jako sygnał, że nasz element zajmuje się zbyt wieloma rzeczami – jego odpowiedzialność nie jest pojedyncza.  

Zarówno ten przykład, jak i wszystkie kolejne będą napisane we frameworku Angular. Weźmy komponent DataComponent, którego zadaniem jest pobranie danych z serwera i ich przetworzenie ich wyświetlenie. W opisie odpowiedzialności natrafiliśmy na słowo “i”, co może oznaczać, że będziemy potrzebowali udoskonalić nasz kod. 

@Component({ 

  selector: 'app-data', 

  template: ` 

    <ul> 

      <li *ngFor="let item of items"> 

        {{ item }} 

      </li> 

    </ul> 

  `, 

}) 

export class DataComponent implements OnInit { 

  items: string[] = []; 

 

  constructor(private http: HttpClient) {} 

 

  ngOnInit(): void { 

    this.http.get<Item[]>('http://example.com/data').subscribe((data: Item[]) => { 

      this.items = data.map((item) => item.name.toUpperCase()); 

    }); 

  }   

}

I faktycznie takie podejście łamie zasadę pojedynczej odpowiedzialności, ponieważ komponent ten powinien być odpowiedzialny tylko za prezentację danych. Aby go poprawić i spełnić zasadę pojedynczej odpowiedzialności, możemy wydzielić logikę pobierania danych i ich przetwarzania do oddzielnych serwisów. Nasz komponent i wydzielone serwisy będą wtedy wyglądały tak: 

@Component({ 

  selector: 'app-data', 

  template: ` 

    <ul> 

      <li *ngFor="let item of items"> 

        {{ item }} 

      </li> 

    </ul> 

  `, 

}) 

export class DataComponent implements OnInit { 

  items: string[] = []; 

 

  constructor(private itemsService: ItemsService,  

    private dataService: DataService) {} 

 

  ngOnInit(): void { 

    this.itemsService.getItems().subscribe((items) => { 

      this.items = this.dataService.processData(items); 

    }); 

  }   

} 

 

@Injectable() 

export class ItemsService { 

  constructor(private http: HttpClient) { } 

 

  getItems(): Observable<Item[]> { 

    return this.http.get<Item[]>('http://example.com/data'); 

  } 

} 

 

@Injectable() 

export class DataService { 

  processData(data: Item[]): string[] { 

    return data.map((item) => item.name.toUpperCase()); 

  } 

}

Dzięki takiemu podejściu odpowiedzialność została rozproszona, a każdy z obiektów ma tylko jedną. 

  • Komponent DataComponent zajmuje się tylko prezentacją danych, 
  • Serwis ItemsService odpowiedzialny jest tylko za pobranie danych, 
  • Serwis DataService zawiera tylko logikę przetwarzania danych. 

Dzięki takiemu podziałowi jesteśmy w stanie w bardzo łatwy sposób przygotować wymagane testy, a także w przyszłości będziemy w stanie szybko zlokalizować miejsca, które potencjalnie będą mogły wymagać jakichś zmian lub udoskonaleń. 

Wniosek jest taki, że zasada pojedynczej odpowiedzialności jest kluczowa dla tworzenia czytelnego i łatwego w utrzymaniu kodu. Przez wielu programistów uważana jest za najważniejszą spośród wszystkich zasad SOLID. Poprawne jej zrozumienie i stosowanie pozwalają na łatwiejsze rozwijanie oprogramowania oraz zmniejszenie ryzyka pojawienia się błędów w kodzie. 

Zasada otwarte/zamknięte 

Zasada ta pomaga w tworzeniu kodu, który jest bardziej elastyczny i łatwiejszy do rozbudowy. Dzięki niej możemy dodawać nowe funkcje bez obawy o to, że wprowadzimy błędy w już istniejącym kodzie.  

Tworzone moduły powinny być otwarte na rozbudowę” – ale jak to rozumieć? Oznacza to tyle, że gdy chcemy wprowadzić nową funkcjonalność do aplikacji, nie powinniśmy modyfikować już istniejącego kodu, tylko dodawać nowy kod, a istniejące moduły powinny to umożliwiać. W ten sposób nowa funkcjonalność może zostać dodana bez wpływu na dotychczasowe działanie aplikacji.  

Aby spełnić tę zasadę, warto w kodzie wykorzystywać zalety programowania obiektowego, czyli mechanizmy dziedziczenia, polimorfizmu i interfejsów. Dzięki nim możemy oddzielić kod, który się zmienia, od kodu, który pozostaje stały. 

Zasada otwarte/zamknięte jest szczególnie ważna w dużych projektach, w których każda zmiana w kodzie może przynieść nieoczekiwane skutki uboczne i prowadzić do błędów. Dzięki spełnieniu tej zasady możemy uniknąć tych problemów i tworzyć bardziej niezawodne oprogramowanie. 

Powinniśmy stosować tę zasadę również wtedy, kiedy tworzymy nową bibliotekę. Z założenia użytkownicy naszej biblioteki nie będą mogli wprowadzać w niej zmian, lecz byłoby dobrze, aby tam, gdzie miałoby to sens, w jakiś sposób byli w stanie rozszerzać jej możliwości w swoim projekcie.  

Jako przykład weźmy serwis PaymentService do przetwarzania płatności: 

@Injectable() 

export class PaymentService {   

  processPayment(paymentMethod: string): void { 

    switch (paymentMethod) { 

        case 'creditCard': 

            this.processCreditCardPayment(); 

            break; 

        case 'cashOnDelivery': 

            this.processCashOnDeliveryPayment(); 

            break;     

        default: 

            throw new Error('Unsupported payment method'); 

    } 

  } 

 

  private processCreditCardPayment(): void { 

    // logika przetwarzania płatności kartą kredytową 

  } 

 

  private processCashOnDeliveryPayment(): void { 

    // logika przetwarzania płatności przy odbiorze 

  } 

}

Serwis ten łamie zasadę otwarte/zamknięte, ponieważ implementacja nowej metody płatności wymagałaby od nas wprowadzenia zmian w samym serwisie. Zamiast tego powinniśmy przygotować serwis w taki sposób, aby nowe metody płatności mogły być dodawane bez ingerencji w jego kod. 

Możemy to zrobić, przygotowując interfejs PaymentMethod, który będzie implementowany przez nowe metody płatności, a same metody płatności będą przekazywane jako argument do funkcji processPayment

Nowy serwis PaymentService wraz z interfejsem PaymentMethod i kolejnymi metodami płatności wyglądałyby tak: 

@Injectable() 

export class PaymentService { 

  processPayment(paymentMethod: PaymentMethod): void { 

    paymentMethod.processPayment(); 

  } 

} 

 

interface PaymentMethod { 

  processPayment(): void; 

} 
 

@Injectable() 

export class CreditCardService implements PaymentMethod { 

  processPayment(): void { 

    // logika przetwarzania płatności kartą kredytową 

  } 

} 
 

@Injectable() 

export class CashOnDeliveryService implements PaymentMethod { 

  processPayment(): void { 

    // logika przetwarzania płatności przy odbiorze 

  } 

} 
 

@Injectable() 

export class PaypalService implements PaymentMethod { 

  processPayment(): void { 

    // logika przetwarzania płatności Paypal 

  } 

} 

 

// ... 

Dzięki temu każdą nową metodę płatności możemy dodać, tworząc nową klasę implementującą interfejs PaymentMethod. Tym samym nie musimy wprowadzać zmian w serwisie PaymentService, a co za tym idzie, spełnia on teraz zasadę otwarte/zamknięte.  

Podsumowując, zasada otwarte/zamknięte zakłada, że klasy powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację. Oznacza to, że nowe funkcjonalności należy wprowadzać nie poprzez wprowadzanie zmian w istniejących elementach programu, lecz za pomocą dodawania nowych klas i modułów. Poprawne zastosowanie tej zasady może przyczynić się do zwiększenia elastyczności kodu oraz zmniejszenia ilości błędów w trakcie tworzenia aplikacji. 

Zasada podstawienia Liskov 

Zasada podstawienia Liskov mówi o tym, że powinniśmy mieć możliwość zastąpienia klasy bazowej klasą dziedziczącą bez zmiany działania programu. Innymi słowy, jeśli klasa A dziedziczy po klasie B, to powinno być możliwe użycie klasy A zamiast obiektu klasy B, a program nadal powinien działać poprawnie. W mojej opinii jest to najbardziej skomplikowana zasada spośród wszystkich zasad SOLID. 

Zasada ta umożliwia łatwe dodawanie nowych funkcjonalności do istniejącego już kodu. Klasa dziedzicząca po klasie bazowej może wprowadzać nowe funkcjonalności, ale musi zachowywać interfejs klasy bazowej. Dzięki temu aplikacja może korzystać z obiektów klas dziedziczących jak z obiektów klasy bazowej, co umożliwia łatwe rozszerzanie funkcjonalności. 

Zasada ta została sformułowana przez Barbarę Liskov w latach 80. XX wieku i była pierwotnie stosowana w językach programowania proceduralnego. Z czasem stała się równie ważna w kontekście programowania obiektowego. 

Jako przykład utworzymy komponent BirdComponent, w ramach którego będziemy chcieli wyprowadzać nasze ptaki „na spacer”. Stworzymy zatem klasę bazową Bird i dla przykładu spróbujemy utworzyć dla niej jakąś klasę dziedziczącą. 

class Bird { 

  fly(distance: number): void { 

    console.log(`Bird flew ${distance} meters`); 

  } 

} 

 

class Penguin extends Bird { 

  fly(distance: number): void { 

    throw new Error('Penguins do not fly'); 

  } 

} 
 

@Component({ 

  selector: 'app-bird', 

  template: `<p>Bird Component</p>` 

}) 

export class BirdComponent implements OnInit { 

  ngOnInit(): void { 

    const bird = new Penguin(); 

    this.takeToFly(bird, 10);   

  } 
 

  takeToFly(bird: Bird, distance: number) { 

    bird.fly(distance); 

  } 

} 

Możemy zaobserwować, że zasada podstawienia Liskov została tutaj złamana, ponieważ kiedy wywołamy metodę takeToFly dla obiektu typu dziedziczącego Penguin, nie otrzymamy tego samego, oczekiwanego rezultatu, jaki chcielibyśmy uzyskać, gdybyśmy wywołali tę samą metodę dla obiektu typu bazowego Bird

A w jaki sposób zasada umożliwia łatwe dodawanie nowych funkcjonalności? Zmienimy trochę przykład i założymy, że nie wszystkie ptaki latają, ale wszystkie potrafią śpiewać. Utworzymy znów klasę bazową Bird i dodamy do niej klasę dziedziczącą, która tym razem będzie implementować poprawnie wszystkie metody z klasy Bird, ale również dodawać nowe. 

class Bird { 

    sing(lyrics: string): void { 

        console.log(`Bird sang ${lyrics} beautifully`); 

    } 

} 
 

class FlyingBird extends Bird { 

    fly(distance: number): void {  

        console.log(`Bird flew ${distance} meters`);      

    }  

} 
 

@Component({ 

    selector: 'app-bird', 

    template: `<p>Bird Component</p>` 

}) 

export class BirdComponent implements OnInit { 

    ngOnInit(): void { 

        const bird = new FlyingBird(); 

        this.makeBirdSign(bird, "We are the champions!"); 

    } 
 

    makeBirdSign(bird: Bird, lyrics: string) { 

        bird.sing(lyrics); 

    } 

} 

Zgodnie z zasadą podstawienia Liskov – FlyingBird zachowuje się tak samo jak Bird, a ma dodatkową funkcjonalność. W rezultacie w kodzie, który do tej pory korzystał z typu Bird, możemy użyć typu FlyingBird. Ptak jest w stanie dodatkowo latać, a istniejący kod nie został popsuty. 

Podsumowując, zasada podstawiania Liskov to ważna zasada programowania obiektowego, która zapewnia, że nasz kod jest elastyczny, łatwy w utrzymaniu i testowaniu oraz spełnia wysokie standardy jakościowe, a stosowana poprawnie, pomaga uniknąć błędów. 

jpro 2022.10.26 cover - Zasady SOLID w programowaniu obiektowym

Wprowadzenie do funkcjonalnego programowania reaktywnego w JavaScript z RxJS

PRZECZYTAJ ARTYKUŁ

Zasada segregacji interfejsów 

Celem tej zasady jest zwiększenie elastyczności, łatwości w utrzymaniu i rozwoju oprogramowania. Mówi ona, że „klient nie powinien być zmuszony do zależności od interfejsów, których nie używa”. 

Zgodnie z nią nie powinno być jednego dużego interfejsu, który jest implementowany przez wszystkie klasy, gdy tylko część z jego metod jest potrzebna w danym kontekście. Zamiast tego interfejsy powinny być podzielone na mniejsze i bardziej konkretne, które są bardziej dostosowane do potrzeb klientów. 

Ta zasada jest ważna, ponieważ pozwala nam na utrzymanie czystości kodu oraz zwiększenie jego czytelności i zrozumiałości. Dzięki temu, że każda klasa implementuje tylko te metody, które są jej potrzebne, kod staje się bardziej modułowy i łatwiejszy w utrzymaniu. 

Powinniśmy używać tej zasady zwłaszcza wtedy, kiedy projektujemy interfejsy, które mają być implementowane przez wiele klas. W szczególności, gdy interfejs jest zbyt duży, powinien być rozbity na mniejsze interfejsy, aby uniknąć sytuacji, w której klasy są zmuszone implementować metody, których nie potrzebują. 

Jako przykład, załóżmy, że chcemy utworzyć strony w naszej aplikacji oraz aby komponenty reprezentujące strony miały tę samą strukturę. Stwórzmy zatem interfejs PageContent i zaimplementujemy go w naszym komponencie.  

 
export interface PageContent { 

    id: string; 

    content: string; 

    loading: boolean; 

    load(): void; 

} 
 

@Component({ 

    selector: 'app-errors', 

    template: `<p>Erros Component</p>` 

}) 

export class ErrorsComponent implements PageContent { 

    id = ""; 

    content = ""; 

    loading = false; 

    load(): void { 

        // Logika ładowania strony... 

    } 

} 

Dodaliśmy przy okazji logikę ładowania strony, która mogłaby zawierać np. animację ładowania. Nasz interfejs gwarantuje, że każdy komponent, który go implementuje, będzie działał prawidłowo. Załóżmy teraz, że dostaliśmy zadanie, aby utworzyć nową stronę, która będzie przechowywała stałą zawartość. Oznacza to, że w komponencie nie potrzebujemy już mechanizmu ładowania. Chcąc zaimplementować ten sam interfejs, musielibyśmy zostawić metodę load pustą, a w naszym komponencie musielibyśmy utworzyć metodę, której nie potrzebujemy. I dlatego ten przykład łamie zasadę segregacji interfejsów.  

Aby go poprawić, powinniśmy wydzielić dodatkowy interfejs, który służyłby nam do ładowania stron, i użyć go wszędzie tam, gdzie tego potrzebujemy. Pozostałe strony implementowałyby inny interfejs, z którego wycięlibyśmy mechanizm ładowania strony. W ten sposób: 

export interface PageContent { 

    id: string; 

    content: string; 

} 
 

export interface PageLoad { 

    loading: boolean; 

    load(): void; 

} 
 

@Component({ 

    selector: 'app-errors', 

    template: `<p>Erros Component</p>` 

}) 

export class ErrorsComponent implements PageContent, PageLoad { 

    id = ""; 

    content = ""; 

    loading = false; 

    load(): void { 

        // Logika ładowania strony... 

    } 

} 
 

@Component({ 

    selector: 'app-static', 

    template: `<p>Static Component</p>` 

}) 

export class StaticComponent implements PageContent { 

    id = ""; 

    content = ""; 

} 

Dzięki temu interfejsy zostały posegregowane, a komponenty nie muszą implementować metod, których nie potrzebują. Tym samym zasada segregacji interfejsów została spełniona. 

Podsumowując, każda klasa powinna implementować tylko te metody, które są jej potrzebne. Dzięki temu kod staje się bardziej modułowy i łatwiejszy w utrzymaniu. Zasada segregacji interfejsów jest szczególnie ważna, gdy projektujemy interfejsy, które mają być implementowane przez wiele klas. Poprawne stosowanie tej zasady pozwala na stworzenie bardziej elastycznego i czytelnego kodu. 

Zasada odwracania zależności 

Zasada ta głosi, że moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu, a oba rodzaje modułów powinny zależeć od abstrakcji. Innymi słowy, powinniśmy unikać silnych zależności między modułami, a zamiast tego wykorzystywać abstrakcję i interfejsy. 

Zasadę odwracania zależności należy stosować w sytuacjach, gdy mamy do czynienia z kodem, w którym jedna część aplikacji jest mocno powiązana z inną i trudno jest ją zrefaktoryzować lub zmienić. W takiej sytuacji należy rozważyć zastosowanie abstrakcji i interfejsów, które pozwolą na łatwiejszą wymianę jednej części aplikacji na inną bez konieczności modyfikowania całej reszty kodu. 

Innym ważnym aspektem zasady odwracania zależności jest to, że umożliwia ona pisanie kodu, który jest bardziej elastyczny i łatwiejszy w utrzymaniu. Dzięki zastosowaniu abstrakcji i interfejsów nasza aplikacja staje się bardziej modularna, co ułatwia jej rozwijanie i testowanie. 

Sprawdźmy, jak dobrze jest to zrobione w Angularze. Jeśli postanowimy stworzyć serwis i wstrzyknąć go w naszym komponencie, Angular sprawi, że ten komponent nie będzie zależny od konkretnej implementacji tego serwisu, a jedynie od jego abstrakcji. Oznacza to, że będziemy mogli w każdej chwili dostarczyć inną jego implementację. Dzięki temu Angular automatycznie poniekąd zapewnia, że zasada odwracania zależności jest spełniona. Spójrzmy na przykład. 

@Component({ 

    selector: "app-some-component", 

    providers: [ 

       provide: SomeService, 

       useClass: SomeImplementationOfSomeService 

    ] 

}) 

export class SomeComponent implements OnInit { 

    constructor(private someService: SomeService) {} 

 

    ngOnInit(): void { 

        this.someService.someMethod(); 

    } 

} 

Dzięki sekcji providers i parametrowi useClass możemy podstawić dowolną implementację serwisu SomeService w każdym komponencie, w którym tego potrzebujemy. Jeśli tego nie zrobimy, komponent wykorzysta domyślną implementację serwisu. Moglibyśmy pójść o krok dalej i potraktować SomeService z założenia abstrakcyjnie, czyli nie przywiązywać się nigdzie do jego domyślnej implementacji i wtedy w każdym module/komponencie podstawiać interesującą nas implementację w zależności od kontekstu i wymagań. 

Podsumowując, zasada odwracania zależności jest kluczowa dla tworzenia kodu, który jest łatwiejszy w utrzymaniu, bardziej elastyczny i modułowy. Stosowanie abstrakcji i interfejsów pozwala nam uniknąć silnych zależności między różnymi częściami aplikacji i ułatwia nam wymianę jednej implementacji na inną. 

Ważne jest również, aby pamiętać, że zasada odwracania zależności nie oznacza całkowitego uniezależnienia od konkretnej implementacji. Często potrzebujemy konkretnej implementacji, ale dzięki zastosowaniu abstrakcji i interfejsów możemy łatwo zamienić jedną implementację na inną, bez potrzeby zmieniania całego kodu. 

Przeczytaj także: Jeśli nie Redux, to co? Zarządzanie stanem aplikacji w React

Podsumowanie  

Zasady SOLID w programowaniu obiektowym to nie tylko teoria, ale przede wszystkim praktyka, która zmienia sposób, w jaki tworzymy oprogramowanie. Ich stosowanie zapewnia łatwiejsze utrzymanie i rozwijanie kodu, zwiększa elastyczność, ułatwia testowanie, a także przyczynia się do zmniejszenia kosztów i czasu potrzebnego na późniejsze modyfikacje. 

Dzięki nim unikamy powszechnych problemów w projektowaniu, takich jak klasyczny problem „spaghetti code”, w którym często trudno jest zrozumieć zależności między klasami. Zasady SOLID zapewniają lepszą separację zadań, co ułatwia pracę w zespole oraz pozwala na tworzenie bardziej modułowego kodu. Kod, który jest łatwy w utrzymaniu i testowaniu, jest też bardziej stabilny i odporny na błędy. A to z kolei przekłada się na mniejszą liczbę błędów w produkcie końcowym oraz mniejsze ryzyko opóźnień w projekcie. 

Na koniec warto wspomnieć, że zasady SOLID nie są czymś, co zawsze należy stosować w 100%. Są to narzędzia, które można dostosować do konkretnych potrzeb projektu. Ważne jest jednak, by pamiętać o ich istnieniu i korzystać z nich, gdy tylko jest to możliwe.