L-wartości i R-wartości w C++
Ta lekcja rozdziela trzy pojęcia: wartość obiektu, kategorię wyrażenia i referencję. Ma to znaczenie przy przypisywaniu do zmiennych, przekazywaniu argumentów funkcjom i przenoszeniu obiektów.
Wartość obiektu a kategoria wyrażenia
Zmienna x może przechowywać liczbę 10. To jest wartość zmiennej. Wyrażenie x ma natomiast kategorię wartości, która mówi, jak można użyć tego wyrażenia.
- L-wartość (lvalue) oznacza konkretny obiekt.
- R-wartość (rvalue) zwykle dostarcza wynik obliczenia albo wartość tymczasową.
int x = 10; // x oznacza istniejący obiekt
x = 20; // zmieniamy wartość tego obiektu
int y = x + 5; // x + 5 jest wynikiem obliczenia: 25
Po tych instrukcjach x przechowuje 20, a y przechowuje 25. Wyrażenie x oznacza obiekt, w którym przechowywane jest x, natomiast x + 5 daje nowy wynik. Dlatego x jest L-wartością, a x + 5 jest R-wartością.
Nazwa może mylić: L-wartość nie oznacza po prostu wyrażenia po lewej stronie =. Stała także jest L-wartością, choć nie można jej zmienić:
const int limit = 100;
// limit = 200; // błąd: obiekt jest stały
Kategoria wyrażenia i możliwość modyfikacji to dwie różne sprawy. O zapisie do obiektu decyduje również jego typ, na przykład kwalifikator const.
L-wartości: wyrażenia oznaczające obiekt
Zmienna, element tablicy i dereferencja poprawnego wskaźnika są typowymi L-wartościami:
int main() {
int x = 10;
int numbers[3] = {4, 5, 6};
int* pointer = &x;
x = 11; // x oznacza obiekt x
numbers[1] = 50; // numbers[1] oznacza drugi element tablicy
*pointer = 12; // *pointer oznacza obiekt x
// x ma teraz 12, a numbers[1] ma 50
}
Operator & pobiera adres obiektu. Operator * użyty przed wskaźnikiem daje dostęp do obiektu pod zapisanym adresem. Ponieważ pointer przechowuje adres x, wyrażenia *pointer i x odnoszą się do tego samego obiektu. Wskaźniki omówimy dokładniej w następnej lekcji.
Prefiksowy operator zwiększania także daje dostęp do obiektu, który zmienia:
int x = 1;
++x = 10; // poprawne, ale nieczytelne: ++x oznacza nadal obiekt x
W zwykłym kodzie lepiej napisać po prostu x = 10. Ten przykład pokazuje, że nazwa „L-wartość” nie jest tylko opisem lewej strony przypisania.
R-wartości: wyniki i wartości tymczasowe
Literały i wyniki zwykłych działań arytmetycznych są typowymi R-wartościami:
int x = 5;
int a = 42; // 42 jest R-wartością
int b = x + 1; // x + 1 jest R-wartością
// (x + 1) = 10; // błąd: wyniku obliczenia nie można tak zmienić
// int* p = &(x + 1); // błąd: nie można pobrać adresu takiego wyniku
Funkcja zwracająca wynik przez wartość również zwykle daje R-wartość:
int add(int left, int right) {
return left + right;
}
int main() {
int result = add(2, 3); // wywołanie dostarcza wynik 5
}
To jest użyteczna intuicja, ale nie cała reguła C++. W dokładniejszym podziale R-wartości obejmują prvalue oraz xvalue. Nie każda R-wartość jest więc „wartością bez obiektu”: na przykład std::move(x) odnosi się do istniejącego x, ale pozwala wybrać operację przenoszącą. Na początek zapamiętaj typowe przykłady: nazwa zmiennej x jest L-wartością, a 42 i x + 1 są R-wartościami.
Referencja jest aliasem obiektu
Referencja to druga nazwa istniejącego obiektu. Nie tworzy kopii:
int main() {
int original = 5;
int& alias = original; // alias odnosi się do tego samego obiektu
alias = 8; // zmienia original
// original ma teraz 8
}
Referencję trzeba powiązać z obiektem przy deklaracji. Nie można później przepiąć jej na inny obiekt. Wskaźnik jest inny: to osobna zmienna przechowująca adres i może mieć wartość nullptr. Porównanie referencji ze wskaźnikiem znajduje się w następnej lekcji.
Parametry funkcji: kopia czy ten sam obiekt?
Przekazanie przez wartość: T
Parametr T value jest osobną zmienną funkcji. Zmiana parametru nie zmienia argumentu wywołującego:
#include <iostream>
void setToZero(int value) {
value = 0; // zmieniamy lokalną kopię
}
int main() {
int score = 7;
setToZero(score);
std::cout << score << '\n'; // 7
}
Funkcja może przyjąć L-wartość i R-wartość, bo w obu przypadkach tworzy własny parametr. Dla małych typów, takich jak int, przekazanie przez wartość jest zwykle dobrym wyborem.
Niestała referencja: T&
Parametr T& wiąże się z L-wartością. Funkcja może zmienić ten sam obiekt, który przekazał wywołujący:
#include <iostream>
void addOne(int& value) {
++value; // value jest inną nazwą argumentu
}
int main() {
int score = 7;
addOne(score);
std::cout << score << '\n'; // 8
// addOne(7); // błąd: int& nie wiąże się z R-wartością
}
Użyj T&, gdy zmiana argumentu jest częścią zadania funkcji. Nie używaj go tylko po to, by uniknąć kopiowania małej liczby.
Referencja do stałej: const T&
const T& pozwala czytać obiekt bez tworzenia kopii i nie pozwala zmieniać go przez tę referencję. Może też przyjąć wartość tymczasową:
#include <iostream>
#include <string>
void print(const std::string& text) {
std::cout << text << '\n';
}
int main() {
std::string message = "Cześć";
print(message); // referencja do istniejącego obiektu
print(std::string("Witaj")); // obiekt tymczasowy żyje przez wywołanie
}
Tymczasowy argument do parametru funkcji istnieje do końca wyrażenia zawierającego wywołanie. Nie zachowuj referencji ani wskaźnika do takiego argumentu na później: po zakończeniu wywołania obiekt może już nie istnieć.
Referencja R-wartości: T&&
Parametr T&& może wiązać się z R-wartością. Przydaje się między innymi w funkcjach i konstruktorach przenoszących:
#include <iostream>
#include <utility>
void acceptTemporary(int&& value) {
std::cout << value << '\n';
}
int main() {
int x = 10;
acceptTemporary(20); // 20 jest R-wartością
// acceptTemporary(x); // błąd: x jest L-wartością
acceptTemporary(std::move(x)); // jawnie traktujemy x jako R-wartość
}
Wewnątrz funkcji parametr value ma nazwę, więc samo wyrażenie value jest L-wartością — mimo że jego typ to int&&. To ważna reguła przy implementowaniu przenoszenia.
std::move i przenoszenie
std::move z nagłówka <utility> samo nie przenosi danych. Pozwala potraktować obiekt jako R-wartość. Dopiero konstruktor lub operator przypisania typu obiektu może wykonać przeniesienie.
#include <iostream>
#include <string>
#include <utility>
struct Message {
std::string text;
explicit Message(std::string value)
: text(std::move(value)) {}
Message(const Message& other)
: text(other.text) {
std::cout << "kopiowanie\n";
}
Message(Message&& other)
: text(std::move(other.text)) {
std::cout << "przenoszenie\n";
}
};
int main() {
Message first("długi tekst");
Message copy = first; // kopiuje napis
Message moved = std::move(first); // wybiera konstruktor przenoszący
std::cout << copy.text << '\n';
std::cout << moved.text << '\n';
}
Przy copy powstaje niezależna kopia first.text. Przy moved wyrażenie std::move(first) pozwala wybrać konstruktor Message(Message&&), który przekazuje napis do przenoszącego konstruktora std::string. Szczegóły przejęcia zasobu zależą od implementacji std::string.
Po przeniesieniu first nadal jest poprawnym obiektem: można go zniszczyć albo przypisać mu nową wartość. Jego poprzednia zawartość może się zmienić; dla std::string jest poprawna, ale nieokreślona przez ogólną regułę języka. Nie zakładaj, że zawsze będzie pusty. Przenoszenie jest przydatne głównie dla obiektów zarządzających zasobami, takich jak napisy i wektory; dla int zwykle niczego nie zyskujemy.
Jak wybrać parametr
| Zapis | Co otrzymuje funkcja | Typowe zastosowanie |
|---|---|---|
T value |
własną kopię | małe wartości |
T& value |
dostęp do zmiennego obiektu wywołującego | funkcja ma zmienić argument |
const T& value |
dostęp tylko do odczytu, także do wartości tymczasowej | odczyt większego obiektu bez kopii |
T&& value |
dostęp do R-wartości | implementacja przenoszenia lub API przyjmujące tymczasowe obiekty |
Najważniejsze wnioski
- Kategoria wyrażenia i możliwość modyfikacji obiektu to odrębne sprawy: L-wartość może być stała.
x,tab[0]i*pointerto typowe L-wartości;42,x + 1i wynik funkcji zwracany przez wartość to typowe R-wartości.- Referencja jest aliasem, a nie kopią ani wskaźnikiem.
- Nazwany parametr jest L-wartością wewnątrz funkcji, także gdy zadeklarowano go jako
T&&. std::move(x)nie przenosi samo z siebie. Umożliwia wybranie operacji przenoszącej, która może zmienić stanx.