聊聊开放封闭原则 (Open/Closed Principle)

勇哥注:

此文章为引用。勇哥在这里只是编写了C#的演示代码,供大家玩读。


最近技术组同事分享了关于 JavaScript 时间循环和任务队列的机制,涉及了异步编程方面的内容,包括定时器、Promise、async/await,于是打算借机会巩固一下这部分知识。随手翻了几篇文章,其中一篇在说到异步编程中回调函数时,提到了 IoC(控制反转)、DI(依赖注入)等设计思想,干脆,把这几个名词弄清楚,于是顺藤摸瓜,牵出了面向对象设计 SOLID 原则(了解什么是SOLID)之开放封闭原则。

对于开放封闭原则,很早就了解过,无奈当时浅尝辄止,没能领会其中奥义,这些年技术方向从后端转向前端,反倒更有兴趣学习这些当初觉得晦涩的知识。查阅了国内的文章,总觉差了些味道,最后还是找到一篇十年前的文章,通俗易懂,豁然开朗。文章内代码是用静态类型语言实现的,我用 JavaScript 实现了一遍,同时奉上译文。

Talk is cheap, show me the code!

原文地址


What is the Open/Closed Principle?(什么是开放封闭原则)

Let’s begin with a short summary of what the Open/Closed Principle is. It’s a principle for object oriented design first described by Bertrand Meyer that says that“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”.

我们先给开放封闭原则做个简短的开场白。开放封闭原则这个面向对象设计思想,是由 Bertrand Meyer 最先进行描述的,他讲到“软件实体(如类、模块、函数等)应该对扩展开放对修改封闭

At first thought that might sound quite academic and abstract. What it means though is that we should strive to write code that doesn’t have to be changed every time the requirements change. How we do that can differ a bit depending on the context, such as our programming language. When using Java, C# or some other statically typed language the solution often involves inheritance and polymorphism, which is what this example will illustrate.

这个描述乍听起来既有些学术风格,又略显抽象。解读其中心思想大致为,我们应努力达到每一次需求变动产生时,不必通过修改代码的方式就能满足的效果。对于不同的上下文环境,如何实现这个效果的方式是有差别的,而这个上下文环境通常是指编程语言。当使用 Java,C# 或者其他静态类型语言时,实现方式中离不开继承与多态,正如下面的例子展示的那样。

译者注:以下示例通过 JavaScript 编写,但仍然展示了面向对象的继承与多态

An example – calculating area(栗子 - 计算图形面积)

Let’s say that we’ve got a Rectangle class. As most rectangles that I’ve encountered it has a width and a height.

我们首先声明一个 Rectangle 类,一般地,长方形拥有宽度和高度。
    function Rectangle(width, height) {
        this.width = width
        this.height = height
    }

Now our customer, Aldford (which apparently means “old river-ford”, did you know that?), wants us to build an application that can calculate the total area of a collection of rectangles.

现在,我们的需求方,Aldford,需要我们编写一个应用,能够计算一系列长方形的总面积

That’s not a problem for us. We learned in school that the area of a rectangle is it’s width multiplied with it’s height and we mastered the for-each-loop a long time ago.

这对我们来说小菜一碟。利用学校里教过的长方形面积等于宽度乘以高度公式,以及我们掌握已久的 for 循环来实现就好了。
    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Rectangle) {
                    area += shape.width * shape.height
                }
            }
            return area
        }
    }

译者注:附上完整代码

/** * 实现了对正方形面积的计算的需求,一切看起来很完美  
 */!(function () {
    function Rectangle(width, height) {
        this.width = width
        this.height = height
    }

    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Rectangle) {
                    area += shape.width * shape.height
                }
            }
            return area
        }
    }

    let rectangles = [new Rectangle(1, 0.5), new Rectangle(20, 10), new Rectangle(3, 4)]
    console.log('总面积为', new AreaCalculator().area(rectangles))})()

We present our solution, the AreaCalculator class to Aldford and he signs us his praise. But he also wonders if we couldn’t extend it so that it could calculate the area of not only rectangles but of circles as well.

我们将方案 AreaCalculator 类提交给 Aldford 并得到了嘉奖。但是他还想知道,如果不对 AreaCalculator 类进行扩展,计算长方形面积的同时是否还能够计算圆形的面积。

That complicates things a bit but after some pondering we with a solution where we change our Area method to accept a collection of objects instead of the more specific Rectangle type. Then we check what type each object is of and finally cast it to it’s type and calculate it’s area using the correct algorithm for the type.

事情开始变得有些棘手了,但是经过一番思考, 我们带来了新的方案,那就是将 area 方法进行改造,不止能够处理长方形集合,还能处理其他对象的集合。通过类型检查的方式,将对象转换为相应的类型,再进行面积计算以保证计算时使用了正确的公式。
译者注:由于 JavaScript 是动态类型语言,所以示例代码中未出现类型的转换,只进行类型检查。
    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Rectangle) {
                    area += shape.width * shape.height
                } else if (shape instanceof Circle) {
                    area += Math.pow(shape.radius, 2) * Math.PI
                } else {
                    area += 0
                }

            }
            return area
        }
    }

译者注:附上完整代码

/** * 现在只计算正方形面积似乎不够了,我们还需要计算圆形的面积,经过一番思考,还是能够掌控局面的 */!(function () {
    function Rectangle(width, height) {
        this.width = width
        this.height = height
    }

    function Circle(radius) {
        this.radius = radius
    }

    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Rectangle) {
                    area += shape.width * shape.height
                } else if (shape instanceof Circle) {
                    area += Math.pow(shape.radius, 2) * Math.PI
                } else {
                    area += 0
                }

            }
            return area
        }
    }

    let shapes = [new Rectangle(1, 0.5), new Rectangle(20, 10), new Circle(3.5), new Circle(1)]
    console.log('总面积为', new AreaCalculator().area(shapes))})()

The solution works and Aldford is happy.

这个方案如预期那样正常执行。

Only, a week later he calls us and asks: “extending the AreaCalculator class to also calculate the area of triangles isn’t very hard, is it?”. Of course in this very basic scenario it isn’t but it does require us to modify the code. That is, AreaCalculator isn’t closed for modification as we need to change it in order to extend it. Or in other words: it isn’t open for extension.

好景不长,一周后 Aldford 打电话给我们并要求:“扩展一下 AreaCalculator 类来支持三角形面积的计算,我想这并不困难,对吧”。诚然,在这个再基础的不过的场景中进行扩展以支持新的业务,并不是什么大事儿,但是修改已有代码逻辑是不可避免的。因为,AreaCalculator 并不是对修改封闭的导致了不得不通过修改代码的方式来扩展它。换句话说,它不是对扩展开放

In a real world scenario where the code base is ten, a hundred or a thousand times larger and modifying the class means redeploying it’s assembly/package to five different servers that can be a pretty big problem. Oh, and in the real world Aldford would have changed the requirements five more times since you read the last sentence :-)

然而回到现实场景,代码量则是十倍、百倍乃至千倍之多,并且对类的修改意味着将 assembly/package 重新部署到五个服务器将成为一个大麻烦。还有,在现实场景中,当你读到这里时,Aldford 可能已经进行了五次需求变更 ;-)

A solution that abides by the Open/Closed Principle(遵循开放封闭原则的方案)

One way of solving this puzzle would be to create a base class for both rectangles and circles as well as any other shapes that Aldford can think of which defines an abstract method for calculating it’s area.

解决这个耦合度的问题,或许可以采用创建一个基类的方式,作为长方形和圆形以及任何其他 Aldford 能够想到的图形的基类,并在其中定义一个计算面积的抽象方法。

译者注:抽象方法仅包含方法的声明,而不提供实现,并强制其派生类对抽象方法进行重写(override),JavaScript 没有抽象方法的概念,这里通过继承的特性强制派生类进行重写。

    // 基类 Shape    function Shape() {
        this.area = function () {
            throw new Error('未对方法进行重写')
        }
    }

Inheriting from Shape the Rectangle and Circle classes now looks like this:

有了基类,那么其派生类 Rectangle 和 Circle 是长这个样子:

译者注:这里继承的写法未使用 ES6 中的 extends 关键字,但通过 Object.create 与 Object.setPrototypeOf 实现了相同的效果

    // 派生类 Rectangle    function Rectangle(width, height) {
        Shape.call(this)
        this.width = width
        this.height = height
        this.area = function () {
            return this.width * this.height
        }
    }
    Rectangle.prototype = Object.create(Shape.prototype, {
        constructor: {
            value: Rectangle,
            writable: true,
            enumerable: true,
        }
    })
    Object.setPrototypeOf(Rectangle, Shape)

    // 派生类 Circle    function Circle(radius) {
        Shape.call(this)
        this.radius = radius
        this.area = function () {
            return Math.pow(this.radius, 2) * Math.PI
        }
    }
    Circle.prototype = Object.create(Shape.prototype, {
        constructor: {
            value: Circle,
            writable: true,
            enumerable: true,
        }
    })
    Object.setPrototypeOf(Circle, Shape)

As we’ve moved the responsibility of actually calculating the area away from AreaCalculator’s Area method it is now much simpler and robust as it can handle any type of Shape that we throw at it.

当我们把进行面积计算的职责从 AreaCalculator 类的 area 方法中移走之后,这个方法就进化得更加简明且健壮,具备了可以处理任何我们丢进去(注入:-p)的图形对象的能力。
    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Shape) {
                    area += shape.area()
                }
            }
            return area
        }
    }

In other words we’ve closed it for modification by opening it up for extension.

扣题,现在的实现达到了对扩展开放对修改关闭的效果。

译者注:附上完整代码

!(function () {
    // 基类 Shape    function Shape() {
        this.area = function () {
            throw new Error('未对方法进行重写')
        }
    }

    // 派生类 Rectangle    function Rectangle(width, height) {
        Shape.call(this)
        this.width = width
        this.height = height
        this.area = function () {
            return this.width * this.height
        }
    }
    Rectangle.prototype = Object.create(Shape.prototype, {
        constructor: {
            value: Rectangle,
            writable: true,
            enumerable: true,
        }
    })
    Object.setPrototypeOf(Rectangle, Shape)

    // 派生类 Circle    function Circle(radius) {
        Shape.call(this)
        this.radius = radius
        this.area = function () {
            return Math.pow(this.radius, 2) * Math.PI
        }
    }
    Circle.prototype = Object.create(Shape.prototype, {
        constructor: {
            value: Circle,
            writable: true,
            enumerable: true,
        }
    })
    Object.setPrototypeOf(Circle, Shape)

    // 重构后的 AreaCalculator 与低层模块解耦了,并且保留了类型的校验    // #TIP 高层模块与低层模块,是IoC中的概念    function AreaCalculator() {
        this.area = function (shapes) {
            let area = 0
            for (let shape of shapes) {
                if (shape instanceof Shape) {
                    area += shape.area()
                }
            }
            return area
        }
    }

    let shapes = [new Rectangle(1, 0.5), new Rectangle(20, 10), new Circle(3.5), new Circle(1)]
    console.log('总面积为', new AreaCalculator().area(shapes))

    // 现在,我们看看如何应对计算三角形面积的需求
    // 派生类 Triangle    function Triangle(base, height) {
        Shape.call(this)
        this.base = base
        this.height = height
        this.area = function () {
            return this.base * this.height * 0.5
        }
    }
    Triangle.prototype = Object.create(Shape.prototype, {
        constructor: {
            value: Triangle,
            writable: true,
            enumerable: true,
        }
    })
    Object.setPrototypeOf(Triangle, Shape)

    shapes.push(new Triangle(4, 3))
    console.log('总面积为', new AreaCalculator().area(shapes))})()

When should we apply the Open/Closed Principle?(开放封闭原则应用于什么场景)

If we look back our previous example, where did we go wrong? Clearly even our first implementation of the Area wasn’t open for extension. Should it have been? I’d say that it all depends on context. If we had had very strong suspicions that Aldford would ask us to support other shapes later on we could probably have prepared for that from the get-go. However, often it’s not a good idea to try to anticipate changes in requirements ahead of time, as at least my psychic abilities haven’t surfaced yet and preparing for future changes can easily lead to overly complex designs. Instead, I would suggest that we focus on writing code that is well written enough so that it’s easy to change if the requirements change.

回顾一下应用开闭原则之前的场景,是哪里出了问题呢?很明显,对 area 方法的实现不是对扩展开放的。那么问题来了,这是必须的吗?我只能说,这完全取决于上下文场景。设想,如果我们从一开始就对 Aldford 的需求(稳定性)表示怀疑,并且预料到后续会有对其他图形进行面积计算的要求,那也许就可以在第一时间着手准备了。然而,在需求发生变化前试图揣测并进行预判,显然以我现在的第六感能力还做不到 ;-D,并且对未发生的变化进行预设很容易踏入过渡设计的陷阱,俗语有云,过犹不及。那么,我会建议更多关注如何将代码实现得容易扩展以应对变化的需求。

Once the requirements do change though it’s quite likely that they will change in a similar way again later on. That is, if Aldford asks us to support another type of shape it’s quite likely that he soon will ask for support for a third type of shape.

一旦需求开始发生变化,那它很可能会以相似的方式一变再变。正如之前场景,Aldford 希望我们支持第二种图形的面积计算后,很快就提出需要支持第三种,甚至第四种。

So, in other words, I definitely think we should have put some effort into abiding by the open/closed principle once the requirements started changing. Before that, in most cases, I would suggest limiting your efforts to ensuring that the code is well written enough so that it’s easy to refactor if the requirements starts changing.

换句话说,一旦需求开始发生变化,我们就应该在代码循环开放封闭原则上多下功夫。在大多数场景中,在需求发生变化之前,我建议避免过度设计,使代码能够支持业务即可,从而在需求开始变化之初,能够更容易地进行重构。

参考文章:

ES6里Class的Extends继承原理_个人文章 - SegmentFault 思否

JavaScript对象的property属性详解 - byd张小伟 - 博客园



下面是勇哥阅读后做的的C# 练习:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApplication4
{
    class Program
    {
        /*
        我们先给开放封闭做个简短的开场白。开放封闭原则这个面向对象设计思想,是由
        Bertrand Meyer最先进行描述的,他讲到“软件实体(如类、模块、函数等)应该对
        扩展开放,对修改封闭”

        这个描述乍听起来即有些学术风格,又略显抽象。解读其中心思想大致为:
        我们应努力达到每一次需求变动产生时,不必通过修改代码的方式就能满足的效果。
        对于不同的上下文环境,如何实现这个效果的方式也是有差别的,而这个上下文环境通常是指编程语言。
        当使用java,C# 或者其它静态类型语言时,实现方式中离不开继承与多态,正如下面的例子展示的那样。
        
          
           
         我们将方案 AreaCalculator 类提交给 Aldford 并得到了嘉奖。
         但是他还想知道,如果不对 AreaCalculator 类进行扩展,
         计算长方形面积的同时是否还能够计算圆形的面积。 



          事情开始变得有些棘手了,但是经过一番思考, 我们带来了新的方案,
          那就是将 area 方法进行改造,不止能够处理长方形集合,还能处理其他对象的集合。
          通过类型检查的方式,将对象转换为相应的类型,再进行面积计算以保证计算时使用了正确的公式。


            这个方案如预期那样正常执行。


            好景不长,一周后 Aldford 打电话给我们并要求:
            “扩展一下 AreaCalculator 类来支持三角形面积的计算,我想这并不困难,对吧”。
            诚然,在这个再基础的不过的场景中进行扩展以支持新的业务,
            并不是什么大事儿,但是修改已有代码逻辑是不可避免的。
            因为,AreaCalculator 并不是对修改封闭的导致了不得不通过修改代码的方式来扩展它。
            换句话说,它不是对扩展开放的


            然而回到现实场景,代码量则是十倍、百倍乃至千倍之多,并且对类的修改意味着将 
            assembly/package 重新部署到五个服务器将成为一个大麻烦。
            还有,在现实场景中,当你读到这里时,Aldford 可能已经进行了五次需求变更 ;-)


            解决这个耦合度的问题,或许可以采用创建一个基类的方式,作为长方形和圆形以及任何其他 
            Aldford 能够想到的图形的基类,并在其中定义一个计算面积的抽象方法。

            译者注:抽象方法仅包含方法的声明,而不提供实现,并强制其派生类对抽象方法进行重写(override)
            
            
            有了基类,那么其派生类 Rectangle 和 Circle 是长这个样子:


            当我们把进行面积计算的职责从 AreaCalculator 类的 area 方法中移走之后,
            这个方法就进化得更加简明且健壮,具备了可以处理任何我们丢进去(注入:-p)的图形对象的能力。


            扣题,现在的实现达到了对扩展开放而对修改关闭的效果。

            开放封闭原则应用于什么场景?

            回顾一下应用开闭原则之前的场景,是哪里出了问题呢?很明显,
            对 area 方法的实现不是对扩展开放的。
            那么问题来了,这是必须的吗?我只能说,这完全取决于上下文场景。
            设想,如果我们从一开始就对 Aldford 的需求(稳定性)表示怀疑,
            并且预料到后续会有对其他图形进行面积计算的要求,那也许就可以在第一时间着手准备了。
            然而,在需求发生变化前试图揣测并进行预判,显然以我现在的第六感能力还做不到 ;-D,
            并且对未发生的变化进行预设很容易踏入过渡设计的陷阱,俗语有云,过犹不及。
            那么,我会建议更多关注如何将代码实现得容易扩展以应对变化的需求。


            一旦需求开始发生变化,那它很可能会以相似的方式一变再变。正如之前场景,
            Aldford 希望我们支持第二种图形的面积计算后,很快就提出需要支持第三种,甚至第四种。


            换句话说,一旦需求开始发生变化,我们就应该在代码循环开放封闭原则上多下功夫。
            在大多数场景中,在需求发生变化之前,我建议避免过度设计,使代码能够支持业务即可,
            从而在需求开始变化之初,能够更容易地进行重构。


            */


        static void Main(string[] args)
        {
            AreaCalculator cal = new AreaCalculator();
            var list1 = new List<Shape>();
            list1.Add(new Rectangle() { width = 1, height = 0.5 });
            list1.Add(new Rectangle() { width = 20, height = 10 });
            list1.Add(new Rectangle() { width = 3, height = 4 });
            list1.Add(new Circle() { radius=3.5 });
            list1.Add(new TriShape() { bottom = 10, height = 6 });
            double area = cal.calArea(list1);
            Console.WriteLine($"area={area.ToString()}");
            Console.ReadKey();
        }
    }

    public class AreaCalculator
    {
        private double area { get; set; }

        public double calArea(List<Shape> shapes)
        {
            foreach(var m in shapes)
            {
                this.area += m.area();
            }
            return this.area;
        }


    }


    public abstract class Shape
    {
        public abstract double area();
    }


    public class TriShape:Shape
    {
        public double bottom { get; set; }
        public double height { get; set; }

        public override double area()
        {
            return bottom * (height / 2);
        }
    }


    public class Rectangle: Shape
    {
        public double width { get; set; }
        public double height { get; set; }

        public override double area()
        {
            return this.width * this.height;
        }
    }

    public class Circle:Shape
    {
        public double radius { get; set; }

        public override double area()
        {
            return this.radius * Math.PI;
        }
    }
}



本文出自勇哥的网站《少有人走的路》wwww.skcircle.com,转载请注明出处!讨论可扫码加群:
本帖最后由 勇哥,很想停止 于 2023-11-15 13:42:23 编辑

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

会员中心
搜索
«    2024年4月    »
1234567
891011121314
15161718192021
22232425262728
2930
网站分类
标签列表
最新留言
    热门文章 | 热评文章 | 随机文章
文章归档
友情链接
  • 订阅本站的 RSS 2.0 新闻聚合
  • 扫描加本站机器视觉QQ群,验证答案为:halcon勇哥的机器视觉
  • 点击查阅微信群二维码
  • 扫描加勇哥的非标自动化群,验证答案:C#/C++/VB勇哥的非标自动化群
  • 扫描加站长微信:站长微信:abc496103864
  • 扫描加站长QQ:
  • 扫描赞赏本站:
  • 留言板:

Powered By Z-BlogPHP 1.7.2

Copyright Your skcircle.com Rights Reserved.

鄂ICP备18008319号


站长QQ:496103864 微信:abc496103864