본문 바로가기
프로그래밍/C & C++

[C++] 클래스(Class) 상속 (3) - 다양한 이슈들

by 별준 2022. 2. 16.

References

Contents

  • 상속과 관련된 여러가지 이슈들
  • RTTI (Run-Time Type Information)
  • typeid
  • Virtual Base Classes

[C++] 클래스(Class) 기본편

[C++] 클래스(Class) 심화편 (1)

[C++] 클래스(Class) 심화편 (2)

[C++] 클래스(Class) 심화편 (3)

[C++] 클래스(Class) 상속 (1) - 확장, 재사용

[C++] 클래스(Class) 상속 (2) - 다형성, 다중 상속

 

이전 포스팅에 이어 계속해서 클래스의 상속, 상속에 관련된 여러가지 이슈들에 대해 알아보도록 하겠습니다!

 


6. 상속과 관련된 여러가지 이슈들

클래스를 확장할 때 여러 가지 이슈들을 고려해야 합니다. 클래스의 속성 중에서 변경 가능한 것은 무엇인지, non-public 상속이 무엇인지, 가상 베이스 클래스(virtual base class)가 뭔지 등을 확실하게 파악해야 합니다. 이번에는 상속으로 인해서 발생하는 여러가지 의문점들을 하나씩 살펴보도록 하겠습니다.

 

6.1 오버라이드한 메소드 리턴 타입 변경

대부분 메소드의 구현을 변경하기 위해서 오버라이드합니다. 하지만 간혹 리턴 타입과 같은 원래 메소드의 속성도 함께 변경하고 싶을 때가 있습니다.

 

메소드를 오버라이드할 때는 베이스 클래스에 선언된 내용, 즉 베이스 클래스의 메소드 프로토 타입과 똑같이 작성하는 것이 원칙입니다. 구현 코드는 달라질 수 있어도 프로토 타입은 동일해야 합니다.

 

하지만, 이렇게 하지 않을 수도 있습니다. C++은 베이스 클래스의 리턴 타입이 다른 클래스에 대한 포인터나 레퍼런스 타입이라면 메소드 오버라이드할 때 리턴 타입을 그 클래스의 파생 클래스에 대한 포인터나 레퍼런스 타입으로 변경할 수 있습니다. 이러나 타입을 convariant return types(공변 리턴 타입)이라고 부릅니다. 이 기능은 베이스 클래스와 파생 클래스가 병렬 계층(parallel hierarchy)을 이룰 때, 다시 말해 두 계층이 따로 존재하지만 어느 한쪽에 관련이 있을 때 유용할 수 있습니다.

 

예를 들어, 체리 과수원 시뮬레이터가 있다고 가정해보겠습니다. 현실에 존재하는 다양한 대상을 표현하기 위해 클래스 계층을 두 개로 구성합니다. 첫 번째 계층은 Cherry 베이스 클래스와 BingCherry 파생 클래스로 구성합니다. 두 번째 계층은 CherryTree 베이스 클래스와 BinCherryTree 파생 클래스로 구성합니다. 두 클래스 체인을 그림으로 표현하면 다음과 같습니다.

이제 CherryTree 클래스에 체리 하나를 따는 pick() 이란 가상 메소드를 정의합니다.

Cherry* CherryTree::pick()
{
    return new Cherry();
}
여기서는 리턴 타입을 변경하는 예를 보여주기 위해서 리턴 타입을 스마트 포인터가 아닌 일반 포인터로 리턴했습니다. 물론 호출한 측에서는 리턴된 결과를 일반 포인터 상태로 남겨두지 말고 곧바로 스마트 포인터로 저장하는 것이 좋습니다.

파생 클래스인 BingCherryTree에서 이 메소드를 오버라이드해보겠습니다. 예를 들어, BingCherry를 딸 때 깨끗이 닦는 동작을 추가할 수 있습니다. BingCherry도 일종의 Cherry이기 때문에 메소드 프로토타입은 그대로 유지하고 메소드의 구현 코드만 다음과 같이 수정합니다. BingCherry 포인터는 Cherry 포인터로 자동으로 캐스팅됩니다. 참고로 polish()에서 예외가 발생하더라도 메모리 누수가 발생하지 않도록 unique_ptr로 구현했습니다.

Cherry* BingCherryTree::pick()
{
    auto theCherry { std::make_unique<BingCherry>() }:
    theCherry->polish();
    return theCherry.release();
}

이렇게 구현해도 문제없이 실행됩니다. 그런데 이 코드는 항상 BingCherry 객체를 리턴합니다. 그래서 다음과 같이 리턴 타입을 변경하면 이 사실을 사용자에게 알려줄 수 있습니다.

BingCherry* BingCherryTree::pick()
{
    auto theCherry { std::make_unique<BingCherry>() }:
    theCherry->polish();
    return theCherry.release();
}

이렇게 구현하면 다음과 같이 BingCherryTree::pick() 메소드를 다음과 같이 호출할 수 있습니다.

BingCherryTree theTree;
std::unique_ptr<Cherry> theCherry{ theTree.pick() };
theCherry->printType();

오버라이드하는 과정에서 원본 메소드의 리턴 타입을 변경해도 되는지 알아내기 위한 좋은 방법은 이렇게 바꿔도 기존 코드가 제대로 동작하는지 확인하는 것입니다. 이를 리스코프 치환 원칙(Liskov substitution principle, LSP)라고 부릅니다. 앞서 나온 예제 코드에서 리턴 타입을 변경해도 문제가 발생하지 않는 이유는 모든 코드에서 pick() 메소드가 항상 Cherry*를 리턴한다고 가정한 코드는 모두 문제없이 컴파일되고 실행되기 때문입니다. BingCherry도 일종의 Cherry이기 때문에 CherryTree 버전의 pick()이 리턴한 결과로 호출할 수 있는 메소드는 모두 BingCherryTree 버전의 pick()이 리턴한 결과로도 호출할 수 있습니다.

 

하지만 리턴 타입을 void*와 같이 전혀 관련 없는 타입으로는 변경할 수 없습니다. 예를 들어, 다음과 같이 작성하면 컴파일 에러가 발생합니다.

void* BingCherryTree::pick() // Error!
{
    /* ... */
}

이때 출력되는 컴파일 에러는 다음과 같습니다.

앞서 설명했듯이 여기서는 스마트 포인터가 아닌 일반 포인터를 사용했습니다. 따라서 리턴 타입을 std::unique_ptr로 지정하면 동작하지 않습니다. CherryTree::pick() 메소드가 다음과 같이 std::unique_ptr<Cherry>를 리턴한다고 작성했다고 가정해봅시다.

std::unique_ptr<Cherry> CherryTree::pick()
{
    return std::make_unique<Cherry>();
}

그러면 BingCherryTree::pick() 메소드의 리턴 타입을 std::unique_ptr<BingCherry>로 변경할 수 없게 됩니다. 따라서 다음과 같이 작성하면 컴파일 에러가 발생합니다.

class BingCherryTree : public CherryTree
{
public:
    virtual std::unique_ptr<BingCherry> pick() override;
};

그 이유는 std::unique_ptr이 클래스 템플릿이기 때문입니다. 클래스 템플릿은 나중에 한 번 다루겠지만, 이렇게 작성하면 unique_ptr에 대한 인스턴스로 unique_ptr<Cherry>와 unique_ptr<BingCherry>가 생성됩니다. 두 인스턴스는 타입이 전혀 다르고 서로 아무런 관련도 없습니다. 따라서 오버라이드할 때 이렇게 완전히 다른 타입을 리턴하도록 변경할 수는 없습니다.

 

6.2 메소드 매개변수 변경

파생 클래스에서 정의하는 코드에서 virtual 메소드를 선언할 때 이름은 부모 클래스에 있는 것과 똑같이 쓰고 매개변수만 다르게 지정하면 부모 클래스의 메소드가 오버라이드되는 것이 아니라 새로운 메소드가 정의됩니다. 위에서 살펴봤던 Base와 Derived 클래스 예제를 다시 가져와서 Derived 클래스에서 someMethod()를 오버라이드하면서 인수를 다르게 지정해보겠습니다.

class Base
{
public:
    virtual void someMethod();
};

class Derived : public Base
{
public:
    using Base::someMethod;         // Explicitly inherits the Base version
    virtual void someMehtod(int i); // Adds a neew overload of someMethod()
    virtual void someOtherMethod();
};

 

6.3 생성자 상속

위에서 using 키워드를 통해 베이스 클래스에 정의된 메소드를 파생 클래스에 명시적으로 지정하는 방법을 소개했습니다. 그런데 이 방법은 일반 클래스 메소드뿐만 아니라 생성자에도 적용할 수 있습니다. 이를 사용하면 베이스 클래스의 생성자도 상속할 수 있습니다. 다음과 같이 정의된 Base와 Derived 클래스를 살펴보겠습니다.

class Base
{
public:
    virtual ~Base() = default;
    Base() = default;
    Base(std::string_view str);
};

class Derived : public Base
{
public:
    Derived(int i);
};

위 코드에서 Base 객체는 Base의 디폴트 생성자나 string_view 매개변수를 받는 생성자로만 생성할 수 있습니다. 반면 Derived 객체는 여기서 선언한 int 타입 인수를 받는 생성자로만 만들 수 있습니다. Base 클래스에 정의된 string_view 인수를 받는 생성자로는 Derived 객체를 만들 수 없습니다.

예를 들면 다음과 같습니다.

Base base{ "Hello" };        // OK, calls string_view Base ctor
Derived dervied1{ 1 };       // OK, calls integer Derived ctor
Derived derived2{ "Hello" }; // Error, Derived does not inerit string_view ctor
Derived derived3;            // Error, Derived does not have a default ctor

만약 string_view 인수를 받는 Base 생성자로 Derived 객체를 만들고 싶다면 다음과 같이 Derived 클래스에서 Base 생성자를 명시적으로 상속해야 합니다.

class Derived : public Base
{
public:
    using Base::Base;
    Derived(int i);
};

using 키워드를 지정하면 부모 클래스의 디폴트 생성자를 제외한 모든 생성자를 상속합니다. 따라서 위와 같이 작성하면 Derived 객체를 다음과 같이 두 가지 방법으로 생성할 수 있습니다.

Derived derived{ 1 };		// OK, calls integer Derived ctor
Derived derived{ "Hello" }; // OK, calls inherited string_view Base ctor
Derived derived; 			// OK, calls ingerited default Base ctor

 

또한 파생 클래스는 자신만의 생성자를 추가하지 않고 모든 베이스 클래스 생성자를 상속받을 수 있는데, 예를 들면 다음과 같습니다.

class Base
{
public:
    virtual ~Base() = default;
    Base() = default;
    Base(std::string_view str);
    Base(float f);
};

class Derived : public Base
{
public:
    using Base::Base;
};

이렇게 정의하면 Derived 객체는 다음과 같은 방법으로 생성할 수 있습니다.

Derived derived1{ "Hello" }; // OK,
Derived derived2{ 1.23f };   // OK,
Derived derived3;            // OK,

 

6.3.1 Hiding of Inherited Constructor

파생 클래스는 베이스 클래스에서 상속된 생성자 중 하나와 동일한 매개변수 목록을 가진 생성자를 정의할 수 있습니다. 이 경우 파생 클래스의 생성자가 상속된 생성자보다 우선시됩니다. 아래 코드에서 파생 클래스는 using 키워드를 통해 베이스 클래스의 모든 생성자를 상속합니다. 그러나 Derived 클래스는 float의 단일 매개변수를 가진 자체 생성자를 정의하므로 float 타입의 단일 매개변수를 가진 베이스 클래스의 상속된 생성자는 숨겨집니다.

class Base
{
public:
    virtual ~Base() = default;
    Base() = default;
    Base(std::string_view str) {}
    Base(float f) {}
};

class Derived : public Base
{
public:
    using Base::Base;
    Derived(float f) {}
};

이렇게 정의하면 Derived 객체를 다음과 같이 생성할 수 있습니다.

Derived derived1{ "Hello" }; // OK, calls inherited string_view Base ctor
Derived derived2{ 1.23f };   // OK, calls float Derived ctor
Derived derived3;            // OK, calls inherited default Base ctor

 

하지만 using 선언으로 베이스 클래스의 생성자를 상속할 때 다음의 몇 가지 제약사항이 있습니다.

  • 베이스 클래스의 생성자를 상속할 때 모든 생성자를 상속합니다. 베이스 클래스 생성자 중의 일부만 상속할 수는 없습니다.
  • 생성자를 상속할 때, using 선언이 적용되는 접근 스펙과 관계없이 베이스 클래스에서 생성자가 가진 것과 동일한 접근 지정자로 상속됩니다.

 

6.3.2 Inherited Constructors and Multiple Inheritance(다중 상속)

생성자를 상속할 때 또 다른 제약은 다중 상속과 관련이 있습니다. 여러 베이스 클래스에서 매개변수 목록이 동일한 생성자는 상속할 수 없습니다. 이는 어느 부모에 있는 것을 호출할지 알 수 없기 때문입니다. 이런 모호함을 해결하려면 Derived 클래스에서 충돌하는 생성자를 명시적으로 정의해야 합니다. 예를 들어 다음 코드는 Derived 클래스에서 Base1과 Base2에 있는 생성자를 모두 상속하려고 using 키워드로 선언했는데, float 인수를 받는 생성자가 Base1과 Base2에 동시에 있기 때문에 컴파일 에러가 발생합니다.

class Base1
{
public:
    virtual ~Base1() = default;
    Base1() = default;
    Base1(float f) {}
};

class Base2
{
public:
    virtual ~Base2() = default;
    Base2() = default;
    Base2(std::string_view str);
    Base2(float f) {}
};

class Derived : public Base1, public Base2
{
public:
    using Base1::Base1;
    using Base2::Base2;
    Derived(char c);
};

int main()
{
    Derived d{ 1.2f }; // Error, ambiguity
}

Derived에서 첫 번째 using 선언문은 Base1의 모든 생성자를 상속합니다. 따라서 Derived에는 다음과 같은 생성자가 있습니다.

Derived(float f); // Inherited from Base1

Derived에서 두 번째 using 선언은 Base2에 있는 모든 생성자를 상속합니다. 그런데 여기서 컴파일 에러가 발생합니다. 이미 Derived(float f)란 생성자가 있기 때문입니다. 이럴 때는 다음과 같이 충돌이 발생한 생성자를 명시적으로 선언하면 됩니다.

class Derived : public Base1, public Base2
{
public:
    using Base1::Base1;
    using Base2::Base2;
    Derived(char c);
    Derived(float f);
};

이렇게 하면 Derived 클래스는 float 타입 매개변수 하나를 받는 생성자를 명시적으로 선언해서 모호함이 발생하지 않게 만듭니다. 원한다면 이렇게 선언한 float 타입 생성자의 생성자 이니셜라이저에 다음과 같이 Base1과 Base2를 호출하게 만들 수도 있습니다.

Derived::Derived(float f) : Base1(f), Base2(f) {}

 

6.3.3 데이터 멤버 초기화

상속된 생성자를 사용할 때, 모든 데이터 멤버가 적절하게 초기화되었는지 확실히 해야합니다. 예를 들어, Base와 Derived 클래스를 다음과 같이 새로 정의했을 때, 다음의 코드는 m_int 데이터 멤버를 모든 케이스에서 적절히 초기화하지 못하고 있으며 심각한 에러를 발생할 가능성이 높습니다.

class Base
{
public:
    virtual ~Base() = default;
    Base(std::string_view str) : m_str{ str } {}
private:
    std::string m_str;
};

class Derived : public Base
{
public:
    using Base::Base;
    Derived(int i) : Base{ "" }, m_int{ i } {}
private:
    int m_int;
};

Derived 객체는 다음과 같이 생성할 수 있습니다.

Derived s1{ 2 };

이 문장이 실행되면 Derived 클래스의 int 버전 생성자가 호출되면서 m_int를 초기화하고, 공백 문자열 인수를 전달하는 Base 생성자가 호출되면서 m_str을 초기화합니다.

 

이때 호출되는 Base 생성자를 Derived 클래스가 상속했기 때문에 Derived 객체를 다음과 같은 방법으로도 만들 수 있습니다.

Derived s2("Hello World");

그러면 Derived 클래스에서 상속한 Base 생성자가 호출됩니다. 그런데 이 Base 생성자는 Base 클래스의 m_str만 초기화하고 Derived의 m_int는 초기화하지 않습니다. 따라서 m_int가 초기화되지 않은 상태로 남는 경우가 발생합니다. 이렇게 되면 나중에 문제가 발생할 수 있습니다.

 

이 문제는 in-class member initialization으로 해결할 수 있습니다. 다음 코드는 이 방법으로 m_int를 0으로 초기화합니다. 이렇게 작성하더라도 여전히 Derived(int i) 생성자로 m_int를 i값으로 초기화할 수 있습니다.

class Derived : public Base
{
public:
    using Base::Base;
    Derived(int i) : Base{ "" }, m_int{ i } {}
private:
    int m_int = 0;
};

 

6.4 Special Cases in 메소드 오버로딩

메소드를 오버라이드할 때 특별히 주의를 기울여야하는 특수한 상황이 있습니다. 그중에서도 특히 자주 마주치는 경우는 몇 가지 알아보겠습니다.

 

6.4.1 베이스 클래스가 static인 경우

C++에서는 static 메소드를 오버라이드할 수 없습니다. 이 정도만 알아도 충분하지만, 다음의 사항도 알아두면 좋습니다.

 

먼저 메소드에 static과 virtual을 동시에 지정할 수 없습니다. static 메소드를 오버라이드하면 원래 의도와 다른 효과가 발생합니다. 파생 클래스에 있는 static 메소드의 이름이 베이스 클래스의 static 메소드와 같으면 서로 다른 메소드 두 개가 생성됩니다.

 

다음 코드를 보면 두 클래스는 모두 beStatic() 이라는 이름의 static 메소드를 가지고 있습니다. 하지만 두 메소드는 서로 관련이 없습니다.

class BaseStatic
{
public:
    static void beStatic() {
        std::cout << "BaseStatic being static." << std::endl;
    }
};

class DerivedStatic : public BaseStatic
{
public:
    static void beStatic() {
        std::cout << "DerivedStatic keepin' it static." << std::endl;
    }
};

static 메소드는 해당 클래스에 속하기 때문에 이름이 갖더라도 각자 클래스에 있는 메소드 코드가 호출됩니다.

BaseStatic::beStatic();
DerivedStatic::beStatic();

위 코드의 출력은 다음과 같습니다.

이렇게 클래스 이름을 명시적으로 지정해서 호출할 때는 문제가 발생할 일이 없습니다. 하지만 이 메소드를 객체를 통해 호출할 때는 헷갈리기 쉽습니다. C++은 static 메소드를 객체 이름으로 호출하는 것을 허용하지만 static이기 때문에 this 포인터도 없고 객체에 접근할 수도 없습니다. 그래서 객체 이름으로 호출하더라도 실질적으로 클래스 이름으로 호출하는 문장과 같습니다. 앞에서 정의한 클래스를 다음과 같이 호출하면 기대와 다른 결과가 나옵니다.

DerivedStatic myDerivedStatic;
BaseStatic& ref{ myDerivedStatic };
myDerivedStatic.beStatic();
ref.beStatic();

첫 번째 beStatic() 호출문은 DerivedStatic 타입으로 선언한 객체로 호출했기 때문에 당연히 DerivedStatic에 있는 beStatic()이 호출됩니다. 하지만 두 번째 호출문은 다릅니다. ref 변수의 타입은 BaseStatic 레퍼런스지만 이 변수가 실제로 가리키는 대상은 DerivedStatic 객체입니다. 이때는 BaseStatic에 있는 beStatic()이 호출됩니다. C++에서 static 메소드를 호출할 때는 실제로 속한 객체를 찾지 않고, 컴파일 시간에 지정된 타입만 보고 호출할 메소드를 결정합니다. 이 코드에서 컴파일 시간에 결정된 타입은 BaseStatic에 대한 레퍼런스이기 때문에 BaseStatic의 beStatic()이 호출되는 것입니다.

 

6.4.2 베이스 클래스 메소드가 오버로드된 경우

베이스 클래스에 다양한 파라미터들로 오버로드(overload)된 메소드가 여러 개 있을 때, 그 중 한 버전만 파생 클래스에서 오버라이드(override)하면 컴파일러는 베이스 클래스에 있는 다른 버전의 모든 메소드를 가려버립니다. 이렇게 하는 이유는 컴파일러 입장에서 볼 때 어느 한 버전만 오버라이드했다는 것은 원래 같은 이름을 가진 모든 메소드를 오버라이드하려다가 깜박 잊고 하나만 적었다고 판단하기 때문입니다. 그래서 이대로 놔두면 에러가 발생할 수 있으므로 나머지 메소드를 모두 가려주는 것입니다. 여러 버전 중에서 일부만 수정할 일은 거의 없기 때문에 생각해보면 나름 합리적인 방법입니다. 

 

예를 들어, 다음의 코드를 살펴보겠습니다. 여기서 Derived 클래스는 Base 클래스의 오버로드된 여러 메소드 중에서 하나만 오버라이드합니다.

class Base
{
public:
    virtual ~Base() = default;
    virtual void overload() { std::cout << "Base's overload()" << std::endl; }
    virtual void overload(int i) {
        std::cout << "Base's overload(int i)" << std::endl;
    }
};

class Derived : public Base
{
public:
    void overload() override {
        std::cout << "Derived's overload()" << std::endl;
    }
};

이렇게 정의한 상태에서 Derived 객체로 int 버전의 overload() 메소드를 호출하면 컴파일 에러가 발생합니다. 이 버전의 메소드를 명시적으로 오버라이드하지 않았기 때문입니다.

int main()
{
    Derived myDerived;
    myDerived.overload(2); // Error! No matching method of overload(int)
}

그러나 Derived 객체에서 이 버전의 메소드에 접근할 방법은 있는데, Derived 객체를 가리킬 변수를 Base 포인터나 레퍼런스로 만들면 됩니다.

int main()
{
    Derived myDerived;
    Base& ref{ myDerived };
    ref.overload(7);
}

이렇게 파생 클래스에서 오버라이드하지 않은 부모 클래스의 오버로드된 메소드를 숨겨주는 것은 사실 C++에서 편의상 제공하는 기능일 뿐입니다. 객체의 타입을 명시적으로 파생 클래스로 지정해버리면 이런 메소드를 가리긴 하지만 언제든지 베이스 클래스로 캐스팅하면 가려진 메소드에 얼마든지 접근할 수 있습니다.

 

실제로 수정(오버라이드)하고 싶은 버전은 하나뿐인데 그것만 오버라이드하면 부모 클래스에 있는 나머지 오버로드된 메소드가 몽땅 가려지는 문제 때문에 파생 클래스에서 다시 모든 버전을 오버로드하는 것은 너무 번거롭습니다. 이럴 때는 using 키워드를 사용하여 간편하게 처리할 수 있습니다.

다음 코드를 보면 Base에 있는 overload() 중에서 단 한 버전만 오버라이드하고 나머지는 Base에 있는 것을 명시적으로 오버로드합니다.

class Derived : public Base
{
public:
    using Base::overload;
    void overload() override {
        std::cout << "Derived's overload()" << std::endl;
    }
};

이렇게 using 키워드를 사용할 때는 한 가지 주의할 점이 있습니다. Derived를 이렇게 정의하고 나서 나중에 Base에 overload() 메소드의 또 다른 오버로딩 버전을 추가했는데, 공교롭게도 이 버전도 Derived에서 오버라이드해야 한다는 것이라고 합시다. 그런데 앞에 나온 코드처럼 using 구문이 나와 있으면 Base에 있는 오버로드된 버전 중에서 Derived 클래스에 명시적으로 오버라이드하지 않은 나머지 버전은 있는 그대로 받아서 쓰는 것으로 처리되어버립니다. 따라서 이렇게 오버라이드를 빼먹은 메소드는 에러 형태로 드러나지 않기 때문에 나중에 문제가 발생해도 찾기 힘들 수 있습니다.

 

6.4.3 private로 선언된 베이스 클래스 메소드

private 메소드도 얼마든지 오버라이드할 수 있습니다. 메소드에 대한 접근 지정자(access specifier)는 그 메소드를 호출할 수 있는 대상만 제한할 뿐입니다. 파생 클래스에서 부모 클래스의 private 메소드를 호출할 수 없다고 해서 오버라이드도 할 수 없는 것은 아닙니다. 실제로 private 메소드를 오버라이딩하는 사례를 C++ 코드에서 흔히 볼 수 있습니다. 이렇게 하면 파생 클래스만의 고유한 버전을 정의할 수 있을 뿐만 아니라 이 메소드를 베이스 클래스에서도 참조할 수 있습니다. 참고로 자바와 C#은 public과 protected 메소드는 오버라이드할 수 있고, private는 오버라이드할 수 없습니다.

 

예를 들어, 다음과 같이 정의한 클래스를 살펴보겠습니다. 이 클래스는 자동차 시뮬레이션의 일부분으로 현재 남은 연료양과 연비를 이용하여 자동차가 이동한 거리를 추정합니다. getMilesLeft() 메소드는 이른바 template method(템플릿 메소드) 입니다. 보통 템플릿 메소드는 virtual이 아닙니다. 이들은 일반적으로 기본 클래스에 일부 알고리즘 형태를 정의하며 정보를 쿼리하기 위해 가상 메소드를 호출합니다. 파생 클래스는 기본 클래스 자체의 알고리즘을 수정할 필요없이 이러한 가상 메소드를 재정의하여 알고리즘을 수정할 수 있습니다.

class MilesEstimator
{
public:
    virtual ~MilesEstimator() = default;
    int getMilesLeft() const { return getMilesPerGallon() * getGallonsLeft(); }
    virtual void setGallonsLeft(int gallons) { m_gallonsLeft = gallons; }
    virtual int getGallonsLeft() const { return m_gallonsLeft; }

private:
    int m_gallonsLeft{ 0 };
    virtual int getMilesPerGallon() const { return 20; }
};

getMilesLeft() 메소드는 다른 두 메소드를 호출한 결과를 이용하여 계산을 수행합니다. 아래 코드는 이렇게 정의한 MilesEstimator를 사용하여 자동차가 2갤런의 연료로 이동할 수 있는 거리를 추정합니다.

int main()
{
    MilesEstimator myMilesEstimator;
    myMilesEstimator.setGallonsLeft(2);
    std::cout << "Normal estimator can go " << myMilesEstimator.getMilesLeft()
        << " more miles." << std::endl;
}

실행 결과는 다음과 같습니다.

시뮬레이터를 조금 더 흥미롭게 구성하기 위해서 자동차의 종류를 늘려볼텐데, 예를 들면 연비가 더 좋은 차를 추가해보겠습니다. 현재 MilesEstimator는 연비가 항상 마일 당 20갤런을 소비한다고 가정했지만, 이 값은 별도 메소드로 받아오도록 정의했기 때문에 파생 클래스에서 이 메소드를 오버라이드하면 얼마든지 변경할 수 있습니다.

다음과 같이 파생 클래스를 정의해서 기존 동작을 변경할 수 있습니다.

class EfficientCarMilesEstimator : public MilesEstimator
{
private:
    virtual int getMilesPerGallon() const override { return 35; }
};

기존 클래스의 public 메소드를 건드리지 않고 이렇게 새로 정의한 클래스에서 private 메소드 하나만 오버라이드해도 동작을 이전과 완전히 다르게 변경할 수 있습니다. 이렇게 하면 베이스 클래스에 있는 getMilesLeft() 메소드는 오버라이드한 버전인 private getMilesPerGallon() 메소드를 호출합니다. 새로 정의한 클래스를 사용한 코드는 다음과 같습니다.

int main()
{
    EfficientCarMilesEstimator myMilesEstimator;
    myMilesEstimator.setGallonsLeft(2);
    std::cout << "Efficient estimator can go " << myMilesEstimator.getMilesLeft()
        << " more miles." << std::endl;
}

그러면 다음과 같이 오버라이드한 결과가 출력됩니다.

기존 클래스의 전반적인 골격은 그대로 유지한 채 특정한 기능만 변경할 때는 private와 protected 메소드를 오버라이드하는 것이 좋습니다.

 

6.4.4 디폴트 인수가 있는 베이스 클래스

파생 클래스와 베이스 클래스에서 지정한 디폴트 인수(default arguments)가 다를 수 있습니다. 그런데 실행할 때 적용할 인수는 실제 내부에 있는 객체가 아닌 변수에 선언된 타입에 따라 결정됩니다. 다음과 같이 파생 클래스에서 메소드를 오버라이드할 때 디폴트 인수를 다르게 지정한 경우를 살펴보겠습니다.

class Base
{
public:
    virtual ~Base() = default;
    virtual void go(int i = 2) {
        std::cout << "Base's go with i= " << i << std::endl;
    }
};

class Derived : public Base
{
public:
    void go(int i = 7) override {
        std::cout << "Derived's go with i= " << i << std::endl;
    }
};

Derived 객체로 go() 를 호출하면 Derived에 정의된 버전의 go() 메소드가 실행되고 디폴트 인수는 7이 적용됩니다. 또한 Base 객체를 통해 go()를 호출하면 Base 버전의 go()가 호출되고 인수는 2가 적용됩니다. 그런데 특이하게도 실제로는 Derived 객체를 가리키지만 Base 타입의 포인터나 레퍼런스로 go()를 호출하면 Derived 버전의 go() 코드가 실행되지만 디폴트 인수는 Base에 지정된 2가 적용됩니다.

예를 들면 다음과 같습니다.

int main()
{
    Base myBase;
    Derived myDerived;
    Base& myBaseReferenceToDerived{ myDerived };
    myBase.go();
    myDerived.go();
    myBaseReferenceToDerived.go();
}

위 코드를 실행하면 다음의 결과를 출력합니다.

이렇게 실행되는 이유는 C++이 디폴트 인수를 실행 시간의 타입이 아닌 컴파일 시간에서의 타입을 보고 결정하기 때문입니다. C++에서 디폴트 인수는 상속되지 않습니다. 이 예제처럼 Derived 클래스가 부모와 다르게 디폴트 인수를 지정하면 인수를 갖는 버전의 새로운 go() 메소드가 오버로드됩니다.

 

6.4.5 베이스 클래스 메소드와 접근 범위를 다르게 지정하는 경우

메소드를 오버라이드할 때 접근 권한을 넓히거나 좁힐 수 있습니다. 흔히 있는 일은 아니지만 이렇게 해야할 때가 있긴 합니다.

 

메소드나 데이터 멤버에 대한 접근 권한을 좀 더 제한하는 방법은 두 가지가 있습니다.

하나는 베이스 클래스 전체에 대한 접근 지정자를 변경하는 것입니다. 이에 대해서는 뒤에서 조금 더 자세히 설명하겠습니다. 또 다른 방법은 아래 코드처럼 파생 클래스에서 접근자를 다르게 지정하는 것입니다.

class Gregarious
{
public:
    virtual void talk() {
        std::cout << "Gregarious says hi!" << std::endl;
    }
};

class Shy : public Gregarious
{
protected:
    void talk() override {
        std::cout << "Shy reluctantly says hello.!" << std::endl;
    }
};

Shy 클래스에서 정의한 protected 버전의 talk() 메소드는 Gregarious::talk() 메소드를 아무 문제없이 오버라이드합니다. 그래서 외부에서 Shy 객체의 talk()를 호출하면 컴파일 에러가 발생합니다.

Shy myShy;
myShy.talk(); // Error! Attemp to access protected method.

그러나 이 메소드의 접근 범위는 protected로 완전히 변경된 것은 아닙니다. Gregrious 타입의 레퍼런스나 포인터를 이용하면 얼마든지 이 메소드에 접근할 수 있습니다.

Shy myShy;
Gregarious& ref{ myShy };
ref.talk();

위 코드를 실행하면 다음과 같이 출력됩니다.

이처럼 파생 클래스에서 메소드를 오버라이드하는데 문제는 없지만 베이스 클래스에서 public으로 선언한 메소드를 protected로 접근 범위를 제한하는 것은 완벽하게 적용되지는 않습니다.

앞서 본 예제는 파생 클래스에서 메세지를 다르게 출력하도록 메소드를 다시 정의했습니다. 구현 코드를 변경하지 않고 메소드의 접근 권한만 변경하고 싶다면 파생 클래스 정의에서 간단히 using문으로 접근 범위만 원하는 종류로 바꾸면 됩니다.

 

파생 클래스에서 접근 범위를 좁히는 것보다 넓히는 것이 더 쉽고, 더 자연스럽습니다. 가장 간단한 방법은 파생 클래스에 베이스 클래스의 protected 메소드를 호출하는 public 메소드를 정의하는 것입니다.

예를 들면 다음과 같습니다.

class Secret
{
protected:
    virtual void dontTell() { std::cout << "I'll never tell." << std::endl; }
};

class Blabber : public Secret
{
public:
    virtual void tell() { dontTell(); }
};

Blabber 객체의 public tell() 메소드를 호출하는 클라이언트는 실제로 Secret 클래스의 protected 메소드에 접근하는 셈입니다. 물론 그렇다고 해서 dontTell() 메소드의 접근 범위가 변경되는 것은 아닙니다. 다만 이 메소드에 접근하는 경로만 제공하는 것입니다.

 

또 다른 방법은 Blabber에서 dontTell() 메소드를 오버라이드하면서 접근 범위를 public으로 변경하는 것입니다. 접근 범위를 축소하는 것보다 훨씬 현실적입니다. 베이스 클래스에 대한 레퍼런스나 포인터를 사용할 때 어떻게 처리될 지 명확히 드러나기 때문입니다. 예를 들어, Blabber 크래스에서 다음과 같이 dontTell() 메소드를 public으로 변경해봅시다.

class Blabber : public Secret
{
public:
    virtual void dontTell() override { std::cout << "I'll tell all!" << std::endl; }
};

그리고 나서 Blabber 객체로 dontTell() 메소드를 호출해봅시다.

Blabber myBlabber;
myBlabber.dontTell(); // prints "I'll tell all!"

오버라이드한 메소드의 구현 코드는 그대로 두고 접근 범위만 변경하고 싶다면 다음과 같이 using문을 사용합니다.

class Blabber : public Secret
{
public:
    using Secret::dontTell;
};

이렇게 하면 Blabber 객체로 dontTell() 메소드를 호출할 때 "I'll never tell."이 출력됩니다.

myBlabber.dontTell(); // prints "I'll never tell."

방금 소개한 두 경우 모두 베이스 클래스 메소드의 접근 범위는 여전히 protected입니다. 그러므로 Secret 타입의 포인터나 레퍼런스로 Secret 버전의 dontTell() 메소드를 외부에서 호출하면 컴파일 에러가 발생합니다.

Blabber myBlabber;
Secret& ref{ myBlabber };
Secret* ptr{ &myBlabber };
ref.dontTell(); // Error! Attempt to access protected method.
ptr->dontTell(); // Error! Attempt to access protected method.
위 예시 중에 현실적으로 유용한 것은 protected 메소드의 접근 범위를 늘리는 경우뿐입니다.

 

6.5 파생 클래스의 복사 생성자와 대입 연산자

클래스 기본편에서 설명했듯이 메모리를 동적으로 할당하는 클래스에는 복사 생성자와 operator=를 제공하는 것이 좋습니다. 하지만 파생 클래스에서 복사 생성자와 대입 연산자를 정의할 때는 몇 가지 주의할 점이 있습니다.

 

파생 클래스에 포인터와 같은 특수한 데이터(주로 포인터)가 있어서 디폴트가 아닌 복사 생성자나 대입 연산자를 정의해야 할 경우가 아니라면 베이스 클래스에 복사 생성자나 대입 연산자가 있더라도 파생 클래스에서 다시 정의할 필요가 없습니다. 파생 클래스에서 복사 생성자나 대입 연산자를 정의하는 코드를 생략하면 파생 클래스의 데이터 멤버에 대한 디폴트 복사 생성자나 대입 연산자가 생성되고, 베이스 클래스의 데이터 멤버에 대해서는 베이스 클래스의 복사 생성자나 대입 연산자가 적용됩니다.

 

반면 파생 클래스에서 복사 생성자를 명시적으로 정의한다면, 다음의 코드처럼 반드시 부모 클래스의 복사 생성자를 호출해야 합니다. 그렇지 않으면 객체에서 부모 부분에 대해 디폴트 생성자(복사 생성자가 아님!)가 사용됩니다.

class Base
{
public:
    virtual ~Base() = default;
    Base() = default;
    Base(const Base& src) {}
};

class Derived : public Base
{
    Derived() = default;
    Derived(const Derived& src) : Base{ src } {}
};

마찬가지로 파생 클래스에서 operator=를 오버라이드하면 객체의 일부분만 대입 연산을 적용할 때처럼 극히 드문 경우를 제외하면 부모 버전의 대입 연산자도 함께 호출해야 합니다.

다음의 코드는 파생 클래스에서 부모 클래스의 대입 연산자를 호출하는 방법을 보여줍니다.

class Derived : public Base
{
    Derived() = default;
    Derived(const Derived& src) : Base{ src } {}
    Derived& operator=(const Derived& rhs);
};

Derived& Derived::operator=(const Derived& rhs)
{
    if (this == &rhs)
        return *this;
    Base::operator=(rhs); // Calls parent's operator=
    // Do necessary assignments for derived class
    return *this;
}

 

6.6 Run-Time Type Facilities

C++은 다른 객체지향 언어보다 컴파일 시간에 결정하는 것이 많습니다. 앞서 설명했듯이 오버라이드는 객체 내부에 있는 클래스 정보가 아닌 메소드 선언과 구현의 연결 관계를 보고 작동합니다.

 

물론 C++도 실행 시간에 객체를 들여다보는 기능을 제공합니다. 이러한 기능을 묶어 RTTI(Run-Time Type Information, 실행 시간 타입 정보)라고 부릅니다. RTTI는 객체가 속한 클래스 정보를 다루는 데 필요한 기능을 다양하게 제공합니다. 대표적인 예로 dynamic_cast()가 있습니다. 이는 객체지향 사이에서 타입을 안전하게 변환해줍니다. vtable이 없는(virtual 메소드가 없는) 클래스에 대해 dynamic_cast()를 사용하면 컴파일 에러가 발생합니다.

 

RTTI에서 제공하는 또 다른 기능으로는 typeid 연산자가 있습니다. 이 연산자를 이용하면 실행 시간에 객체의 타입을 조회할 수 있습니다. 대부분의 경우에는 typeid를 사용할 일은 없는데, 객체의 타입에 따라 다르게 실행되는 코드는 virtual 메소드로 구현하는 것이 바람직하기 때문입니다.

 

다음의 코드는 typeid를 이용하여 객체의 타입에 따라 메세지를 다르게 출력하는 예를 보여줍니다.

#include <typeinfo>;

class animal
{
public:
    virtual ~animal() = default;
};
class dog : public animal {};
class bird : public animal {};

void speak(const animal& animal)
{
    if (typeid(animal) == typeid(dog)) {
        std::cout << "woof!" << std::endl;
    }
    else if (typeid(animal) == typeid(bird)) {
        std::cout << "chirp!" << std::endl;
    }
}

이런 코드를 보면, virtual 메소드를 이용하도록 수정해야 한다고 생각들 것입니다. 이 예제에서는 Animal 클래스에 speak()란 virtual 메소드를 추가하는 것이 좋습니다. 그래서 Dog에서는 "Woof!"를, Bird에서는 "Chirp!"를 출력하도록 speak()를 오버라이드 합니다. 이와 같이 객체에 대한 기능은 최대한 객체 안에 두는 것이 더 객체지향적입니다.

typeid 연산자는 클래스에 virtual 메소드가 최소한 하나 이상 있을 때, 다시 말해 클래스에 vtable이 있을 때만 올바르게 동작합니다.

 

typeid 연산자는 주로 로깅 및 디버깅 용도로 활용합니다.

다음 코드는 typeid로 로깅을 구현한 예를 보여줍니다. logObject() 함수는 Loggable 객체를 매개변수로 받습니다. 로깅을 지원할 객체는 모두 Loggable을 상속해서 getLogMessage() 메소드를 제공하도록 디자인했습니다.

class Loggable
{
public:
    virtual ~Loggable() = default;
    virtual std::string getLogMessage() const = 0;
};

class Foo : public Loggable
{
public:
    std::string getLogMessage() const override
    {
        return "Hello logger.";
    }
};

void logObject(const Loggable& loggableObject)
{
    std::cout << typeid(loggableObject).name() << ": " <<
        loggableObject.getLogMessage() << std::endl;
}

logObject() 함수는 객체의 클래스 이름부터 출력하고 그 뒤에 로그 메세지를 작성합니다. 이렇게 하면 나중에 로그 파일을 읽을 때 각 메세지가 속한 객체를 알기 쉽습니다. 

예를 들면, 다음의 코드를 Visual Studio 2019로 컴파일 후 실행하면 다음의 출력을 확인할 수 있습니다.

int main()
{
    Foo myFoo;
    logObject(myFoo);
}

여기서 보듯이 typeid 연산자는 'class Foo'란 이름을 리턴합니다. 하지만 구체적인 형태는 컴파일러마다 다르며, GCC로 컴파일하면 다음과 같이 출력됩니다.

로깅이나 디버깅 용도가 아니라면 typeid보다는 virtual 메소드로 구현하는 것이 좋습니다.

 

6.7 Non-public 클래스 상속

지금까지는 부모 클래스를 상속할 때 항상 public 키워드를 붙였습니다. 그렇다면 부모 클래스를 private나 protected로 지정할 수는 없을까요? 가능하긴 하지만 public만큼 흔하지는 않습니다. 부모 클래스에 접근 지정자를 붙이지 않으면 상속할 때 class에 대해서는 private가, struct에 대해서는 public이 자동으로 적용됩니다.

 

부모 클래스를 protected로 지정하면 베이스 클래스에 있던 public 메소드와 데이터 멤버가 파생 클래스에서 모두 protected로 취급됩니다. 마찬가지로 private로 지정하면 베이스 클래스의 public 및 protected 메소드와 데이터 멤버가 파생 클래스에서 모두 private로 취급됩니다.

 

이렇게 부모에 대한 접근 범위를 일괄적으로 축소하는 데는 다양한 이유가 있겠지만 대부분 상속 관계를 잘못 디자인 했기 때문입니다. 간혹 이 기능을 다중 상속과 엮어서 클래스의 컴포넌트를 구현하는데 남용하는 경우도 있습니다. 예를 들면, Airplane 클래스에 엔진에 대한 데이터 멤버와 동체에 대한 데이터 멤버를 별도로 정의하지 않고, 엔진 클래스와 동체 클래스를 protected로 상속하는 방식으로 구현합니다. 이렇게 하면 모두 protected가 적용되기 때문에 클라이언트 코드에서 Airplane만 보면 엔진이나 동체가 없는 것 같지만 내부적으로는 두 기능을 모두 사용할 수 있습니다.

 

6.8 Virtual Base Classes

이전 포스팅에서 다중 상속을 설명하면서 동일한 클래스를 상속하는 부모 클래스를 여러 개 상속하면 모호함이 발생한다고 했습니다.

이럴 때는 부모 클래스에 자체 기능을 정의하지 않으면 이러한 모호함 문제를 해결할 수 있다고 언급했습니다.

그런데 C++은 이렇게 중복되는 부모 클래스도 자체 기능을 가질 수 있도록 가상 베이스 클래스라는 기능을 제공합니다. 중복되는 부모가 가상 베이스 클래스라면 모호한 상황이 발생할 일이 없습니다. 아래에서 볼 코드는 Animal 베이스 클래스에 sleep() 메소드를 추가하고, Dog와 Bird 클래스에서 Animal을 가상 베이스 클래스로 상속하도록 합니다. 이 과정에서 만약 Animal 클래스 앞에 virtual 키워드를 지정하지 않으면 DogBird 객체로 sleep()을 호출할 때 모호함이 발생해서 컴파일 에러가 발생합니다. DogBird 입장에서 볼 때 Animal의 하위 타입이 두 개(Dog와 Bird)나 있기 때문입니다. 하지만 Animal을 가상으로 상속하면 Animal의 하위 타입이 하나만 생성되기 때문에 sleep()을 호출할 때 모호한 상황이 발생하지 않습니다.

class Animal
{
public:
    virtual void eat() = 0;
    virtual void sleep() {
        std::cout << "zzzzz...." << std::endl;
    }
};

class Dog : public virtual Animal
{
public:
    virtual void bark() { std::cout << "Woof!" << std::endl; }
    void eat() override { std::cout << "The dog ate." << std::endl; }
};

class Bird : public virtual Animal
{
public:
    virtual void chirp() { std::cout << "Chirp!" << std::endl; }
    void eat() override { std::cout << "The bird ate." << std::endl; }
};
class DogBird : public Dog, public Bird
{
public:
    void eat() override { Dog::eat(); }
};

int main()
{
    DogBird myConfusedAnimal;
    myConfusedAnimal.sleep(); // Not ambiguous because of virtual base class.
}

 

댓글