z 'C++공부/Effective C++' 카테고리의 글 목록 :: C++, 그래픽 기술 블로그

49. Understand the behavior of the new-handler

operator new가 사용자가 보낸 메모리 할당 요청을 맞춰주지 못한다면, 예외를 던지는데 이 예외를 던지기 전에 set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있습니다.

// set_new_handler함수는 <new>에 선언되어 있습니다.
namespace std {
    typedef void (*new_handler)(void);   
    new_handler set_new_handler(new_handler p) throw();
}

 

위 throw()는 이 함수는 어떤 예외도 던지지 않을 것을 의미합니다. 메모리할당 실패시 operator_new가 호출할 함수의 포인터는 set_new_handler 함수의 매개변수이며, 기존에 설정되어 있던 new 처리자는 set_new_handler 함수의 반환값입니다. 사용자가 부탁한 만큼의 메모리를 할당해 주지 못하면, operator new는 충분한 메모리를 찾아낼 때까지 new 처리자를 되풀이해서 호출합니다. 이와 같이 set_new_handler로 지정한 new 처리자는 다음 중 하나를 꼭 수행해야 합니다.

  • 사용할 수 있는 메모리를 더 많이 확보합니다
    operator new가 시도하는 이후의 메모리 확보가 성공할 수 있도록 하자는 전략입니다. 구현 방법은 여러가지가 있지만, 프로그램이 시작할 때 메모리 블록을 크게 하나 할당해 놓았다가 new 처리자가 가장 처음 호출될 때 그 메모리를 쓸 수 있도록 허용하는 방법이 그 예입니다.
  • 다른 new 처리자를 설치합니다
    현재의 new 처리자가 더 이상 가용 메모리를 확보할 수 없다 해도, 이 경우 자기 몫까지 해 줄 다른 new 처리자의 존재를 알고 있을 가능성도 있겠지요. 만약 그렇다면 현재의 new 처리자 안에서 set_new_handler를 호출하여 다른 new 처리자를 설치할 수 있습니다. operator new 함수가 다시 new 처리자를 호출할 때가 되면, 가장 마지막에 설치된 new 처리자가 호출되는 것입니다.
  • new 처리자의 설치를 제거합니다
    다시 말해, set_new_handler에 널 포인터를 넘깁니다. new 처리자가 설치된 것이 없으면, operator new 는 메모리 할당이 실패했을 때 예외를 던지게 됩니다.
  • 예외를 던집니다
    bad_alloc 혹은 bad_alloc에서 파생된 타입의 예외를 던집니다. operator new에는 이쪽 종류의 에러를 받아서 처리하는 부분이 없기 때문에, 이 예외는 메모리 할당을 요청한 원래의 위치로 전파됩니다.
  • 복귀하지 않습니다
    대게 abort 혹은 exit를 호출합니다.

 할당된 객체의 클래스 타입에 따라 메모리 할당 실패에 대한 처리를 다르게 가져 가고 싶을 때는 해당 클래스에서 자체 버전의 set_new_handler 및 operator new를 제공하도록 만들어 주어야 합니다. 먼저 아래와 같이 new_handler를 가리키는 정적 멤버 데이터를 선언하여 new_handler를 보관하고, Widget에서 set_new_handler 함수는 자신에게 넘어온 포인터를 저장하고 이전의 포인터를 반환하는 코드를 짭니다. 이후에 operator new의 역할만이 남습니다.

class Widget {
public:
    static std::new_handler set_new_handler(std::new_handler p) throw();
    static void * operator new(std::size_t size) throw(std::bad_alloc);
private:
    static std::new_handler currentHandler;
};
 
std::new_handler currentHandler = 0;    // NULL로 초기화합니다.
                                        
std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
    std::new_handler oldHandler = currentHandler;
    currentHandler = p;
    return oldHandler;
}
  1. 표준 set_new_handler에 Widget의 new처리자를 넘긴다. 즉 전역 new처리자로 Widget new처리자를 설치합니다.
  2. 전역 operator_new로 메모리를 할당한다. 할당이 실패하면 설치한 Widget new 처리자가 호출되는데 이것마저도 실패시 예외를 발생시켜, new 처리자를 old new처리자로 교체하고 예외를 던집니다.
  3. 할당에 성공한 경우에는 할당된 메모리를 반환하고, 이와 동시에 전역 new처리자를 자동 복원하게 설계합니다.

이는 아래와 같이 구현됩니다.

class NewHandlerHolder {
public:
    explicit NewHandlerHolder(std::new_handler nh)    // 현재의 new 처리자를
    : handler(nh) {}                                // 획득합니다.
    
    ~NewHandlerHolder(void) {                        // 이것을 해제합니다.
        std::set_new_handler(handler);
    }
private:
    std::new_handler handler;                        // 이것을 기억해 둡니다.
    
    NewHandlerHolder(const NewHandlerHolder&);        // 복사를 막기 위한 부분
    NewHandlerHolder&                                // (항목 14를 참고하세요)
        operator=(const NewHandlerHolder&);
};
 
void * Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
    NewHandlerHolder                                // Widget의 new 처리자를
        h(std::set_new_handler(currentHandler));    // 설치합니다.
        
    return ::operator new(size);                    // 메모리를 할당하거나
                                                    // 할당이 실패하면 예외를 던집니다.
                                                    
}                                                    // 이전의 전역 new 처리자가
                                                    // 자동으로 복원됩니다.
                                                    
// Widget클래스를 사용하는 쪽에서의 예제
 
void outOfMem(void);                // Widget 객체에 대한 메모리 할당이
                                    // 실패했을 때 호출될 함수의 선언
 
Widget::set_new_handler(outOfMem);    // Widget의 new 처리자 함수로서
                                    // outOfMem을 설치합니다.
 
Widget *pw1 = new Widget;            // 메모리 할당이 실패하면
                                    // outOfMem이 호출됩니다.
 
std::string *ps = new std::string;    // 메모리 할당이 실패하면
                                    // 전역 new 처리자 함수가
                                    // (있으면) 호출됩니다.
 
Widget::set_new_handler(0);            // Widget 클래스만을 위한
                                    // new 처리자 함수가 아무것도 없도록
                                    // 합니다(즉, null로 설정합니다).
 
Widget *pw2 = new Widget;            // 메모리 할당이 실패하면 이제는
                                    // 예외를 바로 던집니다.(Widget
                                    // 클래스를 위한 new 처리자 함수가
                                    // 없습니다.

위의 예제를 여러 다른 클래스에 적용해도 비슷한 코드가 만들어질 것 같습니다. 따라서 이 경우 "믹스인(mixin) 양식"의 기본 클래스를 만드는 것을 추천합니다.

template <typename T>                // 클래스별 set_new_handler를
class NewHandlerSupport {            // 지원하는 "믹스인 양식"의
public:                                // 기본 클래스
    static std::new_handler set_new_handler(std::new_handler p) throw();
    static void * operator new(std::size_t size) throw(std::bad_alloc);
    
    ...                                // operator new의 다른 버전들을
                                    // 이 자리에 둡니다. 항목 52를 참고하세요.
private:
    static std::new_handler currentHandler;
};
 
template <typename T>
std::new_handler
NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw()
{
    std::new_handler oldHandler = currentHandler;
    currentHandler = p;
    return oldHandler;
}
 
template <typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size)
    throw(std::bad_alloc);
{
    NewHandlerHolder h(std::set_new_handler(currentHandler));
    return ::operator new(size);
}
 
// 클래스별로 만들어지는 currentHandler 멤버를 널로 초기화합니다.
template <typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = 0;

예외불가(nothrow) new는 영향력이 제한되어 있습니다. 메모리 할당 자체에만 적용되기 때문입니다. 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있습니다.

class Widget { ... };
 
Widget *pw1 = new Widget;                // 할당이 실패하면
                                        // bad_alloc 예외를 던집니다.
 
if (pw1 == 0) ...                        // 이 점검 코드는 꼭 실패합니다.
 
Widget *pw2 = new(std::nothrow) Widget;    // Widget을 할당하다 실패하면
                                        // 0(널)을 반환합니다.
 
if (pw2 == 0) ...                        // 이 점검 코드는 성공할 수 있습니다.

이제 Widget 클래스에 set_new_handler 기능을 추가하는 것은 별로 어려워지지 않게 됩니다. 그저 NewHandlerSupport<Widget>로부터 상속만 받으면 끝이거든요.

class Widget: public NewHandlerSupport<Widget> {
    ...                    // 이전과 같지만, 이제는 set_new_handler 혹은
};                        // operator new에 대한 선언문이 빠져 있습니다.

여기서 new가 예외를 던지지 않는다 하더라도 Widget의 생성자에서 예외를 던질 수도 있습니다. 즉 예외불가 new는 그때 호출되는 operator new에서만 예외가 발생되지 않도록 보장할 뿐, "new(std::nothrow) Widget" 등의 표현식에서 예외가 나오지 않게 막아 준다는 이야기는 아닙니다. 이러한 이유 때문에 십중팔구는 예외불가 new를 필요로 할 일이 없을 것입니다.

이것만은 잊지 말자!

 

  • set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수 지정 가능
  • 예외불가 new는 메모리 할당 자체에만 적용되기 때문에 영향력이 제한되어 있어, 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있음.

50. Understand when it makes sense to replace new and delete

스스로 사용자 정의 new와 delete를 만드는 데에는 보통 다음과 같은 목적을 가집니다.

  • 잘못된 힙 사용을 탐지하기 위해
    new한 메모리에 delete를 하는 것을 잊는다던지, delete한 메모리를 한 번 더 delete한다던지, 데이터 오버런 및 데이터 언더런과 같은 실수들을 잡아내기 위해
  • 효율을 향상시키기 위해
    어디든 적합한 코드보다도 필요한 코드를 만들어 더 좋은 성능을 내기 위해
  • 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
    여러분이 만드는 소프트웨어가 동적 메모리를 어떻게 사용하는지에 대한 정보를 수집하기 위하여 안에 표시 및 Log를 남기기 위해
  • 할당 및 해제 속력을 높이기 위해
    예를 들어 여러분이 만들 응용프로그램은 단일 스레드로 동작하는데 컴파일러에서 기본으로 제공하는 메모리 관리 루틴이 다중 스레드에 맞게 만들어져 있다면, 스레드 안전성이 없는 할당자를 여러분이 직접 만들어 씀으로써 성능 향상을 맛볼 수 있음.
  • 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
    범용 메모리 관리자는 사용자 정의 버전과 비교해서 속력이 느린 경우도 많은데다가 메모리도 많이 잡아먹는 사례가 허다합니다. 할당된 각각의 메모리 블록에 대해 전체적으로 지우는 부담이 꽤 되기 때문입니다. 크기가 작은 객체에 대한 튜닝된 할당자를 사용하면 이러한 오버헤드를 실질적으로 제거할 수 있습니다.
  • 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
    예를 들어 x86 아키텍쳐에서는 double이 8바이트 단위로 정렬되어 있을 때 읽기·쓰기 속도가 가장 빠릅니다. 하지만 일부 컴파일러 중에는 operator new 함수가 double에 대한 동적 할당 시에 8바이트 정렬을 보장하지 않는 것들이 있다는 슬픈 소식이 나돌고 있습니다. 이런 경우, 기본제공 operator new 대신에 8바이트 정렬을 보장하는 사용자 정의 버전으로 바꿈으로써 프로그램 수행 성능을 확 끌어올릴 수 있습니다.
  • 임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아 놓기 위해
    한 프로그램에서 특정 자료구조 몇 개가 대게 한 번에 동시에 쓰이고 있습니다. 이들에 대해서 페이지 부재(page fault)발생 횟수를 최소화하고 싶은 경우, 해당 자료구조를 담을 별도의 힙을 생성함으로써 이들이 가능한 한 적은 페이지를 차지하도록 하면 상당히 좋은 효과를 볼 수 있겠지요. 이러한 메모리 군집화는 위치지정(placement) new 및 위치지정 delete를 통해 쉽게 구현할 수 있습니다.
  • 그때그때 원하는 동작을 수행하도록 하기 위해
    기본제공 new와 delete가 해 주지 못하는 일을 operator new 및 operator delete가 해 주길 바랄 때가 있게 마련입니다. 메모리 할당과 해제를 공유 메모리에다 하고 싶은데 공유 메모리를 조작하는 일은 C API로밖에 할 수 없을 때가 한 가지 예입니다. 이 때 사용자 정의 버전 위치지정 new와 위치지정 delete를 만드는 것입니다. 또 다른 예로는 응용프로그램 데이터의 보안 강화를 위해 해제한 메모리 블록에 0을 덮어 쓰는 사용자 정의 operator delete를 만드는 경우도 생각해 볼 수 있습니다.

이것만은 잊지 말자!

  • 개발자가 스스로 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 나름대로 타당한 이유가 존재합니다.

51. Adhere to convention when writing new and delete

[operator new 함수의 관례]

  • 제대로된 반환값
    요청 메모리를 마련할 수 있는 경우 - 포인터 값 반환
    요청 메모리를 마련할 수 없는 경우 - bad_alloc 타입의 예외
  • 가용 메모리가 부족한 경우 new처리자를 호출
  • 크기가 없는 메로리 요청에 대한 대비책
  • 기본 형태의 new가 가려지지 않도록 구현

operator new는 메모리 할당이 실패할 때 마다 new 처리자 함수를 호출하는 식으로 2회 이상 시도합니다. operator new가 예외를 던지는 경우는 오직 new 처리자 함수에 대한 포인터가 널일 때 뿐입니다. 이를 구현하면 다음과 같이 됩니다.

void * operator new(std::size_t size) throw(std::bad_alloc)
{                                // 여러분의 operator new 함수는 다른
                                // 매개변수를 추가로 가질 수 있습니다.
    using namespace std;
    
    if (size == 0) {            // 0바이트 요청이 들어오면
        size = 1;                // 이것을 1바이트 요구로
    }                            // 간주하고 처리합니다.
    
    while (true) {
        size바이트를 할당해 봅니다;
        if ( 할당이 성공했음 )
            return ( 할당된 메모리에 대한 포인터 );
        
        // 할당이 실패했을 경우, 현재의 new 처리자 함수가
        // 어느 것으로 설정되어 있는지 찾아냅니다(아래를 보세요).
        new_handler globalHandler = set_new_handler(0);
        set_new_handler(globalHandler);
        
        if (globalHandler) (*globalHandler)();
        else throw std::bad_alloc();
    }
}

while true에서 무한 루프를 돌면서 루프를 벗어나오기 위해서는 메모리 할당이 성공하거나, new 처리자 함수 쪽에서 처리를 해줘야하는데, new 처리자 함수에서 직무유기를 해 버릴 경우, operator new의 내부 루프는 절대로 스스로 끝나지 않습니다.

 operator new 멤버 함수는 파생 클로스 쪽으로 상속이 되는 함수인데, 만약 어떤 X라는 클래스를 위한 operator new라는 함수가 존재하고, 이 함수의 동작은 크기가 sizeof(X)에 맞춰져 있다고 합시다. 근데 만약 X를 상속받은 자식 클래스에서 상속받은 operator new를 사용하게 되는 순간 크기가 다르기 때문에 이에 대한 처리를 해주어야 합니다. 흔한 방법으로는 표준 operator new를 호출하도록 합니다.

class Base {
  static void *operator new(std::size_t size) throw(std::bad_alloc)
  ...
};

class Derived: public Base { ... };

Derived *p = new Derived;

void *Base::operator new(std::size_t size) throw(std::bad_alloc)
{
  if (size != sizeof(Base))
    return ::operator new(size);
   
   ...
 };

만약 배열에 대한 메모리 할당을 클래스 전용 방식으로 하고 싶다면 operator new[]를 구현해야합니다. 이 때 주의할 점은 operator new[] 안에서 해 줄 일은 단순히 원시 메모리의 덩어리를 할당하는 것밖엔 없다는 것입니다. 왜냐하면 상속 때문에, 파생 클래스 객체의 배열을 할당하는 데 기본 클래스의 operator new[] 함수가 호출될 수 있는데, 그렇기 때문에 Base::operator new[] 안에서조차 배열에 들어가는 객체 하나의 크기가 sizeof(Base)라는 가정을 할 수 없습니다. 이 말을 풀이해 보면, Base::operator new[]에서 할당한 배열 메모리에 들어가는 객체의 개수를 (요구된 바이트 수 / sizeof(Base))로 계산할 수 없다는 뜻입니다. 또한, operator new[]에 넘어가는 size_t 타입의 인자는 객체들을 담기에 딱 맞는 메모리 양보다 더 많게 설정되어 있을 수도 있습니다. 왜냐하면 동적으로 할당된 배열에는 배열 원소의 개수를 담기 위한 자투리 공간이 추가로 들어가기 때문입니다.

 

operator delete 함수는 널 포인터가 들어왔을 때 아무 일도 하지 않아야 합니다. 

void operator delete(void * rawMemory) throw()
{
    if (rawMemory == 0) return;        // 널 포인터가 delete되려고 할 경우에는
                                    // 아무것도 하지 않게 합니다.
    
    rawMemory 가 가리키는 메모리를 해제합니다;
}

클래스 전용 버전의 경우에는 예정 크기보다 더 큰 블록을 처리해야 합니다.

class Base {                            // 이전과 같으나, 지금은
public:                                    // operator delete가 선언된 상태
    static void * operator new(std::size_t size) throw(std::bad_alloc);
    static void operator delete(void *rawMemory, std::size_t size) throw();
    ...
};
 
void Base::operator delete(void *rawMemory, std::size_t size) throw()
{
    if (rawMemory == 0) return;            // 널 포인터에 대한 점검
    
    if (size != sizeof(Base)) {            // 크기가 "틀린" 경우
        ::operator delete(rawMemory);    // 표준 operator delete가
        return;                            // 메모리 삭제 요청을 맡도록 합니다.
    }
    
    rawMemory가 가리키는 메모리를 해제합니다;
    
    return;
}

중요한 점을 하나 더 말씀드리자면, 가상 소멸자가 없는 기본 클래스로부터 파생된 클래스의 객체를 삭제하려고 할 경우에는 operator delete로 C++가 넘기는 size_t 값이 엉터리일 수 있습니다. 이것만으로도 기본 클래스에 가상 소멸자를 꼭 두어야 하는 충분한 이유가 선다고 말씀드릴 수 있을 것 같습니다. (기본 클래스에서 가상 소멸자를 빼먹으면 operator delete 함수가 똑바로 동작하지 않을 수 있다는 사실을 잊지 마세요)

이것만은 잊지 말자!

  • 관례적으로, operator new 함수는 메모리 할당을 반복해서 시도하는 무한루프를 가져야 하고, 메모리 할당 요구를 만족시킬 수 없을 때 new 처리자를 호출해야 하며, 0바이트에 대한 대책도 있어야 합니다. 클래스 전용 버전은 자신이 할당하기로 예정된 크기보다 더 큰 메모리 블록에 대한 요구도 처리해야 합니다.
  • operator delete 함수는 널 포인터가 들어왔을 때 아무 일도 하지 않아야 합니다. 클래스 전용 버전의 경우에는 예정 크기보다 더 큰 블록을 처리해야 합니다.

52. Write placement delete if you write placement new

operator new 함수의 위치지정(placement) 버전을 만들 때는, 이 함수와 짝을 이루는 위치지정 버전의 operator delete 함수도 꼭 만들어 주세요. 이 일을 빼먹었다가는, 찾아내기도 힘들며 또 생겼다가 안 생겼다 하는 메모리 누출 현상을 경험하게 됩니다.

Widget *pw = new Widget;


위의 코드에서는 먼저 operator new 함수가 불려 메모리를 할당한 뒤 Widget의 생성자가 호출됩니다.  그런데 Widget의 생성자에서 예외가 발생한 경우 프로그래머는 메모리 누수를 해결할 수 없습니다. 동적 할당 받은 메모리의 시작 주소가 pw에 담기기도 전에 예외가 던져지기 때문이죠. 이 경우 메모리의 해제는 C++ 런타임 시스템께서 맡이 주시게 됩니다. 하지만 C++ 런타임 시스템이 알아서 메모리를 해제하려면 new와 짝이 맞는 delete를 알아야 합니다.  일반적인 경우 이것은 큰 문제가 되지 않습니다. 왜냐하면 기본형 operator new는 기본형 operator delet와 짝을 맞추기 때문입니다.

void operator delete(void *rawMemory) throw();    // 전역 유효범위에서의 기본형 시그너처
 
void operator delete(void *rawMemory,            // 클래스 유효범위에서의 전형적인 기본형 시그니처
                    std::size_t size) throw();

따라서 표준 형태의 new 및 delete만 사용하는 한, 런타임 시스템은 new의 동작을 되돌릴 방법을 알고 있는 delete을 문제없이 찾아낼 수 있습니다. 하지만 operator new의 기본형이 아닌 형태를 선언하기 시작하면 new와 delete짝을 맞추는 데 문제가 뽀송뽀송 피어나게 됩니다. 비기본형이란 바로 다른 매개변수를 추가로 갖는 operator new를 뜻합니다. (이런 operator new를 위치지정 (placement) new 라고 합니다.)

 operator new를 호출하는 데 cerr을 ostream 인자로 넘기는데, 이 때 Widget 생성자에서 예외가 발생하면 메모리가 누출됩니다.

class Widget {
public:
    ...
    static void* operator new(std::size_t size,            // 비표준 형태의
                              std::ostream& logStream)    // operator new
        throw(std::bad_alloc);
    static void operator delete(void *pMemory            // 클래스 전용
                                size_t size) throw();    // operator delete의
                                                        // 표준 형태
    ...
};
 
Widget *pw = new(std::cerr) Widget;

위에서 호출된 new는 ostream& 타입의 매개변수를 추가로 받아들이므로, 이것과 짝을 이루는 operator delete 역시 똑같은 시그너처를 가진 것이 마련되어 있어야 합니다.

void operator delete(void *, std::ostream&) throw();

이러한 operator delete를 위치지정 delete라고 합니다. 따라서 예제에서 위치지정 delete를 추가해 주면 메모리 누출 위험이 사라집니다.

class Widget {
public:
    ...
    static void* operator new(std::size_t size, std::ostream& logStream)
        throw(std::bad_alloc);
    static void operator delete(void *pMemory) throw();
    static void operator delete(void *pMemory, std::ostream& logStream)
        throw();
    ...
};
 
Widget *pw = new(std::cerr) Widget;    // 이전과 같은 코드이지만
                                    // 메모리 누출이 없습니다.
 
delete pw;    // 기본형의 operator delete가 호출됩니다

일반적인 경우에는 위치지정 delete 대신 기본형의 operator delete 가 호출됩니다.

 new 및 delete의 위치지정 버전을 선언할 때는, 의도한 바도 아닌데 이들의 표준 버전이 가려지는 일이 생기지 않도록 주의해 주세요.

class Base {
public:
    ...
    static void* operator new(std::size_t size,            // 이 new가
                              std::ostream& logStream)    // 표준 형태의
        throw(std::bad_alloc);                            // 전역 new를 가립니다.
    ...
};
 
Base *pb = new Base;                // 에러! 표준 형태의 전역
                                    // operator new가 가려지거든요.
 
Base *pb = new(std::cerr) Base;        // 이건 문제없습니다. Base의
                                    // 위치지정 new를 호출합니다.

파생 클래스는 전역 operator new는 물론이고 자신이 상속받는 기본 클래스의 operator new까지 가려 버립니다.

class Derived: public Base {                    // 위의 Base로부터 상속받은 클래스
public:
    ...
    static void* operator new(std::size_t size)    // 기본형 new를 클래스 전용으로
        throw(std::bad_alloc);                    // 다시 선언합니다.
    ...
};
 
Derived *pd = new(std::cerr) Derived;    // 에러! Base의 위치지정
                                        // new가 가려져 있기 때문입니다.
 
Derived *pd = new Derived;                // 문제없습니다. Derived의
                                        // operator new를 호출합니다.

C++가 전역 유효 범위에서 제공하는 operator new의 형태는 다음 세 가지가 표준입니다.

void* operator new(std::size_t) throw(std::bad_alloc);    // 기본형 new
 
void* operator new(std::size_t, void*) throw();            // 위치지정 new
 
void* operator new(std::size_t,                            // 예외불가 new
                   const std::nothrow_t&) throw();        // (항목 49 참조)

 어떤 형태든 간에 operator new가 클래스 안에 선언되는 순간, 앞의 예제처럼 위의 표준 형태들이 몽땅 가려지는 것입니다. 다음과 같은 한 가지 해결책이 있습니다. 기본 클래스 하나를 만들고, 이 안에 new 및 delete의 기본 형태를 전부 넣어두십시오.

class StandardNewDeleteForms {
public:
    // 기본형 new/delete
    static void* operator new(std::size_t size) throw(std::bad_alloc)
    { return ::operator new(size); }
    
    static void operator delete(void *pMemory) throw()
    { ::operator delete(pMemory); }
    
    // 위치지정 new/delete
    static void* operator new(std::size_t size, void *ptr) throw()
    { return ::operator new(size, ptr); }
    
    static void operator delete(void *pMemory, void *ptr) throw()
    { return ::operator delete(pMemory, prt); }
    
    // 예외불가 new/delete
    static void* operator new(std::size_t,
                              const std::nothrow_t& nt) throw()
    { return ::operator new(size, nt); }
    
    static void operator delete(void *pMemory,
                                const std::nothrow_t&) throw()
    { ::operator delete(pMemory); }
};

표준 형태에 덧붙여 사용자 정의 형태를 추가하고 싶다면, 이제는 이 기본 클래스를 축으로 넓혀 가면 됩니다. 상속과 using 선언을 2단 콤보로 사용해서 표준 형태를 파생 클래스 쪽으로 끌어와 외부에서 사용할 수 있게 만든 후에, 원하는 사용자 정의 형태를 선언해 주세요.

class Widget: public StandardNewDeleteForms {        // 표준 형태를 물려받습니다.
public:
    using StandardNewDeleteForms::operator new;        // 표준 형태가 (Widget
    using StandardNewDeleteForms::operator delete;    // 내부에) 보이도록 만듭니다.
    
    static void* operator new(std::size_t size,        // 사용자 정의 위치지정
                        std::ostream& logStream)    // new를 추가합니다.
        throw(std::bad_alloc);
        
    static void operator delete(void *pMemory,        // 앞의 것과 짝이 되는
                        std::ostream& logStream)    // 위치 지정 delete를
        throw();                                    // 추가합니다.
    ...
};

이것만은 잊지 말자!

  • operator new 함수의 위치지정 버전을 만들 때는, 이 함수와 짝을 이루는 위치지정 버전의 operator delete 함수도 꼭 만들어 주세요. 이를 빼먹으면 찾아내기도 힘들며 또 생겼다가 안 생겼다 하는 메모리 누출 현상을 경험하게 됩니다.
  • new 및 delete의 위치지정 버전을 선언할 때는, 의도한 바도 아닌데 이들의 표준 버전이 가려지는 일이 생기지 않도록 주의해 주세요.

41. Understand implicit interfaces and compile-time polymorphism

객체 지향 프로그래밍의 세계를 회전시키는 축은 명시적 인터페이스(explicit interface) 런타임 다형성(runtime polymorphism)입니다. 이를 밑의 코드에서 살펴봅시다.

class Widget {
	public :
	  Widget();
	  virtual ~Widget();
	  virtual std::size_t size() const;
	  virtual void normalize();
	  void swap(Widget& other);
	  ...
};

void doProcessing(Widget& w)
{
  if(w.size() > 10 && w != someNastyWidget)
  {
    Widget temp(w);
    temp.normalize();
    temp.swap(w);
  }
}

 

  • 명시적 인터페이스 : 함수와 변수가 명시적으로 드러나, w는 Widget 인터페이스를 지원해야하며 이는 소스코드에서 이를 확인할 수 있습니다. 이는 함수의 이름, 매개변수 타입, 반환 타입와 같은 시그너처로 이루어져 명시적 인터페이스를 구성하고 있습니다. 
  • 런타임 다형성 : Widget의 가상함수는 w의 동적 타입을 기반으로 런타임에 결정됩니다.

반면 템플릿과 일반화 프로그래밍은 다른 부분이 존재해서, 중요한 포인트는 암시적 인터페이스(implicit interface)컴파일 타임 다형성(compile-time polymorphism)입니다. 위와의 차이점은 타입을 템플릿으로 변경한 점 입니다.

template<typename T>
void doProcessing(T& w)
{
	if (w.size() > 10 && w != someNastyWidget) {
	    Ttemp(w);
	    temp.normalize();
	    temp.swap(w);
	  }
}
  • 암시적 인터페이스 : 위와 같은 경우 T는 특정 멤버 함수 및 복사생성자 및 부등비교 연산도 지원해야 합니다. 또한 비교 연산자 및 메소드들은 비교 타입으로 암시적인 변환이 가능해야만 합니다. 이와 같이 몇개의 표현이 유효해야만 합니다. 암시적 인터페이스는 함수 시그너처에 기반하지 않습니다. 암시적 인터페이스를 이루는 요소는 유효 표현식입니다. 즉, 안의 표현이 어떤 타입에 대해서 사용 가능한지 아닌지를 정합니다.
  • 컴파일 타임 다형성 : w가 수반된 함수 호출이 일어날 때, 이에 해당하는 템플릿의 인스턴스화가 필요로 되는데 이는 컴파일 도중에 일어납니다. 어떤 템플릿 매개변수가 들어가느냐에 따라 호출되는 함수가 달라집니다.

이것만은 잊지 말자!

  • 클래스 및 템플릿은 모두 인터페이스와 다형성을 제공한다.
  • 클래스의 경우, 인터페이스는 명시적이며 시그너처(반환,매개변수) 중심으로 구성되어있다. 다형성은 런타임에 가상함수를 통해 나타난다.
  • 템플릿의 경우, 인터페이스는 암시적이며 유효표현식을 기반으로 둔다. 다형성은 템플릿인스턴스화와 함수 오버로딩 과정(컴파일 타임)에서 나타난다.

42. Understand the two meanings of typename

 템플릿 매개변수 선언 시에는 class와 typename은 차이점을 보이지 않습니다.

template<class T> class Widget;    // class 사용
template<typename T> class Widget; // typename 사용

 

 하지만 class 와 typename 키워드가 다르게 사용되는 경우가 존재합니다.

template<typename C>
void print2nd(const C& container) //컨테이너에 들어 있는 2번째 원소를 출력합니다.
{
	if (container.size() >= 2 ) {
        C::const_iterator * x; // error
        C::const_iterator iter(container.begin());//첫째 원소에 대한 반복자를 얻습니다.
	
        ++iter;             // 두번째원소로 iter를 옮깁니다.
        int value = *iter;  // value에 값을 복사합니다.
        std::cout << value; 1`// value을 출력합니다.
  }
}

템플릿 내의 이름 중(C::const_iterator iter)에 위와 같이 템플릿 매개변수에 종속된 것을 가리켜 의존 이름(dependent name)이라고 합니다. 클래스 안에 중첩되어 있는 경우에는 중첩 의존 이름(nested dependent name)이라고 부릅니다. value는 템플릿 매개변수와 상관없는 타임 이름이므로, 비의존 이름(non-dependent)이라고 합니다. 중첩 의존이 코드안에 있을 때 문제가 발생합니다.    만약 C::const_iterator가 타입인 경우 x는 지역변수를 선언하게 됩니다. 하지만 C가 const_iterator라는 정적 데이터 멤버이며, x가 전역변수라면 컴파일러는 두 데이터를 곱셈 연산한다고 생각할 것입니다. 따라서, 바로 이 경우에 데이터가 아닌 타입이라는 것을 명시하기 위하여 C::const_iterator 앞에 typename이라는 키워드를 명시해줍니다. 따라서 앞선 코드에서 정의 부분을 다음과 같이 고칠 수 있습니다.

C::const_iterator iter(container.begin());// 틀릴 수도 있는 애매한 표현
typename C::const_iterator iter(container.begin());// typename을 사용하여 명시적 표현

즉, typname 키워드는 중첩 의존 이름 식별에만 사용이 가능하고 다른 이름에는 불가능합니다. 하지만 상속되는 기본클래스의 리스트에 있거나, 초기화 리스트 내에 기본클래스 식별자일 때는 예외로서 작동합니다. 이는 밑에 코드를 통해 확인 가능합니다.

template <typename T>
class Derived: public Base<T>::Nested { // 예외 1. 상속되는 기본클래스 리스트
	public :
		explicit Derived(int x) : Base<T>::Nested(x) { // 예외 2. 초기화리스트내에 기본클래스 식별자

		typename Base<T>::Nested temp;// 중첩의존타입의 이름
		typedef typename std::iterator_traits<T>::value_type value_type;
	    …
	  }
	  …
};

이것만은 잊지 말자!

  • 템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방합니다.
  • 중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용해야 한다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있을 경우는 예외이다.

43. Know how to access names in templatized base classes

class CompanyA { //회사 A
	public :
		...
		void sendCleartext(const string& msg);
		void sendEncrypted(const string& msg);
		...
};

class CompanyB { //회사 B
	public :
  ...
	void sendCleartext(const string& msg);
	void sendEncrypted(const string& msg);
  ...
};

class MsgInfo { ... };// 메시지 생성을 위한 클래스
template<typename Company>
class MsgSender{
public :
  ...
  void sendClear(const MsgInfo& Info)
  {
    string msg;

    Company c;
    c.sendCleartext(msg);
  }

void sendSecret(const MsgInfo& Info)
  {
    ...
  }
};

먼저 기본적으로 다음과 같은 코드가 있다고 가정합니다. 이떄 다음과 같이 기본 클래스 템플릿을 상속 받아 만들어지는 클래스 템플릿에서 함수를 정의하였습니다.

template<typename Company>
class LoggingMsgSender : public MsgSender<Company> {
public :
  ...
void sendClearMsg(const MsgInfo& info)
  {
	//"메시지 전송전 로그 기록"
	sendClear(info);
	//"메시지 전송후 로그 기록"
  }
};

 이 때 sendClear에서 에러가 발생하게 되는데, sendClear의 존재 부재가 그 사유 입니다. 이는 먼저 자식 클래스에서 기본 클래스 안의 sendClear 함수를 확인해야 하는데, 기본 클래스가 Company를 매개변수로 받는 템플릿이고 이를 사전에 파악하기 어렵기 때문에 기본 클래스에 대해서 알기 어렵습니다. 

 왜냐하면 템플릿 특수화를 통해서 Company를 매개변수를 받는 경우에는 snedClear 함수를 없애는 식으로도 코드를 만들어 나갈 수가 있기 때문입니다. 따라서 기본클래스 템플릿을 상속받은 파생 클래스 템플릿은 기본 클래스 안의 이름에 접근하지 않기에 상속의 기본 매커니즘(컴파일 타임 상속 체계)이 끊기게 됩니다.

 이를 해결하기 위해서는 기본 클래스 템플릿이 특수화 되더라도 원래의 일반형 템플릿에서 제공하는 인터페이스를 그대로 제공할 것이라고 컴파일러에게 약속을 해야합니다. 그 방법은 다음과 같습니다.

  1. 기본클래스 함수에 대한 호출문 앞에 this->를 붙여 확실하게 상속된다는 것을 명시합니다.
  2. using 선언을 통해 기본 클래스에 함수가 있음을 명시합니다.
  3. 기본 클래스 명시합니다 (MsgSender<Company>::sendClear) 하지만 이 경우는 가상함수 바인딩이 무시되기 때문입니다.

즉 본질적인 논점은 기본 클래스의 멤버에 대한 참조가 무효한지를 컴파일러가 진단하는 과정을 구문 분석할 때 들어가느냐 아니면 이후에 인스턴스화 될 떄 들어가느냐를 정하는 것입니다,. C++은 기본 정책으로 이른 진단을 하기 떄문에 이를 미루는 것입니다.

이것만은 잊지 말자!

  • 파생클래스 템플릿에서 기본 클래스 템플릿의 이름을 참조할 때는, "this->"접두사로 붙이거나 기본 클래스 한정문을 명시적으로 써주자.

44. Factor parameter-independent code out of templates

아무런 생각 없이 템플릿을 사용하면 코드 비대화를 초래할 수 있습니다. 이를 위해서 함수 측면에서 살펴보아, 두함수에서 공통적으로 사용되는 로직은 따로 함수화 하여, 그 함수를 각각의 함수에서 호출해주는 방식에 해당하는 공통성 및 가변성을 수행합니다.

template<typename T, std::size_t n>
// T타입의 객체를 원소로 하는 n행 n열의 행렬을 나타내는 템플릿
class SquareMatrix {
	public :
	void invert(); // 주어진 행렬을 그 저장공간에서 역행렬로 만든다.
};

위와 같이 고정 크기의 정방행렬을 나타내는 클래스 템플릿에서 템플릿은 T라는 타입 매개변수도 받고, size_t라는 비타입 매개변수도 받습니다. 이때 두 개의 모두 휘둘리기에 코드 비대화가 일어납니다. 이때 이를 줄이기 위해 타입 그대로 사용하고 함수의 크기만을 매개변수로 받아 따로 처리해줍니다.

template<typename T>
class SquareMatrixBAse {
	protected:
		void invert(std::size_t matrixsize);// 매개변수로 받는 별도의 함수
	…
};

template<typename T, std::size_t n)
	class SquareMatrix : private SquareMatrixBase<T> {
	private:
		using SquareMatrixBase<T>::invert;
	public:
	  …
	void invert() {this->invert(n); }// 암묵적 인라인 호출
};

 

이와 같이 구현한 경우에, SquareMatrixBase::invert()는 자신이 상대할 데이터가 어디 있는지 모르는 문제가 발생합니다. 따라서 정방행렬의 메모리 위치를 파생클래스가 기본클래스로 넘겨주려고 합니다. SquareMatrixBase::invert() 함수가 매개변수로 행렬의 포인터를 받지만 invert()함수처럼 행렬의 시작주소를 갖는 포인터를 필요로 하는 함수가 있다면, 그 함수들의 매개변수로 포인터가 추가되어야 하므로 비효율적이게 됩니다. 따라서, Base 자체가 포인터를 저장하는구조로 구현해 보았습니다.

template<typename T>
class SquareMatrixBase{
	protected:
	  SquareMatrixBase(std::size_t n, T *pMem) : size(n), pData(pMem) {}
		void setDataPtr(T*ptr) { pData = ptr; }
	private:
	  std::size_t size;
	  T *pData;
};

 

하지만 이런 경우에는 메모리 할당 권한이 파생클래스 쪽으로 넘어가기에 행렬 데이터를 객체 안에 데이터 멤버로 직접 넣어 구현하였습니다.

template<typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBAse<T> {
	public:
	  SquareMatrix() : SquareMatrixBase<t>(n, data) {}
	  …
	private:
	  T data[n*n];
};

동적메모리 할당은 필요 없지만, 객체 자체의 크기가 커질 수 있습니다. 이와 같은 방법을 통해 다음과 같은 이점을 얻을 수 있습니다.

  • SquareMatrix에 속해 있는 멤버 함수 중 상당수가 기본클래스 버전을 단순 인라인 호출 가능해집니다.
  • 행렬 크기에 상관 없이 기본 클래스의 사본 하나를 공유하게 됩니다.
  • 저마다의 고유타입을 가지고 있다. 즉, <double, 5>와 <double,1>이 <double> 멤버함수를 사용하고 있더라도, 사실상 이 둘의 타입이 다르기 때문에, <double,5>가 <double,10>의 객체를 날름 받아먹으려고 할때 컴파일러가 가만두지 않는다.

비타입 매개변수만이 비대화의 원인은 아니다. 상당수의 플랫폼에서 int와 long은 이진 표현 구조가 동일하기에 이들로 인스턴스화되는 템플릿들은 어떤 환경에서는 코드 비대화를 일으킬 수 있습니다. 대부분의 플랫폼에서 포인터 타입은 똑같은 이진 표현구조를 갖고 있기 때문에, 포인터 타입을 매개변수로 취하는 동일 계열의 템플릿들은 이진 수준에서만 보면 멤버 함수 집합을 한 벌만 써도 되어야 합니다. 타입 제약이 엄격한 포인터를 써서 동작하는 멤버 함수를 구현할 때는 하단에 타입미정 포인터(void*)로 동작하는 버전을 호출하는 식으로 만들 수 있습니다.

이것만은 잊지 말자!

  • 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어진다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 된다.
  • 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 없앨 수 있다.
  • 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있다.

45. Use member function templates to accept “all compatibletypes.”

보통의 포인터도 스마트 포인터로 대신할 수 없는 특징을 갖고 있는데 그 중 하나가 암시적 변환을 지원한다는 점입니다. 보통의 포인터에서 파생클래스는 기본클래스로 변환이 가능하며, 비상수객체에서 상수객체로서의 암시적 변환이 가능합니다. 스마트 포인터는 두 스마트 포인터 간의 변환 방법을 찾지 못하기 때문에 암시적변환기능을 지원하지 않습니다. 하지만, 스마트포인터는 멤버 함수 템플릿을 통해 암시적 변환 기능을 얻어 낼 수 있습니다.

template<typename T>
class SmartPtr{
	public :
		template<typename U>// "일반화된 복사 생성자"를 만들기 위해 마련한 멤버 템플릿
		  SmartPtr<const SmartPtr<U>& other);
		  …
};

 위의 코드는 SmartPtr<U>로부터 SmartPtr<T>로의 변환을 가능케 합니다. 이를 일반화 복사 생성자라 합니다. 여기서 explicit 키워드가 만약 존재한다면 명시적으로만 변환이 가능하기 때문에 암시적 변환 지원을 위해서는 explicit 키워드가 없어야 합니다. 하지만 위의 코드에서는 스마트 포인터는 결국 기본포인터의 기능을 갖춰야 하는데 위 코드는 SmartPtr<double>로부터 SmartPtr<int>로 변환이 가능하게 되버려 기본적인 C++ 규칙과 척을 집니다. 이를 바로잡기 위해서 다음과 같이 코드를 변경하였습니다.

template<typename T>
class SmartPtr{
	public :
	template<typename U>
	  SmartPtr(const SmartPtr<U>& other) : heldPtr(other.get()) { … }
	
	  T *get()const {return heldPtr; }
	  …
	private :
	  T *heldPtr;// SmartPtr에 담긴 기본제공 포인터
	};

SmartPtr<T>의 데이터 멤버인 T* 타입의 포인터를 SmartPtr<U>에 들어 있는 U* 타입의 포인터로 먼저 초기화하여 문제를 해결하였습니다. 이러한 멤버 함수 템플릿의 활용은 생성자 뿐만 아니라 대입 연산에도 적용시킬 수 있습니다.

 멤버함수 템플릿은 코드 재사용만큼이나 훌륭한 기능이지만, C++언어의 기본 규칙까지는 바꾸진 않습니다. 만약 tr1::shared_ptr객체가 자신과 동일한 타입의 다른 tr1::shared_ptr객체로부터 생성되는 상황이라면, 컴파일러는 복사 생성자 템플릿 인스턴스화와 복사 생성자 중에 복사 생성자를 선택하게 됩니다. 만약 동일 타입의 일반화 복사생성자를 생성하지 않았다면, 보통의 복사생성자가 만들어질 것입니다. 따라서 어떤 클래스의 복사생성을 전부 여러분의 손아귀에 넣고 싶다면, "보통의" 복사 생성자까지 직접 선언해야 합니다.

이것만은 잊지 말자!

  • 호환되는 모든 타입을 받아들이는 멤버함수를 만들려면 멤버 함수 템플릿을 사용합시다.
  • 일반화된 복사 생성 연산과 일반화된 대입연산을 위해 멤버 템플릿을 선언했다 하더라도, 보통의 복사생성자와 복사 대입 연산자는 여전히 직접 선언해야 한다.

46. Define non-member functions inside templates when type conversions are desired

template<typename T>
class Rational{
	public :
	  Rational(const T& numerator = 0,const T& denominator = 1);
		const Tnumerator()const;
		const Tdenominator()const;
	  …
};

template<typename T>
const Rational<T>operator* (const Rational<T>& lhs,const Rational<T>& rhs)
{ … }

Rational<int> oneHalf(1, 2);// Rational이 템플릿이라는 점만 빼면 항목24의 예제와 동일하다.
Rational<int> result = oneHalf * 2;// Error ! 왜 ?

 이는 컴파일러가 해석할 때 어떤 함수를 호출하려는 지에 대해 아는 것이 없습니다. 확실히 아는 것은, Rational<T>타입의 매개변수 두 개를 받아들이는 operator *라는 함수를, 자신이 어떻게든 인스턴스화 해야 된다는 것입니다. 따라서 먼저 T에 대한 타입을 알야하는데, 위의 코드에서 int로 선언이 되어 있기 때문에 Rational<int> 타입이 추론 가능합니다. 하지만 두번째 매개변수 "2"는 이 타입이 아닙니다. 템플릿 인자추론과정에서 암시적 타입 변환을 고려하지 않기 때문에, 적절한 함수를 찾지 못하게 됩니다.

 이를 해결하기 위해서는 템플릿 인자추론은 템플릿안에 프렌드 함수를 넣어 operator *를 프렌드 함수로 선언합니다. 템플릿 인자 추론은 함수 템플릿에만 좌우되며, 클래스 템플릿에는 영향을 주지 않게 되어, T의 정확한 정보는 Rational<T>클래스가 인스턴스화 될때 알 수 있게 됩니다.

template<typename T>
class Rational {
	public :
	  ...
		friend const Rationaloperator*(const Rational& lhs,const Rational& rhs);
		// operator*함수를 선언
};

template<typename T>
const Rational<T>operator *(const Rational<T>& lhs,const Rational<T>& rhs)
// operator* 를 정의
{ ... }

 이전과 달리 Rational<int>를 지원하는 operator*를 인스턴스화 시켰고 암시적 변환을 통해서 int가 Rational<int>으로 변환하였기 때문입니다. 하지만 이는 링크 에러가 발생할 수 있습니다. 왜냐하면 함수가 Rational 안에서만 선언이 되었을 뿐, 정의가 되어있지 않기 때문입니다. 따라서, operator* 함수 본문을 선언부와 붙여 사용 가능합니다.

template<typename T>
class Rational {
	public :
		...
		friend const Rationaloperator*(const Rational& lhs,const Rational& rhs);
		// operator*함수를 선언
		{ return Rational( lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
	};
};

 마지막으로, 위 코드의 문제점은 클래스 안에 정의된 함수의 본문은 인라인으로 선언됩니다. 따라서 클래스 바깥에 본문을 분리하여 호출하면 암시적 인라인 선언의 영향을 최소화 할 수 있습니다.

이것만은 잊지 말자!

  • 모든 매개변수에 대해 암시적 타입 변환을 지원하는 템플릿과 관계가 있는 함수를 제공하는 클래스 템플릿을 만들려고 한다면, 이런 함수는 클래스 템플릿 안에 프렌드 함수로서 정의합시다

47. Use traits classes for information about types

템플릿은 컴파일 타임에 인스턴스화 됩니다. 따라서 다양한 템플릿 클래스를 처리하는 함수를 만들 때 우리는 특성 정보 클래스를 이용하여 컴파일 타임에 해당 클래스가 어떤 타입인지 밝혀냅니다. STL에는 advance라는 템플릿이 존재하여 반복자를 지정된 거리만큼 이동시킵니다.

template<typename IterT,typename DistT>
void advance(IterT& iter, DistT dist)
{
	...
}

 

먼저, advance를 살펴보기 전에 반복자의 종류는 5가지가 존재하며 다음과 같다.

  1. input iterator : 전진만 가능, 한번에 하나만 읽기 가능
  2. Output iterator : 전진만 가능, 한번에 하나만 쓰기 가능
  3. Forward iterator : 전진만 가능, 한번에 여러 개 읽고,쓰기 가능
  4. Bidirectional iterator : 전/후진 모두 가능, 한번에 여러 개 읽고, 쓰기 가능
  5. Random access iterator : 반복자를 임의의 위치만큼 전/후진 가능 (가장 강력하며, 반복자 산술 연산을 통해 상수시간에 계산가능한 반복자)
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d); // iter를 d단위만큼 전진시킨다, d<0이면 iter를 후진시킨다.

 어쨌든, 이러한 다섯 개의 반복자를 식별하기 위해 다음과 같은 "태그(tag) 구조체"가 C++ 표준 라이브러리에 정의되어 있습니다.

struct input_iterator_tag{};
struct output_iterator_tag{};
struct forward_iterator_tag : public input_iterator_tag{};
struct bidirectional_iterator_tag : public forward_iterator_tag{};
struct random_access_iterator_tag : public bidirectional_iterator_tag{};

 

 advance가 정말로 하고싶은 것은 iterator를 종류별로 구분하는 하여 최적화를 시키는 것입니다. 이를 위해 특성정보(traits)를 이용할 수 있습니다. 특성정보란, 컴파일 도중에 어떤 주어진 타입의 정보를 얻을 수 있게 하는 객체로 문법 구조가 아닌 구현 기법입니다. 특성 정보는 구조체로 구현하나, 특성정보 클래스라 부르며 기본 제공 타입에 대해 사용 가능 해야합니다. 이를 위해, 표준적은 특성정보를 담은 기본템플릿과, 특수화 버전(특정기능을 위해, ex. random_access_iterator)의 템플릿이 요구됩니다.

 사용자 정의 반복자 타입에 대한 구현을 요구하며, 사용자 정의타입으로 하여금 iterator_category라는 이름의 타입을 가질 것을 요구합니다.

template < ... >
	class deque {
		public :
	class iterator {
		public:
			typedef random_access_iterator_tag iterator_category;// 요 부분!
	    ...
	  };
	...
};

위 예제에서 사용자 정의한 타입인 ranrandom_access_iterator_tag iterator_category을 iterator_traits 구조체에서 정의합니다.

표준 iterator_traits클래스를 만들었다면, 부분특수화를 통해 기본 제공 타입에 대한 버전도 만들어줘야한다.

template<class _Iter>
struct iterator_traits
{
	typedef typename _Iter::iterator_category iterator_category;
};


template<class IterT>
struct iterator_traits<IterT *>
{
	typedef random_access_iterator_tag iterator_category;
};

예제에서 iterator_category라는 이름을 typedef 하고 있으며 이 타입을 iterator_traits로 넘깁니다. 그럼 iterator_traits에서 deque::iterator_category 타입을 다시 iterator_category 로 typedef 하였습니다. 그 다음 iterator_traits 자체를 템플릿 인자를 넘겨 random_access_iterator_tag 구조체를 생성합니다.advance함수는 인자로 넘어온 iterator에 맞는 로직처리를 진행할 것 입니다.이것을 if else로 실행타임에 분기하는 것이 아닌 오버로딩을 이용하여 컴파일 타임에 해결하는 것이다.

template<class IterT,class DistT>
void advance(ItrerT& iter, DistT d)
{
	doAdvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

template<class IterT,class DistT>
void doAdvance(ItrerT& iter, DistT d, std::random_access_iterator_tag)
{
	// ...
}

template<class IterT,class DistT>
void doAdvance(ItrerT& iter, DistT d, std::input_iterator_tag)
{
	// ...
}

이것만은 잊지 말자!

  • 특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관련 정보를 만들어낸다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현한다.
  • 함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if... else 기능을 구사할 수 있다.

48. Be aware of template metaprogramming

TMP(Template Meta Programming)란 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다. TMP프로그램이 실행을 마친 후엔, 그 결과로 나온 출력물(템플릿으로부터 인스턴스화된 C++코드)이 다시 보통의 컴파일 과정을 거친다. 이는 다음과 같은 특징을 가진다.

  • TMP를 쓰면 다른 방법으로는 까다롭거나 불가능한일을 쉽게 할 수 있다.
  • 템플릿 메타프로그램은 C++컴파일이 진행되는 동안 실행되기 때문에, 기존 작업을 런타임 영역 -> 컴파일영역으로 전환할 수 있다.
  • 실행 에러를 컴파일 도중에 찾을 수 있다.
  • 효율적이다. -> 실행 코드, 실행시간, 메모리 차지 축소

이것만은 잊지 말자!

  • 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 냅니다. 따라서, TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있습니다.
  • TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는것을 막는 데도 쓸 수 있습니다.

 

32. Make sure public inheritance models “is-a.” 

C++ 객체 지향 프로그래밍에서 public 상속은 is-a 관계를 의미합니다.

  • 자식 클래스로 만들어진 모든 객체는 부모 클래스에 해당되어야 합니다
  • 부모 클래스는 자식 클래스보다 더 일반적인 개념에 해당합니다
  • 자식 클래스 객체가 사용가능한 곳에는 부모 클래스 객체도 사용 가능합니다
  • 모든 자식 클래스는 부모 클래스의 일종이지만, 부모 클래스는 자식 클래스의 일종이 아닙니다

개념과 실제 구현하는 것은 인식과 다르게 행해질 수도 있습니다. 예를 들면 새가 기본적으로 날 수 있기에 그런 함수를 구현 하였지만 펭귄은 새이지만 날 수 없습니다. 이러한 실제 인식과 다른 부분을 의식해가면 설계 해나가야합니다.

 또한 다른 예시로 정사각형과 직사각형이 존재합니다. 직사각형은 당연하게도 정사각형의 부모클래스로 인식됩니다. 여기서 만약 직사각형에서 폭과 넓이를 늘려주는 함수가 존재한다고 합시다. 이를 똑같이 정사각형에서 수행하는 경우 한쪽 변만 늘어나기 때문에 정사각형의 범주를 벗어나게 됩니다. 따라서 이러한 문제에 대한 통찰력과 직관을 늘려가야만 합니다. 

이것만은 잊지 말자!

  • public 상속의 의미는 "is-a" 입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다.

33. Avoid hiding inherited names

각 이름에는 유효범위(scope)가 존재합니다. 밑처럼 x에 값을 받아 넣는 경우, 전역 변수 x가 아닌 지역 변수 x에 대해 대입하게 됩니다. 이는 안 쪽에 해당하는 someFunc 함수 스택에 있는 이름이 바깥쪽에 해당하는 전역 변수보다 우선시 되어 발견되기 때문입니다. 국소적인 지역에서 탐색을 성공하면 그보다 더 넓은 범위에 대해서는 변수 형태에 신경쓰지 않고 더 찾지 않습니다.

int x; // 전역 변수

void someFunc()
{
    double x; // 지역 변수
    
    std::cin >> x;
}

이를 클래스의 범위로 들고 가봅시다.

class Base {
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};

class Derived : public Base {
public:
    virtual void mf1();
    void mf3();
    void mf4() { mf2(); };
    ...
};

int main() {
    Derived d;
    int x;
    
    d.mf1(); // Derived::mf1 호출
    d.mf1(x); // Derived::mf1이 Base::mf1을 가립니다
    
    d.mf2(); // Base::mf2 호출
    
    d.mf3(); // Derived::mf3 호출
    d.mf3(x); // Derived::mf3이 Base::mf3을 가립니다
}

위와 같이 Derived는 Base를 상속 받고, Derived 함수 mf4() 멤버 함수는 mf2() 함수를 찾고 있습니다. 이때 mf2는 어떠한 mf2가 시행될 것인가 주요 쟁점이 됩니다. 정답은 좀 더 작은 범위인 Derived에서 검색을 하고 Base를 찾게 되는데 밑에서는 mf2()를 찾지 못하기 때문에 Base::mf2()를 수행합니다.  밑의 main문을 보면 이에 대해서 조금 더 자세히 알 수 있습니다. 코드와 코딩 결과를 보면 모두 작은 범위에서 큰 범위를 찾고, 해당하는 함수의 인자 수와 타입이 같지 않더라도, 작은 지역 범위에서 찾은 이름에서 해결하는 것을 확인할 수 있습니다. 이렇게 가려진 이름의 경우는 using Base::mf1, using Base::mf3를 Derived 클래스 내에 선언해주는 것으로 모두 같은 유효 범위를 가지도록 만들면 됩니다.

class Base {
private:
    int x;
    
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};

class Derived : public Base {
public:
    using Base::mf1;
    using Base::mf3;
    
    virtual void mf1();
    void mf3();
    void mf4() { mf2(); };
    ...
};

int main() {
    Derived d;
    int x;
    
    d.mf1(); // Derived::mf1 호출
    d.mf1(x); // Base::mf1 호출
    
    d.mf2(); // Base::mf2 호출
    
    d.mf3(); // Derived::mf3 호출
    d.mf3(x); // Base::mf3 호출
}

또 다른 경우로는 private 상속의 경우 위에서 public으로 사용하던 함수가 private으로 변경되어 탐색이 되지 않을 가능성도 존재합니다.

이것만은 잊지 말자!

  • 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.
  • 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.

34. Differentiate between inheritance of interface and inheritance of implementation

public 상속은 크게 인터페이스의 상속과 함수 구현의 상속으로 나눌 수 있습니다. 예를 들어 Shape라는 추상 클래스를 통해 기하학적 도형을 나타내는 클래스 계통 구조를 만들면 이를 상속하는 모든 함수들은 Shape가 가지고 있는 순수 가상 함수를 모두 오버라이딩해야만 합니다. 따라서 파생된 클래스에 인터페이스를 상속하도록 구조가 짜여져있습니다. 특히 순수 가상 함수는 구현이 아닌 인터페이스만을 물려주는 것이 그 목적입니다.

 다음은 단순 가상 함수를 생각해봅시다. 이것이 의미하는 것은 인터페이스 및 그 함수의 기본 구현도 물려받게 합니다.

이것만은 잊지 말자!

  • 인터 페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.
  • 순수 가상 함수는 인터페이스 상속만을 허용합니다.
  • 단순 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.
  • 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.

35. Consider alternatives to virtual functions

[비가상 인터페이스 관용구(NVI)]

사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 방법입니다. 아래와 같이 public 함수인 heathValue에서 private인 doHealthValue를 호출하는 것으로 이를 완성시켰다.

class GameCharacter {
public:
    int healthValue() const
    {
        ....                              // 사전 동작
        int retVal = doHealthValue();     // 실제 동작
        ...                               // 사후 동작
        return retVal;
    }
    ...
private:
    virtual int doHealthValue() const
    {
        ....
    }
};

 이러한 NVI 관용구의 이점은 코드에 주석문으로 써둔 사전 동작과 사후 동작이 존재하여 가상 함수가 호출되기 전에 어떤 상태를 구성하고 호출 후에는 그 상태를 없애느 작업을 한다는 등 여러 가지 작업을 수행할 수 있습니다. NVI 관용구의 경우는 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우에 적법한 함수 호출이 되려면 그 가상 함수는 private이 아니라 protected 멤버이어야만 합니다.

 

[함수 포인터로 구현한 전략 패턴]

클래스 안에서 일부를 구현하는 것은 다른 함수를 이용하여 처리하는 방법이다.

class GameCharacter; // 전방 선언

int defaultHealthCalc(const GameCharacter& gc); // 체력치 계산 구현

class GameCharacter {
public:
    typedef int (*HealthCalcFunc) (const GameCharacter &);
    
    explicit GameCharacter(HealthCalcFunc hcf = default HealthCalc)
    : healthFunc(hcf)
    {}
    
    int healthValue() const
    { return healthFunc(*this) };
    ...
private:
    HealthCalcFunc healthFunc;
};

 다음과 같은 경우는 체력치 계산을 구현하는 함수를 포인터로 받아 저장하고 이를 사용하는 방법으로 구현하였다. 이 때의 장점은 같은 클래스의 객체더라도 함수를 다르게 받을 수 있다는 점이다. 또한 런타임 도중에도 함수를 교체하여 사용할 수 있단느 점이다.

 하지만 단점으로는, 함수 포인터를 사용하기 때문에 public 멤버가 아닌 부분을 건드릴 수 없기에 접근성의 문제가 생긴다. 반대로 이를 해결하려고 하면 캡슐화의 정도가 낮아지게 된다.

 

[tr1::function으로 구현한 전략 패턴]

앞에서는 함수 객체를 사용하였지만 이번에는 std::tr1::function 계열의 객체를 써서 기존의 함수 포인터를 대신하게 만듭니다.

class GameCharacter; 
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter {
public:
    typedef std::tr1::function<int (const GameCharacter &)> HealthCalcFunc;
    
    explicit GameCharacter(HealthCalcFunc hcf = default HealthCalc)
    : healthFunc(hcf)
    {}
    
    int healthValue() const
    { return healthFunc(*this) };
    ...
private:
    HealthCalcFunc healthFunc;
};

 tr1::function을 인스턴스화하기 위해 매개변수로 쓰인 대상은 const GameCharacter에 대한 참조자를 받고 Int를 반환하는 함수인데, 이때 타입은 앞으로 대상 시그너처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있게 됩니다. 이떄 호환된다는 뜻은 리턴 값과 매개변수 값이 암시적 변환이 가능한 경우를 얘기합니다. std::bind를 통해 매개변수를 미리 몇개 넣어서 호환되도록 만들 수도 있습니다.

 

[고전적인 전략 패턴]

디자인 패턴을 이용한 전략 패턴을 소개하고 있고, 이는 디자인 패턴에서 보는 것이 좀더 좋아보입니다.

이것만은 잊지 말자!

  • 가상 함수 대신에 쓸 수 있는 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.
  • 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.
  • tr1::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 대상 시그너처와 호환되는 모든 함수호출성 개체를 지원합니다.

36. Never redefine an inherited non-virtual function

class B {
public:
    void mf();
    ...
};

class D : public B {
public:
    void mf(); // B::mf를 가려버립니다.
    ...
};

D x;
B *pB = &x; // x에 대한 포인터(클래스 B) 획득
pB->mf(); // B::mf 호출

D *pB = &x; // x에 대한 포인터(클래스 D) 획득
pB->mf(); // D::mf 호출

위와 같이 부모 클래스 B에서 mf함수가 비가상 함수로 선언되고 자식 클래스 D에서도 mf 함수가 선언된 경우, 어떤 값으로 구성되었던 간에 포인터 형식에 알맞게 함수를 호출하게 됩니다. 의도하지 않은 방향으로 프로그램이 실행되게 됩니다. 이 이유는 비가상 함수는 정적바인딩으로 묶여 B 클래스 포인터에 대해서는 항상 B 클래스가 정의되어 있을 것이라고 생각하지만, 가상 함수의 경우에는 동적 바인등으로 묶여 객체의 원본을 찾아가게 됩니다. 설계상으로도 문제가 됩니다.

  • B 객체에 해당되는 모든 것들이 D 객체에 그대로 적용됩니다. 왜냐하면 모든 D 객체는 B 객체의 일종이기 때문입니다.
  • B에서 파생된 클래스는 mf 함수의 인터페이스와 구현을 모두 물려받게 됩니다. mf는 B 클래스에서 비가상 멤버 함수이기 때문입니다.
  • 하지만 D에서 mf를 재정의하면서 설계에 모순이 생기면서, 문제가 발생합니다.

이것만은 잊지 말자!

  • 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 맙시다.

37. Never redefine a function’s inherited default parameter value

기본 매개 변수 값을 가진 가상 함수를 상속하는 경우를 생각해봅시다.

class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    virtual void draw(ShapeColor color = Red) const = 0;
    ..
};

class Rectangle : public Shape {
public:
    virtual void draw(ShapeColor color = Green) const; // 매개 변수 값 바뀜
    ...
};

class Circle: public Shape {
public:
    virtual void draw(ShapeColor color) const;
};

Shape *ps;
Shape *pc = new Circle;
Shape *pr = new Rectangle;

pc->draw(); // Circle::draw(Shape::Red) 호출
pr->draw(); // Rectangle::draw(Shape::Red) 호출

다음과 같이 가상 함수 draw가 존재하고 기본 매개변수로 Red가 들어간다고 하고 이를 나머지 두 곳에서 상속 받는데, 이때 받는 기본 매개변수를 바꾸어 보았습니다. Circle에서는 기본 매개변수가 존재하지 않고 Rectangle에서는 Green이 기본 매개변수로 들어가는 것을 기대하는데 실제로 들어가는 것은 Red가 들어가게 됩니다.

 이는 런타임 효율이라는 요소가 깊게 들어가 있습니다. 포인터를 활용한 객체 부르기는 동적 타입으로 클래스를 찾아 이에 맞는 가상 함수를 찾습니다. 만약 함수의 기본 매개변수가 동적으로 바인딩된다면, 프로그램 실행 중에 가상 함수의 기본 매개변수 값을 결정할 방법을 컴파일러 쪽에서 마련해 주어야 합니다. 이 방법은 컴파일 과정에서 결정하는 현재의 매커니즘보다는 느리고 복잡할 것이 분명하기에, 효율 좋은 동작을 실행합니다.

 이를 해결하기 위해서는 위에서 언급한 NVI 관용구를 활용해서 만들면 이를 해결할 수 있습니다.

이것만은 잊지 말자!

  • 상속받은 기본 매개변수 값은 절대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수는 동적으로 바인딩 되기 때문입니다.

38. Model “has-a” or “is-implemented-in-terms-of” through composition

합성이란 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 그 타입들 사이의 관계를 일컫습니다. public 상속의 의미가 "is-a"라고 생각되는 반면, 객체 합성의 경우는 "has-a"와 "is-implemented-of"라는 뜻을 가지고 있습니다. 객체 중 우리가 일상생활에서 볼 수 있는 사물을 본 뜻것 들은 소프트웨어의 응용 영역에 해당하며 이를 포함하는 경우는 has-a 관계이지만 구현만을 위한 인공물의 의미는 is-implemented-of 관계입니다.

 예를 들면 set은 list와 같은 객체로 만들어질 수 있는데, 이는 is-implemented-of의 영역에 해당하며, Person객체에서의 std::string은 이름을 의미하기에 has-a 관계에 해당합니다.

이것만은 잊지 말자!

  • 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.
  • 응용 영역에서 객체 합성의 의미는 has-a 입니다. 구현 영역에서는 is-implemented-in-terms-of의 의미를 가집니다.

39. Use private inheritance judiciously

private 상속을 한다면 모든 함수를 자식 클래스에서 사용이 가능한 것이 아니기 때문에 is-a 관계가 아니다. 결국 private 상속의 의미는 is-implemented-in-terms-of의 의미를 가진다. 부모 클래스에서 private 상속으로 생긴 자식 크래스는 어떤 개념적 관계가 있어서 만들어진 것은 아니라는 것이다. 이와 같은 의미를 가지는 방법으로는 객체 합성이 존재하는데 이 두개는 사용법에서 조금 다르다.

 비공개 멤버를 접근할 떄 혹은 가상 함수를 재정의할 경우는 private 상속을 이용하는 것이 좋다. 그렇지 않은 경우는 객체 합성을 사용하는 것이 좋다. private 상속으로 구현되는 방법은 객체 합성을 하되 필요한 객체를 다른 클래스의 public 상속을 받는 식으로도 가능합니다. 이는 두가지 관점에서 좋은데, 클래스 설계하는 데 있어서 파생은 가능하게 하되, 파생 클래스에서 필요한 함수를 재정의할 수 없도록 설계 차원에서 막고 싶을 때 유용합니다. 다른 이유로는 컴파일 의존성을 최소화할 수 있습니다.

 그리고 private 상속을 하는 또 다른 경우에는 공간 최적화가 얽힌 경우가 존재합니다. C++에서 독립 구조의 객체는 반드시 크기가 0을 넘겨야 하는데 이를 private 상속을 통해 상속 시킨 클래스의 크기는 0으로 취급되기 됩니다. 이와 같은 것을 공백 기본 클래스 최적화라고 합니다.

이것만은 잊지 말자!

  • private 상속의 의미는 is-implemented-in-terms-of입니다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있습니다.

40. Use multiple inheritance judiciously

다중 상속은 둘 이상의 기본 클래스로부터 똑같은 이름을 물려받을 가능성이 생겨버립니다. 이에 앞에서 말한 듯이 namespace를 점점 넓혀가며 이름을 찾아야하는데, 찾아가는데 최적으로 알맞는 것을 여러개 찾아버릴 수가 있어 최적 일치 함수가 결정되지 않습니다. 따라서 모호성을 해소하려면 확실하게 클래스를 지정해서 알려주어야합니다. 특히 죽음의 MI 마름모꼴이라 불리는 다이아몬드 상속이 나오게 됩니다. 

class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile { ... };

File 클래스에 filename이라는 데이터 멤버가 있다고 하면, IOFile 클래스는 이를 1개로 처리해야할 지 2개로 처리해야할 지를 몰라, 기본적으로는 데이터 멤버를 중복 생성을 합니다. 하지만 데이터 멤버를 가진 클래스를 가상 기본 클래스로 만들어 파생 클래스에서 갓아 상속을 하도록 만듭니다. 이렇게 데이터 멤버의 중복생성을 막기 위해 컴파일러가 꼼수를 부리는데 이는 가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않는 것보다 일반적으로 크기가 더 큽니다. 게다가, 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 기본 클래스의 데이터 멤버에 접근하는 속도보다 느립니다. 즉 가상 상속은 상당히 비쌉니다.

 게다가 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한데다가 직관성도 더 떨어집니다. 대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생기게 됩니다. 이때 초기화가 필요한 갓아 기본 클래스로부터 클래스가 파생됐다면 가상 기본 클래스의 존재를 염두에 두고 있어야 하며, 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 슬래스의 초기화를 떠맡아야 합니다.

 따라서 가상 기본 클래스는 구태여 쓸 필요가 없다면 쓰지 말고, 최대한 가상 기본 클래스에는 초기화 규칙을 따르게 될 데이터를 넣지 않는 것이 좋습니다. 또한 다중 상속을 적법하게 쓰기 위해서 인터페이스 클래스로부터 public 상속을 받고, 구현에 도움이 되는 클래스로부터 private 상속을 받아 이를 해결 할 수 있습니다.

이것만은 잊지 말자!

  • 다중 상속은 단일 상속보다 새로운 모호성 문제 및 가상 상속의 문제로 복잡해집니다.
  • 가상 상속을 쓰면 크기 비용, 속도 비용 모두 늘어나며 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 실용적입니다
  • 다중 상속을 적법하게 쓸 수 있는 경우는 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.

26. Postpone variable definitions as long as possible

생성자와 소멸자를 끌고 다니는 타입으로 변수를 정의하면 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되고 유효범위를 벗어날 때 소멸자가 호출됩니다. 따라서 변수 선언을 신중하게 해야합니다. 변수를 너무 빠르게 선언하면은 쓰이지 않는 경우에도 생성자, 소멸자를 모두 호출시키기에 비용이 비싸집니다. 따라서 변수가 진짜로 필요해질 떄까지 선언을 늦추는 것이 좋습니다. 또한 새로운 변수를 한번에 복사 생성하지 않고 기본 생성자 호출 후 대입 할당을 해주는 경우도 존재합니다. 이는 함수를 두번 호출 하기에 바꿔줄 필요가 있습니다.

std::string password = "1234";

std::string str1; // default constructor
str1 = password; // copy assignment

std::string str2(password) // copy constructor

만약 for 문에 대해서 for 문 밖에서 변수를 선언하는 경우는 생성자 1번 + 소멸 1번 + 대입 n번으로 끝나지만, 안에서 선언하는 경우는 생성자 n번 + 소멸자 n번으로 비용이 커집니다. 이는 생성 비용이 클 때 더욱 심한데, 하지만 비용이 크지 않은 경우는 프로그램 이해도와 유지 보수성을 위해서 안에 선언하는 것도 좋습니다.

이것만은 잊지 말자!

  • 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다.

27. Minimize casting

먼저 casting에는 4가지 방법이 존재합니다.

[const_cast]

객체의 상수성을 없애는 용도로 사용됩니다.

[dynamic_cast]

안전한 다운캐스팅을 할 때 사용하는 연산자입니다. 즉, 주어진 객체가 상속 계통에 속한 특정 타입인지 아닌지를 결정할 수 있기도 합니다.

[reinterpret_cast]

포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 구현 결과는 구현 환경에 의존적입니다. 이 코드는 사실상 잘 안쓰입니다.

[static_cast]

암시적 변환을 강제로 진행할 때 사용합니다.

 

구형 스타일의 캐스트는 적법하게 사용될 수 있지만, C++ 스타일의 캐스트를 쓰는 것이 코드를 읽을 때 알아보기 쉬우며, 캐스트를 사용한 목적을 좀 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러 진단하기가 좋습니다.

 캐스팅을 그냥 어떤 타입을 다른 타입으로 처리하는 것으로만 알고 있으면 이것을 옳지 않습니다. 잘못된 캐스팅에 의해 런타임에러가 발생할 수도 있습니다.

class Base {...};
class Derived : public Base {...};
Derived d;
Base *pb = &d;

다음과 같이 d의 포인터를 pb로 다운 캐스팅했을 때, 포인터의 값은 같지 않게 될 수도 있습니다. 이는 객체 하나가 가질 수 있는 주소가 하나가 아닌 여러 개(Base *에 대한 주소, Derived *에 대한 주소)가 될 수 있기에 이는 위험합니다. 다중 상속이 되면 이러한 일은 계속 생기게 됩니다. 따라서 어떤 객체의 주소를 포인터로 바꿔서 포인터 산술 연산을 적을 경우에는 밎어의 동작을 낳을 수도 있습니다. 또한 이러한 캐스팅은 잘못된 판단을 낳을 수도 있습니다.

class Window {
public:
    virtual void onResize() {....}
    ....
};

class SpecialWindow : public Window {
public :
    virtual void onResize()[ {
        static_cast<Window>(*this).onResize(); --- (1)
        Window::onResize();                    --- (2)
        ....
        }
};

 (1)과 같이 현재 객체를 캐스팅의 값은 this의 Window 값이 아닌 새로운 복사된 객체의 onResize를 불러오게 됩니다. 따라서 이 코드는 예상대로는 동작하지 않게 됩니다. (2)와 같이 Window namespace에서의 onResize를 호출하는 것이 바람직한 코드입니다.

 dynamic_cast는 연산이 상당히 느리기 때문에 최대한 불러오지 않는 것이 좋습니다. 클래스 이름을 비교하기 위해 strcmp를 호출한다던가 클래스 확인을 위한 연산이 상당히 들어갑니다. 하지만 파생 클래스 객체임이 확실한 경우에 대해서 기본 클래스에서 파생 클래스로 바꿔야 하는 경우가 존재합니다. 이번에는 이를 피해가는 법에 얘기하고자 합니다.

  1. 파생 클래스에만 함수가 존재하는 경우 : dynamic_cast가 아닌 부모 클래스에도 해당 함수를 가상으로 추가하고 이를 부모 클래스 포인터에서 부르면 가상 함수 테이블을 거쳐 파생 클래스 함수가 실행됨
  2. 폭포식(cascading) dygnamic_cast를 통한 부모클래스 포인터에서 파생된 자식 클래스를 확인하는 경우 : 상당히 느리고 에러가 발생하기 쉽기 때문에 가능하다면 다른 코드로 바꾸거나 템플릿 특수화를 통해서 해결 가능.

따라서 이와 같은 캐스팅에는 문제가 존재하기 때문에 캐스팅을 최대한 사용하지 않고 필요한 부분에 대해서만 사용하도록 합니다. 또한 캐스팅을 최대한 격리 시키기 위해서 함수 속에 물아 놓고, 이 함수의 동작 방식에 대해서 외부에서 알 수 없도록 인터페이스를 막아두는 것이 좋습니다.

이것만은 잊지 말자!

  • 다른 방법이 가능하다면 캐스팅을 피하십시오.
  • 캐스팅이 어쩔 수 없다면 함수 안에 숨길 수 있도록 해 보십시오.
  • 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자의 의도가 더 잘 드러납니다.

28. Avoid returning “handles” to object internals

class Point {
public:
    Point(int x, int y);
    void setX(int newVal);
    void setY(int newVal);
    ,,,
};

struct RectData {
    Point ulhc;
    Point lrhc;
};

class Rectangle {
private:
    std::shared_ptr<RectData> pData;
public:
    Point& upperLeft() const { return pData->ulhc; } --- (1)
    Point& lowerRight() const { return pData->lrhc; } --- (1)
    const Point& upperLeft() const { return pData->ulhc; } --- (2)
    const Point& lowerRight() const { return pData->lrhc; } --- (2)
};

다음과 같이 Point로 점을 나타내고 RectData로 사각형의 점 데이터를 보관하며 이를 shared_ptr로 보관하고 있는 클래스 Rectangle을 상상해봅시다 만약 이에 대해 (1) 처럼 점들을 반환한다고 생각해봅시다. 이는 Point의 복사 생성을 하지 않기에 더 효율적으로 보입니다. Rectangle 객체를 수정시키지 않기 위해 상수멤버로 선언하였지만 참조값을 내보내기에 이를 변경하게 되면 객체의 private 데이터 값도 변하게 됩니다. 따라서 캡슐화 정도가 낮아지게 됩니다.

 이를 통해 클래스 데이터 멤버는 아무리 숨겨도 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다는 점을 알 수 있습니다. 즉 이번처럼 private 멤버를 참조자를 리턴함으로서 캡슐화 정도가 낮아졌다는 것입니다. 또한 어떤 객체에서 호출한 상수 멤버 함수의 참조자 변환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다는 점입니다. 이와 같이 참조자, 포인터와 같은 핸들을 반환하게 되면 객체의 캡슐화를 무너뜨리게 됩니다.

 그래서 먼저 반환 타입에 const 키워드를 붙이는 것으로 해결이 가능해보입니다. 적어도 위와 같은 문제는 사라집니다. 하지만 또 다른 문제가 하나 대두됩니다.

class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);

GUIObject *pgo;
...
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

 위와 같은 코드에서 GUIObject 값을 받고 이에 대한 복사본을 통해 upperLeft를 호출하여 Point 값을 얻어냅니다. 하지만 이는 임시 객체의 upperLeft Point 값을 가져오는 것이고, 이는 곧 사라질 것입니다. 따라서 pUpperLeft가 실질적으로 가지고 있는 것은 무효참조 핸들이 되기에 위험하게 됩니다. 따라서 핸들을 반환하는 함수는 어떻게든 위험할 수 밖에 없습니다. 하지만 이를 무조건적으로 하지 말라는 것이 아닌 피하라는 것이고, 이를 적절하게 활용해야만 합니다.

이것만은 잊지 말자!

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

29. Strive for exception-safe code

예외 안정성을 가진 함수라면 자원이 새면 안되고, 자료구조가 더럽혀지면 안됩니다. 따라서 예외 안정성을 갖춘 함수는 아래의 세 가지 보장 중 하나를 제공합니다.

 

[기본적인 보장(basic guarantee)]

함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것을 유효한 상태로 유지하겠다는 보장. 어떤 객체나 자료구조도 더렵혀지지 않으며 일관성을 유지해야하지만 프로그램의 상태가 정확히 어떤지 예측하기 어렵습니다.

[강력한 보장(strong guarantee)]

함수 동작 중에 예외가 ㅂ라생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장으로 원자적인 동작입니다. 호출이 성공하면 마무리까지 완벽하게 돌아가고, 아닌 경우는 전처럼 프로그램의 상태가 돌아가게 합니다.

[예외불가 보장(notrhrw guarantee)]

예외를 절대 던지지 않겠다는 보장입니다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻입니다. 기본제공 타입에 대한 모든 연산은 예외를 던지지 않게 되어 있습니다.

 

이 3가지 중 하나라도 만족하지 못한다면 예외에 안전한 함수가 아닙니다. 위의 세가지 보장 중에서는 실용성으로 보면 강력한 보장이 좋아보입니다. 예외불가 보장의 경우는 실제로 만들기가 어렵습니다. 따라서 현실적으로는 기본적인 보장이나 강력한 보장 중 하나를 고르게 됩니다.

이것만은 잊지 말자!

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

30. Understand the ins and outs of inlining

인라인 함수는 함수처럼 보이고 함수처럼 동작하면서 매크로보다 훨씬 안전하며 쓰기 좋습니다. 또한 함수 호출 시 발생하는 오버헤드도 걱정할 필요가 없습니다. 거기다가 인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 반면에 대부분 컴파일러는 아웃라인 함수 호출에 대해 이런 최적화를 적용하지 않습니다.

 하지만 인라인 함수의 아이디어는 함수 호출문을 그 함수의 본문으로 바꿔치기 하는 것이라서, 메모리가 제한된 컴퓨터에서는 문제가 발생할 수도 있습니다. 반대로 본문의 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 본문에 대해 만들어지는 코드의 크기가 함수 호출문에 대해 만들어지는 코드보다 작아질 수도 있기에 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 올라가게 됩니다.

 inline 키워드를 붙여 inline으로 명시하는 것은, 실질적으로는 컴파일러가 이를 보고 눈치껏 판단하여 inline으로 처리할 수도, 처리하지 않을 수도 있습니다. 게다가 코드가 짧은 함수에 대해서는 암시적으로 Inline으로 변환하는 경우가 있어, inline 키워드의 여부와 관계 없이도 작동된다고 생각될 수도 있습니다.

 흔히들 쓰는 함수 템플릿 inline 코드로서 std::max가 존재를 하는데 이 때문에 모든 함수 템플릿은 반드시 inline 함수이어야 하고 대개 헤더 파일 안에 정의한다라고 알고 있습니다. 대체적으로 이는 맞는데 이는 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문입니다. 따라서 인라인 함수 호출을 그 함수의 본문으로 바꿔치기하려면, 일단 컴파일러는 그 함수가 어떤 형태인지 알고 있어야 합니다. 템플릿 역시 같은 이유로 헤더 파일에 들어 있어야 합니다. 하지만 함수 템플릿을 inline으로 선언하는 것은 하등의 관련이 없고 그저 inline으로 선언하고 싶은 경우에만 선언하면 됩니다.

 더욱이 inline은 요청이기 때문에 "어떤 함수를 호출할지 결정하는 작업을 실행 중에 한다"라는 뜻의 virtual과 "함수 호출 위치에 호출된 함수를 끼워 넣는 작업을 프로그램 실행 전에 한다"라는 뜻의 inline은 상충되기 떄문에 서로 쓸 수 없고, 긴 코드인 경우는 inline해주지 않습니다.

 또한 분명히 inline이 되는 함수라도 그냥 부르지 않고, 또 다른 함수 포인터에서 정의하여 부르게 된다면 inline 되자 않습니다. 즉 어떻게 호출하느냐도 중요한 포인트가 됩니다.

그 외로 생성자와 소멸자는 inline하기에는 그리 좋지 않습니다. 왜냐하면 C++은 객체가 생성 소멸될 떄 일어나는 일에 대해 여러 가지 보장을 준비해 놓았습니다. 특히 생성자와 소멸자는 예외 없는 환경을 만들기 위해서 컴파일러에서 더 많은 코드를 부여하게 됩니다. 즉 우리가 적은 코드가 적을지라도 실제로 컴파일러에서 구현하는 코드는 엄청나게 길어지게 되며, 이를 inline으로 선언하게 되면 상당히 곤란한 상황에 처하게 됩니다.

 따라서 inline을 선언할 때에는 그 영향에 대해서 많은 고민을 해야합니다. 만약 inline 함수를 나중에 바꾸게 된다면 그에 맞는 오브젝트 파일을 전부 다시 컴파일 해야할 것입니다.

이것만은 잊지 말자!

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

31. Minimize compilation dependencies between files

 C++이 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는 데에 있습니다. C++의 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있습니다. 만약 class를 구현하는 데에 private 멤버 변수로서 std::string과 같은 여러 클래스를 가지고 있고, 이를 #include 지시어를 통해 위의 클래스에 대한 정보를 가져와야 합니다. 즉 변수 코드들에 대해서 컴파일 의존성을 가지게 도비니다. 만약 해당 클래스를 바꾸게 된다면, 해당 클래스의 파일 뿐만 아니라 이에 해당되는 모든 파일들에 대해서 모두 다시 컴파일 해야만합니다.

 이는 전방선언으로도 큰 성과를 이뤄내기 힘듭니다. std::string 같은 경우는 typedef로 정의된 타입 동의어 이므로 전방 선언이 불가능하고 템플릿을 들고와야만 합니다. 그래서 일부를 끌고 오기위해서는 #include 부분을 적절하게 고쳐야만 하기에 일이 복잡해집니다. 또 다른 이유로는 컴파일 도중에 객체들의 크기를 전부 알아야 하기 때문에 결국 객체가 정의된 클래스의 세부사항을 알아야만 합니다. 하지만 이것은 포인터를 활용한다면 해결이 가능합니다.

 따라서 다음과 같은 방식으로 클래스의 구현과 인터페이스를 각각 제공하도록 합니다.

#include <string>
#include <memory>

class PersonImpl;

class Date;
class Address;

class Person {
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    ...
private:
    std::shared_ptr<PersonImpl> pImpl;
};

 이와 같은 패턴을 pimpl idiom이라고 합니다. Person의 인터페이스에서는 자질구레한 세부사항과 완전히 갈라서면서도 인터페이스를 제공하고 있고 실제적인 구현은 PersonImpl에서 수행하도록 합니다. 즉, '정의부에 대한 의존성'을 '선언부에 대한 의존성'으로 컴파일 의존성을 최소화하도록 만듭니다. 이러한 전략을 가져가는 방법은 다음과 같습니다.

  • 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다. - 타입에 대한 참조자 및 포인터를 정의할 때는 선언부만 필요합니다.
  • 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다. - 클래스 객체를 값으로 전달하거나 반환하여 클래스 정의가 필요 없도록 합니다. 이 방법은 실제 함수를 호출이 일어나는 사용자의 소스 파일 쪽에 헤더 파일 쪽 부담을 전가하는 방법으로, 사용자 입장에서도 실제로 쓰지도 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 거추장스러움을 막을 수 있습니다.
  • 선언부와 정의부에 대해 별도의 헤더 파일을 제공합니다. - 클래스를 선언부와 정의부로 나누어 구성합니다. 앞서 보여준 pImpl과 같은 것이 핸들 클래스로 구현을 맡고, 다른 파트가 인터페이스 및 선언을 맡습니다. 이는 인터페이스 함수는 그에 해당하는 구현부 함수를 불러오게 합니다. 다른 방법으로는 인터페이스 클래스를 만들어 이의 파생 클래스를 만들도록 합니다.인터페이스 클래스는 대부분의 함수를 순수 가상함수로 구현하는데, 객체 생성 수단이 최소한 하나는 있어야하기 때문에 가상 생성자를 만들어 생성자 역할을 대신합니다. 인터페이스 클래스의 구현은 크게 2가지가 사용됩니다.
    1. 인터 페이스 클래스로부터 인터페이스 명세를 물려받게 만든 후에, 그 함수들을 구현하는 방법
    2. 다중 상속을 사용하여 구현하는 방법
     인터페이스 클래스의 경우에는 호출되는 함수가 전부 가상함수라는 것이 약점입니다. 따라서 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모되고, 파생된 객체는 모두 가상 테이블 포인터를 가지고 있어야합니다.
     핸들 클래스의 경우는 한번 접근할 때마다 요구되는 간접화 연산이 한 단계 더 늘어나게 됩니다. 또한 객체르 하나씩 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기도 더해져서 커지며, 구현부 포인터가 동적할당된 구현부 객체를 가르키도록 초기화가 일어나야합니다.
     또한 두가지 모두의 약점으로 인라인 함수의 도움을 제대로 끌어내기가 어렵습니다.

이것만은 잊지 말자!

  • 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 정의 대신에 선언에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.
  • 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합니다.

18. Make interfaces easy to use correctly and hard to use incorrectly

제대로 쓰기에는 쉽고 엉터리로 쓰기에는 어려운 인터페이스를 개발하려면 사용자가 저지를 수 있는 실수의 종류를 머리에 생각해두고 있어야 합니다. 예시로 날짜를 입력 받는 인터페이스가 있다면, 이를 단순히 int로 받기 보다는 새로운 데이터 구조를 만들어 확실하게 명시하여 넣어주도록 유도할 수 있습니다. 또한 이를 위해 유효한 집합을 미리 정의 해두어 이를 사용하게도 만들 수 있습니다.

 그 외로는 if (a * b = c)와 같은 이상한 코드를 짤 수도 있는데 이 때 operator*의 반환 값을 const로 제한하지 않는다면 이는 컴파일될 수도 있습니다. 특별하지 않다면 연산은 보통 int의 동작 원리대로 만들면 됩니다. 기본 제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서 입니다.

 팩토리 함수를 사용하여 포인터를 만들 때 할당된 공간을 해제해주어야만 합니다. 이 때 shared_ptr로 값을 바로 넣어주게 된다면, 이 shared_ptr이 소멸할 때 할당된 공간도 적절하게 해제해줄 것입니다. 이러한 shared_ptr은 사용자 정의 삭제자를 지원하는데, 이 특징 때문에 교차DLL 문제를 막아줄 수 있습니다.

[교차 DLL 문제(cross-DLL problem)]

객체 생성 시에 한 동적 링크 라이브러리의 new를 사용하였는데, delete는 또 다른 라이브러리에서 가져온 경우에 발생합니다. new/delete 짝이 꼬여버리기 떄문인데, 이를 shared_ptr을 사용한다면 new/delete가 같은 라이브러리에 존재해야하기 떄문에 이런 문제가 해결됩니다.

이것만은 잊지 말자!

  • 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리에 쓰기에 어렵습니다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
  • 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 좋은 인터페이스로 이끕니다.
  • 사용자의 실수를 막기 위해서는 새로운 타입 만들기, 연산 제한, 값 제한, 작원 관리 작업을 사용자 책임으로 놓지 않기 등이 있습니다.

19. Treat class design as type design

클래스 설계는 타입 설계와 비슷하게 생각을 해야하는데 다음과 같은 주의점들이 존재합니다.

  • 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
  • 객체 초기화는 객체 대입과 어떻게 달라야 하는가?
  • 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
  • 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
  • 기존의 클래스 상속 계통망에 맞출 것인가?
  • 어떤 종류의 타입 변환을 허용할 것인가?
  • 어떤 연산자와 함수를 두어야 의미가 있을까?
  • 표준 함수들 중 어떤 것을 허용하지 말 것인가?
  • 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
  • '선언되지 않은 인터페이스'로 무엇을 둘 것인가?
  • 새로 만드는 타입이 얼마나 일반적인가?
  • 정말로 꼭 필요한 타입인가?

이것만은 잊지 말자!

  • 클래스 설계는 타입 설계입니다. 위의 항목들로 체크를 해보는 것이 좋습니다.

20. Prefer pass-by-reference-to-const to pass-by-value

C++은 함수로부터 객체를 ㅓㄴ달받거나 함수에 객체를 전달할 때 값에 의한 전달 방식을 사용합니다. 이 때 함수의 매개변수는 실제 인자의 사본을 통해 초기화되며, 호출한 쪽은 리턴 값의 사본을 돌려받게 됩니다. 이를 만드는 것이 복사 생성자인데, 이는 비용이 비싼 동작입니다. 또한 그 안에 인자가 많고, string과 같은 객체가 존재할 떄는 더더욱 그렇습니다. 하지만 bool validate(const Student& s)와 같이 const 참조 객체로 받게 된다면, 이를 복사하지도 않을 뿐더러, 참조 받은 객체에 변화를 주는 것이 불가능하기 때문에 안전합니다.

 참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제도 해결이 됩니다. 파생 클래스 객체가 기본 클래스 객체로서 넘어가는 경우 기본 클래스의 복사 생성자가 발생하게 되고 여기서 파생 클래스 객체로서 동작하게 해주는 특징들이 잘려나가게 됩니다. 복사 손실 문제에서 도망가려면 위와 같이 상수 객체에 대한 참조를 수행하면 됩니다.

 특정 객체의 복사 비용이 작더라도 이를 고려해봐야하는 이유는 많습니다. 먼저 string과 같은 객체는 구현에 따라 그 크기가 최고 7배 차이 날 수도 있을 뿐더러,  컴파일러 중에서는 기본제공 타입과 사용자 정의 타입을 아예 다르게 취급하는 것들이 존재하기 때문에 좀 더 고려를 해보야아만 합니다.

이것만은 잊지 말자!

  • 값에 의한 전달보다는 상수 객체 참조자에 의한 전달을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아줍니다.
  • 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 값에 의한 전달이 더 적절합니다.

21. Don’t try to return a reference when you must return an object

class Rational {
public:
    Rational (int numerator =0, int denomiator = 1);
private:
    int n, d;
friend:
    const Rational operator*(const Rational& lhs, const Rational& rhs);
};

 간단하게 생각했을 때 operator*이 반환하는 값이 Rational의 값을 반환하기에 이를 참조를 사용한다면 복사 비용을 줄일 수 있을 것만 같습니다. 하지만 참조를 이용하여 보내는 경우에는 어떠 한 Rational 객체를 참조하고 있어야 하는데, 함수에서 만든 값은 operator* 함수 스택에 쌓여있고 이는 함수가 종료될 때 메모리가 해제되게 됩니다. 따라서 온전한 Rational 객체에 대한 참조자를 반환하지 않기 떄문에 문제가 발생합니다. 만약 이 값을 힙 영역에 할당하여 반환한다고 했을 경우는, w = x * y * z 처럼 연속적으로 값을 받아서 실행하는 경우에는 메모리 누수가 발생하고 만다. 또한 정적 객체를 사용하게 된다면 operator==를 통해 이를 결과를 유도하는 경우 같은 결과가 나오게 하기 떄문에 이도 사용 불가능하다.

따라서 실제적으로 할 수 있는 함수 구현은 다음과 같습니다

inline const Rational operator*(const Rational& lhs, const Rational &rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d & rhs.d);
}

와 같이 구현 가능합니다. 이는 C++ 컴파일러에서 기존 코드의 수행 성능을 높이는 최적화를 통해 operator* 반환 값에 대한 복사가 일어나지 않고 이 자체를 반환하도록 만들어 줍니다.

이것만은 잊지 말자!

  • 지역 스택 객체에 대한 포인터나 참조자를 반환하거나 힙에 할당된 객체에 대한 참조자를 반환하거나 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 하지 말아야 합니다.

22. Declare data members private

데이터 멤버가 public이면 안되는 이유가 무엇일까에 대해서 고찰을 해봅시다. 접근 제어를 하는 flag를 세우고 이를 통해 데이터 접근을 제어할 수 있습니다.

 데이터 멤버를 인터페이스 뒤에 감추게 되면, 구현상의 융통성을 전부 누리기 쉬워집니다. 데이터를 사용 및 변경에 있어 알림, 사전 조건, 사후조건 등 스레딩 환경에서 동기화를 거는 등의 여러 가지를 수행하기 쉬워집니다.

 게다가 보통 C++ 세상에서 public은 캡슐화되지 않았다는 것을 의미하며 이는 Protected에 대해서도 비슷하게 돌아갑니다. 어떤 클래스가 public 데이터 멤버가 있고 이를 제거할 때 이를 사용하는 사용자 코드는 전부 무사할 수 없을 것입니다. 이는 protected에서도 비슷하게 돌아갑니다. 따라서 캡슐화의 관점에서 쓸모 있는 접근 수준은 private이며 그 외는 캡슐화가 없다고 봐도 무방합니다.

이것만은 잊지 말자!

  • 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐만 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.
  • protectedsms public보다 더 많이 보호받고 있지 않습니다.

23. Prefer non-member non-friend functions to member functions

class WebBrowser {
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    void clearEverything(); // clearCache, clearHistory, removeCookies 호출
};

void clearBrowser(WebBrowser& wb)
{
    clearCache();
    clearHistory();
    removeCookies();
};

위와 같이 한 클래스에 대해서 다른 함수를 부르는 멤버 함수와 비멤버 비프렌드 함수 두개를 구현하였습니다. 이 때 어떤 함수가 더 유용할 까요? 보통 멤버 함수가 더 낫다고 생각될 수 있지만, 멤버 버전이 비멤버 버전보다 캡슐화 정도에서 오히려 형편없습니다. 또한 비멤버 함수를 사용하면 WebBrowser 관련 기능을 구성하는 데 있어서 패키징 유연성이 높아지는 장점도 있습니다. 이로 인해 컴파일 의존도도 낮추고 확장성도 높일 수 있습니다.

 데이터를 직접 볼 수 있는 코드가 적으면 캡슐화가 잘 되었다고 합니다. 캡슐화 입장에서 보면 멤버 함수는 private 자료 멤버에 접근할 수 있고 비멤버인 경우는 그렇지 못하기 때문에 비멤버 비프렌드 함수 쪽이 좀 더 캡슐화가 잘 되어 있다고 볼 수 있습니다. 이를 좀 더 자연스럽게 구성하기 위해서는 같은 namespace에 넣어 구현하는 것도 좋습니다. 이는 파일을 구성할 떄도 좋은데, 클래스를 구성하는 소스 파일을 두고 이들을 참조하는 클래스의 특정 부분을 사용하는 비멤버 함수들을 각각 파일을 나누어서 저장할 수 있습니다. 이는 C++ 표준 라이브러리가 구성된 방식이며 이렇게 하면 사용자가 실제 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 됩니다. 또한 편의 함수 집합의 확장도 손쉽게 이루어질 수 있습니다.

이것만은 잊지 말자!

  • 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다.

24. Declare non-member functions when type conversions should apply to all parameters

class Rational {
public:
    Rational (int numerator =0, int denomiator = 1);
    int numerator() const;
    int denominator() const;
    
    const Rational operator*(const Rational& rhs) const; ---- (1)
private:
    ...
};

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); --- (2)
};

다시 Rational 객체를 생각해봅시다. 먼저 암시적 타입 변환을 지원하는 것은 별로 좋지 않지만 숫자 타입을 만들 때는 예외로 생각됩니다. 유리수를 나타내는 클래스이니 수치 연산을 기본적으로 지원하고 싶지만 이 때 비멤버 함수를 이용할 지 멤버 함수를 이용할 지가 큰 고민이 됩니다. 

 만약 (1)과 같이 멤버 함수로 정의하였다고 생각해봅시다. 이떄 우리는 생성자에 explicit를 쓰지 않아 암시적 생성을 지원하게 됩니다. 기본적인 연산은 지원하게 되지만,  곱셈의 교환 법칙이 성립하지 않게 됩니다. 코드는 아래와 같습니다

Rational oneHalf;

result = oneHalf * 2;           // Good!
result = 2 * oneHalf;           // Error!
result = oneHalf.operator*(2);  // == oneHalf * 2
result = 2.operator*(oneHalf);  // == 2 * oneHalf

이처럼 수행된 경우 Rational 객체가 뒤에 온 경우에 대해서는 제대로 연산이 수행되지 않는다. 또한 앞을 int로 받되 뒤를 Rational 객체를 받는 함수도 존재하지 않게 된다. 2가 뒤에 오는 경우에는 operator*에 맞도록 int가 Rational로 암시적 변환을 하면 수행하지만, 반대인 경우는 접근할 수 있는 함수 중에서 해당 함수 자체를 찾지 못하기 때문에 암시적 타입 변환이 일어나지 않습니다. 따라서 이를 위해서는 (2)와 같이 비멤버 함수를 통해서 operator*에 접근할 수 있도록 돕고, 이에 맞게 암시적 타입 변환이 일어날 수 있도록 합니다.

이것만은 잊지 말자!

  • 어떤 함수에 들어가는 모든 매개변수에 대해 타입 변환을 해줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.

25. Consider support for a non-throwing swap

swap은 구현 코드를 보면 알겠지만 복사만 제대로 지원하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 줍니다. 하지만 객체의 복사 비용이 비싸다면 swap을 사용할 때의 비용도 비쌀 것입니다. 따라서 pimpl 관용구(pointer to implementation idiom)을 사용하여, 설계하면 swap에 대한 비용을 줄일 수 있습니다.

class WidgetImpl {
private:
    int a, b, c;
    std::vector<double> v;
};

class Widget {
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)
    {
        *pImpl = *(rhs.pImpl);
    }
private:
    WidgetImpl *pImpl;
};

다음과 같이 실질적인 내용에 해당하는 WidgetImpl의 복사 비용은 비쌉니다. 하지만 std::swap을 수행할 때 Widget 자체의 복사를 수행하기 보다는 포인터만 교환한다면 실질적으로 swap이 일어난 것과 다르지 않습니다. 이는 std::swap에 대해 템플릿 특수화를 수행해주어 Widget이 들어가는 경우에 대해서는 특별하게 Widget의 멤버 함수 swap을 수행하도록 하고 Widget 클래스에서는 다음과 같이 따로 swap 함수를 구현해주면 됩니다.

class Widget {
public:
    void swap(Widget& other)
    {
        using std::swap;
        
        swap(pImpl, other.pImpl);
    }
};

namespace std {
    template<>
    void swap<Widget>l (Widget& a, Widget& b)
    {
        a.swap(b);
}

Widget이 클래스 템플릿으로 이루어진 경우에는 또 다른 문제를 발생시킵니다. 템플릿 특수화도 안될 뿐더러 std namespace 영역에서의 overloading도 불가능합니다. 이를 위해서는 비멤버 템플릿 함수로 swap 함수를 구현하고 이를 템플릿 특수화시킨 swap에서 인자 기반 탐색을 통해 찾도록 만듭니다. 이는 다음과 같이 구현됩니다.

template<typename T>
class WidgetImpl {...};

template<typename T>
class Widget {...};

template<typename T>
void swap(Widget<T> &a, Widget<T> &b)
{
    a.swap(b);
};

 이때까지는 swap을 구현하는데에 중점을 두었지만, 함수 템플릿 중에 swap을 쓰고 싶은 경우, 타입 T 전용 버전이 존재한다면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶은 경우에는 다음과 같이 std::swap을 함수 안에서 쓴다는 것을 명시하고 사용하도록 합니다.

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap;
    ...
    swap(obj1, obj2);
    ...
};

이렇게 한다면 탐색 순서는

  1. 전역 유효범위 혹은 타입 T와 동일한 namespace 안에 T 전용의 swap 존재 여부를 검색
  2. T 전용 swap이 존재하지 않는다면 using std::swap 호출을 통한 std의 swap 호출 

이것만은 잊지 말자!

  • std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.
  • 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스에 대해서는 std::swap도 특수화해 둡시다.
  • 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 namespace 한정 없이 swap을 호출합시다.
  • 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 추가하려고 들지는 마십시오.

13. Use objects to manage resources.

팩토리 패턴

class Investment() { … }; // 투자 관련 최상위 클래스
class InvestmentA : Investment() { … }; //파생클래스
class Creator {
	public :
		Investment* Operation() { return factoryMethod(); }
	protected :
		virtual Investment* factoryMethod() = 0;
};

// 투자 파생클래스 객체를 동적할당하고 그 포인터를 반환한다. 따라서 객체의 해제는 호출자 쪽에서 직접 해야합니다.
class CreatorA : Creator{
	private: Investment* factoryMethod() { return new InvestmentA }; 
};

void f() {
	InvestmentA *pInv = CreatorA.factoryMethod(); // 팩토리 함수 호출
	… //??!
	delete pInv; // 객체 해제
}

void f()함수는 제대로 된 코드로 보이지만, 객체 삭제는 실패할 가능성이 높습니다.

  • ... 구간에서 return
  • 생성과 해제가 같은 루프일 때, continue, goto가 섞여있을 때
  • ...에서 예외발생

f()가 객체를 제대로 해제 해줄거라고 믿으면 안됩니다. 따라서 얻어낸 자원이 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만들도록 해야합니다. 이 아이디어는 이미 표준라이브러리에 만들어져 있습니다.

  • auto_ptr (스마트 포인터) - 가르키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 있음.여기에는 자원 관리에 객체를 사용하는 방법의 중요한 두가지 특징을 끄집어 낼 수 있다. 
    1. 자원을 획득한 후에 자원 관리 객체에게 넘깁니다. Resource Acquisition Is Initailization(RAII)라는 이름으로 불립니다.
    2. 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 합니다.
    또한 특이한 점으로는, auto_ptr의 경우 복사 생성자, 복사 대입 연산자를 이용할 경우, 원본을 null로 만듭니다.
  • 참조 카운팅 방식 스마트 포인터(reference-counting smart pointer: RCSP) - 다른 대안으로, RCSP가 특정한 어떤 자원을 가리키는 외부 객체의 개수를 유지하고 있다가, 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터입니다. 단, 참조 상태가 고리를 이루는 경우는 없앨 수 없습니다.
void f()
{
 std::auto_ptr<InvestmentA> pInv(CreatorA.fatoryMethod()); //동일
  … //pInv 사용
} //auto_ptr의 소멸자를 통해 pInv를 삭제한다.

auto_ptr 및 tr1::shared_ptr은 소멸자 내부에서 delete 연산자를 사용합니다. 따라서 동적으로 할당한 배열에 대해 활용하면 난감합니다. 또한 동적 배열을 써도 컴파일 에러가 나지 않습니다. 이는 vector와 string으로 대체가 가능하기 때문입니다.

이것만은 잊지 말자!

  • 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII객체를 쓰자
  • 일반적으로 널리 쓰이는 RAII 클래스는 tr1::shared_ptr과 auto_ptr이 존재하며, 복사 시에 원본 객체의 삭제 여부의 차이가 존재한다.

14. Think carefully about copying behavior in resource-managing classes.

앞에서는 동적 할당한 자원에 대해서 스마트 포인터를 사용해 자원 관리를 하는 것은 배웠다. 이번에는 힙이 아닌 경우에, 자원 클래스를 직접 스스로 만들어야한다. RAII 방식은 객체의 scope(존재 할 수 있는 범위)를 이용하여, 자원을 관리하는데, 객체를 복사 했을때, 소멸자를 어떻게 정의하느냐에 따라서, 전기적 쇼크를 프로그래머에게 줄지, 램에게 줄지 결정지을수 있기 때문이다.

일반적인 사례

  1. 복사를 금지한다 - 유니크한 객체를 관리할 때 사용합니다. Uncopyable을 사용하여 복사를 금지시킬 수 있습니다.
  2. 관리하고 있는 자원에 대해 참조 카운팅을 수행한다. - tr1::shared_ptr을 사용하여 해당 자원을 가르키는 포인터만을 복사 할때, 카운팅 합니다. shared_ptr은 참조카운팅이 0일때 대상을 삭제해버립니다. 하지만 옵션값을 이용하여 참조카운팅이 0일때 삭제 대신 특정한 객체 또는 함수(삭제자라고 부른다.)를 호출하도록 설정할 수 있습니다. 까다롭지만 가장 효율적인 방법입니다.
  3. 관리하고 있는 자원을 진짜로 복사한다. - 보통 문자열 객체에 대해 관리할때 사용합니다. 클래스 인터페이스를 무시할 경우가 많이 생기기 때문입니다. []연산자 때문에.. 표준 string을 보면 힙메모리 공간을 포인터로 참조합니다. 이때 복사가 일어날시 포인터도 복사되므로 깊은 복사가 됩니다.
  4. 관리하고 있는 자원의 소유권을 옮긴다. - 관리되는 자원이 유니크 해야만 할때 사용합니다. 대표적으로 std::auto_ptr 이 있습니다. auto_ptr을 이용해서 자원의 소유자를 단 하나로 설정이 가능합니다.(auto_ptr 복사시 이전 auto_ptr은 삭제됩니다.)

이것만은 잊지 말자!

  • RAII 방식의 객체의 복사에 대해서 반드시 고려하자
  • 보통은 일반적인 사례 1번과 (2,3 조합형)을 많이 사용 한다

15. Provide access to raw resources in resource-managing classes.

자원 관리 클래스에서 관리되는 자원은 외부에서 접근가능할 필요가 있습니다. RAII 방식의 객체의 경우 자원의 관리에 그 중점을 두었기 때문에, 그리고 설계상에 멤버 변수로써의 값으로 많이 쓰이기 때문에, 다 무너진다고 볼순 없다는게 저자의 견해이다.

std::tr1::shared_ptr<Investment> pInv(createInvestment());
…
//그리고 이함수를 사용한다고 가정해보자.
int daysHeld(const Investment *pi);

//만약 위 함수를 아래코드와 같이사용한다면 에러!
int days = daysHeld(pInv);//ERROR!

daysHeld는 Investment*의 실제포인터를 원하기 때문이다. 따라서 RAII의 객체안에 실제자원을 제공 해줄 방법이 필요합니다

1. 명시적변환

get과 같이 함수를 통해 값을 반환합니다

int days = daysHeld(pInv.get()); //실제포인터를 반환해준다 get함수를 이용해서

2. 암시적변환

포인터 역참조 연산자(operator-> / operator*)를 오버로딩하여 암시적변환을 수행합니다. 즉, 외부에서 자원 접근을 가능하게 합니다. 따라서 연산자 오버로딩을 통해 daysHeld(pInv) 그대로 사용가능할 수도 있습니다. 또한, 유연하게 C API를 사용하기 훨씬 쉬워지고, 자연스러워집니다. 하지만, 이와 같은 연산자 오버로딩으로 인해 덮어씌워짐 등의 부작용이 생길가능성이 있습니다.

이것만은 잊지 말자!

  • 실제 자원을 직접 접근해야 하는 기존 API들도 많기에, RAII 클래스를 만들 때는 클래스가 관리하는 자원을 얻을 수 있는 방법을 제공해야함.
  • 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능하다. 안전성은 명시적 변환이, 편의성은 암시적 변환이 좋다.

16. Use the same form in corresponding uses of new and delete.

new 연산자를 사용해서 표현식(동적 할당)을 꾸미게 되면, 메모리가 할당 → 할당한 메모리에 한개 이상의 생성자 호출이라는 과정을 겪는데, delete 표현식은 우선 기존에 할당된 메모리에 대해 한개 이상의 소멸자 호출 → 메모리 해제의 순서로 작동합니다. 이때, delete 연산자가 적용되는 객체는 소멸자가 호출되는 횟수와 동일합니다.

만약 맞추지 않으면 어떻게 되는가?

대다수의 컴파일러는 맨 앞 숫자 (ex. 3)을 보고 배열객체라고 해석하며 해당숫자만큼의 소멸자를 호출합니다.

  • 한개의 객체 : [object] - 단일 객체용에는 배열 원소의 개수와 같은 정보가 없습니다.
  • 여러개의 객체 : [5][object][object][object][object][object] - 배열용 힙 메모리에는 대게 배열원소의 개수가 박혀 들어갑니다. 따라서 소멸자가 몇번 호출 해야하는 지를 파악가능합니다.

만약 new std::string[5]를 주고, 그냥 delete 를 이용하면 1개의 객체만 풀어주고 나머지 4개는 프로그램 종료되기까지 잡고 있는 어처구니 없는 사태가 발생합니다. 즉, []는 배열객체임을 확인하고, 해당 개수만큼 소멸자를 호출하기 위해 필요합니다.

  • typedef로 배열타입을 정의 X - typedef 로 배열을 정의하면. 가시적으로 확인하기가 힘듦.
typedef std::string strArr[4];
std::string *pal1 = new strArr; // new strArr 은 new string[4]이다..
…
delete pal; // 문제 발생
delete[] pal; //정상

이것만은 잊지 말자!

  • new 표현식의 [] 여부에 따라, 대응하는 delete 표현식에도 []를 사용

17. Store newed objects in smart pointers in standalone statements.

int priority();//우선순위처리 함수
void processWidget(std::tr1::shared_ptr<Widget> pw,int priority); // smart pointer을 사용하도록 만들어짐.
processWidget(new Widget, priority()); // 비정상적 호출
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority()); //정상적 호출

다음과 같이 정의된 함수를 3번째 줄처럼 호출하면 에러가 발생합니다. tr1::shared_ptr은 explicit으로 선언되어 있기 대문에 암시적형변환이 불가합니다. 4번째 줄 처럼정확하게 타입을 기재해 주어야 합니다. 하지만 여기서도 자원낭비가 발생합니다. 그이유는 호출프로세스에서 자원 leak가 날 수 있기 때문입니다.

  • processWidget의 호출프로세스를 살펴보자(컴파일러마다 다르다, 2<-> 3 변경가능성도 있다.)
  1. new Widget 표현식 실행
  2. priority 호출
  3. tr1::shared_ptr 생성자 호출

만약 priority 호출에서 에러가 나면 new Widget으로 만들어진 포인터가 유실될 수도 있습니다. 하지만 그 역도 가능하며, 이는 자원이 생성되는 시점과 그 자원이 자원 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 떄문입니다. 따라서 다음과 같이 Widget을 별도의 독립적인 한문장으로 만든 후 넘깁니다.

std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

이것만은 잊지 말자!

  • new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들자.

5. Know what functions C++ silently writes and calls

컴파일러는 C++의 클래스의 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자가 선언되어 있지 않으면, 암시적으로 기본형을 생성합니다. 이들이 사용될 때, 즉 필요하다고 판단될 때 이를 생성합니다.

  • 소멸자는 해당 클래스가 상속한 기본 클래스의 소멸자가 가상 소멸자로 되어 있지않다면 비가상 소멸자로 만들어집니다.
  • 복사 생성자와 복사 대입자의 경우는 비정적 데이터를 사본 객체 쪽으로 복사하여 줍니다. 이때 최종 결과 코드가 legal하고 reasonable해야만 합니다.
  • 만약, 클래스의 데이터 중 일부가 참조자로 구성되어 있다면, 어떻게 해야할지 애매해집니다. 참조되는 객체를 복사하기에는 클래스 외의 부분에 영향을 주게 되고, 참조하는 객체를 참조하는 것은 참조자의 이치에 맞지 않습니다. 따라서 C++은 컴파일을 거부하고, 사용자가 이에 대한 정의를 하도록 합니다. 이는 데이터 멤버가 상수 객체인 경우도 동일합니다.
  • 복사 대입 연산자를 private로 선언한 클래스로부터 파생된 경우도 복사 대입 연산자를 가질 수 없습니다.

즉, 기본 함수의 경우 컴파일러가 대신 생성하는 경우가 있으므로, 코드로 확인하지 않고 넘어간다면 추후에 버그의 원인이 될 가능성이 높습니다. 따라서 잘 확인해야 합니다.

이것만은 잊지 말자!

  • 컴파일러는 경우에 따라 클래스의 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 생성할 수 있습니다.

6. Explicitly disallow the use of compiler-generated functions you do not want

복사 생성자와 복사 대입 연산자가 정의되어있지 않는 경우 컴파일러가 이를 생성해버리므로, 컴파일러가 만들어낸 함수가 필요없다면, 확실하게 막아 두는것이 좋다. 함수를 정의하지 않는 방법은 불가능하므로(C++ 11에서는 delete), 함수를 public이 아닌 private으로 정의하여 이를 막아두어야 합니다.

 하지만 멤버 함수 및 friend 함수가 호출하는 것도 가능하기에 이를 막기 위해서는 선언만 하되 정의(define)를 하지 않으면 됩니다. 만약 사용되더라도 링커 타임에서 에러를 발생시키게 됩니다. 하지만 이는 디버깅 시 좋지 않으므로, 링크 에러를 컴파일 시점 에러로 옮길 수 있는데, 이를 복사 생성자와 대입자를 private으로 하는 별도의 기본 클래스를 만들고, 이를 상속받는 방법으로 해결 가능합니다. 이때 상속받는 클래스에서 따로 복사 생성자와 대입자를 정의를 해주면 안됩니다. 이러한 Uncopyable의 구현에 있어, 상속을 public으로 할 필요가 없고(항목 32, 39) virtual 소멸자가 아니어도 됩니다(항목 7). 또한 Boost 라이브러리에 noncopyable이라는 비슷한 클래스가 존재합니다.

class Uncopyable {
protected:                                // 파생된 객체에 대해서
    Uncopyable(void) {}                    // 생성과 소멸을
    ~Uncopyable(void) {}                // 허용합니다.
    
private:
    Uncopyable(const Uncopyable&);        // 하지만 복사는 방지합니다.
    Uncopyable& operator=(const Uncopyable&);
};
 
class HomeForSale: private Uncopyable {    // 복사 생성자도,
    ...                                    // 복사 대입 연산자도
};                                        // 이제는 선언되지 않습니다.

Link Error : C/C++ 컴파일러는 소스 파일 하나를 독립적으로 ‘컴파일’합니다. 그런데 컴파일할 때는 함수 원형 선언만 있고 구현이 없어도 문제 없이 컴파일을 할 수 있습니다. 단순히 함수 원형의 리턴 및 파라미터 타입만 맞으면 아무런 문제 없이 컴파일은 진행되빈다. 이렇게 개별 파일의 컴파일 작업이 끝나면 최종 실행 파일을 만들기 위해 모으는 작업, 즉 ‘링킹’을 하는데 이 때 하는 일이 ‘심볼’들을 찾아 연결하는 작업입니다. 심볼이라는 단어는 컴파일러의 관점에서 나온 단어로, 컴파일러는 함수 및 변수 같은 것을 ‘심볼 테이블’로 관리하기 때문에 나온 말입니다. 링크 과정에서는 각각의 소스 파일이 사용한 ‘심볼’을 해결해야 한다. 쉽게 말하면 사용하고 있는 함수의 실제 몸통을 어디선가 찾아야만 합니다. 만약 이 몸통을 못 찾거나 두 개 이상 찾으면 링킹은 실패하게 됩니다.

이것만은 잊지 말자!

 

  • 컴파일러에서 자동으로 제공하는 기능을 무시하려면, 미리 해당 함수를 private로 선언한 후 구현을 하지 않거나, 이를 구현한 클래스의 상속을 받으면 됩니다.

7. Declare destructors virtual in polymorphic base classes

생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수를 팩토리 함수라 부르며, 이는 파생 클래스의 객체를 동적 할당하기에 메모리를 적절히 삭제해야 합니다. 이때 기본 클래스의 소멸자가 가상이 아닌 경우, 파생 클래스의 객체를 기본 클래스의 포인터에 담아 소멸(delete)시킬때, 기본 클래스의 소멸자만 호출되고 파생 클래스의 소멸자는 호출되지 않아 자원이 누출될 수 있습니다. 따라서 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 다형성을 갖도록 설계된 클래스 혹은 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다. 이 때, 다음과 같이 몇가지 주의 사항이 존재합니다.

  • 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스, 혹은 클래스에 가상 함수가 하나라도 들어 있지 않은 경우에는 가상 소멸자를 선언하지 말아야 합니다. (가상 함수 테이블의 비용이 더 들어감)
  • 가상소멸자를 선언해준 경우, 해당함수를 호출하기위해서 Vptr이라는 녀석이 필요한데 이녀석도 포인터이기에 32비트 시스템에서 4바이트를 차지한다. 따라서 다른언어로 만들어진 소스의 해당 객체를 넘길시 vptr도 같이 넘어가기 때문에 가상소멸자가 없는 경우 기본 클래스로서의 이식성은 포기해야한다
  • STL 컨테이너 타입은 전부가 가상소멸자가 없는 클래스에 해당하므로, 이를 상속하지 말아야한다.

순수 가상 함수

C++에서 순수 가상 함수의 기계적 의미는 해당 추상 클래스의 vtbl에서 그 순수 가상 함수의 포인터가 0(=null)로 들어 있다는 것 입니다. 즉, AWOV 클래스가 이렇게 되어 있다면,

class AWOV {
	public:
		virtual ~AWOV()=0;
		virtual foo();
};

이 클래스의 vtbl은 다음과 같이 나옵니다.

vptr -> [ 0                   ]   // 첫째 : AWOV::~AWOV()의 주소
[ &(AWOV::foo)]   // 둘째 : AWOV::foo()의 주소

하지만, 가상 소멸자의 호출 매커니즘은 파생 클래스의 소멸자가 기본 클래스의 그것을 직접 호출하도록 (컴파일러에 의해) 만들어집니다. 기본 클래스의 가상 소멸자로 인한 파생 클래스의 소멸 시점은 다음과 같은 순서로 진행됩니다.

  1. 기본 클래스의 가상 소멸자 호출
  2. 1번으로 부터 파생 클래스의 소멸자 호출
  3. 2번으로 부터 기본 클래스의 소멸자 호출

반면, 순수 가상 소멸자의 호출 매커니즘은 다음과 같이 됩니다.

  1. 파생 클래스의 소멸자 호출
  2. 컴파일러가 강제로 순수 가상 소멸자 호출 (vtbl에는 순수 가상 함수 0으로 초기화 되어 있기 때문!)

vtbl을 통하지 않는다는 것이죠. 따라서 AWOV::~AWOV의 본문이 정의되어 있지 않으면 링크 에러를 내게 됩니다. 이것은 "이렇게 해도 컴파일이 되네" 수준이 아니라 "꼭 이렇게 해야 합니다"라는 의무 조항입니다.

이것만은 잊지 말자!

  • 다형성을 지닌 기본 클래스는 반드시 가상 소멸자를 선언해야하며, 어떤 클래스가 가상 함수를 지니고 있다면, 해당 클래스의 소멸자도 가상 소멸자이어야합니다.
  • 기본 클래스가 아니거나 다형성을 갖지 않는 경우 가상 소멸자를 선언하지 말야아 합니다.

8. Prevent exceptions from leaving destructors

소멸자에서는 예외가 빠져나가면 안 됩니다. 이는 메모리 leak 혹은 프로그램 강제 종료를 일으킬 수 있는 여지를 남기게 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.

  발생한 예외를 처리하기 위해 스택 되감기를 하는 도중, (소멸자에서)또 다른 예외가 발생하면 상황에 따라 프로그램이 종료되던지, 정의되지 않은 동작을 보이게 됩니다. 따라서 소멸자에서 예외가 빠져나가도록 두면 안됩니다. 이에 대한 해결 방법으로는 3가지가 존재합니다.

해결 방법 1.프로그램 종료

// close에서 예외가 발생하면 프로그램을
// 바로 끝냅니다. 대개 abort를 호출합니다.
DBConn::~DBConn(void)
{
    try { db.close(); }
    catch (...) {
        //close 호출이 실패했다는 로그를 작성합니다;
        std::abort();
    }
}

해결 방법 2. 예외 삼키기

// 에러가 발생한 후에 프로그램을 실행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택입니다.
// close를 호출한 곳에서 일어난 예외를 삼켜 버립니다.
DBConn::~DBConn(void)
{
    try { db.close(); }
    catch (...) {
        close 호출이 실패했다는 로그를 작성합니다;
    }
}

대부분의 경우에서 예외 삼키기는 그리 좋은 발상이 아닙니다. 무엇이 잘못됐는지를 알려 주는 정보가 묻혀 버리기 때문입니다. 예외 삼키기를 선택한 것이 제대로 빛을 보려면, 발생한 예외를 그냥 무시한 뒤라도 프로그램이 신뢰성있게 실행을 지속할 수 있어야합니다.

해결방법3. 문제 대처기회를 사용자에게 선 제공하자.

어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 소멸자가 아닌 보통의 함수이어야 합니다.

class DBConn {
public:
    ...
    
    void close(void) {            // 사용자 호출을 배려(?) 해서
        db.close();                // 새로 만든 함수
        closed = true;
    }
    
    ~DBConn(void) {
        if (!closed)
            try {                // 사용자가 연결을 안 닫았으면
                db.close();        // 여기서 닫아 봅니다.
            }
            catch (...) {        // 연결을 닫다가 실패하면,
                close 호출이 실패했다는 로그를 작성합니다;
                ...                // 실패를 알린 후에
            }                    // 실행을 끝내거나 예외를 삼킵니다.
    }
    
private:
    DBConn db;
    bool closed;
};

close 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 떠넘기는 방법입니다. 사용자는 close가 발생한 예외를 처리할 필요가 있다면 DBConn::close 함수를 try 블록 내에서 호출하여 예외 처리를 할 수 있습니다. 반면 예외 처리를 할 필요가 없다면 DBConn의 소멸자가 예외를 삼키거나, 프로그램을 종료시키도록 두면 됩니다.

이것만은 잊지 말자!

  • 소멸자에서 예외를 묶어두자. 방법은 삼키던가 프로그램 강제종료시키던가
  • 예외가 발생하는 함수는 보통의 함수여야 한다.(소멸자가 아닌 함수)

9. Never call virtual functions during construction or destruction

객체의 생성과 소멸과정과 같이 가상 함수가 존재하지 않는 상태라면 생성자 혹은 소멸자 안에서 가상 함수를 호출하면 안됩니다. 기본 클래스의 생성자가 호출되는 동안, 현재 생성자나 소멸자가 호출되는 동안, 클래스의 파생 클래스 쪽으로는 내려가지 않습니다. 즉, 기본 클래스 생성이 된 후에 파생 클래스가 생성되는데, 이 사이에 오버로딩한 가상함수를 호출하면 미정의 동작이 일어납니다.

 좀 더 근본적인 이야기를 하면, 파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은 그 객체의 타입이 바로 기본 클래스가 됩니다.

상속 관계에 있는 객체의 생성자와 소멸자 호출 시점은 다음과 같이 진행됩니다.

  1. 생성자 호출 시점
    • 첫째, 베이스 생성자 호출 후 베이스 생성자의 멤버 객체들 초기화
    • 둘째, 파생 생성자 호출 후 파생 생성자의 멤버 객체들 초기화
  2. 소멸자 호출 시점
    • 첫째, 파생 소멸자 호출 후 파생 생성자 멤버 객체 소멸
    • 둘째, 베이스 소멸자 호출 후 베이스 생성자 멤버 객체들 초기화

 이를 어떻게 해결하기 위해서는 가상 함수를을 비가상 멤버함수로 변경하고, 이후 파생클래스의 생성자들로 하여금 필요한 정보를 기본클래스인 생성자로 넘깁니다.

class Transaction {
	public:
		explicit Transaction(const std::string& logInfo);
		 void logTransaction(const std::string& logInfo) const; // 비가상 함수
};

Transaction::Transaction(const std::string& logInfo) { logTransaction(logInfo); } //파생클래스 
class BuyTransaction : public Transaction
{
	public:
		BuyTransaction(parameters) :Transaction(createLogString(parameters)){} //생성자에서 파라미터를 기본생성자로 넘긴다. 
	private: 
		static std::string createLogString(parameters); // 밑에서 내용확인해보자. 
};

createLogString 정적함수를 살펴보면, 이 함수는 기본클래스 생성자쪽으로 데이터를 넘기는 도우미 함수역할을 합니다. 예제코드를 보면 생성이 채끝나지 않은 BuyTransaction객체의 미초기화된 데이터 멤버를 건드릴 위험도 없습니다. 왜냐면 정적함수는 정적멤버변수만 사용하기때문이다.

이것만은 잊지 말자!

  • 생성자 or 소멸자에서 가상함수를 호출하지 말자. 가상함수라고 해도, 실행중인 생성자 or 소멸자에 해당하는 클래스로 내려가진 않으니...

10. Have assignment operators return a reference to *this

대입 연산자는 *this의 참조자를 반환하도록 만들면, 연계적으로 대입이 가능토록 됩니다.

int x, y, z;
x = y = z = 15; //우측 연관(right-associative) 연산
x = (y = (z = 15)); // 다음과 같이 분석됨.

이것은 단순 대입형 연산자 뿐만 아니라, 모든 형태의 대입 연산자(예를 들어 +=)에서 지켜져야 합니다. 이러한 관례를 따르지 않아도 컴파일은 되지만, 관례를 따르는 편이 여러 모로 좋습니다. 헷갈린다면, int(기본 제공 타입)의 작동 원리대로 만들면 됩니다.

이것만은 잊지 말자!

  • 대입 연산자는 *this 참조자를 반환하도록 만드세요.

11. Handle assignment to self in operator=

자기 대입, operator=을 구현할 때 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들어야 합니다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다. 자기 대입이 위험한 이유는, 여러 곳에 하나의 객체를 참조하는 상태, 중복 참조(aliasing)으로 인해, 자체적으로 제거 될 수가 있기 때문입니다.

문제 코드

class Bitmap {...};

class Widget { 
	...
	private:
		Bitmap *pb; // 힙에 할당된 객체를 가리키는 포인터
};

// 불안정한 '=' 연산자 구현코드
// case1. 만약 rhs와 this가 같다면 pb가 사라져버린다.
Widget& Widget::operator=(const Widget& rhs) {
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

// case2.만약 new에서 예외가 발생한다면, pb는 삭제된 Bitmap을 가리킨채 남게 된다.
Widget& Widget::operator=(const Widget& rhs) {
	if (this == &rhs) return *this; // 일치성 검사
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

해결 코드

// case1. 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한 코드 작성
Widget& Widget::operator=(const Widget& rhs) {
	if (this == &rhs) return *this; // 일치성 검사
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

------------------------------------------------
// case2. 복사 후 맞바꾸기(copy and swap) 기법

class Widget {
    ...
    void swap(Widget &rhs);    // *this의 데이터 및 rhs이 데이터를 맞바꿉니다.
    ...
};
 
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs);        // rhs의 데이터에 대해 사본을 하나 만듭니다.
    swap(temp);                // *this의 데이터를 그 사본의 것과 맞바꿉니다.
    return *this;
}
 
Widget& Widget::operator=(Widget rhs)    // rhs는 넘어온 원래 객체의 사본입니다.
{                                        // (값에 의한 전달)
    swap(rhs);                            // *this의 데이터를 이 사본의 데이터와 맞바꿉니다.
    return *this;
}

이것만은 잊지 말자!

 

  • operator=을 구현할 때, 자기 대입에 대비하여, 주소 비교, 문장 순서 조절 및 copy and swap을 통해 해결할 수 있다.
  • 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체 인 경우에 정확히 동작하는 지 확인해보자.

12. Copy all parts of an object

복사 함수

복사 생성자와 복사 대입 연산자를 통 틀어 객체 복사 함수라 합니다. 항목5에서 확인할 수 있듯이, 객체 복사 함수는 컴파일러에 의해 자동생성되빈다. 따라서 복사함수를 정의한다는 것은 기본 복사함수가 맘에 안 든다는 뜻입니다. 컴파일러는 이러한 사용자의 행동에 문제가 있어도 이를 고치지 않습니다.

 복사 함수가 모든 부분을 복사하는 것이 아닌 일부를 유실하거나 틀리게 하면 부분 복사가 됩니다. 따라서 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다. 클래스의 복사 함수 두 개(복사 생성자, 복사 대입 연산자)를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 해서는 안됩니다. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결해야 합니다.

주의할 점

  1. 기존의 클래스에서 멤버를 추가하려 할때, 복사생성자, 대입 연산자 들을 수정해야 한다.
  2. 포인터 멤버 변수일때, 깊은 복사를 해야 하는지 얕은 복사만을 해야 하는지 염두해두어야 한다.
  3. 파생 클래스에서 베이스 클래스의 복사생성자와 파생 클래스의 대입연산자도 수정해야 한다. (파생 클래스에서 베이스 클래스의 복사 생성자와 대입연산자를 호출해 주지 않기 때문이다.)

3번, 파생 클래스에서 어떻게 베이스 클래스의 멤버 변수까지 복사 하는가?

파생 클래스의 복사 생성자의 초기화 리스트에 베이스 클래스의 복사 생성자를 호출하고, 파생 클래스의 대입 연산자의 내부에서는 베이스클래스의 대입 연산자를 호출 하면 됩니다.

class PriorityCustomer : public Customer {
	public:
		…
		PriorityCustomer(const PriorityCustomer& rhs);
		PriorityCustomer& operator=(const PriorityCustomer& rhs);
	private:
		int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), priority(rhs.priority) { … }  // 파생클래스에서 기본클래스의 복사생성자를 호출한다.

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) {
	Customer::operator=(rhs); // 기본클래스 부분을 대입합니다.
	priority = rhs.priority; // 파생클래스의 멤버변수를 대입합니다.
	return *this;
}

상속을 구현할때 해당클래스의 데이터 멤버 변수 뿐만 아니라, 기본클래스의 복사함수도 꼬박꼬박 호출해야 합니다. 복사 생성자와 대입연산자는 const Class& rhs 식의 참조자 매개변수를 띄기 때문에, 슬라이스(Slice) 문제가 있어, 파생 클래스의 복사 생성자와 대입 연산자는 정의 부분에 파생 클래스 부분만을 복사하는 것을 꼭 넣어야 한다.

이것만은 잊지 말자!

  • 객체 복사 함수는 객체의 모든것을 복사 해야 한다!
  • 대입 연산자에서 복사 생성자를 호출하는 방법, 복사 생성자에서 대입 연산자를 호출하는 방법은 쓰지말자!

1. View C++ as a federation of language

초기 C++은 C언어에서 기능들이 결합된 상태에서 진화해왔습니다. 이렇게 진화해온 C++은 현재 multiparadigm programming language라 불리며, 절차적(procedural) 프로그래밍을 기본으로 하여 객체 지향, 함수식, 일반화 프로그래밍을 포함하며 메타프로그래밍 개념까지 지원합니다. 하지만 이러한 다양한 기능의 추가는 사용법의 혼란을 야기하였는데 그렇다면 어떻게 하면 이를 효과적으로 활용할 수 있을까요?

이 책에서는 C++을 단일 언어가 아닌 여러 언어의 연합체로 보는 것을 추천합니다. 각각 하위 언어의 특성을 이해함으로서 전체를 이해하도록 이끌고 있습니다. C++의 하위 언어는 다음과 같이 4가지가 존재합니다.

  • C : 블록, 문장, 선행 처리자, 데이터 타입 등 많은 부분이 C에서 제공되었습니다. 역으로 예외, 템플릿, 오버로딩과 같은 기능들은 지원하지 않습니다.
  • Object-Oriented C++ : 클래스를 쓰는 부분들이 모두 여기에 해당됩니다. 캡슐화, 상속, 다형성, 가상 함수 등 객체 지향 설계의 규칙 대부분이 포함됩니다.
  • Template C++ : C++ 일반화 프로그래밍 부분입니다.
  • STL : 템플릿 라이브러리로서, container, iterator, algorithm 및 function object가 서로 얽혀서 구성되어져 있습니다.

이러한 네가지 하위 언어들의 연합체로 이루어져있고, 각각의 부분에 따라 효과적인 코딩 방식과 규칙이 다르기 때문에, C++의 효과적인 사용을 위해서는 하위 언어들의 이해가 기반이 되어야 합니다.

이것만은 잊지 말자!

  • C++를 사용한 효과적인 프로그래밍 규칙은 C++의 어떤 부분을 사용하느냐에 따라 달라진다.

2. Prefer const, enum, inline to #define

가급적 선행 처리자보다 컴파일러를 더 활용해야 합니다. #define보다는 const, enum, inline이 더 깔끔하게 활용될 수 있습니다.

[const]

# define ASPECT_RATIO 1.653
const double AspectRatio = 1.653; // const를 사용하여 변경하지 못하도록 함.

// 문자열을 쓰는 경우
const char * const authorName = "Scott Meyers"; // char* 기반으로 쓰려면 포인터 및 대상까지 const를 써줘야합니다.
const std::string authorName("Scott Meyers"); // 따라서 string 객체에 사용하는 것이 더 효율적입니다.

// 클래스 멤버로 상수를 정의하는 경우
class GamePlayer {  // 상수의 유효범위를 클래스로 한정하는 경우는 멤버로 만들어야 합니다.
private:
    static const int NumTurns = 5;    // 클래스 상수는 구현 파일에서 정의.
    int scores[NumTurns];            // 상수를 사용하는 부분
    ...
};

 위와 같이 ASPECT_RATIO를 다음과 같이 #define으로 정의된 경우는, 선행 처리자가 ASPECT_RATIO와 같은 symbolic name으로 판단하는 것이 아닌 숫자 상수로 바꾼 후에, 컴파일을 합니다. 따라서 기호 테이블에 포함되지 않지 않기에 디버깅할 시에 문제가 발생할 수 있습니다. 또한, ASPECT_RATIO를 하나씩 1.653으로 바꿔주기 때문에 등장 횟수만큼 사본을 만들어줘야 하기에 컴파일 후의 코드 크기가 커집니다.

 또한 const 변수의 경우는 선언과 동시에 초기화가 되어야 하는데, 클래스의 정의에는 상수의 초기값이 있으면 안되기 때문에, 클래스 상수의 정의는 구현 파일에 둡니다. 또한 #define의 경우는 유효범위 개념이 없기 때문에 이렇게 활용 불가능합니다. 가끔 오래된 컴파일러가 이렇게 작동하지 않는 경우가 존재하는데, const 상수의 선언을 헤더 파일에 두고, 정의는 구현파일에서 하면 작동합니다.

[enum]

class GamePlayer {
private:
    enum { NumTurns = 5 };    // "나열자 둔갑술": NumTurns를
                            // 5에 대한 기호식 이름으로 만듭니다.
    int scores[NumTurns];
    ...
};

해당 클래스 컴파일하는 도중에 클래스 상수의 값이 필요할 때(배열 멤버를 선언할 때 사용하는 경우), 위에서 말한 방법이 통하지 않을 수도 있습니다. 따라서 enum을 통해서 이를 회피할 수 있습니다. 또한 enum의 경우 #define처럼 쓸데 없는 메모리 할당도 절대 저지르지 않습니다.

[inline]

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
 
int a = 5, b = 0;
 
CALL_WITH_MAX(++a, b);        // a가 두 번 증가합니다.
CALL_WITH_MAX(++a, b + 10);    // a가 한 번 증가합니다.

template <typename T>
inline void callWithMax(const T& a, const T& b)
{
    f(a > b ? a : b);
}

 #define 함수를 매크로 함수로서 사용이 가능합니다. 이는 호출 오버헤드를 일으키지 않는 매크로인데, 이는 괴현상을 일으킬 수 있습니다. 함수 호출을 없애줄 수 있지만, 좋지 않는 결과를 낼 수 있습니다. 이를 대체할 수 있는 것이 inline입니다. 이 함수를 템플릿이기 때문에 동일 계열 함수군을 만들어냅니다. 따라서 매크로 함수의 효율은 그대로 유지하면서도, 정규 함수의 동작 방식 및 타입 안정성까지 완벽히 취할 수 있습니다.

이것만은 잊지 말자!

  • 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을, macro 함수를 만들 때는 inline함수를 우선시하자.

3. Use const whenever possible

const는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 의미적인 제약입니다. const는 다양하게 사용될 수 있습니다. 다음과 같이 포인터에 사용할 수 있습니다.

char greeting[] = "Hello";
char *p = greeting; // 비상수 포인터, 비상수 데이터
char const *p = greeting; // 비상수 포인터, 상수 데이터
const char *p = greeting; // 상수 포인터, 비상수 데이터
const char const *p = greeting; // 상수 포인터, 상수 데이터

const 포인터의 경우는 좌표값이 바뀔 수 없고, const 데이터의 경우는 값이 바뀔 수 없습니다. 또한, operator의 경우 const가 붙는 경우가 많은데 이는 (a * b) = c 와 같이 이항 연산자에서 리턴 타입이 상수성을 갖지 않아 위와 같은 대입이 되는 경우를 사용될 수 있습니다.

상수 멤버 함수

멤버 함수에 붙는 const 키워드는 "해당 멤버 함수가 const 객체에 대해 호출될 함수이다"라는 것을 명시합니다. 이는 두가지 이유로 중요합니다.

  • 먼저 클래스의 인터페이스를 이해하기 좋게 하기 위함입니다. const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.
  • c++ 성능을 높이는 reference to const를 진행하려면, 상수멤버 함수가 준비되어 있어야 합니다. const 키워드 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능함.

비트수준 상수성 vs 논리적 상수성

컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍 해야 합니다.

  • 비트수준 상수성(물리적 상수성): 어떤 객체의 멤버 함수가 객체의 정적 멤버를 제외한 데이터 멤버를 건드리지 않은 경우에만 그 멤버 함수가 'const'임을 인정합니다. 하지만 이때 아래와 같이 비트적 상수성은 지켜지고 있으나 원하지 않던 방향으로 진행되어 버리는 경우가 존재합니다. 이에 논리적 상수성이 등장합니다.
class CTextBlock {
	public : 
		…
		char& operator[](std::size_t position) const {return pText[position]; }
	private :
		char *pText;
};

const CTextBlock cctb("hello");
char *pc = &cctb[0];
*pc = 'J';
  • 논리적인 상수성: 유연성을 부여하여 상수 멤버 함수도 객체의 일부 몇 비트를 바꿀 수 있되 사용자 측에서 알아채지 못하도록(객체의 상태에 영향을 주지 않도록) 해야 합니다. 따라서 이를 mutable로 통제합니다.
class CTextBlock {
	public : 
		…
		std::size_t length() const;
	private :
		char *pText;
		mutable std::size_t textlength;
		mutable bool lengthIsValid; //mutable 사용할시, 상수멤버함수에서 데이터멤버를 변경할수있다.
    std::size_t Ctextblock::length() const {
	    if(!lengthIsValid) {
		    TextLength = std::strlen(pText);
		    lengthIsValid = true;
	}
	return textlength;
}

코드 중복 피하기

상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만들어야 합니다. 비상수 버전이 const_cast 및 static_cast를 활용합니다.

이것만은 잊지 말자!

  • const를 붙여 선언하면 컴파일러가 사용 상의 에러를 잡아내는 데 도움을 준다
  • 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 사용자는 노릴적인 상수성을 사용하여 코딩해야함.
  • 상수 멤버 및 비상수 멤버 함수가 서로 똑같게 구현되어 있을 경우, 코드 중복을 피해야하므로, 비상수 버전이 상수 버전을 호출하도록 만드는 것이 좋음.

4. Make sure that objects are initialized before they're used

객체의 값을 초기화하는데, 초기화가 잘 되는 경우가 있고 그렇지 않은 경우가 있습니다. 잘 초기화가 되지 않은 값을 읽는 경우 잘 작동하는 경우가 존재하는데, 객체 내부가 이상한 값으로 가득차게 됩니다. 따라서 사전에 모든 객체를 항상 초기화하는 것이 중요합니다. 특히 객체의 경우는 초기화가 생성자로 귀결되는데, 이를 대입을 통해도 해결 할 수 있지만, 멤버 초기화 리스트를 통하면 좀 더 깔끔하고 생성자만 발동시키기 때문에 더 효율적입니다. 주의할 점으로는 두가지가 있습니다.

  • 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열시킵니다. 이를 깔끔하게 하기 위해서는 초기화 가능한 데이터멤버들을 모아서 함수화한다.
  • const거나 reference로 되어 있는 데이터 멤버의 경우에는 반드시 초기화 되어야하기 때문에 멤버를 초기화 리스트를 넣는 것이 의무화됩니다.

초기화 순서는 기본 클래스→파생클래스 순으로 호출합니다.

class score
{
	private:
	    int korean;
	    int math;
	    int english;
	public:
	    babo(int a, int b, int c): korean(a), math(b), english(c){ };
};

 정적 객체(static object)는 객체 생성 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 일컫습니다. 정적 객체로는 전역 객체, **네임스페이스 유효범위**에서 지정된 객체, **클래스나 함수** 내에서 static으로 선언된 객체, 그리고 **파일 유효범위**에서 static으로 정의된 객체가 있습니다. 즉, 이 다섯 종류는 프로그램 종료 시점에 소멸됩니다. 또한 정적 객체는 함수안에 있으면 지역 정적 객체라 불리며, 그 외는 비지역 정적 객체라 불려진다.

 이러한 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해집니다. 여기서 **번역 단위(translation unit)**는 컴파일을 통해 하나의 object file을 만드는 바탕이 되는 소스 코드를 일컫습니다. 앞의 말을 풀어서 설명하면, 여러 소스 파일이 있을 때, 한 파일에서 다른 소스 파일의 정적 객체를 참조하는데 이 객체가 초기화가 되어 있지 않을 수도 있다는 것입니다. 따라서 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야만 합니다. (비지역 객체는 지역 객체로 설정하면 된다!)

class FileSystem { ... };

FileSystem& tfs(void)            // 이 함수는 FileSystem 클래스 안에
{                                // 정적 멤버로 들어가도 됩니다.
 
    static FileSystem fs;        // 지역 정적 객체를 정의하고 초기화 합니다.
    return fs;                    // 이 객체에 대한 참조자를 반환합니다.
}                                // 지역 정적 객체는 함수가 처음 불릴 때
                                // 반드시 초기화 됩니다.
class Directory { ... };
 
Directory::Directory( params )
{
    ...                            // tfs의 참조자였던 것이 tfs()로 바뀌었습니다.
    std::size_t disks = tfs().numDisks();
    ...
}

이것만은 잊지 말자!

  • 기본제공 타입의 객체는 직접 손으로 초기화.
  • 생성자에서는 멤버 초기화 리스트를 즐겨 사용할 것
  • 비지역 정적 객체들의 초기화 순서 문제를 피하기 위해 지역 정적 객체로 바꿔서 설계.

+ Recent posts