前言
在 C++ 编程中,标准库提供的string类是处理文本数据的核心工具,但它的内部实现细节却如同一个 “黑箱”。理解string类的底层原理,不仅是面试中的高频考点,更是掌握 C++ 内存管理与泛型编程思想的关键一步。本章节将从零开始模拟实现一个简化版的string类,通过手动实现动态扩容、迭代器、拷贝构造等核心功能,带你揭开string类的神秘面纱。
我们的实现将聚焦于以下核心内容:
动态内存管理:通过reserve和resize实现按需扩容,避免频繁内存分配
迭代器设计:模拟实现begin()和end()接口,支持范围 for 循环等 STL 算法
拷贝控制:深入探讨浅拷贝与深拷贝的区别,通过现代 C++ 手法实现高效赋值
核心操作实现:包括插入、删除、查找等关键接口,理解其中的边界处理与性能优化
在实现过程中,我们将特别关注以下易错点:
内存泄漏与野指针问题(如析构函数的正确实现)
扩容时的数据迁移与\0结尾符的处理
插入 / 删除操作中的大量数据移动开销
此外,我们还将延伸讨论 “写时拷贝(Copy-On-Write)” 技术,了解如何通过引用计数优化内存使用效率。通过这个完整的实现案例,你不仅能掌握string类的底层原理,更能深刻理解 C++ 中资源管理、深拷贝 / 浅拷贝等重要概念。
无论你是希望深入理解 STL 容器的开发者,还是正在备战技术面试的求职者,亲手实现一个string类都是一次不可多得的学习机会。让我们一起动手,从底层构建属于自己的字符串类,感受 C++ 内存管理的精妙之处!
string类的模拟实现(可能和库里面的有偏差哈)
namespace renshen
{
class string
{
public:
typedef char* iterator;//模拟实现迭代器
typedef const char* const_iterator;//只能读的迭代器
//这俩搞的是内部类,在main函数里用要:renshen::string::iterator这样才是一个类型
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
string(const char* str = "")//构造函数
//""是C风格字符串,自带\0的
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
string(const string& s)//拷贝构造函数
{
_str = new char[s._capacity + 1];
memcpy(_str, s._str, s._size + 1);
//注意,这里要是memcpy,不能是strcpy;因为字符串中间可能有\0这些
_size = s._size;
_capacity = s._capacity;
}
/*string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
这里必须要初始化,
因为delete[]一个未初始化的_str,导致未定义行为
{
string tmp(s._str);->如果对象是h\0a的话就遭了,因为这个时候会用上面那个构造函数
swap(tmp);
}*//这种的用现代写法不好
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char* c_str() const
{
return _str;
}
size_t size() const
{
return _size;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const
//const renshen::string s1;这种要用这个,而且char&前面那个const不能去掉的
{
assert(pos < _size);
return _str[pos];
}
//库里面也是这样写两种的
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
memcpy(tmp, _str, _size+1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);//一般这么搞的
//判断_capacity这一下很巧妙
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
void append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size+len);
}
memcpy(_str + _size, str, len+1);
_size += len;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
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)
//注意这里的end!=npos避免了pos为0的话出现的异常
//异常情况的诱因:end是size_t类型的
//注意:改成int也不行,因为有符号和无符号比较,有符号会整型提升成无符号进行比较
//int转成double叫做算数转换哈
{
_str[end + n] = _str[end];
--end;
}
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
_size += n;
}
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;
}
void erase(size_t pos, size_t len = npos)
{
assert(pos <= _size);
if (len == npos || pos + len >= _size)//注意这里顺序的巧妙之处
{
_str[pos] = '\0';
_size = pos;
_str[_size] = '\0';
}
else
{
size_t end = pos + len;
while (end <= _size)
{
_str[pos++] = _str[end++];
}
_size -= len;
}
}
//由此可见:insert和erease尽量少用,代价太大了
size_t find(char ch, size_t pos = 0)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
size_t find(const char* str , size_t pos = 0)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);//注意strstr的用法
if (ptr)
{
return ptr - _str;
}
else
{
return npos;
}
}
string substr(size_t pos = 0, size_t len = npos)//pos是开始查的位置(此需查)
{
assert(pos < _size);
size_t n = len;
if (len == npos || pos + len > _size)
{
n = _size - pos;
}
string tmp;
tmp.reserve(n);
//new出来的空间虽然出了函数还在,可是tmp是string类型的,有析构函数,出了函数就被析构释放了
//之后里面的东西会深拷贝出去哈
for (size_t i = pos; i < pos + n; i++)
{
tmp += _str[i];//注意理解这里,和s1+="a"再+="b"是一个道理
//这样搞的话,\0永远还是在最后
}
return tmp;
}
void resize(size_t n, char ch = '\0')
{
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';
}
bool operator<(const string& s) const
{
int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
// 不要搞成_size+1这样,这样就把\0也比进去了,虽然答案还是一样,但是意义就相悖了
//(字典序那样比较大小是不把\0结束标志搞进去的)
return ret == 0 ? _size < s._size : ret < 0;
}
bool operator==(const string& s) const
{
return _size == s._size
&& memcmp(_str, s._str, _size) == 0;//这种先后顺序会好些
}
//只要实现两个,其他的都能用这俩个实现了
bool operator<=(const string& s) const
{
return *this < s || *this == s;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
//不能写成std::swap(tmp,*this);不然会无穷递归
}
string& operator=(string tmp)
{
swap(tmp);//这里调用函数,swap里面依旧是可以直接用那个的成员变量
return *this;
}//这里返回*this最主要想满足链式赋值
}
private:
size_t _size;
size_t _capacity;//像这种肯定是非负数的模拟实现一般要搞成size_t
char* _str;
public:
const static size_t npos;
};
const size_t string::npos = -1;//这个也能放在命名空间里
std::ostream& operator<<(std::ostream& out, const string& s)
//没在命名空间里搞的话,要写成renshen::string
{
for (auto ch : s)
{
out << ch;
}
return out;
}
std::istream& operator>>(std::istream& in, string& s)
{
s.clear();
char ch = in.get();
// 处理前缓冲区前面的空格或者换行
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
char buff[128];//这样到一定容量才放的话会比s直接+ch的频繁扩容要好
int i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();//注意这个get是cin的成员函数
}
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
};
注意点:1.记得检查pos位置是否合法
2.string里面的s1.[s1.size()]位置存的是`\0` 3.范围for只要模拟实现出begin和end这两个迭代器就可以用了 (这两个迭代器的名字都不能变,范围for的识别很死板的) 4.自己有些遗忘strcpy和memcpy的用法了:
char * strcpy ( char * destination, const char * source ); 引申:string类型不能被隐式转换成C风格的 memcpy的话是可以指定到哪截止 strcpy的话是遇到\0就终止(但他会默认拷贝上\0) 引申:回忆下strcmp和memcmp
5.扩容时要注意_capacity是不是等于0 6.自己重载出来的<<和直接cout<<s1.c_str()出来的还是有不同的,比如:在string类型的字符串下中间有空格时
7.流插入和流提取不让拷贝
写时拷贝
用途:用来解决浅拷贝相较于深拷贝的问题(由于指向同一空间导致的)
1.会析构多次导致错误 2.一个对象修改了会影响多个对象的内容
引入的一个引用计数去解决这两个问题
解决方法:第一个问题:引用计数是1时进行析构才让析构
第二个问题:在修改时,如果引用计数不是1,就先进行深拷贝,再修改
作用:可以避免一些用浅拷贝就可以满足的场景却用了深拷贝