在C++标准库中,std::string已经是一个非常完善的字符串处理类了,那我们为什么还要自己动手实现一个呢?这就像虽然市面上有各种现成的汽车,但汽车工程师仍然需要从零开始造一辆车来理解所有细节。
首先,通过手写string类,我们可以深入理解以下几个关键点:
我在实际工作中发现,很多C++开发者虽然能熟练使用std::string,但当被问到"string对象在内存中是如何布局的"或者"为什么string可以像基本类型一样用=赋值"时,往往回答不上来。这就是知其然不知其所以然的表现。
我们先来定义类的骨架。一个最基本的string类需要包含以下成员:
cpp复制class MyString {
private:
char* m_data; // 存储字符串数据的指针
size_t m_size; // 当前字符串长度
size_t m_capacity; // 当前分配的内存容量
public:
// 构造函数和析构函数
MyString();
MyString(const char* str);
~MyString();
// 拷贝控制
MyString(const MyString& other);
MyString& operator=(const MyString& other);
// 基本功能
size_t size() const;
size_t capacity() const;
const char* c_str() const;
};
这里有几个设计要点需要注意:
重要提示:在C++中,如果你需要管理动态内存,那么"三大件"(析构函数、拷贝构造函数、赋值运算符)必须同时定义或同时不定义,这就是著名的Rule of Three原则。
内存管理是string类最核心的部分。我们采用类似std::vector的增长策略:
这种策略在时间和空间效率上取得了很好的平衡。我在实际测试中发现,1.5倍增长比2倍增长在内存利用率上更优,但2倍增长在频繁追加操作时性能更好。
让我们先实现基础的构造和析构:
cpp复制MyString::MyString() : m_data(nullptr), m_size(0), m_capacity(0) {
m_data = new char[1];
m_data[0] = '\0';
}
MyString::MyString(const char* str) {
m_size = strlen(str);
m_capacity = m_size + 1;
m_data = new char[m_capacity];
strcpy(m_data, str);
}
MyString::~MyString() {
delete[] m_data;
}
这里有几个容易出错的地方:
拷贝控制是string类最容易出错的部分,我们先看错误示范:
cpp复制// 错误实现 - 浅拷贝
MyString::MyString(const MyString& other)
: m_data(other.m_data),
m_size(other.m_size),
m_capacity(other.m_capacity) {}
这种实现会导致两个string对象共享同一块内存,析构时会出现双重释放的问题。正确的深拷贝实现如下:
cpp复制MyString::MyString(const MyString& other) {
m_size = other.m_size;
m_capacity = other.m_capacity;
m_data = new char[m_capacity];
strcpy(m_data, other.m_data);
}
MyString& MyString::operator=(const MyString& other) {
if (this != &other) { // 自赋值检查
delete[] m_data; // 释放原有内存
m_size = other.m_size;
m_capacity = other.m_capacity;
m_data = new char[m_capacity];
strcpy(m_data, other.m_data);
}
return *this;
}
这里有个重要技巧:赋值运算符要先检查自赋值情况(如s = s),否则直接delete会导致数据丢失。
实现字符串连接操作符+和+=:
cpp复制MyString operator+(const MyString& lhs, const MyString& rhs) {
MyString result;
result.m_size = lhs.m_size + rhs.m_size;
result.m_capacity = result.m_size + 1;
result.m_data = new char[result.m_capacity];
strcpy(result.m_data, lhs.m_data);
strcat(result.m_data, rhs.m_data);
return result;
}
MyString& MyString::operator+=(const MyString& other) {
size_t new_size = m_size + other.m_size;
if (new_size + 1 > m_capacity) {
reserve(new_size * 2); // 扩容
}
strcat(m_data, other.m_data);
m_size = new_size;
return *this;
}
实现时要注意:
为了支持类似数组的访问方式,我们需要实现operator[]:
cpp复制char& MyString::operator[](size_t index) {
if (index >= m_size) {
throw std::out_of_range("Index out of range");
}
return m_data[index];
}
const char& MyString::operator[](size_t index) const {
if (index >= m_size) {
throw std::out_of_range("Index out of range");
}
return m_data[index];
}
同时,为了支持范围for循环,我们可以提供简单的迭代器支持:
cpp复制char* MyString::begin() { return m_data; }
char* MyString::end() { return m_data + m_size; }
const char* MyString::begin() const { return m_data; }
const char* MyString::end() const { return m_data + m_size; }
现代C++中,移动语义可以显著提升性能。我们需要实现移动构造函数和移动赋值运算符:
cpp复制MyString::MyString(MyString&& other) noexcept
: m_data(other.m_data),
m_size(other.m_size),
m_capacity(other.m_capacity) {
other.m_data = nullptr;
other.m_size = 0;
other.m_capacity = 0;
}
MyString& MyString::operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] m_data;
m_data = other.m_data;
m_size = other.m_size;
m_capacity = other.m_capacity;
other.m_data = nullptr;
other.m_size = 0;
other.m_capacity = 0;
}
return *this;
}
移动操作的关键点:
在实际项目中,很多字符串都很短(小于16字节)。我们可以实现小字符串优化(SSO):
cpp复制class MyString {
private:
union {
char* m_data;
char m_sso_buffer[16];
};
size_t m_size;
bool m_is_sso;
// 其他成员...
};
当字符串较短时,直接使用栈上的buffer;较长时才动态分配内存。这种优化可以显著减少小字符串的内存分配开销。
编写测试用例验证基本功能:
cpp复制void test_basic() {
MyString s1; // 默认构造
assert(s1.size() == 0);
MyString s2("hello"); // C字符串构造
assert(s2.size() == 5);
MyString s3 = s2; // 拷贝构造
assert(strcmp(s3.c_str(), "hello") == 0);
s1 = s3; // 赋值
assert(s1.size() == 5);
}
特别注意测试边界情况:
cpp复制void test_edge_cases() {
// 空字符串
MyString empty;
assert(empty.size() == 0);
// 自赋值
MyString s("test");
s = s;
assert(strcmp(s.c_str(), "test") == 0);
// 超长字符串
const char* longStr = "this is a very long string...";
MyString ls(longStr);
assert(ls.size() == strlen(longStr));
}
比较自定义string和std::string的性能:
cpp复制void test_performance() {
auto start = std::chrono::high_resolution_clock::now();
MyString s;
for (int i = 0; i < 100000; ++i) {
s += "test";
}
auto end = std::chrono::high_resolution_clock::now();
std::cout << "MyString: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< "ms\n";
}
内存泄漏是最常见的问题之一。确保:
字符串操作容易发生缓冲区溢出。预防措施:
保证在异常发生时对象仍处于有效状态:
当前实现仅支持ASCII字符。要支持Unicode需要:
C++17引入了string_view,我们可以实现类似的轻量级视图类:
cpp复制class MyStringView {
private:
const char* m_data;
size_t m_size;
public:
// 接口实现...
};
实现类似sprintf的格式化功能:
cpp复制MyString MyString::format(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
// 计算所需空间
int len = vsnprintf(nullptr, 0, fmt, args);
MyString result;
result.reserve(len + 1);
vsnprintf(result.m_data, len + 1, fmt, args);
result.m_size = len;
va_end(args);
return result;
}
手写string类是一个非常好的学习项目,它几乎涵盖了C++面向对象编程和资源管理的所有核心概念。在实际项目中,除非有特殊需求,否则还是建议使用标准库的std::string,因为它经过了充分的优化和测试。但通过这个练习,你会对C++的内存管理、类设计和运算符重载有更深入的理解。