在你已经会写一点 C 或 “C with Classes” 之后,“写个函数出来”这件事几乎成了本能:代码重复了,就抽成函数;想分文件,就把函数丢到另一个 .cpp 里。
但如果你停下来多想一会儿,会发现函数其实连接了几件完全不同的事:
- 人类脑子里的“这是一块完整的逻辑”;
- 编译器眼中的“这是一个有名字、有参数、有返回值的符号”;
- 链接器负责拼起来的“这是某个地址上的一段机器指令”。
这篇文章想做的,是把“函数”这件事从“语法点”提到“设计工具”的高度来讲:
- 为什么要有“声明”和“定义”的区分?
- 为什么可以有多个同名函数(重载),而不是强制你想出一堆不同的名字?
- 默认参数这种看似“语法糖”的设计,背后是在帮谁省事?
我们依然假设你已经会写简单的 C / C++ 函数,现在只是一起把这些看起来理所当然的东西,重新梳理一遍。
1. 从“复制粘贴”到“抽函数”:函数到底在解决什么?
先不谈 C++,只谈你写代码时最原始的冲动。
想象你在写一个简单的统计程序,需要在多个地方打印一行带前缀的日志:
std::cout << "[INFO] start processing" << '\n';
// ... 中间一大段逻辑 ...
std::cout << "[INFO] done" << '\n';
刚开始时,也许就两三处,复制粘贴一下也没什么大不了。但过一阵子,你又加了几处调试输出,甚至改成了 [DEBUG]、[WARNING]……
这时候你会自然地做一件事:
void log_info(const std::string& message) {
std::cout << "[INFO] " << message << '\n';
}
// 使用
log_info("start processing");
// ...
log_info("done");
看起来我们只是“少写了几行 std::cout”,但本质上你做了三件事:
- 给这一块逻辑起了一个名字
log_info,以后一看到这个名字,大脑立刻知道“这是在记一条信息级别的日志”; - 把这块逻辑从具体调用位置里抽离出来,单独放在一个地方,方便修改和复用;
- 在调用点上,只留下“意图”——我想记一条日志,而不是“指令细节”——我想往标准输出写什么前缀、写什么换行符。
这就是“函数是逻辑的基本单元”这句话的具体含义:
它把“我要做什么”(意图)和“具体怎么做”(实现)分开,让你可以在不同抽象层次上阅读和修改代码。
后面我们会看到,C++ 提供的函数机制,就是围绕这几点设计出来的:既要让编译器把这些逻辑翻译成高效的机器码,又要让你在源代码层面能用“函数签名”表达清楚意图和约束。
2. 函数签名:名字、参数和返回值
在 C++ 里,一个最朴素的函数长这样:
int add(int a, int b) {
return a + b;
}
很多教材会告诉你:
int是返回类型;add是函数名;- 括号里的
int a, int b是参数列表; - 花括号里的就是函数体。
这些说法都对,但还不够。更重要的是:
函数的“签名”(signature)是它在类型系统里的“身份证”。
一个函数签名里包含的信息,大致可以理解成:
- 这个函数叫什么(
add); - 它需要什么样的输入(两个整数参数);
- 它承诺给你什么样的输出(一个整数返回值);
- 它在什么“命名空间”下面(是全局的,还是某个类/命名空间里的成员)。
当你在别处写下:
int result = add(1, 2);
编译器会做几件事:
- 在当前作用域里查找名为
add的函数; - 看看有没有哪个
add的签名,能接受两个int; - 确认这个函数的返回类型是可以赋给左边的
int result的。
这一步,其实就是在用“类型系统”帮你检查接口是否对得上。你可以把它想象成:
每个函数签名,都是你对外宣告的一份“小合同”。
- 调用者承诺:按签名提供正确类型和数量的参数;
- 实现者承诺:在所有合法输入下,返回类型声明的那个东西。
一旦你在某一边违约——比如把 std::string 当成参数传给 add,或者试图把 void 返回值赋给一个变量——编译器就会立刻拦住你。
3. 声明与定义:为什么要分成两件事?
在实际项目里,你很快会遇到这样一种写法:
// header.hpp
int add(int a, int b); // 只有声明
// source.cpp
int add(int a, int b) { // 真正的定义
return a + b;
}
乍一看有点啰嗦:明明写一遍就够了,为什么还要拆成“声明”和“定义”两份?
从“编译器的视角”看,这是因为它工作时看不到整个世界,只能一份源文件一份源文件地编译。它在编译使用方时,需要先知道:
- 有没有一个叫
add的函数; - 这个函数需要什么参数,返回什么类型;
至于这个函数的具体实现在哪里、怎么写的,可以晚一点在链接阶段再说。因此我们可以把“编译器现在能看到的东西”拆成两层:
- 声明(declaration):告诉编译器“有这么个东西,它长这样”;
- 定义(definition):真正给出实现细节,告诉编译器“它具体怎么做”。
在函数这件事上,大部分时候你只会写全定义:
int add(int a, int b) { // 同时起到声明 + 定义的作用
return a + b;
}
只有当你开始把代码拆进多个文件、写头文件给别人用时,才会频繁地写“只有声明,没有定义”的函数原型。这部分我们会在“头文件与源文件”那篇文章里详细展开,这里先记住一点:
声明是把函数签名暴露给外界的方式,定义则是实现这份签名承诺的地方。
4. 参数怎么传?值、指针和引用的一瞥
你在 C 课上大概率已经学过:函数参数是“值传递”。比如:
void set_to_zero(int value) {
value = 0; // 只修改了形参
}
int main() {
int x = 42;
set_to_zero(x);
// x 仍然是 42
}
set_to_zero 得到的是 x 的一份拷贝,对它的修改不会反过来影响 main 里的 x。当你希望函数能够修改调用者那边的变量时,传统的 C 写法是用指针:
void set_to_zero_ptr(int* value) {
if (value) {
*value = 0;
}
}
int main() {
int x = 42;
set_to_zero_ptr(&x); // 传入地址
// 现在 x 被改成了 0
}
到了 C++,你多了一种更直接的写法:引用(reference):
void set_to_zero_ref(int& value) {
value = 0; // 直接“改本人”
}
int main() {
int x = 42;
set_to_zero_ref(x); // 不再需要取地址和解引用
}
从“函数签名”这个角度看,这三种写法其实是在表达不同的意图:
void set_to_zero(int value):我只需要一个数的副本,绝不会改你原来的变量;void set_to_zero_ptr(int* value):我可能会改你传进来的东西,但你有可能传入空指针,需要我先检查;void set_to_zero_ref(int& value):我就是要改你这个变量,而且不接受“没有对象”的情况。
这里我们不展开讲所有细节(比如左值/右值引用、参数传递的效率差异等),只先建立一个基本的直觉:
参数类型既决定了“怎么传”,也在告诉读者“我打算怎么用这个参数”。
后面“引用”“函数参数传递”这两篇文章,会在这个基础上继续往下挖。
5. 重载(Overloading):让名字和“意图”贴得更近
当你的项目里开始出现越来越多的函数时,很容易遇到这种选择:
void print_int(int value);
void print_double(double value);
void print_string(const std::string& value);
从机器角度看,这当然没问题;从人的角度看,这一串函数名读起来就有点累。同样是“打印”这件事,为什么非要我在名字上不断重复类型信息?
C++ 的回答是:让函数签名而不是“裸名字”来区分不同版本。
void print(int value) {
std::cout << "int: " << value << '\n';
}
void print(double value) {
std::cout << "double: " << value << '\n';
}
void print(const std::string& value) {
std::cout << "string: " << value << '\n';
}
在调用方那里,你就可以写出非常贴近日常语言的代码:
print(42);
print(3.14);
print(std::string{"hello"});
编译器会根据实参的类型,在所有名为 print 的函数中挑选一个最合适的版本。这就是“函数重载”的核心:
同一个名字,对应一族“签名略有不同”的函数,让调用点的代码更贴近你的思维表述方式。
当然,重载也可以被滥用:如果你写出十几个长得很像的重载,哪怕编译器能分清,读代码的人也会被绕晕。一个比较实用的经验是:
- 把“本质上是同一件事,只是参数类型略有不同”的逻辑放在同一组重载里;
- 把“语义上已经算是另一件事”的逻辑,起一个新名字,而不是硬塞进同一个函数名下。
6. 默认参数:为“常见情况”设计的捷径
有些函数天然有一个“最常见的用法”。比如你写一个简单的日志函数,大部分时候都是记普通信息,只有在少数地方才需要改变级别:
enum class LogLevel {
Info,
Warning,
Error,
};
void log(LogLevel level, const std::string& message) {
// 具体输出略
}
如果每次调用都写:
log(LogLevel::Info, "start");
log(LogLevel::Info, "done");
很快你就会觉得有些啰嗦。C++ 提供的默认参数(default argument),就是帮你把“最常见的那种用法”缩短成一个更顺手的接口:
void log(const std::string& message,
LogLevel level = LogLevel::Info) {
// 仍然可以通过 level 控制级别
}
log("start"); // 等价于 log("start", LogLevel::Info)
log("something wrong", LogLevel::Error);
从类型系统的角度看,带默认参数的函数仍然只有一个签名,只是调用方在某些位置可以省略实参:
- 当你省略了
level时,编译器会按“签名里写死的默认值”自动补上; - 当你显式传入
LogLevel::Error时,就完全按你传的来。
需要注意的是:
- 默认参数值必须在编译期就能确定(比如字面量、
constexpr常量等); - 在头文件里声明函数时,如果要提供默认参数值,只在一个地方写一次 就好,避免多处声明不一致。
比起单纯“少写几个字符”,默认参数更重要的价值在于:
它让你在函数签名上就能表达出“这个参数平时用不到”“大部分情况下都是这个值”的信息。
调用者在看到 LogLevel level = LogLevel::Info 时,不用看实现,就已经心里有数:绝大部分调用都只是传一条普通信息就够了。
7. 函数与类型系统:让接口说真话
回顾一下前面的例子:
void move(Point& p, double dx, double dy)用引用表达“我会改传进来的这个点”;void set_to_zero(int value)用值传递表达“我只用一份拷贝,不会改你的变量”;void print(const std::string& value)用const&表达“我只读不写,而且希望避免不必要的拷贝”;void log(const std::string& message, LogLevel level = LogLevel::Info)用默认参数表达“级别通常是 Info”。
这些设计看起来只是“多打了几个单词”,但它们共同完成了一件事:
让函数签名尽可能地说真话,把你对这段逻辑的期待,提前写进类型系统里。
当你在团队协作或维护旧代码时,这种诚实的函数签名可以极大降低沟通成本:
- 你不必每次都翻实现,就能大致判断一个函数会不会修改传进来的对象;
- 你可以从参数类型上,看出作者是更偏向“值传递”还是“共享修改”;
- 你可以在重载和默认参数的设计里,看出作者心里默认的“常见用法”是什么。
这篇文章只是函数世界的第一步,先从抽象层面把“函数签名”当作一种设计工具看了一眼。接下来几篇会分别把几个关键话题拆开讲:
- 函数参数传递:值、指针、引用到底该怎么选;
const的各种形态,和“只读接口”的设计;- 栈与堆、RAII、智能指针,函数在资源管理中的角色。
等你再回头看这里时,希望你能习惯性地从函数签名入手,先问自己两个问题:
- 这个函数的类型,老老实实地表达了它的意图了吗?
- 如果哪天有人改了实现,但忘了改签名,编译器能不能帮你发现不一致?