C++ Chapter 15.3 : 이동 생성자와 이동 대입 연산자

Date:     Updated:

카테고리:

태그:

인프런에 있는 홍정모 교수님의 홍정모의 따라 하며 배우는 C++ 강의를 듣고 정리한 필기입니다. 😀
🌜 [홍정모의 따라 하며 배우는 C++]강의 들으러 가기!


chapter 15. 의미론적 이동과 스마트 포인터

15.3 이동 생성자와 이동 대입 연산자

소유권을 박탈시켜 주지 않으면 동시에 두 포인터가 동일한 객체를 소유하게 되므로 나중에 한 포인터를 통해 메모리를 해제 시키면 문제가 생길 수 있다.

R-value 객체를 복사, 대입할 때 대처


🔔 복사 생성자, 대입 연산자 오버로딩

const L-value reference를 사용하는 복사 생성자, 대입 연산자 오버로딩

  • 복사, 대입시 ⭐소유권 이동, 박탈 문제를 고려해야 한다.
    • 소유권을 이동시킬 때 새로운 빈 공간을 만든 후 ✨깊은 복사✨로 내용물들을 이동 시킨다
    • 소유권 박탈은 복사 생성자 혹은 대입 연산자 호출이 끝난 후 기존 공간(복사 대상이 된 매개 변수)은 지역 변수이기 때문에 자동으로 사라지므로 수동으로 박탈해줄 필요는 없다.
      • 어차피 새로운 인스턴스를 만들어 그 쪽으로 옮겨준 것이라 애초에 소유한 것이 서로 다른게 된다.
      • 따라서 깊은 복사로 내용물만 복사해서 옮겨준 것.

📜Resource.h

복사생성자대입 연산자로 소유권 이동, 박탈 정상 작동

  • 💙복사 생성자
    • Resource(res.m_length);
      • 복사할 대상이 되는 객체(인수)의 멤버를 생성자 인수로 넘긴다.
        • 이걸로 포인터 멤버인 m_data에 그 크기만큼의 새로운 공간을 할당하고 m_length에 대입함
    • m_data내용물들 깊은 복사하여 이사시킨다.
      • for문 m_data[i] = res.m_data[i];
  • 💙대입 연산자 오버로딩
    • 대입하려는게 자기 자신일 경우 아무것도 하지 않고 자기 자신만 리턴
      • 복사 생성자를 호출할땐 필요 없지만 대입 시엔 필요한 과정
    • 기존 공간 비우기. 메모리 누수 방지
      • 복사 생성자를 호출할땐 필요 없지만 대입 시엔 필요한 과정
      • if (this->m_data != nullptr) delete[] m_data;
    • 포인터 멤버인 m_data새로운 공간을 할당하고 m_length에 대입함
      • m_length = res.m_length;
      • m_data = new int[m_length];
    • m_data내용물들 깊은 복사하여 이사시킨다.
      • for문 m_data[i] = res.m_data[i];
#include <iostream>
using namespace std;

class Resource
{
public:
	int * m_data = nullptr;
	unsigned m_length = 0;

public:
	Resource() // 기본 생성자
	{
		cout << "Resource constructed" << endl;
	}

	Resource(unsigned length) // 일반 매개변수 1개 생성자
	{
		cout << "Resource length constructed" << endl;
		this->m_data = new int[length];
		this->m_length = length;
	}

	Resource(const Resource &res) // 💎복사 생성자💎 
	{
		cout << "Resource copy constructed" << endl;
		
		Resource(res.m_length);

		for (unsigned i = 0; i < m_length; ++i)  // 내용물을 전부 깊은 복사 (시간이 꽤 걸림)
			m_data[i] = res.m_data[i];
	}
 
	~Resource()  // 소멸자
	{
		cout << "Resource destroyed" << endl;
	}

	Resource & operator = (Resource & res)  // 💎대입 연산자 오버로딩💎
	{
		cout << "Resource copy assignment" << endl;

		if (&res == this) return *this; // 대입하려는게 자기 자신이면 아무것도 안함
		
		if (this->m_data != nullptr) delete[] m_data; // 1. 내 자신의 m_data 비워주기

		m_length = res.m_length; // 2. 대입으로 넘겨받은 res의 length 로 내 length 갱신
		
		m_data = new int[m_length]; // 3. 비워진 내 자신의 m_data에 새로운 공간 할당받기
		for (unsigned i = 0; i < m_length; ++i) // 4. m_data내용물 넣기.
			m_data[i] = res.m_data[i]; //  대입으로 넘겨받은 res의 m_data 내용물들을 **내 m_data**에 깊은 복사

		return *this;
	}   
};

📜AutoPtr.h

  • 템플릿 클래스이다.*
    • TResource 타입을 받아 Resource타입의 스마트 포인터 역할을 할 것이다.
      • Resource의 대입 연산자를 호출하여 소유권을 확실히 이전시킨다.

복사생성자대입 연산자로 소유권 이동, 박탈 정상 작동

  • 💙복사 생성자
    • m_ptr = new T;
      • 새로운 공간을 할당
    • *m_ptr = *a.m_ptr;
      • Resource의 ‘대입 연산자 오버로딩 호출
        • m_data내용물들 깊은 복사하여 이사시킨다.
  • 💙대입 연산자 오버로딩
    • 대입하려는게 자기 자신일 경우 아무것도 하지 않고 자기 자신만 리턴
      • 복사 생성자를 호출할땐 필요 없지만 대입 시엔 필요한 과정
    • 기존 공간 비우기. 메모리 누수 방지
      • 복사 생성자를 호출할땐 필요 없지만 대입 시엔 필요한 과정
      • if (m_ptr != nullptr) delete m_ptr;
    • m_ptr = new T;
      • 새로운 공간을 할당
    • *m_ptr = *a.m_ptr;
      • Resource의 ‘대입 연산자 오버로딩 호출
        • m_data내용물들 깊은 복사하여 이사시킨다.
  • 💙어차피 함수 매개변수로서 지역 변수이기 때문에 복사생성자, 대입 연산자 호출이 끝나면 매개 변수 수명도 끝나므로 자동으로 소유권 박탈이 이루어 진다.
#include <iostream>
using namespace std;

template<typename T>
class AutoPtr
{
public:
	T* m_ptr;

public:
	AutoPtr(T* ptr = nullptr)
		:m_ptr(ptr) 
	{
		cout << "AutoPtr default constructor" << endl;
	}
	~AutoPtr()
	{
		cout << "AutoPtr destructor" << endl;

		if (m_ptr != nullptr) delete m_ptr;
	}
	AutoPtr(const AutoPtr& a) // 💎복사 생성자💎
	{
		cout << "AutoPtr copy constructor" << endl;

		// deep copy
		m_ptr = new T;  // T가 Resource 타입으로 들어오면 m_ptr은 Resource 타입의 포인터
		*m_ptr = *a.m_ptr;  // ⭐Resource의 '대입 연산자 오버로딩 호출
	}
	AutoPtr& operator = (const AutoPtr& a) // 💎대입 연산자 오버로딩💎
	{
		cout << "AutoPtr copy assignment" << endl;
		
		if (&a == this)
			return *this;

		if (m_ptr != nullptr) delete m_ptr;

		// deep copy
		m_ptr = new T;  // 새로운 빈 공간 할당 받기. T가 Resource 타입으로 들어오면 m_ptr은 Resource 타입의 포인터
		*m_ptr = *a.m_ptr; // ⭐Resource의 '대입 연산자 오버로딩' 호출 

		return *this;
	}
	T& operator *() const { return *m_ptr; }
	T* operator ->() const { return m_ptr; }
	bool inNull() const { return m_ptr == nullptr; }
};

📜main.cpp

#include <iostream>
#include "AutoPtr.h"
#include "Resource.h"
#include "Timer.h"

AutoPtr<Resource> generateResource()  // AutoPtr<Resource> 타입을 리턴하는 함수
{
   // 10000000 의 length를 가진 Resource타입의 멤버를 가지는 AutoPtr 객체 생성
	AutoPtr<Resource> res(new Resource(10000000));

	return res;
}

int main()
{
	using namespace std;
	streambuf * orig_buf = cout.rdbuf();
	// cout.rdbuf(NULL); 화면에 출력되는 메세지들 끄기. 시간 어마어마하게 걸릴테니까 😎

	Timer timer;
	{
		AutoPtr<Resource> main_res; // ⭐
		main_res = generateResource(); // ⭐ generateResource() 리턴값은 R-value 
	}
		cout.rdbuf(orig_buf);
		cout << timer.elapsed() << endl; // 실행시간 재서 출력
	}
}
💎출력💎

AutoPtr default constructor
Resource length constructor
AutoPtr default constructor
AutoPtr copy assignment
Resource default constructor
Resource copy assignment
AutoPtr destructor
Resource destroyed
AutoPtr destructor
Resource destroyed
0.0392126

실행 순서

  1. AutoPtr 디폴트 생성자
    • AutoPtr<Resource> main_res; 에서 호출 됨
  2. Resource 매개변수 1개 짜리 생성자
    • generateResource() 함수 내부에서 new Resource(10000000); 에서 호출 됨
  3. AutoPtr 디폴트 생성자
    • generateResource() 함수 내부에서 AutoPtr<Resource> res(new Resource(10000000)); 에서 호출 됨
  4. AutoPtr 대입 연산자 오버로딩
    • main_res = generateResource(); 에서 호출 됨
    • 함수의 리턴 값을 main_res에 대입하는 과정에서 호출됨
    • 대입 연산자의 매개변수 aconst인 L-Value Reference이기 때문에 generateResource() 함수 리턴값 같은 R-Value도 참조할 수 있다.
  5. Resource 디폴트 생성자
    • 4번의 AutoPtr 대입 연산자 오버로딩 내부의 m_ptr = new T; 에서 호출됨. m_ptr = new Resource; 나 마찬가지!
  6. Resource 대입 연산자 오버로딩
    • 4번의 AutoPtr 대입 연산자 오버로딩 내부의 m_ptr = *a.m_ptr; 에서 호출 됨
  7. 첫번째 AutoPtr, Resource 소멸자
    • generateResource() 실행이 종료 됨에 따른 AutoPtr<Resource> res(new Resource(10000000)); 에서의 소멸
  8. 두번째 AutoPtr, Resource 소멸자
    • 진짜 main 에서의 두 객체 소멸

위 결과는 “Release” 모드로 실행했을 때의 결과이다.

💎출력💎

AutoPtr default constructor
Resource length constructed
AutoPtr default constructor
AutoPtr copy constructor
Resource default constructed
Resource copy assignment
AutoPtr destructor
Resource destroyed
AutoPtr copy assignement
Resource default constructed
Resource copy assignment
AutoPtr destructor
Resource destroyed
AutoPtr destructor
Resource destroyed

Copy elision

위 결과는 “Debug” 모드로 실행했을 때의 결과이다. 디버그 모드로 실행했을 땐 릴리즈 모드와 달리 generateResource() 함수의 res 이 리턴값이 임시 객체에 복사되는 과정에서 AutoPtr 복사 생성자(copy constructor)를 호출하는 것을 확인할 수 있다. C++ 컴파일러는 경우에 따라 최적화를 위하여 복사생성자 호출을 스킵해주는데 릴리즈 모드에선 함수 리턴값이 임시 객체에 복사되는 과정에 대해선 복사 생성자 호출을 생략해준다. Copy elision

https://stackoverflow.com/questions/33795529/does-a-return-by-value-call-the-copy-constructor-or-the-copy-assignment-operator


🔔 이동 생성자, 이동 대입 연산자 오버로딩

R-value reference(&&)를 사용하는 이동 생성자, 이동 대입 연산자 오버로딩. const가 빠진 것에 주의!

  • AutoPtr&& a
    • R-value referencea가 참조하는 인스턴스는 메모리에 자리 잡지 않고 잠깐 있다가 사라질 R-Value.
  • 소유권 이동시 내용물들을 깊은 복사로 이전시킬 필요 없이 ✨얕은 복사✨로 집 열쇠만 넘겨주면 된다.
    • 따라서 복사 생성자, 일반 대입 연산자 오버로딩에 비해 훨씬 빠르다.
      • for문으로 데이터를 전부 복사시키는 깊은 복사를 하지 않기 때문에
    • 얕은 복사 👉 m_ptr = a.m_ptr;
      • 📢 Resource의 대입 연산자 오버로딩은 호출 되지 않는다.
        • Resource의 대입 연산자의 매개변수는 const가 안붙은 & 레퍼런스이므로 다른 함수라고 인식되어 오버로딩 되지 않음.
          • Resource의 대입 연산자는 깊은 복사를 수행하기 때문에 깊은 복사가 되지 않도록 Resource의 대입 연산자 매개변수는 그냥 L-value Reference로 설정
  • 소유권을 박탈을 반드시 해주어야 한다.
    • a.m_ptr = nullptr;
    • 별개의 새로운 공간을 만들어 내용물 데이터들을 복사한게 아니라 두 포인터가 같은 인스턴스를 가리키게 된 것이므로(✨얕은 복사✨) 복사 대상이 된 포인터의 소유권은 박탈 시켜 주어야 한다.
    • 박탈 시키지 않으면 대입 연산자 인수로 이 인스턴스를 참조 하게 된 매개 변수 AutoPtr && a가 대입 연산자 호출이 종료됨에 따라 소멸자가 호출되어 delete될 수 있기 때문이다.

📜AutoPtr.h

이동 생성자, 이동 대입 연산자 오버로딩를 사용.

template<typename T>
class AutoPtr
{
public:
	T* m_ptr;

public:
	AutoPtr(T* ptr = nullptr)
		:m_ptr(ptr) 
	{
		cout << "AutoPtr default constructor" << endl;
	}
	~AutoPtr()
	{
		cout << "AutoPtr destructor" << endl;

		if (m_ptr != nullptr) delete m_ptr;
	}

	AutoPtr(AutoPtr && a)  // ⭐이동생성자⭐ 
		:  m_ptr(a.m_ptr) // ⭐얕은 복사⭐ 그냥 대입만 하면 땡이다!
	{ 
		cout << "AutoPtr move constructor" << endl;

		a.m_ptr = nullptr; // really necessary?
	}

	AutoPtr& operator = (AutoPtr && a)  // ⭐*이동 대입 연산자 오버로딩⭐ 
	{
		cout << "AutoPtr move assignment" << endl;

		if (&a == this)
			return *this;

		// 공간은 비워줘야하는 것 똑같고 (delete 안하고 그냥 대입하면 메모리 누수가 발생할 수 있다)
		if (m_ptr != nullptr) delete m_ptr; 
		
		m_ptr = a.m_ptr; // ⭐얕은 복사⭐ 그냥 대입만 하면 땡이다!
		a.m_ptr = nullptr; // 소유권 박탈

		return *this;
	}
	T& operator *() const { return *m_ptr; }
	T* operator ->() const { return m_ptr; }
	bool inNull() const { return m_ptr == nullptr; }
};

📜main.cpp

#include <iostream>
#include "AutoPtr.h"
#include "Resource.h"
#include "Timer.h"

AutoPtr<Resource> generateResource()  
{
	AutoPtr<Resource> res(new Resource(10000000));

	return res;
}

int main()
{
	using namespace std;
	streambuf * orig_buf = cout.rdbuf();
	// cout.rdbuf(NULL); 화면에 출력되는 메세지들 끄기. 시간 어마어마하게 걸릴 테니까!

	Timer timer;
	{
		AutoPtr<Resource> main_res;
		main_res = generateResource();  //generateResource() 리턴값은 R-value 
	}
		cout.rdbuf(orig_buf);
		cout << timer.elapsed() << endl; // 실행시간 재서 출력
	}
}
💎출력💎

AutoPtr default constructor
Resource length constructor
AutoPtr default constructor
AutoPtr copy assignment
AutoPtr destructor
AutoPtr destructor
0.00590591
  • 깊은 복사를 하지 않는 다는 것을 알 수 있다.
    • Resource의 복사 생성자를 호출하지 않는 것을 볼 수 있다.
    • 실행시간 또한 복사생성자, 대입 연산자 오버로딩에 비해서 더 빠른 것을 볼 수 있다


🔔 정리

메모리에 잠시 동안만 존재하다가 사라지는 R-value 인스턴스를 대입, 복사 할 때 대처 방법

R-value의 소유권을 이전할 때 2 가지 방법이 있다. 1️⃣깊은 복사 2️⃣얕은 복사 얕은 복사가 깊은 복사보다 더 빠르다.

AutoPtr<Resource> main_res;  // Resource 타입의 스마트 포인터 역할을 한다.
main_res = generateResource(); // R-value 💛
💎깊은 복사💎

📜AutoPtr 대입 연산자 

m_ptr = new T;    // 새로운 공간 할당
*m_ptr = *a.m_ptr; // ⭐깊은 복사⭐ 를 실행하는 📜Resource의 대입 연산자 호출


📜Resource 대입 연산자 호출

m_data = new int[m_length]; // 새로운 빈 공간 할당
for (unsigned i = 0; i < m_length; ++i) 
	m_data[i] = res.m_data[i]; // 새로운 빈 공간에 내용물 옮기기. ⭐깊은 복사⭐
💎얕은 복사💎
📜AutoPtr 대입 연산자 

m_ptr = a.m_ptr; // 소유권 이전 ⭐얕은 복사⭐  📜Resource의 대입 연산자 호출하지 않는다.
a.m_ptr = nullptr; // 소유권 박탈

image

R-value를 무사히 받기 위한 첫 번째 방법 👉 const L-value reference

L-value, R-value 모두 다 참조할 수 있다.

  • Resource타입 임시 객체를 리턴하는 함수 리턴 값 generateResource() 같은 R-value도 참조 가능!

const라 참조 하는 대상의 값을 수정할 수 없다. 👉 소유권 박탈 불가능

  • 소유권 이동새로운, 별개의 인스턴스를 만들어서 그 곳에 내용물들을 싹 다 복사해서 옮긴다(깊은 복사)
    • 얕은 복사는 할 수 없고 깊은 복사만 가능한 이유
      • 깊은 복사는 느리다. 그러나 const L-value reference를 사용하면 빈 인스턴스를 새로 만들어서 그 곳으로 내용물들을 전부 복사하는, 깊은 복사 방식을 사용할 수 밖에 없다.
        • 단순히 m_ptr = a.m_ptr 이렇게 포인터만 복사하여 집 열쇠만 넘겨주는 식의 얕은 복사를 하려면 반드시 복사 대상이 된 포인터의 해당 인스턴스에 대한 소유권을 박탈(a.m_ptr = nullptr)시켜 주어야 하는데
          • 소유권을 박탈시켜 주지 않으면 동시에 두 포인터가 동일한 객체를 소유하게 되므로 나중에 한 포인터를 통해 메모리를 해제 시키면 문제 발생
        • aconst L-value reference, 즉 const 하기 때문에 값을 nullptr로 값을 바꿔줄 수가 없기 때문이다. 즉, 소유권 박탈이 불가능하기 때문에 얕은 복사를 할 수 없음.
        • 따라서 R-value를 복사 혹은 대입하려고 할 때 const L-value reference인수를 가진 복사 생성자, 대입 연산자를 사용한다면 아예 별개의 새로운 인스턴스를 만들 수 밖에 없다. 그리고 그 곳에 내용물들을 전부 복사하는 깊은 복사 방식을 할 수 밖에 없다.
          • 복사 대상이 된 기존의 인스턴스는 복사 생성자, 대입 연산자의 호출이 끝남과 동시에 지역 변수로서 delete 되어 사라져도 영향 받는게 없다. 애초에 소유권이 다른 별개의 새로운 인스턴스를 만들어 내용만 복사한 것이니까 기존의 인스턴스의 소유권을 박탈 시킬 필요는 없다.
            • 자동으로 사라지니까
      • 사실 엄밀히 말하면 아예 별개의 인스턴스이므로 동일한 인스턴스의 소유권 이전이 일어난 것은 아닌 셈이다. 전혀 다른 인스턴스에 내용만 복사 받았을 뿐임!
    • m_ptr = new T;
      • Resource 타입의 별개의 새로운 인스턴스를 만들고 이를 가리키게 함
    • *m_ptr = *a.m_ptr;
      • Resource의 대입 연산자를 호출한다.
        • 여기서 내용물 전부를 복사하는 깊은 복사를 수행한다.
  • 장점 👉 L-value 참조도 가능하므로 유연하다.
  • 단점 👉 깊은 복사를 사용하므로 느리다.

R-value를 무사히 받기 위한 두 번째 방법 👉 R-value reference

R-value만 참조 가능하다.

const가 아니기 때문에 참조 하는 대상의 값을 수정할 수 있다. 👉 소유권 박탈 가능

  • 소유권 이동그냥 포인터만 복사하여 집 열쇠만 바꿔 준다. 동일한 인스턴스에 대한 소유권이 두 포인터에게 있게 됨!(얕은 복사)
  • 반드시 기존 포인터의 소유권을 박탈시켜 주어야 한다. R-value를 담고 있는 기존 참조 변수는 이동 생성자, 이동 대입 연산자 오버로딩의 지역변수이므로 끝나고 delete되기 때문에 현재 인스턴스의 소유권을 두 포인터가 가지고 있으므로 반드시 소유권 박탈을 시켜주어야 한다!
    • a.m_ptr = nullptr
      • nullptr이 되었기 때문에 소멸자에서 delete되지 않음!
    • 안해주면 a.m_ptr이 참조하는 임시생성객체의 Resource 객체도 소멸 되므로 m_ptr = a.m_ptr 이렇게 주소 바꿔준게 헛수고가 된다.
  • 장점 👉 얕은 복사를 사용하므로 빠르다.
  • 단점 👉 오직 R-value만 참조 가능하다.


🔔 번외) 복사 생략

컴파일러는 불필요한 복사 생략자 호출을 막기 위하여 일부 경우에 대해서는 복사를 생략한다.

모두의 코드를 참고하였다.

#include <iostream>

class A {
  int data_;

 public:
  A(int data) : data_(data) { std::cout << "일반 생성자 호출!" << std::endl; }

  A(const A& a) : data_(a.data_) {
    std::cout << "복사 생성자 호출!" << std::endl;
  }
};

int main() {
  A a(1);  // 일반 생성자 호출
  A b(a);  // 복사 생성자 호출

  A c(A(2)); // 일반 생성자만 호출한다.
}
  • A c(A(2));
    • R-value인 익명 객체 A(2)가 생성되면서 일반 생성자가 호출된다.
    • 뒤 이어 A(2)를 복사하여 c를 생성하며 복사 생성자가 호출될 것 같지만 실제론 복사 생성자가 호출되지 않는다! const L-value Reference는 R-value를 받을 수 있는데도 불구하고!
      • 굳이 ‘복사’를 하지 않고 그냥 c 자체를 A(2)로 만들어진 객체로 해버리는 것이 성능적으로 더 낫기 때문이다.
  • 이처럼 컴파일러 판단하에 복사를 생략해 버리는 작업을 복사 생략이라고 한다.


🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기