✨ C++11 进阶系列 · 类和对象篇
💡 从传统的深拷贝,到高效的资源转移;从编译器自动生成函数,到主动约束类的行为。
一篇文章,带你理解 C++11 究竟为类和对象带来了哪些改变!
🌟 写在前面
在学习 C++ 类和对象时,我们一定接触过这样几个熟悉的成员函数:
构造函数、析构函数、拷贝构造函数、赋值运算符重载......
它们构成了 C++ 对象生命周期管理的基础。
但随着学习的深入,我们会逐渐发现一些问题:
-
🤔 为什么有时候明明只是想把资源交给另一个对象,却还要重新拷贝一遍?
-
🤔 为什么使用了
std::move,程序却依然调用拷贝构造? -
🤔 为什么自己定义了拷贝构造函数,编译器就不再自动提供某些函数?
-
🤔 如果一个类不允许拷贝,或者一个虚函数不允许继续重写,该怎么限制?
这些问题,在 C++11 中都有相应的解决方案。
C++11 不仅引入了移动语义,还让程序员能够更加明确地控制类的行为。
🎯 本文学习路线
| 核心内容 | 我们要解决的问题 |
|---|---|
| 📦 类的八种特殊成员函数 | C++11 究竟新增了什么? |
| ⚡ 移动构造与移动赋值 | 为什么移动比深拷贝更高效? |
🔄 std::move 与成员对象 |
为什么使用了 move 却没有真正移动? |
🛠️ default |
如何显式要求编译器生成默认实现? |
🚫 delete |
如何禁止不希望发生的操作? |
🔒 final 与 override |
如何让继承和多态更加安全? |
接下来,我们从一个最经典的 String 类开始。
📦 一、类的八种特殊成员函数
1.1 从六种到八种
在传统 C++ 类和对象的学习中,我们通常将以下六种函数称为特殊成员函数。
| 特殊成员函数 | 主要作用 |
|---|---|
| 构造函数 | 初始化对象 |
| 析构函数 | 对象销毁时完成资源清理 |
| 拷贝构造函数 | 使用已有对象初始化新对象 |
| 拷贝赋值运算符重载 | 将一个对象的内容赋值给另一个已有对象 |
| 普通取地址运算符重载 | 获取非 const 对象的地址 |
| const 取地址运算符重载 | 获取 const 对象的地址 |
这里采用的是常见的 C++ 教学分类。
到了 C++11,又增加了两种:
① 移动构造函数
ClassName(ClassName&& other);
② 移动赋值运算符重载
ClassName& operator=(ClassName&& other);
于是,我们便有了教学中常说的八种特殊成员函数。
不过,需要注意:
📌 八种特殊成员函数,并不意味着编译器一定会为所有类自动生成这八种函数。
尤其是移动构造和移动赋值,它们的隐式生成受到一定条件的限制。
为什么 C++11 要专门增加移动操作呢?
这就要从深拷贝存在的问题说起。
⚡ 二、为什么 C++11 需要移动语义?
2.1 从一个 String 类开始
假设我们自己实现了一个简化版的 String 类,内部通过动态数组保存字符串。
class String
{
private:
char* _str;
size_t _size;
size_t _capacity;
};
其中:
-
_str:指向动态申请的字符串空间。 -
_size:记录字符串长度。 -
_capacity:记录字符串容量。
现在,我们创建一个字符串对象:
String s1("hello");
假设其内部的资源情况如下:
s1 对象
┌───────────┐
│ _str │──────────┐
│ _size = 5 │ │
│ _capacity │ ▼
└───────────┘ ┌────────────┐
│ h e l l o \0 │
└────────────┘
堆空间
此时,如果我们再执行:
String s2(s1);
就需要调用拷贝构造函数。
由于 String 内部管理着动态申请的内存,为了避免两个对象错误地管理同一块堆空间,我们通常需要进行深拷贝。
s1 s2
┌───────────┐ ┌───────────┐
│ _str │ │ _str │
└─────┬─────┘ └─────┬─────┘
│ │
▼ ▼
┌───────────┐ ┌───────────┐
│ "hello" │ │ "hello" │
└───────────┘ └───────────┘
堆空间 A 堆空间 B
看起来好像没什么问题,但仔细想一想:
深拷贝通常意味着重新申请内存,再将原有数据复制过去。
如果原对象管理的是一块非常大的内存,这个过程就可能产生比较明显的时间和空间开销。
2.2 如果不需要原对象了呢?
现在考虑另一种情况。
假设 s1 是一个即将不再使用的对象,我们只希望 s2 接管它内部的字符串资源。
那么,还有必要重新申请空间、复制整个字符串吗?
显然没有。
我们完全可以直接转移资源:
移动之前:
s1._str ──────────> ["hello"]
移动之后:
s1._str = nullptr
s2._str ──────────> ["hello"]
这样就不需要重新申请字符串内存,也不需要复制字符串内容。
这就是移动语义的核心思想:
💡 拷贝是复制一份资源,移动则是转移已有资源的所有权。
对于管理动态内存、文件句柄等资源的类,移动语义尤其有价值。
当然,并不是所有对象的移动都会带来明显的性能提升。例如对于 int 这样的简单类型,所谓移动通常也只是复制数值。
🚀 三、移动构造函数:把资源直接交给新对象
3.1 移动构造的基本形式
传统的拷贝构造函数:
String(const String& other);
C++11 新增的移动构造函数:
String(String&& other);
它们最明显的区别是参数类型。
| 拷贝构造 | 移动构造 |
|---|---|
const String& |
String&& |
| 接收左值,也可以绑定部分右值 | 接收可绑定到该类型右值引用的右值 |
| 通常复制资源 | 可以转移资源 |
这里的 && 就是 C++11 引入的右值引用。
右值引用使我们能够针对右值对象提供专门的移动操作。
3.2 完整实现一个支持移动语义的 String
下面给出一个可以在 C++11 环境下编译运行的简化版本。
为了便于观察,我们在拷贝构造、移动构造和赋值运算符中增加输出信息。
#include <iostream>
#include <cstring>
#include <cstddef>
#include <utility>
class String
{
public:
// 普通构造函数
explicit String(const char* str = "")
{
if (str == nullptr)
str = "";
_size = std::strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
std::memcpy(_str, str, _size + 1);
}
// 拷贝构造函数
String(const String& other)
: String(other.c_str())
{
std::cout << "拷贝构造" << std::endl;
}
// 拷贝赋值运算符
String& operator=(const String& other)
{
if (this != &other)
{
String tmp(other);
swap(tmp);
}
std::cout << "拷贝赋值" << std::endl;
return *this;
}
// 移动构造函数
String(String&& other) noexcept
: _str(other._str),
_size(other._size),
_capacity(other._capacity)
{
other._str = nullptr;
other._size = 0;
other._capacity = 0;
std::cout << "移动构造" << std::endl;
}
// 移动赋值运算符
String& operator=(String&& other) noexcept
{
if (this != &other)
{
delete[] _str;
_str = other._str;
_size = other._size;
_capacity = other._capacity;
other._str = nullptr;
other._size = 0;
other._capacity = 0;
}
std::cout << "移动赋值" << std::endl;
return *this;
}
// 交换内部资源
void swap(String& other) noexcept
{
using std::swap;
swap(_str, other._str);
swap(_size, other._size);
swap(_capacity, other._capacity);
}
// 析构函数
~String()
{
delete[] _str;
}
const char* c_str() const noexcept
{
return _str ? _str : "";
}
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
int main()
{
String s1("hello");
String s2(s1); // 拷贝构造
String s3(std::move(s1)); // 移动构造
String s4("world");
s4 = s2; // 拷贝赋值
s4 = std::move(s3); // 移动赋值
std::cout << s4.c_str() << std::endl;
return 0;
}
这段代码演示了四种不同的操作。
需要说明:拷贝赋值采用了临时对象加资源交换的实现方式,因此执行拷贝赋值时,也会看到一次拷贝构造的输出。
接下来我们重点拆解移动构造。
3.3 移动构造究竟做了什么?
先看核心代码:
String(String&& other) noexcept
: _str(other._str),
_size(other._size),
_capacity(other._capacity)
{
other._str = nullptr;
other._size = 0;
other._capacity = 0;
}
假设:
String s1("hello");
String s2(std::move(s1));
移动构造的过程可以分成两步。
第一步:接管原对象的资源。
_str = other._str;
_size = other._size;
_capacity = other._capacity;
注意,这里只是将原对象内部保存的指针、长度和容量交给新对象。
并没有重新申请字符串内存,也没有重新复制字符串内容。
第二步:让原对象放弃对资源的所有权。
other._str = nullptr;
other._size = 0;
other._capacity = 0;
为什么必须这样做?
因为两个对象都拥有析构函数。
如果移动后,s1 和 s2 内部仍然保存同一个指针,那么两个对象析构时就可能对同一块内存执行两次 delete[]。
这会造成未定义行为。
所以对于这个 String 类,我们让移动后的源对象进入空状态。
这样一来:
移动前:
s1._str ─────────> 字符串内存
移动后:
s1._str = nullptr
s2._str ─────────> 字符串内存
当 s1 析构时:
delete[] nullptr;
这是安全的。
当 s2 析构时,才会真正释放原来的字符串内存。
⚠️ 注意:移动后的对象不一定必须为空。
C++ 要求的是移动后的对象依然满足对应类型的有效状态要求。至于它是否为空,取决于该类型的移动操作如何实现。
我们这个
String类选择将源对象置为空状态,是为了让资源所有权更加清晰。
3.4 为什么移动函数经常使用 noexcept?
观察移动构造的声明:
String(String&& other) noexcept;
其中的 noexcept 表示该函数承诺不会让异常传播到函数外部。
对于我们实现的移动构造来说,它只进行了指针和整数的赋值,不涉及动态内存申请,因此可以安全地声明为 noexcept。
更重要的是,标准库中的部分容器会参考类型的移动操作是否可能抛出异常。
例如,std::vector 扩容时需要将旧空间的元素转移到新空间。
对于某些既可以拷贝又可以移动的类型,为了维持异常安全保证,容器可能优先选择拷贝而不是可能抛异常的移动。
因此,对于确实不会抛出异常的移动操作,使用 noexcept 是一个很好的习惯。
🔄 四、移动赋值:已有对象如何接管新资源?
4.1 移动构造和移动赋值的区别
移动构造:
String s1("hello");
String s2(std::move(s1));
这里 s2 原本不存在,正在通过移动构造函数创建。
移动赋值:
String s1("hello");
String s2("world");
s2 = std::move(s1);
这里 s2 已经存在,因此调用的是移动赋值运算符。
二者最核心的区别在于:
移动构造负责初始化新对象,移动赋值负责修改已有对象。
4.2 移动赋值需要注意什么?
移动赋值比移动构造多了一个需要考虑的问题。
由于目标对象已经存在,所以它可能已经拥有一块资源。
例如:
移动赋值前:
s1._str ──────> ["hello"]
s2._str ──────> ["world"]
现在执行:
s2 = std::move(s1);
如果我们直接让:
s2._str = s1._str;
那么原来 s2 指向的 "world" 内存就可能失去管理者,从而发生内存泄漏。
因此,在直接接管新资源之前,必须先正确处理目标对象原来持有的资源。
我们的实现如下:
String& operator=(String&& other) noexcept
{
if (this != &other)
{
delete[] _str;
_str = other._str;
_size = other._size;
_capacity = other._capacity;
other._str = nullptr;
other._size = 0;
other._capacity = 0;
}
return *this;
}
其中:
if (this != &other)
用于避免对象对自己执行移动赋值。
整个过程:
① 释放 s2 原来的资源
② s2 接管 s1 的资源
③ 将 s1 置为空状态
最终:
s1._str = nullptr
s2._str ──────> ["hello"]
除了这种方式,也可以通过 swap 交换内部资源,让源对象接管目标对象原来的资源,等待源对象后续析构。
两种方式都可以实现合法的移动赋值,但移动后源对象的状态可能不同。
🧠 五、std::move 真的会移动资源吗?
这部分是理解 C++11 移动语义的关键。
5.1 std::move 的作用
来看:
String s1("hello");
String s2(std::move(s1));
我们很容易产生一种误解:
std::move(s1) 执行了移动操作。
实际上, std::move****本身并不会转移任何资源。
它的主要作用是将表达式转换为相应的右值表达式,使其能够参与移动构造或移动赋值的重载决议。
对于普通非 const 对象,可以这样理解:
std::move(s1)
│
▼
转换为右值表达式
│
▼
进行函数重载决议
│
▼
选中 String(String&&)
│
▼
移动构造真正转移资源
所以:
std::move(s1);
单独执行这条语句,通常并不会改变 s1 的资源状态。
真正发生移动,需要后续操作调用合适的移动函数。
5.2 使用了 std::move,就一定会调用移动构造吗?
答案是:不一定。
例如:
class Test
{
public:
Test() = default;
Test(const Test&)
{
std::cout << "拷贝构造" << std::endl;
}
};
当前 Test 类没有移动构造函数。
执行:
Test t1;
Test t2(std::move(t1));
结果依然调用拷贝构造函数。
为什么?
因为 std::move(t1) 虽然是右值表达式,但类中没有可用的移动构造函数。
而拷贝构造:
Test(const Test&);
可以通过 const 左值引用绑定该右值表达式。
因此,最终仍然调用拷贝构造。
这说明:
🎯 std::move 只是提供进行移动操作的条件,并不能保证一定发生移动。
另外,还有一个容易忽略的细节:
const String s1("hello");
String s2(std::move(s1));
由于 s1 是 const 对象,std::move(s1) 得到的仍然带有 const 限定的右值表达式。
它不能绑定到普通的:
String(String&&);
因此,在当前这个 String 类中,这种写法会调用拷贝构造。
👨💻 六、成员对象的移动:为什么 Person 没有真正移动?
前面我们已经实现了支持移动语义的 String 类。
接下来,再设计一个 Person 类。
6.1 先看没有移动构造的 Person
class Person
{
public:
Person(const char* name, int age)
: _name(name), _age(age)
{}
// 拷贝构造函数
Person(const Person& p)
: _name(p._name), _age(p._age)
{
std::cout << "Person 拷贝构造" << std::endl;
}
// 拷贝赋值运算符
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
private:
String _name;
int _age;
};
现在执行:
Person p1("大王", 18);
Person p2 = std::move(p1);
大家可以思考一下:
这里究竟会发生移动构造,还是拷贝构造?
答案是:拷贝构造。
为什么?
因为 Person 已经显式声明了拷贝构造函数和拷贝赋值运算符,所以编译器不会再为它隐式生成移动构造函数。
而 Person 现有的拷贝构造可以接收这个右值表达式。
因此最终调用:
Person(const Person& p);
进入拷贝构造之后:
_name(p._name)
又会调用 String 的拷贝构造。
整个过程:
Person p2 = std::move(p1)
│
▼
Person 没有移动构造
│
▼
调用 Person 拷贝构造
│
▼
初始化 _name
│
▼
调用 String 拷贝构造
│
▼
发生字符串深拷贝
这里有一个非常值得记住的结论:
💡 成员变量支持移动,不代表包含它的类就一定能够自动移动。
6.2 如何让 Person 真正支持移动?
解决方法很简单:给 Person 添加移动构造和移动赋值。
下面是支持两种操作的完整版本。
class Person
{
public:
Person(const char* name, int age)
: _name(name), _age(age)
{}
// 拷贝构造
Person(const Person& p)
: _name(p._name), _age(p._age)
{
std::cout << "Person 拷贝构造" << std::endl;
}
// 拷贝赋值
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
// 移动构造
Person(Person&& p) noexcept
: _name(std::move(p._name)),
_age(p._age)
{
std::cout << "Person 移动构造" << std::endl;
}
// 移动赋值
Person& operator=(Person&& p) noexcept
{
if (this != &p)
{
_name = std::move(p._name);
_age = p._age;
}
return *this;
}
private:
String _name;
int _age;
};
现在重新执行:
Person p1("大王", 18);
Person p2 = std::move(p1);
这一次就能够真正调用 Person 的移动构造。
然后在初始化 _name 成员时:
_name(std::move(p._name))
进一步调用 String 的移动构造。
于是:
Person 移动构造
│
▼
移动 String 类型的 _name
│
▼
String 移动构造
│
▼
转移字符串资源
这里还涉及一个经常被忽略的知识点:
即使函数参数类型为右值引用,只要它有名字,直接使用这个名字时,它就是左值表达式。
例如:
Person(Person&& p)
虽然 p 的类型是右值引用,但是在函数内部:
p._name
依然是左值表达式。
所以需要:
std::move(p._name)
才能将它作为右值表达式传递给 String 的移动构造。
这也是为什么移动构造中经常会出现 std::move。
6.3 编译器自动生成移动函数的条件
C++11 并不会无条件地为类生成移动构造和移动赋值。
简单来说,当类显式声明了拷贝构造、拷贝赋值、移动操作或析构函数等相关特殊成员函数时,编译器是否隐式生成移动函数就会受到影响。
对于我们前面的 Person:
Person(const Person& p);
由于已经显式声明了拷贝构造,因此编译器不会再自动隐式生成移动构造。
但是,如果类本身不需要手动管理资源,而成员又支持正常的拷贝和移动,那么很多时候可以直接使用编译器提供的默认实现。
例如:
class Person
{
public:
Person() = default;
Person(const Person&) = default;
Person& operator=(const Person&) = default;
Person(Person&&) = default;
Person& operator=(Person&&) = default;
private:
String _name;
int _age = 0;
};
这里显式使用 = default 请求编译器为对应函数提供默认实现。
至于默认函数最终能否合法生成,仍然取决于成员类型和基类等条件。
🛠️ 七、default:让编译器帮我们生成默认实现
7.1 为什么需要 default?
先看一个例子:
class Person
{
public:
Person(const Person& p)
{
// 拷贝构造
}
};
现在:
Person p;
会发生什么?
答案是:编译失败。
因为我们已经显式声明了拷贝构造函数。
编译器不会再为这个类隐式声明无参默认构造函数。
因此,如果还希望使用:
Person p;
就需要主动提供默认构造函数。
传统写法:
Person()
{}
C++11 允许我们直接写成:
Person() = default;
完整示例:
class Person
{
public:
Person() = default;
Person(const Person& p)
{
// 拷贝构造
}
};
此时:
Person p;
就可以正常创建对象。
7.2 default 的本质
= default 的核心含义是:
明确要求编译器使用该特殊成员函数的默认实现。
它不只是为了省略一个空函数体。
例如:
class Person
{
public:
Person() = default;
Person(const Person&) = default;
Person& operator=(const Person&) = default;
Person(Person&&) = default;
Person& operator=(Person&&) = default;
~Person() = default;
};
这些特殊成员函数,都可以根据实际需要显式要求默认实现。
但要注意,default 并不能强行生成一个不合法的函数。例如某个成员本身不可拷贝,编译器默认生成的拷贝操作也可能被定义为删除函数。
7.3 顺便补充:C++11 类内成员初始化
C++11 还允许为非静态数据成员直接提供默认成员初始化值。
例如:
#include <string>
class Person
{
private:
std::string _name = "匿名";
int _age = 18;
public:
Person() = default;
Person(const std::string& name, int age)
: _name(name), _age(age)
{}
};
这里:
Person p1;
使用类内提供的默认成员初始化值。
而:
Person p2("张三", 20);
使用构造函数初始化列表中指定的值。
需要注意的是,类内成员初始化提供的是默认值,并不是先按默认值初始化一次,然后再由初始化列表重新赋值。
对于同一个成员,如果构造函数初始化列表已经显式指定了初始化方式,就不会再使用对应的默认成员初始化值。
🚫 八、delete:明确禁止某些函数
8.1 为什么需要禁止拷贝?
有些类在设计时,不希望对象被复制。
例如,一个独占管理某项资源的类,如果允许随意拷贝,可能造成多个对象错误地管理同一资源。
在传统 C++ 中,我们可以把拷贝构造和拷贝赋值声明为 private,并且不提供定义。
class NonCopyable
{
private:
NonCopyable(const NonCopyable&);
NonCopyable& operator=(const NonCopyable&);
public:
NonCopyable() {}
};
但是这种方式不够直观。
C++11 提供了 = delete,允许我们直接声明一个函数不能被使用。
8.2 delete 的基本使用
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
此时:
NonCopyable a;
可以正常创建对象。
但是:
NonCopyable b(a);
会发生编译错误。
因为拷贝构造已经被明确删除。
同样:
NonCopyable a;
NonCopyable b;
b = a;
也会发生编译错误。
因为拷贝赋值运算符已经被删除。
这里需要注意,= delete 并不是让函数在运行时无法执行,而是在编译阶段禁止使用该函数。
8.3 default 与 delete 的区别
| 关键字 | 含义 | 使用目的 |
|---|---|---|
= default |
请求编译器提供默认实现 | 保留需要的默认行为 |
= delete |
明确禁止函数被使用 | 限制不允许的操作 |
一个负责开放默认行为,一个负责禁止特定行为。
需要补充的是:
= default 只能用于允许显式默认化的特殊成员函数,而 = delete 还可以用于普通函数及函数重载。
🔒 九、final:禁止继承与虚函数重写
C++11 除了增强资源管理能力,还为继承和多态提供了更加明确的约束手段。
首先来看 final。
9.1 使用 final 禁止继承
假设我们定义一个类:
class Person final
{
public:
void Print()
{
std::cout << "Person" << std::endl;
}
};
这里:
class Person final
表示 Person 不允许继续被继承。
因此:
class Student : public Person
{
};
会直接产生编译错误。
因为 Person 已经被 final 修饰。
这种方式适合用于设计上不允许继续派生的类。
9.2 使用 final 禁止虚函数继续重写
除了修饰类,final 还可以用于虚函数。
例如:
class Base
{
public:
virtual void Print()
{
std::cout << "Base" << std::endl;
}
virtual ~Base() = default;
};
class Derived : public Base
{
public:
void Print() final
{
std::cout << "Derived" << std::endl;
}
};
这里 Derived 已经重写了基类的虚函数 Print()。
但是,它同时使用:
void Print() final;
表示不允许后续派生类继续重写这个虚函数。
因此:
class Child : public Derived
{
public:
void Print()
{
std::cout << "Child" << std::endl;
}
};
会产生编译错误。
另外需要注意:final 只能用于虚函数,不能随意修饰一个普通非虚成员函数。
🎯 十、override:让编译器帮我们检查虚函数重写
10.1 虚函数重写为什么容易出错?
在 C++ 多态中,派生类想要重写基类的虚函数,必须满足相应的函数签名要求。
但有时,我们可能只是写错了一个细节。
例如:
class Base
{
public:
virtual void Print() const
{
std::cout << "Base" << std::endl;
}
};
class Derived : public Base
{
public:
void Print()
{
std::cout << "Derived" << std::endl;
}
};
我们本来想让 Derived::Print() 重写基类中的虚函数。
但仔细观察:
基类:
virtual void Print() const;
派生类:
void Print();
二者有什么区别?
派生类的函数缺少 const****限定符。
因此,这两个函数并不构成虚函数重写。
在这种情况下,派生类的 Print() 只是声明了另一个成员函数,并隐藏了基类中同名函数的普通名称查找结果。
如果不仔细检查,这种问题很容易被忽略。
10.2 override 解决了什么问题?
C++11 引入 override,用来明确告诉编译器:
我写这个函数,就是要重写基类的虚函数,请帮我检查。
例如:
class Derived : public Base
{
public:
void Print() override
{
std::cout << "Derived" << std::endl;
}
};
这里会直接编译失败。
因为编译器发现,这个函数并没有成功重写基类中的虚函数。
修改为:
class Derived : public Base
{
public:
void Print() const override
{
std::cout << "Derived" << std::endl;
}
};
才能正确编译。
所以,override 最重要的作用不是改变虚函数的运行机制,而是增加编译阶段的检查。
这能够有效避免函数签名错误导致的重写失败。
10.3 override 和 final 可以一起使用吗?
当然可以。
class Base
{
public:
virtual void Print() const
{
std::cout << "Base" << std::endl;
}
virtual ~Base() = default;
};
class Derived : public Base
{
public:
void Print() const override final
{
std::cout << "Derived" << std::endl;
}
};
这里:
void Print() const override final;
同时表达了两个意思:
-
override:这个函数必须正确重写基类虚函数。 -
final:后续派生类不能继续重写这个虚函数。
二者各司其职,并不冲突。
10.4 final 与 override 对比
| 关键字 | 核心作用 | 使用场景 |
|---|---|---|
final |
禁止进一步继承或重写 | 类、虚函数 |
override |
编译时检查虚函数重写是否正确 | 派生类重写虚函数 |
可以这样记忆:
🔒 final:到我这里就结束了。
✅ override:我确实是在重写,请编译器检查。
🏆 十一、面试与复习:这些知识点一定要记住
到这里,我们已经把 C++11 类的新功能串联起来了。
最后,用几个常见问题检查一下自己是不是真的理解了。
Q1:移动构造和拷贝构造最大的区别是什么?
拷贝构造通常需要为拥有资源的对象创建独立资源,而移动构造可以直接转移已有资源的所有权,避免不必要的深拷贝。
Q2:std::move 是不是直接执行移动操作?
不是。
std::move 主要进行类型转换,将表达式转换为相应的右值表达式。
真正是否发生移动,取决于后续调用的函数。
Q3:为什么移动构造通常需要修改源对象?
为了让源对象不再错误地管理已经转移出去的资源,避免重复释放等问题。
但移动后的源对象不一定必须为空,它需要保持该类型允许的有效状态。
Q4:为什么使用 std::move 后仍然调用拷贝构造?
可能是因为类没有可用的移动构造,或者对象带有 const 限定等原因,导致重载决议最终选择了拷贝构造。
Q5:移动构造和移动赋值有什么区别?
移动构造用于创建新对象。
移动赋值用于修改已经存在的对象,因此还需要正确处理目标对象原本拥有的资源。
Q6:编译器一定会生成移动构造吗?
不会。
当类显式声明了某些拷贝、移动或析构相关函数时,隐式移动操作的生成可能受到限制。
Q7:default 和 delete 有什么区别?
default 用于请求编译器提供默认的特殊成员函数实现。
delete 用于明确禁止函数被使用。
Q8:final 和 override 有什么区别?
final 用于禁止继续继承或重写。
override 用于检查派生类函数是否正确重写了基类的虚函数。
📌 最后一张知识速查表
| 知识点 | 最重要的一句话 |
|---|---|
| 移动构造 | 创建新对象时接管已有资源 |
| 移动赋值 | 已有对象接管资源,同时处理旧资源 |
std::move |
进行右值转换,不负责实际移动 |
noexcept |
声明操作不会向外传播异常 |
| 隐式移动操作 | 是否生成受到其他特殊成员函数影响 |
default |
显式请求编译器生成默认实现 |
delete |
在编译阶段禁止特定函数的使用 |
final |
禁止继续继承或重写 |
override |
检查虚函数重写是否正确 |
🌌 写在最后:从拷贝到移动,是对资源管理的一次重新认识
学完这部分内容,我们会发现,C++11 对类的增强远不只是增加几个关键字。
它实际上帮助我们更加清晰地思考三个问题:
✨ 第一,资源究竟应该属于谁?
从拷贝构造到移动构造,我们开始区分"复制一份资源"和"转移资源所有权"。
这也是理解现代 C++ 资源管理的重要一步。
✨ 第二,一个类究竟允许哪些操作?
通过 default 和 delete,我们可以明确表达设计意图。
哪些行为应该交给编译器,哪些行为应该被禁止,不再需要依靠含糊的约定。
✨ 第三,继承和多态怎样才能更加可靠?
通过 final 和 override,我们能够在编译阶段约束类的继承关系,并检查虚函数重写是否符合预期。
这些看似独立的特性,其实共同指向了现代 C++ 的一个重要思想:
让资源的归属更加明确,让对象的行为更加可控,让错误尽可能在编译阶段暴露。
从最开始的构造与析构,到后来的深拷贝,再到 C++11 的移动语义,我们对类和对象的理解也在不断加深。
而今天学习的移动语义,只是现代 C++ 的一个起点。
在后续学习中,无论是智能指针、STL 容器,还是完美转发,我们都会再次遇到右值引用和移动语义的身影。
当我们真正理解这些机制时,很多看似复杂的 C++ 代码,也就不再那么神秘了。
💫 写给正在学习 C++ 的你
技术学习从来不是记住了多少个关键字,而是能否理解每个设计背后真正要解决的问题。
知其然,更要知其所以然。
保持好奇,持续积累,终有一天,那些曾经晦涩难懂的机制,都会成为我们解决问题时最自然的工具。🚀
🎉 本篇完结,感谢阅读!
如果文章对你有所帮助,欢迎点赞、收藏,也欢迎在评论区交流学习过程中遇到的问题。
📚 持续更新 C++、Linux、计算机网络及后台开发相关知识。
⭐ 我们下一篇技术文章再见!