12-12 按引用返回和按地址返回

在之前的课程中,我们讨论过,按值传递参数时,会将参数复制到函数参数中。对于基本类型(复制成本很低),这没有问题。但对于类类型(例如 std::string ),复制通常成本很高。我们可以通过使用按引用(或按地址)传递来避免昂贵的复制操作。

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

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;
}

该程序会输出:

image

因为getProgramName() 返回的是一个常量引用,所以当执行该行代码return s_programName时,它getProgramName() 会返回一个指向 s_programName 的常量引用(从而避免复制)。调用者随后可以使用该常量引用来访问 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;
}

image

该程序的运行结果是未定义的。当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;
}

image

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

当调用者作用域内的临时引用绑定到常量引用变量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;
}

image

clang识别不出来。

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

警告
引用生命周期延长在跨函数边界不起作用。

不要通过引用返回非常量静态局部变量。

在上面的示例中,我们通过引用返回了一个 const 静态局部变量,以简单明了地说明按引用返回的机制。然而,通过引用返回非常量静态局部变量是相当不符合惯用法的,通常应该避免。以下是一个简化的示例,说明了可能出现的此类问题:

#include <iostream>

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;
}

该程序会输出:

image

这是因为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;
}

以下是该程序运行一次的结果:

image

在这个例子中,getName()返回对静态局部变量s_name的引用。用对静态局部变量s_name的引用初始化一个变量const std::string&会导致该变量std::string&绑定到静态局部变量s_name(而不是创建它的副本)。
因此,两者s1和s2(最终都会看到被分配了我们输入的最后一个s_name)。
请注意,如果我们改用参数std::string_view,则当底层std::string发生变化时,第一个参数std::string_view将失效。

另一个常见的问题是,当程序通过引用返回非常量静态局部变量时,没有标准化的方法将其s_x重置为默认状态。这类程序要么必须使用非常规的解决方案(例如,使用重置函数参数),要么只能通过退出并重新启动程序来重置。

最佳实践
避免返回对非常量局部静态变量的引用。

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

返回对常量全局变量的常量引用有时也是一种封装对全局变量访问的方式。我们将在7.8 课“为什么(非常量)全局变量是邪恶的”中讨论这一点。如果使用得当且谨慎,这种做法也是可以的。

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

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

#include <iostream>

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 是非引用变量。在这种情况下,返回的引用的值会被复制到普通变量中。因此,该程序会输出:

image

另请注意,如果程序返回一个悬空引用,则在复制之前该引用将一直处于悬空状态,这将导致未定义行为:

#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;
}

image

通过引用返回引用参数是可以的。

有很多情况下,按引用返回对象是合理的,我们将在以后的课程中遇到很多这样的例子。不过,我们现在可以先展示一个有用的例子。

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

以下是此类函数的一个简单示例:

#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;
}

打印出来的内容:

image

在上面的函数中,调用者通过常量引用传入了两个 std::string 对象,并将字母顺序靠前的字符串以常量引用的方式返回。如果我们使用按值传递和按值返回的方式,就会创建多达 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;
}

image

唯一的区别在于,右值先以常量引用的形式传递给foo()调用者,然后在用于初始化之前再以常量引用的形式返回给调用者s。其他所有操作都完全相同。

我们在第14.6 课——访问函数中讨论了一个类似的案例。

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

当通过非常量引用将参数传递给函数时,该函数可以使用该引用来修改参数的值。

同样地,当一个函数返回一个非常量引用时,调用者可以使用该引用来修改返回的值。

以下是一个示例:

#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()函数时传入参数a和b。引用参数x绑定到参数a,引用参数y绑定到参数b。然后,函数判断x和y哪个更大。在本例中, y更大,因此函数将(仍然绑定到b)返回给调用者。调用者随后将 值7赋给返回的引用。

因此,该表达式max(a, b) = 7实际上可以分解为b = 7。

打印出来的内容:

image

按地址返回

按地址返回与按引用返回几乎完全相同,区别在于前者返回的是指向对象的指针,而非指向对象的引用。按地址返回与按引用返回的主要缺陷相同——被返回的对象必须在返回该地址的函数的作用域之外仍然有效,否则调用者将收到一个悬空指针。

按地址返回相比按引用返回的主要优势在于,如果找不到有效对象,函数可以直接返回nullptr。例如,假设我们有一个学生列表,需要进行搜索。如果列表中找到了我们要查找的学生,我们可以返回指向该学生对象的指针。如果没有找到匹配的学生,我们可以直接返回nullptr,表明没有找到匹配的学生对象。

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

最佳实践
除非返回“无对象”(使用)的功能nullptr很重要,否则应优先按引用返回,而不是按地址返回。

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

相关内容
有关何时返回与何时返回std::string_view vs const std::string&的快速指南,请参阅5.9 std::string_view(第 2 部分)。

posted @ 2025-12-18 15:52  游翔  阅读(18)  评论(0)    收藏  举报