Wytykanie niedoskonałości i różnego rodzaju felerów języka C++ to jedno z ulubionych zajęć niektórych programistów, zwłaszcza tych którzy z jakichś powodów muszą z tego języka korzystać. Sam czasami to robię, ale dzisiaj chcę zwrócić uwagę na to, co C++ robi dobrze – albo przynajmniej lepiej niż w wielu innych, popularnych językach. A chodzi tu o swobodę w definiowaniu wielu wersji tej samej funkcji, różniących się listą parametrów. Nazywa się to zwykle przeciążaniem, chociaż nie jest to jedyny sposób na osiągnięcie takiego efektu.
Innym są bowiem parametry domyślne i istnieje przynajmniej jeden język, w którym jest to sposób jedyny. Tak jest bowiem w Pythonie i nawet czasami ma to sens, biorąc pod uwagę brak deklarowanych typów w tym języku. Jednak równie często wymaga to czegoś w stylu samodzielnego rozstrzygania przeciążania już wewnątrz funkcji:
Powyższe if
y to nic innego jak boilerplate code: redundantne, powtarzalne fragmenty kodu, które w dodatku robią tutaj to, co w innych językach spoczywa na barkach kompilatora. Usprawiedliwieniem może być pewnie to, że przecież Python nie jest kompilowany ;P
Ale jak wspomniałem wcześniej, język spod znaku węża posiada możliwość definiowania argumentów domyślnych, za co należy mu oddać honor. Niestety nie da się tego powiedzieć o kilku innych, popularnych dziś językach, jak choćby C# i Javie. Jeśli chcemy mieć w nich dwie sygnatury tej samej metody, musimy ją przeciążyć. Otrzymujemy wtedy dwie wersje (lub więcej), z których jedna – ta posiadająca mniej argumentów – jest po prostu wywołaniem drugiej. Widuje się to często w konstruktorach, z których czasem układa się swego rodzaju sztafeta wywołań:
Jak łatwo zauważyć, jest ona wybitnie rozwlekła, trudna w utrzymaniu i modyfikacji, a także mało czytelna, gdyż wartości domyślne argumentów są ukryte w wywołaniach zamiast w deklaracjach funkcji. Jej odpowiednik z argumentami domyślnymi byłby natomiast krótki, przejrzysty, zrozumiały i łatwy do ewentualnej zmiany.
“Problem” z tworzeniem wielu wersji funkcji polega właśnie na tym, iż mimo istnienia dwóch sposobów na osiągnięcie tego samego efektu, bardzo często jeden jest wyraźnie lepszy niż drugi. Stąd każdy język nieposiadający wsparcia dla któregoś z nich niejako z automatu kreuje sytuacje, dla których rozwiązania trzeba szukać na około.
I tu pozytywnie wyróżnia się C++, który pozwala na stosowanie obu technik (przeciążania lub argumentów domyślnych) w zależności od potrzeb. Również razem, jeśli rezultat nie powoduje niejednoznaczności w wywołaniach. Z pewnością jest to cenna cecha tego języka, równoważąca znaczącą część jego znanych niedostatków :)
Często powtarzanym bon motem jest twierdzenie, iż przedwczesna optymalizacja (preemptive optimization) jest źródłem wszelkiego zła. Dużo w tym racji i równie dużo przesady. Jednak na pewno faktem jest, że pisanie wydajnych i efektywnych, czyli zoptymalizowanych (“przedwcześnie” lub poniewczasie) programów nie jest prostym zadaniem.
W celu łatwiejszej identyfikacji obszarów, w których można zastosować polepszające wydajność poprawki, możliwe rodzaje optymalizacji zwykle dzieli się na kilka rodzajów. Niektóre z nich są aplikowane przez kompilator i pozostają w dużym stopniu poza kontrolą programisty, który może co najwyżej unikać sytuacji blokujących możliwość ich zastosowania – jeśli w ogóle o nich wie. Pozostałe można pewnie zaklasyfikować na kilka sposobów, a podziałem stosowanym przeze mnie jest następujący:
Kolejność na powyższej liście odpowiada głównie zakresowi, w jaki optymalizacje wpływają na kształt programu i jego kod. Dość powszechna jest aczkolwiek opinia, że zmiany o bardziej dalekosiężnych skutkach dają też wyraźniejsze efekty, co generalnie nie musi być prawdą. Wystarczy przypomnieć sobie znaną zasadę Pareto, która w tym przypadku oznacza, że 10/20% jest wykonywana przez 90/80% czasu, więc nawet drastyczne optymalizacje zastosowane wobec pozostałej jego części dadzą raczej nikły efekt.
Optymalizację dodatkowo komplikuje wiele innych spraw, jak choćby to, że:
Po uwzględnieniu tych uwag wychodzi na to, że najbezpieczniejszym rodzajem optymalizacji jest ten, który polega na odpowiednim refaktoringu kodu – zwłaszcza, jeśli jest on uzasadniony przeprowadzonym uprzednio profilowaniem. Może się bowiem okazać, że najbanalniejsza zmiana, jak np. wydzielenie globalnego obiektu tworzonego w często wywoływanej funkcji, daje natychmiastowy i zauważalny efekt. A jest to efektem jedynie tego, iż optymalizacja była po prostu zastosowana we właściwym miejscu.
I na pewno nie była “przedwczesna” ;P
W Pythonie jest wiele konstrukcji językowych, które wydawać się mogą dziwne dla programistów przyzwyczajonych do innych języków. O kilku z nich już pisałem, a o paru innych pewnie zdarzy mi się jeszcze napomknąć. Dzisiaj jednak chcę wspomnieć o mechanizmie dobrze znanym z wielu innych języków, którego Python nie posiada w ogóle i jakoś sobie z tym brakiem radzi. Mam tu na myśli tytułowe typy wyliczeniowe, czyli enumy.
Jeśli zazwyczaj programujemy w językach kompilowanych ze ściśle kontrolowanymi typami, taki brak może się wydawać co najmniej irytujący. Wiemy oczywiście, że podobną funkcję może pełnić zestaw odpowiednich stałych, ale odpowiedniość nie jest zwykle dokładna – w Javie czy C# konstrukcja enum
tworzy na przykład dodatkowy zasięg. Lecz nie jest to jedyna i prawdopodobnie też najważniejsza różnica.
Zdaje mi się raczej, że kluczową cechą typów wyliczeniowych jest to, że definiują one tylko pewien abstrakcyjny zbiór możliwości – bez konieczności ustalania, czym dokładnie jest każda z nich. Naturalnie wiadomo, że “pod spodem” są to po prostu liczby (aczkolwiek w Javie jest trochę inaczej), ale nie musimy się zastanawiać, skąd się one wzięły. Nie musimy nawet wiedzieć, do jakiego typu liczbowego one należą, choć niekiedy (np. w C#) możemy to doprecyzować.
Ta ostatnia cecha nie jest jednak niczym niezwykłym w języku o dynamicznym typowaniu, takim jak Python. Nieokreśloność typu dotyczy tu bowiem każdej zmiennej i dlatego nie za bardzo pasuje tu koncepcja ograniczania jej wartości do jakiegoś z góry ustalonego zbioru. Technicznie rzecz ujmując, nie bardzo też da się to zrobić.
Podobnie niezbyt pasującą do Pythona koncepcją jest sterowanie logiką za pomocą zbioru wariantów wziętych “znikąd”, czyli stałych wyliczeniowych o automatycznie generowanych wartościach. Brak w tym języku instrukcji switch
jest pewnie również konsekwencją odejścia od tego rodzaju abstrakcji. Założenie jest raczej takie, aby w miarę możliwości operować na surowych danych i nie dokonywać na nich żadnych pojęciowych “wygładzeń”. Ma to sens, gdyż dwa podstawowe cele abstrahowania wartości na zbiór przypadków – upraszczanie API i zwiększanie efektywności – niespecjalnie aplikują się do Pythona.
Jak to jednak bywa w prawdziwym świecie, coś w rodzaju typów wyliczeniowych przydaje się czasami mimo wszystko. Odpowiedzią jest wtedy rzeczywiście zestaw stałych, zapewne zgrupowanych pod szyldem wspólnego zasięgu klasy. Ponieważ musimy nadać im wartości, możemy zadbać o to, by bezpośrednio odnosiły się do danych, które przetwarzamy. A jeśli w skrajnym przypadku trzeba faktycznie wziąć je z powietrza, wystarczy zastosować poniższy idiom z rozpakowywaniem range
‘a:
Istnieją oczywiście bardziej wyrafinowane rozwiązania, pozwalające chociażby na iterowanie po wszystkich nazwach i wartościach naszego enuma. Sądzę jednak, że podobna funkcjonalność jest przydatna raczej rzadko.
Bardzo przydatną cechą operatorów logicznych w wielu językach programowania jest leniwa ewaluacja (lazy evaluation). Polega ona na pominięciu obliczania tych argumentów operatorów &&
(and
) i ||
(or
), które i tak nie mają szans wpłynąć na ostateczny wynik. To pozwala na tworzenie warunków podobnych do poniższego:
Drugi człon nie wykona się tutaj w ogóle, jeśli pierwszy okaże się fałszywy, więc zmienna obj
ustawiona na null
nie spowoduje błędu wykonania.
Oczywiście technika ta jest doskonale znana każdemu przynajmniej średnio zaawansowanemu programiście. Okazuje się jednak, że przynajmniej jeden język idzie dalej i uogólnia ją w sposób pozwalający na stosowanie operatorów and
i or
do argumentów niebędących wartościami logicznymi. Jaki to język?… Python, rzecz jasna :)
W Pythonie dwa standardowe operatory ‘logiczne’ działają w oparciu o możliwość określenia specyficznie pojmowanej prawdziwości wyrażenia, którego typem niekoniecznie jest bool
. Mówiąc w skrócie, każde wyrażenie niepuste i różne od zera – łącznie z odwołaniami do obiektów, liczbami, tablicami, słownikami i innymi kolekcjami – jest uważane za prawdziwe, gdy wystąpi w kontekście wymagającym rozróżnienia prawdy i fałszu.
Takim kontekstem jest chociażby warunek instrukcji if
lub while
– ale nie tylko. Możliwość “rzutowania na bool
” (fachowo nazywanego koercją) jest też wykorzystywana w definicji operatorów and
i or
, które są z grubsza następujące:
A and B
jest równe:
A
, jeśli A
jest wyrażeniem fałszywymB
– w przeciwnym wypadkuA or B
jest równe:
A
, jeśli A
jest wyrażeniem prawdziwymB
– w przeciwnym wypadkuW pierwszej chwili mogą one wydawać się dość skomplikowane, ale nietrudno jest zauważyć, że “działają” one zgodnie z oczekiwaniami wobec argumentów typu bool
i mogą być obliczane leniwie. Ponieważ jednak dzięki nim rezultatem operatora nie jest po prostu True
lub False
, lecz jeden z argumentów, możliwe jest stosowanie and
i or
także wtedy, gdy wynikiem nie ma być wcale wartość logiczna.
Wbrew pozorom ma to czasem wielki sens. Oto bardzo typowy przykład kodu, który korzysta z tej sztuczki:
Co tu się dzieje?… Jeśli wywołanie funkcji zwróci prawdziwą (czyli niepustą i niezerową, więc zapewne sensowną) wartość, jest ona wyświetlona. W przeciwnym razie korzysta się z napisu zastępczego. Dzięki elastycznemu operatorowi or
łatwo więc można określić pewnego rodzaju wartość domyślną (czyli fallback) dla wyrażenia.
Z kolei operator and
jest często wykorzystywany do warunkowego odwoływania się do “głęboko ukrytej” wartości, wymagającej przejścia przez ciąg kilku potencjalnie pustych odwołań:
Jeśli któreś z nich jest równe None
, to taki będzie rezultat całej konstrukcji. W przeciwnym razie wynikiem będzie ostatni argument.
Istnieje szansa, że przynajmniej jeden z powyższych mechanizmów wygląda znajomo, jeśli ma się doświadczenie w językach Java lub C#. Sztuczka z operatorem or
odpowiada bowiem podwójnemu znakowi zapytania (??
) z C#, zaś przykład z and
wprowadzonemu w Javie 7 operatorowi ?.
(znak zapytania i kropka).
Zapewne też w tym momencie wszyscy przypomną sobie o starym dobrym operatorze trójargumentowym, występującym we wspomnianych dwóch językach i jeszcze wielu innych. Okazuje się, że w Pythonie jego działanie też można symulować przy użyciu operatorów and
i or
:
Nie trzeba jednak tego robić, bowiem od wersji 2.5 istnieje nieco inny składniowo odpowiednik takiej konstrukcji:
Warto zwrócić uwagę na inną niż typowa kolejność wyrażeń w tej konstrukcji.
Mam ciekawą obserwację związaną ze sposobem używania różnych systemów kontroli wersji. Dokładniej mówiąc, chodzi o dość wyraźną różnicę w częstotliwości commitów między zwykłymi a rozproszonymi VCS-ami. Do tych pierwszych kod commituje się stosunkowo rzadko, ponieważ każda zmiana jest od razu widoczna w publicznym repozytorium. To ma zaś daleko idące konsekwencje, jak choćby natychmiastowa dostępność naszych modyfikacji dla innych osób pracujących nad projektem. Dlatego też trzeba starać się, aby nie wypuścić bubla. Minimalnym wymaganiem jest kompilowalność commitowanego kodu, a dobrze by było, żebyśmy też poddali go choćby wstępnemu testowaniu.
Gdy z kolei pracujemy z rozproszonymi systemami kontroli wersji, możemy teoretycznie robić dokładnie tak samo. Wówczas jednak nie tylko nie korzystamy z faktu posiadania lokalnego repozytorium, ale wręcz dodajemy sobie pracy. Osiągnięcie tego samego efektu (upublicznienia zmian) wymaga bowiem dwóch kroków – dodatkowym jest push, czyli synchronizacja z globalną bazą kodu. Będąc przyzwyczajonym do scentralizowanych VCS-ów można łatwo o nim zapomnieć.
Dlatego też wydaje mi się, że przy systemie rozproszonym warto nieco zmienić nawyki. Commitów można bowiem dokonywać częściej – znacznie częściej. Ponieważ żaden z nich nie wydostaje się automatycznie poza nasz komputer, nie muszą one dodawać kompletnych funkcjonalności albo w pełni poprawiać znalezione wcześniej błędy. Nie muszą działać. Ba, w zasadzie to nie muszą nawet się kompilować. Grunt, żeby zawierały modyfikacje, które wydają nam się warte zachowania i opatrzenia komentarzem. W praktyce w ciągu dnia można w ten sposób wykonać nawet do kilkudziesięciu commitów – w porównaniu do dwóch lub trzech w przypadku pracy z system scentralizowanym.
Czy taka częstotliwość dobrze wpływa na efektywność kodowania? Z początku nie byłem o tym przekonany, ale teraz widzę, że ma ona niewątpliwe zalety. Wykonując częste commity (a zwłaszcza opisując je), programujemy w sposób bardziej zdyscyplinowany. Trochę trudniej jest wtedy napisać wysublimowany, barokowo skomplikowany i ogólnie przekombinowany moduł albo takąż klasę. Mamy raczej większe szanse na to, że poczynimy jakieś realne postępy w pracy nad projektem. Dodatkowo częste “odhaczanie” wykonanych zadań i poczynionych postępów (nawet jeśli są bardzo drobne) jest bardziej motywujące niż oznaczanie zmian rzadkich a duże.
A co jeśli naszą przeszkodą w wykonywaniu częstych commitów jest brak weny twórczej odnośnie komentarzy do nich?… No cóż, wtedy zawsze można poszukać inspiracji tutaj ;-)
Gdyby było to fizycznie możliwe, chętnie przeprowadziłbym następujący eksperyment. Z odległej przeszłości – na przykład z połowy lat 90. poprzedniego stulecia – sprowadziłbym w obecne czasy dowolnego ponadprzeciętnie uzdolnionego programistę. Jak szybko odnalazłby się we współczesnym koderskim środowisku pracy?… W celu redukcji złożoności problemu poczyńmy daleko idące uproszczenia i pomińmy wszelkiego typu zmiany związane z postępem technologicznym (jak choćby nieporównywalnie większe znaczenie Internetu wtedy i teraz), a także modyfikacje, które zachodzą w samych językach programowania. Interesuje mnie raczej to, czy ów przybysz z przeszłości doceniłby i uznał za przydatne różnego rodzaju czynności i narzędzia pomocnicze, niebędące edytorem tekstu (lub IDE) ani kompilatorem, i pozostające w luźniejszym związku z samym pisaniem kodu jako takiego.
Jakby bowiem przyjrzeć się dokładnie wachlarzowi tych narzędzi, okazuje się, że jest on już całkiem szeroki. Programowanie, zwłaszcza zespołowe (jeżeli w ogóle istnieje jeszcze jakieś inne) już od dawna przestało ograniczać do tworzenia kodu i czasami wydaje się nawet, że czynność ta stała się poboczną. Coraz więcej czasu zajmuje praca z takimi narzędziami jak systemy kontroli wersji, systemy śledzenia błędów (issue tracking systems), narzędzia automatyzujące proces kompilacji, programy do statycznej analizy kodu, systemy zdalnej kompilacji i pewnie mnóstwo jeszcze innych wynalazków, z którymi nie miałem dotąd okazji się zetknąć.
Między innymi dlatego nie potrafię jednoznacznie określić, czy uważam je wszystkie raczej za przydatne czy raczej za zbędne. Wiążące opinie mogę jak dotąd wyrazić tylko o niektórych.
Tym niemniej ciekawi mnie również, czy w dziedzinie wspomagania kodowania (czy ogólnie pracy nad projektami) od strony zautomatyzowanych narzędzi da się wymyślić coś jeszcze…
Jako dzisiejszą ciekawostkę chcę przedstawić pewną technikę, która może być przydatna przy implementacji systemu GUI na przykład na potrzeby gier. Zazwyczaj bowiem elementami interfejsu są różnego rodzaju kontrolki, które – chociaż nierzadko nie przypominają klasycznych widgetów w rodzaju przycisków czy pól tekstowych – posiadają prostokątne obramowanie. Ta ramka musi mieć jedną istotną cechę: powinna dopasowywać się do rozmiaru kontrolki, dopuszczając elementy o dowolnie dużych wymiarach. A jednocześnie powinna ona być utrzymana w odpowiednim stylu graficznym, zwykle spójnym z resztą aplikacji lub gry.
Sposobem na sprostanie tym wymaganiom jest podział prostokąta kontrolki na dziewięć części, różniących się zakresem skalowania, jakiemu są poddawane, gdy kontrolka zmienia rozmiary. Wśród tych części możemy mianowicie znaleźć następujące:
Żeby więc poprawnie narysować naszą kontrolkę, potrzebujemy w ogólności aż dziewięciu różnych obrazków. Oczywiście niekiedy będzie ich mniej, jeśli sprytnie posłużymy się obrotami i odbicia obrazów. Prawie na pewno jednak będzie to więcej niż jeden sprite i każdy z nich trzeba będzie jakoś oznaczyć i zapisać razem z naszą aplikacją, wymyślając do tego mniej lub bardziej skomplikowaną konwencję.
To, co chcę przedstawić, to w gruncie rzeczy taka właśnie konwencja. Nazywa się ona nine-patch images, czyli tytułowe obrazki dziewięciołatkowe. Pochodzi ona z systemu Android i jest jedną z wielu może mało efektownych, ale pomysłowych rozwiązań, ukrytych w jego API.
Idea jest dość prosta. Jeśli mamy już obramowanie w postaci obrazka otoczonego nim prostokąta, nie musimy wycinać z niego poszczególnych części i zapisywać ich osobno. Zamiast tego zaznaczamy je bezpośrednio na obrazku, dodając wpierw jednopikselowe, czarno-białe obramowanie. Czarny fragment określa część podlegającą skalowaniu (czyli środek), zaś biały – niezmieniające rozmiaru mu rogi prostokąta. Najlepiej widać to na ilustracji po lewej stronie.
Można na niej zauważyć, że do określenia obszaru skalowania wystarczy zasadniczo jeden dodatkowy rząd i jedna dodatkowa kolumna pikseli. Pozostała część dodatkowego obramowania może nam wtedy posłużyć do zdefiniowania obszaru treści, czyli tego fragmentu kontrolki, który będzie mógł być wypełniony tekstem i innymi elementami, ustawionymi jako jej zawartość. Obszar treści nie musi pokrywać się z obszarem skalowania, co z kolei jest widoczne na obrazku po prawej stronie.
Z tak przygotowanej grafiki wciąż możemy w miarę łatwo odczytać programowo potrzebne informacje o sposobie rysowania obramowania kontrolki i wypełniania jej treści. Rozwiązanie to ma przy tym tę oczywistą zaletę, że przygotowanie takiej grafiki jest z pewnością mniej pracochłonne i kłopotliwe niż tworzenie od 3 do 9 osobnych sprite‘ów i podawanie informacji o obszarze treści w jakiegoś rodzaju pliku konfiguracyjnym.
Jeśli więc ktoś w ramach silnikologii stosowanej zajmuje się implementacją modułu GUI, może rozważyć zastosowanie podobnego systemu :)