QuanZhou's Wiki

C++ 类型推导 auto decltype

~/ C/C++#C++特性#类型推导#现代C++

1. 我们要弄清楚什么?

从一行熟悉的代码开始

省略类型之后,哪些决定仍然由你做?

std::string name = "cache";
auto copy = name;
auto& alias = name;
const auto& view = name;

这些写法并不陌生。真正值得深入的是:为什么 copy 是独立字符串,而 alias、view 与原字符串关联?为什么 view 不能修改字符串,却能观察到别人对 name 的修改?

auto 没有替你决定接口应该复制还是借用。声明中的 &、const 和初始化表达式共同参与决定,编译器按规则补齐类型。

本文的位置与阅读方式

这是现代 C++ 长期学习计划第一年“类型系统与初始化”中的一个专题。默认你已经会写业务代码、使用容器和函数;这里补齐的是日常写法背后的语义模型。

阅读路线是:普通变量 → const 与指针 → 引用与数组 → 值类别 → decltype → 函数接口。每一步先解释常用情形,再补一个真正会影响用法的边界。

全文以 C++20 为语义基线。示例中的头文件按所用组件包含:std::string 对应 <string>,std::vector 对应 <vector>,std::move 对应 <utility>。代码是分组片段,不需要把所有代码块拼成同一个文件。

先把四件事分开

声明类型、表达式类型、值类别、对象

int count = 10;
int& ref = count;

这里 count 声明为 int,ref 声明为 int&,但没有因此创建第二个整数。引用绑定到了已有的 count。

当你在表达式中使用 ref,访问的是被引用的整数。表达式 ref 的类型是 int,值类别是左值;它的声明类型仍然是 int&。

问题在这个例子中的答案
ref 被声明成什么?int&
表达式 ref 的类型是什么?int
这个表达式是哪种值类别?左值
它指代哪个对象?count

为什么这个区分值得保留?

类型描述可以做什么操作,值类别参与重载和引用绑定,对象关系决定操作影响谁。后面 decltype 有时查询声明类型,有时根据表达式类型和值类别生成结果;不把它们分开,就只能记零散的例外。

暂时把左值理解成这类“标识已有对象的表达式”即可。它并不意味着一定能修改:const int 对象也能通过左值表达式访问。引用表达式的类型调整规则见 C++20 草案 [expr.type]。

2. auto:推导的是什么?

把声明看成一个待填空的模式

auto 只占据类型的一部分

const int source = 7;

auto a = source;          // int
const auto b = source;    // const int
auto& c = source;         // const int&

分析时先不要急着问“auto 是什么类型”,而是把左侧模式写清楚:auto、const auto、auto& 是三个不同的模式。

b 中占位的 auto 推导为 int,声明自己再加上 const;c 中的 auto 推导为 const int,声明自己再加上 &。最后的完整变量类型不是单看占位符就能确定的。

与模板推导的联系

对普通的表达式初始化,可以把这些模式类比为:

template<class T> void by_value(T);
template<class T> void by_ref(T&);
template<class T> void by_const_ref(const T&);

它们不是要你实际调用的函数,而是理解推导方式的辅助模型:先按参数模式推导 T,再得到完整类型。等遇到花括号,我们会说明这个类比的边界。

后面所有推导都按三个步骤读:看左侧模式 → 看右侧表达式 → 应用对应规则。占位类型推导的规范入口是 C++20 草案 [dcl.type.auto.deduct]。

按值推导:为什么 const 和引用没有留下?

新对象不继承原对象的只读访问限制

int value = 7;
const int fixed = 8;
int& ref = value;
const int& readonly = value;

auto a = value;       // int
auto b = fixed;       // int
auto c = ref;         // int
auto d = readonly;    // int

这几种普通的按值推导都得到 int。初始化表达式通过引用访问一个对象,并不意味着新声明也要成为引用;原整数是只读的,也不妨碍拿它的值初始化另一个可修改整数。

以 d 为例:左侧没有引用模式;右侧访问一个 const int 左值;按值推导不保留顶层 const,最终声明为 int d。修改 d 不会修改 value。

“去掉 const”没有修改原对象

这里变化的是被推导的类型,不是对 readonly 或 value 执行去常量操作。原来的访问限制没有被绕过。

同时,不要把规则扩大成“auto 总会复制全部数据”。对 std::string 的普通左值初始化,通常涉及字符串复制;对指针、视图或代理类型,新对象可能仍然关联外部数据。后面会专门解释这个边界。

顶层 const:到底是哪一层只读?

分清指针本身与它指向的整数

int value = 7;
int* const fixed_pointer = &value;
const int* readonly_pointer = &value;

auto p = fixed_pointer;       // int*
auto q = readonly_pointer;    // const int*

fixed_pointer 是一个不能重新指向别处的指针,但它指向的整数可以修改。readonly_pointer 自身可以改指向,却不能通过它修改整数。

对指针类型,顶层 const 修饰指针对象自身;被指向对象的 const 位于更深一层,常称为底层 const。按值推导 p 忽略前者,却保留 q 指向对象的只读限制。

左侧也可以明确补上限制

int value = 7;

const auto* p = &value;   // const int*:不能通过 p 修改 value
auto* const q = &value;   // int* const:不能修改 q 的指向
const auto r = &value;    // int* const:const 修饰推导出的指针类型

最后一行尤其值得慢读:auto 推导为 int*,外面的 const 修饰这个完整指针类型,所以结果是 int* const,不是 const int*。

更深一层的 const 也不会凭空消失

int value = 7;
int* pointer = &value;
int* const* source = &pointer;
auto result = source;    // int* const*

此处 const 限制的是经 source 访问到的那个内部指针,不是最外层指针对象。只背“按值去 const”会把它错误地推导成 int**;正确做法是先找清层次。

auto 是推导,不是目标类型转换

编译器不猜测你想要哪种数值类型

auto count = 1;         // int
auto ratio = 1.0;       // double
auto mask = 1u;         // unsigned int
double converted = 1;  // 先明确目标类型,再允许相应转换

double converted = 1 已经给出了目标类型;auto count = 1 则从初始化表达式推导类型。两者不是省略程度不同的同一个决定。

算术表达式本身也会先按照语言规则形成类型。auto 接收这个结果,不负责重新设计计算过程。

省略类型不等于推迟确定类型

auto value = 1;
// value = "one";   // 编译错误:value 的类型已经是 int
// auto unknown;    // 编译错误:这个变量声明缺少推导依据

auto 不会让变量成为动态类型。初始化完成后,其类型与显式声明一样固定。

3. 引用与数组:保留哪些关系?

auto&:直接绑定,而不是复制数值

右侧的只读性质需要保留

int value = 7;
const int fixed = 8;

auto& a = value;       // int&
auto& b = fixed;       // const int&
const auto& c = value; // const int&

b 必须能绑定到常量整数,因此 auto 推导为 const int。如果得到 int&,就会允许通过这个引用修改一个真正的常量对象,语言不会允许这种丢失限制的绑定。

c 的只读性质来自声明中的 const。value 本身仍然是可修改对象,其他访问路径对它的修改也能被 c 观察到。

引用绑定仍然要满足自己的规则

// auto& bad = 7;       // 编译错误:这里推导出的 int& 不能绑定到该临时值
const auto& good = 7;   // const int&,绑定到实质化的临时整数

类型推导不是绕过初始化规则的通行证。推导出候选类型之后,引用绑定仍然需要合法。

good 对应的临时对象在这个局部直接绑定情形中会延长生命周期。这个保证有适用条件,不能推广为“所有 const 引用都能保证对象活着”。

数组与函数:为什么按值推导会退化?

两种声明看到的是不同层面的信息

int values[3] = {1, 2, 3};
auto pointer = values;   // int*
auto& array = values;    // int (&)[3]

在这里,非引用模式的推导使用数组到指针的调整:得到指向首元素的指针。引用模式不做这个退化,保留数组元素类型与长度。

int (&)[3] 读作“对一个含有 3 个 int 的数组的引用”。括号把 & 与数组类型组合在一起;它不是含有三个引用的数组。

字符串字面量也属于数组

auto text = "cache";     // const char*
auto& literal = "cache"; // const char (&)[6]

字面量包括末尾的空字符,因此长度是 6。auto 不会因为内容像字符串就推导成 std::string;需要拥有字符串数据时,明确构造 std::string。

函数也有相似的调整

int twice(int x) { return x * 2; }

auto function_pointer = twice; // int (*)(int)
auto& function_reference = twice; // int (&)(int)

函数类型本身与函数指针类型不同。按值推导这里得到指针,引用模式保留函数类型。

这些推导调整见 C++20 草案 [temp.deduct.call]。数组长度消失在推导结果中,不意味着原数组真的被改造成了指针对象。

4. auto&&:先理解值类别,再看引用折叠

值类别描述表达式,不描述变量的名字

只建立当前需要的三个区别

int value = 7;
int&& named = 8;
表达式类型值类别如何理解
valueintlvalue,左值标识已有整数
7intprvalue,纯右值计算一个整数值
std::move(value)intxvalue,将亡值仍指代 value,但允许参与右值路径的选择
namedintlvalue,左值通过变量名访问已绑定的对象

右值包括纯右值和将亡值。named 的声明类型是 int&&,但表达式 named 是左值;这正是声明类型与表达式性质不能混为一谈的原因。

std::move 本身没有搬运数据

std::move(value) 的作用是产生合适的右值引用转换结果。有没有复制、移动,取决于接下来选择了什么操作。

对于 std::string,后续初始化可能选中移动构造函数;对于整数,没有需要转移的堆内存。先别把“调用 std::move”和“资源已经被转移”画等号。

auto&& 怎样同时接受左值和右值?

推导结果取决于初始化表达式

int value = 7;
const int fixed = 8;

auto&& a = value;             // int&
auto&& b = fixed;             // const int&
auto&& c = 9;                 // int&&
auto&& d = std::move(value);  // int&&

在这些表达式初始化中,auto&& 具有转发引用的推导行为:遇到左值,推导出的占位类型可以带左值引用;再与声明中的 && 折叠,形成最终类型。

以 a 为例:初始化表达式是 int 左值,auto 推导为 int&;把它代入 auto&&,经过折叠得到 int&。以 c 为例:auto 推导为 int,完整声明为 int&&。

引用折叠是一条组合规则

通过类型别名或推导组合引用时,规则如下:

内层引用外层引用结果
&&&
&&&&
&&&&
&&&&&&

这不是允许直接写 int& && 作为普通声明。它描述的是类型推导、别名等机制组合引用时的处理。参考 C++20 草案 [dcl.ref]。

auto&& 不是“自动移动”

d 只是绑定到了 value,没有创建第二个整数,也没有自动执行移动构造。随后使用名字 d,得到的仍是左值表达式。

const auto&& 也不是同一种转发引用模式;它不能像这里的 auto&& 那样接收普通左值。完美转发需要在这个基础上继续学习 std::forward,本文先不展开。

5. 花括号:auto 的一条特殊分支

auto x{1} 和 auto x = {1} 为什么不同?

先分清初始化形式

auto a = 1;        // int
auto b{1};         // int:直接列表初始化
auto c = {1};      // std::initializer_list<int>:复制列表初始化
auto d = {1, 2};   // std::initializer_list<int>

这里需要 <initializer_list>。对 auto 变量,直接列表初始化要求恰好一个元素,并从那个元素推导;复制列表初始化则按 std::initializer_list<U> 的模式推导元素类型。

{1} 不是一个普通的、已经自带某个类型的表达式。std::initializer_list<int> 来自这条特定推导规则,而不是花括号本身天然具有的类型。

它不会自动寻找数值公共类型

// auto a{1, 2};        // 编译错误:直接列表初始化不是单个元素
// auto b = {1, 2.0};   // 编译错误:元素类型推导冲突

第二行不会因为 int 能转为 double 就自动得到 initializer_list<double>。要指定这种转换意图,应明确目标类型,而不是要求 auto 猜测。

这也解释了为什么一般的 template<class T> void f(T) 不能直接从 f({1, 2}) 推导出 T:auto 变量的复制列表初始化有专门规则,不能把它机械地套给所有模板。

它不是可随意修改元素的容器

initializer_list 通过只读元素接口访问底层数组,不拥有类似 vector 的独立可变存储语义。需要动态数组时,写 std::vector<int>{1, 2} 能更明确地表达需求。

本文采用的这些规则适用于 C++20,不以早期历史版本或旧编译器的差异作为主线。

6. decltype:究竟在查询什么?

先记住分支顺序,而不是记答案

第一条:特定形式直接查询声明

int value = 7;
const int fixed = 8;
int& ref = value;

using A = decltype(value); // int
using B = decltype(fixed); // const int
using C = decltype(ref);   // int&

对于这里不加括号的名字表达式,decltype 得到被命名实体的类型。它不会采用 auto 的按值推导规则,因此 const 和引用都可以保留下来。

using A = ... 只是给类型起别名,不会创建对象,也不会复制 value。

第二条:其他情形根据表达式性质判断

排除第一条以及结构化绑定等特定规则后,设表达式类型为 T:

表达式的值类别decltype 的结果
lvalueT&
xvalueT&&
prvalueT

顺序很重要:不要一看表达式 value 是左值,就直接得出 decltype(value) 是 int&。它先命中了查询声明的特殊分支。

规范入口是 C++20 草案 [dcl.type.decltype]。结构化绑定有专门规则,不能简单当成普通名字处理;它留到容器与结构化绑定专题再讲。

一个括号,为什么多了一个引用?

括号改变的是规则分支

int value = 7;
const int fixed = 8;

using A = decltype(value);    // int
using B = decltype((value));  // int&
using C = decltype((fixed));  // const int&

decltype(value) 命中不加括号的名字规则,得到声明类型 int。

decltype((value)) 的操作数是带括号的表达式,不再属于上述特殊形式,进入一般规则;(value) 仍然是 int 左值,所以结果为 int&。

括号没有创建对象,也没有把一个原本的右值变成左值。它让 decltype 换了一条分析路径。

引用声明与一般规则也可能得到相同结果

int value = 7;
int& ref = value;

using A = decltype(ref);    // int&:来自 ref 的声明类型
using B = decltype((ref));  // int&:来自 int 左值表达式

结果相同,不代表推导过程相同。区分依据,才能把规则迁移到成员访问和函数返回。

成员访问:const 对象并不会改写成员声明

不加括号时,查询成员的声明类型

struct Entry {
    int hits = 0;
};

const Entry entry{};
using A = decltype(entry.hits);   // int
using B = decltype((entry.hits)); // const int&

hits 在类中被声明为 int。decltype(entry.hits) 是不加括号的成员访问形式,得到成员声明的类型 int。

通过常量对象 entry 访问普通非 mutable 成员时,访问表达式则具有 const int 类型,并且这里是左值。因此一般规则给出 const int&。

这不代表可以修改常量成员

decltype(entry.hits) 是 int,只说明类型查询的结果;实际写 entry.hits = 1 仍然不合法。

将“类型查询的规则”和“这次访问允许执行什么操作”混为一谈,很容易误读自动推导的 getter。我们稍后会把这段代码放进函数。

函数调用与运算符:跟着表达式往下分析

返回类型决定调用表达式的类别

int read_value();
int& read_reference();
int&& read_rvalue_reference();

using A = decltype(read_value());            // int
using B = decltype(read_reference());        // int&
using C = decltype(read_rvalue_reference()); // int&&

对这里的整数返回类型,按值返回形成纯右值,返回左值引用形成左值,返回右值引用形成将亡值。函数调用表达式不属于前面的名字查询分支,直接应用一般规则。

这些函数只有声明也足够用于这里的类型查询,因为没有实际调用它们。

常见运算符也有自己的类别

int value = 7;
int* pointer = &value;

using A = decltype(*pointer);   // int&:内建解引用产生左值
using B = decltype(value = 9);  // int&:内建赋值表达式产生左值
using C = decltype(value + 1);  // int:整数加法产生纯右值
using D = decltype(++value);    // int&:内建前置递增产生左值
using E = decltype(value++);    // int:内建后置递增产生纯右值

这里特意限定了整数及内建运算符。类可以重载运算符,其返回类型可能不同,不能只看到 + 或 ++ 的符号就断言所有类型的结果。

不求值:不执行,但仍然检查合法性

decltype 不会顺便修改变量

int count = 0;
using Result = decltype(++count); // int&;这句话不会递增 count

decltype 的操作数处在不求值语境中。编译器分析它的类型与相关语义,但不执行这里的递增。

不求值不等于不检查

// using Bad = decltype(unknown_name + 1); // 名字不存在,仍然是编译错误

名字查找、重载选择和表达式合法性等工作仍可能发生。函数模板的返回类型推导还可能触发实例化。因此,“这里不运行”不能作为“这里随便写都可以”的理由。

后面更深入的模板内容会利用不求值语境检查表达式是否成立;现在只需要理解它的用途与边界。

7. decltype(auto):让初始化表达式决定精确类型

auto 的规则与 decltype 的规则,不是同一套

对照三个声明

int value = 7;
int& ref = value;

auto a = ref;             // int
decltype(auto) b = ref;   // int&
decltype(auto) c = value; // int
decltype(auto) d = (value); // int&

a 使用普通 auto 的按值推导,得到 int。

b 相当于用 decltype(ref) 决定类型,保留声明中的 int&;c 查询 value 的声明类型,得到 int;d 根据括号表达式进入一般分支,得到 int&。

decltype(auto) 不是“尽量聪明地选一个类型”,而是“用 decltype 的规则处理初始化表达式”。

它必须作为完整的占位类型使用

// const decltype(auto) x = 1; // 编译错误
// decltype(auto)& y = value; // 编译错误
// decltype(auto) z = {1, 2}; // 编译错误:不能对这种花括号列表照常取 decltype

这里前两种写法不能像 const auto、auto& 那样组合。需要明确的引用或只读约束时,直接表达它;不要把 decltype(auto) 当作所有声明模式的升级版。

函数的 auto 返回类型:从 return 表达式推导

返回引用的函数,被一层 auto 包装后会发生什么?

int& access(int& value) {
    return value;
}

auto copy_access(int& value) {
    return access(value); // 返回 int
}

decltype(auto) preserve_access(int& value) {
    return access(value); // 返回 int&
}

access(value) 是一个 int 左值。普通 auto 按值推导不保留引用,因此 copy_access 返回整数值;decltype(auto) 根据调用表达式的类别得到 int&,保留借用关系。

这里没有“函数名带 access 就应该返回引用”的隐含规则。一层包装函数就可能改变接口语义。

接收端还会再做一次自己的推导

int value = 7;
int& source = value;

auto copy = source;             // int
const auto& borrowed = source;  // const int&

同样,调用一个返回 int& 的函数后,用普通 auto 接收仍会得到 int。返回端保留引用,不代表调用端一定保留引用。 这两步应分别分析。

getter 中的括号:很小的修改,也可能改变接口

同一个成员,两种返回关系

struct Entry {
    std::string payload;
};

decltype(auto) payload_copy(const Entry& entry) {
    return entry.payload;   // std::string
}

decltype(auto) payload_borrow(const Entry& entry) {
    return (entry.payload); // const std::string&
}

第一种命中不加括号的成员访问规则,返回类型是成员声明的 std::string;返回语句用该成员初始化一个返回值对象。这里参数是常量引用,成员作为常量左值被使用,形成字符串复制。

第二种进入一般规则,(entry.payload) 是 const std::string 左值,所以返回 const std::string&。函数不再交出独立字符串,而是暴露 entry 内部的一部分。

精确不等于安全,也不等于可读

借用版本要求原 Entry 及其成员在使用引用时仍然存活。复制版本表达另一种成本与独立性。

对于固定业务接口,直接写 std::string 或 const std::string& 往往更清楚;对于需要保留被包装表达式返回性质的泛型代码,decltype(auto) 才体现出价值。选择依据是接口意图,不是写法长短。

多个 return:编译器不会替你找公共返回类型

可转换不等于推导一致

// 故意不能通过编译的示例
// auto choose(bool flag) {
//     if (flag) return 1;  // int
//     return 2.0;         // double:与前一个 return 推导结果不同
// }

不能因为 int 可以转成 double 就推导一个统一返回类型。普通控制流中的各个未被丢弃的 return 必须推导出相同类型。

如果接口本来就要返回 double,可以明确写 double choose(bool flag),让各返回语句按目标类型初始化结果。

decltype(auto) 还会区分值与引用

// 故意不能通过编译的示例
// decltype(auto) choose(int& value, bool flag) {
//     if (flag) return value; // int&:参数的声明类型
//     return value + 1;      // int:纯右值,推导结果不一致
// }

if constexpr 在模板实例化时可以丢弃一个分支,是另外的规则;普通 if 不会因为某次运行只走一条路径就免去这项检查。

8. 类型推导结束后,还要检查生命周期

编译通过的引用,也可能没有可用的对象

返回局部变量的值与返回其引用

int safe_value() {
    int local = 7;
    return local;
}

// 只分析,不使用这个函数的返回引用
// decltype(auto) bad_reference() {
//     int local = 7;
//     return (local); // C++20:int&,函数返回后 local 已结束生命周期
// }

第一种返回独立整数值,函数里的 local 结束生命周期不影响返回结果。第二种把已经即将消失的对象暴露成引用,推导出 int& 并不会为对象延长生命周期。

这段带括号的局部变量返回分析限定于 C++20;后续标准对隐式移动相关表达式有调整,不应把这个细节当成跨版本口诀。无论采用哪种引用返回形式,返回引用都不能让局部对象自动存活。

不需要靠一次运行判断是否悬空

错误代码偶然读到原数值,不是它合法的证据。正确分析顺序是:引用指向谁,那个对象什么时候结束生命周期,使用引用发生在之前还是之后。

编译器警告和 Sanitizer 有助于发现问题,但对象关系应先从代码与规则中解释清楚。

const auto& 的生命周期延长,不能沿调用链接力

局部直接绑定的正常情形

const auto& text = std::string("cache");

这里引用直接绑定到临时字符串,临时对象的生命周期延长到这个局部引用的生命周期结束。text 不拥有某个独立复制出来的字符串;它引用的那个临时对象被规则保留了下来。

穿过一个返回引用的函数,情况就变了

const std::string& echo(const std::string& value) {
    return value;
}

// 不要使用以下引用:
// const auto& dangling = echo(std::string("cache"));

临时字符串绑定到函数的引用参数,按这个情形的规则只存活到包含调用的完整表达式结束,也就是上述声明末尾的分号处。函数返回原引用,调用者再用另一个引用接收,并不会重新延长这个临时对象的生命周期。

调用完成前取得副本则不同

const std::string& echo(const std::string& value) {
    return value;
}

void example() {
    auto owned = echo(std::string("cache")); // std::string
}

复制发生时,临时字符串尚未结束生命周期;owned 是独立字符串,之后不再依赖那个临时对象。

这说明:const auto& 可以避免某些复制,却不是普遍优于 auto 的默认写法。相关边界见 C++20 草案 [class.temporary]。

9. 再补一个边界:按值不等于拥有底层数据

指针、视图、代理:独立变量也可能仍然借用

string_view 复制的是视图

std::string storage = "cache";
std::string_view original = storage;
auto another = original; // std::string_view

这里还需要 <string_view>。another 是一个独立的视图对象,但不是字符串内容的独立副本。它仍然指向 storage 的字符;底层字符串销毁或执行导致相关存储失效的操作后,视图可能悬空。

因此,先前的“按值产生新对象”与“新对象是否拥有它所访问的数据”是两个问题。

vector<bool> 返回的是代理

std::vector<bool> flags{false};
auto bit = flags[0]; // vector<bool>::reference,代理对象,不是 bool
bit = true;          // 修改 flags 中的那一位

bool snapshot = flags[0]; // 转换为一个独立的 bool 值

auto 忠实地推导初始化表达式的类型。vector<bool> 的非 const 元素访问提供代理类型,这个新代理对象仍然关联原容器中的位。

不要从这一例推出“auto 不可信”。应该修正的是“写了 auto 就得到独立业务数据”的假设。容器接口返回什么类型、那个类型表达什么所有权,需要一起看。C++20 草案 [vector.bool]给出了这种专门接口。

10. 把规则放回一个缓存接口

返回副本,还是借用条目?

先明确接口意图,再选择类型

struct Entry {
    std::string payload;
};

std::string snapshot(const Entry& entry) {
    return entry.payload;
}

const std::string& borrow(const Entry& entry) {
    return entry.payload;
}

void example() {
    Entry entry{"hello"};
    auto independent = snapshot(entry); // std::string,独立内容
    const auto& alias = borrow(entry);  // const std::string&,借用成员
    auto copied_again = borrow(entry); // std::string,接收端创建副本
}

例子中的两个接口使用显式返回类型,让成本和对象关系直接出现在签名里。第三个变量再次提醒我们:返回端与接收端各有一次决定。

借用的约束不止写一个 const

alias 要求原条目继续存活。若条目被容器搬迁、删除或缓存淘汰,引用可能失效;具体条件取决于承载条目的容器和操作契约。

const 只限制通过当前引用修改字符串,不阻止其他线程修改同一对象,也不提供同步。实际异步缓存接口必须另外设计存活保证和并发访问规则。

本文的例子只讨论普通单线程对象关系,不把一个只读引用当作完整的缓存并发方案。

泛型包装:什么时候需要保留返回表达式的性质?

让包装层不意外丢失引用

template<class Container>
decltype(auto) first(Container& container) {
    return container.front();
}

对非空 std::vector<int>,这里返回 int&;对常量的 std::vector<int>,模板可以把 Container 推导成常量容器类型,此时返回 const int&。

如果换成返回代理或返回值的其他容器接口,decltype(auto) 也会按表达式规则保留它所返回的性质。它不会把所有结果都强制改成引用。

仍然需要清楚的前置条件

这个简化接口要求容器非空、具有合适的 front(),并要求任何返回的借用在使用期间不失效。参数为 Container&,也避免直接接收一个临时容器,但不能防止调用者之后销毁或修改原容器。

选择 decltype(auto) 的理由是“包装层应保留底层接口的返回语义”,不是“自动推导总比明确签名好”。

11. 一套可重复使用的分析顺序

读到复杂声明时,先别猜最终答案

从模式走到对象关系

以 const auto& result = source 为例,先看左侧是带 const 的引用模式,再分析右侧类型和类别,推导占位类型,得到完整引用类型,最后检查引用绑定到哪个对象。

对于 decltype(auto),则先问表达式是否属于声明查询的特殊形式,再考虑一般的值类别规则。得到返回引用之后,额外追踪原对象的生命周期。

要回答的问题关注点
声明用了哪一种模式?auto、auto&、auto&& 或 decltype(auto)
右侧是什么?类型、const 层次、值类别、是否为花括号列表
用哪条规则?按值调整、引用推导、特殊 decltype 或一般 decltype
最终声明类型是什么?把左侧的 const、指针与引用部分合回去
程序操作的是什么对象?新对象、别名、代理,还是非拥有视图
在使用期间是否有效?原对象的存活、失效规则和并发条件

几个值得留下的判断

  • auto 推导类型,不替你表达所有权策略。
  • 顶层 const 与被指向对象的 const 不能混为一谈。
  • 引用类型和值类别属于不同层面的信息。
  • decltype 必须先判断规则分支,括号可能改变分支。
  • decltype(auto) 保留表达式性质,也可能保留你不想暴露的借用。
  • 类型推导正确,不代表生命周期设计正确。

这些规则最终服务于用法:该独立持有数据时取得所有权,该借用时表达约束,该保持泛型接口语义时准确传播类型。

延伸阅读

遇到精确问题时,从对应规则进入

语言规则

以下链接固定到 C++20 草案 N4861,避免把后续标准的变化混入本文的推导。

下一步:初始化怎样选择构造函数?

本文解决了“省略类型后,类型怎样确定”以及“得到什么对象关系”。接下来阅读《C++ 对象初始化》,处理另一个问题:明确类型 T 后,T x(...)、T x{...}、T x = ... 分别怎样选择初始化路径,以及 explicit、窄化转换、initializer_list 构造函数怎样参与其中。

把这两层分开再连接起来,才能解释一行声明的完整含义,而不是只记住不同写法的表面差异。