rValue Reference & Move

Date:     Updated:

카테고리:

태그:

모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.


class MyString {
  char *string_content;  // 문자열 데이터를 가리키는 포인터
  int string_length;     // 문자열 길이

  int memory_capacity;  // 현재 할당된 용량

 public:
  MyString();

  // 문자열로 부터 생성
  MyString(const char *str);

  // 복사 생성자
  MyString(const MyString &str);

  void reserve(int size);
  MyString operator+(const MyString &s);
  ~MyString();

  int length() const;

  void print();
  void println();
};

MyString::MyString() {
  std::cout << "생성자 호출 ! " << std::endl;
  string_length = 0;
  memory_capacity = 0;
  string_content = nullptr;
}

MyString::MyString(const char *str) {
  std::cout << "생성자 호출 ! " << std::endl;
  string_length = strlen(str);
  memory_capacity = string_length;
  string_content = new char[string_length];

  for (int i = 0; i != string_length; i++) string_content[i] = str[i];
}
MyString::MyString(const MyString &str) {
  std::cout << "복사 생성자 호출 ! " << std::endl;
  string_length = str.string_length;
  memory_capacity = str.string_length;
  string_content = new char[string_length];

  for (int i = 0; i != string_length; i++)
    string_content[i] = str.string_content[i];
}
MyString::~MyString() { delete[] string_content; }
void MyString::reserve(int size) {
  if (size > memory_capacity) {
    char *prev_string_content = string_content;

    string_content = new char[size];
    memory_capacity = size;

    for (int i = 0; i != string_length; i++)
      string_content[i] = prev_string_content[i];

    if (prev_string_content != nullptr) delete[] prev_string_content;
  }
}
MyString MyString::operator+(const MyString &s) {
  MyString str;
  str.reserve(string_length + s.string_length);
  for (int i = 0; i < string_length; i++)
    str.string_content[i] = string_content[i];
  for (int i = 0; i < s.string_length; i++)
    str.string_content[string_length + i] = s.string_content[i];
  str.string_length = string_length + s.string_length;
  return str;
}
int MyString::length() const { return string_length; }
void MyString::print() {
  for (int i = 0; i != string_length; i++) std::cout << string_content[i];
}
void MyString::println() {
  for (int i = 0; i != string_length; i++) std::cout << string_content[i];

  std::cout << std::endl;
}

int main() {
  MyString str1("abc");
  MyString str2("def");
  std::cout << "-------------" << std::endl;
  MyString str3 = str1 + str2;
  str3.println();
}

[!result] 생성자 호출 ! 생성자 호출 ! “————-“ 생성자 호출 ! 복사 생성자 호출 ! abcdef

위 코드에서 MyString str3 = str1 + str2; 부분을 자세히 살펴보자.

MyString MyString::operator+(const MyString &s) {
  MyString str;
  str.reserve(string_length + s.string_length);
  for (int i = 0; i < string_length; i++)
    str.string_content[i] = string_content[i];
  for (int i = 0; i < s.string_length; i++)
    str.string_content[string_length + i] = s.string_content[i];
  str.string_length = string_length + s.string_length;
  return str;
}

먼저 빈 Mystring 객체인 str 을 생성한다. 그 후 reserve 함수를 이용해 메모리를 할당하고, str1str2을 더한 문자열을 복사한다. 이렇게 리턴된 strstr3를 생성하는데 전달되어서, str3의 복사 생성자가 호출된다.

move1

여기서 드는 의문은, “굳이 str3의 복사 생성자를 호출해야 하나?” 이다. 왜냐하면 operator + 에서 생성한 (str1 + str2) 객체를 그대로 리턴하는데 불필요한 메모리 할당과 해제, 복사작업이 발생하기 때문이다. 만약 str1str2의 크기가 매우 컸다면 상당한 자원이 낭비될 것이다. 당연히 c++ 에서는 위 상황을 위한 최적화가 존재한다!

lValue vs rValue

int a = 3;

c++ 값에는 종류가 존재한다. 위 코드에서 a는 메모리 상에 존재하는 변수이다. 즉, & 연산자를 통해 a의 주소값을 알아 낼 수 있다. 보통 이렇게 주소값을 취할 수 있는 값을 좌측값(lvalue) 라고 부른다. 그리고 좌측값은 어떠한 표현식의 왼쪽, 오른쪽 모두에 올 수 있다.

반면 3이라는 값은, a와는 다르게 해당 라인에서만 잠깐 존재할 뿐 위 식이 연산되고 나면 사라지는 값이다. 이렇게 주소값을 취할 수 없는 값을 우측값(rvalue) 라고 부른다. 이름에도 알 수 있듯이 우측값은 항상 식의 오른쪽에만 와야 한다.

int& func1(int& a) { return a; }
int func2(int b) { return b; }

int main() {
  int a = 3;
  func1(a) = 4;
  std::cout << &func1(a) << std::endl;

  int b = 2;
  a = func2(b);               // 가능
  func2(b) = 5;               // 오류 1
  std::cout << &func2(b) << std::endl;  // 오류 2
}

[!bug] Error C2106’=’: left operand must be l-value Error C2102’&’ requires l-value

func2의 경우, func1과 달리 레퍼런스가 아닌 일반적인 int 값을 리턴한다. 이때 리턴되는 값은

a = func2(b);

위 문장이 실행될 때 잠깐 존재하는 값이므로 바로 사라지는 실체가 없는 값이다. 따라서 func2(b)는 우측값이므로 표현식에 오른쪽에 오는 경우는 가능하지만

func2(b) = 5;

와 같이 표현식의 왼쪽에 오는 경우는 가능하지 않다. 마찬가지로 우측값이기 때문에 & 연산자도 사용할 수 없다.

하지만 예외적으로 const T& 타입에 한해서만, 우측값도 레퍼런스를 받을 수 있다.

따라서 앞선 예제에서 우측값을 리턴하는 MyString MyString::operator+(const MyString &s) 함수의 결과값이 좌측값 레퍼런스를 인자로 받는 MyString(const MyString &str) 생성자를 호출할 수 있었던 것이다.

[!note] 사실 c++에서 값의 종류는 좌측값, 우측값만 있는게 아니라 조금 더 세부적으로 나뉘어진다. https://medium.com/@barryrevzin/value-categories-in-c-17-f56ae54bccbe

rValue Reference

move2

앞서 Mystring에서 지적한 문제를 해결하기 위해서는 다음과 같은 방식을 생각할 수 있다. str3 생성 시에 임시로 생성된 객체의 string_content 주소값을 str3string_content가 가리키는 것이다.

문제는 임시 객체가 소멸 시에 string_content의 메모리를 해제하게 되는데, 이를 방지하기 위해 임시 객체의 string_contentnullptr로 바꿔주고, 소멸자에서 string_content의 null체크를 해주면 된다.

하지만 이와 같은 방법은 기존의 복사 생성자에서 사용할 수 없다. 왜냐하면 복사 생성자는 const Mystring&으로 인자를 받기 때문에, 임시 객체의 string_content의 값을 바꿀 수 없기 때문이다.

이와 같은 문제가 발생한 이유는 const MyString& 인자가 좌측값과 우측값을 모두 받을 수 있다는 점에서 비롯된다. 이러한 문제를 해결하기 위해 c++ 11부터 우측값만 특수적으로 받을 수 있는 우측값 레퍼런스를 지원한다.

MyString::MyString(MyString&& str) {
  std::cout << "이동 생성자 호출 !" << std::endl;
  string_length = str.string_length;
  string_content = str.string_content;
  memory_capacity = str.memory_capacity;

  // 임시 객체 소멸 시에 메모리를 해제하지
  // 못하게 한다.
  str.string_content = nullptr;
}

MyString::~MyString() {
  if (string_content) delete[] string_content;
}

이동 생성자 작성 시 주의할 점

만약 MyString과 같은 클래스를 c++의 컨테이너들에 넣기 위해서는 한 가지 주의할 점이 필요하다. 바로 이동 생성자를 반드시 noexcept 로 명시해야 한다는 점이다.

move3

vector를 예로 들어 생각해보자. vector는 새로운 원소를 추가할 때 할당해놓은 메모리가 부족한 경우, 새로운 메모리를 할당한 후에 기존에 존재하던 원소들을 새로운 메모리로 옮기게 된다. 이때 복사 생성자를 이용하게 되면 위와 같이 하나 하나 원소가 복사되게 된다. 그런데 만약 이 과정에서 예외가 발생했다고 해보자.

복사 생성자를 이용한 경우 해결책은 간단하다. 새로 할당해 놓은 메모리를 소멸시킨 후, 사용자에게 예외를 전달하면 된다. 이 과정에서 이미 복사된 원소들도 소멸되어버리므로 자원이 낭비되는 일도 없다.

move4

반면 이동 생성자를 사용하는 경우, 예외가 발생하면 꽤나 골치가 아파진다. 이동 생성자의 경우 기존 메모리에 존재하던 원소들이 모두 이동되어 사라져버렸기 때문에, 새로 할당한 메모리를 섯불리 해제할 수 없어진다.

이러한 문제 때문에 vector의 경우 이동 생성자에서 예외가 발생했을 때, 이를 제대로 처리할 수 없다. 따라서 noexcept가 아닌 이상 vector는 이동 생성자를 사용하지 않는다. 이는 c++의 다른 컨테이너들도 마찬가지이다.

Move

template <typename T>
void my_swap(T &a, T &b) {
  T tmp(a);
  a = b;
  b = tmp;
}

my_swap(str1, str2);
swap 1 swap 2
move5 move6

일반적으로 생각하는 swap 함수는 왼쪽 그림처럼 전체 데이터의 복사 과정이 3번이나 발생한다. 이때 위에서 배운 이동 생성자를 이용해 위 문제를 깔끔하게 개선할 수 있을 것 같다.

하지만 문제는 my_swap 함수가 일반적인 타입 T를 대응해야 한다는 것이다. 위 string_content의 경우 MyString 클래스 내부에만 존재하는 필드이기 때문에 일반 타입 T 에 대해서는 작동하지 않는다. 그렇다고 템플릿 특후화를 이용해 작성하기에는 private 멤버이기 때문에 클래스 내부에서 정의해야 하고, 그럼 굳이 my_swap 함수를 정의할 필요가 없어진다.

결국 우리가 원하는 것은 T tmp(a) 에서 이동 생성자가 실행되기를 원한다. 하지만 지금 상태로는 무얼 해도 이동 생성자는 오버로딩 되지 않는다. 필요에 따라 좌측값을 우측값으로 캐스팅 할 수 없을까?

A c(std::move(a));

놀랍게도 c++ 11부터 < utility > 라이브러리에서 좌측값을 우측값으로 바꿔주는 move 함수를 제공한다. std::move 함수는 인자로 받은 객체를 우측값으로 변환해 리턴해준다. 사실 이름만 보면 무언가 이동 시킬 것 같지만 실제로는 단순한 타입 변환만 수행한다.

MyString& MyString::operator=(MyString&& s) {
  std::cout << "이동!" << std::endl;
  string_content = s.string_content;
  memory_capacity = s.memory_capacity;
  string_length = s.string_length;

  s.string_content = nullptr;
  s.memory_capacity = 0;
  s.string_length = 0;
  return *this;
}

template <typename T>
void my_swap(T &a, T &b) {
  T tmp(std::move(a));
  a = std::move(b);
  b = std::move(tmp);
}

[!important] 실제 데이터가 이동되는 과정은 이동생성사나 이동 대입 연산자가 호출될 때 이지, move를 한 시점이 아니다. 따라서 이동 생성자(대입 연산자)를 정의하지 않았다면 일반적인 대입 연산자가 오버로딩 될 뿐이다. 참고로 c++의 원저자인 Bjarne Stroustroup은 move라고 이름 지은 것을 후회했다고 한다.

Exercise

class A {
 public:
  A() { std::cout << "ctor\n"; }
  A(const A& a) { std::cout << "copy ctor\n"; }
  A(A&& a) { std::cout << "move ctor\n"; }
};

class B {
 public:
  A a_;
};

위 코드에서 만약 B 객체를 생성할 때, 이미 생성되어 있는 A 객체를 B 객체 안으로 집어 넣고 싶다면(=이동) B에 어떤 생성자를 정의해야 할까?

[1] 기본 생성자 → 복사 생성자 호출

B(const A& a) : a_(a) {}

B b(a);

[2] 이동 생성자 → 복사 생성자 호출

B(const A& a) : a_(std::move(a)) {}

B b(a);

std::move(a) 가 우측값으로 바꾼 건 맞지만, aconst A& 이므로 std::move(a)의 타입은 const A&& 이다. 그런데 A의 생성자에는 const A& 또는 A&& 만 정의되어 있다. 이런 경우 컴파일러는 const A&를 오버로딩 하게된다.

[3] 우측값 받아오기 → 복사 생성자 호출

B(A&& a) : a_(a) {}

B b(std::move(a));

a의 타입은 우측값 레퍼런스 이지만 그 자체로는 좌측값이기 때문에(이름이 있으니깐) 복사 생성자를 호출한다.

[4] 우측값 받기 & 이동 생성자 → 이동 생성자 호출

 B(A&& a) : a_(std::move(a)) {}

B b(std::move(a));

Forwarding Reference (Universal Reference)

auto 또는 Template으로 우측값 참조를 받으면 굉장히 까다로운 규칙을 따른다. 특히 템플릿 타입의 우측값 레퍼런스는 다음과 같은 레퍼런스 겹침 규칙(reference collapsing rule) 에 따라 T의 타입을 추론하게 된다.

typedef int& T;
T& r1;   // int& &; r1 은 int&
T&& r2;  // int & &&;  r2 는 int&

typedef int&& U;
U& r3;   // int && &; r3 는 int&
U&& r4;  // int && &&; r4 는 int&&

간단하게 말하면 &는 1이고 &&는 0 이라 둔 뒤에, OR 연산

또한 많은 경우 함수 전체의 흐름은 동일한데 복사 생성자를 호출할 것이냐, 이동 생성자를 호출할 것이냐만 다른 경우가 존재할 것이다. 이런 경우를 포함해 왼값 좌측값 레퍼런스를 받는 경우, 우측값 레퍼런스를 받는 경우를 각각 오버로딩해야 하는데 이는 매우 번거로운 작업이다.

template<typename T>
void Test_Forwarding(T&& param)
{
	// 왼값 참조라면 복사
	// 오른값 참조라면 이동
	Test_copy(std::forward<T>(param));
}

이떄 std::forward 를 이용하면 위 문제를 간단하게 해결할 수 있다.



맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기