C++ Move Semantics

JunTak Lee·2025년 2월 6일
0

C++

목록 보기
4/4

이동 연산자체는 오래전부터 존재했었다고 볼 수 있다
예를 들어, 기존의 벡터를 새로 생성한 벡터와 Swap하는 경우를 생각해보자
의미론적인 관점에서 이것은 기존의 벡터를 새로운 벡터로 이동했다고 볼 수 있을 것이다
허나 표현식 그 어디에서도 이동과 관련된 것을 찾아볼 수 없다
이것은 프로그래머가 값이 이동되었는지를 판단하기 어렵고, 실수로 이어지기 쉽다

이러한 문제를 해결하고자 C++11 부터 이동관련 연산이 언어차원에서 지원되었다
문제는 기존에도 복사 연산이라는 것이 존재했고, 여기에 추가를 하다보니 상당히 복잡해졌다는 것이다
옆동네 Rust는 이런 문제를 피하고 싶었는지 이동연산을 default로 만들어버렸다
(C++에서 생성자를 3~4개씩 작성하다보면 Rust가 부럽기도 하다..)

결과가 어떻든 C++에서 이동연산은 자리를 잡았고, 중요한 역할을하고 있다
따라서 한번쯤은 깊게 알아볼 필요가 있다


L-value and R-value

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만으로는 설명이 불가능해졌다

Value categories

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

Keeping things simple

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

Simplicity is not perfect

위에서 언급한 한가지 룰이 모든 경우에 다 적용되면 좋겠지만 그렇지 않다
애초에 그게 그렇게 간단한 문제였다면 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라던가하는 소소한 것들이 있다


Moving operations

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다
다만, 저거까지 포함하기에는 글이 너무 길어져 이번글에서 다룰 생각은없다

std::move

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로 캐스팅하여 전달하는 것이다
즉, 객체를 이동가능한 상태로 전달하여 함수 내부에서 데이터를 이동시키는 것이라 볼 수 있다

Move constructor

데이터를 복사하거나 이동하는것은 보통 객체 초기화와도 관련이 되어있다
따라서 복사 생성자처럼 이동 생성자도 추가되었다
복사 생성자하고 거의 비슷하지만, 파라미터가 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;
}

Move assignment operator

이동생성자가 있으니 이동 대입 연산자도 있어야 할것이다
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

이동 관련 연산들을 정의할때 한가지 주의사항이 있다
어지간하면 이동연산 함수들을 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로 정의하여 이런 상황을 방지해야한다


The rule of zero/five

이동연산의 등장으로 생성자와 대입연산자 등 이제 특수 맴버함수가 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

The rule of zero

만약 모든 맴버 변수의 특수 맴버함수가 이미 정의되어있다면, 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 등으로 감싸면 해결되기 때문이다
그러나 성능이 되었건, 레거시 코드가 되었건 모든 일이 그렇게만 흘러가진 않는다
그래서 두번째 규칙이 존재한다

The rule of five

만약 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 섹션에 가보면 둘 모두 동일한 조건을 확인할 수 있다

  • user-defined 복사 생성자가 없는 경우
  • user-defined 복사 대입 연산자가 없는 경우
  • user-defined 이동 생성자/이동 대입 연산자가가 없는 경우
  • user-defined 소멸자가 없는 경우

이유인즉슨, 소멸자와 복사 생성자가 있기에 자동 생성되지 않은 것이다
그리고 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

이동연산은 상당히 효율적이므로 사용가능한 모든곳에 써도 될것 같지만 그렇지않다
그 대표적인 예시가 바로 Copy Elision으로, 흔히 RVO로도 불린다
본래 권장사양이었는데 c++17에서 요구사항으로 바뀜에따라 모든 컴파일러는 해당 구현을 해야한다
원래도 메이저 컴파일러(GCC, Clang, MSVC)는 다 했었다
https://en.cppreference.com/w/cpp/language/copy_elision

RVO

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());
      |            ^~~~~~~~~~       ~

NRVO

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

Other cases

이 밖에도 Copy Elision은 다양한 상황에서 발생할 수 있다
예를 들어 객체를 초기화하는 상황을 생각해보자
https://godbolt.org/z/Tbzfez3Ee

void foo(debug) {}

debug d = debug(); // copy elision
foo(debug()) // copy elision

이건 그냥 초기화하는게 아닌가 싶을수도 있지만, 엄밀히 말하자면 그렇지 않다
c++에서 대입연산자도 하나의 연산자로써 복사 혹은 이동을 수행한다
그렇기에 굳이 오버로딩을 하면서 정의해줘야한다
그런데 위 예시에서는 복사 없이 값이 초기화됨을 알 수 있다
이것은 RVO와 동일한 이유로 복사가 생략가능하기 때문이다
마찬가지로 함수 호출시 값을 생성하는 경우에도 복사가 생략된다
따라서 이러한 경우에도 동일하게 이동 연산을 수행하면 안된다

위 예시들을 보면 알 수 있듯 대부분의 상황에서 이동 연산은 불필요하다
따라서 이동연산을 남발하기에 앞서 과연 객체가 복사되는지를 한번쯤 고민할 필요가 있다


Reference

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

0개의 댓글