C++教程 第12章 面向对象编程简介
第12章 面向对象编程简介
面向对象编程的核心概念与设计哲学
面向对象编程(Object-Oriented Programming,OOP)是一种基于对象概念的编程范式,它将数据和操作数据的方法封装在一起,组成对象,通过对象之间的交互来实现程序功能。OOP的核心思想是模拟现实世界的实体及其交互方式,使代码更加符合人类的思维模式。
面向对象编程的理论基础
OOP的理论基础源于以下几个重要概念:
- 抽象(Abstraction):从具体事物中提取共同的本质特征,忽略次要细节
- 封装(Encapsulation):将数据和操作数据的方法绑定在一起,隐藏内部实现细节
- 继承(Inheritance):通过层次结构复用代码和建立类型关系
- 多态(Polymorphism):同一接口可以有不同的实现,提高代码的灵活性和可扩展性
面向对象编程的设计原则
现代面向对象编程遵循一系列设计原则,这些原则有助于创建可维护、可扩展的代码:
- 单一职责原则(Single Responsibility Principle):一个类应该只有一个引起它变化的原因
- 开放-封闭原则(Open-Closed Principle):软件实体应该对扩展开放,对修改封闭
- 里氏替换原则(Liskov Substitution Principle):子类应该能够替换其父类,并且程序的行为不会改变
- 接口隔离原则(Interface Segregation Principle):客户端不应该依赖它不使用的接口
- 依赖倒置原则(Dependency Inversion Principle):高层模块不应该依赖低层模块,两者都应该依赖抽象
- 组合优于继承原则(Composition Over Inheritance):优先使用对象组合,而不是类继承
- 迪米特法则(Law of Demeter):一个对象应该对其他对象有尽可能少的了解
这些原则构成了设计模式的基础,是编写高质量面向对象代码的指南。
核心概念详解
对象(Object):
- 现实世界中的实体在程序中的表示
- 具有状态(State):由属性(Attributes)描述
- 具有行为(Behavior):由方法(Methods)实现
- 具有标识(Identity):每个对象都有唯一的标识
- C++对象的底层实现:
- 对象在内存中是连续的内存块
- 包含成员变量的值
- 对于包含虚函数的类,对象还包含虚指针(vptr),指向虚函数表(vtable)
- 对象的大小由成员变量的大小和对齐要求决定
类(Class):
- 对象的蓝图或模板,定义了对象的属性和方法
- 描述了一类对象的共同特征和行为
- 在C++中,类是一种用户定义的类型
- 类的实例化产生对象
- C++类的底层实现:
- 类的定义在编译时被处理,生成类型信息
- 对于包含虚函数的类,编译器生成虚函数表(vtable)
- 成员函数在编译时被转换为普通函数,隐含传入this指针
- 静态成员存储在全局数据区,不属于对象的一部分
封装(Encapsulation):
- 将数据和操作数据的方法封装在一起
- 隐藏内部实现细节,只暴露必要的接口
- 通过访问控制修饰符(public、private、protected)实现
- 提高代码的安全性和可维护性
- C++封装的实现机制:
- 访问控制修饰符在编译时生效,是编译期检查
- 私有成员在类外部不可访问,由编译器强制执行
- 友元机制可以打破封装,但应谨慎使用
- 封装通过信息隐藏减少了代码耦合
继承(Inheritance):
- 从已有类(基类/父类)派生出新类(派生类/子类)
- 子类继承父类的属性和方法
- 可以添加新的属性和方法,或重写父类的方法
- 建立类的层次结构,体现”is-a”关系
- C++继承的底层实现:
- 派生类对象包含基类子对象
- 构造函数调用顺序:基类→派生类
- 析构函数调用顺序:派生类→基类
- 虚函数通过虚函数表实现运行时多态
- 多重继承可能导致菱形继承问题,需要使用虚拟继承解决
多态(Polymorphism):
- 不同对象对同一消息做出不同的响应
- 编译时多态:通过函数重载和运算符重载实现
- 运行时多态:通过虚函数和继承实现
- 提高代码的灵活性和可扩展性
- C++多态的实现机制:
- 编译时多态:通过名称修饰(name mangling)和重载解析实现
- 运行时多态:通过虚函数表(vtable)和虚指针(vptr)实现
- 虚函数表存储虚函数的地址,每个类有一个虚函数表
- 虚指针存储在对象的内存布局中,指向类的虚函数表
- 虚函数调用通过虚指针和虚函数表间接调用,有一定的性能开销
组合(Composition):
- 通过对象之间的包含关系实现代码重用
- 体现”has-a”关系
- 相比继承,组合具有更低的耦合度
- C++组合的实现:
- 一个类包含另一个类的对象作为成员
- 组合对象的构造函数负责初始化成员对象
- 组合对象的析构函数负责销毁成员对象
- 组合可以实现运行时的行为变化,而继承是编译时确定的
聚合(Aggregation):
- 一种特殊的组合关系,表示整体与部分的关系
- 部分可以独立于整体存在
- C++聚合的实现:
- 通常通过指针或引用实现
- 聚合对象不负责部分对象的生命周期
- 部分对象可以被多个聚合对象共享
- 聚合关系比组合关系更松散
面向对象编程的设计哲学
关注点分离(Separation of Concerns):
- 将复杂问题分解为多个独立的关注点
- 每个类负责解决一个特定的关注点
模块化(Modularity):
- 将程序分解为独立的模块
- 每个模块有明确的职责和接口
可扩展性(Extensibility):
- 设计应易于扩展,无需修改现有代码
- 通过继承和多态实现
可维护性(Maintainability):
- 代码应易于理解和修改
- 封装和模块化有助于提高可维护性
代码重用(Code Reuse):
- 通过继承、组合和模板实现代码重用
- 减少重复代码,提高开发效率
类和对象的深度剖析
类的定义与实现
在C++中,类是一种用户定义的类型,它封装了数据和操作数据的方法。类的定义包括成员变量和成员函数,以及访问控制修饰符。
类的基本结构
1 | // 类的声明 |
类的底层实现原理
C++类的底层实现涉及以下几个关键概念:
内存布局:
- 类的成员变量在内存中是连续存储的
- 成员函数不占用对象的内存空间,而是存储在代码段
- 虚函数表指针(vptr)存储在对象的开头(对于包含虚函数的类)
- 内存对齐会影响类的大小
this指针:
- 每个非静态成员函数都有一个隐含的this指针参数
- this指针指向调用该函数的对象
- 在成员函数内部,通过this指针访问对象的成员
名称修饰(Name Mangling):
- 编译器为了支持函数重载,会对函数名进行修饰
- 修饰后的名称包含函数名、参数类型和类名等信息
- 不同编译器的名称修饰规则可能不同
访问控制的实现:
- 访问控制是编译期检查,不是运行时检查
- 编译器通过名称修饰和作用域规则实现访问控制
- 友元机制通过特殊的名称修饰实现
C++11+类的新特性
C++11及以上版本为类添加了许多新特性:
默认成员初始化:
1
2
3
4
5class Person {
private:
std::string name = "Unknown"; // 默认初始化
int age = 0; // 默认初始化
};委托构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Person {
private:
std::string name;
int age;
std::string address;
public:
// 主构造函数
Person(const std::string& n, int a, const std::string& addr)
: name(n), age(a), address(addr) {}
// 委托构造函数
Person() : Person("Unknown", 0, "Unknown") {}
Person(const std::string& n, int a) : Person(n, a, "Unknown") {}
};继承构造函数:
1
2
3
4
5
6
7
8
9
10
11class Base {
public:
Base(int x) : value(x) {}
private:
int value;
};
class Derived : public Base {
public:
using Base::Base; // 继承基类的构造函数
};显式转换操作符:
1
2
3
4
5
6
7class MyInt {
private:
int value;
public:
MyInt(int v) : value(v) {}
explicit operator int() const { return value; } // 显式转换
};类内枚举:
1
2
3
4
5
6class Color {
public:
enum class Type { RED, GREEN, BLUE }; // 作用域枚举
private:
Type type;
};移动语义支持:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class String {
private:
char* data;
size_t size;
public:
// 移动构造函数
String(String&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 移动赋值运算符
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};** constexpr构造函数**:
1
2
3
4
5
6
7
8
9
10
11
12class Point {
private:
int x, y;
public:
constexpr Point(int x, int y) : x(x), y(y) {}
constexpr int getX() const { return x; }
constexpr int getY() const { return y; }
};
// 编译期计算
constexpr Point p(10, 20);
constexpr int x = p.getX(); // 编译期计算内联命名空间:
1
2
3
4
5
6
7
8
9namespace MyLib {
inline namespace V2 {
class Widget {
// 实现
};
}
}
// 可以直接使用 MyLib::Widget
这些新特性使得C++类的设计更加灵活和强大,提高了代码的可读性和可维护性。
成员变量的初始化
成员变量的初始化是C++类设计中的重要环节,直接影响到对象的正确性和性能。
初始化方式
C++支持多种成员变量初始化方式:
类内默认初始化(C++11+):
1
2
3
4
5class Person {
private:
std::string name = "Unknown"; // 默认初始化
int age = 0; // 默认初始化
};构造函数初始化列表:
1
2
3
4
5
6
7
8class Person {
private:
std::string name;
int age;
public:
Person(const std::string& n, int a) : name(n), age(a) {} // 初始化列表
};构造函数体内赋值:
1
2
3
4
5
6
7
8
9
10
11class Person {
private:
std::string name;
int age;
public:
Person(const std::string& n, int a) {
name = n; // 赋值
age = a; // 赋值
}
};
初始化顺序的重要性
成员变量的初始化顺序是由其在类中声明的顺序决定的,而不是初始化列表中的顺序:
1 | class Test { |
正确的做法:确保初始化列表中的顺序与声明顺序一致,或者避免依赖其他成员变量进行初始化。
初始化方式的选择
| 初始化方式 | 适用场景 | 性能 | 特点 |
|---|---|---|---|
| 类内默认初始化 | 提供默认值 | 高 | 代码简洁,适用于大多数成员 |
| 构造函数初始化列表 | 依赖构造参数 | 高 | 避免二次赋值,适用于所有成员 |
| 构造函数体内赋值 | 复杂初始化逻辑 | 中 | 允许复杂计算,有二次赋值开销 |
特殊成员的初始化
const成员:必须在初始化列表中初始化
1
2
3
4
5
6
7class Circle {
private:
const double radius;
public:
Circle(double r) : radius(r) {} // 必须使用初始化列表
};引用成员:必须在初始化列表中初始化
1
2
3
4
5
6
7class ReferenceWrapper {
private:
int& ref;
public:
ReferenceWrapper(int& r) : ref(r) {} // 必须使用初始化列表
};成员对象:如果没有默认构造函数,必须在初始化列表中初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14class Date {
public:
Date(int year, int month, int day) : year(year), month(month), day(day) {}
private:
int year, month, day;
};
class Person {
private:
Date birthDate; // 没有默认构造函数
public:
Person(int y, int m, int d) : birthDate(y, m, d) {} // 必须使用初始化列表
};
C++11+的初始化列表增强
C++11引入了统一初始化语法,支持使用花括号进行列表初始化:
列表初始化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Point {
private:
int x, y;
public:
Point(std::initializer_list<int> init) {
auto it = init.begin();
x = (it != init.end()) ? *it : 0;
y = (++it != init.end()) ? *it : 0;
}
};
// 使用
Point p1 = {10, 20}; // 列表初始化
Point p2{30, 40}; // 列表初始化委托构造函数与初始化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Person {
private:
std::string name;
int age;
std::string address;
public:
// 主构造函数
Person(const std::string& n, int a, const std::string& addr)
: name(n), age(a), address(addr) {}
// 委托构造函数
Person() : Person("Unknown", 0, "Unknown") {}
Person(const std::string& n) : Person(n, 0, "Unknown") {}
};
初始化的性能考虑
避免二次赋值:使用初始化列表而不是构造函数体内赋值,可以避免成员变量的默认构造和二次赋值
大对象的初始化:对于大对象,初始化列表可以直接构造,避免拷贝开销
常量表达式初始化:对于编译期常量,使用
constexpr构造函数可以在编译期完成初始化移动语义:在初始化列表中使用移动语义可以减少拷贝开销
1
2
3
4
5
6
7class Person {
private:
std::string name;
public:
Person(std::string n) : name(std::move(n)) {} // 移动语义
};
正确的成员变量初始化方式不仅能提高代码的可读性和可维护性,还能显著提升程序的性能。
构造函数的高级特性
构造函数是特殊的成员函数,用于初始化对象。C++提供了多种构造函数形式,每种形式都有其特定的用途和实现原理。
构造函数的类型与特性
默认构造函数:无参数的构造函数
- 如果没有定义任何构造函数,编译器会生成默认构造函数
- 使用
= default可以显式要求编译器生成默认构造函数 - 默认构造函数用于创建默认初始化的对象
带参数的构造函数:接受参数初始化对象
- 可以有多个重载版本,根据参数类型和数量区分
- 是最常用的构造函数形式
拷贝构造函数:通过另一个同类型对象初始化
- 签名:
ClassName(const ClassName& other) - 如果没有定义,编译器会生成默认拷贝构造函数
- 执行成员逐个拷贝
- 签名:
移动构造函数:通过移动语义初始化对象(C++11+)
- 签名:
ClassName(ClassName&& other) noexcept - 接受右值引用参数
- 移动资源而不是拷贝,提高性能
- 签名:
委托构造函数:一个构造函数调用同一类的其他构造函数(C++11+)
- 减少代码重复
- 只能在初始化列表中委托
- 可以形成委托链,但不能循环委托
继承构造函数:继承基类的构造函数(C++11+)
- 使用
using BaseClass::BaseClass;声明 - 继承基类的所有构造函数
- 可以与派生类自己的构造函数共存
- 使用
构造函数的底层实现
默认构造函数:
- 编译器生成的默认构造函数会调用成员变量的默认构造函数
- 对于内置类型,不进行初始化
- 对于类类型,递归调用其默认构造函数
拷贝构造函数:
- 编译器生成的拷贝构造函数会逐个拷贝成员变量
- 对于类类型成员,调用其拷贝构造函数
- 对于内置类型成员,直接拷贝值
移动构造函数:
- 编译器生成的移动构造函数会逐个移动成员变量
- 对于类类型成员,调用其移动构造函数
- 对于内置类型成员,直接拷贝值(因为内置类型没有移动语义)
委托构造函数:
- 底层实现类似于函数调用
- 先调用目标构造函数初始化对象
- 然后执行当前构造函数的函数体
继承构造函数:
- 编译器会为每个继承的构造函数生成一个派生类构造函数
- 生成的构造函数会调用相应的基类构造函数
- 不会初始化派生类的额外成员
构造函数的异常安全
构造函数可能会抛出异常,这会影响对象的初始化状态和内存管理:
异常传播:
- 构造函数抛出异常时,对象不会被完全构造
- 已构造的成员会被析构
- 不会执行析构函数
异常安全的构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class SafeClass {
private:
std::string* data;
int size;
public:
SafeClass(int s) : size(s), data(nullptr) {
try {
data = new std::string[s]; // 可能抛出异常
} catch (...) {
// 清理已分配的资源
delete[] data;
throw; // 重新抛出异常
}
}
~SafeClass() {
delete[] data;
}
};使用智能指针提高异常安全性:
1
2
3
4
5
6
7
8
9
10
11class SafeClass {
private:
std::unique_ptr<std::string[]> data;
int size;
public:
SafeClass(int s) : size(s), data(new std::string[s]) {
// 即使抛出异常,unique_ptr也会自动释放资源
}
// 不需要手动析构函数
};
构造函数的性能优化
使用初始化列表:
- 避免成员变量的默认构造和二次赋值
- 对于大对象,直接构造而不是拷贝
移动语义:
- 在构造函数中使用移动语义减少拷贝开销
- 对于临时对象,优先使用移动构造
** constexpr构造函数**:
- 对于编译期常量,使用
constexpr构造函数 - 允许在编译期完成初始化
- 对于编译期常量,使用
inline构造函数:
- 对于简单的构造函数,使用
inline关键字 - 减少函数调用开销
- 对于简单的构造函数,使用
小对象优化:
- 对于小对象,考虑内联存储而不是堆分配
- 减少内存分配开销
构造函数的实际应用示例
1 | class Person { |
构造函数的最佳实践
使用初始化列表:优先使用初始化列表初始化成员变量,而不是构造函数体内赋值
避免在构造函数中执行复杂操作:构造函数应该只负责初始化,复杂操作应该移到其他方法中
使用委托构造函数减少代码重复:当多个构造函数有共同的初始化逻辑时,使用委托构造函数
正确处理异常:构造函数抛出异常时,确保已分配的资源被正确释放
使用移动语义提高性能:对于大对象,提供移动构造函数以提高性能
显式声明特殊构造函数:根据需要显式声明或删除拷贝构造函数和移动构造函数
使用智能指针管理资源:减少内存泄漏的风险,提高异常安全性
遵循零开销原则:构造函数的开销应该与所完成的工作相匹配,避免不必要的开销
对象的创建和使用
对象的创建和使用是C++编程中的核心操作,涉及到内存管理、生命周期控制和性能优化等多个方面。
对象的存储期
C++中的对象有四种存储期:
自动存储期(Automatic Storage Duration):
- 在函数内部定义的对象(除了static修饰的)
- 存储在栈上
- 作用域结束时自动销毁
- 例如:
Person person1("Alice", 25);
静态存储期(Static Storage Duration):
- 使用static修饰的对象
- 存储在全局数据区
- 程序启动时创建,程序结束时销毁
- 例如:
static Person person2("Bob", 30);
动态存储期(Dynamic Storage Duration):
- 使用new运算符创建的对象
- 存储在堆上
- 需要手动使用delete运算符销毁
- 例如:
Person* person3 = new Person("Charlie", 35);
线程存储期(Thread Storage Duration):
- 使用thread_local修饰的对象
- 每个线程有一个独立的实例
- 线程启动时创建,线程结束时销毁
- 例如:
thread_local Person person4("David", 40);
对象的内存布局
对象在内存中的布局取决于其成员变量和继承关系:
简单对象的内存布局:
- 成员变量按声明顺序存储
- 考虑内存对齐
- 不包含成员函数的空间
包含虚函数的对象:
- 开头存储虚函数表指针(vptr)
- 然后存储成员变量
- 虚函数表指针指向类的虚函数表(vtable)
继承关系中的内存布局:
- 基类子对象在前
- 派生类成员在后
- 多重继承会有多个虚函数表指针
对象的生命周期管理
手动管理:
- 使用new/delete手动管理动态对象
- 容易出现内存泄漏和悬挂指针问题
智能指针管理:
- std::unique_ptr:独占所有权
- std::shared_ptr:共享所有权
- std::weak_ptr:非拥有式引用
- 自动管理内存,避免内存泄漏
RAII原则:
- 资源获取即初始化
- 使用对象管理资源
- 确保资源在作用域结束时被正确释放
对象的创建和使用示例
1 | // 对象的创建和使用 |
对象操作的性能考虑
栈 vs 堆:
- 栈上创建和销毁对象的速度更快
- 堆上创建的对象可以有更长的生命周期
- 栈空间有限,不适合创建大对象
拷贝 vs 移动:
- 拷贝操作会创建新对象,开销较大
- 移动操作只是转移资源所有权,开销较小
- 对于大对象,优先使用移动语义
临时对象优化:
- 编译器会进行返回值优化(RVO)和命名返回值优化(NRVO)
- 减少临时对象的创建和销毁开销
- C++17引入了保证拷贝省略(Guaranteed Copy Elision)
对象池:
- 对于频繁创建和销毁的对象,使用对象池
- 预先分配对象,减少内存分配开销
- 适用于游戏、服务器等高性能场景
对象的高级使用技巧
对象的序列化和反序列化:
- 将对象转换为字节流存储或传输
- 从字节流恢复对象
- 用于文件存储、网络传输等
对象的克隆:
- 深拷贝:创建完全独立的对象副本
- 浅拷贝:只拷贝对象的引用
- 原型模式:通过克隆创建对象
对象的比较:
- 重载==和!=运算符
- 实现比较操作符<, >, <=, >=
- 支持标准库算法的使用
对象的哈希:
- 为对象提供哈希函数
- 支持在unordered_map、unordered_set等容器中使用
- 重载std::hash特化
智能指针的高级应用
1 | // 智能指针的高级应用 |
对象创建和使用的最佳实践
优先使用栈对象:对于作用域内的对象,优先使用栈分配
使用智能指针管理堆对象:避免内存泄漏和悬挂指针
合理使用移动语义:对于大对象,使用移动构造和移动赋值
考虑对象的生命周期:明确对象的创建和销毁时机
优化对象的内存布局:合理安排成员变量顺序,减少内存对齐开销
使用对象池:对于频繁创建和销毁的对象,使用对象池提高性能
遵循RAII原则:使用对象管理资源,确保资源的正确释放
注意异常安全:确保对象在异常情况下也能正确管理资源
成员函数的const限定符
成员函数的const限定符是C++中实现常量正确性(Const Correctness)的重要机制,它确保了对象在不同上下文中的正确使用。
const成员函数的基本概念
1 | class Person { |
const成员函数的底层实现
this指针的类型:
- 在非const成员函数中,this指针的类型是
ClassName* const - 在const成员函数中,this指针的类型是
const ClassName* const - 这意味着在const成员函数中,不能修改this指针指向的对象
- 在非const成员函数中,this指针的类型是
const成员函数的重载:
- const和非const成员函数可以构成重载
- 非const对象调用非const版本
- const对象调用const版本
const成员函数的内存布局:
- const成员函数和非const成员函数在内存中的存储方式相同
- const限定符是编译期检查,不影响运行时内存布局
mutable关键字
mutable关键字允许在const成员函数中修改特定的成员变量:
1 | class Counter { |
const的传递性
const限定符具有传递性,影响函数参数、返回值和成员函数调用:
const参数:
- 表示函数不会修改参数
- 可以接受const和非const实参
const返回值:
- 表示函数返回的对象是常量
- 防止返回值被意外修改
const成员函数链:
- const成员函数只能调用其他const成员函数
- 确保整个调用链都不会修改对象状态
const成员函数的高级应用
const与引用返回:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class StringView {
private:
const char* data;
size_t length;
public:
StringView(const char* str) : data(str), length(strlen(str)) {}
// 返回const引用,防止修改原始数据
const char& operator[](size_t index) const {
if (index >= length) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
size_t size() const {
return length;
}
};const与运算符重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class Complex {
private:
double real;
double imag;
public:
Complex(double r = 0, double i = 0) : real(r), imag(i) {}
// const成员函数重载运算符+
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
// const成员函数重载运算符==
bool operator==(const Complex& other) const {
return real == other.real && imag == other.imag;
}
// 非const成员函数重载运算符=
Complex& operator=(const Complex& other) {
if (this != &other) {
real = other.real;
imag = other.imag;
}
return *this;
}
};const与智能指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class Data {
private:
int value;
public:
Data(int v) : value(v) {}
int getValue() const {
return value;
}
void setValue(int v) {
value = v;
}
};
// const智能指针
const std::unique_ptr<Data> constPtr = std::make_unique<Data>(42);
// constPtr->setValue(100); // 错误:constPtr是const的
std::cout << constPtr->getValue() << std::endl; // 允许
// 指向const对象的智能指针
std::unique_ptr<const Data> ptrToConst = std::make_unique<Data>(42);
// ptrToConst->setValue(100); // 错误:指向的对象是const的
std::cout << ptrToConst->getValue() << std::endl; // 允许
常量正确性的最佳实践
尽可能使用const:
- 对于不修改对象状态的成员函数,添加const限定符
- 对于不修改参数的函数参数,添加const限定符
- 对于不修改返回值的函数,考虑返回const值
const与重载:
- 为const和非const对象提供适当的重载版本
- 确保const版本和非const版本的行为一致
合理使用mutable:
- 只对真正需要在const成员函数中修改的成员变量使用mutable
- 通常用于缓存、计数等不影响对象逻辑状态的成员
const与引用传递:
- 对于大对象,优先使用const引用传递
- 避免不必要的拷贝,同时确保对象不被修改
const与移动语义:
- 注意const对象不能被移动,只能被拷贝
- 移动语义需要修改源对象,与const语义冲突
const成员函数的性能考虑
编译器优化:
- const成员函数提供了更强的不变性保证
- 编译器可以进行更多的优化,如常量折叠、函数内联等
线程安全:
- const成员函数通常是线程安全的(如果没有修改mutable成员)
- 多个线程可以同时调用const成员函数
代码可读性:
- const限定符明确了函数的行为
- 提高了代码的可读性和可维护性
const的编译期检查
const限定符是编译期检查,不是运行时检查:
编译错误:
- 在const成员函数中修改非mutable成员会导致编译错误
- 调用非const成员函数会导致编译错误
const_cast:
- 可以使用const_cast移除const限定符
- 但这是不安全的,应该避免使用
- 只有在处理旧代码或第三方库时才考虑使用
1 | class Test { |
常量正确性是C++编程中的重要概念,它通过编译期检查确保了对象的正确使用,提高了代码的安全性、可读性和可维护性。
this指针
this指针是C++中一个特殊的隐含指针,它在非静态成员函数中指向调用该函数的对象。理解this指针的工作原理对于掌握C++的面向对象编程至关重要。
this指针的基本概念
每个非静态成员函数都有一个隐含的this指针,指向调用该函数的对象:
1 | class Person { |
this指针的底层实现
this指针的传递:
this指针是作为隐藏参数传递给非静态成员函数的- 在大多数编译器中,
this指针通过寄存器传递(如x86中的ECX寄存器) - 对于虚函数,
this指针的传递机制更加复杂
this指针的类型:
- 在非const成员函数中,
this的类型是ClassName* const - 在const成员函数中,
this的类型是const ClassName* const - 在volatile成员函数中,
this的类型是volatile ClassName* const - 在const volatile成员函数中,
this的类型是const volatile ClassName* const
- 在非const成员函数中,
this指针的内存位置:
this指针存储在栈上或寄存器中,不是对象的一部分- 对象本身不包含
this指针,因此不会增加对象的大小 this指针的值是对象的内存地址
this指针的优化
编译器优化:
- 编译器会对
this指针的访问进行优化,如内联展开 - 对于简单的成员函数,
this指针的开销可以忽略不计 - 在某些情况下,编译器可以完全消除
this指针的使用
- 编译器会对
this指针的空值检查:
- 在成员函数中,
this指针通常不应该为nullptr - 但是,通过显式调用或指针操作,可能会出现
this指针为nullptr的情况 - 成员函数在
this指针为nullptr时调用会导致未定义行为
- 在成员函数中,
1 | class Test { |
this指针在继承和多态中的应用
继承中的this指针:
- 在派生类的成员函数中,
this指针的类型是派生类类型 - 但是,通过类型转换,可以将
this指针转换为基类类型 - 在基类的成员函数中,
this指针的类型是基类类型
- 在派生类的成员函数中,
多态中的this指针:
- 在虚函数调用中,
this指针指向实际的对象类型 - 虚函数表的查找基于
this指针指向的对象类型 - 这使得多态调用能够正确地调用派生类的实现
- 在虚函数调用中,
this指针的类型转换:
- 在继承层次结构中,可以使用
static_cast或dynamic_cast转换this指针的类型 static_cast用于已知的类型转换dynamic_cast用于运行时类型检查
- 在继承层次结构中,可以使用
1 | class Base { |
this指针的高级应用
返回*this实现链式调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30class Calculator {
private:
int value;
public:
Calculator(int v = 0) : value(v) {}
Calculator& add(int x) {
value += x;
return *this;
}
Calculator& subtract(int x) {
value -= x;
return *this;
}
Calculator& multiply(int x) {
value *= x;
return *this;
}
int getValue() const {
return value;
}
};
// 使用链式调用
Calculator calc;
int result = calc.add(5).subtract(2).multiply(3).getValue(); // 结果:(5-2)*3=9this指针与运算符重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30class Vector2D {
private:
double x, y;
public:
Vector2D(double x = 0, double y = 0) : x(x), y(y) {}
// 重载+=运算符
Vector2D& operator+=(const Vector2D& other) {
x += other.x;
y += other.y;
return *this;
}
// 重载+运算符(基于+=)
Vector2D operator+(const Vector2D& other) const {
Vector2D result = *this;
result += other;
return result;
}
// 重载=运算符
Vector2D& operator=(const Vector2D& other) {
if (this != &other) { // 自我赋值检查
x = other.x;
y = other.y;
}
return *this;
}
};this指针与模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22template <typename T>
class SmartPtr {
private:
T* ptr;
public:
SmartPtr(T* p) : ptr(p) {}
~SmartPtr() { delete ptr; }
T& operator*() const {
return *ptr;
}
T* operator->() const {
return ptr;
}
// 检查两个SmartPtr是否指向同一个对象
bool operator==(const SmartPtr& other) const {
return ptr == other.ptr;
}
};
this指针的最佳实践
明确使用this指针:
- 当成员变量与函数参数同名时,使用
this->明确访问成员变量 - 提高代码的可读性,避免命名冲突
- 当成员变量与函数参数同名时,使用
链式调用:
- 对于修改对象状态的成员函数,返回
*this以支持链式调用 - 提高代码的简洁性和表达力
- 对于修改对象状态的成员函数,返回
自我赋值检查:
- 在赋值运算符和拷贝构造函数中,检查自我赋值
- 避免不必要的操作和潜在的错误
谨慎使用this指针:
- 不要在构造函数中使用
this指针注册对象(可能导致未完全构造的对象被使用) - 不要在析构函数中使用
this指针调用虚函数(虚函数表可能已被销毁) - 避免将
this指针存储在全局或静态变量中(可能导致悬空指针)
- 不要在构造函数中使用
this指针与线程安全:
this指针本身不是线程安全的- 在多线程环境中,需要确保对
this指向对象的访问是线程安全的 - 考虑使用互斥锁或其他同步机制保护对象状态
this指针的性能影响
内存开销:
this指针不增加对象的大小,它是一个隐含的参数- 在32位系统中,
this指针占用4字节;在64位系统中,占用8字节 - 但是,
this指针的传递和使用会产生一定的运行时开销
缓存局部性:
this指针指向的对象通常在内存中是连续存储的- 这有助于提高缓存局部性,减少缓存未命中
- 成员变量的访问通常比全局变量或堆变量的访问更快
编译器优化:
- 现代编译器会对
this指针的使用进行大量优化 - 包括内联展开、常量传播、死代码消除等
- 在大多数情况下,
this指针的开销可以忽略不计
- 现代编译器会对
this指针是C++面向对象编程的核心概念之一,它提供了一种在成员函数中访问调用对象的机制。正确理解和使用this指针,对于编写高效、安全、可维护的C++代码至关重要。
静态成员
静态成员是C++中一种特殊的成员,它属于类而不是对象,所有对象共享同一个静态成员。静态成员在面向对象编程中有着广泛的应用,如计数器、配置信息、单例模式等。
静态成员的基本概念
静态成员属于类而不是对象,所有对象共享同一个静态成员:
1 | class Person { |
静态成员的底层实现
静态成员变量的存储:
- 静态成员变量存储在全局数据区,而不是对象的内存空间中
- 它们在程序启动时分配内存,程序结束时释放内存
- 所有对象共享同一个静态成员变量的实例
静态成员函数的实现:
- 静态成员函数不包含
this指针 - 它们在内存中只有一份拷贝,与普通函数类似
- 静态成员函数不能访问非静态成员,因为没有
this指针
- 静态成员函数不包含
静态成员的初始化:
- 静态成员变量必须在类外部定义和初始化
- 初始化顺序在同一个编译单元内是确定的,但在不同编译单元间是不确定的
- C++17引入了内联静态成员变量,可以在类内部初始化
静态成员的高级特性
内联静态成员变量(C++17+):
1
2
3
4
5
6class Config {
public:
// C++17+:内联静态成员变量,可以在类内部初始化
inline static const std::string version = "1.0.0";
inline static const int maxConnections = 100;
};静态成员与模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25template <typename T>
class Array {
private:
static size_t objectCount;
T* data;
size_t size;
public:
Array(size_t s) : size(s), data(new T[s]) {
++objectCount;
}
~Array() {
delete[] data;
--objectCount;
}
static size_t getObjectCount() {
return objectCount;
}
};
// 为每个模板实例化定义静态成员变量
template <typename T>
size_t Array<T>::objectCount = 0;静态成员与单例模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36class Singleton {
private:
static Singleton* instance;
int value;
// 私有构造函数,防止外部创建实例
Singleton() : value(0) {}
// 防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
static void destroyInstance() {
delete instance;
instance = nullptr;
}
void setValue(int v) {
value = v;
}
int getValue() const {
return value;
}
};
// 初始化静态成员变量
Singleton* Singleton::instance = nullptr;Meyer’s 单例模式(线程安全):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class Singleton {
private:
int value;
// 私有构造函数
Singleton() : value(0) {}
public:
// 静态成员函数返回静态局部变量
static Singleton& getInstance() {
static Singleton instance; // C++11+保证线程安全的初始化
return instance;
}
void setValue(int v) {
value = v;
}
int getValue() const {
return value;
}
// 防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
静态成员的线程安全性
静态成员变量的线程安全问题:
- 多个线程同时访问和修改静态成员变量可能导致竞态条件
- 需要使用互斥锁或其他同步机制来保护
线程安全的静态成员:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ThreadSafeCounter {
private:
static int count;
static std::mutex mtx;
public:
ThreadSafeCounter() {
std::lock_guard<std::mutex> lock(mtx);
++count;
}
~ThreadSafeCounter() {
std::lock_guard<std::mutex> lock(mtx);
--count;
}
static int getCount() {
std::lock_guard<std::mutex> lock(mtx);
return count;
}
};
// 初始化静态成员
int ThreadSafeCounter::count = 0;
std::mutex ThreadSafeCounter::mtx;C++11+的线程安全初始化:
- C++11标准保证静态局部变量的初始化是线程安全的
- 这使得Meyer’s单例模式成为实现线程安全单例的最佳选择
静态成员的性能考虑
内存使用:
- 静态成员变量在全局数据区只存储一份,节省内存
- 对于频繁使用的常量,使用静态成员可以避免重复创建
访问速度:
- 静态成员变量的访问速度与全局变量相当,比成员变量稍快
- 静态成员函数的调用速度与普通函数相当,比成员函数稍快(因为没有
this指针)
初始化开销:
- 静态成员变量在程序启动时初始化,增加了程序的启动时间
- 对于大型静态对象,初始化开销可能较大
静态成员的最佳实践
合理使用静态成员:
- 对于所有对象共享的数据,使用静态成员变量
- 对于与对象状态无关的操作,使用静态成员函数
- 避免过度使用静态成员,以免破坏面向对象的封装性
静态成员的命名约定:
- 使用大写字母或特定前缀标识静态成员
- 提高代码的可读性和可维护性
静态成员的初始化:
- 确保静态成员变量在使用前正确初始化
- 对于依赖于其他静态成员的情况,注意初始化顺序
- 优先使用C++17的内联静态成员变量,简化代码
静态成员与继承:
- 静态成员在继承层次结构中是共享的
- 派生类可以访问基类的静态成员
- 派生类可以隐藏基类的静态成员,但不能覆盖它们
静态成员的测试:
- 静态成员的状态会在测试之间保持
- 在单元测试中,需要确保静态成员的状态不会影响其他测试
- 考虑在测试前后重置静态成员的状态
静态成员的常见应用场景
- 计数器:跟踪类的实例数量
- 配置信息:存储应用程序的配置参数
- 常量定义:定义类级别的常量
- 单例模式:确保类只有一个实例
- 工厂模式:创建对象的工厂方法
- 工具函数:与对象状态无关的工具方法
- 缓存:存储共享的缓存数据
- 全局状态:管理应用程序的全局状态
静态成员是C++中一种强大的特性,它提供了一种在类级别共享数据和行为的机制。正确使用静态成员可以提高代码的效率和可维护性,但过度使用可能会导致代码难以理解和测试。在实际编程中,应该根据具体情况权衡使用静态成员的利弊。
内联函数
内联函数可以减少函数调用的开销,适用于短小的函数:
1 | class Person { |
类的大小
类的大小由其成员变量决定,成员函数不占用对象空间:
1 | class EmptyClass {}; // 空类的大小为1 |
类的前向声明
当两个类相互引用时,可以使用前向声明:
1 | // 前向声明 |
封装:数据隐藏与接口设计
封装的概念与原则
封装是面向对象编程的核心原则之一,它将数据和操作数据的方法捆绑在一起,形成一个独立的单元(类),并通过访问控制机制隐藏内部实现细节,只暴露必要的接口给外部使用。
封装的核心原则:
- 信息隐藏:隐藏对象的内部状态和实现细节
- 接口分离:只暴露必要的公共接口
- 数据保护:防止外部代码直接修改对象的内部状态
- 实现隔离:内部实现的变化不影响外部代码
访问控制的深度分析
C++提供了三种访问控制修饰符,用于控制类成员的可访问性:
| 访问修饰符 | 类内部 | 子类内部 | 类外部 | 说明 |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | 只能在类内部访问 |
protected | ✅ | ✅ | ❌ | 可以在类内部和子类中访问 |
public | ✅ | ✅ | ✅ | 可以在任何地方访问 |
访问控制的高级应用
友元机制:打破封装的限制
友元可以访问类的私有和保护成员,但应谨慎使用,因为它会破坏封装性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36class BankAccount {
private:
std::string accountNumber;
double balance;
// 友元函数
friend void auditAccount(const BankAccount& account);
// 友元类
friend class BankManager;
public:
BankAccount(const std::string& accNum, double initialBalance)
: accountNumber(accNum), balance(initialBalance) {}
// 公共接口
void deposit(double amount);
void withdraw(double amount);
double getBalance() const;
std::string getAccountNumber() const;
};
// 友元函数实现
void auditAccount(const BankAccount& account) {
std::cout << "Auditing account: " << account.accountNumber << std::endl;
std::cout << "Balance: $" << account.balance << std::endl;
}
// 友元类
class BankManager {
public:
void freezeAccount(BankAccount& account) {
// 可以访问私有成员
std::cout << "Freezing account: " << account.accountNumber << std::endl;
}
};类成员的访问控制
- 成员变量:通常设为
private,通过公共方法访问 - 成员函数:根据需要设置为
public、protected或private - 构造函数:通常设为
public,但单例模式中设为private - 析构函数:通常设为
public,但在某些情况下设为protected
- 成员变量:通常设为
继承中的访问控制
继承时的访问控制修饰符会影响基类成员在派生类中的可访问性:
基类成员 公有继承 保护继承 私有继承 publicpublicprotectedprivateprotectedprotectedprotectedprivateprivate不可访问 不可访问 不可访问
封装的实现技巧
属性访问器模式:
使用getter和setter方法访问和修改私有成员,提供额外的控制和验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30class Person {
private:
std::string name;
int age;
public:
// Getter方法
const std::string& getName() const {
return name;
}
int getAge() const {
return age;
}
// Setter方法(带验证)
void setName(const std::string& newName) {
if (newName.empty()) {
throw std::invalid_argument("Name cannot be empty");
}
name = newName;
}
void setAge(int newAge) {
if (newAge < 0 || newAge > 150) {
throw std::invalid_argument("Age must be between 0 and 150");
}
age = newAge;
}
};不可变对象:
通过只提供getter方法,不提供setter方法,创建不可变对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class ImmutablePerson {
private:
std::string name;
int age;
public:
// 构造时初始化,之后不可修改
ImmutablePerson(const std::string& n, int a) : name(n), age(a) {}
// 只提供getter方法
const std::string& getName() const {
return name;
}
int getAge() const {
return age;
}
// 不提供setter方法
};委托与组合:
通过组合和委托实现更灵活的封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54class AccountValidator {
public:
static bool isValidAmount(double amount) {
return amount > 0;
}
static bool hasSufficientFunds(double balance, double amount) {
return balance >= amount;
}
};
class BankAccount {
private:
std::string accountNumber;
double balance;
AccountValidator validator; // 组合
public:
BankAccount(const std::string& accNum, double initialBalance)
: accountNumber(accNum), balance(initialBalance) {
if (!AccountValidator::isValidAmount(initialBalance)) {
throw std::invalid_argument("Initial balance must be positive");
}
}
void deposit(double amount) {
if (AccountValidator::isValidAmount(amount)) {
balance += amount;
std::cout << "Deposited: $" << amount << std::endl;
std::cout << "New balance: $" << balance << std::endl;
} else {
std::cout << "Invalid deposit amount" << std::endl;
}
}
void withdraw(double amount) {
if (AccountValidator::isValidAmount(amount) &&
AccountValidator::hasSufficientFunds(balance, amount)) {
balance -= amount;
std::cout << "Withdrew: $" << amount << std::endl;
std::cout << "New balance: $" << balance << std::endl;
} else {
std::cout << "Invalid withdrawal amount" << std::endl;
}
}
double getBalance() const {
return balance;
}
std::string getAccountNumber() const {
return accountNumber;
}
};
封装的优点
数据安全:
- 防止外部代码意外修改对象的内部状态
- 可以在setter方法中添加验证逻辑,确保数据的有效性
实现隐藏:
- 隐藏内部实现细节,只暴露必要的接口
- 内部实现可以随时修改,而不影响外部代码
代码维护性:
- 集中管理数据的访问和修改,便于维护
- 减少代码耦合,提高代码的可维护性
接口清晰:
- 只暴露必要的公共接口,使类的使用更加清晰
- 降低了使用类的复杂度,提高了代码的可读性
可测试性:
- 封装使得类的行为更加可预测,便于单元测试
- 可以通过mock对象模拟依赖,提高测试覆盖率
封装的最佳实践
成员变量私有化:
- 所有成员变量默认设为
private - 通过公共方法访问和修改成员变量
- 所有成员变量默认设为
接口设计原则:
- 最小接口原则:只暴露必要的接口
- 接口稳定性:公共接口一旦确定,尽量保持稳定
- 接口清晰度:接口名称应清晰表达其功能
访问器方法设计:
- Getter方法:返回成员变量的副本或常量引用
- Setter方法:添加参数验证,确保数据有效性
- 命名规范:使用
getXxx和setXxx命名模式
避免过度封装:
- 不要为每个私有成员都提供getter和setter
- 只提供必要的访问方法,保持类的简洁性
使用const:
- 对于不修改对象状态的方法,使用
const修饰 - 对于返回成员变量的方法,考虑返回
const引用
- 对于不修改对象状态的方法,使用
异常安全:
- 在setter方法中使用异常处理无效参数
- 确保在构造和析构过程中的异常安全性
封装的实际应用案例
银行账户管理系统:
1 | class BankAccount { |
封装与其他OOP原则的关系
封装与继承:
- 封装确保派生类只能通过基类的公共接口访问基类的成员
- 保护成员允许派生类访问基类的实现细节,同时保持对外部的封装
封装与多态:
- 封装隐藏了对象的内部实现,只通过公共接口与对象交互
- 多态允许通过基类接口使用派生类对象,而不需要了解具体实现
封装与组合:
- 组合是实现封装的重要手段,通过包含其他对象来实现复杂功能
- 封装确保组合对象的内部细节不被外部访问
封装是面向对象编程的基础,它通过信息隐藏和接口设计,提高了代码的安全性、可维护性和可扩展性。在C++中,合理使用访问控制修饰符和设计良好的接口,是实现有效封装的关键。
继承:代码重用与层次结构
继承的概念与原理
继承是面向对象编程的核心特性之一,它允许从已有类(基类/父类)派生出新类(派生类/子类),子类继承父类的属性和方法,同时可以添加自己的属性和方法,或者重写父类的方法。
继承的核心原理:
- 代码重用:子类继承父类的代码,减少重复代码
- 层次结构:建立类的层次结构,更符合现实世界的模型
- 类型关系:通过继承建立”is-a”关系
- 多态基础:为运行时多态提供基础
继承的类型
C++支持三种继承方式,不同的继承方式会影响基类成员在派生类中的可访问性:
公有继承(public inheritance):
- 基类的
public成员在派生类中仍然是public - 基类的
protected成员在派生类中仍然是protected - 基类的
private成员在派生类中不可访问 - 体现”is-a”关系,是最常用的继承方式
- 基类的
保护继承(protected inheritance):
- 基类的
public和protected成员在派生类中都是protected - 基类的
private成员在派生类中不可访问 - 体现”is-implemented-in-terms-of”关系
- 基类的
私有继承(private inheritance):
- 基类的
public和protected成员在派生类中都是private - 基类的
private成员在派生类中不可访问 - 体现”is-implemented-in-terms-of”关系,通常用于实现细节的重用
- 基类的
继承的语法与实现
1 | // 基类定义 |
构造函数与析构函数的继承
构造函数的调用顺序:
- 派生类对象创建时,先调用基类的构造函数,再调用派生类的构造函数
- 基类构造函数的调用顺序:从最顶层的基类开始,依次向下
析构函数的调用顺序:
- 派生类对象销毁时,先调用派生类的析构函数,再调用基类的析构函数
- 基类析构函数的调用顺序:从最底层的派生类开始,依次向上
构造函数的继承:
- C++11及以上版本支持使用
using BaseClass::BaseClass;继承基类的构造函数 - 继承的构造函数会被视为派生类的构造函数,但不包括默认构造函数、拷贝构造函数和移动构造函数
- C++11及以上版本支持使用
虚析构函数:
- 当使用基类指针指向派生类对象时,基类的析构函数应该声明为
virtual - 这样可以确保派生类的析构函数被正确调用
- 当使用基类指针指向派生类对象时,基类的析构函数应该声明为
1 | class Animal { |
继承与多态的深度关系
继承是实现多态的基础,多态通过虚函数和继承实现:
虚函数:
- 在基类中使用
virtual关键字声明 - 在派生类中使用
override关键字重写 - 虚函数表(vtable):每个包含虚函数的类都有一个虚函数表,存储虚函数的地址
- 虚指针(vptr):每个对象都有一个虚指针,指向类的虚函数表
- 在基类中使用
运行时多态:
- 通过基类指针或引用调用虚函数
- 运行时根据对象的实际类型确定调用哪个版本的函数
- 实现了”一个接口,多种实现”
静态多态:
- 通过模板和函数重载实现
- 编译时确定调用哪个版本的函数
- 不依赖继承和虚函数
1 | // 多态的经典示例 |
继承的高级特性
多重继承:
- 一个派生类可以从多个基类继承
- 语法:
class Derived : public Base1, public Base2 { ... } - 可能导致菱形继承问题(钻石问题)
虚拟继承:
- 用于解决多重继承中的菱形继承问题
- 语法:
class Base : virtual public GrandBase { ... } - 确保派生类只包含一个基类的实例
函数隐藏:
- 当派生类定义了与基类同名的函数时,会隐藏基类的函数
- 即使参数列表不同,也会隐藏
- 可以使用
using BaseClass::function;显式引入基类的函数
覆盖与隐藏的区别:
- 覆盖(override):派生类重写基类的虚函数,函数签名相同
- 隐藏(hide):派生类定义了与基类同名的函数,函数签名可以不同
1 | // 多重继承示例 |
继承的优缺点
优点:
代码重用:
- 继承父类的代码,减少重复代码
- 提高开发效率,减少维护成本
层次结构:
- 建立类的层次结构,更符合现实世界的模型
- 提高代码的可理解性和可维护性
多态支持:
- 为运行时多态提供基础
- 实现了”一个接口,多种实现”
扩展性:
- 易于扩展现有代码,添加新功能
- 符合开放-封闭原则
缺点:
紧耦合:
- 派生类与基类紧密耦合,基类的变化可能影响派生类
- 降低了代码的灵活性和可维护性
复杂性:
- 多重继承增加了代码的复杂性
- 可能导致菱形继承问题
性能开销:
- 虚函数调用有一定的性能开销(通过虚函数表)
- 虚拟继承增加了内存开销(额外的虚指针)
设计限制:
- 继承是静态的,编译时确定
- 可能导致过度设计,违反组合优于继承原则
继承的最佳实践
优先使用公有继承:
- 只有当确实需要体现”is-a”关系时才使用公有继承
- 避免使用保护继承和私有继承,除非有特殊理由
遵循里氏替换原则:
- 子类应该能够替换其父类,并且程序的行为不会改变
- 子类不应违反父类的前置条件和后置条件
合理使用虚函数:
- 对于需要在派生类中重写的函数,使用虚函数
- 对于不需要重写的函数,不使用虚函数(避免性能开销)
使用纯虚函数和抽象类:
- 对于只作为接口的基类,使用抽象类
- 纯虚函数强制派生类提供实现
虚析构函数:
- 当类可能作为基类时,将析构函数声明为虚函数
- 避免内存泄漏和未定义行为
避免多重继承:
- 尽量避免使用多重继承,除非有充分的理由
- 如果必须使用多重继承,考虑使用虚拟继承解决菱形继承问题
组合优于继承:
- 当不需要体现”is-a”关系时,优先使用组合
- 组合具有更低的耦合度,更灵活
接口分离:
- 将大的接口拆分为小的、专用的接口
- 遵循接口隔离原则
继承层次不宜过深:
- 保持继承层次简洁,一般不超过3-4层
- 过深的继承层次会增加代码的复杂性
文档化继承关系:
- 清晰记录类之间的继承关系
- 说明派生类对基类的扩展和重写
继承的实际应用案例
图形绘制系统:
1 | // 抽象基类 |
继承与其他OOP原则的关系
继承与封装:
- 继承不应破坏封装,派生类应通过基类的公共接口访问基类的成员
- 保护成员允许派生类访问基类的实现细节,同时保持对外部的封装
继承与多态:
- 继承是实现多态的基础,多态通过虚函数和继承实现
- 多态允许通过基类接口使用派生类对象,提高代码的灵活性
继承与抽象:
- 抽象类通过纯虚函数定义接口,派生类提供具体实现
- 抽象类为继承层次提供了清晰的契约
继承与设计模式:
- 许多设计模式都基于继承,如模板方法模式、策略模式、观察者模式等
- 合理使用继承可以实现更灵活、可扩展的设计
继承是面向对象编程的重要特性,它通过代码重用和层次结构,提高了代码的可维护性和可扩展性。然而,继承也有其局限性和缺点,需要谨慎使用。在实际开发中,应该根据具体情况选择合适的继承方式,或者考虑使用组合等其他技术来实现代码重用。
多态:接口与实现的分离
多态的概念与原理
多态(Polymorphism)是面向对象编程的核心特性之一,它允许不同类型的对象对同一消息做出不同的响应,即同一操作作用于不同的对象会产生不同的结果。
多态的核心原理:
- 接口与实现分离:通过统一的接口操作不同的实现
- 运行时绑定:运行时根据对象的实际类型确定调用哪个方法
- 代码复用:通过基类接口操作派生类对象
- 扩展性:添加新的派生类不需要修改现有代码
多态的类型
C++支持两种类型的多态:
编译时多态(静态多态):
- 通过函数重载和运算符重载实现
- 编译时确定调用哪个函数
- 不依赖继承和虚函数
- 示例:函数重载、模板、const重载
运行时多态(动态多态):
- 通过虚函数和继承实现
- 运行时根据对象的实际类型确定调用哪个方法
- 依赖虚函数表和虚指针
- 示例:虚函数、抽象类
编译时多态的深度分析
函数重载
函数重载是指在同一个作用域内,函数名相同但参数列表不同的函数:
1 | // 函数重载示例 |
运算符重载
运算符重载允许自定义运算符的行为:
1 | // 运算符重载示例 |
模板
模板是C++实现编译时多态的强大工具:
1 | // 模板示例 |
运行时多态的深度分析
虚函数
虚函数是在基类中使用virtual关键字声明的函数,允许派生类重写:
1 | class Base { |
虚函数的实现原理
虚函数通过虚函数表(vtable)和虚指针(vptr)实现:
虚函数表(vtable):
- 每个包含虚函数的类都有一个虚函数表
- 存储该类所有虚函数的地址
- 类的所有对象共享同一个虚函数表
虚指针(vptr):
- 每个对象都有一个虚指针,指向类的虚函数表
- 在对象构造时初始化
- 占用对象的存储空间(通常为4或8字节)
调用过程:
- 通过对象的虚指针找到虚函数表
- 根据函数在表中的位置找到函数地址
- 调用相应的函数
纯虚函数和抽象类
纯虚函数是在基类中声明但不提供实现的虚函数,派生类必须提供实现:
1 | // 抽象类 |
接口
在C++中,接口是一种特殊的抽象类,只包含纯虚函数,没有数据成员:
1 | // 接口 |
多态的高级应用
虚函数与默认参数
虚函数可以有默认参数,但需要注意:默认参数值是在编译时根据指针类型确定的,而不是运行时根据对象类型确定的:
1 | class Base { |
虚函数与const
虚函数可以是const成员函数,派生类重写时也必须是const:
1 | class Base { |
虚函数与引用
多态也可以通过引用实现:
1 | void printShape(const AbstractShape& shape) { |
虚函数与继承层次
在多级继承中,虚函数会被正确传递:
1 | class Grandparent { |
多态的实现案例
图形绘制系统的多态实现:
1 | // 抽象基类 |
多态的优缺点
优点:
灵活性:
- 代码可以处理不同类型的对象
- 通过统一的接口操作不同的实现
可扩展性:
- 添加新的派生类不需要修改现有代码
- 符合开放-封闭原则
可维护性:
- 代码更清晰,更易于维护
- 减少了条件判断语句
接口一致性:
- 不同类型的对象通过相同的接口进行操作
- 提高了代码的可读性
代码复用:
- 通过基类接口操作派生类对象
- 减少了代码重复
缺点:
性能开销:
- 虚函数调用比普通函数调用慢(需要通过虚函数表查找)
- 虚指针增加了对象的大小
复杂性:
- 理解虚函数表和运行时绑定需要一定的技术水平
- 调试多态代码可能更复杂
设计限制:
- 多态依赖于继承,可能导致紧耦合
- 过度使用多态可能导致设计过于复杂
内存开销:
- 每个包含虚函数的类都需要一个虚函数表
- 每个对象都需要一个虚指针
多态的最佳实践
合理使用虚函数:
- 只对需要在派生类中重写的函数使用虚函数
- 避免在性能关键路径上使用虚函数
使用虚析构函数:
- 当类可能作为基类时,将析构函数声明为虚函数
- 避免内存泄漏和未定义行为
使用纯虚函数和抽象类:
- 对于只作为接口的基类,使用抽象类
- 纯虚函数强制派生类提供实现
接口设计原则:
- 设计清晰、简洁的接口
- 接口应该专注于功能,而不是实现细节
避免过度设计:
- 不要为了使用多态而使用多态
- 根据实际需求选择合适的设计
使用智能指针:
- 使用
std::unique_ptr和std::shared_ptr管理多态对象 - 避免内存泄漏
- 使用
考虑静态多态:
- 对于性能关键的代码,考虑使用模板实现静态多态
- 避免虚函数的运行时开销
文档化接口:
- 清晰记录虚函数的行为和预期
- 说明派生类应该如何重写虚函数
测试多态行为:
- 测试不同派生类的行为
- 确保多态调用按预期工作
组合优于继承:
- 当不需要体现”is-a”关系时,考虑使用组合
- 组合具有更低的耦合度,更灵活
多态与其他OOP原则的关系
多态与封装:
- 多态通过接口与实现分离,增强了封装
- 外部代码只需要了解接口,不需要了解具体实现
多态与继承:
- 继承是实现多态的基础
- 多态通过虚函数和继承实现运行时绑定
多态与抽象:
- 抽象类通过纯虚函数定义接口,是多态的重要组成部分
- 多态通过抽象接口操作具体实现
多态与设计模式:
- 许多设计模式都基于多态,如策略模式、观察者模式、工厂模式等
- 多态是实现这些设计模式的关键
多态是面向对象编程的核心特性之一,它通过接口与实现的分离,提高了代码的灵活性、可扩展性和可维护性。在C++中,多态通过虚函数、纯虚函数和抽象类实现,同时也支持通过模板和重载实现的编译时多态。合理使用多态,可以编写更加优雅、灵活和可维护的代码。
抽象类和接口:契约与实现
抽象类的深度分析
抽象类是一种特殊的类,它至少包含一个纯虚函数,不能直接实例化,只能作为基类被继承。抽象类为派生类定义了一个共同的接口和部分实现。
抽象类的核心特性:
- 不能实例化:抽象类不能直接创建对象
- 包含纯虚函数:至少有一个纯虚函数
- 可以包含非纯虚函数:可以提供默认实现
- 可以包含数据成员:可以有成员变量
- 作为接口:定义派生类必须实现的方法
纯虚函数的高级特性
纯虚函数是在基类中声明但不提供实现的虚函数,派生类必须提供实现:
1 | class AbstractClass { |
接口的概念与实现
在C++中,接口是一种特殊的抽象类,它只包含纯虚函数,没有数据成员和非纯虚函数实现:
1 | // 接口 |
抽象类与接口的区别
| 特性 | 抽象类 | 接口 |
|---|---|---|
| 实例化 | 不能直接实例化 | 不能直接实例化 |
| 纯虚函数 | 至少有一个 | 全部是纯虚函数 |
| 非纯虚函数 | 可以有 | 不能有 |
| 数据成员 | 可以有 | 不能有 |
| 访问修饰符 | 可以使用public、protected、private | 通常只使用public |
| 继承方式 | 通常使用public继承 | 通常使用public继承 |
| 多重继承 | 支持,但应谨慎使用 | 支持,是C++实现多重接口的方式 |
| 目的 | 提供部分实现,定义共同行为 | 定义纯粹的接口,不提供实现 |
抽象类的应用场景
定义接口:
- 为一组相关的类定义共同的接口
- 强制派生类实现特定的方法
提供部分实现:
- 实现共同的功能,减少代码重复
- 允许派生类重写需要自定义的方法
模板方法模式:
- 定义算法的骨架,将一些步骤延迟到派生类
- 例如:排序算法、生命周期管理
工厂方法模式:
- 提供创建对象的接口,让子类决定实例化哪个类
策略模式:
- 定义一系列算法,将它们封装起来,并使它们可互换
抽象类的设计原则
单一职责原则:
- 抽象类应该只有一个引起它变化的原因
- 专注于定义一个特定领域的接口
接口隔离原则:
- 将大的接口拆分为小的、专用的接口
- 派生类不应该被迫实现它不使用的方法
依赖倒置原则:
- 高层模块不应该依赖低层模块,两者都应该依赖于抽象
- 抽象不应该依赖于具体实现,具体实现应该依赖于抽象
里氏替换原则:
- 子类应该能够替换其父类,并且程序的行为不会改变
开放-封闭原则:
- 抽象类应该对扩展开放,对修改封闭
- 新增功能应该通过添加新的派生类实现,而不是修改抽象类
抽象类的实际应用案例
图形绘制系统:
1 | // 抽象基类:形状 |
接口的最佳实践
只定义纯虚函数:
- 接口应该只包含纯虚函数,不包含数据成员和非纯虚函数
- 保持接口的纯粹性和简洁性
提供虚析构函数:
- 接口应该提供虚析构函数,通常使用默认实现
- 避免内存泄漏和未定义行为
使用public继承:
- 实现接口时应该使用public继承
- 确保接口的方法在派生类中保持public访问级别
接口命名规范:
- 接口名称通常使用形容词或动词形式
- 例如:Drawable、Resizable、Serializable
接口隔离:
- 将大的接口拆分为小的、专用的接口
- 避免创建臃肿的接口
多重接口:
- C++支持多重继承,可以实现多个接口
- 这是C++实现类似Java接口的方式
抽象类的最佳实践
合理使用纯虚函数:
- 对于必须由派生类实现的方法,使用纯虚函数
- 对于可以提供默认实现的方法,使用虚函数
提供虚析构函数:
- 抽象类应该提供虚析构函数
- 确保派生类的析构函数被正确调用
设计清晰的层次结构:
- 抽象类应该位于继承层次的上层
- 具体实现应该位于继承层次的下层
避免深度继承:
- 保持继承层次简洁,一般不超过3-4层
- 过深的继承层次会增加代码的复杂性
文档化抽象类:
- 清晰记录抽象类的目的和使用方法
- 说明纯虚函数的预期行为
测试抽象类:
- 通过测试具体派生类来间接测试抽象类
- 确保抽象类的设计是合理的
抽象类与现代C++特性
抽象类与概念(C++20):
- 概念可以用于约束模板参数,类似于接口
- 概念是编译时的,而抽象类是运行时的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 概念:可绘制的
template <typename T>
concept Drawable = requires(const T& t) {
{ t.draw() } -> std::same_as<void>;
};
// 概念:可调整大小的
template <typename T>
concept Resizable = requires(T& t) {
{ t.resize(double{}) } -> std::same_as<void>;
};
// 使用概念约束模板参数
template <Drawable D>
void drawObject(const D& obj) {
obj.draw();
}
template <Resizable R>
void resizeObject(R& obj, double factor) {
obj.resize(factor);
}抽象类与lambda表达式:
- lambda表达式可以用于实现简单的接口
- 结合std::function使用,更加灵活
1
2
3
4
5
6
7
8
9// 函数对象接口
using DrawFunction = std::function<void()>;
// 使用lambda实现接口
DrawFunction createDrawFunction(const std::string& shape) {
return [shape]() {
std::cout << "Drawing a " << shape << std::endl;
};
}抽象类与智能指针:
- 使用std::unique_ptr和std::shared_ptr管理抽象类的实例
- 避免内存泄漏和悬垂指针
1
2
3
4
5
6
7
8
9
10// 创建抽象类的实例
std::unique_ptr<Shape> createShape(const std::string& type) {
if (type == "circle") {
return std::make_unique<Circle>(0, 0, 5);
} else if (type == "rectangle") {
return std::make_unique<Rectangle>(0, 0, 4, 6);
} else {
throw std::invalid_argument("Unknown shape type");
}
}
抽象类与设计模式
模板方法模式:
- 抽象类定义算法的骨架,将一些步骤延迟到派生类
- 例如:排序算法、生命周期管理
工厂方法模式:
- 抽象类提供创建对象的接口,让子类决定实例化哪个类
策略模式:
- 定义一系列算法,将它们封装起来,并使它们可互换
观察者模式:
- 定义对象间的一种一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都得到通知并被自动更新
命令模式:
- 将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化
小结
抽象类和接口是面向对象编程的重要概念,它们通过定义共同的接口和部分实现,提高了代码的可维护性和可扩展性。
抽象类:
- 包含至少一个纯虚函数
- 可以提供部分实现
- 不能直接实例化
- 用于定义一组相关类的共同行为
接口:
- 只包含纯虚函数
- 不提供任何实现
- 不能直接实例化
- 用于定义纯粹的接口,不涉及实现细节
在C++中,接口是通过特殊的抽象类实现的,即只包含纯虚函数的抽象类。合理使用抽象类和接口,可以编写更加灵活、可扩展和可维护的代码。
面向对象编程的设计原则
1. 单一职责原则(Single Responsibility Principle)
一个类应该只有一个引起它变化的原因,即一个类只负责一项功能。
2. 开放-封闭原则(Open-Closed Principle)
软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。
3. 里氏替换原则(Liskov Substitution Principle)
子类应该能够替换其父类,并且程序的行为不会改变。
4. 接口隔离原则(Interface Segregation Principle)
客户端不应该依赖它不使用的接口,应该将庞大的接口拆分为更小的、更具体的接口。
5. 依赖倒置原则(Dependency Inversion Principle)
高层模块不应该依赖低层模块,两者都应该依赖于抽象;抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
6. 组合优于继承原则(Composition Over Inheritance)
优先使用对象组合而不是类继承来实现代码重用,这样可以降低类之间的耦合度,提高代码的灵活性。
1 | // 继承方式 |
面向对象编程的优点
- 代码重用:通过继承和组合实现代码重用
- 模块化:将代码组织为独立的对象,便于管理和维护
- 可维护性:封装使得修改内部实现不影响外部代码
- 可扩展性:通过继承和多态易于扩展现有代码
- 可理解性:代码结构更符合现实世界的模型,易于理解
- 安全性:封装保护数据,减少错误
面向对象编程的缺点
- 复杂性:相比过程式编程,OOP的概念和实现更复杂
- 性能开销:继承和多态可能带来一定的性能开销
- 过度设计:可能导致过度设计,增加不必要的复杂性
- 学习曲线:学习OOP的概念和设计模式需要一定的时间
C++20新特性:概念(Concepts)
C++20引入了概念(Concepts),用于约束模板参数,提高模板代码的可读性和错误信息的清晰度:
概念的基本概念
- 概念:对类型的约束,指定类型必须满足的条件
- 约束:使用概念来限制模板参数的类型
- requires表达式:定义概念的具体约束条件
概念的使用示例
1 |
|
概念的优点
- 类型安全:在编译时检查类型约束,避免运行时错误
- 错误信息清晰:当类型不满足约束时,提供更清晰的错误信息
- 代码可读性:通过概念名称明确表达模板参数的要求
- 代码重用:概念可以被多个模板使用,提高代码重用性
- 灵活性:概念可以组合使用,创建更复杂的类型约束
面向对象编程与其他编程范式的比较
与过程式编程的比较
- 过程式编程:关注过程和函数,将程序分解为一系列函数调用
- 面向对象编程:关注对象和交互,将程序分解为一系列对象的交互
与函数式编程的比较
- 函数式编程:关注纯函数,避免状态和副作用
- 面向对象编程:关注对象的状态和行为,允许状态变化
与泛型编程的比较
- 泛型编程:关注类型参数化,实现代码重用
- 面向对象编程:关注对象和继承,实现代码重用
小结
本章介绍了面向对象编程的基本概念,包括:
- 面向对象编程的核心概念:对象、类、封装、继承、多态
- 类和对象:类的定义、对象的创建和使用
- 封装:数据隐藏、访问控制
- 继承:基类和派生类、继承的语法和优点
- 多态:编译时多态和运行时多态、虚函数
- 抽象类和接口:纯虚函数、抽象类的使用
- 面向对象编程的设计原则:单一职责、开放-封闭、里氏替换、接口隔离、依赖倒置
- 面向对象编程的优缺点:代码重用、模块化、可维护性等优点,以及复杂性、性能开销等缺点
- 与其他编程范式的比较:与过程式编程、函数式编程、泛型编程的比较
面向对象编程是现代编程语言的重要特性,它提供了一种更符合人类思维方式的编程方法,使得代码更易于理解、维护和扩展。掌握面向对象编程的概念和技巧,对于编写高质量的C++程序至关重要。
在后续章节中,我们将更深入地学习C++的面向对象特性,包括类的设计、构造函数和析构函数、运算符重载、模板等内容。



