C++代码中将函数返回类型后置有啥好处吗?
内容如下:
C++代码中将函数返回类型后置有啥好处吗?
这种语法是 C++11 新增的,学名叫 trailing return type[1]。翻译过来是后置返回类型,trailing 是后面的、拖尾的意思。书写 int func() 比书写 auto func() -> int 省笔墨。强制所有函数的声明改成后者,就如同强制把所有的变量声明类型改成 auto 一样,代码有异味,变得恶心。
C++11 之前
下面说一下这种语法出现的背景,以及要解决的问题。
时间倒退到 2011 年以前,在 C++0x(后面正式定名 C++11) 已经明确要添加两项 make C++ great again 的功能——自动类型推导(auto、decltype 关键字)和 lambda 表达式后,接下来就是缝缝补补,修理各种细微问题了。
在最初的 C/C++,我们写了一个简单的加法函数如下。其中,参数 lhs 是 left hand side,rhs 是 right hand side。lhs 和 rhs 名称是重载二元操作符惯用的名。
int add(const int& lhs, const int& rhs) { return lhs + rhs; }
接着,我们想支持更多的类型 long、float、double。C 语言只能把每种类型都写一遍,如果不想这样,只能借助宏展开。C++ 相比 C 而言,引入类和模板后,正式分家成为一门强大的计算机语言。对于 C++,这个需求湿湿碎啦!用模板完成,这也是 C++ 语言最擅长的地方。于是修改代码如下:
template <typename T>
T add(const T& lhs, const T& rhs)
{
return lhs + rhs;
}
它能处理同种基本数据类型的加法运算,也包括重载了operator + 的类。so far, so good。直到某一天我们发现,它无法完成整数与浮点数的加法。在 C/C++ 语言里,int 和 float 相加,int 会自动类型提升(type promotion) 到 float,运算结果返回 float。测试代码如下:
#include <tuple>
template <typename T>
T add(const T& lhs, const T& rhs)
{
return lhs + rhs;
}
int main()
{
std::ignore = add(1, 2); // OK
std::ignore = add(1.0f, 2.0f); // OK
// std::ignore = add(1, 2.0f); // error: no matching function for call to 'add(int, float)
std::ignore = add<float>(1, 2.0f); // OK
return 0;
}
代码有一处知识点。std::ignore 本是在 C++11 引入给 std::tuple 用的。然后发现,如果不关心返回值,就可以丢给它,根本不用担心变量重复定义问题。不关心返回值时,过去的做法是用 (void)var; 来消除 warning: unused variable 'var' [-Wunused-variable]。现在(Scott Meyers 在他的书 Effective Modern C++,也就是 C++11 之后)的做法是 std::ignore。将来(C++17)的做法是 [[maybe_unused]]。
回过神来。上述代码因为参数用了 const 引用,模板的类型带入 T = int 或 T = float 都会失败。虽然 SFINAE(Substitution failure is not an error),但是最终没有一个可匹配上的函数那就是 error。这种情况还可以挽救——第四行调用,显式指定模板类型 add<float>() 让编译通过。
C++11 之后
救世主在哪里?
C++ 从 2003 到 2011,停滞了 8 年,是需要革新了。C++ 在 C++11 标准里,强行回收了在 C 里几乎从来没用到过的关键字 auto,让 auto 的生命得到完美绽放。在赋值语句里,auto 可以从等号右边的类型推导出左边的类型。在规范书写的函数里,可以从函数里的 return 语句推导返回类型。下面列出了返回 void 类型的正确和错误写法。
void fun0() { return; } // OK
// auto fun1() { return void; } // Error
auto fun2() { return void(); } // OK
auto fun3() { return; } // OK
有了 auto 可用,我们接着改进代码。我们用两种类型的模板试试,至于返回类型?让它自动推导吧~
template <typename TL, typename TR>
auto add(const TL& lhs, const TR& rhs)
{
return lhs + rhs;
}
如果用 C++11 编译器,会报错如下。用 C++14 以后的编译器,是完全通过编译的。C++14 是 C++11 的小更新(比如扩宽了 auto、constexpr 的使用场景),没有增加大的功能。
error: 'auto' return without trailing return type; deduced return types are a C++14 extension.
错误讲得明明白白,在 C++11, auto 必须跟 trailing return type 配对使用。引入 decltype 关键字,继续修改代码如下。我在下面的回答里提到了 decltype 关键字,可以去围观。
c++为什么变量可以取名final?
template <typename TL, typename TR>
auto add(const TL& lhs, const TR& rhs) -> decltype(lhs + rhs)
{
return lhs + rhs;
}
这也是可以通过编译的。然后我们会想,能不能将 decltype(lhs + rhs) 放前面呢?
template <typename TL, typename TR>
decltype(lhs + rhs) add(const TL& lhs, const TR& rhs)
{
return lhs + rhs;
}
很抱歉,这样不行,报错为 error: use of undeclared identifier 'lhs' 'rhs', decltype(lhs + rhs) add(const TL& lhs, const TR& rhs)。
原因是,还没声明类型就在使用变量了,当然不认识变量了!那改成 decltype(TL+ TR) ?不行啊,语法上就说不过去,哪有两个类型相加减的。动动脑筋,改成 decltype(*(TL*)nullptr + *(TR*)nullptr) 就可以了,其他的变种有 decltype(std::declval<TL&>() + std::declval<TR&>())。这算是一种 dirty hack,凭空变出了两种类型的变量。虽然我们知道真正的代码这样写执行时会崩溃,但是 decltype 跟 sizeof 关键字一样,是不会计算括号里的表达式的[2]。比如 decltype(++a) 在完成类型推导,sizeof(++a) 在获取所占空间的大小后,都并不会递增变量 a。
The following operands are unevaluated operands , they are not evaluated:
1. expressions which the typeid operator applies to, except glvalues of polymorphic class types
2. expressions which are operands of the sizeof operator
3. operands of the noexcept operator
4. operands of the decltype specifier
这里又引出 std::decval[3]知识点, std::declval 是删除了构造函数的类(构造函数会标记为 = delete)的福音。可以看链接里的例子,在不能创建实例的情况下,如何获取类的成员函数的返回类型。
lambda 表达式
关于 lambda 表达式,是不能在前面声明返回类型的,只能用 trailing return type。
[](int* p) -> int& { return *p; } // OK
int& [](int* p) { return *p; } // ill-formed
那么在什么情况下,需要使用 trailing return type 呢?
- 你所在的工程要求是 C++11 标准,不得不用。一定想用的话,得用上面的 dirty hack。
- lambda 表达式,想显式写返回类型。这个必须后置了,毫无疑问。
- 返回类型经由模板类型推导,且比较长。为了避免头重脚轻,返回类型放后面可增加代码的可读性。
- 影响函数的正常声明,需要靠 typedef 或 using 的。(下面给出了例子)
对比以下三种写法,中间的可读性显然好些。当然,在发现的代码规范中,返回类型太长也有单独成行的写法,即下面的第三种,阅读性好像没那么好。
template <typename TL, typename TR>
decltype(TL::A::REALLY::LONG::value + TR::A::REALLY::LONG::value) add(const TL& lhs, const TR& rhs)
{
return lhs + rhs;
}
template <typename TL, typename TR>
auto add(const TL& lhs, const TR& rhs) -> decltype(TL::A::REALLY::LONG::value + TR::A::REALLY::LONG::value)
{
return lhs + rhs;
}
template <typename TL, typename TR>
decltype(TL::A::REALLY::LONG::value + TR::A::REALLY::LONG::value)
add(const TL& lhs, const TR& rhs)
{
return lhs + rhs;
}
下面的 fun1 想传递一个 int,返回函数指针。为了聚焦问题,我们这里直接返回 nullptr 而没有写实际内容。可惜 fun1 编译不过,报错 error: expected unqualified-id before ')' token。fun2 利用 typedef FunctionPointer ,完成此项功能。再看 fun3,用上后置返回类型,也能顺利通过。且少了 FunctionPointer 的笔墨,看着也清爽。
int(*)(int) fun1(int)
{
return nullptr;
}
using FunctionPointer = int(*)(int); // typedef int(*FunctionPointer)(int);
FunctionPointer fun2(int)
{
return nullptr;
}
auto fun3(int) -> int(*)(int)
{
return nullptr;
}
很多时候,我们会在类 A 里定义类、枚举等限定作用于的类型 B,外部人员使用的时候,需要写成 A::B 的形式。让后置类型,可以丢掉外面 A:: 这个修饰,尤其是 A 比较长时,会影响代码阅读体验。下面是代码演示,make() 是具体实现,需二选一,看你选择红色药丸, 还是蓝色药丸?
class Matrix
{
public:
enum class Pill
{
RED,
BLUE,
};
public:
Pill make();
};
Matrix::Pill Matrix::make() { return Pill::RED; }
auto Matrix::make() -> Pill { return Pill::BLUE; }
Google 的 C++ Guideline[4] 里,规定了有关 trailing return type 代码的建议写法,可以打开链接,了解大厂的代码规范。上面都讲到了。
有闲暇时间,可以看看 C++ 之父 Bjarne Stroustrup的网站[5],以及写的书《The Design and Evolution of C++》,了解新功能、新语法孕育的过程。
C++语言的设计和演化
京东
¥52.50
去购买
C++语言的设计和演化
语法
-> 级联
在 C++11 之前,-> 记号表示指针,--> 是“快速趋近于”[doge],其实是 -- 与 > 的组合。C++11 以后,-> 可以表示后置返回类型语法。这种语法可以像指针一样玩级联吗?即可以写出 A -> B -> C 的形式?答案是可以的。下面代码中 = 右边的第一项可以没有,带星号的项可以无限级联下去。
#include <typeinfo>
using T = auto() -> auto(*)() -> auto(*)() -> auto(*)() -> auto(*)() -> int;
int main()
{
std::cout << typeid(T).name() << '\n';
return 0;
}
我们来一层层剥洋葱。最里面的是 auto(*)()->int,后置类型拿掉就是 int (*)()。嗯,这个我们知道,函数指针嘛,我们在 C 语言里经常写 typedef int (*fun)();。让 using T1 = auto(*)()->int,剥掉一层,倒数第二层是 auto(*)()-> T1,它的意思也明了了,一个函数指针,返回的类型也是函数指针。这样一层层下去,我们知道,T 最终也将是个函数指针!
// 加上参数玩一下,没毛病。
using T = auto(*)(int, int, int, int, int) ->
auto(*)(int, int, int, int) ->
auto(*)(int, int, int) ->
auto(*)(int, int) ->
auto(*)(int) ->
int;
然后,写出函数的实现也是可以的。
#include <cstdio>
auto game()
{
return printf("%s\n", __func__);
}
auto funGame() -> auto(*)() -> int
{
return game;
}
auto playFunGame() -> auto(*)() -> auto(*)() -> int
{
return funGame;
}
int main()
{
playFunGame()()(); // print "game"
return 0;
}
overide/final
对于函数返回类型后置,需要明确 override / final 两个 context-sensitive 关键字的位置。它俩总是在函数申明(trailing return type 也算函数声明的一部分)的最后。比如成员函数的声明
void foo() const override;
需要写成
auto foo() const -> void override;
而不能写成
auto foo() const override -> void;
注意到 override 的位置,不能放在函数实现的最后,否则会编译报错 error: virt-specifiers in 'foo' not allowed outside a class definition。
其他语言
末尾也附上其他语言的写法吧,可以对比学习和玩味。函数名称是 add。a 和 b、以及返回值都是 int 类型。注释里标住了语言,括号里的 T 与 object 表示类型和对象,在该语言里的语法要求。很多在弱化类型,把类型 T 置于对象 Object 之后,Python 这样的动态类型语言本来就不用写类型。
int add(int a, int b) // C、C++、C#、Java (T object)
auto add(int a, int b) -> int // C++11 trailing return type(T object)
auto add(a -> int, b -> int) -> int // 好像还没有一种语言这么做? (object -> T)
func add(b int, c int) int // Go (object T)
function add(a: Int, b: Int): Int // Haxe、Kotlin (object: T)
function add(a: number, b: number): number // TypeScript (object: T)
function add(a, b) -- Lua (object)
function add (a As Integer, b As Integer) As Integer REM BASIC (object As T)
fn add(b: i32, c: i32) -> i32 // Rust (object: T)
def add(a: int, b: int) -> int: # Python (object: T)
func add(a: Int, b: Int) -> int // Swift (object: T)
编辑于 2024-02-24 · 著作权归作者所有