面试题1:赋值运算符函数
从返回引用、深拷贝和自赋值推进到copy-and-swap与强异常保证,完整实现资源类复制赋值。
从str3 = str2 = str1开始
先预测:如果operator=返回void,str2 = str1仍能完成,为什么作者源码还专门测试str3 = str2 = str1?因为内置赋值表达式会产生被赋值对象,用户自定义类型也应保持同样语义。表达式从右向左结合:先执行str2 = str1,其结果必须还能作为右值参与str3 = ...。
这道题表面只要求“为CMyString添加赋值运算符函数”,实际在同时检查C++接口习惯、资源所有权、自赋值和异常安全。只把源指针复制给目标对象会造成共享所有权,两个析构函数最终重复释放;只会深拷贝却先删除目标旧值,又会在分配失败时破坏对象。
的典型签名是CMyString& operator=(const CMyString& other)。返回CMyString&而不是值,可支持连续赋值且避免额外复制;参数使用常量引用,既避免传参复制,也承诺不修改源对象。
六项契约逐一拆开
返回当前对象的引用。 函数结尾应返回*this。返回引用使结果仍指向左侧对象;返回值虽然也可能让某些表达式编译,却产生额外临时对象,并弱化与内置赋值一致的左值语义。
参数是常量引用。 赋值只读取源对象,不应修改它。按值传参是copy-and-swap的一个现代变体,但那是有意让参数副本承担事务性资源;若采用const CMyString&签名,就要在函数体内显式构造临时副本。
释放目标旧资源。 str2可能已经持有字符数组,覆盖指针前必须安排旧数组的释放,否则每次赋值都会泄漏。关键不是“尽早delete”,而是“新状态准备成功后,旧资源必定有人释放”。
执行。 源和目标的文本可以相同,但m_pData不能指向同一动态数组。赋值结束后销毁或修改其中一个对象,不应影响另一个。仅复制地址的浅拷贝违反独占所有权。
处理。 直接实现通常先用if (this == &other)返回。若没有这一步,先释放m_pData后再从other.m_pData复制,读取的正是已经释放的内存。临时对象交换方案天然能安全处理自赋值,但保留快速判断可以省去一次无意义复制。
保持异常安全。 动态分配可能抛出std::bad_alloc。若目标先删除旧数组,再申请新数组,失败后旧值已经丢失。函数没有泄漏不等于状态正确;资源类还应明确失败时对象处于什么状态。
直接写法为什么只完成了一半
作者仓库的基础实现先排除自赋值,再删除旧数组、分配新数组并复制内容。它能通过普通赋值、自赋值和连续赋值三个示例测试:
CMyString& CMyString::operator=(const CMyString& other) {
if (this == &other) {
return *this;
}
delete[] m_pData;
m_pData = nullptr;
m_pData = new char[std::strlen(other.m_pData) + 1];
std::strcpy(m_pData, other.m_pData);
return *this;
}这段代码展示了返回*this、自赋值判断、释放旧资源和深拷贝,适合暴露基本考点。但把执行顺序画出来就会发现事务边界错误:delete[]已经提交了破坏性变化,后面的new却仍可能失败。若失败,m_pData保持空指针;类的复制构造、Print或其他成员若默认它总指向以'\0'结尾的数组,对象不变式就被破坏。
用临时对象交换提交新状态
更稳健的思路是:先完成所有可能失败的准备,再执行不会抛异常的提交。先调用复制构造创建temp,成功后temp拥有源内容的独立副本;然后交换temp.m_pData与this->m_pData。函数结束时,temp析构并释放目标原来的旧数组。
若构造temp时分配失败,交换尚未发生,目标对象完全不变;若复制成功,交换指针本身不分配内存,提交路径很短。这就是。
#include <cstring>
#include <utility>
class CMyString {
public:
explicit CMyString(const char* text = "")
: m_pData(clone(text)) {}
CMyString(const CMyString& other)
: m_pData(clone(other.m_pData)) {}
~CMyString() {
delete[] m_pData;
}
CMyString& operator=(const CMyString& other) {
if (this != &other) {
CMyString temp(other); // 唯一可能失败的准备阶段
swap(temp); // noexcept提交
}
return *this;
}
void swap(CMyString& other) noexcept {
std::swap(m_pData, other.m_pData);
}
const char* c_str() const noexcept {
return m_pData;
}
private:
static char* clone(const char* text) {
const char* source = text == nullptr ? "" : text;
const std::size_t size = std::strlen(source) + 1;
char* copy = new char[size];
std::memcpy(copy, source, size);
return copy;
}
char* m_pData;
};把资源准备、状态提交和旧值清理连接起来。还可以把参数改为按值传递:
CMyString& operator=(CMyString other) {
swap(other);
return *this;
}调用复制赋值时,other在进入函数前复制完成;调用移动赋值时,合适的构造函数还可能直接移动到参数。这个写法简洁,但是否统一复制与移动赋值,要结合类的移动构造、性能测量和接口设计决定。面试中先给出清晰且正确的版本,再说明变体,不要用一句“现代C++都默认生成”跳过所有权分析。
正确性来自对象不变式
这个类的核心不变式可以写成:每个存活的CMyString独占一块可由delete[]释放、以空字符结尾的数组;m_pData不为空。构造函数建立它,复制构造和赋值保持它,析构函数终止对象生命周期。
若设计允许m_pData == nullptr,所有读取成员都必须显式处理空指针,类契约会更复杂。这里让空字符串也分配一个字符,能把“空字符串”和“无有效资源”分开。无论选择哪种不变式,都要让每个特殊成员函数保持一致。
赋值不是构造。复制构造面对的是尚未拥有资源的新对象;复制赋值面对的是已经有有效状态的对象,必须同时处理旧资源和新资源。面试中把二者混成“复制一下指针”通常会漏掉释放、别名或异常路径。
四组测试覆盖四类失效
作者源码给出普通赋值、自赋值和连续赋值三组测试。为了验证资源语义,还应补“源对象销毁后目标仍有效”;为了验证强异常保证,还应通过可注入分配器、测试替身或受控复制构造故障模拟失败。不要在真实new上碰运气等内存耗尽。
#include <cassert>
#include <string>
void test_normal_and_independent_copy() {
CMyString target("old");
{
CMyString source("new");
target = source;
assert(std::string(target.c_str()) == "new");
}
assert(std::string(target.c_str()) == "new");
}
void test_self_assignment() {
CMyString value("stable");
value = value;
assert(std::string(value.c_str()) == "stable");
}
void test_chained_assignment() {
CMyString first("value");
CMyString second;
CMyString third;
CMyString& result = (third = second = first);
assert(&result == &third);
assert(std::string(second.c_str()) == "value");
assert(std::string(third.c_str()) == "value");
}普通赋值验证内容和旧资源替换;作用域结束后的再次读取验证独立所有权;自赋值验证别名安全;连续赋值验证返回类型和结合顺序。异常注入测试应先保存目标旧值,让复制阶段抛出,再确认旧值未变、资源计数平衡且对象仍可继续赋值。
复杂度与现代C++边界
设源字符串长度为n。深拷贝需要分配n + 1字节并复制n + 1个字符,时间复杂度为O(n),新资源的额外空间为O(n)。交换本身是O(1)。自赋值快速判断可以把显式x = x从O(n)降到O(1),但真实程序更应优先保证语义正确。
生产代码通常优先使用std::string、std::vector、std::unique_ptr等资源管理类型,让成员自己遵守RAII,从而采用Rule of Zero,避免手写析构、复制和赋值。面试题故意使用裸字符数组,是为了观察资源类的特殊成员函数,不是建议业务代码重新实现字符串。
若类手写析构、复制构造和复制赋值,就要检查Rule of Three;在C++11以后,若还需要高效转移资源,则同时考虑移动构造和移动赋值,形成Rule of Five。copy-and-swap并非所有类型的唯一答案:巨大缓冲区复用、noexcept要求和性能热点可能需要更细的实现,但任何优化都不能破坏所有权和异常契约。
本章回顾
- 复制赋值运算符返回
*this的左值引用,才能保持连续赋值语义。 - 参数通常使用常量引用;按值参数是copy-and-swap的一种有意设计。
- 资源对象要深拷贝,源和目标不能共享同一独占缓冲区。
- 自赋值会让源和目标别名,先释放再读取会访问已释放内存。
- 先删除旧资源再
new,即使无泄漏,也不满足强异常保证。 - 临时对象先准备新状态,交换负责提交,临时对象析构负责释放旧状态。
- 测试至少覆盖普通、自赋值、连续赋值、独立所有权和分配失败。
- 真实工程优先使用RAII成员和Rule of Zero,手写资源类时再完整维护特殊成员。