41. 템플릿 프로그래밍의 천릿길도 암시적 인터페이스와 컴파일 타임 다형성부터

객체 지향 프로그래밍의 핵심은 명시적 인터페이스와 런타임 다형성이다.

하지만 템플릿과 일반화 프로그래밍에서는 암시적 인터페이스와 컴파일 타임 다형성이 핵심이다.

 

명시적 인터페이스는 대개 함수 시그니처로 이루어진다. 함수의 이름, 매개변수 타입, 반환 타입 등등이다.

반면 암시적 인터페이스는 유효 표현식으로 이루어진다.

 

template<typename T>
void doProcessing(T& w) {
    if (w.size() > 10 && w != someNastyWidget) ..
}

 

이런 경우 타입 T에서 제공될 암시적 인터페이스에는 size와 operator!=를 지원해야 하는 제약이 걸린다.

size 멤버 함수를 지원해야 하는 것은 맞지만 반드시 수치 타입을 반환할 필요까지는 없다. 그저 어떤 X 타입의 객체와 int가 함께 호출될 수 있는 operator>가 성립될 수 있도록 X타입의 객체만 반환하면 된다.

operator!=의 경우 타입의 암시적 변환이 가능하다면 유효 호출로 간주된다.

 

표현식에 걸리는 제약은 복잡해 보이지만 일반적으로는 평이한 편이다.

예를 들어 위와 같은 조건식인 경우에 size, operator>, operator&&, operator!= 함수에 대한 제약을 일일이 집어내려면 난감하지만, 결과적으로 if문의 조건식 부분은 boolean 표현식이어야 하기 때문에 표현식에서 쓰이는 것들이 정확히 어떤 값을 내놓던지 간에 bool과 호환되면 된다.

이 제약이 암시적 인터페이스의 일부이다.

 

 

if (w.size() > 10 && w != someNastyWidget) ...
// w.size()는 반드시 int size(void)일 필요는 없다
// X size(void)가 되더라도 X객체가 operator>를 지원하기만 하면 된다

 

결과적으로 조건식이 성립하기만 하면 명시적 인터페이스가 아니라도 괜찮은 것이다.

템플릿 안에서 어떤 객체를 쓰려고 할 때, 암시적 인터페이스를 그 객체가 지원하지 않으면 컴파일 오류가 발생한다.

 

◾ 클래스 및 템플릿은 모두 인터페이스와 다형성을 지원합니다.

◾ 클래스의 경우, 인터페이스는 명시적이며 함수의 시그니처를 중심으로 구성되어 있습니다.

◾ 템플릿 매개변수의 경우, 인터페이스는 암시적이며 유효 표현식에 기반을 두어 구성됩니다. 다형성은 컴파일 중에 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타납니다.

 

 

42. typename의 두 가지 의미를 제대로 파악하자

템플릿의 타입 매개변수를 선언할 때 class와 typename은 뜻이 완전히 똑같다.

하지만 typename에는 한가지 의미가 더 있다. (추가: 모던 C++에서는 차이가 아예 없는것 같다)

 

template<typename T>
void print(const T& container) {   
    if (container.size() >= 2) {
    T::const_iterator iter(container.begin());
    // 만약 T::const_iterator가 타입이 아니라 그냥 정적 멤버라면?
    ++iter;
    int value = *iter;
    std::cout << value;
    }
}

 

위와 같이 const_iterator가 T에 의존하는 중첩 의존 타입 이름(종속적 형식 이름)이라면 모호성이 발생할 가능성이 생긴다.

T::const_iterator는 반드시 타입이어야만 하는데 전역 변수이거나 정적 멤버일 가능성이 있기 때문이다.

 

컴파일러가 친절하게 알려준다

 

typename T::const_iterator iter(container.begin());

 

typename 키워드를 붙여줌으로써 반드시 타입이라는 것을 명시한다.

단, 중첩 의존 타입 이름 식별 이외에는 붙여선 안된다.

 

template<typename T> // typename 쓸 수 있음
void f(const T& container, // typename 쓰면 안됨
       typename T::iterator iter); // typename 꼭 써야함

template<typename T>
class Derived: public Base<T>::Nested { // 상속받는 기본 클래스는 typename 쓰면 안됨
public:
    explicit Derived(int x) : Base<T>::Nested(x) { // 초기화 리스트의 기본 클래스는 typename 쓰면 안됨
        typename Base<T>::Nested temp; // 중첩 의존 타입 이름이기 때문에 typename 반드시 필요
    }
};

 

예외적으로 기본 클래스의 리스트에 있거나 멤버 초기화 리스트의 기본 클래스 식별자로 존재하는 경우는 붙여줘서는 안된다.

 

 

template<typename IterT>
void workWithIterator(IterT iter)
{
    typedef typename std::iterator_traits<IterT>::value_type value_type; // 타입 재정의
    value_type temp(*iter);
}

 

여담으로 타입이 너무 길면 타입 재정의로 줄여서 사용할 수 있다. 멤버 이름과 똑같이 짓는것이 관례이다.

 

◾ 템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방합니다.

◾ 중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용합니다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외입니다.

 

 

43. 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자

template<typename T>
class MsgSender {
public:
    void sendClear(const MsgInfo& info) { ... }
    void sendSecret(const MsgInfo& info) { ... }
};

template<typename T>
class LoggingMsgSender: public MsgSender<T> {
public:
    void sendClearMsg(const MsgInfo& info)
    {
        sendClear(info); // 컴파일 에러
    }
};

 

템플릿으로 만들어진 기본 클래스의 멤버 함수를 기존과 같이 호출하면 컴파일러는 클래스가 어디서 파생된 것인지 모르기 때문에 에러를 출력하게 된다.

템플릿 매개변수가 무엇이 될지 모르는 상황이므로 sendClear가 존재하는지 알 수 없기 때문이다.

 

예를 들어서 템플릿 특수화가 적용된 클래스에 sendClear 함수가 존재하지 않을수도 있다.

기본 클래스 템플릿은 언제라도 특수화될 수 있고, 특수화 버전에서 인터페이스가 항상 동일하리라는 보장이 없다.

이 문제를 해결하기 위한 세 가지 방법이 존재한다.

 

 

this->sendClear(info);

 

첫 번째는 this->를 붙여서 해당 함수가 반드시 상속된다고 가정시킨다.

 

 

using MsgSender<T>::sendClear;

 

두 번째는 클래스 내부에서 using 선언을 통해 파생 클래스의 유효 범위로 끌고온다.

마찬가지로 반드시 상속된다고 가정한다.

 

 

MsgSender<T>::sendClear(info);

 

세 번째는 기본 클래스의 함수라는 것을 명시적으로 지정한다.

다만 이 방법은 호출되는 함수가 가상 함수인 경우, 가상 함수 바인딩이 무시되기 때문에 좋은 해결 방법이 아니다.

 

세가지 방법 모두 인터페이스를 그대로 제공할 것이라고 컴파일러에게 약속하는 것이다.

그리고 그 약속이 지켜지지 않으면 컴파일 에러가 발생한다.

 

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

 

 

44. 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자

템플릿이 아닌 코드에서는 코드 중복이 명시적이기 때문에 중복되는 부분을 눈으로 찾기 쉽다. 중복되는 부분을 찾았다면 별도로 만들어서 호출하거나 상속받으면 될 것이다.

하지만 템플릿은 코드 중복이 암시적이기 때문에 실제로 인스턴스화 될 때 발생할 수 있는 코드 중복을 예측으로 알아내야 한다.

 

 

template<typename T, std::size_t n>
class SquareMatrix {
public:
    void invert;
};

SquareMatrix<double, 5> sm1;
sm1.invert(); // SquareMatrix<double, 5>::invert 호출
SquareMatrix<double, 10> sm2;
sm2.invert(); // SquareMatrix<double, 10>::invert 호출

 

위의 코드같은 경우 템플릿 매개변수만 다를뿐 동작은 완전히 동일한 함수가 2개가 생성된다.

코드 비대화를 일으키는 일반적인 원인 중 하나이다.

 

떠올릴 수 있는 단순한 해결 방법으로는 size_t를 매개변수로 받는 별도의 함수를 만들어서 호출하는 방법이 있겠다.

 

 

template<typename T>
class SquareMatrixBase {
protected:
    void invert(std::size_t matrixSize);
};

template<typename T, std::size_t n>
class SuqareMatrix: private SquareMatrixBase {
private:
    using SquareMatrixBase<T>::invert;
public:
    void invert() { this->invert(n); } // 이미 using 선언이 되어있기 때문에 this->를 안붙여도 됨
};

 

두 객체가 기본 클래스의 invert를 호출하는것에 더불어 인라인화 되기 때문에 코드 비대화를 크게 줄일 수 있다.

 

다만 기본 클래스에서 처리할 데이터는 파생 클래스에서밖에 모르기 때문에 기본 클래스로 넘겨줄 방법을 찾아야 한다.

 

template<typename T>
class SquareMatrixBase {
public:
    void invert(T* data, std::size_t n);
    void size(T* data, std::size_t n);
    ... // 계속 늘어난다면?
};

 

단순히 기본 클래스의 invert의 매개변수로 포인터를 추가하는 것은 함수가 추가될때마다 계속 매개변수를 추가로 달아줘야 하기 때문에 좋은 해결책은 아니다.

이 방법을 제외하고 기본 클래스의 멤버에서 파생 클래스가 가지고 있는 데이터의 포인터를 가지고 있으면 된다.

기본 클래스에서 데이터의 포인터를 가지게 됨으로써 파생 클래스의 멤버 함수들 대다수가 기본 클래스 버전을 호출하는 단순한 인라인 함수가 될 수 있어서 코드 비대화의 측면에서 매우 좋은 성과를 거둘 수 있다.

실행 코드가 작아지기 때문에 캐시 참조 지역성도 향상될 수 있다.

 

◾ 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어집니다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 됩니다.

◾ 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 종종 없앨 수 있습니다.

◾ 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있습니다.

 

 

45. "호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방!

같은 템플릿으로 만들어진 인스턴스들끼리는 아무런 관계도 성립하지 않는다.

템플릿 매개변수의 클래스들이 상속관계라고 하더라도 별개의 클래스로 본다.

 

template<typename T>
class SmartPtr {};

// Top<-Middle<-Bottom 상속 관계
SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle); // 성립x
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom); // 성립x

 

이 코드가 성립되게 하려면 모든 생성자를 일일이 만들어 두어야 한다. 물론 현실적으로 말도 안되는 얘기이다.

 

 

template<typename T>
class SmartPtr {
public:
    template<typename U>
    SmartPtr(const SmartPtr<U>& other); // 일반화 복사 생성자
};

 

생성자 함수 대신 생성자 템플릿으로 만들어주면 호환되는 생성자를 일일이 만들지 않아도 인스턴스화 된다.

말로 풀어내면 SmartPtr<T> 객체가 SmartPtr<U>로부터 생성될 수 있다는 이야기이다.

다만 상속 관계가 역행할 가능성이 존재하기 때문에 타입 변환 제약을 줘야 한다.

 

 

template<typename T>
class SmartPtr {
public:
    template<typename U>
    SmartPtr(const SmartPtr<U>& other) : heldPtr(other.get()) { ... }
    // T*타입에서 U*타입 포인터로 변환
    
    T* get() const { return heldPtr; }
private:
    T* heldPtr;
};

 

T* 타입을 U* 타입으로 암시적으로 변환하기 때문에 T와 U간의 호환성이 반드시 지켜져야만 한다.

 

멤버 함수 템플릿은 생성자뿐만 아니라 대입연산에도 많이 사용된다.

여기서 한가지, T타입과 U타입이 동일하게 들어오는 경우에도 일반화 복사 생성자는 복사 생성자를 인스턴스화 한다.

보통의 복사 생성자까지 필요하다면 직접 선언해야 한다.

 

template<class T>
class shared_ptr {
public:
    shared_ptr(const shared_ptr& r); // 복사 생성자
    template<class Y>
    shared_ptr(const shared_ptr<Y>& r); // 일반화 복사 생성자
    ...
};

 

◾ 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용합시다.

◾ 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도, 보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 합니다.

 

 

46. 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자

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

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

 

예전에 다뤘던 코드를 템플릿으로 만들었다.

모든 매개변수에 대해 암시적 타입 변환이 되도록 하기 위해서 연산자 오버로드를 비멤버 함수로 만들었고, 정상적으로 동작했었다.

하지만 템플릿이 적용되면 얘기가 달라진다. 함수 템플릿의 템플릿 인자 추론 과정에서는 암시적 타입 변환이 고려되지 않기 때문이다.

 

 

Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2; // 에러

 

 

생성자에 explicit가 붙지 않아서 암시적 변환이 가능하다 하더라도 함수 템플릿 추론 과정은 암시적 타입 변환이 고려되지 않기 때문에 소용이 없다.

 

다만 클래스 템플릿에서는 템플릿 인자 추론의 영향을 받지 않기 때문에 friend 키워드를 붙여서 operator*를 클래스 내부로 끌고온다.

 

template<typename T>
class Rational {
public:
    ...
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

 

이제 T의 정확한 정보는 Rational<T>가 인스턴스화 될 때 바로 알 수 있고 동시에 operator*도 인스턴스화 된다.

다만 위처럼 선언만 하면 컴파일은 통과하지만 정의부가 없기 때문에 링크 에러가 발생한다.

내부에서 정의까지 해주면 빌드가 정상적으로 통과된다.

 

 

Rational<int> oneHalf(1, 2); // Rational<int> operator*도 같이 인스턴스화
Rational<int> result = oneHalf * 2; // 상수 2는 Rational 생성자의 암시적 변환에 의해 객체화 된다

 

상수 2가 정상적으로 객체화 된다

모든 타입을 지원하기 위해서는 비멤버 함수여야 하고, 함수가 자동으로 인스턴스화 되기 위해서는 클래스 내부에 선언되어야만 한다. 이 두가지가 성립되기 위해 자연스럽게 나온것이 프렌드 함수이다.

 

마지막으로, 클래스 내부에 정의된 함수는 암시적으로 인라인 함수로 선언된다. 그렇기 때문에 꽤 복잡한 내용이 함수의 내용으로 들어가 있다면 다른 도우미 함수만 호출하는것이 효율적이다.

 

 

template<typename T> class Rational; // 전방 선언

template<typename T>
const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs) { ... }

template<typename T>
class Rational {
public:
    friend Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)
    {
        return doMultiply(lhs, rhs);
    }
};

 

doMultiply 함수 템플릿은 맨 처음 문제가 발생했던것과 똑같이 oneHalf * 2 같은 혼합형 연산을 지원하지 못하지만 지원할 필요가 전혀 없다.

애초에 Rational<T>의 operator*만 doMultiply를 호출할 것이고 항상 Rational<T> 타입으로 변환되어서 전달되기 때문이다.

 

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

 

 

47. 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자

STL의 utility에는 반복자를 지정된 거리만큼 이동시키는 advance라는 함수가 있다.

단순히 += 연산으로 처리할 수 있을것 같지만 이게 가능한 반복자는 임의 접근 반복자 밖에 없다. 나머지는 ++, -- 연산만 지원하기 때문에 증감연산을 d만큼 적용하는 것으로 구현해야 한다.

 

반복자의 타입과 지원하는 연산

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 함수는 모든 반복자에 대해 호환성을 제공해야 하기 때문에 반복자의 타입을 알아야 할 필요가 있다.

단순히 모든 접근 반복자에 대해서 증감 연산을 d번 반복하면 되지만 임의 접근 반복자에 대해 성능이 전혀 보장되지 않기 때문에 호환성은 보장될지언정 성능이 보장되지 않는다.

 

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    if (/* iter가 임의 접근 반복자이다 */) {
        iter += d;
    }
    else {
        if (d >= 0) { while (d--) ++iter; }
        else { while (d++) --iter; }
    }
}

 

조건문을 구현하는 것이 최종 목표가 될 것이다.

조건문을 만족시키려면 어떤 타입인지를 알아야 한다는 것인데, 이는 특성정보를 통해 알아낼 수 있다.

특성정보는 컴파일 도중에 어떤 타입인지 알아낼 수 있는 객체를 지칭하는 개념이다. (RTTI)

 

특성정보는 정의된 문법이 아니라 관례적인 구현 기법이다.

반복자의 경우는 이미 iterator_traits라는 이름으로 준비되어 있다.

iterator_traits<IterT> 안에는 IterT 타입 각각에 대해 iterator_category라는 재정의 타입이 선언되어있다.

 

deque의 iterator_category

set, list 등 각기 다른 컨테이너들도 마찬가지로 iterator_category를 가지고 있으며 대신 태그가 다르다.

 

특성정보 클래스의 설계 및 구현 방법은 크게 3가지이다.

◾ 다른 사람이 사용하도록 열어 주고 싶은 타입 관련 정보를 확인한다.

◾ 그 정보를 식별하기 위한 이름을 선택한다. (ex: iterator_category)

◾ 지원하고자 하는 타입 관련 정보를 담은 템플릿 및 그 템플릿의 특수화 버전을 제공한다. (ex: iterator_traits)

 

 

마지막으로, IterT의 타입은 컴파일 타임에 파악되지만 if문은 런타임 도중에 평가되기 때문에 시점이 어긋난다.

 

template<typename IterT, typename DistT> // 컴파일 타임에 파악
void advance(IterT& iter, DistT d)
{
    if (typeif(typename std::iterator_traits<IterT>::iterator_category) // 런타임에 평가
        == typeid(std::random_access_iterator_tag))
}

 

함수 오버로딩을 통해서 둘을 분리시킨다.

 

template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::random_access_iterator_tag) // 임의 접근 반복자
{
    iter += d;
}

template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::std::bidirectional_iterator_tag) // 양방향 접근 반복자
{
    ...
}

...

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    doAdvance(iter, d, std::iterator_traits<IterT>::iterator_category);
    // 3번째 인자로 태그를 넘겨준다
}

 

advance 함수에는 더 이상 조건문이 없기 때문에 문제가 생기지 않는다.

 

정리

 

◾ "작업자" 역할을 맡을 함수 혹은 함수 템플릿(ex: doAdvance)을 특성정보 매개변수를 다르게 하여 오버로딩한다.

◾ 작업자를 호출하는 "주작업자" 역할을 맡을 함수 혹은 함수 템플릿(ex: advance)을 만든다. 이 때, 특성정보 클래스에서 제공되는 정보를 넘겨서 작업자를 호출하도록 구현한다.

 

◾ 특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관련 정보를 만들어냅니다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현합니다.

◾ 함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if...else 점검문을 구사할 수 있습니다.

 

 

48. 템플릿 메타프로그래밍, 하지 않겠는가?

템플릿 메타프로그래밍(TMP)은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다.

TMP를 사용하면 다른 방법으로는 까다롭거나 불가능한 일을 매우 쉽게 할 수 있고, 작업을 런타임 영역에서 컴파일 타임 영역으로 전환시킬 수 있다.

런타임 도중에 찾을 수 있는 에러들이 컴파일 타임으로 끌려오는 것이다.

부수적으로 컴파일 타임에 모두 동작하기 때문에 실행 시간이나 코드의 크기, 메모리를 적게 사용한다. 딱 하나, 컴파일 타임이 길어진다.

 

TMP는 그 자체가 튜링 완전성을 갖고 있다. 변수 선언, 루프 실행, 함수 작성 및 호출 등 모두 가능하다.

대신 구문요소가 보통의 C++과는 많이 다르다.

 

예를 들어 팩토리얼을 계산하는 방식이 있다.

 

// 재귀식 템플릿 인스턴스화. 컴파일 타임에 계산된다
template <int N>
struct Factorial {
    static const int result = N * Factorial<N - 1>::result;
};

template <>
struct Factorial<1> {
    static const int result = 1;
};


// 일반적인 재귀. 런타임에 계산된다
int factorial(int n) {
  if (n == 1) return 1;

  return n * factorial(n - 1);
}

 

 

형태가 많이 다르다.

C++에서의 TMP가 효과적인 사용처는 세 곳 정도이다.

◾ 치수 단위의 정확성 확인

모든 치수 단위의 조합이 제대로 됐는지를 컴파일 타임에 확인할 수 있기 때문에 정확성을 보장할 수 있다.

◾ 행렬 연산의 최적화

표현식 템플릿을 사용하면 행렬 연산에 사용되는 임시 객체를 없앰과 동시에 루프도 합쳐버릴 수 있다.

◾ 맞춤식 디자인 패턴 구현의 생성

 

◾ 템플릿 메타프로그래밍은 기존 작업은 런타임에서 컴파일 타임으로 전환하는 효과를 냅니다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있습니다.

◾ TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있습니다.

'도서 > Effective C++' 카테고리의 다른 글

9. 그 밖의 이야기들  (0) 2022.12.07
8. new와 delete를 내 맘대로  (0) 2022.12.07
6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01

public 상속은 반드시 "is-a" 관계를 뜻해야 한다.

가상함수: 인터페이스가 상속되어야 한다.

비가상함수: 인터페이스와 구현이 둘 다 상속되어야 한다.

 

32. public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자

public 상속과 is-a 관계는 똑같은 뜻이다.

파생 클래스의 타입이 기본 클래스의 타입이 될 수는 있지만 반대는 성립할 수 없다.

public 상속을 받을수록 일반적인 개념에서 특수한 개념으로 점점 좁혀진다.

 

public 상속은 기본 클래스가 가진 모든 것들이 파생 클래스에도 그대로 적용된다고 단정하는 것이다.

 

◾ public 상속의 의미는 "is-a(...는 ...의 일종)"입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 떄문입니다.

 

 

33. 상속된 이름을 숨기는 일은 피하자

유효범위에 관련된 내용이다.

 

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

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

 

위와 같은 코드의 경우 데이터 멤버와 함수의 유효 범위는 그림과 같다.

만약 Derived의 mf4에서 mf2를 호출하게 된다면 컴파일러는 mf2 함수의 선언문이 들어있는 유효 범위를 탐색한다.

바로 Base::mf2에 접근하는 것이 아니라, 안에서부터 바깥으로 탐색을 넓혀나간다.

 

탐색 순서

함수 오버로딩의 경우에도 이름이 가려진다.

 

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

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

Derived d;
int x;

d.mf1(); // Derived::mf1 호출
d.mf1(x); // error. Derived::mf1에 가려짐

d.mf3(); // Derived::mf3 호출
d.mf3(x); // error. Derived::mf3에 가려짐

 

가상 함수와 비가상 함수 여부나 매개변수 타입이 달라도 전혀 관련이 없이 이름이 가려진다.

애초에 public 상속을 쓰면서 오버로딩을 모두 상속받지 않으면 is-a 관계가 위반된다.

 

가려진 이름은 using 선언으로 꺼낼 수도 있다.

 

class Derived: public Base {
public:
    using Base::mf1;
    using Base::mf3;
};

 

파생 클래스 내부에서 가려지는 오버로드된 함수를 using 선언을 통해 Derived의 유효범위 안에서 볼 수 있게 만들어준다.

 

결과적으로 기본 클래스를 상속 받을 때, 오버로드된 함수의 일부만 재정의 하고 싶다면 using 선언을 붙여주어야 한다.

 

 

class Base
{
public:
    virtual void mf1() { std::cout << "mf1()" << '\n'; }
    virtual void mf1(int x) { std::cout << "mf1(x)" << '\n'; }
};

class Derived1 : private Base
{
public:
    using Base::mf1;
};

class Derived2 : public Derived1 {};

int main(void)
{
    Derived2 d;
    d.mf1(10); // Derived1이 private 상속임에도 접근이 가능해진다
    return 0;
}

 

만약 파생 클래스가 private 상속을 받고 일부만 재정의 한 뒤에 또 파생이 된다면 using 선언을 사용해선 안된다. 해당되는 이름이 모두 파생 클래스로 내려가 버리기 때문이다.

 

 

class Derived1 : private Base
{
public:
    virtual void mf1() { Base::mf1(); }
};

class Derived2 : public Derived1 {};

int main(void)
{
    Derived2 d;
    d.mf1(); // ok
    d.mf1(10); // error
    return 0;
}

 

이 때는 using 선언 대신 기본 클래스의 함수를 호출하는 전달 함수를 사용하면 된다.

 

◾ 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.

◾ 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.

 

 

34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자

public 상속의 개념은 인터페이스 상속과 함수 구현 상속 두가지로 나뉜다.

 

class Shape {
public:
    virtual void draw() const = 0;
    virtual void error(const std::string& msg);
    int objectID() const;
};

 

순수 가상 함수, 단순 가상 함수, 비가상 함수의 목적은 각기 다르다.

 

◾ 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다.

인터페이스만 물려받았기 때문에 파생 클래스에서는 반드시 구현을 해야한다.

 

◾ 단순 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스뿐만 아니라 기본 구현도 물려주려는 것이다.

둘 다 물려받았기 때문에 필요에 따라 재정의를 해도 되고 안해도 된다. 다만 재정의를 해야 할 상황에서 잊어버리고 재정의를 하지 않았을 때, 의도치 않은 동작이 이루어지는 것을 주의해야한다.

 

class Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelA: public Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelB: public Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelC: public Airplane {}; // 재정의를 깜빡함

Airport PDX();
Airplane* pa = new ModelC;
pa->fly(PDX); // 하지만 동작함

 

이런 문제는 가상 함수의 인터페이스와 기본 구현을 잇는 연결 관계를 끊으면 조금은 해소할 수 있다.

 

 

class Airplane {
public:
    virtual void fly(const Airport& destination) = 0;
protected:
    void defaultFly(const Airport& destination); // 비가상함수. 재정의불가
};

class ModelA: public Airplane {
public:
    virtual void fly(const Airport& destination) { defaultFly(destination); }
};

class ModelB: public Airplane {
public:
    virtual void fly(const Airport& destination)  { defaultFly(destination); }
};

class ModelC: public Airplane {}; // 재정의를 깜빡함

Airport PDX();
Airplane* pa = new ModelC;
pa->fly(PDX); // error

 

복붙같은 문제를 해결할 수는 없지만 인터페이스와 구현을 분리함으로써 조금은 나아진다.

혹은 기본 클래스의 순수 가상 함수를 구현함으로써 외견을 깔끔하게 할 수 있다.

defaultFly를 구현하고 호출하는 대신 Airplane::fly를 호출하면 된다. 설계는 거의 똑같다.

 

◾ 비가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스뿐만 아니라 필수적인 구현도 물려주는 것이다.

가상 함수와는 다르게 재정의가 불가능하기 때문에 모든 파생 클래스에서 똑같이 작동한다.

 

◾ 인터페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.

◾ 순수 가상 함수는 인터페이스 상속만을 허용합니다.

◾ 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.

◾ 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.

 

35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

◾ 비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

public 비가상 함수를 호출하면 내부 동작은 private 가상 함수에 의해 일부분이 다르게 동작하게 설계한다.

 

class GameCharacter {
public:
    int healthValue() const
    {
        ...
        int retVal = doHealthValue();
        ...
        return retVal;
    }
private:
    virtual int doHealthValue() const { ... } // 파생 클래스 재정의 가능
};

 

가상 함수가 private으로 선언되어 있어도 재정의는 가능하다. 단, 자기 자신의 가상 함수만 접근할 수 있다.

파생 클래스는 구현만 할 뿐이고 실제 호출은 기본 클래스의 비가상 함수에서 이루어지기 때문에 재정의에 아무런 문제가 없다.

위와 같은 설계를 비가상 함수 인터페이스(NVI) 관용구라고 부른다. 템플릿 메서드라고도 한다.

 

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

체력을 계산하는 작업이 굳이 캐릭터의 일부일 필요가 없어서 각 생성자에 체력을 계산하는 함수의 포인터를 넘겨주는 방식을 택할 수 있다.

 

class GameCharacter; // 전방 선언

int defaultHealthCalc(const GameCharacter& gc); // 체력 계산 기본 구현 함수

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

class EvilBadGuy: public GameCharacter {
public:
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc) : GameCharacter(hcf) {}
};

int loseHealthQuickly(const GameCharacter&);
int lostHealthSlowly(const GameCharacter&);

EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly); // 동일한 객체이지만 함수를 다르게 설정 가능

 

가상 함수 대신 함수 포인터로 대체되었다.

템플릿 메서드 패턴과 크게 다른 점이 있는데, 템플릿 메서드는 타입이 같은 객체라면 체력 계산 알고리즘이 동일하지만 함수 포인터는 타입이 같은 객체라도 다른 함수를 사용하거나 타입이 다른 객체라도 같은 함수를 사용할 수 있게 된다.

그대신 클래스 외부로 빠져나왔기 때문에 public 멤버를 제외한 데이터에 접근이 불가능해진다.

 

위와 같은 개념을 디자인 패턴화 시킨 것이 전략(Strategy)패턴이다.

추후 체력 계산 알고리즘이 변경되어도 클래스에서 변하는 것은 아무것도 없다.

 

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

함수 대신 함수 객체를 사용한다. (참고: VC에서는 tr1 이름 공간이 더이상 사용되지 않음. 그냥 std::function이다.)

함수 포인터와 다른점은 대상 시그니처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있게된다. (암시적 변환)

특정 객체의 멤버 함수를 사용할 수도 있다. (std::bind 이용 시)

 

using GameCharacter;
typedef std::function<int(const GameCharacter&)> HealthCalcFunc;
// using HealthCalcFunc = std::function<int(const GameCharacter&)>; 둘 다 똑같다

class GameLevel {
public:
    float health(const GameCharacter&) const;
};
...
EvilBadGuy ebg1(calcHealth); // 함수 사용
EyeCandycharacter ecc1(HealthCalculator()); // 함수 객체 사용
GameLevel currentLevel;

EvilBadGuy ebg2(std::bind(&GameLevel::health, currentLevel, _1)); // 멤버 함수 사용

 

_1은 std::placeholder이다. 매개 변수를 자유롭게 받겠다는 의미이다.

 

◾ "고전적인" 전략 패턴

함수 포인터나 함수 객체가 아니라 체력 계산 함수를 나타내는 클래스를 따로 만들고 실제 계산하는 함수를 가상 함수로 만드는 것이다.

 

class GameCharacter;
class HealthCalcFunc {
public:
    virtual int calc(const GameCharacter& gc) const { ... }
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}
    int healthValue() const { return pHealthCalc->calc(*this); }
private:
    HealthCalcFunc* pHealthCalc;
};

 

기존의 함수 포인터나 함수 객체와 구조가 크게 다르지는 않다. 그저 표준적인 전략 패턴일 뿐이다.

 

요약

 

◾ 비가상 인터페이스 관용구(NVI 관용구)를 사용한다

공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태이다.

 

◾ 가상 함수를 함수 포인터 데이터 멤버로 대체한다

전력 패턴의 핵심만을 보여주는 형태이다.

 

◾ 가상 함수를 함수 객체 데이터 멤버로 대체한다

호환성을 높인다. 전략 패턴의 한 형태이다.

 

◾ 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다

전략 패턴의 전통적인 구현 형태이다.

 

◾ 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.

◾ 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.

◾ tr1::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원합니다.

 

 

36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!

가상 함수는 동적 바인딩으로 묶이지만 비가상 함수는 정적 바인딩으로 묶이기 때문에 재정의를 한다면 의도치 않은 동작을 하게된다.

그것보다는 앞서 언급했듯이 비가상 함수를 상속받으면 불변성이 보장되어야 하는데 재정의를 하는 순간 불변성이 깨져버린다.

 

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

 

 

37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자

가상 함수가 동적으로 바인딩 된다 하더라도 기본 매개변수 값은 정적으로 바인딩 된다.

 

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

 

 

38. "has-a(...는...를 가짐)" 혹은 "is-implemented-in-terms-of(...는...를 써서 구현됨)"를 모형화할 때는 객체 합성을 사용하자

컴포지션은 객체 안에 객체가 포함되는 경우를 얘기한다.

is-a 관계와 has-a 관계는 헷갈일 일이 거의 없지만 is-a 관계와 is-implemented-in-terms-of 관계는 헷갈릴 수 있다.

예를 들어서 list를 이용해 set를 구현한다고 했을 때, is-a 관계는 기본 클래스에서 참인 것들이 파생 클래스에서도 모두 참이어야 하지만 list는 중복 원소를 가질 수 있으므로 is-a 관계가 성립하지 않는다.

대신 list 객체를 사용해서 구현하는 형태(is-implemented-in-terms-of)를 가질 수 있다.

 

template<typename T>
class Set: public std::list<T> { ... }; // is-a 관계가 성립하지 않음

template<class T>
class Set {
public:
    bool member(const T& item) const;
    ...
private:
    std::list<T> rep;
}

template<typename T>
bool Set<T>::member(const T& item) const
{
    return std::find(rep.begin(), rep.end(), item) != rep.end(); // list를 사용한 구현
}

 

◾ 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.

◾ 응용 영역에서 객체 합성의 의미는 has-a(...는...를 가짐)입니다. 구현 영역에서는 is-implemented-in-terms-of(...는 ...를 써서 구현됨)의 의미를 갖습니다.

 

 

39. private 상속은 심사숙고해서 구사하자

클래스 사이의 상속 관계가 private이라면 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다. (업캐스팅X)

 

class Person {};
class Student : private Person {};

void eat(const Person& p) {}

Person p;
Student s;

eat(p);
eat(s); // error

 

또한 상속이 private으로 이루어지면 기본 클래스에서 멤버들의 접근 지정자가 어떻게 되었던 간에 파생 클래스에서 전부 private 멤버가 되어버린다.

 

결론적으로 private 상속의 의미는 is-implemented-in-terms-of이다. private 상속 자체로 구현 기법 중 하나가 된다.

기본 클래스를 private으로 상속하는것은 기능 몇 개를 활용할 목적이 있는것이지 어떤 개념적 관계가 있어서 하는 행동이 아니다. 구현만 물려받을 뿐 인터페이스는 일절 제공하지 않는다.

 

앞서 컴포지션은 has-a 또는 is-implemented-in-terms-of 둘 중 하나라고 언급했었다.

컴포지션이 이미 존재하는데 private 상속은 언제 사용해야 하는걸까?

답은 가능하면 컴포지션을 사용하고 객체의 비공개 멤버에 접근하거나 가상 함수를 재정의 할 필요가 있을때 private 상속을 사용하면 된다.

 

다만 파생은 가능하게 하되 파생 클래스에서 재정의를 막거나 컴파일 의존성을 최소화하고 싶을 때 컴포지션이 더 좋기때문에 컴포지션이 더 자주 쓰인다.

 

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

◾ 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO)를 활성화시킬 수 있습니다. 이 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 합니다.

 

 

40. 다중 상속은 심사숙고해서 사용하자

다중 상속은 똑같은 이름을 물려받을 가능성이 생긴다.

만약 다중 상속으로 인해 기본 클래스와 파생 클래스 사이의 경로가 두 개 이상이 된다면 데이터 멤버를 중복 생성하거나 가상 상속을 통해 해결해야 한다.

 

정확한 동작의 관점에서 보면 public 상속은 항상 가상 상속이어야 맞지만 성능상의 이유로 무조건 사용할 수는 없다.

때문에 쓸 필요가 없다면 가상 기본 클래스를 쓰지 말아야 한다. 만약 반드시 써야할 상황이라면 가상 기본 클래스에는 데이터를 넣지 않도록 각별히 신경써야 한다. 초기화 규칙에서 자유로워지기 때문이다.

 

가능하면 단일 상속을 사용하려고 하되, 정말 필요하다고 확신이 들면 다중 상속을 사용하도록 한다.

 

◾ 다중 상속은 단일 상속보다 확실히 복잡합니다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있습니다.

◾ 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적입니다.

◾ 다중 상속을 적법하게 쓸 수 있는 경우가 있습니다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.

'도서 > Effective C++' 카테고리의 다른 글

8. new와 delete를 내 맘대로  (0) 2022.12.07
7. 템플릿과 일반화 프로그래밍  (0) 2022.12.05
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30

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

변수 타입이 생성자나 소멸자를 호출하게 되면 비용이 발생하게 된다. 정의는 되었으나 사용하지 않은 경우에도 비용이 부과된다.

 

std::string encryptPassword(const std::string& password)
{
    std::string encrypted; // 정의로 인한 생성자 호출
    
    if (password.length() < MinimumPasswordLength) {
        throw logic_error("Password is too short"); // 예외발생
    }
    ...
    return encrypted; // 예외 발생시 사용도 못해본다
}

 

어떤 함수 내에서 지역변수를 정의하고 사용하기 전에 예외가 발생한다면 사용하지 않았음에도 생성과 소멸에 대해 비용을 지불해야한다. 그렇기 때문에 꼭 필요해지기 전까지 정의를 미루는 편이 낫다.

 

 

std::string encryptPassword(const std::string& password)
{
    std::string encrypted; // 기본 생성자 호출
    encrypted = password; // 복사 대입 연산자. 총 2번 호출
}

/* */

std::string encryptPassword(const std::string& password)
{
    std::string encrypted(password); // 복사 생성자 호출
}

 

단순히 정의를 미룰 뿐만 아니라 초기화 인자를 손에 넣기 전까지 정의를 늦출수 있는지도 확인해 봐야 한다.

기본 생성자->대입 보다는 복사 생성자로 정의와 초기화를 동시에 해주는게 성능상 이점이 있기 때문이다.

 

만약 지역변수가 반복문 안에서 사용되는 경우라면 생성자와 소멸자의 비용이 복사 대입 연산자보다 더 저렴하지 않은 이상 반복문 안에서 정의하는게 낫다.

 

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

 

 

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

C++은 어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다는 동작 규칙이 존재하지만 캐스트가 사용되면 예외가 되어버린다.

 

◾ const_cast : 객체의 상수성이나 휘발성을 제거하는 용도로 사용된다.

◾ dynamic_cast : 안전한 다운캐스팅을 할 때 사용된다. 대신 런타임 비용이 매우 높다.

◾ reinterpret_cast : 전혀 관련없는 타입간 캐스팅이 가능하다. 사용할 일이 없다.

◾ static_cast : 암시적 변환을 강제로 할 때 사용된다. c스타일의 타입 변환을 대체하는 것과 같다.

(참고 링크 : https://erikanes.tistory.com/262)

 

C 스타일의 구형 캐스트보다는 C++ 스타일의 캐스트를 사용하는 것이 바람직하다. 가독성도 좋아지고 사용 범위를 좁히기 때문에 컴파일러 쪽에서 오류를 확인할 수 있다.

 

 

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

class SpecialWindow : public Window {
public:
    virtual void onResize() {
        static_cast<Window>(*this).onResize(); // Window의 복사 생성자가 호출된다
    }
};

SpecialWindow sw;
sw.onResize();

 

캐스팅을 통한 형 변환 시, 임시 객체가 생성되어서 *this가 아닌 임시 객체의 onResize를 호출하게 된다.

 

 

static_cast<Window>(*this).onResize(); // X
Window::onResize(); // O

 

기본 클래스의 멤버 함수를 호출하고 싶다면 캐스팅이 아니라 직접 호출하도록 한다.

 

 

dynamic_cast는 다운 캐스팅을 안전하게 할 수 있지만 사용 비용이 높다.

파생 클래스들을 컨테이너에 담아서 접근할때마다 dynamic_cast를 하는것은 바람직하지 않다.

우회 방법으로 파생 클래스의 스마트 포인터만을 담을 수 있는 컨테이너를 만들거나 파생 클래스에 존재하는 함수를 기본 클래스에 가상 함수로 정의하는 것이다.

 

 

// 전자
typedef std::vector<std::shared_ptr<SpecialWindow>> VPSW; // 파생 클래스 스마트 포인터를 담는 vector
VPSW winPtrs;
for (VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->blink(); // 캐스팅이 필요 없다
}

/* */

// 후자
class Window {
public:
    virtual void blink() {}
};

typedef std::vector<std::shared_ptr<Window>> VPW; // 기본 클래스 스마트 포인터를 담는 vector
VPW winPtrs;
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->blink(); // 가상 함수로 구현되어있기 때문에 가능
}

 

전자는 파생 클래스 갯수만큼 컨테이너 여러개를 만들어야 할 것이고 후자는 파생 클래스에서 해당 함수를 사용하지 않더라도 기본 클래스의 함수가 호출이 된다.

 

 

if (dynamic_cast<SpecialWindow1*>(iter->get()) { ... }
else if (dynamic_cast<SpecialWindow2*>(iter->get()) { ... }
else if (dynamic_cast<SpecialWindow3*>(iter->get()) { ... }

 

그렇다고 이딴 식으로(폭포식) 설계하는것은 절대로 해서는 안된다.

속도도 느릴뿐더러 파생 클래스가 추가될 때마다 유지보수가 어려워진다.

 

◾ 다른 방법이 가능하다면 캐스팅은 피하십시오. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각하십시오. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보십시오.

◾ 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보십시오. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 됩니다.

◾ 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러납니다.

 

 

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

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

struct RectData {
    Point ulhc;
    Point lrhc;
};

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

 

upperLeft와 lowerRight는 상수 멤버 함수이고 사각형의 꼭짓점 정보를 알아낼 수 있는 방법만 제공하는 것이 목적이다.

하지만 참조자를 반환함으로써 외부에서 private 멤버 데이터를 수정할 수 있게 된다.

 

 

Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // 상수 객체

rec.upperLeft().setX(50); // 분명 상수 객체인데 값 수정이 된다

 

상수 객체인데 값이 수정될뿐만 아니라 private 영역에 접근까지 해버린다.

클래스 데이터 멤버는 아무리 숨겨봐야 해당 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다.

포인터라고 하더라도 참조자, 포인터 둘 다 핸들이고, 객체의 내부요소에 대한 핸들을 반환하게 되면 해당 객체의 캡슐화는 언제든지 무너질 위험성이 있다.

위의 경우는 상수 참조자를 반환시켜주면 된다. 

 

또한 외부 공개가 차단된 멤버 함수의 포인터를 반환하는 멤버 함수도 존재하면 안된다.

 

 

상수 참조자가 반환된다 하더라도 무효참조 핸들에 대한 문제는 여전히 남아있다.

 

 

class GUIObject { ... };

const Rectangle boudingBox(const GUIObject& obj);

GUIObject *pgo;
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft()); // 임시 객체의 주소값 대입

 

pUpperLeft는 사라진 임시 객체의 주소를 가리키기 때문에 댕글링 포인터가 되어버린다.

일단 핸들이 반환되면 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문에 주의해야한다.

 

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

 

 

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

예외 안전성을 확보하려면 두 가지 요구사항을 맞추어야 한다.

 

◾ 자원이 새도록 만들지 않는다

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

 

뮤텍스의 lock-unlock 사이에서 예외가 발생하면 unlock이 이루어지지 않아서 뮤텍스 반환이 되지 않는다.

 

◾ 자료구조가 더렵혀지는 것을 허용하지 않는다

만약 new Image에서 예외가 발생되면 앞서 삭제 및 값 증가가 모두 이뤄지지만 이미지가 새로 할당되지 않는다.

 

 

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock ml(&mutex);
    ...
}

 

뮤텍스와 같은 자원은 앞서 말했던대로 객체로 관리하는 것이 좋다. 따로 신경쓰지 않아도 함수를 벗어날 때 알아서 해제해주기 때문이다. 자원이 새지 않기 때문에 첫 번째 요구사항을 만족하게 된다.

 

두 번째 요구사항인 자료구조 오염 방지는 우선 예외 안전성을 갖춘 함수가 필요하다.

예외 안전성을 갖춘 함수는 아래의 세 가지 보장중 한 가지를 제공한다.

 

◾ 기본적인 보장

예외 발생 시, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장. 하지만 프로그램의 상태를 정확히 예측하기 어렵다.

 

◾ 강력한 보장

예외 발생 시, 프로그램 상태를 절대로 변경하지 않겠다는 보장. (원자적 동작)

호출이 성공하면 의도한 대로 마무리까지 완벽하게 진행되고 예외 발생 시, 함수 호출 이전의 상태로 되돌아간다.

사용 편의성 측면에서 기본 보장을 제공하는 함수보다 예측 가능한 상태가 두 개밖에 안되기 때문에 더 쉽다.

 

◾ 예외불가 보장

무슨 일이 있어도 예외를 절대로 던지지 않겠다는 보장. 약속한 동작은 언제나 끝까지 완수한다는 의미이다.

 

아무 보장도 제공하지 않으면 예외에 안전한 함수가 아니다. 일반적으로 기본적인 보장과 강력한 보장 중 한가지를 선택한다.

 

class PrettyMenu {
    ...
    std::shared_ptr<Image> bgImage;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock ml(&mutex);
    bgImage.reset(new Image(imgSrc)); // 내부 포인터 교체
    ++imageChanges;
}

 

위와 같이 자원 관리 객체들을 이용해서 구현하는 경우, 다 좋지만 딱 하나의 옥의 티가 있다. reset이 호출되려면 Image가 생성되어야 하는데, Image의 생성자가 실행 도중 예외를 일으키면 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분히 있고, 이것이 전체 프로그램에 영향을 미칠 가능성도 존재하게 된다. 

 

복사 후 맞바꾸기를 통해 강력한 예외 안전성 보장을 좀 더 완성시킬 수 있다. pimpl 관용구를 사용한다.

 

 

struct PMImpl {
    std::shared_ptr<Image> bgImage;
    int imageChanges;
};

class PrettyMenu {
private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    using std::swap;
    Lock ml(&mutex);
    std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl)); // 객체 데이터 복사
    
    pNew->bgImage.reset(new Image(imgSrc)); // 사본 수정
    ++pNew->imageChanges;
    
    swap(pImpl, pNew); // 복사 후 맞바꾸기
}

 

그렇지만 이것도 함수 전체가 강력한 예외 안전성을 갖도록 보장할 수 없다.

함수 내부에서 또 다른 함수를 호출할 때, 그 함수가 강력한 예외 안전성을 보장하지 못하면 덩달아서 같이 강력한 예외 안전성을 보장하기 어려워진다. (side effect)

 

또한 효율적인 측면에서도 마냥 좋지는 않다. 복사 후 맞바꾸기는 수정할 객체를 복사해 둘 공간과 거기에 걸리는 시간을 감수해야 하기 때문이다.

 

실용성이 확보될 때만 강력한 보장을 제공하는데에 힘 쓰고, 기본적인 보장을 우선적으로 삼으면 된다.

 

언제나 예외에 안전한 코드를 만들지를 진지하게 고민하는 버릇을 들여야 한다.

정말 어쩔 수 없이 재래식 코드를 호출해서 예외 안전성의 보장이 무너질 경우, 반드시 문서로 남겨야 한다.

 

◾ 예외 안전성을갖춘 함수는 실행 중 예외가 발생되더라도 자우너을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다.

◾ 강력한 예외 안전성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.

◾ 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.

 

 

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

인라인 함수는 함수처럼 보이고 함수처럼 동작하지만 매크로보다 훨씬 안전하고 쓰기 좋다.

하지만 함수 본문을 바꿔치기 해주는 것 뿐이라서 인라인 함수를 남발하는 것은 목적 코드의 크기가 매우 커지기 때문에 메모리 사용에 있어서 그다지 좋지 않다.

가상 메모리를 쓰는 환경이라면 페이징 횟수도 늘어나고 캐시 적중률이 떨어질 가능성도 높아지기 때문에 성능저하가 일어날 수 있다.

 

인라인 함수는 대체적으로 헤더 파일에 들어 있어야 하는 게 맞다.

또한 인라인 함수는 컴파일러에게 요청하는 것이지 명령이 아니다. 복잡하거나 가상 함수인 경우 인라인 함수라고 명시적으로 선언되어 있어도 컴파일러가 묵살할 수 있다. 인라인 함수의 주소를 취하는 코드가 있는 경우에도 묵살된다.

 

 

inline void f() {...}

void (*pf)() = f;
f(); // 인라인 된다
pf(); // 인라인 되지 않는다
// f는 인라인, 아웃라인 함수 둘 다 존재하게 된다

 

덧붙여서 생성자와 소멸자는 인라인하기 좋지 않은 함수이다.

 

 

사실 인라인 함수는 이러저러한 이유보다 디버깅이 용이하지 않을 수 있다는 점 한가지가 크게 와닿는다.

어떤 함수가 인라인화 된다면 중단점도 안걸리고 호출 스택에 잡히지 않기 때문에 디버깅에 애로사항이 꽃필수 있다.

 

◾ 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어둡시다. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아집니다.

◾ 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 됩니다.

 

 

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

헤더파일 안에서 헤더파일을 include 하는 경우 헤더파일 사이에 컴파일 의존성이 생기게 된다.

 

// A.h
#include <iostream>
class A {};

// B.h
#include "A.h"

// C.h
#include "B.h"

 

위와 같은 구조로 컴파일 의존성이 생기게 되면 A 헤더 파일의 아주 간단한 사항만 변경해도 B와 C 헤더 파일 역시 컴파일이 이루어져야 한다.

 

전방선언을 통해 인터페이스와 구현을 둘로 나누면 컴파일 의존성을 낮출 수 있다. 단, 표준 라이브러리는 전방선언 할 필요가 없다.

 

◾ 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다

어떤 타입의 포인터나 참조자를 정의할 때는 해당 타입의 선언부만 있으면 된다. 하지만 객체를 정의할 때는 정의가 준비되어 있어야 한다.

 

◾ 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다

class Date; // 전방 선언
Data today();
void clearAppointmets(Date d);

 

◾ 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다

 

 

class Date;
class Adress;
class PersonImpl;

class Person { // PersomImpl의 인터페이스
public:
    Person() {}
    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같이 pimpl 관용구를 사용하는 클래스를 핸들 클래스라고 한다. 

구현(PersonImpl)과 핸들(Person)을 두개로 쪼개서 사용한다. 둘의 멤버 함수는 일대일로 대응되기 때문에 인터페이스는 동일하다.

 

 

Person을 핸들 클래스가 아닌 인터페이스 클래스로 만드는 방법도 있다. 추상 클래스로 만드는 것이다.

 

class Person { // 인터페이스 클래스
public:
    virtual Person();
    virtual ~Person();
    virtual std::string name() const = 0;
    ...
    static std::shared_ptr<Person> create(...); // 객체 생성 함수
};

std::shared_ptr<Person> pp(Person::create(...));

 

인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 있어야 하고 주로 정적 멤버 함수로 만들어서 사용한다.(팩토리 함수/가상 생성자)

 

◾ 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언'에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.

◾ 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합시다.

'도서 > Effective C++' 카테고리의 다른 글

7. 템플릿과 일반화 프로그래밍  (0) 2022.12.05
6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30

18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자

사용자가 인터페이스를 잘못 사용했을 경우 컴파일 되지 않아야 올바른 설계이다.

 

class Data {
public:
    Date(int month, int day, int year);
};

 

예를 들어 클래스 설계가 위와 같은 경우 매개변수의 순서를 다르게 전달하여 객체를 생성하는 경우가 발생할 가능성이 존재한다.

각 매개변수를 새로운 타입으로 변경한다면 괜찮게 동작할 수 있다.

enum의 경우 타입 안전성은 그리 믿음직 하지 못하다. (C++11부터 enum class 도입으로 인해 이제는 안전하다)

 

 

class Month {
public:
    static Month Jan() { return Month(1); }
    static Month Feb() { return Month(2); }
    ...
private:
    explicit Month(int m); // 생성자가 private 이므로 값이 새로 생성되지 않음
};

Date d(Month::Mar(), Day(30), Year(1995));

 

또는 타입 클래스의 유효한 집합을 미리 정적 멤버 함수로 정의해 두어도 괜찮다.

 

 

if (a * b = c) // 본래 의도는 ==

 

위와 같은 경우는 operator*의 반환 타입을 const로 한정하면 혹시 모를 실수를 방지할 수 있다.

특별한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만드는게 좋다. 그래야 일관성있는 인터페이스 제공이 가능해진다.

 

사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 언제라도 잊어버릴 수 있기 때문에 잘못 쓰기 쉽다.

 

 

// 팩토리 함수
Investment* createInvestment(); // 만약 스마트 포인터 사용조차 까먹는다면?

 

앞선 장에서 다뤘던 구조이다. 팩토리 함수를 통해 객체를 생성해서 포인터를 반환받을 때, 그것을 스마트 포인터에 담는 것조차 잊어버릴 가능성도 존재한다.

 

 

std::shared_ptr<Investment> createInvestment();

 

그래서 아예 스마트 포인터를 반환하게 만들면 스마트 포인터에 담을수밖에 없고 객체 삭제를 잊더라도 문제가 발생하지 않는다.

 

 

또한 스마트 포인터의 특징으로 객체 생성과 삭제가 교차 DLL에 의해 다르게 이루어지는 문제가 발생하지 않는다.

new를 실행할 당시의 DLL과 delete를 실행하는 순간의 DLL이 다르더라도 객체가 생성될 때의 DLL에서 삭제자를 사용하도록 만들어져있어서 교차 DLL 문제에서 자유롭다.

 

◾ 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.

◾ 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.

◾ 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기(클래스), 타입에 대한 연산을 제한하기(const), 객체의 값에 대해 제약 걸기(enum 등), 작업 관리 작업을 사용자 책임으로 놓지 않기(스마트 포인터)가 있습니다.

◾ shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.

 

 

19. 클래스 설계는 타입 설계와 똑같이 취급하자

새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의하는 것과 같다.

좋은 타입은 문법이 자연스럽고 의미구조가 직관적이고 효율적인 구현이 한 가지 이상 가능해야 한다.

클래스를 설계할 때 아래의 질문을 항상 떠올리며 신경써서 설계하자.

 

◾ 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가? -> 이에 따라 생성자 및 소멸자의 설계가 바뀐다.

◾ 객체 초기화는 객체 대입과 어떻게 달라야 하는가? -> 초기화와 대입을 헷갈려선 안된다.

◾ 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? -> 값에 의한 전달을 구현하는 쪽은 복사 생성자이다.

◾ 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? -> 클래스의 불변속성은 클래스 차원에서 지켜주어야 한다.

◾ 기존의 클래스 상속 계통망에 맞출 것인가? -> 상속 여부에 따라 멤버 함수의 가상 함수 여부가 결정된다. 특히 소멸자.

◾ 어떤 종류의 타입 변환을 허용할 것인가? -> 타입간 변환 허용 여부에 따라 멤버 함수 또는 생성자의 설계가 이루어져야 한다.

◾ 어떤 연산자와 함수를 두어야 의미가 있을까?

◾ 표준 함수들 중 어떤 것을 허용하지 말 것인가? -> private 선언 함수

◾ 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?

◾ '선언되지 않은 인터페이스'로 무엇을 둘 것인가?

◾ 새로 만드는 타입이 얼마나 일반적인가? -> 새로운 클래스가 아닌 새로운 클래스 템플릿을 정의해야 할 수도 있다.

◾ 정말로 꼭 필요한 타입인가? -> 기능 몇개 추가하자고 파생 클래스를 새로 만들거라면 차라리 간단하게 비멤버 함수나 템플릿을 몇 개 더 정의하는 편이 낫다.

 

◾ 클래스 설계는 타입 설계입니다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보십시오.

 

 

20. '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다

기본적으로 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달' 방식을 사용한다.

클래스의 멤버 변수가 객체이고 또 그 객체의 멤버 변수가 객체라면 연속적으로 존재할수록 생성자가 연속적으로 호출된다.

객체 하나를 복사하기 위해 많은 생성자의 호출이 이루어진다. 소멸자도 마찬가지이다.

 

bool validateStudent(const Student& s);

 

상수객체 참조자에 의한 전달은 새로 만들어지는 객체가 없기 때문에 생성자와 소멸자가 전혀 호출되지 않는다.

또한 복사손실 문제(slicing problem)가 없어지는 장점도 있다.

 

 

class Window {
public:
    ...
    std::string name() const;
    virtual void display() const;
};

class WindowWithScrollBars : public Window {
public:
    ...
    virtual void display() const;
};

void printNameAndDisplay(Window w) // 값에 의한 전달로 복사손실 발생
{
    std::cout << w.name();
    w.display();
}

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

 

display 함수가 가상 함수로 선언되었기 때문에 WindowWithScrollBars::display가 호출되어야 하지만, 값에 의한 전달로 인해 파생 클래스의 정보가 모두 잘려서 Window 객체로 생성되어 전달되므로 Window::display만 호출된다.

상수객체 참조자에 의한 전달로 문제를 해결할 수 있다.

 

 

타입 크기가 작다고 무조건 값에 의한 전달이 효율적인것은 아니다. 포인터 멤버가 가리키는 대상까지 모두 복사해야 할 수도 있기 때문이다.

객체의 크기와 복사 생성자가 비싸지 않게 만들어졌어도 기본제공 타입과 사용자 정의 타입을 다르게 취급하는 컴파일러들이 있기 때문에 성능이 다를 수 있다.

예를 들어 기본제공 타입인 double은 레지스터에 담기지만 double 하나로만 만들어진 객체는 레지스터에 담지 않는다. 이런 경우는 참조에 의한 전달이 낫다. 포인터는 반드시 레지스터에 담기기 때문이다.

 

또한 크기가 작더라도 사용자 정의 타입의 크기는 언제나 변할 수 있기 때문에 값에 의한 전달을 무조건 사용할 수 없다.

일반적으로 값에 의한 전달이 저비용이라고 가정해도 되는 것은 기본제공 타입, STL 반복자, 함수 객체 타입 3개 뿐이다.

이 외의 경우에는 상수객체 참조자에 의한 전달을 선택하도록 하자.

 

◾ '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아줍니다.

◾ 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 '값에 의한 전달'이 더 적절합니다.

 

 

21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

대체로 상수 객체 참조자에 의한 전달이 좋다고 해도 있지도 않은 객체의 참조자를 넘기는것은 좋지않다.

함수 수준에서 새로운 객체를 만드는 방법은 스택과 힙에 만드는 두 가지 뿐이다.

어떤 연산에 의한 결과를 반환할 때, 그 결과가 스택에 만들어진 경우 참조자로 반환하게 되면 함수 종료시 소멸하기 때문에 존재하지 않는 참조자를 반환하는 결과가 되어버린다.

 

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return *result; // 결과값을 참조자로 반환하는 것까지는 좋은데 삭제는 누가?
}

 

설령 그 결과가 동적 할당으로 힙에 만들어졌다고 하더라도 참조자가 올바르게 반환될지언정 포인터에 접근할 방법이 없기 때문에 자원의 누수가 발생한다.

또한 스택 기반이든 힙 기반이든 반환되는 결과는 생성자를 반드시 한 번 이상 호출하게 된다. 그렇다고 정적 객체를 반환하는것은 어불성설이다.

 

새로운 객체를 반환해야 하는 함수를 작성하는 경우에는 새로운 객체를 반환하도록 하면 된다. 말장난 같겠지만 말 그대로이다.

 

// case#1
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

// case#2
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

Rational a(1,2);
Rational b(3,5);
Rational c = a*b; // 결과가 다를까?

 

case#1은 함수 내에서 생성자 한번, 밖에서 복사 생성자 한번 총 2번의 생성자가 호출된다.

하지만 case#2는 최적화 매커니즘에 의해 복사 생성자가 호출되지 않는다. (반환 값 최적화, RVO)

 

참조자를 반환할지 객체를 반환할지를 결정할 때 무엇을 선택하든 올바른 동작이 이루어지도록 만드는 것에 초점을 두어야 한다.

 

◾ 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요.

 

 

22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자

데이터 멤버가 public이라면 문법적 일관성과 캡슐화가 깨진다.

예를 들어 데이터의 평균 값을 반환받는 멤버 함수를 만든다고 했을 때, 평균값을 담는 데이터 멤버를 클래스에 추가하거나 호출할때마다 평균 값을 반환하는 두 가지 방법이 존재한다.

 

성능상 이점에는 서로 차이가 있겠지만 멤버 함수를 호출한다는 공통점이 있다. 추후 수정사항이 발생하더라도 함수를 호출하는것은 변하지 않는것이 좋기 때문에 클래스의 불변속성을 유지하는데에 소홀해질수 없다.

 

하지만 데이터 멤버가 public이라면 사용자 코드가 깨질 수 있기 때문에 함부로 손을 대기가 더 어려워진다.

protected 데이터 멤버도 사실상 public과 같은 문제점을 안고있다. (의존성)

 

결과적으로 캡슐화의 관점에서 쓸모 있는 접근 수준은 캡슐화 제공이 가능한 private과 private이 아닌 나머지이다.

 

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

◾ protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아닙니다.

 

 

23. 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자

캡슐화하는 것이 늘어나면 외부에서 볼 수 있는 것들이 줄어든다. 그만큼 변경할 때 필요한 유연성이 커지는 장점이 있다.

어떤 데이터를 접근하는 함수가 많으면 그 데이터의 캡슐화 정도는 낮아진다. 클래스에서 public으로 제공되는 멤버 함수가 많을수록 캡슐화의 정도가 낮아진다는 것이다.

 

그렇기 때문에 private 멤버에 접근할 수 있는 멤버 함수보다 비멤버/비프렌드 함수를 사용하는것이 캡슐화의 정도가 더 높아진다.

 

 

class WebBrowser {
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    void clearEveryThing(); // 위의 3개를 모두 호출
};

void clearBrowser(WebBrowser& wb) // 비멤버 비프렌드 함수
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

 

단순히 멤버 함수를 한번에 호출하는 함수라면 비멤버 비프렌드 함수가 캡슐화에 바람직하다.

단, 비멤버 비프렌드 함수 한정이다. 어쨌든 private 멤버의 캡슐화에 영향을 주지 않으면 된다.

 

위와 같은 코드를 같은 네임스페이스로 묶는것도 하나의 방법이다.

 

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

 

 

24. 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자

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

Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // error

 

어떤 클래스의 생성자가 암시적 변환을 허용한다면 멤버 함수의 매개변수로 오는 값이 상수여도 암시적 변환을 통해 연산이 가능하다. 하지만 순서가 바뀌면 2라는 상수는 클래스와 연관이 없기 때문에 에러가 발생한다.

암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다.

멤버 연산자 오버로딩을 호출하는 쪽은 왼쪽(*this)이고 반드시 명시적이어야만 한다.

 

 

class Rational {
    ...
};
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.getNumerator() * rhs.getNumerator(), lhs.getDenominator() * rhs.getDenominator());
}

Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // ok

 

하지만 비멤버 함수로 만들어준다면 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려둘수 있다.

 

멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다.

 

◾ 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.

 

 

25. 예외를 던지지 않는 swap에 대한 지원도 생각해 보자

두 객체의 값을 맞바꾸기 한다는 것은 각자의 값을 상대방에게 주는 동작이다.

 

 

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

 

pimpl idiom(관용구)(pointer to implementation) 설계를 차용해서 클래스를 설계한 경우 값을 맞바꿀때는 내부의 pImpl 포인터만 서로 교환해주면 된다.

하지만 std::swap은 그런 사실을 모르기때문에 포인터가 가리키는 모든 것을 복사해서 오버헤드가 크게 발생한다.

 

 

namespace std {
    template<> // 템플릿 특수화
    void swap<Widget>(Widget& a, Widget& b)
    {
        swap(a.pImpl, b.pImpl);
    }
}

 

함수 템플릿을 템플릿 특수화 시키면 Widget 타입에 한해서 위의 함수로 동작하게 된다.

물론 pImpl은 private이기 때문에 별도의 함수를 만들어서 사용해야 한다.

 

 

namespace std {
    template<typename T>
    void swap<Widget<T>>(Widget& a, Widget& b) // 부분 특수화 불가능
    {
        a.swap(b);
    }
}

 

만약 Widget과 WidgetImpl이 클래스가 아닌 클래스 템플릿으로 만들어져 있어서 WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀수 있다면 부분 특수화를 시켜야 하는데, 클래스 템플릿은 부분 특수화를 허용하는 반면 함수 템플릿은 부분 특수화를 허용하지 않는다.

함수 템플릿을 부분 특수화 시키고 싶다면 그냥 오버로딩을 시키면 된다.

 

단, 일반적으로 함수 템플릿은 오버로딩 해도 별 문제가 없지만 std 함수 템플릿의 오버로딩은 유효하지 않다.

그렇다고 아예 동작하지 않는것은 아니고 컴파일 및 실행은 이루어지지만 실행되는 결과가 미정의 사항이 되어버린다.

 

 

먼 길을 돌아왔지만 결론적으로 멤버의 swap 함수를 호출하는 비멤버 함수를 선언하되 std::swap의 특수화 버전이나 오버로딩으로 선언하지만 않으면 된다.

 

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

 

한가지 더, 사용자가 함수 템플릿 내부에서 swap을 호출할 때, 어떤 swap을 호출해야하는지 확신할 수 없는 경우에는 상황에 따라 swap을 호출할 수 있도록 해야한다.

 

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap; // std::swap을 이 함수 안으로 끌어올 수 있도록 한다
    swap(obj1, obj2); // T타입 전용의 swap 호출. 만약 없으면 std::swap이 호출된다
}

 

이름 탐색 규칙에 따라 전역 유효범위 또는 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지 찾은 뒤에 없으면 std::swap을 선택한다.

 

◾ std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.

◾ 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 둡시다.

◾ 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출합시다.

◾ 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 '추가'하려고 들지는 마십시오.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

자원은 사용을 마치고 나면 시스템에 돌려주어야 하는 모든 것이다.

 

 

13. 자원 관리에는 객체가 그만!

어떤 함수 내에서 팩토리 함수 등으로 객체의 생성과 삭제가 이루어진다고 했을 때, 해당 함수가 객체의 delete 문에 항상 도달한다는 보장이 없다.

팩토리 함수로 얻어낸 자원이 항상 해제되도록 하려면 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡으면 된다. 소멸자는 함수를 떠날 때 호출되도록 만들면 언제나 저절로 해제된다.

 

개발에서 사용되는 상당수의 자원은 힙에서 동적으로 할당되고 하나의 블록 또는 함수 안에서만 쓰이는 경우가 잦기 때문에 블록이나 함수를 빠져나올 때 자원이 해제되는 것이 맞다.

이 때, 스마트 포인터를 사용하면 된다.

 

void f()
{
    std::auto_ptr<Investment> pInv(createInvestment()); // 팩토리 함수 호출
    ...
} // auto_ptr의 소멸자를 통해 pInv를 삭제한다

 

자원 관리에 객체를 사용하는 방법의 중요한 두 가지 특징

 

◾ 자원을 획득한 후에 자원 관리 객체에게 넘긴다

자원 획득과 자원 관리 객체의 초기화는 한 문장에서 이루어지는 것이 일상적이다. (RAII)

 

◾ 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다

 

auto_ptr은 자신이 소멸될 때 자기가 가리키고 있는 대상에 대해서 자동으로 delete를 실행하기 때문에 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안된다.

그래서 auto_ptr로 객체를 복사하면 원본 객체는 null로 바뀐다.

 

덧붙여서 STL 컨테이너는 auto_ptr의 원소가 될 수 없다.

 

auto_ptr을 쓸 수 없는 상황이라면 대안으로 shared_ptr가 있다.

auto_ptr과 달리 같은 자원을 여럿이 가리킬 수 있으며 참조 갯수가 0이 되면 해당 자원을 자동으로 삭제한다.

복사 동작이 의도한 대로 이루어지기 때문에 STL 컨테이너도 사용 가능하다.

단, 순환 참조가 발생하는 경우 참조 갯수가 0으로 줄어들지 않아서 자원이 해제되지 않는 문제가 발생할 수 있으므로 유의해야한다.

 

참고사항

Effective C++은 모던 C++이 적용되기 이전에 작성되었기 때문에 현재와 상이한 내용들이 있다.

auto_ptr은 C++11부터 사용 중지 권고, C++17부터 삭제되었다. 대신 unique_ptr이 등장했다.

또한 스마트 포인터의 배열의 삭제도 잘 이루어진다. (C++11/14와 17이 다르긴 하지만)

참고 : https://karupro.tistory.com/65

 

◾ 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII 객체를 사용합시다.

◾ 일반적으로 널리 쓰이는 RAII 클래스는 shared_ptr, 그리고 auto_ptr입니다. 이 둘 가운데 shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면 auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버립니다.

 

 

14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자

모든 자원이 힙에서 생기지는 않는다.

예를 들어 Mutex가 있다. 뮤텍스 잠금을 관리하는 클래스를 만들고자 할 때 기본적으로 RAII 법칙을 따라서 구성하게 된다.

하지만 만약 이 객체의 복사가 이루어진다면?

 

◾ 복사 금지

◾ 관리 자원 참조 카운팅 수행

◾ 관리 자원을 실제로 복사 (깊은 복사)

◾ 관리하는 자원의 소유권을 옮김 (=unique_ptr)

 

위 4가지 중 하나의 선택지를 고를 수 있다.

 

◾ RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정됩니다.

◾ RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것입니다. 하지만 이 외의 방법들도 가능하니 참고해 둡시다.

 

 

15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자

스마트 포인터는 보통 역참조 연산자도 오버로딩하고 있기 때문에 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있다.

하지만 임의의 자원 관리 클래스를 만들어서 사용중이라면 외부에서 자원에 접근하는 방법을 제공해야 한다.

 

참고로 RAII 클래스는 데이터 은닉이 목적이 아니기때문에 자원 접근 함수를 열어두어도 캡슐화에 위배되지 않는다.

 

◾ 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 합니다.

◾ 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능합니다. 안전성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮습니다.

 

 

16. new 및 delete를 사용할 때는 형태를 반드시 맞추자

단일 객체의 메모리 배치구조와 객체 배열에 대한 메모리 배치구조가 다르다. 대개 배열의 경우 최상단에 원소의 개수가 같이 박혀서 들어간다.

delete 호출 시 소멸자를 한번만 호출하고 delete[] 호출 시 앞쪽의 메모리 몇 바이트를 읽어서 배열 크기라고 해석한 뒤에 해당하는 횟수만큼 소멸자를 호출한다.

 

가급적 배열 타입을 typedef 타입으로 만들지 않는 것이 좋다.

 

◾ new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 합니다. 마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 합니다.

 

 

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

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority()); // 컴파일 에러
processWidget(std::shared_ptr<Widget>(new Widget), priority()); // 컴파일 가능!.. 하지만 자원을 흘릴 가능성이 있다

 

스마트 포인터의 생성자는 explicit로 선언되어 있기 때문에 동적 할당된 포인터가 스마트 포인터 타입의 객체로 바뀌는 암시적인 변환이 존재하지 않는다.

명시적 변환으로 인자를 주게 될 경우 컴파일은 되지만 자원을 흘릴 가능성이 생긴다.

priority 호출, new Widget 실행, shared_ptr 생성자 호출 총 3가지 연산을 실행하는데 각각의 연산 순서가 컴파일러마다 다르기 때문이다.

만약 new Widget과 shared_ptr 사이에 priority 호출이 끼어들게 되고 priority 호출에서 예외 발생 시, 포인터가 유실될 수 있다.

 

해결 방법은 객체의 동적 할당과 스마트 포인터에 담는 코드를 하나의 독립적인 문장으로 만들고 스마트 포인터를 인자로 넘겨주면 된다.

 

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

 

◾ new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만듭시다. 이것이 안 되어 있으면, 예외가 발생될 때 디버깅하기 힘든 자원 누출이 초래될 수 있습니다.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

+ Recent posts