从零开始 / 一次只解释眼前的一步

定义模板,
写明类型要求。

从两份具体函数出发,找出可以共用的定义,再说明每个类型必须支持哪些操作。用小程序分清推导、编译期判断和约束,解释成功与拒绝的原因。

先备:基础14(含此前基础课与M1)5 小节 · 预计共 9 小时阅读、推演与编码合计

9小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。

这一章怎样学

先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。

每次写下实参类型、函数体实际使用的操作和调用结果,再解释编译器为何接受或拒绝。先完成第14章共享拥有与回调寿命,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。

15.1 函数模板:把类型留成一个明确的参数

你已经会使用vector、span和智能指针,并能给它们填入模板实参。先完成14章的共享拥有与回调,本章再跨一步:自己写一个能用于几种类型的定义,同时说明每种类型必须提供什么操作。

先想一个很小的需求:把输入加上自己。给int写一个普通函数,函数体可以返回value+value;给double写另一个函数,过程完全相同,变化的是参数与结果的类型。我们希望把这个共同过程写一次,但不希望“接受任何东西,出了错再猜”。

先读模板头,再读普通函数部分

template<typename T>声明一个类型形参T。T不是运行时传入的整数,也不是名为T的已有类,而是这个定义暂时留出的类型位置。这里写成template<class T>也是同一类类型形参声明,并不要求实际类型一定是class,int也可以填入。

后面的T twice(T value)沿用普通函数的结构:返回类型T,函数名twice,一个按值参数value,其类型也是T。函数体中的value+value要求实际类型支持这个表达式,并且结果能用于返回T。我们只用安全的小int/double,不宣称任意类型或数值都满足要求。定义写在main外,先于本文件的调用。

调用twice(3)没有写尖括号时,编译器从对应实参的类型推导这里的T:3是int,0.5是double。按值推导不保留实参对象的顶层const;这不意味着会删除指针所指对象或span元素类型里的const。后面按值接收const span时,外层只读性质不妨碍复制,元素的const仍参与参数形状的匹配。

调用twice<double>(3)则明确给出模板类型实参double,先确定参数需要double,再按普通函数调用规则把整数3转换成参数值3.0。编译器根据具体T检查这份函数定义、形成所需具体版本的过程,称为实例化(instantiation)。先掌握这两种调用读法,再看完整文件。

#include <iostream>

template<typename T>
T twice(T value) {
    return value + value;
}

int main() {
    std::cout << twice(3) << '\n';
    std::cout << twice(0.5) << '\n';
    std::cout << twice<double>(3) << '\n';
    return 0;
}

推导、明确指定、实例化

调用 怎样确定T 参数与运算 输出
twice(3) 从按值实参3推导T为int int参数得到3,3+3得到int6 6
twice(0.5) 从0.5推导T为double double参数得到0.5,相加得到1.0 1
twice<double>(3) 调用者明确指定T为double int3转换为double参数3.0,相加得到6.0 6

第二、三行的默认输出没有小数点,不表示类型变回int。类型由调用推导或明确指定,输出格式只负责怎样显示数值。

这里有int与double两种版本的语义;最后两次调用都使用T为double的版本。不要据此断定可执行文件一定保留两个独立函数或某个固定大小,优化可能内联或合并代码。

T是类型参数,value是普通运行参数。对已经确定的twice<int>,每次调用仍可以给出不同的整数;不需要为每一个整数重新写模板。相反,明确指定twice<double>不是修改外部变量的类型,而是在选择本次函数使用的类型版本。

停下来,看一次变化

同一个定义,三个具体调用入口

模板参数T是类型函数体value + value具体调用尚未选择实参1 / 4 · template-twice的类型与数值;不表示运行时生成新代码模板参数T是类型函数体value + value具体调用尚未选择实参1 / 4
1 · 先给出通用定义

template<typename T>把T引入为类型参数。twice的参数和返回值都采用T,函数体要求value+value能够按此定义形成结果。

1 / 4
查看所有步骤的文字与数值
  1. 1 · 先给出通用定义

    模板参数:T是类型;函数体:value + value;具体调用:尚未选择实参

    template<typename T>把T引入为类型参数。twice的参数和返回值都采用T,函数体要求value+value能够按此定义形成结果。

  2. 2 · int实参推导int

    调用:twice(3);推导:T=int;结果:3+3=6

    twice(3)从实参类型推导T=int。这个具体调用对小整数3相加,输出6。

  3. 3 · double实参推导double

    调用:twice(0.5);推导:T=double;结果 / 显示:1.0 / 1

    twice(0.5)推导T=double,得到double值1.0。终端默认格式打印1,格式没有改变结果类型。

  4. 4 · 显式选择模板实参

    调用:twice<double>(3);参数类型:double;结果 / 显示:6.0 / 6

    twice<double>(3)先指定T=double,普通函数参数接收转换后的3.0,结果6.0按默认格式输出6。

只把第一处twice(3)的3改为0,预测三行:0、1、6。后面的明确double调用仍接收3,因此不跟着改变。这里的单条件练习只改一个调用实参,不能顺手改函数体或第三个调用。

15.2 类模板与相关类型:一种结构,也可以留类型和长度的位置

函数模板生成可调用定义;类模板描述一组相关的类类型。下面的Buffer把元素类型与固定长度都留给使用者:template<typename T, std::size_t N>中,T是类型形参,N是值形参,其类型是已经学过的std::size_t。本章使用编译期可确定的长度字面量2,不把运行过程中才读到的长度当成这个N。

Buffer<int, 2>因此是一个具体类类型:T填int,N填2。成员std::array<T, N> values;相应成为长度2的int数组容器。更换T或N会得到另一个具体类型;它不是在运行过程中把同一个array随意变长。

类型别名也可以依赖模板参数

using value_type = T;沿用08章的别名语法,给元素类型提供一个可查询的名字。对具体的Buffer<int,2>,它的value_type就是int;这个别名不占据一个整数元素,也不创建变量。

接着看另一个函数模板:C是任意候选容器结构的类型形参,C::value_type指向它提供的相关类型。因为这个名字依赖尚待确定的C,本例使用typename C::value_type显式标明它按类型来读。这里的typename与模板头中“声明类型形参T”的用途不同;也不把删掉typename一概当作C++20必然报错的练习,因为某些类型上下文本来允许省略。

typename C::value_type first(const C& buffer)按const引用借用buffer,返回它声明的元素类型。函数体还要求buffer有values成员,而且该成员支持合法的首项访问。本例只读取长度2的真实对象,没有承诺空Buffer也能取下标0;换成空输入时,必须先安排判空或改变接口合同。

把两层花括号分开读

const Buffer<int, 2> data{{2, 5}};的外层初始化Buffer的成员,内层给它的array成员提供2与5。此处没有用户定义构造函数,是此前已学过的聚合初始化;普通成员函数或类型别名不会凭空变成需要填写的额外元素。

#include <array>
#include <cstddef>
#include <iostream>

template<typename T, std::size_t N>
struct Buffer {
    using value_type = T;
    std::array<T, N> values;
};

template<typename C>
typename C::value_type first(const C& buffer) {
    return buffer.values[0];
}

int main() {
    const Buffer<int, 2> data{{2, 5}};
    std::cout << first(data) << ' ' << data.values.size() << '\n';
    return 0;
}

调用first(data)时,C从实参推导为Buffer<int,2>。返回类型经value_type确定为int,返回下标0处的2;随后data.values.size()得到固定长度2。一行输出为2 2。const引用没有复制整个Buffer,返回首项的int则是按值返回,不把借用泄露到调用之后。

这里先由程序员保证“类型有这些成员,首项确实存在”。之后的concepts会把可检查的类型要求写在接口处;首项是否存在这种与实际数据相关的条件,仍不能仅凭模板类型就自动保证。

15.3 编译期条件:哪些判断可以先于程序运行?

把类型留成参数以后,一个分支可能只对某类类型成立。先理解编译期的值、断言和类型查询,再看怎样有条件地实例化函数体的一部分。

constexpr变量与constexpr函数并不作同一种承诺

constexpr int known = square(3);要求这个初始化能作为常量表达式求值,known随后是只读的int。它不只是“希望优化器算快一点”。普通const强调对象不能通过该名字修改;constexpr变量还要求这里的初始化满足常量表达式的规则。

为此,square定义前写constexpr,允许满足条件的调用参与常量表达式求值。它的函数体仍是普通的value乘value,固定输入3得到9。constexpr函数不要求每一次调用都必须在编译期求值。代码中的普通变量input保存4,square(input)也可以作为普通调用使用;编译器可能进一步折叠计算,本章不声称已经测出机器实际在哪个阶段做乘法。

static_assert检查一个编译期判断

static_assert(known == 9);要求编译期条件为真,否则拒绝这个程序。它是语言提供的声明,不是需要运行到那一行才检查的普通函数,也不需要额外的断言头文件。条件为真不会打印一行“通过”;以下程序的输出来自显式的cout。

只把该断言的9改成8,应得到编译拒绝,不应期待运行后报错。这里的square、known与断言都使用安全的小值,不尝试在常量表达式中执行溢出或失效访问。

traits提供关于类型的可查询结果

<type_traits>中的std::is_integral_v<T>给出T是否属于整数类型的布尔结果;int为真,double为假。std::is_same_v<A, B>检查两个类型是否相同;这里的int与int当然相同。_v是这些库名字的一部分,用于取得其布尔值,不是新的语言运算符。当前先使用库提供的查询,不实现类型特征库。

这些查询依赖类型,在编译期就能确定。它们不读取某个变量“这次恰好存的是不是整数”,也不保证数据大小、算术结果或容器范围安全。

if constexpr决定模板实例化时保留哪一支

if constexpr (std::is_integral_v<T>)要求条件是编译期可确定的布尔判断。在当前模板show的实例化中,T确定后,未选择的分支不被实例化。T为int时,保留输出value的分支,不要求int提供else中的size成员;T为string时,选择输出value.size()的分支。

这不是把未选分支当成任意文字藏起来:整个模板仍须有合法语法。它也不是在普通非模板代码里随意屏蔽类型错误的工具。本例的未选操作依赖T,选哪一支由具体实例化决定。

#include <iostream>
#include <string>
#include <type_traits>

constexpr int square(int value) {
    return value * value;
}

template<typename T>
void show(const T& value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << value << '\n';
    } else {
        std::cout << value.size() << '\n';
    }
}

int main() {
    constexpr int known = square(3);
    static_assert(known == 9);
    int input{4};
    std::cout << known << '\n';
    std::cout << square(input) << '\n';
    std::cout << std::is_integral_v<int> << ' '
              << std::is_integral_v<double> << ' '
              << std::is_same_v<int, int> << '\n';
    show(4);
    show(std::string{"cat"});
    return 0;
}

五行依次为9、16、1 0 1、4、3。前两行是known与square(input);第三行把两个整数查询和相同类型查询按默认bool格式打印。show(4)输出这个int的值4;show(string“cat”)输出字符串长度3。show没有承诺接受所有非整数:某个非整数类型若没有size,这个else也不适用。

常见错误是把这里的if constexpr只改成普通if,以为“条件已经能算出true,编译器就不会检查另一支”。普通if选择运行路径,不会以同样的规则丢弃模板中的另一支实例化;show<int>会遇到无效的value.size(),本章验证这个派生程序应拒绝编译。恢复constexpr,保留针对本例两类输入的不同类型操作。

15.4 concepts与requires:先检查能做什么,再检查做得对不对

前面的first与show都从函数体隐含地要求成员或操作。concepts把一部分可检查的要求放到名字和接口上,让不合适的类型在选择候选函数时被拒绝。本节固定使用C++20。

先使用一个现成的整数约束

<concepts>提供std::integral<T>:T属于整数类型时满足这个约束,否则不满足。它与上一节的整数类型查询相对应;这里是类型分类,不是“double变量的值恰好等于整数也算通过”。bool等也属于标准的整数类型,因此不能把这个名字读成只接受int。

template<std::integral T>声明一个带约束的类型形参:仍要确定T,同时要求std::integral<T>成立。下面total借用std::span<const T>,并使用long long累加器。main先从array明确建立已学过的span,再让total从这个span的类型推导T,没有要求编译器从array直接猜出某个span模板参数

对本例的int元素,参与sum加法时先转换为long long,加法按long long进行,然后保存回sum。我们只用列出的安全小整数,不把这个转换说成对所有整数类型和任意输入都适用的无溢出证明。

#include <array>
#include <concepts>
#include <iostream>
#include <span>

template<std::integral T>
long long total(std::span<const T> values) {
    long long sum{0};
    for (T value : values) {
        sum += value;
    }
    return sum;
}

int main() {
    const std::array<int, 5> data{3, 1, 4, 1, 5};
    const std::span<const int> view{data};
    std::cout << total(view) << '\n';
    const std::span<const int> empty{};
    std::cout << total(empty) << '\n';
    return 0;
}

完整例二中,view的元素类型为const int,因此total确定T为int,整数约束成立。sum从long long的0开始,按3、1、4、1、5依次累加,状态为3→4→8→9→14。空span使范围循环一次也不进入,返回初值0。所以两行是14、0。

long long给这些小int留出了余量,不能据此宣称它比每种整数类型都更宽,也不能保证任意长度或值域的求和不会溢出。约束解决的是类型操作入口,数值范围仍要使用08.5的合同单独检查。

本章另验证一个拒绝方案:把非空array的元素类型与对应view都改成double,其他代码保持。total推导出T为double,整数约束不满足,应拒绝调用。这是两处类型编辑构成的方案,不把它写成“只改一个字”的动手练习。

补充 · 同一个求和接口,换两种整数元素(另估15分钟)

在贯穿案例stage-06里还会遇到shortunsigned char。short是有符号整数类型,至少能表示−32767到32767;不能只凭名字假定它在每台机器都恰好占两字节。unsigned char表示无符号整数,至少覆盖0到255,占一个C++字节;一个字节也不能无条件等同于八位。它们都满足这里的integral约束。若把unsigned char直接交给字符流输出接口,会按字符处理;想观察数值,可先用已学的static_cast<int>转换本例0到255的小值。

stage-06的total_of<short>(small_type)先明确指定T为short,于是参数类型确定为std::span<const short>;再按09章的动态长度span构造合同,临时建立借用small_type元素的视图作为参数。明确T之后的参数转换,不同于要求编译器从一个vector直接推导span里的T。

先在纸上预测:short元素[3,1,4,1,5]进入long long累加器,得到14;unsigned char元素[250,10]分别转换为long long再相加,得到260,结果没有写回unsigned char。两项相加不会因此回绕成4。这里只验证这两组小输入,不宣称long long能容纳任意整数序列的总和。

现在再读贯穿案例的stage-06:拥有者转移、借用和副本仍按已有章合同分析;新的两个类型不改变那条寿命链。本补充另估15分钟,不计入原15章学时。类型依据:C++基本整数类型

停下来,看一次变化

先检查类型,再执行满足前提的求和

data[3,1,4,1,5]view类型span<const int>调用total(view)1 / 5 · constrained-total原例与明确分开的double拒绝变体data[3,1,4,1,5]view类型span<const int>调用total(view)1 / 5
1 · 让参数形状明确

data是array<int,5>,先建立view:span<const int>。求和调用接收的是已建立的view,不要求从array做模板参数推导。

1 / 5
查看所有步骤的文字与数值
  1. 1 · 让参数形状明确

    data:[3,1,4,1,5];view类型:span<const int>;调用:total(view)

    data是array<int,5>,先建立view:span<const int>。求和调用接收的是已建立的view,不要求从array做模板参数推导。

  2. 2 · 推导并检查约束

    推导:T=int;整数约束:满足;累加初值:long long 0

    从span<const int>匹配span<const T>得到T=int;std::integral<int>为真,可以选择这个求和模板。

  3. 3 · 逐项累加固定输入

    输入:3 / 1 / 4 / 1 / 5;累加过程:3 / 4 / 8 / 9 / 14;输出:14

    循环按元素顺序运行,累加器依次成为3、4、8、9、14,最终输出14。这里的类型约束不自动检查运行时数值上界。

  4. 4 · 空范围也有明确类型

    empty类型:span<const int>;循环次数:0;输出:0

    empty仍是span<const int>,T=int且约束满足。没有元素意味着循环零次,long long累加器仍为0。

  5. 5 · 独立的double拒绝变体

    派生实参:span<const double>;整数约束:不满足;结果:编译拒绝,不运行

    此帧切换到单独编译的派生版本:数据array与view两处一起改为double。由span<const double>推导T=double,但std::integral<double>为假;拒绝发生在编译阶段,没有该变体的运行输出。

自己命名一个要求

template<typename T> concept HasArea = ...;定义一个随T而定的命名约束。右侧的requires(const T& item) { ... }requires表达式:描述一个const T对象需要支持哪些表达式。

这里的item是检查用的名字,不是运行时真的构造出来并调用一次的对象。requires表达式不会执行area来计算某个面积。对模板实参替换后的类型,所列要求有效时得到true,否则得到false;不能把这种模板中的检查泛化成任意位置写一段无效代码都能变成false。

简单要求可以写成item.area();,检查这个调用表达式是否合法。本例使用更明确的复合要求:{ item.area() } -> std::same_as<int>;,除了调用合法,还检查该表达式的结果类型恰好是int。

这一个箭头不是指针访问

std::same_as<A, B>判断A与B是否为相同类型,const和引用差异也属于类型差异。复合要求箭头右侧写std::same_as<int>时,编译器把左边表达式的结果类型放进第一个类型位置,int放进第二个位置;不是缺写了一个形参。

此处的箭头表示表达式结果的类型约束,不沿指针访问成员,也不把一个运行值转换为int。template<HasArea T>再把这个已经命名的要求用在函数入口上。

#include <concepts>
#include <iostream>

template<typename T>
concept HasArea = requires(const T& item) {
    { item.area() } -> std::same_as<int>;
};

struct Box {
    int width;
    int height;
    int area() const {
        return width + height;
    }
};

template<HasArea T>
int report_area(const T& item) {
    return item.area();
}

static_assert(HasArea<Box>);
static_assert(!HasArea<int>);

int main() {
    std::cout << report_area(Box{3, 4}) << '\n';
    return 0;
}

Box有可在const对象上调用、按值返回int的area,所以HasArea<Box>为真,第一条static_assert通过;int没有这个成员,HasArea<int>为假,取逻辑非后第二条断言也通过。两个判断都没有执行面积算法。

真正运行report_area(Box4)时,源码却把宽与高相加,输出7。矩形面积要求3×4=12,这个程序通过类型约束,却实现了错误的数学关系。只把area函数体中的加号改成乘号,应输出12,约束仍然通过。不要通过修改期望面积为7来回避算法错误。

要求结果可转换,与要求结果类型完全一样

长度接口再使用一个现成约束:std::convertible_to<From, To>要求From到To具有相应的隐式和显式转换,并要求两种转换结果一致。它不是“所有输入数值都无损”的保证。这里目标类型是08章已教的std::size_t,实际输入只使用vector和string的长度接口。

{ value.size() } -> std::convertible_to<std::size_t>;把size表达式的结果类型填入From,size_t填入To。它允许合适的可转换结果,不要求每个合适的容器都写出一模一样的类型别名。该检查同样不运行size,也不证明任意同名成员在业务上真的返回长度。

下面模板头之后还有一行requires HasSize<T>。这是requires子句,规定length候选要求HasSize<T>成立;它和前面用于描述表达式有效性的requires表达式是两种不同用途。前者在接口上使用约束,后者用来定义可检查的要求。

#include <concepts>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>

template<typename T>
concept HasSize = requires(const T& value) {
    { value.size() } -> std::convertible_to<std::size_t>;
};

template<typename T>
    requires HasSize<T>
std::size_t length(const T& value) {
    return value.size();
}

int main() {
    const std::vector<int> values{2, 4, 6};
    const std::string empty{};
    std::cout << length(values) << '\n';
    std::cout << length(empty) << '\n';
    return 0;
}

vector<int>包含2、4、6,length得到3;空string得到0。const引用只读取这些有效容器,输出3、0。若在既有调用之外尝试length(int3),int没有size,约束应拒绝这个调用;不能在函数体里偷偷给无接口类型返回一个猜测的长度。

现在可以把四件事分开回答:表达式能否写出来,结果类型是否合适,运行时对象是否有效,计算是否符合问题。concepts帮助检查前两类;HasArea的7与12说明,它不能替代后两类推理与测试。

15.5 泛型调用、转发与定义可见性

本节把已有lambda与参数绑定接上模板。先用简单的按值泛型调用,再单独看保留引用性质的转发;不要一上来把每个参数都改成双与号。

lambda参数中的auto也可以代表待推导的类型

[](auto value) { return value + value; }是泛型lambda。创建闭包时还没有为某个调用确定value的具体类型;调用时依据实参推导,int3得到int6,double0.5得到double1.0。这个auto位于参数声明中,作用不同于main里用auto保存闭包对象,但两者都由编译器推导类型,不是运行时给变量任意换类型。

下面还有同名的两个describe模板:一个接受一般T,一个要求std::integral。对int3,两者的参数形式都匹配,而受约束版本在这组候选中更具体,因此选integral版本;对double0.5,整数约束不成立,只剩generic版本。这里说明的是这两个同参数形式的候选,不能推广成所有复杂重载总会自动选出你心里的“更好版本”。

#include <concepts>
#include <iostream>

template<typename T>
void describe(T value) {
    std::cout << "generic " << value << '\n';
}

template<std::integral T>
void describe(T value) {
    std::cout << "integral " << value << '\n';
}

int main() {
    auto twice = [](auto value) {
        return value + value;
    };
    std::cout << twice(3) << '\n';
    std::cout << twice(0.5) << '\n';
    describe(3);
    describe(0.5);
    return 0;
}

四行是6、1、integral 3、generic 0.5。前两行来自泛型lambda,后两行来自同名函数模板的选择。约束不是进入generic函数之后再运行一个if;它参与了编译期的候选选择。

确定类型的右值引用,与正在推导的T&&

12.1的值类别已经说明:一个具名变量表达式是左值,即使该变量声明为int&&。现在新增本节函数模板template<typename T> void relay(T&& value)的推导规则:这里T没有const修饰,并从调用实参推导,因此这种参数形式称为转发引用(forwarding reference)

给relay传int左值number时,T推导为int&。把它代入T&&需要合并引用,得到int&。传整数临时值7时,T推导为int,参数类型成为int&&。普通int&&或const T&&不是这里的转发引用形式,不能照搬这张推导表。

引用折叠是在合并类型,不是写两个引用变量

替换后需要合并的引用 合并结果
&与& &
&与&& &
&&与& &
&&与&& &&

表中是类型替换过程的记法,不是要求你在源码里直接写两个相连的引用声明。存在左值引用时结果为左值引用,只有两层都是右值引用时保留右值引用。当前只用第一种实参的int&与模板参数上的&&这一组合。

std::forward按T保留下一次调用需要的引用性质

<utility>中的std::forward<T>(value)把已经推导出的T带到下一次调用:T为int&时传出左值,T为int时传出可绑定右值引用的表达式。它不重新创造原来的临时对象,也不会延长一个失效借用的寿命。

如果直接写sink(value),因为value是具名参数表达式,两次relay调用内部都把它作为左值交给sink。forward的作用正是在需要继续传递时保留这个区别。它与一律把表达式转成右值的std::move用途不同,也不意味着实际对象一定发生了移动构造。

#include <iostream>
#include <utility>

void sink(int& value) {
    std::cout << "L " << value << '\n';
}

void sink(int&& value) {
    std::cout << "R " << value << '\n';
}

template<typename T>
void relay(T&& value) {
    sink(value);
    sink(std::forward<T>(value));
}

int main() {
    int number{4};
    relay(number);
    relay(7);
    return 0;
}
外部调用 T及relay参数 sink(value) sink(forward<T>(value))
relay(number),number为int左值4 T为int&,参数折叠为int& 具名表达式走int&,打印L 4 仍为左值,打印L 4
relay(7) T为int,参数为int&& 具名表达式仍走int&,打印L 7 可绑定int&&,打印R 7

四行顺序为L 4、L 4、L 7、R 7。这里两个sink只读数值并输出,R并不表示发生了资源转移。临时整数在这一整条调用语句期间有效,代码没有把引用保存到将来使用。

模板定义在哪里,编译器才能按新类型检查它?

本章所有模板定义都和使用它的main放在同一个源文件中,并写在调用之前,编译器能取得函数体进行实例化。一般的隐式实例化需要有可用的定义;只在一个文件里看到声明,把定义藏在另一个cpp中,不能指望链接器临时替你按任意新类型生成版本。

常见组织方式是让使用模板的源文件通过头文件看到定义。多文件、头文件和链接的实际命令到17章再操作;本章不把单文件编译通过当成多文件构建已经验证,也不把“常见做法”说成所有模板只能有一种组织方式。

四步练习阶梯

1. 预测:把T与value分别写出来

不运行template-twice,为三次调用写出T、参数初值、相加结果和显示结果。然后只把第一个实参3改成0,重新预测,不改第三次明确double的调用。

对照预测

T依次为int、double、double;原显示6、1、6。0.5的相加结果是double1.0,默认输出为1。只改首个实参以后变成0、1、6。模板实参决定类型,普通调用实参决定这次处理的数据;两件事不能混成同一个变量赋值。

2. 补全:把整数要求放到求和入口

遮住constrained-total的模板头与累加器声明,从已教规则补回。对[3,1,4,1,5]与空span分别求和,再说明double输入为何应在整数约束处被拒绝。只能对本例的小数值承诺无溢出。

对照关键一步

模板头为template<std::integral T>,累加器为long long sum0;。已明确建立span后调用total,int输入经3、4、8、9到14;空输入保留0。double不满足整数类型约束。long long不是无限精度,也不能保证每一种整数类型都比它窄,任意输入仍要有范围合同。

3. 找错:约束通过为什么还是7?

在has-area-bug里指出HasArea实际检查了什么、没有检查什么。宽3高4的矩形面积应为12;用一处运算符修改修复程序,保持接口与约束不变。

对照修复与边界

它检查const对象上的area调用合法且结果类型为int,不执行面积算法,也不证明数学关系。将唯一的return width + height改成return width * height,输出从7变12,两条static_assert仍通过。若把测试期望也改成7,就丢失了问题真正要求的面积。

4. 独立迁移:只接受有合适长度接口的对象

从空文件定义HasSize和length,沿本节已教的复合要求及convertible_to<size_t>结果约束。vector<int>的[2,4,6]返回3,空string返回0;额外尝试length(int3)应编译拒绝。写出为什么这个类型约束仍不能替代运行时的数据与语义测试。

对照接口

HasSize的requires表达式使用const T&,要求value.size()合法,且结果符合convertible_to<std::size_t>。length用requires HasSize<T>限制候选,按const引用借用并返回size。两个有效容器得到3、0,int在约束处被拒绝。转换要求不证明任意自定义size都表示真实长度;对象有效性和数值语义仍需自己的合同与测试。

本章运行与验证范围

八份下载源码都可以运行;其中面积原例故意保留数学逻辑错误,预测输出7不等于接受它为正确面积。两个安全单条件变体为twice首个输入3改0、面积加法改乘法;普通if、非整数求和、无size调用及错误static_assert分别用于观察预期编译拒绝,不尝试运行。

使用04.1的严格C++20编译方式,替换当前文件名。关注诊断中与你的类型或约束相关的第一处原因,暂不追踪所有库内部展开。模板提供复用与检查入口;自己的预测、边界测试、独立实现仍须分别完成,示例通过不会自动成为个人G0通过记录。

先留下自己的答案

两道动手练习

先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。

练习 1

template-twice只把第一个twice(3)改成twice(0),保留twice(0.5)与twice<double>(3)。预测三行输出和各自的T。

查看提示
  1. 这个修改没有触及显式double调用中的3。
  2. 默认输出格式可能不显示小数点,不能据此判断T。
对照答案与推理

输出0、1、6;三个调用的T依次仍是int、double、double。第一行的int计算变成0+0,其他两次调用未改。

练习 2

has-area-bug的Box{3,4}通过HasArea约束却输出7。指出约束实际检查的内容,并只改一个运算符,使输出变成正确面积12。

查看提示
  1. const area()存在并返回int,不等于其数学公式正确。
  2. 保持接口和输入不变,检查width与height如何组合。
对照答案与推理

HasArea检查const对象能调用area()且结果类型满足same_as<int>;原函数返回width+height,因此输出7。只将+改成*后返回3×4,输出12;类型约束在修改前后都满足。

关掉参考,再做一次

把理解说出来

先完成正文的独立迁移,再回答下面六题。它们只检验第15.1–15.5节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。

定义、推导与显式实参

twice(3)、twice(0.5)和twice<double>(3)分别怎样得到T?输出1能证明第二个结果是int吗?

对照推理与英文回答

前两个调用从按值参数对应的实参分别推导int与double;第三个先明确指定double,再按普通调用规则接收可转换的整数3。第二个计算得到double值1.0,只是默认输出格式显示1,不能从这个文本推断类型为int。

The first two calls deduce int and double from their arguments. The explicit call selects double before ordinary argument conversion. The second result is the double value one, even though default stream formatting prints it without a decimal point.

类模板与相关类型

Buffer<int,2>中的int与2分别是什么?C::value_type前的typename用于表达什么?first能无条件读取空Buffer吗?

对照推理与英文回答

int是类型实参,2是长度值实参;Buffer的value_type别名随T确定。typename在这里明确相关名称表示类型,本章保留清晰写法,不断言C++20所有位置删除它都会失败。first按值返回首元素,调用前必须有至少一个元素,模板化不会消除这个边界。

Int is a type argument and two is a value argument for the length. The dependent value_type name denotes a type, made explicit here with typename. This spelling is not a claim that omitting typename fails in every C++20 context. First still requires a nonempty buffer.

constexpr与分支检查

known为什么可被static_assert检查?square(input)一定在运行时执行吗?把show中的if constexpr改成普通if,int调用为什么可能直接编译失败?

对照推理与英文回答

known由允许的常量表达式初始化,static_assert在编译时要求其条件为真。constexpr函数也能接受普通实参,但这个示例不保证编译器实际在哪个阶段完成常量折叠。if constexpr在相应模板实例化时舍弃未选的依赖分支;普通if仍要求int版本中的value.size()合法,而int没有这个成员。舍弃分支也不能随便放语法错误。

Known is initialized by a constant expression and is checked at compile time. A constexpr function may also take ordinary arguments; this example does not promise an evaluation stage. If constexpr discards the unselected dependent branch during instantiation, whereas an ordinary if still requires the int version’s size member expression to be valid.

requires两种位置与类型边界

HasSize中的requires表达式与length前的requires子句分别做什么?same_as<int>与convertible_to<size_t>的目的是否相同?

对照推理与英文回答

requires表达式把本章所列操作及结果要求形成编译期条件;requires子句把HasSize<T>条件加在模板候选上。same_as<int>要求相应结果类型与int相同,convertible_to<size_t>要求结果满足向size_t转换的合同,不能混同为完全相同的类型要求。这里使用标准容器的size接口,不由约束推断任意用户接口都返回正确长度。

A requires expression forms a compile-time condition from the listed operations and result requirements. A requires clause constrains the template candidate. Same_as requires matching types, while convertible_to expresses a conversion contract. Neither automatically proves that an arbitrary user-defined size function reports a correct length.

约束不证明算法正确

constrained-total为何接受明确的int span而拒绝double span?HasArea为何接受输出7的错误Box?long long又是否保证任意求和都不溢出?

对照推理与英文回答

total的integral约束对int为真、对double为假,所以类型边界不同。HasArea只要求const area()调用及int结果,错误的加法公式也满足,仍需用3×4应为12核对数学关系。long long能容纳本例小输入的和,但有限范围并不能保证任意值与任意长序列安全。

The integral constraint accepts int and rejects double. HasArea checks a const-callable area operation with an int result, so an incorrect addition formula still passes. The expected area must be checked separately. Long long holds these small sums but cannot guarantee safety for unbounded inputs.

泛型调用与转发

relay(number)与relay(7)中的T和T&&分别是什么?为何sink(value)总选L,而std::forward<T>(value)对第二次调用选R?

对照推理与英文回答

number是int左值,转发引用推导T=int&,折叠后的参数为int&;字面量7推导T=int,参数为int&&。两次函数体里的具名value表达式都是左值,所以直接sink(value)都选L。forward按推导T保留传入类别,第一次仍为左值,第二次形成相应右值表达式,最终日志为L4、L4、L7、R7。

The lvalue argument deduces T as int-reference and collapses the parameter to int-reference. The literal deduces T as int, leaving an rvalue-reference parameter. The named value expression is an lvalue in both bodies. Forward uses the deduced T to preserve the caller’s category, giving L4, L4, L7, R7.

和正文是同一份源码

示例文件

先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。

下载清单来自本章元数据,正文代码与下载同源。八份原例均可运行,其中面积例子用于找错;能通过编译不代表算法正确。故意编译报错的练习按正文指定修改产生,观察诊断,不把它们当作可运行的正确版本。每次编译和运行只使用当前例子的文件与输入。

可选的学习反馈

记下你真正花的时间

每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。

记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。

尚无真实试学用时。

    按开始学习本章前的情况选择;已经会 C++ 时选择“会 C++”。起点随每条时段保存,之后改选不会重标旧记录。旧记录缺少起点时单独保留;已有基础者的用时不用于校准零基础预算。

      换设备:导入记录,或取回损坏的旧记录

      导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。

      阅读记录与课程验收分别保存。

      本章资料与查证

      • WG21 N4861:C++20工作草案 ↗

        固定2020-04-01版本。15.1查[temp.param]、[temp.arg.explicit]与[temp.deduct.call];15.2查[temp.type]与[temp.res];15.3查[dcl.constexpr]、[dcl.pre]的static_assert、[meta.unary.cat]、[meta.rel]及[stmt.if];15.4查[temp.constr]、[expr.prim.req]、[concept.same]、[concept.convertible]与[concepts.arithmetic];15.5查[expr.prim.lambda]、[temp.func.order]、[temp.deduct.call]、[dcl.ref]与[forward]。

      • Microsoft C++参考:Templates ↗

        15.1/15.2补充函数模板、类模板和模板实参的读法。定义可见性只作为15.5的接口阅读边界,多文件构建安排在17章;不把参考页的高级模板写法增加为本章先备。

      本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。