[特殊字符]【C++11】类的新功能全面解析:移动构造、移动赋值、default、delete、final 与 override

✨ 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、计算机网络及后台开发相关知识。

⭐ 我们下一篇技术文章再见!

相关推荐
Dovis(誓平步青云)1 小时前
逆变器报警却找不到板子,热斑图怎样定位到具体组串
java·开发语言·人工智能·机器学习·卷积神经网络
坐吃山猪1 小时前
Python-Hermite基函数
开发语言·python
陈让然1 小时前
Ubuntu Podman 容器镜像构建
linux·ubuntu
库玛西1 小时前
外部排序全解析:败者树、置换-选择排序与最佳归并树
c语言·数据结构·c++·笔记·考研·算法
库玛西1 小时前
计算机网络第三章:数据链路层
服务器·网络·计算机网络·考研·算法·面试
空~自由1 小时前
Python 正则 . * ? .* .*? (.*?) 通俗讲解
开发语言·python
程序 代码狂人2 小时前
经典 SQL 面试题:连续登录问题的两种经典解法
java·开发语言·算法
竣达技术2 小时前
Modbus 转 SNMP 协议转换器|自定义协议 + SNMP TRAP 主动上报
运维·服务器·网络·数据库·安全
优化Henry2 小时前
LTE小区光路降质告警排查与处理实录
运维·网络·学习·5g·tdd