SOLID:面向对象设计的前5个原则

147 阅读6分钟

简介

SOLID是Robert C. Martin(又称Bob叔叔)提出的前五条面向对象设计(OOD)原则的缩写。

**注意:**虽然这些原则可以适用于各种编程语言,但本文中的示例代码将使用PHP。

这些原则确立了开发软件的做法,并考虑到了随着项目的发展而进行维护和扩展。采用这些实践也有助于避免代码臭味,重构代码,以及敏捷或适应性软件开发。

SOLID代表。

在这篇文章中,我们将分别介绍每个原则,以了解SOLID如何帮助你成为一个更好的开发者。

单一责任原则

单一责任原则(SRP)指出。

一个类应该有一个且只有一个变化的理由,也就是说,一个类应该只有一个工作。

例如,考虑一个应用程序,它需要一个形状的集合--圆形和方形,并计算集合中所有形状的面积之和。

首先,创建形状类并让构造函数设置所需的参数。

对于正方形,你将需要知道一个边的length

class Square
{
    public $length;

    public function construct($length)
    {
        $this->length = $length;
    }
}

对于圆形,你将需要知道radius

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }
}

接下来,创建AreaCalculator 类,然后编写逻辑,将所有提供的形状的面积相加。正方形的面积是通过长度的平方来计算的。圆的面积是通过π乘以半径的平方来计算的。

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

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

        return array_sum($area);
    }

    public function output()
    {
        return implode('', [
          '',
              'Sum of the areas of provided shapes: ',
              $this->sum(),
          '',
      ]);
    }
}

要使用AreaCalculator ,你需要实例化这个类,并传入一个形状数组,在页面底部显示输出。

下面是一个有三个形状集合的例子。

  • 一个半径为2的圆
  • 一个长度为5的正方形
  • 第二个正方形,长度为6
$shapes = [
  new Circle(2),
  new Square(5),
  new Square(6),
];

$areas = new AreaCalculator($shapes);

echo $areas->output();

输出方法的问题是,AreaCalculator 处理输出数据的逻辑。

考虑一个场景,输出应该被转换为另一种格式,如JSON。

所有的逻辑都将由AreaCalculator 类来处理。这就违反了单一责任原则。AreaCalculator 类应该只关心所提供形状的面积之和。它不应该关心用户是想要JSON还是HTML。

为了解决这个问题,你可以创建一个单独的SumCalculatorOutputter 类,用这个新的类来处理你需要输出数据给用户的逻辑。

class SumCalculatorOutputter
{
    protected $calculator;

    public function __constructor(AreaCalculator $calculator)
    {
        $this->calculator = $calculator;
    }

    public function JSON()
    {
        $data = [
          'sum' => $this->calculator->sum(),
      ];

        return json_encode($data);
    }

    public function HTML()
    {
        return implode('', [
          '',
              'Sum of the areas of provided shapes: ',
              $this->calculator->sum(),
          '',
      ]);
    }
}

SumCalculatorOutputter 类的工作方式是这样的。

$shapes = [
  new Circle(2),
  new Square(5),
  new Square(6),
];

$areas = new AreaCalculator($shapes);
$output = new SumCalculatorOutputter($areas);

echo $output->JSON();
echo $output->HTML();

现在,你需要向用户输出数据的逻辑是由SumCalculatorOutputter 类处理的。

这就满足了单一责任原则。

开放-封闭原则

开放-封闭原则(OCP)指出。

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

这意味着一个类应该是可扩展的,而不需要修改该类本身。

让我们重新审视一下AreaCalculator 类,并关注一下sum 方法。

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

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

        return array_sum($area);
    }
}

考虑这样一种情况:用户希望sum 额外的形状,如三角形、五边形、六边形等等。你将不得不不断地编辑这个文件,添加更多的if/else 块。这将违反开放-封闭的原则。

你可以使这个sum 方法变得更好的方法是将计算每个形状的面积的逻辑从AreaCalculator 类方法中移除,并将其附加到每个形状的类中。

这里是定义在Squarearea 方法。

class Square
{
    public $length;

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

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

这里是定义在Circle 中的area 方法。

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }

    public function area()
    {
        return pi() * pow($shape->radius, 2);
    }
}

然后,AreaCalculatorsum 方法可以被改写为。

class AreaCalculator
{
    // ...

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

        return array_sum($area);
    }
}

现在,你可以创建另一个形状类,并在计算总和时传入它而不破坏代码。

然而,另一个问题出现了。你怎么知道传入AreaCalculator 的对象实际上是一个形状,或者这个形状是否有一个名为area 的方法?

对接口进行编码是SOLID的一个组成部分。

创建一个支持areaShapeInterface

interface ShapeInterface
{
    public function area();
}

修改你的形状类,以implementShapeInterface

这里是对Square 的更新。

class Square implements ShapeInterface
{
    // ...
}

这里是对Circle 的更新。

class Circle implements ShapeInterface
{
    // ...
}

AreaCalculatorsum 方法中,你可以检查所提供的形状是否真的是ShapeInterface 的实例;否则,抛出一个异常。

 class AreaCalculator
{
    // ...

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

            throw new AreaCalculatorInvalidShapeException();
        }

        return array_sum($area);
    }
}

这就满足了开放-封闭原则。

利斯科夫替代原则

Liskov置换原则指出。

让q(x)是一个关于类型T的x的对象的可证明的属性。那么q(y)对于S类型的对象y应该是可以证明的,其中S是T的一个子类型。

这意味着每个子类或派生类都应该可以替代它们的基类或父类。

AreaCalculator 类的基础上,考虑一个新的VolumeCalculator 类,它扩展了AreaCalculator 类。

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

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

回顾一下,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(
            '',
                'Sum of the areas of provided shapes: ',
                $this->calculator->sum(),
            ''
        ));
    }
}

如果你试图运行一个这样的例子。

$areas = new AreaCalculator($shapes);
$volumes = new VolumeCalculator($solidShapes);

$output = new SumCalculatorOutputter($areas);
$output2 = new SumCalculatorOutputter($volumes);

当你在$output2 对象上调用HTML 方法时,你会得到一个E_NOTICE 错误,通知你有一个数组到字符串的转换。

为了解决这个问题,不要从VolumeCalculator 类的sum方法中返回一个数组,而要返回$summedData

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

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

$summedData 可以是一个浮点数、双数或整数。

这就满足了Liskov替换原则。

接口隔离原则

接口隔离原则指出。

绝不应该强迫客户实现它不使用的接口,也不应该强迫客户依赖他们不使用的方法。

仍然以之前的ShapeInterface 为例,你需要支持新的三维形状CuboidSpheroid ,这些形状也需要计算volume

让我们考虑一下,如果你修改ShapeInterface ,增加另一个契约,会发生什么。

interface ShapeInterface
{
    public function area();

    public function volume();
}

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

这就违反了接口隔离原则。相反,你可以创建另一个名为ThreeDimensionalShapeInterface 的接口,该接口具有volume 的契约,三维图形可以实现这个接口。

interface ShapeInterface
{
    public function area();
}

interface ThreeDimensionalShapeInterface
{
    public function volume();
}

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }
}

这是一个更好的方法,但需要注意的是在对这些接口进行类型提示时的一个隐患。与其使用ShapeInterfaceThreeDimensionalShapeInterface ,你可以创建另一个接口,也许是ManageShapeInterface ,并在平面和立体图形上实现它。

这样一来,你就可以有一个单一的API来管理这些形状。

interface ManageShapeInterface
{
    public function calculate();
}

class Square implements ShapeInterface, ManageShapeInterface
{
    public function area()
    {
        // calculate the area of the square
    }

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

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface, ManageShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }

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

现在在AreaCalculator 类中,你可以用calculate 替换对area 方法的调用,同时检查对象是否是ManageShapeInterface 的实例而不是ShapeInterface

这就满足了接口隔离原则。

依赖反转原则

依赖性反转原则指出。

实体必须依赖抽象,而不是依赖具体化。它指出,高层模块不能依赖低层模块,但它们应该依赖抽象物。

这一原则允许解耦。

下面是一个连接到MySQL数据库的PasswordReminder 的例子。

class MySQLConnection
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

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

首先,MySQLConnection 是低级模块,而PasswordReminder 是高级模块,但根据SOLID中D的定义,它指出要依赖抽象,而不是依赖凝结。上面的这个片段违反了这一原则,因为PasswordReminder 类被强迫依赖于MySQLConnection 类。

之后,如果你要改变数据库引擎,你也必须编辑PasswordReminder 类,这就违反了开放-封闭原则

PasswordReminder 类不应该关心你的应用程序使用什么数据库。为了解决这些问题,你可以对一个接口进行编码,因为高层和低层的模块应该依赖于抽象。

interface DBConnectionInterface
{
    public function connect();
}

该接口有一个连接方法,MySQLConnection 类实现了这个接口。另外,在PasswordReminder 的构造函数中,不要直接对MySQLConnection 类进行类型提示,而是对DBConnectionInterface 进行类型提示,无论你的应用程序使用哪种类型的数据库,PasswordReminder 类都可以毫无问题地连接到数据库,并且不违反开闭原则。

class MySQLConnection implements DBConnectionInterface
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

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

这段代码证明了高层和低层模块都依赖于抽象。

总结

在这篇文章中,我们向你介绍了SOLID代码的五个原则。遵循SOLID原则的项目可以与合作者共享、扩展、修改、测试和重构,并减少复杂情况。

通过阅读其他敏捷适应性软件开发的实践,你可以继续学习。