이동 연산자체는 오래전부터 존재했었다고 볼 수 있다
예를 들어, 기존의 벡터를 새로 생성한 벡터와 Swap하는 경우를 생각해보자
의미론적인 관점에서 이것은 기존의 벡터를 새로운 벡터로 이동했다고 볼 수 있을 것이다
허나 표현식 그 어디에서도 이동과 관련된 것을 찾아볼 수 없다
이것은 프로그래머가 값이 이동되었는지를 판단하기 어렵고, 실수로 이어지기 쉽다
이러한 문제를 해결하고자 C++11 부터 이동관련 연산이 언어차원에서 지원되었다
문제는 기존에도 복사 연산이라는 것이 존재했고, 여기에 추가를 하다보니 상당히 복잡해졌다는 것이다
옆동네 Rust는 이런 문제를 피하고 싶었는지 이동연산을 default로 만들어버렸다
(C++에서 생성자를 3~4개씩 작성하다보면 Rust가 부럽기도 하다..)
결과가 어떻든 C++에서 이동연산은 자리를 잡았고, 중요한 역할을하고 있다
따라서 한번쯤은 깊게 알아볼 필요가 있다
L-value, R-value는 그 이름에서 기원을 찾아볼 수 있다
왼쪽(Left)에 있어서 L-value, 오른쪽(Right)에 있어서 R-value라 불렸기 때문이다
다만, C++이 발전하면서 이 단순한 개념은 전혀 통하지 않게 되었다
std::string s;
(s + s) = s;
https://godbolt.org/z/a39hPxea9
위 예제는 문법적으로 전혀 문제되지 않는다
허나 R-value는 왼쪽에, L-value는 오른쪽에 위치한 모습이다
따라서 C++에서는 값에 대한 분류를 새롭게 할 필요가 있었다
그리고 무엇보다 오늘의 주제인 이동연산이 등장하면서 L/R-value만으로는 설명이 불가능해졌다
C++에서 표현식을 카테고리로 분류할 경우 위와 같이 분류가 된다
통상적으로 L-value는 주소값을 가지는 표현식, R-value 주소값이 없는 표현식으로 해석되기도 한다
그런데 값이 이동하는 경우에는 주소값이 있었으나, 더 이상 프로그램에서 접근하면 안되는 경우다
따라서 새로운 카테고리가 필요하기에 등장한 것이 X-value라 볼 수 있다
X-value는 eXpiring value로 말 그대로 소멸하는 상태이지만 재사용될 수 있는 값이다
이동연산 이외에도 대표적인 예시가 하나 더 있는데 바로 temporary materialized이다
이게 뭔가 싶을수 있지만 예시를 보면 바로 이해가된다
void foo(int const& i) {
// ...
}
foo(1);
L-value와 R-value를 모두 받기 위해 흔히 const L-value ref를 사용한다
그런데 이름은 분명 L-value ref인데 왜 R-value도 허용하는 것일까
정확한 이유는 모르지만, 단순히 가능하기 때문에 적용했다고 알고있다
const가 있으므로, 값이 수정될 일이없어 R-value를 L-value처럼 취급해도 되기 때문이다
정확한 이유는 operator overloading 때문이라고 하는데, 궁금하면 아래 링크를 통해 확인해보자
https://oneraynyday.github.io/dev/2020/07/03/Value-Categories/#c98-1998
L/PR/X-value에 대한 정확한 분류는 cppreference에서 찾아볼 수 있다
https://en.cppreference.com/w/cpp/language/value_category
그런데 저 많은걸 항상 기억하고 있는것은 꽤나 큰 고통이다
하물며 L/PR/X 이렇게 3가지 카테고리로 나누는것조차 고통이다
따라서 실제 프로그래밍에서 활용할 수 있는 부분만 기억하고자 한다면 한가지룰만 기억하면 된다
표현식의 결과가 이름을 가지고 있는가?
O: L-value
X: R-value
가장 간단한 예시부터 생각해보자
당연하겠지만 a
는 이름이 있으니 L-value, 5
는 이름이 없으니 R-value다
void func(int);
int a;
a = 5; // 'a' -> L-value
func(a); // 'a' -> L-value
func(5); // '5' -> R-value
함수의 리턴값 자체는 이름이 없으므로 R-value다
반대로 파라미터로 전해진 값은 파라미터로써 이름이 부여된다
따라서 argument로 R-value를 넣었을지라도 함수 내부에서는 L-value로 취급된다
마찬가지로 파라미터가 R-value Ref일지라도, 이름이 존재하기에 L-value이다
int foo(int a) { // 'a' -> L-value
return a;
}
int bar(int&& a) { // 'a' -> L-value
return a;
}
int a = foo(5); // 'return value of func(5)' -> R-value
억지같아 보일순 있지만 연산자에도 동일한 규칙을 적용시킬 수 있다
assignment의 경우 결과적으로 남는건 왼쪽의 변수이니 L-value다
대신 덧셈 같은 이항 연산자는 이름이 없는 결과값을 생성함으로 R-value다
a += 5; // 'a' -> L-value;
(a + 5); // '(a + 5)' -> R-value
위에서 언급한 한가지 룰이 모든 경우에 다 적용되면 좋겠지만 그렇지 않다
애초에 그게 그렇게 간단한 문제였다면 cppreference가 저렇게 길게 설명할 이유도 없다
따라서 위 룰에 해당되지 않으면서도 실수하기 좋은 몇가지만 소개하고 넘어갈까한다
우선 명시적으로 캐스팅하는 경우가 있을 것이다
L/R-value ref로 캐스팅할 경우, 당연하게도 캐스팅한 결과를 따라간다
애초에 저게 안됐으면 이동연산 자체가 불가능해진다
int a;
static_cast<int&>(a); // L-value
static_cast<int&&>(a); // R-value
함수의 리턴 타입이 L-value ref인 경우 당연하게도 L-value다
대표적인 예시라하면 C++ 컨테이너의 front가 있을 것이다
int& foo();
int a = foo(); // 'foo()' -> L-value
std::vector<int> vec{1,2,3};
vec.front(); // 'vec.front()' -> L-value
또 다른 예외가 바로 배열이다
배열은 주소값을 가지고 있기에 L-value이다
따라서 string literal도 상수형 중에선 유일하게 L-value다
int a[5]; // 'a[5]' -> L-value
char const* str = "hello world!" // "hello world!" -> L-value
그 외에 *
연산자는 L-value지만, &
연산자는 R-value라던가하는 소소한 것들이 있다
c++11에서 이동연산을 구현하기 위해 R-value ref를 추가했다
물론 R-value ref라고해서 모두 이동연산일 필요는 없다
다만 함수의 파라미터가 R-value ref라면, 암묵적으로 이동연산과 관련된 함수라 보는것이다
애초에 임시값을 나타내는 R-value가 이후에 무언가를 한다는것 자체가 말이 안된다
스탠다드에서는 R-value ref를 통해 R-value의 lifetime을 연장한다 정도로 표현하는듯하다
https://en.cppreference.com/w/cpp/language/reference
여기에 한가지 예외가 존재하는데 바로 universial reference다
다만, 저거까지 포함하기에는 글이 너무 길어져 이번글에서 다룰 생각은없다
c++에서 값의 이동이라하면 가장 먼저 떠오르는 것이다
하지만 std::move
자체는 실제로 값을 이동시키지 않는다
단지 값을 이동 가능한 상태로 만들어주는것 그뿐이다
실제 구현을 보면 더 확실하게 이해할 수 있다
template <typename T>
constexpr std::remove_reference_t<T>&& move(T&& arg) noexcept {
return static_cast<std::remove_reference_t<T>&&>(arg);
}
구현부에서 이동연산은 찾아볼 수 없고, 값을 R-value ref로 캐스팅만 한다
즉, 값이 이동가능한 상태(R-value ref)로 변환시켜주는 것이다
따라서 이동 자체는 함수에서 구현한다고 볼 수 있다
예를 들어 아주 단순한 string을 구현한다고 생각해보자
https://godbolt.org/z/Gen1PWdP3
#include <cstring>
#include <utility>
class string {
char* data = nullptr;
public:
string() = default;
string(char const* s) {
data = new char[strlen(s) + 1];
strcpy(data, s);
}
~string() {
delete[] data;
data = nullptr;
}
void move_from(string&& s) {
delete[] data;
data = s.data;
s.data = nullptr;
}
};
int main() {
string s1("hello world!");
string s2;
s2.move_from(std::move(s1));
return 0;
}
만약 string을 옮기고 싶다면 위와 같이 R-value ref를 사용한다
그리고 함수 내부에서 포인터를 옮겨줌으로써 값을 이동시킨다
여기서 L-value는 캐스팅하지 않는 이상 R-value가 될 수 없다
때문에 std::move를 통해 R-value로 캐스팅하여 전달하는 것이다
즉, 객체를 이동가능한 상태로 전달하여 함수 내부에서 데이터를 이동시키는 것이라 볼 수 있다
데이터를 복사하거나 이동하는것은 보통 객체 초기화와도 관련이 되어있다
따라서 복사 생성자처럼 이동 생성자도 추가되었다
복사 생성자하고 거의 비슷하지만, 파라미터가 R-value ref라는 차이만 있다
https://en.cppreference.com/w/cpp/language/move_constructor
위 예시를 다시 가져와 move_from
을 이동생성자로 바꿔보았다
https://godbolt.org/z/s1a3s9Yer
#include <cstring>
#include <utility>
class string {
char* data = nullptr;
public:
string() = default;
string(char const* s) {
data = new char[strlen(s) + 1];
strcpy(data, s);
}
string(string&& s) noexcept {
data = s.data;
s.data = nullptr;
}
~string() {
delete[] data;
data = nullptr;
}
};
int main() {
string s1("hello world!");
string s2(std::move(s1));
return 0;
}
이동생성자가 있으니 이동 대입 연산자도 있어야 할것이다
https://en.cppreference.com/w/cpp/language/move_assignment
구현자체가 특별할건 없고, 생성이 아닌 대입 연산이기에 약간만 더 신경 써주면 된다
https://godbolt.org/z/ezzE7dezE
#include <cstring>
#include <utility>
class string {
char* data = nullptr;
public:
string() = default;
string(char const* s) {
data = new char[strlen(s) + 1];
strcpy(data, s);
}
string(string&& s) noexcept {
data = s.data;
s.data = nullptr;
}
~string() {
delete[] data;
data = nullptr;
}
string& operator=(string&& s) noexcept {
delete[] data;
data = s.data;
s.data = nullptr;
return *this;
}
};
int main() {
string s1("hello world!");
string s2(std::move(s1));
s1 = std::move(s2);
return 0;
}
이동 관련 연산들을 정의할때 한가지 주의사항이 있다
어지간하면 이동연산 함수들을 noexcept로 정의하고, exception을 던지면 안된다
그 이유는 vector를 생각해보면 쉽게 이해가 된다
template <typename T, typename Alloc>
constexpr void vector<T, Alloc>::push_back(T const& ele)
{
std::size_t old_cap = this->capacity();
std::size_t old_size = this->size();
if (old_size < old_cap)
{
std::construct_at(end(), ele);
}
else
{
std::size_t new_cap = old_cap == 0 ? 1 : old_cap * 2;
pointer new_begin get_allocator().allocate(new_cap);
pointer new_end = new_begin + old_size;
iterator old_begin = begin();
iterator old_end = end();
std::construct_at(new_end, ele); // construct new element
for (; old_end != old_begin;) { // move old elements
std::construct_at(*(--new_end),
std::move(*(--old_end)));
}
std::destroy(old_begin, old_end); // destruct old elements
get_allocator()->deallocate(old_begin);
m_begin = new_begin; // swap new buffer
m_end = new_end;
m_capacity = new_begin + new_cap;
}
}
push_back만 생각하더라도 capacity가 부족하면 메모리 alloc을 받는다
이 과정에서 기존의 객체들은 이동해야해서 이동 생성자가 호출된다
그런데 만약 이동생성자가 exception을 던진다면 어떨까
exception 발생 전까지는 이동이 될 것이고, 이후는 이동이 안될것이다
이러한 현상을 방지하려면 매 allocate마다 값을 복사해야한다
그리고 이는 심각한 성능 저하로 이어질 수 있다(사실상 이동연산의 의미가 사라진다)
이것은 vector만의 문제가 아니다
standard library의 상당 부분이 이러한 상황을 마주할 수 있다
따라서 c++ 표준 위원회는 이동생성/대입 연산이 noexcept가 되도록 강제했다
적어도 standard library에 한해서는 말이다..
커스텀 클래스를 만들면서 이동연산을 noexcept로 만들 필요는 없다
하지만 위 사례에서 알 수 있듯, 이동중에 exception을 던진다면 인생이 피곤해진다
따라서 우리도 이동 관련 연산을 noexcept로 정의하여 이런 상황을 방지해야한다
이동연산의 등장으로 생성자와 대입연산자 등 이제 특수 맴버함수가 6개로 늘어났다
이 중 어떤 함수를 작성하고 말지를 고민하는건 상당히 머리가 아프다
그래서 친절하게도 c++ core guideline에는 다음과 같은 규칙이 존재한다
C.20: If you can avoid defining any default operations, do
C.21: If you define or =delete any copy, move, or destructor function, define or =delete them all
위 두가지 규칙은 각각 The rule of zero/five로도 불린다
이 2가지 규칙을 따르기만 하면 문제없이 클래스를 작성할 수 있다
https://en.cppreference.com/w/cpp/language/rule_of_three
만약 모든 맴버 변수의 특수 맴버함수가 이미 정의되어있다면, rule of zero를 따르면 된다
5가지 특수 맴버함수들을 전부 컴파일러가 자동 생성해주기 때문이다
따라서 우리는 생성자만 정의해주면 된다
class foo {
std::string str;
std::vector<int> vec
public:
foo()
: str("some string")
, vec{1, 2, 3, 4, 5}
{}
// ~foo();
// foo(foo const&);
// foo(foo&&);
// foo& operator=(foo const&);
// foo& operator=(foo&&);
};
디버그용 클래스를 만들어서 직접 호출해보면 다음과 같은 결과를 얻을 수 있다
https://godbolt.org/z/aja4z7Ef5
int main() {
foo f1;
foo f2(f1);
foo f3(std::move(f1));
f2 = f3;
f2 = std::move(f3);
return 0;
}
// constructor
// copy constructor
// move constructor
// copy assign operator
// move assign operator
// destructor
// destructor
// destructor
세상 만사 이렇게 편하기만하면 참 좋을 것이다
그렇기에 일각에서는 cpp std library를 최대한 활용하라고한다
포인터조차 unique_ptr, shared_ptr 등으로 감싸면 해결되기 때문이다
그러나 성능이 되었건, 레거시 코드가 되었건 모든 일이 그렇게만 흘러가진 않는다
그래서 두번째 규칙이 존재한다
만약 5가지 함수들(소멸, 복사생성/대입, 이동생성/대입) 중 한개라도 정의할 경우, 모두하라는 룰이다
이것은 포인터를 직접 조작하는 예시를 생각해보면 이해가 쉽다
class foo {
std::string str;
int* arr;
debug d;
public:
foo()
: str("some string")
, arr(new int[5])
, d()
{}
~foo() { delete[] arr; }
// foo(foo&&);
// foo(foo const&);
// foo& operator=(foo const&);
// foo& operator=(foo&&);
};
클래스 안에서 동적할당을 받을경우, 위와 같이 소멸자에서 cleanup하는 것이 일반적이다
다만 이 경우 한가지 문제가 발생한다
https://godbolt.org/z/6Ta3bz88s
int main() {
foo f1;
foo f2(f1);
foo f3(std::move(f1));
f2 = f3;
f2 = std::move(f3);
return 0;
}
// ==1==ERROR: AddressSanitizer: attempting double-free
// #0 0x63ae6a00f5bd in operator delete[](void*)
// #1 0x63ae6a01070e in foo::~foo() /app/example.cpp:26:14
// #2 0x63ae6a01070e in main /app/example.cpp:43:1
address sanitizer가 친절하게 알려준 에러를 보면 더블프리라고 되어있다
이는 당연한것이, 포인터는 값이 복사될때 주소값만 복사되기 때문이다
즉, 흔히 말하는 shallow copy가 발생하면서 더블 프리가 된것이라 볼 수 있다
그렇다면 이걸 해결하기 위해서는 복사생성자와 복사 대입연산자를 작성해야할 것이다
https://godbolt.org/z/v9Wfz6e6e
class foo {
std::string str;
int* arr;
debug d;
public:
foo()
: str("some string")
, arr(new int[5])
, d()
{}
~foo() { delete[] arr; }
foo(foo const& o)
: str(o.str)
, d(o.d)
{
arr = new int[5];
std::copy(o.arr, o.arr + 5, arr);
}
foo& operator=(foo const& o) {
str = o.str;
d = o.d;
delete[] arr;
arr = new int[5];
std::copy(o.arr, o.arr + 5, arr);
return *this;
}
// foo(foo&&);
// foo& operator=(foo&&);
};
// constructor
// copy constructor
// copy constructor
// copy assign operator
// copy assign operator
// destructor
// destructor
// destructor
메모리 문제없이 성공적으로 프로그램이 도는것을 확인할 수 있다
그런데 뭔가 결과가 이상하다
우리는 이동연산을 원했는데 복사연산이 실행되고 있다
이에 대한 이유는 이동연산관련 레퍼런스 페이지에 나와있다
https://en.cppreference.com/w/cpp/language/move_constructor
https://en.cppreference.com/w/cpp/language/move_assignment
Implicitly-declared 섹션에 가보면 둘 모두 동일한 조건을 확인할 수 있다
이유인즉슨, 소멸자와 복사 생성자가 있기에 자동 생성되지 않은 것이다
그리고 const L-value ref는 R-value로 부터 암묵적 변환이 가능하다
따라서 이동연산을 수행하더라도 복사 연산이 대신 호출되는것이다
이것을 해결하는 방법은 이동연산 또한 우리가 직접 선언해줘야한다
https://godbolt.org/z/9W3jnfMns
class foo {
std::string str;
int* arr;
debug d;
public:
foo()
: str("some string")
, arr(new int[5])
, d()
{}
~foo() {
delete[] arr;
arr = nullptr;
}
foo(foo const& o)
: str(o.str)
, d(o.d)
{
arr = new int[5];
std::copy(o.arr, o.arr + 5, arr);
}
foo (foo&& o)
: str(std::move(o.str))
, d(std::move(o.d))
{
arr = o.arr;
o.arr = nullptr;
}
foo& operator=(foo const& o) {
str = o.str;
d = o.d;
delete[] arr;
arr = new int[5];
std::copy(o.arr, o.arr + 5, arr);
return *this;
}
foo& operator=(foo&& o) {
str = std::move(o.str);
d = std::move(o.d);
delete[] arr;
arr = o.arr;
o.arr = nullptr;
return *this;
}
};
원하던 결과를 얻기 위해 5가지 모두를 직접 선언하여 사용하였다
이처럼 저 5가지 중 하나만 원하더라도 5가지 모두를 직접 선언하여 사용해야한다
이동연산은 상당히 효율적이므로 사용가능한 모든곳에 써도 될것 같지만 그렇지않다
그 대표적인 예시가 바로 Copy Elision으로, 흔히 RVO로도 불린다
본래 권장사양이었는데 c++17에서 요구사항으로 바뀜에따라 모든 컴파일러는 해당 구현을 해야한다
원래도 메이저 컴파일러(GCC, Clang, MSVC)는 다 했었다
https://en.cppreference.com/w/cpp/language/copy_elision
RVO, Return Value Optimization이라는 이름에서 그 뜻을 알 수 있는데
말 그대로 return value를 복사하지 않음으로써 최적화를 수행한다는 것이다
struct debug;
debug foo() {
return debug();
}
debug d = foo();
본래 리턴값이 대입되는 과정에서 복사연산이 발생해야한다
하지만 그렇게되면 생성자가 호출된 직후, 복사 생성자가 호출된다
즉, 불필요한 복사가 이루어지는 것이다
따라서 컴파일러는 복사 생성자를 호출하지 않고, d
의 위치에서 생성자를 호출한다
이를 어셈블리 단에서 확인해보면 이해가 쉽다
https://godbolt.org/z/8sfYG7P8q
foo():
push rbx
mov rbx, rdi // d의 위치에서 생성자 호출
call debug::debug() [base object constructor]
mov rax, rbx
pop rbx
ret
main:
push rax
lea rdi, [rsp + 7] // d의 주소 전달
call foo()
foo는 파라미터가 한개도 없는데 뭔가가 rdi
를 통해 넘어간다
이를 잘 살펴보면 rsp + 7
, d의 위치임을 확인할 수 있다
즉, d의 주소를 파라미터로 넘김으로써 해당 위치에서 생성자를 호출하게 되는것이다
이는 릴리즈모드가 아닌 디버그 모드에서조차 해당되는 이야기이다
그런데 여기서 리턴값을 이동시키면 어떻게 될까
struct debug;
debug foo() {
return std::move(debug());
}
debug d = foo();
// constructor
// move constructor
// destructor
// destructor
더 이상 RVO가 동작하지 않는다
최근 컴파일러들은 친절하게 Copy Elision이 안된다고 알려주기까지 한다
<source>:14:12: warning: moving a temporary object prevents copy elision [-Wpessimizing-move]
14 | return std::move(debug());
| ^
<source>:14:12: note: remove std::move call here
14 | return std::move(debug());
| ^~~~~~~~~~ ~
RVO는 일반적으로 이름이없는(unnamed) 상황을 산정하고 사용하는 표현이다
그렇다면 이름이 부여된 경우에는 어떨까
https://godbolt.org/z/ha1vo9cEa
struct debug;
debug foo() {
debug d;
d.a = 5;
return d;
}
debug d = foo();
// constructor
// destructor
이 경우에도 복사, 이동 연산이 수행되지 않은 것을 확인할 수 있다
굳이 foo
의 스택에서 변수가 꼭 생성되야할 이유가 없기 때문이다
따라서 컴파일러는 RVO와 동일하게 d
의 위치에서 생성할 수 있다
이러한 경우를 Named RVO라 하며 줄여서 NRVO라 한다
그리고 RVO와 마찬가지로 이동연산을 사용할 경우 NRVO는 불가능해진다
https://godbolt.org/z/bcaq5vbed
struct debug;
debug foo() {
debug d;
return std::move(d);
}
debug d = foo();
// constructor
// move constructor
// destructor
// destructor
RVO랑 다르게 변수가 함수 내부에서 선언되기에 더 큰 조심성이 필요하다
예를 들어 다름 함수에 파라미터로 전달되는 경우를 생각할 수 있다
https://godbolt.org/z/7WfPneGdx
struct debug;
debug& bar(debug& d) {
return d;
}
debug foo() {
debug d;
return bar(d);
}
debug d = foo();
// constructor
// copy constructor
// destructor
// destructor
이 경우 bar에서는 아무런 조작을 하지 않음에도 NRVO가 불가능하다
더불어 값이 복사됨을 확인할 수 있다
물론 이런 상황이 있을까 싶지만은..만약 필요하다면 차라리 이동 연산이 나을 것이다
https://godbolt.org/z/8djre5ab6
struct debug;
debug& bar(debug& d) {
return d;
}
debug foo() {
debug d;
return std::move(bar(d));
}
debug d = foo();
// constructor
// move constructor
// destructor
// destructor
이 밖에도 Copy Elision은 다양한 상황에서 발생할 수 있다
예를 들어 객체를 초기화하는 상황을 생각해보자
https://godbolt.org/z/Tbzfez3Ee
void foo(debug) {}
debug d = debug(); // copy elision
foo(debug()) // copy elision
이건 그냥 초기화하는게 아닌가 싶을수도 있지만, 엄밀히 말하자면 그렇지 않다
c++에서 대입연산자도 하나의 연산자로써 복사 혹은 이동을 수행한다
그렇기에 굳이 오버로딩을 하면서 정의해줘야한다
그런데 위 예시에서는 복사 없이 값이 초기화됨을 알 수 있다
이것은 RVO와 동일한 이유로 복사가 생략가능하기 때문이다
마찬가지로 함수 호출시 값을 생성하는 경우에도 복사가 생략된다
따라서 이러한 경우에도 동일하게 이동 연산을 수행하면 안된다
위 예시들을 보면 알 수 있듯 대부분의 상황에서 이동 연산은 불필요하다
따라서 이동연산을 남발하기에 앞서 과연 객체가 복사되는지를 한번쯤 고민할 필요가 있다
https://junstar92.tistory.com/471
https://learn.microsoft.com/ko-kr/cpp/cpp/move-constructors-and-move-assignment-operators-cpp?view=msvc-170
https://www.youtube.com/watch?v=St0MNEU5b0o
https://en.cppreference.com/w/cpp/language/value_category#cite_note-pmfc-2