深入理解string底层:手写高效字符串类

🔥个人主页:************************************************************************************************************************************************************************************************************************************************************胡萝卜3.0****************************************************************************************************************************************************************************************************************************************************************

📖个人专栏:

⭐️人生格言:不试试怎么知道自己行不行


🎥胡萝卜3.0🌸的简介:


目录

[一、 别再停留在"会用"!深挖string底层,真正理解字符串](#一、 别再停留在“会用”!深挖string底层,真正理解字符串)

[1.1 只会调用string接口,够用吗?](#1.1 只会调用string接口,够用吗?)

[1.2 理解string底层,究竟有什么价值?](#1.2 理解string底层,究竟有什么价值?)

[二. 0基础手撕:从0搭建string核心底层逻辑(附实现代码)](#二. 0基础手撕:从0搭建string核心底层逻辑(附实现代码))

[2.1 底层构造逻辑:string类的成员变量与构造逻辑](#2.1 底层构造逻辑:string类的成员变量与构造逻辑)

[2.1.1 构造与析构:对象的 "创建" 与 "销毁"](#2.1.1 构造与析构:对象的 “创建” 与 “销毁”)

[2.1.1.1 打印](#2.1.1.1 打印)

[2.1.1.2 构造:对象的"创建"](#2.1.1.2 构造:对象的“创建”)

[2.1.1.3 析构:对象的"销毁"](#2.1.1.3 析构:对象的“销毁”)

[2.2 迭代器与下标:string遍历的"两大高效工具"](#2.2 迭代器与下标:string遍历的"两大高效工具")

[2.2.1 operator[] 的底层逻辑与实现](#2.2.1 operator[] 的底层逻辑与实现)

1、普通对象(可以修改)

2、const对象(不能被修改)

[2.2.2 迭代器的基本框架和实现](#2.2.2 迭代器的基本框架和实现)

1、普通对象

[2、const 对象](#2、const 对象)

[2.2.3 遍历+修改](#2.2.3 遍历+修改)

[1、下标+[ ]](#1、下标+[ ])

2、迭代器

3、范围for

[2.3 字符串修改:push_back,append,insert与+=的实现](#2.3 字符串修改:push_back,append,insert与+=的实现)

[2.3.0 容量管理:resize和reserve的协同使用](#2.3.0 容量管理:resize和reserve的协同使用)

[2.3.0.1 reserve](#2.3.0.1 reserve)

[2.3.0.2 resize](#2.3.0.2 resize)

[2.3.1 尾插单个字符:push_back的实现](#2.3.1 尾插单个字符:push_back的实现)

[2.3.2 追加字符串:append的实现](#2.3.2 追加字符串:append的实现)

[2.3.3 运算符重载:+=实现字符 / 字符串追加](#2.3.3 运算符重载:+=实现字符 / 字符串追加)

[2.3.4 任意位置插入:insert的实现(插入字符/字符串)](#2.3.4 任意位置插入:insert的实现(插入字符/字符串))

1、插入字符

2、插入字符串

[2.4 字符串删减与截取:erase,clear与substr的实现](#2.4 字符串删减与截取:erase,clear与substr的实现)

[2.4.1 任意位置删除:erase的实现(删字符/删区间)](#2.4.1 任意位置删除:erase的实现(删字符/删区间))

[2.4.2 清空字符串:clear的实现](#2.4.2 清空字符串:clear的实现)

[2.4.3 截取子串:substr的实现](#2.4.3 截取子串:substr的实现)

[2.5 拷贝构造和赋值重载](#2.5 拷贝构造和赋值重载)

[2.5.1 拷贝构造](#2.5.1 拷贝构造)

[2.5.2 赋值重载](#2.5.2 赋值重载)

[2.6 流插入<<、流提取>>和getline](#2.6 流插入<<、流提取>>和getline)

[2.6.1 流插入<<](#2.6.1 流插入<<)

[2.6.2 流提取>>](#2.6.2 流提取>>)

[2.6.3 getline](#2.6.3 getline)

[2.7 字符串查找:find的实现(找字符/子串)](#2.7 字符串查找:find的实现(找字符/子串))

[2.7.1 查找单个字符:find(char)的实现](#2.7.1 查找单个字符:find(char)的实现)

[2.7.2 查找字符串:find(const char*)的实现](#2.7.2 查找字符串:find(const char*)的实现)

[2.8 字符串交换探秘:深入理解 swap 的底层机制](#2.8 字符串交换探秘:深入理解 swap 的底层机制)

结尾


一、 别再停留在"会用"!深挖string底层,真正理解字符串

1.1 只会调用string接口,够用吗?

在日常编码中,很多人对 string 的理解往往停留在"知道怎么用"的层面------调用几个接口,完成功能,便以为足够。然而,一旦面试中被要求"手动实现一个 string 类",不少人就陷入困境。更常见的是,代码在把 string 对象作为参数传递或返回值之后,程序莫名崩溃,其根源往往在于对"浅拷贝"所造成的内存问题缺乏认知。

1.2 理解string底层,究竟有什么价值?

深入理解 string 的底层机制,其意义远不止于应对面试。它直接关系到我们日常开发的效率与代码的健壮性。比如:

  • 了解 reserve 预分配容量的机制,能够有效减少字符串动态扩容带来的性能开销;

  • 理解深拷贝的实现逻辑,可以避免因传参、赋值所引发的内存错误;

  • 明白为什么 string 可以使用多种 swap 函数,有助于我们写出更高效、更安全的代码。

更重要的是,string 的底层设计是 C++ 容器实现思想的一个"缩影"------吃透 string,再学习 vector、list 等其他容器,将会事半功倍。

二. 0基础手撕:从0搭建string核心底层逻辑(附实现代码)

2.1 底层构造逻辑:string类的成员变量与构造逻辑

通过对string类中接口的学习,我们不难发现,string类的私有成员变量应该包括以下三个:

  • 数组:用来存储字符;
  • size:有效字符的个数;
  • capacity:空间大小

既然知道了这些,我们就可以很快的写出相对应的代码:

string.h

cpp 复制代码
namespace carrot
{
	class string
	{
	private:
		char* _str;//数组用来存储字符串
		size_t _size;//有效字符个数
		size_t _capacity;//空间大小
	};
}

也许这时候,会有小伙伴会感到疑惑,**为什么这里要加上命名空间?**其实这是为了和库中的string做区分。

2.1.1 构造与析构:对象的 "创建" 与 "销毁"

构造函数为string对象分配初始内存,初始化状态;析构函数则在对象生命周期结束时,回收动态分配的内存,避免内存泄露。

2.1.1.1 打印

在看相应的构造之前,我们先来看看如何进行打印操作。

通过上面的学习,我们知道string类的底层中有一个_str的数组,我们是将数据存储在这个数组中,既然是这样的话,那我们打印的操作就是对这个数组进行了,只要我们知道数组的地址,我们就可以很轻松的打印出相应的数据。

在string类的接口中,有一个成员函数------c_str,这个函数就是可以返回底层的字符串,所谓返回底层的字符串就是返回底层中指向数组的指针,ok,既然已经这么清晰了,直接上代码:

cpp 复制代码
const char* c_str() const
{
	return _str;
}
2.1.1.2 构造:对象的"创建"

对于使用来说,频率较高的应该是无参构造和有参构造,我们一一来看:

1、无参构造

测试代码:

  • string.h
cpp 复制代码
#include<iostream>
using namespace std;
namespace carrot
{
	class string
	{
	public:
		//无参构造
		string()
			:_str(nullptr)
			,_size(0)
			,_capacity(0)
		{}
		const char* c_str() const
		{
			return _str;
		}
	private:
		char* _str;//数组用来存储字符串
		size_t _size;//有效字符个数
		size_t _capacity;//空间大小
	};
}
  • test.cpp
cpp 复制代码
#include"string.h"
namespace carrot
{
	void testString1()
	{
		string s1;
		cout << s1.c_str() << endl;
	}
}
int main()
{
	carrot::testString1();
	return 0;
}

我们构造一个对象,使用上面的c_str,运行一下,会出现什么意想不到的事情发生:

嗯?这是为什么?为什么运行的结果不正确?

ok,其实这是因为cout进行输出的时候,输出的是const char* 类型的一个指针,const char*的指针,cout在自动识别类型的时候,const char* 不会按指针进行输出,而是按照字符串进行输出,会对指针指向的字符串进行解引用操作,只有遇到'\0'才结束。

简单来说,就是当cout遇到const char* 类型时,他会将其视为C风格的字符串,并输出该指针指向的字符串内容,若const char* 指向的是nullptr,则cout是未定义的行为,通常会导致程序崩溃。

所以,我们不能给_str初始化为nullptr,而是应该加上'\0'。

正确代码:

cpp 复制代码
//无参构造
string()
	:_str(new char[1]{'\0'})
	, _size(0)
	, _capacity(0)
{}

我们再运行测试一下:

此时就没有什么问题了~

2、有参构造

这~代码有没有什么问题?

ok,我们知道strlen 是一个时间复杂度为O(n)的接口,如果按照上图中的写法,这里会算三遍,效率会有点子低。

那我们可以改成下面这种写法吗?

其实是不行的,这时候就有UU想问了,为什么不能这么写?

在前面的学习中,我们学到过这么一个知识------

初始化的顺序要跟声明的顺序是一致的,先初始化_str,再_size,最后_capacity,如果按照上面的写法在初始化_str时,_str中的_size是一个随机值,会有问题。

那我们该怎么写这个代码呢?

我们知道私有成员变量初始化时是最好走初始化列表的,但是这并没有说必须走初始化列表,在下面的括号中进行初始化也是可以的。

正确代码:

cpp 复制代码
//有参构造
string(const char* str)
	:_size(strlen(str))//可以走初始化的尽量走初始化
{
	_str = new char[_size + 1];//多开的一个空间给\0
	_capacity = _size;
	//strcpy(_str, str);//再将str中的数据拷贝到_str中
    memcpy(_str, str, _size + 1);//再将str中的数据拷贝到_str中
}

通过前面的学习,我们知道,无参构造和有参构造可以合并成一个带有缺省值的构造函数

代码演示:

cpp 复制代码
string(const char* str = "")
	:_size(strlen(str))//可以走初始化的尽量走初始化
{
	_str = new char[_size + 1];//多开的一个空间给\0
	_capacity = _size;
	//strcpy(_str, str);//再将str中的数据拷贝到_str中
    memcpy(_str, str,_size+1);//再将str中的数据拷贝到_str中
}

str=""要比str="\0" 的要好,这是因为常量字符串中的末尾默认有\0

2.1.1.3 析构:对象的"销毁"
cpp 复制代码
//析构
~string()
{
	delete[] _str;//释放_str的空间
	_str = nullptr;
	_size = 0;
	_capacity = 0;
}
2.2 迭代器与下标:string遍历的"两大高效工具"
2.2.1 operator[] 的底层逻辑与实现

下标访问是string最常用的操作之一,通过重载operator[ ],可以像访问数组一样操作string中的字符,底层本质是对 _str 指针的索引访问,同时也需要确保访问不会越界(这个可以加断言)

1、普通对象(可以修改)
cpp 复制代码
//普通对象
char& operator[](size_t pos)
{
    assert(pos<=_size);
	return _str[pos];
}
2、const对象(不能被修改)
cpp 复制代码
//const 对象
const char& operator[](size_t pos) const
{
    assert(pos<=_size);
	return _str[pos];
}
2.2.2 迭代器的基本框架和实现

迭代器是遍历容器元素的抽象机制,对于string,可以通过封装指针来实现简单迭代器,结合下标访问可以覆盖不同遍历场景。

1、普通对象
cpp 复制代码
//普通对象
typedef char* iterator;
iterator begin()
{
	return _str;
}
iterator end()
{
	return _str + _size;
}
2、const 对象
cpp 复制代码
//const对象
typedef const char* const_iterator;
const_iterator begin() const
{
	return _str;
}
const_iterator end() const 
{
	return _str + _size;
}
2.2.3 遍历+修改
1、下标+[ ]

通过上面的重载[ ] 运算符,我们就可以通过下标返回数组上对应数组元素的引用,从而修改相应的值这里的修改是对于普通对象,const对象只能读,不能被修改

在进行上面的操作前,我们先来看一个简单的算法:求数组的长度,也就是string 类中size

代码演示:

cpp 复制代码
//size
size_t size() const 
{
	return _size;
}

ok,我们接着来看如何使用下标+[ ]进行遍历+修改的操作

代码演示:

cpp 复制代码
string s2("hello bit");
for (size_t i = 0; i < s2.size(); i++)
{
	s2[i]++;
	cout << s2[i] << " ";
}

s2是一个普通对象,可以进行读和写的操作。

如果是一个const对象,那只能进行读的操作------

cpp 复制代码
const string s3("hello world");
for (size_t i = 0; i < s3.size(); i++)
{
	//s3[i]++;//const对象只能读,不能写
	cout << s3[i] << " ";
}
2、迭代器
cpp 复制代码
void testString2()
{
    //普通对象
	string s1("hello world");
	string::iterator it = s1.begin();
	while (it != s1.end())
	{
		(*it)++;
		cout << *it << " ";
		++it;
	}
	cout << endl;

    //const 对象
	const string s2("hello bit");
	string::const_iterator it2 = s2.begin();
	while (it2 != s2.end())
	{
		cout << *it2 << " ";
		++it2;
	}
}
3、范围for

支持迭代器的都支持范围for!!!

通过前面的学习,我们知道范围for的底层其实就是迭代器!!!

cpp 复制代码
void testString2()
{
	//范围for
	//普通对象(可以读,可以写)
	string s3("hello world");
	for (auto& ch : s3)
	{
		ch++;
		cout << ch << " ";
	}
	cout << endl;
	//const 对象(可以读,不可以修改)
	const string s4("hello bit");
	for (auto& ch : s4)
	{
		//ch++;
		cout << ch << " ";
	}
}
2.3 字符串修改:push_back,append,insert与+=的实现
  • 字符串的修改操作是string的核心功能,push_back用于尾插单个字符,append用于追加字符串,insert支持指定位置插入,但是三者的底层实现都需要处理内存扩容和数据迁移,+=运算符可以进行单个字符/字符串的追加。

在学习push_back,append,insert与+=之前,我们先来看看,我们该怎么对空间容量进行操作

2.3.0 容量管理:resize和reserve的协同使用
  • reserve 用于提前预留内存空间,避免频繁扩容;resize 则用于调整字符串的有效长度,在需要时还会调用 resize 进行扩容,还可以指定填充字符。
2.3.0.1 reserve
  • reserve是对capacity进行调整,当我们知道要插入数据的个数时,我们可以使用reserve进行提前开空间的操作,这样就可以减少扩容次数

ok,话不多说,直接上代码:

  • string.h
cpp 复制代码
public:
    //扩容
    void reserve(size_t n);
  • string.cpp
cpp 复制代码
//扩容
void string::reserve(size_t n)
{
	//一般情况下,reserve不缩容
	if (n > _capacity)
	{
		char* tmp = new char[n + 1];
		strcpy(tmp, _str);
		delete[] _str;
		_str = tmp;
		_capacity = n;
	}
}
2.3.0.2 resize
  • string.h
cpp 复制代码
public:
    //resize
    void resize(size_t n, char ch = '\0');
  • string.cpp
cpp 复制代码
//resize
void string::resize(size_t n, char ch)
{
    //n <= _size,删除数据,保留前n个
	if (n <= _size)
	{
		_size = n;
		_str[_size] = '\0';
	}
	else
	{
		reserve(n);
		for (size_t i = _size; i < n; i++)
		{
			_str[i] = ch;
		}
		_size = n;
		_str[_size] = '\0';
	}
}

注意:resize用于删除数据的场景用的不多,用来一次性插入数据的场景较多!!!

2.3.1 尾插单个字符:push_back的实现
  • push_back的作用是在字符串的末尾添加一个字符,核心逻辑是"先检查容量,不足就扩容",再插入字符并更新_size
cpp 复制代码
void string::reserve(size_t n)
{
    //一般情况下,reserve不缩容
    if (n > _capacity)
    {
	    char* tmp = new char[n+1];
	    //strcpy(tmp, _str);
        memcpy(tmp,_str,_size+1);
	    delete[] _str;
	    _str = tmp;
	    _capacity = n;
    }
}

ok,接下来我们来看一下push_back的代码:

string.h

cpp 复制代码
public:
    void push_back(char ch);

string.cpp

cpp 复制代码
void string::push_back(char ch)
{
	//空间不够,需要扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	//空间足够,直接尾插
	_str[_size] = ch;
	_size++;
	_str[_size] = '\0';
}

关键逻辑:

  • 扩容策略采用 "2 倍增长"(空串特殊处理为 1),平衡内存利用率和扩容次数;
  • 每次插入后强制补'\0',确保C_str()返回的字符串始终有效
2.3.2 追加字符串:append的实现
  • append是实现在一个string对象的尾部在追加一个字符串。底层需要计算追加的字符串的长度,然后计算空间容量够不够,最后拷贝字符串

我们先来看看append的扩容机制,append在进行扩容时,就不能再延续push_back的2倍扩容的机制,可以进行下面操作中的一个:

  • 需要多少空间,开多少空间
cpp 复制代码
//需要多少空间,开多少空间
reserve(_size + len);
  • 比较_size + len和2 * _capacity的大小
cpp 复制代码
reserve(max(_size + len, 2 * _capacity));

这种扩容方式,可以防止空间开大了

扩容的大逻辑:开多了浪费。开少了不够用!!!

  • string.h
cpp 复制代码
public:
    void append(const char* str);
  • string.cpp
cpp 复制代码
//append
void string::append(const char* str)
{
	//空间不够,需要扩容
	size_t len = strlen(str);
	if (_size + len > _capacity)
	{
		//需要多少空间,开多少空间
		reserve(_size + len);
		//reserve(max(_size + len, 2 * _capacity));
	}
	//空间足够,直接操作
	//这里就不需要再手动添加\0了,strcpy会将str中的\0拷贝过去
	//strcpy(_str + _size, str);
    //字符串中间有\0,使用memcpy
    memcpy(_str + _size, str, len + 1);
	_size += len;
}
2.3.3 运算符重载:+=实现字符 / 字符串追加
  • +=是push_back和append的**"语法糖**",支持追加单个字符或者字符串,底层直接复用已有函数逻辑,简化代码的书写。
  • string.h
cpp 复制代码
public:
    //+=
    //单个字符
    string& operator+=(char ch);
    //字符串
    string& operator+=(const char* str);
  • string.cpp
cpp 复制代码
//+=
//单个字符
string& string::operator+=(char ch)
{
	push_back(ch);
	return *this;
}
//字符串
string& string::operator+=(const char* str)
{
	append(str);
	return *this;
}

优势

  • +=本质是对push_back和append的封装,避免重复编写扩容和字符拷贝逻辑;
  • 返回*this(对象引用)是实现链式操作的核心,确保每次调用后仍能继续操作当前对象;
  • 与append相比,+=更适合简单场景,代码可读性更高,两者底层效率一致。
2.3.4 任意位置插入:insert的实现(插入字符/字符串)
  • insert支持在指定位置插入单个字符或者字符串,核心是"先挪到原有字符,再插入新内容",需要特别处理扩容和内存重叠问题。
1、插入字符

通过前面的学习,我们很快就可以写出相应的代码,但是,这个代码正确吗?

当我们执行头插时,会不会有啥问题呢?我们运行一下

这是为什么?

这是因为end的类型是size_t,也就是无符号整型,永远不会小于0,这就导致end>pos恒成立。

那我们这样改?

这样还是不行,end是int ,pos是size_t ,在运算时,会进行算术转化,范围大的向范围小的转换,end>pos还是恒成立

我么应该这么改------

最终代码:

  • string.h
cpp 复制代码
public:
    //在pos位置上插入一个字符
    void insert(size_t pos, char ch);
  • string.cpp
cpp 复制代码
//insert
//在pos位置上插入一个字符
void string::insert(size_t pos, char ch)
{
    //代码改进
    assert(pos < _size);
	//空间不够,需要扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	//空间足够,先挪动数据,在插入数据
	int end = _size;
	while (end >= (int)pos)
	{
		_str[end + 1] = _str[end];
		--end;
	}
	_str[pos] = ch;
	++_size;
}

移动示意图:

这时候就有UU想说了,上面的操作感觉有点麻烦,有没有比较简洁的方法?当然有

我们可以按照下图的方式进行移动:

end为\0的下一个位置,然后我们将end-1位置上的数据移动到end位置上,这样就可以避免end==pos的情况的发生。

  • string.cpp
cpp 复制代码
void string::insert(size_t pos, char ch)
{
    //代码改进
    assert(pos < _size);
   	//空间不够,需要扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	//空间足够,先挪动数据,在插入数据
	int end = _size + 1;
	while (end > pos)
	{
		_str[end] = _str[end - 1];
		--end;
	}
	_str[pos] = ch;
	++_size;
}
2、插入字符串

数据移动方式一:

把end位置的数据挪动到end+len的位置上,直到end<pos,终止挪动

  • string.h
cpp 复制代码
public:
    //在pos位置上插入一个字符串
    void insert(size_t pos, const char* str);
  • string.cpp
cpp 复制代码
//在pos位置上插入一个字符串
void string::insert(size_t pos, const char* str)
{
    //代码改进
    assert(pos < _size);
	// 空间不够,需要扩容
	size_t len = strlen(str);
	if (_size + len > _capacity)
	{
		//需要多少空间,开多少空间
		reserve(_size + len);
		//reserve(max(_size + len, 2 * _capacity));
	}
	//空间足够,先挪动数据,在插入数据
	int end = _size;
	while (end >= (int)pos)
	{
		_str[end + len] = _str[end];
		--end;
	}
	//strncpy(_str + pos, str, len);
    //字符串中间有\0,用memcpy
    memcpy(_str + pos, str, len);
	_size += len;
}

数据移动方式二:

  • string.cpp
cpp 复制代码
//在pos位置上插入一个字符串
void string::insert(size_t pos, const char* str)
{
	// 空间不够,需要扩容
	size_t len = strlen(str);
	if (_size + len > _capacity)
	{
		//需要多少空间,开多少空间
		reserve(_size + len);
		//reserve(max(_size + len, 2 * _capacity));
	}
	//空间足够,先挪动数据,在插入数据
	int end = _size+len;
	while (end >pos+len-1)
	{
		_str[end] = _str[end-len];
		--end;
	}
	//strncpy(_str + pos, str, len);
    memcpy(_str + pos, str, len);
	_size += len;
}
2.4 字符串删减与截取:erase,clear与substr的实现
2.4.1 任意位置删除:erase的实现(删字符/删区间)

erase是从pos位置开始,删除len个字符

  • string.h
cpp 复制代码
public:
    //erase
    void erase(size_t pos = 0, size_t len = npos);
    const static size_t npos = -1;
  • string.cpp
cpp 复制代码
//erase
void string::erase(size_t pos, size_t len)
{
	assert(pos < _size);
	if (len == npos || len >= _size - pos)
	{
		
		_size = pos;
		_str[_size] = '\0';
	}
	else
	{
		//strcpy(_str + pos, _str + pos + len);
        memcpy(_str + pos, _str + pos + len, _size - (pos + len) + 1);
		_size -= len;
	}
}
2.4.2 清空字符串:clear的实现

clear是用来快速清空数据,但是不销毁空间,仅需重置_size和结束符

cpp 复制代码
public:
    //clear
    void clear()
    {
	    _str[0] = '\0';
	    _size = 0;
    }
2.4.3 截取子串:substr的实现

substr是拷贝从pos位置开始的len个字符,然后构造一个string对象返回

  • string.h
cpp 复制代码
public:
    //substr
    string substr(size_t pos = 0, size_t len = npos);
    const static size_t npos = -1;
  • string.cpp
cpp 复制代码
//substr
string string::substr(size_t pos, size_t len)
{
	assert(pos <= _size);
	if (len == npos || len > _size - pos)
	{
		len = _size - pos;
	}
	string tmp;
	for (size_t i = 0; i < len; i++)
	{
		tmp += _str[pos + i];//从pos位置开始的len个字符
	}
	return tmp;
}
2.5 拷贝构造和赋值重载
2.5.1 拷贝构造

通过前面的学习,我们知道,对于自定义类型中有资源的,编译器自动生成的默认拷贝构造是行不通的,这是因为自动生成的构造为浅拷贝,析构时会析构两次

具体见:C++拷贝构造与运算符重载实战

所以我们需要自已写拷贝构造完成深拷贝,也就是新开一块空间将拷贝后的数据放入新开的空间中

  • string.h
cpp 复制代码
public:
    //拷贝构造
    string(string& str);
  • string.cpp
cpp 复制代码
//拷贝构造
string::string(string& str)
{
	_str = new char[str._capacity+1];
	//多开的一个空间给\0,capacity中不包含\0
	//strcpy(_str, str._str);//完成的是深拷贝
    memcpy(_str,str._str,str._size+1);
	_size = str._size;
	_capacity = str._capacity;
}
2.5.2 赋值重载

在进行赋值操作前,我们总结出有三种情况,如果我直接进行赋值操作,会有些问题,那我们该如何做呢?

执行步骤

  1. 内存准备阶段
cpp 复制代码
char* tmp = new char[str._size + 1];
  • 首先为新的字符串数据分配内存空间

  • 分配的大小根据源字符串的长度 _size 确定,额外+1用于存放字符串结束符 \0

  • 创建临时指针 tmp 来管理这块新内存

  1. 数据拷贝阶段
cpp 复制代码
//strcpy(tmp, str._str);
memcpy(tmp, s._str, s._size + 1);
  • 将源字符串 str 中的实际字符数据(包括结束符)完整复制到新分配的内存中

  • 此时系统中存在两份相同字符串数据的副本

  1. 资源清理与更新阶段
cpp 复制代码
delete[] _str;
_str = tmp;
  • 释放当前对象原来持有的字符串内存,避免内存泄漏

  • 将新分配的内存地址赋给当前对象的 _str 指针,完成所有权的转移

  1. 元数据同步阶段
cpp 复制代码
_size = str._size;
_capacity = str._capacity;
  • 更新当前对象的长度信息 _size,使其与源字符串保持一致

  • 更新当前对象的容量信息 _capacity,反映新的内存分配情况

  • string.h

cpp 复制代码
public:
    //赋值重载
    string& operator=(string& str);
  • string.cpp
cpp 复制代码
//赋值重载
string& string::operator=(string& str)
{
	if (this != &str)
	{
		char* tmp = new char[str._size + 1];//和str中的数组开一样的大小
		delete[] _str;
		//strcpy(tmp, str._str);
		memcpy(tmp, s._str, s._size + 1);
		_str = tmp;
		_size = str._size;
		_capacity = str._capacity;
	}
	return *this;
}

写到这里,我们稍微暂停一下~~~

我们来想一个问题,通过对上面代码的学习,发现了一个问题:上面的代码中的字符串好像没有中间有\0的情况,那如果我们在中间插入一个\0,并打印这个字符串,还会是正确的吗?

嗯?为什么会是上面的结果?打印的结果不应该是hello worldxyyy吗?为什么是hello worldx?

很奇怪,其实这是因为c_str,c_str在打印的过程中遇到\0就终止了,如果中间有\0,并且还是用c_str打印,\0后面的数据就无法打印。

这就要求我们不得不自己实现流插入以及顺便实现一下流提取、getline

2.6 流插入<<、流提取>>和getline

流插入<<、流提取>>和getline 这三个都是非成员函数

2.6.1 流插入<<
  • string.h

位于string类的外面

cpp 复制代码
std::ostream& operator<<(std::ostream& out, const string& str);
  • string.cpp
cpp 复制代码
	std::ostream& operator<<(std::ostream& out, const string& str)
	{
		for (auto ch : str)
		{
			out << ch;
		}
		return out;
	}
2.6.2 流提取>>
  • string.h

位于string类的外面

cpp 复制代码
std::istream& operator>>(std::istream& in, string& str);
  • string.cpp
cpp 复制代码
std::istream& operator>>(std::istream& in, string& str)
{
    str.clear();
	char ch;
	ch = in.get();
	while (ch != ' ' && ch != '\n')
	{
		str += ch;
		ch = in.get();
	}
	return in;
}

也许会有uu看见上面的代码,会想问:为什么不使用>>,而是使用get呢?

这是因为"operator>>"会跳过空白字符(空格和换行),如果使用它,就永远无法检测到空格和换行,而get函数是一个字符一个字符的获取,这样就可以检测到空格和换行

为什么会有str.clear();的操作呢?

当我们不加str.clear();会出现下面的情况:

ok,我么原本是想给s2初始化为"hello",s3为"bit",结果成了上面的样子,这是因为s3中原本就有数据,">>"会在原有的数据后面继续追加,导致结果的错误,所以我们一不做二不休直接将数据清空(并没有销毁空间)

这里面还有一个问题:若有一个很长的字符串,会进行多次扩容,会很麻烦,此时我们该怎么做?

我们可以这样做:

cpp 复制代码
std::istream& operator>>(std::istream& in, string& str)
{
	str.clear();
	char ch;
	ch = in.get();
	char buff[256];
	int index = 0;
	while (ch != ' ' && ch != '\n')
	{
		buff[index++] = ch;
		if (index == 255)
		{
			buff[index] = '\0';
			str += buff;
			index = 0;
		}
		ch = in.get();
	}
	if (index > 0)
	{
		buff[index] = '\0';
		str += buff;
	}
	return in;
}

创建一个buff数组,大小为256(任何大小都可以),数据一开始先放到buff数组中,若数组满了,再拼接到str中,index置为0,重新往数组中插入数据。这样就可以减少扩容的次数,提高效率

2.6.3 getline
  • string.h

位于string类的外面

cpp 复制代码
std::istream& getline(std::istream& in, string& str,char delim='\n');
  • string.cpp
cpp 复制代码
std::istream& getline(std::istream& in, string& str, char delim)
{
    str.clear();
	char ch;
	ch = in.get();
	while (ch !=delim)
	{
		str += ch;
		ch = in.get();
	}
	return in;
}

注意:getline默认是以\n为间隔,也可以指定其他间隔符!!!

改进代码:

cpp 复制代码
std::istream& getling(std::istream& in, string& s, char delim)
{
	s.clear();
	char ch;
	ch = in.get();
	char buff[256];
	int index = 0;
	while (ch !=delim)
	{
		buff[index++] = ch;
		if (index == 255)
		{
			buff[index] = '\0';
			s += buff;
			index = 0;
		}
		ch = in.get();
	}
	if (index != 0)
	{
		buff[index] = '\0';
		s += buff;
	}
	return in;
}

ok,实现完流插入、流提取以及getline,我们继续来看中间有\0的情况:

如果字符串中间有\0,那就不能再继续使用strcpy,strcpy遇到\0就停止拷贝,也会出现和c_str一样的问题,那我们可以将strcpy换成memcpy,这样就可以解决问题~~~

2.7 字符串查找:find的实现(找字符/子串)
  • find是字符串查找的核心接口,支持从指定位置开始查找单个字符或子串,返回首次出现的位置(未找到就返回npos),底层通过遍历比对实现,逻辑清晰使用场景广。
2.7.1 查找单个字符:find(char)的实现

查找单个字符时,是从指定pos位置开始通过遍历字符串,逐个比较字符,若匹配成功,则返回该下标;若没有,则返回npos

  • string.h
cpp 复制代码
public:
    //查找单个字符
    size_t find(char ch, size_t pos = 0);

默认是从头开始查找,也可以指定位置开始查找

  • string.cpp
cpp 复制代码
//查找单个字符
//从pos位置开始查找
size_t string::find(char ch, size_t pos)
{
	assert(pos < _size);
	for (size_t i = pos; i < _size; i++)
	{
		if (_str[i] == ch) {
			return i;
		}
	}
	return npos;
}

关键逻辑:

  • 起始位置pos默认从0开始(整个字符串查找),也可以指定位置开始。
  • 遍历范围限制在[pos,_size),避免越界。
2.7.2 查找字符串:find(const char*)的实现
  • string.h
cpp 复制代码
public:
    //查找子字符串
    size_t find(const char* str, size_t pos = 0);
  • string.cpp
cpp 复制代码
//从pos位置开始查找
size_t string::find(const char* str, size_t pos)
{
	const char* ptr = strstr(_str+pos, str);
	if (ptr)
	{
		return ptr - _str;
	}
	else
	{
		return npos;
	}
}
2.8 字符串交换探秘:深入理解 swap 的底层机制

ok,我们先来看一下下面的这张图:

嗯?为什么这里会有三个交换算法?一个是算法库中的swap,另外两个是string 类中的swap。

在前面的学习中,我们会经常使用算法库中的swap,感觉它比较好用。那这里就有个问题:既然算法库中的swap已经很好用了,string 类中为什么还要自己搞个swap呢?

其实这是因为算法库中的swap有巨大问题算法库中的swap对于内置类型的交换肯定是可以的,但是对于string类这种,并且内部有资源的自定义类型,算法库中的swap会进行3次拷贝,代价很大,所以string类就自己搞了swap

对于string类型没有必要这么做,对于两个string类的交换,仅仅只需要交换内部资源,_size和_capacity即可~

  • string.h
cpp 复制代码
public:
    void swap(string& s);
  • string.cpp
cpp 复制代码
//交换
void string::swap(string& s)
{
	std::swap(_str, s._str);//直接调用算法库中的swap,直接调换其中资源地址
	std::swap(_size, s._size);
	std::swap(_capacity, s._capacity);
}

内部有资源,浅拷贝有问题,只能进行深拷贝,对于深拷贝的类型,内部都会实现一个自己的swap函数,仅仅交换内部资源即可

那如果我们想调用像算法库中的swap,这该怎么实现呢?

这时候我们就可以直接复用上面的代码,并搞成inline就可以了

  • string.h
cpp 复制代码
inline void swap(string& s1, string& s2)
{
	s1.swap(s2);
}

这样的话,以后要交换string就都可以用了~

完整模拟代码+测试代码:

结尾

各位UU们,你们感觉string类模拟实现这块哪里比较难呢?评论区可以聊聊哦~~~

相关推荐
kyle~2 小时前
计算机系统---CPU的进程与线程处理
linux·服务器·c语言·c++·操作系统·计算机系统
fanstering2 小时前
腾讯混元P3-SAM: Native 3D Part Segmentation
笔记·学习·3d·点云
西柚小萌新3 小时前
【Python从入门到精通】--Pycharm增加内存
开发语言·python·pycharm
不爱编程的小九九3 小时前
小九源码-springboot082-java旅游攻略平台
java·开发语言·旅游
只是懒得想了3 小时前
用C++实现一个高效可扩展的行为树(Behavior Tree)框架
java·开发语言·c++·design-patterns
bkspiderx3 小时前
C++设计模式之行为型模式:模板方法模式(Template Method)
c++·设计模式·模板方法模式
我是华为OD~HR~栗栗呀3 小时前
华为OD-23届考研-Java面经
java·c++·后端·python·华为od·华为·面试
yan8626592463 小时前
于 C++ 的虚函数多态 和 模板方法模式 的结合
java·开发语言·算法
mit6.8243 小时前
pq|二维前缀和
c++