一、预备知识
我个人感觉在深入理解指针之前,必须复习和学习一下计算机原理的基础知识。首先,大家先来看一下这么一个问题:
计算机是如何从内存中进行取指的?
计算机的总线分为
数据总线、
地址总线和
控制总线。这里不对控制总线进行叙述,数据总线用于进行数据信息传送,数据总线的位数一般与CPU字长一致。数据总线的位数跟当前机器int值的长度相等。例如在16位机器上,int的长度是16bit,32位机器则是32bit;这是计算机一条指令最多能够读取或存取的数据长度。大于这个值,计算机将进行多次访问。这也就是我们说的64为机器进行64位数据运算的效率比32位要高的原因,因为32位机要进行两次取指和运行,而64位却只需要一次。
地址总线专门用于寻址,CPU通过该地址进行数据的访问,然后把处于该地址处的数据通过数据总线进行传送,传送的长度就是数据总线的位数。地址总线的位数决定了CPU可直接寻址的内存空间大小,比如CPU总线长32位,其最大的直接寻址空间长232KB,也就是4G。这也就是我们常说的32位CPU最大支持的内存上限为4G(当然,实际上支持不到这个值,因为一部分寻址空间会被映射到外部的一些IO设备和虚拟内存上。现在通过一些新的技术,可以使32位机支持4G以上内存,但这个不在这里的讨论范围内)。
一般而言,计算机的地址总线和数据总线的宽度是一样的,我们说32位的CPU,数据总线和地址总线的宽度都是32位。
计算机访问某个数据的时候,首先要通过地址总线传送数据存储或读取的位置,然后通过数据总线传送需要存储或读取的数据。一般的,int整型的位数等于数据数据总线的宽度,指针的位数等于地址总线的宽度。
学过C语言的人都知道,C语言的基本数据类型中,就属char的位数最小,是8位。我们可以认为计算机以8位,即一个字节为基本访问单元,小于一个字节的数据,必须通过位操作来进行访问。
二、内存访问方式
如图1所示,计算机在进行数据访问的时候,是以字节为基本单元进行访问的,所以可以认为,计算机每次都是从第p个字节开始访问的。访问的长度由编译器根据实际类型进行计算,这在后面将会进行讲述。
图1 内存访问方式
想要了解更多,请去翻阅计算机组成原理和编译原理。
三、sizeof关键字
sizeof关键字是编译器用来计算某些类型的数据的长度的,以字节为基本单位。例如:sizeof(char) = 1; sizeof(int) = 4; sizeof(Type)的值是在编译的时候就计算出来了的,可以认为这是一个常量!也正因为这样更加确定sizeof是运算符而不是函数,这是经常出现在面试的问题。
四、什么是指针
指针其实就是数据存放的地址,图1中的p就是一个指针。在图1中,n一般是CPU的位数,32为机上,n = 32。
因为指针需要能够指向内存中的任意一个位置,因此,指针的长度应该是n位的,32位机器上指针长度就是32位,这和整型的长度是相等的!
在我个人理解中,可以将指针理解为int整型,只不过它存放的数据是内存地址,而不是普通数据,我们通过这个地址值进行数据的访问,假设它的值是p,意思就是该数据存放位置为内存的第p个字节。
当然,我们不能像对int类型的数据那样进行各种加减乘除操作,这是编译器不允许的,因为这样做是非常危险的!
图2就是对指针的描述,指针的值是数据存放地址,因此,我们说,指针指向数据的存放位置。
图2 指针
五、指针的长度
我们使用这样的方式定义一个指针:
Type *p;
我们说p是指向type类型的指针,
type可以是任意类型,除了可以是char、short、int、long等基本类型外,还可以是指针类型,例如:int *、int **,或者是更多级的指针,也可以是结构体或者函数等。所以我们说:
int *是指向int类型的指针;
int **,即(int *)*,是指向int *类型的指针,也就是指向指针的指针;
int ***,即(int **)*,是指向int **类型的指针,也就是指向指针的指针的指针;
看完这几个,大概能理解吧。
struct xxx *,是指向struct xxx类型的指针;其实,说那么多,主要是希望大家在看到指针的时候,不要被int ***这样的东西所吓到,就像前面说的,指针就是指向某种类型的指针,我们只看最后一个*号,前面的只不过是type类型罢了。
细心的朋友们应该妨碍西安,在“什么是指针”那一小节中,已经表明了:
指针的长度跟CPU的位数相等,大部分的CPU是32位 的,因此,我们说,指针的长度是32bit,也就是4个字节!注意:任意指针的长度都是4个字节,不管是什么指针!(当然64位机自己可以测一下,应该是8个字节)
所以,Type *p;
sizeof(p)的值是4,Type可以是任意类型,char、int、long、struct、class、int **......
以后大家看到什么sizeof(char *)、sizeof(int *)、sizeof(xxx *),不要理会,统统写4,绝对不要被type类型迷惑!至于type是干什么用的,这个是给编译器用的,用于指针运算,这个在下面章节做详细介绍。
六、取地址
我们说指针指向的是数据的存放地址,因此指针的值等于数据的存放地址。那么给指针赋值的时候就需要进行数据的取地址操作,这个我想不用多说,各位也是知道&符号,没错,就是&符号。
我们可以这样取地址:
Type v,*p = &v;
当然也可以:
Type v,*p;(或者Type v;Type *p)
p = &v;
这里的Type依然是任意的类型,可以是N级指针、结构体、类或者函数什么的。
七、指针运算
大家都知道几乎所有的面试都会考这种东西:
Type *p;
p ++;
然后问你p的值变化了多少?
其实也可以认为这是在考编译器的基本知识。因此p的值并不像表面看到的+1那么简单,
编译器实际上对p进行的是sizeof(Type)的操作。
看一个一段代码的测试结果:
-
short sv = 1,*psv = &sv;
-
int iv = 1,*piv = &iv;
-
long lv = 1,*plv = &lv;
-
long long llv = 1,*pllv = &llv;
-
float fv = 1.0,*pfv = &fv;
-
double dv = 1.0,*pdv = &dv;
-
long double ldv = 1.0,*pldv = &ldv;
-
-
cout<<"psv:"<<psv<<"psv + 1:"<<psv + 1<<endl;
-
cout<<"piv:"<<piv<<"piv + 1:"<<piv + 1<<endl;
-
cout<<"plv:"<<plv<<"plv + 1:"<<plv + 1<<endl;
-
cout<<"pllv:"<<pllv<<"pllv + 1:"<<pllv + 1<<endl;
-
cout<<"pfv:"<<pfv<<"pfv + 1:"<<pfv + 1<<endl;
-
cout<<"pdv:"<<pdv<<"pdv + 1:"<<pdv + 1<<endl;
-
cout<<"pldv:"<<pldv<<"pldv + 1:"<<pldv + 1<<endl;
-
cout<<endl;
执行结果:
观察结果,可以看出,他们的增长结果分别是:
2 =>sizeof(short)
4 =>sizeof(int)
4 =>sizeof(long)
8 =>sizeof(long long)
4 =>sizeof(float)
8 =>sizeof(double)
1
2 =>sizeof(long double)
这样看来,增加的值是不是sizeof(Type)呢?别的什么struct,class之类的,就不验证了,有兴趣的自己验证一下。
我们再对这样的一段代码进行汇编,查看编译器是如何进行指针的加法操作的:
-
int iv =1,*piv = &iv;
-
piv ++;
-
cout<<piv<<endl;
-
piv = piv + 4;
-
cout<<piv<<endl;
-
cout<<endl;
汇编结果:
-
call __main
-
...
-
addl $4,-8(%ebp) //这里是piv++
-
...
-
addl $16,-8(%ebp) //这里是piv+4
-
...
-
ret
注意看注释部分的结果,我们看到,piv的值显示加了4(sizeof(int)),然后又加了16(4*sizeof(int))。
总结:
指针的实际运算,将会由编译器在编译的时候,根据指针指向数据类型的大小进行实际的翻译转换。指针类型的作用就在于此,让编译器能够正确的翻译这些指令的操作,另一方面,也让编译器检查程序员对指针操作是否合法,保证程序的正确性和健壮性。
Type *p; p = p + i;
最终p的值实际上是:p + i*sizeof(Type);
Type *p; p = p - i;
最终p的值实际上是:p - i*sizeof(Type);
注意:指针只能进行加法和减法操作,不能进行乘除法!(指针毕竟不是普通的整数,乘除法的跨度太大,出发还会搞出小数点,这个是我个人理解,但是编译器不允许进行指针的乘除法。)
八、NULL指针
NULL是C语言标准定义的一个值,这个值其实就是0,只不过是为了使得看起来更加有意义,才定义了这样的一个宏,中文的意思是空,表明不指向任何东西,你懂的,这里就不讨论空和零的区别。
在C语言中,NULL其实就是0,就像是前面说的指针可以理解成特殊的int,它总是有值的,p = NULL,其实就是p的值等于0。对于大多数机器而言,0地址是不能直接访问的,设置为0,就表示该指针哪里都没指向。
当然,就机器内部而言,NULL指针的实际值可能与此不同,这种情况下,编译器将负责零值和内部值之间的翻译转换。
NULL指针的概念非常有用,
它给了你一种方法,表示某个特定的指针目前并未指向任何东西。例如,一个用于某个数组中查找特定值的函数可能返回一个指向查找到的数组元素的指针。如果没有找到,则返回一个NULL指针。
在内存的动态分配上,NULL的意义非同凡响,我们使用它来避免内存被多次释放,造成经常性段错误(segmentation fault)。一般,在free或者delete掉动态分配的动态内存后,都应该立即把指针置空,避免出现所谓的悬挂指针,致使出现各种内存错误!例如:
-
int *p = (int *)malloc(sizeof(int));
-
*p = 23;
-
free(p);
-
p = NULL;
free函数是不会也不可能把p置空的。像下面这样的代码就会出现内存段错误:
-
int *p = (int *)malloc(sizeof(int));
-
*p = 23;
-
free(p);
-
free(p);
因为第一次free操作之后,p指向的内存已经释放了,但是p的值还没有变化,free函数改变不了这个值,再free一次的时候,p指向的内存区域已经被释放了,这个地址已经变成了非法地址,这个操作将导致段错误的发生(此时,p指向的区域刚好又被分配出去了,但是这种概率非常低,而且对这样一块内存区域进行操作是非常危险的!)。
但是下面的这段代码就不会出现这样的问题:
-
int *p = (int *)malloc(sizeof(int));
-
*p = 23;
-
free(p);
-
p = NULL;
-
free(p);
因为p的值变成了NULL,free函数检测到p为NULL,会直接返回,而不会发生错误。
顺便告诉大家
一个内存释放的小窍门,可以有效的避免因为忘记对指针置空而出现各种内存问题。这个方法是自定义一个内存释放函数,但是传入的参数不是指针,而是指针的地址,在这个函数里面置空,如:
-
#include <iostream>
-
#include <stdlib.h>
-
using namespace std;
-
void my_free(void *p)
-
{
-
void **tp = (void **)p;
-
if(NULL == *tp)
-
return;
-
free(*tp)
-
*tp = NULL;
-
}
-
-
int main(int argc,char **argv)
-
{
-
int *p = new int;
-
*p = 1;
-
cout<<p<<endl;
-
my_free(&p);
-
cout<<p<<endl;
-
free(p);
-
return 0;
-
}
结果:0xba0300
0
my_free调用了之后,p的值就变成了0(NULL),调用多少次free都不会报错了。
另外一个方式也非常有效,那就是定义FREE宏,在宏里面对它进行置空。例如:
-
#include <iostream>
-
#include <stdlib.h>
-
using namespace std;
-
-
#define FREE(x) if(X) free(x); x=NULL
-
-
int main(int argc,char **argv)
-
{
-
int *p = new int;
-
*p = 1;
-
cout<<p<<endl;
-
FREE(p);
-
cout<<p<<endl;
-
free(p);
-
return 0;
-
}
执行结果同上面一样,不会报段错误:0x800200
0
九、void指针
虽然从字面上看,void的意思是空,但是void指针的意思,可不是空指针的意思,空指针指的是上面所说的NULL指针。
void指针实际上的意思是指向任意类型的指针。
任意类型的指针都可以直接赋给void指针,而不需要进行强制转换。如:
-
Type a,*p = &a;//Type等于char,int,struct,int*...
-
void *pv;
-
pv = p;
就像前面说的,void指针的好处,就是任意类型的指针都可以直接赋值给它,这在某些场合非常有用,因此有些操作对应任意指针都是相同的。void指针最常用于内存管理。最典型的,也是大家最熟知的,就是标准库的free函数。它的原型如:void free(void *ptr);free函数的参数可以是任意类型的指针,没有谁见过free参数里面的指针需要强转为void*的吧?
malloc,calloc,realloc这些函数的返回值也是void指针,因为内存分配,实际上只需要知道分配的大小,然后返回新分配内存的地址就可以了,指针的值就是地址,返回的不管是何种指针,其实结果都是一样的,因为所有的指针长度其实都是32位的(32位机器),它的值就是内存的地址,指针类型只是给编译器看的,目的是让编译器在编译的时候能够正确地设置指针的值。如果malloc函数设置成下面这样的原型,完全没有问题。
char *malloc(size_t sz);
实际上设置成:
Type *malloc(size_t sz);
也是完全正确的,使用void指针的原因,实际上就像前面说的,void指针意思是任意类型的指针,这样设计更加严谨一些,也更符合我们的直观理解。如果对前面我说的指针概念理解的同学,肯定明白这一点。
十、未初始化和非法指针
经常有面试,会考这样的代码改错:
int *a;
...
*a = 12;
这段代码,在*a = 12这里出了问题。这里的问题就在于,a究竟指向哪里?我们声明了这个变量,但是从未对它进行初始化,一般而言,没有初始化,a的值是任意的,随机的。如果a是全局变量或static类型,它会被初始化为0(前面说过,其实指针可以理解成值是内存地址的int),不管哪种方式,这种方式的赋值都是非常危险的,如果你有着中体彩头号彩票的运气,a的值刚好等于某个变量或分配内存的地址,那么这里运行不会报错,但这个时候的运气却不是什么好运,相反,是非常倒霉!因为这一块不属于你的内存进行操作,这实在是太危险了!如果a的初始值是个非法地址,这个赋值语句在执行的时候将会报错,从而终止程序 ,这个错误同样是段错误(segmentation fault),如果是这样的,你是幸运的,因为你发现了它,这样就可以修正它。
关于这种问题,编译器可能会,也可能不会对它进行检测。GUN的编译器是会进行检测的,会对未初始化的指针或变量输出警告信息。
十一、多级指针(也叫指针的指针)
其实如果对前面的指针概念完全理解了,这里可以略过。指针的指针,无非就是指针指向的数据类型是指针罢了。
Type *p;
其中Type类型是指针,比如可以是int *,也可以是int **,这样p对应的就是二级指针和三级指针。一级指针的值存放的是数据的地址,二级指针的值是存放一级指针的地址,三级指针的值存放的是二级指针的地址,以此类推...
十二、函数指针
跟普通的变量一样,每一个函数都是有其地址的,我们通过跳转到这个地址执行代码来进行函数调用,只是,跟普通数据不同的在于函数有参数和返回值,既然函数也是可以通过地址来进行访问的,那么它也可以使用指针来指向,事实上,每一个函数名都是一个指针,不过它是指针常量和常量指针,它的值是不能改的,指向的值也不能改。关于指针常量和常量指针,可以参考我的另外一篇博文,有详细的讲解:。
函数指针一般用来干什么呢?
函数指针最常用的场合就是
回调函数。回调函数,顾名思义,就是某个函数会在适当的时候被别人调用。当期望你调用的函数能够使用你的某些方式去操作的时候,回调函数就很有用,比如,你期望某个排序函数在比较的时候,能够使用你定义的比较方法比较。
有过较深入的C编程经验的人应该都接触过。C标准库中就有使用,例如在strlib.h头文件的qsort函数,它的原型为:
void qsort(void *__base,size_t __nmemb,size_t __size,int(*_compar)(const void *,const void *));
其中
int(*_compar)(const void *,const void *)就是回调函数,这个函数用于数据的比较。下面,我会举个例子,来描述qsort函数的工作原理。
一般我们使用下面这样的方式定义函数指针:
typedef int(* compare)(const void *x,const void *y);
这个时候,compare就是参数为
const void *,const void *类型,返回值是int类型的函数。例如:
-
typedef int(*compare)(const void *x,const void *y);
-
int my_compare(const void *x,const void *y)
-
{
-
const int *a = (int *)x;
-
const int *b = (int *)y;
-
if(*a > *b)
-
return 1;
-
if(*a == *b)
-
return -1;
-
}
-
-
void my_sort(void *data,int length,int size,compare)
-
{
-
char *d1,*d2;
-
//do something
-
if(compare(d1,d2) < 0)
-
{
-
//do something
-
}
-
else if(compare(d1,d2) == 0)
-
{
-
//do something
-
}
-
else
-
{
-
//do something
-
}
-
//do something
-
}
-
-
int main(int argc,char **argv)
-
{
-
int arr = {2,4,3,656,23};
-
my_sort(arr,5,sizeof(int),my_compare);
-
//do something
-
return 0;
-
}
用typedef来定义的好处,就是可以使用一个简短的名称来表示一种类型,而不需要总是使用很长的代码来,这样不仅使得代码更加简洁易读,更是避免了代码敲写容易出错的问题。强烈推荐定义结构体,指针(尤其是函数指针)等比较复杂的结构时,使用typedef来定义。