美文网首页
登堂入室C++之数组

登堂入室C++之数组

作者: 杜凌霄 | 来源:发表于2023-02-10 22:58 被阅读0次

数组简介

数组是我们编程中经常遇到的一个类型。多维数组是如何在内存中存储的呢?数组名到底代表什么呢?

数组是相同类型数据的一个集合,在内存中连续存储

数组可以有一维、二维、三维甚至更多维。多维的概念是存在于C/C++语言层面,对于编译后的汇编和二进制,只有一维的概念。

一维数组

一维数组使用如下形式进行定义:

data_type array_name[array_size]

比如

int scores[4]; 

定义一个数组,该数组的每个元素的类型的是int, 该数组一共有4个元素。

当然了,我们也可以在定义的时候给定初始值

int scores[4] = {90, 95, 99, 89};

这时候四个值在内存中的排布为:

1d-array.png

那如果我们没有给定足够的值呢?比如

int scores[4] = {90, 95};

这时候后面两个元素的值是啥?

其实这时候,后面两个值会被填充0,得到的值为

1d-array-pad.png

我们可以写一个简单的小程序分析一下

// maian.cpp
int main()
{
    int scores[4] = {90, 95};
    return 0;
}

然后用clang++ -S main.cpp生成汇编,我们看一下生成的汇编

1d-array-main.png

这里我们不会详细介绍x86汇编,只介绍一点我们需要用到的汇编的。

moveq src, dst 

也就是x86汇编里面,源在前面,目标在后面。

a(%register)

表示register的值加上a这个偏移量指向的地址。所以上面

movq %rax, -24(%rbp)

相当于*(rbp - 24) = rax

如果源是常数,那么直接使用一个符号加上这个常数就可以了,比如```90```就表示常数90。

如果我们把数组的大小变大一点,比如下面的代码

int main()
{
    int scores[40] = {90, 95};
    return 0;
}

反汇编我们可以看到

1d-array-main-2.jpg

那也许有的人会问,是不是默认就会清零?我们也可以验证一下,把代码修改为

int main()
{
    int scores[40];
    return 0;
}

反汇编我们可以看到

1d-array-main-3.jpg

从汇编代码我们可以看到是完全没有默认清零的。

最后值得一提的是,如果我们有初始值,也可以不指定数组大小,系统会根据初始值计算出大小。

int main()
{
    int scores[] = {95,98,99,100};
    return 0;
}

系统会自动根据初始值推断出scores数组大小为4。

二维和多维数组

二维数组可如如下定义

data_type array_name[size_1][size_2];

比如

int scores[2][4];

三维以及多维数组的定义类似

data_type array_name[size_1][size_2]...[size_n];

比如三维数组可以定义为

int scores[2][3][4];

跟一维数组一样,多维数组也可以在定义的时候给定初值

int scores[2][2][3] = {
  {{1,2,3},{4,5,6}},
  {{7,8,9},{10,11,12}}
};

它在内存中的排布为

2d-array.png

我们给定下面一段代码

int main()
{
    int scores[2][2][3] = {
        {{1,2,3},{4,5,6}},
        {{7,8,9},{10,11,12}}
      };
    scores[1][1][0] =  95;
    
    return 0;
}

反汇编的代码如下

2d-array-as-1d.jpg

从上面反汇编我们可以很容易看出,编译之后并没有多维的概念,都是一维的,多维的概念只存在于语言层面。但是,这并不影响我们按照概念使用。从上面的汇编更多可以容易看出多维数组的值是怎么排布的。

可变大小的数组

可变大小数组也被称为运行时大小数组。不同于在定义的时候指定常量大小,可变大小数组可以根据参数来定义大小。

比如

void f(int size)
{
   int scores[size];
   ...
}

这样我们就可以在运行时根据需要传入需要的大小来分配数组。

可变大小数组是C99标准里面的,所以C语言程序如果标准设为C99以及以上是一定可以使用的。但是C++11之前是一定不支持可变大小数组的,C++11中提及数组大小的时候也说size是一个常量表达式,那么也就说明C++11从标准里面也是不支持可变大小数组的。但是GCC和Clang提供了扩展来支持可变大小数组,所以上面的代码使用GCC和Clang是可以编译的。

这里面就有两个问题需要我们注意:

  1. 可变参数数组是一个很好的特性,但是如果我们想要代码可移植性强,最好不要在C++中使用它。当然了,纯C程序可以放心使用;
  2. C++并不是C的严格超集,C中的一些东西在C++中是不能使用的。

数组元素的访问

常见的用数组名加上下标访问的方式我们已经见过了,就是类似

int a[3]  ={};
a[2] = 10;

但是在有的地方我们可以看见如下写法

2[a] = 11

这种写法是很不常见的,只是作为一种语法进行说明。更多的是a[-1]这种写法,其中a是一个指针。这里不详细说明,后面在聊指针和内存的时候会详细说明。

我们有下面一段代码

int main()
{
    int a[3] = {};
    a[2] = 10;
    1[a] = 11;
    
    return 0;
}

这段代码是完全可以编译和运行的,在执行到return 0;的时候,a[0]=0, a[1]=11, a[2]=10。从反汇编我们也可以看一下

array-ele-access.jpg

我们可以看到无论是a[2]还是1[a]最后都是基指(也就是rbp寄存器的值)加上一个偏移量。其实t[b]可以认为都是*(t+b),所以t和b哪一个是数组名,哪一个是偏移量并没有关系。

数组类型

对一个多维数组,每一维的类型到底是什么

为了解答这个问题,我们首先写一个打印类型的工具函数

template <typename T>
void print_type()
{
#if _MSC_VER
    const char *sig = __FUNCSIG__;
    printf("%s\n", sig);
    return;
#else
    constexpr int skip_begin = 23;
    constexpr int skip_end = 1;
    const char* sig = __PRETTY_FUNCTION__;
#endif
    char *result = new char[strlen(sig) - skip_begin - skip_end + 1]{};
    memcpy(result, sig+skip_begin, strlen(sig) - skip_begin - skip_end);

    printf("%s\n", result);
}

这个函数主要使用函数的签名中含有参数名的原理来提取我们的类型名,比如print_type<int>()可以得到

void print_type() [T = int]

这里面是不是就有类型int名在里面,我们去掉int前面的字符串和后面的]就可以得到最终的类型。这就是上面函数的原理。

手上没有Visual Studio,所以Windows版本并没有调试skip_begin和skip_end的值,需要的人可以自行调试。Clang版本是测试过的,值为上面的23和1,可以直接使用。

我们现在可以写个小程序打印不同维度的类型:

int main()
{
    int scores[2][3][4];
    
    print_type<decltype(&scores)>();
    print_type<decltype(scores)>();
    print_type<decltype(scores[0])>();
    print_type<decltype(scores[0][0])>();
    print_type<decltype(scores[0][0][0])>();
    
    return 0;
}

可以得到如下输出

int (*)[2][3][4]
int[2][3][4]
int (&)[3][4]
int (&)[4]
int &

也就是说

  • 对数组名取地址,得到的是一个指向数组类型的指针类型;
  • 数组的类型就是数组定义中去掉数组名之后得到的类型;
  • 数组的第一维元素是第二维和第三维组成的数组;
  • 数组的第二维元素是一个一维数组;
  • 数组的第三维元素是一个整形。

另外,数组的第一维、第二维、第三维都是常规的引用类型,所以在类型里面都有&。

这里有一个有意思的小问题就跟类型息息相关:v的地址假设是a,那么v+1的地址用a表示是多少?

比如我们假设&scores的地址值是a,那么&scores+1的地址值是多少?

首先我们需要知道,对于地址的加减法,v+1中1的量纲是sizeof(*v)。也就是1的大小用字节表示是v所指对象的大小。

&scores+1中的1用字节表示就是
\begin{aligned} sizeof(*\&scores)&=sizeof(scores)\\ &=2\times 3\times 4\times sizeof(int) \\ &= 96 \end{aligned}
所以&scores+1的地址用a表示就是a+96

同理我们可以计算scores + 1&scores[0] + 1, &scores[0][0]+1, &scores[0][0][0]+1的地址的值。

复合字面量

经常我们都会提到C++是C的超集,C中可以用的C++都可以。但是今天我们就可以遇到第二个在两门语言中表现不一样的:复合字面量。

我们上面的多维数组是规则的,那我们如果想要一个二维数组,它的每个维度的大小不一样,是可以实现的吗?

答案是当然可以,而且方法不止一种。

第一种常规的就是数组的成员是指针,然后让指针指向不同大小的内存;

第二种方法就是使用复合字面量。那什么事复合字面量呢?这也是一个C99标准里面的东西。简单来说

(int [2]){1,2}; 

就是一个复合字面量。

我们可以使用复合字面量得到一个大小不同的数组

int main()
{
    int (*scores[]) = {
        (int[]){1,2},
        (int[]){3,4,5},
        (int[]){6, 7, 8, 9}
    };
    
    return 0;
}

这部分代码,在Xcode中使用main.c是这样的

var-array-c.jpg

在main.cpp就成了

var-array-cpp.jpg

本质上这是一个C99的标准而不是一个C++的标准。在C++中的时候也需要谨慎谨慎再谨慎。

数组的分享就到这里了,还有一部分,比如数组名是左值还是右值,需要分享了对应的概念之后我们再回头来讨论。

更多文章及时发布在公众号“探知轩”,欢迎关注更及时看到文章。

相关文章

网友评论

      本文标题:登堂入室C++之数组

      本文链接:https://www.haomeiwen.com/subject/ipklkdtx.html