Skip to main content

[Effective C++] Chapter 2. 생성자, 소멸자 및 대입 연산자

이펙티브 C++ 책을 읽고 공부한 노트입니다.




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

  • 컴파일러는 필요하다고 생각하면 다음의 함수들을 자동으로 만들어 낸다.
    • 모두 public 멤버이며, inline 함수이다.
    • (1) 기본 생성자
    • (2) 복사 생성자
    • (3) 소멸자 (비가상으로 만들어진다.)
    • (4) 복사 대입 연산자
// 이것은
class Empty {};  

// 다음과 같다. 
class Empty
{
public:
  Empty() { ... }                            // 기본 생성자
  Empty(const Empty& e) { ... }              // 복사 생성자
  ~Empty() { ... }                           // 소멸자
  Empty& operator=(const Empty& e) { ... }   // 복사 대입 연산자
};

  • 만약, 멤버 변수가 참조자 이거나, const 이면, 반드시 복사 대입 연산자를 정의해 주어야한다.
    • 그렇지 않으면 컴파일 에러가 발생한다.
template<class T>
class NamedObject
{
private:
  std::string& namedValue;  // 참조자
  const T objectValue;      // const

public:
  NamedObject(std::string& name, const T& value);
};

int main()
{
  std::string newDog("Persephone");
  std::string oldDog("Satch");
  
  NamedObject<int> p(newDog, 2);
  NamedObject<int> s(oldDog, 36);
  
  p = s;    // 컴파일 에러! 복사 대입 연산자 정의 필요!
}



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

  • 객체의 사본이 만들어지면 안되는 경우에 어떻게 해야할까?
    • 복사 생성자와 복사 대입 연산자를 선언하지 않으면 컴파일러가 마음대로 만들어버린다.
    • 따라서 private으로 선언하면 되겠다!
    • 하지만 클래스의 멤버 함수, friend 함수에서 호출할 수 있다는 허점이 있다.
    • 그럼 선언만 하고, 정의를 빼먹자!
class Uncopyable
{
protected:
  Uncopyable() {}
  ~Uncopyable() {}

// 선언만 하고 정의는 하지 않는다. 
private:
  Uncopyable(const Uncopyable&);
  Uncopyable& operator=(const Uncopyable&);
};

// 복사 생성자도, 복사 대입 생성자도 선언되지 않는다. 
class HomeForSale : private Uncopyable { };



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

  • 비가상 소멸자의 경우, 기본 클래스의 소멸자만 호출하고 파생 클래스의 소멸자는 호출하지 않는다.
    • 따라서, 다형성을 가진 기본 클래스의 소멸자는 반드시 가상 소멸자여야 한다.
class TimeKeeper
{
public:
  TimeKeeper();
  virtual ~TimeKeeper();  // 가상 소멸자
};

class AtomicClock: public TimeKeeper { ... };
class WaterClock: public TimeKeeper { ... };
class WristClock: public TimeKeeper { ... };

int main()
{
  TimeKeeper* t = getTimeKeeper(); // 생성된 파생 클래스의 기본 클래스 포인터를 반환하는 팩토리 함수이다. 
  delete t; // 파생 클래스의 소멸자 -> TimeKeeper의 소멸자
}

  • 기본 클래스가 아닌 클래스거나, 다형성을 갖도록 설계되지 않은 클래스에 가상 소멸자를 선언하는 것도 옳지 않다.
    • 가상 함수를 구현하려면 C++에서는 클래스에 vptr (가상함수 테이블 포인터; virtual table pointer) 가 클래스에 별도로 들어간다.
      • 이것은 vtbl (가상함수 테이블; virtual table) 을 가리키고 있다.
    • 따라서 예를들어, 64비트 시스템에서는 포인터의 크기가 64비트이므로, 클래스의 크기에 64비트가 추가되는 셈이다.
    • 또한 vtpr을 따로 만들기는 어렵기 때문에 C 등의 다른 언어로 호환성이 없어진다.

  • 가상 소멸자가 없는 타입은 상속해서 사용하면 안 된다.
    • string
    • STL의 모든 컨테이너

  • 기본 클래스를 추상 클래스로 만들고 싶은 데 마땅한 순수 가상 함수가 없는 경우에는?
    • 순수 가상 소멸자를 두면 편리하다.
    • 주의할 점은, 순수 가상 소멸자의 정의를 꼭 두어야 한다는 점이다.
class AbstractWithVirtual
{
public:
  virtual ~AbstractWithVirtual() = 0; // 순수 가상 소멸자
};

AbstractWithVirtual::~AbstractWithVirtual() {}; // 꼭 정의를 두어야 한다. 



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

  • 소멸자에서 예외가 발생하면?
    • 프로그램이 불완전 종료되거나, 미정의 동작의 위험을 내포하고 있다.
    • 따라서 예외가 발생될만한 코드는 소멸자가 아닌 다른 함수에 존재해야 할 것이다.
class DBConnection()
{
public:
  static DBConnection Create();
  
  // 연결을 닫을 때 실패하면 예외를 던진다. 
  // 소멸자가 아닌 다른 함수에서 예외가 발생하도록 했다. 
  void Close(); 
};

class DBHandler
{
private:
  DBConnection db;
  
public:
  // 데이터베이스 연결이 항상 닫히도록 소멸자에서 Close해준다. 
  ~DBHandler()
  {
    db.Close();
    // 하지만 여기서 예외가 발생했을 때 어떤 조취를 취해야 할까?
    // (1) 프로그램을 바로 종료하거나 (2) 아무일 없었던 듯 무시한다
    // 단순히 위와같은 조치를 바로 취하는 건 좋지 않겠다. 
    // 그렇다면 사용자가 직접 예외에 대처할 수 있게 하는 것이 좋겠다. 
  }
};

// 유저가 이렇게 사용할 수 있겠다. 
int main()
{
  DBHandler h(DBConnection::Create());
}

  • 해결법: 사용자가 직접 예외를 처리할 기회를 갖도록 한다.
class DBHandler
{
private:
  DBConnection db;
  bool closed;
  
public:
  // 사용자가 처리할 수 있도록 책임을 전가한다. 
  void Close()
  {
    db.Close();
    closed = true;
  }

  ~DBHandler()
  {
    // 만약 사용자가 Close를 안 했을 때만 여기서 Close해본다. 
    if (closed == false)
    {
      try
      {
        db.Close();
      }
      catch (...)
      {
        // Close 호출이 실패했다는 로그를 작성한다. 
        // (1) 프로그램을 바로 종료하거나 (2) 아무일 없었던 듯 무시한다
      }
    }
  }
};



Item 9: 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자 #

  • 생성자나 소멸자에서 가상 함수를 호출하면 절대 안 된다.
  • 기본 클래스의 생성자가 호출될 때,
    • 파생 클래스의 멤버들은 아직 초기화가 안 되었으므로
    • 객체는 기본 클래스 타입으로 작동된다.
  • 기본 클래스의 소멸자가 호출될 때,
    • 파생 클래스의 소멸자가 호출된 후이므로
    • 객체는 기본 클래스 타입으로 작동된다.
class Transaction
{
public:
  Transaction()
  {
    // 생성자에서 가상 함수를 호출해버렸다. 
    LogTransaction(); 
    // 하지만 파생 클래스의 멤버들은 아직 초기화가 안 되었으므로
    // 이 시점에서 b의 타입은 Transaction이다. 
    // (2) 따라서 Transaction의 LogTransaction()이 호출된다. 
  }
  
  virtual void LogTransaction() const = 0;
};

class Buy : public Transaction
{
public:
  virual void LogTransaction() const;
};

class Sell : public Transaction
{
public:
  virual void LogTransaction() const;
};

int main()
{
  // 이 코드가 실행되면 어떻게 될까?
  Buy b; 
  // 가장 먼저 Buy의 기본 클래스인 (1) Transaction의 생성자가 호출될 것이다. 
}

  • 해결법: 함수를 비가상으로 바꾸고, 생성자의 매개변수로 필요한 정보를 넘기도록 한다.
class Transaction
{
public:
  explicit Transaction(const str::string logInfo)
  {
    LogTransaction(logInfo); 
  }
  
  // 비가상 함수이다. 
  // 매개변수로 필요한 정보를 받는다. 
  void LogTransaction(const str::string logInfo) const;
};

class Buy : public Transaction
{
public:
  // 매개변수로 필요한 정보를 넘긴다. 
  Buy() : Transaction(createLogString()) 
  {
    // ...
  }
 
private:
  static std::string createLogString();
};



Item 10: 대입 연산자는 *this의 참조자를 반환하게 하자 #

  • C++의 대입 연산은 사슬처럼 엮일 수 있다.
    • 따라서 대입연산자는 좌변 인자에 대한 참조자를 반환해야 한다는 일종의 관례(convention)가 있다.
class Widget
{
public:
  // 단순 대입 연산자
  Widget& operator=(const Widget& rhs)
  {
    // ...
    return *this; // 참조자를 반환한다. 
  }
  
  // 다른 대입 연산자도 마찬가지이다. 
  Widget& operator+=(const Widget& rhs)
  {
    // ...
    return *this;
  }
};

int main()
{
  Widget a, b, c;
  
  // 대입이 사슬처럼 이루어진다. 
  a = b = c; 
  // 이것은 다음과 같이 우측 연관 연산(right-associative)으로 진행된다. 
  // (a = (b = c))
}



Item 11: operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자 #

  • 자기대입(self assignment)이란?
    • 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.
    • 하나의 객체를 여러 곳에서 참조할 수 있기(중복참조; aliasing) 때문에 자기대입이 발생한다.
class Widget {};

Widget w;
w = w;  // 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.  

  • 자기대입은 위험성을 내포한다.
class Bitmap {};

class Widget 
{
private:
  Bitmap * pb;
  
public:
  Widget& operator=(const Widget& rhs);
};

Widget& Widget::operator=(const Widget& rhs) // 만약 rhs가 자기 자신이라면?
{
  delete pb; // 자기자신의 데이터를 지웠기 때문에
  pb = new Bitmap(*rhs.pb); // rhs.pb는 이미 삭제된 상태가 된다. 아뿔싸!

  return *this;
}

  • 해결법
  • (1) 첫머리에 일치성 검사(identify test) 를 한다.
    • 이것은 공짜가 아니다. 일치성 검사 코드가 들어가면 그만큼 코드가 커지는데다가, 처리 흐름에 분기를 만들게 되므로 실행 시간 속력이 줄어들 수 있다.
Widget& Widget::operator=(const Widget& rhs) 
{
  // 객체가 같은지 확인해서 같으면 아무것도 하지 않는다. 
  if (this == &rhs) return *this;

  delete pb; 
  pb = new Bitmap(*rhs.pb); 
  return *this;
}

  • (2) 객체를 복사하고 삭제한다.
Widget& Widget::operator=(const Widget& rhs) 
{
  Bitmap * pOrig = pb;      // 원래의 pb를 복사한다. 
  pb = new Bitmap(*rhs.pb); // 새로운 값을 대입한다. 
  delete pOrig;             // 원래의 pb를 삭제한다.
  
  return *this; 
}

  • (3) 복사 후 맞바꾸기(copy and swap) 를 한다.
    • Item 29에서 자세히 설명한다.
class Widget 
{
private:
  Bitmap * pb;
  void swap(Widget& rhs); // *this와 rhs의 데이터를 맞바꾼다. Item 28에서 자세히 설명한다.  

public:
  Widget& operator=(const Widget& rhs);
};

Widget& Widget::operator=(const Widget& rhs)
{
  Widget temp(rhs); // rhs의 사본을 만든다. 
  swap(temp);       // *this와 사본을 맞바꾼다. 
 
  return *this;
}
  • 이것은 사본을 매개변수로 넘겨서 좀 더 간소화할 수 있다.
Widget& Widget::operator=(Widget rhs) // 사본이 전달된다. 
{
  swap(rhs); // *this와 사본을 맞바꾼다. 
     
  return *this;
}



Item 12: 객체의 모든 부분을 빠짐없이 복사하자 #

  • 복사 함수(copying function)에서는 모든 부분을 복사해야 한다.
    • 복사 생성자와 복사 대입 연산자가 있다.
void logCall(const std::string& funcName);

class Customer
{
private:
  std::string name;
  
public:
  // 복사 생성자
  Customer(const Customer& rhs) : name(rhs.name)
  {
    logCall("Customer copy constructor");
  }
  
  // 복사 대입 연산자
  Customer& operator=(const Customer& rhs)
  {
    logCall("Customer copy assignment operator");
      
    name = rhs.name;
    return *this;
  }
};

class PriorityCustomer : public Customer
{
private:
  int priority;
  
public:
  PriorityCustomer(const PriorityCustomer& rhs);
  PriorityCustomer& operator=(const PriorityCustomer& rhs);
};

// 파생 클래스의 복사 생성자에서
// 기본 클래스의 멤버 데이터를 복사하지 않고 있다!
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority)
{
  logCall("PriorityCustomer copy constructor");
}

// 파생 클래스의 복사 대입 연산자에서도 마찬가지다!
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");
  
  priority = rhs.priority;
  return *this;
}

  • 복사 함수를 작성할 때는 다음의 두 가지를 꼭 확인하자
    • (1) 해당 클래스의 데이터 멤버를 모두 복사했는가?
    • (2) 이 클래스가 상속한 기본 클래스의 복사 함수를 호출했는가?
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) 
  : Customer(rhs), // 기본 클래스의 복사 생성자를 호출한다. 
  priority(rhs.priority)
{
  logCall("PriorityCustomer copy constructor");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");
  
  priority = rhs.priority;
  Customer::operator=(rhs); // 기본 클래스 부분을 대입한다. 
  return *this;
}

  • 코드 간소화를 위해 두 복사함수에서 겹치는 내용을 init() 멤버 함수를 따로 두고 호출할 수도 있겠다.