C++模板初阶

模板初阶

从重复的函数说起

假设我们要写一个交换函数。交换两个整数不难,交换两个浮点数也不难,问题是:它们的逻辑明明一样,只是类型不同,我们却要把同一套过程写上好几遍。

C++ 有函数重载,可以让这些函数使用同一个名字,但函数体还是要一个一个写。

cpp 复制代码
// swap.hpp
#pragma once

namespace wind {
inline void swap(int& here, int& there) {
    int tmp = here;
    here = there;
    there = tmp;
}

inline void swap(double& here, double& there) {
    double tmp = here;
    here = there;
    there = tmp;
}

inline void swap(char& here, char& there) {
    char tmp = here;
    here = there;
    there = tmp;
}
}  // wind 命名空间

这里把函数定义写进了 swap.hpp。.hpp 是 C++ 头文件常用的扩展名,通常用来存放类、模板等内容。

这个头文件可能被多个 .cpp 包含,所以三个普通函数都加上了 inline,使符合条件的同一函数定义可以出现在多个翻译单元中。这与前面讨论过的单一定义规则(ODR)有关。

如果以后还要交换其他类型,又得再添一份重载。我们真正希望表达的是:类型可以先不确定,交换过程只写一次,等使用时再把类型填进去。

这种把具体类型抽出来、让同一套逻辑适用于不同类型的写法,就是泛型编程的一种方式。C++ 的模板可以帮助我们做到这一点。

函数模板:把类型也作为参数

写出交换函数模板

把交换函数中具体的类型换成一个类型参数:

cpp 复制代码
// swap.hpp
#pragma once

namespace wind {
template <typename T>
void swap(T& here, T& there) {
    T tmp = here;
    here = there;
    there = tmp;
}
}  // wind 命名空间

template <typename T> 表示下面定义的是一个模板,T 是其中的类型参数。在这个定义里,原来写 int、double 的地方,现在都可以用 T 表示。

这里也可以写成 template <class T>。声明这种类型模板参数时,class 与 typename 的作用相同,T 都可以代表 int、double 等内置类型,也可以代表自定义类型。

T 也不是固定名字。只要是合法标识符就行,能表达用途更好。如果需要多个类型参数,可以写成:

cpp 复制代码
template <typename T1, typename T2>

上面的函数模板没有加 inline,仍然可以把定义放在头文件中。先记住这种写法,等弄清实例化之后,再回头解释它与 ODR 的关系。

使用时确定类型

cpp 复制代码
// main.cpp
#include "swap.hpp"

int main() {
    int i = 1, j = 10;
    double m = 1.25, n = 6.25;

    wind::swap(i, j);
    wind::swap(m, n);
    return 0;
}

第一处调用传入两个 int,编译器据此确定 T 为 int;第二处传入两个 double,于是 T 为 double。这种根据调用实参确定模板参数的过程,叫作模板实参推导。

类型确定以后,编译器就能依据模板得到对应的具体函数。这两个调用使用的是 wind::swap<int> 和 wind::swap<double>,各自的参数类型、临时变量类型也就随之确定了。

这份交换逻辑用到了复制初始化和赋值,因此传入的类型需要支持这两种操作。模板确定了具体类型以后,函数体中的操作也会按这个类型接受检查。

模板实例化:从模板得到具体的函数或类

从调试记录观察实例化后的函数调用

模板描述的是一套生成规则。程序运行时,执行的是据此得到的具体函数。依据模板和模板实参形成具体函数或类的过程,叫作模板实例化。

下面通过调试记录观察交换函数的调用。截图中的代码与上面的示例略有差异;反汇编的地址、偏移和行号对应这次实验使用的可执行文件。

在下面这份 LLDB 记录中,可以直接看到 main 分别调用了 wind::swap<int> 和 wind::swap<double>:

text 复制代码
(lldb) disassemble --frame
main-debug`main:
    0x555555555130 <+0>:  pushq  %rbp
    0x555555555131 <+1>:  movq   %rsp, %rbp
    0x555555555134 <+4>:  subq   $0x20, %rsp
    0x555555555138 <+8>:  movl   $0x0, -0x4(%rbp)
->  0x55555555513f <+15>: movl   $0x1, -0x8(%rbp)
    0x555555555146 <+22>: movl   $0xa, -0xc(%rbp)
    0x55555555514d <+29>: movsd  0xebb(%rip), %xmm0
    0x555555555155 <+37>: movsd  %xmm0, -0x18(%rbp)
    0x55555555515a <+42>: movsd  0xea6(%rip), %xmm0
    0x555555555162 <+50>: movsd  %xmm0, -0x20(%rbp)
    0x555555555167 <+55>: leaq   -0x8(%rbp), %rdi
    0x55555555516b <+59>: leaq   -0xc(%rbp), %rsi
    0x55555555516f <+63>: callq  0x555555555190 ; wind::swap<int> at swap.hpp:23
    0x555555555174 <+68>: leaq   -0x18(%rbp), %rdi
    0x555555555178 <+72>: leaq   -0x20(%rbp), %rsi
    0x55555555517c <+76>: callq  0x5555555551c0 ; wind::swap<double> at swap.hpp:23
    0x555555555181 <+81>: xorl   %eax, %eax
    0x555555555183 <+83>: addq   $0x20, %rsp
    0x555555555187 <+87>: popq   %rbp
    0x555555555188 <+88>: retq   
(lldb) 

两条 callq 的目标分别是 0x555555555190 和 0x5555555551c0,调试器标出了 wind::swap<int> 和 wind::swap<double>。这次构建分别生成了两个类型对应的函数,main 通过各自的入口调用它们。

同一份模板就这样得到了不同类型对应的具体函数。开启优化后,调用还可能被展开,最终的机器码会随构建配置变化。

隐式实例化与显式指定模板实参

前面的代码没有专门要求编译器生成某个函数,只是在调用时需要它,编译器便据此实例化。这叫隐式实例化。

在这个过程中,模板实参推导负责确定类型参数,实例化则依据确定的模板实参形成对应的函数。前面的调用把这两步连在了一起。

用加法函数来看会更清楚:

cpp 复制代码
// add.hpp
#pragma once

namespace wind {
template <typename T>
T add(const T& left, const T& right) {
    return left + right;
}
}  // wind 命名空间
cpp 复制代码
// main.cpp
#include "add.hpp"
#include <iostream>

int main() {
    int a1 = 10, a2 = 20;
    double d1 = 10.0, d2 = 20.0;

    wind::add(a1, a2);  // T 推导为 int
    wind::add(d1, d2);  // T 推导为 double

    // wind::add(a1, d1);  // 错误: 两处对 T 的推导不一致

    double i = wind::add<double>(6, 1.25);

    int j = wind::add(6, (int)1.25);
    std::cout << i << '\n';  // 7.25
    std::cout << j << '\n';  // 7
    return 0;
}

对于 add(a1, d1),第一个参数要求 T 是 int,第二个参数要求 T 是 double,而模板里只有同一个 T。这两处推导对不上,调用就不能成立。

这个模板要求两个参数使用同一个 T。遇到不同类型的实参,需要由调用者确定采用哪种类型;这个选择也决定了后面的计算和返回结果。

解决方法有两种。

一种是自己先转换调用实参,例如 add(6, (int)1.25)。传入推导过程的已经是两个 int,所以 T 可以确定为 int,结果是 7。

另一种是在函数名后的尖括号中写出模板实参,例如 add<double>(6, 1.25)。这时 T 已经明确是 double,具体函数的形参就是两个 const double&。整数 6 可以转换为 double,再参与调用,结果是 7.25。

同样,add<int>(a1, d1) 会把 d1 转换为 int,再让形参 const int& 绑定转换产生的临时对象。这里使用常引用,也就允许这种传参方式。

下面是加法函数的实验截图,可以对照其中的调用与输出:

函数模板与普通函数怎样匹配

同名的普通函数和函数模板可以同时存在。一次调用到底使用哪一个,需要一起参与重载决议,最后选择最合适的。

cpp 复制代码
// add.hpp
#pragma once

#include <iostream>

namespace wind {
inline int add(int v1, int v2) {
    std::cout << "int" << '\n';
    return v1 + v2;
}

template <typename T>
T add(const T& v1, const T& v2) {
    std::cout << "template" << '\n';
    return v1 + v2;
}
}  // wind 命名空间

普通的整型加法直接按值传参。模板还可能用于较大的自定义类型,这里选择常引用,避免传参时复制整个对象。

cpp 复制代码
// main.cpp
#include "add.hpp"
#include <iostream>

int main() {
    auto i = wind::add(1, 5);
    std::cout << i << '\n';

    auto j = wind::add<int>(1, 5);
    std::cout << j << '\n';

    auto k = wind::add(1.25, 2.5);
    std::cout << k << '\n';
    return 0;
}

add(1, 5) 中,普通函数与模板对应的 int 版本匹配程度相同,最终优先选择普通函数。这样,单独为整数提供的实现就能优先用于整数调用,方便针对某种类型安排专门的处理。

add<int>(1, 5) 明确使用模板实参列表,选择的是函数模板对应的版本。

add(1.25, 2.5) 则不同:普通函数需要把两个 double 转换成 int,模板可以推导出 T 为 double,直接匹配。此时模板对应的函数更合适,即使已经存在同名的普通函数,仍然会选中模板版本。

这几个调用可以按同一个顺序理解:先比较参数的匹配程度,匹配程度相同时,普通函数优先。

下面的实验截图展示前两个调用的结果;第三个调用用于对比匹配程度不同的情况,截图未包含这一项。

类模板:让类中的类型也可以变化

函数的逻辑可以复用,类的结构和成员函数也可以。只要一个类的结构或操作需要随某种类型变化,就可以考虑用类模板表达;数据结构是很常见的应用,其他用途的类也同样可以使用模板。这里仍然拿熟悉的栈来看。

例如,一个栈原来通过 typedef 确定元素类型:

cpp 复制代码
typedef int SDate;

如果类中的存储指针、入栈参数等位置都使用 SDate,改这一处就能让整份实现换一种元素类型。但这样的别名仍然只代表一个确定的类型。如果同一个程序既要一个存整数的栈,又要一个存浮点数的栈,单靠修改这处别名就不够了。

把元素类型改成模板参数,就可以让同一份栈的定义用于不同类型:

cpp 复制代码
// Stack.hpp
#pragma once

#include <cstddef>
#include <iostream>

namespace wind {
template <typename SDate>
class Stack;

template <typename SDate>
std::ostream& operator<<(std::ostream& out, const Stack<SDate>& s);

template <typename SDate>
class Stack {
public:
    // explicit 禁止通过隐式类型转换调用这个构造函数.
    explicit Stack(std::size_t sz = 4, const SDate* pArr = nullptr);
    ~Stack();
    Stack(const Stack& s);
    Stack& operator=(const Stack& s);

    void Push(const SDate& val);
    void Extend();

    friend std::ostream& operator<< <SDate>(std::ostream& out, const Stack& s);

private:
    SDate* _val;
    std::size_t _top;
    std::size_t _capacity;
};
}  // wind 命名空间

现在 SDate 是每次使用模板时确定的类型参数。存储指针使用 SDate*,入栈参数也要相应地使用 const SDate&。如果仍然写成 Push(int),即使创建的是浮点型栈,浮点数也会在进入函数时先被转换为整数,这就和我们想要的行为对不上了。

上面先给出了类模板和输出函数模板的声明,再在类模板中声明接口。成员函数与输出函数的定义都放在后面,补齐之后就能运行下面的例子。

cpp 复制代码
#include "Stack.hpp"

int main() {
    wind::Stack<int> i;
    wind::Stack<double> j;

    i.Push(10);
    j.Push(1.25);
    std::cout << i << '\n';
    std::cout << j << '\n';
    return 0;
}

Stack<int> 和 Stack<double> 是两个不同的类类型,分别保存 int 和 double。这里的 i、j 则是各自类型的对象。

如果再创建一个 Stack<int> 对象,它和 i 使用同一个类类型,两个对象各自拥有自己的成员数据。

C++17:根据构造实参推导类模板实参

上面写 Stack<int>,是直接告诉编译器元素类型为 int。从 C++17 开始,在某些创建对象的地方,也可以省略模板实参,让编译器根据初始化时提供的信息推导。这叫作类模板实参推导。

继续使用上面的栈:

cpp 复制代码
#include "Stack.hpp"

int main() {
    const int ints[] = {1, 2, 3};
    const double doubles[] = {1.25, 2.5};

    wind::Stack i(3, ints);       // 推导为 wind::Stack<int>
    wind::Stack j(2, doubles);    // 推导为 wind::Stack<double>
    std::cout << i << '\n';
    std::cout << j << '\n';
    return 0;
}

构造函数的声明是:

cpp 复制代码
explicit Stack(std::size_t sz = 4, const SDate* pArr = nullptr);

第二个形参的类型包含 SDate。传入 ints 时,数组在这里转换为 const int*,与形参类型 const SDate* 对应,就可以推导出 SDate 为 int;传入 doubles 时,同样根据 const double* 推导出 SDate 为 double。确定具体的类类型之后,再使用对应的构造函数创建对象。

提供推导依据的形参不必放在最后,也不必是指针。例如,构造函数的某个形参直接写成 SDate value,也可以根据对应实参推导 SDate。这里使用数组指针,只是因为栈的这个构造函数需要接收初始元素。

第一个形参 std::size_t sz 的类型已经固定,无论传入的容量是多少,都不能从中得知元素类型。这就是两个形参在推导过程中承担的不同作用。

这里可以先把它理解为:编译器根据构造函数声明建立推导依据,再拿初始化实参与之匹配。这个例子的构造函数声明已经提供了所需信息。

不过,推导需要有依据:

cpp 复制代码
// 推导失败的示例:
// wind::Stack a;      // 没有能确定 SDate 的实参
// wind::Stack b(4);   // 4 只决定容量, 不能确定元素类型

nullptr 可以作为不同指针类型的空值,本身没有提供元素类型。在这种默认构造的情况下,写出 Stack<int> 就明确了类型。

对象的类型在定义时确定,因此类模板实参推导使用的是初始化时提供的信息。后面的 Push 等操作,使用的都是此时已经确定的类型。

类模板成员函数写到类外时,怎样表示所属类型

为了集中展示接口,可以把成员函数声明放在类里,实现放在类外。下面都写在同一个 Stack.hpp 中,通过定义的位置来观察类型名称和模板参数的写法。

普通类的类外成员定义会写 Stack::。现在面对的是类模板,需要继续说明它依赖哪些模板参数,再用 Stack<SDate>:: 表示成员所属的类型:

cpp 复制代码
template <typename SDate>
void wind::Stack<SDate>::Push(const SDate& val) {
    // 具体实现见下文
}

开头的 template <typename SDate> 表示这仍然是一份模板定义;wind::Stack<SDate>:: 则把成员函数归到使用该类型参数的栈中。

赋值运算符还有返回值,因此可以更明显地看到两处类型名称:

cpp 复制代码
template <typename SDate>
wind::Stack<SDate>& wind::Stack<SDate>::operator=(const Stack& s) {
    // 具体实现见下文
}

开头的返回类型位于成员所属作用域之外,所以这里写出完整的 wind::Stack<SDate>&。后面经过 wind::Stack<SDate>:: 以后,已经进入相应的类作用域,参数中的 Stack 可以简写,代表当前的 Stack<SDate>。

这也解释了类内为什么能写 Stack(const Stack& s):在当前类的作用域里,Stack 可以表示当前的 Stack<SDate> 类型。

以后阅读和编写 STL 相关代码时,还会遇到在类内为当前类型起别名的写法,例如:

cpp 复制代码
typedef Stack<SDate> Self;

Self 是给当前类型起的别名,后面可以用它代替 Stack<SDate>。名字由程序员选择,Self、self 都是常见写法。

输出函数在类外定义时的对应关系

这里的输出函数也写成函数模板,定义和成员函数一起放在类外。为了让声明互相衔接,头文件开头先声明类模板 Stack,再声明接收 Stack<SDate> 的 operator<< 函数模板。

类内这一行:

cpp 复制代码
friend std::ostream& operator<< <SDate>(std::ostream& out, const Stack& s);

用 <SDate> 指定当前元素类型,对应到前面已经声明的函数模板。例如,在 Stack<int> 中,这一行把接收 const Stack<int>& 的输出函数声明为友元。

这样,类内只保留声明,具体的输出过程在后面定义,也与其他函数的组织方式保持一致。

栈的成员函数实现

下面把这些函数的定义接在类模板之后,组成同一个 Stack.hpp。从中可以看到,元素类型怎样通过 SDate 贯穿接口、存储和各个操作。

这里把声明和定义分开,是为了展示类外定义时的写法。代价也很明显:每个成员函数前面都要重新写 template <typename SDate>,所属类型也得写完整。掌握这种形式以后,对于规模不大的模板,后面通常直接在类内定义成员函数,省掉这些重复。

构造函数的参数约定是:pArr 为空时创建容量为 sz 的空栈;不为空时,复制它指向的 sz 个元素,调用者需要保证这些元素有效。栈管理自己的存储,不接管传入数组的所有权。

这里沿用 malloc、free 管理存储,示例用于 int、double 等简单数值类型。涉及类对象的构造与析构时,存储管理也需要相应调整,留到 C++ 内存管理部分再讨论。

复制和扩容时都逐元素赋值。赋值运算符也采用直接申请空间、复制元素、释放旧空间的写法。

cpp 复制代码
// 接在 Stack.hpp 的类模板定义之后
#include <cstdlib>

// pArr 为空时创建空栈, 否则复制 sz 个元素.
template <typename SDate>
wind::Stack<SDate>::Stack(std::size_t sz, const SDate* pArr)
    : _val(nullptr), _top(0), _capacity(0) {
    if (sz == 0) {
        return;
    }

    std::size_t maxCount = (std::size_t)-1 / sizeof(SDate);
    if (sz > maxCount) {
        std::cerr << "错误: 栈容量过大.\n";
        return;
    }

    SDate* p = (SDate*)std::malloc(sizeof(SDate) * sz);
    if (p == nullptr) {
        std::cerr << "错误: 栈内存申请失败.\n";
        return;
    }

    _val = p;
    _capacity = sz;
    if (pArr != nullptr) {
        for (std::size_t i = 0; i < sz; ++i) {
            _val[i] = pArr[i];
        }
        _top = sz;
    }
}

template <typename SDate>
wind::Stack<SDate>::~Stack() {
    std::free(_val);
}

template <typename SDate>
wind::Stack<SDate>::Stack(const Stack& s)
    : _val(nullptr), _top(0), _capacity(0) {
    if (s._capacity == 0) {
        return;
    }

    SDate* p = (SDate*)std::malloc(sizeof(SDate) * s._capacity);
    if (p == nullptr) {
        std::cerr << "错误: 拷贝构造时内存申请失败.\n";
        return;
    }

    for (std::size_t i = 0; i < s._top; ++i) {
        p[i] = s._val[i];
    }
    _val = p;
    _top = s._top;
    _capacity = s._capacity;
}

template <typename SDate>
wind::Stack<SDate>& wind::Stack<SDate>::operator=(const Stack& s) {
    if (this == &s) {
        return *this;
    }

    SDate* p = nullptr;
    if (s._capacity != 0) {
        p = (SDate*)std::malloc(sizeof(SDate) * s._capacity);
        if (p == nullptr) {
            std::cerr << "错误: 赋值时内存申请失败.\n";
            return *this;
        }
        for (std::size_t i = 0; i < s._top; ++i) {
            p[i] = s._val[i];
        }
    }

    // 复制完成后, 再释放原来的空间.
    std::free(_val);
    _val = p;
    _top = s._top;
    _capacity = s._capacity;
    return *this;
}

template <typename SDate>
void wind::Stack<SDate>::Push(const SDate& val) {
    if (_top == _capacity) {
        // 先保存 val, 避免扩容后引用失效.
        SDate saved = val;
        Extend();
        if (_top == _capacity) {
            return;
        }
        _val[_top] = saved;
    } else {
        _val[_top] = val;
    }
    ++_top;
}

template <typename SDate>
void wind::Stack<SDate>::Extend() {
    std::size_t maxCount = (std::size_t)-1 / sizeof(SDate);
    if (_capacity > maxCount / 2) {
        std::cerr << "错误: 扩容后的容量过大.\n";
        return;
    }

    std::size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
    if (newCapacity > maxCount) {
        std::cerr << "错误: 扩容后的容量过大.\n";
        return;
    }
    SDate* p = (SDate*)std::malloc(sizeof(SDate) * newCapacity);
    if (p == nullptr) {
        std::cerr << "错误: 扩容时内存申请失败.\n";
        return;
    }

    // 逐个赋值元素, 不按字节直接搬运.
    for (std::size_t i = 0; i < _top; ++i) {
        p[i] = _val[i];
    }
    std::free(_val);
    _val = p;
    _capacity = newCapacity;
}

template <typename SDate>
std::ostream& wind::operator<<(std::ostream& out, const wind::Stack<SDate>& s) {
    for (std::size_t i = 0; i < s._top; ++i) {
        out << s._val[i] << ' ';
    }
    return out;
}

赋值运算符先判断是否是自己给自己赋值。需要复制时,先申请一片独立空间,把源栈的有效元素逐个赋值过去,完成后再释放当前对象原来的空间,并更新指针、元素数量和容量。两个栈各自管理自己的数组,不会因为复制而共同释放同一片空间。

扩容的过程也类似:申请更大的空间,逐元素赋值,再释放旧空间。Push 在扩容前先保存一份 val,避免它恰好引用旧空间中的元素、扩容后却还继续使用已经失效的引用。只有元素写入完成以后,才增加 _top。

(std::size_t)-1 得到 std::size_t 能表示的最大值,再除以单个元素的大小,就得到了字节数计算所允许的元素数量上限。申请和扩容前先用它检查容量,防止乘法计算溢出。

申请内存失败时,函数输出错误提示并返回:构造或拷贝构造留下空对象,赋值或扩容保留原有数据,Push 在扩容失败后结束本次入栈。这份示例以正常路径为主,失败时的操作没有完成,调用处的成功状态反馈留待后面完善。

类模板的成员函数体通常按需实例化。例如,调用入栈操作时会用到 Push 的定义;使用流输出时,才会进一步要求元素支持相应的 << 操作。

模板定义放在头文件中,为什么不违反 ODR

头文件被多个源文件包含后,其中的内容会分别进入不同的翻译单元。于是我们自然会遇到一个问题:普通的非内联函数定义这样放可能重复,模板定义为什么可以?

理解这个问题,可以先从模板的使用需求出发,再看相应的语言规则和编译链接过程。

模板的用途,是把同一套逻辑交给不同类型、不同调用处复用。在本篇采用的组织方式中,定义放在头文件里,多个源文件都可能使用同一个模板,也可能用相同的模板实参得到同一个具体函数。

如果这些正常的使用每次都被当成互相冲突的定义,模板就很难方便地在多个源文件之间复用。我们希望保留这种便利,同时又要求各处描述的是同一个东西,相同模板实参对应的函数具有统一的身份。这就需要语言规则允许符合条件的重复定义,也需要工具链有办法协调重复生成的实例。

语言规则需要服务于这些实际需求。语言会随着需求和实现经验发展,也会包含兼容性与工程上的取舍。理解这里时,可以沿着"希望怎样使用模板、需要什么规则、怎样实现"的顺序往下看。

从语言规则来看:符合条件的模板允许跨翻译单元定义

ODR 允许同一个模板的定义出现在多个翻译单元中,条件是各处定义满足相应的一致性要求。

cpp 复制代码
// add.hpp
#pragma once

namespace wind {
template <typename T>
T add(const T& left, const T& right) {
    return left + right;
}
}

当 a.cpp、b.cpp 都包含这个头文件时,各自的翻译单元中都会出现模板定义。这些定义共同描述同一个模板,正是上述规则所允许的情况。

这种一致性具体体现在定义的记号序列、其中名字的查找结果等方面。例如,如果不同的宏配置把模板函数体展开成了不同内容,就可能破坏一致性。

把定义集中写在一个头文件中,再由各个源文件包含,可以让这些翻译单元共享同一份实现,便于保持一致。

#pragma once 则处理单个翻译单元内的重复包含:一个源文件通过不同路径多次包含这个头文件时,头文件只展开一次。其他源文件包含它时,会在各自的翻译单元中展开。

前面的普通函数通过 inline 支持这种头文件定义方式,函数模板则适用模板的相应规则。至于调用是否在优化时展开,由编译器根据具体情况决定。

从编译和链接来看:同一个实例可能被分别生成

假设 a.cpp 和 b.cpp 都包含 add.hpp,也都调用了 wind::add<int>。

这两个源文件通常是分别编译的。编译器处理 b.cpp 时,通常不知道 a.cpp 是否已经生成了 add<int>,因此两个翻译单元都可能各自生成对应的代码。

到了链接阶段,工具链会按照模板实体的规则协调处理这些重复实例。常见的实现方式,是把相应代码放进可以合并的节区,或者使用允许协调重复定义的符号机制,最终让各处使用对应于同一个函数实体。

这种方案允许各翻译单元独立生成需要的代码,再在链接时统一处理。前面的一致性要求使这些代码能够作为同一个实例协调起来。

类模板也是类似的。同一个类模板使用相同模板实参,在符合规则的前提下表示同一个类类型;其中按需实例化的成员函数也可能由不同翻译单元分别生成,再由工具链协调。至于用这个类型创建的不同对象,它们仍然是各自独立的对象。

语言规则允许符合条件的定义分布在多个翻译单元中,工具链协调各处生成的实例。两者配合,使同一份模板能够在多个源文件之间使用,相同的模板实参也能对应到同一个实体。

参考资料

以下资料对应正文中涉及的规则,可在需要时查阅。

相关推荐
纪念 2292 小时前
C++ 算法(模拟)
开发语言·c++·算法
揽星河..2 小时前
【C语言】函数
c语言·算法
zwc32104320552 小时前
Day 2:图像内存、数据类型与stride
c++·人工智能·opencv·算法·计算机视觉
重生之小比特2 小时前
【算法拾光】模拟|提莫攻击 + N 字形变换
算法·模拟
道尔柯南2 小时前
C++ 内存管理详解:从 malloc/free 到 new/delete
c++·面试
東隅已逝,桑榆非晚2 小时前
AVL数实现
c++·笔记·学习·算法
思想觉悟3 小时前
SEAndroid实战
android·linux·c++
驭渊的小故事3 小时前
LeetCode 算法题解:滑动窗口与数组合并的实战应用
算法·leetcode·职场和发展
.YM.Z3 小时前
优选算法——【双指针算法2】C++实现
开发语言·c++·算法