【C++八股】C++三大特性
本文档参考《代码随想录指示星球精华-最强八股文(第三版)》,结合自己查询资料补充,用于自己补充知识,夯实基础。如有错误,还请各位大佬指正。
一、访问权限
1. 类内部访问权限
在类内部,所有成员(无论是 public
、protected
或 private
)均可直接访问:
class Base {
private:
int c = 30; // 私有成员
public:
int a = 10; // 公有成员
protected:
int b = 20; // 受保护成员
void accessInternal() {
// 类内部可自由访问所有成员
std::cout << "a=" << a << ", b=" << b << ", c=" << c << std::endl;
}
};
输出:a=10, b=20, c=30
说明:类内部无访问限制,所有成员均可直接访问。
2. 类外部访问权限
在类外部,只能通过对象访问 public
成员:
Base obj;
std::cout << obj.a; // 合法:访问公有成员
// std::cout << obj.b; // 编译错误:无法访问受保护成员
// std::cout << obj.c; // 编译错误:无法访问私有成员
说明:类外部仅能通过对象访问 public
成员,protected
和 private
成员不可见。
3. 派生类对基类成员的访问
(1) 公有继承(public inheritance)
class Derived : public Base {
public:
void accessBaseMembers() {
// 公有继承下,可访问基类的 public 和 protected 成员
std::cout << "a=" << a << ", b=" << b << std::endl; // 合法
// std::cout << c; // 编译错误:无法访问基类私有成员
}
};
说明:公有继承时,派生类可访问基类的 public
和 protected
成员,但无法访问 private
成员。
(2) 私有继承(private inheritance)
class DerivedPrivate : private Base {
public:
void accessBaseMembers() {
// 私有\u001B[1;31mprivate inheritance下,基类的 public/protected 成员在派生类中变为 private
std::cout << "a=" << a << ", b=" << b << std::endl; // 合法
}
};
说明:私有继承后,基类成员在派生类中变为 private
,但派生类仍可访问它们。
4. 派生类对象的访问权限
(1) 公有继承对象的访问
Derived d;
std::cout << d.a; // 合法:基类 public 成员对派生类对象可见
// std::cout << d.b; // 编译错误:基类 protected 成员对派生类对象不可见
说明:公有继承时,派生类对象仅能访问基类的 public
成员。
(2) 私有/保护继承对象的访问
DerivedPrivate dp;
// std::cout << dp.a; // 编译错误:私有继承后,基类所有成员对派生类对象不可见
说明:私有或保护继承时,基类的所有成员对派生类对象均不可见。
5. 继承方式对访问权限的总结
继承方式 | 基类 public 成员 | 基类 protected 成员 | 基类 private 成员 |
---|---|---|---|
public | public | protected | 不可访问 |
protected | protected | protected | 不可访问 |
private | private | private | 不可访问 |
关键规则:
- 派生类无法访问基类的
private
成员。 - 继承方式会调整基类成员在派生类中的访问级别。
- 派生类对象的访问权限取决于继承方式及基类成员的原始访问级别。
二、继承
1. 继承的定义与功能
定义:继承是指让某种类型对象(派生类)获得另一个类型对象(基类)的属性和方法的能力。通过继承,派生类可以复用基类的功能,并在此基础上进行扩展或修改,而无需重新编写基类代码。
功能:
- 代码复用:派生类直接使用基类的属性和方法,减少重复代码。
- 扩展性:在不修改基类的前提下,通过派生类新增或修改功能。
- 层次化设计:通过继承关系构建类的层次结构(如从抽象到具体)。
2. C++ 中继承的三种方式
(1) 实现继承
派生类直接复用基类的属性和方法,无需额外编码。
示例:
class Animal {
public:
void eat() { cout << "Animal eats." << endl; }
};
class Dog : public Animal { // 公有继承
public:
void bark() { cout << "Dog barks." << endl; }
};
int main() {
Dog d;
d.eat(); // 直接复用基类方法
d.bark(); // 扩展新方法
}
特点:
-
派生类完全继承基类的实现,适用于“is-a”关系。
-
输出:
Animal eats. Dog barks.
(2) 接口继承(抽象类实现)
基类定义纯虚函数(接口),派生类必须提供具体实现。
示例:
class Shape { // 抽象类
public:
virtual double area() = 0; // 纯虚函数
};
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() override { return 3.14 * radius * radius; } // 实现接口
};
int main() {
Circle c(2.0);
cout << "Circle area: " << c.area() << endl; // 输出 12.56
}
特点:
-
基类仅提供接口规范,强制派生类实现。
-
支持多态,适用于需要统一接口但不同实现的场景。
-
输出:
Circle area: 12.56
(3) 可视继承(非典型,需结合具体框架)
可视继承通常指子类复用基类的界面外观和实现代码(如 GUI 框架中的窗体继承)。C++ 标准库未直接支持,但可通过组合或框架特性实现。
简化示例(模拟概念):
class BaseWindow {
public:
virtual void draw() { cout << "BaseWindow: Drawing frame." << endl; }
};
class ChildWindow : public BaseWindow {
public:
void draw() override {
BaseWindow::draw(); // 复用基类绘制逻辑
cout << "ChildWindow: Adding buttons." << endl; // 扩展界面元素
}
};
int main() {
ChildWindow win;
win.draw();
}
特点:
-
复用基类的界面布局逻辑,子类扩展个性化内容。
-
常见于 MFC、Qt 等 GUI 框架。
-
输出:
BaseWindow: Drawing frame. ChildWindow: Adding buttons.
3. 继承的实际应用案例
问题场景:定义“人”的抽象类,具体类继承并扩展行为。
示例:
class Person {
public:
virtual void speak() = 0; // 纯虚函数(接口继承)
void sleep() { cout << "Sleeping..." << endl; } // 实现继承
};
class Student : public Person {
public:
void speak() override { cout << "Student studies." << endl; } // 实现接口
void takeExam() { cout << "Taking exam." << endl; } // 扩展功能
};
int main() {
Student s;
s.speak(); // 接口继承:输出 "Student studies."
s.sleep(); // 实现继承:输出 "Sleeping..."
s.takeExam(); // 扩展功能:输出 "Taking exam."
}
分析:
-
Person
作为抽象类定义通用行为(接口+实现),Student
通过继承复用并扩展功能。 -
符合面向对象设计的开闭原则(对扩展开放,对修改关闭)。
-
输出:
Student studies. Sleeping... Taking exam.
4. 注意事项
- 访问控制:基类成员在派生类中的可访问性取决于继承方式(
public
、protected
、private
)。 - 虚析构函数:若基类可能被继承,析构函数应声明为
virtual
,确保派生类析构时正确调用链式析构。 - 避免菱形继承:多继承可能导致二义性,可通过虚继承(
virtual
)解决。
三、 封装
1. 封装的定义
定义:封装是将数据和操作数据的代码(方法)捆绑在类中,通过访问控制机制(如 private
、public
)限制外界对内部实现的直接访问,从而避免外部干扰和不确定性操作。
2. 封装的功能
- 抽象化:将客观事物(如“人”)抽象为类,隐藏实现细节。
- 访问控制:仅允许可信的类或对象通过公共接口(
public
方法)操作数据,不可信的访问被限制(如private
成员)。 - 数据保护:通过封装逻辑(如数据验证),确保数据状态的合法性。
3. C++ 示例
代码:
#include <iostream>
#include <string>
using namespace std;
class Person {
private:
string name; // 私有数据成员,外部不可直接访问
int age;
public:
// 公共构造函数
Person(string n, int a) : name(n), age(a) {}
// Getter 方法(公共接口)
string getName() const { return name; }
// Setter 方法(带数据验证)
void setAge(int a) {
if (a > 0 && a < 150) // 防止非法年龄值
age = a;
else
cout << "Invalid age!" << endl;
}
// 公共方法
void display() const {
cout << "Name: " << name << ", Age: " << age << endl;
}
};
int main() {
Person p("Alice", 25);
p.display(); // 合法调用:通过公共方法访问数据
// p.age = -5; // 非法操作:直接访问私有成员会报错
p.setAge(-5); // 通过封装方法验证数据合法性
p.display(); // 输出仍为 25
return 0;
}
输出:
Name: Alice, Age: 25
Invalid age!
Name: Alice, Age: 25
4. 代码解析
- 封装数据:
name
和age
被声明为private
,外部无法直接修改。 - 公共接口:
getName()
和display()
允许外部读取数据。setAge()
提供安全修改数据的方式,内置验证逻辑。
- 访问控制效果:
- 若尝试直接访问
p.age
,编译器会报错(违反封装规则)。 - 通过
setAge(-5)
的验证逻辑,非法值被拒绝,数据状态保持合法。
- 若尝试直接访问
5. 封装的优势
- 安全性:防止外部直接修改敏感数据(如年龄、账户余额)。
- 维护性:修改内部实现(如增加数据验证)不影响外部调用代码。
- 简化使用:用户只需关注公共接口,无需了解内部复杂逻辑。
四、多态
1. 定义与核心概念
定义:多态(Polymorphism)指同一接口在不同对象下表现出不同的行为。C++ 中通过虚函数(运行时多态)和函数重载(编译时多态)实现。
2. 实现方式与示例
2.1 覆盖(Override)——运行时多态
通过虚函数实现,子类重写父类的虚函数,父类指针或引用在运行时动态绑定到子类对象。
示例代码:
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
virtual void speak() const { // 虚函数
cout << "Animal speaks!" << endl;
}
};
// 子类1
class Dog : public Animal {
public:
void speak() const override { // 重写虚函数
cout << "Woof!" << endl;
}
};
// 子类2
class Cat : public Animal {
public:
void speak() const override {
cout << "Meow!" << endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->speak(); // 输出 "Woof!"
animal2->speak(); // 输出 "Meow!"
delete animal1;
delete animal2;
return 0;
}
输出:
Woof!
Meow!
关键点:
virtual
关键字声明虚函数,启用动态绑定。override
明确标识重写父类方法。- 父类指针调用时,实际执行子类的实现。
问题:为什么使用 new 动态分配内存并通过基类指针 (Animal*) 实例化对象,而不是直接实例化对象(如 Dog d; Cat c;)
1. 使用 new
和指针的原因:实现运行时多态
- 虚函数和动态绑定:代码中的
Animal
基类定义了虚函数speak()
(通过virtual
关键字),子类Dog
和Cat
重写了该函数。虚函数的目的是通过基类指针或引用调用子类对象的实现,从而实现运行时多态。 - 基类指针的动态绑定:
- 当使用
Animal* animal1 = new Dog();
时,animal1
是一个指向Dog
对象的基类指针。尽管指针类型是Animal*
,但在调用animal1->speak();
时,由于speak()
是虚函数,C++ 会在运行时通过虚函数表(vtable)动态绑定到Dog
类的speak()
实现,输出"Woof!"
。 - 类似地,
animal2->speak();
调用Cat
类的speak()
,输出"Meow!"
。
- 当使用
- 多态的关键:基类指针可以指向任何子类对象,允许在运行时根据实际对象类型调用正确的函数。这种灵活性是面向对象编程中多态的核心特性。
2. 如果直接实例化会怎样?
如果不使用 new
和指针,而是直接实例化对象(如 Dog d; Cat c;
),代码如下:
int main() {
Dog d;
Cat c;
d.speak(); // 输出 "Woof!"
c.speak(); // 输出 "Meow!"
return 0;
}
- 结果:代码仍然可以运行,且输出与原代码相同(
"Woof!"
和"Meow!"
)。 - 区别:
- 无多态性:直接实例化的对象类型在编译时是固定的(
d
是Dog
类型,c
是Cat
类型)。调用d.speak()
或c.speak()
时,编译器直接绑定到对应类的speak()
函数,而不是通过虚函数表进行运行时动态绑定。 - 无法统一处理:直接实例化无法将不同子类对象存储在同一个基类类型的容器(如
Animal*
数组或列表)中。例如,无法写Animal* animals[] = {&d, &c};
并统一调用speak()
,因为多态需要通过指针或引用实现。 - 编译时绑定:直接实例化的调用是静态绑定(static binding),不涉及虚函数表的开销,但丧失了运行时多态的灵活性。
- 无多态性:直接实例化的对象类型在编译时是固定的(
3. 为什么不直接用基类引用?
除了指针,C++ 也支持通过引用实现多态。例如:
int main() {
Dog d;
Cat c;
Animal& animal1 = d; // 基类引用绑定到 Dog 对象
Animal& animal2 = c; // 基类引用绑定到 Cat 对象
animal1.speak(); // 输出 "Woof!"
animal2.speak(); // 输出 "Meow!"
return 0;
}
- 效果:与使用指针类似,引用也能实现运行时多态,调用正确的子类
speak()
函数。 - 区别:
- 引用不需要手动管理内存(无需
new
和delete
),更安全。 - 引用的局限性是必须绑定到已存在的对象,无法像指针那样动态分配或重新指向其他对象。
- 引用不需要手动管理内存(无需
- 为什么代码用指针而不用引用:使用
new
和指针可能是为了演示动态分配的场景,或者为了更灵活地管理对象生命周期(例如,将指针存储在容器中或动态切换指向的对象)。此外,指针在多态场景中更常见,尤其是在需要动态分配内存或对象生存期不确定的情况下。
4. 使用 new
和指针的注意事项
- 内存管理:代码中通过
new
分配内存,必须用delete
释放,否则会导致内存泄漏。示例中正确使用了delete animal1; delete animal2;
。 - 虚析构函数:如果基类有虚函数(如
speak()
),通常需要将析构函数声明为虚函数(virtual ~Animal() {}
),以确保通过基类指针删除子类对象时,子类的析构函数被正确调用。示例代码中缺少虚析构函数,可能导致未定义行为(如果子类有需要清理的资源)。 - 性能开销:使用虚函数和指针涉及虚函数表查找,相比直接实例化的静态绑定有轻微性能开销,但通常可以忽略。
5. 为什么示例选择指针而非直接实例化?
示例代码的目的是展示运行时多态的经典用法:
- 使用基类指针 (
Animal*
) 指向不同子类对象 (Dog
和Cat
),通过同一接口 (speak()
) 调用不同实现,体现了多态的灵活性。 - 指针允许模拟真实场景,如将不同类型的对象统一存储或处理(例如,
vector<Animal*> animals;
)。 - 动态分配(
new
)强调对象在运行时的动态创建,适合展示多态在复杂系统中的应用。 - 如果示例仅需展示子类行为而无需多态,直接实例化会更简单,但无法体现面向对象编程中多态的核心价值。
2.2 重载(Overload)——编译时多态
同一作用域内,函数名相同但参数列表不同(类型、数量、顺序)。
示例代码:
#include <iostream>
using namespace std;
// 基础函数
int add(int a, int b) {
return a + b;
}
// 重载:参数类型不同
double add(double a, double b) {
return a + b;
}
// 重载:参数数量不同
int add(int a, int b, int c) {
return a + b + c;
}
int main() {
cout << add(2, 3) << endl; // 调用 int add(int, int)
cout << add(2.5, 3.1) << endl; // 调用 double add(double, double)
cout << add(1, 2, 3) << endl; // 调用 int add(int, int, int)
return 0;
}
输出:
5
5.6
6
关键点:
- 重载仅依赖参数列表,与返回值无关。
- 编译时根据参数类型和数量确定调用哪个函数。
3. 多态的核心区别
特性 | 覆盖(Override) | 重载(Overload) |
---|---|---|
绑定时机 | 运行时(动态绑定) | 编译时(静态绑定) |
作用范围 | 继承关系中的虚函数 | 同一作用域内的函数 |
关键字 | virtual + override | 无特殊关键字 |
示例场景 | 父类指针调用子类方法 | 同一函数名处理不同输入类型 |
4. 注意事项
- 虚析构函数:若基类可能被继承,析构函数需声明为虚函数,防止内存泄漏。
- 纯虚函数与抽象类:包含纯虚函数(
virtual void func() = 0;
)的类不可实例化,用于定义接口。 - 性能差异:虚函数调用需通过虚函数表,略微影响性能;重载无额外开销。
五、虚函数
1. 虚函数的基本用法
虚函数通过**虚函数表(vtable)和虚函数指针(vptr)**实现多态。当基类希望派生类定义自己的版本时,需将函数声明为 virtual
。
示例代码:
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
virtual void speak() { cout << "Animal speaks!" << endl; }
};
// 派生类
class Dog : public Animal {
public:
void speak() override { cout << "Dog barks!" << endl; }
};
int main() {
Animal* animal = new Dog(); // 基类指针指向派生类对象
animal->speak(); // 输出 "Dog barks!"
delete animal;
}
关键点:
animal->speak()
调用的是Dog
的speak()
,而非Animal
的版本。- 虚函数表在运行时根据对象的实际类型动态绑定函数。
2. 多态的必要条件
多态需要满足以下条件:
- 调用者必须是基类指针或引用。
- 被调用的函数必须是虚函数。
- 派生类必须重写基类的虚函数。
示例代码:
class Base {
public:
virtual void foo() { cout << "Base::foo" << endl; }
};
class Derived : public Base {
public:
void foo() override { cout << "Derived::foo" << endl; }
};
void callFoo(Base& obj) {
obj.foo(); // 动态绑定
}
int main() {
Derived d;
callFoo(d); // 输出 "Derived::foo"
}
3. 虚函数与构造函数
构造函数不能是虚函数,且在构造函数中调用虚函数会禁用多态。
示例代码:
class Base {
public:
Base() { init(); } // 构造函数调用虚函数
virtual void init() { cout << "Base::init" << endl; }
};
class Derived : public Base {
public:
void init() override { cout << "Derived::init" << endl; }
};
int main() {
Derived d; // 输出 "Base::init",而非 "Derived::init"
}
原因:
- 对象构造时,虚函数表指针(vptr)尚未指向派生类的虚函数表。
4. 虚析构函数的必要性
如果基类可能被继承,析构函数应声明为虚函数,以确保派生类析构函数被正确调用。
示例代码:
class Base {
public:
virtual ~Base() {} // 虚析构函数
};
class Derived : public Base {
public:
~Derived() { cout << "Derived destroyed" << endl; }
};
int main() {
Base* obj = new Derived();
delete obj; // 会调用 Derived::~Derived()
}
未声明虚析构函数的后果:
- 若基类析构函数非虚,
delete
基类指针时只会调用基类析构函数,导致派生类资源未释放。
5. 纯虚函数与抽象类
纯虚函数(= 0
)使类成为抽象类,不能直接实例化。
示例代码:
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
};
class Circle : public Shape {
public:
void draw() override { cout << "Drawing Circle" << endl; }
};
int main() {
// Shape s; // 错误:不能实例化抽象类
Shape* shape = new Circle();
shape->draw(); // 输出 "Drawing Circle"
}
纯虚析构函数的特殊规则:
- 纯虚析构函数必须提供定义体,因为派生类析构时会隐式调用基类析构函数。
示例代码:
class Base {
public:
virtual ~Base() = 0; // 纯虚析构函数
};
Base::~Base() {} // 必须定义体
6. 虚函数的限制
以下函数不能为虚函数:
- 内联函数(inline):内联在编译时展开,与虚函数的运行时绑定冲突。
- 静态函数(static):无
this
指针,无法访问虚函数表。 - 构造函数:对象构造前虚函数表未初始化。
示例代码:
class MyClass {
public:
// 错误示例:虚函数不能是 static 或 inline
virtual static void func1() {} // 编译错误
virtual inline void func2() {} // 通常不推荐
};
7. 返回值协变(Return Type Covariance)
派生类虚函数可以返回基类虚函数返回类型的派生类型。
Return Type Covariance 是C++中虚函数重写时的一个特殊规则,允许派生类虚函数的返回类型为基类虚函数返回类型的派生类型。
1. 核心规则
- 适用条件:基类虚函数的返回类型必须是指针(
*
)或引用(&
),派生类才能返回其派生类型。例如:
class Base { virtual Base* func(); };
class Derived : public Base {
Derived* func() override { return new Derived(); } // 合法:返回类型是 Base* 的派生类型
};
-
若基类返回类型是普通对象(如
Base func()
),则派生类无法协变。 -
继承关系要求:派生类返回类型必须与基类返回类型存在继承关系。例如,若基类返回
Animal*
,派生类可返回Dog*
(需Dog
继承自Animal
)。
2. 示例详解
用户提供的代码已满足协变条件,以下是补充细节的示例:
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() {} // 需有虚函数,否则无法多态
};
class Dog : public Animal {
public:
void speak() override { cout << "Woof!" << endl; }
};
class Farm {
public:
virtual Animal* getAnimal() { return new Animal(); }
};
class DogFarm : public Farm {
public:
Dog* getAnimal() override { return new Dog(); } // 协变:返回 Dog*(Animal* 的派生类型)
};
int main() {
Farm* farm = new DogFarm();
Animal* animal = farm->getAnimal(); // 多态调用:实际返回 Dog*
animal->speak(); // 输出 "Woof!"
delete farm;
delete animal;
}
关键点:
DogFarm::getAnimal()
返回Dog*
,但通过基类指针farm
调用时,仍能正确绑定到Dog
的实现。- 若基类
Farm
的getAnimal()
返回Animal
(非指针),则派生类无法协变。
3. 作用与优势
- 避免强制类型转换:无需对返回值进行
dynamic_cast
转换即可直接使用派生类型。 - 接口灵活性:允许基类定义通用接口,派生类提供更具体的返回类型,增强代码可扩展性。
- 多态一致性:保持虚函数动态绑定特性的同时,支持更精确的类型表达。
4. 常见错误与限制
- 非指针/引用返回类型:
class Base { virtual Base func(); };
class Derived : public Base {
Derived func() override { return Derived(); } // 错误:返回类型非指针/引用,无法协变
};
- 无关类型:
class Base { virtual Base* func(); };
class Derived : public Base {
int* func() override { return new int(5); } // 错误:int* 与 Base* 无继承关系
};
5. 实际应用场景
- 工厂模式:基类定义通用工厂接口,派生类生成具体产品类型。
class Product {
public:
virtual void use() = 0;
};
class ConcreteProduct : public Product {
public:
void use() override { /* 实现 */ }
};
class Creator {
public:
virtual Product* create() = 0;
};
class ConcreteCreator : public Creator {
public:
ConcreteProduct* create() override { return new ConcreteProduct(); }
};
- 虚函数表机制:协变依赖虚函数表的动态绑定,派生类虚函数表会替换对应条目指向自身实现。
- 析构函数:若基类析构函数为虚函数,派生类析构时可正确调用链式析构,避免内存泄漏。
8. 虚函数表(vtable)的底层机制
- 虚函数表:每个类维护一个虚函数表,存储虚函数的地址。
- 虚函数指针(vptr):每个对象隐藏一个指针,指向其所属类的虚函数表。
示例代码:
class Base {
public:
virtual void foo() { cout << "Base::foo" << endl; }
};
class Derived : public Base {
public:
void foo() override { cout << "Derived::foo" << endl; }
};
int main() {
Base b;
Derived d;
// 伪代码:假设 vptr 指向虚函数表
void** vptrBase = *reinterpret_cast<void***>(&b);
void** vptrDerived = *reinterpret_cast<void***>(&d);
// 调用虚函数
((void(*)())vptrBase[0])(); // 调用 Base::foo
((void(*)())vptrDerived[0])(); // 调用 Derived::foo
}
内存布局:
Base
对象:vptr
→Base
虚函数表(含Base::foo
地址)。Derived
对象:vptr
→Derived
虚函数表(含Derived::foo
地址)。
为什么需要虚继承
在 C++ 中,虚继承主要用于解决多继承场景下的菱形继承问题,即多个基类共享同一个间接基类时导致的数据冗余和访问歧义问题。以下是具体分析及示例:
1. 核心问题:菱形继承导致的冗余与歧义
当类 D
同时继承自类 B
和类 C
,而类 B
和类 C
又共同继承自类 A
时,若未使用虚继承,类 D
会包含两份类 A
的成员(如成员变量 a
)。此时访问 a
时,编译器无法确定是来自 A→B→D
路径还是 A→C→D
路径,导致歧义。例如:
#include <iostream>
using namespace std;
class A { public: int a; };
class B : public A {}; // 非虚继承
class C : public A {}; // 非虚继承
class D : public B, public C {
void func() {
cout << a; // 错误!a 来自 B::A 还是 C::A?歧义!
}
};
2. 虚继承的解决方案
通过将基类 A
声明为虚基类,确保在派生类 D
中仅保留一份 A
的成员,消除冗余和歧义:
#include <iostream>
using namespace std;
class A { public: int a; };
class B : virtual public A {}; // 虚继承
class C : virtual public A {}; // 虚继承
class D : public B, public C {
void func() {
cout << a; // 正确!a 唯一来自虚基类 A
}
};
说明:此时,D
对象的内存布局中,B
和 C
共享同一个 A
实例,避免了重复存储。
3. 虚继承的实现机制
- 虚基类指针(vbptr):编译器会为每个虚继承的类添加一个指针(vbptr),指向虚基类表,记录虚基类成员的偏移量,确保访问时能正确定位共享的基类成员。
- 内存布局变化:
- 非虚继承时,
D
对象包含两个A
子对象(分别来自B
和C
)。 - 虚继承后,
D
对象仅包含一个A
实例,B
和C
通过 vbptr 间接访问该实例。
- 非虚继承时,
4. 实际应用案例
C++ 标准库中的 iostream
类是虚继承的经典应用:
class baseios; // 共同基类
class istream : virtual public baseios {}; // 虚继承
class ostream : virtual public baseios {}; // 虚继承
class iostream : public istream, public ostream {}; // 仅保留一份 baseios 成员
说明:若不使用虚继承,iostream
将包含两份 baseios
成员,导致冗余和操作混乱。
5. 虚继承的代价与适用场景
优点:
- 消除菱形继承中的冗余数据。
- 解决跨路径访问基类成员的歧义问题。
缺点:
- 增加内存开销(需存储 vbptr)。
- 降低访问效率(需通过 vbptr 动态定位基类成员)。
适用场景:
- 需要共享基类状态的多继承结构(如接口类、混合类设计)。
- 标准库或框架中需避免重复基类实例的场景。
六、空类
空类的大小不是 0,而是 1 字节,这是为了确保每个对象在内存中都有唯一的地址。如果空类的大小为 0,那么多个空类对象可能会共享同一个地址,这违反了 C++ 的对象模型要求。因此,编译器会为每个空类隐式添加一个字节,以确保对象地址的唯一性。
一、空类大小为 1 的原因
示例代码:
class A {};
sizeof(A)
的结果是 1。
原因:
- 确保每个对象在内存中都有唯一的地址。
- 编译器自动添加一个字节(通常称为“填充字节”)来实现这一点。
二、虚函数与虚继承对类大小的影响
1. 含有虚函数的类
示例代码:
class A {
virtual void f() {}
};
sizeof(A)
的结果是 4(32 位系统)或 8(64 位系统)。
原因:
- 类中包含一个指向虚函数表的指针(vptr),用于支持多态行为。
- 在 32 位系统中,指针占 4 字节;在 64 位系统中占 8 字节。
2. 虚继承
示例代码:
class A {};
class B : public virtual A {};
sizeof(B)
的结果是 4。
原因:
- 虚继承引入了一个虚基类指针(vbptr),用于支持虚基类的访问。
- vbptr 指向虚 base class table,记录虚基类相对于派生类的偏移量。
- 在 32 位系统中,指针占 4 字节。
三、多重继承与虚继承的大小分析
示例 1:多重继承(非虚继承)
示例代码:
class Father1 {};
class Father2 {};
class Child : public Father1, public Father2 {};
sizeof(Child)
的结果是 1。
原因:
- 两个基类都是空类,编译器可以进行空基类优化(Empty Base Class Optimization)。
- 所有空基类共享同一个内存地址,因此派生类大小仍为 1。
示例 2:虚继承链
示例代码:
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
sizeof(X)
= 1sizeof(Y)
= 4sizeof(Z)
= 4
原因:
Y
和Z
都虚继承X
,引入 vbptr,每个 vbptr 占 4 字节。
示例 3:虚继承链的扩展
示例代码:
class A : public virtual Y {};
sizeof(A)
= 8
原因:
A
虚继承Y
,引入一个 vbptr。Y
本身已经有一个 vbptr(指向X
)。A
的布局中包含Y
的子对象(4 字节)和A
的 vbptr(4 字节),总大小为 8。
示例 4:多重虚继承
示例代码:
class B : public Y, public Z {};
sizeof(B)
= 8
原因:
B
非虚继承Y
和Z
,Y
和Z
各自带一个 vbptr(各 4 字节)。B
的布局中包含Y
和Z
的子对象,总大小为 8。
示例 5:虚继承多个虚基类
示例代码:
class C : public virtual Y, public virtual Z {};
sizeof(C)
= 12
原因:
C
虚继承Y
和Z
,引入两个 vbptr(各 4 字节)。- 每个 vbptr 指向各自的虚基类表。
- 总大小为 12(4 * 3)。
示例 6:虚继承虚继承链
示例代码:
class D : public virtual C {};
sizeof(D)
= 16
原因:
D
虚继承C
,引入一个 vbptr(4 字节)。C
的大小为 12,D
的布局中包含C
的子对象(12 字节)和自己的 vbptr(4 字节)。- 总大小为 16。
四、虚函数表共享规则
1. 虚函数表共享的条件
- 如果派生类的第一个基类定义了虚函数表,那么派生类会共享该虚函数表的地址。
- 否则,派生类会创建自己的虚函数表。
2. 示例分析
示例代码:
class A { virtual void f() {} };
class B : public A {}; // 共享 A 的虚函数表,大小为 4
class C : public A, public X { virtual void g() {} }; // 自己创建虚函数表,大小为 8
sizeof(B)
= 4(共享A
的虚函数表)sizeof(C)
= 8(A
有虚函数表,X
是空类,C
自己创建虚函数表)
五、总结
类型 | 大小(32 位系统) | 原因 |
---|---|---|
空类 | 1 | 确保对象地址唯一 |
含虚函数 | 4 | 引入虚函数表指针(vptr) |
虚继承 | 4 | 引入虚基类指针(vbptr) |
多重非虚继承 | 1 | 空基类优化 |
虚继承链 | 8 | 基类子对象 + vbptr |
多重虚继承 | 12 | 多个 vbptr |
虚继承虚继承链 | 16 | 子对象 + vbptr |
空类的作用是什么
空类的用途主要体现在以下几个方面:
1. 确保对象实例的唯一地址
空类的大小为 1 字节(而非 0),这是为了保证每个实例在内存中有唯一的地址。若空类大小为 0,多个实例可能共享同一地址,导致指针比较或容器存储时出现歧义。
示例代码:
#include <cassert>
class Empty {};
Empty e1, e2;
assert(&e1 != &e2); // 确保地址不同
2. 作为虚基类解决多继承问题
在虚继承中,空类可作为共享的虚基类,避免菱形继承导致的冗余数据。
示例代码:
class Base {}; // 空类作为虚基类
class Derived1 : virtual public Base {};
class Derived2 : virtual public Base {};
class Final : public Derived1, public Derived2 {}; // 只保留一份 Base 成员
说明:此时 Final
类的大小会包含指向虚基类的指针(通常为 4 字节)。
3. 接口或标记类的设计
空类可用于定义纯接口(无方法或数据成员),或作为类型标记。例如,通过私有化拷贝构造函数和赋值运算符,禁用对象拷贝。
示例代码:
class NonCopyable {
private:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
// 子类继承 NonCopyable 后无法拷贝
4. 模板元编程中的类型特征
空类可作为模板参数传递类型信息,例如标准库中的 std::true_type
和 std::false_type
,用于启用或禁用特定模板特化。
5. 内存对齐与占位
在需要精确控制内存布局的场景(如硬件寄存器映射),空类可能用于占位或对齐,确保后续成员按预期排列。
七、抽象类与接口实现
抽象类(Abstract Class)是实现接口(Interface)的主要手段。通过将类中的某些成员函数声明为纯虚函数(Pure Virtual Function),我们可以定义一个接口,要求派生类必须实现这些函数。这种机制不仅提供了多态性,还实现了面向对象设计中的接口与实现分离的原则。
1.抽象类与纯虚函数的定义
在 C++ 中,抽象类是指至少包含一个纯虚函数的类。纯虚函数的声明形式如下:
virtual 返回类型 函数名(参数列表) = 0;
示例代码:
class Shape {
public:
virtual int getArea() = 0; // 纯虚函数
};
- 抽象类不能被实例化,即不能创建该类的对象。
- 派生类必须实现所有纯虚函数,否则它本身也将成为抽象类。
- 抽象类通常作为基类,为派生类提供接口框架。
2.接口的实现方式
在 C++ 中,接口并没有像 Java 或 C# 那样的专用关键字(如 interface
),而是通过只包含纯虚函数的抽象类来模拟接口。
示例代码:
class ShapeInterface {
public:
virtual int getArea() = 0;
virtual void draw() = 0;
virtual ~ShapeInterface() {} // 推荐添加虚析构函数
};
这样的类仅定义行为(方法),不包含任何实现或数据成员,符合接口的定义。
3.示例代码解析
以下是一个典型的抽象类与接口实现的例子:
1. 抽象类 Shape
class Shape {
public:
virtual int getArea() = 0; // 纯虚函数,定义接口
void setWidth(int w) { width = w; }
void setHeight(int h) { height = h; }
protected:
int width;
int height;
};
getArea()
是纯虚函数,表示这是一个接口方法。setWidth()
和setHeight()
是普通虚函数,提供默认实现。width
和height
是受保护的成员变量,供派生类访问。
2. 派生类 Rectangle 和 Triangle
class Rectangle : public Shape {
public:
int getArea() override {
return width * height;
}
};
class Triangle : public Shape {
public:
int getArea() override {
return (width * height) / 2;
}
};
- 这两个类都实现了
getArea()
方法,满足了接口的要求。 - 它们继承了
Shape
的width
和height
成员,并通过setWidth()
和setHeight()
设置值。
3. 主函数调用
#include <iostream>
int main() {
Rectangle Rect;
Triangle Tri;
Rect.setWidth(5);
Rect.setHeight(7);
std::cout << "Rectangle Area: " << Rect.getArea() << std::endl; // 输出 35
Tri.setWidth(5);
Tri.setHeight(7);
std::cout << "Triangle Area: " << Tri.getArea() << std::endl; // 输出 17
}
- 通过基类指针或引用调用派生类的方法,体现了运行时多态(Runtime Polymorphism)。
getArea()
的具体实现由对象类型决定,而非指针类型。
4.抽象类的设计目的
- 提供接口框架:抽象类定义了所有派生类必须遵循的行为规范。
- 实现多态性:通过虚函数和继承,允许在运行时根据对象的实际类型调用不同的函数。
- 封装与解耦:接口与实现分离,提高了代码的可维护性和可扩展性。
- 强制派生类实现特定方法:纯虚函数确保派生类必须实现某些关键功能。
5.注意事项与最佳实践
- 虚析构函数:如果抽象类可能被继承并用于多态删除(如通过基类指针删除派生类对象),则必须为析构函数添加
virtual
,以避免未定义行为。
virtual ~Shape() {}
- 抽象类可以有数据成员和实现:与接口不同,抽象类可以包含数据成员和非虚函数的实现,这使得它更灵活,但同时也增加了耦合性。
- 接口与抽象类的区别:
- 接口:只包含纯虚函数,无数据成员,用于定义行为规范。
- 抽象类:可以包含数据成员和实现,用于提供部分实现,作为基类使用。