와와

[씹어먹는 C++] 우측값 레퍼런스/ 이동 생성자/ Move 문법/ 완벽한 전달 본문

개발/C++

[씹어먹는 C++] 우측값 레퍼런스/ 이동 생성자/ Move 문법/ 완벽한 전달

정으주 2024. 9. 20. 13:36

https://modoocode.com/227

 

씹어먹는 C++ - <12 - 1. 우측값 레퍼런스와 이동 생성자>

모두의 코드 씹어먹는 C++ - <12 - 1. 우측값 레퍼런스와 이동 생성자> 작성일 : 2018-03-24 이 글은 72589 번 읽혔습니다. 이번 강좌에서는 복사 생략 (Copy elision)우측값 레퍼런스 (rvalue referen ce)이동 생성

modoocode.com

https://modoocode.com/228#google_vignette

 

씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)>

모두의 코드 씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)> 작성일 : 2018-03-27 이 글은 46742 번 읽혔습니다. 등에 대해 다룹니다. 안녕하세요 여러분! 지난번의 우

modoocode.com

 

 

< 복사 생략 >

 

복사 생략이란, 임시 객체를 만들고 복사를 수행하려 할 때 바로 새 객체를 생성하며 컴파일러 자체에서 복사를 생략해버리는 행위를 말한다.

 

예시

#include <iostream>

class A {
  int data_;

 public:
  A(int data) : data_(data) { std::cout << "일반 생성자 호출!" << std::endl; }

  A(const A& a) : data_(a.data_) {
    std::cout << "복사 생성자 호출!" << std::endl;
  }
};

int main() {
  A a(1);  // 일반 생성자 호출
  A b(a);  // 복사 생성자 호출

  // 그렇다면 이것은?
  A c(A(2));
}

 

c(A(2)) 에서는 A(2) 를 호출하여 임시 객체를 생성하고, 이는 바로 c를 초기화 하는데 사용된다.

이때 컴파일러 최적화로 복사 생략이 일어나게 된다. 바로 c 객체를 생성해버림.

 

출력 결과

일반 생성자 호출!  // a 객체 생성
복사 생성자 호출!  // b 객체 생성
일반 생성자 호출!  // c 객체 생성 (A(2)로 인한 호출)

 

 

* C++ 17 부터 일부 경우에 대해서 (예를 들어서 함수 내부에서 객체를 만들어서 return 할 경우) 반드시 복사 생략을 해야되는 것으로 바뀌었다.

 

 

<복사 생략이 일어나지 않는 문제>

 

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::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 main() {
  MyString str1("abc");
  MyString str2("def");
  std::cout << "-------------" << std::endl;
  MyString str3 = str1 + str2;
  str3.println();
}

 

str3를 생성하려 할 때,

 MyString 객체인 str  을 생성하고 (생성자 호출! 출력됨) 그 후에, reserve 함수를 이용해서 공간을 할당하고, str1  str2 를 더한 문자열을 복사하게 된다. 이렇게 리턴된 str  str3 을 생성하는데 전달되어서, str3 의 복사 생성자가 호출 된다.

 

여기서는 컴파일러가 복사 생략 최적화를 수행하지 않아서 (str1+str2) 의 임시객체를 생성한 후 복사 생성자를 호출하게 되며 자원 낭비가 일어나고 있다.

 

 

문제를 해결하기 위해 우선 좌측값과 우측값에 대해 알아보자.

더보기

<좌측값과 우측값>

 

int a = 3;

 

좌측값 (lvalue) 

- 주소값을 취할 수 있는 값

- 메모리 상에 존재하는 값

좌측값은 어떠한 표현식의 왼쪽 오른쪽 모두에 올 수 있습니다 (왼쪽에만 와야 하는게 아닙니다).

ex) a

 

우측값 (rvalue)

- 주소값을 취할 수 없는 값, 임시 값

- 우측값은 식의 오른쪽에만 항상 와야 합니다.

ex) 3

 

좌측값 레퍼런스

int a;         // a 는 좌측값
int& l_a = a;  // l_a 는 좌측값 레퍼런스

int& r_b = 3;  // 3 은 우측값. 따라서 오류

 

 

MyString str3 = str1 + str2;

위 문장은

MyString str3(str1.operator+(str2));

와 동일함. 그런데, operator+ 의 정의를 살펴보면,

MyString MyString::operator+(const MyString &s)

로 우측값을 리턴하고 있는데, 이 우측값이 어떻게 좌측값 레퍼런스를 인자로 받는,

MyString(const MyString &str);

를 호출 시킬 수 있었을까? 이는 & 가 좌측값 레퍼런스를 의미하지만, 예외적으로

const T&

의 타입의 한해서만, 우측값도 레퍼런스로 받을 수 있다. 그 이유는 const 레퍼런스 이기 때문에 임시로 존재하는 객체의 값을 참조만 할 뿐 이를 변경할 수 없기 때문이다.

 

위 문제를 해결하기 위해선

생성 -> 복사를 하지 않고 생성 -> 이동을 해주면 된다.

 

즉, str1 + str2 가 리턴한 임시 생성 객체의 string_content가 가리키는 문자열의 주소값을 str3의 string_content로 해주면 된다.

 

그러려면 이동 생성자를 만들어야 하는데,  임시객체(우측값)을 매개변수로 받아야함.

우측값 레퍼런스를 사용하면 된다.

(참고로, 호출 우선 순위는 이동 > 복사임)

 

 

우측값 레퍼런스를 사용한 이동 생성자의 정의

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 타입의 우측값을 인자로 받고 있다.

 str 은 타입이 'MyString 의 우측값 레퍼런스' 인 좌측값임.

 

위와 같이 임시 객체의 string_content 가 가리키는 메모리를 새로 생성되는 객체의 메모리로 옮겨주기만 하면 된다.

 

여기서 주의할 점은

str.string_content = nullptr;

 

인데,

이동 생성자의 호출이 끝날 때, 인자로 받은 임시 객체가 소멸되면서 자신이 가리키고 있던 문자열을 delete 하지 못하게 해야 한다. 만약에 그 문자열을 지우게 된다면, 새롭게 생성된 문자열 str3 도 같은 메모리를 가리키고 있기 때문에 str3 의 문자열도 같이 사라지는 셈이 되기 때문이다.

 

nullptr 은 C++11에서 도입된 키워드로, 널 포인터(null pointer)를 나타내는 리터럴이다. 포인터가 어떤 유효한 메모리 주소도 가리키지 않는다는 것을 나타냄.

 

이렇게 nullptr 로 바꾸고

소멸자에서 메모리를 해제하지 못하도 예외 처리를 해주면 된다.

MyString::~MyString() {	//소멸자
  if (string_content) delete[] string_content;
}

 

 

 

<벡터의 이동 생성자>

 

 MyString 을 C++ 컨테이너들, 예를 들어 vector 에 넣기 위해서는 이동 생성자를 반드시 noexcept 로 명시해야 한다.

 

 

 

복사 도중 예외가 발생하면 새로 할당한 메모리를 전부 소멸시켜 버린다.

 

 

 

이동 생성중 예외가 발생하면 기존의 메모리에 원소들이 모두 이동되어서 사라져버렸기에, 새로 할당한 메모리를 섯불리 해제해버릴 수가 없다.

이럴 경우를 대비하여 vector 는 이동 생성자가 noexcept 가 아닌 이상 이동 생성자를 사용하지 않는다.

 

  // 이동 생성자
  MyString(MyString &&str) noexcept;

 

이렇게 noexcept를 명시해주어야 이동 생성자를 사용할 수 있음.

 

 


 

< Move 문법>

 

정리하자면,

복사는 새 공간에 원본 내용을 옮겨 담는 것이고 이동은 새 공간으로 원본 소유권을 이전하는 것이다.

우측값 레퍼런스를 사용하여 우측값에 대한 이동의 구현이 가능해졌다.

만약에 좌측값도 이동을 시키고 싶다면 어떨까?

 

좌측값을 우측값으로 변환해주면 된다. move를 사용해서!

 

std::move

  • 인자로 받은 객체를 우측값으로 변환해서 리턴함
  • C++11 이후 <utility> 라이브러리에서 제공
  • 이름과는 달리 move를 수행하지 않고 그냥 우측값으로 캐스팅만 해줌!! 

 

 

my_swap  

 

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

 

위의 swap 함수에서는 tmp 복사 생성, b 복사, tmp 복사 이렇게 총 세 번의 복사가 발생한다.

move 함수를 통해 tmp 이동 생성, b 이동, tmp 이동으로 바꿔보려 한다.

 

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

 

첫번째 줄에서는 이동 생성자, 두번째와 세번째 줄에서는 이동 대입 연산자가 호출되어야 한다.

실제로 데이터가 이동 되는 과정은 위와 같이 정의한 이동 생성자나 이동 대입 연산자를 호출할 때 수행 되는 것이지 std::move 를 한 시점에서 수행되는 것이 아니다.

이름 좀 잘 지어놓지..

 

*전체 코드

더보기
#include <iostream>
#include <cstring>

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

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

 public:
  MyString();

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

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

  // 이동 생성자
  MyString(MyString &&str);

  // 일반적인 대입 연산자
  MyString &operator=(const MyString &s);

  // 이동 대입 연산자
  MyString& operator=(MyString&& s);

  ~MyString();

  int length() const;

  void println();
};

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

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;
  string_content = new char[string_length];

  for (int i = 0; i != string_length; i++)
    string_content[i] = str.string_content[i];
}
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;
  str.string_length = 0;
  str.memory_capacity = 0;
}
MyString::~MyString() {
  if (string_content) delete[] string_content;
}
MyString &MyString::operator=(const MyString &s) {
  std::cout << "복사!" << std::endl;
  if (s.string_length > memory_capacity) {
    delete[] string_content;
    string_content = new char[s.string_length];
    memory_capacity = s.string_length;
  }
  string_length = s.string_length;
  for (int i = 0; i != string_length; i++) {
    string_content[i] = s.string_content[i];
  }

  return *this;
}
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;
}
int MyString::length() const { return string_length; }
void MyString::println() {
  for (int i = 0; i != string_length; i++) std::cout << string_content[i];

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

template <typename T>
void my_swap(T &a, T &b) {
  T tmp(std::move(a));
  a = std::move(b);
  b = std::move(tmp);
}
int main() {
  MyString str1("abc");
  MyString str2("def");
  std::cout << "Swap 전 -----" << std::endl;
  std::cout << "str1 : ";
  str1.println();
  std::cout << "str2 : ";
  str2.println();

  std::cout << "Swap 후 -----" << std::endl;
  my_swap(str1, str2);
  std::cout << "str1 : ";
  str1.println();
  std::cout << "str2 : ";
  str2.println();
}

 

 

 

 

< 완벽한 전달 : Perfect forwarding >

 

우측값 레퍼런스를 도입함으로써 wrapper 함수도 구현할 수 있다.

 

wrapper

template <typename T>
void wrapper(T u) {
  g(u);
}

 

이 함수는 인자로 받은 u 를 그대로 g 라는 함수에 인자로 전달해준다.

 

더보기

wrapper 함수의 대표적인 예로 vector의 emplace_back 함수를 들 수 있다.

emplace_back 함수는 인자를 직접 전달받아서, 내부에서 A 의 생성자를 호출한 뒤에 이를 벡터 원소 뒤에 추가하는 함수이다.

 

사실 push_back 함수를 사용할 경우 컴파일러가 알아서 최적화를 해주기 때문에 불필요한 복사-이동을 수행하지 않고 emplace_back 을 사용했을 때와 동일한 어셈블리를 생성합니다. 따라서 push_back 을 사용하는 것이 훨씬 낫습니다. (emplace_back 은 예상치 못한 생성자가 호출될 위험이 있습니다)

 

wrapper 함수 적용해보기

#include <iostream>
#include <vector>

template <typename T>
void wrapper(T u) {
  g(u);
}

class A {};

void g(A& a) { std::cout << "좌측값 레퍼런스 호출" << std::endl; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출" << std::endl; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출" << std::endl; }

int main() {
  A a;
  const A ca;

  std::cout << "원본 --------" << std::endl;
  g(a);
  g(ca);
  g(A());

  std::cout << "Wrapper -----" << std::endl;
  wrapper(a);
  wrapper(ca);
  wrapper(A());
}

 

실행 결과

원본 --------
좌측값 레퍼런스 호출
좌측값 상수 레퍼런스 호출
우측값 레퍼런스 호출
Wrapper -----
좌측값 레퍼런스 호출
좌측값 레퍼런스 호출
좌측값 레퍼런스 호출

 

위 세 경우 모두 좌측값 레퍼런스를 받는 g 함수가 호출되었다.

그 이유는

g(u)를 호출할 때, u는 항상 A 타입의 비-const 좌측값이다. 세 가지 g 함수 중에서 void g(A& a)가 가장 적합한 매치로 선택됨

각 케이스를 자세히 살펴보면:

  • wrapper(a): T는 A로 추론, u는 A 타입의 좌측값.
  • wrapper(ca): T는 A로 추론 (const 무시), u는 A 타입의 좌측값.
  • wrapper(A()): T는 A로 추론, 우측값이 u로 복사되어 A 타입의 좌측값이 됨.

모든 경우에 wrapper 내부의 u는 A 타입의 비-const 좌측값이 되므로, g(A& a)가 호출되는 것이다.

보편적 레퍼런스를 통해 이 문제를 해결할 수 있다.

 

 

보편적 레퍼런스

template <typename T>
void wrapper(T&& u) {
  g(std::forward<T>(u));
}

 

이렇게 템플릿 인자 T에 대해서, 우측값 레퍼런스를 받는 형태를 보편적 레퍼런스 (Universal reference) 라고 한다.

보편적 레퍼런스는 우측값 뿐만 아니라 좌측값도 받아낼 수 있다. (레퍼런스 겹침 규칙에 따라)

 

더보기

레퍼런스 겹침 규칙(reference collapsing rules)

 

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. 좌측값 레퍼런스(&)와 좌측값 레퍼런스(&)의 조합 → 좌측값 레퍼런스(&)
  2. 우측값 레퍼런스(&&)와 좌측값 레퍼런스(&)의 조합 → 좌측값 레퍼런스(&)
  3. 좌측값 레퍼런스(&)와 우측값 레퍼런스(&&)의 조합 → 좌측값 레퍼런스(&)
  4. 우측값 레퍼런스(&&)와 우측값 레퍼런스(&&)의 조합 → 우측값 레퍼런스(&&)

간단히 말해, 좌측값 레퍼런스(&)가 하나라도 포함되면 결과는 좌측값 레퍼런스(&)가 된다.

 

std::forward 의 역할

  • std::forward<T>(u)는 T의 추론된 타입에 따라 u를 적절히 캐스팅함
  • 만약 T가 레퍼런스 타입이면 좌측값으로, 아니면 우측값으로 캐스팅