Left-to-Right vs. Right-to-Left Coding Styles
进入 Modern C++,声明风格由 Right-to-Left 逐渐转变为 Left-to-Right,个中差异,优劣得失,且看本篇内容。
前言
Classic C++ 中,声明风格是自右向左,如:
int f() {}
int a = 42;
std::string s{"str"};
而 Modern C++ 中变为自左向右,对应写法为:
auto f() -> int {}
auto a = 42;
auto s = std::string{"str"};
其实很多编程语言都采用或支持 Left-to-Right 这种声明风格,下面列举几种。
Rust:
fn f(num: i32) -> i32 {}
let x = f(5);
Swift:
func f(_ n: Int) -> Int {}
let x = f(5)
Python:
# type hinting
def f(num: int) -> int:
return num * num
x = f(5)
Haskell:
f :: Int -> Int
f n = n * n
x :: Int
x = f 5
这里只列举了一些同样使用 ->
表示返回类型的语言,它们都基于数学中对函数的表示 f: X -> Y
。因此,如果你是来自于这一系的 C++ 学习者,Left-to-Right 的这种新形式可能会更加友好。
从视觉上来说,代码本身就是左对齐,采用 Left-to-Right 这种语法形式能够使代码风格更具有一致性,且可突出重点,缺点则是声明更长。
// Classic C++
struct S {
int f1() {}
char f2() {}
std::string f3() {}
SomeOtherTypeWithALongName f4() {}
};
int a = 42; // variable
char func(); // function
// Modern C++
struct S {
auto f1() -> int {}
auto f2() -> char {}
auto f3() -> std::string {}
auto f4() -> SomeOtherTypeWithALongName {}
};
auto a = 42; // variable
auto func() -> char; // function
这些只是形式上的差异,算不得主要,下面来看其他差异。
细数差异
1. 繁杂名称
许多时候,你可能并不关心某些类型名称,典型例子是迭代器。
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::vector<int>::iterator it;
for (it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << " ";
}
旧式写法过于繁琐,损耗精力,新式写法具有推导能力,可以直接用省略名称。
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << " ";
}
当然如今这种写法也略显冗余,举例而已。
同样的名称,还包含 Literal suffixes,Smart Pointers 等等,皆属此类,不胜枚举。
2. 名称查找
编译器从左向右解析名称,使用 Right-to-Left 这种形式某些情况下必须写出完整的名称,才能让名称查找正常工作。
一个例子:
struct DummyName {
using type = std::vector<int>;
type f();
};
DummyName::type DummyName::f() {
return {};
}
你必须完整地写出 DummyName::type
,而不是 type
,否则名称查找失败。Left-to-Right 则无此约束:
struct DummyName {
using type = std::vector<int>;
type f();
};
auto DummyName::f() -> type {
return {};
}
这将减少不少重复。不明白原因请再次翻开 洞悉函数重载决议,查看 Name Lookup 小节。
3. 泛型代码
同样由于名称解析的顺序,下面这种情况,Right-to-Left 无能为力:
// Error!
template <class T, class U>
decltype(a + b) f(T& a, U& b) {
return a + b;
}
函数需要返回两个类型相加的新类型,但因为解析顺序,编译器在遇到 a, b
之前无法识别名称。当然,可以借助 std::common_type
来满足需求:
template <class T, class U>
std::common_type_t<T, U> f(T& a, U& b) {
return a + b;
}
虽说可以满足,但表意不够直观。Left-to-Right 可以直接这样写:
template <class T, class U>
auto f(T& a, U& b) -> decltype(a + b) {
return a + b;
}
更简单的方式是采用 C++14 的自动类型推导,代码最少:
template <class T, class U>
auto f(T& a, U& b) {
return a + b;
}
但这种方式声明和实现都必须放在 .h
里面。
4. 复杂声明
有些声明非常复杂,比如:
void (*f(int i))(int);
尽管可以分析出 f
的实际类型为:
f is a function passing an int returing a pointer to a function passing int returning void.
但是可阅读性很差,采用 Left-to-Right 可使表意一目了然。
auto f(int i) -> void (*)(int);
5. 修饰位置
一个不同地方在于,override
的修饰位置不同。
struct Base {
virtual int f() const noexcept;
};
struct Derived: Base {
virtual int f() const noexcept override;
};
Right-to-Left 风格的 override
和其他修饰符靠得很近,而 Left-to-Right 则略显奇怪:
struct Base {
virtual auto f() const noexcept -> int;
};
struct Derived: Base {
virtual auto f() const noexcept -> int override;
};
override
与其他修饰符位置相距甚远,始终出现在声明结尾。
6. SFINAE Friendly
另一个细微的差异,看 The Book of Modern C++ §1.3.2 中详细解释过的一个例子。
// Example from ISO C++
template <class T> struct A { using X = typename T::X; };
// normal return type
template <class T> typename T::X f(typename A<T>::X);
template <class T> void f(...);
// trailing return type
template <class T> auto g(typename A<T>::X) -> typename T::X;
template <class T> void g(...);
int main() {
f<int>(0); // #1 OK
g<int>(0); // #2 Error
}
这两种写法完全相同,但是此处 Right-to-Left 将产生 SFINAE,而 Left-to-Right 则会产生 Hard-error。前者是 SFINAE-Friendly,而后者并不是,是以编译失败。
7. Lambdas
Lambda expressions 的类型是 closure type,没有办法显式写出类型,此时必须采用 Left-to-Right 的写法。
auto f = [](int a) -> int {
return a * a;
}
这个是最无法替代的一类,因此不论是否喜欢新风格,其实都会在某些情况下使用。
Conclusion
Left-to-Right 还是 Right-to-Left 好?这种争论毫无意义,风格所好本就是非常主观的事情,只要你觉得有使用的理由,大可以坚持自己的风格。
此外,Left-to-Right 中包含 auto, trailing-return-type, decltype(auto) 这些具有细微差异的特性,再结合左值右值等概念,若无一定经验,使用起来容易出错。
若是新项目,再考虑选择新的风格,旧项目就保持一致吧。
易出错的其他细微差异,不属本篇小主题,遂未涵盖,请待后续单独更新。若有遗漏,亦可补充。