[Effective C++] 6. 상속, 그리고 객체 지향 설계 [2/4]
이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 34 : 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
public 상속이라는 개념은 언뜻 보기에 그다지 복잡하지 않은 것 같지만, 좀더 자세히 들여다보면 사실 두 가지로 나뉜다. 하나는 함수 인터페이스의 상속이고, 또 하나는 함수 구현의 상속이다. 인터페이스 상속 및 구현 상속의 차이는 함수 선언(function declaration)과 함수 정의(function definition)의 차이와 맥을 같이 한다고 보면 된다.
클래스 설계자의 입장에서 보면, 멤버 함수의 인터페이스(선언)만을 파생 클래스에 상속받고 싶을 때가 분명히 있다. 어쩔 때는 함수의 인터페이스 및 구현을 모두 상속받고 그 상속받은 구현이 오버라이드가 가능하게 만들었으면 하는 사람도 있다. 반대로, 인터페이스와 구현을 상속받되 어떤 것도 오버라이드할 수 없도록 막고 싶은 경우도 있다.
이러저러한 선택사항들 사이의 차이점은 몸으로 제대로 느껴보는 게 중요하다. 이런 의미에서, 그래픽 응용프로그램에 쓰이는 기하학적 도형을 나타내는 클래스 계통구조를 놓고 한 번 생각해 보자.
1
2
3
4
5
6
7
8
9
10
11
class Shape
{
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle : public Shape { ... };
class Ellipse : public Shape { ... };
Shape는 추상 클래스이다. 순수 가상 함수인 draw가 바로 추상 클래스 딱지를 붙인 장본인이다. 결국, Shape 클래스의 인스턴스를 만들려고 하면 안 되고, 이 클래스의 파생 클래스만 인스턴스화가 가능하다. 하지만 아무 힘도 없을 것 같은 이 Shape가 이 클래스로부터 public 상속에 의해 파생된 클래스에 대해 미치는 영향을 크다. 어째서일까?
멤버 함수 인터페이스는 항상 상속되게 되어 있기 때문이다.
항목 32에서 설명했듯이, public 상속의 의미는 is-a이므로 기본 클래스에 해당하는 것들은 모두 파생 클래스에도 해당되어야 한다. 따라서 어떤 클래스에서 동작하는 함수는 그 클래스의 파생 클래스에서도 동작되어야 맞다.
Shape 클래스에는 세 개의 함수가 선언되어 있다.
- draw 함수는 암시적인 표시 장치에 현재의 객체를 그린다.
- error 함수는 다른 멤버 함수들이 호출하는 함수로, 이들이 에러를 보고할 필요가 있을 때 사용된다.
- objectID 함수는 주어진 객체에 붙는 유일한 정수 식별자를 반환한다.
이들 세 함수는 선언된 방법도 각기 다르다. draw는 순수 가상 함수이고, error는 비순수 가상 함수이고, objectID는 비가상 함수이다. 그런데 선언이 다르다는 것에 대체 어떤 속뜻이 들어 있는 것일까?
우선 순수 가상 함수인 draw부터 생각해 보자.
1
2
3
4
5
6
class Shape
{
public:
virtual void draw() const = 0;
...
};
순수 가상 함수에 대해 말할 수 있는 가장 큰 특징은 두 가지이다.
- 어떤 순수 가상 함수를 물려받은 구체 클래스가 해당 순수 가상 함수를 다시 선언해야 한다.
- 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다.
이 두 가지를 하나로 모아 보면 다음과 같은 결론이 나온다.
- 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다.
Shape::draw 함수에 대해 정말이지 딱 맞는 이야기이다. 그도 그럴 것이, “Shape 계통을 따르는 모든 객체는 그리기가 가능해야 한다”라는 요구사항은 그리 이상할 것이 없지만, 그리기에 대해 구체적인 생각도 없이 객체를 만들었을 때도 ‘그리기’가 되도록 해주는 draw 함수의 구현, 다시 말해 기본 구현을 제공하는 것은 Shape 클래스 차원에서 어떻게 할 수가 없으니 말이다. 이를테면, 타원을 그리는 알고리즘은 직사각형을 그리는 알고리즘과 같을래야 같을 수 없을 것이다. Shape::draw 함수의 선언은 차후에 Shape로부터 구체 클래스를 파생시킬 설계자에게 “draw 함수는 직접 제공하시되, 어떻게 구현할 지는 난 아무 생각 없다”라고 말하는 것과 같다.
말 나온 김에, 사실은 순수 가상 함수에도 정의를 제공할 수 있다. 다시 말해, Shape::draw 함수에 대해 구현을 붙일 수 있다는 이야기이다. C++도 이에 대해 툴툴거리는 일이 없다. 단, 구현이 붙은 순수 가상 함수를 호출하려면 반드시 클래스 이름의 한정자로 붙여 주어야 한다는 점도 명심해야 한다. (추상 클래스로는 인스턴스를 만들 수 없으므로 당연한 이치이다)
1
2
3
4
5
6
7
8
9
10
Shape *ps = new Shape; // error. Shape는 추상 클래스이므로 인스턴스화할 수 없다.
Shape *ps1 = new Rectangle;
ps1->draw(); // ok. Rectangle::draw 호출
Shape *ps2 = new Ellipse;
ps2->draw(); // ok. Ellipse::draw 호출
ps1->Shape::draw(); // ok. Shape::draw 호출
ps2->Shape::draw(); // ok. Shape::draw 호출
다음은 단순(비순수) 가상 함수이다. 단순 가상 함수은 파생 클래스로 하여금 함수의 인터페이스를 상속하게 한다는 점은 똑같지만, 파생 클래스 쪽에서 오버라이드할 수 있는 함수 구현부도 제공한다는 점이 다르다.
- 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것이다.
Shape::error 함수의 경우를 생각해 보자.
1
2
3
4
5
6
class Shape
{
public:
virtual void error(const std::string& msg);
...
};
이 인터페이스가 전하는 바는 이렇다. 실행 중에 에러와 마주쳤을 때 자동으로 호출될 함수를 제공하는 것은 모든 클래스가 해야 하는 일이지만, 그렇다고 각 클래스마다 그때그때 꼭 맞는 방법으로 에러를 처리할 필요는 없다는 것이다. 에러가 생기더라도 특별히 해주는 일이 없는 클래스라면, Shape 클래스에서 기본으로 제공되는 에러 처리를 그냥 써도 된다. 다시 말해, Shape::error 함수의 선언문은 나중에 파생 클래스를 설계할 개발자에게 이런 메세지를 전하는 것이다. “error 함수는 우리가 지원해야 한다. 그러나 굳이 새로 만들 생각이 없다면 Shape 클래스에 있는 기본 버전을 그냥 써도 된다.”
알고 보면 단순 가상 함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하도록 내버려두는 것은 위험할 수도 있다. 이유를 설명하기 위해 예를 하나 들자. XYZ라는 이름의 가상의 항공사가 있고, 이 항공사의 비행기는 A, B 모델 두 가지가 있다고 하자. 게다가 이 두 모델의 비행 방식이 똑같다. 따라서 XYZ의 비행기는 다음과 같은 클래스 계통으로 설계할 수 있을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Airport { ... }; // 공항을 나타내는 클래스
class Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void Airplane::fly(const Airport& destination)
{
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
class ModelA : public Airplane { ... };
class ModelB : public Airplane { ... };
보다시피 Airplane::fly 함수는 가상 함수로 선언되어 있다. 모든 비행기는 fly 함수를 지원해야 한다는 점을 나타내야 하니까 말이다. 또 모델이 다른 비행기는 원칙상 fly 함수에 대한 구현을 저마다 다르게 요구할 수 있다는 사실을 알고 있다는 뜻도 되는 거다. 하지만 ModelA 및 ModelB 클래스에 대해 똑같은 코드를 작성하지는 말아야 하므로, 기본적인 비행 원리를 Airplane::fly 함수의 본문에 제공함으로써 이것을 ModelA 및 ModelB가 물려받을 수 있도록 하였다.
지극히 고전적인 객체 지향 설계라는 점은 당연하다. 두 클래스가 하나의 공통 특징을 공유하고 있으므로, 이 공통 특징을 기본 클래스로 올려보낸 뒤에 두 클래스가 이 특징을 물려받는 식으로 설계된 것이다. 설계를 이렇게 하면 우선 클래스 사이의 공통 사항으로 둘 수 있는 특징이 명확해지고, 코드가 중복되지 않으며, 이후의 기능 개선 통로도 열려 있게 되는데다가, 장기적인 유지보수도 쉬워진다.
이와 같은 훌륭한 설계에 힘입어 XYZ 항공사가 새로운 항공기 C 형태를 도입하기로 결정한다. C 모델은 A 및 B 모델과는 몇 가지가 사뭇 다른데, 특히 비행 방식이 완전히 다르다.
XYZ 항공사의 프로그래머들은 서둘러 C 모델을 위한 클래스를 기존의 클래스 계통에 추가했지만, 새 모델을 서비스에 투입하려고 허둥대다가 그만 fly 함수를 재정의하는 것을 잊어버리고 말았다.
1
2
3
4
class ModelC : public Airplane
{
... // fly 함수가 선언되지 않음
};
이것을 가지고 코드를 만든다면 아래와 크게 다르지 않게 나올 것이다.
1
2
3
4
Airport PDX(...);
Airplane *pa = new ModelC;
...
pa->fly(PDX); // Airplane::fly 함수가 호출된다.
ModelC 객체가 마치 ModelA 혹은 ModelB라도 된 것처럼 날려보는 개념이탈의 코드이다.
문제는 Airplane::fly 함수가 기본 동작을 구현해서 갖고 있다는 점이 아니라, ModelC 클래스는 이 기본 동작을 원한다고 명시적으로 밝히지 않았는데도 이 동작을 물려 받는 데 아무런 걸림돌이 없다는 점이다. 단, 기본 동작을 파생 클래스에게 제공하는 것도 쉽지만 파생 클래스에서 요구하지 않으면 주지 않는 방법도 그리 어렵지 않아서 다행이다. 일종의 수법인데, 가상 함수의 인터페이스와 그 가상 함수의 기본 구현을 잇는 연결 관계를 끊어버리는 것이다. 아래 코드에서 확인하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Airplane
{
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
Airplane::fly 함수가 순수 가상 함수로 바뀌었는데, 어떻게 바뀌었는지 잘 보자. 이 가상 함수가 바로 fly 함수의 인터페이스를 제공하는 역할을 맡게 된다. 그렇다고 이전의 기본 구현이 사라진 것은 아니고, 여전히 Airplane 클래스에 남아 있다. 단, 지금은 defaultFly라는 이름의 별도의 함수로 거듭났다. 기본 동작을 쓰고 싶은 ModelA 및 ModelB 등에서는 fly 함수의 본문 안에서 그냥 이 defaultFly 함수를 인라인 호출하기만 하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ModelA : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination);
}
...
};
class ModelB : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination);
}
...
};
이제는 ModelC 클래스가 자신과 맞지 않는 기본 구현을 우연찮게 물려받을 가능성은 없어졌다. fly 함수가 Airplane 클래스의 순수 가상 함수로 선언되어 있어서, ModelC도 자신만의 버전을 스스로 제공하지 않으면 안 되는 상황이니 말이다.
1
2
3
4
5
6
7
8
9
10
11
class ModelC : public Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
// ModelC에 맞는 비행 방식으로 fly 함수 구현
}
이 방법으로 프로그래머의 실수를 100% 막을 수는 없겠지만, 원래의 설계보다 훨씬 믿고 쓸만해졌다. 시간이 남은 김에 Airplane::defaultFly 함수에 대해서도 이야기해 보자. 이 함수는 protected 멤버이다. Airplane 및 그 클래스의 파생 클래스만 내부적으로 사용하는 구현 세부사항이기 때문이다. 비행기를 사용하는 사용자는 비행기가 날 수 있다는 점만 알면 될 뿐, 비행 동작이 어떻게 구현되는가는 신경 쓰지 말아야 한다.
또 다른 중요사항은 Airplane::defaultFly 함수가 비가상 함수라는 점이다. 그 이유는 파생 클래스 쪽에서 이 함수를 재정의해선 안 되기 때문이다. 항목 36에서 그대로 알려 주는 내용이기도 하다. 만일 defaultFly가 가상 함수라면 우리는 꼬리에 꼬리를 무는 문제로 빠질 수 있다. 즉, 어떤 파생 클래스에서 defaultFly 함수를 재정의하는 것을 잊어버렸다면 대체 어떻게 해야 하냐는 것이다.
인터페이스 및 기본 제공을 구현하는 함수, 그러니까 fly 및 defaultFly 같은 함수를 별도로 마련하는 아이디어를 별로 좋아하지 않는 사람들도 있다. 그다지 중요하지도 않은 관계로 얽힌 함수 이름들이 많아지면서 클래스의 네임스페이스가 더러워진다는 것을 한 가지 이유로 지적한다. 그러면서도 인터페이스와 기본 구현이 분리되어야 한다는 점은 여전히 동의한다. 이걸 해결하는 방법은 순수 가상 함수가 구체 파생 클래스에서 재선언되어야 한다는 사실을 활용하되, 자체적으로 순수 가상 함수의 구현을 구비해 두는 거라고 한다. 그럼, 순수 가상 함수를 정의하는 기능을 Airplane 클래스 계통에서 어떻게 활용할 수 있을지 코드로 확인해 보자.
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Airplane
{
public:
virtual void fly(const Airport& destination) = 0;
...
};
void Airplane::fly(const Airport& destination)
{
// 순수 가상 함수의 구현
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
class ModelA : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination);
}
...
};
class ModelB : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination);
}
...
};
class ModelC : public Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
// ModelC에 맞는 비행 방식으로 fly 함수 구현
}
별도의 함수인 Airplane::defaultFly의 자리에 순수 가상 함수인 Airplane::fly의 본문이 들어와 있는 것만 제외하면 이전의 설계와 거의 똑같다. 요컨대, fly 함수가 선언부 및 정의부의 두 쪽으로 나뉜 것이다. 선언부는 이 함수의 인터페이스를 지정하고, 정의부는 이 함수의 기본 동작을 지정한다. 하지만 fly와 defaultFly가 하나로 합쳐지는 바람에 함수 양쪽에 각기 다른 보호 수준을 부여할 수 있는 융통성을 날아가고 말았다.
이제는 세 번째 함수인 Shape의 비가상 함수인 objectID에 대해서도 이야기해 보자.
1
2
3
4
5
6
class Shape
{
public:
int objectID() const;
...
};
멤버 함수가 비가상 함수로 되어 있다는 것은, 이 함수는 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않았다는 뜻이다. 실제로, 비가상 멤버 함수는 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다. 이 함수의 역할 자체가, 미래에 만들어질 파생 클래스가 아무리 특수한 클래스라 해도 변하지 않는 동작을 수행하는 것이기 때문이다. 정리하자면,
- 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현을 물려받게 하는 것이다.
그러니까, Shape::objectID의 선언은 이렇게 말하는 것이다. “Shape 및 이것에서 파생되는 모든 객체는 객체의 식별자를 내어 주는 함수를 갖게 된다. 객체의 식별자를 계산하는 방법은 항상 똑같다. 실제 계산 방법은 Shape::objectID의 정의에서 결정되고, 파생 클래스에서 이것을 바꿀 수는 없을 것이다.” 비가상 함수는 클래스 파생에 상관없는 불변동작과 같기 때문에, 파생 클래스에서 재정의할 수 있는 수준의 것들이 절대로 아니다. 이 부분은 항목 36에서 자세히 살펴보자.
순수 가상 함수, 단순 가상 함수, 비가상 함수의 선언문이 가진 이런저런 차이점 덕택에, 우리는 파생 클래스가 물려받았으면 하는 것들을 정밀하게 지정할 수 있다. 판단에 따라 인터페이스만 상속시켜도 되고, 인터페이스와 기본 구현을 함께 상속시킬 수도 있으며, 아니면 인터페이스와 필수 구현을 상속시킬 수 있는 것이다. 각각의 선언문 형식만큼 뜻하는 바도 제각각이기 때문에, 어떤 클래스에 멤버 함수를 선언해 넣는 우리는 이들 중 하나를 고를 때 각별히 신경을 써야 한다. 멤버 함수를 선언할 때, 클래스 설계를 많이 해 보지 못한 사람들의 클래스에서 가장 흔히 발견되는 결정적인 실수 두 가지를 피해 주었으면 한다.
첫 번째 실수는 모든 멤버 함수를 비가상 함수로 선언하는 것이다. 이렇게 하면 파생 클래스를 만들더라도 기본 클래스의 동작을 특별하게 만들 만한 여지가 없어지게 된다. 특히 비가상 소멸자가 문젯거리가 될 수 있다. 아, 물론 클래스 파생을 처음부터 염두에 두지 않은 클래스를 설계하는 경우는 얼마든지 있다. 사실 이런 용도의 클래스는 비가상 함수들만 모아두는 게 맞다. 하지만, 가상 함수와 비가상 함수의 차이에 대한 생각도 잘 하지 않고 이런 클래스를 선언하거나, 가상 함수를 쓰면 무조건 성능이 안 좋은 줄만 알고 이런 클래스를 선언하는 사람들이 안타까울 정도로 많다는 것이 문제이다. 진실은 안 변한다. 기본 클래스로 쓰이는 클래스는 십중팔구 가상 함수를 갖게 된다.
가상 함수의 비용 때문에 마음이 불편한 사람들에게, 훌륭한 선배님들이 남기신 경험의 산물인 80-20 법칙을 말하고자 한다(항목 30 참조). 이 법칙은 어지간한 프로그램에서는 전체 실행 시간의 80%가 소모되는 부분이 전체 코드의 20%밖에 되지 않는다는 법칙이다. 굉장히 중요한 법칙이다. 왜냐하면 정말 특수한 상황이 아니면 함수 호출 중 80%를 가상 함수로 두더라도 우리 프로그램 전체 수행 성능에 가장 약하게 느낄 수 있을 만큼의 손실도 생기지 않는다는 뜻이다. 가상 함수의 비용을 물까 말까 갈팡질팡하며 눈가에 그늘 만들기 전에, 비용을 무느냐 안 무느냐에 따라 진짜 큰 차이를 만들 수 있는 20%의 코드에 좀더 집중해 보는 게 어떨까.
또 한 가지 실수는 모든 멤버 함수를 가상 함수로 선언하는 것이다. 물론 맞을 경우도 있는데, 항목 31의 인터페이스 클래스가 그 증거이다. 하지만 어떻게 보면 이 클래스를 설계한 사람은 자신이 만든 것을 남에게 보일 용기가 없는 부끄럼쟁이가 아닌가 하는 인상을 줄 수 있다. 분명히 파생 클래스에서 재정의가 안 되어야 하는 함수도 분명히 있을 것이다. 그리고 이런 함수가 있으면 반드시 비가상 함수로 만들어 둠으로써 입장을 확실히 밝히는 것이 제대로 된 자세이다. 클래스 파생에 상관없는 불변동작을 갖고 있어야 한다면, 비가상 함수로 선언하란 말이다.
인터페이스 상속과 구현 상속은 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받는다.
순수 가상 함수는 인터페이스 상속만을 허용한다. 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정한다. 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다.
추가로
비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다. 라고 저자가 말했는데, 필수 구현이라는 번역? 표현?이 어색하게 느껴지는 것 같다. 대신에 필수적인 구현의 상속도 가하도록 지정한다. 라고 하는 게 더 자연스럽지 않을까 싶다.
C++ 11부터는 final 키워드가 도입되어서, 가상 함수 인터페이스를 상속하되, 특정 단계부터는 이 구현을 재정의할 수 없도록 막는 것도 가능해졌다. 즉, 필수적인 구현의 상속도 가하도록 지정한다.를 문법적으로 명시하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Shape {
public:
virtual void draw() const = 0;
};
class Rectangle : public Shape
{
public:
// Rectangle 밑으로 나오는 자식들은 절대 이 draw() 구현을 변경할 수 없음
void draw() const override final
{
// 사각형 그리는 로직
}
};
게다가! 가상 함수는 런타임에 결정되므로 인라인화가 안 된다. 하지만, final 키워드를 붙여주면 컴파일러는 ‘이 클래스 밑으로는 절대 이 함수를 오버라이딩 할 수 없구나’라고 알게 된다. 그럼 이 클래스 포인터로 호출할 때는 무조건 이 코드가 실행되겠네?라고 확신할 수 있다.
이릍 통해 컴파일러는 가상 함수 테이블을 거치는 오버라이딩 메커니즘을 생략하고, 가상 함수임에도 불구하고 인라인 화가 가능하다!
항목 35 : 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
우리가 게임 개발팀에서 일하고 있다고 생각해 보자. 맡은 일은 게임에 등장하는 각종 캐릭터를 클래스로 설계하는 작업이다. 여러 적들이 달려들어 싸우는 게임이다 보니, 캐릭터가 다치거나 체력이 깎이는 경우가 많을 것이다. 그래서 healthValue라는 이름의 멤버 함수를 제공하기로 정한다. 이 함수는 이름 그대로 캐릭터의 체력이 얼마나 남았는지를 나타내는 정수 값을 반환한다. 체력이 어떻게 계산하는지는 캐릭터마다 다를 것이 뻔하므로, 이 함수를 가상 함수로 선언하는 것이 확실한 설계일 것 같다.
1
2
3
4
5
6
class GameCharacter
{
public:
virtual int healthValue() const;
...
};
healthValue 함수가 순수 가상 함수로 선언되지 않은 것을 보아, 체력치를 계산하는 기본 알고리즘이 제공된다는 사실을 알 수 있다(항목 34 참조).
누가 뭐라 할 것도 없는, 너무나 당연한 설계이다. 이와 동시에, 또 어떤 의미로는 이것이 약점이다. 당연 그 자체의 설계이기 때문에, 이것 말고 적당한 다른 방법을 떠올리는 것도 잘 안 된다. 재미있어야 할 객체 지향 설계의 여정에서 뭔가 판에 박힌 건 아닌가 하며 우울해 할 우리를 구원하기 위해, 필자가 두 팔 다 걷었다. 다른 방법이 없는지 생각해 보자.
비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴
이번 이야기는 “가상 함수는 반드시 private 멤버로 두어야 한다”고 주장하는 소위 ‘가상 함수 은폐론’으로 시작하려고 한다. 이 이론을 따르는 사람들이 제안하는 더 괜찮은 설계는, healthValue를 public 멤버 함수로 그대로 두되 비가상 함수로 선언하고, 내부적으로는 실제 동작을 맡은 private 가상 함수를 호출하는 식으로 만드는 것이다. 가상 함수 이름은 doHealthValue 정도가 적당할 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameCharacter
{
public:
int healthValue() const
{
... // 사전 동작 수행
int retVal = doHealthValue(); // 실제 동작 수행
... // 사후 동작 수행
return retVal;
}
private:
virtual int doHealthValue() const
{
... // healthValue의 기본 동작을 구현
}
};
코드를 보면 알겠지만, 멤버 함수의 본문이 클래스 정의 안에 들어가 있다. 항목 30에서 이야기했듯, 이렇게 하면 암시적으로 인라인 함수로 선언된다. 필자가 코드를 이렇게 나타낸 이유는 진행 상황을 보기 쉽게 하기 위해서이다. 지금부터 보여주는 설계는 인라인 함수하고는 하등 관계가 없으니 오해하지 말자.
여기까지가 기본 설계이다. 사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 방법으로, 비가상 함수 인터페이스(non-virtual interface, NVI) 관용구라고 많이 알려져 있다. 사실 이 관용구는 템플릿 메서드(C++ 템플릿하고는 아무 관계가 없는 패턴 이름이다)라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것이다. 필자의 경우엔 이 관용구에 쓰이는 비가상 함수를 가상 함수의 랩퍼(wrapper)라고 부른다.
NVI 관용구의 이점은 필자가 위의 코드에 주석문으로 써둔 “사전 동작” 및 “사후 동작”에 전부 다 들어 있다. 글자 그대로, 주석문이 가리키는 부분은 실제 동작을 수행하는 가상 함수를 호출하기 전과 호출한 후에 호출될 코드들이 들어갈 자리이다. 다시 말해, 가상 함수가 호출되기 전에 어떤 상태를 구성하고 가상 함수가 호출된 후에 그 상태를 없애는 작업이 랩퍼를 통해 공간적으로 보장된다는 뜻이다. 예는 아주 많이 들 수 있다. 뮤텍스 잠근을 건다든지, 로그 정보를 만든다든지, 클래스의 불변속성과 함수의 사전조건이 만족되었나를 검증하는 작업 등이 “사전” 동작에 들어갈 수 있다. “사후” 동작으로는 뮤텍스 잠금을 푼다드지, 함수의 사후조건을 점검하고 클래스의 불변속성을 재검증하는 작업 등을 들 수 있다. 만약 사용자 쪽에서 가상 함수를 직접 호출하도록 놔두었다면, 지금처럼 사전/사후 동작을 끼워 넣을 좋은 방법이 있어 줄 리가 없었을 것이다.
그러고 보니, NVI 관용구를 쓰면 private 가상 함수를 파생 클래스에서 재정의하겠구나, 이런 생각이 들 것이다. 이 함수는 재정의해 놓고 호출할 수도 없는데 말이다. 하지만 여기에 설계상의 모순이 있는 것은 아니다. 가상 함수를 재정의하는 일은 어떤 동작을 어떻게 구현할 것인가를 지정하는 것이고, 가상 함수를 호출하는 일은 그 동작이 수행될 시점을 지정하는 것이다. 그러니 이 둘은 관심사 자체가 서로 무관한 것이다. NVI 관용구에서는 파생 클래스의 가상 함수 재정의를 허용하기 때문에, 어떤 기능을 어떻게 구현할지를 조정하는 권한은 파생 클래스가 갖게 되지만, 함수를 언제 호출할 지를 결정하는 것은 기본 클래스만의 고유 권한이다. 이 이야기는 처음엔 꽤나 이상하게 들릴 수 있으나, “상속받은 private 가상 함수를 파생 클래스가 재정의할 수 있다”라는 C++의 규칙에는 흠잡을 만한 것이 전혀 없다.
C++에서는 가상 함수의 제어 접근 권한과 오버라이딩 메커니즘은 완전히 독립적으로 작동한다. 위의 예시에서 healthValue 함수(public 비가상 함수) 내에서 doHealthValue 함수(private 가상 함수)를 호출한다. 컴파일러는 호출하는 함수(healthValue)가 public인가를 검사하고 통과된다. 그리고 런타임에서 healthValue 내부에서 doHealthValue를 호출할 때, 가상 함수 테이블을 보니 자식 클래스에서 이 함수를 재정의해 둔 것을 발견한다. 접근 제한자는 컴파일러가 검사하는 문법일 뿐이므로, 실행 시점에는 자식이 오버라이딩한 함수가 정상적으로 가리켜져 호출된다.
- 접근 제한자 : 컴파일러가 검사
- 가상 함수 오버라이딩 : 런타임에 가상 함수 테이블을 통해 결정
따져 보면 NVI 관용구에서 가상 함수는 엄격하게 private 멤버일 필요가 없다. 어떤 클래스 계통의 경우엔, 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우에 적법한 함수 호출이 되려면 그 가상 함수가 private 멤버가 아니가 protected 멤버이어야 한다. 간혹 가상 함수가 심지어 public 멤버이어야 할 때도 있지만(다형성 기본 클래스의 소멸자가 그 예이다. 항목 7 참조), 여기까지 오면 사실 NVI 관용구를 적용하는 의미가 없어진다.
함수 포인터로 구현한 전략 패턴
앞에서 보았듯이 NVI 관용구는 public 가상 함수를 대신할 수 있는 꽤 괜찮은 방법이지만, 클래스 설계의 관점에서 보면 눈속임이나 다름없다. 어쨌든 게임 캐릭터의 체력치를 계산하는 데 가상 함수를 사용하는 것은 여전하니까이다. 조금 더 극적인 설계 쪽으로 가 본다면, 캐릭터의 체력치를 계산하는 작업은 캐릭터의 타입과 별개로 놓는 편이 맞을 것이다. 다시 말해, 체력치 계산이 구태여 어떤 캐릭터의 일부일 필요가 없다는 말이다. 한 예로, 각 캐릭터의 생성자에 체력치 계산용 함수의 포인터를 넘기게 만들고, 이 함수를 호출해서 실제 계산을 수행하도록 하면 되지 않을까?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthCalcFunc(hcf)
{ }
int healthValue() const { return healthCalcFunc(*this); }
...
private:
HealthCalcFunc healthCalcFunc;
};
이 방법은 주변에서 많이들 쓰고 있는 디자인 패턴인 전략 패턴의 단순한 응용 예이다. GameCharacter 클래스 계통에 가상 함수를 심는 방법과 비교하면, 꽤 재미있는 융통성을 갖고 있다.
같은 캐릭터 타입으로부터 만들어진 객체들도 체력치 계산 함수를 각각 다르게 가질 수 있다. 즉, 이런 게 가능하다는 이야기이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
class EvilBadGuy : public GameCharacter { public: explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc) : GameCharacter(hcf) { ... } ... }; int loseHealthQuickly(const GameCharacter&); int loseHealthSlowly(const GameCharacter&); // 같은 타입인데도 체력치 변화가 다르게 나오는 캐릭터들 EvilBadGuy ebg1(loseHealthQuickly); EvilBadGuy ebg2(loseHealthSlowly);
게임이 실행되는 도중에 특정 캐릭터에 대한 체력치 계산 함수를 바꿀 수 있다. 예를 들어 GameCharacter 클래스에서 setHealthCalculator라는 멤버 함수를 제공하고 있다면 이를 통해 현재 쓰이는 체력치 계산 함수의 교체가 가능해 지는 것이다.
하지만, 장점이 있으면 단점도 있는 법이다. 체력치 계산 함수가 이제 GameCharacter 클래스 계통의 멤버 함수가 아니라는 점은, 체력치가 계산되는 대상 객체의 비공개 데이터는 이 함수에 접근할 수 없다는 뜻도 된다. 예를 들어, defaultHealthCalc 함수는 EvilBadGuy 객체의 public 멤버가 아닌 부분을 건드릴 수 없다. 뭐, 그 캐릭터의 public 인터페이스로 얻은 정보만을 사용해서 캐릭터의 체력치를 계산할 수 있게 되어 있다면 문제가 없겠지만, 정확한 체력치 계산을 위해서 public 멤버가 아닌 정보를 써야 할 경우에는 문제가 발생한다. 사실 이 부분은 클래스 내부의 기능을 그 바깥에 있는 동등한 기능으로 대체하려고 하면 언제든 생기는 고민거리이다. 여기서 끝날 이야기도 아니며, 이번 항목이 끝날 때까지 이어질 것이다. 왜냐하면 이후에 생각해 볼 다른 설계 방법들이 전부 GameCharacter 계통의 외부에 있는 함수를 사용하고 있기 때문이다.
public 영역에 없는 부분을 비멤버 함수도 접근할 수 있게 하려면 그 클래스의 캡슐화를 약화시키는 방법밖에는 없다는 것이 일반적인 법칙이다. 이를테면 비멤버 함수를 프렌드로 선언해 놓는다든지, 지금처럼 부득이한 이유가 아니면 숨겨 놓는 것이 더 나을지도 모르는 세부 구현사항에 대한 접근자를 public 멤버로 제공하는 일 등이 있겠다. 함수 포인터를 통해 얻는 이점들이 과연 GameCharacter 클래스의 캡슐화를 떨어뜨리면서 얻는 불이익을 채워 줄지 아닐지는 우리가 실제로 맡은 설계를 보면서 스스로 판단해야 한다.
tr1::function으로 구현한 전략 패턴
(C++ 11부터는 std::function으로 네임스페이스가 바뀌었다. 이후로도 std::function라고 부를게요~)
템플릿과 암시적 인터페이스에 대해 어색하지 않은 독자라면 함수 포인터 기반의 방법이 뭔가 꽉 막혀 보일 수 있다. “체력치 계산을 왜 꼭 함수가 해야 해? 그냥 함수처럼 동작하는 함수 객체를 쓰면 안 되나?”라고 반박하고 싶다. 혹여 반드시 함수여야 한다면, 어째서 멤버 함수는 안 되느냐는 의문도 나온다. 반환 값도 그렇다. int로 바꿀 수 있는 임의의 타입이면 충분하겠는데, 왜 꼭 int가 아니면 안 될까?
이런 것 저런 것, 신경 쓰자니 거북한데, std::function 타입의 객체를 써서 기존의 함수 포인터(healthFunc)를 대신하게 만드는 순간 이 모든 것들이 시원하게 사라진다. 항목 54를 본 사람은 알겠지만 std::function 계열의 객체는 **함수호출성 개체(callable entity) (풀어서 말하면 함수 포인터, 함수 객체 혹은 멤버 함수 포인터)를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그니처와 호환되는 시그니처를 갖고 있다. 그럼 이번에는 std::function을 써서 방금 본 클래스 설계를 바꿔보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
// HealthCalcFunc는 함수호출성 개체로서, GameCharacter와 호환되는 어떤 것이든 넘겨받아서 호출될 수 있으며,
// int와 호환되는 모든 타입의 객체를 반환한다.
typedef std::function<int(const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthCalcFunc(hcf)
{ }
int healthValue() const { return healthCalcFunc(*this); }
...
private:
HealthCalcFunc healthCalcFunc;
보다시피, HealthCalcFunc는 std::function 템플릿을 인스턴스화한 것에 대한 typedef 타입이다. 다시 말해 이 타입은 일반화된 함수 포인터 타입처럼 동작한다는 뜻이다. 그럼, HealthCalcFunc가 원래 어떤 것을 typedef한 것인지 다시 한 번 눈 크게 뜨고 들여다 보자.
1
typedef std::function<int(const GameCharacter&)>
std::function을 인스턴스화하기 위해 매개변수로 쓰인 “대상 시그니처”를 잘 보자(int (const GameCharacter&)). 이 대상 시그니처를 그대로 읽으면 “const GameCharacter에 대한 참조자를 받고 int를 반환하는 함수”이다. 이렇게 정의된 std::function 타입으로 만들어진 객체는 앞으로 대상 시그니처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있다. 여기서 ‘호환된다(compatible)’라는 말은, 함수 호출성 개체의 매개변수 타입이 const GameCharacter&이거나 const GameCharacter&으로 암시적 변환이 가능한 타입이며, 반환 타입도 암시적으로 int로 변환될 수 있다는 뜻이다.
바로 앞 소절에서 살펴본 설계(GameCharacter가 함수 포인터를 물게 했던)와 비교하면, 지금 설계도 사실 크게 다른 것은 없다. 다른 점이 있다면 GameCharacter가 이제는 std::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
26
27
28
29
30
31
32
short calcHealth(const GameCharacter&); // 반환 타입이 int가 아닌 부분에 주목
struct HealthCalculator // 체력치 계산용 함수 객체를 만들기 위한 클래스
{
int operator()(const GameCharacter&) const;
{ ... }
};
class GameLevel
{
public:
float healthCalc(const GameCharacter&) const; // 반환 타입이 int가 아닌 부분에 주목
...
};
class EvilBadGuy : public GameCharacter
{
... // 이전과 동일
};
class EyeCandyCharacter : public GameCharacter
{
... // 또 하나의 캐릭터 타입. 생성자는 EvilBadGuy 똑같다고 가정
};
EvilBadGuy ebg1(calcHealth);
EyeCandyCharacter ecc1(HealthCalculator());
GameLevel currentLevel;
...
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::healthCalc, currentLevel, _1));
(std::tr1::bind은 std::bind으로 바뀌었습니다. 이후로도 std::bind라고 부르겠습니다)
강요할 생각은 없지만, 이렇게 멋진 코드를 만들 수 있는 것은 순전히 std::function 템플릿 덕분이다.
위의 정의문이 말하는 바는, ebg2의 체력치를 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 써야 한다는 것이다. 현재, GameLevel::health 함수는 매개변수 하나를 받는 것으로 선언되어 있지만, 실제로는 두 개를 받는다. GameLevel 객체 하나를 암시적으로 받아들이니까 말이다. 이 객체는 this 포인터가 가리키는 것이다. 하지만 GameCharacter 객체에 쓰는 체력치 계산 함수가 받는 매개변수는 체력치가 계산되는 GameCharacter 객체, 그것 하나 뿐이다.
만일 ebg2의 체력치 계산에 GameLevel::health 함수를 쓰려고 한다면, 어떻게든 때려 맞추어야 할 것이다. 매개변수 두 개를 받는 함수를 매개변수 한 개만 받는 함수로 바꿔야 한단 말이다. 지금의 예제 코드에서는 ebg2의 체력치 계산에 쓸 GameLevel 객체로서 currentLevel만을 쓸 생각이므로, 우리는 GameLevel::health 함수가 호출될 때마다 currentLevel이 사용되도록 “묶어”준 것이다. std::bind는 바로 이 묶기 작업을 맡았다. 다시 말해, ebg2의 체력치 계산 함수는 항상 currentLevel만을 GameLevel 객체로 쓴다고 지정한 것이다.
쉽게 설명하기 힘든 세부사항이 많이 남았긴 했지만 넘어가야 한다. “_1”은 “ebg2에 대해 currentLevel과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수”를 뜻한다는 점도 이런 세부사항들 중 하나이다. 어쨌든 우리는 함수 포인터 대신에 std::function을 사용함으로써, 사용자가 게임 캐릭터의 체력치를 계산할 때, 시그니처가 호환되는 함수호출성 개체는 어떤 것도 원하는 대로 구사할 수 있도록 융통성을 활짝 열어 줬다는 것이다. 이거면 훌륭하다.
(추가로)
현대 C++에서는 std::bind 대신 람다 표현식을 사용하는 것이 더 일반적이다.
1
2
3
EvilBadGuy ebg2([¤tLevel](const GameCharacter& gc) {
return currentLevel.healthCalc(gc);
});
“고전적인” 전략 패턴
C++만으로 파고드는 방법보다 디자인 패턴 쪽에 더 조예가 깊은 사람들도 있을 것이다. 이런 사람들을 위한 더 전통적인 방법으로 구현한 전략 패턴도 알아보자. 체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력치 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.
GameCharacter가 상위 계통의 파생 클랫이고 EvilBadGuy 및 EyeCandyCharacter는 여기서 갈라져 나온 파생 클래스이며, 한편 HealthCalcFunc는 SlowHealthLoser, FastHealthLoser 등을 파생 클래스로 거느린 최상위 클래스이다. 그리고 GameCharacter 타입을 따르는 모든 객체는 HealthCalcFunc 타입의 객체에 대한 포인터를 포함해야 한다.
코드를 보면 다음과 같다.
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 GameCharacter;
class HealthCalcFunc
{
public:
...
virtual int cacl(const GameCharacter& gc) const { ... }
...
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter
{
public:
explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc)
: pHealthCalc(phcf)
{ }
int healthValue() const { return pHealthCalc->calc(*this); }
...
private:
HealthCalcFunc* pHealthCalc;
};
이 방법은 “표준적인” 전략 패턴 구현 방법에 친숙한 경우에 빨리 이해할 수 있다는 점에서 매력적이다. 게다가 HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 기존의 체력치 계산 알고리즘을 조정할 수 있는 가능성을 열어두었다는 점도 플러스이다.
지금까지 공부한 것들에 대한 요약
이번 항목의 핵심 조언은 ‘어떤 문제를 해결하기 위한 설계를 찾을 때 가상 함수를 대신하는 방법들도 고려해 보자’라는 것이다. 지금까지 살펴본 것들을 다시 간단히 정리해 보자.
- 비가상 인터페이스 관용구(NVI 관용구)를 사용한다 : 공개되지 않은 가상 함수를 비가상 public 멤버로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태이다.
- 가상 함수를 함수 포인터 데이터 멤버로 대체한다 : 군더더기 없는 전략 패턴의 핵심만을 보여주는 형태이다.
- 가상 함수를 std::function 데이터 멤버로 대체하여, 호환되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 만든다 : 역시 전략 패턴의 한 형태이다.
- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다 : 전략 패턴의 전통적인 구현 형태이다.
가상 함수 대신 쓸 수 있는 방법으로 이번 항목에서 본 네 가지가 전부는 아니지만, 대안이 얼마든지 있다는 점을 공부하는 데에는 충분하다. 게다가 각 방법들의 장단점까지 비교해 보았으니, 나중에 실제로 일이 생길 때 충분히 고려할 수 있을 것이다.
가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 형태이다.
객체의 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
std::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원한다.
참고