C++ 函数入门:参数、调用栈、返回值与重载
05 函数:把一段逻辑装进一个可调用的盒子
控制流帮我们把基本的逻辑组织起来,但随着代码越写越长,新的问题也会随之而来。比如同一段计算可能在不同地方反复出现,或者 main 里的代码长到让人很难一眼看清程序到底在做什么。
这时候就需要函数来解决代码组织的问题。它的核心作用是把一段逻辑打包收拢,起个专属的名字,等需要用的时候只要叫这个名字就能执行。
先来看一个最简单的函数:
int Add(int a, int b) {
return a + b;
}
这段代码中 Add 是函数名,括号里的 int a, int b 就是我们要传给函数的参数。最后的 return a + b; 负责把计算结果交回给调用的地方。整个函数的意图一目了然:接收两个整数,然后返回它们的和。
函数首要的价值就在于命名。虽然 a + b 只是个简单的表达式,但把它封装成 Add(a, b) 后,它就拥有了一个具体的动作名称。名字一定下来,调用代码的人就无需关心里面具体是怎么加的,只要清楚这个函数需要什么输入、会给出什么输出即可。当程序规模越来越大,这些函数名就会成为代码主线上的清晰路标。
函数给一段逻辑起名字
一个完整的函数定义通常由四个部分组成:
拿前面的 Add 函数来对照的话:
int Add(int a, int b) {
return a + b;
}
开头的 int 是返回类型,表明这个函数最终会算出一个整数结果。Add 就是函数名。小括号里的 int a, int b 构成了参数列表,而大括号包起来的那些代码就是函数体。
如果想要调用这个函数,只需要写出函数名并传入具体的参数值:
int result = Add(3, 4);
这里的 3 和 4 是实际传进去的参数。在调用发生的瞬间,它们会被用来初始化函数内部的参数 a 和 b。等到函数执行完毕,return 后面的结果就会回到调用的位置,顺便把 result 这个变量也初始化了。
其实函数调用本身也会产生一个结果,我们可以把它当成一个普通的值来用:
std::cout << Add(3, 4) << "\n";
int total = Add(1, 2) + Add(3, 4);
第一行代码直接把函数算出的结果打印出来,第二行则是把两次调用的结果又做了一次加法。可见函数调用完全可以融入更复杂的表达式中,用起来和普通变量或者数字没什么区别。
这就意味着函数调用往往承担着执行具体动作和产生计算结果双重任务。它不仅跑了一段逻辑,还会在表达式里留下一个结果。因此在阅读代码时,遇到函数调用需要留意两点:它具体去做了什么事,以及它有没有把处理完的数据交回给当前的表达式。
我们在给函数起名时,要尽量清晰地回答“这段逻辑到底在做什么”。像 Add、Square、ClampScore 或 PrintAverage 这种名字就能让人立刻抓住动作的意图。如果名字起得含糊不清,哪怕函数体只有一两行,也会无形中增加阅读负担。诸如 Process、Handle 或 DoWork 这种宽泛的词,在没有上下文的时候很难让人明白它到底负责哪一步。因此在刚开始学习拆分函数时,把名字起准确往往比刻意追求拆解的层级更重要。
除此之外,函数名和参数名的配合也很关键。比如 ClampScore(int score) 读起来就非常顺畅,名字表示“限制分数范围”,参数明确指代“分数”。如果图省事写成 Clamp(int x),编译器虽然不会报错,但读代码的人就得点进去看函数体才能知道这个 x 究竟是个什么东西。理想情况下,函数接口应该读起来像一个简短的句子,这样在调用的地方就会非常清晰。
返回类型其实也是这个“句子”的一部分。比如 bool IsValidScore(int score) 就像是在抛出一个真假问题,double Average(const std::vector<int>& scores) 显然是在执行某个数值计算,而 void PrintAverage(int total, int count) 则像是在下达一个纯粹的输出指令。函数名、参数和返回类型这三者结合在一起,理应让读者不看函数内部细节也能把它的职责猜个八九不离十。
实参怎样进入函数
我们来看看一段带有调用的完整代码:
#include <iostream>
int Add(int a, int b) {
return a + b;
}
int main() {
int x = 3;
int y = 4;
int result = Add(x, y);
std::cout << result << "\n";
return 0;
}
在这段代码中,x 和 y 都是 main 函数自己地盘里的局部变量。当执行到 Add(x, y) 时,程序会把 x 的值拿去初始化 Add 内部的 a,同时把 y 的值拿去初始化 b。在这个最基础的例子里,a 和 b 完完全全是 Add 函数专属的变量。
整个过程可以大概画成这样:
main:
x = 3
y = 4
调用 Add(x, y)
↓
Add:
a = 3
b = 4
return a + b
↓
main:
result = 7
目前这种传递参数的方式叫做“按值传递”,也就是说函数拿到的只是数据的一份拷贝。等到后续学习引用时,我们会看到还有一种方式能让函数直接去修改调用者原本的数据。
按值传递非常适合那些体积小的基础类型,比如 int、double 或者是 bool。但如果面对的是体积庞大的对象,每次调用都把数据拷贝一遍就会显得有些浪费性能了。这个话题我们留到探讨引用和对象生命周期的时候再去深入。
参数传递方式其实也属于函数接口的一部分。像 int Add(int a, int b) 这种写法就明确表达了函数只需要两个独立的整数拷贝。因为 Add 里拿到的是自己独立的 a 和 b,所以哪怕在函数里把这两个值改得面目全非,也不会对外部的 x 和 y 产生丝毫影响。但如果参数类型变成了带引用的 int&,函数和外部调用者之间的边界就没有这么泾渭分明了。
这也是为什么在阅读函数声明时,绝对不能只盯着函数名看,参数类型里往往藏着很多关键信息。比如 int score 意味着函数仅仅需要一份成绩数据;而带有引用的 std::string& name 则暗示它可能会悄悄修改外面传进来的字符串;如果是带了常量的 const std::string& name,那就在保证不会弄坏数据的前提下,仅仅去读一下内容。只要把接口写得足够讲究,代码里的很多废话注释都能直接删掉。
调用栈解释"进去又回来"
对于刚接触编程的人来说,函数最不可思议的地方可能在于:程序跳到另一个函数去执行代码后,居然还能精确地找回刚才中断的位置继续往下跑。
我们可以把函数调用想象成一场短暂的出差旅程:
这里提到的“局部环境”可以粗略理解为函数调用时独享的临时工作区,里面存放着这次传入的参数和函数自己声明的变量。一旦函数完成任务,这个临时工作区就会被立刻撤销清理。
我们可以观察一个带有局部变量的函数:
int AddWithBonus(int a, int b) {
int bonus = 1;
int result = a + b + bonus;
return result;
}
每当我们调用一次 AddWithBonus,系统都会为它专门准备一套全新的 a、b、bonus 和 result。一旦调用结束,这些变量也就失去了赖以生存的空间。等到下一次再调用这个函数时,系统又会重新搭建一个新的工作区并创建一批新的局部变量。
这就是关于调用栈最基础的直觉。虽然深入探究调用栈会牵扯到复杂的内存布局和底层规范,但在这里我们只需建立起一个朴素的模型:函数调用就是带着参数临时进入另一段代码,办完事后带着结果原路返回。
有了这个心智模型,很多原本令人费解的现象就有了合理的解释。比如为什么外面无法访问函数内部的变量?因为那是人家临时工作区里的私有物品。为什么有些自己调用自己的函数在每一层都不会互相干扰?因为每一次进入都会分配一块全新的场地。为什么函数一结束局部变量就灰飞烟灭?自然是因为整个场地都被系统回收了。
另外,从调用栈的角度来看,把大函数拆小也具备非常现实的意义。试想如果一个巨型函数里堆满了各种变量,我们在读代码时脑子里也得跟着塞满一堆状态。而把它拆散成几个小函数后,每次阅读时只需要关注当前这几个变量就够了。所以拆分函数绝不是为了让代码看起来更高级,而是为了切实减轻我们理解代码时的心智负担。
返回值怎样带出来
通常在 return 关键字后面,我们会跟上一个具体的计算表达式:
int Square(int x) {
return x * x;
}
程序会先把 x * x 的结果算明白,然后再把这个结果作为函数的最终答卷交上去。在调用的地方,我们可以自由支配这个拿到的结果:
int area = Square(5);
std::cout << Square(6) << "\n";
int total = Square(3) + Square(4);
需要注意的是,函数声明的返回类型必须和 return 后面的结果对得上号。因为 Square 承诺了要返回一个 int,那么最后送出来的就必须是个整数。如果类型对不上,编译器会试图按它的规则去转换,一旦它觉得这种转换可能会弄丢数据,就会弹警告甚至直接报错。
当然,并非所有函数都必须交回一个结果:
void PrintLine(int value) {
std::cout << value << "\n";
}
如果前面标了 void,就说明这个函数属于那种光干活不交差的类型。它照常能去控制台打印东西,能去修改外面的数据,也能去差遣别的函数。在学习初期,可以用一个简单的标准来做判断:如果这段逻辑的任务是算出个结果给别人用,就写返回值;如果它仅仅是去执行某个具体的动作,那就放心用 void。
返回值的有无,直接决定了函数在代码里的长相。能吐出结果的函数经常被嵌在复杂的表达式里充当一个组件,而 void 函数往往只能老老实实地独占一行。所以 int Average(...) 看起来就是在安心算数,而 void PrintAverage(...) 显然是在发号施令做输出。我们在体会函数职责的时候,要结合函数名和返回类型一起来看。
从长远来看,是否带返回值还会影响到代码好不好测试以及能不能被复用。负责计算的函数由于把结果交了出去,别人既能拿着它的结果继续深加工,也方便写测试去验证它算得对不对;而那些直接把结果打印到终端的输出型函数,通常就只能放在程序的收尾环节了。所以在写基础小程序的时候,尽量有意识地把计算和输出分开来做:先写个 Average 算出具体数值,再专门写个 PrintAverage 负责往外打印,这样的代码结构将来如果要修改或者扩展都会从容得多。
函数声明和函数定义
C++ 的编译器是个规矩很严的家伙,它在检查某行函数调用是否合法之前,必须得先知道这个函数大概长什么模样。在这里,完整的函数实现叫做“定义”,而仅仅透露函数接口长相的叫做“声明”。
第一句 int Add(int a, int b); 就是在做声明。它相当于是跟编译器提前打个招呼:待会你会遇到一个叫 Add 的函数,它需要两个 int 数据,并且最后会还给你一个 int 结果。有了这句招呼,main 函数里再去调用它时,编译器就不会觉得莫名其妙了。
至于写在后面的 int Add(int a, int b) { ... },就是函数真正的定义了。这里面包含了具体怎么做加法的详细步骤。
如果是平时随手写的小段代码,我们直接把整个函数的定义扔到 main 函数前面也就完事了。但如果真遇到什么大型项目,声明和定义往往会被残忍地拆散到不同的文件里去。这种做法的具体机制会在编译链接的环节详细讲解,眼下只需要记住一点:声明是用来应付编译器检查的,定义才是负责具体干活的。
将声明和定义拆开来写,是 C++ 构建多文件工程的基石。因为编译器在扫过 main 函数看到调用时,只要确认接口没用错就行了,至于怎么算的逻辑,它留到稍后再去检查后面的定义。这套机制使得庞大的代码终于可以按功能分块管理,当然也附带引出了诸如头文件、链接之类的进阶烦恼。
所以在初期的单文件练习里,最直接的解法就是让短小的函数定义乖乖待在 main 前面。这样编译器顺着看下来,先知道怎么实现再看到怎么调用,一切顺理成章。等到后面函数越写越多,看着觉得乱了,也可以试着在文件最开头列一排声明,把所有的实现细节都挪到 main 的尾巴后面。这两种写法其实都行得通,只是代码阅读顺序有细微差别而已。大家按着自己觉得最舒服的方式来就行,大可不必为了假装大项目而在小代码里折腾这些形式。
以后真正上手正经项目时,你会发现大家普遍都把声明塞进头文件,而把定义藏进源文件里。其他人只要包含了那个头文件,就能弄明白接口该怎么用,直到最后的链接阶段,整个程序才会把各自分散的零件拼合起来运转。所以千万别觉得写声明和写定义是在重复造轮子:声明关注的是“别人该怎么用”,而定义关注的才是“我自己具体怎么做”。
简单重载
在 C++ 的规则里,只要函数所需要的参数列表有明显区别,它是允许好几个函数共享同一个名字的。这种操作在编程里叫做“函数重载”。
int Add(int a, int b) {
return a + b;
}
double Add(double a, double b) {
return a + b;
}
当代码实际运行到调用那一步时,编译器会根据你传进去的数据类型,自动去匹配最合适的那一个版本:
int int_result = Add(1, 2);
double double_result = Add(1.5, 2.5);
比如第一行传了两个整数,它就会乖乖去找 int Add(int, int) 来执行。而第二行传了带小数的数值,它就非常机灵地换成了 double Add(double, double)。
重载的存在,主要是为了表达“同一个动作逻辑能顺手处理不同类型的数据”。不过在刚开始写代码的时候,建议把这个技巧用得稍微克制一点。毕竟让好几个函数顶着同一个名字,在一定程度上就是在考验读者的眼力,这就要求它们在参数上的差异必须做到一目了然。如果在某些场景下你觉得别人可能会看岔眼,那老老实实给它们起两个不同的名字反而是更稳妥的做法。
重载最大的优势是让写调用代码的人觉得非常自然,不用去记一堆稀奇古怪的变体名字。但这同样也带来了隐患:编译器背后那套选择规则其实并不简单。像 Add(1, 2) 和 Add(1.5, 2.5) 这种确实是一眼看穿的逻辑;但如果哪天你把整数、浮点数、乃至各种稀奇古怪的数据混在一起传,读代码的人可能就得在脑子里跟编译器斗智斗勇,去猜它到底选中了哪个版本。因此在初期,我们大可以把重载纯粹当成是一个方便我们“用相同逻辑处理不同基础类型”的小工具就行了。
用函数拆分一个小任务
我们来分析一段稍微具体点的成绩统计代码:
#include <iostream>
int ClampScore(int score) {
if (score < 0) {
return 0;
}
if (score > 100) {
return 100;
}
return score;
}
int Add(int a, int b) {
return a + b;
}
void PrintAverage(int total, int count) {
if (count == 0) {
std::cout << "no scores\n";
return;
}
std::cout << "average = " << total / count << "\n";
}
int main() {
int total = 0;
int count = 0;
int score = 120;
score = ClampScore(score);
total = Add(total, score);
++count;
PrintAverage(total, count);
return 0;
}
这段逻辑里,ClampScore 专门负责处理分数超界的问题,Add 用来做单纯的累加,而 PrintAverage 集中火力去输出平均分。作为总调度室的 main 函数只需要保留一条干净的主线脉络:准备好数据,按顺序调用对应的函数,最后把结果呈现出来。
我们去拆解函数,核心目标就是让每一个小区块只专注干好一件明确的事情。函数里的代码越短小精悍,外面挂着的名字越是一针见血,别人在看代码的时候就越容易一眼判断出这段逻辑到底正不正常。
如果我们把这种调用关系画出来,大概是这个样子:
拆分函数真正的价值其实就体现在这三点上:消灭重复出现的脏代码、把复杂的逻辑分摊变薄、以及让最核心的流程水落石出。
在考虑该怎么拆的时候,首先要顺着职责去切分。比如在这段代码中,纠正边界和打印输出都是非常独立且具体的职责,而统筹全局自然是留给 main 函数的。如果你发现拆出来的一段代码能轻而易举地用一个精准的名字概括掉,那这种拆分往往就是站得住脚的。反之,如果在起名字的时候抓耳挠腮、犹豫不决,那八成是因为这段代码里夹杂了太多乱七八糟的琐事。
当然,我们也不提倡陷入“为了拆而拆”的怪圈。比如上面的那个 Add 函数,如果在真实的业务代码里大概率是不需要单独写出来的,直接一句 total += score; 反而更清爽直接。在教程里之所以把它摆出来,仅仅是为了演示函数的相互调用。在日常写项目的时候,你得时刻去衡量这次拆分到底有没有真的让代码复用起来,或者有没有让某个概念变得更具象。检验拆分合不合理其实有个很朴素的标准:拆完之后,你的主线看起来是不是更清晰了?局部的那些小计算是不是更好排查错误了?起出来的名字是不是恰如其分?只有经得起这几个反问,你的拆分才是有意义的。
另外,还有一个非常直白的信号暗示你该拆函数了:当同一套逻辑在不同的地方重复出现的时候。比如程序里好几个角落都需要把分数强行卡在 0 到 100 之间,这时候就应该毫不犹豫地抽出一个 ClampScore 来。那些重复复制粘贴的代码,真正可怕的不仅仅是占地方,而是在未来某天规则变动的时候,你极有可能改漏掉其中的一处。如果能把这种共用的规则统收进一个函数里,所有的调用方共享同一个事实来源,以后的维护工作就会顺心很多。
还有一个常见的信号,就是当你发现主流程已经被各种鸡毛蒜皮的细节彻底淹没的时候。想象一下,main 函数原本的戏份应该是在高屋建瓴地调度“获取数据、处理计算、输出结果”,结果却被一堆诸如边界检测、字符串格式化之类的琐碎逻辑塞得满满当当,读代码的人根本摸不清程序的重心到底在哪。只要把这些繁杂的细节分别装进各自的函数盲盒里,main 函数就能立马找回主线的清晰感。即使是平时写些玩具级的小程序,也有意识地去培养这种有条理的阅读结构,绝对是稳赚不赔的买卖。
函数里的局部变量会引出下一章
只要有函数调用发生,系统就会在后台默默创建好形参和那些属于函数内部的变量。一旦函数收工,这些名字的作用范围也就跟着终结了。举个简单的例子:
int Add(int a, int b) {
int result = a + b;
return result;
}
代码里的 a、b 和 result 统统只服务于当前这唯一一次的 Add 调用,别的地方哪怕是管事的 main 函数也别想绕进去访问它们。而且你每次去调用 Add,它都会原样复制一套全新的变量供自己支配。
这种现象很自然地引出了我们下一章即将面临的两个核心概念:作用域和生命周期。其中作用域主要探讨的是一个名字在哪些范围内才算合法有效,而生命周期则关心这些对象在内存里到底能撑到什么时候才会被回收。在我们将代码用函数层层包裹起来之后,去认清并掌握这两个概念的边界感,就会变得异常关键。
面对一个完全陌生的函数,一个比较舒服的阅读习惯是先从接口看起:
等到把接口里透露的信息摸个大概,再接着去看函数体里写了啥:
return a + b;
有了前面接口的铺垫,你此刻心里已经很清楚 a 和 b 都是外面传进来的数值,而 return 则负责把结果安全地运回原地。这也是为什么读函数大可不必一上来就一头扎进实现细节里。先在接口层面弄清楚它和外界是怎样的一种协作关系,然后再去看它内部是如何去兑现这个约定的,往往会事半功倍。
当我们自己动手去写函数时,不妨也在心里过一遍类似的检查清单:
🏂
函数名是否说清动作
参数是否说清需要什么
返回类型是否说清交回什么
函数体是否只做名字承诺的事
设想一下,如果一个函数大张旗鼓地起名叫 PrintAverage,结果里面的代码不仅把成绩打印出来了,还顺手去读取了文件、悄悄改掉了全局的配置、甚至还算出了个绩点评级,那它显然是手伸得太长了。在刚开始学习编程的时候,哪怕写的是微不足道的小程序,也尽量把这条底线守好:函数名字承诺了去干嘛,里面的代码就只准干嘛。
读完这一章,如果只带走一个实用的习惯,我希望是这种有层次的阅读方式:先看函数名,接着看需要什么参数,再留意返回了什么类型,最后才去细究函数的内部实现。只要顺着这套由外向内的顺序去拆解,哪怕是面对稍微有些规模的代码,你眼里的结构感也会比逐行去生啃要清晰透彻得多。
阅读导航




