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

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

by 별준 2022. 2. 14.

References

  • Professional C++

Contents

  • 상속(inheritance)
  • override 키워드
  • virtual 키워드
  • 재사용(reuse)를 위한 상속

이전 포스팅들을 통해 클래스에 대해 자세히 살펴봤습니다.

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

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

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

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

 

하지만, 클래스에 상속(inheritance)이 없다면 구조체에 동작만 추가한 것에 불과합니다. 클래스 자체만으로 절차형 언어를 뛰어넘는 굉장한 향상이지만, 상속은 완전히 새로운 차원의 것입니다. 상속을 통해 기존에 정의된 클래스를 바탕으로 새로운 클래스를 정의할 수 있습니다. 이 방법으로 클래스를 재사용하거나 확장 가능한 컴포넌트로 만들 수 있습니다. 이번 포스팅부터는 상속의 강력한 기능을 최대한 활용하기 위한 여러 가지 방법들에 대해서 알아볼 예정입니다. 또한 상속에 관련된 구체적인 문법뿐만 아니라 상속을 최대한 활용하기 위한 테크닉들도 알아보겠습니다.

 

이번에도 저번 포스팅부터 사용한 스프레드시트 예제를 활용합니다. 

 


1. 상속을 이용한 클래스 구현

is-a 관계처럼 현실에 존재하는 대상은 대체로 계층 구조로 존재하는 경향이 있습니다. 프로그래밍에서도 클래스를 수정하거나 다른 클래스를 바탕으로 새로운 클래스를 정의할 때 이러한 패턴을 볼 수 있습니다. 코드에서 이런 관계를 다루는 방법 중의 하나는 기존 클래스를 그대로 복사한 뒤 그 위에서 작업을 진행하는 것입니다. 관련된 부분을 변경하거나 코드를 수정하면 원본과 약간 다른 새로운 클래스를 만들 수 있습니다. 하지만 이렇게 하면 OOP 프로그래머 입장에서 작업이 단조롭거나 귀찮은 일들이 발생할 수 있습니다. 그 이유는 다음과 같습니다.

  • 원본과 새 클래스의 코드는 완전히 별개이기 때문에 원본 클래스에서 버그를 수정한 코드가 새로운 클래스에 반영되지 않습니다.
  • 컴파일러는 두 클래스의 관계를 모릅니다. 따라서 한쪽이 동일한 클래스를 변형한 것으로 취급하지 않기 때문에 다향성(polymorphic)를 구현할 수 없습니다.
  • 이 접근 방법은 진정한 is-a 관계가 아닙니다. 새 클래스의 코드 중의 상당 부분이 원본과 같아서 서로 비슷하긴 하지만 그렇다고 타입이 같은 것은 아닙니다.
  • 간혹 원본 클래스를 소스 코드를 구할 수 없거나 컴파일된 바이너리 버전만 있어서 소스 코드를 직접 복사할 수 없을 수도 있습니다.

C++에서는 당연히 진정한 is-a 관계를 정의하는 기능을 기본으로 제공합니다. 그럼 C++에서 제공하는 is-a 관계에 대해 자세히 알아보겠습니다.

 

 

1.1 클래스 확장하기

C++에서 클래스를 정의할 때 컴파일러에 기존 클래스를 상속(inherit), 파생(derive), 확장(extend)한다고 선언할 수 있습니다. 이렇게 하면 새로 만들 클래스에 기존 클래스의 데이터 멤버와 메소드를 자동으로 가져올 수 있습니다. 이때 원본 클래스를 부모 클래스(base class 또는 super class)라고 부릅니다. 그리고 기존 클래스를 확장한 자식 클래스 child class(파생 클래스, derived class 또는 subclass)는 부모 클래스와 다른 부분만 구현하면 됩니다.

 

C++에서 클래스를 확장하기 위해서는 클래스를 정의할 때 다른 클래스를 확장한다고 지정해야 합니다. 다음과 같이 정의한 Base와 Derived 클래스를 이용하여 상속을 표현하는 문법에 대해 알아보겠습니다.

class Base
{
public:
    void someMethod() {}
protected:
    int m_protectedInt{ 0 };
private:
    int m_privateInt{ 0 };
};

이제 Base 클래스를 상속하는 Derived 클래스를 정의하겠습니다.

class Derived : public Base
{
public:
    void someOtherMethod() {}
};

 

Derived 클래스는 Base 클래스가 가진 특성을 그대로 물려받은 완전한 형태의 클래스입니다. 여기서 Base를 public으로 지정한 것은 일단 넘어가고 나중에 살펴보겠습니다. 아래 그림은 Base와 Derived의 관계를 보여줍니다.

Derived 타입의 객체도 다른 객체처럼 얼마든지 선언할 수 있습니다. 심지어 다음 그림처럼 Derived 클래스를 상속하는 클래스를 정의할 수도 있습니다.

Derived만 Base를 상속하는 법은 없습니다. 아래 그림처럼 다른 클래스도 얼마든지 Base를 상속하여 Derived와 형제 관계를 이룰 수 있습니다.

 

1.1.1 클라이언트 입장에서 본 상속

클라이언트나 다른 코드에서, Derived는 Base를 상속했기 때문에 Derived 타입의 객체는 Base 타입의 객체이기도 합니다. 따라서 Base에 있는 public 메소드나 데이터 멤버뿐만 아니라 Derived의 public 메소드와 데이터 멤버도 사용할 수 있습니다.

 

파생 클래스를 사용하는 코드는 메소드를 호출하기 위해서 그 메소드가 상속 체인에서 어느 클래스에 정의되어 있는지 알 필요가 없습니다. 예를 들어, 다음의 코드는 Derived 객체의 두 메소드를 호출하는데 그 중 하나는 Base 클래스에서 정의된 것입니다.

Derived myDerived;
myDerived.someMethod();
myDerived.someOtherMethod();

단, 상속은 반드시 한 방향으로만 진행된다는 점에 주의해야합니다. Derived 클래스 입장에서는 Base 클래스와의 관계가 상당히 명확하지만, Base 클래스를 정의하는 시점에는 Derived 클래스의 존재를 알 수 없습니다. 따라서 Base 타입은 Derived가 아니기 때문에 Base 타입의 객체는 Derived 객체의 메소드나 데이터 멤버를 사용할 수 없습니다.

 

다음 코드는 컴파일 에러가 발생하는데, Base 클래스에는 someOtherMethod()라는 public 메소드가 없기 때문입니다.

Base myBase;
myBase.someOtherMethod(); // Error!
다른 코드의 관점에서 객체는 그 객체를 직접 정의한 클래스뿐만 아니라 그 클래스의 모든 베이스 클래스의 타입으로 취급합니다.

 

어떤 객체를 포인터나 레퍼런스로 가리킬 때 그 객체를 선언한 클래스뿐만 아니라 그 클래스의 파생 클래스 객체도 가리킬 수 있습니다. 이에 대해서는 잠시 뒤에 자세히 설명하겠습니다. 일단 Base에 대한 포인터로 Derived 객체를 가리킬 수 있다는 정도만 알고 있으면 됩니다. 레퍼런스도 마찬가지 입니다. 클라이언트는 Base에 존재하는 메소드나 데이터 멤버에만 접근할 수 있지만, 상속을 통해 Base에서 동작하는 코드는 Derived에서도 동작할 수 있습니다.

 

예를 들어, 다음의 코드는 타입이 맞지 않는 것처럼 보이지만 정상적으로 컴파일되고 동작합니다.

Base* base{ new Derived{} }; // Create Derived, store in Base pointer.

그러나 Base 포인터를 통해서 Derived 클래스의 메소드를 호출할 수는 없습니다. 따라서 다음 코드는 동작하지 않습니다.

base->someOtherMethod();

객체의 실제 타입이 Derived이고 실제로 someOtherMethod()가 정의되어 있지만 컴파일러는 여전히 이 객체는 someOtherMethod()가 없는 Base로 알고 있기 때문입니다.

 

1.1.2 파생 클래스 입장에서 본 상속

파생 클래스(자식 클래스)를 작성하는 방법은 일반 클래스와 기본적으로 같습니다. 파생 클래스의 메소드와 데이터 멤버도 일반 클래스처럼 정의합니다. 앞에서 Derived 클래스를 정의할 때는 someOtherMethod()란 메소드를 선언했습니다. Derive 클래스는 메소드를 하나 더 추가하여 Base 클래스를 보완한 셈입니다.

 

파생 클래스는 베이스 클래스에 선언된 public 및 protected 메소드나 데이터 멤버를 마치 자신의 클래스 안에서 정의한 것처럼 사용할 수 있습니다. 실제로 파생 클래스 안에 담겨 있기 때문입니다. 예를 들어 Derived의 someOtherMethod()를 구현하는 코드에서 Base에 선언된 m_protectedInt라는 데이터 멤버를 사용할 수 있습니다. 다음 코드처럼 베이스 클래스에 있는 데이터 멤버나 메소드를 마치 파생 클래스에서 선언한 것처럼 사용해도 됩니다.

void Derived::someOtherMethod()
{
    std::cout << "I can access base class data member m_protected." << std::endl;
    std::cout << "Its value is " << m_protectedInt << std::endl;
}

만약 클래스가 protected로 메소드나 데이터 멤버를 선언했다면, 파생 클래스는 그들에게 접근할 수 있습니다. 만약 private로 선언했다면, 파생 클래스는 접근할 수 없습니다. 아래의 someOtherMethod() 구현은 더 이상 컴파일이 되지 않는데, 이는 파생 클래스가 베이스 클래스의 private 데이터 멤버에 접근하려고 시도하기 때문입니다.

void Derived::someOtherMethod()
{
    std::cout << "I can access base class data member m_protected." << std::endl;
    std::cout << "Its value is " << m_protectedInt << std::endl;
    std::cout << "The value of m_privateInt is " << m_privateInt << std::endl; // Error !
}

 

private 접근 지정자는 나중에 정의된 파생 클래스에서 현재 클래스에 접근하는 수준을 제어하는 데 활용할 수 있습니다. 기본적으로 데이터 멤버는 모두 private로 지정하는 것이 바람직합니다. 그리고 나서 이 데이터 멤버에 접근하도록 하고 싶다면 public getter/setter를 만들면 되고, 만약 오직 파생 클래스만 이 데이터 멤버에 접근하도록 하고 싶다면 protected getter/setter를 만들면 됩니다. 이렇게 데이터 멤버를 기본적으로 private로 선언하는 이유는 캡슐화 수준을 최대로 끌어올리기 위해서 입니다. 이렇게 하면 인터페이스는 public이나 protected로 선언된 상태를 유지하고, 데이터의 내부 표현 방식을 마음껏 바꿀 수 있습니다. 또한, 데이터 멤버를 직접 접근할 수 없게 만들면 public/protected setter에서 입력 데이터를 검사하는 작업을 추가하기도 쉽습니다.

메소드 또한 기본적으로 private로 선언하고, 외부에 공개할 메소드만 별도로 public으로 선언합니다. 물론 파생 클래스만 접근하게 하려면 protected로 선언합니다.

 

다음 표는 3가지 접근 지정자의 요약 내용입니다.

 

1.1.3 상속 방지

C++ 클래스를 정의할 때 final 키워드를 붙여주면 다른 클래스가 이 클래스를 상속할 수 없습니다. final로 선언한 클래스를 상속하면 컴파일 에러가 발생합니다. final 키워드는 클래스 이름 오른쪽에 작성합니다.

class Foo final
{
    // 코드 생략
};

 

1.2 Overriding Methods

클래스를 상속하는 이유는 기능을 추가하거나 바꾸기 위해서 입니다. 위에서 살펴본 Derived 클래스는 베이스 클래스에 someOtherMethod()라는 메소드를 추가하는 방식으로 새로운 기능을 정의합니다. 또 다른 메소드인 someMethod()는 Base로부터 상속되었으며 파생 클래스에서 베이스 클래스에서 동작하는 것과 완전히 동일하게 동작합니다. 경우에 따라 베이스 클래스에 정의된 메소드의 동작을 변경하길 원할 수 있는데, 이는 메소드 오버라이딩(method overriding)을 통해 가능합니다.

 

1.2.1 vitual 키워드

C++에서는 베이스 클래스에 virtual 키워드로 선언된 메소드만 파생 클래스에서 오버라이드 할 수 있습니다. 이 키워드는 메소드 선언 제일 앞부분에 적습니다. 예를 들면 다음과 같습니다.

class Base
{
public:
    virtual void someMethod() {}
protected:
    int m_protectedInt{ 0 };
private:
    int m_privateInt{ 0 };
};

파생 클래스도 동일하게 virtual 키워드를 사용할 수 있습니다.

class Derived : public Base
{
public:
    virtual void someOtherMethod() {}
};

 

1.2.2 메소드 오버라이딩 문법

파생 클래스에서 베이스 클래스의 메소드를 오버라이드하려면 그 메소드를 베이스 클래스에서 선언한 것과 같이 완전히 동일하게 선언하고 맨 뒤에 override 키워드를 붙여주고, virtual 키워드는 제거합니다.

예를 들어, 베이스 클래스에 선언된 someMethod()란 메소드가 다음과 같이 정의되어 있다고 가정해봅시다.

void Base::someMethod()
{
    std::cout << "This is Base's version of someMethod()." << std::endl;
}

메소드를 구현(정의)할 때는 virtual 키워드를 생략합니다.

 

Derived 클래스에서 someMethod()를 새로 정의하려면 Derived 클래스를 정의하는 코드에서 이 메소드의 선언을 다음과 같이 수정해야 합니다. virtual 키워드없이 override 키워드를 추가해줍니다.

class Derived : public Base
{
public:
    void someMethod() override;     // Overrides Base's someMethod()
    virtual void someOtherMethod() {}
};

Dervied 클래스의 someMethod()를 정의합니다.

void Derived::someMethod()
{
    std::cout << "This is Derived's version of someMethod()." << std::endl;
}

 

만약 원한다면 virtual 키워드를 오버라이드된 메소드 앞에 추가해줄 수는 있지만 이는 중복으로 사용하는 것입니다.

class Derived : public Base
{
public:
    virtual void someMethod() override;     // Overrides Base's someMethod()
};

 

virtual 키워드가 메소드나 소멸자에 지정되면, 비록 파생 클래스에서 virtual 키워드가 제거되더라도 모든 파생 클래스에 자동으로 virtual이 붙는 효과가 있습니다.

 

1.2.3 클라이언트 관점에서 본 오버라이드된 메소드

위와 같이 수정하더라도, someMethod()를 호출하는 방법은 그대로입니다. Base나 Derived 객체에 대해 호출할 수 있습니다. 하지만 someMethod()의 실제 동작은 객체가 속한 클래스에 따라 달라집니다.

 

예를 들어, 다음 코드는 이전과 마찬가지로 Base에 정의된 someMethod()를 호출합니다.

Base myBase;
myBase.someMethod();

Derived 클래스 객체를 선언하고 동일한 메소드를 실행하면, 파생 클래스 버전의 메소드가 자동으로 호출됩니다.

Derived myDerived;
myDerived.someMethod();

Derived 객체에 있는 다른 부분은 이전과 동일합니다. Base를 상속한 다른 메소드도 Derived에서 오버라이드되지 않았다면 Base에 정의된 내용이 그대로 유지됩니다.

 

앞에서 설명한 것처럼 포인터나 레퍼런스는 해당 클래스뿐만 아니라 파생 클래스 객체까지 가리킬 수 있습니다. 객체 자신은 실제 멤버가 어느 클래스에 속해있는지 알기 때문에 virtual로 선언되었다면 가장 적합한 메소드를 호출합니다. 예를 들어, 다음과 같이 Derived 객체를 가리키는 레퍼런스를 Base 타입으로 선언한 상태에서 someMethod()를 호출하면 파생 클래스 버전의 메소드가 호출됩니다. 하지만 베이스 클래스에 virtual 키워드를 적지 않았다면 오버라이드한 버전이 호출되지 않습니다.

Derived myDerived;
Base& ref{ myDerived };
ref.someMethod(); // Calls Derived's version of someMethod()

위의 경우 베이스 클래스 타입의 레퍼런스나 포인터가 실제로 파생 클래스 타입 객체를 가리킨다 해도 베이스 클래스에 정의되지 않은 파생 클래스의 데이터 멤버나 메소드에는 접근할 수 없습니다.

 

다음 코드는 Base 레퍼런스에 someOtherMethod()가 없기 때문에 컴파일 에러가 발생합니다.

Derived myDerived;
Base& ref{ myDerived };
myDerived.someOtherMethod(); // This is fine
ref.someOtherMethod();       // Error

이렇게 파생 클래스를 인식해서 적합한 메소드를 호출하는 기능은 포인터나 레퍼런스 객체에만 적용됩니다. Derived 타입은 일종의 Base 타입이기 때문에 Derived를 Base로 캐스팅하거나 Derived 객체를 Base 변수에 대입할 수는 있습니다. 하지만 이렇게 하면 캐스팅하거나 대입하는 순간 파생 클래스 정보가 사라집니다.

Derived myDerived;
Base assignedObject{ myDerived }; // Assigns a Derived to a Base
assignedObject.someMethod();      // Calls Base's version of someMethod()

 

내부 처리 과정이 헷갈린다면 객체가 메모리에 저장된 상태를 떠올리면 이해하기 쉽습니다. Base 객체가 메모리의 일정 영역을 차지하고 있고, Derived 객체는 그보다 더 큰 메모리 영역에 Base 객체의 내용이 몇 가지 사항이 더 추가되었다고 생각할 수 있습니다. 이 상태에서 포인터나 레퍼런스로 Derived 객체를 가리키면 메모리 영역은 그대로이고 접근 방식만 달라집니다. 하지만 Derived를 Base로 캐스팅하면 메모리 영역이 축소되어 Derived 클래스에서 추가한 정보가 사라집니다.

 

1.2.4 override 키워드

override 키워드의 사용은 optional이지만, 사용하도록 강력하게 권장됩니다. 이 키워드가 없으면 베이스 클래스의 메소드를 오버라이드하는 대신 새로운 virtual 메소드를 생성하는 실수를 범할 수 있습니다. 아래 코드에서 Derived는 someMethod()를 오버라이드했지만, override 키워드를 적지 않았습니다.

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

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

이렇게 정의된 코드로 아래 코드를 실행하면,

Derived myDerived;
Base& ref{ myDerived };
ref.someMethod(1.1);    // Calls Derived's version of someMethod()

올바르게 Derived 클래스의 someMethod()를 호출합니다.

이제 실수로 오버라이드한 someMethod()의 파라미터 타입을 double이 아닌 int를 사용했다고 가정해봅시다.

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

이렇게 하면 Base의 someMethod()가 오버라이드되지 않고 또 다른 virtual 메소드가 생성됩니다. 이 상태에서 다음 코드처럼 레퍼런스로 someMethod()를 호출하면 Derived 버전이 아닌 Base 버전의 someMethod()가 호출됩니다.

Derived myDerived;
Base& ref{ myDerived };
ref.someMethod(1.1);    // Calls Base's version of someMethod()

이런 타입의 문제는 Base 클래스를 수정하다가 파생 클래스를 업데이트하는 것을 깜박했을 때 발생할 수 있습니다. 예를 들어 Base 클래스를 처음 작성할 때 정수값을 받는 someMethod()를 선언했다고 해봅시다. 그리고 나서 이 정수값을 받는 someMethod()를 오버라이드하는 Derived 클래스를 작성합니다. 그런데 나중에 Base 클래스의 someMethod()가 int가 아닌 double값을 받도록 수정하고나서 파생 클래스의 someMethod()도 double값을 받도록 수정하는 것을 깜박 잊어버립니다. 그러면 베이스 클래스의 메소드를 오버라이드한 것이 아닌 새로운 virtual 메소드를 정의한 것으로 처리됩니다.

 

이러한 문제를 방지하려면 다음과 같이 override 키워드를 작성해줍니다.

class Derived : public Base
{
public:
    virtual void someMethod(int i) override {}
};

이렇게 Derived를 정의하면 컴파일 에러가 발생합니다. someMethod()가 베이스 클래스의 메소드를 오버라이드하도록 override 키워드를 지정해주었는데, Base 클래스에 있는 someMethod()는 int가 아닌 double을 받기 때문입니다.

 

이와 같은 문제는 베이스 클래스에 있는 메소드 이름을 변경하고 나서 깜박하고 파생 클래스에서 오버라이딩한 메소드 이름을 업데이트하지 않을 때도 발생하기 쉽습니다.

베이스 클래스의 메소드를 오버라이드할 때는 항상 override 키워드를 붙이는게 좋습니다.

 

1.2.5 virtual 메소드

만약 virtual로 선언하지 않은 메소드를 오버라이드하면 몇 가지 문제점이 발생할 수 있습니다. 따라서 오버라이드할 메소드는 항상 virtual로 선언하는 것이 좋습니다.

 

  • Hiding Instead of Overriding

다음 코드에 나온 베이스 클래스와 파생 클래스는 각각 메소드를 하나씩 갖고 있습니다. 파생 클래스는 베이스 클래스의 메소드를 오버라이드하려고 하지만, 그 메소드는 베이스 클래스에서 virtual로 선언되지 않았습니다.

class Base
{
public:
    void go() { std::cout << "go() called on Base" << std::endl; }
};

class Derived : public Base
{
public:
    void go() { std::cout << "go() called on Derived" << std::endl; }
};

이 상태에서 Derived 객체의 go() 메소드를 호출하면,

Derived myDerived;
myDerived.go();

예상한대로 결과가 출력됩니다. 하지만 이 메소드는 virtual로 선언되지 않았기 때문에 실제로 오버라이드 된 것이 아니라 Derived 클래스에 go()란 이름을 갖는 메소드가 새로 생성된 것입니다. 이 메소드는 Base 클래스의 go()와 전혀 다릅니다. 다음과 같이 Base 포인터 또는 레퍼런스로 이 메소드를 호출해보면 정말 다른지 확인할 수 있습니다.

Derived myDerived;
Base& ref{ myDerived };
ref.go();

"go() called on Derived"가 아닌 "go() called on Base"가 출력됩니다. ref 변수는 Base 타입 레퍼런스인데 Base 클래스 안에서 virtual 키워드를 지정하지 않았기 때문에 이 메소드가 파생 클래스에도 있는지 찾아보지 않습니다. 그래서 go() 메소드를 호출하면 Base의 go() 메소드가 호출됩니다.

virtual로 선언하지 않은 메소드를 오버라이드하면 베이스 클래스 정의를 숨겨버립니다. 그래서 오버라이드한 메소드를 파생 클래스 문맥에서만 사용할 수 있게 됩니다.

 

  • virtual 메소드의 내부 동작 방식

앞서 본 것처럼 메소드가 숨겨지지 않게 하려면, 먼저 virtual 키워드가 내부적으로 처리되는 과정을 이해할 필요가 있습니다. C++에서 클래스를 컴파일하면 그 클래스의 모든 메소드를 담은 바이너리 객체가 생성됩니다. 그런데 컴파일러는 virtual로 선언되지 않은 메소드를 호출하는 부분을 컴파일 시간에 결정된 타입의 코드로 교체합니다. 이를 정적 바인딩(static binding) 또는 이른 바인딩(early binding)이라고 합니다.

 

만약 메소드를 virtual로 선언하면, vtable(virtual table, 가상 테이블)이라 부르는 특수한 메모리 영역을 활용해서 가정 적합한 구현을 호출합니다. virtual 메소드가 하나 이상 정의된 클래스마다 vtable이 하나씩 있는데, 이 클래스로 생성한 객체마다 이 vtable에 대한 포인터를 갖게 됩니다. vtable에는 virtual 메소드의 구현 코드에 대한 포인터가 담겨 있습니다. 그래서 객체에 대해 메소드를 호출하면 vtable을 보고 그 시점에 적합한 버전의 메소드를 실행합니다. 이를 동적 바인딩(dynamic binding) 또는 늦은 바인딩(late binding)이라고 합니다.

 

vtable로 메소드 오버라이드 처리하는 과정을 조금 더 구체적으로 이해하기 위해, 다음의 예제 코드를 살펴보겠습니다.

class Base
{
public:
    virtual void func1();
    virtual void func2();
    void nonVirtualFunc();
};

class Derived : public Base
{
public:
    void func2() override;
    void nonVirtualFunc();
};

이렇게 정의된 상태에서 인스턴스 두 개를 생성합니다.

Base myBase;
Derived myDerived;

다음 그림은 이렇게 생성한 두 인스턴스가 vtable에 표현된 모습을 보여줍니다.

myBase 객체는 vtable에 대한 포인터를 가지고 있으며, 이 vtable에는 func1()과 func2()에 대한 항목이 있습니다. 각 항목은 Base::func1()과 Base::func2()의 구현 코드를 가리킵니다.

 

myDerived도 마찬가지로 vtable에 대한 포인터를 가지며, func1()과 func2()에 대한 항목으로 구성됩니다. 그런데 Derived가 func1()을 오버라이드하지 않기 때문에 func1()에 대한 항목은 Base::func1()을 가리킵니다. 반면 func2()에 대한 항목은 Derived::func2()를 가리킵니다.

 

여기서 주목할 점은 두 vtable 모두 nonVirtualFunc() 메소드에 대한 항목은 가지지 않는다는 점입니다.

 

  • virtual 키워드가 필요한 이유

자바와 같은 몇몇 언어에서는 모든 메소드가 자동으로 virtual로 선언됩니다. 하지만 C++에서는 그렇지 않습니다. C++에서 모든 메소드를 virtual로 지정하지 않는 이유는 vtable의 오버헤드를 줄이기 위해서 virtual 키워드를 만들었기 때문입니다. virtual 메소드를 호출하려면, 프로그램은 가장 적합한 코드를 선택하는 과정에서 포인터를 역참조해야 합니다. 이 정도의 오버헤드는 큰 부담이 되지는 않지만, 당시 C++ 설계자는 성능을 최대한 높일 수 있ㄷ도록 이러한 결정을 프로그래머가 내리는 것이 낫다고 판단했습니다. 오버라이드할 일이 없는 메소드라면 굳이 virtual로 만들어서 오버헤드를 발생시킬 필요가 없기 때문입니다. 하시만 최신 CPU를 사용할 때는 이 과정에서 발생하는 오버헤드가 나노초 단위로 미미하며, 향후 출시될 CPU에서는 더 낮아질 것입니다. 대부분 프로그램에서 virtual 메소드를 사용할 때와 사용하지 않을 때의 성능의 눈에 띄게 달라지지는 않습니다.

 

하지만, 특정 케이스에서 이정도 성능 오버헤드도 부담스러울 때가 있습니다. 예를 들어 virtual 메소드를 가진 Point 클래스를 수백만 또는 수십억 개의 Point 객체를 만들어서 저장한다면 Point 객체마다 virtual 메소드를 호출하여 엄청난 오버헤드가 발생합니다. 이럴 때는 Point 클래스에서 메소드를 virtual로 지정하지 않는 것이 바람직합니다.

또한 메모리 사용량에도 약간의 영향이 있습니다. 메소드 구현과 별도로 각 객체는 vtable을 위한 포인터가 필요하며 이는 약간 공간을 차지합니다. 대부분의 경우에는 문제가 되지 않지만, 방금 언급한 것처럼 수백만, 수십억의 객체가 있다면 상당한 양의 공간을 차지하게 됩니다.

 

  • virtual 소멸자의 필요성

소멸자는 거의 항상 virtual로 지정되어야 합니다. 소멸자를 virtual로 선언하지 않으면 객체가 소멸할 때 메모리가 해제되지 않을 수 있습니다. 클래스를 final로 선언할 때는 제외한 나머지 경우는 항상 소멸자를 virtual로 선언하는 것이 좋습니다.

 

예를 들어 파생 클래스의 생성자가 동적으로 할당한 메모리를 사용하다가 소멸자에서 삭제하도록 작성했을 때, 소멸자가 호출되지 않으면 메모리 누수가 발생합니다. 마찬가지로 std::unique_ptr처럼 파생 클래스에서 자동으로 삭제되는 멤버가 있을 때 그 클래스의 인스턴스가 삭제될 때 소멸자가 호출되지 않으면 이런 멤버가 삭제되지 않고 남게 됩니다.

 

다음 코드를 보면 virtual로 선언되지 않은 소멸자가 호출되지 않는 일이 얼마나 발생하기 쉬운지 알 수 있습니다.

class Base
{
public:
    Base() {}
    ~Base() {}
};

class Derived : public Base
{
public:
    Derived()
    {
        m_string = new char[30];
        std::cout << "m_string allocated" << std::endl;
    }
    ~Derived()
    {
        delete[] m_string;
        std::cout << "m_string deallocated" << std::endl;
    }

private:
    char* m_string;
};

int main()
{
    Base* ptr{ new Derived{} }; // m_string is allocated here
    delete ptr; // ~Base() is called, but ~Derived()
                // because the destructor is not virtual!

    return 0;
}

위 코드를 실행하면, Derived의 소멸자가 호출되지 않아 "m_string allocated"라는 출력밖에 확인할 수 없습니다.

 

실제로 delete를 호출하는 동작은 표준에 정의되어 있지 않으며, C++ 컴파일러는 각자 나름대로 구현하고 있습니다. 그런데 대부분의 컴파일러는 파생 클래스의 소멸자가 아닌 베이스 클래스의 소멸자를 호출하도록 처리합니다.

소멸자에서 따로 처리할 일은 없고 virtual로만 지정하고 싶다면 다음과 같이 디폴트도 지정하면 됩니다.
    virtual ~Base() = default;

참고로 C++11부터 클래스에 사용자가 선언한 소멸자가 있을 때 복사 생성자와 복사 대입 연산자를 자동으로 생성해주는 기능이 폐기되었습니다. 그래도 여전히 복사 생성자와 복사 대입 연산자를 컴파일러에서 생성해주는 기능이 필요하다면 명시적으로 디폴트로 지정하면 됩니다. 이번 포스팅에서 이 부분은 생략되어 있습니다.

 

  • 오버라이딩 방지하기

final을 클래스에 지정할 수 있을 뿐만 아니라, 메소드도 final로 지정할 수 있습니다. 메소드를 final로 지정하면 파생 클래스에서 오버라이드할 수 없습니다. 다음 예제 코드와 같이 final로 지정된 Derived의 someMethod()를 오버라이드하려고 하면 컴파일 에러가 발생합니다.

class Base
{
public:
    virtual ~Base() = default;
    virtual void someMethod();
};
class Derived : public Base
{
public:
    void someMethod() override final;
};
class DerivedDerived : public Derived
{
public:
    void someMethod() override; // Compile error!
};

 


2. 코드 재사용을 위한 상속

이번에는 C++에서 상속이 중요한 또 다른 이유인 코드 재사용에 대해 알아보겠습니다. 상속을 이용하면 기존에 작성된 코드를 그대로 활용할 수 있는데, 여기서는 코드 재사용 관점에서 상속을 활용하는 예제에 대해 소개하겠습니다.

 

2.1 WeatherPrediction 클래스

간단한 일기예보 프로그램을 작성한다고 가정해보겠습니다. 이때 온도 단위로 섭씨와 화씨를 모두 사용합니다. 일기예보에 관련된 부분을 직접 구현하기는 힘들기 때문에 현재 온도와 목성과 화성 사이의 현재 거리를 정보를 기반으로 날씨를 예측하는 서드파티 클래스 라이브러리를 사용한다고 가정하고 WeatherPrediction 클래스를 정의하도록 하겠습니다.

#include <string>

// 현재 온도와 목성과 화성 사이의 거리를 기반으로 날씨를 예측하는 new-age techniques를 구현
class WeatherPrediction
{
public:
    // virtual destructor
    virtual ~WeatherPrediction();

    // Set the current temperature in Fahrenheit
    virtual void setCurretnTempFahrenheit(int temp);

    // Set the current distance between Jupiter and Mars
    virtual void setPositionOfJupiter(int distanceFromMars);

    // Get the prediction for tomorrow's temperature
    virtual int getTomorrowTempFahrenheit() const;

    // Get the probability of rain tomorrow. 1 means
    // definite rain. 0 means no chance of rain.
    virtual double getChanceOfRain() const;

    // Display the result to the user in this format:
    // Result: x.xx change. Temp. xx
    virtual void showResult() const;

    // Return a string representation of the temperature
    virtual std::string getTemperature() const;

private:
    int m_currentTempFahrenheit{ 0 };
    int m_distanceFromMars{ 0 };
};

이 클래스 정의를 보면 모든 메소드를 virtual로 선언했는데, 이는 클래스의 모든 메소드가 파생 클래스에서 오버라이드 한다고 가정했기 때문입니다.

 

일기예보 프로그램에 필요한 작업은 대부분 이 클래스로 처리합니다. 하지만 우리가 만들 프로그램의 요구사항에는 딱 맞지 않습니다. 첫째, 온도값이 모두 화씨 단위입니다. 작성할 프로그램은 섭씨로도 표현해야 합니다. 두번째는 showResult() 메소드에서 출력하는 결과의 형식이 맞지 않습니다.

 

클래스 정의에 대한 구현은 다음과 같습니다. 여기서 내일 온도를 구하거나, 비가 올 확률을 구하는 부분은 실제와는 다르고 대강 구현되었으니 참고하시길 바랍니다 !

#include "WeatherPrediction.h"

#include <iostream>
#include <sstream>

WeatherPrediction::~WeatherPrediction()
{
}

void WeatherPrediction::setCurretnTempFahrenheit(int temp)
{
    m_currentTempFahrenheit = temp;
}

void WeatherPrediction::setPositionOfJupiter(int distanceFromMars)
{
	m_distanceFromMars = distanceFromMars;
}

int WeatherPrediction::getTomorrowTempFahrenheit() const
{
	// Obviously, this is nonsense
	return (m_distanceFromMars / 1000) + m_currentTempFahrenheit;
}

double WeatherPrediction::getChanceOfRain() const
{
	// Obviously, this is nonsense too
	return 0.5;
}

void WeatherPrediction::showResult() const
{
	std::cout << "Result: " << getChanceOfRain() * 100
		<< " chance.  Temp. " << getTomorrowTempFahrenheit() << std::endl;
}

std::string WeatherPrediction::getTemperature() const
{
	std::stringstream ss;
	ss << m_currentTempFahrenheit;
	return ss.str();
}

 

2.2 파생 클래스에 기능 추가

여기서 작성할 프로그램은 기본적으로 WeatherPrediction 클래스와 비슷하지만 몇 가지 기능을 더 추가합니다. 이럴 때는 상속으로 코드를 재사용하면 좋습니다. 먼저 WeatherPrediction을 상속하는 MyWeatherPrediction이란 클래스를 다음과 같이 새로 정의합니다.

#include "WeatherPrediction.h"

class MyWeatherPrediction : public WeatherPrediction
{

};

이렇게만 작성해도 컴파일은 잘 됩니다. 이 상태만으로도 WeatherPrediction 클래스 자리에 MyWeatherPrediction을 대신 넣어도 됩니다. 아직은 MyWeatherPrediction과 WeatherPrediction이 똑같고 아무런 차이도 없습니다.

 

가장 먼저 수정할 부분은 섭씨 단위를 추가하는 것입니다. 그런데 이 클래스가 내부적으로 어떻게 작동하는지 모른다는 문제가 있습니다. 만약 내부적으로 화씨 단위로만 계산한다면 어떻게 해야 섭씨 단위를 지원할 수 있을까요? 한 가지 방법은 섭씨와 화씨를 모두 사용하는 파생 클래스(MyWeatherPrediction)와 화씨만 사용하는 베이스 클래스(WeatherPrediction) 사이를 중계하는 인터페이스를 추가하는 것입니다.

 

섭씨 단위를 지원하기 위한 첫 번째 단계는 클라이언트가 현재 온도를 화씨가 아닌 섭씨 단위로 설정하는 메소드와 내일 온도 예측값을 화씨가 아닌 섭씨 단위로 받는 메소드를 추가하는 것입니다. 또한 섭씨와 화씨를 양방향으로 변환하는 private 헬퍼 메소드도 정의합니다. 변환 메소드의 구현 코드는 이 클래스의 객체에서 동일하기 때문에 이 메소드는 static으로 지정합니다.

class MyWeatherPrediction : public WeatherPrediction
{
public:
    virtual void setCurrentTempCelsius(int temp);
    virtual int getTomorrowTempCelsius() const;
private:
    static int convertCelsiusToFahrenheit(int celsius);
    static int convertFahrenheitToCelsius(int fahrenheit);
};

이렇게 새로 정의한 메소드는 부모 클래스의 명명 규칙을 그대로 따릅니다. 다른 코드에서 볼 때 MyWeatherPrediction 객체와 WeatherPrediction 객체가 똑같습니다. 따라서 부모 클래스의 명명 규칙을 그대로 따르면 인터페이스를 일관성 있게 유지할 수 있습니다.

 

우선 섭씨/화씨 변환 메소드는 다음과 같이 구현할 수 있습니다.

int MyWeatherPrediction::convertCelsiusToFahrenheit(int celsius)
{
    return static_cast<int>((9.0) / (5.0) * celsius + 32);
}

int MyWeatherPrediction::convertFahrenheitToCelsius(int fahrenheit)
{
    return static_cast<int>((5.0) / (9.0) * (fahrenheit - 32));
}

 

그리고 현재 온도를 섭씨 단위로 설정하려면 현재 온도를 부모 클래스가 이해할 수 있는 단위로 변환해서 전달해야 합니다.

void MyWeatherPrediction::setCurrentTempCelsius(int temp)
{
    int fahrenheitTemp = convertCelsiusToFahrenheit(temp);
    setCurretnTempFahrenheit(fahrenheitTemp);
}

코드를 보면 알 수 있듯이 이란 온도를 변환했다면 베이스 클래스의 메소드를 그대로 호출할 수 있습니다.

마찬가지로 getTomorrowTempCelsius()의 구현 코드에서도 부모 클래스의 기능을 이용하여 현재 온도를 화씨 단위로 가져온 다음 이를 섭씨로 변환해서 리턴합니다.

int MyWeatherPrediction::getTomorrowTempCelsius() const
{
    int fahrenheitTemp = getTomorrowTempFahrenheit();
    return convertFahrenheitToCelsius(fahrenheitTemp);
}

 

방금 작성한 두 메소드는 실제로 부모 클래스의 메소드를 재사용하고 있습니다. 실제 동작은 기존 메소드로 처리하고, 새 인터페이스는 기존 메소드를 감싸기만 하기 때문입니다.

물론 부모 클래스와 전혀 다른 기능을 추가해도 됩니다. 예를 들어 인터넷에서 날씨 예측 정보를 가져오거나 예측된 날씨에 적합한 활동을 추천해주는 기능을 추가할 수 있습니다.

 

2.3 파생 클래스에서 기존 기능 변경하기

상속의 또 다른 주요 기능은 기존 기능을 변경하는 것입니다. 여기서는 showResult() 메소드가 결과를 조금 더 예쁘게 출력하도록 수정할 필요가 있습니다. 이때 MyWeatherPrediction에서 이 메소드를 오버라이드해서 원하는 형태로 동작을 바꾸면 됩니다.

class MyWeatherPrediction : public WeatherPrediction
{
public:
    virtual void setCurrentTempCelsius(int temp);
    virtual int getTomorrowTempCelsius() const;
    void showResult() const override;
private:
    static int convertCelsiusToFahrenheit(int celsius);
    static int convertFahrenheitToCelsius(int fahrenheit);
};

showResult()에서 결과를 조금 더 예쁘게 출력하도록 다음과 같이 정의했습니다.

void MyWeatherPrediction::showResult() const
{
    std::cout << "Tomorrow will be " << getTomorrowTempCelsius() << " degrees Celsius ("
        << getTomorrowTempFahrenheit() << " degrees Fahrenheit)" << std::endl;
    std::cout << "Chance of rain is " << getChanceOfRain() * 100 << "%" << std::endl;
    if (getChanceOfRain() > 0.5)
        std::cout << "Bring an umbrella!" << std::endl;
}

이 클래스를 사용하는 코드(클라이언트)에서 보면 마치 기존 버전의 showResult()가 없는 것 같습니다. 이렇게 객체를 MyWeatherPrediction 타입으로 생성하면 새로 구현한 메소드가 호출됩니다.

지금까지 본 것처럼 상속을 활용하면 자신이 원하는 요구사항에 딱 맞는 MyWeatherPrediction이라는 클래스를 기존 기능을 수정하는 방식으로 만들 수 있습니다. 게다가 베이스 클래스에 있던 기능을 재사용했기 때문에 새로 작성할 코드도 많지 않습니다.

 


3. Respect Your Parents

파생 클래스를 작성할 때 반드시 부모 클래스와 자식 클래스의 연동 방식에 주의해야 합니다. 생성 순서, 생성자 체이닝(chaining), 캐스팅 등에서 다양한 버그가 발생할 수 있습니다.

 

3.1 Parent Constructors

객체는 한 번에 생성되지 않습니다. 부모에 있던 것과 새로 추가할 내용을 모두 담아서 생성합니다. C++은 객체 생성 과정을 다음과 같이 정의하고 있습니다.

  1. 만약 베이스 클래스라면 디폴트 생성자를 실행합니다. 단 생성자 이니셜라이저(ctor-initializer)가 있다면 디폴트 생성자 대신 생성자 이니셜라이저를 호출합니다.
  2. static으로 선언하지 않은 데이터 멤버를 코드에 나타난 순서대로 생성합니다.
  3. 클래스 생성자의 본문을 실행합니다.

이 규칙은 재귀적으로 적용됩니다. 클래스에 부모 클래스가 있다면 현재 클래스보다 부모 클래스를 먼저 초기화하는데, 만일 그 부모의 부모 클래스가 있다면 그 클래스를 먼저 초기화합니다.

 

예를 들어 다음의 코드를 살펴보겠습니다. 이 코드를 실행하면 123이라는 결과를 출력합니다.

(여기서는 간결하게 읽기 쉽도록 구성하기 위해 메소드를 클래스 정의에 구현했습니다.)

class Something
{
public:
    Something() { std::cout << "2"; }
};

class Base
{
public:
    Base() { std::cout << "1"; }
};

class Derived : public Base
{
public:
    Derived() { std::cout << "3"; }
private:
    Something m_dataMember;
};

int main()
{
    Derived myDerived;
}

위 코드에서 myDerived 객체가 생성되면 Base 생성자가 먼저 호출되면서 문자열 "1"을 출력합니다. 다음으로 m_dataMember가 초기화되면서 Something 생성자를 호출하고, 문자열 "2"를 출력합니다. 마지막으로 Derived 생성자가 호출되면서 문자열 "3"을 출력합니다.

 

여기서 Base 클래스 생성자는 자동으로 호출됩니다. C++은 부모 클래스에 디폴트 생성자가 있으면 자동으로 호출해줍니다. 부모 클래스에 디폴트 생성자가 없거나, 있더라도 다른 생성자를 사용할 때는 생성자 이니셜라이저(ctor-initializer)로 데이터 멤버를 초기화할 때와 같은 방식으로 생성자를 체인(chain)으로 엮을 수 있습니다.

예를 들어, 다음의 코드는 디폴트 생성자 없이 Base 클래스를 정의했습니다. 이를 상속한 Derived 클래스는 반드시 컴파일러에 Base 생성자를 호출하는 방법을 알려주어야 합니다. 그렇지 않으면 컴파일 에러가 발생합니다.

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

class Derived : public Base
{
public:
    Derived() : Base{ 7 } { /* other initialization */ }
};

Derived 생성자는 고정된 값 7을 Base 생성자에 전달합니다. 물론 변수를 전달할 수도 있습니다.

Derived::Derived(int i) : Base{ i } {}

파생 클래스에서 베이스 클래스로 생성자 인수를 전달해도 아무런 문제가 없습니다. 하지만 데이터 멤버를 전달할 수는 없습니다. 그렇게 해도 컴파일 에러는 발생하지 않지만 데이터 멤버는 베이스 클래스 생성자가 실행된 후에야 초기화됩니다. 따라서 부모 생성자에 데이터 멤버를 인수로 전달하면 초기화되지 않은 데이터 멤버가 전달됩니다.

생성자 안에서는 virtual 메소드의 동작 방식이 다릅니다. 파생 클래스에서 베이스 클래스의 virtual 메소드를 오버라이드하고 그 메소드를 베이스 클래스 생성자에서 호출하면 파생 클래스에서 오버라이드한 버전이 아닌 베이스 클래스에 구현된 virtual 메소드가 호출됩니다.

 

3.2 Parent Destructors

소멸자는 인수를 받지 않기 때문에 부모 클래스의 소멸자는 언제나 자동으로 호출되게 할 수 있습니다. 소멸자의 호출 과정은 다음과 같이 생성자와 반대입니다.

  1. 현재 클래스의 소멸자를 호출합니다.
  2. 현재 클래스의 데이터 멤버를 생성할 때와 반대 순서로 삭제합니다.
  3. 부모 클래스가 있다면 부모의 소멸자를 호출합니다.

이 규칙도 생성자와 마찬가지로 재귀적으로 적용됩니다. 상속 체인에서 가장 하위 멤버를 먼저 삭제합니다.

다음 코드는 앞에서 작성한 예제에 소멸자를 추가한 버전입니다. 여기서는 소멸자를 모두 virtual로 선언했으며 코드를 실행하면 "123321"이 출력됩니다.

class Something
{
public:
    Something() { std::cout << "2"; }
    virtual ~Something() { std::cout << "2"; }
};

class Base
{
public:
    Base() { std::cout << "1"; }
    virtual ~Base() { std::cout << "1"; }
};

class Derived : public Base
{
public:
    Derived() { std::cout << "3"; }
    virtual ~Derived() { std::cout << "3"; }
private:
    Something m_dataMember;
};

int main()
{
    Derived myDerived;
}

여기서 소멸자를 virtual로 선언하지 않아도 코드 실행에는 문제가 없습니다. 하지만 파생 클래스를 가리키는 베이스 클래스 타입 포인터에 대해 delete를 호출하면 소멸자 실행 순서가 뒤바뀝니다.

예를 들어, 앞에 나온 코드에서 소멸자 앞에 있는 virtual 키워드를 모두 삭제하고 나서 다음과 같이 Derived 객체를 가리키는 Base 타입 포인터를 삭제하면 문제가 발생합니다.

Base* ptr{ new Derived{} };
delete ptr; // print "1231"

이 코드를 실행하면 "1231"이라는 결과를 출력합니다. ptr 변수를 삭제하면 Base 소멸자만 호출되는데, 이는 소멸자를 virtual로 지정하지 않았기 때문입니다. 결국 Derived 소멸자가 호출되지 않아 Derived의 데이터 멤버에 대한 소멸자도 호출되지 않습니다.

 

이는 Base 생성자 앞에 virtual이란 키워드만 추가해도 문제를 해결할 수 있습니다. virtual 속성은 자식 클래스에도 자동으로 적용되기 때문입니다. 하지만 애초에 이런 문제를 만들지 않도록 항상 모든 소멸자를 virtual로 선언하는 것이 바람직합니다.

소멸자 앞에는 항상 virtual을 붙입니다. 컴파일러가 생성한 디폴트 소멸자는 virtual이 아니므로 최소한 부모 클래스에서만이라도 virtual 소멸자를 따로 정의하거나 명시적으로 default로 지정합니다.
생성자와 마찬가지로 소멸자 안에서도 virtual 메소드의 동작이 달라집니다. 파생 클래스에서 베이스 클래스의 virtual 메소드를 오버라이드했을 때 이 메소드를 베이스 클래스 소멸자에서 호출하면 그 메소드에 대한 파생 클래스의 구현 코드가 아닌 베이스 클래스의 구현 코드가 호출됩니다.

 

3.3 부모 클래스 참조

파생 클래스에서 메소드를 오버라이드하면, 다른 코드에서 볼 때 원본 코드를 바꾸는 효과가 나타납니다. 하지만 그 메소드의 부모 버전은 그대로 남아 있기 때문에 얼마든지 실행할 수 있습니다. 예를 들어 오버라이드한 메소드에서 베이스 클래스의 구현 코드를 그대로 유지한 채 다른 작업을 추가할 수 있습니다. 구체적인 예시로 다음과 같이 현재 온도를 string으로 표현한 값을 리턴하도록 구현된 WeatherPrediction 클래스의 getTemperature() 메소드를 살펴보겠습니다. 전체 코드는 위쪽에 있습니다.

class WeatherPrediction
{
public:
    // Return a string representation of the temperature
    virtual std::string getTemperature() const;
    // .. 나머지 코드 생략
};

이 메소드를 MyWeatherPrediction 클래스에서 다음과 같이 오버라이드합니다.

class MyWeatherPrediction : public WeatherPrediction
{
public:
    std::string getTemperature() const override;
    // .. 나머지 코드 생략
};

파생 클래스에서 결과값에 화씨 기호(℉)를 추가해보겠습니다. 다음과 같이 베이스 클래스의 getTemperature() 메소드를 호출한 뒤 그 결과로 나온 string에 ℉를 추가하면 됩니다.

std::string MyWeatherPrediction::getTemperature() const
{
    // Note: \u00B0 is ISO/IEC 10646 representation of the degree symbol.
    return getTemperature() + "\u00B0F"; // BUG
}

하지만 이렇게 작성하면 의도한 대로 실행되지 않습니다. C++은 이름을 처리할 때 로컬 스코프부터 살펴본 뒤 클래스 스코프를 검색합니다. 따라서 이렇게 하면 MyWeatherPrediction::getTemperature()가 호출됩니다. 그러면 스택 공간이 가득 찰 때까지 무한히 재귀 호출됩니다 (컴파일러에 따라 이런 에러를 미리 감지해서 컴파일 시간에 알려주기도 합니다.).

 

제대로 작성하려면 다음과 같이 스코프 지정 연산자(scope resolution operator)를 추가해주어야 합니다.

std::string MyWeatherPrediction::getTemperature() const
{
    // Note: \u00B0 is ISO/IEC 10646 representation of the degree symbol.
    //return getTemperature() + "\u00B0F"; // BUG
    return WeatherPrediction::getTemperature() + "\u00B0F";
}
마이크로소프트 Visual C++에서 지원하는 __super 키워드(non-standard)를 사용해서 작성해도 됩니다.
    return __super::getTemperature() + "\u00B0F";

 

C++ 프로그래밍을 할 때는 현재 메소드의 부모 버전 코드를 호출하는 패턴을 많이 사용합니다. 파생 클래스가 체인처럼 구성된 상태에서 각 클래스마다 베이스 클래스에 정의된 연산을 그대로 실행하면서 원하는 기능을 추가하는 것입니다.

 

또 다른 예시로 아래 그림과 같이 클래스 계층으로 분류한 경우를 살펴보겠습니다.

이 계층을 보면 아래쪽으로 갈수록 구체화되기 때문에 어떤 책에 대한 정보를 가져오려면 베이스 클래스부터 현재 클래스까지 담긴 내용을 모두 가져와야 합니다. 이 기능은 부모 메소드를 호출하는 패턴으로 구현할 수 있습니다.

코드로 표현하면 다음과 같습니다.

class Book
{
public:
    virtual ~Book() = default;
    virtual std::string getDescription() const { return "Book"; }
    virtual int getHeight() const { return 120; }
};

class Paperback : public Book
{
public:
    std::string getDescription() const override {
        return "Paperback " + Book::getDescription();
    }
};

class Romance : public Paperback
{
public:
    std::string getDescription() const override {
        return "Romance " + Paperback::getDescription();
    }
    int getHeight() const override { return Paperback::getHeight() / 2; }
};

class Technical : public Book
{
public:
    std::string getDescription() const override {
        return "Technical " + Book::getDescription();
    }
};

int main()
{
    Romance novel;
    Book book;
    std::cout << novel.getDescription() << std::endl; // Outputs "Romance Paperback Book"
    std::cout << book.getDescription() << std::endl;  // Outputs "Book"
    std::cout << novel.getHeight() << std::endl;      // Outputs "60"
    std::cout << book.getHeight() << std::endl;       // Outputs "120"
}

베이스 클래스인 Book은 두 개의 virtual 메소드, getDescription()과 getHeight()를 가지고 있습니다. 파생 클래스는 모두 getDescription() 메소드를 오버라이드 합니다. Romance 클래스만 getHeight()도 오버라이드하는데, 부모 클래스(Paperback)의 getHeight()를 호출한 뒤 결과를 2로 나누는 방식으로 구현합니다. Paperback은 getHeight()를 오버라이드하지 않기 때문에 C++은 getHeight()의 구현 코드를 찾기 위해 클래스 계층을 거슬러 올라가며 탐색합니다. 따라서 이 예제에서 Paperback::getHeight()를 호출한 부분은 Book::getHeight()로 처리됩니다.

 

3.4 Casting Up and Down

앞서 본 것처럼 다음과같이 객체를 부모 클래스 타입으로 캐스팅하거나 대입할 수 있습니다.

Base myBase{ myDerived }; // Slicing!

이처럼 최종 결과가 Base 객체라면 slicing(슬라이싱)이 발생합니다. Base 객체에는 Derived 클래스에 정의된 부가 기능이 없기 때문입니다. 하지만 파생 클래스 타입의 객체를 베이스 클래스 타입의 포인터나 레퍼런스에 대입할 때는 슬라이싱이 발생하지 않습니다.

Base& myBase{ myDerived }; // No slicing!

이렇게 베이스 클래스 타입으로 파생 클래스를 참조하는 것을 업캐스팅(upcasting)이라고 합니다. 바로 이 때문에 객체가 아닌 객체의 레퍼런스를 함수나 메소드로 전달하도록 구성하는 것이 좋습니다. 이렇게 레퍼런스를 활용하면 슬라이싱없이 파생 클래스를 전달할 수 있습니다.

 

반면 베이스 클래스를 파생 클래스로 캐스팅하는 것을 다운캐스팅(downcasting)이라고 합니다. 이렇게 하면 해당 객체가 반드시 파생 클래스에 속한다고 보장할수 없고, 다운캐스팅이 있다는 것은 디자인이 잘못된 것을 의미하기 때문에 다운캐스팅을 부정적으로 봅니다.

예를 들어, 다음의 코드를 살펴봅시다.

void presumptuous(Base* base)
{
    Derived* myDerived{ static_cast<Derived*>(base) };
    // Proceed to access Derived methods on myDerived.
}

presumptuout()를 작성한 사람이 이를 호출하는 코드를 작성할 때는 아무런 문제가 없습니다. 이 함수에서 Derived* 타입의 인수를 받는다는 것을 알고 있기 때문입니다. 하지만 다른 프로그래머가 이 메소드를 호출할 때는 Base* 타입의 인수를 전달할 가능성이 있습니다. 인수의 구체적인 타입을 컴파일 시간에 결정할 수 없기 때문에 이 함수는 막연히 base가 Derived에 대한 포인터라고 가정합니다.

 

하지만 간혹 다운캐스팅이 필요할 때도 있습니다. 단, 완벽히 통제할 수 있는 상황에서만 사용해야 합니다. 그러나 만약 다운캐스팅을 하고자 한다면, dynamic_cast()를 사용하는 것이 좋습니다. 이 함수는 객체 내부에 저장된 타입 정보를 보고 캐스팅이 잘못됐다면 처리하지 않습니다. 이러한 타입 정보는 vtable에 담겨 있기 때문에 dynamic_cast()는 vtable이 있는, 다시 말해 vtable 멤버가 하나라도 있는 객체에만 적용할 수 있습니다. 포인터 변수에 대해 dynamic_cast()가 실패하면 포인터의 값이 임의의 객체가 아닌 nullptr이 됩니다. 또한 객체 레퍼런스에 대해 dynamic_cast()가 실패하면 std::bad_cast 예외가 발생합니다. 다양한 캐스팅 방법은 추후에 다루도록 하겠습니다.

 

위와 같은 내용을 숙지하고, 앞서 나온 코드는 다음과 같이 수정할 수 있습니다.

void lessPresumptuous(Base* base)
{
    Derived* myDerived{ dynamic_cast<Derived*>(base) };
    if (myDerived != nullptr) {
        // Proceed to access Derived methods on myDerived.
    }
}

 

그러나 흔히 디자인이 잘못됐을 때 다운캐스팅하는 코드가 나타나는 경향이 있습니다. 이럴 때는 다운캐스팅을 사용할 일이 없도록 다시 디자인해야 합니다. 예를 들어 위의 lessPresumptuous() 함수는 실제로 Derived 객체만 다루기 때문에 인수를 Base 포인터로 받지 말고 곧바로 Derived 포인터를 받도록 수정해야 합니다. 그러면 다운캐스팅 코드를 제거할 수 있습니다.

 

 

이 함수에서 Base를 상속한 다른 파생 클래스를 사용해야 한다면, 뒤에서 소개하는 다형성을 활용하는 것이 좋습니다.

다음 포스팅에서 다형성을 위한 상속과 다중 상속, 상속과 관련된 여러가지 이슈들에 대해서 알아보도록 하겠습니다.

댓글