C++深入体验之旅七:指针

原创 2013年12月02日 11:08:03

 1.什么是指针

在我们的桌面上,往往有这样一些图标:在它们的左下角有个小箭头,我们双击它,可以调出本机内的一些程序或文件。然而我们发现这些图标所占的存储空间很小,一般也就几百到几千字节。可是那么小的文件怎么会让上百兆的程序执行起来的呢?

后来,我们知道那些有小箭头的图标文件称为快捷方式。它所存储的内容并不是所要调用的程序本身,而是所要调用的程序在本机磁盘上的位置。(比如D:\Tencent\QQ\QQ.exe,如图8.1所示)使用快捷方式的目的就是为了快捷方便,不用去查找程序就能去执行它。不过如果所要调用的程序不存在或位置不正确,那么双击了这个快捷方式就会导致错误发生。
在内存中,可能会有一些数据,我们不知道它的变量名,却知道它在内存中的存储位置,即地址。我们能否利用快捷方式的原理对这些数据进行调用和处理呢?
很幸运,在C++中,也可以给内存中的数据创建“快捷方式”,我们称它为指针(Pointer)。它和整型、字符型、浮点型一样,是一种数据类型。指针中存储的并不是所要调用的数据本身,而是所要调用的数据在内存中的地址。我们可以通过对指针的操作来实现对数据的调用和操作。

2.指针变量的定义和使用

指针的类型

同变量的数据类型类似,指针也有类型。之所以指针会有类型,是为了符合对应的变量或常量数据类型。要注意,指针本身也是一种数据类型。
不同指针类型的本质在于不同的操作。这点和快捷方式是类似的。比如双击一个可执行文件(.EXE)快捷方式的操作是运行这个可执行文件,而双击一个Word文档文件(.DOC)快捷方式的操作是使用Word程序打开这个文档。类似地,一个字符型数据在内存中占用一个字节,那么读取数据就应以字符型数据读出一个字节;一个长整型数据在内存中占用四个字节,那么读取数据时就应以长整型数据读出四个字节。如果指针类型与它所指向的数据类型不匹配,就可能对数据作出错误的操作。

指针变量的声明

指针变量也是一种变量。所以在使用之前,必须先声明。声明指针变量的语句格式为:
指针的类型 *指针变量名;
其中,指针类型应该是与指针所指向的数据相符合的数据类型。比如int、char、float等等。*表示所要定义的是一个指针变量,而不是一个普通变量。指针变量名则应该遵循起变量名的一切规则。
例如:
   char *cptr;//指向字符型变量的指针变量
   int *iptr;//指向整型变量的指针变量
要注意,当我们要声明多个指针变量时,必须在每个指针变量名前加上*,例如:
   int *iptr1,*iptr2,iptr3;//iptr1和iptr2是指向整型变量的指针变量,而iptr3是整型变量

获取地址和指针变量初始化

我们已经声明了指针变量,那么我们如何获得数据在内存中的地址呢?
在C++中,用&操作符可以获取变量或常量在内存中的地址,我们称之为取地址操作符。它的使用格式是:
&变量名或常量名
既然能够获取到数据所在的地址,我们就能把这个地址交给指针了。例如:
    int c=9;
    int *iptr=&c;//声明指向整型变量的指针变量,并作初始化
这时,我们称指针iptr指向了变量c。在第三章我们说过,声明一个未经初始化的变量之后,它的值是无法确定的。所以如果声明了一个指针却不对它作初始化,那么它所指向的内容也是无法确定的,而这种情况是十分危险的。

特殊的值——NULL

没有初始化的指针变量是危险的。可是如果在声明变量之后,找不到合适的地址进行初始化,我们该怎么办呢?显然,随便找个地址对指针变量做初始化是不负责任的。
在这里,我们引入一个特殊的地址——NULL。它的意思是“空”,即指针没有指向任何东西。比如:
    int *iptr=NULL;
要注意的是,C++是大小写敏感的,NULL与null是不同的。所以,在使用的时候必须要大写。

指针的使用——间接引用

双击一个有效的快捷方式,就能够调用对应的文件,那么我们通过什么方法才能操作指针所指向的变量呢?
在这里,*又出现了,它称为间接引用操作符。其作用是获取指针所指向的变量或存储空间。间接引用的指针可以作为左值。(关于左值概念请参见第三章)具体的使用格式为:
*指针变量名
下面,我们来看一段程序,实践一下如何使用指针变量:(程序8.2)

#include "iostream.h" 
int main() 
{ 
   int i=3; 
   int *iptr=&i; 
   int **iptrptr=&iptr;//iptr也是变量,也能够获取它的地址 
   cout <<"Address of Var i=" <<iptr <<endl;//输出iptr存储的内容,即i在内存中的地址 
   cout <<"Data of Var i=" <<*iptr <<endl;//输出iptr所指向的变量 
   cout <<"Address of Pointer iptr=" <<iptrptr <<endl;//输出iptr在内存中的地址 
   cout <<"Address of Var i=" <<*iptrptr <<endl;//输出iptrptr所指向的变量,即iptr 
   *iptr=2+*iptr;//*iptr可以作左值 
   cout <<"Data of Var i=" <<*iptr <<endl; 
   return 0; 
} 
运行结果:
Address of Var i=0x0012FF7C 
Data of Var i=3 
Address of Pointer iptr=0x0012FF78 
Address of Var i=0x0012FF7C 
Data of Var i=5 
通过运行结果,我们可以知道变量i在内存中的地址是0012FF7C(前面的0x表示这是一个十六进制的数);指针也是一种变量,在内存中也有地址;间接引用指针以后就和使用指针指向的变量一样方便。

试试看:
1、如果将程序8.2中的所有整型变量换成字符型变量(把对应的变量数据也换成字符),则执行后会有什么奇怪的现象?请根据第七章的知识,猜想产生这个奇怪现象的原因。
2、如果声明一个指针变量后不对其进行初始化,而是将其间接引用,作为左值修改它所指向的内存中的数据,会有什么结果产生?
结论:在没有保护措施的操作系统中,这样的操作可能会导致系统错误甚至崩溃。
3、能否将一个常量的地址赋值给一个对应类型的指针变量?
结论:将一个常量的地址赋给指针变量可能导致通过指针更改常量,所以是不合法的。

3.指针的操作和运算

既然指针是一种数据类型,那么它也应该有对应的操作或运算,正如整数能做加减乘除一样。但是每一种操作或运算都应该对这种数据类型有意义。比如两个实数可以用关系运算得知哪个大哪个小,而两个虚数却不能使用关系运算,因为比较虚数的大小是没有意义的。
对于指针类型来说,可以使用的运算有:和整数做加法运算、和整数做减法运算、两指针做关系运算。很显然,指针类型的乘除法是没有意义的,也是不允许的。

指针的加减运算

指针的加减法和数的加减法是不同的。我们认为,指针只能够和整数做加减法运算(包括和整型常量、变量做加减法和自增自减)。其实这也不难理解,内存的存储空间是按“个”计算的,不会出现半个存储空间的情况。那么,指针的加减法是否在地址值上做加减呢?我们先写一段程序来验证一下指针加减法的运算结果:(程序8.3)

#include "iostream.h"
int main() 
{ 
   int a[5]={1,2,3,4,5}; 
   int *aptr=&a[0];//把数组首元素的地址给指针 
   int i=1; 
   for (int j=0;j<5;j++) 
   { 
      cout <<'(' <<aptr <<")=" <<*aptr <<endl;//输出指针内存储的地址和该地址的数据 
      aptr=aptr+i;//指针和整型变量做加法 
   } 
   return 0; 
} 
运行结果:
(0x0012FF6C)=1 
(0x0012FF70)=2 
(0x0012FF74)=3 
(0x0012FF78)=4 
(0x0012FF7C)=5 
我们发现,每次做了加法以后,地址值并不是相差1,而是相差了4。所以指针和整数做加法并不是简单地将地址值和整数相加。我们又发现,每次做了加法以后,能够输出原先指针所指的下一个元素。根据数组在内存中的存储情况我们不难得出这样一个结论:指针和整数C的加减法是指针向前或向后移动C个对应类型的存储区域,即可以得到以下公式:
新地址=旧地址±C*每个对应数据类型的变量在内存中所占字节数
因为每个int变量在内存中所占字节数为4,所以在程序8.3.1中每做完一次加法,新地址=旧地址+1*4=旧地址+4。如右上图8.3所示。

指针的关系运算

在第四章中,我们知道关系运算有等于、大于、小于、大于等于、小于等于和不等于六种。对于指针来说,等于和不等于就是判断两个指针的值是否相同或不同,即两个指针是否指向了相同或不同的地方。而大于和小于是判断指针的值哪个大哪个小。值较小的在存储器中的位置比较靠前,值较大的在存储器中的位置比较靠后。
指针的关系运算在数据结构中会经常用到。我们在下一章介绍链表的时候会着重介绍它的用法。

4.指针与保护

在第二节,我们说到了,如果没有对指针进行初始化就改变它所指向的内存里的数据是非常危险的。因为不确定的指针有可能指向了一个系统需要的关键数据,数据一旦被更改或破坏系统就会崩溃。在我们使用电脑磁盘的时候,都知道有一种措施叫做“写保护”。(或者称为“只读”,即只可以读,不可以写)那么,我们能否给指针加上写保护呢?

对内存只读的指针

为了解决指针可能误修改或破坏内存中的数据,我们可以对内存中的数据加上写保护。即具有这种属性的指针只能够读出内存中的数据,却不能修改内存中的数据。我们把具有这种属性的指针称为指向常量的指针,简称常量指针。
给内存中的数据加写保护的方法是在声明指针时,在指针类型(即各种数据类型)前加上const,表示这些数据是常量,只能读不能写。比如:const int *iptr;,这时候我们只能够通过指针iptr读出内存里的数据,但是不能对其写入、修改。
当然,这样的保护措施只是防止通过间接引用iptr修改内存中的数据,并不保护iptr本身和iptr所指向的变量。

指针型常量

我们说,指针同整型,字符型一样,是一种数据类型。整型可以有整型常量和整型变量;字符型可以有字符型常量和字符型变量。那么,指针也应该有指针常量。指针常量和常量指针不同,指针常量是指正所指向的位置不能改变,即指针本身是一个常量。但是指针常量可以通过间接引用修改内存中的数据。定义指针常量的语句格式为:
指针类型 * const 指针常量名=&变量名;
下面我们来看一段程序,实践一下指针常量和常量指针:(程序8.4)

#include "iostream.h" 
int main() 
{ 
   int a=42; 
   const int b=84; 
   const int *captr=&a;//常量指针 
   int * const acptr=&a;//指针常量 
   int *bptr=&b;//错误,不能把常量的地址给指针变量 
   const int *cbprt=&b;//把常量的地址给常量指针是允许的 
   *captr=68;//错误,间接引用常量指针不可修改内存中的数据 
   *acptr=68;//间接引用指针常量可以修改内存中的数据 
   captr=&b;//常量指针可以指向其他变量 
   acptr=&b;//错误,指针常量不能指向别的变量 
   const int * const ccaptr=&a;//常量指针常量,既不能间接引用修改数据,也不能指向别的变量或常量 
   *ccaptr=68;//错误,不能间接引用修改数据 
   ccaptr=&b;//错误,不能指向别的常量或变量 
   return 0; 
} 
以上程序存在错误,无法通过编译。我们考虑到使用指针的安全性的时候,就能想到要使用以上这些保护措施保护内存中的数据。

5.指针与数组

我们在上一章说到,在向函数传递数组参数的时候,实质上是传递数组首元素的地址。那么数组和指针有着什么样的关系吗?

数组名的实质

数组名并不是一个普通的变量,而是一个指向数组首元素的指针。也就是说,我们可以用数组名来初始化一个对应类型的指针。既然如此,经过初始化的指针能否代替原来的数组名呢?答案是肯定的。
下面我们来看一段程序,了解数组名和指针的用法:(程序8.5)

#include "iostream.h" 
int main() 
{ 
   int a[6]={5,3,4,1,2,6}; 
   int *aptr=a; 
   for (int i=0;i<6;i++) 
   { 
      cout <<a[i] <<aptr[i] <<*(aptr+i) <<*(a+i) <<endl; 
   } 
   return 0; 
} 
运行结果:
5555 
3333 
4444 
1111 
2222 
6666 
根据上面的这段程序,可以知道a[i]、aptr[i]、*(aptr+i)、*(a+i)都能够访问到数组的元素。所以,我们说上述四者是等价的。虽然数组名是指针,但它是一个指针常量。也就是说,不带下标的数组名不能作为左值。

指针数组

指针是一种数据类型,所以,我们可以用指针类型来创建一个数组。声明一个指针数组的语句格式是:
指针类型 * 数组名[常量表达式];
对指针数组的操作和对指针变量的操作并无不同,在此不作赘述。

试试看:
如果有两个大小相同的数组a和b,我们想把数组b的元素都一一复制到数组a中,通过一句a=b;语句是否可以实现?试用本节的知识解释这一现象。
结论:数组名a和b都是指向数组首元素的指针,所以a=b;并不能把数组b的数据复制给数组a。

6.指针与函数

指针在函数中的使用也是十分广泛的。某些情况下,将指针作为函数的参数或函数的返回值会给我们带来方便。而某些情况下,我们又不得不将指针作为函数的参数或函数的返回值。

指针作为参数

我们在上一章我们已经了解向函数传递数组的实质是向函数传递数组首元素的地址。我们又知道数组名是一个指向数组首元素的指针常量。所以我们认为,向函数传递数组是将指针作为参数的特殊形式。
由于指针可以直接操作内存中的数据,所以它可以用来修改实参。这个功能和引用是类似的。
下面我们来看一段程序,了解指针作为参数时的上述两个特点:(程序8.6.1)

#include "iostream.h" 
void arrayCopy(int *src,int *dest,int size);//复制数组元素 
void display(const int *array,int size);//输出数组元素 
int main() 
{ 
   int a[]={3,4,5,6,3,1,6}; 
   int b[7]; 
   arrayCopy(a,b,sizeof(a)/sizeof(int));//把数组a的元素依次复制到数组b中 
   cout <<"The data of array a is:"; 
   display(a,sizeof(a)/sizeof(int)); 
   cout <<"The data of array b is:"; 
   display(b,sizeof(b)/sizeof(int)); 
   return 0; 
} 
void arrayCopy(int *src,int *dest,int size) 
{ 
   for (int i=0;i<size;i++) 
   { 
      dest[i]=src[i];//修改了实参数组元素 
   } 
   cout <<size <<" data Copied." <<endl; 
} 
void display(const int *array,int size)//const用来保护指针指向的数据 
{ 
   for (int i=0;i<size;i++) 
   { 
      cout <<array[i] <<" "; 
   } 
   cout <<endl; 
} 
运行结果:
7 data Copied. 
The data of array a is:3 4 5 6 3 1 6 
The data of array b is:3 4 5 6 3 1 6 
根据arrayCopy函数,不难看出传递数组和传递指针是完全相同的。而通过指针的间接引用或数组操作,我们可以在函数内实现对实参的修改。这就是arrayCopy函数能够实现复制功能的原因。
不过,将指针作为函数参数的副作用仍然不容我们忽视。指针和引用虽然都能够修改实参,但是指针却更加危险。因为引用仅限于修改某一个确定的实参,而指针却可以指向内存中的任何一个数据,通过间接引用就能够在一个函数内修改函数外甚至系统中的数据了。这样一来,函数的黑盒特性就被破坏了,系统也因此变得不再安全。对于程序员来说,将指针作为函数参数可能把函数内的问题引到函数外面去,使得调试程序变得非常困难。所以,我们要认识到使用指针的两面性,谨慎对待指针做函数参数。
为了避免指针作为函数参数导致数据被意外修改,我们可以使用const来保护指针指向的数据,如程序8.6.1中的display函数。

指针作为返回值

和别的数据类型一样,指针也能够作为函数的一种返回值类型。我们把返回指针的函数称为指针函数。在某些情况下,函数返回指针可以给我们设计程序带来方便。而且此时通过间接引用,函数的返回值还可以作为左值。
下面我们来看一段程序,了解函数如何返回指针:(程序8.6.2)

#include "iostream.h" 
int * max(int *array,int size);//返回值类型是int *,即整型指针 
int main() 
{ 
   int array[]={5,3,6,7,2,1,9,10}; 
   cout <<"The Max Number is " <<*max(array,sizeof(array)/sizeof(int)) <<endl;//间接引用返回的指针
   return 0; 
} 
int * max(int *array,int size)//寻找最大值 
{ 
   int *max=array; 
   for (int i=0;i<size;i++) 
   { 
      if (array[i]>*max) 
      max=&array[i];//记录最大值的地址 
   } 
   return max; 
} 
运行结果:
The Max Number is 10 
需要注意的是,返回的指针所指向的数据不能够是函数内声明的变量。道理很简单,我们在第六章已经说明,一个函数一旦运行结束,在函数内声明的变量就会消失。就好像下课同学们都走了,教室里的某一个座位到底有没有坐着谁我们无法确定。所以指针函数必须返回一个函数结束运行后仍然有效的地址值。

7.堆内存空间

家里要来客人了,我们要给客人们泡茶。如果规定只能在确定来几位客人之前就把茶泡好,这就会显得很尴尬:茶泡多了会造成浪费,泡少了怕怠慢了客人。所以,最好的方法就是等知道了来几位客人再泡茶,来几位客人就泡几杯茶。
然而,我们在使用数组的时候也会面临这种尴尬:数组的存储空间必须在程序运行前申请,即数组的大小在编译前必须是已知的常量表达式。空间申请得太大会造成浪费,空间申请得太小会造成数据溢出而使得程序异常。所以,为了解决这个问题,我们需要能够在程序运行时根据实际情况申请内存空间。
在C++中,允许我们在程序运行时根据自己的需要申请一定的内存空间,我们把它称为堆内存(Heap)空间

如何获得堆内存空间

我们用操作符new来申请堆内存空间,其语法格式为:
new 数据类型[表达式];
其中,表达式可以是一个整型正常量,也可以是一个有确定值的整型正变量,其作用类似声明数组时的元素个数,所以两旁的中括号不可省略。如果我们只申请一个变量的空间,则该表达式可以被省略,即写作:
new 数据类型;
使用new操作符后,会返回一个对应数据类型的指针,该指针指向了空间的首元素。所以,我们在使用new操作符之前需要声明一个对应类型的指针,来接受它的返回值。如下面程序段:

int *iptr;//声明一个指针 
int size;//声明整型变量,用于输入申请空间的大小 
cin >>size;//输入一个正整数 
iptr=new int[size];//申请堆内存空间,接受new的返回值 
我们又知道,数组名和指向数组首元素的指针是等价的。所以,对于iptr我们可以认为是一个整型数组。于是,我们实现了在程序运行时,根据实际情况来申请内存空间。

释放内存

当一个程序运行完毕之后,它所使用的数据就不再需要。由于内存是有限的,所以它原来占据的内存空间也应该释放给别的程序使用。对于普通变量和数组,在程序结束运行以后,系统会自动将它们的空间回收。然而对于我们自己分配的堆内存空间,大多数系统都不会将它们回收。如果我们不人为地对它们进行回收,只“借”不“还”,那么系统资源就会枯竭,电脑的运行速度就会越来越慢,直至整个系统崩溃。我们把这种只申请空间不释放空间的情况称为内存泄露(Memory Leak)
确认申请的堆内存空间不再使用后,我们用delete操作符来释放堆内存空间,其语法格式为:
delete [] 指向堆内存首元素的指针;
如果申请的是一个堆内存变量,则delete后的[]可以省略;如果申请的是一个堆内存数组,则该[]不能省略,否则还是会出现内存泄露。另外,我们也不难发现,delete后的指针就是通过new获得的指针,如果该指针的数据被修改或丢失,也可能造成内存泄露。
下面我们来看一段程序,实践堆内存的申请和回收:(程序8.7)

#include "iostream.h" 
int main() 
{ 
   int size; 
   float sum=0; 
   int *heapArray; 
   cout <<"请输入元素个数:"; 
   cin >>size; 
   heapArray=new int[size]; 
   cout <<"请输入各元素:" <<endl; 
   for (int i=0;i<size;i++) 
   { 
      cin >>heapArray[i]; 
      sum=sum+heapArray[i]; 
   } 
   cout <<"这些数的平均值为" <<sum/size <<endl; 
   delete [] heapArray; 
   return 0; 
} 
运行结果:
请输入元素个数:5 
请输入各元素: 
1 3 4 6 8 
这些数的平均值为4.4 
可见,申请的堆内存数组在使用上和一般的数组并无差异。我们需要记住的是,申请了资源用完了就一定要释放,这是程序员的好习惯,也是一种责任。
那么,我们能不能来申请一个二维的堆内存数组呢?事实上,new 数据类型[表达式][表达式]的写法是不允许的。所以,如果有需要,最简单的方法就是用一个一维数组来代替一个二维数组。这就是上一章最后一小段文字的意义所在。




版权声明:本文为博主原创文章,未经博主允许不得转载。

相关文章推荐

【C++探索之旅】第一部分第十二课:指针一出,谁与争锋

现在,终于来到第一部分的最后一个知识点了,也是C++的基础部分的最后一个讲题。之后进入第二部分,就会开始面向对象之旅。因此,这一课也注定不平凡。系好安全带吧,因为马力要加足了! 指针这个C系语言的...

C++深入体验之旅八:枚举类型和结构体

1.什么是枚举类型 在基本的数据类型中,无外乎就是些数字和字符。但是某些事物是较难用数字和字符来准确地表示的。比如一周有七天,分别是Sunday、Monday、Tuesday、Wednesday...

C++深入体验之旅九:程序调试

1.头文件的奥秘 如何创建一个头文件 在第二章中,我们看到一个C++的工程里面除了源文件还有头文件。根据以下步骤便能创建一个头文件: 首先要创建或打开一个工程,然后按File菜单中的new...

C++深入体验之旅三:分支结构

1.if语句 对于可能发生的事情,我们平时经常会说“如果……,那么……”。语文里,我们叫它条件复句。“如果”之后的内容我们称为条件,当条件满足时,就会发生“那么”之后的事件。我们来看这样一句英语:If...

C++深入体验之旅二:变量和数据

1.C++变量简介   1、什么是变量? 电脑具有存储的功能。我们可以通过Word打开一个保存的文章,也可以通过FPE(整人专家,一款游戏修改软件)来查看或锁定内存中保存的游戏人物的生命值。那么,一...

C++深入体验之旅六:数组

1.数组的声明和初始化 我们知道,在程序设计中,大多数数据都是存放在变量里的。如果我们要处理较多的数据,增加存放数据的空间最简单的方法就是多开设一些变量。然而,变量多了就难以管理了。这就好像一个...

C++深入体验之旅十一:类(上)

1.类是一种数据类型 我们已经了解了数据类型和变量的关系。数据类型是各个变量的归类,而变量则是某个数据类型的具体表现,我们称变量为数据类型的一个实例(Instance)。各个变量都有他们的属性:...

C++深入体验之旅十二:类(下)

12.友元类 在编写链表类的时候我们有着这样的困惑:链表类和链表结点类都是我们编写的,我们能保证链表类对链表结点类的操作都是安全的。但由于类的封装性,我们不得不编写一些成员函数,以便于链表类访问链表...

C++深入体验之旅四:循环结构

1.for循环语句 大家看过赛车的话都知道,赛车就是围绕着一个固定的跑道跑一定数量的圈数,如果没有发生意外,那么跑完了指定数量的圈数,比赛就结束了。 我们来设想一下赛车的实际情况,当比赛开始,赛车越...

C++深入体验之旅五:C++函数

1.什么是函数   在日常生活中,我们经常会用到工具。比如我们画一个表格需要用到直尺,它可以告诉我们线段的长度;我们开瓶子的时候需要用开瓶器,它可以把瓶子打开;我们在做计算题的时候需要用到计算器,它...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:C++深入体验之旅七:指针
举报原因:
原因补充:

(最多只允许输入30个字)