-- 简书作者 谢恩铭 转载请注明出处

第二部分第二课:进击的指针,C语言的王牌!


上一课是C语言探索之旅 | 第二部分第一课:模块化编程

终于来到了这一刻(课),是的,这一课我们就来看《C语言探索之旅》的重头戏中的重头戏:

指针


  • 如果把这个系列课程比做寻宝之旅的话,那么指针就是最贵重的那个宝藏。

  • 如果把C比做一棵佳美的葡萄树,那么指针就是那累累硕果;

  • 如果把C比作太阳系,那么指针就是我们美丽的地球;

  • 如果把C比作人的一生,那么指针就是令人神往的爱情;

  • 如果一定要在这份爱上加一个期限,我希望是一万年...

不好意思,又跑题了。

总而言之,言而总之,一起来享用这份精心烹制的指针大餐吧!

在开始这一课前深吸一口气,因为这一课可能不会像之前那些课一般“悠哉”了。

指针也许是C语言设计最出彩的地方了,也是精华部分。如果没有了指针,C语言也会黯然失色。

可能你觉得我有点夸大其词,但是在C语言的编程中,指针是随处可见的,使用很广泛,所以我们不得不来吃掉这个“烫手山芋”。

不少朋友学C语言的时候,指针那块总是有点“蹒跚却步”,在这一课里我们会努力使你不再如此。你会发现,指针也没那么难么。

好的开始是成功的一半,一起加油吧!

棘手的问题


对于初学C语言的朋友,除了觉得指针有点神秘之外,最大的问题之一可能是:

搞明白指针到底是用来干什么的。

对此,我会回答说:“指针绝对是必不可少的,我们会一直使用它,请相信我”。

你可能会丢我砖头(说不定还有西红柿和鸡蛋) :P ,因为我说了等于没说。

好吧,我会给你一个问题,你会发现假如不用指针是不能解决的。这个问题有点类似我们这一课的引子。

在这一课的结尾我们还会重新来谈这个问题,并尝试用我们马上要学到的知识来解决。

问题是这样:

我要写一个函数,它返回两个值。

“这不可能!” 你会理直气壮地说。

确实啊,之前我们学过:一个函数只能通过return返回一个值:

int function()
{
  return value;
}

如上,我们将函数的返回值类型定为int,那么就用return返回一个int类型的值。

我们也可以不返回任何值,只要把函数的返回值类型定为void:

void function()
{
}

你会说:“是啊,要一个函数一次返回两个值,不可能啊,我们不能写两个return啊。臣妾做不到啊...”

假设我要写的这个函数是这样:

我们给它输入的参数是一个分钟数,它返回给我们两个值:小时数和分钟数。
例如:
传给函数30,它返回0小时30分钟
传给函数60,它返回1小时0分钟
传给函数90,它返回1小时30分钟

我们可以试着来写一下这个函数:

#include <stdio.h>

/* 我把函数原型放在开头了,并没有用到.h头文件,因为程序实在太小了。当然在正常情况下,一般是用.h头文件比较好 */
void transformMinutes(int hours, int minutes);

int main(int argc, char *argv[])
{
  int hours = 0, minutes = 90;

  /* 我们的分钟数是 90。我想要在调用transformMinutes函数后,小时数变为1,分钟数变为30 */
  transformMinutes(hours, minutes);

  printf("%d 小时 : %d 分钟\n", hours, minutes);

  return 0;
}

void transformMinutes(int hours, int minutes)
{
  hours = minutes / 60; // 90 / 60 = 1
  minutes = minutes % 60; // 90 % 60 = 30
}

看上去还不错对么?我们来运行一下这个程序。输出:

0 hours and 90 minutes

不对,不对,这个函数没有按照我们所想的来运行嘛!

到底这里发生了什么呢?

事实上,C语言的函数参数默认是传值调用的,就是说当我们传给函数的参数一个变量时,事实上传递的是这个变量的一份拷贝,并不是这个变量本身!

程序会先对这个要传递给函数的变量做一份拷贝,然后把这份拷贝传给函数使用。

所以说:上面我们main函数里的hours变量和实际传给transformMinutes函数的hours,是不一样的,传给transformMinutes函数的只是hours变量的一个拷贝而已。就好比用复印机复印了一份纸张,内容是一样,但是一个是原件,一个是复印件。

当然了,我们的函数transformMinutes很乖很呆萌,你叫它干的活它肯定出色完成:在函数内部,它把参数hours和minutes的值通过计算转换成了1和30。

但是,注意了,因为transformMinutes拿到的两个参数hours和minutes的值本身只是实际的变量hours和minutes的一份拷贝。

而且我们之前学过,在函数结束时,它里面的非static变量都会销毁,所以hours和minutes这两个拷贝就都会被删除了。

所以函数transformMinutes勤勤恳恳地工作之后,把程序交还给main函数继续执行,但是hours和minutes这两个main函数里的变量的值并没有被改变,还是0和90。可惜啊!

注意:函数的参数的名字和要传给它的变量的名字不需要是一样的,上例中我们为了清楚表示含义,才把main函数里的两个变量和函数transformMinutes的两个参数都命名为hours和minutes。

其实你大可以把函数的参数写成随便什么名字,例如:

void transformMinutes(int h, int m)

简而言之,问题还是在那里。

这里我们需要用函数来改变两个变量的值,我们不能用return,因为一个函数只能return一个返回值;也不能用全局变量,虽然全局变量行得通,但是我们之前的课已经说了,尽量不用这种不安全的变量。

那么指针到底能如何解决我们的难题呢?且听我们慢慢道来。

内存,地址的问题


往事重提

“When I was young, I listened to the radio, waiting for my favorite song...”

不好意思,我搞错了,不是《昨日重现》(Yesterday Once More)这首歌,我们说的是回顾一下之前“变量”的那一课。

不论你的回答如何,我都很建议你回去看一下《变量的世界》那一课的第一部分C语言探索之旅 | 第一部分第四课第一章:变量的世界之内存那档事,因为里面有一张很重要的图,我重新展示给你:

我们用上图简单地展示了我们的内存(RAM)。

我们应该一行一行地来“研究”这张图:

第一行(地址为0的那一行)展示了内存的第一个“区块”(内存地址的最小单位是 1 个 byte,也就是一个字节,一个字节有8个比特位(bit))。

每一个“区块”都有一个“门牌号码”,就是它的地址。

好比我们有一排信箱,每个信箱上有不同的号码,编号由小到大。

每个信箱里储存的东西就是信啦,就相当于内存地址上存放的数据。

但我们知道电脑数数是从0开始的(因为二进制的关系),所以内存地址的第一位地址是0。

我们的内存一般有好多地址,从0一直到某一个比较大的数。一般来说,内存容量越大,可用地址就越多。比如4GB的内存的地址数就比1GB的多得多。

在每一个内存地址上,我们都可以存放一个数,也只能存放一个数,一个内存地址不能存放两个数。

内存的主要功用就是存储数值嘛。它不能存储字母也不能存储句子(因为电脑只认识0和1组成的数字)。

为了解决这个问题,计算机先驱们创立了一个表,这个表格建立了字符与数字的一一对应关系,比较常用的就是ASCII码表(更全面的是Unicode表)。

在这个表里,字母“Y”对应的数字是89,字母“a”对应的数字是97,等等。大家可以Google或百度一下。

在之后的课程里我们会再讨论字符的处理,目前我们先把注意力集中在内存的功用上。

地址和值


当我们创建了一个int类型的变量,例如:

int age = 10;

实际上,你的程序首先请示一下操作系统(比如说Windows):“能否拨一点内存给我用用?”。

一般来说都是可以的,于是操作系统“告诉”你哪一小块内存地址可以用来存放我们的age变量的值。

上面所说的其实也正是操作系统的一个主要任务:分配内存给程序。它好像一个大boss、大管家,控制每个程序,确认程序是否有使用某一块内存区域的权利。

这其实也是我们的程序很多时候奔溃的原因:如果你的程序试图操作一块没有使用权的内存,那么操作系统就会“横加干涉”,“粗暴”地停止你的程序(老大,就是这么任性...)。

用户就会看到一个“美丽”的窗口弹出来,里面写着“出现了一个问题,导致程序停止正常工作,Windows正在寻找问题的解决方案” (但其实一般是没什么解决方案的,比尔.盖茨只会忽悠你)。

如下图:

重新说回我们的变量age。因为将它的值定为10,所以在内存的某个地址上就储存了10这个值,假设内存地址为12345。

那我们的程序编译的时候会发生什么呢?对了,还记得我们的编译器么。它负责把我们的源代码转成电脑可以理解的二进制数。所以age这个变量在程序运行时就被12345这个地址所取代了。

这使得每次你在程序里调用age这个变量时,电脑都会把其替换为12345这个地址,并且去内存中的12345这个地址取它的值。

所以我们就知道变量的值是怎么被取得的了。在程序中,我们只需要在想要使用变量的值的地方简单地输入变量的名字就可以了。例如我们要显示age的值,我们可以这样调用printf函数:

printf("变量age的值是 : %d\n", age);

运行程序会显示:

变量age的值是 : 10

至此,并没有太多新的知识点。但是...

稍微来点劲爆的


我们已经知道怎么显示变量的值了,但是你可知道我们也可以显示变量在内存上的地址?

是的,你没有听错。

为了显示变量的地址,我们需要使用符号组合 %p(p是pointer的首字母,pointer就是英语“指针”的意思!)。

而且我们这次不是把age传给printf函数这么简单啦,我们是要传递age变量的地址。

为了做到这一点,我们需要在age前面加上&这个符号(为了取得地址也是蛮拼的)。

还记得我们之前的课里面,说到scanf函数的用法时,就是用的&age这样的形式,那时候没有解释为什么,现在你知道了吧。

因此,我们的代码可以这么写:

printf("变量age的地址是 : %p", &age);

运行的结果是:

变量age的地址是 : 0034FFE6

你看到的 0034FFE6就是我运行程序时得到的age的地址的值。

是的, 0034FFE6是一个数,而且是用16进制来表示的。当然你可以将上述代码中的 %p 改写为 %d,那就能看到以十进制来表示的age的地址值了。

当然了,你运行程序得到的地址值一般来说肯定与我的不一样,这个值其实是电脑分配给age变量的一个可用地址值,所以每个人运行出来的结果不尽相同。

你也可以多次运行程序,可以看到这个值没变,因为这段时间里,内存还是保持原样,如果你重启电脑,再次运行程序,那么这个值一般会变得不一样了。

上面的知识点,可以归纳如下:

  • age :表示变量的值

  • &age :表示变量的地址

不难吧~

指针的使用


到目前为止,我们还只是创建了储存数值的变量。

现在,我们一起来学习如何创建储存地址的变量:也就是我们所说的指针

但是,你又会问:“内存的地址也是数值啊?搞了半天都是存储数值啊,那指针和一般变量到底有什么区别呢?”

好问题!

为什么说指针特殊呢?因为指针存储的地址值,指明了内存中另一个变量的地址。

创建指针


为了创建一个指针类型的变量,我们需要在变量名前再加一个*号。例如:

int *myPointer;

注意: 上面的代码也可以写成

int* myPointer;

效果是一样的; 但是建议使用第一种写法,因为第二种写法容易让人搞混。

假如一行里同时声明好几个指针变量,也许会写成:

int* pointer1, pointer2, pointer3;

我们以为pointer2和pointer3也是int* 的指针变量,但其实不是,它们只是int变量,因为*号的结合优先级是从左到右,所以上面这行代码的实际效果是:

创建了三个变量,pointer1是一个指向int类型的指针变量,pointer2和pointer3都只是int类型的普通变量。

所以正确的写法应该是这样:

int *pointer1, *pointer2, *pointer3;

之前的课里,我们说过,在声明变量的同时最好初始化,这样可以保证变量的值不是任意的。

这个原则也适用于指针,而且对于指针来说初始化尤为重要,以后会说为什么。

初始化一个指针变量,就是给它赋一个默认值,我们不用0,而是用NULL(注意在C语言中是大写,其他语言如Java中null是小写)。

int *myPointer = NULL;

这下,初始时你的指针里面不包含有效地址。

实际上,这段代码会在内存中占用一块区域,就和普通的变量定义没什么两样。但是,不同的是,这块区域(内存地址)上存放的是指针的值,而这个值是一个地址值,一个其他变量的地址。

那我们何不来试试把变量age的地址赋给一个指针呢?如下:

int age = 10;

int *pointerOnAge = &age;
  • 第一行的意思是:“创建一个int型的变量,名字是age,值为10”;
  • 第二行的意思是:“创建一个指针变量,名字是pointerOnAge,值为变量age的地址”。

你肯定注意到了,我们虽然说“指针变量”,但是并没有一种特定类型叫“指针”,就像int,douboe这样的基础类型(虽然有的书上说有“指针”这个类型),我们并没有像下面这样写(只是打个比方,pointer是英语“指针”的意思):

pointer pointerOnAge;  // 这种写法不存在,因为C语言没有pointer这个类型

相反地,我们用了符号*来声明一个指针变量,而类型我们还是给它int这样的基本类型,这意味着什么呢?

事实上,我们必须指明指针所要包含其地址的那个变量的类型(有点拗口)

比如上例中,我们的pointerOnAge指针需要包含age的地址,而age变量的类型是int,因此我们就必须将指针的类型定为 int*

如果age变量的类型是double,

double age = 10;

那么我就得这样声明我的指针:

double *pointerOnAge;

我们可以简单地理解为:

一个基本的数据类型(如int, double,包括结构体等自定义类型(关于自定义类型,我们马上就可以学到了)加上*号就构成了一个指针类型的“模子”。

这个“模子”的大小是一定的,与*号前面的数据类型无关。

*号前面的数据类型只是说明指针所指向的内存里存储的数据类型。

所以,在32 位 系统下,不管什么样的指针类型,其大小都为4个Byte(字节,8个二进制位)。可以测试一下sizeof(void *)

术语: “指针pointerOnAge指向age变量”


下图给大家一个更直观的展示,到底内存里是怎么一回事:

上图中,age变量被存放在地址177450上,我们可以看到它的值是10;而我们可爱的指针变量pointerOnAge被存放在地址3上(这里的地址值只是举个例子)。

当我的指针变量pointerOnAge被创建时,操作系统就在内存上给它分配了一块地址,就跟创建age变量一样。

但是不同的是,变量pointerOnAge有点特别,仔细看图,它的值正是age变量的地址:

177450

好了,亲爱的读者,其实你已经了解了所有C语言程序的绝对奥秘!

我们已经做到了,是的,我们刚刚跨入了指针的美妙世界!(当然了,要精通指针的使用还有不少路要走,但是难道不应该给自己一点鼓励吗?)

那你要问了:“这个机制有什么用呢?”

当然了,这个伟大的机制并没能让你的电脑成为一台可以煮咖啡的机器...

目前我们还只是有了一个指针变量pointerOnAge,它的值是age这个变量的地址,仅此而已(读者:我读书少,你可不要骗我...)。

用printf函数来输出pointerOnAge这个变量的值吧:

int age = 10;

int *pointerOnAge= &age;

printf("%d\n", pointerOnAge);

上面的代码输出如下:

177450

没什么可惊讶的,我们用printf输出pointerOnAge的值,它的值正是age变量的地址:

177450

那我们怎么能够取得pointerOnAge这个指针变量中储存的内存地址上的那个变量的值呢(好拗口)?

我们需要再一次用到*号,但这次它的作用和上一次声明指针变量时不一样,这一次它的作用是:

取得指针所指向的地址上的变量值。

例如:

int age = 10;

int *pointerOnAge= &age;

printf("%d\n", *pointerOnAge);

运行以上程序,输出为:

10

太棒了,我们只是使用了*号,把它放在指针变量名前,就取得了它所指向的变量的值。

假如上面的程序中,我们在pointerOnAge前面写的不是*号,而是&号,那么printf输出的就是pointerOnAge的地址值(是3)了。

但是你又要问了:
“大费周章使用指针干什么呢?我们好像把事情复杂化了,不是么?
本来我们要输出变量age的值,只需要直接调用age就好了,现在还要把age的地址赋给指针变量pointerOnAge,然后再用*号来提取pointerOnAge里的地址值所存的变量值,就是age。
搞了半天是同一个东西,为什么要绕这么一大圈呢?”

这个问题是很合理的。毕竟,谁能说你没什么道理呢?

但是不要急,学下去,你就会发现指针的妙处了。

请暂时不理会这个问题,目前主要是学习指针的功用,这个问题稍后自会“守得云开见月明”的。

毕竟,老爷子 Dennis Ritchie(C语言之父 丹尼斯.里奇)不是傻子,他不会只为了好玩或者把事情搞复杂而发明指针的。

你说是不,老爷子。

必须要牢记的


在这一课中要牢记几点:

  • 对于一个普通变量,例如age变量:

age: 意味着“age变量的值”
&age: 意味着“age变量所在的地址”

  • 对于一个指针变量,例如pointerOnAge变量:

pointerOnAge: 意味着“pointerOnAge的值” (这个值是一个地址)
*pointerOnAge: 意味着“pointerOnAge的值所标明的地址上的变量值”

下图可以帮助你加深理解:

注意: 不要混淆了*号的作用

在声明一个指针变量时,*号的作用只是表示我要创建一个指针变量:

int *pointerOnAge;

而在之后的程序中,当我们写:

printf("%d\n", *pointerOnAge);

这里的*号的作用不是说“我要创建一个指针变量”,而是“取得指针变量pointerOnAge储存的地址所指向的变量的值”。

上面的概念是“根基性”的,指针的基本概念是比较难理解,即使你现在云里雾里,没什么好羞愧的,小编以前也是花了很久才搞清楚指针到底怎么回事。

现在看来有点抽象是完全正常的,慢慢来,不要急,随着多看代码和多写代码,会慢慢入门的。

传递指针给函数


指针的一个优势就是用来传递给函数,作为函数的参数,使得在函数里面修改指针所指向的变量的值,就直接在内存上修改了,而不是像之前看到的那样,只是修改了一份拷贝,并没有真正修改到实际的那个变量。

怎么做到呢?有好几种方法,先来看第一种:

#include <stdio.h>

void triplePointer(int *pointerOnNumber);

int main(int argc, char *argv[])
{
  int number = 5;

  triplePointer(&number); // 将number变量的地址传给函数triplePointer

  printf("%d\n", number); // 显示number的值。上面函数已经直接修改了number的值,因为函数知道number的内存地址

  return 0;
}

void triplePointer(int *pointerOnNumber)
{
  *pointerOnNumber *= 3; // 将pointerOnNumber的值乘以3
}

运行程序,显示:

15

函数triplePointer接受一个int*类型的参数(就是说指向int类型的指针)。

我们从main函数的开始处分析究竟程序里发生了什么吧:

  • 创建变量number,类型是int,值为5。

  • 调用函数triplePointer,给它的参数是变量number的地址。

  • 函数triplePointer接受了这个参数(number的地址),并把它传递给pointerOnNumber储存。现在triplePointer函数的内部,我们就有一个叫pointerOnNumber的指针,它的值是number的地址。

  • 因为我们已经有了一个指向number的指针,我们就可以在内存中直接修改number的值了,而不是像普通的传值调用那样修改拷贝的值。我们只需要用 *pointerOnNumber来表示number的值。

    上例中,我们将*pointerOnNumber 乘以3,其实就是直接将number的值乘以3。

  • 函数triplePointer执行完成,把控制权交给main函数继续执行,这时number的值已经变成15了,因为函数triplePointer借着指针直接修改了number的值(大有“挟天子以令诸侯”之势)。

所以,借着指针,我们不需要用return,也可以修改好多个变量的值,直接在内存上修改!就好像我们可以返回多个值一样。

有了指针,函数不再只能返回一个值了。

你的问题又来了:“既然指针这么好,那我们还要return语句干嘛呢?”

好问题。

答案是:
这取决于你和你的程序。由你决定。要知道的是,return语句在C语言里很有用也很常用的。

有时候我们可以返回一个值,来表明程序是否正常运行。假如出错,则返回0(表示false);假如正常结束,则返回不为0的整数,一般为1(表示true)。也有反过来用的。

将指针传给函数的另一种方式


刚才的那段代码中,我们在main函数里并没有声明指针,只有一个变量number,唯一看到指针的地方是在triplePointer函数的定义中

现在我们就来看一下第二种方式,在main函数里用到指针的方式:

#include <stdio.h>

void triplePointer(int *pointerOnNumber);

int main(int argc, char *argv[])
{
  int number = 5;

  int *pointer = &number; // pointer里面储存的是number的值

  triplePointer(pointer); // 将pointer(值是number的地址)传给函数

  printf("%d\n", *pointer); // 用 *pointer 来显示number的值

  return 0;
}

void triplePointer(int *pointerOnNumber)
{
  *pointerOnNumber *= 3; // 将number的值乘以3
}

运行程序,输出:

15

对比一下两个程序,有细微的差别,结果却是相同的。

这两个程序最关键的地方就是:传给函数triplePointer的参数是变量number的地址。

第二个程序中,pointer的值就是number的地址,所以正确。

在函数printf中,我们用 *pointer来代替number。因为它们两者在内存中是一回事。

在以前的课中,我们写过一个C语言的游戏,就是那个第一个C语言小游戏:《或多或少》(C语言探索之旅 | 第一部分第八课:第一个C语言小游戏)。

其实在那个程序里你也在不知不觉中使用了指针。

有的读者可能想到了。是的,就是scanf函数。

还记得么,我们当时有这样一小段程序:

int number = 0;

scanf("%d", &number);

类似的,我们也是传递了number的地址给scanf函数,所以scanf就可以使用用户通过键盘输入的值来直接修改number的值了。

当然我们也可以这样写:

int number = 0;

int *pointer = &number;

scanf("%d", pointer);

这两段小程序的效果是一样的。

谁说“棘手的问题”了?


这一课就要结束了,是时候来调出我们的引子:那个所谓“棘手”的问题。

如果你跟着这一课学下来,这个问题应该难不倒你了吧?不妨试试。

给出我的解法,可以做对比:

#include <stdio.h>

/* 我把函数原型放在开头了,并没有用到.h头文件,因为程序实在太小了。当然在正常情况下,一般是用.h头文件比较好 */
void transformMinutes(int *hours, int *minutes);

int main(int argc, char *argv[])
{
  int hours = 0, minutes = 90;

  /* 这一次我们传递了hours和minutes的地址 */
  transformMinutes(&hours, &minutes);

  // 这一次,数值如我们所愿改变了
  printf("%d 小时 : %d 分钟\n", hours, minutes);

  return 0;
}

void transformMinutes(int *hours, int *minutes)
{
  // 记得,不要忘了取值符号(*),这样你才可以改变变量的值,而不是它们的地址。
  *hours = *minutes / 60; // 90 / 60 = 1
  *minutes = *minutes % 60; // 90 % 60 = 30
}

运行以上程序,输出:

1 小时 : 30 分钟

看到了吗?自从有了指针,天空飘来五个字:“那都不是事”~

总结


  1. 每一个变量都储存在内存中的确定地址上。

  2. 指针是一种特殊的变量。与普通变量存储值不一样的是,指针存储的是地址,而在这块地址上,储存着一个变量(或者是另一个指针)。

  3. 如果我们将符号&放在一个变量前面,那么可以得到这个变量的储存地址,例如 &age。

  4. 如果我们将符号*放在一个指针变量名前,那么可以得到指针所储存的地址上存放的那个变量。

  5. 指针是C语言的精华,也是强大所在,但是一开始会显得比较难。需要我们好好花时间来理解指针的机制,因为很多其他知识点是建基于其上的。

当然,今天只是为指针这一个难题开了一个头,因为目前我们还有很多概念没讲。

之后会慢慢深入,指针的强(ke)大(pa)绝不仅于此。

而且,我们也不能在这一课里丢给大家太多知识点,假如这里就讲

  • 指向指针的指针
  • 数组指针
  • 指针数组
  • 结构体指针
  • 函数指针

等等知识点,那还能不能愉快地玩耍了...

第二部分第三课预告:


今天的课就到这里,一起加油咯。

下一次我们学习第二部分第三课:C语言探索之旅 | 第二部分第三课:数组

数组是最常用的一种数据类型,也是C语言的一个重点。