1. 必须实现拷贝构造函数的场景
核心问题:默认拷贝构造的缺陷
C++ 默认的拷贝构造函数(浅拷贝),会直接拷贝指针 / 引用成员的地址。若类包含引用成员 或指向堆内存的指针,浅拷贝会导致 "多个对象共享同一份资源",引发 double free、数据混乱等问题。此时必须手动实现深拷贝的拷贝构造函数。
场景 1:类包含引用成员(int&
)
引用成员必须绑定有效对象,默认拷贝构造会让新对象的引用绑定到原对象的引用成员(共享同一块内存)。若原对象的引用成员失效(如局部变量销毁),新对象的引用会变成 "野引用"。
示例:默认拷贝构造的危险
cpp
class Test {
public:
int& ref;
// 构造函数:绑定引用到外部变量
Test(int& r) : ref(r) {}
// 默认拷贝构造(编译器生成,危险!)
// Test(const Test& other) : ref(other.ref) {}
};
int main() {
int x = 10;
Test t1(x);
// 用默认拷贝构造,t2.ref 也绑定到 x
Test t2 = t1;
x = 20;
// t1.ref 和 t2.ref 都变成20(共享x)
cout << t1.ref << " " << t2.ref << endl;
// 若 x 是局部变量,t2 的引用可能失效
// int y = 30;
// Test t3(y);
// Test t4 = t3;
// y 销毁后,t4.ref 变成野引用
return 0;
}
解决方案:手动实现拷贝构造
需让新对象的引用绑定到新的有效变量(或确保原引用的生命周期足够长)。若引用需绑定到独立变量,需重新构造引用关系(如示例中让新对象的引用绑定到原引用的值,而非原引用本身)。
cpp
class Test {
public:
int& ref;
Test(int& r) : ref(r) {}
// 手动实现拷贝构造:让新对象的引用绑定到原引用的值
Test(const Test& other)
// 用原引用的值,构造新的 int 变量,再绑定引用
: ref(*new int(other.ref)) {}
// 注意:需手动管理内存,否则内存泄漏
~Test() {
// 释放 new 出来的 int(否则内存泄漏)
delete &ref;
}
};
场景 2:类包含指针成员(指向堆内存)
默认拷贝构造会拷贝指针地址(浅拷贝),导致多个对象共享堆内存。若一个对象释放内存,其他对象的指针会变成 "野指针"。
示例:浅拷贝导致 double free
cpp
class Test {
public:
int* ptr;
Test(int val) : ptr(new int(val)) {}
// 默认拷贝构造(浅拷贝,危险!)
// Test(const Test& other) : ptr(other.ptr) {}
~Test() { delete ptr; }
};
int main() {
Test t1(10);
// 浅拷贝,t2.ptr 和 t1.ptr 指向同一块内存
Test t2 = t1;
// t1 析构时释放 ptr,t2.ptr 变成野指针
// t2 析构时再次释放,引发 double free
return 0;
}
解决方案:深拷贝的拷贝构造
cpp
class Test {
public:
int* ptr;
Test(int val) : ptr(new int(val)) {}
// 深拷贝:新对象重新分配堆内存
Test(const Test& other)
: ptr(new int(*other.ptr)) {}
~Test() { delete ptr; }
};
总结:必须实现拷贝构造的时机
当类包含 ** 引用成员(需重新绑定)或指针成员(需深拷贝)** 时,默认浅拷贝会引发未定义行为,必须手动实现拷贝构造函数,确保:
- 引用成员绑定到新的有效对象(或值)。
- 指针成员重新分配堆内存(深拷贝)。
2. 对 explicit
关键字的理解
核心作用:禁止单参数构造函数的隐式类型转换
C++ 中,单参数构造函数(或多参数但其余参数有默认值的构造函数)会被编译器用作隐式类型转换 :用一个类型的值(如int
)直接构造另一个类型的对象(如MyInt
)。explicit
关键字禁止这种隐式转换,强制要求显式构造对象。
场景 1:单参数构造函数的隐式转换(危险!)
cpp
class MyInt {
public:
int value;
// 单参数构造函数:int → MyInt
MyInt(int num) : value(num) {}
};
void print(MyInt mi) {
cout << mi.value << endl;
}
int main() {
// 隐式转换:10 → MyInt(10)
print(10);
return 0;
}
这种隐式转换可能让代码逻辑晦涩(读者难以发现类型转换),甚至引发错误(如意外触发构造函数的副作用)。
场景 2:explicit
禁止隐式转换
给构造函数加 explicit
后,编译器不再允许隐式转换,必须显式构造对象。
cpp
class MyInt {
public:
int value;
// 禁止隐式转换
explicit MyInt(int num) : value(num) {}
};
void print(MyInt mi) {
cout << mi.value << endl;
}
int main() {
// 编译报错:无法隐式转换 int→MyInt
// print(10);
// 必须显式构造
print(MyInt(10));
return 0;
}
扩展:C++11 后支持多参数的 explicit
(配合列表初始化)
C++11 允许 explicit
修饰多参数构造函数(需配合{}
列表初始化),禁止用括号初始化的隐式转换。
cpp
class Point {
public:
int x, y;
// 多参数构造函数,explicit 禁止隐式转换
explicit Point(int a, int b) : x(a), y(b) {}
};
void draw(Point p) { /*...*/ }
int main() {
// 编译报错:无法隐式转换 (1,2)→Point
// draw((1,2));
// 必须显式构造
draw(Point(1,2));
// 或用列表初始化(C++11)
draw({1,2});
return 0;
}
最佳实践
- 所有单参数构造函数(或可能被隐式转换的构造函数)都应加
explicit
,避免意外的隐式转换。 - 仅在需要隐式转换的场景(如智能指针
std::shared_ptr
的构造),才不用explicit
。
3. 无法被继承的函数
核心规则:构造函数、析构函数、赋值运算符、final
修饰的函数无法被继承
函数类型 | 无法继承的原因 |
---|---|
构造函数 | 构造函数与类的初始化逻辑强绑定,子类需独立构造 |
析构函数 | 析构函数与类的资源释放逻辑强绑定,子类需独立析构 |
赋值运算符(operator= ) |
赋值需处理子类新增成员,继承父类的赋值逻辑会遗漏子类成员 |
被 final 修饰的虚函数 |
final 显式禁止子类重写该虚函数 |
详细解析
(1)构造函数与析构函数
- 构造函数:子类对象包含父类成员和子类成员,父类构造函数负责初始化父类成员,子类构造函数需调用父类构造函数(通过初始化列表),无法直接继承父类构造逻辑(否则无法初始化子类新增成员)。
- 析构函数:子类析构函数需先释放子类资源,再调用父类析构函数。若继承父类析构函数,无法保证子类资源优先释放,可能导致内存泄漏。
(2)赋值运算符(operator=
)
父类的 operator=
仅处理父类成员,子类的 operator=
需处理子类新增成员。若继承父类的 operator=
,会导致子类成员未被赋值(遗漏),引发数据不完整。
示例:继承赋值运算符的危险
cpp
class Father {
public:
int x;
Father& operator=(const Father& other) {
x = other.x;
return *this;
}
};
class Son : public Father {
public:
int y;
// 若继承父类 operator=,会遗漏 y 的赋值
Son& operator=(const Son& other) {
// 必须手动调用父类的 operator=
Father::operator=(other);
y = other.y;
return *this;
}
};
(3)被 final
修饰的虚函数
final
是 C++11 引入的关键字,用于禁止虚函数被重写。若父类虚函数被 final
修饰,子类无法继承(重写)该函数,否则编译报错。
cpp
class Father {
public:
// 禁止子类重写
virtual void func() final { /*...*/ }
};
class Son : public Father {
public:
// 编译报错:无法重写 final 函数
// void func() override { /*...*/ }
};
4. 继承中同名成员的处理
核心规则:子类同名成员会隐藏 父类同名成员(而非覆盖),需用作用域运算符 ::
显式访问父类成员。
场景 1:子类定义与父类同名的成员变量
父类的同名成员变量仍被继承到子类,但子类的同名成员会 "隐藏" 父类成员(默认访问子类成员)。
cpp
class Father {
public:
int money = 100;
};
class Son : public Father {
public:
// 子类同名成员,隐藏父类的 money
int money = 50;
};
int main() {
Son son;
// 访问子类的 money(输出50)
cout << son.money << endl;
// 显式访问父类的 money(输出100)
cout << son.Father::money << endl;
return 0;
}
场景 2:子类定义与父类同名的成员函数
若子类函数与父类函数同名但参数不同 (非虚函数),会隐藏父类的所有同名函数(无论参数是否相同)。若子类函数与父类函数同名且参数相同(虚函数),则重写父类函数(多态)。
示例:同名非虚函数的隐藏
cpp
class Father {
public:
void func(int x) {
cout << "Father: " << x << endl;
}
};
class Son : public Father {
public:
// 同名但参数不同,隐藏父类的 func(int)
void func(double x) {
cout << "Son: " << x << endl;
}
};
int main() {
Son son;
// 调用子类的 func(double)
son.func(3.14);
// 编译报错:父类的 func(int) 被隐藏
// son.func(10);
// 显式访问父类的 func(int)
son.Father::func(10);
return 0;
}
最佳实践
- 避免同名成员:设计类时,尽量让子类成员与父类成员名称不同,减少混淆。
- 显式访问父类成员 :若必须同名,用
Father::money
、Father::func()
明确访问父类成员,增强代码可读性。
5. 继承中构造与析构的顺序
构造顺序:父类 → 成员对象 → 子类
对象构造时,需先确保父类和成员对象的构造完成(为子类提供初始化的基础),因此顺序为:
- 调用父类的构造函数(按继承顺序,从最顶层父类到直接父类)。
- 调用成员对象的构造函数(按成员在子类中的声明顺序)。
- 调用子类的构造函数体。
示例:多层继承 + 成员对象的构造顺序
cpp
class GrandFather {
public:
GrandFather() { cout << "GrandFather 构造" << endl; }
};
class Father : public GrandFather {
public:
Father() { cout << "Father 构造" << endl; }
};
class Member {
public:
Member() { cout << "Member 构造" << endl; }
};
class Son : public Father {
public:
Member m;
Son() { cout << "Son 构造" << endl; }
};
int main() {
Son son;
// 输出顺序:
// GrandFather 构造 → Father 构造 → Member 构造 → Son 构造
return 0;
}
析构顺序:子类 → 成员对象 → 父类
对象析构时,需先释放子类资源,再释放成员对象和父类资源(避免父类资源先释放,子类资源访问无效内存),因此顺序与构造相反:
- 调用子类的析构函数体。
- 调用成员对象的析构函数(按构造顺序的逆序)。
- 调用父类的析构函数(按继承顺序的逆序,从直接父类到最顶层父类)。
示例:析构顺序验证
cpp
class GrandFather {
public:
~GrandFather() { cout << "GrandFather 析构" << endl; }
};
class Father : public GrandFather {
public:
~Father() { cout << "Father 析构" << endl; }
};
class Member {
public:
~Member() { cout << "Member 析构" << endl; }
};
class Son : public Father {
public:
Member m;
~Son() { cout << "Son 析构" << endl; }
};
int main() {
Son son;
// 构造顺序:GrandFather → Father → Member → Son
// 析构顺序:Son → Member → Father → GrandFather
return 0;
}
6. 继承中的构造与析构顺序
继承体系中,构造函数和析构函数的调用顺序严格遵循 "先构造父类,再构造子类;先析构子类,再析构父类" 的规则,这是由 C++ 对象的内存布局和生命周期决定的。
一、构造函数的调用顺序
1. 子类对象创建时,先调用父类构造函数
原理 :
子类继承了父类的成员变量,这些成员的初始化依赖父类的构造逻辑。如果先构造子类,父类成员可能因未初始化而出现访问非法内存、数据未定义等问题。因此,C++ 强制规定:创建子类对象时,必须先确保父类构造完成,为继承的成员打好初始化基础。
示例验证:
cpp
class Father {
public:
Father() {
cout << "Father 构造函数调用" << endl;
}
};
class Son : public Father {
public:
Son() {
cout << "Son 构造函数调用" << endl;
}
};
int main() {
Son son;
// 输出顺序:
// Father 构造函数调用 → Son 构造函数调用
return 0;
}
内存视角 :
子类对象的内存布局中,父类成员在前、子类成员在后。构造时必须先初始化父类部分(确保父类成员有效),再初始化子类独有的成员。
2. 父类有参数时,子类必须用初始化列表显式调用
场景 :
如果父类没有默认构造函数 (即构造函数需要传入参数,无法无参调用),子类构造函数必须在初始化列表 中显式调用父类的有参构造函数,否则编译器报错(因为父类无法自动用默认构造初始化)。
示例验证:
cpp
class Father {
public:
int money;
// 父类没有默认构造,必须传参初始化
Father(int m) : money(m) {
cout << "Father 带参构造:" << money << endl;
}
};
class Son : public Father {
public:
int toyNum;
// 子类构造函数初始化列表,显式调用父类带参构造
Son(int m, int toy)
// 调用父类有参构造,初始化父类成员 money
: Father(m),
// 初始化子类成员 toyNum
toyNum(toy)
{
cout << "Son 带参构造:" << toyNum << endl;
}
};
int main() {
Son son(100, 5);
// 输出顺序:
// Father 带参构造:100 → Son 带参构造:5
return 0;
}
关键细节:
- 初始化列表的调用顺序不受书写顺序影响 ,而是严格按照成员变量在类中声明的顺序执行。
- 若父类无默认构造,子类构造函数的初始化列表必须显式调用父类有参构造,否则编译报错(编译器无法自动推断父类的构造方式)。
二、析构函数的调用顺序
原理 :
对象销毁时,要保证 "先构造的后销毁,后构造的先销毁" 。子类对象依赖父类的资源(如父类成员变量、父类申请的堆内存),若父类先销毁,子类在析构时可能访问已销毁的父类成员,引发未定义行为。因此,析构顺序严格与构造顺序相反:先调子类析构函数,再调父类析构函数。
示例验证:
cpp
class Father {
public:
~Father() {
cout << "Father 析构函数调用" << endl;
}
};
class Son : public Father {
public:
~Son() {
cout << "Son 析构函数调用" << endl;
}
};
int main() {
Son son;
// 构造顺序:Father → Son
// 析构顺序:Son → Father
// 输出:
// Son 析构函数调用 → Father 析构函数调用
return 0;
}
内存视角 :
子类析构时,需先释放子类独有的资源(避免父类析构后,子类资源释放逻辑依赖父类成员但父类已销毁),再通知父类释放其资源。
7. 子类调用成员对象、父类有参构造的注意点
子类构造函数不仅要处理父类的构造,还要处理 ** 成员对象(子类中包含的其他类对象)** 的构造。成员对象的构造顺序与父类构造紧密相关,需特别注意默认构造和有参构造的调用规则。
一、默认构造的自动调用
规则 :
当父类和成员对象存在默认构造函数 (无参构造函数,或所有参数都有默认值的构造函数)时,子类构造时会自动调用它们的默认构造,无需手动在初始化列表中处理。
示例验证:
cpp
class Father {
public:
// 父类默认构造(无参)
Father() {
cout << "Father 默认构造" << endl;
}
};
class Member {
public:
// 成员对象默认构造(无参)
Member() {
cout << "Member 默认构造" << endl;
}
};
class Son : public Father {
private:
// 子类包含的成员对象
Member m;
public:
Son() {
cout << "Son 构造" << endl;
}
};
int main() {
Son son;
// 输出顺序:
// Father 默认构造 → Member 默认构造 → Son 构造
return 0;
}
原理 :
编译器会自动在子类构造函数的初始化列表中,插入对父类默认构造和成员对象默认构造的调用,确保所有基类和成员对象都被正确初始化。
二、有参构造的强制调用(初始化列表)
规则 :
若父类或成员对象没有默认构造函数 (只有带参数的构造函数),子类构造函数必须在初始化列表 中显式调用它们的有参构造函数,否则编译器报错(因为无法默认初始化)。
调用规则细节:
- 父类 :用
父类名(参数)
调用,指定父类构造函数及参数。 - 成员对象 :用
成员对象名(参数)
调用,指定成员对象构造函数及参数。
示例验证(父类、成员对象均为有参构造):
cpp
class Father {
public:
int money;
// 父类有参构造(无默认构造)
Father(int m) : money(m) {
cout << "Father 有参构造:" << money << endl;
}
};
class Member {
public:
int toy;
// 成员对象有参构造(无默认构造)
Member(int t) : toy(t) {
cout << "Member 有参构造:" << toy << endl;
}
};
class Son : public Father {
private:
// 子类包含的成员对象
Member m;
public:
// 初始化列表:调用父类、成员对象的有参构造
Son(int m_money, int m_toy)
// 调用父类有参构造,初始化父类成员 money
: Father(m_money),
// 调用成员对象有参构造,初始化成员对象 m 的 toy
m(m_toy)
{
cout << "Son 构造" << endl;
}
};
int main() {
Son son(100, 5);
// 输出顺序:
// Father 有参构造:100 → Member 有参构造:5 → Son 构造
return 0;
}
避坑点 :
初始化列表的调用顺序由成员变量在类中的声明顺序决定,而非初始化列表的书写顺序。若顺序错误,可能导致未定义行为(如用未初始化的成员给其他成员赋值)。
8. 虚继承的原理
虚继承是 C++ 为解决多继承中的菱形继承问题 (多个子类继承同一父类,导致父类成员在子类中重复存储)而设计的机制。其核心是通过虚基类指针和虚基类表,让多个子类共享同一父类的成员,避免数据冗余和访问冲突。
一、菱形继承问题(不使用虚继承)
场景 :
类 A
是类 B
和 C
的父类,类 D
同时继承 B
和 C
。此时,D
中会包含两份 A
的成员(一份来自 B
,一份来自 C
),导致数据冗余和二义性。
示例(问题代码):
cpp
class A {
public:
int data;
A(int d) : data(d) {}
};
class B : public A {
public:
B(int d) : A(d) {}
};
class C : public A {
public:
C(int d) : A(d) {}
};
class D : public B, public C {
public:
D(int d1, int d2) : B(d1), C(d2) {}
};
int main() {
D d(10, 20);
// 编译报错:data 二义性(B::data 和 C::data 冲突)
// cout << d.data << endl;
return 0;
}
问题 :
D
中存储了两份 A
的 data
(B::data
和 C::data
),访问 d.data
时编译器无法确定访问哪一份,引发二义性错误。
二、虚继承的解决方案
原理 :
虚继承通过在子类中添加虚基类指针(vbptr) ,指向虚基类表(vbtable) 。虚基类表中记录了从该指针到公共祖先(如 A
)成员的偏移量,确保多个子类(如 B
和 C
)共享同一 A
的成员,避免数据冗余。
使用虚继承的代码:
cpp
class A {
public:
int data;
A(int d) : data(d) {}
};
// 虚继承 A
class B : virtual public A {
public:
B(int d) : A(d) {}
};
// 虚继承 A
class C : virtual public A {
public:
C(int d) : A(d) {}
};
class D : public B, public C {
public:
// 必须显式调用 A 的构造(虚继承后,B 和 C 无法直接初始化 A)
D(int d) : A(d), B(d), C(d) {}
};
int main() {
D d(10);
// 输出10(B 和 C 共享 A::data)
cout << d.data << endl;
return 0;
}
内存布局变化:
B
和C
中各有一个虚基类指针(vbptr
),指向虚基类表。- 虚基类表中存储了
B
/C
到A
成员的偏移量,确保B::data
和C::data
实际指向同一A::data
。
三、虚继承的优缺点
优点:
- 解决菱形继承的二义性和数据冗余问题,让公共基类成员只存储一份。
缺点:
- 增加内存开销(每个虚继承的子类需存储虚基类指针和虚基类表)。
- 构造函数复杂(虚继承后,最终子类需显式调用公共基类的构造函数,无法依赖中间子类)。
最佳实践 :
虚继承是 "无奈之举",实际开发中应尽量避免多继承(用组合、接口继承替代),因为多继承的复杂性(如虚继承的理解成本、构造顺序问题)远大于其带来的便利。
9. 纯虚函数与抽象类
纯虚函数是 C++ 实现抽象类 和接口的核心机制。它强制子类必须实现某些函数,确保继承体系的行为统一。
一、普通纯虚函数的定义与特性
定义 :
纯虚函数是在基类中声明的虚函数,格式为 virtual 返回值类型 函数名(参数列表) = 0;
。纯虚函数不需要(也不能强制要求)在基类中实现函数体 ,但子类必须以 override
关键字重写该函数(否则子类也会成为抽象类,无法实例化)。
示例:
cpp
class Base {
public:
// 纯虚函数:基类只声明,不实现
virtual void func() = 0;
};
class Derived : public Base {
public:
// 子类必须重写纯虚函数
void func() override {
cout << "Derived::func 调用" << endl;
}
};
int main() {
// 错误:抽象类不能实例化
// Base b;
Derived d;
d.func(); // 输出:Derived::func 调用
return 0;
}
二、纯虚函数的特殊用法(基类实现函数体)
规则 :
纯虚函数可以在基类中实现函数体 ,但这不是强制的。子类可以选择调用基类的实现 (通过 Base::func()
),但即便基类实现了函数体,子类仍需显式重写纯虚函数(否则子类是抽象类)。
示例验证:
cpp
class Base {
public:
// 纯虚函数声明
virtual void func() = 0;
};
// 基类中实现纯虚函数的函数体(非强制,仅为示例)
void Base::func() {
cout << "Base::func 调用" << endl;
}
class Derived : public Base {
public:
// 子类必须重写纯虚函数
void func() override {
// 调用基类的实现(可选)
Base::func();
cout << "Derived::func 调用" << endl;
}
};
int main() {
Derived d;
d.func();
// 输出:
// Base::func 调用 → Derived::func 调用
return 0;
}
注意 :
基类纯虚函数的实现体通常用于提供 "默认逻辑",子类可选择复用或完全重写,但子类必须显式重写(否则无法实例化)。
三、纯虚函数对析构函数的影响
纯虚析构函数是特殊场景:基类若声明纯虚析构函数,必须提供函数体(否则链接报错,因为析构函数需要释放资源)。子类析构函数会自动调用基类析构函数,确保资源正确释放。
示例(纯虚析构函数):
cpp
class Base {
public:
// 纯虚析构函数,必须实现函数体
virtual ~Base() = 0;
};
// 基类纯虚析构函数的实现体
Base::~Base() {
cout << "Base 析构函数调用" << endl;
}
class Derived : public Base {
public:
~Derived() {
cout << "Derived 析构函数调用" << endl;
}
};
int main() {
Base* ptr = new Derived();
// 销毁时:先 Derived 析构,再 Base 析构
delete ptr;
// 输出:
// Derived 析构函数调用 → Base 析构函数调用
return 0;
}
关键 :
纯虚析构函数的函数体必须定义(否则链接阶段报错),子类析构函数会隐式调用基类析构函数,保证继承体系的资源释放顺序。
10. 多态成立的条件
多态是 C++ 面向对象设计的核心特性,允许程序根据对象的实际类型(而非声明类型)调用对应的函数。其成立需满足以下三个条件:
