函数

掌握函数的定义、调用、参数传递(值/引用/指针/const)、返回类型、重载与内联——把代码组织成可复用的模块

学习目标

  • 能正确定义和调用一个函数——写出返回类型、形参列表和函数体,并能解释实参到形参的值传递过程
  • 能比较并选择值传递、引用传递或 const 引用传递,并通过重载为同一个操作提供不同参数类型的版本
  • 能回答:void reset(int &x) { x = 0; }void reset(int x) { x = 0; } 在调用 int a = 5; reset(a); 之后,a 的值分别是什么?为什么?

机制总览

函数:机制路径

  1. 1

    直觉:你的代码需要一个“加工车间”

    到目前为止你写的程序都可以装在一个 main() 里面——所有代码从上到下一口气跑完。但真实世界的软件不是这样的:一个程序可能有几千行甚至几百万行代码——如果全挤在一个大块里,你找不到哪段代码在干什么、也没法说「同样的逻辑别写两遍,下次直接调用」。

  2. 2

    函数是什么:四个零件拼出一个加工车间

    // 调用 —— 把材料送进去、拿成品出来 int main() int sum = add(3, 5); // 3 和 5 是实参——实际的原材料 std::cout << sum; // 输出 8 return 0;

  3. 3

    参数传递:拷贝还是别名?——这是整章最重要的一节

    上一节说了函数调用时实参会被「拷贝」给形参。但 C++ 给了你三种传递原材料的方式—— 什么时候用哪一种,是这章最重要的决策 。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

函数:机制与证据

切换《函数》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 直觉:你的代码需要一个“加工车间”

到目前为止你写的程序都可以装在一个 main() 里面——所有代码从上到下一口气跑完。但真实世界的软件不是这样的:一个程序可能有几千行甚至几百万行代码——如果全挤在一个大块里,你找不到哪段代码在干什么、也没法说「同样的逻辑别写两遍,下次直接调用」。

可核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:你的代码需要一个“加工车间”」的契约。

学完《函数》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

函数:失效与核验

直觉:你的代码需要一个“加工车间”

典型失效

若把「直觉:你的代码需要一个“加工车间”」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:你的代码需要一个“加工车间”」的契约。

函数是什么:四个零件拼出一个加工车间

典型失效

若把「函数是什么:四个零件拼出一个加工车间」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「函数是什么:四个零件拼出一个加工车间」的契约。

参数传递:拷贝还是别名?——这是整章最重要的一节

典型失效

若把「参数传递:拷贝还是别名?——这是整章最重要的一节」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「参数传递:拷贝还是别名?——这是整章最重要的一节」的契约。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

直觉:你的代码需要一个“加工车间”

到目前为止你写的程序都可以装在一个 main() 里面——所有代码从上到下一口气跑完。但真实世界的软件不是这样的:一个程序可能有几千行甚至几百万行代码——如果全挤在一个大块里,你找不到哪段代码在干什么、也没法说「同样的逻辑别写两遍,下次直接调用」。

想象你要给 100 个不同的零件打孔——你不会每次跑到厂房里手画图纸重新造一台钻床。你会造一台「钻床车间」——画好图纸、标好参数(材料进、钻头型号、打多深),之后每次只消把材料送进去就能拿到打好孔的成品。C++ 里的函数就是你的钻床车间——你把数据送进去,拿到计算结果出来,再也不用重写同样的代码。

如果没有函数会怎样?你会在 main 里反复写一样的逻辑——「计算平方」要写十几遍、「判断素数」每一处都拷贝粘贴——代码又长又难改。本章教你建造自己的加工车间:怎么写、怎么送材料进去、怎么拿成品出来——这是从「写顺序步骤」到「设计可复用的代码模块」的关口。

函数是什么:四个零件拼出一个加工车间

在 C++ 里定义一个函数需要四个零件——缺一个都不行:

——理解成一张车间蓝图:车间名字(函数名)、需要什么原材料(形参列表)、产出什么(返回类型)、以及怎么加工(函数体)。

调用栈(高地址 → 低地址)栈底栈顶 ↑ 调用前main() 正在执行,栈上只有调用方的局部变量 传参实参压栈,保存返回地址(或通过寄存器传递) 执行中CPU 跳到 add() 第一行main() 暂停,等 add() 返回 返回值return 执行——返回值写入寄存器,即将返回 main 栈帧销毁add() 的栈帧被回收——内存还给操作系统,main 继续add() 栈帧形参 x=5, y=3返回地址、局部变量 zint z = x + y;实参: 5, 3(副本)main() 暂停⬜ 栈帧 = 内存中一块区域(存形参、局部变量、返回地址)紫色 = 当前活跃帧
每次函数调用都在栈上分配一块新区域(栈帧),存放形参、局部变量和返回地址。函数返回后它的整个栈帧被销毁——局部变量也随之消失。

把车间蓝图翻译成 C++ 代码——下面是一个完整的函数定义和调用:

// 函数定义 —— 车间蓝图
int add(int x, int y) {          // int: 返回类型,return 的东西的类型
    int result = x + y;          // 形参 x, y: 原材料;函数体: 怎么加工
    return result;               // return: 把成品扔出去
}
 
// 调用 —— 把材料送进去、拿成品出来
int main() {
    int sum = add(3, 5);         // 3 和 5 是实参——实际的原材料
    std::cout << sum;            // 输出 8
    return 0;
}

整个过程分五步——看上面的 FunctionCallStackDiagram 图,每一步对应图上一个高亮部分:

  1. 调用前main() 正常运行,栈上只有 main 的局部变量。
  2. 传参:实参 3 和 5 被拷贝给形参 x 和 y——这是关键:x 和 y 是 3 和 5 的副本,不在同一个内存位置。
  3. 执行:CPU 跳到 add 第一行——int result = x + y,算出 result = 8。此时 main 处于暂停状态。
  4. 返回return result——把 8 放到约定的寄存器(eax/rax)里,add 即将退出。
  5. 栈帧销毁:add 的整个「车间」——形参 x、y 和局部变量 result——随着栈帧回收全部消失。CPU 回到 main 继续执行,把寄存器的 8 赋给 sum。

参数传递:拷贝还是别名?——这是整章最重要的一节

上一节说了函数调用时实参会被「拷贝」给形参。但 C++ 给了你三种传递原材料的方式——什么时候用哪一种,是这章最重要的决策

——就是复印图纸:车间拿到的是原稿的复印件,在上面写字画图不影响原件。

——就是直接把原件递给你:你往原件上写字,原件就变了。

——就是给你看原件但不许动笔:你能读上面的全部内容,但不能做任何修改——同时省了复印的成本。

值传递(void f(int x))引用传递(void g(int &x))int a = 5;a: 50x100int x = a;x: 50x200拷贝修改 x 不会影响 a —— 两块独立内存int a = 5;a: 5同一块内存int &x = a;x: 5别名引用修改 x 直接改变 a —— 同一块内存行为对比void addOne(int x) { x = x + 1; }int a = 5;addOne(a); // a 还是 5✗ 无法修改实参void addOne(int &x) { x = x + 1; }int a = 5;addOne(a); // a 变成 6✓ 能修改实参const T& → 「只读借阅,不拷贝」(传大对象必用)T& → 「读写别名」(函数要改原值时用)T → 「独立副本」(开销大)
值传递在栈上开辟新内存拷贝实参——修改形参不会影响原变量。引用传递让形参直接「指向」实参的内存——修改形参就是修改原变量。

看上面的对比图——左边是值传递:实参 a 存 5 在地址 0x100,形参 x 存 5 的拷贝在地址 0x200——两块独立内存,改 x 不影响 a。右边是引用传递:int &x = a 让 x 和 a 共享同一块内存——修改 x 就是修改 a。

代码对照——三种传递方式的行为差异:

// ① 值传递:改不了原值
void addOne_val(int x) { x = x + 1; }    // 改的是副本
 
// ② 引用传递:能改原值
void addOne_ref(int &x) { x = x + 1; }   // 改的是原值本身
 
// ③ const 引用:只读不拷贝
void print_vec(const std::vector<int> &v) {
    // v.push_back(1);  ← 编译错误,const 不能改
    std::cout << v.size();  // ✓ 只读 OK,且没拷贝整个 vector
}
 
int main() {
    int a = 5;
    addOne_val(a);         // a 还是 5
    addOne_ref(a);         // a 变成 6
    print_vec({1,2,3});    // const 引用能接受临时对象
    return 0;
}

把数组当参数传进去——一个你必须知道的大坑

现在你知道值传递会拷贝实参——那把整个数组拷一份传给函数行不行?不行。 C++ 在设计上不允许直接拷贝数组——所以「传数组」这件事有一个特殊的规则。

——数组一进函数就脱掉了「数组」的外套,只剩一根指向第一个元素的指针。函数永远不知道你传进来的数组有多长。

调用方:数组int arr[5] = {1,2,3,4,5};12345sizeof(arr) = 20(5 × 4 字节 = 20)sizeof(arr)/sizeof(arr[0])= 20/4 = 5 ✓ 长度可知函数形参:退化为指针这四种写法完全等价:void f(int arr[10]);void f(int arr[]);void f(int arr[5]);void f(int *arr);函数内只拿到一个指针int* arr指向 arr[0]函数内:长度丢失sizeof(arr) = ?= 8 (64 位上指针大小)不是 20!完全不知道长度必须单独传长度:void f(int *arr, int n)或传首/尾后指针(begin/end)数组形参 = 指针形参(完全等价)编译器不管你在 [] 里写什么数,都当成 T* 处理——函数里永远不知道数组长度。例外:void f(int (&arr)[10]); —— 带括号的引用可以保留数组长度信息,但极少用。实践法则:凡是函数需要知道数组长度,一律把长度作为第二个参数传进去。
把数组传给函数时,传递的不是整个数组,而是指向第一个元素的指针。函数内部 `sizeof(arr)` 返回的是指针大小而不是数组大小——这是 C++ 新手最高频的惊愕之一。

看上面的图——左边调用方有一个完整的 int arr[5] = {1,2,3,4,5}sizeof(arr) = 20 能算长度。传进函数后,形参 int arr[] 实际上是 int* arr——一根 8 字节的指针。函数内部的 sizeof(arr) 返回 8,不是 20——长度信息丢了。

代码验证——四种写法的形参完全等价:

// 下面四个函数声明——完全等价!都只收到一个 int* 指针
void print1(int arr[10]);    // [10] 被编译器无视
void print2(int arr[5]);     // [5] 也被无视
void print3(int arr[]);      // 空括号同样退化为指针
void print4(int *arr);       // 这才是它真正的样子
 
// 函数内部 sizeof(arr) = 8 —— 指针大小,不是数组大小
// 必须单独传长度:
void print_correct(int *arr, int n) {
    for (int i = 0; i < n; i++)   // n 是调用方传入的长度
        std::cout << arr[i] << ' ';
}

同一车间,多种订单:函数重载

有时候你需要「同一个操作名、但接受不同类型的原材料」。比如 max(3, 5) 比较两个 int、max(3.14, 2.71) 比较两个 double——你不想起两个名字叫 maxIntmaxDouble,而是想用一个 max 搞定。

——就是把同一种操作注册为多个工单类型:车间看到来的是 int 就走 int 产线、来的是 double 就走 double 产线。

编译器如何匹配重载:三步走阶段 1候选函数(Candidate)与调用点**同名且可见**的函数——不管参数对不对,先全部找到。例:调用 print(3.14) → 筛选出所有名为 print 的函数(任何形参)。筛选:形参数量对得上吗?(加默认参数后)阶段 2可行函数(Viable)形参**数量匹配** + 每个实参都能通过隐式转换配到对应形参类型。例:print(int) ✓(3.14→int 隐式转) / print(string) ✗(double 不能转 string)筛选:谁的隐式转换最少?(哪个匹配得最"近")阶段 3最佳匹配(Best match)在所有可行函数中,**每个实参都不差于其他函数**、且**至少有一个实参比其他好**的那个。例:print(int) vs print(double) 用 3.14 调用 → print(double) 是最好的(不需要转)调用对应的函数匹配规则优先级(从高到低)① 精确匹配(类型完全一样) > ② const 转换 > ③ 类型提升(int→long)> ④ 算术/指针转换 > ⑤ 类类型转换无法选出唯一最佳 → 二义性(ambiguous)→ 编译错误
三步筛选:先找同名函数(候选项)→ 再看形参数量和类型是否对得上(可行项)→ 最后挑转换代价最小的那一个(最佳匹配)。找不到或找到多个 = 编译报错。

看上面的流程图——编译器做了三步筛选才能确定调用哪个重载:

  1. 候选:找所有叫同一个名字且可见的函数——不管形参类型对不对。
  2. 可行:筛出形参数量对得上、每个实参都能通过隐式转换配上对应形参的。
  3. 最佳匹配:在可行函数里挑隐式转换最少的那一个。

代码示例——用 max 重载处理 int 和 double:

// 两个重载:同名、形参类型不同
int max(int a, int b) {
    return a > b ? a : b;
}
double max(double a, double b) {
    return a > b ? a : b;
}
 
int main() {
    auto r1 = max(5, 3);       // 调用 max(int, int)  → 5
    auto r2 = max(3.14, 2.71); // 调用 max(double, double) → 3.14
    return 0;
}

重载的匹配有优先级——从高到低:① 精确匹配(类型完全一样)② const 转换 ③ 类型提升(int→long)④ 算术/指针转换 ⑤ 类类型转换。如果两个重载版本匹配得「一样好」——就会出现二义性(ambiguous),编译器报错。

特殊车间:内联函数和编译期函数

普通函数调用可能涉及参数传递、跳转与返回,但是否把函数体展开是优化器的决定。C++ 的 inline 首先是跨 translation unit 的 ODR/定义可见性机制,constexpr 则允许在常量表达式上下文中求值;二者都不承诺某种机器指令形态。

通常定义在头文件中。它消除的是跨 translation units 重复定义的 ODR 障碍,不是强制消除调用开销。

——就是在工厂开工之前就把结果算好了。适合需要在编译阶段就确定值的场景——比如数组大小、模板参数。

// inline:一行小工具——建议编译器展开
inline int square(int x) {
    return x * x;           // 体积极小、调用频繁
}
 
// constexpr:在编译期就能算出来
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
 
int main() {
    int a = square(5);          // 运行时或内联——看编译器心情
 
    constexpr int b = factorial(5);  // 编译期计算——b = 120
    int arr[factorial(3)];           // 编译期算出来 6——用作数组大小
    return 0;
}

官方 Chapter 6 的完整函数契约

原书还要求 separate compilation、command-line options、varying parameters、复杂 return type、default arguments、debugging aids 与 pointers to functions。它们共同决定函数能否跨文件、跨调用形态稳定使用。

要求所有调用点看到相同 function declaration,且程序中 non-inline definition 只有一个。修改头文件通常需要重编译包含它的 translation units;只修改 .cpp implementation 可缩小重编译范围。Declaration 中 parameter names 可省略,但 return/parameter types、constexpr 等契约必须一致。

包括 std::initializer_list<T> 和 ellipsis。前者元素类型统一、可安全范围遍历;main(int argc, char* argv[]) 是固定的命令行参数接口,不是普通 variadic function。C 风格 ... 丢失静态类型信息,应限制在兼容接口。

必须先证明对象在函数返回后仍存在。返回 local object 的 reference/pointer 会悬空;按值返回通常由 copy elision 或 move 高效完成。返回数组指针可用 type alias 或 trailing return:

using Row = int[10];
Row* chooseRow(int (&matrix)[4][10], std::size_t index);
auto chooseRow2(int (&matrix)[4][10], std::size_t index) -> int (*)[10];

必须从 parameter list 右侧连续提供。Default expression 在调用时求值,名字按 declaration 所在 scope 绑定;不要在后续 declaration 重复写同一默认值。inlineconstexprassertNDEBUG 则属于 specialized uses:assert(expr) 在未定义 NDEBUG 时检查条件,不能承载必须发生的 side effect。

类型由 return 与 parameter types 决定:

bool shorter(const std::string&, const std::string&);
using Compare = bool (*)(const std::string&, const std::string&);
 
Compare comparator = shorter;
bool result = comparator("alpha", "beta");
void sortWords(std::vector<std::string>& words, Compare compare);

函数名在多数表达式中自动转换为 pointer。若名字 overloaded,目标 pointer type 可帮助选择唯一版本;否则要用 static_cast 明确。函数指针参数也可写成 function type,但形参会调整为 pointer type。

动手:一步步看函数调用在栈上发生了什么

每次函数调用 CPU 都在栈上做一套精准的机械动作——压入实参、创建局部变量、执行函数体、回收整个帧。下面把 int add(int x, int y) { int z = x + y; return z; } 的调用拆成五步——每一步对应 FunctionCallStackDiagram 图中高亮的那一帧。

猜一猜:int add(int x, int y) { x = 100; return x + y; } 调用 add(3, 5) 后,函数返回什么?调用方传进去的 3 在 add 里面被改成 100 之后,调用方手里的 3 受影响了吗?通过下面 Stepper 一步步看。

分步1 / 5

① 调用前:main 的栈帧

调用栈(高地址 → 低地址)栈底栈顶 ↑ 调用前main() 正在执行,栈上只有调用方的局部变量 传参实参压栈,保存返回地址(或通过寄存器传递) 执行中CPU 跳到 add() 第一行main() 暂停,等 add() 返回 返回值return 执行——返回值写入寄存器,即将返回 main 栈帧销毁add() 的栈帧被回收——内存还给操作系统,main 继续main() 栈帧(局部变量 a,b,c)⬜ 栈帧 = 内存中一块区域(存形参、局部变量、返回地址)紫色 = 当前活跃帧
每次函数调用都在栈上分配一块新区域(栈帧),存放形参、局部变量和返回地址。函数返回后它的整个栈帧被销毁——局部变量也随之消失。

调用 add(3, 5) 之前——栈上只有 main() 的栈帧。main 的局部变量(如果有的话)舒舒服服待在帧里,add 还什么都没分到。

代码逐段拆解:把本章所有工具串起来

这一节覆盖本章函数定义、参数传递、引用与 const、数组、重载、inline/constexpr 的核心用法——每段一个主题,读完就能在自己的程序里设计和使用函数。

用引用参数实现真正的 swap

#include <iostream>
 
// 引用的经典用例——交换两个变量的值
void swap(int &a, int &b) {
    int temp = a;          // 把 a 存起来
    a = b;                 // 把 b 写进 a —— 改的就是原值
    b = temp;              // 把暂存的 a 写进 b
}
 
int main() {
    int x = 10, y = 20;
    std::cout << "交换前: " << x << " " << y << '\n';
    swap(x, y);
    std::cout << "交换后: " << x << " " << y << '\n';
    return 0;
}

这是引用传递最经典的例子。如果形参没有 &——void swap(int a, int b)——交换的只是副本,x 和 y 纹丝不动。加上 & 之后,a 就是 x 的别名、b 就是 y 的别名——交换 a 和 b 就是交换 x 和 y。

const 引用 vs 值传递的性能对比

#include <iostream>
#include <string>
 
// 值传递:拷贝一整个 string——str 在这被完整复制了一份
void greet_copy(std::string str) {
    std::cout << "你好," << str << "!\n";
}
 
// const 引用:不拷贝——直接引用调用方手里的 string
void greet_ref(const std::string &str) {
    std::cout << "你好," << str << "!\n";
    // str = "别的";  ← 编译错误——const 不能改
}
 
int main() {
    std::string name = "张三李四王五赵六钱七孙八周吴郑王";
    greet_copy(name);   // 把整个 string 拷贝一份进栈
    greet_ref(name);    // 只传 8 字节引用,零开销
    return 0;
}

差别在哪?greet_copy 把整个 name 字符串(可能是几十到几千字节)完整拷贝一份到形参 str——内存和时间开销。greet_ref 只传一个 8 字节的引用——零拷贝。结论:凡是比 int/double 大的对象,默认用 const T&——除非函数确实需要修改原值或要改独立的副本。

遍历数组并返回统计结果

#include <iostream>
 
// 传数组 = 传指针 —— 必须同时传长度
void analyze(const int *arr, int n, int &sum, int &max) {
    sum = 0;
    max = arr[0];
    for (int i = 0; i < n; i++) {
        sum += arr[i];
        if (arr[i] > max) max = arr[i];
    }
}
 
int main() {
    int scores[] = {88, 95, 73, 64, 91, 55};
    int n = sizeof(scores) / sizeof(scores[0]);
 
    int total, highest;
    analyze(scores, n, total, highest);
 
    std::cout << "总和: " << total << '\n';
    std::cout << "最高: " << highest << '\n';
    std::cout << "平均: " << static_cast<double>(total) / n << '\n';
    return 0;
}

设计要点:① const int *arr——只读指针,函数不修改数组内容 ② int &sumint &max——引用参数作为「输出」渠道,把计算结果送出去 ③ C++ 风格里一个函数通常通过 return 返回一个值,用引用参数输出附加结果 ④ sizeof(scores)/sizeof(scores[0]) 只在定义数组的作用域里能算出长度——传给 analyze 后 sizeof(arr) 就是 8 了。

函数重载:同一个名字、不同类型

#include <iostream>
#include <string>
 
// 三个重载——同名、不同形参
void print(int x) {
    std::cout << "整数: " << x << '\n';
}
void print(double x) {
    std::cout << "浮点数: " << x << '\n';
}
void print(const std::string &s) {
    std::cout << "字符串: " << s << '\n';
}
 
int main() {
    print(42);              // 匹配 print(int)
    print(3.14);            // 匹配 print(double)
    print(std::string("hello")); // 匹配 print(const string&)
    return 0;
}

编译器根据实参类型自动选择匹配的版本。print(42) 里 42 是 int 字面值,选 print(int)print("hello") 可以经 std::string 的非 explicit constructor 做一次 user-defined conversion,匹配 print(const std::string&);若同时存在更好的 print(const char*),则后者会胜出。这提示我们重载时要比较完整 conversion sequence。

用 constexpr 在编译期算阶乘

#include <iostream>
 
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
 
int main() {
    constexpr int f5 = factorial(5);    // 编译期计算 = 120
    int arr[factorial(4)];              // 编译期 24——用作数组大小
 
    int user_input;
    std::cin >> user_input;
    int f_user = factorial(user_input); // 运行时计算——实参不是常量
 
    std::cout << "5! = " << f5 << '\n';
    std::cout << user_input << "! = " << f_user << '\n';
    return 0;
}

factorial(5) 在编译期展开为 5*4*3*2*1 并算出 120——运行时零开销。同时同一个函数在运行期对用户输入也能正常算——constexpr 函数根据实参是否常量自动切换计算时机。这是现代 C++ 的精妙设计。

静态局部变量:函数调用的记忆

#include <iostream>
 
// 每次调用都返回当前调用次数——static 变量跨调用保持值
int count_calls() {
    static int call_count = 0;   // 只在第一次调用时初始化一次
    return ++call_count;          // 之后每次调用 += 1
}
 
int main() {
    for (int i = 0; i < 5; i++)
        std::cout << count_calls() << ' ';
    // 输出:1 2 3 4 5
    return 0;
}

static int call_count 是局部静态对象——只在第一次执行到这行时初始化为 0,之后每次调用 count_calls() 返回自增后的值。它的生命周期是整个程序运行期间——不像普通局部变量函数返回就销毁。适合做「调用计数」「第一次标记」「单例中的持久状态」等场景。

容易踩的坑

小结

  • 函数由返回类型、函数名、形参列表和函数体四个部分组成——定义是完整实现,声明是告诉编译器「有这函数」
  • 参数传递有三种:值传递(改副本不动原值)、引用传递(改别名动原值)、const 引用(不拷贝只读)——90% 场景选 const T& 或 T&
  • 数组作为形参退化为指针——函数内部永远不知道数组长度,必须单独传 n——或用 std::vector/std::array
  • 函数重载允许同名函数经形参列表区分——编译器通过「候选→可行→最佳」三步匹配,无法唯一选出就是编译错误
  • inline 主要解决头文件定义的 ODR 规则,不保证调用点展开;constexpr 在常量上下文中允许编译期求值

练习

问题 1(改代码型) 下面这段代码试图写一个函数 doubleArray 把数组里每个元素翻倍。但它有 bug——找出并写出修正后的代码。

#include <iostream>
void doubleArray(int arr[10]) {
    for (int i = 0; i < 10; i++)
        arr[i] *= 2;
}
int main() {
    int nums[] = {1, 2, 3, 4, 5};
    doubleArray(nums);
    for (int i = 0; i < 5; i++)
        std::cout << nums[i] << ' ';
    return 0;
}

问题 2(独立实现题) 设计并实现一个函数 read_numbers,从标准输入读取一组整数存进 std::vector<int> 返回。要求:(1) 读到非整数或 EOF 时停止——用 while 循环 (2) 返回 vector<int>(不是引用、不是用引用参数输出)(3) 在 main 中用范围 for 循环打印每个元素及其下标。提示:用 while (std::cin >> val) 判断是否成功读到数。

问题 3(问答型) 本章 §5 Stepper 演示了值传递——形参 x 是实参 3 的副本。如果把 int add(int x, int y) 改成 int add(const int &x, const int &y),函数的抽象语义会发生什么变化?形参 x 和 y 还会被「拷贝」吗?修改 x 在 const 引用下能通过编译吗?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

函数(function)

C++ 中最基本的代码复用单元。由四部分构成:返回类型(产出什么)、函数名(车间名字)、形参列表(需要什么原材料)、函数体(怎么加工)。int add(int x, int y) { return x + y; } 就是一个完整的函数定义。调用时把实参的值拷贝给形参后执行函数体,return 后整个栈帧被销毁。详见本章「函数基础」一节和 FunctionCallStackDiagram 图示。

值传递(pass by value)

把实参的值拷贝一份给形参——修改形参不影响实参。适合小对象(int、double 等基本类型)或函数需要独立副本来修改的场景。void f(int x) 就是值传递。拷贝大对象(string、vector)开销大——此时用 const 引用更好。详见本章「参数传递」一节和 ParameterPassingDiagram 图示中左侧的内存对比。

引用传递(pass by reference)

形参用 T& 声明——不拷贝实参,而是让形参直接引用实参的内存。void f(int &x) { x = 0; } 中修改 x 就是修改调用方传进来的原变量。经典用例是 swap 交换两值。不能接受字面值(f(5) 编译错误)——因为临时值没有可以修改的内存地址。详见本章「参数传递」一节和右侧的内存对比图。

const 引用传递(const reference)

形参用 const T& 声明——不拷贝实参但也不能修改它。可以接受字面值和临时对象。void f(const std::string &s) 是传大对象的首选——清晰表达「我只读」的意图同时还省了拷贝。详见本章「参数传递」一节和 const 引用 vs 值传递的代码示例。

数组退化(array decay)

C++ 中数组作为函数形参时自动转换成指向首元素的指针。void f(int arr[10])void f(int* arr) 完全等价——编译器不管 [] 里写什么数字都当 T* 处理。函数内部 sizeof(arr) 返回指针大小而非数组大小,必须单独传长度参数。详见本章「数组形参」一节和 ArrayParameterDiagram 图示。

函数重载(function overloading)

C++ 允许同一作用域内同名函数但形参列表不同(类型、数量、顺序)。编译器比较实参与形参类型按优先级自动匹配——「候选→可行→最佳」三步。如果找不到唯一最佳就是 ambiguous(二义性)——编译报错。只改返回类型不算重载。详见本章「函数重载」一节和 OverloadResolutionDiagram 图示。

内联函数(inline function)

可在多个 translation units 中出现相同定义的函数,通常定义在头文件。关键字不强制机器码在调用点展开;优化器可内联没有该关键字的函数,也可保留 inline 函数调用。

constexpr 函数

constexpr 声明的函数——当所有实参都是常量表达式时编译器在编译期就能算出结果。constexpr int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); } 可在编译期计算并用于数组大小等场景。如果实参中有变量则退化为普通运行时函数。constexpr 函数隐式是 inline 的。详见本章「constexpr 函数」一节的编译期与运行时对照示例。

分离式编译

声明放头文件、定义放源文件,各 translation unit 独立编译为目标文件后再链接的组织方式;函数签名必须在所有使用处一致。

可变形参

接收数量可变实参的机制;同类型集合优先使用 initializer_list,C 风格省略号因缺乏静态类型信息只用于必要兼容。

复杂返回类型

返回引用、指针或数组指针等形式;除声明语法外,还必须证明被引用对象在函数返回后继续存活。

默认实参

声明中为末尾形参提供的调用默认值;省略实参时在调用点使用,且同一作用域不能重复指定同一默认值。

函数指针

保存特定函数类型地址的指针,可作为值传递并间接调用;overloaded function 需要目标类型帮助选择唯一版本。

资料与写作方式声明

本章以C++ Primer, Fifth Edition, Chapter 6: Functions权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…