본문 바로가기

컴퓨터과학

[Effective C++] (17-3) 특수 멤버 함수들의 자동 작성 조건을 숙지할 것

* C++ 특수 멤버 함수

 

이러한 접근방식은 다형적 기반 클래스(polymorphic base class), 즉 파생 클래스 객체들을 조작하는 데 쓰이는 인터페이스를 정의하는 클래스에 유용한 경우가 많습니다. 대체로 다형적 기반 클래스에는 가상 소멸자가 있습니다. 소멸자가 가상이 아니면 미정의 행동이나 오해하기 쉬운 결과를 산출하는 연산들이 있기 때문입니다(이를테면 기반 클래스 포인터나 참조를 통해서 파생 클래스 객체에 대해 delete나 typeid를 적용하는 등). 이미 가상으로 선언된 소멸자를 상속는 것이 아닌 한, 소멸자가 가상이 되게 만드는 유일한 방법은 명시적으로 virtual로 선언하는 것뿐입니다. 그런데 소멸자를 가상으로 만드는 것 외에는 변경할 것이 없는, 즉 기본 구현이 적합한 경우도 많습니다. "=default"는 그런 점을 표현하는 좋은 수단입니다. "=default"의 용도가 그것만은 아닙니다. 독자가 소멸자를 직접 선언하면 이동 연산들의 자동 작성이 금지됩니다. 만일 그러한 사용자 선언 소멸자를 두면서도 이동 능력을 지원하고 싶다면, 이동 연산들에 "=default"를 지정하면 됩니다. 이동 연산들을 직접 선언하면 복사 연산들이 비활성화되며, 만일 이동과 함께 복사도 지원하고 싶다면 역시 마찬가지로 복사 연산들에 "=default"를 지정하면 됩니다.

class Base {
public:
 virtual ~Base() = default; // 소멸자를 가상으로

 Base(Base&&) = default; // 이동 지원
 Base& operator=(Base&&) = default;

 Base(const Base&) = default; // 복사 지원
 Base& operator=(const Base&) = default;
 ...
};

사실 컴파일러가 기꺼이 복사 연산들과 이동 연산들을 작성해주는 클래스에서도, 그리고 작성된 함수들이 독자가 원하는 방식으로 행동한다고 해도, 그 연산들을 독자가 직접 선언하고 그 정의들에 "=default"를 적용하는 방침을 택하는 것이 바람직한 경우가 있습니다. 그러면 타자가 더 늘긴 하지만, 독자의 의도가 더 명확해질 뿐만 아니라 상당히 미묘한 버그들을 피하는 데에도 도움이 됩니다. 예를 들어 하나의 문자열 테이블, 즉 정수 ID를 통해서 문자열 값을 빠르게 조회할 수 있는 자료구조를 나타내는 클래스가 있다고 합시다.

class StringTable {
public:
 StringTable() {}
 ... // 삽입, 삭제, 조회 등을 위한 함수들은 있지만, 복사/이동/소멸자 기능성은 없음

private:
 std::map<int, std::string> values;
};

이 클래스가 복사 연산들과 이동 연산들, 그리고 소멸자를 전혀 선언하지 않는다면, 그리고 그런 함수들을 사용하는 클라이언트 코드가 있다면, 컴파일러는 해당 함수들을 자동으로 작성합니다. 이는 아주 편리한 기능입니다.
그러나 나중에, 이런 객체들의 기본 생성과 소멸을 기록하는 것이 유용하겠다는 생각이 들었다고 합시다. 

그런 기능성을 추가하는 것은 쉬운 일입니다.

class StringTable {
public:
 StringTable()
 { makeLogEntry("Creating StringTable object"); } // 추가됨

 ~StringTable() // 역시 추가됨
 { makeLogEntry("Destroying StringTable object"); }

 ... // 다른 함수들은 이전과 동일
private:
 std::map<int, std::string> values; // 이전과 동일
};

이것이 합리적인 해결책처럼 보이지만, 소멸자를 선언하면 의미 있는 부수 효과가 발생할 수 있습니다. 바로, 이동 연산들이 자동으로 작성되지 않는다는 것입니다. 그러나 클래스의 복사 연산 작성에는 아무런 영향도 미치지 않습니다. 따라서 이 코드는 문제없이 컴파일되고, 실행되고, 기능 검사를 통과할 가능성이 큽니다. 특히, 이동 능력에 관함 검사도 통과할 것입니다. 비록 이제는 클래스의 이동이 비활성화되었지만, 이동 요청들은 아무 문제없이 컴파일 및 실행되기 때문입니다. 이 항목의 도입부에서 언급했듯이 그런 요청들은 실제 이동이 아니라 복사에 의해 만족됩니다. 즉, StringTable 객체를 '이동'하는 코드는 실제로는 바탕 std::map<int, std::string> 객체의 복사본을 생성합니다. 

그리고 std::map<int, std::string>의 복사에 걸리는 시간은 이동에 걸리는 시간에 여러 자릿수의 배수를 곱한 것(이를테면 수백, 수천 배)일 수 있습니다. 그냥 클래스에 소멸자를 하나 추가했을 뿐인데 엄청난 성능 문제가 발생하게 되는 것입니다! 복사 연산들과 이동 연산들을 "=default"를 이용해서 명시적으로 정의했더라면 이런 문제가 없었을 것입니다.
C++11의 복사 및 이동 연산 규칙들에 관한 다소 장황한 이야기를 꾹참고 들은 독자라면, 다른 두 특수 멤버 함수, 즉 기본 생성자와 소멸자에 관한 규칙들은 언제 나올지가 궁금할 것입니다. 이제 그 부분을 이야기하겠습니다. 뭔가 대단한 것을 기대했을지도 모르겠지만, 할 이야기는 딱 한 문장입니다: 그 둘에 관한 C++11의 규칙들은 C++98의 규칙들과 거의 같습니다.