W aplikacjach, gdzie tworzone są kosztowne obiekty lub chcemy ograniczyć ich tworzenie, przydać się może wzorzec projektowy Object Pool. Omówienie wzorca znajdziesz w najnowszym artykule na moim blogu. W artykule dowiesz się, jak działa ten wzorzec, poznasz jego charakterystykę, przykładowe zastosowania i przykładową implementację. Omawiam również potencjalne problemy, jakie można napotkać w trakcie implementacji.
Object Pool to wzorzec projektowy, który pomoże Ci ograniczyć konstruowanie kosztownych obiektów. Sprawdź, jak możesz go wykorzystać!
https://devszczepaniak.pl/wzorzec-projektowy-object-pool/Zapewne wielu z was widziało materiał od Casey Muratori zwany "Clean" Code, Horrible Performance traktujący o tym, jak wydajność oprogramowania cierpi przez przyjęte zasady wytwarzania ”czystego kodu”. O ile to jak najbardziej prawda, co zresztą Casey ładnie w tym materiale wykazał, o tyle jest z tym jeden zasadniczy problem — skalowalność.
Tego typu optymalizacje wspaniale prezentują się na przykładzie bzdurnych figur geometrycznych, natomiast kiedy przychodzi czas na ich zastosowanie w sensownym, szczególnie dużym projekcie, to już na samą myśl chce się rwać włosy z głowy.
Jeśli potrzebuję prostych struktur danych, których zawartość nie jest dziedziczona, korzystam ze zwykłych rekordów. Jeśli taka struktura nie jest krytyczną, a do dowolnego użytku (np. punkt, obszar, wierzchołek itp.), czyli jej zawartość może być swobodnie modyfikowana, pola struktury są widoczne.
Natomiast jeśli struktura zawiera w sobie elementy, które muszą być w jakiś sposób zarządzane (np. osadzone listy, obiekty stanu itd.) lub służą wyłącznie na wewnętrzny użytek struktury, cała struktura deklarowana jest jako nieprzezroczysta. Ot operator odniesienia pokazuje pustą listę składowych i nie ma do nich dostępu spoza modułu, w którym jest zadeklarowana.
Aby więc móc z niej odczytywać dane i je modyfikować, dla każdej takiej nieprzezroczystej struktury przygotowany jest zestaw funkcji. Zestaw ten daje dostęp tylko do tych danych, do których dostęp powinien być zapewniony, natomiast do pozostałych dostępu nie ma. Ot zwykła enkapsulacja, tyle że struktur zamiast klas.
Drugim typem struktur danych są te, które mogą być dziedziczone. Ponieważ pascalowe rekordy nie obsługują dziedziczenia, używam do tego celu klasycznych obiektów, wprowadzonych do języka Turbo Pascal, jako pierwszy krok Borlanda w kierunku implementacji nowoczesnej na tamte czasy obiektowości. Mimo, że takie obiekty wspierają niemal wszystkie ficzery OOP (oprócz interfejsów), na fundamentalnym poziomie są binarnie zgodne z rekordami — zajmują w pamięci tyle ile suma rozmiaru wszystkich pól, a jeśli nie używa się metod wirtualnych, VMT w ogóle nie jest alokowany. Tak więc stare dobre obiekty mogą być używane dokładnie tak samo jak proste struktury danych, ale w gratisie ma się dostęp do dziedziczenia.
Casey w ww. materiale złamał wszystkie zasady czystego kodu, aby zyskać maksymalną wydajność. Pozbył się klas, a więc VMT (wirtualnych metod), zapewnił bezpośredni dostęp do internalsów struktur, a nawet przeniósł podobne dane do lookup-tablic. Ile można było uprościć, tyle uprościł i przyspieszył działanie kodu ~dwudziestokrotnie — nieźle.
Mam więc w silniku bazowy typ kontrolki UI — nazwijmy ją UiControl
. Jest to klasyczny, turbo-pascalowy obiekt, zawierający kilka pól. Na podstawie tego bazowego typu mogę stworzyć konkretny typ kontrolki (np. UiButton
), odziedziczyć dane z typu bazowego oraz dodać te specyficzne dla przycisku (np. Caption
). Taki typ przycisku mogę ponownie rozszerzyć i stworzyć np. UiGlyphButton
, czyli przycisk z obrazkiem obok tekstu.
I tutaj pojawia się problem — jak z jednej strony pominąć VMT, a jednocześnie zapewnić polimorfizm? Ten jest absolutnie konieczny, dlatego że kontener UI, w którym osadzone są kontrolki, nie ma bladego pojęcia o tym czym konkretnie dana kontrolka jest i jak ma ją obsłużyć, a potrzebuje ją przecież aktualizować, zarówno na podstawie inputu użytkownika, jak i popchnąć do przodu jej animacje (w każdej klatce gry).
Skoro nie mamy VMT ani żadnych wskaźników na funkcje bazowe, możemy zrobić to tak, jak sugerują zwolennicy ”brudnego kodu”, czyli na podstawie typu kontrolki (enum lub liczba) zapisać sobie instrukcję wyboru i w niej wywołać docelową funkcję, przeznaczoną stricte dla danego typu kontrolki. Mniej więcej w ten sposób:
type
PUiControl = ^TUiControl;
TUiControl = object
Type_: Integer; // typ kontrolki
// tutaj pola z różnymi danymi
end;
procedure UiControlUpdate (AControl: PUiControl);
begin
case AControl^.Type_ of
UI_BUTTON: UiButtonUpdate(AControl);
UI_SLIDER: UiSliderUpdate(AControl);
{..}
end;
end;
Wszystko fajnie — skoro kontener UI nie wie jakiego typu jest kontrolka, ot wywołuje UiControlUpdate
i ta ją rozróżnia, a następnie aktualizuje konkretną funkcją. Aby więc zyskać polimorfizm, wystarczy postąpić odwrotnie niż w przypadku typowego OOP — klasa bazowa ma informacje o typie końcowym (skonkretyzowanym).
Ale tutaj zamiast wywołania jednej funkcji, mamy dwie, a także narzut związany z instrukcją wyboru. Można by pójść dalej i zamiast wywoływać funkcje w tej instrukcji wyboru, przenieść kod z funkcji UiButtonUpdate
i UiSliderUpdate
do wnętrza funkcji UiControlUpdate
— wtedy będziemy mieli tylko jedno wywołanie. Tak zrobił Casey w przypadku jego figur.
Problem jednak polega na tym, że docelowych, skonkretyzowanych typów kontrolek będzie dużo (obstawiam, że koło 20-30). Mało tego, kod aktualizujący każdą z tych kontrolek może być długi, to mogą być setki linijek. Tak więc upchnięcie całej logiki aktualizacji kontrolek w jednej funkcji z instrukcją posiadającą dziesiątki wyborów i wymagającą zapewne dziesiątek zmiennych lokalnych wyprodukowałoby funkcję UiControlUpdate
zawierającą tysiące linijek kodu. No słabo.
Problem drugi — Casey złamał również hermetyzację, aby z poziomu takiej funkcji mieć bezpośredni dostęp do danych każdego obiektu figury. Niedawno sprawdzałem takie podejście i się od niego odbiłem. Mając dziesiątki typów konkretnych kontrolek i dostęp do dziesiątek pól w każdej kontrolce, bałbym się cokolwiek z nimi robić, aby czegoś nie zepsuć lub nie stracić spójności ich danych. Tak więc bezpośrednia modyfikacja ich pól w giga-funkcji aktualizującej byłaby ryzykowna i trudna w utrzymaniu.
Problem trzeci — uwspólnianie logiki. Załóżmy więc, że pozostajemy przy instrukcji wyboru, w której wywoływana jest docelowa funkcja aktualizująca, np. przycisk. Aby wykonać bazowy kod aktualizacji oraz dodatkowy kod aktualizujący specyficzne dane przycisku, mamy dwa rozwiązania. Pierwsze to skopiowanie logiki bazowej funkcji aktualizującej do funkcji aktualizującej przycisk. Łamiemy więc regułę DRY, w efekcie mamy dokładnie ten sam kod w dwóch miejscach, w dodatku najpewniej w dwóch osobnych modułach (każdy typ kontrolki to osobny moduł).
W typowym OOP, czyli w przypadku klas, możemy bez problemu wywołać wirtualną metodę z klasy bazowej — robi się to za pomocą słówka inherited
. W razie chęci przekazania zmodyfikowanych parametrów do wywołania bazowego, możemy po tym słówku kluczowym napisać nazwę metody i podać jej parametry. Jeśli byśmy chcieli coś takiego zrobić w naszym przypadku, możemy to zrobić tak:
procedure UiButtonUpdate(AButton: PUiButton);
begin
// wywołanie funkcji aktualizującej typ bazowy
UiControlUpdate(AButton);
// tutaj aktualizacja danych specyficznych dla przycisku
end;
Wszystko fajnie, ale to nie zadziała — bazowa funkcja nie wykonuje żadnej aktualizacji, a jedynie wywołuje tę konkretną funkcję. To oczywiście prowadzi do nieskończonej pętli i stack overflow. Żeby móc coś takiego zrobić, trzeba by mieć dwie bazowe funkcje aktualizujące — jedna faktycznie aktualizuje dane typu bazowego, a druga jest przeznaczona wyłącznie na potrzeby polimorfizmu, czyli ta z instrukcją wyboru:
procedure UiControlUpdate(AControl: PUiControl);
begin
// tutaj aktualizacja danych zadeklarowanych w strukturze bazowej kontrolki
end;
procedure UiControlUpdatePoli(AControl: PUiControl);
begin
case AControl^.Type_ of
UI_BUTTON: UiButtonUpdate(AControl);
UI_SLIDER: UiSliderUpdate(AControl);
{..}
end;
end;
Teraz jeśli potrzebujemy zaktualizować kontrolkę w ramach polimorfizmu, korzystamy z dedykowanej funkcji. Np. kontener UI zawiera listę kontrolek, których typów docelowych nie zna, więc używamy funkcji przeznaczonej do polimorfizmu:
// dla każdej kontrolki osadzonej w kontenerze UI
for Control in UiContainer.Controls do
// wywołaj funkcję aktualizującą, bez znajomości typu docelowego
UiControlUpdatePoli(Control);
Natomiast aby z poziomu funkcji aktualizującej konkretny typ kontrolki — np. Button
— móc zaktualizować dane typu bazowego (odpowiednik inherited
z OOP), wywołujemy tę właściwą, zamiast duplikowania jej kodu:
procedure UiButtonUpdate(AButton: PUiButton);
begin
// wywołanie nie-polimorficznej funkcji aktualizującej typ bazowy
UiControlUpdate(AButton);
// tutaj aktualizacja danych specyficznych dla przycisku
end;
I wtedy będzie cacy. Tzn. nie będzie, dlatego że dla każdej takiej funkcji udającej metodę wirtualną, tak naprawdę musimy deklarować dwie osobne. W dodatku za każdym razem gdy tworzymy nowy typ skonkretyzowanej kontrolki, trzeba wracać do implementacji typu bazowego i go modyfikować. Gra nie warta świeczki, a to co opisałem wyżej to i tak nie wszystko, bo problemów może być więcej.
Jeśli dany obiekt potrzebuje funkcji, której zachowanie nie będzie redefiniowane w typach dziedziczących, to jest to zwykła funkcja. Natomiast aby mieć odpowiednik metod wirtualnych, ale bez VMT, skorzystałem ze starych dobrych pointerów na funkcje i jej bazowa wersja zadeklarowana jest w tym samym module co typ bazowy. Dla każdej funkcji, której zachowanie może być redefiniowane, w strukturze obiektu jest pointer — nazywam je top-order functions
, czyli funkcjami najwyższego rzędu:
type
PUiControl = ^TUiControl;
TUiControl = object
Update: procedure (AControl: PUiControl); // pointer na funkcję najwyższego rzędu
end;
// deklaracja funkcji jako widocznej z poziomu innych modułów
procedure UiControlUpdate (AControl: PUiControl);
W funkcji inicjalizującej obiekt kontrolki, ustawiam pointer na funkcję bazową:
procedure UiControlInitialize (AControl: PUiControl);
begin
AControl^.Update := @UiControlUpdate;
end;
A co z typami dziedziczącymi z UiControl
? To redefiniowane jest w ich funkcjach inicjalizujących. Niech za przykład posłuży UiButton
:
procedure UiButtonInitialize (AButton: PUiButton);
begin
// inicjalizacja bazowych danych kontrolki
UiControlInitialize(AButton);
// ustawienie właściwych przyciskowi wskaźników na funkcje, nadpisując bazowe
AButton^.Update := @UiButtonUpdate;
end;
W ten sposób można swobodnie nadpisywać funkcje najwyższego rzędu, nie być zmuszonym do tworzenia dwóch funkcji zamiast jednej, a także mieć polimorfizm. W dodatku, bezpośrednie wywołanie funkcji bazowej (tutaj: UiControlInitialize
w ciele UiButtonInitialize
) może być bez problemu zinline'owane.
Tak więc nawet jeśli droga dziedziczenia jest długa (np. UiControl
→ UiButton
→ UiGlyphButton
), wszystkie wywołania funkcji bazowych mogą zostać zinline'owane, a więc polimorficzne wywołanie funkcji aktualizującej może zostać zoptymalizowane przez kompilator do formy wywołania tylko jednej funkcji spod wskaźnika, co będzie wielokrotnie szybsze niż w przypadku klas i ich metod wirtualnych.
Przykład aktualizacji wszystkich kontrolek kontenera UI:
// dla każdej kontrolki osadzonej w kontenerze UI
for Control in UiContainer.Controls do
// wywołaj funkcję najwyższego rzędu spod wskaźnika z pola "TUiControl.Update"
Control^.Update(Control);
Casey elegancko pokazał jak łamiąc zasady czystego kodu, drastycznie zwiększyć wydajność kodu. Niestety wygląda to zachęcająco jedynie na prymitywnych przykładach, natomiast skorzystanie z takich technik w dużym projekcie, przerobiłoby kod na istne pole minowe — skomplikowane, niebezpieczne, trudne w utrzymaniu i czasochłonne.
Z reguły olewam przyjęte zasady, wybierając to co najlepiej pasuje do wymagań projektowych i moich preferencji. Chętnie słucham rad udzielanych przez tych, którzy stali się wrogami czystego kodu (bo to mniej lub bardziej słuszne), jednak staram się nie słuchać takich rad bezkrytycznie, bo można sobie zdewastować projekt, produkując zapas spaghetti na kilka lat.
To że nie musi posiadać metod wirtualnych jest wiadome. W przypadku tego wpisu kontekst jest jasny — jak zrobić odpowiednik metod wirtualnych dla prostych struktur danych, bez używania VMT i z minimalnym narzutem, w Pascalu. I to w sumie wszystko.
Wzorzec projektowy Flyweight pozwala na współdzielenie danych między obiektami, redukując ilość zużytej pamięci przez aplikację. Z najnowszego artykułu na moim blogu dowiesz się, jakie są założenia wzorca Flyweight oraz kiedy warto z niego skorzystać. Przygotowałem również przykładową aplikację implementującą ten wzorzec w praktyce.
Wzorzec Pyłek (Flyweight) pozwala zoptymalizować zużycie pamięci przez współdzielenie danych. Sprawdź jak wykorzystać go w praktyce!
https://devszczepaniak.pl/wzorzec-projektowy-pylek/Interpreter jest wzorcem projektowym, którego jeszcze nie miałem okazji wykorzystać w praktyce. Patrząc na jego specyfikę, raczej nieprędko się to zmieni. W najnowszym artykule na moim blogu dowiesz się, kiedy potencjalnie można by wykorzystać Interpreter. Dowiesz się też, dlaczego Interpreter jest tak trudnym w zastosowaniu wzorcem i o czym nie wspomniał Gang of Four w jego charakterystyce.
Interpreter w teorii jest przydatnym wzorcem. Jednak nigdy nie widziałem jego implementacji w praktyce. Dowiedz się w artykule dlaczego.
https://devszczepaniak.pl/wzorzec-projektowy-interpreter/@slsy no ale jeżeli robisz ewaluacje podczas parsowania to masz w sumie (parser+interpreter) trochę, czy nie? Dodatkowo możesz pominąć AST, ale czy nie daje ci to jakiejś modularności/przenaszalności, jakbyś chciał robić jakieś optymalizacje na tym AST i wynieść ten kod z parsera Przykładowo nikt nie przegląda AST danego regexa (xd)
czy aby na pewno nie chcesz zrobić sobie jakiejś wizualizacji? może nie przy regexie akurat, ale jakieś control flow graphy itd
@WeiXiao: wybacz, trochę się zagmatwałem i mam tak często z wzorcami. Z tego co rozumiem to interpreter opiera się na tym, że jest AST, parser tworzy AST, klienci je konsumują np. generują kod binarny albo robią wizualizację. Fajnie, tylko jaka jest wartość z wprowadzania takiego pojęcia
Według https://en.wikipedia.org/wiki/Chomsky_hierarchy możemy przedstawić języki jako:
Ten izomorfizm fajnie działa, bo zarówno jak klasy języków tak te struktury danych są hierarchicznie powiązane i tak jak każda lista jest drzewem tak każdy język regularny jest bezkontekstowy. Niestety mamy już design pattern pozwalający na zaimplementowanie tych dwóch przypadków i jest to kompozyt
Tak więc nie widzę na wprowadzanie takiego pojęcia jak interpreter
, bo kompozyt
jest IMO prostszy do zrozumienia i bardziej ogólny. Jedyne co mi przychodzi do głowy to fakt, że języki bezkontekstowe są bardzo popularne (wszystkie języki programowania takie są/chcą być) i ktoś uznał, że warto ten szczególny przypadek jakoś nazwać inaczej. Osobny pattern na każdy możliwą kategorię rekursywnej struktury danych jest imo bezcelowy i tylko wprowadza chaos
Wzorzec projektowy Most (Bridge) pozwala na oddzielenie abstrakcji od jej implementacji, umożliwiając ich niezależny rozwój i zmniejszając coupling w systemie. Pozwala to tworzyć kod zgodny z SRP i OCP, co pomaga utrzymać go bardziej elastycznym i skalowalnym. W najnowszym artykule na blogu wyjaśniam, kiedy warto wykorzystać wzorzec Most. Na praktycznym przykładzie pokażę Ci też jak go zaimplementować w praktyce:
Wzorzec Most pozwala na oddzielenie abstrakcji od jej implementacji, umożliwiając ich niezależny rozwój i zmniejszając coupling w systemie.
https://devszczepaniak.pl/wzorzec-projektowy-most/Kult cargo konteneryzacji z perspektywy dekady przypomina bardzo kult cargo programowania "zorientowanego obiektowo". Wtyka się to gdzie nie trzeba, ignoruje koszty i wszelką krytykę próbuje się "obalać" recytacją w koło Macieju wyuczonych na pamięć sloganów o domniemanych korzyściach [czytaj–absolutnej technologicznej supremacji nad "przestarzałymi" konkurencyjnymi rozwiązaniami]. Do tego wygenerowało populację "ekspertów" niezdolnych do pracy w innym paradygmacie niż ten obowiązujacy w sekcie. Wszystko to jak zwykle okraszone wywalaniem milionów w błoto przez korpo molochy na szkolenia, wdrożenia, refaktory, dostosowania itd i żarciem gigantycznych ilości elektryczności w skali światowej.
@robertos7778: wątpię co do mikrokomputera. Nawet minikomputery mieściły się w trzech szafach
tylko ze klasa w C++ nie musi mieć wirtualnych metod no nie musi po prostu