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

c++에서 생성자는 새로운 객체를 메모리에 만드는데 필요한 과정을 제어하고 객체의 초기화를 맡으며 소멸자는 객체를 없애면서 그 객체가 메모리에서 적절하게 사라질 수 있도록 하는 함수입니다. 그리고 대입 연산자는 기존의 객체에 값이나 정보를 다른 객체에게 줄 때 사용합니다.

우리가 클래스를 그냥 공백으로 생성하여도 컴파일러는 복사 생성자, 복사 대입 연산자, 소멸자는 자동으로 만들어 줍니다. 아래처럼 아무것도 선언하지 않는 클래스의 객체를 생성해서 연산을 해도 문제가 없습니다.

#include <iostream>

using namespace std;

class Empty {};

int main()
{
  Empty e1, e2;
  e2 = e1;
  Empty e3(e1);
}

하지만 기본 생성자의 경우 클래스 내부에 아무런 생성자가 선언되어 있지 않을 때만 자동으로 생성해줍니다.

기본으로 만들어주는 복사 생성자와 복사 대입 연산자의 경우에는 단순히 비정적 데이터를 복사해주게 됩니다. 그런데 만약 클래스의 멤버 변수가 아래와 같다면 문제가 생깁니다. 참조자로 선언된 변수는 자신이 참조하고 있지 않는 것을 참조할 수 없습니다. 그렇기에 컴파일 단계에서 에러를 떨어트리게 됩니다.

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

위에 설명한 것처럼 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자는 선언한 게 없다면 자동으로 만들어 줍니다. 이제 여기서 문제가 될 수 있는 상황은 복사 생성자나 복사 대입 연산자입니다. 만약 개체를 복사하면 안 되는 경우 복사 생성자와 복사 대입 연산자를 생성하지 않고 객체를 선언한다면 어디에서 의도치 않게 복사를 시도했을 때 제대로 막을 수 없습니다.

이러한 경우 복사를 막기 위해서는 private으로 선언 후 아무런 정의를 해주지 않는 것입니다.

class Empty {
public:
  Empty() {}
private:
  Empty(const Empty&);
  Empty& operator=(const Empty&);
};

int main()
{
  Empty e1, e2;
  e2 = e1;
  Empty e3(e1);
}

또 다른 방법은 복사 방지를 할 클래스를 선언 후 이 클래스를 상속받는 방법입니다.

#include <iostream>

using namespace std;

class PreventCopy {
protected:
  PreventCopy() {}
  ~PreventCopy() {}
private:
  PreventCopy(const PreventCopy&);
  PreventCopy& operator=(const PreventCopy&);
};

class Empty : private PreventCopy {
};

int main()
{
  Empty e1, e2;
  e2 = e1;
  Empty e3(e1);
}

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

어떤 기본 클래스를 상속받는 클래스를 만들고 팩토리 함수처럼 상속된 함수 객체를 만들어서 기본 클래스 형태로 접근해서 사용하기도 합니다. 이렇게 생성한 객체는 결국 다시 소멸시키는데 만약 이처럼 기본 클래스 형태로 소멸시키게 됐을 때 소멸자가 비가상 소멸자일 경우 파생 클래스까지 제대로 소멸이 이루어지지 않습니다.

class Base {
public:
  Base() { cout << "Base Create\n"; }
  ~Base() { cout << "Base Delete\n"; }
  virtual void f() { cout << "Base\n"; }
};

class Test : public Base {
public:
  Test() { cout << "Test Create\n"; }
  ~Test() { cout << "Test Delete\n"; }
  virtual void f() { cout << "Test\n";}
};

Base* getBase() {
  Test* p = new Test;
  return p;
}

int main() 
{
  Base* b = getBase();
  b->f();
  delete b;
}

위처럼 파생 클래스의 생성자는 제대로 호출되었지만 소멸자는 제대로 호출되지 않습니다. 이럴 경우 파생 클래스에서 할당한 메모리가 제대로 해제가 되지 않을 수 있어 문제를 일으킬 수 있습니다.

이러한 문제를 방지하기 위해서는 기본 클래스의 소멸자를 가상 소멸자로 선언하는 것입니다. 위 코드에서 기본 클래스의 소멸자에 virtual만 붙여줬을 뿐인데 파생 클래스도 모두 제대로 소멸자가 호출되었습니다.

하지만 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스의 경우에는 소멸자에 virtual을 해주게 되면 오히려 독이 될 수 있으므로 상황에 맞게 사용해야 합니다.

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

우리가 클래스를 정의하고 그 클래스를 아래처럼 사용하는 경우에 만약 소멸자에서 예외가 발생한다고 봅시다.

class Widget {
public:
  Widget() {}
  ~Widget() { ...; }
};

void func() {
  std::vector<Widget> vec;
  ...;    // action
}

func 함수의 어떤 행위 이후 vec은 소멸하면서 자신이 가지고 있는 Widget 객체들을 소멸해줍니다. 첫 번째 객체에서 예외가 떨어지고 두 번째 예외에서도 예외가 떨어진다면 어떤 동작이 벌어질지 예측할 수 없게 됩니다.

위처럼 만약 소멸자에서 어떤 행위를 해야 하고 예외 처리를 해야 한다면 2가지 방법이 있습니다. 예외 처리 이후 바로 프로그램을 종료시키거나 예외를 삼킨다고 하는 방식을 사용합니다.

class Widget {
public:
  Widget() {}
  ~Widget() { 
    try { 
      close();
    } catch ( ... ) {
      std::abort();
    }
  }
  void close() { ...; }
};

void func() {
  std::vector<Widget> vec;
  ...;    // action
}
class Widget {
public:
  Widget() {}
  ~Widget() { 
    try { 
      close();
    } catch ( ... ) {
      // 예외 삼키기 따로 처리 x
    }
  }
  void close() { ...; }
};

void func() {
  std::vector<Widget> vec;
  ...;    // action
}

하지만 두 방법 모두 올바른 방법이라 보기엔 애매합니다. 결국 어떤 행위를 했을 때 에러 처리를 회피하는 뉘앙스를 보이는데 이런 방식보다 예외가 일어날 소지가 있는 부분은 따로 어딘가에서 처리하게 하는 것이 맞다고 생각합니다.

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

제목처럼 하면 안 되는 이유는 원하는 동작이 제대로 동작하지 않아서입니다.
아래와 같이 기본 클래스와 여러 파생 클래스가 있다고 가정해 보겠습니다.

class Skill {
public:
  Skill() {
    cout << "Skill\n";
    SetDamage();
  };
  virtual void SetDamage() = 0;
};

class fireball : public Skill {
public:
  fireball() {
    cout << "fireball\n";
  };
  virtual void SetDamage() { }
};

class PowerStrike : public Skill {
public:
  PowerStrike() {
    cout << "PowerStrike\n";
  };
  virtual void SetDamage() { }
};

int main()
{
  PowerStrike sk;
}

여기서 PowerStrike 객체를 생성하면서 Skill의 생성자를 먼저 호출합니다. 이 Skill의 생성자에서 가상 함수인 SetDamage를 호출하는데 아직 기본 클래스에서 함수를 호출하기 때문에 Skill::SetDamage()가 호출됩니다. 만약 위 예제처럼 순수 가상 함수로 선언했다면 실행 자체가 되지 않게 됩니다.

위 문제를 해결하는 방법은 일단 해당 함수를 비가상 함수로 만들고 파생 클래스의 생성자에서 필요한 정보를 기본 클래스의 생성자로 넘기도록 합니다.

class Skill {
public:
  Skill(int nDamage) {
    SetDamage(nDamage);
  };
  void SetDamage(int& nDamage) {
    nPower = nDamage;
  }
  int GetDamage() { return nPower; }
protected:
  int nPower;
};

class PowerStrike : public Skill {
public:
  PowerStrike() : Skill(Calc()) {
  };
  static int Calc() { return 1; }
};

int main()
{
  PowerStrike sk;
  cout << sk.GetDamage();
}

이렇게 되면 이전 방식에서 필요로 했던 기본 클래스가 파생 클래스로부터 특정 정보를 가져온 후 세팅하는 게 문제 없어집니다.

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

c++에서 대입 연산은 아래처럼 어떤 연산의 결과는 그다음 연산에 영향을 미치게 됩니다. 이러한 결과는 대입 시 참조자를 반환하게 되었기 때문인데 클래스를 구현했을 때도 이러한 방식을 따르는 게 기존의 대입과 동일하게 구현하는 것이라고 생각합니다.

이 규칙은 대입에 관련된 모든 대입 연산자에서 지켜져야 합니다.

class Test {
public:
  Test& operator=(const Test& rhs) {
    return *this;
  }
  Test& operator+=(const Test& rhs) {
    return *this;
  }
  Test& operator=(int rhs) {
    return *this;
  }
  ...;
};

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

자기 대입이란 아래처럼 대입하는 작업입니다.

int main()
{
    Widget w;
    w = w;
}

물론 위 코드만 봐서는 왜 저렇게 하나 싶지만 눈에 보이지 않게 자기 대입이 일어날 수 있습니다. 배열 인덱스를 통한 복사 혹은 포인터를 통한 복사 등으로 일어날 수 있습니다.

아래처럼 어떤 자원을 관리하는 클래스가 있다고 봅시다. 이 객체들을 복사할 때 조심해야 하는 것이 자기 대입 관련되어 대입 연산자를 올바르게 구현하는 것입니다.
만약 아래처럼 Widget이 대입이 이루어질 때 기존의 자원은 해제 후 복사 대상의 자원 정보를 갖고 오게 됩니다.

class Resource {};
class Widget {
  Widget& operator=(const Widget& rhs) {
    delete pR;
    pR = new Resource(*rhs.pR);
    return *this;
  }
private:
  Resource* pR;
};


그런데 위 상황에서 복사 대상이 자신이라면 자신의 자원 정보는 delete 된 후 새로 할당하려 할 때 문제가 일어나게 됩니다. 이러한 문제를 방지하기 위해 이 복사 대상이 자기 자신인지 체크하는 로직이 필요합니다.

class Resource {};
class Widget {
  Widget& operator=(const Widget& rhs) {
    if ( this == &rhs ) { return *this; }
    // 복사 대상이 자기 자신이라면 반환
    delete pR;
    pR = new Resource(*rhs.pR);
    return *this;
  }
private:
  Resource* pR;
};

만약 어떤 문제가 있어 new 자체에서 예외가 발생한다면 삭제가 돼버린 자원 정보만을 갖게 됩니다. 이러한 문제를 방지하기 위해선 선 삭제 후 생성이 아닌 선 생성 후 삭제를 하면 됩니다. 먼저 생성을 해보고 문제가 없다 싶으면 삭제를 하는 것입니다.

class Resource {};
class Widget {
  Widget& operator=(const Widget& rhs) {
    if ( this == &rhs ) { return *this; }
    // 복사 대상이 자기 자신이라면 반환
    Resource* pOrigin = pR;
    pR = new Resource(*rhs.pR);
    delete pOrigin;

    return *this;
  }
private:
  Resource* pR;
};

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

이전에 설명했듯이 복사 생성자, 복사 대입 연산자는 사용자가 선언하지 않으면 컴파일러가 알아서 구현해줍니다. 만약 사용자가 어떤 이유에서 위 함수들을 선언했다면 모든 데이터들을 빠짐없이 복사해줘야 합니다.

이 복사 관련해서 실수하기 좋은 부분이 파생 클래스의 복사 생성, 대입 연산일 때입니다.

class Base {
public:
  Base(const Base& rhs) : nBase(rhs.nBase) {}
  Base& operator=(const Base& rhs) {
    nBase = rhs.nBase;
    return *this;
  }
private:
  int nBase;
};

class Test : public Base {
public:
  Test(const Test& rhs) : nTest(rhs.nTest) {}
  Test& operator=(const Test& rhs) {
    nTest = rhs.nTest;
    return *this;
  }
private:
  int nTest;
};

위와 같이 정의했을 때 얼핏 보기에는 복사가 잘 되는 것 같지만 기본 클래스에 담겨 있는 데이터는 제대로 복사가 되지 않게 됩니다. 복사를 제대로 해주기 위해선 파생 클래스의 복사 생성자에서는 기본 클래스의 복사 생성자까지 호출하고 파생 클래스의 복사 대입 연산자에서는 동일하게 기본 클래스의 복사 대입 연산자까지 호출해줘야 합니다.

class Base {
public:
  Base(const Base& rhs) : nBase(rhs.nBase) {}
  Base& operator=(const Base& rhs) {
    nBase = rhs.nBase;
    return *this;
  }
private:
  int nBase;
};

class Test : public Base {
public:
  Test(const Test& rhs) :
    Base(rhs), 
    nTest(rhs.nTest) {}
  Test& operator=(const Test& rhs) {
    Base::operator=(rhs);
    nTest = rhs.nTest;
    return *this;
  }
private:
  int nTest;
};




반응형

+ Recent posts