文章目录
一、引言:为什么要自己实现string类
在C++编程中,标准库提供的string类功能强大且高效,但作为C++学习者,自己动手实现一个简化版的string类是非常有价值的练习。通过这个过程,我们可以:
- 深入理解字符串操作的底层原理
- 掌握动态内存管理的技巧
- 学习类设计的基本原则
- 熟悉运算符重载的应用
- 理解迭代器的工作原理
本文将详细讲解如何从零开始实现一个功能完整的string类,每个函数实现都会配有详细的解释和设计思路分析。
二、类的整体设计与成员变量
2.1 命名空间与类声明
为了避免与标准库的string类冲突,我们将自定义的string类放在yh命名空间中(读者们可以自定义命名空间名字,yh为笔者姓名缩写):
namespace yh {
class string {
public:
// 成员函数和成员变量
private:
size_t _size;
size_t _capacity;
char* _str;
};
}
这种命名空间的使用是C++工程中的良好实践,可以有效地防止命名冲突。
2.2 成员变量设计
我们的string类包含三个私有成员变量:
private:
size_t _size; // 当前字符串长度
size_t _capacity; // 当前分配的内存容量
char* _str; // 指向动态分配的字符数组
这种设计与标准库的string类类似,采用动态内存分配的方式存储字符串内容。其中:
-
_size记录字符串的实际长度(不包括结尾的’\0’)
-
_capacity记录当前分配的内存大小(同样不包括’\0’)
-
_str指向动态分配的字符数组
三、迭代器实现详解
3.1 迭代器类型定义
typedef char* iterator;
typedef const char* const_iterator;
这里定义了两种迭代器类型:
- iterator:普通迭代器,可以修改指向的字符
- const_iterator:常量迭代器,不能修改指向的字符
这种设计与标准库一致,使得我们的string类可以与STL算法无缝配合(方便后续测试)。
3.2 迭代器获取函数
iterator begin() { return _str; }
iterator end() { return _str + _size; }
const_iterator begin() const { return _str; }
const_iterator end() const { return _str + _size; }
这些函数提供了获取迭代器的标准接口:
-
begin()返回指向第一个字符的迭代器
-
end()返回指向最后一个字符后面位置的迭代器
-
const版本用于const对象
这些函数的实现使得我们的string类支持以下用法:
yh::string s = "hello";
for(auto it = s.begin(); it != s.end(); ++it) {
cout << *it;
}
或者更简洁的范围for循环(它本质还是迭代器的应用,但是很死板,重载的名字必须是begin()和end() 。)
for(char c : s) {
cout << c;
}
四、构造函数与析构函数实现
4.1 默认构造函数与字符串构造函数
string(const char* str = "")
{
_size = strlen(str);// 使用strlen获取输入字符串长度
_capacity = _size;//初始容量设置为与长度相同(后续可以扩展)
_str = new char[_capacity + 1];//分配_capacity+1的空间(多出的1用于存放'\0')
strcpy(_str, str);//使用strcpy拷贝字符串内容
}
这个构造函数实现了两种功能:
- 默认构造:当不传参数时,构造一个空字符串
- C字符串构造:接收一个C风格字符串作为参数
4.2 拷贝构造函数
string(string &s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
拷贝构造函数实现了深拷贝,这是字符串类的关键特性:
- 为新对象分配独立的内存空间
- 拷贝源字符串的内容
- 拷贝大小和容量信息
这种实现确保了每个string对象都有自己独立的内存,修改一个对象不会影响另一个对象。
4.3 析构函数
~string()
{
delete[] _str;//使用delete[]匹配new[]
_str = nullptr;
_size = _capacity = 0;
}
析构函数负责:
- 释放动态分配的内存
- 将指针置为nullptr防止悬空指针
- 将大小和容量置为0
确保资源在对象生命周期结束时被正确释放。
五、容量相关操作实现
5.1 基本容量查询
size_t size() const { return _size; }
const char* c_str() const { return _str; }
这两个基础函数:
-
size()返回字符串的当前长度
-
c_str()返回C风格字符串(以’\0’结尾)
c_str()特别重要,因为它使得我们的string类可以与大量接受C风格字符串的函数交互。
5.2 内存预分配
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp,_str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
reserve()函数用于预分配内存,避免频繁重新分配:
- 只有当请求的大小大于当前容量时才执行操作
- 分配新内存(多出1字节给’\0’)
- 拷贝原有内容
- 释放旧内存
- 更新容量值
这个函数是后续许多操作(如push_back、append)的基础,它体现了"预分配以减少分配次数"的优化思想。
六、元素访问实现
6.1 下标运算符重载
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版本返回常量引用,用于const对象
- 使用assert检查下标有效性
- 不执行边界检查的版本效率更高,符合C++哲学
这使得我们可以像使用数组一样使用string:
yh::string s = "hello";
s[0] = 'H'; // 修改第一个字符
char c = s[1]; // 读取第二个字符
七、字符串修改操作详解
7.1 追加单个字符
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity ==0 ? 4 : _capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
push_back()实现在字符串末尾添加一个字符:
- 检查是否需要扩容(容量为0时初始化为4,否则翻倍)
- 在末尾放置新字符
- 更新大小并添加新的’\0’
这种"容量不足时翻倍"的策略是STL容器的常见做法,可以保证多次追加的平均时间复杂度为O(1)。
7.2 追加字符串
void append(const char* str)
{
size_t len = strlen(str);
if (len + _size > _capacity)
{
reserve(len + _size);
}
strcpy(_str + _size, str);
_size += len;
}
append()用于追加C风格字符串:
- 计算要追加字符串的长度
- 检查并扩容(精确扩容到刚好能容纳新字符串)
- 使用strcpy拷贝字符串
- 更新大小
7.3 +=运算符重载
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
这两个运算符重载提供了更直观的字符串拼接方式:
- 分别调用push_back和append实现功能
- 返回*this以支持链式调用
使用示例:
yh::string s = "Hello";
s += ' '; // 追加单个字符
s += "World"; // 追加字符串
✳阅读下文请先看此处:npos详解
public:
const static size_t npos;
}
const size_t string::npos = -1;//类外定义
-
静态成员需要在类外单独定义
-
-1赋值给 size_t类型时,由于无符号整数的溢出处理,实际值为该类型的最大值
在32位系统中:npos = 4,294,967,295 (0xFFFFFFFF)
在64位系统中:npos = 18,446,744,073,709,551,615 -
npos 的设计体现了C++标准库的通用约定,使得字符串操作具有一致的错误处理机制和接口规范。这种设计让自定义string类与标准库保持兼容,方便使用者理解和迁移代码。
这个定义不仅是string类中非常重要的一个组成部分,还为字符串查找和相关操作提供了标准化的错误处理和特殊值标记机制。
八、字符串插入操作
8.1 插入多个相同字符
void insert(size_t pos, size_t n, char ch)
{
assert(pos <= _size);//检查位置有效性
if (_size + n > _capacity)//必要时扩容
{
reserve(_size + n);
}
size_t end = _size;
while (end >= pos && end != npos)//从后向前移动字符,为新字符腾出空间
{
_str[end + n] = _str[end];
--end;
}
for (size_t i = 0; i < n; i++)//插入新字符
{
_str[pos + i] = ch;
}
_size += n;//更新大小
}
8.2 插入字符串
void insert(size_t pos,const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);// 计算要插入字符串的长度
if (_size + len > _capacity)//检查并扩容
{
reserve(_size + len);
}
size_t end = _size;
while (end >= pos && end != npos)//移动现有字符
{
_str[end + len] = _str[end];
--end;
}
for (size_t i = 0; i < len; i++)//拷贝新字符串内容
{
_str[pos + i] = str[i];
}
_size += len;// 更新大小
}
九、字符串删除操作
9.1 erase函数实现
erase函数删除从pos开始的len个字符
void erase(size_t pos,size_t len = npos)
{
assert(pos <= _size);
//如果不指定len或len过大,则删除从pos到结尾的所有字符
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
//否则,将后面的字符向前移动覆盖要删除的部分
else
{
size_t end = pos + len;
while (end <= _size)
{
_str[pos++] = _str[end++];
}
_size -= len;
}
}
这个实现考虑了边界情况,并保证了操作的安全性。
十、字符串查找操作
10.1 查找子字符串
使用标准库的strstr函数实现子字符串查找
size_t find(const char* str, size_t pos = 0)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr)
{
return ptr - _str;
}
else
{
return npos;
}
}
10.2 查找单个字符
线性查找指定字符:
- 从前往后遍历字符串
- 找到即返回位置
- 未找到返回npos
size_t find(char ch, size_t pos = 0)
{
assert(pos < _size);
for (size_t i = 0; i < _size ; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
十一、关系运算符重载
11.1 比较运算符实现
bool operator<(const string& s)const
{
int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
return ret == 0 ? _size < s._size : ret < 0;
}
bool operator==(const string& s)const
{
return _size ==s._size
&& memcmp(_str,s._str, _size)==0;
}
比较运算符的实现要点:
- 使用memcmp进行内存比较,效率高于字符逐个比较
- <运算符先比较共同长度部分,如果相同再比较长度
- ==运算符要求长度和内容都相同
- 其他运算符基于这两个实现,如下举例几个,读者们举一反三即可:
bool operator>(const string& s)const
{
return !(*this <= s);
}
bool operator>=(const string& s)const
{
return !(*this < s);
}
bool operator!=(const string& s)const
{
return !(*this == s);
}
这种实现方式既高效又符合字符串比较的字典序规则。
十二、输入输出运算符重载
12.1 输出运算符
ostream& operator<<(ostream& out, const string& s)
{
for(auto ch:s)
{
out << ch;
}
return out;
}
输出运算符实现:
- 使用范围for循环遍历字符串
- 逐个字符输出
- 返回ostream引用以支持链式输出
12.2 输入运算符
// 重载输入运算符>>,用于从输入流读取字符串
istream& operator>>(istream& in, string& s)
{
// 清空目标字符串
s.clear();
// 读取第一个字符
char ch = in.get();
// 跳过前导的空格和换行符
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
// 定义缓冲区,大小为128字节
char buff[128];
int i = 0;
// 读取字符直到遇到空格或换行
while (ch != ' ' && ch != '\n')
{
// 将字符存入缓冲区
buff[i++] = ch;
// 如果缓冲区满了(127个字符+1个'\0')
if (i == 127)
{
// 添加字符串结束符
buff[i] = '\0';
// 将缓冲区内容追加到字符串
s += buff;
// 重置缓冲区索引
i = 0;
}
// 读取下一个字符
ch = in.get();
}
// 处理缓冲区中剩余的内容
if (i != 0)
{
// 添加字符串结束符
buff[i] = '\0';
// 将剩余内容追加到字符串
s += buff;
}
// 返回输入流以支持链式操作
return in;
}
输入运算符实现特点:
- 使用缓冲区减少内存分配次数
- 跳过前导空白字符
- 遇到空格或换行停止读取
- 缓冲区满(127字符)时刷新到字符串
- 最后处理缓冲区剩余内容
十三、尾言
本文全部代码已放在本人gitee开源。欢迎各位读者学习使用。
代码链接
22万+

被折叠的 条评论
为什么被折叠?



