函数与其调用者通过两种机制进行通信:形参和返回值。当函数被调用时,调用者提供实参,函数通过其形参接收这些实参。这些实参可以通过值、引用或地址传递。
通常,我们会通过值或通过 const 引用传递实参。但在某些情况下,我们需要采用其他方式。
输入参数(in parameters)
在大多数情况下,函数形参仅用于接收来自调用者的输入。仅用于接收调用者输入的形参有时被称为输入参数(in parameters)
#include <iostream>
void print(int x) // x 是输入参数
{
std::cout << x << '\n';
}
void print(const std::string& s) // s 是输入参数
{
std::cout << s << '\n';
}
int main()
{
print(5);
std::string s{ "Hello, world!" };
print(s);
return 0;
}
输入参数通常通过值或通过 const 引用传递。
输出参数(out parameters)
通过非常量引用(或指向非常量的指针)传递的函数实参允许函数修改作为实参传递的对象的值。这为函数提供了一种在某些情况下无法通过返回值返回数据给调用者时的解决方案。
仅用于将信息返回给调用者的形参被称为输出参数(out parameters)。
示例:
#include <cmath> // 用于 std::sin() 和 std::cos()
#include <iostream>
// sinOut 和 cosOut 是输出参数
void getSinCos(double degrees, double& sinOut, double& cosOut)
{
// sin() 和 cos() 使用弧度而非角度,因此需要转换
constexpr double pi{ 3.14159265358979323846 }; // π 的值
double radians = degrees * pi / 180.0;
sinOut = std::sin(radians);
cosOut = std::cos(radians);
}
int main()
{
double sin{ 0.0 };
double cos{ 0.0 };
double degrees{};
std::cout << "Enter the number of degrees: ";
std::cin >> degrees;
// getSinCos 将正弦和余弦值返回到变量 sin 和 cos 中
getSinCos(degrees, sin, cos);
std::cout << "The sin is " << sin << '\n';
std::cout << "The cos is " << cos << '\n';
return 0;
}
该函数有一个输入参数 degrees
(通过值传递),并“返回”两个输出参数(通过引用传递)。
我们给这些输出参数加上了后缀“out”,以表明它们是输出参数。这有助于提醒调用者传递给这些参数的初始值并不重要,且这些值将被覆盖。按照惯例,输出参数通常是函数的最右侧参数。
让我们更详细地探讨这是如何工作的。首先,main
函数创建了局部变量 sin
和 cos
。这些变量通过引用传递给 getSinCos()
函数(而不是通过值)。这意味着 getSinCos()
函数可以访问 main()
中的实际 sin
和 cos
变量,而不仅仅是它们的副本。getSinCos()
函数随后通过引用 sinOut
和 cosOut
分别为 sin
和 cos
赋予新值,从而覆盖了它们的旧值。main()
函数随后打印这些更新后的值。
如果 sin
和 cos
是通过值而非引用传递的,getSinCos()
函数将修改 sin
和 cos
的副本,导致函数结束时对它们的修改被丢弃。但由于 sin
和 cos
是通过引用传递的,对 sin
或 cos
的任何修改(通过引用)都将持续到函数之外。因此,我们可以利用这种机制将值返回给调用者。
(相关阅读:StackOverflow 上的这个回答解释了为什么非常量左值引用不能绑定到右值/临时对象,因为隐式类型转换与输出参数结合时会产生意外行为。)
输出参数的使用语法不自然
输出参数虽然功能上可行,但存在一些缺点。
首先,调用者必须实例化(并初始化)对象并将其作为实参传递,即使它并不打算使用这些对象。这些对象必须能够被赋值,因此不能声明为 const
。
其次,由于调用者必须传递对象,这些值不能作为临时值使用,或在单个表达式中轻松使用。
以下示例展示了这两个缺点:
#include <iostream>
int getByValue()
{
return 5;
}
void getByReference(int& x)
{
x = 5;
}
int main()
{
// 通过值返回
[[maybe_unused]] int x{ getByValue() }; // 可用于初始化对象
std::cout << getByValue() << '\n'; // 可在表达式中使用临时返回值
// 通过输出参数返回
int y{}; // 必须首先分配一个可赋值的对象
getByReference(y); // 然后传递给函数以赋予期望的值
std::cout << y << '\n'; // 然后才能使用该值
return 0;
}
正如您所见,使用输出参数的语法有些不自然。
通过引用传递的输出参数不会明显表明实参将被修改
当我们将函数的返回值赋给一个对象时,很明显该对象的值将被修改:
x = getByValue(); // 显然 x 将被修改
这很好,因为它清楚地表明我们期望 x 的值发生变化。
然而,让我们再次看看上面示例中的 getSinCos()
函数调用:
getSinCos(degrees, sin, cos);
从这个函数调用中,不清楚 degrees
是输入参数,而 sin
和 cos
是输出参数。如果调用者没有意识到 sin
和 cos
将被修改,很可能会导致语义错误。
通过使用地址传递而不是引用传递,在某些情况下可以使输出参数更加明显,因为需要调用者传递对象的地址作为实参。
考虑以下示例:
void foo1(int x); // 通过值传递
void foo2(int& x); // 通过引用传递
void foo3(int* x); // 通过地址传递
int main()
{
int i{};
foo1(i); // 不能修改 i
foo2(i); // 可以修改 i(不明显)
foo3(&i); // 可以修改 i
int* ptr{ &i };
foo3(ptr); // 可以修改 i(不明显)
return 0;
}
注意,在调用 foo3(&i)
时,我们必须传递 &i
而不是 i
,这有助于更清楚地表明我们期望 i 被修改。
然而,这并不是万无一失的,因为 foo3(ptr)
允许 foo3()
修改 i,而不需要调用者取 ptr 的地址。
调用者可能还会认为他们可以将 nullptr
或空指针作为有效实参传递,而这是被禁止的。而且函数现在需要进行空指针检查和处理,这增加了更多的复杂性。这种对空指针检查的需求通常会导致比坚持使用引用传递更多的问题。
由于所有这些原因,除非没有其他好的选择,否则应避免使用输出参数。
最佳实践 避免使用输出参数(除非在没有更好选择的罕见情况下)。 对于非可选输出参数,优先通过引用传递。
输入/输出参数(in/out parameters)
在极少数情况下,函数实际上会在覆盖其值之前使用输出参数的值。这样的参数被称为输入/输出参数(in-out parameters)。输入/输出参数的工作方式与输出参数完全相同,并且具有所有相同的挑战。
何时通过非常量引用传递
如果你打算通过引用传递以避免复制实参,你应该几乎总是通过 const 引用传递。
(作者注:在以下示例中,我们用 Foo 表示某种我们关心的类型。目前,你可以将 Foo 想象为你选择的类型的别名(例如 std::string
)。)
然而,有以下两种主要情况,通过非常量引用传递可能是更好的选择。
- 当形参是输入/输出参数时,使用通过非常量引用传递。因为我们已经将需要的对象传递进来以便返回,直接修改该对象通常更直接且性能更好。
void someFcn(Foo& inout)
{
// 修改 inout
}
int main()
{
Foo foo{};
someFcn(foo); // 调用后 foo 被修改,可能不明显
return 0;
}