본문 바로가기
컴퓨터/Modern Effective C++ 정리

Modern Effective C++ Item 17 : 자동 생성 함수 in C++11

by 김짱쌤 2015. 4. 8.

특수 멤버함수 자동 생성 in C++11

특수 멤버함수 자동 생성 in C++ 98

이펙티브 C++에서 말한 것처럼, 컴파일러가 프로그래머 몰래 만드는 함수들이 있다. 클래스에게 기본적인( 생성자, 소멸자, 복사생성자, 복사 대입 연산자 )함수들이 필요할 때, 사용자가 해당 함수를 정의하지 않았다면 컴파일러가 유저의 편의를 위해 자동으로 만들어 준다. 이 말인 즉, 정의한다면 자동 생성함수가 만들어지지 않는다는 뜻이다.

이런 자동 생성 멤버함수들을 Scott Meyers는 특수 멤버함수(Special Member Function)라고 말한다. 이런 특수멤버함수들이 자동으로 생성되면 public, inline형태로 선언되고, 각 비정적 멤버 변수의 해당 함수(생성자, 소멸자, 복사 생성자, 복사 대입 연산자)를 호출하는 형식으로 만들어진다.

C++ 11부터 추가된 특수 멤버함수

Scott Meyers님이 C++11의 중요한 변화로 지목했던 것중 하나가 rValue의 등장이었다. 불필요한 복사와 소멸자 호출을 줄이는 이 개념을 제대로 지원하기 위해서는 클래스의 move 생성자 및 대입 연산자가 필요하다. 그래서 C++ 11부터는 Move 생성자와 대입 연산자가 특수 멤버함수에 추가되었다. 그러니까 컴파일러의 자동생성도 지원한다는 의미이다.

자동 생성된 이 함수의 default동작구조는 복사생성자나 복사대입연산자와 거의 같다. 모든 비 정적 멤버 변수들에 대해서 move 생성자 또는 대입 연산자를 호출하는 것이다. 좀더 정확하게 말하자면 move하길 요청하는 것이다. 왜냐면 모든 비정적 멤버 변수의 타입이 move를 지원하진 않을 것이기 때문이다. 이 경우 복사하는 방식으로 move(사실상 복사함수지 이름만 move)한다. 해당 함수가 호출 되었을 때 move 지원 여부를 보고 둘중 어떤 것을 수행해야 하는지가 결정된다. item 23에서 이 부분에 대해서 자세히 다룰 예정이다.

여기까지는 move가 자동생성될때 그 동작이 실제로 move일 수도 있고, copy일 수도 있음을 밝혔고 그때 수행되는 default move동작과 copy동작의 차이를 말했다. 이제부터는 move생성자, 대입연산자와 copy생성자, copy 대입 연산자의 생성시점에 대한 차이를 말할 것이다. 앞서 이야기한것은 default 동작의 차이를 말한 것이고 이제부터 말할 것은 함수의 생성시점을 말하는 것이다. 이건 C++98과 C++11의 자동생성 조건 차이에 대한 이야기이다.

Copy 생성자, 대입 연산자의 생성 시점 (in C++98)

copy 생성자와 copy 대입 연산자의 자동 생성은 독립적이다. copy생성자를 선언하고 && copy대입 연산자를 선언하지 않은경우, copy 생성자는 자동생성되지 않을 것이고, 대입 연산자는 자동생성된다. 반대의 경우도 마찬가지이다. 즉, 이 두 함수의 생성 조건이 서로서로에게 영향을 끼치지 않는다. 서로서로 뿐 아니라 다른 어떤 특수 멤버함수에게도 영향을 받지 않는다. 

복사 생성이 필요하다 -> 복사 생성자가 없다 -> 자동생성. 

이게 C++ 98 컴파일러의 심플한 마인드. 이렇게 말했으니 move(C++11)에서는 뭐가 다를지 좀 감이 잡힐 것이다.

move 생성자, 대입 연산자의 생성 시점 (in C++11)

move 생성자와 move 대입 연산자의 자동 생성은 의존적이다. 위의 copy의 예와는 달리 move 생성자와 대입 연산자 둘중 하나만 정의해도 둘다 자동생성이 안된다. 여기까지는 추측가능하겠지만, 중요한건 왜냐고!

move생성이든 대입이든 따로 만들었다는 것은, 기존 default move(전 멤버 move)만으로는 유저가 원하는 move 동작을 할 수 없다는 뜻이다. (꼭 그렇진 않더라도 컴파일러는 그렇게 생각한다.) 그러면 컴파일러는 default move동작을 믿을 수 없다고 판단하여 move 생성과 move 대입연산 모두를 자동 생성하지 않아버리는 것이다.

그리고 C++11컴파일러의 까다로움은 여기서 끝나지 않는다. 심지어 복사생성자( 또는 대입 연산자)가 선언되어있기만 해도 move 함수들을 자동 생성하지 않는다. (왜냐고!) 일단 copy랑 move가 유사한 점이 많기 때문에 유저가 원하는 복사가 일반적인 멤버 복사와 다르다면, move도 다를 가능성이 높다고 생각하는 것 같다.

필자처럼 상상력이 부족한 많은 사람들을 위해서 예시를 하나 들어본다. 복사 생성자를 따로 만들어야 하는 흔한경우를 하나 생각해보자.

class Widget
{
public:
   Widget(const Widget&){
     ...
   }
private:
   char*   mString;
}

char* 문자열을 멤버 변수로 가지고 있는 Widget 클래스의 경우, 일반 복사를 하면 주소를 복사해 온다. 그러면 같은 대상을 서로다른 인스턴스가 들고 있기 때문에 소멸자 호출 등에서 문제가 생길 가능성이 크다. 그래서 보통 깊은 복사라는 것을 수행한다.

이때 move생성및 대입을 default 동작으로 했다고 생각해보자. char*타입에 대한 move가 따로 없으니 복사할 거고, 복사하면 또 주소를 그대로 가져오는 불상사가 발생한다. 그러면 rValue인 상대방은 라인을 넘으면 소멸자를 호출할 건데, 거기서 문자열을 해제해버린다면? 뒤는 상상에 맡긴다. 결국 깊은 복사가 필요한 경우, move도 다른 대안이 필요하다.

이런 이유에서 c++11부터는 좀더 민감하게 특수함수의 자동 생성을 체크한다. 그렇다고 이전에 있던 자동 생성 조건을 다 바꿔버릴 수는 없으니(for 레거시 코드), C++98에는 없던 move에 관련한 자동 생성 조건만 까다롭게 설정을 한거다. 그런 의미에서 move가 따로 선언되어있다면 copy도 자동 생성하지 않는다. 이유는 그 역의 경우 설명한 이유와 다를 것이 없다고 생각한다. 그러면 C++ 11부터 Copy의 자동 생성 조건이 좀 달라진 것이다. copy 자동 생성 조건은 move 생성자와 대입 연산자의 생성조건에 의존적이다.

Rule Of Three

"복사 생성자, 복사 대입 연산자, 소멸자 하나라도 정의하면 다른 것들도 다 정의해줘야 한다."는 룰 자원관리가 필요한(ex: new & delete/ get & release)멤버를 가진 클래스들에서 보통 이 룰이 적용된다. 위에서 든 문자열을 지닌 클래스의 예도 이것에 해당한다. 이 셋이 같이 다녀야 하는 이유는 다음과 같다.

  1. 자원관리가 필요한 멤버는 복사 생성시 새로 할당받은 다음에 내용을 복사해야한다.
  2. 복사 대입연산에서는 미리 할당받은 멤버의 내용을 복사한다.
  3. 자원관리가 필요한 멤버는 소멸자에서 자원 해제를 해줘야한다.

자원관리를 하는 대표적 클래스인 stl에서 복사 생성자, 복사대입 연산자, 소멸자를 각각 정의하는 이유도 위와 같다. C++98을 만들었을 때는 Big Three 룰을 별로 신경쓰지 않았다. 그래서 소멸자는 따로 만들었는데 복사 연산은 자동생성된 Default를 쓰는 코드도 많이 있다. 이제와서 신경쓰이는 그 녀석을 컴파일러 수준에서 강제하려고하면 예전에 작성한 코드들이 문제를 일으킬 것이다. 그래서 C++11에서도 이걸 강제할수는 없다고 한다. 대신 이번에 새로 들어온 신입부터는 이 까다로운 룰을 적용하는 것이다.

그러니까 move 연산자들은 복사 연산자들 뿐 아니라 소멸자가 정의된 경우에도 자동 생성이 안된다. move 연산자들이 자동 생성되는 조건을 다시 정리해보면 다음과 같다.

  • move 연산자들이 정의되지 않았을 때
  • copy 연산자들이 정의되지 않았을 때
  • 소멸자가 정의되지 않았을 때

default 선언

원칙적으로 이 룰은 소멸자나 복사 연산자들의 자동생성에도 영향을 미쳐야한다. 실제로 C++11부터 이 룰에 어긋난 자동 생성들을 권장하지 않는다(deprecate). C++ 98에서부터 코딩하던 사람들은 새로운 룰이 성가시다고 생각할 수 있다. default 복사만으로도 충분한데도 복사 연산자들을 만들라고 강요한다. 이런 성난 군중을 달래기 위해서 C++ 11은 default라는 좋은 키워드를 제공한다. default로 선언한 함수는 따로 정의해주지 않아도 기존의 자동 생성함수와 동일한 기능을 수행한다. 그러니 권장사항에도 맞출 수 있고 귀찮지도 않다.

class Widget {
public:~Widget();                          // 소멸자는 선언
Widget(const Widget&) = default;    // default 복사로 충분하다
Widget& operator=(const Widget&) = default; // 이하 동문
…
};

default를 사용하면 좋은 경우

본래 Big 3 rule이 적용되는 것이 일반적이기 때문에 이렇게 사용하는 경우가 그리 많지는 않다. 하지만 다형성을 지원하는 클래스를 만들어야 하는 경우 내용은 default 소멸자와 같더라도 반드시 virtual로 따로 소멸자를 정의해야한다. 그러면 copy연산자들과 move 연산자들도 정의해야되는데 ... 괜히 화가난다. 이럴 때 = default 선언을 활용하면 덜 귀찮고 더 깔끔한 코드가 나온다.

class Base {
public:
   virtual ~Base() = default; 
   Base(Base&&) = default;
   Base& operator=(Base&&) = default;
   Base(const Base&) = default;
   Base& operator=(const Base&) = default;
…
};

자동 생성보다는 default를 사용하자

뭐든지 명시적으로 작성하는 것이 가독성이 좋고 오류를 찾아내기 쉽다. default 선언 자체가 의미를 파악하기 쉬운 형태로 되어 있기에 모든 특수 함수에 대해서 선언은 다 해놓고 자동 생성되는 경우에 default를 사용하는 것이 효율적이다. 심지어 default를 사용하면 자동생성에서 발생하는 버그를 줄일 수 있다.

class StringTable {
public:
   StringTable() {}
private:
   std::map<int, std::string> values;
};

위 StringTable클래스의 코드는 전혀 문제가 없는 코드이다. std::map은 자체적으로 자원을 관리하기 때문에 복사 연산자, move 연산자, 소멸자들이 내부적으로 잘 구현되어있다. 그러니 우리는 자동생성해주는 default 형태의 Big Three로 충분히 이 클래스를 운영할 수 있다.

class StringTable {
public:
   StringTable()
   { makeLogEntry("Creating StringTable object"); }
   ~StringTable() 
   { makeLogEntry("Destroying StringTable object"); }
private:
   std::map<int, std::string> values;
};
...
StringTable MakeStringTable(); //위 클래스의 팩토리 함수
StringTable table = MakeStringTable(); //rvalue를 받았을 때 복사생성자가 호출!

생성 소멸 시점에 로그를 찍기 위해서 위 예제 클래스를 살짝 바꿧다. 로그를 찍기 위해서 자원 해제와 상관없는 소멸자를 따로 만들었다. 이러면 자동 생성 룰에 의해서 copy연산자들은 자동 생성되고 move연산자들은 자동 생성되지 않는다. move 연산자들이 자동으로 만들어지지 않았기 때문에 move 연산이 이루어져야하는 경우 copy를 수행하게 된다.

멤버인 std::map에 대해서 본래 move 연산이 수행되야 할것을 복사 연산을 수행하게 된다. 이 두 연산의 성능 차이는 매우 크다. 이제 이 코드를 짠 프로그래머는 성능체크를 하면서 이렇게 생각할 것이다. "여기가 갑자기 왜이렇게 느려졌지? 로그 찍는게 느린가?." 하지만 문제는 감춰져 있는 컴파일러의 자동생성 로직 때문에 발생한 것이다. 따라서 이 문제의 원인을 찾기가 쉽지않다. 하지만 처음부터 default를 사용하여 모든 특수 멤버 함수들을 선언하였다면, 이런 문제는 발생하지 않는다.

class StringTable {
public:
   StringTable()
   { makeLogEntry("Creating StringTable object"); }
   ~StringTable() 
   { makeLogEntry("Destroying StringTable object"); }

   StringTable(const StringTable&) = default;   
   StringTable& operator=(const StringTable&) = default;
   StringTable(const StringTable&&) = default;   
   StringTable& operator=(const StringTable&&) = default;

private:
   std::map<int, std::string> values;
};
...
StringTable table = MakeStringTable(); //rvalue를 받았을 때 move생성자가 호출!

자동생성 in C++ 11 총정리

  • default 생성자 : 생성자 정의하면 자동 생성 안된다. C++ 98과 동일
  • 소멸자 : 부모가 virtual이면 자식도 virtual로 자동 생성. 정의하면 자동생성안된다. 여기까지 C++ 98 C++ 11부터 바뀐점은 자동 생성된 소멸자가 noexcept로 만들어진다는 점.
  • copy 연산자들 : 해당 함수가 선언되지 않았을 때, move 연산자들이 정의되지 않았을 때 자동 생성된다. 다른 copy연산자나, 소멸자가 있는 경우 자동 생성하는 것이 deprecated되었다. 비정적 멤버들에 대해서 해당 복사 연산자를 호출하는 방식으로 동작한다.
  • move 연산자들 : copy 연산자들, move 연산자들, 소멸자 모두가 정의되지 않았을 경우에만 자동 생성한다. 비정적 멤버들에 대해서 move 연산자를 호출하는 방식으로 동작한다.

특수 멤버함수가 템플릿 함수인 경우

템플릿으로 짜여진 특수 멤버함수가 정의된 경우 자동생성을 방지하는 룰이 적용되지 않는다.

class Widget {
public:
   template<typename T> // construct Widget
   Widget(const T& rhs); // from anything

   template<typename T> // assign Widget
   Widget& operator=(const T& rhs); // from anything
…
};

위에 작성한 템플릿 함수들은 분명히 복사 생성자와 복사 대입 연산자로 동작할 수 있지만, 컴파일러는 얘네들을 정의된 복사 연산자들로 보지 않는 것처럼 행동한다. 그러니까 자동으로 복사 연산자들과 대입 연산자들을 생성해버린다. 지금까지 열심히 설명한 많은 이론들이 무너지는 순간이지만 이렇게 하는 분명한 이유가 있다. 그 이유는 item 26에서 설명해주신다고 한다.(왜냐고!)