C++ 类型推导 auto decltype
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;
| 表达式 | 类型 | 值类别 | 如何理解 |
|---|---|---|---|
value | int | lvalue,左值 | 标识已有整数 |
7 | int | prvalue,纯右值 | 计算一个整数值 |
std::move(value) | int | xvalue,将亡值 | 仍指代 value,但允许参与右值路径的选择 |
named | int | lvalue,左值 | 通过变量名访问已绑定的对象 |
右值包括纯右值和将亡值。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 的结果 |
|---|---|
| lvalue | T& |
| xvalue | T&& |
| prvalue | T |
顺序很重要:不要一看表达式 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,避免把后续标准的变化混入本文的推导。
- 占位类型推导 [dcl.type.auto.deduct]:auto、花括号和 decltype(auto)。
- decltype [dcl.type.decltype]:声明查询与表达式分类。
- 模板调用推导 [temp.deduct.call]:引用、const、数组与函数调整。
- 引用 [dcl.ref]:引用折叠。
- 临时对象 [class.temporary]:生命周期延长及其例外。
下一步:初始化怎样选择构造函数?
本文解决了“省略类型后,类型怎样确定”以及“得到什么对象关系”。接下来阅读《C++ 对象初始化》,处理另一个问题:明确类型 T 后,T x(...)、T x{...}、T x = ... 分别怎样选择初始化路径,以及 explicit、窄化转换、initializer_list 构造函数怎样参与其中。
把这两层分开再连接起来,才能解释一行声明的完整含义,而不是只记住不同写法的表面差异。