作为C++初学者,模拟实现标准库中的 string类 是一个非常好的练习。他不技能帮助我们理解字符串的底层存储逻辑,还能深入掌握类的封装、构造函数、运算符重载等核心知识点。今天我想分享一下自己实现 string类 的过程和思考。
类的基本框架设计
首先,我们需要确定 string类 的核心成员变量,一个字符串类至少需要存储字符串数据、当前长度和容量:
cpp
class string
{
private:
char* _str = nullptr; //存储字符串数据
size_t _size = 0; //当前字符串长度
size_t _capacity = 0; //容量(当前开辟的空间最多可存储的字符数,不含'\0')
static const size_t npos;//表示无效位置的静态常量
};
其中 npos 是一个特殊值,通常定义为 -1 (由于是 size_t 类型,会自动转换为最大的无符号整数),用于表示"未找到"或"到字符串末尾"等场景。
构造函数与析构函数
默认构造函数
我们实现一个支持默认参数的构造函数,既可以创建空字符串,也可以用C风格字符串初始化:
cpp
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1]; //+1是为了存储'\0'
strcpy(_str, str);
}
这里有个细节:字符串常量""本身包含一个'\0',所以即使传入空字符串,也能正确处理。
拷贝构造函数
拷贝构造函数需要实现深拷贝,避免两个对象共用同一块内存:
cpp
//现代写法,利用临时对象的资源转移
string(const string& s)
{
string tmp(s._str); //先创建临时对象
swap(tmp); //与临时对象交换资源
}
//交换函数
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
这种写法既简洁又安全,不需要手动释放内存,还能避免自赋值问题。
析构函数
析构函数负责释放动态分配的内存:
cpp
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
赋值运算符重载
赋值运算符也需要处理深拷贝,同样可以用现代写法实现:
cpp
string& operator=(string tmp) //传值参数会触发拷贝构造
{
swap(tmp); //榆林市对象交换资源
return *this;
}
这种写法非常巧妙:通过传值方式接收参数,自动完成依次拷贝,然后通过 swap函数 交换当前对象和临时对象的资源,临时对象销毁时会自动释放原有的资源。
基本功能实现
容量管理:reserve函数
reserve函数 用于预留空间,当需要插入大量数据时可以提前扩容,减少频繁的内存分配:
cpp
void string::reserve(size_t n)
{
if (n > _capacity) //只有当n大于当前容量时才会扩容
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
插入单个字符:push_back
实现向字符串末尾插入单个字符的功能:
cpp
void string::push_back(char ch)
{
if (_size == _capacity)
{
//如果容量为0则先扩容到4,否则扩容到原来的2倍
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch; //插入字符
_size++;
_str[_size] = '\0'; //字符串结束标志
}
追加字符串:append
实现向字符串末尾追加C风格字符串的功能:
cpp
void string::append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity) //检查是否需要扩容
{
//扩容到足够容纳新字符串的大小
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
strcpy(_str + _size, str); //拷贝字符串
_size += len;
}
运算符重载:+=
为了使用更方便,我们可以重载+=运算符:
cpp
string& string::operator+=(char ch)
{
push_back(ch);
return *this;
}
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
插入与删除操作
插入操作
插入操作需要先移动数据,再插入新内容。插入单个字符:
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size); //确保插入位置有效
if (_size == _capacity) //检查是否需要扩容
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//从后往前移动数据,避免覆盖
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch; //插入字符
++_size;
}
插入字符串的逻辑类似,但需要考虑插入多个字符的情况:
cpp
void string::insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
if (_size + len > _capacity) //检查是否需要扩容
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
//移动数据,留出插入空间
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
//拷贝插入的字符串
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = s[i];
}
_size += len;
}
删除操作
删除操作需要将删除位置后的字符前移:
cpp
void string::erase(size_t pos, size_t len)
{
if (len >= _size - pos) //如果删除长度超过剩余长度,直接截断
{
_str[pos] = '\0';
_size = pos;
}
else //否则将后面的字符前移
{
for (size_t i = pos + len; i <= _size; i++)
{
_str[i - len] = _str[i];
}
_size -= len;
}
}
查找与子串操作
查找操作
实现查找字符和字符串的功能:
cpp
// 查找字符
size_t string::find(char ch, size_t pos)
{
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos; // 未找到返回npos
}
// 查找字符串
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str); // 利用库函数
if (ptr == nullptr)
{
return npos;
}
else
{
return ptr - _str; // 计算相对位置
}
}
子串操作
提取子串功能
cpp
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
// 如果长度超过剩余部分,就取到字符串末尾
if (len > _size - pos)
{
len = _size - pos;
}
string sub;
sub.reserve(len); // 提前预留空间
for (size_t i = 0; i < len; i++)
{
sub += _str[pos + i];
}
return sub;
}
关系运算符重载
为了比较字符串,我们需要重载关系运算符:
cpp
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);
}
输入输出运算符重载
为了方便地使用 cout 和 cin 操作我们的 string 类,需要重载输入输出运算符:
cpp
// 输出运算符
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s) // 利用迭代器遍历
{
out << ch;
}
return out;
}
// 输入运算符
istream& operator>>(istream& in, string& s)
{
s.clear(); // 先清空原有内容
const int N = 256;
char buff[N]; // 用缓冲区暂存输入
int i = 0;
char ch;
in >> ch;
// 读取直到遇到空格或换行
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == N - 1) // 缓冲区满了就先存入string
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get(); // 读取下一个字符
}
// 处理剩余字符
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
总结与反思
通过模拟实现 string 类,我对 C++ 的类设计有了更深入的理解:
-
内存管理:字符串操作的核心是内存管理,需要特别注意动态内存的分配与释放,避免内存泄漏和野指针。
-
深拷贝与浅拷贝:这是自定义类时非常重要的概念,对于包含动态内存的类,必须实现深拷贝。
-
异常安全:虽然目前的实现还比较简单,但已经能体会到异常安全的重要性,比如使用 swap 技术可以提高代码的安全性。
-
接口设计 :一个好的类需要设计直观易用的接口,比如重载
+=、[]等运算符,让类的使用更加自然。
当然,这个实现还有很多可以改进的地方,比如增加迭代器的完善支持、实现更高效的字符串拼接、处理更多边界情况等。但作为初学者的练习,这个版本已经覆盖了 string 类的核心功能,让我对 C++ 的面向对象编程有了更具体的认识。