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