[Effective C++]어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자

상속, 그리고 객체 지향 설계, 여섯 번째 이야기

Posted by SungBeom on December 25, 2019 · 8 mins read

가상 함수: 재정의할 수 있는 유일한 함수

C++에서 상속받을 수 있는 함수의 종류는 두 가지, 그러니까 가상 함수와 비가상 함수뿐입니다. 그런데 이들 중 비가상 함수는 언제라도 재정의해서는 안 되는 함수이므로, 지금부터 드릴 이야기는 '기본 매개변수 값을 가진 가상 함수를 상속하는 경우'로 좁히더라도 별 문제가 없을 듯합니다. 여기서 상속받은 기본 매개변수 값은 재정의하면 안 되는데, 이유인즉 가상 함수는 동적으로 바인딩되지만, 기본 매개변수 값은 정적으로 바인딩되기 때문입니다.
공식적으로, 정적 바인딩은 선행 바인딩(early binding)이란 다른 이름으로도 알려져 있고, 동적 바인딩은 지연 바인딩(late binding)이란 이름으로도 알려져 있습니다.

정적 타입

객체의 정적 타입(static type)은 프로그램 소스 안에 여러분이 놓는 선언문을 통해 그 객체가 갖는 타입입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 기하학 도형을 나타내는 클래스
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
 
    // 모든 도형은 자기 자신을 그리는 함수를 제공해야 합니다.
    virtual void draw(ShapeColor color = Red) const = 0;
    ...
};
 
class Rectangle: public Shape {
public:
    // 기본 매개변수 값이 달라진 부분을 놓치지 마세요. 큰일 났습니다!
    virtual void draw(ShapeColor color = Green) const;
    ...
};
 
class Circle: public Shape {
public:
    virtual void draw(ShapeColor color) const;
    ...
};
cs

이들을 써서 포인터를 나타내면 어떻게 되나 볼까요?

1
2
3
Shape *ps;                  // 정적 타입 = Shape*
Shape *pc = new Circle;     // 정적 타입 = Shape*
Shape *pr = new Rectangle;  // 정적 타입 = Shape*
cs

여기서 ps, pc 및 pr은 모두 'Shape에 대한 포인터'로 선언되어 있기 때문에, 각각의 정적 타입도 모두 이 타입입니다. 단, 그렇다고 해서 이들이 진짜로 가리키는 대상이 달라지는 것은 하나도 없습니다. 그냥 정적 타입이 Shape*일 뿐입니다.

동적 타입

객체의 동적 타입(dynamic type)은 현재 그 객체가 진짜로 무엇이냐에 따라 결정되는 타입입니다. 다시 말해, '이 객체가 어떻게 동작할 것이냐'를 가리키는 타입이 동적 타입이라 하겠습니다. 위의 예제에서는, pc의 동적 타입은 Circle*이고, pr의 동적 타입은 Retangle*이고, ps의 경우엔 동적 타입이 없습니다. (아직) 아무 객체도 참조하고 있지 않으니까요. 동적 타입은 프로그램이 실행되는 도중에 바뀔 수 있습니다.

1
2
ps = pc;  // ps의 동적 타입은 이제 Circle*가 됩니다.
ps = pr;  // ps의 동적 타입은 이제 Rectangle*가 됩니다.
cs

가상 함수는 동적으로 바인딩됩니다. 가상 함수의 호출이 일어난 객체의 동적 타입에 따라 어떤 가상 함수가 호출될지가 결정된다는 뜻입니다.

1
2
pc->draw(Shape::Red);  // Circle::draw(Shape::Red)를 호출합니다.
pr->draw(Shape::Red);  // Rectangle::draw(Shape::Red)를 호출합니다.
cs

여기까지는 괜찮지만, '기본 매개변수 값이 설정된' 가상 함수로 오게 되면 뭔가 꼬이기 시작합니다. 이유는 앞에서 말씀드렸듯이, 가상 함수는 동적으로 바인딩되어 있지만 기본 매개변수는 정적으로 바인딩되어 있기 때문입니다. 그러니까, 파생 클래스에 정의된 가상 함수를 호출하면서 기본 클래스에 정의된 기본 매개변수 값을 사용해버릴 수 있다는 이야기입니다.

1
pr->draw();  // Rectangle::draw(Shape::Red)를 호출한단 말입니다!
cs

이 경우 pr의 동적 타입이 Rectangle*이므로, 호출되는 가상 함수는 Rectangle의 것입니다. Rectangle::draw 함수에서는 기본 매개변수 값이 Green으로 되어 있습니다. 하지만 pr의 정적 타입은 Shape*이기 때문에, 지금 호출되는 가상 함수에 쓰이는 기본 매개변수 값을 Shape 클래스에서 가져옵니다. Rectangle 클래스여야 할 것 같은데 말입니다! 그 결과, Shape 및 Rectangle 클래스 양쪽에서 선언된 것이 한데 섞이는 기상천외한 함수 호출이 이루어지는 것입니다. 포인터가 아니라 참조자라도 가상 함수에서 기본 매개변수 값들 중 하나가 파생 클래스에서 재정의되면 여전히 문제입니다.

NVI 관용구를 이용한 매개변수에 대한 기본값 고정

C++이 이러한 동작방식을 고집하는 데에는 런타입 효율이라는 요소가 숨어 있습니다. 만약에 함수의 기본 매개변수가 동적으로 바인딩된다면, 프로그램 실행 중에 가상 함수의 기본 매개변수 값을 결정할 방법을 컴파일러 쪽에서 마련해 주어야 할 것입니다. 아무래도 이 방법은 컴파일 과정에서 현재의 메커니즘보다는 느리고 복잡할 것이 분명하겠지요. 지금의 메커니즘은 속도 유지와 구현 간평성에 무게를 더 두어 결정 내린 결과이고, 그 덕택에 효율 좋은 실행 동작을 누릴 수 있게 된 것입니다.

기본 클래스 및 파생 클래스의 사용자에게 기본 매개변수 값을 똑같이 제공해 보려고 하면 코드 중복이 발생합니다. 더 안 좋은 것은 코드 중복에 의존성까지 걸려 있다는 점입니다. 상위 클래스에서 기본 매개변수 값이 변하기라도 하면, 이 값을 반복하고 있는 파생 클래스는 모두 그 값을 바꿔야 할 것입니다.

이 문제의 해결법은 바로 비가상 인터페이스(non-virtual interface) 관용구(NVI 관용구)를 쓰는 것입니다. 이 방법은 파생 클래스에서 재정의할 수 있는 가상 함수를 private 멤버로 두고, 이 가상 함수를 호출하는 public 비가상 함수를 기본 클래스에 만들어 두는 것입니다. 여기서 이 방법을 응용한다면, 비가상 함수가 기본 매개변수를 지정하도록 할 수 있겠지요. 이 비가상 함수의 내부에서는 진짜 일을 맡은 가상 함수를 호출하게 만들고요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
 
    void draw(ShapeColor color = Red) const           // 이제는 비가상 함수입니다.
    { doDraw(color); }                                // 가상 함수를 호출합니다.
    ...
private:
    virtual void doDraw(ShapeColor color) const = 0;  // 진짜 작업은 이 함수에서
};                                                    // 이루어집니다.
 
class Rectangle: public Shape {
public:
    ...
private:
    virtual void doDraw(ShapeColor color) const;      // 기본 매개변수 값이
    ...                                               // 없습니다.
};
cs

비가상 함수는 파생 클래스에서 오버라이드되면 안 되기 때문에, 위와 같이 설계하면 draw 함수의 color 매개변수에 대한 기본값을 깔끔하게 Red로 고정시킬 수 있습니다.


정리

상속받은 기본 매개변수 값은 절대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수(오버라이드할 수 있는 유일한 함수)는 동적으로 바인딩되기 때문입니다.