More Effective C++

more.bmp 

목차
  1. 기본 개념들
    1. 1. 포인터와 참조자를 구분하자.
    2. 2. 가능한 C++ 스타일의 캐스트를 즐겨 쓰자.
    3. 3. 배열과 다형성은 같은 수준으로 놓고 볼 것이 아니다.
    4. 4. 쓸데 없는 기본 생성자는 그냥 두지 말자.
  2. 연산자
    1. 5. 사용자 정의 타입변환 함수에 대한 주의를 놓지 말자.
    2. 6. 증가 및 감소 연산자의 전위/후위 형태를 반드시 구분하자.
    3. 7. &&, ||, 혹은 , 연산자는 오버로딩 대상이 절대로 아니다.
    4. 8. new 와 delete의 의미를 정확히 구분하고 이해하자.
  3. 예외
    1. 9. 리소스 누수를 피하는 방법의 정공은 소멸자이다.
    2. 10. 생성자에서는 리소스 누수가 일어나지 않게 하자.
    3. 11. 소멸자에서는 예외가 탈출하지 못하게 하자.
    4. 12. 예외 발생이 매개변수 전달 혹은 가상 함수 호출과 어떻게 다른지를 이해하자.
    5. 13. 발생한 예외는 참조자로 받아내자.
    6. 14. 예외 지정 기능은 냉철하게 사용하자.
    7. 15. 예외 처리에 드는 비용에 대해 정확히 파악하자.
  4. 효율
    1. 16. 뼛속까지 잊지 말자, 80-20 법칙!
    2. 17. 효율 향상에 있어 지연 평가는 충분히 고려해 볼 만하다.
    3. 18. 예상되는 계산 결과를 미리 준비하면 처리비용을 깎을 수 있다.
    4. 19. 임시 객체의 원류를 정확히 이해하자.
    5. 20. 반환값 최적화가 가능하게 하자.
    6. 21. 오버로딩은 불필요한 암시적 타입변환을 막는 한 방법이다.
    7. 22. 단독 연산자(op) 대신에 =이 붙은 연사자(op=)를 사용하는 것이 좋을 때가 있다.
    8. 23. 정 안 되면 다른 라이브러리를 사용하자!
    9. 24. 가상 함수, 다중 상속, 가상 기본 클래스, RTTI에 들어가는 비용을 제대로 파악하자.
  5. 유용하고 재미있는 프로그래밍 기법들
    1. 25. 생성자 함수와 비멤버 함수를 가상 함수처럼 만드는 방법
    2. 26. 클래스 인스턴스의 개수를 의도대로 제한하는 방법
    3. 27. 힙에만 생성되거나 힙에는 만들어지지 않는 특수한 클래스를 만드는 방법
    4. 28. 스마트 포인터
    5. 29. 참조 카운팅
    6. 30. 프록시 클래스
    7. 31. 함수를 두 개 이상의 객체(타입)에 대해 가상 함수처럼 동작하도록 만들기
  6. 이외의 이야기들
    1. 32. 미래 지향적인 프로그래머가 되자.
    2. 33. 상속 관계의 말단에 있지 않은 클래스는 반드시 추상 클래스로 만들자.
    3. 34. 한 프로그램에서 C++와 C를 함께 사용하는 방법을 이해하자.
    4. 35. C++ 언어의 최신 표준안과 표준 라이브러리에 대해 익숙해지자.

 

기본 개념들#

 

1. 포인터와 참조자를 구분하자.#

우선, 참조자 개념에선 "널 참조자(null reference)"란 것이 없다는 점을 아셔야 합니다. 참조자는 어떤 경우든지 메모리 공간을 차지한 객체를 참조하고 있어야 합니다. 객체를 참조하는 어떤 변수가 가리키는 메모리가 항상 유효한 객체이어야 한다면, 말하자면 그 변수가 널일 가능성을 완전히 처음부터 배제한다면 참조자를 쓰셔야 할 것입니다. 참조자는 반드시 객체를 참조하고 있어야 하기 때문에, C++ 스펙에 의하면 참조자는 선언될 때 반드시 초기화해야 합니다.

 

  1. string& rs;   // 에러! 참조자는 반드시 초기화해야 합니다.
  2. string s("abcde");
  3. string& rs = s;   // 통과. rs는 s를 참조합니다.

 

참조자 같은 것이 C++ 계엔 존재하지 않는다는 사실은 참조자는 쓰기 전에 유효성을 검사할 필요가 없다는 것을 뜻합니다. 하지만, 포인터를 쓸 경우에는 사용 전에 반드시 유효한 객체를 가리키고 있는지 검사해야 하지요.

 

포인터와 참조자가 다른 점 또 하나는 "다른 객체를 참조하게 할 수 있는가"의 여부입니다. 포인터는 다른 객체의 주소값으로 얼마든지 바꾸어 셋팅할 수 있습니다. 하지만 참조자는 초기화될 때 참조했던 그 객체만 참조합니다.

 

  1. string s1("Nancy");
  2. string s2("Clancy");
  3. string& rs = s1;   // rs는 s1을 참조합니다.
  4. string *ps = &s1;   // ps는 s1을 가리킵니다.
  5. rs = s2;         // rs는 여전히 s1을 가리키지만, s1의 값은 이제 "Clancy"입니다.
  6. ps = &s2;         // ps는 이제 s2를 가리킵니다. 그리고 s1은 바뀌지 않습니다.

 

참조자를 반드시 써야 하는 특수한 상황이 연산자 함수를 구현할 때입니다. 가장 흔한 예를 들자면 operator[]인데, 이 연산자는 대입 연산자의 좌변(대입 대상)으로 쓸 수 있는 값을 반환해 주도록 만드는 것이 보통입니다.

 

  1. vector<int> v(10);   // 크기 10의 int 벡터를 만듭니다.
  2. v[5] = 10;            // 이 대입 연산의 대상은 operator[]의 반환값입니다.

 

만일에 operator[]가 포인터를 반환하면, 앞의 마지막 문장은 다음과 같은 조금 어색한 형태가 되어야 할 것입니다.

  1.  *v[5] = 10;

코드는 v가 포인터의 벡터인 것처럼 보이게 하는 단점을 가지고 있습니다. 실제로는 포인터의 벡터가 아닌데도 말이죠. 이 때문에 operator[]를 구현할 때는 십중팔구 참조자를 반환하도록 해야 할 것입니다.

 

지금까지의 이야기를 종합해볼 때, 참조자는 참조하고자 하는 어떤 객체를 미리 알고 있을 때, 다른 객체를 바꾸어 참조할 일이 결코 없을 때, 그리고 포인터를 사용하면 문법상 의미가 어색해지는 연산자를 구현할 때 선택하면 됩니다. 이 세 가지의 경우를 제외하고는 무조건 포인터입니다.

 

 

2. 가능한 C++ 스타일의 캐스트를 즐겨 쓰자.#

 이번 항목에서는 goto와 함께 프로그래밍계의 1급 기피대상인 "캐스트(cast, 형변환)"란 것에 대해 생각해 보기로 합시다. C 스타일의 캐스트는 있어야 할 것이 못 됩니다. 우선, 이것의 첫째 문제는 C 스타일의 캐스트는 어떤 타입을 다른 타입으로 아무 생각 없이 바꾸어주는 괴물이나 마찬가지라는 것입니다. C 스타일의 캐스트가 가진 또 하나의 문제는 눈으로 찾아내기가 힘들다는 점입니다. 문법적으로 보면 캐스트는 식별자를 괄호로 둘러싼 것일 뿐입니다.

 

C++는 이러한 C 스타일 캐스트의 문제를 보완하기 위해 C++ 스타일의 캐스트 연산자 네 가지를 새로 도입했습니다. 이 네 가지의 연산자는 static_cast, const_cast, dynamic_cast, reinterpret_cast인데, 이 연산자에 대해 여러분이 알아야 하는 딱 한 가지는, C++ 스타일의 타입 캐스팅을 하려면 다음과 같이 쓰지 않고

(타입) 표현식

보통 다음과 같이 쓴다는 점입니다.

static_cast<타입>(표현식)

예를 하나 듭시다.

  1. int num;
  2. double re = (double) num;   // C 스타일 캐스트
  3. double re = static_cast<double>(num)   // C++ 스타일 캐스트

이제 인간의 눈이나 프로그램이나 발견하기 쉬운 캐스트가 되었습니다.

 

static_cast는 C 스타일의 캐스트와 똑같은 의미와 형변환 능력을 가지고 있는, 기본적인 캐스트 연산자입니다. C 스타일의 그것과 구실이 똑같다 보니 받는 제약도 똑같습니다. 예를 들어 struct를 int 타입으로 바꾼다든지 double을 포인터 타입으로 바꾸는 일은 이것으로 할 수 없습니다.

 

const_cast는 표현식의 상수성이나 휘발성(volatileness)을 없애는 데에 사용합니다. 즉, 상수성이나 휘발성을 제거하는 것 이외의 용도로 const_cast를 쓰면 통하지 않습니다. 다음 예제를 봅시다.

  1. class Widget {...};
  2. class SpecialWidget : public Widget {...};
  3. void update(SpecialWidget *psw);
  4. SpecialWidget sw;                // sw는 비상수 객체입니다.
  5. const SpecialWidget& csw = sw;   // 그러나 csw는 이 객체를 상수 객체인 것처럼 참조합니다.
  6. update(&csw);                    // 에러! const SpecialWidget*는
  7.  // SpecialWidget*를 받는 함수에 넘길 수 없습니다.
  8. update( const_cast<SpecialWidget*>(&csw) ); // 이상무! &csw의 상수성이 명확하게 제거됨.

 

dynamic_cast는 계층 관계를 가로지르거나 하향시킨 클래스 타입으로 안전하게 캐스팅할 때 사용합니다. 말하자면, dynamic_cast는 기본 클래스의 객체에 대한 포인터나 참조자의 타입을 파생 클래스, 혹은 형제(sibling) 클래스의 타입으로 변환해 준다는 것입니다. 캐스팅의 실패는 널 포인터(포인터를 캐스팅할 때)나 예외(참조자를 캐스팅할 때)를 보고 판별할 수 있습니다.

  1. Widget *pw;
  2. ...
  3. update(dynamic_cast<SpecialWidget*>(pw));
  4. // 이상 무! update에 포인터 pw를
  5. // SpecialWidget에 대한 포인터로서 넘깁니다.
  6. // 물론 pw가 그 객체를 가리켜야 되고,

    // 그렇지 않으면 널 포인터가 넘어갑니다.

dynamic_cast에도 제약이 있습니다. 이 연산자는 상속 계층 구조를 오갈 때에만 사용해야 합니다. 게다가 가상 함수가 없는 타입에는 적용할 수 없고, 상수성 제거에도 쓸 수 없습니다.

 

reinterpret_cast가 적용된 후의 변환 결과는 거의 항상 컴파일러에 따라 다르게 정의되어 있습니다. 따라서, 이 연산자가 쓰인 소스는 직접 이식이 불가능합니다.

reinterpret_cast의 가장 흔한 용도는 함수 포인터 타입을 서로 바꾸는 것입니다. 예를 들어, 어떤 특정한 타입의 함수 포인터를 배열로 만들어 놓았다고 가정합시다.

  1. typedef void (*FuncPtr) ();      // FuncPtr은 인자를 받지 않고 void를 반환하는 함수에 대한 포인터
  2. FuncPtr funcPtrArray[10];        // funcPtrArray는 10개의 FuncPtr로 만들어진 배열
  3. // 이때 다음의 함수에 대한 포인터를 funcPtrArray에 넣어야 할 피치 못할 사정이 생겼습니다.
  4. int doSomething();

간단할 것 같지만 캐스팅을 하지 않으면 절대로 안 됩니다. 왜냐하면 doSomething은 funcPtrArray에 넣기에는 타입이 맞지 않기 때문입니다. 이 배열에 들어가는 함수는 void를 반환하지만, doSomething은 int를 반환하지 않습니까?

  

  1. funcPtrArray[0] = &doSomething;                            // 에러! 타입 불일치
  2. funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); // 컴파일 통과!

함수 포인터의 캐스팅은 소스의 이식성을 떨어뜨리고(C++ 스펙에는 모든 함수 포인터를 똑같은 방법으로 나타내야 한다는 보장이 전혀 없습니다), 어떤 경우에 이런 캐스팅은 잘못된 결과를 낳기도 하기 때문에, 함수 포인터의 캐스팅은 어떻게든 피하세요.

 

 

3. 배열과 다형성은 같은 수준으로 놓고 볼 것이 아니다.#

상속성이 주는 가장 중요한 혜택 중 하나는, 아마도 기본 클래스 객체의 포인터나 참조자를 통해 파생 클래스 객체를 조작할 수 있다는 점입니다.

예를 하나 들겠습니다. BST란 클래스가 있고, 이 BST에서 파생시킨 BalancedBST란 클래스가 있습니다. BST와 BalancedBST 객체는 int만을 요소로 가진다고 가정합니다.

  1. class BST {...};
  2. class BalancedBST : public BST {...};

 

자, BST의 배열에 담긴 BST 각각의 정보를 프린트하는 함수가 하나 등장합니다.

  1. void printBSTArray(ostream& s, const BST array[], int numElements)
  2. {
  3. for(int i = 0 ; i < numElements ; ++i)
  4. s << array[i];      // operator<< 연산자 함수가 BST 클래스에서 이미 정의되어 있다고 가정
  5. }

 

이 함수는 이제 BST 객체의 배열을 매개변수로 넘겨줄 때 작동하겠지요.

  1. BST BSTArray[10];
  2. ...
  3. printBSTArray( cout, BSTArray, 10 );      // 잘 작동합니다.
  4. BalancedBST bBSTArray[10];
  5. ...
  6. printBSTArray( cout, bBSTArray, 10 );    // 잘 작동할까?

컴파일러는 이 함수 호출문에 대해 아무런 불평도 하지 않을 것입니다.

 

사실, printBSTArray()의 array[i]는 포인터값 계산을 수행하는 표현식을 짧게 나타난 것입니다. 즉, 원래의 표현식은 *(array+i)라 이거죠. array+i가 가리키는 메모리 위치는 배열이 가리키는 위치로부터 얼마나 떨어져 있는지도 간단한 공식으로 알아 낼 수 있습니다. i*sizeof(배열내의 요소 객체 하나)입니다. array[0]와 array[i] 사이에 i개의 객체가 있으니까요. printBSTArray()에서 매개변수 array는 BST의 배열 타입으로 선언되어 있기 때문에, 배열 내의 각 요소는 BST일 수밖에 없고, array와 array+i 사이의 거리는 i*sizeof(BST)가 됩니다.

여기까지가 컴파일러가 동작하는 원리입니다. 하지만, BalancedBST의 배열을 printBSTArray에 넘기면 컴파일러는 똑바로 동작하지 못할 것입니다. 이 경우, 컴파일러는 배열에 들어있는 각 객체의 크기가 sizeof(BST 객체)라고 가정하지만, 실제로는 BalancedBST의 크기여야 맞습니다. 파생 클래스는 보통 기본 클래스보다 더 많은 데이터를 가지고 있게 마련이므로, 당연히 그 크기도 기본 클래스보다 큽니다.

 

다형성과 포인터 산술 연산은 주먹구구식으로 간단히 섞이는 성질의 것이 아닙니다. 배열 연산에는 거의 항상 포인터 산술 연산이 따라다니기 때문에, 배열과 다형성은 물과 기름의 관계라고 알아두십시오.

 

 

4. 쓸데 없는 기본 생성자는 그냥 두지 말자.#

기본 생성자(아무런 인자도 받지 않고 호출될 수 있는 생성자)는 "특별하게 무엇을 하긴 귀찮은데 객체 하나는 얻고 싶어"라고 알리는 C++식 대사입니다. 생성자는 객체를 초기화하는 역할을 맡기 때문에, 기본 생성자는 객체가 생성되고 있는 위치의 주변 정보를 전혀 갖지 않고도 객체를 초기화합니다. 하지만 어떤 객체의 경우 외부 정보 없이는 완전한 초기화를 수행할 수 없기도 합니다. 외부 정보 없이 객체가 생성될 수 있는 클래스는 기본 생성자를 가져야 할 것이고, 객체가 생성되려면 외부 정보를 요구하는 경우의 클래스는 이것을 갖지 말아야 할 것입니다.

 

기본 생성자가 없는 클래스의 경우에는 사용상의 몇 가지 주의 사항을 지켜야 합니다.

설명을 위해, ID번호를 붙여햐 하는 회사 장비(Equipment)를 나타내는 클래스의 코드를 구체적으로 살펴보도록 합시다.

  1. class EquipmentPiece {
  2. public :
  3. EquipmentPiece(int IDNumber);
  4. ...
  5. };

보다시피 EquipmentPiece엔 기본 생성자가 없기 때문에, 이 클래스를 썼을 때 문제가 일어날 수 있는 경우가 세 가지나 됩니다. 첫째는 배열을 생성할 때입니다. 일반적으로, 배열의 요소로서 들어가는 객체에 대해 생성자 매개변수를 지정할 수가 없기 때문에, EquipmentPiece 객체의 배열을 생성하기란 불가능합니다.

  1. EquipmentPiece bsetPieces[10];   // 에러! EquipmentPiece의 생성자를 호출 못함.
  2. EquipmentPiece *bsetPieces = new EquipmentPiece[10];   // 에러!

이 제약을 피해 가는 것에는 세 가지 방법이 있습니다. 배열을 힙에 만들지 않았을 때의 해결책은 배열이 정의된 위치에서 생성자 매개변수를 직접 넣어 주는 것입니다.

  1. int ID1, ID2, ID3, ..., ID10;      // 장비의 ID 번호를 가질 변수들
  2. ...
  3. EquipmentPiece bsetPieces[] = {   // 아무 문제 없음.
  4. EquipmentPiece(ID1),          // 생성자의 인자가 주어졌다.
  5. EquipmentPiece(ID2),
  6. ...
  7. };

불행히도 힙에 생성한 배열(new로)에 대해서는 이 방법을 어떻게 써볼 수가 없답니다.

보다 일반적인 방법은 객체의 배열 대신에 포인터의 배열을 사용하는 것입니다.

  1. typedef EquipmentPiece* PEP;
  2. PEP bestPieces[10];      // 문제없습니다. 생성자가 전혀 호출되지 않습니다.
  3. PEP *bestPieces = new PEP[10];   // 역시 문제 없습니다.
  4. for(int i = 0 ; i < 10 ; ++i)
  5. bestPieces[i] = new EquipmentPiece(ID NUMBER);

이 방법은 눈에 밟히는 단점이 두 개나 됩니다. 우선, 이 배열 내의 모든 포인터가 가리키는 객체를 삭제해야 한다는 것을 잊으면 안 됩니다. 이것을 잊으면 반드시 리소스 누수가 일어나게 됩니다. 둘째로, 필요한 메모리 사용량이 늘어나게 됩니다. EquipmentPiece 객체를 담을 메모리 공간 외에 포인터를 담을 공간이 필요하니까요.

물론 약간의 머리를 쓰면 메모리 공간을 줄일 수 있습니다. 배열에 대해 직접 비가공(raw) 메모리를 할당한 후에 "메모리 지정 new"를 써서 그 메모리 안에다 EquipmentPiece 객체가 생성되게 하는 것이죠.

  1. // 10개의 EquipmentPiece 객체를 담을 수 있는 크기의 비가공 메모리를 생성합니다.
  2. void *rawMemory = operator new[] (10*sizeof(EquipmentPiece));
  3. // bestPieces가 EquipmentPiece 배열 첫 요소의 주소가 되도록 세팅합니다.
  4. EquipmentPiece *bestPieces = static_cast<EquipmentPiece*>(rawMemory);
  5. // "메모리 지정 new"를 써서 이 메모리 안에 EquipmentPiece 객체 10개를 생성합니다.
  6. for(int i = 0 ; i < 10 ; ++i)
  7. new (bestPieces+i) EquipmentPiece(ID NUMBER);

메모리 지정 new를 사용하는 방법의 단점은, "객체를 삭제할 때에 소멸자를 손으로 직접 호출해야 한다"는 점입니다. 소멸자 호출로만 일이 끝나는 것이 아니라, 그 다음에는 operator delete[]를 호출해서 원래의 비가공 메모리를 직접 해제해야 합니다.

  1. // bestPieces 내의 객체들을 그 객체가 생서된 순서의 반대 순서로 없앱니다.
  2. for( int i = 9 ; i >= 0 ; --i )
  3. bestPieces[i].~EquipmentPiece();
  4. // 원래의 메모리를 없앱니다.
  5. operator delete[] (rawMemory);

 

기본 생성자가 없는 클래스의 두 번째 문제점은 많은 템플릿 기반의 컨테이너 클래스에 먹일수가 없다는 것입니다. 왜일까요? 이런 컨테이너 클래스를 인스턴스화하기 위해서는, 템플릿 매개변수로 들어가는 타입이 기본 생성자를 가지고 있어야 하기 때문입니다. 예를 들어, Array 클래스에 대한 템플릿은 다음과 같은 형태로 되어 있겠지요.

  1. template<class T>
  2. class Array {

    public :

  3. Array(int size);
  4. ...
  5. private :
  6. T *data;
  7. };
  8.  
  9. template<class T>
  10. Array<T>::Array(int size)
  11. {
  12. data = new T[size];   // 배열 안의 각 요소에 대해 T::T()를 호출
  13. ...
  14. }

대부분의 경우, 신경써서 템플릿을 설계하면 기본 생성자가 필요 없어지기도 합니다. 한 예가 표준 vector 템플릿인데, 템플릿 매개변수로 받는 타입이 기본 생성자를 제공하지 않아도 됩니다.

 

기본 생성자가 없는 가상 기본 클래스는 소프트웨어 개발에 쓰기 힘듭니다. 그 이유는 가상 기본 클래스의 생성자 매개변수를, 생성되는 객체의 파생 클래스 쪽에서 제공해야 하기 때문입니다. 따라서, 기본 생성자가 없는 가상 기본 클래스를 상속한 파생 클래스는, 어처구니없게도 기본 클래스의 생성자 매개변수의 리스트와 각 매개변수의 의미를 알고 직접 제공해야 한다는 결과가 나옵니다.

 

쓸데 없는 기본 생성자는 클래스의 효율에도 걸림돌이 됩니다. 멤버 함수들은 클래스의 멤버 데이터(필드)가 제대로 초기화되었는지를 검사해야 하니, 이 검사 시간 만큼 실행 속도가 떨어지겠지요. 게다가, 추가된 검사 코드의 크기만큼 실행 파일 및 라이브러리도 커집니다. 그뿐만이 아닙니다. 검사가 실패했을 경우를 처리하는 코드도 생각해야죠. 이것을 막는 길은 클래스 생성자가 객체의 멤버 데이터를 확실히 초기화하는 것입니다. 기본 생성자에서 이런 확실한 초기화를 기대하기란 어려우므로, 쓸데 없는 상황에서는 기본 생성자를 피하는 것만이 유일한 상책입니다. 물론 이렇게하면 클래스 사용에 제약을 받죠. 하지만 멤버 함수를 작성할 때 모든 객체 멤버가 제대로 초기화되어 있다는 확신을 가질 수 있기 때문에, 함수마다 귀찮은 검사/처리 코드를 넣지 않아도 되고 멤버 함수의 효율도 좋아집니다.

 

 

연산자#

 

5. 사용자 정의 타입변환 함수에 대한 주의를 놓지 말자.#

C++가 컴파일러로 하여금 스스로의 추론에 의한 암시적 타입변환을 할 수 있도록 한다는 사실은 웬만한 프로그래머는 아는 사실입니다. 사실 이런 암시적 타입변환에 대해 어떻게 할 수는 없습니다. 언어 안에 이 매커니즘이 하드코딩되어 있는 것을 어쩌겠습니까. 하지만, 사용자가 만드는 타입에 대해서는 암시적 타입변환을 수행하기 위해, 컴파일러가 사용하는 함수를 제공할 것인가 말 것인가의 여부를 결정할 수 있습니다.

컴파일러가 사용할 수 있는 이런 타입변환 함수는 두 가지 종류입니다. 하나는 단일 인자 생성자이고, 또 하나는 암시적 타입변환 연산자입니다. 단일 인자 생성자는 인자를 하나만 받아 호출되는 생성자인데, 이런 생성자는 매개변수가 하나를 받도록 선언되어 있든지, 매개변수가 여러 개인데 처음 것을 제외한 나머지가 모두 기본값을 갖도록 선언되어 있습니다.

 

암시적 타입 변환 연산자는 사실 이상한 모양의 멤버 함수일 뿐입니다. 왜 이상한 모양이냐 하면, 보통의 함수 선언 형태가 아니라 타입 앞에 operator만 덜렁 붙어 있는 꼴이기 때문입니다.

  1. class Rational {
  2. public :
  3. ...
  4. operator double() const;   // Rational을 double로 암시적으로 바꿉니다.
  5. };

이 함수는 다음과 같은 문장에 들어 있을 때 자동으로 호출됩니다.

  1. Rational r(1,2);      // r은 1/2 이란 값을 가지고 있습니다.
  2. double d = 0.5 * r;   // r을 double로 바꾸고 곱셈을 수행합니다.

근본적인 문제는 이런 타입변환 함수들이 여러분이 원하든 싫어하든 상관 없이 호출되고 만다는 것입니다. 이 때문에 프로그램의 동작이 엉뚱해지고 비직관적으로 변합니다. 이런 동작은 오류를 찾아내기가 끔찍이도 어렵습니다.

 

우선 암시적 타입변환 연산자를 살펴보도록 하죠. 앞과 비슷한 유리수 표현 클래스가 하나 있고, Rational 객체를 기본 제공 타입처럼 콘솔에 출력하고 싶습니다. 즉, 다음과 같이 하고 싶다는 것을 뜻합니다.

  1. Rational r(1,2);
  2. cout << r;      // "1/2"를 출력해야 합니다.

이때, 이 Rational 객체에 operator<< 함수를 작성해 넣는 것을 잊었다는 가정을 하나 더 합시다. 여러분 중 열에 아홉은 r을 콘솔에 출력하진 못할 것이라고 예상하시겠지요. 왜냐하면 컴파일러가 호출할 수 있는 operator<<가 없으니까요. 그런데 틀렸습니다. 분명히 컴파일러는 Rational 객체를 받는 operator<<를 호출해야 하는 시점에서 이 함수를 찾지 못하지만, 어떻게든 함수 호출을 성공시키기 위해 자신이 쓸 수 있는 암시적 타입변환 함수들을 찾아 맞추어 봅니다. 컴파일러가 적합한 타입변환 함수를 찾는 규칙은 꽤나 복잡하지만, 이 경우에는 Rational::operator double()을 찾아내어 암시적 타입변환을 수행하고, 결국 operator<< 함수 호출은 double 타입이 출력되는 것으로 성공합니다. 문제는 출력되는 r이 유리수가 아니라 부동 소수점 실수(0.5)라는 것인데, 프로그래머의 의도와 달리 함수가 잘못 호출됩니다.

이것을 해결하려면 똑같은 일을 하되 다른 이름을 갖는 함수로 이 연산자를 바꿔치기 해야 합니다. 예를 들어, Rational 객체를 double 타입으로 바꾸어주는 연산자인 operator double은 asDouble 같은 이름의 함수로 바꾸는 것이죠.

  1. class Rational {
  2. public :
  3. ...
  4. double asDouble() const;   // Rational을 double 타입으로 바꿉니다.
  5. };
  6. Rational r(1,2);
  7. cout << r;      // 에러! Rational을 받는 operator<<가 없습니다.
  8. cout << r.asDouble();    // 문제없습니다! r을 double타입으로 출력합니다.

이렇게 타입변환 함수를 직접 호출하면 불편하지만, 의도하지 않았던 잘못된 함수 호출은 막을 수 있다는 사실이 충분히 보상해 줍니다.

 

단일 인자 생성자를 통한 암시적 타입변환은 없애기가 더 까다롭습니다. 게다가 이것 때문에 일어나는 문제는 암시적 타입변환 연산자 때문에 발생하는 문제보다 많은 경우에 더 고약합니다.

역시 예를 하나 들겠습니다. 배열 객체를 나타내는 클래스 템플릿이 하나 있습니다. 이 클래스 템플릿은 사용자가 배열 인덱스의 상한값과 하한값을 지정할 수 있습니다.

  1. template<class T>
  2. class Array {
  3. public :
  4. Array(int lowBound, int highBound);
  5. Array(int size);
  6. T& operator[](int index);
  7. ...
  8. };

이 클래스의 두번째 생성자는 사용자가 배열의 크기를 지정하는 정수값을 넣어 줄 수 있도록 하나의 인자만 받게끔 만들어져 있습니다. 이 생성자는 타입변환 함수로 쓰일 수 있습니다.

예를 하나 들겠습니다. 앞의 템플릿을 특화시킨 Array<int> 객체가 있고, 이 객체들을 비교하는 연산자, 그리고 이것을 사용하는 코드가 다음과 같다고 합시다.

  1. bool operator==(const Array<int>& lhs, const Array<int>& rhs);
  2. Array<int> a(10);
  3. Array<int> b(10);
  4. ...
  5. for(int i = 0 ; i < 10 ; ++i)
  6. if( a == b[i] ) {         // 윽! "a"는 "a[i]"가 되어야 합니다.
  7. // a[i]와 b[i]가 같으면 정해둔 무언가를 합니다.
  8. }
  9. else {
  10. // 그렇지 않을 때에는 또 무언가를 합니다.
  11. }

애초에 의도한 바는 a에 들어있는 요소를 b에 들어있는 요소와 1대1로 비교하는 것이었는데, 어쩌다가 실수로 a 옆에 배열 인덱스 연산자를 붙이지 않았습니다. 순간적으로 '컴파일 에러 메시지'가 나올거라 생각하실 테지만, 컴파일러는 전혀 불평하지 않습니다. 왜냐하면, 컴파일러는 Array<int>타입과 int타입을 받는 operator== 연산자 함수가 호출되는 것으로 판단하기 때문입니다. 실제론 이렇게 선언된 operator==는 없습니다. 하지만 컴파일러는 int를 Array<int> 타입으로 바꿀 수 있다는 사실을 발견합니다. 어째서일까요? 바로 int 하나만 받아들이는 문제의 그 생성자가 있었거든요. 이리하여 컴파일러에 의해 다음과 같은 비슷한 코드가 생성됩니다.

  1. for( inti = 0 ; i < 10 ; ++i )
  2. if( a== static_cast<Array<int> >(b[i])) ...

앞의 코드는 다시 말하자면, 루프를 한 번 돌 때 a의 내용과 b[i] 만큼의 크기를 가진 임시 배열(Array<int> 객체)에 대해 operator==를 호출하라는 뜻입니다.

 

이런 경우를 피하는 쉬운 방법은 C++에 도입된 새로운 기능 중 하나인 explicit 키워드를 사용하는 것입니다. 이 기능은 암시적 타입변환의 문제를 막기 위해 만들어진 것이고, 사용법도 상당히 단순합니다. 그냥 이 키워드를 생성자 앞에 붙여주면 끝입니다. 매개변수와 호출이 명확할 때에만 이 생성자를 호출하라고 알려주는 뜻이므로, explicit로 선언된 생성자는 암시적 타입변환에 사용되지 않습니다. 하지만, "명시적" 타입변환은 여전히 허용됩니다.

  1. template<class T>
  2. class Array {
  3. public :
  4. explicit Array(int size); 
  5. ...
  6. };
  7. Array<int> a(10);   // 좋습니다, explicit로 선언된 ctor는 객체 생성시에 평상시와 마찬가지로 사용할 수 있습니다.
  8. Array<int> b(10);
  9. if( a == b[i] ) ...   // 에러! int를 암시적으로 Array<int>로 바꿀 방법이 없습니다.
  10. if( a == Array<int>(b[i])) ... // 좋습니다. int를 Array<int>로 바꾸는 작업이 명시적으로 이루어졌습니다.

 만일 여러분이 쓰시는 컴파일러가 explicit를 지원하지 않으면, 별 수 없이 단일 인자 생성자를 사용하지 않는 다른 방법을 써야 합니다. "사용자 정의 타입변환 함수는 두 개 이상 쓰이지 않는다"(말하자면, 단일 인자 생성자와 암시적 타입변환 연산자가 동시에 쓰이지 않는다는 것이죠). 그렇기 때문에, 클래스만 제대로 만들어 놓으면 이 규칙을 역이용해서 객체 생성은 허용하지만, 암시적 타입변환은 허용되지 않게 할 수 있습니다.

 

 

6. 증가 및 감소 연산자의 전위/후위 형태를 반드시 구분하자.#

전위와 후위 형태를 구분하는 방법은, 전위 형태는 인자를 전혀 받지 않고 후위 형태는 int 타입의 인자를 받도록 해서, 컴파일러는 후위 형태의 증감 연산자가 호출될 때 0을 넘기도록 한 것입니다.

  1. class UPInt {
  2. public :
  3. UPInt& operator++();           // 전위 ++
  4. const UPInt operator++(int);   // 후위 ++
  5. UPInt& operator--();           // 전위 --
  6. const UPInt operator--(int);   // 후위 --
  7. UPInt& operator+=(int);        // UPInt와 int에 대해 마련한 += 연산자
  8. ...
  9. };
  10. UPInt i;
  11. ++i;         // i.operator++()를 호출
  12. i++;         // i.operator++(0)를 호출

 

C++ 스펙에 공식적으로 설정되어 있는 전위/후위 증가 연산자의 실제 구현 내용

  1. // 전위 형태 : 증가시킨 후에 값을 가져옵니다.
  2. UPInt& UPInt::operator++()
  3. {
  4. *this += 1;         // 증가
  5. return *this;       // 값을 가져옵니다.
  6. }
  7. // 후위 형태 : 값을 가져오고 증가시킵니다.
  8. const UPInt UPInt::operator++(int)
  9. {
  10. const UPInt oldValue = *this;   // 값을 가져옵니다.
  11. ++(*this);                      // 증가(전위형태를 사용)
  12. return oldValue;                // 미리 가져온 값을 반환.
  13. }

매개 변수의 역할은 그냥 후위 형태와 전휘 형태를 구분하기 위한 것뿐입니다.

 

후위 증가 연산자가 증가되기 전의 객체를 반환해야 한다는 점은 확실히 납득이 간다고 합시다. 한데 왜 const 타입이어야 할까요? 후위 연산자가 const가 아닌 객체를 반환하는 것을 꺼려야 하는 데는 두 가지의 이유가 있습니다. 첫째 이유는 기본 제공 타입에 대한 증가 연산자의 동작과 맞지 않는다는 것입니다. 클래스를 설계할 때 따르면 괜찮은 규칙 중 하나가 "아리송하면 int의 동작원리대로 만들지어다"라고 하는데, int 타입에 후위 증가 연산자를 두 번 쓰는 경우를 한 번도 본 적이 없죠?

  1. int i;
  2. i++++;       // 에러!

두 번째 이유는 사용자는 절대로 이런 괴상한 동작을 웃고 넘기지 않는다는 것입니다. 두 번째로 호출되는 operator++는 원래의 객체가 아닌 첫째 호출로 반환된 객체의 값을 바꾸어 놓습니다. 따라서 다음의 코드가 맞는다면,

  1. i++++;

i의 값은 한 번만 증가합니다. i=0이었다고 하면, i++ 이후에는 i가 1이 되지만, 두 번째의 ++는 원래의 i(1)가 아니라 i++로 반환된 임시 정수 값에 대해서 적용된다. 즉 그 임시 정수값은 2가 되지만, i는 그대로 1이 되는 것이다. 이런 연산자의 쓰임은 아무리 보아도 직관적이지 않을 뿐만 아니라 헷갈리기 때문에(int와 UPInt 모두에 대해) 절대로 용서되지 않습니다.

 

후위 증가 연산자 함수는 반환값으로 쓰기 위한 임시 객체를 만들어야 하고, 지역 변수로서 생겼다가 없어지는 임시 객체(oldValue)를 대놓고 만들고 있습니다. 전위 증가 연산자 함수는 이런 임시 객체를 사용하지 않습니다. 일단, 사용자 정의 타입을 가지고 작업할 때에는 되도록 전위 증가 연산자가 좋습니다. 부인할 수 없이 효율적이니까요.

"후위 증감 연산자는 반드시 전위 증감 연산자를 사용해서 구현할지어다"라는 원칙만 지키면 코드 유지가 매우 간편해집니다. 전위 버전만 관리하면 후위 버전은 알아서 똑같이 동작하기 때문입니다.

 

7. &&, ||, 혹은 ,연산자는 오버로딩 대상이 절대로 아니다.#

C와 마찬가지로 C++는 복합적인 불린 표현식을 평가할 때 단축평가 처리를 할 수 있습니다. 즉, 표현식의 일부가 참 혹은 거짓이란 것이 판명되면, 그 이후의 표현식은 거들떠보지도 않고 전체의 표현식 평가를 그만 둔다는 것입니다. 예를 들어, 이런 경우에

  1. char *p;
  2. ...
  3. if( (p != 0) && (strlen(p) > 10) ) ...

p가 널 포인터이면 strlen이 호출될 걱정을 할 필요가 없습니다. 왜냐하면, p != 0 부분이 거짓으로 판명된 이상 && 연산자는 바로 거짓을 나타낼 수 있기 때문입니다.

C++ 에서는 &&와 ||도 사용자의 입맛에 맞게 동작하도록 구현할 수 있습니다. 바로 operator&&와 operator|| 함수를 오버로딩하는 것인데, 이 연산자의 오버로딩은 전역 함수로도 할 수 있고 클래스의 멤버 함수로 할 수도 있습니다. && 연산자와 || 연산자를 여러분 입맛에 맞게 바꾸겠다고 생각하신다면 대단한 결심을 한 것입니다. C++ 세상의 이치를 바꾸는 것이나 마찬가지이기 때문입니다. 이 일은 본래 단축평가 의미구조를 함수 호출 의미구조로 대체하는 것이거든요.

함수 호출 의미구조는 단축평가 의미구조와 두 가지 측면에서 근본적으로 다릅니다. 첫째, 함수 호출이 이루어질 때에는 모든 매개변수를 평가해야 하므로, operator&&와 operator|| 함수에 들어가는 매개변수는 우선적으로 평가 과정을 거쳐야 합니다. 쉬운 말로 바꾸면, 단축평가가 이루어지지 않는다는 것입니다. 둘째, C++ 언어 명세서에는 함수 호출에 사용되는 매개변수의 평가를 어떤 순서로 할지에 대해 명확하게 적혀있지 않기 때문에, 사용되는 매개변수 중에 무엇이 먼저 평가되는가를 알 만한 방법이 없습니다. 함수 호출은 일찌감치 단축평가와는 다른 길을 걷고 있는 셈이죠. 반면에, 단축평가는 왼쪽에서 오른쪽으로 표현식 평가를 하도록 정해졌습니다.

쉽표 연산자(,)는 표현식을 꾸미는 용도로 사용됩니다. 아마도 for 루프의 증감식 부분에서 많이 보셨을 것입니다. 쉼표 연산자도 기본 제공 타입에 대해 일정한 규칙을 따라 동작합니다. 쉼표를 가지고 있는 표현식은 쉼표의 왼쪽에 있는 표현식을 먼저 평가하고, 그 다음에 쉼표의 오른쪽에 있는 표현식을 평가 하면서 진행해 갑니다. 그리고 쉼표가 쓰인 전체 표현식의 결과는 오른쪽에 있는 표현식의 값입니다.

  1. for( int i = 0, j = 10 ; i < j ; ++i, --j )
  2. { ... }

앞에서 본 루프의 마지막 부분에 대해, 컴파일러는 ++i를 먼저 평가하고 --j를 그 이후에 평가합니다. 그리고 쉼표 연산자의 결과값은 --j의 값입니다.

 

C++ 문서를 보시면 오버로딩할 수 없는 연산자는 다음의 12가지 입니다.

.                    .*                       ::                     ?:

new               deldete               sizeof               typeid

static_cast      dynamic_cast     const_cast         reinterpret_cast

 

그리고 오버로딩이 가능한 연산자는 다음과 같습니다.

operator new                operator delete            operator new[]            operator delete[]

+         -       *         /         %          ^           &          |         ~

!         =       <         >         +=         -=         *=         /=        %=

^=      &=       |=      <<        >>        >>=      <<=       ==        !=

<=      >=      &&      ||        ++         --          ,          ->*       ->

()       []

 

물론 단순히 오버로딩이 가능하다고 해서 옳다구나 하고 덥석 오버로딩을 해버리면 나쁜 사람입니다.

 

 

8. new 와 delete의 의미를 정확히 구분하고 이해하자.#

다음과 같은 코드를 작성했다고 가정합시다.

  1. string *ps = new string("Memory Management");

여기서 사용된 new는 new 연산자입니다. 이 연산자는 C++ 언어에서 기본적으로 제공하는 것이고, 동작 원리를 바꿀 수 없습니다. new 연산자의 동작은 두 단계입니다. 우선, 요청한 타입의 객체를 담을 수 있는 크기의 메모리를 할당합니다. 그리고 그 객체의 생성자를 호출하여 할당된 메모리에 객체 초기화를 수행합니다. 다시 말하지만, new 연산자가 하는 이 일은 변함이 없습니다. 아무리 마음에 안 들어도 바꿀 수 없습니다.

여러분이 바꿀 수 있는 것이라고는 '객체를 담을 메모리를 할당하는 방법'입니다. new 연산자는 필수적인 메모리 할당을 위해 어떤 함수를 호출하도록 만들어져 있는데, 이 함수를 다시 작성하든지 오버로딩하면 됩니다. 그런데, new 연산자가 호출하는 그 함수의 이름이 바로 operator new 입니다.

operator new 함수는 대개 다음과 같이 선언됩니다.

  1. void * operator new( size_t size );

이 함수의 반환 타입은 void* 입니다. 왜냐하면, 이 함수는 초기화되지 않은 원시 메모리의 포인터를 반환하기 때문이죠. size_t 매개변수는 할당할 메모리의 크기를 지정합니다. operator new를 오버로딩할 때에는 이외의 매개변수를 추가할 수도 있는데, 이럴 때에도 첫째 매개변수는 항상 size_t 타입이어야 합니다.

사실 operator new의 역할은 malloc과 마찬가지로 메모리만 할당하는 것뿐입니다. 그러니, 생성자가 어떤 것인지를 알 턱이 없습니다. operator new는 죽어라 메모리만 할당합니다. 할당된 메모리를 받아 객체 구실을 할 수 있도록 하는 것은 new 연산자의 일입니다. 컴파일러는 다음과 같은 문장을 만나면,

  1. string *ps = new string("Memory Management");

다음 내용과 크게 어긋나지 않은 코드를 만들어 내야 합니다.

  1. void * memory = operator new(sizeof(string));   // string 타입의 객체를 담을 크기의
  2. // 미초기화 메모리를 얻어냅니다.
  3. *memory에 대해                                  // 그 메모리에 값을 세팅하여(생성자대로)
  4. string::string("Memory Management")를 호출한다. // 객체를 초기화합니다.
  5. string *ps = static_cast<string*>(memory);      // ps가 새 객체를 가리키게 합니다.

밑줄로 표시한 생성자 호출 단계는 '일개' 프로그래머인 여러분이 손 댈 수 있는 부분이 아닙니다. 하지만 컴파일러는 원하는 대로 할 수 있죠. 그렇기 때문에 객체를 힙에 할당하는 용도에 여러분이 쓸 수 있는 수단은 new 연산자 뿐인 것입니다.

 

메모리 지정 new ( Placement new )

어디선가 미초기화 메모리가 여러분 손에 놓여 있는 경우, 어떻게든 객체 행세를 하게 만들고 싶을 것입니다. 이럴 때에 사용하는 것이 operator new의 특별판인 메모리 지정 new라고 불리는 함수입니다. 이 함수를 쓰는 예를 다음의 코드와 함께 살펴보도록 합시다.

  1. class Widget {
  2. public :
  3. Widget(int widgetSize);
  4. ...
  5. };
  6. Widget* constructWidgetInBUffer(void *buffer, int widgetSize)
  7. {
  8. return new (buffer) Widget(widgetSize);
  9. }

이 함수는 Widget 객체를 buffer로 지정되는 메모리에 생성한 후에 그 포인터(buffer와 같은 값)를 반환합니다. 이런 류의 함수는 공유 메모리나 메모리-맵 I/O를 사용하는 애플리케이션을 만들 때 꽤 유용하겠지요? 이런 애플리케이션에서는 객체를 특별한 주소 공간이나 별도의 루틴을 통해 할당한 메모리에다가 두어야 하기 때문입니다.

operator new가 바로 메모리 지정 new 입니다. 이 함수의 선언 형태는 다음과 같습니다.

  1. void * operator new( size_t, void *location)
  2. {
  3. return location;
  4. }

메모리 지정 new에 필요한 매개변수는 이 두 개가 전부입니다. 메모리 지정 new의 경우에는 이 함수를 호출하는 쪽에서 메모리의 포인터를 알고 있습니다. 객체의 데이터를 넣어 둘 메모리 주소를 넣어주는 쪽이 호출하는 쪽이니 당연하겠죠? 따라서, 메모리 지정 new는 자신이 넘겨받은 포인터만 반환하면 끝입니다(존재감이 없는 하지만 꼭 넘겨주어야 합니다). size_t 매개변수는 매개변수가 없다고 불평하는 컴파일러의 입을 막는 역할밖에 하지 않습니다. 메모리 지정 new를 사용하는 방법도 간단합니다. #include <new>가 그것입니다.

 

메모리 지정 new에서 한 발짝만 뒤로 물러나서 가만히 팔짱끼고 생각해 보면, new 연산자와 operator new 함수 사이의 관계는 개념상으로는 아주 간단합니다. 어떤 객체를 힙에 생성할 때에는 new 연산자를 써야 합니다. 이 연산자를 쓰면 메모리 할당과 그 객체에 대한 생성자 호출이 동시에 이루어집니다. 반면, 메모리 할당만 하고 싶을 경우에는 operator new 함수를 씁니다. 이때에는 생성자가 호출되진 않습니다. 즉, 만약에 힙 객체의 생성을 도맡는 메모리 할당 매커니즘에 손을 대고 싶다면, operator new를 여러분이 직접 작성한 후에 new 연산자를 호출하면 됩니다. new 연산자가 operator new 를 호출한다는 사실은 변하지 않지만, 실제로 호출되는 operator new는 여러분이 만든 것입니다. 자, 이때 힙에서 직접 메모리를 떼어오지 않고, 여러분이 지정한(이미 가지고 있는) 메모리를 사용하게 하려면 메모리 지정 new를 사용하는 것입니다.

 

객체 삭제와 메모리 해제(deletion & deallocation)

리소스 누수를 막기 위해서는, 할당한 메모리는 반드시 그에 대응되는 메모리 해제를 통해 운영체제에게 돌려주어야 합니다. 기본적으로 제공되는 delete 연산자를 사용하도록 합니다. 이 연산자도 내부적인 함수를 사용합니다. 바로 operator delete란 함수입니다. 이 함수는 다음과 같이 선언되어 있습니다.

  1. void operator delete(void* memoryToBeDeallocated);

따라서, 컴파일러는 다음의 문장

  1. string *ps;
  2. ...
  3. delete ps;

을 보고 다음과 같은 비스무레한 코드를 만듭니다.

  1. ps->~string();               // 그 객체의 소멸자를 호출합니다.
  2. operator delete(ps);         // 그 객체가 자리잡은 메모리를 해제합니다.

여기서 알 수 있는 점은 미초기화 메모리만을 가지고 어떤 일을 할 때에는 new와 delete 연산자를 건너뛰어야 한다는 사실입니다. 그 대신에 메모리를 얻을 때에는 operator new를, 그 메모리를 운영체제에 돌려줄 때에는 operator delete를 호출하라는 것이죠.

  1. void *buffer = operator new(50*sizeof(char));      // 50개의 문자를 담을 수 있는 메모리를 할당합니다.

       // 그리고 생성자는 호출하지 않습니다.

  2. ...
  3. operator delete(buffer);                           // 메모리를 해제합니다.

       // 소멸자를 호출하지 않습니다.

말하자면, C++ 버전의 malloc과 free인 것입니다.

 

배열(Arrays)

지금까지 공부한 내용은 한 번에 하나의 객체만 생성하는 경우만 다루었습니다. 그렇다면 배열을 할당하는 경우는 어떨까요? 이 경우에는 어떤 일이 일어날까요?

  1. string *ps = new string[10];         // 객체의 배열을 할당합니다.

여기에 사용된 new는 여전히 new 연산자이지만, 배열 생성에 사용되는 new 연산자는 단일 객체 생성에 사용되는 그것과 조금 다릅니다. 첫 번째 다른 점은 메모리를 할당할 때 operator new 함수를 호출하지 않는다는 것입니다. 그 대신, operator new의 배열 할당용 버전인 operator new[]를 호출합니다. 이 함수 역시 오버로딩이 가능합니다. 따라서, 여러분이 마음만 먹으면 배열 메모리 할당 동작도 마음대로 조작할 수 있습니다. ( operator new[]는 비교적 최신에 속하기 때문에, 여러분이 쓰고 있는 컴파일러가 지원하지 않을 경우도 있습니다. )

단일 객체 생성용 new 연산자와 객체 배열 생성용 new 연산자가 갖는 두 번째 차이점은 호출하는 생성자의 개수입니다. 배열 생성용 new 연산자는 배열 요소에 대해 일일이 생성자를 호출해야 합니다.

  1. string *ps = new string[10];      // operator new[]를 호출해서 10개의 string 객체에 대한 메모리를 할당합니다.
  2.   // 그리고 나서 배열의 각 요소에 대해 기본 생성자를 호출합니다.

비슷한 이치로, 객체 배열에 대해 delete 연산자가 사용될 때에는, 배열의 각 요소에 대해 생성자를 호출하고 나서 그 배열 메모리 전체를 operator delete[]를 통해 해제합니다.

  1. delete [] ps;                     // 배열의 각 요소에 대해 소멸자를 호출하고 나서, operator delete[]를 써서
  2.   // 배열 전체의 메모리를 해제합니다.

operator delete를 대체하거나 오버로딩할 수 있듯이, operator delete[]도 대체 혹은 오버로딩이 가능합니다. 

 

 

예외#

 

9. 리소스 누수를 피하는 방법의 정공은 소멸자이다.#

강아지나 새끼 고양이들의 집을 구해 주는 단체인 ALA라는 곳에서 날마다 입양 시킨 동물에 관한 정보를 파일로 작성해서 만들어 두는데, 이 파일을 읽어서 각 입양 정보에 대해 적절한 처리를 가하는 프로그램을 만들어야 한다고 가정합시다. 우선 추상 기본 클래스인 ALA를 정의하고, 강아지와 고양이를 나타내는 구체 클래스를 ALA에서 파생시킵니다. 가상 함수인 processAdoption은 동물에 따라 적절한 처리를 가하는 동작을 맡습니다.

  1. class ALA {
  2. public :
  3. virtual void processAdoption() = 0;
  4. ...
  5. };
  6.  
  7. class Puppy : public ALA {
  8. public :
  9. virtual void processAdoption();
  10. ...
  11. };
  12.  
  13. class Kitten : public ALA {
  14. public :
  15. virtual void processAoption();
  16. ...
  17. };

파일에서 정보를 읽어서, 그 파일의 정보에 따라 Puppy 객체나 Kitten 객체를 처리하는 함수가 있습니다.

  1. // s로 부터 동물 정보를 읽은 후에, 적절한 타입의 객체를 동적 할당하여 그것에 대한 포인터를 반환합니다.
  2. ALA * readALA(istream& s);
  3. // 여러분 프로그램의 핵심은 아마도 다음과 같이 동작하는 가상 함수이겠지요?
  4. void processAdoptions( istream& dataSource )
  5. {
  6. while( dataSource ) {                  // 데이터가 있는 한
  7. ALA *pa = readALA( dataSource );   // 그 다음 동물정보를 읽어,
  8. pa->processAdoption();             // 입양을 처리하고
  9. delete pa;                         // readALA가 반환한 객체를 삭제합니다.
  10. }
  11. }

pa->processAdoption이 예외를 발생시키면 어떤 일이 일어날까요? processAdoptions 함수는 예외 처리를 못하도록 되어 있으므로, 발생된 예외는 processAdoptions를 호출한 하뭇에 전파되겠지요. 이때 pa->processAdoption을 호출한 이후의 processAdoptions 코드는 모두 실행되지 않은 채로 지나갑니다. 즉, pa는 절대로 삭제되지 않는다는 뜻입니다. 결과적으로, pa->processAdoption이 예외를 발생시키면 그때가 언제이든지 processAdoptions는 리소스 누수를 일으키는 함수가 됩니다.

processAdoptions 함수의 스택에 생긴 지역 객체에 대해 항상 실행되어야 하는 마무리 코드를 소멸자로 옮기면 어떨까요? 지역 객체는 어떤 요인(함수 복귀 / 예외 발생)이든지 함수가 종료되면 저절로 없어집니다.

문제가 있으면 해답이 있는 법. 해답은 포인터 pa를 포인터처럼 동작하는 객체로 대신하는 것입니다. 이렇게 하면, 포인터처럼 동작하는 객체가 (자동적으로) 소멸될 때, 이 객체의 소멸자에서 delete를 호출하도록 만들 수 있습니다. '포인터처럼 동작하는 객체'는 스마트 포인터라고 불리고 있습니다.

조금만 생각하면 이런 객체는 만들기가 그렇게 어려운 것도 아닙니다만, 만들 필요도 없습니다. 표준 C++ 라이브러리에는 지금 상황에 딱 필요한 것이 auto_ptr이란 이름으로 들어 있습니다. auto_ptr 클래스는 힙 객체에 대한 포인터를 생성자를 통해 받고, 이 객체를 소멸자를 통해 삭제합니다. auto_ptr의 대략적인 구조는 다음과 같이 생겼습니다.

  1. template<class T>
  2. class auto_ptr {
  3. public :
  4. auto_ptr(T *p=0):ptr(p) {}      // 객체에 대한 포인터를 ptr에 저장합니다.
  5. ~auto_ptr() { delete ptr; }     // 객체에 대한 ptr을 삭제합니다.
  6. private :
  7. T *ptr;
  8. };

C++ 원시 포인터 대신에 auto_ptr를 사용하도록 processAdoptions를 고친 결과는 다음과 같을 것입니다.

  1. void processAdoptions( istream& dataSource )
  2. {
  3. while( dataSource ) {
  4. auto_ptr<ALA> pa ( readALA( dataSource ) );
  5. pa->processAdoption();
  6. }
  7. }

새로 고쳐진 processAdoptions은 이전 버전과 비교해서 두 가지가 다릅니다. 첫째, pa가 ALA* 포인터가 아닌 auto_ptr<ALA> 객체로 선언되었습니다. 둘째, 루프의 끝에 있었던 delete 문이 없어졌습니다. 그렇습니다. 이것만 제외하면 나머지는 똑같습니다. 왜냐, 객체 소멸에 관한 부분만 제외하면 auto_ptr 객체는 보통의 포인터처럼 동작하니까요.

이번 항목에서 공부한 "동적 할당 리소소는 객체로 포장하라"는 규칙을 뼛속 깊이 묻어 놓으면, 예외 발생으로 인해 리소스가 새는 일은 방지할 수 있을 것입니다.

 

 

10. 생성자에서는 리소스 누수가 일어나지 않게 하자.#

C++는 생성 과정이 완료된 객체만을 안전하게 소멸시킵니다. 그런데, 생성자가 실행을 마치기 전에는 생성 작업이 완료되었다고 간주하지 않습니다. 따라서, 다음과 같이 어떤 BookEntry 객체 b가 지역 객체로 생성되고,

  1. void testBookEntryClass()
  2. {
  3. BookEntry b("Addison", "One Jacob way, Reading, MA 01867");
  4. ...
  5. }

b가 생성되는 도중에 예외가 발생되었다고 하면, b의 소멸자는 불리지 않습니다.

 

C++는 생성 도중에 예외를 일으킨 객체에 대해서는 마무리 작업을 해 주지 않기 때문에, 이런 작업이 필요하다면 여러분이 직접 생성자를 설계해야 합니다. 간단한 방법 중 하나는, 가능한 모든 예외를 받고(catch) 마무리 코드를 실행한 다음, 받은 예외를 다시 발생시켜 이것이 전파되도록 하는 것입니다. 이 전략을 BookEntry 생성자에 그대로 구현하면 다음과 같습니다.

  1. BookEntry::BookEntry(const string& name, const string& address,
  2. const string& imageFileName, const string& audioClipFileName)
  3. :theName(name), theAddress(address), theImage(0), theAudioClip(0)
  4. {
  5. try {
  6. if(imageFileName != "") {
  7. theImage = new Image(imageFileName);
  8. }
  9. if(audioClipFileName != "") {
  10. theAudioClip = new AudioClip(audioClipFileName);
  11. }
  12. }
  13. catch(...) {            // 모든 예외를 받습니다.
  14. cleanup();          // 필요한 마무리 동작을 취합니다.
  15. throw;              // 받은 예외를 전파합니다.
  16. }
  17. }
  18. void BookEntry::cleanup()
  19. {
  20. delete theImage;
  21. delete theAudioClip;
  22. }
  23. BookEntry::~BookEntry()
  24. {  
  25. cleanup();
  26. }

 

이보다 더 좋은 해결책도 있습니다. 항목 9에서 제사한 방버을 따라, theImage와 theAudioClip이 가리키는 객체를 지역 객체로 관리하는 리소스로 취급하는 것입니다. 사실, theImage와 theAudioClip은 동적 할당된 객체의 포인터이고, 이 포인터가 유효범위를 떠나면 객체도 삭제되야 하기 때문에 항목 9의 방법도 매우 적당합니다. auto_ptr 클래스가 설계된 목적과 쓰임새와도 부함되고요. 그러므로, theImage와 theAudioClip의 원시 포인터 타입을 auto_ptr 버전으로 바꾸어도 큰 문제가 없을 것입니다.

  1. class BookEntry {
  2. public:
  3. ...
  4. private:
  5. ...
  6. const auto_ptr<Image> theImage;
  7. const auto_ptr<AudioClip> theAudioClip;
  8. };

이렇게 하면, BookEntry의 생성자는 예외가 발생하더라도 메모리 누수를 일으키지 않게 되고, theImage와 theAudioClip을 멤버 초기화 리스트를 통해 초기화할 수 있게 됩니다.

  1. BookEntry::BookEntry( const string& name, const string& address,
  2. const string& imageFileName, const string& audioClipFileName )
  3. :theName(name), theAddress(address),
  4. theImage(imageFileName != "" ? new Image(imageFileName) : 0),
  5. theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName) : 0)
  6. {}

이렇게 설계한 클래스에서는, theAudioClip을 초기화하는 도중에 예외가 발생하더라도 theImage는 이미 생성 과정이 완료된 객체이기 때문에, theName이나 theAddress가 자동적으로 소멸됩니다. 게다가, theImage와 theAudioClip은 이제 엄연한 '객체'이기 때문에, 이것들을 가지고 있는 BookEntry 객체가 소멸될 때 자동으로 소멸됩니다. 따라서, 이 객체들이 가리키고 있는 리소스를 손으로 삭제해 줄 필요가 없어집니다.

 

포인터 클래스 멤버를 auto_ptr 버전으로 바꾸면, 생성자는 실행도중에 예외가 발생하더라도 리소스 누수를 일으키지 않게 되고, 소멸자에서 여러분이 직접 리소스를 해제하지 않아도 되는 데다가, 상수 멤버 포인터도 비상수 포인터와 똑같이 깔끔하게 처리할 수 있으니 1석 3조입니다.

 

 

11. 소멸자에서는 예외가 탈출하지 못하게 하자.#

 

 

12. 예외 발생이 매개변수 전달 혹은 가상 함수 호출과 어떻게 다른지를 이해하자.#

 

 

13. 발생한 예외는 참조자로 받아내자.#

 

 

14. 예외 지정 기능은 냉철하게 사용하자.#

 

 

15. 예외 처리에 드는 비용에 대해 정확히 파악하자.#

 

 

효율#

 

16. 뼛속까지 잊지 말자, 80-20 법칙!#

80-20 법칙이란 프로그램의 전체 수행 성능이 일부의 코드에 의해 좌지우지되는 된다는 말이다. 80-20 법칙의 진짜 의미는 '아무 곳이나 골라잡고 효율을 향상시키려고 애쓰는 것은 별 도움이 안 된다'는 것 입니다. 성능 개선의 정도(正道)는 여러분의 가슴을 턱턱 막히게 하는 20%의 코드를 경험적으로 판별하는 것입니다. 그리고 지긋지긋한 20%를 판별하는 방법은 프로그램 프로파일러를 사용하는 것입니다. 하지만 아무 프로파일러나 다 쓴다고 해서 되는 것은 아니고, 여러분이 관심을 두고 있는 리소스를 직접 측정해 주는 도구가 필요합니다.

프로그램 프로파일러 : 실행되는 프로그램의 진행 단계별, 호출 함수별 경과 시간 및 소요 메모리, 리소스의 양 등을 도식화해 주는 프로그램. 이것을 사용하면 어떤 함수에서 시간이 오래 걸리는지 쉽게 찾을 수 있다. 많이 쓰이는 프로파일러로는 볼랜드사의 볼랜드 프로파일러, 마이크로소프트 비주얼 스튜디오에 번들된 Visual Studio Analyzer등이 있다.

 

 

17. 효율 향상에 있어 지연 평가는 충분히 고려해 볼 만하다.#

지연 평가를 사용해서 만든 C++ 클래스는 어떤 처리 결과가 진짜로 필요해질 때까지 그 처리를 미룹니다. 이 방법은 어떤 컴퓨팅 작업을 수행하는 데 있어서 그 작업 결과가 진짜로 요구되기 전에는 그것을 하지 않는 것입니다. 예를 들어, 다음의 코드를 한 번 봅시다.

  1. class String { ... };
  2. String s1 = "Hello";
  3. String s2 = s1;         // String의 복사 생성자를 호출합니다.

String 복사 생성자가 보통의 상식대로 만들어졌다면, s2가 s1으로 초기화된 후에 s1가 s2가 "Hello"란 데이터 사본을 동시에 가지고 있게 되겠지요. 이런 복사 생성자의 실행 비용은 만만치 않게 큽니다. 왜냐하면, s1의 값과 동일한 데이터를 s2도 갖게 하기 위해 new 연산자를 통한 힙 메모리 할당이 이루어 질 것이 뻔하고, strcpy를 사용해서 s1에 있는 데이터를 s2에서 할당한 힙 메모리로 복사할 것이 자명하기 때문이지요. 이렇게 String 복사 생성자가 호출되자마자 메모리 할당, 데이터 복사 등을 처리해 버리는 것을 가리켜 즉시 평가라고 합니다. 하지만 앞의 경우에는 s2가 굳이 자신의 메모리를 가질 필요가 없습니다. s2는 아직 어디에서 사용되지도 않았기 때문입니다.

앞의 코드에 지연 방법을 적용하면 작업량이 줄어듭니다. s2에 s1의 사본을 주는 대신에 s2가 s1의 값을 공유하도록 합니다. 이때 우리가 해 줄 일은 "어떤 것이 무엇을 공유하고 있는가"에 대한 간단한 정보 유지뿐입니다. 이렇게 하면 new 연산자와 strcpy를 호출하는 비용을 절약할 수 있습니다. s1과 s2가 데이터를 공유하고 있다는 사실은 사용자에게 별 문제가 되지 않으며, 사용자는 다음과 같은 코드를 거리낌 없이 쓸 수 있습니다. 값을 읽기만 하고 쓰지는 않기 때문입니다.

  1. cout << s1;            // s1의 값을 읽습니다.
  2. cout << s1 + s2;       // s1과 s2의 값을 읽습니다.

사실, 값을 공유하는 방법이 조금 부담스러워지는 경우는 한쪽의 문자열값이 수정될 때입니다. 그렇다고 해서 두 문자열을 모두 바꿀 필요는 없고, 하나만 바꾸면 됩니다. 다음의 문장에서,

  1. s2.convertToUpperCase();

s2의 값만 바꾸면 됩니다. s1의 값은 바꿀 필요가 없습니다.

이런 문장을 처리하려면, String의 convertToUpperCase를 구현할 때 s2의 값이 수정되기 전에 s2의 데이터 사본을 만들어 놓고 이 값을 s2만의 것으로 세팅하도록 코딩하면 됩니다. convertToUpperCase 함수에서는 더 이상 작업을 지연시킬 수 없습니다. 한편, s2가 수정되지 않는다면 언제까지라도 s2만의 데이터 사본을 만들어 둘 필요가 없겠지요. 운이 좋으면 문자열값을 공유하고 있는 상태의 s2를 끝까지 계속 사용할 수도 있습니다.

 

지연 평가 방법은 여러 가지 방면에 유용합니다. 불필요한 객체 복사를 피한다든지, operator[]를 사용하는 읽기와 쓰기를 구분한다든지, 데이터베이스에서 필요한 데이터 필드만 읽어 온다든지, 불필요한 수치 계산을 피하는데에 지연 평가를 쓸 수 있습니다. 그렇지만, 지연 평가가 만병 통치약은 아닙니다. 모든 계산 결과를 즉시에 사용해야 하는 경우에는 지연 평가로 얻을 수 있는 것이 없습니다. 심지어 빠른 처리에 걸림돌이 되거나 메모리 사용량을 늘리기도 합니다. 왜냐하면, 여러분이 피하려고 했던 계산을 모두 해야 할 뿐만 아니라, 지연 평가에 쓰려고 만들어 둔 자료구조마저도 조작해야 하기 때문입니다. 계산을 바로 하지 않아도 되는 경우가 잦은 소프트웨어를 만들 때에 지연 평가의 효과를 톡톡히 볼 수 있습니다.

 

 

18. 예상되는 계산 결과를 미리 준비하면 처리비용을 깎을 수 있다.#

현재 요구된 것 이외에 더 많은 작업을 미리 해둠으로써 소프트웨어의 성능을 향상시킬 수 있다. 이런 스타일의 작업을 과도 선행 평가라고 부릅니다. 시키지도 않은 일을 미리 한다는 뜻이죠.

예를 하나 들어보겠습니다. 다음의 템플릿은 수치데이터의 콜렉션을 관리하고 처리하는 클래스의 템플릿입니다.

  1. template<class NumericalType>
  2. class DataCollection {
  3. public :
  4. NumericalType min() const;
  5. NumericalType max() const;
  6. NumericalType avg() const;
  7. ...
  8. };

min, max, avg는 콜렉션에 속해 있는 수치 데이터들에 대한 최소값, 최대값, 평균값을 반환하는 함수입니다. 그렇게 생각하면, 이 함수들을 구현하는 방법은 세 가지입니다. 첫째는 즉시 평가 방법인데 min, max, avg가 호출될 때 콜렉션 안의 모든 데이터를 점검해서 원하는 값을 반환하게 합니다. 둘째는 지연 평가 방법입니다. 그냥 내부 자료구조를 반환하게 해서 그 함수의 반환값이 실제로 사용될 때에 거기서 원하는 값을 찾도록 하는 것이죠. 마지막으로, 이번 항목의 주제인 과도 선행 평가 방법으로는 이렇게 구현합니다. 콜렉션 데이터들에 대한 최소값, 최대값, 평균값을 미리 구해 두었다가 min, max, avg의 함수 호출이 떨어질 때 그 값을 바로 반환하게 합니다. 따라서 함수에서는 계산이 전혀 필요없게 됩니다. min, max, avg가 꽤 자주 호출되는 환경에서는 과도선행 평가 방법이 콜렉션의 최소, 최대, 평균값을 구하는데 필요한 비용을 절감할 수 있기 때문에, 함수 호출을 한 번 할 때마다 드는 비용이 즉시 평가나 지연 평가 방법보다 더 낮아집니다.

 

과도선행 평가 방법의 기본 아이디어는, 상당히 자주 요구될 것 같은 계산이 있다면, 그 요구를 효율적으로 처리할 수 있는 자료구조를 설계하여 비용을 낮추자는 것입니다.

가장 간단하게 구현할 수 있는 방법 중 하나는, 이미 계산이 끝났고 또다시 사용될 것 같은 값을 캐싱(caching)하는 것입니다. 캐싱 말고 미리가져오기(Prefetching)란 방법도 있습니다. 이 방법은 예를 들어 컴퓨터에서 디스크 컨트롤러는 디스크에서 데이터를 읽을 때, 프로그램 쪽에서 아주 적은 양만 요구했음에도 불구하고 블록 하나 혹은 섹터 하나를 왕창 읽습니다. 왜냐하면 조금씩 여러 번 읽는 것보다 한 번에 많이 읽는 쪽이 빠르기 때문입니다. 게다가, 많은 엔지니어의 경험에 따르면 인접한 데이터들이 연달아 읽히는 경우가 많습니다. 이것을 가리켜 참조 위치의 근접성이라고 합니다.

 

이번 항목의 줄거리를 잘 훑어보면 어떤 똑같은 주제를 가지고 여러 번 언급했음을 알 수 있는데, 그건 바로 "메모리를 많이 쓰면 속도가 빨라진다"입니다. 어떤 숫자들의 최소, 최대, 평균값을 수시로 유지하려면 추가적인 공간이 필요하지만, 시간은 절약됩니다. 계산 결과를 캐싱하려면 메모리 사용량이 필연적으로 높아지지만 캐시에 넣어 둔 계산값을 재생성하는 데에는 시간이 들지 않습니다. 미리가져오기 방법을 쓰려면 미리 가져온 명령어나 데이터를 저장해 둘 공간이 필요하지만, 저장해 놓은 데이터나 명령어를 접근하는 데에 필요한 시간은 줄어듭니다. 공간과 시간은 함께 절약하기 힘듭니다.

과도선행 평가는 "어떤 작업의 결과가 거의 항상 사용되거나 한 번 이상 자주 사용된다면 그 작업을 미리 해둠으로써 프로그램의 효율을 높이는" 방법입니다.

 

 

19. 임시 객체의 원류를 정확히 이해하자.#

실행 도중에 잠깐만 사용되는 변수. 프로그래머들은 이것을 가리켜 "임시 객체"라고 부릅니다.

C++에서의 진짜 '임시 객체'는 우리 눈에 보이지 않습니다. 소스 코드에도 없습니다. C++에서의 진짜 임시 객체는 힙 이외의 공간에 생성되는 '이름 없는' 객체입니다. 이런 이름 없는 객체가 만들어지는 상황은 두 가지입니다. 첫째는 함수 호출을 성사시키기 위해 암시적 타입변환이 적용될 때이고, 둘째는 함수가 객체를 값으로 반환할 때입니다. 임시 객체가 생성되고 소멸되는데 드는 비용이 프로그램의 전체 성능에 미치는 영향이 꽤 쏠쏠한 수준입니다.

 

우선, 함수 호출을 성사시키기 위해 임시 객체가 생성되는 첫째 경우를 생각해 보죠. 함수에 전달되는 객체의 타입과 바인딩되어야 하는(원래의 함수 선언에 들어 있었던) 매개변수의 타입이 다를 때 이런 일이 일어납니다. 예를 들겠습니다. 다음의 소스 코드는 문자열을 나타내는 string 객체에 들어 있는, 특정한 문자의 출현 회수를 세어주는 함수와 그것을 사용한 예입니다.

  1. // str 안에 들어 있는 ch의 출현회수를 반환합니다.
  2. size_t countChar(const string& str, char ch);
  3.  
  4. char buffer[MAX_STRING_LEN];
  5. char c;
  6.  
  7. // 문자와 문자열을 읽습니다. 문자열을 읽을 때 버퍼가 넘치는 것을 막기 위해 setw를 사용했습니다.
  8. cin >> c >> setw(MAX_STRING_LEN) >> buffer;
  9. cout << countChar(buffer, c) << endl;

countChar를 호출한 부분을 똑바로 보세요. 이 함수에 넘겨진 첫 번째 인자는 char 타입의 배열입니다. 하지만 여기에 대한 함수 매개변수는 const string& 타입의 string 객체 참조자 입니다. 이 함수 호출이 성사되려면 타입 불일치가 해결되어야 하기 때문에, 컴파일러는 아주 친절하게도 string 타입의 임시 객체를 만들어 줍니다. 이 임시 객체는 buffer를 생성자 인자로 사용하여 호출되는 string 생성자를 통해 초기화됩니다. 이제, countChar의 str 매개변수는 이렇게 만들어진 임시 string 객체와 바인딩됩니다. 이 임시 객체는 countChar가 복귀할 때 자동으로 소멸됩니다. 효율의 관점에서 볼 때 임시 string 객체가 갑자기 생성되었다가 소멸되는 일은 불필요한 낭비입니다. 이런 일을 막는 일반적인 방법이 두 개 있습니다. 하나는 코드를 다시 설계해서 이런 변환이 일어나지 않게 하는 것인데, 이 방법은 이미 항목 5에서 다루었습니다. 또 하나는 타입변환이 불필요하도록 소프트웨어를 수정하는 것인데, 여기에 대해서는 항목 21에 따로 두었습니다.

한 가지 기억해 둘 점은, 이런 암시적 타입변환이 이루어지는 때는 오직 객체가 값으로 전달될 때 혹은 상수 객체 참조자 타입의 매개변수로 객체가 전달될 때라는 점입니다. 즉, 비상수 객체 참조자 타입의 매개변수로 객체가 전달될 때에는 암시적 타입변환이 일어나지 않는다는 것이죠.

 

임시 객체가 생성되는 두 번째 경우는 함수가 객체를 (값으로) 반환할 때라고 했습니다. 예를 들어, operator+는 두 피연산자의 합을 나타내는 객체를 반환해야 합니다. Number라는 타입의 객체에 대해 덧셈을 수행하는 operator+가 있다고 하면 다음과 비슷할 것입니다.

  1. const Number operator+(const Number& lhs, const Number& rhs);

이 함수의 반환값은 임시 객체입니다. 왜냐하면, 이 객체는 이름이 없기 때문입니다. 그냥 함수의 반환값일 뿐이죠. 임시 객체를 반환하는 이상, operator+를 호출할 때마다 이 객체는 만들어졌다가 없어지는 일을 반복할 수밖에 없습니다.( 반환값이 상수(const)인 이유는 항목 6에서 확인 )

보통, 이런 식의 객체 생성과 소멸은 여러분에겐 그리 달갑지 않은 비용입니다. operator+와 같은 특수한 함수는 비슷한 함수인 operator+=을 통해 비용 부담을 없앨 수 있습니다. 자세한 내용은 항목 22에서 참고하세요.

 

어쨌든 기본 아이디어는 임시 객체에는 적지 않은 비용이 든다는 것입니다. 할 수 있으면 임시 객체는 만들어지지 않도록 하는 것이 좋습니다.

 

 

20. 반환값 최적화가 가능하게 하자.#

객체를 값으로 반환하는 함수는 효율에 절망적입니다. 왜냐하면 보이지 않는 임시 객체의 생성 및 소멸과 함께 값에 의한 반환이 이루어 질 뿐만 아니라, 없앨 수도 없기 때문입니다. 문제는 단순합니다. 어떤 함수가 있는데, 원하는 동작을 위해 객체를 반환할 수도 있고, 안 할 수도 있습니다. 하지만 객체를 반환하면, 반환되는 객체를 제거할 방법이 없습니다.

유리수에 대해 곱셈을 해 주는 operator* 함수가 있다고 가정합시다.

  1. class Rational {
  2. public :
  3. Rational(int numerator = 0, int denominator = 1);
  4. ...
  5. int numerator() const;
  6. int deniminator() const;
  7. };
  8. const Rational operator*(const Rational& lhs, const Rational& rhs);

operator*의 코드를 전혀 보지 않더라도, 이 함수는 객체를 반환하지 않으면 절대로 안 될 것 같습니다. 왜냐하면, 이 함수는 아무 유리수나 두 개를 받아서 곱셈 결과를 내주도록 되어 있기 때문입니다. 이 두 개의 유리수는 어떤 수일지 누구도 알 수 없습니다. 여기서, operator*는 어떻게 해야 이 두 유리수의 곱을 담아 둘 객체를 생성하지 않아도 될까요? 다시 말씀드리지만 불가능합니다.

 

그렇다면 문제는, 값으로 객체를 반환하면서도 임시 객체의 비용이 들지 않는 함수를 작성하는 것일 텐데, 어떻게 잘만 작성하면 컴파일러는 실제로 임시 객체의 비용을 없애줍니다. 그 방법이 바로 객체 대신에 생성자 인자를 반환하는 것입니다. 다음의 코드를 봐 주세요.

  1. // 객체를 반환하는 함수를 효율적이면서도 정확한 방법
  2. const Rational operator*(const Rational& lhs, const Rational& rhs)
  3. {
  4. return Rational( lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator() );
  5. }

반환되는 표현식 부분을 자세히 보시기 바랍니다. Rational의 생성자가 호출되고 있죠? 실제로 그렇습니다. 이 표현식을 써서 임시 Rational 객체를 만든 것입니다. 그리고, 이 함수가 반환값을 준비하기 위해 복사하는 대상이 임시 객체입니다.

 

지역 객체 대신에 생성자 인자를 반환하는 이 코드는 임시 객체가 생성되었다가 소멸되기 때문에 그만큼의 비용이 들게 됩니다. 하지만 얻는 것은 분명히 있습니다. 이런 식으로 반환되는 임시 객체에 대해서는 컴파일러가 최적화를 해 줍니다. 즉, 다음과 같은 경우에 operator*를 호출하면,

  1. Rational a = 10;
  2. Rational b(1,2);
  3. Rational c = a * b;      // operator*가 여기서 호출됩니다.

컴파일러는 operator* 안에서 생기는 임시 객체와 operator*가 반환하는 임시 객체를 모두 없애고, 계산 결과값을 객체 c에 대해 할당된 메모리에다가 직접 넣어 초기화해 줍니다. 대부분의 컴파일러가 이런 기능을 가지고 있는데, 이렇게 되면 operator*를 호출했을 때 소모되는 임시 객체의 총 비용은 제로가 됩니다. 즉 임시 객체가 전혀 만들어지지 않고, c 객체에 대한 생성자 호출에 필요한 비용만 들어갑니다.

 

 

21. 오버로딩은 불필요한 암시적 타입변환을 막는 한 방법이다.#

특별히 주목받을 만한 부분이 보이지 않는 코드 몇 줄입니다.

  1. class UPInt {   // Unlimited Precision Integer를 나타내기 위한 클래스
  2. public :
  3. UPInt();
  4. UPInt(int value);
  5. ...
  6. };
  7. const UPInt operator+(const UPInt& lhs, const UPInt& rhs);

다음의 문장을 봐 주세요.

  1. UPInt upi1, upi2;
  2. UPInt upi3 = upi1 + 10;
  3. upi3 = 10 + upi2;

물론, 실행이 안 되는 것은 아닙니다. 정수 10을 UPInt 타입으로 바꾸기 위해 임시 객체를 생성했고, 이것을 써서 덧셈을 마쳤습니다. 임시 객체의 생성은 우리가 원하지 않는 비용의 낭비를 초래하지요.

어떻게 하면 UPInt와 int 타입의 인자에 대해 operator+를 호출할 수 있을까요? 타입이 섞여서 들어오더라도 바로 operator+를 호출할 수 있는 방법이 있습니다. 타입변환이 처음부터 필요 없도록 만드는 것입니다. 가령 UPInt와 int 객체를 더한다고 하면, 이 두 타입에 대해 호출되는 operator+도 만들자는 것이죠. 함수 몇 개를 더 오버로딩하여 선언하면 간단히 해결됩니다.

  1. const UPInt operator+(const UPInt& lhs, const UPInt& rhs);   // UPInt와 UPInt를 더합니다.
  2. const UPInt operator+(const UPInt& lhs, int rhs);            // UPInt와 int를 더합니다.
  3. const UPInt operator+(int lhs, const UPInt& rhs);            // int와 UPInt를 더합니다.
  4. UPInt upi1, upi2;
  5. ...
  6. UPInt up3 = upi1 + upi2;   // 좋습니다. upi1 혹은 upi2에 대해 임시 객체가 만들어지지 않습니다.
  7. upi3 = upi1 + 10;          // 좋습니다. upi2 혹은 10에 대해 임시 객체가 만들어지지 않습니다.
  8. upi3 = 10 + upi2;          // 좋습니다. 10 혹은 upi1에 대해 임시 객체가 만들어지지 않습니다.

 

단, 타입변환은 내게 맡겨라! 하면서 아무 생각 없이 신나게 오버로딩하다 보면, 다음과 같은 함수를 선언하게 되는 주화입마에 빠질 것입니다.

  1. const UPInt operator+(int lhs, int rhs);         // 에러!

물론 논리적으로는 맞습니다. 하지만 그건 하찮은 인간의 생각이고, C++ 게임에서는 C++ 세계에서 정해 둔 규칙이 있습니다. 그 규칙 중 하나가 '오버로딩되는 연산자 함수는 반드시 최소한 한 개의 사용자 정의 타입을 매개변수로 가져야 한다'라는 것입니다. int는 사용자 정의 타입이 아니기 때문에, 이 타입의 인자만 취하도록 오버로딩할 수가 없습니다.

 

 

22. 단독 연산자(op) 대신에 =이 붙은 연사자(op=)를 사용하는 것이 좋을 때가 있다.#

연산자의 대입 형태(이를테면 operator+=)와 단독 형태(이를테면 operator+) 사이에 C 언어의 +,+=과 같은 자연스런 관계를 지어 두는 괜찮은 방법은, 대입 형태를 사용해서 단독 형태를 구현하는 것입니다.

  1. class Rational {
  2. public :
  3. ...
  4. Rational& operator+=(const Rational& rhs);
  5. Rational& operator-=(const Rational& rhs);
  6. };
  7. // operator+=를 사용해서 구현한 operator+입니다.
  8. const Rational operator+(const Rational& lhs, const Rational& rhs)
  9. {
  10. return Rational(lhs) += rhs;
  11. }
  12. // operator-=를 사용해서 구현한 operator-
  13. const Rational operator-(const Rational& lhs, const Rational& rhs)
  14. {
  15. return Rational(lhs) -= rhs;
  16. }

이 예제 코드를 보시면, operator+= 그리고 -=은 다른 함수를 사용하지 않고 바닥부터 만들어졌고, operator+와 operator-에서 호출되고 있음을 알 수 있습니다. 이렇게 설계해 두면 대입 형태의 연산자만 신경쓰는 것으로 코드의 유지보수가 끝납니다.

 

단독 형태의 연산자를 모두 전역 유효범위에 두어도 크게 신경쓰지 않는 프로그래머라면, 다음과 같이 템플릿을 써서 클래스 안에다 단독 형태의 연산자 함수를 구현할 필요 자체를 없앨 수도 있습니다.

  1. template<class T>
  2. const T operator+(const T& lhs, const T& rhs)
  3. {
  4. return T(lhs) += rhs;
  5. }
  6. template<class T>
  7. const T operator-(const T& lhs, const T& rhs)
  8. {
  9. return T(lhs) -= rhs;
  10. }

이 템플릿 타입 T에 대한 대입 형태 연산자만 준비되어 있으면, 여기에 대응되는 단독 형태 연산자는 자동으로 만들어집니다.

 

여기서 체크할 수 있는 효율에 관한 포인트는 세 개입니다. 첫째는, 일반적으로 대입 형태 연산자는 단독 형태 연산자보다 효율적이란 사실입니다. 왜냐하면, 으레 단독 형태 연산자는 새 객체를 반환해야 하기 때문에, 임시 객체를 생성하고 소멸하는 비용이 소모되지만, 대입 형태 연산자는 왼쪽 인자(=의 왼쪽 피연산자)에다가 처리결과를 기록하기 때문에, 이 연산자의 반환값을 담을 임시 객체를 만들어 놓을 필요가 없기 때문입니다.

두 번째 포인트는 대입 형태 연산자와 단독 형태 연산자를 동시에 제공함으로써 클래스 사용자에게 "효율과 편리성"을 경우에 따라 저울질할 기회를 줄 수 있다는 것입니다. 즉, 여러분의 클래스를 쓰는 고마운 사용자가 다음과 같이 코드를 쓸 것인가,

  1. Rational a, b, c, d, result;
  2. ...
  3. result = a + b + c+ d;   // 세 개의 임시 객체가 사용되는데 임시 객체 하나마다 operator+가 호출됩니다.

아니면 다음과 같이 코드를 쓸 것인가를 판단할 수 있다는 것입니다.

  1. result = a;         // 임시 객체가 필요없습니다.
  2. result += b;        // 임시 객체가 필요없습니다.
  3. result += c;        // 임시 객체가 필요없습니다.
  4. result += d;        // 임시 객체가 필요없습니다.

+를 연달아 쓴 첫째 코드는 작성하기 좋고, 디버깅하거나 유지보수하기에도 쉽습니다. 그리고 실행 시간의 80%를 잡아먹는 문제의 코드입니다. +를 따로 쓴 둘째 코드는 훨씬 효율적이고, 어셈블리 언어 프로그래머의 눈에는 무척 읽기 좋습니다.

효율에 관한 셋째이자 마지막 포인트는, 단독 형태 연산자를 구현하는 문제입니다. operator+를 구현한 코드를 다시 봐 주시기 바랍니다.

  1. template<class T>
  2. const T operator+(const T& lhs, const T& rhs)
  3. {   return T(lhs) + rhs;  }

 T(lhs) 부분은 T의 복사 생성자를 호출하는 표현식입니다. 이 표현식은 임시 T 객체를 만들면서 그 객체의 값을 lhs와 똑같이 만듭니다. 그리고 나서 이 임시 객체와 rhs를 사용해서 operator+=을 호출합니다. 마지막으로, 이 연산자를 호출한 결과가 operator+에서 반환됩니다(어디까지나 이것은 최소한 '이렇게 될것 같다'고 예상하는 것입니다. 안타깝게도 어떤 컴파일러는 T(lhs)를 lhs의 상수성을 제거한 캐스팅 결과로 취급해서, rhs를 lhs에 더하고 값이 바뀐 lhs의 참조자를 반환해 버리고 맙니다!). 이 코드에서는 임시 객체 하나를 만드는 비용이 드는데, 이름 없는 객체가 이름 있는 객체보다 제거하기 쉽습니다. 임시 객체는 최소한 이름 있는 객체보다 비용이 더 들지 않을 것이고, 구버전의 컴파일러를 쓰는 경우엔 비용이 확실히 덜 듭니다.

 

 

23. 정 안 되면 다른 라이브러리를 사용하자!#

비슷한 기능을 가진 라이브러리라도 (어떤 철학을 가지고 설계했느냐에 따라) 수행 성능에서 차이를 보일 수 있기 때문에, 여러분이 만든 소프트웨어에서 용서할 수 없는 병목현상이 발견된다면, 라이브러리만 바꾸어도 병목현상이 해결될 가능성이 있음을 잊지 마세요. 이를테면 입.출력에 병목 현상이 있으면 iostream 대신에 stdio를 쓸 수 있겠죠. 라이브러리마다 효율, 확장성, 이식성, 타입 안정성 등에 대하여 다른 설계 철학을 가지고 구현되었기 때문에, 다른 사항보다 수행 성능 쪽에 많은 무게를 두어 설계한 라이브러리를 구할 수 있으면, 그것을 바꾸는 것만으로 손쉽게 소프트웨어의 효율을 향상시킬 수 있습니다.

 

24. 가상 함수, 다중 상속, 가상 기본 클래스, RTTI에 들어가는 비용을 제대로 파악하자.#

구현에 따라 성능에 영향을 미치는 C++ 언어의 특징들 중에 부동의 일등자리를 차지하는 것은 바로 가상 함수입니다.

가상 함수가 호출될 때, 그 함수에 대해 실행되는 코드는 함수가 호출되는 객체의 동적 타입에 맞는 것이어야 합니다. 즉, 소스 파일에 쓰여진 객체의 포인터나 참조자는 하찮은 존재일 뿐입니다. 그렇다면, 컴파일러는 어떻게 이 동작을 효율적으로 할 수 있을까요? 대부분의 컴파일러는 가상 테이블과 가상 테이블 포인터를 사용하여 이것을 구현합니다. 가상 테이블은 vtbl, 가상 테이블 포인터는 vptr로 표기합니다.

vtbl은 보통 함수 포인터의 배열입니다(어떤 컴파일러는 배열 대신에 링크드 리스트 형태를 쓰기도 하는데, 어쨌든 근본적인 전략은 같습니다). 이 테이블은 가상 함수를 선언했거나 상속받은 클래스(클래스 인스턴스가 아니라 클래스)에 무조건 생기고, vtbl의 각 요소는 해당 클래스에 정의한 가상 함수 코드의 시작 주소(즉, 함수 포인터)입니다.

  1. class C1 {
  2. public :
  3. C1();
  4.  
  5. virtual ~C1();
  6. virtual void f1();
  7. virtual int f2(char c) const;
  8. virtual void f3(const string& s);
  9.  
  10. void f4() const;
  11. ...
  12. };

이 상태에서 C1의 가상 함수 테이블 배열은 다음과 같이 되어 있습니다.

efc++.bmp 

여기서 가상 함수가 아닌 f4는 테이블에 없습니다. C1의 생성자도 그렇고요. 비가상 함수( 원래부터 virtual 키워드를 붙일 수 없는 생성자를 비롯해서)는 보통의 C 함수처럼 구현되기 때문에, 이 함수에 대해서는 특별한 수행 성능 문제가 끼어들거나 하진 않습니다.

 

이때 C1으로부터 C2가 상속을 받았다고 합시다. C2는 자신이 상속받은 가상 함수 몇 개를 재정의했고, C2만의 함수를 몇 개 더 가지고 있습니다.

  1. class C2: public C1 {
  2. public :
  3. C2();                         // 비가상 함수
  4. virtual ~C2();                // 재정의한 가상 함수
  5. virtual void f1();            // 재정의한 가상 함수
  6. virtual void f5(char *str);   // 새로 추가한 가상 함수
  7. ...
  8. };

이 클래스의 가상 테이블 엔트리는 C2의 객체에 대해 실행될 함수를 가리키고 있겠지요. 물론, C2에서 재정의하지 않은 C1의 가상 함수도 이 테이블의 엔트리로 들어 있습니다. 그림을 봐 주세요.

efc++2.bmp 

가상 함수에 들어가는 비용에 대한 이야기는 바로 여기서부터 시작됩니다. 가상 함수를 운용하는데 필요한 가상 테이블을 담는 메모리가 필요하지 않겠습니까? 가상 테이블은 클래스에 딱 하나만 생기기 때문에, 가상 테이블에 들어가는 메모리의 총량은 사실 보잘 것 없죠. 하지만 소프트웨어에 들어가는 클래스의 개수가 막대하거나 각 클래스의 가상 함수의 개수가 많을 때에는, 결코 무시 못 할 정도의 메모리 부담이 들어갑니다.

 

한 클래스의 vtbl은 프로그램 이미지 안에 딱 하나만 있어야 하기 때문에, 컴파일러는 이것으로 발생하는 골치 아픈 문제를 해결해야 합니다. 즉, "대체 어디에 둘거냐?"하는 것이죠. 여기엔 몇 가지 방법이 있는데, 대체적으로 컴파일러 제작자는 두 가지 방법 중 하나를 쓴다고 합니다. 컴파일러가 vtbl이 필요한 목적 파일마다 해당 vtbl의 사본을 만들어 놓고, 링커가 중복되는 사본을 제거해 가면서 최종 실행 파일이나 라이브러리에는 하나만 남겨 놓는 것입니다.

 

두 번째 방법은 경험적 규칙(휴리스틱)을 써서 클래스의 vtbl을 가질 목적 파일을 결정하는 것으로, 위의 방법보다 좀더 많이 쓰인다고 합니다. 이 규칙은 대개 이렇습니다. 어떤 클래스의 인라인도 아니고 순수 가상 함수도 아닌 함수  중 가장 첫 번째 것의 정의부분(함수몸체)을 가지고 있는 목적 파일에 그 클래스의 vtbl을 넣기로 하는 것입니다. 따라서, 이 규칙에 따르면, 앞 코드에 있는 클래스의 C1의 vtbl은 C1::~C1(이 소멸자가 인라인이 아닐 때)의 정의부분을 가지고 있는 목적 파일에 들어갈 것이고, 클래스 C2의 vtbl은 C2::~C2의 정의부분을 가지고 있는 목적 파일에 들어가게 되겠죠.

실제로 이 규칙은 상당히 잘 통한다고 합니다. 그러나 가상 함수를 인라인으로 선언하는 경우가 너무 많으면 문제에 봉착할 수 있으니 주의해야 합니다.

 

가상 테이블은 C++ 가상 함수 구현의 한 축이라 해도 과언이 아니지만, 이것만 가지고는 가상 함수를 돌릴 수 없습니다. '어떤 객체에 대해 어떤 vtbl을 사용할 것인가'를 연결하는 수단이 준비되어 있어야 비로소 자신의 도리를 할 수 있습니다. 그 연결을 맡은 녀석이 바로 가상 테이블 포인터입니다.

 

가상 함수를 선언한 클래스로부터 만들어진 객체에는 그 클래스의 가상 함수를 가리키는 데이터 멤버가 하나 숨겨져 있습니다. 이 숨겨진 데이터 멤버(vptr)가 놓이는 객체 내의 위치는 컴파일러만 알고 있습니다. 가상 함수를 가진 객체의 메모리 배열구조를 개념적으로 그려보면 다음과 같습니다.

efc++3.bmp 

이 그림을 보면 vptr이 객체의 끝부분에 있죠? 하지만 어디까지나 개념도일 뿐이니 주의하시기 바랍니다. vptr을 숨겨놓는 위치는 컴파일러마다 다릅니다. 상속된 객체의 경우에는 vptr이 다른 데이터 멤버에 둘러싸이기도 합니다. '가상 함수에 들어가는 두 번째 비용이 이것이다'라는 점을 새겨 두십시오. 가상 함수를 가진 클래스로 만들어지는 객체에는 가상 테이블을 가리키는 포인터가 하나 더 들어간다는 사실 말입니다.

 

객체가 별로 크지 않은 경우의 이 비용은 만만치 않습니다. 객체의 데이터 멤버가 4바이트 였다면, vptr까지 합쳐진 실제 객체의 크기는 (포인터의 크기가 4바이트라고 할 때) 두 배가되는 것입니다. 덩치가 큰 객체는 캐시나 가상 메모리 페이지에 잘 맞지 않게 되는데, 결국 운영체제가 메모리 페이징을 더 많이 하는 결과를 낳아 수행 성능에서 손해를 보게 됩니다.

 

자, 이제는 C1과 C2 타입의 객체를 가지고 설명해 가겠습니다. C1과 C2의 클래스 관계는 이미 앞에서 보신 대로이고, 어떤 프로그램에서 C1과 C2로 객체를 몇 개 만들면 다음과 같은 객체 그림을 그릴 수 있을 것입니다.

efc++4.bmp 

 

이 프로그램에는 다음과 같은 함수가 들어 있습니다.

  1. void makeACall(C1 *pC1)
  2. {
  3. pC1->f1();
  4. }

포인터 pC1을 통해 가상 함수 f1을 호출하라는 뜻입니다. 이 코드만 봐서는 f1 함수가 C1의 것인지, C2의 것인지 알 수가 없습니다. pC1이 가리키는 객체는 C1 타입일 수도 있고, C2 타입일 수도 있으니까요. 하지만 컴파일러는 어쨌든 makeACall 안에서 f1을 호출하는 코드를 만들어 뱉어야 합니다. pC1이 어딜 가리키든 간에 함수가 제대로 호출되어야 함은 당연하고요. 컴파일러는 이를 위해 다음과 같은 단계를 거쳐 코드를 만듭니다.

1. pC1이 가리키는 객체의 vptr을 따라 vtbl로 갑니다. 간단한 동작으로 마칠 수 있습니다. 아까도 말했지만, vptr이 들어있는 위치는 컴파일러가 잘 알고 있으니까요. 결국, 이때 들어가는 비용은 (vptr의 위치로 가기 위한) 옵셋 조정과 (vtbl로 가기 위한) vptr 참조 뿐입니다.

2. vtbl을 뒤져서 지금 호출해야 하는 함수(이 경우엔 f1)에 해당하는 포인터를 찾아옵니다. 이 동작 역시 아주 단순합니다. 컴파일러가 가상 테이블 내의 가상 함수 포인터에 고유 인덱스를 미리 붙여 놓았기 때문에, vtbl 배열 내의 옵셋 조정 비용만 들어갈 뿐입니다.

3. 2단계에서 가져온 포인터가 가리키는 함수를 호출합니다.

 

즉, 가상 테이블이 vptr이고 f1의 가상 테이블 내 인덱스는 i라고 가정할 때, 다음의 코드

  1. pC1->f1();

에 대해 컴파일러가 생성하는 코드는 다음과 같을 것입니다.

  1. (*pC1->vptr[i]) (pC1);      // pC1->vptr이 가리키는 vtbl 내의 i번째 요소가 가리키는 함수를
  2. // 호출합니다. pC1은 그 함수에 "this" 포인터로 넘겨집니다.

이런 절차로, 가상 함수 때문에 속도가 엄청나게 느려진다는 말은 할 수가 없습니다. 비가상 함수와 거의 비슷한 효율을 보입니다.

 

그렇다면, 수행 성능의 발목을 잡는 가상 함수 비용의 진수는 어디에 있을까요? 바로 인라인에서 찾을 수 있습니다. 제정신이 아닌 이상, 가상 함수 호출이 인라인되는 경우는 없습니다. 왜냐하면 '인라인(inline)'이라 함은 "컴파일 도중에, 호출 위치에 호출되는 함수의 몸체를 끼워 넣는다"라는 뜻인데 반해, "가상(virtual)"이라 함은 "호출할 함수를 런타임까지 기다려 결정한다"라는 뜻이기 때문입니다. 바로 여기서 가상 함수에 들어가는 세 번째 비용을 찾을 수 있습니다. 사실상 함수의 인라인 효과를 포기해야 한다는 것이죠( 객체를 통해 호출될 경우에는 가상 함수의 인라이닝이 가능합니다. 하지만 대부분의 가상 함수 호출은 포인터나 참조자를 통해 이루어지는데, 이런 함수 호출에 대해서는 컴파일러가 vptr를 사용한 코드를 만들수가 없습니다. 다시 말하건대, 첨조자나 포인터를 통한 호출이 대부분이기 때문에 가상 함수는 인라인하지 않습니다).

 

다중 상속의 경우에는, 객체 안에서 vptr의 위치를 찾는 옵셋 계산이 더 복잡해 집니다. 한 객체 안에 vptr이 여러 개 들어있고 ( 상속받은 기본 클래스에서 하나씩 가져온 결과), 파생 클래스에 대한 vtbl외에 기본 클래스에 대한 vtbl까지 꼬입니다. 그 결과, 클래스별 오버헤드와 객체별 오버헤드가 동시에 늘어나고, 런타임 생성 혹은 호출에 들어가는 비용도 그만큼 높아집니다.

 

다중 상속에 그림자처럼 따라오는 것이 가상 기본 클래스입니다. 이것이 없으면, 하나의 파생 클래스에서 하나의 기본 클래스까지 가는 상속 계통 경로가 두 개 이상일 때(즉, 하나의 기본 클래스로부터 상속받은 두 개의 클래스를 동시에 상속할 경우) 기본 클래스의 데이터 멤버가 계통 경로의 개수만큼 파생 클래스의 객체에 중복 생성됩니다. 가상 기본 클래스란, 이런 현상을 원할 턱이 없으므로 기본 클래스를 가상 상속하게 하여 데이터 멤버의 중복을 막자는 것이죠. 하지만, 가상 기본 클래스도 그 나름대로 비용 부담을 가져옵니다. 데이터 멤버의 중복을 피하기 위해 가상 기본 클래스 부분에 대한 포인터를 객체에 넣어 둔다든지 해야 하기 때문입니다.

 

이런 경우를 그림과 코드로 나타내어 보았습니다.

  1. class A { ... };
  2. class B : virtual public A { ... };
  3. class C : virtual public A { ... };
  4. class D : public B, public C { ... };

여기서 A는 가상 기본 클래스입니다. B와 C가 A를 가상 상속하고 있으니까요. 컴파일러의 컴파일을 거쳐 만들어진 D 타입의 객체는 다음과 같은 메모리 배열 구조를 가집니다.

efc++6.bmp 

물론, 객체의 메모리 배열을 구성하는 것은 컴파일러의 구현에 따라 다르기 때문에, 앞의 그림은 "가상 기본 클래스를 사용하면 이런 식으로 포인터가 추가되는 구나"라고 알아두는 정도로만 쓰시고 너무 믿진 마시기 바랍니다.

이제는 바로 이 그림과 가상 테이블 포인터가 객체에 추가될 때를 나타낸 그림을 합쳐 보도록 합시다. 합쳐 놓고 보니, 앞에서 보았던 클래스 계통의 A 클래스에 가상 함수가 들어 있으면, D 타입의 객체는 다음의 그림과 같은 메모리 배열 구조를 가지게 되는군요.

efc++7.bmp 

컴파일러가 추가해 준 부분은 어둡게 칠했습니다. 이 그림도 너무 믿지 말길 바랍니다. 이 그림에서 좀 이상한 부분이 느껴지지 않습니까? 네 개의 클래스가 들어갔는데 vptr은 세 개뿐입니다. 구현에 따라서는 얼마든지 네 개의 포인터를 만들어 넣을 수 있지만, 세 개로 충분합니다(B와 D가 vptr을 공유할 수 있습니다). 대부분의 컴파일러는 이런 것을 활용해서 컴파일러에 의한 오버헤드를 어떻게든 줄이는 기능을 갖추고 있습니다. 

 

RTTI(runtime type identification, 런타임 타입 식별)는 실행 중에 객체와 클래스의 정보를 알아낼 수 있게 하는 꽤 쓸만한 기능인 반면, 그 정보를 저장해 둘 어딘가가 필요하다는 것도 당연한 사실입니다. 이 정보는 type_info라는 타입의 객체에 저장하고, 이 type_info 객체는 typeid 연산자를 써서 엑세스할 수 있습니다(typeid 연산자는 type_info 객체의 참조자를 반환한다).

 

RTTI 정보는 클래스마다 하나씩만 있으면 되겠고, 이 정보는 어느 객체에서든지 뽑아낼 수 있을 것 같습니다. 그런데 실제로는 그렇지 않습니다. C++ 스펙에 의하면, 객체의 동적 타입을 정확히 뽑아낼 수 있으려면 그 타입(클래스)에 가상 함수가 최소한 하나는 있어야 한다고 하네요. RTTI가 가상 함수 테이블과 비슷하게 느껴지는 순간입니다. 클래스마다 RTTI 정보를 하나씩만 유지하고, 가상 함수를 가진 객체에서만 RTTI 정보를 뽑아낼 수 있답니다. 애초부터 RTTI는 클래스의 vtbl을 통해 구현될 수 있도록 설계되었습니다.

 

이를테면, 컴파일러가 vtbl 배열의 인덱스가 0인 위치에는 vtbl에 해당하는 클래스에 대한 type_info 객체의 포인터가 들어 있는 식으로 vtbl을 구현하는 것이죠(물론, 다를 수 있습니다). 이 항목의 가장 처음 그림에서 C1 클래스의 vtbl은 다음과 같이 나타낼 수 있습니다.

efc++5.bmp 

 

결국 이런 식으로 구현된 RTTI를 사용하면, type_info 객체의 메모리 뿐만 아니라 vtbl에 type_info 객체에 대한 포인터가 하나 더 추가됩니다. 그렇긴 해도, type_info 객체의 크기로 인해 발생하는 문제는 눈에 띌 수준은 아닙니다. 웬만한 소프트웨어에서 가상 테이블이 차지하는 메모리 오버헤드가 그렇게 크지 않은 것처럼요.

 

 

유용하고 재미있는 프로그래밍 기법들#

 

25. 생성자 함수와 비멤버 함수를 가상 함수처럼 만드는 방법#

가상 생성자란, 자신이 받은 입력 데이터에 의존하여 다른 타입의 객체를 생성하는 함수를 일컫습니다. 가상 생성자 중 특히 널리 쓰이는 놈이 가상 복사 생성자입니다. 가상 복사 생성자는 이것을 호출한 객체를 그대로 본 뜬 사본의 포인터를 반환합니다. 이런 생질 때문에 copySelf, cloneSelf 아니면 다음처럼 간단히 clone 등으로 불리기도 하는 함수가 가상 복사 생성자입니다. 구현 방법이요? 아주 간단합니다. 다음을 보시죠.

  1. class NLComponent {
  2. public :
  3. // 가상 복사 생성자의 선언
  4. virtual NLComponent * clone() const = 0;
  5. ...
  6. };
  7. class TextBlock : public NLComponent {
  8. public :
  9. virtual TextBlock * clone() const   // 가상 복사 생성자
  10. {  return new TextBlock(*this);  }
  11. ...
  12. };
  13. class Graphic : public NLComponent {
  14. public :
  15. virtual Graphic * clone() const    // 가상 복사 생성자
  16. {  return new Graphic(*this);  }
  17. ...
  18. };

보셨듯이, 가상 복사 생성자는 해당 클래스의 진짜 복사 생성자를 호출하는 것으로 끝입니다. 여기서, 복사의 의미구조는 가상 버전과 진짜 버전이 모두 같습니다.

C++의 가상 복사 생성자는 비교적 최근에 완화된 가상 함수의 반환 타입 규칙을 사용한 기법입니다. 완화된 규칙에 의하면, 기본 클래스의 가상 함수를 재정의한 파생 클래스의 가상 함수는 똑같은 반환 타입을 갖지 않아도 됩니다. 가상 함수의 반환 타입이 기본 클래스 포인터(혹은, 참조자)라고 해도, 파생 클래스의 가상 함수는 파생 클래스 포인터(혹은 참조자)를 반환할 수 있습니다.

 

비멤버 함수를 가상 함수처럼 동작하게 하는 방법

비멤버 함수도 가상 함수가 될 수 없지만, 매개변수의 동적 타입에 따라 다른 동작을 하도록 만들 수 있습니다. 예를 들어 볼까요? TextBlock과 Graphic 클래스에 대해 따로 동작하는 출력 연산자를 구현하고 싶습니다. 가장 확실한 방법은 출력 연산자를 가상 함수로 만드는 것이겠죠. 하지만 출력 연산자는 operator<<이고, 이 함수(연산자)는 좌변 인자로 ostream&를 받습니다. 이 상황에서 출력 연산자를 TextBlock Graphic 클래스의 멤버 함수로 만든다는 것은 상식적으로 불가능합니다( 물론 억지로 되긴 하지만 일관성이 파괴됩니다).

우리가 가진 소박한 꿈은 operator<<를 그대로 쓰되, 이 연산자의 우변 인자로 들어가는 객체의 동적 타입에 따라 operator<<가 다르게 동작했으면 하는 바람인 것입니다. 한데, 우리가 바란 내용과 거의 흡사하게 구현할 수 있습니다. operator<<와 print를 동시에 정의하고 전자가 후자를 호출하게 하면 상황 종료입니다.

  1. class NLComponent {
  2. public :
  3. virtual ostream& print(ostream& s) const = 0;
  4. ...
  5. };
  6. class TextBlock : public NLComponent {
  7. public :
  8. virtual ostream& print(ostream& s) const;
  9. ...
  10. };
  11. class Graphic : public NLComponent {
  12. public :
  13. virtual ostream& print(ostream& s) const;
  14. ...
  15. };
  16. inline ostream& operator<<(ostream& s, const NLComponent& c)
  17. {
  18. return c.print(s);
  19. }

결국, 비멤버 함수를 가상 함수처럼 동작하게 만들기는 꽤나 쉽습니다. 원하는 일을 하는 가상 함수를 만들어 놓고, 비가상 함수에서 이것을 호출하게 하면 됩니다. 함수가 한 번 더 호출되기 때문에 걱정되는 비용은 인라인 선언으로 없애 버리세요.

 

 

26. 클래스 인스턴스의 개수를 의도대로 제한하는 방법#

객체의 개수는 0부터 시작하는 것이 훨씬 이해하기 쉽기 때문에 숫자 0부터 시작하겠습니다. 말하자면 '객체의 생성을 원천적으로 막을 수 있는 방법'부터 하겠다는 것입니다.

 

객체를 전혀 생성하지 않거나 한 개만 생성하기

인스턴스로 만들어지는 객체가 어떤 것이든 간에 우리가 확실히 알고 있는 사실이 있습니다. 바로 생성자가 호출되는 것이죠. 그렇다면, 특정한 클래스의 객체가 만들어지지 않게 하는 가장 쉬운 방법은 그 클래스의 생성자를 private로 선언하는 것입니다.

  1. class CantBeInstantiated {
  2. private :
  3. CantBeInstantiated();
  4. CantBeInstantiated(const CantBeInstantiated&);
  5. ...
  6. };

이렇게 해 버리면 CantBeInstantiated 클래스는 절대로 생성할 수가 없는 상태가 되는데, 이것을 출발점으로 해서 조금씩 제약을 풀어 가는 것입니다. 예를 들어, 프린터를 제어하는 클래스의 객체를 만들어야 하는데 사용 가능한 프린터 객체의 개수를 한 개로 제한하고 싶다면, 프린터 객체를 생성자 함수 안에 그냥 넣는 것입니다. 다음의 코드를 보세요.

  1. class PrintJob;      // 전방 참조를 위한 클래스 선언
  2. class Printer {
  3. public :
  4. void submitJob(const PrintJob& job);
  5. void reset();
  6. void performSelfTest();
  7. ...
  8. friend Printer& thePrinter();
  9. private :
  10. Printer();
  11. Printer(const Printer& rhs);
  12. ...
  13. };
  14. Printer& thePrinter()
  15. {
  16. static Printer p;      // 프린터 객체는 하나입니다.
  17. return p;
  18. }

이 코드 설계에 필요한 키 포인트는 세 가지입니다. 첫째, Printer 클래스의 생성자가 private입니다. 생성자가 private이기 때문에 사용자 맘대로 소스 코드에서 객체를 생성할 수가 없습니다. 둘째, 전역 함수인 thePrinter가 클래스의 프렌드로 선언되었습니다. 이 때문에 thePrinter는 private로 선언된 생성자의 굴레에서 벗어날 수 있게 되지요. 마지막으로, thePrinter에는 Printer 객체가 정적 객체로 들어 있습니다. 즉, 딱 하나만 뜬다는 것입니다.

앞의 코드에는 기억해 두면 피가 되고 살이 되는 미묘한 특징이 두 개나 숨어 있습니다. 우선 Printer 객체가 클래스의 정적 객체가 아니라 함수의 정적 객체라는 점이 첫째입니다. 클래스의 정적 멤버로 선언된 객체는 그것이 사용되든 사용되지 않든 상관 없이 생성됩니다(그리고 소멸됩니다). 이와 대조적으로, 함수 안에서 정적 변수로 선언된 객체는 그 함수가 최소한 한 번 호출되어야 생성됩니다. 즉, 그 함수가 호출되지 않으면 객체도 만들어지지 않는다는 것이지요(그 함수가 호출될 때마다 그 객체가 생성되어야 하는지의 점검은 여러분이 하셔야 합니다만).

앞의 코드에 숨겨진 두 번째의 미묘한 특징은 함수 안에 정의된 정적 객체와 인라이닝 사이의 관계입니다. thePrinter의 비멤버 버전을 한 번 더 봐 주세요.

  1. Printer& thePrinter()
  2. {
  3. static Printer p;
  4. return p;
  5. }

효율을 높여보겠다고 웬만한 함수는 다 인라인하자고 마음먹었다면, 이 함수는 바로 인라이닝 대상입니다. 그럼에도 불구하고 이 함수는 inline으로 선언되지 않았습니다. 왜 그랬을까요?

정적 객체를 선언하는 이유는 그 객체의 사본이 하나만 필요하기 때문일 것입니다. 하지만 inline이란, 컴파일러에게 그 함수가 호출된 부분 대신에 그 함수의 몸체를 끼워 넣고 "즉시 처리해라"라고 알려주는 것입니다. 하지만 비멤버 함수에 있어 inline은 다른 의미를 가집니다. 바로 "이 함수는 내부 연결(internal linkage)을 가진다"라는 뜻입니다. ( 내부 연결 : 여기서의 linkage이란, 컴파일 단위(쉽게 말하면 하나의 목적 파일을 생성하는 소스 코드의 몸체) 사이에 객체나 함수의 이름이 공유되는 방식을 일컫는다. 내부 연결이란, 어떤 객체의 이름 혹은 함수의 이름이 주어진 컴파일 단위 안에서만 의미를 가진다는 뜻이다. 즉, 어떤 함수 b가 내부 연결을 가지면, 목적 코드 a에 들어 있는 함수 b와 목적 코드 c에 들어 있는 함수 b는 동일한 코드임에도 불구하고, 별개의 함수로 인식되어 코드가 중복된다)

내부 연결을 가진 함수는 한 프로그램 안에서 중복될 수 있습니다(즉, 내부 연결을 가진 함수의 코드가 프로그램의 목적 코드 안에 두 개 이상 나타날 수 있습니다). 중복된 함수 코드마다 정적 객체가 들어 있게 되고요. 지역 정적 객체를 가진 비멤버 함수를 인라이닝하면, 한 프로그램에 그 정적 객체가 두 개 이상 나타날 수 있다는 것입니다. 거듭, 정적 객체를 선언한 비멤버 함수는 절대로 inline으로 선언하면 안 된다고 말씀드리고 싶습니다(1996년 7월, ISO/ANSI의 C++ 표준화 위원회에서는 인라인 함수가 갖는 연결 형태를 외부 연결로 변경하였습니다.즉, 이후에 만들어진 컴파일러에 대해서는 이런 문제가 존재하지 않음).

 

생성된 객체의 개수를 직접 세어서, 일정한 개수가 넘었을 때 예외를 일으키는 방법도 있습니다. 어떤 방법이고 하니, 프린터 객체를 다음과 같이 처리하는 것입니다.

  1. class Printer {
  2. public :
  3. class TooManyObjects {};   // 너무 많은 객체가 요구될 때 발생시키려고 만든 예외 클래스
  4. Printer();
  5. ~Printer();
  6. ...
  7. private :
  8. static size_t numObjetcs;
  9. Printer(const Printer& rhs);     // 프린터 객체는 한 개로 제한했으므로, 복사는 금지합니다.
  10. };

아이디어는 간단합니다. Printer 객체의 개수를 numObjects를 통해 유지하는 것이죠. 이 값은 클래스 생성자에서 증가시키고, 소멸자에서 감소시킵니다. 일정한 개수에 도달했는데 객체를 생성하라는 요구가 들어오면, TooManyObjects 타입의 예외를 발생시킵니다.

 

  1. // 클래스의 정적 변수이므로 초기화가 필요합니다.
  2. size_t Printer::numObjects = 0;
  3. Printer::Printer()
  4. {
  5. if(numObjects >= 1) {
  6. throw TooManyObjects();
  7. }
  8. 여느 때와 같은 객체 생성(필드 세팅) 과정을 진행합니다;
  9. ++numObjects;
  10. }
  11. Printer::~Printer()
  12. {
  13. 여느 때와 같은 객체 소멸 과정을 수행합니다;
  14. --numObjects;
  15. }

이러한 객체 개수 제한 방법은 두 가지 면에서 장점을 가지고 있습니다. 우선 직관적이고 단순합니다. 즉, 어떤 동작 원리로 돌아가는지 모든 사람이 이해할 수 있다는 것입니다. 그리고, 객체의 개수를 한 개 이외의 다른 개수로 일반화할 수 있습니다. numObjects의 수만 바꾸면 되니까요.

 

객체 생성이 이루어지는 세 가지 상황

하지만, 이 방법에도 문제가 숨어 있습니다. 특별한 프린터가 하나 있다고 가정합시다. 뭐, 칼라 프린터로 하겠습니다. 이런 프린터는 원래의 프린터가 가지고 있는 기능을 가지고 있을 것이 분명하므로, 이런 식으로 상속 관계를 만들 수 있을 것입니다.

  1. class ColorPrinter : public Printer {
  2. ...
  3. };

이제, 원래의 기본 프린터 한 대와 칼라 프린터 한 대를 동시에 띄우겠습니다.

  1. Printer p;
  2. ColorPrinter cp;

이 코드로 인해 만들어지는 Printer 객체는 총 몇 개일까요? 답은 2입니다. 하나는 p에 대해서 만들어진 것이고, 또 하나는 cp의 기본 클래스 부분을 생성하는 도중에 TooManyObjects 예외를 일으킵니다. 여러분이나 필자나 전혀 원하지 않았던 결과죠(구체 클래스가 다른 구체 클래스로부터 상속받는 것을 막는 설계 방법을 사용하면 이런 문제가 발생하지 않습니다. 항목 33 참조).

이런 비슷한 문제는 Printer 객체가 다른 객체에 포함될 때에도 일어납니다.

  1. class CPFMachine {       // 복사, 인쇄, 팩스 기능을 갖춘 기계를 나타내는 클래스
  2. private :
  3. Printer p;         // 인쇄 기능 담당
  4. FaxMachine f;      // 팩스 기능 담당
  5. CopyMachine c;     // 복사 기능 담당
  6. ...
  7. };
  8. CPFMachine m1;         // 여기까지는 문제없습니다.
  9. CPFMachine m2;         // TooManyObjects 예외를 일으킵니다.

문제는 바로 Printer 객체가 세 가지의 상황에서 생성될 수 있다는 점입니다. 첫 번째는 그 자체로 생성될 때, 두 번째는 파생된 객체의 기본 클래스 부분으로 만들어질 때, 세 번째는 다른 객체의 클래스 멤버로서 만들어질 때입니다.

생성자가 private로 선언된(friend 선언이 없는 상태에서) 클래스는 기본 클래스로 사용될 수 없으며, 다른 객체의 클래스 멤버로 들어갈 수도 없다.

 

객체를 불러냈다가 들여보내는 것도 자유롭게 하고 싶다!

thePrinter 함수를 설계한 방법은 Printer 객체의 개수를 딱 한 개로 묶어버리는 것이지만, 이 방법은 실행되는 프로그램 하나에 사용되는 Printer 객체의 개수를 딱 한 개로 묶어버리기도 합니다. 즉, 다음과 같은 코드를 쓸 수 없다는 것입니다.

  1. Printer 객체 p1을 생성합니다;
  2. p1을 사용합니다;
  3. p1을 소멸시킵니다;
  4.  
  5. Printer 객체 p2를 생성합니다;
  6. p2를 사용합니다;
  7. p2를 소멸시킵니다;
  8. ...

이렇게 설계된 코드는 한 번에 Printer 객체를 하나만 만들어 내지만 프로그램의 서로 다른 부분에서 서로 다른 Printer 객체를 사용하고 있습니다. 이런 코드를 쓸 수 없다면 좀 문제가 있겠지요? 어쨌든 '오직 한 개의 프린터만 존재하도록 한다'라는 제약을 어긴 부분을 어디에서도 찾을 수 없으니까 말이죠. 이런 코드가 가능하도록 만들 방법이 있을까요? 당연히 있습니다. 유사 생성자와 카운팅 코드를 합쳐면 일은 끝납니다.

  1. class Printer {
  2. public :
  3. class TooManyObjects {};
  4. // 유사 생성자
  5. static Printer * makePrinter();
  6. static Printer * makePrinter(const Printer& rhs);
  7.  
  8. ~Printer();
  9. void submitJob(const PrintJob& job);
  10. void reset();
  11. void performSelfTest();
  12. ...
  13. private :
  14. static size_t numObjects;
  15. Printer();
  16. Printer(const Printer& rhs);      // 이 함수는 정의하지 않습니다. 왜냐하면 객체의 복사를 허용하지 않을 테니까요.
  17. };
  18.  
  19. // 클래스의 정적 멤버이기 때문에 꼭 정의해야 합니다.
  20. size_t Printer::numObjects = 0;
  21.  
  22. Printer::Printer()
  23. {
  24. if(numObjects >= 1) {
  25. throw TooManyObjects();
  26. }
  27. 여느 때와 같은 객체 생성(필드 세팅) 과정을 진행합니다;
  28. ++numObjects;
  29. }
  30.  
  31. Printer::Printer(const Printer& rhs)
  32. {
  33. if(numObjects >= 1) {
  34. thorw TooManyObjects();
  35. }
  36. ...
  37. }
  38.  
  39. Printer * Printer::makePrinter()
  40. {  return new Printer;  }
  41.  
  42. Printer * Printer::makePrinter(const Printer& rhs)
  43. {  return new Printer(rhs);  }

객체 생성이 요청된 횟수가 일정 횟수를 초과할 때, 예외를 일으키는 부분이 조금 어처구니없다고 생각하는 독자는 유사 생성자가 널 포인터를 반환하도록 바꾸면 됩니다. 물론, 클래스 사용자로 하여금 포인터의 널 체크를 통해 상태를 파악하도록 해야 하겠지요.

 

인스턴스 카운팅(Object-Counting) 기능을 가진 기본 클래스

Printer 클래스의 객체 개수를 유지하는 값은 정적 변수로 선언된 numObjects에 저장됩니다. 따라서 이 변수를 인스턴스 카운팅 클래스에 넣으면 될 것 같습니다. 하지만, 인스턴스의 수를 세는 클래스마다 별도의 카운터를 두어야 한다는 점도 염두에 두어야 합니다. 이런 고민의 결과로 만든 것이 다음의 클래스 템플릿입니다. 인스턴스 카운터는 이 클래스 템플릿으로부터 만들어진 클래스의 정적 멤버로 들어가기 때문에, 원하는 개수만큼의 카운터를 자동으로 만들 수 있습니다.

  1. template<class BeingCounted>
  2. class Counted {
  3. public :
  4. class TooManyObjects {};   // 예외를 일으킬 때 사용하는 객체
  5. static size_t objectCount() { return numObjects; }
  6. protected :
  7. Counted();
  8. Counted(const Counted& rhs);
  9. ~Counted() {  --numObjects;  }
  10. private :
  11. static size_t numObjects;
  12. static const size_t maxObjects;
  13. void init();      // 생성자 코드를 중복해서 작성하는 일을 막기 위해 만든 함수
  14. };
  15.  
  16. template<class BeingCounted>
  17. Counted<BeingCounted>::Counted()
  18. {  init();  }
  19.  
  20. template<class BeingCounted>
  21. Counted<BeingCounted>::Counted(const Counted<BeingCounted>&)
  22. {  init();  }
  23.  
  24. template<class BeingCounted>
  25. void Counted<BeingCOunted>::init()
  26. {
  27. if( numObjects >= maxObjects ) throw TooManyObjects();
  28. ++numObjects;
  29. }

이 템플릿으로부터 만들어지는 클래스는 기본 클래스로만 사용되도록 설계되었기 때문에, 생성자와 소멸자가 모두 protected로 선언되어 있습니다. 두 개의 Counted 생성자에 똑같은 코드가 작성되는 것을 막기 위해 들여놓은 init() 함수도 주목할 만 합니다.

 

이제, Printer 클래스를 Counted 템플릿을 사용하도록 고쳐보겠습니다.

  1. class Printer::private Counted<Printer> {
  2. public :
  3. // 유사 생성자들
  4. static Printer * makePrinter();
  5. static Printer * makePrinter(const Printer& rhs);
  6. ~Printer();
  7. void submitJob(const PrintJob& job);
  8. void reset();
  9. void performSelfTest();
  10. ...
  11. using Counted<Printer>::objcetCount;
  12. using Counted<Printer>::TooManyObjects;
  13. private :
  14. Printer();
  15. Printer(const Printer& rhs);
  16. };

Printer 클래스가 생성 객체의 개수를 제한하기 위해 Counted 템플릿을 쓰고 있다는 사실은 솔직히 말해 Printer 클래스를 만든 사람 외엔 아무도 신경쓰지 않습니다. 따라서 Counted쪽에 있는 것들은 건들일 필요가 없겠죠? 그렇기 때문에 private 상속이 사용된 것입니다. 물론, public 상속을 해도 동작원리가 뒤집히거나 하진 않지만, public 상속을 할 경우엔 Counted 클래스에 가상 소멸자를 만들어 주어야 합니다(그렇지 않으면 Counted<Printer>* 포인터를 가지고 Printer 객체를 삭제할 때 동작 오류가 일어납니다). 항목 24에서 명쾌하게 밝히고 있듯이, Counted 클래스에 가상 함수가 들어가면 Counted를 상속한 클래스의 객체의 크기와 메모리 배열 구조에 영향을 끼칩니다. 이런 오버헤드는 private 상속을 통해 피할 수 있습니다.

 

매우 적절하게 Counted의 모습 대부분이 Printer에서 가려졌습니다만, Printer 객체가 현재 몇 개 생성되었는지 알아 볼 권리 제공을 위해 Counted 템플릿에는 objectCount 함수가 들어 있습니다. 하지만 앞의 private 상속 때문에 이 함수마저 private이 되어 버렸습니다. 따라서, 이 함수가 원래 가지고 있는 public성질(접근성)을 되돌리기 위해 다음과 같이 using 선언을 사용합니다.

  1. class Printer : private Counted<Printer> {
  2. public :
  3. using Counted<Printer>::objectCount;   // 이 함수를 외부 사용자가 쓸 수 있도록 public으로 만듭니다.
  4. ...
  5. };

TooManyObjects도 objectCount와 똑같은 방식으로 사용자가 쓸 수 있게 해야 하는데, 정해진 개수를 넘었을 때 발생되는 TooManyObjects 타입의 예외를 받을 수 있으려면 필요하기 때문입니다.

Printer는 Counted<Printer>를 상속했기 때문에 더 이상 객체의 개수에 대해 신경쓰지 않아도 됩니다. 인스턴스 카운팅은 누군가(Counted<Printer>부분)가 알아서 해 주기 때문입니다. Printer의 생성자는 이제 다음과 같이 변합니다.

  1. Printer::Printer()
  2. {
  3. 여느 때와 같은 객체 생성(필드 세팅) 과정을 진행합니다;
  4. }

여기서 재미있는 부분은 코드 자체가 아니라 여러분이 하지 않아도 저절로 되는 동작에 있습니다. 즉, 생성된 객체가 제한된 개수를 초과했는지를 검사하는 루틴도 없고, 생성자가 실행될 때 객체 카운트도 덩달아 증가시키는 루틴도 찾을 수 없습니다. 이 모든 것을 Counted<Printer> 생성자가 알아서 해 주기 때문이지요.

 

이 클래스 설계에는 확실히 마무리지어야 할 부분이 아직 남아있습니다. 무엇인고 하니, Counted 안에 선언되어 있는 정적 상수 멤버를 두고 하는 말입니다. 일단 numObjects는 처리하기 쉽습니다. Counted의 구현 파일에 다음과 같이 쓰기만 하면 됩니다.

  1. template<class BeingCounted>      // numObjects를 구현 파일에 정의하면, 자동으로 0으로 초기화됩니다.
  2. size_t Counted<BeingCounted>::numObjects;   // 요즘 대부분의 컴파일러는 정수 타입의 정적 변수 초기값을 0으로 초기화 한다.

 

반면, maxObjects의 경우에는 처리하기가 조금 까다롭습니다. 이런 값들은 클래스 제작자가 결정할 것이 아니기 때문에 클래스 사용자에게 초기화를 맡기는 것입니다. 즉, Printer 클래스의 경우에는 Printer를 만드는 사람이 직접 구현 파일에 다음과 같은 초기화 문장을 넣어야 합니다.

  1. const size_t Counted<Printer>::maxObjects = 10;

혹시, 클래스 사용자가 깜박 잊고 maxObjects를 초기화하지 않으면 링크 에러가 납니다. 

 

 

27. 힙에만 생성되거나 힙에는 만들어지지 않는 특수한 클래스를 만드는 방법#

객체가 힙에만 생성되게 하기

소멸자만 private로 하고 생성자는 public으로 만든다. 이렇게 한 후에, 진짜 소멸자를 호출하는 유사 소멸자를 만드는 것입니다. 객체를 소멸시킬 때는 이 유사 소멸자를 호출할 수 있습니다.

  1. class UPNumber {
  2. public :
  3. UPNumber();
  4. UPNumber(int initValue);
  5. UPNumber(double initValue);
  6. UPNumber(const UPNumber& rhs);
  7. // 가상 소멸자(const 멤버 함수 : 상수 객체도 소멸될 수 있기 때문에)
  8. void destory() const { delete this; }
  9. ...
  10. private :
  11. ~UPNumber();
  12. };

사용자는 이 클래스를 써서 다음과 같이 프로그래밍할 것입니다.

  1. UPNumber n;            // 에러! (지금은 불법이 아니지만, 나중에 n의 소멸자가 암시적으로

       // 호출될 때 불법이 됩니다.

  2. UPNumber *p = new UPNumber;
  3. ...
  4. delete p;              // 에러! private로 선언된 소멸자는 호출할 수 없습니다.
  5. p->destory();          // 문제없습니다.

상속이 안 되는 문제는 UPNumber의 소멸자를 protected로 바꾸면 해결할 수 있고(생성자는 public으로 유지하고요), UPNumber 타입의 객체를 다른 객체에 넣지 못하는 문제는 PNumber 객체 대신에 그 객체의 포인터를 넣으면 간단히 해결할 수 있습니다.

 

어떤 객체가 힙에 생성되었는지, 그렇지 않은지를 알아내는 방법

UPNumber 생성자가 힙 기반 객체의 기본 클래스 부분으로 호출되는지 그렇지 않은지를 알아내기란 불가능합니다. 말하자면, UPNumber 생성자로 다음의 두 가지 경우를 구별할 방법이 없다는 것입니다.

  1. NonNegativeUPNumber * n1 = new NonNegativeUPNumber;   // 힙에 있습니다.
  2. NonnegativeUPNumber n2;                               // 힙에 없습니다.

mmr.bmp 

대부분의 시스템이 주소 공간을 연속된 주소로 배열한다는 사실을 떠올리고, 이 점을 활용할 작정을 하고 있을지도 모릅니다. 프로그램의 메모리 영역을 위 그림처럼 배치하는 시스템(많은 시스템이 이렇게 합니다만 그렇지 않은 경우도 있다고 합니다)에서는 특정한 주소값이 힙 영역에 있는지를 알아내는 함수를 써서 객체가 힙 기반인지를 파악하는 목적을 달성할 수도 있습니다. 하지만 힙 객체와 정적 객체를 구분할 수 없습니다.

하지만 다행스러운 사실은 어떤 포인터가 힙 주소를 가리키는지 알아내는 방법보다, 어떤 포인터가 삭제해도 괜찮은 포인터인지를 알아내는 것이 더 쉽다는 것입니다. operator new 에서 지금까지 할당된 주소들의 콜렉션을 유지하면 구현이 가능하기 때문입니다.

  1. void *operator new(size_t size)
  2. {
  3. void *p = getMemory(size);   // 메모리를 할당하고 가용 메모리가 없는
  4.  // 상황을 처리해주는 함수를 호출합니다.
  5. p를 할당된 주소 콜렉션에 추가합니다;
  6. return p;
  7. }
  8. void operator delete(void *ptr)
  9. {
  10. releaseMemory(ptr);         // 관리 장치에게 현재의 메모리를 돌려 줍니다.
  11. 할당된 주소 콜렉션에서 ptr을 제거합니다;
  12. }
  13. bool isSaeToDelete(const void *address)
  14. {
  15. address가 할당된 주소 콜렉션에 있는지 없는지의 여부를 반환합니다;
  16. }

operator new는 할당된 주소 콜렉션에 새로 할당한 포인터를 추가하고, operator delete는 그 콜렉션에서 포인터를 제거하고, isSafeToDelete는 특정한 주소가 그 콜렉션에 들어 있는지를 점검합니다. 이와 같은 함수들을 '추상 믹스인 기본 클래스'를 이용해서 제공하게 만듭니다. 믹스인 클래스는 명확한 기능을 딱 하나만 제공하는 클래스로서, 이 클래스의 파생 클래스가 제공할지도 모르는 다른 기능과 호환되도록 설계된 희한한 클래스입니다.

 

객체가 힙에 생성되지 않게 하기

우리가 고려해야 하는 경우의 가짓수는 딱 세 개인데, 객체가 직접 인스턴스화되는 경우, 파생 클래스 객체의 기본 클래스 부분으로 인스턴스화되는 경우, 그리고 다른 객체의 멤버로 들어가는 경우입니다.

객체가 힙에 직접 만들어지는 경우를 막는 방법은 아주 쉽습니다. 'operator new'를 private 로 선언하면 됩니다. 다음의 예제는 객체를 힙에 할당되지 못하게 만든 UPNumber 클래스입니다.

  1. class UPNumber {
  2. private :

    static void * operator new(size_t size);

  3. static void operator delete(void *ptr);

  4. ...

  5. };

사용자는 이제 UPNumber에게 시켜 놓은 일만 할 수밖에 없습니다.

  1. UPNumber n1;         // 문제 없습니다.
  2. static UPNumber n2;  // 문제 없습니다.
  3. UPNumber *p = new UPNumber;   // 에러! private로 선언된 operator new의 호출을 시도했습니다.

일단 이것으로 소기의 목적은 달성했습니다만, operator delete는 public인 채로 operator new만 private으로 만들어 놓자니 조금 찜찜합니다. 둘을 찢어 놓을 피치 못 할 이유가 없는 한, 두 함수는 같은 성질로 선언하는 것이 좋습니다. 또 하나, UPNumber 객체의 배열을 힙에 만들지 못하게 하려면, operator new[]와 operator delete[]도 private로 선언해 두셔야 합니다.

이제 파생 클래스 객체의 기본 클래스 부분으로 객체가 인스턴스화되는 경우를 생각해 봅시다. 그런데, 이 경우는 첫 번재 경우가 해결되면 자연스럽게 해결됩니다. 즉, operator new를 private로 선언하면 힙 기반의 파생 클래스 객체의 기본 클래스 부분으로서 인스턴스화되는 것도 막을 수 있다는 것이죠. 왜냐하면, operator new와 operator delete는 상속이 가능한 함수이기 때문에, 이 함수들이 파생 클래스에서 public으로 선언되지 않는 한 기본 클래스에 들어 있는 private 버전이 고스란히 내려옵니다.

파생 클래스가 자신만의 operator new를 또 선언하면, 파생 클래스의 객체를 힙에 할당할 때 이 함수가 호출된다는 사실에 주의해야 합니다. 이럴 때에는 UPNumber 기본 클래스 부분까지 힙으로 끌려가지 않게 하는 다름 방법을 찾아야 합니다. 비슷하게, UPNumber의 operator new가 private라고 해서 UPNumber 객체를 멤버로 가지고 있는 다른 객체의 할당 방식까지 영향을 주는 것은 아니라는 점도 유념해야 하겠습니다.

 

 

28. 스마트 포인터#

스마트 포인터를 사용하는 이유들을 추려보면 다음의 세 가지로 요약할 수 있습니다.

 생성과 소멸 작업을 조절할 수 있습니다. 스마트 포인터가 생성되고 소멸되는 시기를 여러분이 결정할 수 있습니다. 게다가 대부분의 스마트 포인터는 생성될 때 기본값 0(널)을 가집니다. 어떤 스마트 포인터는 객체를 가리키고 있던 최후의 포인터가 소멸될 때, 자동으로 그 객체를 삭제하는 기능도 가지고 있습니다. 리소스 누수를 막는 것이죠.

 복사와 대입 동작을 조절할 수 있습니다. 스마트 포인터가 복사되거나 대입될 때 일어나는 일을 여러분이 결정할 수 있습니다.

 역참조 동작을 조절할 수 있습니다. 사용자가 스마트 포인터가 가리키는 객체를 가져오려고 할 때 어떤 일이 일어날지를 여러분이 결정할 수 있습니다.

 

스마트 포인터는 템플릿 기반으로 만들어집니다. 템플릿 매개변수를 통해 스마트 포이터로 가리킬 객체의 타입을 지정하는 것입니다. 스마트 포인터가 생긴 모습은 대개 다음과 같습니다.

  1. template<class T>         // 스마트 포인터 객체의 템플릿
  2. class SmartPtr {
  3. public :
  4. SmartPtr(T* realPtr = 0);      // 스마트 포인터를 생성하는데, 벙어리 포인터가

       // 가리키는 객체를 가리키도록 초기화됩니다.

  5.    // 값이 주어지지 않으면 0(널) 입니다.

  6. SmartPtr(const SmartPtr& rhs); // 스마트 포인터를 복사합니다.
  7. ~SmartPtr();                   // 스마트 포인터를 소멸시킵니다.
  8. // 스마트 포인터에 대입 연산을 수행합니다.
  9. SmartPtr& operator=(const SmartPtr& rhs);
  10. T* operator->() const;         // 스마트 포인터를 역참조하여 스마트 포인터가

       // 가리키는 멤버에 접근합니다.

  11. T& operator*() const;          // 스마트 포인터를 역참조합니다.
  12. private :
  13. T *pointee;                    // 스마트 포인터가 가리키는 실제 객체의 주소
  14. };

여기서 복사 생성자와 대입 연산자는 둘 다 public으로 되어 있는데, 복사와 대입이 허용되지 않는 스마트 포인터라면 이 함수를 private로 선언해야 할 것입니다. 역참조 연산자(*)가 const로 선언되어 있는 이유는 포인터를 역참조한다고 해서 내부 데이터가 바뀌진 않기 때문입니다. 마지막으로, 이 클래스 안에는 T를 가리키는 벙어리 포인터가 멤버로 들어 있습니다. 즉, 실제로 객체를 가리키는 것은 벙어리 포인터입니다.

 

스마트 포인터의 생성, 대입, 소멸

표준 C++ 라이브러리에 들어 있는 auto_ptr 객체는 자신이 소멸될 때까지 힙 기반 객체를 가리키는 일종의 스마트 포인터입니다. auto_ptr 객체가 소멸될 때 소멸자가 호출되면서 자신이 가리키는 객체를 삭제합니다. 이 auto_ptr 템플릿의 내부는 다음과 같이 생겼을 것입니다.

  1. template<class T>
  2. class auto_ptr {
  3. public :
  4. auto_ptr(T *ptr = 0) : pointee(ptr) {}
  5. ~auto_ptr() { delete pointee; }
  6. ...
  7. private :
  8. T *pointee;
  9. };

auto_ptr은 복사 혹은 대입될 때 소유 관계를 옮깁니다.

 

역참조 연산자 구현하기

이 부분은 스마트 포인터의 심장부라고 말할 수 있는 operator*와 operator-> 함수입니다.

  1. template<class T>
  2. T& SmartPtr<T>::operator*() const
  3. {
  4. "스마트 포인터" 처리를 수행합니다;
  5. return *pointee;
  6. }

먼저, 이 함수는 pointee를 초기화하든지 pointee를 유효하게 하는 일을 닥치는 대로 합니다. 일단 pointee가 유효하다고 판단되면, operator* 함수는 스마트 포인터가 가리키는 객체의 참조자를 반환합니다. 그리고 끝입니다.

여기서 이 함수의 반환 타입이 참조자라는 점에 주목할 필요가 있습니다. pointee는 꼭 T 타입의 객체를 가리키지 않을 수도 있습니다. T에서 파생된 클래스의 객체를 가리켜도 문제가 없다는 것이죠. 만일 이런 경우에 실제로 파생된 클래스 객체의 참조자가 아닌 T 객체를 (값으로) 반환해 버리면, 이 함수는 반환 타입도 못 맞추는 엉터리 함수가 되는 것입니다!(이것이 슬라이스 문제입니다) 이렇게 반환된 객체에서 호출되는 가상 함수는 그 객체의 동적 타입에 맞는 함수를 호출하지 못합니다.

operator->의 동작도 operator*의 동작과 별반 다르지 않습니다.

  1. template<class T>
  2. T* SmartPtr<T>::operator->() const
  3. {
  4. "스마트 포인터" 처리를 수행합니다;
  5. return pointee;
  6. }

문제가 없는 함수입니다. 이 함수는 포인터를 반환하기 때문에 operator->를 통해 호출되는 가상 함수가 동적 타입에 잘 맞추어 의도한 대로 동작합니다.

 

 

29. 참조 카운팅#

참조 카운팅(Reference Counting)이란 여러 개의 객체들이 똑같은 값을 가졌으면, 그 객체들로 하여금 그 값을 나타내는 하나의 데이터를 공유하게 해서 데이터의 양을 절약하는 기법입니다.

참조 카운팅은 객체들 사이에서 값의 공유가 빈번하다는 가정 하에 고안한 최적화 기법입니다. 만약에 참조 카운팅을 쓰지 않은 코드보다 참조 카운팅을 쓴 코드가 메모리도 더 많이 먹고 코드도 더 많이 실행된다면, 이 가정은 실패한 것입니다. 하지만 객체들이 공통된 값을 가지는 경향이 뚜렷한 경우에는 참조 카운팅을 통해 실행 시간과 메모리 공간을 절약할 수 있습니다. 값이 크고 그 값을 동시에 공유하는 객체가 많을수록, 메모리 절약 효과는 커집니다. 객체 사이에 값을 복사하거나 대입하는 경우가 잦을수록, 실행 시간의 절약효과는 커집니다. 값을 생성하고 소멸시키는 비용이 클수록 실행 시간도 더 많이 절약됩니다. 참조 카운팅이 효율 향상에 효과적일 수 있는 상황은 다음의 두 가지로 정리할 수 있습니다.

 상대적으로 많은 객체들이 상대적으로 적은 값을 공유할 때.  이러한 상황은 대개 대입 연산자와 복사 생성자가 호출될 때 만들어집니다. 객체 개수에 대한 값 개수의 비율이 클수록 참조 카운팅을 할 필요가 더 커집니다.

 어떤 객체값을 생성하거나 소멸시키는데 많은 비용이 들거나 메모리 소모가 클 때.  이러한 경우라고 해도, 여러 객체들이 값을 공유하지 않으면 참조 카운팅은 별 효과를 보이지 않습니다.

 

 

30. 프록시 클래스#

프록시 클래스는 어떤 객체를 대신하여 동작하게 하는 장치입니다. 이것 덕택에 어떤 특정한 동작 원리 몇 가지를 아주 쉽게 구현할 수 있습니다. 다차원 배열이 첫 번째 예이고, 좌항값/우항값 구분이 두 번째 예이며, 암시적 타입변환의 방지가 세 번째 예입니다.

이와 동시에, 프록시 클래스에는 단점도 있습니다. 함수로부터 반환되는 프록시 객체는 임시 객체이기 때문에, 객체 생성 및 소멸 과정이 저절로 수반되어야 합니다. 프록시를 통해 손에 쥔 읽기/쓰기 구분 능력으로 어느 정도의 보상은 받을 수 있겠지만, 객체를 생성하고 소멸시키는데 드는 비용은 공짜가 아닙니다. 프록시 클래스를 사용한 소프트웨어 시스템은 그렇지 않은 것보다 더 복잡합니다. 클래스 몇 개가 더 추가되기 때문에 설계, 구현, 파악, 유지보수 등에 관한 작업이 어려워질 수 밖에 없지요.

 

 

31. 함수를 두 개 이상의 객체(타입)에 대해 가상 함수처럼 동작하도록 만들기#

가상 함수는 단일 디스패치를 구현한 것이라고 생각할 수 있습니다. 이중 디스패치는 여러 개의 매개변수에 대해 가상 함수처럼 동작하는 함수를 말합니다.

 

 

이외의 이야기들#

 

32. 미래 지향적인 프로그래머가 되자.#

미래 지향적인 프로그래밍이란, 변화를 받아들이고 변화에 대비하는 것입니다.

이렇게 하는 한 가지 방법은 설계 제약을 나타내는 데에 있어서 주석문이나 다른 문서화 대신 C++의 특징을 사용하는 것입니다.

C++ 프로그래밍에서 어기면 안 되는 원칙이 있습니다. 바로 "황당함 최소화의 원칙"이란 것입니다. 어떤 클래스에 대해 만들어지는 연산자와 함수는, 다른 사람들이 자연스럽게 사용할 수 있는 문법과 직관적인 의미구조를 갖도록 하라는 것이죠. C++의 기본 제공 타입과 동일한 원리를 갖도록 하세요.

컴파일러가 별 불평을 하지 않는 동작은 누군가가 반드시 하게 된다고 생각하시는 것이 좋습니다. 따라서 C++ 클래스를 만들 때에는, 맞게 사용하기에는 쉽고 틀리게 사용하기에는 어렵도록 클래스를 만들어야 합니다. 사용자(개발자)는 항상 실수를 하는 족속이란 사실을 명심하십시오.

코드의 이식성도 최대한 신경 쓰셔야 하는 부분입니다.

변경이 필요할 때 그 변경의 영향이 제한된 부분에만 미치도록 코드를 설계하기 바랍니다. 여러분이 힘닿는 데까지 캡슐화를 하고, 구현에 관련된 상세한 부분은 외부에 노출시키지 마세요.

미래 지향적 사고를 가지고 만드는 프로그램은 코드의 재사용성, 유지보수성, 견고성이 향상될 뿐만 아니라, 변화하는 환경에 맞추어 변화시키기에도 좋습니다. 단, 미래 지향적 프로그램은 현재의 제약과 보조를 맞출 때 비로소 제 힘을 발휘할 수 있습니다.

 

 

33. 상속 관계의 말단에 있지 않은 클래스는 반드시 추상 클래스로 만들자.#

 

 

34. 한 프로그램에서 C++와 C를 함께 사용하는 방법을 이해하자.#

 

 

35. C++ 언어의 최신 표준안과 표준 라이브러리에 대해 익숙해지자.#