C++ 참조자, 값 범주, 이동 의미론, 전달 참조
참조자가 객체가 아닌 이유와 값 범주, 바인딩 규칙과 임시 객체 수명 연장, 이동 의미론, 전달 참조와 참조 축약을 정리했습니다.
1. 참조자는 객체가 아니다.
참조자는 왜 처음 바인딩한 객체 말고 다른 객체를 가리킬 수 없을까? 결론부터 말하면, 참조자는 객체가 아니라 이미 있는 객체에 붙인 또 하나의 이름이기 때문이다. 이름은 그 자체로 바꿀 수 있는 무언가가 아니므로, 재바인딩을 표현할 문법이 언어에 아예 없다. 그리고 이렇게 설계한 이유는 연산자 오버로딩 때문이다.
참조자는 객체의 이름이다.
객체는 크기, 수명, 저장 공간을 가진 실체다. 표준은 참조자를 이렇게 설명한다.
참조는 객체의 이름으로 생각할 수 있다. 참조가 저장 공간을 필요로 하는지 여부는 명시되어 있지 않다. ([dcl.ref])
1
2
int x = 10;
int& r = x;
여기서 객체는 x 하나뿐이다. r은 새 객체가 아니라 그 객체를 부르는 두 번째 이름이다.
이걸 문법 차원에서 보강하는 규칙이 [expr.type]에 있다. 참조 타입의 표현식은 이후의 어떤 분석보다 먼저 참조가 아닌 타입으로 조정된다. 즉 표현식으로 쓰인 r은 분석이 시작되기도 전에 x가 된다. 참조자 자체를 가리키는 표현식은 존재하지 않는다.
여기서 따라 나오는 제약
초기화가 필수다.
붙일 대상이 없는 이름은 말이 안 된다.
int* p;는 값이 불확정이어도 포인터 객체는 존재하지만,int& r;은 아무것도 아니다.재바인딩할 수 없다.
r = y에서 r은 이미 x이므로 이건x = y, 즉 x에 대한 대입이다.자기 주소가 없다.
&r은&x이다.자기 크기가 없다.
sizeof(r)은sizeof(int)다. 표준이 참조 대상의 크기를 돌려주도록 정해 두었다.참조의 참조, 참조의 배열, 참조의 포인터를 만들 수 없다.
셋 다 참조자 자체를 다룰 수 있어야 성립하는데, 그런 표현식이 없다. 배열은
arr[i]가*(arr + i)로 정의되어 원소의 포인터가 필요하므로 같은 이유로 불가능하다.cv 한정자를 붙일 수 없다.
int& const r = x;는 ill-formed(표준 위반, 즉 컴파일 에러)다. const는 바꿀 수 있는 것을 못 바꾸게 하는 한정자인데, 참조자는 원래 바꿀 수 없으니 붙을 자리가 없다.
cv 한정자에는 예외가 하나 있다. typedef나 템플릿을 거쳐 간접적으로 참조자에 붙으면 에러 대신 무시된다.
1
2
3
typedef int& IntRef;
const IntRef r = x; // const int&가 아니라 int& const가 되고 const는 무시되어 int&
r = 20; // 컴파일 되고 x가 20으로 바뀐다.
IntRef는 int&를 통째로 가리키는 이름이라 const가 int가 아니라 참조자에 붙는다.
포인터와 비교
| 포인터 | 참조자 | |
|---|---|---|
| 객체인가 | 예 (주소값을 담는 객체) | 아니오 (이름) |
| 저장 공간 | 반드시 있음 | 표준은 요구하지 않음 |
| 자기 주소 | 있음 (&p) | 없음 (&r은 대상의 주소) |
| null | 가능 | 불가능 (만드는 순간 정의되지 않은 동작) |
| 재지정 | p = &y; | 문법 없음 |
| 산술 | p + 1 (주소 이동) | 없음 (r + 1은 대상 값의 덧셈) |
| 초기화 없이 선언 | 가능 | 불가능 |
왜 이렇게 설계했을까
참조자는 연산자 오버로딩을 위해 도입되었다.
1
a[i] = x;
이 대입이 a의 원소를 바꾸려면 operator[]가 원소 자체를 돌려줘야 한다. 포인터를 돌려주면 *a[i] = x를 써야 하고, 값을 돌려주면 사본에 대입하게 된다. 그래서 참조자를 돌려준다. 이때 a[i] = x가 “참조자를 x로 재바인딩”하는 뜻이었다면 원소는 바뀌지 않는다. 참조자에 대한 대입은 항상 대상에 대한 대입이어야 했고, 그래서 재바인딩은 의도적으로 빠졌다고 한다. 재지정이 필요하면 포인터를 쓰라는 것이다.
컨테이너와 멤버
참조자가 객체가 아니라는 사실은 두 가지로 드러난다.
첫 번째, std::vector<int&>는 만들 수 없다. 컨테이너는 원소를 메모리에 할당하고 포인터로 다루는데, 참조자는 할당할 수도 가리킬 수도 없다. 참조를 담고 싶으면 std::reference_wrapper를 쓴다.
1
2
3
int a = 1, b = 2;
std::vector<std::reference_wrapper<int>> v { a, b };
v[0].get() = 10; // a가 10이 된다.
reference_wrapper는 내부에 포인터를 든 객체라서 컨테이너에 담을 수 있고, 대입하면 다른 객체로 재바인딩된다. 참조자가 못 하는 일을 하는 대신 참조자가 아니다.
두 번째, 참조 멤버가 있는 클래스는 복사 대입 연산자가 삭제된다.
1
2
3
4
5
struct Holder { int& r; };
Holder h1 { a }, h2 { b };
Holder h3 = h1; // 된다. 복사 생성은 새 참조자를 a에 바인딩하면 된다.
h1 = h2; // 에러. r을 b로 재바인딩할 수 없다.
복사 대입은 이미 있는 객체를 다른 객체와 같게 만드는 일인데, 참조 멤버는 재바인딩이 안 되니 컴파일러가 만들어 줄 방법이 없다. 그래서 대입이 필요한 클래스는 참조 멤버 대신 포인터나 reference_wrapper를 멤버로 둔다.
실제로는 어떻게 구현되나??
표준은 참조자가 저장 공간을 쓰는지 정하지 않았다. 실제 컴파일러는 이렇게 한다고 한다.
- 같은 함수 안의 지역 참조자는 보통 아무것도 남지 않는다. 컴파일러가 r을 x로 바꿔 놓기 때문이다.
- 참조 매개변수와 참조 멤버처럼 대상을 따로 기억해야 하면 주소를 저장한다.
sizeof(Holder)는 보통 포인터 크기다.
즉 구현은 보통 포인터와 같다. 차이는 언어 수준에 있다. 포인터는 주소를 값으로 다루는 객체이고, 참조자는 그 주소를 드러낼 문법이 없는 이름이다. 그래서 “참조자는 포인터인가?”라는 질문에는 “구현은 포인터와 같을 수 있지만, 언어에서는 객체가 아니라 이름이다”라고 말할 수 있다.
조금만 정리하자면, 참조자는 객체가 아니라 이미 있는 객체의 이름이다. 그래서 초기화가 필수이고, 재바인딩할 문법이 없고, 자기 주소와 크기가 없고, 컨테이너에 담을 수 없다. 재바인딩을 뺀 이유는 연산자 오버로딩에서 a[i] = x가 대상에 대한 대입이 되어야 했기 때문이다. 구현은 보통 포인터와 같지만 언어 수준에서는 별개다.
2. 값 범주
먼저 1장의 T&는 C++11부터 좌측값 참조라고 부르고, C++11은 우측값 참조 T&&를 새로 추가했다. 우측값 참조는 곧 사라질 임시 객체나 std::move를 거친 객체처럼, 이름 그대로 rvalue에만 바인딩된다.
1
2
3
4
std::string s = "abc";
std::string& r1 = s; // 좌측값 참조. 이름 있는 객체에 붙는다
std::string&& r2 = std::string(); // 우측값 참조. 임시 객체에 붙는다
std::string&& r3 = s; // 에러. s는 rvalue가 아니다
우측값 참조를 따로 둔 이유는 이동 의미론이다. “이 객체는 곧 사라지니 자원을 가져가도 된다”는 것을 타입으로 표현할 방법이 필요했다. 그러면 무엇이 rvalue일까?
값 범주는 변수나 객체나 타입의 성질이 아니라 표현식의 성질이다. 모든 표현식은 타입과 값 범주를 독립적으로 가진다.
1
2
3
4
int x = 10;
x; // 타입 int, lvalue
x + 1; // 타입 int, prvalue
std::move(x); // 타입 int, xvalue
세 줄 모두 x를 썼고 타입도 같은데 값 범주는 전부 다르다. std::move는 int&&를 반환하지만, 1장에서 본 대로 표현식의 참조 타입은 분석 전에 int로 조정되고, &&라는 정보는 xvalue라는 값 범주로 남는다.
왜 세 가지인가
C 시절에는 대입문의 왼쪽에 올 수 있는 lvalue와 오른쪽에만 올 수 있는 rvalue 둘뿐이었다. C++11이 이동 의미론을 넣으면서 컴파일러가 “이 표현식의 자원을 훔쳐도 되는가”를 판단해야 했는데, 둘로는 부족했다.
- 임시 객체 makeString()은 곧 사라지니 훔쳐도 된다.
- 변수 s는 나중에 또 쓰니 안 된다.
- std::move(s)는 s라는 특정 객체를 가리키면서도 훔쳐도 된다. 어느 쪽에도 맞지 않는다.
그래서 두 가지 질문으로 다시 나눴다. 이름은 남았지만 이제 대입문의 위치와는 관계가 없다.
- 정체성이 있는가? 특정 객체를 가리키는가
- 이동해도 되는가? 자원을 훔쳐 가도 안전한가
| 정체성 | 이동 가능 | 뜻 | |
|---|---|---|---|
| lvalue | O | X | 이름 있는 객체. 나중에 또 쓴다 |
| xvalue | O | O | 곧 소멸할(eXpiring) 객체. 훔쳐도 된다 |
| prvalue | X | O | 순수(pure) 값. 아직 객체가 아니다 |
정체성이 있는 쪽을 묶어 glvalue(lvalue + xvalue), 이동 가능한 쪽을 묶어 rvalue(xvalue + prvalue)라고 부른다. xvalue는 양쪽에 다 속한다.
1
2
3
4
5
표현식
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
무엇이 어디에 속하나
- lvalue : 변수 이름 x, *p, a[i], s.member, ++x, a = b, 문자열 리터럴 “abc”, T&를 반환하는 함수 호출
- xvalue : std::move(x), static_cast<T&&>(x), T&&를 반환하는 함수 호출, makeWidget().member
- prvalue : 42, true, x + y, x++, &x, this, 람다 식, T를 값으로 반환하는 함수 호출
직관과 다른 것이 있는데,
- ++x는 lvalue이고 x++은 prvalue이다. 전위는 x 자체를, 후위는 옛 값의 사본을 돌려준다.
- 문자열 리터럴 “abc”는 정적 저장 공간에 있는 배열이라 lvalue다. 42는 prvalue다.
- &x는 prvalue다. 주소라는 새 값을 계산한 결과일 뿐, 어딘가에 저장된 객체가 아니다.
함수 호출의 값 범주는 반환 타입이 정한다.
| 반환 타입 | 호출 표현식의 값 범주 |
|---|---|
T& | lvalue |
T&& | xvalue |
T (값) | prvalue |
vector의 operator[]가 T&를 반환해서 a[i]가 lvalue가 되고, 그래서 a[i] = x가 된다. std::move가 T&&를 반환해서 std::move(x)가 xvalue다.
이름이 붙은 우측값 참조는 lvalue이다.
우측값 참조 타입으로 선언한 변수 자체의 값 범주는 lvalue다.
1
2
3
4
5
6
7
8
void g(const Widget&); // 1
void g(Widget&&); // 2
void f(Widget&& w)
{
g(w); // 1. w의 타입은 Widget&&지만 표현식 w는 lvalue
g(std::move(w)); // 2. xvalue로 바꿔서 넘긴다
}
변수 이름을 쓴 표현식은 그 변수의 타입과 상관없이 lvalue다. “우측값 참조”는 rvalue에 바인딩되는 참조라는 뜻이지, 그 참조자를 쓴 표현식이 rvalue라는 뜻이 아니다.
이렇게 정한 이유는 안전이다. w는 함수 안에서 여러 번 쓰일 수 있는데, 첫 번째 사용에서 이동해 버리면 두 번째는 빈 껍데기를 받는다. 그래서 이름이 있으면 lvalue로 취급하고, 정말 훔쳐도 될 때만 std::move로 명시하게 했다. std::move의 존재 이유가 이 규칙이다. std::move는 아무것도 이동시키지 않는다. 내부는 static_cast<T&&>이고, 타입은 그대로 둔 채 값 범주만 xvalue로 바꾼다.
값 범주 확인하기
decltype((expr))처럼 괄호를 한 겹 더 씌우면 값 범주가 타입에 드러난다.
decltype((expr)) | 값 범주 |
|---|---|
T& | lvalue |
T&& | xvalue |
T | prvalue |
1
2
3
4
int x = 0;
static_assert(std::is_lvalue_reference_v<decltype((x))>); // lvalue
static_assert(std::is_rvalue_reference_v<decltype((std::move(x)))>); // xvalue
static_assert(!std::is_reference_v<decltype((x + 1))>); // prvalue
괄호 없는 decltype(x)는 표현식이 아니라 x의 선언을 보기 때문에 선언된 타입이 나온다. 빠르게 감을 잡을 때는 주소를 얻어 보면 된다. &x는 되고, &42, &(x + 1), &std::move(x)는 안 된다.
C++17 - prvalue는 아직 객체가 아니다.
C++17부터 prvalue는 객체가 아니라 “객체를 초기화하는 방법”으로 정의된다.
1
Widget w = Widget(); // 임시 객체 없이 w를 직접 초기화한다.
Widget()은 임시 객체를 만든 뒤 w로 옮기는 것이 아니라, w 자리에 바로 Widget을 만든다. 이걸 보장된 복사 생략(guaranteed copy elision)이라 한다. 그래서 복사 생성자와 이동 생성자가 둘 다 삭제된 타입도 이렇게 초기화할 수 있다.
prvalue를 객체처럼 써야 할 때는 그 자리에 임시 객체가 만들어진다. 대표적으로 참조자에 바인딩하거나 멤버에 접근할 때다. 이 변환을 임시 구체화(temporary materialization)라 하고, 결과는 xvalue다. makeWidget().member가 xvalue인 이유가 이것이다.
조금만 정리하자면, 값 범주는 표현식의 성질이고, 정체성이 있는가와 이동해도 되는가로 lvalue, xvalue, prvalue로 나뉜다. 이름이 붙은 우측값 참조도 lvalue라서 이동하려면 std::move가 필요하다. std::move는 이동이 아니라 값 범주를 xvalue로 바꾸는 캐스트다. C++17부터 prvalue는 객체가 아니라 초기화 방법이고, 필요할 때 임시 구체화로 객체가 된다.
3. 세 가지 참조자와 바인딩 규칙
C++11 이후 참조자는 사실상 세 가지 형태로 쓰인다. 2장의 값 범주를 세로축에 놓으면 어떤 참조자가 무엇을 받는지 표 하나로 정리된다. xvalue와 prvalue는 결과가 같아서 rvalue 하나로 묶었다.
| lvalue | rvalue | 이유 | |
|---|---|---|---|
T& | O | X | 곧 사라질 객체를 수정해 봐야 결과가 남지 않는다. 실수이므로 막는다 |
const T& | O | O | 수정하지 않으니 임시 객체에 붙어도 안전하다 |
T&& | X | O | 자원을 훔쳐도 된다는 신호다. 나중에 또 쓸 lvalue가 몰래 비워지면 안 된다 |
1
2
3
4
5
6
7
8
9
void increment(int& n);
increment(5); // 에러. 5를 증가시켜서 어디에 쓰나
void print(const std::string& s);
print("hello"); // 된다. 임시 string을 만들어 바인딩
std::string s = "abc";
std::string&& r1 = s; // 에러. s는 lvalue
std::string&& r2 = std::move(s); // 된다. 훔쳐도 된다고 명시했다
이 표에서 눈여겨볼 칸은 const T&의 rvalue 칸이다. 이동 의미론이 없던 C++03에서는 이것이 임시 객체를 복사 없이 함수에 넘기는 유일한 방법이었다. 사용자 정의 타입을 const T&로 받는 관례가 여기서 나왔다.
const T&&도 문법적으로는 있지만, “훔쳐 가겠다”와 “수정하지 않겠다”가 충돌해서 거의 쓰지 않는다.
오버로드가 여럿이라면??
const T&와 T&& 둘 다 rvalue를 받을 수 있으니, 둘 다 있으면 어느 쪽이 선택되는지 정해야 한다.
1
2
3
4
5
6
7
void f(const Widget&); // 1
void f(Widget&&); // 2
Widget w;
f(w); // 1. lvalue는 2에 붙지 못한다
f(Widget()); // 2
f(std::move(w)); // 2
표준은 rvalue를 rvalue 참조에 바인딩하는 쪽을 더 좋은 변환으로 친다. 그래서 rvalue는 2번으로 가고, 2번이 없을 때만 1번이 대신 받는다. const T&는 대체재이고, 둘 다 있으면 T&&가 우선이다.
복사 생성자 T(const T&)와 이동 생성자 T(T&&)도 같은 규칙을 따른다. 그래서 Widget w2 = std::move(w);는 이동 생성자를 부른다. 단, 2장에서 본 대로 Widget w = Widget();처럼 같은 타입의 prvalue로 초기화하면 생성자 호출 자체가 없다.
타입이 다르면 임시 객체가 생긴다
1
2
3
4
5
double d = 3.14;
int& r1 = d; // 에러. 타입 int에 대한 비 const 좌측값 참조 'r1'은(는) 타입 double의 좌측값에 바인드할 수 없습니다 라고 나온다.
const int& r2 = d; // 된다. int 임시 객체 3을 만들어 바인딩
int&& r3 = d; // 된다. 마찬가지
r1을 허용하면 r1은 d가 아니라 컴파일러가 몰래 만든 임시 int에 붙는다. 이게 무슨 말이냐면,
1
2
3
4
5
6
double d = 3.14;
int& r1 = d;
// 컴파일러가 실제로 하는 일
int _temp = static_cast<int>(d); // 3을 담은 임시 int를 만든다
int& r1 = _temp; // 그 임시 객체에 바인딩한다
참조자는 자기 타입의 객체에만 붙을 수 있다. 즉, int&는 int를 가리켜야 하는데, d는 double이므로 가리킬 수 없다. 그래서 컴파일러는 위 코드처럼 임시 int를 만들어 r1에 바인딩한다. 그런데 r1은 실제로 d를 가리키는 게 아니라 임시 int를 가리키므로 r1 = 5가 d를 바꾸지 않는 함정이 된다.
r2는 수정할 수 없으니 상관없고, r3는 처음부터 임시 객체를 받겠다고 선언한 것이라 허용된다. const std::string& s = "abc";가 되는 것도 같은 원리다. const T&와 T&&는 필요하면 임시 객체를 만들어서라도 바인딩하고, T&는 절대 그러지 않는다.
임시 객체의 수명 연장
임시 객체는 원래 완전식(full-expression)이 끝날 때, 쉽게 말해 세미콜론에서 소멸한다.
1
const char* p = makeName().c_str(); // 세미콜론에서 임시 string이 소멸한다. p는 무효
그런데 참조자에 직접 바인딩된 임시 객체는 그 참조자가 살아 있는 동안 함께 산다.
1
const std::string& s = makeName(); // 임시 string이 s의 수명만큼 연장된다.
2장의 말로 하면, prvalue인 makeName()이 참조자에 바인딩되면서 임시 구체화되고, 그 객체의 수명이 참조자에 맞춰지는 것이다. 이 규칙에는 예외가 많아서 정확히 알아야 한다.
| 상황 | 연장 여부 |
|---|---|
| 지역 참조자를 임시 객체로 직접 초기화 | 연장됨 |
임시 객체의 멤버에 바인딩 (const auto& r = getWidget().name;) | 연장됨. 임시 객체 전체가 산다 |
| 함수의 참조 매개변수에 바인딩 | 안 됨. 호출이 든 완전식이 끝나면 소멸 |
| return 문에서 반환 참조자에 바인딩 | 안 됨. 무효 참조를 반환 |
| 생성자 초기화 리스트에서 참조 멤버에 바인딩 | ill-formed |
| 함수가 반환한 참조자를 거쳐 바인딩 | 안 됨 |
가장 자주 사고가 나는 것은 마지막 줄이다.
1
2
3
4
5
6
7
8
9
10
struct Widget
{
const std::string& getName() const { return name; }
std::string name;
};
Widget getWidget();
const std::string& a = getWidget().name; // 된다. 임시 Widget 전체가 연장된다.
const std::string& b = getWidget().getName(); // 무효 참조. 임시 Widget은 세미콜론에서 소멸한다.
a는 임시 객체의 멤버에 직접 바인딩해서 연장된다. b는 임시 객체가 아니라 getName()이 돌려준 참조자에 바인딩한다. 컴파일러는 그 참조자가 어디를 가리키는지 알 수 없으니 연장할 대상을 정할 수 없고, 그래서 연장하지 않는다.
그럼 왜 함수가 반환한 참조자에 바인딩한 임시 객체는 컴파일러가 수명을 연장하지 않을까? 수명 연장은 실행 중에 판단하는 게 아니라 컴파일러가 그 선언문을 보는 순간 정해야 하는 규칙이다. “이 임시 객체는 세미콜론에서 지우지 말고 b의 범위가 끝날 때 지운다”는 코드를 그 자리에서 만들어야 한다. 하지만 b를 선언하는 자리에서 컴파일러가 아는 것은 getName()의 선언 뿐이다. 같은 선언이라도 본문에 따라 돌려주는 대상이 완전히 다를 수 있다. 예를 들어
1
2
3
const std::string& getName() const { return name; } // 임시 Widget 안의 멤버
const std::string& getName() const { static std::string s = "x"; return s; } // 정적 변수
const std::string& getName() const { return globalName; } // 전역 변수
첫 번째라면 Widget을 살려야 하고, 두 번째와 세 번째라면 Widget을 살릴 필요가 없다. 함수 본문이 다른 .cpp 파일에 있으면 컴파일러는 그 본문을 아예 볼 수 없다. 본문이 보여도 수명 연장은 언어 규칙이라 어떤 컴파일러에서도 똑같이 동작해야 하므로 연장하지 않는다.
같은 이유로 const int& m = std::min(x, y + 1);도 위험하다. std::min은 인자 중 하나를 참조로 돌려주는데, y + 1이 더 작으면 m은 세미콜론에서 소멸한 임시 int를 가리킨다. 값에 따라 멀쩡해 보이기도 해서 찾기 어렵다.
참고로 C++23부터 범위 기반 for의 초기화 식에 있는 임시 객체는 루프가 끝날 때까지 연장된다. for (auto& e : getWidget().getItems())가 C++20까지는 무효 참조였던 이유가 바로 위의 마지막 규칙이다. 그리고 람다의 [&] 캡처도 참조자일 뿐이라 수명을 늘려 주지 않는다. 지역 변수를 참조로 캡처한 람다를 함수 밖으로 돌려주면 무효 참조가 된다.
정리해보자면, T&는 lvalue만, T&&는 rvalue만, const T&는 둘 다 받는다. 앞의 둘이 거부하는 건 안전 때문이고, const T&가 rvalue를 받는 건 이동 의미론 이전에 임시 객체를 복사 없이 넘기기 위해서였다. 둘 다 있으면 rvalue는 T&&로 간다. 임시 객체는 참조자에 직접 바인딩되면 참조자만큼 살지만, 함수 매개변수나 함수가 반환한 참조자를 거치면 연장되지 않는다.
4. 우측값 참조와 이동 의미론
이동 의미론(Move Semantics)은 “곧 사라질 객체의 자원은 복사하지 말고 가져오자”는 생각이다. 3장의 바인딩 규칙이 그 판단을 맡는다. rvalue는 T&&에 붙으니, T&&를 받는 생성자를 따로 두면 rvalue가 들어올 때만 그쪽이 선택된다.
복사와 이동
힙 메모리를 소유하는 클래스로 보면 차이가 분명하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Buffer
{
public:
explicit Buffer(size_t n) : data(new int[n]), size(n) {}
~Buffer() { delete[] data; }
// 복사: 새로 할당하고 내용을 전부 복사한다
Buffer(const Buffer& other) : data(new int[other.size]), size(other.size)
{
std::copy(other.data, other.data + size, data);
}
// 이동: 포인터만 가져오고 원본을 비운다.
Buffer(Buffer&& other) noexcept : data(std::exchange(other.data, nullptr)), size(std::exchange(other.size, 0)) {}
private:
int* data;
size_t size;
};
참고로 std::exchange(a, b)는 a를 돌려주고 a를 b로 바꾸는 함수다.
복사는 원소가 백만 개면 백만 개를 복사한다. 이동은 원소 개수와 상관없이 포인터 하나와 정수 하나를 옮긴다. 원본의 포인터를 nullptr로 바꿔 두는 이유는 원본의 소멸자가 가져간 메모리를 해제하지 않게 하기 위해서다.
1
2
3
Buffer a(1000000);
Buffer b = a; // 복사 생성자. a는 나중에 또 쓸 수 있는 lvalue
Buffer c = std::move(a); // 이동 생성자. a를 더 쓰지 않겠다고 명시했다
이동 생성자가 없는 클래스에 rvalue를 넘기면 3장의 규칙대로 const T&를 받는 복사 생성자가 대신 받는다. 그래서 이동은 “되면 빠르고, 안 되면 복사로 돌아가는” 최적화다. 컴파일 에러가 나지 않으니 이동이 안 되고 있어도 알아채기 어렵다.
std::move는 아무것도 이동시키지 않는다.
2장에서 본 대로 std::move는 static_cast<T&&>이고, 값 범주를 xvalue로 바꿀 뿐이다. 실제로 자원을 옮기는 것은 그 xvalue를 받은 이동 생성자나 이동 대입 연산자다. 이 사실에서 함정 두 가지가 나온다.
첫 번째, const 객체에 std::move를 쓰면 복사된다.
1
2
const Buffer a(100);
Buffer b = std::move(a); // 복사 생성자가 불린다
std::move(a)의 타입은 const Buffer&&다. 이동 생성자 Buffer(Buffer&&)는 const를 받을 수 없으니, const Buffer&를 받는 복사 생성자가 선택된다. 훔치려면 원본을 수정해야 하는데 const라서 불가능한 것이다.
두 번째, 지역 변수를 반환할 때 std::move를 쓰면 오히려 손해다.
1
2
3
4
5
6
7
8
9
10
11
12
Buffer make()
{
// 복사도 이동도 없을 수 있다
Buffer local(100);
return local;
}
Buffer make()
{
// 이동 생성자가 반드시 불린다
Buffer local(100);
return std::move(local);
}
return local;이면 컴파일러가 local을 반환값 자리에 바로 만들어 복사와 이동을 모두 없앨 수 있다(NRVO). 없애지 못하더라도 지역 변수를 반환할 때는 자동으로 rvalue로 취급되어 이동된다. 그런데 std::move를 쓰면 반환식이 더 이상 지역 변수의 이름이 아니게 되어 이 생략이 막히고, 이동 생성자가 반드시 한 번 불린다.
이동된 객체의 상태
이동하고 남은 원본(moved-from 객체)은 어떤 상태일까? 표준 라이브러리 타입은 유효하지만 지정되지 않은 상태 (valid but unspecified)로 남는다. 소멸시키거나 새 값을 대입하는 것은 안전하지만, 안에 무슨 값이 남았는지에 기대면 안 된다.
1
2
3
4
5
std::string s = "hello";
std::string t = std::move(s);
s = "world"; // 새 값 대입은 안전
s.size(); // 호출은 안전하지만 값은 보장되지 않는다. 구현에 따라 보통 0
예외로 std::unique_ptr처럼 이동 후 상태가 명시된 타입도 있다. 이동된 unique_ptr은 반드시 null이다. 직접 만드는 클래스도 최소한 소멸과 대입이 안전하게 해 둬야 한다. Buffer가 원본의 포인터를 nullptr로 바꿔 둔 것이 그 이유다.
noexcept가 성능을 바꾼다
이동 생성자에 noexcept를 붙이는 것은 이유가 있다.
1
2
std::vector<Buffer> v;
v.push_back(Buffer(100)); // 용량이 차면 더 큰 메모리로 원소를 전부 옮긴다.
vector는 용량이 차면 새 메모리를 할당하고 기존 원소를 옮긴다. 이때 vector는 “옮기다가 예외가 나도 원래 상태로 돌아갈 수 있다”는 보장(강한 예외 보장)을 지켜야 한다. 복사는 중간에 실패해도 원본이 그대로 남지만, 이동은 이미 옮긴 원소가 비어 있어서 되돌릴 수 없다. 그래서 vector는 이동 생성자가 noexcept일 때만 이동하고, 아니면 복사한다 (std::move_if_noexcept).
원소 4개를 가진 vector의 용량을 늘려 보면 차이가 그대로 드러난다.
| 이동 생성자 | 재할당 때 일어나는 일 |
|---|---|
| noexcept 없음 | 복사 4번 |
| noexcept 있음 | 이동 4번 |
noexcept를 빼먹으면 이동 생성자를 공들여 만들어도 vector는 쓰지 않는다.
Rule of 0, 3, 5
컴파일러는 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자를 필요하면 자동으로 만들어 준다. 문제는 이 중 일부를 직접 선언하면 나머지 자동 생성이 바뀐다는 점이다.
- Rule of 3 (C++03) : 소멸자, 복사 생성자, 복사 대입 중 하나를 직접 정의해야 한다면 셋 다 정의해야 한다. 하나를 직접 만들었다는 건 자원을 직접 관리한다는 뜻이고, 자동 생성된 나머지는 포인터만 복사해서 이중 해제가 나기 때문이다.
- Rule of 5 (C++11) : 여기에 이동 생성자와 이동 대입을 더한다. 소멸자나 복사 연산을 하나라도 직접 선언하면 이동 연산이 자동으로 만들어지지 않는다. 그러면 rvalue도 조용히 복사로 처리된다.
1
2
3
4
5
6
7
8
9
struct WithDtor
{
// 소멸자만 선언했다
Buffer b;
~WithDtor() {}
};
WithDtor x;
WithDtor y = std::move(x); // 이동이 아니라 복사된다
소멸자를 비워 두기만 했는데 멤버 b가 이동되지 않고 복사된다. 디버깅용 로그를 넣으려고 소멸자를 추가했다가 성능이 떨어지는 전형적인 경우이다.
- Rule of 0 : 가장 좋은 방법은 아무것도 직접 정의하지 않는 것이다. 자원을 std::unique_ptr, std::vector, std::string 같은 RAII 타입 멤버에 맡기면, 컴파일러가 만든 다섯 개가 모두 올바르게 동작한다. 직접 정의는 Buffer처럼 자원을 직접 관리하는 클래스에서만 하고, 그때는 다섯 개를 모두 챙긴다.
정리하자면, 이동 의미론은 rvalue를 T&& 오버로드로 받아 자원을 복사 대신 가져오는 것이다. std::move는 xvalue로 바꾸는 캐스트일 뿐이라 const 객체는 복사되고, return std::move(local)는 NRVO를 막는다. 이동된 객체는 유효하지만 지정되지 않은 상태이다. vector는 이동 생성자가 noexcept일 때만 재할당에 이동을 쓰고, 소멸자나 복사 연산을 직접 선언하면 이동 연산이 자동 생성되지 않으니 Rule of 0를 기본으로 한다.
noexcept
이 함수는 예외를 던지지 않는다는 약속을 함수 선언에 적는 것이다.
1
Buffer(Buffer&& other) noexcept;
noexcept 함수 안에서 예외가 밖으로 나가려 하면 프로그램이 즉시 std::terminate로 종료된다. 예외를 잡을 기회가 없다. 그래서 정말로 예외를 던지지 않는 함수에만 붙여야 한다.
왜 중요하냐면 다른 코드가 컴파일 타임에 물어볼 수 있다. noexcept의 진짜 가치는 다른 코드가 컴파일 타임에 이 약속을 확인하고 전략을 바꿀 수 있다는 데 있다.
1
std::is_nothrow_move_constructible_v<Buffer> // 이동 생성자가 noexcept인가?
vector가 바로 이렇게 한다. 재할당 때 원소를 옮기다 중간에 실패하면 이렇게 된다.
위에서 말했지만, 복사 중간에 예외가 나면 원본이 그대로 남아 있으니 새 메모리를 버리면 원래 상태로 돌아간다. 하지만 이동 중간에 예외가 나면 이미 옮긴 원소들은 원본이 비어 있어서 되돌릴 수 없다.
vector는 “push_back이 실패해도 원래 상태를 유지한다”(강한 예외 보장)는 약속을 지켜야 한다. 그래서 이동 생성자가 noexcept라고 확실히 알 때만 이동하고, 모르면 안전한 복사를 택한다.
이동 생성자와 이동 대입은 포인터만 옮기니 예외가 날 일이 없으니 꼭 붙인다.
소멸자는 직접 안 써도 기본으로 noexcept다.
메모리를 할당하는 함수(복사 생성자 등)는 할당에서 std::bad_alloc 예외를 던질 수 있으니 붙이지 않는다.
컴파일러가 자동으로 만든 이동 생성자는 모든 멤버의 이동 생성자가 noexcept이면 자동으로 noexcept가 된다. Rule of 0를 따르면 신경 쓸 필요가 없는 이유다.
NRVO(Named Return Value Optimization)
NRVO는 이름 있는 지역 변수를 반환할 때 복사와 이동을 둘 다 없애는 최적화이다.
1
2
3
4
5
6
7
Buffer make()
{
Buffer local(100);
return local; // local을 반환값 자리에 바로 만들어 복사와 이동이 없다.
}
Buffer b = make();
어떻게 없애냐면, 호출하는 쪽이 반환값을 받을 메모리(b의 자리)를 미리 마련하고 그 주소를 함수에 몰래 넘긴다. 함수는 local을 자기 스택이 아니라 그 자리에 바로 만든다. 그러면 local과 b가 처음부터 같은 객체라서 옮길 것이 없다.
하지만 NRVO는 컴파일러가 항상 할 수 있는게 아니기 때문에 보장되지 않는다.
1
2
3
4
5
Buffer make(bool flag)
{
Buffer a(100), b(200);
return flag ? a : b; // 어느 쪽을 반환값 자리에 만들어야 할지 미리 알 수 없다.
}
반환될 지역 변수가 둘 이상이면 하나만 그 자리에 미리 만들 수 없으니 NRVO가 안 된다. 매개변수, 멤버, 전역 변수를 반환할 때도 안 된다. 이 객체들은 함수가 만든 것이 아니어서 반환값 자리에 만들 수 없다.
C++11부터는 NRVO가 안 될 때도 지역 변수를 반환하면 자동으로 rvalue로 취급한다. 그래서 복사가 아니라 이동이 일어난다.
return local;의 결과 | 비용 |
|---|---|
| NRVO 성공 | 0 |
| NRVO 실패 | 이동 1번 |
| C++11 이전에 NRVO 실패 | 복사 1번 |
이게 return std::move(local);이 손해인 이유다. std::move(local)은 지역 변수의 이름이 아니라 함수 호출 표현식이라서 NRVO 대상에서 빠진다. 그래서 “0 또는 이동 한 번”이던 것을 “항상 이동 한 번”으로 만든다. 얻는 것은 없고 최선의 경우만 사라진다.
5. 전달 참조와 참조 축약
받은 인자를 다른 함수에 그대로 넘기기만 하는 함수가 있다고 하자. 4장의 이동 의미론을 살리려면, lvalue를 받으면 lvalue로, rvalue를 받으면 rvalue로 넘겨야 한다. 그래야 받는 쪽에서 lvalue는 복사하고 rvalue는 이동할 수 있다.
1
2
void target(const std::string& s); // lvalue를 받으면 복사한다
void target(std::string&& s); // rvalue를 받으면 이동한다
이 target에 인자를 넘겨주는 wrapper를 만들려면, 지금까지 배운 것으로는 오버로드를 두 개 써야 한다.
1
2
void wrapper(const std::string& x) { target(x); } // lvalue용.
void wrapper(std::string&& x) { target(std::move(x)); } // rvalue용.
인자가 하나면 두 개로 끝나지만, 인자가 셋이면 각각 두 가지씩이라 2x2x2 = 8개가 된다. 템플릿 하나로 lvalue와 rvalue를 모두 받고, 받은 그대로 넘길 수는 없을까? C++11은 이를 위해 세 가지를 준비했다.
- 전달 참조 : lvalue와 rvalue를 모두 받는
T&& - 참조 축약 : 그 과정에서 생기는
int& &&모양을 정리하는 규칙 - std::forward : 받은 값이 lvalue였는지 rvalue였는지 되살려서 넘기는 함수
전달 참조
템플릿에서 T&&로 받으면 lvalue도 받을 수 있다.
1
2
3
4
void f(Widget&& w); // 우측값 참조. rvalue만 받는다
template <typename T>
void g(T&& x); // 전달 참조. lvalue도 rvalue도 받는다
같은 &&인데 뜻이 다르다. 타입이 정해져 있으면 4장의 우측값 참조이고, 그 자리에서 T가 추론되면 전달 참조(forwarding reference)다. 그 유명한 스콧 마이어스 아저씨가 universal reference라는 이름으로 알렸고, C++17에서 표준 용어가 forwarding reference로 정해졌다. auto도 템플릿과 같은 규칙으로 추론되므로 auto&&도 전달 참조다.
전달 참조가 되려면 정확히 T&& 모양이고, T가 그 호출에서 추론되어야 한다. 비슷해 보여도 아래는 전부 우측값 참조다.
1
2
3
4
5
template <typename T> void h(const T&& x); // const가 붙으면 아니다
template <typename T> void k(std::vector<T>&& v); // T&& 모양이 아니다
template <typename T>
struct Box { void put(T&& x); }; // T는 Box<int>를 만들 때 이미 정해졌다
Box
그렇다면 T&&는 어떻게 lvalue를 받을 수 있을까
전달 참조의 타입 추론
답은 T&&에만 있는 특별한 추론 규칙이다. 보통 템플릿은 lvalue를 넘겨도 T를 참조로 추론하지 않는다. 그런데 T&&에 lvalue를 넘기면 T가 참조 타입으로 추론된다.
1
int x = 10;
| 매개변수 | x(lvalue)를 넘기면 T | 10(rvalue)을 넘기면 T |
|---|---|---|
T | int | int |
T& | int | 에러 |
const T& | int | int |
T&& | int& | int |
T&&만 lvalue를 받았을 때 T에 &를 남긴다. 이 &가 “넘긴 것이 lvalue였다”는 기록이다. rvalue를 받으면 T는 그냥 int다. 즉, T를 보면 무엇을 받았는지 알 수 있다.
| 받은 것 | T |
|---|---|
| lvalue | int& |
| const lvalue | const int& |
| rvalue | int |
그런데 T가 int&이면 매개변수 T&&는 int& &&, 즉 참조의 참조가 된다. 1장에서 참조의 참조는 만들 수 없다고 했다. 그래서 참조 축약이 필요하다.
참조 축약
참조의 참조는 직접 쓰면 에러지만, 템플릿이나 typedef를 거쳐 저절로 생기면 하나로 접힌다. 이것을 참조 축약(reference collapsing)이라 한다.
| 생긴 모양 | 결과 |
|---|---|
& & | & |
& && | & |
&& & | & |
&& && | && |
하나라도 &가 있으면 &다. 이렇게 정한 이유는 앞의 추론 규칙과 맞물려 있다. lvalue를 받았을 때 T = int&이고, 매개변수 T&&는 int& &&다. 이것이 int&로 접혀야 매개변수가 좌측값 참조가 되어 lvalue에 바인딩될 수 있다. rvalue를 받았을 때는 T = int이고 T&&는 그냥 int&&다.
| 받은 것 | T | T&& | 매개변수 타입 |
|---|---|---|---|
| lvalue | int& | int& && → 축약 | int& |
| rvalue | int | int&& | int&& |
추론 규칙이 T에 기록을 남기고, 참조 축약이 그 기록에 맞는 매개변수 타입을 만든다. 둘이 합쳐져서 T&& 하나가 lvalue와 rvalue를 모두 받는다. 그리고 “lvalue라는 정보가 한 번이라도 끼면 결과도 lvalue 참조”이므로, 이름 있는 객체가 실수로 우측값 참조로 바뀌어 비워지는 일이 없다.
참조 축약은 전달 참조에만 있는 규칙이 아니다. typedef, decltype, 템플릿 인자를 직접 쓴 경우에도 똑같이 일어난다. 1장의 “typedef를 거친 const는 무시된다”와 합쳐지면 이런 일도 생긴다.
1
2
3
4
5
6
template <typename T>
void f(const T& x);
int x = 10;
f(x); // T = int → 매개변수는 const int&
f<int&>(x); // T = int& → 매개변수는 int&
T가 int&이면 const T&는 const가 참조자에 붙은 int& const가 되어 const가 무시되고, &가 더해진 int& &는 축약되어 int&가 된다. const로 받겠다고 선언한 함수 안에서 인자를 수정할 수 있게 되는 것이다.
const T& -> const int& & (T가 int&) -> int& const & (const가 int& 자체에 붙음) -> int& & (const 무시) -> int& (참조 축약)
앞에서도 말했지만 const가 무시되는 이유는 참조자엔 const를 붙일 수 없기 때문이다.
std::forward
이제 T&&로 lvalue와 rvalue를 모두 받을 수 있다. 받은 그대로 target에 넘겨 보자.
1
2
3
4
5
6
7
template <typename T>
void wrapper(T&& x) { target(x); }
std::string s = "a";
wrapper(s); // const string& (lvalue)
wrapper(std::string("t")); // const string& (lvalue) ← rvalue를 넘겼는데?
wrapper(std::move(s)); // const string& (lvalue) ← 이것도?
rvalue를 넘겨도 전부 lvalue로 도착한다. 2장에서 본 이름이 있으면 lvalue라는 규칙 때문이다. 매개변수 x의 타입이 std::string&&여도 표현식 x는 lvalue다. 그렇다고 std::move를 쓰면 이번에는 lvalue까지 전부 rvalue로 넘어가 버린다.
필요한 것은 “원래 rvalue였을 때만 rvalue로” 넘기는 방법이다. 원래 무엇이었는지는 T에 기록되어 있다. 그 기록을 읽어서 되살리는 것이 std::forward다.
1
2
3
4
5
6
template <typename T>
void wrapper(T&& x) { target(std::forward<T>(x)); }
wrapper(s); // const string& (lvalue)
wrapper(std::string("t")); // string&& (rvalue)
wrapper(std::move(s)); // string&& (rvalue)
std::forward를 요약하면 이렇게 생겼다.
1
2
3
4
5
template <typename T>
T&& forward(std::remove_reference_t<T>& arg)
{
return static_cast<T&&>(arg);
}
wrapper가 넘긴 T를 그대로 받아서, 앞에서 본 두 규칙을 한 번 더 적용한다.
lvalue를 받았을 때 (T = std::string&)
- 매개변수
std::remove_reference_t<T>&는std::string&이다. lvalue인 x를 받는다. - 반환 타입
T&&는std::string& &&이고, 참조 축약으로std::string&다. - 반환 타입이
T&이니 2장의 규칙대로 호출 결과는 lvalue다.
rvalue를 받았을 때 (T = std::string)
- 매개변수는
std::string&이다. 마찬가지로 x를 받는다. - 반환 타입
T&&는 그냥std::string&&다. - 반환 타입이
T&&이니 호출 결과는 xvalue, 즉 rvalue다.
같은 코드인데 T에 따라 반환 타입이 &가 되거나 &&가 된다. 추론 규칙이 남긴 기록을 참조 축약으로 읽어 내는 것이다.
여기서 두 가지가 눈에 띈다.
첫째, std::forward는 std::forward<T>(x)처럼 T를 직접 써 줘야 한다. T를 추론하게 두면 안 되기 때문이다. wrapper 안에서 x는 언제나 lvalue라서, forward가 x만 보고 추론하면 항상 “lvalue를 받았다”는 결과가 나온다. 원래 무엇을 받았는지는 x가 아니라 wrapper의 T에만 남아 있으니 그 T를 직접 넘겨야 한다. 매개변수를 std::remove_reference_t<T>&라는 모양으로 둔 것도 T를 추론하지 못하게 막기 위해서다.
둘째, std::forward도 std::move처럼 아무것도 옮기지 않는다. 둘 다 캐스트일 뿐이고, 차이는 언제 rvalue로 바꾸느냐다.
| 하는 일 | 쓰는 곳 | |
|---|---|---|
std::move(x) | 무조건 rvalue로 | 우측값 참조로 받은 것, 더 쓰지 않을 지역 변수 |
std::forward<T>(x) | 원래 rvalue였을 때만 rvalue로 | 전달 참조로 받은 것 |
완벽한 전달
세 가지를 합치면 처음의 문제가 템플릿 하나로 풀린다.
1
2
3
4
5
template <typename T>
void wrapper(T&& x) // 1. 전달 참조로 무엇이든 받는다
{
target(std::forward<T>(x)); // 3. T에 남은 기록대로 되살려 넘긴다
} // 2. 그 사이에서 참조 축약이 타입을 정리한다
이렇게 값 범주까지 그대로 넘기는 것을 완벽한 전달(perfect forwarding)이라 한다. 호출 하나하나를 따라가면 이렇다.
| 호출 | 넘긴 것 | T | x의 타입 | forward<T>(x) | 선택된 target |
|---|---|---|---|---|---|
wrapper(s) | lvalue | std::string& | std::string& | lvalue | const string& |
wrapper(cs) (const) | const lvalue | const std::string& | const std::string& | lvalue | const string& |
wrapper(std::string("t")) | prvalue | std::string | std::string&& | xvalue | string&& |
wrapper(std::move(s)) | xvalue | std::string | std::string&& | xvalue | string&& |
인자가 몇 개든 template <typename... Args> void wrapper(Args&&... args)와 std::forward<Args>(args)...로 똑같이 된다. std::make_unique나 vector::emplace_back이 받은 인자를 생성자에 그대로 넘길 수 있는 것이 이 덕분이다.
전달 참조를 쓸 때 주의할 점
같은 인자를 두 번 forward하지 않는다. 첫 번째에서 rvalue로 넘겨 이동되었다면 두 번째는 이동된 객체를 받는다. forward는 그 인자를 마지막으로 쓰는 곳에서만 한다.
auto&&는 무엇이든 받을 때 쓴다.
1
2
3
std::vector<bool> flags { true, false };
for (auto& e : flags) {} // 에러. 원소가 bool&가 아니라 prvalue인 프록시 객체다
for (auto&& e : flags) {} // 된다. prvalue도 받는다
vector
전달 참조 오버로드는 너무 많이 가져간다. 전달 참조는 무엇과도 정확히 맞기 때문에 다른 오버로드를 가로챈다.
1
2
3
4
5
6
7
8
9
struct Person
{
template <typename T>
Person(T&& name); // 이름으로 생성
Person(const Person& other); // 복사 생성자
};
Person p1("Kim");
Person p2(p1); // 복사 생성자가 아니라 템플릿 생성자가 선택된다
p1은 const가 아닌 lvalue다. 복사 생성자는 const Person&라서 const를 더해야 하지만, 템플릿은 T = Person&로 정확히 맞는다. 그래서 템플릿이 이기고, 안에서 Person으로 string을 만들려다 컴파일 에러가 난다. 전달 참조를 받는 함수는 오버로드하지 않는 게 원칙이고, 필요하면 concepts나 enable_if로 받을 타입을 제한한다.
정리하자면 T&&는 T가 추론될 때만 전달 참조다. lvalue를 넘기면 T가 X&로 추론되어 “lvalue였다”는 기록이 남고, 참조 축약(하나라도 &면 &)으로 매개변수가 X&가 된다. 함수 안에서 매개변수는 이름이 있어 lvalue이므로, std::forward<T>가 T의 기록을 읽어 원래 rvalue였을 때만 rvalue로 되돌린다. 이 셋이 합쳐진 것이 완벽한 전달이다.
참고