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

[C++] Move Semantics

by 별준 2021. 8. 12.

References

Contents

  • Move Semantics(std::move)

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

 

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

References 씹어먹는 C++ (https://modoocode.com/227) http://thbecker.net/articles/rvalue_references/section_07.html Contents 복사 생략(Copy Elision) 좌측값(lvalue)와 우측값(rvalue) 우측값 레퍼런스(rv..

junstar92.tistory.com

지난 글에서 우측값 참조에 대해서 알아보았고, 우측값 참조를 통해 기존에는 불가능했던 우측값에 대한 복사가 아닌 이동(move)의 구현을 살펴봤습니다.

 

이번에는 좌측값도 이동(move)을 시키는 방법에 대해서 알아볼 예정입니다.

 

먼저 기본적인 swap 함수를 예시로 시작해보겠습니다.

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

위의 swap 함수에서 tmp라는 임시 객체를 생성한 뒤에, b를 a에 복사하고 b에는 a가 복사됩니다.

여기서 문제는 복사를 쓸데없이 3번이나 한다는 점입니다. 

 

예를 들기 위해서 이전 글에서 사용한 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&& str) noexcept; 	// 이동생성자

	~MyString();

	MyString operator+(const MyString& rhs);
	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 << "복사 생성자 호출 : ";
	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;
}

MyString::~MyString() { 
	if(string_content)
		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;
}

MyString& MyString::operator=(const MyString& rhs) {
	std::cout << "복사\n";

	if (rhs.string_length > memory_capacity) {
		delete[] string_content;
		string_content = new char[rhs.string_length];
		memory_capacity = rhs.string_length;
	}

	string_length = rhs.string_length;
	for (int i = 0; i < string_length; i++)
		string_content[i] = rhs.string_content[i];

	return *this;
}

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

대입 연산자를 사용하기 위해서 아래의 함수가 추가되었습니다.

MyString& MyString::operator=(const MyString& rhs) {
	std::cout << "복사\n";

	if (rhs.string_length > memory_capacity) {
		delete[] string_content;
		string_content = new char[rhs.string_length];
		memory_capacity = rhs.string_length;
	}

	string_length = rhs.string_length;
	for (int i = 0; i < string_length; i++)
		string_content[i] = rhs.string_content[i];

	return *this;
}

 

그리고, 다음 코드를 실행시켜 봅시다.

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

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

	std::cout << "----- Swap 후 -----\n";
	swap(str1, str2);
	str1.println();
	str2.println();

	return 0;
}

컴파일 후 실행시키면 위의 출력 결과를 확인할 수 있습니다. swap 과정에서 총 3번의 복사가 발생했습니다.

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

swap 함수를 살펴보면, 첫 번째 줄에서 a가 좌측값이기 때문에 MyString의 복사 생성자가 호출됩니다. 따라서 a가 차지하는 공간을 할당하고 a의 데이터가 복사되면서 첫 번째 복사가 발생합니다.

두 번째 줄에서 b의 데이터를 a로 대입하면서 대입 연산자가 호출되고 두 번째 복사가 발생합니다.

그리고 마지막으로 세 번째 줄에서 a의 데이터를 복사했던 tmp의 데이터를 b로 대입하면서 세 번째 복사가 발생하게 됩니다. 

이처럼 swap을 하기 위해서 문자열 전체 복사를 총 3번이나 하는 상황이 발생한 것입니다.

 

하지만, 지난 글에서 봤던 이동 생성자처럼 굳이 문자열 내용을 복사할 필요없이 a, b의 string_content의 주소값만 서로 바꿔주면 됩니다. 물론 string_length과 memory_capacity 의 값도 바꿔야하지만, 단순한 4바이트 int의 복사이기 때문에 속도에 크게 영향을 주지는 않습니다.

 

주소값만 서로 바꿔주는 형태로 swap을 구현하기 위해서는 여러가지 문제가 존재합니다. 일단 swap 함수는 임의의 타입을 받는 Generic 함수입니다. 따라서 위의 swap 함수는 일반적인 타입 T에 대해 작동을 해야합니다. 하지만 문자열을 가리키는 데이터인 string_content의 경우 MyString에만 존재하는 필드이므로 일반적인 타입 T에 대해서 작동하지 않습니다.

 

물론 아예 불가능한 것은 아니고 아래처럼 템플릿 특수화를 사용하면 되긴 합니다.

template <>
void swap(MyString& a, MyString& b) {
	//...
}

한 가지 문제는 string_content가 private이기 때문에, string_content에 접근하기 위해서 MyString 내부에 swap에 관련된 함수를 만들어주어야 한다는 것입니다. 사실 이정도까지 온다면 굳이 swap 함수를 따로 정의할 필요가 없습니다.

 

위 문제를 원래의 swap 함수만을 사용해서 조금 더 깔끔한 방법은 무엇이 있을까요?

다시 swap 함수로 돌아와서 살펴보면, 쓸데없는 복사를 하지 않기 위해서 함수 내부에서 복사 생성자 대신에 이동 생성자가 호출되기를 원합니다.

T tmp(a);

위 코드를 살펴보면, tmp를 복사 생성할 필요없이 a를 잠시 옮겨놓고 보관만하면 되기 때문입니다.

하지만 문제는 a가 좌측값(lvalue)라는 점입니다. 따라서 이 상태에서는 무엇을 하더라도 이동 생성자로 오버로딩되지 않습니다.


Move Semantics (move 함수)

C++11부터 <utility> 라이브러리에서 좌측값을 우측값으로 바꾸어주는 move 함수를 제공하고 있습니다.

어떻게 사용되는지 간단한 std::move 예시부터 살펴보겠습니다.

#include <iostream>
#include <utility>

class A {
public:
	A() { std::cout << "일반 생성자 호출\n"; }
	A(const A& a) { std::cout << "복사 생성자 호출\n"; }
	A(A&& a) { std::cout << "이동 생성자 호출\n"; }
};

int main(void) {
	A a;

	std::cout << "-----------------------\n";
	A b(a);

	std::cout << "-----------------------\n";
	A c(std::move(a));

	return 0;
}

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

주목할 부분은 아래의 코드인데,

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

이동 생성자가 호출이 되었습니다. 그 이유는 std::move 함수가 인자로 받은 객체를 우측값으로 변환하여 리턴해주기 때문입니다. 이처럼 std::move 함수는 이름만 보면 무언가 이동을 할 것 같지만, 실제로는 단순히 우측값으로 타입 변환만 수행합니다.

 

std::move 때문에 강제적으로 우측값 레퍼런스를 인자로 받는 이동 생성자를 호출할 수 있습니다. 이 아이디어를 바탕으로 MyString에 한 번 적용해보겠습니다.

 

먼저 swap 함수는 아래처럼 변경해야 합니다.

void swap(T& a, T& b) {
	T tmp(std::move(a));
	a = std::move(b);
	b = std::move(tmp);
}

복사가 아닌 이동 생성자가 호출되기 위해서 std::move를 사용해서 tmp에 a를, a에 b를, 다시 b에 tmp를 이동시켰습니다. 이동 생성이기 때문에 기존에 복사 생성보다 훨씬 빠르게 수행됩니다.

 

하지만 이것만으로는 부족합니다. 아무리 swap 함수에 std::move를 사용하도록 변경했지만, MyString에는 우측값 레퍼런스에 대한 이동 대입 연산자가 정의되어 있지 않기 때문에, swap 함수를 변경시키고 실행시켜봐야 

MyString& operator=(const MyString& rhs);

위 함수가 오버로딩 됩니다.

 

따라서 우측값 레퍼런스에 대해서 이동을 위해 아래처럼 이동 대입 연산자를 정의해주어야 합니다.

class MyString {
...
public:
	MyString& operator=(MyString&& rhs);
}

MyString& MyString::operator=(MyString&& rhs) {
	std::cout << "이동\n";
	string_content = rhs.string_content;
	memory_capacity = rhs.memory_capacity;
	string_length = rhs.string_length;

	rhs.string_content = nullptr;
	rhs.memory_capacity = 0;
	rhs.string_length = 0;

	return *this;
}

이동 대입 연산자는 이동 생성자처럼 매우 간단합니다. 전체 문자열을 복사할 필요없이 기존의 문자열을 가리키고 있던 string_content만 복사하면 됩니다.

 

따라서 아래 코드를 다시 실행시키면,

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

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

	std::cout << "----- Swap 후 -----\n";
	swap(str1, str2);
	str1.println();
	str2.println();

	return 0;
}

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

여기서 실제로 데이터가 이동되는 과정은 위와 같이 이동 생성자나 이동 대입 연산자를 호출할 때 수행되고, std::move한 시점이 아니라는 것입니다. 만약 이동 대입 연산자를 정의하지 않았다면, 일반적인 대입 연산자가 오버로딩되어 이동이 아닌 일반적인 복사가 이루어집니다. (이 경우에는 MyString의 이동 생성자까지 삭제하고 실행시켜야 에러가 발생하지 않습니다. 이동 대입 연산자만 삭제하고 실행시켜보면, a의 데이터는 tmp로 정상적으로 이동하여 string_content가 nullptr을 가리키지만, 다음 a = std::move(b); 에서 b를 a로 복사하는 작업이 이루어지면서 nullptr인 a의 string_content에 b의 string_content의 값을 복사하기 때문에 잘못된 주소값을 참조하게 됩니다.)

 

따라서, 이동 생성자와 이동 대입 연사자를 모두 삭제하고 실행해보면 다음의 결과를 확인할 수 있습니다.

 

여기까지 Move Semantics과 관련된 내용이었습니다.

다음글에서는 우측값 참조를 도입함으로써 해결할 수 있었던 Perfect Forwarding에 대해서 알아보겠습니다.

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

[C++] 비동기(Asynchronous) 실행  (0) 2021.08.14
[C++] Perfect Forwarding  (0) 2021.08.13
[C++] 우측값 참조(rvalue reference)  (0) 2021.08.11
[C++] 생산자(Producer) / 소비자(Consumer) 패턴  (0) 2021.08.09
[C++] mutex  (0) 2021.08.07

댓글