在前面的课程中,我们讨论了当通过值传递参数时,参数的副本会被复制到函数的形参中。对于基本类型(复制成本较低),这没有问题。但对于类类型(如 std::string
),复制通常是昂贵的。我们可以通过使用通过(const)引用传递(或通过地址传递)来避免进行昂贵的复制。
当通过值返回时,我们也会遇到类似的情况:返回值的副本会被传递回调用者。如果函数的返回类型是类类型,这可能是昂贵的。
std::string returnByValue(); // 返回一个 std::string 的副本(昂贵)
通过引用返回
在某些情况下,我们可能(也可能不)希望以引用的方式返回类类型。通过引用返回会返回一个绑定到被返回对象的引用,从而避免了返回值的复制。为了通过引用返回,我们只需将函数的返回值定义为引用类型:
std::string& returnByReference(); // 返回一个现有的 std::string 的引用(便宜)
const std::string& returnByReferenceToConst(); // 返回一个现有的 std::string 的 const 引用(便宜)
下面是一个学术性的程序,用于演示通过引用返回的机制:
#include <iostream>
#include <string>
const std::string& getProgramName() // 返回一个 const 引用
{
static const std::string s_programName { "Calculator" }; // 具有静态持续时间,程序结束时销毁
return s_programName;
}
int main()
{
std::cout << "This program is named " << getProgramName();
return 0;
}
这个程序打印:
This program is named Calculator
因为 getProgramName()
返回一个 const 引用,当执行 return s_programName
时,getProgramName()
将返回一个绑定到 s_programName
的 const 引用(从而避免了复制)。然后,调用者可以使用这个 const 引用来访问 s_programName
的值,该值被打印出来。
被引用返回的对象必须在函数返回后存在
使用通过引用返回有一个主要的注意事项:程序员必须确保被引用的对象在返回引用的函数之后仍然存在。否则,返回的引用将变成悬垂引用(引用了一个已经被销毁的对象),使用这个引用将导致未定义行为。
在上面的程序中,因为 s_programName
具有静态持续时间,s_programName
将一直存在直到程序结束。当 main()
访问返回的引用时,它实际上访问的是 s_programName
,这是可以的,因为 s_programName
直到稍后才会被销毁。
现在让我们修改上面的程序,以展示当我们的函数返回一个悬垂引用时会发生什么:
#include <iostream>
#include <string>
const std::string& getProgramName()
{
const std::string programName { "Calculator" }; // 现在是一个非静态局部变量,函数结束时销毁
return programName;
}
int main()
{
std::cout << "This program is named " << getProgramName(); // 未定义行为
return 0;
}
这个程序的结果是未定义的。当 getProgramName()
返回时,返回了一个绑定到局部变量 programName
的引用。然后,因为 programName
是一个具有自动持续时间的局部变量,programName
在函数结束时被销毁。这意味着返回的引用现在是悬垂的,main()
函数中对 programName
的使用导致了未定义行为。
现代编译器会在你尝试通过引用返回一个局部变量时产生警告或错误(因此上面的程序可能根本无法编译),但编译器有时难以检测更复杂的情况。
警告
通过引用返回的对象必须在返回引用的函数的作用域之外存在,否则将导致悬垂引用。永远不要通过引用返回(非静态)局部变量或临时变量。
生命周期扩展不适用于函数边界之外
让我们来看一个通过引用返回临时变量的例子:
#include <iostream>
const int& returnByConstReference()
{
return 5; // 返回一个临时对象的 const 引用
}
int main()
{
const int& ref { returnByConstReference() };
std::cout << ref; // 未定义行为
return 0;
}
在上面的程序中,returnByConstReference()
返回了一个整数字面量,但函数的返回类型是 const int&
。这导致创建并返回了一个绑定到值为 5 的临时对象的临时引用。这个返回的引用被复制到调用者作用域中的一个临时引用中。然后,临时对象超出作用域,留下调用者作用域中的临时引用悬垂。
等到调用者作用域中的临时引用绑定到 main()
中的 const 引用变量 ref
时,为临时对象扩展生命周期已经太晚了——因为它已经被销毁了。因此,ref
是一个悬垂引用,使用 ref
的值将导致未定义行为。
这里有一个不太明显的例子,同样行不通:
#include <iostream>
const int& returnByConstReference(const int& ref)
{
return ref;
}
int main()
{
// 情况 1:直接绑定
const int& ref1 { 5 }; // 扩展生命周期
std::cout << ref1 << '\n'; // 好的
// 情况 2:间接绑定
const int& ref2 { returnByConstReference(5) }; // 绑定到悬垂引用
std::cout << ref2 << '\n'; // 未定义行为
return 0;
}
在情况 2 中,创建了一个临时对象来保存值 5,函数参数 ref
绑定到它。函数只是将这个引用返回给调用者,然后调用者使用这个引用来初始化 ref2
。因为这不是对临时对象的直接绑定(因为引用是通过函数“反弹”的),所以生命周期扩展不适用。这使得 ref2
悬垂,其后续使用是未定义行为。
警告
引用的生命周期扩展不适用于函数边界之外。
不要通过引用返回非 const 静态局部变量
在最初的示例中,我们通过引用返回了一个 const 静态局部变量,以简单的方式说明通过引用返回的机制。然而,通过引用返回非 const 静态局部变量是相当非惯用的,通常应该避免。下面是一个简化的示例,说明了可能出现的一个问题:
#include <iostream>
#include <string>
const int& getNextId()
{
static int s_x{ 0 }; // 注意:变量是非 const 的
++s_x; // 生成下一个 ID
return s_x; // 并返回对它的引用
}
int main()
{
const int& id1 { getNextId() }; // id1 是一个引用
const int& id2 { getNextId() }; // id2 是一个引用
std::cout << id1 << id2 << '\n';
return 0;
}
这个程序打印:
22
这是因为 id1
和 id2
都引用了同一个对象(静态变量 s_x
),所以当任何东西(例如 getNextId()
)修改了那个值时,所有引用现在都访问了修改后的值。
上面的示例可以通过将 id1
和 id2
作为普通变量(而不是引用)来修复,这样它们保存了返回值的副本,而不是对 s_x
的引用。
对于高级读者
这里有一个不太明显的相同问题的另一个示例:
#include <iostream>
#include <string>
#include <string_view>
std::string& getName()
{
static std::string s_name{};
std::cout << "Enter a name: ";
std::cin >> s_name;
return s_name;
}
void printFirstAlphabetical(const std::string& s1, const std::string& s2)
{
if (s1 < s2)
std::cout << s1 << " comes before " << s2 << '\n';
else
std::cout << s2 << " comes before " << s1 << '\n';
}
int main()
{
printFirstAlphabetical(getName(), getName());
return 0;
}
这个程序的一个运行结果是:
Enter a name: Dave
Enter a name: Stan
Stan comes before Stan
在这个例子中,getName()
返回了对静态局部变量 s_name
的引用。用对 s_name
的引用初始化一个 const std::string&
会导致这个 std::string&
绑定到 s_name
(而不是复制它)。
因此,s1
和 s2
最终都查看了 s_name
(它被赋予了我们最后输入的名字)。
注意,如果我们使用 std::string_view
参数,当底层的 std::string
被修改时,第一个 std::string_view