C/C++指针种类+常见错误+组合技巧,挑战写出全网最全指针文章
学习C/C++时,常常会遇到一个很烦人的东西:指针,说实话,指针在很多人入门C/C++时都是属于噩梦级的,它牵涉了庞大的知识体系,容易出现各种未定义行为,因此被称为C/C++中的最难知识点,那么这篇文章,就让我们好好聊一聊,这个BOSS级的指针。
一.汇编层面的指针理解
1.程序与内存
不管你使用什么语言,只要它最后生成了可执行程序,在运行时都是被分为四部分读入内存的,在内存中,分别是:
代码段:程序编译出的机器码逐行存放在代码段
数据段:静态变量、全局变量等生命周期跨越整个程序的数据存放的位置
堆栈段:又分为堆段和栈段,堆段放置动态的变量,栈段存放上下文信息(函数的局部变量、调用时的参数、返回地址)
从上面可以看出,我们在开发中使用到的变量实际上都是写入了内存的,因此理论上我们可以通过修改内存来修改变量
2.内存地址、变量地址
内存是一小块一小块存储单元线性排列组成的的,因此我们可以为内存的每一块都依次标上序号,这样的序号我们称之为内存地址,内存地址相当于内存每一个单元的身份证号,对于一块内存,有且只有一个内存地址与之对应,以此,我们可以很方便的利用内存地址描述、操作内存中的区块。
上面讲过,高级语言编写的程序运行时变量都是写在内存之中的,因此我们可以使用内存地址来描述一个变量的起始位置,在这种情况下,变量对应的内存地址就称为变量地址。
我们看下面的例子:
int a;
a = 10;对于编译器,你定义了一个变量,并且在之后赋值为了10,那么它转换后的机器语言代码大致动作如下:
找连续的空内存,累计大小正好是一个
int型的大小将
10的二进制值写入内存,假内存长度大于数字长度,则数字前面的部分全部为0
也就是说变量名在高级语言中属于标识符,你为一块内存起了个名字,这个名字是你在定义时告诉编译器的,相对于汇编使用内存地址,你可以更方便的在开发中明白某个内容的用意是什么,但它的本质还是内存操作,只是操作内存的部分由编译器帮你编写了。
3.汇编与内存
汇编中(起码在intel汇编中)使用[]标记地址,汇编编译器在看到这个符号时,就会将里面的内容理解为地址,然后读取内容后参与操作,比如MOV EAX,[EBX]指令的作用,就是到EBX寄存器(一种高速但很小的存储器,位价很高)存储的内存地址值对应的内存中取出数据然后移动到EAX寄存器,此例中,EAX存储数据,EBX存储地址,EBX存储的地址对应的内存存储数据。
实际上,汇编语言更多时候使用诸如MOV EAX,BYTE[EBX]的格式,BYTE叫“类型修饰符”或“大小指示符”,近似于高级语言中的数据格式,约定了“这个地址指向的数据有多长”
4.C/C++的内存操作
作为现代最贴近硬件的高级语言,C/C++自然保存了内存操作,这也就是我们要讲的指针。
指针在本质上讲是变量,只不过存储的是地址,因此是一类特殊的变量,指针变量的数据类型可以说是“某类型指针”,比如“整数指针”就是指向整数的指针。类比汇编的内存操作中的内容,变量类似于寄存器,可以存储各种数据,此时我们单独拿出一个寄存器,使其只存储地址,这个寄存器就是指针。
指针提供了灵活操作内存的可能,扩展了C/C++的能力,同时也可以用于节约复制传址造成的大开销,如果你对指针有所了解,你甚至会发现它允许你将一个有你自定义的数据类型交给已经写好的函数进行传递(我是指Win32的窗口状态信息)
我们将在下面细致讨论他。
二.C/C++指针基础
1.指针的基本定义格式
C/C++的指针本质就是变量,因此也和变量遵循近似的定义方式,下面是基本模板:
<type> * <name>;其中,<type>填充指针所指向的数据的数据类型,<name>填充这个指针的名称(类似于变量名,可以自己在规则内随便起名);*是C/C++的特殊符号,有很多重意义,在这里用于标注紧跟的标识符是指针。
下面的内容定义了一个整数型指针:
int *ptr; // 这里是无初始值的,所以不指向任何内容
2.星号的位置
需要注意的是,指针的定义有一个非常需要注意的内容,就是*的位置,有时它在不同位置是同样的效果,但是阅读起来却会给人两种感觉,可能会导致阅读者对代码产生误解。
// 下面两种都是正确的
int *ptr1; // 星号紧跟标识符
int* ptr2; // 星号紧跟数据类型
对于指针的定义,C/C++不关心数据类型、*、标识符中间的空格,因此上面的两种间隔方式都是对的,但是普遍来讲在开发中更推荐第一种,因为第二种写法容易给人一种错觉:ptr2的数据类型是int*,这在单个指针的定义中还看不出什么,但是如果多写几个,情况就不一样了,前面我们说到过,*算是一种特殊符号,它只作用于紧跟的标识符,所以之后出现的被分隔的标识符不会被作用到!
我们来看例子:
int* ptr1,ptr2,ptr3,*ptr4;此时,ptr1、ptr4是指针,另外两个则是普通变量,*并没有作用于2、3,想要定义的全部都是指针变量,则需要:
int *ptr1,*ptr2,*ptr3,*ptr4;在项目开发中,建议避免int* ptr;的使用,这样能够有效的防止误解的产生,当然你也可以选择讲所有指针的定义放到普通变量之后,,如果你翻看windows.h就会发现很多数据类型的定义都是这种顺序(其实也许不止这个头,但我只看过这个头)
Note
其实用哪一种是风格的选择,因此没有绝对的对何错,现代C++反而更倾向于将 * 与类型结合,以强调“指针类型”概念,所以只需注意多变量定义时的陷阱即可,不需要纠结到底用哪一种
3.指针的初始化
普通变量可以初始化,指针变量当然也可以初始化,指针变量的初始化如下:
// 我们这里先介绍借助已有变量的初始化,其他方式之后再讲
int var; // 先要有一个int变量
int *ptr = &var; // 借助已有变量初始化
上述代码出现了&符号,这个符号跟在标识符前方时是取地址的意思,可以获取变量、函数等内容在内存中的地址。
指针变量一般直接使用=进行初始化,将右侧内容写入指针变量,也就是说,右侧的初始值应当是内存地址。
4.使用指针
使用指针时,*又有了新的含义,当其紧跟着已经定义过的标识符时,*就是解引用符号,与汇编[]发挥相同效用,操作被解引用的地址就可以理解为在直接操作地址对应的内存:
*ptr = 20指针很大一部分难点就是要需要区分ptr和*ptr,前面我们讲过,指针变量就相当于汇编里存了数据的寄存器,因此我们可以修改指针变量的内容,这会调整其指向;我们也可以修改指针变量指向内容的值。
下面是C++的例子,大家可以自己复制下来放到编译器里运行试试:
#include<iostream>
int main(){
int var = 10; // 普通变量
int *ptr = &var; // 初始化ptr指向var
std::cout<<&var<<" "<<ptr<<" "<<(*ptr)<<" "<<var<<std::endl;
(*ptr)++; // 增加指针指向的值,注意括号是必须的
std::cout<<&var<<" "<<ptr<<" "<<(*ptr)<<" "<<var<<std::endl;
return 0;
}可以很明显的看出,指针变量的单独使用,其内容是地址,与对应的变量取地址的结果相同;指针解引用后的内容是真正的数据,也就是对应变量的内容
注意(*ptr)++之中括号是必须的,因为*ptr++会被理解为解引用prt++而不是解引用ptr再++,这是改变优先级的操作
Note
上面的代码中使用(*ptr),这在第六行是必须的,但在输出时貌似并不必须,只是我不知道当时看的哪个指针教程教了一个优先级,导致我现在各种没必要的地方也去加括号
5.引用
C++提供了一种C语言没有的形式叫引用,它与指针相似,都是基于地址操作的,所以我们放在一起讲。
一个变量具有以下属性:
变量地址:变量的起始位置
数据类型:变量的长度、编码等信息
修饰信息:决定变量以什么形态存在
生命周期等等….
对于指针变量,他存储了某个地址,同时它也有自己的地址,也就是我们在借助一个变量访问一个内存,指针正如它的名字那样,它就是一个“指针”,本身不可能作为“目的地”,但可”指向真正的目的地
引用则与之不同,引用本身可以理解为普通变量,只是不会在定义时寻找空的内存,而是直接把自己的地址信息改成别的变量的,你可以理解为内存是一间小屋,变量是允许你进入的门,引用就是再开一个门:
int a; // 一个变量a
int &b = a; // 一个引用b
b=20;
a=10;上面就是引用的写法了,此时我们直接改a,a和b的值同时变化,因为它们本质上对应的同一块内存,只是取了个别名而已。
6.多级指针
指针是可以指向指针的,像这样:
int *ptr;
int **ptr2=&ptr;如果想通过ptr2访问到ptr指向的内存,就需要写*(*ptr2),这样的嵌套理论上可以无限地进行,但是实际上你在应用中用到三级以上的指针就很罕见了
三.动态内存分配
你可能会问:指针一定需要指向已经有的变量吗?答案是:没必要。
上面使用已有变量取地址初始化讲解只是为了方便讲解,指针真正的大用途是动态内存分配,也就是灵活的、直接的申请内存、操作内存,标准店的定义是:
动态内存分配是指在程序运行时根据需要分配和释放内存,而不是在编译时确定内存大小
C++和C的实现是不一样的,我们分开讲
1.C语言实现
C语言有这样几个函数:malloc、calloc、realloc、free,定义在 <stdlib.h> 头文件中,负责完成动态内存分配的工作
1.1. malloc (Memory Allocation)
malloc函数允许你申请指定长度的内存,常见的使用模板如下:
<type>* <ptr_name> = (<type>*) malloc(<size>)其中,等号左侧内容是指针的定义,用来保存申请到的内存信息(也就是起始地址),等号右侧的(<type>*)是在转换数据类型,因为malloc返回的是void*型的也就是说没有数据类型泛型指针,我们需要告诉编译器这个指针应该是指向什么类型的数据的
Note
我们上面说的“没有数据类型”是指“没有标记指针指向的内容的数据类型”,更偏向于在说“不知道类型”
malloc只要一个参数,那就是申请多大的内存,我们直接使用sizeof函数即可,申请内存存放n个typeA型数据就申请n*sizeof(typeA)长度的内存
下面是使用示例
int *arr = (int *)malloc(5 * sizeof(int)); // 分配5个整数大小的内存
Note
malloc函数返回值可能为NULL,发生在申请失败时,大项目中要对此做错误处理;malloc申请到的内容都不初始化,可能包含垃圾数据,一定要手动初始化
1.2. calloc (Contiguous Allocation)
calloc与malloc类似,这个函数的语法如下:
void* calloc(size_t num, size_t size);calloc的一个好处是分为了数量和单个大小两部分,不需要你自己计算了,另一个好处就是它会初始化你申请的内存的所有字节为0
下面是例子:
int *arr = (int *)calloc(5, sizeof(int)); // 分配并初始化5个整数大小的内存
1.3. realloc (Reallocate Memory)
realloc函数可以用于调整已分配内存块的大小,当新大小>原大小时,新增部分未初始化;当其<原大小时,多余部分被释放,语法如下:
void* realloc(void* ptr, size_t new_size);用例如下:
arr = (int *)realloc(arr, 5 * sizeof(int)); // 扩展到5个整数大小
1.4. free (Free Memory)
free函数用于释放通过 malloc、calloc 或 realloc 分配的内存,语法如下
void free(void* ptr);这样做,是为了节约系统资源,否则会出现内存泄漏
Note
内存在使用Free释放后,指针仍然会存在,但不再指向有效内存,此时就成为了悬空指针,建议将其置为 NULL。
Warning
重复释放同一块内存;释放某块内存后继续访问该内存
2.C++的实现
C++的动态内存分配使用new和delete实现,相对于C语言,C++的分配更为简单
2.1.new
new是C++的一个关键字,负责开辟一块连续的内存空间存放指定类型的数据,不同于C语言版本需要自己计算内存大小、自行数据转换,C++的new会直接指定申请的内存的数据类型,它的基本写法是这样的:
new type;如果new出来的内存不是直接使用的(比如使用构造函数初始化智能指针),我们就需要一个指针来存储,写法大致是这样:
type *name = new type;我们可以写一个申请单个int变量长度的内存的程序试试手
int *ptr = new int;我们不止可以使用new关键字开辟一个变量的长度的内存,也可以开辟可容纳多个变量的内存空间,如下:
int *ptr = new int[5]此时我们开辟了5*sizeof(int)大小的内存空间,ptr指针可以在ptr到ptr+4之间自由活动。
另外需要说明的是,我们可以在new时使用构造函数赋予内存初始值,比如我们可以写:new int(10)
2.2.delete
和C一样的,在用完了申请的内存后,需要进行释放,以便告诉计算机对应的内存已经不会被用到了,好让计算机分配给别的东西,这就会用到delete。
delete很简单,直接就写成:
delete ptr_name;他会释放紧跟着的指针变量所指向的内存地址
当然,这种方式有一个局限,就是不能很好的释放诸如new int[10]申请的内存,因此就需要使用delete[]来与之对应:
int *ptr = new int[10];
delete[] ptr;注意[]中不需要填充任何内容
四.进阶的指针常见种类
讲完了指针的基础,我们可以看看常用的一些进阶概念了
1.常量指针、指针常量
指针可以指向常量,我们称呼指向常量的指针为常量指针,下面展示常量指针的一个定义示例:
const int *ptr;编译器首先会看到ptr是个指针(*),然后在看到ptr指向int数据,最后再看到ptr指向的内容是常量,此情况下,ptr是可变的,但它指向的内容是不可变的
与之对应的,还有指针常量,也就是指向为常量的指针,下面也演示一个示例:
int * const ptr;编译器首先看到ptr是个常量,然后在看到它是个指针,最后才看到它指向int型数据,此时ptr指向不可变,但其指向的内容可变
你当然可以组合一下,让它变成这个样子:
const int * const ptr;这玩意叫常量指针常量,在很多面试题里喜欢拿来说事。
2.数组指针、指针数组
类似于常量指针、指针常量,数组也能与指针结合
数组指针就是指向数组的指针,示例如下:
int (*ptr)[10];这里需要使用()改变优先级,直接告诉编译器ptr是个指针,指向一个数组
指针数组就是存着指针的数据,用下面的代码可以定义:
(int *) ptr[10];此时ptr是数组,每一个成员都是指针
与常量同理,你可以组合出数组指针数组,但我已经懒得思考了,所以具体代码就不写了(我写这里时已经1:19了,并且我刚写完下面的智能指针等内容)
3.restrict修饰符
Note
restrict关键字是C语言关键字,在C99标准引入,C++标准并不支持这一关键字,不过主流编译器(比如GCC, Clang, MSVC)可能会在C++模式中将其作为扩展支持,有时需要写作__restrict,但并不是都支持的且使用不一定相同
有时编译器不敢擅自对程序进行优化,即使只是为了防止某些小概率事件发生
比如当你写下:
bool func(int *ptr1,int *ptr2){
*ptr1 = 10;
*ptr2 = 20;
if(*ptr1 != *ptr2){
return 1;
}
return 0;
}对于这个函数,分别将*ptr1、*ptr2赋值成了10、20,所以自然不会相等,对此,我们似乎可以优化这个函数,使其变成:
bool func(int *ptr1,int *ptr2){
return 1;
}但真的是这样吗?
如果你仔细想想就会发现,这种情况在ptr1=ptr2时是不成立的,因为虽然分别赋了不同值,但由于指向同一块内存,所以赋值操作一前一后,后者覆盖了前者的内容,最后,*ptr1 = *ptr2
于是编译器就不敢优化了,因为这种情况虽然概率小,但未必不会发生,此时我们如果想让编译器去优化,就应该告诉它:这两个指针不一样
那么该怎么告诉它呢,答案就是restrict,这个关键字告诉编译器这个指针是只想这一块内存的唯一方式,没有另外的指针指向着一块内存,这时,编译器就会去优化了
bool func(restrict int *ptr1,restrict int *ptr2){
*ptr1 = 10;
*ptr2 = 20;
if(*ptr1 != *ptr2){
return 1
}
return 0;
}注意,这是一种”保证“,假如你写了restrict但实际上还有指针指向了这块内存,那么程序就可能会因为优化出现问题,所以请一定要谨慎使用
4.泛型指针
C/C++支持一种叫泛型指针的东西,泛型指针本质上是指针,只是没有数据类型
泛型指针写作下面的形式:
void* ptr_name;泛型指针本身不难理解,指针无非就是地址,数据类型只是标记理解方式,假如我们只要看地址,而不需要程序去理解”这是什么数据“,我们就可以使用void*,通常,泛型指针会出现在某些函数的返回值中,比如一个申请一块内存并且每一位都填充1的函数,返回指针,具体类型交给使用者决定。
5.智能指针
Note
智能指针是C++特有的内容,如果你只想学习C语言,请直接跳过本条到1.常量指针、指针常量
Tip
接下来的内容篇幅很长,但智能指针是C++中很重要的概念,请各位一定对此要有耐心
动态内存分配很容易搞出内存泄漏,C++提供了方便的方式也就是智能指针,它可以来避免这种危险(C语言没有智能指针,需要手动管理指针),智能指针会根据情况自动的释放内存
智能指针由<memory>提供,包括shared_ptr、unique_ptr和weak_ptr三种,他们有着不同的释放判断方式,但基本功能都差不多,我们之后将逐一讲解
5.1.定义智能指针与初始化
智能指针的定义是这样的:
std::ptr_type<type> ptr_name;其中,ptr_type填上上面说的三种中的一种,type填数据类型,ptr_name填指针名,此时我们的指针是没有初始值的,我们还需要赋初始值才能使用
刚刚的内容改成:
std::ptr_type<type> ptr_name(...);这个是通过构造函数赋值的方法,比如:
std::shared_ptr<int> ptr(new int);这就是new了一块内存然后用于构建智能指针ptr,其初始值为new int的返回结果
假设你更喜欢看=的初始化,那当然也是可以的:
auto ptr = make_shared<int>(20);此时我们就得到了一个指向内容为20的内存的智能指针。
5.2.unique_ptr
std::unique_ptr指向一个内存,当它移走或结束了生命周期时,也就是说不再指向那一块内存,对应的内存会自动delete,可以看下面的例子:
{std::unique_ptr<int> ptr(new int(10));}
// ptr超出作用域被销毁,自动delete
对于一块内存,只会有一个unique_ptr指向它,因此只要这个指针不再指向这块内存,就自动销毁
5.3.shared_ptr
与unique_ptr不同,std::shared_ptr允许多指针同时指向同一内存,因此它是计数销毁的指针,也就是当很多shared_ptr指向同一个内存,只有这些指针都不指向这块内存了(引用计数为0),它才会被销毁
5.4.weak_ptr
shared_ptr存在一个瑕疵,就是如果遇到了循环引用,照样会内存泄漏,于是便引入了我们的weak_ptr也就是弱引用
在某种程度上,std::weak_ptr和shared_ptr其实差不多,只是它不参与引用计数,也就是说,当weak和shared同时指向一块内存,只要shared都不在指向该内存,无论weak是否还在指着,内存都会被销毁
weak_ptr介于shared_ptr和普通指针之间,它提供了安全的访问,使得我们无需担心普通指针会遇到的悬空指针,也不会担心出现shared_ptr的循环引用
5.5.转移所有权、放弃所有权
对于智能指针,你可以直接理解为它是一个帮你保管指针的第三方,对于一个unique_ptr,它所指向的内存只会被它自己指向,我们便可以说某块内存是该智能指针所有的
我们可以使用std::move来转移所有权:
std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
// 转移所有权
std::unique_ptr<int> ptr2 = std::move(ptr1);
// 现在 ptr1 为 nullptr,ptr2 拥有对象
if (!ptr1) {
std::cout << "ptr1 已失去所有权" << std::endl;
}由于ptr1已经将指针转移给了ptr2,它就不再管理这个对象,因此其值变为了nullptr也就是不指向任何内容,ptr2则是指向ptr1之前指向的内存
Tip
对于nullptr,其在if等条件判断语句中拥有与fault相似的效果,!nullptr则有与true类似的效果,可以使用这个方式直接写if(!ptr)来判断指针ptr是否为空
当然了,ptr1可以直接变成nullptr而不转移所有权,也就是直接放弃所有权,不转移到其他智能指针手中,此时指针不再被智能指针托管,回归普通指针,我们可以使用release()实现:
std::unique_ptr<int> ptr = std::make_unique<int>(100);
int* raw_ptr = ptr.release(); // ptr 变为 nullptr,release返回原始指针
// 这里你也可以选择不把指针存起来,但这一定会内存泄漏
// 现在需要手动管理 raw_ptr
delete raw_ptr;注意释放后的智能指针不再智能管理,因此需要你在release()后手动的delete掉对应的内存,因为放弃所有权是不会自动delete的
5.6.获取原始指针
放弃所有权会返回一个原指针,但有时我们可能会更希望在保留所有权的状态下拿到原始指针,比如我们想要给已有的智能指针做个拷贝,此时我们就需要使用get()
std::shared_ptr<int> ptr = std::make_shared<int>(42);
int* raw_ptr = ptr.get(); // 获取但不放弃所有权
Warning
当你获取到原始指针时,一定不要试图去delete,我们在后面的内容中会论述为什么这样做是危险的
5.7.释放资源
智能指针虽然可以自动的管理指针,但是那只是达成了某些条件才触发的,有时我们希望在这些条件没达成之时就释放掉对应的内存,那么假如我们写了下面的内容:
std::unique_ptr<int> ptr(new int);
delete ptr.get();
// delete ptr;
Warning
上述代码是坚决不可取的!!!!
如果你采用了delete ptr.get();,这将导致双重释放错误,我们手动释放的操作智能指针是不知道的,因此当我们的智能指针结束了生命周期或者在其他符合条件的情况下,仍然会去尝试释放这块已经释放过的内存,这将导致很多问题在运行时发生。
加入你采用了上方注释中的delete ptr;,情况会好些,但我说的“好些”是指这种写法压根不会通过编译,因此不会在运行时搞出各种各样稀奇古怪的状况。
那么该怎么正确的释放呢?答案是reset()
reset可以改变智能指针的朝向,无参数时,它会将智能指针指向的内容销毁(或计数-1或什么都不做),然后让智能指针变为nullptr;有参数时,提供一个新的内存地址,reset释放(或计数-1等)旧对象指向新对象
因此对于unique_ptr,我们可以使用reset销毁对应的内存并将该智能指针值改为nullptr(也有可能是指向新对象,总之不再指向原来指向的位置了)
对于shared_ptr,我们可以将所有指向同一块内存的指针都reset一下,这样计数就变为0了
下面看例子:
std::unique_ptr<int> ptr = std::make_unique<int>(50);
ptr.reset(); // 释放当前对象,ptr 变为 nullptr
// ptr.reset(new int(60)); // 释放旧对象,管理新对象
5.8.shared特有操作
5.8.1.user_count()
shared_ptr有一些特有的操作,常用的是use_count(),它可以查看某个shared_ptr指向的内容的引用计数,也就是同时有多少shared_ptr在指向这个内存
auto ptr1 = std::make_shared<int>(100);
auto ptr2 = ptr1;
std::cout << "引用计数: " << ptr1.use_count() << std::endl; // 输出 2
5.8.2.unique()
C++20标准之前,shared_ptr还有一个unique()命令,不过在20以后就不再使用了,这个命令可以检查智能指针是否是指向的内存的唯一所有者
Note
unique()在C++20标准之后是“不能使用”,实际上其早在C++17中就已经被弃用了,只不过是在C++20中被正式移除了
unique()用例如下:
ptr.unique();5.9.weak特有操作
5.9.1.lock()
weak是削弱的shared,其出现目的就是解决循环引用之类的问题,但有时我们也想把它变成一个shared_ptr,这时我们就需要使用lock()
Note
你当然可以直接用get()拿到地址然后再去定义一个shared_ptr,但你要明白weak是弱引用指针,它指向的内存可能已经被释放了,所以这样一条手动路径就有些危险了
lock()是weak转shared的一个相对安全的方式,对于正常的内存,lock()返回一个shared_ptr,对于一个已经释放的内存,则会返回一个nullptr
std::shared_ptr<int> shared = std::make_shared<int>(200);
std::weak_ptr<int> weak = shared;
if (auto temp = weak.lock()) { // 尝试获取 shared_ptr
std::cout << "对象存在,内容为: " << *temp << std::endl;
} else {
std::cout << "对象已被释放" << std::endl;
}5.9.2.expired()
expired可以检测weak指向的内存有没有被释放(也可以说说叫检查指针有没有过期)
std::weak_ptr<int> weak;
{
auto shared = std::make_shared<int>(300);
weak = shared;
std::cout << "过期: " << weak.expired() << std::endl; // false
}
std::cout << "过期: " << weak.expired() << std::endl; // true
5.10.自定义删除器
智能指针的大概实现就是在构造函数处定义指针,然后在析构函数处释放内存,但我们可以让这个过程更自定义一些,以便让智能指针可以为我们管理更多的东西,这就需要自定义删除器,也就是让智能指针在析构函数处做出我们期待的行为
自定义删除器可以是函数(指针)、类(的对象),或者lambda表达式
下面的例子采用了最经典的文件句柄管理的例子,包含了上面的三种方法,详细的讲解通过注释写出了:
#include <iostream>
#include <memory>
#include <cstdio>
// 函数形式的删除器,返回值是void
void FileDeleter(FILE* file) {
if (file) {
std::cout << "使用函数删除器关闭文件" << std::endl;
fclose(file);
}
}
// 类形式的删除器
class FileDeleterClass {
public:
void operator()(FILE* file) const {
// 使用operator()重载操作符
if (file) {
std::cout << "使用类删除器关闭文件" << std::endl;
fclose(file);
}
}
};
// Lambda 表达式删除器
auto lambdaDeleter = [](FILE* file) {
if (file) {
std::cout << "使用Lambda删除器关闭文件" << std::endl;
fclose(file);
}
};
int main() {
// 创建临时文件用于演示
FILE* temp1 = tmpfile();
FILE* temp2 = tmpfile();
FILE* temp3 = tmpfile();
if (!temp1 || !temp2 || !temp3) {
// 虽然只是演示,但错误处理的意识也要有
std::cerr << "创建临时文件失败" << std::endl;
return 1;
}
std::cout << "开始演示三种自定义删除器(unique_ptr)" << std::endl;
// 使用函数形式的删除器
std::unique_ptr<FILE, decltype(&FileDeleter)> filePtr1(temp1, FileDeleter);
// decltype是让编译器推导数据类型你也可以写做下面的形式:
// std::unique_ptr<FILE, void(*)(FILE*)>
// 使用类形式的删除器
std::unique_ptr<FILE, FileDeleterClass> filePtr2(temp2);
// 使用Lambda表达式的删除器
std::unique_ptr<FILE, decltype(lambdaDeleter)> filePtr3(temp3, lambdaDeleter);
// 尝试向文件写入一些数据
fprintf(filePtr1.get(), "Hello from file 1\n");
fprintf(filePtr2.get(), "Hello from file 2\n");
fprintf(filePtr3.get(), "Hello from file 3\n");
std::cout << "文件使用完毕,即将自动调用删除器..." << std::endl;
// 当 main 函数结束时,三个 unique_ptr 会超出作用域
// 它们各自的删除器会被自动调用
return 0;
}限于篇幅原因,很多内容我们无法展开,比如shared_ptr的使用和unique_ptr是不同的,很多张要的概念也没有展开,各位可以自行阅读下面的内容:
5.11.一些坑
智能指针可能会遇到循环引用的问题,可以到对应的位置阅读(点链接)
6.函数指针
用过易语言可能会了解,有一种指针叫做函数指针,毕竟函数最后编译出的机器指令还是存在内存中的代码段的,也会有地址,自然也能有指针,其基本写法是
返回类型 (*指针变量名)(参数类型1, 参数类型2, ...);例如:
int func(int a,char b);// 函数(声明)
int (*ptr_func)(int a,char b); // 指针
函数指针通常会被用作回调函数传递给别的函数,一般的用途是让对应的函数在特定事件发生时自动调用被指向的函数
大多数时候,函数指针的数据类型都是很长的,我们有两个选择:
使用
auto,让编译器自行推到使用
typedef,简化书写使用
std::function使用
decltype获取数据类型,适合极度复杂的情况
7.__ptr32、__ptr64
微软为了跨平台,提供了__ptr32与__ptr64,这两个指针的特点就是:固定,前者无论在32位系统还是64位系统长度都为32位,后者则永远64位,这两个指针形式是为了同一指针长度而出现的,32位系统上,__ptr64会被截去一半;64位系统上,__ptr32会被扩充一倍
具体内容请参考微软官方文档
8.长指针
Warning
长指针是16位x86架构(实模式、分段内存模型)的历史概念,在现代32/64位平坦内存模型中已无必要且不被标准支持,本部分内容仅为了让你能了解涉及的关键字,防止看不懂某些古早的代码
由于历史原因,C/C++支持了远指针、巨指针,这两个统称为长指针,相较之下普通指针称为近指针
支持这两个指针的原因是早期计算机寻址能力很差,一个指针变量能存的数小于实际的内存数量,就导致指针无法记录所有地址,很多地址都无法被表示,因此就引入了基址、偏移的概念,人们表示一个内存,不再用单独的地址,而是使用一个基址与一个偏移组合,写成基址:偏移,算出基址*16+偏移即为真正的物理地址
早期C/C++支持了这一用法,也就是长指针,这让程序有了更大的内存空间可用
far int * ptr1; // 远指针
large int * ptr2; // 巨指针
巨指针和远指针差不多,只不过巨指针多了一些规范,我们先看这样两个基址:偏移表述的物理地址:
1F2A:3A13 = 223B3
1D09:5323 = 223B3
也就是说,我们用不同的基址:偏移算出了同样的物理地址,这就是远指针可能的问题,巨指针使用了特殊规则,使得一个物理地址只会有一个表述
五.语法糖
对于类的指针,C++提供了语法糖
一般操作类的指针,我们需要这样:
class classA{
int num;
}
int main(){
classA A;
classA* ptr = &A;
(*ptr).num=10;
}我们需要(*ptr).num=10;来解引用后使用成员,,但我们也可以这么写:
ptr->num=10;写法选哪个纯粹看个人喜好,我个人更喜欢->,看上去简洁不少
六.指针有关的错误
1.内存泄漏
大量使用new却不delete(或者大量申请内存不free()),会导致电脑大量资源被占用,因为你并不释放用过的内存,所以电脑不知道你用没用完,自然不敢分配给其他内容,时间一长,就有大量内存是你虽然不用但是系统也用不了的,这就是内存泄漏
内存泄漏的最好解决方案就是C++的智能指针,可以自动管理指针
2.悬空指针
悬空指针,又叫“悬垂指针”、“迷途指针”、”失效指针“等等,其成因是内存销毁了,但指针仍在指向这一块内存,比如:
int *ptr = new int;
delete ptr; // 内存已经释放
(*ptr)++; // 此时还在解引用ptr
这种情况下,内存已经释放了,随时会被重新分配,因此*ptr这样的语句就很危险。
想要避免这样的情况,就要在释放内存后立刻将ptr设为NULL
当然悬空指针肯定不止这一种成因,有时生命周期长的指针指向生命周期短的变量也会使得悬空指针出现:
int *ptr;
{
// 单独的作用域
int var = 10;
ptr = &var;
}
// 此处不在var作用域,var已销毁
*ptr = 20;// 出错,var已经不存在了,对应的内存状况未知
因此,指针在使用时一定要注意生命周期的问题
3.野指针
当指针因为各种原因具有了随机的值时,这个指针就是野指针,比如我们使用取随机数取到了一个数字,再传换成了int*赋值给了一个int指针,我们无法预见这个指针在运行时会有怎么样的行为,如果这个指针读数据,那么就会读到意义不明的数据;如果这个指针写数据,则有可能会导致软件甚至系统的数据被修改导致运行时崩溃,这就是未定义行为
下面的几种行为很容易出现野指针:
使用结果带有随机性的函数的返回值作为指针指向的地址
不初始化指针变量
随便给指针变量了一个初始值
程序开发中一定要避免
4.未初始化指针
定义指针后,未经初始化,也没有赋任何值,指针内存的数据就是上一次用过这一块内存的程序(变量)所留下来的内容,这个内容是不确定的,因此指针不初始化就使用是是绝对错误的,它是野指针的一种
5.空指针
指针没有内容就是空指针,与未初始化指针不同,空指针有着确定的值,但这个值是NULL,也就是说这个指针一定没有值,NULL不会和任何内存对应,因此解引用NULL是十分荒谬的行为,这会导致软件允许出错
int *ptr=NULL;
std::cout<<*ptr;使用时一定要注意规避这种情况,可以在无法确定指针值的情况下加入判断来提升程序的异常处理能力,比如从函数中获取的作为返回值的指针值就需要判断是否为NULL
6.循环引用
Note
此处内容需要智能指针的前置知识了解
C++提供智能指针是为了解决内存泄漏而生,但依旧没有完全规避掉内存泄漏,比如循环引用依旧会导致内存泄漏发生
循环引用常发生在两个或多个对象通过shared_ptr互相持有时,我们先写出两个示例类,使之可以达到互相持有的条件:
class classB; // 向前声明classB
class classA{
public:
std::shared_ptr<classB> ptr;
}
class classB{
public:
std::shared_ptr<classA> ptr;
}此时,只需要两个shared_ptr分别指向这两个类的对象,然后再在成员中互相指向对方就形成了循环引用:
std::shared_ptr<classA> ptr1(new classA);
std::shared_ptr<classB> ptr2(new classB);
ptr1->ptr = ptr2; // 该语句等效于(*ptr1).ptr = ptr2;
ptr2->ptr = ptr1;此时存在两个对象,分别是classA对象和classB对象,这两个对象的引用计数都为2,对于classA的对象,存在ptr1和ptr2->ptr的引用,对于classB对象则类似(为了简化语言,我们接下来说A对象、B对象)
Tip
关于ptr2->ptr这一类写法,请参考语法糖部分的说明
当ptr1与ptr2都销毁了时,A对象、B对象引用计次均减为1,但由于不为零,并不会释放内存,此时虽然我们的程序无法用到这两个对象,但它们仍然客观存在于我们的内存中,保持互相指向对方的状态,不会被智能指针释放
解决这一问题,请使用weak_ptr进行弱引用,这样对象内部的成员就不会增加引用计次(这个行为貌似成为解耦合,不过我也不是很清楚来着,嘿嘿)
七.数组退化为指针
数组在某种意义上是指针的特殊形式,其指向数组内容的开头,标记数据类型与数据的量,因此具有一些指针的性质
如果手头有编译器,可以试试下面的代码:
int a[5]={1,2,3,4,5};
std::cout<<a[2]<<std::endl<<2[a];你会发现输出了两次3,因此我们不难发现数组操作的本质:
对于数组操作X[Y],以X+Y得到一个地址,随后解引用,得到内容
我们可以说数组是指针的变种,那么自然数组也可以退化为指针,在大多数表达式中,数组名会隐式转换为指向其首元素的指针:
int a[10];
int *ptr;
ptr = a;八.指针的移动长度
指针变量内容变化,朝向就会变化,我们可以用++、--来让指针向上或向下移动一位,这可以用于在数组中游走,不过有一点需要注意,指针变量+1不代表内存地址加一,而是意味着内存地址加上一个对应数据类型的数据的大小的值,比如假设classA的每个成员需要占据50块内存,classA的指针+1时地址就+50
九.写在最后
本文考虑篇幅,很多内容并没有细讲,再加上笔者本身实力有限,您可以阅读下面由笔者精选的内容进行更细致的学习:
转发本文章到以下平台?
