C++ bez choletsterolu

Rzutowanie

Rzutowanie jest jednym z ważniejszych problemów w C++. Jako język wywiedziony z C, odziedziczył jedyną istniejącą tam metodę rzutowania, która była zazwyczaj sposobem na ominięcie typizacji w celu uzyskania różnych nieistniejących w tym języku możliwości. C++, jako język w którym wiele możliwości dodano, używanie rzutowania ma dużo mniejszy sens, aczkolwiek wprowadzenie rzutowania do języka jest jednocześnie przyznaniem się, że język nie jest doskonały – a przynajmniej twórca nie stara się robić użytkownikowi na złość. Zresztą, żaden język nie jest doskonały, więc jeśli nie zawiera rzutowania, to albo jest to język z typizacją dynamiczną, albo nikt go nie używa. Natomiast jakość samego języka oraz napisanej w nim biblioteki jest odwrotnie proporcjonalna do użytych rzutowań.

Ponieważ jednak rzutowanie jest z natury czymś niebezpiecznym, w C++ wprowadzono odpowiednie ograniczone operatory rzutowania. Łącznie C++ dysponuje następującymi operatorami rzutowania:

Rzutowanie wymuszone, zwane też "obleśnym" rzutowaniem pozwala na dokonanie konwersji w praktyce dowolnej. Oczywiście nie przekonwertuje to przez wartość struktury X na strukturę Y (chyba że mają zdefiniowane odpowiednie operatory rzutowania), ale pomiędzy wskaźnikami nie ma absolutnie żadnych ograniczeń co do możliwości wykonania rzutowania. W przypadku wskaźników jest to nie tylko zmiana interpretacji reprezentacji wewnętrznej, ale również całkowite pominięcie kwestii wariancji wskaźnika. Operator ten odradza się używać przede wszystkim dlatego, że użytkownik zazwyczaj rzutuje w jakimś jednym konkretnym celu; w każdym razie istnieje kilka aspektów typizacji, a użytkownik przez rzutowanie chce naruszyć tylko jeden. Używając jednak tego operatora narusza się każdy możliwy aspekt typizacji, dlatego zaleca się używać jednego z pozostałych operatorów.

Operator static_cast jest rzutowaniem z dużym ograniczeniem. Można by to zdefiniować w następujący sposób:

Niech będą dane typy X i Y oraz ich obiekty: X x; Y y;. Wyrażenie static_cast<X>( y ) jest prawidłowe, gdy jedno z wyrażeń: Y y = x; lub X x = y; jest prawidłowe.

Jak więc widać, static_cast można stosować w celu zrzutowania typu X na Y gdy konwersja z X do Y lub z Y do X może się odbyć niejawnie. Właściwie stosuje się to tylko do zrzutowania X do Y, gdy konwersja z Y do X może się odbyć niejawnie, gdyż jeśli można konwersję przeprowadzić niejawnie, to po co rzutować. Nie jest to jednak takie pewne; czasem zdarza się, że instrukcja jest tak skonstruowana, iż w danym przypadku kompilator takiej niejawnej konwersji nie dokona (albo dokona nie takiej, jak chcemy).

Tu też trzeba pamiętać, że najczęściej ten operator wykorzystuje się do rzutowania wskaźników. Ponieważ też jak wiemy, jeśli typ Y jest klasą pochodną X, to w takim razie konwersja z Y* do X* odbywa się niejawnie. Aby z kolei konwertować z X* do Y*, używamy operatora static_cast. Jednak tu jest dość istotna rzecz związana z dziedziczeniem wielorakim.

Wyobraźmy sobie, że klasa Y dziedziczy po klasach A, B i X (w tej kolejności). Rozmieszczenie obiektów zatem wskazuje, że gdy dokona się nawet niejawnej konwersji z Y* od X*, to będą one miały różną wartość liczbową. Wynika to stąd, że pod-obiekt klasy X w obiekcie klasy Y zaczyna się trochę dalej, niż zaczyna się obiekt klasy Y. W typowym rozmieszczeniu obiektów w większości implementacji C++ polega to na tym, że pod-obiekt klasy X jest umieszczony za innymi pod-obiektami, a różnica pomiędzy początkiem X i początkiem Y jest to tzw. delta.

Zatem zarówno niejawne rzutowanie, jak i operator static_cast uwzględniają istnienie delty. Jeśli rzutujemy tym operatorem z X* do Y*, to wynikowy wskaźnik Y* będzie miał wartość odpowiednią przy uwzględnieniu budowy klasy. To jest właśnie największa różnica w stosunku do operatora rzutowania wymuszonego: gdyby tego operatora użyć do takiej konwersji, to otrzymalibyśmy co prawda wskaźnik na Y*, ale będący zupełnie na bakier z budową klasy.

Operator dynamic_cast jest operatorem służącym do poruszania się po hierarchii klas polimorficznych i rzutowania pomiędzy wskaźnikami lub referencjami. W istocie jest podobny do static_cast, z tym tylko że na podstawie danych statycznych klasy (mówię "statycznych" ze względu na podobieństwo do pól i metod statycznych) dokonuje sprawdzenia, czy poruszając się po hierarchii klas można rzeczywiście dotrzeć z jednej do drugiej. Jeśli dane klasy wykażą, że jest to poprawne, wtedy otrzymujemy prawidłowy wynik rzutowania. Jeśli nie, to jest to błąd, z tym tylko że akcja zależy od tego co rzutujemy, tzn.:

Z doświadczenia jednak mogę powiedzieć, że ten operator ma dość ograniczone zastosowanie. Jednym z założeń co do operatorów jest to, żeby miały długie nazwy, a przez to były wyraźne i łatwe do znalezienia. W związku z tym jego używanie nie jest czymś wygodnym. Zatem tego operatora jest sens używać tylko w takiej sytuacji, gdy dana konwersja jest przeprowadzana bardzo rzadko i wyjątkowo. Jest on zresztą łatwy do zastąpienia przez metody wirtualne, nie mówiąc już o tym, że są one zazwyczaj szybsze. Jeśli bowiem np. mamy klasę Y wyprowadzoną z klasy X, a tą z kolei z klasy A, to jeśli chcemy zrzutować obiekt typu Y ze wskaźnika na A do wskaźnika na X, to będzie to bardziej złożona operacja. Operator bowiem najpierw zagląda do obiektu charakterystycznego i co stwierdza – jest to Y. Musi więc jeszcze dodatkowo sprawdzić, czy pomiędzy Y a X można dokonać konwersji, a jeśli tak to co jeszcze z deltą. W przypadku gdybyśmy zrobili sobie metodę wirtualną pozwalającą na otrzymanie wskaźnika do X, to ze wskaźnika na A przeszłoby na zasadzie wywołania wirtualnego, a ze wskaźnika na Y na zasadzie wywołania metody z klasy podstawowej i w obu przypadkach sprowadziłoby się to tylko do jednego wołania przez wskaźnik. Jak więc widać, operator dynamic_cast zaleca się stosować tylko, gdy rzadko kiedy potrzebna jest danego rodzaju konwersja i raczej zaleca się konwertować wyłącznie na typ wyjściowy obiektu (oczywiście to, co napisałem, to jest bzdura - jeśli już ktoś ma polimorficzną hierarchię klas, to zwykle nie może w jakiejś funkcji zakładać, że obiekt trzymany za wskaźnik do klasy podstawowej ma jakiś-tam konkretny typ wyjściowy).

Operator reinterpret_cast jest w swojej istocie bardzo podobny do rzutowania wymuszonego. Powoduje on bowiem dokładnie takie samo zachowanie. Jednak jest jedno ograniczenie jego użycia: nie można konwertować pomiędzy wskaźnikami różniącymi się wariancją. Nie jest to jednak tak do końca dobrze chronione; wyobraźmy sobie np. taką sytuację:

const char** c; // inaczej ptr<ptr<const char>>
void* v = c;  // dozwolone: ptr<X> można konwertować do ptr<void>
char** x = reinterpret_cast<char**>( v ); // no i co ? :)

Oczywiście niestety przy wskaźnikach wyższego rzędu nie da się zapewnić żadnej ochrony. Wspomniane `X' jest tu typem jak każdy inny i podczas konwersji wszelkie informacje na jego temat są tracone. Nie jest to więc doskonała ochrona, ale też prawda jest taka, że takie rzutowania nie są czymś, co w C++ jest na porządku dziennym. No, oczywiście zależy jeszcze od tego, ile czasu ktoś robił w C... :)

Do zmiany wariancji zatem służy już ostatni z tych operatorów, const_cast. Służy on do rzutowania wskaźników lub referencji (tu zwracam na to szczególną uwagę!!!) różniących się modyfikatorami const i volatile. Operacje takiego rzutowania w praktyce rzadko mają sens. Jednak mogę podać przykład takiego rzutowania, gdzie to rzeczywiście ma sens.

Wyobraźmy sobie taką funkcję, która ma pobrać lub ustawić tekst na przycisku. Niech ona się nazywa ManageWindowText i ma taki nagłówek:

bool ManageWindowText(
  WId window, char* text, bool retrieve
);

Argumentami są: `window' – identyfikator kontrolki, `text' – wskaźnik na tablicę tekstową, `retrieve' – flaga operacji. Flaga operacji decyduje o tym, czy należy tekst ustawić na kontrolkę, czy pobrać z kontrolki.

Gdybyśmy chcieli tekst pobrać z kontrolki (czyli przycisku), to wtedy jako drugi argument należałoby podać wskaźnik na tablicę tekstową, do której ten tekst byłby wpisany. Z tego względu w nagłówku funkcji nie może być mowy o const. Oczywiście, że gdy retrieve == false, to funkcja nie modyfikuje w ogóle podanego ciągu znaków, ale co z tego, gdy ta funkcja jest taka "uniwersalna".

I teraz taka sytuacja: dostaliśmy z jakiejś funkcji napis podany przez typ std::string, który można przez c_str() skonwertować na const char*, no i chcemy go teraz ustawić na tej kontrolce. Co jednak jesteśmy w stanie zrobić, gdy choć funkcja będzie nasz string czytać, to jednak chce go dostać przez char*? Są dwie metody. Pierwsza z nich polega na skopiowaniu stringa do lokalnej tablicy i wtedy można go bezproblemowo przekazać przez char*. Jednak jest to dodatkowa niepotrzebna operacja. Druga metoda polega na wywołaniu tego przez:

ManageWindowText(
  wid, const_cast<char*>( str.c_str() ), false
);

Oczywiście wielu się zgodzi, że projekt należy tak robić, aby rzutowanie – a już zwłaszcza tak niebezpieczne – nie było konieczne. Jednak nie jest to takie proste. Co prawda pod windows tekst na okienku obsługuje się funkcjami SetWindowText i GetWindowText, jednak istnieje taka funkcja (do obsługi combo-boxów), która ma właśnie taki protokół. Jak więc widać, nie ma czasami sposobu na uniknięcie takich potworków, właśnie dlatego, że nie zawsze mamy wpływ na to, jak ktoś rozplanował bibliotekę. Podana funkcja jest to oczywiście koronny przykład głupiego programowania, gdzie ma się gdzieś const, a przede wszystkim ma się gdzieś C++ (w C modyfikatora const używa się znacznie rzadziej, niż w C++). Niestety często dostosowanie się do takich bibliotek bywa trudne (w systemach uniksowych też takowych nie brakuje).