设计原则

每一个行业,每一个人都有着自己的原则。面向对象也是一样,代码的复用性,扩展性,类的职责,高内聚,低耦合都是我们在开发过程中需要考虑的问题。优秀软件设计都会符合这些特征,在它们进行新的功能扩展时也会得心应手。
那么什么是优秀的软件设计? 如何对其进行评估? 你需要遵循哪些实践方式才能实现这样的方式? 如何让你的架构灵活、 稳定且易于理解?这些都是很好的问题。 但是, 我们实际开发中由于项目的不同, 这些问题的答案也不尽相同。 如果有几个通用的软件设计原则可能会对解决这些问题有很大的帮助。

基于接口而非实现编程

"基于接口而非实现编程"的英文是"Program to an interface,not an implementation"。这条原则最早出现于1994年的GoF的《设计模式》这本书。他先于很多编程语言的诞生,是一条比较抽象泛化的设计思想。
实际上,理解这条原则的关键,就是理解其中的"接口"二字。从本质上看,接口就是一组协议或者约定,是功能提供者提供给使用者的一个功能列表。接口在不同的应用场景有不同的解读。,比如服务端与客户端之间的接口,类库提供的接口。甚至是基于一组通信协议都可以叫做接口。落实到具体的编码中,"基于接口而非实现编程"这条原则的接口,可以理解为我们编程语言中的接口或者抽象类。
这条原则能够非常有效的提高代码质量,应用这条原则可以将接口和实现分离。封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节。当实现发生变化的时候,上游系统的代码基本上不用做出改动,以此来降低耦合性,提高扩展性。
实际上"基于接口而非实现编程"这条原则的另一个表述方式是"基于抽象而非实现编程"。后者的表述更能体现这条原则的设计初衷。在软件开发过程中,最大的挑战之一就是需求不断的变化,这也是考验代码设计的好坏标准之一。越抽象,越顶层,越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化,好的代码设计,不仅能应对当下需求,而且在将来需要发生变化时,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性,灵活性,可维护性最有效的手段之一。
示例:
假设你正在开发一款软件开发公司模拟器,而且使用了不同的类来代表各种类型的员工,代码如下:

<?php
class Designer{
    public function designArchitecture(){
        echo "设计师设计<br>";
    }
}

class Programmer{
    public function writercode(){
        echo "程序员编写代码<br>";
    }
}
class Tester{
    public function testSoftware(){
        echo "测试测试软件<br>";
    }
}

class Company{
    public function createSoftware(){
        $designer = new Designer();
        $designer->designArchitecture();
        $programmer = new Programmer();
        $programmer->writercode();
        $tester = new Tester();
        $tester->testSoftware();
    }
}

$company = new Company();
$company->createSoftware();

我们可以看到公司Company类与具体员工类紧密耦合,尽管实现不尽相同,但其实每个员工都与工作相关 ,于是我们可是使用多态机制通过Employee接口来处理各类员工对象。代码如下:

<?php
interface Employee{
    public function dowork();
}

class Designer implements Employee{
    public function dowork(){
        echo "设计师设计<br>";
    }
}

class Programmer implements Employee{
    public function dowork(){
        echo "程序员编写代码<br>";
    }
}

class Tester implements Employee{
    public function dowork(){
        echo "测试测试软件<br>";
    }
}

class Company{
    private $employees = [];
    public function createSoftware(){
       $this->employees =[
           new Designer(),
           new Programmer(),
           new Tester()
       ];
       foreach ( $this->employees as $key => $value) {
          $value->dowork();
       }
    }
}

$company = new Company();
$company->createSoftware();

多态机制虽然帮我们简化了代码,但公司类仍与具体的员工类相耦合,如果我们引入包含其他类型员工的公司类型的话,我们就需要重写绝大部分的公司类了,不能复用代码。
为了解决这个问题,我们可以声明一个抽象方法来获取员工,每个具体公司都将以不同的方式实现该方法,从而创建自己所需要的雇员
示例代码如下:

<?php
interface Employee{
    public function dowork();
}

class Designer implements Employee{
    public function dowork(){
        echo "设计师设计<br>";
    }
}

class Programmer implements Employee{
    public function dowork(){
        echo "程序员编写代码<br>";
    }
}

class Tester implements Employee{
    public function dowork(){
        echo "测试测试软件<br>";
    }
}
class Artist implements Employee{
    public function dowork(){
        echo "艺术家绘画<br>";
    }
}

abstract class Company{

    public function createSoftware(){
        $employees =  $this->getEmployees();
        foreach ($employees as $key => $value) {
            $value->dowork();
        }
    }

    abstract public function getEmployees();
}

class GameDevCompany extends Company{
    public function getEmployees(){
        return [
            new Designer(),
            new Artist()
        ];
        
    }
}

class OutsourcingCompany extends Company{

    public function getEmployees(){
        return [
            new Programmer(),
            new Tester()
        ];
    }
}

$GameDevCompany = new GameDevCompany();
$GameDevCompany->createSoftware();
$OutsourcingCompany = new OutsourcingCompany();
$OutsourcingCompany->createSoftware();

修改后 公司类的主要方法独立于具体的员工类。员工对象将在具体的公司子类中创建。现在你可以对该类进行扩展,并在复用部分公司积累的情况下引入新的公司和员工类型。对公司基类进行扩展时无需修改任何依赖于基类的已有代码。

组合优于继承

在面向对象编程中,还有一条非场经典的设计原则,那就是"组合优于继承,多用组合少用继承"。
继承可能是类之间最明显,最简洁的代码复用方式。如果你有两个代码相同的类,就可以为他们创建一个通用的基类,然后将相似的代码移动到其中。轻而易举。
不过,继承这件事通常只有在程序中已包含大量类,且修改任何东西都非常困难才会引起关注。下面就是此类问题的一些例子

子类不能减少超类的抽象。你必须实现父类中所有的抽象方法,即使他们没什么用。

在重写方法时,你需要确保新行为与其基类中的版本兼容。这一点很重要,因为子类的所有对象都可能被传递给以超类对象为参数的任何代码,如果这些代码出错,那么将是非常棘手的。

继承打破了超类的封装,因为子类拥有访问父类内部详细内容的权限。此外还可能会有相反的情况出现,那就是程序员为了进一步扩展的方便而让超类知晓子类的内部详细内容。

子类与超类紧密耦合。 超类中的任何修改都可能会破坏子类的功能。

通过继承复用代码,可能导致平衡继承体系的产生。继承通常仅发生在一个维度中。只要出现两个以上的维度,你就必须创建数量巨大的类组合,从而使类层次结构膨胀到不可思议的程度。

示例:
假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。
我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出异常不就可以了吗?具体的代码实现如下所示:

<?php

abstract class AbstractBird {
    //...省略其他属性和方法...
    abstract public function fly();
} 
class Ostrich extends AbstractBird { //鸵鸟
    //...省略其他属性和方法...
    public function fly() {
      throw new \Exception("I can't fly.'");
    }
  }

这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。你可能又会说,那我们再通过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承 AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类,不就可以了吗?具体的继承关系如下图所示:

从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用继承。
那么如何去解决呢?这就用到了我们设计原则中的组合。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口。
以下是示例代码:

<?php

interface Flyable {
    public function fly();
  }
interface Tweetable {
    public function tweet();
}
interface EggLayable {
    public function layEgg();
}
class Ostrich implements Tweetable, EggLayable {//鸵鸟
    //... 省略其他属性和方法...
    
    public function tweet() {
        //...
    }

    public function layEgg() {
        //...
    }
  }
class Sparrow implements Flayable, Tweetable, EggLayable {//麻雀
    //... 省略其他属性和方法...
   
    public function fly() { 
        //... 
    }
    
    public function tweet() {
        //... 
    }
  
    public function layEgg() { 
        //... 
    }
  }

不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:

<?php

interface Flyable {
    public function fly();
  }
interface Tweetable {
    public function tweet();
}
interface EggLayable {
    public function layEgg();
}

class FlyAbility implements Flyable{
    public function fly(){
        echo "i can fly<br>";
    }
}

class TweetAbility implements Tweetable{

    public function tweet(){
        echo "i can tweet<br>";
    }
}

class EggLayAbility implements EggLayable{
    public function layEgg(){
        echo "i can layEgg<br>";
    }
}

class Ostrich implements Tweetable, EggLayable {//鸵鸟
    private $tweetAbility;
    private $eggLayAbility;

    public function __construct(){
        $this->tweetAbility = new TweetAbility();//组合
        $this->eggLayAbility = new EggLayAbility();//组合
    }
    
    public function tweet() {
        $this->tweetAbility->tweet();//委托
    }

    public function layEgg() {
        $this->eggLayAbility->layEgg();//委托
    }

}

$ostrich = new Ostrich();
$ostrich->tweet();
$ostrich->layEgg();

我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
尽管设计原则让我们多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

参考资料 :
为什么基于接口而非实现编程?有必要为每个类都定义接口吗?
为何说要多用组合少用继承?如何决定该用组合还是继承?
参考书籍:
深入设计模式

Last modification:June 2nd, 2020 at 04:18 pm