S.O.L.I.D:面向对象设计的头 5 大原则

image.png

S.O.L.I.D 是面向对象设计(OOD)的头五大基本原则的首字母缩写,由俗称「鲍勃大叔」的 Robert C. Martin 提出。
这些原则,结合在一起能够方便程序员开发易于维护和扩展的软件,也让开发人员轻松避免代码异味,易于重构代码,也是敏捷或自适应软件开发的一部分。
注意:这只是一篇“欢迎来到S.O.L.I.D”的简单介绍文章,它只是揭示了S.O.L.I.D是什么。

S.O.L.I.D 代表什么:

虽然缩略词展开后看似复杂,但其实非常容易掌握。

  • S – 单一职责原则
  • O – 开放封闭原则
  • L – 里氏替换原则
  • I – 接口隔离原则
  • D – 依赖倒置原则

让我们来单独看看每个原则,来理解为什么 S.O.L.I.D 能帮助我们成为更优秀的开发人员。

单一职责原则

image.png

S.R.P(简称)原则指出:

一个类应该有且只有一个去改变它的理由,这意味着一个类应该只有一项工作。

例如,假设我们有一些shape(形状),并且我们想求所有shape的面积的和。这很简单对吗?

class Circle {
    public $radius;
 
    public function __construct($radius) {
        $this->radius = $radius;
    }
}
 
class Square {
    public $length;
 
    public function __construct($length) {
        $this->length = $length;
    }
}

首先,我们创建shape类,让构造函数设置需要的参数。接下来,我们继续通过创建AreaCalculator类,然后编写求取所提供的shape面积之和的逻辑。

class AreaCalculator {
 
    protected $shapes;
 
    public function __construct($shapes = array()) {
        $this->shapes = $shapes;
    }
 
    public function sum() {
        // logic to sum the areas
    }
 
    public function output() {
        return implode('', array(
            "<h1>",
                "Sum of the areas of provided shapes: ",
                $this->sum(),
            "</h1>"
        ));
    }
}

使用AreaCalculator类,我们简单地实例化类,同时传入一个shape数组,并在页面的底部显示输出。

$shapes = array(
    new Circle(2),
    new Square(5),
    new Square(6)
);
 
$areas = new AreaCalculator($shapes);
 
echo $areas->output();

输出方法的问题在于,AreaCalculator处理了输出数据的逻辑。因此,如果用户想要以json或其他方式输出数据该怎么办?

所有的逻辑将由AreaCalculator类处理,这是违反单一职责原则(SRP)的;AreaCalculator类应该只对提供的shape进行面积求和,它不应该关心用户是需要json还是HTML。

因此,为了解决这个问题,你可以创建一个SumCalculatorOutputter类,使用这个来处理你所需要的逻辑,即对所提供的shape进行面积求和后如何显示。

SumCalculatorOutputter类按如下方式工作:

$shapes = array(
    new Circle(2),
    new Square(5),
    new Square(6)
);
 
$areas = new AreaCalculator($shapes);
$output = new SumCalculatorOutputter($areas);
 
echo $output->JSON();
echo $output->HAML();
echo $output->HTML();
echo $output->JADE();

现在,不管你需要何种逻辑来输出数据给用户,皆由SumCalculatorOutputter类处理。

开放封闭原则

对象或实体应该对扩展开放,对修改封闭。

这就意味着一个类应该无需修改类本身但却容易扩展。让我们看看AreaCalculator类,尤其是它的sum方法。

public function sum() {
    foreach($this->shapes as $shape) {
        if(is_a($shape, 'Square')) {
            $area[] = pow($shape->length, 2);
        } else if(is_a($shape, 'Circle')) {
            $area[] = pi() * pow($shape->radius, 2);
        }
    }
 
    return array_sum($area);
}

如果我们希望sum方法能够对更多的shape进行面积求和,我们会添加更多的If / else块,这违背了开放封闭原则。

能让这个sum方法做的更好的一种方式是,将计算每个shape面积的逻辑从sum方法中移出,将它附加到shape类上。

class Square {
    public $length;
 
    public function __construct($length) {
        $this->length = $length;
    }
 
    public function area() {
        return pow($this->length, 2);
    }
}

对Circle类应该做同样的事情,area方法应该添加。现在,计算任何所提的shape的面积的和的方法应该和如下简单:

public function sum() {
    foreach($this->shapes as $shape) {
        $area[] = $shape->area;
    }
 
    return array_sum($area);
}

现在我们可以创建另一个shape类,并在计算和时将其传递进来,这不会破坏我们的代码。然而,现在另一个问题出现了,我们怎么知道传递到AreaCalculator上的对象确实是一个shape,或者这个shape具有一个叫做area的方法?

对接口编程是S.O.L.I.D不可或缺的一部分,一个快速的例子是我们创建一个接口,让每个shape实现它:

interface ShapeInterface {
    public function area();
}
 
class Circle implements ShapeInterface {
    public $radius;
 
    public function __construct($radius) {
        $this->radius = $radius;
    }
 
    public function area() {
        return pi() * pow($this->radius, 2);
    }
}

在我们AreaCalculator的求和中,我们可以检查所提供的shape确实是ShapeInterface的实例,否则我们抛出一个异常:

public function sum() {
    foreach($this->shapes as $shape) {
        if(is_a($shape, 'ShapeInterface')) {
            $area[] = $shape->area();
            continue;
        }
 
        throw new AreaCalculatorInvalidShapeException;
    }
 
    return array_sum($area);
}

里氏替换原则

image.png

在对象 x 为类型 T 时 q(x) 成立,那么当 S 是 T 的子类时,对象 y 为类型 S 时 q(y) 也应成立。(即对父类的调用同样适用于子类)

这一切说明的是,每一个子类或派生类应该可以替换它们基类或父类。

还利用AreaCalculator类,我们有一个VolumeCalculator类,它扩展了AreaCalculator类:

class VolumeCalculator extends AreaCalulator {
    public function __construct($shapes = array()) {
        parent::__construct($shapes);
    }
 
    public function sum() {
        // logic to calculate the volumes and then return and array of output
        return array($summedData);
    }
}

In the SumCalculatorOutputter class:

在SumCalculatorOutputter类中:

class SumCalculatorOutputter {
    protected $calculator;
 
    public function __constructor(AreaCalculator $calculator) {
        $this->calculator = $calculator;
    }
 
    public function JSON() {
        $data = array(
            'sum' => $this->calculator->sum();
        );
 
        return json_encode($data);
    }
 
    public function HTML() {
        return implode('', array(
            '<h1>',
                'Sum of the areas of provided shapes: ',
                $this->calculator->sum(),
            '</h1>'
        ));
    }
}

如果我们试图这样来运行一个例子:

$areas = new AreaCalculator($shapes);
$volumes = new AreaCalculator($solidShapes);
 
$output = new SumCalculatorOutputter($areas);
$output2 = new SumCalculatorOutputter($volumes);

程序可以运行,但是当我们在$output2对象调用HTML方法,我们得到一个E_NOTICE错误,提示数组到字符串的转换。

为了解决这个问题,不要从VolumeCalculator类的sum方法返回一个数组,你应该:

public function sum() {
    // logic to calculate the volumes and then return and array of output
    return $summedData;
}

求和的结果作为一个浮点数,双精度或整数。

接口隔离原则

不应强迫客户端实现一个它用不上的接口,或是说客户端不应该被迫依赖它们不使用的方法。

仍然以shape为例,我们知道也有立体shape,如果我们也想计算shape的体积,我们可以添加另一个合约到ShapeInterface:

interface ShapeInterface {
    public function area();
    public function volume();
}

任何我们创建的shape必须实现volume的方法,但是我们知道正方形是平面形状没有体积,所以这个接口将迫使正方形类实现一个它没有使用的方法。

接口隔离原则(ISP)不允许这样,你可以创建另一个名为SolidShapeInterface的接口,它有一个volume合约,对于立体形状比如立方体等等,可以实现这个接口:

interface ShapeInterface {
    public function area();
}
 
interface SolidShapeInterface {
    public function volume();
}
 
class Cuboid implements ShapeInterface, SolidShapeInterface {
    public function area() {
        // calculate the surface area of the cuboid
    }
 
    public function volume() {
        // calculate the volume of the cuboid
    }
}

这是一个更好的方法,但小心一个陷阱,当这些接口做类型提示时,不要使用ShapeInterface或SolidShapeInterface。

你可以创建另一个接口,可以是ManageShapeInterface,平面和立体shape都可用,这样你可以很容易地看到它有一个管理shape的单一API。例如:

interface ManageShapeInterface {
    public function calculate();
}

class Square implements ShapeInterface, ManageShapeInterface {
    public function area() { /*Do stuff here*/ }

    public function calculate() {
        return $this->area();
    }
}

class Cuboid implements ShapeInterface, SolidShapeInterface, ManageShapeInterface {
    public function area() { /*Do stuff here*/ }
    public function volume() { /*Do stuff here*/ }

    public function calculate() {
        return $this->area() + $this->volume();
    }
}

现在AreaCalculator类中,我们可以轻易用calculate替代area调用,同时可以检查一个对象是ManageShapeInterface而不是ShapeInterface的实例。

依赖反转原则

最后一条,但肯定不是最无足轻重的一条:

实体必须依靠抽象而不是具体实现。它表示高层次的模块不应该依赖于低层次的模块,它们都应该依赖于抽象。

这听起来可能有点绕,但它很容易理解。这一原则允许解耦,这似乎是用来解释这一原则最好的例子:

class PasswordReminder {
    private $dbConnection;
 
    public function __construct(MySQLConnection $dbConnection) {
        $this->dbConnection = $dbConnection;
    }
}

首先MySQLConnection是低层次模块,而PasswordReminder处于高层次,但根据S.O.L.I.D.中D的定义,即依赖抽象而不是具体实现,上面这段代码违反这一原则,PasswordReminder类被迫依赖于MySQLConnection类。

以后如果你改变数据库引擎,你还必须编辑PasswordReminder类,因此违反了开闭原则。

PasswordReminder类不应该关心你的应用程序使用什么数据库,为了解决这个问题我们又一次“对接口编程”,因为高层次和低层次模块应该依赖于抽象,我们可以创建一个接口:

interface DBConnectionInterface {
    public function connect();
}

接口有一个connect方法,MySQLConnection类实现该接口,在PasswordReminder类的构造函数不使用MySQLConnection类,而是使用接口替换,不用管你的应用程序使用的是什么类型的数据库,PasswordReminder类可以很容易地连接到数据库,没有任何问题,且不违反OCP。

class MySQLConnection implements DBConnectionInterface {
    public function connect() {
        return "Database connection";
    }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct(DBConnectionInterface $dbConnection) {
        $this->dbConnection = $dbConnection;
    }
}

根据上面的代码片段,你现在可以看到,高层次和低层次模块依赖于抽象。

结论

老实说,S.O.L.I.D初看起来可能棘手,但只要通过连续使用并遵守其指导方针,它就会变成你和你的代码的一部分,可以让你的代码很容易地扩展、修改、测试和重构,不出任何问题。

原文出处
翻译出处

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,816评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,729评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,300评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,780评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,890评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,084评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,151评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,912评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,355评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,666评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,809评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,504评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,150评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,121评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,628评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,724评论 2 351

推荐阅读更多精彩内容

  • 国家电网公司企业标准(Q/GDW)- 面向对象的用电信息数据交换协议 - 报批稿:20170802 前言: 排版 ...
    庭说阅读 10,934评论 6 13
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,638评论 18 139
  • 报了两个写作班,一个偏技巧,一个偏心态建设。想到写作这件事情,我是几个月前开始写的,之前想着记记日记,倒也能笔耕不...
    站立小兔阅读 180评论 0 0
  • 昨天和房东外出,聊到他的孩子。为了孩子上个好点的学校,他们把房子卖了,准备买个学区房。价格高了很多,生活压力也大了...
    xxwade阅读 201评论 0 2
  • 一,迭代器 在对向量、列表和序列进行处理(比如,查找某一特定的元素)时,一种典型的操作就是依次访问或修改其中的各个...
    峰峰小阅读 608评论 0 0