[Effective C++] 5. 구현 [1/3]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
요구 사항에 맞추어 클래스 집합의 정의를 마치고 우리가 쓸 함수의 선언문까지 다 끌어내기만 하면, 기획자들의 온갖 상상들이 쏟아지는 소프트웨어 개발에서 필요한 연장은 대체로 다 만들었다고 볼 수 있다. 클래스와 함수의 골격이 준비된 이상, 여기에 맞추어 코드의 살을 붙이는 구현 작업은 별로 어렵지 않다. 그렇다고 구현을 대충해도 된다는 건 아니다. 변수를 너무 앞서서 정의하면 갑자기 떨어지는 수행 성능에 당황해 할 수도 있다. 캐스트를 남용했다가는 느리고 손보기도 힘들며 미묘한 버그에 치명타를 입을 코드가 탄생할 위험도 있다. 객체 내부에 대한 핸들을 반환하게 만들면 캡슐화가 깨짐은 물론이고 유효하지 않은 핸들이 남는 등 좋지 않은 문제가 생긴다. 또한 예외 발생 시 어떤 일이 생기는지를 모르고 지나쳐 버리면 자원이 누출되고 자료구조가 망가질 가능성도 있다. 신의 계시라도 받은 듯 인라인 함수를 끼워 넣기에 열심이다가는 터질듯이 부푼 코드를 보게 될 것이다. 코드의 결합도가 너무 높으면 빌드 시간이 길어지고 짜증이 날 것이다.
사실 이런 문제들은 조금만 조심하면 모두 피해 갈 수 있다. 어떻게 조심해야 될 까? 이번 장에서 이야기해 보자.
항목 26 : 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자
생성자 혹은 소멸자를 끌고 다니는 타입으로 변수를 정의하면 반드시 물게 되는 비용이 두 개 있다. 하나는 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되는 비용이고, 또 하나는 그 변수가 유효범위를 벗어날 때 소멸자가 호출되는 비용이다. 변수가 정의됐으나 사용되지 않은 경우에도 비용이 부과되는데, 이런 비용은 웬만한 경우가 아니면 물고 싶을 생각이 안 들 것이다.
사용하지 않을 변수를 누가 정의하겠냐고 생각할 수 있지만, 아래 함수를 보고 다시 생각해 보자. 이 함수는 주어진 비밀번호가 충분히 길 경우에 해당 비밀번호를 암호화하여 반환하는 함수이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if (password.length() < MinimumPasswordLength)
{
throw logic_error("Password is too short");
}
... // 암호화
return encrypted;
}
encrypted 객체가 사실 이 함수에서 완전히 안 쓰인다고는 말할 수 없지만, 예외가 발생되면 이 변수는 분명히 사용되지 않게 된다. 다시 말해, encryptPassword 함수가 예외를 던지더라도 encrypted 객체의 생성과 소멸에 대한 비용을 내야 한다는 이야기이다. 이런 사정을 확인한 이상, encrypted 변수를 정의하는 일은 꼭 필요해지기 전까지로 미루는 편이 낫겠다는 생각이 든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::string encryptPassword(const std::string& password)
{
using namespace std;
if (password.length() < MinimumPasswordLength)
{
throw logic_error("Password is too short");
}
string encrypted;
... // 암호화
return encrypted;
}
위와 같이 만들고 나면 끝인 줄 알았는데, 여전히 무언가 부족해 보인다. 그도 그럴 것이, encrypted 변수가 정의될 때 초기화 인자 하나도 없는 게 그 이유인 것 같다. 기본 생성자가 호출될 것이란 뜻이다. 상당수의 경우에 우리가 어떤 객체를 가지고 하는 가장 처음 일은 ‘값을 주는 것’일 텐데, 이때 대개 대입 연산을 쓴다. 그런데 객체를 기본 생성하고 나서 값을 대입하는 방법이 어째서 우리가 원하는 값으로 직접 초기화하는 방법보다 효율이 좋지 않은지, 항목 4에 설명되어 있다. 다음 함수 안에서 encryptPassword의 암호화 부분이 돌아간다고 가정해 보자.
1
void encrypt(std::string& s); // 이 자리에서 s를 바로 암호화
그리고 encryptedPassword의 암호화 부분이 다음과 같이 구현되어 있다고 가정해 보자. 생긴걸 보면 그리 좋은 방법 같아 보이진 않는다.
1
2
3
4
5
6
7
8
9
10
std::string encryptPassword(const std::string& password)
{
... // 길이 점검 생략
std::string encrypted; // 기본 생성자에 의해 만들어지는 encrypted
encrypted = password;
encrypt(encrypted);
return encrypted;
}
이 순간 진정으로 바람직한 방법이라면 encrypted를 password로 확 초기화해 버려야 할 것이다. 요컨대, 의미도 없고 비용도 만만치 않을 듯한 기본 생성자 호출을 건너뛰어야 한다는 이야기다.
1
2
3
4
5
6
7
8
9
std::string encryptPassword(const std::string& password)
{
... // 길이 점검 생략
std::string encrypted(password); // 변수 정의와 동시에 초기화. 이때 복사 생성자가 쓰인다.
encrypt(encrypted);
return encrypted;
}
이번 항목의 제목에 적힌 ‘늦출 수 있는 데까지’의 진짜 뜻이 바로 이것이다. 어떤 변수를 사용해야 할 때가 오기 전까지 그 변수의 정의를 늦추는 것은 기본이고, 초기화 인자를 손에 넣기 전까지 정의를 늦출 수 있는지도 둘러봐야 한다는 것이다. 이렇게 해야 쓰지도 않을 객체가 만들어졌다 없어지는 일도 생기지 않으며, 불필요한 기본 생성자 호출도 일어나지 않는다. 덤으로, 누가 보아도 그 변수의 의미가 명확한 상황에서 초기화가 이루어지기 때문에, 변수의 쓰임새를 문서화하는 데도 큰 도움이 된다.
그럼 루프에 대해선 어떻게 할까? 어떤 변수가 루프 안에서만 쓰이는 경우라면, 해당 변수를 루프 바깥에 미리 정의해 놓고 루프 안에서 대입하는 방법이 좋을까, 아니면 루프 안에 변수를 정의하는 방법이 좋을까? 그러니까, 다음 중 어떤 구조가 좋겠냐는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A 방법 : 루프 바깥 쪽에 정의
Widget w;
for (...)
{
w = ...; // 루프 안에서 대입
...
}
// B 방법 : 루프 안쪽에 정의
for (...)
{
Widget w(...);
...
}
위의 예제에서는 string 타입 대신에 Widget 타입으로 바꾸어 봤는데, 객체의 생성 혹은 소멸, 대입에 들어가는 비용이 타입마다 다르지 않다는 걸 알고 가자. 아무튼 이제 Widget 객체에 들어가는 연산을 기준으로 해서 두 방법에 걸리는 비용을 정리해 보자. 그 결과는 다음과 같다.
- A 방법 : 생성자 1번 + 소멸자 1번 + 대입 n번
- B 방법 : 생성자 n번 + 소멸자 n번
클래스 중에 대입에 들어가는 비용이 생성자/소멸자 쌍보다 적게 나오는 경우가 있는데, Widget 클래스가 이런 종류에 속한다면 A 방법이 일반적으로 훨씬 효율이 좋다. 이 차이는 n이 커질 때 특히 더 커진다. 반면, 그렇지 않은 경우엔 B 방법이 아마 더 좋을 것이다. 하나 더 생각해 볼 부분이 있는데, A 방법을 쓰면 w라는 이름을 볼 수 있는 유효범위가 B보다 넓어지기 때문에, 프로그램의 이해도와 유지보수성이 역으로 안 좋아질 수도 있다. 그러니까 이렇게 하자. 1. 대입이 생성/소멸자 쌍보다 비용이 덜 들고 2. 전체 코드에서 수행 성능에 민감한 부분을 건드리는 중이라고 생각하지 않는다면, 무조건 B 방법이 좋다.
변수 정의는 늦출 수 있을 때까지 늦추자. 프로그램이 더 깔끔해지며 효율도 좋아진다.
항목 27 : 캐스팅은 절약, 또 절약! 잊지말자
“어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다.” C++의 동작 규칙은 바로 이 철학을 바탕으로 설계되어 있다. 즉, 이론적으로 C++ 프로그램은 일단 컴파일만 깔끔하게 끝나면 그 이후엔 어떤 객체에 대해서도 불안전한 연산이나 말도 안되는 연산을 수행하려 들지 않는다는 것이다.
그런데, 공교롭게도 C++에는 이 타입 시스템을 가볍게 무시할 수 있는 방법이 있다. 바로 캐스트(cast)라고 불리는 녀석이다. 캐스트가 만들어내는 문제 중 어떤 것은 쉽게 찾아볼 수 있지만, 어떤 것은 발견하기가 어렵다. C++에서 캐스팅은 정말로 조심해서 써야 하는 기능이다.
일단 캐스팅 문법부터 정리하고 들어가자. 똑같은 캐스트인데 쓰는 방법이 세 가지나 있다. 우선, C 스타일의 캐스트이다.
1
(T)표현식 // 표현식 부분을 T 타입으로 캐스팅한다.
다음은 함수 방식 캐스트이다. 문법이 함수호출문과 같다.
1
T(표현식) // 표현식 부분을 T 타입으로 캐스팅한다.
어떻게 쓰든 이들이 가진 의미는 똑같다. 단지 괄호를 어디에 썼느냐만 다를 뿐이다. 책에서는 이 두 형태를 통틀어 ‘구형 스타일의 캐스트’라고 부른다.
C++는 네 가지로 이루어진 새로운 형태의 캐스트 연산자를 독자적으로 제공한다 (신형 스타일의 캐스트 혹은 C++ 스타일의 캐스트라고 부른다).
1
2
3
4
5
6
7
const_cast<T>(표현식)
dynamic_cast<T>(표현식)
reinterpret_cast<T>(표현식)
static_cast<T>(표현식)
각 연산자는 나름 대로의 목적이 있다.
- const_cast
- 객체의 상수성(constness) 혹은 휘발성(volatileness)을 없애는 용도로 사용된다. 이런 기능을 가진 C++ 스타일의 캐스트는 이것밖에 없다.
- dynamic_cast
- 이른바 ‘안전한 다운캐스팅’을 할 때 사용하는 연산자이다. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰인다.
- 구형 스타일의 캐스트 문법으로는 흉내조차 낼 수 없는 유일한 캐스트이기도 하다.
- 덤으로, 신경 쓰일 정도로 런타임 비용이 높은 캐스트 연산으로도 유일하다.
- reinterpret_cast
- 포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적이다(이식성이 없다는 뜻이다).
- 이런 캐스트는 하부 수준 코드 외에 거의 없어야 한다.
- 책에서도 이 캐스트를 딱 한 번 썼는데, 그것도 원시 메모리용 디버깅 할당자를 작성하는 방법에 대해 의견을 제시할 때뿐이었다(항목 50 참조).
- static_cast
- 암시적 변환(비상수 객체를 상수 객체로 바꾸거나, int를 double로 바꾸는 등의 변환)을 강제로 진행할 때 사용한다.
- 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도(void*를 일반 타입의 포인터로 바꾸거나, 기본 클래스의 포인터를 파생 클래스의 포인터로 바꾸는 등)로도 쓰인다.
- 물론 상수 객체를 비상수 객체로 캐스팅하는 데 이것을 쓸 수는 없다.
구형 스타일의 캐스트는 여전히 적법하게 쓰일 수 있지만, 그보다는 C++ 스타일의 캐스트를 쓰는 것이 바람직하다. 우선, 코드를 읽을 때 알아보기 쉽기 때문에, 소스 코드의 어디에서 C++의 타입 시스템이 망가졌는지 찾아보는 작업이 편해진다. 둘째, 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다. 무슨 말인고 하니, 상수성을 없애려고 한 부분에다가 const_cast 대신에 다른 싱형 스타일의 캐스트를 실수로 썼다면 코드 자체가 컴파일되지 않으므로 좋다는 것이다.
저자는 개인적으로 구형 스타일의 캐스트를 쓰는 곳은 딱 한 군데라고 한다. 객체르 인자로 받는 함수에 객체를 넘기기 위해 명시호출 생성자를 호출하고 싶을 경우인데, 간단히 설명하면 이런거다.
1
2
3
4
5
6
7
8
9
10
11
12
class Widget
{
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15)); // 함수 방식 캐스트 문법으로 int로부터 Widget 객체를 만들어서 넘긴다.
doSomeWork(static_cast<Widget>(15)); // c++ 스타일 방식 캐스트를 써서 int로부터 Widget 객체를 만들어서 넘긴다.
객체를 생성한다고 하면 어쩐지 캐스팅과는 다른 느낌이 든다. 그래서 이런 경우에 아마도 static_cast 대신에 함수 방식 캐스트를 쓸 것도 같다. 다시 강조하는데, 코어 덤프가 곧잘 나는 코드는 우리가 작성하고 나서 봤을 때 ‘오, 그럴 듯한데?’라는 느낌이 나는 경우가 많다. 그러니까 이런 느낌에 넘어가지 말고 언제든지 신형 스타일의 캐스트를 쓰도록 하자. 속 편하고 더 낫다.
코어 덤프 : 유닉스 운영체제 등에서 프로그램이 잘못된 연산으로 인해 죽을 때 그 시점의 정보(대개 메모리의 내용)을 기록한 파일을 생성하는 동작을 일컬음
캐스팅은 그냥 어떤 타입을 다른 타입으로 처리하라고 컴파일러에게 알려 주는 것밖에 더 있느냐고 생각하는 프로그래머가 있는데, 크나큰 오해이다. 어떻게 쓰더라도(캐스팅으로 명시적으로 바꾸거나 컴파일러가 암시적으로 바꾸거나) 일단 타입 변환이 있으면 이로 말미암아 런타임에 실행되는 코드가 만들어지는 경우가 정말 적지 않다. 다음의 코드를 봐보자.
1
2
3
int x, y;
...
double d = static_cast<double>(x) / y; // x를 y로 나눈다. 그러나 이때 부동소수점 나눗셈을 사용한다.
int 타입의 x를 double 타입으로 캐스팅한 부분에서 코드가 만들어진다. 그것도 거의 항상 그렇다. 왜냐하면 대부분의 컴퓨터 아키텍처에서 int의 표현구조와 double의 표현구조가 아예 다르기 때문이다. 여기까지는 그리 놀라는 분이 없을 것 같은데, 아래 예제를 보면 놀랄 수도 있다.
1
2
3
4
class Base { ... };
class Derived : public Base { ... };
Derived d;
Base* pb = &d; // Derived* -> Base*의 암시적 변환이 이루어진다.
보다시피 파생 클래스 객체에 대한 기본 클래스 포인터를 만드는(초기화하는), 흔한 코드이다. 그런데 두 포인터의 값이 같지 않을 때도 가끔 있다는 사실을 알고 있는가? 이런 경우가 되면, 포인터의 변위(오프셋)를 Derived* 포인터에 적용하여 실제의 Base* 포인터 값을 구하는 동작이 바로 런타임에서 이루어진다.
객체 하나(이를테면 Derived 타입의 객체)가 가질 수 있는 주소가 오직 한 개가 아니라 그 이상이 될 수 있음을(Base* 포인터로 가리킬 때의 주소, Derived* 포인터로 가리킬 때의 주소) 보여주는 사례가 이렇게 우리 가까이에 있다. 이런 일은 C/자바/C#에서는 결코 생길 수 없지만 C++에서는 생긴다. 사실 C++에서는 다중 상속이 사용되면 이런 현상이 항상 생기지만, 심지어 단일 상속인데도 이렇게 되는 경우가 있다. 이 점이 우리에게 시사하는 바는 크다. C++을 쓸 때는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야하며, 더욱이 이런 가정에 기반한 캐스팅은 절대 피해야 한다는 것이다. 이를테면, 어떤 객체의 주소를 char* 포인터로 바꿔서 포인터 산술 연산을 적용하는 등의 코드는 거의 항상 미정의 동작을 낳을 수 있다는 것이다.
하지만 포인터 변위를 써야 하는 때가 ‘가끔’이라고 말한 부분에 집중해야 한다. 객체의 메모리 배치구조를 결정하는 방법과 객체의 주소를 계산하는 방법은 컴파일러마다 천차만별이다. 그러니까, 어떤 플랫폼에서 ‘메모리 배치를 다 꿰고 있어서’ 캐스팅을 했을 때 문제가 없었을지라도 다른 플랫폼에서 그게 또 통하지 않는다는 이야기다.
또 다른 캐스팅에 관한 이야기로, 캐스팅이 들어가면 보기엔 맞는 것 같지만 실제로는 틀린 코드를 쓰고도 모르는 경우가 많아진다. 이를테면, 주변에서 많이들 쓰이는 응용프로그램 프레임워크를 하나 살펴보면, 가상 함수를 파생 클래스에서 재정의해서 구현할 때 기본 클래스의 버전을 호출하는 문장을 가장 먼저 넣어달라는 요구사항을 보게 된다. 어떤 프레임워크에 Window 기본 클래스가 있고 SpecialWindow 파생 클래스가 있다고 가정해 보자. 이들 클래스는 OnResize라는 이름의 가상 함수를 모두 정의하고 있다. 그리고 SpecialWindow의 OnResize를 구현하려면 Window의 OnResize를 호출해야 한다. 어디서 많이 본 것 같기에 하라는 대로 구현해 보았다. ‘보기엔 맞는 것 같지만 실제로는 틀린’ 바로 그 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Window
{
public:
virtual void OnResize() { ... }
...
};
class SpecialWindow : public Window
{
public:
virtual void OnResize() { static_cast<Window>(*this).OnResize(); ... }
...
};
위의 코드에서 캐스트 부분을 보자(신형/구형 스타일의 캐스트는 상관없다). 우리가 예상한 대로, *this를 Window로 캐스팅하는 코드이다. 이에 따라 호출되는 OnResize 함수는 Window::OnResize가 된다. 그런데 이제부터 어처구니가 없어진다. 함수 호출이 이루어지는 객체가 글쎄, 현재의 객체가 아니다. 이 코드에서는 캐스팅이 일어나면서 *this의 기본 클래스 부분에 대한 사본이 임시적으로 만들어지게 되어 있는데, 지금의 OnResize는 바로 이 임시 객체에서 호출된 거다. 결국, 위의 코드는 현재의 객체에 대해 Window::OnResize를 호출하지 않고 지나간다. 그러고 나서 SpecialWindow 전용의 동작은 또 현재의 객체에 대해서 수행한다. 다시 말해, SpecialWindow 만의 동작을 현재 객체에 대해 수행하기도 전에 기본 클래스 부분의 사본에 대고 Window::OnResize를 호출하는 것이다. 이때 Window::OnResize가 객체를 수정하도록 만들어졌기라도 하면, 현재 객체는 실제로 그 수정이 반영되지 않을 것이다. 오히려 수정이 반영되는 쪽은 현재 객체의 사본이다. 하지만 SpecialWindow::OnResize에서 객체를 수정하면 진짜 현재 객체가 수정될 게 분명하다. 기본 클래스에서 들어가는 수정은 반영되지 않고 파생 클래스에서 들어가는 수정만 반영될 것이다.
이 문제를 풀려면 일단 캐스팅을 빼버려야 한다. 우리가 진짜로 원하는 바를 써야 한다. 이번에는 컴파일러에서 *this를 기본 클래스 객체로 취급하도록 하는 꼼수 같은 것은 생각하지 말자. 그냥 현재 객체에 대고 OnResize의 기본 클래스 버전을 호출하도록 만들면 되는 것이다.
1
2
3
4
5
6
class SpecialWindow : public Window
{
public:
virtual void OnResize() { Window::OnResize(); ... }
...
};
지금까지 본 예제는 “캐스트 연산자가 쓰고 싶은 상황이라면 뭔가 꼬여가는 징조이다”라는 사실을 보여주는 예제라고도 할 수 있다. 특히 지금 꼭 썼으면 좋을 것 같은 연산자가 dynamic_cast인 경우라면 더욱 그렇다.
dynamic_cast는 그 설게부터 말도 많고 탈도 많은 연산자이다. 이 부분을 잘 알아두면 꽤 유익하긴 하겠지만, 지금은 상당수의 구현환경에서 이 연산자가 정말 느리게 구현되어 있다는 문제만 짚는 정도로 충분할 것 같다. 굳이 이름은 밝히지 않겠으나 많이들 쓰고 있는 어떤 구현환경의 경우, 클래스의 이름에 대한 문자열 비교 연산자에 기반을 두어 dynamic_cast가 만들어져 있다. 예를 들어, 깊이가 4인 단일 상속 계통에 속한 어떤 객체에 대해 이 연산자를 적용할 때, 방금 말한 그 구현환경에서는 클래스 이름을 비교하기 위해 strcmp가 최대 네 번까지 불릴 수 있다는 이야기가 된다. 상속 깊이가 더 깊거나 심지어 다중 상속이라도 쓰게 되면 그 비용이 더 커질 것이다. 어쨌든, 일상생활에서 캐스트 연산자에 대해 경계의 끈을 놓지 않는 자세는 기본일 것 같은데, 수행 성능에 사활이 걸린 코드라면 특히 dynamic_cast에 주의를 놓지 말아햐 한다.
dynamic_cast 연산자가 쓰고 싶어지는 때가 있긴 하다. 파생 클래스 객체임이 분명한 녀석이 있어서 이에 대해 파생 클래스의 함수를 호출하고 싶은데, 그 객체를 조작할 수 있는 수단으로 기본 클래스의 포인터 밖에 없을 경우에는 적지 않게 생긴다. 이런 문제를 피해 가는 일반적인 방법으로는 두 가지를 들 수 있다.
첫 번째 방법은, 파생 클래스 객체에 대한 포인터를 컨테이너에 담아둠으로써 각 객체를 기본 클래스 인터페이스를 통해 조작할 필요를 아예 없애 버리는 것이다. 이를테면 지금까지 봐 왔던 Window 및 SpecialWindow 상속 계통에서 깜박거리기 기능을 SpecialWindow 객체에만 지원하게 되어 있다면, 아래처럼 하지 말고
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Window { ... };
class SpecialWindow : public Window
{
public:
void blink();
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
if (SpecialWindow* psw = dynamic_cast<SpecialWindow*>(iter->get()))
psw->blink();
}
다음과 같이 해볼 수 있다.
1
2
3
4
5
typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;
VPSW winPtrs;
...
for (VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
(*iter)->blink(); // 더 괜찮은 코드: dynamic_cast가 없다
이 방법으로는 Window에서 파생될 수 있는 모든 녀석들에 대한 포인터를 똑같은 컨테이너에 저장할 수는 없다. 맞다. 다른 타입의 포인터를 담으려면 타입 안정성을 갖춘 컨테이너 여러 개가 필요할 것이다.
한편, Window에서 뻗어 나온 자손들을 전부 기본 클래스 인터페이스를 통해 조작할 수 있는 다른 방법이 없는 것은 아니다. 우리가 원하는 조작을 가상 함수 집합으로 정리해서 기본 클래스에 넣어두면 된다. 예를 들어, 지금은 blink 함수가 SpecialWindow에서만 가능하지만, 그렇다고 기본 클래스에 못 넣어둘 만한 것도 아니다. 그러니까, 아무것도 안하는 기본 blink를 구현해서 아래처럼 가상 함수로 제공하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Window
{
public:
virtual void blink() { } // 아무것도 안하는 기본 blink. 참고로 항목 34를 보면 가상 함수의 기본 구현이 좋지 않은 아이디어인지 확인할 수 있다.
...
};
class SpecialWindow : public Window
{
public:
virtual void blink() { ... }
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
(*iter)->blink(); // dynamic_cast가 없다.
말한 두 가지 방법 중 어떤 것도 모든 상황에 다 적용하기란 불가능하지만, 상당히 많은 상황에서 dynamic_cast를 쓰는 방법 대신에 꽤 잘 쓸 수 있다. 이 정도면 알아둘 가치는 충분할 것이다.
정말 피해야 하는 설계가 하나 있다. 바로 폭포식(cascading) dynamic_cast라고 불리는 구조인데, 이름이 낯설지만 코드를 보면 바로 알 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Window { ... };
... // 파생클래스가 여기서 정의
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
if (SpecialWindow1* psw1 = dynamic_cast<SpecialWindow1*>(iter->get()))
psw1->blink();
else if (SpecialWindow2* psw2 = dynamic_cast<SpecialWindow2*>(iter->get()))
psw2->blink();
else if (SpecialWindow3* psw3 = dynamic_cast<SpecialWindow3*>(iter->get()))
psw3->blink();
...
}
이런 C++ 코드 때문에 C++가 욕을 먹는 것이다 크기만 하고 아름답지도 않으며, 속도도 둔한데다가 망가지기 쉬운 코드가 만들어 진다. Window 클래스 계통이 바뀌었다는 소식이라도 들리면 항상 이런 코드는 검토 대상이 된다. 이런 형태의 코드를 보면 넘어가지 말자. 가상 함수 호출에 기반을 둔 어떤 방법이든 써서 바꿔놓아야 한다.
정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않는다. 하지만 캐스팅을 아예 쓰지 않는 것도 어찌 보면 불가능할 것이다. 앞에서 본 int를 double로 바꾸는 경우는 터무니없는 캐스팅은 아니다. 엄밀히 따지만 꼭 필요한가에 대한 의문이 남긴 하지만 말이다(차라리 x의 값으로 초기화한 double 타입의 변수를 새로 선언하는게 나은거 같다). 캐스팅 역시, 그냥 막 쓰기에는 꺼림칙한 문법 기능을 써야 할 때 흔히 쓰이는 수단을 활용해서 처리하는 것이 좋다. 쉽게 말해 최대한 격리시키라는 것이다. 캐스팅을 해야 하는 코드는 내부 함수 속에 몰아 넣고, 그 안에서 일어나는 ‘천한’일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하면 된다.
다른 방법이 가능하다면 캐스팅은 피하자. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각해보자. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보자.
캐스팅이 어쩔 수 없이 필요하다면 함수 안에 숨길 수 있도록 해보자. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 된다.
구형 스타일의 캐스팅을 쓰려거든 C++ 스타일의 캐스팅을 더 선호하자. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러난다.
참고