函数深度分析
函数声明与定义
- 声明的意义在于告诉编译器程序单元的存在
- 定义则明确指示程序单元的意义
- C语言通过extern进行程序单元的声明
- 一些程序单元在声明时可以省略extern
严格意义而言声明和定义完全不同
声明是向编译器介绍名字–标识符。它告诉编译器“这个函数或变量在某处可找到,它的模样象什么”。
而定义是说:“在这里建立变量”或“在这里建立函数”。它为名字分配存储空间。无论定义的是函数还是变量,编译器都要为它们在定义点分配存储空间。
1 |
|
extern int g_var;
是一个变量声明,意思是g_var在其他C文件定义了,编译器编译到这一行知道这是个别名,但是不会为这个别名分配一块内存,在后续看到这个g_var”不会感到意外“不会报错。
另一个文件global.c定义了这些声明的变量。
1 |
|
运行结果:
1 | fengyun@ubuntu:~/share$ ./test |
但如果修改test.c第13行改为struct Test* p = (struct Test*)malloc(sizeof(struct Test));
则会报错:
1 | fengyun@ubuntu:~/share$ gcc test.c global.c -o test |
而如果修改global.c 中int g_var = 10;
改为float g_var = 10;
1 | fengyun@ubuntu:~/share$ ./test |
观察输出的结果,发现g_var的值竟然变化如此大。本质原因就是声明(int 4个字节)和定义(float 8字节)不同。
当编译器到19行printf("g_var = %d\n", g_var);
的时候,编译器将会按照int4个字节去解释从外部文件global.h中定义的g_var的值(实际是8个字节,float型)。
函数参数
函数参数求值顺序不固定
函数参数的求值顺序依赖于编译器的实现
一道面试题:
下面的程序输出什么?为什么?
1 | int k = 1; |
我们理论上觉得应该输出1 2
;
然而求值顺序并没有一个明确的规定,ubantu gcc编译器实际上是先求第二个k++
,后求第一个k++
。
1 | fengyun@ubuntu:~/share$ ./test |
C语言的操作数求值顺序也并不是固定的,依赖于编译器实现。例如f()*g(),可能先返回g(),后返回f()。
程序的顺序点
- 程序中存在一定的顺序点
- 顺序点指的是执行过程中修改变量值的最晚时刻
- 在程序到达顺序点的时候,之前所做的一切操作必须完成
顺序点:
- 每个完整表达式结束时,即分号处
&& || ?:
以及逗号表达式的每个参数计算之后- 函数调用时所有实参求值完成后(进入函数体之前),比如上面的func(k++,k++)
1 |
|
理论分析结果应该是 5。但也可能是6(先执行k+k,在进行两次k++操作)。
1 | fengyun@ubuntu:~/share$ ./test |
我的gcc编译器输出5了,但是bcc32上输出的是6。
参数入栈顺序
函数参数的计算次序是依赖编译器实现的,那么函数参数的入账次序是如何确定的呢?
当函数调用发生时
- 参数会传递给被调用的函数
- 而返回值会被返回给函数调用者
调用约定描述参数如何传递到栈中以及栈的维护方式
- 参数传递顺序
- 调用栈清理
调用约定
调用约定是预定义的可理解为调用协议
调用约定通常用于库调用和库开发的时候(入栈顺序应保持一致)
- 从右往左依次入栈:_stdcall,_cdel,_thiscall
- 从左往右依次入栈:_pascal,_fastcall
在使用第三方库的时候一定要考虑一下调用约定。
可变参数
C语言中可以定义参数可变的函数
参数可变函数的实现依赖于stdarg.h的头文件
- va_list–参数集合
- va_arg–取具体参数值
- va_start–表示参数访问的开始
- va_end–标识参数访问的结束
可变参数的限制
- 可变参数必须从头到尾按照顺序诸葛访问
- 参数列表中至少要存在一个确定的命名参数
- 可变参数函数无法确定实际存在的参数的数量
- 可变参数函数无法确定参数的实际类型
注意:va_arg中如果指定了错误的类型,那么结果是不可预测的
printf中的%d就是一个可变参数。
参数默认值
- C++中可以在函数声明时为参数提供一个默认值
- 当函数调用时没有提供参数的值,则使用默认值
- 参数默认值必须在函数声明中指定
问题:
函数定义中是否可以出现参数的默认值?
当函数声明和定义中的参数默认值不同时会发生什么?
答案:
会报错,不允许
函数默认参数的规则
- 参数的默认值必须从右向左提供
- 函数调用时使用了默认值,则后续参数必须使用默认值
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
占位参数
在C+ +中可以为函数提供占位参数
- 占位参数只有参数类型声明,而没有参数名声明
- 一般情况下,在函数体内部无法使用占位参数
1 | int func(int x, int){ |
函数占位参数的意义
- 占位参数与默认参数结合起来使用
- 兼容C语言程序中可能出现的不规范写法
void func();与void func(void);
二者是否等价?
C语言中func()不加上参数直接编译不会报错,但是func(void)会报错
C++中func(void)和func()等价,都会报错
C++语言为了兼容C语言提出了函数占位参数的概念
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
总结:
在C语言中
int f()表示返回值为int ,接受任意参数的函数
f(void)表示返回值默认为int的无参函数
在C++中
int f()和int f(void)具有相同的意义,表示返回值为int的无参函数
调用函数时栈变化
cdecl 调用约定 (C 语言默认调用约定)
- 参数从右向左入栈
- 函数调用者负责参数的入栈出栈
- 函数本身根据约定使用栈中参数
1 | int main() |
- main 函数以 p1,p2,p3 的颇序将参数入栈
- 调用结束后, main 将 p1,p2,p3 从栈中弹出
调用者做了两件事情:第一,将被调用函数的参数按照从右到左的顺序压入栈中。第二,将返回地址压入栈中。这两件事都是调用者负责的,因此压入的栈应该属于调用者的栈帧。我们再来看看被调用者,它也做了两件事情:第一,将老的(调用者的) %ebpold
压入栈,此时 %esp
指向它。第二,push完ebpold后,将ebp的值更新为当前的esp的值,%ebp
就有了新的值,即指向 %ebpold
的地址。这时,它成了是函数 main()
栈帧的栈底。这样,我们就保存了“调用者”函数的 **%ebp
**,并且建立了一个新的栈帧。
ret返回时,esp=ebp,ebp=ebpold,pop后得到返回地址cs:ip即可
ebp 是函数调用以及函数返回的核心寄存器
- - ebp 为当前栈帧的基准(存储上一个栈帧的ebp 值)
- - 通过 ebp 能够获取返回值地址,参数, 局部变量, 等
函数和宏分析
- 宏是由预处理器直接展开替换的,编译器不知道宏的存在
- 函数是由编译器直接编译的实体,调用行为由编译器决定
- 多次使用宏会导致最终可执行程序的体积增大(多次文本替换)
- 函数是跳转执行的,内存中只有一份函数体存在
- 宏的效率比函数要高,因为是直接展开,无调用开销
- 函数调用时会创建活动记录,效率不如宏
函数和宏使用规则
- 宏的效率比函数稍高,但是其副作用巨大
- 宏是文本替换,参数无法进行类型检查
- 可以用函数完成的功能绝对不用宏
- 宏的定义中不能出现递归定义
宏的副作用
观察这个程序,宏展开后容易发现程序的语义变成了我们无法预料的结果。
我们可以使用内联函数来代替宏。
宏的妙用
- 用于生成一些常规性代码
- 封装函数,加上类型信息
封装函数:
1 |
|
注意FOREACH,while(1)是增加了一个代码块,虽然while循环只执行了一次,但是while循环代码块让作用域内部定义的变量生命周期只在作用域内部,不会影响到外面。
1 | fengyun@ubuntu:~/share$ ./test |
递归函数分析
递归是一种数学上的分而治之的思想
递归需要有边界条件
- 当边界条件不满足时,递归继续进行
- 当边界条件满足时,递归停止。如果没有边界条件将导致栈溢出
递归将大型复杂问题转化为原问题相同但是规模较小的问题进行处理
函数体内部可以调用自己
递归函数 –函数体中存在自我调用的函数
递归函数时递归的数学思想在程序设计中的应用,
函数设计原则
- 函数从意义上应该时一个独立的功能模块
- 函数名要在一定程度上反映函数的功能
- 函数参数名要能够体现参数的意义
- 尽量避免在函数中使用全局变量
- 当函数参数不应该在函数体内部修改时,应该加上const声明
- 如果参数是指针,且仅作输入参数,则应加上const声明
- 不能省略返回值的类型,如果不需要返回值,也应声明为void类型(C语言编译器允许不提供返回值类型,默认是int,但不建议这样做,存在二义性)
- 对参数进行有效性检查(对指针参数尤为重要)
- 不允许返回指向“栈内”的指针
- 函数体的规模要小,尽量控制在80行代码以内
- 相同的输入对应相同的输出,避免函数带有“记忆”功能
- 避免函数有过多的参数,参数个数尽量控制在4个以内(linux系统提供API参数一般不多)
- 有时候函数不需要返回值,但是为了增加灵活性,如支持链式表达,可以附加返回值,比如
int len = strlen(strcpy(s,"fengyun"));
- 函数名与返回值类型在语义上不可冲突,比如getchar()函数,返回的竟然是int类型,虽然返回值当成char不会报错,但造成二义性
优秀代码范例:IBM公司的eclipseUtil.c
C++内联函数
C++中的const常量可以替代宏常数定义,如:
const int A= 3; 《=》 #define A 3
C+ +中是否有解决方案替代宏代码片段呢?
- C+ +中推荐使用内联函数替代宏代码片段
- C++中使用inline 关键字声明内联函数
1 | inline int func (int a, int b) |
注意:内联函数声明时inline关键字必须和函数定义结合在一起,否则编译器会直接忽略内联请求。
- C+ +编译器可以将一个函数进行内联编译
- 被C++编译器内联编译的函数叫做内联函数
- C++编译器直接将函数体插入函数调用的地方
- 内联函数没有普通函数调用时的额外开销(压栈,跳转,返回)
- C+ +编译器不一定满足函数的内联请求!
宏和内联
1 |
|
如果用宏代码块,a,b,c的值都将会是3。因为宏扩张后14行int c = (++a) < (b) ? (++a) : (b)
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
内联函数汇编分析
用vs2010进行反汇编查看代码,发现是调用func函数,而不是函数体直接放在这,并没有所谓的内联展开的行为,这是因为vs2010拒绝了我们的内联请求。
修改vs2010配置
接着再来反汇编查看
再来g++编译器反汇编查看,默认也是拒绝了内联请求。
内联函数特性
- 内联函数具有普通函数的特征(参数检查,返回类型等)
- 函数的内联请求可能被编译器拒绝
- 函数被内联编译后,函数体直接扩展到调用的地方
宏代码片段由预处理器处理,进行简单的文本替换,没有任何编译过程,因此可能出现副作用。内联函数效率不输于宏代码块而且具有语法检查等功能
- 现代C++编译器能够进行编译优化,一些函数即使没有inline声明,也可能被内联编译
- 一些现代C++编译器提供了扩展语法,能够对函数进行强制内联,如:
g++:_attribute_((always_inline))
MSVC:_forceinline
inline内联编译的限制
- 不能存在任何形式的循环语句
- 不能存在过多的条件判断语句
- 函数体不能过于庞大
- 不能对函数进行取址操作
- 函数内联声明必须在调用语句之前
现代编译器非常先进,即使有以上的限制,也可以进行内联,具体看编译器
C++函数重载
重载(Overload)
同一个标识符在不同的上下文有不同的意义
如:
“洗”和不同的词汇搭配后有不同的含义
洗衣服,洗脸,洗脑,洗马桶…
‘play” 和不同的单词搭配后有不同的含义
play chess, play piano, play basketball…
函数重载(Function Overload)
- 用同一个函数名定义不同的函数
- 当函数名和不同的参数搭配时函数的含义不同
1 | int func(int x) |
函数重载的条件
- 参数个数不同
- 参数类型不同
- 参数顺序不同
当函数重载遇上了函数默认参数,有可能存在二义性而导致出错。
函数重载准则
- 将所有同名函数作为候选者
- 尝试寻找可行的候选函数
精确匹配实参
通过默认参数能够匹配实参
通过默认类型转换匹配实参 - 匹配失败
最终寻找到的候选函数不唯一, 则出现义性,编译失败。
无法匹配所有候选者, 函数未定义, 编译失败。
注意事项
- 重载函数在本质上是相互独立的不同函数
- 重载函数的函数类型不同
- 函数返回值和返回类型不能作为函数重载的依据
函数重载是由函数名和参数列表决定的!
如果函数的名称和参数完全相同,仅仅是返回值类型不同,是无法进行函数重载的。
重载与指针
函数重载遇上函数指针
将重载函数名赋值给函数指针时
- 根据重载规则挑选与函数指针参数列表一致的候选者
- 严格匹配候选者的函数类型与函数指针的函数类型【返回值类型不是函数重载匹配的的依据,但是函数重载遇上函数指针就需要考虑返回值类型了】
如图所示,函数指针的函数返回类型与其他三个候选者返回类型都不一样,候选者的函数类型与函数指针的函数类型匹配不上而报错。
C++是一种强类型语言,如果匹配不上就会报错。
注意
- 函数重载必然发生在同一个作用域中(全局作用域,类作用域等)
- 编译器需要用参数列表或函数类型进行函数选择
- 无法直接通过函数名得到重载函数的入口地址
如图,直接通过函数名无法得到重载函数的入口地址,15行和16行报错
但是如果加上函数指针,指针会严格的匹配函数类型和参数列表获取地址,如图所示得到了两个不同的入口地址。
接着我们通过中间文件test.obj寻找两个重载函数add的信息有何不同
注意看这个符号表,编译器编译这个文件的两个重载函数add所得到的标识符【名字】是不同的,对编译器而言,重载函数是不同的标识符的函数
C++和C的相互调用
extern “C”
- 实际工程中C++和C代码相互调用是不可避免的
- C++编译器能够兼容C语言的编译方式
- C++编译器会优先使用C++编译的方式
- extern关键字能强制让C++编译器进行C方式的编译
如图所示add.c是C代码,main.cpp是C++代码
先gcc -c add.c -o add.o
生成add.o二进制代码。然后执行g++ main.cpp add.o
试图生成a.out可执行文件,但是执行失败了。
这是因为add.c是用C代码,是用C语言编译方式生成的,
注意C语言编译方式和C++编译方式是不同的,而我们的C++为了兼容C语言,我们必须要用extern "C"
告诉C++编译器这些内容是C代码,必须用C语言编译方式来编译它
1 | fengyun@ubuntu:~/share$ gcc -c add.c -o add.o |
问题:
如何保证一段C代码只会以C的方式被编译?
extern "C"
是C++的写法,gcc编译器是不认识extern "C"
,
我们不能在main.c中添加extern 'C'
的
这里给了一个巧妙地写法
1 |
|
_cplusplus
是C++编译器内置的标准宏定义_cplusplus
的意义: 确保C代码以统一-的C方式被编译成目标文件
注意
C++编译器不能以C的方式编译重载函数
编译方式决定函数名被编译后的目标名
- C++编译方式将函数名和参数列表编译成目标名
- C编译方式只将函数名作为目标名进行编译
1 | //extern "C" |
1 | fengyun@ubuntu:~/share$ gcc -c main.cpp -o main.o |
但如果在main.cpp上加上extern "C"
后用g++编译将会报错
1 | fengyun@ubuntu:~/share$ g++ -c main.cpp -o main.o |
小结
- 函数重载是C++对C的一个重要升级
- 函数重载通过函数参数列表区分不同的同名函数
- extern关键字能够实现C和C++的相互调用
- 编译方式决定符号表中的函数名的最终目标名 [C++将函数参数和函数名共同决定最终目标名,C语言中函数名决定最终目标名]