[Effective C++ 3판] Chapter 5. 구현 (항목 26~31)

Chapter 5. 구현 (항목 26~31)

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

변수를 함수 처음에 다 정의할 때가 있다. 하지만 이러면 함수 내부에서 분기가 됨에 따라 특정 변수를 사용하지 않을 수 도 있기때문에 비효율적이다.
그래서 변수 정의도 효율을 고려하여 최적의 위치에 해야한다.
// 방법 1
// 생성자 1번 + 소멸자 1번 + 대입 n번
for (Data data, int i = 0; i < n; ++i) {
    data = func(i);
}
// 방법 2
// 생성자 n번 + 소멸자 n번
for (int i = 0; i < n; ++i) {
    Data data = func(i);
}
// 방법 1 if cost(대입) > cost(생성자 + 소멸자),
// 방법 2 otherwise.

[이것만은 잊지 말자]
- 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다.

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

[C style cast]
(T) expression  <=>  T(expression)

[C++ style cast]
const_cast, dynamic_cast, reinterpret_cast, static_cast

캐스팅은 공짜가 아니다. (런타임시 실행되는 코드가 증가한다.) 따라서 절약할 수 있는 한 절약해야한다. 특히 dynamic_cast는 주의해야한다.

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

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

class Example {
public:
    const Data& getData() const { return data; }
private:
    Data data;
};

int main() {
    const Data &data = Example().getData();
    // data is dangling handle!
}

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

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

[예외 안정성의 세 가지 보장 종류]
- 기본적인 보장(basic guarantee) : 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장. 모든 객체의 상태는 내부적으로 일관성을 유지하고 있다. 그러나 객체 각각의 상태가 일관성이 유지될 뿐이지, 프로그램 전체의 논리나 상태가 일관성을 유지하는 것을 보장하진 않는다.
- 강력한 보장(strong guarantee) : 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장. 즉, 함수의 동작이 원자적(atomic)이다.
- 예외불가 보장(nothrow guarantee) : 예외를 절대로 던지지 않겠다는 보장.
* 예외 안정성을 갖춘 함수는 위 3가지 보장 중 하나를 반드시 제공해야한다. 현실적으로 기본적인 보장과 강력한 보장중 한 가지를 제공하게 된다.

강력한 보장을 제공하면 좋겠지만, 불가능한 경우도 있고 현실적으로 '효율' 문제때문에 제공이 힘들 수 있다. (copy-and-swap기법등을 사용함에 있어서 overload가 있기때문에...)

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

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

인라인을 과하게 하면 코드크기가 커질 수 있고, 코드가 캐쉬되는 것의 효과를 못 볼 수 있다. 그리고 inline함수 내용이 변경되면 inline함수를 사용하는 모든 소스파일들을 재 컴파일해야하는 단점도 있다. 따라서 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는게 좋다. 개인적으로 어떤 함수들을 inline 해야되는 지에 대한 결정은 프로젝트 구현이 완료될 시점에 수행하는 것이 좋을 것같다.

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

항목 31. 파일 사이의 컴파일 의존성을 최대로 줄이자.

[용어 정리]
클래스 선언 : class Example;
클래스 정의 : class Example { ... };

[전략]
1. 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다.
    어떤 타입에 대한 참조자 및 포인터를 정의할 때는 타입의 선언부만 필요.
2. 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다.
    굳히 클래스 정의가 필요하지 않을 경우에는 클래스 선언에만 의존하도록 한다.
3. 선언부와 정의부에 대해 별도의 헤더 파일을 제공합니다.
    클래스에 대해 클래스 선언, 클래스 정의 별도로 헤더 파일을 제공함으로서 사용자가 경우에 따라 골라서 쓸 수 있도록 한다.

클래스 선언과 클래스 정의에 대한 의존성을 분리해서 생각하는 것으로도 컴파일 의존성을 줄일 수 있지만 클래스의 '인터페이스'와 '구현'을 분리하면 컴파일 의존성을 더욱 더 줄일 수 있다 (어차피 다른 곳에서는 '구현'은 알 필요가 없고 '인터페이스'만 알면 되기때문에). 구체적인 방법으로는 '핸들 클래스'와 '인터페이스 클래스'가 있다. 핸들 클래스는 pimpl 관용구(pointer to implementation)를 이용하는 방법이다. 인터페이스 클래스는 인터페이스들만 모아서 클래스를 만들고, 그 클래스를 상속하는 구현 클래스를 작성하는 방법이다.
// 예제를 보여주기 위해서 한 개의 파일에 모든 것을 넣었다.
// 실제상황에서 아래 방법들을 사용할 때는 파일 분할을 해야만 한다.

// 핸들 클래스 기법에 대한 예제
namespace HandleClassExample {

    // 실제 구현에 대한 내용을 가지고 있는 클래스
    // ExampleImpl은 유저에게 완전히 숨겨진다.
    class ExampleImpl {
    public:
        ExampleImpl(const Data &data)
            :data(data)
        {}
        Data getData() const { return data; }
    private:
        Data data;
    };

    // ExampleImpl에 대한 핸들 역할을 하는 클래스.
    // 유저에겐 Example의 선언 혹은 정의만 제공된다.
    class Example {
    public:
        Example(const Data &data)
            :pImpl(std::make_shared<ExampleImpl>(data))
        {}
        Data getData() const { return pImpl->getData(); }
    private:
        std::shared_ptr<ExampleImpl> pImpl;
    };
}

// 인터페이스 클래스 기법에 대한 예제
namespace InterfaceClassExample {

    // ExampleImpl에 대한 인터페이스 역할을 하는 클래스.
    // 유저에겐 Example의 선언 혹은 정의만 제공된다.
    class Example {
    public:
        virtual ~Example() = default;
        virtual Data getData() const = 0;
        static std::shared_ptr<Example> create(const Data &data) {
            return std::make_shared<ExampleImpl>(data);
        }
    };

    // 실제 구현에 대한 내용을 가지고 있는 클래스
    // ExampleImpl은 유저에게 완전히 숨겨진다.
    class ExampleImpl : public Example {
    public:
        ExampleImpl(const Data &data)
            :data(data)
        {}
        Data getData() const { return data; }
    private:
        Data data;
    };
}

핸들 클래스는 pimpl관용구를 사용함으로 연산, 메모리상의 overhead가 존재하고, 동적할당을 사용함에 있어서의 성능상 overhead와 예외문제가 발생할 소지가 있다. 인터페이스 클래스는 가상함수를 사용함으로 이에 따라 연산, 메모리상의 overhead가 존재하고, factory함수에서 동적할당에 따른 overhead와 예외문제도 생길 수 있다.
이렇듯 핸들 클래스와 인터페이스 클래스는 캡슐화, 컴파일 의존성 최소화 측면에서 매우 유용하지만 런타임시 성능상의 overhead가 존재한다. 따라서 무조건 사용하는게 아니라 상황과 조건에 따라 사용여부를 결정해야 한다.

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



후 이번 포스팅도 원래 2일전에 (70%)작성된 건데 어제 낮에는 일이 있어서 완성을 못했고, 밤부터 마저 작성을 시작해서 12시가 넘겨서 완료를 하게됐네요. 내일이 아니라 오늘도(?) 포스팅을 1개 하도록 하겠습니다. 아.. 시간묘사가 너무 어렵군요... 이래서 12시 이전에 모든걸 끝내고 자야해...ㅠㅜ
어쨌든 이번 챕터에서 항목31의 핸들 클래스와 인터페이스 클래스 기법에 대해서 생각이 많아집니다.. 코드도 더 깔끔해지는 것 같고, 캡슐화와 컴파일 의존성 최소화 측면에서도 좋고... 하지만 '런타임'시 overhead가 존재하니... 사용을 해야할 지 말아야 할 지 참 애매~합니다 그쳐?? 이런 걸 판단하는 게 아마 경험이 아닐 까 싶습니다. '논리'를 통해서 깨닫고 터득할 수 있는 것도 참 많지만 경험이 이럴 때 참 중요한 것 같습니다... 어쨌든 항상 설계할 때마다 설계의 깔끔함과 퍼포먼스 중에서 고민할 때가 많은데...pimpl관용구가 정말로 그런 경우 같습니다. 하하... 그러면 다음에 만나요~~ (급마무리)

댓글

  1. 이 글을 작성한 시점에는 제가 역량이 부족해서, 쓸 때없이 std::shared_ptr<> 를 남발했었군요..

    답글삭제

댓글 쓰기