글 작성자: Sowhat_93

C++11에 std::move 그리고 std::forward 가 추가 되었다.

우선 lvalue와 rvalue 의 개념을 이해해야 제대로 사용할 수 있을 것 같아 정리하기로 한다.

 

1. lvalue

lvalue는 이름이 있는 값이다. 그러니까, C++ 코드에서 변수명을 정한 값이 바로 lvalue이다.

글로 표현할수록 오개념이 생길 수 있다.

아래의 아주 간단한 코드를 보도록 하자.

int IamInt = 10;
//IamInt는 lvalue이다.
//이름에서 알 수 있듯 대입시에 좌측에 위치한다.
//IamInt 라는 이름을 가졌기 때문에, 이 곳으로의 값의 복사는
//위 처럼 간단하게 가능하다.

아주 쉽다.

우선은 우리가 아는 variable 의 개념이 lvalue라고 이해하자.

하나의 예시를 더 보도록 하자.

int IamInt	= 10;
int Metoo	= IamInt;
//IamInt라는 이름의 변수는 스택의 어디엔가 존재한다.
//Metoo 또한 마찬가지이다.
//위 코드는 우리가 IamInt라고 이름을 붙인 주소값에서
//값을 읽은다음 그 값을
//Metoo 라고 이름을 붙인 해당 주소로 복사한다.

eax라는 레지스터에, 값을 IamInt라는 주소값의 데이터를 복사한다음,

해당 데이터를 다시 Metoo라고 불리는 주소값에 붙여넣는다.

여기서 두가지만 기억하도록한다.

1. IamInt라고 이름 붙인 것은 비주얼 스튜디오에서 디스어셈블리시에 출력해준것이다.

실제 기계어에는 저 이름이 아무리 찾아도 없다.

C++ 언어는 고급언어, 즉 인간이 이해하고 작성하기 쉽게 만들어진 프로그래밍 언어이기 때문에

우리는 메모리 번지에 이름을 붙일 수 있는 것 뿐이다.

그리고 컴파일러는 개발자의 의도에 맞게 변수이름에 맞는 주소값을 찾아서

기계어로 번역한다.

 

2. 메모리에서 메모리로의 즉각적인 연산은 불가능하다.

여기 까지만 우선 이해하도록 한다.

 

2. rvalue

lvalue를 이해했다면 , 이제 rvalue를 보도록한다.

맨 위의 예제를 다시 보도록 하자.

int IamInt	= 10;
//10 = IamInt; 이건 안된다.
//IamInt라는 주소에서 값을 가져오는 것 까지는 오케이.
//근데 10이라는 값을 지칭하는 공간이 있을까? 없으니까 안된다.
//만들라고 한다면 만들수는 있겠다.
//허나 컴파일러의 생각에서는 이는 비효율적인 일이다.
//기계어 명령으로 바로 용접한다.
//왜 ? 그래도 프로그래머의 의도에 맞게 잘 동작한다.

당연하게도

10 = IamInt; 는 컴파일 될 수 없다. 

이런 value가 바로 rvalue이다.

위에서는 상수의 예시를 들었는데,

절대로! rvalue는 상수구나 이해해서는 안된다.

상수는 rvalue가 맞다. 

허나 rvalue에는 상수 말고도 다른 것 또한 존재한다.

하나씩 추가해가보자 rvalue에는 무엇이 있는지.

방금 예시로 하나는 확실해졌다.

rvalue 에는 기계어로 바로 용접되는 상수가 포함된다.

다른 예시를 더 보도록 하자.

int IamInt	= 10;
int Metoo = IamInt + 85;
//lvalue IamInt의 값을 가져오고, 그 값에 1을 더한다. 
//그리고 그값을 다시 Metoo에 쓴다.
//IamInt + 85는 어디에 있을까?
//어디엔가 있어야 Metoo의 메모리에 던져 골인 시킬 수 있다.
//레지스터에 있다.
//레지스터에 갖고있다가 Metoo 로 그냥 던지면 될걸 왜 따로 공간을 만들겠는가? 
//따라서 다음은 불가능하다.
//IamInt + 85 = 34

역시나 당연하게도 IamInt + 85 = 34 와 같은 경우도 불가능 하다.

왜 그럴까?

IamInt + 85를 어떻게 만들어 낼까? 값은 95이다.

위 코드에서 이 95라는 값을 다시 쓸까? 안 쓴다. 

그러니 컴파일러는 레지스터에 값을 가져오고, 레지스터에 값을 더하고 그리고 그 값을 다시 Metoo에 복사한다.

IamInt + 85를 만드는 방식은 그렇다.

생각해보면 매우 합리적이다.

더한 값을 레지스터에 갖고 있기 떄문에, 별도의 메모리를 사용하지 않는다.

공간은 절약 되었으면

자 그렇다면 C++ 코드에서의 IamInt + 85는 lvalue ? rvalue?

당연히 이것도 rvalue 이다.

자 그럼 또 하나가 추가되었다.

기계어로 바로 용접되는 상수는 rvalue다.

레지스터에 잠깐 존재했다가 사라지는 임시변수 또한 rvalue다.

 

3. lvalue reference, const lvalue reference

이제는 lvalue reference의 이야기를 함수의 파라미터까지 확장해보자.

#include <iostream>

void GiveMeInt(int A)
{
	return;
}

void GiveMeIntRef(int& A)
{
	return;
}

void GiveMeIntConstRef(const int& A)
{
	return;
}

int main()
{
	int IamInt = 10;
	GiveMeInt(10);
	//컴파일 가능.
	//10이라는 값을 단순히 함수내에서 복사한다음 사용하면 Okay.
	
	GiveMeIntRef(10);
	//컴파일 불가능.
	//참조변수이기 때문에 그 메모리에 접근이 가능해야 한다.
	//10이라는 상수는 주소가 어디있나?

	GiveMeIntConstRef(10);
	//컴파일 가능.
	//어짜피 함수내에서 변경 될 수 없으며,
	//기계어로 바꾼 코드에서도 주소에 접근을 안한다.
	//왜? 어짜피 상수이기 때문에. 코드에 용접하면 그만이다.
	
	
}

아주 쉽다.

Const Reference 는 상수를 넣어도 오케이다.

 

Const 가 붙지 않은 Reference는, 상수를 넣었을때 안된다.

이 일반적으로 사용하는 Const가 붙지 않은 &형태를 lvalue reference라고 한다.

이건 lvalue 를 참조할때 밖에 사용하지 못한다.

 

const& 는 const lvalue reference 라고 한다.

const lvalue reference 는 rvalue에 대해서도 참조가 가능하다.

계산의 결과로 레지스터에 쥐고있는 값으로 함수 스택에서 const lvalue reference 를 만들고

(const int& ref = 90) 코드내에서 사용할 수 있고,

또한 컴파일러는 언급했듯이 상수 값을 기계어 코드에 직접 넣으면 그만이기 때문이다.  

 

이제 객체로도 적용해본다.

아래의 예시를 보도록하자.

#include <iostream>
#include <string>

void PrintConstStringRef(const std::string& String)
{
	std::cout << String.c_str() << std::endl;
}

void PrintStringRef(std::string& String)
{
	std::cout << String.c_str() << std::endl;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	std::string HelloWorld = Hello + World;

	PrintStringRef(Hello);
	//lvalue ref 이기 때문에 당연히 lvalue를 전달 가능하다.

	PrintStringRef("Hello");
	//No. Param으로 lvalue Ref를 원한다.
	//"Hello" 로 임시객체를 만들고, 이를 사용하면 되지 않을까?
	//허나 컴파일러는 임시객체를 lvalue ref 로 허용하지 않는다.

	PrintStringRef(Hello + World);
	//No. 마찬가지로 Param 으로 lvalue Ref를 원한다.
	//마찬가지로 Hello + World로 Rvalue 인 임시객체가 만들어진다.
	//컴파일러는 lvalue ref로 임시객체를 허용하지 않는다.

	PrintConstStringRef("Hello");
	//Yes. 생성자의 param인 char* 로 임시객체를 만들고,
	//이 임시객체는 Rvalue다.

	PrintConstStringRef(Hello + World);
	//Yes. 역시나 임시객체인 Rvalue가 만들어진다.
	//const lvalue ref는 Rvalue를 허용한다.

}

 

어느정도 감이 왔을 것이다.

그리고 의문이 생긴다.

int 형은 레지스터에 잠깐 가지고 있을 수 있다.

작기 때문이다.

심지어 value를 기계어에 용접해도 될정도로 작다.

허나, 데이터가 크다면?

 

결론 부터 이야기 하면, 당연히 임시적으로 생성되는 rvalue가 크기가 크다면, 스택에 저장 된다.

함수 Call 시 컴파일러가 알아서 필요한 스택의 크기를 판단하고, 스택의 크기를 알아서할당한다.

임시변수의 크기가 작으면 레지스터에 들어가고, 크면 스택.

경우가 늘어나면 머리가 아플수 있다.

허나 C++을 사용하는 우리의 입장에서는 개념적으로는 별 달라질 것이 없다.

임시변수가 스택에 저장되던, 레지스터에 저장되던, 기계어 코드로 용접이 되던

컴파일러는 C++ 코드를 해석하고 rvalue가 필요하다고 판단되는 시점까지 알아서 rvalue를 살려둔다.

const lvalue reference로 해당 rvalue를 참조한다면?

해당 rvalue가 위치한 스택영역이 정리되지 않을 것이다.

즉 rvalue를 안 죽이고 살려두는 거다.

 

std::string Hello = "Hello";
std::string World = " World!";

std::string HelloWorld = Hello + World;
//이 C++ 코드 라인이 끝나면 Hello + World로 생성된 임시객체는 제거된다고 보면 된다.
//소멸자가 호출될 것이며, 해당 임시객체가 자리를 차지했던 스택영역은 정리될 것이다.

std::cout << (Hello + "World").c_str() << std::endl;
//마찬가지 이다.

const char* pBuffer = (Hello + World).data();
//임시객체인 (Hello + World) 스택영역은 정리될 것이다.
//(Hello + World) 에 대해 소멸자가 호출 된다.
//std::string 소멸자는 동적으로 할당된 메모리에 대한 정리 작업을 하도록
//구현되어 있다.
//따라서 pBuffer에 대한 접근은 undefined behavior이다.

const std::string& ConstRef = (Hello + World);
const char* pBuffer2 = ConstRef.data();
//const lvalue reference로 참조했다.
//이 경우 계속 참조가 가능해야 하므로 
//임시객체인 Hello + World 는 죽지 않는다.

아래 함수 내부에서의 const std::string& String, 즉 파라미터로 들어온 rvalue 는 언제 정리 될까?

 

void PrintConstStringRef(const std::string& String)
{
	std::cout << String.c_str() << std::endl;
}

쉽다.

함수가 끝나자마자 모두 정리된다.

리턴형이 void이다.

함수내부에서의 결과를 Caller는 궁금해하지 않는다.

#include <iostream>
#include <string>

const std::string& RetConstStringRef(const std::string& String)
{
	std::cout << String.c_str() << std::endl;
	return String;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	std::cout << RetConstStringRef(Hello + "World").c_str() << std::endl;
	std::cout << RetConstStringRef(Hello + World).c_str() << std::endl;
	std::cout << RetConstStringRef("Hello" + World).c_str() << std::endl;

	const std::string& AliveRvalue = RetConstStringRef(Hello + "World");
	//rvalue의 life cycle 이 연장된다.

}

 

언뜻보면 우리는 함수의 인자를 rvalue를 사용함에 있어 const lvalue reference로 사용해도 별 문제가 없어보인다.

단 하나, rvalue 를 인자로 전달했을때 함수 내에서 rvalue에 대한 데이터의 조작이 불가능 하다는 것만 빼면.

조작을 원하지 않는다면, 별 문제가 되지 않겠지만

이게 잘 생각해보면 많은 상황에서 상당한 걸림돌이 된다.

 

4. rvalue reference

rvalue reference를 사용할때에는 타입에 &를 두개 붙인다.

이름 그대로 rvalue에 대한 reference를 가능하게 한다.

아래의 예시를 보도록 하자.

#include <iostream>
#include <string>

std::string& GetStringAddress(std::string&& String)
{
	std::cout << "Address Of Param Instance On Function Stack : " << &(String) << std::endl;
	return String;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	std::string&& HelloWorld = (std::string&&)GetStringAddress(Hello + World);
	//rvalue reference를 수행하기를 원한다.
	//Hello + World의 결과 값이 저장된 스택영역에 대해 정리하지 말아라.

	std::cout << "Address Of Rvalue In Stack : " << &HelloWorld << std::endl;

}

const lvalue reference와 마찬가지로 rvalue의 생명주기가 연장되게 된다.

왜 ? 참조하고 있는 이가 있으니까.

뭐 이건 너무 당연한 이야기 이다.

 

사실 rvalue reference가 정말로 유용하게 사용되는 때는 함수의 파라미터 일때이다.

#include <iostream>
#include <string>
std::string& RetRValueStringRef(std::string&& String)
{
	//Rvalue Reference 의 형태로 param 정의.
	String = "It is not Hello World Now.";
	//param이 rvalue reference의 형태이기 때문에 마음대로 바꿀 수 있다.

	//std::string&&의 경우 
	//const 가 붙지 않은 lvalue refenrece로 리턴이 가능하다. 
	return String;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	std::cout << RetRValueStringRef(Hello + " World!")	  <<std::endl;
	std::cout << RetRValueStringRef("Hello " + World)	  <<std::endl;
	std::cout << RetRValueStringRef(Hello + World)		  <<std::endl;

}

 

뭐 느낌은 이제 올 것이다.

함수의 인자가 rvalue reference 이므로 마음대로 조작할 수 있다.

근데 이걸 어디다가 써먹냐가 문제이다.

아래의 예시를 보도록 하자.

#include <iostream>
#include <string>
std::string& RetRValueStringRef(std::string&& String)
{
	//Rvalue Reference 의 형태로 param 정의.
	String = "It is not Hello World Now.";
	//param이 rvalue reference의 형태이기 때문에 마음대로 바꿀 수 있다.

	//std::string&&의 경우 
	//const 가 붙지 않은 lvalue refenrece로 리턴이 가능하다. 
	return String;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	const char* pBuffer = RetRValueStringRef(Hello + World).data();
	//RetRValueStringRef로 리턴된 객체는 언제까지 유효한가? 
	//pBuffer에 data()를 전달하면 소멸자가 호출된다.
	//즉 pBuffer에 담긴 주소의 메모리 또한 해제된다.
}

pBuffer 에 담긴 메모리의 주소가 만료되는 것을 막으려면 어떻게 해야할까?

버퍼를 훔쳐간다음, 객체의 멤버로 있는 버퍼 주소에 nullptr를 남겨둔다. 

문제가 생기지 않도록 소멸자를 버퍼가 nullptr인 경우에는 해제하지 않도록 구현하면 된다.

std::string에도 이런 구현이 존재할까?

당연히 존재한다.

#include <iostream>
#include <string>
std::string& RetRValueStringRef(std::string&& String)
{
	//Rvalue Reference 의 형태로 param 정의.
	String = "It is not Hello World Now.";
	//param이 rvalue reference의 형태이기 때문에 마음대로 바꿀 수 있다.

	std::cout << (unsigned long long)String.data() << std::endl;
	//버퍼의 주소는?

	//std::string&&의 경우 
	//const 가 붙지 않은 lvalue refenrece로 리턴이 가능하다. 
	return String;
}

int main()
{
	std::string Hello = "Hello";
	std::string World = " World!";

	std::string Res;
	Res.operator=(std::move(RetRValueStringRef(Hello + World)));
	//std::string::operator=(std::string&& rhs) 호출.
	//구현은 rhs의 버퍼 주소를 훔치고,
	//rhs가 소멸될때 버퍼 메모리를 해제하지 못하게 하기위해 nullptr로 바꾸도록 구현되어 있다.

	std::cout << (unsigned long long)Res.data() << std::endl;
	//훔친 버퍼의 주소를 확인해보자.
}

operator=(std::string&& rhs) 가 존재한다.

rhs의 버퍼를 그냥 훔쳐가버린다.

그리고 rhs의 버퍼 어드레스에 nullptr를 남겨둔다.

새로운 버퍼를 동적으로 할당할 필요가 없어졌다.

메모리 카피도 없어졌다.

 

함수의 인자를 rvalue refenrence로 줘서, 원하는 데이터를 쏙 골라 빼먹는 것.

이걸 이동 의미론(move semantics) 이라고 한다.

보통 std::move 와 std::forward를 통해 사용한다.

이건 다음 포스트에