[C++] 오른값 참조(Rvalue Reference)

도윤·2023년 7월 26일
0

C++

목록 보기
3/4
post-thumbnail

C++에는 오른값 참조(Rvalue Reference)라는 특이한 개념이 존재합니다. 오른값 참조는 C++11에 새로 추가된 신기능으로 C++에 어떠한 문제를 해결하기 위해 탄생한 개념입니다.

이전 버전에 C++에서 어떠한 문제가 제기되어 왔고, 오른값 참조의 도입으로 이 문제를 어떻게 해결하였는지 알아봅시다!

왼값(Lvalue)과 오른값(Rvalue)

오른값 참조에 대해 알기전에 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++에 어떠한 문제를 해결하기 위해 생겨난 개념입니다. 스포를 조금 하자면 해당 문제는 불필요한 복사입니다.

이게 무엇인지는 지금부터 알아가보도록 합시다.

복사 생략(Copy Elision)

우선 한가지 예제를 살펴봅시다.

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을 생성하며 일반 생성자가 호출되었습니다. 이후 새로 공간을 할당하여 주고 ab객체의 값을 복사하여 리턴해주게 됩니다.

리턴되는 valc객체에 전달되어 c객체의 복사 생성자를 호출합니다.

별 문제가 없어보이지만 위에서 설명한 복사 생략을 생각해보면, 굳이 c의 복사 생성자를 또 호출할 필요가 없다는 것을 알 수 있습니다. 함수 내에서 임시로 생성한 객체를 바로 c객체로 사용하면 되기 때문이죠.

위에서 살펴본 예제에서는 똑똑이 컴파일러가 불필요한 복사 생성자를 실행하지 않았지만, 이 경우에는 복사 생략을 수행하지 않았습니다.

이런식으로 불필요한 복사가 일어나게 되면 원본 객체가 가지고 있는 값 소멸, 임시 객체의 복사, 임시 객체의 소멸.. 이러한 불필요한 과정이 추가되어 여러모로 비효율적입니다.

자 이게 바로 오른값 참조가 해결하고자 했던 불필요한 복사입니다.

move 연산

그렇다면 예제에서 발생한 불필요한 복사문제를 해결해봅시다.

정말 간단하게 생각하여 operator+함수 내에서 생성한 임시 객체의 m_pResourcecm_pResource와 서로 바꿔주면(Swap) 됩니다.

이를 바로 move 연산이라고 합니다.

하지만 이는 기존의 복사 생성자에서는 사용할 수 없습니다. 그 이유는 인자를 const로 받았기에 임시 객체의 값을 수정할 수 없기 때문입니다. 이 때 오른값 참조를 사용하게 됩니다. 오른값 참조를 사용하면 좌측값이 아닌 오른값만 받을 수 있고 해당 문제를 해결할 수 있습니다.

오른값 참조(Rvalue Reference)

타입 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;
}

정리

  • C++에 불필요한 복사를 해결하기 위해 오른값 참조를 이용한다.
  • 함수 오버로딩 시 void func(A& a); 는 왼값 참조 오버로딩, void func(A&& a); 은 오른값 참조 오버로딩이다.
  • std::move()는 받은 인자를 오른값으로 변환하여준다.

참고

https://modoocode.com/189#page-heading-6
https://junstar92.tistory.com/187

profile
Game Client Developer

0개의 댓글