C++内存管理:使用new和智能指针操作dynamic array


在之前的动态内存管理中,涉及的多为单一对象,但是在应用中也有这样的需求,在一次内存申请中需要一个连续的内存空间,为了满足这种需求,有两种解决方法:

  • 使用各种类型的container

    根据不同的需求,可以使用vector,其将变量名称保存在stack中,而数据存储在heap,与dynamic array申请的内存在同一个区域。

    Most applications should use a library container rather than dynamically allocated arrays. Using a container is easier, less likely to contain memorymanagement bugs, and is likely to give better performance.

    而且在C++ Primer中也建议大家多使用这种container。

  • 使用dynamic array来动态的分配一块内存空间;

即便container是一个更好的选择,但是dynamic array也是一种解决方案。关于dynamic array的使用和管理,有两种途径:

  • 直接使用指针类型

    与分配单个对象的使用类似,包括:

    • 通过 new/delete 实现;
    • 通过smart poiners实现;

    这是本篇文章的主要内容。

  • 使用封装类allocator

    这种方法相比于上述方法,性能更好,也更灵活。

使用new/delete管理dynamic array

声明与初始化

使用new声明,申请10个int数据对应的内存空间,并将指向第一个int的指针返回给pa1,但是这里没有初始化。

不同于普通数据的声明限制,其要求数组的dimension在编译时已知,dynamic array没有这个要求,因为就是在运行时才会动态申请内存,因此使用get_size()也是可以指定申请的内存的空间的,也不需要是constants。

class Foo{
public:
    Foo(){
        cout << "call foo constructor" << endl;
    }
};

// 1. declaration
int *pa1 = new int[10];
// 2. ok, use get_size() to indicate the dimension of pa2.
int *pa2 = new int[get_size()];
// 3. call foo constructor three times.
Foo *pa3 = new Foo[3];

/** output
call foo constructor
call foo constructor
call foo constructor
*/

但是定义类型别名时,编译器也会使用new[]来申请内存,如下所示,分别使用了typedefusing定义Foo数组类型,之后通过这些类型别名来申请内存。

class Foo{
public:
    Foo(){
        static int count = 0;
        count += 1;
        cout << count << " : call foo constructor" << endl;
    }
};

int main()
{
    typedef Foo arrT1[2];
    using arrT2 = Foo[2];

    Foo *ap1 = new arrT1;
    Foo *ap2 = new arrT2;

    cout << typeid(ap1).name() << endl;
    cout << typeid(ap2).name() << endl;
}
/**
1 : call foo constructor
2 : call foo constructor
3 : call foo constructor
4 : call foo constructor
P3Foo
P3Foo
*/

另外,对于dynamic array的初始化而言,如果是上述的方式时,则为default initialization如同单个对象的方式一样

  • 对于基础类型,则是未定义的值;
  • 对于类类型,则调用默认构造函数;
int *p = new int[10];
// the value of p is undefined and can be any value.
cout << *p << endl;

可以通过value initialize的方式完成初始化,这样可以避免不确定的行为。

// all elements are initialized into 0.
int *pa1 = new int[10]();
// all elements are initialized into empty string.
string *pa2 = new string[10]();

但是要注意一点,我们不能在()中放入初始化数据,这种会导致错误如下。

int *pa1 = new int[3](23, 45, 12);
/**
error: parenthesized initializer in array new
*/

新标准下可以使用list initialization。

  • 当list中的元素比dimension少时,剩余的使用value initialization;

    int *pa1 = new int[4]{23, 45, 12};
    cout << *(pa1+3) << endl;
    /**
    output: 0
    */
    
  • 当超出dimension时,则申请内存失败,如下:

    int *p = new int[2]{12, 45, 67};
    /**
    error: excess elements in array initializer
    */
    

new的注意事项

不是数组类型

对于通过new得到的dynamic array而言,准确的说不是数组类型,得到的返回值是指向第一个元素的指针。一个显著的特性就是,不能对这种dynamic array使用beginend,因为这两个函数需要使用数组的dimension来定位对应的指针位置。但是可以使用下标来访问对应的数组元素。

// ok
int a[3] = {3, 4, 5};
for(auto p = begin(a); p!= end(a); p++){
    cout << *p << endl;
    // p[1] is ok
    // cout << p[1] << endl;
}

int *pa = new int[3]{23, 45, 12};
// compile error, error: no matching function for call to 'begin'
cout << *(begin(pa) + 1) << endl;

基于同样的原因,也不能使用range-for语句,因为无dimension信息。但是如下的遍历方法是可行的:

int d = 3;
int *pa = new int[d]{23, 45, 12};
for(int *s = pa; s != pa + d; s++) {
    cout << *s << endl;
}
/**
output:
23
45
12
*/

允许申请0空间的内存

诡异的是,new允许申请dimension为0的内存空间,如下:

int a[0];
cout << a << endl;
cout << *a << endl;

int *p = new int[0];
cout << p << endl;
cout << *p << endl;
/**
0x7ffee7a29398
259436581
0x7fb0d55058d0
0
*/

从上述的输出中发现,即便是申请0空间的dynamic array,也是会成功的,也会返回对应的指针, 这种指针是off-the-end pointer,即指向每个数组最后一个元素之后的元素的地址。但是上述通过指针找到对应的元素的做法都是非常尾危险的,因为得到的数据是不确定的

C++ Primer中说不允许定义dimension为0的普通数组,但是尝试后发现是可以的,在clang 12编译器和c++11标准下。

delete的注意事项

在释放new申请的dynamic array时,与释放单个对象类似,但是仍有一定的区别。

释放顺序

delete释放内存的顺序与初始化的顺序完全相反,即首先释放内存中的最后一个对象,接着向前直到第一个。

class Foo{
public:
    int m_a;
    Foo(int a): m_a(a){
        cout << m_a << ": call constructor" << endl;
    }

    ~Foo(){
        cout << m_a << ": call destructor" << endl;
    }
};

int main()
{
    Foo *foo = new Foo[2]{Foo(1), Foo(2)};
    delete[] foo;
    return 0;
}
/** output
1: call constructor
2: call constructor
2: call destructor
1: call destructor
*/

在上述代码中,发现Foo的构造函数和析构函数均被调用多次,与dynamic array的dimension相同。

必须使用[]

在释放dynamic array对应的内存时,必须使用[],否则行为未可知。

If we omit the brackets when we delete a pointer to an array (or provide them when we delete a pointer to an object), the behavior is undefined.

即便使用类型别名时,也需要指定[],如下:

typedef int arrT[42];
int *p = new arrT;
// ok
delete [] p;
// error
//delete p

使用smart pointers管理dynamic array

unique_ptr申请dynamic array

  • unique_ptr申请连续的内存空间时,采用new完成申请。

    int main()
    {
        unique_ptr<Foo[]> up(new Foo[2]{Foo(1), Foo(2)});
        return 0;
    }
    /** output
    1: call constructor
    2: call constructor
    2: call destructor
    1: call destructor
    */
    

    这里的写法是,要保证 new Foo[2]{Foo(1), Foo(2)} 是一个Foo*类型。

  • 在unique_ptr中还重载了[],可以直接访问其中的元素,如下:

    cout << up[1].m_a << endl;
    /**
    output: 2
    */
    

    支持的函数如下图所示:

    unique_ptr支持dynamic array的操作

  • 还有另外一种写法,是通过make_unique实现的,这是在C++14才引入的函数,如下:

    class Foo{
    public:
        int m_a;
        Foo(){
            cout << "call default constructor" << endl;
        }
        Foo(int a): m_a(a){
            cout << m_a << ": call constructor" << endl;
        }
    
        ~Foo(){
            cout << m_a << ": call destructor" << endl;
        }
    };
    
    int main()
    {
        unique_ptr<Foo[]> up = make_unique<Foo[]>(2);
        return 0;
    }
    /**
    call default constructor
    call default constructor
    0: call destructor
    0: call destructor
    */
    

    在上述代码中,使用make_unique时,需要指定数组的dimension,且必须在类中指定默认构造函数,无法指定特定的构造函数。

shared_ptr申请dynamic array

shared_ptr中申请dynamic array比unique_ptr还要麻烦。

  • shared_ptr中不支持自动释放dynamic array的内存资源,因此需要显式给出deleter,通过delete[]。如下:

    shared_ptr<Foo> sp(new Foo[2]{Foo(1), Foo(2)}, [](Foo *foo) { delete[] foo; });
    /** output
    1: call constructor
    2: call constructor
    2: call destructor
    1: call destructor
    */
    

    注意,这里还有跟unique_ptr不同的地方在于模版类型中不用Foo[],直接用Foo就行。上述这种写法在C++11中就支持了。

    这里还产生一个疑问,如果我不指定自定义的deleter,会发生啥?

    shared_ptr<Foo> sp(new Foo[2]{Foo(1), Foo(2)});
    cout << sp.get() << endl;
    cout << sp << endl; 
    

    编译不会出错,运行出现如下错误:

    shared_ptr中不提供自定义的deleter时出错

    从运行结果来看,Foo(2)的析构函数没有被执行,因此大胆猜测,如果我们不提供自定义的deleter,默认情况下使用的是deleter sp,也就是将第一个元素释放了。

  • c++11中的shared_ptr不支持使用[]访问元素,如下:

    cout << sp[1].m_a << endl;
    // error: type 'shared_ptr<Foo>' does not provide a subscript operator
    

同样的,shared_ptr中也支持使用make_shared申请dynamic array,如下,但是其中涉及的内容较多,超出了本篇的内容,可以查看参考资料2阅读。

总结

对于dynamic array的使用频率不算多,而且在C++ Primer中也建议多用vector等container,但是对于需要独占控制权等场景,unique_ptr等还是有其用途的,对比之下vector等需要进行拷贝。

参考资料

  1. C++ Primer - 12.2.1 new and Arrays
  2. https://www.cppstories.com/2021/smartptr-array/
  3. https://www.nextptr.com/question/qa1348405750/dynamic-array-with-stdunique_ptr

文章作者: alex Li
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 alex Li !
  目录