文章目录
- C++ `string` 类的模拟实现:从构造到高级操作
- 前言
- 第一章:为什么要手写 C++ `string` 类?
- 1.1 理由与价值
- 第二章:实现一个简单的 `string` 类
- 2.1 基本构造与析构
- 2.1.1 示例代码:基础的 `string` 类实现
- 2.1.2 解读代码
- 2.2 浅拷贝与其缺陷
- 2.2.1 示例代码:浅拷贝问题
- 2.3 深拷贝的解决方案
- 2.3.1 示例代码:实现深拷贝
- 第三章:赋值运算符重载与深拷贝
- 3.1 为什么需要重载赋值运算符?
- 3.2 实现赋值运算符重载
- 3.2.1 示例代码:赋值运算符重载
- 3.2.2 解读代码
- 第四章:迭代器与字符串操作
- 4.1 迭代器的实现
- 4.1.1 示例代码:实现 `string` 类的迭代器
- 第五章:字符串的常见操作
- 5.1 查找操作
- 5.1.1 示例代码:实现字符和子字符串查找
- 5.1.2 静态 `const` 成员变量初始化规则详解
- 5.1.2.1 静态成员变量属于类,而不属于对象
- 5.1.2.2 `const` 修饰符的作用
- 5.1.2.3 整型和枚举类型的特殊处理
- 5.1.2.4 复杂类型为什么不能在类内初始化?
- 5.1.2.5 为什么 `static const size_t npos = -1` 可以在类内初始化?
- 5.1.2.6 总结:为什么静态 `const` 的复杂类型不能在类内初始化
- 5.2 插入操作
- 5.2.1 示例代码:实现字符串插入
- 5.3 删除操作
- 5.3.1 示例代码:实现字符串删除
- 读者须知与结语
C++ string
类的模拟实现:从构造到高级操作
💬 欢迎讨论:在实现
string
类的过程中,如果有任何疑问,欢迎在评论区交流!我们一起探讨如何一步步实现它。👍 支持一下:如果这篇文章对你有所帮助,记得点赞、收藏并分享给更多对 C++ 感兴趣的小伙伴吧!你们的支持是我创作的动力!
前言
在 C++ 标准库中,string
类是用于字符串操作的一个非常常见和重要的类,它极大地简化了开发者处理字符串的过程。然而,为了深入理解 C++ 的核心机制,特别是内存管理、深拷贝与浅拷贝的差异、运算符重载等底层细节,自己实现一个简易的 string
类是一个很好的练习。
通过本篇博客,我们将一步步实现一个简单的 string
类,并且深入探讨与之相关的现代 C++ 特性,包括内存管理、深拷贝与浅拷贝、移动语义等。我们会从最基础的构造函数开始,逐步扩展功能。
第一章:为什么要手写 C++ string
类?
1.1 理由与价值
在面试或者一些学习场景中,手写 string
类不仅仅是对字符串操作的考察,更多的是考察程序员对 C++ 内存管理的理解。例如,深拷贝与浅拷贝的实现,如何正确重载赋值运算符,如何避免内存泄漏,这些都是需要掌握的核心技能。
实现一个简易的 string
类可以帮助我们更好地理解:
- C++ 中动态内存管理:如何正确地分配与释放内存。
- 深拷贝与浅拷贝的区别:当对象之间共享资源时,如何避免潜在问题。
- 运算符重载的实现:尤其是赋值运算符和输出运算符的重载。
- 现代 C++ 特性:包括移动语义、右值引用等。
接下来,我们会从一个简单的 string
类开始,逐步扩展。
第二章:实现一个简单的 string
类
2.1 基本构造与析构
我们先实现 string
类的基础部分,包括构造函数、析构函数、字符串存储、内存管理等基础操作。在最初的实现中,我们将模拟 C++ 标准库 string
类的基本行为,让其能够存储字符串,并在析构时正确释放内存。
2.1.1 示例代码:基础的 string
类实现
#include <iostream>
#include <cstring> // 包含 strlen 和 strcpy 函数
#include <cassert> // 包含 assert 函数
namespace W
{
class string
{
public:
// 默认构造函数
string(const char* str = "") {
_size = strlen(str); // 计算字符串长度
_capacity = _size;
_str = new char[_capacity + 1]; // 动态分配内存
strcpy(_str, str); // 复制字符串内容
}
// 析构函数
~string() {
if (_str) {
delete[] _str; // 释放动态分配的内存
_str = nullptr;
}
}
private:
char* _str; // 存储字符串的字符数组
size_t _capacity; // 分配的内存容量
size_t _size; // 当前字符串的有效长度
};
}
int main() {
W::string s("Hello, World!");
return 0; // 程序结束时,析构函数自动释放内存
}
2.1.2 解读代码
在这个简单的 string
类中,我们实现了两个重要的函数:
- 构造函数:为字符串动态分配内存,并将传入的字符串内容复制到新分配的空间中。
- 析构函数:使用
delete[]
释放动态分配的内存,以避免内存泄漏。
接下来,我们将讨论拷贝构造函数以及浅拷贝带来的潜在问题。
2.2 浅拷贝与其缺陷
当前版本的 string
类只支持基本的构造和析构操作。如果我们通过另一个 string
对象来构造新的对象,默认情况下会发生浅拷贝,即对象共享同一块内存。这会带来潜在的内存管理问题,特别是当对象被销毁时,会导致多个对象同时试图释放同一块内存,进而导致程序崩溃。
2.2.1 示例代码:浅拷贝问题
void TestString() {
W::string s1("Hello C++");
W::string s2(s1); // 浅拷贝,s1 和 s2 共享同一块内存
// 当程序结束时,析构函数会尝试两次释放同一块内存,导致程序崩溃
}
问题分析:浅拷贝的默认行为只复制指针的值,即 s1
和 s2
都指向同一个内存区域。因此,当程序执行析构函数时,会尝试两次释放同一块内存,导致程序崩溃。
2.3 深拷贝的解决方案
为了避免浅拷贝带来的问题,我们需要在拷贝构造函数中实现深拷贝。深拷贝确保每个对象都有自己独立的内存空间,不会与其他对象共享内存。
2.3.1 示例代码:实现深拷贝
namespace W
{
class string
{
public:
// 构造函数
string(const char* str = "") {
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 深拷贝构造函数
string(const string& s) {
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1]; // 分配新的内存
strcpy(_str, s._str); // 复制字符串内容
}
// 析构函数
~string() {
delete[] _str;
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
void TestString() {
W::string s1("Hello C++");
W::string s2(s1); // 深拷贝,s1 和 s2 拥有独立的内存
}
第三章:赋值运算符重载与深拷贝
3.1 为什么需要重载赋值运算符?
在C++中,当我们将一个对象赋值给另一个对象时,默认情况下,编译器会为我们生成一个浅拷贝的赋值运算符。这意味着赋值后的对象和原对象会共享同一个内存空间,这会导致和浅拷贝相同的潜在问题,特别是在一个对象被销毁时,另一个对象继续使用该内存区域会引发错误。
为了解决这个问题,我们需要手动重载赋值运算符,确保每个对象都拥有自己独立的内存空间。
3.2 实现赋值运算符重载
在赋值运算符重载中,我们需要考虑以下几点:
- 自我赋值:对象是否会被赋值给自己,避免不必要的内存释放和分配。
- 释放原有资源:在赋值前,我们需要释放被赋值对象原有的内存资源,避免内存泄漏。
- 深拷贝:为目标对象分配新的内存,并复制内容。
3.2.1 示例代码:赋值运算符重载
namespace W
{
class string
{
public:
// 构造函数
string(const char* str = "") {
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 深拷贝构造函数
string(const string& s) {
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
// 赋值运算符重载
string& operator=(const string& s) {
if (this != &s) { // 避免自我赋值
delete[] _str; // 释放原有内存
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1]; // 分配新内存
strcpy(_str, s._str); // 复制内容
}
return *this;
}
// 析构函数
~string() {
delete[] _str;
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
void TestString() {
W::string s1("Hello");
W::string s2("World");
s2 = s1; // 调用赋值运算符重载
}
3.2.2 解读代码
-
自我赋值检查:自我赋值是指对象在赋值时被赋值给自己,例如
s1 = s1
。在这种情况下,如果我们没有进行检查,就会先删除对象的内存,然后再试图复制同一个对象的内容,这样会导致程序崩溃。因此,重载赋值运算符时,自我赋值检查是非常必要的。 -
释放原有内存:在分配新内存之前,我们必须先释放旧的内存,以防止内存泄漏。
-
深拷贝:通过分配新的内存,确保目标对象不会与源对象共享内存,避免浅拷贝带来的问题。
第四章:迭代器与字符串操作
4.1 迭代器的实现
迭代器是一种用于遍历容器(如数组、
string
等)的工具,它允许我们在不直接访问容器内部数据结构的情况下遍历容器。通过迭代器,可以使用范围for
循环等简便的方式遍历string
对象中的字符。
在我们的 string
类中,迭代器一般会被实现为指向字符数组的指针
4.1.1 示例代码:实现 string
类的迭代器
namespace W
{
class string
{
public:
// 非const迭代器
typedef char* iterator;
// const迭代器
typedef const char* const_iterator;
// 构造函数与析构函数等...
// 非const迭代器接口
iterator begin() { return _str; }
iterator end() { return _str + _size; }
// const迭代器接口(针对const对象)
const_iterator begin() const { return _str; }
const_iterator end() const { return _str + _size; }
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
void TestIterator() {
W::string s("Hello World!");
// 非const对象使用迭代器
for (W::string::iterator it = s.begin(); it != s.end(); ++it) {
*it = toupper(*it); // 转换为大写
}
std::cout << s << std::endl; // 输出:HELLO WORLD!
// const对象使用const迭代器
const W::string cs("Const String!");
for (W::string::const_iterator it = cs.begin(); it != cs.end(); ++it) {
std::cout << *it; // 只能读取,不能修改
}
std::cout << std::endl;
for (auto& ch : s) {
ch = tolower(ch); // 转换为小写
}
std::cout << s << std::endl; // 输出:hello world!
// 范围for循环遍历const对象
for (const auto& ch : cs) {
std::cout << ch; // 只能读取,不能修改
}
std::cout << std::endl;
}
第五章:字符串的常见操作
在 C++ 标准库
string
类中,提供了很多方便的字符串操作接口,如查找字符或子字符串、插入字符、删除字符等。我们也需要在自定义的string
类中实现这些操作。接下来,我们将逐步实现这些功能,并进行测试。
5.1 查找操作
C++ 中 string
类的 find()
函数用于查找字符串或字符在当前字符串中的位置。如果找到了字符或子字符串,find()
会返回其位置;如果找不到,则返回 string::npos
。
我们将在自定义的 string
类中实现类似的功能。
5.1.1 示例代码:实现字符和子字符串查找
namespace W
{
class string
{
public:
// 构造函数与析构函数等...
// 查找字符在字符串中的第一次出现位置
size_t find(char c, size_t pos = 0) const {
assert(pos < _size);
for (size_t i = pos; i < _size; ++i) {
if (_str[i] == c) {
return i;
}
}
return npos; // 如果没有找到,返回 npos
}
// 查找子字符串在字符串中的第一次出现位置
size_t find(const char* str, size_t pos = 0) const {
assert(pos < _size);
const char* p = strstr(_str + pos, str);
if (p) {
return p - _str; // 计算子字符串的位置
}
return npos; // 如果没有找到,返回 npos
}
public:
static const size_t npos = -1; // 定义 npos 为 -1,表示未找到
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
void TestFind() {
W::string s("Hello, World!");
// 查找字符
size_t pos = s.find('W');
if (pos != W::string::npos) {
std::cout << "'W' found at position: " << pos << std::endl;
} else {
std::cout << "'W' not found." << std::endl;
}
// 查找子字符串
size_t subPos = s.find("World");
if (subPos != W::string::npos) {
std::cout << "'World' found at position: " << subPos << std::endl;
} else {
std::cout << "'World' not found." << std::endl;
}
}
看到这里细心的小伙伴可能发现了,我们在声明
npos
的时候直接给了初始值,但是之前我们在【C++篇】C++类与对象深度解析(四):初始化列表、类型转换与static成员详解里明确说过静态成员变量只能在类外初始化,以及const
修饰的变量只能在初始化列表初始化,但这里却可以
这是为什么呢?不得不承认这是一看到就令人困惑的语法😂让我们来梳理一下
5.1.2 静态 const
成员变量初始化规则详解
5.1.2.1 静态成员变量属于类,而不属于对象
静态成员变量是在类层次上定义的,而不是在对象层次上。换句话说,静态成员变量是所有对象共享的,且只会有一份实例存在。因此,静态成员变量的内存是在程序的全局区域分配的,而不是在每个对象的内存中分配。
- 静态变量需要在全局范围内被初始化,以确保在所有对象中共享的唯一实例具有一致的值。
5.1.2.2 const
修饰符的作用
const
表示变量的值在其生命周期内不能被修改。因此,const
静态成员变量的值必须在类加载时确定,并且在整个程序运行过程中保持不变。- 但是
const
静态成员的值不能在对象实例化时通过构造函数来提供,必须直接在类级别初始化。
5.1.2.3 整型和枚举类型的特殊处理
C++ 允许整型(如 int
、char
)和枚举类型的 const
静态成员变量在类内部进行初始化。这是因为这些类型的值可以在编译时完全确定,编译器不需要等待运行时计算或分配内存。
class MyClass {
public:
static const int value = 42; // 可以直接在类内初始化
};
- 编译器可以将
value
当作编译时常量,它可以直接内联到使用它的代码中,不需要单独的存储空间。这种优化适用于常量表达式。
5.1.2.4 复杂类型为什么不能在类内初始化?
对于复杂类型(如 double
、float
或自定义类等),这些类型的初始化可能涉及到运行时的计算或需要分配更多的内存。C++ 的设计者为了避免复杂类型的静态成员在类内初始化时增加不必要的复杂性,要求这些变量必须在类外进行初始化。
class MyClass {
public:
static const double pi; // 在类内声明,但不能直接初始化
};
// 在类外初始化
const double MyClass::pi = 3.14159;
5.1.2.5 为什么 static const size_t npos = -1
可以在类内初始化?
size_t
是一种整型类型,尽管其大小和符号位取决于平台,但它仍然是整型常量的一种。因此,npos
的初始化类似于前面提到的整型静态成员变量。由于 -1
可以表示为 size_t
的最大值,这个值在编译时就可以确定,因此它符合类内初始化的条件。
class String {
public:
static const size_t npos = -1; // 可以在类内初始化
};
- 总结:因为
npos
是整型常量,并且编译器可以在编译时确定其值,符合在类内部初始化的条件。
5.1.2.6 总结:为什么静态 const
的复杂类型不能在类内初始化
- 整型和枚举类型的
const
静态成员变量可以在类内初始化,因为它们是编译时常量,编译器可以直接替换为常量值。 - 复杂类型(如
double
或对象类型)需要在类外初始化,因为这些类型的初始化可能依赖运行时的条件或动态内存分配。 - 这是 C++ 设计者在保证效率和复杂性之间做出的权衡,允许简单类型进行编译时优化,但要求复杂类型在类外显式初始化,以确保其初始化的灵活性和正确性。
没啥好说的,人家设计的,记住就行了🥲🥲
5.2 插入操作
C++ 中的 string
类允许我们在字符串的任意位置插入字符或子字符串。接下来,我们将在自定义的 string
类中实现类似的插入功能。
5.2.1 示例代码:实现字符串插入
其他没啥,注意下面这个问题:
无符号整型的易错问题👍
//注意:下面这个写法当pos==0时会出现死循环问题哦
/*for (size_t i = _size; i >= pos; --i) {
_str[i + len] = _str[i];
}*/
namespace W
{
class string
{
public:
// 构造函数与析构函数等...
// 在指定位置插入字符
string& insert(size_t pos, char c) {
assert(pos <= _size); // 确保插入位置合法
if (_size == _capacity) {
reserve(_capacity * 2); // 如果容量不够,扩展容量
}
// 将 pos 位置后的字符后移一位
for (size_t i = _size; i > pos; --i) {
_str[i] = _str[i - 1];
}
_str[pos] = c;
++_size;
_str[_size] = '\0';
return *this;
}
// 在指定位置插入字符串
string& insert(size_t pos, const char* str) {
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity) {
reserve(_size + len); // 如果容量不够,扩展容量
}
// 将 pos 位置后的字符后移 len 位
//注意:下面这个写法当pos==0时会出现死循环问题哦
/*for (size_t i = _size; i >= pos; --i) {
_str[i + len] = _str[i];
}*/
//采用这种
for (size_t i = _size; i + 1 > pos; --i) {
_str[i + len] = _str[i];
}
// 复制要插入的字符串
memcpy(_str + pos, str, len);
_size += len;
_str[_size] = '\0';
return *this;
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
void TestInsert() {
W::string s("Hello World!");
// 在第 5 位置插入逗号
s.insert(5, ',');
std::cout << s << std::endl;
// 在第 6 位置插入字符串
s.insert(6, " Beautiful");
std::cout << s << std::endl;
}
5.3 删除操作
string
类允许我们删除指定位置的字符或子字符串。接下来,我们实现字符串的删除功能。
5.3.1 示例代码:实现字符串删除
namespace W
{
class string
{
public:
// 在指定位置删除若干字符
string& erase(size_t pos, size_t len = npos) {
assert(pos <= _size); // 确保删除的位置合法
if (len == 0 || pos == _size) {
// 如果 len 为 0 或 pos 已经到达字符串末尾,无需执行任何操作
return *this;
}
if (len == npos || pos + len > _size) {
len = _size - pos; // 确保不越界删除
}
// 将 pos 后 len 位字符前移
for (size_t i = pos; i + len < _size; ++i) {
_str[i] = _str[i + len];
}
_size -= len;
_str[_size] = '\0'; // 更新字符串末尾
return *this;
}
private:
char* _str;
size_t _capacity;
size_t _size;
public:
static const size_t npos = -1; // 定义 npos 为 -1 表示无效位置
};
}
void TestErase() {
W::string s("Hello, Beautiful World!");
// 删除第 5 位置后的 9 个字符
s.erase(5, 9);
std::cout << s << std::endl; // 输出:Hello World!
}
读者须知与结语
在本文中,我们手写了一个简易版的
string
类,实现了诸如字符串插入、删除、查找等基本功能。然而,这个实现仍然是非常简陋的,使用了大量C
风格的字符串函数,如strlen
和strcpy
。这些函数都假设字符串是以'\0'
结尾的字符数组,这意味着如果字符串中间出现'\0'
,程序的行为将不可预期——它会错误地认为字符串已经结束。
此外,这个简易string
类在面对一些复杂的情况时也会显得捉襟见肘。例如,我们并没有考虑多线程安全性、异常处理等高级特性,而标准库的std::string
早已针对这些问题进行了优化。标准库中的string
类还支持更多的操作,并且在效率和内存管理上做了大量优化,因此我们的实现和真正的std::string
相比可谓天差万别。
但这并不是我们这篇文章的初衷。我们的目的是通过手写一个string
类,让你深入理解底层的内存管理、拷贝控制、动态分配等核心概念。这些基础知识对于深入学习 C++ 编程和理解 STL 容器的实现原理至关重要。通过这个简化版的实现,希望你能更加透彻地理解std::string
背后的机制。
如果你有任何问题或者更好的想法,欢迎在评论区分享你的观点。你们的反馈和支持是我创作的最大动力!
💬 欢迎讨论:如果你在学习过程中遇到问题,欢迎在评论区交流!
👍 支持一下:如果这篇文章对你有所帮助,请点赞、收藏并分享!你们的支持是我创作的动力!
以上就是关于【C++篇】手撕 C++ string 类:从零实现到深入剖析的模拟之路的内容啦,各位大佬有什么问题欢迎在评论区指正,或者私信我也是可以的啦,您的支持是我创作的最大动力!❤️