[Effective C++] 6. 상속, 그리고 객체 지향 설계 [3/4]
이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 36 : 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
D라는 이름의 클래스가 B라는 이름의 클래스로부터 public 상속에 의해 파생되었고, B 클래스에는 mf라는 이름의 public 멤버 함수가 정의되어 있다고 가정하자. mf의 매개변수나 반환 타입은 중요하지 않기에 void라고 하자.
1
2
3
4
5
6
7
8
class B
{
public:
void mf();
...
};
class D : public B { ... };
B나 D, 혹은 mf에 대해 전혀 모르는 상태에서 D 타입의 객체인 x가 다음처럼 있다고 할 때,
1
D x;
다음과 같이 작성된 코드가,
1
2
B *pB = &x;
pB->mf();
다음처럼 동작하지 않으면 꽤나 황당할 것이다.
1
2
D *pD = &x;
pD->mf();
황당한 이유는 간단하다. 양쪽의 경우에서 한결같이 x 객체로부터 mf 멤버 함수를 호출하고 있기 때문이다. 함수도 똑같고 객체도 똑같으니 동작도 같아야 하는 게 맞지 않는가.
그런데 다를 수도 있다는 게 문제이다. 특히, mf가 비가상 함수이고 D 클래스가 자체적으로 mf 함수를 또 정의하고 있으면 위와 같은 황당한 동작이 나오게 된다.
1
2
3
4
5
6
7
8
9
class D : public B
{
public:
void mf(); // B::mf를 가려 버린다.
...
};
pB->mf(); // B::mf가 호출된다.
pD->mf(); // D::mf가 호출된다.
이렇게 두 얼굴의 동작을 하는 이유는, B::mf 및 D::mf 등의 비가상 함수는 정적 바인딩(static binding) 묶이기 때문이다(항목 37 참조). 무슨 뜻인고 하니, pB는 ‘B에 대한 포인터’ 타입으로 선언되었기 때문에, pB를 통해 호출되는 비가상 함수는 항상 B 클래스에 정의되어 있을 것이라고 결정해 버린다는 말이다. 심지어 B에서 파생된 객체를 pB가 가리키고 있다 해도 마찬가지이다. 위의 예제에서 본 바와 같다.
반면, 가상 함수의 경우엔 동적 바인딩(dynamic binding)으로 묶인다(역시 항목 37 참조). 비가상 함수와 같은 문제로 골머리를 썩을 이유가 없다. 만약 mf가 가상 함수였다면, mf가 pB에서 호출되든 pD에서 호출되든 D::mf가 호출되었을 것이다. pB 및 pD가 진짜로 가리키는 대상은 D 타입의 객체이니까.
만약 우리가 D 클래스를 만드는 도중에 B 클래스로부터 물려받은 비가상 함수인 mf를 재정의해 버리면, D 클래스는 일관성 없는 동작을 보이는 이상한 클래스가 된다. 특히, 분명히 D 객체인데도, 이 객체에서 mf 함수를 호출하면 B와 D 중 어느 쪽의 mf가 호출될지 알 수 없는 상황이 벌어질 수 있다. 게다가 B냐 D냐를 좌우하는 요인이 해당 객체 자신이 아니라, 그 객체를 가리키는 포인터의 타입이란 점이 심각하게 암울하다. 참조자도 마찬가지이다.
그러니 위의 이야기는 코드를 실제로 작성했을 때 이런 일이 생길 수 있다고 말하는 것 뿐이다. 상속받은 비가상 함수를 재정의하면 안 되는 이유에 대한 이론적인 설명을 해보자.
항목 32를 읽은 사람은 알겠지만 public 상속의 의미는 ‘is-a’ 관계이다. 그리고 항목 34에서 한 설명에 의하면 비가상 멤버 함수는 클래스 파생에 관계없는 불변동작을 정해 두는 거라고 이야기했다. 이 두 가지 포인트를 B, D 클래스 및 비가상 멤버 함수은 B::mf에 그대로 가져가면, 이렇게 풀 수 있다.
- B 객체에 해당되는 모든 것들이 D 객체에 그대로 적용된다. 왜냐하면 모든 D 객체는 B 객체의 일종이기 때문이다.
- B에서 파생된 클래스는 mf 함수의 인터페이스와 구현을 모두 물려받게 된다. mf는 B 클래스에서 비가상 멤버 함수이기 때문이다.
자, 이제 D에서 mf를 재정의한단 말이다. 그 순간 우리의 설계에 모순이 생겨 버린다. 만약 mf를 B와 다르게 구현한 것이 진짜로 원해서 그런 거였고, 그리고 B 및 B의 파생 클래스로부터 만들어진 모든 객체가 B의 mf 구현을 사용해야 한다고 정한 것이 진짜라면, mf의 재정의로 인해 ‘모든 D는 B의 일종’이란 명제는 거짓이 된다. 이런 상황이라면, D는 B로부터 public 상속을 받으면 안 된다. 한편, D는 B로부터 public 상속을 받아 파생시킬 수밖에 없는 사정이 있고, 진짜로 D에서 mf 함수를 B의 그것과 다르게 구현해야 한다면, ‘mf는 클래스 파생에 상관없이 B에 대한 불변동작을 나타낸다’라는 점도 거짓이 된다. 이런 경우라면 mf는 가상 함수로 만들어지는 것이 맞다. 마지막으로, 만약 모든 D가 B의 일종이고 정말 mf가 클래스 파생에 상관없는 B의 불변동작에 해당한다면, D에서는 결단코 mf를 재정의할 생각도 할 수 없다. 시도도 하지 말아야 한다.
어떤 상황에서도 상속받은 비가상 함수를 재정의하는 것은 절대 금물이다.
항목 7에서 비슷한 내용을 보았는데 바로, 다형성을 부여한 기본 클래스의 소멸자를 반드시 가상 함수로 만들어 두어야 하는 이유를 공부했었다. 그런데 항목 7의 이야기대로 하지 않으면, 그러니까 다형성 기본 클래스에서 비가상 소멸자를 선언해 버리면, 결국 이번 항목에서 배운 내용도 듣지 않은 것이 된다. 파생 클래스에서 상속받은 비가상 소멸자를 재정의할 것이 뻔하기 때문이다. 심지어 소멸자를 선언하지 않은 경우에도 컴파일러가 자동으로 만들어주기 때문이다. 따져보면 항목 7의 내용은 사실 이번 항목의 특수한 한 경우라는 것을 알 수 있다.
상속받은 비가상 함수를 재정의하는 일은 절대로 하지 말자.
항목 37 : 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 정의하지 말자
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
23
// 기하학 모형을 나타내는 클래스
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 override;
...
};
class Circle : public Shape
{
public:
virtual void draw(ShapeColor color) const;
...
};
이들을 써서 포인터를 나타내면 어떻게 되나 보자.
1
2
3
4
5
6
7
8
// 정적 타입 = Shape*
Shape *ps;
// 정적 타입 = Shape*
Shape *pc = new Circle;
// 정적 타입 = Shape*
Shape *pr = new Rectangle;
여기서 ps, pc, pr은 모두 Shape에 대한 포인터로 선언되어 있기 때문에, 각각의 정적 타입도 모두 이 타입이다. 단, 그렇다고 해서 이들이 진짜로 가리키는 대상이 달라지는 것은 하나도 없다. 그냥 정적 타입이 Shape*일 뿐이다.
객체의 *동적 타입(dynamic type)은 현재 그 객체가 진짜로 무엇이냐에 따라 결정되는 타입이다. 다시 말해, ‘이 객체가 어떻게 동작할 것이냐’를 가리키는 타입이 동적 타입이라 하겠다. 위의 예제를 가지고 말하면, pc의 동적 타입은 Circle이고, pr의 동적 타입은 Rectangle*이다. ps의 경우엔 동적 타입이 없다. 아직 아무 객체도 참조하조 있지 않으니까.
동적 타입은 이름에서 풍기는 느낌 그대로 프로그램이 실행되는 도중에 바뀔 수 있다. 대개 대입문을 통해 바뀐다. 다음의 예제를 봐 보자.
1
2
ps = pc; // ps의 동적 타입은 이제 Circle*이다.
ps = pr; // ps의 동적 타입은 이제 Rectangle*이다.
가상 함수는 동적으로 바인딩된다. 가상 함수의 호출이 일어난 객체의 동적 타입에 따라 어떤 가상 함수가 호출될지가 결정된다는 뜻이다.
1
2
pc->draw(Shape::Red); // Circle::draw(Shape::Red)가 호출된다.
pr->draw(Shape::Red); // Rectangle::draw(Shape::Red)가 호출된다
여기까지는 모두가 아는 내용이다. 가상 함수 정도는 다 알고 있을 것이다. 그런데 ‘기본 매개변수 값이 설정된’ 가상 함수로 오게 되면 뭔가 꼬이기 시작한다. 이유는 앞에서 말했듯이, 가상 함수는 동적으로 바인딩되어 있지만 기본 매개변수는 정적으로 바인딩되어 있기 때문이다. 그러니까, 파생 클래스에 정의된 가상 함수를 호출하면서 기본 클래스에 정의된 기본 매개변수 값을 사용해 버릴 수 있다는 이야기이다.
1
pr->draw(); // Rectangle::draw(Shape::Red)를 호출해 버린다.
이 경우 pr의 동적 타입이 Rectangle이므로, 호출되는 가상 함수는 Rectangle의 것이다. 여기까지는 우리도 예상했을 것이다. Rectangle::draw 함수에서는 기본 매개변수 값이 Green으로 되어 있다. 하지만 pr의 정적 타입은 Shape이기 때문에, 지금 호출되는 가상 함수에 쓰이는 기본 매개변수 값을 Shape 클래스에서 가져온다. Rectangle 클래스여야 할 것 같은데 말이다. 그 결과, Shape 및 Rectangle 클래스 양쪽에서 선언된 것이 한데 섞이는, 참으로 이상하고 거의 누구도 예상할 수 없으리만치 기상천외한 함수 호출이 이루어지는 것이다.
ps, pc, pr이 포인터이기 때문에 그런 거라고 할 만한 변명거리도 없다. 포인터가 아니라 참조자라도 문제는 여전히 문제이다. 중요한 점은 draw 함수가 가상 함수이고, 기본 매개변수 값들 중 하나가 파생 클래스에서 재정의되면 여지없이 문제가 생긴다는 것이다.
어째서 C++는 이토록 삐딱한 동작방식을 고집하는 것일까? 여기에는 런타임 효율이라는 요소가 깊이 숨어 있다. 만약에 함수의 기본 매개변수가 동적으로 바인딩된다면, 프로그램 실행 중에 가상 함수의 기본 매개변수 값을 결정할 방법을 컴파일러 쪽에서 마련해 주어야 할 것이다. 아무래도 이 방법은 컴파일 과정에서 결정하는 현재의 메커니즘보다는 느리고 복잡할 것이 분명할 것이다. 지금의 메커니즘은 속도 유지와 구현 간편성에 무게를 더 두어 결정 내릴 결과이고, 그 덕택에 우리는 효율 좋은 실행 동작을 누릴 수 있게 된 것이다. 그러나 이번 항목의 이야기를 모르거나 살짝 잊고 사는 사람이라면 이런 동작은 어이없고 헷갈릴 수밖에 없을 것이다.
어쨌든 피가 되고 살이 되는 다 좋은 이야기였지만, 지금 말한 이야기를 잘 따라서 기본 클래스 및 파생 클래스의 사용자에게 기본 매개변수 값을 똑같이 제공해 보려고 하면 어떻게 되는지도 두 눈으로 확인해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 = Red) const override;
...
};
언제 보아도 반갑지 않은 코드 중복이다. 더 안 좋은 것은 코드 중복에 의존성까지 걸려 있다는 점이다. Shape 클래스에서 기본 매개변수 값이 변하기라도 해 보자. 이 값을 반복하고 있는 파생 클래스는 모두 그 값을 바꿔야 할 것이다. 그렇게 하지 않으면 상속받은 기본 매개변수 값을 재정의하는 꼴이 된다. 그럼 대체 어쩌란 말일까?
우리가 원하는 대로 가상 함수가 동작하도록 만드는 일이 힘들다고 느껴지면 다른 설계 방법으로 눈을 돌리는 편이 현명한 방법이다. 항목 35를 보면 가상 함수 대신에 쓸 수 있는 방법들 몇 개가 소개되어 있다. 이들 중 하나를 잘 쓰면 될 것 같은데, 바로 비가상 인터페이스(non-virtual interface) 관용구(NVI 관용구)를 쓰는 것이다. 이 방법은 파생 클래스에서 재정의할 수 있는 가상 함수를 private 멤버로 두고, 이 가상 함수를 호출하는 public 비가상 함수를 기본 클래스에 만들어 두는 것이다. 여기서 이 방법을 응용한다면, 비가상 함수가 기본 매개변수를 지정하도록 할 수 있겠다. 이 비가상 함수의 내부에서는 진짜 일을 맡은 가상 함수를 호출하게 만들면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 override;
};
비가상 함수는 파생 클래스에서 오버라이드되면 안 되기 때문에, 위와 같이 설계하면 draw 함수의 매개변수에 대한 기본 값을 깔끔하게 Red로 고정시킬 수 있다.
상속받은 기본 매개변수 값은 절대로 재정의해서는 안 된다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수(우리가 오버라이드할 수 있는 유일한 함수)는 동적으로 바인딩되기 때문이다.
항목 38 : “has-a(…는 …를 가짐)” 혹은 “is-implemented-in-terms-of(…는 …를 써서 구현됨)”를 모형화할 때는 객체 합성을 사용하자.
합성(Composition)이란, 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 그 타입들 사이의 관계를 일컫는다. 포함된 객체들을 모아서 이들을 포함한 다른 객체를 합성한다는 뜻인데, 이를테면 다음과 같은 경우이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Address { ... };
class PhoneNumber { ... };
class Person
{
public:
...
private:
// 이 클래스를 이루는 객체들
std::string name;
Address address;
PhoneNumber voiceNumber;
PhoneNumber faxNumber;
};
예제를 보면 알겠지만 Person 객체는 string, Address, PhoneNumber 객체로 이루어져 있다. 개발자들 사이에선 ‘합성‘대신에 다른 용어들도 많이 쓰인다 이를테면 레이어링(layering), 포함(containment), 통합(aggregation), 내장(embedding) 등으로도 알려져 있다.
항목 32에서 public 상속의 의미가 ‘is-a’ 관계라고 했었다. 객체 합성 역시 의미를 갖고 있다. 실제로는 뜻이 두 개나 되는데, “has-a”를 뜻할 수도 있고, “is-implemented-in-terms-of”를 뜻할 수도 있다. 이렇게 뜻이 두 개인 이유는 소프트웨어 개발에서 우리가 대하는 영역(domain)이 두 가지이기 때문이다. 객체 중에는 우리 일상생활에서 볼 수 있는 사물을 본 뜬 것들이 있다. 이를테면 사람, 이동수단, 비디오 프레임 등인데, 이런 객체는 소프트웨어의 응용 영역(application domain)에 속한다. 응용 영역에 속하지 않는 나머지들은 버퍼, 뮤텍스, 탐색트리 등 순수하게 시스템 구현만을 위한 인공물이다. 이런 종류의 객체가 속한 부분은 소프트웨어의 구현 영역(implementation domain)이라고 불린다. 여기서 객체 합성이 응용 영역의 객체들 사이에서 일어나면 “has-a” 관계가 되고, 구현 영역의 객체들 사이에서 일어나면 “is-implemented-in-terms-of” 관계가 된다.
위의 예제에서 Person 클래스가 나타내는 관계는 has-a 관계이다. 하나의 Person 객체는 이름, 주소, 음성전화 및 팩스전화 번호를 가지고 있다. 사람이 이름의 일종(Person is a name)이라든지 사람이 주소의 일종(Person is an address)이라고 말할 수 없을 것이다. 사람이 이름을 가지며 사람이 주소를 가진다고 말하는 것이 자연스럽다. 이렇게 구분하는 데 어려움을 느끼는 사람은 거의 없으므로, is-a 관계와 has-a 관계의 역할을 헷갈리는 경우는 그다지 없다.
상대적으로 오락가락하는 부분이 바로 is-a 관계와 is-implemented-in-terms-of 관계의 차이점일 것이다. 예를 하나 들어 보자. 객체로 구성된 작은 집합(set), 정확히 말해서 중복 원소가 없는 집합체를 나타내고 저장 공간도 적게 차지하는 클래스의 템플릿이 하나 필요하다고 가정해보자. 코드 재사용이야말로 개발자가 할 수 있는 가장 장하고 갸륵한 일이라고들 하길래, 우리는 표준 라이브러리의 set 템플릿을 활용해 보기로 결정한다. 이미 누군가가 훌륭하게 만든 것을 그대로 쓸 수 있는데 구태여 새 템플릿을 작성할 이유가 없다.
그런데 재수가 없으려니 시작부터 문제가 생겼다. set 템플릿이 원소 한 개당 퐁니터 세 개의 오버헤드가 걸리도록 구현되어 있다는 점이 신경을 건드린다. 그럴 만도 한 것이, set 템플릿은 대개 균형 탐색 트리(balanced search tree)로 구현되어 있기 때문이다. 탐색, 삽입, 삭제에 걸리는 시간 복잡도를 로그 시간으로 보장하기 위해 이렇게 만든 것이다. 속력이 공간보다 더 중요한 분야에서는 통하는 설계이지만, 지금 우리에게 필요한 것은 속력보다는 공간이다. 그러니까 표준 라이브러리의 set 템플릿은 적당하지 않다. 아무래도 우리가 템플릿을 그냥 만들어야 할 것 같다는 생각이 들려는 순간이다.
게으르다고 팀장에게 육두문자를 들어 먹을지언정, 코드 재사용은 여전히 개발자가 할 수 있는 가장 장하고 갸륵하며 훌륭한 일이다. 자료구조만큼은 꽤 한다고 생각하는 우리들이기에, 집합 클래스 템플릿을 구현할 때 쓸 만한 것들도 이미 머릿속에 준비 완료해 놓았다. 이 중에서 연결 리스트를 꺼내 보자. 표준 C++ 라이브러리에 list 템플릿이 있다는 사실도 역시 알고 있는 우리들이기에, 이것을 재사용하기로 결정한다.
이번 재사용의 포인트는, Set 템플릿을 만들되 list에서 파생된 형태로부터 시작하도록 만든다는 것이다. 다시 말해, Set
1
2
3
// Set을 만든답시고 list를 잘못 쓰는 방법
template <typename T>
class Set : public std::list<T> { ... };
모든 것이 순조롭게 풀릴 것 같은 코드이지만, 사실 잘못되어 버렸다. 항목 32에서 설명 했듯이, D와 B 사이에 is-a 관계가 성립하면 B에서 참인 것들이 전부 D에서도 참이어야 한다. 하지만 list 객체는 중복 원소를 가질 수 있는 컨테이너란 말이다. 그러니까 3051이란 값이 list
이들 두 클래스 사이의 관계는 is-a가 될 리 없으므로, public 상속은 지금의 관계를 모형화하는 데 맞지 않는다. 정답은 이번 항목의 제목에 있는데, Set 객체는 list 객체를 써서 구현되는(is implemented in terms of) 형태의 설계가 가능하다는 사실을 잡아내는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
template <class T>
class Set
{
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep; // Set 데이터의 내부 표현부
};
Set의 멤버 함수는 list에서 이미 제공하는 기능 및 표준 C++ 라이브러리의 다른 구성 요소를 잘 버무려서 만들기만 하면 되기 때문에, 실제 구현은 아주 쉽게 이해할 수 있을 정도로 간단하다. 우리가 STL 쓰는 방법만 어색하지 않다며 말이다.
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
template<typename T>
bool Set<T>::member(const T& item) const
{
return std::find(rep.begin(), rep.end(), item) != rep.end();
}
template<typename T>
void Set<T>::insert(const T& item)
{
if (!member(item))
rep.push_back(item);
}
template<typename T>
void Set<T>::remove(const T& item)
{
typename std::list<T>::iterator it = std::find(rep.begin(), rep.end(), item);
if (it != rep.end())
rep.erase(it);
}
template<typename T>
std::size_t Set<T>::size() const
{
return rep.size();
}
보다시피 너무 간단해서 인라인 함수로 만들어도 될 정도의 구현이다. 물론, 인라인 함수로 만들 것인지를 진지하게 고민한다면 항목 30에서 거론한 이야기를 찬찬히 읽어 보는 것이 좋다.
위의 코드에 대해 뭔가 더 이야기하고 싶은 독자도 있을 것이다. 어차피 만든 거, STL 컨테이너 규약에 똑바로 맞추어서 구현했다면 “인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자”라고 주장하는 항목 18의 이야기에 더 잘 부합하지 않겠냐고 말하는 독자도 있을 것이다. 그러나 그 규약에 맞추려면 Set에 이렇게 저렇게 우겨 넣어야 할 것들이 많은데, 이걸 다 하다가는 결국 Set과 list 사이의 관계가 드러나지 못할 게 뻔하다. 이번 항목에서 눈 크게 뜨고 기억해야 하는 것이 바로 이 ‘관계’이므로, 원칙적인 STL 호환성을 살짝 뒤로 하고 내용 전달의 명확성을 살릴 거라고 보면 된다. 게다가, 지금 Set에서 진짜로 중요한 점은 주렁주렁 인터페이스가 아니라 바로 list와의 관계란 말이다. 다시 말하지만 이 관계는 ‘is-a’가 아니라 ‘is-implemented-in-terms-of’이다.
객체 합성(composition)의 의미는 public 상속이 가진 의미와 완전히 다르다.
응용 영역에서 객체 합성의 의미는 has-a(…는 …를 가짐)이다. 구현 영역에서는 is-implemented-in-terms-of(…는 …를 써서 구현됨)의 의미를 갖는다.
참고