C++的指针
C++的指针
指针的概念
C++中内存单元内容与地址
- 内存由很多内存单元组成,这些内存单元用于存放各种类型的数据
- 计算机对内存中的每个内存单元都进行了编号,这个编号就称为内存地址,地址决定了内存单元在内存中的位置
- 记住这些单元地址不方便,于是C++的编译器就允许通过设置变量的方式来访问这些内存地址,而这个变量就是指针
举例:
|
|
指针的定义与间接访问操作
- 指针定义的基本形式:指针本身就是一个变量,只不过它存储的内容是地址。对于类型
T
,T* ptr
就是在定义一个指向类型为T
的指针ptr
,这个ptr
就可以存储「类型为T
的变量的地址」 - 通过一个指针访问它所指向地址的过程称为间接访问或者引用指针,而这个用于执行间接访问的操作符就是单目运算符
*
例如:std::cout << *ptr << std::endl;
就可以打印出来ptr
所指向的值(使用std::cout << ptr << std::endl;
可以打印出来ptr存储的地址)
左值与右值
一个小案例:
|
|
上面这个小demo中,str2可以赋值为str1,但是str1并不可以被str2赋值,换言之指针变量的值允许改变而数组变量的值不可以改变,虽然在使用的时候str1和str2区别不大,但在赋值上他俩截然不同,这就是左值与右值。
概念
这里不深究原理,简单从工程的角度上来解读一下左值与右值
左值即可以放到赋值运算符=
左边的值,包括设定好的变量、指针等(左值也可以放到=
右边)
右值即只能放到赋值运算符=
右边的值,它一般是一个数据的本身,不能取到本身的地址,包括字面值常量、数值常量等(右值只能放到=
右边)
具体分析
- 左值最常见的情况如函数和数据成员的名字
- 右值是没有标识符、不可以取地址的表达式,一般也称为临时对象
- 比如
a = b + c
,可以通过&a
来取a的地址但不可以&(b + c)
来取(b + c)的地址,这里的a就是左值,而(b + c)就是右值
C中的指针
这里先简单引入一下C中的指针以及其特点
一般类型的指针T*
这里的T
指的是任何一种类型,比如int*
指的就是int
类型的指针,它指向一个相应类型的对象,存储的内容就是其地址
这个类型也包括函数类型,当对应的类型是函数时,它也就被称为函数指针
指针数组
指针数组首先是一个数组,其里面存储的变量是指针,一般的数组定义时如int a[]
,对于指针类型也一样,定义一组指针数组形如T* a[]
数组指针
数组指针与指针数组是相反的概念,数组指针首先是一个指针,只是这个指针指向的类型是数组,比如T(*b)[]
其实可以理解为:
[]
的优先级比*
高
const与指针
const与指针真的爱恨纠缠,const和pointer的前后顺序不同就会导致其表达的意思不同,总体上分为两类:
- const pointer:常量指针,形式为
const T* ptr
,常量指针表示的是这个指针指向的是一个常量,即指向的对象本身不可以修改,但指针可以更换地址(也即可以更换指向的对象) - pointer to const:指针常量,形式为
T* const ptr
,指针常量表示的是这个指针是一个常量,即指针所存储的地址不可以改变(指向的对象不可以变更),但指向的对象本身可以更改其值
已经在上面的介绍中透露出来了一种速记方式,即按照
const
与*
出现的顺序来判断,const
在前就先念常量,*
在前就先念指针,同时按照顺序去理解,常量指针——类似于「字符串指针」、「数组指针」即它指向的是常量;而指针常量——则直接按照顺序读即可「指针是常量」。这里个人推荐尽可能避免使用指针常量,因为它本身确实有些反人类,在代码中会很容易降低可读性,除非必须使用,否则应当避免
指向指针的指针
即一个指针,它存储的地址所对应的对象还是一个地址,形如T** ptr
|
|
野指针与悬空指针
- 野指针是未初始化的指针,这种指针内存储的地址值是未定义的,会存在两种情况:
- 访问的地址是无效的:会报错,进程会终止
- 访问的地址是有效的:但并不知道访问的是哪里的地址,造成程序混乱
- 解决办法:只要定义一个指针就要对其进行初始化,比如直接赋值为
nullptr
,在具体使用时再对其赋实际的地址
- 悬空指针是指针所指的对象已被释放掉的指针,对其访问也会存在与野指针相似的两种情况
- 解决办法:当析构对象的时候,将指向其的指针设置为
nullptr
,当然这样子并不一定总是可靠,最好还是通过RAII的方式来管理指针,当析构函数执行时自动执行设置为nullptr
的行为,比如使用智能指针
- 解决办法:当析构对象的时候,将指向其的指针设置为
⚠️:这里给到的提示就是当用指针进行间接访问时,一定要非常非常小心,确保其地址有意义再去使用
❗️:另外,历史代码中一般使用
NULL
来表示空指针,但这个本质上是0,是一个数字,仍然存在着一定的问题,因此在C++11中推出了nullptr
表示空指针,nullptr
无任何实际含义,只表示指针为空,现代C++程序应尽可能使用nullptr
C++的资源管理方式——RAII
RAII(Resource Acquisition Is Initialization):
- C++所特有的资源管理方式(部分语言如D、Rust也采纳了RAII),主流语言中唯一一个依赖RAII来做资源管理的
- RAII依托于栈和析构函数,来对所有的资源——包括堆内存进行管理。通过使用RAII,使得C++不需要类似于JAVA的垃圾回收机制也可以有效的管理内存分配释放问题。
- RAII比较成熟的代表:智能指针(unique_ptr、shared_ptr)
内存泄漏
-
什么是内存泄漏:
程序中通过动态分配(
new
)的堆内存由于某种原因导致程序未释放或无法释放相应资源而导致的内存资源浪费的情况称为内存泄漏,内存泄漏会导致程序运行期间可用内存的减少,严重者可以导致程序的运行速度减缓甚至崩溃 -
发生原因和排查方法:
- 内存泄漏主要发生在堆内存分配方式中,即“配置了内存后,所有指向该内存的指针都遗失了”,没有垃圾回收机制时这块内存就再也无法归还给系统
- 内存泄漏属于程序运行的问题,无法在编译期间排查,只能通过代码检查以及运行期间内存检测工具进行诊断和判别
-
解决办法:通过RAII对内存进行管理,一个类构造的时候进行内存申请,在析构的时候进行内存释放,由于一个变量有其作用域,当离开作用域范围时会自动调用析构函数,也就会自动进行内存释放了(典型的做法是使用智能指针来对其进行管理)
智能指针
比普通指针更安全的解决方案
之前介绍了普通指针可能会出现的问题,对于这些问题,现代C++一般有两种典型方案:
- 使用更安全的指针——智能指针
- 不使用指针,用更安全的方式——使用引用的方式
C++中的智能指针
C++目前一共推出了共四种智能指针,分别是unique_ptr
、shared_ptr
、weak_ptr
以及已经在C++11中被弃用(deprecated)的auto_ptr
(在C++17中已经正式被删除)
auto_ptr
首先来看一下auto_ptr
,它是最早的智能指针,可以比较容易的看出来智能指针的作用与思想,后续的智能指针本质上都是将auto_ptr
进行完善与延伸。
-
基本特性:首先
auto_ptr
使用方法跟指针一样,内部也相当于存储着一个地址,但对于new expression
获得的对象不需要像普通的new
的对象一样delete
去销毁对象而是在auto_ptr
对象销毁时,它所管理的对象也会被自动delete
掉,这里还是比较抽象的,举一个简单的例子:在一个函数片段中如果需要new
一个对象,那么使用普通指针和智能指针的表现如下:1 2 3 4 5 6 7 8 9 10 11
// 普通指针 if (xxx) { test_class* ptr = new test_class(); ... delete ptr; } // 智能指针 if (xxx) { auto_ptr<test_class> ptr2 (new test_class()); ... }
可以看出来,当使用普通指针的时候,在离开作用域之前需要先delete,而使用智能指针则不用,这是因为智能指针离开作用域时会被销毁,而智能指针被销毁时会自动delete掉它的对象。
-
所有权转移:如果将
auto_ptr
传递给另外的智能指针,原来的指针就不再拥有这个对象了,在拷贝/赋值的过程中,会剥夺指针对原对象的内存都控制权,控制权转交给新对象,再将原对象的指针置为nullptr
,为了更好展示这个特点以及其可能存在的隐患,看一下下面这个例子1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
#include <iostream> #include <string> #include <memory> using namespace std; class Test { public: explicit Test(string str) : str(std::move(str)) { printf("构造成功\n"); } ~Test() { printf("析构成功\n"); } public: void print() { cout << str << endl; } private: string str; }; int main() { auto_ptr<Test> strs[3] = { auto_ptr<Test>(new Test("C")), auto_ptr<Test>(new Test("C++")), auto_ptr<Test>(new Test("Java")) }; cout << "------first loop------" << endl; for (auto& str_ptr : strs) { str_ptr->print(); } strs[1]->print(); cout << "------second loop------" << endl; for (auto str_ptr : strs) { str_ptr->print(); } strs[1]->print(); printf("运行结束\n"); return 0; }
运行结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
构造成功 构造成功 构造成功 ------first loop------ C C++ Java C++ ------second loop------ C 析构成功 C++ 析构成功 Java 析构成功 进程已结束,退出代码139 (interrupted by signal 11: SIGSEGV)
可以看到,first loop可以打印出
C++
,而second loop并没有能够打印出C++
,这个就是因为所有权的转移,第一个循环中使用的是引用的方式,因此并没有发生所有权的转移,因此循环结束再次访问没有问题,而第二个循环使用的是值拷贝的方式,这就导致了每一次使用都会使得之前的auto_ptr
损失掉自己的所有权,而每一轮循环又会因为这个问题导致new
的对象被直接释放掉,进而在第二次循环结束的时候再去访问这个指针,导致了crash被信号中断了,这就意味着auto_ptr
其实并不安全。
unique_ptr
在刚刚的auto_ptr
中,智能指针与对象有着强耦合关系,如果出现了拷贝智能指针就很有可能会出现之前所讲述的问题,并不想释放掉这个元素但在使用过程中很可能在没有察觉的过程中就完成了释放,导致后续程序错误。为了改进auto_ptr
也就提出了unique_ptr
。
-
unique_ptr
的所有权是专属所有权,所以unique_ptr管理的内存,智能被一个对象所持有,同时不支持拷贝和赋值 -
移动语义:
unique_ptr
禁止了拷贝语义,但有时也需要能够转移所有权,于是提供了移动语义,也即可以使用std::move()
语句进行转移所有权。接下来具体看一下,首先同样是之前的例子,将auto_ptr
更换为unique_ptr
:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
#include <iostream> #include <string> #include <memory> using namespace std; class Test { public: explicit Test(string str) : str(std::move(str)) { printf("构造成功\n"); } ~Test() { printf("析构成功\n"); } public: void print() { cout << str << endl; } private: string str; }; int main() { unique_ptr<Test> strs[3] = { unique_ptr<Test>(new Test("C")), unique_ptr<Test>(new Test("C++")), unique_ptr<Test>(new Test("Java")) }; cout << "------first loop------" << endl; for (auto& str_ptr : strs) { str_ptr->print(); } strs[1]->print(); cout << "------second loop------" << endl; for (auto str_ptr : strs) { str_ptr->print(); } strs[1]->print(); printf("运行结束\n"); return 0; }
如果这个时候进行编译,会发现程序编译会报错,报错内容在
for (auto str_ptr : strs)
,报错信息为:Call to implicitly-deleted copy constructor of 'unique_ptr<Test>'
,也就是说unique_ptr
不支持通过拷贝来构造,通过这种方式就不可以继续使用之前的方式来进行访问了,如果确定要移动走所有权,那么就要显示的使用std::move()
进行转移,代码如下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
#include <iostream> #include <string> #include <memory> using namespace std; class Test { public: explicit Test(string str) : str(std::move(str)) { printf("构造成功\n"); } ~Test() { printf("析构成功\n"); } public: void print() { cout << str << endl; } private: string str; }; int main() { unique_ptr<Test> strs[3] = { unique_ptr<Test>(new Test("C")), unique_ptr<Test>(new Test("C++")), unique_ptr<Test>(new Test("Java")) }; cout << "------first loop------" << endl; for (auto& str_ptr : strs) { str_ptr->print(); } strs[1]->print(); cout << "------second loop------" << endl; for (auto& str_ptr : strs) { auto tmp = std::move(str_ptr); tmp->print(); } if (strs[1] != nullptr) { strs[1]->print(); } printf("运行结束\n"); return 0; }
通过明确的移动语义移动走所有权,然后进行使用,析构,当使用明确的移动语义时就可以避免程序员的失误导致对象被释放。另外,
unique_ptr
也支持了与nullptr
进行比较,这一点也是auto_ptr
没有的,相比之下也就更安全,另外,在C++11中更加提倡使用工厂模式的工厂函数来生产智能指针,因此在第一段生成unique_ptr
的函数那里,将代码更改为:1 2 3 4 5
unique_ptr<Test> strs[3] = { std::make_unique<Test>("C"), std::make_unique<Test>("C++"), std::make_unique<Test>("Java") };
常用的
unique_ptr
方法:ptr.get()
:用于获取原始指针,因为历史的包袱问题,很多时候C++并不能完全使用智能指针,对于需要使用C
指针的时候通过get()
方法就可以使用普通指针了
shared_ptr与weak_ptr
不论是auto_ptr
还是unique_ptr
,本质上其实都是一样的,都只允许一个智能指针来访问一个对象,而不允许多个智能指针来访问同一个对象,但如果想像普通指针一样去使用智能指针,那么就必须允许多个智能指针来访问同一个对象,因此也就有了shared_ptr
-
解决方法:通过「引用计数」机制使得多个智能指针可以访问同一个对象,引用计数就是当有一个智能指针指向该对象时,引用计数加1,当一个智能对象被销毁时引用计数减1,当引用计数减小到0时会释放对象资源然后执行对应的析构函数。(当然引入「引用计数」会存在一定的额外开销,但相比较于它的优势可以忽略不计,除非非常明确不使用
shared_ptr
会更好,否则使用shared_ptr
一定不是一件坏事情) -
引用计数的问题——循环引用:
-
起因:如下图所示,两个类中,A中含有类型B的
shared_ptr
,B中含有类型A的shared_ptr
这时使用分别
new
一个类型为A和B的对象A和对象B,并使用ptr A指向对象A,ptrB指向对象B,此时对象A的指针指向对象B,此时对象B的指针指向对象A。当ptrA使用结束时,ptrA释放,对象A的引用计数-1,但因为对象B仍然持有对象A的智能指针,所以引用计数大于0,也就不会析构对象A;接下来ptrB也使用结束,对象B的引用计数-1,但因为之前对象A并没有被析构,因此对象B的引用计数也仍然大于0,因此对象B也没有被析构,这种情况下,ptrA和ptrB都被释放了,但对象A和对象B都没有被析构,同时也没有其他指针指向他们,换言之,已经发生了内存泄漏,因为对象A和对象B不可能再被析构。 -
解决方法:使用
weak_ptr
weak_ptr
被设计用来和shared_ptr
一起使用,它工作在观察者模式,对于它指向的对象是一种弱引用,也就是说它可以获得资源的观测权,像旁观者一样观察资源的使用情况,但是弱引用就代表着它不会增加shared_ptr
的引用计数,当被观察的shared_ptr
失效之后,相应的weak_ptr
也就失效了,如下图所示,虚线代表观察,不增加引用计数
-
代码示例:
-
不使用
weak_ptr
:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
#include <iostream> #include <memory> #include <string> #include <memory> using namespace std; class B; class A { public: A(shared_ptr<B> ptr = nullptr) : ptr(ptr) { printf("A构造完成\n"); } ~A() { printf("A析构完成\n"); } shared_ptr<B> ptr; }; class B { public: B(shared_ptr<A> ptr = nullptr) : ptr(ptr) { printf("B构造完成\n"); } ~B() { printf("B析构完成\n"); } shared_ptr<A> ptr; }; int main() { auto ptrA = make_shared<A>(); auto ptrB = make_shared<B>(ptrA); ptrA->ptr = ptrB; }
此时,ptrA和ptrB出现了相互引用,程序结果为:
1 2
A构造完成 B构造完成
可以看出来并没有发生析构,此时如果将最后一行代码去掉,即ptrA的ptr为nullptr
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
#include <iostream> #include <memory> #include <string> #include <memory> using namespace std; class B; class A { public: A(shared_ptr<B> ptr = nullptr) : ptr(ptr) { printf("A构造完成\n"); } ~A() { printf("A析构完成\n"); } shared_ptr<B> ptr; }; class B { public: B(shared_ptr<A> ptr = nullptr) : ptr(ptr) { printf("B构造完成\n"); } ~B() { printf("B析构完成\n"); } shared_ptr<A> ptr; }; int main() { auto ptrA = make_shared<A>(); auto ptrB = make_shared<B>(ptrA); // ptrA->ptr = ptrB; }
此时结果为:
1 2 3 4
A构造完成 B构造完成 B析构完成 A析构完成
可以推断出来,只要一方不持有另一方的指针,那么就可以正常析构,而这也就是
weak_ptr
的基本原理,同时再赋予其观察者的身份,使得其可以正常使用相对应的指针。 -
使用
weak_ptr
:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
#include <iostream> #include <memory> #include <string> #include <memory> using namespace std; class B; class A { public: A(shared_ptr<B> ptr = nullptr) : ptr(ptr) { printf("A构造完成\n"); } ~A() { printf("A析构完成\n"); } shared_ptr<B> ptr; }; class B { public: B(shared_ptr<A> ptr = nullptr) : ptr(ptr) { printf("B构造完成\n"); } ~B() { printf("B析构完成\n"); } weak_ptr<A> ptr; }; int main() { auto ptrA = make_shared<A>(); auto ptrB = make_shared<B>(ptrA); ptrA->ptr = ptrB; }
这里只需要将B中的ptr指针更改为
weak_ptr
类型即可以完成实验,实验结果如下:1 2 3 4
A构造完成 B构造完成 A析构完成 B析构完成
这里有两个地方要注意:
- 类型B的构造函数其实是运用了隐式转换,另外一方面也可以理解为
weak_ptr
是通过shared_ptr
构造的,它不可以由普通指针构造,要牢记它是一个shared_ptr
的观察者 - 这里实验结果的析构顺序和之前将A的ptr置空的析构顺序是相反的,这里是因为置空时的ptrA被释放时,B中还持有A的智能指针,也就是说引用计数不为0所以一定是先析构B再析构A;而使用
weak_ptr
时正好相反,B中相当于没持有A的指针而A中持有了B的shared_ptr
,所以一定是先析构A再析构B
- 类型B的构造函数其实是运用了隐式转换,另外一方面也可以理解为
-
-
-
shared_ptr
常用函数:-
ptr.use_count()
:返回shared_ptr
当前指向对象的引用计数,还是刚刚的例子,我们来看一下使用weak_ptr
和不使用weak_ptr
在引用计数上的区别:1 2 3 4 5 6
int main() { auto ptrA = make_shared<A>(); auto ptrB = make_shared<B>(ptrA); ptrA->ptr = ptrB; cout << ptrA.use_count() << endl; }
-
不使用
weak_ptr
执行结果:1 2 3 4 5
A构造完成 B构造完成 2 A析构完成 B析构完成
-
使用
weak_ptr
执行结果1 2 3 4 5
A构造完成 B构造完成 1 A析构完成 B析构完成
-
-
shared_ptr
的move
语义:对shared_ptr
进行std::move()
会将这个智能指针的所有内容转移给新指针,同时原指针会被置为nullptr
示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12
int main() { auto ptrA = make_shared<A>(); cout << ptrA.use_count() << endl; auto ptr2 = std::move(ptrA); cout << ptrA.use_count() << endl; cout << ptr2.use_count() << endl; if (ptrA == nullptr) { cout << "ptrA已经被置为nullptr" << endl; } return 0; }
执行结果如下:
1 2 3 4 5 6
A构造完成 1 0 1 ptrA已经被置为nullptr A析构完成
-
shared_ptr
也支持get()
方法,使用作用与weak_ptr
相同
-
C++的引用
引用是什么
引用就是一种特殊的指针,是一种不允许修改的指针。
-
使用指针的坑:
- 野指针
- 悬空指针
- 不知不觉更改了指针的值但却继续使用
-
使用引用解决的问题:
- 不存在空引用
- 必须初始化
- 一个引用永远指向它初始化的那个对象
引用的基本使用
可以认为引用就是指定变量的别名,在使用时可认为引用即是变量本身
最常用的使用场景
输入到一个函数中,需要更改两个对象的值,比如最简单的交换值swap()
程序:
|
|
执行结果:
|
|
两个问题的思考
有了指针为什么还需要引用
JAVA只有引用,而C则只有指针,C++则支持指针和引用混合编程
C++之父Bjarne Stroustrup的解释是:为了支持函数运算符重载
有了引用为什么还需要指针
因为这是C++,一定要去兼容C语言,是一种历史遗留问题,像JAVA就可以直接舍弃C语言,因为它并不需要兼容C语言
简单来说,个人认为指针是历史,跑不掉而且在很多场景下更灵活,而引用则更符合人的直觉,使用起来更加顺畅,所有引用的场景确实都可以通过指针实现,但引用是一种更加便捷的工具,都支持不是C++的缺点,反而是优点,而重点则在程序员们要如何去使用这两样工具