1.OOP概述
面向对象编程(Object-Oriented Programming,简称 OOP)是一种基于“对象”概念的编程范式。它将数据和处理数据的方法组合成一个单元,从而模拟现实世界中的事物及其交互。
与传统的面向过程编程(关注“步骤”)不同,OOP 关注的是“谁”在执行操作以及“对象”之间的关系。
1. 核心概念:类与对象
在 OOP 中,最基础的两个概念是类 (Class) 和 对象 (Object):
- 类 (Class):是一个模板或蓝图。它定义了一类事物共有的属性(成员变量)和行为(方法)。
- 对象 (Object):是类的具体实例。
比喻: “汽车设计图”就是一个类;而你停在车库里的那辆具体的“红色特斯拉”就是该类的一个对象。
2. OOP 的四大支柱
OOP 的强大之处在于其四个核心特性,它们共同提高了代码的可维护性和重用性:
封装 (Encapsulation)
封装是将数据(属性)和操作数据的代码(方法)绑定在一起,并对外隐藏对象的内部实现细节。
- 作用:保护数据安全,防止外部代码意外修改。
- 实现:通常通过“访问修饰符”(如
private,public)来实现。
继承 (Inheritance)
继承允许一个类(子类)获取另一个类(父类)的属性和方法。
- 作用:实现代码重用,建立类之间的层次结构。
- 例子:定义一个“动物”类,然后让“狗”和“猫”继承它。
多态 (Polymorphism)
多态是指同一个接口可以有不同的实现方式。简单来说,就是“一个接口,多种表现”。
- 作用:增加程序的灵活性和可扩展性。
- 例子:父类定义一个
发声()方法,狗对象调用时是“汪汪”,猫对象调用时是“喵喵”。
抽象 (Abstraction)
抽象是提取现实世界中某个实体的核心特征,而忽略无关紧要的细节。
- 作用:降低复杂度,让程序员专注于“做什么”而不是“怎么做”。
3. 为什么使用 OOP?
使用面向对象编程有以下显著优点:
- 重用性:通过继承和组件化,可以减少重复代码。
- 可维护性:代码结构清晰,修改某个模块通常不会影响全局。
- 灵活性:多态性使得程序在处理不同类型的对象时更加通用。
- 团队协作:对象化的思维更符合人类逻辑,方便大型项目的分工。
4. 主流的 OOP 语言
目前大多数流行的编程语言都支持或完全基于 OOP:
- Java(经典的纯 OOP 语言)
- Python(支持多种范式,但核心是对象)
- C++(在 C 的基础上引入了面向对象)
- C# / JavaScript / Ruby / Swift
2.定义基类和派生类
在 C++ 中定义基类时,除了基本的语法结构,还需要特别注意内存管理和多态性。C++ 赋予了开发者对底层细节的高度控制权,因此定义基类时有一些特有的规则。
1. C++ 基类的基本语法
一个标准的 C++ 基类通常包含属性、构造函数、成员函数,以及最重要的虚析构函数。
#include <iostream>
#include <string>
class Animal {
protected: // 受保护成员:子类可以访问,外部不可访问
std::string name;
public:
// 构造函数
Animal(std::string n) : name(n) {
std::cout << "Animal 构造函数被调用" << std::endl;
}
// 虚析构函数:非常重要!确保通过基类指针删除子类对象时,子类析构函数能被调用
virtual ~Animal() {
std::cout << "Animal 析构函数被调用" << std::endl;
}
// 虚函数:允许子类重写(Override)
virtual void makeSound() const {
std::cout << name << " 发出了一些声音..." << std::endl;
}
};
2. C++ 定义基类的关键要素
① 访问修饰符 (Access Specifiers)
public: 任何地方都可以访问。protected: 基类及其派生类(子类)内部可以访问,但外部对象不能访问。这是定义基类属性时最常用的修饰符。private: 只有基类自己可以访问,子类也无法直接访问。
② 虚析构函数 (virtual ~ClassName)
在 C++ 中,如果你打算让一个类作为基类,必须将析构函数声明为 virtual。
- 原因:如果你使用基类指针指向子类对象(如
Animal* a = new Dog();),当执行delete a;时,如果析构函数不是虚函数,程序只会调用基类的析构函数,而不会调用子类的析构函数,从而导致内存泄漏。
③ 虚函数 (virtual)
如果你希望子类能够重新定义某个行为,需要在基类中使用 virtual 关键字。这开启了 C++ 的动态绑定(Dynamic Binding),即在运行时根据对象的实际类型来决定调用哪个函数。
3. 抽象基类 (Abstract Base Class)
如果你希望基类只作为一个接口蓝图,而不允许被直接实例化(例如,你可以有“狗”,但不能有一个模糊的“动物”实例),你可以使用纯虚函数。
class Shape {
public:
// 纯虚函数:没有函数体,以 = 0 结尾
virtual double getArea() const = 0;
virtual ~Shape() {} // 依然需要虚析构函数
};
- 包含至少一个纯虚函数的类被称为抽象类。
- 抽象类不能创建对象,子类必须实现所有的纯虚函数才能被实例化。
4. 常见陷阱
- 忘记虚析构函数:这是初学者最容易犯的内存管理错误。
- 不必要的
virtual:虽然virtual很有用,但它会增加一个虚函数表(vtable)指针的开销。对于不打算被继承的类,不要随意使用。 - 成员初始化列表:在基类构造函数中,建议使用初始化列表(如上面的
: name(n))以提高效率。
1.成员函数与继承
在 C++ 中,成员函数与继承的关系决定了子类如何复用、修改或完全重写父类的行为。理解这一点的关键在于理清 访问权限 和 虚函数机制。
1. 成员函数的访问权限
当子类继承基类时,基类成员函数对子类的可见性取决于访问修饰符:
| 基类成员声明 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| public | 在子类中仍为 public | 在子类中变为 protected | 在子类中变为 private |
| protected | 在子类中仍为 protected | 在子类中变为 protected | 在子类中变为 private |
| private | 不可见(子类无法直接调用) | 不可见 | 不可见 |
注意:基类的
private成员函数虽然被子类继承了(占用了空间),但子类代码无法直接访问它们。通常建议将希望子类使用但不对外公开的函数设为protected。
2. 函数重写与隐藏
子类可以定义一个与基类同名的函数,这会产生两种不同的效果:
A. 覆盖 (Overriding) —— 针对虚函数
如果基类函数被声明为 virtual,子类提供相同签名的函数时,会发生覆盖。这是多态的基础。
- 特点:通过基类指针或引用调用时,会执行子类的版本。
- 最佳实践:在子类中使用
override关键字,让编译器检查签名是否匹配。
B. 隐藏 (Hiding) —— 针对非虚函数
如果基类函数不是 virtual,子类定义了同名函数(无论参数是否相同),基类的同名函数在子类作用域中会被隐藏。
- 特点:通过基类指针调用时,依然执行基类的版本;通过子类对象调用时,执行子类的版本。
3. 代码示例:成员函数在继承中的表现
#include <iostream>
class Base {
public:
void normalFunc() {
std::cout << "Base: 普通函数" << std::endl;
}
virtual void virtualFunc() {
std::cout << "Base: 虚函数" << std::endl;
}
};
class Derived : public Base {
public:
// 隐藏了 Base::normalFunc
void normalFunc() {
std::cout << "Derived: 隐藏了基类函数" << std::endl;
}
// 重写了 Base::virtualFunc
void virtualFunc() override {
std::cout << "Derived: 重写了基类虚函数" << std::endl;
}
void callBase() {
// 如果想在子类内部调用被隐藏的基类版本,使用作用域解析符
Base::normalFunc();
}
};
4. 调用机制与虚函数表 (VTable)
C++ 实现多态的核心是虚函数表。
- 当一个类包含虚函数时,编译器会为该类创建一个
vtable,存储虚函数的地址。 - 每个对象含有一个隐藏指针
vptr,指向其类的vtable。 - 运行时,程序根据
vptr找到对应的vtable,从而调用正确的函数版本。
5. 如何在子类中调用基类函数?
有时我们不想完全替换基类的行为,而是在其基础上增加功能。你可以显式调用基类函数:
void Derived::virtualFunc() override {
Base::virtualFunc(); // 先执行基类的逻辑
std::cout << "Derived: 增加的额外逻辑" << std::endl;
}
2.访问控制与继承
在 C++ 中,访问控制(Access Control)与继承(Inheritance)的结合决定了:
- 子类内部是否可以访问父类的成员。
- 子类对象(外部代码)是否可以访问父类的成员。
这是一个二维的权限过滤过程:基类成员自身的访问级别 + 继承方式 = 子类中该成员的实际权限。
1. 访问权限过滤表
这是理解 C++ 继承最核心的矩阵。它展示了基类成员在经过不同方式继承后,在子类中变成了什么权限:
| 基类成员访问级别 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
public | 保持 public | 变为 protected | 变为 private |
protected | 保持 protected | 变为 protected | 变为 private |
private | 不可见 | 不可见 | 不可见 |
2. 三种继承方式详解
① 公有继承 (public) —— 最常用
表示 “是一个 (is-a)” 的关系。
- 规则:基类的公有成员在子类中仍为公有;保护成员仍为保护。
- 特点:子类对象可以直接调用基类的公有方法。这是最符合直觉的继承方式。
② 保护继承 (protected)
表示 “内部实现复用”。
- 规则:基类的
public和protected成员在子类中全部变成protected。 - 特点:外部代码(通过子类对象)无法再访问基类的任何成员,但该子类的子类(孙子类)仍然可以访问这些成员。
③ 私有继承 (private)
表示 “根据…实现 (implemented-in-terms-of)”。
- 规则:基类的所有成员在子类中全部变成
private。 - 特点:这种继承切断了后续的继承链。孙子类将无法访问爷爷类的任何成员。通常用于完全隐藏父类接口的情况。
3. 关键点:基类的 private 成员
无论哪种继承方式,基类的 private 成员在子类中都是不可直接访问的。
- 理解:虽然子类对象在内存中确实包含了基类的私有变量,但子类的代码没有权限去读写它。
- 解决方法:如果希望子类能访问,但外部不能访问,应将基类成员声明为
protected。
4. 代码演示
class Base {
public: int pub = 1;
protected: int prot = 2;
private: int priv = 3;
};
// 1. 公有继承
class PublicDerived : public Base {
void access() {
pub = 10; // OK: 仍是 public
prot = 20; // OK: 仍是 protected
// priv = 30; // 错误:基类私有成员不可见
}
};
// 2. 私有继承
class PrivateDerived : private Base {
void access() {
pub = 10; // OK: 但在 PrivateDerived 中已变成 private
prot = 20; // OK: 但在 PrivateDerived 中已变成 private
}
};
int main() {
PublicDerived d1;
d1.pub = 100; // OK: public 成员在外部可访问
PrivateDerived d2;
// d2.pub = 100; // 错误:在私有继承下,pub 变成了私有,外部无法访问
}
5. 总结:如何选择?
- 如果你想表达 “子类是父类的一种”(如狗是动物),请使用
public继承。 - 如果你只想 “借用父类的代码” 而不希望外界把子类当成父类看,请考虑
private继承 或 组合(Composition)。 - 如果你定义的是
class,默认继承方式是private;如果你定义的是struct,默认继承方式是public。
2.定义派生类
1.派生类的虚函数
1. 基本概念:重写 (Overriding)
当基类声明了一个虚函数时,派生类可以提供该函数的新实现。这被称为覆盖或重写(Override)。
- 函数签名必须匹配:派生类中的函数名、参数列表和返回类型必须与基类中的虚函数完全一致。
- 虚特性会被继承:一旦基类中的函数被声明为
virtual,它在所有派生类中自动也是虚函数,即使派生类中不写virtual关键字(但为了代码可读性,建议加上)。
class Base {
public:
virtual void show() { cout << "Base show" << endl; }
};
class Derived : public Base {
public:
void show() override { // 使用 override 确保正确重写
cout << "Derived show" << endl;
}
};
2. C++11 关键字:override 和 final
为了增强代码的可读性和编译器检查,C++11 引入了两个重要的关键字:
override:显式告诉编译器,该函数意图重写基类虚函数。如果基类中没有同名虚函数,编译器会报错。这能有效避免因拼写错误或参数不同而导致的“隐式重载”问题。final:如果不希望某个虚函数被进一步派生类重写,可以使用final。
class SubDerived : public Derived {
public:
void show() override final { // 此后任何子类都不能再重写 show()
cout << "SubDerived show" << endl;
}
};
3. 底层机制:虚函数表 (Vtable)
理解派生类虚函数,必须理解其背后的实现机制。
- Vtable (虚函数表):编译器为每个拥有虚函数的类创建一个表,表里存放着该类所有虚函数的地址。
- Vptr (虚函数指针):每个类的对象实例中,都会隐藏一个指针,指向该类对应的 Vtable。
- 派生类的重写:
- 如果派生类重写了虚函数,其 Vtable 中对应位置的地址会被更新为派生类函数的地址。
- 如果派生类没有重写,其 Vtable 则会保留基类函数的地址。
4. 关键点:虚析构函数
这是使用派生类时最容易出错的地方。如果一个类有虚函数,其析构函数也应该是虚的。
- 原因:如果你通过基类指针删除一个派生类对象(
Base* p = new Derived(); delete p;),如果析构函数不是虚的,编译器只会调用基类的析构函数,导致派生类特有的成员变量无法被正确释放,造成内存泄漏。
class Base {
public:
virtual ~Base() {} // 虚析构函数
};
5. 常见注意事项
| 特性 | 说明 |
|---|---|
| 构造函数 | 构造函数不能是虚函数。构造对象时,虚函数机制尚未完全建立。 |
| 静态成员函数 | 静态成员函数不能是虚函数,因为它们不属于特定的对象实例。 |
| 内联函数 | 虚函数通常不内联,因为内联是编译期决定的,而虚函数是运行期决定的。 |
| 纯虚函数 | 如果基类函数定义为 virtual void func() = 0;,则该类为抽象类,派生类必须实现该函数才能实例化。 |
2.派生类对象及派生类向基类的类型转化
1. 派生类对象的构成
一个派生类对象在内存中可以看作由两部分组成:
- 基类部分(Base Part):包含基类的所有成员变量。
- 派生类部分(Derived Part):包含派生类自己新增的成员变量。
这种结构是类型转化的物理基础:因为派生类对象内部“包含”了一个完整的基类对象。
2. 派生类向基类的类型转化(向上转型 / Upcasting)
向上转型是指将派生类对象转换为基类类型。这在 C++ 中是自动且安全的,因为每一个派生类对象逻辑上都是一个基类对象。
A. 转换为基类指针或引用(最常用)
这是实现多态的关键。当你使用基类指针指向派生类对象时,你可以通过该指针调用基类的成员。
class Base {
public:
void greet() { cout << "Hello from Base" << endl; }
};
class Derived : public Base {
public:
void greet() { cout << "Hello from Derived" << endl; }
};
int main() {
Derived d;
Base* b_ptr = &d; // 自动向上转型:派生类指针 -> 基类指针
Base& b_ref = d; // 自动向上转型:派生类引用 -> 基类引用
b_ptr->greet(); // 如果 greet 不是虚函数,调用的是 Base::greet
}
B. 转换为基类对象(对象切片 / Object Slicing)
当你直接将派生类对象赋值给基类对象(传值)时,会发生对象切片。
- 现象:派生类对象中特有的成员会被“切掉”,只保留基类部分。
- 后果:转换后的对象完全变成了一个基类对象,不再具备派生类的任何行为(即使有虚函数,多态也会失效)。
Derived d;
Base b = d; // 发生对象切片:d 的派生类部分被丢弃,只有基类部分复制给 b
3. 类型转化的三个前提条件
并非所有的类都能随意转化,必须满足以下条件:
- 必须是公有继承(public inheritance):
- 如果是
private或protected继承,编译器不允许用户代码进行这种自动转化。 - 原因:非公有继承下,派生类不再被视为基类的“一种”。
- 如果是
- 只能向上转化:
- 派生类 -> 基类:自动隐式转换(向上转型)。
- 基类 -> 派生类:必须显式转换(向下转型 / Downcasting),且通常需要使用
dynamic_cast来保证安全性。
- 非二义性:
- 在多重继承中,如果一个类从两个不同的路径继承自同一个基类(且没有使用虚继承),转化时可能会产生二义性。
4. 为什么需要这种转化?
- 实现多态:编写一个接受 Base& 或 Base* 参数的函数,该函数就可以处理任何 Base 的子类对象。void render(Base& obj) { // 可以传入 Derived1, Derived2…
obj.draw();
} - 代码复用:基类的逻辑可以统一处理所有子类,无需为每个子类重载函数。
5. 总结:指针/引用 vs 对象赋值
| 转化方式 | 是否保留派生类信息 | 是否触发多态 | 风险 |
|---|---|---|---|
指针 (Base\*) | 是(指向原始对象) | 是(配合 virtual) | 指针悬挂/内存管理 |
引用 (Base&) | 是(原始对象的别名) | 是(配合 virtual) | 相对安全 |
对象 (Base) | 否(发生切片) | 否(静态绑定) | 导致数据丢失和逻辑错误 |
核心建议:在处理派生类向基类的转化时,始终优先使用指针或引用,除非你明确需要创建一个剥离了子类信息的基类副本。
3.派生类使用基类的函数
1. 直接调用基类函数
如果基类中的函数是 public 或 protected 的,且派生类中没有定义同名的函数,派生类对象可以直接像使用自己的函数一样调用它。
class Base {
public:
void sayHello() { cout << "Hello from Base" << endl; }
};
class Derived : public Base {
// 内部可以直接调用 sayHello()
};
int main() {
Derived d;
d.sayHello(); // 直接使用基类函数
}
2. 在派生类中调用基类的同名函数
当派生类重写(Override)或隐藏了基类的函数,但仍需要执行基类的逻辑时,必须使用作用域解析运算符 ::。
这是最常见的用法,通常用于在扩展子类功能的同时,保留父类的核心逻辑。
class Base {
public:
virtual void process() {
cout << "Base: 核心逻辑处理" << endl;
}
};
class Derived : public Base {
public:
void process() override {
Base::process(); // 显式调用基类版本
cout << "Derived: 增加额外逻辑" << endl;
}
};
3. 处理“隐藏”问题 (Name Hiding)
在 C++ 中,如果派生类定义了一个与基类同名但参数列表不同的函数,基类的同名函数会被“隐藏”,导致无法直接通过派生类对象调用。
- 解决方法:使用
using声明将基类函数引入派生类作用域。
class Base {
public:
void func(int x) { cout << "Base int" << endl; }
};
class Derived : public Base {
public:
using Base::func; // 将 Base 的所有 func 重载版本引入当前作用域
void func(string s) { cout << "Derived string" << endl; }
};
// 如果不写 using,d.func(10) 会编译报错,因为 Base::func(int) 被隐藏了。
4. 访问权限的影响
派生类能否使用基类函数,严格受继承方式和成员访问修饰符的限制:
| 基类成员访问级别 | public 继承后 | protected 继承后 | private 继承后 |
|---|---|---|---|
| public | 保持 public | 变为 protected | 变为 private |
| protected | 保持 protected | 保持 protected | 变为 private |
| private | 不可见 | 不可见 | 不可见 |
注意:基类的
private成员对派生类是完全不可见的。如果希望成员能被派生类使用但不被外部访问,应将其声明为protected。
5. 构造函数的使用
虽然构造函数不能被继承,但派生类的构造函数必须初始化其基类部分。这通过初始化列表实现:
class Base {
public:
Base(int x) { /* ... */ }
};
class Derived : public Base {
public:
Derived(int x, int y) : Base(x) { // 显式调用基类构造函数
// 派生类初始化
}
};
总结建议
- 扩展功能:在重写函数时,先用
Base::func()执行基类逻辑,再编写派生类特有逻辑。 - 避免隐藏:如果重载了基类函数,记得用
using Base::func;以防基类版本失效。 - 权限控制:设计基类时,将需要给子类使用但不暴露给外部的接口设为
protected。
4.继承和静态函数
在 C++ 中,静态成员(静态变量和静态函数) 同样会被派生类继承,但由于它们“属于类而不属于对象”的特性,其继承行为与普通的成员函数有显著区别。
以下是关于继承与静态函数的深度解析:
1. 静态成员的继承规则
- 可以继承:派生类可以访问基类的
public和protected静态成员。 - 全局唯一性:无论派生出多少个子类,整个继承体系中,静态变量只有一份实例(除非子类定义了同名的静态变量)。
- 作用域限定:可以通过
Base::func()访问,也可以通过Derived::func()访问,它们指向的是内存中的同一个函数地址。
2. 为什么静态函数不能是虚函数?
在 C++ 中,声明 virtual static 是非法的,会导致编译错误。
- 虚函数机制:依赖于对象的
vptr(虚函数指针)和vtable(虚函数表)。调用时需要this指针来定位具体对象。 - 静态函数机制:静态函数没有
this指针,它们在编译期就绑定了。 - 逻辑矛盾:虚函数是为了实现“运行时根据对象类型调用”,而静态函数的设计初衷是“无需对象即可调用”。这两者在底层逻辑上是互斥的。
3. “同名隐藏”而非“重写”
如果派生类定义了一个与基类同名的静态函数,这不叫“重写(Override)”,而叫隐藏(Hiding)。
- 静态绑定:编译器会根据你使用的指针/引用的静态类型来决定调用哪个函数,而不是根据实际指向的对象类型。
class Base {
public:
static void info() { cout << "Base static info" << endl; }
};
class Derived : public Base {
public:
static void info() { cout << "Derived static info" << endl; } // 隐藏了 Base::info
};
int main() {
Base* ptr = new Derived();
ptr->info(); // 输出 "Base static info"(由指针类型决定)
Derived::info(); // 输出 "Derived static info"
}
4. 静态成员变量在继承中的表现
静态变量在整个继承链中是共享的。
class Base {
public:
static int count;
};
int Base::count = 0;
class Derived : public Base {};
int main() {
Base::count = 10;
cout << Derived::count; // 输出 10,因为它们访问的是同一个变量
}
5. 核心差异对比表
| 特性 | 普通成员函数 | 静态成员函数 |
|---|---|---|
| 继承性 | 可继承 | 可继承 |
| 虚函数化 | 可以(实现多态) | 不可以 |
| 绑定方式 | 动态绑定(运行时) | 静态绑定(编译时) |
| this 指针 | 有 | 没有 |
| 同名处理 | 重写(Override) | 隐藏(Hiding) |
| 内存位置 | 随对象分配(逻辑上) | 全局/静态存储区(全体系共享) |
总结与建议
- 访问习惯:虽然可以通过对象调用静态函数(如
obj.static_func()),但为了代码清晰,建议始终使用类名调用(如Base::static_func())。 - 避免同名:除非有特殊设计需求,否则尽量不要在派生类中定义与基类同名的静态函数,以免造成调用时的混淆。
- 多态需求:如果你需要根据对象的实际类型产生不同行为,请务必使用普通的虚函数(
virtual),而不是静态函数。
5.派生类的声明
1. 基本语法格式
派生类的声明通常紧跟在类名之后,使用冒号 : 指明其基类。
class 派生类名 : 访问修饰符 基类名 {
// 派生类新增的成员
};
- 访问修饰符(Access Specifier):可以是
public、protected或private。如果省略,对于class关键字定义的类,默认是private;对于struct关键字定义的类,默认是public。
2. 多重继承的声明
C++ 支持一个派生类同时继承多个基类。在声明时,只需用逗号分隔基类列表即可。
class Derived : public Base1, private Base2 {
public:
// 同时拥有 Base1 和 Base2 的特性
};
注意:多重继承虽然强大,但容易引发“菱形继承”问题(即多个父类拥有同一个祖先类),此时通常需要配合
virtual关键字进行虚继承声明。
3. 派生类的向前声明 (Forward Declaration)
如果你只需要告诉编译器某个类名是派生类(而不涉及其成员访问),可以进行向前声明。
注意: 向前声明不能包含继承列表。
// 错误写法:
class Derived : public Base;
// 正确写法:
class Derived;
// 只有在真正定义该类时,才需要写出继承关系:
class Derived : public Base {
// ...
};
4. 虚继承的声明 (Virtual Inheritance)
为了解决多重继承中的路径二义性,可以在声明继承时加上 virtual 关键字。
class Base { /* ... */ };
// 声明虚继承
class Mid1 : virtual public Base { /* ... */ };
class Mid2 : virtual public Base { /* ... */ };
// 最终派生类只会包含一个 Base 实例
class FinalDerived : public Mid1, public Mid2 { /* ... */ };
总结
在声明派生类时,应始终明确以下三点:
- 继承方式:绝大多数情况下应使用
public继承。 - 基类完整性:在定义派生类之前,基类的定义必须是可见的(即已经
#include了基类的头文件),因为编译器需要知道基类的大小。 - 职责清晰:利用
final明确类的设计意图,防止滥用继承。
6.被用作基类的类
1. 核心要求:虚析构函数 (Virtual Destructor)
这是设计基类时最重要的规则。如果一个类可能被继承,并且程序中可能通过基类指针删除派生类对象,那么基类的析构函数必须声明为 virtual。
- 原因:如果析构函数不是虚的,执行
delete baseptr时只会调用基类的析构函数,而不会触发派生类的析构函数,从而导致内存泄漏或资源未释放。
C++
class Base {
public:
virtual ~Base() { /* 确保派生类资源能被清理 */ }
};
2. 访问控制:protected 的艺术
在基类中,protected 关键字扮演着桥梁的角色:
- 对外部隐藏:像
private一样,外部用户无法直接访问。 - 对子类开放:派生类可以自由访问这些成员。
设计建议:
- 将基类的成员变量设为
private(为了封装),但提供protected的访问器(Getter/Setter)。 - 将仅供子类内部使用的辅助工具函数设为
protected。
3. 抽象类与纯虚函数
如果基类代表一个抽象概念(如“形状”、“动物”),不需要也不应该被实例化,可以将其声明为抽象基类(ABC)。
- 语法:包含至少一个纯虚函数(声明后缀为
= 0)。 - 作用:它强制要求所有派生类必须实现这些函数,否则派生类也将是抽象的,无法创建对象。
C++
class Shape {
public:
virtual void draw() = 0; // 纯虚函数,定义了接口规范
};
4. 成员函数的选择:Virtual 还是 Non-virtual?
在基类中声明函数时,设计者必须做出明确的选择:
- 非虚函数(Non-virtual):代表强制性的不变性。子类不应该重写这些函数,它们在所有派生类中的行为应该是统一的。
- 虚函数(Virtual):代表可修改的默认行为。子类可以重写它,但如果子类不处理,则使用基类的默认实现。
- 纯虚函数(Pure Virtual):代表必须提供的功能。基类不提供实现(或仅提供可选实现),子类必须自定义。
5. 阻止继承:final 关键字
如果你设计的一个类不打算作为基类(例如为了性能优化或逻辑完整性),应该在类名后加上 final。这可以防止他人误用继承。
C++
class DatabaseConnection final {
// 该类不能被继承
};
6. 基类设计的“禁忌”
- 不要在构造函数或析构函数中调用虚函数:在基类构造时,派生类部分尚未初始化;在基类析构时,派生类部分已经销毁。此时调用虚函数,并不会触发多态,而是调用基类自身的版本(如果是纯虚函数则会导致程序崩溃)。
- 避免深度继承:被用作基类的类应该尽量精简。过深的继承链(超过 3-4 层)会显著增加维护难度和认知负担。
总结:基类检查清单
| 检查项 | 结论 |
|---|---|
| 析构函数 | 必须是 virtual(除非确定不通过指针删除) |
| 数据成员 | 建议设为 private 或 protected |
| 接口规范 | 使用 virtual 或纯虚函数定义 |
| 构造函数 | 确保能正确初始化基类状态 |
| 对象拷贝 | 若基类包含资源,需考虑禁用拷贝构造(delete)或实现深拷 |
3.类型转化和继承
1.静态类型和动态类型
1. 静态类型 (Static Type)
静态类型是变量在声明时所指定的类型。
- 确定时间:编译期(Compile-time)。
- 特性:它是固定的,编译器在翻译代码时只根据静态类型来检查语法、匹配函数重载以及计算内存偏移。
- 范围:所有变量、指针和引用都有静态类型。
Base* ptr; // ptr 的静态类型是 Base*
Shape& ref = circle; // ref 的静态类型是 Shape&
2. 动态类型 (Dynamic Type)
动态类型是指变量(通常是指针或引用)实际指向的对象的类型。
- 确定时间:运行期(Runtime)。
- 特性:它是变化的。同一个基类指针,在程序运行过程中可以先后指向不同的派生类对象。
- 多态的基础:虚函数的调用是由动态类型决定的(即动态绑定)。
Base* ptr = new Derived(); // ptr 的静态类型是 Base*,动态类型是 Derived
3. 两者的对比与转换
| 特性 | 静态类型 (Static Type) | 动态类型 (Dynamic Type) |
|---|---|---|
| 决定时机 | 编译阶段 | 运行阶段 |
| 可见性 | 编译器可见,程序员通过声明确定 | 只有在程序运行时才确定 |
| 绑定方式 | 静态绑定(早绑定) | 动态绑定(晚绑定) |
| 主要用途 | 语法检查、非虚函数调用、默认参数 | 虚函数调用(多态) |
4. 关键案例分析
A. 虚函数的动态绑定
虚函数是 C++ 中极少数依赖动态类型的特性。
Base* p = new Derived();
p->someVirtualFunc(); // 编译器根据 p 的动态类型 (Derived) 调用函数
B. 默认参数的陷阱(重要!)
这是一个非常容易出错的点:虚函数是动态绑定的,但函数的默认参数是静态绑定的。
如果你在基类的虚函数里写了默认参数 = 10,而在派生类里改成了 = 20,通过基类指针调用时,你会得到“派生类的函数逻辑”加上“基类的默认参数”。
准则:绝不要重新定义继承而来的虚函数的默认参数。
class Base {
public:
virtual void func(int x = 10) { cout << "Base " << x << endl; }
};
class Derived : public Base {
public:
void func(int x = 20) override { cout << "Derived " << x << endl; }
};
Base* p = new Derived();
p->func(); // 输出: "Derived 10" <-- 注意这里!
5. 什么时候静态类型与动态类型一致?
如果一个变量不是指针也不是引用,它的静态类型和动态类型永远一致。
Derived d; // 静态类型和动态类型都是 Derived
这种情况下不涉及多态,所有函数调用都在编译期完成。
总结
- 静态类型:看变量怎么声明的(决定了你能调用哪些接口)。
- 动态类型:看变量具体指谁(决定了虚函数到底执行哪一段代码)。
2.不存在从基类类到派生类的隐式转化
1. 为什么禁止隐式向下转型(Downcasting)?
隐式转换通常发生在“绝对安全”的情况下(如派生类转基类,即“向上转型”)。而基类转派生类被禁止,主要基于以下原因:
A. 逻辑上的“不一定” (The “is-a” relationship)
- 向上转型:每一个“狗”都一定是一个“动物”。所以
Dog*转Animal*是安全的。 - 向下转型:每一个“动物”不一定都是“狗”。它可能是一只猫,甚至就是一个纯粹的动物基类。如果编译器允许隐式转换,程序员可能会无意中把一只“猫”当成“狗”来处理。
B. 内存访问的安全性
派生类通常比基类更“大”,因为它包含基类没有的成员变量和函数。
- 如果你将一个真实的基类对象强制看作派生类,当你尝试访问派生类特有的成员变量时,程序会访问到非法内存区域,导致程序崩溃或产生未定义行为。
2. 如何显式进行转换?
虽然没有“隐式”转换,但 C++ 提供了显式转换的手段。当你作为开发者,确定某个基类指针确实指向一个派生类对象时,可以使用以下两种方式:
① static_cast(不安全但快速)
编译器在编译时完成转换,不进行运行时检查。
- 适用场景:你非常确定对象的真实类型。
- 风险:如果类型不匹配,编译器不会报错,但运行时访问成员会出错。
Base* bp = new Derived();
Derived* dp = static_cast<Derived*>(bp); // 成功,因为 bp 确实指向 Derived
② dynamic_cast(安全但有开销)
这是处理向下转型的标准方式。它会在运行时检查对象的真实类型。
- 要求:基类必须至少有一个虚函数(通常是虚析构函数)。
- 结果:
- 如果转换成功,返回有效的指针。
- 如果转换失败,返回
nullptr(如果是引用转换则抛出异常)。
Base* bp = new Base();
Derived* dp = dynamic_cast<Derived*>(bp);
if (dp) {
// 转换成功
} else {
// 转换失败,bp 并不是指向 Derived 对象
cout << "Invalid downcast!" << endl;
}
3. 两种转型的对比总结
| 特性 | 向上转型 (Upcasting) | 向下转型 (Downcasting) |
|---|---|---|
| 方向 | 派生类 \rightarrow 基类 | 基类 \rightarrow 派生类 |
| 隐式转换 | 允许 (自动发生) | 禁止 |
| 安全性 | 绝对安全 | 存在风险 |
| 必要性 | 多态的基础 | 通常应尽量避免,除非特定逻辑需要 |
4. 延伸:对象切片 (Object Slicing)
不仅是指针,对象之间也没有隐式向下转换。
Base b = Derived();是允许的(向上转型,发生切片)。Derived d = Base();会直接导致编译错误。因为Derived的构造函数无法从一个Base对象中初始化自己特有的成员。
总结建议
在设计良好的代码中,应该尽量减少向下转型(Downcasting)的使用。如果你发现自己频繁需要将基类转回派生类,通常意味着你的多态设计方案(接口设计)可能不够完善,或者本该在基类定义的虚函数没有定义。
3.在对象之间不存在类型转化
1. 派生类 \rightarrow 基类:对象切片 (Object Slicing)
当你把一个派生类对象直接赋值给一个基类对象时,转化是存在的,但它是一种“阉割式”的转化。
- 发生过程:编译器只拷贝派生类对象中属于基类的那个部分,而把派生类特有的成员(变量、虚函数表指针等)全部“切掉”。
- 结果:目标对象变成了一个纯粹的基类对象。
Derived d;
Base b = d; // 允许,但发生了“切片”。b 失去了 d 的所有特性。
2. 基类 \rightarrow 派生类:彻底禁止
编译器绝不允许你直接写 Derived d = base_obj;。
- 内存缺口:派生类通常包含更多的成员变量。如果允许这种转化,派生类对象
d中特有的成员变量将处于未定义/未初始化的状态。 - 构造逻辑缺失:基类对象根本不知道如何去初始化派生类才有的那些属性。
3. 对象转化 vs 指针/引用转化的本质区别
这是很多开发者容易混淆的地方。请看下表对比:
| 特性 | 对象之间的转化 (Base b = d) | 指针/引用之间的转化 (Base* p = &d) |
|---|---|---|
| 内存操作 | 发生拷贝(创建了新对象) | 不发生拷贝(只是换个视角看同一块内存) |
| 多态性 | 完全丢失(静态绑定) | 保留(动态绑定,支持虚函数) |
| 安全性 | 导致数据丢失(切片问题) | 只要是向上转型,就是绝对安全的 |
| 双向性 | 仅支持派生 \rightarrow 基类 | 向上自动转,向下可强制转 (dynamic_cast) |
4. 只有一种情况可以实现“伪转化”
如果你真的需要从一个基类对象“变成”一个派生类对象,你必须在派生类中显式定义一个特殊的构造函数:
class Derived : public Base {
public:
// 接收一个基类引用,并手动初始化派生类特有的部分
Derived(const Base& b) : Base(b) {
this->extra_data = 0; // 手动补齐派生类成员
}
};
// 此时可以这样写,但这本质上是“构造”了一个新对象,而不是类型转换
Base b;
Derived d = b;
总结
在 C++ 的设计哲学中,对象是内存的实体,而指针/引用是访问内存的接口。
- 实体之间因为大小和结构不同,无法随意转化(除非切片)。
- 接口之间通过类型系统进行约束,允许在确保安全的前提下灵活切换视角。
3.虚函数
1.对虚函数的调用可能在运行时才被解析
1. 触发运行时解析的两个必要条件
并不是所有的虚函数调用都会在运行时解析。要触发这一特性,必须同时满足以下两个条件:
- 通过基类的指针或引用进行调用:如果直接通过对象调用,编译器在编译时就已知对象的确切类型。
- 所调用的函数在基类中被声明为
virtual。
Base* ptr = getObject(); // 编译时不知道 ptr 指向 Base 还是 Derived
ptr->speak(); // 运行时解析:根据 ptr 实际指向的对象决定调用哪个版本的 speak
2. 核心机制:虚函数表(Vtable)
运行时解析并非“凭空发生”,而是依赖于编译器在幕后维护的一套数据结构:
- Vtable (虚函数表):每个包含虚函数的类都有一个。它本质上是一个函数指针数组,存储了该类所有虚函数的地址。
- Vptr (虚函数指针):每个对象实例的内存空间中,都隐藏着一个指针,指向该类对应的 Vtable。
运行时解析的具体步骤:
- 程序运行到调用处,获取对象的 Vptr。
- 通过 Vptr 找到该类对应的 Vtable。
- 在 Vtable 中根据函数偏移量找到具体的函数地址。
- 跳转到该地址执行代码。
3. 为什么说是“可能”?(编译器的优化)
你的措辞非常准确——是“可能”而非“绝对”。在以下几种情况下,编译器会执行静态解析(早绑定)以提升性能:
A. 对象调用(而非指针或引用)
当你直接操作对象时,类型是确定的,不具备多态空间。
C++
Derived d;
d.speak(); // 静态解析:编译器明确知道这是 Derived::speak
B. 使用 final 关键字
如果一个类或函数被标记为 final,编译器知道它不可能再有子类或被重写,因此会直接进行静态绑定。
C. 构造函数与析构函数内部
正如我们之前讨论过的,在构造/析构期间,多态机制失效,虚函数调用会被静态解析为当前类的版本。
D. 编译器去虚化(Devirtualization)
现代编译器(如 Clang 或 GCC)非常智能。如果编译器能够通过上下文分析出指针或引用指向的绝对是某个特定类,它会绕过 Vtable 直接调用,这种技术称为去虚化。
4. 运行时解析的代价
虽然运行时解析带来了极大的灵活性,但也并非没有代价:
- 内存开销:每个对象多了一个指针(Vptr),每个类多了一张表(Vtable)。
- 执行开销:增加了一次指针跳转(查表)的动作。
- 内联受限:由于函数地址在运行时才确定,编译器通常无法对虚函数进行内联优化(Inline),这在高性能计算中可能成为瓶颈。
总结
“在运行时解析”意味着 C++ 把决策权从编译器交给了运行环境。这使得我们可以编写高度通用的代码(如处理 Shape* 数组),而不需要知道数组里具体是圆还是方,因为每个对象自己“知道”该调用哪个函数。
2.派生类中的虚函数
1. 重写(Override)的本质
当派生类定义了一个与基类虚函数原型完全一致(函数名、参数列表、const 属性均相同)的函数时,派生类的函数就会在虚函数表(Vtable)中替换掉基类的函数地址。
- 隐式虚特性:一旦基类声明了
virtual,派生类中的同名同参函数自动成为虚函数,即使你不写virtual关键字。 - 建议做法:始终在派生类中使用
override关键字。
class Base {
public:
virtual void func(int x) { cout << "Base"; }
};
class Derived : public Base {
public:
// 明确告诉编译器这是在重写,如果原型不匹配(比如写成了 double),编译器会报错
void func(int x) override { cout << "Derived"; }
};
2. 派生类可以改变访问权限吗?
这是一个非常有意思的特性:派生类重写虚函数时,可以改变该函数的访问级别(public/protected/private)。
- 场景:基类中函数是
private的,派生类可以将其重写为public。 - 规则:访问检查是在编译期根据静态类型决定的。
class Base {
private:
virtual void secret() { cout << "Base Secret"; }
};
class Derived : public Base {
public:
void secret() override { cout << "Derived Reveal!"; }
};
// 使用:
Derived d;
d.secret(); // OK: Derived 中是 public
Base* b = &d;
// b->secret(); // Error: Base 中 secret 是 private,编译不通过
3. 协变返回类型 (Covariant Return Types)
通常情况下,派生类重载虚函数要求返回类型必须完全一致。但有一个例外:如果原返回类型是基类的指针或引用,派生类可以将其修改为派生类的指针或引用。
这被称为协变,它在实现“克隆(Clone)”等模式时非常有用。
class Base {
public:
virtual Base* clone() { return new Base(*this); }
};
class Derived : public Base {
public:
// 返回类型从 Base* 变为 Derived*,这是允许的
Derived* clone() override { return new Derived(*this); }
};
4. 派生类中的 final 关键字
如果你希望某个虚函数在当前类中是最后一次重写,不允许再往下的子类继续重写它,可以使用 final。
class Intermediate : public Base {
public:
void func(int x) override final {
// 这里的实现是最终版本,SubDerived 不能再重写它
}
};
5. 避免“虚函数屏蔽”(Name Hiding)
如果你在派生类中定义了一个与基类虚函数同名但参数不同的函数,这不是重写,而是隐藏。
- 后果:通过派生类对象将无法访问基类的那个虚函数版本。
- 对策:使用
using Base::func;。
class Base {
public:
virtual void func(int x);
};
class Derived : public Base {
public:
void func(double x); // 隐藏了 Base::func(int)
};
// Derived d;
// d.func(10); // 可能会调用 func(double),或者报错,取决于编译器
6. 派生类调用基类的虚函数版本
有时派生类重写虚函数是为了“增强”功能,而不是完全替换。此时需要在派生类内部显式调用基类版本。
void Derived::func(int x) override {
Base::func(x); // 先执行基类的逻辑(静态绑定)
// ... 派生类特有的逻辑
}
总结:派生类虚函数的最佳实践
- 使用
override:强制编译器检查原型,避免低级错误。 - 考虑
final:如果不希望继承链无限延伸。 - 注意协变:需要返回“当前类型”的对象副本时,利用协变可以避免调用者进行强制类型转换。
- 不要修改默认参数:正如之前讨论过的,默认参数是静态绑定的,在派生类中修改它会导致极其混乱的“混血”行为。
3.final和override
1. override:防止“意外重载”
override 的作用是显式告诉编译器:“这个函数必须是重写基类中的某个虚函数。”
解决的问题
在传统 C++ 中,如果你在派生类中写错了一个字符或参数类型,编译器会认为你定义了一个新的函数(重载),而不是重写基类的虚函数。
class Base {
public:
virtual void func(int x) const;
};
class Derived : public Base {
public:
// 假设开发者想重写,但漏写了 const 或写错了类型
void func(int x) { ... } // 编译器:这是一个新函数,不是重写。
};
在上面的代码中,如果你通过 Base* 调用 func,执行的依然是 Base 的逻辑,这种 Bug 极难发现。
使用 override 后
class Derived : public Base {
public:
void func(int x) override { ... }
// 编译器报错:Base 中没有与其签名完全匹配的虚函数(缺少 const)
};
核心好处:
- 强制匹配:确保函数名、参数、返回值、
const属性、引用限定符完全一致。 - 文档化:阅读代码的人一眼就能看出哪些函数是多态接口的一部分。
2. final:终结继承与重写
final 有两个完全不同的应用场景:
A. 禁止虚函数被进一步重写
如果你认为某个类对虚函数的实现已经是“最终版本”,不希望后续的子类再修改它,可以在函数声明后加 final。
class Base {
public:
virtual void step();
};
class Intermediate : public Base {
public:
void step() override final { // 以后谁也别改我了
cout << "Intermediate's version";
}
};
class Sub : public Intermediate {
public:
// void step() override; // 编译报错:无法重写 final 函数
};
B. 禁止类被继承
如果你设计的一个类结构已经完整,不希望他人通过继承来扩展它(或出于性能考虑),可以将整个类标记为 final。
class SecureBuffer final : public Base {
// 这个类不能有子类
};
// class Exploit : public SecureBuffer {}; // 编译报错
3. final 的隐藏福利:性能优化(去虚化)
这是编译器层面的一项重要优化,称为 去虚化 (Devirtualization)。
- 原理:当编译器看到一个
final类或final虚函数时,它确定在该点之后不可能再有多态发生。 - 结果:编译器会将原本需要通过虚函数表(Vtable)查表进行的“动态绑定”,直接转换为“静态绑定”(直接调用函数地址)。
- 效果:消除了查表的开销,并允许编译器进行内联(Inline)优化,这在循环中频繁调用虚函数时能显著提升性能。
4. 组合使用:override final
在某些复杂的继承体系中,你可能会看到两个关键字连用:
class Derived : public Base {
public:
void func() override final;
};
override:确保我正确重写了父类的东西。final:确保我的子类不能再改动这个函数。
5. 最佳实践建议
- 始终使用
override:只要是重写基类的虚函数,务必写上override。这应该成为一种肌肉记忆,因为它能让编译器帮你找 Bug。 - 谨慎使用
final关键字:只有当你确定某个类或函数在设计逻辑上已经达到顶端,或者通过性能测试发现虚函数调用是瓶颈时,再考虑使用final。 - 接口设计:在设计底层库时,利用
final可以防止用户破坏你的内部逻辑一致性。
4.回避虚函数的机制
1. 使用作用域解析运算符 (::)
这是最常用、最直接的回避方式。通过显式指定类名,编译器会在编译时确定要调用的函数地址。
语法:
ptr->ClassName::FunctionName()` 或 `obj.ClassName::FunctionName()
典型场景:在派生类中调用基类逻辑
当派生类重写了虚函数,但仍需要运行基类的部分逻辑时,必须回避虚机制,否则会导致无限递归。
class Base {
public:
virtual void process() { cout << "Base logic" << endl; }
};
class Derived : public Base {
public:
void process() override {
// 回避虚机制:如果不加 Base::,则会无限调用自己
Base::process();
cout << "Derived additional logic" << endl;
}
};
2. 通过对象(非指针或引用)调用
虚函数的多态性仅在通过指针或引用调用时才会触发。如果你直接通过具体的对象实例调用函数,编译器知道该对象的类型在生存期内不会改变,因此会直接进行静态绑定。
Derived d;
d.process(); // 静态绑定:直接调用 Derived::process,不查虚函数表
3. 为什么需要回避虚函数机制?
主要有以下三个核心原因:
- 实现功能的“增量扩展”:如上所述,子类在重写父类功能时,通常需要先执行父类的初始化或基础处理。
- 性能优化:虚函数调用需要查表(Vtable Lookup)和两次指针跳转,且通常无法进行内联(Inlining)。在极高性能要求的循环中,显式调用特定版本可以节省几十个时钟周期。
- 安全性(构造与析构):在构造函数和析构函数中,系统会自动回避多态(正如我们之前讨论过的,此时 vptr 指向的是当前类的表)。
4. 强制回避的潜在风险
强行回避虚机制虽然给了开发者精确的控制权,但也打破了多态的封装性:
- 破坏抽象:调用者需要知道对象的具体继承层级。如果基类的逻辑发生变化,或者继承链被修改,硬编码的
Base::func()可能会导致逻辑错误。 - 维护成本:如果代码中充斥着大量的类名修饰符,多态带来的简化代码、解耦模块的优势就会丧失。
5. 编译器的自动回避:去虚化 (Devirtualization)
现代高级编译器(如 GCC, Clang, MSVC)在开启优化(如 -O2 或 -O3)时,会自动尝试“回避”不必要的虚函数机制。
- 全程序优化 (LTO):编译器通过分析整个程序的调用链,如果发现某个基类指针在运行期间仅可能指向一种派生类,它会自动把动态调用改成静态调用。
final关键字助力:当你给类或函数加上final后,编译器得到了“官方许可”去进行去虚化,因为它确定没有其他重写版本了。
总结
| 回避方法 | 实现手段 | 绑定时间 | 适用场景 |
|---|---|---|---|
| 显式调用 | Base::func() | 编译期 | 内部重写逻辑、显式调用基类 |
| 对象直接调用 | obj.func() | 编译期 | 确定对象类型且无需多态时 |
| 编译器优化 | final / LTO | 编译/链接期 | 无需代码修改的自动提速 |
4.抽象基类
1.纯虚函数
在 C++ 编程中,纯虚函数(Pure Virtual Function) 是实现多态性(Polymorphism)和定义接口(Interface)的核心机制。
简单来说,纯虚函数是一种在基类中声明但不具体实现的虚函数,它要求任何派生出的子类都必须提供自己的具体实现。
1. 语法定义
纯虚函数的声明方式是在虚函数声明的末尾加上 = 0:
class Base {
public:
// 纯虚函数
virtual void func() = 0;
};
2. 核心特征
- 抽象类 (Abstract Class): 包含至少一个纯虚函数的类被称为“抽象类”。
- 不可实例化: 你不能创建抽象类的对象(例如:
Base obj;会报错)。它的存在仅仅是为了被继承。 - 强制重写: 如果一个类继承自抽象类,它必须重写(Override)所有的纯虚函数,否则该子类也会变成抽象类,同样无法实例化。
3. 为什么要用纯虚函数?
纯虚函数的主要目的是为了定义接口规范。它告诉子类:“你必须有这个功能,但具体你怎么做,我(基类)不管。”
场景示例:绘图系统
假设你在写一个绘图软件,有“圆形”、“矩形”和“三角形”。它们都有一个共同的动作:draw()。
class Shape {
public:
// 纯虚函数:因为“形状”本身没法画,只有具体的圆形、矩形才能画
virtual void draw() = 0;
};
class Circle : public Shape {
public:
void draw() override {
// 实现画圆的代码
}
};
class Rectangle : public Shape {
public:
void draw() override {
// 实现画矩形的代码
}
};
4. 纯虚函数 vs 普通虚函数
| 特性 | 普通虚函数 (virtual func() {}) | 纯虚函数 (virtual func() = 0) |
|---|---|---|
| 是否有定义 | 有(提供默认实现) | 通常没有(仅声明接口) |
| 子类是否必须重写 | 可选 | 强制 |
| 所属类是否可实例化 | 可以 | 不可以(是抽象类) |
5. 关键注意事项
- 析构函数: 虽然类中可以有纯虚函数,但基类的析构函数通常应该声明为
virtual(虚析构),以确保子类对象通过基类指针删除时能正确调用子类的析构函数。 - 纯虚函数也可以有定义: 这是一个冷知识。在 C++ 中,你可以为纯虚函数提供函数体,但即便如此,该类仍然是抽象类,无法实例化。子类依然需要重写该函数,但可以通过
Base::func()调用基类的版本。
总结: 纯虚函数是 C++ 中用来搭建“框架”和“合同”的工具。它确保了所有的子类都遵循统一的接口,从而让你可以编写出高度通用且易于扩展的代码。
2.含纯虚函数的类是抽象类
1. 为什么它是“抽象”的?
“抽象”一词在编程中意味着“不完整”。
由于纯虚函数没有在当前类中提供具体的实现(或者说它只是一个占位符),编译器认为这个类是不完整的。
- 禁止实例化: 你不能创建抽象类的对象。class Animal {
public:
virtual void makeSound() = 0; // 纯虚函数
};
// Animal a; // 错误!编译失败,因为 Animal 是抽象类
2. 抽象类的“合同”作用
抽象类更像是一份法律合同或设计蓝图。它规定了所有派生类“必须做什么”,但并不限制“怎么做”。
- 强制性: 任何继承自抽象类的子类,除非它也想当抽象类,否则必须重写所有的纯虚函数。
- 多态的基础: 尽管不能创建抽象类的对象,但你可以创建抽象类的指针或引用,指向它的子类对象。这是实现运行时多态的核心。
3. 容易混淆的细节
A. 抽象类可以有非虚成员吗?
可以。 抽象类不仅仅能有纯虚函数,它还可以拥有:
- 成员变量(Data members)
- 普通构造函数和析构函数
- 已经实现的普通函数或虚函数
B. 纯虚析构函数
这是一个特殊情况。如果你想让一个类变成抽象类,但又没有任何合适的函数可以作为纯虚函数,你可以将析构函数声明为纯虚的:
class Base {
public:
virtual ~Base() = 0; // 纯虚析构函数
};
// 注意:纯虚析构函数必须在类外提供定义,否则子类析构时会出错
Base::~Base() {}
4. 抽象类 vs. 接口 (Interface)
在其他语言(如 Java 或 C#)中有专门的 interface 关键字。C++ 没有这个关键字,它是通过只包含纯虚函数且没有成员变量的抽象类来实现接口功能的。
| 特性 | 抽象类 (Abstract Class) | 接口 (Interface-like Class) |
|---|---|---|
| 组成 | 纯虚函数 + 普通函数 + 变量 | 通常只有纯虚函数 |
| 目的 | 代码复用 + 定义规范 | 仅定义行为规范 |
| C++ 实现 | 包含 = 0 函数的类 | 包含 = 0 函数且无数据的类 |
总结
- 含纯虚函数 = 抽象类。
- 抽象类 = 不能实例化。
- 用途 = 统一接口,强制子类实现特定功能。
3.派生类构造函数只初始化它的基类和它自己新增的成员变量
1. 派生类构造函数的双重职责
一个完整的派生类构造函数通常由两部分组成:
- 调用基类构造函数: 确保从基类继承而来的数据成员被正确初始化。
- 初始化自身成员: 对派生类中定义的成员变量进行赋值或初始化。
语法示例:
class Base {
int x;
public:
Base(int val) : x(val) {} // 基类构造函数
};
class Derived : public Base {
int y; // 派生类自己的成员
public:
// 这里的 : Base(a) 是初始化基类
// 这里的 , y(b) 是初始化自己的成员
Derived(int a, int b) : Base(a), y(b) {
// 构造函数体
}
};
2. 构造顺序(非常重要)
即使你在初始化列表中把自己的变量写在基类前面,C++ 的执行顺序也永远是:
- 最先调用基类构造函数: 先把“地基”打好。
- 然后初始化派生类成员: 按照成员在类中声明的顺序初始化。
- 最后执行派生类构造函数的大括号
{ }内的代码。
注意: 如果基类是抽象类(含纯虚函数),派生类依然必须调用基类的构造函数。虽然抽象类不能独立存在,但它内部定义的变量(如
id或name)仍需通过构造函数初始化。
3. 如果你不显式初始化基类会怎样?
如果你在派生类构造函数的初始化列表中没有写基类:
- 默认调用: 编译器会尝试自动调用基类的默认构造函数(即无参构造函数)。
- 编译报错: 如果基类只有带参数的构造函数,而你没有显式调用它,编译器会报错,因为它不知道该如何给基类的成员“打地基”。
4. 为什么不能“只”初始化基类?
如果派生类构造函数“只”初始化基类,那么派生类自己定义的那些变量就会处于未初始化(随机值)的状态。这在 C++ 中是极其危险的,会导致程序崩溃或逻辑错误。
总结
- 基类部分: 必须初始化(显式或隐式)。
- 派生类部分: 必须初始化(为了代码安全)。
5.访问控制和继承
1.受保护的成员
在 C++ 的封装机制中,protected(受保护) 成员是一个处于“中间地带”的访问修饰符。它比 private 更开放,但比 public 更保守。
它的核心存在意义是为了继承。
1. 访问权限对比
为了清晰理解,我们将 protected 与其他两个修饰符进行对比:
| 访问修饰符 | 在本类中访问 | 在子类(派生类)中访问 | 在类外部(如 main 函数)访问 |
|---|---|---|---|
public | ✅ 可以 | ✅ 可以 | ✅ 可以 |
protected | ✅ 可以 | ✅ 可以 | ❌ 不可以 |
private | ✅ 可以 | ❌ 不可以 | ❌ 不可以 |
2. 为什么要使用 protected?
通常在设计基类(特别是你之前提到的抽象类)时,有些数据或工具函数不希望被外界随意修改,但你希望你的“后代”能够直接使用它们。
场景示例:
假设你有一个抽象类 Shape,它有一个颜色属性。你希望子类(如 Circle)能直接设置这个颜色,但不允许 main 函数里的用户随便改。
class Shape {
protected:
string color; // 受保护成员:子类可见,外界不可见
public:
virtual void draw() = 0; // 纯虚函数
};
class Circle : public Shape {
public:
void setColor(string c) {
color = c; // 合法!子类可以直接访问基类的 protected 成员
}
void draw() override {
cout << "Drawing a " << color << " circle." << endl;
}
};
int main() {
Circle c;
c.setColor("Red"); // 合法:调用 public 函数
// c.color = "Blue"; // 错误!color 是 protected,在 main 函数中无法直接访问
}
3. protected 与继承方式
当你使用 class Derived : public Base 时,基类的 protected 成员在子类中依然是 protected。
但 C++ 中还有更复杂的继承方式会影响它的去向:
- 公有继承 (
public inheritance):基类的protected变为子类的protected。 - 保护继承 (
protected inheritance):基类的public和protected成员在子类中都变成protected。 - 私有继承 (
private inheritance):基类的所有成员在子类中都变成private。
4. 经验法则(Best Practices)
- 数据成员通常首选
private:即使是基类,为了极致的封装,有些开发者倾向于将数据设为private,然后提供protected的 Getter/Setter 函数。 - 工具函数适合
protected:如果基类有一些辅助计算的函数,只给子类用,不给外界用,设为protected是完美的。 - 防止直接实例化:有时候将构造函数设为
protected,可以达到和“纯虚函数”类似的效果——防止该类在外部被实例化,只能被子类继承。
2.公有、私有和受保护的继承1. 访问权限转换矩阵
这是理解继承最直观的方式。无论哪种继承,基类的 private 成员在派生类中永远是不可见(不可直接访问)的。
| 基类成员访问权限 | 公有继承 (public) | 受保护继承 (protected) | 私有继承 (private) |
|---|---|---|---|
public | 仍为 public | 变为 protected | 变为 private |
protected | 仍为 protected | 仍为 protected | 变为 private |
private | 不可见 | 不可见 | 不可见 |
2. 三种继承方式的详细拆解
① 公有继承 (public) —— 最常用
- 语义: 表示 “is-a”(是一个) 的关系。例如:轿车“是一个”交通工具。
- 特点: 基类的特征原封不动地传递给子类。子类的对象可以当做基类对象来使用。
- 代码示例:class Base { public: int a; };
class Derived : public Base { };
// Derived 对象在外部可以直接访问 a
② 受保护继承 (protected) —— 极少用
- 语义: 基类的所有公开接口在子类中都被“保护”起来了。
- 特点: 除了子类及其后代,外界无法通过子类对象访问基类的成员。
- 影响: 它切断了子类对象与基类接口的直接联系,但允许孙子类继续使用这些成员。
③ 私有继承 (private) —— 偶尔用于组合
- 语义: 表示 “implemented in terms of”(根据…实现) 的关系。
- 特点: 基类的所有内容在子类中都变成了“私有秘密”。
- 影响: 即使是孙子类(派生类的派生类)也无法访问基类的任何成员。它将继承关系终结在了当前这一代。
3. 核心区别:对“外部”和“后代”的影响
为了方便记忆,你可以关注这两个维度:
- 外部访问(通过对象点号访问):
- 只有
public继承 允许外部访问基类的public成员。 protected和private继承都会把基类的所有成员对外界“锁死”。
- 只有
- 后代访问(孙子类):
public和protected继承允许孙子类继续访问基类的成员。private继承则会让基类成员在孙子类中彻底消失(不可见)。
[Image showing visibility levels in C++ inheritance hierarchy for child and grandchild classes]
4. 为什么 C++ 需要这么多继承方式?
虽然 90% 的场景都在用 public 继承,但其他两种也有其用处:
- 私有继承 常用于“组合”的替代方案。如果你想使用某个类的功能,但不希望你的类被视为那个类的“一种”,也不希望暴露对方的接口,就可以用私有继承。
- 封装性: 它们提供了比 Java 或 C# 更精细的访问控制,允许开发者严格限制类库的接口暴露。
3.派生类向基类转化的可访问性
1. 核心规则:谁能看到“继承关系”?
转换的可访问性取决于继承方式(public, protected, private)以及发起转换的位置。
A. 公有继承 (Public Inheritance)
- 谁可以转换: 任何人(类内部、派生类、外部函数如
main)。 - 原因:
public继承代表 “is-a” 关系,全世界都知道Derived是一个Base。
B. 受保护继承 (Protected Inheritance)
- 谁可以转换: 只有派生类自己和派生类的子类(孙子类)。
- 原因: 对外部而言,这种继承关系是受保护的秘密;只有家族成员知道它们属于同一个谱系。
C. 私有继承 (Private Inheritance)
- 谁可以转换: 只有派生类自己。
- 原因: 这是一种纯粹的内部实现细节,甚至连派生类的子类都不知道这个继承关系的存在。
2. 可访问性矩阵
假设有 class D : <mode> B,以下是执行 B* ptr = &d_obj; 的可行性:
| 转换发生的位置 | Public 继承 | Protected 继承 | Private 继承 |
|---|---|---|---|
| 外部代码 (如 main) | ✅ 允许 | ❌ 禁止 | ❌ 禁止 |
| 派生类成员/友元 | ✅ 允许 | ✅ 允许 | ✅ 允许 |
| 派生类的子类 | ✅ 允许 | ✅ 允许 | ❌ 禁止 |
3. 代码示例演示
class Base {};
// 私有继承:只有 D 自己知道它继承自 Base
class PrivateDerived : private Base {
public:
void memberFunc() {
Base* b = this; // ✅ 成功:派生类内部可以转换
}
};
class GrandChild : public PrivateDerived {
public:
void gcFunc() {
// Base* b = this; // ❌ 错误:私有继承下,孙子类看不到 Base
}
};
int main() {
PrivateDerived d;
// Base* b = &d; // ❌ 错误:外部代码(main)看不到这个继承关系
}
4. 为什么要这样设计?(设计意图)
这种限制是为了维持封装性:
- 接口承诺:通过
public继承,你向外界承诺Derived可以当做Base用。 - 实现细节:通过
private继承,你只是想复用Base的代码,而不希望外界把你的类当做Base的一种。如果你允许外部进行向上转型,就打破了这种“隐藏实现”的初衷。
5. 一个特殊的注意事项
即使是 private 继承,在以下情况下转换也会失败:
- 强制类型转换 (
C-style cast):虽然(Base*)&d这种强制转换在某些编译器下能强行通过,但它极具风险,因为它绕过了 C++ 的类型安全检查,破坏了封装。在编写现代 C++ 时,应始终尊重继承的访问权限。
总结
- 想让外界能转: 用
public。 - 只想让后代能转: 用
protected。 - 只想让自己能转: 用
private。
4.友元和继承
1. 核心规则:友元不能继承
① 基类的友元 不是 派生类的友元
如果类 A 是类 B 的友元,类 C 继承自类 B,那么类 A 并不能 直接访问类 C 新增的私有或受保护成员。
class Base {
friend class FriendClass; // FriendClass 是 Base 的友元
private:
int base_val = 1;
};
class Derived : public Base {
private:
int derived_val = 2;
};
class FriendClass {
public:
void access(Base& b, Derived& d) {
cout << b.base_val; // ✅ 合法:可以访问基类的私有成员
// cout << d.derived_val; // ❌ 错误:FriendClass 不是 Derived 的友元
}
};
② 派生类的友元 不是 基类的友元
反之亦然。如果类 F 是派生类 Derived 的友元,它能访问 Derived 自己的秘密,但除非基类 Base 明确授权,否则 F 不能访问 Base 的私有成员。
2. 为什么这样设计?
如果友元关系可以继承,将会导致封装性的彻底破坏:
- 如果基类的友元能自动访问派生类,那么任何人只要继承一个类,他的私有成员就可能被他完全不知道的“基类的老朋友”窥探。
- C++ 的设计哲学是:只有类本身能决定谁是它的友元。
3. 如何解决?(巧妙的替代方案)
虽然友元关系不能直接继承,但我们经常需要通过友元来处理整个类族。常用的技巧是:友元 + 虚函数。
如果你希望基类的友元(比如一个全局输出运算符 operator<<)能够根据具体的派生类表现出不同的行为,你可以让友元函数调用一个虚函数。
class Base {
friend ostream& operator<<(ostream& os, const Base& b);
public:
virtual void print(ostream& os) const { os << "Base content"; }
};
// 友元函数只负责触发虚函数
ostream& operator<<(ostream& os, const Base& b) {
b.print(os);
return os;
}
class Derived : public Base {
public:
void print(ostream& os) const override { os << "Derived content"; }
};
在这个例子中,operator<< 虽然只是 Base 的友元,但通过多态性,它实际上实现了对 Derived 数据的访问。
4. 总结对比
| 特性 | 是否支持 | 说明 |
|---|---|---|
| 友元不继承 | ✅ | 基类的友元看不见子类的秘密。 |
| 友元不互逆 | ✅ | A 是 B 的友元,不代表 B 是 A 的友元。 |
| 友元不传递 | ✅ | A 是 B 的友元,B 是 C 的友元,不代表 A 是 C 的友元。 |
| 派生类访问基类友元 | ❌ | 派生类不能使用基类的友元函数作为自己的。 |
5. 易错点提示
- 受保护成员的特殊性:即使不是友元,派生类也能访问基类的
protected成员。但派生类的友元只能访问派生类从基类继承下来的那部分protected成员,不能直接通过基类对象去访问基类的protected成员(除非它也是基类的友元)。
5.改变个别成员的可访问性
1. 使用 using 声明
在派生类的合适访问区域(public、protected 或 private 部分)使用 using 关键字,可以重新定义基类成员在派生类中的访问级别。
语法示例:
class Base {
public:
void commonFunc() {}
void secretFunc() {}
protected:
int m_value = 0;
};
// 私有继承:默认所有 Base 成员在 Derived 中都变成 private
class Derived : private Base {
public:
// 恢复 commonFunc 的公有访问权限
using Base::commonFunc;
protected:
// 将 m_value 恢复为保护权限,供孙子类使用
using Base::m_value;
// secretFunc 依然保持 private(默认继承结果)
};
2. 关键规则与限制
- 不能放宽基类的私有成员: 派生类只能改变它有权访问的成员。因为基类的
private成员对派生类是不可见的,所以你无法通过using恢复它们的权限。 - 只能恢复或收紧,不能突破物理隔离: 你可以将基类的
protected成员在子类中提升为public,但前提是你必须是通过公有或保护继承,且该成员在基类中本就不是私有的。 - 成员名一致:
using声明只使用成员名,不需要写参数列表或返回类型。这意味着如果基类中有重载函数,using Base::func;会一次性恢复该名称下所有重载版本的权限。
3. 实际应用场景:隐藏不相关的接口
假设你正在编写一个“栈”类 (Stack),你决定复用 std::vector 的功能,所以使用了私有继承。但是,你不想让用户看到 vector 的所有方法(比如 insert 或 clear),你只想暴露 empty 和 size。
#include <vector>
class MyStack : private std::vector<int> {
public:
// 尽管是私有继承,但我只想让用户用这两个方法
using std::vector<int>::empty;
using std::vector<int>::size;
void push(int v) { push_back(v); }
void pop() { pop_back(); }
};
int main() {
MyStack s;
s.push(10);
if (!s.empty()) { // 合法:使用了 using 提升权限
return s.size();
}
// s.push_back(5); // 错误:push_back 是私有的
}
4. 另一种老式方法(已不推荐)
在早期的 C++ 中,人们使用“访问声明”(Access Declaration)来达到同样的目的:
Base::commonFunc; // 不带 using 关键字,仅写类名::成员名
注意: 这种语法在现代 C++ 中已被弃用,请始终使用 using 关键字,因为它的语义更清晰,且支持模板。
5. 总结:权限的“开关”
| 操作位置 | 效果 |
|---|---|
在 public 区写 using Base::x; | 无论继承方式如何,x 在子类外可见。 |
在 protected 区写 using Base::x; | x 仅对子类及其后代可见。 |
在 private 区写 using Base::x; | x 仅对本类可见。 |
6.默认的继承保护级
1. 核心规则
- 使用
class定义派生类时: 默认继承方式是private(私有继承)。 - 使用
struct定义派生类时: 默认继承方式是public(公有继承)。
示例代码对比:
class Base {
public:
int a;
};
// 情况 A:使用 class,省略继承方式
class Derived1 : Base {
// 相当于 class Derived1 : private Base
};
// 情况 B:使用 struct,省略继承方式
struct Derived2 : Base {
// 相当于 struct Derived2 : public Base
};
int main() {
Derived1 d1;
// d1.a = 10; // ❌ 错误:a 在 Derived1 中是私有的
Derived2 d2;
d2.a = 10; // ✅ 合法:a 在 Derived2 中依然是公有的
}
2. 为什么会有这种区别?
这主要源于 C++ 对 C 语言兼容性 的考虑:
struct的初衷: 在 C 语言中,struct只是纯粹的数据聚合体,没有封装概念。为了保持兼容,C++ 默认struct的所有内容(包括成员访问权限和继承方式)都是公开的。class的初衷:class是 C++ 引入的关键字,核心理念是封装。为了强调安全性,默认将权限设为最高级别的保护(即私有)。
3. 关联知识:成员的默认访问权限
这个规则也同样适用于类内部成员的定义。如果你不写 public: 等标签:
| 关键字 | 默认继承方式 | 内部成员默认访问权限 |
|---|---|---|
class | private | private |
struct | public | public |
4. 编程最佳实践建议
尽管编译器有默认行为,但在编写继承代码时,强烈建议始终显式写出继承方式(例如 public):
- 可读性: 让其他开发者一眼看出类与类之间的关系(是 “is-a” 还是内部实现复用)。
- 避免混淆: 尤其是在同时使用
class和struct的大型项目中,显式标注可以有效防止因记错默认规则而导致的编译错误。
推荐写法:
class MyController : public BaseController { // 明确标注 public
// ...
};
总结
class默认“关门”: 继承是私有的,成员也是私有的。struct默认“开门”: 继承是公有的,成员也是公有的。
6.继承中的类作用域
1.在编译时进行名字查找
1. 名字查找的基本顺序
编译器查找名字遵循“由内而外”的原则。一旦在某个作用域(Scope)找到了这个名字,它就会停止查找,不再往更外层的嵌套看。
查找的基本路径通常是:
- 局部作用域(函数内部)。
- 当前类的作用域。
- 基类的作用域(按继承链向上爬)。
- 命名空间作用域。
- 全局作用域。
2. 名字查找的“遮蔽效应”(Hiding)—— 核心陷阱
这是名字查找中最重要的一条规则:查找先于类型检查。
如果编译器在派生类中找到了名字,它就不会再去基类找了,即使基类中有一个参数匹配更好的同名函数。这被称为名字遮蔽(Name Hiding)。
示例:
class Base {
public:
void func(int x) { cout << "Base::func(int)" << endl; }
};
class Derived : public Base {
public:
void func(string s) { cout << "Derived::func(string)" << endl; }
};
int main() {
Derived d;
// d.func(10); // ❌ 编译错误!
}
为什么会报错?
- 编译器在
Derived类中找到了名字func。 - 名字查找停止。
- 编译器发现
Derived::func需要一个string,而你传了int,类型不匹配,报错。 - 它根本没去看
Base里的func(int)。
3. 静态类型决定查找范围
名字查找是基于对象的静态类型(指针或引用的类型)进行的,而不是动态类型。
Base* ptr = new Derived();
// ptr->func("hello"); // ❌ 编译错误
即使 ptr 实际指向的是 Derived 对象,但因为 ptr 的静态类型是 Base*,编译器只会在 Base 类及其父类中查找 func。如果 Base 里没有接受 string 的 func,编译就会失败。
4. 完整的查找与执行流程
编译器处理一个函数调用(如 p->f(args))时,遵循以下严格的四个步骤:
- 名字查找(Lookup): 确定
p的静态类型。在该类及其基类中寻找名为f的成员。如果找不到,报错。 - 重载解析(Overload Resolution): 在找到该名字的那个作用域中,寻找与参数
args最匹配的函数版本。如果匹配不上,报错。 - 访问控制检查(Access Control): 检查选中的函数是否为
public。如果不可访问,报错。 - 确定调用方式:
- 如果
f是虚函数且通过指针/引用调用,生成运行时动态绑定的代码(去虚函数表找)。 - 如果
f不是虚函数,直接生成调用该函数的静态代码。
- 如果
5. 如何“打破”遮蔽:using 声明
如果你希望派生类能够“看到”基类的同名重载函数,需要显式地把基类的名字引入到派生类的作用域中。
class Derived : public Base {
public:
using Base::func; // 让 Base 里的所有 func 对 Derived 可见
void func(string s) { ... }
};
// 现在 d.func(10); 就能成功调用 Base::func(int) 了
6. 总结对比
| 阶段 | 任务 | 依据 |
|---|---|---|
| 名字查找 | 找到这个“词”在哪 | 静态类型 & 作用域层级 |
| 重载解析 | 选出最合适的“版本” | 参数列表 |
| 访问控制 | 确定是否有权限看 | public/protected/private |
| 动态绑定 | 决定最终执行哪个函数体 | 虚函数表 (vtable) |
💡 一个深刻的提示
不要在派生类中重新定义一个非虚函数。
根据名字查找的规则,如果你在 Derived 里重定义了 Base 的非虚函数 f,那么通过 Base* 指针调用 f 会执行基类版本,通过 Derived 对象调用会执行派生类版本。这种不一致性会导致极其难以调试的 Bug。
2.通过作用域运算符来使用隐藏的函数
1. 核心语法
你可以通过 类名::成员名 的方式来指名道姓地访问成员:
- 在派生类内部:
Base::func(); - 在外部(如 main 函数):
obj.Base::func();
2. 代码演示:打破遮蔽
class Base {
public:
void show() { cout << "Base show" << endl; }
};
class Derived : public Base {
public:
// 遮蔽了基类的 show()
void show() { cout << "Derived show" << endl; }
void callBoth() {
show(); // 默认查找:调用 Derived::show()
Base::show(); // 显式指定:调用 Base::show()
}
};
int main() {
Derived d;
d.show(); // 输出:Derived show
d.Base::show(); // 输出:Base show(强制调用基类版本)
}
3. 一个极其重要的特性:强制关闭虚机制
这是作用域运算符最强大的地方之一:如果 show() 是一个虚函数(virtual),使用 :: 调用会禁用动态绑定。
在普通的多态中,通过基类指针调用虚函数会根据对象的实际类型运行。但如果你使用了 ::,编译器会执行静态绑定,直接跳转到你指定的那个类的代码块。
为什么要这样做?
这在派生类重写虚函数,但又需要复用基类逻辑时非常有用:
class Shape {
public:
virtual void draw() { /* 基础的绘图逻辑,如设置画笔颜色 */ }
};
class Circle : public Shape {
public:
void draw() override {
Shape::draw(); // 1. 先执行基类的通用初始化
// 2. 再执行画圆特有的逻辑
}
};
注意: 如果你在
Circle::draw里只写draw();而不加Shape::,会发生无限递归调用导致栈溢出,因为编译器会一直找到并调用它自己。
4. 解决多重继承中的二义性
在多重继承中,如果两个基类有同名成员,作用域运算符是唯一的救星。
class Printer { public: void start() {} };
class Scanner { public: void start() {} };
class AllInOne : public Printer, public Scanner {
public:
void work() {
// start(); // ❌ 错误:二义性,编译器不知道找谁
Printer::start(); // ✅ 明确指定
Scanner::start(); // ✅ 明确指定
}
};
5. 总结::: 的三板斧
- 突破遮蔽: 访问被子类同名成员隐藏的基类成员。
- 禁用虚机制: 在重写函数内部调用基类的原始实现,避免递归。
- 消除二义性: 在多重继承中指明到底使用哪个基类的成员。
💡 深度提示
虽然 :: 很方便,但如果在类外部频繁使用 obj.Base::func(),通常说明你的 接口设计可能存在问题(因为这违反了封装原则,外界不应该关心一个功能是来自基类还是派生类)。
3.名字查找优先于类型查找
1. 编译器的工作流水线
为了理解这个原则,我们可以看看编译器在处理一个函数调用(如 d.func(42))时的标准顺序:
- 第一步:名字查找(Name Lookup)
- 编译器在当前类
Derived中找func这个词。 - 找到了吗? 如果找到了,停止搜索!
- 没找到? 去基类
Base里找,直到找到或报错。
- 编译器在当前类
- 第二步:重载解析(Overload Resolution)
- 编译器盯着第一步找到的那个作用域里的所有同名函数。
- 检查参数类型(例如
42是int),看看哪个函数最匹配。
- 第三步:访问检查(Access Control)
- 检查选中的那个函数是不是
public的。
- 检查选中的那个函数是不是
2. 为什么这会导致“隐藏”现象?
假设有如下代码:
class Base {
public:
void print(int x) { cout << "Base int: " << x << endl; }
};
class Derived : public Base {
public:
void print(string s) { cout << "Derived string: " << s << endl; }
};
当你调用 d.print(10) 时:
- 名字查找: 编译器在
Derived里找到了print。查找结束,编译器现在只知道Derived::print(string)。 - 重载解析: 编译器尝试把整数
10转换成string。 - 结果: 转换失败。编译器报错:“无法将参数 1 从 int 转换为 string”。
关键点: 即使 Base 类里有一个完美的 print(int),编译器也根本没去“看”它,因为名字查找在 Derived 那一层就已经“吃饱”停下了。
3. “名字查找” vs “虚函数绑定”
这是一个容易混淆的地方。
- 名字查找(Compile-time): 发生在编译阶段,看的是静态类型。
- 虚函数绑定(Runtime): 发生在运行阶段,看的是动态类型。
如果函数是虚函数,名字查找依然先发生。如果名字查找在基类中找到了虚函数声明,那么在运行阶段才会根据虚函数表(vtable)去定位子类的实现。
4. 这种设计的深层原因
为什么 C++ 不设计成“在所有作用域中寻找最匹配的类型”?
- 避免远距离冲突: 如果基类在多年后新增加了一个重载版本,而编译器总是寻找全局最匹配的类型,那么这个基类的改动可能会在不知不觉中改变子类代码的运行逻辑(本来调用的是子类的函数,现在突然跳到了基类)。
- 性能: 限制查找范围可以加快编译速度。
5. 总结
| 规则 | 描述 |
|---|---|
| 查找范围 | 找到即止。子类有同名词,基类同名词即被“遮蔽”。 |
| 类型无关 | 查找只看名字,不看参数。参数匹配是查到名字之后的事。 |
| 解决方法 | 使用 using Base::func; 手动将基类名字引入子类作用域,打破遮蔽。 |
💡 进阶思考
如果你在派生类中定义了一个和基类同名、但参数完全一样的非虚函数,这其实也是名字查找优先的一个典型应用。这种做法虽然语法正确,但会导致“函数遮蔽”,使得通过基类指针和子类对象调用同一个名字时结果不同,这通常被视为一种不好的编程习惯。
4.虚函数和作用域
1. 查找路径:名字查找先于虚机制
当编译器看到 p->func() 时,它首先进行名字查找。这个查找是基于 p 的静态类型(声明类型)所属的作用域进行的。
- 如果在静态类型的作用域里找不到该名字: 编译报错(即便指向的实际对象里有这个函数)。
- 如果找到了: 编译器接着检查这个函数是否是
virtual。 - 只有是
virtual且通过指针/引用调用时: 运行时的虚函数机制才会启动。
2. 虚函数被“隐藏”的作用域陷阱
正如你之前提到的“名字查找优先”,如果派生类定义了一个与基类虚函数同名但参数不同的函数,基类的虚函数在派生类作用域内会被遮蔽(Hiding)。
class Base {
public:
virtual void func(int x) { cout << "Base::func"; }
};
class Derived : public Base {
public:
// 这里的 func 遮蔽了 Base::func,且它不是对虚函数的重写(因为参数不同)
void func(string s) { cout << "Derived::func"; }
};
int main() {
Derived d;
// d.func(10); // ❌ 错误:在 Derived 作用域找不到 func(int)
Base* pb = &d;
pb->func(10); // ✅ 成功:在 Base 作用域找到了 func(int),且它是虚函数,执行 Base::func
}
3. 作用域运算符 :: 强行跳出虚机制
作用域运算符可以显式指定编译器去哪个类的作用域里查找名字。一旦使用了 ::,C++ 会关闭动态绑定,无论该函数是否为虚函数。
- 用途: 当子类重写了虚函数,但仍想调用基类的实现时。
- 后果: 绕过虚函数表(vtable),直接进行静态链接。
4. 最阴险的陷阱:虚函数 + 默认实参
这是一个关于作用域和虚函数的经典高级 Bug。虚函数是动态绑定的,但默认实参是静态绑定的(根据作用域确定)。
class Base {
public:
virtual void display(int i = 10) { cout << "Base: " << i << endl; }
};
class Derived : public Base {
public:
void display(int i = 20) override { cout << "Derived: " << i << endl; }
};
int main() {
Base* pb = new Derived();
pb->display(); // 输出结果是什么?
}
答案:
Derived: 10为什么?
- 查找阶段: 编译器在
Base作用域查找display,发现默认参数是10。- 绑定阶段: 因为是虚函数,运行时调用了
Derived的函数体。- 结果: 使用了
Base的默认参数,运行了Derived的函数逻辑。
5. 总结
| 维度 | 规则 |
|---|---|
| 名字查找 | 由静态类型的作用域决定查找起点。 |
| 虚函数执行 | 由动态类型决定最终执行的代码块(仅限无 :: 调用)。 |
| 默认参数 | 由调用时的静态类型作用域决定。 |
| 覆盖(Override) | 必须在相同作用域(子类)且签名完全一致。 |
💡 最佳实践建议
- 始终使用
override关键字:这可以强制编译器检查签名是否一致,防止你意外地因为作用域遮蔽而没有真正重写虚函数。 - 绝对不要重新定义继承而来的默认实参:这会导致逻辑上的混乱(如上面的
Derived: 10示例)。
5.通过基类调用隐藏的虚函数
1. 核心逻辑:为什么基类指针能“复活”隐藏函数?
当编译器处理 ptr->func() 时,它的查找顺序是基于 ptr 的 静态类型 的。
- 如果是
Derived d; d.func(): 编译器在Derived作用域查找,找到了同名函数(哪怕参数不对),查找停止。基类版本被隐藏。 - 如果是
Base\* p = &d; p->func(): 编译器在Base作用域查找。它找到了Base::func,查找成功。
一旦名字查找成功,编译器会发现这是一个 virtual 函数,于是它不再管隐藏不隐藏,而是直接去 虚函数表(vtable) 里找最终要执行的代码。
2. 代码示例:隐藏与调用的博弈
class Base {
public:
virtual void show(int x) {
cout << "Base::show(int): " << x << endl;
}
};
class Derived : public Base {
public:
// 这里的参数是 string,它隐藏了基类的 show(int)
// 注意:这不是 override(重写),而仅仅是隐藏
void show(string s) {
cout << "Derived::show(string): " << s << endl;
}
};
int main() {
Derived d;
// d.show(10); // ❌ 错误:Derived 作用域内 show(int) 被隐藏了
Base* pb = &d;
pb->show(10); // ✅ 成功:输出 "Base::show(int): 10"
}
为什么 pb->show(10) 执行的是基类版本?
- 查找: 编译器看
pb的类型是Base*,在Base类里找到了show(int)。 - 虚机制: 编译器发现
Base::show是虚函数,去查d对象的虚函数表。 - 结果: 因为
Derived并没有重写(Override)show(int),它只是定义了一个同名的show(string)。所以虚函数表里show(int)的位置依然指向Base::show(int)的地址。
3. 深度对比:隐藏 vs 重写
通过基类调用时,行为完全不同:
| 情况 | 子类定义 | 基类指针调用 p->func() | 结果 |
|---|---|---|---|
| 隐藏 (Hiding) | 名字相同,参数不同 | 查找基类作用域 -> 找到虚函数 -> 查表 | 执行 基类 的版本 |
| 重写 (Overriding) | 名字相同,参数相同 | 查找基类作用域 -> 找到虚函数 -> 查表 | 执行 子类 的版本 |
4. 这种行为的危险性
这种通过基类调用“隐藏”函数的特性,有时会导致逻辑上的不对称性:
- 你通过
Derived对象无法访问的函数,通过Base*却能访问。 - 如果你原本的意图是想重写虚函数,却不小心写错了参数(变成了隐藏),编译器不会报错,但程序运行结果会完全背离你的预期(本该运行子类逻辑,结果运行了基类逻辑)。
5. 现代 C++ 的救星:override
为了防止这种“意外隐藏”而非“重写”的情况发生,C++11 引入了 override 关键字。
class Derived : public Base {
public:
// 如果你写了 override,但参数和基类不一样
void show(string s) override { } // ❌ 编译报错:没有重写任何基类成员
};
总结
- 名字查找 决定了你能看到哪些函数。
- 静态类型 决定了名字查找的起点。
- 虚函数表 决定了最终运行哪段代码。
利用基类指针调用被隐藏的虚函数是合法的,但这通常意味着你的子类接口设计与基类产生了歧义。
6.覆盖重载的函数
1. 现象:为什么我只重写了一个,其他的都没了?
这是因为编译器在进行“名字查找”时,一旦在派生类找到了这个名字,就不会再去基类找该名字的任何重载版本。
代码示例:
class Base {
public:
virtual void func(int x) { cout << "Base::func(int)" << endl; }
virtual void func(double d) { cout << "Base::func(double)" << endl; }
};
class Derived : public Base {
public:
// 我只想重写 int 版本
void func(int x) override { cout << "Derived::func(int)" << endl; }
};
int main() {
Derived d;
d.func(10); // ✅ 正常:调用 Derived::func(int)
// d.func(1.5); // ❌ 错误!编译不通过
}
编译错误原因: 编译器在 Derived 类中找到了名字 func。查找停止。但在 Derived 类中,只有一个 func(int)。编译器不会为了 1.5(double)去回溯基类里的 func(double)。
2. 解决方案:使用 using 声明
如果你希望在派生类中既能重写特定的版本,又能保留基类其他的重载版本,最标准的方法是使用 using 声明将基类的名字引入派生类作用域。
class Derived : public Base {
public:
// 将 Base 类中所有的 func 重载版本都“拉”进 Derived 的作用域
using Base::func;
// 现在重写我感兴趣的版本
void func(int x) override { cout << "Derived::func(int)" << endl; }
};
int main() {
Derived d;
d.func(10); // ✅ 调用 Derived::func(int)
d.func(1.5); // ✅ 调用 Base::func(double) —— 现在它不再被隐藏了!
}
3. 为什么 C++ 要这么设计?
这听起来很麻烦,但它实际上是一种安全保护机制,为了防止“意外重载”。
假设基类位于一个远端库中。如果基类后来增加了一个新的重载版本,而编译器会自动在基类和派生类之间跨作用域寻找最匹配的函数,那么基类的这个微小改动可能会在不经意间改变你派生类代码的调用逻辑。
通过隐藏机制,C++ 强制要求开发者通过 using 明确表示:“我确实想要基类里的那些重载版本”。
4. 覆盖重载函数的关键点总结
| 特性 | 行为说明 |
|---|---|
| 遮蔽效应 | 只要子类定义了同名函数,基类的所有重载版本对子类对象均不可见。 |
| 虚函数重写 | 如果基类函数是 virtual,子类重写时建议加上 override 确保签名一致。 |
使用 using | using Base::member; 会引入该名字的所有重载版本。 |
| 访问控制 | using 声明放在哪个区(public/protected),引入的函数就具有该区的权限。 |
💡 常见面试考点:重载 (Overload) vs. 覆盖 (Override)
在处理“覆盖重载函数”时,容易混淆这两个概念:
- 重载 (Overload):发生在同一个作用域内,函数名同,参数不同。
- 覆盖 (Override):发生在基类与派生类之间,函数名同,参数也同,且基类有
virtual。 - 隐藏 (Hiding):发生在基类与派生类之间,只要函数名同,参数不同(或者没写 virtual),基类版本就会被隐藏。
7.构造函数和拷贝控制
1.虚析构函数
简单来说,它的主要作用是:确保当通过基类指针删除派生类对象时,派生类的析构函数能够被正确调用,从而防止内存泄漏。
1. 为什么需要虚析构函数?
如果没有虚析构函数,当你使用基类指针指向一个派生类对象,并对该指针执行 delete 操作时,程序只会调用基类的析构函数,而不会调用派生类的析构函数。
错误示例(非虚析构函数):
#include <iostream>
class Base {
public:
Base() { std::cout << "Base 构造函数\n"; }
~Base() { std::cout << "Base 析构函数 (非虚)\n"; }
};
class Derived : public Base {
private:
int* data;
public:
Derived() {
data = new int[100];
std::cout << "Derived 构造函数\n";
}
~Derived() {
delete[] data;
std::cout << "Derived 析构函数\n";
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 关键点:这里只会调用 Base 的析构函数
return 0;
}
运行结果:
Base 构造函数
Derived 构造函数
Base 析构函数 (非虚)
问题: Derived 的析构函数从未被执行,导致 data 指向的内存没有被释放,造成了内存泄漏。
2. 虚析构函数的工作原理
当你将基类的析构函数声明为 virtual 时,编译器会使用动态绑定(Dynamic Binding)。在执行 delete ptr 时,程序会根据指针实际指向的对象类型(即 Derived),查表(vtable)并调用对应的析构函数链。
正确示例:
class Base {
public:
virtual ~Base() { std::cout << "Base 虚析构函数\n"; }
};
加上 virtual 后的运行结果:
- 首先调用
Derived的析构函数。 - 然后自动调用 Base 的析构函数。这样,派生类中申请的所有资源都能被安全释放。
3. 使用场景与规则
- 何时使用: 如果一个类会被其他类继承,且你可能会通过基类指针来管理派生类对象,那么该基类的析构函数必须是虚的。
- 多态基类: 只要类中包含任何虚函数(Virtual Functions),就应该同时声明虚析构函数。
- 不适用的情况: 如果一个类不打算作为基类(例如简单的工具类),则不应声明虚析构函数。因为虚函数会增加一个虚函数表指针(vptr)的开销,这会增加对象的内存占用。
4. 总结
| 特性 | 非虚析构函数 | 虚析构函数 |
|---|---|---|
| 调用对象 | 仅取决于指针类型(静态绑定) | 取决于实际对象类型(动态绑定) |
| 安全性 | 容易导致派生类资源未释放(内存泄漏) | 确保完整的析构链被执行,安全可靠 |
| 性能开销 | 无额外开销 | 增加一个 vptr 的空间及查表的时间开销 |
金科玉律: “如果一个类有任何虚函数,它就应该有一个虚析构函数。”
2.虚析构函数将阻止合成移动操作
你说得完全正确:一旦你显式声明了析构函数(包括虚析构函数),编译器就不会再为你自动生成“合成移动操作”(移动构造函数和移动赋值运算符)。
以下是关于这一机制的深度解析:
1. 为什么会这样?(移动操作的合成规则)
根据 C++ 标准,编译器自动合成移动操作的条件非常苛刻。只有当以下三个条件同时满足时,编译器才会自动生成移动构造函数或移动赋值运算符:
- 类中没有显式声明拷贝构造函数或拷贝赋值运算符。
- 类中没有显式声明移动构造函数或移动赋值运算符。
- 类中没有显式声明析构函数。
逻辑在于: 编译器认为,如果你觉得有必要显式定义析构函数(哪怕只是为了将其设为 virtual),那么这个类很可能在处理某种特殊的资源管理(如内存、文件句柄等)。在这种情况下,编译器默认生成的“按成员移动”逻辑可能是不安全的,因此它会保守地选择不自动生成移动操作。
2. 后果是什么?
如果你为一个基类定义了虚析构函数:
class Base {
public:
virtual ~Base() = default; // 显式声明了析构函数
};
此时,Base 类将:
- 失去移动能力: 如果你尝试对
Base对象使用std::move,编译器找不到移动构造函数。 - 退化为拷贝: 根据 C++ 的重载解析规则,如果移动操作不可用,它会尝试调用拷贝操作(如果拷贝操作存在)。这意味着原本期望的“廉价”移动会变成“昂贵”的拷贝。
3. 如何解决?(遵循“五法则”)
在现代 C++ 中,为了维持多态安全性同时不损失性能,我们必须遵循 “五法则”(Rule of Five)。
如果你声明了虚析构函数,你也应该显式地声明你需要的移动和拷贝操作。通常情况下,使用 = default 即可恢复编译器的默认行为:
class Base {
public:
virtual ~Base() = default;
// 显式恢复移动操作
Base(Base&&) = default;
Base& operator=(Base&&) = default;
// 显式恢复拷贝操作(如果需要)
Base(const Base&) = default;
Base& operator=(const Base&) = default;
};
4. 总结
- 现象: 定义了
virtual ~Base(),移动构造和移动赋值就消失了。 - 影响: 导致对象在传递过程中被静默地拷贝而非移动,造成性能下降。
- 最佳实践: 只要显式写了析构函数(即使是
= default),就应该把五大成员函数(拷贝构造/赋值,移动构造/赋值,析构)都明确处理一遍。
3.合成控制拷贝和继承
1. 合成操作的基本逻辑:逐层递归
当编译器为派生类(Derived)合成这些成员函数时,其核心逻辑是:先处理基类部分,再处理派生类自身的成员。
- 合成构造/拷贝函数: 调用基类的对应构造函数,然后对派生类成员进行初始化/拷贝。
- 合成析构函数: 执行派生类自身的清理逻辑,然后自动调用基类的析构函数。
2. 派生类操作被定义为“删除”(deleted)的情况
这是继承中最容易出错的地方。如果基类的拷贝控制成员表现异常,会直接导致派生类的相应合成操作被定义为 = delete:
| 如果基类 (Base) 的情况是… | 派生类 (Derived) 的合成结果 |
|---|---|
| 析构函数不可访问 (private) 或被删除 | 派生类的构造、拷贝、移动、析构全被删除 |
| 拷贝构造/赋值不可访问或被删除 | 派生类的拷贝构造/赋值被删除 |
| 移动操作不可访问或被删除 | 派生类的移动操作被删除(且不自动倒退为拷贝) |
| 基类没有移动操作(如你上个问题提到的情况) | 派生类的移动操作被删除 |
关键点: 派生类的合成操作不仅受自身成员影响,还深受基类“健康状况”的制约。
3. 继承中的移动操作与虚析构函数的连锁反应
正如你之前提到的,虚析构函数会阻止基类合成移动操作。这在继承体系中会产生链式影响:
- 你在
Base中定义了virtual ~Base() = default;。 Base因此失去了合成的移动构造函数和移动赋值运算符。- 当你定义
Derived : public Base且不手动写移动操作时:- 编译器尝试为
Derived合成移动构造函数。 - 由于
Base没有移动构造函数,编译器无法在Derived的移动构造函数中移动基类部分。 - 结果:
Derived的移动操作被定义为删除。 - 后果:对
Derived对象使用std::move会变成调用拷贝构造函数(性能下降)。
- 编译器尝试为
4. 派生类中手动定义的拷贝控制
如果你在派生类中手动定义了拷贝控制函数,你必须手动负责基类部分的拷贝/移动。编译器不会帮你自动调用基类的非默认版本。
class Derived : public Base {
public:
// 错误做法:基类部分会调用默认构造函数,而不是拷贝构造函数
Derived(const Derived& d) : mem(d.mem) { }
// 正确做法:显式调用基类的拷贝构造函数
Derived(const Derived& d) : Base(d), mem(d.mem) { }
// 移动操作同理
Derived(Derived&& d) : Base(std::move(d)), mem(std::move(d.mem)) { }
};
5. 最佳实践建议
- Rule of Three/Five: 在基类中,如果你为了多态声明了
virtual ~Base() = default;,请务必同时显式声明拷贝和移动操作:class Base {
public:
virtual ~Base() = default;
Base(const Base&) = default;
Base& operator=(const Base&) = default;
Base(Base&&) = default;
Base& operator=(Base&&) = default;
}; - Rule of Zero: 在派生类中,如果你的类成员都支持拷贝/移动,尽量不要写任何析构、拷贝或移动函数。让编译器根据基类的状态自动合成,这样代码最简洁且不易出错。
4.派生类中删除的拷贝构造与基类的关系
简单来说,派生类无法执行其基类禁止执行的操作。 如果基类部分的拷贝无法完成,编译器就会将派生类的合成拷贝构造函数定义为 = delete(删除的)。
以下是派生类拷贝构造函数被定义为“删除”的几种核心场景及其与基类的关系:
1. 基类拷贝构造函数被删除或不可访问
这是最常见的情况。如果基类显式删除了拷贝构造函数,或者将其设为 private,派生类在尝试合成拷贝构造函数时,发现无法初始化其基类部分,因此该合成函数会被标记为“删除”。
- 基类显式删除:
Base(const Base&) = delete; - 基类设为私有:
private: Base(const Base&);
后果: 编译器无法为 Derived 生成拷贝构造函数,因为 Derived 的拷贝构造函数必须隐式调用 Base 的拷贝构造函数。
2. 基类的析构函数被删除或不可访问
这是一个初学者容易忽视的逻辑链条。如果基类的析构函数被删除(= delete)或不可访问(private):
- 派生类的析构函数会被合成并标记为“删除”。
- 进而: 派生类的拷贝构造函数也会被定义为“删除”。
原因: 如果一个类无法被销毁(析构函数不可用),那么它也就不允许被创建(构造/拷贝构造)。为了防止产生一个永远无法销毁的对象,编译器直接禁止了拷贝构造。
3. 基类只有移动操作(Move-only Base)
正如你之前提到的,如果基类定义了移动构造函数但没有显式定义拷贝构造函数:
- 基类的合成拷贝构造函数会被定义为“删除”。
- 此时,派生类的合成拷贝构造函数也会随之变为“删除”。
这通常发生在处理像 std::unique_ptr 这样的成员,或者基类显式定义了 Base(Base&&) 而忽略了拷贝操作时。
4. 汇总对照表
| 基类的状态 | 派生类合成拷贝构造函数的行为 | 原因 |
|---|---|---|
拷贝构造函数为 public | 正常合成 | 基类部分可正常拷贝 |
拷贝构造函数为 delete | 被定义为 delete | 无法拷贝基类部分 |
拷贝构造函数为 private | 被定义为 delete | 派生类无权访问基类拷贝构造 |
析构函数为 delete/private | 被定义为 delete | 无法销毁的对象不允许通过拷贝创建 |
| 只有移动构造函数 | 被定义为 delete | 基类拷贝功能默认已失效 |
5. 如何“打破”这种限制?
如果你发现派生类的拷贝构造函数因为基类原因被删除了,但你仍然需要拷贝派生类,你有两个选择:
- 修复基类: 如果你有权限修改基类,显式提供拷贝构造函数(或
= default)。 - 手动定义派生类拷贝构造:你可以不依赖编译器合成,手动写一个拷贝构造函数。但请注意,如果基类部分确实不可拷贝,你只能在初始化列表中以其他方式初始化基类(例如调用基类的默认构造函数,虽然这在语义上可能不再是“拷贝”了)。
class Derived : public Base {
public:
// 如果 Base(const Base&) 被删除了,下面这个手动定义的函数会报错
// 因为 Derived 依然无法合法地初始化 Base 部分
Derived(const Derived& d) : Base(d), mem(d.mem) { }
};
总结:
编译器非常保守。它在合成拷贝构造函数时,第一件事就是检查:“我能合法地拷贝这个对象的每一个部分(包括基类子对象和成员变量)吗?” 只要有一个环节(如基类)说“不”,整个派生类的合成操作就会被标记为 delete。
5.移动操作和继承
1. 派生类移动操作的合成规则
编译器为派生类(Derived)合成移动构造函数和移动赋值运算符时,遵循逐成员移动的原则,其中也包括基类部分。
- 合成前提: 只有当派生类没有定义任何拷贝控制成员,且基类及所有成员都支持移动时,派生类才会获得合成的移动操作。
- 基类的影响: 如果基类因为定义了析构函数(虚析构函数)而导致移动操作未合成,那么派生类的合成移动操作也会被定义为
= delete(删除的)。
后果: 当你尝试移动一个派生类对象时,如果合成移动操作被删除了,编译器会尝试寻找拷贝操作。这会导致原本期望的“移动”退化为“拷贝”,造成性能下降。
2. 手动实现派生类的移动操作
如果你在派生类中手动定义移动构造函数,你必须显式地移动基类部分。关键点在于使用 std::move 将派生类对象转换为基类引用。
正确的代码实现:
class Base {
public:
virtual ~Base() = default;
// 遵循“五法则”,显式声明移动操作
Base(Base&&) = default;
Base& operator=(Base&&) = default;
};
class Derived : public Base {
std::string data;
public:
// 手动定义移动构造函数
Derived(Derived&& d) noexcept
: Base(std::move(d)), // 必须显式移动基类部分
data(std::move(d.data))
{ }
// 手动定义移动赋值运算符
Derived& operator=(Derived&& d) noexcept {
if (this != &d) {
Base::operator=(std::move(d)); // 显式调用基类的移动赋值
data = std::move(d.data);
}
return *this;
}
};
3. 移动操作中的“切片”与类型转换
在上面的代码中,Base(std::move(d)) 之所以有效,是因为:
std::move(d)将Derived&转换为Derived&&(右值引用)。- 由于派生类到基类的转换规则,
Derived&&可以安全地绑定到Base&&。 - 这样就会匹配并调用
Base的移动构造函数,而不是拷贝构造函数。
如果不使用 std::move(d):
如果你写成 Base(d),那么 d 将作为一个左值传递,导致调用 Base 的拷贝构造函数,即便 Base 拥有移动操作。
4. 移动操作与虚析构函数的连锁总结
这是一个完整的逻辑闭环:
- 为了多态,基类需要
virtual ~Base()。 - 定义了析构函数,基类失去了自动合成的移动操作。
- 基类失去移动操作,导致派生类的自动合成移动操作也被删除。
- 解决方案: 在基类中手动使用
= default补齐移动操作。
5. 继承体系下的拷贝控制建议表
| 场景 | 基类处理方式 | 派生类处理方式 |
|---|---|---|
| 需要多态(基类指针指向派生类) | 显式定义 virtual ~Base() = default | 通常无需定义析构函数 |
| 需要支持高效移动 | 在基类显式定义(或 default)五大函数 | 尽量让编译器自动合成(Rule of Zero) |
| 派生类包含独有资源 | 遵循五法则,并显式移动基类部分 | 显式调用 Base(std::move(rhs)) |
总结
在继承关系中,移动语义不是“自动向下传递”的。如果你破坏了基类的移动能力(比如只加了个虚析构函数),你就切断了整个继承链的移动效率。
6.定义派生类的拷贝或移动构造函数
当你决定在派生类中手动定义拷贝或移动构造函数时,你实际上接管了对象构造的全部责任。编译器不再提供“合成”的便利,这意味着你必须显式地处理基类部分的构造。
如果不这样做,基类部分将只会执行默认初始化,这通常会导致逻辑错误。
1. 定义派生类拷贝构造函数
手动定义拷贝构造函数时,你必须在初始化列表中将整个派生类对象传递给基类的拷贝构造函数。由于派生类到基类的隐式转换,这种“切片”操作是安全的。
class Base {
public:
Base(const Base& b) { /* 拷贝基类成员 */ }
};
class Derived : public Base {
std::string name;
public:
// 正确的做法:显式调用 Base 的拷贝构造函数
Derived(const Derived& d)
: Base(d), // 将 d 传递给 Base(const Base&)
name(d.name) // 拷贝派生类成员
{
std::cout << "Derived 拷贝构造\n";
}
};
警示: 如果你漏掉了
: Base(d),编译器会尝试调用Base的默认构造函数。这意味着派生类对象里的基类成员会被重置为初始状态,而不是拷贝自原对象。
2. 定义派生类移动构造函数
移动构造函数的原则与拷贝一致,但有一个至关重要的区别:你必须使用 std::move。
class Derived : public Base {
std::string name;
public:
// 显式调用 Base 的移动构造函数
Derived(Derived&& d) noexcept
: Base(std::move(d)), // 必须使用 std::move(d)
name(std::move(d.name))
{
std::cout << "Derived 移动构造\n";
}
};
为什么要对 d 使用 std::move?
虽然 d 的类型是 Derived&&(右值引用),但在一个函数内部,d 本身是一个有名字的变量,因此它是一个左值。如果你直接写 Base(d),编译器会去匹配基类的拷贝构造函数。只有使用 std::move(d),才能将其强制转换为右值,从而触发基类的移动构造函数。
3. 赋值运算符的定义
对于赋值运算符(operator=),逻辑也是类似的:你必须显式调用基类的赋值运算符。
Derived& Derived::operator=(const Derived& rhs) {
if (this != &rhs) {
Base::operator=(rhs); // 显式调用基类的赋值运算符
name = rhs.name; // 处理派生类成员
}
return *this;
}
Derived& Derived::operator=(Derived&& rhs) noexcept {
if (this != &rhs) {
Base::operator=(std::move(rhs)); // 显式调用基类的移动赋值
name = std::move(rhs.name);
}
return *this;
}
4. 总结:手动定义的关键点
| 构造函数类型 | 基类部分处理方式 | 成员部分处理方式 |
|---|---|---|
| 拷贝构造 | Base(rhs) | member(rhs.member) |
| 移动构造 | Base(std::move(rhs)) | member(std::move(rhs.member)) |
| 拷贝赋值 | Base::operator=(rhs) | member = rhs.member |
| 移动赋值 | Base::operator=(std::move(rhs)) | member = std::move(rhs.member) |
5. 什么时候需要手动定义?
通常建议遵循 “零法则”(Rule of Zero):如果派生类没有管理特殊的原始资源(如原生指针、文件句柄),就不要手动定义这些函数,让编译器合成。
你需要手动定义的唯一理由通常是:
- 派生类增加了一个需要特殊处理的成员(如
char*指针需要深拷贝)。 - 你想在拷贝/移动时增加特定的日志或统计逻辑。
思考一个进阶场景:
如果你在派生类中定义了移动构造函数,但忘记在初始化列表中写 Base(std::move(d)),结果会发生什么?
- A) 编译错误。
- B) 基类部分被默认构造。
- C) 基类部分被静默拷贝。
答案是 B。这往往是性能隐患或逻辑 Bug 的源头。
7.派生析构函数
在 C++ 的继承体系中,派生类析构函数的执行逻辑与构造函数恰恰相反。理解它的关键在于:析构函数的调用逻辑是自动化的、由内而外(从派生类到基类)的。
以下是派生类析构函数的核心要点:
1. 执行顺序:逆序执行
构造函数的顺序是“先基类后派生类”,而析构函数的执行顺序是完全反过来的:
- 执行派生类析构函数的函数体(释放派生类独有的资源)。
- 析构派生类的成员变量(按照声明顺序的逆序析构)。
- 自动调用基类的析构函数(释放基类部分的资源)。
2. 自动调用的机制
与构造函数不同,你不能(也不需要)在派生类析构函数的初始化列表中显式调用基类的析构函数。
- 隐式调用: 编译器会自动在派生类析构函数的末尾插入对基类析构函数的调用。
- 责任分工: 派生类析构函数只负责清理派生类自己定义的成员,基类部分的清理交给基类析构函数自己完成。
示例代码:
class Base {
public:
virtual ~Base() { std::cout << "Base 析构\n"; }
};
class Derived : public Base {
int* p;
public:
Derived() : p(new int(10)) {}
~Derived() {
delete p; // 只需要清理派生类特有的资源
std::cout << "Derived 析构\n";
}
};
// 执行 delete derivedPtr 时输出:
// Derived 析构
// Base 析构
3. 合成析构函数(Synthesized Destructor)
如果你没有为派生类显式定义析构函数,编译器会为你合成一个。
- 合成逻辑: 它的函数体为空,但它会按照成员声明的逆序调用派生类成员的析构函数,然后隐式调用基类的析构函数。
- 注意: 即使基类的析构函数是虚函数,派生类的合成析构函数依然能够正确地参与到虚函数链中。
4. 派生类析构函数被“删除”的情况
正如之前讨论过拷贝/移动构造函数,派生类的析构函数也可能被编译器标记为 = delete:
- 基类析构函数不可访问: 如果基类的析构函数被定义为
private或被显式delete,派生类将无法合成析构函数(因为无法隐式调用基类部分)。 - 后果: 如果析构函数被删除,该派生类将无法被销毁,编译器通常会禁止你创建该类的任何对象。
5. 核心规则总结
| 维度 | 派生类构造函数 | 派生类析构函数 |
|---|---|---|
| 执行顺序 | 基类 \rightarrow 成员 \rightarrow 派生类函数体 | 派生类函数体 \rightarrow 成员 \rightarrow 基类 |
| 调用方式 | 需在初始化列表中显式指定基类版本(若无默认构造) | 自动调用,严禁手动显式调用基类析构 |
| 虚函数需求 | 构造函数不能是虚函数 | 基类析构函数必须是虚函数(为了多态) |
| 异常安全性 | 若构造中途抛异常,已构造部分会自动析构 | 析构函数绝不应抛出异常(通常默认为 noexcept) |
深入思考:对象销毁的最后一步
当基类的析构函数执行完毕后,整个对象的生命周期才算正式结束。这意味着在派生类的析构函数体执行时,基类部分依然是完整的、可访问的;但当基类析构函数开始执行时,派生类部分已经消失了。
这引出了一个经典陷阱:
在析构函数(或构造函数)中调用虚函数,不会触发动态绑定。它只会调用当前类层次中的函数版本。
8.在构造函数和析构函数中调用虚函数
核心结论: 在构造函数或析构函数中调用虚函数时,多态(动态绑定)不会发生。 调用的永远是当前类定义的版本,而不会跳转到派生类的覆盖版本。
1. 为什么多态会“失效”?
这并不是编译器的 Bug,而是一种保护机制。
构造阶段的情况:
- 当基类构造函数执行时,派生类的成员变量还没有被初始化。
- 如果此时允许调用派生类的虚函数,而该函数访问了派生类的成员,程序就会访问到未初始化的内存。
- 为了安全,C++ 规定:在基类构造期间,对象的身份就是“基类”,虚函数表(vtable)也指向基类的版本。
析构阶段的情况:
- 当派生类析构函数执行完毕后,派生类的成员已经被销毁了。
- 接着进入基类析构函数。此时,对象已经不再是一个“派生类对象”了,它退化成了“基类对象”。
- 如果此时调用虚函数,同样只会执行基类的版本,因为派生类的部分已经不存在了。
2. 代码示例:见证“失效”过程
#include <iostream>
class Base {
public:
Base() {
std::cout << "Base 构造中...\n";
log(); // 调用虚函数
}
virtual ~Base() {
std::cout << "Base 析构中...\n";
log(); // 调用虚函数
}
virtual void log() { std::cout << "执行 Base::log\n"; }
};
class Derived : public Base {
int* data;
public:
Derived() : data(new int(42)) { std::cout << "Derived 构造中...\n"; }
~Derived() {
delete data;
std::cout << "Derived 析构中...\n";
}
void log() override { std::cout << "执行 Derived::log,数据为: " << *data << "\n"; }
};
int main() {
Base* p = new Derived();
delete p;
return 0;
}
实际输出:
Base 构造中...
执行 Base::log <-- 注意:并没有调用 Derived::log
Derived 构造中...
Derived 析构中...
Base 析构中...
执行 Base::log <-- 注意:并没有调用 Derived::log
3. 潜在的风险
- 逻辑错误: 开发者原本期望通过虚函数实现某种初始化逻辑(如打开特定类型的日志文件),结果却执行了基类的空逻辑。
- 纯虚函数崩溃(Pure Virtual Function Call): 如果你在基类构造函数中调用了一个纯虚函数(没有定义的虚函数),程序会在运行时直接崩溃(通常报错
pure virtual method called),因为此时没有派生类的实现可以跳转。
4. 最佳实践与替代方案
原则:永远不要在构造或析构函数中调用虚函数来实现多态逻辑。
如果你确实需要根据不同的派生类来执行不同的初始化动作,可以考虑以下替代方案:
- 向上传递参数: 将需要差异化的信息作为参数传递给基类构造函数。class Base {
public:
Base(const std::string& info) { /* 使用 info 进行初始化 */ }
};
class Derived : public Base {
public:
Derived() : Base(“Derived info”) {}
}; - 二段式初始化(Init 函数): 对象构造完成后,再手动调用一个
init()虚函数。虽然这不够“自动化”,但它是安全的。 - 工厂模式(Factory Pattern): 在工厂函数中完成对象创建及后续的多态初始化。
总结
在构造和析构的过程中,对象的“身份”是不断变化的。构造时是从基类向派生类“进化”,析构时是从派生类向基类“退化”。由于这种身份的动态切换,虚函数只能在该身份允许的范围内寻找实现。
4.继承的构造函数
C++11 引入了 “继承的构造函数” (Inherited Constructors),通过 using 声明,你可以直接让基类的构造函数在派生类中可见。
1. 基本语法
使用 using Base::Base; 就可以将基类的所有构造函数(除了特殊的拷贝、移动、默认构造函数外)引入派生类。
class Base {
public:
Base(int i) { /* ... */ }
Base(double d, int i) { /* ... */ }
};
class Derived : public Base {
public:
using Base::Base; // 继承 Base 的所有构造函数
// 现在 Derived 自动拥有了 Derived(int) 和 Derived(double, int)
};
int main() {
Derived d1(10); // 调用继承自 Base 的 Base(int)
Derived d2(3.14, 5); // 调用继承自 Base 的 Base(double, int)
}
2. 核心特性与规则
虽然这个语法看起来很简单,但它有一些非常重要的细节:
A. 派生类成员的初始化(重要陷阱!)
继承的构造函数只会初始化基类部分。 如果派生类定义了新的成员变量,它们将执行默认初始化。
最佳实践: 如果使用继承的构造函数,请务必为派生类的成员变量使用 类内初始值(In-class initializers)。
class Derived : public Base {
using Base::Base;
int x = 0; // 必须在这里给初值,否则 Derived(int) 被调用时 x 是随机值
};
B. 访问权限保持不变
基类构造函数是 public 的,继承过来后在派生类中也是 public 的;如果是 protected,继承后依然是 protected。
C. explicit 属性被保留
如果基类构造函数声明为 explicit,那么它在派生类中继承的版本也同样具有 explicit 特性。
D. 默认参数的处理
如果基类构造函数带有默认参数,编译器会为派生类生成多个版本的构造函数:
- 一个版本包含所有参数。
- 每一个省略了带默认值参数的版本都会生成一个对应的构造函数。
3. 哪些构造函数不会被继承?
并不是所有的构造函数都能被 using Base::Base 搞定:
- 默认、拷贝和移动构造函数: 它们遵循类自身的合成规则。
- 派生类中已存在的: 如果派生类自己定义了与基类签名完全相同的构造函数,则派生类的版本会隐藏继承的版本。
4. 多重继承中的冲突
如果一个类从两个基类继承,而这两个基类拥有签名相同的构造函数,就会产生冲突。
class A { public: A(int); };
class B { public: B(int); };
class C : public A, public B {
public:
using A::A;
using B::B; // 错误:C(int) 产生歧义
// 解决方法:手动定义一个覆盖它们
C(int i) : A(i), B(i) { }
};
5. 总结:为什么要使用它?
| 优点 | 说明 |
|---|---|
| 减少冗余 | 无需为每个基类构造函数编写透传代码。 |
| 易于维护 | 基类新增或修改构造函数时,派生类自动同步。 |
| 性能透明 | 这种继承由编译器在编译期处理,没有额外的运行时开销。 |
思考与进阶
继承的构造函数在模板类设计和深度继承链中非常有用,因为它能让派生类像“壳”一样无缝套在基类之上。
5.容器与继承
在 C++ 中,当容器(Containers)遇到继承(Inheritance)时,会产生一个核心冲突:容器通常是“按值”存储的,而多态却是“按引用或指针”工作的。
如果不理解这一点,很容易写出逻辑错误甚至导致内存崩溃的代码。以下是容器与继承结合时的三大核心议题:
1. 对象切片(Object Slicing):最大的陷阱
如果你定义了一个存储基类对象的容器,例如 std::vector<Base>,并尝试向其中放入 Derived 对象,就会发生对象切片。
- 现象: 容器只为
Base大小的对象分配空间。当你放入Derived时,只有Base部分被拷贝进去,Derived特有的成员和虚函数表信息会被“切掉”。 - 后果: 当你从容器中取出元素并调用虚函数时,它永远只会执行基类的版本,多态性完全丧失。
std::vector<Base> vec;
Derived d;
vec.push_back(d); // 发生切片!d 的 Derived 部分丢失
vec[0].view(); // 即使 view 是虚函数,也只会调用 Base::view
2. 解决方案:存储指针(尤其是智能指针)
为了在容器中维持多态性,你必须存储指向基类的指针。
A. 原始指针(不推荐)
std::vector<Base*> 可以实现多态,但你需要手动管理内存(delete 每个对象),这在异常发生时非常危险。
B. 智能指针(最佳实践)
使用 std::unique_ptr 或 std::shared_ptr 是现代 C++ 的标准做法。
// 推荐做法:使用智能指针
std::vector<std::unique_ptr<Base>> vec;
vec.push_back(std::make_unique<Derived>());
vec.push_back(std::make_unique<OtherDerived>());
for (const auto& item : vec) {
item->view(); // 正确触发动态绑定,实现多态
}
3. 容器操作与拷贝控制的连锁反应
这部分与你之前询问的“合成拷贝控制”紧密相关。容器的操作(如 push_back、insert 或 vector 扩容)高度依赖元素的拷贝/移动构造函数。
- 扩容时的移动: 当
std::vector空间不足需要搬移元素时,它会优先使用移动构造函数。如果你的基类定义了虚析构函数但忘记= default移动操作,vector就会退而求其次使用拷贝构造,这会大幅降低性能。 - noexcept 的重要性: 为了保证异常安全性,
vector只会在移动构造函数被声明为noexcept时才会使用移动。在继承体系中,请确保你的移动构造函数都带上noexcept。
4. 性能与内存分布对比
| 存储方式 | 多态性 | 内存布局 | 访问速度 | 内存管理 |
|---|---|---|---|---|
vector<Base> | 无(切片) | 连续(Cache 友好) | 极快 | 自动 |
vector<Base\*> | 有 | 不连续(间接访问) | 较慢 | 手动(易泄露) |
vector<unique_ptr> | 有 | 不连续 | 中等 | 自动(安全) |
5. 进阶:如何处理“异构容器”?
有时候你可能需要在一个容器里存储完全不同的类,但它们没有共同的基类。此时可以参考:
std::variant(C++17): 适合已知类型集合的“类型安全联合体”。std::any(C++17): 适合存储任何类型,但类型转换开销较大。
总结
在处理容器与继承时,请务必记住:“容器存对象,多态没戏;容器存指针,多态成真。” 同时,由于容器会频繁触发元素的构造与析构,此时基类的虚析构函数是你防止内存泄漏的最后一道防线