C++数组的详细解析(转)

原文地址:https://blog.csdn.net/weixin_39640298/article/details/84780164

概述

数组在写程序时经常用到,但是对于它和指针的关系,自己经常搞混,所有抽点时间对数组进行整理。

1、数组的概念和使用

数组是用来存储相同类型的变量的顺序集合。所有的数组都是由连续的内存位置组成。最低的地址对应第一个元素,最高的地址对应最后一个元素。

语法:

type arrayName [ arraySize ][arraySize1];    arraySize必须是一个大于等于零的整数常量。
  • 1

所以sizeof的计算值和字符串常量可做arraySize。

通过数组名称加索引进行访问,数组下标从0开始

使用大括号{ }赋初值,{ }之间的值的数目不能大于声明时[ ]中指定的元素数目。如果忽略了数组大小,数组的大小则为初始化时元素个数。

int a[5] =  {0,1,2,3,4}; 
int b[]  =  {0,1,2,3,4};	//b和a的效果是一样
int aa[2][5] = {0,1,2,3,4, 5,6,7,8,9}; //二维数组

注意:多维数组的列下标是不能删除的

2、数组与指针

数组和指针是两种不同的类型,数组是具有确定数量元素的集合,指针只是一个标量值。
数组都是占有一块连续的内存位置,数组的地址就是占据内存空间的第一块存储单元的编号。而指针只是一个地址编号。

int a[10];
int *const p = a;

std::cout << sizeof(a); // 40
std::cout << sizeof(p); // 64位机器上为8

作为数组,a存放了10个int类型数据,占内存40个字节。p只是一个指针,占8个字节。为啥可以把数组a赋值给指针p呢?

2.1、数组名赋值给指针

可以将数组名赋值给一个指针,而赋值后的指针是指向数组首元素的,这让数组名看起来确像一个指针。

数组名可以像指针一样运算,对数组的索引和指针的运算看起来也是相同的。

#include <stdio.h>

int main()
{
    int a[] = {1,2,3};
    int * p = a;
    printf("a:\%p, p:%p, &a[0]:%p\n", a, p, &a[0]);
    printf("*a:\%d, *p:%d, a[0]:%d, p[0]:%d\n", *a, *p, a[0], p[0]);
    printf("*(a+1):\%d, *(p+1):%d, a[1]:%d, p[1]:%d\n", *(a+1), *(p+1), a[1], p[1]);
    return 0;
}

运行输出是一样的,如下:

a:0x5fcaf0, p:0x5fcaf0, &a[0]:0x5fcaf0
*a:1, *p:1, a[0]:1, p[0]:1
*(a+1):2, *(p+1):2, a[1]:2, p[1]:2

之所以说看起来像个指针,其实它暗含其它信息,我们接着往下看。

2.2、&a 与 &a[0]

&a表示数组地址,其结果是指向该数组的指针

&a[0]表示数组首元素的地址,其结果是指向该数组首元素的指针

它们意义不同,但是地址的值相同。因为数组是一连续内存,它的地址是所占内存单元的第一块存储单元地址。而第一块存储单元就是数组的首元素,所以两者相同。

注意:指向数组的指针和指向数组首元素的指针是两种不同类型的指针

#include <stdio.h>

int main()
{
    int a[]={1,2,3};
    int (* pa)[3];
    int * pi;
    pa = &a;
    pi = &a[0];
    printf("&a=%p, &a[0]=%p\n",&a, &a[0]);
    printf("pa=%p, sizeof(a)=%d, pa+1=%p\n", pa, sizeof(a), pa+1);
    printf("pi=%p, sizeof(a[0])=%d, pi+1=%p\n", pi, sizeof(a[0]), pi+1);
    return 0;
}

运行之后,输出如下:

&a=0x5fcaf0, &a[0]=0x5fcaf0
pa=0x5fcaf0, sizeof(a)=12, pa+1=0x5fcafc
pi=0x5fcaf0, sizeof(a[0])=4, pi+1=0x5fcaf4
  • 1
  • 2
  • 3

取数组地址(&a)得到的指针pa和取数组首元素(&a[0])得到的指针pi是两种不同类型的指针,pa是一个指向有三个int型元素的数组的指针,pi是一个指向int型对象的指针。虽然pi和pa的值相同,但所指的内存空间不同,pi所指的空间处于pa所指空间的内部,而且是内部最靠前的部分。pi和pa所指内存块的大小显然是不同的,因此我们看到pa+1并不等于pi+1。

由指针运算规则可知,pa+1的值就是pa所指空间的下一个空间的地址,所以pa+1的值就是pa的地址向后偏移一段后的地址,这个偏移量就是pa所指的数组a的大小,即12个内存单元。同样,pi+1的值是pi向后偏移4个单位(int型的大小)后的地址。

2.3、权威的解释——decay

除了作为 sizeof_Alignof& 这3个操作符的操作数以及用于初始化数组的串字面量外, 表达式中的数组都会被自动转换为指向其首元素的指针 ,转换而来的指针不是一个左值(lvalue)。因为这种转换丢失了数组的大小和类型,因此有一个专用的称谓叫做 “decay”(衰退) 。

于是,所有的疑惑都有了最权威的解释。

char a[10];
char * p = a;  			//这里的a被转换为 char* 型指针,所以可以赋值给p
a = p; 					//ERROR! a虽然被转换为指针了,但转换后得到的指针无确切地址,不是lvalue,不能被赋值
char (*pa) [10] = &a; 	//a是&的操作数,没有发生转换,&a意为取数组a的地址,得到一个指向数组a的指针
sizeof(a); 				//a未发生转换,仍是数组,所以表达式得到的值是数组的大小
&a; 					//a未发生转换,仍是数组,所以表达式得到的值是数组的地址
*a;						//a被转换为指针,操作符*是作用于指针的,而非数组
*(a+1); 				//a被转换为指针,所以并不是数组a与1相加,而是转换后得到的指针在参与运算
a[0]; 					//a被转换为指针,所谓数组的下标本质是指针运算
a == a; a - a;			//a被转换为指针,本质是指针运算

我们发现,一旦有了 decay ,表达式中所有让一个数组看上去像个指针的现象都合情合理了。除赋值外,可用于指针的运算都可以适用于会发生转换的数组。不可赋值是因为先要转换,转换后不是左值。

ps:lvalue这个术语翻译成左值会过分强调它可以作为赋值操作符(=)的左操作数,实际上lvalue的核心在于location,而不是left,虽然它最初被命名为lvalue确是因为left。lvalue意味着在内存中有确切位置(location),可以定位(locator)。所以,数组被转换为指针后不是lvalue的原因是没有确切地址,而不能被赋值是结果。

可能读到这里会有一个疑问,那就是转换后不是左值不可以赋值,那么 a[0] = ‘x’; 却怎么解释?注意,转换后得到左值的是a,而非a[0]。什么意思呢?把这个表达式中除去a的部分([0])看成是对转换后得到的指针的继续运算,结果就是数组第一个元素,有确切地址,那么a[0]整体就是一个左值了。 于是赋值成功!

值得注意的是,取址时取到的是数组地址而非转换后指针的地址,因为取址时数组不会发生转换,实际上,转换后得到的指针没有确切地址不是左值,是无法取到地址的。这里多说这么几句是因为,有一些博客中纠结于 “数组名作为一个指针常量有没有被分配空间?分配到的地址是什么?” 这样伤神的问题中。

2.4、不执行默认转换的情况

C规范中指出了四种不发生转换的例外情况。

前三种情况是说数组作为 sizeof_Alignof& 这3个操作符的操作数时是不会被转换为指针,这个较好理解,就是这三个操作符直接作用于数组时,不会发生转换,如 sizeof(a) 和 &a 中的a都不会被转换。而像 &a[0] 这样的表达式中,&的优先级不是最高的,所以&的直接作用对象是 a[0] 这个子表达式,此时a转换为指针后进行运算,得到数组的第一个元素,&作用于这个元素后取得其地址,得到的最终结果指向数组首元素的指针。例如:

int a[4];
printf("%d\n", sizeof(a)); 		//不转换,输出数组a的大小 16
printf("%d\n", sizeof(a+0)); 	//转换,输出指针类型大小 8

现在我们来看第四种情况:用于初始化数组的串字面量

在编译期间,串字面量所声明的多字节字符序列会先行合并(如果有多个相邻)并在结尾追加一个零值( ‘\0’ ),然后用此序列初始化一个静态存储周期的等长度字符数组。

所以,串字面量作为一个原始表达式,其结果是一个字符数组!因为地址可知,它是一个 lvalue 。

需要注意的是,程序中对这种数组试图修改的行为在C11标准中是未定义的,C11标准同样也没有说明对于内容相同的这种数组是否可以可以视为一个。

char a[4] = "abc"; 				//注意,此处是初始化,而非赋值
char * p = "abc";
a[1] = 'x'; 					//正确
p[1] = 'x';						//编译没问题,运行时出错
printf("%d\n", sizeof("abc")); 	//输出 4
printf("%p\n", &("abc")); 		//本机输出 0x403031 ,证明没有转换,因为转换后非lvalue,无法取值

第一行代码中的串字面量 “abc” 的本质是一个长度为4(被追加了’\0’)的字符数组,其用于初始化另一个数组a时不会发生转换。这就是所谓的用于初始化数组的串字面量不会decay。

第二行代码中的串字面量同第一行中的一样,也是一个长度为4的字符数组,只是是否和上一行的是同一个就不得而知了,C标准没有规定。这个字符数组此刻并未用于初始化一个数组,所以它被转换为指向其首元素的指针,然后用于初始化另一个指针p了。

所以第一行可以认为是用数组初始化数组,第二行是用指针初始化指针。不过因为转换规则的存在,可用于初始化数组的“数组”仅限于串字面量。

第三行很好理解,a是我们新初始化的一个数组,和初始化它的串字面量已经是两回事了,所以修改a是合法的操作。但是第四行在大多数系统中会报错,因为p指向的是一个串字面量,对串字面量的修改行为未被C标准所定义,因为串字面量本质是即一个静态存储周期的字符数组,大多数系统对其有写保护因而修改出错。

如果尝试将串字面量作为 sizeof 、_Alignof 和 & 这3个操作符的操作数,我们发现这个“字符数组”也没有转换。

2.5、作为表达式结果的数组

在讨论串字面量本质的时候,我们发现,在转换概念的范围内,所谓数组不光是指在程序中被我们明确定义的数组,也可以是表达式计算的结果。如原始表达式串字面量的结果就是字符数组。果真如此吗?我们来看看下面的情况。

试想对于一个数组a,表达式 (&a)[0] 和表达式 &a[0] 有什么不同?

&a[0] 这个表达式我们在前面已经分析过了,它的结果是一个指向a的首元素的指针。

而表达式 (&a)[0] 的不同之处在于提高了取址操作符&的优先级。于是,在 (&a)[0] 中,数组 a 作为操作符&的操作数,不会发生转换。子表达式 &a 是取数组a的地址得到指向该数组的指针,而接下来的运算就是指针运算了,结果便是数组a本身。

那么作为表达式 (&a)[0] 的结果的数组会不会有转换行为呢?答案是肯定的。
空口无凭,再看代码:

char a[4] = "abc";
char * p;
p = &a[0];
p = (&a)[0];
printf("%d\n", (&a)[0] == &a[0]);  	//输出1
printf("%d\n", sizeof((&a)[0])); 	//输出 4,数组a的大小
printf("%d\n", sizeof(&a[0])); 		//输出 8,x64系统中指针的大小
char (*pa)[4];
pa = &((&a)[0]);

表达式 &a[0] 的结果是char * 型的指针,它可以赋值给同类型的指针p是理所当然的。但奇怪的是,表达式 (&a)[0] 结果是一个数组,竟然也可以赋值给指针p。考虑下转换规则,这个情况就完全合理了,这说明作为计算结果的数组,也会在符合转换条件的情况下发生转换。

将这两个表达式进行逻辑比较,得到的结论是它们是相同的,这也说明表达式 (&a)[0] 发生了转换。我们再尝试将这两个表达式分别作用于操作符 sizeof ,根据规则,作为sizeof的操作数,它们不会发生转换,事实确是如此。

对一个数组取地址会得到指向该数组的指针,我们将表达式 (&a)[0] 作为操作符&的操作数(此时也没有转换)得到的确实是指向数组类型的指针。如果,我们脑洞大一些,将 &a[0] 作为&的操作数会怎么样? &a[0] 是一个指针,可能我们会觉得是不是会得到一个指向指针的指针?答案是不会! &a[0] 是一个指针没错,但没有确切地址,不是lvalue,无法取址, &(&a[0]) 会编译错误!

2.6、转换的基础

数组和指针之所以有这么微妙的关系,内存分配才是关键因素。上面讨论的表达式 (&a)[0] 的结果就是数组a本身,如果我们把方括号中的整数0改为1或者2这样的值,那又意味着什么?

char a[4] = "abc";
char * p = &a[1]; 						//p指向数组a的第二个元素
p = (&a)[1];							//p不指向数组第二个元素
printf("%d\n", sizeof((&a)[1])); 		//输出数组大小 4
printf("%d\n", (&a)[1] == &a[0]+4 ); 	//输出 1
char (*pa)[4] = &a;
printf("%c\n", *(pa+1)[0]); 			//即((&a)[1]))[0], 输出值不确定 

我们先看看表达式 (&a)[1] 的结果是什么,首先, &a 将得到指向数组a的一个指针,而指针的下标运算 [1] 表示指针所指空间相邻的同大小空间内的对象,虽然我们知道,数组a的旁边有什么并不确定,但至少不应该是个和a一样的数组,因为我们此前并没有定义一个数组的数组。尽管如此,我们发现系统依然照着a的样子,在内存中为我们找到了它旁边一块同样大的空间,并“假装”它保存的也是一个同类型的数组,我们用sizeof测试时会得到数组大小的值。而且 (&a)[1] 这个“数组”还可以转换为一个指向它首元素的指针,我们将它和 &a[0]+4 (可以看作指向从a的首元素起后面的第5个元素的指针,即&a[4])比较发现,它们是相等的。

我们尝试按照取数组a中元素那样,从a旁边这个并不存在的数组中读取数据,虽然输出值没有意义,但这却并非是一种非法操作,C语言允许我们这么做。

顺便说下,数组下标在C语言中是被定义为指针运算的,ISO/IEC 9899:201 §6.5.2.1 [Array subscripting] para 2 中的的原文如下:
A postfix expression followed by an expression in square brackets [] is a subscripted designation of an element of an array object. The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))). Because of the conversion rules that apply to the binary + operator, if E1 is an array object (equivalently, a pointer to the initial element of an array object) and E2 is an integer, E1[E2] designates the E2-th element of E1 (counting from zero).

因此像 a[-1] 这样的负数下标虽然没有意义,但也是合法的。小于0 或大于等于数组元素数的下标值是无意义的,那我们如何看待这些越界的下标呢?实际上,下标是定义给指针的。 E1[E2] 中的两个子表达式E1和E2一个是指针类型一个是整数类型[^ISO/IEC 9899:201x §6.5.2.1 para1],因为转换规则的存在,数组才可以成为E1和E2中的一个。

为什么说是E1和E2中的一个?难道数组类型的不应该是E1吗?看下面的代码示例:

int a[5] = { 0, 1, 2, 3, 4 };
cout << a[2] << endl;            // 输出 2
cout << 2[a] << endl;            // 输出 2
cout << 5["abcdef"] << endl;     // 输出 f

对于a[2]的输出我们没有异议,但后面两个表达式有点"诡异"。前面在引用C11中下标规范时提到, E1[E2] 与 (*((E1)+(E2))) 是等同的(identical )。那么就会有:

E1[E2]
(*((E1)+(E2)))    下标定义
(*((E2)+(E1)))    加法交换律
E2[E1]            下标定义

所以, E1[E2] 与 E2[E1] 是等同的。因此,a[2]和2[a]都输出2,而 5[“abcdef”] 其实是 “abcdef”[5] ,结果是串字面量(字符数组)中的第5个(从0计)字符f。

我们发现,数组和指针在从内存访问数据上并没有本质区别,数组貌似仅意味着数据连续不间断的存放而已,而数组类型都有相应的指针类型对应,如int型数组对应有指向int型的指针,这种类型信息提供了指针访问内存时每个步长的移动跨度。除此之外,数组好像也不能再给我们太多信息了,它甚至不能做出越界检查。

3、传递数组给函数

当一维数组作为函数参数传入时,数组退化成指针。数组的数组会退化成数组的指针。
因此,数组永远都不会真正地被传递给一个函数。

void  myFunc1 ( int*  param )
{
	......	
}

void  myFunc2 ( int  param[ 10 ] )
{
	......
}

void  myFunc3 ( int  param[ ] )
{
	......
}

一维数组传入时,以上的三种写法效果是一样的,都是传入的指针

void myFunc4 ( int param[ ][10] )
{
	//param实际类型是int(*)[10],即int[10]数组的指针
}

void myFunc5 ( int(*param)[10] )
{
	//param实际类型是int(*)[10],即int[10]数组的指针
}

多维数组传入时,变为数组的指针

需要注意的是,虽然编译器会做调整,但我们不应将数组类型的形参声明都改为指针类型。因为数组和指针意义不同,在定义函数时不要理会编译器的调整有助于我们写出意义更加明确可读性更高的代码。

void f(char str[])   				//这会被调整为: void f(char *str)
{
    cout << sizeof(str) << endl;  	//str是指针,而不是数组
    if(str[0] == '\0')
        str = "none";   			//这里并不是数组被赋值,而是指针str被赋值
    cout << str << endl;
}
int main()
{
    char a[3]; 
    f(a);
    a[0] = 'x';
    f(a);
    return 0;
}

在上面例子中,函数f声明了一个字符数组型的参数str,实际上在编译时str会被调整为指向字符的指针类型。在f内,尽管str被我们显式地声明为数组类型,但函数内部它就是作为一个指针来用的,sizeof操作符给出的结果是指针大小,而且我们看到str可以被直接赋值,要知道数组是没有这个特性的。

字符数组a出现在函数调用时的实参列表中时,并不代表a本身,而是被转换为指向a首元素的指针,因此真正的实参是一个指针而非数组a。如果忽略f中sizeof那一行,那么f的功能就可被描述为:输出一个字符数组,如果字符数组为空则输出“none”。

4、数组函数作为返回值

C++ 不允许返回一个完整的数组作为函数的参数。但是,您可以通过指定不带索引的数组名来返回一个指向数组的指针。

如果您想要从函数返回一个一维数组,您必须声明一个返回指针的函数,如下:
int * myFunction()
{

}

另外,C++ 不支持在函数外返回局部变量的地址,除非定义局部变量为 static 变量。

5、动态数组

数组大部分情况下是知道数组长度的,一旦定义了数组,系统会为他分配一个固定大小的内存,以后不能改变,这种数组叫做静态数组

但是实际使用的过程中,我们有时无法确定数组的大小,需要使用动态数组。动态数组是从堆上分配的空间,在执行过程中进行分配,所以叫做动态数组。

int n;
scanf("%d",&n);			//手动输入数组长度

int* parr = new int[n];	//动态申请数组内存
......					//进行必要的操作
delete[] parr;			//手动释放申请的内存

动态数组的内存由程序员申请,所以应该由程序员手动释放。

本来想自己整理,然后上网去查资料,发现了这篇文章,这个作者写的实在是太好了,所以我大部分都是从他这摘取,其中的代码自己做了验证。有兴趣的朋友可以去查看他的原文。

6、柔性数组

柔性数组(Flexible Array)也叫伸缩性数组,其实就是变长数组声明在结构体的最后一个元素,只作为一个符号地址存在,并不占用空间。如下所示:

struct MyStruct
{
	int a;
	double b;
	char c[]; 	// or char c[0]; 也可以用其他数据类型
};

我们知道如果定义一个MyStruct变量mystruct,mystruct 的地址和 mystruct.a 的地址相同。mystruct.b 的地址是mystruct.a 的地址加上 a 类型的大小。所以 mystruct.c 意味着一个偏移地址
编译器在编译时,因为数组没有大小,所以是不占用空间的,求sizeof 是 a 和 b的大小,当然还有对齐。

柔性数组的作为一个动态的大小,只能由我们在堆上进行生成。如下所示:

int main()
{
	char c1[] = "Short string.";

	cout << sizeof(MyStruct) << endl;

	MyStruct* pms1 = (MyStruct*)malloc(sizeof(MyStruct) + strlen(c1) + 1);
	if (NULL != pms1)
	{
		pms1->a = 1;
		pms1->b = 11;
		strcpy_s(pms1->c, strlen(c1) + 1,c1);
	}

	cout << "pms1: " << pms1->a << " " << pms1->b << " " << pms1->c << endl;

	free(pms1);

    return 0;
}

打印的结果为:
16
pms1: 1 11 Short string.

柔性数组主要作用于网络传输,因为你若定义一个固定大小的数组,若没有写满等于内存浪费。若数据太多,可能造成溢出。柔性数组则很好的解决了这个问题。

7、测试题

示例代码:

int main(int argc, char** argv)
{
	int x[5] = { 2,4,6,8,10 }, *p;
	int(*pp)[5];
	
	p = x;
	pp = &x;
	
	cout << *(p++) << endl;
	cout << *pp << endl;
}

输出的结果为:2 数组地址(一个16进制的数字)

猜你喜欢

转载自blog.csdn.net/kim5659/article/details/113136025
今日推荐