整理自计蒜客-CS 112: C++ 程序设计
指针是什么
指针是一个变量,其储存的是值的地址,而不是值本身。指针提供了另一种访问内存空间的方法:虽然我们不知道变量的名称,但我们可以通过变量存放的地址访问它。
指针的初始化
- 可以直接初始化:
//<数据类型> *指针变量名 = 赋值;
//指针的数据类型,表示指针所指向的数据的数据类型
int a = 1;
int array[] = { 1,2,3,4,5 };
int *p1 = &a;
int *p2 = array; 或者 int *p2 = &array[0]
如果int *p2 = &array;
将不能通过编译,原因是数据类型不匹配。
- 也可以先定义,再赋值:
//对象指针:
Node node1;
Node *p3 = &node1;
//函数指针:
int add(int x,int y);
int (*p3)(int,int);
p3 = add;
指针的运算
- 算数运算:
指针可以跟 整数 进行加法和减法的运算,但是运算规则比较特殊——对指针进行加减运算的结果,与指针本身的类型密切相关。可以看出,指针+1
移动了一种数据类型在内存中存放的字节数。不过,空指针和函数指针是不能进行算数运算的(无法通过编译),因为无法确定移动多少个字节。
//整数指针:
int *p1 = &a;
cout << p1 << " " << p1+1 <
输出结果:
0x7ffe597f872c 0x7ffe597f8730 people eople 0x7ffe597f8760 0x7ffe597f8761 0x7ffe597f8750 0x7ffe597f8760 0x4009ed 1
1)以上使用cout
输出字符指针
的时候结果跟想象中不太一样?
答:这是因为cout
对象认为char
的地址是字符串的地址,因此它打印该地址处的字符,然后继续打印后面的字符,直到遇到空字符(\0)
为止。
2)为什么用cout
输出函数指针
会得到1
呢?
答:这里先挖个坑...
- 关系运算&逻辑运算
指针变量的关系运算,指的是 指向相同类型数据的指针之间,进行的关系运算。 如果两个相同类型的指针相等,就表示两个指针指向的是同一个地址——不同类型的指针之间,或者指针与非 0 整数之间的比较是没有意义的。
但是有一种情况是特殊的——指针可以跟 整数 0 之间进行比较,0专门用于表示空指针,即指针变量中保存的地址是空的,不指向任何有效的地址。
关系运算的结果经常用于逻辑运算。
int a = 9,b = 7;
int *p[4] = {&a,&a,&b,NULL};
//判断前后指针是否相等
cout << "equal?" <
输出结果:
equal? YES NO NO YES NULL? NO NO NO YES
值得注意的是,越界的指针数组元素p[4]
是一个空指针。
空指针
为什么我们需要空指针呢?因为有的时候,我们在声明一个指针的时候,并没有一个确定的地址值可以赋给它,当程序运行到某个时刻的时候,才会将某个地址赋值给这个指针。这样,在指针定义但没有使用的这段时间里,它的值是不确定的——要是误用了这个不确定的指针的话,就很有可能会造成不可预见的错误(比如意外地把某个不该变更的值给改掉了),因此在这种情况下,我们首先应该将地址设置为空。
除了给指针赋值0
或NULL
使其为空,在C++11标准中,我们还可以使用nullptr
关键字来表示空指针,用法跟NULL基本相同(需要引用命名空间std
中的对应标识符)。
指针与数组
数组的本质,实际上是一串连续的相同大小的内存空间——比如说,对于整形数组int a[10]
,它在内存中就是连续排列的十个可以容纳一个整形变量的内存空间。而数组的名称,其实就是一个常量指针,即不能被赋值的指针。作为一个指针,数组名指向的是数组的第一个元素。
指针加减运算的特点,使得它可以特别被用于处理存储在一段连续内存空间中的同类数据。而数组正好是具有一定顺序关系的,若干同类型变量的集合体——数组元素的存储,在物理上与逻辑上都是连续的,数组名就是变量的首地址。如果有数组array[5]
,那么array
和&array[0]
是相同的。
- 要访问数组元素,下面两种方法是等效的:
int *p = array;
cout << array[10] <
- 此外,如果我们要把数组作为函数的形参的话,那么它实际上是等价于把指向数组元素类型的指针作为形参的——例如,下面三个写法,出现在形参列表中就是等价的:
void f(int p[]);
void f(int p[3]);
void f(int *p);
指针数组
如果一个数组的所有元素都是指针变量,那么这就是一个指针数组。指针数组的每一个元素都必须是同一类型的指针。指针数组有一个神奇的应用:
//创建一个指针数组,其元素分别指向三个数组
int line1[]={1,0,0};
int line2[]={0,1,0};
int line3[]={0,0,1};
int *pLine[3]={line1,line2,line3};
//用类似二维数组的形式访问三个数组
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
cout << pLine[i][j] << “ ”;
}
}
输出结果:
1 0 0 0 1 0 0 0 1
上个例子中的pLine在使用上跟一个二维数组没有区别,但是在存储方式上,它跟真正的二维数组并不相同:
二维数组在内存中,是以行优先的方式按照一维顺序关系存放的。因此,对于二维数组,可以将其理解成一个一维数组的一维数组,其首地址为数组名,元素个数就是行数——而它的每一个元素,就是一个一维数组。
然而,对于指针数组pLine,它的三个“元素数组”在内存中,并不是连续存放的——访问line2或者line3的时候,首先要在pLine中找出对应的元素指针,即为指向line2或者line3头元素的地址,然后再通过指针跳转到要访问的数组。
对象指针
跟基本类型的变量一样,每一个对象在初始化之后,都会在内存中占据一定的空间——所以我们同样也可以通过地址来访问一个对象。尽管对象同时包含了数据和函数两种成员,但是对象所占据的内存空间只用于存放数据成员——函数成员并不在每一个对象的存储副本之中。对象指针就是用于存放对象地址的变量——对象指针遵循一般变量指针的各种规则。
- 通过对象名,我们可以访问对象成员——同样,通过对象指针,我们可以访问对象的成员,以下三种方法完全等价:
//假设已有Line类
cout << line1.getLength() <getLength() <
-
this
指针
对于类的成员函数来说,我们可以直接在函数体内访问成员变量——例如,如果对象Line
有一个成员变量length
的话,那么我们就可以直接在成员函数内访问这个成员:
int getLength(){return length;}
而实际上,C++ 为每一个类的非静态成员函数(就是没有static
关键字的成员函数),都提供了一个隐含的指针this
,当我们写下return length;
的时候,编译器执行的实际上是return this->length;
。
this
指针明确地指出了函数当前所操作的数据所属的对象——它是成员函数隐藏的一个形参,当我们在成员函数中操作对象的数据成员的时候,我们其实就是在使用this
指针。
然在一般情况下,我们不需要特别把this
指针写出来——但是如果函数的形参列表中的参数跟成员变量重名的话,那么由于标识符作用域覆盖,我们将没法直接通过成员变量名来访问它。当然我们也可以选择更改形参名——但是更好的办法是通过this
指针来访问成员变量,这样我们可以让代码拥有更好的可读性:
void setLength(int length){
this->length=length;
}
另一种解决方法是使用初始化列表:
void setLength(int length):length(length){
}
函数指针
以上我们使用的指针都是指向数据的——而在程序运行的时候,不仅数据要占据内存空间,执行程序的代码也会被存入到内存,并占据一定的空间。每一个函数都有函数名,而实际上这个函数名就表示函数的代码在内存中的起始地址。在程序中可以像使用函数名一样,使用指向函数的指针来调用函数——也就是说,一旦函数指针指向了某个函数,那么它与函数名就具有同样的作用。
函数名在表示函数代码起始地址的同事,也包括函数的返回值类型,以及参数的个数、类型、排列次序等信息。因此,在通过函数名调用函数的时候,编译器就可以自动检查实参与形参是否相符,用函数的返回值参与其他运算时,能够自动进行类型一致性检查。而函数指针也具有同样的效果。
- 声明:
声明一个函数指针时,需要提供构造一个函数需要的所有信息——包括函数的返回值和形式参数列表,如下所示:
返回值类型 (* 函数指针名)(形参表)
由于对函数指针的定义在形式上比较复杂,如果在程序中出现多个这样的定义,那么多次重复这样的定义会相当繁琐。这里我们有一种很方便的解决方案——使用typedef
。例如:
typedef int (* DoubleIntFunction)(double);
这里我们声明了DoubleIntFunction
为“有一个double
形参,返回类型为int
的函数的指针”的类型的别名——接下来,如果我们需要声明这个类型的变量的时候,我们就可以直接进行使用:
DoubleIntFunction funcPtr;
这样我们就可以直接使用这个类型的指针funcPtr
了。
- 赋值:
函数指针名=函数名;
注意这里的“函数名”必须是一个已经声明过的函数,并且必须具有跟函数指针相同返回类型跟相同参数表的函数。赋值之后你就可以像使用函数一样,使用函数指针了。
- 使用:
int add(int x,int y) {
return x+y;
}
int (*func_ptr)(int,int);
func_ptr = add;
//以下二者完全等价
cout << func_ptr(2,3) <
- C++ 11 提供的
lambda 表达式
,它可以替代函数指针的作用。
动态内存分配
动态内存解决了诸如“用户输入XX个数据,那么我应该开多大的数组?”之类的只能在程序运行时才能确定的问题。那跟指针有什么关系呢?这是因为我们申请的动态内存时,返回的就是指向这个这个动态内存首地址的指针。
在 C++ 中,动态内存分配可以保证程序在运行的过程中,可以按照实际需要申请适量的内存,等到使用结束之后我们还可以将其释放——这种在程序运行的过程中申请和释放的存储单元也称为堆对象,而动态内存分配所调用的内存空间则称为堆内存。建立和删除堆对象使用以下两个运算符:new
和delete
。
-
new
的功能是动态分配内存,其语法形式如下所示:
new 数据类型(初始化参数列表);
以上语句的作用是在程序运行的过程中,申请分配用于存放指定类型数据的内存空间,然后根据参数列表中给出的值来进行初始化。如果内存申请成功,那么new
运算符就会返回一个指向新分配内存区域首地址的指针——我们可以通过这个指针来访问堆对象。
- 例如我们申请一个
int
类型的内存空间:
int *point;
point=new int(2);
以上,系统动态分配了用于存放int
类型数据的内存空间,然后用初始值2
赋值,得到的地址返回给point
指针变量。我们也可以这么写,但注意区别:
int *point = new int;//没有初值
int *point = new int();//初始值为0
- 还可以解决“开多大数组?”的问题:
cin >> n;
int *a=new int[n];
其中,方括号内的表达式表示数组长度,它可以是任何能够得到正整数值的式子。
- 除了数组类型跟基本类型之外,
new
运算符还可以建立一个类的实例对象:
//假设已有类Node
Node *node_ptr;
node_ptr = new Node();
如果要建立一个对象的话,那么这里的“参数列表”就要跟对象所属类的构造函数一一对应:如果不写括号或者括号里为空的话,那么就会调用类的默认构造函数;而如果写了对应的参数的话就会调用类所具有的对应的构造函数。
-
delete
的作用是删除一个用new
建立的对象,回收其申请的内存。
所有用new
分配的内存,都必须使用delete
进行回收,否则会导致动态分配的内存无法回收,造成内存泄露!另外,delete
和new
是一一对应的,不能delete
一个不是用new
建立的对象,否则会出现“段错误”之类的问题。
使用方法比较简单——如果你觉得一个堆对象已经不再被需要,那么你直接将其删除即可,如下所示:
delete node_ptr;//对于基本类型或者对象的指针
delete[] array_ptr;//对于指向数组的指针
注意如果要删除的是一个数组的话,那么后面的那对方括号不可省略。