作为C++语言的亮点与精髓,指针一直是备受人们追捧和病诟的东西。不知道有多少精巧的代码是通过它实现的,也不知道有多少难缠的bug是由它引发的,这篇文章先对指针做一个全局的总结,从声明、赋值、调用和实现机制上对所有指针做一个说明,希望对大家有所帮助。在后续的文章中,我将给出那些使用指针的技巧。
首先是一个列表,列出我们今天讨论的内容:
- 指向基本数据类型的指针
- 指向指针的指针
- 指向数组的指针
- 指向函数的指针
- 指向结构体和对象的指针
- 指向类成员变量的指针
- 指向类成员函数的指针
1. 指向基本数据类型的指针
| 定义 | 示例 |
声明 | [数据类型] * [指针名]; | int* pa; |
赋值 | [指针名] = &[变量]; | int a; pa = &a; |
取值 | *[指针名] | int b = *pa; |
实现机制:
对于指向基本数据类型的指针,它包含两个信息:数据所在的地址、取数据时的长度(类型)。
其中地址信息存放在指针pa中,如果是一个32-bit的计算机,pa的长度应为4 bytes,即地址的长度是32位(unsigned int)。那么怎样获取一个变量的地址呢?
13: int* pa = &a;
00411A5A lea eax,[a]
00411A5D mov dword ptr [pa],eax
可以使用取地址运算实现,即lea:Load Effective Address。
不过这不是唯一一种对基本数据类型指针的赋值方式,其他方式还有:
(1) 强制把其他类型的指针转换为该类型的指针:
double *px = &x;
pa = (int *) px;
(2) 直接使用常数或者变量值对指针赋值:
pa = (int *) 0;
我就经常把指向对象的指针强制转换为unsigned char *以便研究对象内部结构,不过强制转换面临的最大问题是,如果指针指向了不可访问的内存空间,取值的时候就会引发异常,相信大家都见过这个:
它是怎么引发的呢?
我们先来看一下取值的原理:
14: int b = *pa;
00412250 mov eax,dword ptr [pa]
00412253 mov ecx,dword ptr [eax]
00412255 mov dword ptr [b],ecx
现获取pa的值,放到eax中,然后再把eax当做地址,取出4个字节(int型)的值。“取出多少字节的数据”就是指针所表示的第二个信息(指针的类型),这个信息由编译器自动转换为目标代码(即dword ptr, word ptr等)。
(注:dword ptr [pa]表示从pa开始,取出4个byte的数据,组成一个32位的整数。在asm32中,访问修饰符byte表示1个字节,word:2个字节,dword:4个字节,qword:8个字节。而我们学过一个字(word)表示一台计算机一次处理的数据长度,按理说word应该是4个字节,这里却是两个,原因是微软为了兼容它16位程序代码而导致的,同样,在windows API中,我们看到的DWORD也被定义为16位,即typedef short DWORD)
图中的访问错误异常就产生在mov ecx, dword ptr[eax]这一句,如果eax里面放的地址,映射到不存在、不可读或者没有权限访问的页面,就会产生CPU内部错误,由于我们的程序没有捕获错误,所以把错误抛给了windows,windows就弹出了一个很难看的对话框,然后终止进程的执行。
这里还要说一下指针的运算,如果把指针加1,表示地址向后移动了指针类型那么长的字节数,也就是说,pa+1表示pa的地址加4个字节,可使用下面的伪代码表示:
pa + 1 == (int *)((char *)pa + sizeof(a))
指针不单可以做加减运算(不能做乘除),还可以做[]运算,pa[i]表示*(pa + i),但这并不能表示指针和数组是同一类型。
如果用指针做乘法,则会产生这样的编译错误:
error C2296: '*' : illegal, left operand has type 'int *'
2. 指向指针的指针
指向指针的指针,是存储指针地址的变量,而且每次都取出4个byte的数据(因为地址是统一的)。定义方式和原理与指向基本数据变量的指针一模一样,这里再写一下:
| 定义 | 示例 |
声明 | [数据类型] *[*…] [指针名]; | int** ppa; |
赋值 | [指针名] = &[指针]; | ppa = &pa; |
取值 | *[*…][指针名] | int b = **pa; |
指向指针的指针没有什么特别的地方,把指针看做基本数据类型来对待就行了。
[int*] * ppa = &pa;
[数据类型] * [指针名] = &[变量名];
3. 指向数组的指针
还记得前文说过数组和指针根本不同吗,要理解指向数组的指针,我们先来看什么是数组。
指针是一个存放地址的变量,而数组是一个地址。
或者说,数组是一个指针常量,数组只能指向确定的数据。数组声明时一定要赋值,这是为了它肯定能指向一个有用的地址空间(数组变量一旦确定了地址,就不能变了,因为编译器已经把它定死了(或者说把它替换为一个常量了))。
| 定义 | 示例 |
声明和赋值 | [数据类型] [数组名][数组长度]; [数据类型] [数组名][]; | int arr_a[10]; int arr_b[] = {1,2,3}; |
取值 | [数组名][索引号] | int a = arr_a[1]; |
当使用int arr_a[10]声明数组时,会发生两种情况:如果arr_a在函数中申请,那么编译器就会自动计算这个函数所使用的栈空间,然后把arr_a安插到栈中,转换为ebp-常数;而如果arr_a在全局数据区申请(函数外),那么编译器就在未初始化数据段(.bss = Block Started by Symbol)中申请一块可以放10个int的符号,然后将所有使用arr_a的地方,都转换为那个地址。
局部数组:
24: int arr_c[3];
26: int d = arr_c[1];
00412F47 mov eax,dword ptr [ebp-2Ch]
00412F4A mov dword ptr [ebp-38h],eax
全局数组:
9: int arr_d[4];
27: int e = arr_d[1];
00412F4D mov eax,dword ptr [arr_d+4 (42E2ACh)]
00412F52 mov dword ptr [ebp-3Ch],eax
而使用int arr_b[] = {1,2,3}这种形式,则更为麻烦一点,因为除了给数组分配空间外,还要赋初值。如果声明在函数中,那么在声明这条语句的代码块里,编译器会写入赋值语句,来初始化这个数组;如果声明在函数外,编译器就会把它放入到数据段中(.data),在程序加载时,由loader赋值。
局部数组:
25: int arr_a[] = {1,2};
00412F36 mov dword ptr [ebp-24h],1
00412F3D mov dword ptr [ebp-20h],2
当然,如果使用static int arr_c[10];也可以将数组放入数据段中,并且还能将初值设为全0。
另外一种将数组元素设为全0的方法是int arr_a[10] = {0},不过这种形式只是int arr_a[10] = {0, 0, …, 0}的简化版罢了,如果在给数组赋初值时,大括号里面的元素不足数组的长度,就用0替代。即int arr_a[10] = {1, 2}会被转换为int arr_a[10] = {1, 2, 0, 0,…, 0}。
为什么初学者会认为数组就是指针呢,大概是数组类型变量可以直接赋值给指针类型变量吧,即:
int* pArr_a = arr_a;
但这只是一种赋值,就像int a = 1,反过来就不行了(1 = a),就连使用强制转换都不行。
// x:\工程\deepintocpp\objectmodel\04_data\pointer.cpp(35) : error C2440: 'initializing' : cannot convert from 'int *' to 'int []'
// There are no conversions to array types, although there are conversions to references or pointers to arrays
/*35:*/ int arr_p[] = pArr_a;
这里还要说的是,数组也不能赋值给数组(就像不能写1=1一样,常量是不能赋值给常量的):
int arr_f[10];
// wrong:
// x:\工程\deepintocpp\objectmodel\04_data\pointer.cpp(39) : error C2440: 'initializing' : cannot convert from 'int [10]' to 'int []'
// There is no context in which this conversion is possible
int arr_g[] = arr_f;
// wrong:
// x:\工程\deepintocpp\objectmodel\04_data\pointer.cpp(41) : error C2075: 'arr_i' : array initialization needs curly braces
int arr_i[10] = arr_f;
但是有一个例外:在传递函数参数时,可以使用int []表示一个数组。
void func(int arr[])
{
return;
}
// correct.
func(arr_a);
// also correct.
func(pArr_a);
但是这种数组其实就是指针,只是给人看上去好看一点,对于编译器来说,它和指针是一样的:
// passing array.
13: void func(int arr[])
14: {
00412540 push ebp
00412541 mov ebp,esp
00412543 sub esp,40h
00412546 push ebx
00412547 push esi
00412548 push edi
15: arr[0] = 1;
00412549 mov eax,dword ptr [arr]
0041254C mov dword ptr [eax],1
16: return;
17: }
// passing pointer.
19: void func_p(int* pArr)
20: {
00412560 push ebp
00412561 mov ebp,esp
00412563 sub esp,40h
00412566 push ebx
00412567 push esi
00412568 push edi
21: pArr[0] = 1;
00412569 mov eax,dword ptr [pArr]
0041256C mov dword ptr [eax],1
22: return;
23: }
指针和数组的另一个相同之处是,指针+1表示指针所指向的地址+sizeof(指针的类型),而数组+1同样表示表示数组首地址+数组元素类型长度:
printf("arr_a size = %d, arr_a = 0x%08x, arr_a + 1 = 0x%08x\n", sizeof(arr_a), arr_a, arr_a + 1);
printf("pArr_a size = %d, pArr_a = 0x%08x, pArr_a + 1 = 0x%08x\n", sizeof(pArr_a), pArr_a, pArr_a + 1);
// output:
// arr_a size = 40, arr_a = 0x0012ff34, arr_a + 1 = 0x0012ff38
// pArr_a size = 4, pArr_a = 0x0012ff34, pArr_a + 1 = 0x0012ff38
arr_a的长度是10个int。因此可以用sizeof(arr_a)/sizeof(arr_a[0])来计算一个数组的长度(当然这个长度本来就是编译时已知的)。
弄清了数组和指针的关系,我们来看什么是指向数组的指针:
内部存放数组首地址的变量。
对于定义来说,比较容易理解,可是形式就比较难记了:
| 定义 | 示例 |
声明 | [数据类型] (* [指针名])[数组长度]; | int (*pArr)[10]; |
赋值 | [指针名] = &[数组名]; | pArr = &arr; |
取值 | (*[指针名])[下标] | int b = (*pa)[1]; |
由于operator[]的优先级高于*,因此要在指针名和*之上加一个括号,表示声明的是指针。若不加,则表示的是存放指针的数组。
// pointer, point to an array which has 10 int elements.
int (*pArr)[10] = &arr_a;
// array, stores 10 pointers.
int *arrP[10];
arrP[0] = pa;
现在的问题是:pArr+1表示的是什么?
printf("sizeof(pArr) = %d, sizeof(*pArr) = %d, pArr = 0x%08x, pArr + 1 = 0x%08x\n", sizeof(pArr), sizeof(*pArr), pArr, pArr + 1);
// output:
// sizeof(pArr) = 4, sizeof(*pArr) = 40, pArr = 0x0012ff34, pArr + 1 = 0x0012ff5c
从代码中,可以看出:
首先pArr是一个指针,因此它的长度是4个字节,而*pArr是一个有10个int元素的数组, 因此它的长度是40。使用第一节的指针+1公式:
pArr + 1 == (int [10])((char *)pArr + sizeof(int [10])) == pArr的地址 + pArr指向的数组长度 == pArr指向的地址+40个字节
由此可知,pArr+1表示下一个数组。
由于*pArr取出的是数组,那么就要使用一个可以指向数组的变量来存放它的值了。int []表示常量,肯定不能作为左值,因此要想取出pArr指向的内容,只能使用指针或者operator[]了:
61: int (*pArr)[10] = &arr_a;
00413CE1 lea eax,[ebp-44h]
00413CE4 mov dword ptr [ebp-6Ch],eax
65: b = (*pArr)[0];
00413CF0 mov eax,dword ptr [ebp-6Ch]
00413CF3 mov ecx,dword ptr [eax]
00413CF5 mov dword ptr [ebp-10h],ecx
注意:上面代码中lea eax,[ebp - 44h]表示“把ebp-44放入eax中”,这是一种聪明的地址运算的做法,否则就需要用两条语句来实现:
mov eax, ebp
sub eax, 44h
(lea表示取某个变量的地址,而[…]表示取某个地址指向变量的值,两者叠加就变为“取某个地址所指向变量的值的地址”,相互抵消,就变成了计算括号中的值,然后把它放入到前面那个寄存器中。)
ebp-44h就是数组名arr_a所转换为的地址常量。