SOLID, KISS i DRY

Opublikowano Kategorie Czysty kodCzas czytania 13min

SOLID, KISS i DRY to jedne z najpopularniejszych pojęć/skrótów wśród programistów. Są to na tyle kluczowe zagadnienia oraz przydatne w zadaniach programisty, że nie mogło ich zabraknąć na moim blogu.

Wszystkie przykłady w tym artykule zostały napisane w TypeScripcie. Jeśli jednak TypeScript jest Ci obcy, to przykłady w dalszym ciągu powinny być dla Ciebie zrozumiałe.

Czym jest SOLID

SOLID to akronim, który można rozwinąć w pięć zasad programowania obiektowego:

  1. Single Responsibility Principle — Zasada jednej odpowiedzialności
  2. Open/Closed Principle — Zasada otwarte-zamknięte
  3. Liskov Substitution Principle — Zasada podstawienia Liskov
  4. Interface Segregation Principle — Zasada segregacji interfejsów
  5. Dependency Inversion Principle — Zasada odwrócenia zależności

Zasady SOLID zostały przedstawione po raz pierwszy przez Roberta C. Martina (Uncle Bob) w artykule Design Principles and Design Patterns z 2000 roku. Sam akronim SOLID został opracowany nieco później przez Michaela Feathersa.

Zanim przejdę do omówienia poszczególnych zasad, chciałbym podkreślić fakt, że reguły SOLID to nie jest coś, czego należy bezwzględnie się trzymać, niezależnie od okoliczności. Może zdarzyć się tak, że w pewnych przypadkach pewne reguł SOLID nie znajdą zastosowania lub okażą się przerostem formy nad treścią — co za dużo to niezdrowo. Osobiście traktuję programowanie według zasad SOLID-a jako istotną rekomendację, ale nie jako obowiązek.

S jak Single Responsibility Principle

Zasada jednej odpowiedzialności, jak sama nazwa wskazuje, mówi o tym, że klasa lub moduł powinny mieć tylko jedną odpowiedzialność. Można również powiedzieć, że klasa lub moduł powinny mieć jeden powód do zmiany. Mówiąc jeszcze prościej, klasa lub moduł powinny robić tylko i wyłącznie jedną rzecz. Aby zrozumieć na czym polega ta reguła, przeanalizuj poniższy przykład.


import bcrypt from 'bcrypt';

import database from './databaseConnection';
import HTTPResponse from './HTTPResponse';

interface IUserData {
  name: string;
  password: string;
}

const SALT_ROUNDS: number = 10;

class UserService {
  public async registerUser( userData: IUserData ): Promise<HTTPResponse> {
    if ( userData.name.length < 1 || userData.name.length > 24 ) {
      return HTTPResponse.badRequest( 'Invalid user name.' );
    }

    if ( userData.password.length < 8 ) {
      return HTTPResponse.badRequest( 'Password is too short!' );
    }

    const id: number = await database.insert( {
      table: 'users',
      data: {
        name: userData.name,
        password: await bcrypt.hash( userData.password, SALT_ROUNDS )
      },
      {
        createTimestamps: true
      }
    );

    return HTTPResponse.created( { id } );
  }
}

Już na pierwszy rzut oka można zauwazyć, że coś z tym kodem jest nie tak. Liczba odpowiedzialności w tym kodzie jest zdecydowanie wyższa niż 1:

  • sprawdzanie poprawności danych wejściowych;
  • wykonywanie operacji na bazie danych — jawne wykorzystanie ORM, jawne przekazanie szczegółów implementacyjnych takich jak nazwa tabeli docelowej, struktury kolumn w tabeli, obecność timestampów;
  • wygenerowanie wyniku funkcji skrótu (hasha) przekazanego hasła. Jawne użycie algorytmu bcrypt oraz przekazanie dodatkowych parametrów, które są nieistotne z puktu widzenia logiki biznesowej;
  • zwrócenie odpowiedzi HTTP.

Jest to oczywiste złamanie zasady pojedynczej odpowiedzialności. W powyższym przypadku brzydko wyglądający kod jest najmniejszym problemem. Podczas dalszych prac nad projektem ten kod prawdopodobnie urośnie, powstaną nowe odpowiedzialności, powiązania czy zależności. Mogą się także zmienić wymagania biznesowe lub techniczne. Kod powyżej nie jest w żaden sposób na to przygotowany, a w miarę rozwoju projektu dokonywanie zmian w nim będzie coraz trudniejsze, bardziej czasochłonne i mniej odporne na błędy. Wisienką na torcie jest brak możliwości przetestowania tego kodu za pomocą testów jednostkowych. Jeśli temat testów jest Ci obcy, to zachęcam do przeczytania artykułu omawiającego podstawy testów automatycznych oprogramowania. Zobacz, jak mógłby wyglądać kod po zastosowaniu SRP.


import UsersRepository from './UsersRepository';
import Validators, { validate } from './Validators';
import HTTPResponse from './HTTPResponse';

class UserService {
  public constructor( private readonly _usersRepository: UsersRepository ){}

  public async registerUser(
    @validate( Validators.username() )
        username: string,
    @validate( Validators.password() )
        password: string
    ): Promise {
    const id: number = await this._usersRepository.add( username, password );

    return HTTPResponse.created( { id } );
  }
}

Ten kod, moim zdaniem, wygląda zdecydowanie lepiej. Udało się pozbyć trzech z czterech odpowiedzialności. Walidacja odbywa się teraz za pomocą wcześniej przygotowanych walidatorów. Do walidacji wykorzystano dekoratory, dzięki czemu ukryto szczegóły implementacyjne walidacji. Operacje na bazie danych oraz generowania hasha również zostały przeniesione do innej klasy. Do tego celu powstała klasa UsersRepository, która jest odpowiedzialna za wykonywanie zapytań do silnika bazodanowego. Dzięki tym krokom złożoność metody registerUser została znacząco zredukowana i poprawiła się czytelność kodu. Co więcej, kod zyskał na elastyczności oraz stał się testowalny za pomocą testów jednostkowych — klasa UsersRepository może zostać zastąpiona mockiem.

SOLID - zasada otwarte-zamknięte - obrazek alegoryczny

O jak Open/Closed Principle

Zasada otwarte-zamknięte mówi o tym, że klasa powinna być otwarta na rozbudowę, lecz zamknięta na modyfikację. Oznacza to, że zachowanie danej klasy powinno być rozszerzalne, lecz nie powinno być modyfikowalne. Tutaj również przydatny jest przykład.


import FilesRepository from './FilesRepository';
import HTTPResponse from './HTTPResponse';

interface IFile {
  name: string;
  size: number;
  mimeType: string;
  metatada: Record<string, unknown>;
}

class FileUploader {
  public constructor( private readonly _filesRepository: FilesRepository ) {}

  public async upload( file: IFile ): Promise<HTTPResponse> {
    if ( file.mimeType  === 'text/css' ) {
      this._cssParse( file );
    }

    if ( file.mimeType  === 'image/jpeg' ) {
      this._jpegParse( file );
    }

    await this._filesRepository.add( file );

    return HTTPResponse.ok();
  }

  private _cssParse( file: IFile ): void {
    // Some business logic
  }

  private _jpegParse( file: IFile ): void {
    // Some business logic
  }
}

Powyższy kod jest odpowiedzialny za upload plików na serwer. Każdy plik musi zostać wcześniej przeparsowany. W obecnej formie kod wspiera pliki z mimeType mage/jpeg oraz text/css. Problem zacznie się, gdy będziemy chcieli dodać wsparcie dla innych typów plików. Każdy dodatkowy mimeType to dodatkowa instrukcja warunkowa oraz dodatkowa metoda prywatna. Dodanie wsparcia dla kolejnych typów wymusza modyfikację klasy. Przykładowe rozwiązanie tego problemu może wyglądać następująco.


import FilesRepositoryfrom './FilesRepository';
import HTTPResponse from './HTTPResponse';
import { IParser } from './Parser';

interface IFile {
  name: string;
  size: number;
  mimeType: string;
  metatada: Record<string, unknown>;
}

class FileUploader {
  public constructor(
    private readonly _filesRepository: FilesRepository,
    private readonly _parsers: Map<string, IParser>
    ) {}

  public async upload( file: IFile ): Promise {
    const parser: IParser | undefined = this._parsers.get( file.mimeType );

    if ( !parser ) {
      throw new Error( `${ file.mimeType } mimeType not supported!` );
    }

    await parser.parse( file );

    await this._filesRepository.add( file );

    return HTTPResponse.ok();
  }
}

Problem wielu parserów rozwiązano poprzez przekazanie mapy z parserami, gdzie kluczem parsera jest mimeType pliku. Dzięki temu dodanie kolejnego parsera będzie sprowadzone do dodaania go do mapy, co jest zdecydowanie wygodniejsze niż każdorazowa modyfikacja klasy FileUploader. Co więcej, to podejście pozwoliło w bardzo łatwy sposób obsłużyć przypadek, gdy został podany niewspierany mimeType. Co prawda w pierwszym przykładzie również jest to możliwe, ale wiązałoby się to z bardzo długą listą klauzul if else ( osobna dla każdego mimeType ), lub stworzeniu redundantnej tablicy ze wspieranymi typami co zaciemniłoby kod. Na deser udało się zredukować liczbę odpowiedzialności dzięki przeniesieniu kodu odpowiedzialnego za parsowanie do parserów, dzięki czemu spełniona została zasada SRP.

L jak Liskov Substitution Principle

Zasada podstawienia Liskov została zdefiniowana w książce Data Abstraction and Hierarchy przez Barbarę Liskov. Zasada ta mówi o tym, że jeżeli w danej funkcji będzie wykorzystany obiekt klasy potomnej, to wywołanie na nim metody pierwotnie zdefiniowanej w klasie bazowej powinno dać te same rezultaty. Przykład złamania zasady LSP.


abstract class Car {
  protected _gear: number = 0;

  public gearUp() {
    this._gear++;
  }

  public gearDown() {
    this._gear--;
  }
  public drive(): void {
    // Some business logic
  }

  public get gear() {
    return this._gear;
  }
}

class Mercedes extends Car {}

class Tesla extends Car {
   public gearUp() {
    return;
  }

  public gearDown() {
    return;
  }
}

const tesla: Car = new Tesla();
const mercedes: Car = new Mercedes()

tesla.gearUp();
tesla.gearUp();
mercedes.gearUp();
mercedes.gearUp();

console.log( mercedes.gear, tesla.gear ); // 2 0

Na podstawie istniejącej klasy abstrakcyjnej Car stworzono dwie klasy potomne — Tesla oraz Mercedes. Z uwagi na to, że Tesla nie posiada skrzyni biegów, wywoływanie metod gearUp oraz gearDown mija się z celem. Dlatego też ich zachowanie zmieniło się względem klasy bazowej. W tym momencie następuje złamanie zasady podstawienia Liskov, ponieważ zachowanie obiektu potomnego da inny rezultat niż w przypadku klasy bazowej. Rozwiązaniem tego problemu jest zrezygnowanie z dziedziczenia po klasie Car w przypadku klasy Tesla i stworzenie, na przykład, klasy bazowej ElectricCar.

I jak Interface Segregation Principle

Zasada segregacji interfejsów mówi o tym, że klient wykorzystujący dany interfejs nie powinien być zmuszony do obsłużenia metod, których nie używa.


interface IBird {
  makeSound(): string;
  fly(): void;
  eat(): void;
}

class Duck implements IBird {
  makeSound(): string {
    return 'Quack!';
  }

  fly(): void {
    // Fly!
  }

  eat(): void {
    // Eat!
  }
}

class Chicken implements IBird {
  makeSound(): string {
    return 'Cluck!';
  }

  fly(): void {
    throw new Error( 'Chicken cannot fly!' );
  }

  eat(): void {
    // Eat!
  }
}

W przypadku klasy Chicken niemożliwe jest poprawne zaimplementowanie metody fly. Przez wykorzystanie tego samego interfejsu co w przypadku klasy Duck złamano zasadę ISP. Rozwiązaniem tego problemu jest… segregacja interfejsów.


interface IBird {
  makeSound(): string;
  eat(): void;
}

interface IFlyingBird extends IBird {
  fly(): void;
}

class Duck implements IFlyingBird {
  makeSound(): string {
    return 'Quack!';
  }

  fly(): void {
    // Fly!
  }

  eat(): void {
    // Eat!
  }
}

class Chicken implements IBird {
  makeSound(): string {
    return 'Cluck!';
  }

  eat(): void {
    // Eat!
  }
}

Interfejs IBird podzielono na dwa interfejsy — IBird oraz IFlyingBird, który jest interfejsem potomnym względem IBird. Pozwoliło to na wyeliminowanie zbędnej implementacji metody w klasie Chicken. Co więcej, w dalszym ciągu obie klasy implementują ten sam interfejs IBird.

D jak Dependency Inversion Principle

Zasada odwrócenia zależności mówi o tym, że wysokopoziomowe moduły nie powinny zależeć od modułów niskopoziomowych. Co więcej, abstrakcje nie powinny być zależne od implementacji, tylko to implementacje powinny być zależne od abstrakcji. Tak jak w przypadku poprzednich zasad posłużę się przykładem.


import Database from './Database';
import HTTPResponse from './HTTPResponse';

class UserService {
  private readonly _database: Database;

  public constructor() {
    this._database = new Database( {
      database: 'db_local',
      password: 'secret',
      user: 'root',
      url: '127.0.0.1',
      port: '1234'
    } );
  }

  public async registerUser(username: string, password: string ): Promise<HTTPResponse> {
    const id: number = await this._database.insert( 'users', {
      username,
      password
    } );

    return HTTPResponse.ok( { id } );
  }
}

Powyższy przykład miesza ze sobą warstwy wysokiego poziomu, czyli logikę biznesową, z warstwami niskiego poziomu, czyli tworzeniu połączenia z bazą danych oraz wykonywaniu zapytań do bazy danych. Taka implementacja będzie problematyczna w momencie zastąpienia obecnego rozwiązania bazodanowego innym. Przez zmiany na niskim poziomie, należy dostosować kod wysokiego poziomu, czyli logikę biznesową aplikacji co powoduje złamanie zasady odwrócenia zależności. W tym wypadku, zamiast konkretnego rozwiązania w postaci połączenia z bazą danych należy wykorzystać abstrakcję, pozwalającą na pełne odseparowanie warstw aplikacji i uniezależnienie wyższych warstw od niższych.


import HTTPResponse from './HTTPResponse';

interface IUsersRepository {
  add( username: string, password: string ): number;
}

class UserService {
  public constructor( private readonly _usersRepository: IUsersRepository ){}

  public async registerUser( username: string, password: string ): Promise {
    const id: number = await this._usersRepository.add( username, password );

    return HTTPResponse.ok( { id } );
  }
}

W tym fragmencie kodu wykorzystano abstrakcję. Przy tworzeniu obiektu klasy UsersService otrzymujemy abstrakcję spełniającą interfejs IUsersRepository pozwalającą na wywołanie metody add, która zwraca spodziewany rezultat.

Z poziomu logiki biznesowej, programisty nie interesuje jakiego rozwiązania niskopoziomowego użyto, ani nie jest od niego w żaden sposób zależny. Co więcej, takie rozwiązanie pozwala na szybką zmianę rozwiązania zaimplementowanego w repozytorium na inne, bez ingerencji w logikę biznesową.

KISS - obrazek alegoryczny

KISS — Keep It Simple, Stupid

Reguła ta oznacza dosłownie „zrób to prosto, głupku”. Programista powinien dążyć do jak najprostszych i jak najbardziej zrozumiałych rozwiązań. Jest to odpowiedź na tendencje programistów do tworzenia nadmiarowych i przekombinowanych rozwiązań. Wbrew pozorom, prawdziwą sztuką jest tworzenie rozwiązań zarówno eleganckich pod względem czystości kodu, jak i prostych pod względem złożoności.

O regule KISS warto pamiętać szczególnie w kontekście pracy zespołowej. Pamiętaj, że rozwiązanie, które jest dla Ciebie proste i zrozumiałe, może nie być takim dla innych członków zespołu.

Zasada ta idzie w parze z zasadą YAGNI, czyli „you ain’t gonna need it”. Nie należy tworzyć kodu „na zaś” ani zostawiać kodu „bo się przyda”. Jeżeli w danym momencie fragment kodu jest zbędny, to nie należy go utrzymywać. Jak się okaże że jednak go potrzebujesz, to powinieneś go przywrócić wykorzystując system kontroli wersji.

DRY — Don’t Repeat Yourself

Ta zasada brzmi bardzo prosto – „nie powtarzaj się”. Stosowanie się do reguły DRY niesie ze sobą szereg zalet:

  • łatwość utrzymania kodu — wprowadzanie zmian jest łatwiejsze, gdy trzeba to zrobić w jednym miejscu;
  • łatwiejsza detekcja błędów — debuggowanie jednego fragmentu kodu jest prostsze;
  • mniejsze ryzyko popełnienia błędu — jeżeli zmieniamy kilka fragmentów kodu, istnieje większe ryzyko popełnienia błędu. Co więcej, błąd popełnia się tylko w jednym miejscu, co spowoduje różne zachowanie pozornie takiego samego kodu w różnych częściach systemu.

Należy pamiętać jednak, że zduplikowany kod jest zdecydowanie lepszy niż źle zaprojektowana abstrakcja. Wyeliminowanie zduplikowanego kodu to zdecydowanie mniejszy koszt niż pozbycie się źle zaprojektowanej abstrakcji. Zmiany w abstrakcjach, w skrajnych przypadkach mogą rzutować na całą aplikację, a ich naprawienie może oznaczać ogromny nakład pracy.

Będąc DRY, należy również pamiętać o unikaniu couplingu (wiązania). Zmiany w jednym komponencie nie powinny powodować konieczności dopasowania innych komponentów. Oprócz tego powinna istnieć możliwość bezproblemowego zastąpienia obecnego komponentu innym. Programista powinien dążyć do jak najmniejszego powiązania pomiędzy komponentami — zarówno poszczególnych klas, abstrakcji, modułów, jak i w szerszym kontekście, na przykład serwisów.

Podsumowanie

Mam nadzieję, że ta solidna dawka wiedzy okaże się przydatna w twojej pracy. Zdecydowałem się poruszyć ten temat z uwagi na to, że reguły SOLID początkowo traktowałem jako coś teoretycznego, jak regułki do wykucia na pamięć.

Znając jedynie teorię SOLID-a, nie potrafiłem zastosować go w praktyce. Dużo czasu zajęło mi nauczenie się stosowania tych reguł w praktyce oraz zdobycie umiejętności odnajdywania problemów związanych z łamaniem zasad SOLID-a w istniejącym już kodzie. Co więcej, nawet zastosowanie tak pozornie prostych reguł ,jak KISS i DRY w praktyce również nie jest takie łatwe, jak początkowo mogłoby się wydawać.

Zachęcam do zostawienia komentarza. Jeśli wpis okazał się dla Ciebie przydatny, to będę wdzięczny za udostępnienie go.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

2 komentarzy
oceniany
najnowszy najstarszy
Inline Feedbacks
View all comments
Piotrek
Piotrek
8 miesięcy temu

Wydawałoby się, że SOLID jest już przemielony przez 10tki artykułów. Powiem jednak szczerze, że ten jest pierwszym, w którym znalazłem coś więcej niż copy-paste Wikipedii. Dzięki.