ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 템플릿과 일반화 프로그래밍
    프로그래밍 기초/C++ 2022. 8. 25. 08:05

      템플릿과 일반화 프로그래밍 

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

    명시적 인터페이스와 암시적 인터페이스

    기본적으로 소스 코드에서 명시되어 있는 인터페이스를 명시적 인터페이스라고 하고 표현식에서 지원해야 하는 인터페이스를 암시적 인터페이스라고 한다.

     

    다음 코드에서 명시적 인터페이스와 암시적 인터페이스의 예를 확인할 수 있다.


    template<typename T>

    void doProcessing(T& w)

    {

        // T는 operator.!=로 someNastyWidget과 비교할 수 있는 연산을 암시적으로 지원하고

        // "w.size() > 10 && w != someNastyWidget"의 결과가 bool 이라는 것을 암시적으로 지원한다.
        if (w.size() > 10 && w != someNastyWidget) {

            // T는 복사 생성자, normalize, swap을 명시적으로 지원한다.

            T temp(w);

            temp.normalize();

            temp.swap(w);

        }

    }


     

    ※ 템플릿 객체에서 요구하는 암시적 인터페이스를 그 객체가 지원하지 않으면 사용이 불가능하다.

     

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

    typename의 첫 번째 의미

    템플릿 선언문에서 타입 매개변수를 선언할 때 classtypename이 동등하게 사용된다.


    template<class T> class Widget;

    template<typename T> class Widget;


     

    템플릿 내부에서 사용되는 타입 종류

    템플릿 내부에서 타입 매개변수에 의존되는 타입과 타입 매개변수에 독립된 타입이 존재한다.

    • 중첩 의존 타입 이름(nested dependent type name) T::const_iterator같이 타입 매개변수 T에 의존하는 타입
    • 비의존 이름(non-dependent type name) int와 같이 T와는 독립적으로 존재하는 타입

     

    typename의 두 번째 의미

    기본적으로 컴파일러는 중첩 의존 타입 이름이 등장하면 이를 타입이 아닌 것으로 해석한다.

    다음과 같은 코드가 있을 때 다음의 내용을 변수의 곱으로 해석한다는 것이다.


    C::const_iterator * x; // C의 const_iterator라는 변수와 x의 곱으로 해석된다.


     

    컴파일러에게 중첩 의존 타입 이름이 타입이라는 것을 전달하기 위해 typename을 사용한다.


    typename C::const_iterator * x; // C::const_iterator의 포인터 x를 선언한다.


     

    주의할 점은 기본 클래스의 리스트 또는 멤버 초기화 리스트의 기본 클래스 식별자는 typename을 붙이지 않는다는 것이다.


    template<typename T>

    class Derived : public Base<T>::Nested {

    public:

       explicit Derived(int x)

            : Base<T>::Nested(x)

        {

            typename Base<T>::Nested temp;

        }

    };


     

    ※ 컴파일러에 따라 중첩 의존 타입 이름의 규칙 강도가 다를 수 있다.

     

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

    기본 대응은 없는 것으로 가정하는 것이다

    컴파일러는 템플릿 기본 클래스의 이름은 기본적으로 존재하지 않는 것으로 가정하는데 가령 다음과 같은 코드는 컴파일 에러가 발생하게 된다.


    class LoggingMsgSender : public MsgSender<Company> {

    public:

        void sendClearMsg(const MsgInfo& info)

        {

            ...

            sendClear(info); // 컴파일 에러 발생! sendClear가 존재하는지 확신할 수 없음.

            ...

        }

    };


     

    컴파일러가 기본적으로 기본 클래스의 이름을 없는 것으로 가정하는 이유는 템플릿 기본 클래스는 언제든 특수화되어 특정 이름이 제거될 수 있기 때문이다.

     

    가령 위의 예제는 다음과 같은 특수화가 존재할 수 있을 것이다.


    template<> class MsgSender<CompanyZ> {

    public:

        // sendClear가 존재하지 않는다.

        void sendSecret(const MsgInfo& info) { ... }

    };


     

    ※ 비어있는 template<>는 템플릿 특수화를 나타낼 때 사용된다.

     

    컴파일러에 기본 템플릿 클래스의 이름을 알리는 방법

    세 가지 방법으로 컴파일러에 정보를 전달할 수 있다.

    1. this->를 붙이는 방법 this->sendClear(info);
    2. using 선언을 사용하는 방법 using MsgSender<Company>::sendClaer; // 클래스 내 using 선언
    3. 기본 클래스의 함수라고 명시하는 방법 MsgSender<Company>::sendClear(info); // 이름 사용 시 기본 정보를 표시

     

    이 중 세 번째 방법은 가상 함수 바인딩이 무시되기 때문에 추천되지 않는다.

     

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

    템플릿의 코드 비대화 문제

    템플릿을 아무 생각 없이 사용하다 보면 거의 똑같은 코드와 데이터가 여러 벌 중복되어 생성될 수 있다.

     

    이런 문제를 해결하기 위해서는 공통성 및 가변성 분석, 풀어쓰면 만들고 있는 클래스에 다른 클래스와 중복되는 부분이 있으면 해당 부분을 새로운 클래스로 만들고 합성을 통해 공통부분을 유지하는 전략을 사용할 수 있다.

     

    가령 다음과 같이 정방 행렬을 나타내는 템플릿이 존재하는데 해당 클래스에 역행렬을 만들어주는 invert 함수가 존재하는 경우 중복이 발생할 것이다.


    template<typename T, std::size_t n>

    class SquareMatrix {

    public:

        ...

        void invert();

    };

     

    SquareMatrix<double, 5> sm1; // 크기만 다른 템플릿 인스턴스

    sm1.invert(); // 크기만 다른 동일한 모양의 함수가 여러 개 생성된다.

    SquareMatrix<double, 10> sm2;

    sm2.invert();


     

    매개변수에 독립적인 코드 분리

    코드 비대화 문제를 해결하는 방법은 매개변수에 영향을 받지 않는 함수를 가지는 기본 클래스를 만들고 기본 클래스를 상속받는 파생 클래스를 만들어 중복을 제거하는 것이다.


    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); }

    };


     

    이 구현에 문제가 하나 남아 있는데 기본 클래스에서 파생 클래스의 데이터 정보를 알 수 없기 때문이다.

    이는 기본 클래스에 데이터 포인터를 추가하여 해결할 수 있다.


    template<typename T>

    class SquareMatrixBase {

    protected:

        SquareMatrixBase(std::sized_t n, T *pMem)

            : size(n), pData(pMem) { }

        void invert(std::size_t matrixsize);

    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];

    };


     

    매개변수 독립 코드를 분리할 때의 장, 단점

    당연하게도 이러한 구현에는 장점과 단점이 공존한다.

     

    장점으로는 실행 코드의 크기가 작아지므로 프로그램 작업 세트도 작아지고 결과적으로 캐시 참조 지역성도 향상된다는 점이 있다. 쉽게 말해 속도가 빨라진다.

     

    단점으로는 생성되는 코드의 효율이 떨어질 수 있는데 크기가 고정되어 있는 버전의 경우 컴파일러 단에서 최적화가 들어갈 여지가 많아 더 좋은 코드가 생성될 가능성이 높다.

     

    번외로 효율 측면에서 고민해야 할 게 있는데 기본 클래스에 포인터를 추가하는 것은 파생 클래스 입장에서 이미 알고 있는 정보를 추가로 들고 있는 것이기 때문에 불필요하게 최소 하나의 포인터 크기만큼 크기가 커진다는 것이다.

     

    ※ 장, 단점과 효율을 따져서 템플릿을 작성해야 한다.

     

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

    스마트 포인터와 일반 포인터

    기본적으로 스마트 포인터는 "++" 연산을 통해 다음 노드로 이동하는 등 포인터에서 지원하지 않는 다양한 기능을 지원한다. 다만 일반 포인터와 달리 스마트 포인터는 암시적 변환을 지원하지 않는다.


    class Top{ ... };

    class Middle : public Top{ ... };

    class Bottom : public Middle{ ... };

    // 스마트 포인터는 다음과 같은 암시적 변환이 지원되지 않는다.

    Top* pt1 = new Middle;

    Top* pt2 = new Bottom;

    const Top* pct2 = pt1;


     

    스마트 포인터의 암시적 변환 지원

    이를 위해서 특정 타입의 생성자를 만드는 게 아닌 생성자를 만들어주는 템플릿 함수, 멤버 함수 템플릿을 응용하면 된다.


    template<typename T>

    class SmartPtr {

    public:

        template<typename U>

        SmartPtr(const smartPtr<U>& other); // 복사 생성자를 자동 생성한다.

    };


     

    다만 이런식으로 사용하면 기본 클래스로의 변환 외에도 기본 클래스에서 파생 클래스로의 변환도 허용되고, 심지어 int*에서 double*로 변환도 발생할 수 있다.

     

    이를 막기 위해서는 SmartPtr내부에 T타입 포인터를 두고 이를 초기화 해주는 방식으로 T로 암시적 변환이 가능한 것에 대해서만 컴파일이 되도록 만들면 된다.


    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;

    };


     

    마지막으로 TU가 같은 경우를 제대로 처리하기 위해서는 기본 복사 생성자들도 생성을 해줘야 한다. C++의 원칙에 따라 복사 생성자를 사용자화 했을 때 기본 복사 생성자는 만들어지지 않기 때문이다.

     

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

    생성자를 통한 암시적 변환은 컴파일러에게 고려되지 않는다

    다음과 같이 생성자에서 암시적 형 변환을 하던 클래스를 템플릿으로 만들면 operator*에 사용되는 T가 무엇인지 알 수 없기 때문에 컴파일 에러가 발생하게 된다.


    template<typename T>

    class Rational {

    public:

        Rational(const T& numerator = 0, const T& denominator = 1);

        const T numerator() const;

        const T denominator() const;

    };

    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; // 컴파일 에러 발생!


     

    컴파일 에러 발생하는 구간에서 oneHalf는 정상적으로 진입하지만 두 번째 인자인 2는 int가 되어 암시적으로 Rational<int>가 될 수 있지만 컴파일러는 이러한 암시적 변환을 신경 쓰지 않기에 에러가 발생한다.

     

    클래스 안에 비멤버 함수를 사용

    클래스 안에 비멤버 함수를 넣는 방법은 friend밖에 없기 때문에 friend함수를 이용해 operator*를 구현하면 위의 코드가 정상적으로 실행되게 된다.


    template<typename T>

    class Rational {

    public:

        // 클래스 내부에 선언 및 정의를 해줘야 한다.

        friend const Rational Operator*(const Rational& rhs, const Rational& lhs)

        {

            return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * hrs.denominator());

        }

    };


     

    friend 함수로 선언했다고 public 외의 접근 권한을 부여한다는 뜻은 아니다.

     

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

    STL 반복자의 종류

    STL에서 지원하는 반복자는 다음의 다섯 종류가 존재한다.

    • 입력 반복자 전진만 가능, 한 칸씩만 이동, 가리키는 위치에서 읽기만 가능, 읽기 가능 횟수 1회 / 읽기 전용 파일 포인터
    • 출력 반복자 전진만 가능, 한 칸씩만 이동, 가리키는 위치에서 쓰기만 가능, 쓰기 가능 횟수 1회 / 쓰기 전용 파일 포인터
    • 순방향 반복자 입력 반복자, 출력 반복자 처리 가능, 읽기 쓰기 동시에 가능, 횟수 제한 없음 / 해시 컨테이너에서 사용
    • 양방향 반복자 순방향 반복자 처리 가능, 뒤로 이동 가능 / list, map, multimap 등에서 사용
    • 임의 접근 반복자 양방향 반복자 처리 가능, 임의의 거리만큼 상수 시간에 이동 가능 / vector, deque, string 등에서 사용

     

    이러한 반복자들의 관계는 다음과 같다.


    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 { };


     

    특성 정보

    컴파일 도중에 주어진 타입의 정보를 얻을 수 있게 해주는 객체를 말하는 것으로 C++에 미리 정의된 문법 구조가 아니고 단순히 구현 기법이다.

     

    특성 정보를 다루는 표준적인 방법은 해당 특성 정보를 템플릿 및 템플릿의 특수화 버전에 넣는 것이다.


    // 일반 데이터를 위한 템플릿

    template<typename IterT>

    struct iterator_traits {

        // 타입 정보를 외부에서 접근할 수 있도록 한다.

        typedef typename IterT::iterator_category iterator_category;

    };

    // 포인터 데이터를 위한 템플릿

    template<typename IterT>

    struct iterator_traits<IterT*> {

        // 타입 정보를 외부에서 접근할 수 있도록 한다.

        typedef typename IterT::iterator_category iterator_category;

    };

     

    template< ... >

    class list {

    public:

        class iterator {

        public:

            // 클래스에서 지원하는 동작을 typedef로 정의한다.

            typedef bidirectional_iterator_tag iterator_category;

        };

    };


     

    이러한 정보를 이용하는 예제로 이터레이터와 거리가 주어지면 해당 거리만큼 이동하는 advance 함수를 들 수 있다.


    template<typename IterT, typename DistT>

    void advance(IterT& iter, DistT d)

    {

        // 상수 계산이 지원되는 경우와 아닌 경우를 나눠서 처리할 수 있을 것이다.

        if (typeid(typename std::iterator_traits<IterT>::iterator_category)

                    == typeid(std::random_access_iterator_tag))

        {

            iter += d;

        }

        else

         {

            if (d >=0) { while (d--) ++iter; }

            else { while (d++) --iter; }

        }

    }


     

    그런데 IterT의 타입은 컴파일 타임에 파악되기 때문에 굳이 실행 시간을 들여 검사를 할 필요가 없다.

    함수를 오버 로드하여 상세 작업을 생성하고 상위 함수에서 이 함수에 값을 전달해주기만 하면 된다.


    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::bidirectional_iterator_tag)

    {

            if (d >=0) { while (d--) ++iter; }

            else { while (d++) --iter; }

    }

    template<typename IterT, typename DistT>

    void doAdvance(IterT& iter, DistT d, std::input_iterator_tag)

    {

        if (d < 0) {

            throw std::out_of_range("Negative distance"); // input_iterator_tag는 전진만 가능하므로 예외를 던진다.

        }

        while (d--) ++iter;

    }

     

    template<typename IterT, typename DistT>

    void advance(IterT& iter, DistT d)

    {

        doAdvance(iter, d, typename std::iterator_triats<IterT>::iterator_category());

    }


     

    ※ C++에는 여러 가지 특성 정보가 존재하므로 필요에 따라 사용하면 된다.

     

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

    TMP의 장점

    TMP에는 크게 두 가지 장점이 존재한다.

    • 까다롭거나 불가능한 일을 쉽게 처리할 수 있다.
    • 기존 작업을 런타임 영역에서 컴파일 영역으로 전환할 수 있다.
      • 실행 도중에 잡혀 오던 에러를 컴파일 도중에 찾을 수 있다.
      • 효율적일 여지가 많다.
        • 실행코드가 작아진다.
        • 실행 시간이 짧아진다.
        • 메모리도 적게 잡아먹는다.

     

    TMP의 반복 처리

    TMP는 루프가 존재하지 않기 때문에 재귀를 이용해 반복 처리를 하게 된다.


    template<unsigned n>

    struct Factorial {

        // 이 경우 재귀를 통해 구현이 되며 모든 계산이 컴파일 타임에 이루어진다.

        enum { value = n * Factorial<n - 1>::value };

    };

    template<>

    struct Factorial<0> {

        enum { value = 1 };

    };


     

    TMP가 강점을 가지는 영역

    TMP는 여러 부분에서 사용되지만 다음의 영역에서는 확실한 강점을 가진다.

    • 치수 단위의 정확성 확인 정확한 치수를 맞춰야 하는 물리 등의 계산에서 컴파일 타임에 이러한 치수를 확인할 수 있다.
    • 행렬 연산의 최적화 덩치 큰 임시 객체를 없애는 것은 물론 루프까지 합칠 수 있다.
    • 맞춤식 디자인 패턴 구현의 생성 스마트 포인터 같이 설계상의 정책을 만들고 이것을 통해 필요한 인스턴스를 구현할 수 있다.

     

    댓글

Designed by Tistory.