스토리텔링 개발자

[More Effective C++] 33. 추상 클래스(abstract class) 본문

개발/More Effective C++

[More Effective C++] 33. 추상 클래스(abstract class)

김디트 2024. 10. 17. 11:21
728x90

항목 33. 상속 관계의 말단에 있지 않은(non-leaf) 클래스는 반드시 추상 클래스로 만들자

 

 

 

대입 문제
class Animal
{
public:
    Animal& operator=(const Animal& rhs);
    ...
};

class Lizard : public Aanimal
{
public:
    Lizard& operator=(const Lizard& rhs);
    ...
};

class Chicken : public Animal
{
public:
    Chicken& operator=(const Chicken& rhs);
    ...
};

Lizard liz1;
Lizard liz2;

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2; // 문제!
  • 두 가지 문제가 있다.
    1. 둘 다 Lizard 타입인데, 대입 연산자는 Animal의 것이 호출된다.
      • 부분 대입(partial assignment) 현상 : Animal의 멤버 부분만 바뀐다.
    2. 객체 사이의 대입을 포인터를 통해 수행하는 경우는 드문 사용법이 아니다.
      • 맞게 사용하기엔 쉽고, 틀리게 사용하기엔 어렵게 를 위반한다.
  • 참고로, 구체(concrete) 기본 클래스가 데이터 멤버를 가지고 있지 않고 있을 때라면 대입 문제는 발생하지 않는다.
    • 즉, 데이터가 없는 구체 클래스로부터 구체 클래스를 파생 시키는 것은 안전하다.
    • 구체 기본 클래스가 데이터가 없을 수 있는 상황은 아래 두 가지이다.
      1. 이후에 데이터를 가질 수 있으나 지금 안 가진 경우
      2. 진짜로 데이터가 없는 경우
        • 이 경우, 구체 클래스일 필요가 없으니, 추상 클래스로 만들어 버리자.(아래 추상 클래스에 대한 내용 참조)

 

 

 

해결 방법 1. 대입 연산자를 가상 함수로 만든다.
class Animal
{
public:
    virtual Animal& operator=(const Animal& rhs);
    ...
};

class Lizard : public Animal
{
public:
    // C++ 표준화에 따라
    // 대입 연산자의 반환값은 바꿀 수 있지만,(공변 반환 타입)
    // 매개변수 타입은 바꿀 수 없다.
    virtual Lizard& operator=(const Animal& rhs) override;
    ...
};

class Chicken : public Animal
{
public:
    // 마찬가지
    virtual Chicken& operator=(const Animal& rhs) override;
    ...
};

// 매개변수가 Animal 타입을 전부 다 받을 수 있기 때문에 아래와 같은 문제가..
Lizard liz;
Chicken chick;

Animal* pAnimal1 = &liz;
Animal* pAnimal2 = &chick;
...
*pAnimal1 = *pAnimal2; // 도마뱀에 닭을 대입?
  • 타입 불일치 대입(mixed-type assignment) 현상
    • 보통은 이 경우 C++에서는 컴파일 단계에서 문제를 잡아낼 수 있다.
    • 하지만 대입 연산자가 가상 함수로 만들어져 있기 때문에 컴파일 통과.
  • dynamic_cast(항목 2 참조)를 쓰면 해결할 수는 있지만..
Lizard& Lizard::operator=(const Animal& rhs)
{
    // rhs가 진짜로 Lizard 타입인지 확인
    // 해당 타입이 아니면 std::bad_cast 예외가 발생한다.
    const Lizard& rhs_liz = dynamic_cast<const Lizard&>(rhs);
    
    rhs를 *this에 대입한다;
}
  • 진짜 Lizard 객체 사이의 대입이 훨씬 흔함에도 쓸데없는 비용이 소모되는 문제가 있다.
  • 해결의 해결 방법 : Lizard 클래스에 Lizard용 대입 연산자를 추가하면 간단히 해결
class Lizard : public Animal
{
public:
    virtual Lizard& operator=(const Animal& rhs) override;
    
    Lizard& operator=(const Lizard& rhs); // 이것을 추가한다.
    ...
};

Lizard& Lizard::operator=(const Animal& rhs)
{
    return operator=(dynamic_cast<const Lizard&>(rhs)); // 코드 재활용
}

Lizard liz1, liz2;
...
liz1 = liz2; // 새로 추가된 버전이 호출된다.

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2; // 오버라이드 버전이 호출된다.
  • 이렇게 해결은 했지만... 찝찝한 부분
    1. dynamic_cast를 지원하지 않는 컴파일러가 있을 수 있다?
      • 사실 이제는 걱정할 필요가 없는 문제라 할 수 있다.
    2. 개발자는 항상 bad_cast에 대한 대비를 해야 한다.
      • try-catch 문을 늘 달고 살아야 한다는 뜻인데, 너무 거추장스럽다.

 

 

 

해결 방법 2. Animal::operator= 함수를 private 영역으로 둔다.
  • 애초에 이상한 대입을 하면 컴파일 에러가 뜨도록 하면 되지 않을까?
class Animal
{
private:
    Animal& operator=(const Animal& rhs);
    ...
};

class Lizard : public Animal
{
public:
    Lizard& operator=(const Lizard& rhs);
    ...
};

class Chicken : public Animal
{
public:
    Chicken& operator=(const Chicken& rhs);
    ...
};

Lizard liz1, liz2;
...
liz1 = liz2; // 문제 없다.

Chicken chick1, chick2;
...
chick1 = chick2; // 문제 없다.

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &chick1;
...
*pAnimal1 = *pAnimal2; // 에러!

 

  • 이 코드의 문제는 Animal 역시 구체 클래스(concete class)이므로 아래 코드도 컴파일이 불가능하다는 점이다.
Animal animal1, anima2;
...
animal1 = animal2; // 컴파일 에러면 안되는데..
  • 또 다른 문제는, Lizard와 Chicken의 대입 연산자를 부모의 operator=를 사용하여 구현할 수 없다는 점이다.
    • 물론 protected로 선언하면 해결되긴 한다.
Lizard& Lizard::operator=(const Lizard& rhs)
{
    if(this == &rhs) return *this;
    
    Animal::operator=(rhs); // 컴파일 에러! private 멤버이기 때문이다.
    ...
}

 

 

 

 

해결 방법 3. Animal을 추상 클래스로 만든다.
  • Animal 객체끼리 대입할 필요를 만들지 않도록 하면 되지 않을까?
    • Animal 클래스를 추상 클래스로 만들면 된다.
  • Animal을 객체로 써먹으려 했었다면 추상 클래스로 만들면 안되는데..
  • 해결의 해결 방법 : 새로운 추상 클래스를 만들어서 최상단에 놓는다.
class AbstractAnimal
{
protected:
    AbstractAnimal& operator=(const AbstractAnimal& rhs);
    
public:
    virtual ~AbstractAnimal() = 0; // 추상 클래스
    ...
};

class Animal : public AbstractAnimal
{
public:
    Animal& operator=(const Animal& rhs);
    ...
};

class Lizard : public AbstractAnimal
{
public:
    Lizard& operator=(const Lizard& rhs);
    ...
};

class Chicken : public AbstractAnimal
{
public:
    Chicken& operator=(const Chicken& rhs);
    ...
};
  • 이 경우 가장 큰 허들은 AbstractAnimal이 순수 가상 함수로 만들만한 멤버 함수가 하나도 없는 상황이라는 것이다.
    • 보통 소멸자를 순수 가상 함수로 선언한다.
    • 소멸자의 구현을 바깥에 구현해야 하는 정도의 수고만이 추가로 필요할 뿐이다.

 

 

 

추상 클래스에 대해
  • 순수 가상 함수로 선언한다는 말의 뜻
    • 이 클래스는 추상 클래스이고,
    • 이 클래스로부터 파생된 모든 구체 클래스는 순수 가상 함수로 선언된 이 함수를 "보통의" 가상 함수로 구현해야 한다.
    • 구현을 하지 않는다는 뜻이 아니라, 구현을 하든 안하든 상관하지 않겠다는 뜻이다.
  • 순수 가상 소멸자를 '구현'해야 하는 이유
    • 파생 클래스의 소멸자가 호출될 때 호출될 부분이 필요하기 때문이다.
    • 부모 클래스의 소멸자가 호출되지 않는다면, 부모 클래스 부분의 메모리 누수가 발생할 수 있다.
  • 추상 기본 클래스를 사용할 때의 이점
    • 사용자에게 파생 클래스들이 공통적으로 가지는 것을 확실히 각인시킬 수 있다.
  • 그렇다고 해서 모든 상속 계통 클래스에 추상 기본 클래스를 넣겠다고 억지부릴 필요는 없다.
    • 클래스가 많아지므로 복잡해지고, 이해가 어려워진다.
    • 유지보수가 어려워진다.
    • 컴파일 시간이 오래 걸린다.
    • 그러므로 쓸모 있는 추상 클래스라는 확신이 설 때만 추상 클래스를 넣어주자.
  • 진짜 필요한 추상 클래스란?
    • 사실 누구도 추측할 수 없는 영역이긴 하지만...
    • 우연히 어떤 추상 타입이 필요한 경우가 한 번이라도 생기면, 그 추상 타입이 필요한 경우는 다른 상황에서도 생길 수 있다.
    • 구체 기본 클래스에서 새로운 추상 기본 클래스로 변환해야 할 필요성을 판단하는 방법
      • 기존 구체 클래스를 기본 클래스로 사용해야 할 때에만,
      • 즉 이 클래스가 다른 경우에서 재사용되어야 할 때에만 추상 클래스가 새로 도입되어야 한다.
    • 즉, 미리 추상 클래스를 고민하지 말고 정말 필요해질 때에서야 추상 클래스를 덧붙이도록 하자.

 

 

 

추상 클래스를 적용할 수 없는 경우
  • 라이브러리에 속한 클래스의 기능을 끌어온 파생 클래스를 만들고 싶다면?
  • 추상 클래스를 새로 만들어 상위단에 끼워넣는 것은 불가능하다.
  • 선택할 수 있는 방법
    • 기존의 구체 클래스로부터 새로 만들 구체 클래스를 파생시켜 사용한다.
      • 위에서 언급한 대입 관련 문제를 늘 명심하며 실수하지 않도록 한다.
      • 추가적으로 객체 배열 함정(항목 3 참조)도 조심한다.
    • 해당 라이브러리 클래스보다 상위 계통 구조의 추상 클래스를 찾아본다.
      • 없을 수도 있다.
      • 있다 해도 원래 상속받으려 했던 클래스의 기능을 중복 구현해야 하므로 품이 많이 든다.
    • 기본 클래스로 하려 했던 라이브러리 클래스를 사용한 래퍼 클래스를 만든다.
      • class Window { ... }; // 라이브러리 클래스
        
        class SpecialWindow
        {
        public:
            ...
            // 기존 기능들
            int width() const { return w.width(); }
            int height() const { return w.height(); }
            
            // 상속하여 재구현하고 싶었던 부분들
            virtual void resize(int newWidth, int newHeight);
            virtual void repaint() const;
        private:
            Window w;
        };
      • 이 경우 라이브러리가 업데이트 되면 그때마다 유지보수 해야한다.
      • 또한, 라이브러리 클래스의 가상 함수를 직접 재정의할 수 없다.
    • 라이브러리 클래스를 그냥 사용한다. 그리고 추가 기능은 비멤버 함수로 처리한다.
      • 매우 더러워질 테지만, 아무튼 돌아간다..
728x90
Comments