전문가를 위한 C++/ 상속 활용하기

Contents

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

상속을 이용한 클래스 구현

클래스 확장하기

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

Base 클래스가 다음과 같이 생겼다고 하자.

class Base
{
public:
void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
}

이제 Derived 클래스가 Base 클래스를 상속하도록 만들어 보자.

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

(이하 설명 생략)

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

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

상속은 반드시 한 방향으로만 진행된다는 점에 주의한다. Derived 클래스 입장에서는 Base 클래스와의 관계가 상당히 명확하지만 Base 클래스를 정의하는 시점에서는 Derived 클래스의 존재를 알 수 없다.

어떤 객체를 포인터나 레퍼런스로 가리킬 때 그 객체를 선언한 클래스의 객체뿐만 아니라 그 클래스의 파생 클래스 객체도 가리킬 수 있다. 레퍼런스도 마찬가지다. 클라이언트는 기본적으로 Base에 있는 데이터 멤버나 메서드에 접근할 수 있지만 상속을 통해 Base에 적용되는 코드를 Derived에도 적용할 수 있다. 

예컨대 다음과 같이 작성하면 타입이 맞지 않는 것처럼 보이지만 정상적으로 컴파일 된다.

Base* base = new Derived();  // Derived 객체를 생성해서 Base 포인터에 저장한다.

그런데 Base 포인터로 Derived 클래스의 메서드를 호출할 수는 없다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

base->someOtherMethod();

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

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

파생 클래스를 작성하는 방법은 일반 클래스와 기본적으로 같다. 파생 클래스의 메서드와 데이터 멤버도 일반 클래스처럼 정의한다.

(이하 설명 생략)

상속 방지

C++에서 클래스를 정의할 때 final 키워드를 붙이면 다른 클래스가 이 클래스를 상속할 수 없다. final로 선언한 클래스를 상속하면 컴파일 에러가 발생한다. 

class Base final
{
// 코드 생략
}

메서드 오버라이딩

클래스를 상속하는 주된 이유는 기능을 추가하거나 바꾸기 위해서다. 베이스 클래스에 정의된 메서드의 동작은 변경할 일이 많은데, 이는 메서드 오버라이딩을 이용해 가능하다.

메서드 오버라이딩과 virtual 속성

C++에서는 베이스 클래스에 virtual 키워드로 선언된 메서드만 파생 클래스에서 오버라이드 할 수 있다. 예컨대 Base 클래스의 메서드를 virtual로 선언하려면 다음과 같이 작성한다.

class Base
{
public:
virtual void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
}

일반적으로 모든 메서드를 virtual로 선언하는 것이 바람직한데, 그러면 오버라이드하는 과정에서 메서드가 제대로 작동할지 신경 쓸 필요가 없다. 하지만 이렇게 하면 성능이 좀 떨어진다.

메서드 오버라이딩 문법

파생 클래스에서 베이스 클래스의 메서드를 오버라이드하려면 그 메서드를 베이스 클래스에 나온 것과 똑같이 선언하고 맨 뒤에 override 키워드를 붙인다.

class Derived : public Base
{
public:
virtual void someMethod() override; // Base의 someMethod 오버라이딩
virtual void someOtherMethod();
}

메서드나 소멸자를 virtual로 지정하면 모든 파생 클래스에서도 virtual 상태를 유지한다. 파생 클래스에서 virtual 키워드를 제거해도 마찬가지다. 예컨대 Derived 클래스에서 someMethod()를 다음과 같이 선언해도 Base 클래스에서 virtual로 선언했기 때문에 여전히 Derived를 상속한 클래스에서 이를 오버라이드 할 수 있다.

class Derived : public Base
{
public:
virtual void someMethod() override; // Base의 someMethod 오버라이딩
}

클라이언트 관점에서 본 오버라이드한 메서드

(설명 생략)

앞서 설명한 것처럼 포인터나 레퍼런스는 해당 클래스뿐만 아니라 파생 클래스 객체까지 가리킬 수 있다. 객체 자신은 멤버가 어느 클래스에 속해 있는지 알기 때문에 virtual로 선언됐다면 가장 적합한 메서드를 호출한다.

예컨대 다음과 같이 Derived 객체를 가리키는 레퍼런스를 Base 타입으로 선언한 상태에서 someMethod()를 호출하면 파생 클래스 버전이 호출된다. 하지만 베이스 클래스에 virtual이란 키워드가 없었다면 오버라이드한 버전이 호출되지 않는다.

Derived myDerived;
Base& ref = myDerived;
ref.someMethod(); // Derived 버전의 someMethod()가 호출된다.

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

Derived myDerived;
Base assignedObject = myDerived; // Base 변수에 Derived 객체를 대입한다.
assignedObject.someMethod(); // Base 버전의 someMethod()를 호출한다.

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

Note) 파생 클래스에서 오버라이드한 메서드를 베이스 클래스 포인터나 레퍼런스로 참조할 때는 원래 상태를 유지한다. 파생 클래스 객체를 베이스 클래스 타입으로 캐스팅할 때만 파생 클래스 고유의 정보가 사라진다. 이렇게 파생 클래스의 데이터 멤버다 오버라이드된 메서드가 삭제되는 것을 슬라이싱(slicing)이라 한다.

override 키워드

(생략)

virtual 메서드

virtual로 선언하지 않음 메서드를 오버라이드하면 몇 가지 미묘한 문제가 발생한다. 따라서 오버라이드할 메서드는 항상 virtual로 선언하는 것이 좋다.

오버라이드하지 않고 숨기기

클래스 정의가 다음과 같다고 하자.

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

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

이 상태에서 Derived 객체의 go() 메서드를 호출해 보자.

Derived myDerived;
myDerived.go();

그러면 기대했던 대로 ‘go() called on Derived’가 출력된다. 하지만 이 메서드는 virtual로 선언되지 않았기 때문에 실제로 오버라이드 된 것이 아니라 Derived 클래스에 go() 란 이름을 갖는 메서드가 새로 생성된 것이다. 이 메서드는 Base 클래스의 go()와 전혀 다르다. 다음과 같이 Base 포인터 또는 레퍼런스로 이 메서드를 호출해 보면 정말 다른지 확인할 수 있다.

Derived myDerived;
Base& ref = myDerived;
ref.go();

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

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

virtual 메서드의 내부 작동 방식

C++에서 클래스를 컴파일하면 그 클래스의 모든 메서드를 담은 바이너리 객체가 생성된다. 그런데 컴파일러는 virtual로 선언되지 않은 메서드를 호출하는 부분을 컴파일 시간에 결정된 타입의 코드로 교체한다. 이를 정적 바인딩(static biding) 또는 이른 바인딩(early binding)이라 부른다.

메서드를 virtual로 선언하면 vtable(가상 테이블)이라 부르는 특수한 메모리 영역을 활용해서 가장 적합한 구현 코드를 호출한다. virtual 메서드가 하나 이상 정의된 클래스마다 vtable이 하나씩 있는데, 이 클래스로 생성한 객체마다 이 vtable에 대한 포인터를 갖게 된다.

virtual 메서드의 구현 코드에 대한 포인터는 바로 이 vtable에 담겨 있다. 그래서 객체에 대해 메서드를 호출하면 vtable을 보고 그 시점에 적합한 버전의 메서드를 실행한다. 이를 동적 바인딩(dynamic binding) 또는 늦은 바인딩(late binding)이라 부른다.

다음과 같이 정의된 Base와 Derived 클래스를 통해 vtable로 메서드 오버라이드를 처리하는 과정을 좀 더 구체적으로 살펴보자.

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

class Derived : public Base
{
public:
virtual 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 키워드가 필요한 이유

앞서 메서드를 모두 virtual 로 선언하는 것이 좋다고 했는데, 굳이 virtual 키워드가 따로 존재하는 이유가 궁금할 것이다. 어차피 모두 virtual 로 선언할 것이라면 컴파일러가 알아서 virtual로 선언하면 되기 때문이다. 실제로 자바에서는 그렇게 하고 있다.

하지만 모두 virtual로 처리하면 안 된다고 주장하는 사람도 있는데, 애초에 vtable에 대한 오버헤드를 줄이기 위해 virtual 키워드를 만들었기 때문이다. virtual 메서드를 호출하려면 가장 적합한 코드를 선택하는 과정에서 포인터를 역참조 해야 한다.

(이하 설명 생략)

virtual 소멸자의 필요성

메서드를 모두 virtual로 선언하는 방식에 반대하는 프로그래머도 소멸자만큼은 virtual로 선언해야 한다고 생각한다. 소멸자를 virtual로 선언하지 않으면 객체가 소멸할 때 메모리가 해제되지 않을 수 있기 때문이다. 클래스를 final로 선언할 때를 제외한 나머지 경우는 항상 소멸자를 virtual로 선언하는 것이 좋다.

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

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

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

class Derived : public Base
{
public:
Derived()
{
mString = new char[30];
cout << "mString allocated" << endl;
}

~Derived()
{
delete[] mString;
cout << "mString deallocated" << endl;
}

private:
char* mString;
};

int main()
{
Base* ptr = new Derived(); // mString이 여기서 할당된다.
delete ptr; // ~Base는 호출되지만 ~Derived는 호출되지 않는다. 소멸자를 virtual로 선언하지 않았기 때문.

return 0;
}

이 코드를 실행하면 Derived 객체의 소멸자가 호출되지 않은 것을 확인할 수 있다. 실제로 앞서 나온 코드에서 deelte를 호출하는 동작은 표준에 정의돼 있지 않고 C++ 컴파일러마다 나름대로 구현하고 있다. 그런데 대부분의 컴파일러는 파생 클래스의 소멸자가 아닌 베이스 클래스의 소멸자를 호출하도록 처리한다.

Note) 소멸자에서 처리할 일은 없고 virtual로만 지정하고 싶다면 다음과 같이 디폴트로 지정한다.

class Base
{
public:
virtual ~Basae() = default;
}

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

Caution) 특별한 이유가 없거나 클래스를 final로 선언하지 않았다면 소멸자를 포함한 모든 메서드를 virtual로 선언한다. 단, 생성자는 virtual로 선언할 수 없고 그럴 필요도 없다. 객체를 생성할 때 항상 정확한 클래스를 지정하기 때문이다.

오버라이딩 방지하기

메서드도 final로 지정할 수 있다. 메서드를 final로 지정하면 파생 클래스에서 오버라이드할 수 없다.

class Base
{
public:
virtual ~Base() = default;
virtual void someMethod() final;
}

(이하 생략)

코드 재사용을 위한 상속

(상속 예시 생략)

부모를 공경하라

부모 클래스의 생성자

C++는 객체 생성 과정을 다음과 같이 정의하고 있다.

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

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

아래 예시 코드를 실행하면 123이라는 결과가 나온다.

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

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

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

int main()
{
Derived myDerived;
return 0;
}

myDrived 객체가 생성되면 Base 생성자가 먼저 호출되면서 문자열 “1”을 출력한다. 다음으로 mDataMember가 초기화되면서 Something 생성자를 호출한다. 그러면 문자열 “2”를 출력한다. 마지막으로 Derived 생성자가 호출되면서 문자열 “3”을 출력한다.

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

예컨대 다음 코드는 디폴트 생성자 없이 Baser 클래스를 정의했다. 이를 상속한 Derived 는 반드시 컴파일러에 Base 생성자를 호출한느 방법을 알려줘야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

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

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

Derived::Derived() : Base(7)
{
// Derived의 다른 초기화를 여기서 실행한다.
}

여기 나온 Derived 생성자는 고정된 값(7)을 Base 생성자로 전달한다. Derived 클래스에서도 이처럼 생성자에서 인수를 받는다면 Base처럼 변수를 전달할 수 있다.

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

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

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

부모 클래스의 소멸자

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

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

이 규칙도 생성자와 마찬가지로 재귀적으로 적용된다. 상속 체인의 가장 하위 멤버를 먼저 삭제한다. 다음 코드를 실행하면 123321이 출력된다.

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

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

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

여기서 소멸자를 virtual로 선언하지 않아도 코드 실행에는 문제 없다. 하지만 파생 클래스를 가리키는 베이스 클래스 타입 포인터에 대해 delete를 호출하면 소멸자 실행 순서가 뒤바뀐다. 예컨대 앞에 나온 코드에서 소멸자 앞에 있는 virtual 키워드를 모두 삭제하고 다음과 같이 Derived 객체를 가리키는 Base 타입 포인터를 삭제하면 문제가 발생한다.

Base* ptr = new Derived();
delete ptr;

이 코드를 실행하면 놀랍게도 “1231”이란 결과가 나온다. ptr 변수를 삭제하면 Base 소멸자만 호출되는데, 이는 소멸자를 virtual 로 지정하지 않았기 때문이다. 결국 Derived 소멸자가 호출되지 않아 Derived의 데이터 멤버에 대한 소멸자도 호출되지 않았다.

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

부모 클래스 참조하기

(생략)

자식이 부모의 메서드를 호출하려면 스코드 지정 연산자를 이용한다.

class WeatherPrediction
{
public:
virtual std::string getTemperature() const;
}

class MyWeatherPrediction : public WetherPrediction
{
public:
virtual std::string getTemperature() const override;
}

string MyWeatherPrediction::getTemperature() const
{
// 부모 메서드를 호출한 뒤 단위 추가
return WetherPrediction::getTemperature() + "\u00B0F";
}

Note) MS 비주얼 C++에서 제공하는 __super(언더스코어가 2개임) 키워드를 사용해서도 작성할 수 있다. 

return __super::getTemparature() + "\u00B0F";

(이하 생략)

업캐스팅과 다운캐스팅

앞서 본 것처럼 객체를 부모 클래스 타입으로 캐스팅하거나 대입할 수 있다. 기존 객체를 캐스팅이나 대입하면 자식 클래스의 특성이 사라지는 슬라이싱이 발생한다.

Base myBase = myDerived; // 슬라이싱이 발생한다.

하지만 파생 클래스 타입의 객체를 베이스 클래스 타입의 포인터나 레퍼런스에 대입할 때는 슬라이싱이 발생하지 않는다.

Base& myBase = myDerived; // 슬라이싱이 발생하지 않는다.

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

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

간혹 다운캐스팅이 필효한 경우가 있다면 dynamic_cast()를 사용할 수 있다. 이 함수는 객체 내부에 저장된 타입 정보를 보고 캐스팅이 잘못됐다면 처리하지 않는다. 이런 타입 정보는 vtable에 담겨 있기 때문에 dynamic_cast()는 vtable이 있는, 다시 말해 virtual 멤버가 하나라도 있는 객체에만 적용할 수 있다. 

포인터 변수에 대해 dynamic_cast()가 실패하면 포인터의 값이 임의의 객체가 아닌 nullptr이 된다. 또 객체 레퍼런스에 대해 dynamic_cast()가 실패하면 std::bad_cast 익셉션이 발생한다.

void lessPresumptuous(Base* base)
{
Derived* myDerived = dynamic_cast<Derived*>(base);

if (myDerived != nullptr)
{
// myDerived로 Derived의 메서드에 접근하는 코드를 작성한다.
}
}

(이하 설명 생략)

다형성을 위한 상속

스프레드시트 예제

(생략)

다형성을 지원하는 스프레드시트 셀 디자인하기

(생략)

SpreadsheetCell 베이스 클래스

첫 번째 버전

(생략)

순수 가상 메서드와 추상 베이스 클래스

순수 가상 메서드(pure virtual method)란 클래스 정의 코드를 명시적으로 정의하지 않는 메서드다. 메서드를 순수 가상 메서드로 만들면 컴파일러는 이 메서드에 대한 정의가 현재 클래스에 없다고 판단한다.

순수 가상 메서드가 최소한 하나라도 정의된 클래스를 추상 클래스(abstract class)라 한다. 추상 클래스에 대해서는 인스턴스를 만들 수 없다. 클래스에 순수 가상 메서드가 한 개 이상 있으면 컴파일러는 이 클래스가 객체를 생성하는데 사용되지 않는다고 판단한다.

순수 가상 메서드를 지정하려면 메서드 선언 뒤에 =0을 붙인다. 그리고 구현 코드는 작성하지 않ㅎ는다.

class SpreadsheetCell
{
public:
virtual ~SpreadsheetCell() = default;
virtual void set(std::string_view inString) = 0;
virtual std::string getString() const = 0;
}

이렇게 하면 베이스 클래스인 SpreadsheetCell은 추상 클래스가 되기 때문에 SpreadsheetCell 객체를 생성할 수 없다. 

(C++은 명시적으로 인터페이스 뿐만 아니라 abstract keyword도 지원하지 않는다. 그래서 불편한 방식으로 인터페이스와 추상 클래스를 구현함)

파생 클래스 구현하기

베이스 클래스에서 순수 가상으로 정의한 메서드 중 파생 클래스에서 구현하지 않은 것이 하나라도 있으면 파생 클래스도 추상클래스가 되기 때문에 부모 클래스로부터 받은 순수 가상 메서드를 하나도 빠짐 없이 구현해야 한다.

(생략)

다형성 최대로 활용하기

(생략)

나중에 대비하기

객체지향 디자인 관점에서 볼 때 SpreadsheetCell을 계층화해서 구현하는 것이 좋다. 그래도 (예시 코드) 여전히 실전에서 사용하기 부족한 기능들이 있는데 소개하면 다음과 같다.

첫째, 셀 타입끼리 상호 변환하는 기능이 빠졌다. 타입을 변환하는 기능을 별도로 제공해야하는데 DoubleSpreadsheetCell을 StringSpreadsheetCell 타입으로 변환하는 기능은 변환 생성자(converting constructor, 또는 타입 생성(typed constructor)를 추가하는 방식으로 구현할 수 있다.

이는 복제 생성자와 비슷하지만 동일한 클래스가 아니라 형제 클래스 객체에 대한 레퍼런스를 인수로 받는다. 이렇게 하면 디폴트 생성자를 반드시 선언해야 한다. 명시적으로 디폴트로 지정해도 된다. 생성자를 직접 작성하면 컴파일러가 더는 자동으로 만들어주지 않기 때문이다.

class StringSpreadsheetCell : public SpreadsheetCell
{
public:
StringSpreadsheetCell() = default;
StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell);
// 코드 생략
}

이렇게 선언한 변환 생성자의 구현 코드는 다음과 같다.

StringSpreadsheetCell::StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell)
{
mValue = inDoubleCell.getString();
}

변환 생성자를 이용하면 DoubleSpreadsheetCell을 이용해서 StringSpreadsheetCell 객체를 생성하기 쉬워진다. 하지만 포인터나 레퍼런스를 캐스팅할 때와는 방식이 다르다는 점에 주의한다. cast 연산자를 오버로드하지 않으면 포인터나 레퍼런스 타입을 형제 클래스끼리 캐스팅할 수 없다.

둘째, 셀마다 연산자를 오버로드해야 한다. 구현 방식은 여러 가지가 있다. 모든 셀 타입 조합에 대해 연산자를 일일이 구현하는 것이다.

(설명 생략)

다중 상속

여러 클래스 상속하기

다중 상속을 정의하는 방법은 간단하다. 클래스 이름 옆에 상속할 베이스 클래스를 나열하기만 하면 된다.

class Baz : public Foo, public Bar
{
// 코드 생략
}

(C#이나 자바 등은 죽음의 다이아몬드 문제 때문에 다중 상속을 허용하지 않는다. 대신 인터페이스를 다중으로 사용하는 것은 허용됨. 아마도 C++은 인터페이스가 명시적으로 없기 때문에 다중 상속을 지원하는게 아닐까 싶다. 여튼 나는 부정적인 입장이기도 하고 개념적으로 특별한게 없으므로 이하 내용 생략)

이름 충돌과 모호한 베이스 클래스

(생략)

상속에 관련된 미묘하면서 흥미로운 문제들

오버라이드한 메서드의 속성 변경하기

대부분 메서드의 구현을 변경하기 위해 오버라이드한다. 하지만 간혹 메서드를 오버라이드하는 과정에서 원래 메서드의 속성도 함꼐 변경하고 싶을 때가 많다.

리턴 타입 변경하기

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

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

예컨대 체리 과수원 시뮬레이터가 있다고 하자. 여기서 클래스 계층을 두 개로 구성하는데, 첫 번째 계층은 Cherry 베이스 클래스와 BingCherry 파생 클래스를 구성하고 두 번째 계층은 CherryTree 베이스 클래스와 BingCherryTree 파생 클래스로 구성한다.

이제 CherryTree 클래스에 체리 하나를 따는 pick()이라는 가상 메서드를 정의한다.

Cherry* CherryTree::pick()
{
return new Cherry();
}

이제 파생 클래스인 BingCherryTree에서 이 메서드를 오버라이드해보자. 예컨대 BingCherry를 딸 때 깨끗이 닦는 동작을 추가할 수 있다. BingCherry도 일종의 Cherry이기 때문에 메서드 프로토타입은 그대로 유지하고 메서드의 구현 코드만 다음과 같이 수정한다. BingCherry 포인터는 Cherry 포인터로 자동으로 캐스팅된다. 

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 subsitiution principle, LSP)라 부른다. 앞서 나온 코드에서 리턴 타입을 변경해도 문제가 발생하지 않은 이유는 모든 코드에서 pick() 메서드가 항상 Cherry*를 리턴한다고 가정한 코드는 모두 문제 없이 컴파일되고 실행되기 때문이다. BingCherry도 일종의 Cherry이기 때문에 CherryTree 버전의 pick()이 리턴한 값에 대해 호출할 수 있는 메서드는 모두 BingCherryTree 버전의 pick()이 리턴한 값에 대해서도 호출할 수 있다.

하지만 리턴 타입을 void*와 같이 전혀 관련 없는 타입으로는 변경할 수 없다. 또한 위 코드는 스마트 포인터가 아닌 일반 포인터를 사용했는데, 따라서 리턴 타입을 std::unique_ptr로 지정하면 작동하지 않는다.

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

그러면 BingCherryTree::pick() 메서드의 리턴 타입을 unique_ptr<BingCherry>로 변경할 수 없다. 다시 말해 아래와 같이 작성하면 컴파일 에러가 발생한다.

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

그 이유는 std::unique_ptr가 클래스 템플릿이기 때문이다. 

메서드 매개변수 변경하기

파생 클래스를 정의하는 코드에서 virtual 메서드를 선언할 때 이름은 부모 클래스에 있는 것과 똑같이 쓰고 매개변수만 다르게 지정하면 부모 클래스의 메서드가 오버라이드 되는 것이 아니라 새로운 메서드가 정의된다.

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

class Derived : public Base
{
public:
virtual void someMethod(int i); // 컴파일 에러는 없지만 오버라이드되지 않는다.
virtual void someOtherMethod();
};

이렇게 정의하면 에러는 없지만 someMethod()가 오버라이드 되지 않는다. 인수가 다르기 때문에 Derived에만 존재하는 새 메서드가 생성된다. 

C++ 표준에 따르면 Derived 클래스에서 메서드를 이렇게 정의하면 부모 클래스에 있던 메서드를 가린다. 따라서 다음과 같이 작성하면 인수를 받지 않는 someMethod()가 없기 때문에 컴파일 에러가 발생한다.

Derived myDerived;
myDerived.someMethod(); // 원본 메서드가 가려졌기 때문에 컴파일 에러가 발생한다.

Base에 있는 someMethod()를 오버라이드하려 했다면 메서드를 이렇게 작성할 때 override 키워드를 붙여야 한다. 그래야 이 메서드를 오버라이드하는 코드에 잘못된 부분이 있으면 컴파일러가 에러 메시지를 출력한다.

그런데 파생 클래스에서 프로토타입이 달라져도 실질적으로 클래스 버전을 상속하는 효과를 낼 수 있는 특이한 테크닉이 있다. 방법은 다음과 같이 파생 클래스에서 베이스 클래스 메서드를 정의하고 그 앞에 using 키워드를 지정하면 된다.

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

class Derived : public Base
{
public:
using Base::someMethod; // Base 버전을 명시적으로 상속한다.
virtual void someMethod(int i); // 새 버전을 추가한다.
virtual void someOtherMethod();
};

생성자 상속

앞서 using 키워드로 베이스 클래스에 저으이된 메서드를 파생 클래스에 명시적으로 지정하는 방법을 소개했다. 그런데 이 기법은 일반 클래스 메서드 뿐만 아니라 생성자에도 적용할 수 있다. 그러면 베이스 클래스의 생성자도 상속할 수 있다.

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

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

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

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

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

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

Derived derived1(1);  // int 인수를 받는 Derived 생성자 호출
Derived derived2("Hello");  // string_view 인수를 받는 Base 생성자 호출

여기서 Derived 클래스는 Base에서 상속한 생성자 중에서 매개변수 리스트가 똑같은 생성자를 정의할 수 있다. 오버라이드할 때와 마찬가지로 Derived 클래스 생성자의 우선순위가 Base의 생성자보다 높다.

다음 예제는 Derived 클래스에서 using 키워드를 이용해서 디폴트 생성자를 제외한 Base 클래스의 모든 생성자를 상속한다. 그런데 Derived 클래스에 float 타입의 매개변수 하나만 받는 생성자를 정의했다. 그러므로 Base 클래스에 있는 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); // float 버전의 Base 생성자를 오버라이드 한다.
};

이렇게 정의하면 Derived 객체를 다음과 같이 두 가지 방식으로 생성할 수 있다.

Derived derived1("Hello");  // Base의 string_view 버전 생성자가 호출된다.
Derived derived2(1.23f);  // Derived의 float 버전 생성자가 호출된다.

using 구문으로 베이스 클래스의 생성자를 상속할 때 몇 가지 제약사항이 있다. 베이스 클래스의 생성자를 상속하면 디폴트 생성자를 제외한 모든 생성자를 한 번에 상속한다. 베이스 클래스 생성자 중 일부만 상속할 수는 없다. 또한 다중 상속과 관련된 제약사항도 있다. 여러 베이스 클래스에서 매개변수 목록이 똑같은 생성자는 상속할 수 없다. 어느 부모에 있는 것을 호출할지 알 수 없기 때문이다.

(이하 다중 상속 관련 예시 생략)

메서드 오버라이딩의 특수한 경우

베이스 클래스가 static인 경우

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

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

(이하 설명 생략)

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

베이스 클래스에 다양ㅎ나 버전으로 오버로드된 메서드가 여러 개 있는데 그 중 한 버전만 오버라이드 하면 컴파일러는 베이스 클래스에 있는 다른 버전의 메서드도 함께 가려버린다. 이렇게 하는 이유는 컴파일러 입장에서 볼 때 어느 한 버전만 오버라이드 했다는 것은 원래 같은 이름을 가진 모든 메서드를 오버라이드하려다가 깜빡 잊고 하나만 적었다고 판단하기 때문이다. 그래서 이대로 놔두면 에러가 발생할 수 있으므로 나머지 메서드를 모두 가려주는 것이다.

다음 코드를 보다. 여기서 Derived 클래스는 Base 클래스의 오버로드된 여러 메서드 중에서 하나만 오버라이드했다.

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

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

이렇게 정의된 상태에서 Derived 객체로 int 버전의 overload() 메서드를 호출하면 컴파일 에러가 발생한다. 그런데 Derived 객체에서 이 버전의 메서드에 접근할 방법은 있다. Derived 객체를 가리킬 변수를 Base 포인터나 Base 레퍼런스로 만들면 된다.

Derived myDerived;
Base& ref = myDerived;
ref.overload(7);

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

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

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

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

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

private나 protected로 선언된 베이스 클래스 메서드

private이나 protected 메서드도 얼마든지 오버라이드할 수 있다. 메서드에 대한 접근 지정자는 그 메서드를 호출할 수 있는 대상만 제한할 뿐이다. 파생 클래스에서 부모 클래스의 private 메서드를 호출할 수 없다고 해서 오버라이드도 할 수 없는 것은 아니다.

실제로 private이나 protected 메서드를 오버라이드 하는 사례를 C++ 코드에서 굉장히 자주 볼 수 있다. 반면 자바와 C#은 public, protected 메서드는 오버라이드 할 수 있고, private 메서드는 오버라이드 할 수 없다.

(설명 생략)

베이스 클래스 메서드에 디폴트 인수가 지정된 경우

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

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

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

Derived 객체를 통해 go()를 호출하면 Derived에 정의된 버전의 go() 메서드가 실행되고 디폴트 인수는 7이 적용된다. 또한 Base 객체를 통해 go()를 호출하면 Base 버전의 go()가 호출되고 디폴트 인수는 2가 적용된다. 

그런데 특이하게도 실제로는 Derived 객체를 가리키지만 Base 타입이나 Base 레퍼런스로 선언된 변수로 go()를 호출하면 Derived 버전의 go() 코드가 실행되지만 디폴트 인수는 Base에 지정된 2가 적용된다.

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

Note) 디폴트 인수가 지정된 메서드를 오버라이드할 때는 파생 클래스에서도 반드시 디폴트 인수를 지정하고 기왕이면 값도 똑같이 지정하는 것이 좋다. 이때 디폴트값을 기호 상수(symbolic constant)로 표현하는 것이 좋다. 그러면 파생 클래스에서도 똑같은 기호 상수로 표현할 수 있다.

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

메서드를 오버라이드 할 때 접근 권한을 넓히거나 좁힐 수 있다. 흔치 않지만 C++ 프로그래밍시 이렇게 해야 할 때가 있다.

메서드나 데이터 멤버에 대한 접근 권한을 좀 더 제한하는 방법은 두 가지가 있다. 하나는 베이스 클래스 전체에 대한 접근 지정자를 변경하는 것이다. 이에 대해서는 뒤에서 설명하겠다. 또 달느 방법은 아래 코드와 같이 Shy 클래스처럼 파생 클래스에서 접근자를 다르게 지정하는 것이다.

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

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

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

그런데 이 메서드의 접근 범위는 protected로 완벽하게 변경되지 않았는데 Gregarious 타입의 레퍼런스나 포인터를 이용하면 얼마든지 이 메서드에 접근할 수 있다.

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

파생 클래스에서 접근 범위를 좁히는 것보다 넓히는 것이 더 쉽다. 사실 그게 더 자연스럽다.

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

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

물론 위와 같이 한다고 해서 dontTell() 메서드의 접근 범위가 변경되는 것은 아니다. 다만 이 메서드에 접근하는 경로만 제공하는 것이다.

또 다른 방법은 메서드를 오버라이드하면서 접근 범위를 public으로 변경하는 방법이 있다. 접근 범위를 축소하는 것보다 훨씬 현실적이다. 베이스 클래스에 대한 레퍼런스나 포인터를 사용할 때 어떻게 처리될지 명확히 드러나기 때문이다.

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

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

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

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

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

파생 클래스에 포인터 같은 특수한 데이터(주로 포인터)가 있어서 디폴트가 아닌 복제 생성자나 대입 연산자를 저읭해야 할 경우가 아니라면 베이스 클래스에 복제 생성자나 대입 연산자가 있더라도 파생 클래스에서 다시 정의할 필요가 없다.

파생 클래스에서 복제 생성자나 대입 연산자를 정의하는 코드를 생략하면 파생 클래스의 데이터 멤버에 대한 디폴트 생성자나 대입 연산자가 생성되고, 베이스 클래스의 데이터 멤버에 대해서는 베이스 클래스의 복제 생성자나 대입 연산자가 적용된다.

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

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

Base::Base(const Base& src)
{
}

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

Derived::Derived(const Derived& src) : Base(src)
{
}

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

Derived& Derived::operator=(const Derived& rhs)
{
if (&rhs == this)
{
return *this;
}

Base::operator=(rhs); // 부모의 대입 연산자를 호출한다.
// 대입 연산 수행 코드 생략
return *this;
}

실행 시간 타입 정보

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

물론 C++도 실행 시간에 객체를 들여다보는 기능을 제공한다. 이를 한데 묶어 RTTI(Run-Time Type Information, 실행 시간 타입 정보)라 부른다. RTTI는 객체가 속한 클래스 정보를 다루는데 필요한 기능을 다양하게 제공한다. 대표적인 예로 dynamic_cast()가 있다. 

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

#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))
{
cout << "Woof!" << endl;
}
else if (typeid(animal) == typeid(Bird))
{
cout << "Chirp!" << endl;
}
}

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;
};

std::string Foo::getLogMessage() const
{
return "Hello logger";
}

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

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

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

non-public 클래스 상속

부모를 private이나 protected로 지정할 수는 없을까? 가능하긴 하지만 public 만큼 흔하지 않다. 부모에 대한 접근 범위를 일괄적으로 축소하는데는 다양한 이유가 있겠지만 대부분 상속 관계를 잘못 디자인했기 때문이다.

(이하 설명 생략)

가상 베이스 클래스

다중 상속을 설명하면서 동일한 클래스를 상속하는 부모 클래스를 여러 개 상속하면 모호함이 발생한다고 설명했다. 그런데 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() { cout << "zzzzz..." << endl; }
};

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

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

class DogBird : public Dog, public Bird
{
public:
virtual void eat() override { Dog::eat(); }
}

int main()
{
DogBird myConfusedAnimal;
myConfusedAnimal.Sleep(); // 베이스를 virtual로 선언해서 모호함이 발생하지 않음
return 0;
}

Note) 클래스 계층의 모호함을 방지하기 위한 좋은 방법은 가상 베이스 클래스를 사용하는 것이다. 유일한 단점은 이 개념을 모르는 C++ 프로그래머가 많다는 것이다.

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.