[Advanced C++] 64. Multiple inheritance, Mixin Class, virtual function, virtual function이 필요한 이유, Polymorphism, 가상 함수 사용 시 주의할 점

dev.kelvin·2025년 6월 19일
1

Advanced C++

목록 보기
64/74
post-thumbnail

1. Multiple inheritance

다중 상속

이제까지 단 한개의 부모 클래스만을 상속받는 단일 상속을 정리했지만 C++은 하나 이상의 부모 클래스를 상속 받을 수 있는 다중 상속을 지원한다

,로 단일 상속들을 구분해서 여러개 상속받을 수 있다

    class Base1
    {
    public:
        Base1(int inValue)
            : base1Int{inValue}
        {

        }

        int GetBase1Int() { return base1Int; }

    private:
        int base1Int{};
    };

    class Base2
    {
    public:
        Base2(int inValue)
            : base2Int{inValue}
        {

        }

        int GetBase2Int() { return base2Int; }

    private:
        int base2Int{};
    };

    class Derived : public Base1, public Base2 //Base1, Base2 두개의 클래스를 상속받음
    {
    public:
    	//2개의 클래스를 상속받았기 때문에 생성자 초기화 리스트에서 2개의 클래스 생성자를 이용하여 초기화하는 모습
        Derived(int inBase1Value, int inBase2Value, int inDerValue)
            : Base1(inBase1Value), Base2(inBase2Value), derInt{inDerValue}
        {

        }

        int GetDerInt() { return derInt; }

    private:
        int derInt{};
    };

    int main()
    {
        Derived derived{1, 2, 3};

		//2개의 클래스를 상속받았기 때문에 2개의 클래스 멤버 함수,변수를 사용할 수 있다
        derived.GetBase1Int();
        derived.GetBase2Int();
        derived.GetDerInt();

        return 0;

    }

Mixin

Mixin은 특정 클래스에 속성을 추가하기 위해 상속될 수 있는 작은 클래스를 의미한다, 이러한 Mixin클래스들은 클래스 단독으로 인스턴스화 되어 사용되기 보다 다른 클래스에 상속되어 사용되는걸 의도한 클래스이다

    struct Point2D
    {
        int x{};
        int y{};
    };

    class Box // Mixin Box class
    {
    public:
        void setTopLeft(Point2D point) { m_topLeft = point; }
        void setBottomRight(Point2D point) { m_bottomRight = point; }
    private:
        Point2D m_topLeft{};
        Point2D m_bottomRight{};
    };

    class Label // Mixin Label class
    {
    public:
        void setText(const std::string_view str) { m_text = str; }
        void setFontSize(int fontSize) { m_fontSize = fontSize; }
    private:
        std::string m_text{};
        int m_fontSize{};
    };

    class Tooltip // Mixin Tooltip class
    {
    public:
        void setText(const std::string_view str) { m_text = str; }
    private:
        std::string m_text{};
    };

    //여러 MixinClass를 상속받아 속성이 추가된 Button Class
    class Button : public Box, public Label, public Tooltip 
    {

    };
    
    int main()
    {
        Button button{};
        button.Box::setTopLeft({ 1, 1 });
        button.Box::setBottomRight({ 10, 10 });
        button.Label::setText("Name");
        button.Label::setFontSize(6);
        button.Tooltip::setText("Kelvin Button");
    }

위 코드에서는 Box, Label, ToolTip 과 같은 class들이 Mixin 클래스가 되고 Button클래스는 이러한 Mixin 클래스들을 상속받아 여러 속성이 추가된 클래스이다

이때 범위 지정 연산자를 사용하여 함수를 호출한 이유는 Label클래스와 ToolTip 클래스에 동일한 함수 시그니처가 존재하기 때문에 함수 호출시 모호함을 없애기 위함이다

다중 상속시 이렇게 동일한 시그니처의 함수가 여러개 상속 될 수 있기 때문에 범위 지정 연산자 ::를 이용하여 호출할 함수를 명시적으로 특정하는게 좋다

모호하지 않더라도 범위 지정 연산자를 사용하여 어떠한 Mixin 클래스의 함수가 사용되는지 확실히 할 수 있고 추후에 Mixin클래스가 수정될 때도 모호함을 방지할 수 있다 (가독성, 유지보수에 좋다)

이러한 Mixin클래스는 단순히 파생 클래스에 기능을 추가하도록 설계되었기 때문에 가상 함수를 사용하지 않는다 (호출할 함수가 컴파일 타임에 명확히 정해져있기 때문에 런타임에 어떤 함수를 호출할지 결정하는 가상 함수가 필요 없다)

근데 만약 이러한 가상함수를 사용하지 않는 Mixin 클래스를 특정 상황에 맞게 커스텀해야 한다면 어떻게 해야할까? 바로 템플릿을 사용하면 된다

자식 클래스는 자기 자신을 템플릿 인자로 사용하여 Mixin클래스를 상속받을 수 있다, 이러한 상속을 CRTP라고 부른다 (Curiously Recurring Template Pattern)

    template <typename T>
    class Base {
    public:
        void BaseFoo() {
            static_cast<T*>(this)->DerivedFoo();
        }
    };

    class Derived : public Base<Derived> {
    public:
        void DerivedFoo() {
            std::cout << "Derived Function called!\n";
        }
    };

    int main()
    {
        Derived derived{};
        derived.Base::BaseFoo();

        return 0;
    }

가상함수를 사용하지 않고 다형성과 유사한 효과를 낼 수 있기 때문에 이러한 동작을 static polymorphism이라고 한다

다중 상속의 단점

다중 상속은 프로그램의 복잡도를 증가시키고 유지보수성을 낮추는 원인이 될 수 있다

위에 정리한 함수의 모호성도 단점 중 하나이다

    class Base1
    {
    public:
        Base1(int inValue)
            : base1Int{inValue}
        {

        }

        int GetBaseInt() { return base1Int; }

    private:
        int base1Int{};
    };

    class Base2
    {
    public:
        Base2(int inValue)
            : base2Int{inValue}
        {

        }

        int GetBaseInt() { return base2Int; }

    private:
        int base2Int{};
    };

    class Derived : public Base1, public Base2
    {
    public:
        Derived(int inBase1Value, int inBase2Value, int inDerValue)
            : Base1(inBase1Value), Base2(inBase2Value), derInt{inDerValue}
        {

        }

        int GetDerInt() { return derInt; }

    private:
        int derInt{};
    };

    int main()
    {
        Derived derived{1, 2, 3};

        derived.GetBaseInt(); //Base1, Base2 클래스에 둘 다 존재하는 함수이기 때문에 함수 호출 시 모호함이 발생하여 compile error가 발생함

        return 0;
    }

물론 위와 같은 코드는 derived.Base1::GetBaseInt();로 함수 호출 시 호출할 함수를 범위 지정 연산자를 통해 명시함으로서 해결할 수 있다

하지만 만약 다중 상속받은 클래스가 굉장히 여러개인 상황에서 다음과 같은 해결법은 상당히 프로젝트의 복잡도를 올릴 수 있다

다중 상속의 더욱 심각한 문제는 다이아몬드 문제이다

한 클래스가 각각 동일한 단일 클래스를 상속받은 두 클래스를 다중 상속받을 때 발생한다

	class PoweredDevice {};
    class Scanner: public PoweredDevice {};
    class Printer: public PoweredDevice {};
    class Copier: public Scanner, public Printer {}; //다이아몬드 문제 발생

이러한 상황에서 Copier객체에는 PoweredDevice의 멤버 데이터가 2개 존재하게 된다, 따라서 Copier객체를 통해 PoweredDevice 멤버 데이터에 접근 시 모호성이 발생하고 객체의 메모리가 의도치 않게 커지는 문제가 발생한다, 이는 가상 함수 상속으로 해결이 가능하다

단일 상속으로 구현할 수 있으면 다중 상속은 최대한 피하는 편이 좋다 (애초에 다른 언어들은 다중 상속을 아예 지원하지 않기도 한다, C#, Java는 단일 상속만 허용하고 인터페이스 클래스의 다중 상속만 허용한다)

물론 꼭 사용해야 한다면 사용해야 한다
(std::cin, cout 도 다중 상속으로 구현됨)


2. 상속관계에서 virtual function을 사용해야 하는 이유

상속관계에서 virtual function을 사용해야 하는 이유

상속을 해서 자식 클래스를 만들고 인스턴스화 하게 되면 이 자식 클래스 객체는 부모 클래스 부분과 자식 클래스 부분을 포함한다 (자식 클래스가 부모 클래스를 포함한다)

    class Base
    {
    protected:
        int m_value{};

    public:
        Base(int value)
            : m_value{ value }
        {
        }

        std::string_view getName() const { return "Base"; }
        int getValue() const { return m_value; }
    };

    class Derived : public Base
    {
    public:
        Derived(int value)
            : Base{ value }
        {
        }

        std::string_view getName() const { return "Derived"; }
        int getValueDoubled() const { return m_value * 2; }
    };

    int main()
    {
        Derived derived{ 10 };
        std::cout << derived.getName() << derived.getValue() << '\n';

        Derived& refDerived{ derived };
        std::cout << refDerived.getName() << refDerived.getValue() << '\n';

        Derived* ptrDerived{ &derived };
        std::cout << ptrDerived->getName() << ptrDerived->getValue() << '\n';

        return 0;
    }

위 코드의 결과는 전부 동일하게 Derived와 10이 나오게 된다

refDerived, ptrDerived는 Derived클래스 객체의 참조, 포인터이기 때문에 Derived클래스 타입이므로 Derived클래스의 함수를 호출한다

그렇다면 Base타입으로 사용하면 어떻게 될까?

	Derived derived{ 10 };
    std::cout << derived.getName() << derived.getValue() << '\n';

    Base& refDerived{ derived };
    std::cout << refDerived.getName() << refDerived.getValue() << '\n';

    Base* ptrDerived{ &derived };
    std::cout << ptrDerived->getName() << ptrDerived->getValue() << '\n';

파생 클래스는 기반 클래스 부분을 가지고 있기 때문에 위와 같은 코드가 정상 동작한다

결과는 Derived 10, Base 10, Base 10이 나오게 된다

분명 Derived클래스 타입의 객체를 이용하여 refDerived, ptrDerived를 초기화 했지만 이 두 변수의 타입이 Base이기 때문에 Base::함수가 호출되게 된 것이다

만약 부모 클래스의 함수가 아닌 각각의 초기화 된 객체의 함수를 호출하고 싶다면 그냥 파생 클래스 타입 객체를 사용하면 되지 않나? 라고 생각할 수 있지만 이는 비효율적일 수 있다

    void Foo(const Base& InBase)
    {
        std::cout << InBase.getName();
    }

    void Foo(const Derived& InDerived)
    {
        std::cout << InDerived.getName();
    }

특정 함수의 인자로 클래스 타입 객체를 넘겨서 함수를 호출한다고 가정했을때 각각의 객체별로 함수를 오버로딩 해야하기 때문이다

굉장히 많은 함수 오버로딩이 발생할 수 있기 때문에 적절한 방법이 아니다

이러한 함수는 다음과 같이 구현되어야 한다

	void Foo(const Base& InBase)
    {
        std::cout << InBase.getName();
    }

Base에서 함수 호출 시 실제 호출자 객체의 타입에 맞는 함수가 호출되는게 가장 이상적이다 (자식 클래스 객체는 부모 클래스 부분을 가지고 있기 때문에 가능해야 함)

물론 지금 상태로 위 함수를 호출하게 되면 Derived타입 객체를 넘겨도 Base::getName()이 호출되게 된다

혹은 템플릿 함수를 사용해서 함수 오버로딩을 줄일 수 있다

	template<typename T>
    void Foo(const T& InBase)
    {
        std::cout << InBase.getName();
    }

이렇게 하면 실질적으로 T에는 Derived클래스 타입이 들어가기 때문에 Derived::getName()이 호출된다

그러나 이러한 방식은 getName()이 있는 어떠한 클래스도 다 사용이 가능하기때문에 문제가 있다

함수뿐만 아니라 배열에서도 같은 문제를 확인할 수 있다

	std::array<Base, 3> Bases{ 1, 2, 3 };
    std::array<Derived, 3> Deriveds{4, 5, 6};

    for (const Base& base : Bases)
    {
        std::cout << base.getName() << '\n';
    }

    for (const Derived& derived : Deriveds)
    {
        std::cout << derived.getName() << '\n';
    }

위 코드에서 for loop도 Base타입 하나로 처리하는 logic이 이상적이다

	for (const Base& base : Bases)
    {
        std::cout << base.getName() << '\n';
    }

결국 문제는 부모 클래스 타입의 포인터나 참조, 값을 자식 클래스 객체로 초기화 한다음에 함수를 호출하면 부모 클래스 버전의 함수를 호출한다는게 문제이다

이는 virtual function으로 해결이 가능하다

virtual function

가상함수는 호출될 때 참조되거나 포인팅되는 객체의 실제 타입 버전의 함수로 결정되는 특별한 멤버 함수이다

가상함수를 만들기 위해서는 함수 앞에 virtual 키워드를 붙이면 된다

    class Base
    {
    protected:
        int m_value{};

    public:
        Base(int value)
            : m_value{ value }
        {
        }

		//가상함수
        virtual std::string_view getName() const { return "Base"; }
        int getValue() const { return m_value; }
    };

    class Derived : public Base
    {
    public:
        Derived(int value)
            : Base{ value }
        {
        }

		//가상함수 override
        virtual std::string_view getName() const override { return "Derived"; }
        int getValueDoubled() const { return m_value * 2; }
    };

    int main()
    {
        Derived der{ 10 };
        Base& base{ der };

        std::cout << base.getName();

        return 0;
    }

이제 결과는 실제 타입인 Derived클래스의 getName()이 호출된다

처음에 base.getName()을 하면 우선 Base::getName()을 찾고 이 함수가 virtual함수이기 때문에 파생 클래스에서 override된 함수를 찾아 호출한다

Base&타입인 base는 실질적으로 Derived클래스 타입 객체로 초기화 되었기 때문에 가상함수가 호출될 때 실제로 참조하는 타입인 Derived클래스의 getName()이 호출되는 것이다

이러한 가상 함수 결정은 가상 함수가 클래스 타입 객체 포인터나 참조를 통해 호출될 때만 동작한다, 포인터나 참조를 통한 호출이어야 컴파일러가 실제로 참조하는 객체의 타입을 구별할 수 있기 때문이다

일반 클래스 타입 객체에 대해서 가상함수를 호출한다면 가상 함수 결정이 되지 않고 변수 타입에 맞는 함수가 호출된다

    Derived der{ 10 };
    Base base{ der }; //Slicing발생, Derived에서 Base부분만 가지고 임시객체 생성후 복사

    std::cout << base.getName(); //Base::getName()이 호출됨

위에서 문제가 되었던 배열 for loop도 마찬가지로 해결되었다

    std::array<Base, 3> Bases{ 1, 2, 3 };
    std::array<Derived, 3> Deriveds{ 4, 5, 6 };

    for (const Base& base : Deriveds)
    {
        std::cout << base.getName() << '\n'; //전부 Derived::getName()호출
    }

이렇게 가상함수를 이용하려면 반드시 해당 함수와 동일한 시그니처의 함수로 자식 클래스에서 override 해야 한다, 또한 특정 함수가 virtual로 지정되면 파생 클래스에서 동일한 시그니처를 가진 함수(반환형까지)는 자동으로 virtual로 간주된다 (물론 명시하는게 더 좋음), 이때 역은 성립하지 않는다

Polymorphism

다형성이란 하나의 개체가 여러 형태를 가질 수 있는걸 의미한다

함수 오버로딩이나 템플릿 활용은 컴파일러에 의해 결정되는 컴파일 타임 다형성이고 가상 함수 결정은 런타임 다형성이다

가상 함수 사용 시 주의할 점

생성자나 소멸자에서는 가상함수를 호출해서는 안된다, 왜냐하면 자식 클래스가 생성될 때 부모 클래스의 생성자가 먼저 호출되고 자식 클래스의 생성자가 호출된다

따라서 부모 클래스 생성자에서 가상 함수를 호출한다면 자식 클래스 부분이 생성되기도 전에 호출되기 때문에 무조건 부모 클래스 버전의 함수만 호출될 것이다

    class Base
    {
    protected:
        int m_value{};

    public:
        Base(int value)
            : m_value{ value }
        {
            std::cout << getName(); //안좋은 예 : 생성자에서 가상함수 호출
        }

        //가상함수
        virtual std::string_view getName() const { return "Base"; }
        int getValue() const { return m_value; }
    };

    class Derived : public Base
    {
    public:
        Derived(int value)
            : Base{ value }
        {
        }

        //가상함수 override
        virtual std::string_view getName() const override { return "Derived"; }
        int getValueDoubled() const { return m_value * 2; }
    };

    int main()
    {
        Derived d1{ 10 }; //Base()가 호출되고 Derived()가 호출되기 때문에 Base()에서 가상함수 호출 시점에 Derived가 생성되지 않아 무조건 Base::getName()이 호출된다

        return 0;
    }

소멸자도 마찬가지이다, ~Derived()가 먼저 호출되고 ~Base()가 호출되기 때문에 ~Base()에서 가상함수를 호출하게 된다면 이미 Derived는 소멸되고 난 뒤라 무조건 Base클래스의 가상함수가 호출되게 된다

이러한 가상 함수는 일반 함수 호출보다 오버헤드가 많이 발생한다 (가상 함수 결정 때문)

또한 컴파일러는 가상 함수를 가진 클래스 타입 객체마다 추가적인 포인터 하나씩을 할당해야 하기 때문에 오버헤드가 더 발생할 수 있다

따라서 자식 클래스에서 override하는 함수가 아니라면 virtual function으로 사용하는건 지양한다

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글