《Effective C++》——模板与泛型编程(item 41 ~ item 48)

33 阅读11分钟

Item 41:了解隐式接口和编译期多态

  • “classes 和 templates 都支持接口(interfaces)和多态(polymorphism)。”

    • 类中的接口和多态

      • 接口:

        可以通过定义一个包含纯虚函数的抽象基类(即接口类)来表示一个接口。派生类可以继承这个基类并实现其纯虚函数,这样不同的派生类可以实现不同的行为。

      • 多态:

        通过动态多态实现,即使用基类指针或引用调用派生类的重写方法,这种多态在运行时通过虚表(vtable)实现。

    • 模板中的接口和多态

      • 接口:

        模板没有传统的接口概念,但可以通过约定的“接口”来要求模板参数具备某些行为或方法

      • 多态:

        模板实现的多态性是静态多态(编译时多态)。在编译时,模板会根据具体的类型参数生成不同的代码版本,因此不需要虚表。每个不同的类型都会实例化出不同的函数版本,从而避免运行时开销。

    • 类多态和模板多态对比

      特性类(动态多态)模板(静态多态)
      多态类型运行时多态编译时多态
      开销依赖虚函数表,略有运行时开销编译时展开,无额外运行时开销
      接口要求通过继承抽象基类,实现特定接口通过约定,要求类型符合接口
      灵活性适用于多种类型的指针或引用适用于编译期已知的特定类型
      适用场景适合运行时接口一致的多态性适合需要最大性能的泛型编程
  • “对 classes 而言,接口是显式的(explicit),以函数签名为中心。多态则是通过 virtual 函数发生于运行期。”

  • “对 template 参数而言,接口是隐式的(implicit),奠基于有效表达式。多态则是通过 template 具现化和函数重载解析(function overloading resolution)发生于编译期。”

    接口的隐式性

    模板的接口并不是显式地通过函数签名来定义的,而是基于模板参数支持的有效表达式。模板代码不会检查模板参数的类型是否具有特定的函数,而是依赖编译器在实例化模板时检测传入的类型是否具备所需的功能。这种依赖被称为鸭子类型(duck typing)——即只要类型“看起来像”具有所需接口,就可以使用。

    template <typename T>
    void renderShape(const T& shape) {
        shape.draw();  // 假设 T 类型有 draw() 方法
    }
    

    在这个例子中,renderShape模板并未显式要求T具有draw()方法。只有在实例化renderShape时,编译器才会检查传入的类型是否支持draw(),因此模板的接口是隐式的。

Item 42:了解 typename 的双重含义

  • “声明 template 参数时,前缀关键字 classes 和 typename 可互换。”

  • “请使用关键字 typename 标识嵌套从属类型名称;但不得在 base class lists(基类列)或 member initialization list 内以它作为 base class 修饰符。”

    1. 使用 typename 标识嵌套从属类型

    在模板中,当访问某个依赖于模板参数的嵌套类型时,编译器无法提前知道这个嵌套名称到底是类型还是成员变量,需要使用typename来明确声明它是一个类型。例如:

    template <typename T>
    class Container {
    public:
        typename T::value_type element;  // 明确 T::value_type 是一个类型
    };
    

    在上面的代码中,T::value_type是依赖于T的嵌套类型,使用typename告诉编译器这是一个类型而不是变量。

    2. 不得在基类列表或成员初始化列表中使用 typename

    在以下情况中,不得使用typename关键字作为类型修饰符:

    • 基类列表:在基类列表中指定基类时,即使基类依赖于模板参数,也不需要且不允许使用typename
    template <typename T>
    class Derived : public T::BaseType {  // 不需要 typename
    public:
        // ...
    };
    
    • 成员初始化列表:在构造函数的成员初始化列表中,使用依赖类型的基类构造函数初始化时,也不能使用typename
    template <typename T>
    class Derived : public T::BaseType {
    public:
        Derived() : T::BaseType() {  // 不允许使用 typename
            // ...
        }
    };
    

Item 43:学习处理模板化基类内的名称

  • “可在 derived class templates 内通过 this-> 指涉 base class templates 内的成员名称,或藉由一个明白写出的 ‘base class 资格修饰符’ 完成。”

    观察下面的例子:

    class CompanyA {
    public:
            ...
            void sendCleattext(const std::string& msg);
            void sendEncrypted(const std::string& msg);
            ...
    };
    
    
    class CompanyB {
    public:
            ...
            void sendCleattext(const std::string& msg);
            void sendEncrypted(const std::string& msg);
            ...
    };
    
    
    ...			// 针对其他公司设计的 classes
    
    
    class MsgInfo {...};		// 这个classes用来保存信息,以备将来产生信息
    
    
    template<typename Company>
    class MsgSender {
    public:
            ...
            void sendClear(const MsgInfo& info)
            {
                    std::string msg;
                    // 根据 info 产生 信息
                    ...
                    Company c;
                    c.sendCleartext(msg);
            }
    
            void sendSecret(const MsgInfo& info)
            {
                    std::string msg;
                    // 根据 info 产生 信息
                    ...
                    Company c;
                    c.sendEncrypted(msg);
            }
    };
    
    // 当想要在每次送出信息时志记某些信息。
    // derived class 可轻易加上这样的功能
    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company> {
    public:
            ...
            void sendClearMsg(const MsgInfo& info)
            {
                    // 将 传送前 的信息写入 log
                    ...
                    sendClear(info);		// 调用 base class 函数;这段代码无法通过编译
                    // 将 传送后 的信息写入 log
                    ...
            }
    };
    

    正如注释中所说,sendClear(info); 无法通过编译,原因看下面的例子:

    class CompanyZ {
    public: 
            ...		// 这个类不提供 sendCleartext 函数
            void sendEncrypted(const std::string& msg);
            ...
    };
    

    对于 CompanyZ 来说,一般性的 MsgSneder template 并不合适,因为 template 提供了一个 sendClear 函数,而这个函数调用了 CompanyZ 中并没有的 sendCleartext 函数。欲解决这个问题,可以针对 CompanyZ 产生一个 MsgSender 特化版。

    // 一个全特化的 MsgSender,它和一般 template 相同,差别只在于它删掉了 sendClear
    template<>
    class MsgSender<CompanyZ> {
    public:
            ...
            void sendSecret(const MsgInfo& info)
            {
                    std::string msg;
                    // 根据 info 产生 信息
                    ...
                    Company c;
                    c.sendEncrypted(msg);
            }
    };
    

    所以 sendClear(info); 无法通过编译的原因是:它知道 base class templates 有可能被特化,而那个特化版本可能不提供和一般性 template 相同的接口。因此它往往拒绝在 templatized base classes(模板化基类,本例中的 MagSender<Company>)内寻找继承而来的名称。

    有三个办法令C++ “不进入 templatized base classes 观观察” 的行为失效:

    (1)在 base class 函数调用动作之前加上 this->

    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company> {
    public:
            ...
            void sendClearMsg(const MsgInfo& info)
            {
                    // 将 传送前 的信息写入 log
                    ...
                    this->sendClear(info);	// 假设 sendClear 将被继承	
                    // 将 传送后 的信息写入 log
                    ...
            }
    };
    

    (2)使用 using 声明式

    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company> {
    public:
            // 告诉编译器,请它假设 sendClear 位于 base class 内
            using MsgSender<Company>::sendClear;
            ...
            void sendClearMsg(const MsgInfo& info)
            {
                    // 将 传送前 的信息写入 log
                    ...
                    sendClear(info);		
                    // 将 传送后 的信息写入 log
                    ...
            }
    };
    

    (3)指出被调用的函数位于 base class 内

    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company> {
    public:
            ...
            void sendClearMsg(const MsgInfo& info)
            {
                    // 将 传送前 的信息写入 log
                    ...
                    MsgSender<Company>::sendClear(info);		
                    // 将 传送后 的信息写入 log
                    ...
            }
    };
    

    注意:这种方法往往是最不让人满意的一个解法,因为如果被调用的是 virtual 函数,上述的明确资格修饰(explicit qualification) 会关闭 “virtual 绑定行为”。

Item 44:将与参数无关的代码抽离 templates

  • “Templates 生成多个 classes 和多个函数,所以任何 template 代码都不该与某个造成膨胀的 template 参数产生相依关系。”

  • “因非类型模板参数(non-type template parameters)而造成的代码膨胀,往往可消除,做法是以函数参数或 class 成员变量替换 template 参数。”

    • 模板版本(如 Buffer<10>, Buffer<100> 等):每个不同的模板参数 Size 都会生成独立的类定义。对于 Buffer<10>Buffer<100>,编译器会分别生成两个独立的类代码,包括构造函数、clear 函数以及每个类的 data 数组大小。这种编译时的多重实例化增加了代码体积(即编译器膨胀问题)。

    • 非模板版本(以 size 参数代替模板):不论 size 值是多少,只生成一个 Buffer 类定义,并且所有实例共享同一个类代码。虽然会在运行时生成多个对象,但代码段保持不变,不会因为对象大小不同而重新生成类代码。

    template <size_t Size>
    class Buffer {
    public:
        void clear() {
            for (size_t i = 0; i < Size; ++i) {
                data[i] = 0;
            }
        }
    
    private:
        int data[Size];
    };
    

    如果这个模板用不同的 Size 值实例化,编译器将为每个 Size 值生成一个不同的 Buffer 类。这样会导致多个 Buffer 类实例被生成,从而造成代码膨胀。

    替换为类成员变量

    可以将 Size 从模板参数中移除,并用一个构造函数参数或类成员变量代替:

    class Buffer {
    public:
        Buffer(size_t size) : size(size), data(new int[size]) {}
    
        void clear() {
            for (size_t i = 0; i < size; ++i) {
                data[i] = 0;
            }
        }
    
    private:
        size_t size;
        std::unique_ptr<int[]> data;
    };
    

    替换为函数参数

    对于某些使用场景,也可以直接在成员函数中引入该参数,而不是在类中保存:

    class Buffer {
    public:
        void clear(size_t size) {
            for (size_t i = 0; i < size; ++i) {
                data[i] = 0;
            }
        }
    
    private:
        int* data = new int[default_size];
    };
    
  • “因类型参数(type parameters)而造成的代码膨胀,往往可降低,做法是让带有完全相同的二进制表述(binary representation)的具现类型(instantiation types)共享实现码。”

    template <typename T>
    class Container {
    public:
        void doSomething() { /* ... */ }
    };
    

    对于 Container<int>Container<long>,编译器会生成两份独立的代码,即使 intlong 的二进制结构可能一致。这种机制导致了代码膨胀。

    template <typename T>
    class Container {
    public:
        void doSomething() { /* 默认实现 */ }
    };
    
    // 特化:使 Container<int> 和 Container<long> 使用相同代码
    template <>
    class Container<long> : public Container<int> {};
    

    在这个例子中,Container<long> 特化直接复用了 Container<int> 的代码,这样编译器只生成 Container<int> 的实现代码。

Item 45:运用成员函数模板接受所有兼容类型

  • “请使用 member function templates(成员函数模板)生成 ‘可接受所有兼容类型’ 的函数。”

  • “如果你声明 member templates 用于 ‘泛化 copy 构造’ 或 ‘泛化 assignment 操作’ ,你还是需要声明正常的 copy 构造函数和 copy assignment 操作符。”

    当有泛化版本存在时,编译器不会自动生成默认的拷贝构造函数和拷贝赋值操作符。

    假设我们有一个类 Widget,想通过成员模板实现泛化的拷贝构造和赋值操作:

    #include <iostream>
    
    class Widget {
    public:
        int data;
    
        // 泛化拷贝构造函数模板
        template <typename T>
        Widget(const T& rhs) : data(rhs.data) {}
    
        // 泛化赋值操作符模板
        template <typename T>
        Widget& operator=(const T& rhs) {
            data = rhs.data;
            return *this;
        }
    };
    

    在这个示例中:

    • 泛化拷贝构造函数模板和赋值操作模板允许 Widget 从不同类型的对象(T)构造和赋值。
    • 然而,这些模板不会处理相同类型的 Widget 对象拷贝,即 Widget w1 = w2; 这种普通的拷贝操作,编译器不会自动生成缺失的拷贝构造函数和赋值操作。

    因此,如果想支持 Widget 的正常拷贝操作,仍然需要手动添加:

    class Widget {
    public:
        int data;
    
        // 普通的拷贝构造函数
        Widget(const Widget& rhs) : data(rhs.data) {}
    
        // 普通的拷贝赋值操作符
        Widget& operator=(const Widget& rhs) {
            if (this != &rhs) {
                data = rhs.data;
            }
            return *this;
        }
    
        // 泛化拷贝构造函数模
        template <typename T>
        Widget(const T& rhs) : data(rhs.data) {}
    
        // 泛化赋值操作符模板
        template <typename T>
        Widget& operator=(const T& rhs) {
            data = rhs.data;
            return *this;
        }
    };
    

Item 46:需要类型转换时请为模板定义非成员函数

  • “当我们编写一个 class template,而它所提供之 ‘与此 template 相关的’ 函数支持 ‘所有参数之隐式类型转换’ 时,请将那些函数定义为 ‘class template’ 内部的 friend 函数。”

    观察下面的例子:

    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;   // 编译错误
    

    这个例子中,Rational<int> result = oneHalf * 2; 无法通过编译,原因是 oneHalf * 2 中的 2 是一个 int 类型的值,而 operator* 只接受两个 Rational<T> 类型的参数。由于 operator* 是一个函数模板,编译器不会自动将 int 转换为 Rational<int>,导致类型不匹配的错误。

    你也许会期盼编译器使用 Rational<int> 的 non-explicit 构造函数将2转化为 Rational<int>,进而将 T 推导为 int,但它们不那么做,因为在 template 实参推导过程中从不将隐式类型转换函数纳入考虑。

    解决办法: 将 operator* 定义为 Rational 的友元。

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

    当我们将 operator* 定义为 Rational 的友元时,编译器会把 operator* 的定义视为 Rational 类的“成员”——尽管它不是真正的成员函数。这意味着 operator* 会自动绑定到 Rational 的模板参数 T,所以在 Rational<T> 作用域内调用时,T 的类型自动被传递给 operator*

Item 47:请使用 traits classes 表现类型信息

  • “Traits classes 使得 ‘类型相关信息’ 在编译期可用。它们以 templates 和 ‘templates 特化’ 完成实现。”

  • “整合重载技术(overloading)后,traits classes 有可能在编译期对类型执行 if...else 测试。”

Item 48:认识模板元编程

  • “Template metaprogramming(TMP,模板元编程)可将工作由运行期移往编译期,因而得以实现早期错误侦测和更高的执行效率。”

  • “TMP 可被用来生成 ‘基于政策选择组合’ (based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。”