본문 바로가기

개발 Note/C++11,14 (modern C++)

rvalue reference 가 항상 유익하지 않다?

반응형

오른쪽 참조, R-Value Reference 란?

 

 

시기는 잘기억이 안나는데 어느시점에서 부턴가 C++ 쪽에서 rvalue , move sementic 이런 용어들이 자주 거론 되기 시작한 시기가 있었습니다.

꽤 많은 사람들이 여기에 대해서 유익한 개념이라고 언급했던 기억이 나네요.

 

블로그로 정리하기에는 좀 오래된 내용이지만, 한번 머리속을 정리해볼겸해서 작성하게 되었습니다.

 

 

C++11 에서 도입된 새로운 특성중 의미이동(move semantic) 과 오른쪽 참조가 있습니다.

이것은 불필요한 복사를 줄이는 것을 목적으로 합니다.

 

여기에 대해서 알아보기 전에 이미 C++의 특성에 대해서 많이 알고 계신 분도 있겠지만, 그렇지 못한 분들도 있을것이라서 ,

기본이 되는 내용이지만 생성자, 복사 생성자 등에 대해서 언급을 하고 넘어가겠습니다. 

 

 

객체 복사와 복사 생성자

 

객체 복사와 복사 생성자를 설명하기 위해 간단한 예제를 준비 했습니다.

getMyObj()는 MyObj의 지역(local) 객체를 반환합니다.

MyObj getMyObj()
{
  MyObj temp;
  temp.set(10);
  return temp; // temp 라는 지역 객체(local object)를 리턴합니다.
}

//지역(local) 객체는 의 life cycle 스코프(scope)을 벗어나면 삭제됩니다. 따라서 위의 함수에서 return한 temp 의 삭제 시점이 매우 중요합니다.
//아래 main 함수에서는 a라는 객체를 선언하면서 getMyObj(); 를 호출 했습니다.

int main(void)
{
  MyObj a = getMyObj();  //(1) 임시 객체 temp 가 a로 복사되고, temp는 사라짐

  MyObj b = a;     //(2) a를 b에 복사함.
  b.printData();
}

 

MyObj 라는 클래스(class) 형태가 어떤 모양일지는 모르겠지만,

main 함수의 문맥상으로는 크게 문제될 것이 없어보입니다.

getMyObj() 가 로컬 객체를 넘겼다 하더라도, a 로 복사(copy)가 발생 할 것이고 , a는 다시 b로 복사(copy) 될 것으로 보이기 때문입니다.

그렇지만 MyObj가 아래와 같은 class라고 한다면, 기능상 심각한 오류가 발생할 가능성이 있습니다.
class MyObj
{
  int* __data = nullptr;

  public:
    MyObj()    // 일반 생성자
    {
       	__data= new int[3000];
		for (int i = 0 ; i < 3000 ; i++)
	 		__data[i] = 0; 
    }

    virutal ~MyObj()
    {
		delete __data;
    }    

    void set(int val)
    {
		for (int i = 0; i < 3000;i++)
			__data[i] = val ;
    }
    
    void printData(){
        for ( int i =0; i<3000;i++)
               printf("data[%d] = %d", i, __data[i]);
    }

};
 
위의 main 함수에서 getMyObj()를 호출하여 temp 가 삭제 되는 부분을 다시 살펴봅시다.
   MyObj a = getMyObj();  //(1) 임시 객체 temp 가 a로 복사되고, temp는 사라짐

이 과정을 좀 풀어서 얘기하면 다음과 같습니다.

1) getMyObj() 함수는 temp를 return합니다.

2) temp는 a에 복사가 됩니다.

3) temp는 삭제가 되면서 __data 를 삭제(delete)합니다.

4) 여기까지 진행되면, a.__data는 삭제된 메모리 주소를 가리키게 됩니다.(dangling pointer)

이 내용을 좀더 자세히 확인하기 위해서는 복사 생성자를 알아봐야 합니다.
 

복사 생성자 (copy constructor)

 

사용자가 작성한 복사 생성자가 아닌, 컴파일러(compiler) 가 제공하는 기본 복사 생성자는 다음과 같이 동작 하게 됩니다.

 

MyObj( const MyObj& obj)

{

   __data = obj.__data;  //a.__data 는 address를 담고 있는 값이라서, __data에 해당 주소값(address) 만 복사가 됩니다.

  // 복사 생성자가 없는 경우 default 복사는 이것과 동일한 결과를 만듭니다.

}

 

__data라는 포인터(pointer,주소를 담을 수 있는 변수) 값을 복사하게 됩니다.

 

MyObj b=a; 라는 코드를 보면, 

a.__data는 메모리 주소값을 담을 수 있는 변수이기에, a._data 값을 b.__data 로 주소만  복사하지,  __data가 가리키는 곳의 실제 데이타를 복사 하지 않습니다.

결과적으로  a와 b가 모두 같은 주소를 바라보는 __data를 갖게 됩니다.

이 경우  a가 삭제된다면,  b.__data가 가리키고 있는 곳의 data를 메모리가 해제 되었기 때문에, b.__data 는 dangling 된 pointer가 됩니다.

 

때문에 MyObj b=a; 에서는 반드시 복사가 이뤄져야 합니다.

 

이 때문에 복사 생성자는 다음과 같이 작성되어야 합니다.

 

MyObj(const MyObj& obj)  // 복사 생성자

{

__data= new int[3000];

for (int i = 0; i < 3000;i++)

__data[i] = obj.__data[i] ;

}

 

반면에,

MyObj a = getMyObj();
 
에서 data를 모두 copy하는 과정은 temp 객체는 더이상 사용할 일이 없기 때문에 불필요한 작업입니다.
그런데 class MyObj가 들고 있는 data의 내용이 크고 복잡하다고 한다면,  상당히 많은 시간을 소모하게 됩니다.
 

A의 data가 커지면 커질수록, 복잡해지면 복잡해질 수록 복사 비용은 커지게 됩니다.

 

맨 처음 코드의 main() 함수에서 보면, (1)(2)가 미묘하게 다릅니다.

 

(1)은 임시 객체를 a에 복사하는 것이고,  (정확하게는 미묘한 어떤 것이 더 있긴 하지만 설명의 편의를 위해 여기까지만)

(2)는 b에 임시 객체가 아닌 a를 넘기는 것입니다.

 

C++ 개발자들(C++언어를 개선하고자 하는 사람들)은 여기에 착안을 해서 복사와 이동을 분리해서 생각하게 되었습니다.

다시 생각해보자면,

(1) 은 데이타를 넘겨주고 임시 객체는 사라지기 때문에 사실상 이동과 동일합니다..

(복사를 하고 삭제한다.!)

(2) 는 데이타를 넘겨주고 객체가 2개 다 유지가 되기 때문에 복사에 해당합니다.

 

(1)과 같이 이동을 원하는 경우에는 복사 생성자를 통한 처리가 아니라 다른 함수를 제공하게 되면 훨씬 성능에 이득을 취할수 있게 되죠.

 

void MoveFrom(MyObject& from)

{

   __data = from.__data; // data를 copy 하는 것이 아니라 주소만 복사함.

   from.__data = nullptr; // 원본의 data는 nullptr로 만듬. 삭제가 안되도록.

}

 

 

 

a.MoveFrom(getMyObj());  

 

이렇게 사용하면 됩니다.

 

 

이것의 단점은 ,

1. 객체를 생성하는 시점에 처리 할 수 없습니다.

 

2. 넘겨주는 객체가 더이상 사용안되는지 사용되는지 판단을 개발자가 해야 하여 실수의 여지가 있고,

   코드가 변경될때 마다 항상 다시 확인을 해야 합니다.

 

3. 함수 형태로 클래서 설계자가 작성을 해야 하기 때문에, 이름과 사용처라 모두달라 유지 보수가 힘듭니다.

 

 

 

이런 이동에 대한 고민과 경험들은 C++언어의 개선에 도움 되었고, 의미 이동이라는 개념과 R-Value Reference 라는 것이 

C++11에 추가가 되었습니다.

 

 

C++ 개발자들은 이 move semantic 과 R-value reference 에 대해서 사람들은 드디어 획기적인 성능 개선이 이뤄질 수 있겠다는 기대들을 하게 됩니다.

 

실제로 STL 전반에 걸쳐서 의미 이동이 추가되어 상당한 성능 개선이 이루어졌습니다.

 

 

 

자 이제 R-Value Reference 라는 것이 무엇인지, 이것에 의해서 무엇이 달라지는지 보도록 합시다.!!

 

 

오른쪽 참조, R-Value Reference 란?

 

 

오른쪽 참조는 임시 객체와 매우 밀접합니다.

 

다음 식으로 L value 와 R value를 간단히 설명드리겠습니다.

 

A = B  라는 식이 있다고 한다면,

 

A는 L-Value 이고 B는 R-Value 입니다. 즉 왼쪽에 있는 값은 L value, 오른쪽에 있는 값은 R value로 보면 됩니다.

왼쪽 편에 있는 값들은 뭔가 저장할 수 있는 것이어야 합니다.

12 =B  이런식으로 12라는 상수값은 사용될 수 없습니다.

 

오른쪽 값 형식은 상수나 변수 모두 올 수 있습니다.

A = 12

A = B+4

A = 4-3

A = B   // 실질적으로 L-value = L-value 라서 복사 생성자가 실행됨.

 

R-Value를 좀 다르게 표현하면, L value에 assign하기 위한 임시적인 값 이라고 표현하는게 더 올바른 것 같습니다.

 

편의상 임시 객체라고 표현 하겠습니다.

 

정수에 대한 처리를 위한 Integer라는 class가 있다고 합시다.

 

Integer a;   // a는 일반 객체

 

Integer b = a;  // b는 일반 객체,  a는 일반 객체

 

Integer c = 10; // c는 일반 객체, '10'을 담는 임시 객체 생성

 

Integer d = b+c;  d,b,c는 일반객체,  (b+c)는 b와 c를 더한 결과를 담는 임시 객체 생성

 

 

우리가 연산자등을 이용할때 임시 객체들이 의도치 않게 생성되는 경우들을 흔히 볼수 있습니다.

고수 개발자 분들은 코드를 볼때 그런 부분들 까지 염두해 두고 살펴보죠.

 

이때 이런 임시 객체들은 한번 생성되고 바로 버려지는데, 이를 복사하기 위해서 소비되는 비용( 리소스, CPU) 가 너무 아까운 것이죠.

이런 부분들은 바로 성능에 영향을 미치게 되고 말이죠.

 

그리고 이런 부분들을 개발자들에게 맡기기에는 너무나 불안한 요소들이 너무 많아서, 임시 객체를 처리하기 위한 특별한 문법이 추가되었습니다. 

 

R-Value Reference 라고 합니다.  형식은 "&&"  입니다.

&&??? 이게 뭐냐구요?

 

타입명&& 변수 이렇게 사용을 하게 되면, 임시 객체를 가리키는 레퍼런스가 만들어지게 됩니다.

 

void Integer::Get(Integer&& value)  이런 형식이죠.

 

만약  아래와 같이 2개의 함수가 만들어진다고 하면,

void Integer::Get(Integer&& value)   --> 임시 객체가 인자로 들어올때 처리되는 함수

void Integer::Get(const Integer& value)  --> 일반 객체가 인자로 들어올때 처리되는 함수

가 됩니다.

void Integer::Get(Integer&& value) 이 함수가 없다면, 모든 객체에 대해서 void Integer::Get(const Integer& value) 가 동작하게 됩니다.

 

 

 

그럼 처음에 만들었던 MyObj class에 이동 생성자를 추가해봅시다.

 

이동 생성자는 인자로 받는 객체가 임시 객체이기 때문에 더이상 사용 안되고,사라질 것을 전제로 작성되어야 합니다.

 

MyObj(MyObj&& obj)  // 이동 생성자

{

__data = obj.__data; // 주소 복사

obj.__data = nullptr; // 이전 객체에서 pointer는 삭제,

//이동 완료.

}

 

 

입력으로 받은 obj는 바로 사라질 것이기 때문에,  obj의 data를 nullptr로 변환 하는 것은 꼭 필요합니다.

그렇지 않으면,  임시 객체인 obj의  destructor에서 __data를 delete 하여,  현재 객체의 __data는 dangling pointer가 되어버립니다.

 

 

 

class MyObj

{

  int* __data= nullptr;

 

  public:

    MyObj()    // 일반 생성자

    {

       __data= new int[3000];

for (int i = 0 ; i < 3000 ; i++)

__data[i] = 0; 

    }

 

   MyObj(const MyObj& obj)// 복사 생성자

    {

__data= new int[3000];

for (int i = 0; i < 3000;i++)

 __data[i] = obj.__data[i] ;

    }

 

   MyObj(MyObj&& obj)  // 이동 생성자

    {

__data = obj.__data; // 주소 복사

  obj.__data = nullptr; // 이전 객체에서 pointer는 삭제,

  이동 완료.

}

    virutal ~MyObj()

    {

delete __data;

    }    

 

    void set(int val)

    {

for (int i = 0; i < 3000;i++)

 __data[i] = val ;

    }

};

 

MyObj getMyObj()

{

  MyObj temp;

  temp.set(10);

  return std::move(temp);

}

 

int main(void)

{

  MyObj a = getMyObj();  //(1) MyObj(const MyObj&& obj) 이동 생성자가 호출됨

  MyObj b = a;     //(2) MyObj(const MyObj& obj) 복사 생성자가 호출됨.

}

 

 
이렇게 이동 생성자와 복사 생성자를 모두 구현해 놓으면,
프로그램 코드에 따라 경우에 따라서 복사가 되고나, 이동이 됩니다.
 
 
 
 
std::move()
 
std::move()는 의미 이동을 위한 도구중 하나입니다.
 
MyObj b = a; 는 복사 생성을 하게 됩니다.
MyObj b = std::move(a); 라고 하게 되면, b는 이동 생성을 하게 됩니다.
 
이런 식으로 경우에 따라서 복사를 이동으로 변환하여 처리 할 수도 있습니다.
이런 강제 이동의 이면에는 이에 따른 불안 요소를 남기게 됩니다.
즉, b로 강제 이동을 했지만, a는 현재 살아있는 상태(변수이기 때문에)이기 때문에 만약 a를 사용하려 한다면, a.__data 는 nullptr 상태이기 때문에 문제가 생깁니다.
 
즉, std::move() 를 사용할 수 있는 경우는 a를 더이상 사용 안해야 합니다.
 
 
 
 
 
 
 

생각해볼 만한것.!

 

컴파일러나 언어 차원에서 성능 개선을 위해 최적화를 해주는 부분들이 존재 합니다.

그런것에 대해서 적어봤습니다.

 

B = 5

A = 10

A = B

 

A = B 에서 A는 복사가 됩니다.

 

그러나 원리를 따지자면,

원래는 B쪽은 r value가 되어야 합니다. 따라서, 다음과 같은 과정이 되어야 합니다.

1. 임시 객체를 만들고 거기에 B가 copy 된다.   

  temp의 복사 생성자를 통해 B가 temp 로 복사가 된다.

   temp(B)  // temp 는 임시 객체 , B는 인자.

 

2. A에게 임시 객체를 넘긴다.  

    A의 이동 연산자를 통해 temp가 A로 이동한다.

    A = temp(B) 

 

그러나 실제로는 이런 불합리한 점을 제거하기 위해서 예외적으로 A의 copy construct 로 처리 됩니다.

 

L value = L value 의 경우에는 copy라고 보시면 됩니다.

 

 

 

Integer getInt()

{

  return Integer(10);

}

 

Integer B = getInt();

 

이런 코드의 경우에는 B의 이동 생성자가 불려서 임시 객체가 copy 되어야 합니다.

그러나 컴파일러가 이런경우는 이동 생성 없이 B 가 곧 임시 객체가 되도록 만들어 줍니다.

마치,

Integer&& B = Integer(10); 

이런 것 처럼 만들어줍니다.

 

이 경우 만약 아래와 같이 복사보다는 move가 낳지 라고 생각해서 아래와 같이 move를 명시화 시킨다면,

Integer B =  std::move(getInt());

 

B의 move 연산자가 동작하여 오히려 성능에 안좋은 영향을 주게 됩니다.