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

3,649 阅读19分钟

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

前言

相信学过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;

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

表达式表达式的值
_a‘a’
pa&_a
*pa_a,'a'
ppa&pa
*ppapa,&_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‘;

使用监视器查看结果如下:

可以看到监视器和代码分析过程是相呼应的,这里有个注意点

  • 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个字节。

所以对于上面的结构体:

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

数据对齐方式如下:

可以看到该结构体在内存中给的布局就是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;
    };
    

    该结构体内存布局:

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

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

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

    使用的是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;//会报错

运行结果:

可以看到自增运算符作为右值时,会按规律获取对于指针下面的值。但是在作为左值的情况下

  • 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。 模型如下:

    案例分析:

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

    运行结果:

    可以看到在ptr2和ptr1指向同一块地址后,ptr1变为了nullPtr,这种情况是一种强制性的,ptr1是不可预知的,可能导致一些很严重的bug,这也是在C++ 11后被废弃的原因之一

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

  • 2.unique_ptr

    unique_ptr禁止用户使用复制和赋值,其只能被一个对象持有,拥有专属使用权。 但是如果其他指针需要使用怎么办呢?

    使用move进行所有权转移,这种方式让开发者可以注意到该指针move后,原指针会置为nullptr,不会和auto_ptr一样,开发者可能是无感知的。

    模型如下:

    案例:

    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,则说明这块内存可以释放了。 模型如下:

    引用计数让我们的可以有多个指针拥有使用权,但是这种方式还是会有风险的,假如一个指针对指向的内存区域进行了更改,则其他指针希望是原来的值,那这就会出一些分歧了,还有个就是引用计数的方式可能会触发循环引用

    循环引用模型如下:

    案例分析:

    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 << "第二次引用:" << endl;
        cout << "计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
    }
    运行结果:
    第一次引用:
    计数a:1
    计数b:1
    第二次引用:
    计数a:2
    计数b:2
    

    可以看到运行结果并没有打印出对应的析构函数,也就是没被释放。

为什么退出了指针作用域还是没释放内存?

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

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

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

    案例:这里只将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 << "第二次引用:" << endl;
        cout << "计数a:" << a.use_count() << endl;
        cout << "计数b:" << b.use_count() << endl;
    }
    运行结果:
    第一次引用:
    计数a->pa:0
    计数a:1
    计数b:1
    第二次引用:
    计数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++中的指针以及引用会有一个全新的认识,我是小余,欢迎点赞加关注,我们下期见。