Programowanie “bez gadania”, czyli jak ułatwić pracę sobie i innym
W projektach programistycznych nierzadko pracujemy w zespołach i potrzebujemy komunikować się z innymi. Często nie zdajemy sobie jednak sprawy, że komunikacja ma miejsce nawet jeśli nie wypowiemy ani jednego słowa. W tym artykule chciałem zwrócić uwagę, gdzie porozumiewanie się bez rozmowy ma miejsce i co możemy zrobić, żeby było jak najkorzystniejsze dla nas, naszego zespołu, a w konsekwencji dla produktu, który razem tworzymy.
Spis treści
Architektura jako fundament współpracy
Wybór architektury to jedna z tych decyzji, która ułatwia lub wręcz automatyzuje podejmowanie wielu kolejnych. Kiedy umówimy się na określoną strukturę kodu, nie musimy przy implementowaniu każdej funkcjonalności na nowo zastanawiać się ani omawiać, w jaki sposób zaprogramować typowy element, jak nawigacja czy wyświetlanie widoku. Zmniejsza to również wysiłek potrzebny na zapoznanie się z kodem pozostałych członków zespołu, ponieważ wiemy, jak ułożone są typowe fragmenty implementacji i możemy poświęcić większą uwagę rdzeniowi analizowanego rozwiązania.
Dodatkową korzyścią jest to, że dalsze dyskusje na temat struktury kodu są bardziej skupione. Jeśli trafimy na implementację, która nie pasuje do żadnego ze zdefiniowanych przez architekturę elementów, najczęściej rozmowa sprowadza się do określenia jaki kształt powinien przyjąć brakujący element układanki oraz w jaki sposób powinien łączyć się z już istniejącymi.
Niektórzy uważają, że raz zdefiniowana architektura to świętość, której należy bezwzględnie przestrzegać. Z moich doświadczeń wynika jednak, że architektura może i wręcz powinna ewoluować wraz z projektem. Nie jesteśmy w stanie z góry przewidzieć jak będzie się zmieniała nasza aplikacja, a często zasady, które pomagają ją rozwijać na początku, nie odpowiadają złożoności, z jaką przychodzi nam się mierzyć w kolejnych miesiącach czy latach prac. Żeby jednak zmiany rzeczywiście przysłużyły się projektowi, trzeba zadbać o to, aby dokonywane były w porozumieniu z zespołem.
Celem doboru i rozwoju architektury moim zdaniem nie powinno być ustalenie rozwiązania doskonałego, ponieważ takie nie istnieje. Chodzi natomiast o to, aby uzgodnić podejście, które zaadresuje typowe problemy, da przestrzeń do tworzenia kolejnych funkcjonalności i które wszyscy w zespole zaakceptują. Jeśli na późniejszym etapie dostrzeżemy praktyczne wady przyjętego rozwiązania, możemy i powinniśmy go ulepszać.
Warto też pamiętać, że stosunkowo łatwo jest przekształcić jedną formę porządku w inną, natomiast uporządkowanie chaosu, gdzie każdy element aplikacji zaimplementowany jest w zupełnie inny sposób, wymaga już o wiele więcej wysiłku i często wiąże się też z większym ryzykiem.
Co w kodzie piszczy…
Kod i komentarze powinny odpowiadać na trzy podstawowe pytania:
- jak korzystać z naszego rozwiązania?
- co ono robi?
- dlaczego zdecydowaliśmy się na takie a nie inne podejście?
Duplikacja informacji kosztuje nas sporo czasu i wysiłku nie tylko na początku, ale również przy późniejszym utrzymaniu, dlatego starajmy się nie powtarzać i do przekazania odpowiedzi na każde z tych trzech pytań używajmy tylko jednego narzędzia.
Jeśli chodzi o wyjaśnienie co robi nasz kod, to najlepiej posłużyć się… samym kodem. Mam na myśli zastosowanie znanych porad mówiących o tym, żeby dzielić implementację na odpowiednio małe fragmenty, w zrozumiały sposób nazywać funkcje i zmienne oraz logicznie je grupować. Porady te, choć proste, często przysparzają nam trudności. Kiedy podzielimy już nasze rozwiązanie na mniejsze części, skąd mamy wiedzieć czy nie wymagają one dalszego podziału? Czy nasze czytelne nazwy rzeczywiście będą zrozumiałe dla innych? Pomocne w takich wypadkach jest zadanie sobie pytania czy dodanie komentarza, sprawi, że łatwiej będzie zrozumieć co robi nasza implementacja? Jeśli tak, to powinniśmy ponownie pochylić się nad jej formą. Często znajdziemy sposób, żeby poprzez zmianę w kodzie uniknąć konieczności pisania tekstu, który łatwo się dezaktualizuje.
Na poniższym przykładzie pokażę jak możemy zwiększyć czytelność rozwiązania bez pisania dodatkowych komentarzy. Najpierw postać wymagająca dodatkowych objaśnień:
sendNotification(date) { for (appointment.pid in appointments) { if (appointment.pid.date == date) { notificationService.send(appointment.pid, notificationText) } } }
Patrząc na powyższy fragment możemy jedynie domyślić się, że wysyła on jakiś rodzaj powiadomienia. Nie mamy natomiast pojęcia, co może być treścią powiadomienia ani do kogo jest adresowane. Co się stanie, jeśli zmienimy jedynie nazwy funkcji i zmiennych?
notifyPatientsAboutCancelledDentistAppointment(cancelledAppointmentDate) { for (var appointment in dentistAppointments) { var isAppointmentCancelled = appointment.date == cancelledAppointmentDate if (isAppointmentCancelled) { patientNotificationService.send(appointment.patientId, appointmentCancelledText) } } }
W drugim przykładzie jesteśmy w stanie łatwo odczytać, że notyfikacja kierowana jest do pacjentów i zawiera komunikat o odwołaniu wizyt u dentysty. Są to informacje związane z dziedziną naszej aplikacji, których bez pomocy autora nie bylibyśmy w stanie się domyślić. Oczywiście tę samą wiedzę można by przekazać za pomocą komentarzy, jednak wiązałoby się to niepotrzebnie z większym kosztem oraz ryzykiem dezaktualizacji komunikatu.
Wydaje mi się, że główną przyczyną, dla której często spotykamy kod z pierwszego przykładu jest tak zwana “klątwa wiedzy”. Kiedy piszemy funkcję sendNotification, jest dla nas oczywiste, że wysyła ona pacjentowi powiadomienie o anulowaniu wizyty u dentysty a nie o tym, że kończy mu się karma dla kota, bo przecież pracujemy nad zadaniem TD-2300. Jesteśmy w kontekście i nie wyobrażamy sobie, że ktoś może go nie znać. A jednak, kiedy przy tym kodzie będzie pracowała inna osoba albo my sami będziemy musieli do niego wrócić po dłuższym czasie, ta wiedza już nie będzie taka oczywista.
Bez komentarza?
Skoro za pomocą samego kodu jesteśmy w stanie wyrazić tak wiele, to może faktycznie komentarze są zbędne? Niestety nawet najlepszy kod nie jest w stanie opisać dlaczego zdecydowaliśmy się na takie a nie inne rozwiązanie, co często ma spore znaczenie.
Po pierwsze w naszym opisie możemy ująć listę rozwiązań, które rozważaliśmy zaznaczając przy tym, które ostatecznie wybraliśmy i dlaczego. Wyobraźmy sobie widok prezentujący kolekcję produktów. Na górze znajduje się nagłówek, który na początku ma pełną wysokość, natomiast, kiedy przewijamy stronę, menu to zakotwicza się przy górnej krawędzi i zmniejsza się do postaci cienkiego paska. Produkty są wyświetlane w formie wąskich, prostokątnych kart, dlatego odrzucamy kontrolkę listy, która rozciąga elementy na całą szerokość ekranu. Zamiast niej postanawiamy użyć tabeli. Scalamy komórki w pierwszym wierszu, żeby reprezentował nagłówek. Karty wyświetlamy jako pojedyncze komórki poniżej.
Przyczepienie nagłówka do krawędzi ekranu okazuje się wyzwaniem, ale znajdujemy bibliotekę, która to umożliwia. Idąc dalej zauważamy, że tabela źle reaguje na próby zmiany rozmiaru nagłówka. Przy zmniejszaniu kontrolka pozostawia białą przestrzeń, a przy powiększaniu nagłówek zakrywa elementy znajdujące się poniżej. Z dokumentacji dowiadujemy się, że tabelka wspiera tylko elementy o stałych rozmiarach i nie ma możliwości zmiany tego zachowania. Decydujemy się więc napisać własne rozwiązanie od podstaw. Po zaimplementowaniu, pozostawiamy komentarz:
// Wymagania: https//wwww.zarzadzaniewymaganiami.it/zadania/2839472397 // Tabela połączona z nagłówkiem o dynamicznym rozmiarze // przesuwanie tabeli do góry powoduje zmniejszenie nagłówka // przesuwanie w drugą stronę powoduje odwrotny efekt // Inne rozwiązania: // 1) Tabela (na dzień 13.05.2077) // brak dynamicznej zmiany rozmiaru elementów, puste miejsca przy // zmniejszaniu, przesłanianie przy powiększaniu // 2) Lista // rozciągnięcie elementów na szerokość ekranu pozostawia pustą przestrzeń
Opis wyjaśnia, że łączymy dwa elementy zmiennym rozmiarze. Tekst odnoszący się do tabeli, informuje dlaczego to rozwiązanie na dany dzień nie jest wystarczające. Może po roku API tabeli zostanie wzbogacone o funkcje dynamicznej zmiany wielkości elementów. Mając wiedzę o tym dlaczego kontrolka ta nie została użyta od razu, osoba utrzymująca nasz kod będzie mogła podjąć decyzję o uproszczeniu implementacji. Dokumentując naszą motywację dzisiaj ułatwiamy podjęcie lepszych decyzji w przyszłości. Informacja dotycząca listy również może okazać się przydatna. Uzasadnieniem dla niewykorzystania tego rozwiązania są wymagania biznesowe dotyczące tego jak prezentować dane.
Jak każde wymagania, te również z czasem mogą ulec zmianie. Być może po testach z użytkownikami okaże się, że karty powinny prezentować więcej informacji i dodatkowa szerokość będzie pomocna. Implementujący na bazie komentarza może wnioskować, że nie było innych przeciwwskazań do zastosowania listy i zdecydować o jej zastosowaniu.
Czasami wybór nietypowego rozwiązania podyktowany jest wymaganiami biznesowymi. Wyobraźmy sobie, że w naszym widoku użytkownicy mieli problem z małymi przyciskami, które wymagały precyzji przy kliknięciu. Ponieważ wizualnie rozmiar przycisków był w porządku, podjęta została decyzja o powiększeniu tylko interaktywnego obszaru kontrolek. Wprowadzamy zmianę i voilà, aplikacja od razu staje się łatwiejsza w użyciu. Po jakimś czasie inna osoba pracuje nad tym samym widokiem i zauważa, że grafika przycisku nie pokrywa się z obszarem kliknięcia. “To pewnie bug”, myśli. Ktoś zmieniał obrazek i zapomniał dostosować obszar interakcji. Pod naszą nieobecność ta osoba może więc poprawić nasz “błąd” od razu albo zgłosić go jako defekt. Czasem taka poprawka prześlizgnie się bez uwag. W ten sposób stare problemy w aplikacji wracają. Gdybyśmy jednak zostawili komentarz przy linijce odpowiadającej za powiększenie obszaru klikalnego, to byłoby jasne, że takie rozwiązanie to celowe działanie.
Innym przypadkiem, kiedy kod może zaskoczyć przeglądającą go osobę, są obejścia stosowane często w pracy z bibliotekami oraz przy trudnych lub nowych problemach. Wróćmy na chwilę do sytuacji ze zmieniającym rozmiar nagłówkiem i tabelą. Tym razem elementami w komórkach są pola formularza, w które użytkownik może wpisywać tekst. Jak się okazuje, oferuje ona funkcjonalności takie jak automatyczne centrowanie pól tekstowych, aby użytkownik zawsze widział wpisywany tekst. Wspaniale! Tylko że… nie do końca.
Funkcjonalność ta działa prawidłowo przy założeniach, które spełnia tabelka, czyli że elementy mają stały rozmiar. Niestety my akurat zmieniliśmy to zachowanie, ponieważ takie było wymaganie biznesowe. Sprawdzamy w dokumentacji i dowiadujemy się, że automatyczne przewijanie zostało uznane za tak podstawowe i użyteczne, że nie da się go wyłączyć. Testujemy różne podejścia i zauważamy, że kontrolka obsługuje przewijanie za pomocą elementu suwaka, pierwszego w hierarchii powyżej pola tekstowego. A gdyby tak każde pole tekstowe wyposażyć we własny suwak? Wtedy tabelka przestanie nieprawidłowo przesuwać treść, kiedy nagłówek zmienia rozmiar. Udało się! Sami nie spodziewaliśmy się takiego problemu, a co dopiero rozwiązania.
A co jeśli kiedyś zmieni się zachowanie systemowe albo ktoś odziedziczy po nas kod? Zostawiamy krótki komentarz informujący o tym, dlaczego każdy element formularza jest połączony z własnym suwakiem i co spowoduje ich usunięcie. Jednocześnie dajemy szansę na zaimplementowanie lepszego rozwiązania w przyszłości, jeśli API zacznie oferować nowe funkcje jak na przykład wyłączenie automatycznego przewijania. Obejścia zawsze wiążą się z ryzykiem, jednak możemy je ograniczać odpowiednio opisując swoje podejście i motywację.
Testy, czyli kompilowana dokumentacja
Choć temat testów automatycznych jest dobrze zbadany, to w praktyce nadal przysparza wielu trudności. Mamy wątpliwości co testować, w jaki sposób, jaką strukturę nadać naszym testom. Kiedy już je napiszemy czasem okazuje się, że utrzymanie ich zajmuje ogromne ilości czasu i zaczynamy się zastanawiać czy rzeczywiście warte są aż takiej inwestycji. Cała ta niepewność i trudności pojawiają się już na najbardziej podstawowym poziomie pisania kodu testowego. Nie ma się więc co dziwić, że bardziej zaawansowane, bo i mniej techniczne zastosowania, jak wsparcie implementacji i dokumentacja, rzadko są wykorzystywane w praktyce.
Wynika to moim zdaniem z dwóch powodów. Po pierwsze z faktu, że testowanie, podobnie jak programowanie, to umiejętność, którą trzeba rozwijać przez poznanie teorii a następnie ciągłą praktykę. Nie da się po jednym dniu czytania książek od razu samodzielnie, efektywnie pisać użytecznych testów. To jest proces, który wymaga czasu, wysiłku oraz licznych prób i błędów. Drugim powodem, moim zdaniem, jest to, że nawet kiedy już mamy jakąś informację jak na przykład to, że testy mogą być dokumentacją, to często tak naprawdę jej nie rozumiemy albo na podświadomym poziomie nie akceptujemy.
W przypadku testów to może nie być problemem w odniesieniu do bibliotek, które są produktami i często mają dokumentację. Inaczej jest jednak z wewnętrznymi API, które zazwyczaj posiadają jedynie testy. Dla nas test = kod, czyli czytając testy, zapoznajemy się z implementacją, więc… często po prostu tego nie robimy. A jednak korzystając z bibliotek czytamy dokumentację, bo chcemy wiedzieć jak sprawnie ich użyć. A gdyby tak na potrzeby tego przypadku w naszych głowach porównanie test = kod, zastąpić przez test = dokumentacja?
Jeśli jesteśmy w stanie z tego źródła dowiedzieć się tego samego co z dokumentacji równie szybko, to może warto skorzystać? Dobrze napisane testy pokazują w jaki sposób użyć API, jak różne opcje wpływają na jego zachowanie oraz jak zareaguje w przypadku podania nieprawidłowych danych wejściowych.
Gdy wszystko inne zawiedzie
Jako ostatni temat pozostała nam dokumentacja. Każdy projekt skorzysta z niej w jakiejś minimalnej postaci jak na przykład plik README, który zawiera informacje o tym jak przygotować lokalne środowisko i zbudować aplikację. W dużych, rozproszonych zespołach jest wręcz konieczna, ponieważ ciągłe omawianie podejść i koncepcji bezpośrednio byłoby zbyt czasochłonne. Ma ona jednak znaczące wady. Łatwo dezaktualizuje się, ma tendencję do “puchnięcia” a wielu z nas zwyczajnie nie lubi pisać prozy. Żeby zminimalizować te przypadłości, dobrze jest starać się ograniczać ilość dokumentacji jaką tworzymy. Jest to możliwe bez strat, ponieważ wiele celów, które chcemy osiągnąć za jej pomocą, da się zrealizować w inny, często bardziej efektywny sposób.
Standardowe architektury mają swoją dokumentację. Systemy zarządzania zależnościami opisują zewnętrzne biblioteki dołączone do aplikacji. Jeśli nasz kod piszemy w taki sposób, że sam siebie dokumentuje, a dodatkowo tam gdzie są potrzebne, dodajemy znaczące komentarze i w końcu całość okraszamy testami to ilość dokumentacji, jaka pozostaje do napisania w formie tekstu, staje się znacząco mniejsza. Kiedy dokumentacja techniczna zawiera już tylko te najważniejsze informacje, które do niej przynależą, czyli konfiguracja i uruchomienie aplikacji, listę zewnętrznych usług, z których korzysta, wysokopoziomowy opis rozwiązań używanych w wielu miejscach w projekcie, uzasadnienie dla obrania konkretnego kierunku w skali całego przedsięwzięcia oraz opis rozpatrzonych i odrzuconych alternatyw, tekstu do napisania powinno pozostać już bardzo niewiele.
Dodatkowo możemy ułatwić życie sobie i innym zastępując część tekstu… obrazkami. Długi opis jakiegoś rozwiązania da się często zastąpić diagramem. Podobnie zamiast rozpisywać się na temat tego, w jakich warunkach system ma być użyty, możemy dołączyć grafiki, które prezentują docelowe lub zbliżone środowisko. Jeśli nasza aplikacja ma na przykład usprawnić proces produkcji w fabryce, zdjęcie będzie bardzo cennym źródłem wiedzy, ponieważ na pierwszy rzut oka będzie widać w jakich warunkach nasz użytkownik będzie z niej korzystał i czym konkretnie będzie ona zarządzała.