理解C语⾔(⼀)数组、函数与指针
1 指针
⼀般地,计算机内存的每个位置都由⼀个地址标识,在C语⾔中我们⽤指针表⽰内存地址。指针变量的值实际上就是内存地址,⽽指针变量所指向的内容则是该内存地址存储的内容,这是通过解引⽤指针获得。声明⼀个指针变量并不会⾃动分配任何内存。在对指针进⾏间接访问前,指针必须初始化: 要么指向它现有的内存,要么给它分配动态内存。
对未初始化的指针变量执⾏解引⽤操作是⾮法的,⽽且这种错误常常难以检测,其结果往往是⼀个不相关的值被修改,并且这种错误很难调试,因⽽我们需要明确强调: 未初始化的指针是⽆效的,直到该指针赋值后,才可使⽤它。
int *a;
*a=12; //只是声明了变量a,但从未对它初始化,因⽽我们没办法预测值12将存储在什么地⽅
int *d=0; //这是可以的,0可以视作为零值
int b=12;
int *c=&b;
另外C标准定义了NULL指针,它作为⼀个特殊的指针常量,表⽰不指向任何位置,因⽽对⼀个NULL指针进⾏解引⽤操作同样也是⾮法的。因⽽在对指针进⾏解引⽤操作的所有情形前,如常规赋值、指针作为函数的参数,⾸先必须检查指针的合法性- ⾮NULL指针。
解引⽤NULL指针操作的后果因编译器⽽异,两个常见的后果分别是返回置0的值及终⽌程序。总结下来,不论你的机器对解引⽤NULL指针这种⾏为作何反应,对所有的指针变量进⾏显式的初始化是种好做法。
如果知道指针被初始化为什么地址,就该把它初始化为该地址,否则初始化为NULL
在所有指针解引⽤操作前都要对其进⾏合法性检查,判断是否为NULL指针,这是⼀种良好安全的编程风格
1.1 指针运算基础
在指针值上可以进⾏有限的算术运算和关系运算。合法的运算具体包括以下⼏种: 指针与整数的加减(包括指针的⾃增和⾃减)、同类型指针间的⽐较、同类型的指针相减。例如⼀个指针加上或减去⼀个整型值,⽐较两指针是否相等或不相等,但是这两种运算只有作⽤于同⼀个数组中才可以预测。如float
指针加3的表达式实际上使指针的值增加3个float类型的⼤⼩,即这种相加运算增加的是指针所指向类型字节⼤⼩的倍数。参考
对于任何并⾮指向数组元素的指针执⾏算术运算是⾮法的,但常常很难被检测到。
如果对⼀个指针进⾏减法运算,产⽣的指针指向了数组中第1个元素前⾯的内存位置,那么它是⾮法的。
加法运算稍微不同,如果产⽣的指针指向了数组中最后⼀个元素后⾯的那个内存地址,它是合法的,但不能对该指针执⾏解引⽤操作,不过之后就不合法了(这和STL中迭代器尾部元素可指向尾部元素的下⼀个位置是⼀样的道理)
关于指针的运算操作将会在数组中的应⽤中更深⼊地介绍。
1.2 typedef和C++中的引⽤
C语⾔中⽤typedef说明⼀种新类型名,来代替已有类型名。它的作⽤是给已存在的类型起⼀个别名,原有类型名仍然有效。如下:
typedef float REAL;
REAL a,b;
typedef char* PCHAR;
PCHAR p;
那么和#define有什么区别呢?
typdef int* int_t;
#define int_d int*;
它们的区别主要在于:
前者在于声明⼀个类型的别名,在编译时处理有类型检查;⽽后者只是简单的宏⽂本替换,⽆类型检查
从使⽤上来说,int_t a,b这两个变量都是int *类型的,⽽int_d a,b中b是int类型的
为了更好的理解指针,所以也有必要把C++中的⼀些概念引⼊进来作对⽐。C++中所谓的引⽤实际上是⼀个特殊的变量,这个变量的内容是绑定在这个引⽤上⾯的对象的地址,⽽使⽤这个变量时,系统
⾃动根据这个地址去到它绑定的变量,再对变量操作。即引⽤的本⾝只是⼀个对象的别名,在引⽤的操作实际是对变量本⾝的操作。
本质上说,引⽤还是指针,只不过该指针不能修改,⼀旦定义了引⽤,就必须跟⼀个变量绑定起来,且⽆法修改此绑定。尽管使⽤引⽤和指针都可间接访问某个值,但它们还是有区别的。
引⽤被创建时,它必须初始化(引⽤不能为空);指针可以为空值,可在任何时候被初始化
⼀旦引⽤被初始化为指向某个对象,它就不能改变为另⼀个对象的引⽤;指针可以在任何时候指向另⼀个对象
不能有NULL引⽤,必须确保引⽤是和⼀块合法的存储单元关联
sizeof(引⽤)得到的是所指向变量的⼤⼩;sizeof(指针)得到的是指针本⾝的⼤⼩
在函数中传递实参时,对于⾮引⽤类型的形参的任何修改仅作⽤于局部副本,并不影响实参本⾝(指针作为参数传递时仍然是传值调⽤,传递的副本是指针变量的值)。在C++中,为了避免传递副本带来的开销,将形参指定为引⽤类型,可见这样效率更⾼。但是也带来了对引⽤形参的任何修改会直接影响实参本⾝的副作⽤。
所以既要利⽤引⽤提⾼效率,⼜要保护传递的函数参数在函数中不被改变,就应使⽤常引⽤,定义⼀个普通变量的只读属性的别名,避免实参在函数中意外被改变。
const int ival=10;
const int &ref=ival; //必须使⽤const引⽤
1.3 各种指针
该⼩节主要讲述⼆级指针、通⽤指针和函数指针,与数组相关的指针在后⾯第2章中会具体解释。
1.3.1 指向指针的指针
指针本⾝也是可⽤指针指向的内存对象。指针占⽤内存空间存放其值(值作为地址),因⽽指针的存储地址可存放在指针中,通过间接访问的⽅式。只要当确实需要时,才应该是多级指针。
我们在实现⼆叉树时经常会遇到如何插⼊节点,在C中由于涉及到了指针,经常使我们对节点间究竟有没有链接成功产⽣混淆,特别是不清楚什么时候使⽤⼆级指针,什么时候⼜是⼀级指针。它的结构描述如下:
typedef int T;
typedef struct tree_node {
T data;
struct tree_node *lchild;
struct tree_node *rchild;
} bstree_node;
下⾯分析调⽤该插⼊节点的⽅法,能否成功构建⼆叉树。
void insert_node(node *root,T element);
Step 1: 函数调⽤参数前,root=NULL
当传递的参数是指针时,我们仍然可以把指针看做变量,即传递的是指针值的副本,即产⽣了⼀个和实参地址不同的形参地址,但它们的内容是相同的(这⾥为NULL),并不指向任何位置
Step 2: 调⽤函数并修改形参的内容,为root分配了新地址
if(root==NULL)
root=new_node(data);
可看出函数结束后,形参root的内容(指针本⾝的值)发⽣了变化,由NULL变成了0x4567的地址(只是为了说明情形,该地址表⽰并不准确)。可知root已指向⼀块含有数据的堆内存,⽽实参root仍为NULL,不指向任何内存位置。
因⽽⼀级指针作为参数传递时,在这种⽅法下形参的变化并未使实参发⽣任何变化,因⽽下⼀次调⽤插⼊节点函数时,实参root值始终为NULL,这种⽅法不能建⽴起⼆叉树。那么要成功地构建⼆叉树,使实参指向的内容发⽣真正改变呢,有3个⽅法:
A. 初始化的root结点不为空,即根结点始终不为空
Step 1: 函数调⽤前实参和形参指向
Step 2: 函数调⽤后实参和形参指向
回想⼀下,这种情形是不是很像单链表中的头结点,它极⼤地简化了插⼊和删除操作,实现上更为简洁。
**B. 插⼊函数定义为:bstree_node *insert_node(bstree_node *root,element) **
返回函数操作中变化的形参地址,再把返回值赋值给实参地址(root初始化可以允许为NULL),这样函数结束后实参和形参均指向了相同内容
root=insert_node(root,element);
这种⽅法确实有效,但也可看出有⼀缺点:需要重新调整指针的指向,⽆法在程序执⾏中⾃动修改root的地址,⽽且还占⽤内存空间。所以要想在插⼊和删除节点的操作过程中,⼆叉树能动态地变化⽽⽆需指定返回root地址,该⽤什么样的⽅法呢。于是⼆级指针就上场了
**C. 插⼊函数定义为:void insert_node(bstree_node **root,T element) **
利⽤⼆级指针⽆需返回值便可动态修改⼆叉树,这种实现是最有效的。下⾯请看函数执⾏前后实参和形参的变化图(始终要记住: 函数的参数传递始终是传值调⽤(不包括C++中的引⽤),即传递的始终是参数的拷贝,⼀个副本⽽已)
Step 1: 函数调⽤前实参和形参指向
Step 2: 函数调⽤后实参和形参指向
当根结点为空时, *root=new_node(element) 表明执⾏函数后形参指向的⼆级指针root的内容发⽣了
变化,重新分配了地址,从⽽导致指向的结点内容发⽣了变化,这样实参指向的指针所指向的结点内容同样也发⽣了变化。
当根结点不为空时,根结点的地址不会发⽣变化,只会通过链接的形式链上了左右⼦树。
**D 总结 **
对⽐,我们在实现单链表时使⽤虚拟头结点。优点之⼀是⽅便我们简化插⼊和删除操作,它会动态链接上节点或删除节点。其实它还有⼀个优点:
不管链表是否为空,头结点始终存在。如果不使⽤头结点,插⼊和删除操作就必须要保证⼀个结点存在使结点链接上,否则就必须使⽤返回结点地址(这会占⽤空间)或者使⽤⼆级指针(抽象,使⽤起来容易出问题)。因⽽使⽤虚拟头结点就可避免这些问题了
void insert(linknode *list,int data); //list是虚拟头结点,推荐使⽤
void insert(linknode **list,int data);//使⽤⼆级指针,难懂
linknode *insert(linknode *list,int data);//需要重新调整指针指向,占⽤内存空间
1.3.2 void *指针
C中提供⼀个特殊的指针类型: void *,它可以保存任何类型对象的地址:
double obj=3.14;
double *pd=&obj;
void *pv=&obj;
pv=pd;
void *表明该指针与⼀地址值相关,但不清楚存储在此地址上的对象的类型。void *指针只⽀持以下⼏种操作:
与另⼀个指针⽐较
给另外⼀个void *指针赋值
void *指针当函数参数或返回值
不允许使⽤void *指针操作它指向的对象,值得注意的是函数返回void *类型时返回⼀个特殊的指针类型,⽽不是向返回void 类型那样⽆返回值。
1.3.3 函数指针
函数指针是指指向函数的指针,函数类型由其返回类型及形参表确定,与函数名⽆关,有时候还⽤typedef简化函数指针的定义。
bool (*pf)(int *,int *);
typedef bool (*cmpfcn)(int *,int *); //cmpfcn是⼀种指向函数的指针类型的名字,该类型为指向返回bool类型并带有两个整型指针参数的函数的指针。
在引⽤函数名但⼜没有调⽤该函数,函数名⾃动解释为指向函数的指针,并且直接引⽤函数名就等价于在函数名应⽤取地址操作符。
bool lencmp(int *,int *);
bool (*)(int *,int *);
cmpfcn pf1=0;
pf1=lencmp;
cmpfcn pf2=&lencmp;
函数指针只能通过同类型的函数名或者函数指针或者0值常量进⾏初始化和赋值。初始化为0表⽰该指针不指向任何函数,只有当初始化后才能调⽤函数。调⽤它可以直接使⽤函数名或者直接利⽤函数指针,不⽤解引⽤符号或者使⽤解引⽤符号,如下:
cmpfcn pf=lencmp;
lencmp(a,b); //调⽤1
pf(a,b); //调⽤2
(*pf)(a,b); //调⽤3
另外函数的形参也可以是指向函数的指针,这个通常被称为回调函数。允许形参是⼀个函数类型,它对应的实参被⾃动转换为指向相应函数类型的指针,注意函数的返回类型不能是函数。
int (*ff(int))(int ,int); //返回指向函数的指针
typedef int (*PF)(int ,int );
PF ff(int); //函数ff返回⼀个函数指针
typdef int func(int ,int); //func是⼀个函数,⽽不是⼀个函数指针
void f1(func); //正确,f1的形参是⼀个函数指针,func⾃动转换为函数指针
func f2(int); //错误,⽆法被⾃动转换
func *f3(int); //正确,f3返回⼀个函数指针
int (*a[10])(int); //⼀个有10个指针的数组,每个指针指向⼀个函数,接收⼀个整型参数返回⼀个整型
int (*(*p)[10])(int); //声明⼀个指向10个元素的数组指针,每个元素是⼀个函数指针,接收⼀个整型参数返回⼀个整型。
2 数组与指针
⼈们在使⽤数组时经常会把等同于指针,并⾃然⽽然地假定在所有的情况下数组和指针都是等同的。为什么出现这样的混淆?因为我们在使⽤时经常可以看到⼤量的作为函数参数的数组和指针,在这种情况下它是可以互换的,但是⼈们容易忽视它只是发⽣在⼀个特定的上下⽂环境中。如在main函数的
参数中有这样的char **argv 或char *argv[]的形式,因为argv是⼀个函数的参数,它诱使我们错误地总结出指针和数组是等价的。如下⾯⼀个程序:
#include< stdio.h >
int len(int arr[]){
return sizeof(arr);
}
int main(){
int arr[]={1,2,3,4,5};
printf("%d\n",sizeof(arr)); //sizeof计算类型或变量或类的存储字节⼤⼩
printf("%d\n\n",len(arr)); //同样使⽤sizeof,为什么结果不同
printf("arr=%p\n",(void *)arr); //指针表⽰法取地址:arr+0..len-1
printf("&arr[0]=%p\n\n",(void *)&arr[0]); //数组表⽰法取地址: &arr[0..len-1]
int *p=(int *)(&arr+1);//&arr+1与arr+1为什么有区别
int *p1=(int *)(arr+1);
printf("arr+1=%p\n",(void *)p1);
printf("&arr+1=%p\n\n",(void *)p);
return 0;
}
c语言listinsert函数结果如图所⽰:
可以看出:
使⽤sizeof计算数组的时候数组名有时候当指针来看,有时候⼜当整个数组来看待
数组表⽰法有时候和指针表⽰法等价,但数组名前加⼀个&运算符,它却不等同于指针的使⽤。
可知数组和指针并不全都相同。那么数组什么时候等同于指针,什么时候不等同于指针呢?
2.1 区分定义和声明
extern声明说明编译器对象的类型和名字,描述了其他地⽅的创建对象
定义要求为对象分配内存:定义指针时编译器并不为指针所指向的对象分配空间,它只是分配指针本⾝的空间
int a[100];
extern int a[]; //正确并且⽆需提供关于数组长度的信息
extern int *a;//错误,⼈们总是错误地认为数组和指针⾮常类似
2.2 数组和指针是如何访问的
X=Y:左值在编译时可知,表⽰存储结果的地址;右值表⽰Y的内容
也就是说编译器为每个变量分配⼀个左值,该地址在编译时可知,⽽变量在运⾏时⼀直保存于这个地址,⽽右值只有在运⾏时才可知。如需⽤到变量中存储的值,编译器就发出指令从指定地址读⼊变量
值并将它存⼊寄存器中。如果编译器需要⼀个地址,可能要加上偏移量来执⾏某种操作,它就可以直接进⾏操作,并不需要增加指令取得具体的地址。相反对于指针,必须先在运⾏时取得它的值然后才能对它解引⽤。
下⾯分别是对数组下标的引⽤和对指针的引⽤的描述:
char a[9]=”abcdefgh”; c=a[i];
编译器符号表具有⼀个地址9980,运⾏时
步骤1:取i的值,将它与9980相加
步骤2:取地址(9980+i)的内容
char *p; c=*p;
编译器符号表有⼀个符号p,它的地址为4624,运⾏时
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论