Skip to content

对象模型

构造函数和析构函数执行顺序是什么?

一句话理解: C++ 里构造顺序是“先打地基,再装零件,最后建自己”;析构顺序刚好反过来:“先拆自己,再拆零件,最后拆地基”。

constructor-destructor-order

普通继承下的顺序

假设有:

c
class Base {};
class Member {};
class Derived : public Base
{
    Member member;
};

构造顺序是:

1. 先构造 Base 基类
2. 再构造 member 成员对象
3. 最后执行 Derived 自己的构造函数体

析构顺序反过来:

1. 先执行 Derived 自己的析构函数体
2. 再析构 member 成员对象
3. 最后析构 Base 基类

代码验证

c
#include <iostream>
using namespace std;

class Base
{
public:
    Base()
    {
        cout << "Base 构造" << endl;
    }

    ~Base()
    {
        cout << "Base 析构" << endl;
    }
};

class Member
{
public:
    Member()
    {
        cout << "Member 构造" << endl;
    }

    ~Member()
    {
        cout << "Member 析构" << endl;
    }
};

class Derived : public Base
{
private:
    Member member;

public:
    Derived()
    {
        cout << "Derived 构造" << endl;
    }

    ~Derived()
    {
        cout << "Derived 析构" << endl;
    }
};

int main()
{
    Derived d;

    return 0;
}

输出:

c
Base 构造
Member 构造
Derived 构造
Derived 析构
Member 析构
Base 析构

记忆:

c
构造:Base → Member → Derived
析构:Derived → Member → Base

成员对象的构造顺序看声明顺序

这是面试非常爱问的坑。

c
#include <iostream>
using namespace std;

class A
{
public:
    A()
    {
        cout << "A 构造" << endl;
    }
};

class B
{
public:
    B()
    {
        cout << "B 构造" << endl;
    }
};

class Test
{
private:
    A a;
    B b;

public:
    // 注意:初始化列表里写的是 b 在前,a 在后
    Test() : b(), a()
    {
        cout << "Test 构造" << endl;
    }
};

int main()
{
    Test t;

    return 0;
}

输出仍然是:

A 构造
B 构造
Test 构造

为什么? 因为成员初始化顺序看的是成员变量在类里的声明顺序:

c
A a;
B b;

不是看初始化列表顺序。

所以:

成员构造顺序 = 类中声明顺序。
成员析构顺序 = 声明顺序的反序。

多个局部对象的顺序

c
void Func()
{
    A a;
    B b;
    C c;
}

构造顺序:

c
A → B → C

析构顺序:

c
C → B → A

也就是:

先创建的后销毁。
后创建的先销毁。

这很像叠盘子:最后放上去的盘子最先拿走。

多个基类的构造顺序

c
class Base1
{
public:
    Base1()
    {
        cout << "Base1 构造" << endl;
    }
};

class Base2
{
public:
    Base2()
    {
        cout << "Base2 构造" << endl;
    }
};

class Derived : public Base1, public Base2
{
public:
    Derived()
    {
        cout << "Derived 构造" << endl;
    }
};

构造顺序看继承列表:

c
class Derived : public Base1, public Base2

所以:

c
Base1 → Base2 → Derived

析构反过来:

c
Derived → Base2 → Base1

数组对象的顺序

c
class Player
{
public:
    Player()
    {
        cout << "Player 构造" << endl;
    }

    ~Player()
    {
        cout << "Player 析构" << endl;
    }
};

int main()
{
    Player* players = new Player[3];

    delete[] players;

    return 0;
}

构造顺序:

c
players[0]
players[1]
players[2]

析构顺序:

c
players[2]
players[1]
players[0]

注意必须:

c
delete[] players;

不能写:

c
delete players;

new[] 必须配 delete[]

基类析构函数为什么常写 virtual

如果用基类指针指向派生类对象:

c
Base* p = new Derived();
delete p;

如果 Base 的析构函数不是 virtual,可能只调用 Base 析构,不调用 Derived 析构。 这样派生类资源可能泄漏。

正确写法:

c
class Base
{
public:
    virtual ~Base()
    {
        cout << "Base 析构" << endl;
    }
};

只要一个类准备被继承,并且可能通过基类指针删除对象,基类析构函数就应该是虚析构。

构造函数里抛异常会怎样

如果构造过程中出错:

c
class Test
{
private:
    A a;
    B b;

public:
    Test()
    {
        throw std::runtime_error("构造失败");
    }
};

规则是:

已经构造成功的基类和成员对象会被析构。
当前对象自己的析构函数不会执行,因为它没有完整构造成功。

这也是为什么 C++ 推荐 RAII: 成员对象负责自己的资源释放,即使构造中途失败,也能正确清理已经构造好的部分。

静态对象的构造析构

同一个 .cpp 文件里:

c
A a;
B b;
C c;

构造顺序:

c
A → B → C

析构顺序:

c
C → B → A

但是不同 .cpp 文件之间的全局静态对象初始化顺序不可靠。 这就是著名的“静态初始化顺序问题”。

更安全的写法是函数内局部静态:

c
class Config
{
public:
    static Config& Instance()
    {
        // 第一次调用时初始化
        static Config instance;

        return instance;
    }
};

C++11 以后,局部静态变量初始化是线程安全的。

完整高分顺序

如果考虑虚继承、多继承、成员对象,构造顺序是:

1. 虚基类构造
2. 普通基类按继承列表顺序构造
3. 成员对象按声明顺序构造
4. 执行当前类构造函数体

析构顺序反过来:

1. 执行当前类析构函数体
2. 成员对象按声明反序析构
3. 普通基类按继承列表反序析构
4. 虚基类析构

面试高分回答

NOTE

C++ 中构造函数执行顺序是先构造基类,再构造成员对象,最后执行当前类自己的构造函数体。

如果有多个基类,普通基类按照继承列表顺序构造;如果有成员对象,成员对象按照在类中声明的顺序构造,而不是按照初始化列表顺序。

析构顺序和构造顺序完全相反,先执行当前类析构函数体,再按声明反序析构成员对象,最后按继承反序析构基类。

对于数组,元素按下标从小到大构造,析构时从大到小析构。

如果通过基类指针删除派生类对象,基类析构函数应该声明为 virtual,否则可能不会调用派生类析构函数,导致资源泄漏。

最短记忆版

构造:虚基类 → 普通基类 → 成员对象 → 自己。
析构:自己 → 成员对象 → 普通基类 → 虚基类。
成员顺序看声明顺序,不看初始化列表顺序。

拷贝构造和赋值运算符区别是什么?

一句话理解: 拷贝构造函数是“用一个已有对象创建新对象”;赋值运算符是“两个对象都已经存在,把右边对象的内容赋给左边对象”。

copy-constructor-vs-assignment

先看最简单区别

c
Player a;

// 这里是拷贝构造:b 是新对象
Player b = a;

// 这里是赋值运算符:c 已经存在
Player c;
c = a;

判断方法:

左边对象正在被创建 → 拷贝构造。
左边对象早就存在了 → 赋值运算符。

拷贝构造函数是什么

典型写法:

c
class Player
{
public:
    Player(const Player& other)
    {
        // 用 other 初始化当前这个新对象
    }
};

触发场景:

c
Player a;

// 1. 用已有对象创建新对象
Player b(a);

// 2. 也是拷贝构造
Player c = a;

// 3. 函数按值传参时,可能触发拷贝构造
void Func(Player p)
{
}

// 4. 函数按值返回时,理论上可能触发
// 现代编译器可能做返回值优化
Player CreatePlayer()
{
    Player temp;
    return temp;
}

核心:

拷贝构造发生时,目标对象之前不存在。

赋值运算符是什么

典型写法:

c
class Player
{
public:
    Player& operator=(const Player& other)
    {
        // 把 other 的内容赋给当前已经存在的对象

        return *this;
    }
};

触发场景:

c
Player a;
Player b;

// b 已经存在了
// 所以这里不是构造,而是赋值
b = a;

核心:

赋值运算符发生时,左右两边对象都已经存在。

用资源类看区别最清楚

假设类里面有堆内存:

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        cout << "普通构造" << endl;
    }

    ~Buffer()
    {
        delete[] data;

        cout << "析构" << endl;
    }
};

如果不自己写拷贝构造和赋值,编译器默认会做“浅拷贝”。 浅拷贝会让两个对象指向同一块内存,最后析构时重复释放,非常危险。

正确写拷贝构造

c
class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];
    }

    // 拷贝构造:用 other 创建一个新对象
    Buffer(const Buffer& other)
    {
        size = other.size;

        // 为新对象单独申请一块内存
        data = new char[size];

        // 把 other 的内容拷贝过来
        memcpy(data, other.data, size);
    }

    ~Buffer()
    {
        delete[] data;
    }
};

拷贝构造的重点:

当前对象是新对象。
它还没有旧资源。
所以直接申请新资源并拷贝即可。

正确写赋值运算符

c
class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];
    }

    Buffer(const Buffer& other)
    {
        size = other.size;
        data = new char[size];
        memcpy(data, other.data, size);
    }

    Buffer& operator=(const Buffer& other)
    {
        // 1. 处理自赋值:a = a
        if (this == &other)
        {
            return *this;
        }

        // 2. 当前对象已经有旧资源,先释放旧资源
        delete[] data;

        // 3. 再申请新资源
        size = other.size;
        data = new char[size];

        // 4. 拷贝内容
        memcpy(data, other.data, size);

        // 5. 返回当前对象,支持 a = b = c
        return *this;
    }

    ~Buffer()
    {
        delete[] data;
    }
};

赋值运算符的重点:

当前对象已经存在。
它可能已经有旧资源。
所以必须先处理旧资源,再拷贝新内容。

为什么赋值要返回引用

c
Buffer& operator=(const Buffer& other)

返回 Buffer& 是为了支持连续赋值:

c
a = b = c;

执行顺序大概是:

c
b = c 返回 b
a = b

如果不返回引用,连续赋值就不好用了。

为什么要判断自赋值

c
a = a;

如果不判断,可能出问题:

c
delete[] data;
memcpy(data, other.data, size);

this == &other 时,other.datadata 是同一块。 你先把它删了,再去拷贝它,就危险了。

所以赋值运算符里常见:

c
if (this == &other)
{
    return *this;
}

拷贝构造和赋值运算符对比

c
触发时机:
拷贝构造:创建新对象时。
赋值运算符:对象已存在后。

典型写法:
拷贝构造:T(const T& other)
赋值运算符:T& operator=(const T& other)

是否有旧资源:
拷贝构造:没有旧资源。
赋值运算符:可能有旧资源,需要先处理。

是否需要返回值:
拷贝构造:没有返回值。
赋值运算符:通常返回 T&

是否考虑自赋值:
拷贝构造:一般不用。
赋值运算符:需要考虑。

三法则 / 五法则

如果一个类需要自己写析构函数,通常也要考虑自己写:

拷贝构造函数
拷贝赋值运算符

这叫三法则:

析构函数
拷贝构造
拷贝赋值

C++11 后还有移动语义,所以变成五法则:

析构函数
拷贝构造
拷贝赋值
移动构造
移动赋值

如果类里有裸指针、文件句柄、网络连接、GPU 资源等,就要特别注意这些函数。

现代 C++ 更推荐怎么做

很多时候不建议自己手写裸指针资源管理。

更推荐用:

c
std::vector
std::string
std::unique_ptr
std::shared_ptr
RAII 封装

例如:

c
#include <vector>

class Buffer
{
private:
    // vector 会自己管理内存
    std::vector<char> data;

public:
    Buffer(int size)
        : data(size)
    {
    }
};

这样拷贝构造、赋值、析构都可以交给编译器默认生成,安全很多。 这叫“零法则”:能不用自己写特殊成员函数,就不写。

面试高分回答

TIP

拷贝构造函数和赋值运算符的核心区别是目标对象是否已经存在。

如果用一个已有对象初始化一个新对象,比如 Player b = a,这是拷贝构造,签名通常是 T(const T& other)。

如果两个对象都已经存在,比如 b = a,这是赋值运算符,签名通常是 T& operator=(const T& other)。

拷贝构造时当前对象还没有旧资源,所以只需要申请并拷贝新资源。赋值运算符中当前对象可能已经持有资源,所以要先处理自赋值,再释放旧资源,然后拷贝新资源,并返回 *this 以支持连续赋值。

如果类中管理堆内存、文件句柄等资源,通常需要遵守三法则或五法则。现代 C++ 更推荐用 RAII、vector、string、智能指针来避免手写这些函数。

最短记忆版

拷贝构造:新对象 = 旧对象。
赋值运算符:旧对象 = 另一个旧对象。
拷贝构造负责“创建”,赋值运算符负责“替换已有内容”。

深拷贝和浅拷贝区别是什么?

一句话理解: 浅拷贝只复制“地址”,两个对象可能指向同一块资源;深拷贝会重新申请一份资源,再把内容复制过去,两个对象互不影响。

deep-vs-shallow-copy

零基础理解

假设对象里有一个指针,指向一块堆内存。

浅拷贝像这样:

对象 a 有一把钥匙,能打开房间 A。
拷贝出对象 b 时,只复制了一把相同的钥匙。
结果 a 和 b 打开的是同一个房间。

深拷贝像这样:

对象 a 有房间 A。
拷贝出对象 b 时,重新建一个房间 B。
再把房间 A 里的东西复制到房间 B。
结果 a 和 b 各有自己的房间。

浅拷贝是什么

浅拷贝就是成员逐个复制。 如果成员是普通 intfloat,问题不大。 但如果成员是指针,就只会复制指针地址,不会复制指针指向的资源。

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
public:
    char* data;
    int size;

    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        strcpy(data, "hello");
    }

    ~Buffer()
    {
        delete[] data;
    }
};

int main()
{
    Buffer a(16);

    // 默认拷贝构造:浅拷贝
    // b.data 和 a.data 指向同一块堆内存
    Buffer b = a;

    return 0;
}

这段代码很危险。 因为 a.datab.data 指向同一块内存。

程序结束时:

c
b 析构,delete[] data
a 析构,又 delete[] data

同一块内存被释放两次,这就是重复释放,可能崩溃。

浅拷贝的问题

浅拷贝的典型风险:

1. 两个对象共享同一块堆内存
2. 一个对象修改内容,另一个对象也受影响
3. 一个对象释放资源,另一个对象变成悬空指针
4. 两个对象析构时可能重复 delete

比如:

c
Buffer a(16);
Buffer b = a;

b.data[0] = 'H';

// a.data 也变了,因为它们指向同一块内存
cout << a.data << endl;

深拷贝是什么

深拷贝会重新申请一块内存,然后把内容复制过去。

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        strcpy(data, "hello");
    }

    // 深拷贝:拷贝构造函数
    Buffer(const Buffer& other)
    {
        size = other.size;

        // 重新申请一块属于当前对象自己的内存
        data = new char[size];

        // 把 other 的内容复制过来
        strcpy(data, other.data);
    }

    // 深拷贝:赋值运算符
    Buffer& operator=(const Buffer& other)
    {
        // 防止 a = a 这种自赋值
        if (this == &other)
        {
            return *this;
        }

        // 当前对象原来可能已经有内存,先释放旧资源
        delete[] data;

        size = other.size;

        // 重新申请自己的内存
        data = new char[size];

        // 复制内容,而不是复制地址
        strcpy(data, other.data);

        return *this;
    }

    ~Buffer()
    {
        delete[] data;
    }

    void Print() const
    {
        cout << data << endl;
    }
};

这才是深拷贝:

a.data 指向资源 A
b.data 指向资源 B
资源 A 和资源 B 内容一样,但不是同一块内存

深拷贝和浅拷贝对比

浅拷贝:
复制成员值。
如果成员是指针,只复制地址。
速度快,但容易共享资源。
可能导致重复释放、悬空指针。

深拷贝:
重新申请资源。
复制资源内容。
对象之间互不影响。
更安全,但成本更高。

什么时候浅拷贝没问题

如果类里只有普通值类型,浅拷贝通常没问题。

c
class Player
{
public:
    int hp;
    int attack;
};

int main()
{
    Player a;
    a.hp = 100;
    a.attack = 20;

    // 默认浅拷贝没问题
    Player b = a;

    return 0;
}

因为:

int hp 直接复制值
int attack 直接复制值
没有共享堆资源

什么时候必须考虑深拷贝

如果类里有这些东西,就要非常小心:

裸指针
new 出来的内存
文件句柄
网络连接
系统资源句柄
GPU 资源
自定义资源对象

比如:

c
class Texture
{
private:
    unsigned char* pixels;
};

如果默认拷贝,pixels 地址被复制,两个 Texture 可能共享同一块像素内存。 这通常不是你想要的。

三法则和五法则

如果你需要自己写析构函数,通常也要考虑:

拷贝构造函数
拷贝赋值运算符

这叫三法则:

析构函数
拷贝构造
拷贝赋值

C++11 后还有移动语义,所以有五法则:

析构函数
拷贝构造
拷贝赋值
移动构造
移动赋值

如果你的类管理资源,默认浅拷贝往往不够安全。

现代 C++ 更推荐什么

很多时候,不要手写裸指针。

比如用 std::string

c
#include <string>
using namespace std;

class Player
{
private:
    string name;

public:
    Player(const string& name)
        : name(name)
    {
    }
};

std::string 自己会处理拷贝、赋值、析构。

再比如用 std::vector

c
#include <vector>
using namespace std;

class Buffer
{
private:
    vector<char> data;

public:
    Buffer(int size)
        : data(size)
    {
    }
};

这样你不用自己写深拷贝,vector 会自己管理内部内存。

这叫零法则:

能交给标准库和 RAII 管理,就不要自己手写资源管理。

如果资源不能复制怎么办

有些资源不应该被拷贝,比如:

独占文件句柄
独占网络连接
独占 GPU 资源
独占内存块

这时可以禁用拷贝:

c
class Resource
{
public:
    Resource()
    {
    }

    ~Resource()
    {
    }

    // 禁止拷贝构造
    Resource(const Resource&) = delete;

    // 禁止拷贝赋值
    Resource& operator=(const Resource&) = delete;
};

这样别人不能随便拷贝它。 如果要转移所有权,可以设计移动构造和移动赋值。

常见坑

1. 类里有裸指针,却使用默认拷贝构造
2. 两个对象指向同一块内存,析构时重复 delete
3. 赋值运算符里忘记处理自赋值
4. 深拷贝时忘记先释放旧资源,导致泄漏
5. 以为所有拷贝都必须深拷贝,其实要看所有权语义
6. 能用 string/vector/智能指针却手写 new/delete

面试高分回答

NOTE

浅拷贝是按成员逐个复制。如果成员是普通值类型,通常没问题;但如果成员是指针,浅拷贝只会复制指针地址,两个对象会指向同一块资源,可能出现互相影响、悬空指针和重复释放。

深拷贝则会重新申请一份资源,并把原资源内容复制过去。这样两个对象拥有各自独立的资源,互不影响,但成本更高。

在 C++ 中,如果类管理堆内存或其他资源,通常需要自己实现拷贝构造、拷贝赋值和析构函数,也就是三法则;C++11 后还要考虑移动构造和移动赋值,也就是五法则。

现代 C++ 更推荐用 RAII、std::string、std::vector、智能指针来管理资源,尽量避免裸指针导致的浅拷贝问题。

最短记忆版

浅拷贝 = 复制地址,两个对象共享资源。
深拷贝 = 重新申请资源,复制内容,两个对象各管各的。
有裸指针和资源所有权时,要特别小心默认浅拷贝。

移动构造是什么?

一句话理解: 移动构造是 C++11 引入的机制:用一个“即将不用的对象”来创建新对象时,不再深拷贝资源,而是把资源所有权直接转移过去。

move-constructor-explained

零基础理解

假设对象里有一块堆内存。

拷贝构造像这样:

重新买一个新箱子
把旧箱子里的东西一件件复制过去
两个箱子都还在

移动构造像这样:

不复制东西
直接把旧箱子的钥匙交给新对象
旧对象变成空箱子

所以移动构造的核心不是“复制得更快”,而是:

不复制资源,而是转移资源所有权。

拷贝构造的问题

假设有一个类管理堆内存:

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        strcpy(data, "hello");
    }

    ~Buffer()
    {
        delete[] data;
    }
};

如果我们要把一个 Buffer 拷贝给另一个 Buffer,深拷贝需要:

重新申请内存
复制每个字节
两个对象各有一份资源

如果资源很大,比如:

大数组
字符串缓冲区
图片数据
网格数据
音频数据

深拷贝就可能很贵。

移动构造函数长什么样

移动构造函数的典型签名:

Buffer(Buffer&& other)

完整例子:

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        strcpy(data, "hello");

        cout << "普通构造" << endl;
    }

    // 拷贝构造:深拷贝资源
    Buffer(const Buffer& other)
    {
        size = other.size;

        // 重新申请一块内存
        data = new char[size];

        // 复制内容
        strcpy(data, other.data);

        cout << "拷贝构造" << endl;
    }

    // 移动构造:偷走 other 的资源
    Buffer(Buffer&& other) noexcept
    {
        // 直接接管 other 的内存地址
        data = other.data;
        size = other.size;

        // 把 other 置为空状态
        // 防止 other 析构时 delete 掉这块内存
        other.data = nullptr;
        other.size = 0;

        cout << "移动构造" << endl;
    }

    ~Buffer()
    {
        delete[] data;

        cout << "析构" << endl;
    }

    void Print() const
    {
        if (data != nullptr)
        {
            cout << data << endl;
        }
        else
        {
            cout << "空 Buffer" << endl;
        }
    }
};

移动构造最关键的三步:

1. 接管 other 的资源
2. 把 other 的资源指针置空
3. 保证 other 析构时不会释放已经被转移走的资源

怎么触发移动构造

std::move

c
#include <utility>

int main()
{
    Buffer a(16);

    // std::move(a) 把 a 转成右值引用
    // 于是优先匹配移动构造
    Buffer b = std::move(a);

    b.Print(); // hello
    a.Print(); // 空 Buffer

    return 0;
}

注意: std::move 本身不移动任何东西。

它只是告诉编译器:

我以后不再依赖 a 原来的内容了,你可以把它当成将亡对象。

真正移动资源的是移动构造函数。

std::move 后对象还能用吗

能用,但不要依赖它原来的值。

Buffer b = std::move(a);

之后的 a

仍然是一个有效对象
可以析构
可以重新赋值
可以调用不依赖旧内容的安全函数
但不要指望它还保留原来的资源

常见说法是:

c
moved-from object 处于 valid but unspecified state

也就是:

对象有效,但具体内容不保证。

移动构造和拷贝构造对比

拷贝构造:
T(const T& other)
复制资源内容。
other 不能被修改。
适合保留两个独立副本。

移动构造:
T(T&& other)
转移资源所有权。
other 可以被修改为“空状态”。
适合临时对象或不再使用的对象。

为什么移动构造更快

假设资源是一个 100MB 的缓冲区。

拷贝构造:

申请新的 100MB
复制 100MB 内容

移动构造:

复制一个指针地址
复制一个 size
把旧指针置空

成本差别非常大。

什么时候会用到移动构造

常见场景:

1. 用临时对象初始化新对象
2. 函数返回大对象
3. std::vector 扩容搬移元素
4. std::move 显式转移资源
5. unique_ptr 这种独占资源转移

例如:

c
Buffer CreateBuffer()
{
    Buffer temp(1024);

    return temp;
}

现代编译器可能做返回值优化,甚至不需要移动。 但如果不能优化,移动构造就能避免昂贵拷贝。

vector 扩容为什么喜欢移动构造

c
#include <vector>

int main()
{
    vector<Buffer> buffers;

    buffers.push_back(Buffer(16));
    buffers.push_back(Buffer(32));

    return 0;
}

vector 扩容时,可能需要把旧元素搬到新内存区域。

如果元素支持移动构造,vector 可以移动元素,而不是拷贝元素。 这对性能很重要。

为什么移动构造常写 noexcept

c
Buffer(Buffer&& other) noexcept

noexcept 表示这个函数不会抛异常。

很多标准容器,比如 std::vector,在扩容时更愿意使用 noexcept 的移动构造。 因为如果移动一半抛异常,容器很难保证强异常安全。

所以面试可以说:

移动构造函数通常应该标记 noexcept,尤其是资源所有权转移类。

移动赋值也要了解

移动构造是创建新对象:

c
Buffer b = std::move(a);

移动赋值是对象已经存在:

c
Buffer a(16);
Buffer b(32);

b = std::move(a);

移动赋值典型写法:

c
Buffer& operator=(Buffer&& other) noexcept
{
    if (this == &other)
    {
        return *this;
    }

    // 先释放自己原来的资源
    delete[] data;

    // 接管 other 的资源
    data = other.data;
    size = other.size;

    // 把 other 置为空状态
    other.data = nullptr;
    other.size = 0;

    return *this;
}

和移动构造的区别:

移动构造:当前对象是新对象,没有旧资源。
移动赋值:当前对象已存在,要先释放自己的旧资源。

五法则

如果一个类自己管理资源,通常要考虑五个函数:

析构函数
拷贝构造函数
拷贝赋值运算符
移动构造函数
移动赋值运算符

这就是五法则。

如果不想让对象被拷贝,只允许移动,可以:

c
class Resource
{
public:
    Resource()
    {
    }

    ~Resource()
    {
    }

    // 禁止拷贝
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;

    // 允许移动
    Resource(Resource&& other) noexcept
    {
        // 转移资源
    }

    Resource& operator=(Resource&& other) noexcept
    {
        // 转移资源
        return *this;
    }
};

现代 C++ 的建议

如果可以,优先用标准库帮你管理资源:

c
#include <vector>
using namespace std;

class Buffer
{
private:
    vector<char> data;

public:
    Buffer(int size)
        : data(size)
    {
    }
};

这样:

析构
拷贝
移动
赋值

都可以交给 vector 处理。

这叫零法则:

能不自己写资源管理函数,就不自己写。

常见坑

1. 移动构造里忘记把 other 指针置空
2. std::move 后还继续使用原对象旧内容
3. 以为 std::move 本身会移动资源
4. 移动赋值里忘记释放当前对象旧资源
5. 移动构造没写 noexcept,导致容器不愿意移动
6. 有析构函数和裸指针,却没写拷贝/移动控制函数

面试高分回答

CAUTION

移动构造是 C++11 引入的特殊构造函数,签名通常是 T(T&& other)。它用于用一个右值或将亡对象创建新对象。

和拷贝构造不同,拷贝构造会复制资源内容,而移动构造会直接接管 other 持有的资源,比如堆内存指针、文件句柄等,然后把 other 置为一个安全的空状态,避免 other 析构时重复释放资源。

std::move 本身不会移动资源,它只是把对象转换成右值引用,让编译器可以选择移动构造或移动赋值。

移动构造可以避免大对象深拷贝,常见于临时对象、函数返回值、容器扩容、unique_ptr 所有权转移等场景。移动后的对象仍然有效,但内容不应该再依赖。

实际写移动构造时通常要加 noexcept,这样标准容器在扩容时更愿意使用移动操作。

最短记忆版

移动构造 = 用右值创建新对象,不复制资源,而是接管资源。
std::move 不移动,只是把对象转成右值。
移动后原对象仍有效,但内容不要再依赖。
资源类移动构造通常要 noexcept。

左值和右值是什么?

一句话理解: 左值是“有身份、能被持续访问的表达式”;右值是“临时结果、通常用完就消失的表达式”。移动语义就是建立在右值基础上的。

lvalue-rvalue-explained

零基础理解

左值像“有门牌号的房子”:

你知道它在哪里。
可以反复找到它。
可以给它赋新值。

右值像“刚算出来的一次性结果”:

它只是一个临时结果。
通常没有稳定名字。
用完就可以丢。

比如:

c
int x = 10;
int y = x + 1;

这里:

x 是左值。
y 是左值。
10 是右值。
x + 1 是右值。

左值是什么

左值,英文是 lvalue

典型左值:

c
int x = 10;

x = 20;

x 是左值,因为它是一个有名字的变量。 你可以反复访问它,也可以取它的地址:

c
int* p = &x;

常见左值:

变量名:x
数组元素:arr[0]
对象成员:player.hp
解引用结果:*p
返回左值引用的函数调用

例子:

c
int arr[3] = {1, 2, 3};

arr[0] = 100; // arr[0] 是左值

int* p = &arr[1];

*p = 200; // *p 是左值

右值是什么

右值,英文是 rvalue

典型右值:

c
int x = 10;
int y = x + 1;

这里的 x + 1 是一个临时计算结果。 你不能这样写:

c
x + 1 = 20; // 错误

常见右值:

c
字面量:10
表达式结果:a + b
临时对象:std::string("hello")
函数返回的普通值

例子:

c
int GetNumber()
{
    return 100;
}

int x = GetNumber(); // GetNumber() 的返回值通常是右值

“能不能放在等号左边”只是粗略判断

以前常说:

能放在 = 左边的是左值。
只能放在 = 右边的是右值。

这个说法适合初学理解,但不够严谨。

比如:

c
const int x = 10;

x 是左值,因为它有身份、能取地址:

c
const int* p = &x;

但你不能写:

c
x = 20; // 错误,因为 x 是 const

所以更准确的说法是:

左值重点是“有身份”。
不是所有左值都能被赋值。

能不能取地址也只是常用判断

通常:

c
int x = 10;

int* p = &x; // 可以,x 是左值

但不能:

c
int* p = &(x + 1); // 错误,x + 1 是临时右值

所以很多时候可以用“能否取地址”帮助判断。 但面试高分说法还是:

左值有身份。
右值通常是临时值。

左值引用和右值引用

C++ 里:

c
int x = 10;

int& ref = x; // 左值引用绑定左值

int& 叫左值引用。 它通常只能绑定左值。

不能这样:

c
int& ref = 10; // 错误,普通左值引用不能绑定右值

const 左值引用可以绑定右值:

c
const int& ref = 10; // 正确

这会延长临时对象的生命周期。

C++11 引入右值引用:

c
int&& rref = 10; // 正确,右值引用绑定右值

T&& 是移动语义的基础。

为什么右值对移动构造重要

假设有一个大对象:

c
Buffer a(1024);

如果写:

c
Buffer b = a;

a 是左值,通常会调用拷贝构造。 因为 a 还活着,编译器不能随便把它的资源拿走。

如果写:

c
Buffer b = std::move(a);

std::move(a) 会把 a 转成右值引用。 这表示:

我不再依赖 a 原来的内容了。
可以把 a 的资源移动给 b。

于是可以调用移动构造:

c
Buffer(Buffer&& other)

std::move 本身不移动

这个点非常重要。

c
std::move(a)

不是立刻把资源搬走。 它只是做类型转换:

把左值 a 转成一个右值表达式。

真正搬资源的是移动构造或移动赋值。

c
Buffer b = std::move(a); // 这里才可能触发移动构造

左值、右值和函数重载

看这个例子:

c
#include <iostream>
using namespace std;

void Print(int& value)
{
    cout << "左值引用版本" << endl;
}

void Print(int&& value)
{
    cout << "右值引用版本" << endl;
}

int main()
{
    int x = 10;

    Print(x);      // x 是左值
    Print(20);     // 20 是右值
    Print(x + 1);  // x + 1 是右值

    return 0;
}

输出:

左值引用版本
右值引用版本
右值引用版本

注意:

c
int&& r = 20;

虽然 r 的类型是右值引用,但表达式 r 本身是左值。 因为 r 有名字,可以反复访问。

Print(r);           // 调用左值引用版本
Print(std::move(r)); // 调用右值引用版本

这也是面试容易追问的点。

C++11 后更细的分类

面试基础通常只问左值和右值。 但 C++11 后更细:

c
lvalue:
有身份,不能被直接移动资源。
比如变量名 x。

prvalue:
纯右值,临时值。
比如 10、a + b、T()。

xvalue:
将亡值,有身份但资源可以被移动。
比如 std::move(x)。

你可以简单记:

c
右值大体包括 prvalue 和 xvalue。
std::move(x) 产生的是 xvalue。

不用一开始就背特别深,但知道这个会很加分。

常见判断例子

c
int x = 10;
int y = 20;
int* p = &x;
x:左值
y:左值
10:右值
x + y:右值
*p:左值
&x:右值,结果是一个地址值
std::move(x):右值,更准确是 xvalue
x++:右值,返回旧值
++x:左值,返回 x 自己

x++++x 是经典考点:

c
int x = 1;

x++ = 10; // 通常不行,x++ 返回旧值,偏右值语义
++x = 10; // 可以,++x 返回 x 本身,是左值

面试高分回答

NOTE

左值和右值主要区别不只是能不能放在等号左边,而是表达式有没有身份。

左值有身份,可以持续访问,比如变量名、数组元素、解引用结果。右值通常是临时结果,比如字面量、表达式计算结果、临时对象。

普通左值引用 T& 通常绑定左值,右值引用 T&& 绑定右值,这是 C++11 移动语义的基础。

std::move 本身不移动资源,它只是把一个左值转换成右值表达式,让编译器可以选择移动构造或移动赋值。真正资源转移发生在移动构造或移动赋值里。

需要注意的是,右值引用变量本身如果有名字,那么这个名字表达式仍然是左值,所以要再次 std::move 才能触发右值重载。

最短记忆版

左值 = 有身份,能反复找到,比如变量 x。
右值 = 临时结果,用完就走,比如 10、x + 1。
T& 绑定左值,T&& 绑定右值。
std::move 不移动,只是把左值转成右值。

右值引用有什么用?

一句话理解: 右值引用 T&& 的作用是接住“临时对象 / 将亡对象”,让 C++ 可以少拷贝、多移动资源;它也是移动构造、移动赋值、完美转发的基础。

rvalue-reference-uses

右值引用是什么

普通左值引用:

c
int x = 10;

int& ref = x; // 正确,ref 绑定左值 x

右值引用:

c
int&& rref = 10; // 正确,rref 绑定右值 10

T&& 的意思是:

我可以绑定一个右值。
这个对象通常是临时的,或者马上不用了。
所以它的资源可以被移动。

作用一:支持移动语义

没有右值引用之前,大对象传来传去只能拷贝。

比如一个 Buffer 里面有一块堆内存:

c
class Buffer
{
private:
    char* data;
    int size;
};

如果拷贝它,就要:

重新申请内存
复制内容

如果数据很大,就很贵。

右值引用允许你写移动构造:

c
#include <cstring>
#include <iostream>
using namespace std;

class Buffer
{
private:
    char* data;
    int size;

public:
    Buffer(int size)
    {
        this->size = size;
        data = new char[size];

        strcpy(data, "hello");
    }

    // 移动构造:接收右值引用
    Buffer(Buffer&& other) noexcept
    {
        // 直接接管 other 的资源
        data = other.data;
        size = other.size;

        // 把 other 置空,避免析构时重复 delete
        other.data = nullptr;
        other.size = 0;

        cout << "移动构造" << endl;
    }

    ~Buffer()
    {
        delete[] data;
    }
};

这样:

c
Buffer a(16);

// std::move(a) 把 a 转成右值
// 然后触发移动构造
Buffer b = std::move(a);

不是复制一份内存,而是:

b 接管 a 的 data 指针
a 被置为空状态

作用二:避免不必要的深拷贝

假设对象里有:

大数组
字符串缓冲区
图片数据
音频数据
网格数据
文件句柄
网络连接
GPU 资源句柄

拷贝可能非常贵。 移动只需要转移管理信息:

指针
大小
容量
句柄
所有权标记

比如 std::vector 移动时,不需要复制所有元素的内存块,很多情况下只需要把内部指针交给新对象。

作用三:区分左值和右值重载

可以写两个版本:

c
#include <iostream>
#include <string>
using namespace std;

void Print(const string& text)
{
    cout << "左值/只读引用版本" << endl;
}

void Print(string&& text)
{
    cout << "右值引用版本,可以移动资源" << endl;
}

int main()
{
    string name = "Alice";

    Print(name);              // name 是左值
    Print(string("Bob"));     // 临时对象是右值
    Print(std::move(name));   // 把 name 转成右值

    return 0;
}

输出大概是:

左值/只读引用版本
右值引用版本,可以移动资源
右值引用版本,可以移动资源

这让函数可以根据参数类型选择更高效的处理方式。

作用四:让容器更高效

比如:

c
std::vector<std::string> names;

std::string name = "Alice";

names.push_back(name);            // 拷贝
names.push_back(std::move(name)); // 移动
names.push_back("Bob");           // 临时对象,通常可移动

push_back 可以有类似重载:

c
void push_back(const T& value); // 拷贝
void push_back(T&& value);      // 移动

如果传进来的是右值,容器就可以优先移动资源,减少拷贝。

作用五:完美转发

模板里的 T&& 比较特殊。

c
template <typename T>
void Wrapper(T&& arg)
{
    Target(std::forward<T>(arg));
}

这里的 T&& 叫转发引用,也常被叫万能引用。 它既可以接左值,也可以接右值。

c
string a = "hello";

Wrapper(a);              // a 是左值
Wrapper(string("world")); // 临时对象是右值

std::forward<T>(arg) 的作用是:

原来传进来是左值,就继续当左值传出去。
原来传进来是右值,就继续当右值传出去。

这叫完美转发。

为什么需要 std::forward

注意一个坑:

c
void Func(string&& s)
{
    // 虽然 s 的类型是 string&&
    // 但 s 这个变量有名字,所以表达式 s 是左值
}

有名字的右值引用变量,本身是左值。

所以如果你想继续把它当右值传出去,要写:

c
std::move(s)

在模板完美转发里,要写:

c
std::forward<T>(arg)

std::move 和右值引用关系

std::move 本身不移动资源。

c
std::move(x)

它只是告诉编译器:

把 x 当成右值看待。
可以匹配 T&&。

真正移动资源的是:

移动构造函数
移动赋值运算符
容器内部的移动操作

也就是说:

c
Buffer b = std::move(a);

这里 std::move(a) 只是转换值类别。 资源转移发生在 Buffer(Buffer&& other) 里。

move 后对象还能不能用

能用,但不要依赖它原来的值。

c
std::string s = "hello";

std::string t = std::move(s);

// s 仍然是有效对象
// 可以重新赋值
s = "new value";

但是不要写依赖旧内容的逻辑:

c
std::string t = std::move(s);

// 不推荐依赖 s 还等于 "hello"
cout << s << endl;

移动后的对象通常处于:

c
valid but unspecified state

意思是:

对象有效,可以析构,可以重新赋值。
但原来的内容不保证还在。

右值引用常见用途总结

1. 实现移动构造函数
2. 实现移动赋值运算符
3. 减少大对象深拷贝
4. 区分左值/右值重载
5. 支持容器高效扩容和插入
6. 配合 std::forward 实现完美转发
7. 支持 unique_ptr 这种独占资源转移

常见坑

1. 以为 std::move 会移动资源,其实它只是类型转换。
2. move 后继续依赖原对象旧值。
3. 右值引用变量有名字后,本身表达式是左值。
4. 移动构造忘记把源对象置空,导致重复释放。
5. 移动构造没写 noexcept,容器可能不愿意移动。
6. 模板里 T&& 不一定永远是右值引用,它可能是转发引用。

面试高分回答

NOTE

右值引用是 C++11 引入的引用类型,形式是 T&&。它可以绑定右值,也就是临时对象或将亡对象。

它最大的作用是支持移动语义。比如一个对象内部管理堆内存时,拷贝构造需要重新申请内存并复制内容,而移动构造可以直接接管源对象的资源指针,再把源对象置为空状态,从而避免昂贵的深拷贝。

右值引用还可以用于函数重载,让函数区分传入的是左值还是右值。比如 const T& 版本用于拷贝,T&& 版本用于移动。

在模板中,T&& 还可以作为转发引用,配合 std::forward 保持实参原来的左值或右值属性,这就是完美转发。

需要注意 std::move 本身不移动资源,它只是把左值转换成右值表达式,真正的资源转移发生在移动构造或移动赋值函数中。

最短记忆版

右值引用 T&& = 接住临时对象。
主要用途 = 移动语义 + 完美转发。
std::move 不移动,只是把左值转成右值。
移动后对象仍有效,但旧内容别再依赖。

虚函数是什么?

一句话理解: 虚函数就是在基类里用 virtual 修饰的函数,它允许子类重写,并且当你通过基类指针或引用调用时,程序会在运行时根据真实对象类型决定调用哪个版本。

virtual-function-explained

零基础理解

假设游戏里有很多敌人:

c
史莱姆 Slime
弓箭手 Archer
Boss

它们都属于敌人 Enemy,但攻击方式不一样:

Slime:撞击
Archer:射箭
Boss:放大招

你希望写:

c
enemy->Attack();

然后程序自动根据 enemy 实际指向的是谁,调用对应的攻击方式。 这就是虚函数和多态要解决的问题。

没有 virtual 会怎样

c
#include <iostream>
using namespace std;

class Enemy
{
public:
    void Attack()
    {
        cout << "Enemy 普通攻击" << endl;
    }
};

class Boss : public Enemy
{
public:
    void Attack()
    {
        cout << "Boss 放大招" << endl;
    }
};

int main()
{
    Enemy* enemy = new Boss();

    // 没有 virtual 时,看指针类型
    // enemy 的类型是 Enemy*,所以调用 Enemy::Attack
    enemy->Attack();

    delete enemy;

    return 0;
}

输出:

Enemy 普通攻击

虽然真实对象是 Boss,但因为 Attack 不是虚函数,所以调用的是基类版本。

加上 virtual 后

c
#include <iostream>
using namespace std;

class Enemy
{
public:
    // virtual 表示这是虚函数
    // 允许派生类重写,并支持运行时多态
    virtual void Attack()
    {
        cout << "Enemy 普通攻击" << endl;
    }

    // 基类析构函数建议写成 virtual
    // 防止通过基类指针 delete 派生类对象时析构不完整
    virtual ~Enemy()
    {
    }
};

class Boss : public Enemy
{
public:
    // override 表示重写基类虚函数
    // 如果函数签名写错,编译器会报错
    void Attack() override
    {
        cout << "Boss 放大招" << endl;
    }
};

int main()
{
    Enemy* enemy = new Boss();

    // 有 virtual 时,看真实对象类型
    // 真实对象是 Boss,所以调用 Boss::Attack
    enemy->Attack();

    delete enemy;

    return 0;
}

输出:

c
Boss 放大招

这就是虚函数的核心:

指针类型是 Enemy*
真实对象是 Boss
调用虚函数时执行 Boss 的版本

虚函数解决了什么问题

虚函数实现的是运行时多态。

c
同一个接口
不同对象
不同表现

比如:

c
void DoAttack(Enemy* enemy)
{
    enemy->Attack();
}

你可以传入:

c
DoAttack(new Slime());
DoAttack(new Archer());
DoAttack(new Boss());

只要它们都继承 Enemy 并重写 Attack,就能执行各自的逻辑。

这让代码更灵活:

调用者只依赖 Enemy 接口
不用写一堆 if 判断具体类型

虚函数底层大概怎么实现

一般编译器会用:

c
vptr:虚函数表指针
vtable:虚函数表

简单理解:

有虚函数的类,对象内部通常会多一个 vptr。
vptr 指向这个类对应的 vtable。
vtable 里存着虚函数真正要调用的地址。

当你调用:

c
enemy->Attack();

大概过程是:

c
1. 通过 enemy 找到真实对象
2. 通过对象里的 vptr 找到虚函数表
3. 在虚函数表里找到 Attack 对应的函数地址
4. 调用 Boss::Attack

所以虚函数是运行时决定调用哪个函数,这叫动态绑定。

virtual、override、final

virtual 写在基类:

c
class Enemy
{
public:
    virtual void Attack();
};

意思是:

这个函数支持多态。

override 写在派生类:

c
class Boss : public Enemy
{
public:
    void Attack() override;
};

意思是:

我明确要重写基类虚函数。
如果没重写成功,请编译器报错。

final 可以阻止继续重写:

c
class Boss : public Enemy
{
public:
    void Attack() final;
};

意思是:

Attack 到 Boss 这里为止,后面的子类不能再重写。

为什么推荐写 override

如果你不写 override,可能发生这种错误:

c
class Enemy
{
public:
    virtual void Attack(int damage)
    {
    }
};

class Boss : public Enemy
{
public:
    // 错误:参数不一样
    // 这不是重写,而是定义了一个新函数
    void Attack()
    {
    }
};

你以为重写成功了,其实没有。 如果写 override

c
void Attack() override
{
}

编译器会直接报错。 所以现代 C++ 推荐派生类重写虚函数时加 override

虚析构函数非常重要

如果一个类有虚函数,通常说明它会被当作基类使用。 这时析构函数也应该是虚的。

错误例子:

c
class Enemy
{
public:
    virtual void Attack()
    {
    }

    // 错误:基类析构不是 virtual
    ~Enemy()
    {
        cout << "Enemy 析构" << endl;
    }
};

class Boss : public Enemy
{
public:
    ~Boss()
    {
        cout << "Boss 析构" << endl;
    }
};

int main()
{
    Enemy* enemy = new Boss();

    // 如果基类析构不是 virtual
    // 通过基类指针 delete 派生类对象,可能析构不完整
    delete enemy;

    return 0;
}

正确:

c
class Enemy
{
public:
    virtual void Attack()
    {
    }

    virtual ~Enemy()
    {
        cout << "Enemy 析构" << endl;
    }
};

面试常说:

如果一个类要作为多态基类使用,析构函数应该声明为 virtual。

虚函数的代价

虚函数有一点成本:

对象里通常多一个 vptr
类有一张 vtable
调用时多一次间接跳转
可能影响内联优化

但多数业务场景下,这个成本可以接受。 不要为了省这一点成本,把本来需要多态的设计写成一堆 if-else

什么时候用虚函数

适合:

基类定义统一接口
子类有不同实现
希望通过基类指针/引用调用真实对象行为
运行时才知道具体类型

比如:

c
Enemy::Attack
Skill::Cast
UIWindow::Open
Shape::Draw
Component::Update

不适合:

不需要继承
不需要运行时多态
性能极端敏感的底层小函数
构造函数

注意:构造函数不能是虚函数。 因为对象构造时,派生类部分还没构造好,无法按完整派生类进行多态调用。

常见坑

1. 基类函数忘记加 virtual,结果没有多态。
2. 派生类忘记写 override,签名错了也没发现。
3. 基类析构函数不是 virtual,却通过基类指针 delete 派生类。
4. 在构造函数/析构函数里调用虚函数,以为会调用派生类版本。
5. 以为函数名一样就是重写,不对,签名也要匹配。

面试高分回答

NOTE

虚函数是 C++ 实现运行时多态的机制。基类中用 virtual 声明函数,派生类可以 override 重写它。当通过基类指针或引用调用虚函数时,实际调用哪个版本不是看指针类型,而是看对象的真实类型。

底层通常通过 vptr 和 vtable 实现。有虚函数的对象内部会有虚函数表指针,指向对应类的虚函数表。调用虚函数时,通过虚函数表找到真正的函数地址,因此属于动态绑定。

虚函数适合用在基类定义接口、派生类提供不同行为的场景,比如 Enemy::Attack、Shape::Draw。

需要注意,如果一个类作为多态基类使用,析构函数应该声明为 virtual,避免通过基类指针删除派生类对象时析构不完整。派生类重写虚函数时推荐加 override,让编译器帮忙检查。

最短记忆版

虚函数 = virtual 修饰的可重写函数。
作用 = 基类指针/引用调用时,根据真实对象类型决定执行哪个版本。
底层 = vptr + vtable。
多态基类析构函数要 virtual。

虚表和虚指针是什么?

一句话理解: 虚指针 vptr 通常藏在对象里,指向这个对象真实类型对应的虚表 vtable;虚表里存的是虚函数地址。调用虚函数时,程序通过 vptr → vtable → 函数地址 找到真正要执行的函数。

vtable-vptr-explained

先说结论

vptr:
虚函数表指针,通常存在于对象内部。

vtable:
虚函数表,通常每个有虚函数的类一张。

虚函数调用:
通过对象的 vptr 找到 vtable,再从 vtable 里找到真正函数地址。

注意: vptr/vtable 是主流编译器实现虚函数的常见方式,但 C++ 标准没有强制规定对象内存必须这样布局。面试里按这个模型回答是可以的。

为什么需要虚表和虚指针

假设:

c
class Enemy
{
public:
    virtual void Attack()
    {
        cout << "Enemy 攻击" << endl;
    }
};

class Boss : public Enemy
{
public:
    void Attack() override
    {
        cout << "Boss 放大招" << endl;
    }
};

调用:

c
Enemy* enemy = new Boss();

enemy->Attack();

问题来了:

c
enemy 的静态类型是 Enemy*
真实对象类型是 Boss
到底调用 Enemy::Attack 还是 Boss::Attack?

因为 Attack 是虚函数,所以程序运行时会根据真实对象类型来决定。 这就需要 vptrvtable 帮忙。

vtable 是什么

虚表可以理解成“虚函数地址表”。

对于 Enemy,可能有一张表:

c
Enemy vtable
[0] Enemy::Attack
[1] Enemy::~Enemy

对于 Boss,可能有一张表:

c
Boss vtable
[0] Boss::Attack
[1] Boss::~Boss

当子类重写虚函数时,子类虚表里对应位置会换成子类函数地址。

也就是说:

c
Enemy 的 Attack 表项指向 Enemy::Attack
Boss 的 Attack 表项指向 Boss::Attack

vptr 是什么

vptr 是对象内部的一个隐藏指针。

如果一个类有虚函数,那么它的对象通常会多一个隐藏成员:

c
vptr

它指向当前对象真实类型的虚表。

比如:

c
Enemy* enemy = new Boss();

虽然 enemyEnemy*,但真实对象是 Boss。 这个 Boss 对象内部的 vptr 指向的是:

c
Boss vtable

所以调用:

c
enemy->Attack();

会走到:

c
Boss vtable → Boss::Attack

虚函数调用过程

c
enemy->Attack();

大概可以理解为:

1. 通过 enemy 找到对象内存
2. 从对象内存里取出 vptr
3. 通过 vptr 找到 Boss 的 vtable
4. 在 vtable 里找到 Attack 对应的函数地址
5. 调用 Boss::Attack

所以虚函数调用是运行期决定的,也叫动态绑定。

代码示例

c
#include <iostream>
using namespace std;

class Enemy
{
public:
    virtual void Attack()
    {
        cout << "Enemy 攻击" << endl;
    }

    virtual ~Enemy()
    {
        cout << "Enemy 析构" << endl;
    }
};

class Boss : public Enemy
{
public:
    void Attack() override
    {
        cout << "Boss 放大招" << endl;
    }

    ~Boss() override
    {
        cout << "Boss 析构" << endl;
    }
};

int main()
{
    Enemy* enemy = new Boss();

    // 静态类型是 Enemy*
    // 真实对象是 Boss
    // 通过 vptr/vtable 调用 Boss::Attack
    enemy->Attack();

    // 因为 Enemy 析构是 virtual
    // 所以 delete 时会先调 Boss 析构,再调 Enemy 析构
    delete enemy;

    return 0;
}

输出:

c
Boss 放大招
Boss 析构
Enemy 析构

对象里真的会多东西吗

通常会。

比如:

c
class A
{
public:
    int x;
};

没有虚函数时,对象里可能只有:

c
x

如果加虚函数:

c
class B
{
public:
    virtual void F()
    {
    }

    int x;
};

对象里通常变成:

c
vptr
x

所以有虚函数的对象通常会更大一点。 在 64 位程序里,一个指针通常是 8 字节,因此对象可能多出 8 字节左右。

虚表属于对象还是类

简单说:

vptr 属于对象。
vtable 属于类。

更准确地说:

c
每个对象通常有自己的 vptr。
同一个类的多个对象通常共享同一张 vtable。

比如:

c
Boss b1;
Boss b2;
Boss b3;

这三个对象通常各自有一个 vptr,但它们的 vptr 都指向同一张 Boss vtable

普通函数为什么不需要虚表

普通非虚函数在编译期就能确定调用谁。

c
class Enemy
{
public:
    void Attack()
    {
    }
};

调用:

c
Enemy* enemy = new Boss();

enemy->Attack();

如果 Attack 不是虚函数,编译器看见 enemyEnemy*,就直接绑定到:

c
Enemy::Attack

这叫静态绑定。

虚函数不同:

c
编译期只知道是 Enemy*
运行期才知道真实对象是 Boss

所以需要虚表动态查找。

多继承时会更复杂

单继承时,通常一个对象一个主要 vptr 就够理解。

但多继承时:

c
class A
{
public:
    virtual void FA()
    {
    }
};

class B
{
public:
    virtual void FB()
    {
    }
};

class C : public A, public B
{
};

C 对象里可能有多个 vptr,分别服务不同的基类子对象。 这也是为什么 C++ 多继承对象布局会复杂。

面试基础回答不用展开太深,但可以补一句:

单继承下通常一个 vptr;多继承或虚继承下可能有多个 vptr,布局更复杂。

构造和析构时 vptr 会变化

构造派生类对象时,大概经历:

先构造基类部分
再构造派生类部分

在基类构造期间,对象还没有完全变成派生类。 所以在构造函数里调用虚函数,不会按完整派生类多态调用。

例子:

c
class Base
{
public:
    Base()
    {
        // 不建议在构造函数里调用虚函数
        // 这里通常调用 Base::Func
        Func();
    }

    virtual void Func()
    {
        cout << "Base::Func" << endl;
    }
};

class Derived : public Base
{
public:
    void Func() override
    {
        cout << "Derived::Func" << endl;
    }
};

构造/析构函数里调用虚函数是面试常见坑:

构造和析构过程中,不要依赖虚函数表现出完整多态。

虚析构和虚表

如果基类析构函数是虚函数:

c
class Base
{
public:
    virtual ~Base()
    {
    }
};

那么析构函数地址也会进入虚表。 这样:

c
Base* p = new Derived();
delete p;

才能通过虚表找到正确的派生类析构逻辑。

这就是为什么多态基类通常需要虚析构函数。

虚表和虚指针的代价

虚函数机制有成本:

1. 对象通常多一个 vptr 指针。
2. 类需要一张 vtable。
3. 调用虚函数时多一次间接寻址。
4. 有时影响内联优化。

但通常这点成本可以接受。 虚函数换来的是运行时多态和代码扩展性。

常见面试问题

有虚函数的类对象一定有 vptr 吗?
主流编译器通常会这样实现,但标准不强制。

vtable 是每个对象一份吗?
通常不是。vtable 通常是类级别共享的。

vptr 是每个对象一份吗?
通常是。每个多态对象需要知道自己对应哪张虚表。

虚函数为什么能实现多态?
因为通过基类指针调用时,会根据对象内部 vptr 找到真实类型的 vtable。

构造函数能是虚函数吗?
不能。对象还没构造完成,没法按派生类多态构造。

析构函数为什么常常要 virtual?
为了通过基类指针 delete 派生类对象时,能正确调用派生类析构。

面试高分回答

NOTE

虚表 vtable 是编译器为包含虚函数的类生成的一张函数地址表,表里保存虚函数对应的实际函数地址。派生类如果重写虚函数,它的虚表对应表项会指向派生类版本。

虚指针 vptr 通常存在于对象内部,指向该对象真实类型对应的虚表。同一个类的多个对象通常共享同一张虚表,但每个对象通常各自有一个 vptr。

当通过基类指针或引用调用虚函数时,程序会先通过对象的 vptr 找到 vtable,再从 vtable 中取出对应函数地址并调用,所以能实现运行时多态。

需要注意 vptr/vtable 是主流编译器常见实现方式,C++ 标准不强制具体布局。虚函数机制会带来对象额外指针和间接调用开销,但换来了动态绑定和多态能力。

最短记忆版

vptr = 对象里的虚表指针。
vtable = 类的虚函数地址表。
虚函数调用 = 对象 → vptr → vtable → 真正函数。
vptr 通常每个对象一份,vtable 通常每个类一份。

为什么基类析构函数通常要声明为虚函数?

一句话理解: 如果一个类要作为多态基类使用,并且可能通过 Base* 删除 Derived 对象,那么基类析构函数必须声明为 virtual,否则派生类析构可能不会正确执行,严格说是未定义行为。

virtual-base-destructor-why

问题代码

c
#include <iostream>
using namespace std;

class Base
{
public:
    ~Base()
    {
        cout << "Base 析构" << endl;
    }
};

class Derived : public Base
{
private:
    int* data;

public:
    Derived()
    {
        data = new int[100];
    }

    ~Derived()
    {
        delete[] data;
        cout << "Derived 析构" << endl;
    }
};

int main()
{
    Base* p = new Derived();

    // 危险:Base 的析构函数不是 virtual
    delete p;

    return 0;
}

你希望析构顺序是:

c
Derived 析构
Base 析构

但如果基类析构不是 virtual,通过 Base* 删除 Derived 对象时,程序不一定能正确调用 Derived::~Derived()

结果可能是:

c
Derived 里的 data 没释放
资源泄漏
行为不可预测

严格说,这属于未定义行为,不只是“少调一个析构函数”这么简单。

正确写法

c
#include <iostream>
using namespace std;

class Base
{
public:
    // 多态基类的析构函数应该声明为 virtual
    virtual ~Base()
    {
        cout << "Base 析构" << endl;
    }
};

class Derived : public Base
{
private:
    int* data;

public:
    Derived()
    {
        data = new int[100];
    }

    ~Derived() override
    {
        delete[] data;
        cout << "Derived 析构" << endl;
    }
};

int main()
{
    Base* p = new Derived();

    // 正确:先调用 Derived 析构,再调用 Base 析构
    delete p;

    return 0;
}

输出:

c
Derived 析构
Base 析构

为什么 virtual 能解决

virtual ~Base() 会让析构也走虚函数机制。

也就是:

c
Base* p = new Derived();
delete p;

执行 delete p 时,程序会通过虚表找到真实对象类型 Derived 的析构函数。

正确析构链是:

c
Derived::~Derived()
Base::~Base()

这样派生类自己的资源先释放,再释放基类部分。

什么时候必须写虚析构

满足这些情况就应该写:

这个类有虚函数
这个类会被继承
这个类会通过基类指针或引用使用
这个类可能通过 Base* delete 派生类对象

典型例子:

c
class Enemy
{
public:
    virtual void Attack() = 0;

    virtual ~Enemy()
    {
    }
};

如果一个类已经有虚函数,比如 Attack(),通常它就是多态基类。 这种类的析构函数基本也应该是虚的。

什么时候不一定需要

如果这个类不会被当作多态基类使用,就不一定需要虚析构。

比如:

c
class Vector3
{
public:
    float x;
    float y;
    float z;
};

这种普通值类型不需要虚析构。

还有一种设计是:

c
class Base
{
protected:
    ~Base()
    {
    }
};

把析构函数设为 protected 且非虚,意思是:

c
不允许外部通过 Base* delete。

常见原则是:

c
多态基类:public virtual destructor。
不允许多态删除:protected non-virtual destructor。

为什么派生类资源会泄漏

派生类可能持有:

堆内存
文件句柄
网络连接
纹理资源
音频资源
GPU Buffer

系统句柄

这些通常在派生类析构函数里释放。

如果派生类析构不执行,这些资源就可能泄漏。

c
class TextureResource : public Resource
{
private:
    unsigned char* pixels;

public:
    ~TextureResource()
    {
        delete[] pixels;
    }
};

如果只调 Resource::~Resource(),不调 TextureResource::~TextureResource()pixels 就没机会释放。

虚析构的代价

虚析构会带来一点点成本:

类通常需要 vtable
对象通常需要 vptr
析构通过虚函数机制动态分派

但如果这个类已经有虚函数,本来就已经有虚表机制了。 这时候再把析构设为虚函数,额外成本通常很小,但安全性提升很大。

常见误区

c
1. 以为只要基类没有资源,就不用 virtual 析构。
   不对,派生类可能有资源。

2. 以为 delete Base* 最多只是少打印一句日志。
   不对,严格说是未定义行为。

3. 以为所有类析构都要 virtual。
   不对,只有作为多态基类时才通常需要。

4. 以为 override 只能用于普通函数。
   不对,析构函数也可以写 override。

面试高分回答

NOTE

基类析构函数通常声明为 virtual,是为了保证通过基类指针删除派生类对象时,能够正确调用派生类析构函数。

如果基类析构不是 virtual,代码 Base* p = new Derived(); delete p; 会产生未定义行为,派生类析构函数可能不会执行,导致派生类持有的堆内存、句柄、文件、纹理等资源无法释放。

声明为 virtual 后,delete 会通过虚表进行动态绑定,先调用 Derived::~Derived(),再调用 Base::~Base(),析构顺序完整正确。

经验上,只要一个类作为多态基类使用,比如有虚函数并可能通过基类指针管理对象生命周期,就应该提供 public virtual 析构函数。否则可以考虑 protected 非虚析构来禁止外部通过基类指针 delete。

最短记忆版

多态基类析构要 virtual。
原因:Base* 指向 Derived 时,delete Base* 才能先调 ~Derived,再调 ~Base。
否则派生类资源可能泄漏,严格说是未定义行为。

多继承可能有什么问题?

一句话理解: C++ 多继承允许一个类同时继承多个基类,但会带来名字冲突、菱形继承、重复基类对象、对象布局复杂、构造析构顺序复杂等问题。所以工程里通常谨慎使用,尤其要少用“带状态的多继承”。

multiple-inheritance-problems

多继承是什么

c
class A
{
};

class B
{
};

class C : public A, public B
{
};

C 同时继承了 AB,这就是多继承。

它的好处是:

一个类可以同时复用多个基类能力。

但它的问题也很多。

问题一:名字冲突 / 二义性

c
#include <iostream>
using namespace std;

class A
{
public:
    void Run()
    {
        cout << "A::Run" << endl;
    }
};

class B
{
public:
    void Run()
    {
        cout << "B::Run" << endl;
    }
};

class C : public A, public B
{
};

int main()
{
    C c;

    // 错误:编译器不知道调用 A::Run 还是 B::Run
    // c.Run();

    c.A::Run();
    c.B::Run();

    return 0;
}

问题在于:

c
A 有 Run
B 也有 Run
C 同时继承 A 和 B
调用 c.Run() 时就产生二义性

解决方式:

c
c.A::Run();
c.B::Run();

或者在 C 中统一封装一个入口:

c
class C : public A, public B
{
public:
    void Run()
    {
        A::Run();
    }
};

问题二:菱形继承

这是多继承最经典的问题。

c
class A
{
public:
    int value;
};

class B : public A
{
};

class C : public A
{
};

class D : public B, public C
{
};

继承结构是:

    A
   / \
  B   C
   \ /
    D

这叫菱形继承。

问题是: D 里面会有两份 A

c
一份来自 B::A
一份来自 C::A

所以:

c
D d;

// 错误:不知道访问 B 那边的 A::value,还是 C 那边的 A::value
// d.value = 10;

必须写:

c
d.B::value = 10;
d.C::value = 20;

这就很混乱。

问题三:重复基类子对象

菱形继承里,D 默认包含两份 A 子对象。

c
D
├── B
│   └── A
└── C
    └── A

这会导致:

数据重复
内存变大
状态不一致
访问二义性

比如:

c
B::A::value = 10
C::A::value = 20

DA::value 到底是多少? 它其实有两份。

解决菱形继承:虚继承

可以用虚继承:

c
#include <iostream>
using namespace std;

class A
{
public:
    int value;
};

class B : virtual public A
{
};

class C : virtual public A
{
};

class D : public B, public C
{
};

int main()
{
    D d;

    // 现在 D 里只有一份共享的 A
    d.value = 10;

    cout << d.value << endl;

    return 0;
}

virtual public A 的意思是:

B 和 C 虚继承 A。
最终 D 中只保留一份 A 子对象。

但虚继承也有代价:

对象布局更复杂
构造规则更复杂
可能有额外指针
理解成本更高

所以虚继承能解决问题,但不是让设计变简单。

问题四:构造和析构顺序更复杂

多继承时,基类构造顺序看继承列表:

c
class A
{
public:
    A()
    {
        cout << "A 构造" << endl;
    }
};

class B
{
public:
    B()
    {
        cout << "B 构造" << endl;
    }
};

class C : public A, public B
{
public:
    C()
    {
        cout << "C 构造" << endl;
    }
};

构造顺序:

c
A 构造
B 构造
C 构造

因为继承列表是:

c
class C : public A, public B

析构顺序反过来:

c
C 析构
B 析构
A 析构

如果有虚继承,规则还会更复杂: 虚基类由最派生类负责构造。

问题五:对象内存布局复杂

单继承对象一般比较好理解:

Base 部分
Derived 部分

多继承对象可能是:

Base1 子对象
Base2 子对象
Derived 自己的成员
多个 vptr
指针偏移调整
虚基类指针

比如:

c
class A
{
public:
    virtual void FA()
    {
    }
};

class B
{
public:
    virtual void FB()
    {
    }
};

class C : public A, public B
{
};

C 可能有多个虚指针。 当你把 C* 转成 A*B* 时,指针地址可能需要调整。

所以多继承会让底层对象布局和调试都更复杂。

问题六:维护成本高

多继承容易让一个类同时承担太多身份。

比如:

c
class Player :
    public Character,
    public Damageable,
    public Serializable,
    public NetworkObject,
    public EventListener
{
};

看起来复用很多能力,但后面可能变成:

这个类到底是谁负责初始化?
哪个基类拥有资源?
哪个基类析构先执行?
哪个接口函数冲突了?
哪些状态来自哪个父类?

类关系会越来越难看懂。

什么时候多继承可以接受

比较安全的场景:

多个纯接口类
基类不持有数据
基类没有复杂资源
只表达能力,不共享状态

比如:

c
class IUpdateable
{
public:
    virtual void Update() = 0;
    virtual ~IUpdateable() = default;
};

class IRenderable
{
public:
    virtual void Render() = 0;
    virtual ~IRenderable() = default;
};

class Player : public IUpdateable, public IRenderable
{
public:
    void Update() override
    {
    }

    void Render() override
    {
    }
};

这种多继承相对可控,因为:

IUpdateable 没有成员状态
IRenderable 没有成员状态
只是接口

这和 C# 里一个类实现多个接口有点像。

什么时候要谨慎

要谨慎:

多个基类都有成员变量
多个基类都有资源所有权
多个基类都有复杂构造析构
多个基类有同名函数
继承结构出现菱形

这种情况下,优先考虑组合。

组合替代多继承

与其这样:

c
class Player : public PhysicsObject, public AudioObject
{
};

可以这样:

c
class PhysicsComponent
{
public:
    void Simulate()
    {
    }
};

class AudioComponent
{
public:
    void PlaySound()
    {
    }
};

class Player
{
private:
    PhysicsComponent physics;
    AudioComponent audio;

public:
    void Update()
    {
        physics.Simulate();
        audio.PlaySound();
    }
};

组合的好处:

关系更清楚
生命周期更好控制
减少名字冲突
减少对象布局复杂度
更容易替换组件

经典原则:

优先组合,谨慎继承。

常见坑

1. 多个基类有同名函数,调用产生二义性。
2. 菱形继承导致共同基类有两份。
3. 忘记用虚继承解决共享基类问题。
4. 虚继承后构造顺序又变复杂。
5. 多个基类都有虚函数,对象里可能多个 vptr。
6. 多个基类都有资源,析构和所有权很难管。
7. 滥用多继承导致类职责混乱。

面试高分回答

WARNING

C++ 支持多继承,但多继承会带来不少问题。

第一是名字二义性,比如两个基类都有同名函数,派生类调用时编译器不知道该用哪个,需要显式指定作用域或在派生类中重新封装。

第二是菱形继承问题,比如 B 和 C 都继承 A,D 又继承 B 和 C,那么 D 中默认会有两份 A 子对象,导致数据重复和访问二义性。可以通过虚继承解决,让最终派生类只保留一份 A,但虚继承会让对象布局和构造规则更复杂。

第三是对象模型复杂。多继承下可能有多个基类子对象、多个 vptr,基类指针转换时可能需要指针偏移,调试和理解成本都会提高。

第四是构造析构顺序和资源所有权更难管理。如果多个基类都有状态和资源,维护成本很高。

所以工程上我会谨慎使用有状态多继承。比较推荐的用法是多个纯接口类的多继承,或者使用组合替代继承,把能力拆成成员对象来管理。

最短记忆版

多继承问题 = 同名二义性 + 菱形继承 + 重复基类子对象 + 对象布局复杂 + 构造析构复杂。
接口式多继承可以,有状态多继承慎用;能用组合就优先组合。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0