C++에는 오른값 참조(Rvalue Reference)라는 특이한 개념이 존재합니다. 오른값 참조는 C++11에 새로 추가된 신기능으로 C++에 어떠한 문제를 해결하기 위해 탄생한 개념입니다.
이전 버전에 C++에서 어떠한 문제가 제기되어 왔고, 오른값 참조의 도입으로 이 문제를 어떻게 해결하였는지 알아봅시다!
오른값 참조에 대해 알기전에 C++에서 왼값과 오른값이 무엇인지 알아야합니다.
왼값과 오른값의 정의는 C에서 부터 내려와 C++에서 그 의미가 조금 바뀌었는데 C에서의 의미부터 천천히 알아봅시다.
C
왼값: 대입 시 식의 왼쪽 혹은 오른쪽에 오는 식
오른값: 대입 시 식의 오른쪽에만 올 수 있는 식
예시를 한번 봅시다.
int a = 10;
int b = 20;
// a와 b는 식의 왼쪽 혹은 오른쪽에 와도 상관없기에 왼값이다.
a = b;
b = a;
// 다만 a * b는 오른값이다.
int c = a * b;
// error! 오른값이 왼쪽에 있기에 오류가 발생한다.
a * b = 10;
C에서는 그 이름에 걸맞게 오른값은 오른쪽에 왼값은 왼쪽 혹은 오른쪽에 오는 값으로 이해하여도 충분하였습니다. C++에서도 이렇게 간단하면 좋겠지만.. 여러 사용자 정의 타입 때문에 C에서의 정의와는 조금 다른 의미를 가집니다.
C++
왼값: 특정 메모리의 위치를 가르키는 값. 즉 참조 연산자(&)를 통해 참조가 가능하다.
오른값: 왼값이 아닌 값
잘 이해가 안 갈수 있으니 이것도 예시를 한번 봅시다.
// 왼값
//
int i = 10;
int& func1(); // int&를 리턴하는 함수
int* p1 = &i; // 참조 연산자(&)로 참조가 가능하기에 i는 왼값이다.
func1() = 42; // func1()은 왼값이다.
int* p2 = &func1(); // 참조 연산자(&)로 참조 가능.
// 오른값
//
int j = 10;
int func2(); // int를 리턴하는 함수
j = func2(); // func2()는 오른값이다.
j = 42; // 42는 오른값이다.
int* p3 = &func2(); // error! 오른값은 참조 연산자(&)로 참조가 불가능하다.
왼값과 오른값에 대한 개념은 어느정도 잡혔으니 이제 대망의 오른값 참조에 대해서 알아봅시다.
위에서 언급했듯이 오른값 참조는 C++에 어떠한 문제를 해결하기 위해 생겨난 개념입니다. 스포를 조금 하자면 해당 문제는 불필요한 복사입니다.
이게 무엇인지는 지금부터 알아가보도록 합시다.
우선 한가지 예제를 살펴봅시다.
class A{
public:
A(){
std::cout << "일반 생성자 호출\n";
m_size = 0;
m_pResource = nullptr;
}
A(int size){
std::cout << "일반 생성자 호출\n";
m_size = size;
m_pResource = new int[m_size];
}
A(const X& rhs){
std::cout << "복사 생성자 호출\n";
m_size = rhs.m_size;
m_pResource = new int[m_size];
for(int i = 0; i < m_size; i++){
m_pResource[i] = rhs.m_pResource[i];
}
}
private:
int m_size;
int* m_pResource;
}
다음과 같은 기본적인 클래스를 선언 후 객체 3개를 생성해봅시다.
int main(){
A a(10);
A b(a);
A c(A(20));
}
// output
일반 생성자 호출
복사 생성자 호출
일반 생성자 호출
이 코드를 실행하게 되면 다음과 같은 출력이 나오게 됩니다.
A a(10);
A b(a);
위 두 코드에서는 예상할 수 있듯이 일반 생성자와 복사 생성자가 잘 호출되었습니다.
A c(A(20));
하지만, 해당 코드에서는 일반 생성자 하나만 호출 된 것을 볼 수 있습니다. 예상대로라면 A(20)이라는 임시 객체를 생성하면서 일반 생성자가 하나 호출되고 생성된 임시 객체를 c에 복사하면서 복사 생성자가 한번 호출되었어야 합니다.
이러한 현상은 똑똑한 컴파일러가 이 코드에서 복사가 불필요하다고 판단하여 만들어진 A(20) 이라는 임시 객체를 바로 c로 만들어버렸기 때문입니다. 이를 복사 생략(Copy Elision)이라고 합니다. 때문에 임시 객체를 만들 때 실행되었던 일반 생성자 하나만 호출이 되고 복사 생성자는 사용되지 않은 것 입니다.
이러한 복사 생성은 함수 내부에서 생성한 객체를 리턴할 때에도 사용됩니다.
A test(){
A a(10);
return a;
}
해당 함수를 실행하여 보면 이번에도 일반 생성자 하나만 호출 되는 것을 확인 할 수 있습니다. 이 또한 똑똑한 컴파일러가 a객체를 생성하고, 리턴 시 a의 값을 복사해서 넘기는 것이 아닌 리턴하는 값 위치에 바로 a를 생성해버렸기 때문입니다. 이를 반환값 최적화(Return Value Optimize)라고 부릅니다.
이번에는 A클래스 안에 두 클래스가 가지는 배열을 더해 새로운 A객체를 반환하는 operator+ 함수를 만들고 다시 출력값을 확인해보겠습니다.
int main(){
A a(10);
A b(10);
cout << "---------------------\n";
// operator+ 함수 호출
A c = a + b;
}
// output
일반 생성자 호출
일반 생성자 호출
---------------------
일반 생성자 호출
복사 생성자 호출
a와 b객체의 일반 생성자가 차례로 호출되었고, a와 b를 더한 새로운 객체 c가 생성되었습니다. 이제 operator+함수 내부에서는 무슨 일이 벌어졌는지 살펴봅시다.
A operator+(const X& rhs){
A val;
val.m_size = this->m_size + rhs.m_size;
val.m_pResource = new int[val.m_size];
for(int i = 0; i < this->m_size; i++){
val.m_pResource[i] = this->m_pResource[i];
}
for(int i = 0; i < rhs.m_size; i++){
val.m_pResource[this->m_size + i] = rhs.m_pResource[i];
}
return val;
}
operator+함수의 구현부입니다.
우선 리턴을 위한 임시 객체 val을 생성하며 일반 생성자가 호출되었습니다. 이후 새로 공간을 할당하여 주고 a와 b객체의 값을 복사하여 리턴해주게 됩니다.
리턴되는 val은 c객체에 전달되어 c객체의 복사 생성자를 호출합니다.
별 문제가 없어보이지만 위에서 설명한 복사 생략을 생각해보면, 굳이 c의 복사 생성자를 또 호출할 필요가 없다는 것을 알 수 있습니다. 함수 내에서 임시로 생성한 객체를 바로 c객체로 사용하면 되기 때문이죠.
위에서 살펴본 예제에서는 똑똑이 컴파일러가 불필요한 복사 생성자를 실행하지 않았지만, 이 경우에는 복사 생략을 수행하지 않았습니다.
이런식으로 불필요한 복사가 일어나게 되면 원본 객체가 가지고 있는 값 소멸, 임시 객체의 복사, 임시 객체의 소멸.. 이러한 불필요한 과정이 추가되어 여러모로 비효율적입니다.
자 이게 바로 오른값 참조가 해결하고자 했던 불필요한 복사입니다.
그렇다면 예제에서 발생한 불필요한 복사문제를 해결해봅시다.
정말 간단하게 생각하여 operator+함수 내에서 생성한 임시 객체의 m_pResource를 c의 m_pResource와 서로 바꿔주면(Swap) 됩니다.
이를 바로 move 연산이라고 합니다.
하지만 이는 기존의 복사 생성자에서는 사용할 수 없습니다. 그 이유는 인자를 const로 받았기에 임시 객체의 값을 수정할 수 없기 때문입니다. 이 때 오른값 참조를 사용하게 됩니다. 오른값 참조를 사용하면 좌측값이 아닌 오른값만 받을 수 있고 해당 문제를 해결할 수 있습니다.
타입 A에 대해서 A&&를 A의 오른값 참조라고 정의합니다. 또한 쉽게 구분 가능하도록 A&를 A의 왼값 참조라고 정의하도록 합시다.
오른값 참조는 기존의 레퍼런스 A&와 꽤나 유사하게 동작합니다. 차이점이라고 한다면 함수 오버로딩 시 왼값은 왼값 참조를 오른값은 오른값 참조를 통해 함수가 호출됩니다.
void func(A& rhs); // 왼값 참조 오버로딩
void func(A&& rhs); // 오른값 참조 오버로딩
A a;
A test();
func(a); // 인자가 왼값이므로, 왼값 참조 함수가 호출
func(test()); // 인자가 오른값이므로, 오른값 참조 함수가 호출
이제 A에 클래스에 오른값 참조를 통한 생성자를 정의하고 다시 테스트 해봅시다.
class A{
...
public:
A(A&& rhs){
std::cout << "이동 생성자 호출\n";
m_size = rhs.m_size;
m_pResource = rhs.m_pResource;
// 임시 객체 메모리 소멸 방지
rhs.m_pResource = nullptr;
}
...
}
우측값 참조를 사용하는 이동 생성자를 정의하고
int main(){
A a(10);
A b(10);
cout << "---------------------\n";
// operator+ 함수 호출
A c = a + b;
}
// output
일반 생성자 호출
일반 생성자 호출
---------------------
일반 생성자 호출
이동 생성자 호출
실행해보면 이번에는 복사 생성자가 아닌 이동 생성자가 호출된 것을 확인할 수 있습니다!
이동 생성자에서 주의해야할 점은 인자로 들어오는 rhs는 오른값 참조 형식이긴 하지만 오른값은 아니라는 점입니다.
오른값 참조라고 정의한 것들도 왼값 혹은 오른값이 될 수 있는데, 이를 판단하는 기준은 만약 이름을 가지고 있다면 왼값, 가지고 있지 않다면 오른값입니다.
void func1(A&& a);
A&& func2();
위에 두 가지 함수를 살펴봅시다. func1() 함수 속 a는 오른값 참조로 정의되었지만 a라는 이름을 가지고 있기 때문에 왼값입니다.
반면 func2()는 A&&타입의 데이터를 리턴하고 그것의 이름은 없기 때문에 오른값입니다.
만약 A 클래스를 상속받은 B 클래스에서 이동 생성자를 오버로딩 한다고 할 때 앞서 설명한 이름에 대한 규칙을 모른 채로 구현한다고 하면 한가지 오류가 발생할 수 있습니다.
B(B&& b) : A(b){
// B에 관련된 작업들
}
위 생성자에서는 A의 복사 생성자가 호출되게 될 것 입니다. 왜냐하면 b는 오른값 참조 형식이지만 이름이 있는 왼값이기 때문이죠.
이를 해결하기 위해 std::move(x)를 사용할 수 있습니다. std::move(x)는 오른값 참조로 정의되었고, 이름을 가지지 않습니다. 따라서 이는 오른값이 됩니다.
즉, std::move(x)는 인자로 들어오는 값에 이름을 가려주는 역할을 해주어 오른값이 아닌 값도 오른값으로 변환해줍니다.
따라서 B 클래스의 이동 생성자를 다음과 같이 수정할 수 있습니다.
B(B&& b) : A(std::move(b)){ // A의 이동 생성자 호출
// B에 관련된 작업들
}
서론이 길었지만, 다시 A의 이동 생성자로 돌아와서...
A(A&& rhs){
std::cout << "이동 생성자 호출\n";
m_size = rhs.m_size;
m_pResource = rhs.m_pResource;
// 임시 객체 메모리 소멸 방지
rhs.m_pResource = nullptr;
}
rhs는 이름이 있는 왼값임을 알 수 있습니다. 때문에 마지막 코드에서 처럼 표현식에 좌측에 올 수 있는 것입니다.
// 임시 객체 메모리 소멸 방지
rhs.m_pResource = nullptr;
한 가지 중요한 부분은 인자로 넘겨 받은 임시 객체가 소멸되며 자신을 delete하지 못하도록 해야합니다. 값을 복사해준 것이 아닌 같은 메모리를 가르키고 있기에 임시 객체가 소멸되게 되면 c가 가르키게 될 메모리도 사라지게 됩니다.
따라서 소멸자도 아래와 같이 변경해주어야 합니다.
~A(){
if(m_pResource)
delete[] m_pResource;
}
void func(A& a); 는 왼값 참조 오버로딩, void func(A&& a); 은 오른값 참조 오버로딩이다.std::move()는 받은 인자를 오른값으로 변환하여준다.https://modoocode.com/189#page-heading-6
https://junstar92.tistory.com/187