Tekst: Jakub Bigora
* akronim w poniższej grafice stworzyłem na potrzeby tekstu i nie ma on żadnego związku z nazwą popularnej sieci handlowej
Dobry programista wie, że oprócz kompilatora kod czytają ludzie. Kod, który piszecie, to też list do przyszłości, a ona znajdzie czas, by Was ocenić.
Programowanie jest podobne do budowy domu. Im dalej zagłębiamy się w oba procesy, tym częściej zauważamy, że pewne rzeczy moglibyśmy zrobić inaczej, ale fundament czyli to, co zrobiliśmy dotychczas, teraz ogranicza nasze możliwości. Nie da się przecież niczego zmienić bez poważnej przebudowy, poświęcenia dodatkowego czasu, a więc podniesienia kosztów. Podobnie jest z poprawkami kodu. Poświęcenie procesowi projektowania dużej ilości czasu nigdy nie gwarantuje jakości. Realnie bowiem każda aplikacja otrzymuje kilka dodanych „szybciej niż inne”, słabszych jakościowo poprawek i zmian. To, że wymagania klienta zostały szybko spełnione, zadowala kierownictwo, ale wykonawcy, znajdując ślady przeszłości, muszą w przyszłości tłumaczyć, dlaczego pewne, działające już rozwiązania zrefaktoryzowali lub, co gorsza, zaprogramowali od podstaw. Przed taką sytuacją chroni nas code review i kilka akronimów będących stałymi bywalcami TOP10 pytań dla seniorów. Zaczynajmy!
Zacznijmy od SOLID czyli 5 zasad solidnego fundamentu Wujka Boba bo taki pseudonim miał Robert Cecil Martin – programista formułujący ten akronim. Postaram się rozwinąć go podając najprostsze, najbardziej zrozumiałe wytłumaczenia.
S: Single Responsibility Principle (SRP) – Zasada pojedynczej odpowiedzialności
Każda, zaprojektowana przez nas klasa powinna brać odpowiedzialność np. tylko za jedną składową procesu obróbki informacji czyli mówiąc najprościej ‘robić jedną rzecz’. Jeżeli w naszym kodzie zajmujemy się analizowaniem, sortowaniem i drukowaniem dokumentów to w klasie drukującej – nie liczymy i nie sortujemy. Jeżeli występuje błąd w module drukowania naszego systemu, poprawiając go powinniśmy poruszać się tylko w obrębie klasy związanej z drukowaniem.
Przykład naruszenia:
public class DocumentProcessor {
public void analyze() {
// analiza dokumentu
}
public void print() {
// drukowanie dokumentu
}
}
Poprawiony kod:
public class DocumentAnalyzer {
public void analyze() {
// analiza dokumentu
}
}
public class DocumentPrinter {
public void print() {
// drukowanie dokumentu
}
}
W ten sposób jeśli występuje błąd w module drukowania, poprawiamy tylko klasę DocumentPrinter
, bez ingerencji w inne klasy.
O: Open/Closed Principle (OCP) – Zasada otwartości na rozszerzenia
Kod powinien być otwarty na rozszerzenia, ale zamknięty na modyfikacje. Oznacza to, że rozszerzać funkcjonalność powinniśmy poprzez dziedziczenie lub kompozycję, zamiast modyfikować istniejący kod.
Przykład naruszenia:
public class PaymentProcessor {
public void process(String paymentType) {
if (paymentType.equals("credit_card")) {
processCreditCard();
} else if (paymentType.equals("paypal")) {
processPayPal();
}
}
private void processCreditCard() {
// obsługa karty kredytowej
}
private void processPayPal() {
// obsługa PayPal
}
}
Poprawiony kod:
Dzięki takiemu podejściu dodanie nowej metody płatności nie wymaga modyfikacji istniejącego kodu.
public interface PaymentProcessor {
void process();
}
public class CreditCardPayment implements PaymentProcessor {
@Override
public void process() {
// obsługa karty kredytowej
}
}
public class PayPalPayment implements PaymentProcessor {
@Override
public void process() {
// obsługa PayPal
}
}
L: Liskov Substitution Principle (LSP) – Zasada podstawienia Liskov
Litera L pochodzi od nazwiska Pani Barabry, która sformułowała zasadę podstawienia dobrze znaną każdemu programiście posługującego się listą. Deklarując listę List możemy podstawić pod nią ArrayList ponieważ wskaźnik do obiektu podstawowego może także pokazywać na obiekt pochodny. Pamięć o tej zasadzie pozwala nam np. na dostarczanie szerszej funkcjonalności przy użyciu tego samego gdyż metoda przyjmująca typ Collection może przyjmować dowolną kolekcję (listę, set, kolejkę) i wykonywać na niej działania.
Przykład:
Jeśli metoda akceptuje obiekt klasy bazowej, powinna działać również dla obiektów klas pochodnych.
public class Bird {
public void fly() {
// latanie
}
}
public class Sparrow extends Bird {
@Override
public void fly() {
System.out.println("Sparrow flying");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
Penguin nie może być traktowany jako typ Bird
, bo narusza kontrakt metody fly
. Lepszym rozwiązaniem byłoby wydzielenie interfejsu:
public class Bird {
// wspólne cechy ptaków
}
public interface FlyingBird {
void fly();
}
public class Sparrow extends Bird implements FlyingBird {
@Override
public void fly() {
System.out.println("Sparrow flying");
}
}
public class Penguin extends Bird {
// brak implementacji fly, bo pingwin nie lata
}
I: Interface Segregation Principle (ISP) – Zasada segregacji interfejsów
Interfejsy powinny być małe i konkretne. Duże interfejsy są trudne w utrzymaniu i zmuszają klasy do implementacji metod, których nie potrzebują.
Przykład:
public interface Worker {
void work();
void eat();
}
public class Robot implements Worker {
@Override
public void work() {
// robot może pracować
}
@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat");
}
}
Poprawiony kod:
public interface Worker {
void work();
}
public interface Eater {
void eat();
}
public class HumanWorker implements Worker, Eater {
@Override
public void work() {
// człowiek pracuje
}
@Override
public void eat() {
// człowiek je
}
}
public class Robot implements Worker {
@Override
public void work() {
// robot pracuje
}
}
D: Dependency Inversion Principle (DIP) – Zasada odwrócenia zależności
Moduły wysokopoziomowe nie powinny być zależne od modułów niskopoziomowych. Oba powinny zależeć od abstrakcji.
Przykład naruszenia:
public class ReportGenerator {
public void generate() {
PDFPrinter printer = new PDFPrinter();
printer.print();
}
}
Poprawiony kod:
public interface Printer {
void print();
}
public class PDFPrinter implements Printer {
@Override
public void print() {
System.out.println("Printing PDF");
}
}
public class ReportGenerator {
private final Printer printer;
public ReportGenerator(Printer printer) {
this.printer = printer;
}
public void generate() {
printer.print();
}
Kolejnymi, równie ważnymi akronimami dotyczącymi dobrego kodu są:
DRY (Don’t Repeat Yourself) – powtarzanie podobnych fragmentów kodu to często podstawowy błąd, który w późniejszym rozwoju generuje koszty – refaktoryzujemy jeden fragment, pozostawiając drugi, bliźniaczy, w innym miejscu programu. Zamiast kopiować należy pamiętać by wydzielać wspólną logikę do funkcji lub metody używając dziedziczenia, interfejsów lub kompozycji. Warto także pamiętać, że wzorce, takie jak Template Method, Strategy czy Decorator, także pomagają wyeliminować powtarzającą się logikę oraz – o czym zapomina większość – pisanie takich samych funkcji testowych także narusza zasadę DRY.
Oprócz braku powtarzalności warto także pamiętać by nie tworzyć kodu, który nie będzie używany – w myśl zasady YAGNI (You Aint Gonna Need It) czyli Nie potrzebujesz tego == ‘nie dodawaj’ nie pozostawiamy ‘zaczątków’ nowych funkcjonalności, a także – by nie stosować optymalizacji i lepiej wyglądających form (jak np. wyrażeń lambda) w każdym możliwym miejscu, niejako ‘na pokaz’ – stosowanie się do zasady „pozostawiania kodu prostego w odbiorze” KISS (Keep it Simple Stupid) oszczędzi naszym następcom czasu i powiększających się zakoli w kolejnych sprintach.
U zupełnych podstaw leży także:
Odpowiednie nazewnictwo oraz pilnowanie długości metod
Klasy i metody powinny mieć odpowiednie nazwy.
Nazwy klas to zwykle rzeczowniki napisane w formacie PascalCase gdzie każde słowo rozpoczyna się wielką literą, bez spacji ani podkreśleń np. CustomerManager
.
Nazwy metod to czasowniki w stylu camelCase gdzie pierwsze słowo piszemy małą literą, kolejne wielkimi np. calculateTotal
.
W obu przypadkach używamy nazw, które mówią same za siebie i nie używamy skrótów, które mogą być źle zrozumiałe (dotyczy to także nazw argumentów). Nie obawiajmy się długich nazw – lepiej wiedzieć co dana metoda wykonuje już widząc jej deklaracje. Co do długości i wyglądu samej metody – przyjmuje się że nie powinna ona przekraczać 20 wierszy i 2 wcięć – wynikających ze stosowania instrukcji warunkowych. Nie powinna także przyjmować więcej niż trzech argumentów.
Jednolite formatowanie
Mieszanie różnych styli w zespole programistycznym nie jest wskazane. Zespół powinien ustalić jednolity standard autoformatowania na początku swojej pracy z projektem tak by nie dochodziło do commitowania plików bez realnych zmian.
Ogólne zasady dobrego kodu
Gdyby pomijając przedstawione powyżej, już sformuowane zasady dobry kod można by opisać jako kod, który cechuje:
- Poprawność: kod powinien działać prawidłowo niezależnie od rodzaju danych wejściowych, zarówno oczekiwanych, jak i nieoczekiwanych.
- Wydajność: kod powinien być efektywny pod względem zużycia pamięci i czasu, uwzględniając zarówno teorię (np. złożoność obliczeniową w notacji „O”), jak i praktyczne aspekty, takie jak czynniki wpływające na rzeczywiste działanie.
- Prostota: zadanie powinno być realizowane przy użyciu minimalnej liczby wierszy kodu
- Czytelność: kod musi być zrozumiały dla innych programistów napisany w sposób przejrzysty, unikając nadmiernie złożonych konstrukcji
- Łatwość konserwacji: kod powinien być prosty do rozbudowy zarówno dla autora, jak i innych osób w całym cyklu życia produktu.
Pisząc kod nie możemy zapominać o testach.
Pisanie testów zgodnie z zasadą FIRST
Zasada FIRST to akronim opisujący cechy dobrze zaprojektowanych testów jednostkowych. Przypomnijmy znaczenie każdej z liter.
Fast (Szybkie): Testy powinny działać błyskawicznie, aby mogły być uruchamiane regularnie. By to uzystkać należy minimalizować zależności od zewnętrznych systemów.
Independent (Niezależne): Każdy test powinien być samodzielny, a więc nie wpływać na inne (mieć niezależne dane wejściowe oraz nie współdzielić zasobów)
Repeatable (Powtarzalne): Wyniki testów muszą być przewidywalne i identyczne w każdych warunkach.
Self-validating (Samoweryfikujące się): Test powinien sam jednoznacznie wskazywać sukces lub porażkę.
Timely (Na czas): Testy należy pisać odpowiednio wcześnie, najlepiej przed implementacją kodu (TDD).
Pamiętając o powyższym możesz znacząco poprawić jakość swojego kodu i całego oprogramowania.
Zakończenie
Przestrzeganie zasad SOLID, DRY, YAGNI i KISS oraz stosowanie ogólnie przyjętych konwencji nazw czy dodawanie łatwo rozwijalnych testów to nie tylko recepta na lepsze oprogramowanie – to inwestycja w przyszłość projektów i zespołów. Te proste, ale potężne reguły sprawiają, że programowanie przestaje być chaotycznym rzemiosłem, a staje się przemyślaną sztuką – kod staje się czytelniejszy, bardziej niezawodny i gotowy na zmiany.
W świecie, gdzie technologie zmieniają się z dnia na dzień, warto budować na fundamentach, które nigdy nie tracą na wartości. Trzymając się tych zasad tworzysz rozwiązania, które wytrzymają próbę czasu, a to ważne, w dzisiejszym, dynamicznym świecie.