2장. 생성자, 소멸자 및 대입연산자 🎺

안녕하세요! 두두코딩 입니다 ✋
오늘은 항목9 생성자와 소멸자에서 가상함수를 사용하면 안되는 이유에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

객체 생성과 소멸시 가상함수 사용하면 안되는 이유

객체를 생성하거나 소멸할 때, 가상함수를 사용하면 절대절대 안된다. 그 이유는 두 가지이다.

  1. 호출 결과가 원하는 대로 돌아가지 않음
  2. 원하는 대로 돌아간다고 해도, 정상적이지 않은 동작일 확률일 큼

예시를 통해 생각해보자.

우리가 주식 거래 매매시스템을 만든다고 생각했을 때, 우리는 보통 거래를 하나의 Transaction 단위로 설계한다. 예를들어서 아래와 같이, BuyTransaction or SellTransaction 등이 존재할 것이다. 또한, 우리는 하나의 Transaction이 잘 이뤄지고 있는가 혹은 어디선가 에러난것은 아닌가 하는 감사를 확인할 수 있는 LogTransaction을 추가로 설계했다고 해보자.

// 모든 거래에 대한 기본 클래스
class Transction {
public:
  Transaction();

  // 타입에 따라 달라지는 log를 기록한다.
  virtual void LogTransaction() const = 0;

  ...
};

// 기본 생성자의 구현
Transaction::Transaction() {
  ...
  // 마지막 동작으로 거래에 대한 로깅을 시작함.
  logTransaction();
}

// Transaction의 파생클래스
class BuyTransaction : public Transaction {
public:

  // 해당 타입에 따른 거래내역을 로깅함.
  virtual void LogTransaction() const;

  ...
};

// Transaction의 파생클래스
class SellTransaction : public Transaction {
public:

  // 해당 타입에 따른 거래내역을 로깅함.
  virtual void LogTransaction() const;

  ...
};

int main() {
  BuyTransaction b;
}

위의 코드가 어떻게 동작할 것인가? 생각해보자.

우선, main()에서 BuyTransaction 객체를 생성한다. 따라서, 해당 객체의 생성자가 처음 불린다. C++ 컴파일러의 규칙에 따르면, 파생클래스가 호출되었을 경우 먼저 기반 클래스가 호출 되고 이후 파생클래스가 호출 된다. 해당 부분이 낯선 경우 여기를 클릭해서 내용을 보완하자.

파생클래스의 호출로 기반 클래스가 먼저 호출된다고 보자. 해당 코드에서 보면, 기반 클래스에서 LogTransaction()이라는 가상함수를 통해 타입별 로그를 작성한 것을 볼 수 있다. 머릿속으로는 LogTransactionBuyTransaction에서 부르겠지라고 생각할 수 있다. 하지만, 현재 수행되는 시점이 기반 클래스 라는 점을 주목해야한다. 기반 클래스를 초기화할 경우 파생클래스의 존재는 알 수 없다. 즉, 여기서 호출 되는 LogTransaction()는 기반 클래스인 Transaction내 존재하는 함수가 호출된다. 따라서, 순수가상함수에 접근을 하기 때문에 오류를 만날 것이다.

❗ 여기서 중요한 사실은 기반 클래스의 생성자가 호출 될 경우 파생 클래스로 절대 넘어가지 않고 오로지 기반 클래스내에서만 초기화 한다는 사실이다.

이와 같이 동작하는 이유는, 우리가 파생 클래스를 호출할 경우 컴파일러가 기반 클래스의 생성자를 먼저 호출해준다. 이 시점에는 파생 클래스의 존재도 모르고, 파생 클래스의 데이터 멤버들도 초기화 되지 않았다. 따라서, 기반 클래스는 파생 클래스에 접근을 하지 않도록 할 것이며, 어쩌다 접근이 가능했다 치더라도, 초기화 되지 않은 데이터 멤버를 건드리게 된다. 초기화 되지 않은 데이터를 건드릴 경우 우리가 어떤 문제를 만날지 모르고 일단 문제를 만나게 되면 프로그래머는 밤을 세야하는 숙명으로.. 아주 힘들어진다. 😓

기반 클래스 생성 시점에 타입

해당 문제의 핵심점을 다시한번 생각해보자.

해당 문제의 핵심은 “파생 클래스 객체의 기반 클래스가 생성되는 동안은, 그 객체의 타입은 바로 기반 클래스 이다.”

호출되는 함수는 물론 타입 정의까지 모두 기반 클래스로 결정된다. 위의 예시를 통해보면, BuyTransaction 객체의 기반 클래스 부분을 초기화하기 위해 호출되는 Transaction 생성자 실행 동안에 타입은 Transaction이다. 따라서, 우리는 BuyTansaction이 초기화 되기 시점에 사용하는 Transaction에서는 BuyTransaction의 파생 클래스가 없다고 생각하고 행동하는 것이 편하다.

객체가 소멸될 때는 어떨까 ❔

동일한 문제가 존재한다. 왜냐하면, 파생클래스의 소멸자를 호출해도, 생성자와 동일하게 기반 클래스의 소멸자 혹은 생성자를 부른다. 기반 클래스의 소멸자가 호출 될 당시 오로지 기반 클래스의 타입으로 정의되기 때문에 해당 소멸자에서 파생 클래스에 접근을 하면 접근이 불가능해진다.

위의 코드를 보면 “누가 저렇게 파생클래스를 넣어”하는 사람도 있을 수 있다. 해당 예시가 너무 직관적이여서 그럴 수 있다는 것이다. 이런 위반은 눈으로 봐도 쉽고 일부 컴파일러는 잘못됐다고 경고를 내주기도 한다. (컴파일러 바이 컴파일러이다..) 해당 예시에서는 컴파일러 예시가 나오지 않더라도, 프로그램 수행 전에 에러를 만날 수 있다.

해당 virtual함수 같은 경우 순수 가상 함수로 선언되어져 있다. 따라서, 컴파일 타임에는 정의부가 어딘가 구현되어져있겠지 하고 넘어간다. 하지만, Linking 과정에서 취합을 하면서 함수간 호출이 가능한지를 체크했을때, 에러가 발생하게 된다. 즉, “링크에러”를 만날 수가 있게 된다.

위와 같이, 쉽게 찾을 수 있는 오류라면 문제가 되지 않는다. 생성자 및 소멸자 안에서 가상 함수 호출 부분을 찾기란 쉽지 않은 경우가 많다. 예를들어 Transaction 클래스 내 생성자가 다수가 된다고 가정해보자. 이럴 경우 동일한 클래스는 묶어 private영역에 생성을 해둔다.

class Transaction {
public:
  Transaction() {
    // 다양한 생성자가 있으면 코드를 묶어버림
    init ();
  }

  virtual void LogTransaction() = 0;

private:
  void init() {
    ...

    // init() 호출 마지막 부분에 로그를 출력해줌
    // 비가상함수에서 가상함수 호출..
    LogTransaction();
  }
};

위와같이 코드가 구성될 경우 생성자 내에는 LogTransaction이 없구나 하고 잘 돌아가겠지 할 수도 있다. 위와 같은 경우에는 “순수가상함수”로 정의되서 그나마 쉽지만, 만약 “일반 가상 함수”로 정의될 경우 컴파일 에러 혹은 링크에러 없이 너무 잘 동작하게 된다. 이후 문제를 만나게 돼 찾으려면 한세월 걸릴 것이다.

해당 문제를 해결하기 위해서는, 생성자 혹은 소멸자에서 가상함수 호출 부분을 추출해 사용하지 못하게 하고, 생성자에서 사용하고 있는 일반함수에도 혹여나 가상함수를 호출하고 있는지 확인해야한다.

이런 과정을 일일히 하기에는 너무 벅차다는 생각이 들수도 있다. 그렇다면 아래의 방법을 통해 해결해보자!

대처방법

해당 문제의 대처방법은 여러가지지만 Effective에서 소개하는 방법은 다음과 같다.

❗ Not Top donw Use Bottom up!

즉, LogTransactionTransaction 클래스의 비가상 멤버 함수로 변경한다. 그러고 난 후 파생 클래스들의 생성자들로 하여금 필요한 로그 정보를 Transaction의 생성자에 넘기도록 한다는 규칙을 만드는 것이다. LogTransaction은 이제 비가상 멤버함수이기 때문에 Transaction에서 안심하고 사용할 수 있다.

class Transaction {
public:
  explicit Transaction(const std::string& logInfo);

  // 비가상 함수로 변경함
  void LogTransaction(const std::string& logInfo) const;

  ...
};

Transaction::Transaction(const std::string& logInfo) {
  ...
  LogTransaction(logInfo);
}

class BuyTransaction : public Transaction {
public:
  BuyTransaction( parameters )
    :Transaction(createLogString(parameters))
  { ... }

  ...
private:
  static std::string createLogString(parameters);
};

위의 코드를 보면, Transaction에 실제 발생한 로그를 전달하는 것을 볼 수 있다. 해당 함수에서 전달받은 parameter를 바로 Transaction에 넘기지 않고, createLogString이라는 함수를 활용해 넘긴다. 해당 함수는 도우미 함수로, 기반 클래스에 전달해야되는 값들이 많을 때 구별하기 편하다. 해당 함수가 도와주는 부분은 기존에 초기화 리스트로 전달된 값들이 객체라면 그들도 객체의 초기화가 필요하다. 하지만, 그들이 초기화 되기 전에 만약 기반 클래스의 생성자가 먼저 호출되 사용하게된다면? 문제가 될수도 있다. 따라서, 파생클래스에서 도움이 함수를 활용해 넘겨주면 안전하게 넘길 수 있다.

이 파생클래스에서 사용하는 도움이 함수의 가장 중요한 기법은 static을 사용한다는 점이다.

위의 언급과 같이, 중요한 점은 해당 도우미 함수가 static으로 선언되어져 있다는 점인데.. 우리가 이전 포스팅에서 봤던 것과 같이, 로컬 정적멤버로 선언되어져 있기 때문에 무조건 초기화가 일어날 수 있도록 한다는 점이다.

즉, 정적 멤버로 인해 생성이 끝나지 않은 채 BuyTransaction 객체의 미초기화된 데이터 멤버를 자칫 실수로 건드릴 위험도 제거할 수 있다는 말이다.

미초기화 된 데이터 멤버같은 경우 정의되어있지 않은 즉, 미정의 상태이기 때문에 굉장히 위험하기 때문에 static을 활용하여 막는 방법을 사용하도록 하자.

To Sum Up

👉 생성자 혹은 소멸자 안에서 가상함수를 절대절대 호출하지말도록 하자.

👉 Top down 방식으로는 접근이 불가능해, 가상함수를 호출하지 못할 수도 있다.