Item 17 - Special member function
마지막 수정 시각: 2020-10-26 22:03:12
C++에서 special member function이란 따로 명시하지 않아도 저절로 생성될 수 있는 멤버 함수들을 말한다. C++ 98에는 기본 생성자, 기본 파괴자, 복사 생성자, 복사 대입 연산자라는 4개의 special member function이 있었다.
C++11에는 여기서 이동 생성자, 이동 대입 연산자라는 두 가지 special member function이 더 추가 되었다.
class Widget
{
public:
Widget(Widget&& rhs);
Widget& operator =(Widget&& rhs);
};
새로운 두 special member function은 위와 같은 type signature를 갖고 있다. 이 함수들도 조건이 성립하면 자동으로 생성된다.
move - copy
이동 생성자와 이동 대입 연산자 역시 필요하면 생성된다. 자동으로 생성되는 이동 생성자와 이동 대입 연산자는 static이 아닌 멤버들을 모두 이동시키고, 상속된 클래스라면 자신의 부모에 해당하는 부분도 모두 이동시킨다.
그러나 여기서 알아두어야할 점이 하나 있다. 이동 생성자와 이동 대입 연산자가 static이 아닌 멤버들을 모두 이동시킨다라고 했지만, 실제로는 이동하길 요청한다에 더 가깝다는 것이다. 왜냐하면 이동할 수 없는 타입들(C++ 98 시절 작성된 거의 대부분의 클래스들)은 이동되는 게 아니라 복사되기 때문이다.
move 자동 생성 조건
이동 연산들은 복사 연산들과 비슷하게, 직접 해당 연산들을 명시할 경우에는 자동 생성되지 않는다. 하지만 두 연산의 생성 조건에는 미묘한 차이가 존재한다.
copy는 독립적이다
무슨 뜻이냐면, 복사 생성자와 복사 대입 연산자의 생성 조건은 서로 독립적으로 동작한다는 것이다. 복사 생성자를 명시하고 복사 대입 연산자를 명시하지 않은 상태에서 복사 대입 연산자를 사용할 경우, C++은 복사 대입 연산자를 자동 생성한다. 물론 그 반대의 경우도 마찬가지.
move는 독립적이지 않다
반면에 이동 생성자와 이동 대입 연산자는 서로 독립적으로 동작하지 않는다. 이동 생성자든 이동 대입 연산자든 둘 중 하나만 명시해도, 나머지 하나는 자동 생성되지 않는다. 사실 이게 논리적으로 더 올바르다. 이동 생성자든 이동 대입 연산자든 명시해서 정의를 했다는 건 사용자가 의도하는 이동 동작이 컴파일러에서 자동 생성해주는 기본 동작과는 뭔가 차이가 있다는 뜻이다. 즉 뭔가 하나 명시를 했다면 그 클래스의 이동 동작은 컴파일러가 기본적으로 제공하는 방식과는 다를 가능성이 크기 때문에 자동적으로 생성하지 않는 것이 더 올바르다.
게다가 move는 copy 연산이 명시적으로 선언되었을 경우에도 자동 생성되지 않는다. copy - move는 연관성이 꽤 크고 copy 동작을 자체 정의했다는 이야기는 move 동작 역시 기본적인 동작과는 차이가 날 확률이 있다. 안정성이라는 측면에서 이런 경우 자동으로 생성하지 않고 컴파일 에러를 내는 편이 잠재적인 에러를 덜 발생시킬 것이다.
그런데 특이하게도 이 경우는 반대 케이스도 성립한다. 즉 move 연산을 명시적으로 선언했을 경우 copy는 자동생성되지 않는다. 이게 C++ 98 시절의 legacy code에 문제를 일으킬 것 같지만 전혀 문제를 일으키지 않는다. C++ 98 시절에는 move라는 개념 자체가 존재하지 않았으니 당연한 일이다(C++ 98 코드 중에 move 연산을 명시적으로 선언한 코드가 존재할 리가 없다).
Rule Of Three
Rule Of Three는 다음 가이드라인을 가리키는 말이다. 만약 복사 생성자, 복사 대입 연산자, 소멸자 셋 중 하나라도 선언했다면, 그 세 가지 다 선언해야만 한다. 보통 저 셋 중 하나를 선언했다는 것은 셋 다 필요한 경우가 많기 때문이다. 보통 복사 생성자를 선언한다는 것은 이게 단순한 값의 복사로 해결되는 게 아니라 깊은 복사가 필요한 경우인데, 이런 경우는 클래스 내부에서 관리하는 자원이 존재하는 경우가 대부분이다(메모리든 뭐든). 그래서 복사 생성자를 만들었다면 마찬가지로 깊은 복사를 해야하니 복사 대입 연산자도 필요할 것이고, 또 내부적으로 관리하는 자원이 존재할 테니 소멸자를 선언해서 소멸자에서 자원을 해제해줘야 할 것이다. 따라서 셋 중 하나라도 선언해야하는 경우는 셋 다 선언해야하는 경우가 대부분이라는 것이다.
이 세가지의 관계를 생각해본다면 소멸자를 선언할 경우 복사 생성자나 복사 대입 연산자를 자동으로 생성해주지 않는 것이 훨씬 안전할 것이다. 그러나 C++ 98 시절에는 이에 대한 깊은 공감이 없었고 그 때문에 소멸자의 선언 여부가 복사 생성자, 복사 대입 연산자의 생성에 아무런 영향을 미치지 못한다(심지어 위에서도 언급했듯이 복사 생성자 선언이 복사 대입 연산자의 생성에 아무 영향을 못 끼친다. 물론 그 반대도). 이건 C++ 11에서도 마찬가지다. 이걸 수정했다간 legacy code 대부분이 제대로 동작하지 않는 끔찍한 사태가 발생하기 때문이다.
하지만 이동에 대해서는 이야기가 다르다. 이동은 legacy code에 포함된 개념이 아니기 때문에 이 녀석이라도 안정성있게 돌아가도록 만들어야할 것이다. 그래서 C++11에서는 소멸자를 선언할 경우 이동 연산은 자동으로 생성하지 않는다. 따라서 이동 연산은 아래 세 가지 조건이 만족될 때만 자동으로 생성된다.
- 클래스에 복사 연산(복사 대입 연산자, 복사 생성자)이 하나도 선언되어 있지 않다
- 클래스에 이동 연산(이동 대입 연산자, 이동 생성자)이 하나도 선언되어 있지 않다.
- 클래스에 소멸자가 선언되어 있지 않다.
복사 생성자 역시 이와 유사한 룰이 적용되면 좋을 것이다. legacy code들 때문에 복사 연산에는 이동 연산과 같은 제약이 걸려 있진 않지만 C++11은 이런 제약에 걸리는 상황에서 자동으로 생성되는 복사 연산을 사용하는 것을 권장하지 않는다(deprecate). 즉 C++ 98시절에 작성한 코드 중에 소멸자를 선언해 놓고 자동으로 생성되는 복사 연산 등을 사용하는 코드가 존재했을 경우 C++ 11의 규약에 맞게 적절히 업그레이드해주는 것이 좋을 것이다. C++ 11은 이런 작업을 간단히 할 수 있게 만드는 키워드를 제공한다.
class Widget
{
public:
//소멸자 선언
~Widget();
//=default는 자동 생성되는 녀석을 쓰겠다는 뜻
Widget(const Widget&) = default;
Widget& operator=(const Widget&) = default;
};
위 코드 처럼 = default
키워드를 통해 자동 생성되는 함수를 사용함을 쉽게 명시할 수 있다.
이건 base 클래스를 만들 때도 유용하다. 단순 인터페이스만 제공하는 base 클래스에서, 소멸자를 virtual로 선언해주어야 하는데 이를 virtual로 선언하면 이동 연산같은 건 다 선언이 안 되어버리니 이들을 =default
를 이용해서 자동으로 생성되게 해주면 좋을 것이다.
class Base
{
public:
virtual ~Base() = default;
Base(Base&&) = default;
Base& operator=(Base&&) = default;
Base(const Base&) = default;
Base& operator=(const Base&) = default;
};
그리고 컴파일러가 자동으로 생성해주는 경우라 하더라도 자동 생성되는 함수에 대해 = default
를 이용해 명시적으로 선언해주는 것이 좋다. 그게 클래스 설계자의 의도를 훨씬 명확하게 보여줄 뿐만 아니라, 잠재적인 버그도 잡아주기 때문이다. 예를 들어 아래와 같은 경우를 생각해보자.
class StringTable
{
public:
StringTable() {}
private:
std::map<int, std::string> values;
};
문자열 테이블을 관리하는 클래스다. 이 클래스는 소멸자를 따로 명시하지 않았으므로 이동, 복사, 소멸자 모두 자동으로 생성될 것이고 이를 사용할 수 있을 것이다.
근데, 이 때 이 클래스에 아래처럼 클래스가 생성될 때, 삭제될 때 로그를 남기는 기능을 추가했다고 하자.
class StringTable
{
public:
StringTable()
{ log("StringTable create."); }
~StringTable()
{ log("StringTable destroy."; }
private:
std::map<int, std::string> value;
};
이렇게 하면 갑자기 이동 연산 관련된 함수들도 다 생성이 안 되게 바뀌어버린다. 물론 딱히 결과에 차이는 안 난다. move 연산이 없다고 해도 move 연산이 없는 타입에 대해서는 위에 언급한 것처럼 copy를 이용하기 때문에 나타나는 결과에서는 아무런 차이가 안 난다. 하지만, 성능에는 큰 영향을 끼칠 수 있다. std::map을 복사하는 것과 이동하는 것에 걸리는 시간은 정말 하늘과 땅 차이다. 이 때문에 큰 규모의 StringTable을 빈번하게 이동시키거나 하는 프로그램일 경우 아마 큰 성능의 저하를 느끼게 될 것이다. 그러니 되도록 = default
를 써서 자동 생성되는 함수들도 명시적으로 선언해주도록 하자.
C++ 11의 자동 생성 규칙
지금까지 다룬 내용들을 정리해서, C++11 에서 special member function들의 자동 생성 규칙을 한 번 요약해보자.
- Default Constructor : C++ 98 때와 동일. 클래스가 소멸자를 선언하지 않았을 경우 자동 생성.
- Destructor : C++98과 핵심적인 내용은 동일하다. 약간의 차이점은 C++11에서는 소멸자는 기본적으로 noexcept라는 것(Item 14 - noexcept 참고). 그리고 C++98에서 처럼 소멸자는 부모 클래스가 virtual일 때만 자동 생성되는 소멸자도 virtual이다.
- Copy Constructor : 런타임 중의 동작은 C++98때와 같다. static이 아닌 멤버들과 부모 클래스 부분을 복사한다. Copy Constructor를 선언하지 않은 상황에서 이를 호출했을 경우 자동으로 생성된다. 만약 이동 연산이 선언되어 있는 경우 delete된다(즉 자동 생성되는 걸 쓸 수 없다).
- Copy assignment operator : Copy Constructor와 거의 동일.
- Move Constructor, Move assignment operator : static이 아닌 멤버들과 부모 클래스 부분을 이동시킨다. 클래스가 복사 연산, 이동 연산, 소멸자 그 어느 것도 선언하지 않았을 때만 자동 생성된다.
템플릿 멤버함수에 관한 주의점
한가지 주의해야 할 것이 있는데, 템플릿 멤버 함수는 어떤 special member function의 자동 생성도 막지 않는다.
class Widget
{
public:
template<typename T>
Widget(const T& rhs);
template<typename T>
Widget& operator=(const T& rhs);
};
즉 위와 같은 코드에서, 템플릿 멤버 함수를 통해 복사 생성자와 복사 연산자가 생성될 수 있지만(T = Widget인 경우 자동 생성되는 녀석들과 동일하다) 이런 경우에도 컴파일러는 이동 연산을 자동 생성한다. 템플릿 함수보다 일반 함수가 오버로딩 호출에서 더 우선순위가 높으므로 그 경우 템플릿 멤버함수가 아니라 자동으로 생성된 함수를 호출할 것이다. 언뜻보면 상당히 괴랄해보이는데 도대체 왜 이런 식으로 동작하는지는 Item 26 - universal Reference는 오버로딩을 피해라에서 다룬다고 한다.