【C++】手搓一个STL风格的string容器

06-02 1155阅读

C++ string类的解析式高效实现

GitHub地址

有梦想的电信狗

1. 引言:字符串处理的复杂性

​ 在C++标准库中,string类作为最常用的容器之一,其内部实现复杂度远超表面认知。本文将通过一个简易仿照STL的string类的完整实现,揭示其设计精髓。我们将从内存管理、操作优化等维度,逐步构建一个简单支持核心功能的string类。


2. 基础架构设计

2.1 成员变量声明

  • 为了和标准库中的string区分,我们把自己实现的string封装在m_string这个命名空间中
  • string的底层是存放字符的顺序表,因此我们采用顺序表的结构来实现
  • 基本结构如下:
    namespace m_string {
    	class string {
    	public:
            //成员函数...etc...我们逐一实现
            
            //迭代器,运算符重载等...
    	private:
        	size_t _size;         // 当前有效字符数
        	size_t _capacity;     // 存储容量(不含结束符'\0'),会进行扩容
        	char* _str;           // 动态数组的指针
    	public:
    		//静态成员变量 类内声明、类外(定义)初始化
       	 	const static size_t npos; 
        };
        //类内声明、类外初始化   特殊标记值  
    	const size_t string::npos = -1;		//建议静态成员变量,声明和定义分离
    }
    

    设计要点:

    • _size的大小代表了当前空间的内容,已存放的字符的个数:包括字符和\0
      • 三成员架构是顺序表结构的经典设计:
        • _size:记录有效数据个数
        • _capacity:管理当前的最大容量,扩容后容量更新,注意_capacity不包含末尾的\0
        • _str:指针指向堆内存
        • 静态常量npos的类外定义需特别注意,要在类外进行初始化。令size_t类型的npos值为-1,用来表示整型的最大值
        • 成员变量设为访问权限设为private, 对外提供public的成员函数,符合面向对象中封装的思想

          2.2 迭代器实现

          STL中的迭代器,可能是指针,也可能不是指针

          string的迭代器本质上是char,因为string本质上就是一个字符数组,天生适合用指针来访问*

          public:
          	// 将char* 封装成 iterator 迭代器
          	typedef char* iterator;
          	typedef const char* const_iterator;
          	
          	// 普通对象的迭代器  
          	// 普通对象的迭代器如果加了const, 会导致非const对象只能返回const_iterator,失去修改元素的能力(违反直觉)。
          	iterator begin() {		
          		return _str;	//数组名是第一个元素的地址
          	}
          	iterator end() {
                  //指针相加 _str + _size 得到最后一个元素的下一个位置的指针
          		return _str + _size;	
          	}
          	//const对象的迭代器
          	const_iterator begin() const {
          		return _str;	
          	}
          	const_iterator end() const {
          		return _str + _size;
          	}
          
          • begin()方法要返回数组的第一个位置的指针
          • end()方法要返回数组最后一个元素的下一个位置
          • 普通版本和const版本需分别实现

            实现分析:

            • 迭代器本质是原生指针的封装
            • 访问权限是public, 调用begin/end方法返回相应的迭代器。
            • 通过const重载实现常量迭代器
            • 与标准库的迭代器体系完全兼容

              3. 构造函数与析构函数

              3.1 基础构造函数

              v1
              // 初始实现(存在问题)
              // 初始化列表是按照成员变量在类中声明的次序进行初始化的
              string(const char* str = "\0") 
                  : _size(strlen(str))
                  , _capacity(_size)				//利用_size来初始化_capacity
                  , _str(new char[_capacity+1]) 
              {
                  strcpy(_str, str); // 中间含\0时会被截断
              }
              

              问题发现:

              • 使用strcpy()会因"hello world\0hello Linux"这样中间含有'\0'的字符串导致数据丢失。
              • 初始化列表是按照成员变量在类中声明的次序完成初始化的,与初始化列表中实现的顺序无关。
                • 如果有人调整了初始化列表的初始化顺序或成员变量的声明顺序,那么利用_size来初始化_capacity会发生未定义行为。

                  优化版本:

                  v2
                  // 参数列表是  用c风格的字符串 const char* 进行构造。c风格的字符串默认以\0为结束符
                  // 因此传入 "hello world\0hello Linux" 这样中间含有'\0'的字符串,导致数据丢失是调用者的问题
                  string(const char* str = "") {	//默认构造会构造一个""空字符串
                  	_size = strlen(str);
                  	_capacity = _size;					//capacity表示可以存放的下的字符个数
                  	_str = new char[_capacity + 1];		//开空间+1  要存放'\0'
                  	//strcpy(_str, str);	//拷贝数据,遇到中间含有\0的字符串会有Bug
                  	//strcpy默认会 拷贝\0, 因此使用memcpy,需要将拷贝的字节数+1,考虑\0的位置
                  	memcpy(_str, str, _size + 1);	
                  }
                  

                  思路:

                  • strlen(str)计算传入字符串的长度,并赋值给_size
                  • _capacity = _size,,初始化时,默认使容量和有效字符和数相等,构造时不额外开空间
                  • _str = new char[_capacity + 1],为字符串分配空间,_capacity + 1为字符串结尾的\0预留位置
                  • memcpy(_str, str, _size + 1),最后将源字符串中的数据拷贝至新开辟的空间。

                    优化点:

                    • 使用memcpy()替代strcpy(),memcpy()是拷贝内存中的数据,遇到\0不会结束

                    • 参数缺省值改为空字符串""

                    • 均为内置类型,不使用初始化列表进行初始化,而是在构造函数内部完成初始化,消除初始化列表顺序依赖

                      3.2 析构函数

                      ~string() {
                      	delete[] _str;
                      	_str = nullptr;
                      	_size = _capacity = 0;
                      }
                      

                      析构函数的思路和实现较为简单

                      • 释放管理字符数组的空间_str,注意释放数组要使用delete []
                      • 管理字符数组的空间的指针_str置空
                      • 将_size 和 _capacity置零

                        3.3 拷贝构造函数

                        往期文章对深拷贝的简单总结介绍:

                        https://blog.csdn.net/2301_80064645/article/details/145593384?fromshare=blogdetail&sharetype=blogdetail&sharerId=145593384&sharerefer=PC&sharesource=2301_80064645&sharefrom=from_link

                        传统实现:

                        手动开辟释放内存。

                        // 拷贝构造  要实现深拷贝,开一块新的空间,拷贝数据,并初始化。
                        // 新开一块空间,并进行深拷贝防止两个指针指向同一块空间
                        string(const string& str) {
                        	_str = new char[str._capacity + 1];	// _capacity不包含\0, +1 考虑 \0
                        	//strcpy(_str, str._str);
                        	memcpy(_str, str._str, str._size + 1);
                        	_size = str._size;
                        	_capacity = str._capacity;
                        }
                        

                        拷贝构造函数需要实现深拷贝。如果是浅拷贝的话,字符串指针会存下同一块空间的地址,析构时会对同一块空间析构两次,会引发错误

                        • 开辟新空间,new char[str._capacity + 1],大小为_capacity + 1
                        • memcpy拷贝原数据到新空间,拷贝大小为_size + 1,确保字符串末尾的\0也被拷贝。
                        • 更新_size和_capacity

                          现代写法:

                          // 交换两个string对象成员变量的内容
                          void swap(string& str) {		
                          	std::swap(_str, str._str);		//不能直接交换两个对象
                          	std::swap(_size, str._size);
                          	std::swap(_capacity, str._capacity);
                          }
                          string(const string& s) 
                          	:_str(nullptr)
                          	,_size(0)
                          	,_capacity(0)
                          {
                          	string tmp(s.c_str());	//用s的数据 调用构造函数  新构造一个 局部tmp对象, 具有不同的字符数组 地址
                          	this->swap(tmp);			//交换tmp和s2内的三个成员变量,
                          						//交换后tmp内的_str为nullptr,局部对象出了函数作用域销毁后,析构tmp对象
                                  
                          	//构造tmp时使用s.c_str()初始化,而c_str返回以\0结尾的C风格字符串
                          	//若s._str中间含有\0,tmp的构造会截断数据,导致拷贝不完整
                          }
                          

                          s2(s1)

                          • 将s2初始化为
                            • _str(nullptr)

                              ,_size(0)

                              ,_capacity(0)

                            • 用s1.c_str()构造一个和s1一样的局部对象tmp,字符串数据的内容一样,但空间和地址不同。
                            • 交换s2和tmp中成员变量的值,
                              • 交换前:tmp._str指向和s1内容一样的一块新空间。拷贝构造时,s2待初始化,没有空间
                              • 交换后:s2._str指向之前tmp._str管理的那块空间,tmp._str指向待初始化的那块空间
                              • 之后局部对象出了函数作用域销毁后,析构待初始化tmp对象,析构前tmp内的数据为 nullptr 0 0

                                优势对比:

                                方法异常安全代码复用可维护性
                                传统实现不安全
                                现代写法强保证优秀

                                4. 赋值运算符的进化

                                4.1 传统实现

                                //	s1 = s3
                                string& operator=(const string& str) {
                                	if (this != &str) {
                                		char* newSpace = new char[str._capacity + 1];	//开空间
                                		memcpy(newSpace, str._str, str._size + 1);		//拷数据
                                		delete[] _str;			//被赋值前可能为非空string,因此要释放原空间
                                		
                                        _str = newSpace;
                                		_size = str._size;
                                		_capacity = str._capacity;
                                	}
                                	return *this;
                                }
                                
                                • 赋值运算符要实现深拷贝,赋值后,两个string对象要拥有两块独立的空间,并对赋值前的那块空间进行机构

                                • if (this != &str):自己给自己赋值时直接跳过。

                                • 传统实现,手动开空间,拷贝数据

                                • 更改指针前,析构原空间

                                  • delete[] _str 被赋值前可能为非空string,因此要释放原空间
                                  • _str = newSpace;
                                  • 更新_size和_capacity

                                  • 返回*this,满足连续赋值的需求

                                    潜在风险:

                                    • new可能抛出异常导致原对象损坏

                                      4.2 现代实现

                                      //	s1 = s3
                                      string& operator=(const string& str) {
                                      	if (this != &str) {
                                      		string tmp(str);	//反正tmp为局部对象,出了作用域也要销毁,不如让他销毁时,顺便把s1的空间析构了
                                      		// s1 想要tmp管理的那块空间
                                      		std::swap(_str, tmp._str);		//不能直接交换两个对象,否则会引发无穷赋值
                                      		std::swap(_size, tmp._size);
                                      		std::swap(_capacity, tmp._capacity);
                                      		//可以直接
                                      		//this->swap(tmp);
                                      	}
                                      	return *this;
                                      }
                                      
                                      • s1 = s3

                                      • 深拷贝创建局部对象tmp,反正tmp为局部对象,出了作用域也要销毁,不如让他销毁时,顺便把s1的空间析构了

                                      • 交换tmp和this对象的成员变量,更改指针指向。

                                        • 构造的tmp和str相同,且为深拷贝构造
                                        • this->swap(tmp)之后,s1接管了tmp的数据,tmp接管了s1的数据。
                                        • 返回*this,满足连续赋值的需求

                                        • 函数结束后,局部对象tmp销毁,调用析构,释放赋值前的旧空间(s1)。

                                          4.3 终版实现

                                          // s1 = s3
                                          string& operator=(string tmp) {		//直接利用函数参数,深拷贝s3,函数结束后,形参自动析构
                                          	this->swap(tmp);			// 将s3的拷贝和s1 也就是 *this 交换
                                          	return *this;		// 返回*this, 也就是 s1
                                          }
                                          
                                          • 既然现代实现要构造局部对象,那不妨直接在形参中使用值传递的方式构造局部对象,形参tmp是右值实参的拷贝
                                            • 自定义类型对象在值传参时,会默认被要求去调用拷贝构造函数。
                                            • 实参s3值传递,传递给形参tmp。string tmp = s3。局部对象tmp中有和s3一样的数据
                                            • this->swap(tmp);:直接和局部对象交换资源。
                                            • 函数结束后,形参对象tmp销毁,调用析构,释放s1的旧空间。

                                              革命性改进:

                                              1. 参数值传递,自动调用拷贝构造
                                              2. swap操作保证强异常安全
                                              3. 代码量减少60%
                                              4. 自动清理旧资源

                                              5. 容量管理策略

                                              5.1 reserve

                                              ///可以用reserve预留空间,来实现扩容
                                              // reserve实现的均是异地扩容
                                              //考虑特殊情况的话,memcpy会更好
                                              void reserve(size_t request_capacity) {		//request_size指的是要新存放的字符的个数
                                              	if (request_capacity > _capacity) {		//请求的空间大于_capacity时,才扩容
                                              		char* newSpace = new char[request_capacity + 1];	//多开一个空间存放\0
                                              		//strcpy(newSpace, _str);		//new是异地扩容
                                              		memcpy(newSpace, _str, _size + 1);
                                                      
                                              		delete[] _str;
                                              		_str = newSpace;
                                              		_capacity = request_capacity;
                                              	}
                                              }
                                              

                                              扩容思路:

                                              • 期望容量request_capacity 大于当前容量_capacity时,才进行扩容
                                              • 采取异地扩容策略,newSpace存放新空间的地址
                                              • 使用memcpy而非strcpy。
                                                • strcpy拷贝数据时,遇到\0就终止了,遇到中间含有\0的字符串会带来意外的结果。
                                                • 使用memcpy来拷贝空间中的所有数据,包括中间的\0
                                                • 将原来的字符串空间释放:delete[] _str;
                                                • 将新空间的地址赋值给管理字符数组的指针:_str = newSpace;
                                                • 更新容积:_capacity = request_capacity

                                                  扩容策略:

                                                  • 异地扩容保证数据完整性
                                                  • 精确计算拷贝字节数(_size+1),此字节数是数组中有效字符的个数
                                                  • 典型应用场景:push_back时的容量检查

                                                    5.2 resize

                                                    void resize(size_t newSize, char ch = '\0') {
                                                    	if (newSize  
                                                    

                                                    双模式操作:

                                                    • 收缩(if逻辑):直接截断
                                                      • if (newSize
                                                      • 小于的话,直接截断,更新_size = newSize;
                                                      • 再将字符串最后一个字符的下一个位置设为\0, _str[_size] = '\0';
                                                      • 扩展(else逻辑):填充指定字符
                                                        • 期望大小大于当前_size,通过reserve(newSize)进行扩容
                                                          • 进入else逻辑后,newSize是 >= _size,等于时reserve(newSize)不做处理。
                                                          • 大于时会进行扩容
                                                          • 扩容后,对下标在_size及之后的位置进行初始化,用字符ch进行填充
                                                          • 初始化后,更新_size,将末尾位置设置\0

                                                            6. 字符串操作优化

                                                            6.1 push_back

                                                            • +=的本质是调用了push_back,因此先实现push_back
                                                              void push_back(char ch) {
                                                              	//先考虑扩容
                                                              	if (_size == _capacity) {
                                                              		//二倍扩容,并防止空构造的字符串
                                                              		reserve(_capacity == 0 ? 4 : _capacity * 2);
                                                              	}
                                                              	_str[_size++] = ch;	 //放字符
                                                              	_str[_size] = '\0';	 //加上\0
                                                              }
                                                              
                                                              • +=操作可以直接复用push_back,push_back是在字符串末尾插入一个字符
                                                              • 插入前考虑数组是否还有容量,_size == _capacity时表示容量已满
                                                              • reserve扩容,_capacity == 0 ? 4 : _capacity * 2
                                                                • 若push_back前为空串,则分配空间容量为4。
                                                                • 若push_back前为已有数据且容量已满,则二倍扩容
                                                                • 更新状态
                                                                  • 插入字符
                                                                  • 在末尾放上\0;

                                                                    6.2 append

                                                                    void append(const char* str) {
                                                                       size_t len = strlen(str);
                                                                        if(_size + len > _capacity){
                                                                            //至少扩容到 _size + len
                                                                            reserve(_size + len);
                                                                        }
                                                                        memcpy(_size, str, len + 1);	//拷贝大小为 len + 1, 要拷贝\0
                                                                        _size += len;    
                                                                    }
                                                                    
                                                                    • 计算待插入的字符串的长度
                                                                    • 检查容量,if(_size + len > _capacity)为真,代表需要扩容
                                                                    • 扩容至少扩容到_size + len
                                                                    • 拷贝数据到_size位置开头的空间
                                                                    • 拷贝大小为len+1,为\0预留空间
                                                                    • 最后更新_size的大小

                                                                      6.3 operator+=

                                                                      利用+=可以方便的在字符串后面追加字符或字符串

                                                                      +=字符
                                                                      string& operator+=(const char ch){
                                                                          push_back(ch);
                                                                          return *this;
                                                                      }
                                                                      
                                                                      +=字符串
                                                                      string& operator+=(const char* str){
                                                                          append(str);
                                                                          return *this;
                                                                      }
                                                                      
                                                                      • +=字符/字符串直接复用push_back和append即可
                                                                      • 为了满足连续+=的功能,需要返回当前对象,也就是*this

                                                                        6.3 insert实现

                                                                        6.3.1 insert字符串
                                                                        void insert(size_t pos, const char* str) {
                                                                        	assert(pos  _capacity)
                                                                        		reserve(_size + len);
                                                                        	size_t end = _size;		//从末尾的 \0 开始挪动
                                                                            //如果在 0 位置插入 end最后会变成 size_t -1 也就是npos 整形的最大值
                                                                        	while (end >= pos && end != npos) {	
                                                                        		_str[end + len] = _str[end];
                                                                        		--end;
                                                                        	}
                                                                        	for (size_t i = 0; i  
                                                                        
                                                                        • 越界检查,确保待插入字符串str为非空
                                                                        • 计算待插入串的长度,根据长度判断是否需要扩容并扩容,至少要扩容到_size + len长度
                                                                        • size_t end = _size,size是\0的下标,挪动时从末尾的 \0 开始挪动
                                                                          • end + len是需要挪动的字符调整后的下标位置

                                                                          • end类型为size_t,跳出循环时,end 的值 为 pos - 1

                                                                            • 当pos为0时,size_t end = -1的值为整型的最大值,是大于pos的。为了避免进入死循环,需增加循环条件为end >= pos && end != npos
                                                                            • _str[pos + i] = str[i]:挪动数据过后,再将待插入字符串逐个字符插入

                                                                            • 更新状态
                                                                              • _size += len:更新大小
                                                                              • _str[_size] = '\0':添加字符串结束标志
                                                                                6.3.2 insert n个字符
                                                                                • 从pos位置开始,插入n个相同的字符
                                                                                  //让插入的那个字符的下标变成pos
                                                                                  void insert(size_t pos, size_t n, char ch) {
                                                                                  	assert(pos  _capacity)
                                                                                  		reserve(_size + n);
                                                                                  	//挪动数据
                                                                                  	//当传入的pos为0时,end会变成-1,end是size_t,-1会变成整形的最大值,会一直进入循环
                                                                                  	//size_t end = _size;	
                                                                                  	//int end = _size;	
                                                                                  	//while (end >= (int)pos) {	//运算符两端 两个操作数类型不一致时,会进行  提升
                                                                                  	//						// 一般是范围小的向范围大的进行提升
                                                                                  	//	_str[end + n] = _str[end];
                                                                                  	//	--end;
                                                                                  	//}
                                                                                  	size_t end = _size;
                                                                                  	while (end >= pos && end != npos) {
                                                                                  		_str[end + n] = _str[end];
                                                                                  		--end;
                                                                                  	}
                                                                                  	for (size_t i = 0; i  
                                                                                  
                                                                                  • 越界检查,确保待插入位置有效
                                                                                  • 根据待插入字符的个数,根据个数判断是否需要扩容并扩容,至少要扩容到_size + len长度
                                                                                  • size_t end = _size,_size是\0的下标,挪动时从末尾的 \0 开始挪动
                                                                                    • end + n是需要挪动的字符调整后的下标位置

                                                                                    • end类型为size_t,跳出循环时,end 的值 为 pos - 1

                                                                                      • 当pos为0时,size_t end = -1的值为整形的最大值,是大于pos的。为了避免进入死循环,需增加循环条件为end >= pos && end != npos
                                                                                      • _str[pos + i] = ch:挪动数据过后,再将待插入字符循环依次插入

                                                                                      • 更新状态
                                                                                        • _size += n:更新大小
                                                                                        • _str[_size] = '\0':添加字符串结束标志
                                                                                          6.3.3 insert总结

                                                                                          在实现的过程中,我们不难发现,实现在pos位置插入n个字符和插入一个字符串的思路高度相似

                                                                                          • 插入字符时:每个字符已经通过参数传入,挪动数据后直接赋值即可
                                                                                          • 插入字符串时:每个字符需要从长度为len的字符串中取

                                                                                            insert多个字符和insert字符串,除了细节问题,逻辑上没有任何区别!

                                                                                            6.4 erase

                                                                                            //从pos位置开始删,向后删除len个字符,不传参默认全部删完
                                                                                            void erase(size_t pos, size_t len = npos) {
                                                                                            	assert(pos  _size) {		//这两种都是删完的情况
                                                                                            		_str[pos] = '\0';
                                                                                            		_size = pos;
                                                                                            	}
                                                                                                // 删除一部分的情况
                                                                                            	else {
                                                                                            		size_t end = pos + len;		// 跳过要删除的三个字符,end从需要挪动的第一个字符开始
                                                                                                    
                                                                                            		//_str[end] == '\0'时(end == _size),可以把'\0'也挪过来,也可以不挪,最后统一设置\0
                                                                                                    while (end  
                                                                                            

                                                                                            删除策略:

                                                                                            • 尾部删除:直接截断
                                                                                            • 中间删除:前向覆盖

                                                                                              思路分析:

                                                                                              • 对pos进行越界检查
                                                                                              • 先判断要全部删完的情况
                                                                                                • len == npos || pos + len > _size时,代表要删完pos及之后的字符,直接截断
                                                                                                  • 直接将pos位置设为\0
                                                                                                  • 修改_size为pos
                                                                                                  • else是删除完从pos开始的len个字符的情况:
                                                                                                    • 跳过要删除的三个字符,pos+len位置是需要挪动的第一个字符,开始依次向前挪动数据,到_size(\0)结束
                                                                                                    • 更新状态:
                                                                                                      • _size -= len_
                                                                                                      • 最后统一设置结束符_str[_size] = '\0'

                                                                                                        7. 运算符重载的艺术

                                                                                                        7.1 比较类运算符

                                                                                                        int ret = memcmp(_str, s._str, std::min(_size, s._size)); //前面小于后面时,返回值小于0 return ret == 0 ? _size
免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

相关阅读

目录[+]

取消
微信二维码
微信二维码
支付宝二维码