面向对象编程(设计模式)需要遵循的 6 个基本原则

2020-08-17 16:35:45 浏览数 (1)

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

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

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

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

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

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

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

代码语言:javascript复制
/**
 * CustomerDataChart 客户图片处理
 */
class CustomerDataChart
{
    /**
    * 获取数据库连接
    */
    public function getConnection()
    {
    }

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

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

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

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

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

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

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

代码语言:javascript复制
/**
 * 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 实现创建和显示图表。

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

单一职责原则的定义是就一个类而言,应该仅有一个引起他变化的原因。也就是说一个类应该只负责一件事情。如果一个类负责了方法M1,方法M2两个不同的事情,当M1方法发生变化的时候,我们需要修改这个类的M1方法,但是这个时候就有可能导致M2方法不能工作。这个不是我们期待的,但是由于这种设计却很有可能发生。所以这个时候,我们需要把M1方法,M2方法单独分离成两个类。让每个类只专心处理自己的方法。

单一职责原则的好处如下:

可以降低类的复杂度,一个类只负责一项职责,这样逻辑也简单很多 提高类的可读性,和系统的维护性,因为不会有其他奇怪的方法来干扰我们理解这个类的含义 当发生变化的时候,能将变化的影响降到最小,因为只会在这个类中做出修改。

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

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

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

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

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

代码语言:javascript复制
<?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 构造函数,实现面向接口编程。

代码语言:javascript复制
/**
* 图表接口
*/
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),当用户收到留言时需要给用户发送邮件通知。原系统设计如下:

代码语言:javascript复制
<?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 方法来接收父类即可实现不同类型用户的邮件发送功能:

代码语言:javascript复制
<?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 抽象类作为参数,到实际运行时传入具体实现类就可以轻松扩展需求,再多客户类型也不用担心了。

里氏替换原则是一个非常有用的一个概念。他的定义

如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有对象o1都替换成o2的时候,程序P的行为都没有发生变化,那么类型T2是类型T1的子类型。

这样说有点复杂,其实有一个简单的定义

所有引用基类的地方必须能够透明地使用其子类的对象。

里氏替换原则通俗的去讲就是:子类可以去扩展父类的功能,但是不能改变父类原有的功能。他包含以下几层意思:

  • 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
  • 子类可以增加自己独有的方法。
  • 当子类的方法重载父类的方法时候,方法的形参要比父类的方法的输入参数更加宽松。
  • 当子类的方法实现父类的抽象方法时,方法的返回值要比父类更严格。

里氏替换原则之所以这样要求是因为继承有很多缺点,他虽然是复用代码的一种方法,但同时继承在一定程度上违反了封装。父类的属性和方法对子类都是透明的,子类可以随意修改父类的成员。这也导致了,如果需求变更,子类对父类的方法进行一些复写的时候,其他的子类无法正常工作。所以里氏替换法则被提出来。

确保程序遵循里氏替换原则可以要求我们的程序建立抽象,通过抽象去建立规范,然后用实现去扩展细节,这个是不是很耳熟,对,里氏替换原则和开闭原则往往是相互依存的。

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

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

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

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

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

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

代码语言:javascript复制
<?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 类的构造函数的情况下轻松完成缓存服务的迁移工作。

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

代码语言:javascript复制
<?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 创建文字报表及现实。
代码语言:javascript复制
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 抽象类进行拆分,封装进不同的接口中:

代码语言:javascript复制
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)两种实现:

代码语言:javascript复制
<?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 聚合起来使用。

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

代码语言:javascript复制
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);

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

定义:一个对象应该对其他对象保持最少的了解。 问题由来:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

解决方案:尽量降低类与类之间的耦合。

自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢?那正是迪米特法则要去完成的。

迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的Ian Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

举一个例子:有一个集团公司,下属单位有分公司和直属部门,现在要求打印出所有下属单位的员工ID。先来看一下违反迪米特法则的设计。

//总公司员工 class Employee{ private String id; public void setId(String id){ this.id = id; } public String getId(){ return id; } } //分公司员工 class SubEmployee{ private String id; public void setId(String id){ this.id = id; } public String getId(){ return id; } } class SubCompanyManager{ public List<SubEmployee> getAllEmployee(){ List<SubEmployee> list = new ArrayList<SubEmployee>(); for(int i=0; i<100; i ){ SubEmployee emp = new SubEmployee(); //为分公司人员按顺序分配一个ID emp.setId("分公司" i); list.add(emp); } return list; } } class CompanyManager{ public List<Employee> getAllEmployee(){ List<Employee> list = new ArrayList<Employee>(); for(int i=0; i<30; i ){ Employee emp = new Employee(); //为总公司人员按顺序分配一个ID emp.setId("总公司" i); list.add(emp); } return list; } public void printAllEmployee(SubCompanyManager sub){ List<SubEmployee> list1 = sub.getAllEmployee(); for(SubEmployee e:list1){ System.out.println(e.getId()); } List<Employee> list2 = this.getAllEmployee(); for(Employee e:list2){ System.out.println(e.getId()); } } } public class Client{ public static void main(String[] args){ CompanyManager e = new CompanyManager(); e.printAllEmployee(new SubCompanyManager()); } } 现在这个设计的主要问题出在CompanyManager中,根据迪米特法则,只与直接的朋友发生通信,而SubEmployee类并不是CompanyManager类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲总公司只与他的分公司耦合就行了,与分公司的员工并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:

class SubCompanyManager{ public List<SubEmployee> getAllEmployee(){ List<SubEmployee> list = new ArrayList<SubEmployee>(); for(int i=0; i<100; i ){ SubEmployee emp = new SubEmployee(); //为分公司人员按顺序分配一个ID emp.setId("分公司" i); list.add(emp); } return list; } public void printEmployee(){ List<SubEmployee> list = this.getAllEmployee(); for(SubEmployee e:list){ System.out.println(e.getId()); } } } class CompanyManager{ public List<Employee> getAllEmployee(){ List<Employee> list = new ArrayList<Employee>(); for(int i=0; i<30; i ){ Employee emp = new Employee(); //为总公司人员按顺序分配一个ID emp.setId("总公司" i); list.add(emp); } return list; } public void printAllEmployee(SubCompanyManager sub){ sub.printEmployee(); List<Employee> list2 = this.getAllEmployee(); for(Employee e:list2){ System.out.println(e.getId()); } } } 修改后,为分公司增加了打印人员ID的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。

迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,总公司就是通过分公司这个“中介”来与分公司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。 --------------------- 作者:愤怒的韭菜 来源:CSDN 原文:https://blog.csdn.net/zhengzhb/article/details/7296930 版权声明:本文为博主原创文章,转载请附上博文链接!

0 人点赞