piątek, 27 marca 2009

C++ i zasady: Potłuc o kant

Znalazłem takiego wpisa, którego autorem jest niejaki Łukasz Milewski. Znałem kogoś o tym nazwisku, pracował ze mną w poprzedniej firmie, choć mam wątpliwości, czy to ta sama osoba, bo tam się pisało wyłącznie w Javie. A ten widać jest specjalistą od C++. Trochę oczywiście nie do końca specjalistą.

Przeczytałem sobie te zasady i w najlepszym razie zaliczyłbym je do "intermediate C++", choć od pewnego czasu zacząłem snuć teorię "lewicowych poglądów w programowaniu". Wbrew pozorom bowiem, lewicowość nie jest rzeczą zarezerwowaną dla polityki, czy obyczajowości - w programowaniu istnieje również, a jakże.

Przykładowym "lewicowym" poglądem w programowaniu w C++ jest wymaganie, aby wszystkie metody klasy były wirtualne. Ale nie uprzedzajmy faktów... :)


Destruktory zawsze są wirtualne - niezależnie od przeznaczenia klasy. Co z tego, że po klasie nie będzie można dziedziczyć? Musisz się niepotrzebnie zastanawiać nad tym czy może kiedyś semantyka ulegnie zmianie i będzie trzeba dodać virtual. Dodanie virtual nic nie kosztuje i niczego nie psuje


Ależ oczywiście, że kosztuje, i to wbrew pozorom wcale nie mało.

Dodanie co najmniej jednej metody wirtualnej (również destruktora) powoduje konieczność utworzenia obiektu charakterystycznego klasy. Oczywiście jeśli destruktor jest trywialny, to obiekt charakterystyczny będzie wygenerowany raz na hierarchię (kompilatorowi wolno współdzielić większe części obiektów charakterystycznych spokrewnionych klas i dzieje się tak np. gdy klasa pochodna nie przesłoni żadnej metody wirtualnej z klasy bazowej) - ale to nie zmienia faktu. A jeśli robimy wirtualne destruktory do wszystkich klas, to zwykle też do małych struktur, nie przeznaczonych do dziedziczenia. Tu więc nie będzie możliwości współdzielenia obiektów charakterystycznych.

Prawda, oczywiście, że destruktor wirtualny ma znaczenie tylko wtedy, gdy używamy "delete". Wtedy bowiem istnieje kwestia tego, czy należy prowadzić wywołanie wirtualne, czy też nie. Obiekt bowiem można usunąć na dwa sposoby:


  1. zostaje usunięty jego obiekt nadrzędny
  2. na wskaźniku do niego zostanie wywołane delete


Nie ma innych sposobów. Inne sposoby są co najwyżej pochodną powyższych (np. dla zmiennej lokalnej obiektem nadrzędnym jest środowisko funkcji). Jeśli więc mówimy o klasie (strukturze), na której wskaźniku nigdy nie ma być wołane delete, to wirtualność destruktora nic nie znaczy.

Byłoby więc znacznie bardziej z sensem, gdyby zastrzeżenie było takie, że należy zrobić zawsze jedną z dwóch rzeczy:

  • destruktor musi być wirtualny oraz nierzucający (throw())
  • klasa ma sprywatyzowany operator delete


Przecież to takie proste. Deklaracja operatora delete podlega dziedziczeniu, więc nie będzie można usunąć delete'm także żadnej jego pochodnej. Po prostu kaplica. Jest to więc w praktyce, można powiedzieć, częściowy zakaz dziedziczenia: nie będzie można usunąć obiektu tego typu. Oczywiście to nie znaczy, że nie będzie go można utworzyć (choć oczywiście można również podobnie zablokować operator new).

W praktyce jednak w zupełności wystarczy jedna rzecz: wymóg, aby destruktor był wirtualny w każdej klasie, która posiada metody wirtualne. Większość kompilatorów oczywiście stosuje do tego odpowiednie ostrzeżenie. I jest to naprawdę w zupełności wystarczające rozwiązanie problemu: niewirtualny destruktor w klasie posiadającej metody wirtualne jest czymś mającym mało sensu (optymalizacja???), natomiast jeśli nie mamy metod wirtualnych - nie będzie to dziedziczenie w sensie obiektowym, a jedynie dziedziczenie w sensie C++ (tzn. przejęcie w klasie pochodnej definicji składowych z klasy bazowej).

Na szczęście faktycznie nic nie kosztuje wywołania destruktora w razie usunięcia jego obiektu nadrzędnego. Tam również w ogóle jakiekolwiek wywołanie zostanie pominięte, jeśli destruktor jest trywialny. Ale nie zmienia to faktu, że pozostaje koszt obiektu charakterystycznego, a także koszt obciążenia wprowadzanego do klas, które z tej klasy dziedziczą.

Przypominam, że C++ zawsze był tworzony zgodnie z zasadą "nie płać za to, czego nie używasz". Gdyby więc rzeczywiście jawne zadeklarowanie destruktora wirtualnego nic nie kosztowało, to w ogóle w języku nie istniałoby pojęcie wirtualnego destruktora - kompilator sam by pamiętał o ustawieniu destruktora na wirtualny w razie potrzeby (albo nawet w ogóle zawsze robił wszystkie destruktory wirtualne i tylko pomijałby wywołanie wirtualne, gdyby nie było takiej potrzeby). Na to się nie zdecydowano, prawdopodobnie dlatego, że kompilator mógłby nie umieć tego stwierdzić.

Niestety to założenie nie dotyczy wywoływania metod - w ogóle (tzn. nie ma takiej możliwości, żeby wywołanie metody wirtualnej uprościć do zwykłego wywołania lub inline). Przecież nigdy nie wiadomo, czy z danej klasy ktoś gdzieś nie odziedziczył. A tu proszę:


Wszystkie metody powinny być zawsze wirtualne. Jeżeli nie musisz pamiętać o virtual to bardzo dobrze... masz czas na inne rzeczy. (UPDATE: wszystkie w sensie, te od implementacji. Metody interfejsu raczej nie powinny być wirtualne).


Widzę, że facet czegoś tu nie rozumie.

Po pierwsze, jeśli jakakolwiek metoda jest wirtualna, to zostanie ona wywołana poprzez wywołanie wirtualne (czyli: z obiektu wyciągamy obiekt charakterystyczny, z niego z kolei wskaźnik na funkcję będącą implementacją metody i dopiero to jest wołane - oczywiście o jakimkolwiek inline zapomnij). To jest co najmniej dwukrotny koszt samego wywołania.

Po drugie, jeśli już chcemy tak dzielić na metody interfejsu i metody implementacji, to miejmy na uwadze, że w C++ istnieją następujące elementy w kwestii metod klasy:


  1. definicja implementacji. Jest to funkcja, która stanowi zestaw instrukcji, jaki zostanie wykonany w ramach wywołania funkcji
  2. definicja slotu. Jest to deklaracja, którą będzie można wypełnić swoją implementacją funkcji
  3. definicja interfejsu. Jest to deklaracja, która pozwala na wywołanie (w celu wykonania określonych czynności)


Możemy sobie te definicje odpowiednio upraszczać. Przykładowo, pojedyncza funkcja składa się wyłącznie z interfejsu i implementacji. Tak samo się dzieje w przypadku, gdy mamy zwykłą metodę klasy. W obu przypadkach zaś mamy do czynienia z podpięciem implementacji bezpośrednio pod interfejs. Fakt, że i w C++ i w innych językach obiektowych nie dostarczono oddzielnych definicji do implementacji i slotu, to oczywiście pewne ułatwienie dla użytkownika, ale zarazem powoduje, że wielu ludzi nie nie rozróżnia tych pojęć.

Metoda wirtualna składa się bowiem z trzech części: interfejsu, podpiętego do niego slotu, a dopiero do slotu podpiętej implementacji. To mniej więcej tak, jak byśmy w definicji metody zrobili tylko przekierowanie wywołania do slotu, oraz dostarczyli informację o tym, co jest wpięte do slotu w bieżącej klasie (chyba że to jest metoda abstrakcyjna, to wtedy do slotu nic nie jest wpięte).

Jeśli więc wprowadzamy sobie taki slot, to znaczy, że przewidujemy tutaj możliwość wpięcia sobie później przez kogoś innego w to miejsce własnej implementacji.

Więc nie ma czegoś takiego jak "metody interfejsu" i "metody implementacji". Interfejsem jest tutaj to, za pomocą czego można funkcję wołać, a implementacją jest to, co ma się dziać w przypadku wywołania. Tyle. Oczywiście, że istnienie publicznej metody wirtualnej powoduje równocześnie, że taka metoda staje się równocześnie trzema rzeczami (a przynajmniej dwoma, gdyby była abstrakcyjna): interfejsem wystawionym do wołania oraz slotem do podpięcia dowolnej implementacji.

Wiem, istnieje taka szkoła, żeby wszystkie metody wirtualne robić jako protected; jest to nawet niezła szkoła, aczkolwiek nie zawsze istnieje konieczność trzymania się tego wymagania. Oczywiście jest to o tyle dobra szkoła, że oddziela dwie rzeczy, które nie mają nic ze sobą wspólnego: interfejs do wywoływania oraz slot do podpinania. Różne elementy kodu mogą bowiem używać tego czegoś w różny sposób: jedni do podpinania, inni do wywoływania. Ustawienie metody wirtualnej jako protected i dorobienie do niej metody publicznej, która wywołuje ową metodę wirtualną, pozwala rozdzielić te dwa sposoby użycia tej metody, które tak naprawdę nie mają ze sobą nic wspólnego. W ten sposób twórca klasy bazowej ma możliwość wprowadzania np. dodatkowej kontroli do wywołania z poziomu interfejsu, nie ingerując w to, co ktoś (może w poprzedniej wersji) zrobił w klasie pochodnej.

Ale nie o tym była mowa. Ale w świetle tego wychodzi to coraz bardziej bez sensu. Jeśli bowiem stwierdzimy, że ok, dotyczy to tylko metod protected, to już z góry zakładamy, że będziemy robić metody protected z interfejsem zewnętrznym, który będzie tych metod używał. A przecież jeśli ktoś w ogóle nie robi metod klasy jako wirtualne, to tym bardziej nie będzie robił takiego sztucznego podziału!

Pamiętajmy też o dwóch szczegółach: po pierwsze, w C++ nie ma final, a po drugie, C++ zwykle nie jest kompilowany na maszynę wirtualną działającą jako JIT (jeśli już: rozwiązanie to ma sens tylko w aplikacjach uruchamianych na serwerze aplikacji, a do tego C++ jest rzadko stosowany). W pierwszym przypadku powoduje to, że każde wywołanie wirtualne będzie wywołaniem pośrednim (bo nigdy nie można założyć, czy ktoś czasem nie podał obiektu klasy pochodnej i nie przesłonił podanej metody), w drugim, że żadne wywołanie pośrednie nie będzie mogło być uproszczone.

To ostatnie zresztą ukazuje w pełni swoje wady, gdy dojdziemy do uruchamiania programu. Zbyt duża ilość metod wirtualnych w ogóle w aplikacji potwornie wydłuża czas uruchamiania programu (były w KDE prowadzone badania na ten temat). Spowodowane jest to koniecznością renumeracji adresów funkcji. Nie ma to żadnego związku z ilością funkcji, natomiast ma ścisły związek z ilością wskaźników do funkcji zapisanych na twardo w danych programu po kompilacji. Przykładowo, jeśli wyprowadzamy nową klasę B z klasy A, która ma 10 metod wirtualnych, przesłaniamy w klasie B tylko jedną metodę wirtualną. Ponieważ przesłaniamy co najmniej jedną metodę, to kompilator musi stworzyć dla klasy B oddzielny obiekt charakterystyczny, w którym 9 wskaźników do implementajci metod wirtualnych jest identyczne z tym z klasy A. To już powoduje powielenie istniejących 9 wskaźników dwukrotnie. Co się stanie, gdy takich klas, jak B, będzie więcej?

W Javie tego problemu nie ma dlatego, że zwykle kompilatory do tego języka wykonują kompilację JIT (oczywiście to powoduje równocześnie, że Java nie nadaje się do zastosowań innych, niż serwery aplikacji). To oznacza, że pewne części faktycznej kompilacji wykonują się po uruchomieniu, a to z kolei oznacza, że taka czynność może zostać wykonana przy znajomości całej hierarchii WSZYSTKICH klas, jakie wchodzą w skład uruchomionego procesu, a co za tym idzie, JIT wie na pewno, czy wywołanie danej metody na rzecz danej referencji jest wywołaniem efektywnie wirtualnym, czy nie. No i oczywiście istnieje też final, którego użycie wprost deklaruje, że nie może to wywołanie być wirtualne.

Pamiętajmy też, że C++ to jest język "wieloparadygmatowy", a nie "obiektowy". W C++ złożone struktury można programować na różne sposoby i przykładanie zbyt wielkiej wagi akurat do programowania obiektowego w C++ ma mało sensu.

Jeśli więc ktoś robi metodę wirtualną, to przewiduje podmianę implementacji - jeśli ktoś nie przewiduje podmiany implementacji w tym miejscu, to po co ma robić metodę wirtualną? Jest to kwestia designu - możesz chcieć, aby była, możesz nie chcieć. A że moja klasa będzie mniej użyteczna przez to, że dana metoda jest niewirtualna?

Momencik. Przecież jeśli ja piszę klasę i jej metody, to ja decyduję o tym, jak wygląda jej call flow. Dlaczego mam pozwalać każdemu kto popadnie na to, żeby się, za przeproszeniem, wpierdalał w mój kod? Jak mam zapewnić poprawność mojego kodu i zagwarantować właściwe zachowanie się programów używających mojej klasy, jeśli ktoś będzie się chciał wciąć w każde możliwe wywołanie w mojej klasie i zrobić mi burdel?

Przypominam również, że np. w Javie każda metoda jest wirtualna, ALE:

  • Istnieje final, które zabrania przesłaniania metody. Taka metoda zachowuje się jak zwykła metoda, jeśli jest wołana z klasy, w której jest dodane final (jeśli dodano to klasy, to wszystkie metody)
  • W Javie "private" implikuje "final".


Mówiąc bardziej ściśle: w C++ nie ma możliwości ani zakazania dziedziczenia, ani zakazania przesłonięcia metody. Java zatem pozwala, mając wszystkie metody wirtualne, na to, żeby wciąż przesłanianie metod było ograniczone tylko do wybranego zakresu (inna sprawa, czy w praktyce ktoś z tego w ogóle korzysta, ale pomińmy ten temat). Wymóg robienia wszystkich metod wirtualnymi w przypadku C++ odpowiada więc w Javie nieużywaniu final (a tym samym oczywiście private). Możemy to również porównać do polimorfizmu parametrycznego i wtedy dojdziemy do prawdziwego kuriozum: niech wszystkie argumenty funkcji i pola klas będą typami-parametrami wzorca...

Mnie, jako twórcy klasy, naprawdę szczerze wisi i powiewa, czy moja klasa będzie użyteczna. Ważne, żeby była używana w takim zakresie, w jakim ją przewidziałem do używania. Wymaganie, abym pisał klasę tak, żeby ktoś mógł sobie później kombinować, jak jej używać, jest po prostu nieprzydatne. O tym, że potem za to się płaci, już nie wspominam.


Nie używaj wyrażenia warunkowego (to jest to ? : ). Jeżeli będziesz musiał kiedyś dodać zagnieżdżony warunek, to gdy będziesz potem czytał takie zagnieżdżone instrukcje warunkowe prawdopodobnie stracisz dużo czasu.


Ależ oczywiście, że używaj. Nie mówiąc o tym, że jest to jedyna możliwość warunkowego zainicjalizowania zmiennej referencyjnej. Poza tym, nie zapominajmy w ogóle, że jest to wyrażenie - a jako takie potrafi sporo rzeczy, w odróżnieniu od instrukcji.

No, chyba że ktoś woli coś takiego:


template<class Value>
const Value& if_then_else( bool b, const Value& ifso, const Value& ifnot )
{ return b ? ifso : ifnot; }



Uważaj na niekompletne typu (tylko zapowiedziane). Niektórzy chcą być sprytni i zamiast include dają zapowiedź klasy. To może prowadzić do problemów, więc ja na to uważam. Typowym problemem jest zwolnienie pamięci po obiekcie wskazywanym przez wskaźnik niekompletnego typu. Jeżeli destruktor tego typu jest nietrywialny to nie zostaje wywołany. Tak na prawdę nie musisz nawet wiedzieć o tej sytuacji, jeżeli zawsze dostarczasz kompilatorowi wszystkich informacji jakich potrzebuje - w szczególności jeżeli nie oszukujesz go przez zapowiedź zamiast include.


I kolejna rzecz - gość w ogóle nie ma pojęcia, po co istnieją typy niekompletne. A właśnie dobra znajomość zagadnienia typów niekompletnych pozwala umiejętnie rozwikłać wzajemne zależności między dwoma modułami, jak również mocno oszczędzić sobie kompilacji. Co więcej, uważam że spartolili sprawę w bibliotece standardowej C++ poprzez niedodanie nagłówka <stringfwd>. Problem polega na tym, że "zapowiedzianej" wersji nie da się zrobić, bo string to jest typedef na basic_string<char>, więc trzeba zrobić zapowiedź basic_string i tylko tam można podać parametry domyślne. A przydałby się taki nagłówek choćby do tego, żeby fstream::open mogło przyjmować string. Przyjmowałoby się go przez const referencję, więc typ niekompletny by się nadawał.

Typy niekompletne jest to narzędzie do ucinania niepotrzebnych zależności między modułami, a tym samym oszczędzenie mnóstwa czasu kompilacji, pomijając już przypadki, gdy jest to konieczne. Fakt, że delete na tym działa i to działa źle uważam za defekt C++ i być może zostanie on usunięty - ale póki co ostrzeżenia kompilatora spełniają swoją rolę. Jedyne co poza tym robi to różnicę, to fakt, że nie da się użyć żadnego szczegółu typu, który jest niekompletny, ale przecież próba wykonania takiej operacji kończy się błędem kompilacji.

Nie jest wcale oczywiste, jak ten błąd kompilacji należy poprawić. Może należy zaciągnąć pełną definicję typu. A może raczej należy przenieść użycie tego typu do innego pliku, a w tym wstawić tylko typ niekompletny.

Jedno jest pewne - ci, którzy używają typów niekompletnych, to nie ci, którzy chcą być sprytni, tylko ci, którzy pewnie nieraz spędzili długie godziny na rozplątywaniu zależności.


Jedna klasa - dwa pliki. Zawsze rób dwa pliki - z interfejsem i z implementacją. Nawet jeżeli używasz szablonów. Wyjątkiem mogą być malutkie klasy, o których wiadomo, że nie urosną. Ludzie mają to do siebie, że łatwiej im analizować mniejsze kawałki (dlatego nie jest zalecane uczenie się poprzez wyłożenie mnóstwa książek na biurko - mózg boli na sam widok tej sterty).


Nie rozumiem tego zalecenia.

Ja nie tylko nie zawsze robię dwa pliki, ale nawet czasem na cztery klasy robię dwa pliki nagłówkowe i trzy pliki z implementacją, bądź trzy pliki nagłówkowe i jeden plik z implementacją. Niekiedy zdarza mi się pisać pełną definicję metody w klasie, najczęściej jednak nie widzę w tym sensu. Po prostu implementacje metod umieszczam w taki sposób, żeby łączyło je wspólne przeznaczenie. Jest rzeczą całkowicie normalną, że z dwóch różnych klas można wyróżnić trzy grupy metod i to całkowicie w poprzek klas.

A w przypadku szablonów to jest po prostu kuriozum. Przecież i tak wszystkie definicje w przypadku szablonów muszą być w pliku nagłówkowym. Ciekawe, że to tych "wspaniałych zaleceń" jakoś nie stosują się twórcy biblioteki standardowej C++.


Nie używaj delete. Dokładnie tak. Zamiast tego lepiej zastosować inteligentne wskaźniki (np. boost::shared_ptr). Po pierwsze nie trzeba pamiętać o zwolnieniu pamięci - ale trzeba określić kiedy powinna być zwolniona, dla własnej wiadomości. Po drugie delete ma w C++ dwie formy delete i delete [], o czym niestety wszyscy zapominają. Po trzecie delete nie sprawdza czy typ jest kompletnym. Po czwarte przy użyciu delete trzeba się niepotrzebnie zastanawiać czy ktoś nie potrzebuje jeszcze danego zasobu - kolejna możliwość pomyłki i okazja do zmarnowania odrobiny czasu.


No nie do końca. Pomijam już oczywiste pytanie "co w przypadku, gdy sam robię dla siebie jakiś specyficzny smart-pointer". Po drugie, nie wiem czy autor wie, że smart-pointery typu właśnie shared_ptr (dziś już std::tr1::shared_ptr) dają bardzo duży overhead na operacje zmiany zawartości wskaźnika. Pamiętajmy, że shared_ptr to jest wskaźnik "z przypinką" i jego implementacja jest dość złożona (znacznie prościej jest zrobić intruzyjny smart-pointer, ale intruzyjność oznacza, że trzeba odpowiednio zmodyfikować definicję klasy, która ma być trzymana przez taki smart-pointer, czyli inaczej, nie każdej klasy obiekt taki smart-pointer może trzymać). A ja, tak się składa, że robiłem pomiary czasowe programu z użyciem shared_ptr i obciążenie na jego przypisanie było o rząd wielkości większe, niż ta sama seria wykonana ręcznie z surowym wskaźnikiem. Pod względem wydajności bije go na głowę nawet odśmiecacz Boehma.

I znów ten typ niekompletny. Powtarzam: wystarczy sam fakt, że kompilator ostrzega.

I jasne, trzeba się zastanawiać, czy ktoś nie używa obiektu. Ciekawe. A w przypadku shared_ptr to już nie trzeba. Problem właśnie polega na tym, że jeśli wprowadzamy współdzielenie dostępu do obiektu, to jeszcze najczęściej dochodzą do tego kwestie związane z możliwością "podglądania" obiektu (czyli słabymi wskaźnikami). Bo sam fakt, że istnieje współdzielenie jeszcze nic nie znaczy; nadal istnieje odpowiedzialność za zniszczenie obiektu. Techniki reference-count są znane już od dawna przecież i w wielu miejscach stosowane - a i tak jakoś tak się dziwnie składa, że co rusz gdzieś trzeba sztucznie podkręcać licznik referencji, bo coś-tam się nie zgadza. Współdzielenie dostępu do obiektu nie jest niestety najczęstszym przypadkiem sposobu operowania obiektem; najczęściej tak naprawdę właściciel jest jeden, to właśnie współdzielenie dostępu do obiektu jest przypadkiem szczególnym.

Współdzielenie możliwości usunięcia obiektu to nie jest jedyny aspekt współdzielonego obiektu. Równie istotną sprawą jest możliwość modyfikacji obiektu i synchronizacja powodowanych tych zmian. Możliwość domyślnego współdzielenia właścicielstwa do obiektu (co oferują nam języki z gc) zwalnia nas zatem z konieczności ustalania nadzorcy obiektu, spychając jedynie jednak problem w inne miejsce.


Dokładnie poznaj wybrane inteligentne wskaźniki - często mają wady, które wynikają z samej konstrukcji. Typowym błędem są cykle utworzone przez takie wskaźniki. Wówczas jest wyciek pamięci. Dlatego ten element biblioteki zawsze trzeba dobrze poznać.


Ha! A więc: używaj zawsze shared_ptr... ale spodziewaj się problemów. :)

To ja mam lepszą radę: nie używaj shared_ptr :) A tak na poważnie: staraj się w miarę możliwości nigdy nie współdzielić obiektów. Jeśli już jednak istnieje taka potrzeba, nadal obowiązkowo utrzymuj hierarchię obiektów. Jeśli np. dwa obiekty muszą współdzielić inny, to niech to będą dwa obiekty, które nie wymieniają się żadnymi innymi danymi. Jeśli te dwa obiekty mają współdzielić więcej niż jeden obiekt, to niech zarządzaniem tymi obiektami zajmie się oddzielny obiekt. Również obiekty uczestniczące we współdzieleniu nie mogą same być współdzielone.


Nie używaj std::auto_ptr. Jeżeli poznałeś dobrze auto_ptr to wiesz dlaczego ;-P Ich polityka własności powoduje, że nietrudno o błąd. W szczególności można kopiować obiekty typu auto_ptr, ale wówczas oryginał traci ważność(!) - jest zerowany. Dodam tylko, że algorytmy STL-a używają kopiowania dość sporo...


LOL :)

Oczywiście, w C++0x należy już zrezygnować z auto_ptr na rzecz scoped_ptr, ale to nie znaczy, że auto_ptr jest bezużyteczny. Co więcej, na kompilatorze, który nie jest jeszcze zgodny (wystarczająco) z C++0x jedyny dostępny z takim fajnym ficzerem. Oczywiście, że posiada on wadę polegającą na niemożności współdzielenia, ale ta wada wychodzi już na etapie kompilacji, ponieważ obiekty auto_ptr nie spełniają koncepcji "kopiowalny" (wymaganej przez większość elementów STL). Wskaźnik auto_ptr jest za to wyśmienity do:

  • przytrzymania obiektów dynamicznych na czas, aż wszystkie operacje, które mogą potencjalnie rzucić wyjątkiem, zostaną zakończone
  • zwracania nowo uworzonych obiektów jako rezultat wywołania (zapobiegają niebezpieczeństwu wycieku pamięci w przypadku zignorowania wartości zwracanej)



Unikaj dziedziczenia wirtualnego. Gdy jest to możliwe, bo pominięcie virtual w tym przypadku może być czasami groźne w skutkach. Chodzi o to, że hierarchia dziedziczenia nie powinna być grafem (który nie jest jednocześnie drzewem). Jeżeli już jest to trzeba bardzo uważać na operator= definiowany automatycznie. Polecam spróbować jak to działa, to zobaczysz dlaczego lepiej tak nie robić.


Super :) Polecam do przeczytania następujący artykuł (mój oczywiście, obcych nigdy nie polecam :D): Multiple inheritance considered nice, w którym objaśniam dokładnie, kiedy dziedziczenie w C++ jest dziedziczeniem z punktu widzenia obiektowego. Jak się okazuje, dziedziczenie wirtualne ma tutaj spory udział, co więcej, właśnie dziedziczenie wielorakie, w którym jest ta sama klasa wprowadzona pośrednio przez dwie oddzielne klasy, podane równocześnie do dziedziczenia, to jest właśnie dziedziczenie niedozwolone z obiektowego punktu widzenia.

Mam więc znacznie prostszą poradę i to w sam raz w stylu Łukasza: unikaj dziedziczenia wielorakiego. Szczególnie, gdy nie rozumiesz, o co chodzi.

A tu trafia nam się ciekawostka dwóch kolejnych:


Asercje są dobre. Warto używać asercji. Pozwalają sprawdzić czy wywołanie metody faktycznie spełnia zadane warunki wstępne (ang. preconditions). Niejednokrotnie dzięki temu mechanizmowi wykrywałem błędy, które przeszłyby testy modułowe. Typową sztuczką jest assert(warunek && "Komunikat błędu") aby dostać trochę sensowniejszy komunikat niż tylko numer linii i nazwę pliku z błędem. Niestety nie można odpowiedniego tekstu tworzyć dynamicznie.


Słusznie.


Asercje są złe. Niestety asercje z C++ są złe. W przypadku błędnego warunku przerywają program. Wyobraź sobie, że grasz w grę komputerową i nagle aplikacja zostaje przerwana z komunikatem "assertion failed". Dlatego warto stworzyć własną funkcję asercji, która będzie rzucała wyjątek - przynajmniej można go potem złapać. Dodatkowo można dać dokładniejsze informacje o błędzie (np. że index = 5 a powinno być <4


No bo na tym właśnie polega rola asercji, żeby przerywać program i nie dopuścić do jego dalszego wykonywania się!

Asercje i wyjątki. Jasne. To chyba trzeba się w Javie wykąpać.

Asercja jest to sprawdzenie KRYTYCZNEGO warunku wstępnego funkcji. Krytycznego, to znaczy takiego, że jeśli taki warunek nie jest spełniony, to dalsze wykonywanie się programu nie powinno być możliwe. Dlatego asercjami nie można sobie szastać. Asercje służą właśnie do wysypania programu, bo mają oznaczać błąd, z którego nie można się już "odzyskać".

Jeśli ktoś mówi o tym, że miałbym grać w grę i nagle dostać komunikat "assertion failed", to chyba nie rozumie, że jeśli stosuje się asercje, to stosuje się również dwie oddzielne konfiguracje kodu, zwane "debug" i "release". I asercje tylko w trybie "debug" powodują wysyp programu, bo uważa się to wtedy za niegroźne, podczas gdy w trybie release sprawdzanie tego warunku jest ignorowane. Jeśli uważamy, że pozostawienie tego warunku jest zbyt ryzykowne również w trybie release, to w trybie release powinniśmy wykonać ODDZIELNE sprawdzenie i tym razem możemy się ewentualnie posłużyć wyjątkiem (oraz zaplanować na tę okoliczność sensowne recovery). W przypadku gry, dajmy na to, skoro już podano ten przykład, należy spróbować zebrać wzystkie możliwe informacje o bieżącym stanie, gdzieś go zrzucić, utworzyć środowisko od nowa, nałożyć zmiany i pozwolić kontynuować grę - użytkownik najwyżej zauważy spowolnienie, ale mimo wszystko przynajmniej może dalej grać.

A teraz czas na doświadczenia na żywo:


Innym razem nie było tak wesoło. Zostałem zapytany o slicing. Nie bezpośrednio lecz przez przykład w C++. W skrócie była klasa A i jej pochodna B. Była zdefiniowana funkcja foo z argumentem typu A. Ta funkcja w main była wywołana z argumentem typu B. Pytanie brzmiało co się stanie? Zastanów się...


Odpowiedź jest oczywiście banalnie prosta - przekazanie przez wartość obiektu klasy pochodnej B spowoduje utworzenie obiektu tymczasowego klasy A w celu przekazania go dalej przez wartość. Oczywiście, jeśli przekażemy tam obiekt klasy A, to do funkcji zostanie przekazany również obiekt tymczasowy. O ile ja sobie przypominam, jest to również konstrukcja, do której kompilator powinien rzucać ostrzeżenie. Zresztą nie jest to jedyny taki przypadek - dokładnie ta sama sytuacja istnieje w przypadku typów ze sobą nie powiązanych, mających jednak zdefiniowaną konwersję (albo domyślną, albo zdefiniowaną przez operator konwersji, albo przez jednoargumentowy konstruktor nie-explicit).

I do tego celu potrzeba było aż eksperymentów, żeby to stwierdzić? Ciekawe.

C++ nie jest pod tym względem wcale szczególny - tak jest w każdym języku, który posiada konwersje między typami wartości. Problem w tym szczególnym przypadku nie polegał na tym, że przekazano obiekt klasy pochodnej (bo skąd twórca klasy A mógł wiedzieć, że ktoś zrobi klasę B?). Problem polegał na przekazaniu klasy A przez wartość. A, jak to już gdzieś wspominałem, typy wartościowe nie mogą podlegać hierarchiom obiektowym. Oczywiście klasę, jeśli ma być typem wartościowym, najlepiej zabezpieczyć w podany sposób (czyli dając explicit do konstruktora kopiującego). Ważne jest jednak, żeby wiedzieć nie tylko to, że tak o po prostu przypadkiem nadzialiśmy się na "beznadziejne" mechanizmy w C++. Ważne jest, aby wiedzieć o podziale na typy wartościowe i obiektowe, że ten podział naprawdę istnieje, i że istnieją reguły używania jednych i drugich - opisałem to w innym artykule pt. Valuables and objectives.

I odpowiem od razu - oczywiście, operator = ma również tą samą wadę, a co gorsza, operatora= nie można zrobić explicit. Ale jest na to rada: należy zadeklarować alternatywny operator=, z typem template, który wywoła błąd. W C++0x jest nawet dodatkowe ułatwienie, przez co nie trzeba sztucznie wywoływać błędu:


A& operator=( const A& a ) = default;

template <class X>
A& operator=( const X& x ) = delete;



Ja, w odróżnieniu od Łukasza, z kolei, mogę dać gwarancję na poprawność mojego rozumowania. Zasada jest zresztą prosta: każdy konstruktor jednoargumentowy bez explicit jest równocześnie konstruktorem konwertującym, który sam z siebie ustanawia definicję konwersji z każdym, kto pasuje do typu argumentu (w przypadku konstruktora kopiującego - również domyślnego - oznacza to, że ustanawia ją z każdym obiektem pochodnym, ponieważ przyjmuje referencję do niego jako typ argumentu, a referencje i wskaźniki do klas pochodnych domyślnie konwertują się na te od klasy bazowej). Konstruktor konwertujący jest to jedna z możliwych zdefiniowanych konwersji; poza tym istnieją jeszcze konwersje "wbudowane" oraz konwersje definiowane operatorem konwersji (czyli operator X() zdefiniowany w klasie Y pozwala konwertować obiekt klasy Y na obiekt klasy X). Mógłbym nawet podać jakieś referencje w standardzie, ale nie chce mi się szukać :D.

Przestrzegam ogólnie przed takimi zasadami. To, co zacytowałem tu powyżej to trzymanie się sztywnego schematu podyktowanego faktem, że człowiek się na tym czy tamtym potknął. Tymczasem prawda może być zupełnie inna; potknięcie wynika niekoniecznie stąd, że zastosowane konstrukcję niezgodnie z przeznaczeniem. Wynika stąd, że nie do końca rozumie się określony mechanizm i nie wie się, że można go w określony sposób wykorzystać. Akurat C++ jest takim językiem, że bardzo wiele z jego właściwości można używać nie tylko niezgodnie z przeznaczeniem, ale nawet w sposób, o jakim się jego twórcom nie śniło. Jest to dla mnie zresztą dość ciekawa cecha wspólna z całkowicie odmiennym od niego językiem Tcl i chyba najważniejszy powód, dla którego lubię używać obu tych języków.

To nie znaczy, że C++ ma być językiem bez zasad. To znaczy, że aby używać określonych ficzerów z C++ należy mieć do tego zawsze jakąś konkretną logiczną "podkładkę". Taka podkładka wystarczy, aby zasady, podobne do powyższych, można było właśnie potłuc o kant.

Brak komentarzy: