一、String类需要解决什么问题
假设自己设计一个字符串类:
class String {
private:
char *data_;
};
如果创建:
String str("hello");
希望内部结构类似:
String对象
┌──────────────┐
│ data_ │──────┐
└──────────────┘ │
↓
堆内存
┌─────────────┐
│ h e l l o \\0│
└─────────────┘
因为字符串长度运行时才能确定,所以可以:
new char[len + 1];
动态申请空间。
但是只写构造和析构还不够。
例如:
String s1("hello");
String s2 = s1;
这里会发生:
拷贝构造
如果直接进行默认浅拷贝:
s1.data_ ─────┐
↓
"hello"
↑
s2.data_ ─────┘
两个对象指向同一块内存。
当两个对象析构:
delete[] s1.data_;
delete[] s2.data_;
同一块内存就可能被释放两次。
因此必须:
深拷贝
让两个对象拥有独立资源。
而如果源对象马上就不用了:
String s2 = std::move(s1);
就没必要再复制整个字符串,可以直接:
把s1的资源交给s2
这就是移动构造。
所以这道题实际上是在考:
动态资源管理
↓
拷贝构造
↓
深拷贝
↓
移动构造
↓
资源所有权转移
二、先实现普通构造和析构
先写最基础版本:
#include <cstring>
#include <iostream>
class String {
private:
char *data_;
size_t size_;
public:
// 普通构造
String(const char *str = "")
: data_(nullptr), size_(0) {
if (str == nullptr) {
str = "";
}
size_ = std::strlen(str);
data_ = new char[size_ + 1];
std::memcpy(data_, str, size_ + 1);
std::cout << "普通构造" << std::endl;
}
// 析构函数
~String() {
delete[] data_;
std::cout << "析构函数" << std::endl;
}
};
例如:
String s("hello");
执行:
strlen("hello")
↓
size_ = 5
↓
new char[6]
↓
复制 hello\\0
内存:
栈:
s
┌──────────────┐
│ data_ │─────┐
│ size_ = 5 │ │
└──────────────┘ │
↓
堆:
┌─────────────┐
│ h e l l o \\0│
└─────────────┘
当:
s
离开作用域:
~String()
↓
delete[] data_
自动释放资源。
这其实也是:
RAII
的典型应用。
三、实现拷贝构造:必须进行深拷贝
现在:
String s1("hello");
String s2 = s1;
调用的是:
String(const String &other);
实现:
String(const String &other)
: data_(nullptr), size_(other.size_) {
data_ = new char[size_ + 1];
std::memcpy(data_,
other.data_,
size_ + 1);
std::cout << "拷贝构造" << std::endl;
}
注意这里不能:
data_ = other.data_;
因为这样只是:
复制指针地址
而不是复制字符串。
错误的浅拷贝:
s1
┌────────────┐
│ data_ │─────┐
└────────────┘ │
↓
"hello"
↑
┌────────────┐ │
│ data_ │─────┘
└────────────┘
s2
正确的深拷贝:
s1
┌────────────┐
│ data_ │────────→ "hello"
└────────────┘
s2
┌────────────┐
│ data_ │────────→ "hello"
└────────────┘
虽然:
字符串内容相同
但是:
内存地址不同
因此:
s2[0] = 'H';
不会影响:
s1
这就是:
深拷贝
Deep Copy
所以面试问:
为什么 String 类必须自己实现拷贝构造?
可以回答:
因为 String 内部管理动态内存,如果使用编译器默认拷贝构造,只会复制指针地址,两个对象会共享同一块内存,析构时可能产生重复释放。因此需要实现深拷贝,为新对象重新申请内存并复制字符串内容。
四、实现移动构造:直接接管资源
现在:
String s1("hello");
String s2 = std::move(s1);
这里希望调用:
String(String &&other);
移动构造可以写成:
String(String &&other) noexcept
: data_(other.data_),
size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
std::cout << "移动构造" << std::endl;
}
移动之前:
s1
│
└────→ "hello"
执行:
data_ = other.data_;
以后:
s1 ─────┐
↓
"hello"
↑
s2 ─────┘
如果停在这里:
两个对象还是指向同一资源
所以必须:
other.data_ = nullptr;
other.size_ = 0;
最终:
s1
↓
nullptr
s2
↓
"hello"
这样 s1 析构时:
delete[] nullptr;
是安全的。
真正的 "hello":
已经交给s2管理
所以:
拷贝构造
↓
重新申请内存
↓
复制数据
而:
移动构造
↓
不复制字符串
↓
直接接管指针
这就是移动语义性能更高的原因。
移动构造建议写:
noexcept
即:
String(String &&other) noexcept;
因为像:
std::vector<String>
扩容时需要把旧元素搬到新的内存。
如果移动构造明确:
不会抛异常
标准容器就更容易安全地优先使用移动构造,而不是拷贝构造。
五、完整实现:Rule of Five
既然 String 自己管理:
动态内存
那么除了:
析构函数
拷贝构造
移动构造
还应该考虑:
拷贝赋值
移动赋值
这就是:
Rule of Five
五法则
一个适合面试手写的完整版本:
#include <cstring>
#include <iostream>
#include <utility>
class String {
private:
char *data_;
size_t size_;
public:
// 1. 普通构造
String(const char *str = "")
: data_(nullptr), size_(0) {
if (str == nullptr) {
str = "";
}
size_ = std::strlen(str);
data_ = new char[size_ + 1];
std::memcpy(data_,
str,
size_ + 1);
std::cout << "普通构造" << std::endl;
}
// 2. 析构函数
~String() {
delete[] data_;
std::cout << "析构函数" << std::endl;
}
// 3. 拷贝构造
String(const String &other)
: data_(nullptr),
size_(other.size_) {
data_ = new char[size_ + 1];
std::memcpy(data_,
other.data_,
size_ + 1);
std::cout << "拷贝构造" << std::endl;
}
// 4. 移动构造
String(String &&other) noexcept
: data_(other.data_),
size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
std::cout << "移动构造" << std::endl;
}
// 5. 拷贝赋值
String &operator=(const String &other) {
if (this != &other) {
// 先申请新空间
char *newData =
new char[other.size_ + 1];
std::memcpy(newData,
other.data_,
other.size_ + 1);
// 再释放自己原来的资源
delete[] data_;
data_ = newData;
size_ = other.size_;
}
std::cout << "拷贝赋值" << std::endl;
return *this;
}
// 6. 移动赋值
String &operator=(String &&other) noexcept {
if (this != &other) {
// 先释放自己原来的资源
delete[] data_;
// 接管other的资源
data_ = other.data_;
size_ = other.size_;
// other不再拥有资源
other.data_ = nullptr;
other.size_ = 0;
}
std::cout << "移动赋值" << std::endl;
return *this;
}
// 获取字符串
const char *c_str() const {
return data_ ? data_ : "";
}
// 获取长度
size_t size() const {
return size_;
}
// 下标访问
char &operator[](size_t index) {
return data_[index];
}
const char &operator[](size_t index) const {
return data_[index];
}
};
测试:
int main() {
String s1("hello");
// 拷贝构造
String s2 = s1;
// 移动构造
String s3 = std::move(s1);
String s4("world");
// 拷贝赋值
s4 = s2;
String s5("C++");
// 移动赋值
s5 = std::move(s3);
std::cout << "s2 = "
<< s2.c_str()
<< std::endl;
std::cout << "s5 = "
<< s5.c_str()
<< std::endl;
return 0;
}
整个区别可以直接记成:
String s2 = s1;
调用:
拷贝构造
过程:
s1 → "hello"
重新申请内存
↓
复制hello
↓
s2 → "hello"
而:
String s3 = std::move(s1);
调用:
移动构造
过程:
s1 → "hello"
↓
把指针交出去
↓
s1 → nullptr
s3 → "hello"
需要特别区分:
String s2 = s1;
这是:
拷贝构造
因为:
s2原来不存在
现在正在创建
而:
String s2("abc");
s2 = s1;
这是:
拷贝赋值
因为:
s2已经存在
需要先处理自己原来的资源,再复制 s1。
移动同样如此:
String s2 = std::move(s1);
是:
移动构造
而:
String s2("abc");
s2 = std::move(s1);
是:
移动赋值
所以面试中可以这样总结:
自定义 String 类内部通过 char* 管理动态内存。普通构造根据字符串长度申请内存;析构函数负责释放资源;拷贝构造需要进行深拷贝,重新申请一块独立内存并复制字符串;移动构造则直接接管源对象的指针,然后把源对象指针置空,从而避免大字符串复制。因为类自己管理资源,还需要同时考虑拷贝赋值和移动赋值,也就是 Rule of Five。
如果面试官继续问:
拷贝构造和移动构造最大的区别是什么?
可以回答:
拷贝构造不会破坏源对象,它需要创建一份独立资源;移动构造用于资源可以被转移的对象,直接接管源对象资源,并把源对象调整为合法但不再拥有原资源的状态,因此对于动态资源较大的对象通常效率更高。
如果继续问:
为什么移动以后要把 other.data_ 置空?
可以回答:
因为资源已经转移给新对象。如果源对象仍然保留原指针,那么两个对象析构时都会 delete[] 同一块内存,造成 Double Free,所以移动后需要让源对象不再拥有这块资源。
如果继续问:
为什么移动构造写 noexcept?
可以回答:
因为 STL 容器在扩容搬迁元素时需要考虑异常安全。如果移动构造声明为 noexcept,容器可以更放心地使用移动构造,从而避免成本较高的深拷贝。
整个 String 类的核心知识链就是:
char*
↓
动态内存
↓
析构释放
↓
默认浅拷贝有问题
↓
拷贝构造实现深拷贝
↓
移动构造转移资源
↓
拷贝赋值
↓
移动赋值
↓
Rule of Five
如果这是现场笔试,时间比较紧,至少要把下面三个函数写正确:
~String();
String(const String &other);
String(String &&other) noexcept;
也就是:
析构
+
深拷贝
+
移动资源
这三个基本就是这道题最核心的考点。
0voice · GitHub
网硕互联帮助中心


评论前必须登录!
注册