[Cpp] Move Sementics

9 분 소요

이번 포스팅에서는 Aideo 앱 개발 과정에 NDK 개발을 하면서 학습한 cpp의 move sementics 에 대한 내용을 정리해보는 시간을 가져보려 합니다.

java/kotlin 의 경우 primitive types 에 대해서는 스택 공간에 값을 저장할 수 있지만, 그 외의 객체들은 heap 에 저장되고 garbage collection 에 의해서 참조가 끊어진 객체들을 정리 및 compaction 을 수행하게 됩니다.

다만, cppjava/kotlin 과 달리 언어적 수준에서 동적 할당을 하지 않았다면, 지역 변수들은 stack 공간에 쌓이는 함수 영역에 값이 저장됩니다. 그리고 실행 파일이 시작되면서 가장 먼저 실행되는 main() 함수에서 다른 함수들을 호출할 때, 일반적으로 값을 복사해서 전달하게 됩니다. 만약, 스택 공간에 있는 값의 메모리 공간을 가르키는(주소값을 갖는) 포인터나 참조 연산자를 전달한다면, 값의 복사 과정이 일어나지 않습니다.(대신 주소값을 갖는 포인터가 복사됩니다. 이는 32비트 프로세서는 4byte, 64비트 프로세서에서는 8byte 크기를 갖습니다.)

따라서, 객체를 전달하는 경우에는 복사 과정에 메모리 공간의 크기가 기본타입(int, char, long 등) 에 비해 크기 때문에 복사 오버헤드를 줄이기 위해, 포인터(*)나 참조(&)를 함수의 인자로 사용하는 것이 바람직합니다.


class A {
	char* c;

public:
	A();
	A(const A& a); // 참조로 전달되는 a 를 복사하지 않습니다. 다만, 내부의 상태(char* c) 를 위한 깊은 복사가 필요합니다.
	~A();
}

A::A(const A& a) {
	int len = strlen(a.c);
	c = new char[len];

	for(int i = 0; i < len; ++i) {
		c[i] = a.c[i];
	}
}

int main() {
	A a;
	A b(a); // 객체 a 의 복사 생성자가 호출됩니다. 새로운 객체(b)를 생성하면서, 인자로 전달된 a 의 상태를 복사합니다.
}

위 예제에서 A 클래스의 복사 생성자가 호출되면서, 레퍼런스 타입으로 인해 인자가 전달되는 과정에 복사가 일어나지는 않습니다. 그러나 내부의 상태값에 대해서 깊은 복사를 위해 동적할당과 순회를 동반한 복사 과정이 필요합니다. 이 경우 c의 길이가 길수록, 또 복사 생성자 호출이 잦을수록 성능 오버헤드가 발생하게 됩니다.

예를들어, 시퀀스 컨테이너의 Vector에 A 객체를 원소로 저장한다고 생각해 보겠습니다.


int main() {
	vector<A> a;
	a.resize(0);
	
	A b("abc");

	a.push_back(b); // 복사 생성자에 의해 복사가 일어납니다.
}

위의 상황에서는 A 객체인 b 를 생성하고, 벡터 a 에 추가하는 과정에서 b 원소에 대해 복사 생성자가 호출됩니다.(깊은 복사 발생) 또한, 벡터 a의 크기를 0으로 만들었기 때문에, 원소를 삽입하기 위해 새로운 공간을 위한 할당이 필요합니다. 그리고 연이은 원소 삽입에서 같은 동작이 반복됩니다.

만약, 위와 같은 상황에서 복사 과정을 생략하고, 참조를 단순히 넘길 수만 있다면 성능을 개선할 수 있지 않을까요?

Move Sementics


class A {
	char* c;

public:
	A();
	A(const char* ch);
	A(const A& a); // 복사 생성자
	A(A&& a); // 이동 생성자(우측값 레퍼런스를 인자로 받습니다.)
	~A();
}

A::A(A&& a) {
	c = a.c; // a 가 소유한 데이터 c 를 이양합니다. 우측값 레퍼런스 타입이기 때문에 단순히 대입 연산자로 가능합니다.
	a.c = nullptr; // 그리고 a 가 가졌던 c 에 대한 참조를 끊어버립니다.
}

int main() {
	vector<A> a;
	a.resize(0);
	
	A b("def");

	a.push_back(A("abc")); // 이동 생성자가 있는 경우, 이동이 일어납니다.
	a.push_back(b); // 복사 생성자에 의해 복사가 일어납니다.
}

Cpp 에서는 위의 상황을 개선하기 위해 Move 개념을 언어적 수준에서 제공합니다. Move 는 복사 대신 이동을 통해 성능적 오버헤드를 줄이는 것을 의미합니다. 여기서 “이동” 이라는 것은 메모리 공간상에 생성된 자원에 대한 소유권을 넘기는 것을 의미합니다. 구체적으로 객체가 소유하고 있는 상태(값)들의 소유권을 대상 객체에게 이양하고, 본인의 상태는 기본값 혹은 nullptr 로 변환함으로써 자원에 대한 권리를 이동시키는 것이 이동 의미론의 핵심입니다.

객체의 복사 대신 이동을 사용하기 위해서는 우측값 레퍼런스 타입 인자를 가지는 이동 생성자 또는 이동 대입 연산자를 정의해야 하고, 해당 함수들에 우측값(rvalue 라고 부릅니다.) 을 전달해야만 합니다.

중요한 것은 클래스가 아닌 기본 타입은 “이동” 으로 얻는 효과가 없습니다. 이 때는 단순히 값을 초기화 혹은 복사하는 것과 다를바 없으며, 객체가 가지는 상태들이 모두 기본값일 때도 마찬가지 입니다.

즉, 위 예제의 상황에서 첫번째로 원소를 추가하는 문장 처럼 우측값을 함수의 인자로 전달하는 경우에만 이동 생성자를 이용할 수 있습니다. 두번째로 원소를 추가하는 예시(b 객체의 값을 벡터 a에 원소로 추가)는 좌측값을 넘기기 때문에, 이 때는 복사 생성자가 호출됩니다.

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

cpp 에서는 표현식이 TypeValue Category 라는 두가지 특성을 기반으로 평가됩니다. Type 은 int, double, int*, int& 와 같은 타입을 말하고, Value Category 는 lvalue, rvalue, xvalue 와 같은 값의 종류를 말합니다. 즉, 하나의 표현식을 결정하기 위한 근거가 두가지 입니다. 컴파일러는 이 두가지 특성을 기반으로 표현식을 평가합니다.

lvalue 는 한글로는 ‘좌측값’ 이라고 부르며, 정체성을 갖지만 이동할 수 없는 특징을 갖습니다. 정체성 이란 자원을 소유할 수 있는 메모리 공간을 갖는 값을 말하고(그래서 주소값 연산 & 을 할 수 있는 값이라고도 합니다.), 이동은 앞서 설명한 바와 같이 메모리 공간상에 있는 자원의 소유권을 넘길 수 있는 것을 의미합니다. 또한, 좌측값은 표현식에서 연산자의 좌측 또는 우측에 올 수 있다는 특징이 있습니다.

이와 달리 rvalue 는 ‘우측값’ 이라 부르며, 정체성을 갖지는 않지만 이동할 수 있는 값을 뜻합니다. 좌측값과 달리 우측값은 표현식에서 연산자의 오른쪽에만 올 수 있습니다. 즉, 좌측값을 초기화 하는 목적으로 임시로 생성된 객체의 값을 전달하는 것을 대표적 예시로 생각할 수 있습니다. 이 경우 표현식이 ; 로 끝나면, 우측값은 더 이상 유효하지 않게 되고, 참조할 수 없는 값이 됩니다.


int a = 10; // a는 lvalue, 10은 rvalue
int b = a; // b는 lvalue

위 예제의 a 변수는 메모리 공간상에 값을 갖기 때문에 정체성이 있지만, a 의 값을 다른 변수에 이동할 수 없고 복사만 가능하기 때문에 lvalue(좌측값) 에 해당합니다. 이와 달리 a 에 대입되는 ‘10’ 은 ; 로 문장의 끝 이후 부터는 유효하지 않는 임시값이기 때문에 정체성이 없으므로 rvalue(우측값)에 해당합니다. 이 경우 단순히 변수 a가 메모리 공간상에 생성되면서, 값이 10으로 초기화되기만 합니다.

레퍼런스

레퍼런스는 한글로 ‘별명’ 타입이라고 부르며, c 에서의 포인터를 대체하기 위한 용도로 사용됩니다.


void ref(int* c) {
	//TODO *c 로 값을 또 가져올 수 있습니다.
}

int main() {

	int a = 10;
	int* b = &a;

	cout << *b;

	ref(&a);
}

포인터는 주소값을 담는 변수로, 포인터가 참조하는 원본값을 가져오기 위해서 * 연산자를 써야 합니다. 또, 함수의 인자로 전달하기 위해서는 주소값을 가져오는 & 연산자를 함수 호출문에 쓰고, 함수의 인자에 * 연산자를 써야 합니다. 만약, 함수 내에서 포인터가 참조하는 값을 가져오려면 다시 * 연산자를 붙여야 하죠. 이런 불편함을 개선하기 위해 C++ 에서는 ‘레퍼런스’ 타입을 이용합니다.


void ref(int& c) {
	//TODO
}

int main() {

	int a = 10;
	int& b = a; // 선언 과 동시에 초기화 되어야 합니다.

	int c = 20;
	b = c; // 참조 대상을 바꾸지 않고, b 가 참조하는 a 의 값을 c 가 가진 20 으로 바꿉니다.

	b = 20; // a 가 20의 값으로 초기화됩니다.

	int& c = 10; // 틀린 표현입니다. 좌측값 레퍼런스는 우측값을 할당받을 수 없습니다.
	const int& c = 10; // 상수 레퍼런스에는 우측값을 할당할 수 있습니다.

	cout << a;

	ref(a);
}

레퍼런스 타입을 사용하면 더 이상 C 의 포인터 처럼 사용하지 않아도 됩니다. 단순히 타입과 함께 & 연산자를 이용하면 원본 값을 참조하는 변수를 선언할 수 있습니다. 다만, 레퍼런스 타입 변수는 선언과 동시에 초기화 되어야 하며, 값을 변화시키면 레퍼런스가 참조하는 원본 변수의 값을 변화시키는 것과 같습니다.(그래서 별명이라고 불립니다.)

또한, 한번 초기화 된 이후에는 다른 변수를 참조할 수 없으며, 우측값을 참조할 수도 없습니다. 우측값은 ; 으로 표현식이 종료되기 직전에 더 이상 유효하지 않도록 바뀌기 때문에 이를 참조하는 것을 언어적 수준에서 금지합니다. 다만, 상수 레퍼런스의 경우 우측값을 대입할 수 있고, 레퍼런스의 생명주기까지 대입된 우측값의 수명이 연장됩니다.

앞서 우측값을 통해서만 이동 의미론을 이용할 수 있다고 언급했었습니다. 이동은 이동 생성자나 연산자와 같은 함수로 전달할 때 이용 가능한다고 했었는데, 우측값이 레퍼런스 타입 인자에 대입될 수 없다면, 이게 어떻게 가능할 수 있을까요?

우측값 레퍼런스

바로 우측값만 받을 수 있는 우측값 레퍼런스를 통해 가능합니다. 앞서 설명드린 레퍼런스는 lvalue reference(좌측값 레퍼런스) 이고, 좌측값만 대입 가능한 lvalue 입니다. 우측값을 참조하기 위해서는 우측값 레퍼런스 타입을 이용해야 합니다.

우측값 레퍼런스는 좌측값 레퍼런스에서 & 연산자를 하나만 더 붙이면 됩니다.


void ref(int&& a) {
	wrapper(a); // 오류가 발생합니다. lvalue 는 우측값 레퍼런스로 전달할 수 없습니다.
}

void wrapper(int&& w) {
	//TODO
}

int main() {

	ref(10); // 이제 우측값을 인자로 전달할 수 있습니다.

	int b = 5;
	ref(b); // 틀린 표현식 입니다. 좌측값은 전달할 수 없습니다.

}

ref 함수가 int&& 타입이 되면서, 함수 호출에 우측값을 전달할 수 있게 되었지만, 좌측값은 전달할 수 없습니다. 또한, ref 함수 내에서 wrapper() 함수로 전달하는 a 는 타입이 int&& 이지만, 그 자체로 정체성(이름)이 있는 lvalue 이기 때문에 wrapper() 함수의 인자로 전달할 수 없습니다.

이제 다시 되돌아가서 원래의 목적을 복기해 봅시다. cppjava/kotlin 과 달리 함수로 값을 전달할 때 복사가 일어난다고 했었습니다. java/kotlin 은 객체 생성시 heap 공간에 생성하고, 참조를 복사하여 전달하지만 cpp 은 stack 공간에 생성된 객체를 복사하여 전달하기 때문에 객체의 크기가 크고, 내부 상태에 대해 동적 할당이 있는 경우 복사는 성능 오버헤드를 야기할 수 있습니다.

따라서, 우측값 레퍼런스 타입 인자를 받는 이동 생성자나 이동 대입 연산자를 오버로딩하여, 값을 전달하기 위한 목적으로 복사 대신 이동을 사용함으로써 성능을 개선할 수 있습니다.


class A {
	char* c;

public:
	A();
	A(const char* ch);
	A(const A& a); // 복사 생성자
	A& operator=(const A& a); // 복사 대입 연산자
	~A();
}

void swap(A& a, A& b) {
	A tmp(a); // 복사 생성자 호출
	a = b; // 복사 대입 연산자 호출
	b = tmp; // 복사 대입 연산자 호출
}

int main() {
	A a("abc");
	A b("def");
	swap(a, b);
}

a, b 두 인자를 받아 서로 값을 교환하는 간단한 swap() 함수를 예제로 살펴보겠습니다. 단순히 a와 b 인자를 좌측값 레퍼런스로 받아오기 때문에 swap() 함수로 전달할 때 a, b 객체는 복사되지 않습니다. 다만 swap() 함수 내부에서 임시 객체 ‘tmp’ 를 생성하기 위해 복사 생성자가 호출되고, a 에 b 를 대입하는 것과 b 에 tmp 를 대입하는 것 역시 복사 대입 연산자가 호출됩니다.

따라서 총 3번의 복사 과정이 발생하여, 내부에서 3번의 동적 할당과 문자열 복사 과정이 동반됩니다.


class A {
	char* c;

public:
	A();
	A(const char* ch);
	A(const A& a); // 복사 생성자
	A(A&& a); // 이동 생성자(우측값 레퍼런스를 인자로 받습니다.)
	A& operator=(A&& a); // 이동 대입 연산자
	~A();
}

A::A(A&& a) {
	c = a.c; // a 가 소유한 데이터 c 를 이양합니다. 우측값 레퍼런스 타입이기 때문에 단순히 대입 연산자로 가능합니다.
	a.c = nullptr; // 그리고 a 가 가졌던 c 에 대한 참조를 끊어버립니다.
}

A& A::operator=(A&& a) {
	c = a.c;
	a.c = nullptr;
	return *this;
}

void swap(A&& a, A&& b) {
	A tmp(a);
	a = b;
	b = tmp;
}

int main() {
	swap(A("abc"), A("def"));
}

복사 오버헤드 문제를 해결하기 위해 이동 생성자를 생성했습니다. 이동 생성자는 우측값 레퍼런스를 인자의 타입으로 두고, 구현하면 됩니다. 그럼 컴파일러는 타입을 기반으로 이동을 수행합니다.

라고 생각하겠지만, 놓친 부분이 있습니다.

앞서 우측값 레퍼런스 타입 인자로 우측값만 전달이 가능하다고 했습니다. 그리고 컴파일러는 표현식을 타입과 값의 종류를 기반으로 평가한다고 했었습니다.

swap() 함수 인자로 전달된 a, b 는 우측값 레퍼런스 타입은 맞지만, 그 자체로 정체성(이름)이 있기 때문에 우측값으로 평가되지 않습니다. 따라서 내부에서는 여전히 복사생성자가 호출되는 문제가 발생합니다. 이를 해결하기 위해서는 인자를 우측값으로 평가 받도록 std::move() 함수를 이용해야만 합니다.

std::move()

std::move() 함수는 단순히 인자로 전달된 변수를 static_cast<T> 를 이용하여 우측값으로 만듭니다. 이를 토대로 컴파일러는 std::move() 로 감싸진 값을 우측값으로 평가할 수 있게 됩니다.


class A {
	char* c;

public:
	A();
	A(const char* ch);
	A(const A& a); // 복사 생성자
	A(A&& a); // 이동 생성자(우측값 레퍼런스를 인자로 받습니다.)
	A& operator=(A&& a); // 이동 대입 연산자
	~A();
}

A::A(A&& a) {
	c = a.c; // a 가 소유한 데이터 c 를 이양합니다. 우측값 레퍼런스 타입이기 때문에 단순히 대입 연산자로 가능합니다.
	a.c = nullptr; // 그리고 a 가 가졌던 c 에 대한 참조를 끊어버립니다.
}

A& A::operator=(A&& a) {
	c = a.c;
	a.c = nullptr;
	return *this;
}

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

int main() {
	A a("abc");
	A b("def");

	swap(a, b);
}

이제 swap() 함수로 전달 받은 인자를 내부에서 사용할 때 복사 대신 이동 생성자와 이동 대입 연산자가 사용되어, 복사 대신 이동이 수행됩니다.

태그:

카테고리:

업데이트:

댓글남기기