封装、继承与多态究极详解

简介: 本文详细介绍了面向对象编程中的三大核心特性:封装、继承和多态。封装通过隐藏数据和提供接口,确保对象的安全性和一致性;继承通过类之间的“is-a”关系实现代码复用和扩展;多态则允许不同类的对象通过相同的接口执行不同的操作,增强程序的灵活性和可扩展性。文章还探讨了这些特性的底层实现机制,如虚函数表和内存布局,并提供了具体的代码示例。

个人理解

封装是类自带的固有属性,就像一个盒子天然就可以分装东西

继承是类与类之间的一种关系表现,我们知道除了继承,类之间的关系还可以有关联、依赖、实现、聚合、组合,为什么只强调继承?私以为实现是继承的特例,而其他四种关系都属于将类放在不同位置的灵活使用,且C中的结构体本身也具有这些特性,它并不是C++新创造出来的,但继承不一样,继承是新的需要提前约定的规则。

继承之后资源分配的规则就是多态

类的固有属性(封装),与它类新关系(继承)以及继承衍生出来的资源分配规则(多态)就是c++之于c多出来的可以从面向过程转为面向对象的内容。

其他的像模版方法、

前言

这个系列我们将就封装、继承、多态概念来展开,尽可能详尽且底层的将他们的原理性的东西展示出来! 话题内容包括但不限于:

  • 封装只有类能做吗?结构体如何封装?名空间能实现封装吗?
  • 封装有哪些好处?
  • 继承的特殊情况说明,比如多继承带来的菱形继承问题……
  • 继承时如何合理细分类的职责?
  • 多态的具体规则,引入指针之后的资源分配本质……
  • 多态的虚函数表和虚函数指针具体是什么?创建时机是什么?
  • 多态的静态绑定和动态绑定是什么?有什么区别?
  • 继承一定好吗?组合优于继承这句话的依据是什么?什么条件下适用?

封装是什么?

面向对象(OOP)与面向过程编程(POP)相比,封装是其中的一个核心特性。封装不仅仅是将数据和行为捆绑在一起,更是通过隐藏实现细节、限制对数据的直接访问来提供一个更安全、易管理的代码结构。为了理解封装,我们需要逐步深入到它的本质,并在代码层面和理论上解释它。

1. 面向过程编程(POP) vs 面向对象编程(OOP)

  • 面向过程编程(Procedural-Oriented Programming,POP):是一种依赖于函数调用和过程的编程范式。在POP中,程序通过执行一系列步骤(函数调用)来达到目标。数据和操作这些数据的功能是分开的。程序的核心是通过操作全局数据来进行的。
  • 面向对象编程(Object-Oriented Programming,OOP):将数据和操作这些数据的功能封装在一起,构成一个“对象”。面向对象的程序是由对象组成的,这些对象通过消息(方法调用)与其他对象交互。

在OOP中,封装是将数据和方法绑定到一个对象中,并通过控制数据的访问来保证对象内部的一致性和安全性。

2. 封装的核心概念

封装的基本思想是隐藏内部实现细节暴露必要的接口。封装有两个主要方面:

  • 数据隐藏:只允许通过公开的接口(方法)访问和修改数据。这样可以避免外部代码直接修改对象的内部状态,减少错误的发生。
  • 接口与实现分离:对象暴露的是一组操作数据的接口,而不是数据本身。外部只关心如何使用这个对象提供的功能,而不需要了解它的内部实现。

3. 如何实现封装

在C++中,封装是通过和访问修饰符(如publicprivateprotected)来实现的。

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 方法:提供了一个公有的方法来获取余额,确保外部代码不能直接修改余额,但可以查询。

为什么使用封装?

  1. 数据保护:封装隐藏了数据的实现,外部无法直接改变对象的内部状态,防止了误操作或非法操作。
  2. 提高代码可维护性:通过暴露清晰的接口和隐藏复杂的内部实现,程序更加模块化。如果需要改变实现细节,只需要修改类的内部代码,不会影响到其他依赖这个类的代码。
  3. 提高安全性:封装可以确保对象的一致性和有效性。比如,withdraw方法中检查提款金额是否合理,确保余额不被非法提取。

4. 封装的底层实现

从底层的角度看,封装的实现通常依赖于内存布局和访问控制机制。在C++中,类的成员变量通常会在对象实例化时分配内存。通过访问控制(privatepublic)和getset方法,编译器帮助开发者实现了对数据访问的精细控制。

  • 内存分配:每个对象都有独立的内存区域来存储成员变量。当对象被创建时,内存会分配给它的所有成员变量。privatepublic 只是影响这些成员在外部代码中的访问方式,实际的内存布局不会变化。
  • 访问控制privatepublicprotected 是由编译器支持的访问权限控制机制,确保类的私有数据只能通过特定的公有方法来修改。编译器会在编译时检查是否有非法访问的代码,防止程序出现不可预期的行为。

5. 总结

封装是面向对象编程的基础,它通过将数据和行为捆绑在一起,并限制外部对数据的访问,来保护对象的内部状态,提供更安全、灵活和易维护的代码结构。通过控制数据的访问和修改,我们能够保证数据的完整性和一致性,同时也能隐藏复杂的实现细节,简化外部接口的使用。

继承是什么

面向对象编程中的继承(Inheritance)是一个非常重要的概念,它允许一个类(子类)继承另一个类(父类)的方法和属性,从而避免代码重复,提高代码的复用性。继承是OOP的三大特性之一,另外两个特性是封装和多态。

1. 继承的基本概念

继承是一种“is-a”(是一个)关系。例如,假设你有一个基类Animal,然后你创建一个类Dog继承自Animal,那么Dog就可以看作是Animal的一个特例,继承了Animal的一些属性和方法。

  • 父类(基类):提供共通的属性和方法。
  • 子类:从父类继承属性和方法,子类可以添加新的属性和方法,或重写(覆盖)父类的方法。

2. 继承的主要作用

  • 代码复用:子类无需重新定义父类已经实现的方法和属性,可以直接使用它们。
  • 扩展性:子类可以在继承的基础上扩展功能,添加特有的行为。
  • 层次化设计:继承允许程序员通过类层次结构来组织和简化代码。例如,DogCat都可以继承自Animal,然后你可以根据需要为DogCat添加各自的特殊行为。

3. 如何实现继承

在C++中,继承通过classpublicprotectedprivate修饰符来实现。

  • 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;
}

代码解释:

  1. 基类 Animal:定义了两个方法,speakmove,表示动物的行为。
  2. 派生类 Dog:继承自 Animal,除了继承 Animalspeakmove 方法外,Dog 还定义了一个新的方法 bark,表示狗的行为。
  3. 方法重写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),即子类可以重写(覆盖)父类的方法。

多态的两种类型

  1. 编译时多态(静态多态):在编译时决定调用哪个函数,常见的实现方式是方法重载(Overloading)和运算符重载(Operator Overloading)。
  2. 运行时多态(动态多态):在程序运行时决定调用哪个函数,常通过虚函数和继承实现。

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 类中被声明为虚函数,并在 DogCat 类中重写了该函数。
  • 父类指针animal1animal2 是指向 Animal 类型的指针,但它们分别指向 DogCat 类型的对象。
  • 运行时多态:当通过父类指针调用 speak 方法时,C++ 会根据指针实际指向的对象类型来决定调用哪个函数(即 Dog 类的 speakCat 类的 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 函数,但它们的参数类型不同(intdoubleconst 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)。每个包含虚函数的类,在编译时会生成一个虚函数表,其中存储着类的所有虚函数指针。当通过父类指针调用虚函数时,程序会查找虚函数表,找到对应的子类实现并调用。

虚函数表的工作原理

  1. 每个类有一个虚函数表,表中存储该类的虚函数的地址。
  2. 当创建一个对象时,虚函数表会绑定到该对象中。
  3. 当调用虚函数时,程序会通过对象的虚函数表找到对应的函数地址,进而实现多态。

5. 总结

多态是面向对象编程的核心特性之一,它通过相同的接口执行不同的实现。多态主要分为两种类型:

  • 编译时多态:通过方法重载和运算符重载等手段实现。
  • 运行时多态:通过虚函数和继承实现,通常通过基类指针或引用调用派生类的重写方法。

多态使得代码更加灵活和可扩展,有助于构建更易于维护和扩展的程序架构。

目录
相关文章
|
3月前
|
Java 编译器
封装,继承,多态【Java面向对象知识回顾①】
本文回顾了Java面向对象编程的三大特性:封装、继承和多态。封装通过将数据和方法结合在类中并隐藏实现细节来保护对象状态,继承允许新类扩展现有类的功能,而多态则允许对象在不同情况下表现出不同的行为,这些特性共同提高了代码的复用性、扩展性和灵活性。
封装,继承,多态【Java面向对象知识回顾①】
|
7月前
|
Java 程序员 编译器
Java面向对象之——继承
Java面向对象之——继承
|
Java
Java面向对象之继承
Java面向对象之继承
67 0
|
设计模式
26【软件基础】简单计算器的实现+工厂方法模式应用+封装、继承、多态的体现
工厂方法模式是一种常用的`创建型设计模式`,它提供了一种将对象的创建过程封装起来的方法。在工厂方法模式中,将对象的创建过程交给一个`工厂类`来完成,而不是在代码中直接调用构造函数来创建对象。这样可以使得代码更加灵活,`降低耦合度`,方便后期维护和扩展。
169 0
|
Java
Java面向对象继承
继承的概念 继承是面向对象编程中的一个概念,它允许一个类(称为子类或派生类)继承另一个类(称为父类或基类)的属性和方法。子类在继承父类的同时也可以添加自己的属性和方法,从而实现代码的复用和扩展。继承是面向对象编程的三大特征之一,其他两个分别是封装和多态。 继承的作用 1.提高代码的复用性:通过继承,子类可以直接使用父类的属性和方法,不需要重复定义。 2.便于代码的扩展:通过在子类中添加新的属性和方法,实现了代码的扩展。 3.提高代码的可维护性:将公共的属性和方法放在父类中,可以减少重复的代码,从而方便代码维护和升级。
76 0
|
设计模式 程序员 编译器
【大话设计模式】封装 继承 多态
【大话设计模式】封装 继承 多态
|
Java
【Java面向对象】继承的认识与实现(1)
【Java面向对象】继承的认识与实现(1)
89 0
重写前奏~继承和多态
上次说到的重载是指同一类中方法和方法之间的关系,这次向大家介绍的重写是子不同类中方法和方法的关系;在介绍重写之前,要先说说继承和多态;
|
Java
Java面向对象—继承
Java面向对象—继承
85 0
|
Java
Java面向对象 继承
Java面向对象 继承
86 0

热门文章

最新文章