期中复习课件
期中复习串讲
参与投票:21 人
top3:函数指针相关(18)函数嵌套(14)内联/重载函数(13)
我们着重讲这三部分,有一半人存在疑点,剩下的几个部分也会提一嘴,帮助大家复习一下。
函数指针相关
函数地址
函数和变量一样,都要占用内存空间。
既然占用了内存空间,存函数的那片内存的地址自然就成为函数的地址,函数的指针就指向了那一片地址。
但是函数地址和变量地址有所不同,调用方式也有所不同。
void Sample();
//调用的三种方式
Sample();//函数名直接调用
(&Sample)();//通过地址调用
(*&Sample)();//间址调用
//特别的,函数地址也是它的入口地址
cout << Sample << endl;
cout << &Sample << endl;
cout << *&Sample << endl;
//结果一样,都是一个地址,也就是函数的入口地址
printf("%p\n%p\n%p\n", Sample, &Sample, *&Sample);
实际上,函数调用的一般形式应该是:函数地址(形参表)
其中函数地址是指入口地址,这个入口地址可以是任意一个结果是函数地址的表达式(当然也可以是指针)
函数的类型
函数类型和它的返回类型不是一个东西。
比如 int a
,a 的类型是 int,double b
, b 的类型是 double
int fun(int, int)
,fun 的类型是 int(int, int)。
假设函数有五个参数,那他的类型就是 int(int, int, int, int, int)。这样很麻烦
typedef 关键字可以自定义重命名一种类型。
typedef long long LL;
typedef unsigned long long uLL;
typedef int rint_5int (int, int, int, int, int);
rint_5int max, min, jntm;
函数指针
我们说指针必须和他指向内存里存的东西的类型一致。
假设一个内存里存的 int,那么必须用 int* 的指针指向它的内存 (void* 除外)。
同理,指向函数地址的指针也必须和函数类型相同。
typedef int rint_5int (int, int, int);
int f(int, int, int, int, int);
int* pf1(int, int, int, int, int) = &f;
rint_5int* pf2 = &f;
//令 pf1 pf2 指向 f 的地址
pf1(1, 2, 3, 4, 5);
pf2(1, 2, 3, 4, 5);
//通过指针调用函数 f
递归嵌套/递归
讲 ppt 吧,上次 cty 已经讲过一次了。
内联函数与重载函数
内联函数
int f(int i)
{
return i * 2;
}
int main()
{
int a = 4;
int b = f(a);
}
上面是一段 C++ 程序,下面是我充当人肉编译器 yy 的一段汇编代码(它不是汇编,只是为了方便大家理解)
_f_int:
add ax, @sp[-8], @sp[-8] 让 sp - 8 的两个值加起来放到 ax 里
ret 返回
_main_int:
add sp, #8 sp 是堆栈寄存器指针,sp + 8 在堆栈里留出 8 字节空间
mov ax, #4 ax 是运算用寄存器,让 ax = 4
mov @sp[-8], ax 把 ax 赋值到 sp - 8 的内存里 (a = 4)
mov ax, @sp[-8] 把 sp - 8 的内存赋值给 ax
push ax 把 ax 推进堆栈里,成为参数 i
call _f_int 呼叫 f 函数,把 sp - 4 的位置作为返回值
mov @sp[-4], ax ax 赋值到 sp - 4 的地方去
pop ax 把 i pop 掉,销毁参数
当一个函数被调用的时候,发生了这些事情:
- 把参数推进堆栈
- 把返回地址推进堆栈
- 准备返回值,放在 ax 寄存器
- 把 push 进去的本地变量 pop 掉
所以调用一个函数并没有我们想象的那么简单,编译出来之后实际上会做很多额外的开销。
所以 C++ 提供了一个手段——内联函数(inline small functions)。
如果一个函数是内联的,编译器会把这个函数的代码嵌入调用它的地方去,并且最终不保留函数原型。
如果 f 是 inline 的函数呢?
/* 源代码 */
inline int f(int i)
{
return i * 2;
}
int main()
{
int a = 4;
int b = f(a);//这里编译器会做类型检查,运行时不再检查
}
/* 实际生成的代码 */
int main()
{
int a = 4;
int b = a + a;
}
可以看到 inline 函数实际上可以通过复制自身来减少函数调用开销。
但是 inline 不是万能的,而且程序员的 inline 对于编译器来说只是一个建议,最终函数能否内联是编译器说了算的。
- 当函数体过大的时候(超过 5 行),编译器放弃内联。
- 当函数体内存在循环、递归的时候,编译器放弃内联。
- 当声明的时候加了 inline,定义的时候不加 inline,编译器放弃内联(对定义不对声明)。
- 声明时加 inline,定义不在本文件内,编译器放弃内联。
- 声明时加 inline,定义加 inline 且不在本文件内,编译器报错。
- 没加 inline 的小函数,编译器自动内联。
在内联的过程中,编译器是皇帝,你只能建议皇帝做什么,而不能指示皇帝做什么。
有些编译器对放弃内联的判定非常松,如果乱加 inline 可能导致负优化。
重载函数
重载函数是指同名不同参数表的 n 个函数。
「不同参数表」的定义很广泛,参数的数量,顺序都会影响参数表。
void f(double x, int y) { cout << "Hello, World!" << endl; }
void f(int x, double y) { cout << "World, Hello!" << endl; }
void f(int x, int y){ cout << "KFC crazy Thursday, vivo 50." << endl; }
void f(int x, int y, int z){ cout << "sing, dance, rap, basketball." << endl; }
上面的四个 f 互相为重载函数。
注意,同名同参数表不同返回类型的函数不是重载函数:
int f(int x){ return 114514; }
double f(int x){ return 191981.0; }
形参名字不一样也不是重载函数:
int f(int x){ return 114514; }
double f(int y){ return 1919810; }
骗自己可以,但你骗不过编译器。
在编程的时候,我们希望可以“顾名思义”,并且代码具有美感,这样可以有效缓解偏头痛。
重载函数给我们提供了根据不同参数类型用一个相同的名字调用不同函数的可能。
比如说我们可以定义一个 plus 函数,支持 int,double,自定义类 Complex(复数),char* 的相加:
const int plus(const int& x, const int& y) { return x + y; }
const double plus(const double& x, const double& y) { return x + y; }
const Complex plus(const Complex& x, const Complex& y)
{ return Complex(x.real + y.real, x.imaginary + y.imaginry); }
char* plus(const char* x, const char* y)
{
int lenx = strlen(x), leny = strlen(y);
char* newString = new char[lenx + leny + 1];
strcpy(newString, x);
strcpy(newString + lenx, y);
return newString;
}
c = plus(a, b);
不论 a,b 是什么类型,只要我们的 plus 支持,它们就可以被加在一起。
这样程序员不需要写 plus1, plus2, plus3, plus4, plus5 这种令人头疼的东西来区分它们。
其实上面的东西已经有点「多态性」的意思了,不过多态性比这复杂得多,不再介绍。
函数定义相关
额,刚才都讲了那么多函数了,这个还用讲吗……
其实我感觉这部分同学是不太明白形参和实参的关系,对返回值可能也理解没到位。
声明和定义的区别
借这个机会强调一下,C++ 里声明和定义的区别很大。
int f(int, int);//declearation
int f(int, int)//definition
{
cout << "I love SCCE\n";
}
声明只是告诉编译器:我有一个叫 x 的变量/函数,但是它放在哪不知道。
定义才是告诉编译器:x 这个变量/函数就在这。
通常声明和定义是在一起的,但如果有需要分开的话,一定要注意。
如果声明了没定义,编译器会报错 undefined symbol.
形参和实参的关系
在传参数进去的时候,实际上发生了赋值操作:
void f(int x)
{
x ++;
}
int a = 1;
f(a);
函数里面的参数和本地变量在一起,函数结束后就会销毁。
进函数的时候,实际上执行了 x = a
,把 x 赋值为 a,x 和 a 是两个不同的变量,从此函数和外面的 a 没关系了。
“我改的 x 的值关你 a 什么事?”
如果参数是一个指针呢?
void f(int* x)
{
*x ++;
}
int *p = new int;
*p = 1;
f(p);
这时候,实际上也执行了 x = p
,x 和 p 是两个不同的指针,从此函数和外面的 p 没关系了。
但是我可没说和 p 指向的内存里的东西没关系了。
注意,x = p
只是新建了一个 x 指针指向 p 所指的地址,改变 x 指向的对象不影响外面 p 指向的对象。
但是 x 通过解引用修改它指向对象里的东西可是 p 管不着的。
“我改 p 指向的内存里的东西关你 p 什么事?”
如果参数是一个引用呢?
void f(int& x)
{
x ++;
}
int p = 1;
f(p);
这时候依然执行了 x = p;
,函数里的 x 是外面的 p 的一个引用。从此 x 就是 p,p 就是 x,它们同富贵,共患难。
如果参数是一个指针的引用呢?
void f(int*& x)
{
x ++;
*x ++;
}
int* p = new int;
*p = 1;
f(p - 1);//Error! 非常量引用的初始值必须为左值
f(p --);//Error!
f(-- p);//OK!
思考:如果参数是一个引用的引用/引用的指针呢?
返回值是引用的函数
返回值是引用的函数可以做表达式的左值:
inline int& plus(int& x, int& y)
{
x += y;
return x;
}
int a(1), b(2);
plus(a, b) += 3;
// a = 6
加了 const 之后,该引用不能做左值:
inline const int& plus(int& x, int& y)
{
x += y;
return x;
}
plus(a, b) += 3;//Error!
分支结构
if-else
最简单最常用的分支控制语句。
if(表达式)//这里没有分号
{
//do something...
}
if(表达式)
{
//do something...
}
else
{
//do something...
}
如果 if 下面不加大括号,if 的作用域为它下面一行的代码,通常用缩进来区分。
switch
switch(opt)
{
case (常量表达式) :
break;
case (常量表达式) :
break;
default :
break;
}
cout << opt;
- 常量表达式必须与 opt 具有相同类型。
- 当 opt 等于某个常量表达式,执行该 case 后的所有语句,遇到 break 为止。
- 遇到 break 语句,switch 语句终止,控制流转 switch 代码块下一行。
- 在任何时候,break 和 default 都不是必须的,根据需要添加。
switch 在 3 分支以上的执行效率通常高于 if-else,但是它写起来麻烦一些。
三目运算符
(表达式1) ? (表达式2) : (表达式3);
x == 1 ? cout << "yes" : cout << "no";
更简单的理解方式:
表达式1是真吗 ? 是 : 不是。
循环结构
for 和 while
for(表达式1 ; 表达式2 ; 表达式3)//中间是分号,不是逗号(分号和逗号的区别)
{
//do something...
}
- 表达式 1,2,3 均不是必须填写的。
- 表达式 1 在进入 for 的时候会执行一次。
- 表达式 2 每次循环结束后执行一次,为假时,退出 for 循环。
- 表达式 3 每次循环结束后都要执行一次。
while(表达式)
{
//do something...
}
do
{
//do something...
}while(表达式)
- 重复执行直到表达式为假。
break 语句可以直接退出 for 语句,continue 可以中断当前轮循环直接进行下一轮。
循环嵌套
for(int i = 0 ; i < 6; i ++)
{
for(int j = 0 ; j < 6 ; j ++)
{
cout << "i = " << i << ", j = " << j << '\n';
}
}
只有内层循环执行完之后,外层循环执行下一轮。
变量作用域
变量的作用域即变量的生存周期,当一个变量的生存周期结束,就无法再调用这个变量了(但是它占用的内存有可能还在某个地方)。
一般变量
通常来说变量的作用域是它所在的代码块
int main()
{
{
int i;
cin >> i;
}
cout << i << '\n';//Error! i is not decleared in this scope
}
全局变量
不在任何一个函数中的变量是全局变量,从它被声明到程序结束,在任意位置都可以调用全局变量。
函数变量
函数的参数,函数内申请的本地变量,作用域为函数内。
函数内定义的 static 变量,作用域为全局。
int z;
void f(int a)
{
static int x = 1;
int j = 0;
}
int main()
{
cout << x;//Error!
f(1);
cout << x;//OK!
cout << j;//Error!
cout << a;//Error!
cout << y;//Error!
cout << z;//OK!
}
int y;
代码调试法
这一 part 会教大家如何调试一坨屎山。
虽然编译器提供 gdb 调试工具,但对于单个源文件的调试来说,还是太麻烦了。
杀鸡焉用牛刀?
大眼瞪小眼
指不动手只对着代码看的方法,此法调试的效率不稳定,通常适用于一些简单的错误。
写过的 bug 越多,干瞪眼法调试的平均效率越高。
但是我们要有的放矢,不能真大眼瞪小眼。
你可以优先检查这些东西:
- 死循环/无限递归?
- 是否有变量未初始化直接拿来使用?
- 是不是忘了调用某个函数?
- 循环终点判断是否正确?
- 内存管理合适与否?
void copy(char*& x,const char*& y)
{
//delete [] x;
strcpy(x, y);
}
//copy(x, x);
中间数据透明化
这个方法适用于输出的结果和你期望的结果不一样的情况。
检查每一步的中间变量,输出它们,看看值是否正确?
我们的手段包括但不限于:
- 输出整个数组检查
- 输出输入的值检查
- 输出循环变量检查
- 输出递归调用的函数参数检查
幸存者报告
这个方法适用于程序执行到某个地方莫名其妙炸了的情况。
在可能导致程序 RE 的句子后面添加幸存报告,表示程序执行到这一句的时候暂时还活着。
typedef int rint_5int (int, int, int, int, int);
int f(int, int, int, int, int)
{
// cout << "rint_5int f called.\n";
cout << "Hello, World!" << endl;
// cout << "fint_5int f ended.\n";
}
int main()
{
int (*pf1)(int, int, int, int, int) = &f;
rint_5int* pf2 = &f;
pf1(1, 2, 3, 4, 5);
// cout << "Program survived after called pf1.\n";
pf2(1, 2, 3, 4, 5);
// cout << "Program survived after called pf2.\n";
return 0;
}
小黄鸭调试法
首先找一个物品,当然,也可以是真人。
把你的代码一句一句解释给 TA 听,当你自己有点解释不下去的时候,说明这个地方可能有问题。
答疑时间
大家的疑问可以畅所欲言哦!
包括学习 C++ 的一些经验,等等方面。
微信程设课群也是一个很方便的提问途径,大家有问题也可以发到群里一起讨论(这是段老师官方推荐且允许的)。