在 C++ 的学习中,模拟实现 string 类,能让我们深入了解 C++ 中字符串的底层实现机制。当我们亲手构建一个 string 类时,就不得不面对内存分配、字符存储、字符串操作等一系列底层问题。我们可以了解到字符串是如何在内存中存储的,是连续存储还是离散存储;在进行字符串拼接时,底层是如何处理内存的重新分配和数据的复制的。这些原本抽象的概念变得具体可触,我们对 C++ 字符串的理解也会更加深刻。
一、前置知识
在开始模拟实现 string 类之前,我们需要先掌握一些 C++ 的基础知识,这些知识就像是搭建房屋的基石,是实现 string 类的必备条件。
1.1 指针
指针是 C++ 中的一个重要概念,它就像是一个指向内存地址的 "箭头"。在实现 string 类时,我们会使用指针来指向存储字符串的内存空间。例如,我们可以定义一个字符指针char* str,通过这个指针来操作字符串。当我们创建一个 string 对象时,内部会有一个指针指向存放字符串的内存区域,通过这个指针,我们可以对字符串进行读取、修改等操作。指针的使用让我们能够直接与内存交互,这对于实现高效的字符串操作至关重要,但同时也需要我们小心处理,避免出现指针悬空、内存泄漏等问题 。
1.2 内存管理
内存管理是 C++ 编程中的关键环节,在实现 string 类时,我们需要自己管理字符串所占用的内存。当创建一个 string 对象时,需要为其分配足够的内存来存储字符串;当字符串内容发生变化时,可能需要重新分配内存;当 string 对象被销毁时,要及时释放所占用的内存,否则就会造成内存泄漏。比如,在实现 string 的构造函数时,我们使用new操作符为字符串分配内存,而在析构函数中,使用delete操作符释放内存,以此来确保内存的正确管理。
1.3 运算符重载
运算符重载是 C++ 的一个强大特性,它允许我们为自定义类型定义运算符的行为。在 string 类中,我们会重载很多运算符,如+、+=、[]、==、!=等 。通过重载+运算符,我们可以实现两个字符串的拼接;重载[]运算符,能够像访问数组元素一样访问字符串中的字符。这使得我们在使用 string 类时,就像使用内置数据类型一样自然和方便。例如,string s1 = "hello"; string s2 = "world"; string s3 = s1 + " " + s2;,这里的+运算符就是被重载后的,用于实现字符串的拼接功能。
二、定义 string 类的基本结构
2.1 成员变量
在 C++ 中模拟实现 string 类,首先要定义其基本结构,其中成员变量的设计至关重要。一般来说,我们会定义以下几个关键的成员变量:
- char* str:这是一个字符指针,用于指向存储字符串的内存空间。它就像是一个指向字符串 "家" 的地址牌,通过这个指针,我们可以找到字符串在内存中的存储位置,进而对字符串进行各种操作,比如读取、修改等。例如,当我们创建一个字符串 "hello" 时,str指针就会指向一块足够存储这 5 个字符以及结束符'\0'的内存区域。
- size_t size:用于记录字符串的实际长度,也就是字符串中字符的个数,但不包括字符串结束符'\0'。它就像是一个计数器,时刻告诉我们字符串里有多少个字符。比如对于字符串 "world",size的值就是 5。
- size_t capacity:表示当前为字符串分配的内存容量,它一般会大于或等于size。这可以理解为我们为字符串准备的一个 "房间" 的大小,size是实际使用的空间,而capacity是整个房间的容量。例如,我们为字符串 "hello" 分配了 10 个字符的空间,那么capacity就是 10 ,而size是 5。预留一定的容量可以减少在字符串操作过程中频繁的内存重新分配,提高效率 。
2.2 命名空间
在 C++ 中,标准库已经提供了一个string类,为了避免我们自定义的string类与标准库中的string类发生命名冲突,我们需要将自定义的string类放在一个命名空间中。命名空间就像是一个 "隔离舱",把我们的代码与其他代码隔离开来,确保相同的名字在不同的命名空间中不会相互干扰。
比如,我们可以定义一个名为my_string_namespace的命名空间:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
// 这里添加类的成员函数和其他定义
};
}
这样,我们自定义的string类就被封装在了my_string_namespace命名空间中。在使用时,需要通过命名空间限定符来访问,例如:
my_string_namespace::string s = "Hello";
如果不使用命名空间,当我们的代码中同时包含标准库的string和自定义的string时,编译器就会因为无法区分两个同名的类而报错。使用命名空间不仅解决了命名冲突的问题,还提高了代码的可读性和可维护性,使代码结构更加清晰 。
三、实现构造函数
构造函数在 C++ 中就像是一个特殊的 "工匠",当我们创建一个对象时,它会自动被调用,承担起初始化对象的重要职责。在模拟实现 string 类时,我们需要定义几种不同的构造函数,来满足各种创建字符串对象的需求 。
3.1 无参构造函数
无参构造函数是构造函数中的一种特殊形式,当我们创建对象时不需要传递任何参数,它就会 "登场"。在实现 string 类的无参构造函数时,我们通常会开辟一个包含一个字符空间的内存区域,用于存放字符串结束符'\0'。这就像是为一个空字符串准备了一个小小的 "家",虽然这个家目前只需要容纳一个结束符,但它是字符串存在的基础。例如:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
string() : size(0), capacity(1) {
str = new char[capacity];
str[0] = '\0';
}
};
}
在这段代码中,我们首先将size初始化为 0,表示字符串长度为 0;capacity初始化为 1,表示分配了一个字符的空间。然后使用new操作符为str指针分配一个字符大小的内存空间,并将字符串结束符'\0'存入这个空间,完成了无参构造函数的初始化工作 。
3.2 有参构造函数
有参构造函数允许我们在创建对象时传入一个字符串,它会根据传入的字符串来初始化 string 对象。在实现时,首先需要根据传入字符串的长度来计算所需的内存容量,然后开辟相应大小的内存空间,最后将传入的字符串内容复制到新开辟的内存中。这就像是根据客人的行李大小来准备合适的行李箱,并把行李整齐地装进去。例如:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
string(const char* s) {
size = strlen(s);
capacity = size + 1;
str = new char[capacity];
strcpy(str, s);
}
};
}
这里,通过strlen(s)获取传入字符串s的长度,将其赋值给size,并将capacity设置为size + 1,以容纳字符串结束符'\0'。接着使用new分配内存空间,并通过strcpy函数将字符串s的内容复制到str所指向的内存中 。
3.3 拷贝构造函数
拷贝构造函数是一种特殊的构造函数,用于根据已有的对象创建一个新的对象,新对象是原对象的副本。在实现 string 类的拷贝构造函数时,我们要特别注意深拷贝和浅拷贝的问题。浅拷贝就像是简单地复制了对象的指针,这样两个对象的指针会指向同一块内存,当其中一个对象销毁时,这块内存被释放,另一个对象的指针就会变成悬空指针,导致程序出错。而深拷贝则是为新对象开辟一块新的内存空间,并将原对象的内容复制到新空间中,这样两个对象就有了各自独立的内存,不会相互影响。这就像是制作一份文件的副本,浅拷贝只是复制了文件的链接,而深拷贝是实实在在地复制了文件的内容。例如:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
string(const string& other) : size(other.size), capacity(other.capacity) {
str = new char[capacity];
strcpy(str, other.str);
}
};
}
在这个拷贝构造函数中,首先将新对象的size和capacity初始化为原对象的size和capacity,然后为新对象的str指针分配一块大小为capacity的内存空间,最后使用strcpy将原对象str所指向的字符串内容复制到新分配的内存中,实现了深拷贝 。
四、实现析构函数
析构函数在 C++ 中扮演着 "资源回收者" 的重要角色,当对象的生命周期结束时,它会被自动调用,负责释放对象所占用的资源,避免内存泄漏。在我们模拟实现的 string 类中,析构函数主要承担着释放str指针所指向的内存空间,并将成员变量size和capacity置零的任务。
当 string 对象不再被使用,比如局部对象离开其作用域、动态分配的对象被delete释放、全局或静态对象在程序结束时,析构函数就会开始工作。在析构函数中,首先使用delete[]操作符释放str指针指向的动态分配的字符数组内存,因为str是通过new[]分配的内存,所以要用delete[]来释放,以确保内存的正确回收。例如:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
~string() {
delete[] str;
size = 0;
capacity = 0;
}
};
}
释放内存后,将size和capacity置为 0,这不仅是一种良好的编程习惯,能让对象在销毁后处于一个明确的、可预期的状态,还方便后续对对象的管理和调试。如果不将这些成员变量置零,它们可能会保留原来的值,在某些情况下可能会导致程序出现难以排查的错误。比如,当我们在程序的其他地方意外访问到已经销毁的对象的这些成员变量时,如果它们没有被置零,可能会误以为对象还持有有效的字符串内容和内存容量 。通过正确实现析构函数,我们能够确保 string 类在使用过程中的内存安全和资源有效管理,为整个类的稳定运行提供保障 。
往期精选干货 | C/C++ 开发人从迷茫到进阶的全攻略
不管你是选方向、找工作、冲大厂,还是学路线、备面试、练实操,这里都有对应的解决方案,帮你少走弯路:
👉 搞懂 C++ 就业前景 & 求职避坑:为什么很多人劝退学 C++,但大厂核心岗位还是要 C++?
👉 大厂后端岗系统进阶路线:【大厂标准】Linux C/C++ 后端进阶学习路线
👉 音视频赛道核心学习路径:音视频流媒体高级开发 - 学习路线
👉 Qt 桌面 / 嵌入式开发全闭环攻略:C++ Qt 学习路线一条龙!(桌面开发 & 嵌入式开发)
👉 Linux 内核硬核修炼指南:Linux 内核学习指南,硬核修炼手册
👉 面试冲刺高频八股题库:C/C++ 高频八股文面试题 1000 题(三)
👉 C++ 实操能力硬核提升:手撕线程池:C++ 程序员的能力试金石
五、运算符重载实现
5.1 赋值运算符重载
赋值运算符重载是 C++ 中一个重要的操作,它允许我们自定义对象之间的赋值行为。在实现 string 类的赋值运算符重载时,我们可以采用一种巧妙的方式,即通过交换临时对象来实现。这种方法不仅简洁高效,还能避免一些常见的问题,比如自赋值问题。
具体实现步骤如下:首先,我们以值传递的方式接收一个参数,这会触发参数的拷贝构造函数,创建一个临时对象。这个临时对象就像是一个 "副本",拥有和传入对象相同的内容。然后,我们使用swap函数交换当前对象和临时对象的资源,包括str指针、size和capacity等成员变量。这一步就像是把两个对象的 "内部物品" 进行了交换。最后,当函数结束时,临时对象会自动调用析构函数,释放掉它原来的资源,而这些资源现在已经属于当前对象了。
通过这种方式,我们不仅实现了对象之间的赋值,还避免了自赋值可能带来的风险。因为即使当前对象和传入对象是同一个,交换操作也不会有问题,只是相当于自己和自己交换,不会对资源造成任何破坏。例如:
namespace my_string_namespace {
class string {
private:
char* str;
size_t size;
size_t capacity;
public:
string& operator=(string other) {
swap(str, other.str);
swap(size, other.size);
swap(capacity, other.capacity);
return *this;
}
void swap(string& other) noexcept {
std::swap(str, other.str);
std::swap(size, other.size);
std::swap(capacity, other.capacity);
}
};
}
在这段代码中,operator=函数通过值传递接收other参数,创建临时对象。然后通过swap函数交换当前对象和临时对象的资源,最后返回当前对象,完成赋值操作。这种实现方式简洁明了,同时保证了代码的健壮性和安全性 。
5.2 关系运算符重载
关系运算符重载在 C++ 中用于定义自定义类型之间的比较关系,在 string 类中,重载==、!=、<、>等运算符能够让我们方便地比较两个字符串的大小和相等性。
对于==运算符,我们需要逐个比较两个字符串中的字符,直到遇到不相等的字符或者到达字符串的末尾。只有当两个字符串的所有字符都相等,并且长度也相等时,才认为它们相等。例如:
bool operator==(const string& other) const {
if (size != other.size) {
return false;
}
for (size_t i = 0; i < size; ++i) {
if (str[i] != other.str[i]) {
return false;
}
}
return true;
}
在这段代码中,首先比较两个字符串的长度,如果长度不同,直接返回false。然后逐个比较字符,若发现不相等的字符,立即返回false。只有所有字符都相等时,才返回true。
!=运算符的实现可以借助==运算符,只要两个字符串不相等,!=就为true,即return!(*this == other); 。
对于<运算符,比较逻辑稍微复杂一些。我们同样逐个比较字符,当遇到不相等的字符时,根据字符的大小关系返回结果。如果一个字符串是另一个字符串的前缀,那么较短的字符串小于较长的字符串。例如:
bool operator<(const string& other) const {
size_t min_len = std::min(size, other.size);
for (size_t i = 0; i < min_len; ++i) {
if (str[i] < other.str[i]) {
return true;
} else if (str[i] > other.str[i]) {
return false;
}
}
return size < other.size;
}
这里先取两个字符串长度的最小值,在这个范围内比较字符。如果找到较小的字符,返回true;若找到较大的字符,返回false。若在这个范围内字符都相等,最后根据字符串长度判断,较短的字符串小于较长的字符串。>、<=、>=等运算符可以通过<和==运算符组合实现 。
5.3 下标运算符重载
下标运算符重载在 C++ 中允许我们像访问数组元素一样访问自定义类中的元素,在 string 类中,重载[]运算符能够让我们方便地访问字符串中的每个字符。
实现[]运算符时,我们需要返回对应下标的字符的引用,这样既可以读取字符,也可以修改字符。为了确保安全性,我们还需要进行边界检查,防止下标越界。例如:
char& operator[](size_t index) {
// 这里可以添加更详细的边界检查,比如抛出异常
return str[index];
}
const char& operator[](size_t index) const {
// 这里可以添加更详细的边界检查,比如抛出异常
return str[index];
}
在这段代码中,定义了两个重载版本的[]运算符,一个用于非 const 对象,返回字符的引用,允许修改字符;另一个用于 const 对象,返回 const 字符引用,只能读取字符。通过这样的实现,我们可以像使用数组一样使用 string 对象,如s[0] = 'a';来修改字符串的第一个字符,或者char c = s[1];来读取第二个字符 。
六、常见成员函数实现
6.1 reserve 函数
reserve函数在 C++ 的 string 类中起着重要的作用,它的主要职责是根据传入的容量参数n,对字符串当前的容量进行调整。当n大于当前字符串的容量capacity时,就需要重新分配内存。这就像是为字符串换一个更大的 "房子" 来居住。
在重新分配内存的过程中,首先会创建一个新的字符指针new_str,并使用new操作符为其分配大小为n + 1的内存空间,这里的+1是为了给字符串结束符'\0'预留位置。然后,通过strcpy函数将原字符串str的内容复制到新分配的内存空间new_str中。
void string::reserve(size_t n) {
if (n > capacity) {
char* new_str = new char[n + 1];
strcpy(new_str, str);
delete[] str;
str = new_str;
capacity = n;
}
}
完成复制后,使用delete[]操作符释放原字符串str所占用的内存空间,以避免内存泄漏。最后,将str指针指向新分配的内存空间new_str,并更新capacity为新的容量n,这样字符串就有了足够的空间来存储更多的字符 。
6.2 resize 函数
resize函数的作用是改变字符串的长度,使其变为指定的长度n。当n小于当前字符串的实际长度size时,会截断字符串,只保留前n个字符,并在第n个字符的位置添加字符串结束符'\0',就像是把一条长绳子剪短到指定的长度。
当n大于当前字符串的实际长度size时,会在字符串的末尾填充指定的字符ch,直到字符串的长度达到n。在填充之前,会先调用reserve函数来确保有足够的容量。
void string::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';
}
}
通过这样的操作,resize函数能够灵活地调整字符串的长度,满足不同的需求 。
6.3 push_back 函数
push_back函数用于在字符串的末尾添加一个字符ch。在添加字符之前,会先检查当前字符串的容量是否足够。如果size大于或等于capacity,说明当前容量不足,需要进行扩容。
扩容时,通常会将容量扩大为原来的两倍(也可以采用其他扩容策略),然后调用reserve函数来重新分配内存,为字符串提供足够的空间
void string::push_back(char ch) {
if (size >= capacity) {
size_t new_capacity = capacity == 0? 4 : capacity * 2;
reserve(new_capacity);
}
str[size] = ch;
++size;
str[size] = '\0';
}
在确保有足够的空间后,将字符ch添加到str[size]的位置,然后将size加 1,并在新的size位置添加字符串结束符'\0',完成字符的添加操作 。
6.4 append 函数
append函数用于在字符串的末尾追加另一个字符串。在追加之前,需要先计算追加后的总长度,判断当前容量是否足够。如果追加后的总长度大于当前容量capacity,则需要进行扩容。
扩容时,会根据追加字符串的长度和当前容量,选择合适的新容量,比如可以选择当前容量和追加后总长度中较大的那个值作为新容量,然后调用reserve函数重新分配内存。
void string::append(const char* s) {
size_t len = strlen(s);
if (size + len > capacity) {
size_t new_capacity = std::max(capacity * 2, size + len);
reserve(new_capacity);
}
strcpy(str + size, s);
size += len;
}
在保证有足够空间后,使用strcpy函数将字符串s复制到str + size的位置,即当前字符串的末尾,然后更新size为追加后的总长度,完成字符串的追加操作 。
6.5 find 函数
find函数用于查找字符或子串在字符串中首次出现的位置。当查找字符ch时,从指定位置pos开始,逐个遍历字符串中的字符,直到找到与ch相等的字符,返回该字符的位置;如果遍历完整个字符串都没有找到,则返回一个特殊值,比如可以定义一个静态成员变量npos表示未找到,其值可以设为-1(在实际实现中,由于size_t是无符号类型,通常会将npos设为size_t类型能表示的最大值)。
size_t string::find(char ch, size_t pos = 0) const {
for (size_t i = pos; i < size; ++i) {
if (str[i] == ch) {
return i;
}
}
return npos;
}
当查找子串时,从pos位置开始,依次比较字符串中的每个子串是否与目标子串相等。可以使用strncmp函数来进行子串的比较,当找到相等的子串时,返回子串的起始位置;若未找到,同样返回npos。
size_t string::find(const char* s, size_t pos = 0) const {
size_t len = strlen(s);
for (size_t i = pos; i <= size - len; ++i) {
if (strncmp(str + i, s, len) == 0) {
return i;
}
}
return npos;
}
通过这样的实现,find函数能够方便地在字符串中查找字符或子串的位置,为字符串的操作提供了有力的支持 。
七、测试与验证
为了确保我们模拟实现的 string 类功能的正确性,编写测试用例是必不可少的环节。测试就像是一场严格的考试,对我们实现的 string 类进行全方位的检验,确保它在各种情况下都能稳定、准确地运行 。
在 C++ 中,有许多优秀的测试框架可供选择,比如 Google Test、Catch2 等 。这里以 Google Test 为例,展示如何编写测试用例来验证我们的 string 类。
首先,需要包含 Google Test 的头文件,并将测试用例放在TEST宏中。TEST宏接受两个参数,第一个是测试套件的名称,第二个是测试用例的名称。例如,我们要测试 string 类的构造函数,可以这样编写测试用例:
#include <gtest/gtest.h>
#include "my_string.h" // 假设自定义string类的头文件名为my_string.h
TEST(StringConstructorTest, DefaultConstructor) {
my_string_namespace::string s;
EXPECT_EQ(s.size(), 0);
EXPECT_EQ(s.capacity(), 1);
EXPECT_STREQ(s.c_str(), "");
}
TEST(StringConstructorTest, ParameterizedConstructor) {
const char* str = "Hello, World!";
my_string_namespace::string s(str);
EXPECT_EQ(s.size(), strlen(str));
EXPECT_EQ(s.capacity(), strlen(str) + 1);
EXPECT_STREQ(s.c_str(), str);
}
TEST(StringConstructorTest, CopyConstructor) {
my_string_namespace::string s1("Hello");
my_string_namespace::string s2(s1);
EXPECT_EQ(s2.size(), s1.size());
EXPECT_EQ(s2.capacity(), s1.capacity());
EXPECT_STREQ(s2.c_str(), s1.c_str());
}
在这个例子中,StringConstructorTest是测试套件的名称,DefaultConstructor、ParameterizedConstructor和CopyConstructor分别是不同的测试用例名称。每个测试用例中,通过EXPECT_EQ、EXPECT_STREQ等断言宏来验证 string 类对象的属性和行为是否符合预期。
对于运算符重载的测试,比如==运算符,可以这样编写:
TEST(StringOperatorTest, EqualityOperator) {
my_string_namespace::string s1("Hello");
my_string_namespace::string s2("Hello");
my_string_namespace::string s3("World");
EXPECT_TRUE(s1 == s2);
EXPECT_FALSE(s1 == s3);
}
对于成员函数的测试,以find函数为例:
TEST(StringMemberFunctionTest, FindFunction) {
my_string_namespace::string s("Hello, World!");
EXPECT_EQ(s.find('W'), 7);
EXPECT_EQ(s.find("World"), 7);
EXPECT_EQ(s.find('z'), my_string_namespace::string::npos);
}