1. 为什么我们需要自己写一个String类?
说实话,C++标准库里的std::string已经很好用了。但我在带团队的时候发现,很多新人其实并不清楚它背后到底干了什么。你想想看,一个简单的字符串赋值,编译器究竟帮你处理了多少事情?
我个人习惯是,在讲深拷贝、浅拷贝、移动语义这些概念时,先让大家亲手实现一个String类。这就像学开车前先拆一遍发动机——虽然你以后不会天天拆,但懂了原理,开起来心里踏实。
好,那我们就从零开始,设计一个完整的String类。它会包含:
- 构造函数(默认、带参、拷贝)
- 析构函数
- 移动构造函数和移动赋值运算符
- 拷贝赋值运算符
- 几个常用的运算符重载(+、==、[]、<<)
嗯,这基本上就是一个迷你版std::string了。
2. 类的骨架设计
先搭个架子。我们的String类内部用动态分配的char数组来存储字符串。为什么不用固定数组?因为字符串长度是不确定的,动态分配更灵活。
class String {
private:
char* data_; // 指向动态分配的字符数组
size_t size_; // 字符串长度(不含'\0')
public:
// 构造函数
String(); // 默认构造
String(const char* str); // 从C风格字符串构造
String(const String& other); // 拷贝构造
String(String&& other) noexcept; // 移动构造
// 析构函数
~String();
// 赋值运算符
String& operator=(const String& other); // 拷贝赋值
String& operator=(String&& other) noexcept; // 移动赋值
// 运算符重载
String operator+(const String& other) const;
bool operator==(const String& other) const;
char& operator[](size_t index);
const char& operator[](size_t index) const;
// 工具函数
size_t size() const;
const char* c_str() const;
// 友元函数用于输出
friend std::ostream& operator<<(std::ostream& os, const String& str);
};
这个结构很清晰。你可能会问:为什么移动构造函数和移动赋值要加noexcept?
我在项目中踩过这个坑。如果不加noexcept,标准库容器(比如std::vector)在扩容时,宁愿选择拷贝也不会用移动。因为移动如果抛异常,容器无法回滚。加了noexcept,编译器才敢放心用移动语义,性能能提升不少。
3. 构造与析构:内存管理的基石
3.1 默认构造函数
默认构造一个空字符串。注意,即使是空串,我们也要分配一个字节存放'\0'。这是C风格字符串的惯例。
String::String() : data_(new char[1]), size_(0) {
data_[0] = '\0';
}
3.2 带参构造函数
从const char*构造。这里要处理nullptr的情况——我曾经见过线上崩溃就是因为传入了空指针。
String::String(const char* str) {
if (str == nullptr) {
data_ = new char[1];
data_[0] = '\0';
size_ = 0;
return;
}
size_ = strlen(str);
data_ = new char[size_ + 1];
strcpy(data_, str);
}
3.3 拷贝构造函数
深拷贝。这是C++新手最容易犯错的地方。如果不自己实现拷贝构造,编译器会生成一个浅拷贝——两个对象指向同一块内存,析构时double free,直接崩溃。
String::String(const String& other)
: data_(new char[other.size_ + 1]), size_(other.size_) {
strcpy(data_, other.data_);
}
说白了,深拷贝就是「你有一份,我也有一份,互不干扰」。
3.4 析构函数
释放动态分配的内存。这个很简单,但别忘了。
String::~String() {
delete[] data_;
}
delete[]而不是delete。前者会调用每个元素的析构函数,后者只释放第一个元素。对于char数组来说,虽然结果一样,但语义上必须用delete[]。
4. 移动语义:现代C++的性能利器
移动构造和移动赋值是C++11引入的。它们的核心思想是:与其费力拷贝一份数据,不如把对方的资源「偷」过来,再把对方置为空。
String::String(String&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
String& String::operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_; // 释放当前资源
data_ = other.data_; // 接管对方资源
size_ = other.size_;
other.data_ = nullptr; // 对方置空
other.size_ = 0;
}
return *this;
}
为什么移动后要把原对象置空?因为原对象很快会被析构。如果不置空,析构时会把我们刚偷来的内存也释放掉——那就白忙活了。
我记得有一次优化一个字符串处理密集的服务,把拷贝全部改成移动后,性能提升了将近30%。移动语义在返回临时对象时特别有用。
5. 赋值运算符:拷贝与移动
5.1 拷贝赋值运算符
拷贝赋值要处理自赋值的情况。虽然自赋值不常见,但万一有人写了a = a呢?
String& String::operator=(const String& other) {
if (this == &other) {
return *this; // 自赋值,直接返回
}
// 先分配新内存,再释放旧内存
// 这样做的好处是:如果分配失败,当前对象状态不变(强异常安全)
char* new_data = new char[other.size_ + 1];
strcpy(new_data, other.data_);
delete[] data_;
data_ = new_data;
size_ = other.size_;
return *this;
}
6. 运算符重载:让String用起来像原生类型
6.1 下标运算符
char& String::operator[](size_t index) {
return data_[index];
}
const char& String::operator[](size_t index) const {
return data_[index];
}
这里提供了两个版本:一个用于修改,一个用于只读。const对象只能调用const版本。
6.2 相等运算符
bool String::operator==(const String& other) const {
return strcmp(data_, other.data_) == 0;
}
6.3 拼接运算符
String String::operator+(const String& other) const {
String result;
result.size_ = size_ + other.size_;
result.data_ = new char[result.size_ + 1];
strcpy(result.data_, data_);
strcat(result.data_, other.data_);
return result;
}
这里返回的是临时对象,编译器会优先使用移动语义(如果开启了C++11及以上标准)。所以不用担心性能问题。
6.4 输出运算符
std::ostream& operator<<(std::ostream& os, const String& str) {
os << str.data_;
return os;
}
7. 知识体系总览
下面这张图总结了String类的核心设计思路和各个函数之间的关系:
8. 完整代码示例
最后,把上面所有代码拼在一起,就是一个可用的String类了。你可以把它放在一个头文件中,直接用于项目。
#include <iostream>
#include <cstring>
class String {
private:
char* data_;
size_t size_;
public:
// 默认构造
String() : data_(new char[1]{'\0'}), size_(0) {}
// 带参构造
String(const char* str) {
if (str == nullptr) {
data_ = new char[1]{'\0'};
size_ = 0;
return;
}
size_ = strlen(str);
data_ = new char[size_ + 1];
strcpy(data_, str);
}
// 拷贝构造
String(const String& other)
: data_(new char[other.size_ + 1]), size_(other.size_) {
strcpy(data_, other.data_);
}
// 移动构造
String(String&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 析构
~String() {
delete[] data_;
}
// 拷贝赋值
String& operator=(const String& other) {
if (this == &other) return *this;
char* new_data = new char[other.size_ + 1];
strcpy(new_data, other.data_);
delete[] data_;
data_ = new_data;
size_ = other.size_;
return *this;
}
// 移动赋值
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
// 运算符重载
String operator+(const String& other) const {
String result;
result.size_ = size_ + other.size_;
result.data_ = new char[result.size_ + 1];
strcpy(result.data_, data_);
strcat(result.data_, other.data_);
return result;
}
bool operator==(const String& other) const {
return strcmp(data_, other.data_) == 0;
}
char& operator[](size_t index) { return data_[index]; }
const char& operator[](size_t index) const { return data_[index]; }
size_t size() const { return size_; }
const char* c_str() const { return data_; }
friend std::ostream& operator<<(std::ostream& os, const String& str) {
os << str.data_;
return os;
}
};
写到这里,这个String类已经具备了现代C++中一个资源管理类应有的所有要素。你可以在自己的项目里直接使用它,或者基于它扩展更多功能,比如substr、find、replace等。
我个人建议,初学者把这个类手敲一遍,然后写几个测试用例跑一跑。你会发现,很多以前模糊的概念——深拷贝、移动语义、异常安全——一下子就清晰了。