[Effective C++] 4. 설계 및 선언 [1/4]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
소프트웨어 설계는 시작할 때는 뜬 구름 잡는 일반적인 아이디어로 시작했다가, 끝날 무렵이면 질척한 인터페이스 설계에 이르게 된다. 그리고 이렇게 만들어진 인터페이스는 C++ 선언문으로 옮겨져야 함이 옳은 순서일 것이다. 어떻게 하면 좋은 C++ 인터페이스를 설계하고 선언할 수 있을까? 이 장에서는 바로 이 문제에 대해 이야기해보자. 일단 시작은 어떤 인터페이스를 설계하든지 막론하고 아마 가장 중요할 것 같은 지침, ‘제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게’로 시작해보자. 이 지침이 깔아 준 무대 위에서 정확성/효율/캡슐화/유지보수성/확장성 그리고 규약 준수에 이르는 인터페이스 설계에 얽힌 많고 많은 문제에 대한 구체적인 지침들을 만나게 될 것이다.
지금부터의 내용은 좋은 인터페이스 설계를 위해 필요한 모든 것을 제공한다고 볼 순 없지만, 가장 중요한 고려사항들 중 몇 가지는 또렷하게 조명하고 있고, 가장 흔한 잘못들 중 몇 개는 제대로 알려 주고 있으며, 클래스/함수/템플릿을 설계하는 데 있어 종종 신경을 건드리는 문제들의 해결방법도 꼼꼼히 준비되어 있다.
항목 18 : 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자.
C++에서는 발에 치이고 손에 잡히는 것이 인터페이스이다. 함수도 인터페이스요, 클래스도 인터페이스요, 템플릿도 인터페이스이다. 인터페이스는 사용자가 우리의 코드와 만리장성을 쌓는 접선수단이다. 우리의 제품을 사용하는 사용자는 작업을 제대로 하고 싶어 하는 게 당연할 것이다. 우리가 만든 인터페이스를 똑바로 쓰고 싶을 거란 말이다. 그러니까, 행여 잘못 사용했을 경우에 우리의 인터페이스가 최소한 항의의 몸부림이라도 보여주는 것은 어떻게 보면 의무이다. 이상적으로는, 어떤 인터페이스를 어떻게 써 봤는데 결과 코드가 사용자가 생각한 대로 동작하지 않는다면 그 코드는 컴파일되지 않아야 맞다. 거꾸로 생각해서, 어떤 코드가 컴파일된다면 그 코드는 사용자가 원하는 대로 동작해야 할 것이고 말이다.
‘제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운’ 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 한다. 예를 들어, 날짜를 나타내는 어떤 클래스에 넣을 생성자를 설계하고 있다고 가정하자.
1
2
3
4
5
class Date
{
public:
Date(int month, int day, int year);
...
별 문제 없을 것 같다. 그런데 여기에는 사용자가 쉽게 저지를 수 있는 오류가 적어도 두개나 있다. 우선 매개변수의 전달 순서가 잘못될 여지가 열려 있다는 것이 첫째이다.
1
Date d(30, 3, 1995); // 3월 30일이 아니라 30월 3일이 된다.
두 번째는 월과 일에 해당되는 숫자가 어이없는 숫자일 수 있다는 점이다.
1
Date d(3, 40, 1995); // 3월 40일이 된다.
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있다. 살다 보면 몸으로 느끼겠지만, 어처구니없는 코드가 컴파일되는 부조리로부터 우리를 지켜주는 오른팔이 바로 타입 시스템이다. 지금의 경우, 일, 월, 연을 구분하는 간단한 랩퍼 타입을 각각 만들고 이 타입을 Data 생성자 안에 둘 수 있을 것이다.
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
struct Day
{
explicit Day(int d) : val(d) {}
int val;
};
struct Month
{
explicit Month(int m) : val(m) {}
int val;
};
struct Year
{
explicit Year(int y) : val(y) {}
int val;
};
class Date
{
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // 타입이 맞지 않아서 컴파일이 안된다.
Date d(Day(30), Month(3), Year(1995)); // 타입이 맞지 않아서 컴파일이 안된다.
Date d(Month(3), Day(30), Year(1995)); // 굳
물론, Day 및 Month, Year에 데이터를 이것저것 숨겨 넣어 제몫을 하는 온전한 클래스로 만들면 위의 단순한 구조체보다는 확실히 낫겠지만 (항목 22 참조), 타입을 적절히 새로 준비해 두기만 해도 인터페이스 사용 에러를 막는 데는 충분하다는 것이다.
일단 적절한 타입만 제대로 준비되어 있으면, 각 타입의 값에 제약을 가하더라도 괜찮은 경우가 생기게 된다. 예를 들어 월이 가질 수 있는 유효한 값은 12개뿐이므로, Month 타입은 이 사실을 제약으로 사용할 수 있다. 한 가지 방법으로 월 표시 값을 나타내는 enum을 넣는 방법이 있는데, enum은 개발자들에게 유용하게 사용되지만 타입 안전성을 그리 믿음직하지 못한다. 떄로는 int처럼 쓰일 수 있다는 사실도 있다. 타입 안전성이 신경 쓰인다면 유효한 Month의 집합을 미리 정의해 두어도 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Month
{
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
...
static Month Dec() { return Month(12); }
...
private:
explicit Month(int m);
...
};
Date d(Month::Mar(), Day(30), Year(1995));
특정한 월을 나타내는 데 객체를 쓰지 않고 함수를 쓴 것은 항목 4에서 설명하였다. 비지역 정적 객체들의 초기화를 믿고 밀고 나가는 것은 문제가 있다.
예상되는 사용자 실수를 막는 다른 방법으로는 어떤 타입이 제약을 부여하며 그 타입을 통해 할 수 있는 일들을 묶어 버리는 방법이 있다. 제약 부여 방법으로 아주 흔히 쓰이는 예가 const 붙히기 이다. 항목 3에서 잘 설명해 놨는데, operator*의 반환 타입을 const로 한정함으로써 사용자가 사용자 정의 타입에 대해 다음과 같은 실수를 저지르지 않도록 할 수 있다.
1
if (a * b = c) ... // 비교하려고 했는데, 실수로 대입이 되어 버린다.
사실 이 이야기는 ‘제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운 타입 만들기’를 위한 또 하나의 일반적인 지침을 쉽게 알려 주려고 일부러 끄집어낸 것이다. 이름하여 ‘그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들지어다’ 라고 한다. int 등의 타입 정도는 사용자들이 그 성질을 이미 다 알고 있기 때문에, 우리가 사용자를 위해 만드는 타입도 웬만하면 이들과 똑같이 동작하게 만드는 센스를 갖추어라 이거다. 위의 문장을 봐도 그렇다. a와 b가 int라면 a*b에 대입한다는 게 말이 안된다. 그러니까 int와 굳이 다른 길을 걸어갈 이유가 없다면 우리가 만드는 타입에서도 이게 말이 안 되어야 한다는 것이다. 아리송하면, int의 동작 원리대로 만들자.
기본제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서이다. 제대로 쓰기에 괜찮은 인터페이스를 만들어 주는 요인 중에 일관성만큼 똑 부러지는 것이 별로 없으며, 편찮은 인터페이스를 더 나쁘게 만들어 버리는 요인 중에 비일관성을 따라오는 것이 거의 없다. STL 컨테이너의 인터페이스는 완벽하진 않지만 전반적으로 일관성을 갖고 있으며, 이 때문에 사용하는 데 큰 부담이 없다. 한 예로, 모든 STL 컨테이너는 size라는 멤버 함수를 개방해 놓고 있다. 이 함수는 어떤 컨테이너에 들어 있는 원소의 개수를 알려 준다.
자바의 경우, 배열에 대해선 length 프로퍼티를 사용하고 String에 대해선 length 메서드를 불러야 하며 List에 대해서는 size 메서드를 쓰도록 되어 있다. 닷넷은 어떨까? Array에서 이런 용도로 개방해 놓은 프로퍼티의 이름은 length인데, ArrayList에서 원소 개수를 세는 프로퍼티의 이름은 Count이다. 뭐 통합 개발 환경 (Integrated Development Environment, IDE)을 쓰다 보면 이름이 좀 달라도 크게 문제될 게 없다고 쉽게 넘기려는 개발자도 있지만, 그분들 실수하신 거다. 인터페이스의 이름들이 이렇게 다르면 개발자의 작업에 심적인 마찰이 생긴다.
사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽다. 언제라도 잊어버릴 수 있으니까 말이다. 팩토리 함수를 예로 들자. 이 함수는 Investment 클래스 게통에 있는 어떤 객체를 동적 할당하고 그 객체의 포인터를 반환하는 함수이다.
1
Investment* createInvestment();
이 함수를 사용할 때는 자원 누출을 피하기 위해 createInvestment에서 얻어낸 포인터를 나중에라도 삭제해야 한다. 그런데 이 점 때문에 사용자가 실수를 최소한 두 가지나 저지를 가능성이 만들어진다. 포인터 삭제를 깜박 잊을 수도 있고, 똑같은 포인터에 대해 delete가 두 번 이상 적용될 수 있다.
그래서 항목 13의 이후를 더 읽어 보면 createInvestment의 반환 값을 스마트 포인터에 저장한 후에 해당 포인터의 삭제 작업을 스마트 포인터에게 떠넘기는 방법을 확인할 수 있을 것이다. 하지만 이 스마트 포인터를 사용해야 한다는 사실도 사용자가 잊어버리면 어떻게 할까? 차라리 우리가 처음부터 끝까지 문제가 생길 여지를 일망타진해 놓는 편이 낫다. 많은 경우에 말이다. 즉, 애초에 팩토리 함수가 스마트 포인터를 반환하게 만드는 것이다.
1
std::tr1::shared_ptr<Investment> createInvestment();
이렇게 해 두면, 이 함수의 반환 값은 tr1::shared_ptr에 넣어둘 수 밖에 없을 뿐더러, 나중에 Investment 객체가 필요 없어졌을 때 이 객체를 삭제하는 것을 깜빡하고 넘어가는 불상사도 생기지 않을 것이다.
사실, tr1::shared_ptr을 반환하는 구조는 자원 해제에 관련된 상당수의 사용자 실수를 사전 봉쇄할 수도 있어서 여러모로 인터페이스 설계자에게 좋다. 이유는 항목 14에서도 이야기했듯이 tr1::shared_ptr은 생성 시점에 자원 해제 함수(삭제자)를 직접 엮을 수 있는 기능을 갖고 있기 때문이다.
이런 가정도 한번 해 보자. createInvestment를 통해 얻은 Investment* 포인터를 직접 삭제하지 않게 하고 getRidOfInvestment라는 이름의 함수를 준비해서 여기에 넘기게 하면 어까. 왠지 더 깔끔해 보이지만 이런 인터페이스는 되래 사용자 실수를 하나 더 열어놓는 결과를 가져온다. 자원 해제 매커니즘을 잘못 사용할 수가 있기 때문이다. (getRidOfInvestment를 잊어버리고 delete를 쓴다든지) createInvestment를 살짝 고쳐서, getRidOfInvestment가 삭제자로 묶인 tr1::shared_ptr을 반환하도록 구현해 둔다면 이런 문제는 발도 못 들여놓을 것이다.
tr1::shared_ptr에는 두 개의 인자를 받는 생성자가 있다. 첫 번쨰 인자는 이 스마트 포인터로 관리할 실제 포인터이고, 두 번째 인자는 참조 카운트가 0이 될 때 호출될 삭제자이다. 그러니까 tr1::shared_ptr을 널 포인터를 물게 함과 동시에 삭제자로 getRidOfInvestment를 갖게 하는 방법으로 다음과 같은 코드를 쓰면 안 될까 하는 생각이 든다.
1
2
// 이렇게 해서 사용자 정의 삭제자를 가진 널 sharded_ptr을 생성했으면 좋겠다. 하지만 컴파일이 안 된다.
std::tr1::shared_ptr<Investment> pInv(0, getRidOfInvestment);
이것은 제대로 쓴 C++ 코드가 아니다. tr1::shared_ptr의 첫 번째 매개변수로 포인터를 받아야 한다. 그런데 0은 포인터가 아니라 int이다. 물론 0은 포인터로 변환할 수 있지만, 지금의 경우에는 이것만으로는 부족하다. tr1::shared_ptr이 요구하는 포인터는 Investment* 타입의 실제 포인터이기 때문이다. 그래서 캐스트를 적용하여 사태를 해결한다.
1
std::tr1::shared_ptr<Investment> pInv(static_cast<Investment*>(0), getRidOfInvestment);
이제는 createInvestment 함수에서 getRidOfInvestment를 삭제자로 갖는 tr1::shared_ptr을 반환하도록 구현하는 방법이 어렴풋이 정리될 것이다. 아마 다음의 코드와 비슷할 것이다.
1
2
3
4
5
6
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment);
// retVal은 실제 객체를 가리키도록 만든다.
retVal = ...;
return retVal;
눈치 챈 사람도 있겠지만, retVal로 관리할 실제 객체의 포인터를 결정하는 시점이 retVal을 생성하는 시점보다 앞설 수 있으면, 위의 코드처럼 retVal을 널로 초기화하고 나서 나중에 대입하는 방법보다 실제 객체의 포인터를 바로 retVal의 생성자로 넘겨버리는 게 더 낫다. 이유가 궁금하면 항목 26을 보자.
tr1::shared_ptr에는 엄청 좋은 특징이 하나 있다. 바로 포인터별(pre-pointer) 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 또 하나의 잘못을 미연에 없애 준다는 점인데, 이 또 하나의 잘못이란 바로 ‘교차 DLL 문제(cross-DLL problem)’이다. 이 문제가 생기는 경우가 언제냐 하면, 객체 생성 시에 어떤 동적 링크 라이브러리(dynamically linked library, DLL)의 new를 썼는데 그 객체를 삭제할 때는 이전의 DLL과 다른 DLL에 있는 delete를 썼을 경우이다. 이렇게 new/delete 짝이 실행되는 DLL이 달라서 꼬이게 되면 대다수의 플랫폼에서 런타임 에러가 일어나게 된다. 그런데 tr1::shared_ptr은 이 문제를 피할 수 있다. 이 클래스의 기본 삭제자는 tr1::shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있기 때문이다. 무슨 뜻이냐 하면, 예를 들어 Stock이라는 클래스가 Investment에서 파생된 클래스이고 createInvestment 함수가 아래와 같이 구현되어 있다고 할 때,
1
2
3
4
std::tr1::shared_ptr<Investment> createInvestment()
{
return std::tr1::shared_ptr<Investment>(new Stock);
}
이 함수가 반환하는 tr1::shared_ptr은 다른 DLL들 사이에 이리저리 넘겨지더라도 교차 DLL 문제를 걱정하지 않아도 된다는 뜻이다. Stock 객체를 가리키는 tr1::shared_ptr은 그 Stock 객체의 참조 카운트가 0이 될 때 어떤 DLL의 delete를 사용해야 하는지를 꼭 붙들고 잊지 않는다.
이야기가 좀 길어지긴 했지만 이번 항목의 주제는 tr1::shared_ptr이 아니라 제대로 쓰기엔 쉽고 엉터리로 쓰기엔 어려운 인터페이스를 만들자는 것이다. 그러나 tr1::shared_ptr을 사용하면 사용자가 무심코 저지를 수 있는 실수 몇 가지를 쉽게 없앰으로써 그러한 인터페이스를 만드는 데 쉽게 다가갈 수 있다. 이 정도면 전반적인 사용법을 알아본 값어치를 한다고 생각한다. 참고로, tr1::shared_ptr을 구현한 제품 중 가장 흔히 쓰이는 것은 부스트 라이브러리(항목 55 참조)이다. 부스트의 shared_ptr은 일단 크기가 원시 포인터의 두 배이다. 그리고 내부 관리 데이터 및 삭제자 매커니즘을 돌릴 데이터를 위해 동적 할당 메모리를 사용하며, 다중스레드로 돌아가는 프로그램을 지원할 경우에는 참조 카운트를 변경할 때 스레드 동기화 오버헤드를 일으킨다. 간단히 말해 이 클래스를 사용하면 원시 포인터보다 크고 느리며 게다가 내부 관리용 동적 메모리까지 추가로 매달린다. 하지만 이런 것들 때문에 응용 프로그램에서 런타임 비용이 눈에 띄게 늘어나는 경우는 어지간해서는 찾기 힘들 것입니다. 반면에 사용자 실수가 눈에 띄게 줄어드는 경우는 모든 사람들이 잡아낼 수 있을 정도이다.
좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민하자.
인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있다.
사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있다. tr1::shared_ptr은 사용자 정의 삭제자를 지원한다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있다.
항목 19 : 클래스 설계는 타입 설계와 똑같이 취급하자.
여느 객체 지향 프로그래밍 언어와 마찬가지로, C++에서 새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의한다는 것과 같다. 그러니까 C++ 개발자로서 우리가 보내는 시간은 우리 자신의 타입 시스템을 키워가는 데 들어가는 시간인 셈이다. 바꿔 말해 우리는 그냥 클래스 설계자로 그치지 않으며 타입 설계자라는 권위를 가지고 있다는 이야기이다. 함수와 연산자를 오버로드하고, 메모리 할당 및 해제를 제어하며, 객체 초기화 및 종료처리를 정의하는 작업, 이 모두가 우리 손에 달려 있다.
그렇기 때문에 클래스를 설계할 때는 마치 언어 설계자가 그 언어의 기본제공 타입을 설계하면서 쏟아 붓는 것과 똑같은 정성과 보살핌이 필요하다.
좋은 클래스를 설계하기란 꽤 까다로운 도전이다. 좋은 타입을 설계하기가 그리 만만한가? 좋은 타입은 일단 문법이 자연스럽고, 의미구조가 직관적이며, 효율적인 구현이 한 가지 이상이 가능해야 하는데, C++에서는 충분한 고민 없이 클래스 정의를 했다가는 이 세 가지 중 어느 것도 달성하기 힘들다. 심지어 멤버 함수조차도 어떻게 선언되었느냐에 따라 수행 성능이 달라진다.
우리가 직면하게 될 고려사항이 무엇인지부터 파악하는 것이 우선이다. 어떤 클래스를 설계하든 간에 사실상 모든 경우에 우리의 머리를 괴롭힐 질문을 모아봤다. 이들 질문의 대답에 따라 설계를 제한하는 것들이 생기게 되는데, 이 부분 역시 신경쓰지 않으면 안된다.
새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
이 부분이 어떻게 되느냐에 따라 클래스 생성자 및 소멸자의 설계가 바뀐다. 그뿐 아니라 메모리 할당 함수를 직접 작성할 경우에는 이들 함수의 설계에도 영향을 미친다. (8장 참조)
객체 초기화는 객체 대입과 어떻게 달라야 하는가?
생성자와 대입 연산자의 동작 및 둘 사이목의 차이점을 결정짓는 요소이다. 초기화와 대입을 헷갈리지 않는 것이 가장 중요한데, 각각에 해당되는 함수 호출이 아예 다르기 때문이다. (항목 4 참조)
새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
여기서 잊으면 안되는 포인트를 하나 알려주자면, 어떤 타입에 대해 ‘값에 의한 전달’을 구현하는 쪽은 바로 복사 생성자이다. 기억해두자.
새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
전부는 아니지만, 클래스의 데이터 멤버의 몇 가지 조합 만은 반드시 유효해야 한다. 이런 조합을 가리켜 클래스의 불변속성(invariant)라고 하며, 클래스 차원에서 지켜주어야 하는 부분이다. 이 불변속성에 따라 클래스 멤버 함수 안에서 해 주어야 할 에러 점검 루틴이 좌우되는데, 특히 생성자, 대입 연산자, 각종 쓰기(setter) 함수는 불변속성에 많이 좌우된다. 그뿐 아니라 불변속성은 우리의 함수가 발생시키는 예외에도 영향을 미치며, 혹시나 우리가 예외 지정(exception specification)을 쓴다면 그 부분에도 영향을 준다.
기존의 클래스 상속 계통망에 맞출 것인가?
이미 갖고 있는 클래스로부터 상속을 시킨다고 하면, 당연히 우리의 설계는 이들 클래스에 의해 제약을 받게 된다. 특히 멤버 함수가 가상인가 비가상인가의 여부가 가장 큰 요인이다. 우리가 만든 클래스를 다른 클래스들이 상속할 수 있게 만들자고 결정했다면, 이에 따라 가상 함수 여부가 결정된다. 특히 소멸자가 그렇다.
어떤 종류의 타입 변환을 허용할 것인가?
우리가 만든 타입은 결국 기존의 수 많은 타입들과 어울려야 하는 운명을 짊어진다. 우리의 타입과 다른 타입 사이에 변환 수단이 있어야 할까? T1 타입의 객체를 T2 타입의 객체로 암시적으로 변환되기 만들고 싶다면, T1 클래스에 타입 변환 함수를 하나 넣어두든가(이를테면 operator T2) 아니면 인자 한 개로 호출될 수 있는 비명시호출(non-explicit) 생성자를 T2 클래스에 넣어두어야 할 것이다. 명시적(explicit) 타입 변환만 허용하고 싶을 경우에는, 해당 변환을 맡는 별도 이름의 함수를 만들되 타입 변환 연산자 혹은 (인자 하나로 호출될 수 있는) 비명시호출 생성자는 만들지 말아야 할 것이다. (명시적 변환 및 암시적 변환 함수의 예제는 항목 15에서 볼 수 있다)
어떤 연산자와 함수를 두어야 의미가 있을까?
우리의 클래스 안에 선언할 함수가 바로 여기서 결정된다. 어떤 것들은 멤버 함수로 적당할 것이고, 또 몇몇은 그렇지 않을 것이다. (항목 23, 24, 46 참조)
표준 함수들 중 어떤 것을 허용하지 말아야 할 것인가?
private으로 선언해야 하는 함수가 여기에 해당된다. (항목 6 참조)
새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
어떤 클래스 멤버를 public, protected, private 영역에 둘 것인가를 결정하는 데 도움을 주게 될 질문이다. 또한 프렌드로 만들어야 할 클래스 및 함수를 정하는 것은 물론이고 한 클래스를 다른 클래스에 중첩시켜도 되는가에 대한 결정을 내리는 데도 이 질문이 우리를 거들어 줄 것이다.
‘선언되지 않은 인터페이스’로 무엇을 둘 것인가?
우리가 만들 타입이 제공할 보장이 어떤 종류일까에 대한 질문으로서, 보장할 수 있는 부분은 수행 성능 및 예외 안전성(항목 29 참조) 그리고 자원 사용(잠금 및 동적 메모리 등)이다. 이들에 대해 우리가 보장하겠다고 결정한 결과는 클래스 구현에 있어 제약으로 작용하게 된다.
새로 만드는 타입이 얼마나 일반적인가?
실상은 타입 하나를 정의하는 것이 아닐지도 모른다. 우리가 정의하는 것이 동일 계열 타입군 전체일지도 모른다. 진짜 그렇다면 우리가 원하는 것은 새로운 클래스가 아니라 새로운 클래스 템플릿을 정의해야 할 것이다.
정말로 꼭 필요한 타입인가?
기존의 클래스에 대해 기능 몇 개가 아쉬워서 파생 클래스를 새로 뽑고 있다면, 차라리 간단하게 비멤버 함수라든지 템플릿을 몇 개 더 정의하는 편이 낫다.
어느 것 하나 만만하게 볼 수 없는 질문이다. 그래서 효과적인 클래스를 정의한다는 일이 무척이나 어렵다는 말이 나오는 것이다. 하지만 이런 역경을 뚫고 설계된 사용자 정의 클래스는 최소한 기본제공 타입 정도의 든든함을 가진 타입, 동시에 설계에 들인 노력만큼의 가치를 지닌 타입으로 쓰일 것이다.
클래스 설계는 타입 설계이다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보자.
참고