17
本文首发于 没错,这就是面向对象编程(设计模式)需要遵循的 6 个基本原则,转载请注明出处。

在讨论面向对象编程和模式(具体一点来说,设计模式)的时候,我们需要一些标准来对设计的好还进行判断,或者说应该遵循怎样的原则和指导方针。

现在,我们就来了解下这些原则:

  • 单一职责原则(S)
  • 开闭原则(O)
  • 里氏替换原则(L)
  • 接口隔离原则(I)
  • 依赖倒置原则(D)
  • 合成复用原则
  • 及迪米特法则(最少知道原则)

本文将涵盖 SOLID + 合成复用原则的讲解及示例,迪米特法则以扩展阅读形式给出。

单一职责原则(Single Responsibility Principle[SRP]) ★★★★

一个类只负责一个功能领域中的相应职责(只做一类事情)。或者说一个类仅能有一个引起它变化的原因。

来看看一个功能过重的类示例:

/**
 * CustomerDataChart 客户图片处理
 */
class CustomerDataChart
{
    /**
    * 获取数据库连接
    */
    public function getConnection()
    {
    }

    /**
    * 查找所有客户信息
    */
    public function findCustomers()
    {
    }

    /**
    * 创建图表
    */
    public function createChart()
    {
    }

    /**
    * 显示图表
    */
    public function displayChart()
    {
    }
}

我们发现 CustomerDataChart 类完成多个职能:

  • 建立数据库连接
  • 查找客户
  • 创建和显示图表

此时,其它类若需要使用数据库连接,无法复用 CustomerDataChart;或者想要查找客户也无法实现复用。另外,修改数据库连接或修改图表显示方式都需要修改 CustomerDataChart 类。这个问题挺严重的,无论修改什么功能都需要多这个类进行编码。

所以,我们采用 单一职责原则 对类进行重构,如下:

/**
 * DB 类负责完成数据库连接操作
 */
class DB
{
    public function getConnection()
    {
    }
}

/**
 * Customer 类用于从数据库中查找客户记录
 */
class Customer
{
    private $db;

    public function __construct(DB $db)
    {
        $this->db = $db;
    }

    public function findCustomers()
    {
    }
}

class CustomerDataChart
{
    private $customer;

    public function __construct(Customer $customer)
    {
        $this->customer = $customer;
    }

    /**
    * 创建图表
    */
    public function createChart()
    {
    }

    /**
    * 显示图表
    */
    public function displayChart()
    {
    }
}

重构完成后:

  • DB 类仅处理数据库连接的问题,挺提供 getConnection() 方法获取数据库连接;
  • Customer 类完成操作 Customers 数据表的任务,这其中包括 CRUD 的方法;
  • CustomerDataChart 实现创建和显示图表。

各司其职,配合默契,完美!

开闭原则(Open-Closed Principle[OCP]) ★★★★★

开闭原则最重要 的面向对象设计原则,是可复用设计的基石。

「开闭原则」:对扩展开放、对修改关闭,即尽量在不修改原有代码的基础上进行扩展。要想系统满足「开闭原则」,需要对系统进行 抽象

通过 接口抽象类 将系统进行抽象化设计,然后通过实现类对系统进行扩展。当有新需求需要修改系统行为,简单的通过增加新的实现类,就能实现扩展业务,达到在不修改已有代码的基础上扩展系统功能这一目标。

示例,系统提供多种图表展现形式,如柱状图、饼状图,下面是不符合开闭原则的实现:

<?php

/**
 * 显示图表
 */
class ChartDisplay
{
    private $chart;

    /**
     * @param string $type 图标实现类型
     */
    public function __construct(string $type)
    {
        switch ($type) {
            case 'pie':
                $this->chart = new PieChart();
                break;

            case 'bar':
                $this->chart = new BarChart();
                break;

            default:
                $this->chart = new BarChart();
        }

        return $this;
    }

    /**
     * 显示图标 
     */
    public function display()
    {
        $this->chart->render();
    }
}

/**
 * 饼图
 */
class PieChart
{
    public function render()
    {
        echo 'Pie chart.';
    }
}

/**
 * 柱状图
 */
class BarChart
{
    public function render()
    {
        echo 'Bar chart.';
    }
}

$pie = new ChartDisplay('pie');
$pie->display(); //Pie chart.

$bar = new ChartDisplay('bar');
$bar->display(); //Bar chart.

在这里我们的 ChartDisplay 每增加一种图表显示,都需要在构造函数中对代码进行修改。所以,违反了 开闭原则。我们可以通过声明一个 Chart 抽象类(或接口),再将接口传入 ChartDisplay 构造函数,实现面向接口编程。


/**
* 图表接口
*/
interface ChartInterface
{
    /**
    * 绘制图表
    */
    public function render();
}

class PieChart implements ChartInterface
{
    public function render()
    {
        echo 'Pie chart.';
    }
}

class BarChart implements ChartInterface
{
    public function render()
    {
        echo 'Bar chart.';
    }
}

/**
 * 显示图表
 */
class ChartDisplay
{
    private $chart;

    /**
     * @param ChartInterface $chart
     */
    public function __construct(ChartInterface $chart)
    {
        $this->chart = $chart;
    }

    /**
     * 显示图标 
     */
    public function display()
    {
        $this->chart->render();
    }
}


$config = ['PieChart', 'BarChart'];

foreach ($config as $key => $chart) {
    $display = new ChartDisplay(new $chart());
    $display->display();
}

修改后的 ChartDisplay 通过接收 ChartInterface 接口作为构造函数参数,实现了图表显示不依赖于具体的实现类即 面向接口编程。在不修改源码的情况下,随时增加一个 LineChart 线状图表显示。具体图表实现可以从配置文件中读取。

里氏替换原则(Liskov Substitution Principle[LSP]) ★★★★★

里氏代换原则:在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立。如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。

示例,我们的系统用户类型分为:普通用户(CommonCustomer)和 VIP 用户(VipCustomer),当用户收到留言时需要给用户发送邮件通知。原系统设计如下:

<?php

/**
 * 发送邮件
 */
class EmailSender
{
    /**
     * 发送邮件给普通用户
     *
     * @param CommonCustomer $customer
     * @return void
     */
    public function sendToCommonCustomer(CommonCustomer $customer)
    {
        printf("Send email to %s[%s]", $customer->getName(), $customer->getEmail());
    }
    
    /**
     * 发送邮件给 VIP 用户
     * 
     * @param VipCustomer $vip
     * @return void
     */
    public function sendToVipCustomer(VipCustomer $vip)
    {
        printf("Send email to %s[%s]", $vip->getName(), $vip->getEmail());
    }    
}

/**
 * 普通用户
 */
class CommonCustomer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }
}

/**
 * Vip 用户
 */
class VipCustomer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }
}

$customer = new CommonCustomer("liugongzi", "liuqing_hu@126.com");
$vip = new VipCustomer("vip", "liuqing_hu@126.com");

$sender = new EmailSender();
$sender->sendToCommonCustomer($customer);// Send email to liugongzi[liuqing_hu@126.com]
$sender->sendToVipCustomer($vip);// Send email to vip[liuqing_hu@126.com]

这里,为了演示说明我们通过在 EmailSender 类中的 send* 方法中使用类型提示功能,对接收参数进行限制。所以如果有多个用户类型可能就需要实现多个 send 方法才行。

依据 里氏替换原则 我们知道,能够接收父类的地方 一定 能够接收子类作为参数。所以我们仅需定义 send 方法来接收父类即可实现不同类型用户的邮件发送功能:

<?php

/**
 * 发送邮件
 */
class EmailSender
{
    /**
     * 发送邮件给普通用户
     *
     * @param CommonCustomer $customer
     * @return void
     */
    public function send(Customer $customer)
    {
        printf("Send email to %s[%s]", $customer->getName(), $customer->getEmail());
    }
}

/**
 * 用户抽象类
 */
abstract class Customer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }

}


/**
 * 普通用户
 */
class CommonCustomer extends Customer
{
}

/**
 * Vip 用户
 */
class VipCustomer extends Customer
{
}

$customer = new CommonCustomer("liugongzi", "liuqing_hu@126.com");
$vip = new VipCustomer("vip", "liuqing_hu@126.com");

$sender = new EmailSender();
$sender->send($customer);// Send email to liugongzi[liuqing_hu@126.com]
$sender->send($vip);// Send email to vip[liuqing_hu@126.com]

修改后的 send 方法接收 Customer 抽象类作为参数,到实际运行时传入具体实现类就可以轻松扩展需求,再多客户类型也不用担心了。

依赖倒置原则(Dependence Inversion Principle[DIP]) ★★★★★

依赖倒转原则:抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。

在里氏替换原则中我们在未进行优化的代码中将 CommonCustomer 类实例作为 sendToCommonCustomer 的参数,来实现发送用户邮件的业务逻辑,这里就违反了「依赖倒置原则」。

如果想在模块中实现符合依赖倒置原则的设计,要将依赖的组件抽象成更高层的抽象类(接口)如前面的 Customer 类,然后通过采用 依赖注入(Dependency Injection) 的方式将具体实现注入到模块中。另外,就是要确保该原则的正确应用,实现类应当仅实现在抽象类或接口中声明的方法,否则可能造成无法调用到实现类新增方法的问题。

这里提到「依赖注入」设计模式,简单来说就是将系统的依赖有硬编码方式,转换成通过采用 设值注入(setter)构造函数注入接口注入 这三种方式设置到被依赖的系统中,感兴趣的朋友可以阅读我写的 深入浅出依赖注入 一文。

举例,我们的用户在登录完成后需要通过缓存服务来缓存用户数据:

<?php

class MemcachedCache
{
    public function set($key, $value)
    {
        printf ("%s for key %s has cached.", $key, json_encode($value));
    }
}

class User
{
    private $cache;

    /**
     * User 依赖于 MemcachedCache 服务(或者说组件)
     */
    public function __construct()
    {
        $this->cache = new MemcachedCache();
    }

    public function login()
    {
        $user = ['id' => 1, 'name' => 'liugongzi'];
        $this->cache->set('dp:uid:' . $user['id'], $user);
    }
}

$user = new User();
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

这里,我们的缓存依赖于 MemcachedCache 缓存服务。然而由于业务的需要,我们需要缓存服务有 Memacached 迁移到 Redis 服务。当然,现有代码中我们就无法在不修改 User 类的构造函数的情况下轻松完成缓存服务的迁移工作。

那么,我们可以通过使用 依赖注入 的方式,来实现依赖倒置原则:

<?php

class Cache
{
    public function set($key, $value)
    {
        printf ("%s for key %s has cached.", $key, json_encode($value));
    }
}

class RedisCache extends Cache
{
}

class MemcachedCache extends Cache
{
}

class User
{
    private $cache;

    /**
     * 构造函数注入
     */
    public function __construct(Cache $cache)
    {
        $this->cache = $cache;
    }

    /**
     * 设值注入
     */
    public function setCache(Cache $cache)
    {
        $this->cache = $cache;
    }

    public function login()
    {
        $user = ['id' => 1, 'name' => 'liugongzi'];
        $this->cache->set('dp:uid:' . $user['id'], $user);
    }
}

// use MemcachedCache
$user =  new User(new MemcachedCache());
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

// use RedisCache
$user->setCache(new RedisCache());
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

完美!

接口隔离原则(Interface Segregation Principle[ISP]) ★★

接口隔离原则:使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。

简单来说就是不要让一个接口来做太多的事情。比如我们定义了一个 VipDataDisplay 接口来完成如下功能:

  • 通过 readUsers 方法读取用户数据;
  • 可以使用 transformToXml 方法将用户记录转存为 XML 文件;
  • 通过 createChart 和 displayChart 方法完成创建图表及显示;
  • 还可以通过 createReport 和 displayReport 创建文字报表及现实。
abstract class VipDataDisplay
{
    public function readUsers()
    {
        echo 'Read all users.';
    }

    public function transformToXml()
    {
        echo 'save user to xml file.';
    }

    public function createChart()
    {
        echo 'create user chart.';
    }

    public function displayChart()
    {
        echo 'display user chart.';
    }

    public function createReport()
    {
        echo 'create user report.';
    }

    public function displayReport()
    {
        echo 'display user report.';

    }
}

class CommonCustomerDataDisplay extends VipDataDisplay
{

}

现在我们的普通用户 CommonCustomerDataDisplay 不需要 Vip 用户这么复杂的展现形式,仅需要进行图表显示即可,但是如果继承 VipDataDisplay 类就意味着继承抽象类中所有方法。

现在我们将 VipDataDisplay 抽象类进行拆分,封装进不同的接口中:

interface ReaderHandler
{
    public function readUsers();
}

interface XmlTransformer
{
    public function transformToXml();
}

interface ChartHandler
{
    public function createChart();

    public function displayChart();
}

interface ReportHandler
{
    public function createReport();

    public function displayReport();
}

class CommonCustomerDataDisplay implements ReaderHandler, ChartHandler
{
    public function readUsers()
    {
        echo 'Read all users.';
    }

    public function createReport()
    {
        echo 'create user report.';
    }

    public function displayReport()
    {
        echo 'display user report.';

    }
}

重构完成后,仅需在实现类中实现接口中的方法即可。

合成复用原则(Composite Reuse Principle[CRP]) ★★★★

合成复用原则:尽量使用对象组合,而不是继承来达到复用的目的。

合成复用原则就是在一个新的对象里通过关联关系(包括组合和聚合)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系) ,少用继承。

何时使用继承,何时使用组合(或聚合)?

当两个类之间的关系属于 IS-A 关系时,如 dog is animal,使用 继承;而如果两个类之间属于 HAS-A 关系,如 engineer has a computer,则优先选择组合(或聚合)设计。

示例,我们的系统有用日志(Logger)功能,然后我们实现了向控制台输入日志(SimpleLogger)和向文件写入日志(FileLogger)两种实现:

<?php

abstract class Logger
{
    abstract public function write($log);
}

class SimpleLogger extends Logger
{
    public function write($log)
    {
        print((string) $log);
    }
}

class FileLogger extends Logger
{
    public function write($log)
    {
        file_put_contents('logger.log', (string) $log);
    }
}

$log = "This is a log.";

$sl = new SimpleLogger();
$sl->write($log);// This is a log.

$fl = new FileLogger();
$fl->write($log);

看起来很好,我们的简单日志和文件日志能够按照我们预定的结果输出和写入文件。很快,我们的日志需求有了写增强,现在我们需要将日志同时向控制台和文件中写入。有几种解决方案吧:

  • 重新定义一个子类去同时写入控制台和文件,但这似乎没有用上我们已经定义好的两个实现类:SimpleLogger 和 FileLogger;
  • 去继承其中的一个类,然后实现另外一个类的方法。比如继承 SimpleLogger,然后实现写入文件日志的方法;嗯,没办法 PHP 是单继承的语言;
  • 使用组合模式,将 SimpleLogger 和 FileLogger 聚合起来使用。

我们直接看最后一种解决方案吧,前两种实在是有点。

class AggregateLogger
{
    /**
     * 日志对象池
     */
    private $loggers = [];

    public function addLogger(Logger $logger)
    {
        $hash = spl_object_hash($logger);
        $this->loggers[$hash] = $logger;
    }

    public function write($log)
    {
        array_map(function ($logger) use ($log) {
            $logger->write($log);
        }, $this->loggers);
    }
}

$log = "This is a log.";

$aggregate = new AggregateLogger();

$aggregate->addLogger(new SimpleLogger());// 加入简单日志 SimpleLogger
$aggregate->addLogger(new FileLogger());// 键入文件日志 FileLogger

$aggregate->write($log);

可以看出,采用聚合的方式我们可以非常轻松的实现复用。

迪米特法则

设计模式六大原则(5):迪米特法则

感谢 Liuwei-Sunny 大神在「软件架构、设计模式、重构、UML 和 OOAD」领域的分享,才有此文。


柳公子
3.3k 声望1.5k 粉丝

学以致用,边学边用