Standardowe konwersje wyrażeń dotyczące kategorii wartości
W ostatnim artykule dotyczącym kategorii typów powiedzieliśmy sobie o podziale wyrażeń, oraz o tym, czym są kategorie wartości. Nie wspomnieliśmy jednak, co się dzieje, kiedy wyrażenie danej kategorii pojawi się w miejscu gdzie oczekiwane jest wyrażenie innej kategorii.
Ten artykuł ma na celu wyjaśnienie właśnie zasad konwersji wyrażeń oraz tego, jakie operacje program musi przeprowadzić, aby te konwersje mogły się odbyć.
Konwersje glvalue na prvalue
Żeby wyjaśnić sobie czym jest konwersja wyrażeń glvalue na prvalue, spójrzmy na poniższy przykład:
int add5(int a){
return a+5;
}
int value=/*some value here*/;
add5(value);
Każdy patrząc na kod powyżej stwierdzi, że się on skompiluje. Nie znaczy to jednak, że jest poprawny. (W kodzie czai się na Was potencjalne UB, czy je widzicie? - dajcie znać w komentarzu). Nie mniej jednak, jeżeli zastanowimy się nad tym bliżej, możemy mieć pewną wątpliwość. W końcu funkcja add5
oczekuje wartości jako argumentu, a więc prvalue. Wyrażenie, które podstawiamy natomiast pod ten argument, tak naprawdę jest lvalue, ponieważ odnosi się ono do zmiennej value.
Przytoczony przykład to właśnie przykład działania konwersji glvalue na prvalue. Konwersja ta zawsze odbywa się wtedy, gdy spodziewanym wyrażeniem jest prvalue, a programista podaje glvalue (czyli lvalue lub xvalue).
Konwersję lvalue na rvalue można dalej wyszczególnić na:
- ogólne lvalue na rvalue.
- konwersję tablicy na wskaźnik,
- konwersję funkcji na wskaźnik,
Ogólne lvalue na rvalue
Każde wyrażenie glvalue można zamienić na jego odpowiednie prvalue (jeżeli glvalue to tablica lub wskaźnik na funkcję; konwersje odbywają się w nieco inny sposób wyjaśniony później) i jak zwykle standard języka precyzyjnie mówi, jakie zasady muszą być spełnione oraz jakie akcje muszą się “zadziać”, aby kompilator mógł taką konwersję wykonać.
W najbardziej ogólnym przypadku, kiedy naszym typem jest typ klasowy (może to być klasa, unia lub struktura), to w celu uzyskania prvalue, potrzebna jest dodatkowa kopia obiektu. Kopia ta jest uzyskiwana poprzez copy-initialization:
T copy = expression;
Warto tutaj zwrócić uwagę na następstwa wykonania kopii w ten sposób:
- kopia bierze pod uwagę lvalue oraz xvalue - jeżeli expression będzie xvalue, to wywołany zostanie konstruktor przenoszący
- glvalue oraz oczekiwane prvalue mogą mieć różne typy tak długo, jak typ expression jest konwertowalny na typ
T
(poprzez odpowiedni konstruktor lub operatory rzutujące). Na przykład typemT
może byćstd::vector
, aexpression
może być typustd::initializer_list
.
Dygresja - copy-initialization
Spróbujmy zobaczyć, jak wygląda w praktyce copy-initialization (inicjalizacja kopiująca).
Załóżmy, że mamy funkcję wypisującą wszystkie elementy wektora:
void printAll(std::vector<int> elements);
Załóżmy również, że z jakiegoś powodu, funkcja ta przyjmuje wektor poprzez wartość (zostawmy na bok dyskusję na temat tego, że wektor powinien być przyjmowany poprzez const referencję).
Jeżeli będziemy chcieli użyć tej funkcji z lvalue jako argumentem, to możemy spróbować następujących kawałków kodu:
std::vector<int> myvector;
printAll(myvector);
Powyższy przykład zadziała (skompiluje się), ponieważ można utworzyć wektor z innego wektora. Pozwala na to następujący konstruktor:
vector( const vector& other );
Nie zawsze możemy jednak posiadać pod ręką gotowy wektor do przekazania do funkcji. Co wtedy możemy zrobić? Powiedzmy, że mamy do dyspozycji jedynie std::initializer_list<int>
. Kod mógłby wtedy wyglądać następująco:
auto mylist = {1,2,3,4};
printAll(mylist);
Czy to zadziała? Jak najbardziej, ponieważ można utworzyć wektor z std::initializer_list
‘y.
Domyślam się, że na świecie są osoby kochające pisać nieczytelny kod. Na pewno zastanawiają się one, co by było gdyby tak wykorzystać konstruktor przyjmujący liczbę całkowitą. W końcu można utworzyć wektor w następujący sposób:
std::vector myvector(42);
Sprawi to, że wektor będzie miał 42 elementy o wartości 0.
Okazuje się, że kod:
int noElements = 42;
printAll(noElements);
wcale nie zadziała (nie skompiluje się). I dobrze! Z wywołania funkcji wcale nie widzimy, że mielibyśmy wypisać 42 zera. Pytanie jednak: dlaczego kod się nie skompiluje?
Odpowiedź jest następująca: std::vector myvector(noElements);
to nie jest copy-initialization, ale direct-initialization. Copy initialization dla wektora musiałoby wyglądać następująco:
std::vector<int> myvector = noElements;
Jak się okazuje ten kod również się nie skompiluje! Wynika to stąd, że konstruktor wektora przyjmującego wartość całkowitą jest explicit
i zabrania rzutowania wartości całkowitej na wektor. Cała deklaracja wektora jest następująca:
explicit vector( size_type count );
Pamiętajmy zatem, że konwersja glvalue na prvalue uwzględnia jedynie to, co można zrobić za pomocą copy-initialization.
Ale koniec już tej dygresji. Wróćmy do pierwotnego tematu. Omówiliśmy sobie ogólne zasady konwersji glvalue na prvalue. Przyjrzyjmy się teraz szczególnym ich przypadkom.
Rzutowanie tablicy na wskaźnik
Jest to bardzo znane rzutowanie, którego dopuszcza się kompilator. Powoduje ono, że możemy traktować C-style tablicę, jako wskaźnik na ciągły obszar pamięci. Spójrzmy bliżej co to oznacza:
int tab[4];
Powyższy przykład to deklaracja tablicy czteroelementowej. Innymi słowy, na stosie zostaną zarezerwowane cztery miejsca (jedno obok drugiego) na liczbę całkowitą typu int
. Pytanie brzmi, jaki typ ma tablica? Wiele osób z czytelników zapewne odpowie, że typ to int*
. Jest to przyzwyczajenie osób do zachowania kompilatora, które w tej sekcji tłumaczymy jako rzutowanie tablicy na wskaźnik. Wskaźnik na int nie jest jednak typem zmiennej tab
. Okazuje się, że tak naprawdę typ tablicy to int[4]
. Oczywiście możemy stworzyć sobie referencję do takiej tablicy:
auto& ref = tab;
jak widzimy po prawej stronie przypisania spodziewamy się lvalue (odnosimy się do nazwy zmiennej), a więc konwersja glvalue na prvalue nie będzie miała miejsca (tablica nie zostanie zrzutowana na wskaźnik). Jakiego typu więc będzie zmienna ref? Od powiedź brzmi: int(&)[4]
możemy to oczywiście sprawdzić:
static_assert(std::is_same<decltype(ref), int(&)[4]>::value);
Rzutowanie funkcji na wskaźnik
Podobnie jak rzutowanie tablicy na wskaźnik, istnieje również znane nam rzutowanie funkcji na wskaźnik. Praktycznie każdy z nas, kto miał szansę spotkać się z programowaniem w języku C i słyszał o callback’ach, wie, że funkcję można przekazać jako argument innej funkcji. Spójrzmy sobie na przykład takiego kodu:
void callback_caller(void(*callback)(int), int arg){
std::cout << "calling callback:" << std::endl;
callback(arg);
}
void printer(int arg){
std::cout << arg << std::endl;
}
int main(){
callback_caller(printer, 5);
}
Widzicie to? W funkcji przyjmujemy przecież wskaźnik do funkcji przez wartość - prvalue, a podajemy kompilatorowi wyrażenie, będące lvalue - nazwa funkcji printer. To jest właśnie rzutowanie funkcji na wskaźnik i kolejny przykład konwersji glvalue na prvalue. Tutaj, tak samo jak w przypadku tablic, typ również ulega zmianie. Sam typ funkcji to void(int)
. Jeżeli poprosilibyśmy kompilator o dedukcję referencji do funkcji, to naturalnie moglibyśmy to zrobić tak samo, jak w przypadku tablic:
auto& function_ref = printer;
I upewnijmy się jakiego typu jest nasz zmienna function ref
:
static_assert(std::is_same<decltype(function_ref), void(&)(int)>);
Jak widać więc zarówno w przypadku rzutowania wyrażeń odnoszącymi się do funkcji lub tablic, nie tylko kategoria wartości ulega zmianie, ale także sam typ - w obu przypadkach typ ulega zmianie na typ wskaźnikowy.
Podsumowanie konwersji glvalue na prvalue
Pewnie jesteście ciekawi po co nam te konwersje? W przypadku ogólnej konwersji glvalue na rvalue jest ona praktycznie wymogiem, aby można było pisać jakiekolwiek programy. Inaczej przekazywanie argumentów do funkcji byłoby znacznie utrudnione, jeżeli nie niemożliwe w wielu przypadkach. Jeżeli natomiast chodzi o konwersje funkcji oraz tablic na wskaźniki, to:
Z pewnością istnieją w języku C++ dlatego, że istniały również w języku C. Oczywiście w języku C również istniały one z jakiegoś powodu. Jeżeli chodzi o tablice, to jak widać w przykładach - informacja na temat ich rozmiaru jest niejako wbudowana w ich typ. Gdyby ta konwersja nie zachodziła, to w języku C++ musielibyśmy obsługiwać takie tablice za pomocą funkcji szablonowych, jak np. ta:
template <unsigned long long val>
void process_tab(int(&tab_ref)[val]){/*processing object*/}
Takie podejście do obsługi tablic oczywiście ma pewne ograniczenia. Przede wszystkim funkcji szablonowych nie da się użyć w przypadkach, kiedy rozmiar tablicy nie jest znany w czasie kompilacji. Funkcji szablonowych również nie dałoby się w ogóle napisać w języku C. Gdybyśmy chcieli bez tej automatycznej konwersji wywołać funkcję obsługującą dowolną tablicę, musielibyśmy to robić mniej więcej w następujący sposób:
void foo(int*, unsigned long long size);
int tab[4];
foo(&tab[0], 4);
Zdecydowanie jest to mniej przejrzyste niż wersja z automatyczną konwersją.
Co więcej, podejście z traktowaniem tablicy jako wskaźnik było stosowane już za czasów asemblera, gdzie często programiści umieszczali pewne stałe wartości w kodzie i oznaczali ten kod pewną etykietą, która podczas kompilacji zamieniana była na adres. Do tej etykiety programiści później odnosili się w kodzie.
Jeżeli chodzi o kwestie rzutowania funkcji na wskaźnik, to wydaje mi się, że kwestia wprowadzenia jej była wyłącznie dla wygody programisty. Traktując wskaźnik na funkcję, jak i samą funkcję tak samo (co nie jest tym samym, co rzutowanie funkcji na wskaźnik), możemy łatwo zmieniać wskaźnik tak, aby wskazywał na inną funkcję czy przechowywać wartość wskaźnika w klasach. Co więcej możemy traktować funktory (typy klasy ze zdefiniowanym operatorem wywołania funkcji) i wskaźniki na funkcje w szablonowych klasach tak samo:
template <typename Func>
void foo(Func func){
func();
}
Gdybyśmy nie mogli traktować wskaźników na funkcje i funktorów tak samo, to dla powyższej funkcji, musielibyśmy napisać przeciążenie:
template <typename FuncPtr>
void foo(FuncPtr* func){
(*func)();
}
aby nasz kod był w pełni generyczny.
Ale to nie wszystko. Pozostała nam jeszcze konwersja prvalue na glvalue (a konkretnie xvalue).
Konwersja prvalue na glvalue
Zanim zaczniemy sobie mówić o konwersji z prvalue na glvalue, to chciałbym wam pokazać pewien przykład kodu:
struct Foo{
void printMe(){std::cout << "foo" << std::endl;}
};
/**/
Foo{}.printMe();
Na pierwszy rzut oka wszystko gra. Wszyscy są przyzwyczajeni do tworzenia tymczasowych zmiennych, na których wykonują pewne operacje. Co jednak, jeśli powiem Wam, że operator .
do działania (poprawnej kompilacji) potrzebuje po swojej lewej stronie glvalue? Można to łatwo sprawdzić w standardzie:
A postfix expression followed by a dot . or an arrow ->, optionally followed by the keyword template ([temp.names]), and then followed by an id-expression, is a postfix expression.
The postfix expression before the dot or arrow is evaluated;62 the result of that evaluation, together with the id-expression, determines the result of the entire postfix expression.
For the first option (dot) the first expression shall be a glvalue. …
A więc wiedząc tylko to, Foo{}.printMe();
nie powinno się skompilować, ponieważ wyrażenie po lewej stronie kropki jest prvalue, które glvalue nie jest. Jak to się więc dzieje, że program się kompiluje?
Nie trudno się domyślić, że za tym wszystkim stoi właśnie konwersja prvalue na glvalue. A więc na czym to polega?
Za każdym razem, kiedy kompilator spodziewa się glvalue, a dostaje wyrażenie prvalue, następuje process nazywany temporary materialization conversion (po polsku przetłumaczylibyśmy to prawdopodobnie jako konwersja materializacji zmiennych tymczasowych). Proces materializacji zmiennej tymczasowej polega na dwóch rzeczach: po pierwsze utworzeniu obiektu takiego jak tymczasowy, który będzie posiadał swój adres, a następnie zmianie wyrażenia na xvalue.
Powiedzieliśmy, że ten obiekt tymczasowy będzie posiadał swój adres w pamięci, ale ktoś może się doczepić, że przecież:
Foo* ptr = &Foo{};
nie skompiluje się.
I na całe szczęście będzie mieć rację, inaczej skończylibyśmy z tzw. dangling pointerem (wskaźnikiem, który nie wskazuje na poprawne dane). No więc jak sprawdzić adres takiej zmiennej tymczasowej? Można to zrobić np. w następujący sposób:
void printAddress(Foo&& temporary){ std::cout << &temporary << std::endl;}
printAddress(Foo{});
Może Was to dziwić, że samo pobranie adresu zmiennej tymczasowej nie działa, ale kiedy opakujemy je w funkcję, to wszystko jest w porządku. Na pewno jesteście również ciekawi, dlaczego tak się dzieje. Już śpieszę Wam z odpowiedzią!
Otóż operator &
wymaga lvalue jako swojego argumentu. Jak już wspomnieliśmy zmienna tymczasowa jest konwertowana na xvalue, a nie lvalue, stąd błąd kompilacji.
Kiedy opakujemy jednak operację pobrania adresu w funkcję i użyjemy jej jak na przykładzie, to okazuje się, że nazwa temporary
będzie lvalue. Dlaczego lvalue? Najprostszą odpowiedzią będzie, że jest glvalue, ale nie jest xvalue. Po szczegóły zapraszam do poprzedniego postu na temat kategorii wartości.
Podsumowanie
Jak widać, nawet bardzo “proste” i znane mechanizmy C++ mogą mieć dość skomplikowane zasady działania, jednak wszystko jest robione po to, żeby dogodzić programiście w jak największej ilości przypadków. Jakkolwiek by na to nie patrzeć, nawet bez wiedzy zawartej w tym artykule, bylibyście w stanie napisać dużą ilość kodu, ewentualnie od czasu do czasu napotykając na błąd kompilacji, który byłby łatwy do naprawienia, nieprawdaż?
Jednak z nową wiedzą powinniście lepiej zrozumieć zasady działania C++ a także stać się lepszymi przyjaciółmi kompilatora, a mianowicie zrozumieć, dlaczego w niektórych sytuacjach kompilator się Was czepia :)