Post

[Effective C++] 6. 상속, 그리고 객체 지향 설계 [1/4]

[Effective C++] 6. 상속, 그리고 객체 지향 설계 [1/4]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.


개요


객체 지향 프로그래밍이 이 바닥에서 큰 유행을 일으키며 주름잡아 온 지도 어언 20년 가까이 된 만큼, 아마도 우리 중 대부분은 상속이니, 파생이니, 가상 함수니 하는 것들을 웬만큼 경험해 보았을 것이다. 한평생 C만 가지고 프로그래밍을 해왔더라도, OOP와 담 쌓았을 사람은 아마 없을 것이다.

그럼에도, C++의 OOP는 우리가 익히 알고들 있는 OOP보다 조금 더 생각할 부분이 많다는 점이 이번 장을 준비한 이유이다. 상속은 단일 상속과 다중 상속이 가능하고, 상속 관계 하나하나가 public, protected, private의 성질을 가질 수 있다. 그뿐 아니라 여기에 가상 상속과 비가상 상속이 얹힐 수 있다. 멤버 함수는 또 어떤가. 가상 함수? 비가상 함수? 순수 가상 함수? 그리고 C++가 지원하는 다른 기능들과 이들의 상호관계도 생각하지 않으면 안 된다. 기본 매개변수는 가상 함수와 어떻게 맞물려 돌아갈까? 상속은 C++의 이름 탐색 규칙에 어떤 영향을 줄까? 또 설계 시의 선택사항은 어떤 식으로 바뀔까? 어떤 클래스의 동작 원리를 외부에서 수정할 수 있어야 한다고 결정했을 때, 과연 가상 함수가 최선의 방법일까?

이 장에서는 이런저런 이야깃거리를 모두 정리해 보겠다. 여기에 덧붙여, ‘조금 더 생각할 부분이 많은’ C++의 OOP 개념이 실제로 어떤 의미를 지니는가, 다시 말해 C++의 OOP 관련 기능 하나하나를 코드에 적용할 때 무엇을 표현해야 정확하게 쓰는 것인지를 이야기해 볼까 한다. 이를테면, public 상속은 반드시 “is-a” 관계를 뜻해야 하며, 이 외의 의미를 붙이려고 하면 난처해질 수도 있다. 또 다른 예로, 가상 함수의 의미는 “인터페이스가 상속되어야 한다”인 반면, 비가상 함수의 의미는 “인터페이스와 구현이 둘 다 상속되어야 한다”이다. 이것을 정확히 구분하지 못해서 힘들어하는 프로그래머들이 정말 많다.

객체 지향 프로그래밍에 있어서 C++가 갖고 있는 이런저런 특징들의 의미를 이해하고 나면, 일단 OOP를 보는 우리의 눈이 바뀌는 기분이 들 것이다. 우리의 바뀐 눈은 언어에서 제공하는 객체 지향 관련 특징들을 좀더 잘 구분하는 데만 써먹히고 끝나는 것이 아니라, 우리가 지금 만들고 있는 소프트웨어 시스템에 대해 우리가 말하고 싶은 바를 결정하는 문제에까지 유쾌한 영향을 줄 것이다. 뭐니 뭐니 해도, 우리가 무엇을 말하고 싶은 가를 몸으로 파악하는 것이 먼저이다. 이게 되어 있으면, 그것을 C++로 옮기는 것은 그다지 허덕거릴 일도 아니다.


항목 32 : public 상속 모형은 “is-a(…는 …의 일종이다)를 따르도록 만들자”


C++로 객체 지향 프로그래밍을 하면서 다른 건 잊더라도 꼭 잊지 말아야 할 중요한 규칙이 딱 하나 있다. 바로 public 상속은 “is-a“를 의미한다는 이야기이다. 이 규칙을 우리의 두뇌에 박아넣자.

우리가 클래스 D(Derived)를 클래스 B(Base)로부터 public 상속을 통해 파생시켰다면, 우리는 C++ 컴파일러에게 이렇게 만한 것과 똑같다. D 타입으로 만들어진 모든 객체는 또한 B 타입의 객체이지만, 그 반대는 되지 않는다. 다시 말해 B는 D보다 더 일반적인 개념을 나타내며, D는 B보다 더 특수한 개념을 나타낸다고 알리는 것이다. 그러니까, B 타입의 객체가 쓰일 수 있는 곳에는 D 타입의 객체도 마찬가지로 쓰일 수 있다고 단정하는 것이다. D 타입의 모든 객체는 B 타입의 객체도 되니까이다. 반면, D 타입이 필요한 부분에 B 타입의 객체를 쓰는 것은 불가능하다. 모든 D는 B의 일종이지만(D is a B), B는 D의 일종이 아니기 때문이다.

C++는 public 상속을 이렇게 해석하도록 문법적으로 지원하고 있다. 아래의 예제를 한번 보자.

1
2
3
class Person { ... };

class Student : public Person { ... };

모든 학생들은 사람이지만 모든 사람은 학생이 아니라는 사실은 일상적인 경험을 통해 모두 알고 있다. 위의 클래스 계통이 말해 주는 바 그대로다. 사람에 해당되는 사실은 어떤 것이든 학생에게도 해당된다고 예상할 수 있는 것이다. 하지만 학생에 해당되는 모든 것들이 일반적인 사람에게도 해당될 거라고 기대하지는 않는다. ‘사람’은 ‘학생’보다 더 일반적인 개념이다. 학생은 사람을 더 특수하게 만든 한 종류이다.

이제 C++의 땅에 발을 디뎌 보자. Person 타입의 인자를 기대하는 함수는 Student 객체 역시도 받아들일 수 있다. 다음을 보자.

1
2
3
4
5
6
7
8
9
10
11
12
void eat(const Person& p);

void study(const Student& s);

Person p;
Student s;

eat(p); // OK
eat(s); // OK

study(s); // OK
study(p); // ERROR

이 이야기는 public 상속에서만 통한다. 필자가 설명한 대로 C++가 동작하려면 Student가 Person과 public 상속 관계에 있어야 한다. private 상속은 의미 자체가 완전히 다르고(항목 39 참조), protected 상속은 요즘에도 의미가 아리아리하다.

public 상속과 is-a 관계까 똑같은 뜻이라는 이야기는 꽤 직관적이고 간단하긴 하지만, 그 직관 때문에 판단을 잘못하는 경우도 있다. 예를 하나 보자. 펭귄이 새의 일종이라는 점은 누구나 아는 사실이고, 새라는 개념만 보면 새가 날 수 있다는 점도 사실이다. 이것을 순박하게 C++로 표현해 보려고 하면 아래와 비슷한 코드가 나올 것이다.

1
2
3
4
5
6
7
8
9
10
class Bird
{
public:
    virtual void fly();
    ...
};

class Penguin : public Bird
{
};

하지만 뭔가 이상해진 느낌이 든다. 위의 클래스 계통에 의하면 펭귄은 날 수 있다. 하지만 이것은 맞지 않다. 무슨 일일까?

지금 우리는 명확하지 않은 자연어, 즉 사람의 말에 낚인 것이다. “새는 날 수 있다”라고 말할 당시, 모든 종류의 새가 날 수 있다는 의미를 품고 말한 것은 아니었다. 그저 자체 비행 능력을 가진 동물이 새이니까 그렇게 말했을 뿐이다. 말을 더 명확히 했다면 날지 않는 새 종류도 있다는 점도 구분할 수 있었을 것이다. 즉, 다음과 같이 조금 더 현실에 가까운 클래스 계통구조를 뽑을 수 있었을 것이란 말이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bird
{
...
};

class FlyingBird : public Bird
{
public:
    virtual void fly();
    ...
};

class Penguin : public Bird
{
...
};

보다시피, 처음에 했던 설계보다 우리가 알고 있는 현실에 더욱 충실한 클래스 구조가 되었다.

그렇다고 해서 이 날개 달린 동물 이야기가 이것으로 끝난 것은 아니다. 어떤 소프트웨어 시스템의 경우엔 비행 능력이 있는 새와 없는 새를 구분하지 않아도 될 수 있기 때문이다. 새의 부리와 날개에만 관심이 있고 비행 가지고 도통 할 일이 없는 응용프로그램을 만든다면, 두 개의 클래스로 구성된 처음의 계통이 훨씬 만족스러울 것이다. 이 점은 모든 소프트웨어에 이상적인 설계 같은 것은 없다는 사실을 간단히 반증하는 예라고 할 수 있겠다. 무릇 최고의 설계는, 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 달라지는 것이다. 오늘날도 그렇고 미래에도 마찬가지이다. 우리가 지금 만드는 응용프로그램이 비행에 대한 지식을 전혀 쓰지 않으며 나중에도 쓸 일이 없을 것이라면, 날 수 있는 새와 날지 않는 새를 구분하지 않는 것이 탁월한 선택일 수도 있다. 사실 이런 것들을 잘 구분해서 설계하는 쪽이 바람직하기도 하다. 나는 새도 있고 못 나는 새도 있다는 사실은 우리가 본뜨려는 세계가 어떤 것이냐에 따라 고려해도 되고 안 해도 되니까 말이다.

이 문제에 대해 또 다른 대체 방법을 생각하는 사람도 물론 있다. 그 방법이란, 펭귄의 fly 함수를 재정의해서 런타임 에러를 내도록 하자는 거다.

1
2
3
4
5
6
7
8
void error(const std::string& msg); // 어딘가에 정의

class Penguin : public Bird
{
public:
    virtual void fly() { error("Attempt to make a penguin fly"); }
    ...
};

눈을 크게 뜨고 보아야 한다. 위의 코드가 말하는 바는 우리가 생각하고 있는 것과 조금 다르기 때문이다. 이 경우는 “펭귄은 날 수 없다”가 아니다. “펭귄은 날 수 있다. 그러나 펭귄이 실제로 날려고 하면 에러가 난다”라고 말하는 것이다.

어떻게 그 차이를 알아챌 수 있을까? 에러가 발견될 때를 보면 된다. “펭귄은 날 수 없다”라는 지령은 컴파일러가 내릴 수 있지만, “펭귄이 실제로 날려고 하면 에러가 난다”라는 규칙을 위반하는 것은 프로그램이 실행될 때만 발견할 수 있다.

그럼 “펭귄은 날 수 없다. 도장 꽝”이라는 제약사항도 같이 넣어 보자. Penguin 객체에 대해서는 비행과 관련된 함수를 정의하지 않도록 하면 된다.

1
2
3
4
5
6
7
8
9
class Bird
{
... // fly 함수 선언 x
};

class Penguin : public Bird
{
... // fly 함수 선언 x
};

이제 우리가 펭귄을 날려 보려고 하면, 컴파일 단계에서 컴파일러가 에러를 낼 것이다.

1
2
Penguin p;
p.fly(); // ERROR

런타임 에러를 내주는 방법을 썼을 때 생기는 일과 꽤나 다른 모습이다. 이 방법을 쓰면 컴파일러는 p.fly를 호출하는 것에 대해 한 마디도 하지 않는다. 항목 18에서 이야기했듯이, 유효하지 않은 코드를 컴파일 단계에서 막아 주는 인터페이스가 좋은 인터페이스이다. 즉, 펭귄의 무모한 비행을 컴파일 단계에서 거부하는 설계가 그것을 런타임에 뒤늦게 알아채는 설계보다 훨씬 좋다는 말이다.

그렇다면 정사각형 Square 클래스는 직사각형 Rectangle 클래스부터 상속을 받아야 할까? 뭔가 당연할 거 같다. 정사각형이 직사각형의 일종이고, 그 반대는 대개 아니라는 건 누구나 다 안다. 아래의 코드를 잘 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle
{
public:
    virtual void setHeight(int newHeight);
    virtual void SetWidth(int newWidth);
    
    virtual int height() const;
    virtual int width() const;
    ...
};

void makeBigger(Rectangle& r)
{
    int oldHeight = r.height();
    r.setHeight(r.width() + 10);
    assert(r.height() == oldHeight); // r의 세로 길이가 변하지 않는다는 조건에 단정문

여기서 위의 단정문이 실패할 일이 없다는 것은 확실하다. makeBigger 함수는 r의 가로 길이만 변경할 뿐이고, 세로 길이는 바뀌지 않는다.

이제 아래의 코드를 봐 보자. public 상속을 써서 정사각형을 직사각형처럼 처리하게끔 허용한느 코드이다.

1
2
3
4
5
6
7
8
class Square : public Rectangle {...};

Square s;
...
assert(s.width() == s.height()); // 이 단정문은 모든 정사각형에 대해 참이어야 한다.

makeBigger(s); // 상속된 것이므로, s는 Rectangle의 일종이다. 즉, s의 넓이를 늘릴 수 있다.
assert(s.width() == s.height()); // 이번에도 이 단정문이 모든 정사각형에 대해 참이어야 한다.

당연히 두 번째 단정문도 실패해서는 안 된다. 정사각형의 정의가 그렇듯, 정사각형의 가로 길이는 세로 길이와 같으니까!

그런데 문제가 생겼다. 뭔가 조정이 필요한 단정문들이 한두 개가 아니지 않는?

  • makeBigger 함수를 호출하기 전에, s의 세로 길이는 가로 길이와 같아야 한다.
  • makeBigger 함수가 실행되는 중에, s의 가로 길이는 변하는데 세로 길이는 안 변해야 한다.
  • makeBigger 함수에서 복귀한 후에, s의 세로 길이는 역시 가로 길이와 같아야 한다.

이로써 우리는 이상하고 아름다운 public 상속 나라를 체험하는 것이다. 이 나라는 다른 학문 분야에서까지 발휘된 우리의 육감이 우리 예상대로 움직여 주지 않는 나라이다. 지금의 상황에서 우리들의 발목을 잡고 있는 것은, 직사각형의 성질(가로 길이가 세로 길이에 상관없이 바뀔 수 있다) 중 어떤 것은 정사각형(가로 길이와 세로 길이가 같아야 한다)에 적용할 수 없다는 점이다. 그러니 public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에 그대로 적용된다고 단정하는 상속이다. 그런데 직사각형과 정사각형의 경우를 보면 이런 단정이 참이 될 수 없다. 따라서 이 둘의 관계를 public 상속을 써서 표현하려고 하면 틀리는 것이 당연하다. 컴파일러 수준에서는 문법적 하자가 없기 때문에 이런 코드가 무사히 통과된다. 하지만 위이 코드에서 살펴보았듯이 무사통과된 코드가 제대로 동작할 것이라는 보장은 없다. 코드가 컴파일된다는 것이 제대로 동작한다는 의미는 아니다.

모처럼 새로 배운 객체 지향 설계를 한 번 써먹어 봤는데 몇 년 동안 소프트웨어를 개발해 오면서 쌓아 온 우리의 직관적인 통찰력이 통하지 않는다고 해서 슬퍼하지 말자. 결코 헛공부가 아니다. 다만 우리는 우리가 갈고 닦아 머리속에 넣어둔 설계 방법들에 “상속”이란 것을 지금 막 더한 것이고, 이제는 상속을 적절히 응용할 수 있도록 도와줄 새로운 안목을 우리의 직관적인 통찰력에 붙여 갈 순서이다.

클래스 사이에 맺을 수 있는 관계로 is-a 관계만 있는 것은 아니다. 두 가지가 더 있는데, 하나는 “has-a(…는 …를 가짐)”이고, 또 하나는 “is-implemented-in-terms-of(…는 …를 써서 구현됨)”이다. 이 두 가지는 항목 38 및 항목 39에서 보게 될 것이다. C++ 코드를 보다 보면 is-a 이외의 나머지 두 관계를 is-a 관계로 모형화해서 설계가 이상하게 꼬이는 경우가 정말 많다. 그러니까 클래스 사이에 맺을 수 있는 관계들을 명확하게 구분할 수 있도로 하고, 이 각각을 C++로 가장 잘 표현하는 방법도 공부해 두자.

public 상속의 의미는 is-a이다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 한다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문이다.


항목 33 : 상속된 이름을 숨기는 일은 피하자


C++ 코드에서 볼 수 있는 ‘상속된 이름’이 이번 항목의 주제이다. 상속이란 이름을 달고 이번 항목을 시작했지만, 사실 상속과는 별 관계가 없다. 진짜 관계가 있는 것은 유효범위(scope)이다. 우선 다음의 코드를 보면서 시작하도록 하자.

1
2
3
4
5
6
7
int x; // 전역 변수

void someFunc()
{
    double x; // 지역 변수
    std::cin >> x; // 지역 변수 x에 새 값을 읽어 넣는다.
}

값을 읽어 x에 넣는 위의 문장에서 실제로 참조하는 x는 전역 변수가 아니라 지역 변수 x이다. 이유는 안쪽 유효범위에 있는 이름이 바깥쪽 유효범위에 있는 이름을 가리기 떄문이다.

컴파일러가 someFunc의 유효범위 안에서 x라는 이름을 만나면, 일단 그 컴파일러는 자신이 처리하고 있는 유효범위, 즉 지역 유효범위(local scope)를 뒤져서 같은 이름을 가진 것이 있는가를 알아본다. 위에서 본 대로 x라는 이름이 바로 있기 때문에, 이 외의 유효범위에 대해서는 더 이상 탐색하지 않는다. 지금의 경우 someFunc에 있는 x의 타입은 double이고 전역 변수 x의 타입은 int이지만, 이것은 지금 중요한 사안이 아니다. C++의 이름 가리기 규칙은 어쨌든 이름을 가려 버린다. 겹치는 이름들의 타입이 같으냐 다르냐에 대한 부분은 신경도 안 쓴다는 것이다.

자, 이제는 상속 이야기를 해 보자. 기본 클래스에 속해 있는 것(멤버 함수, typedef 혹은 데이터 멤버)을 파생 클래스 멤버 함수 안에서 참조하는 문장이 있으면 컴파일러는 이 참조 대상을 바로 찾아낼 수 있다. 기본 클래스에 선언된 것은 파생 클래스가 모두 물려받기 때문이다. 사실 이렇게 동작하는 이유는 파생 클래스의 유효범위가 기본 클래스의 유효범위 안에 중첩되어 있기 때문이다. 다음의 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base
{
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf2();
    void mf3();
    ...
};

class Derived : public Base
{
public:
    virtual void mf1();
    void mf4();
    ...
};

Base의 유효범위는 x, mf1, mf2, mf3이고, Derived의 유효범위는 mf1, mf4이다.

보다시피 데이터 멤버와 멤버 함수의 이름이 public으로 공개되거나 private로 숨겨진 상태로 한데 뒤섞여 있는 예제이다. 온갖 멤버 함수, 그러니까 순수 가상, 그냥 가상, 비가상 함수가 전부 모여 있다. 지금 우리가 이름에 대해 이야기하고 있다는 점을 강조해야 한다. 맘 같아서는 타입의 이름도 넣을 수 있었을 것이다. enum, 중첩 클래스, typedef 등등 말이다. 어쨌든 중요한 건 ‘이들이 이름이다’라는 것뿐ㅇ치다. 무엇의 이름이냐 하는 것은 신경 쓰지 말자. 지금 보는 예제에서는 단일 상속이 쓰이고 있는데, 일단 여기서 생기는 일만 제대로 이해해 두면 다중 상속을 썼을 때의 상황도 어렵지 않게 예상할 수 있다.

mf4가 파생 클래스에서 다음과 같이 구현되어 있다고 가정해 보자.

1
2
3
4
5
6
void Derived::mf4()
{
    ...
    mf2();
    ...
}

컴파일러는 이 함수 안을 차례로 읽어 가다가 mf2라는 이름이 쓰이고 있다는 것을 발견하게 되는데, 이때 이 mf2가 어느 것에 대한 이름인지를 파악해야 하는 것이 급선무이다. 이름의 출처 파악을 위해, 컴파일러는 mf2라는 이름이 붙은 것의 선언문이 들어 있는 유효 범위를 탐색하는 방법을 쓴다. 우선 지역 유효범위(즉, mf4의 유효범위) 내부를 뒤져 보는데, mf2라 불리는 어떤 것도 선언된 게 없다. 그래서 mf4의 유효범위를 바깥에서 감싸고 있는 유효범위를 찾는다. 그러니까 지금의 경우에는 Derived 클래스의 유효범위가 그 유효범위에 해당된다. 그런데 여전히 mf2라는 이름을 가진 것이 보이지 않으므로, 컴파일러는 Derived 클래스를 감싸고 있는 바로 다음의 유효범위, 즉 Base 클래스의 유효범위로 옮겨 간다. 여기서 컴파일러는 드디어 mf2라는 이름을 붙은 놈을 찾아내고, 탐색이 비로소 끝난다. 만약 Base 안에 mf2가 없으면 계속 탐색이 진행되는데, 우선 Base를 둘러싸고 있는 네임스페이스가 있으면 그쪽부터 탐색을 시작해서, 마지막엔 전역 유효범위까지 간다.

  1. 컴파일러는 이름이 쓰인 곳에서부터 시작해서, 그 이름이 선언된 곳을 찾아내기 위해 유효범위를 탐색한다.
  2. 유효범위 탐색은 가장 안쪽의 유효범위부터 시작해서 바깥쪽으로 진행한다.
  3. mf4의 유효범위 탐색
  4. Derived의 유효범위 탐색
  5. Base의 유효범위 탐색
  6. 이름이 발견되면 탐색 종료, 발견되지 않으면 계속 탐색
  7. Base를 둘러싸고 있는 네임스페이스 탐색
  8. 전역 유효범위 탐색 순으로 진행된다.

방금 위에서 말한 탐색 과정은 유효범위에서 컴파일러가 이름을 찾을 때 실제로 정확히 일어나는 과정이지만, C++의 이름 탐색 과정을 모두 설명한 것은 아니다. 이름 규칙을 마스터하기 위한게 목표가 아니니 잠깐 넘어가자.

다시 앞의 예제로 돌아오자. 이번에는 mf1 및 mf3을 오버로드하고, mf3의 오버로드 버전을 Derived에 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Base
{
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};

class Derived : public Base
{
public:
    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

Base의 유효범위는 x, mf1 2개, mf2, mf3 2개이고, Derived의 유효범위는 mf1, mf3, mf4이다.

위의 코드가 동작하는 모습을 처음 접하고 나서 놀라지 않을 C++ 개발자는 아마 없을 것이다. 무대가 클래스로 옮겨졌을 뿐, 유효범위에 기반한 이름 가리기 규칙은 전혀 변한 것이 없기 때문에, 기본 클래스에 있는 함수들 중 mf1 및 mf3이라는 이름이 붙은 것은 모두 파생 클래스에 들어 있는 mf1 및 mf3에 의해 가려지고 만다. 이름 탐색의 시점에서 보면, 어처구니없게도 Base::mf1과 Base::mf3은 Derived가 상속한 것이 아니게 된단 말이다.

1
2
3
4
5
6
7
8
9
10
Derived d;
int x;
...
d.mf1(); // ok
d.mf1(x); // 에러. Derived::mf1이 Base::mf1을 가린다

d.mf2(); // ok

d.mf3(); // ok
d.mf3(x); // 에러. Derived::mf3이 Base::mf3을 가린다

보았겠지만, 이런 어처구니없는 이름 가리기는 기본 클래스와 파생 클래스에 있는 이름이 같은 함수들이 받아들이는 매개변수 타입이 다르거나 말거나 거리낌이 없다. 심지어 함수들이 가상 함수인지 비가상 함수인지의 여부에도 상관없이 이름이 가려진다. 이번 항목을 시작하자마자 본 someFunc 함수 안에 있는 double 타입의 x가 전역 유효범위에 있는 int 타입의 x를 가렸듯이, 이번에는 Derived 클래스의 mf3 함수가 Base 클래스의 mf3 함수를 가리고 있다. 받아들이는 타입도 완전히 다른데 말이다.

이렇게 동작하는 데에는 다 그만한 이유가 있다. 우리가 어떤 라이브러리 혹은 응용프로그램 프레임워크를 이용하여 파생 클래스를 하나 만들 때, 멀리 떨어져 있는 기본 클래스로부터 오버로드 버전을 상속시키는 경우를 막겠다는 것이다. 일종의 실수로 간주하겠다는 것인데, 오버로드 버전을 상속했으면 하는 프로그래머가 우리 주변에 많으니 애석한 일이다. 사실, public 상속을 버젓이 쓰면서 기본 클래스의 오버로드 함수를 상속받지 않겠다는 것도 엄연히 is-a 관계 위반이다. is-a 관계는 바로 public 상속의 진수라고 항목 32에서 강조했었다. 사정이 이러하니, C++가 기본적으로 해 버리는 ‘상속된 이름 가리기’를 무시하고 싶은 경우가 거의 대부분일 것이다.

가려진 이름은 using 선언을 써서 끄집어낼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Base
{
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};

class Derived : public Base
{
public:
    // Base에 있는 것들 중 mf1과 mf3을 이름으로 가진 것들을 Derived의 유효범위에서 볼 수 있도록, 또한 public 멤버로 만든다. 
    using Base::mf1;
    using Base::mf3;

    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

이제 우리가 예상했던 대로 돌아가는 상속이 되었다.

1
2
3
4
5
6
7
8
9
10
Derived d;
int x;
...
d.mf1(); // ok
d.mf1(x); // 이제는 ok. Base::mf1을 호출

d.mf2(); // ok

d.mf3(); // ok
d.mf3(x); // 이제는 ok. Base::mf3을 호출

대충 어떤 이야기인지 감이 올 것이다. 어떤 기본 클래스로부터 상속을 받으려고 하는데, 오버로드된 함수가 그 클래스에 들어 있고 이 함수들 중 몇 개만 재정의(== 오버라이드)하고 싶다면, 각 이름에 대해 using 선언을 붙여 주어야 한다는 것이다. 이렇게 하지 않으면 이름이 가려져 버린다. 가려진 이름은 우리가 상속받고 싶어도 할 수가 없게 된다.

기본 클래스가 가진 함수를 전부 상속했으면 하는 것이 아닌 경우도 있긴 하다. 물론 이 경우와 public 상속은 함께 놓고 생각하지 말아야 한다. 기본 클래스와 파생 클래스 사이의 is-a 관계가 깨져 버리기 때문이다.

위의 예제에서 본 using 선언이 파생 클래스의 public 영역에 들어 있는 이유도 바로 이것이다. 어떤 파생 클래스가 기본 클래스로부터 public 상속으로 만들어진 것일 경우, 기본 클래스의 public 영역에 있는 이름들은 팟애 클래스에서도 public 영역에 들어 있어야 한다.

하지만 private 상속을 사용한다면 이 경우가 말이 될 수 있다. 예를 하나 들어서, Derived가 Base로부터 private 상속이 이루어졌다고 가정하자. 그리고 Derived가 상속했으면 하는 mf1 함수는 매개변수가 없는 버전 하나밖에 없다고 치자. 이때는 using 선언으로 해결할 수 없다. 그 이유는 using 선언을 내리면 그 이름에 해당되는 것들이 모두 파생 클래스로 내려가 버리기 때문이다. 바야흐로 다른 기법이 필요한 경우가 된 것이다. 그 기법이란, 간단한 전달 함수(forwarding function)을 만들어 놓는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Base 
{
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    ... // 동일
};

class Derived : private Base
{
public:
    virtual void mf1() { Base::mf1(); } // 전달 함수이고, 암시적으로 인라인 함수가 된다.
    ...
};

...

Derived d;
int x;

d.mf1(); // ok. Derived::mf1(매개변수 없는 버전)을 호출한다.
d.mf1(x); // 에러. Base::mf1()은 가려져 있다.

지금 본 인라인 전달 함수의 용도는 하나 더 있다. 기본 클래스의 이름을 파생 클래스의 유효범위에 끌어와 쓰고 싶은데, using 선언을 아예 지원 못하는 옛날 컴파일러를 사용하고 있다면 이 인라인 전달 함수를 써서 우회적으로 해결할 수 있다.

상속과 이름 가리기에 대한 이야기는 여기까지이다. 전부 알아갔다고 생각하지만, 상속이 템플릿과 엮일 경우에 전혀 다른 형태로 우리를 괴롭히는 ‘상속된 이름 가려짐’ 문제는 여기서 다루지 않았다.

파생 클래스의 이름은 기본 클래스의 이름을 가린다. public 상속에서는 이런 이름 가림 현상을 바람직하지 않다.
가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있다.


참고

This post is licensed under CC BY 4.0 by the author.