“开闭原则”背后的意义和理由是什么?

62

开闭原则指出,软件实体(类、模块等)应该对扩展开放,但对修改关闭。这意味着什么,为什么它是良好面向对象设计的重要原则?

14个回答

71

这意味着你应该将新代码放到新的类/模块中。只有在修复错误时才应修改现有代码。 新类可以通过继承重用现有代码。

开闭原则旨在减轻引入新功能时的风险。由于不修改现有代码,因此可以确保它不会被破坏。 它降低了维护成本并增加了产品的稳定性。


8
确实,这就是OCP的意义所在。不过,我相信“降低维护成本和提高产品稳定性”的目标可以通过采用KISS、YAGNI、敏捷开发、TDD和重构等方式来实现,始终保持良好的自动化测试套件作为安全网。 - Rogério

32

具体而言,这是关于在面向对象编程(OOP)中设计的一种“圣杯”,即使实体的代码不需要重写(有时甚至无需重新编译),通过个体设计或参与架构支持未来无法预见的变化。

实现这种目标的方法包括多态/继承(Polymorphism/Inheritance)、组合(Composition)、控制反转(Inversion of Control,也称为DIP)、面向切面编程(Aspect-Oriented Programming)、模式,如策略模式(Strategy)、访问者模式(Visitor)、模板方法模式(Template Method)以及OOAD中的许多其他原则、模式和技术。

** 请参阅六个“包原则”:REP、CCP、CRP、ADP、SDP、SAP


所有这些“做这件事的方式”都涉及到继承吗?(我不喜欢创建接口,以防将来可能需要多种方法来完成某件事情。) - Paul Sumpner
@PaulSumpner 一些非继承的例子:组合,控制反转(又称DIP),面向切面编程,模式如策略、访问者。 - Troy DeMonbreun

11

比DaveK的更具体,通常意味着如果想要添加额外的功能或更改类的功能,则创建一个子类而不是更改原始类。这样,使用父类的任何人都不必担心以后的更改。基本上,它都是关于向后兼容性的。

另一个非常重要的面向对象设计原则是通过方法接口实现松散耦合。如果您要进行的更改不会影响现有接口,那么更改是相当安全的,例如使算法更加有效率。当然,面向对象的原则也需要适度运用常识 :)


9
让我们将问题分解成三个部分,以便更容易理解各种概念。

开闭原则背后的推理

考虑以下代码示例。不同类型的车辆需要以不同的方式进行维修。因此,我们有了 BikeCar 两个不同的类,因为维修 Bike 的策略与维修 Car 的策略不同。Garage 类接受各种类型的车辆进行维修。

僵化性问题

观察以下代码,看看当引入新功能时,Garage 类如何展现出僵化性的迹象:
class Bike {
    public void service() {
        System.out.println("Bike servicing strategy performed.");
    }
}

class Car {
    public void service() {
        System.out.println("Car servicing strategy performed.");
    }
}

class Garage {
    public void serviceBike(Bike bike) {
        bike.service();
    }

    public void serviceCar(Car car) {
        car.service();
    }
}

如您所见,每当需要维修新的交通工具(例如TruckBus)时,就需要修改Garage以定义一些新方法,例如serviceTruck()serviceBus()。这意味着Garage类必须知道所有可能的车辆类型,例如BikeCarBusTruck等等。因此,它违反了开闭原则,因为它对修改是开放的。同时,它也不支持扩展,因为要扩展新功能,我们需要修改类。

开闭原则背后的含义

抽象

为解决上述代码中的刚性问题,我们可以使用开闭原则。这意味着我们需要剥离Garage类的实现细节,使其变得简单。换句话说,我们应该为每种具体类型(如BikeCar)抽象出服务策略的实现细节。

为了抽象各种类型车辆的服务策略的实现细节,我们使用一个名为Vehicle的接口,并在其中有一个抽象方法service()

多态

同时,我们也希望Garage类接受各种形式的车辆,例如BusTruck等,而不仅仅是BikeCar。为此,开闭原则使用多态(多种形式)。

为了使Garage类接受多种形式的Vehicle,我们将其方法的签名更改为service(Vehicle vehicle) { }以接受接口Vehicle而不是实际实现,如BikeCar等。我们还从该类中删除多个方法,因为一个方法将接受多种形式。

interface Vehicle {
    void service();
}

class Bike implements Vehicle {
    @Override
    public void service() {
        System.out.println("Bike servicing strategy performed.");
    }
}

class Car implements Vehicle {
    @Override
    public void service() {
        System.out.println("Car servicing strategy performed.");
    }
}

class Garage {
    public void service(Vehicle vehicle) {
        vehicle.service();
    }
}

开闭原则的重要性

对修改关闭

从上面的代码可以看到,现在Garage类已经变成了对修改关闭。因为它不需要知道各种类型车辆维修策略的实现细节,只需将新的Vehicle接口的派生类扩展并传递给Garage就行了!我们不需要修改Garage类里的任何代码。

另一个对修改关闭的实体是Vehicle接口。我们不需要更改接口来扩展软件功能。

对扩展开放

Garage类现在变成了对扩展开放状态,支持不同类型的Vehicle,而无需进行修改。

我们的Vehicle接口是对扩展开放的,因为我们可以通过扩展Vehicle接口并提供适用于该特定车辆的服务策略的新实现来引入任何新车。

策略设计模式

您有没有注意到我多次使用“策略”这个词?这也是策略设计模式的一个例子。我们可以通过扩展Vehicle接口来为不同类型的车辆实现不同的服务策略。例如,维修Truck与维修Bus有不同的策略。因此,我们在不同的派生类中实现这些策略。

策略模式允许我们的软件随着时间推移而变得灵活。每当客户端更改其策略时,只需为其派生一个新类并将其提供给现有组件即可,无需更改其他内容!开闭原则在实现此模式中扮演了重要角色。


就是这样!希望对您有所帮助。


1
讲解得非常好。 - Sanoj Kashyap

9

开闭原则是面向对象编程中非常重要的一个原则,它是SOLID原则之一。

按照这个原则,一个类应该对扩展开放,对修改关闭。让我们理解一下为什么。

class Rectangle {
    public int width;
    public int lenth;
}

class Circle {
    public int radius;
}

class AreaService {
    public int areaForRectangle(Rectangle rectangle) {
        return rectangle.width * rectangle.lenth;
    }

    public int areaForCircle(Circle circle) {
        return (22 / 7) * circle.radius * circle.radius;
    }
}

如果您看上面的设计,我们可以清楚地观察到它没有遵循“开放/封闭原则”。每当有一个新的形状(三角形、正方形等),AreaService 就必须被修改。
使用开放/封闭原则:
interface Shape{
    int area();
}

class Rectangle implements Shape{
    public int width;
    public int lenth;

    @Override
    public int area() {
        return lenth * width;
    }
}

class Cirle implements Shape{
    public int radius;

    @Override
    public int area() {
        return (22/7) * radius * radius;
    }
}

class AreaService {
    int area(Shape shape) {
        return shape.area();
    }
}

无论是三角形还是正方形等新的形状,都可以轻松地适应新的形状,而无需修改现有的类。通过这种设计,我们可以确保现有的代码不会受到影响。

这个更改破坏了单一职责原则,因为形状计算自己的面积。 - User 10482

8

软件实体应该对扩展开放,但对修改关闭

这意味着任何类或模块都应该以一种可用于现有情况、可以扩展,但永远不会修改的方式编写

Javascript中的坏例子

var juiceTypes = ['Mango','Apple','Lemon'];
function juiceMaker(type){
    if(juiceTypes.indexOf(type)!=-1)
        console.log('Here is your juice, Have a nice day');
    else
        console.log('sorry, Error happned');
}

exports.makeJuice = juiceMaker;

现在,如果你想添加另一种果汁类型,你必须编辑模块本身,这样做会破坏开放封闭原则。

Javascript中的好例子

var juiceTypes = [];
function juiceMaker(type){
    if(juiceTypes.indexOf(type)!=-1)
        console.log('Here is your juice, Have a nice day');
    else
        console.log('sorry, Error happned');
}
function addType(typeName){
    if(juiceTypes.indexOf(typeName)==-1)
        juiceTypes.push(typeName);
}
function removeType(typeName){
  let index = juiceTypes.indexOf(typeName)
    if(index!==-1)
        juiceTypes.splice(index,1);
}

exports.makeJuice = juiceMaker;
exports.addType = addType;
exports.removeType = removeType;

现在,您可以在不编辑同一模块的情况下从外部添加新的果汁类型。

实际上,实现“可以扩展,但不能修改”的要求是不可能的。 - undefined

6

这是解决“脆弱基类问题”的答案,该问题指出对基类的看似无害的修改可能会对依赖于先前行为的继承者产生意想不到的后果。因此,您必须小心地封装您不希望被依赖的内容,以便派生类将遵守基类定义的契约。一旦继承者存在,您必须非常小心地更改基类中的内容。


3
我觉得这应该是LSP(里氏替换原则)吧? - Rogério

4
Open Closed原则是SOLID原则中的一个,其目的在于:
  1. 减少业务变更需求的成本。
  2. 减少对现有代码的测试。

开闭原则的基本思想是:当我们添加新功能时,尽量不要修改现有代码。这基本上意味着现有代码应该开放扩展,关闭修改(除非现有代码中存在错误)。如果在添加新功能时修改现有代码,则需要再次测试现有功能。

让我通过以AppLogger util类为例来解释这一点。

假设我们有一个需求,将应用程序范围内的错误记录到名为Firebase的在线工具中。因此,我们创建以下类并在1000处使用它,以记录API错误、内存不足等:

open class AppLogger {

    open fun logError(message: String) {
        // reporting error to Firebase
        FirebaseAnalytics.logException(message)
    }
}

假设一段时间后,我们为应用程序添加了支付功能,并有一个新需求,规定仅在与支付相关的错误时,我们必须使用名为Instabug的新报告工具,同时对于包括支付在内的所有功能,仍然像以前一样继续向Firebase报告错误。
现在,我们可以通过在现有方法中添加if else条件来实现这一点。
fun logError(message: String, origin: String) {
    if (origin == "Payment") {
        //report to both Firebase and Instabug
        FirebaseAnalytics.logException(message)
        InstaBug.logException(message)
    } else {
        // otherwise report only to Firebase
        FirebaseAnalytics.logException(message)
    }
}

这种方法的问题在于它违反了单一职责原则,该原则规定一个方法只应该完成一项任务。换句话说,一个方法只应该有一个变化的原因。使用这种方法会有两个原因可能导致方法发生变化(if和else块)。

更好的方法是通过继承现有的Logger类来创建一个新的Logger类,如下所示。

class InstaBugLogger : AppLogger() {

    override fun logError(message: String) {
        super.logError(message) // This uses AppLogger.logError to report to Firebase.
        InstaBug.logException(message) //Reporting to Instabug
    }
}

现在我们只需要在支付功能中使用InstaBugLogger.logError()来将错误日志记录到Instabug和Firebase。这样,我们就可以将新的错误报告需求的测试缩减/隔离到仅支付功能中,因为代码更改仅在支付功能中进行。应用程序的其余功能不需要测试,因为现有的日志记录器没有进行代码更改。

2

遵循OCP的另一个经验法则是使基类针对派生类提供的功能具有抽象性。如Scott Meyers所说,'将非叶子类变为抽象类'。

这意味着在基类中有未实现的方法,并且只在没有子类的类中实现这些方法。这样,基类的客户端就不能依赖于基类中的特定实现,因为没有这种实现。


2

开放封闭原则是指在不改变已有的稳定和经过测试的功能的情况下,容易添加新功能,从而节省时间和金钱。通常,采用多态性,例如使用接口,是实现这一目标的好工具。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接