Effective C++ : 5

Updated:

Chapter5.구현

항목 26: 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자

 생성자 혹은 소멸자를 끌고 다니는 타입으로 변수를 정의하면 반드시 물게 되는 비용이 두 개 있다. 하나는 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되는 비용이고, 또 하나는 그 변수가 유효범위를 벗어날 때 소멸자가 호출되는 비용이다. 변수가 정의됐으나 사용되지 않은 경우에도 비용이 부과되는데, 이런 비용은 웬만한 경우가 아니면 물고 싶을 생각이 안 들 것이다.

 다음 경우는 비밀번호(password)가 충분히 길 경우에 해당 비밀번호를 암호화해 반환하는 함수이다. 비밀번호가 너무 짧으면 logic_error 타입의 예외를 던지도록 만들었졌다.

// 이 함수는 "encrypted" 변수를 너무 일찍 정의했다.
std::string encryptPassword(const std::string& password)
{
	using namespace std;
  
  string encrypted;
  
  if (password.length() < MinimumPasswordLength)
  {
		throw logic_error("Password is too short");
  }
  ...									// 주어진 비밀번호를 암호화해 encrypted
    									// 변수에 넣는 데 필요한 일들을 진행한다.
    return encrpted;
}

 encrypted 객체가 이 함수에서 완전히 안 쓰인다고는 말할 수 없지만, 예외가 발생되면 이 변수는 분명히 사용되지 않게 된다. 다시 말해, encryptPassword 함수가 예외를 던지더라도 encrypted 객체의 생성과 소멸에 대해 비용을 내야 한다는 이야기이다.

// 이 함수는 encrypted 변수가 진짜로 필요해질 때까지 정의를 미룬다.
std::string encryptPassword(const std::string& password)
{
	using namespace std;
  
  if (password.length() < MinimumPasswordLength)
  {
		throw logic_error("Password is too short");
  }
  
  string encrypted;
  ...										// 주어진 비밀번호를 암호화해 encrypted
    										// 변수에 넣는 데 필요한 일들을 여기서 한다.
  return encrypted;
}

 다음으로 encrypted를 password로 초기화해서 진행하는 코드이다. 의미도 없고 비용도 만만치 않을 듯한 기본 생성자 호출을 건너뛰어야 한다는 이야기이다.

// encrypted를 정의하고 초기화하는 가장 좋은 방법
std::string encryptPassword(const std::string& password)
{
	...																// 길이를 점검한다.
  
  std::string encrypted(password);	// 변수를 정의함과 동시에 초기화한다.
  																	// 이때 복사 생성자가 쓰인다.
  encrypt(encrypted);
  
  return encrypted;
}

 그렇다면 루프에서는 어떻게 할까? 어떤 변수가 루프 안에서만 쓰이는 경우라면, 해당 변수를 루프 바깥에서 미리 정의해 놓고 루프 안에서 대입하는 방법이 좋을까, 아니면 루프 안에 변수를 정의하는 방법이 좋을까?

// A 방법 : 루프 바깥쪽에 정의
Widget w;
for (int i = 0; i < n; ++i)
{
	w = i 따라 달라지는 ;
  ...
}

// B 방법 : 루프 안쪽에 정의
for (int i = 0; i < n; ++i)
{
	Widget w(i 따라 달라지는 );
  ...
}

  Widget 객체에 들어가는 연산을 기준으로 해서 두 방법에 걸리는 비용을 정리해보자.

  1. A 방법 : 생성자 1번 + 소멸자 1번 + 대입 n번

  2. B 방법 : 생성자 n번 + 소멸자 n번

 클래스 중에는 대입에 들어가는 비용이 생성자-소멸자 쌍보다 적게 나오는 경우가 있는데, Widget 클래스가 이런 종류에 속한다면 A 방법이 일반적으로 훨씬 효율이 좋다. 이 차이는 n이 커질 때 특히 더 커진다. 반면, 그렇지 않은 경우엔 B 방법이 더 좋을 것이다.

 그리고 생각해 볼 부분이 하나 더 있다. A 방법을 쓰면 w라는 이름을 볼 수 있는 유효범위가 B 방법을 쓸 때보다 넓어지기 때문에, 프로그램의 이해도와 유지보수성이 역으로 안 좋아질 수도 있다.

  • 이것만은 잊지 말자!
    1. 변수 정의는 늦출 수 있을 때까지 늦추자. 프로그램이 더 깔끔해지며 효율도 좋아진다.

항목 27: 캐스팅은 절약, 또 절약! 잊지 말자

 ”어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다.” C++의 동작 규칙은 바로 이 철학을 바탕으로 설계되어 있다. 즉, 이론적으로 C++ 프로그램은 일단 컴파일만 깔끔하게 끝나면 그 이후엔 어떤 객체에 대해서도 불안전한 연산이나 말도 안 되는 연산을 수행하려 들지 않는다는 것이다.

 그런데 공교롭게도 C++에는 이 타입 시스템을 가볍게 배뒤치기할 수 있는 괴물이 있으니, 바로 캐스트(cast)라고 불리는 녀석이다. 일단 몸을 푸는 기분으로 캐스팅 문법부터 정리해보자. 똑같은 캐스트인데 쓰는 방법은 세 가지나 있다.

 첫째는 C 스타일 캐스트이다.

(T) 표현식			// 표현식 부분을 T 타입으로 캐스팅한다.

 둘째는 함수 방식 캐스트이다. 문법이 함수호출문과 닮았다.

T (표현식)			// 표현식 부분을 T 타입으로 캐스팅한다.

 위 둘의 의미는 똑같다. 단지 괄호를 어디에 썼느냐만 다를 뿐이다. 필자는 이 두 형태를 통틀어 ‘구형 스타일의 캐스트’라고 부르겠다.

 C++은 네 가지로 이루어진 새로운 형태의 캐스트 연산자를 독자적으로 제공한다. (신형 스타일의 캐스트 혹은 C++ 스타일의 캐스트라고 부른다)

  • const_cast (표현식)
  • dynamic_cast (표현식)
  • reinterpret_cast (표현식)
  • static_cast (표현식)

각 연산자는 나름대로의 목적이 있다.

  • const_cast
    • 객체의 상수성(constness)을 없애는 용도로 사용된다. 이런 기능을 가진 C++ 스타일의 캐스트는 이것밖에 없다.
  • dynamic_cast
    • 이른바 ‘안전한 다운캐스팅(safe downcasting)’을 할 때 사용하는 연산자이다. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰인다. 구형 스타일의 캐스트 문법으로는 흉내조차도 낼 수 없는 유일한 캐스트이기도 하다. 덤으로, 신경 쓰일 정도로 런타임 비용이 높은 캐스트 연산자로도 유일하다.
  • reinterpret_cast
    • 포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적이다. (이식성이 없다는 뜻이다) 이런 캐스트는 하부 수준 코드 외에는 거의 없어야 한다. 필자 역시 이 책에서 이 캐스트를 딱 한 번 썼는데, 그것도 원시 메모리용 디버깅 할당자를 작성하는 방법에 대해 의견을 제시할 때 뿐이었다.
  • static_cast
    • 암시적 변환을 강제로 진행할 때 사용한다. 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도로도 쓰인다. 물론 상수 객체를 비상수 객체로 캐스팅하는데 이것을 쓸 수는 없다. (위에서 말했지만 const_cast 연산자밖에 안 된다)

 구형 스타일의 캐스트는 요즘도 여전히 적법하게 쓰일 수 있지만, 그보다는 C++ 스타일의 캐스트를 쓰는 것이 바람직하다. 우선, 코드를 읽을 때 알아보기 쉽기 때문에, 소스 코드의 어디에서 C++의 타입 시스템이 망가졌는지를 찾아보는 작업이 편해진다. 둘째, 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다.

 캐스팅은 그냥 어떤 타입을 다른 타입으로 처리하라고 컴파일러에게 알려 주는 것밖에 더 있느냐고 생각하는 프로그래머가 의외로 많다. 크나큰 오해이다. 어떻게 쓰더라도(캐스팅으로 명시적으로 바꾸거나 컴파일러가 암시적으로 바꾸거나) 일단 타입 변환이 있으면 이로 말미암아 런타임에 실행되는 코드가 만들어지는 경우가 정말 적지 않다. 다음의 코드를 보자.

int x, y;
...
double d = static_cast<double>(x)/y;	// x를 y로 나눈다. 그러나 이때 부동소수점 나눗셈을 사용한다.

 int 타입의 x를 double 타입으로 캐스팅한 부분에서 코드가 만들어진다. 그것도 거의 항상 그렇다. 왜냐하면 대부분의 컴퓨터 아키텍처에서 int의 표현구조와 double의 표현구조가 아예 다르기 때문이다.

class Base { ... };
class Derived: public Base { ... };
Derived d;
Base *pb = &d;				// Derived* => Base*의 암시적 변환이 이루어진다.

 보시다시피 파생 클래스 객체에 대한 기본 클래스 포인터를 만드는(초기화하는), 지극히 흔하디 흔한 코드이다. 그런데 두 포인터의 값이 같지 않을 때도 가끔 있다는 사실을 알고 있나? 이런 경우가 되면, 포인터의 변위(offset)를 Derived* 포인터에 적용해 실제의 Base* 포인터 값을 구하는 동작이 바로 런타임(Runtime)에 이루어진다.

 객체 하나(이를테면 Derived 타입의 객체)가 가질 수 있는 주소가 오직 한 개가 아니라 그 이상이 될 수 있음을(Base* 포인터로 가리킬 때의 주소, Derived* 포인터로 가리킬 때의 주소) 보여주는 사례가 이렇게 가까이에 있다니 놀랍다. 이런 일은 C, C#, JAVA에서는 볼 수 없다. C++을 쓸 때는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야 하며, 더욱이 이런 가정에 기반한 캐스팅은 통하지 않는다는 것이다. 이를테면, 어떤 객체의 주소를 char* 포인터로 바꿔서 포인터 산술 연산을 적용하는 등의 코드는 거의 항상 미정의 동작을 낳을 수 있다는 이야기이다.

 다음은 캐스팅에 관한 웃지 못 할 이야기를 하나 해 보도록 하자. 캐스팅이 들어가면, 보기엔 맞는 것 같지만 실제로는 틀린 코드를 쓰고도 모르는 경우가 많아진다. 주변에서 많이들 쓰이는 응용프로그램 프레임 워크(application framework)를 하나 살펴보면, 가상 함수를 파생 클래스에서 재정의해서 구현할 때 기본 클래스의 버전을 호출하는 문장을 가장 먼저 넣어달라는 요구사항을 보게 된다. 어떤 프레임워크에 Window 기본 클래스가 있고, SpecialWindow 파생 클래스가 있다고 가정해 보자. 이들 클래스는 onResize라는 이름의 가상 함수를 모두 정의하고 있다. 그리고 SpecialWindow의 onResize를 구현하려면 Window의 onResize를 호출해야 한다. 어디서 많이 본 것 같지 않나? 그래서 하라는 대로 구현해 보았다. ‘보기엔 맞는 것 같지만 실제로는 틀린’ 바로 그 코드이다.

class Window 		 // 기본 클래스
{
	public:
  	virtual void onResize() { ... }	// 기본 클래스의 onResize 구현 결과
  	...
};

class SpecialWindow: public Window	// 파생 클래스
{
  public:
  	virtual	void onResize() 
    {
      // 파생 클래스의 onResize 구현 결과 *this를 Window로 캐스팅하고
      // 그것에 대해 onResize를 호출한다.
      // 동작이 안 되어서 문제다.
      static_cast<Window>(*this).onResize();
      
      // SpecialWindow에서만 필요한 작업을 여기서 수행한다.
      ...
    }
  	...
};

 위의 코드에서 캐스트 부분은 강조했다. (신형 스타일의 캐스트를 썼는데 그렇다고 구형 스타일 캐스트를 쓰더라도 바뀔 건 아무것도 없다) 여러분이 예상한대로, *this를 Window로 캐스팅하는 코드이다. 이에 따라 호출되는 onResize 함수는 Window::onResize가 된다. 그런데 이제부터 어처구니가 없어진다. 함수 호출이 이루어지는 객체가 글쎄, 현재의 객체가 아니다. 이 코드에서는 캐스팅이 일어나면서 *this의 기본 클래스 부분에 대한 사본이 임시적으로 만들어지게 되어 있는데, onResize는 바로 이 임시 객체에서 호출된 것이다. 결국, 위의 코드는 현재의 객체에 대해 Window::onResize를 호출하지 않고 지나간다. 그러고 나서 SpecialWindow 전용의 동작은 또 현재의 객체에 대해서 수행한다. 다시 말해, SpecialWindow만의 동작을 현재 객체에 대해 수행하기도 전에 기본 클래스 부분의 사본에 대고 Window::onResize를 호출하는 것이다. 이때 Window::onResize가 객체를 수정하도록 만들어졌기라도 하면(사실 그럴 가능성이 꽤 적지 않다. onResize가 비상수 멤버 함수로 되어있기 때문이다), 현재 객체는 실제로 그 수정이 반영되지 않을 것이다. 오히려 수정이 반영되는 쪽은 현재 객체의 사본이다. 하지만 SpecialWindow::onResize에서 객체를 수정하면 진짜 현재 객체가 수정될 게 분명하다.

 이 문제를 풀려면 일단 캐스팅을 빼버려야 한다. 이번에는 컴파일러에게 *this를 기본 클래스 객체로 취급하도록 하는 꼼수 같은 것은 생각하지 말자. 그냥 현재 객체에 대고 onResize의 기본 클래스 버전을 호출하도록 만들면 된다.

class SpecialWindow: public Window
{
	public:
  	virtual void onResize()
    {
      Window::onResize();				// *this에서
      ...												// Window::onResize를 호출한다.
		}
  	...
};

 앞에서 말했다싶이 dynamic_cast는 비용이 많이 든다. 특히 클래스 이름에 대한 문자열 비교 연산에 기반을 두어 만들어졌다. 그래도 dynamic_cast 연산자가 쓰고 싶어지는 때가 있긴 하다. 파생 클래스 객체임이 분명한 녀석이 있어서 이에 대해 파생 클래스의 함수를 호출하고 싶은데, 그 객체를 조작할 수 있는 수단으로 기본 클래스의 포인터(혹은 참조자)밖에 없을 경우는 적지 않게 생긴다. 이런 문제를 피해 가는 일반적인 방법으로는 두 가지를 들 수 있다.

 첫 번째 방법은, 파생 클래스 객체에 대한 포인터(혹은 스마트 포인터)를 컨테이너에 담아둠으로써 각 객체를 기본 클래스 인터페이스를 통해 조작할 필요를 아예 없애 버리는 것이다. 이를테면 지금까지 봐 왔던 Window 및 SpecialWindow 상속 계통에서 깜빡거리기(blink) 기능을 SpecialWindow 객체만 지원하게 되어 있다면, 아래처럼 하지 말고,

class Window { ... };
class SpecialWindow: public Window
{
  void blink();
  ...
};

// tr1::shared_ptr에 대한 이야기는 항목 13에서 확인할 수 있다. 
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;

VPW winPtrs;
...
  
// 그다지 바람직스럽지 않은 코드 : dynamic_cast를 쓰고 있다.
for (VPW::iterator iter = winPtrs.begin(); iter!= winPtrs.end(); ++iter)
{
  if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()))
    psw->blink());
}

다음과 같이 해보자.

typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;

VPSW winPtrs;
...

  // 더 괜찮은 코드 : dynamic_cast가 없다.
for (VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
  (*iter)->blink();
}

 이 방법으로는 Window에서 파생될 수 있는 모든 녀석들에 대한 포인터를 똑같은 컨테이너에 저장할 수는 없다. 다른 타입의 포인터를 담으려면 타입 안정성을 갖춘 컨테이너 여러 개가 필요할 것이다.

 한편, Window에서 뻗어 나온 자손들을 전부 기본 클래스 인터페이스를 통해 조작할 수 있는 다른 방법이 없는 것은 아니다. 여러분이 원하는 조작을 가상 함수 집합으로 정리해서 기본 클래스에 넣어두면 된다. 예를 들어, 지금은 blink 함수가 SpecialWindow에서만 가능하지만, 그렇다고 기본 클래스에 못 넣어둘 만한 것도 아니다. 그러니까, 아무것도 안 하는 기본 blink를 구현해서 가상 함수로 제공하는 것이다.

class Window
{
  public:
  	virtual void blink() { } // 기본 구현은 '아무 동작 안하기'이다.
  	...											 // 참고로, 항목 34를 보면 어째서 가상 함수의 기본 구현이
  													 // 좋지 않은 아이디어인지 확인할 수 있다.
};

class SpecialWindow: public Window
{
  public:
  	virtual void blink() { ... } // 이 클래스에서는 blink 함수가 특정한 동작을 수행한다.
  	...
};

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs; // 이 컨테이너는 Window에서 파생된 모든 타입의 객체(에 대한 포인터)들을 담는다.

...

for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
  (*iter)->blink();	// 놓치지 말고 보아라. dynamic_cast가 없다.
}

 앞에서 말한 두 가지 방법 중 어떤 것도(타입 안정성을 갖춘 컨테이너를 쓰든지 가상 함수를 기본 클래스 쪽으로 올려두든지) 모든 상황에 다 적용하기란 불가능하지만, 상당히 많은 상황에서 dynamic_cast를 쓰는 방법 대신에 꽤 잘 쓸 수 있다.

 정말 피해야 하는 설계가 하나 있다. 바로 ‘폭포식(cascading) dynamic_cast’라고 불리는 구조인데, 이름이 낯설지만 코드를 보면 바로 알 수 있다.

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())) 
  	{ ... }
  else if (SpecialWindow2 *psw2 = dynamic_cast<SpecialWindow2*>(iter->get()))
	  { ... }
  else if (SpecialWindow3 *psw3 = dynamic_cast<SpecialWindow3*>(iter->get()))
	  { ... }
  ...
}

 이런 C++ 코드 때문에 C++가 욕을 먹는 것이다. 크기만 하고 아름답지 않으며, 속도도 둔한데다가 망가지기 쉬운 코드가 만들어진다. Window 클래스 계통이 바뀌었다는 소식이라도 들렸다 치면 항상 이런 코드는 ‘또 뭐 넣고, 뺄 거 없나?’ 하는 검토 대상이 되기 때문이다. (파생 클래스가 하나 추가되었다고 가정해보면, 위의 폭포식 코드에 계속해서 조건 분기문을 우겨 넣어야 한다) 이런 형태의 코드를 보시면 넘어가지 말아라. 가상 함수 호출에 기반을 둔 어떤 방법이든 써서 바꿔 놓아야 한다.

 캐스팅은 그냥 막 쓰기에는 꺼림칙한 문법 기능을 써야 할 때 흔히 쓰이는 수단을 활용해서 처리하는 것이 좋다. 쉽게 말해 최대한 격리 시키라는 것이다. 캐스팅을 해야 하는 코드를 내부 함수 속에 몰아 놓고, 그 안에서 일어나는 ‘천한’ 일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하면 된다.

  • 이것만은 잊지 말자!
  1. 다른 방법이 가능하다면 캐스팅은 피하자. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각해라. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보자.
  2. 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보아라. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고, 이 함수를 호출할 수 있게 된다.
  3. 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호해라. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러난다.

항목 28: 내부에서 사용하는 객체에 대한 ‘핸들’을 반환하는 코드는 되도록 피하자

  • 핸들(handle, 다른 객체에 손을 댈 수 있게 하는 매개자)

 어떤 객체의 내부요소에 대한 핸들을 반환하게 만들면 언제든지 그 객체의 캡슐화를 무너뜨리는 위험을 무릅쓸 수밖에 없다. (심지어 상수 멤버 함수조차도 객체 상태의 변경을 허용하는 지경에까지 이를 수 있다)

 어떤 객체의 ‘내부요소(internals)’라고 하면 흔히들 데이터 멤버만 생각하는 분들이 많은데, 일반적인 수단으로 접근이 불가능한(쉽게 말해 protected 혹은 private으로 선언된) 멤버 함수도 객체의 내부요소에 들어간다. 즉, 외부 공개가 차단된 멤버 함수에 대해, 이들의 포인터를 반환하는 멤버 함수를 만드는 일이 절대로 없어야 한다는 말이다. 이런 함수가 하나라도 들어가는 순간부터 실질적인 접근 수준이 바뀐다.

class Rectangle 
{
	public:
  	...
    const Point& upperLeft() const { return pData->ulhc; }
  	const Point& lowerRight() const { return pData->lrhc; }
  	...
};

 위와 같이 설계하면 사용자는 사각형을 정의하는 꼭짓점 쌍을 읽을 수는 있지만 쓸 수는 없게된다. 뭔가 한 것 같지만 그래도 찝찝하다. upperLeft 함수와 lowerRight 함수를 보면 내부 데이터에 대한 핸들을 반환하고 있는 부분이 남아있기 때문이다. 이것을 남겨두면 다른 쪽에서 문제가 될 수 있다. 가장 큰 문제가 무효참조 핸들(dangling handle)로서, 핸들이 있기는 하지만 그 핸들을 따라갔을 때 실제 객체의 데이터가 없는 것이다. 핸들이 물고 있는 객체가 기약도 없이 어디론가 증발하는 현상은 함수가 객체를 값으로 반환할 경우에 가장 흔하게 발생된다. 예를 하나 들어 보자. 어떤 GUI 객체의 사각 테두리 영역(bounding box)을 Rectangle 객체로 반환하는 함수가 있다고 가정하자.

class GUIObject { ... };

// Rectangle 객체를 값으로 반환한다.
// 반환 타입에 const가 붙는 이유는 항목 3을 보면 나온다.
const Rectangle boundingBox(const GUIObject& obj);

 이 상태에서 어떤 사용자가 이 함수를 사용한다고 생각해 보자.

GUIObject *pgo;				// pgo를 써서 임의의 GUIObject를 가리키도록 한다.
...
// pgo가 가리키는 GUIObject의 사각 테두리 영역으로부터
// 좌측 상단 꼭짓점의 포인터를 얻는다.
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

 가장 마지막 문장을 보자. boundingBox 함수를 호출하면 Rectangle 임시 객체가 새로 만들어진다. 이 객체는 겉으로 드러나는 이름 같은 것이 없으므로 일단 temp라고 부르자. 다음엔 이 temp에 대해 upperLeft가 호출될 텐데, 이 호출로 인해 temp의 내부 데이터, 정확히 말하면 두 Point 객체 중 하나에 대한 참조자가 나온다. 마지막으로 이 참조자에 & 연산자를 건 결과 값(주소)이 pUpperLeft 포인터에 대입된다. 여기까지는 대충 괜찮지만, 아직 끝나기엔 이르다. 이 문장이 끝날 무렵, boundingBox 함수의 반환 값(임시 객체인 temp)이 소멸된다는 사실을 넘어가면 안 된다. temp가 소멸되니, 그 안에 들어 있는 Point 객체들도 덩달아 없어질 것이다. 그러면 어떻게 될까? pUpperLeft 포인터가 가리키는 객체는 이제 날아가고 없게 된다. 다시 말해 이 문장은 pUpperLeft에게 객체를 달아 줬다가 주소 값만 남기고 몽땅 빼앗아 간 것이다.

 객체의 내부에 대한 핸들을 반환하는 함수는 어떻게든 위험하다는 말이 이래서 나오는 것이다. 일단 바깥으로 떨어져 나간 핸들은 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문이다.

 그렇다고 해서 핸들을 반환하는 멤버 함수를 절대로 두지 말라는 이야기가 아니다. 이번 항목의 제목을 보자. ‘피하자’이다. 어쩌다 보면 필요할 때도 있다. 예를 들어 operator[] 연산자는 string이나 vector 등의 클래스에서 개개의 원소를 참조할 수 있게 만드는 용도로 제공되고 있는데, 실제로 이 연산자는 내부적으로 해당 컨테이너에 들어 있는 개개의 원소 데이터에 대한 참조자를 반환하는 식으로 동작한다. 물론 이 원소 데이터는 컨테이너가 사라질 때 같이 사라지는 데이터이다. 하지만 이런 함수는 예외적인 것이다. 일반적인 규칙이 아니다.

  • 이것만은 잊지 말자!
    1. 어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피해라. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있다.

항목29: 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

 예쁜 배경그림을 깔고 나오는 GUI 메뉴를 구현하기 위해 클래스를 하나 만든다고 가정하자. 이 클래스는 스레딩 환경에서 동작할 수 있도록 설계되었기 때문에, 병행성 제어를 위해 뮤텍스(mutex)를 갖고 있다.

class PrettyMenu
{
  public:
  	...
    // 배경 그림을 바꾸는 멤버 함수
    void changeBackground(std::istream& imgSrc);
    ...
  private:
  	Mutex mutex;			// 이 객체 하나를 위한 뮤텍스
  
  	Image *bgImage;		// 현재의 배경 그림
  	int ImageChanges;	// 배경 그림이 바뀐 횟수
};

 여기서 PrettyMenu의 changeBackground 함수가 다음과 같이 구현되었다고 생각해보자.

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
	lock(&mutex);									// 뮤텍스를 획득한다. (항목 14와 같다)
  
  delete bgImage;								// 이전의 배경그림을 없앤다.
  ++imageChanges;								// 그림 변경 횟수를 갱신한다.
  bgImage = new Image(imgSrc);	// 새 배경 그림을 깔아 놓는다.
  
  unlock(&mutex);								// 뮤텍스를 해제한다.
}

 예외 안정성이라는 측면에서 볼 때 이 함수는 “이보다 더 나쁠 수는 없다”고 말하고 싶다. 일반적으로 예외 안정성을 확보하려면 두 가지의 요구사항을 맞추어야 하는데, 이 함수는 어느 요구사항에도 맞지 않는, 위험천만의 함수이다.

  • 예외 안전성을 가진 함수라면 예외가 발생할 때 이렇게 동작해야 한다.

    • 자원이 새도록 만들지 않는다. 그런데 위의 코드는 자원이 샌다. 왜냐하면 “new Image(imgSrc)” 표현식에서 예외를 던지면 unlock 함수가 실행되지 않게 되어 뮤텍스가 계속 잡힌 상태로 남기 때문이다.
    • 자료구조가 더럽혀지는 것을 허용하지 않는다. 그런데 위의 코드에서 “new Image(imgSrc)”가 예외를 던지면 bgImage가 가리키는 객체는 이미 삭제된 후이다. 그뿐인가? 새 그림이 제대로 깔린 게 아닌데도 imageChanges 변수는 이미 증가되었을 것이다.
  • 자원 누출 문제는 맞서 싸우기가 그렇게 까다롭지 않다. 객체를 써서 자원 관리를 전담케 하는 방법을 항목 13에서 읽고, 항목 13의 아이디어를 살려 뮤텍스를 적절한 시점에 해제하는 방법을 구현한 Lock 클래스를 항목 14에서 접했으면, 그대로 따라하는 것으로 마무리가 되기 때문이다.

    void PrettyMenu::changeBackground(std::istream& imgSrc)
    {
    	Lock m1(&mutex);				// 항목 14에서 가져왔다: 뮤텍스를 대신 획득하고
      												// 이것이 필요 없어질 시점에 바로 해제해 주는 객체이다.
      delete bgImage;
      ++imageChanges;
      bgImage = new Image(imgSrc);
    }
    

    이렇게 해서 일단 자원 누출 문제는 꼬리를 내렸다. 다음 상대자는 자료구조 오염 문제이다. 여기서는 선택을 좀 해야 하는데, 일단 그전에 우리가 고를 수 있는 것이 무엇인지를 제대로 파악하기 위해 용어 공부가 좀 필요할 것 같다.

  • 예외 안정성을 갖춘 함수는 아래의 세 가지 보장(guarantee) 중 하나를 제공한다.

    • 기본적인 보장(basic guarantee) 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장이다. 어떤 객체나 자료구조도 더럽혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지하고 있다. (즉, 모든 클래스 불변속성이 만족된 상태이다)

    • 강력한 보장(strong guarantee) 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장이다. 이런 함수를 호출하는 것은 원자적인(atomic) 동작이라고 할 수 있다. 호출이 성공하면(예외가 발생하지 않으면) 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 돌아간다는 면에서 말이다.

    • 예외 불가 보장(nothrow guarantee) 예외를 절대로 던지지 않겠다는 보장이다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이다. 기본제공 타입(int, 포인터 등)에 대한 모든 연산은 예외를 던지지 않게 되있다. (즉, 예외불가 보장이 제공된다) 예외에 안전한 코드를 만들기 위한 가장 기본적이며 핵심적인 요소가 아닐까 싶다

      어떤 예외도 던지지 않게끔 예외 지정이 된 함수는 예외불가 보장을 제공한다고 생각해도 일견 맞을 것 같지만, 잘못 생각한 것이다. 아래를 보자

      int doSomething() throw();	// 비어 있는 예외 지정
      

      위의 함수 선언이 전하는 메시지는 doSomething이 절대로 예외를 던지지 않겠다는 말이 아니다. 만약 dosomething에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자인 unexpected 함수가 호출되어야 한다는 뜻이다. 사실 doSomething은 어떤 예외 안전성 보장도 제공하지 않을 수도 있다. 함수 선언문에는(예외 지정이 붙어 있으면 이것도 포함된다) 해당 함수가 맞는지, 이식성이 있는지, 아니면 효율적인지 알려 주는 기능 같은 것이 없다. 예외 안전성 보장을 제공하는지도 당연히 알려 주지 않는다. 함수가 어떤 특성을 갖느냐 하는 부분은 ‘구현’이 결정하는 것이다. ‘선언’은 그냥 선거공약 같은 것이다.

  • 앞에서 말했지만, 예외 안정성을 갖춘 함수는 위 세 가지 보장 중 하나를 반드시 제공해야 한다. 따라서 여러분이 ‘선택’해야 하는 것은 ‘어떤 보장을 제공할 것인가’이다.

  • changeBackground 함수를 다시 들여다보도록 하자. 이 함수의 경우엔 강력한 보장을 거의 제공하는 것은 그다지 어렵지 않다. 우선 첫째로, PrettyMenu의 bgImage 데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담용 포인터로 바꾼다. 뒤에서 볼 코드에서 자원관리용 객체로 tr1::shared_ptr을 쓸 것이다. auto_ptr도 있긴 하지만, 복사될 때의 동작이 더 직관적이라 사용하기가 더 좋기 때문이다. 둘째로, changeBackground 함수 내의 문장을 재배치해서 배경그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만든다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우에는 해당 동작이 실제로 일어날 때까지 그 객체의 상태를 바꾸지 않는 편이 일반적으로 좋다고 한다.

    class PrettyMenu
    {
    	...
      std::tr1::shared_ptr<Image> bgImage;
      ...
    };
      
    void PrettyMenu::changeBackground(std::istream& imgSrc)
    {
      Lock m1(&mutex);
        
      // bgImage의 내부 포인터를
      // "new Image" 표현식의 실행 결과로 바꿔치기한다.
      bgImage.reset(new Image(imgSrc));
        
    ++imageChanges;
    }
    

    이제는 이전의 배경그림(Image 객체)을 프로그래머가 직접 삭제할 필요가 없게 되었다. 이제는 tr1::shared_ptr::reset 함수가 호출되려면 이 함수의 매개변수(“new Image(imgSrc)”의 결과)가 제대로 생성되어야 한다는 것이다. delete 연산자는 reset 함수 안에 쏙 들어 있기 때문에, reset이 불리지 않는 한 delete도 쓰일 일이 없을 것이다. 객체(tr1::shared_ptr)를 써서 자원(동적 할당된 Image 객체)을 관리하게 하니까 changeBackground 함수의 길이까지 줄어들었다.

  • 이것만은 잊지 말자! 1) 예외 안정성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않는다. 이런 함수들이 제공할 수 있는 예외 안정성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있다. 2) 강력한 예외 안정성 보장은 ‘복사-후-맞바꾸기’ 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아니다. 3) 어떤 함수가 제공하는 예외 안정성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다.


  • 항목30: 인라인 함수는 미주알고주알 따져서 이해해 두자

    • 인라인 함수의 아이디어는 함수 호출문을 함수의 본문으로 바꿔치기하자는 것이다. 메모리가 제한된 컴퓨터에서 아무 생각 없이 인라인을 남발했다가는 프로그램 크기가 그 기계에서 쓸 수 있는 공간을 넘어버릴 수도 있다. 가상 메모리를 쓰는 환경일지라도 인라인 함수로 인해 부풀려진 코드는 성능의 걸림돌이 되기 쉽다. 페이징 횟수가 늘어나고, 명령어 캐시 적중률이 떨어질 가능성도 높아진다. 수행 성능은 이런 문제들과 얽히면서 타격을 입는 것이다.

    • 반대의 경우도 있다. 본문 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 본문에 대해 만들어지는 코드의 크기가 함수 호출문에 대해 만들어지는 코드보다 작아질 수도 있다. 이런 경우에는 상황이 바뀐다. 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 높아진다.

    • inline은 컴파일러에 대해 ‘요청’을 하는 것이지, ‘명령’이 아니다. 이 요청은 inline을 붙이지 않아도 그냥 눈치껏 되는 경우도 있고 명시적으로 할 수도 있다. 우선 암시적인 방법부터 알아보자. 클래스 정의 안에 함수를 바로 정의해 넣으면 컴파일러는 그 함수를 인라인 함수 후보로 찍는다.

      class Person
      {
        public:
         ...
         int age() const {return theAge;} // 암시적인 인라인 요청 : age는  
        																	// 클래스 정의 내부에서 정의되었다.
         ...
        private:
         int theAge;
      };
      
    • 인라인 함수를 선언하는 명시적인 방법은 함수 정의 앞에 inline 키워드를 붙이는 것이다. 한 예로, 표준 라이브러리의 max 템플릿(에 있다)은 대개 다음과 같이 구현되어 있다.

      template<typename T>
          
      // 명시적인 인라인 요청
      // std::max 앞에 "inline"이 붙어 있다.
      inline const T& std::max(const T& a, const T& b)
      {return a < b ? b : a;}
      
    • 대부분의 컴파일러의 경우, 아무리 인라인 함수로 선언되어 있어도 자신이 보기에 복잡한 함수는 절대로 인라인 확장의 대상에 넣지 않는다. (루프가 들어 있다거나 재귀 함수인 경우가 이런 예이다) 정말 간단한 함수라 할지라도 가상 함수 호출 같은 것은 절대로 인라인해 주지 않는다.

    • 인라인 함수가 실제로 인라인되느냐 안 되느냐의 여부는 전적으로 개발자가 사용하는 빌드 환경에 달려있다. 여러분이 요청한 함수에 대한 인라인이 실패했을 경우에 경고 메시지를 내주는 진단 수준 설정 기능이 대부분의 컴파일러에 들어 있다.

    • 이것만은 잊지 말자! 1) 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어두자. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아진다. 2) 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 된다.

Categories:

Updated:

Leave a comment