2024년 2월 28일 수요일

C++ 우측값 참조와 std::move 의 실제 동작 원리

 우측값 참조에 대해 검색해보면 보통 아래와 같이 설명한다.

1. 좌측값/우측값에 대한 설명
2. 좌측값 참조/우측값 참조에 대한 설명
3. std::move 를 통한 자원 이동에 대한 설명

설명을 읽어보고 대략적인 의미와 적용방법은 알 수 있었으나 실체적인 동작원리를 알 수 없었다.
예를 들어 std::move 를 사용하면 해당 변수를 좌측값->우측값 으로 변환한다 고 설명하는데 구체적인 설명은 보통 없다.

먼저 우측값 참조를 간단하게 설명하자면,

int a = 10;
int b = 20;
int&& c = a + b;

위와같은 코드가 있을때 마지막 줄에서 메모리 상에 어떤 일이 발생하는가하면
CPU가 a+b(30)를 계산한 후 해당 값을 임시 메모리 변수에 저장을 하게되고 우측값 참조변수 c가 이 메모리를 참조하게된다.
int c = a + b; 였으면 컴파일러에 따라 생성되지않거나 혹은 바로 사라질 임시 변수(우측값)를 해제하지 않고 c가 참조하게 함으로써 메모리에서 해제를 하지않게된다.
사실 int같은 기본타입에서는 별다른 사용 용도가 없으며 실제로는 클래스(구조체)에서 사용된다.

먼저 std::move 함수를 보자.
std::move(변수) 는 static_cast<변수타입&&>(변수) 와 같은 의미이다. 그러니까 그냥 우측값 참조형으로 타입 캐스팅을 하는것이지
실제로 메모리 상에 뭐가 이동하고 어쩌고 그런것과 아무 상관이 없다.

그리고 우측값 참조는 int 같은 기본형에서는 메모리 이동의 효과가 전혀 없다.(당연하게도)
아래 코드와 같이 클래스(구조체)인 경우에 우측값 참조를 받는 이동 생성자/이동 대입 연산자에 우측값 참조를 받아서
깊은 복사없이 포인터만 변경하게 해주는 것으로 그 용도가 전부이다.

 class MoveTest  
 {  
 private:  
   char* str = nullptr;  
   
 public:  
   MoveTest()  
   {  
     str = nullptr;  
   }  
   
   MoveTest(const char* str1)  
   {  
     int len = strlen(str1);  
     str = new char[len + 1];  
     strcpy_s(str, len+1, str1);  
   }  
   
   MoveTest(const MoveTest& lhs)  
   {  
     int len = strlen(lhs.str);  
     str = new char[len + 1];  
     strcpy_s(str, len+1, lhs.str);  
   }  
   
   // 이동 생성자  
   MoveTest(MoveTest&& rhs) noexcept : str(rhs.str)  
   {  
     rhs.str = nullptr;  
   }  
   
   MoveTest& operator=(const MoveTest& lhs)  
   {  
     int len = strlen(lhs.str);  
     str = new char[len + 1];  
     strcpy_s(str, len + 1, lhs.str);  
     return *this;  
   }  
   
   // 이동 대입 연산자  
   MoveTest& operator=(MoveTest&& rhs) noexcept  
   {  
     str = rhs.str;  
     rhs.str = nullptr;  
     return *this;  
   }  
   
   ~MoveTest()  
   {  
     delete[] str;  
   }  
 };  
위 코드에서 

MoveTest m1("abc");
MoveTest m2(std::move(m1)); // 이동 생성자 호출, m1->m2 로 이동
// MoveTest m2(static_cast<MoveTest&&>(m1)); 와 같음

MoveTest m3;
m3 = std::move(m2); // 이동 대입 연산자 호출, m2->m3 로 이동
// m3 = static_cast<MoveTest&&>(m2); 와 같음

와 같이 해당 이동 생성자/이동 대입 연산자를 호출하게되고 호출을 받은쪽은 포인터 이동을 하면 되는것이다.

그러면 왜 하필 굳이 std::move 를 통해 우측값 참조 타입으로 이 기능을 구현하는가?
그냥 위 코드에서 참조(const MoveTest& lhs)를 받는 복사 생성자와 대입 연산자에서도 처리가 가능할텐데 말이다.
몇 가지 이유가 있지만 제일 큰 이유는 자원이 이동한다는 것을 명시적으로 표현함으로써 표준을 정하고 혼란을 없애기 위함이다.
일반 복사 생성자/대입 연산자에서 이것을 처리하게 되면 실제 깊은 복사가 일어나야 하는 것인지 자원이동만 일어나야하는지 구분이 어렵기 때문이다.

댓글 없음:

댓글 쓰기