본문 바로가기
프로그래밍/C & C++

[C++] 우측값 참조(rvalue reference)

by 별준 2021. 8. 11.

References

Contents

  • 복사 생략(Copy Elision)
  • 좌측값(lvalue)와 우측값(rvalue)
  • 우측값 레퍼런스(rvalue reference)
  • 이동 생성자(move constructor)
  • STL Container에서 사용할 때 주의할 점

요즘 threadpool 구현을 해보기 위해서 thread와 관련된 여러가지를 공부하다보니 컴파일 에러나 경고에서 자주 봤던 rvalue에 대해서도 조금씩 나오고, C++을 조금 더 효율적으로 사용하기 위해서 어느 정도 개념은 잡아야 한다고 생각이 들어서 우측값 참조에 대해서 알아보려고 합니다.

 

해당 내용들을 살펴보니 글이 조금 길어질 것 같아서, 먼저 이번글을 통해 우측값에 대해서 알아보고, 다음글에서 우측값으로 해결할 수 있는 문제인 Move Semantic(std::move), Perfect Forwading에 대해서 알아보도록 하겠습니다.


복사 생략(Copy Elision)

#include <iostream>

class A {
	int data_;

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

	A(const A& rhs) : data_(rhs.data_) {
		std::cout << "복사 생성자 호출\n";
	}
};

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

위 코드를 실행하면 어떤 결과를 얻을 수 있을까요?

A a(1);
A b(a);

이 코드에서는 예상대로 일반 생성자와 복사 생성자가 순서대로 호출되었습니다.

A c(A(2));

하지만, 위 코드에서는 '일반 생성자 호출'이 한번만 출력되었습니다. 

코드 자체만 놓고 본다면 A(2)를 생성하면서 일반 생성자를 호출하므로 '일반 생성자 호출', 이렇게 생성된 객체가 c로 복사되면서 복사 생성자 호출되면서 '복사 생성자 호출'이 되어야합니다.

 

이는 컴파일러 최적화 기법인 복사 생략(Copy Elision)으로 인해서 복사를 수행하지 않고, 임시로 만들어진 A(2) 자체를 바로 c로 만들어버렸기 때문입니다.

사실 c(A(2)) 에 대해서 생각해보면 굳이 임시 객체인 A(2)를 만들고, 이를 c로 복사 생성할 필요가 없습니다. 어차피 A(2)로 똑같이 c를 만들거라면, 차라리 c 자체를 A(2)로 만들어진 객체로 하는 것이랑 똑같기 때문입니다.

 

이러한 복사 생략은 함수 내부에서 생성된 객체를 리턴할 때도 수행될 수 있습니다.

A getA() {
	A a(2);
    
    return a;
}

똑똑한 컴파일러는 위 함수 내부에서 A 객체 a를 생성하고, 리턴할 때 a의 값을 복사해서 리턴하는 것이 아니라 리턴하는 값 위치에 a(2)를 생성해버립니다. 이를 반환값 최적화(return value optimization)이라고 합니다.


이번에는 MyString 이라는 클래스로 살펴보겠습니다. MyString 클래스는 아래와 같이 구현되어 있습니다.

#include <iostream>

class MyString {
	char* string_content;
	int string_length;

	int memory_capacity;

public:
	MyString();
	MyString(const char* str);
	MyString(const MyString& str);

	~MyString();

	MyString operator+(const MyString& rhs);

	void reserve(int size);
	int length() const;
	void println();
};

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

MyString::MyString(const char* str) {
	std::cout << "생성자 호출\n";
	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 << "복사 생성자 호출\n";
	string_length = str.string_length;
	memory_capacity = str.memory_capacity;
	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; }

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

	return str;
}

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

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 << "\n";
}

그리고 아래의 코드를 실행시켜보겠습니다.

int main(void) {
	MyString str1("abc");
	MyString str2("def");
	std::cout << "----------------\n";

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

str1과 str2의 일반 생성자가 호출이 되었고, 이후에 str1과 str2를 더한 새로운 문자열로 str3을 생성하고 있습니다.

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

	return str;
}

MyString의 operator+ 함수를 살펴보면 위와 같습니다. str1 + str2를 실행하면서 위 함수가 호출되는데, 함수 내부에서 먼저 리턴을 위한 임시 객체인 str을 생성하게 되며, '생성자 호출'을 출력하게 됩니다. 그 이후에 공간을 할당하고 str1과 str2를 더한 문자열을 복사하게 됩니다.

함수가 모두 수행되고 리턴되는 str은 str3을 생성하는데 전달되면서, str3의 복사 생성자가 호출됩니다.('복사 생성자 호출'을 출력합니다.)

 

처음 이야기했던 복사 생략을 생각해보면, 굳이 str3의 복사 생성자를 또 호출할 필요가 없습니다. 어짜피 똑같이 복사해서 생성할 것이라면, 이미 생성된 (str1 + str2)가 리턴한 객체(함수 내에서 임시로 생성한 객체 str)를 str3인 셈치고 사용하면 되기 때문입니다. 물론 이전 예제에서는 컴파일러가 불필요한 복사 생성자 호출을 하지 않았지만, 이 경우에는 컴파일러가 복사 생략 최적화를 수행하지 않았습니다.

 

이렇게 쓸데없는 복사를 수행하는데, 만약 str1과 str2의 크기가 매우 컸다면 상당한 리소스가 소모될 것입니다.

 


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

위 문제를 어떻게 C++에서 해결하는 지를 알아보기 전에 좌측값과 우측값에 대해서 먼저 정리하겠습니다.

C에서부터 내려오는 정의는 다음과 같습니다.

좌측값(lvalue)는 대입(assignment) 시에 왼쪽 혹은 오른쪽에 오는 식(expression)이고,
우측값(rvlaue)는 대입 시에 오른쪽에만 오는 식이다.

예를 들면 다음과 같습니다.

int a = 42;
int b = 43;
// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok
// a * b is an rvalue:
int c = a * b; // ok, 우측값이 대입에서 오른쪽에 있음
a * b = 42; // error, 우측값이 대입식 왼쪽에 있음

C++에서도 위와 같이 생각할 수 있는데, C++에서는 여러가지 사용자 정의 타입 때문에 C에서의 정의가 완벽하게 들어맞지는 않습니다. 따라서 C++에서는 조금 다른 방법의 정의를 하는데, 다음과 같이 정의됩니다.

좌측값은 어떠한 메모리 위치를 가리키는데, & 연산자를 통해 그 위치를 참조할 수 있다.
우측값은 좌측값이 아닌 것들이다.

예를 들면 다음과 같습니다.

// 좌측값:
int i = 42;
i = 43; // ok, i는 좌측값
int* p = &i; // ok, i는 좌측값
int& foo();
foo() = 42; // ok, foo()는 좌측값
int* p1 = &foo(); // ok, foo()는 좌측값
// 우측값:
int foobar();
int j = 0;
j = foobar(); // ok, foobar()는 우측값
int* p2 = &foobar(); // error, 우측값의 주소는 참조불가
j = 42; // ok, 42는 우측값

 

우리에게 익숙한, 자주 다루어왔던 레퍼런스는 '좌측값'에만 레퍼런스를 가질 수 있습니다.

int a;	// 좌측값
int& l_a = a;	// l_a는 좌측값 레퍼런스
int& r_b = 3;	// error, 3은 우측값

 

이처럼 & 하나만 사용해서 정의하는 레퍼런스를 좌측값 레퍼런스(lvalue reference)라고 부르고, 좌측값 레퍼런스 자체도 좌측값이 됩니다.

 

다른 예제를 살펴보겠습니다.

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

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

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

위 코드를 컴파일하면, 11, 12 line에서 에러가 발생합니다.

우선 func1 함수는 좌측값 레퍼런스를 리턴합니다. 그리고 좌측값 레퍼런스는 그 자체로 좌측값이기 때문에

func1(a) = 4;

위 코드에서 func1(a)가 리턴하는 레퍼런스의 값을 4로 변경하게 되고, 실제로 변수 a의 값이 4로 변경됩니다. 또한, func1(a)가 좌측값 레퍼런스를 리턴하기 때문에 그 리턴값의 주소값 역시 취할 수 있습니다.

 

하지만 func2 함수는 레퍼런스가 아닌 일반적인 int 값을 리턴하고 있습니다.

a = func2(b);

이때 리턴되는 값은 문장이 실행될 때 잠시 존재하며, 문장 실행이 끝나면 삭제가 되는 실체가 없는 값입니다.

따라서 func2(b)는 우측값이 됩니다. 우측값은 위와 같이 표현식의 오른쪽에 오는 것은 가능하지만,

func2(b) = 5; // error

위와 같이 우측값이 왼쪽에 오는 경우는 불가능합니다. 또한 실체가 없기 때문에 우측값의 주소값 또한 취할 수 없습니다.

 

다시 MyString 예제로 돌아와서,

MyString str3 = str1 + str2;

위 문장은

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

와 동일합니다. 하지만, operator+의 정의를 살펴보면, 우측값을 리턴하고 있습니다.

MyString MyString::operator+(const MyString& rhs);

이렇게 우측값을 리턴하지만 좌측값 레퍼런스를 인자로 받는,

MyString(const MyString& str);

MyString 복사 생성자를 호출하고 있습니다. 이는 &가 좌측값 레퍼런스를 의미하지만, 예외적으로 const T& 의 타입에 한해서만 우측값도 레퍼런스로 받을 수 있기 때문입니다. 이것은 const 레퍼런스이기 때문에 임시로 존재하는 객체의 값을 참조만할 뿐 변경할 수 없기 때문입니다.


우측값 레퍼런스

그렇다면 MyString 예제에서 발생한, 쓸데없는 복사가 일어나는 문제를 해결하려면 생성자가 어떻게 동작해야 할까요?

이는 매우 간단합니다.

operator+ 함수에서 리턴된 (str1 + str2)의 임시 객체의 string_content가 가리키는 문자열 주소값을 str3의 string_content가 가리키도록 하면 됩니다.

즉, 임시 객체에서 생성된 문자열 주소값을 str3의 string_content가 가리키게 되는 것이죠.

 

문제는 이렇게 하게 되면, 임시 객체가 소멸 시에 string_content를 메모리에서 해제하게 되는데, 그렇게 되면 str3의 string_content가 가리키고 있던 문자열이 메모리에서 소멸되게 됩니다. 따라서 이를 방지하기 위해서는 임시 생성된 객체의 string_content를 nullptr로 바꿔주고, 소멸자에서 임시 객체의 string_content가 nullptr이면 소멸하지 않도록 해주면 됩니다.

 

하지만, 이 방법은 기존의 복사 생성자(좌측값 레퍼런스를 인자로 받는)에서는 사용할 수 없습니다. 그 이유는 인자를 const MyString& 타입으로 받았기 때문입니다. 즉, const 이므로 임시 객체의 string_content 값을 수정할 수 없습니다.

이와 같은 문제가 발생한 이유는 const MyString& 타입이 좌측값과 우측값 모두 받을 수 있다는 점에서 발생합니다.

따라서 C++11 부터 제공하는 우측값 레퍼런스를 사용하면 좌측값말고 우측값만 특이적으로 받을 수 있고, 위 문제를 해결할 수 있습니다.


이동 생성자

기존의 MyString 클래스에 아래의 이동 생성자를 추가하고, 구현한 뒤에 다시 실행시켜보겠습니다.

class MyString {
...

public:
	...
    MyString(MyString&& str);	// 이동생성자
    
    ...
}

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

	// 임시 객체 메모리 소멸 방지
	str.string_content = nullptr;
}

위 코드를 추가하고, 아래 코드를 컴파일 후 실행하면,

int main(void) {
	MyString str1("abc");
	MyString str2("def");
	std::cout << "----------------\n";

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

다음의 출력 결과를 확인할 수 있습니다.

아까와는 다르게 복사 생성자가 아닌 이동 생성자가 호출되었습니다.

 

우측값 레퍼런스를 사용한 이동생성자를 살펴봅시다.

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

	// 임시 객체 메모리 소멸 방지
	str.string_content = nullptr;
}

우측값 레퍼런스를 정의하기 위해서 좌측값과는 달리 &를 두 개 사용해서 정의합니다. 위 생성자의 경우에는 MyString 타입의 우측값을 인자로 받고 있는 것입니다.

여기서 주의해야 할 점은 위 생성자에서 str은 좌측값이라는 점입니다. str의 타입이 우측값 레퍼런스이지만, 실체가 존재하기 때문에 주소를 취할 수도 있습니다. '좌측값은 & 연산자로 주소값을 취할 수 있는 것'이라는 정의에 따라서 str은 좌측값이며, str은 타입이 'MyString의 우측값 레퍼런스'인 좌측값이라고 볼 수 있습니다. 따라서 위 함수 마지막 줄처럼 표현식의 좌측에도 올 수 있습니다.

 

이제 이 함수를 통해서 우리는 임시 객체의 string_content가 가리키는 메모리를 새로 생성되는 객체의 string_content가 가리키게만 하면됩니다. 기존의 복사 생성자의 경우에는 새로운 공간을 할당하고 문자열 전체의 값을 복사했지만, 이동 생성자의 경우에는 단순히 주소값만 복사해주면 끝이기 때문에 매우 간단합니다.

// 임시 객체 메모리 소멸 방지
str.string_content = nullptr;

한 가지 중요한 부분은 인자로 받은 임시 객체가 소멸되면서 자신이 가리키고 있던 문자열을 delete 하지 못하게 막아야 합니다. 만약 이 임시 객체의 문자열일 지우게 되면, 새롭게 생성된 문자열 str3의 string_content도 같은 메모리를 가리키고 있기 때문에 str3의 문자열도 사라지게 됩니다.

 

따라서, 임시 객체의 string_content가 nullptr을 가리키게 되고, 소멸될 때 소멸자를 호출하는데 nullptr을 delete 하지 못하도록 소멸자도 아래처럼 변경해주어야 합니다.

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

 

참고로 우측값 레퍼런스의 경우에는 반드시 우측값의 레퍼런스만 가능합니다.

그리고, 우측값 레퍼런스는 레퍼런스하는 임시 객체가 소멸되지 않도록 붙들 수 있다는 특징이 있습니다.

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

따라서 위 코드의 경우, str1+str2에서 리턴되는 임시 객체를 str3이 우측값 레퍼런스로 받으면서 임시 객체가 소멸되지 않도록 합니다.


이동 생성자를 STL 컨테이너에서 사용할 때 주의할 점

위에서 이동 생성자를 구현한 MyString을 C++ STL 컨테이너에서 사용할 때는 주의할 점이 있는데, 바로 이동 생성자를 반드시 noexcept로 명시해야 한다는 것입니다.

 

vector를 예로 들어서 생각해보면, vector는 새로운 원소를 추가할 때, 할당해놓은 메모리가 부족하면 새로운 메모리를 할당한 후에 기존에 있던 원소들을 새로운 메모리로 옮기게 됩니다.

 

복사 생성자만을 사용하였을 경우에는 vector에 있는 원소가 하나씩 복사되는데, 만약 복사 생성하는 과정에서 예외가 발생한다고 가정해봅시다. 이 경우에는 새로 할당한 메모리를 소멸시켜 버린 후에 사용자에게 예외를 전달하면 해결됩니다. 새로 할당한 메모리를 소멸시켜 버리는 과정에서 이미 복사된 원소들도 소멸되므로 리소스가 낭비되는 일도 없습니다.

 

반면에 이동 생성자를 사용한 경우에는 예외가 발생하면 꽤 골치아파집니다. 복사 생성을 하였을 경우 새로 할당한 메모리를 소멸시켜 버려도, 복사하던 기존의 메모리에 원소들이 그대로 존재하기 때문에 상관이 없습니다.

하지만, 이동 생성의 경우에는 기존의 메모리들이 전부 새로운 메모리로 이동되어서 사라졌기 때문에, 새로 할당한 메모리를 섣불리 해제해버릴 수 없습니다.

 

따라서 vector의 경우 이동 생성자에서 예외가 발생하였을 때 이를 제대로 처리할 수 없으며, 이는 다른 C++ STL 컨테이너들도 동일합니다. 이 때문에 vector는 이동 생성자가 noexcept가 아닌 이상 이동 생성자를 사용하지 않습니다.

 

아래의 예제 코드를 통해 살펴보겠습니다.

int main(void) {
	MyString s("abc");
	std::vector<MyString> v;
	v.resize(0);

	std::cout << "----- 첫 번째 원소 추가 -----\n";
	v.push_back(s);
	std::cout << "----- 두 번째 원소 추가 -----\n";
	v.push_back(s);
	std::cout << "----- 세 번째 원소 추가 -----\n";
	v.push_back(s);

	return 0;
}

위 코드를 실행시키면, 아래의 결과를 확인할 수 있습니다.

이처럼 이동 생성자를 만들었지만, vector가 확장할 때마다 복사 생성자를 이용하는 것을 볼 수 있습니다.

하지만, 이동 생성자에 noexcept를 추가하면 달라집니다.

class MyString {
...

public:
	...
    MyString(MyString&& str) noexcept;	// 이동생성자
    
    ...
}

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

	// 임시 객체 메모리 소멸 방지
	str.string_content = nullptr;
}

위와 같이 수정하고 다시 실행해보면,

위의 결과를 확인할 수 있습니다.

 

복사 생성자와 이동 생성자에 어떠한 문자열이 복사/이동 되었는지 살펴보기 위해서 아래와 같이 변경하고,

MyString::MyString(const MyString& str) {
	std::cout << "복사 생성자 호출 : ";
	for (int i = 0; i < str.string_length; i++)
		std::cout << str.string_content[i];
	std::cout << "\n";
	string_length = str.string_length;
	memory_capacity = str.memory_capacity;
	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) noexcept {
	std::cout << "이동 생성자 호출 : ";
	for (int i = 0; i < str.string_length; i++)
		std::cout << str.string_content[i];
	std::cout << "\n";
	string_length = str.string_length;
	string_content = str.string_content;
	memory_capacity = str.memory_capacity;

	// 임시 객체 메모리 소멸 방지
	str.string_content = nullptr;
}

아래의 코드를 실행시키면,

int main(void) {
	MyString s1("abc");
	MyString s2("def");
	MyString s3("ghi");
	std::vector<MyString> v;
	v.resize(0);

	std::cout << "----- 첫 번째 원소 추가 -----\n";
	v.push_back(s1);
	std::cout << "----- 두 번째 원소 추가 -----\n";
	v.push_back(s2);
	std::cout << "----- 세 번째 원소 추가 -----\n";
	v.push_back(s3);

	return 0;
}

다음의 결과를 확인할 수 있습니다.

없던 원소가 추가될 때는 복사 생성자가 호출되어 복사가 수행되고, vector의 공간이 모자라서 새로운 메모리를 할당하고 존재하던 원소는 이동 생성자로 이동시키고, 추가되는 원소는 복사 생성자로 복사하는 것을 확인할 수 있습니다.

 

이상으로 우측값에 대해서 알아보았습니다. 다음 글에서는 C++11에서 우측값 레퍼런스와 함께 새로 추가된 std::move(move semantics)와 Perfect Forwarding에 대해서 알아보겠습니다.

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Perfect Forwarding  (0) 2021.08.13
[C++] Move Semantics  (0) 2021.08.12
[C++] 생산자(Producer) / 소비자(Consumer) 패턴  (0) 2021.08.09
[C++] mutex  (0) 2021.08.07
[C++] thread  (0) 2021.08.07

댓글