一、先看我们的 string 到底保存了什么
类中最核心的是三个成员:
cpp
private:
char* _str;
size_t _capacity;
size_t _size;
它们分别代表:
cpp
_str → 指向真正存储字符的动态内存
_size → 当前有效字符个数
_capacity → 当前最多能存多少个有效字符
比如:
hu::string s("hello");
可以理解成:
s对象
┌─────────────────────┐
│ _str ────────────┐ │
│ _size = 5 │ │
│ _capacity = 5 │ │
└──────────────────┼──┘
↓
堆区空间
┌──────────────┐
│ h e l l o \0 │
└──────────────┘
这里一定要注意:
_size = 5
但是实际至少需要申请:
_capacity + 1
个字符空间,因为 C 风格字符串末尾还需要保存:
'\0'
这里有一个初学者容易踩的坑:_size 记录的是有效字符个数,而 _capacity 是当前分配的内存容量。两者并不总是相等,例如频繁 push_back 时,_capacity 会按一定策略扩容,往往大于 _size。
为了更直观地理解,我们看一个简单的对比示例:
cpp
hu::string s("hello");
// _size = 5, _capacity = 5
s.push_back('!');
// 此时 _size = 6
// 若触发扩容,_capacity 可能变为 10 或更大
小结一下本节要点:
- 三个成员各司其职 :
_str指向堆内存,_size记录有效字符数,_capacity记录已分配容量。 - 末尾必须留
'\0':所以实际申请空间至少是_capacity + 1。 - 扩容策略 :当
_size达到_capacity时,需要重新分配更大的内存并拷贝旧数据。
ty + 1
个字符空间,因为 C 风格字符串末尾还需要保存:
cpp
'\0'
二.构造函数:一个 string 对象是如何诞生的?
来看:
cpp
string::string(const char* str)
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
memcpy(_str, str, _size + 1);
}
例如:
cpp
hu::string s("hello");
第一步:
cpp
_size = strlen("hello");
因此:
cpp
_size = 5
第二步:
cpp
_capacity = _size;
得到:
cpp
_capacity = 5
然后申请:
cpp
new char[6];
内存结构:
cpp
0 1 2 3 4 5
h e l l o \0
所以有一个非常重要的规律:
cpp
真正申请空间
=
capacity + 1
多出来的位置专门存:'\0'
三.为什么一定要自己写拷贝构造函数?
假设没有自己实现:
cpp
string(const string& s);
编译器会生成默认拷贝构造。
例如:
cpp
hu::string s1("hello");
hu::string s2(s1);
默认拷贝相当于:
cpp
s2._str = s1._str;
s2._size = s1._size;
s2._capacity = s1._capacity;
结果:
cpp
s1
_str ───────┐
│
↓
hello
↑
│
_str ───────┘
s2
也就是说:
cpp
s1._str
和
s2._str
指向同一块空间。
这就是:
浅拷贝。
问题发生在析构的时候。
s2 销毁:
cpp
delete[] s2._str;
空间被释放。
然后 s1 再销毁:
cpp
delete[] s1._str;
同一块空间又释放一次。
这就可能导致:多次释放同一块空间
因此我们必须重新申请一块空间:
cpp
string::string(const string& s)
{
_str = new char[s._capacity + 1];
memcpy(_str, s._str, s._size + 1);
}
于是:
cpp
s1._str ───→ hello
s2._str ───→ hello
两块空间内容相同,但内存地址不同。
这就是:
深拷贝。
四.赋值运算符重载
代码实现如下:
cpp
string& string::operator=(const string& s)
{
if (this!=&s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
1.strcpy(tmp, s._str):复制实际内容
cpp
strcpy(tmp, s._str);
这一步才是真正复制字符串的内容。
它会将源对象 s._str 中的字符(包括结束字符 \0)复制到 tmp 指向的新内存。
例如:
cpp
s._str = "hello";
执行后:
cpp
tmp = "hello";
此时两块内存是独立的。
即使以后修改 tmp 中的内容,也不会影响 s._str。
2. delete[] _str:释放原来的内存
cpp
delete[] _str;
这一步非常重要。
因为赋值运算符操作的是已经存在的对象,该对象原本可能就拥有一块堆区内存。
例如:
cpp
hu::string s1("hello");
hu::string s2("world");
s2 = s1;
如果只是让 s2._str 指向新内存,而不释放旧内存,那么 "world" 对应的内存就会泄漏。
因此,我们需要先保存源对象的副本,然后释放目标对象原来的内存。
为什么要先 strcpy 再 delete[]?
因为这样在申请新内存失败时,原对象仍然保持不变,能够提供更好的异常安全性。
3. 更新成员变量
cpp
_str = tmp;
_size = s._size;
_capacity = s._capacity;
这三行分别完成:
-
_str = tmp:让当前对象管理新申请的内存。 -
_size = s._size:同步有效字符个数。 -
_capacity = s._capacity:同步字符串容量。
现在 s2 就拥有了一份与 s1 内容相同、但内存独立的数据。
4. return *this:返回当前对象
cpp
return *this;
this 是当前对象的地址,而 *this 表示当前对象本身。
函数返回类型为:
cpp
string&
所以这里返回的是当前对象的引用。
这样既能支持连续赋值:
cpp
s1 = s2 = s3;
五.析构函数
代码实现如下:
cpp
string::~string()
{
delete[] _str;
_str = nullptr;
_size = 0;
_capacity = 0;
}
因为构造时用了:
cpp
new char[];
那么销毁时就必须对应:
cpp
delete[];
即:
cpp
string::~string()
{
delete[] _str;
}
不要写:
cpp
delete _str;
因为:
cpp
new ↔ delete
new[] ↔ delete[]
必须成对出现
六.迭代器(iterator)
我在 string 类中定义了:
cpp
typedef char* iterator;
这句话的含义是:
给 char* 类型起了一个别名,叫作 iterator。
也就是说:
cpp
iterator it;
实际上等价于:
cpp
char* it;
1.begin
代码实现如下:
cpp
string::iterator string::begin()
{
return _str;
}
首先假设字符串对象为:
cpp
hu::string s("hello");
在 string 类中,_str 指向动态开辟的字符数组:
cpp
char* _str;
size_t _size;
size_t _capacity;
其中:
cpp
_str = "hello";
_size = 5;
那么在内存中,可以这样理解:
字符串 "hello" 的内存布局
其中 _str 保存的是第一个字符 'h' 的地址。
因此:
cpp
string::iterator string::begin()
{
return _str;
}
就相当于告诉程序:
如果你想遍历我的字符串,我就把第一个字符的地址交给你。
例如:
cpp
hu::string s("hello");
hu::string::iterator it = s.begin();
cout << *it << endl;
输出:h
为什么是 h?
因为 it 指向第一个字符,*it 就是对指针解引用,取得它指向的字符。
2.end
代码实现如下:
cpp
string::iterator string::end()
{
return _str + _size;
}
假设:
cpp
_str = "hello";
_size = 5;
那么:
cpp
_str + _size
等价于:
cpp
_str + 5
由于 _str 是 char* 类型,每加 1,就会向后移动一个字符的位置。
因此:
cpp
_str + 0 // 指向 'h'
_str + 1 // 指向 'e'
_str + 2 // 指向 'l'
_str + 3 // 指向 'l'
_str + 4 // 指向 'o'
_str + 5 // 指向 '\0'
对于这个以 \0 结尾的字符串,end() 恰好指向结束字符 \0 所在的位置。
但要注意:end() 的真正含义不是指向 \0,而是指向最后一个有效元素之后的位置。
这正是 C++ 迭代器遵循的左闭右开区间规则
七.扩容:reverse()
代码实现如下:
cpp
void string::reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
八.尾插一个字符:
1.push_back()
代码实习如下:
cpp
void string::push_back(char c)
{
if (_size == _capacity)
{
size_t newcapacity = _capacity == 0?4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = c;
_size++;
_str[_size] = '\0';
}
如果:
cpp
size == capacity
说明:
cpp
abcde
已经塞满,于是扩容
2.+=
代码实现如下:
cpp
string& string::operator+=(char c)
{
push_back(c);
return *this;
}
例如:
cpp
s += 'A';
和:
cpp
s.push_back('A');
本质上一样。
所以:
string& string::operator+=(char c)
{
push_back(c);
return *this;
}
这是一种非常重要的编程思想:
已经有正确实现的函数,就不要重复造逻辑
九.追加字符串:
1.append()
代码实现如下:
cpp
void string::append(const char* str)
{
assert(_str != nullptr);
size_t len = strlen(str);
string tmp(str);
if (len + _size > _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
while (newcapacity<len+_size)
{
newcapacity *= 2;
}
reserve(newcapacity);
}
strcpy(_str + _size, tmp.c_str());
_size += len;
}
例如:
cpp
hu::string s("hello");
s.append(" world");
目标:
cpp
hello world
首先算:
cpp
len = strlen(" world");
假设空间不足:
cpp
if (_size + len > _capacity)
就扩容。
然后:
cpp
strcpy(_str + _size, tmp.c_str());
为什么是:
_str + _size
因为:
hello
01234
_size = 5。
所以:
_str + 5
刚好是:
hello后面的位置
2.+=字符串
代码实现如下:
cpp
string& string:: operator+=(const char* str)
{
append(str);
return *this;
}
和上方尾插+=一个字符原理相同
十.clear()
代码实现:
cpp
void string::clear()
{
_size = 0;
_str[0] = '\0';
}
例如:
cpp
原来:
size = 5
capacity = 10
hello_____
clear 后:
cpp
size = 0
capacity = 10
空间还在。
只是:
cpp
_str[0] = '\0';
于是逻辑上的字符串变成:
""
所以clear是清元素,而不是释放容量
十一.swap()
代码实现如下:
cpp
void string::swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
1. void string::swap(string& s)
先分析函数声明:
cpp
void string::swap(string& s)
-
void:函数不需要返回值。 -
string::swap:说明这是string类的成员函数。 -
string& s:引用传参,操作的是外部原对象,而不是它的副本。
例如:
cpp
s1.swap(s2);
在这个调用中:
cpp
this // 指向 s1
s // 引用 s2
因此:
cpp
_str
其实就是:
cpp
this->_str
而:
cpp
s._str
就是 s2 对象中的 _str。
2. std::swap(_str, s._str):交换指针
cpp
std::swap(_str, s._str);
这是整个函数最关键的一行。
std::swap() 是 C++ 标准库提供的交换函数,主要作用是交换两个变量的值。
例如:
cpp
int a = 10;
int b = 20;
std::swap(a, b);
交换之后:但是你这里交换的不是整数,而是两个指针!
cpp
std::swap(_str, s._str);
它交换的是两个指针保存的内存地址,并不是把字符串中的字符一个个交换。
因此交换指针的效率很高,时间复杂度为 \(O(1)\)。
3. std::swap(_size, s._size):交换长度
cpp
std::swap(_size, s._size);
假设:
cpp
s1._size = 5;
s2._size = 8;
执行后:
cpp
s1._size = 8;
s2._size = 5;
为什么必须交换?
因为前面 _str 指向的字符串已经交换。
如果不交换 _size,那么 s1 指向 "world",但是它的 _size 仍然是 5。
此时调用:
cpp
s1.size();
就会错误地得到 5,而不是 8。
4. std::swap(_capacity, s._capacity):交换容量
cpp
std::swap(_capacity, s._capacity);
容量同样需要一起交换。
假设:
cpp
s1._capacity = 10;
s2._capacity = 20;
交换 _str 后,s1 接管了 s2 原本的内存,那么相应的容量也应该变成 20。
否则,字符串对象保存的容量与实际分配的空间不一致,后续执行 push_back()、append() 等操作时,就可能出现错误。
所以这三行代码缺一不可:
std::swap(_str, s._str); // 交换内存所有权
std::swap(_size, s._size); // 交换有效长度
std::swap(_capacity, s._capacity); // 交换容量
十二.判空empty()
代码实现如下:
cpp
bool string::empty()const
{
return _size == 0;
}
当_size=0时,也就是有效字符个数是0时,字符串为空