引言:全局变量
全局变量是一件坏事。每个人都知道,对吧?
但你知道具体原因么?我一直在问这个问题,我们很多人都无法确切解释为何要避免使用全局变量。
这不是范围问题。事实上,全局常数与全局变量具有相同的范围,但全局常数通常被视为一件好事情,因为它让“魔数”有了可识别的标签。
一些人回答说,应该避免使用全局变量,因为它们会导致多线程问题。它们确实会导致多线程问题,因为一个全局变量可以从任何函数访问,也可以从多个线程同时读写,但我不认为这是主要问题。因为,正如所有人都知道的,即使一个程序中只有一个线程,也应该避免使用全局变量。
我认为全局变量是个问题,因为它们破坏了函数。
函数有助于将一个程序(或另一个函数)分解为更简单的元素,因此,它们降低了复杂性,是提高代码表达能力的工具。但要做到这一点,函数必须遵守某些规则。其中一条规则源于一函数的定义:
函数接受输入,并提供输出。
这听上去很简单,因为它就是这样。为了保持简单,重要的是要理解一个函数必须清楚地显示其输入和输出是什么。这是全局变量打破上述函数定义的地方。一旦存在一个全局变量,其范围内的每个函数都可能将该全局变量作为输入和/或输出。这在函数声明中是隐藏的。因此,该函数有输入和输出,但并不确切地说明它们是什么。这些函数…功能不全。
注意全局常数没有这个问题。它们不是一个函数的输入,因为它们不能改变(定义为输入),它们也肯定不是一个输出,因为该函数不能在它们中写入内容。
因此,一个函数必须清楚地表达其输入和输出。这种思想恰好是基于函数式编程,因此我们可以这样制定指导方针:
让你的函数具有函数性!
这篇文章的其余部分展示了如何在C++中以惯用的方式实现这一点。
表示函数的输入
很简单,输入来自于它的参数。通常,输入值通过传递对const
参数(const T&
)的引用来表示。因此,当阅读或编写一个函数原型时,请注意对const
的引用意味着输入。对于某些类型,输入也可以按值输入(例如基本类型)。
表示输入输出参数
C++允许修改一函数的输入。这些参数既是输入又是输出。代表这一点的典型方式是参考not const(T&
)。
表示函数的输出
规则如下:
输出应按返回类型输出。
Output f(const Input& input);
这看上去很自然,但在很多情况下我们不愿意这样做,相反,通常会看到一种更为复杂的方法:将输出参数作为not const(T&
)的引用传递,如下所示:
void f(const Input& input, Output& output);
然后该函数将负责填充该输出参数。
使用此技术有若干缺点:
这不是自然的。输出应按返回类型输出。通过以上代码,最终在调用点出现了一种难懂的句法:
Output output;
f(input, output);
更简单的调用句法:
Output output = f(input);
当有多个函数在一行中被调用时,这会变得更为困难。
- 不能保证该函数实际上会填充输出,
- 可能默认构造输出类没有意义。在这种情况下,出于一个上面有疑问的原因,你会强迫它有一个默认构造函数。
如果通过返回类型产生输出更好,为何每个人都不一直这样做?
有3种原因阻止我们这样做。所有这些都可以有变通方法,大部分时间都很容易做。它们是:性能、错误处理和多返回值。
性能
在C语言中,按值返回似乎很荒唐,因为它产生了一个对象副本,而不是复制指针。但在C++中,有多种语言机制在按值返回时对副本进行省去。例如,返回值优化(RVO)或移动语义可以做到这一点。例如,按值返回任何STL容器将移动该容器而不是复制该容器。移动STL容器所需的时间与复制指针所需的时间相同。
事实上,甚至不必掌握RVO或移动语义来按值返回对象。想做就做!在许多情况下,编译程序会尽其所能将副本删除,而在没有删除副本的情况下,有超过80%的可能性认为该代码不属于性能的关键部分。
只有当探查器(profiler)显示在按特定函数的值返回期间所做的复制是性能的瓶颈时,才可以考虑通过引用传递输出参数来降低代码的性能。即使如此,仍然可以有其他选择(如促进RVO或为返回类型实现move语义)。
错误处理
有时一个函数在某些情况下可能无法计算其输出。例如,功能可能因某些输入而失效。如果没有输出,可以返回什么?
在这种情况下,一些代码会退回到通过引用传递输出的模式,因为该函数不必填充它。然后,若要指示是否已填充输出,该函数将返回一个布尔值或错误代码,如下所示:
bool f(const Input& input, Output& output);
这就造成了调用现场的代码过于繁琐和脆弱:
Output output;
bool success = f(input, output);
if (success)
{
// use output ...
}
对于调用点,最清晰的解决方案是当函数失败时引发异常,并在成功时返回输出。然而,周围的代码必须是异常安全的,而且很多团队在他们的代码行中也不使用异常。
即使如此,仍然有一个解决方案可以使输出按返回类型输出:使用boost::optional
(或者std::optional
)。
你可以在一篇专门的文章中看到所有关于optional
的内容,但简而言之,optional<T>
表示一个对象,该对象可以是类型T
的任何值,也可以是空的。因此,当函数成功时,可以返回一个包含实际输出的optional
对象,而当其失败时,可以只返回一个空的optional
对象:
boost::optional<Output> f(const Input& input);
请注意,可选组件正在标准化过程中,并将在C++17中提供。
在调用点:
auto output = f(input); // in C++11 simply write auto output = f(input);
if (output)
{
// use *output...
}
多个返回类型
在C++中,一个函数只能返回一个类型。因此,当一个函数必须返回多个输出时,有时会出现以下模式:
void f(const Input& intput, Output1& output1, Output2& output2);
或者更糟的不对称的方式:
Output1 f(const Input& input, Output2& output2);
无论如何都退回到通过引用传递输出的令人生畏的模式。
按照目前的语言(<C++17),解决此问题并按返回类型产生多个输出的最简单的解决方案是定义一个新结构,将输出分组:
struct Outputs
{
Output1 output1;
Output2 output2;
};
这将导致更具表现力的声明:
Outputs f(const Input& input);
如果这二个输出经常在一起,那么将它们分组到一个实际对象(使用私有数据和公共方法)中甚至可能是有意义的,尽管这种情况并不总是如此。
在C++11中,一个更快但不太清晰的解决方案是使用元组:
std::tuple<Output1, Output2> f(const Input& input);
调用方法:
Output1 output1;
Output2 output2;
std::tie(output1, output2) = f(inputs);
这有强制输出对象是默认可构造的缺点。(如果你还不熟悉tuple
,不用担心,当我们在一篇专门的文章中探索tuples时,我们将深入了解上述工作的细节)。
最后请注意,以下是一种可能集成在C++17中以本机返回多个值的句法:
auto [output1, output2] = f(const Input& input);
这将是两个世界中最好的。这称为结构化绑定。f将在此处返回std::tuple
。
结论
最后,努力让你的函数按其返回类型输出结果。当这不现实时,请使用其他解决方案,但请注意,这不利于代码的清晰性和可表达性。