DRY, YAGNI, KISS i inne. Uniwersalne zasady dla każdego programisty.
Te tajemnicze akronimy skrywają w sobie dekady doświadczeń naszych programistycznych poprzedników. Choć brzmi to nieco patetycznie, tak właśnie jest. Zasady, opisane w tym wpisie, to dziedzictwo wielu błędów, obserwacji i celnych spostrzeżeń, zbieranych latami przez bardzo doświadczonych ludzi dla ich następców. Te zasady działają jak sita. Przesiany przez nie kod staje się znacznie lepszy, bardziej czytelny, odporny na błędy i łatwiejszy w utrzymaniu.
Po co nam zasady?
Zasady w programowaniu wytyczają nam szlak - kierunek, w jakim powinniśmy podążać, aby uzyskać kod wysokiej jakości. Każda z poniższych reguł, o których wspominam w tym wpisie, dotyka nieco innych aspektów wytwarzanego przez nas oprogramowania. Tworzą one niejako pewien standard. Można oczywiście pisać kod i bez nich, tylko po co? DRY i inne zasady to ogrom doświadczenia naszych poprzedników, z którego powinniśmy korzystać, ucząc się na ich błędach i kto wie, może formułować nowe? Czemu by nie!
Co daje nam trzymanie się dobrych zasad programowania?
- Ułatwiają utrzymanie kodu.
- Zmniejszają ryzyko wystąpienia błędów.
- Umożliwiają dobrą współpracę między twórcami i odbiorcami kodu.
- Tworzą bardziej uporządkowany projekt.
- Pomagają podejmować odpowiedzialne decyzje.
A teraz przejdźmy już do konkretów.
DRY - jedno źródło informacji
DRY - Don’t Repeat Yourself - unikaj powielania wiedzy. Zasada ta jest prosta, jeśli widzisz w swoim kodzie ciągle powtarzające się linijki, to należy je odpowiednio nazwać i przenieść do funkcji lub metody, w zależności od kontekstu. Dzięki temu, gdy przyjdzie czas na zmianę (a z pewnością przyjdzie), będziemy musieli edytować tylko jedno miejsce, a nie wiele. Unikniemy sytuacji, gdy trzeba zmienić kod w kilku miejscach i o którymś zapomnimy. Daje nam to również możliwość zwiększenia czytelności naszego kodu. Nowa funkcja czy metoda musi mieć nazwę, warto, by była ona wyjaśniająca, tłumaczyła w krótki sposób, co robi wewnątrz.
Money calculateSalary(const Employee& employee)
{
if(18 <= employee.getAge())
{
//...
}
else
{
//...
}
}
int freeDays(const Employee& employee)
{
if(18 <= employee.getAge())
{
//...
}
else
{
//...
}
}
Widać tutaj powtórzenie, “18 <= employee.getAge()”. Jest to pewien element wiedzy biznesowej. Jeśli pracownik jest niepełnoletni, to jego wynagrodzenie i liczba dni wolnych jest inna. Spróbujmy to lepiej wyrazić i zastosować DRY.
bool isAdult(const Employee& employee)
{
const unsigned adultAge { 18 };
return adultAge <= employee.getAge();
}
Money calculateSalary(const Employee& employee)
{
if(isAdult(employee))
{
//...
}
else
{
//...
}
}
int freeDays(const Employee& employee)
{
if(isAdult(employee))
{
//...
}
else
{
//...
}
}
To tylko prosty przykład, jak możemy stosować DRY. Należy jednak pamiętać, że ta zasada, jak i wszystkie następne, mają nam pomagać tworzyć kod wysokiej jakości. Jak ze wszystkim, tak z DRY, też da się przesadzić. Zauważyłem, że często w unit testach DRY nie do końca się sprawdza.
Mamy klasę UserService, która odpowiada za logowanie (pomińmy na razie aspekty security ;) ).
class UserService
{
public:
bool login(const std::string& username, const std::string& password)
{
return username == "admin" && password == "1234";
}
};
void checkLogin(UserService& service,
const std::string& user,
const std::string& pass,
bool expected)
{
EXPECT_EQ(expected, service.login(user, pass));
}
TEST(UserServiceTest, AllLogins)
{
UserService service;
checkLogin(service, "admin", "1234", true);
checkLogin(service, "admin", "wrong", false);
checkLogin(service, "user", "1234", false);
}
W tym przykładzie testujemy różne sposoby logowania. Wygląda prosto. Nie ma powtórzeń, ale jest tutaj jeden zasadniczy problem - czytelność, a raczej jej brak. Mamy tylko jeden test sprawdzający tę samą jednostkę, ale już w różnych scenariuszach. Nie spełnia on standardu AAA. Gdy coś pójdzie nie tak, dużo trudniej będzie dowiedzieć się, który scenariusz nie działa poprawnie.
Teraz porównaj to z poniższymi testami.
TEST(UserServiceTest, login_ValidCredentials_ReturnTrue)
{
UserService service;
bool result = service.login("admin", "1234");
EXPECT_TRUE(result);
}
TEST(UserServiceTest, login_WrongPassword_ReturnFalse)
{
UserService service;
bool result = service.login("admin", "wrong");
EXPECT_FALSE(result);
}
TEST(UserServiceTest, login_UnknownUser_ReturnFalse)
{
UserService service;
bool result = service.login("user", "1234");
EXPECT_FALSE(result);
}
To klasyczny przykład, gdzie czytelność jest wyżej niż DRY. Don’t Repeat Yourself to świetna reguła. Pomaga uporządkować kod, ułatwia wprowadzanie zmian i zrozumienie logiki kodu. W testach również ma swoje zastosowanie, lecz nie powinna być regułą wiodącą w nich prym.
DRY to jednak coś znacznie więcej, niż tylko ograniczanie powielania kodu, jak dla mnie najlepiej opisuję tę zasadę Robert C. Martin w swojej bardzo znanej książce - Czysty Kod. Podręcznik dobrego programisty, którą z czystym sumieniem polecam ;)
YAGNI - potrzebne ponad możliwe
YAGNI - You Aren’t Gonna Need It, ta zasada mówi o tym, że jeśli w danym momencie rozwoju oprogramowania nie potrzebujesz jakiejś funkcjonalności, to jej nie dodawaj. Czasem chcemy wychodzić naprzeciw oczekiwaniom użytkowników naszego kodu, lub staramy się przewidzieć, co jeszcze będzie potrzebne. Zapominamy jednak o czymś bardzo istotnym. Założenia się zmieniają. To, co wydawało nam się być potrzebne, choć nie planowane, po chwili może wylądować w gitowej historii.
W tej regule nie chodzi o to, by nie myśleć o przyszłości, o architekturze. Projekt jest ważny, umożliwia rozwój oprogramowania w jasno określonym kierunku i określony sposób. Bierze pod uwagę różne aspekty, takie jak elastyczność, łatwość utrzymania, koszty wytworzenia i wiele innych.
YAGNI nie mówi o tym, byś nie planował, nie przewidywał. Mówi o tym, byś nie implementował czegoś, co może okazać się niepotrzebne.
Wróćmy do UserService. Założenie jest proste, klasa odpowiada za logowanie. Można by przewidzieć, co będzie jeszcze potrzebne do logowania i nasza klasa rozrośnie się.
class UserService
{
public:
bool login(const std::string& username, const std::string& password)
{
return username == "admin" && password == "1234";
}
void logout() {}
void resetPassword() {}
void twoFactorAuth() {}
};
Tylko na tym etapie nie wiemy jeszcze, co tak naprawdę się przyda. Jak mamy zastosować TDD, gdy brak jest założeń? Co zrobić z kodem, którego nikt nie używa?
Nie traćmy czasu i zasobów na tworzenie kodu, którego nikt w danym momencie nie potrzebuje. Ogranicza to również koszty, które taki nadmiarowy kod generuje. Trzeba do niego napisać testy (choć w przykładzie dodaliśmy tylko puste metody, więc testów nie napiszemy), utrzymywać, aktualizować, gdy interfejs się zmienia. To są koszty, koszty, które ponosisz Ty, Twój zespół i Twoja firma. Ogranicz zakres zmian do minimum. YAGNI to istota minimalizmu w programistycznym świecie.
KISS - prosto ale skutecznie
KISS - Keep It Simple, Stupid, dość wymowna nazwa. Kultura w branży IT wydaje mi się na całkiem wysokim poziomie, i nikt raczej nie wyzywa nikogo od idiotów :). Zasada ta mówi o tym, aby nie dodawać nadmiernej złożoności do naszego kodu. Kod prosty, to taki kod, który nie tylko łatwo napisać, ale przede wszystkim zrozumieć i zmienić, gdy będzie to potrzebne. Prosty kod też łatwiej się testuje.
Tutaj chcę zaznaczyć, że sama złożoność kodu nie jest zła, jeśli wynika ze złożoności problemu, który rozwiązuje. KISS trochę łączy się z YAGNI, bo możemy dodać więcej kodu, tworząc bardziej elastyczne rozwiązanie, potencjalnie łatwiejsze w rozszerzaniu. Może stosując jakiś wzorzec projektowy.
Tylko decyzja o użyciu wzorca powinna być podejmowana na poziomie architektury. Nie mówię tu o tym, żeby najpierw mieć cały projekt, a potem kod. W Agile tak się nie dzieje. Całe oprogramowanie tworzymy przyrostowo. Mówię o tym, by nie komplikować kodu bez potrzeby i bez planu.
Spójrz na poniższy przykład. Widać w nim pewien zamysł, może plany na przyszłość. Jednak kod jest zbyt zawiły w stosunku do tego, za co jest odpowiedzialny.
class IValidator
{
public:
virtual ~IValidator() = default;
virtual bool validate(const std::string& value) const = 0;
};
class EmailValidator : public IValidator
{
public:
bool validate(const std::string& value) const override
{
return value.find('@') != std::string::npos;
}
};
class PasswordValidator : public IValidator
{
public:
bool validate(const std::string& value) const override
{
return 8 <= value.size();
}
};
class UserService
{
public:
UserService(std::unique_ptr<IValidator> inputEmailValidator,
std::unique_ptr<IValidator> inputPasswordValidator)
: emailValidator(std::move(inputEmailValidator)),
passwordValidator(std::move(inputPasswordValidator))
{}
bool registerUser(const std::string& email, const std::string& password)
{
return emailValidator->validate(email) && passwordValidator->validate(password);
}
private:
std::unique_ptr<IValidator> emailValidator;
std::unique_ptr<IValidator> passwordValidator;
};
Widać tutaj zdecydowany overengineering. Tyle konstrukcji tylko po to, by sprawdzić dwa proste warunki. Z KISS kod wyglądałby mniej więcej tak.
class UserService
{
public:
bool registerUser(const std::string& email, const std::string& password)
{
if (email.find('@') == std::string::npos)
return false;
if (password.size() < 8)
return false;
return true;
}
};
Zdecydowanie, w takim przypadku prostota wygrywa. KISS pomaga nam trzymać w ryzach złożoność naszego kodu.
KISS to świetna zasada w połączeniu z TDD. Każda zmiana w kodzie ma sprawić by nowy test przeszedł, nic więcej. TDD zakłada właśnie to, żeby tworzyć kod, który tylko sprawi, że nasz nowy test przejdzie i nie wprowadzi regresji do poprzednich.
Zasada KISS łamana jest najczęściej w trzech przypadkach:
-
Przedwczesna optymalizacja - stosujemy sztuczki w kodzie, które potencjalnie mogą zwiększyć wydajność kodu. Sprawić, że będzie on działał szybciej. Jednak praktycznie nigdy się tego nie mierzy, a sama optymalizacja jest tak naprawdę znikoma lub pozorna. Kompilator nie raz jest w stanie czysty kod lepiej sam zoptymalizować niż z naszymi “sprytnymi” sztuczkami.
-
Nadużywanie wzorców projektowych - sam się na tym złapałem kilka lat temu, gdy poznałem wzorce. Chęć ich wykorzystania była tak duża, że przy jednym z zadań rekrutacyjnych od razu chciałem zastosować fabrykę, a wystarczyłby jeden prosty if.
-
Magia w kodzie - metaprogramowanie, refleksje, wiele poziomów abstrakcji. Te wszystkie rzeczy mają swoje zastosowanie, ale są to narzędzia do konkretnych celów. Nie należy ich stosować wszędzie, gdzie popadnie, bo akurat nam pasuje.
Jak stosować KISS w codziennej pracy? Najpierw zrób tak, aby działało, potem uprość kod, a na samym końcu optymalizuj, jeśli jest to potrzebne.
POLA - to oczywiste!
POLA - Principle Of Least Astonishment, to nic innego jak zasada najmniejszego zaskoczenia. Gdy widzimy nazwę funkcji lub metody klasy, która mówi A, to powinna robić A, a nie ABC plus jeszcze Z. Albo w ogóle nie robi A, tylko F. W tej zasadzie musimy pamiętać, że tworząc kod, należy dobrze opisywać, co on robi, poprzez nadawanie odpowiednich nazw zmiennym, stałym, metodom, funkcjom itp. Kluczem do zachowania POLA jest dobre nazewnictwo. Zwiększa ono czytelność i łatwość zrozumienia naszego kodu, a to bezpośrednio przekłada się na niższy koszt jego utrzymania. Pamiętajmy, że kod zazwyczaj piszemy raz, czytamy natomiast wielokrotnie.
POLA mówi o tym, by nasz kod, był intuicyjny i spójny w zachowaniu. Jeśli metody naszej klasy robią coś innego niż to, na co wskazuje ich nazwa, łatwo popełnić błąd, trudniej korzystać z takiej klasy, jest to bardziej czasochłonne, bo musimy zapoznać się z jej implementacją. Naruszenie tej zasady bywa nieraz bardzo subtelne, dlatego tym bardziej powinniśmy o niej pamiętać.
Mamy tutaj klasę FileWriter z jedną metodą write. Pozornie wszystko wygląda ok.
class FileWriter
{
public:
void write(const std::string& path, const std::string& data)
{
std::ofstream file(path);
if (!file.is_open())
{
std::filesystem::create_directories(std::filesystem::path(path).parent_path());
std::ofstream retry(path);
retry << data;
return;
}
file << data;
}
};
Metoda write próbuje otworzyć podany w path plik. Jeśli się nie uda, utworzy go wraz z wszystkimi katalogami prowadzącymi do pliku. Tylko czy ta metoda powinna to robić? Co jeżeli, ktoś w path popełni tylko literówkę? Nazwa write nie mówi o tym, że metoda coś tworzy. Jak w takim razie powinna wyglądać?
class FileWriter
{
public:
void write(const std::string& path, const std::string& data)
{
std::ofstream file(path);
if (!file.is_open())
{
throw std::runtime_error("Cannot open file: " + path);
}
file << data;
}
};
Myślę, że rzucenie wyjątku jak najbardziej jest dobrym rozwiązaniem, jeżeli tylko nasz projekt zakłada ich użycie. Jeśli chcesz poznać więcej szczegółów dotyczących sytuacji wyjątkowych, to odsyłam do mojego wpisu o wyjątkach.
W takiej formie, użytkownik klasy FileWriter nie powinien być zaskoczony wyrzuceniem wyjątku, gdy poda złą ścieżkę do pliku, zwłaszcza, iż metoda write nie jest oznaczona jako noexcept.
LoD - im mniej wiesz, tym lepiej
LoD - Law of Demeter, czyli inaczej Principle of Least Knowledge. Zasada najmniejszej wiedzy mówi o tym, by metoda wykorzystywała tylko to, co sama “wie”.
Metoda w klasie powinna komunikować się tylko z obiektami, które zna bezpośrednio. Sprowadza się to do używania tylko własnych pól klasy, przekazanych argumentów, stworzonych przez tę metodę obiektów i ewentualnie elementów globalnych (tak, tak, te ostatnie to zazwyczaj oznaka problemów projektowych, choć nie zawsze. Może napiszę osobny post, jak radzić sobie z globalami w testach).
Przyjrzyjmy się metodzie sendReport klasy ReportService. Metoda ta korzysta z wszystkich rodzajów dostępnej wiedzy, zachowując jednocześnie LoD.
class Report
{
public:
std::string generate() const
{
return "Daily report data";
}
};
class EmailClient
{
public:
void send(const std::string& recipient, const std::string& content)
{
std::cout << "Sending email to " << recipient << " with content:\n"
<< content << "\n";
}
};
class Config
{
public:
static std::string getDefaultRecipient()
{
return "admin@example.com";
}
};
class ReportService
{
public:
void sendReport(const std::string& customRecipient = "")
{
// użycie argumentu metody
std::string recipient = customRecipient.empty()
? Config::getDefaultRecipient() // globalne źródło wiedzy
: customRecipient;
// stworzenie i użycie obiektu lokalnego
Report report;
std::string content = report.generate();
// wysłanie raportu przez własne pole emailClient
emailClient.send(recipient, reportTitle + "\n" + content);
}
private:
EmailClient emailClient;
std::string reportTitle { "Daily Report" };
};
W Law od Demeter chodzi o ograniczanie łańcuchów wywołań.
order.getCustomer().getAddress().getCity().getName();
Zamiast takiego łańcuszka powinniśmy dążyć do tego, aby klasa order udostępniła nazwę miasta klienta bezpośrednio.
order.getCustomerCityName();
Wewnątrz tej metody nie powinno być kolejnego, nieco krótszego łańcucha tylko coś w tym rodzaju.
std::string Order::getCustomerCityName() const
{
return customer.getCityName();
}
Zaletą stosowania LoD jest zmniejszenie sprzężenia między klasami. Poprawia się również czytelność kodu.
COI - niczym klocki Lego
COI - Composition Over Inheritance, dziedziczenie to potężny mechanizm niosący za sobą bardzo cenną mechanikę - polimorfizm. Jednak ma ono też drugie oblicze, łatwo można przesadzić. Hierarchia dziedziczenia powinna odzwierciedlać zależności typu “jest”, a nie “ma”. Jeśli klasa dziedziczy po innej tylko dlatego, że część jej funkcjonalności by się przydała, to należy dodać potrzebny obiekt jako nowe pole klasy zamiast po niej dziedziczyć.
Ta zasada nie mówi o tym, by z dziedziczenia nie korzystać, lecz o tym, by korzystać z niego mądrze. Kompozycja daje nam większą elastyczność kodu. Elementy, jako że są to pola klasy, można łatwo dodawać, usuwać czy wymieniać. Wewnętrzna implementacja klasy rodzica, może się zmienić i dużo łatwiej ta zmiana może negatywnie odbić się na naszej klasie, gdy po niej dziedziczy, niż gdy jest tylko polem.
Kompozycja ułatwia też testowanie naszej klasy. Tworząc testy, możemy łatwo zastąpić pola klasy mockami poprzez wstrzykiwanie zależności (Dependency Injection). W ten sposób będziemy mogli skupić się na przetestowaniu logiki tylko naszej klasy. W przypadku dziedziczenia, nie możemy już w tak łatwy sposób oddzielić logiki naszej klasy od logiki rodzica. Musimy nie jako przetestować całość, mimo iż klasa rodzica ma swoje testy.
Dziedziczenie dużo mocniej wiąże ze sobą klasy, co utrudnia ich ponowne użycie. Jeśli nie tworzą spójnej całości, może się okazać, że zamiast ponownie skorzystać z już napisanego kodu, musimy nie jako napisać go od nowa. Boleśnie się o tym przekonałem, gdy zacząłem pisanie swojej drugiej gry. W pierwszej, mocno korzystałem z dziedziczenia, co sprawiło, że nie mogłem w prosty sposób przenieść fragmentu kodu. Musiałbym przenieść kilka klas na raz. To doprowadziło do tego, że zamiast użyć kod ponownie, stał się on tylko przykładem, do którego zaglądam, implementując nową grę.
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw() = 0;
};
class Rectangle : public Shape
{
public:
void draw() override
{
std::cout << "Drawing rectangle\n";
}
};
class ColoredRectangle : public Rectangle
{
public:
ColoredRectangle(const std::string& inputColor)
: color(inputColor)
{}
void draw() override
{
std::cout << "Drawing " << color << " rectangle\n";
}
private:
std::string color;
};
Mamy tutaj interfejs Shape. Klasa Rectangle dziedziczy po interfejsie i to jest jak najbardziej ok. Problem zaczyna się w klasie ColoredRectangle, która dziedziczy po Rectangle. Co się stanie, jeżeli zaczniemy potrzebować klasy kwadratu z obramowaniem albo animacją? Idąc za dziedziczeniem, utworzymy BorderedRectangle i AnimatedRectangle. A jeśli będziemy potrzebować kolorowego kwadratu z obramowaniem? Kolejna klasa. A teraz dołóżmy trójkąt. Co wtedy? Triangle, ColoredTriangle, BorderedTriangle, AnimatedTriangle? Klasy mnożą się w zastraszającym tempie! Spróbujmy zastosować COI w tym przypadku.
class ColoredShape : public Shape
{
public:
ColoredShape(Shape& inputShape, const std::string& inputColor)
: shape(inputShape), color(inputColor)
{}
void draw() override
{
std::cout << "Drawing " << color << " ";
shape.draw();
}
private:
Shape& shape;
std::string color;
};
Teraz mamy ogólną klasę ColoredShape, niezwiązaną bezpośrednio z Rectangle. Obie klasy mogą być przetestowane osobno. Dodanie nowego kształtu sprowadza się do utworzenia tylko jednej klasy. Dodanie nowego atrybutu również. Oczywiście i z tym można by powalczyć, tworząc jeszcze bardziej uniwersalne rozwiązanie, ale to tylko przykład.
Podsumowanie
I to już wszystkie najważniejsze zasady programowania, które każdy programista powinien znać i stosować, aby jego kod był wysokiej jakości. Celowo nie poruszyłem tutaj zestawu zasad SOLID, gdyż one bardziej tyczą się projektowania. Przyjdzie i na to czas :). Tymczasem dziękuję Ci za dotrwanie do końca wpisu. Mam nadzieję, że przekazana tutaj wiedza pomoże Ci w codziennych bataliach z kodem. Jeśli masz pomysły na inne tematy, które mógłbym poruszyć na blogu - napisz w komentarzu.
Mój blog oparty jest na GitHub’ie, stąd trzeba się zalogować do niego, ale spokojnie, to jest odizolowany fragment strony, do którego nie mam bezpośredniego dostępu ;). Podobał Ci się ten wpis? Zapraszam do podzielenia się swoją opinią w komentarzu!
Autor: Tadeusz Biela
Programista C++ | Entuzjasta TDD | Fan unit testów
Zostaw komentarz