cpp

Wprowadzenie

Kategorie wyrażeń to podstawa, ale trudno je zrozumieć, bo chodzi o wiele szczegółów l-wartości i r-wartości, które w codziennym programowaniu uchodzą naszej uwadze.

Żeby łatwiej zrozumieć l-wartości i r-wartości, proponuję szczegółowo przyswoić ten materiał, bez poszukiwania głębszego sensu na tym etapie. Podobną radę otrzymała Alicja od Humpty Dumpty w powieści “Po drugiej stronie lustra” autorstwa Lewisa Carrolla:

“Must a name mean something?” Alice asks Humpty Dumpty, only to get this answer: “When I use a word… it means just what I choose it to mean – neither more nor less.”

Wartość wyrażenia

Wyrażenie może być:

Wartość wyrażenia jest wynikiem opracowania wyrażenia.

Wyrażenie ma:

Możemy mówić o kategorii wartości wyrażenia, albo w skrócie o kategorii wyrażenia.

Historia: CPL, C, C++98

Język CPL (około pół wieku temu) zdefiniował dwie kategorie wyrażeń w odniesieniu do operatora przypisania:

Te definicje mają jedynie znaczenie historyczne i nie są stosowane w C++.

W języku C, wyrażenie jest albo l-wartością, gdzie “l” pochodzi od “locator”, czyli czegoś, co lokalizuje (wskazuje) miejsce wartości wyrażenia. W języku C, non-lvalue jest wyrażeniem, które nie jest kategorii l-wartość. W języku C nie ma pojęcia r-wartości!

C++98 przyjął termin i znaczenie l-wartości z języka C, a wyrażenie, które nie jest l-wartością, nazwał r-wartością.

Szczegóły

Kategorie wyrażeń

W C++, dwiema podstawowymi kategoriami wyrażeń są l-wartość i r-wartość. Wyrażenie, które jest:

Kategoria wyrażenia określa, co możemy zrobić z wyrażeniem. Pewne operacje możemy wykonać wyłącznie na l-wartości (np. &x, czyli pobranie adresu zmiennej x), inne operacje wyłącznie na r-wartości.

Przykładowe operacje na wyrażeniu <expr>:

Definicje l-wartości i r-wartości

Na próżno szukać w standarcie C++ zwięzłej i poprawnej definicji l-wartości i r-wartości. Standard C++, który ma około 1500 stron, definiuje po trochu te kategorie w różnych miejscach, według potrzeby, co utrudnia zrozumienie kategorii wartości.

Na domiar tych trudności, w C++11 wprowadzono kolejne kategorie: pr-wartość, gl-wartość i x-wartość, które w C++17 uszczegółowiono. Jednak dwiema podstawowymi kategoriami są l-wartość i r-wartość i tylko nimi będziemy się zajmować.

Trzeba poznać szczegóły l-wartości i r-wartości, żeby zrozumieć i wydajnie używać nowoczesny C++ (chociaż też bez nich można jakoś się obejść po omacku). Na przykład, nie sposób zrozumieć poniższego zdania pochodzącego z http://cppreference.com bez szczegółowej wiedzy na temat kategorii wyrażeń:

Nawet jeżeli typem zmiennej jest referencja typu r-wartość (r-referencja), to wyrażenie składające się z nazwy tej zmiennej jest l-wartością.

L-wartość

Standard C++ nie podaje zwięzłej definicji, ale poniższa obserwacja (nie definicja) wydaje się sprawdzać.

Obserwacja: Jeżeli &<expr> kompiluje się, to <expr> jest l-wartością. Czyli wyrażenie jest l-wartością, jeżeli możemy pobrać jego adres.

Najważniejszym przypadkiem tej obserwacji jest &x, gdzie x jest nazwą zmiennej. Wyrażenie z nazwą zmiennej jest l-wartością.

Przykłady l-wartości:

Definicja l-wartości jako wyrażenia, które może znaleźć się po lewej stronie operatora przypisania (czyli może też po prawej) nie ma zastosowania w C++. W poniższym przykładzie nie możemy użyć l-wartości po lewej stronie operatora przypisania (a niby powinniśmy móc zgodnie z definicją), bo jest ona stała:

int main()
{
  const int i = 1;

  &i; // Expression "i" is an lvalue.
  // &2; // Expression "2" is an rvalue.

  // i = 2; // Error, even though "i" is an lvalue.
}

Operator przypisania dla typów całkowitych wymaga l-wartości po lewej stronie, więc nie możemy napisać 1 = 1. Oto bardziej rozbudowany przykład:

struct A
{
  int m_t[3];

  int
  operator[](unsigned i)
  {
    return m_t[i];
  }
};

int main()
{
  A a1;
  // The built-in assignment operator for integers expects an lvalue
  // on the left-hand size.  However, the overloaded operator[]
  // function returns a non-reference type, and so its call expression
  // is an rvalue.  That's why the following equivalent lines of code
  // do not compile.
  // a1[0] = 1;
  // a1.operator[](0) = 1;
}

R-wartość

Wyrażenie jest r-wartością, jeżeli nie jest l-wartością. Nie możemy pobrać adresu r-wartości.

Przykładami r-wartości są:

Definicja r-wartości jako wyrażenia, które nie może znaleźć się po lewej stronie operatora przypisania (czyli musi po prawej), nie ma zastosowania w C++. R-wartości możemy coś przypisać, jak pokazuje poniższy przykład. A() jest r-wartością (bo tworzy obiekt tymczasowy) i możemy mu przypisać 1, bo zdefiniowaliśmy taki operator przypisania w strukturze A:

int main()
{
  struct A
  {
    void
    operator = (int i)
    {
    }
  };

  A() = 1;
  A().operator=(1);
}

Konwersja standardowa z l-wartości na r-wartość

Standard C++ definiuje taką konwersję standardową: l-wartość może zostać niejawnie poddana konwersji do r-wartości. Niejawnie, czyli programista nie musi rzutować.

Na przykład, operator + dla typów całkowitych (np. int) wymaga r-wartości jako operandów. W poniższym przykładzie operator + wymaga r-wartości, więc l-wartości x i y są konwertowane niejawnie do r-wartości.

int main()
{
  int x = 1, y = 2;
  x + y;
}

Kolejny przykład dotyczy jednoargumentowego operatora * (czyli operatora wyłuskania), który wymaga r-wartości: adresu pamięci. Ale wyłuskać możemy też l-wartość, bo zostanie ona poddana konwersji standardowej:

int main()
{
  // The dereference operator requires an rvalue.  The null pointer
  // literal static_cast<int *>(0) is an rvalue.
  *static_cast<int *>(0);

  int x = 1;
  int *p = &x;
  *p; // OK: "p" is an lvalue, but converted to an rvalue.
}

Nie ma niejawnej konwersji z r-wartości na l-wartość. Na przykład, operator pobrania adresu (czyli jednoargumentowy operator &) wymaga l-wartości. Jeżeli przekażemy mu r-wartość, to nie będzie ona poddana niejawnej konwersji do l-wartości, jak pokazuje przykład niżej:

int main()
{
  // static_cast<int *>(0) and nullptr are null-value literals of a
  // pointer type.  They both are rvalues.

  // &static_cast<int *>(0); // Error: lvalue required.

  // &nullptr; // Error: lvalue required.
}

Przykład z operatorem inkrementacji

Operator inkrementacji (czyli ++) dla typów całkowitych wymaga l-wartości jako operandu. Wymóg ten dotyczy wersji prefiksowej i sufiksowej operatora. To samo dotyczy operatora dekrementacji.

int main()
{
  int x = 1;
  ++x; // The prefix version of the increment operator.
  x++; // The suffix version of the increment operator.
  // ++1; // Error: lvalue needed, no rvalue to lvalue conversion.
  // 1++; // Error: lvalue needed, no rvalue to lvalue conversion.
}

Wyrażenie operatora inkrementacji dla typów wbudowanych jest:

Dlatego ++++x kompiluje się, a x++++ nie.

int main()
{
  int x = 1;
  ++++x; // OK: ++x is an lvalue, and ++ wants an lvalue.
  // x++++; // Error: x++ is an rvalue, and ++ wants an lvalue.
}

Tak przy okazji:

Przykład poniżej pokazuje implementację sufiksowego operatora inkrementacji dla std::string. Pętla z prefiksowym operatorem byłaby bardziej skomplikowana.

#include <algorithm>
#include <iostream>
#include <string>

using namespace std;

// We have to define the function as non-member, because we cannot
// modify type std::string.
string
operator++(string &s, int)
{
  string tmp = s;
  next_permutation(s.begin(), s.end());
  return tmp;
}

int main()
{
  cout << "Permutations for abc:" << endl;
  for(string i = "abc"; i++ != "cba";)
    cout << i << endl;
}

Dana tymczasowa

Podczas opracowywania wyrażenia może być tworzona dana tymczasowa (ang. a temporary), która jest później niszczona automatycznie (czyli nie musimy jej jawnie niszczyć), kiedy nie jest już potrzebna. Data tymczasowa to wartość typu podstawowego (np. int), lub obiekt.

Dana tymczasowa jest potrzebna, kiedy:

Wyrażenie tworzące daną tymczasową jest r-wartością. Na przykład, jeżeli A jest typem klasowym, to A() tworzy obiekt tymczasowy, a wyrażenie to jest r-wartością.

Dana tymczasowa czasem jest błędnie określana r-wartością, a przecież dana tymczasowa nie jest wyrażeniem, więc nie ma kategorii i nie możemy mówić o niej jako o l-wartości czy r-wartości.

Ta sama dana tymczasowa może być użyta w l-wartości, albo r-wartości, kiedy, na przykład:

Dana tymczasowa jako argument funkcji

Dana tymczasowa może być argumentem wywołania funkcji. Jeżeli funkcja przyjmuje argument przez referencję stałą (czyli parametr funkcji jest typu referencyjnego na daną stałą), to parametr będzie aliasem danej tymczasowej.

I tu zwrot akcji: dana tymczasowa została stworzona w r-wartości, a wyrażenie odwołujące się do niej przez nazwę (referencję) to już l-wartość. L-wartość i r-wartość odnoszą się do wyrażeń, a dana tymczasowa była i jest bez kategorii.

Omówiony wyżej przypadek prezentuje poniższy przykład. Konstruktor wypisuje adres tworzonego obiektu, żebyśmy mogli się upewnić, że to ten sam obiekt w funkcji foo.

#include <iostream>

struct A
{
  A()
  {
    std::cout << "ctor: " << this << std::endl;
  }
};

// "a" is a parameter of a const reference type.
void
foo(const A &a)
{
  // "a" is an lvalue.
  std::cout << "foo: " << &a << std::endl;
}

int main()
{
  // "A()" is an rvalue.
  foo(A());
}

Dana tymczasowa jako wyjątek

Wyrażenie z daną tymczasową (czyli r-wartość) może być argumentem rzucania wyjątku. Jeżeli blok obsługi wyjątku przechwyci wyjątek przez referencję stałą, to parametr bloku będzie aliasem danej tymczasowej.

Podobny zwrot akcji: dana tymczasowa została stworzona w r-wartości, a wyrażenie odwołujące się do niej przez nazwę (referencję) to już l-wartość.

Omówiony wyżej przypadek prezentuje poniższy przykład. Konstruktor wypisuje adres tworzonego obiektu, żebyśmy mogli się upewnić, że to ten sam obiekt w bloku obsługi wyjątku.

#include <iostream>

int main()
{
  struct A
  {
    A()
    {
      std::cout << "ctor: " << this << std::endl;
    }
  };

  try
    {
      // "A()" is an rvalue.
      throw A();
    }
  // Catch the exception by reference.  It's a non-const reference!
  catch (A &a)
    {
      // "a" is an lvalue.
      std::cout << "catch: " << &a << std::endl;
    }
}

Powinniśmy obsługiwać wyjątki przez referencję, bo jeżeli będziemy obsługiwać przez wartość, to wyjątek będzie kopiowany. Proszę zmienić przykład wyżej, żeby obsługiwać wyjątek przez wartość: wyjątek będzie kopiowany i zobaczymy różne adresy.

Co ciekawe, w powyższym przykładzie obsłużyliśmy wyjątek przez niestałą referencję! C++98 mówi, że tylko stałą referencję można zainicjalizować r-wartością, czyli niestałej referencji już nie. Ale jakoś ta zasada nie dotyczy obsługi wyjątków. Spodziewałbym się, że w przykładzie wyżej catch(A &a) nie będzie się kompilowało, i że będzie trzeba napisać catch(const A &a). A jednak.

Blok instrukcji (czyli {<instrukcje>}) z jedną instrukcją możemy zamienić na tę jedną instrukcję, co jest wygodne w pisaniu pętli. Na przykład, możemy zamienić {++i;} na ++i;. Jednak bloki przechwytywania wyjątku (try) i obsługi wyjątku (catch) ciągle muszą być blokami i nie można usunąć {}, nawet jeżeli zawierają jedną instrukcję. Taka nieścisłość.

Przeciążanie funkcji składowych

Funkcja składowa może być wywołana dla l-wartości lub r-wartości. Możemy jednak zadeklarować funkcję z kwalifikatorem referencji & albo &&, żeby można ją było wywołać albo dla l-wartości, albo r-wartości. Na przykład:

int main()
{
  struct A
  {
    void operator = (int) &
    {
    }

    void operator = (int) && = delete;
  };

  A a;
  a = 1;

  // Does not compile, because the overload declared deleted.
  // A() = 1;
}

Funkcje a kategorie wyrażeń

Funkcja foo (np. void foo();) może być użyta w wyrażeniu na dwa sposoby:

To jest przykład wyrażenia wywołania funkcji, które jest l-wartością, bo zwracana wartość jest typu referencyjnego:

int &
loo()
{
  // FYI: It compiles even if we remove the return statement below!
  return *static_cast<int *>(0);
}

int main()
{
  &loo(); // OK: "loo()" is an lvalue.
  int &l = loo(); // OK: "loo()" is an lvalue.
}

To jest przykład wyrażenia wywołania funkcji, które jest r-wartością, bo zwracana wartość nie jest typu referencyjnego:

int
roo()
{
  return 0;
}

int main()
{
   // &roo(); // Error: "roo()" is an rvalue.
   // int &r = roo(); // Error: "roo()" is an rvalue.
}

Typy niekompletne a kategorie wyrażeń

Typ niekompletny to taki, którego obiektów nie jesteśmy w stanie stworzyć, bo:

Wyrażenia typów niekompletnych mogą być tylko l-wartością (czyli nie mogą być r-wartością).

W przykładzie niżej używamy typu, który nie został zdefiniowany:

class B;

B &
boo()
{
  return *static_cast<B *>(0);
}

int main()
{
  &boo(); // OK: "boo()" is an lvalue.
  // B(); // Error: expression "B()" is an rvalue.
}

Podsumowanie

Quiz