在C++开发中,string类是我们最常用的工具之一。但你是否想过,标准库中的string是如何工作的?自己动手实现一个简化版的string类,是深入理解内存管理、类设计和运算符重载的绝佳途径。我在刚接触C++时,通过这个练习真正理解了深浅拷贝的区别,也学会了如何设计一个资源管理类。
这个实现指南将带你从零开始,构建一个具备基本功能的MyString类。我们会实现构造函数、遍历方法、修改操作以及常用接口。不同于简单的代码展示,我会重点解释每个设计决策背后的考量,以及实际开发中可能遇到的陷阱。
我们先定义MyString类的基本结构:
cpp复制class MyString {
public:
// 构造函数和析构函数
MyString();
MyString(const char* str);
MyString(const MyString& other);
~MyString();
// 常用接口
size_t size() const;
const char* c_str() const;
// 运算符重载
MyString& operator=(const MyString& other);
private:
char* m_data; // 存储字符串数据
size_t m_length; // 字符串长度(不含'\0')
};
这里有几个关键设计点:
char*和size_t作为成员变量,这是最基础的内存管理方式注意:m_length存储的是字符串的实际长度,不包括结尾的'\0'。这与标准库string的行为保持一致。
字符串类的核心挑战在于内存管理。我们采用最简单的策略:每次分配刚好足够的空间。下面是构造函数的实现:
cpp复制MyString::MyString(const char* str) {
if (str == nullptr) {
m_data = new char[1];
*m_data = '\0';
m_length = 0;
} else {
m_length = strlen(str);
m_data = new char[m_length + 1];
strcpy(m_data, str);
}
}
这里有几个值得注意的细节:
字符串类必须正确处理拷贝语义。默认的拷贝构造函数和赋值运算符执行的是浅拷贝,这会导致多个对象共享同一块内存,引发双重释放等问题。我们必须实现深拷贝:
cpp复制MyString::MyString(const MyString& other) {
m_length = other.m_length;
m_data = new char[m_length + 1];
strcpy(m_data, other.m_data);
}
MyString& MyString::operator=(const MyString& other) {
if (this != &other) { // 防止自赋值
delete[] m_data; // 释放原有内存
m_length = other.m_length;
m_data = new char[m_length + 1];
strcpy(m_data, other.m_data);
}
return *this;
}
关键点:
析构函数的实现相对简单,但至关重要:
cpp复制MyString::~MyString() {
delete[] m_data;
m_data = nullptr; // 好习惯,但非必须
m_length = 0;
}
实际经验:在调试时,将指针置为nullptr可以帮助发现悬垂指针问题。但在生产代码中,这个操作通常可以省略。
我们先实现最基础的size()和c_str()方法:
cpp复制size_t MyString::size() const {
return m_length;
}
const char* MyString::c_str() const {
return m_data;
}
这些方法非常简单,但要注意:
实现字符串遍历有多种方式,我们提供两种常用方法:
1. 使用operator[]
cpp复制char& MyString::operator[](size_t index) {
if (index >= m_length) {
throw std::out_of_range("Index out of range");
}
return m_data[index];
}
const char& MyString::operator[](size_t index) const {
if (index >= m_length) {
throw std::out_of_range("Index out of range");
}
return m_data[index];
}
2. 实现迭代器支持
cpp复制class MyString {
public:
// 迭代器类型定义
using iterator = char*;
using const_iterator = const char*;
iterator begin() { return m_data; }
iterator end() { return m_data + m_length; }
const_iterator begin() const { return m_data; }
const_iterator end() const { return m_data + m_length; }
};
这样,用户就可以用range-for循环遍历我们的字符串:
cpp复制MyString str("hello");
for (char c : str) {
std::cout << c << " ";
}
实现字符串拼接的operator+=:
cpp复制MyString& MyString::operator+=(const MyString& other) {
char* new_data = new char[m_length + other.m_length + 1];
strcpy(new_data, m_data);
strcat(new_data, other.m_data);
delete[] m_data;
m_data = new_data;
m_length += other.m_length;
return *this;
}
这个实现有几个优化点:
实现clear()方法:
cpp复制void MyString::clear() {
delete[] m_data;
m_data = new char[1];
*m_data = '\0';
m_length = 0;
}
注意这里不是简单地设置m_length=0,而是真正释放内存并重新分配。这与标准库string的行为一致。
实现find方法,查找子串位置:
cpp复制size_t MyString::find(const char* substr, size_t pos = 0) const {
if (substr == nullptr || pos >= m_length) {
return npos;
}
const char* result = strstr(m_data + pos, substr);
return result ? result - m_data : npos;
}
关键点:
实现substr方法,提取子串:
cpp复制MyString MyString::substr(size_t pos, size_t len = npos) const {
if (pos >= m_length) {
throw std::out_of_range("Position out of range");
}
size_t actual_len = std::min(len, m_length - pos);
MyString result;
delete[] result.m_data; // 释放默认构造分配的空间
result.m_data = new char[actual_len + 1];
strncpy(result.m_data, m_data + pos, actual_len);
result.m_data[actual_len] = '\0';
result.m_length = actual_len;
return result;
}
这个实现展示了如何:
标准库string通常会实现短字符串优化(SSO),即对小字符串不使用堆分配。我们可以简化实现如下:
cpp复制class MyString {
private:
static const size_t SSO_SIZE = 15; // 根据平台调整
union {
struct {
char* m_data;
size_t m_length;
} long_str;
char sso_buffer[SSO_SIZE + 1];
};
bool is_sso;
// 其他成员...
};
这种实现更复杂,但能显著提升小字符串的性能。实际项目中,需要权衡实现的复杂性和性能收益。
现代C++中,实现移动构造函数和移动赋值运算符可以避免不必要的拷贝:
cpp复制MyString::MyString(MyString&& other) noexcept
: m_data(other.m_data), m_length(other.m_length) {
other.m_data = nullptr;
other.m_length = 0;
}
MyString& MyString::operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] m_data;
m_data = other.m_data;
m_length = other.m_length;
other.m_data = nullptr;
other.m_length = 0;
}
return *this;
}
移动操作的关键点:
编写测试代码验证我们的实现:
cpp复制void test_MyString() {
// 构造测试
MyString s1;
MyString s2("hello");
MyString s3 = s2;
// 修改测试
s1 = s3;
s1 += " world";
// 遍历测试
for (size_t i = 0; i < s1.size(); ++i) {
s1[i] = toupper(s1[i]);
}
// 查找测试
size_t pos = s1.find("WORLD");
MyString sub = s1.substr(pos);
// 输出验证
std::cout << s1.c_str() << std::endl;
std::cout << sub.c_str() << std::endl;
}
特别注意测试边界条件:
我们的MyString实现了基本功能,但与标准库string相比还有差距:
在实际项目中,除非有特殊需求,否则应该优先使用标准库string。这个练习的主要价值在于理解底层实现原理。
如果你想进一步挑战自己,可以考虑:
我在实际项目中遇到过需要自定义字符串类的情况,主要是为了与特定内存管理系统集成。但99%的情况下,标准库string已经足够优秀。理解它的实现原理,能帮助我们更好地使用它。