SOLID v JavaScripte: Open-Closed Principle

01.10.2023

Open-Closed Principle je druhým zo SOLID princípov a ako taký je druhým najdôležitejším, ktorého by sme sa mali pri programovaní držať. V tomto článku sa dozvieme, o čom tento princíp hovorí a akým postupom krokov vieme dosiahnuť, že bude v našom kóde splnený.

Open-Closed Principle

V literatúre a článkoch sa s týmto princípom môžeme stretnúť aj pod skratkou OCP. Jeho definícia znie:

Jednotka kódu by mala byť otvorená pre rozširovanie, ale uzatvorená pre zmeny.

z predchádzajúceho článku vieme, že za slovné spojenie jednotka kódu si môžeme dosadiť čokoľvek z množiny trieda, modul, objekt a funkcia. Ale aj keď to urobíme, tak nám definícia na prvé počutie nemusí vôbec dávať zmysel.

Trochu zmysluplnejšie nám môže znieť, že Open-Closed Principle hovorí, nech kód píšeme tak, aby sme vedeli rozšíriť jeho funkcionalitu aj bez toho, aby sme ho museli upraviť.

Znie to síce trochu magicky, ale čoskoro si ukážeme, že to nie je nič zložité.

Ak sa nám v našom kóde podarí spĺňanie tohto princípu dosiahnuť, tak odmenou nám budú bezpečnejšie a rýchlejšie úpravy funkcionality, pretože budeme robiť minimálne alebo žiadne zásahy do kódu.

Symptómy porušenia princípu

Pokiaľ uvažujeme, či nejaká jednotka nášho kódu spĺňa OCP, tak si môžeme pomôcť príznakmi toho, kedy to tak nie je. Platí však to isté, čo aj pri Single Responsibility Principle: tieto príznaky hovoria len o tom, že je vyššia pravdepodobnosť nesplnenia princípu. Nehovoria, že to tak určite je.

Príznaky porušenia OCP princípu v nejakej jednotke sú:

  • v kóde sú podmienky pre určenie stratégie (typicky napríklad switch)
  • podobné podmienky sa opakujú na viacerých miestach v kóde
  • kód obsahuje natvrdo zapísaný názov súboru, emailovú adresu alebo inú skalárnu hodnotu
  • trieda v sebe obsahuje natvrdo zapísané názvy iných tried (v poliach, stringoch, podmienkach)
  • vo vnútri triedy sa vytvárajú iné objekty pomocou new (výnimkou sú továrničky)

Ako refaktorovať

V prípade, že máme hotový kód nejakej jednotky (povedzme funkcie) a tento nespĺňa OCP, tak ho vieme refaktorovať v piatich krokoch. Časom sa tento postup pre nás stane takým automatickým, že ho budeme vediet aplikovať na jeden prepis. Ale kým v tom nie sme zbehlí, tak je lepšie sa držať postupu krok za krokom.

  1. zabezpečíme, že jednotka spĺňa Single Responsibility Principle
  2. skúsime identifikovať všeobecné a špecifické časti úlohy, ktorú jednotka rieši
  3. oddelíme všeobecné a špecifické časti od seba (vo vnútri jednotky)
  4. špecifické časti prenesieme von mimo jednotku
  5. všeobecnú a špecifickú časť prepojíme pomocou "konfigurácie"

Pokiaľ nám zoznam týchto krokov znie rovnako magicky, ako samotná definícia princípu, tak sa netreba báť, v nasledovnom príklade uvidíme, že to nie je také strašidelné.

Príklad

Riadky kódu, ktoré sú podobné ukážke nižšie, môžeme vidieť pomerne často. Na prvý pohľad vyzerajú celkom v poriadku. Ale pri bližšom pohľade uvidíme problémy.

import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

export async function getSortedCharacters() {
    const characters = await loadCharacters();

    return sortCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch('/api/list.json');

    return (await result.json()) as Promise<Character[]>;
}

Zamerajme sa na funkciu loadCharacters. Čo keby sme chceli, aby zoznam postáv načítavala z inej URL? Museli by sme siahnuť do jej vnútra a zmeniť jej kód. A toto je znak toho, že nespĺňa OCP. Chceme ju rozšíriť, ale musíme ju zmeniť. Keby spĺňala OCP, tak by sme ju vedeli rozšíriť aj bez toho, aby sme ju museli meniť.

Ako na to? Aplikujeme náš refaktorovací algoritmus.

Krok 1: Zabezpečíme, že jednotka spĺňa Single Responsibility Principle

Tu našťastie nemusíme nič robiť, funkcia už spĺňa SRP. Keby to tak nebolo, tak tento krok je nutným.

Krok 2: Skúsime identifikovať všeobecné a špecifické časti úlohy, ktorú jednotka rieši

Toto v tejto funkcii opäť nie je žiaden veľký problém. Špecifickou časťou je natvrdo zapísaná URL.

Krok 3: Oddelíme všeobecné a špecifické časti od seba (vo vnútri jednotky)

Oddelenie všeobecných a špecifických častí je lepšie najskôr robiť priamo vo vnútri funkcie. Vizuálne tak uvidíme rozdiel, ktorý sme v predchádzajúcom kroku vytušili a pomenovali. A takéto zvýraznenie nám môže pomôcť v neskoršom rozhodovaní, čo s tým.

V tejto situácii toho nemáme na práci veľa, potrebujeme len špecifickú URL nejako oddeliť a zvýrazniť. Najjednoduchšie, čo môžeme urobiť, je vytiahnuť ju do samostatnej premennej.

async function loadCharacters() {
    const url = '/api/list.json';

    const result = await fetch(url);

    return (await result.json()) as Promise<Character[]>;
}

Krok 4: Špecifické časti prenesieme von mimo jednotku

V tomto kroku potrebujeme URL dostať von z funkcie. Je niekoľko spôsobov, ako to urobiť. Najpriamočiarejší je urobiť z nej globálnu premennú, čo však nie je vôbec dobrý nápad. Globálne premenné sú vo všeobecnosti prvok, ktorý prináša do kódu veľa neprehľadných rizík a tak sa im snažíme vyhýbať.

Druhou jednoduchou možnosťou by bolo urobiť z premennej parameter funkcie. A presne to je riešenie, ktoré je v tejto situácii správne.

async function loadCharacters(url) {
    const result = await fetch(url);

    return (await result.json()) as Promise<Character[]>;
}

Krok 5: Všeobecnú a špecifickú časť prepojíme pomocou "konfigurácie"

Zmenou v kroku 4 sme dosiahli, že funkcia loadCharacters je teraz konfigurovateľná. Zároveň už funkcia spĺňa aj OCP, pretože ju vieme rozšíriť o možnosť načítavať údaje z inej URL a pritom nemusíme modifikovať jej telo. Je teda otvorená pre rozširovanie, ale uzatvorená pre zmeny.

Aby nám však kód naďalej fungoval, tak teraz musíme nájsť všetky miesta, kde sa funkcia volá a dodať tam želanú URL.

export async function getSortedCharacters() {
    const characters = await loadCharacters('/api/list.json');

    return sortCharactersByRole(characters);
}

async function loadCharacters(url) {
    const result = await fetch(url);

    return (await result.json()) as Promise<Character[]>;
}

Tento krok by sme však nemali robiť bezhlavo. Treba sa zamyslieť jednak nad viacerými spôsobmi prepojenia a nad ich dôsledkami a vybrať si ten, ktorý nám vyhovuje najviac.

Zmena urobená vyššie je možno pre túto situáciu jednoduchá, lebo funkcia je volaná iba na jednom mieste, takže to vieme urobiť ľahko a rýchlo. Ale tým, že sme robili zmenu v inej funkcii (getSortedCharacters), tak sa teraz musíme pýtať, či táto druhá funkcia po zmene stále spĺňa Single Responsibility Principle a zároveň aj Open-Closed Principle.

Už asi tušíme, že OCP nespĺňa. Pretože sme v nej vytvorili rovnakú situáciu, akú sme mali na začiatku vo funkcii loadCharacters. Zároveň nespĺňame ani SRP, pretože okrem triedenia postáv má funkcia v zodpovednosti aj znalosť o URL na načítanie dát.

Veľmi dôležitou je aj otázka, či getSortedCharacters má vôbec niečo vedieť o nejakej URL. Je to funkcia, ktorá podľa názvu robí triedenie a dáta si len pýta od druhej funkcie. Pre svoju činnosť by nemala potrebovať poznať žiadnu URL a dokonca by jej malo byť jedno, akým spôsobom funkcia loadCharacters dáta získava.

Čo teraz s tým? Môžeme skúsiť aplikovať refaktorovacie kroky odznova na funkciu getSortedCharacters, čo nás zrejme privedie k poznaniu, že musíme nanovo premyslieť architektúru. Tento spôsob nemusí byť zlý, ale ukážeme si, že pre spĺňanie OCP princípu nám častokrát stačí dosiahnuť potenciál a nemusíme ho hneď využiť.

Ten potenciál je v tom, že chceme, aby sme funkcii loadCharacters mohli zvonka povedať URL, z ktorej má načítať údaje. Dôlezité je slovíčko mohli. Na dosiahnutie tohto nám úplne postačí dať parametru url defaultnú hodnotu. Výsledný kód vyzerá takto:

export async function getSortedCharacters() {
    const characters = await loadCharacters();

    return sortCharactersByRole(characters);
}

async function loadCharacters(url = '/api/list.json') {
    const result = await fetch(url);

    return (await result.json()) as Promise<Character[]>;
}

A toto je dostatočne dobré. Funkcia loadCharacters spĺňa OCP a keďže sme do getSortedCharacters nezasahovali, tak sa nad ňou nemusíme zamýšľať.

Defaultný parameter je celkom pekné riešenie pre túto situáciu, v iných však nemusí byť dostatočné. Pokiaľ by vás zaujímali iné spôsoby, tak by ste mohli skúsiť:

  • uložiť URL do enumu, konštanty alebo zoznamu URL v nejakom konfiguračnom súbore
  • modul (súbor) bude mať privátnu premennú držiacu URL a bude exportovať funkciu na jej nastavenie z vonka
  • loadCharacters si URL vypýta z nejakého "registra"

Zhrnutie

Open-Closed Principle nám vraví, že je dobré písať kód tak, aby bol otvorený pre rozširovanie funkcionality, ale uzatvorený pre zmeny kódu.

Na jednom príklade sme si ukázali jednu z najčastejších situácií, kedy je princíp porušený a postupným refaktoringom sme dosiahli spĺňanie princípu.

Je dôležité si uvedomiť, že nie je možné dosiahnuť taký stav kódu, kde nemusíme robiť žiadne zmeny a všetko bude možné urobiť len úpravou konfiguračných hodnôt. OCP teda nie je možné aplikovať absolútne. Limitom je pre nás zodpovednosť upravovanej jednotky. Pokiaľ sa táto zodpovednosť nemení, tak by dosiahnutie OCP nemalo byť zložité. Preto je zároveň dôležité, aby sme spĺňali princíp jedinej zodpovednosti.