Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Podział wyrażeń ze względu na kategorie wartości w C++


2019-03-28, 01:20

Prawdopodobnie słyszałeś o lvalue oraz rvalue. W czasach panowania C oraz C++ (przed standardem C++ 11) były one dość łatwe do rozróżnienia - lvalue, to były te wyrażenia, które mogły znajdować się po lewej stronie przypisania, a rvalue to cała reszta.

Od C++11 podział na wyrażenia stał się nieco bardziej skomplikowany. Poza znanymi l- i r-value mamy także gl-, x- oraz p-rvalue. W tym poście, chciałbym się przyjrzeć temu podziałowi nieco bardziej.

Kategorie wartości

W standardzie C++ znajdziemy następujące drzewko przedstawiające zależności między poszczególnymi kategoriami wartości:

kategorie wartości

Z podziału wynika, że aby zrozumieć podział kategorii wystarczy zrozumieć kategorie znajdujące się na samym dole drzewa, czyli:

  1. lvalue
  2. xvalue
  3. prvalue

A więc od początku:

lvalues

W standardzie niestety, definicja lvalues jest mało przydatna bez zrozumienia najpierw czym są glvalues oraz xvalues, ponieważ czytamy tam, że:

An lvalue is a glvalue that is not an xvalue.

Zacznijmy w takim razie od spojrzenia na to, czym jest glvalue. Czytamy, że:

A glvalue is an expression whose evaluation determines the identity of an object, bit-field, or function.

Na pierwszy rzut oka jest to trochę bełkot, ale postarajmy się to zrozumieć w intuicyjny sposób.

To, co standard stara się nam przekazać, to to, że glvalue to wyrażennie, którego wynikiem (expression whose evaluation determines), nie jest wartość, a referencja do już istniejącego obiektu, lub kiedy odnosimy się do jakiejś nazwanej zmiennej poprzez jej nazwę (determines identity of an object) .

W taki właśnie sposób możemy rozumieć glvalues. Pytanie brzmi teraz, jaka jest różnica pomiędzy xvalues a lvalues. Odpowiedź zawiera nowa funkcjonalność dodana w C++11, a mianowicie tzw. move semantics.

Każde wyrażenie, które nie “przenosi” wartości zmiennych i jest glvalue, jest też lvalue.

Spójrzmy sobie może na przykłady takich wyrażeń, aby nabrać intuicji, co do tego czym jest lvalue. Popatrzmy najpierw na definicje:

int& foo(){
    static int i=0;
    return ++i; //lvalue - odnosi się do obiektu i
}

int a;

struct Bar{
    int m;
};
Bar bar;

dla podanych definicji następujące wyrażenia będą lvalue:

    foo() // zwracana wartość odnosi się do zmiennej statycznej 'i'.
    a // odnosi się do zmiennej 'a' można przypisać do zmiennej int&
    bar // odnosi się do zmiennej `bar`. zwracany typ to Bar&
    bar.m // odnosi się do pola `m` obiektu `bar`. Można przypisać do zmiennej  int&

Łatwo wywnioskować, że jeżeli zwracany typ wyrażenia jest postaci T&, lub odnosi się przez nazwę do nazwanej zmiennej, to wyrażenie jest lvalue.

Dla porównania następujące wyrażenia nie będą lvalue:

    5 // nie odnosi się do żadnej istniejącej zmiennej.
    Bar{} // nie odnosi się do żadnej istniejącej zmiennej, lecz tworzy nową.
    std::move(a); // zwracany typ jest T&&, a nie T&

Podsumowując, lvalues to wyrażenia, które zwracają lvalue referencję do zmiennej.

xvalues

Xvalue to coś zupełnie nowego (zaczynając od C++11) i jak już zostało wspomniane, jest mocno związane z semantyką przenoszenia. Spójrzmy zatem na to, w jaki sposób standard definiuje xvalues:

An xvalue is a glvalue that denotes an object or bit-field whose resources can be reused.

Kluczową częścią tego zdania, do zrozumienia, czym są xvalues jest to, że zasoby obiektu mogą być ponownie użyte stąd nazwa - eXpiring values. Pozostaje tylko pytanie w jaki sposób oznaczamy referencję do obiektu, że wskazuje na obiekt, którego zasoby mogą zostać wykorzystane. Odpowiedź brzmi: rvalue referencje.

A więc innymi słowy, tak jak lvalue to wyrażenie, którego wynikiem jest lvalue referencja (lub inne odniesienie do istniejącej zmiennej), tak xvalue to wyrażenie, którego wynikiem jest rvalue referencja. Co więcej, standard szczegółowo opisuje w jaki sposób możemy uzyskać wyrażenie xvalue.

Jawne rzutowanie na rvalue referencję

Spójrzmy na poniższy kod:

    //rzutowanie na rvalue referencję.
    struct Foo{/* definition */};
    int main() {
        Foo a;
        static_cast<Foo&&>(a); // wyrażenie xvalue
    }

W podanym przykładzie mamy pewną klasę Foo, której definicja nie jest istotna. Ciało funkcji main zawiera tworzenie oraz rzutowanie obiektu na rvalue referencję. Właśnie to rzutowanie powoduje, że całe wyrażenie zawarte w ostatniej linijce jest rvalue.

Co więcej wyrażenie możemy rozbić na dwa mniejsze:

  1. a - wyrażenie lvalue
  2. static_cast<Foo&&>(a); - wyrażenie xvalue

Tak jak zostało wcześniej wspomniane, xvalue oznacza nam obiekt, którego zasoby można ponownie użyć, tak więc

Foo b(static_cast<Foo&&>(a));

spowoduje wywołanie konstruktora przenoszącego dla obiektu b, jeżeli takowy jest zdefiniowany.

Wywołanie funkcji, która zwraca rvalue referencję

Innym sposobem na to, żeby wyrażenie było xvalue, jest wywołanie funkcji, której typem zwracanym jest rvalue referencja.

    //wywołanie funkcji zwracającej rvalue referencję
    struct Foo{};
    Foo&& bar(); 

    int main(){
        bar(); // "bar()" jest wyrażeniem xvalue 
    }

W naszym przykładzie jest to funkcja bar. Jej przykładowe ciało mogłoby wyglądać następująco:

Foo&& bar(){
  static Foo foo(/*argumenty*/);
  return static_cast<Foo&&>(foo);
}

Funkcja bar zwraca rvalue referencję do statycznie utworzonej zmiennej. Warto zwrócić uwagę na to, że wywołanie takiej funkcji kilkukrotnie może nie być najlepszym pomysłem. Pierwsze wywołanie funkcji spowoduje utworzenie zmiennej foo oraz potencjalne przeniesienie zawartości obiektu. Po przeniesieniu zawartości, kolejne wywołanie funkcji może skończyć się niespodziewanym (przez wywołującego) wynikiem.

Innym przykładem na uzyskanie rvalue referencji jest wywołanie dobrze nam już znanej funkcji z biblioteki standardowej std::move.

Przyjrzyjmy się możliwej definicji takiej funkcji:

template<typename T>
std::remove_reference<T>::type&& move(T&& value){
  return static_cast<T&&>(value);
}

Nie będziemy przyglądać się szczegółom implementacyjnym tej funkcji, ważne natomiast jest to, że funkcja ta zawsze będzie zwracać rvalue referencję. Z tego właśnie powodu wywołanie funkcji będzie zawsze wyrażeniem xvalue.

Dostęp do pola obiektu, nie będącego referencją

Kolejnym sposobem na otrzymanie wyrażenia xvalue jest dostęp do pola obiektu, nie będącego referencją, gdzie po lewej stronie operatora . jest xvalue.

    //dostęp do składowej klasy.
    template <typename T>
    struct Foo{
        T member;
    };
    int main(){
        using non_reference_type=/**/;
        Foo<non_reference_type> a{};
        std::move(a).member; // wyrażenie xvalue
    }

Jeżeli na rvalue referencji do obiektu klasy będziemy próbowali się dobrać do pola tego obiektu (pole nie może być typem referencyjnym), to otrzymamy nie lvalue referencję, ale rvalue referencję, co oznacza, że nasze wyrażenie będzie xvalue.

Dzieje się tak z prostego powodu: Jeżeli obiekt, do którego membera próbujemy się dostać jest xvalue, czyli jego zasoby mogą zostać ponownie użyte, to również możemy użyć ponownie zasobów jego memberów, stąd są one również xvalue.

Nie działa to dla typów referencyjnych, ponieważ istnienie referencji jako pola oznacza, że obiekt raczej nie zarządza czasem życia obiektu, na który wskazuje referencja. Stąd prawdopodobnie nie powinniśmy przywłaszczać sobie jego zasobów.

Dostęp do pola obiektu, poprzez wskaźnik na pole

Przykład z poprzedniego punktu można również zapisać z wykorzystaniem wskaźnika na pole.

    // analogiczny dostęp do poprzedniego,
    // ale tym razem z użyciem wskaźnika na pole.
    int main(){
        using non_reference_type=/**/;
        non_reference_type Foo<non_reference_type>::* pointer =
                                                &Foo<non_reference_type>::member;
        Foo<non_reference_type> foo{};
        std::move(foo).*pointer; //wyrażenie xvalue
        return 0;
    }

Nasz program robi dokładnie to samo, co poprzedni i zasady uzyskania rvalue referencji/wyrażenia xvalue są dokładnie takie same. W tym przykładzie użyto po prostu składni z użyciem wskaźnika na pole klasy, która to jest, łagodnie mówiąc, brzydka.

Jeżeli nie spotkałeś się do tej pory ze wskaźnikami na klasy, moją radą jest pominięcie tego przykładu :)

Użycie operatora indeksowania na xvalue o typie tablicy

Przeanalizujmy poniższy przykład.

    //w przypadku tablic xvalue jest "propagowane"
    //do obiektów uzyskanych przez operator indeksowania
    int main(){
        Foo arr[10] = {};
        std::move(arr)[0]; //rvalue referencja do pierwszego elementu tablicy
    }

Z podobnego powodu, jak w przypadku klas i obiektów, wyrażenie jest xvalue, jeżeli na xvalue tablicy wywołany zostanie operator indeksowania. Jeżeli zawartość tablicy można wykorzystać ponownie to tak samo jej elementy, więc są one zwracane z operatora jako rvalue referencja, a samo wyrażenie staje się xvalue.

Inne przykłady xvalues

Poza powyższymi punktami które mówią o tym w jaki sposób uzyskać xvalue wyrażenie, istnieją pewne reguły konwersji wyrażeń, o których powiemy sobie w kolejnym poście.

Zobaczmy inne przykłady xvalues wzięte wprost ze standardu. Dla podanych definicji:

struct A {
    int m;
};

A&& operator+(A, A);
A&& f();
A a;
A&& ar = static_cast<A&&>(a);

następujące wyrażenia to xvalues:

  1. f()
  2. f().m
  3. static_­cast<A&&>(a)
  4. a + a

Podsumowując, xvalues to wszystkie wyrażenia, których wynik jest typu rvalue referencja.

prvalues

Omówiliśmy już wyrażenia, których wynikiem jest referencja. Nie pozostało nam więc nic innego jak wyrażenia zwracające wartości i jak nie trudno się domyślić są to właśnie wyrażenia prvalues.

Cóż więcej można o nich powiedzieć? Z ciekawostek, wyrażenia, które zwracają typ void, są również prvalue. Wynika to stąd, że glvalues muszą odnosić się do jakiegoś konkretnego obiektu. Wyrażenia zwracające void, nie mogą spełnić tego wymagania, ponieważ nie można stworzyć obiektu typu void.

Kojarzycie może zmienne tymaczasowe? Właśnie te tworzone są poprzez wyrażenia prvalue.

Co by tradycji stała się zadość, tutaj również przyjrzyjmy się kilku przykładom wyrażeń prvalue:

struct Foo{};
Foo foo();

5; // prvalue
Foo{}; // prvalue
foo(); // prvalue

Wymóg pełnej definicji typu

Jeżeli zaczniemy myśleć o glvalues oraz prvalues możemy wywnioskować coś wyjątkowego. Przede wszystkim dawny podział wyrażeń na lvalues i rvalues odpowiada mniej więcej dzisiejszemu glvalues oraz prvalues.

Glvalues oraz prvalues różnią się jeszcze jedną rzeczą - mianowicie wymogiem tego, czy typ na którym operują jest w pełni zdefiniowany.

W przypadku prvalues zawsze musimy mieć pełną definicję typu, aby z niego korzystać, a w przypadku glvalues możemy czasami ominąć wspomnianą pełną definicję typu, ale spójrzmy na przykład takiego kodu:

(pierwsza jednostka kompilacji)

struct foo {};

foo& get_first(){
    static foo foo1;
    return foo1;
}
foo& get_second(){
    static foo foo2;
    return foo2;
}

Obie funkcje robią dokładnie to samo - zwracają referencję do statycznego obiektu, jednak każda z funkcji zwraca referencję do innego obiektu.

Teraz możemy napisać następujący kod, wykorzystując jedynie glvalues i forward deklaracje typu foo:

struct foo; //forward declaration

foo& get_first();
foo& get_second();

foo& first_value(foo& first, foo& second){
    return first;
}

int main() {
    auto& first = get_first();
    auto& second = get_second();
    auto& result = first_value(first, second);
    return 0;
}

Okazuje się, że cały program skompiluje się i będzie działał prawidłowo. Jeżeli ośmielimy się zmienić chociaż w jednym miejscu gvalue na prvalue, to kompilacja się nie powiedzie. Zmieńmy zwracany typ funkcji first_value na nie referencyjny typ:

struct foo; //forward declaration

foo& get_first();
foo& get_second();

foo first_value(foo& first, foo& second){
    return first;
}

int main() {
    auto& first = get_first();
    auto& second = get_second();
    auto& result = first_value(first, second);
    return 0;
}

Naszym oczom ukaże się błąd kompilacji. Przykładowa wiadomość, jaką możemy dostać od kompilatora, to:

error: invalid use of incomplete type 'struct foo'
error: return type 'struct foo' is incomplete

Podsumowanie

To by było na tyle, jeżeli chodzi o kategorie wartości. Mam nadzieję, że post chociaż trochę przybliżył Wam ich semantykę i w jakiś sposób ułatwi Wam to rozumienie, co się dzieje w Waszym programie. W kolejnym poście omówimy sobie konwersje między różnymi wyrażeniami, różniącymi się kategorią wartości.

Do usłyszenia!



Dawid Pilarski

Software Developer w TomTom. Absolwent Politechniki Łódzkiej kierunku Automatyka i Robotyka. Członek komitetu standaryzacyjnego C++, blogger, prywatny nauczyciel C++.

Blog Dawida
Profil na LinkedIn
Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.