7

面向对象基本原则(3)- 最少知道原则与开闭原则

面向对象基本原则(1)- 单一职责原则与接口隔离原则
面向对象基本原则(2)- 里式代换原则与依赖倒置原则
面向对象基本原则(3)- 最少知道原则与开闭原则


五、最少知道原则【迪米特法则】

1. 最少知道原则简介

最少知识原则(Least KnowledgePrinciple,LKP)也称为迪米特法则(Law of Demeter,LoD)。虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。

通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。

2. 最少知道原则实现

只与直接关联的类交流

每个对象都必然会与其他对象有耦合关系,耦合关系的类型有很多,例如组合、聚合、依赖等。
出现在成员变量、方法的输入输出参数中的类称为直接关联的类,而出现在方法体内部的类不属于直接关联的类。

下面举例说明如何才能做到只与直接关联的类交流。

场景:老师想让班长清点女生的数量

  • Bad

/**
 * 老师类
 * Class Teacher
 */
class Teacher {
    /**
     * 老师对班长发布命令,清点女生数量
     * @param GroupLeader $groupLeader
     */
    public function command(GroupLeader $groupLeader)
    {
        // 产生一个女生群体
        $girlList = new \ArrayIterator();
        // 初始化女生
        for($i = 0; $i < 20; $i++){
            $girlList->append(new Girl());
        }

        // 告诉班长开始执行清点任务
        $groupLeader->countGirls($girlList);
    }
}

/**
 * 班长类
 * Class GroupLeader
 */
class GroupLeader {

    /**
     * 清点女生数量
     * @param \ArrayIterator $girlList
     */
    public function countGirls($girlList)
    {
        echo "女生数量是:", $girlList->count(), "\n";
    }
}

/**
 * 女生类
 * Class Girl
 */
class Girl {

}

$teacher= new Teacher();
//老师发布命令
$teacher->command(new GroupLeader()); // 女生数量是:20

上面实例中,Teacher类仅有一个直接关联的类 -- GroupLeader。而Girl这个类就是出现在commond方法体内,因此不属于与Teacher类直接关联的类。
方法是类的一个行为,类竟然不知道自己的行为与其他类产生依赖关系,这是不允许的,违反了迪米特法则。

对程序进行简单的修改,把 对 $girlList 的初始化移出 Teacher 类,同时在 GroupLeader 中增加对 Girl 的注入,避开 Teacher 类对陌生类 Girl 的访问,降低系统间的耦合,提高系统的健壮性。
下面是改进后的代码:

  • Good
/**
 * 老师类
 * Class Teacher
 */
class Teacher {

    /**
     * 老师对班长发布命令,清点女生数量
     * @param GroupLeader $groupLeader
     */
    public function command(GroupLeader $groupLeader)
    {
        // 告诉班长开始执行清点任务
        $groupLeader->countGirls();
    }
}

/**
 * 班长类
 * Class GroupLeader
 */
class GroupLeader {

    private $_girlList;

    /**
     * 传递全班的女生进来
     * GroupLeader constructor.
     * @param Girl[]|\ArrayIterator $girlList
     */
    public function __construct(\ArrayIterator $girlList)
    {
        $this->_girlList = $girlList;
    }

    //清查女生数量
    public function countGirls()
    {
        echo "女生数量是:", $this->_girlList->count(), "\n";
    }
}

/**
 * 女生类
 * Class Girl
 */
class Girl {

}

// 产生一个女生群体
$girlList = new \ArrayIterator();
// 初始化女生
for($i = 0; $i < 20; $i++){
    $girlList->append(new Girl());
}

$teacher= new Teacher();
//老师发布命令
$teacher->command(new GroupLeader($girlList)); // 女生数量是:20

关联的类之间也要有距离

迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、protected等访问权限。

一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、protected等访问权限,是否可以加上final关键字等。

实例场景:实现软件安装的过程,其中first方法定义第一步做什么,second方法定义第二步做什么,third方法定义第三步做什么。

  • Bad
/**
 * 导向类
 * Class Wizard
 */
class Wizard {

    /**
     * 第一步
     * @return int
     */
    public function first()
    {
        echo "执行第一步安装...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }

    /**
     * 第二步
     * @return int
     */
    public function second()
    {
        echo "执行第二步安装...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }

    /**
     * 第三步
     * @return int
     */
    public function third()
    {
        echo "执行第三步安装...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }
}

/**
 * 安装软件类
 * Class InstallSoftware
 */
class InstallSoftware {

    /**
     * 执行安装软件操作
     * @param Wizard $wizard
     */
    public function installWizard(Wizard $wizard)
    {
        $first = $wizard->first();
        //根据first返回的结果,看是否需要执行second
        if($first === 1){
            $second = $wizard->second();
            if($second === 1){
                $third = $wizard->third();
                if($third === 1){
                    echo "软件安装完成!\n";
                }
            }
        }
    }
}

// 实例化软件安装类
$invoker = new InstallSoftware();
// 开始安装软件
$invoker->installWizard(new Wizard()); // 运行结果和随机数有关,每次的执行结果都不相同

Wizard类把太多的方法暴露给InstallSoftware类,两者的朋友关系太亲密了,耦合关系变得异常牢固。如果要将Wizard类中的first方法返回值的类型由int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。因此,这样的耦合是极度不合适的。

改进:在Wizard类中增加一个installWizard方法,对安装过程进行封装,同时把原有的三个public方法修改为private方法。

/**
 * 导向类
 * Class Wizard
 */
class Wizard {

    //第一步
    private function first()
    {
        echo "执行第1个方法...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }
    //第二步
    private function second()
    {
        echo "执行第2个方法...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }
    //第三个方法
    private function third()
    {
        echo "执行第3个方法...\n";
        // 模拟用户点是或取消
        return rand(0, 1);
    }

    public function installWizard(){
        $first = $this->first();
        //根据first返回的结果,看是否需要执行second
        if($first === 1){
            $second = $this->second();
            if($second === 1){
                $third = $this->third();
                if($third === 1){
                    echo "软件安装完成!\n";
                }
            }
        }
    }
}

/**
 * 安装软件类
 * Class InstallSoftware
 */
class InstallSoftware {

    /**
     * 执行安装软件操作
     * @param Wizard $wizard
     */
    public function installWizard(Wizard $wizard)
    {
        $wizard->installWizard();
    }
}

// 实例化软件安装类
$invoker = new InstallSoftware();
// 开始安装软件
$invoker->installWizard(new Wizard()); // 运行结果和随机数有关,每次的执行结果都不相同

代码改进后,类间的耦合关系变弱了,结构也清晰了,变更引起的风险也变小了。

3. 最佳实践

在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错,那怎么去衡量呢?
你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。

在实际应用中,如果一个类跳转两次以上才能访问到另一个类,就需要想办法进行重构了。
因为一个系统的成功不仅仅是一个标准或是原则就能够决定的,有非常多的外在因素决定,跳转次数越多,系统越复杂,维护就越困难,所以只要跳转不超过两次都是可以忍受的,这需要具体问题具体分析。

迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。
原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要大家在采用原则时反复度量,不遵循是不对的,严格执行就是“过犹不及”。

六、开闭原则

1. 开闭原则简介

开闭原则的英文名称是 Open-Close Principle,简称OCP。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立一个稳定、灵活的软件系统。
开闭原则的英文定义是

Software entities like classes,modules and functions should be open for extension but closed for modifications.

一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。 其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。

软件实体包括以下几个部分:

  • 项目或软件产品中按照一定的逻辑规则划分的模块。
  • 抽象和类。
  • 方法。

一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

2. 开闭原则的优点

提高复用率

在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。
复用可以减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码。
那怎么才能提高复用率呢?缩小逻辑粒度,直到一个逻辑不可再拆分为止。

提高可维护性

一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情,不要让他在原有的代码海洋里游弋完毕后再修改,那是对维护人员的一种折磨和摧残。

面向对象开发的要求

万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略去应对。怎么快速应对呢?这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。

2. 变化的三种类型

逻辑变化

只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是 a*b+c,现在需要修改为 a*b*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。

子模块变化

一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的,刚刚的书籍打折处理就是类似的处理模块,该部分的变化甚至会引起界面的变化。

视图变化

可见视图是提供给客户使用的界面,该部分的变化一般会引起连锁反应。如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,例如一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。

3. 开闭原则的使用

抽象约束

抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:
第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;
第三,抽象层尽量保持稳定,一旦确定即不允许修改。

封装变化

对变化的封装包含两层含义:
第一,将相同的变化封装到一个接口或抽象类中;
第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。
封装变化,也就是受保护的变化(protected variations),找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到或“第六感”发觉有变化,就可以进行封装。

4. Show me the code

书店售书场景

代码使用PHP7.2语法编写
  • 书籍接口
/**
 * Interface IBook
 * 书籍接口
 */
interface IBook {
    /**
     * 书籍名称
     * @return mixed
     */
    public function getName() : string ;

    /**
     * 书籍价格
     * 这里把价格定义为int类型并不是错误,
     * 在非金融类项目中对货币处理时,一般取2位精度,
     * 通常的设计方法是在运算过程中扩大100倍,在需要展示时再缩小100倍,减少精度带来的误差。
     * @return mixed
     */
    public function getPrice() : int ;

    /**
     * 书籍作者
     * @return mixed
     */
    public function getAuthor() : string ;
}
  • 小说类
/**
 * 小说类
 * Class NovelBook
 */
class NovelBook implements IBook {

    /**
     * 书籍名称
     * @var string $_name
     */
    private $_name;

    /**
     * 书籍价格
     * @var int $_price
     */
    private $_price;

    /**
     * 书籍作者
     * @var string $_author
     */
    private $_author;

    /**
     * 通过构造函数传递书籍信息
     * @param string $name
     * @param int $price
     * @param string $author
     */
    public function __construct(string $name, int $price, string $author)
    {
        $this->_name = $name;
        $this->_price = $price;
        $this->_author = $author;
    }

    /**
     * 获取书籍名称
     * @return string
     */
    public function getName() : string
    {
        return $this->_name;
    }

    /**
     * 获取书籍价格
     * @return int
     */
    public function getPrice() : int
    {
        return $this->_price;
    }

    /**
     * 获取书籍作者
     * @return string
     */
    public function getAuthor() : string
    {
        return $this->_author;
    }

}
  • 售书场景
// 产生一个书籍列表
$bookList = new ArrayIterator();

// 始化数据
$bookList->append(new NovelBook("天龙八部",3200,"金庸"));
$bookList->append(new NovelBook("巴黎圣母院",5600,"雨果"));

echo "------书店卖出去的书籍记录如下:--------\n";
foreach($bookList as $book){
    $price = $book->getPrice() / 100;
    echo <<<TXT
书籍名称: {$book->getName()}
书籍作者: {$book->getAuthor()}
书籍价格: {$price} 元
---\n
TXT;
}
------书店卖出去的书籍记录如下:--------
书籍名称: 天龙八部
书籍作者: 金庸
书籍价格: 32 元
---
书籍名称: 巴黎圣母院
书籍作者: 雨果
书籍价格: 56 元
---

一段时间之后,书店决定对小说类书籍进行打折促销:所有40元以上的书籍9折销售,其他的8折销售。面对需求的变化,我们有两种解决方案。

  1. 修改实现类NovelBook

直接修改NovelBook类中的getPrice()方法实现打折处理。该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的。例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对称而出现决策失误的情况。

  1. 通过扩展实现变化

增加一个子类OffNovelBook,覆写getPrice方法,高层次的模块通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发,修改少,风险也小。

  • 打折销售的小说类
/**
 * 打折销售的小说类
 * Class OffNovelBook
 */
class OffNovelBook extends NovelBook {
    /**
     * 覆写获取销售价格方法
     *
     * @return int
     */
    public function getPrice() : int
    {
        //原价
        $originPrice = parent::getPrice();

        if($originPrice > 40){ //原价大于40元,则打9折
            $discountPrice = $originPrice * 90 / 100;
        }else{
            $discountPrice = $originPrice * 80 / 100;
        }
        return $discountPrice;
    }
}
  • 打折售书场景
// 产生一个书籍列表
$bookList = new ArrayIterator();

// 始化数据,实际项目中一般是由持久层完成
$bookList->append(new OffNovelBook("天龙八部",3200,"金庸"));
$bookList->append(new OffNovelBook("巴黎圣母院",5600,"雨果"));

echo "------书店卖出去的书籍记录如下:------\n";
foreach($bookList as $book){
    $price = $book->getPrice() / 100;
    echo <<<TXT
书籍名称: {$book->getName()}
书籍作者: {$book->getAuthor()}
书籍价格: {$price} 元
---\n
TXT;
}
------书店卖出去的书籍记录如下:------
书籍名称: 天龙八部
书籍作者: 金庸
书籍价格: 28.8 元
---
书籍名称: 巴黎圣母院
书籍作者: 雨果
书籍价格: 50.4 元
---

又过了一段时间,书店新增加了计算机书籍,它不仅包含书籍名称、作者、价格等信息,还有一个独特的属性:面向的是什么领域,也就是它的范围,比如是和编程语言相关的,还是和数据库相关的,等等。

  • 增加一个IComputerBook接口,它继承自IBook
/**
 * 计算机类书籍接口
 * Interface IComputerBook
 */
interface IComputerBook extends IBook
{
    /**
     * 计算机书籍增加一个范围属性
     * @return string
     */
    public function getScope() : string ;
}
  • 计算机书籍类
/**
 * 计算机书籍类
 * Class ComputerBook
 */
class ComputerBook implements IComputerBook
{
    /**
     * 书籍名称
     * @var string $_name
     */
    private $_name;

    /**
     * 书籍价格
     * @var int $_price
     */
    private $_price;

    /**
     * 书籍作者
     * @var string $_author
     */
    private $_author;

    /**
     * 书籍范围
     * @var string $_scope
     */
    private $_scope;

    /**
     * 通过构造函数传递书籍信息
     * ComputerBook constructor.
     * @param string $name
     * @param int $price
     * @param string $author
     * @param string $scope
     */
    public function __construct(string $name, int $price, string $author, string $scope)
    {
        $this->_name = $name;
        $this->_price = $price;
        $this->_author = $author;
        $this->_scope = $scope;
    }

    /**
     * 获取书籍名称
     * @return string
     */
    public function getName() : string
    {
        return $this->_name;
    }

    /**
     * 获取书籍价格
     * @return int
     */
    public function getPrice() : int
    {
        return $this->_price;
    }

    /**
     * 获取书籍作者
     * @return string
     */
    public function getAuthor() : string
    {
        return $this->_author;
    }

    /**
     * 获取书籍范围
     * @return string
     */
    public function getScope() : string
    {
        return $this->_scope;
    }
}
  • 增加计算机书籍销售
//产生一个书籍列表
$bookList = new ArrayIterator();

// 始化数据,实际项目中一般是由持久层完成
$bookList->append(new OffNovelBook("天龙八部",3200,"金庸"));
$bookList->append(new OffNovelBook("巴黎圣母院",5600,"雨果"));
$bookList->append(new ComputerBook("高性能MySQL",4800,"Baron", '数据库'));


echo "------书店卖出去的书籍记录如下:------\n";
foreach($bookList as $book) {
    $price = $book->getPrice() / 100;
    echo <<<TXT
书籍名称: {$book->getName()}
书籍作者: {$book->getAuthor()}
书籍价格: {$price} 元
---\n
TXT;
}
------书店卖出去的书籍记录如下:------
书籍名称: 天龙八部
书籍作者: 金庸
书籍价格: 28.8 元
---
书籍名称: 巴黎圣母院
书籍作者: 雨果
书籍价格: 50.4 元
---
书籍名称: 高性能MySQL
书籍作者: Baron
书籍价格: 48 元
---

开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。

参考文献:《设计模式之禅》

白菜1031
5.4k 声望1.6k 粉丝