[C++] string管理:深浅拷贝&写时拷贝

2024-07-26 13:29:54 浏览数 (2)

拷贝问题的引入

问题代码

string类的构造函数
代码语言:javascript复制
String(const char* str = "")
{
    if (nullptr == str)
    {
        assert(false);
        return;
    }
    _str = new char[strlen(str)   1];
    strcpy(_str, str);
}

这个构造函数分配了动态内存来存储字符串,并复制了传入的 str 字符串。但当你用 new 分配内存并用 _str 变量存储时,你并没有处理已有 _str 的情况,例如拷贝构造或赋值操作。这样会在对象被拷贝或赋值时出现问题。

String 类的析构函数
代码语言:javascript复制
~String()
{
    if (_str)
    {
        delete[] _str;
        _str = nullptr;
    }
}

析构函数负责释放动态分配的内存。这是一个好的实践,但在没有拷贝构造函数的情况下,如果多个对象指向同一块内存,析构函数会尝试释放相同的内存多次,导致程序崩溃

测试入口函数(问题)
代码语言:javascript复制
void TestString()
{
    String s1("hello bit!!!");
    String s2(s1); // 这里调用了拷贝构造函数
}

TestString 函数中,s1 是一个 String 对象,s2 是通过 s1 进行拷贝构造的。**如果没有拷贝构造函数,编译器会生成一个默认的拷贝构造函数,该默认构造函数仅逐位复制成员变量,对于指针类型,这会导致 **s1****s2** 指向同一块内存区域。这样,当 **s1****s2** 的析构函数被调用时,会尝试释放同一块内存,导致程序崩溃。 **

详细分析

通过以上代码及解析可以发现,在VS下,当没有拷贝构造函数的话,会直接将被构造的那个对象中成员的指针指向拿来构造的对象的指针指向的空间。当程序结束时,因为有析构函数,所以会将两个对象进行析构,又因为两个对象中的指针指向的是同一块空间,所以会对同一块空间析构两次,造成程序崩溃。

由此 -> 引出深浅拷贝的概念

浅拷贝

浅拷贝也称为位拷贝,当不存在拷贝构造函数或者重载的赋值运算符时,编译器会将对象中的值拷贝过来。如果对象中包含指针等资源管理信息,这种方式会导致多个对象共享同一份资源。当一个对象销毁时,会将该资源释放掉,而其他对象不知道资源已被释放,继续操作会导致访问违规。

代码语言:javascript复制
class ShallowCopy {
public:
    ShallowCopy(int* data) : data_(data) {}
    int* getData() const { return data_; }
private:
    int* data_;
};

void example() {
    int* data = new int(42);
    ShallowCopy obj1(data);
    ShallowCopy obj2 = obj1;
    delete data;
    // obj2.getData() 现在是悬空指针,继续访问会出错
}

在上述例子中,obj1obj2共享同一个指针data,当删除data后,obj2中存储的指针变成悬空指针。

深拷贝

深拷贝是为了解决浅拷贝的问题,每个对象都有一份独立的资源,不与其他对象共享。这样,当一个对象销毁时,其他对象的资源不会受到影响。例如:

代码语言:javascript复制
class DeepCopy {
public:
    DeepCopy(int* data) : data_(new int(*data)) {}
    DeepCopy(const DeepCopy& other) : data_(new int(*other.data_)) {}
    ~DeepCopy() { delete data_; }
    int* getData() const { return data_; }
private:
    int* data_;
};

void example() {
    int* data = new int(42);
    DeepCopy obj1(data);
    DeepCopy obj2 = obj1;
    delete data;
    // obj1 和 obj2 都有独立的 data
}

在上述例子中,obj1obj2都有各自独立的data,删除原始data指针后,它们的资源仍然有效。

传统版与现代版的String

传统String

传统版的String类使用深拷贝来管理字符串资源。以下是其示例代码:

代码语言:javascript复制
class String {
public:
    String(const char* str = "") {
        if (nullptr == str) {
            assert(false);
            return;
        }
        _str = new char[strlen(str)   1];
        strcpy(_str, str);
    }

    String(const String& s) : _str(new char[strlen(s._str)   1]) {
        strcpy(_str, s._str);
    }

    String& operator=(const String& s) {
        if (this != &s) {
            char* pStr = new char[strlen(s._str)   1];
            strcpy(pStr, s._str);
            delete[] _str;
            _str = pStr;
        }
        return *this;
    }

    ~String() {
        delete[] _str;
    }

private:
    char* _str;
};

在这个版本中,每个String对象都有独立的字符串数据,通过拷贝构造函数和赋值运算符重载实现深拷贝。

现代版String

现代版的String类使用资源管理技术,如智能指针或“写时拷贝”(Copy-On-Write, COW),来优化资源管理。以下是其示例代码:

代码语言:javascript复制
class String {
public:
    String(const char* str = "") {
        if (nullptr == str) {
            assert(false);
            return;
        }
        _str = new char[strlen(str)   1];
        strcpy(_str, str);
    }

    String(const String& s) : _str(nullptr) {
        String temp(s._str);
        swap(_str, temp._str);
    }

    String& operator=(String s) {
        swap(_str, s._str);
        return *this;
    }

    ~String() {
        delete[] _str;
    }

private:
    char* _str;
};

现代代码的灵活之处就在于swap(_str, temp._str); 当使用swap(_str, temp._str);时,swap底层会将_str指向的空间与temp._str指向的空间相互交换。这样的话就可以将_str指向已经构造好的temp._str的空间,然后temp._str指向的之前_str不需要的空间会在temp._str生命周期结束的时候通过析构函数进行释放。 简单理解为:**temp._str**承包了构造和析构的活,而**_str**只是负责与**temp._str**交换一下需要的空间地址。

写时拷贝

写时拷贝是一种优化技术,只有在需要修改时才执行深拷贝,而读取操作仍然共享资源。实现写时拷贝通常需要引用计数来管理资源。

关键点:

  • 引用计数:每个共享资源都有一个引用计数,当一个对象引用该资源时,引用计数增加;当对象销毁时,引用计数减少。
  • 深拷贝触发:当一个对象试图修改共享资源时,如果引用计数大于1,则执行深拷贝,这样修改不会影响其他共享该资源的对象。
  • 析构函数:当对象销毁时,如果引用计数减为0,则释放资源。

先构造的对象后析构的影响

考虑如下情景:

  1. 对象A的构造:对象A创建时分配资源,引用计数为1。
  2. 对象B的构造(通过拷贝构造):对象B通过拷贝构造从对象A创建,引用计数增加到2。
  3. 对象B的析构:当对象B销毁时,引用计数减少到1,但资源不释放,因为对象A仍在使用该资源。
  4. 对象A的析构:当对象A销毁时,引用计数减少到0,资源被释放。

由于对象B是从对象A拷贝构造而来的,在对象B修改资源前引用计数已经增加,因此写时拷贝能够正常工作。因为对象的生命周期顺序(先构造的对象后析构),确保了引用计数正确管理资源的分配和释放。

总结: 因为对象的析构顺序是反向的,即后构造的对象先析构,这种顺序确保了在写时拷贝机制中,资源的引用计数能够正确地管理和释放。通过引用计数,我们可以确定资源在没有对象使用时才被释放,从而保证了写时拷贝的正确性和效率

写时拷贝举例及测试样例

代码举例
代码语言:javascript复制
class String {
public:
    String(const char* str = "") 
        : _str(new char[strlen(str)   1])
        , _count(new int(1))  // 一个string对象刚开始的 _count 就是 1
    {
        strcpy(_str, str);
    }

    String(const String& s) 
        : _str(s._str)
        , _count(s._count) 
    {
          (*_count);
    }

    String& operator=(const String& s) {
        if (this != &s) {

            // 确保在没有对象再引用该资源时,正确地释放内存以避免内存泄漏
            if (--(*_count) == 0) {
                delete[] _str;
                delete _count;
            }
            _str = s._str;
            _count = s._count;
              (*_count);
        }
        return *this;
    }

    ~String() {
        if (--(*_count) == 0) {
            delete[] _str;
            delete _count;
        }
    }

    void modify(const char* newStr) {
        if (*_count > 1) {
            --(*_count);
            _str = new char[strlen(newStr)   1];
            strcpy(_str, newStr);
            _count = new int(1);
        }
        else {
            delete[] _str;  // 先释放旧的字符串内存
            _str = new char[strlen(newStr)   1];
            strcpy(_str, newStr);
        }
    }

    const char* c_str() const {
        return _str;
    }

private:
    char* _str;
    int* _count;
};

在这个拷贝函数中:

  • 共享资源:新对象_str指向原对象的字符串数据_str,新对象的引用计数指针_count也指向原对象的引用计数。
  • 增加引用计数 (*_count)表示将引用计数加1,这样可以跟踪有多少个对象共享这份资源。
  • 修改操作:当modify方法被调用时,如果引用计数大于1,则进行深拷贝并独立修改,否则直接修改原有字符串。
  • 析构函数:减少引用计数,当引用计数为0时释放资源。

当深拷贝触发时,即需要改变资源指向时,会进行_count数值的确认

代码语言:javascript复制
if (--(*_count) == 0) {
    delete[] _str;
    delete _count;
}

当确认已经没有其他对象共享该资源时会进行销毁。

测试用例
代码语言:javascript复制
void testCopyOnWrite() {
    String s1("Hello");
    String s2 = s1;

    std::cout << "修改前:" << std::endl;
    std::cout << "s1: " << s1.c_str() << ", s2: " << s2.c_str() << std::endl;

    s1.modify("World");

    std::cout << "修改后:" << std::endl;
    std::cout << "s1: " << s1.c_str() << ", s2: " << s2.c_str() << std::endl;
}

int main() {
    testCopyOnWrite();
    return 0;
}

输出:

代码语言:javascript复制
修改前:
s1: Hello, s2: Hello
修改后:
s1: World, s2: Hello
  1. **s1****s2**共享资源:在创建s2时,s2通过拷贝构造函数共享s1的资源,引用计数为2。
  2. 修改**s1**
    • 调用modify方法时,检查引用计数。
    • 由于引用计数大于1(说明有其他对象共享资源),s1进行深拷贝:分配新内存,将新字符串复制到新内存,并初始化新的引用计数。
    • 此时,s2仍然保持原来的字符串内容不变。

通过这种方式,写时拷贝机制可以有效地管理共享资源,确保在需要修改时进行深拷贝,避免不必要的内存拷贝操作。

0 人点赞