C++:Copy-On-Write技术以及string类的模拟实现

Posted It‘s so simple

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++:Copy-On-Write技术以及string类的模拟实现相关的知识,希望对你有一定的参考价值。


前言

深浅拷贝(深浅赋值)

在以前的文章C++:类的6个默认成员函数以及深浅拷贝,我们已经深入的探讨过深浅拷贝的问题,如果不是很清楚深浅拷贝的,可以先去看看这篇文章。

我们这里直接给出深浅拷贝的定义。

浅拷贝

也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进行操作时,就会发生发生了访问违规

如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显示的给出,一般都是按照深拷贝的方式提供。

深拷贝

给每个对象独立的分配资源,保证多个对象之间不会因共享资源而造成多次释放,以至于造成程序崩溃的问题。

1. 深拷贝中可能存在的一些坑(🕳)

我们来模拟实现一个string类,由于它的成员变量是一个char*的指针,所以我们实现其拷贝构造函数,赋值构造函数的时候,就需要用到深拷贝技术。

代码如下:

#include <iostream>
#include <cstring>

using namespace std;

namespace mytest{
    class string{
        friend ostream& operator<<(ostream& out,const string& s);
        private:
            char* _arr;
        public:
            explicit string(): _arr(nullptr)
            {}
            string(const char* s = "")
            {
                _arr = new char[strlen(s)+1];
                strcpy(_arr,s);
            }

            string(const string& s)
            {
                _arr = new char[strlen(s._arr)+1];
                strcpy(_arr,s._arr);
            }

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

            ~string()
            {
                delete[] _arr;
                _arr = nullptr;
            }


    };

     ostream& operator<<(ostream& out,const string& s)
     {
         out << s._arr;
         return out;
     }
}

这个代码打眼一看确实没什么问题,大多数人实现深拷贝可能也是和我一样,都这样实现,并且认为是没什么问题的。但是这个代码还是存在着一些异常不安全的问题。

所谓异常不安全就是在程序发生异常的时候,原来的指针、空间、内存等状态安不安全,或者是说被修改了。那么异常安全可以说是即使发生异常也不会泄露资源或允许任何数据结构破坏,程序的回退是干净的

扩展:线程安全:是指多个执行流在访问同一临界资源的时候,不会导致程序结果产生二义性。

那么再来看下我们的这段代码:

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

假设当前程序的堆上的空间已经满了,已经没有办法再分配空间给_arr变量了,这个时候我们程序就会出现错误,当申请空间失败的时候,new函数会抛出一个异常,表示申请失败,但这个时候,我们已经将_arr原来的空间给释放掉了,_arr就会变成一个野指针,这样就造成了程序的异常不安全的问题。当new一个空间失败的时候,程序的回退是不干净的。

那么该如何改进呢?有两种方法。

改进1:拿一个临时的char*类型的变量来进行申请空间,若申请成功,则将该空间的地址赋值给_arr指针。

string& operator=(const string& s)
{   
    if(this != &s)
    {  
    	char* tmp = new char[strlen(s._arr)+1];  
        delete[] _arr; 
         _arr = tmp; 
        strcpy(_arr,s._arr); 
	} 
    return *this;
}

改进2: 创建一个临时的对象,用s_arr去初始化该对象,然后再交换当前对象的_arr和临时对象的_arr

string& operator=(const string& s)    
{    
    if(this != &s)    
    {    
        string tmp(s);    
        std::swap(_arr,tmp._arr);
	}       
	return *this;
} 

对比一下改进1和改进2,我们可以发现改进2更为妙一些!因为它调用拷贝构造函数直接申请了一个空间,通过swap函数交换了各自指针的指向,并且它是一个临时的变量,当该函数运行完的时候,他会自动的调用自己的析构函数进行析构,就不需要我们操心它的内存释放的问题了。

代码改进:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

namespace mytest{
    class string{
        friend ostream& operator<<(ostream& out,const string& s);
        private:
            char* _arr;
        public:
            explicit string(): _arr(nullptr)
            {}
            string(const char* s = "")
            {
                _arr = new char[strlen(s)+1];
                strcpy(_arr,s);
            }

            //程序是异常安全的,因为如果空间不足,程序就会抛出异常,并且回退的过程中都是干净的
            string(const string& s):_arr(nullptr)
            {
                //注意这里一定要用一个string来接收
                //若为char* tmp = s._arr 来写的话会造成Double Free问题
                //因为tmp指向的是s._arr的那个空间,当s释放之后,
                //当前对象再次进行释放的时候就会对_arr那个空间进行重复的释放。
                string tmp(s._arr);
                std::swap(_arr,tmp._arr);
            }

            string& operator=(const string& s)
            {
                if(this != &s)
                {
                    //用一个临时变量来进行接收有两大好处
                    //1.tmp是调用了拷贝构造函数进行初始化的,该过程是异常安全的
                    //2.因为tmp是临时变量,因此在赋值重载完了后,
                    //就会自动的调用析构函数进行析构,我们不必自己单独的将原来的_arr空间释放掉,
                    //然后在交换了,这样做的简直就是一举三得。
                    string tmp(s);
                    std::swap(_arr,tmp._arr);
                }
                return *this;
            }

            ~string()
            {
                if(_arr)
                {
                    delete[] _arr;
                    _arr = nullptr;
                }
            }


    };

     ostream& operator<<(ostream& out,const string& s)
     {
         out << s._arr;
         return out;
     }
}

2.Copy-On-Write技术

我们前面也谈了深浅拷贝,那么问题来了,到底是用深拷贝好还是用浅拷贝好?

这个时候,有人可能会说当一个类的成员变量中有类似于指针这种类型的变量存在,实现的时候就需要进行深拷贝,反之,当没有这种变量存在的时候就使用浅拷贝。

这个回答可以再大的方面来说是没有问题的,但是,如果存在这样一种情况呢?就拿string类进行举例,假如我这个string类是用深拷贝实现的,那么,如果我对string的操作就仅仅单纯是一个访问操作呢?就是我定义的这个对象不会调用拷贝构造函数或赋值重载函数,就不会涉及到深拷贝的问题。这个时候,用深拷贝去实现这个类会不会有些大材小用的感觉?因为浅拷贝就完全可以满足我的要求了。

那如果只使用浅拷贝,万一又遇到这种需要进行拷贝构造的形式,岂不是要再重新顶一个string类?显然这是不可能的。那该怎样做才是最好的呢?

这个时候就有一种技术,Cpoy-On-Write技术,俗称写时才拷贝技术,它就是编程界“懒惰行为”——拖延战术的产物。举个例子的话,我们每次往一个文件中写数据的时候,调用fwirte函数,但是它并不会立即的写到这个文件中去,而是都写在特定大小的一块内存中(磁盘缓存),只有当我们关闭文件时,才写到磁盘上(这就是为什么如果文件不关闭,所写的东西会丢失的原因)。

2.1 原理

引用计数就是string类中写时才拷贝的原理,当string类实例化出不同对象,这些对象在进行共享内存的操作的时候,就会触发写时才拷贝的机制。

那什么时候才会进行共享内存呢?一般来说就两种情况,一是调用别的类来构造自己(触发拷贝构造函数),二是以别的类来赋值(触发赋值重载函数)。

那什么时候才会对共享内存进行操作呢?就是我们平常所进行的 +=、=、+、[ ]等等操作,这个时候不同的对象就会对同一个共享内存进行操作,在这个时候就要触发我们的写时才拷贝技术,要不然就又变为浅拷贝造成Double Free的问题。

那么,引用计数是如何实现我们的共享内存的管理呢?

2.2 引用计数对共享内存的管理

引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。

假设现在有一个string s1("abc")的对象,当它初始化的时候,我们可以将它的引用计数器设置为1,表示当前"abc"这块空间有一个对象在使用,当我们使用string s2(s1)拷贝构造它的时候,只需要将指向"abc"这个空间的引用计数器进行加1操作即可。

然后在对象析构的时候,我们只需要将它所指向的那块空间的引用计数器进行减1操作即可,当引用计数器减至0的时候,最后一次进行减1操作的那个对象就要负责对这块空间进行回收。以免内存泄漏的情况产生。

2.3 写时才拷贝的代码实现

为了保证string类实例化出的不同的对象拥有不同空间的引用计数器,我们需要将该引用计数器封装成为一个类,使用该类作为string类的成员变量。

代码如下:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

namespace mytest{
    class string;
    class stringRefC
    {
        friend ostream& operator<<(ostream& out,const stringRefC& s);
        friend class string;
        private:
        int ref_count;
        char* _arr;
        public:
        stringRefC(const char* s = "") : ref_count(0)
        {
            _arr = new char[strlen(s)+1];
            strcpy(_arr,s);
        }
        stringRefC(const stringRefC& s);


        void increaseCount()
        {
            ++ref_count;
        }

        void decraseCount()
        {
            if(--ref_count == 0)
            {
                //如果当计数器为0的时候,就调用自己的析构函数
                delete this;
            }
        }
        
        int use_count()const
        {
            return ref_count;
        }

        ~stringRefC()
        {
            if(ref_count == 0)
            {
                delete []_arr;
                _arr = nullptr;
            }
        }


    };
    class string{
        friend class stringRefC;
        friend ostream& operator<<(ostream& out,const string& s);
        private:
        stringRefC* _ref; 
        public:
        explicit string(): _ref(nullptr)
        {}
        string(const char* s= ""):_ref(new stringRefC(s))
        {
            _ref->increaseCount();
        }
        string(const string& s) : _ref(s._ref)
        {
            _ref->increaseCount();
        }
        string& operator=(const string& s)
        {
            if(this != &s)
            {
                _ref->decraseCount();
                _ref = s._ref;
                _ref->increaseCount();
            }
            return *this;
        }

        ~string()
        {
            _ref->decraseCount();
        }
    };
    
     ostream& operator<<(ostream& out,const string& s)
       {
           out << s._ref << ",ref_count = " << s._ref->use_count();
           return out;
       }
};

void test()
{
    mytest::string s("abc");
    cout << "s:" << s << endl;
    mytest::string r("def");
    cout << "r:" << r << endl;

    cout << "copyonwrite:r = s" << endl;
    r = s;
    cout << "r:" << r << endl;

    cout << "copyonwrite: string k(r)" << endl;
    mytest::string k(r);
    cout << "k:" << k << endl;
}

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

运行结果:

在这里插入图片描述

更为详细的关于写时才拷贝的情况,可以查看:C++ STL STRING的COPY-ON-WRITE技术

3. string类的模拟实现

3.1 string类的介绍

  • string是表示字符串的字符串类
  • 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
  • string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator> string;
  • 不能操作多字节或者变长字符的序列
  • 注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。
  • size()length()方法底层实现原理完全相同,引入size()的原因是为了与其他容器的接口保持一致,一般情况下基本都是用size()
  • clear()只是将string中有效字符清空,不改变底层空间大小。
  • resize(size_t n)resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字符个数增多时:resize(n)用0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。
  • reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于
    string的底层空间总大小时,reserver不会改变容量大小。

更为详细的查看string文档介绍

3.2 模拟代码实现

#include <iostream>
#include <assert.h>
#include <cstring>
#include <algorithm>

using namespace std;


namespace mytest{
    class string
    {
        friend ostream& operator<<(ostream& out,const string& s);
        public:
        explicit string() : _arr(nullptr),_size(0),_capacity(0)
        {}
        string(const char* s) : _arr(nullptr),_size(0),_capacity(0)
        {
            _size = strlen(s);
            _capacity = _size;
            _arr = new char[_size];
            strcpy(_arr,s);
        }
        string(const string& s):_arr(nullptr),_size(0),_capacity(0)
        {
            string tmp(s._arr);
            swap(tmp);
        }
        string& operator=(const string& s)
        {
            if(this != &s)
            {
                string tmp(s._arr);
                swap(tmp);
            }
            return *this;
        }
        ~string()
        {
            if(_arr)
            {
                delete []_arr;
                _arr = nullptr;
            }
        }
        public:
        typedef char* iterator;
        //iterator
        iterator begin() const
        {
            return _arr;
        }
        iterator end() const
        {
            return _arr+_size;
        }

        iterator insert(iterator pos,char c)
        {
            if(_size == _capacity)
            {
                size_t oldpos = pos - begin();
                size_t newcapacity = _capacity == 0? 1 : 2*_capacity;
                //先扩容
                resever(newcapacity);

                pos = begin() + oldpos;
            }
            iterator it = end();
            while(it > pos)
            {
                *it = *(it-1);
                --it;
            }
            *pos = c;
            _size++;
            _arr[_size] = '\\0';
            return pos;
        }
        string& insert(size_t pos,char c)
        {
            if(_size == _capacity)
            {
                //扩容
                size_t newcapacity = _capacity == 0 ? 1 :  2*_capacity;
                resever(newcapacity);
            }
            size_t sz = size();
            while(sz > pos)
            {
                _arr[sz] = _arr[sz-1];
                --sz;
            }
            _arr[posSwift 多线程环境中的 Copy-on-Write

Linux写时拷贝技术(copy-on-write)

死磕 Java 基础 — 谈谈那个写时拷贝技术(copy-on-write)

死磕 Java 基础 — 谈谈那个写时拷贝技术(copy-on-write)

死磕 Java Core — 谈谈那个写时拷贝技术(copy-on-write)

标准C++类std::string的内存共享和Copy-On-Write技术