一个最为简单的
return x;
语句是C++中出现频率最高的语句之一。为了将这一语句的效率发挥到极致,历代C++都在其上下了极大功夫。
不就是一个return嘛?
是的,确实就是一个return
。
C语言的所有变量都符合现代C++的trivial
types的定义,即当一个变量被赋值时,只需要将该值对应内存空间的数据简单地copy到当前变量对应的内存空间。
对于 trivial
的类来说,return确实只是将变量的值丢回到函数栈存储返回值之处(随后再销毁其它局部变量等等)。
但C++作为一门面向对象的编译期语言,其揭示了一点——这一表面风平浪静的过程实则暗藏汹涌。
因为某一片具备数据、可使用的内存距离C++中的“对象”概念完全不能相提并论。
这也是C/C++对于堆内存分配的“看法”迥异之处:C的
malloc
/free
实际上不会对内存做任何事情(只是“申请”并获得使用权),顶多算上calloc
的零初始化;而C++中的
new
实际上涉及分配内存->调用构造函数两步,具体地,你可以认为operator new
就是一个calloc
加上一个thiscall
函数的调用:
1
2
3
4
5
6
7
8
9
10
11 template<class Ty>
Ty* operator new(size_t size) //C++中operator new的最常用函数重载,在隐式调用时size_t会自动传递
{
Ty* ptr = static_cast<Ty*>(malloc(size));
//随后,我们假设一个函数的ctor在调用时实际上具备Ty::Ty(Ty* where,...)的形式。
//其就是将本来隐式传递的this写成了显式的形式,参考python中的self和C++23的deducing this。
Ty::Ty(ptr);
return ptr;
}
return背后的风云及传统C++的“应付”之策
现在我们假设希望去返回一个值,在C语言中由于并没有明确的“对象”概念——即使是struct
也无异于具有特殊内存layout的fundamental
types而已;
因此在返回时,可以直接从源内存块复制数据到目标内存块,随后释放源内存块的内存占用(栈/堆皆然)。以一个对应用途的、比较知名的C函数来说就是可以单纯地memcpy
。
C语言中确实有
memmove
这一函数,但其与C++的移动语义云云无关——其只是提供了在源内存和目标内存重叠时,保证数据可以正确覆写的保证。
而在C++中却不是这样:对象可以认为是内存之上的上层建筑,获得对象时要先分配内存再调用构造函数,销毁对象时需要先调用析构函数再释放内存。
由此,直观的return
解决方案就应运而生:以return
的值作为参数,分配新内存并构造新对象,随后析构原对象、释放原内存即可。
但对于存在大型类的C++,如此做的弊端也非常明显:
- (复制)构造函数、析构函数可能会涉及到大量的其它操作,时间、资源开销较大;
- C++的OOP思想决定了资源是由对象所“拥有”的,涉及到保有资源对象的相关操作时,或存在大量不必要的复制-析构操作(如堆内存),或很难/无法依此法安全地完成资源所有权的转移(如文件句柄)。
举个有趣的实例:不知各位在初学C++,观察复制构造函数的作用时,是否有思索——即使是一念间,下面这段函数有些蹊跷?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class S
{
public:
S(int _x):x(_x){}
S(const S& from):x(from.x * 2){}
S(S&& from):x(from.x*3){}
~S(){std::cout << x << ' ';}
private:
int x;
}
S single()
{
return S{1};
}
S transfer()
{
S w{single()};
return w;
}
int main()
{
S a{transfer()};
return 0;
}
事实上,即使到了C++23,依然无法绝对把握地预测上述代码的输出——(在遵循标准下)不同的编译器给出的不同优化结果都会导致输出不同。
这也是纷争来源之始端:只要拥有non-trivial的复制/移动构造函数(甚至不涉及赋值,涉及赋值更麻烦),就有很多的胡来优化空间。
比如,single()
返回的S到底是要copy一次返回(传统C++)还是move一次返回(C++11)?(剧透:C++17起,single()
必须遵循
prvalue sematics
,意即其既不会copy也不会move(surprise!)
再比如,transfer()
明明初始化w后啥都没干,返回的时候是copy,还是move,还是都不调用?
从C++98的观点来看(忽略移动构造函数),single()
返回时需要复制构造一次,transfer()
返回时亦然,故std::cout
应当输出1 2 4
。
但其实即使是C++98的编译器编译运行之,依然不一定会输出上述结果。
C++11:"move sematics comes to the rescue"吗?
其中,第二条也是C++的一大痛点,因而C++11提出了右值引用和移动语义的概念,语言层面上支持了我们通过函数等来完成转移资源的操作。
作为配套,C++11提出了 move-eligible
这一概念,通过它来规范一种"Automatic Move"。
以下标志着一个具有自动存储周期(automatic storage duration)变量的表达式都是 move-eligible (可移动)的:
- 其为一个非易失(non-volatile
)对象类型,或一个非易失对象的右值引用(此项C++20中加入);
- 且其在函数体内被声明/作为函数体内的参数出现。
从C++11开始,对于return x;
这条语句用于初始化一个临时对象时,其规定调用构造函数的重载决议会执行两次:
-
第一次决议将x视为一个右值(rvalue?);但若第一轮重载决议失败,或其成功但未选择移动构造函数(所谓选择函数的形参不是纯粹的“右值引用”),则继续执行第二轮
- 第二次决议时,x就会被视为一个左值(lvalue)。
这里可见的最大的疑点就在第一轮重载决议的“失败”定义。比如,选到= delete;
的函数算不算失败?若有多个函数的形参复合要求且优先级相同(即制造了歧义(ambiguity)),是遵循此条规则进行第二轮重载决议还是直接报错?
不同版本中,不同编译器都给出了诸多不同的实现、决策方式。
关于C++11——C++20的规则细讲,可以参考cppreference。
两轮重载决议带来的最大问题就是很多奇特的现象,主要出在所谓“未选择移动构造函数”上。
下述代码中,f1()
在C++23前不能通过编译,f2()
则通过隐式转换“逃过一劫”:
1 | struct T{}; |
C++11起,由于第一轮重载决议会先将up看作右值,因此可以调用移动构造函数初始化返回值。
C++11以前,若通过一些其它方式禁用了up的复制构造,则其将不能被返回:
1 | std::unique_ptr<int> f3() |
C++20前若第一轮重载决议没有选择移动构造函数(必须是“move constructor”),则会回退到传统C++的重载决策(rr视为左值)进而无法使该段程序ill-formed:
1 | std::unique_ptr<int> f4(std::unique_ptr<int>&& rr) |
C++23:终究利落地斩断万缕过往
对于二次重载决议的纷争不休,C++23最终选择给出了一个干净利落的解决方案:移除此前搭建的诸多为了保证兼容性而留下的设施,最大化"Automatic
move"的适用范围。
C++23起,只要return exp;
中的exp是 move-eligible
的, 那么该表达式就会被视为 xvalue 。
当然,如此做也有一定的副作用:
1
2
3
4
5int& fun()
{
int x = 1;
return x;
}
(当然,如果你执意要创造一个悬挂引用,可以把返回值类型改成int&&
)
预告:C++26中对于上述悬挂引用的规定作出了更新,现在若一个函数返回的引用值绑定至临时对象上,则该程序非良构(ill-formed)。
相当于悬挂引用return渐渐成为历史
NRVO/URVO:C++优化之谜
回到最开始的那个函数套娃传递的实例(不要笑——这种套娃传递初始化构造是非常常见的),在许多场景下其编译结果依然有优化空间:
与其让编译器调用几次移动构造函数,不如甚至直接略去这个过程,让最上层的栈函数直接控制最底层函数构造的对象栈空间。
事实上,早在移动语义提出之前的C++98,就已经允许了编译器执行这样的优化(“优化”掉部分return
函数中对可能带副作用的复制构造函数的调用)。
这样的优化场景有两种:
- 返回值的表达式是一个纯右值(prvalue)表达式,称为"URVO"(Unnamed
Return Value Optimization);
-
返回值的表达式是一个本地变量的表达式,但编译器可以预知该变量在一定代码块内不会变化(相当于可以“预判”其返回值/状态),称为"NRVO"(Named
Return Value Optimization)。
1 | std::string getstr(int var) |
如上,C++23中在没有任何优化的情况下,至少需要调用std::string
的移动构造函数两次。
但实际上getstr(int)
函数中初始化一个对象后直接return
了,显然它的栈空间是可以复用的;同时process()
中并没有对base
进行任何其它操作就直接返回,因而其移动也可以省略。
C++17提出了 prvalue sematics
的概念,要求编译器对纯右值表达式(包括潜在需要隐式调用转换构造函数的返回值)进行复制省略优化(一定不会调用移动/复制构造函数)。
对于以下代码: 1
2
3
4
5
6
7
8
9
10
11
12
13class Test
{
public:
Test(){}
Test(const Test&){std::println("copy ctor");}
Test(Test&&) noexcept {std::println("move ctor");}
};
int main()
{
auto v = []{return Test{};}();
return 0;
}
但对于NRVO来说,有两个指导于实践中的要点:
1.
C++只是允许了这种optimization的存在,对其优化程度则未做任何规定/明确,最终优化/编译结果亦因编译器及编译设置而异:
比如将上述代码中main
函数的第一行改为 1
auto v = []{Test t{}; return t;}();
严格来说,C++23前这段代码的输出很大程度上取决于v的初始化声明方式(参见“两轮重载决议”)。
C++23保证了这段代码若有输出,则一定是"move ctor"。
- NRVO的返回值不能有任何修饰(必须只是一个变量名)。因此
return std::move(local)
是不被建议的写法(如此做会disable NRVO)。
C++23前,在部分返回引用/move-only类型时可能需要用std::move
来保证一定的兼容性,但随着C++23统一将 move-eligible 的本地变量视为xvalue,现在依然建议绝大部分场景直接return local;
即可。
1 | std::string ret() |
结语
C++23起,return x;
的诸多用法和语义得以被归一化;
从程序设计的角度来说,除少数需要移动一些volatile
、static
等变量的场景,其它情况下均使用最简单的return x;
即可获得最大效率。
无论是学习编程语言,还是学习、工作与生活中处理各种问题,都尽量应当摒弃"take
it for granted"的思维。
对一些司空见惯之现象深入探究,方能助自身视野格局突破惰性之樊笼,逐渐得以游走自如于空阔之境。