个人理解
封装是类自带的固有属性,就像一个盒子天然就可以分装东西
继承是类与类之间的一种关系表现,我们知道除了继承,类之间的关系还可以有关联、依赖、实现、聚合、组合,为什么只强调继承?私以为实现是继承的特例,而其他四种关系都属于将类放在不同位置的灵活使用,且C中的结构体本身也具有这些特性,它并不是C++新创造出来的,但继承不一样,继承是新的需要提前约定的规则。
继承之后资源分配的规则就是多态
类的固有属性(封装),与它类新关系(继承)以及继承衍生出来的资源分配规则(多态)就是c++之于c多出来的可以从面向过程转为面向对象的内容。
其他的像模版方法、
前言
这个系列我们将就封装、继承、多态概念来展开,尽可能详尽且底层的将他们的原理性的东西展示出来! 话题内容包括但不限于:
- 封装只有类能做吗?结构体如何封装?名空间能实现封装吗?
- 封装有哪些好处?
- 继承的特殊情况说明,比如多继承带来的菱形继承问题……
- 继承时如何合理细分类的职责?
- 多态的具体规则,引入指针之后的资源分配本质……
- 多态的虚函数表和虚函数指针具体是什么?创建时机是什么?
- 多态的静态绑定和动态绑定是什么?有什么区别?
- 继承一定好吗?组合优于继承这句话的依据是什么?什么条件下适用?
封装是什么?
面向对象(OOP)与面向过程编程(POP)相比,封装是其中的一个核心特性。封装不仅仅是将数据和行为捆绑在一起,更是通过隐藏实现细节、限制对数据的直接访问来提供一个更安全、易管理的代码结构。为了理解封装,我们需要逐步深入到它的本质,并在代码层面和理论上解释它。
1. 面向过程编程(POP) vs 面向对象编程(OOP)
- 面向过程编程(Procedural-Oriented Programming,POP):是一种依赖于函数调用和过程的编程范式。在POP中,程序通过执行一系列步骤(函数调用)来达到目标。数据和操作这些数据的功能是分开的。程序的核心是通过操作全局数据来进行的。
- 面向对象编程(Object-Oriented Programming,OOP):将数据和操作这些数据的功能封装在一起,构成一个“对象”。面向对象的程序是由对象组成的,这些对象通过消息(方法调用)与其他对象交互。
在OOP中,封装是将数据和方法绑定到一个对象中,并通过控制数据的访问来保证对象内部的一致性和安全性。
2. 封装的核心概念
封装的基本思想是隐藏内部实现细节,暴露必要的接口。封装有两个主要方面:
- 数据隐藏:只允许通过公开的接口(方法)访问和修改数据。这样可以避免外部代码直接修改对象的内部状态,减少错误的发生。
- 接口与实现分离:对象暴露的是一组操作数据的接口,而不是数据本身。外部只关心如何使用这个对象提供的功能,而不需要了解它的内部实现。
3. 如何实现封装
在C++中,封装是通过类
和访问修饰符(如public
、private
、protected
)来实现的。
3.1. 类与对象
- 类:是一个模板或蓝图,它定义了数据和方法。数据通常称为“成员变量”,方法称为“成员函数”。
- 对象:类的实例。每个对象有自己的数据,并可以使用类中的方法。
3.2. 访问修饰符
- public:类的公共部分,外部可以访问和修改。
- private:类的私有部分,外部无法直接访问,只能通过类提供的公有方法来间接访问。
- protected:类似于private,但允许派生类(子类)访问。
3.3. 封装的实现示例
下面是一个简单的C++示例,展示了如何通过封装保护数据和提供接口。
#include <iostream>
using namespace std;
class BankAccount {
private:
double balance; // 余额是私有的,外部不能直接访问
public:
// 构造函数,初始化账户余额
BankAccount(double initialBalance) {
if (initialBalance < 0) {
balance = 0;
} else {
balance = initialBalance;
}
}
// 提供一个公有方法来访问余额
double getBalance() const {
return balance;
}
// 提供一个公有方法来修改余额
void deposit(double amount) {
if (amount > 0) {
balance += amount;
} else {
cout << "Deposit amount must be positive." << endl;
}
}
void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
} else {
cout << "Invalid withdrawal amount." << endl;
}
}
};
int main() {
BankAccount account(1000); // 创建一个初始余额为1000的账户
cout << "Initial balance: $" << account.getBalance() << endl;
account.deposit(500); // 存款500
cout << "After deposit: $" << account.getBalance() << endl;
account.withdraw(200); // 提款200
cout << "After withdrawal: $" << account.getBalance() << endl;
// 尝试直接访问balance(会出错)
// cout << "Direct balance access: $" << account.balance << endl; // 编译错误
return 0;
}
代码解释:
- BankAccount 类:这个类包含了一个私有成员变量
balance
,它存储账户余额。外部代码无法直接访问或修改这个余额。 - deposit 和 withdraw 方法:这些是公有的接口方法,允许外部代码在合法的条件下(如存款金额为正,提款金额不超过余额)修改账户余额。
- getBalance 方法:提供了一个公有的方法来获取余额,确保外部代码不能直接修改余额,但可以查询。
为什么使用封装?
- 数据保护:封装隐藏了数据的实现,外部无法直接改变对象的内部状态,防止了误操作或非法操作。
- 提高代码可维护性:通过暴露清晰的接口和隐藏复杂的内部实现,程序更加模块化。如果需要改变实现细节,只需要修改类的内部代码,不会影响到其他依赖这个类的代码。
- 提高安全性:封装可以确保对象的一致性和有效性。比如,
withdraw
方法中检查提款金额是否合理,确保余额不被非法提取。
4. 封装的底层实现
从底层的角度看,封装的实现通常依赖于内存布局和访问控制机制。在C++中,类的成员变量通常会在对象实例化时分配内存。通过访问控制(private
、public
)和get
、set
方法,编译器帮助开发者实现了对数据访问的精细控制。
- 内存分配:每个对象都有独立的内存区域来存储成员变量。当对象被创建时,内存会分配给它的所有成员变量。
private
和public
只是影响这些成员在外部代码中的访问方式,实际的内存布局不会变化。 - 访问控制:
private
、public
和protected
是由编译器支持的访问权限控制机制,确保类的私有数据只能通过特定的公有方法来修改。编译器会在编译时检查是否有非法访问的代码,防止程序出现不可预期的行为。
5. 总结
封装是面向对象编程的基础,它通过将数据和行为捆绑在一起,并限制外部对数据的访问,来保护对象的内部状态,提供更安全、灵活和易维护的代码结构。通过控制数据的访问和修改,我们能够保证数据的完整性和一致性,同时也能隐藏复杂的实现细节,简化外部接口的使用。
继承是什么
面向对象编程中的继承(Inheritance)是一个非常重要的概念,它允许一个类(子类)继承另一个类(父类)的方法和属性,从而避免代码重复,提高代码的复用性。继承是OOP的三大特性之一,另外两个特性是封装和多态。
1. 继承的基本概念
继承是一种“is-a”(是一个)关系。例如,假设你有一个基类Animal
,然后你创建一个类Dog
继承自Animal
,那么Dog
就可以看作是Animal
的一个特例,继承了Animal
的一些属性和方法。
- 父类(基类):提供共通的属性和方法。
- 子类:从父类继承属性和方法,子类可以添加新的属性和方法,或重写(覆盖)父类的方法。
2. 继承的主要作用
- 代码复用:子类无需重新定义父类已经实现的方法和属性,可以直接使用它们。
- 扩展性:子类可以在继承的基础上扩展功能,添加特有的行为。
- 层次化设计:继承允许程序员通过类层次结构来组织和简化代码。例如,
Dog
和Cat
都可以继承自Animal
,然后你可以根据需要为Dog
和Cat
添加各自的特殊行为。
3. 如何实现继承
在C++中,继承通过class
和public
、protected
、private
修饰符来实现。
- public继承:子类继承父类的公有成员和保护成员,父类的公有方法和属性在子类中保持可访问。
- protected继承:子类继承父类的公有成员和保护成员,但父类的公有方法和属性在子类中变为受保护。
- private继承:子类继承父类的公有成员和保护成员,但父类的公有方法和属性在子类中变为私有。
示例代码:
#include <iostream>
using namespace std;
// 基类(父类)
class Animal {
public:
void speak() {
cout << "Animal speaks!" << endl;
}
void move() {
cout << "Animal moves!" << endl;
}
};
// 派生类(子类)
class Dog : public Animal {
public:
void bark() {
cout << "Dog barks!" << endl;
}
// 重写父类方法
void speak() {
cout << "Dog barks loudly!" << endl;
}
};
int main() {
Animal animal;
animal.speak(); // 调用基类方法
animal.move(); // 调用基类方法
Dog dog;
dog.speak(); // 调用子类重写的方法
dog.move(); // 调用继承的父类方法
dog.bark(); // 调用子类自己的方法
return 0;
}
代码解释:
- 基类 Animal:定义了两个方法,
speak
和move
,表示动物的行为。 - 派生类 Dog:继承自
Animal
,除了继承Animal
的speak
和move
方法外,Dog
还定义了一个新的方法bark
,表示狗的行为。 - 方法重写:
Dog
中重写了speak
方法,使得狗发出的声音与其他动物不同。
4. 继承的底层实现
在底层,继承通过对象布局和指针偏移来实现。每个对象都有一个虚函数表(vtable),用于支持多态(如果使用了虚函数)。当你创建一个子类对象时,它不仅包含自己的数据成员,还会包含父类的数据成员(如果父类有数据成员的话)。
内存布局:
- 对象的内存布局包含了父类部分和子类部分。父类的成员变量和成员函数会先存储在内存中,子类会在父类的基础上添加额外的成员。
- 如果有虚函数,编译器会为类创建一个虚函数表,虚函数表包含所有虚函数的指针,确保子类能够重写(覆盖)父类的虚函数。
示例内存布局:
假设有以下类继承关系:
A
是基类,B
是从A
继承的子类,C
是从B
继承的子类。
内存布局 | 说明 |
A 类的成员 |
基类 A 中的成员数据存储在内存中 |
B 类的成员 |
子类 B 扩展的成员数据存储在内存中 |
C 类的成员 |
子类 C 扩展的成员数据存储在内存中 |
5. 继承的类型
继承可以分为不同类型,常见的包括:
- 单继承:子类只继承一个父类。
- 多重继承:子类可以继承多个父类。
- 多级继承:子类继承自父类,孙类继承自子类等。
示例:多重继承
#include <iostream>
using namespace std;
// 基类1
class Animal {
public:
void move() {
cout << "Animal moves!" << endl;
}
};
// 基类2
class Mammal {
public:
void nurse() {
cout << "Mammal nurses!" << endl;
}
};
// 派生类
class Dog : public Animal, public Mammal {
public:
void bark() {
cout << "Dog barks!" << endl;
}
};
int main() {
Dog dog;
dog.move(); // 来自 Animal
dog.nurse(); // 来自 Mammal
dog.bark(); // 来自 Dog
return 0;
}
6. 继承的优缺点
优点:
- 代码重用:子类继承父类的行为,可以减少代码重复,提升代码复用性。
- 模块化设计:通过继承可以构建层次结构,使得代码更具组织性。
- 扩展性:子类可以继承父类的功能,并在此基础上扩展或重写,满足更多需求。
缺点:
- 紧密耦合:继承会导致类之间的紧密耦合,子类对父类的依赖较强,修改父类可能影响子类的行为。
- 继承层次复杂:多层继承可能导致类关系复杂,尤其是多重继承时,可能出现二义性(例如“菱形继承问题”)。
- 不利于灵活性:过度使用继承可能导致代码不易扩展或维护,过度继承会使类层次过于复杂。
7. 总结
继承是OOP的重要特性,能够通过建立类的层次关系实现代码重用和扩展。它允许子类继承父类的行为和属性,并且能够扩展或修改这些行为。理解继承如何在底层实现、如何利用它来构建高效的程序,是掌握OOP的关键。
多态是什么
多态(Polymorphism)是面向对象编程(OOP)中的一个核心概念,它允许不同类的对象通过相同的接口(方法名)来调用不同的实现。简单来说,多态使得不同类型的对象可以通过相同的接口执行不同的操作。多态性使得程序更加灵活和可扩展。
1. 多态的基本概念
多态来源于两个希腊词根:“poly”(多)和“morph”(形态)。在OOP中,多态指的是同一个操作作用于不同类型的对象时,可以有不同的表现形式。最常见的多态形式是方法重写(overriding),即子类可以重写(覆盖)父类的方法。
多态的两种类型:
- 编译时多态(静态多态):在编译时决定调用哪个函数,常见的实现方式是方法重载(Overloading)和运算符重载(Operator Overloading)。
- 运行时多态(动态多态):在程序运行时决定调用哪个函数,常通过虚函数和继承实现。
2. 运行时多态与虚函数
运行时多态通常通过虚函数来实现。虚函数是基类中声明为 virtual
的函数,子类可以重写这个函数。当通过基类指针或引用调用该函数时,程序会根据对象的实际类型(而不是指针或引用的类型)来决定调用哪个函数实现。
2.1. 虚函数的定义和用法
虚函数是在父类中声明的成员函数,并使用 virtual
关键字修饰,表示这个函数可以在子类中被重写。
2.2. 多态的实现方式
- 父类指针或引用指向子类对象:当父类指针或引用指向子类对象时,调用虚函数会动态绑定到子类的实现上,而不是父类的实现。这样就实现了多态。
示例代码:
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
// 虚函数
virtual void speak() {
cout << "Animal speaks!" << endl;
}
virtual ~Animal() {} // 虚析构函数,避免内存泄漏
};
// 派生类
class Dog : public Animal {
public:
void speak() override { // 重写父类的 speak 方法
cout << "Dog barks!" << endl;
}
};
class Cat : public Animal {
public:
void speak() override { // 重写父类的 speak 方法
cout << "Cat meows!" << endl;
}
};
int main() {
// 父类指针指向不同的子类对象
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
// 调用虚函数
animal1->speak(); // 输出: Dog barks!
animal2->speak(); // 输出: Cat meows!
delete animal1; // 释放内存
delete animal2; // 释放内存
return 0;
}
代码解析:
- **虚函数
speak
**:在Animal
类中被声明为虚函数,并在Dog
和Cat
类中重写了该函数。 - 父类指针:
animal1
和animal2
是指向Animal
类型的指针,但它们分别指向Dog
和Cat
类型的对象。 - 运行时多态:当通过父类指针调用
speak
方法时,C++ 会根据指针实际指向的对象类型来决定调用哪个函数(即Dog
类的speak
或Cat
类的speak
),这就是运行时多态。
3. 编译时多态与函数重载
编译时多态指的是在编译阶段就可以确定调用哪个函数。编译时多态通常通过函数重载和运算符重载实现。
3.1. 函数重载(Function Overloading)
在同一个类中,可以定义多个同名的函数,只要它们的参数类型或参数个数不同。编译器会根据函数调用时传递的参数来决定调用哪个版本的函数。
#include <iostream>
using namespace std;
class Printer {
public:
// 函数重载
void print(int i) {
cout << "Printing integer: " << i << endl;
}
void print(double d) {
cout << "Printing double: " << d << endl;
}
void print(const char* str) {
cout << "Printing string: " << str << endl;
}
};
int main() {
Printer printer;
printer.print(10); // 输出: Printing integer: 10
printer.print(3.14); // 输出: Printing double: 3.14
printer.print("Hello!"); // 输出: Printing string: Hello!
return 0;
}
代码解析:
- 函数重载:在
Printer
类中,定义了三个同名的print
函数,但它们的参数类型不同(int
、double
、const char*
)。 - 编译时多态:编译器根据传入的参数类型来决定调用哪个
print
函数,这就是编译时多态。
3.2. 运算符重载(Operator Overloading)
C++允许我们为自定义类型重载运算符,这也是一种编译时多态的表现。
#include <iostream>
using namespace std;
class Complex {
public:
int real;
int imag;
Complex(int r, int i) : real(r), imag(i) {}
// 运算符重载
Complex operator + (const Complex& other) {
return Complex(real + other.real, imag + other.imag);
}
void print() {
cout << real << " + " << imag << "i" << endl;
}
};
int main() {
Complex c1(1, 2), c2(3, 4);
Complex c3 = c1 + c2; // 使用重载的 + 运算符
c3.print(); // 输出: 4 + 6i
return 0;
}
代码解析:
- 运算符重载:我们重载了
+
运算符,使其可以对Complex
类型的对象进行加法操作。 - 编译时多态:当我们使用
c1 + c2
时,编译器会调用重载的operator +
函数来执行加法运算。
4. 多态的底层实现
多态的底层实现依赖于虚函数表(vtable)。每个包含虚函数的类,在编译时会生成一个虚函数表,其中存储着类的所有虚函数指针。当通过父类指针调用虚函数时,程序会查找虚函数表,找到对应的子类实现并调用。
虚函数表的工作原理:
- 每个类有一个虚函数表,表中存储该类的虚函数的地址。
- 当创建一个对象时,虚函数表会绑定到该对象中。
- 当调用虚函数时,程序会通过对象的虚函数表找到对应的函数地址,进而实现多态。
5. 总结
多态是面向对象编程的核心特性之一,它通过相同的接口执行不同的实现。多态主要分为两种类型:
- 编译时多态:通过方法重载和运算符重载等手段实现。
- 运行时多态:通过虚函数和继承实现,通常通过基类指针或引用调用派生类的重写方法。
多态使得代码更加灵活和可扩展,有助于构建更易于维护和扩展的程序架构。