• 【C++】数组和指针的爱恨情仇。。。


    前言

      最近研究C++中的数组怎么作为参数传入到函数中,自然而然引出了这篇博客的标题,即数组和指针的爱恨情仇。。。

    1 数组和指针都是啥?

      想要知道数组和指针交织在一起会摩擦出怎样的火花,那就先要了解数组和指针各自的语法和特点。这里不想写一些非常专业的定义(咱也没看),就是针对一些常用的用法做一个直观的认识
      首先是数组,实际上就是一些相同数据类型的变量放在一起,就构成了一个数组,这里的重点是“一起”,即它们的存储地址是连续的这也是为什么可以通过指针来依次访问)。此外,就是在定义数组时一定要用常量指定一个数组的大小,即包含多少个元素。
      其次是指针,一般常用的语法为int *p = NULL,表示定义一个指针变量指针变量一定要养成定义即初始化的好习惯!),它存储的内容是一个int类型变量对应的地址,那如果直接输出这个地址(即指针变量的值)呢?一般会得到一个比较大的数,它对应的内存中的某个位置:

    #include 
    using namespace std;
    int main()
    {
    	int a = 10;
    	int *p = &a;
    	cout << "p=" << p << endl << "*p=" << *p;
    	return 0;
    }
    //输出:
    //p=0x61fe14
    //*p=10
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

      此外,最为重要的是,指针指向的数据类型其实就决定了这个指针的 “步长”,即运行p++++p后它移动的内存大小。举个例子:

    #include 
    using namespace std;
    int main()
    {
    	int a = 10; double b = 1.0;
    	int *p1 = &a; double *p2 = &b;
    	cout << "p1 = " << p1 << endl << "p1+1 = " << (++p1) << endl;
    	cout << "p2 = " << p2 << endl << "p2+1 = " << (++p2) << endl;
        cout << "size of pointer:" << sizeof(p1) <<" "<< sizeof(p2) << endl;
    	return 0;
    }
    //输出:
    //p1 = 0x61fe0c
    //p1+1 = 0x61fe10 //int类型4个字节
    //p2 = 0x61fe00
    //p2+1 = 0x61fe08 //double类型8个字节
    //size of pointer:8 8 //指针都是8个字节(可能是64位系统?)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    2 用指针访问数组

      一般定义好一个数组后,最常用的访问方式是下标访问数组中的元素,但这种方式要保证有整个数组,才能够通过下标来访问,而如果用指针来访问,只需要一个指针变量即可(顶多加上一个数组长度),不管是作为返回值还是参数都十分方便,效率更高。
      首先说一个众所周知的知识点:一维数组的名称实际上就是一个指针变量,指向的是数组首元素地址

    #include 
    using namespace std;
    int main()
    {
    	int a[5] = {1,2,3,4,5};
    	for(int i=0; i<5; i++)
    		cout << *(a+i) << " "; //等价于 cout << a[i]
    	return 0;
    }
    //输出:1 2 3 4 5
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    需要注意,用*访问和用[]访问是等价的。

      那么问题来了,一维数组的数组名是一个指针,那二维数组的数组名是不是指针的指针(二级指针)呢?来看一下试验结果:

    #include 
    using namespace std;
    int main()
    {
    	int a[2][2] = {0};
        **a = 1;
        cout << a[0][0] << endl; //输出的是刚赋值的1
        int **p = a; //会报错: "cannot convert 'int (*)[2]' to 'int**' in initialization"
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

      因此,对于二维数组,虽然可以使用**去访问其中的元素,但是它并不是一个二维指针,其实也很容易理解,从前面说的步长的概念来看,二级指针中的第二级指针,由于指针变量为8个字节,所以所有的二级指针移动时(+1)都是移动8个字节,和数组数据类型无关,因此无法实现逐个访问数组元素。然后从上面的报错可以看出二维数组的真实数据类型为int (*)[2],这是啥意思呢?埋个伏笔,请看下文分解。

    3 数组指针和指针数组

      提到数组和指针,就不得不提二者的结合体:数组指针和指针数组了,复杂度一下子上了一个台阶,堪称1+1>2。

    事先说明,这里探讨的数组指针和指针数组,其“数组”都是指一维数组,但其实更高维的数组也可以类推。

    3.1 基本概念

      首先是区分这两者的概念,最简单的方式就是看后两个字——到底是指针还是数组
      所谓指针数组,首先是一个数组,其次是这个数组的每个元素都是一个指针,都占8个字节。
      所谓数组指针,首先是一个指针,其次是这个指针指向的内容是一个数组,注意:这里的指针指向的是数组首地址,不是数组首元素地址,这两个地址值是相同的(即指针变量的取值),但是当赋予同一个指针时,各自的步长不同,具体可以看一下下面这个例子。

    #include 
    using namespace std;
    int main()
    {
    	int a[2] = {0};
        int (*p1)[2] = &a; //数组指针,指向数组首地址
        cout << p1 << " " << 1+p1 << endl;
    	int *p2 = *p1; //普通指针,指向的是数组首元素地址
    	cout << p2 << " " << 1+p2 << endl;
    	return 0;
    }
    //输出: 0x61fe08 0x61fe10 //步长为8个字节
    // 0x61fe08 0x61fe0c //步长为4个字节
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

      可以发现,对于指向数组首元素地址的指针,它的步长就是一个数组元素所占字节大小;而对于指向数组首地址的数组指针,它的步长是整个数组所占字节大小,而且这两者之间还存在一个关系,即数组首地址 = &(数组首元素地址)或者数组首元素地址 = *(数组首地址),记住这个表达式,后面要考。

    3.2 语法

      了解完了基本概念,再来看看其基本语法有什么区别:

    int a=1,b=2;
    int *p[2] = {&a, &b}; //指针数组,长度可指定可不指定(参考一般数组初始化方法)
    
    int A[2] = {1,2};
    int (*p)[2] = &A;  //数组指针,既然是指针,在定义时要初始化
    
    • 1
    • 2
    • 3
    • 4
    • 5

      乍一看似乎长得很像?感觉就是加了个括号?那怎么记忆呢?目前我看到的最好的方法是按照符号优先级来记忆:() > [] > *,所以,对于int *p[]p先和[]结合,构成一个数组变量,然后是int *修饰数组中的内容,表示数组中全为指向int类型的指针;对于int (*p)[2],先看()内,*表示p是一个指针变量,然后再看[]int,表示p指向的变量为一个int类型的数组,即决定了这个指针的步长。
      此外,数组指针还可以按照一般的变量定义的格式来表达,即int(*)[2] p,前面的int(*)[2]即为该数组指针的数据类型,其中(*)表示变量为一个指针,int [2]指定了指针指向变量的内存大小,即步长。
      这个表达式似乎看着很眼熟?没错,就是上面第2节最后提到的报错信息。经过试验发现,这种写法不能用在变量定义中,但能用在类型转换中,例如:int (*)[3] p = NULL;【×】;int (*p)[3] = (int(*)[3])a;//a是一个指针【√】

    3.3 应用

      学会了基本语法,再来看看怎么应用。
      对于指针数组,没啥好说的,可以把它视为一般数组进行赋值或通过下标取值,但要注意,取出来的是指针,如果要取指针指向的数值,还要加上星号。下面以一个访问二维数组的例子来说明:

    #include 
    using namespace std;
    int main()
    {
    	int a[2][2] = {0};
        int *p[2] = {NULL}; //包含两个指针的指针数组,记得初始化
        for(int i=0; i<2; i++)
            p[i] = a[i];   //将数组a的每一行首元素地址赋给p中对应位置元素
        p[0][1] = 1;  //此时p和a基本等价,所以也可以通过下标访问
        *(*p + 3) = 3; //或者通过指针来访问
        cout << a[0][1] << endl;
        cout << a[1][1] << endl;
        return 0;
    }
    //输出: 
    //1
    //3
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

      可以发现这里使用的指针数组中的每一个元素,即指针变量,其指向的都是数组每一行首元素地址,其步长就是这个数组中元素的数据类型所占内存大小。所以可以通过下标或者取指针的方式依次访问数组中的每个元素。


      对于数组指针,在二维数组应用当中,常称为行指针,因为它往往指向的是一个二维数组的一行数组首地址,它的步长就是二维数组的一行,所以p+i就是访问第i+1行的首地址。那么问题来了?数组指针指向数组首地址,它的步长是一行?那岂不是不能访问一行的某个元素了?好像有点道理,但是还记得上面提到的那个公式吗?数组首元素地址 = *数组首地址,可以先取一个*来得到每一行首元素地址,再访问数组中的每个元素。然后,由于使用*[]是等价的,所以很多时候数组指针的使用和二维数组差不多。同样看一个访问二维数组的例子。

    #include 
    using namespace std;
    int main()
    {
    	int a[2][2] = {0};
        int (*p)[2] = a; //数组指针,指向第一行一维数组首地址
        int *q = *p; //一般指针,指向第一行首元素地址
        q[0] = -1;  //a[0][0]
        *(q+1) = 2; //a[0][1]
        p++; //指向变为a[1]首地址
        **p = 1; //a[1][0]
        cout << a[0][0] << " " << a[0][1] << endl;
        cout << a[1][0] << " " << a[1][1] << endl;
        return 0;
    }
    //输出: 
    // -1 2
    // 1 0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    通过比对指针数组和数组指针访问二维数组的例子,可以发现二者的语法似乎是一样的。有点神奇~

      上文提到,数组指针的数据类型为 int(*)[2]且可以进行强制类型转换,那能不能不按照数组的每一行为步长来进行访问呢?答案是可以的,看下面这个例子。

    #include 
    using namespace std;
    int main()
    {
    	int a[3][4] = {0};
        int (*p)[2] = (int(*)[2])a; //强行将其步长变成2,但地址不变
        for(int i=0; i<6; i++)
            **(p+i) = 1; //内侧星号是取到数组首元素地址,外侧星号是取该地址对应元素值
        for(int i=0; i<3; i++)
            for(int j=0; j<4; j++)
                cout << a[i][j] << ' ';
        return 0;
    }
    //输出:1 0 1 0 1 0 1 0 1 0 1 0 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    虽然原数组是3*4的,但是通过数组指针,可以将原数组以6*2的方式进行访问,相当于一个矩阵形状变换的操作(reshape),这个关键还是在于不管是一维数组还是二维数组,它们的存储空间都是连续的,所以能够使用指针来访问。

    4 数组作为参数传入函数

      最后,让我们再回到最开始的目标:数组作为参数传入函数。这里有两种常用的方式:即以数组形式或以指针形式。

    4.1 数组形式

      数组形式简单明了,比较好用。
      对于一维数组,常用void func(int a[]),来传数组参数,在访问数组元素时直接使用a[i]来访问,因为是传入的地址,所以数组内容会被修改,如果是全局变量,可以不用返回;
      对于二维数组,常用void func(int a[][4])来传递参数,注意,第一个括号为空,第二个括号必须是一个常数,即数组每一行的元素个数得确定,在访问数组元素时直接通过a[i][j]来访问,同样这个数组也会被修改。

    4.2 指针形式

      对于一维数组,前面我们提到,数组名本质上就是一个指针,因此可以把指针作为参数或返回值应用到函数中。比如void func(int *a),这种比较直观,理解起来没有什么难度。
      对于二维数组,可能有点小复杂,前面我们提到,二维数组的数组名本质上是一个数组指针,它对应的步长为二维数组的一行,同时它指向的是一行的数组首地址(而不是每一行数组首元素地址),它的数据类型是int(*)[2](2为每一行数据的个数),而不是int *,所以这里一般有两种方式:不进行类型转换、将数组进行强制类型转换成int*,看下面这个代码

    #include 
    using namespace std;
    
    void func1(int (*a)[4]) //数组指针,数据类型为int(*)[4]
    {
        a[0][0] = 10; //访问时和一般的数组类似,可以用下标
        int i=1, j=2;
        *(*a + i*4 +j) = 5; //也可以用指针
    }
    
    void func2(int *a) //将二维数组的数据类型强制转换为一级指针,此时该指针指向数组【首元素地址】
    {
        int i=1, j=1;
        *(a+i*4+j) = 7; //取值时只需要用一级星号即可
    }
    
    int main()
    {
    	int a[3][4] = {0};
        func1(a);
        func2((int*)a);
        for(int i=0; i<3; i++)
            for(int j=0; j<4; j++)
                cout << a[i][j] << ' ';
        return 0;
    }
    //输出:
    //10 0 0 0 0 7 5 0 0 0 0 0
    
    • 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

      此外,这里可能会存在一个误区,那就是会考虑将二维数组转换为二维指针,因为数组首地址 = &(数组首元素地址),里面还有一级取地址的操作,但实际上这里数组的取地址和一般变量取地址似乎是两个概念,因为这里取完地址,它们的数值仍然是相同的,但是一般的变量取两次地址,实际上是将上一级指针的值作为下一级指针指向的内容,如下所示。

    #include 
    using namespace std;
    int main()
    {
    	int a[2] = {0};
        int b = 0;
    	int *p = &b;
        cout << a << " " << &a << endl;
    	cout << p << " " << &p << endl;
    	return 0;
    }
    //输出:
    //0x61fe18 0x61fe18
    //0x61fe14 0x61fe08
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    因此,从这个例子中可以看出,使用int**对二维数组进行强制类型转换是不合适的,如果强行将a[2][2]转换为int**类型,其内部逻辑是先将数组首地址变成数组首元素地址,然后将首元素的值视为地址继续访问,因此,往往会报内存错误,如Segmentation fault。除非这个数值恰好在地址范围内,但也会得到奇怪的结果。

    5 Update

    5.1 使用指针访问数组实现用变量定义数组长度

      在一些场合,常常会需要用到某个变量来定义一个数组的大小,但是C语言规定数组的长度必须是常数。遇到这种情况,就可以考虑使用指针来实现。

    #include 
    using namespace std;
    int main()
    {
    	int c; //定义数组的长度
    	cin >> c;
    	int *p = new int[c];//一定要注意先输入c再定义数组	
    	...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    总结

      在使用指针时,我认为只需要关注两个内容:指针的取值和指针的步长,其中步长也可以理解为指针指向的数据类型,这决定了指针在移动时跳过多大的内存。
      数组再取地址得到的值仍然为该地址,改变的只是指针对应的步长,和一般的变量取地址是不同的。

  • 相关阅读:
    秋季期中考复现xj
    【python】【logging】python如何使用logging模块,一个完美的logging模块
    allatori8.0文档翻译-第十步:增加过期日期
    低代码如何增强团队应用开发能力?
    JVM 垃圾回收详解
    【C语言】初识指针(二)
    完整的代码
    微服务学习之——nacos安装部署
    Python实现SSA智能麻雀搜索算法优化XGBoost回归模型(XGBRegressor算法)项目实战
    防止重复点击按钮执行重复请求2
  • 原文地址:https://blog.csdn.net/ZHOU_YONG915/article/details/128144261