Wyjątki oraz checked vs unchecked

Wyjątki

Prawdopodobnie już od samego początku przygody z programowaniem masz jakąś styczność z wyjątkami – możliwe, że osobiście ich nie używasz, ale tak naprawdę nie raz zaznaczyły swoją obecność podczas działania aplikacji.
Bo w końcu, czym jest wiadomość w konsoli np. na temat NullPointerException?
A może widziałeś kiedyś wiadomość z nagłówkiem ArrayIndexOutOfBoundsException?
Wymieniłem właśnie nazwy dwóch z wielu innych popularnych wyjątków wbudowanych w Javę, zastanówmy się teraz: kiedy zauważałeś ich obecność?
Nie musisz się trudzić – pomogę. Ich oddech mogłeś poczuć, gdy coś poszło nie tak w czasie działania aplikacji – np. wyjątek ArrayIndexOutOfBoundsException jest rzucany, gdy próbujesz dostać się do komórki tablicy, która nie istnieje (indeks ujemny lub większy niż rozmiar tablicy)

Czym są wyjątki?

Skoro masz już krótkie wprowadzenie do wyjątków to podsumujmy sobie to, co napisałem wyżej oraz dodajmy do tego kilka nowych informacji.
Wyjątki – exceptiony – służą do zaznaczenia, że coś podczas działania programu poszło nie tak – wystąpiła sytuacja nieprzewidzania, nieoczekiwana.
Taką sytuacją może być:

  • nie podanie wartości w polu wymaganym przez użytkownika,
  • próba przeczytania nieistniejącego pliku,
  • próba odczytania danych z poza zakresu tablicy.

W Javie wyjątki są tak naprawdę zwyczajną klasą, jedynym wymogiem zakwalifikowania klasy jako wyjątek jest rozszerzenie klasy Exception lub RuntimeException.

Rzucanie wyjątków

Napisałem już raz, że wyjątek został rzucony – zostańmy na chwilę przy tym określeniu.

Faktycznie wszystkie klasy będące wyjątkiem mogą zostać rzucone przy użyciu słowa kluczowe throw. Każde innej klasy nie będącej wyjątkiem nie można skomponować razem ze słówkiem throw. Nie i koniec.

Skoro coś możemy rzucić to logiczne jest to, że można je również złapać. I tak samo jest z wyjątkami, rzucamy je i łapiemy – choć nie jest to żaden wymóg.

Wymyślmy sobie prostą sytuację – próbujemy wczytać tekst z pliku. Na początek otwieramy plik i chcielibyśmy zacząć go czytać, jednak nie tak prędko – bo co, gdy plik nie istnieje? Prawdopodobnie zostanie rzucony wyjątek np. FileNotFoundException, który możemy złapać. Co możemy zrobić po złapaniu wyjątku?
Możemy poinformować użytkownika, że plik, który chce wczytać nie istnieje lub po prostu zwrócić pustego Stringa – zauważasz możliwości wyjątków?

Wystąpiła nieoczekiwana sytuacja – nie ma pliku ;( – nie chcemy, aby taka sytuacja przewróciła naszą aplikację do góry nogami, dlatego łapiemy odpowiedni wyjątek i odpowiednio reagujemy na taką sytuację, czyli możemy zwrócić pusty String lub poinformować użytkownika, że się gdzieś pomylił.

Mam nadzieję, że załapałeś już teorię wykorzystania wyjątków. O ile teoria jest prosta to skupimy się w dalszej części artykułu jak poprawnie stworzyć flow wyjątków w aplikacji i poprawnie z nich korzystać, aby nasza aplikacja była jak najmniej podatna na błędy.

Jak stworzyć wyjątek?

Znasz już wymóg, jaki musi spełnić klasa, aby została zakwalifikowana jako wyjątek. Jest on prosty, ale go powtórzę jeszcze raz – klasa musi rozszerzać klasę:

– Exception,

– RuntimeException.

Nic więcej nam nie potrzeba, dlatego stwórzmy sobie dwa wyjątki – pierwszy z nich niech będzie odpowiedzialny za informowanie o tym, że operacja na pliku nie mogła zostać wykonana:

public class FileOperationException extends Exception {
    public FileOperationException() {
    }

    public FileOperationException(String message) {
        super(message);
    }

    public FileOperationException(String message, Throwable cause) {
        super(message, cause);
    }
}

Kolejny będzie odpowiedzialny za informowanie o tym, że podany login już istnieje

public class UserLoginExistException extends RuntimeException {
    public UserLoginExistException() {
    }

    public UserLoginExistException(String message) {
        super(message);
    }
}

O ile tworzenie jest proste, to warto trzymać się jednej koncepcji.

Każda nazwa wyjątku niech kończy się sufiksem „Exception” – pomoże to zachować porządek w kodzie i patrząc na nazwę klasy będziemy od razu wiedzieć, że jest ona wyjątkiem.

Warto jest podkreślić, że wyjątki są zwykłą klasą dlatego możemy w niej tworzyć własne metody oraz pola.

Przykładem wykorzystania może być możliwość przekazania dokładnych informacji o błędzie – np. dokładną długość wpisanego tekstu i możliwość użycia tego po złapaniu wyjątku.

<konstruktor z tekstem i get length w exception>

Rodzaje wyjątków

Od samego początku możesz zastanawiać się, dlaczego możemy tworzyć wyjątki na dwa sposoby – rozszerzając klasę Exception lub RuntimeException. Różnica jest znacząca i jest to częste pytanie na rozmowach rekrutacyjnych – skup się w tym punkcie jeszcze bardziej. 😉

Wyjątki checked są to klasy, które rozszerzają klasę Exception. Jej główną zaletą (o tym jeszcze porozmawiamy) jest to, że ten wyjątek musi zostać zawsze obsłużony.

Mamy dwie możliwości – albo go złapać lub przerzucić do metody wyżej, o tym wszystkim za chwilę sobie powiemy.

Idźmy dalej, wyjątki unchecked to wyjątki, które powstały na bazie klasy RuntimeException i w przeciwieństwie do checked exception nie musimy ich nigdzie obsługiwać. Mogą one sobie „latać” jak chcą w obrębie naszych aplikacji, co może nastąpić i narobić nam sporo bałaganu, jeśli ich nie ogarniemy.

W kolejnych akapitach pokażę Ci jak poprawnie zarządzać wyjątkami unchecked, jednak wcześniej będziemy musieli poznać dogłębnie jak działa rzucanie oraz łapanie wyjątków.

Throw and catch!

Nadchodzi czas, w którym będziemy mieli trochę więcej kodu, ale teorii też nie zabraknie.

Na początek zacznijmy od tego jak w ogóle rzucić wyjątek – powinieneś już kojarzyć, że służy do tego słowo kluczowe throw.

throw new FileOperationException("File could not be found!");

Rzucanie wyjątków niczym się nie różni dla unchecked:

throw new UserLoginExistException("User with login="+login+"already exists!");

Skoro potrafimy już rzucać wyjątki to czas rozpocząć ich obsługę.

 

Do obsługi wyjątków wykorzystuje się blok try – catch – finally. Już tłumaczę, na czym on polega.

Wyróżniamy trzy bloki, w pierwszym bloku (try) powinien się znajdować kod, którego wywołanie może wygenerować nieoczekiwane sytuację. Taką sytuację może być próba otwarcia pliku – jeśli nie istnieje to zostanie rzucony wyjątek.

try {
    BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));
}

I o to nadchodzi blok catch – jest to blok, w którym definiujemy jaki wyjątek ma zostać złapany i co ma zostać zrobione, gdy złapiemy danych wyjątek. W przeciwieństwie do bloku try, bloków catch może być nieskończenie i każdy może się odnosić do w ogóle innego wyjątku. Dzięki takiej możliwości możemy całkiem inaczej zareagować na inne sytuacje awaryjne.

try {
    BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));
} catch (IOException e) {
    throw new FileOperationException(e.getMessage());
} catch (Exception anotherException) {
    anotherException.printStackTrace();
}

Dodatkowo możemy dołączyć blok finally, który jest wywoływany zawsze – niezależnie od tego czy zostanie rzucony wyjątek czy też nie. Kod z bloku finally zostanie wykonany zawsze – nawet jeśli wcześniej użyjemy return.

try {
    BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));

    String line = bufferedReader.readLine();
    while(line != null) {
        lines.add(line);
        line = bufferedReader.readLine();
    }

    bufferedReader.close();
    return lines;
} catch (IOException e) {
    throw new FileOperationException(e.getMessage());
} catch (Exception anotherException) {
    anotherException.printStackTrace();
    throw anotherException;
} finally {
    System.out.println("I tak się wykonam poimomo tego, że w try jest return!");
}

Zdarza się, że czasami w danej metodzie nie chcemy obsłużyć wyjątku checked – czyli złapać go złapać w try-catch – w takiej sytuacji możemy go przerzucić z metody przy użyciu słowa kluczowe throws użytego w sygnaturze metody.

void doSth(String fileName) throws Exception {
}

Przy takim zapisie, gdy zostanie rzucony dany wyjątek za obsługę tego wyjątku jest odpowiedzialna metoda wywołująca tą metodę – czyli ta „wcześniejsza”. 😉

Call stack

Skoro mówimy już o przerzucaniu wyjątków checked oraz o tym, że unchecked exception są rzucane, aż do momentu napotkania bloku try – catch to muszę Ci pokazać jak wygląda stos wywołań metod oraz jak się on zachowuje w przypadku rzucenia wyjątków.

Przypuśćmy, że mamy trzy zwykłe metody void – po kolei każda z nich wywołuje następna, ostatnia rzuca wyjątek checked.

public class MainCallStack {
    private static void metodaA() throws Exception {
        System.out.println("Rozpoczynam wykonywanie metody A...");
        metodaB();
        System.out.println("Zakończyłem wykonywanie metody A...");
    }

    private static void metodaB() throws Exception {
        System.out.println("Rozpoczynam wykonywanie metody B...");
        metodaC();
        System.out.println("Zakończyłem wykonywanie metody B...");

    }

    private static void metodaC() throws Exception {
        System.out.println("Rozpoczynam wykonywanie metody C...");
        throw new Exception();
    }

    public static void main(String[] args) throws Exception {
        metodaA();
    }

}

Gdy wywołamy metodę A to nasz stos wywołań będzie wygląda tak – każde następne wywołanie metody jest wrzucone na stos i są one wywoływane od samej góry, od samego końca.

W naszym przypadku, gdy wywołamy metodę A, ona wywoła metodę B zaś ona metodę C. Na stosie widać metodę C na samej górze – rozumiemy to tak: gdy zakończy się działanie metody C to zostanie wykonana metoda B, po zakończeniu B wywoła się metoda A.

A co w przypadku, gdy zostanie rzucony wyjątek w klasie C tak jak stało się to w naszym przypadku? Sprawdźmy działanie.

Rozpoczynam wykonywanie metody A...
Rozpoczynam wykonywanie metody B...
Rozpoczynam wykonywanie metody C...
W wywołaniu został rzucony wyjątek
Zakończyłem wykonywanie metody A...
java.lang.Exception
  at pl.maniaq.MainCallStack.metodaC(MainCallStack.java:24)
  at pl.maniaq.MainCallStack.metodaB(MainCallStack.java:17)
  at pl.maniaq.MainCallStack.metodaA(MainCallStack.java:7)
  at pl.maniaq.MainCallStack.main(MainCallStack.java:28)

Jak widzimy w ogóle nie wywołał się końcowy kod z metody B i A, ponieważ do jej całego wywołania nie doszło – rzucenie wyjątku spowodowało usunięcię metodyB z CallStacka. Wyrzucałoby kolejne metody ze stosu, aż do napotkania bloku try – catch, u nas zatrzyma się na metodzie A gdzie obsługujemy rzucony wyjątek.

Zróbmy podobne doświadczenie dla wyjątków unchecked – już powinieneś wiedzieć, że nie potrzebujemy słów throws w sygnaturach metod.

Wywołajmy ponownie metodę A i przeanalizujmy działanie.

Rozpoczynam wykonywanie metody A...
Rozpoczynam wykonywanie metody B...
Rozpoczynam wykonywanie metody C...
W wywołaniu został rzucony wyjątek
Zakończyłem wykonywanie metody A...
java.lang.RuntimeException
  at pl.maniaq.MainCallStackUnchecked.metodaC(MainCallStackUnchecked.java:24)
  at pl.maniaq.MainCallStackUnchecked.metodaB(MainCallStackUnchecked.java:17)
  at pl.maniaq.MainCallStackUnchecked.metodaA(MainCallStackUnchecked.java:7)
  at pl.maniaq.MainCallStackUnchecked.main(MainCallStackUnchecked.java:28)

I jak widzisz wyjątki unchecked działają tak samo jak checked – jedynie nie potrzebujemy zadeklarowanego przerzucania wyjątków z metod do metod używając słówka throws.

Działanie się nie zmieniło wyjątek unchecked nadal wyrzuca metody z CallStack aż do napotkania obsługi błędu.

Let’s try!

Już sporo wiesz o samych wyjątkach – w teorii i praktyce. Praktyki było mało, więc zróbmy sobie bardziej praktyczne zastosowanie.

Zbudujmy sobie klasę, która będzie umożliwiać wczytanie tekstu z pliku i automatycznie będzie wypisywała w różnych kolorach tekst w konsoli. Metoda, która do tego posłuży, będzie rzucała wyjątek checked – chcemy zaznaczyc użytkownikowi naszej małej biblioteki, że  coś naprawde poszło nie tak. 😉

package pl.maniaq.checked;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;

public class FileManager {
    Random random = new Random();

    public void displayColorTextFromFile(String fileName) throws FileOperationException {
        List<String> lines = loadFileLines(fileName);
        for (String line : lines) {
            line.chars()
                    .mapToObj(word -> (char) word)
                    .forEach(word -> displayColorWord(word));
            System.out.println();
        }
    }

    private void displayColorWord(char word) {
        ConsoleColor color = randomColor();
        System.out.print(color + String.valueOf(word));
    }

    private ConsoleColor randomColor() {
        int colorIndex = random.nextInt(ConsoleColor.values().length);
        return ConsoleColor.values()[colorIndex];
    }

    private List<String> loadFileLines(String fileName) throws FileOperationException {
        List<String> lines = new LinkedList<>();

        try {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));

            String line = bufferedReader.readLine();
            while(line != null) {
                lines.add(line);
                line = bufferedReader.readLine();
            }

            bufferedReader.close();
            return lines;
        } catch (IOException e) {
            throw new FileOperationException(e.getMessage());
        }
    }
}

Do tego jeszcze kolorki:

package pl.maniaq.checked;

public enum ConsoleColor {
    RED("\u001B[31m"),
    GREEN("\u001B[32m"),
    BLUE("\u001B[34m"),
    PURPLE("\u001B[35m"),
    YELLOW("\u001B[33m");

    private String color;

    ConsoleColor(String color) {
        this.color = color;
    }

    @Override
    public String toString() {
        return color;
    }
}

Oraz nasz wyjątek checked:

package pl.maniaq.checked;

public class FileOperationException extends Exception {
    public FileOperationException() {
    }

    public FileOperationException(String message) {
        super(message);
    }

    public FileOperationException(String message, Throwable cause) {
        super(message, cause);
    }
}

Następnie zbudujmy sobie prosty serwis, który umożliwia dodanie użytkownika, jednak przed samym dodanie musimy zrobić walidację podanych danych – nie możemy ufać użytkownikowi. 😉

  1. Gdy podane hasło będzie miało mniej niż 6 znaków rzucimy wyjątkiem TooShortPasswordException.
  2. Gdy login będzie istniał w bazie to zostanie rzucony wyjątek UserLoginExistException.
package pl.maniaq.unchecked;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

public class UserService {
    private List<String> users = new LinkedList<>(Arrays.asList("Pablo", "Domindo", "Godrto"));

    public void addUser(String user, String password) {
        validateUserExistence(user);

        validatePassword(password);

        validateUser(user);

        users.add(user);
    }


    private void validatePassword(String password) {
        if (password.length() < 6) {
            throw new TooShortPasswordException(String.format("Password: %s is too short.", password));
        }
    }

    private void validateUserExistence(String user) {
        if (users.contains(user)) {
            throw new UserLoginExistException(String.format("User: %s exists!", user));
        }
    }

    private void validateUser(String user) {
        if (user.length() < 3) {
            throw new TooShortUserNameException(String.format("User name: %s is too short", user));
        }
    }

}

Do tego jeszcze trzy wyjątki:

TooShortPasswordException

package pl.maniaq.unchecked;

public class TooShortPasswordException extends RuntimeException {
    public TooShortPasswordException() {
    }

    public TooShortPasswordException(String message) {
        super(message);
    }
}

TooShortUserNameException

package pl.maniaq.unchecked;

public class TooShortUserNameException extends RuntimeException {
    public TooShortUserNameException(String message) {
        super(message);
    }
}

UserLoginExistException

package pl.maniaq.unchecked;

public class UserLoginExistException extends RuntimeException {
    public UserLoginExistException() {
    }

    public UserLoginExistException(String message) {
        super(message);
    }
}

Wykorzystajmy sobie oba rozwiązania – jedno z wyjątkiem checked, a drugie z unchecked.

package pl.maniaq;

import pl.maniaq.checked.FileManager;
import pl.maniaq.checked.FileOperationException;
import pl.maniaq.unchecked.UserService;

public class Main {
    private static void testFileManagerWithTryCatch() {
        FileManager fileManager = new FileManager();

        try {
            fileManager.displayColorTextFromFile("fileManagerTest2.in");
        } catch (FileOperationException e) {
            e.printStackTrace();
        }
    }

    private static void testFileManagerWithThrows() throws FileOperationException {
        FileManager fileManager = new FileManager();

        fileManager.displayColorTextFromFile("fileManagerTest.in");
    }

    private static void testUserServiceWithoutTryCatch() {
        UserService userService = new UserService();

        userService.addUser("Kamil", "admin2");
        System.out.println("Utworzono Usera 'Kamil'");
    }


    private static void testUserServiceWithTryCatch() {
        UserService userService = new UserService();

        try {
            userService.addUser("adm", "qw");
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
    }

    private static void testUserServiceWithoutTryCatchWithPassedIncorrectData() {
        UserService userService = new UserService();

        userService.addUser("Pablo", "admin");
        System.out.println("Utworzono Usera 'Pablo'");
    }


    public static void main(String[] args) throws FileOperationException {
        System.out.println("Początek działania programu...");
        testFileManagerWithTryCatch();
        testFileManagerWithThrows();
        testUserServiceWithoutTryCatch();
        testUserServiceWithTryCatch();
        testUserServiceWithoutTryCatchWithPassedIncorrectData();
        System.out.println("Koniec działania programu...");

    }
}

Sprawdźmy jego wywołanie

Początek działania programu...
pl.maniaq.checked.FileOperationException: fileManagerTest2.in (No such file or directory)
  at pl.maniaq.checked.FileManager.loadFileLines(FileManager.java:48)
  at pl.maniaq.checked.FileManager.displayColorTextFromFile(FileManager.java:14)
  at pl.maniaq.Main.testFileManagerWithTryCatch(Main.java:12)
  at pl.maniaq.Main.main(Main.java:52)
Blog 1024kb.pl
File Manager v1.0
EXCEPTIONS POWER!!!
Utworzono Usera 'Kamil'
pl.maniaq.unchecked.TooShortPasswordException: Password: qw is too short.
  at pl.maniaq.unchecked.UserService.validatePassword(UserService.java:23)
  at pl.maniaq.unchecked.UserService.addUser(UserService.java:13)
  at pl.maniaq.Main.testUserServiceWithTryCatch(Main.java:36)
  at pl.maniaq.Main.main(Main.java:55)
Exception in thread "main" pl.maniaq.unchecked.UserLoginExistException: User: Pablo exists!
  at pl.maniaq.unchecked.UserService.validateUserExistence(UserService.java:29)
  at pl.maniaq.unchecked.UserService.addUser(UserService.java:11)
  at pl.maniaq.Main.testUserServiceWithoutTryCatchWithPassedIncorrectData(Main.java:45)
  at pl.maniaq.Main.main(Main.java:56)

Przeanalizujmy sobie powyższy rezultat z konsoli i sprawdźmy zalety wykorzystania obu wyjątków.

W klasie FileManager jest rzucany wyjątek checked FileOperationException, musimy obsłużyć taki wyjątek – czy tego przypadku nie można byłoby zastąpić wyjątkiem unchecked?

Niby mogę, ale chcę Ci pokazać dlaczego tego nie zrobiłem.

Tworzyłem klasęFileManager z myślą, że będą z niej korzystać obcy mi ludzie. Gdybym wykorzystał unchecked exception użytkownicy mojej biblioteki mogliby nie wiedzieć o wystąpieniu takiego wyjątkuFileOperationException co mogłoby ich zaskoczyć. W mojej implementacji nie zaskoczy ich ten wyjątek, ponieważ są zmuszeni go obsłużyć.

W drugim przypadku użyłem wyjątków unchecked choć sprawdziłyby się również tutaj wyjątki checked. Jest to dla mnie jednak gra wyborów – mógłbym skorzystać z checked, ale przez to każda metoda mająca styczność z tym serwisem w moim flow zabrudziłaby się przerzucaniem wyjątków – czyli throws.

Dlatego wybrałem wyjątki unchecked, gdzie nie muszę brudzić sobie sygnatur metod, jednak mamy inny problem. Musimy pamiętać o złapaniu tych wyjątków, kompilator za nas tego nie zrobi, dlatego łatwo zabrudzić sobie całe flow wywołań w aplikacji.

Dodatkowo zauważ, że ostatnie wywołanie metody testUserServiceWithoutTryCatchWithPassedIncorrectData i rzucenie przez nią wyjątku spowodowało całkowicie przerwanie dałszego działania aplikacji -> nie wyświetliło napisu:

Koniec działania programu...

Checked vs unchecked

Wyjątki bardzo się nie różnią, dlatego to od nas zależy jakich wyjątków będziemy używać. Ja korzystam z kilku prostych zasad, które przydają się w tworzeniu większych aplikacji:

– Prawie wszystkie wyjątki są unchecked,

– Wyjątków checked używam w sytuacjach, w których przypadku może wystąpić jakiś dziwny błąd np. podczas integracji z zewnętrzną aplikacją – o, którym ja i inni programiści powinni pamiętać i nie powinni go ignorować.

Tak jak wspominałem wykorzystanie wyjątków unchecked może zaburzyć flow wywołań, przez co później musimy się przez dłuższy czas debugować i śledzić przez co nasza aplikacja się wysypuje – w końcu nie mamy żadnej informacji o możliwości wystąpienia wyjątku w danej metodzie.

Dlatego od samego początku trzeba odpowiednio śledzić unchecked exception i zdecydować, w którym miejscu powinniśmy je złapać.

Podsumowanie

Postarałem się, aby pokazać Ci wachlarz możliwości jaką oferują wyjątki. Chcę, abyś pamiętał po tym wpisie jedną z rzeczy – wyjątki są mieczem obusiecznym, nie poprawne ich używanie może w ogóle pozbawić ich sensu użycia oraz mogą wprowadzić tylko chaos do flow aplikacji.

Możesz również zapamiętać kilka rad, które się mogą przydać podczas korzystania z wyjątków:

– Wyjątki niech będą rzucane tylko w przypadku wystąpienia nieoczekiwanej sytuacji, nigdy w przypadku, gdy takiego rezultatu oczekiwaliśmy

– Staraj się korzystać z wyjątków unchecked, ale pamiętaj o tym, aby w odpowiednim miejscu jest obsługiwać. Nie pozwól zawładnąć swoją aplikacją.

– Wyjątki mogą przechowywać dodatkowe informację o danym błędzie – wykorzystuj ten fakt np. HttpException może przechowywać w sobie status code błędu (np. 404, 500).

– Niech twoje wyjątki mają sufiks Exception.

– Nigdy nie zostawiaj pustego bloku catch – wtedy wyjątki są bezużyteczne. W większości przypadków wystarczy logowanie błędu, w innych może być wymagane zareagowanie w ustalony sposób.

– Nie twórz dużych bloków try. Jeśli blok try jest duży powinieneś zastanowić się nad rozbiciem tego np. na kilka metod.

– Możesz tworzyć wiele bloków catch i w definiować inne reakcję na inne wyjątki.

Dodatkowo o walce checked vs unchecked możesz przeczytać tutaj.

Kod z artykułu znajduję się w tym repozytorium.

Kamil Klimek

Od 2016 jestem programistą Java. Przez pierwsze 4 lata pracowałem jako Full Stack Java Developer. Później postanowiłem postawić nacisk na Javę, żeby jeszcze lepiej ją poznać.

Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x