那些年:没有它的时候
很久以前。
你写 C++。
还没有 auto。
返回类型就得老老实实写全。
写 wrapper 的时候。
最先累的是手。
然后是眼睛。
再然后。
你开始想办法“少写点”。
typedef。
宏。
还有一句自我安慰。
“我知道它大概是啥类型。”
直到某天。
线上一个小项目。
啪一下。
你发现你明明在改对象。
最后改到的却是拷贝。
C++11 的 auto:省字,也顺手埋雷
后来 C++11 带来了 auto。
你终于不用把一长串类型写在脸上了。
但 auto 推导有个默认动作:它会把引用去掉,也会把顶层 const 去掉。
平时这很友好,一旦放进“返回值”里,就容易阴沟翻船。
先把 auto 的脾气说清楚
你可以把 auto 当成一句话:
“这个类型你别问我,让编译器去猜。”
它猜类型的时候,走的路线很像“模板参数推导”。
template <class T>
void f(T x);
int n = 0;
int& r = n;
f(r);
这里 r 虽然是 int&。
但 T 推出来是 int。
因为 T x 这个形状,本来就会把引用“吸收”掉。
auto x = r; 也是类似的逻辑。
顶层 const 也一样。
你以为自己在“保留只读”。
其实 auto 会先把它抹掉。
const int c = 1;
auto x = c; // int
auto& y = c; // const int&
x 是一份新值。
y 才是原来的那份。
如果你就是想保留引用。 你得写得更像“我很确定”。
auto& x = r;
这一句就不会拷贝。
x 和 r 指向同一个 n。
线上啪一下:缓存命中率永远是 0
我当年做过一个很小的服务,说白了就是一层缓存。
命中就把 hits 加一。
很朴素。
也很容易写错。
代码大概这样。
#include <map>
#include <string>
struct Entry {
std::string data;
int hits = 0;
};
std::map<std::string, Entry> g;
auto get_entry(const std::string& key) {
return g[key];
}
看起来没毛病。
但 g[key] 实际返回的是 Entry&,auto 一推导,引用就没了。
这里顺手解释一下“引用”。 你可以把它当成“给同一个对象起了个别名”,不是拷贝出一个新对象。
于是你在业务里写。
void on_request() {
auto e = get_entry("home");
e.hits++;
}
这句能编译,也能跑。 而且跑得很安静。
但你加的是 e 的 hits。
e 是一份拷贝。
函数一结束。 它就被销毁。
容器里的那份,完全没动。
如果 Entry 里还有一些大字段,你每次请求还会多拷贝一份。
性能也会跟着挨一刀。
你看到的现象就很真实:命中率像是被诅咒了一样永远上不去。 CPU 也会莫名其妙变高。
为什么 g[key] 看起来像“原件”
std::map 的 operator[] 返回的是引用。
换句话说。
你写 g[key],拿到的不是“值的拷贝”。
而是容器里那份东西本体。
Entry& e = g["home"];
e.data += "!";
上面这种写法。 改的就是容器里的那份。
但 operator[] 还有一个小脾气。
如果 key 不存在,它会顺手插入一个默认值。
这也是为什么线上排查时,你会看到 map 里“莫名其妙多了点东西”。 不是幽灵。 是你自己写出来的。
左值右值:先别被名字吓到
新手第一次听“左值右值”很容易皱眉。 我一般先用一句更土的话解释。
左值更像“能放抽屉里”的东西。 它有地方放,有名字可叫,你可以反复拿来改。
右值更像“一次性外卖盒”。 用完就走,别指望你能长期抓着它。
如果你想要一个更“能跑通脑子”的感觉。 可以记这两句。
int x = 0;
x = 1; // 能赋值
0 = 1; // 不能赋值
能放在等号左边的,通常更像左值。
像 g[key] 这种,是左值。
所以它天然就适合返回引用。
当年大家怎么爬出来:先写引用,然后发现写不下去
第一次踩坑时,最直接的修法其实很朴素。 把返回类型写成引用。
Entry& get_entry_ref(const std::string& key) {
return g[key];
}
这样就没问题了。 你改的是原件,不是复印件。
但很快你会遇到下一关。 你开始写更通用的 wrapper(就是“包一层”的小函数,外面都叫它,里面再转给真正实现)。
参数可能是 const,也可能不是。
返回值有时候要返回引用,有时候又必须返回值。
这时候“手写返回类型”,又回到了那条老路:又长,又容易写错。
更早的野路子:typeof 和 Boost.Typeof
在 decltype 还没进标准之前。
社区里其实早就想要“从表达式拿到类型”。
GCC 这边有扩展:typeof(或者你会看到 __typeof__)。
写起来很爽。
但它不是标准。
Boost 也出过一个 Boost.Typeof。
思路很工程化:既然编译器不给,那我用一堆模板和宏去“套”。
这些东西的共同点是。 能用。 但你心里一直没底。
毕竟一换编译器。 就可能全线爆炸。
C++11:decltype 把这件事收编进标准
当年 C++11 的经典解法是尾置返回类型配 decltype。
写起来像咒语,但确实能救命。
auto get_hits_cxx11(const std::string& key) -> decltype((g[key].hits)) {
return g[key].hits;
}
这里的双括号不是装饰。
decltype 有个很怪、但很重要的特例。
如果你直接写 decltype(g[key].hits)。
它会按“成员的声明类型”来。
也就是 int。
多那一层括号,decltype 才会按“表达式的样子”来。
g[key].hits 是左值,于是你拿到的是 int&。
C++14:decltype(auto) 的意思就是“按原样交给你”
decltype(expr) 的想法很简单。
别猜。
看 expr 自己是什么类型,就用什么类型。
这里有个新词要记一下:左值(lvalue)。
你可以把它理解成“有名字、有地址、能长期放着的东西”。
像 g[key] 这种,就是左值。
它本来就是引用语义。
decltype(auto) 说白了就是:别让我写那段咒语了。
你只要在返回类型那里写上它,我就帮你“按原样返回”。
decltype(auto) get_entry_fixed(const std::string& key) {
return g[key];
}
这一次。
返回类型会是 Entry&。
你改它。
就真改到容器里了。
横向对比:auto、auto&、decltype(auto) 到底怎么选
先看最常见的三种“拿法”。
auto a = g["home"]; // 拷贝
auto& b = g["home"]; // 引用
decltype(auto) c = g["home"]; // 基本等价于引用
a 是一份新对象。
b 和 c 更像是在拿“原件”。
你写 wrapper 的时候,问题就出在这。
你以为自己在写 b。
实际上你写出来的是 a。
再给你三个短例子:你以后大概率还会遇到
第一个例子。 迭代器解引用。
#include <vector>
std::vector<int> v = {1, 2};
auto it = v.begin();
auto a = *it;
decltype(auto) b = *it;
*it 这东西。
其实经常就是个引用。
a 会变成一份拷贝。
b 会更像“原样拿到”。
第二个例子。 字符串下标。
#include <string>
std::string s = "hi";
auto ch1 = s[0];
decltype(auto) ch2 = s[0];
ch2 = 'H';
s[0] 返回的是 char&。
ch1 是拷贝。
你改 ch1,s 不会动。
ch2 更像“原样拿到”。
你改 ch2,s 也跟着改。
第三个例子。 你以为自己在写“只读”。
const std::map<std::string, Entry>& cg = g;
decltype(auto) e = cg.at("home");
at 不会插入。
它返回引用。
而且因为 cg 是 const。
你拿到的是 const Entry&。
你想改它。 编译器会拦你。
这就对了。 接口说只读,它就得只读。
横向对比:C 的世界为什么没这坑
在 C 里。 你要改外面的东西,一般就两种写法。
一种是传指针。 一种是传“输出参数”。
它们有个共同点。 你一眼就能看出来:你在改谁。
C++ 比较像是把两套世界叠在一起。 既能按值传,也能按引用传。
于是“看起来像引用,其实是拷贝”这种错。 就有了生存空间。
一个阴坑:括号不是装饰
decltype 对“有没有括号”特别敏感。
敏感到离谱。
int g_x = 0;
decltype(auto) f1() { return g_x; } // int
decltype(auto) f2() { return (g_x); } // int&
你看。 只是多了一对括号,结果就从值变成了引用。
这个坑在 decltype(auto) 的返回值里更常见。
decltype(auto) hits1() { return g["home"].hits; } // int
decltype(auto) hits2() { return (g["home"].hits); } // int&
你只是改了括号。 语义就换了一套。
这时候你可能会想。 “那我到底该不该加括号?”
我的建议很简单。 别为了好看去加。 你加的每一对括号,都要有理由。
更危险的是这种。
decltype(auto) bad() {
int x = 0;
return (x);
}
它会返回 int&。
但 x 已经要离开作用域了,拿到的是悬空引用。
所以我一般的习惯是。
用 decltype(auto) 时,不要为了“好看”去乱加括号。
你在返回什么表达式,你得心里有数。
一句话结论
写 wrapper。
要么把返回类型写死。
要么用 decltype(auto),别让引用和 const 悄悄丢了。
最后留个亮点
我后来越来越相信一句话。
很多性能问题。
不是你算法不行。
是你不小心把“引用的世界”,写成了“拷贝的世界”。
decltype(auto) 这种东西。
看起来只是语法糖。
但它有时候,真能把你从坑里拽出来。