C++ / a working model

29 / 80   ·   C++11   ·   约 8 分钟

成员模板为何不能 virtual

先记住这句话

成员函数模板不能声明为虚函数,它的某个特化也不会自动覆盖基类虚函数。但类模板里的普通非模板成员可以是虚函数。需要兼顾泛型入口与运行期扩展时,可把模板转换层和固定签名的虚接口分开。

本篇内容
  1. 区分两种不同的模板位置
  2. 限制是语言规则,不是虚表容量推导
  3. 泛型适配层加固定动态接口
  4. 运行示例
  5. 动手练习

区分两种不同的模板位置

template<class T> void send(T) 是成员函数模板,标准禁止给它加 virtual;即使实例化出 send(int),这个模板特化也不会因签名巧合而覆盖基类虚函数。需要覆盖时,必须声明一个真正的非模板成员,例如 void send(int) override

另一方面,template<class T> struct Sink { virtual void send(T) = 0; }; 中 send 本身没有模板参数列表,是类模板里的普通成员,可以为虚函数。Sink<int> 实例化后,虚接口签名已经固定为 int;Sink<double> 是另一个不同的类类型。

限制是语言规则,不是虚表容量推导

模板按使用情境产生特化,虚函数则为已确定的类接口建立覆盖关系;两者的扩展时间和匹配规则不同。固定签名让独立编译的调用方与实现方能够约定同一个动态接口,常见 ABI 的虚表布局也围绕这种接口组织。

“模板可能无限多所以虚表装不下”可以作为动机直觉,但不是证明,更不能据此声称编译器理论上无法实现别的模型。准确回答应先引用成员模板不得 virtual 的规则,再说明类模板普通成员这个经常被混淆的合法情况。

泛型适配层加固定动态接口

示例提供非虚的 put<T> 模板,只负责把输入转成统一的 string,再调用固定签名的虚 write。调用者得到泛型便利,派生类只需要实现一个动态接口。转换可能失败时,应让错误在 write 前被报告,避免把不完整的数据交给实现。

这种设计是一种边界收敛:不是让运行期虚接口认识任意类型,而是把类型差异留在编译期适配层。如果数据不能无损转换成共同表示,可选择有限重载、variant 或有明确操作集合的类型擦除;不要为了看起来泛型而丢失业务语义。

容易答错的地方

  • 派生类的同名成员模板可能隐藏基类函数,却不覆盖它;通过基类引用调用时仍按原虚接口分派。
  • 一个成员不是模板,不代表其所属类也不是模板;判断 virtual 是否合法要看函数声明本身。

运行一个例子

最低标准 C++11 · 完整程序 · 下载 .cpp

#include <cassert>
#include <sstream>
#include <stdexcept>
#include <string>

class Sink {
public:
    template<class T>
    void put(const T& value) {
        std::ostringstream stream;
        stream << value;
        if (!stream) throw std::runtime_error("conversion failed");
        write(stream.str());
    }
    virtual ~Sink() = default;
private:
    virtual void write(const std::string& text) = 0;
};

class Buffer final : public Sink {
    std::string data_;
    void write(const std::string& text) override { data_ += text; }
public:
    const std::string& data() const { return data_; }
};

int main() {
    Buffer buffer;
    Sink& sink = buffer;
    sink.put(12);
    sink.put(" apples");
    assert(buffer.data() == "12 apples");
}

在本地编译

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread objects-virtual-template.cpp -o example && ./example

预期结果

预期:正常退出、无输出;所有 assert 通过。

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

基类声明 virtual void f(int),派生类只写 template<class T> void f(T),通过 Base& 调用 f(1) 会到哪里?如何转发到模板?

查看参考答案

该成员模板不会覆盖基类函数,因此虚调用仍到基类的最终覆盖者;若基类函数是纯虚,派生类仍抽象。应增加非模板的 void f(int value) override { f<int>(value); }。明确模板实参可选中模板特化,避免包装函数误调用自身而无限递归。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录