如果让我选一个最适合检验 C++ 基本功的练手题目,我大概率会选:手写一个简化版的 std::string。
它不需要你写复杂算法,也不涉及晦涩的模板元编程,但几乎把 C++ 里最容易“踩坑”的基础点,全都串了一遍:资源管理、拷贝控制、异常安全、移动语义、接口设计。
更重要的是,你写出来的代码,跑不跑、稳不稳、好不好维护,自己一眼就能看出来。
为什么是 string,而不是 vector 或智能指针?
std::string 的好处在于:它足够小,但不简单。
- 内部一定有 动态内存
- 一定会涉及 深拷贝 / 浅拷贝
- 一定要考虑 异常安全
- 一定绕不开 Rule of Three / Five
- 稍微写完整一点,就会自然引出 移动语义
而且你平时用得越多,越容易低估它的复杂度。 真正动手写一遍,才会发现标准库的设计并不“理所当然”。
先明确一个目标:我们要实现到什么程度?
这里不是造一个“能替代标准库”的怪物,而是一个教学级、但设计正确的 String。
目标功能:
- 管理一段以
\0 结尾的字符数组
- 支持:
- 默认构造
- 从
const char* 构造
- 拷贝构造 / 拷贝赋值
- 移动构造 / 移动赋值
- 析构
- 提供最基本的接口:
size()、c_str()
不做的事情:
但语义必须正确。
第一版:最朴素的资源管理模型
先把类的骨架搭出来:
class String {
public:
String() : data_(nullptr), size_(0) {}
explicit String(const char* s) {
size_ = std::strlen(s);
data_ = new char[size_ + 1];
std::memcpy(data_, s, size_ + 1);
}
~String() {
delete[] data_;
}
size_t size() const { return size_; }
const char* c_str() const { return data_; }
private:
char* data_;
size_t size_;
};
到这里为止,一切都看起来很合理。但这只是第一步。
此时的 String 有一个致命问题:一旦发生拷贝,就会炸。
拷贝构造:C++ 新手的第一个大坑
如果你现在这样用:
String a(“hello”);
String b = a;
编译能过,运行未定义行为。
原因很简单:默认拷贝构造是“逐成员拷贝”,data_ 被两个对象指向同一块内存,析构时必然 double free。
正确的拷贝构造必须是深拷贝:
String(const String& other) : size_(other.size_) {
if (other.data_) {
data_ = new char[size_ + 1];
std::memcpy(data_, other.data_, size_ + 1);
} else {
data_ = nullptr;
}
}
写到这里,其实已经在无形中用到了一个非常重要的设计原则:
谁申请资源,谁负责拷贝它。
拷贝赋值:真正考验基本功的地方
拷贝赋值比拷贝构造更容易写错。
一个常见但危险的版本是:
String& operator=(const String& other) {
delete[] data_;
size_ = other.size_;
data_ = new char[size_ + 1];
std::memcpy(data_, other.data_, size_ + 1);
return *this;
}
问题在于:一旦 new 抛异常,对象已经被破坏。
标准库采用的思路是 copy-and-swap,这里我们也照着做:
String& operator=(String other) {
swap(other);
return *this;
}
void swap(String& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
这段代码背后,其实同时解决了三个问题:
很多人第一次见到这种写法会觉得“绕”,但真正理解之后,反而很难再写回去。
移动语义:不是为了快,而是为了“对”
既然已经实现了拷贝控制,那再加上移动语义,几乎是顺手的事。
String(String&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
这里有两个细节非常关键:
- 必须把源对象置为可析构状态
noexcept 不是装饰品,容器会依赖它做优化
很多“看起来写了移动构造”的代码,真正用进 vector 里时,却完全没触发移动,原因往往就在这里。
这个小类到底考了你什么?
一个不到百行的 String,实际上串起了 C++ 的一整条主线:
- RAII 是否理解到位
- 拷贝和赋值是否分得清
- 异常安全有没有概念
- 移动语义是不是只停留在语法层面
- 接口设计有没有保持对象不变式
如果这些点你都能写对、讲清楚,那C++ 的地基已经很稳了。这不仅是对内存管理的深刻理解,也是对RAII、移动语义等现代C++核心特性的实践检验。
写在最后
很多人刷题、刷八股,但对自己每天在用的 string、vector 却从没真正“拆开看过”。
手写一次,不是为了造轮子,而是为了知道:标准库到底替你扛走了多少复杂度。
如果你最近在补 C++ 基础、准备面试,或者只是想验证一下自己的理解是否扎实——这个练习,非常值得。如果你想与更多开发者交流此类基础但至关重要的技术话题,欢迎到云栈社区参与讨论。