Hi,我是小余。 本文已收录到 GitHub Androider-Planet 中。这儿有 Android 进阶成长常识体系,关注大众号 [小余的自习室] ,在成功的路上不走失!

前语

信任学过C++都知道指针以及引证,C++中运用指针是为了兼容C言语,而运用引证是为了更加遵循面向目标编程思维,今日小余就来和大家聊聊关于C++中指针以及引证。

核算机内存单元内容以及地址

内存由很多内存单元组成,这些内存单元用于寄存各种类型的数据。核算机对每个内存单元都做了编号,这个编号便是内存地址,这个地址决定了内存单元在内存中的位置。 这些内存单元很复杂,人为很难记住,所以这些C++编译器经过变量名来访问这些内存地址。

目录

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

1.指针根本概念:

指针也是一个变量,可是这个变量存储的是别的一个变量的地址。

指针运用*号表明:

char c = ‘a’;
char *pChar = &c;

pChar中存储的是c的地址(&是取地址符),咱们就说pChar是一个指向变量c的指针、

2.C++中的左值和右值

左值是一个用来指明目标的一个表达式。最简略的左值便是变量,之所以叫左值,是由于左值表明一个目标,其可呈现在赋值运算符的左面。

右值表明一个数值但不指明一个目标的表达式或许常量,右值呈现在赋值表达式右边。

左值表达式=右值表达式。

从一个左值中必定能够解分出一个目标的地址。除非该目标是位字段(C言语中一种存储结构,不同于一般结构体的是它在界说成员的时分需求指定成员所占的位数)或许被声明为寄存器存储类。生成左值的运算符包含(下标运算符“[]”和直接运算符“*”)。

对下面界说的c和pChar左值判别:

char c[] = "helloworld";;
char *pChar = c;
表达式 是否是左值 左值判别依据
c 数组变量其实是一个地址常量,是数组的首地址的一个符号常量,常量不是一个具体位置的目标
c[1] 一个数组元素是一个能够解分出具体位置的目标
&c[1] 取数组元素的地址得到的并非一个具有具体位置的目标
pChar 指针变量是一个能够解分出具体位置
*pChar 指针变量指向的地址变量是一个能够解分出具体位置的目标
pChar+1 此加法发生一个新的地址,可是并非一个目标
*pChar+1 此加法发生一个新的算术值,可是并非一个目标
*(pChar + 1) pChar+1后得到的是一个新的地址,这个新的地址下面的内容是一个具体位置的目标

目标能够运用const被声明为常量,此刻就不能坐落赋值运算符的左面,由于赋值运算符的左面需求是一个能够修正的左值。

3.指针中的const关键字的运用

首先看未运用const润饰的状况:

char str[] = "helloworld";
char* pStr = str;
​
char a1 = 'a';
pStr = &a1;//pStr为左值正常赋值
*pStr = 1; //*pStr为左值正常赋值

下面咱们对pChar运用const润饰,首要分三种状况:

  • 1.const放在char前面或许char后边*号前面:
//const放在char前面
char a[] = "helloworld";
const char* pCharA = a;
pCharA = &a1; //正常赋值
*pCharA = 1; //编译报错,表达式有必要为可修正的左值
​
//const放在char后边,*号前面。
char _a[] = "helloworld";
char const * _pCharA = _a;
_pCharA = &a1;//正常赋值
*_pCharA = 1;//编译报错,表达式有必要为可修正的左值

const放在char前面仍是放在char后边*号前面得到的成果是相同的:作为左值的pCharA能够赋值,作为左值的*pCharA编译报错。 继续检查其它两种状况,然后统一剖析成果。

  • 2.const放在*号的后边
char b[] = "helloworld";
char* const pCharB = b;
pCharB = &a1;//编译报错,表达式有必要为可修正的左值
*pCharB = 1; //正常赋值

const放在*号后边,指针的指向不能再改动,可是指针指向的地址的内容能够改动。

  • 3.char前面和*号后边都有const
char c[] = "helloworld";
const char* const pCharC = c;
pCharC = &a1;//编译报错,表达式有必要为可修正的左值
*pCharC = 1;//编译报错,表达式有必要为可修正的左值

const放在*号后边和char前面,指针的指向不能再改动且指针的指向的内容也不能改动。

经过对以上三种状况的剖析:咱们能够得出以下定论const润饰的怎么看哪些被界说为常量?

首先看const的左面,假设左面没有,则看右边,这便是状况1中剖析的状况,不论const是放在char前面仍是char后边(且号前面)都润饰了char,假设放在号后边则阐明润饰的是*号,

那润饰char和润饰号有什么区别呢? 答案在前面例子中现已给出了。

  • 1.润饰char代表当时当时指针指向的内容不行改动,所以在对指针内容pChar做赋值运算时会报:表达式有必要为可修正的左值
  • 2.润饰*号表明当时指针指向不能够改动,假设对指针重定向,就会呈现:表达式有必要为可修正的左值的编译问题。

4.二级指针

指向指针的指针称为二级指针,界说方法如下:

char _a = 'a';
char* pa = &_a;
char** ppa = &pa;

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

操作符具有从右向左的结合性: ppa表达式相当于 ( ppa),从里到外逐层求值。

表达式 表达式的值
_a ‘a’
pa &_a
*pa _a,’a’
ppa &pa
*ppa pa,&_a
**ppa(二级指针) *pa,_a,’a’

5.野指针

指向“垃圾”内存的指针称为野指针。一般有以下三种状况:

  • 1.指针变量没有初始化:这种状况运转时会报错。
  • 2.现已开释不必的指针没有置为NULL.如delete或许free后的指针。
  • 3.指针操作逾越了变量的效果域

根绝野指针主张:

没有初始化的,不必的或许超出规模的指针请把指针置为NULL;

6.指针的根本操作

1.pChar, pChar+1,*(pChar+1)表达式左右值运算:

仍是看前面的事例:

char c[] = "helloworld";;
char *pChar = c;

下面咱们来看下:pChar, pChar+1,*(pChar+1)这三个表达式分别作为左值和右值的操作:

char c[] = "helloworld";;
char* pChar = c;//将"helloworld"字符数组的首地址赋值给pChar
*pChar = 'a';//将a赋值为数组c首地址上的内容,此刻c将变为“aelloworld”
char c1 = *pChar;//将pChar指针也便是c首地址上的值赋值给c1,此刻c1 = ‘a’;
char c2 = *pChar + 1;//将*pChar + 1也便是c的首地址上的内容字符‘a’+1得到的是ASCII的字符‘b’,所以c2位’b‘
//*pChar + 1 = 'b';//编译报错,*pChar + 1不是一个目标,仅仅一个常量,不能作为左值
*(pChar + 1) = 'c';//将字符’c‘赋值给pChar+1的地址上,由于此刻c为字符数组,所以+1,仅仅移动一个位置,也便是c首地址的下一个地址内容置为'c' ,也便是字符数组c变为“aclloworld”
char c3 = *(pChar + 1);//将c首地址的下一个地址内容’c‘赋值给c3,所以此刻c3 = ’c‘;

运用监视器检查成果如下:

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

能够看到监视器和代码剖析进程是相呼应的,这儿有个留意点

  • 1.此刻咱们运用的char字符数组,pChar(0x003cfab4)和pChar+1(0x003cfab5)相差的是一个字节的位置,监视器中也能够看到一个.可是假设咱们运用int数组来测试

    int c[] = {1,2,3};
    int* pChar = c;
    

    再来看下pChar和pChar+1:pChar地址:0x0026f618 pChar+1地址:0x0026f61c 相差了四个字节。这就有意思了,很多人就说了pChar+1按逻辑来说不便是在pChar的地址上移动一格么? 蒙圈了吧..

为了回答这个问题,咱们首先来说下C言语的指针运算的两种方法

  • 方法1:指针 整数

这种核算出来的值会根据指针的数据类型进行了拉伸,假定指针值为0x00000001,指针类型为int类型,整数为n,则核算出来的成果为0x00000001+ n*4,这儿的4是由于指针类型为int,假设是char,则为1. 所以上面咱们运用char指针+1,地址移动了1位,而用int指针+1,地址移动了4位,便是这个道理。

刚好经过这个事例,咱们延伸到别的一种方法

  • 方法2:指针 – 指针

指针减法的值是两个指针在内存中的间隔(等于两个指针内存位置差除以该元素数据类型的大小),和加法是相似的道理。

咱们来看一个事例:

struct tree
{
    int height;
    int age;
    char tag;
};
​
char buffer[128] = { 0 };
char* tmp_ptr = buffer;
struct tree* t_ptr = (struct tree*)tmp_ptr;
char* t_ptr_new = NULL;
t_ptr_new = (char*)(t_ptr + 1);
​
printf("t_ptr_new point to buffer[%ld]\n", t_ptr_new - tmp_ptr);
​
输出成果:t_ptr_new point to buffer[12]

这个12是怎么来的呢?

这就要说到数据对齐的概念了。

数据对齐

许多核算机体系对根本的数据类型的合法地址做了一些约束。要求某种类型目标的地址有必要是某个值(通常为2、4、8)的倍数。 对齐原则是:

任何占用K字节空间大小的根本目标,其地址有必要是K的倍数。关于32位体系来说默许对齐方法便是4个字节。

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

所以关于上面的结构体:

struct tree
{
    int height;
    int age;
    char tag;
};

数据对齐方法如下:

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

能够看到该结构体在内存中给的布局便是12个字节,所以最终在指针+1后得到的便是12个字节的内存间隔,假定此刻运用的是int类型指针会是多少呢?按前面公式猜测是12/4 = 3;

int buffer[128] = { 0 };
int* tmp_ptr = buffer;
struct tree* t_ptr = (struct tree*)tmp_ptr;
int* t_ptr_new = NULL;
t_ptr_new = (int*)(t_ptr + 1);
​
printf("t_ptr_new point to buffer[%ld]\n", t_ptr_new - tmp_ptr);

成果也确实是:3

t_ptr_new point to buffer[3]

留意结构体对齐的几种常见面试场景:

  • 1.假定前后两个数据类型占用的字节数小于对齐数,则会合并到一行中,如下所示:

    struct tree
    {
        char height;
        short age;
        int tag;
    };
    

    该结构体内存布局:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

  • 2.结构体的内存字节数要是最大子元素的整数倍。如下面结构体:

    struct tree
    {
        char height;
        short age;
        double tag;
    };
    

    由于最后一个是double类型,double在内存中占用了8个字节,所以整个结构体的内存也要是8的整数倍, 根据对齐规矩以及最大子元素整数倍核算内存布局以及大小。上面结构体布局应该是下面这种:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    运用的是16个字节来表明。 从上面两个结构体事例能够得到以下定论:

1.在界说结构体的时分尽量将两个小的放在一块如char和short,这样内存在做数据对齐的时分,会节约一些内存空间,也是常用功能优化常识点

2.内存对齐规矩要求对齐的倍数是结构体中占用最大字节数的那个类型的倍数,如double,则要求以8的倍数对齐。

  • 数据对齐还能够运用如下编译指令进行更改:

    #pragma pack(1)
    struct tree
    {
        int height;
        int age;
        char tag;
    };
    #pragma pack
    

    pragma pack的首要效果便是改动编译器的内存对齐方法。在不运用这条指令的状况下,编译器采纳默许方法对齐。这两条编译预处理指令,使得在这之间界说的结构体按照1字节方法对齐。在本例中,运用这两条指令的效果是,编译器不会在结构体尾部填充空间了。

    此刻上面的结构体+1得到的应该便是9(2个int加一个char的结构)了,读者能够自行试试看,代码就补贴出来了、

记住对指针加法和减法操作都是按数据类型单元来核算的+1代表+一个数据单元的内存空间,-1表明缩小一个数据单元的内存空间,1个数据单元表明当时数据类型占用的字节数,如char占一个字节,int占用4个字节等。

好了关于指针的加减法运转就讲到这儿。

2.指针自增和自减运算符的左值和右值概念:

大家都知道自增运算符包含前自增(++cp)和后自增(cp++),前自减(–cp)和后自减(cp–)

  • ++cp,–cp:表明先加或许先减再运算
  • cp++,–cp:表明先进行运算然后再自增或许自减。

下面咱们来看几个事例:

char c4[] = "abcdefg"; //c4->0x0022f53c
char* pc = c4;//pc指向c4 ->0x0022f53c
​
char* pc1 = pc++;//pc1等于pc也便是c4 ->0x0022f53c,尔后pc变为pc+1 也便是c4+1:  = 0x0022f53d
char* pc2 = ++pc; //pc2等于pc+1=0x0022f53e 尔后pc变为:c4+2: = 0x0022f53e
​
char* pc3 = --pc;//pc3等于pc-1 = 0x0022f53d 尔后 pc变为c4+1: = 0x0022f53d
char* pc4 = pc--;//pc4等于pc = 0x0022f53d ,尔后pc变为c4: = 0x0022f53c
​
(++pc) = pc2;//这儿的操作能够看做两步,1.pc = pc+1 2.pc = pc2,所以成果pc赋值为pc2:0x0022f53e
(--pc) = pc2;//和自增操作移植,分为两部 1.pc = pc-1 2.pc = pc2所以成果pc赋值为pc2:0x0022f53e
//pc-- = pc2;//会报错
//pc++ = pc2;//会报错

运转成果:

【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

能够看到自增运算符作为右值时,会按规律获取关于指针下面的值。可是在作为左值的状况下

  • 1.前自增++cp,能够理解为cp=cp+1,所以其回来的是引证类型的变量pc,依然能够作为左值运用,仅仅pc做了两次赋值罢了,前自减也是相同逻辑。
  • 2.后自增cp++,看网上说其回来是一个非引证类型的表达式无法获取到实在地址,所以不能够作为左值运用。假设你有更好的解说,欢迎纠正哦。

尽管后自增不能作为左值运用,可是其地址中的内容确能够作为左值运用,如下:

*(++pc) = c4[0];
*(--pc) = c4[1];
*(pc++) = c4[2];
*(pc--) = c4[2];

由于地址下面的内容是能够获取到具体地址的目标的。

3.关于++++,—-等运算符的解说

事例剖析:

int a = 10, b = 20, c;
c = a+++b;
c = a++++b;

如何剖析呢?咱们运用 “贪心法” ,便是取+号的时分,假设后边再取一个+号还能够作为运算符如++,则就继续取,如a+++b,咱们能够取前面两个+变为a++ 再去+b,而不是取a+ ++b,这个便是贪心算法,所以a+++b成果为30,可是a变为了11.而a++++b,按贪心算法是得不到正确的表达式的:如取前面的a++,则后边便是++b,这两个组合在一块并不是一个合法的表达式。

不过一般为了稳定性,都会运用括号将优先级括号起来。

智能指针与引证

运用指针是一个存在必定危险的行为,或许存在空指针和野指针等状况,还或许形成严峻的内存泄露,需求在内存不再运用的时分及时运用delete删除指针引证并置为NULL;

可是指针又是一个非常高效,有没有更安全的方法去运用指针呢C++中两种典型计划: 1.运用智能指针 2.运用引证

1.智能指针

C++中四种常见的指针:unique_ptr,shared_ptr,weak_ptr,以及C++中现已抛弃的auto_ptr

下面咱们根据目标所有权以及目标生命周期分别对这4类进行解说:

  • 1.auto_ptr

    auto_ptr要求一起只能有一个指针指向同一个目标,假设有别的一个指针引证了目标,则当时指针引证会被强制抹除置为null_ptr。 模型如下:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    事例剖析:

    auto_ptr<int> ptr1(new int(10));
    cout << *ptr1 << endl;
    ​
    auto_ptr<int> ptr2 = ptr1;
    cout << *ptr2 << endl;
    cout << *ptr1 << endl;
    

    运转成果:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    能够看到在ptr2和ptr1指向同一块地址后,ptr1变为了nullPtr,这种状况是一种强制性的,ptr1是不行预知的,或许导致一些很严峻的bug,这也是在C++ 11后被抛弃的原因之一

为了防止这种强制退出的问题,于是推出了unique_ptr

  • 2.unique_ptr

    unique_ptr制止用户运用仿制和赋值,其只能被一个目标持有,拥有专属运用权。 可是假设其他指针需求运用怎么办呢?

    运用move进行所有权转移,这种方法让开发者能够留意到该指针move后,原指针会置为nullptr,不会和auto_ptr相同,开发者或许是无感知的。

    模型如下:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    事例:

    unique_ptr<int> ptr1(new int(10));
    //unique_ptr<int> ptr2 = ptr1;error不能赋值
    //unique_ptr<int> ptr2(ptr1); //error不能拷贝
    unique_ptr<int> ptr2 = std::move(ptr1);cout << "ptr1:" << (ptr1 != nullptr ? *ptr1 : -1) << endl;
    cout << "ptr2:" << (ptr2 != nullptr ? *ptr2 : -1) << endl;
    运转成果:
    ptr1:-1
    ptr2:10
    

    尽管unique_ptr能够在必定程度上让开发者能够知道或许发生的内存更改危险,可是假设确实是需求有多个指针能够访问同一块内存怎么办呢?

  • 3.shared_ptr

    为了处理auto_ptr以及unique_ptr的局限性,C++又推出了shared_ptr。 shared_ptr运用一个引证计数器,相似java中目标垃圾的定位方法,假设有一个指针引证某块内存,则引证计数+1,开释计数-1.假设引证计数为0,则阐明这块内存能够开释了。 模型如下:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    引证计数让咱们的能够有多个指针拥有运用权,可是这种方法仍是会有危险的,假设一个指针对指向的内存区域进行了更改,则其他指针期望是原来的值,那这就会出一些分歧了,还有个便是引证计数的方法或许会触发循环引证

    循环引证模型如下:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    事例剖析:

    class A {
     public:
        shared_ptr<B> pa;
    ​
        ~A() {
            cout << "~A" << endl;
        }
    ​
    };
    class B {
    public:
        shared_ptr<A> pb;
    ​
        ~B() {
            cout << "~B" << endl;
        }
    ​
    };
    void sharedPtr() {
        shared_ptr<A> a(new A());
        shared_ptr<B> b(new B());
        cout << "第一次引证:" << endl;
        cout <<"计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
        a->pa = b;
        b->pb = a;
        cout << "第2次引证:" << endl;
        cout << "计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
    }
    运转成果:
    第一次引证:
    计数a:1
    计数b:1
    第2次引证:
    计数a:2
    计数b:2
    

    能够看到运转成果并没有打印出对应的析构函数,也便是没被开释。

为什么退出了指针效果域仍是没开释内存?

指针a和指针b是栈上的,当退出他们的效果域后,引证计数会-1,可是其计数器数是2,所以还不为0,也便是不能被开释。你不开释我,我也不开释你,咱两耗着呗。

为了处理这种问题,C++又推出了weak_ptr。真是绝了。。

  • 4.weak_ptr weak_ptr运用一种观察者模式进行订阅,与shared_ptr共同合作运用。意在打破循环引证的状况。 模型如下:

    【重学C/C++系列(三)】:这一次彻底搞懂指针和引用

    事例:这儿只将shared_ptr的事例小改下

    class B;
      class A {
      public:
        shared_ptr<B> pa;
    ​
        ~A() {
            cout << "~A" << endl;
        }
    ​
    };
    class B {
    public:
        weak_ptr<A> pb;
    ​
        ~B() {
            cout << "~B" << endl;
        }
    ​
    };
    void sharedPtr() {
        shared_ptr<A> a(new A());
        shared_ptr<B> b(new B());
        cout << "第一次引证:" << endl;
        cout <<"计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
        a->pa = b;
        b->pb = a;
        cout << "第2次引证:" << endl;
        cout << "计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
    }
    运转成果:
    第一次引证:
    计数a->pa:0
    计数a:1
    计数b:1
    第2次引证:
    计数a->pa:1
    计数a:1
    计数b:2
    ~A
    ~B
    

    能够看到正常打印出了A和B析构函数

    这是由于weak_ptr对shared_ptr引证的时分,不会改动计数器的值,所以关于a来说,其只被引证了一次,在跳出效果域后,a的计数器会-1变为0,所以能够顺利开释,a开释后,由于b对a的订阅效果,也会调用析构函数开释内存、

2.引证

C++中引证其实便是对一个已知变量取一个别号。便是你的实在名字和奶名相同,其实都是指向你自己。 运用“&”符号来表明一个变量的引证。

int a = 12;
int& _a = a;

引证特性

  • 1.引证的不行变性 这儿说的不是引证不能够赋值,而是它引证的这个目标这个操作,是不行更改的, 一个引证在初始化为一个变量的别号之后,就现已和这个变量进行了绑定,不会再引证其他目标,也便是引证的不行变性,当对引证进行赋值其实对引证的目标的赋值。 事例剖析:

    int a = 10;
    int& rename_a = a;
    rename_a = 20;
    cout << "a:" << a << endl;
    cout << "rename_a:" << rename_a << endl;运转成果:
    a:20
    rename_a:20
    

    能够看到再对别号进行赋值的时分,被引证变量a值也改动了。

  • 2.一个变量能够多个别号 一个变量能够有多个引证,可通俗理解为一个人能够有多个昵称。

引证运用场景

1.做参数
void swap(int& a, int& b) {
    int temp = a;
    a = b;
    b = temp;
}
int main()
{
  int a = 1, b = 2;
  swap(a,b);
  assert(a == 2 && b == 1);
  return 1;
}

如上代码所示运用引证作为形参,在函数被调用时实质便是传递了实参,这儿和指针有点相似,或许说和java中的参数传递类型,传递的是一个具体的目标引证。

这儿提下关于C++中传参的两个主张:

  • 1.关于内置根底数据类型(如int,char等),在函数中运用传值更高效。
  • 2.假设是C++中自界说类,在函数中传递运用引证或许指针传递效率更高。
2.做回来值

事例剖析:

int& Add(int num1, int num2)
{
    int sum = num1 + num2;
    return sum;
}
​
int main()
{
    int& ret = Add(1, 2); //int& ret = Add(1, 2);
    cout << "hello wait" << endl;
    cout << ret << endl;
    return 0;
}

下面代码一看没啥问题,运转下看看:

hello wait
265525640

竟然回来的不是3,而是一串随机数。这是什么原因形成的呢?

咱们留意到回来的sum在add函数中是一个处于函数效果域规模的临时变量,当add方法完毕后,就超过了sum的效果域规模,此刻sum在内存中的值就会被更改,回来的临时引证也会被更改,所以看到的是一串随机数,而不是实践的3.

在运用引证做回来值时,运用全局变量或许静态变量是不会呈现这种问题。

于是,关于引证作为回来值有如下的运用规矩若回来目标在函数调用完毕后还会继续存在则能够运用引证回来,如静态变量,反之则不宜运用。

两个混沌问题

  • 问题1:有了引证为什么还要指针?

    C++之父Stroustrup给的答案:为了兼容C言语

  • 问题2:有了指针为什么还要引证?

    由于C++是一个面向目标的编程方法,而指针是C言语中的语法不支持函数运算符重载,运用了引证后就能够支持函数运算符重载了。

好了,关于C++中的引证和指针就讲到这儿了

总结

本篇文章对C++中的指针以及引证做了较为详细的解说。 首要内容如下:

  • 1.指针的根本概念
  • 2.指针的左值和右值概念
  • 3.const在指针中的运用
  • 4.解说了一些常用指针:如二级指针,野指针等
  • 5、指针的常见算法,加法,减法等,顺带解说了下C++中的类型在内存中的布局、
  • 6.智能指针模型与实例解说
  • 7.引证的概念以及和指针的区别。

信任你看完这篇文章,会对C++中的指针以及引证会有一个全新的知道,我是小余,欢迎点赞加关注,咱们下期见。