C++指针详解

时间:2024-11-13 07:47:05

概述

C/C++语言之所以强大,以及其*性,很大部分体现在其灵活的指针运用上。因此,说指针是C/C++语言的灵魂一点都不为过。 有好的一面,必然会有坏的一面,指针的灵活导致了它的难以控制,所以C/C++程序员的很多bug是基于指针问题上的。今天就对指针进行详细的整理。

1、指针是什么?

指针是“指向(point to)”另外一种类型的复合类型。复合类型是指基于其它类型定义的类型。

理解指针,先从内存说起:内存是一个很大的,线性的字节数组。每一个字节都是固定的大小,由8个二进制位组成。最关键的是,每一个字节都有一个唯一的编号,编号从0开始,一直到最后一个字节。

程序加载到内存中后,在程序中使用的变量、常量、函数等数据,都有自己唯一的一个编号,这个编号就是这个数据的地址

指针的值实质是内存单元(即字节)的编号,所以指针单独从数值上看,也是整数,他们一般用16进制表示。指针的值(虚拟地址值)使用一个机器字的大小来存储,也就是说,对于一个机器字为w位的电脑而言,它的虚拟地址空间是0~[2的w次幂] - 1,程序最多能访问2的w次幂个字节。这就是为什么xp这种32位系统最大支持4GB内存的原因了。
在这里插入图片描述

因此可以理解为:指针是程序数据在内存中的地址,而指针变量是用来保存这些地址的变量。

2、变量在内存中的存储

举一个最简单的例子 int a = 1,假设计算机使用大端方式存储:
在这里插入图片描述
内存数据有几点规律:

  1. 计算机中的所有数据都是以二进制存储的
  2. 数据类型决定了占用内存的大小
  3. 占据内存的地址就是地址值最小的那个字节的地址

现在就可以理解 a 在内存中为什么占4个字节,而且首地址为0028FF40了。

3、指针对象(变量)

用来保存指针的对象,就是指针对象。如果指针变量p1保存了变量 a 的地址,则就说:p1指向了变量a,也可以说p1指向了a所在的内存块 ,这种指向关系,在图中一般用 箭头表示:
在这里插入图片描述
指针对象p1,也有自己的内存空间,32位机器占4个字节,64位机器占8个字节。所以会有指针的指针。

3.1、定义指针对象

定义指针变量时,在变量名前写一个 * 星号,这个变量就变成了对应变量类型的指针变量。必要时要加( ) 来避免优先级的问题:

int* p_int; 		//指向int类型变量的指针         

double* p_double; 	//指向double类型变量的指针  
   
Student* p_struct; 	//类或结构体类型的指针

int** p_pointer; 	//指向 一个整形变量指针的指针

int(*p_arr)[3]; 	//指向含有3个int元素的数组的指针 
 
int(*p_func)(int,int); 	//指向返回类型为int,有2个int形参的函数的指针  

3.2、获取对象地址

指针用于存放某个对象的地址,要想获取该地址,虚使用取地址符(&),如下:

int add(int a , int b)
{
    return a + b;
}

int main(void)
{
    int num = 97;
    float score = 10.00F;
    int arr[3] = {1,2,3};
    
    int* p_num = #
    int* p_arr1 = arr;		//p_arr1意思是指向数组第一个元素的指针
    float* p_score = &score;
    int (*p_arr)[3] = &arr;           
    int (*fp_add)(int ,int )  = add;  //p_add是指向函数add的函数指针
    const char* p_msg = "Hello world";//p_msg是指向字符数组的指针
    return 0;
}

通过上面可以看到&的使用,但是有几个例子没有使用&,因为这是特殊情况:

  1. 数组名的值就是这个数组的第一个元素的地址
  2. 函数名的值就是这个函数的地址
  3. 字符串字面值常量作为右值时,就是这个字符串对应的字符数组的名称,也就是这个字符串在内存中的地址

3.3、解析地址对象

如果指针指向了一个对象,则允许使用解引用符(*)来访问该对象,如下:

int 	age = 19;
int*	p_age = &age;
*p_age  = 20;  			//通过指针修改指向的内存数据

printf("age = %d\n",*p_age);   	//通过指针读取指向的内存数据
printf("age = %d\n",age);

对于结构体和类,两者的差别很小,所以几乎可以等同,则使用->符号访问内部成员:

struct Student
{
    char name[31];
    int age;
    float score;
};

int main(void)
{
    Student stu = {"Bob" , 19, 98.0};
    Student*	p_s = &stu;

    p_s->age = 20;
    p_s->score = 99.0;
    printf("name:%s age:%d\n",p_s->name,p_s->age);
    return 0;
}

3.4、指针值的状态

指针的值(即地址)总会是下列四种状态之一

  1. 指向一个对象的地址
  2. 指向紧邻对象所占空间的下一个位置
  3. 空指针,意味着指针没有指向任何对象
  4. 无效指针(野指针),上述情况之外的其他值

第一种状态很好理解就不说明了,第二种状态主要用于迭代器和指针的计算,后面介绍指针的计算,迭代器等整理模板的时候在介绍。

空指针:在C语言中,我们让指针变量赋值为NULL表示一个空指针,而C语言中,NULL实质是 ((void*)0) , 在C++中,NULL实质是0。C++中也可以使用C11标准中的nullpte字面值赋值,意思是一样的。
任何程序数据都不会存储在地址为0的内存块中,它是被操作系统预留的内存块

无效指针:指针变量的值是NULL,或者未知的地址值,或者是当前应用程序不可访问的地址值,这样的指针就是无效指针,不能对他们做解指针操作,否则程序会出现运行时错误,导致程序意外终止。

任何一个指针变量在做解地址操作前,都必须保证它指向的是有效的,可用的内存块,否则就会出错。坏指针是造成C语言Bug的最频繁的原因之一。

未经初始化的指针就是个无效指针,所以在定义指针变量的时候一定要进行初始化如果实在是不知道指针的指向,则使用nullptr或NULL进行赋值

3.5、指针之间的赋值

指针赋值和int变量赋值一样,就是将地址的值拷贝给另外一个。指针之间的赋值是一种浅拷贝,是在多个编程单元之间共享内存数据的高效的方法。

int* p1  = &a;
int* p2 = p1;

在这里插入图片描述
p1和p2所在的内存地址不同,但是所指向的内存地址相同,都是0028FF40。

4、指针内含信息

通过上面的介绍,我们可以看出指针包含两部分信息:所指向的值和类型信息。

指针的值:这个就不说了,上面大部分都是这个介绍。
指针的类型信息:类型信息决定了这个指针指向的内存的字节数并如何解释这些字节信息。一般指针变量的类型要和它指向的数据的类型匹配。

同样的地址,因为指针的类型不同,对它指向的内存的解释就不同,得到的就是不同的数据

char array1[20] = "abcdefghijklmnopqrs";
char* ptr1 = array1;
int* ptr2 = (int*)ptr1;

ptr1++;
ptr2++;
cout << &array1 << endl;
cout << *ptr1 << endl;
cout << ptr2 << endl;
cout << *(char*)ptr2 << endl;

运行结果如下:
在这里插入图片描述
这里&array1,是数组的地址,其实和数组首地址&array1[0]是一样的。
ptr1和ptr2都指向了同一块地址,但是ptr1的类型个ptr2的类型不一致,导致内存解释不一样,所以同样是后++操作,但是结果却不一样。

上面我用了C的强制转换,C++中有另一套转换方法。其根本,转换(cast)其实是一种编译器指令。大部分情况下它并不改变一个指针所含的真正地址,它只影响“被指出内存的大小和其内容”的解释方式

4.1、void*指针

void* 指针是一种特殊的指针类型,可用于存放任意对象的地址,但是丢失了类型信息。如果想要完整的提取指向的数据,程序员就必须对这个指针做出正确的类型转换,然后再解指针。因为,编译器不允许直接对void*类型的指针做解指针操作(提示非法的间接寻址)。

利用void所做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个void对象

5、指针的算数运算

指针可以加上或减去一个整数。指针的这种运算的意义和通常的数值的加减运算的意义是不一样的,指针的运算是有单位的

如上面第4节介绍的例子:
ptr1和ptr1都被初始化为数组的地址,并且后续都加1。
指针ptr1的类型是char*,它指向的类型是char,ptr1加1,编译器在1的后面乘上了单位sizeof(char)。
同理ptr2的类型是int*,它指向的类型是int,ptr2加1,编译之后乘上了单位sizeof(int)。
所以两者的地址不一样,通过打印信息,可以看出两者差了2个字符。

若ptr2+3,则编译之后的地址应该是 ptr2的地址加 3 * sizeof(int),打印出的字母应该是m

指针运算最终会变为内存地址的元素,内存又是一个连续空间,所以按理只要没有超出内存限制就可以一直增加。这样前面所说的指针值的状态第二条就很好解释了。

6、函数和指针

6.1、函数的参数和指针

实参传递给形参,是按值传递的,也就是说,函数中的形参是实参的拷贝份,形参和实参只是在值上面一样,而不是同一个内存数据对象。这就意味着:这种数据传递是单向的,即从调用者传递给被调函数,而被调函数无法修改传递的参数达到回传的效果。

void change(int a)
{
    a++;      //在函数中改变的只是这个函数的局部变量a,而随着函数执行结束,a被销毁。
}

int main(void)
{
    int age = 19;
    change(age);
    printf("age = %d\n",age);   // age = 19
    return 0;
}

有时候我们可以使用函数的返回值来回传数据,在简单的情况下是可以的,但是如果返回值有其它用途(例如返回函数的执行状态量),或者要回传的数据不止一个,返回值就解决不了了。

传递变量的指针可以轻松解决上述问题。

void change(int* pa)
{
    (*pa)++;   //因为传递的是age的地址,因此pa指向内存数据age。当在函数中对指针pa解地址时
               //会直接去内存中找到age这个数据,然后把它增1。
}
int main(void)
{
    int age = 19;
    change(&age);
    printf("age = %d\n",age);   // age = 20
    return 0;
}

上述方法,当然也可以用引用的方式。之后会整理引用和指针的区别

传递指针还有另外一个原因:
有时我们会传递类或者结构体对象,而类或者结构体占用的内存有时会比较大,通过值传递的方式会拷贝完整的数据,降低程序的效率。而指针只是固定大小的空间,效率比较高。当然如果你用C++,使用引用效率比指针更高。

6.2、函数的指针

每一个函数本身也是一种程序数据,一个函数包含了多条执行语句,它被编译后,实质上是多条机器指令的合集。在程序载入到内存后,函数的机器指令存放在一个特定的逻辑区域:代码区。既然是存放在内存中,那么函数也是有自己的指针的。

其实函数名单独进行使用时就是这个函数的指针。

int add(int a,int b)		//函数的定义
{
	return a + b;
}

int (*function)(int,int); 	//函数指针的声明

function = add;		//给函数指针赋值
function = &add;		//跟上面是一样的

int c = function(1,2);	 	//跟函数名一样使用
int d = (*function)(1,2);	//跟上面的调用是一样的

6.3、返回值和指针

这里唯一需要注意的是不要把非静态局部变量的地址返回。我们知道局部变量是在栈中的,由系统创建和销毁,返回之后的地址有可能有效也有可能无效,这样会造成bug。

可以返回全局变量、静态的局部变量、动态内存等的地址返回。

7、const与指针

这里主要的就是指针常量和常量指针了,两者的区别是看const修饰的谁。

7.1、常量指针

实际是个指针,指针本身是个常量。

int	a = 97;
int	b = 98;
int* const p = &a;
*p 	= 98;		//正确
p 	= &b;		//编译出错

常量指针必须初始化,而且一旦初始化完成,则它的值就不能改变了

7.2、指向常量的指针

int a = 97;
int b = 98;       
const int* p = &a;
int const *p = &a; 	//两者的含义是一样的
*p = 98;		//编译出错
p = &b;			//正确

所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,但是对象的值可以通过其它途径进行改变。

需要注意的是常量变量,必须使用指向常量的指针来指向。但是对于非常量变量,两者都可以。

8、浅拷贝和深拷贝

如果2个程序单元(例如2个函数)是通过拷贝 他们所共享的数据的 指针来工作的,这就是浅拷贝,因为真正要访问的数据并没有被拷贝。如果被访问的数据被拷贝了,在每个单元中都有自己的一份,对目标数据的操作相互 不受影响,则叫做深拷贝
在这里插入图片描述

9、指针和数组

这个之前整理数组的时候整理过了,大家可以看数组的详解

10、比较经典面试题

1、

void GetMemory(char* p)
{
	p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(str);
	strcpy(str,"hello world");
	printf(str);
}
运行的结果是什么?

2、

int array[] = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
int *p = array;
p += 5;
int* q = NULL;
*q = *(p+5);
printf("%d %d",*p,*q);
运行结果是什么?

3、

int arr[5] = {0,1,2,3,4};
const int *p1 = arr;
int* const p2 = arr;
*p1++;
*p2 = 5;
printf("%d,%d",*p1,*p2);
*p1++ = 6;
*p2++ = 7;
printf("%d %d \n", *p1, *p2);

第一道题:我们一看函数传递,指针只是浅拷贝,申请的内存在临时对象p中,并没有传递到函数外面,然后又对str地址进行写操作,str初始地址为NULL,不能进行书写,所以系统会崩溃。

第二道题:一看很开心是指针类型的加减法,下标从0开始,但是数字从1开始,所以应该是6 11。但是你忽略了q是一个NULL指针,不能进行书写,所以会崩溃。

第三道题:指针指向数组,数组退化成指针,前两个指针操作是对的。但是后面*p1++ = 6; 不可以通过p1进行值的修改,*p2++ = 7;不能对p2进行修改。所以这道题是编译出错。

感谢大家,我是假装很努力的YoungYangD(小羊)。

参考资料:
《C++ primer 第五版》
/lulipro/p/
/ggjucheng/archive/2011/12/13/