12.12 — 按引用返回和按地址返回

在之前的课程中,我们讨论过,当按值传递参数时,参数的副本会被复制到函数参数中。对于基本类型(复制开销小),这没问题。但对于类类型(如 std::string),复制通常开销很大。我们可以通过使用按(const)引用传递(或按地址传递)来避免昂贵的复制。

当按值返回时,我们会遇到类似的情况:返回值的副本会被传递回调用者。如果函数的返回类型是类类型,这可能会很昂贵。

std::string returnByValue(); // returns a copy of a std::string (expensive)

按引用返回

在我们将类类型返回给调用者的情况下,我们可能(或可能不)希望按引用返回。按引用返回返回一个绑定到被返回对象的引用,这避免了创建返回值的副本。要按引用返回,我们只需将函数的返回值定义为引用类型即可。

std::string&       returnByReference(); // returns a reference to an existing std::string (cheap)
const std::string& returnByReferenceToConst(); // returns a const reference to an existing std::string (cheap)

这是一个演示按引用返回机制的学术程序。

#include <iostream>
#include <string>

const std::string& getProgramName() // returns a const reference
{
    static const std::string s_programName { "Calculator" }; // has static duration, destroyed at end of program

    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" }; // now a non-static local variable, destroyed when function ends

    return programName;
}

int main()
{
    std::cout << "This program is named " << getProgramName(); // undefined behavior

    return 0;
}

此程序的结果是未定义的。当 getProgramName() 返回时,会返回一个绑定到局部变量 programName 的引用。然后,因为 programName 是一个具有自动持续时间的局部变量,programName 在函数结束时被销毁。这意味着返回的引用现在悬空,在 main() 函数中使用 programName 会导致未定义行为。

如果你尝试按引用返回局部变量,现代编译器会产生警告或错误(因此上面的程序甚至可能无法编译),但编译器有时难以检测更复杂的情况。

警告

按引用返回的对象必须在返回引用的函数的范围之外存在,否则将导致悬空引用。切勿按引用返回(非静态)局部变量或临时对象。

生命周期延长不跨函数边界工作

让我们看一个按引用返回临时对象的例子。

#include <iostream>

const int& returnByConstReference()
{
    return 5; // returns const reference to temporary object
}

int main()
{
    const int& ref { returnByConstReference() };

    std::cout << ref; // undefined behavior

    return 0;
}

在上述程序中,returnByConstReference() 返回一个整数字面量,但函数的返回类型是 const int&。这导致创建并返回一个绑定到持有值 5 的临时对象的临时引用。此返回的引用被复制到调用者范围内的临时引用中。然后临时对象超出作用域,使调用者范围内的临时引用悬空。

当调用者范围内的临时引用绑定到 const 引用变量 ref(在 main() 中)时,延长临时对象的生命周期已经太晚了——因为它已经被销毁。因此 ref 是一个悬空引用,使用 ref 的值将导致未定义行为。

这是一个不那么明显的、同样不起作用的例子。

#include <iostream>

const int& returnByConstReference(const int& ref)
{
    return ref;
}

int main()
{
    // case 1: direct binding
    const int& ref1 { 5 }; // extends lifetime
    std::cout << ref1 << '\n'; // okay

    // case 2: indirect binding
    const int& ref2 { returnByConstReference(5) }; // binds to dangling reference
    std::cout << ref2 << '\n'; // undefined behavior

    return 0;
}

在情况 2 中,创建了一个临时对象来保存值 5,函数参数 ref 绑定到该对象。函数只是将此引用返回给调用者,然后调用者使用该引用初始化 ref2。因为这不是与临时对象的直接绑定(因为引用是通过函数跳过的),所以生命周期延长不适用。这导致 ref2 悬空,其后续使用是未定义的行为。

警告

引用生命周期延长不跨函数边界工作。

不要按引用返回非 const 静态局部变量。

在上面的原始示例中,我们按引用返回了一个 const 静态局部变量,以一种简单的方式说明按引用返回的机制。然而,按引用返回非 const 静态局部变量是非常非惯用的,通常应避免。下面是一个简化示例,说明了可能发生的一个此类问题。

#include <iostream>
#include <string>

const int& getNextId()
{
    static int s_x{ 0 }; // note: variable is non-const
    ++s_x; // generate the next id
    return s_x; // and return a reference to it
}

int main()
{
    const int& id1 { getNextId() }; // id1 is a reference
    const int& id2 { getNextId() }; // id2 is a reference

    std::cout << id1 << id2 << '\n';

    return 0;
}

这个程序打印

22

发生这种情况是因为 id1id2 引用同一个对象(静态变量 s_x),所以当任何东西(例如 getNextId())修改该值时,所有引用现在都访问修改后的值。

上述示例可以通过将 id1id2 设为普通变量(而不是引用)来修复,以便它们保存返回值的副本而不是对 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(而不是创建它的副本)。

因此,s1s2 都最终查看 s_name(它被赋予了我们输入的最后一个名称)。

请注意,如果我们改用 std::string_view 参数,当底层 std::string 改变时,第一个 std::string_view 参数将失效。

返回非 const 静态局部变量引用的程序经常遇到的另一个问题是,没有标准化的方法可以将 s_x 重置回默认状态。此类程序必须使用非传统解决方案(例如重置函数参数),或者只能通过退出并重新启动程序来重置。

最佳实践

避免返回对非 const 局部静态变量的引用。

有时会返回对*常量*局部静态变量的 const 引用,如果被按引用返回的局部变量创建和/或初始化成本很高(这样我们就不必在每次函数调用时重新创建变量)。但这很少见。

有时也会返回对*常量*全局变量的 const 引用,作为封装访问全局变量的一种方式。我们在第 7.8 课 -- 为什么(非 const)全局变量是邪恶的中讨论了这一点。如果故意小心使用,这也是可以的。

用返回的引用给普通变量赋值/初始化会创建一个副本

如果一个函数返回一个引用,并且该引用用于初始化或赋值给一个非引用变量,则返回值将被复制(就像它是按值返回一样)。

#include <iostream>
#include <string>

const int& getNextId()
{
    static int s_x{ 0 };
    ++s_x;
    return s_x;
}

int main()
{
    const int id1 { getNextId() }; // id1 is a normal variable now and receives a copy of the value returned by reference from getNextId()
    const int id2 { getNextId() }; // id2 is a normal variable now and receives a copy of the value returned by reference from getNextId()

    std::cout << id1 << id2 << '\n';

    return 0;
}

在上面的示例中,getNextId() 返回一个引用,但 id1id2 是非引用变量。在这种情况下,返回引用的值被复制到普通变量中。因此,此程序打印:

12

还要注意,如果程序返回一个悬空引用,那么在进行复制之前,该引用就会悬空,这将导致未定义行为。

#include <iostream>
#include <string>

const std::string& getProgramName() // will return a const reference
{
    const std::string programName{ "Calculator" };

    return programName;
}

int main()
{
    std::string name { getProgramName() }; // makes a copy of a dangling reference
    std::cout << "This program is named " << name << '\n'; // undefined behavior

    return 0;
}

按引用返回引用参数是可以的。

有许多情况下按引用返回对象是有意义的,我们将在未来的课程中遇到许多此类情况。但是,现在我们可以展示一个有用的例子。

如果一个参数通过引用传递给函数,那么通过引用返回该参数是安全的。这很有道理:为了将参数传递给函数,参数必须存在于调用者的作用域中。当被调用的函数返回时,该对象仍必须存在于调用者的作用域中。

这是一个此类函数的简单示例。

#include <iostream>
#include <string>

// Takes two std::string objects, returns the one that comes first alphabetically
const std::string& firstAlphabetical(const std::string& a, const std::string& b)
{
	return (a < b) ? a : b; // We can use operator< on std::string to determine which comes first alphabetically
}

int main()
{
	std::string hello { "Hello" };
	std::string world { "World" };

	std::cout << firstAlphabetical(hello, world) << '\n';

	return 0;
}

这会打印

Hello

在上面的函数中,调用者通过 const 引用传入两个 std::string 对象,其中按字母顺序排在前面的字符串会通过 const 引用传回。如果我们使用按值传递和按值返回,我们最多会创建 3 个 std::string 副本(每个参数一个,返回值一个)。通过使用按引用传递/按引用返回,我们可以避免这些副本。

通过 const 引用传递的右值可以安全地通过 const 引用返回。

当一个 const 引用参数的实参是一个右值时,仍然可以通过 const 引用返回该参数。

这是因为右值直到创建它们的完整表达式结束时才会被销毁。

首先,让我们看看这个例子。

#include <iostream>
#include <string>

std::string getHello()
{
    return "Hello"; // implicit conversion to std::string
}

int main()
{
    const std::string s{ getHello() };

    std::cout << s;
    
    return 0;
}

在这种情况下,getHello() 按值返回一个 std::string,它是一个右值。这个右值接着被用来初始化 s。在 s 初始化之后,创建右值的表达式已经完成求值,右值被销毁。

现在让我们看一个类似的例子。

#include <iostream>
#include <string>

const std::string& foo(const std::string& s)
{
    return s;
}

std::string getHello()
{
    return "Hello"; // implicit conversion to std::string
}

int main()
{
    const std::string s{ foo(getHello()) };

    std::cout << s;
    
    return 0;
}

在这种情况下,唯一的区别是右值通过 const 引用传递给 foo(),然后通过 const 引用返回给调用者,然后才用于初始化 s。其他一切都完全相同。

我们在第 14.6 课 -- 访问函数中讨论了类似的情况。

调用者可以通过引用修改值

当一个参数通过非 const 引用传递给函数时,函数可以使用该引用修改参数的值。

类似地,当一个非 const 引用从函数返回时,调用者可以使用该引用修改被返回的值。

这是一个说明性示例。

#include <iostream>

// takes two integers by non-const reference, and returns the greater by reference
int& max(int& x, int& y)
{
    return (x > y) ? x : y;
}

int main()
{
    int a{ 5 };
    int b{ 6 };

    max(a, b) = 7; // sets the greater of a or b to 7

    std::cout << a << b << '\n';
        
    return 0;
}

在上面的程序中,max(a, b) 调用 max() 函数,其中 ab 作为参数。引用参数 x 绑定到参数 a,引用参数 y 绑定到参数 b。函数然后确定 x (5) 和 y (6) 中哪个更大。在这种情况下,是 y,所以函数将 y(它仍然绑定到 b)返回给调用者。然后调用者将值 7 赋值给这个返回的引用。

因此,表达式 max(a, b) = 7 有效地解析为 b = 7

这会打印

57

按地址返回

按地址返回的工作方式几乎与按引用返回相同,只是返回的是对象的指针而不是对象的引用。按地址返回与按引用返回具有相同的主要注意事项——按地址返回的对象必须比返回地址的函数的范围更长,否则调用者将收到一个悬空指针。

按地址返回相对于按引用返回的主要优点是,如果没有有效的对象可返回,我们可以让函数返回 nullptr。例如,假设我们有一个学生列表要搜索。如果我们找到我们要找的学生,我们可以返回一个指向代表匹配学生的对象的指针。如果找不到任何匹配的学生,我们可以返回 nullptr 以指示未找到匹配的学生对象。

按地址返回的主要缺点是,调用者必须记住在解引用返回值之前进行 nullptr 检查,否则可能会发生空指针解引用并导致未定义行为。由于此危险,除非需要返回“无对象”的能力,否则应优先选择按引用返回而不是按地址返回。

最佳实践

除非返回“无对象”(使用 nullptr)的能力很重要,否则优先选择按引用返回而不是按地址返回。

相关内容

如果你需要返回“无对象”或值(而不是对象)的能力,12.15 -- std::optional 描述了一个很好的替代方案。

相关内容

有关何时返回 std::string_viewconst std::string& 的快速指南,请参阅5.9 -- std::string_view(第 2 部分)

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
589 评论
最新
最早 最多投票
内联反馈
查看所有评论