Appearance
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
| 维度 | struct | class |
|---|---|---|
| 默认成员访问 | public | private |
| 默认继承方式 | public 继承 | private 继承 |
| 能力(成员函数 / 继承 / 虚函数 / 模板 / 运算符重载) | 完全等价 | 完全等价 |
| 习惯用法 | 数据聚合、POD、接口参数包 | 有封装与不变量的对象 |
唯一区别就是两个默认值。 面试如果追问"还有别的区别吗"——没有,struct 也可以有虚函数、构造析构、private 段、模板;反之 class 也可以只放 public 数据。
struct | union | |
|---|---|---|
| 成员布局 | 顺序共存(大小 ≈ 成员和 + 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_ : ""; }
};为什么同时解决两个问题:
- 自赋值安全:
a = a时形参先拷贝出副本,再和自己 swap——swap(a, a)对同一对象是安全的(a = a正常)。 - 强异常保证:拷贝失败发生在形参构造阶段,此时
*this一个字节都没改(令拷贝构造抛异常时,赋值失败后对象仍是原值)。函数体里只有noexcept的swap,不会失败 → 要么完整成功、要么原封不动。 - 附带红利:拷贝赋值和移动赋值共用同一个实现——传左值走拷贝构造、传右值走移动构造。
代价与边界:
- 按值传参意味着左值赋值多一次拷贝(可用移动缓解);对"拷贝昂贵且很少自赋值"的类型,代价可接受,换来的是正确性。
- 强异常保证依赖拷贝构造本身能提供;如果拷贝构造自己就写坏了,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* |
| 非虚析构 + 基类指针 delete | UB(GCC 有 -Wdelete-non-virtual-dtor 警告) |
| 成员初始化顺序 | 按声明顺序,不是初始化列表顺序 |
| 隐式转换 | 单参构造默认允许隐式转换 → 加上 explicit |
| 自赋值 | 要么 if (this != &rhs),要么 copy-and-swap |
| 构造函数里调虚函数 | 静态绑定到本层,不是多态 |
| 析构抛异常 | 隐式 noexcept → 直接 terminate;栈展开中再抛同样 terminate |
| const/引用成员 | 拷贝/移动赋值被删除 |
delete this | 仅当对象是 new 出来、之后不再碰任何成员时才勉强合法;工程上禁止 |
| 虚函数默认实参 | 静态绑定基类的默认值 |
九、自测清单
- struct 和 class 到底差什么?为什么说它们能力等价?
- 六个特殊成员函数分别在什么情况下被隐式生成 / 被删除?(能背出第三章那张表)
- Rule of 0/3/5 各说什么?为什么 Rule of Zero 是首选?
- copy-and-swap 一次解决哪两个问题?为什么要求
swap是noexcept?哪些类用不了? - 构造/析构顺序是什么?构造中抛异常时析构谁?
- 为什么析构要虚、构造不能虚、构造里调虚函数不生效?
- 虚函数的默认实参、
override/final、private virtual(NVI)分别怎么理解? - 类模板的 static 成员在
A<int>和A<double>里是同一个吗?为什么? - 类模板能偏特化吗?函数模板呢?CTAD 解决什么问题?
- 空类、只含成员函数的类、含一个
int的类、含int+ 虚函数的类分别多大?为什么?
子文档
| 文件 | 内容 |
|---|---|
concepts.md | 重载/重写/隐藏、struct/class/union、访问控制、初始化列表、切片 |
polymorphism.md | 多态机制、静态/动态绑定 |
vtable.md | vtable/vptr 内存模型、各类对象大小、EBO |
virtual-destructor.md | 虚析构/构造辨析、特殊成员函数与三/五规则 |
inheritance-deep.md | 多继承/菱形/虚继承、this 调整、thunk |
rtti-typeinfo.md | typeid / dynamic_cast / -fno-rtti |
patterns/idioms.md | Rule of Zero、copy-and-swap、scope guard、强类型 |
metaprogramming/basics.md | 模板:实例化、特化/偏特化、NTTP、依赖名 |