算法入门经典 刘汝佳 4.2 地址与指针

发布时间 2023-12-05 16:27:13作者: 王闯wangchuang2017

4.2 地址和指针

4.1节介绍的数学函数的特点是:做计算,然后返回一个值。有时候,我们要做的事情 并不是“计算”——如交换两个变量;而有时候,我们需要返回两个甚至更多的值——如解一个二元一次方程组。

4.2.1 变量交换

程序4-4 用函数交换变量(错误)

#include<stdio.h>

void swap(int a, int b)

{

  int t = a; a = b; b = t;

}

int main()

{

  int a = 3, b = 4;

  swap (3, 4) ;

  printf("%d %d\n", a, b) ;

  return 0;

}

你应当还记得,这就是三变量交换算法,其中void是“无值”的意思。返回“无值型" 的函数没有返回值,可以用不带参数的return语句退出,也可以在函数体执行完毕后自然退出。下面我们测试一下这个函数好不好用。很不幸,输出是3 4,而不是4 3。事实上,a和b并没有被交换。为什么会这样呢?为了理解它,请回忆赋值。“诡异”的赋值语句a=a+1是这样解释的:分为两步,首先计算赋值符号右边的a+1,然后把它装入变量a,覆盖它原来的值。那函数调用的过程又是怎样的呢?

第一步,计算参数的值。在上面的例子中,因为a=3,b=4,所以swap(a,b)等价于swap(3,4)。这里的3和4被称为实际参数(简称实参)。

第二步,把实参赋值给函数声明中的a和b。注意,这里的a和b与调用时的a和b是完全不同的。前面已经说过,实参最后将算出具体的值,swap函数知道调用它的参数是3和4,却不知道它们是怎么算出来的

函数声明中的a和b称为形式参数(简称形参)。

稍等一下,这里有个问题!这样一来,程序里有两个变量a,一个在main函数里定义,一个是swap的形参,二者不会混淆吗?不会。函数(包括main函数)的形参和在该函数里定义的变量都被称为该函数的局部变量(local variable)。不同函数的局部变量相互独立——你无法访问其他函数的局部变量。需要注意的是,局部变量的存储空间是临时分配的,函数执行完毕时,局部变量的空间将被释放,其中的无法保留到下次使用。与此对应的是全局变量(global variable):它在函数外声明,可以在任何时候,由任何函数访问。需要注意的是,全局变量非常危险,应该谨慎使用。

提示4-11:函数的形参和在函数内声明的变量都是该函数的局部变量。无法访问其他函数的局部变量。局部变量的存储空间是临时分配的,函数执行完毕时,局部变量的空间将被释放,其中的值无法保留到下次使用。在函数外声明的变量是全局变量,它们可以被任何函数使用。操作全局变量有风险,应谨慎使用。

这样一来,函数的调用过程就可以简单说成:计算实参的值,赋值给对应的形参,然后把“当前代码行”转移到函数的首部。换句话说,在函数刚开始执行时,局部变量 a=3,b=4,二者的值是在函数调用时,由实参复制而来。

那么执行完毕后,函数又干了些什么呢?把返回值扔给调用它的函数,然后再次修改 “当前代码行”,恢复到调用它的地方继续执行。等一下!函数是如何知道该返回到哪里继续执行的呢?为了解释这一问题,我们需要暂时把讨论变得学术一些——不要紧张,很快就会结束。

4.2.2 调用栈

还记得在讲解for循环时,笔者是如何建议的吗?多演示程序执行的过程,把注意力集中在“当前代码行”的转移和变量值的变化。这个建议同样适用于对函数的学习,只是要加一样东西——调用栈(Call Stack)。

调用栈描述的是函数之间的调用关系。它由多个栈帧(Stack Frame)组成,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量,因而不仅能在执行完毕后找到正确的返回地址,还很自然地保证了不同函数间的局部变量互不相干——因为不同函数对应着不同的栈帧。

提示4-12:C语言用调用栈(Call Stack)来描述函数之间的调用关系。调用栈由栈帧(Stack Frame)组成,每个栈帧对应着一个未运行完的函数。在gdb中可以用backtrace(简称bt)命令打印所有栈帧信息。若要用p命令打印一个非当前栈帧的局部变量,可以用frame命令选择另一个栈帧。

在继续学习之前,建议读者试着调试一下刚才几个程序,除了关心“当前代码行”和变量的变化之外,再看看调用栈的变化。强烈建议你在执行完函数的主体但还没有返回main函数之前,停下来看看swap和main函数所对应的栈帧中a和b的值。如果受条件所限制,在阅读到这里时没有办法完成这个实验,下面给出了用gdb完成上述操作的命令和结果。

第一步:编译程序

gcc 4-4.c -g

生成可执行程序a.exe(在Linux编译选项下是a.out)。编译选项-g告诉编译器生成调试信息。

第二步: 运行gdb

gdb a.exe

这样,gdb在运行时会自动装入刚才生成的可执行程序。

第三步 : 查看源码

(gdb) 1

1 #include<stdio.h>

2 void swap(int a, int b)

3 {

4     int t = a; a = b; b = t;

5 }

7 int main() 

8 {

9     int a = 3, b = 4;

10     swap(3, 4);

11     printf("%d %d\n", a, b);

12     return 0;

13 }

这里(gdb)是gdb的提示符,字母1是输入的命令,它是list(列出程序清单)的缩写。正如我们所看到的,swap函数的最后一行是第4行,当执行到这一行时,swap函数的主体已经结束,但函数还没有返回。

第四步: 加断点并运行

(gdb) b 4

Breakpoint 1 at 0x401308: file 4-4.c, line 4.

(gdb) r

Starting program: D:\a.exe

Breakpoint 1, swap (a=4, b=3) at 4-4.c:4

4    }

其中b命令把断点设在了第4行,r命令运行程序,之后碰到了断点并停止。接下来, 让我们来看看调用栈吧。

第五步:查看调用栈

(gdb) bt

#0 swap (a=4, b=3) at 4-4.c:4

#1 0x00401356 in main () at 4-4.c:8

(gdb) p a

$1 = 4

(gdb) p b

$2 = 3

(gdb) up

#1 0x00401356 in main () at 4-4.c:8

8 swap(3, 4);

(gdb) p a

$3 = 3

(gdb) p b

$4 = 4

这一步是关键。根据bt命令,调用栈中包含两个栈帧:#0和#1,其中0号是当前栈帧函数,1号是它的“上一个”栈帧——main函数。在这里我们甚至能看到swap函数的返回地址0x00401356,尽管我们看不懂它的具体含义。

使用p命令可以打印变量值。我们先查看了当前栈帧中a和b的值,分别等于4和3——这正是用三变量法交换后的结果。接下来用up命令选择上一个栈帧,再次使用p命令查看a和b的值,这次却是3和4了——它们是main函数中的a和b。前面说过,在函数调用时,它们只起到了“计算实参数”的作用。但实参被赋值到形参之后,main函数中的a和b也完成了它们的使命。swap函数甚至无法知道函数中也有着和形参同名的a和b变量,当然也就无法修改它们。最后别忘了用q命令退出gdb。

花了这么多篇幅解释调用栈和栈帧,你也许会觉得有点不值,但无数的经验告诉笔者:理解它们对于今后的学习和编程是至关重要的,特别是递归——初学者学习语言的最大障碍之一,调用栈将大大帮助理解。

4.2.3 用指针实现变量交换

是时候回到我们的函数中来了:好不容易讲清楚了为什么刚才的不能奏效,那应该如何编写swap函数呢?答案是用指针。

程序 4-5 用函数交换变量 (正确〉

#include<stdio.h>

void swap(int* a,int* b)

{

int t = *a; *a = *b; *b = t;

}

int main()

{

int a = 3, b = 4; 

swap(&a, &b); 

printf("%d %d\n", a, b) ; 

return 0;

}

怎么样,是不是觉得不太习惯,却又有点似曾相识呢?不太习惯的是int和a中间的“乘号”,而似曾相识的是swap(&a,&b)这种“变量名前面加&”的用法——到目前为止,唯一采取这种用法的是scanf系列函数,而只有它改变了实参的值!

变量名前面加&得到的是该变量的地址。什么叫“地址”呢?

提示4-13:C语言的变量都是放在内存中的,而内存中的每个字节都有一个称为地址(address)的编号。每个变量都占有一定数目的字节(可用sizeof运算符获得),其中第一个字节的地址称为变量的地址。

下面用gdb来调试上面的程序,看看它和程序4-4到底有什么不同。前四步是一样的, 直接看看调用栈。

(gdb) bt

#0 swap (a=0x22ff74, b=0x22ff70) at 4-5.c:4

#1 0x0040135c in main () at 4-5.c:8

(gdb) p a

$1 = (int *) 0x22ff74

(gdb) p b

$2 = (int *) 0x22ff70

(gdb) p *a

$3 = 4

(gdb) p *b

$4 = 3

(gdb) up

#1 0x0040135c in main () at 4-5.c:8

8 swap(&a, &b);

(gdb) p a

$5 = 4

(gdb) p b

$6 = 3

(gdb) p &a

$7 = (int *) 0x22ff74

(gdb) p &b

$8 = (int *) 0x22ff70

在打印a和b的值时,得到了“诡异”的东西——(int *)0x22ff74和(int *)0x22ff70数值0x22ff74和0x22ff70是两个地址(以0x开头的整数以十六进制表示,在这里暂时不需了解细节),而前面的(int*)告诉我们:a和b是指向int类型的指针。

提示4-14:用int *a声明的变量a是指向int型变量的指针。赋值a=&b的含义是把变量b的地址存放在指针a中,表达式*a代表a指向的变量,它既可以放在赋值符号的左边(左值),也可以放在右边(右值)。

注意:*a是指“a指向的变量”,而不仅是“a指向的变量所拥有的值”。理解这一点 相当重要。例如,*a=*a+1就是让a指向的变量自增1。你甚至可以把它写成(*a++)。注意不要写成*a++,因为++运算符的优先级高于“取内容”运算符*,实际上会被解释成*(a++)。

很复杂是吗?是的!有了指针,C语言突然变得复杂了很多。一方面,你需要了解更多底层的东西才能彻底解释一些问题——包括运行时的地址空间布局,以及操作系统的内存管理方式等。另一方面,指针的存在,使得C语言中变量的说明变得异常复杂了——你能轻易地说出用char * const *(*next)()声明的next是什么类型的吗?毫不夸张地说,指针是程序员(不仅是初学者)杀手

既然如此,我们应当如何使用指针呢?别忘了本书的背景——算法竞赛。算法竞赛的核心是算法,我们没有必要纠缠如此复杂的语言特性。了解底层的细节是有益的(事实上, 我们已经介绍了一些底层细节!),但我们在编程时应尽量避开,只遵守一些注意事项即可。

提示4-15:千万不要滥用指针,这不仅会把自己搞糊涂,还会让程序产生各种奇怪的错误。 事实上,本书的程序会很少使用指针。

再次回到我们的主题:对正确swap程序的调试。在程序中,a和b都是局部变量,在函数执行完毕以后就不复存在了,但是a和b里保存的地址却依然有效——它们是main函数中的局部变量a和b的地址。在main函数执行完毕之前,这两个地址将始终有效,并且分别指向main函数的局部变量a和b。程序交换的是*a和*b,也就是main函数中的局部变量3和15。

4.2.4初学者易犯的错误

这个swap函数看似简单,但初学者还是很容易写错。一种典型的错误写法是:

void swap(int* a, int* b)

{

int *t = a; a = b; b = t;

}

它交换了swap函数的局部变量a和b (辅助变量t必须是指针。int t=a是错误的), 但却始终没有修改它们指向的内容,因此main函数中的a和b不会改变。另一种错误写法是:

void swap(int* a, int* b)

{

int *t;

*t= *a; *a = *b; *b = *t;

}

这个程序错在哪里? t是一个指向int型的指针,因此*t是一个整数。用一个整数作为辅助变量去交换两个整数有何不妥?事实上,如果你用这个函数去替换程序4-5,很可能会得到“4 3”的正确结果。为什么我要坚持说它是错误的呢?

问题在于,t存储的地址是什么?也就是说,t指向哪里?因为t是一个变量(指针也是一个变量,只不过类型是“指针”而已),所以根据规则,它在赋值之前是不确定的。如果这个“不确定的值”所代表的内存单元恰好是能写入的,那么这段程序将正常工作;但如果它是只读的,程序可能会崩溃!不信的话,赋初值int *t = 0试试,看看内存地址“0” 到底能不能写。

花了这么多篇幅,我们终于初步理解了地址和指针——尽管只是初步理解,但是为将来的学习奠定了良好的基础。指针有很多巧妙但又令人困惑的用法。如果你曾听别人说过有一种语法,但在完整地学习本书的过程中始终没有看到它被用过一次,那么这通常意味着这个语法不必学(至少在算法竞赛中不必用到)。事实上,笔者在编写本书的例程时首先考虑通俗易懂,避开复杂的语言特性,其次才是简洁和效率。