第二章:C++ 函数篇 —— 从单文件到多文件程序
学习来源:LearnCpp — Chapter 2
整理目的:以更精炼、更适合复习和分享的方式,梳理 C++ 函数的基础知识与工程组织方式。
0. 本章脉络(先看全景)
第二章回答了一个问题:
怎么把程序拆成多个小块,组织成清晰、可复用、可维护的工程?
围绕这个问题,学完之后你应该能讲清楚这几件事:
- 函数是什么,为什么需要函数
- 函数的返回值机制(
void vs 有返回值)
- 形参与实参:函数怎么接收数据
- 局部变量的生命周期与作用域
- 怎么高效地设计和使用函数
- 前向声明、声明与定义的区别、ODR
- 多文件工程的组织方式
- 命名冲突与命名空间
- 预处理器的工作机制
- 头文件的作用、写法与头文件保护
- 从头设计一个程序的方法论
下面按这个顺序展开。
1. 函数简介
1.1 什么是函数
函数是一段可复用的、用于完成某个特定任务的语句序列。
程序的执行从 main() 开始,遇到函数调用时,CPU 会"中断当前函数",转去执行被调用的函数,执行完再回来继续。
void doPrint() { std::cout << "In doPrint()\n"; }
int main() { std::cout << "Starting main()\n"; doPrint(); std::cout << "Ending main()\n"; return 0; }
|
输出:
Starting main() In doPrint() Ending main()
|
1.2 函数的基本语法
returnType functionName() { }
|
关键点:
caller(调用方)调用 callee(被调用方)
- 函数调用必须带
(),否则不会被调用
- C++ 不支持嵌套函数
1.3 命名约定:占位名
| 名称 |
用途 |
foo、bar、baz |
示例中的占位名 |
goo、moo、boo |
常见的三字母占位名 |
2. 函数的返回值
2.1 返回值的基本机制
要让函数返回值,需要做两件事:
- 指定返回类型:写在函数名前
- 使用
return 语句:返回具体的值
int returnFive() { return 5; }
|
return 语句执行时:
- 计算返回表达式
- 把值拷贝回调用方(按值返回)
- 函数结束,控制权返回调用方
2.2 完整的例子
#include <iostream>
int getValueFromUser() { std::cout << "Enter an integer: "; int input{}; std::cin >> input; return input; }
int main() { int num{ getValueFromUser() }; std::cout << num << " doubled is: " << num * 2 << '\n'; return 0; }
|
2.3 关于 main() 的返回值
main() 必须返回 int
- 返回值叫做状态码(status code)
0 表示程序正常运行
- 不允许显式调用
main()
2.4 重要规则
| 规则 |
说明 |
| 有返回值的函数必须返回一个值 |
否则是未定义行为 |
main() 可以不写 return |
会隐式返回 0 |
| 函数每次只能返回一个值 |
后续会学多种绕开方法 |
| 返回值含义由函数作者决定 |
建议加注释说明 |
2.5 DRY 原则
Don’t Repeat Yourself(不要重复自己)
int main() { int x{}; std::cout << "Enter an integer: "; std::cin >> x;
int y{}; std::cout << "Enter an integer: "; std::cin >> y; }
int main() { int x{ getValueFromUser() }; int y{ getValueFromUser() }; }
|
3. void 函数(无返回值)
3.1 什么是 void 函数
返回类型为 void 的函数不向调用方返回任何值。
void printHi() { std::cout << "Hi" << '\n'; }
|
3.2 void 函数的特点
| 特点 |
说明 |
不需要 return 语句 |
函数末尾自动返回 |
可以写 return; |
用于提前返回,但末尾的 return; 是多余的 |
| 不能用于需要值的表达式 |
std::cout << printHi(); ❌ 编译错误 |
| 不能在 void 函数里返回一个值 |
return 5; ❌ 编译错误 |
void returnNothing() {}
int returnFive() { return 5; }
int main() { returnNothing(); returnFive();
std::cout << returnFive(); std::cout << returnNothing(); }
|
4. 函数的形参与实参
4.1 基本概念
- 形参(parameter):函数头中定义的变量,由调用方提供初始值
- 实参(argument):调用函数时传给形参的具体值
int add(int x, int y) { return x + y; }
int main() { add(2, 3); }
|
4.2 按值传递
实参的值通过拷贝初始化赋给形参,这个过程叫"按值传递"。
void printValues(int x, int y) { std::cout << x << '\n'; std::cout << y << '\n'; }
int main() { printValues(6, 7); }
|
4.3 形参与实参的配合
实参可以是任何表达式(字面量、变量、函数调用等):
std::cout << add(1 + 2, 3 * 4) << '\n'; std::cout << add(1, multiply(2, 3)) << '\n'; std::cout << add(1, add(2, 3)) << '\n';
|
4.4 未使用的形参
如果形参在函数体内未被使用,可以省略形参名(避免编译器警告):
void doSomething(int ) { }
|
4.5 关键区分
形参 ← 调用方提供初始值 → 用形参
局部变量 ← 函数自己提供初始值 → 用局部变量
int getValueFromUser(int val) { std::cin >> val; return val; }
int getValueFromUser() { int val{}; std::cin >> val; return val; }
|
5. 局部作用域
5.1 局部变量的生命周期
- 创建:形参在函数进入时创建;局部变量在定义处创建
- 销毁:在所在大括号结束时销毁(顺序与创建相反)
int add(int x, int y) { int z{ x + y }; return z; }
|
5.2 局部作用域(块作用域)
标识符的作用域:从定义处开始,到最内层大括号结束为止。
int main() { int x{ 0 }; doSomething(); return 0; }
|
5.3 生命周期 vs 作用域
| 概念 |
阶段 |
说明 |
| 生命周期 |
运行时 |
对象从创建到销毁的时间 |
| 作用域 |
编译期 |
标识符在源码中可见的位置 |
5.4 不同函数的同名变量互不干扰
int add(int x, int y) { return x + y; }
int main() { int x{ 5 }; int y{ 6 }; std::cout << add(x, y); }
|
5.5 变量定义的位置
最佳实践:在"尽可能接近首次使用"的位置定义局部变量。
std::cout << "Enter an integer: "; int x{}; std::cin >> x;
|
5.6 临时对象
- 没有名字的对象,用于短暂保存值
- 在完整表达式末尾被销毁
- 按值返回时,返回值保存在临时对象中
std::cout << getValueFromUser() << '\n';
|
6. 高效使用函数
6.1 为什么要用函数
| 好处 |
说明 |
| 组织性 |
把复杂程序拆成小模块 |
| 可复用性 |
避免重复代码(DRY) |
| 可测试性 |
函数自包含,测一次即可 |
| 可扩展性 |
改一处,所有调用处自动生效 |
| 抽象性 |
只需知道函数签名,不用关心内部实现 |
6.2 何时写函数
- 程序中多次出现的一组语句
- 有明确输入、明确输出的代码块
- 一个函数只做一件事
- 函数太长/太复杂 → 拆成多个子函数(重构)
6.3 常见的"三步走"结构
新手易犯错误:把"计算"和"输出"合并到一个函数里。
void printDouble() { int num{ getValueFromUser() }; std::cout << num * 2; }
int doubleValue(int x) { return x * 2; }
|
7. 前向声明与定义
7.1 问题:编译器按顺序编译
#include <iostream>
int main() { std::cout << add(3, 4); }
int add(int x, int y) { return x + y; }
|
7.2 解决方案一:调整顺序
把 add 移到 main 前面。但不总是可行(例如相互调用的函数)。
7.3 解决方案二:前向声明
前向声明:在正式定义之前,告诉编译器某个标识符的存在。
#include <iostream>
int add(int x, int y);
int main() { std::cout << add(3, 4); }
int add(int x, int y) { return x + y; }
|
7.4 声明 vs 定义
| 术语 |
含义 |
示例 |
| 声明(Declaration) |
告诉编译器标识符存在及其类型 |
int add(int x, int y); |
| 定义(Definition) |
实现函数/实例化变量 |
int add(int x, int y) { ... } |
| 纯声明(Pure Declaration) |
不是定义的声明 |
前向声明 |
| 初始化(Initialization) |
给对象提供初始值 |
int x{ 5 }; |
所有定义都是声明,但不是所有声明都是定义。
7.5 一次定义原则(ODR)
- 一个文件内:每个标识符只能有一个定义
- 一个程序内:每个函数/变量只能有一个定义
- 类型/模板/内联函数允许在不同文件中有重复定义(但必须完全相同)
int add(int x, int y) { return x + y; } int add(int x, int y) { return x + y; }
|
8. 多源文件程序
8.1 核心机制
编译器逐文件编译,不会记住别的文件里有什么。
int add(int x, int y) { return x + y; }
#include <iostream>
int add(int x, int y);
int main() { std::cout << add(3, 4); }
|
8.2 链接器的职责
- 编译器:每个文件独立编译,产生目标文件(
.obj / .o)
- 链接器:把所有目标文件中的定义"连接"起来
8.3 常见错误排查
| 错误类型 |
原因 |
解决方案 |
编译错误:add not found |
缺少前向声明 |
在调用前加前向声明 |
| 链接错误:unresolved external |
.cpp 没加入项目 |
把文件加入项目/编译命令 |
| 链接错误:多重定义 |
在多个文件中定义了同一个函数 |
只保留一个定义 |
8.4 关键注意
- 每个用到
std::cout 的源文件都要 #include <iostream>
- 不要在
.cpp 里 #include 另一个 .cpp 文件
9. 命名冲突与命名空间
9.1 命名冲突
同一个程序中,如果两个同名标识符让编译器/链接器分不清,就是命名冲突。
void myFcn(int x) { }
void myFcn(int x) { }
|
9.2 命名空间
命名空间提供了一种作用域区域,把同名标识符隔离开。
| 命名空间 |
说明 |
| 全局命名空间 |
所有默认定义在全局的名字 |
std 命名空间 |
标准库的名字都在这里 |
9.3 访问命名空间中的名字
方式一:显式限定(推荐)
方式二:using namespace std;(不推荐)
using namespace std; cout << "Hello";
|
#include <iostream> using namespace std;
int cout() { return 5; }
int main() { cout << "Hello"; }
|
警告:不要在程序顶部或头文件里使用 using namespace std;
9.4 作用域解析运算符 ::
:: 左边是命名空间,右边是名字。
10. 预处理器简介
10.1 预处理阶段
在真正编译之前,每个 .cpp 文件先经过预处理。
预处理器处理的是文本,不关心 C++ 语法。
10.2 #include
10.3 宏定义 #define
#define MY_NAME "Alex" std::cout << MY_NAME;
|
对象式宏:
宏名惯例:全部大写,下划线分隔。
除非没有更好的替代方案,否则避免使用带替换文本的宏。
10.4 条件编译
#define PRINT_JOE
#ifdef PRINT_JOE std::cout << "Joe\n"; #endif
#ifdef PRINT_BOB std::cout << "Bob\n"; #endif
|
#ifndef 是反义(检查是否未定义):
#ifndef PRINT_BOB std::cout << "Bob\n"; #endif
|
10.5 #if 0 屏蔽代码
#if 0 std::cout << "Hidden\n"; #endif
#if 1 std::cout << "Visible\n"; #endif
|
10.6 指令的作用范围
- 从定义处到所在文件的末尾
- 一个文件中的指令不影响其他文件
11. 头文件
11.1 为什么要用头文件
头文件用于集中存放声明,然后在需要的地方 #include 进来。
int add(int x, int y);
#include "add.h" int add(int x, int y) { return x + y; }
#include "add.h" int main() { add(3, 4); }
|
11.2 尖括号 vs 双引号
| 写法 |
用途 |
查找路径 |
<iostream> |
系统/标准库头文件 |
包含目录(不查项目目录) |
"add.h" |
自定义头文件 |
先查当前目录,再查包含目录 |
11.3 不要把头文件里的定义放到头文件
int add(int x, int y) { return x + y; }
|
11.4 配对包含
源文件应 #include 与它配对的头文件。
#include "add.h"
double add(int x, int y) { return x + y; }
|
11.5 头文件可以 #include 其他头文件
#include <string_view> std::string_view getApplicationName();
|
11.6 传递性包含
不要依赖另一个头文件传递进来的包含,显式 #include 自己需要的内容。
#include <iostream> std::string name;
|
11.7 #include 的推荐顺序
- 本源文件配对的头文件(如
add.cpp 先 #include "add.h")
- 本项目其他头文件
- 第三方库头文件
- 标准库头文件
11.8 头文件最佳实践
| 建议 |
说明 |
| 始终写头文件保护 |
下一节详述 |
| 不在头文件里定义函数/变量 |
目前阶段 |
| 头文件与源文件同名 |
add.h ↔ add.cpp |
| 显式包含所需头文件 |
不依赖传递包含 |
不使用 .cpp 文件 |
避免 #include "add.cpp" |
12. 头文件保护
12.1 问题:头文件被重复包含
int getSquareSides() { return 4; }
#include "square.h"
#include "square.h" #include "wave.h"
|
展开后 main.cpp 中有两个 getSquareSides 定义 → 重复定义错误。
#ifndef SQUARE_H #define SQUARE_H
#endif
|
工作原理:
- 第一次包含:
SQUARE_H 未定义 → 定义它,包含内容
- 第二次包含:
SQUARE_H 已定义 → 跳过内容
12.3 完整示例
#ifndef SQUARE_H #define SQUARE_H
int getSquareSides(); int getSquarePerimeter(int sideLength);
#endif
|
#include "square.h"
int getSquareSides() { return 4; }
int getSquarePerimeter(int sideLength) { return sideLength * getSquareSides(); }
|
12.4 头文件保护 vs #pragma once
| 方式 |
写法 |
特点 |
| 传统头文件保护 |
#ifndef ... #define ... #endif |
标准、可移植 |
#pragma once |
#pragma once |
更简洁,现代编译器普遍支持 |
本教程使用传统头文件保护(更规范、最广泛支持)。
12.5 重要澄清
头文件保护防止"同一个文件内"重复包含,不防止"不同文件各包含一次"。
square.h 被 square.cpp 和 main.cpp 各包含一次 → 各得一份声明,链接器正确连接。
12.6 即使只有声明也应加保护
#ifndef FOO_H #define FOO_H
void foo();
#endif
|
13. 如何设计一个程序
13.1 设计流程
明确目标 → 明确需求 → 确定工具/平台 → 拆解问题 → 确定顺序 → 实现
|
13.2 第 1 步:明确目标
用一两句话描述"用户视角的结果":
- 让用户能管理姓名和电话的名单
- 根据用户输入的高度计算球落地所需时间
13.3 第 2 步:明确需求
需求聚焦"做什么",不是"怎么做"。
13.4 第 3 步:明确工具与备份
- 目标平台、工具链、测试策略
- 一定要有备份策略(云盘、GitHub 等)
13.5 第 4 步:拆解问题(自顶向下)
把大任务拆成小任务,直到每一步都足够简单。
打扫房子 ├── 吸尘 ├── 打扫卫生间 │ ├── 刷马桶 │ └── 洗洗手台 └── 打扫厨房 ├── 清空台面 ├── 擦净台面 └── 倒垃圾
|
任务树对应程序结构:
13.6 第 5 步:确定事件顺序
13.7 实现策略
1. 先搭 main 骨架
2. 逐个实现每个函数,每实现一个就测试一个
int getUserInput() { std::cout << "Enter an integer: "; int input{}; std::cin >> input; return input; }
int main() { int value{ getUserInput() }; std::cout << value << '\n'; }
|
13.8 写作建议
| 原则 |
说明 |
| 一开始做简单 |
从小目标开始,逐步扩展 |
| 逐步加功能 |
每次只加一小步 |
| 一次只专注一件事 |
别同时做多个任务 |
| 写一段测一段 |
及时编译、测试,尽早发现错误 |
| 不要过早追求完美 |
先让代码能跑,再逐步打磨 |
| 优先可维护性 |
不要过早优化性能 |
一个能工作的复杂系统,无一例外都是从一个能工作的简单系统演化而来的。
—— John Gall
14. 本章重点回顾(面试/复试速记版)
| # |
关键问题 |
一句话答案 |
| 1 |
函数调用时发生了什么? |
中断当前函数,执行被调用函数,执行完返回 |
| 2 |
有返回值的函数必须怎么做? |
通过 return 返回对应类型的值 |
| 3 |
void 函数的特点? |
不返回值,不能用于需要值的表达式 |
| 4 |
形参和实参有什么区别? |
形参是函数头中的变量,实参是调用时传入的值 |
| 5 |
实参如何传递到形参? |
按值传递(拷贝) |
| 6 |
局部变量的生命周期? |
从定义处创建,到所在大括号结束销毁 |
| 7 |
作用域是什么? |
标识符在源码中可见的区域(编译期) |
| 8 |
什么时候用前向声明? |
调用定义在后面的函数,或定义在其他文件的函数 |
| 9 |
声明和定义的区别? |
声明告诉存在,定义给出实现/实例化 |
| 10 |
ODR 是什么? |
一个标识符在给定作用域只能有一个定义 |
| 11 |
头文件有什么用? |
集中存放声明,通过 #include 传播 |
| 12 |
头文件保护的作用? |
防止同一个头文件在一个源文件中被多次包含 |
| 13 |
std:: 前缀为什么重要? |
避免与全局命名空间中的名字冲突 |
| 14 |
预处理器做什么? |
处理 #include、#define、条件编译等文本指令 |
15. 学习心得
学完第二章我最大的三个感受:
-
main 只是起点,真正的工程在函数与文件之间。
一个单文件的 main 再复杂也是玩具,学会拆文件、用头文件、理解链接,才真正摸到了工程的门槛。
-
"声明"和"定义"的区别,是 C++ 编译模型的核心。
不理解这个,就永远搞不懂为什么加了头文件还要加源文件、为什么有些错误是编译期有些是链接期。
-
设计比编码更重要。
以前总想"先写起来再说",结果代码越写越乱。现在学会先搭骨架、拆函数、逐个实现和测试,效率反而更高。
从下一章开始,我准备学习代码调试和基础数据类型,把"写出能跑的代码"升级为"写出能跑且正确的代码"。
代码仓库:cpp-learning-journey
学习网站:learnCPP