Dla Megan, za wiarę i czas,
dwa niezbędne składniki

Dane oryginału
Original edition copyright © 2014 by Robert Nystrom. Title of English-language original: Game Programming Patterns by Robert Nystrom, ISBN 978-0-9905829-0-8. Polish-language edition © 2020 by Polish Scientific Publishers PWN Wydawnictwo Naukowe PWN Spółka Akcyjna. All Rights reserved.

Przekład Wojciech Fenrich, Małgorzata Dąbkowska-Kowalik i Witold Sikorski na zlecenie WITKOM Witold Sikorski

Projekt okładki polskiego wydania na podstawie oryginału INT-MEDIA Dawid Mazur

Wydawca Wioleta Szczygielska-Dybciak

Redaktor prowadzący Jolanta Kowalczuk

Redaktor Jadwiga Witecka

Koordynator produkcji Anna Bączkowska

Konsultant merytoryczny
Andrzej Mazur

Skład wersji elektronicznej na zlecenie Wydawnictwa Naukowego PWN

Karol Ossowski/Woblink

Zastrzeżonych nazw firm i produktów użyto w książce wyłącznie w celu identyfikacji.

Copyright © for the Polish edition by Wydawnictwo Naukowe PWN SA

Warszawa 2020

ISBN 978-83-01-21049-6

eBook został przygotowany na podstawie wydania papierowego z 2020 r., (wyd. I)

Warszawa 2020

Wydawnictwo Naukowe PWN SA

02-460 Warszawa, ul. Gottlieba Daimlera 2

tel. 22 69 54 321, faks 22 69 54 288

infolinia 801 33 33 88

e-mail: pwn@pwn.com.pl; reklama@pwn.pl

www.pwn.pl

Podziękowania

Słyszałem, że jedynie inni autorzy książek wiedzą, z czym wiąże się ich pisanie. Istnieje jednak i inne plemię, które dokładnie zna ciężar tego brzemienia – ci, którzy na swoje nieszczęście są w związku z piszącym. Napisałem tę książkę w czasie, który moja żona Megan skrupulatnie wykroiła dla mnie z gęstej skały życia. Zmywanie naczyń czy kąpanie dzieciaków być może nie jest „pisaniem”, jednak gdyby tego nie robiła, książka ta nigdy by nie powstała.

Prace nad tym projektem rozpoczynałem będąc programistą w Electronic Arts. Nie sądzę, aby firma wiedziała do końca, co z tym zrobić, i jestem wdzięczny Michaelowi Malone’owi, Olivierowi Nalletowi i Richardowi Wifallowi za ich wsparcie oraz szczegółowe i wnikliwe uwagi do kilku pierwszych rozdziałów.

W połowie pisania postanowiłem zrezygnować z tradycyjnego wydawcy. Wiedziałem, że stracę przez to przewodnictwo redaktora, otrzymałem jednak e-maile od dziesiątek czytelników, którzy informowali mnie, gdzie chcieliby, by dotarła ta książka. Wiedziałem, że stracę przez to korektorów, otrzymałem jednak ponad 250 zgłoszeń dotyczących błędów, które pomogły mi poprawić tekst. Wiedziałem, że stracę bodziec w postaci harmonogramu, mając czytelników klepiących mnie po plecach po skończeniu każdego rozdziału, byłem jednak mocno zmotywowany.

Nie straciłem jednak dobrego fachowca od składu. Lauren Briese pojawiła się dokładnie wtedy, gdy jej potrzebowałem i wykonała znakomitą pracę.

Nazywa się to „samodzielnym publikowaniem” (self publishing), bardziej odpowiedni byłby tu jednak termin „publikowanie społecznościowe” (crowd publishing). Pisanie może być samotnym zajęciem, nigdy jednak nie byłem sam. Nawet gdy na dwa lata zawiesiłem pracę nad tą książką, zachęty nie ustawały. Bez dziesiątek osób, które nie pozwoliły mi zapomnieć o tym, że czekają na kolejne rozdziały, nigdy bym do niej nie wrócił i jej nie skończył.

Specjalne podziękowania należą sią Colmowi Sloanowi, który dwukrotnie przestudiował każdy rozdział tej książki i dał mi mnóstwo fantastycznych rad, a wszystko to z dobroci serca. Jestem Ci winien piwo albo i dwadzieścia piw.

Moje serce przepełnia wdzięczność dla każdego, kto wysłał mi e-mail lub podzielił się komentarzem, dał kciuk w górę lub dodał do ulubionych, zatweetował lub retweetował, odezwał się do mnie, powiedział swojemu znajomemu o tej książce albo przesłał mi zgłoszenie błędu. Ukończenie tej książki było jednym z największych celów w moim życiu, a wy sprawiliście, że stało się to możliwe. Dziękuję!

Bob Nystrom

6 września 2014 r.

I

Wprowadzenie

Rozdział 1: Architektura, wydajność i gry

W piątej klasie ja i moi znajomi zyskaliśmy dostęp do małej, nieużywanej sali lekcyjnej, w której znajdowało się kilka zdezelowanych komputerów TRS-80. Mając nadzieję, że nas to zainspiruje, nauczyciel odnalazł wydruk jakichś prostych programów w BASIC-u, przy których moglibyśmy pomajstrować.

Czytniki kaset magnetofonowych w komputerach były popsute, więc za każdym razem, gdy chcieliśmy uruchomić jakiś kod, musieliśmy starannie wklepywać go od zera. Sprawiło to, że preferowaliśmy programy, które miały jedynie kilka wierszy:

Być może gdyby komputer wypisał to wystarczająco dużo razy, w magiczny sposób stałoby się to prawdą.

Mimo to proces ten był pełen niebezpieczeństw. Nie znaliśmy się na programowaniu, więc drobne błędy składni były dla nas nieprzeniknione. Jeśli program nie działał – co zdarzało się często – zaczynaliśmy od początku. Z tyłu stosu stron znajdowało się prawdziwe monstrum: program, który zabierał kilka gęsto zapisanych stron kodu. Minęło trochę czasu nim nabraliśmy odwagi, aby choćby spróbować się z nim zmierzyć. Nie mogliśmy jednak mu się oprzeć – tytuł znajdujący się powyżej kodu głosił, że mamy do czynienia z programem „Tunele i Trole”. Nie mieliśmy pojęcia, na czym polegał ten program, ale brzmiało to jak jakaś gra, a co mogłoby być lepszego niż gra komputerowa, którą zaprogramowało się samemu?

Nigdy nie udało nam się jej uruchomić, a po roku wynieśliśmy się z tej sali. (Znacznie później, gdy faktycznie znałem trochę BASIC, zdałem sobie sprawę, że był to jedynie generator znaków dla gry planszowej, a nie sama gra). Kości jednak zostały rzucone – odtąd chciałem zostać programistą gier.

Gdy miałem naście lat, do mojej rodziny trafił Macintosh wyposażony w QuickBASIC, a później THINK C. Spędzałem większą część moich letnich wakacji, klecąc gry. Samodzielne uczenie się było powolne i bolesne. Uruchomienie czegoś, co działało, było łatwe – na przykład ekranu z mapą lub niewielkiej łamigłówki – ale w miarę tego, jak program się rozrastał, stawało się coraz trudniejsze. Początkowo wyzwaniem było nawet to, aby coś zaczęło działać. Następnie stawało się nim wykombinowanie, jak napisać program większy niż coś, co mogłoby zmieścić się w mojej głowie. Zamiast po prostu poczytać o tym, „Jak programować w C++”, spróbowałem poszukać książek o tym, w jaki sposób organizować programy.

Swoje wakacje wielokrotnie spędzałem też łapiąc węże i żółwie na bagnach południowej Luizjany. Gdyby na zewnątrz nie było tak piekielnie gorąco, istnieją spore szanse, że książka ta byłaby poświęcona płazom lub gadom, a nie programowaniu.

Kilka lat później mój znajomy wręczył mi książkę Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku¹. Nareszcie! Książka, której szukałem od czasów, gdy byłem nastolatkiem. Za jednym zamachem przeczytałem ją od deski do deski. Nadal zmagałem się z moimi własnymi programami, ale dostrzeżenie, że inni ludzie również zmagają się ze swoimi i dochodzą do jakichś rozwiązań, przyniosło mi ogromną ulgę. Poczułem, jakbym wreszcie miał do dyspozycji trochę narzędzi, a nie tylko nagie ręce.

Było to nasze pierwsze spotkanie i pięć minut po tym, jak zostałem przedstawiony, usiadłem na jego kanapie i spędziłem kilka następnych godzin kompletnie go ignorując i czytając. Mam nadzieję, że od tego czasu moje umiejętności społeczne nieco się poprawiły.

W 2001 roku dostałem swoją pracę marzeń: zostałem inżynierem oprogramowania w firmie Electronic Arts. Nie mogłem się doczekać, kiedy będę mógł rzucić okiem na jakieś prawdziwe gry i popatrzeć, w jaki sposób profesjonaliści składają je w całość. Jak wyglądała architektura gry tak ogromnej, jak Madden Football? W jaki sposób różne systemy wchodziły ze sobą w interakcję? W jaki sposób udawało im się sprawić, że pojedyncza baza kodu działała na wielu platformach?

Wgryzanie się w kod źródłowy było uczącym pokory i zadziwiającym doświadczeniem. Grafika, sztuczna inteligencja, animacje i efekty graficzne zawierały genialny kod. Mieliśmy ludzi, którzy wiedzieli, jak wycisnąć ostatnie cyki z procesora i dobrze je wykorzystać. Ci ludzie jeszcze przed lunchem robili rzeczy, o których nie wiedziałem nawet, że są możliwe.

Architektura, od której zależał ten genialny kod, była jednak często wynikiem namysłu już po fakcie. Ludzie ci byli tak skupieni na funkcjonalnościach, że na sposób organizacji kodu patrzono przez palce. Sprzężenia między modułami były częste. Nowe funkcjonalności były często przyśrubowane do kodu źródłowego tam, gdzie akurat dało się je wpasować. Wyglądało na to, że wielu z tych programistów nawet nie zerknęło do Wzorców projektowych, a już na pewno nie przebrnęło przez wzorzec Singleton (rozdz. „Singleton”).

W rzeczywistości nie było rzecz jasna tak źle. Wyobrażałem sobie programistów gier siedzących w jakiejś pełnej tablic suchościeralnych wieży z kości słoniowej, tygodniami spokojnie omawiających najdrobniejsze szczegóły architektury. Rzeczywistość była taka, że kod, na który patrzyłem, był napisany przez ludzi starających się dotrzymać ostatecznych terminów. Robili wszystko, co mogli, a to, co mogli zrobić, było często bardzo dobre, z czego stopniowo zdawałem sobie sprawę. Im więcej czasu spędzałem pracując nad kodem gry, tym więcej odnajdywałem genialnych elementów kryjących się pod powierzchnią.

Niestety, „kryjących się” było często trafnym opisem. W kodzie ukryte były prawdziwe perły, ale wiele osób przechodziło tuż obok nich. Widziałem współpracowników usiłujących wymyśleć na nowo dobre rozwiązania, podczas gdy przykłady tego, czego potrzebowali, znajdowały się w tej samej bazie kodu, przy której pracowali.

Jest to problem, który usiłuje rozwiązać ta książka. Wykopałem i wypolerowałem najlepsze wzorce, jakie znalazłem w grach, i przedstawiłem je tutaj, byśmy mogli poświęcać swój czas na wymyślanie nowych rzeczy, a nie na wymyślanie ich raz jeszcze.

Co nas czeka?

Powstały już dziesiątki książek o programowaniu gier. Dlaczego miałaby powstać kolejna? Większość książek poświęconych programowaniu gier, które widziałem, wpada do jednej z dwóch kategorii:

Książki specyficzne dla określonej dziedziny. Te tytuły są poświęcone wąskim zagadnieniom i wnikają głęboko w określony aspekt programowania gier. Można się z nich dowiedzieć czegoś na temat grafiki 3D, renderowania w czasie rzeczywistym, symulowania fizyki, sztucznej inteligencji czy dźwięku. Są to obszary, w których wielu programistów zajmujących się grami specjalizuje się w miarę rozwoju swoich karier.

Książki poświęcone całości silnika. Te dla odmiany próbują objąć wszystkie najróżniejsze elementy całości silnika gry. Zorientowane są na budowę kompletnego silnika dostosowanego do jakiegoś konkretnego gatunku gier, zwykle pierwszoosobowych strzelanek 3D.

Lubię oba te rodzaje książek, myślę jednak, że zostawiają one pewną lukę. Publikacje specyficzne dla tej dziedziny rzadko przedstawiają, w jaki sposób ten spory kawałek kodu wchodzi w interakcje z resztą gry. Możemy być czarodziejami od fizyki czy renderingu, ale czy wiemy, w jaki sposób zręcznie powiązać te kwestie ze sobą?

Druga kategoria książek obejmuje te kwestie, często jednak są one zbyt monolityczne i ograniczone do konkretnego gatunku. Zwłaszcza wraz z powstaniem gier mobilnych i rekreacyjnych znaleźliśmy się w czasach, w których tworzonych jest wiele różnych gatunków gier. Dziś nie jest już tak, że wszyscy klonujemy Quake’a. Książki, które przeprowadzają nas przez pojedynczy silnik nie pomogą nam, jeśli nasza gra nie będzie do niego pasować.

Zamiast tego, to, co próbuję tu zrobić, jest bardziej à la carte. Każdy z rozdziałów tej książki opisuje odrębny pomysł, który możemy wykorzystać, pracując nad swoim kodem. Możemy wymieszać je i dopasować w sposób, który najlepiej sprawdzi się w przypadku gry, którą my chcemy stworzyć.

Inny przykład tego stylu à la carte stanowi ukochana przez wielu seria Game Programming Gems.

W jaki sposób wiąże się to z wzorcami projektowymi?

Każda książka dotycząca programowania mająca w tytule słowo „wzorce” w jasny sposób nawiązuje w jakiś sposób do klasycznej pozycji Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, autorstwa Ericha Gammy, Richarda Helma, Ralpha Johnsona i Johna Vlissidesa (złowieszczo nazywanych „Bandą czterech”).

Sama książka Wzorce projektowe była z kolei zainspirowana inną książką. Pomysł wykonania języka wzorców w celu opisania otwartych rozwiązań problemów pochodzi z książki A Pattern language Chrisophera Alexandra (oraz Sarah Ishikawy i Murraya Silversteina).

Nazywając tę książkę Programowanie gier. Wzorce, nie próbuję sugerować, że książki napisanej przez Bandę czterech nie można zastosować do gier. Przeciwnie: część „Wzorce projektowe raz jeszcze” niniejszej książki obejmuje wiele wzorców z Wzorców projektowych, kładąc jednak nacisk na to, w jaki sposób można je zastosować do programowania gier.

Ich książka była poświęcona architekturze (takiej prawdziwej architekturze, z budynkami, ścianami i tak dalej), mieli oni jednak nadzieję, że inni wykorzystają tę samą strukturę do opisania rozwiązań w innych obszarach. Wzorce projektowe to próba podjęta przez Bandę czterech, której celem było zrobienie tego w odniesieniu do oprogramowania.

Myślę, że jest odwrotnie i ta książka znajdzie zastosowanie również w przypadku oprogramowania innego niż gry. Również dobrze mógłbym ją zatytułować Jeszcze więcej wzorców projektowych, myślę jednak, że gry dostarczają bardziej interesujących przykładów. Czy naprawdę chcielibyśmy przeczytać jeszcze jedną książkę o ewidencji pracowników i kontach bankowych? Choć przedstawione tu wzorce są użyteczne w przypadku innych rodzajów oprogramowania, myślę jednak, że szczególnie dobrze pasują do wyzwań inżynieryjnych pojawiających się często w przypadku gier:

Czas i sekwencjonowanie stanowią często kluczowy składnik architektury gry. Rzeczy muszą się dziać w odpowiedniej kolejności i w odpowiednim czasie.

Cykle deweloperskie są bardzo napięte, a niektórzy programiści muszą być w stanie szybko zbudować i iterować na dużym zbiorze różnych zachowań, bez nadeptywania sobie wzajemnie na palce czy zostawiania śladów w całej bazie kodu.

Gdy całe zachowanie jest już zdefiniowane, zaczyna wchodzić w interakcje. Potwory kąsają bohatera gry, eliksiry mieszają się ze sobą, a bomby niszczą tak wrogów, jak i przyjaciół. Te interakcje muszą zachodzić, nie powodując, że baza kodu stanie się splątanym kłębkiem wełny.

I wreszcie: w przypadku gier wydajność jest krytyczna. Programiści gier uczestniczą w bezustannym wyścigu o to, kto wyciśnie najwięcej ze swojej platformy. Triki służące zaoszczędzeniu kilku cykli mogą stanowić o różnicy między chwaloną przez wszystkich grą i milionami sprzedanych egzemplarzy a utraconymi klatkami i rozzłoszczonymi recenzentami.

Jak czytać tę książkę?

Książka Programowanie gier. Wzorce jest podzielona na trzy duże części. Pierwsza z nich stanowi wprowadzenie i nadaje ogólne ramy całej książce. Składa się na nią ten rozdział oraz rozdział kolejny.

Druga część zatytułowana „Wzorce projektowe raz jeszcze” omawia kilka wzorców z książki autorstwa Bandy czterech. W każdym rozdziale wypróbowuję jakiś wzorzec i omawiam to, jak ma się on do programowania gier.

Ostatnia trzecia część to gwóźdź programu. Przedstawia ona trzynaście wzorców projektowych, o których przekonałem się, że są przydatne. Są one pogrupowane w cztery kategorie: „Wzorce sekwencyjne”, „Wzorce zachowania”, „Wzorce oddzielania” oraz „Wzorce optymalizacji”.

Każdy z tych wzorców jest opisany za pomocą jednolitej struktury, tak aby można było wykorzystać tę książkę w charakterze materiału źródłowego i szybko znaleźć to, co jest potrzebne.

Podrozdział „Motywacja” zawiera opis przykładowego problemu, do którego wzorzec zostanie zastosowany. W odróżnieniu od konkretnych algorytmów, wzorzec pozostaje zwykle pozbawiony formy dopóty, dopóki nie zastosuje się go do jakiegoś konkretnego problemu.

Podrozdział „Wzorzec” wydobywa istotę przykładowego wzorca użytego we wcześniejszym podrozdziale. Jeśli potrzebujemy jego suchego, podręcznikowego opisu – oto on. Będzie to również dobre utrwalenie, jeśli znamy już wzorzec i chcemy upewnić się, że nie zapomnieliśmy o żadnym składniku.

Do tej pory wzorzec został wyjaśniony jedynie w kategoriach pojedynczego przykładu. Skąd jednak wiadomo, że sprawdzi się w odniesieniu do naszego problemu? Podrozdział „Kiedy z niego korzystać” zawiera wskazówki dotyczące tego, w jakich okolicznościach wzorzec jest użyteczny, a kiedy najlepiej jest go unikać. Podrozdział „Do zapamiętania” zwraca uwagę na konsekwencje i ryzyka, jakie niesie ze sobą wykorzystanie wzorca.

Jeśli chcielibyście coś zrozumieć, potrzebujecie konkretnych przykładów – tak jak często dzieje się to ze mną – podrozdział „Przykładowy kod” jest dla nas. Przeprowadza on krok po kroku przez pełną implementację wzorca, tak byśmy mogli dokładnie zobaczyć, w jaki sposób on działa.

Wzorce różnią się od pojedynczych algorytmów, ponieważ mają charakter otwarty. Za każdym razem, gdy korzystamy z wzorca, prawdopodobnie zaimplementujemy go inaczej. Kolejny podrozdział, „Decyzje projektowe”, zgłębia ten obszar i pokazuje różne możliwości, które powinniśmy wziąć pod uwagę, stosując wzorzec.

Krótki podrozdział „Patrz również” służy podsumowaniu oraz pokazuje, w jaki sposób dany wzorzec wiąże się z innymi, a także wskazuje rzeczywiste przykłady kodu open source, w którym są one wykorzystywane.

Kilka słów o przykładowym kodzie

Przykładowy kod w tej książce jest napisany w C++, nie należy jednak z tego wnosić, że wzorce te znajdują zastosowanie jedynie w tym języku, ani że język C++ nadaje się do tego lepiej od innych. Prawie każdy język dobrze się sprawdzi, choć niektóre wzorce wydają się zakładać, że język, którym się posługujemy, uwzględnia obiekty i klasy.

Wybrałem C++ z kilku powodów. Po pierwsze, jest to najpopularniejszy język tworzenia komercyjnych gier. To uniwersalny język całej branży. Co więcej, składnia C, na której opiera się C++, stanowi również podstawę języków Java, C#, JavaScript i wielu innych. Nawet jeśli nie znamy C++, istnieją spore szanse, że przy odrobinie wysiłku będziemy w stanie zrozumieć zawarte tu próbki kodu.

Nauczenie C++ nie jest celem tej książki. Przykłady zostały dobrane tak, aby były tak proste, jak to możliwe i nie są reprezentatywne dla dobrego stylu korzystania z C++. Należy je czytać, zwracając uwagę na ideę, jaką wyrażają, a nie na wyrażający ją kod.

W szczególności kod ten nie został napisany we „współczesnym” stylu charakterystycznym dla C++ w wersji 11 lub nowszej. Nie korzysta on ze standardowej biblioteki i jedynie z rzadka wykorzystuje szablony. Sprawia to, że powstaje „kiepski” kod C++, mam jednak nadzieję, że dzięki tym ograniczeniom będzie on bardziej przystępny dla osób korzystających na co dzień z C, Objective-C, Javy i innych języków.

Aby uniknąć marnowania miejsca na kod, który już widzieliśmy lub który nie ma znaczenia dla danego wzorca, fragmenty kodu zostaną niekiedy pominięte w przykładach. W takich przypadkach miejsca, w którym znajduje się brakujący fragment kodu, w przykładowym kodzie zostanie oznaczony wielokropkiem.

Rozważmy funkcję, która będzie coś robić, a następnie zwracać jakąś wartość. Z punktu widzenia wyjaśnianego wzorca istotna jest tylko zwracana wartość, a nie to, co robi ta funkcja. W takim przypadku przykładowy kod będzie wyglądać tak:

Co dalej?

Wzorce to bezustannie rozwijający się i zmieniający element tworzenia oprogramowania. Niniejsza książka stanowi kontynuację procesu rozpoczętego przez Bandę czterech, polegającego na dokumentowaniu i dzieleniu się napotkanymi wzorcami oprogramowania, który to proces będzie trwać jeszcze po tym, jak wyschnie tusz na tych kartkach.

Jesteście kluczowym elementem tego procesu. Opracowując swoje własne wzorce i doskonaląc (lub odrzucając!) wzorce opisane w tej książce, przyczyniacie się do rozwoju społeczności pracującej nad oprogramowaniem. Jeśli chcecie podzielić się swoimi sugestiami, poprawkami lub innego rodzaju uwagami dotyczącymi zagadnień opisanych w tej książce – bądźmy w kontakcie!

1

Architektura, wydajność i gry

Przyszło mi do głowy, że nim skoczymy na główkę do basenu pełnego wzorców, dobrym pomysłem mogłoby być przedstawienie kontekstu w postaci moich przemyśleń dotyczących architektury oprogramowania i jej zastosowań w świecie gier. Pomoże to lepiej zrozumieć dalsze rozdziały tej książki. Nawet jeśli nie przyda nam się to w żadnym innym celu, dostaniemy nieco amunicji pozwalającej nam toczyć spory o to, jak okropne (lub jak wspaniałe) są wzorce projektowe i architektura oprogramowania.

Zauważmy, że nie zakładam, po której stronie tego sporu mielibyśmy stanąć. Jak każdy handlarz bronią, oferuję towar wszystkim.

Czym jest architektura oprogramowania?

Jeśli przeczytamy tę książkę od początku do końca, nie poznamy algebry liniowej stojącej za grafiką 3D ani analizy matematycznej będącej podstawą fizyki gier. Nie dowiemy się też, w jaki sposób przyciąć drzewo poszukiwań naszej sztucznej inteligencji za pomocą algorytmu alfa-beta ani jak zasymulować pogłos w jakimś pokoju w trakcie odtwarzania dźwięków.

Oj! Ten akapit byłby fatalną reklamą tej książki.

Zamiast tego, książka ta poświęcona jest kodowi znajdującemu się pomiędzy tym wszystkim. W mniejszym stopniu dotyczy pisania kodu niż tego, w jaki sposób powinien on być zorganizowany. Każdy program jest zorganizowany w jakiś sposób, nawet jeśli jest to po prostu zasada „wpakujmy wszystko do main() i zobaczmy, co się stanie”. Myślę więc, że bardziej interesujące będą rozważania dotyczące tego, na czym polega dobra organizacja. W jaki sposób odróżnić dobrą architekturę od złej?

Rozmyślałem nad tym pytaniem przez około pięć lat. Tak jak każdy z nas, posiadam jakąś intuicję dotyczącą tego, czym charakteryzuje się dobry projekt. Wszyscy musieliśmy borykać się z bazą kodu tak złą, że najlepsze, co mogliśmy dla niej zrobić, to zlikwidować ją, skracając jej cierpienia.

Przyznajmy wprost, większość z nas jest odpowiedzialna za kilka takich przypadków.

Kilku szczęśliwców zgromadziło przeciwne doświadczenia, mając okazję pracować z pięknie zaprojektowanym kodem – z bazami kodu, które sprawiały wrażenie, jakby były doskonale urządzonymi, luksusowymi hotelami pełnymi lokajów gotowych gorliwie spełnić każdą naszą zachciankę. Na czym polega różnica między nimi?

Czym jest dobra architektura oprogramowania?

Według mnie projekt jest dobry, gdy dokonując jakiejś zmiany, mam wrażenie, że cały program został skrojony tak, jak gdyby jej oczekiwał. Mogę rozwiązać zadanie za pomocą tylko kilku wywołań funkcji wyboru, które pasują tak doskonale, że nie pozostawiają nawet najmniejszej zmarszczki na idealnie gładkiej tafli kodu.

Ładnie to brzmi, ale trudno przełożyć to na działania. „Po prostu napisz kod tak, by zmiany nie deformowały jego gładkiej tafli”. Tak, jasne...

Pozwólcie, że podzielę to na nieco mniejsze fragmenty. Pierwszy istotny element to to, że w architekturze chodzi o zmianę. Ktoś musi modyfikować bazę kodu. Jeśli nikt go nie tyka – czy to dlatego że jest już doskonały i kompletny, czy dlatego że jest tak nędzny, że nikt nie chce kalać nim swego edytora kodu – to, jak jest zaprojektowany, nie ma znaczenia. Miarą jakości projektu jest to, jak łatwo dostosowuje się do zmian. Jeśli do zmian nie dochodzi, jest jak sprinter, który nigdy nie opuszcza bloków.

Jak dokonujemy zmian?

Zanim będziemy mogli zmienić kod w celu dodania nowej funkcjonalności, naprawienia błędu czy z dowolnego innego powodu, który sprawił, że odpaliliśmy nasz edytor, musimy zrozumieć, co robi istniejący kod. Nie musimy rzecz jasna znać całego programu, ale musimy załadować wszystkie jego istotne fragmenty do naszego mózgu ssaka naczelnego.

To dziwne, ale jest to dosłownie proces OCR.

Zwykle staramy się prześliznąć się przez ten krok, ale często jest to najbardziej czasochłonny element programowania. Jeśli uważamy, że stronicowanie jakiejś ilości danych z dysku do pamięci RAM jest powolne, spróbujmy przestronicować je do naszego mózgu za pośrednictwem pary nerwów wzrokowych. Kiedy cały poprawny kontekst znajdzie się już w naszej pamięci, zastanawiamy się przez moment i dochodzimy do rozwiązania. Czasem możemy przez dłuższą chwilę poruszać się tam i z powrotem, ale często jest to proste. Kiedy już zrozumiemy problem i fragmenty kodu, których dotyczy, samo kodowanie okazuje się trywialne.

Przez chwilę stukamy naszymi mięsistymi palcami w klawiaturę, aż na ekranie pokażą się światełka we właściwych kolorach. I po wszystkim, prawda? Chwila, moment! Zanim napiszemy testy i prześlemy kod do inspekcji, często czeka nas trochę czyszczenia.

Czy powiedziałem „testy”? O, rzeczywiście! Ciężko jest napisać testy jednostkowe jakiegoś kodu gry, ale duża część bazy kodu znakomicie nadaje się do testowania.

Upchnęliśmy w naszej grze jeszcze trochę kodu, ale nie chcemy, aby kolejna osoba, która się tu pojawi, potknęła się o zmarszczki, które pozostawiliśmy w źródle. Wyłączywszy przypadki minimalnych zmian, chcąc zintegrować nasz kod i wygładzić cały program, czeka nas odrobina reorganizacji. Jeśli zrobimy to poprawnie, kolejna pojawiająca się osoba nie będzie w stanie stwierdzić, kiedy został napisany jakiś wiersz kodu.

Nie będę się nad tym w tym miejscu rozwodzić, ale naprawdę warto w większym stopniu polegać na testach automatycznych (jeśli jeszcze tego nie robimy). Czy naprawdę nie mamy nic lepszego do roboty i za każdym razem chcemy sprawdzać różne rzeczy ręcznie?

Krótko mówią, diagram przepływu dla programowania wygląda jakoś tak:

Rysunek 1.1. Dzień pracy programisty w pigułce

Teraz, gdy o tym myślę, fakt, że z tej pętli nie ma wyjścia, wydaje mi się odrobinę niepokojący.

W jaki sposób oddzielanie może pomóc?

Choć nie jest to oczywiste, sądzę, że architektura oprogramowania zależy przede wszystkim od fazy poznawania kodu. Załadowanie kodu do neuronów jest tak okropnie wolne, że opłaca się poszukać strategii redukujących jego objętość. Cała osobna część tej książki jest poświęcona wzorcom służącym oddzielaniu, a duża część książki Wzorce projektowe jest poświęcona temu samemu.

„Oddzielanie” możemy zdefiniować na wiele sposobów, jednak moim zdaniem jeśli dwa fragmenty kodu są sprzężone, to znaczy że nie można zrozumieć jednego z nich bez zrozumienia drugiego. Jeśli je oddzielimy, o każdym z nich możemy rozumować niezależnie. To świetna sprawa, ponieważ jeśli nasz problem dotyczy tylko jednego z nich, będziemy musieli załadować do naszego małpiego mózgu tylko jeden fragment, a drugiego już nie.

Jeśli chodzi o mnie, jest to kluczowe zadanie stojące przed architekturą oprogramowania: zminimalizować ilość wiedzy, jaką musimy mieć w głowie, nim będziemy mogli posunąć sprawy do przodu.

W grę wchodzą oczywiście również późniejsze stadia. Inna definicja oddzielania skupia się na tym, aby zmiana w jednym fragmencie kodu nie wymuszała zmian w innym. Jasne, trzeba będzie coś zmienić, ale im mniej mamy sprzężeń, tym mniej zmarszczek pojawi się za sprawą tych zmian w innych miejscach tafli naszej gry.

Jakim kosztem?

Brzmi świetnie, prawda? Oddzielimy wszystko, co trzeba, a będziemy w stanie kodować niczym wicher. Każda zmiana będzie się wiązać z modyfikacją tylko jednej lub dwóch wybranych metod, a my będziemy mogli tańczyć na tafli kodu źródłowego, nie zostawiając nawet cienia śladu.

To uczucie sprawia, że ludzie tak ekscytują się abstrakcjami, modularnością, wzorcami projektowymi i architekturą oprogramowania. Praca nad programem o dobrej architekturze to naprawdę radosne doświadczenie, a każdy lubi być bardziej produktywny. Dobra architektura ma ogromny wpływ na produktywność. Trudno przecenić jak głęboki może on być.

Jak wszystko w życiu, ma to jednak swoją cenę. Dobra architektura wymaga prawdziwego wysiłku i dyscypliny. Za każdym razem, gdy dokonujemy jakiejś zmiany lub implementujemy jakąś funkcjonalność, musimy się ciężko napracować, aby zgrabnie zintegrować ją z resztą programu. Musimy starannie zadbać zarówno o dobrą organizację kodu źródłowego, jak i o to, by utrzymać ten stan w trakcie dokonywania tysięcy drobnych zmian składających się na cykl produkcyjny oprogramowania.

Druga część – poświęcona utrzymaniu naszego projektu – zasługuje na szczególną uwagę. Widziałem wiele programów, których początki były piękne, a które następnie dogorywały w wyniku tysiąca drobnych cięć w miarę jak programiści raz za razem dodawali „jeszcze tylko jeden, malutki kawałek”.

Musimy się zastanowić, między którymi częściami programu powinny zostać oddzielone zależności i w tych miejscach wprowadzić abstrakcje. Podobnie musimy określić, gdzie powinna zostać uwzględniona możliwość rozszerzenia, tak by przyszłe zmiany były łatwiejsze do wprowadzenia.

Jak w ogrodnictwie – nie wystarczy posadzić nowe rośliny, trzeba je również przycinać i odchwaszczać.

Ludzie naprawde się tym ekscytują. Wyobrażają sobie, że w przyszłości programiści (albo po prostu oni sami) zagłębiają się w bazę kodu i orientują się, że jest ona otwarta, potężna i wprost prosi się o to, by ją rozszerzyć. Wyobrażają sobie „Silnik Gry nad Wszystkie Inne Silniki”.

Ale to właśnie tu zaczynają się schody. Zawsze gdy dodajemy warstwę abstrakcji lub miejsce wspierające rozszerzalność, spekulujemy, że w przyszłości ta elastyczność będzie nam potrzebna. Zwiększamy objętość oraz złożoność kodu naszej gry, co wymaga poświęcenia czasu na zaprogramowanie, usunięcie usterek i utrzymanie.

Wysiłek się opłaci, jeśli zgadniemy poprawnie i w przyszłości wrócimy do tego kodu. Przewidywanie przyszłości jest jednak trudne, a gdy taka modułowość koniec końców nie okaże się pomocna, szybko zaczyna aktywnie szkodzić. Ostatecznie jest to dodatkowy kod, z którym musimy sobie radzić.

Niektórzy ukuli termin „YAGNI” od angielskiego „You aren’t gonna need it” (Nie będzie ci to potrzebne) i stosują go jak mantrę, aby zwalczyć pragnienie spekulowania o tym, czego może potrzebować nasze przyszłe „ja”.

Gdy ludzie zaczynają podchodzić do tego zbyt gorliwie, dostajemy bazę kodu, której architektura wymknęła się spod kontroli. Wszędzie mamy do czynienia z interfejsami i abstrakcjami. Systemy wtyczek, abstrakcyjne klasy bazowe, obfitość metod wirtualnych i wszelkiego rodzaju punkty rozszerzenia.

Poruszanie się po całym tym rusztowaniu zajmie nam wieczność, nim znajdziemy jakiś rzeczywisty kod, który faktycznie coś robi. Gdy musimy coś zmienić – jasne, pewnie znajdzie się interfejs, który nam w tym pomoże. Powodzenia w jego odszukaniu! W teorii całe to oddzielanie znaczy, że mamy mniej kodu, który musimy zrozumieć, zanim będziemy mogli go rozszerzyć, ale przecież same warstwy abstrakcji też wypełniają nasz mentalny dysk.

Tego typu bazy kodu sprawiają, że wiele osób krzywo patrzy na architekturę oprogramowania, a na wzorce projektowe w szczególności. Łatwo jest tak mocno zaplątać się w sam kod, że tracimy z oczu to, że próbujemy dostarczyć na rynek jakąś grę. Syreni śpiew rozszerzalności wciąga niezliczonych programistów, którzy spędzają lata, pracując nad „silnikiem”, nie rozumiejąc nawet, czemu ten silnik ma służyć.

Wydajność i szybkość

Istnieje jeszcze inny rodzaj krytyki architektury oprogramowania i abstrakcji, o którym niekiedy się słyszy, zwłaszcza w świecie programistów gier. Głosi on, że szkodzą one ich wydajności. Wiele wzorców, które uelastyczniają nasz kod, zależy od wirtualnych dyspozytorów, interfejsów, wskaźników, wiadomości i innych mechanizmów. Wszystkie one generują jakiś koszt w postaci czasu wykonania.

Interesujący kontrprzykład stanowią szablony w C++. Metaprogramowanie szablonów może niekiedy dać nam abstrakcję od interfejsów bez negatywnych konsekwencji po stronie czasu wykonania. Istnieje tu całe spektrum elastyczności. Gdy piszemy kod wywołujący konkretną metodę w jakiejś klasie, określamy tę klasę w momencie pisania kodu – na sztywno zakodowaliśmy, którą klasę wywołujemy. Gdy korzystamy z metody wirtualnej lub interfejsu, klasa, która zostaje wywołana, jest znana dopiero w momencie wykonania. Jest to rozwiązanie znacznie elastyczniejsze, ale zakłada, że istnieją pewne rezerwy po stronie czasu wykonania. Metaprogramowanie szablonów znajduje się gdzieś pośrodku. W tym przypadku decyzję o tym, którą klasę wywołać, podejmujemy w momencie kompilacji, gdy tworzona jest instancja szablonu.

Istnieje po temu powód. W architekturze oprogramowania w dużej mierze chodzi o to, aby nasze programy były bardziej elastyczne, a ich modyfikacja wymagała mniejszego wysiłku. Znaczy to, że w programie zakodowane zostanie mniej założeń. Korzystamy z interfejsów, by nasz kod działał z dowolną klasą, która go implementuje, a nie tylko z tą jedną, która pozwala na to dzisiaj. Korzystamy z obserwatorów (rozdz. „Obserwator”) i komunikatów (rozdz. „Kolejka zdarzeń”), by pozwolić dwóm elementom gry na rozmowę ze sobą po to, aby potem z łatwością mogły to robić trzy lub cztery.

Wydajność to natomiast w całości kwestia założeń. W praktyce optymalizacja rozkwita dzięki konkretnym ograniczeniom. Czy możemy bezpiecznie założyć, że nigdy nie będziemy mieli więcej niż 256 oponentów? Świetnie, możemy wsadzić ID do pojedynczego bajta. Czy będziemy tu wywoływać jakąś metodę na jednym konkretnym typie? Świetnie, możemy to statycznie wyekspediować lub skorzystać z konstrukcji inline. Czy wszystkie jednostki będą tej samej klasy? Świetnie, możemy z nich utworzyć sympatyczną, ciągłą tablicę (rozdz. „Lokalizacja danych”).

Nie znaczy to, że elastyczność jest czymś złym! Pozwala nam szybko wprowadzić zmiany do naszej gry, a szybkość rozwoju oprogramowania jest absolutnie kluczowa, jeśli chcemy stworzyć produkt pozwalający na dobrą zabawę. Nikt, nawet Will Wright, nie może opracować zrównoważonego projektu gry na papierze. Wymaga to wielu iteracji i eksperymentowania. Im szybciej jesteśmy w stanie wypróbowywać pomysły i sprawdzać, jakie dają odczucia, tym więcej rzeczy możemy wypróbować i tym większe są szanse na to, że trafimy na coś fantastycznego. Nawet gdy znajdziemy już prawidłową mechanikę, mnóstwo czasu zajmie nam dostrajanie. Drobne zaburzenie równowagi może zniweczyć frajdę z gry.

Nie ma tu łatwej odpowiedzi. Aby sprawić, żeby nasz program stał się bardziej elastyczny, dzięki czemu łatwiej będzie prototypować, będziemy musieli ponieść pewien koszt po stronie wydajności. Podobnie optymalizacja kodu sprawi, że będzie on mniej elastyczny.

Z mojego doświadczenia wynika jednak, że łatwiej jest zwiększyć szybkość działania gry sprawiającej frajdę, niż sprawić, aby szybka gra zaczęła ją sprawiać. Możliwy kompromis polega na utrzymywaniu elastyczności kodu do czasu aż projekt się ustabilizuje i usunięciu kilku abstrakcji później w celu zwiększenia wydajności.

Co jest dobrego w kiepskim kodzie?

Prowadzi nas to do kolejnej kwestii: jest czas i miejsce na różne style kodowania. Większa część tej książki jest poświęcona tworzeniu łatwego w utrzymaniu, czystego kodu, więc moje przywiązanie do robienia tego we „właściwy” sposób jest jasne, niemniej niedbały kod również ma pewną wartość.

Pisanie kodu o dobrej architekturze wymaga starannego przemyślenia i ma swoje przełożenie na czas. Więcej nawet – utrzymanie dobrej architektury w trakcie życia projektu wymaga mnóstwa wysiłku. Musimy traktować naszą bazę kodu tak, jak dobry harcerz traktuje swoje obozowisko, zawsze próbując zostawić ją po sobie w trochę lepszym stanie, niż ją zastaliśmy. Ma to sens, jeśli przez długi czas będziemy żyć z tym kodem i nad nim pracować. Tak jak wspomniałem wcześniej, projektowanie gier wymaga jednak mnóstwa eksperymentów i poszukiwań. Szczególnie w początkowym stadium pisanie kodu, o którym wiadomo, że ostatecznie trafi do kosza, jest czymś powszechnym.

Jeśli po prostu chcemy dowiedzieć się, czy jakiś pomysł dotyczący rozgrywki w ogóle ma sens, pieczołowita dbałość o jego architekturę znaczy, że poświecimy więcej czasu, nim rzeczywiście pojawi się on na ekranie i będziemy w stanie uzyskać jakąś informację zwrotną. Jeśli okaże się, że pomysł się nie sprawdza, czas poświęcony na stworzenie eleganckiego kodu, który usuniemy, będzie stracony.

Prototypowanie – wklepanie kodu, który z ledwością działa wystarczająco dobrze, by można było uzyskać odpowiedź na pytanie projektowe – to całkowicie zasadna praktyka programistyczna. Istnieje jednak bardzo poważne zastrzeżenie. Jeśli piszemy niskiej jakości kod jednorazowego użytku, musimy mieć pewność, że będziemy w stanie go usunąć. Widziałem, jak kiepscy menedżerowie raz za razem grali w następującą gierkę:

Szef: „Hej, mamy ten pomysł, który chcielibyśmy wypróbować. Chodzi nam tylko o prototyp, więc nie musisz tego robić porządnie. Jak szybko mógłbyś to złożyć do kupy?”.

Programista: „Cóż, jeśli przytnę tu i tam, podaruję sobie testy, odpuszczę dokumentację i przymknę oko na tony błędów, wstępny kod mogę dostarczyć w ciągu kilku dni”.

Szef: „Znakomicie!”.

Mija kilka dni:

Szef: „Hej, ten prototyp jest świetny! Czy mógłbyś poświęcić kilka godzin na jego lekkie doczyszczenie, tak żebyśmy mieli już gotowy produkt?”.

Musimy się upewnić, że ludzie korzystający z jednorazowego kodu rozumieją, że nawet jeśli wygląda, jakby działał, nie można go zachować i trzeba go przepisać. Jeśli istnieje szansa, że koniec końców zostanie on z nami na dłużej, w ramach samoobrony możemy napisać go od razu porządnie.

Istnieje jeden trik gwarantujący, że nasz prototypowy kod nie będzie musiał stać się prawdziwym kodem. Polega on na napisaniu go w języku innym niż język wykorzystywany przez naszą grę. Dzięki temu trzeba będzie go przepisać, zanim trafi do rzeczywistej gry.

Osiąganie równowagi

W naszym polu oddziałuje kilka sił:

Chcemy mieć porządną architekturę tak, aby kod był łatwiejszy do zrozumienia w trakcie życia projektu.

Chcemy dobrej wydajności i krótkiego czasu wykonania.

Chcemy szybko uporać się z przewidzianymi na dziś funkcjonalnościami.

Myślę, że to ciekawe, że wszystkie one w jakiś sposób dotyczą szybkości: tempa, w jakim programujemy w długiej perspektywie czasu, szybkości, z jaką wykonuje się gra i naszego tempa programowania w krótkiej perspektywie czasu.

Cele te przynajmniej do pewnego stopnia są sobie przeciwstawne. Dobra architektura zwiększa produktywność w długim okresie, ale jej utrzymanie znaczy, że każda zmiana wymaga dodatkowej odrobiny wysiłku, aby utrzymać porządek.

Implementacja, która jest najszybsza do napisania, rzadko jest tą, która działa najszybciej. Z kolei optymalizacja wymaga poświęcenia dużej ilości czasu na prace inżynieryjne. Gdy już dobiegnie końca, sprawia zazwyczaj, że baza kodu tężeje: dobrze zoptymalizowany kod jest nieelastyczny i bardzo trudny do zmiany.

Zawsze istnieje presja, aby skończyć to, co trzeba skończyć dzisiaj, a o całą resztę pomartwić się jutro. Jednak jeśli będziemy dorzucać funkcjonalności tak szybko, jak jesteśmy w stanie, w naszej bazie kodu zacznie panować bałagan i pojawi się mnóstwo hacków, błędów oraz  niespójności, które osłabią naszą produktywność w przyszłości.

Nie ma tu prostej odpowiedzi – tylko „coś za coś”. Z e-maili, które do mnie docierają, wnoszę, że wiele osób to zniechęca. W szczególności nowicjusze, którzy po prostu chcą zrobić jakąś grę, mogą być nieco przestraszni, gdy usłyszą, iż „nie ma dobrej odpowiedzi, tylko różne odcienie złych”.

Dla mnie jest to jednak ekscytujące! Spójrz na dowolną dziedzinę, w której ludzie doskonalą się przez całą swoją karierę, a w jej centrum zawsze znajdziemy jakiś zbiór powiązanych ze sobą ograniczeń. Przecież jeśli istniałoby jakieś proste rozwiązanie, wszyscy po prostu by z niego skorzystali. Dziedzina, którą możemy opanować w tydzień w ostateczności jest nudna. Nie słyszy się o ludziach, którzy zrobiliby nieprzeciętne kariery na kopaniu rowów.

Może inni tak zrobili, ale ja nie zgłębiałem tej analogii. Z tego, co wiem, mogą istnieć gorliwi hobbyści zajmujący się kopaniem rowów, konwencje dotyczące kopania rowów i cała otaczająca je subkultura. Kimże jestem, aby to oceniać?

Jak dla mnie, ma to wiele wspólnego z samymi grami. Gry takiej jak szachy nie da się opanować w doskonały sposób, ponieważ wszystkie figury są ze sobą tak idealnie zrównoważone. Znaczy to, że możemy spędzić życie, zgłębiając rozległe przestrzenie możliwych strategii. Źle zaprojektowana gra wali się za sprawą jednej zwycięskiej taktyki, którą możemy stosować raz za razem, aż nam się to znudzi i damy sobie spokój.

Prostota

Ostatnimi czasy mam poczucie, że jeśli istnieje jakakolwiek metoda, która jest w stanie coś poradzić na te ograniczenia, to jest nią prostota. Obecnie za pomocą kodu usilnie staram się napisać najczystsze, najbardziej bezpośrednie rozwiązanie danego problemu. Po zapoznaniu się z kodem tego rodzaju, dokładnie rozumiemy, co on robi i nie jesteśmy w stanie wyobrazić sobie żadnego innego rozwiązania.

Staram się poprawnie ująć struktury danych i algorytmy (mniej więcej w tej kolejności), a następnie ruszyć dalej. Zauważyłem, że jeśli jestem w stanie zrobić coś w prosty sposób, powstaje mniej kodu. Znaczy to, że mniej kodu musi zostać załadowane do mojej głowy, aby go zmienić. Często działa on szybko, ponieważ po prostu jest tu niewielki narzut i niewiele kodu do wykonania. (Choć z pewnością nie zawsze tak jest. W niewielkiej ilości kodu można upakować sporo pętli i rekursji).

Zauważmy jednak, że nie twierdzę, że napisanie prostszego kodu zabiera mniej czasu. Można by sądzić, że tak jest, ponieważ finalnie dostajemy mniej kodu, ale dobre rozwiązanie nie powstaje dzięki przyrastaniu kodu, ale dzięki jego destylacji.

Blaise Pascal zakończył jeden ze swych znanych listów w następujący sposób: „List ten jest dłuższy jedynie dzięki temu, iż nie miałem czasu napisać go krócej”².

Inny cytat pochodzi od Antoine’a de Saint-Exupery’ego: „Wydaje się, że doskonałość osiąga się nie wtedy, kiedy nie można już nic dodać, ale raczej wtedy, gdy nie można nic ująć”³.

Mówiąc wprost, zauważyłem, że za każdym razem, gdy wracam do któregoś rozdziału tej książki, staje się on krótszy. Niektóre rozdziały stają się krótsze o 20%, nim zostaną ukończone.

Rzadko stawia się przed nami elegancki problem. Zamiast tego dostajemy stertę przypadków użycia. Chcemy, aby X robiło Y, gdy Z, a W, gdy A i tak dalej. Innymi słowy, mamy do czynienia z długą listą różnych przykładowych zachowań. Rozwiązanie wymagające najmniejszego umysłowego wysiłku polega na prostym zakodowaniu tych przypadków użycia, po jednym na raz. Jeśli przyjrzymy się początkującym programistom, zauważymy, że często zachowują się w następujący sposób: masowo produkują mnóstwo logiki warunkowej dla każdego przypadku, który przyjdzie im do głowy.

Nie ma w tym jednak nic eleganckiego, a kod napisany w takim stylu wali się, gdy pojawiają się dane wejściowe choćby odrobinę różne od tych, jakie pojawiały się w przykładach rozważanych przez programistę. Gdy myślimy o eleganckim rozwiązaniu, często mamy na myśli takie, które ma charakter ogólny: odrobina logiki, która nadal może poprawnie obsłużyć wiele przypadków użycia.

Odnalezienie takiego rozwiązania przypomina poniekąd dopasowywanie wzorców czy rozwiązywanie zagadki. Trzeba wysiłku, aby w różnorodności przypadków użycia odnaleźć leżący u ich podstawy ukryty porządek. Gdy uda nam się tego dokonać, uczucie jest niesamowite.

Zaczynajmy już!

Niemal każdy pomija wstępne rozdziały, cieszę się więc, że zabrnęliśmy tak daleko. Nie mogę zaoferować wiele w podzięce za okazaną cierpliwość, ale mogę dać kilka rad, które – mam nadzieję – okażą się pomocne:

Abstrakcja i oddzielanie sprawiają, że ewolucja naszego oprogramowania jest szybsza i łatwiejsza, nie traćmy jednak na nie czasu, jeśli nie mamy pewności, że kod, którego dotyczą, będzie potrzebować tej elastyczności.

Myślmy o wydajności i twórzmy projekty, mając ją na względzie w trakcie całego cyklu rozwoju oprogramowania, ale na tak późny etap, jak to tylko możliwe, odłóżmy niskopoziomowe, zasadnicze optymalizacje, które wbetonowują założenia w nasz kod.

Szybko rozpocznijmy eksplorację przestrzeni projektowej naszej gry, ale nie róbmy tego w nadmiernym tempie, aby nie zostawić po sobie bałaganu. Ostatecznie będziemy musieli z tym żyć.

Jeśli zamierzamy odrzucić jakiś kod, nie traćmy czasu na jego upiększenie. Gwiazdy rocka dewastują pokoje hotelowe, ponieważ wiedzą, że następnego dnia je opuszczą.

Przede wszystkim jednak, jeśli chcemy, aby coś sprawiało frajdę, tworząc to coś, sami powinniśmy ją mieć.

Wierzcie mi, dwa miesiące przed dostarczeniem produktu to nie jest dobry moment na to, aby zacząć martwić się tym drobnym, uporczywym problemem objawiającym się tym, że gra działa z płynnością jednej klatki na sekundę.

II

Wzorce projektowe raz jeszcze

Rozdział 2: Polecenie

Rozdział 3: Pyłek

Rozdział 4: Obserwator

Rozdział 5: Prototyp

Rozdział 6: Singleton

Rozdział 7: Stan

Jeśli wierzyć mojemu kalendarzowi, książka Wzorce projektowe ma już niemal 20 lat. O ile nie spoglądacie mi właśnie przez ramię, gdy będziecie czytać te słowa, będzie już mogła kupić w sklepie alkohol⁴. W przypadku tak dynamicznej branży jak tworzenie oprogramowania są to praktycznie czasy starożytne. Utrzymująca się popularność tej książki mówi co nieco o tym, jak ponadczasowe jest projektowanie w porównaniu do wielu platform i metodologii.

Chociaż sądzę, że książka ta ciągle jest ważna, w ciągu kilku ostatnich dekad wiele się nauczyliśmy. W tej części omówimy garść oryginalnych wzorców udokumentowanych przez Bandę czterech. Mam nadzieję, że na temat każdego z nich będę miał do powiedzenia coś pożytecznego. Uważam, że niektóre wzorce są nadużywane (Singleton), podczas gdy inne pozostają niedocenione (Polecenie). Kilka znalazło się tu, ponieważ chcę zgłębić ich znaczenie w przypadku gier (Pyłek oraz Obserwator). Wreszcie, niekiedy uważam po prostu, że pokazanie, w jaki sposób wzorce są uwikłane w szerszy obszar programowania może być zabawne (Prototyp oraz Stan).

2

Polecenie

Kapsułkuje żądanie w formie obiektu. Umożliwia to parametryzację klienta przy użyciu różnych żądań oraz umieszczanie żądań w kolejkach i dziennikach, a także zapewnia obsługę cofania operacji⁵.

Polecenie to jeden z moich ulubionych wzorców. W przypadku większości pisanych przeze mnie programów – gier, ale nie tylko – koniec końców znajduje on gdzieś zastosowanie. Jeśli wykorzystuję go we właściwym miejscu, udaje się rozplątać trochę naprawdę sękatego kodu. Jak na tak elegancki wzorzec, Banda czterech – jak można się było spodziewać – stworzyła jego zawiły opis. Spójrzcie tylko wyżej.

Myślę, że wszyscy możemy się zgodzić, że jest to okropne zdanie. Po pierwsze, zniekształca metaforę, którą próbuje stworzyć – jakakolwiek by ona nie była. Na zewnątrz dziwnego świata oprogramowania, w którym słowa mogą znaczyć wszystko, „klient” to osoba – ktoś, z kim robi się interesy. Ostatnim razem, gdy sprawdzałem, istoty ludzkie nie mogły zostać „sparametryzowane”.

Następnie reszta tego zdania to po prostu lista rzeczy, do których być może moglibyśmy wykorzystać ten wzorzec. Nie oświeci nas to, chyba że nasz przypadek użycia znajdzie się akurat na tej liście. Mój zwięzły slogan opisujący wzorzec Polecenie jest następujący:

Polecenie to zreifikowane wywołanie metody.

Oczywiście „zwięzły” często znaczy tyle co „mętnie zdawkowy”, więc może to wcale nie oznaczać poprawy. Rozwijając to nieco: „reifikować” – na wypadek, gdybyście nigdy nie słyszeli tego słowa – znaczy „uczynić rzeczywistym”. Inny termin na reifikowanie to uczynienie czegoś „pierwszoklasowym”.

Słowo „reifikować” pochodzi od łacińskiego słowa „res”, które znaczy „rzecz” i końcówki „-ować”. Zasadniczo znaczy więc ono tyle, co „urzeczowić”, którego użycie, szczerze mówiąc, byłoby znacznie lepsze.

Oba terminy oznaczają wzięcie jakiegoś pojęcia i przemienienie go we fragment danych – jakiś obiekt – który można umieścić w zmiennej, przekazać do funkcji itp. Tak więc, mówiąc, że wzorzec Polecenie to „zreifikowane wywołanie metody”, mam na myśli to, że jest to wywołanie metody opakowane w obiekt.

Systemy odbicia w niektórych językach pozwalają nam w naszym programie na imperatywną pracę z typami w czasie wykonania. Możemy dostać obiekt, który reprezentuje klasę jakichś innych obiektów i możemy się z nim bawić, obserwując, co ten typ jest w stanie zrobić. Innymi słowy, refleksja to zreifikowany system typów.

Przypomina to w dużym stopniu „wywołanie zwrotne”, „funkcję pierwszoklasową”, „wskaźnik do funkcji”, „domknięcie” lub „funkcję częściowo aplikowaną”, w zależności od tego, z jakiego języka się wywodzimy. I rzeczywiście, wszystkie te kwestie są do siebie zbliżone. Banda czterech stwierdza następnie: „Polecenia to zorientowane obiektowo zamienniki wywołania zwrotnego”.

Byłoby to lepszym sloganem dla tego wzorca niż ten, który wybrała Banda czterech. Wszystko to jednak jest abstrakcyjne i mgliste. Chciałbym rozpoczynać rozdziały od czegoś konkretnego i pod tym względem zawaliłem. Aby to wynagrodzić, odtąd będę posługiwał się już wyłącznie przykładami, do których polecenia pasują znakomicie.

Konfigurowanie poleceń użytkownika

W każdej grze gdzieś znajduje się fragment kodu, który wczytuje surowe polecenia użytkownika – wciśnięte przyciski, zdarzenia w obrębie klawiatury, kliknięcia myszki – cokolwiek. Bierze on każdą taką daną wejściową i przekłada ją na sensowne akcje w obrębie gry:

Rysunek 2.1. Przyciski odwzorowane na akcje w grze

Najprostsza implementacja wygląda jakoś tak:

Wskazówka: nie należy za często wciskać B.

Zwykle ta funkcja jest wywoływana raz na każdą klatkę przez pętlę gry (rozdz. „Pętla gry”) i jestem pewny, że łatwo można dojść do tego, co takiego robi. Ten kod działa, jeśli chcemy na sztywno przypisać polecenia użytkownika do akcji w grze, ale wiele gier pozwala użytkownikowi na skonfigurowanie odwzorowania przycisków.

Aby to obsłużyć, musimy zamienić te bezpośrednie odwołania do jump() i fireGun() na coś, co będziemy w stanie podstawić. „Podstawianie” bardzo przypomina przypisywanie wartości zmiennej, potrzebujemy więc obiektu, który może nam posłużyć za reprezentację akcji w grze. Wprowadzamy wzorzec Polecenie.

Definiujemy klasę bazową, która reprezentuje wywoływalne polecenie w grze:

Jeśli mamy interfejs z pojedynczą metodą, która niczego nie zwraca, jest spora szansa, że jest to wzorzec Polecenie.

Następnie tworzymy podklasy dla każdej z różnych akcji w grze:

W naszym kodzie obsługi wejścia przechowujemy wskaźnik do polecenia dla każdego przycisku:

Teraz kod odpowiadający za obsługę wejścia po prostu przekazuje to do:

Zauważyliście, że nie sprawdzamy tu NULL? Oznacza to założenie, że każdy przycisk będzie miał dowiązane do siebie jakieś polecenie.
Jeśli chcemy obsługiwać przyciski, które nic nie robią i nie muszą bezpośrednio sprawdzać NULL, możemy zdefiniować klasę polecenia, którego metoda execute() nie robi nic. Następnie, zamiast ustawiać kod obsługi przycisku na NULL, robimy tak, aby wskazywał on na ten obiekt. Jest to wzorzec nazywany „Pustym obiektem”.

Wcześniej każde polecenie bezpośrednio wywoływało funkcję, a teraz mamy warstwę pośrednią.

Rysunek 2.2. Przyciski odwzorowane na przypisywalne polecenia

Oto wzorzec Polecenie w pigułce. Jeśli już możemy dostrzec jego wartość, resztę tego rozdziału możemy potraktować jako bonus.

Kierowanie aktorami

Klasy poleceń, które dopiero co zdefiniowaliśmy, sprawdzają się w poprzednim przykładzie, ale są dość ograniczone. Problem polega na tym, że zakładają one, iż istnieją funkcje najwyższego poziomu takie jak jump(), fireGun() itd., które w ukryty sposób wiedzą, w jaki sposób odnaleźć awatar gracza i zmusić go do tego, aby tańczył tak, jak mu zagramy.

To założone sprzężenie ogranicza użyteczność tych poleceń. JumpCommand może sprawić, aby skoczył jedynie gracz. Poluzujmy to ograniczenie. Zamiast wywoływać funkcje, które same odnajdują obiekty, których dotyczy polecenie, przekażemy obiekt, któremu chcemy rozkazywać:

W tym przypadku GameActor to nasza klasa „obiektu gry”, która reprezentuje jakąś postać w świecie gry. Przekazujemy ją do execute(), tak by pochodne polecenie mogło wywoływać metody na tych postaciach, które sobie wybierzemy, np. w taki sposób:

Teraz możemy wykorzystać tę jedną klasę, aby sprawić, że dowolna postać w grze będzie skakać. Między kodem obsługi wejścia a poleceniem brakuje nam jedynie jakiegoś kawałka, który przyjmuje polecenie i wywołuje je na właściwym obiekcie.

Najpierw zmieniamy handleInput() tak, by zwracało polecenia:

Nie możemy wykonać polecenia od razu, ponieważ nie wie ono, do którego aktora przekazać. W tym miejscu wykorzystujemy fakt, że polecenie jest zreifikowanym wywołaniem – możemy opóźnić moment wykonania wywołania. Następnie potrzebne nam będzie trochę kodu, który przyjmuje to polecenie i wykonuje je na aktorze reprezentującym gracza. Coś takiego:

Zakładając, że aktor odnosi się do postaci gracza, umożliwi to poprawne kierowanie nim na podstawie poleceń użytkownika. Wracamy więc do tego samego zachowania, jakie mieliśmy w pierwszym przykładzie. Dodanie warstwy pośredniej między poleceniem a wykonującym je aktorem pozwoliło nam uzyskać małą i zgrabną właściwość: możemy teraz pozwolić graczowi na kontrolowanie dowolnego aktora w grze, zmieniając aktora, na którym wykonujemy te polecenia.

W praktyce nie jest to powszechna cecha, istnieje jednak podobny przypadek użycia, który rzeczywiście często się pojawia. Jak dotąd rozważaliśmy jedynie postacie kierowane przez gracza, co jednak z wszystkimi innymi aktorami w świecie gry? Te sterowane są przez sztuczną inteligencję gry. Możemy wykorzystać ten sam wzorzec Polecenie w charakterze interfejsu między silnikiem sztucznej inteligencji i aktorami. Kod sztucznej inteligencji emituje po prostu obiekty Command.

W tym miejscu oddzielenie sztucznej inteligencji wybierającej polecenia oraz kodu aktora, który je wykonuje, daje nam mnóstwo elastyczności. Dla różnych aktorów możemy wykorzystać różne moduły sztucznej inteligencji. Możemy też dobrać sztuczną inteligencję do różnych rodzajów zachowań. Chcemy bardziej agresywnego przeciwnika? Zwyczajnie wpinamy bardziej agresywną sztuczną inteligencją, aby wygenerować dla niego polecenia. W rzeczywistości możemy nawet podpiąć sztuczną inteligencję do postaci gracza, co może się przydać np. w trybie demo, gdy gra musi działć w trybie autopilota.

Tworząc polecenia kontrolujące pierwszoklasowe obiekty aktora, usunęliśmy ścisłą zależność bezpośredniego wywołania metody. Zamiast tego możemy o tym pomyśleć jako o kolejce czy też strumieniu poleceń:

Rysunek 2.3. Kiepsko narysowana analogia

Znacznie więcej informacji na temat tego, do czego może nam się przydać kolejkowanie znaleźć można w rozdziale Kolejka zdarzeń.

Dlaczego odczułem potrzebę stworzenia rysunku przedstawiającego „strumień”? I dlaczego wygląda on jak rura?

Jakiś kod (odpowiadający za obsługę wejścia lub sztuczną inteligencję) tworzy polecenia i umiejscawia je w strumieniu, inny kod (dyspozytor lub sam aktor) konsumuje polecenia i je wywołuje. Wtykając w środek tę kolejkę, oddzieliliśmy producenta z jednej strony i konsumenta z drugiej.

Jeśli sprawimy, aby te polecenia były możliwe do serializacji, możemy przesłać ich strumień przez sieć. Możemy wziąć polecenia gracza, pchnąć je po sieci do innej maszyny, a następnie ponownie je odtworzyć. Jest to istotny element tworzenia wieloosobowych gier sieciowych.

Przypisy

1 Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, przeł. Tomasz Walczak, Helion 2017 (przyp. tłum.).

2 Cyt. za: Blaise Pascal, Prowincjałki, przeł. Tadeusz Boy-Żeleński. Bezpłatne elektroniczne wydanie książki dostępne jest na stronach projektu „Wolne Lektury”: https://wolnelektury.pl/katalog/lektura/prowincjalki/.

3 Cyt. za: Antoine de Saint-Exupéry, Ziemia, planeta ludzi, przeł. Wiera i Zbigniew Bieńkowscy, Muza, 2002.

4 W USA alkohol można legalnie zakupić dopiero po ukończeniu 21 roku życia (przyp. tłum.).

5 Cytat za: Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, przeł. Tomasz Walczak, Helion 2017 (przyp. tłum.).