第 15 章

函数参数与返回值:值、指针、引用与输出参数

按值传递给的是副本,按指针与按引用才能改到调用者的变量;把参数当输出通道是 C 风格接口的常见做法。返回值默认也是拷贝,但编译器会用返回值优化把拷贝省掉。

Parameters and Return Values: By Value, Pointer, Reference, and Output Parameters

Passing by value hands over a copy; only pointers and references can modify the caller's variable. Treating a parameter as an output channel is the usual C-style interface. Returns are copies by default, but the compiler's return-value optimization removes the copy.

首发于 2022.02.17中 / EN
系统课程cppfunctionspass-by-valuepass-by-referencervo
语言
字号
目录
篇内目录

这是 31 小时长视频的第 15 章,对应时间轴 15:42:29–16:34:13。上一章留下一个问题:按值传递只给副本,那怎样从函数里改到调用者的变量?这一章给出三种传递方式、一种输出通道,以及返回值的拷贝语义。

这一章在讲什么

数据的流动有两个方向:

  • 进去:实参 → 形参。三种方式——按值、按指针、按引用。
  • 出来:函数 → 调用者。两种方式——return,或者把参数当「输出通道」。

一条主线贯穿全章:能不能改到原变量,取决于你拿到的是副本、地址,还是别名。 课件反复用同一个例子 say_age 对比三者的内存地址,把这一点讲成了可观察的现象。

时间轴

时间内容
15:42:29按值传递:函数拿到副本
15:45:11VS Code:pass by value,对比函数内外的地址
15:47:57打印 age 前后的值,证明原变量未变
15:50:29按指针传递:形参是指针,调用时传地址
15:53:11VS Code:pass by pointer
15:56:09忘记解引用的踩坑现场
15:57:45按引用传递:形参是引用,调用时直接传变量
15:59:23VS Code:pass by reference,内外地址相同
16:03:26从函数取出数据:输出参数
16:03:56输入参数 + 输出参数(max_str、max_int、max_double)
16:08:04VS Code:input and output parameters
16:14:17用指针作输出参数的版本
16:17:48通过返回值取数据
16:18:57局部变量与返回值的生命周期
16:21:28返回值优化:编译器省掉拷贝
16:23:46VS Code:returning from functions by value
16:32:34下一章预告:函数重载

按值传递:给的是副本

void say_age(int age) {
    ++age;
    std::cout << "Hello you are " << age << " years old\n";
}

int main() {
    int age{23};
    std::cout << "before call: " << age << " at " << &age << "\n";
    say_age(age);
    std::cout << "after call:  " << age << " at " << &age << "\n";
}

输出:

before call: 23 at 0x...dec
Hello you are 24 years old
after call:  23 at 0x...dec

函数内部打印的地址是 0x...dc0,外部是 0x...dec——两个不同的内存位置。++age 改的是副本,函数返回时副本销毁。

为什么默认按值:对 int、double 这类小的基本类型,拷贝的代价几乎为零,而且函数无法意外修改调用者的数据。默认选择是安全的。

按指针传递:给的是地址

void say_age(int* age) {      // 形参是指针
    ++(*age);                 // 必须解引用才能改到原变量
    std::cout << "Hello you are " << *age << " years old\n";
}

int main() {
    int age{23};
    say_age(&age);            // 调用时传地址
    std::cout << "after call: " << age << "\n";   // 24
}

三处语法必须对齐:

位置写法原因
声明void say_age(int* age);形参类型是指针
定义void say_age(int* age) { ... }同上
调用say_age(&age);指针里要存地址
使用*age通过解引用访问原变量

课件现场漏写了一个解引用(打印时写成 age 而不是 *age),编译器不报错但输出不对——指针忘记解引用是这类代码最常见的错误。

按引用传递:给的是别名

void say_age(int& age) {      // 形参是引用
    ++age;                    // 直接改,无需解引用
    std::cout << "Hello you are " << age << " years old\n";
}

int main() {
    int age{23};
    say_age(age);             // 调用时直接传变量
    std::cout << "after call: " << age << "\n";   // 24
}

引用是变量的真正的别名:函数内 &age 与 main 里的 &age 是同一个地址。相比指针,引用省掉了两处噪音——调用时不用 &,使用时不用 *。

三种方式的对比

方式形参写法调用写法能否改原变量典型用途
按值int agesay_age(age)不能小对象、只读输入
按指针int* agesay_age(&age)能(需 *)可选参数(可传 nullptr)、C 接口
按引用int& agesay_age(age)能输出参数、大对象只读输入用 const&

现代 C++ 的默认建议:能按引用就不要按指针,因为引用不可能为空,语义更清楚;只有「参数可以缺席」时才用指针加 nullptr。

输出参数:把参数当出口

C 语言没有引用,函数只能返回一个值。当需要「同时返回多个结果」时,惯用做法是把额外的结果写进参数:

void max_str(const std::string& input1,
             const std::string& input2,
             std::string& output) {
    if (input1 > input2) output = input1;
    else                 output = input2;
}

三个参数的角色一眼可见:

  • 前两个是 const 引用:只读输入,避免拷贝大字符串;
  • 第三个是 非 const 引用:输出通道,函数内写入、调用者可见。
std::string out;
std::string s1{"Alabama"};
std::string s2{"Bellevue"};
max_str(s1, s2, out);          // out == "Bellevue"

如果把第三个参数也标成 const,函数体的赋值会直接编译失败——const 就是在这里守住「输入参数不被改动」这条规矩的。

用指针作输出参数

同样的模式可以换成指针:

void max_double(const double& input1,
                const double& input2,
                double* output) {
    if (input1 > input2) *output = input1;
    else                 *output = input2;
}

double out{};
max_double(45.8, 6.9, &out);   // 调用时传地址

这就是很多 C 接口的样子:返回值表示成功/失败,真正的结果经由指针参数带出。读这类代码时,看到非 const 指针参数,先怀疑它是输出参数。

什么时候该用返回值

如果只需要一个结果,return 更自然:

int sum(int a, int b) {
    int result = a + b;    // 局部变量
    return result;
}

result 是函数内的局部变量,函数结束即销毁;返回时它的值被拷贝到调用者的变量里。课件通过打印两边的地址证明这一点:函数内 0x...dac,main 里 0x...de4,确实是两块内存。

返回值优化:默认拷贝,但编译器会插手

对于基本类型,拷贝无所谓;对于 std::string 这种可能很长的对象,拷贝一次就是一次分配。

std::string add_str(const std::string& s1, const std::string& s2) {
    std::string result = s1 + s2;
    std::cout << "inside:  " << &result << "\n";
    return result;
}

int main() {
    std::string a{"hello"}, b{"world"};
    std::string out = add_str(a, b);
    std::cout << "outside: " << &out << "\n";
}

两个地址相同。按语言规则,return result; 是按值返回,应该产生拷贝;但编译器看到局部变量 result 在函数返回后立刻销毁,就直接在调用者提供的位置上构造它,省掉一次拷贝。这叫返回值优化(RVO / NRVO)。

课件给出的结论值得记住:

不要依赖「返回值一定发生拷贝」这个假设。 编译器发现能做得更好时,就会优化掉拷贝。代码的正确性不应建立在「这里一定有一次深拷贝」之上。

本章结论

  • 按值传递给副本,函数内修改不影响调用者;小类型默认按值即可。
  • 按指针传递传地址,使用时必须解引用;指针可以为 nullptr,适合「参数可缺席」。
  • 按引用传递是真正的别名,调用与使用都无需 &/*,是现代 C++ 的首选。
  • 输入参数用 const(大对象用 const&),输出参数用非 const 引用或指针,角色一目了然。
  • 多个结果可以靠输出参数带出,这是 C 风格接口的常见形态。
  • return 默认按值返回,但编译器会做返回值优化;不要依赖拷贝发生。

理解检验

1. 三个 say_age 版本分别在什么情况下能改到 main 里的 age?

参考答案:void say_age(int age) 不能,因为函数拿到的是副本;void say_age(int* age) 能,前提是调用时传 &age,并在函数内写 ++(*age);void say_age(int& age) 能,引用是原变量的别名,直接 ++age 即可。三者的区别不在「能不能」,而在「拿到的是副本、地址还是别名」。

2. 为什么输出参数不能标 const?输入参数为什么最好标 const?

参考答案:输出参数要在函数体内被赋值,const 引用或 const 指针禁止写入,赋值会直接编译失败。输入参数只读,标 const 把「不修改」写进类型里:函数体一旦误写就编译报错,调用者也因此知道传进去的值不会被改。对大对象(如 std::string)用 const&,既避免拷贝又保证只读。

3. 下面这个函数有什么问题?

void max_of(int a, int b, int out) {
    out = (a > b) ? a : b;
}

参考答案:out 是按值传递,函数内改的是副本,函数返回后副本销毁,调用者的变量不会得到结果。应改为 int& out(或 int* out 并在调用时传地址、函数内解引用)。此外该函数没有任何返回值,调用者也无从得知失败与否。

4. 指针作参数时,最常见的错误是什么?编译器会拦住它吗?

参考答案:最常见的是忘记解引用,例如把 ++(*age) 写成 ++age——后者移动的是指针本身,不是它指向的值。编译器通常不会报错(类型上合法),只是行为错误,往往表现为输出不对或指针跑到数组外。这也是优先使用引用的理由之一:引用没有「解引用」这一步,少一类错误。

5. 为什么 add_str 返回 std::string 时,函数内外的地址可能相同?这与「按值返回」矛盾吗?

参考答案:不矛盾。按值返回是语言层面的语义,编译器必须让调用者得到一个独立可用的对象;但当它发现返回的是即将销毁的局部变量时,可以直接在调用者的存储位置上构造该对象,省去拷贝。这就是返回值优化(RVO/NRVO),属于「as-if」规则允许的优化:可观察行为不变。代码不应依赖「这里发生过一次拷贝」。

与前后章的关系

上一章讲清楚「按值传递只给副本」,本章把替代方案补齐:指针、引用、输出参数与返回值。下一章讨论函数重载——同一个函数名配不同的参数列表,编译器按实参类型挑选最合适的那一个。理解了签名与参数类型,重载决议才有基础。

This is chapter 15 of a 31-hour single video, covering 15:42:29–16:34:13. The previous chapter left a question open: pass by value only gives a copy, so how can a function modify the caller's variable? This chapter supplies three passing modes, one output channel, and the copy semantics of returns.

What this chapter covers

Data flows in two directions:

  • In: arguments to parameters. Three modes — by value, by pointer, by reference.
  • Out: function to caller. Two mechanisms — return, or a parameter used as an output channel.

One thread runs through the chapter: whether you can modify the original depends on whether you hold a copy, an address, or an alias. The lecture reuses say_age and compares memory addresses to make this observable.

Timeline

TimeTopic
15:42:29Pass by value: the function receives a copy
15:45:11In VS Code: pass by value, comparing addresses inside and outside
15:47:57Printing age before and after to show the original is unchanged
15:50:29Pass by pointer: the parameter is a pointer, the call passes an address
15:53:11In VS Code: pass by pointer
15:56:09The missing-dereference mistake, live
15:57:45Pass by reference: the parameter is a reference
15:59:23In VS Code: pass by reference, identical addresses
16:03:26Getting data out of a function: output parameters
16:03:56Input plus output parameters (max_str, max_int, max_double)
16:08:04In VS Code: input and output parameters
16:14:17The pointer version of an output parameter
16:17:48Getting data out through the return value
16:18:57Local variables and the lifetime of a returned value
16:21:28Return-value optimization removes the copy
16:23:46In VS Code: returning from functions by value
16:32:34Preview of the next chapter: function overloading

Pass by value: a copy is handed over

void say_age(int age) {
    ++age;
    std::cout << "Hello you are " << age << " years old\n";
}

int main() {
    int age{23};
    std::cout << "before call: " << age << " at " << &age << "\n";
    say_age(age);
    std::cout << "after call:  " << age << " at " << &age << "\n";
}

Output:

before call: 23 at 0x...dec
Hello you are 24 years old
after call:  23 at 0x...dec

The address printed inside the function is 0x...dc0, the one outside is 0x...dec — two different memory locations. ++age modifies the copy, which dies on return.

Why value is the default: for small fundamental types such as int and double the copy is essentially free, and the function cannot accidentally modify the caller's data. The default is the safe choice.

Pass by pointer: an address is handed over

void say_age(int* age) {      // parameter is a pointer
    ++(*age);                 // must dereference to reach the original
    std::cout << "Hello you are " << *age << " years old\n";
}

int main() {
    int age{23};
    say_age(&age);            // the call passes an address
    std::cout << "after call: " << age << "\n";   // 24
}

The syntax has to line up in four places:

PlaceFormWhy
Declarationvoid say_age(int* age);the parameter type is a pointer
Definitionvoid say_age(int* age) { ... }same
Callsay_age(&age);a pointer stores an address
Use*agedereference to reach the variable

The lecture drops a dereference live (printing age instead of *age); the compiler stays silent but the output is wrong. Forgetting the dereference is the classic bug in this style.

Pass by reference: an alias is handed over

void say_age(int& age) {      // parameter is a reference
    ++age;                    // no dereference needed
    std::cout << "Hello you are " << age << " years old\n";
}

int main() {
    int age{23};
    say_age(age);             // pass the variable directly
    std::cout << "after call: " << age << "\n";   // 24
}

A reference is a true alias: &age inside the function equals &age in main. Compared with pointers it removes two pieces of noise — no & at the call, no * at the use.

The three modes side by side

ModeParameterCallCan modify originalTypical use
By valueint agesay_age(age)nosmall objects, read-only input
By pointerint* agesay_age(&age)yes (*)optional arguments (nullptr), C interfaces
By referenceint& agesay_age(age)yesoutput parameters, large read-only input as const&

Modern C++ guidance: prefer a reference to a pointer when possible, because a reference cannot be null and says so in the type; use a pointer only when the argument may be absent, expressed as nullptr.

Output parameters: using a parameter as an exit

C has no references, and a function returns a single value. When several results are needed, the idiom is to write the extras into parameters:

void max_str(const std::string& input1,
             const std::string& input2,
             std::string& output) {
    if (input1 > input2) output = input1;
    else                 output = input2;
}

The three roles are visible at a glance:

  • the first two are const references: read-only input, avoiding copies of large strings;
  • the third is a non-const reference: the output channel, written inside and visible to the caller.
std::string out;
std::string s1{"Alabama"};
std::string s2{"Bellevue"};
max_str(s1, s2, out);          // out == "Bellevue"

Mark the third as const and the assignment in the body fails to compile — that is exactly where const enforces "input parameters are not modified".

Pointer output parameters

The same pattern works with a pointer:

void max_double(const double& input1,
                const double& input2,
                double* output) {
    if (input1 > input2) *output = input1;
    else                 *output = input2;
}

double out{};
max_double(45.8, 6.9, &out);   // pass an address

This is what many C interfaces look like: the return value reports success or failure, and the real result travels through a pointer parameter. When reading such code, a non-const pointer parameter is very likely an output parameter.

When a return value is better

If there is only one result, return is the natural form:

int sum(int a, int b) {
    int result = a + b;    // local variable
    return result;
}

result is local, destroyed when the function ends; on return its value is copied into the caller's variable. The lecture proves this by printing both addresses: 0x...dac inside, 0x...de4 in main — genuinely two blocks of memory.

Return-value optimization: copies by default, but the compiler intervenes

Copying is free for fundamental types; for a std::string it may be an allocation.

std::string add_str(const std::string& s1, const std::string& s2) {
    std::string result = s1 + s2;
    std::cout << "inside:  " << &result << "\n";
    return result;
}

int main() {
    std::string a{"hello"}, b{"world"};
    std::string out = add_str(a, b);
    std::cout << "outside: " << &out << "\n";
}

The two addresses are identical. By the language rules return result; is a by-value return and should copy; but the compiler sees that result dies immediately and constructs the object directly in the caller's storage, skipping the copy. This is the return-value optimization (RVO / NRVO).

The lecture's conclusion is worth keeping:

Do not build code on the assumption that a return copies. When the compiler can do better, it removes the copy. Correctness must not depend on "a deep copy happens here".

Takeaways

  • Pass by value gives a copy; changes do not reach the caller. Fine for small types.
  • Pass by pointer passes an address and requires dereferencing; it can be nullptr, which suits optional arguments.
  • Pass by reference is a true alias; no & or * at call or use, and it is the modern default.
  • Mark inputs const (large ones const&); use non-const references or pointers for outputs so roles are obvious.
  • Several results can travel through output parameters — the common shape of C-style interfaces.
  • return copies by default, but the compiler applies return-value optimization; do not rely on the copy.

Comprehension check

1. In which situations can each of the three say_age versions modify main's age?

Reference answer: void say_age(int age) cannot, because the function holds a copy; void say_age(int* age) can, provided the call passes &age and the body writes ++(*age); void say_age(int& age) can, because the reference is an alias for the original and ++age suffices. The difference is not whether modification is possible in principle but whether the parameter holds a copy, an address, or an alias.

2. Why must an output parameter not be const, and why should input parameters be const?

Reference answer: an output parameter is assigned inside the body, and a const reference or pointer forbids writing, so the assignment fails to compile. An input parameter is read-only; marking it const encodes "not modified" in the type, so any accidental write inside the function is a compile error and callers know their argument will not change. For large objects such as std::string, const& avoids a copy while keeping the guarantee.

3. What is wrong with this function?

void max_of(int a, int b, int out) {
    out = (a > b) ? a : b;
}

Reference answer: out is passed by value, so the assignment modifies a copy that is destroyed on return and the caller's variable never receives the result. It should be int& out (or int* out, passing an address and dereferencing inside). The function also returns nothing, so the caller cannot even detect failure.

4. What is the most common mistake with pointer parameters, and will the compiler catch it?

Reference answer: forgetting to dereference — writing ++age instead of ++(*age) moves the pointer rather than the value it points to. This is usually legal C++ and the compiler stays silent; the behaviour is simply wrong, often showing up as bad output or a pointer walking off the array. That is one reason to prefer references: with a reference there is no dereference step to forget.

5. Why can the addresses inside and outside add_str be identical? Does that contradict "return by value"?

Reference answer: it does not contradict it. Return by value is a language-level guarantee that the caller receives an independent, usable object. When the compiler sees that the returned local is about to be destroyed, it may construct the object directly in the caller's storage, eliminating the copy. That is the return-value optimization (RVO/NRVO), permitted by the as-if rule because observable behaviour is unchanged. Code must not depend on a copy having occurred.

Relation to neighbouring chapters

The previous chapter established that pass by value hands over a copy; this chapter fills in the alternatives — pointers, references, output parameters, and return values. The next chapter covers function overloading: one function name with several parameter lists, from which the compiler selects the best match. Understanding signatures and parameter types is the prerequisite for overload resolution.

来源