更多优质内容
请关注公众号

面向对象和设计模式(二) 面向对象四大特性之封装性/抽象性/继承性/多态性 和 类与类之间6种交互关系-张柏沛IT博客

正文内容

面向对象和设计模式(二) 面向对象四大特性之封装性/抽象性/继承性/多态性 和 类与类之间6种交互关系

栏目:其他内容 系列:面向对象与设计模式 发布时间:2022-12-31 11:55 浏览量:1125
本系列文章目录
展开/收起

一、面向对象的四大特性:封装,抽象,继承,多态

几年前在我还年轻的时候,我听我的一个亦师亦友的暖男同事说过一句话:“你以为你在面向对象编程,其实你只是在用类写面向过程的代码”。

天真的我以为只要实现一个类,使用类和对象的方法和属性就是在面向对象编程了,实际上很多新手会忽略本质,面向对象的本质就是4点:封装,抽象,继承,多态,我们之所以使用类和对象就是为了通过这4点特性使我们的代码达到高度的可扩展性、可读性、可复用性和可维护性。

所以一句话,当我们的代码做到“封装,抽象,继承,多态”才能说我们是在面向对象编程(即使你没有用到类和对象),反之如果没有做到这4点,即使我们用到了类和对象,也只是在面向过程编程。

尽管99%的程序员都知道面向对象的四个特性,但是我还是希望用最直白的话帮大家回顾一下它们的内容、存在的意义和能解决什么问题。

 

1、封装性

封装性本质上是信息隐藏或者数据访问保护

具体体现在通过控制访问权限可以隐藏用户不需要知道也不需要使用的方法和属性(私有/公有的方法或属性);

封装性的意义在于,如果我们对类中属性的访问不做限制,那么:

a. 属性可以随意被以各种奇葩或者难看的方式修改(例如在外部做出$obj->name="xxx"这样的修改),而且修改逻辑可能散落在代码中的各个角落,影响代码的可读性、可维护性。

b. 将一些调用者无需了解的内部方法/属性暴露出去,降低了类的易用性。反过来说,如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解,否则一不小心用错了会造成不可预知的后果。

 

举个例子:

<?php
class Wallet {  // 用类实现一个电子钱包
  private $id;
  private $createTime;
  private $balance;
  private $balanceLastModifiedTime;
  // ...省略其他属性...


  public function __construct(){
     $this->id = uniqid();
     $this->createTime = time();
     $this->balance = 0;
     $this->balanceLastModifiedTime = 0;
  }

  public function getId() { return $this->id; }
  public function getCreateTime() { return $this->createTime; }
  public function getBalance() { return $this->balance; }
  public function getBalanceLastModifiedTime() { return $this->balanceLastModifiedTime;  }


  public function increaseBalance($increasedAmount){
    $this->balance += $increasedAmount;
    $this->balanceLastModifiedTime = time();
  }


  public function decreaseBalance($decreasedAmount) {
    $this->balance -= $decreasedAmount;
    $this->balanceLastModifiedTime = time();
  }
}

这个例子中,类的四个属性全都设置为私有属性,只能访问(通过方法访问)不能修改。这样做原因是如下:钱包的 id和创建时间是固定的,一旦生成就不能改变且这两个属性的初始化设置,对于调用者来说也应该是透明的。

余额 balance 属性,从业务的角度来说只能增或者减,不会被重新设置。所以只暴露了 increaseBalance() 和 decreaseBalance() 方法,并没有暴露 set 方法。

balanceLastModifiedTime 属性是跟 balance 属性的修改操作绑定在一起的。只有在 balance 修改的时候,这个属性才会被修改。所以把 balanceLastModifiedTime 封装在了 increaseBalance() 和 decreaseBalance() 内部,不对外暴露任何修改这个属性的方法,可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性。比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了 wallet 中的balanceLastModifiedTime 属性,这就会导致 balance 和 balanceLastModifiedTime 两个数据不一致。

 

2、抽象性

抽象性本质上指隐藏方法的具体实现,让调用者只需关心方法提供了什么功能,不需要知道这些功能是怎么实现。

听起来好像和封装性没什么区别,但细细一品还是有差异的,封装性是“不让调用者乱用类中有风险的属性和方法”,是一种限制。抽象性是一种简化和傻瓜化,调用者不用细读方法内容就知道这个方法是用来干什么的。

我们常借助编程语言提供的接口类或者抽象类(比如PHP或者Java中的interface 和 abstract关键字),并让一个或多个类实现接口类 或 继承抽象类。

例如:

<?php
Interface IPictureStorage {
    public function savePicture($picture);
    public function  getPicture($pictureId);
    public function deletePicture($pictureId);
    public function modifyMetaInfo($pictureId, $metaInfo);
}
  
class PictureStorage implements IPictureStorage {
    // ...省略其他属性...
    
    public function savePicture($picture) { ... }
    
    public function getPicture($pictureId) { ... }
    
    public function deletePicture($pictureId) { ... }
    
    public function modifyMetaInfo($pictureId, $metaInfo) { ... }
}

调用者在使用 PictureStorage 这个图片存储类的时候,只需要看看 IPictureStorage 接口类暴露了哪些方法就可以大概了解PictureStorage类有些什么功能,无需去查看 PictureStorage 类里的具体实现逻辑。

实际上这个特性也可以不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性,所以有时候它不被看做是面向对象编程的特性之一。

抽象性的意义在于对代码傻瓜化,提高可读性的同时降低调用者对类的了解成本。

做好抽象性的关键在于要对接口类或抽象类的方法进行抽象命名而非具象命名,不能在方法名的定义上暴露太多实现的信息。

例如,一个图片存储接口类中定义一个getAliyunPictureUrl 方法(获取阿里云图片方法)就没有体现抽象性从而带来潜在问题。因为以后如果我们不仅要把图片存储在阿里云上,还要存储在七牛云上,那这个命名在七牛云的实现类中就不合适了,这不利于我们扩展新的存储方式。如果我们定义成 getPictureUrl(),那即便要扩展一个七牛云的存储类也不需要修改方法命名了。

你看,这么一来,抽象性是不是也提高了可扩展性呢!

 

3、继承性

继承性本质上是两点,一是复用代码,二是反映对象之间的is-a关联关系(is-a关系说白了是指 “某个对象是不是某个类实例化出来的",“A是不是一个B”)。

很多人可能只能第一时间想到第一点,而忽略掉第二点。实际上第二点更重要,因为通过继承来关联两个类,反应真实世界中的对象与对象的关系,非常符合人类的认知,提升了代码的可读性。复用代码不只是继承性能做到,函数也能做到,但反映对象的关联关系的只有继承性能做到。

同时继承性也是多态性的基础,我们可以继承父类的A方法,在子类的A方法中调用父类A方法,并在此基础上扩展子类自己的A方法的功能。然后另一个子类的A方法扩展另一种功能。

 

继承性的缺点有2点:

1. 继承层次过深会降低可读性。为了了解一个类的功能,我们可能要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。

2. 子类和父类高度耦合,修改父类的代码,会直接影响到子类,降低了可扩展性。

 

我在实际开发中遇到过这样一个问题:一个父类的方法A接收3个参数,这个父类下有9个子类,这9个子类的A方法中先调用了父类的A方法,然后再实现自己的逻辑。之后有一天,父类的A方法要多支持一个必传参数,这么一来,就导致下面的子类的A方法都要加上这个参数,扩展起来极为恶心。当然,这个问题可以这样优化,子类不实现方法A,而是实现方法B,并在父类的方法A中调用子类的方法B。但是这还是不能解决父子耦合的问题,例如,9个子类中有3个只用到父类A方法公共逻辑的一部分而非全部公共逻辑,其他6个子类用到父类A方法公共逻辑的另一部分,下一次迭代的时候新增第10个子类,用到的又是全部公共逻辑。

 

对于这样的耦合问题,我们可以通过“多用组合少用继承”来解决。

 

4、多态性

多态性本质上是子类方法可以重新实现,一个父类下多个子类相同命名的方法其功能百花齐放的状况。

只要两个类有相同名字的方法,我们就可以说这两个类实现了多态。

我们可以通过 “普通类+继承”、“接口类+实现” 以及 “鸭子类型(duck-type)” 这三种方式做到多态性。

 

其中最灵活的就是鸭子类型,什么叫鸭子类型?它的解释很有意思:“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”

在鸭子类型中,关注点在于对象对另一个对象有什么相同行为,都能做什么,都有什么相同的功能和方法,是一种 has-a 关系,而不关注对象与对象之间的所属关系,即它不是一种is-a关系。

换句话说,如果某一种 鸭子类型X 要求拥有方法A和方法B,而类M和类N都有方法A和方法B,那么我们可以说M和N都是相同的鸭子类型 X。而不要求M和N之间是父子继承关系或者是继承中的兄弟关系

 

鸭子类型的行为和作用和接口类一毛一样,只是因为一些语言不支持接口类,所以才衍生出了鸭子类型这种方式,而且接口类是强约束,要求实现它的类必须实现它的所有方法,否则会报错。鸭子类型就没有这个问题,它更灵活而且属于一种非强制实现,即使少实现了一个方法,也不会报错,顶多这个实现类不再是这种鸭子类型而已。

 

下面我们回到多态性,看一个python的例子:

class Logger:
    def record(self):
        print(“I write a log into file.”)
        
class DB:
    def record(self):
        print(“I insert data into db. ”)
        
def test(recorder):
    recorder.record()

def demo():
    logger = Logger()
    db = DB()
    test(logger)
    test(db)

这段代码就是用 duck-typing 实现多态。Logger 和 DB 两个类没有任何关系(这里是指父子或继承中的兄弟关系),也不是接口和实现的关系,但是只要它们都有定义了 record() 方法,就可以被传递到 test() 方法中执行对应的 record() 方法。

只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing。

多态性的意义在于提高代码的可扩展性,也一定程度提高了复用性,我们无需写一堆的if/else扩展不同情况下所需的新功能(因为这种扩展方式很low,不易扩展),而是通过新建一个类,在已知命名的方法中实现新的功能即可。

 

二、类与类之间的交互关系

UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。

泛化可以简单理解为继承关系。

public class A { ... }
public class B extends A { ... }

 

聚合组合是类调用另一个类的包含关系。

A 类对象包含 B 类对象,如果B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象则为聚合关系

这要求:B对象不在A对象内实例化,并且作为A对象的属性,然后A对象可以调用B对象的方法。

public class A {
  private B b;
  public A(B b) {
    this.b = b;
  }
}

 

如果B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在则为组合关系。销毁 A 类对象后 B 对象也随之销毁。

这要求:B对象在A对象内实例化,并且将B对象作为A对象的属性,然后A对象调用B对象的方法。

class A {
  private B b;
  public A() {
    this.b = new B();
  }
}

 

关联是一种非常弱的关系,包含聚合、组合两种关系。

public class A {
  private B b;
  public A(B b) {
    this.b = b;
  }
}
或者
public class A {
  private B b;
  public A() {
    this.b = new B();
  }
}

 

依赖是包含关联关系的。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。

public class A {
  private B b;
  public A(B b) {
    this.b = b;
  }
}
或者
public class A {
  private B b;
  public A() {
    this.b = new B();
  }
}
或者
public class A {
  public void func(B b) { ... }
}

 

这里作者将6中类与类间的关系简化为了四种:泛化、实现、组合、依赖

只要 B 类对象是 A 类对象的成员变量,那 A 类跟 B 类就是组合关系。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > 面向对象和设计模式(二) 面向对象四大特性之封装性/抽象性/继承性/多态性 和 类与类之间6种交互关系

热门推荐
推荐新闻