手写简易 STL string|C++ 字符串底层实现

前言:

很多人会用std::string,但不清楚它底层是一块动态字符数组。本篇博客我们从零实现简化版bit::string,实现迭代器、扩容、尾插、追加、插入、下标访问等核心接口,吃透顺序容器的设计思想


关于 npos

npos 是 string 的静态常量,类型是 size_t,定义写成 -1,实际是无符号最大值,代表不存在的位置

cpp 复制代码
static const size_t npos = -1;

接口实现

cpp 复制代码
// 自定义一个命名空间,防止和标准库里的string冲突

namespace bit
{
	class string
	{
	public:

		// 这里放成员函数(各种接口)的声明
		
		// ----------------------------------------------
		
		// 一些需要频繁调用的函数直接在类内给出实现逻辑
		
		// 这里为了博客看起来不冗杂,我就直接单独拎出来写实现逻辑
		
		typedef char* iterator;
		typedef const char* const_iterator;

		iterator begin();
		iterator end();
		const_iterator begin() const;
		const_iterator end() const;

		// 构造函数...
		// 拷贝构造...
		// 赋值重载...
		
		// 析构函数...

		const char* c_str() const;
		void clear();
		size_t size() const;
		size_t capacity() const;
		char& operator[](size_t pos);
		const char& operator[](size_t pos) const;


		//-------------------------------------------------
		

		// 其他调用没那么频繁的函数,在类外(声明和定义分离)给出实现逻辑

		void reserve(size_t n);
		void push_back(char ch);
		void append(const char* str);
		string& operator+=(char ch);
		string& operator+=(const char* str);

		void insert(size_t pos, char ch);
		void insert(size_t pos, const char* str);
		void erase(size_t pos, size_t len = npos);
		
		size_t find(char ch, size_t pos = 0);
		size_t find(const char* str, size_t pos = 0);
		string substr(size_t pos = 0, size_t len = npos);


	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;

		// 加上 static,让 npos 成为类的静态常量
		// 所有 string 对象共享同一份,不需要每个对象都存一份,节省内存

		static const size_t npos;

	};
	


	// 现在的位置----bit命名空间中,string类外
	


	// static 成员:类内只是声明,必须在类外面单独定义初始化
	
	const size_t string::npos = -1;
	
	
	// 单独定义在命名空间里的全局函数
	
	bool operator<(const string& s1, const string& s2);
	bool operator<=(const string& s1, const string& s2);
	bool operator>(const string& s1, const string& s2);
	bool operator>=(const string& s1, const string& s2);
	bool operator==(const string& s1, const string& s2);
	bool operator!=(const string& s1, const string& s2);

	ostream& operator<<(ostream& out, const string& s);
	istream& operator>>(istream& in, string& s);



	// 各种接口的具体实现逻辑...

}

  1. 迭代器 begin / end
cpp 复制代码
typedef char* iterator;
typedef const char* const_iterator;

iterator begin() 
{
	return _str;
}

iterator end() 
{
	return _str + _size;
}

// 函数后+const 代表不修改对象
const_iterator begin() const 
{
	return _str;
}

const_iterator end() const 
{
	return _str + _size;
}

string 的迭代器就是原生的 char* 指针


  1. 构造 & 析构 & 拷贝构造 & 赋值重载
cpp 复制代码
// 构造函数---利用C风格字符串初始化string对象

string(const char* str = "")

// 缺省值为空的C风格字符串,注意是双引号!!

{

// strlen不计算C风格字符串里的'\0'
	_size = strlen(str);
	
	_capacity = _size;

// 多一个位置是留给'\0',为了兼容C风格
	_str = new char[_capacity + 1];
	
// strcpy会拷贝C风格字符串里的'\0'
	strcpy(_str, str);
}


// 析构函数

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

	_size = _capacity = 0;

}


// 深拷贝问题(拷贝构造)
// 
// s2(s1)
string(const string& s)
{
	_str = new char[s._capacity + 1];
	strcpy(_str, s._str);
	_size = s._size;
	_capacity = s._capacity;
}


// 赋值重载
string& operator=(const string& s)
{
	if (this != &s)
	{
		delete[] _str;

		_str = new char[s._capacity + 1];
		strcpy(_str, s._str);
		_size = s._size;
		_capacity = s._capacity;
	}

	return *this;
}

🚨 注意 :

  • 为什么构造函数的参数默认值是 ""(空字符串)而不是 nullptr?

    • "" 是合法的空 C 风格字符串:它在内存里只有一个'\0',strlen("")得到 0,后续代码strlen(str)、strcpy可以安全执行;
    • 如果默认写 nullptr:不传参数时 str=nullptr,执行 strlen(nullptr) 直接程序崩溃,空指针访问
  • new[容量+1],多一个位置保存字符串结束符\0,兼容 C 语言c_str()


  1. reserve 扩容(核心接口)
cpp 复制代码
// 声明和定义分离时要指定命名空间和类域

// 由于这篇博客默认将实现逻辑写在对应命名空间内,所以可以只指定类域

// 预先控制底层字符数组的容量

void string::reserve(size_t n)
{
 if (n > _capacity)
 {
	 // _capacity不包含'\0'的位置
	 // 
	 // 所以要 new+1

	 char* tmp = new char[n+1];

	 // strlen 不包含'\0'
	 // strcpy 包含'\0'

	 strcpy(tmp, _str);

	 delete[] _str;
	 _str = tmp;
	 _capacity = n;
 }
}

只开空间、不改变有效数据_size, 只有 n 比当前容量大才扩容


  1. push_back 尾插字符、operator += (char)
cpp 复制代码
void string::push_back(char ch)
{
 // 插入之前先检查是否要扩容

 if (_size == _capacity)
 {
	 // 扩容按照2倍来扩
	 reserve(_capacity == 0 ? 4 : 2 * _capacity);
 }

 _str[_size] = ch;

 ++_size;


 // 末尾主动补'\0',保证底层始终是合法的C风格字符串

 _str[_size] = '\0';
 
}



string& string::operator+=(char ch)
{
	push_back(ch);
	return *this;
}

复用思想:+=直接调用 push_back,避免重复写扩容逻辑


  1. append 追加字符串、operator += (const char*)
cpp 复制代码
 // 末尾追加字符串
 void string::append(const char* str)
 {
	 size_t len = strlen(str);

	 // 插入前先检查容量

	 if (_size + len > _capacity)
	 {
		 // 如果追加后的字符串很长,直接按需要扩容
		 // 如果不长,就按照2倍扩容
		 reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
	 }

	 // 从字符数组最后一个有效元素的后一位开始黏贴
	 strcpy(_str + _size, str);

	 _size += len;
 }


string& string::operator+=(const char* str)
{
	append(str);
	
	return *this;
}

  1. insert 插入(难点,挪动数据)
cpp 复制代码
// 在任意位置插入字符
void string::insert(size_t pos, char ch)
{
 assert(pos <= _size);

 // 插入前先检查是否需要扩容
 if (_size == _capacity)
 {
	 reserve(_capacity == 0 ? 4 : 2 * _capacity);
 }

 // 要连同末尾的'\0'一起拷贝
 size_t end = _size + 1;

 // 将pos位置空出来存放要插入的数据
 while (end > pos)
 {
	 // 将pos位置及其以后的数据
	 // 从尾巴开始一个一个往后移动一位

	 _str[end] = _str[end - 1];

	 end--;
 }

 _str[pos] = ch;

 ++_size;

 // 末尾主动补'\0',保证底层始终是合法的C风格字符串
 _str[_size] = '\0';
 
}



//------------------------------------------------



void string::insert(size_t pos, const char* str)
{

 assert(pos<=_size);
 
 size_t len = strlen(str);

 // 插入前先检查是否扩容
 if (_size + len > _capacity)
 {
	 reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
 }

 // 要连同末尾的'\0'一起拷贝
 size_t end = _size + len;


 // pos~pos+len-1 这len个空间是留给要插入的新字符串的
 // 
 // 将下标大于等于 pos+len-1的元素整体往后移动len个单位长度
 //
 // 从数组末尾移动,防止数据覆盖

 while (end > pos + len - 1)
 {
	 _str[end] = _str[end - len];

	 end--;
 }

 // 移动完成
 // 现在从pos位置开始填数据

 for (size_t i = 0; i < len; i++)
 {
	 _str[pos + i] = str[i];
 }

 _size += len;


 // 末尾主动补'\0',保证底层始终是合法的C风格字符串
 _str[_size] = '\0';

}

✅ 面试重点:必须从后往前搬数据,如果从前向后移动,原有数据会被覆盖丢失

✅ 时间复杂度 O (N),所以 string 中间插入效率很低


  1. operator 下标重载
cpp 复制代码
// 类内实现

char& operator[](size_t pos)
{
	assert(pos < _size);
	return _str[pos];
}

const char& operator[](size_t pos) const
{
	assert(pos < _size);
	return _str[pos];
}
  • 返回引用:可读可改

  • const 版本保证 const 对象只能读,不能修改


  1. erase( )

字符串左闭右开减出来的就是中间元素个数

cpp 复制代码
	void string::erase(size_t pos, size_t len = npos)
	{
	 // _size位置是'\0'的位置,len左闭右开减出来的长度是0,允许出现这个位置
	 // reserve开的空间也包含了'\0'的位置
	
	 assert(pos <= _size);
	
	 // 分两种情况: 删除的长度超过后续字符串 or 删除的长度不超过后续字符串
	
	 // 左闭右开 减出来的是中间字符串长度
	 if (len >= _size - pos || len == npos)
	 {
		 // clear接口的逻辑
		 _str[pos] = '\0';
		 _size = pos;
	 }
	
	 // 删除的长度不超过后续字符串
	 else
	 {
		 size_t i = pos + len;
	
		 // 后面的字符往前覆盖
		 for (; i < _size; i++)
		 {
			 _str[i - len] = _str[i];
		 }
	
		 _size -= len;
	
		 _str[_size] = '\0';
	 }
	}

  1. clear( ), size( ), capacity( ), c_str( )
cpp 复制代码
// 这些需要频繁调用的接口直接在类内实现

	void clear()
	{
		_str[0] = '\0';
		_size = 0;
	}
	
	size_t size() const
	{
		return _size;
	}
	
	size_t capacity() const
	{
		return _capacity;
	}
	
	
	// 把 string 转回 C 风格 char * 字符串,很多老接口需要
	
	const char* c_str() const
	{
		return _str;
	}

  1. find( )
  • 查找字符
cpp 复制代码
 size_t string::find(char ch, size_t pos = 0)
 {
	 // _size位置代表数组里'\0'的位置
	 assert(pos < _size);

	 for (int i = pos; i < _size; i++)
	 {
		 if (_str[i] == ch)
		 {
			 return i;
		 }
	 }

	 return npos;
 }
  • 查找字符串

补充的C语言函数接口 strstr

c 复制代码
#include <string.h>

char *strstr(const char *haystack, const char *needle);

作用:在主串 haystack 里查找子串 needle 第一次出现的位置,返回匹配起始地址;找不到返回 NULL

返回值细节 :

  1. 找到:返回 haystack 中该子串首字符指针
  2. 没找到:NULL
  3. 特殊规则:needle 是空字符串 "",直接返回 haystack 本身(C 标准规定)
cpp 复制代码
	size_t string::find(const char* str, size_t pos = 0)
	{
	 assert(pos < _size);
	
	 // 在主字符串中查找字串
	 const char* ptr = strstr(_str + pos, str);
	
	 if (ptr == nullptr)
	 {
		 return npos;
	 }
	
	 else
	 {
	 	// 指针相减得到对应下标
		 return ptr - _str;
	 }
	}

  1. substr ( pos , len )
cpp 复制代码
	// 截取子串
	string string::substr(size_t pos = 0, size_t len = npos)
	{
	 assert(pos < _size);
	
	 // 确认有效长度
	 if (len > _size - pos || len == npos)
	 {
		 len = _size - pos;
	 }
	
	 string sub;
	 
	 sub.reserve(len);
	
	 for (size_t i = 0; i < len; i++)
	 {
		 sub += _str[pos + i];
	 }
	
	 return sub;
	}

  1. 关系运算符号的重载

先重载<和==,然后其他关系运算符直接复用这两个

cpp 复制代码
	 // 放在命名空间内,类外实现的全局函数

	 // strcmp是针对C风格字符串的比较,所以先转化类型

	 bool operator<(const string& s1, const string& s2)
	 {
		 return strcmp(s1.c_str(), s2.c_str()) < 0;
	 }

	 bool operator==(const string& s1, const string& s2)
	 {
		 return strcmp(s1.c_str(), s2.c_str()) == 0;
	 }

	 bool operator<=(const string& s1, const string& s2)
	 {
		 return s1 < s2 || s1 == s2;
	 }

	 bool operator>(const string& s1, const string& s2)
	 {
		 return !(s1 < s2);
	 }

	 bool operator>=(const string& s1, const string& s2)
	 {
		 return !(s1 < s2);
	 }

	 bool operator!=(const string& s1, const string& s2)
	 {
		 return !(s1 == s2);
	 }

  1. 流插入运算符与流提取运算符的重载
cpp 复制代码
// 流插入运算符的重载
 ostream& operator<<(ostream& out, const string& s)
 {
	 // 用范围for遍历 string

	 for (auto ch : s)
	 {
		 cout << ch;
	 }

	 // 返回流引用,支持链式调用

	 return out;
 }


// 流提取运算符的重载
 istream& operator>>(istream& in, string& s)
 {
	 // >> 默认以空格,换行作为分隔符号

	 // 一个一个字符读取
	  
	 // 先将读到的字符存入缓冲数组
	 
	 // 等到缓冲数组满了再+进字符串里
	 

	 // 先清空字符串的内存
	 s.clear();

	 // 这么做不会把缓冲数组的大小写死,方便自定义
	 const int N = 256;

	 char buff[N];

	 int i = 0;
	 char ch;

	 // 读取一个输入的字符,包含空格与换行符号
	 ch = in.get();

	 // 读到空格与换行符号就停止读取
	 while (ch != ' ' && ch != '\n')
	 {
		 buff[i++] = ch;
		 if (i == N-1)
		 {
			 buff[i] = '\0';
			 s += buff;
			 i = 0;
		 }

		 ch = in.get();
	 }

	 if (i > 0)
	 {
		 buff[i] = '\0';
		 s += buff;

	 }
	
	 // 返回流引用,支持链式调用

	 return in;
 }
相关推荐
AI吃大瓜1 小时前
指针压力表读数3:C/C++实现指针压力表读数(可实时检测)
c++·指针读数·指针压力表·压力表自动读数·压力表读数
-银雾鸢尾-2 小时前
两数之和-C++实现
c++·算法
j7~2 小时前
【动态规划算法专题】动态规划五部曲实战:泰波那契数、三步问题、最小花费爬楼梯
c++·算法·动态规划·数据结构与算法
jimy12 小时前
C++ 报错的 qualifiers 的意思
开发语言·c++
揽星河..2 小时前
【C++】类和对象
c++·算法
D e re k ZH3 小时前
C++ 并发、未定义行为与工程化难点(No 3)
数据结构·c++·算法·面试
不积跬步无以至千里mmbb263 小时前
rust_analysis
开发语言·后端·rust
暴力求解4 小时前
C++ ---继承(二)
开发语言·c++·算法
陈飞EXCEL4 小时前
北京杰飞 IT 旗下妙码生花少儿Python编程上线两大学习系统
开发语言·python·学习