본문 바로가기
프로그래밍 언어/C, C++

[Effective C++] EC++ 정리하기 - Chapter 2 생성자, 소멸자 및 대입 연산자 (1)

by 니키티스 2025. 6. 28.

오늘 정리할 내용은 Effective C++의 Chapter 2 전반부(5~8번 항목)이다.

 

항목 5: C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자

  • 별도로 우리가 기본 생성자, 복사 생성자, 복사 대입 생성자, 소멸자를 선언하지 않으면 public, inline 함수로 컴파일러가 자동 선언해버린다. 이점을 주의해야 한다.
// 우리가 작성한 코드
class Empty {};
// 실제 컴파일러에서 만들어 지는 코드
class Empty {
  public:
    Empty() { ... }; // 기본 생성자
    Empty(const Empty& rhs) { ... }; // 복사 생성자
    ~Empty{ ... }; // 소멸자

    Empty& operator=(const Empty& rhs) { ... }; // 복사 대입 연산자
};
 
  • 위 함수를 자동 선언하는 조건은 “이를 호출하면” 이다. 단 한 번이라도 복사 생성자를 사용하면, 복사 생성자 코드를 컴파일러가 자동 선언하는 식이다.
  • 기본 생성자: 클래스에 아무 생성자도 없어야 자동 생성이 가능하다.
  • 소멸자: 이 클래스가 상속한 기본 클래스의 소멸자가 가상 소멸자로 되어 있지 않으면 해당 클래스의 소멸자도 비가상 소멸자로 만들어진다.
  • 복사 생성자: 단순히 각 멤버마다 복사 생성자를 호출하여 자체 초기화. 기본제공 타입은 비트를 그대로 복사해온다.
  • 복사 대입 연산자(operator=): 복사 생성자와 원리는 같은데, 최종 코드가 적법해야 하고(legal) 이치에 닿아야만(reasonable) 자동생성된다.

이때 복사 대입 연산자에서 적법(legal)과 이치에 닿는다(reasonable)는 말은 다음과 같다.

  • 예를 들어 클래스에 참조자, 상수 멤버가 있으면 컴파일 거부를 때려버린다. → 그 경우 직접 복사 대입을 정의해야 한다.
  • 복사 대입 연산자를 private로 선언한 기본 클래스로부터 파생되었다면, 이 클래스는 암시적 복사 대입 연산자를 가질 수 없다. 부모 클래스가 복사 대입을 private로 했으면 자식 클래스에선 복사 대입을 컴파일러가 안 만들어준다는 뜻이다.
실제로 다음 코드는 컴파일이 안된다. s = p에서 아예 에러가 뜬다.
template<class T>
class NamedObject {
public:
    NamedObject(std::string& name, const T& value) : nameValue(name), objectValue(value), other(1)  {}

    int other;

private:
    std::string& nameValue;
    const T objectValue;
};

int main()
{
    std::string newDog("Persona");
    std::string oldDog("oldman");
    NamedObject<int> p(newDog, 2);
    p.other = 3;
    
    NamedObject<int> s(oldDog, 4);
    s = p;

    return 0;
}
에러는 다음과 같다. 아예 함수가 정의되지 않고 지워졌다는 것이다.
test.cpp:24:9: error: use of deleted function 'NamedObject<int>& NamedObject<int>::operator=(const NamedObject<int>&)'
     s = p;
         ^
위에서는 s = p로 복사 대입을 시도하였는데, 그로 인해 해당 클래스에서는 컴파일러에서 복사 대입 연산자를 생성하려 한다.
문제는 멤버에 참조형 변수 std::string& nameValue가 존재하기 때문에, 컴파일러가 복사 대입 연산자를 생성하는 데 거부하였다.
그래서 '삭제된 함수'를 사용하게 되어 오류가 뜬 것이다.
 

항목 6: 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자

해당 항목은 C++11이 나오기 전의 항목이기 때문에, 이런 문제가 있구나 참고만 하도록 하자.
항목 5에서, 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자는 선언하지 않으면 암시적으로 생성된다고 설명했다.
이에 대한 해결 방법은 해당 항목 가장 아래에 있는 delete를 이용한 방법을 사용하는 것이 베스트이다.
  • 컴파일러가 생성하는 함수는 모두 public 멤버가 된다. 즉, 컴파일러가 생성 못 하게 막고 사람들이 못 쓰게 하려면 private으로 복사 생성자 및 복사 대입 연산자를 선언하자.
  • 그런데 friend 함수로 접근할 위험이 있으므로, 아예 “함수를 private으로 선언만” 하고 정의하지 말자. 이는 꼼수지만 많이 사용하는 기법이다.
class Widget {
private:
  ...
  // 아래와 같이 하면 선언만 존재하여, friend가 있어도 복사 생성자와 복사 대입 연산자를 호출할 수 없다.
  Widget(const Widget&);
  Widget& operator=(const Widget&);
}
  • 링크 시점 에러를 컴파일 시점 에러로 옮기면 더 좋다. 이를 위해 복사 생성자, 복사 대입 연산자를 private로 선언하되, 내 클래스 MyClass가 아니라 별도의 기본 클래스 Uncopyable에 넣고 이곳으로부터 MyClass를 파생시키면 된다.
class Uncopyable {
protected:
  Uncopyable();
  ~Uncopyable(); // 생성, 소멸 허용

private:
  Uncopyable(const Uncopyable&); // 복사 방지
  Uncopyable& operator=(const Uncopyable&);
}

class Widget : private Uncopyable {}
// 위와 같이 하면 아예 복사 생성자, 복사 대입 연산자를 선언하지 않는다.
  • 이때 굳이 public으로 할 필요가 없으므로 private 상속을 한다. 또, Uncopyable의 소멸자는 가상 소멸자가 아니게 된다(다형성을 이용하지 않으므로. Uncopyable으로 캐스팅할 일이 없기 때문.)
  • 부스트 라이브러리에 이와 같은 작업을 하는 noncopyable 클래스가 있어서 이걸 쓸 수도 있다.

단, 위 이야기는 C++ 11 이전의 이야기이므로 주의할 필요가 있다.

왜냐하면, 그 이후에는 delete라는 새로운 키워드가 나왔기 때문이다.

따라서 해당 항목은 다음과 같이 정리할 수 있겠다.

  • 베스트 프랙티스: C++11 이후에는 컴파일러가 함수를 생성 못 하게 하려면 delete 키워드를 사용하면 된다. 굳이 private으로 옮기고 그럴 필요 없다.
class Widget {
public:
  // C++ 11 이후에는 delete만 붙이면 자동 생성을 막을 수 있다.
  Widget(const Widget&) = delete;
  Widget& operator=(const Widget&) = delete;
}
 

항목 7: 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

  • 하나의 기본 클래스를 상속받아 여러 파생 클래스를 만들 수 있다. 이 경우 팩토리 함수를 만들어서, TimeKeeper에서 파생된 AtomicClock, WaterClock 등의 포인터를 반환하게 할 수 있다.
class TimeKeeper {
public:
  TimeKeeper();
  ~TimeKeeper();
  ...
};
class AtomicClock: public TimeKeeper { ... };
class WaterClock: public TimeKeeper { ... };
class WristWatch: public TimeKeeper { ... };

// 팩토리 함수: 동적으로 할당된 객체의 포인터를 반환한다.
TimeKeeper* getTimeKeeper();
  • 문제는 이를 삭제할 때이다.
  • 위 코드의 문제는, getTimekeeper 함수가 반환하는 포인터가 파생 클래스(AtomicClock) 객체에 대한 포인터라는 점과, 포인터가 가리키는 객체가 삭제될 땐 기본적으로 기본 클래스(Timekeeper*)를 통해 삭제된다는 점, 결정적으로 기본 클래스(TimeKeeper)에 들어 있는 소멸자가 비가상 소멸자(non-virtual destructor)이라는 점이다.
  • C++ 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이다.
    • 즉, 대부분은 파생 클래스 부분이 삭제되지 않는다.
    • 파생 클래스 객체 부분인 AtomicClock의 멤버들도 사라지지 않고, AtomicClock의 소멸자가 호출되지 않는다. 소멸 과정이 제대로 일어나지 않는다는 거다. AtomicClock 부분에 해당하는 객체가 삭제되지 않아 메모리 누수가 발생할 수 있다.
  • 이를 해결하기 위해서, 다형성으로 이용할 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다.
class TimeKeeper {
public:
  TimeKeeper();
  virtual ~TimeKeeper(); // 가상 소멸자로 변경
  ...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // 이제 제대로 동작한다!
  • 구체적으로는, 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 한다고 한다.
  • 반대로 기본 클래스로 의도하지 않았는데 소멸자를 가상으로 선언하는 것도 좋지 않다.
    • 가상 함수를 c++에서 실행하기 위해서는 객체마다 어떤 가상 함수를 호출해야 하는지를 나타내는 자료구조 Vptr[가상 함수 테이블 포인터(virtual table pointer)]가 존재한다. 이는 가상 함수의 주소, 즉 포인터들의 배열을 갖고 있으며 가상함수 테이블 포인터의 배열vtbl[가상 함수 테이블(virtual table)]이라 불린다.
    • 가상 함수를 하나라도 가지면 해당 클래스는 반드시 vtbl을 가진다. 그래서 가상 함수를 가지는 클래스에는 vtbl을 가리키기 위한 포인터 vptr가 필요하므로, 그만큼 클래스 크기가 커진다!
  • 가상 함수가 아예 없고 비가상 소멸자를 사용하는 클래스(ex: string 등 STL 컨테이너 전부)를 사용하면, 만약 부모 클래스로 변환하여 delete하면 미정의 동작이 일어난다. 이 경우엔 상속을 하면 안된다...
  • 순수 가상 함수(virtual 함수인데 null)를 쓰면 추상 클래스가 되는데, 가상화할 게 딱히 없다면 순수 가상 소멸자를 선언하면 된다. 다만 파생 클래스는 자기 소멸자를 호출하고 나서 기본 클래스의 소멸자를 호출하므로, 순수 가상 소멸자도 정의가 필요하긴 하다.
  • 정리해서, 기본 클래스에 가상 소멸자를 쥐어주는 건 ‘다형성’을 가진 기본 클래스만 해당. 기본 클래스 인터페이스로 파생 클래스 타입을 조작하는 경우에만 필요하다.
  • 참고 사항: 이젠 final로 상속을 제한할 수 있다. 가상 소멸자를 사용하지 않는 경우, 확실하게 final로 클래스의 상속을 시원하게 제한해 버리자.
 

추가 조사: 왜 가상 소멸자가 필요한 조건이 "가상 함수를 하나라도 가진 클래스"인가?

가상 소멸자가 필요한 조건을 ‘가상 함수를 하나라도 가진 클래스’로 두었을까?
아마도 이 부분을 읽으면서 나와 같은 궁금증을 품은 분들도 계실 것 같다. 책에서는 가상 함수를 가지기만 한다면 가상 소멸자를 써야 한다고 했는데, 꼭 그렇지는 않을 것 같은 느낌적인 느낌이 들었다. 그래서 좀 찾아본 결과, 이 말이 약간 비유적인 표현임을 알 수 있었다.
실제로는 “기본 클래스 포인터로 파생 클래스 객체를 삭제할 가능성이 있으면, 반드시 기본 클래스의 소멸자도 가상이어야 한다”라고 말하는 게 정확하다.
테스트해 본 결과, 가상 함수의 존재 여부와 관계 없이 기본 클래스 포인터로 파생 클래스 객체를 삭제하면 파생 클래스의 소멸자가 호출되지 않았다. 아래 코드에서는 아무 가상 함수가 없지만, 비가상 소멸자를 가진 기본 클래스 포인터로 처리하니 그 자체로 문제가 발생했다.
class Base {
public:
    // 아무 가상 함수도 없음
    ~Base() { std::cout << "Base destructor\n"; }
};

class Derived : public Base {
public:
    ~Derived() { std::cout << "Derived destructor\n"; }
};

void foo() {
    Base* ptr = new Derived();
    delete ptr; // 파생 클래스 Derived의 소멸자가 호출되지 않는다!!
}
 
이런 상황도 있는 걸 보면, 가상 소멸자의 조건에 '가상 함수를 하나라도 가진 클래스'라는 말을 단 건 이상하게 느껴진다.
왜 조건을 ‘가상 함수 여부’로 두었는지 고민해 보았는데, 내 생각에 ‘일반적으로 가상 함수가 존재한다면 해당 클래스는 다형성으로 사용될 가능성이 높기’ 때문이다.
경험적으로 다형성으로 쓸 클래스가상 함수를 만들기 마련이다. 복잡하게 생각하지 않는다면, ‘가상 함수가 있다 → 다형성으로 사용될 가능성이 높다’고 생각할 수 있고, 반대로 가상 함수가 없다면 구태여 해당 클래스를 다형성으로 활용할 이유가 없다.
그 때문에, 기억하기 쉬운 조건으로 ‘가상 함수 존재 여부로 구분’하라고 제안한 것이라고 판단했다.
추가로, 메모리 누수가 발생하는 문제에 대해 다음 글에서도 설명하고 있으니 궁금하다면 참고하자.
 

항목 8: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

  • 예를 들어, 컨테이너 내에 있는 객체가 소멸자에서 예외가 발생했다면 프로그램은 실행이 종료되든지 정의되지 않은 동작을 보인다.
  • 즉, 소멸자에서는 예외가 발생하지 않거나, 새어나가지 않도록 막아야 한다.
  • 예를 들어, DB 관리 클래스에서 close를 호출할 때 예외가 발생한다 하자. 사용자가 실수로 close를 호출하지 않을 때를 대비해 소멸자에서 close를 호출했다. 이런 경우, 소멸자에서 예외가 발생할 수 있다.
  • 이를 막기 위해서 떠올릴 수 있는 방법은 ‘소멸자에서 예외가 빠져나가지 않게 하는 것’이다.
// 1. 예외 발생시 바로 프로그램 종료
DBConn::~DBConn()
{
  try { db.close(); }
  catch (...) {
    close 호출이 실패했다는 로그 작성;
    std::abort();
  }
}

// 2. Close 호출한 곳에서 예외 삼켜버리기
DBConn::~DBConn()
{
  try { db.close(); }
  catch (...) {
    close 호출이 실패했다는 로그 작성;
  }
}
  1. close에서 예외가 발생하면 프로그램을 바로 끝낸다. 보통 abort를 호출한다.
  2. Close를 호출한 곳에서 일어난 예외를 삼켜 버린다.
  • 다만 더 좋은 방법은 ‘사용자가 문제를 처리할 수 있게 하는 것’이다. 예를 들어 close를 사용자가 처리해서 예외를 해결하게 하고, 만약 사용자가 close를 하지 않았다면 그땐 실행 종료나 예외 삼키기를 한다.
class DBConn {
public:
  ...
  void close() // 사용자가 필요할 때 db를 닫을 수 있게 함수를 제공하자.
  {
    db.close();
    closed = true;
  }

  DBConn::~DBConn()
  {
    if (!closed) // 사용자가 연결을 안 닫았을 때, 닫기 시도를 한다.
    try { db.close(); }
    catch (...) {
      close 호출이 실패했다는 로그 작성; // 연결 닫다가 실패하면 끝내기 혹은 예외 삼키키 방법 중 하나를 쓴다.
      ...
    }
  }

private:
  DBConnection db;
  bool closed;

}
  • 책임을 떠넘기는 것 같지만, 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 하므로, 사용자가 이에 대해 반응할 수 있게 ‘보통의 함수’에서 예외를 던지도록 한다.
 

요약

  • 소멸자에서 예외가 빠져나가면 안된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 한다.
  • 어떤 클래스 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(소멸자가 아닌 함수)여야 한다.

댓글