引导语:在信息工程中,指针是一个用来指示一个内存地址的计算机语言的变量或中央处理器(CPU)中的寄存器(Register)。以下是百分网小编分享给大家的C语言指针知识点,欢迎阅读!
【考点1】指针变量
指针变量是用来存储地址的,而一般变量是存储数值的。指针变量可指向任意一种数据类型,但不管它指向的数据占用多少字节,一个指针变量占用四个字节。
【考点2】指针变量的定义
格式为:类型名 *指针变量名。二维指针int **p;可以理解为基类型为(int *)类型。
【考点3】指针变量的初始化
指针变量在使用前必须要初始化,把一个具体的地址赋给它,否则引用时会有副作用,如果不指向任何数据就赋“空值”NULL。
指针变量两种初始化
方法二:int a=2,*p; (定义之后初始化)
【考点4】指针变量的引用
&是取地址符,*是间接访问运算符,它们是互逆的两个运算符。在指针变量名前加间接访问运算符就等价它所指向的量。
【考点5】指针的`运算
*p 可以当做变量来用;*的作用是取后面地址p 里面的数值
p 是当作地址来使用。
*p++ 和(*p)++的之间的差别:改错题目中很重要
*p++是地址会变化。
(*p)++ 是数值会要变化。
三名主义:(考试的重点)
数组名:表示第一个元素的地址。数组名不可以自加,他是地址常量名。(考了很多次)
函数名:表示该函数的入口地址。
字符串常量名:表示第一个字符的地址。
指针变量是存放地址的。并且指向哪个就等价哪个,所有出现*p 的地方都可以用它等价的代替指向的变量。
(由于*p 指向变量a,所以指向哪个就等价哪个,这里*p 等价于a,可以相当于是a=a+2)
大家好,我是努力学习游泳的鱼,今天我们来学习C语言的重头戏:指针。指针时C语言里的重难点,是很多初学者的拦路虎,有些同学就是被C语言的指针劝退的,我在初学指针时也走了不少弯路。但是,不要怕!指针真的没有那么难!觉得指针难,是因为学习的方法不对。这篇文章里,我会尽可能用最通俗易懂的语言,来详细讲解指针的方方面面,希望能帮助到有需要的朋友。文章较长,建议先收藏,防止迷路。如果你觉得这篇文章帮助到了你,麻烦点个免费的赞支持一下博主。感谢大家的支持!
提到指针,首先要认识内存。
组装电脑时需要插入内存条,这是电脑必不可少的硬件。内存的作用是存储数据,我们在编程中创建的变量都是放在内存中的。
常见的内存条,大小有8G
,16G
等等。那么大的一块空间是如何管理的呢?内存被划分为一个个很小的内存单元,每个内存单元大小是一个字节,并且对应一个编号。
那么编号是怎么产生的呢?现在电脑常见的配置有64
位机器和32
位机器。这里的64
和32
指的是地址线的条数,每条地址线可以产生高电势和低电势,对应着二进制中的1
和0
。以32
位机器为例,32
位机器可以产生的编号包括32
位全0
到32
位全1
的总共232个二进制数。64
位机器同理。
这里的编号就是传说中的地址!
总结:内存是一个存储器,分为一个个很小的内存单元,每个内存单元大小是一个字节,并且唯一对应一个由32
(64
)根地址线产生的二进制编号,这个编号就是地址。
这就很容易理解后面会讲到的指针变量的大小:32
位机器的地址(也就是刚刚讲到的编号)是32位
的0/1
序列,比如,每个0
或者1
是一个比特位(bit)。比特位是计算机单位中最小的,一个二进制位(0/1
)就是一个比特位。所以,32
位机器的地址大小就是32
bit,也就是4
个字节。同理64
位机器的地址就是64
比特,也就是8
个字节。
&
是C语言提供的操作符,用于取出操作数的地址。其实,我们已经见过这个操作符了,scanf
函数里就会用到scanf("%d", &num);
。用法非常简单,在它后面直接跟你想取地址的对象。比方说:
这里需要说明一下,a
是int
类型,大小是4
个字节,也就是需要占用4
个内存单元(前面说了一个内存单元大小是1
个字节),用&
取出来的只是第一个内存单元的地址。
我们还可以把地址打印出来,地址的打印格式是%p
。
我们拿到了a
的地址后,会想要把它存起来,这就需要定义一个变量。用来存放地址的变量叫做指针变量,也叫指针。所以,我们可以这样理解:指针就是地址!
没错,指针变量,也就是指针,等价于地址,等价于内存的编号,这只是不同的叫法而已,意思是完全一样的。所以不要把指针想的太高大上,它只是一个普普通通的编号而已。
这一行代码蕴含着很多的信息。定义了一个指针变量,名字是pa
,并且初始化为变量a
的地址。这里的pa
的类型是int*
。其中这个*
表示pa
是指针变量,而int
表示pa
指向的对象(即a
)是int
类型的。
这里的*
表示pch
是一个指针变量。char
表示pch
指向的对象(即ch
)是char
类型的。
我们拿到了一个变量的地址,就可以通过这个地址来访问这个变量。这里就要介绍另外一个重要的操作符*
。*
是解引用操作符,又称间接访问操作符。在*
后面跟指针,就能找到指针指向的空间。
这里就直接把pa
指向的对象a
改成了1
。
那么指针变量的大小是多大呢?
其实前面已经剧透过了。32
位机器是4
个字节,而64
位机器是8
个字节,这是由于32
位机器产生的地址是32
个0/1
组成的二进制序列,每个0/1
是一个比特位,总共32
个比特位,即4
个字节。同理64
位机器产生的地址是64
个比特位,即8
个字节。
注意:指针变量的大小跟指针指向的变量的大小无关,只跟机器是32
位还是64
位有关。
下面来验证一下这一点。
测试结果:在32
位(X86
)环境下,全是4
;在64
位环境下(X64
)全是8
。
注:如未特殊声明,以下环境均为X64
。
指针的大小跟指针的类型无关,只和环境(32
位还是64
位虚拟地址空间)有关。也就是说,相同的环境下,不同类型的指针的大小是相同的,比如X86
环境下,char*
和int*
的大小都是4
个字节,那么为什么还要区分不同的指针类型呢?不同的指针类型有什么区别呢?
一般来说,如果我们会用一个整型指针来存储一个整型变量的地址,再对这个整型指针解引用,就能够访问这个整型变量。比如:
0x
是一个十六进制数字。这里补充一个知识:十六进制数字都是以0x
开头的。一个十六进制位的大小是4
个比特位,所以两个十六进制位的大小是8
个比特位,即一个字节。对于0x
,11
是一个字节,22
是一个字节,33
是一个字节,44
是一个字节,总共是4
个字节,刚好能够存放在一个int
类型的变量中。
执行int a = 0x;
后,通过调试看,我们把0x
放到了变量a
中,并且在内存中也找到了a
的位置。
最后*pa = 0;
,由于pa
是int*
类型的指针,对它解引用就能够访问一个int
,会把变量a
改成0
。即,对int*
类型的指针解引用,能访问4
个字节。
如果是char*
类型的指针呢?结果又会如何呢?
0;我们对于pa
这个char*
的指针解引用,编译器会认为,我们想要找一个char
类型的变量。而char
类型的变量只有1
个字节,所以只会访问1
个字节,把这1
个字节的空间存储的数据改成0
。
综上,对一个int*
的指针解引用,能访问4
个字节。对一个char*
的指针解引用,能访问1
个字节。
这就是指针类型的第一个作用:
指针类型决定了,指针在被解引用的时候,访问的权限。
指针类型是一种看待内存空间的角度。对一个字符指针来说,内存空间存储的都是字符,对这个指针解引用,会访问1
个字符,即1
个字节的空间。对一个整型指针来说,内存空间存储的都是整型,对这个指针解引用,会访问1
个整型,即4
个字节的空间。
pa
是一个整型指针,pc
是一个字符指针,对它们分别+1
的结果相同吗?
pa
和pc
存储的地址是相同的,都是&a
,但是pa+1
跳过了4
个字节,pc+1
跳过了1
个字节。
这就是指针类型的第二个作用:
指针类型决定了,指针向前或者向后走一步,走多大距离。
对于一个整型指针,向前走一步会跳过一个整型,即跳过4
个字节。对于字符指针,向前走一步会跳过一个字符,即跳过1
个字节。
本质上,对一个int*
指针+1
会在地址上+1*sizeof(int)
,即跳过4
个字节, 明白了指针类型的作用后,我们就可以在不同的场景下选择合适的指针类型来解决问题了。比如:
我们有一个数组int arr[10] = {0};
,这个数组有10
个int
,总共40
个字节,如何以字节为单位访问,把这40
个字节的数据都改成'x'
的ASCII码值呢?
首先我们需要一个char*
的指针,才能一次访问1
个字节,+1
后也会跳过1
个字节。char* p =
(int*)arr;
,数组名arr
表示数组首元素的地址,是int*
类型的,需要强制类型转换成char*
。一开始让p
指向这个地址,对p
指针解引用能访问1
个字节,从而把这个字节改成'x'
的ASCII码值,接着对p
指针+1
都会跳过1
个字节,就可以以字节为单位访问arr
数组的40
字节的空间了。
同理,如果我们想按照整型的方式来访问arr
,每次把4
个字节的数据改成0x
,就应该使用int*
类型的指针。
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。
由于指针p
是个局部变量,而且没有初始化,存放的是随机值。如果我们把这个随机值当做一个地址,这个地址对应的内存空间不属于我们,是不能访问的,如果强行对这个地址解引用,就会造成非法访问。此时的p
就是野指针。
由于数组arr
里只有5个元素,p
指针以整型为单位向后访问时,只能访问5
次,第6
次访问(i==5
)时,p
指针已经超出了数组的范围,形成了越界访问。此时的p
就是野指针。
局部变量a
在进入test
函数是创建,test
函数调用完毕后就销毁了。如果把a
的地址放到p
指针里,p
指针就指向了一块已经销毁的空间,这块空间的使用权限不属于我们。如果强行对p
解引用,就形成了非法访问。此时的p
就是野指针。
我们一定要小心,不要在代码中出现野指针。规避野指针有以下几点经验。
当我们知道应该如何对指针初始化时,应对其初始化。如:int* p = &a;
当我们不知道应该如何对指针初始化时,应初始化成NULL
。如:int *q = NULL;
而使用前需检查,不是NULL
时才能使用if (NULL != q)
。
尤其是使用指针访问数组时,一定要检查是否越界。
假设我们已经对指针p
进行了各种操作,已经不想使用这个指针了,则应该置空p = NULL
。
局部变量的作用域是变量所在的局部范围,如果出了作用域就销毁了。如果一个局部变量在销毁之后,仍然有指针指向它,这个指针就是野指针。我们应避免出现这种情况。
使用一个指针之前,要检查其是否为空,非空才可使用。我们应避免对空指针解引用。
一个int*
指针±n
,会向后(前)跳n*sizeof(int)
字节。其他类型的指针同理。
相同类型且指向同一块空间的指针可以相减。指针-指针的绝对值是指针和指针之间元素的个数。
当然,如果反过来,&arr[0]-&arr[9]
得到的结果就是-9
,因为随着数组下标的增长,地址是由低到高变化的。&arr[0]-&arr[9]
是低地址-高地址,得到的结果是负数。
我们可以使用指针-指针求字符串的长度。比如对于字符串"abcdef"
,内存空间实际存储的是[a b c d e f \0]
,那么\0
的地址减a
的地址就是中间字符的个数,也就是字符串的长度。
两个指针是可以比较大小的。
举个例子:随着数组下标的增长,地址是由低到高变化的。创建一个数组int arr[10] =
这段代码使用指针vp
来遍历数组values
。vp
被初始化为首元素地址,把改地址对应的元素置成0后,访问下一个位置。当vp
指向values[N_VALUES]
(即数组最后一个元素的下一个位置)时就越界了,不再继续访问,跳出循环。
注意!有朋友可能会认为这段代码有问题,因为访问了values[N_VALUES]
,似乎越界了。事实上,这段代码是没有问题的,因为虽然越界了,但是没有修改该处的值。如果我们把values[N_VALUES]
的值修改了,那vp
就是野指针了,造成了非法访问。
如果我们想用指针从后往前遍历数组,就可以这么写:
一开始指针vp
指向了最后一个元素的下一个位置,进入循环后立刻自减,访问最后一个元素,以此类推。最后一次进入循环后,自减后指向values[0]
,不再满足vp > &values[0]
,跳出循环。
但是有朋友可能会认为这么写有点别扭,于是对这段代码简化如下:
一开始让vp
指向values[N_VALUES-1]
(最后一个元素),访问后再往前走,直到vp
指向values[0]
后,把values[0]
置成0,再往前走,指向values[-1]
(第一个元素的前一个位置),不满足vp
这种写法,实际在绝大部分的编译器上是可以顺利完成任务的,然而我们还是应该避免这样写,因为标准并不保证它可行。
允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较,但是不允许与指向第一个元素之前的那个内存位置的指针进行比较。
在写法3中,最后一次执行循环的判断部分时,是拿
&values[-1]
(第一个元素的前一个位置的地址)与&values[0]
比较,是不被允许的。
对于数组的详细讲解,请阅读【C语言】数组。
先来复习一个问题:数组名是什么?
数组名表示数组首元素的地址,但是有两个例外。
sizeof(数组名)
,数组名表示整个数组,计算的是整个数组的大小,单位是字节。
&数组名
,数组名表示整个数组,取出的是整个数组的地址。
我们用指针来访问数组,首先需要一个指针p
。数组名arr
表示首元素的地址(类型是int*
),我们就用这个地址来初始化指针p
:int* p = arr;
。又因为数组在内存中是连续存放的,我们有了数组首元素的地址,就能够找到后面所有元素的地址。
我们可以在for
循环内,用循环变量i
产生0~sz-1
的数。那么p+i
就跳过了i
个int
类型的数据,就指向了数组中下标为i
的元素。再对其解引用,*(p+i)
就能访问数组中下标为i
的元素了。
当我们有一个变量int a = 10;
时,我们取出它的地址并存放在一个指针变量中int *pa = &a;
,此时pa
是一个一级指针。pa
也是一个变量,也有地址,我们取出pa
的地址,存放在另一个指针中int** ppa =
我们如何理解
int**类型呢?可以拆分成int*
*
。后面这个单独的*
表示ppa
是一个指针变量,前面的int*
表示ppa
指向的对象(即pa
)是int*
类型的。
同理,我们还可以取出ppa
的地址,存放在一个三级指针里:int*** pppa = &pa;
。这样就能无限套娃了。
我们对二级指针ppa
解引用,由于ppa
存放的是pa
的地址,我们就能访问pa
了。比如*ppa = NULL;
就等价于pa = NULL;
。
如果写int arr[5];
,则arr
是存放整型的数组,简称整型数组;如果写char ch[6];
,则ch
是存放字符的数组,简称字符数组。
那什么是指针数组呢?就是存放指针的数组。
比如写:int* arr[10];
,arr
就是一个整型指针数组,有10
个元素,每个元素是int*
类型的。
我们可以用一个指针数组来模拟二维数组。
假设有三个整型数组,分别是data1
,data2
和data3
,由于数组名表示首元素地址,所以data1
,data2
和data3
就分别表示对应的数组首元素地址,我们把它们都存放在一个数组指针arr
里。那么,arr[i]
就可以访问到数组data1
,data2
和data3
,arr[i][j]
就可以访问到data1
,data2
和data3
的元素。
对比一下二维数组的访问,是不是非常像?
假设我们有一个整数,int a = 10;
由于a
是个变量,可以直接修改a = 20;
,如果我们不想修改这个变量,可以加const
来修饰,如const int a = 10;
,此时如果强行修改a
,如a =
20;
就会报编译错误。对于const
修饰的变量a
,我们称为常变量。
此时a
真的不能被修改了吗?也不见得。我们再用一个指针来存储它的地址:int *pa = &a;
,接着对它解引用来间接地修改:*pa = 20;
这么写就强行把常变量a
给修改了。
如果我们想用一个指针来存储变量a
的地址,又不想通过解引用指针的方式来修改变量a
,就可以使用const
修饰指针。正确的写法是const int *pa = &a;
或者int const *pa =
&a
。这两种写法const
都放在*
左边,修饰的是*pa
,就不能通过解引用pa
来修改a
了,但是仍然可以改变pa
的值。比如:int b = 20; pa =
char*
类型的指针可以存储一个字符的地址。根据我们所学的知识,我们已经可以看懂下面的代码。
字符指针还有一个更加常见的用法。当我们直接写出一个常量字符串,比如"abcdef"
时,它的值是这个字符串的首字符(即'a'
)的地址。如果我们想存储这个地址,就需要用到字符指针。如:char* p =
"abcdef";
。此时指针p
就指向了字符'a'
,相当于指向了字符串"abcdef"
。
当我们用%s
的格式打印字符串时,只需要字符串的起始地址,即字符串首字符的地址,程序就会从这个起始地址指向的字符开始,一直向后打印字符,直到遇到\0
停止打印。如:当字符指针p
指向了字符串"abcdef"
的首字符(即a
),我们打印字符串就写printf("%s\n",
对于
char* p = "abcdef";,我们把一个常量字符串首字符的地址存储在一个字符指针中,由于常量字符串时不能修改的,如果写*p = 'w';
,强行修改常量字符串,程序就会崩溃。为了防止这种危险的行为,我们一般会使用const
来修饰这个指针,即const char *p =
下面代码输出的结果是什么呢?
由于
p1
和p2
指向的都是常量字符串"abcdef"
,这个常量字符串是不能修改的,所以没必要存在两份,只需保存一份就行了,p1
和p2
指向的是内存中同一块空间,这块空间存放"abcdef"
这个字符串。
反观arr1
和arr2
,是两个数组,必然是两块不同的空间,数组名表示首元素地址,所以arr1
和arr2
不相等。
数组指针,即存放数组地址的指针。
我们直接对数组名取地址,取出的是数组的地址。如:创建数组int arr[10] = {0};
,对数组取地址&arr;
即为数组的地址。
如果我们想把数组的地址存起来,就需要数组指针。对于上面的例子,正确的写法:int (*p)[10] =
&arr;
。括号里的*p
,p
先和*
结合,说明p
是一个指针。向外一看,看到了[10]
,这里的方括号说明p
是一个数组指针,指向了一个数组,方括号里的10
说明p
指向的数组有10
个元素。再往前一看,看到了int
,说明数组元素的类型是int
。
对于int (*p)[10]
,我们把指针变量的名字p
去掉,就能得到数组指针类型是int
(*)[10]
。这个类型中,括号里的*
表示这是一个指针类型,向外一看[10]
,方括号表示这个类型创建的指针变量可以存放一个数组的地址,数组有10
个元素。再向前一看,这个int
表示数组的元素类型是int
。
由于指针类型决定了指针+1
跳过几个字节,数组指针类型+1
跳过整个数组。比如int (*)[10]
类型,+1
跳过整个数组,即跳过10
个int
,即40
个字节。
我们可以用数组指针来访问数组的元素。先举个一维数组的例子。
我们需要一个数组指针来接收数组的地址int
(*parr)[10]
。对parr
解引用,即*parr
就能找到整个数组,相当于数组名,而数组名表示首元素地址。根据以上分析,*parr
就相当于&arr[0]
,对首元素地址+i
就能找到下标为i
的元素的地址,再对其解引用就能找到下标为i
的元素。
arr
是数组名,表示数组首元素的地址。二维数组的首元素就是它的第一行,而第一行是5
个int
的一维数组。所以arr
是5
个int
的一维数组的地址,用数组指针parr
来接收,加上类型应该这样写:int
那如何使用
parr来访问arr
呢?parr
是数组第一行的地址,parr+i
就跳过了i
行,即parr+i
是数组第i
行的地址。对其解引用,即*(parr+i)
就能找到第i
行,相当于第i
行的数组名,就是第i
行首元素的地址。*(parr+i)+j
就是第i
行首元素的地址跳过j
个元素,即*(parr+i)+j
是第i
行第j
个元素的地址。再对其解引用*(*(parr+i)+j)
就能访问第i
行第j
个元素。
假设我们有个一维数组int arr[10];
,我们想调用一个test
函数,把数组名arr
传过去,即test(arr);
,试问,test
函数应该用什么类型的形参来接收呢?
数组传参,可以数组接收,所以可以写成void test(int arr[10]) {}
。又因为,使用数组名传参时,实际传递的是数组首元素的地址,并不会再函数内部创建一个新的数组,所以可以省略数组的大小,即void test(int arr[]) {}
。甚至可以乱写数组的大小,但是不建议,比如void test(int
由于使用数组名传参时,实际传递的是首元素的地址,我们可以直接使用指针来接收
void test(int* p) {}。
除了上面提到的写法,其余写法都是错误的。
假设我们有个二维数组int arr[3][5]
,我们想调用一个test
函数,把数组名arr
传过去,即test(arr);
,试问,test
函数应该用什么类型的形参来接收呢?
由于使用数组名传参时,实际传递的是首元素的地址,二维数组的首元素就是第一行的地址,我们可以直接使用数组指针来接收void test(int (*p)[5]) {}
。
除了上面提到的写法,其余写法都是错误的。
假设我们有一个函数,新参是一个二级指针void test(int** ptr) {}
,试问,实参部分可以怎么写呢?
可以直接传一个二级指针过去。如
当然,对于以上代码,也可以直接传递一级指针的地址,即test(&pa)
。
除此之外,还可以传一个数组过去。数组名表示首元素的地址,只需要首元素是int*
类型,首元素地址就是int**
类型。所以我们需要一个指针数组int* arr[10];
,然后传过去就行了test(arr);
。
假设我们有一个函数Add
:
我们如何拿到函数的地址呢?只需要对函数名取地址即可。&Add
就是函数的地址。除此之外,函数名也表示函数的地址,也就是说,直接写出函数名Add
也表示函数的地址。
如果我们想把函数的地址存起来,就需要函数指针变量。假设我们用变量pf
来存放Add
的地址,应该如何书写它的类型呢?首先,我们要确保pf
是个指针,就用括号把这玩意和*
括起来,即(*pf)
,接着向外一看,这是一个函数指针,就需要一个圆括号(对比数组指针的方括号):(*pf)()
,圆括号内写函数的形参:(*pf)(int,
13.2 函数指针的使用
对函数指针解引用,就可以找到对应的函数。如上面的例子中,写
*pf就可以调用这个函数了,即int ret = (*pf)(2, 3);
,此时ret
就是5
。需要注意的是,使用函数指针调用函数,是可以省略*
的,也就是说,直接写int ret =
pf(2, 3);
也可以调用Add
函数。事实上,这个*
就是摆设,你甚至可以写很多个*
,比如int ret = (********pf)(2, 3);
,当然这是开个玩笑,建议别这么写。
函数指针数组,就是存放函数指针的数组。比如,假设我们有几个函数,它们的参数和返回类型都是一样的。
函数名表示函数的地址,要想把这些函数的地址都存起来,就需要一个函数指针数组。这个函数指针数组的类型应该怎么写呢?假设数组名是pf
,由于是一个数组,就要先和方括号结合,即pf[4]
,方括号里的4
表示数组有4
个元素,当然我们如果要对这个数组初始化,就可以省略数组元素个数,即pf[]
。接下来写数组的元素类型,由于数组的元素是函数指针,所以先与*
结合,说明它是个指针(*pf[])
。向外一看,是个函数指针,所以需要圆括号,圆括号里写函数的形参类型(*pf[])(int,
14.2 函数指针数组的使用
访问这个数组的元素也很简单,使用
for循环访问就行了。由于访问的元素都是函数指针,直接在后面加圆括号就可以调用对应的函数。
当然,我们可以把上面的代码改造成一个计算器程序,此时函数指针数组就被称作转移表。
};。如果再取出这个函数指针数组的地址,即&pfArr
,就需要存放到指向函数指针数组的指针。
假设这个指向函数指针数组的指针变量名是p
,那么应该如何写它的类型呢?首先,它是个指针,所以用括号把它和*
括起来(*p)
。向外一看,它指向一个数组,所以需要一个方括号,里面放数组的元素个数(*p)[4]
。每个元素的类型是什么呢?是函数指针类型,即int (*)(int,
如何使用这个指向函数指针的数组来访问前面的
4个函数呢?对数组指针解引用,相当于数组名,由数组名可以访问这个数组的元素,而这个数组的元素都是函数指针,就可以调用这些函数。
再详细一点,p
是指向函数指针数组pfArr
的指针,那么*p
就相当于数组名pfArr
,而(*p)[i]
就相当于pfArr[i]
,即函数指针数组的元素,再用这些函数指针调用函数即可。
回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外一方调用,用于对该事件或条件进行响应。
如以下的test
函数就是回调函数。
我们可以使用回调函数实现计算器。
void* base
:待排序数据的起始位置。
e1
和e2
分别指向一个元素,假设e1
指向data1
,e2
指向data2
,若data1>data2
,则该比较函数返回值为正数;若data1<data2
,则该比较函数返回值为负数;若data1=data2
,则该比较函数返回值为0
。
对于最后一个参数,要求qsort
函数的使用者自定义一个比较函数,调用qsort
函数时,需要把比较函数的地址作为参数传递给qsort
,此时这个比较函数就是回调函数。
我们已经会使用冒泡排序来排序一个整型数组了,代码如下:
接下来我们参考qsort
,把冒泡排序也改造得通用一点。有以下几点需要改进:
><=
来比较,而是使用回调函数cmp
。
其中Swap
函数还有另一种写法。
接下来我们来调用这个改进后的bubble_sort
函数。
修正的部分内容的索引放在这里进行说明:
第一次修正:关于自定义类型那里进行了部分内容的修正
1.对语句部分进行了大程度的修正
2.对数组部分进行了大程度的修正
3.补上了位段的一系列操作
4.对文章中的一些地方进行了小幅度修改(使之更严谨,更容易阅读理解)
1.增加了文件操作部分的内容
2.对文章中的一些地方进行了小幅度修改。
我在开头这里提一下c语言标识符和关键字
c语言的标识符不能和标准库中的关键字重名,除此外,它由字母、数字、以及下划线组成,只能以字母或者下划线开头。这里两种命名方法,各举一个例子(这两种没有优劣之别,使用完全看个人习惯):
这就代表以后可以用p_char 来代表指向字符的指针类型了。
1、使复杂的声明变得简单,避免出错和提高代码的可阅读性
2、可以隐藏指针,数组语法
他们两个都可以给类型名更改一个名字,但是不同于typedef的是,#define只是简单的符号替换,并没有使一个标识符根本上成为一种已知的类型名( 我这么说你可能听不明白,没关系,我写一段代码,结合我的文字你绝对就理解了 )
第一段代码中的a是一个指向字符的指针,而b只是一个字符变量
第二段代码中的a、b都是指向字符的指针
还有,#define重命名的类型支持扩展,而typedef重命名的类型不支持扩展,看以下代码:
在c++的编译器上这个输出结果是:
但是如果把定义num的那行代码改成这种
那么输出结果就会是我们想要的
下面的书写的格式为: 所占位数->所表示范围
下面我特地讲一下,浮点型和整形在内存中的存储方式
这里我们要知道什么是原码、反码和补码:
1)原码:将一个整数转换成二进制形式就是它的原码(即正数和负数的原码相同)
2)反码:负整数的反码就是它的原码除首位的符号位外其他位按位取反,正数的反码就是它的原码
3)补码:负整数的补码就是它的反码加一,正整数的补码就是它的原码
一切整数在内存中都是以它的补码的形式存储的,而读取的时候则转换回原码。这样的存储方式有两个好处
- 把加法和减法合并为一种运算
2>浮点型数据在内存中的存储方式
比方说数字12.125,它首先会转换成二进制的数字:。用指数表示法就是1.100001 x 2^3,又因为所有的数字转换成二进制指数表示法小数点前的数都是1,所以这个数字和小数点就都可以省略了(若是0.1xx怎么办?当然是转换成1.xxx *2^-xx的形式了。。。),然后将截取后的尾数放到尾数位,不满的则补0。指数位置的存储方式是取能表示的数的范围中的中间值(8位范围为0~255,所以中间值就是127),然后给2的指数减去这个数字(读取的时候再加上),然后转换成二进制的形式,然后放到指数的位置。
struct定义的结构体类型、union定义的联合体(共用体)类型、enum定义的枚举类型
这里提一下关键字typedef,typedef的用法就是给原有的数据类型起一个名字,方便以后的使用。例如:
以后再用到上面封装的那个结构体类型时,就不用使用struct Node来定义了,直接用Node来定义可以了,这样一方面时为了减少因为手误而出现的错误,一方面也可以使代码变得简洁。
switch case语句除了上述用法外,还有一个fall though的用法,下面说明这种用法
可以用好几个情况对应一种事件的发生,例如1~5都代表工作日,而如下代码就表示了这种用法的使用方式。事实证明,fall though在某些特定情况下还是非常有用的。。。
for语句相对于while语句的好处在于,它的使用可以使代码变得紧凑,且部分变量的增减位于头部,一目了然,不易出错。
do_while语句的作用和while语句类似,不同之处在于它至少要执行一次花括号内的代码块。
对于操作符来说,主要就三点内容:优先级(网上有详细的15级)、结合性、是否控制求值顺序(&&,||……)
左值:标识了一个可以存储结果值的地点。
在使用右值的地方都可以使用左值,但是相反则不一定
复杂表达式的求值是由三个因素决定的:操作符的优先级,操作符的结合性,以及操作符是否控制求值顺序,相邻两个操作符究竟哪个先执行取决于他们的优先级,若是优先级相同,则再看结合性。这里我强调一下,优先级的那个表最好还是背过,因为虽然自己可以在所有要执行的运算两边加上括号,以避免优先级的问题,但是不代表别人也会那样,而背过那个表,就会避免很多不必要的麻烦。
PS:笔者是见得多了,用得多了就记住了,这个记忆方法网上还是有很多的,觉得吃力的读者可以去学习学习
(注意:这个维数可不是空间上的维数,所有类型的数组在内存上都是呈线性排列的,读者千万别被和谭书上类似的说法给误导了)
需要注意 int a[10] = {1}; 并不能把数组初始化为全1,只是将第一位初始化为1,后面全部都是0滴.
注意:数组只能在定义的时候一次给多个元素赋值,以后一次只能给一个元素赋值
这里需要注意的地方有以下几点:1)数组名只有在:sizeof()里面,还有取地址的时候代表整个数组的长度,其他时候都代表数组首元素的首地址2)数组在使用的时候可以采用下标的方式(eg:arr[3]),也可以采用解引用的方式(eg:*(arr+3))
指针和数组是什么关系呢?答案是:一点关系都没有
这里主要论述一下二者的不同
1>在多文件编程中的声明上来说,在A文件中定义为数组,在B文件中声明为指针是会出错的,定义为指针,声明……也一样会出错。2>从举藕法来说,两个指针指向同一个字符串,更改一个指针的内容是会改变另一个指针指向的内容,但是两个数组若是都存放同一个字符串,更改一个数组的内容对另一个数组的内容并没有影响。这是因为两个指针指向的字符串内容存储在字符常量区,而数组中存放的内容则存储在内存上的栈区。3>看代码
因为数组名放在sizeof里面代表整个数组,而指针的名字永远代表一块地址,而地址在内存中永远占4个字节(windows32位平台下)
柔性数组是什么呢?它定义在结构体的最后一个成员,但是它的前面至少要有一个成员。使用方法如下:
柔性数组的优点主要有这两点:
在实际操作中,我们常常需要将一些长度不等的字符串保存在一个二维数组中,你大概会这么做:
这样做确实达成了我们的要求,但是这样操作明显有两个缺点:
1.你为了能够存放下所有的字符串,定义的二维数组第二维大于最大的那个字符串长度,但是对于相对很短的字符串的存储来说就浪费了大量的空间,比方上例中的存储 I 的那块空间
2.我们在实际操作中有时候并不知道需要多大的空间来存储要存储的数据,这样你这只能尽可能的分配更大的空间来达成我们的需求,这样以来,既有极大的不安全性,又有可能浪费大量的空间
为了避免这种问题,我们可以这样做>
这种操作就被称为“锯齿形数组”,由于 [] 的优先级高于 *,所以dst先和 [] 结合形成一个数组,然后数组里面存放的元素就是指针,指向要存储的字符串。
作用:给编译器声明函数的信息,使函数不用重复定义就可以直接使用
模板: 返回值类型 函数名{参数列表}
1、返回值类型若是没有则写void,否则会缺省为整形类型。这里若是遗忘显式注明返回值类型,而恰好函数返回值不是整形(eg:float)则会出现问题
2、函数名的命名规则见开始的标识符命名规则
3、参数列表的格式:类型名称+变量名称(可写可不写,但是建议写上,因为这可以起到帮助程序员理解函数的作用!),……
若是没有参数传递,则注明void
由于在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机提供了一种被称为栈(在调用的时候形成栈帧)的数据结构来支持参数传递。
栈是一种先进后出的数据结构,它由栈顶、栈底、以及一个指针构成。栈底通常不变,变的都是栈顶的位置。函数调用在传入参数的过程中,会按照一定的顺序将参数压入栈中(push),栈顶指针随之移动,指向最新的栈顶,当调用结束时,又将参数从栈顶弹出(pop)。
函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在堆栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改堆栈,使堆栈恢复原装。
栈溢出的原理:函数在调用的时候就会给定一块栈帧空间,而如果操作使得数组的大小超出了给定的空间大小,从而覆盖掉函数的返回值地址,函数返回错误就会出现段错误。eg:你定义了一个具有10个元素的数组,但是在赋值的时候却超过了10个元素,这时候,多余的数据就会覆盖函数的返回值地址,这就是所谓的栈溢出。
常见的调用约定有:stdcall(pascal调用约定)和cdecl(c调用约定)
在参数传递中,有两个很重要的问题必须得到明确说明:
而调用约定就是用来解释说明这两个问题的。
这里我简单的说明一下这些函数的作用,具体的实现网上都有,需要的朋友可以去查
这里要注意两个字符串相等的时候
函数说明: 这个函数用于将字符串source复制在字符串target的后面,若是字符串target的长度比source的长度长,则在复制过去的source后面自动加上\0,即覆盖以前的字符串,但是相对应的,要是……比……短,则不会在后面加上\0,这样用%s输出的时候就会出错!!!
函数说明:这个函数用于求字符串的长度,需要注意的是函数的返回值是一个无符号类型的整数,也就是说,需要注意向下面这种情况:
恐怕这句“OK!”哪种情况都是无法输出的,因为strlen()的返回值是一个无符号类型整数,而两个无符号类型整数运算的结果也是一个正整数,所以……所以如果有需要,则要将strlen的返回值强制类型转换成int型
函数说明:从字符串dst中查找字符cc(注意这里是整形,用的时候要用单引号括起来),函数返回第一次找到cc的位置,若未找到,则返回NULL,类似的还有strrchr(这个函数是从后向前找)
函数说明:它从字符串str1中隔离各个单独的称为标记的部分,并且丢弃分隔符。
这个函数还是相当重要的,我在这里写一段代码来说明一下它的使用方法:
这个函数若用于将一串字符中想要去掉的一些字符拿走,将这些字符前后的字符连起来,则可以封装成一个函数,需要用的时候直接将字符串作为参数传进去还是很方便的!
3) 字符分类函数和字符转换函数
这里我列举几个常用的函数
4)I/O家族(具体放到文件那里讲)
详情参见我的另一篇博客:
这里易出现的问题有:带歧义的宏、宏的副作用(i++和i+1)
按照命名约定,定义宏名时通常为大写字母。
下面做一下函数和宏的优缺对比
1、从代码量上来说,函数是将一段代码块封装成一条代码(即函数调用),所以它的使用可以减少一个项目中的代码量。但是宏替换每一次都会将一段代码插入到项目代码中,从而增加项目代码量。所以除非宏非常短,否则就使用函数来代替宏的功能
2、从执行速度上来说,由于函数的使用存在额外的返回/调用开销,所以在这点上,宏普遍比函数快
3、从参数类型上来说,函数的参数必须声明为已知类型,也就是说,它的使用一定程度上受到了参数类型的限制。还有函数的参数不可以传递类型。而宏是与类型无关的,而这个特性既是优点,也是缺点(不严谨)。
4、从操作符优先级上来说,由于函数只在函数调用时求值一次,所以它不会存在由于邻近操作符优先级的问题而产生不可预料的结果。
5、函数没有宏的歧义性
6、宏无法调试,因为它在预编译阶段就已经完成了操作
这里说明一下#和##的作用
上面这段代码的结果是给sum1加上10
在文件的开头加条件编译指令:
指向自定义类型的指针(eg:结构体指针……)
两个指向相同数组的指针相减的值是他们相差的元素个数,指针不可以相加。
指针变量名+n代表指针指向向前跳n个指向元素类型的偏移量
我在这里举几个复杂声明的例子来说明问题(附解析)
这是一个什么东西!?别晕,听我讲。
看到这种复杂的声明,我们首先从标识符入手,它的标识符是signal,左边是星号,右边是括号,但是由于括号的优先级高于星号的优先级,所以signal首先被解释为一个函数,既然是函数,那么signal后面紧接着的那个括号内肯定就是参数了,第二个参数是一个指针,指向一个返回值为空,具有一个整形参数的函数,然后再来看signal的返回值。首先我们可以看到它的返回值是一个指针,指向什么呢?这时你可以把signal左边的括号去掉,就剩下了 void 空缺(int),这时就一目了然了,指针的指向就是一个返回值为空,有一个整形参数的函数。
这时我们总结一下,signal就是一个函数,这个函数有两个参数,一个整形,一个指向(返回值为空,具有一个整形参数的函数)的函数指针,返回值为一个(返回值为空,具有一个整形参数的函数)!
一般碰到这种情况我们可以这样来简化声明:将重复出现的类型用typedef关键字进行重新命名,然后再声明就会变得简单多了,比方说上面这个:
我们来看,取arr的地址加1代表将数组的地址整个向后偏移一个数组长度,然后再强转成int * ,此时的地址类型由数组变成了整形,再赋值给ptr1。ptr-1,根据指针运算,减一就代表减一个类型的步长,它的类型是整形,也就是此时ptr1由指向arr数组后面那数组大小的地址的开始向前偏移一个整形大小,那么它此时就指向arr数组的末尾元素,解引用的结果就是10。第二个,(arr+1),从数组那里我们可以知道,数组名大多数情况下都代表数组首元素的地址,这里数组arr的首元素是一个一维数组,给它加一,就相当于偏移了一个一维数组的长度,此时它的地址就是数组arr第二个元素,对它解引用,翻译成另一种你容易理解的形式就是arr[1],而这代表第二个元素的地址,再将其转换成int *,输出的时候减一就相当于向前偏移一个整形的长度,所以第二个输出结果为5
c是一个指针数组,cp是一个二级指针数组,cpp是一个三级指针。第一个输出:先对cpp进行自加一,此时它指向cp[1],而cp[1]又指向c+2,所以对其两次解引用的结果是:POINT。第二个输出:cpp前面的操作符的优先级都高于加法操作符的优先级,所以我们先看前面,先对cpp自加1,此时它指向cp[2],对cpp一次解引用得到c+1,然后再自减1得到的是c,对其解引用再加3,它的输出是:ER。第三个输出:cpp[-2]存放的是c+3的地址,对其解引用得到的结果加三也就是:ST。第四个输出:cpp[-1][-1]的意思翻译成另一种形式就是 * ( *(cpp-1)-1 ),也就是c+1,然后对解引用结果再加1,所以输出结果就是:EW。
回调函数(参见qsort()函数的使用)
说到结构体的大小,就不得不提一下内存对齐
内存对齐(性能原因):数据结构中内存需要对齐到自然边界上,原因在于由于CPU访问未对齐的内存需要两次访问,而访问对齐的内存只需要一次,提高了效率
1。结构体中的第一个成员实际操作(后面有解释为什么是实际操作)上不需要内存对齐,也就是说它在与结构体偏移量为0的地址处。
2。其他成员需要对齐到对齐数的整数倍的地址处。(对其数:编译器默认的一个对齐数字和该成员本身大小的较小值< Linux下:4 Windows下:8>)
3。结构体的总大小等于成员最大对齐数的整数倍(包括第一个成员,这就是前面为什么是实际操作上)
4。如果是嵌套结构体的结构体求大小,内部结构体对齐到它成员的最大对齐数的整数倍处,而外部结构体的大小则是所有成员的最大对其数的整数倍(包括内部的结构体的最大对其数)。
当数值的低位段存储在内存的低地址处,这种计算机模式被称为小端模式,反之则被称为大端模式。(这里列举一种用联合体判断大小端的方法)
位段的声明和普通的结构类型相同,但是它的成员是一个或者多个bit位的字段。这些不同长度的字段实际上存储在一个或者多个整形变量中。(概念看不懂不要紧,往下看,最后学会位段的使用就行)
从它的概念中我们就可以看出,位段必须声明为int,signed int或unsigned int类型,其次,在成员的后面加上冒号,冒号后面为位段所占的位数。(这里我强调一下,最好将位段显式声明为signed /unsigned ,不要为了省事而只写一个int,因为只声明为一个int,它被解释为unsigned
还是signed事要根据编译器决定的!)
1.最大的好处在于,它可以节省存储空间,尤其是需要成千上万个这种结构的时候!
2.位段可以很方便的操作一个整数的部分内容。(常见于操作系统的设计代码)
向函数传递结构参数的效率非常低下,通常可以采用传递指向结构的指针的方法
位段在本质上是不可移植的
联合在实现变体记录(内存中的某个特定区域在不同时刻具有不同类型的值)的时候非常有用
联合变量初始化的时候必须与第一个变量的类型相匹配
malloc的实现原理涉及到内存池的概念
所谓野指针即没有确定指向的指针,这时候若是对其解引用操作就会出现操作异常失败。野指针通常出现在使用free释放一块内存后,忘记将指针指向NULL,而后面却对其解引用操作。
注意每一段申请的内存在使用完毕后都要使用free来释放掉,否则,就会导致内存泄露的问题
进行内存分配的时候要对函数的返回值进行检查,eg:
这里要注意realloc的返回值有三种可能:其一为原内存后面空闲内存的大小足够新分配的内存,所以直接返回原内存的首地址;其二为原内存后的闲置内存不够分配,所以重新找了一块空间分配,而原来的内存则丢失掉;其三为新找了一块内存还放不下,所以返回NULL,而原来的内存而也会丢失掉,所以在使用realloc的时候,事先准备一个指针来接受新分配的内存,若是分配成功,则赋值给原来的指针,否则就报错,这样原来分配的内存就不至于丢失。
1.打开文件和关闭文件
“打开”函数说明:第一个参数filename是要打开的文件路径(包含文件名称),第二个参数mode是指对文件操作的权限,一般常用的权限有:
r: 以只读的形式打开文件,文件必须存在
w: 以只写的形式打开文件,若是文件已经包含内容,则覆盖其内容,若是文件不存在,则在该路径创建目标文件并写入内容。
a: 以追加的形式打开文件,若是文件已经包含内容,则在其后面继续添加。若是文件不存在,则在该路径创建目标文件并写入内容。
b: 二进制文件,和上述三个操作联合使用,eg:rb,wb。
t: 文本文件,可省略不写。
“关闭”函数说明:文件正常关闭时,fclose() 的返回值为0,如果返回非零值则表示有错误发生。
注意:基于防御性编程的原则,通常要对打开文件文件操作进行检验,一般的格式可以为下(打开当前目录下的文件filename):
最后一定要记得对打开的文件使用fclose()进行关闭。
2.以字符形式进行文件操作
说明:两个函数若是读取/写入成功,则返回读取的字符数目。若是失败,返回EOF(具体是哪个数值要看编译器是怎么规定的,一般为-1)
在文件内部有一个位置指针,用来指向当前读写到的位置,也就是读写到第几个字节。在文件打开时,该指针总是指向文件的第一个字节。使用fgetc(fuptc) 函数后,该指针会向后移动一个字节,所以可以连续多次使用fgetc(fputc)读取(输出)多个字符。
feof函数用来判断文件操作指针是否到达文件的末尾,若是,则返回非零值,否则返回零。ferror函数用来判断文件操作是否出错,是则返回非零值,否则返回零值。
3.以字符串的形式文件操作
函数说明: dststr 为字符数组,n 为要读取的字符数目,fp 为文件指针。
函数返回值,读取成功时返回字符数组首地址,也即 str;读取失败时返回 NULL;如果开始读取时文件内部指针已经指向了文件末尾,那么将读取不到任何字符,也返回 NULL。
遇到换行时,会将换行符一并读取到当前字符串,然后结束本次读取操作。这点和gets()不同,gets()会自动忽略换行符。还有,在设置fgets的接收参数时,要将其空间设置为需要接收的文件字节数加一,这是因为fgets每次读取的字符个数为n-1,最后一个字符默认置为NULL。假如目标文件中目标行的字符数目大于n-1,则fgets函数读取n-1个字符后结束本次操作,下次读取继续从上次未读完的行读取字符。
2.fgets函数不能用于读取二进制文件的内容!
什么是随机读写文件呢?顾名思义,就是可以在文件的任意位置进行文件读写。要实现这个操作的关键就在于怎样按照想法移动文件内部的位置指针,称为文件的定位
说明: 将文件内部位置指针放到文件的开始位置
说明: offset为移动的距离
orign为开始移动的位置,规定有三种:
**注意:**fseek函数通常用于二进制文件的操作
PS:这是作者的脑力劳动成果,希望广大网友转载可以注明出处