我们已经看到,将逻辑上相关的值收集到聚合对象中会带来一个附带的好处:可以将这些值作为整体传递给函数,并通过名称来访问,而不是依赖在冗长列表中的位置顺序。但关键在于“逻辑相关” —— 仅仅因为某些值恰好在一次函数调用中一起使用,就强行将它们聚合在一起,会创建出不必要的对象,还需要为这些对象取名字。
我们需要一种方法来创建临时的聚合体,最好无需显式的命名或声明。实际上,我们早已拥有解决此问题的方法,并且这个方法在 C++ 中已经存在了很长时间;现在,我们只需从一个全新的角度重新审视它,而这正是我们接下来要做的。
方法链是一种借鉴自 Smalltalk 的 C++ 技术,其主要目的是消除不必要的局部变量。可能已经多次使用过方法链,即使并未意识到。考虑一下可能已经写过很多次的这段代码:
// Example 02
int i, j;
std::cout << i << j;
最后一行代码调用了两次插入运算符 <<。第一次调用作用于运算符左侧的对象 std::cout。那么第二次调用作用于哪个对象呢?通常,运算符语法只是一种调用名为 operator<<() 的函数的方式。这个特定的运算符通常是作为非成员函数实现的,但 std::ostream 类也提供了几个成员函数的重载,其中之一就是用于 int 值的。因此,最后一行代码实际上等价于:
// Example 02-
std::cout.operator<<(i).operator<<(j);
第二次对 operator<<() 的调用是作用在第一次调用的结果之上的。等效的 C++ 代码如下:
// Example 02
auto& out1 = std::cout.operator(i);
out1.operator<<(j);
这就是方法链 —— 一个方法的调用会返回下一个方法所需调用的对象。在 std::cout 的例子中,成员函数 operator<<() 返回了对象自身的引用。顺便提一下,非成员函数 operator<<() 也做了同样的事,只不过它将流对象作为显式的第一个参数,而不是隐式的 this 指针。
现在,可以利用方法链来消除显式命名的参数对象。
正如之前所见,当聚合参数对象不仅仅用于承载参数时,它们工作得很好;如果需要一个对象来保存系统的状态,并且需要随时间逐步构建并长期持有该对象,可以将此对象作为单个参数传递给需要该状态的函数。真正让我们感到困扰的,是仅仅为了单次函数调用而创建聚合对象。另一方面,我们也不喜欢编写带有大量参数的函数。对于那些通常大多数参数都使用默认值、只有少数参数需要更改的函数,这种情况尤其明显。
回到我们的游戏,假设游戏中的每一天(游戏时间)都是通过一次函数调用来处理的。该函数每天被调用一次,用于推进城市度过这一天,并处理游戏可能产生的各种随机事件的后果:
class City {
...
void day(bool flood = false, bool fire = false,
bool revolt = false, bool exotic_caravan = false,
bool holy_vision = false, bool festival = false, ... );
...
};
随着时间的推移,可能会发生许多不同的事件,但通常在特定的一天里,最多只发生一个事件。我们将所有参数默认设置为 false,但这并没有太大帮助;因为这些事件之间没有特定的顺序,如果节日发生了,即使其他事件仍处于默认的 false 状态,我们也必须指定之前的所有参数。
使用聚合对象会有很大帮助,但仍需要创建并命名:
class City {
...
struct DayEvents {
bool flood = false;
bool fire = false;
...
};
void day(DayEvents events);
...
};
City capital(...);
City::DayEvents events;
events.fire = true;
capital.day(events);
我们希望能够为 City::day() 的调用临时创建一个 DayEvents 对象,但需要一种方式来设置它的数据成员。这正是方法链发挥作用的地方:
// Example 03
class City {
...
class DayEvents {
friend City;
bool flood = false;
bool fire = false;
public:
DayEvents() = default;
DayEvents& SetFlood() { flood = true; return *this; }
DayEvents& SetFire() { fire = true; return *this; }
...
};
void day(DayEvents events);
...
};
City capital(...);
capital.day(City::DayEvents().SetFire());
默认构造函数创建了一个匿名的临时对象。在这个对象上,调用了 SetFire() 方法。该方法修改了这个临时对象,并返回了对它自身的引用。随后,将这个创建并修改后的临时对象传递给 day() 函数。day() 函数处理当天的事件,显示城市着火的更新画面,播放火焰燃烧的声音,并更新城市状态,以反映部分建筑已被大火损毁。
由于每个 Set() 方法都返回对同一对象的引用,因此我们可以在一个方法链中调用多个方法,以指定多个同时发生的事件。当然,Set() 方法也可以接受参数;例如,可以将不使用的火灾事件从默认的 false 改为 true 的 SetFire() 方法,而是设计一个能够双向设置事件标志的方法:
DayEvents& SetFire(bool value = true) {
fire = value;
return *this;
}
今天是我们的城市集市日,恰逢一个盛大的节日,因此国王除了城中已有的两支卫队外,又额外雇佣了一支卫队:
City capital(...);
capital.day(City::DayEvents().
SetMarket().
SetFestival().
SetGuard(3));
对于所有未发生的事件,我们完全不需要进行指定。现在,我们真正实现了“命名参数”;当调用函数时,可以按任意顺序通过名称传递参数,并且无需明确提及那些希望保持默认值的参数。这就是 C++ 中的命名参数惯用法。
当然,使用命名参数的调用比使用位置参数的调用更冗长 —— 每个参数都必须显式写出其名称。而这正是我们所追求的:清晰、不易出错。另一方面,当存在大量默认参数无需更改时,这种写法反而让我们受益更多,只关注那些真正发生变化的参数。
可能会有人提出性能方面的问题:我们引入了许多其他的函数调用 —— 包括对象的构造、以及每个命名参数对应的 Set() 方法调用,这必然会产生一些开销。来确切地分析一下这个开销到底有多大。
使用命名参数的调用确实涉及更多的操作,因为调用了更多的函数。然而,这些函数调用本身非常简单,如果这些函数定义在头文件中,并且整个实现对编译器可见,那么编译器没有理由不将所有的 Set() 调用内联,并消除不必要的临时变量。在良好优化的情况下,可以预期命名参数惯用法与显式命名的聚合对象具有相似的性能表现。
衡量单个函数调用性能的恰当工具是微基准测试,使用 Google 的微基准测试库来实现这一目的。虽然基准测试通常写在一个文件中,但如果希望调用的函数是外部的(即不被内联),就需要另一个源文件。另一方面,Set() 方法必须内联,因此应该定义在头文件中。第二个源文件应包含使用命名参数或位置参数调用的函数的定义。这两个文件在链接时合并:
$CXX named_args.C named_args_extra.C -g -O4 -I. \
-Wall -Wextra -Werror -pedantic --std=c++14 \
-I$GBENCH_DIR/include $GBENCH_DIR/lib/libbenchmark.a \
-lpthread -lrt -lm -o named_args
我们可以比较位置参数、命名参数以及聚合参数这三种方式。结果将取决于参数的类型和数量。例如,对于一个接受四个布尔类型参数的函数,可以比较以下几种调用方式:
// Example 04
// 位置参数:
Positional p(true, false, true, false);
// 命名参数惯用法:
Named n(Named::Options().SetA(true).SetC(true));
// 聚合对象:
Aggregate::Options options;
options.a = true;
options.c = true;
Aggregate a(options));
基准测试测得的性能将极大地依赖于编译器以及控制优化的选项。例如,以下数据是在 GCC 12 配合 -O3 优化选项下收集的:
Benchmark Time UserCounters...
BM_positional_const 0.233 ns items_per_second=138.898G/s
BM_named_const 0.238 ns items_per_second=134.969G/s
BM_aggregate_const 0.239 ns items_per_second=135.323G/s
对于显式命名的聚合对象,编译器能够将其内联并优化掉,没有明显的性能损失。命名参数和位置参数的性能表现相似。需要注意的是,函数调用的性能在很大程度上取决于程序同时发生的其他操作,参数是通过寄存器传递的,而寄存器的可用性受上下文影响。
在我们的基准测试中,使用了编译时常量作为参数值。这种情况并不少见,尤其是对于指定某些选项的参数 —— 通常在每个调用点,许多选项是静态的、不变的(这些值在代码中其他调用同一函数的地方可能不同,但在这一行,许多值在编译时就是固定的)。如果我们在游戏中有一个特殊代码分支来处理自然灾害,那么普通分支总是会以洪水、火灾和其他灾难标志设为 false 来调用我们的每日模拟函数。
然而,同样常见的情况是参数在运行时计算得出。这会对性能产生什么影响?让我们创建另一个基准测试,其中参数的值是从例如一个 vector 中获取的:
// Example 04
std::vector<int> v; // 用随机值填充 v
size_t i = 0;
// ... 基准测试循环 ...
const bool a = v[i++];
const bool b = v[i++];
const bool c = v[i++];
const bool d = v[i++];
if (i == v.size()) i = 0; // 假设 v.size() 能被 4 整除
Positional p(a, b, c, d); // 位置参数
Named n(Named::Options().
SetA(a).SetC(b).SetC(c).SetD(d)); // 命名参数
Aggregate::Options options;
options.a = a;
options.b = b;
options.c = c;
options.d = d;
Aggregate a(options)); // 聚合对象
顺便提一下,以这种方式缩短前面的代码并不明智:
Positional p(v[i++], v[i++], v[i++], v[i++]);
原因是函数实参的求值顺序在 C++ 中是未定义的,哪个 i++ 表达式先执行是任意的。如果 i 从 0 开始,这个调用最终可能变成 Positional(v[0], v[1], v[2], v[3]),也可能是 Positional(v[3], v[2], v[1], v[0]),甚至是其他排列组合。这会导致严重且难以调试的未定义行为。
在同一编译器和硬件上,得到了不同的数字:
Benchmark Time UserCounters...
BM_positional_vars 50.8 ns items_per_second=630.389M/s
BM_named_vars 49.4 ns items_per_second=647.577M/s
BM_aggregate_vars 45.8 ns items_per_second=647.349M/s
从结果中我们可以看到,编译器完全消除了匿名临时对象(或命名的聚合对象)的开销,并为这三种向函数传递参数的方式生成了性能相似的代码。通常情况下,编译器优化的结果很难预测。例如,clang会产生显著不同的结果(当大多数参数是编译时常量时,命名参数调用更快;但当参数是运行时值时,则更慢)。
基准测试并未偏向特定的参数传递机制,命名参数惯用法的性能至少不逊于显式命名的聚合对象或等效的位置参数,特别是当编译器能够消除匿名临时对象时。某些编译器上,如果函数参数很多,命名参数甚至可能更快。如果优化未能发生,调用可能会稍慢一些。另一方面,在许多情况下,函数调用本身的性能并不关键;例如,城市只在玩家建造时才会创建,整个游戏过程中可能只发生几次。每日事件每天处理一次,这可能需要现实时间中的数秒以上,至少是为了让玩家能够享受与游戏的互动。另一方面,那些在性能关键代码中被反复调用的函数应尽可能内联,也可以预期在这种情况下参数传递的优化效果会更好。
总体而言,可以得出结论:除非特定函数调用的性能对程序整体性能至关重要,否则不应担心命名参数的开销。对于性能关键的调用,应逐案进行性能测量,而且命名参数有可能比位置参数更快。