跳转至

C++的堆内存管理特性

约 3307 个字 347 行代码 预计阅读时间 15 分钟

有关右值引用和移动语义的细节原理, 见咸鱼暄的代码空间 > C++ > 右值引用与移动语义

拷贝构造和移动构造

拷贝构造

  • 浅拷贝: 复制对象内存.
  • 深拷贝: 复制对象内存以及复制对象成员指针指向的堆空间.

浅拷贝可能导致出现重复析构问题:

class String{
public:
    String(const char* str){
        size_ = strlen(str);
        str_ = new char[size+1];
        memecpy(str_, str, size_+1);
        cout<<"String ctor "<< str_.c_str() <<endl;
    }
    ~String(){
        delete str_;
        str_ = nullptr;
        cout<<"String dtor "<< size_ <<endl;
        size_= 0;
        
    }
    const char* c_str(){
        if(!str_) return "";
        return str_;
    }

private:
    char* str_{nullptr};
    int size_{0};
}

void testString(String s){//注意此处没有用引用
    cout << s.c_str()<<endl;
}

int main(){
    String str1("test test");
    TestString(str1);
}
上述代码会最后报错, 因为在向void testString()传参时发生了自动的拷贝(浅拷贝), 指向堆内存的指针str_也一并被拷贝了, 局部对象s在函数结束时析构同时释放了str_指向的堆内存, 但在main()函数结束时又发生了str1的析构, 重复释放同一块内存导致了报错.

实现深拷贝依赖于拷贝构造, 拷贝构造是一种转换构造函数. A::A(const A& objA){...return objB;} 对于上述例子, 可实现拷贝构造函数如下:

String(const String& s){
    size_ = s.size_;
    str_  = new char[size_+1];
    memcpy(str_, s.str_, size_+1);
    cout<<"String copy-ctor "<< str_.c_str() <<endl;
}

Note

注意, 并非使用引用传参就完全不需要拷贝构造, 但凡”需要申请内存把参数的值存下来”, 就很可能涉及到隐形的拷贝, 例如, 还是上文的String类, 定义一个MyString类包含String类成员变量并接受String类初始化:

class MyString{
public:
    MyString(String &s):str_(s){
        cout <<"MyString ctor " << str_.c_str()<<endl;
    }
private:
    String str_;
}

MyString使用成员初始化器列表来初始化, 此时也会调用String的拷贝构造函数(拷贝传入的参数s去构造成员变量str_).

移动构造

原先的对象不保留, 其资源(内存)移动给新构造的对象, 不需要新申请内存.

移动语义

当用对象A给对象BB=A;赋值, 且我们确保对象A再也不使用时, 我们此时最高效的办法是把A申请的空间直接移动给B(把A的堆指针与B互换), B对象就不需要申请空间, 大大降低了开销.

例如, Type obj=func();, func()的返回值是个临时对象, 此时若能直接把临时对象的资源(堆内存)交给obj, 免去给obj申请内存的操作, 就能降低开销;

值类别Value category

首先需要明确: 1. “value category”是独立于”type”的属性; 2. “value category”是”表达式的类别”而非值的类别(“The approach we take to provide guaranteed copy elision is to tweak the definition of C++’s ‘glvalue’ and ‘prvalue’ value categories (which, counterintuitively, categorize expressions, not values).”)

  • 左值lvalue: 可以取地址, 是一个变量实体(有名变量);

  • 纯右值prvalue: 只在乎其值语义(字面量, this指针, enum项);

  • 将亡值(临时但需要使用其地址的右值, expiring value)xvalue

右值引用

右值引用的”type”是引用类型, 和所有引用类型一样, 右值引用和汇编层面和指针一样. 这个指针的特殊之处在于其指向一块即将被释放的内存(一个右值的内存), 所以我们不能将其当成一块普通内存处理, 故我们定义了右值引用这一特殊的类型来限制对这块内存的操作.

右值引用变量本身是一个左值

注意表达式的值类别(value category)与变量/表达式所求的值的类型(type)是两个独立的概念, 右值引用的命名是因为我们借助这种type来使用右值, 其只能绑定右值, 并不意味着引用本身是一个右值(相反, 有名变量是左值). 例如在T && r = T{};move(r);中, 右值引用r是一个lvalue, T{}是一个prvalue, move(r)是一个xvalue.

右值引用最主要的用法之一就是移动语义和移动赋值: 移动将亡值的内存用以初始化/赋值新的对象. 具体的代码实现需要我们实现一个构造函数, 这个构造函数应该要针对将亡值做重载, 即我们采用一个将亡值作为构造参数时, 构造函数会移动该将亡值的内存.

在函数重载决议中存在 f(T&) 与 f(T&&) 这类对称重载时,xvalue 实参不能绑定到 T&,但可以绑定到 T&&,因此重载决议会选择 T&& 版本(例外是有const时, 如move(const var)只能绑定到f(const T&&)). 这也是移动构造/移动赋值会被编译器自动调用的原因. 同时我们也可以通过move()手动转换表达式为右值, 使其可以被绑定到右值引用的形参.

右值引用重载的意义

引入右值引用对于重载决议的意义在于我们实现了更灵活的重载: 不仅仅是基于”type”(f(int x)f(string x)), 而是基于一个变量的生存期阶段(是否将亡)/用途(其内存是否可被挪用).

move()

??? Note+ “cast表达式的值类别” 对于一个 cast 表达式:

- 如果目标不是引用,则该表达式是 prvalue

- 如果目标是左值引用,则该表达式是 lvalue

- 如果目标是对象的右值引用,则该表达式是 xvalue

std::move() = static_cast<T&&>(), 其作用是将表达式转换为右值引用. 这个转换本身不消耗姿源(编译期作用), 只改变程序如何对待表达式: 因为右值引用是用来访问一块即将销毁的内存, 将表达式转换为右值即告诉编译器可以认为该表达式所拥有的内存是可以被移动(给别的对象的).

简而言之, 语法上, 使用move()来匹配右值引用为形参的函数; 语义上, 使用move()来将一块内存视为可被移动.

自定义移动语义的String 类

#include <iostream>
#include <cstring>
using namespace std;

class String
{
private:
    char* str_{nullptr};
    int size_{0};

public:
    String(const char* s=""){
        size_ = strlen(s) + 1;
        str_ = new char[size_];
        memcpy(str_, s, size_);
        cout << "String constructor: " << "\"" << str_.c_str()  << "\" " << (size_t)((void *)str_) << endl;
    }

    String(const String& other){
        size_ = other.size_;
        str_ = new char[size_];
        memcpy(str_, other.str_, size_);
        cout << "String copy constructor: " << "\"" << str_.c_str()  << "\" " << (size_t)((void *)str_) << endl;
    }

    //注意移动构造函数的参数不能是const, 因为我们要将改参数的成员变量
    String(String &&other) noexcept {
         swap(str_, other.str_);
         swap(size_, other.size_);
         cout << "String move constructor: " << "\"" << str_.c_str() << "\" " << (size_t)((void *)str_) << endl;
     }

    ~String()
    {
        cout << "String destructor: " << "Release size " << size_ << " at address " << (size_t)((void *)str_) << endl;
        delete[] str_;
        str_ = nullptr;
        size_ = 0;
    }

    const char* c_str() const {
        if(!str_) return "";
        return str_;
    }

    size_t data() const {
        return (size_t)((void*)str_);
    } 
};

String func(String s){
    String s;
    cout << "testString, s2 " << s2.data() << endl;
    return s2;
}

int main(){
    String s("hello");
    cout << "main, s " << s.data() << endl;
    auto s1 = testString(s);
    cout << "main, s1 " << s1.data() << endl;
}

Copy Elision和 NRVO

  • Copy elision(省略拷贝/移动)是 C++ 标准允许(有时强制)的一类优化: 在某些语境中,本来语言语义上“好像要”构造一个临时对象、再拷贝/移动到目标对象,但实现可以直接在目标位置构造,从而不调用 copy/move ctor。 它是一个统称/大类,涵盖多种具体场景。

  • RVO(Return Value Optimization)和NRVO(Named RVO)是 copy elision 在 return 场景 的两种典型形式:

    • RVO或URVO(通常指“未命名返回值”的 elision) 例:return T(...);return make_T();(返回一个 prvalue 临时), 这里返回的是“未命名对象/临时对象”。

    • NRVO(Named Return Value Optimization) 例:T x; ...; return x;(返回一个具名局部变量),这里返回的是“有名字的局部对象”。

    RVO/NRVO 都是允许优化(permitted),不是必须. 用 -fno-elide-constructors 可以把它们大幅关掉. 注意RVO和NRVO不会在返回值是形参时生效!

C++17 起的强制copy elision

函数调用表达式的值类别

对于一个函数调用表达式:

  • 如果函数返回值类型不是引用,则该表达式是 prvalue

  • 如果函数返回值类型是左值引用,则该表达式是 lvalue

  • 如果函数返回值类型是对象的右值引用,则该表达式是 xvalue

有两个强制copy elision的规则:用 prvalue初始化对象, 以及返回一个与返回值类型相同的prvalue. 注意, 返回值类型不是引用的函数调用表达式是prvalue, 因此强制copy elision最常出现的两种情况通常涉及到函数调用表达式:

  • 对于第一条规则, 有如下例子:

    Foo makeFoo(Foo input) {
        return input; 
    }
    int main() {
        Foo foo = makeFoo(Foo{});
    }
    
    在这里, 由于返回值是形参, 不适用于NRVO, 所以c++此处的优化只可能是NRVO/RVO以外的copy elision. 这段代码会有一次默认构造(input)和一次移动构造(intput -> foo). 若不考虑强制copy elision, 会从Foo{}默认构造临时对象, 再使用移动或拷贝构造从临时对象构造input, 最后移动构造foo, 共1次默认构造和两次移动构造.

  • 对于第二条规则, 有如下例子:

    T2 getT2(){
        ...
        return T2{}; 
    }
    
    int main(){
        T2 obj1=getT2();//返回一个与返回值类型相同的prvalue(表达式T2())
    }
    
    当然, 即便在C++17之前, 上述T2 obj1=getT2();也会被RVO优化掉移动构造, 但其优化依据的规则是不同的. 最大的区别是, 在C++17之前, T2必须有移动构造函数(即便明知会被优化掉), 而C++17之后, T2可以删除移动构造函数.

RVO和NRVO

下面的代码展示了使用C++ 11/17, 分别启用/禁用NRVO的输出. 可见在启用NRVO时, 无论是C++11还是C++17, 都没有移动构造, 在testString()中的s2的构造实际上是对main中的s1的构造. 而在禁用NRVO时, C++11有两次移动构造, C++17只有一次移动构造. 由于这里c++ 17有强制copy elision, 函数表达式testString()是prvalue, 根据copy elision的第一种规则, 在C++ 17中临时对象testString()->s1这一次初始化发生了elision, s2直接初始化了s1. 而C++ 11中没有强制copy elision, 因此对于C++11而言, 这一次elision是编译器可选的, 所以受-fno-elide-constructors编译选项控制(而C++ 17中, 这一次elision是语言层面强制的, 就不受编译选项控制了. 若将下面的代码改为testString()直接返回形参来禁用NRVO, 即可见除了C++11启用-fno-elide-constructors时多一次移动构造, 其它3种情况的输出都相同.).

#include <iostream>
#include <cstring>
using namespace std;

class String
{
    ... ... 见上文: ### 自定义移动语义的String 
};

String testString(String s){
    String s2{s};
    cout << "testString, s2 " << s2.data() << endl;
    return s2;
}

int main(){
    String s("hello");
    cout << "main, s " << s.data() << endl;
    auto s1 = testString(s);
    cout << "main, s1 " << s1.data() << endl;
}

/*
1.
g++ -std=c++11 -fno-elide-constructors b.cpp -o b.exe
./b.exe

String constructor: "hello" 2513910633520
main, s 2513910633520
String copy constructor: "hello" 2513910633552
String copy constructor: "hello" 2513910634400
testString, s2 2513910634400
String move constructor: "hello" 2513910634400
String destructor: Release size 0 at address 0
String move constructor: "hello" 2513910634400
String destructor: Release size 0 at address 0
String destructor: Release size 6 at address 2513910633552
main, s1 2513910634400
String destructor: Release size 6 at address 2513910634400
String destructor: Release size 6 at address 2513910633520

2.
g++ -std=c++11 b.cpp -o b.exe
./b.exe

String constructor: "hello" 2032578729008
main, s 2032578729008
String copy constructor: "hello" 2032578729040
String copy constructor: "hello" 2032578729888
testString, s2 2032578729888
String destructor: Release size 6 at address 2032578729040
main, s1 2032578729888
String destructor: Release size 6 at address 2032578729888
String destructor: Release size 6 at address 2032578729008

3.
g++ -std=c++17 -fno-elide-constructors b.cpp -o b.exe
./b.exe

String constructor: "hello" 2535170708528
main, s 2535170708528
String copy constructor: "hello" 2535170708560
String copy constructor: "hello" 2535170709408
testString, s2 2535170709408
String move constructor: "hello" 2535170709408
String destructor: Release size 0 at address 0
String destructor: Release size 6 at address 2535170708560
main, s1 2535170709408
String destructor: Release size 6 at address 2535170709408
String destructor: Release size 6 at address 2535170708528

4.
g++ -std=c++17 b.cpp -o b.exe
./b.exe

String constructor: "hello" 2624135762992
main, s 2624135762992
String copy constructor: "hello" 2624135763024
String copy constructor: "hello" 2624135763872
testString, s2 2624135763872
String destructor: Release size 6 at address 2624135763024
main, s1 2624135763872
String destructor: Release size 6 at address 2624135763872
String destructor: Release size 6 at address 2624135762992

*/

运算符重载:拷贝赋值和移动赋值

运算符重载

  • 类内的重载: str1 = str2 等价于str1.operator=(str2);

  • 类外(全局)的重载+友元

    如果对于一个矩阵的类, 我们希望实现矩阵点乘的交换律, 即int*MatrixMatrix*int都能写, 但不支持修改基本类型int的重载定义, 可以写成全局的重载: Matrix oeprator*(int x, Matrix mat);

C++编译器会尝试x.operator*(y)operator*(x,y)两种重载解析.

友元在运算符重载中的应用

重载的运算符的函数实现内很可能需要对成员变量进行操作,但在全局重载时, 全局函数并非类的成员函数, 因此可能不能访问private成员变量. 因为在类的定义中, 需要将对应的类外的函数声明为一个友元:

const int M = 100;
class Matrix {
    int data[M][M];
public:
    Matrix operator+(Matrix mat) { /* */ }
    Matrix operator*(int x) { /* */ }
    Matrix operator*(Matrix mat) { /* */ }
    friend Matrix operator*(int x, Matrix mat); // Designates a function as friend of this class
};
Matrix operator*(int x, Matrix mat) {
    Matrix tmp = mat;   // copy mat
    for (int i = 0; i < M; i++)
        for (int j = 0; j < M; j++)
            tmp.data[i][j] *= x;        // can access private member Matrix::data
    return tmp;
}

拷贝赋值和移动赋值

  • 拷贝赋值String& operator = (const String& s)

  • 移动赋值String& operator = (String&& s)

拷贝赋值和移动赋值要注意, 常常需要先释放被赋值对象的原内存, 再重新分配内存并进行资源拷贝.

什么时候需要先清空再拷贝

主要是在有动态资源时: 比如, 一个字符串类可以存不同长度的的字符串. 如果原字符串为10字节, 拷贝赋值后为100 字节, 无法直接扩大原字符串的10字节内存到100字节. 以及, 有许多资源的语义不支持”原地改写”. 例如对象可能管理数据库的连接/文件句柄/socket等等, 这些都要求先正确释放旧资源, 再重新获取新资源. 另外的考虑在于, 在内存上局部修改使得对象赋值的途中容易出现不合法的状态, 例如len已经改了而data还没扩容成功, 容易造成逻辑混乱. 仅在纯值类的拷贝/移动赋值实现中, 例如class RGB (包含3个int成员变量), 可以直接对成员变量进行赋值.

注意考虑自己拷贝给自己和自己移动给自己的情况, this指针若和传入对象的地址相同, 则不应清空, 应直接返回.

class String
{
private:
    char* str_{nullptr};
    int size_{0};

public:
    ... ... 
    String& operator=(const String& s){
        if (this == &s) return *this;

        delete str_;
        str_ = nullptr;
        size_ = 0;

        size_ = s.size_;
        str_ = new char[size_];
        memcpy(str_, s.str_, size_);

        cout << "operator=(constr String& s):"  << "\"" << str_.c_str()  << "\" " <<endl;

        return *this;
    }

    String& operator=(String&& s)
    {
        if (this == &s)
            return *this;

        swap(str_, s.str_);
        swap(size_, s.size_);
        cout << "operator=(String&& s): " << "\"" << str_.c_str()  << "\" " << endl;

        return *this;
    }

    String& operator=(const char* s){
        if(!s) return *this;
        if(str_==s) return *this;

        delete str_;
        str_ = nullptr;
        size_ = 0;

        size_ = strlen(s) + 1;
        str_ = new char[size_];
        memcpy(str_, s, size_);
        cout << "operator=(const char* s): " << "\"" << str_.c_str()  << "\" " << (size_t)((void *)str_) << endl;
        return *this;
    }

    ... ...
}


String str1;
String str2=str1;//此处调用的是拷贝构造
String str3;str3=str2;//此处调用的是拷贝赋值

String str4;
String str5{str4.operator=(str3)};//此处先调用拷贝赋值,再调用拷贝构造.

String str6;
String str7{str6=str5};//此处也是先调用拷贝赋值,再调用拷贝构造

str1 = "testtest"; //Operator=(const char *)
str2 = std::move(str1)  //移动赋值

智能指针

#include <memory>

std::unique_ptr<Data> ptr (new Data);

//C++ 14才开始支持
std::unique_ptr<Data> p = std::make_unique<Data>();

unique_ptr, 将指针作为该类对象的成员变量, 同时让对象创建在栈区, 这样当对象脱离作用域时自动析构, 也释放指针所指向的内存. 同时禁止拷贝构造和拷贝赋值, 避免拷贝带来的开销与重复析构, 只能移动构造和移动赋值. 同时通过重载运算符使得该对象能够像指针变量一样被使用.

例子:

#include <iostream>
#include <memory>
using namespace std;
class Data
{
public:
    Data(){cout <<"ctor Data\n";}
    ~Data(){cout <<"dtor Data\n";}
    Print(){cout<<"print sth";}
}

int main(){
    unique_ptr<Data> ptr1(new Data);

    //C++14支持, 编译更精简, 减少代码重复赘余, 内存更安全
    auto ptr2=make_unique<Data>();
    
    /// 1.使用->和.将智能指针当成智能指针内部的指针使用
    ptr2->Print();
    (*ptr2).Print();
    
    /// 2. 使用.get()获取智能指针内部的指针
    auto ptr2_ptr = ptr2.get();
    ptr2_ptr->Print();

    /// 3. 使用.reset()修改智能指针
    auto ptr3=make_unique<Data>();
    ptr3.reset(new Data);//释放原有空间并把新的空间绑定到智能指针
    ptr3.reset(nullptr);//仅释放原有空间, 无新的空间

    /// 4. 释放控制权(尽量减少使用, 仅仅是为了与某些旧系统兼容)
    auto ptr4=make_unique<Data>();
    auto ptr4_ptr = ptr4.release();//ptr4此时不再绑定内存空间, 需要手动管理了
    delete(ptr4_ptr);

    /// 5. 智能指针的移动
    auto ptr5=make_unique<Data>();
    unique_ptr<Data> ptr6=move(ptr5);
    unique_ptr<Data> ptr7;
    ptr7 =move(ptr6);
}

unique_ptr的手动实现(不考虑线程安全)

//......使用上一节的class Data
class DataPtr
{
public:
    DataPtr() = default;//保持默认构造函数, 否则被带参数构造函数覆写
    DataPtr(Data *d){d_=d;}
    ~DataPtr(){delete d_;d_=nullptr;}

    DataPtr(const DataPtr&) = delete; //禁止拷贝构造
    DataPtr& operator=(const DataPtr&) = delete; //禁止拷贝赋值

    DataPtr(DataPtr&& dp){//移动构造
        swap(d_,dp.d_);
    }
    DataPtr& operator=(DataPtr&& dp){//移动赋值
        if(this==&dp)
            return *this;
        swap(this->d_, dp->d_);
        return *this;
    }

    Data* operator->(const DataPtr& dp){
        return d_;
    }

    Data* Get(){return d_;}
    void Reset(Data *d){
        if(d==d_) returnl
        delete d_;
        d_=d;
    }
    Data* Release(){
        auto d= d_;
        d_= nullptr;
        return d;
    }
    
private:
    Data* d_{nullptr};
}

int main(){
    Dataptr ptr(new Data);

}

在类内利用智能指针管理动态资源

在类内我们也常需要使用指针管理可变长度资源, 这样在析构时也需要考虑释放成员指针的问题, 利用智能指针也可以简便化这个场景.

  1. 智能指针禁止拷贝构造和拷贝赋值, 而若某类的成员变量的拷贝构造和拷贝赋值为delete, 该类的拷贝构造和拷贝赋值也自然为delete.
  2. 智能指针仅允许移动构造和移动赋值, 而类的移动构造和移动赋值会自然触发成员变量的移动构造和移动赋值
  3. 最后, 智能指针在析构时释放管理的资源, 而类的析构要求成员变量先析构.

所以使用类内的智能指针适用于那些要求移动而禁用拷贝的类. 例如: 使用智能指针实现一个类似于`vector<string>的StringVector类, 允许移动而禁止拷贝, 要求自动扩容:

///// StringVector.h /////
#pragma once
#include<memory>
#include<string>
class StringVector
{
public:
	StringVector(int capacity = 10);
	
	void Push(const std::string& s);

	const std::string& operator[](int index);

	int Size();

	int Capacity();

private:
	int size_{ 0 };
	int capa_{ 0 };
	std::unique_ptr<std::string[]> strs_;
};


///// StringVector.cpp /////
#include "StringVector.h"
#include <iostream>
using namespace std;

StringVector::StringVector(int capacity):capa_(capacity), strs_(make_unique<string[]>(capa_)){
	if (capacity <= 0) {
		capa_ = 10;
		strs_ = make_unique<string[]>(capa_);
		cout << "Capacity initialized to " << capa_ << " (default 10)"<< endl;
	}
}

void StringVector::Push(const string& s) {
	if (size_ == capa_) {
		auto ptr = make_unique<string[]>(capa_ * 2);
		for (int i = 0; i < size_;i++) {
			ptr[i] = move(strs_[i]);
		}
		strs_ = move(ptr);
		capa_ = capa_ * 2;
		cout << "Capacity expanded to " << capa_ << endl; 
	}
	strs_[size_++] = s;
}

const string& StringVector::operator[](int index) {
	return strs_[index];
}

int StringVector::Size() { return size_; }
int StringVector::Capacity() { return capa_; }

///// main.cpp /////
// StringVec_hw8.2.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//

#include <iostream>
#include "StringVector.h"
using namespace std;
int main()
{
	StringVector vec(2);
	vec.Push("test 001");
	vec.Push("test 002");
	vec.Push("test 003");
	for (int i = 0; i < vec.Size(); i++) {
		cout << vec[i] << endl;
	}
	return 0;
}

完美转发

评论