天哪,对OCP和LSP有一些奇怪的误解,还有一些是由于某些术语和示例不匹配而造成的。如果您以相同的方式实现它们,那么这两个原理只是“同一件事”。模式通常以一种或另一种方式遵循原则,只有少数例外。
差异将在下面进一步解释,但首先让我们深入了解原理本身:
开闭原则(OCP)
据鲍伯叔叔说:
您应该能够扩展类的行为,而无需对其进行修改。
请注意,在这种情况下,扩展一词不一定意味着您应该将需要新行为的实际类子类化。看到我最初提到的术语不匹配之处是什么?关键字extend
仅表示Java中的子类,但是其原理比Java古老。
原始图片来自1988年的Bertrand Meyer:
软件实体(类,模块,功能等)应打开以进行扩展,但应关闭以进行修改。
在这里,将原理应用于软件实体更加清楚。一个不好的例子是在您完全修改代码而不是提供某些扩展点时覆盖软件实体。软件实体本身的行为应该是可扩展的,并且一个很好的例子是Strategy-pattern的实现(因为这是最简单的GoF-patterns串IMHO展示):
// Context is closed for modifications. Meaning you are
// not supposed to change the code here.
public class Context {
// Context is however open for extension through
// this private field
private IBehavior behavior;
// The context calls the behavior in this public
// method. If you want to change this you need
// to implement it in the IBehavior object
public void doStuff() {
if (this.behavior != null)
this.behavior.doStuff();
}
// You can dynamically set a new behavior at will
public void setBehavior(IBehavior behavior) {
this.behavior = behavior;
}
}
// The extension point looks like this and can be
// subclassed/implemented
public interface IBehavior {
public void doStuff();
}
另外,在上述的例子中Context
被锁定的进一步的修改。大多数程序员可能想对类进行子类化以扩展它,但是在这里我们不这样做,因为它假定可以通过实现接口的任何方式来更改其行为IBehavior
。
即,上下文类关闭以进行修改,但打开以进行扩展。实际上,它遵循另一个基本原理,因为我们将行为与对象组成而不是继承结合在一起:
“ 从' 类继承 ' 到' 对象 '的偏爱 '。” (四人帮1995:20)
我会让读者阅读该原理,因为它不在本问题的范围之内。要继续该示例,请说我们具有IBehavior接口的以下实现:
public class HelloWorldBehavior implements IBehavior {
public void doStuff() {
System.println("Hello world!");
}
}
public class GoodByeBehavior implements IBehavior {
public void doStuff() {
System.out.println("Good bye cruel world!");
}
}
使用此模式,我们可以通过将setBehavior
方法作为扩展点来在运行时修改上下文的行为。
// in your main method
Context c = new Context();
c.setBehavior(new HelloWorldBehavior());
c.doStuff();
// prints out "Hello world!"
c.setBehavior(new GoodByeBehavior());
c.doStuff();
// prints out "Good bye cruel world!"
因此,每当要扩展“封闭”上下文类时,都可以通过将其“开放”协作依赖项子类化来实现。显然,这与对上下文本身进行子类化是不同的,但它是OCP。LSP对此也没有提及。
用Mixins扩展而不是继承
除子类化外,还有其他方法可以执行OCP。一种方法是通过使用mixins来使您的类保持开放状态以进行扩展。例如,这在基于原型而非基于类的语言中很有用。这个想法是根据需要修改具有更多方法或属性的动态对象,换句话说就是与其他对象融合或“混合”的对象。
这是一个mixin的javascript示例,可为锚点呈现一个简单的HTML模板:
// The mixin, provides a template for anchor HTML elements, i.e. <a>
var LinkMixin = {
render: function() {
return '<a href="' + this.link +'">'
+ this.content
+ '</a>;
}
}
// Constructor for a youtube link
var YoutubeLink = function(content, youtubeId) {
this.content = content;
this.setLink(this.youtubeId);
};
// Methods are added to the prototype
YoutubeLink.prototype = {
setLink: function(youtubeid) {
this.link = 'http://www.youtube.com/watch?v=' + youtubeid;
}
};
// Extend YoutubeLink prototype with the LinkMixin using
// underscore/lodash extend
_.extend(YoutubeLink.protoype, LinkMixin);
// When used:
var ytLink = new YoutubeLink("Cool Movie!", "idOaZpX8lnA");
console.log(ytLink.render());
// will output:
// <a href="http://www.youtube.com/watch?=vidOaZpX8lnA">Cool Movie!</a>
这个想法是动态扩展对象,这样做的好处是即使对象处于完全不同的域中,它们也可以共享方法。在上述情况下,您可以通过扩展特定的实现,轻松创建其他类型的html锚LinkMixin
。
就OCP而言,“ mixins”是扩展名。在上面的示例中,YoutubeLink
是我们的软件实体,已关闭以供修改,但通过使用mixins进行扩展以供扩展。对象层次结构被展平,这使得无法检查类型。但这并不是一件坏事,我将进一步解释说,检查类型通常不是一个好主意,并且会通过多态性破坏这个主意。
请注意,由于大多数extend
实现可以混合多个对象,因此可以使用此方法进行多重继承:
_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);
您唯一需要记住的是不要冲突名称,即,mixin碰巧定义了某些属性或方法的名称,因为它们将被覆盖。以我的拙劣经验,这不是问题,如果确实发生,则表明设计存在缺陷。
里斯科夫的替代原则(LSP)
鲍勃叔叔的定义很简单:
派生类必须可以替代其基类。
这个原则是古老的,实际上Bob叔叔的定义没有区别原则,因为在上述Strategy示例中,使用了相同的超类型,使得LSP仍然与OCP紧密相关IBehavior
。因此,让我们看一下Barbara Liskov最初的定义,看看是否可以找到关于该原理的其他一些东西,就像数学定理一样:
这里需要的是类似以下替换属性的内容:如果对于每个o1
类型S
的对象o2
,都有一个对象类型T
,使得对于所有P
根据定义的程序T
,P
当o1
替换时的行为不变,o2
则S
是的子类型T
。
让我们对此稍作耸耸肩,注意,因为它根本没有提到类。在JavaScript中,即使它不是显式基于类的,您实际上也可以遵循LSP。如果您的程序包含至少两个JavaScript对象的列表,则这些列表:
- 需要以相同的方式计算
- 具有相同的行为,并且
- 否则在某种程度上完全不同
...然后将这些对象视为具有相同的“类型”,这对于程序来说并不重要。这本质上是多态性。从一般意义上讲;如果您使用的是接口,则不需要知道实际的子类型。OCP对此没有明确说明。实际上,它还指出了大多数新手程序员所做的设计错误:
每当您想检查对象的子类型的渴望时,您很可能会错误地执行它。
好了,所以它可能不是错所有的时间,但如果你有冲动做一些类型检查用instanceof
或枚举,你可能会做的节目多一点令人费解自己比它需要。但这并非总是如此。如果解决方案足够小,并且可以进行无情的重构,那么快速而又肮脏的hack使事情正常运行是我可以想到的让步,如果更改需要,它可能会得到改善。
可以根据实际问题解决“设计错误”的方法:
- 超类没有调用先决条件,而是强制调用者这样做。
- 超类缺少调用者需要的通用方法。
两者都是常见的代码设计“错误”。您可以执行几种不同的重构,例如上拉方法,或重构为类似Visitor模式的模式。
我实际上非常喜欢Visitor模式,因为它可以处理大型的if语句意大利面条,并且比您在现有代码上的想法更易于实现。说我们有以下上下文:
public class Context {
public void doStuff(string query) {
// outcome no. 1
if (query.Equals("Hello")) {
System.out.println("Hello world!");
}
// outcome no. 2
else if (query.Equals("Bye")) {
System.out.println("Good bye cruel world!");
}
// a change request may require another outcome...
}
}
// usage:
Context c = new Context();
c.doStuff("Hello");
// prints "Hello world"
c.doStuff("Bye");
// prints "Bye"
可以将if语句的结果转换成自己的访问者,因为每个结果都取决于某些决策和要运行的某些代码。我们可以像这样提取这些:
public interface IVisitor {
public bool canDo(string query);
public void doStuff();
}
// outcome 1
public class HelloVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Hello");
}
public void doStuff() {
System.out.println("Hello World");
}
}
// outcome 2
public class ByeVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Bye");
}
public void doStuff() {
System.out.println("Good bye cruel world");
}
}
在这一点上,如果程序员不了解Visitor模式,那么他将实现Context类以检查其是否为某种特定类型。由于Visitor类具有boolean canDo
方法,因此实现者可以使用该方法调用来确定它是否是执行此工作的正确对象。上下文类可以使用所有访问者(并添加新访问者),如下所示:
public class Context {
private ArrayList<IVisitor> visitors = new ArrayList<IVisitor>();
public Context() {
visitors.add(new HelloVisitor());
visitors.add(new ByeVisitor());
}
// instead of if-statements, go through all visitors
// and use the canDo method to determine if the
// visitor object is the right one to "visit"
public void doStuff(string query) {
for(IVisitor visitor : visitors) {
if (visitor.canDo(query)) {
visitor.doStuff();
break;
// or return... it depends if you have logic
// after this foreach loop
}
}
}
// dynamically adds new visitors
public void addVisitor(IVisitor visitor) {
if (visitor != null)
visitors.add(visitor);
}
}
两种模式都遵循OCP和LSP,但是它们都针对它们确定了不同的地方。那么,如果代码违反其中一项原则,它的外观如何?
违反一个原则但遵循另一个原则
有多种方法可以打破其中一个原则,但仍然可以遵循另一个原则。出于充分的原因,以下示例似乎是人为的,但实际上我已经在生产代码中看到了这些示例(甚至更糟):
遵循OCP但不遵循LSP
假设我们有给定的代码:
public interface IPerson {}
public class Boss implements IPerson {
public void doBossStuff() { ... }
}
public class Peon implements IPerson {
public void doPeonStuff() { ... }
}
public class Context {
public Collection<IPerson> getPersons() { ... }
}
这段代码遵循开闭原则。如果我们调用上下文的GetPersons
方法,我们将得到一群拥有自己的实现的人。这意味着IPerson已关闭以进行修改,但可以进行扩展。但是,当我们不得不使用它时,事情就发生了转机:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// now we have to check the type... :-P
if (person instanceof Boss) {
((Boss) person).doBossStuff();
}
else if (person instanceof Peon) {
((Peon) person).doPeonStuff();
}
}
您必须进行类型检查和类型转换!还记得我上面提到的类型检查如何是一件坏事吗?不好了!但是不要担心,正如上面也提到的那样,要么做一些上拉重构,要么实现一个Visitor模式。在这种情况下,我们可以在添加常规方法后简单地进行上拉重构:
public class Boss implements IPerson {
// we're adding this general method
public void doStuff() {
// that does the call instead
this.doBossStuff();
}
public void doBossStuff() { ... }
}
public interface IPerson {
// pulled up method from Boss
public void doStuff();
}
// do the same for Peon
现在的好处是,您不再需要遵循LSP知道确切的类型:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// yay, no type checking!
person.doStuff();
}
遵循LSP但不遵循OCP
让我们看一下遵循LSP但不遵循OCP的一些代码,虽然有些人为设计,但请允许我对此进行编码,这是一个非常微妙的错误:
public class LiskovBase {
public void doStuff() {
System.out.println("My name is Liskov");
}
}
public class LiskovSub extends LiskovBase {
public void doStuff() {
System.out.println("I'm a sub Liskov!");
}
}
public class Context {
private LiskovBase base;
// the good stuff
public void doLiskovyStuff() {
base.doStuff();
}
public void setBase(LiskovBase base) { this.base = base }
}
该代码执行LSP,因为上下文可以在不知道实际类型的情况下使用LiskovBase。您可能认为此代码也遵循OCP,但仔细观察一下,该类是否真的关闭了?如果该doStuff
方法不仅仅是打印一行,该怎么办?
如果遵循OCP,答案很简单:否,这不是因为在此对象设计中,我们需要使用其他内容完全覆盖代码。这打开了蠕虫的剪切和粘贴功能,因为您必须从基类中复制代码才能使工作正常。该doStuff
方法确定可以扩展,但尚未完全关闭以进行修改。
我们可以对此应用模板方法模式。模板方法模式在框架中非常常见,以至于您可能在不知道它的情况下就一直在使用它(例如,java swing组件,c#表单和组件等)。这是一种关闭doStuff
修改方法并通过使用java的final
关键字将其标记为关闭的方法。该关键字可以防止任何人进一步对该类进行子类化(在C#中,您可以sealed
用来做同样的事情)。
public class LiskovBase {
// this is now a template method
// the code that was duplicated
public final void doStuff() {
System.out.println(getStuffString());
}
// extension point, the code that "varies"
// in LiskovBase and it's subclasses
// called by the template method above
// we expect it to be virtual and overridden
public string getStuffString() {
return "My name is Liskov";
}
}
public class LiskovSub extends LiskovBase {
// the extension overridden
// the actual code that varied
public string getStuffString() {
return "I'm sub Liskov!";
}
}
这个示例遵循OCP,看起来确实很愚蠢,但是可以想象它会随着更多代码的处理而扩展。我一直看到部署在生产中的代码,其中子类完全覆盖了所有内容,并且被覆盖的代码大部分在实现之间被剪切粘贴。它可以工作,但是与所有代码重复一样,它也是维护噩梦的一种设置。
结论
我希望所有这些都清除了一些有关OCP和LSP以及它们之间的异同的问题。很容易将它们视为相同,但是上面的示例应该表明它们并非相同。
请注意,从上面的示例代码收集:
OCP的目的是锁定工作代码,但仍要通过某种扩展名使其保持打开状态。
这是通过封装与模板方法模式示例相同的代码来避免代码重复。它也允许快速失败,因为破坏性变化是痛苦的(即更改一个地方,将其破坏到其他地方)。为了维护起见,封装更改的概念是一件好事,因为更改总是会发生。
LSP旨在让用户处理实现超类型的不同对象,而无需检查它们的实际类型。这本质上就是多态性。
该原理提供了一种进行类型检查和类型转换的替代方法,它可以随着类型数量的增加而失控,并且可以通过上拉重构或应用模式(例如Visitor)来实现。