C++ string 类模拟实现:从底层理解字符串(上)

一、先看我们的 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时,字符串为空

相关推荐
垆边人似月.1 小时前
华为机试题 :有效的括号
c++·算法·华为
2601_966949651 小时前
多市场量化策略的数据接口应该如何设计:从数据层架构到策略接入
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
坤坤子吖1 小时前
C++智能指针:RAII、shared_ptr与内存泄漏
开发语言·c++·笔记·学习
奋斗的阿狸_19861 小时前
ESP32-S3 + ES7210 四通道麦克风录音上传方案
c语言·开发语言·fpga开发
weixin199701080161 小时前
《1688图片空间API踩坑:img.upload 与 album.* 的防盗链与CDN缓存问题》(附Python源码)
开发语言·python·缓存
零基础1231 小时前
FinalShell 使用教程:从安装到高效运维
运维·经验分享·笔记·finalshell
神仙别闹2 小时前
基于C++实现的贪吃蛇游戏平台
java·c++·游戏
ACP广源盛139246256732 小时前
Type‑C 扩展坞方案选型笔记|国产 DP1.4 转 HDMI2.0 桥接芯片 GSV2201S @ACP评估
c语言·开发语言·笔记·硬件架构·硬件工程·国产芯片
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——JAVA服务端
java·开发语言