c++ rvalue reference

윤태웅·2022년 2월 28일
0

c++

목록 보기
3/6
post-thumbnail

Rvalue Reference?

Rvalue Reference는 c++ 11에서 성능 개선을 위해 추가된 문법이다.
형태는 다음과 같다.

int&& a = 3;

이 문장은 rvalue인 3을 a라는 lvalue로 참조한다는 뜻이다.
이를 확실히 이해하기 위해서는 rvalue,lvalue를 구분하는 c++의 value category를 이해할 필요가 있다.

C++ Value Category

c++의 표현식(expression)은 크게 2가지 분류, 작게는 3가지 분류로 나눌 수 있다.
나누는 기준은 expression이 move가능하면 rvalue, identity가 있다면 glvalue로 나뉘고 다시 glvalue가 move가능하면 xvalue 아니면 lvalue, rvalue가 identity가 있다면 xvalue 아니면 prvalue로 나뉜다.


Identity가 있다는 의미는 microsoft사이트에서 찾을 수 있었다.

What does it mean for a value to have identity? If you have (or you can take) the memory address of a value, and use it safely, then the value has identity 출처


move가 가능하다는 의미는 move semantic에 의해 해당 데이터 영역을 가리키는 변수가 변한다는 의미이다.

말보다는 위에 그림을 참고하는것이 더 이해가 잘 될것이다.
중요한 점은 move를 한다는 것이 '메모리 공간을 움직이는 것'이 아니라 '메모리 공간의 이름을 바꾸는 것'이라는 것이다. 참 헷갈리는 개념이다.

C++ Value Category 예시

int a = 0;//a는 lvalue, 0은 prvalue
int& b = a;//a,b모두 lvalue
Obj o = Obj();//o는 lvalue, Obj()는 prvalue
int&& c = std::move(a);//std::move(a)는 xvalue, c는 lvalue

&& 기호가 바로 c++11에서 추가된 rvalue reference기호이다.
rvalue reference타입으로 선언된 변수는 lvalue이며 해당 rvalue가 저장된 메모리 영역을 참조할 수 있다.

Rvalue Reference문법

int a = 0;
int& b = a;//b는 lvalue reference
int&& c = a;//컴파일 에러(rvalue reference는 rvalue만 받을수 있다)
int&& c = a + 1;//c는 a+1의 계산결과 생성되는 임시데이터를 참조한다(rvalue reference)
cout << c << endl;//1출력(본래 rvalue는 표현식이 끝나고  메모리에서 
//삭제되지만 c라는 변수가 참조해서 int데이터가 메모리에 남아있다)
	

Move Semantics

c++11의 rvalue reference문법을 이용해서 Move Semantics구현이 가능하다.

Move Semantics:어떤 변수의 값을 새로운 변수에 할당한다고 할 때,
새로운 메모리 영역을 할당받는 것이 아니라 어떤 변수의 기존 메모리 영역을 새로운 변수의 메모리 주소로 의미를 '이동'시키는 방법

예시

Obj.h

#pragma once
#include <iostream>
using namespace std;
class Obj
{
public:
	Obj(int num);//기본 생성자
	Obj(const Obj& obj);//복사 생성자
	Obj(Obj&& obj);//이동 생성자
	Obj& operator=(const Obj& obj);
	Obj& operator=(Obj&& obj);
	void print();
	~Obj();//소멸자
private:
	int* a;//과부하 테스트용 뻥 데이터
};

Obj.cpp

#include "Obj.h"
Obj::Obj(int num)
{
	a = new int[10000000];
	for (int i = 0; i < 100000; i++)
		a[i] = num;
	cout << "기본 생성자 호출" << endl;
}
Obj::Obj(const Obj& obj)
{
	a = new int[10000000];
	for (int i = 0; i < 10000000; i++)
	{
		a[i] = obj.a[i];
	}
	cout << "복사 생성자() 호출" << endl;
}
Obj::Obj(Obj&& obj)
{
	cout << "이동 생성자() 호출" << endl;
	a = obj.a;
	obj.a = nullptr;
}
Obj& Obj::operator=(const Obj& obj)
{
	a = new int[10000000];
	for (int i = 0; i < 10000000; i++)
	{
		a[i] = obj.a[i];
	}
	cout << "복사 연산자= 호출" << endl;
	return *this;
}
Obj& Obj::operator=(Obj&& obj)
{
	a = obj.a;
	obj.a = nullptr;
	cout << "이동 연산자= 호출" << endl;
	return *this;
}
void Obj::print()
{
	cout << a[0] << endl;
}
Obj::~Obj()
{
	delete[] a;
	cout << "소멸자 호출" << endl;
}

main.cpp

#include <iostream>
#include <time.h>
#include "Obj.h"
using namespace std;
int main()
{
	clock_t start, end;
	start = clock();
	Obj aaa(3);
	//Obj bbb(Obj(3));//copy elision 규칙에 의해서 이동 생성자를 호출하지 않음
	//Obj bbb1(aaa);//복사 생성자 호출(deep copy)
	//Obj bbb2(move(aaa));//이동 생성자를 명시적으로 호출하는 방법(shallow copy)
	//aaa.print();//런타임 에러(move를 호출한 이후 반환된 rvalue reference를 다른 변수에 할당하면 aaa는 존재하지 않음)
	end = clock();
	cout << "걸린 시간" << end - start << endl;//복사생성자 : 50ms, 이동생성자:13ms
	return 0;
}

std::move(lvalue)함수는 lvalue를 rvalue reference로 type casting해주는 함수이다. 이 함수덕분에 lvalue를 rvalue reference로 전환시켜서 이동생성자를 호출할 수 있다. 참고로 move(aaa)는 xvalue이다.

예시는 새로운 Obj객체를 생성할 때,복사생성자로 deep copy를 하는 경우랑 이동생성자로 shallow copy를 하는경우 성능차를 계산하는 코드이다(실험결과를 잘 알아보기 위해 의도적으로 1000000개의 int array를 Obj객체에 동적할당하였다). 결과는 복사생성자는 50ms 이동생성자는 13ms로 이동생성자가 확실히 더 빨랐다.

이 실험에서 몇가지 재미있는 사실도 발견하였다.
첫번째는 Obj bbb(Obj(3))처럼 prvalue를 인자로 넘겼을때 vc컴파일러가 copy elision규칙에 의해서 이동 생성자의 호출을 무시하였다는 것이다. 최신식 컴파일러의 최적화 기법이라고 한다. copy elision은 이 글의 주제에 맞지 않으니 여기까지만 알아봤다.
두번째는 rvalue reference로 인해 의미를 도둑맞아버린 aaa변수에 다시 접근하면 런타임 에러가 난다는 것이다. aaa라는 단어의 의미는 본래 특정 메모리 영역을 의미했지만 bbb2라는 단어가 그 특정 메모리 영역을 의미하게 바꾸어 버렸으니 이렇게 될 수 밖에 없나보다.

결론

C++11의 Rvalue Reference문법은 Move Semantics를 가능하게 하고
Move Semantics는 불필요한 복사방지를 실현해서 성능 향상을 기대할 수 있다. 하지만, 그렇다고 무조건 Move Semantics를 쓰는것이 좋은것인가? 라고 물어본다면 그렇지 않다. Move Semantics는 분명 성능상의 이점은 있지만, 문법적으로 어렵고 복잡하다는 단점이 있다..(예를 들어,move(lvalue);로 의미가 이동당한 lvalue의 소멸자가 멤버 포인터 변수 대상으로 delete하지 않게 하기 위해 lvalue의 멤버 포인터를 nullptr로 바꿔야 한다)

1개의 댓글

comment-user-thumbnail
2023년 9월 13일

오래 전 글이신거 같지만 지나가다 질문 올립니다.

이동 생성자의 구현 부분에서 obj.a 를 nullptr로 바꾸신 거 같은데,

Obj::Obj(Obj&& obj)
{
cout << "이동 생성자() 호출" << endl;
a = obj.a;
obj.a = nullptr;
}

프린트의 구현 이 이 a를 사용하기 때문에
void Obj::print()
{
cout << a[0] << endl;
}

이렇게 되면 마지막 main.cpp 에시에서
//aaa.print();
는 당연히 런타임 에러가 날수밖에 없지 않나요?

두번째는 rvalue reference로 인해 의미를 도둑맞아버린 aaa변수에 다시 접근하면 런타임 에러가 난다는 것이다. aaa라는 단어의 의미는 본래 특정 메모리 영역을 의미했지만 bbb2라는 단어가 그 특정 메모리 영역을 의미하게 바꾸어 버렸으니 이렇게 될 수 밖에 없나보다.

마지막 요 문장이 잘못된 결론이 아닌가 하여 질문 드려봅니다

답글 달기