Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

Mniej instrukcji warunkowych

Jak ulepszyć kod za pomocą obiektów i metod

Czy zdarza Ci się tworzyć wiele instrukcji warunkowych? Nie wiesz, jak skracać rozbudowane bloki else-if? Jeśli jedyne, co przychodzi Ci do głowy, to instrukcja switch – koniecznie przeczytaj ten artykuł i dowiedz się, jak ulepszać kod za pomocą obiektów!

Spis treści

 If, else-if i switch w JavaScript – jak uprościć kod?

Przyjmijmy, że mamy napisać prostą grę, w której dziecko odczytuje kolor i ma wskazać owoc lub warzywo, który jest właśnie tego koloru. Dla uproszczenia rozważmy trzy zestawy:

  • Czerwony: żurawina, wiśnia
  • Żółty: banan, melon
  • Zielony: ogórek, awokado

Za każdym razem, gdy dziecko kliknie dany produkt, sprawdzamy, czy należy on do wskazanej grupy. Na przykład:

  1. Wyświetlony kolor ➡ żółty
  2. Dziecko klika ➡ banana
  3. Sprawdzamy ➡ czy banan zawiera się w grupie żółtych produktów?
  4. Dajemy feedback ➡ tak, zwycięstwo

📍 Jeżeli chcesz testować poniższe przykłady na żywym kodzie, to zapraszam Cię do pobrania repozytorium color-game-before (działanie gry już teraz możesz sprawdzić tutaj).

 Pierwsze działające rozwiązanie

W programowaniu stworzenie działającego rozwiązania jest priorytetem. Możesz więc z czystym sumieniem napisać kod na piechotę – tak jak my robimy to poniższej z if-else – a potem dopiero go optymalizować.

Oczywiście jeśli miałbyś 15 kolorów i 100 produktów, to nie zachęcałbym Cię do pisania każdego warunku z osobna, lecz nadal mógłbyś stworzyć na podstawie tych danych kilka linijek.

Dzięki temu poznajesz działanie swojego programu i już na tym etapie możesz wychwycić pierwsze błędy, luki i powtarzające się schematy. Te ostatnie pomogą Ci w stworzeniu bardziej elastycznych rozwiązań.

Teoretycznie możemy więc stworzyć wiele warunków za pomocą if, else if oraz else, np.

function isProductCorrect(color, product) {
    if (color === 'red' && (product === 'cranberry' || product === 'cherry')) {
        alert('Hurray!');
    } else if (
        color === 'yellow' &&
        (product === 'banana' || product === 'melon')
    ) {
        alert('Hurray!');
    } else if (
        color === 'green' &&
        (product === 'cucumber' || product === 'avocado')
    ) {
        alert('Hurray!');
    } else {
        alert('Wrong answer');
    }
}

Program działa, osiągnęliśmy cel. Jednak jakie „zagrożenia” wiążą się z takim rozwiązaniem?

Przy rozbudowywaniu gry o kolejne kolory i produkty utoniemy w ilości danych: będzie je trzeba dopisywać w odpowiednich linijkach, mnożąc warunki. To nie jest dobre również dla wydajności aplikacji.

Przy okazji łamiemy zasadę DRY (Don't Repeat Yourself), ponieważ ciągle powtarzamy ten sam fragment kodu: alert('Hurray!').

 Porządkowanie kodu z instrukcją warunkową

Instrukcja switch

Czy z else-if mamy przerzucić się na instrukcję switch? Niekoniecznie – tam również będziemy mieć wiele linijek niepotrzebnego kodu. Spójrz na przykład poniżej:

function isProductCorrect(color, product) {
    switch (true) {
        case color === 'red' &&
            (product === 'cranberry' || product === 'cherry'):
            alert('Hurray!');
            break;
        case color === 'yellow' &&
            (product === 'banana' || product === 'melon'):
            alert('Hurray!');
            break;
        case color === 'green' &&
            (product === 'cucumber' || product === 'avocado'):
            alert('Hurray!');
            break;
        default:
            alert('Wrong answer');
    }
}

Takie dane najlepiej gromadzić w postaci tablic i obiektów, a potem korzystać z nich za pomocą odpowiednich, wbudowanych w JavaScript metod.

Zebranie danych w zmiennych i tablicach

Jak moglibyśmy uporządkować nasze dane? Być może zestawienie kolorów i produktów z początku tego artykułu już nasunęło Ci pewien pomysł.

Spróbujmy z tablicami:

function isProductCorrect(color, product) {
    const redProducts = ['cranberry', 'cherry'];
    const yellowProducts = ['banana', 'melon'];
    const greenProducts = ['cucumber', 'avocado'];

    if (color === 'red' && redProducts.includes(product)) {
        alert('Hurray!');
    } else if (color === 'yellow' && yellowProducts.includes(product)) {
        alert('Hurray!');
    } else if (color === 'green' && greenProducts.includes(product)) {
        alert('Hurray!');
    } else {
        alert('Wrong answer');
    }
}

Co zyskaliśmy?

  • większą przejrzystość danych – wyodrębniliśmy je do zmiennych przechowujących tablice, przez co łatwiej je odczytać
  • dane o produktach w jednym miejscu – jeśli będziemy zwiększać ilość warzyw i owoców, to wystarczy je dopisać do odpowiedniej tablicy
  • trochę bardziej uproszczoną funkcję – to, co wcześniej implementowaliśmy za pomocą operatora logicznego ||, teraz uzyskujemy za pomocą metody tablicowej .includes().

Czego nadal nie mamy? Optymalizacji dodawania nowych kolorów. Każdy nowy kolor to potrzeba stworzenia nowej zmiennej z tablicą produktów i dopisania warunku w funkcji isProductCorrect(). Tym samym nadal łamiemy zasadę DRY – nie pozbyliśmy się powtarzania alert('Hurray!').

Zebranie danych w obiekcie

Spróbujmy dopracować nasze rozwiązanie za pomocą obiektu.

function isProductCorrect(color, product) {
    const products = {
        red: ['cranberry', 'cherry'],
        yellow: ['banana', 'melon'],
        green: ['cucumber', 'avocado'],
    };

    if (products[color].includes(product)) {
        alert('Hurray!');
    } else {
        alert('Wrong answer');
    }
}

Wygląda to o wiele lepiej!

Co zyskaliśmy?

  • łatwość w odczytywaniu danych – proste nazwy: products zamiast trzech zmiennych oraz np. red zamiast redProducts, sprzyjają szybszej orientacji w kodzie
  • dane w jednym miejscu, w jednym obiekcie – to ułatwia zarządzanie aplikacją. W razie potrzeby możemy te dane z łatwością uzupełnić o nowe elementy, przesłać do bazy danych, przenieść do innego pliku czy zachować w LocalStorage
  • prostą instrukcję warunkową, której nie musimy modyfikować
  • łatwość obsługi kodu przez kogoś, kto go nie zna – wystarczy, że uzupełni obiekt products o kolejne kolory i owoce/warzywa
  • możliwość rozwoju aplikacji o dodatkowe funkcjonalności – mogłoby to być np. dodawanie przez użytkownika własnych zestawów kolorów i produktów. Po odebraniu tych danych po prostu uzupełnilibyśmy o nie obiekt products za pomocą nowej funkcji. Czy widzisz różnicę? O wiele trudniej byłoby umieścić w kodzie dane odebrane od użytkownika, gdybyśmy zostawili kod w postaci z pierwszego przykładu (if, else-if, else).

Zasada pojedynczej odpowiedzialności

Moglibyśmy zrobić jeszcze coś, dzięki czemu zaplusujemy w oczach rekrutera technicznego oraz sami ułatwimy sobie i naszym współpracownikom życie.

Zasada pojedynczej odpowiedzialności mówi o tym, by funkcja, klasa czy metoda odpowiadała za jedną rzecz. Przy okazji dobrze jest zadbać o adekwatne nazewnictwo funkcji i zmiennych.

Zwróć uwagę, że w tej chwili funkcja isProductCorrect() jest odpowiedzialna za wyświetlanie alertów, przy czym jej nazwa sugeruje, że powinniśmy spodziewać się zwrotu wartości true lub false. Nazwa funkcji to pytanie „Czy produkt jest poprawny?”, a odpowiedź to „tak” lub „nie”.

Zmodyfikujmy więc kod w taki sposób, aby funkcja isProductCorrect() rzeczywiście była odpowiedzialna tylko za sprawdzanie poprawności wyboru produktu i zwracała true lub false. Logikę wyświetlania alertów przeniesiemy do funkcji przypisanej do zdarzenia click (jak widzisz, nasłuchiwanie zdarzenia kliknięcia jest ustawione na listę produktów).

function isDOMContentLoaded() {
    const colors = ['red', 'green', 'yellow'];

    const randomColor = chooseRandomColor(colors);

    displayRandomColor(randomColor);

    const productsList = document.querySelector('.productsList');

    productsList.addEventListener('click', e => {
        const product = e.target.innerText;

        if (isProductCorrect(randomColor, product)) {
            alert('Hurray!');
        } else {
            alert('Wrong answer');
        }
    });
}

// (...)

function isProductCorrect(color, product) {
    const products = {
        red: ['cranberry', 'cherry'],
        yellow: ['banana', 'melon'],
        green: ['cucumber', 'avocado'],
    };

    return products[color].includes(product); //zwracamy true lub false
}

Gotowe: funkcja odpowiada za jedną rzecz i zachowuje się tak, jak sugeruje to jej nazwa.

Operator warunkowy zamiast instrukcji if-else

Co jeszcze możemy zrobić? Teoretycznie możemy skrócić instrukcję if-else za pomocą operatora warunkowego, lecz nie jest to niezbędne.

// instrukcja warunkowa if
if (isProductCorrect(randomColor, product)) {
    alert('Hurray!');
} else {
    alert('Wrong answer');
}

// operator warunkowy
isProductCorrect(randomColor, product)
    ? alert('Hurray!')
    : alert('Wrong answer');

📍 Cały kod po zmianach znajdziesz na repozytorium color-game-after.

 Wiele instrukcji warunkowych if – prosty kalkulator w JavaScript

 Pierwsza działająca wersja programu

Aby nie utrudniać zrozumienia kodu, nie zabezpieczałem kalkulatora przed błędnym wprowadzeniem danych (możesz więc dzielić przez 0 czy uzyskać wynik, nawet jeśli nie wpiszesz danych lub wpiszesz np. litery).

Skupmy się na działaniach:

  1. Użytkownik wpisuje liczby.
  2. Wybiera operację, jaką chce wykonać.
  3. Klika „Wykonaj” i otrzymuje wynik.

📍 Jeżeli chcesz testować poniższe przykłady na żywym kodzie, to zapraszam Cię do pobrania repozytorium calculator-before (jego działanie już teraz możesz sprawdzić tutaj).

Potrzebujemy więc logiki odpowiedzialnej za obliczenia. Musimy ją też uzależnić od wyborów użytkownika. Pierwsze działające rozwiązanie mogłoby wyglądać tak:

function getResult(e) {
    e.preventDefault();
    const number1 = Number(e.target.elements.number1.value);
    const number2 = Number(e.target.elements.number2.value);
    const operationType = e.target.elements.operation.value;

    let result;

    if (operationType === '+') {
        result = number1 + number2;
    }
    if (operationType === '-') {
        result = number1 - number2;
    }
    if (operationType === '*') {
        result = number1 * number2;
    }
    if (operationType === '/') {
        result = number1 / number2;
    }

    const resultEl = document.querySelector('.result');
    resultEl.innerText = result;
}

Jakie są minusy tego rozwiązania?

  • Tworzenie nowych działań dla kalkulatora wiązałoby się z wydłużaniem bloku instrukcji if.
  • Dobrą praktyką jest tworzenie takich funkcji, których nie musimy modyfikować, gdy zmieniamy, usuwamy czy uzupełniamy dane. Jak widzisz, teraz nie stosujemy się do tej praktyki – dopisywanie warunków to modyfikacja funkcji getResult().

 Optymalizacja kodu – rezygnacja z instrukcji warunkowych

W przykładzie z grą nasze dane umieściliśmy w obiekcie: właściwościom, czyli kolorom, przypisaliśmy wartości w postaci stringów – nazw owoców i warzyw. Czy możemy użyć obiektu w przypadku funkcji kalkulatora? Tak. Zdecydowanie uprości nam to funkcję getResult() i uczyni nasze rozwiązanie bardziej elastycznym.

Najpierw tworzymy obiekt o nazwie operations. Właściwościami są wartości inputów („przycisków” kalkulatora), a wartościami – nazwy funkcji, które wykonują obliczenia. Funkcje te zaraz napiszemy.

WAŻNE: Zwróć szczególną uwagę na to, że w obiekcie mamy tylko nazwy funkcji, a nie ich wywołanie, np. add().

const operations = {
    '+': add,
    '-': subtract,
    '*': multiply,
    '/': divide,
};

Ciała funkcji mamy tak naprawdę niemal gotowe. Wystarczy z naszych instrukcji warunkowych przenieść działania przypisywane do zmiennej result.

const operations = {
    '+': add,
    '-': subtract,
    '*': multiply,
    '/': divide,
};

function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

function multiply(a, b) {
    return a * b;
}

function divide(a, b) {
    return a / b;
}

Czas użyć powyższego kodu i usprawnić funkcję getResult(). Zauważ, że nie musimy teraz nawet korzystać z żadnej metody wbudowanej w JavaScript. Wystarczy, że odwołamy się do przechowywanej w obiekcie nazwy funkcji i tę funkcję wywołamy (stąd użycie nawiasów okrągłych i przekazanie argumentów).

Schemat ten wygląda tak:

obiekt[właściwość]()

W naszym przykładzie zastosowaliśmy go tutaj:

const result = operations[operationType](number1, number2);

Zobacz, jak bardzo skróciło to naszą funkcję getResult()! Cały blok instrukcji warunkowych zamieniliśmy na jedną linijkę kodu. Co więcej, linijka ta się nie zmieni, nawet jeśli rozbudujemy kalkulator o dodatkowe operacje!

const operations = {
    '+': add,
    '-': subtract,
    '*': multiply,
    '/': divide,
};

function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

function multiply(a, b) {
    return a * b;
}

function divide(a, b) {
    return a / b;
}

function getResult(e) {
    e.preventDefault();
    const number1 = Number(e.target.elements.number1.value);
    const number2 = Number(e.target.elements.number2.value);
    const operationType = e.target.elements.operation.value;
    const operationFunc = operations[operationType];
    
    if(typeof operationFunc === 'function') {
        const result = operationFunc(number1, number2);
        const resultEl = document.querySelector('.result');
        resultEl.innerText = result;
    } else {
        console.error('Selected operation is not a function')
    }
}

Do funkcji getResult() warto dodać jeszcze warunek sprawdzający, czy podana operacja jest funkcją. Jeśli właściwość obiektu z operacjami nie miałaby odpowiednika w postaci funkcji (bo np. inny programista dopisał właściwość, a zapomniał dodać funkcję), to w konsoli otrzymamy adekwatną informację o błędzie.

 Elastyczne rozwiązanie – łatwiejsze rozszerzanie kodu

Chcesz się przekonać, że nasze rozwiązanie stało się bardziej elastyczne? Dopisz operację wyznaczania reszty z dzielenia, czyli modulo (nie zapomnij uzupełnić o ten element pliku HTML). Widzisz? Nie musieliśmy modyfikować funkcji getResult()!

const operations = {
    '+': add,
    '-': subtract,
    '*': multiply,
    '/': divide,
    '%': modulo,
};

function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

function multiply(a, b) {
    return a * b;
}

function divide(a, b) {
    return a / b;
}

function modulo(a, b) {
    return a % b;
}

function getResult(e) {
    e.preventDefault();
    const number1 = Number(e.target.elements.number1.value);
    const number2 = Number(e.target.elements.number2.value);
    const operationType = e.target.elements.operation.value;
    const operationFunc = operations[operationType];

    if(operationFunc === 'function') {
        const result = operationFunc(number1, number2);
        const resultEl = document.querySelector('.result');
        resultEl.innerText = result;
    } else {
        console.error('Selected operation is not a function')
    }
}

📍 Cały powyższy kod po modyfikacjach znajdziesz na repozytorium calculator-after.

 

Wyjaśnienie: Jeżeli to nie są Twoje pierwsze kroki w JavaScripcie, to zapewne zauważasz, że moglibyśmy jeszcze bardziej usprawnić nasz kod. W tej chwili najistotniejsze jednak jest zrozumienie możliwości wykorzystania obiektów w radzeniu sobie z rozbudowanymi instrukcjami warunkowymi. Umieszczanie danych w obiektach to świetny wstęp do refaktoryzacji kodu oraz do klas w JavaScripcie i korzystania z bibliotek takich jak React.

 

Zobacz, jaką drogę przeszliśmy: od kodu nieczytelnego, rozbudowanego i mogącego sprawiać problemy przy rozszerzaniu implementacji, po kod czytelny, elastyczny i łatwy w obsłudze! Czy ulepszanie rozwiązania (refaktoryzacja) zawsze wiąże się ze skróceniem liczby linii kodu? Nie – zwykle istotniejsza jest czytelność i prostota (np. przeniesienie części logiki do osobnej funkcji). O tym przekonasz się w artykule o refaktoryzacji, który pojawi się niebawem!

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.