Jak nie zabić projektu złymi praktykami kodowania?
Bartosz Słysz
Software Engineer
Wielu programistów rozpoczynających swoją karierę uważa temat nazewnictwa zmiennych, funkcji, plików i innych komponentów za mało istotny. W efekcie ich logika projektowa często jest poprawna - algorytmy działają szybko i przynoszą pożądany efekt, natomiast potrafią być mało czytelne. W tym artykule postaram się pokrótce opisać czym powinniśmy się kierować przy nadawaniu nazw różnym elementom kodu i jak nie popaść z jednej skrajności w drugą.
Dlaczego zaniedbanie etapu nazewnictwa wydłuży (w niektórych przypadkach - ogromnie) rozwój projektu?
Załóżmy, że ty i twój zespół przejmują kontrolę nad kod od innych programistów. The projekt została stworzona bez żadnej miłości - działała dobrze, ale każdy jej element mógł zostać napisany w znacznie lepszy sposób.
Jeśli chodzi o architekturę, w przypadku dziedziczenia kodu prawie zawsze wywołuje to nienawiść i złość programistów, którzy ją otrzymali. Czasami wynika to z użycia umierających (lub wymarłych) technologii, czasami z niewłaściwego sposobu myślenia o aplikacji na początku rozwoju, a czasami po prostu z braku wiedzy odpowiedzialnego za to programisty.
W każdym razie, w miarę upływu czasu projektu, możliwe jest osiągnięcie punktu, w którym programiści szaleją na punkcie architektur i technologii. W końcu każda aplikacja po pewnym czasie wymaga przepisania niektórych części lub po prostu zmian w określonych częściach - to naturalne. Ale problemem, który sprawi, że włosy programistów staną się siwe, jest trudność w czytaniu i zrozumieniu kodu, który odziedziczyli.
Szczególnie w skrajnych przypadkach, gdy zmienne nazywane są pojedynczymi, nic nie znaczącymi literami, a funkcje są nagłym przypływem kreatywności, w żaden sposób nie są spójne z resztą aplikacji, programiści mogą oszaleć. W takim przypadku każda analiza kodu, która mogłaby przebiegać szybko i sprawnie przy poprawnym nazewnictwie, wymaga dodatkowej analizy algorytmów odpowiedzialnych np. za generowanie wyniku funkcji. A taka analiza, choć niepozorna - marnuje ogromną ilość czasu.
Wdrażanie nowych funkcjonalności w różnych częściach aplikacji oznacza przejście przez koszmar analizowania go, po pewnym czasie trzeba wrócić do kodu i przeanalizować go ponownie, ponieważ jego intencje nie są jasne, a poprzedni czas spędzony na próbach zrozumienia jego działania był zmarnowany, ponieważ nie pamiętasz już, jaki był jego cel.
W ten sposób zostajemy wciągnięci w tornado nieporządku, które panuje w aplikacji i powoli pochłania każdego uczestnika jej rozwoju. Programiści nienawidzą projektu, kierownicy projektów nienawidzą wyjaśniać, dlaczego czas jego rozwoju zaczyna się stale wydłużać, a klient traci zaufanie i wścieka się, ponieważ nic nie idzie zgodnie z planem.
Jak tego uniknąć?
Spójrzmy prawdzie w oczy - niektórych rzeczy nie da się przeskoczyć. Jeśli na początku projektu wybraliśmy pewne technologie, to musimy mieć świadomość, że z czasem albo przestaną być one wspierane, albo coraz mniej programistów będzie biegle posługiwać się technologiami sprzed kilku lat, które powoli stają się przestarzałe. Niektóre biblioteki w swoich aktualizacjach wymagają mniej lub bardziej zaangażowanych zmian w kodzie, co często pociąga za sobą wir zależności, w którym można utknąć jeszcze bardziej.
Z drugiej strony nie jest to taki czarny scenariusz; oczywiście - technologie się starzeją, ale czynnikiem, który zdecydowanie spowalnia czas rozwoju projektów z ich udziałem jest w dużej mierze brzydki kod. I oczywiście należy tutaj wspomnieć o książce Roberta C. Martina - jest to biblia dla programistów, w której autor przedstawia wiele dobrych praktyk i zasad, których należy przestrzegać, aby tworzyć kod dążący do perfekcji.
Podstawową rzeczą podczas nazywania zmiennych jest jasne i proste przekazanie ich intencji. Brzmi to dość prosto, ale czasami jest zaniedbywane lub ignorowane przez wiele osób. Dobra nazwa będzie precyzować, co dokładnie zmienna ma przechowywać lub co funkcja ma robić - nie może być nazwana zbyt ogólnie, ale z drugiej strony nie może stać się długim ślimakiem, którego samo przeczytanie stanowi nie lada wyzwanie dla mózgu. Po pewnym czasie obcowania z dobrej jakości kodem doświadczamy efektu immersji, gdzie jesteśmy w stanie podświadomie tak ułożyć nazewnictwo i przekazywanie danych do funkcji, że całość nie pozostawia złudzeń co do tego, jaka intencja nią kieruje i jaki jest oczekiwany rezultat jej wywołania.
Kolejną rzeczą, którą można znaleźć w JavaScript, między innymi, jest próbą nadmiernej optymalizacji kodu, co w wielu przypadkach czyni go nieczytelnym. To normalne, że niektóre algorytmy wymagają szczególnej uwagi, co często odzwierciedla fakt, że intencja kodu może być nieco bardziej zawiła. Niemniej jednak przypadki, w których potrzebujemy nadmiernych optymalizacji są niezwykle rzadkie, a przynajmniej te, w których nasz kod jest brudny. Należy pamiętać, że wiele optymalizacji związanych z językiem odbywa się na nieco niższym poziomie abstrakcji; na przykład silnik V8 może, przy wystarczającej liczbie iteracji, znacznie przyspieszyć pętle. Należy podkreślić fakt, że żyjemy w XXI wieku i nie piszemy programów dla misji Apollo 13. Mamy znacznie większe pole manewru w temacie zasobów - są one po to, by z nich korzystać (najlepiej w rozsądny sposób :>).
Czasem rozbicie kodu na części naprawdę wiele daje. Gdy operacje tworzą łańcuch, którego celem jest wykonanie akcji odpowiedzialnych za konkretną modyfikację danych - łatwo się pogubić. Dlatego w prosty sposób, zamiast robić wszystko w jednym ciągu, można rozbić poszczególne części kodu odpowiedzialne za konkretną rzecz na pojedyncze elementy. Nie tylko sprawi to, że intencje poszczególnych operacji będą jasne, ale także pozwoli na testowanie fragmentów kodu, które odpowiadają tylko za jedną rzecz i mogą być łatwo ponownie wykorzystane.
Kilka praktycznych przykładów
Myślę, że najtrafniejszym przedstawieniem niektórych z powyższych stwierdzeń będzie pokazanie jak działają one w praktyce - w tym akapicie postaram się nakreślić kilka złych praktyk w kodzie, które w mniejszym lub większym stopniu można zamienić na dobre. Wskażę co w niektórych momentach zaburza czytelność kodu i jak temu zapobiec.
Zmora zmiennych jednoliterowych
Fatalną praktyką, niestety dość powszechną nawet na uczelniach, jest nazywanie zmiennych jedną literą. Trudno się nie zgodzić, że czasami jest to dość wygodne rozwiązanie - unikamy zbędnego zastanawiania się jak określić przeznaczenie zmiennej i zamiast używać kilku lub kilkunastu znaków do jej nazwania, używamy po prostu jednej litery - np. i, j, k.
Paradoksalnie, niektóre definicje tych zmiennych opatrzone są znacznie dłuższym komentarzem, który określa, co autor miał na myśli.
Dobrym przykładem może być tutaj reprezentacja iteracji nad dwuwymiarową tablicą, która zawiera odpowiednie wartości na przecięciu kolumny i wiersza.
const array = [[0, 1, 2], [3, 4, 5], [6, 7, 8]];
// całkiem źle
for (let i = 0; i < array[i]; i++) {
for (let j = 0; j < array[i][j]; j++) {
// tutaj jest zawartość, ale za każdym razem, gdy i i j są używane, muszę wrócić i przeanalizować, do czego są używane
}
}
// wciąż źle, ale zabawnie
let i; // wiersz
let j; // kolumna
for (i = 0; i < array[i]; i++) {
for (j = 0; j < array[i][j]; j++) {
// tutaj jest zawartość, ale za każdym razem, gdy i i j są używane, muszę wrócić i sprawdzić komentarze, do czego są używane
}
}
// znacznie lepiej
const rowCount = array.length;
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = array[rowIndex];
const columnCount = row.length;
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
const column = row[columnIndex];
// czy ktoś ma wątpliwości co jest czym?
}
}
Podstępna nadmierna optymalizacja
Pewnego pięknego dnia natknąłem się na wysoce wyrafinowany kod napisany przez inżynier oprogramowania. Inżynier ten odkrył, że wysyłanie uprawnień użytkownika jako ciągów znaków określających konkretne działania można znacznie zoptymalizować za pomocą kilku sztuczek na poziomie bitów.
Prawdopodobnie takie rozwiązanie byłoby OK, gdyby celem było Commodore 64, ale celem tego kodu była prosta aplikacja internetowa, napisana w JS. Nadszedł czas, aby pokonać to dziwactwo: Powiedzmy, że użytkownik ma tylko cztery opcje w całym systemie do modyfikowania treści: tworzenie, odczyt, aktualizacja, usuwanie. To całkiem naturalne, że albo wysyłamy te uprawnienia w formie JSON jako klucze obiektu ze stanami, albo jako tablicę.
Jednak nasz sprytny inżynier zauważył, że liczba cztery jest magiczną wartością w prezentacji binarnej i rozgryzł to w następujący sposób:
Cała tabela uprawnień ma 16 wierszy, wymieniłem tylko 4, aby przekazać ideę tworzenia tych uprawnień. Odczytywanie uprawnień przebiega następująco:
To, co widzisz powyżej, nie jest Kod WebAssembly. Nie chcę być tutaj źle zrozumiany - takie optymalizacje są normalną rzeczą dla systemów, w których pewne rzeczy muszą zajmować bardzo mało czasu lub pamięci (lub jedno i drugie). Z drugiej strony aplikacje internetowe zdecydowanie nie są miejscem, w którym takie nadmierne optymalizacje mają sens. Nie chcę generalizować, ale w pracy programistów front-end rzadko wykonuje się bardziej złożone operacje sięgające poziomu abstrakcji bitowej.
Jest on po prostu nieczytelny, a programista potrafiący przeprowadzić analizę takiego kodu z pewnością będzie się zastanawiał, jakie niewidzialne zalety ma to rozwiązanie i co może zostać uszkodzone, gdy zespół programistów chce go przerobić na bardziej rozsądne rozwiązanie.
Co więcej - podejrzewam, że wysłanie uprawnień jako zwykłego obiektu pozwoliłoby programiście na odczytanie intencji w 1-2 sekundy, podczas gdy przeanalizowanie całości od początku zajmie co najmniej kilka minut. W projekcie będzie kilku programistów, każdy z nich będzie musiał zetknąć się z tym kawałkiem kodu - będą musieli przeanalizować go kilka razy, bo po jakimś czasie zapomną co za magia się tam dzieje. Czy warto oszczędzać te kilka bajtów? Moim zdaniem nie.
Dziel i zwyciężaj
Tworzenie stron internetowych rośnie w szybkim tempie i nic nie wskazuje na to, by w najbliższym czasie coś miało się w tej kwestii zmienić. Trzeba przyznać, że w ostatnim czasie znacznie wzrosła odpowiedzialność front-end developerów - przejęli oni część logiki odpowiedzialną za prezentację danych w interfejsie użytkownika.
Czasami logika ta jest prosta, a obiekty dostarczane przez API mają prostą i czytelną strukturę. Czasami jednak wymagają różnego rodzaju mapowania, sortowania i innych operacji, aby dostosować je do różnych miejsc na stronie. I to jest miejsce, w którym łatwo wpaść w bagno.
Wiele razy łapałem się na tym, że dane w operacjach, które wykonywałem, były praktycznie nieczytelne. Pomimo poprawnego użycia metod tablicowych i właściwego nazewnictwa zmiennych, łańcuchy operacji w niektórych momentach niemal traciły kontekst tego, co chciałem osiągnąć. Ponadto, niektóre z tych operacji czasami musiały być używane gdzie indziej, a czasami były na tyle globalne lub skomplikowane, że wymagały napisania testów.
Wiem, wiem - to nie jest jakiś banalny kawałek kodu, który w prosty sposób ilustruje to, co chcę przekazać. Wiem też, że złożoność obliczeniowa obu przykładów jest nieco inna, ale w 99% przypadków nie musimy się tym przejmować. Różnica między algorytmami jest prosta, ponieważ oba przygotowują mapę lokalizacji i właścicieli urządzeń.
Pierwszy przygotowuje tę mapę dwukrotnie, podczas gdy drugi przygotowuje ją tylko raz. A najprostszy przykład, który pokazuje nam, że drugi algorytm jest bardziej przenośny polega na tym, że musimy zmienić logikę tworzenia tej mapy dla pierwszego algorytmu i np. dokonać wykluczenia pewnych lokalizacji lub innych dziwnych rzeczy zwanych logiką biznesową. W przypadku drugiego algorytmu modyfikujemy jedynie sposób pobierania mapy, natomiast cała reszta modyfikacji danych występujących w kolejnych liniach pozostaje bez zmian. W przypadku pierwszego algorytmu musimy poprawiać każdą próbę przygotowania mapy.
A to tylko przykład - w praktyce istnieje wiele takich przypadków, gdy musimy przekształcić lub refaktoryzować określony model danych w całej aplikacji.
Najlepszym sposobem na uniknięcie nadążania za różnymi zmianami biznesowymi jest przygotowanie globalnych narzędzi, które pozwolą nam wyodrębnić interesujące nas informacje w dość ogólny sposób. Nawet kosztem tych 2-3 milisekund, które możemy stracić kosztem deoptymalizacji.
Podsumowanie
Bycie programistą to zawód jak każdy inny - każdego dnia uczymy się nowych rzeczy, często popełniając wiele błędów. Najważniejszą rzeczą jest uczenie się na tych błędach, stawanie się lepszym w swoim zawodzie i nie powtarzanie tych błędów w przyszłości. Nie można wierzyć w mit, że wykonywana przez nas praca zawsze będzie bezbłędna. Można jednak, bazując na doświadczeniach innych, odpowiednio zredukować wady.
Mam nadzieję, że lektura tego artykułu pomoże ci uniknąć przynajmniej części z nich. złe praktyki kodowania których doświadczyłem w mojej pracy. W przypadku jakichkolwiek pytań dotyczących najlepszych praktyk kodowania, możesz skontaktować się z Załoga The Codest aby skonsultować swoje wątpliwości.