在C++中如何动态分配一个二维数组

有日子没写blog了,都要长毛了。有了娃之后,能用来码字的时间骤减,加上这学期课多,事多,科研任务多,所以这是好容易出个差,才能闲出一个晚上来码点字。

这个问题的起源是C++课上有个学生问,能不能用new来生成动态的二维数组,这个事情,听起来超级容易,但是,事实上要完全搞清楚,还是要花点精力的。我们下面的讨论全部基于CodeBlocks 16.0 + MinGW的配置,采用CodeBlocks自带的MinGW G++编译器,默认开-std=c++11标准,并且默认兼容了C99特性。

我们先来看最简单的情况:

#include <iostream>
using namespace std;
int main()
{
    int (*p)[10] = new int[20][10];
    return 0;
}

上面这段代码的编译是可以通过的,然而,如果我们稍微不那么仔细一点,写成了下面这个样子,就是把其中变量p的类型由行指针转换成了二重指针。

#include <iostream>
using namespace std;
int main()
{
    int **p = new int[20][10]; //Compile Error
    return 0;
}

这个样子的程序是连编译都过不去的,编译器会报错,说cannot convert ‘int (*)[10]’ to ‘int**’ in initialization。这也就是说,new int[][]这玩意返回的类型是int(*)[]这个样子的,并且看来,这个返回的类型的后面那个中括号里面得是常量才行。既然这样,我们直接用C++11的自动类型推断好了,不就是要一个二维数组吗?于是,程序改成下面这样:

#include <iostream>
using namespace std;
int main()
{
    auto p = new int[20][10];
    return 0;
}

这似乎编译,运行都没问题了。好了,那么我们可不可以这样就直接的转化成变量版本呢?答案是否定的,下面的程序是不能通过编译的

#include <iostream>
using namespace std;
int main()
{
    int m = 20, n = 10;
    auto p = new int[m][n];
    return 0;
}

编译过程会报错:the value of ‘n’ is not usable in a constant expression。也就是说,那个变量n所在的位置,得是个常量才能完成上面这个任务。不过这事略奇怪,为啥能new一维的,就不能new两维的呢,非得要后面那个括号里头是常量?

这事得从new是啥玩意说起,考试的时候,一般没人会犯错,那就是new是个运算符,这个运算符还可以被重载。既然如此,对于C++而言,new这个运算符所能做的事情就是确定的,那么,究竟能不能new[][]这样写呢,答案是:NO,这样不行,因为这样定义了一个新的运算符叫new[][],而C++里面没有这个运算符。那,为啥new[]就是可以的呢?因为对于C++来说new和new[]是被认为是两个不同的运算符!他们可以分别重载!参考这里 http://www.cplusplus.com/reference/new/ 。

可是,在写了new[][]的时候,那又是怎么搞出来那个叫做int (*)[10]的类型的呢?这个类型似乎C++11还可以自动推断出来,这是怎么回事呢?这就涉及到写了例如new int[20][10]的时候,编译器会怎么理解这个玩意。首先记住,没有new[][]这个运算符,所以,编译器只能理解为new[]这个运算符的应用,而代码的剩余部分就会都被作为要new的数据类型定义而存在。所以这个时候,编译器看到的,你要求new的20个变量的类型,并不是你想当然的int类型,而是一个叫做int [10] 的类型!

这是什么玩意??这就是数组类型!这个类型的意思是10个连续的整数,每个这个类型的数据占用40个字节(32位机),而int (*)[10]则是指向这种数据的指针,对于这种数据来说,每一个变量都相当于10个整数,所以类型为int (*)[10]的指针要+1的话,地址是向后移动40个字节的。并且,要知道的是,这种类型是可以通过typedef的方式类型化的。例如下面的代码是可以正确编译并输出40的,也就是说p就是一个10个整数组成的数组:

#include <iostream>
using namespace std;
int main()
{
    typedef int (arr_10) [10];
    arr_10 p;
    cout << sizeof(p) << endl;
    return 0;
}

说到这里,似乎已经很明白了,那就是,不能直接使用new[][]这种形式,来生成行列都是变量的二维数组。至少列数得是常量才行。问题是,有些场合就得用行列都是变量的二维数组啊,不能没有这个的。最简单的解决方案就是把原来直接定义二维数组中的行指针值给实现出来,就是真的拿个一维数组来存。代码如下:

#include <iostream>
using namespace std;
int main()
{
    int m = 20, n = 10;
    int **p = new int*[m];
    for (int i = 0 ; i < m ; i ++)
        p[i] = new int[n];
    return 0;
}

上面的代码是可以正常编译的,需要注意的是p指向了一个额外的一维数组,这个一维数组中的每个元素都是一个int *的指针,这些指针每一个都代表一个一维的int数组,这样,至少在使用体验上p就是一个二维数组。但是,必须注意的是,上述程序一共进行了m + 1次的内存分配,这样得到的“二维数组”p不但多用了m个指针的空间,实际的m*n个元素的存储空间在内存中也不能保证连续有序。这对于很多依赖连续空间存储的算法来说,是不可接受的。那么,可不可以将m*n个元素连续存放?当然可以,代码如下:

#include <iostream>
using namespace std;
int main()
{
    int m = 20, n = 10;
    int **p = new int*[m];
    int *s = new int[m*n];
    for (int i = 0 ; i < m ; i ++)
        p[i] = s + i * n;
    return 0;
}

上面的方法里,是将辅助数组p和存储空间s分别存储,而让辅助数组p的中的各个指针分别指向s中各个行的起点。这样的程序已经可以满足二维数组存储的所有要求,并且由于辅助数组的存在,使用p访问这些数据的时候,就跟p是二维数组已经没有两样。唯一的问题是,p的类型并不是一个真正的二维数组类型,那么有没有办法让p至少具有一个行指针类型,也就是说让程序运行时的sizeof(*p)的值不是指针宽度,而是那个变量n的值?这个功能借用C99的特性,变长数组类型定义(VLA typedefs)可以在一定程度上实现,代码如下:

#include <iostream>
using namespace std;
int main()
{
    int m = 20, n = 10;
    int (*p)[n] = (int(*)[n])new int[m*n]; //VLA type
    return 0;
}

必须说明的,VLA type,也就是上面的int(*)[n]这种带着变量的类型,具有一个限制,当这一行代码执行过后,再改变n的值,类型不会跟着变。也就是说,如果执行到这一行的时候,变量n的值是10,那么p的类型就被确定为int(*)[10]了,而后面再把n改成20,p的类型并不改变。必须说明的是,这是C99所具有新特性,对于执行C89标准的编译器来说,上述代码是不工作的。这有什么用?这已经把类型定义的工作放到执行期了,可以根据运行时用户输入的数据来定义类型了,这跟一般意义上的new的动态内存分配可是两个不同层面的事情。

此条目发表在软件使用与程序设计分类目录。将固定链接加入收藏夹。