W miarę ostatnio na blogu
Holistycznie o inżynierii oprogramowania pojawił się post o TDD i czystym kodzie. Podstawowym założeniem było, że TDD nie ma wiele wspólnego z czystym kodem, że łączenie tych dwóch rzeczy przez Roberta C. Martina zwanego czasem Wujem Bobem jest manipulacją i próbą wylansowania się. I choć Wuj faktycznie jest medialny i ma parcie na LCD, to myślę, że autor jednak trochę się tu zagalopował. Pod koniec autor zamieścił co prawda wytłumaczenie o co mu chodziło w poście, ale cyniczne żarty w jego treści spowodowały, że czyta się go jak tyradę przeciw testom jednostkowym. Co więcej wskazującą na brak praktycznego doświadczenia w takim tworzeniu oprogramowania przez jej autora, więc niestety dla praktyki tej krzywdzącą.
A że internet to wolne medium, zdecydowałem się zamieścić własny pogląd na TDD i jego rolę w zapewnianiu czystości kodu, tyle, że bazującą na paroletnim doświadczeniu tworzenia w ten sposób oprogramowania.
Czysty kod to kod czytelny. Wprowadzony przez Donalda Knuth'a termin/idea/paradygmat
Literate Programming oznacza prawie to samo. Kod powinien dać się czytać jak literaturę (na tyle na ile to możliwe), powinien być jak najbardziej przejrzysty dla programisty pracującego z nim. Tylko wtedy będzie można zapewnić niski poziom błędów (wiemy co robi i rozumiemy jak to robi) i wysoką utrzymywalność (kod zrozumiały dla każdego kto go czyta). To oczywiście platoński ideał kodu, ale właśnie czysty kod, tak jak opisuje go Uncle Bob w książce
Clean Code zdaje się być temu ideałowi najbliższy (przynajmniej w javie). To kod, który mówi dokładnie co robi. W metodach wyższego poziomu (zwykle o szerszym dostępie) jest kod bardziej odpowiadający na pytanie "co?" a czym niżej, tym bardziej idzie w kierunku "jak?".
Czytelny kod rzadko powstaje przy pierwszym podejściu. To naturalne, że mamy jakiś pomysł - nasze przemyślenia i doświadczenie podpowiadają nam rozwiązanie i jest ono zwykle w mało czytelnej formie. Czasem jest to paręnaście/parędziesiąt linii odzwierciedlających nasz pomysł na rozwiązanie w formie jakiegoś algorytmu. I to jest nasze pole prób i błędów. Niestety bardzo często kod pozostaje na tym etapie. Tzn. działa, realizuje to o czym akurat myślał jego autor, ale nie komunikuje jasno do czego służy. Potem dodajemy kolejne metody i klasy współpracujące z tą pierwszą i tak powstaje nieczytelna aplikacja o rozmytych odpowiedzialnościach klas i metod.
Gdyby jednak nie pozostać na poziomie poligonu, tylko spojrzeć na działający kod pod kątem jego czytelności i nadać mu zrozumiałą formę, byłby on nie tylko poprawny, ale też utrzymywalny. Gdy metody mają 2-3 linijki (czasem może 5-7) dużo łatwiej o lepsze ich rozmieszczenie, o przydzielenie odpowiednim klasom, o naturalne wyłanianie się wzorców projektowych (np. strategia, dekorator czy mediator to wzorce, które same wyłaniają się przy poprawnym rozłożeniu odpowiedzialności). Nie mówię, że pojawią się nawet jak ich nie znamy (w końcu to nie czary), ale dostrzeżenie miejsca dla nich jest dużo prostsze. Stąd też refaktoryzacja jest zupełnie nieodzowną praktyką programistyczną. Uważam, że niemożliwe jest napisanie kodu (o realnej złożoności, nie akademickie przykłady) od razu"na czysto". Najpierw realizujemy więc wymaganie, a potem za pomocą refaktoryzacji (i niskopoziomowej i do wzorców projektowych) dochodzimy do czystego kodu.
Ale zaraz, kod który piszemy realizując jakąś funkcjonalność rzadko żyje sam sobie. Prawie zawsze wchodzi w interakcję z innymi komponentami. Metody nowych klas będą wołane z zewnątrz, same obiekty tworzone w różnych miejscach i na różne potrzeby. Jak więc stworzyć strukturę klasy tak, by była łatwa w użyciu? Jak osiągnąć jednoznaczność wywołań? Jak zapewnić, że nasz kod będzie wygodny w wykorzystaniu z zewnątrz? Hm. Najprościej zasymulować wykorzystanie. Wiemy czego chcemy od obiektów jakiejś klasy, więc to po prostu napiszmy. W ten sposób powstają przykłady.
Piszemy więc np.
email
.withTitle(someTitle)
.withBody(someTextInTheBody)
.shouldBeSentTo(recipient)
.withReplyToAddress(replyTo);
email.send();
Jakie są szanse utworzenia takiego API za pierwszym razem, bez tworzenia takiego przykładu? Oczywiście można pozostać przy setterach i getterach, ale o ile łatwiej taki kod czytać i zrozumieć o co autorowi chodziło!
A skoro już piszemy takie przykłady, to dlaczego nie napisać tego najpierw. Najlepiej rozpocząć myślenie o klasie właśnie przez pryzmat jej wykorzystania. Każde środowisko programistyczne pozwala automatycznie wygenerować puste definicje metod, więc kod kompiluje się od razu. A jak będziemy mieli gotowy szkielet klasy, wypełniamy ją kodem. W ten właśnie sposób prowadzimy projektowanie przykładami. Dzięki temu nasze klasy mają większe szanse na właściwe rozmieszczenie odpowiedzialności między nimi (bo przykłady definiują właśnie odpowiedzialności) a do tego metody publiczne od razu mają zrozumiałe nazwy. A po zakodowaniu ich "wnętrzności" jeszcze trochę refaktorowania i również wewnętrzna struktura klas będzie przejrzysta.
Teraz mamy już kod, który powstał dzięki przykładom. No to przykłady można wywalić i pisać nowe... Zaraz! A może za miesiąc będzie trzeba trochę to przerobić... Może będzie trzeba do emaila dodać pole BCC albo formatowanie HTML. To lepiej te przykłady zostawmy - przydadzą się za miesiąc. Dodamy nowe wywołania, zmienimy nazwy tych, które zmienią znaczenie (tak, to też refaktoryzacja). Takie przykłady będące na bieżąco są do tego dość dobrą dokumentacją tego co kod potrafi i jak to osiągnąć. Może za miesiąc do zespołu dojdzie nowy programista i z satysfakcją powiemy mu RTFE ;)
To jak już mamy te przykłady i utrzymujemy je na bieżąco, jak już definiują nam one wywołania, to dlaczego nie sprawdzać przy okazji czy te wywołania poprawnie działają? Wtedy gdy tylko ktoś zmieni implementację bez modyfikacji przykładu, ten przestanie odzwierciedlać rzeczywistość i automatycznie zgłosi to programiście. Co więcej pozwolą nam one weryfikować świadome zmiany w kodzie w przyszłości. W końcu gdy tylko będzie trzeba zmienić istniejącą funkcjonalność - powiedzmy wszystkich odbiorców maila z tej samej domeny umieścić w CC a z poza w BCC - przykłady nie spełniające tego wymagania zgłoszą nam błędy. Wystarczy je wtedy zaktualizować i kod dalej jest spójny. Albo lepiej - najpierw napisać przykład, który weźmie pod uwagę nowe zmiany, zweryfikuje poprawność ich implementacji, a potem już pewni poprawności uaktualnimy niedziałające przykłady.
I tak nasze przykłady stają się automatycznymi testami. W większości jednostkowymi, częściowo akceptacyjnymi, gdzieniegdzie integracyjnymi. W połączeniu ze środowiskiem ciągłej integracji dają z niczym nie porównaną pewność poprawności oprogramowania. Przez cały czas.
W ten sposób od kodu spisanego jak notatka, draft zawierający co prawda meritum, ale w niezrozumiałej dla nikogo oprócz autora formie, dochodzimy do programistycznego dzieła literackiego. Od cowboy-coding do metodycznego rozwijania oprogramowania. Od amatorskiego "żeby działało" do profesjonalnej pewności poprawności. To jest właśnie
clean code.
Mówiąc krótko: czysty kod jest wynikiem poprawnego stosowania TDD - definicji przykładu, potem implementacji a na koniec refaktoryzacji. TDD pewnie nie jest jedynym sposobem uzyskania tego efektu, ale nie spotkałem się przykładami dużych projektów o czystym kodzie tworzonych bez TDD.