본문 바로가기
C,C++/C++

[C++] 복사 생성자 및 할당(=) 연산자가 호출되는 시점 | 초기화와 할당의 차이 | RVO(Return Value Optimization)

by woohyeon 2020. 2. 7.
반응형

먼저 초기화(Initializeinitialization)와 할당(Assignment)의 차이부터 살펴보자.

초기화는 객체의 생성과 동시에 초기 값을 설정해주는 것을 말한다.
흔히들 변수 선언만 해놓고 다른 곳에서 값을 할당하는 것을 초기화라고 하는 사람도 있는데, 엄밀히 말하면 이것은 초기화가 아니라 할당이다.

그 이유는, 일단 객체 선언 시 값을 대입을 하지 않으면 객체의 타입에 맞게 알아서 기본 초기화가 된다.
객체가 int, double과 같은 기본 타입(non-class)일 경우는 초기에 값을 할당하지 않을 경우 기본 초기화가 되며,  c++에선 기본 타입에 기본 초기화 시 정의되지 않은 쓰레기 값을 갖는다.

반면 string과 같은 class 타입의 객체는 타입에 알맞게 기본 생성자를 호출하여 초기화가 된다. 즉 클래스 타입은 클래스 내에서 정한 초기화 방식을 따른다. 따라서 객체의 선언과 동시에 값을 할당하지 않으면 이것은 초기화가 아니라 이미 초기화 된 객체에 값을 대입하는 할당이다.

아래는 초기화와 할당의 예제이다.

int a = 3;                // initialization
std::string str = "hello" // initialization
char c = 'h';             // initialization
double d;                 // initialization
d = 13.3;                 // assignment
std::string str2;         // initialization
str2 = "hola"             // assignment

즉 초기화는 좌변에 존재하지 않던 새로운 변수가 오며, 할당은 이미 존재하던 변수가 온다.
(복사 생성자와 할당 연산자에서 중요한 개념이므로 꼭 기억해두자!)


참고로 클래스의 멤버 변수를 적절하게 초기화하기 위해 사용하는 생성자를 작성할 때, 다음과 같이 생성자 본문에 작성하는 것은 초기화가 아니라 할당이다.

class MyClass
{
public:
   MyClass() 
   { 
      num = 4;       // assignment
      name = "hello" // assignment 
   } 
   
private:
   int num;
   string name;
};




C++의 클래스에선 초기화 리스트(Initializer list)를 제공하며, 생성자 본문보다 먼저 실행된다. 초기화 리스트에서 멤버 변수 값을 설정하면 변수의 메모리 할당 후 해당 값으로 초기화된다. 하지만 본문에 작성하는 것은 기본 초기화가 된 이후에 값을 설정하는 것이므로 초기화가 아닌 할당이다.

class MyClass
{
public:
   MyClass() 
     : num(5) // initialization
   { 
       name = "hello"; // assignment
   } 
   
private:
   int num;
   string name;
};

 

 

위 개념을 바탕으로 복사 생성자와 (복사)할당 연산자(=)의 차이와 호출 시점에 대해 간략하게 알아보자. 먼저 다음은 복사 생성자와 할당 연산자의 prototype이다.

// class A

A(const A& other);          // 복사 생성자
A& operator=(const A& rhs); // 할당 연산자


복사 생성자와 할당 연산자의 공통점은 '=' 연산자를 통해 호출된다는 점이다. 그리고 크게보면 복사 생성자와 할당 연산자는 인자로 들어온 객체를 좌변에 그대로 복사한다는 점에서 공통점을 가진다. 하지만 두 함수는 호출되는 시점이 다르다. 복사 생성자는 초기화와 비슷한 개념이며 할당 연산자는 할당과 비슷한 개념이다. 초기화가 좌변의 객체의 생성과 동시에 값을 부여한다는 의미를 그대로 복사 생성자에 적용하면 알맞다.

복사 생성자가 호출되었을 때 좌변의 객체는 이전에 메모리에 없던 객체이다. 즉, 새로운 객체이며 다음과 같이 선언과 동시에 객체를 할당할 경우 초기화 개념에서 복사 생성자가 호출된다.

A a1;
A a2 = a1; // 복사 생성자 호출

 

반면 할당 연산자는 새로운 객체가 아닌 이미 존재하던 객체에 새 객체를 복사하는 것이다. 따라서 초기화가 아닌 할당의 개념이다. 할당 연산자는 다음과 같은 상황에 호출된다.

A a1;
A a2;
a2 = a1; // 할당 연산자 호출

사실 위 두 상황은 기본적인 상황이기 때문에 복사 생성자와 할당 연산자가 뭔지 아는 사람이라면 다 알 것이다.

그럼 다음 상황에선 무엇이 호출될까?

class A
{
public:

	A() : mName() {}

	A(const string& name) : mName(name) {}
    
	A(const Test& other)
	  : mName(other.mName)
	{
		cout << "Copy constructor" << endl;
	}
    
	A& operator=(const A& rhs)
	{
 		mName = other.name;
		cout << "Assignment operator" << endl;
		return *this;
	}

	string mName;
};

A retObject()
{
  A a("woohyeon");
  return a;
}

A a1 = retObject(); // 1번
A a2;
a2 = retObject();   // 2번


정답 먼저 말하자면 1번은 복사 생성자가 호출되며, 2번은 복사 생성자와 할당 연산자가 차례로 호출된다.

- 1번
retObject 함수는 지역 객체를 생성 후 반환한다. 지역 객체 a를 만들기 위해 생성자를 호출한다. 그 다음 return 문에서 main의 a1 주소를 통해 a를 인자로 복사 생성자를 호출한다.  즉 a1->A(a), 호출자는 a1이고 인자는 a이다. main 함수의 a1은 복사 생성이 완료되었고 함수를 빠져나오면서 지역 객체 a는 사라지며 소멸자를 호출한다.  

1번

위 사진의 복사 생성자 호출자의 주소 0x00A6F898가 a1의 주소이며, 지역 객체(a)의 주소를 인자로 받는다.
즉 a를 a1으로 복사 생성한다.



- 2번 
a2 객체가 이미 선언되었기 때문에 초기화가 아닌 할당의 개념이다. 따라서 무조건 할당 연산자가 호출 되는 것이 맞다. 하지만 그 전에 한 단계가 더 있으며 할당 연산자 호출은 retObject 함수 내에서가 아니라 나중에 main함수에서 일어난다. 

일단 할당 연산자를 호출하기 전에 함수에서 리턴할 때 임시 객체를 위해 복사 생성자를 호출한다. 1번도 복사 생성자를 호출했지만 이번엔 호출자가 다르다. 1번에선 main 함수의 a1의 주소를 통해 직접 복사 생성을 했지만, 2번에선 이름이 없는 임시 객체가 복사 생성자를 호출한다. 임시 객체가 생성과 동시에 초기화를 하기 위해 a를 인자로 복사 생성자를 호출한다. 즉 임시객체->A(a), 호출자는 임시객체이고 인자는 a이다.

1번처럼 바로 main함수의 객체를 통해 직접 복사 생성자를 호출하지 않는 이유는 1번과 다르게 2번은 할당이기 때문이다. 복사 생성자는 새로운 객체를 복사 생성하기 위해 호출하는 것인데, a2는 존재하던 객체이므로 직접 복사 생성자를 호출할 순 없다. 

이제 임시 객체의 복사 생성이 완료되고 함수가 종료되면, 지역 객체 a는 소멸자를 호출하며 소멸이 되고 임시 객체는 함수를 호출한 main으로 이동한다. 여기서 좌변 a2에 복사 할당하기 위해 a2가 임시 객체를 인자로 할당 연산자를 호출하게 된다.

즉 a2->operator=(임시객체), a2가 호출자이며 임시 객체가 인자이다. a2에 복사한 뒤 복사 생성되었던 임시 객체는 소멸자를 호출하며 소멸된다.

2번

위 사진에서 할당 연산자(Assignment operator) 호출자가 a2이며 함수에서 복사 생성된 임시 객체인 0x012FFBC4를 인자로 받아 할당 연산자를 호출하고 있다.  



그럼 다음 코드는 무엇이 호출될까?

A retObject()
{
  return A("woohyeon");
}

A a = retObject(); // 1
A a2;
a2 = retObject();  // 2


결과 먼저 말하면 위 코드는 1, 2번 모두 복사 생성자가 호출되지 않는다. 다만 2번은 할당 연산자가 호출된다.

1번

 

2번



할당 연산자는 할당하기 위해 어쩔 수 없이 호출하는 것이니 그렇다 치고, 복사 생성자는 왜 호출이 되지 않을까 생각해보면 일단 직접적인 답은 RVO(Return Value Optimization) 기법 때문이다. 원래라면 복사 생성자가 호출되어야 하지만  컴파일러가 어떻게 어떻게 하여 복사 생성자를 호출하지 않도록 최적화해준 것이다.


그렇다면 무엇을 위해 어떻게 최적화를 했길래 복사 생성자가 호출되지 않은 것일까?


복사 생성자가 호출되었던 코드의 1번은 지역 객체 a를 위한 생성자와 a1을 복사 생성하기 위한 복사 생성자가 호출되었다. 목적은 1개의 객체를 반환하는 것인데 총 2번의 생성자가 호출되었다.




그리고 2번 역시 지역 객체를 위한 생성자와 임시 객체를 위한 복사 생성자 이렇게 총 2번 호출되었다.
(처음에 호출된 기본 생성자는 main함수에서 a2 생성을 위한 호출이다.)

2번



하지만 잘 생각해보면 객체 1개를 생성하여 반환하는 것인데 굳이 함수 내에서 생성을 하고 반환을 하기 위해 또 생성자를 호출할 필요가 없는 것이다. 임시 객체는 1회용인데 객체 생성 비용이 크다고 생각해보면 이것은 심한 메모리 낭비이다. RVO의 Idea는 이러한 불필요한 임시 객체의 사용과 소멸을 없애는 것이다. 즉 임시 객체를 생성하는 과정없이 함수 내에서 a1의 주소를 통해 직접 생성자를 호출하는 것이다. main 함수의 객체를 함수 내부로 옮겨 직접 연산을 한다고 생각하면 이해하기 편할 것이다.

1번

위 사진에서 생성자의 호출자 주소는 a1의 주소이고 인자는 임시 객체의 주소이다.



그런데 a2는 할당이 필요하기 때문에 할당 연산자를 호출하기 위한 인자로 임시 객체가 한 번은 필요하다. 하지만 아까처럼 복사 생성자를 호출하여 지역객체 따로 임시 객체 따로 2번 만드는 과정은 없어진다.

2번

처음에 호출된 기본 생성자는 main함수의 a2 생성을 위한 생성자이며, 두번째 생성자의 호출자 주소는 임시 객체이다.
세 번째는 a2가 임시 객체를 인자로 할당 연산자를 호출하여 값을 복사한다.  


위처럼 함수 내에서 A("woohyeon")과 같이 생성자로 반환 하면 컴파일러가 알아서 최적화를 하여 복사 생성자가 호출되지 않는다.
그런데 사실 아까 복사 생성자가 호출되었던 지역 객체를 반환하는 방식도 요즘 컴파일러들은 알아서 최적화를 해줘 복사 생성자가 호출되지 않게 해준다. 단 지역 객체를 반환하는 방식은 Debug모드에서 되지 않으며 Release 모드에서 적용이 된다.


이렇게 지역 객체의 이름을 반환해도 최적화가 되는 것을 NRVO(Named RVO)라고 한다. Release 모드에서는 다음과 같이 반환하여도 컴파일러가 알아서 생성자를 반환하듯이 최적화 해준다는 것이다. 

A retObject()
{
  A a("woohyeon");
  return a;
}



댓글