Advanced C++

[C++] 이동 시맨틱과 r-value 참조형

로파이 2021. 1. 28. 20:53

먼저 C++ 객체 지향 프로그래밍의 복사 생성자와 대입 연산자를 복습해보자.

 

Requset.h

Request 클래스는 멤버 변수로 동적으로 할당하는 address 포인터 변수와 정수형 변수를 가지고 있다.

이에 대해 올바른 복사 생성자와 일반 대입 연산자 오버 로딩을 다음과 같이 할 수 있다.

class Request {
private:
	char* _address;
	unsigned int _id;

public:
	Request() : _address(nullptr), _id(0){}
	~Request()
	{
		if (_address) delete[] _address;
		cout << "destructor is called" << endl;
	}
	Request(const char* address, unsigned int id) : _address(nullptr), _id(id)
	{
		setAddress(address);
		cout << "parameter constructor" << endl;
	}
	Request(const Request& rhs) : _address(nullptr), _id(rhs._id)
	{
		setAddress(rhs.getAddress());
		cout << "copy constructor" << endl;
	}
	Request& operator=(const Request& rhs)
	{
		// if rhs is itself, no assign
		if (this == &rhs) return *this;

		_id = rhs._id;
		setAddress(rhs.getAddress());
		cout << "assign by other" << endl;
		return *this;
	}
	char* getAddress() const
	{
		return _address;
	}
	void setAddress(const char* address)
	{
		if (_address) delete[] _address;
		int length = strlen(address);
		_address = new char[length + 1];
		strcpy_s(_address, length + 1, address);
	}
};
  • 내부적으로 문자열을 처리하기 위한 함수와 Get, Set 메서드를 적절히 이용하여 char* 형인 address 주소를 초기화한다.
  • 소멸자는 동적 할당한 변수가 있으므로 반드시 해당 변수에 대한 메모리를 해제한다.
  • 대입 연산에서 메모리 해제된 자신의 주소를 다시 참조할 수 있으므로 자기 자신 = 자기 자신을 잘 처리해 준다.

Request a("", 1)등 예외사항이 있을 것 같지만 예제를 위해 이 정도로 정의하면 충분할 것 같다.

 

다음 예시에서 복사 생성자와 대입 연산를 호출하는 것을 볼 수 있다.

Request a("192.168.15.8", 0);
// calling copy constructor 복사 생성자
Request b = a;
Request c("192.168.15.1", 1);
// calling assign by other 대입 연산자 오버로딩
c = b;

앞서 참조자가 아닌 일반 변수나 인스턴스를 반환하는 함수의 경우 함수 반환 시 임시 객체를 생성한다고 언급하였다.

Request 객체를 생성하고 이를 반환하는 foo() 함수가 포함된 다음 예제에서 그 연산 과정을 확인해보자.

Request foo()
{
	Request t("192.168.15.2", 2);
	return t;
}
int main()
{
	Request a("192.168.15.1", 1);
	a = foo();
	cout << a.getAddress() << endl;
 	return 0;
}

위 코드를 실행을 하면 다음과 같이 결과가 출력된다.

foo() 함수 반환 시 생성되는 임시 객체를 "tmp"라 하면 (실제 코드상에서는 이름이 없다.)

임시 객체를 위한 복사 생성자가 호출되고 함수 내 지역변수로 사용된 t가 삭제된다.

그 임시 객체가 a에 대입되면서 대입 연산자를 호출하게 된다.

임시 객체는 대입되자마자 삭제된다.

 

"tmp"는 잠시 생성되었다 사라지는 임시 객체인데 t -> tmp -> a로 대입되는 것은 불합리해 보인다.

또한 복사 생성자나 대입 연산자 내부에서 호출되는 setAddress(const char*) 연산의 깊은 복사에 의해 기존 인스턴스 rhs의 address 길이만큼 복사되기 때문에 길이가 긴 경우, 즉 인스턴스의 데이터가 크다면 복사 생성자와 일반 대입 연산시 발생하는 비용도 최적화의 대상이 된다.

 

이동 시맨틱 - 이동 생성자와 이동 대입 연산

어차피 사라지는 객체 tmp에 대해 깊은 복사를 하지 말고 얕은 복사로 포인터의 주소와 일반 변수만 복사하면 적은 비용으로 복사가 될 것이다.

또한 동적 메모리를 가리키고 있는 포인터가 전달이 될 뿐 잃어버리는 것은 아니기 때문에 문제가 되지 않는다.

 

A -> B에 전달한다면 다음과 같은 일을 수행한다.

정확히 전달을 하는 과정이기 때문에 A는 자기 데이터를 초기화한다. (메모리 해제 X)

B. 멤버 변수 = A. 멤버 변수

A. 멤버 변수 = 초기화

 

이러한 생성자와 이동 대입 연산을 정의할 수 있는데,

매개변수로 r-value를 참조하는 변수, rhs를 받아 임시 객체(r-value)로부터 얕은 복사를 수행한다.

Request(Request&& rhs)
{
  _address = rhs.getAddress();
  _id = rhs._id;

  rhs.initAddress();
  rhs._id = 0;
  cout << "move constructor is called" << endl;
}
Request& operator=(Request&& rhs)
{
  if (_address) delete[] _address;
  _address = rhs.getAddress();
  _id = rhs._id;

  rhs.initAddress();
  rhs._id = 0;

  cout << "move assign is called" << endl;
  return *this;
}

initAddress()는 해당 인스턴스의 address에 nullptr을 대입하는데 다른 함수에서 호출하지 않도록 추가 작업이 필요할 것 같다.

 

다시 foo() 함수를 호출하는 예제를 실행하면 다음과 같이 이동 생성자와 이동 대입 연산이 호출된다.

이동 생성자와 이동 대입 연산자는 클래스 인스턴스가 반환되는 함수를 위해 쓰일 수 있다.

modern C++ compiler는 release모드로 빌드시 알아서 최적화 옵션에 따라 foo()같은 함수를 최적화하여 t 인스턴스를 반환 임시 객체로 취급하기 때문에 tmp(t)와 같은 복사 생성자는 일어나지 않는다.

 

R-value 참조형

이동 생성자와 연산자 오버로딩에 사용된 매개변수 Request&& rhs는 무엇 일까?

임시 객체 즉, 연산 결과나 함수 반환 값을 의미하고 대입되면 사라지게 되는데 이를 잡아 && 변수로 저장할 수 있다.

// 연산 결과에 대한 r-value 참조자
int r = 12;
int&& tmp1 = r * 2;

// 함수 결과값에 대한 r-value 참조자
int foo();
int&& tmp2 = foo();

r-value를 참조하는 && 변수(tmp2)는 이름이 있기 때문에 실제 존재하는 객체이고 메모리 상에 존재한다.

아니 r-value의 주소는 없다 그랬는데 그렇다면 &&변수는 l-value인 것인가 r-value 인 것인가?

-> 정답은 당연히 l-value이다.

왜냐하면 && 변수(r-value 참조자)는 r-value를 "참조"하는 이름있는 변수이기 때문이다.

& 변수(레퍼런스 타입 혹은 l-value 참조자)도 결국 l-value를 "참조"하는 이름있는 변수이다.

 

r-value 참조자는 l-value 타입

다음을 실행하면 a = tmp; 라인에서 이동 생성자가 정의되어 있더하더라도 복사 생성자(assign by other)가 호출된다.

왜냐하면 tmp는 r-value를 참조하는 l-value 변수이기 때문이다.

Request foo()
{
	Request t("192.168.15.2", 2);
	return t;
}
int main()
{
	Request a("192.168.15.5", 1);
	Request&& tmp = foo();
	a = tmp;
	cout << a.getAddress() << endl;
 	return 0;
}

 

std::move()

<utility> 헤더에 정의되어 있는 함수로 매개 변수에 대해 r-value 타입화 한다. 

즉, static_cast<T&&>() 와 같은 역할을 한다.

따라서 r-value를 참조하는 l-value 타입 tmp를 std::move() 함수를 통해 r-value화 하여 Request& operator=(Request&& rhs)가 오버로딩되고 이동 대입 연산이 수행된다.

Request foo()
{
	Request t("192.168.15.2", 2);
	return t;
}
int main()
{
	Request a("192.168.15.5", 1);
	Request&& tmp = foo();
	a = std::move(tmp);
	// a = static_cast<Request&&> (tmp);
	cout << a.getAddress() << endl;
	return 0;
}

a = foo(); 와 차이를 안다면 r-value를 참조하는 && 변수를 이해한 것이다.

tmp는 r-value를 잠시 잡아놓고 move에 의해 다시 r-value화 된다.

std::move()는 인스턴스를 r-value화 하여 해당 클래스의 이동 생성자와 이동 대입 연산이 오버로딩 되도록 해준다.

'Advanced C++' 카테고리의 다른 글

[C++] 예외 처리 Exception  (0) 2021.02.20
[C++ 클래스] 순수 가상 소멸자  (0) 2021.02.18
[C++ 클래스] 가상 함수 테이블  (0) 2021.01.29
[C++ 클래스] 가상 함수  (0) 2021.01.29
[C++] l-value와 r-value  (0) 2021.01.28