Admit it: all your projects have this one slightly odd part. It has many different names: “helper” classes, “common” functions, “auxiliary” code (if you’re that erudite), “shared” subroutines… Or simply a utility module, which is how I will call it from here.
This is the logic outside of application’s core. The non-essential building blocks which are nevertheless used throughout the whole project. Heap of scraps, squirreled to plug in the holes of your language, framework or libraries.
Those flea markets are notoriously difficult to keep in check. If left to their own devices, they gradually expand, swallowing more and more of incoming logic – code that would otherwise go into more specific, adequate places. This is where you can carefully observe the second law of programming dynamics: without directed influence, entropy can never decrease.
You cannot do away with utility modules, though. There will always be code that exhibits their two characteristic properties:
But neither is binary: they are more like a continuous spectrum. And to make it even more difficult, they are also constantly in flux, effected by any change that happens in the code. To remain minimal (and thus manageable), utility modules require probably the most frequent, extensive and aggressive refactoring.
So how exactly do you deal with this necessary menace? I think it’s a matter of reacting quickly and decisively when one of these properties change. For the most part, the usage and (inter)dependencies of your utility package will dictate which one of these four steps you should take:
[X, Y, Z]
into a string "X, Y and Z"
, or something similar to that. This is way worse than even the biggest utility module you might end up dealing with."1 apple"
vs "3 apples"
; my native language is especially complex here), this can be the reason for creating a full-blown i18n package.So help you helper classes and utilize your utility modules for shared, common good :)
Nieodłączną częścią realizacji projektu programistycznego jest wybór używanych technologii. Dotyczy to zarówno samego początku, jak i późniejszych etapów, gdy czasami trzeba zdecydować się np. na skorzystanie z jakiejś biblioteki X lub jej zamiennika Y. Takie dylematy mają i koderzy-amatorzy (co często uzewnętrzniają na przeróżnych forach ;]), i firmy tworzące oprogramowanie komercyjnie. Decyzję trzeba jednak w końcu podjąć; czy są jakieś uniwersalne kryteria, którymi można się kierować?
Inżynieria oprogramowania każe zwykle zdać się na poprzednie doświadczenia lub wiedzę ekspertów. Ta druga opcja jest często preferowana wobec braku podstaw (tj. wspomnianych doświadczeń) do skorzystania z pierwszej. Wydaje się to rozsądne. W końcu osoba, która bardzo dobrze zna dane rozwiązanie, powinna mieć też odpowiednie pojęcie o tym, czy aplikuje się ono do konkretnego problemu.
A jednak mam co do tego całkiem poważne wątpliwości. Potrafiłbym znaleźć kilka sensownych argumentów na to, że osoba biegła w stosowaniu danej technologii nie musi być wcale dobrym doradcą. W grę mogą bowiem wchodzić silne czynniki zaburzające solidność osądu eksperta. Przychodzą mi na myśl zwłaszcza dwa:
Jak wynika z powyższych argumentów, wypowiedziane przez eksperta zdanie na temat przydatności jakiegoś rozwiązania niekoniecznie musi być obiektywne i wartościowe. Oczywiście nie twierdzę, że największe pole do popisu przy podejmowaniu decyzji powinni mieć wobec tego początkujący, bo to z kolei zakrawa na przegięcie w drugą stronę :) Sądzę jednak, że najbardziej kompetentnymi i obiektywnymi osobami byłyby takie, które wprawdzie stosowały dane rozwiązanie w praktyce, ale nie są w nim całkiem biegłe. A już zupełnie dobrze byłoby wówczas, jeśli mają one też pewne pojęcie o porównywalnych, alternatywnych rozwiązaniach.
Fajnie jest używać oprogramowania, które da się łatwo dopasować do swoich upodobań – zwłaszcza, jeśli da się to zrobić łatwo, szybko i intuicyjnie, bez konieczności czytania długich manuali. Niestety, konstruowanie interfejsu udostępniającego szeroką funkcjonalność oraz wcześniej wspomniane cechy nie jest łatwe. Dla mnie dobrym przykładem tego, co może pójść źle podczas próby uszczęśliwienia użytkownika mnogością opcją i elastycznością konfiguracji jest banalny na pierwszy rzut oka mechanizm: regulacja głośności dźwięku w komputerach PC.
Problem z nim związany polega – mówiąc ogólnie – na rozbiciu zagadnienia na mniejsze fragmenty (dzięki czemu w teorii uzyskujemy większą kontrolę nad każdym z nich) bez zastanowienia się nad niepożądanymi efektami wzajemnych relacji między tymi fragmentami. Regulacja głośności w komputerach PC występuje bowiem obecnie nie w jednym, a w kilku miejscach. W skrajnych przypadkach może być ich więcej, niż da się policzyć na palcach jednej dłoni, gdy w grę wchodzi:
Ałć. Sporo tego, prawda? W celu usprawiedliwienia tego bałaganu można argumentować, że za prawie każdy z elementów tej listy odpowiada ktoś, jako że znajdują się one w rożnych warstwach abstrakcji. Tak też w teorii powinniśmy je traktować i nawet czerpać korzyści z tego faktu, na przykład poprzez przyciszenie efektów dźwiękowych w grze na rzecz muzyki z działającego w tle odtwarzacza.
Teoria zaś, jak wiemy, niczym nie różni się od praktyki – ale tylko w teorii. W praktyce możemy mieć zupełnie inny przypadek użycia, kiedy na przykład próbujemy rozmowy przez komunikator i stwierdzamy, że nie słyszymy drugiej strony wystarczająco głośno. Będąc rozsądnym użytkownikiem (optymistyczne założenie ;]) udamy się wpierw do ustawień programu i tam wyregulujemy głośność. Ale jeśli to nie pomoże, zapewne w drugim kroku pokręcimy odpowiednim pokrętłem lub przesuniemy globalny systemowy suwak. W konsekwencji następny chord.wav czy inny dźwięk będący częścią UI systemu może nam dostarczyć, mówiąc oględnie, zaskakująco intensywnych wrażeń słuchowych ;)
To jest właśnie przykład niepożądanej interakcji pomiędzy zachodzącymi na siebie fragmentami zagadnienia. Ale w przypadku regulacji głośności nawet pożądane interakcje nie są oczywiste. Czy łatwo jest bowiem określić, w jaki sposób X% nadrzędnego i Y% podrzędnego poziomu dźwięku przełoży się na to, co ostatecznie usłyszymy w głośnikach? Wymaga to chwili zastanowienia, a przecież mówimy tu o czynności, którą powinno się wykonywać automatycznie, bezwiednie i niemal zupełnie nieświadomie! Nie spodziewam się też, aby statystyczny użytkownik miał jakiekolwiek pojęcie o istotnym tutaj prawie Webera-Fechnera, które dodatkowo wpływa na faktycznie słyszaną intensywność wynikowego dźwięku.
Z tych wszystkich narzekań wyłania się wniosek, że regulacja głośności w PC-tach to zagadnienie o sztucznie zawyżonym poziomie komplikacji. Mogłoby ono być znacznie prostsze, gdyby nie obciążono go balastem pozornej konfigurowalności. Jako dobry przykład może służyć analogiczny mechanizm w telefonach, opierający się na dokładnie jednej sprzętowej kontrolce poziomu dźwięku (np. przyciskach) i braku zależności między poszczególnymi ustawieniami (np. multimediów, dzwonka czy rozmowy).
Moi koledzy-częściowo-po-fachu, czyli programiści silników gier, wymyślili niedawno magiczny trzyliterowy akronim DOD – skrót od Data-Oriented Design, czyli projektowanie oparte o dane. Oczywiście określenie ‘niedawno’ jest względnym i pewnie wielu z nich orzekło by, że DOD jest z nimi już całkiem długo. Każdy mem potrzebuje jednak czasu na rozprzestrzenienie się, a w przypadku tego fala tweetów na jego temat dotarła do mnie dopiero niedawno. Niedługo potem rzecz wydała mi się cokolwiek podejrzana.
Podstawowe pytanie brzmi rzecz jasna: o co w tym właściwie chodzi?… Ponieważ mówimy o programowaniu gier, to odpowiedź jest jasna: jeśli nie wiadomo o co chodzi, to chodzi o wydajność. W zaawansowanych grach czasu rzeczywistego mamy do czynienia z ogromną ilością danych, na których trzeba wykonać wiele, często skomplikowanych operacji, a wszystko to jeszcze musi być zrobione dostatecznie szybko, aby możliwe było pokazanie na ekranie kolejnej klatki bez widocznych przycięć. Dlatego też już dawno zauważono, że kodowanie “blisko sprzętu” się opłaca, bo pozwala maksymalnie wykorzystać jego możliwości.
To oczywiście nakłada na kod pewne wymagania oraz stwarza konieczność zwrócenia uwagi na rzeczy, którymi “normalnie” nie ma potrzeby się zajmować. Ładnym przykładem jest chociażby zarządzanie pamięcią. W wielu językach jest ono albo kompletnie pomijalne (garbage collector), albo sprowadza się do dbania o to, aby każdy zaalokowany blok był w końcu zwolniony. Gdy jednak stawiamy na wydajność, powinniśmy też zainteresować się szybkością samej operacji alokacji oraz takim rozmieszczeniem przydzielanych bloków, aby komunikacja na linii procesor-pamięć odbywała się z jak najmniejszą liczbą zgrzytów.
Ten i wiele podobnych szczegółów platformy sprzętowej powodują, że pisanie efektywnego kodu w silnikach gier to często dość literalne postępowanie według zasady Do It Yourself, połączone z ignorowaniem części feature‘ów wysokopoziomowych języków programowania, o których wiadomo, że negatywnie odbijają się na wydajności. Cóż, życie; nie ma w tym nic zaskakującego. Myślę, że każdy co bardziej zaawansowany programista zdążył zdać sobie sprawę z tego, że wszelkie koderskie udogodnienia związane z podniesieniem poziomu abstrakcji mają swój koszt liczony w dodatkowych cyklach procesora (i nie tylko). Rezygnacja z nich jest więc dobrym posunięciem, jeśli chcemy te “stracone” cykle odzyskać.
Robiąc to, będziemy mieli ciastko, ale już nie będziemy mogli go zjeść – a to oczywiście nie jest przyjemne. I po części zapewne stąd wzięło się pojęcie DOD, które nie odnosi się do niczego w gruncie rzeczy nowego, ale pozwala łatwiej odnosić się do tego rodzaju koderskich praktyk poprzez nadanie im nazwy. A przy okazji – jak mi się wydaje – w jakiś nie do końca wytłumaczalny sposób redukuje dysonans poznawczy programistów silników gier, którzy świadomie muszą pozbawiać się możliwości przestrzegania “jedynie słusznych” zasad pisania kodu.
Jak dotąd wszystko jest w gruncie rzeczy bardzo ładne i sensowne, i bez problemu zgadzam się z postulatami Data-Oriented Design tam, gdzie się one aplikują. Zgadzam się nawet z tą domniemaną ukrytą motywacją, zwłaszcza że sam nieraz narzekałem na owe “jedynie słuszne” rady. Za to nijak nie mogę pojąć, dlaczego następnym krokiem – po wynalezieniu pojęcia DOD – był mniej lub bardziej frontalny atak na programowanie obiektowe, określane nieco bardziej znanym (ale naturalnie również trzyliterowym) akronimem OOP.
Nie, nie chodzi o to, że programowanie obiektowe jest doskonałe – bo nie jest, nie było, nigdy nie będzie i nawet nie aspiruje do miana finalnego rozwiązania dla dowolnego problemu (już nie wspominając o tym, że takowe po prostu nie istnieją). Rzecz w tym, że zwolennicy DOD (DOD-a? :]) w nieprzemyślany sposób wybrali sobie przeciwnika, nie zauważając, że jest on paradygmatem zupełnie innego rodzaju niż ich własny. A to przecież takie proste:
Widać to, prawda?… Miedzy powyższymi dwoma podejściami nie tylko nie ma sprzeczności. One są od siebie po prostu niezależne, co oznacza również, że mogą występować razem w jednym programie.
Jeśli Data-Oriented Design koniecznie potrzebuje jakiegoś przeciwnika, to są nim raczej inne xOD-y, których jest już przynajmniej kilka, chociaż wiele nie zostało jeszcze nawet nazwanych. (Dobry przykład to projektowanie oparte o user experience, czyli wrażenie użytkownika, gdzie priorytetem jest m.in. responsywność, nie będąca wcale synonimem wydajności). To, co piewcy DOD zdają się krytykować w swoich publikacjach, to jakieś “projektowanie oparte o eleganckie abstrakcje”, czyli pisanie kodu, który jest sztuką dla sztuki: ładnie wygląda (w założeniu), ściśle trzyma się założeń używanego paradygmatu przy jednoczesnym eksploatowaniu wszelkich jego “zdobyczy” (czyli np. wzorców projektowych). I chociaż bywają w swoich wysiłkach niezwykle twórczy (w prezentacjach z tego tematu spotkałem nawet cytaty z Baudrillarda), to nie zmienia to faktu, że kopią leżącego (czy raczej biją martwego konia, jakby to powiedzieli Amerykanie ;-)). Bo jeśli ktoś naprawdę posuwa się do takich absurdów jak czteropoziomowa hierarchia dziedziczenia obiektów gry, to znaczy że ma znacznie poważniejsze problemy niż okazjonalny cache miss :)
Pisanie kodu to czasami niewdzięczne zajęcie, ale tylko z rzadka. Nieprzyjemnych wrażeń o wiele częściej, jak sądzę, dostarcza czytanie kodu. Jest tak po części pewnie dlatego, że statystycznie programista przeznacza na to zajęcie znacznie więcej czasu (wystarczy policzyć chociażby debugowanie). Innym powodem może być też to, że dokładne zdefiniowanie takiego określenia jak przejrzysty kod okazuje się zaskakująco trudno, bo kategoria ta wydaje się w dużym stopniu subiektywna.
Weźmy na przykład komentarze. Potoczna koderska mądrość mówi nam, że łatwy do zrozumienia kod zapewne posiada dużo komentarzy, bo w końcu jak inaczej opisać zawiłości konstrukcji językowych i bibliotecznych, które się w nim znajdują?… Może to i przekonująca logika, ale ja mam lepszą. Można by przecież liczbę tych zawiłości minimalizować jeśli nie eliminować zupełnie. Kod przejrzysty powinien dokumentować się w zasadzie sam. Wyjątki istnieją, oczywiście, bo zawsze znajdą się jakieś kruczki, haki i inne triki, które można opatrzyć jedynie napisem // Magic. Do not touch.
(o ile nie chcemy opisywać ich działania w elaboracie na piętnaście linii). Jeśli jednak większość kodu wygląda “magicznie”, to jego autora należy czym prędzej przeszkolić w bardziej mugolskich technikach programowania :)
Trochę odrębnie od przygodnych komentarzy traktuję wstawki mające służyć na dokumentację do poszczególnych elementów kodu. Tworzenie jej w pełnej formie, z opisem każdego pola, metody i parametru jest nie tylko czasochłonne, ale i w dużej części niezbyt sensowne. Z drugiej jednak strony brak chociaż słowa wzmianki nt. tego, co dana klasa czy metoda w zasadzie robi to zbrodnia popełniona na nieszczęśnikach, którzy potem muszą przedzierać się przez taką terra incognita. Można obyć się bez wiedzy, jak dany element działa, ale informacja o tym, do czego służy lub co ma w założeniu robić jest po prostu niezbędna.
Komentarze, według mnie, to jednak w ostatecznym rozrachunku sprawa drugorzędna. Znacznie ważniejsza – być może nawet pierwszorzędna – zdaje mi się bowiem odpowiednia organizacja na każdym poziomie projektu. Zaczyna się ona od rozmieszczenia funkcjonalności w klasach, modułach i pakietach, a kończy na układzie samych deklaracji w plikach. Ogólna zasada jest tu bardzo prosta: chodzi o to, by elementy podobne i odpowiedzialne za zbliżone zadania były fizycznie blisko siebie. Regułę tę można łamać w przypadkach uzasadnionych i chęć podziału kodu na warstwy (np. logiki i interfejsu) jest na pewno jednym z nich. Często nie da się tego jednak powiedzieć np. o grupowaniu ze sobą w klasie metod o tej samej widoczności zamiast podobnej i/lub zależnej funkcjonalności
Wreszcie, o przejrzystości kodu decydują też wrażenia wzrokowe otrzymywane w czasie jego oglądania. Wpatrywanie się w długie, gęsto zapełnione znakami wiersze o znikomej ilości pustych przestrzeni jest zajęciem męczącym i dlatego powinniśmy oszczędzać go sobie i innym. Stosujmy więc białe znaki, i to rozrzutnie. Spacje, podziały wiersza oraz puste wiersze (a czasami i wcięcia, jeśli język na to pozwala) używane w przemyślany sposób poprawiają wizualny odbiór listingów i jednocześnie poprawiają wspomnianą wcześniej organizację – tym razem na poziomie mikro.
Chociaż obiekty powinny być jak najbardziej autonomiczne i możliwie niezależne od innych, to bardzo często zdarza się sytuacja, gdy trzeba “zrobić coś” używając czegoś “powszechnie dostępnego”. W praktyce chodzi też o to, by obiekty nie miały kilometrowej długości konstruktorów, do których trzeba by przekazywać wszystko, z czego ewentualnie będą one chciały kiedyś skorzystać.
Dlatego też stosuje się raczej inne rozwiązania, pozwalające w razie potrzeby ‘skoczyć’ w inne miejsce całej sieci powiązań między obiektami, jaka zwykle występuje w programie.
Sieć ta ma zresztą często formę drzewka, wychodzącego od wspólnego korzenia (jakiegoś ‘superobiektu’, reprezentującego całą aplikację), zawierającego w sobie wszystkie obiekty niższych rzędów. Logiczne jest więc zapewnienie odpowiednich metod, pozwalających na dostęp do nich. Wówczas można zawsze rozpocząć od samej góry i, schodząc coraz niżej, dojść w końcu do interesującego nas obiektu.
Z drugiej strony można też rozpoczynać wędrówkę zawsze od obiektu, w którym aktualnie jesteśmy, i w razie potrzeby poruszać się też w górę. Aby było to możliwe, należy jednak dla każdego obiektu jasno określić jego obiekt nadrzędny (parent) i dać możliwość dostępu do niego (np. poprzez przekazanie w konstruktorze odpowiedniego wskaźnika).
Niestety, obie te metody mogą w niektórych sytuacjach zaowocować długimi ciągami wywołań w rodzaju:
Według mnie jest to jednak żadna wada. Podobne potworki świadczą tak naprawdę, że struktura programu jest nieprzemyślana i niezgodna z potrzebami. Nic zatem dziwnego, że korzystanie z niej jest trudne i niewygodne. W tym przypadku można wręcz powiedzieć, że język programowania wykazuje się pewną inteligencją, jednoznacznie wskazując nam, że coś robimy źle ;-)
Kiedy piszemy jakieś klasy, wydaje nam się, że dokładnie przemyśleliśmy ich interfejs, który jest intuicyjny, łatwy w użytkowaniu, wydajny, rozszerzalny, i tak dalej. Musi tak być, skoro sami go napisaliśmy, prawda? ;-) Cóż, życie niestety aż nazbyt często weryfikuje to jakże naiwne założenie. A wtedy pozostaje nam przyjrzeć się, co zaprojektowaliśmy nie dość dobrze i próbować to zmienić.
Jest tylko jedno “ale”: do chwili, gdy zdecydujemy się na zmiany w interfejsie, nasza klasa może być już wykorzystywana w innych fragmentach projektu lub zgoła nawet w innych projektach. To może dotyczyć i takich, których nie jesteśmy autorami – jeżeli tylko zdecydowaliśmy się udostępnić naszą twórczość szerszej publiczności. A wtedy sprawa staje się co najmniej problematyczna, bo każda nieprzewidziana modyfikacja może spowodować kaskadę kolejnych zmian, jakie trzeba będzie poczynić w odpowiedzi na nią.
Jeśli naturalnie bardzo tego chcemy możemy je wszystkie przeprowadzić. Wówczas przynajmniej nasz interfejs będzie znów elegancki – przynajmniej do czasu, gdy stwierdzimy, że znów już taki nie jest ;] Jest to jak najbardziej możliwe, ale pewnie nie trzeba wspominać, jak pracochłonna może być taka operacja.
Spójrzmy raczej na sposób, w jaki radzą sobie z tym problemem twórcy szeroko wykorzystywanych bibliotek programistycznych różnego rodzaju. To, co jest ich cechą wspólną w kolejnych wersjach, to zachowywana zawsze kompatybilność wstecz. Jest ona osiągana przez modyfikacje polegające wyłącznie na dodawaniu elementów interfejsu bez zmiany już istniejących. Pewnie najbardziej znanym przejawem takiej praktyki jest istnienie funkcji z końcówką Ex
w Windows API, które robią trochę więcej niż ich “zwykłe” odpowiedniki i mają nieco zmienioną listę parametrów.
To oczywiście nie jedyna droga nieinwazyjnego poprawiania interfejsu. Takich sposobów jest co najmniej kilka, jak chociażby:
Ex
. Zauważmy, że po takiej operacji możemy zmienić implementację starych metod tak, aby wewnętrznie korzystały one z nowych, rozszerzonych wersji. Póki nie zmieni się sposób ich wywoływania, kod pozostanie kompatybilny wstecz.Można się zastanawiać, czy taki “miękki refaktoring” nie jest odkładaniem na później tego, co i tak należy wykonać? Zapewne tak: przecież każdy kod można zawsze napisać lepiej, czyli od nowa :) Trzeba jednak odpowiedzieć sobie na pytanie, czy korzyści z tego będą większe niż włożony wysiłek. Jeżeli nie kodujemy jedynie dla samej przyjemności kodowania, to nie ma cudów: odpowiedź najczęściej będzie negatywna.