Długie ciągi odwołań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 ;-)
Wiosenne porządki #3 – Miękki refaktoringKiedy 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.
O pożytkach i nieużytkach UML-aStare chińskie przysłowie mówi, że klient zazwyczaj nie wie dokładnie, czego tak naprawdę chce. (Inne przysłowie mówi też, że jeśli nie znamy pochodzenia danej sentencji, to najlepiej powiedzieć, że jest to stare chińskie przysłowie). Dlatego też często nie potrafi swoich potrzeb przełożyć na opis wymagań co do oprogramowania. Jest to jeden z problemów, przy rozwiązywaniu których ma pomagać Unified Modelling Language, czyli UML. W założeniu jest to notacja, umożliwiająca rozpisanie całego procesu tworzenia aplikacji na szereg różnego rodzaju diagramów, na których znaczenie poszczególnych symboli jest ściśle określone - na pewno ściślej niż języka naturalnego. Założenie jest szczytne i bardzo ambitne, a z praktyką jest jak zwykle nieco gorzej :)
Niewykluczone, że jedną z idei przyświecających twórcom UML-a było przynajmniej częściowe zasypanie tej przepaści między dwoma etapami tworzenia: kiedy wiemy, co mamy zrobić, ale jeszcze nie mamy wielkiego pojęcia o tym, jak to zrobić. Przeskoczenie dystansu pomiędzy tymi punktami jest bowiem często kwestią odpowiedniego pomysłu, który najlepiej realizuje całą koncepcję systemu. Jak zaś wiadomo, pomysły biorą się głównie z niezbadanych obszarów pewnego organu znajdującego się między uszami i ich jakość zależy głównie od tego, do kogo ów organ należy. UML stara się więc usystematyzować programistyczną kreatywność, aby zaprojektowanie dobrze działającego systemu nie było tylko wypadkową wymysłów kłębiących się pod czaszką analityka.
Trzeba przyznać, że wychodzi mu to dość średnio. Różne rodzaje diagramów, jakie mamy do dyspozycji, nie bardzo pomagają w płynnym przechodzeniu od wymagań funkcjonalnych do projektu, który te wymagania ma spełniać. Mam raczej wrażenie, że ich celem jest głównie spoglądanie na aplikację z coraz to nowych punktów widzenia. Patrzymy więc na projekt z perspektywy użytkownika zewnętrznego (diagram przypadków użycia), zmieniających się stanów obiektów (diagramy stanów), przepływu danych i obiektów między "miejscami" w programie (diagramy interakcji), i tak dalej. W sumie widzimy coraz więcej pojęć, związków, relacji i zależności, przez co projekt - zamiast upraszczać się, co z pewnością kieruje nas bliżej implementacji - komplikuje się jeszcze bardziej.
Większość z tych konstrukcji nie jest zresztą widoczna w wynikowym kodzie. Nic więc dziwnego, że spośród całego UML-a zdecydowanie najpopularniejsze i najczęściej stosowane są diagramy klas i diagramy sekwencji (przepływu zdarzeń). Odpowiadają one bowiem niemal bezpośrednio strukturze klas i ich składowych oraz przepływowi sterowania w metodach i funkcjach. W ich przypadku przyznaję, że schemat graficzny jest bardziej przejrzysty niż tekst w języku naturalnym czy kod. Co więcej, używana przy okazji notacja jest też najszerzej znana. Autorzy wielu książek dotyczących języków programowania bardzo często bowiem "przemycają" w nich zwłaszcza diagramy klas, z nieśmiertelną strzałką w górę jako symbolem dziedziczenia.
Jeśli zaś chodzi o resztę diagramów, to ich użyteczność wydaje mi się wątpliwa. Mówiąc wprost, uważam je póki co za zwyczajne zawracanie głowy :)
Niemal standardowe nazwy klas
Wzorce projektowe (design patterns) to w założeniu ogólne modele związków pomiędzy klasami (i samych klas), jakie mogą pojawiać się w projektach. Każdy taki wzorzec jest przeznaczony do dość ściśle określonych okoliczności, dokładnie opisany i, przede wszystkim, nazwany. Na pewno każdy średnio zaawansowany programista miał okazję spotkać się z takimi terminami jak Iterator, Fabryka czy Singleton. Są one już na tyle długo używane, że w większości przypadków nie ma problemów ze zrozumieniem tego, co w danej sytuacji oznaczają.
Wzorce nie są oczywiście doskonałe. Ze względu na względnie dużą ścisłość nie mogą opisywać wszystkich rozwiązań, jakie mogą być konieczne, jeśli myślimy o zaprojektowaniu dowolnego programu. Nie jest więc tak, że każda klasa, jaka przyjdzie nam głowy, może być od razu wpasowana w jakiś gotowy szablon.
Przeglądając gotowe kody i różnego rodzaju dokumentacje stwierdziłem jednak, że często powtarzają się w nich różnego rodzaju "pseudowzorce". Objawiają się one głównie używaniem pewnych słów w nazwach klas, dzięki którym można mniej więcej domyślić się, jaka jest rola poszczególnych typów, do czego służą, jak - z grubsza - działają oraz jakie wykazują zależności z innymi klasami. Naturalnie mogą występować spore różnice pomiędzy poszczególnymi bibliotekami i językami, ale przynajmniej dla dwóch największych zbiorów klas, jakie są obecnie w powszechnym użyciu (czyli .NET Framework i JDK), rozbieżności nie są zbyt duże. Co więcej, ponieważ wielu programistów używa któregoś z tych dwóch narzędzi, często przejmują oni te wzorce nazewnictwa (świadomie lub nie) i stosują je we własnych kodach. Kto wie, może dzięki temu przeciętna czytelność kodu produkowanego przez statystycznego programistę (jeśli w ogóle istnieje ktoś taki :]) ma szansę choć odrobinę wzrosnąć?...
Jakie są więc te nieformalne "wzorce"? Otóż znalazłem kilka następujących:
Prawdopodobnie dałoby się wyróżnić jeszcze kilka pozycji (chociaż część byłaby dokładnym odpowiednikiem klasycznych wzorców projektowych), ale, jak widać, w sumie chyba nie jest ich zbyt dużo. To w gruncie rzeczy całkiem dobra wiadomość, gdyż nietrudno zauważyć, że wszystkie te nazwy są raczej "magiczne" i na pierwszy rzut nie przywołują jakichś natychmiastowych skojarzeń - zwłaszcza, jeśli nie jesteśmy do nich przyzwyczajeni. Ale taki już urok projektowania zorientowanego obiektowo, polegającego na tworzeniu dziwnych bytów i jeszcze dziwniejszych zależności między nimi :)
Kartka papieruTeoretycznie wszystko zakodować można "na żywioł", po prostu siadając do ulubionego środowiska programistycznego, pisząc i kompilując. Proste dzieła może i można w ten sposób stworzyć, ale do czegoś bardziej skomplikowanego zawsze pomaga chociaż pobieżny projekt.
W jakiej formie? To już nie jest sprawą taką prostą. Obecnie mamy sporo różnych narzędzi, umożliwiających formowanie naszych pomysłów w miejscu ich docelowego egzystencji - czyli programów komputerowych. I nie chodzi mi tu o aplikacje wysoce wyspecjalizowane, bo do zaprojektowania programu może się okazać przydatny edytor tekstu typu Notatnika. Każde z tych narzędzi będzie użyteczne dla określonego celu i każde ma też swoje ograniczenia.
Ale mamy również inny sposób. Możemy odejść (albo przynajmniej się odwrócić) od komputera, wziąć kartkę, długopis i... do dzieła. Być może wkrótce posługiwanie się tak "archaicznymi" sprzętami będzie nieco staroświeckie, ale pewnie jeszcze długo będzie miało niezaprzeczalne zalety:
Każdy jednak wie rzecz jasna, jakie są jego wady. Niektóre alternatywy - jak na przykład skrobanie po tabletowym komputerze - niwelują część z nich, zbijając przy okazji niektóre zalety. Największym mankamentem jest chyba jednak to, że rysunkowe bazgroły na papierze zawsze już pozostaną bazgrołami: nie da się ich przetworzyć na użyteczną formę schematów czy tabel zrozumiałą dla komputera, a i rozpoznawanie czystego tekstu też nie jest jeszcze doskonałe.
Wygląda więc na to, że poczciwa kartka papieru jeszcze długo będzie podstawowym środkiem tworzenia przynajmniej wstępnych - nomen omen - szkiców, zostawiając bardziej szczegółowe projektowanie innym narzędziom. Warto zatem wciąż kultywować starożytną umiejętność ręcznego pisania i rysowania :)
Bolączki C++ #1 – Pimp(l) my codeKiedy programuje się w języku, który jest - jak to ładnie mówią Amerykanie - 'standardem przemysłowym' w danej dziedzinie, chcąc nie chcąc trzeba się do niego przyzwyczaić. A to oznacza, że musimy nauczyć się żyć z jego wadami, które niekiedy mogą być tylko irytujące, a niekiedy bardzo nieprzyjemne. Ważne, by mieć świadomość ich istnienia i w miarę możliwości sobie z nimi radzić.
C++ jako ustandaryzowany język ma już prawie dziesięć lat, więc lista jego niedoskonałości w porównaniu z nowszymi językami siłą rzeczy staje się coraz dłuższa. Takie listy są jednak w dużym stopniu subiektywne, zatem i ten, który rozpoczynam poniżej, absolutnie nie pretenduje do miana ostatecznej wyroczni :) Na pewno inni usunęliby z niego część pozycji, niektóre uznali za mniej lub bardziej dotkliwe, a także dodali własne propozycje.
Dla mnie sprawą, która w C++ jest powodem największego bólu zębów, jest sposób organizacji kodu zaproponowany w tym języku. Jest on bodaj jedynym mi znanym (poza swoim poprzednikiem C), w którym występuje podział plików z kodem na dwa rodzaje: pliki nagłówkowe (*.h, *.hpp) i moduły kodu (*.cpp). W tych pierwszych teoretycznie umieszczany jest interfejs klas i funkcji, czyli informacje potrzebne do ich użycia w innym miejscu programu. W tych drugich jest zaś zawarta implementacja rzeczonych klas oraz funkcji.
Tyle teorii. W praktyce osiągnięcie idealnej separacji obu tych elementów wymaga sztuczki nazywanej szumnie wzorcem projektowym, o nazwie Pimpl (skrót od private implementation). Wygląda on na przykład następująco:
// właściwa klasa
class CFoo
{
private:
CFoo_Impl* m_pImpl;
public:
CFoo();
~CFoo();
void SomeOperation1();
};
// -- foo.cpp --
#include "foo.hpp"
class CFoo_Impl
{
// implementacja
};
CFoo::CFoo() : m_pImpl(new CFoo_Impl) { /* ... */}
CFoo::~CFoo() { delete m_pImpl; }
void CFoo::SomeOperation1() { m_pImpl->SomeOperation1(); }
// itd.
Wtedy faktycznie nie widać, co siedzi w środku naszej klasy, ale cena takiej hermetyzacji to konieczność stworzenia dodatkowej klasy i przekierowania do niej metod. Może nie jest to dwa razy więcej roboty, ale przynajmniej 10-20%.
Z drugiej strony większość języków nowszych niż C++, jak Java, C# czy Python, w ogóle zna takich pojęć jak 'prototyp funkcji' czy 'deklaracja zapowiadająca'. Tam kod piszemy od początku do końca: funkcję zawsze z jej treścią, a klasę zawsze w całości łącznie ze wszystkimi polami i kodem wszystkich metod. Nie ma podziału na pliki z 'interfejsem' i z implementacją.
A w C++ ten podział istnieje i tak naprawdę służy on tylko i wyłącznie... wygodzie kompilatora. Dzięki temu, że treść plików nagłówkowych jest taka, a nie inna (i np. zawierają one deklaracje prywatnych pól klas, które są przecież częścią implementacji), mogą być one zwyczajnie dołączane do modułów przy pomocy arcyprymitywnej dyrektywy #include. Dla kompilatora jest to najprostsze rozwiązanie, bo może on pracować nad każdym modułem osobno i nie musi się martwić żadnymi niejawnymi zależnościami między plikami. A dla programisty oznacza to wybór między wygodą kodowania, a jakością i "odpornością" stworzonego kodu.
C++ stoi więc w pewnym sensie pośrodku i wcale nie jest to złoty środek. Z jednej strony mógłby pójść w kierunku wyznaczonym przez wspomniane nowsze języki, czyli wprowadzenia jednego typu plików źródłowych, zawierających kod bez podziału na deklaracje i implementacje. To by było jednak mało oryginalne :) Bardziej interesujące byłoby, jak sądzę, polepszenie istniejącego rozwiązania w odwrotnym kierunku; być może automatyczne stosowanie wzorca Pimpl dla każdej klasy jest jednym ze sposobów.
Niestety, patrząc na obecny roboczy szkic standardu C++0x, w którym nic na ten temat nie znajdziemy, nie możemy raczej oczekiwać, że coś tu się zmieni w przewidywalnej przyszłości.
Wyjście = format + ujścieDla odmiany zająłem się ostatnio czymś nieco prostszym niż GUI, a mianowicie systemem logującym. To zdecydowanie niezbędne narzędzie, które docenia się zwłaszcza wtedy, kiedy coś innego psuje. Jak dotąd aczkolwiek nic poważnego nie zdążyło się jeszcze, ale w programowaniu jest to, jak wiadomo, tylko kwestią czasu ;)
Do logowania istnieje mnóstwo podejść, które różnią się głównie tym, gdzie i jak zapisujemy nasze informacje. Bo jeśli o to, co mamy logować, to odpowiedź jest prosta: wszystko albo jak najwięcej.
Dość obszerny przegląd możliwych wyjść loggera przedstawił TeMPOraL w jednej swoich notek. Jakkolwiek długa ta lista by nie była, pewne jest jedno: na pewno nie może być ona zapisana "na sztywno" w kodzie głównej klasy systemu logującego. Same 'wyjścia' należy bowiem opakować w osobne klasy, wywodzące się ze wspólnej bazy i wykorzystać zalety polimorfizmu. W ten sposób można kierować komunikaty zarówno na konsolę, do zwykłego pliku tekstowego, jak i do funkcji OutputDebugString.
Pomyślałem jednak nad rozszerzeniem tego pomysłu, który wyraża się w tytułowym równaniu: wyjście = format + ujście. Postanowiłem oto podzielić wyjście logowania na dwie części:

Nie jest to oczywiście żadna rewolucja. Czasami też obie części muszą być do siebie ściśle dopasowane, aby osiągnąć pożądany efekt (np. wypisywanie kolorowych komunikatów w asynchronicznej konsoli Windows). Zwykle jednak można je zestawiać dowolnie i otrzymywać ciekawe rozwiązania. Najważniejsze, że tą dodatkową elastyczność da się osiągnąć małym kosztem.