用C++模板创造任意维度任意类型动态数组

前言

最近在造一个二维动态数组的轮子的时候想到能不能用模板搞一个可以支持任意纬度和数据类型的数组,为了验证自己的想法,就试着写了一下,这里记录一下主要的思考过程。

目录

  1. 基本内容
  2. 一点点改进
  3. 后续

1. 基本内容

这一部分几乎没有什么有价值的内容,所有的处理都是显而易见的,只是为了叙述更完整加上了这一部分,因此你可以直接跳过不阅读本段。

因为要应用于多种数据类型以及任意纬度,这里最方便的就是用模板了,差不多就是下面的这个样子

        
            template<typename T, int dimension>
class Mat
{
private:
  	// some private data or function here
public:
    // some public data or function here
}
        
    

类的基础的定义差不多就是这样了,剩下的就是一点点把功能填充起来。

首先是数据的存储,这里还是采用最常见的线形存储的方法,也就是搞一个一维的数组用来存储数据,然后再定义一个一维的int类型数组来储存每一纬度上的长度的数据就差不多了。从而我们得到下面的定义

        
            private:
	T* data;
public:
	int* size;
        
    

然后是初始化类,因为是刚刚开始写,先假定用一个传入一个一位数组的方法来给定每一纬度的长度,所以没啥可说的,直接上代码

        
            private:
	int init(void)
    {
        size = nullptr;
        data = nullptr;
        return 0;
    }
public:
	Mat(void)
    {
        init();
    }
    Mat(int sizelist[])
    {
        init();

        size = new int[dimension];
        for(int i=0;i<dimension;i++)
        {
            size[i] = sizelist[i];
        }

        int totalsize = 1;
        for(int i=0;i<dimension;i++)
        {
            totalsize *= sizelist[i];
        }

        data = new T[totalsize];
    }
        
    

然后就是要考虑往具体的位置上读取以及写入信息了,我们先定义一个基础的函数at来获取具体的元素的位置,这样就可以进行读写操作了,还是假定用传入一个数组的形式来获取位置信息,依然没什么亮点,直接上代码

        
            public:
	T& at(int position[])
    {
        int LinePos = 0;
        for(int i = 1; i <= dimension; i++)
        {
            int CurrentDimensionSize = 1;
            for(int j = i; j < dimension; j++)
            {
                CurrentDimensionSize *= size[j];
            }

            LinePos += CurrentDimensionSize*position[i-1];
        }
        return *(data + LinePos);
    }
        
    

经过上面的一些处理,如果不出意外的话应该已经可以正常创建数组,并且能过通过at函数对数组进行一定的操作了。但是使用起来非常不方便,因此需要在这个基础上做一些改进,下面一节将会讲一些关于改进的内容。

2. 改进

2.1 重载[]运算符,实现和原生数组的访问形式的一致性

运算符重载可以帮助我们实现一些比较方便的操作功能,保持程序某些操作的一致性,在这里,我们首先重载[]运算符,从而可以用和原生的数组一样的方法来对这个mat内部的元素进行操作。

首先第一个问题就是只有[]是一个运算符, [][]以及更多的[]并不是运算符, 也就是并不能重载。这时候考虑原生的数组的处理方法,每一次[]以后返回的都是一个低一级的数组, 在这里我们采用相似的方式,在[]里面返回一个低一级的mat,现在代码差不多长这个样子

        
            public:
	Mat<T, dimension-1> operator[] (int i)
    {
        Mat<T, dimension-1> temp;
        temp.size = size+1;
      
        int position=1;
        for(int i=1;i<dimension;i++)
        {
            position*=size[i];
        }
        SetDataPointer(data + position*i);

        return temp;
    }

	void SetDataPointer(T* _data)
    {
        data = _data;
    }
        
    

这里稍微注释一下,

  1. 要返回的低一级的Mat的size的列表正好是当前的size列表除掉第一个元素的后面所有的元素,从而要获取低一级的Mat的size列表只要把当前的size+1即可
  2. 数据区域的获取也和前面类似,只要加上计算得到的偏移量即可
  3. 虽然每一次[]都会创建一个Mat对象,但是由于这个创建的mat对象的size以及data都没有分配内存而是仅仅利用了前一级的size和data, 所以并没有占用很多空间

光这样还不够,因为这时候如果我们直接用[]访问的时候如果访问到最后一级的时候得到的并不是一个T类型的变量而是一个dimension为1的Mat, 这时候如果让他和T类型的变量/常量直接进行赋值操作是会报错的,有两种方法,一种是重载=运算符,另一种是利用一下C++的隐式类型转换。重载=运算符有个问题就是如果重载为成员函数的形式也就是T& operator= (T& item)这样的形式的时候将只能使用 T test; testMat[a][b][c] = test 的形式, 如果交换等号的两边也就是 test=testMat[a][b][c] 的形式就会编译报错, 为了解决这个问题,我们可以用友元函数的方式重载。这里我们为了简单,我们利用一下C++的隐式类型转换来进行。

首先我们定义一个T* 的类型转换以及T 的类型转换, 代码很简单, 如下所示:

        
            operator T*()
    {
        return data;
    }

    operator T()
    {
        return data[0];
    }
        
    

这样再进行测试的时候就能通过了。但是这里也有一个问题,就是这样的转换并没有检查dimension的信息,我们是希望仅在dimension = 1的时候能调用T*, 在dimension = 0 的时候调用T。因此就需要使用static_assert来控制,这里我们先按下不表,第三节中将会给出例子。

2.2 用模板实现可变参数函数来方便定义

前面我们可以注意到,我们在main函数里使用这个类都是差不多这么使用的:

        
            int sizeList[] = {1, 2, 3};
Mat<int, 3> test(sizeList);
//some operations next...
        
    

每一次用的时候需要先定义一个int的数组,然后才可以再来定义这个Mat, 使用起来非常不方便,我们可以用可变参数函数来稍微改进一下, 在C语言中,我们用经典的va一类的宏来实现可变参数,但是这种方式由于实际上是在运行过程中对内存中的栈进行操作,实际上是非常不安全的,很容易带来DEBUG上的困难。自C++11后, 我们有了可变参数的模板,这里可以用可变参数模板来实现一下。

为了说明可变参数模板是如何工作的, 我们先来看一个抽离出来的简单的例子:

        
            #include <iostream>

template<typename... Args> void outnum(Args... list)
{
    int test[]={list...};

    for(int i = 0; i < 3; i ++)
    {
        std::cout<<test[i]<<std::endl;
    }
}

int main(void)
{
    outnum(1, 2, 3);
    return 0;
}
        
    

由于我们在那个Mat类中有一个dimension来存储了纬度的数目,因此在这个例子里我们先不考虑怎么获取参数个数的问题(我们已经知道了), 然后来看这个例子。

这个例子使用了初始化列表展开的形式, 如果所有的传入的类型都是int的话所有的参数都会通过int test[]的数组的初始化列表展开,然后数值存储在test这个数组里面,后面就可以使用了。

了解了这一点后,我们可以很轻松的对构造函数进行一点点改造

        
            public:	
	template<typename ...Args>
    Mat(Args... args)
    {
        int list[] = {args...};
        init();

        size = new int[dimension];
        for(int i=0;i<dimension;i++)
        {
            size[i] = list[i];
        }

        int totalsize = 1;
        for(int i=0;i<dimension;i++)
        {
            totalsize *= size[i];
        }

        data = new T[totalsize];
        
    

同理,对at函数也可以进行类似的改造。如果不出什么意外的话,现在我们就可以通过如下的方式使用这个类了

        
            int main(void)
{
    Mat<int, 3> test(4, 5, 6);
    test.at(1, 2, 3) = 5;
    int out = test[1][2][3];
    cout<<out<<endl;

    return 0;
}
        
    

就目前来看,这个类的使用已经相对比较方便了。

3. 后续

事实上,我们的工作远远没有完成,我们没有考虑任何意外的情况,现在只有在所有的操作都是正确的前提下才能正常工作,我们甚至连析构函数都没有。今天就先到这里,关于后续的一些异常的考虑,我将会在下一篇文章中继续讲解。