资讯动态

C++函数详解:声明、定义、传参、默认值与重载

发布时间:2026/10/14 9:28:42 来源:尧图企业网站定制
把 C 的函数单独整理一篇笔记是我自己学完一遍后觉得非常有必要的事。函数声明、函数定义、函数调用还有传值、指针、引用、参数默认值和函数重载看起来是各自独立的语法点实际上它们串着同一条线你写给别人调用的代码到底怎样才算把“接口”这件事说清楚了。很多初学者能轻松写出 main 里的简单调用但一旦开始写多文件工程遇到的第一批编译错误几乎都出在这些地方。这篇笔记适合两类人一类是刚看完 C 基础语法、正在往“能写结构清晰的小程序”阶段走的人另一类是已经写过一些练习项目但被声明、定义、三种传参方式绕晕过的人。我会把每个概念的“为什么”尽量讲清楚而不只是罗列语法规则。最后还会给一份可以完整编译运行的示例代码把声明、定义、调用、默认参数和重载全部串起来方便你照着敲一遍。为了控制篇幅这篇文章不会讲类、模板、lambda 这些进阶话题只聚焦在普通自由函数也就是全局函数上。把这一篇吃透后面再去看成员函数、构造函数很多思路都是相通的。1. 为什么函数值得单独拆一篇它其实是程序的第一层抽象很多人刚学函数的时候只记住了一句话把重复代码包起来可以反复调用。这句话没错但它严重低估了函数在 C 里的地位。函数不只帮你减少重复代码它还是程序里最基础的一层抽象边界。一个程序能不能改得动、测得了很大程度上取决于函数的边界画得清不清楚。我见过不少练习项目一开始只写一个 main几百行代码全堆在一起。能跑但想改一个功能的时候要在一大坨代码里反复上下找关联。后来把计算逻辑、输入输出、数据校验分别拆成函数之后很多问题一下就清晰了。这其实就是“抽象”的价值你调用一个函数只需要知道它接收什么、返回什么不需要关心它内部到底写了多少行。1.1 函数的三种身份对应三组问题同样一个函数站在不同视角看关注点完全不同。区分这些视角能帮你理解很多编译错误和设计取舍。调用者视角关心的是“我该传什么我能拿回什么”。比如说int result max(3, 5);调用者只关心传两个整数进去拿回来较大的那个。调用者不需要知道函数内部是用了if还是三目运算符。实现者视角关心的是“拿到参数之后怎么算出正确结果有没有副作用”。写实现的人会纠结边界条件、类型转换、性能开销这些事。编译器视角关心的是函数的签名是否清晰、声明和定义是否一致、调用点的参数类型能不能匹配。编译器才是真正“较真”的那一方它不会猜你的意图只按规则办事。强调编译器视角是因为初学者经常把编译错误单纯当成“程序写错了”。其实很多错误的原因是编译器不知道“你要调用的函数长什么样”或者找不到“函数的具体实现”。搞懂这三种视角后面再遇到报错信息你会更容易定位原因。1.2 函数签名就是一份接口合同在 C 里函数签名通常指函数名、参数类型、参数个数以及参数和返回值的 const 限定等。这些信息加在一起相当于一份“接口合同”双方按合同约定的方式协作。调用者必须按合同给参数实现者必须按合同返回结果。如果有一方不按合同办事编译器或者链接器就会跳出来报错。比如函数声明里写的是void foo(int, double);调用者却传了三个参数编译器会直接告诉你参数个数不匹配。这不是你代码写错了而是合同没对齐。理解“接口合同”还有一个好处它能帮你分清什么该写在头文件里什么该写在源文件里。头文件里放的是合同函数声明源文件里放的是履约过程函数定义。这两者缺了谁程序都无法正常构建。2. 函数声明与函数定义先弄懂编译器在找什么声明和定义的区别是标题里最容易被人一眼带过、却又最容易出问题的一对概念。很多人把这两个词混着用写起来也不分场合。这里把它们分清楚后面所有讨论都有基础了。2.1 编译器为什么需要先见到函数声明C 源文件是自上而下被处理的。编译器遇到一个函数调用时它需要在这个调用点之前就知道这个函数的返回值类型是什么、参数列表是什么。如果不知道编译器就无法生成正确的调用代码也无法判断实参是否匹配。来看一个典型错误#include iostream int main() { std::cout max(3, 5) std::endl; // 这里 max 还没有声明 return 0; } int max(int a, int b) { return a b ? a : b; }这段代码在主流编译器下会报类似“标识符 max 未定义”或“在此作用域中尚未声明”的错误。为什么函数明明写在下面却不能用因为编译器在你调用max的那一刻还不知道它的存在。解决办法有两种把max的定义挪到main之前或者在main之前加一句声明。第二种做法更常见因为它不改变代码的组织顺序也更接近真实工程里“头文件放声明、源文件放定义”的玩法。2.2 声明和定义的书写边界函数声明只有一行签名加一个分号int max(int a, int b);函数定义则是签名加函数体int max(int a, int b) { return a b ? a : b; }一个核心规律是声明可以出现多次定义在整个程序里只能有一次。这个“一次”指的是 ODR也就是“单一定义规则”。如果一个函数在多个源文件里都被定义了链接器会报重复定义错误如果声明了却始终没有定义链接器会报无法解析的外部符号。之所以强调这个规律是因为很多人把“声明可以多次”理解成“随便在哪声明都行”把“定义只能一次”理解成“反正就写一次无所谓”。这两句话只有放在多文件编译的环境下你才会真正感受到分量。2.3 头文件放声明、源文件放定义的原因假设你有circle.h和circle.cpp两个文件。circle.h里放函数声明circle.cpp里放函数实现另一个文件main.cpp通过#include circle.h来调用这些函数。每个.cpp文件在编译时是一个独立的编译单元。main.cpp只看到了头文件里的声明所以它知道该怎么调circle.cpp只管实现最后链接器把两边的信息拼在一起。如果反过来把函数定义直接写在头文件里并且让多个.cpp文件都包含这个头文件每个编译单元就都有一份定义链接阶段就会撞车。除非你写成inline函数否则头文件里放定义是典型的坑。头文件守卫也值得顺手养成习惯#ifndef CIRCLE_H #define CIRCLE_H double circleArea(double radius); double circlePerimeter(double radius); #endif这样能避免同一个头文件在同一个编译单元里被重复包含。2.4 声明与定义不一致时你会看到什么声明和定义不一致分为几种情况。常见的是参数类型不一致比如声明写的是void foo(int);定义写的是void foo(double);。调用方按声明的签名生成调用代码传了int进去链接器却找不到void foo(int)的实现因为实现只有void foo(double)于是报“无法解析的外部符号”。另一种情况更隐蔽定义里多写了一个参数默认值导致声明某一处和定义不完全一致。这种问题在单文件里可能不明显一旦拆成多文件报错会变得很难排查。我的习惯是改函数签名时先改头文件里的声明再同步改源文件里的定义绝不只改一边。3. 函数调用的三种传参方式传值、指针、引用这一部分是 C 初学者最容易产生“会写但不懂”感觉的地方。传值、传指针、传引用语法各自不同底层行为也不同用错场景很危险。我们把它们拆开看。3.1 传值拷贝一份互不相欠传值是最直观的一种方式。调用时实参的值会被复制一份给形参函数内部操作的是这份拷贝和实参不再有关系。#include iostream void addTen(int num) { num 10; std::cout 函数内部 num num std::endl; } int main() { int a 5; addTen(a); std::cout 调用之后 a a std::endl; return 0; }输出会显示函数内部num变成了 15但a仍然是 5。因为num只是a的副本。传值的好处是安全函数怎么折腾都不影响外部实参。坏处是开销如果参数是一个大对象每次调用都要整体拷贝一份时间和内存成本都不小。所以传值适合小型对象、基础类型以及那些你明确不想修改实参的场景。3.2 传指针传的是地址改的是原主传指针时形参是一个指针变量它保存的是实参的地址。函数内部可以通过解引用操作*ptr来访问并修改实参本身。#include iostream void addTen(int* num) { if (num ! nullptr) { *num 10; } } int main() { int a 5; addTen(a); std::cout 调用之后 a a std::endl; return 0; }注意两件事调用时要传地址a函数内部要解引用*num才能改到a。如果你在函数里直接写num 10改的是指针本身而不是它指向的整数。传指针的一大特点是可以表达“可能没有值”。指针允许为空函数里必须做判空处理。这既是优点也是风险。常见的 C 语言接口大量使用指针传参但 C 里如果只是为了修改实参通常有更好的选择。3.3 传引用漂亮的别名语法引用变量本质上是另一个变量的别名。写引用参数时调用端不用取地址函数内部不用解引用但修改会对实参生效。#include iostream void addTen(int num) { num 10; } int main() { int a 5; addTen(a); std::cout 调用之后 a a std::endl; return 0; }这里的num就是a的别名num 10等价于直接改a。引用语法的优点是写起来像传值行为上像指针但比指针更安全引用一旦绑定到某个变量就没有空引用的说法也不需要判空。缺点是它无法表达“这里可能没有对象”的情况。另外非 const 的左值引用不能绑定字面量比如addTen(5)会编译失败因为5不是一个有名字的变量不能给非 const 引用当别名。3.4 选型判断一张表和一个递进问题把三种方式放在一起对比会更直观传参方式是否拷贝能否修改实参能否表达空值调用端写法函数内写法传值是否不需要直接传直接用形参传指针否只拷贝地址可以可以传变量判空后*ptr传引用否可以不可以直接传直接改别名选型时可以先问自己三个问题我这个函数需不需要修改实参如果不需要小对象直接传值大对象用const引用避免无谓拷贝。如果需要修改实参那么这个对象有没有“可能不存在”的情况有用指针并在函数入口判空没有优先用引用写法更安全。这个函数是否要兼容旧式的 C 风格接口如果是那只能按指针方式处理。const引用是一个经常被忽略的利器。比如一个函数只读一个很大的字符串写成void printLength(const std::string s)既不拷贝又不会误改实参。刚学函数的人往往只会想到传值和传指针等体会到const引用带来的方便才算真正理解了引用存在的意义。4. 函数参数默认值三条规则管住省事写法默认参数的本意是减少调用方的负担某些参数大多数时候用同一个值调用时可以不写编译器会自动补上。听起来很方便但用错位置、用错规则报错也都很烦。4.1 规则一默认参数必须从右往左连续设置如果函数有多个参数带默认值的参数必须排在参数列表右边。这是 C 的硬性规定。void showMessage(const std::string msg, const std::string prefix [INFO]);这个写法合法调用时可以写showMessage(hello)编译器补上prefix为[INFO]。反过来写出void showMessage(const std::string msg hello, const std::string prefix);这是不合法的。因为你调用showMessage()时编译器不知道prefix该填什么调用showMessage(hi)时按第二个参数必须传的要求这个hi到底是给msg还是给prefix参数匹配会变得混乱所以编译器直接不允许。4.2 规则二默认参数写在声明处定义处不要重复这是最容易踩的坑。很多人在实现文件里给函数定义加默认值结果编译报“默认参数重复定义”。原因在于默认参数要做到对所有调用者透明就必须放在调用者能看到的地方也就是声明所在的位置。一个正确的拆分是这样的// 头文件 test.h void showMessage(const std::string msg, const std::string prefix [INFO]);// 源文件 test.cpp void showMessage(const std::string msg, const std::string prefix) { std::cout prefix : msg std::endl; }注意定义处不要再次写 [INFO]。这样设计的原因也很简单其他文件引用头文件时只有声明可见默认值必须跟着声明走调用者才能获得自动补全的能力。如果默认值只写在定义里那所有没有直接看到定义的调用方都必须老老实实把所有参数都写齐全。4.3 规则三默认参数是编译期替换默认参数不是函数运行时自己判断“这个参数没传我就用它”而是在调用点由编译器直接替换成默认值。这意味着默认参数的值要在编译期可见并且遵循当时的调用上下文。最常见的默认参数是常量字面量如3.14、0、[INFO]。在调用点编译器会把对应的参数位置填成这些值。了解这一点对你排查问题很有帮助如果你写了一个默认参数依赖某个全局变量这个值是在调用点读取的而不是在函数定义处读取的。同时这也解释了为什么默认参数不能依赖某个被调函数内部的局部变量。因为编译器在调用点根本无法访问那个函数内部的状态默认参数的值必须在调用点上下文中能够求值。4.4 和函数指针一起使用时要注意函数指针的类型签名不携带默认参数信息。也就是说虽然你可以通过函数名直接调用时省略带默认值的参数但一旦你把函数地址赋给函数指针再通过指针调用就必须把所有参数写全。void showMessage(const std::string msg, const std::string prefix [INFO]); void (*ptr)(const std::string, const std::string) showMessage; // 通过函数指针调用时下面这种写法是错误的 // ptr(hello); // 必须写全参数 ptr(hello, [INFO]);原因是函数指针类型本身只记录了参数类型不记录默认参数信息。编译器按指针类型生成调用无法替你做参数补全。这是一个比较隐蔽的坑出现过一次就很难忘记。5. 函数重载同名函数背后的匹配规则函数重载就是让同名函数根据不同参数类型执行不同逻辑。C 和 C 的一大差异就在这里C 语言里函数名必须全局唯一而 C 允许你根据参数列表区分同名函数。5.1 编译器如何区分重载函数C 编译器会通过“名字修饰”技术把函数名和参数类型信息编码成内部符号名。比如两个函数void show(int value); void show(double value);编译后它们内部的符号名会被改成包含参数类型信息的形式本质上已经是两个不同的名字。链接器看到的不是两个show而是两个不同的符号。这正是重载能在链接阶段“太平相处”的原因。明白了这一点你就能理解为什么返回值类型不能作为重载区分依据。调用show(5)时如果只看返回值类型int show(int)和double show(int)无法区分因为很多情况下调用者可以完全忽略返回值那编译器就不知道到底该选哪一个。所以 C 明确规定仅返回类型不同不能算有效重载。5.2 重载决议的匹配顺序当调用一个重载函数时编译器会按照各种匹配级别选择最佳候选。粗略的优先顺序是精确匹配优于提升提升优于标准转换标准转换优于用户定义转换。举个例子void show(int value); void show(double value); short s 10; show(s);short到int属于整型提升short到double属于标准转换。提升的优先级高于转换所以编译器会选择void show(int)。再看一个常见案例void f(int value); void f(double value); f(42); // 42 是 int精确匹配 int 版本 f(3.14); // 3.14 是 double精确匹配 double 版本如果实参是char类型char到int的提升优先级也会高于char到double所以大概率选择void f(int)。初学者不用把每一档规则背到滚瓜烂熟但至少要知道编译器不是随便挑选一个看起来能转换的版本它有一套优先级系统。5.3 默认参数与重载叠加的歧义重载和默认参数单独看都挺好用叠在一起就可能出问题。考虑下面的声明void f(int a, int b 10); void f(int a);调用f(5)时第一个版本可以通过默认补全变成f(5, 10)参数个数是合法的第二个版本就是f(5)参数个数也是合法的。两个版本都能精确匹配编译器无法判断你更想调用哪一个于是报二义性错误。这类问题在现实中出现频率不低。解决办法是调整设计要么重载的各版本参数结构差异足够大要么干脆只用一个带默认参数的函数不要同时提供容易冲突的版本。5.4 引用和 const 参与重载的细节引用和 const 也会影响重载决议。最经典的例子void change(int a); void change(const int a); int x 10; change(x); // x 是可修改的左值优先匹配 int 版本 change(10); // 10 是右值字面量不能绑定到非 const 引用只能匹配 const int 版本这里还有一个容易误解的地方有人以为const int也能匹配change(x)为什么非 const 版本优先因为非 const 引用绑定到可修改对象时限制更少、更精确编译器倾向于选择它。只有当实参本身是 const 对象或右值无法绑定到非 const 引用时才会退而求其次选择 const 版本。指针也有类似区别void f(char*)和void f(const char*)是两个不同的重载版本。传字符串字面量时由于字面量是 const 数据会优先匹配 const 指针版本。6. 把知识点串起来一个带头文件的小项目单独讲语法容易“懂是懂了写不出来”。这里给一个完整的迷你项目把声明、定义、传值、指针、引用、默认参数、重载全部串起来。建议你新建三个文件照着敲一遍亲手观察每个函数的效果。6.1 头文件声明部分// geometry.h #ifndef GEOMETRY_H #define GEOMETRY_H // 函数声明都在这里 double circleArea(double radius); double circlePerimeter(double radius); // 引用版本直接修改传入的半径默认按 1.2 倍缩放 void scaleRadius(double radius, double factor 1.2); // 指针版本可能传空指针函数内部需要判空 void scaleRadius(double* radiusPtr, double factor); // 重载版本支持整型半径缩放 int scaleRadius(int radius, int factor); #endif这里特意让scaleRadius出现了三个版本引用版本带默认参数、指针版本、整型重载版本。它们的参数类型各不相同所以可以共存。6.2 源文件实现部分// geometry.cpp #include geometry.h double circleArea(double radius) { return 3.14159 * radius * radius; } double circlePerimeter(double radius) { return 2 * 3.14159 * radius; } void scaleRadius(double radius, double factor) { radius * factor; } void scaleRadius(double* radiusPtr, double factor) { if (radiusPtr ! nullptr) { *radiusPtr * factor; } } int scaleRadius(int radius, int factor) { return radius * factor; }注意scaleRadius(double, double)的定义处不能写 1.2默认参数已经写在声明里了。另外重载版本scaleRadius(int, int)的形参没有用引用它的语义是返回一个新值而不是直接修改实参。6.3 调用方如何选择传参方式// main.cpp #include iostream #include geometry.h int main() { double r1 5.0; std::cout 面积为 circleArea(r1) std::endl; // circleArea 传的是值r1 在调用后仍然是 5.0 double r2 5.0; scaleRadius(r2); // 引用 默认参数r2 被直接修改为 5.0 * 1.2 std::cout r2 缩放后 r2 std::endl; double r3 5.0; scaleRadius(r3, 2.0); // 指针版本调用要取地址函数内部通过 *radiusPtr 修改 r3 std::cout r3 缩放后 r3 std::endl; double* rp nullptr; scaleRadius(rp, 2.0); // 空指针被安全处理函数内部会判空再解引用 int r4 5; int result scaleRadius(r4, 3); // 整型重载版本传值返回新结果r4 本身不变 std::cout r4 缩放结果为 result std::endl; return 0; }看这段代码时建议把注意力放在“哪个实参被改了、哪个实参没被改”上。circleArea(r1)是传值所以 r1 不变scaleRadius(r2)是传引用所以 r2 被改成 6scaleRadius(r3, 2.0)是传指针所以 r3 被改成 10scaleRadius(r4, 3)是整型重载它返回新值 15r4 还是 5。亲手跑一遍你会对三种传参方式有一个非常直观的印象。7. 高频错误清单与学习建议把函数这块学完真正希望你带走的不只是一套语法而是一套排查问题的思路。这里整理几个我在教学和写代码过程中最常遇到的高频错误。7.1 最容易踩的六个坑错误类型典型现象解决思路调用前没有声明编译阶段报“标识符未定义”使用前加声明或把定义提前声明与定义签名不一致链接阶段报“无法解析的外部符号”对照头文件和实现文件逐参数核对类型默认参数写在实现里调用者使用默认参数时提示“参数太少”默认参数只写在声明位置非 const 引用绑定字面量编译阶段提示“非常量引用的初始值必须为左值”改用传值、const 引用或先放到变量里只用返回值类型区分重载编译阶段报“无法重载仅区分返回类型的函数”调整参数列表或换一个函数名指针参数没判空就解引用运行阶段崩溃或行为异常函数入口判空或改用引用传参第六个坑尤其隐蔽。指针传参给了你“表达空值”的能力但实实在在地要求你承担起判空责任。如果你其实根本不需要传空指针那引用通常比指针更省心。7.2 一套适合初学者的验证流程我建议初学者不要一上来就写一个 500 行的练习工程而是按下面这个流程走每走一步都亲手验证先把所有函数写在一个源文件里在调用之前写定义跑通最简单的传值调用。改成“先声明、后调用、定义放最后”的结构体会声明的必要性。把函数拆到另一个源文件通过头文件声明来调用感受多文件编译的基本方式。分别用传值、传指针、传引用写一个“修改实参”的小实验观察变量在调用前后的变化。给某个函数加默认参数先写在声明处再尝试写在定义处看看两种写法的报错差异。写一个带重载的版本故意把函数参数改成容易产生歧义的组合观察编译器的二义性报错。这样一轮下来你踩过的坑都会变成经验。我在早期学习时最不重视函数声明觉得它不过是“可有可无的一行”直到开始拆多文件工程才被一连串的“未定义”“无法解析”教育了一轮。现在我的习惯是每个文件名对应的头文件先写好声明再写实现实现和声明永远同步修改绝不一边写一边两边各自发挥。这能省下大量查错时间。说到底函数这块内容不难但它决定了你写出来的 C 是“只给自己看的练习脚本”还是“能交给别人维护的代码结构”。把声明、定义、调用、传参、默认参数和重载这六件事理顺后面学类、学模板、学智能指针都会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑