Skip to content

class 基础与经典问题(总览) ​

一篇串起 class 的高频面试点:class vs struct/union、访问控制、特殊成员函数(0/3/5 法则)、构造/析构顺序、copy-and-swap、虚函数、类模板、经典陷阱。 文中 sizeof、时序等数据均取自本机实测(GCC 16.2.1 / x86-64 / libstdc++)。 需要深挖的点直接跳子文档(vtable.md、virtual-destructor.md、polymorphism.md、concepts.md、模板基础)。


一句话总览 ​

概念结论
class / struct二者能力完全等价(成员函数、继承、虚函数、模板、运算符重载皆可),只差两个默认值:成员默认访问级别与默认继承方式
public / protected / private三种访问级别,区别仅在可见范围:本类 / 派生类 / 外部
六个特殊成员函数编译器默认生成;用户声明其中一个,其余若干的隐式生成规则随之改变(见「三、特殊成员函数」)
0/3/5 法则要么一个都不声明,要么成套声明(析构 + 拷贝构造 + 拷贝赋值,加移动即五个);首选 0——资源交给 string / vector / 智能指针承载
copy-and-swap拷贝在形参构造阶段完成(失败则 *this 未被修改),函数体只做 noexcept 的 swap ⇒ 强异常保证 + 自赋值安全
构造 / 析构顺序构造:虚基类 → 直接基类 → 非静态成员(均按声明顺序)→ 函数体;析构完全逆序 ⇒ 基类先于派生类构造、后于派生类析构
虚函数多态依赖对象真实类型;按值传参只复制基类子对象 ⇒ 切片
vtable / vptr虚调用 = 一次间接跳转:多一次访存,通常无法内联
构造 / 析构中调用虚函数绑定到当前构造(析构)层次的版本,不会派发到最终派生类
虚函数默认实参静态绑定(默认实参按指针/引用的静态类型确定)——常见易错点
类模板每个实例化都是独立的类,各有自己的静态成员与代码
空类 sizeof == 1至少占 1 字节,保证不同对象地址不同(&a != &b)
成员函数是否占对象空间不占:对象只放数据成员(有虚函数时另加一个 vptr)

一句话概括:类 = 数据 + 不变量(由访问控制维持);默认生成的六个特殊成员函数要么全部沿用、要么成套接管(0/3/5);运行时替换行为用虚函数(代价:一个 vptr + 一次间接跳转);同一套代码适配多种类型用类模板(代价:每个类型各生成一份代码)。


一、class vs struct vs union ​

维度structclass
默认成员访问publicprivate
默认继承方式public 继承private 继承
能力(成员函数 / 继承 / 虚函数 / 模板 / 运算符重载)完全等价完全等价
习惯用法数据聚合、POD、接口参数包有封装与不变量的对象

唯一区别就是两个默认值。 面试如果追问"还有别的区别吗"——没有,struct 也可以有虚函数、构造析构、private 段、模板;反之 class 也可以只放 public 数据。

structunion
成员布局顺序共存(大小 ≈ 成员和 + padding)共享起始地址(大小 ≈ 最大成员)
同时有效全部同一时刻只有一个活跃成员
典型用途组合对象省内存的互斥存储、协议报文字段
  • union 的活跃成员切换需自己管构造/析构(C++ 里成员可以是类对象,但要手动 placement new + 显式析构)——工程上优先用 std::variant(类型安全、能查哪个分支活着)。
  • struct 的对齐/位域细节 → memory/alignment.md。

记忆锚点:struct/class 只差"默认 public/private + 默认继承方式";union 是"共享地址、同时只有一个活跃成员"。


二、访问控制与类内元素 ​

访问级别本类派生类外部/友元
public✅✅✅(外部)
protected✅✅❌
private✅❌❌
  • 友元(friend) 打破封装的定向例外:友元函数/类可访问私有成员。友元关系不传递、不继承。
  • 类内定义的友元函数是"隐藏友元"(hidden friend):只能通过 ADL 找到,普通限定名查找看不见——这是有意为之(避免污染命名空间,也让运算符重载只在实参类型参与查找时可见)。例如 friend void touch(const S&) 定义在类内:touch(s) 可用,::touch(s) 找不到。
  • static 成员:属于类而非对象——不占对象空间、没有 this、可以用类名限定访问;static 数据成员类内声明、类外定义(C++17 起可用 static inline int n = 0; 直接类内定义,避免 ODR 麻烦)。
  • mutable:允许在 const 成员函数里修改(缓存、互斥锁、计数器等"不影响逻辑状态"的成员)。
  • const 成员函数:this 变成 const T*,不能改非 mutable 成员,也不能调用非 const 成员函数。可以按 const 与否重载,const 对象选 const 版。
  • 成员函数不占对象空间:只含成员函数(含成员模板)的类 sizeof(S) == 1——函数放代码段,对象只放数据(+ vptr)。
  • 类内定义成员函数 = 隐式 inline(模板成员同理,走 vague linkage / weak symbol,故能放头文件)。
cpp
class Widget {
    int cached_ = 0;
public:
    int size() const { return cached_; }        // const 版
    static int instances();                     // 静态成员函数:无 this
    mutable std::mutex m_;                      // const 函数里也能加锁
    friend void swap(Widget& a, Widget& b) noexcept;   // 友元打破封装
};

三、特殊成员函数:0/3/5 法则 ​

六个特殊成员函数 ​

#函数默认行为何时必须手写
1默认构造逐成员默认构造需要特定初值 / 不变量
2析构逐成员析构持有裸资源
3拷贝构造逐成员浅拷贝需深拷贝
4拷贝赋值逐成员浅拷贝需深拷贝
5移动构造(C++11)逐成员移动(无移动则退化为拷贝)优化 / 转移所有权
6移动赋值(C++11)同上优化 / 转移所有权

三/五法则:只要需要自定义析构、拷贝构造、拷贝赋值中的一个,通常三个都要(Rule of Three);C++11 加上两个移动就是 Rule of Five。反过来——只写析构不写拷贝,编译器生成的拷贝是浅拷贝,两个对象共享同一裸指针 → 双重释放。

Rule of Zero(首选):资源全部交给 std::string/vector/unique_ptr 等 RAII 成员承载,自己不声明任何一个特殊成员函数,编译器默认版本全部正确。详见 lifetime.md、idioms.md。

隐式生成 / 删除的细则(易考) ​

你声明了…编译器对其它特殊成员的处理
拷贝构造 或 拷贝赋值移动构造/移动赋值不再隐式生成;对右值改用拷贝(moves == 0、copies == 1)
移动构造 或 移动赋值拷贝构造/拷贝赋值被定义为 = delete(is_copy_constructible == false)
析构函数移动操作不再隐式生成(拷贝照常生成,仍是浅拷贝)
有 const 或引用数据成员拷贝/移动赋值被删除(无法给 const/引用重新赋值);只能构造、不能赋值
有引用/const 成员而想赋值只能自己写语义(或改用指针/reference_wrapper)
cpp
struct OnlyCopy { OnlyCopy() = default; OnlyCopy(const OnlyCopy&) {} };
static_assert(std::is_move_constructible_v<OnlyCopy>);   // true —— 但走的是拷贝构造

struct OnlyMove { OnlyMove() = default; OnlyMove(OnlyMove&&) {} };
static_assert(!std::is_copy_constructible_v<OnlyMove>);  // 拷贝被 delete

= default / = delete:=default 显式要回默认实现(例如声明了析构还想保留移动,就得手动 =default 补上);=delete 明确禁用(禁拷贝、禁隐式转换)。explicit 用于单参构造/转换运算符,阻断隐式转换。


四、构造与析构 ​

初始化列表 vs 函数体内赋值 ​

cpp
class A {
    const int c_;            // 只能用初始化列表
    int& r_;                 // 引用只能用初始化列表
    Base b_;                 // 想调带参构造只能用初始化列表
public:
    A(int c, int& x, int v) : c_(c), r_(x), b_(v) {}   // 在进入函数体前完成
};
  • const 成员、引用成员、没有默认构造的成员 → 只能初始化列表。
  • 函数体内赋值是"先默认构造、再赋值",多一次构造/赋值开销,且对上述三类成员不可行。
  • 初始化顺序 = 成员声明顺序(不是初始化列表书写顺序!)——列表里写反了会有"用未初始化成员初始化另一个成员"的坑,编译器一般给 -Wreorder 警告。

构造/析构顺序 ​

  • 构造:虚基类 → 直接基类(按声明顺序)→ 成员(按声明顺序)→ 构造函数体。
  • 析构:完全逆序。
  • 因此基类先于派生类构造、后于派生类析构——这也是"基类析构必须 virtual"的直接原因(见 virtual-destructor.md)。
  • 构造函数中抛异常:已构造完的基类子对象和成员会被析构,但对象自身的析构函数不会被调用 → 资源要靠 RAII 成员兜住。
  • 其他:委托构造(C++11,一个构造函数调另一个)、explicit、聚合初始化(C++17 起聚合可有基类)、default member initializer。

析构函数不该抛异常 ​

  • C++11 起析构函数隐式 noexcept(true):在里面 throw 直接 std::terminate(GCC 会给出 -Wterminate 警告:"in C++11 destructors default to noexcept")。
  • 就算显式写 noexcept(false),在栈展开(已有异常在传播)过程中再抛仍会 std::terminate(正常 try 里可捕获;栈展开期间抛出 → abort,退出码 134)。
  • 结论:析构只做释放,可能失败的操作要么吞掉、要么提供独立的 close()/commit() 让调用方显式处理。

五、copy-and-swap(拷贝交换惯用法) ​

要解决的问题:手写 operator= 有两个经典坑——① 自赋值 a = a 要先 if (this != &rhs);② 中途抛异常会让对象停在"改了一半"的破损状态(既不是旧值也不是新值)。

做法:形参按值接收,函数体里只做不抛异常的 swap。

cpp
class Buf {
    char* p_ = nullptr;
    size_t n_ = 0;
public:
    explicit Buf(const char* s) : n_(std::strlen(s)) {
        p_ = new char[n_ + 1];
        std::memcpy(p_, s, n_ + 1);
    }
    Buf(const Buf& o) : n_(o.n_) {              // 拷贝构造
        if (!o.p_) return;                      // 若这里抛异常:*this 一个字节都没动
        p_ = new char[n_ + 1];
        std::memcpy(p_, o.p_, n_ + 1);
    }
    Buf(Buf&& o) noexcept { swap(*this, o); }   // 移动构造也复用它
    Buf() = default;
    ~Buf() { delete[] p_; }

    friend void swap(Buf& a, Buf& b) noexcept {  // 关键:swap 必须不抛
        std::swap(a.p_, b.p_);
        std::swap(a.n_, b.n_);
    }
    Buf& operator=(Buf o) {                      // 按值接收:拷贝发生在函数外
        swap(*this, o);                          // 体内只剩不抛的操作
        return *this;                            // o 析构时释放旧资源
    }
    const char* c_str() const { return p_ ? p_ : ""; }
};

为什么同时解决两个问题:

  1. 自赋值安全:a = a 时形参先拷贝出副本,再和自己 swap——swap(a, a) 对同一对象是安全的(a = a 正常)。
  2. 强异常保证:拷贝失败发生在形参构造阶段,此时 *this 一个字节都没改(令拷贝构造抛异常时,赋值失败后对象仍是原值)。函数体里只有 noexcept 的 swap,不会失败 → 要么完整成功、要么原封不动。
  3. 附带红利:拷贝赋值和移动赋值共用同一个实现——传左值走拷贝构造、传右值走移动构造。

代价与边界:

  • 按值传参意味着左值赋值多一次拷贝(可用移动缓解);对"拷贝昂贵且很少自赋值"的类型,代价可接受,换来的是正确性。
  • 强异常保证依赖拷贝构造本身能提供;如果拷贝构造自己就写坏了,copy-and-swap 救不了。
  • 含 const/引用成员的类不能用:它们无法 swap(拷贝赋值本来也是删除的)。
  • swap 必须是 noexcept——否则强保证失效、vector 等容器的移动优化也不生效。
  • 更省事的替代:Rule of Zero,让成员(vector/string)替你写这一切。

面试怎么问:为什么 copy-and-swap 能自动兼顾自赋值和强异常保证?→ 拷贝在形参构造阶段完成(失败则对象未动),函数体只有不抛的 swap,故状态要么全新、要么原样。详见 idioms.md、atomic-failure.md。


六、虚函数 ​

1. 基本语义 ​

  • virtual 使函数支持运行期多态:通过基类指针/引用调用时,走 vptr → vtable 找到对象实际类型的实现。
  • 按值传递不体现多态(对象切片):void f(Base b) 只拷基类部分,连 vptr 都不是派生类的 → 用 Base&/Base*。
  • override(C++11)让编译器检查"确实重写了基类虚函数",写错签名直接报错(不加则变成隐藏,见 concepts.md)。
  • final 禁止继续重写/继承,也给编译器**去虚化(devirtualization)**的机会。
  • 纯虚 = 0 → 抽象类,不能实例化;全是纯虚且无数据 = 接口类。

2. 底层:vtable / vptr ​

  • 每个含虚函数的类有一张 vtable(虚函数地址表,放只读数据段),每个对象头部有一个 vptr 指向它。
  • 单继承一个 vptr;多继承每个非虚基类子对象一个 vptr;虚继承还要 vbptr。细节 → vtable.md、inheritance-deep.md。
  • 虚调用 = 一次间接跳转(多一次内存读取 + 无法内联)→ 有轻微开销,这是"零开销"原则的例外:不用多态就不付这个成本(zero-overhead.md)。

3. 必背辨析 ​

问题答案
析构为什么要虚?基类指针 delete 派生类对象时,非虚析构只调用基类析构 → 派生类资源泄漏 / UB
构造能虚吗?不能:此时 vptr 还没指向最终类型,且"虚构造"语义无意义
构造/析构里调虚函数会多态吗?不会,绑定到当前正在构造/析构的那一层
默认实参呢?静态绑定:走基类声明的默认值 + 派生类的函数体
虚函数能是模板吗?不能(error: templates may not be 'virtual')——vtable 是固定槽位,模板实例化数量不受限
虚函数能 inline 吗?语法可以;编译期已知动态类型时能被去虚化内联,否则退化为间接调用
能是 static / private 吗?不能 static(没有 this);可以 private——NVI 惯用法(public 非虚接口调 private 虚实现)

4. 验证输出(构造/析构中调虚函数 + 默认实参) ​

cpp
struct B {
    B()  { who(); }          // 输出 B
    virtual ~B() { who(); }  // 输出 B
    virtual void who() const;
};
struct D : B {
    D()  { who(); }          // 输出 D
    ~D() override { who(); } // 输出 D(析构:先 D 后 B)
    void who() const override;
};
// 实际输出:B::B 里 who() -> B ;D::D 里 who() -> D ;D::~D 里 who() -> D ;B::~B 里 who() -> B

struct VBase { virtual int f(int x = 1) const { return x; } };
struct VDer  : VBase { int f(int x = 2) const override { return x * 10; } };
VBase* p = new VDer;
p->f();      // == 10 —— 函数体用 VDer 的,默认实参用 VBase 的 1(静态绑定)

面试怎么问:为什么在构造函数里调虚函数不会调到派生类实现?→ 构造期 vptr 逐层上移,当前只指向"正在构造的这一层"的 vtable;析构时对称回退。


七、类模板(class template) ​

1. 定义与实例化 ​

cpp
#include <cstddef>
#include <memory>
#include <utility>

template <typename T, typename Alloc = std::allocator<T>>
class MyVec {
    T* data_ = nullptr;
    size_t size_ = 0, cap_ = 0;
public:
    void push_back(const T& v);                             // 类内声明、类外定义
    T& at(size_t i) { return data_[i]; }                    // 非 const 版
    const T& at(size_t i) const { return data_[i]; }        // const 重载(见第二节)
    size_t size() const { return size_; }
    template <typename U> void assign(const MyVec<U>& o) {  // 成员函数模板(不能是 virtual)
        size_ = 0;
        for (size_t i = 0; i < o.size(); ++i) push_back(T(o.at(i)));
    }
    ~MyVec() { delete[] data_; }
};

template <typename T, typename Alloc>
void MyVec<T, Alloc>::push_back(const T& v) {               // 类外定义:要重写 template 头 + 全限定名
    if (size_ == cap_) {                                    // 扩容(细节见 vector 专题)
        size_t nc = cap_ ? cap_ * 2 : 1;
        T* nd = new T[nc];
        for (size_t i = 0; i < size_; ++i) nd[i] = std::move(data_[i]);
        delete[] data_;
        data_ = nd;
        cap_ = nc;
    }
    data_[size_++] = v;
}
  • 类模板不是类型,是生成类型的蓝图;MyVec<int> 才触发实例化。
  • 成员函数按需实例化(只有被用到才生成代码)→ 这也是模板定义必须放头文件的原因:别的翻译单元要用就得自己实例化(除非显式实例化)。
  • 类内定义的成员函数隐式 inline;模板实体走 vague linkage(weak symbol),多 TU 重复定义合法。
  • 代价:每个 TU 各实例化一份 → 二进制膨胀,用 extern template 抑制隐式实例化。
  • 接口约束(C++20 concepts)→ concepts.md。

2. 类模板的 static 成员:每个实例化一份 ​

cpp
template <typename T> struct Counter { static int n; T v; };
template <typename T> int Counter<T>::n = 0;

Counter<int>::n = 7;  Counter<double>::n = 9;   // 互不影响(7 / 9)

Counter<int>::n 和 Counter<double>::n 是两个完全独立的变量——这和"static 成员属于类"并不矛盾:模板实例化出的是不同类。

3. 特化与偏特化 ​

cpp
template <typename T, typename U> struct Pair      { /* 主模板 */ };
template <typename U>             struct Pair<int, U> { /* 偏特化 */ };
template <>                       struct Pair<int, char> { /* 全特化 */ };
  • 选择优先级:全特化 > 更特化的偏特化 > 主模板(Pair<double,double>→主模板、Pair<int,double>→偏特化、Pair<int,char>→全特化)。
  • 类模板可以偏特化;函数模板只能全特化,不能偏特化(要"偏特化效果"就写重载)——常考。详见 basics.md。

4. CTAD:类模板实参推导(C++17) ​

cpp
std::pair p{1, 2.0};            // 从构造函数实参推 → pair<int,double>
std::vector v{1, 2, 3};         // → vector<int>

template <typename T> struct Box { T v; Box(T x) : v(x) {} };
Box(const char*) -> Box<std::string>;   // 自定义「推导指引」

Box b1(3);        // → Box<int>
Box b2("hi");     // → Box<std::string>(typeid 确认)

C++17 之前只能写 std::pair<int, double> p{1, 2.0};;现在构造函数实参能推出模板参数。这也解释了 priority_queue.md 里的 lambda 比较器为什么能少写 decltype:std::priority_queue pq(cmp, std::vector<Task>{});。对照:函数模板靠实参推导(一直可以),类模板靠 CTAD(C++17 起)。

5. 其它必知 ​

  • 依赖名:T::value_type 编译器默认当值看,是类型要写 typename T::value_type;访问成员模板要 obj.template f<int>()。
  • 成员函数模板不能是 virtual(报错 templates may not be 'virtual')。
  • 模板类/函数的友元规则、模板参数包(typename... Ts)、NTTP(template <size_t N>)→ basics.md。
  • CRTP(编译期多态,template <typename D> class Base,static_cast<D*>(this))→ crtp.md。

八、经典陷阱速查 ​

陷阱结论 / 正确做法
空类大小1 字节(保证不同对象地址不同):sizeof(Empty) == 1
加了虚函数加一个 vptr(8 字节);int + virtual = 16(4 数据 + 8 vptr + 4 padding)
成员函数占空间吗不占:只含成员函数(含成员模板)的类 sizeof == 1
static 数据成员不占对象空间,全类共享一份(类模板则每实例化一份)
拷贝构造按值传参A(A o) 直接非法(GCC:invalid constructor; you probably meant A(const A&));必须写 A(const A&),否则既绑不上 const 对象/右值,语义上还会自递归
operator= 返回值返回 T& 以支持链式 a = b = c;别返回 const T&,否则无法移动
浅拷贝持裸指针的类默认拷贝是浅拷贝 → double free(Rule of 3/5)
对象切片按值传基类丢掉派生部分和多态 → 传 Base&/Base*
非虚析构 + 基类指针 deleteUB(GCC 有 -Wdelete-non-virtual-dtor 警告)
成员初始化顺序按声明顺序,不是初始化列表顺序
隐式转换单参构造默认允许隐式转换 → 加上 explicit
自赋值要么 if (this != &rhs),要么 copy-and-swap
构造函数里调虚函数静态绑定到本层,不是多态
析构抛异常隐式 noexcept → 直接 terminate;栈展开中再抛同样 terminate
const/引用成员拷贝/移动赋值被删除
delete this仅当对象是 new 出来、之后不再碰任何成员时才勉强合法;工程上禁止
虚函数默认实参静态绑定基类的默认值

九、自测清单 ​

  1. struct 和 class 到底差什么?为什么说它们能力等价?
  2. 六个特殊成员函数分别在什么情况下被隐式生成 / 被删除?(能背出第三章那张表)
  3. Rule of 0/3/5 各说什么?为什么 Rule of Zero 是首选?
  4. copy-and-swap 一次解决哪两个问题?为什么要求 swap 是 noexcept?哪些类用不了?
  5. 构造/析构顺序是什么?构造中抛异常时析构谁?
  6. 为什么析构要虚、构造不能虚、构造里调虚函数不生效?
  7. 虚函数的默认实参、override/final、private virtual(NVI)分别怎么理解?
  8. 类模板的 static 成员在 A<int> 和 A<double> 里是同一个吗?为什么?
  9. 类模板能偏特化吗?函数模板呢?CTAD 解决什么问题?
  10. 空类、只含成员函数的类、含一个 int 的类、含 int + 虚函数的类分别多大?为什么?

子文档 ​

文件内容
concepts.md重载/重写/隐藏、struct/class/union、访问控制、初始化列表、切片
polymorphism.md多态机制、静态/动态绑定
vtable.mdvtable/vptr 内存模型、各类对象大小、EBO
virtual-destructor.md虚析构/构造辨析、特殊成员函数与三/五规则
inheritance-deep.md多继承/菱形/虚继承、this 调整、thunk
rtti-typeinfo.mdtypeid / dynamic_cast / -fno-rtti
patterns/idioms.mdRule of Zero、copy-and-swap、scope guard、强类型
metaprogramming/basics.md模板:实例化、特化/偏特化、NTTP、依赖名

糌粑的面经笔记 · VitePress 版