복사 생략(Copy Elision)

 

우측값 레퍼런스에 대해 이해하기 전에 복사생략에 대해 알아야 함

int main()
{
    A a(1);
    A b(a);
    A c(A(2));
}

위처럼 객체를 생성한다면 

첫번째 줄의 a객체는 일반 생성자를 이용해 생성하게 되고

두번째 줄의 b객체는 복사 생성자를 이용해 a를 복사하며 생성하게 된다.

 

그렇다면 세번째 줄의 c 객체의 경우는 먼저 A(2)로 임시객체를 생성한 후 해당 객체를 복사해서 c로 넣으니

먼저 일반생성자가 호출된 후 복사생성자가 호출될것으로 예상된다.

 

★ 하지만 그렇지 않다.

c 객체의 생성 과정은 어차피 A(2)를 복사해서 넣으나 바로 A(2)로 객체를 만드나 같으므로

컴파일러 최적화에 의해 일반 생성자 한번만 호출된다.

 즉, 이런 상황을 복사 생략(copy elision)이라고 한다.

 

modern C++에서는 일부 경우에 대해서는 반드시 복사 생략을 하지만 몇몇 경우의 경우 복사 생략을 하는것을 권장하지만 안하는 경우도 있다. 아래의 예시도 현재는 복사 생략이 일어나지만 일어나지 않는다면 어떻게 될지 예시로 사용해봄

 

만약 아래의 경우 복사 생략을 하지 않는다면 어떻게 될까?

MyString str1("abcdefg......."); // 아주 긴 문자열 객체 생성
MyString str2("abcdefg......."); // 아주 긴 문자열 객체 생성
// 위의 둘은 일반 생성자 호출

MyString str3 = str1 + str2;

위의 코드에서 str3의 경우 먼저 str1 + str2 임시객체를 만들어야 하므로 일반 생성자가 호출되고, 

해당 임시 객체가 str3으로 복사되므로 복사생성자가 호출될텐데

복사 생략이 일어나지 않는다면?

 

str1과 str2는 아주아주 긴 문자열이므로 둘을 합친 문자열을 일반 생성자로 생성한 후

복사 생성자로 복사할 경우 상당한 자원의 소모가 생길 수 있음

 

이러한 문제를 해결하기 위한 방법이 아래 내용들이다.

 

 

 

좌측값(lvalue)과 우측값(rvalue)

 

int a = 1;

/*
모든 C++ 표현식은 위처럼 좌측과 우측으로 나뉘며
어떤 타입을 가지는지 or 어떤 값을 가지는지로 나뉜다고도 할 수 있음
*/

위 표현식에서 a는 메모리 상에 존재하는 변수이며, a의 주소값을 &연산자를 이용해 알아낼 수 있음

즉 이렇게 주소값을 가질 수 있는 값을 좌측값(lvalue)라고 부름

또한 좌측값은 표현식의 왼쪽에도, 오른쪽에도 올 수 있음

좌측값이라고 해서 꼭 왼쪽에만 위치하지는 않음

 

반면 위 표현식에서 1은 a와는 다르게 주소값을 취할 수 없음

연산할때 잠시 존재했다가 연산이 끝나면 사라지는 값이며

이처럼 주소값을 취할 수 없는 경우를 우측값(rvalue)라고 부름

좌측값과 달리 우측값은 반드시 식의 오른쪽에 와야만 함

 

int a; // a는 좌측값
int& l_a = a; // a는 좌측값이므로 주소를 취할 수 있고, l_a는 좌측값 레퍼런스가 됨

int& r_b = 1; // 1은 우측값이므로 주소를 취할 수 없고, 이 식은 오류가 발생함

위의 l_a처럼 &하나를 이용해서 정의하는 레퍼런스를 좌측값 레퍼런스라고 부르며, 좌측값 레퍼런스 자체도 좌측값이 된다.

 

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
}

또한 위처럼 func1은 좌측값 레퍼런스를 리턴하므로 좌측값으로 사용가능하지만

func2의 경우 그냥 int인 우측값을 리턴하므로 연산할때 잠시 존재했다가 사라지는 값이니

func2(b) = 5;처럼 대입도 불가능하고, &func(b)처럼 주소연산도 불가능함

 

★ 하지만 유일하게 const T& 타입에 한해서만 우측값도 레퍼런스로 받을 수 있음

const 레퍼런스이기 때문에 임시로 존재하는 객체여도 값을 참조만 할 뿐 변경할 수는 없기때문에

 

 

 

MyString str3 문제점의 해결방법

 

MyString str3 = str1 + str2;

위의 수식이 복사 생략을 하지 않아 일반 생성자를 호출한 후 복사 생성자를 호출한다면 비효율이 발생한다.

 

해결 방법으로는 str1 + str2 임시객체가 일반생성자로 만들어져 있을때

이걸 str3으로 복사해오는것이 아니라

임시객체와 "str1 + str2 문자열"의 연결을 끊고 str3이 해당 문자열을 가리키게 만든다.

 

구체적으로는 str3 생성시 가리키는 문자열을 "str1 + str2 문자열"로 만들어주고 

임시객체가 소멸할때 해당 문자열도 같이 소멸되지 않게 하기 위해 임시 생성된 객체가 nullptr을 가리키게 바꿔주면 된다.

그리고 소멸자에서 가리키는 문자열이 nullptr이면 소멸하지 않게 해준다.

 

하지만 const T& 형식인 복사생성자에서는 위 방식이 불가능하다.

const이므로 값을 변경 할 수 없기 때문인데

이걸 우측값 레퍼런스를 활용해 구현한 이동 생성자를 사용해서 문제를 해결한다.

 

 

 

우측값 레퍼런스

 

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

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

레퍼런스(&)를 두번 사용한 생성자를 추가해주며 이걸 이동 생성자라고 부른다.

MyString::MyString(MyString &&str) {
    std::cout << "이동 생성자 호출 !" << std::endl;
    string_length = str.string_length;
    string_content = str.string_content;
    memory_capacity = str.memory_capacity; // str의 값들을 가져와 준 후 
    
    str.string_content = nullptr; // 임시 객체 소멸 시에 메모리를 해제하지 못하게 함
}

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

위처럼 좌측값과 달리 &을 두번 사용하여 우측값 레퍼런스를 정의할 수 있고

위의 이동 생성자는 MyString 타입의 우측값을 인자로 받음

 

이제 str은 MyString의 우측값 레퍼런스이며 좌측값이 됨

따라서 표현식의 좌측에 올 수도 있음

 

int a;
int& l_a = a;
int& ll_a = 3;  // 불가능

int&& r_b = 3;
int&& rr_b = a;  // 불가능

우측값 레퍼런스는 반드시 우측값의 레퍼런스로만 사용 가능하므로 좌측값인 a는 대입 불가

 

MyString&& str3 = str1 + str2;
str3.println();

우측값 레퍼런스는 레퍼런스 하는 임시객체가 소멸되지 않도록 붙들고 있게 됨

 

 

 

이동 생성자 사용시 주의할 점

 

만약 MyString을 C++컨테이너(vector같은 것)에 넣으려면 이동생성자를 noexcept로 명시해야 함

vector의 경우 push_back으로 값들을 추가할때 공간이 부족하다면 더 큰 새로운 공간을 할당 한 후 값들을 복사함

 

이 때 복사 생성자를 사용할 경우 값들이 하나씩 복사될텐데 복사하는 과정에서 예외가 발생한다면 예외가 발생했음을 알리고 새로 할당중이던 큰 공간은 그냥 버리면 끝

 

하지만 이동생성자를 사용하는 경우라면?

이동생성자는 이동 중에 예외가 발생한다면 기존 메모리에 있던 값들이 새로운 공간으로 이동중이었기에

새로운 공간을 그냥 할당 해제해버리면 기존 공간에는 값이 없으므로 값이 소멸됨

 

그러므로 vector같은 컨테이너들은 이동생성자에서 발생한 예외를 처리하기 힘들게 됨

그래서 컨테이너들은 noexcept가 아니면 이동 생성자를 사용하지 않고 복사 생성자만 사용함

하지만 noexcept를 붙여주면 그제서야 이동생성자를 사용하게 됨

 

 

 

좌측값도 이동생성자를 사용하고 싶다면?

 

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

위처럼 swap 함수를 사용한다면 a와 b는 좌측값이므로 이동생성자가 아닌 복사생성자가 호출됨

근데 만약 a나 b가 너무 큰 객체라서 swap하면서 발생하는 복사에 부하가 크다면?

 

이럴땐 좌측값도 이동생성자를 사용할 수 있으면 좋음

그러므로 위에서 T tmp(a); 가 복사 생성자를 호출하지만 이게 아니라 잠시 a의 주소값만 다른곳에 이동시켜두는것을 원하게 됨

 

이때 <utility>라이브러리의 move 함수를 사용할 수 있음

#include <utility>

int main() {
    A a;				// 일반 생성자 호출
    A b(a);				// 복사 생성자 호출
    A c(std::move(a));	// 이동 생성자 호출
}

위처럼 좌측값을 std::move에 넣어주면 우측값처럼 취급되어 이동생성자로 오버로딩 됨

정확히는 move함수가 인자로 받은 객체를 우측값으로 변환해서 리턴해줌

 

이름만 move로 이동 시키는 느낌이고 실상은 단순한 타입 변환 함수

단지 이동생성자를 사용할 수 있게 한다는 의미로 기억하는게 나을 듯

 

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

이제 swap 함수를 이동생성자를 이용해 변경한다면 위처럼 되고 복사생성자가 아닌 이동생성자로 오버로딩됨

또한 위의 a = ~~ , b = ~~ 에서 operator=를 만들어서 이동되는 과정을 아래와 같이 만들어줘야 빠르게 사용 가능

그렇지 않다면 일반적인 대입으로 매우 느린 복사가 일어날 수 있음

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;
}

 

u가 우측값 레퍼런스일 때만 move와 같은 역할을 해주는 forward 함수

g(forward<T>(u));

 

 

 

 

※ 참고 문헌

https://modoocode.com/227

https://modoocode.com/228