设计六宗罪
- 刚性 - 使其难以改变
- 脆弱性 - 可以很容易地打破
- 不可移动 - 使其难以重用
- 粘度 - 使其难以做正确的事
- 不必要的复杂性 – 过分设计
- 不必要的重复 - 容易出错
规律
- 根据应用常识自然之道
不要太教条/宗教
每个决定都是权衡
- 所有其他原则都只是
方针
“最佳实践”
是否应该违背他们时慎重考虑- 但是,知道你可以的。
面向对象五个基本原则
- OCP开闭原则
- SRP单一职责原则
- ISP接口隔离原则
- LSP里氏代换原则
- DIP依赖倒置原则 依赖注入
OCP开闭原则
- 软件实体(类,模块,方法等)应该对扩展开放,但对修改关闭。
- 也被称为受保护的变化
- Parnas方法:他创造了“信息隐藏”
- 最早的软件开发方法是由D.Parnas在1972年提出的
“对扩展开放”意味着你的类应该很容易因为需求变化添加新功能,“对修改封闭”意味着一旦你已经开发和测试完成一个类,那就不要轻易去修改它。
这个原则的开放和封闭两个部分好像是矛盾的,关键是:如果你有正确的类结构和依赖关系,你就无需更改原来的代码,只要通过增加新代码实现新功能,在这方面Gof设计模式为我们提供了很多参考方式。
通常你可以使用抽象方式来实现,比如接口和抽象类,这样当有新功能需要增加时,我们只要通过实现一个新的接口子类就可以。
使用开闭原则能够限制对现有代码的修改,这会降低引入新的BUG的风险,其实我们在对原有代码修正Bug时也会引入更多BUG,所以,如果原有代码的Bug不是很致命,或者可以通过拓展增加代码来避免,那么尽量不要破坏封装。
以下案例:
void checkOut(Receipt receipt) {
Money total = Money.zero;
for (item : items) {
total += item.getPrice();
receipt.addItem(item);
}
Payment p = acceptCash(total);
receipt.addPayment(p);
}
在这个Checkout案例中,原来是现金money交易,如果我们需要增加信用卡怎么办?直观朴素的做法是引入一个IF判断语句,但是这样破坏开闭原则。如下:
Payment p;
if (credit)
p = acceptCredit(total);
else
p = acceptCash(total);
receipt.addPayment(p);
比较好的解决方案:
public interface PaymentMethod {void acceptPayment(Money total);}
void checkOut(Receipt receipt, PaymentMethod pm) {
Money total = Money.zero;
for (item : items) {
total += item.getPrice();
receipt.addItem(item);
}
Payment p = pm.acceptPayment(total);
receipt.addPayment(p);
}
这里我们增加了一个支付方法的接口PaymentMethod,现金和信用卡不同的支付只要实现PaymentMethod即可,当然如果有新的第三种支付方式,也是采取实现PaymentMethod接口方式。
OCP只有在你的改变是可预期的情况下有效,如果两次重复修改以上发生,你就要使用OCP。
为什么要 OCP
- 如果对一个程序的单一的改变导致依赖模块的级联式改变。(一发动全身)
- 该方案变得脆弱,僵硬,不可预测和非可重复使用。
OCP 实现
- 类成员使用private修饰符
- 自动添加Getter方法
- 使用抽象
- 通过继承,
- 控制反转(IoC)
OCP实现.NET模板类:
单一职责SRP
- A类只有有一个理由去改变 责任职责是那个用来改变的理由
- 为了得到正确的粒度可能会非常棘手
SRP认为,不应该也不能有超过一个理由去对类进行改变,这意味着每个类只能实现一个目标。而不能实现多个目的与目标,当然这个职责目标不只是指的是功能,一个职责目标可能有许多功能去完成,就像条条大路通罗马,但是你的目标只能是罗马一个,不能今天罗马,明天巴黎,将这两种目标职责混合在一个类实现就违反了SRP。
在类中每个事情都和这单个目标有关,这样才能更凝聚,就如同在管理中要求集体凝聚成一股绳,这种凝聚力越强,完成目标的可能性才越大。
和某个职责目标有关的成员可能有很多,当目标一旦改变,所有类的成员都可能会修改,当然也可能是相关类都会修改。这就是凝聚性。
class Employee {
public Pay calculatePay() {...}
public void save() {...}
public String describeEmployee() {...}
}
这一个类中我们有支付 (calculatePay)和逻辑计算以及数据库逻辑(save)和报表逻辑等功能,如果混合了多个功能在一个类中,那就很难于改变,因为其中一部分变化,很难不影响其他部分。
将职责混合在一起也使得类难以理解,难以测试,降低聚合。解决这个问题的办法就是将这个类切分为三个不同的类,每个类有自己的职责: 数据库访问, 计算支付和报表。
为什么SRP
- Single Responsibility = 增加凝聚
- 不遵守会导致不必要的依赖关系:
有很多的理由来改变。
刚性好,不可移动
详见:对象职责协作
Liskov替换原则
如果对于类型S的每个对象O1存在类型T的对象O2,那么对于所有定义了T的程序P来说,当用O1替换 O2并且S是T的子类型时,P的行为不会改变。“通俗地讲,就是子类型能够完全替换父类型,而不会让调用父类型的客户程序从行为上有任何改变
“派生类(子类)对象能够替换其基类(超类)对象被使用 。 所有子类都应该永远是可用的,而不是它的父类。 所有派生类必须兑现其基类的合同
又称为Design by Contract 契约设计 按合同设计。契约设计专题
LSP使用在继承层次,规定你设计你的类时,当有调用者调用你的类时,需要其他依赖,你应该将这些依赖设计到你的类中,而不是放入调用者中,当你的依赖有变化时,你的调用者不会知晓。
作为子类必须和他们的基类操作一致,子类中的特殊功能可以与基类不一样,但是大部分方法功能应该两者一致的。子类不只是实现基类的方法,而且必须名符其实。
通常,如果一个子类的子类做了一些这个子类的调用者并不期望做的事情,这就违背了LSP, 想象一下,如果一个子类抛出Exception错误,而父类并没有,或者一个子类有不可预期的副作用等等,这些都是名不符实,总体来说,子类做的事情要少于他们的父类。
LSP经典案例是矩形和正方形的案例,正方形是要求长等于宽,如果你应该使用矩形的时候你却使用了正方形,不可预期的错误会发生,因为正方形的尺寸是不能被修改 (修改了就不是正方形,违背事物内在逻辑一致性)。
这个问题可不容易被解决,如果我们修改正方形的setter方法修改尺寸,这可能违反了正方形的逻辑,但是不修改就违反矩形的后置条件,因为矩形的长宽是可以独立修改的。
public class Rectangle {
private double height;
private double width;
public double area();
public void setHeight(double height);
public void setWidth(double width);
}
上面是矩形代码,下面是正方形代码:
public class Square extends Rectangle {
public void setHeight(double height) {
super.setHeight(height);
super.setWidth(height);
}
public void setWidth(double width) {
setHeight(width);
}
}
违背LSP将导致没有定义的行为,没有定义的行为意味着它也许在开发阶段工作得很好,但是在产品生存环境掉链子,或者你要花数周时间去调试一天只发生一次的问题,或者你得遍历几百兆的日志去找出哪儿出错了。
ISP接口隔离原则
- 许多客户端特定的接口,胜过一个通用接口更好
- 根据每个客户端类型创建接口,而不是每个客户端实例, 避免不必要的耦合到客户端
ISP认为:客户端调用者不应该被强迫依赖于它们不使用接口成员,当我们有非凝聚的接口时,ISP会指导你创建多个 小的 凝聚聚合的接口。
当你使用ISP时, 类和他们的依赖使用正确聚焦的接口通讯,会最小化减少依赖,降低耦合,小的接口更加易于实现,提高灵活性和重用。一个接口只有少量的实现子类,一旦需求引起变化,导致接口变化后,引起的修改范围少,能够提高健壮性。
想象一个ATM机器,有一个屏幕可以显示不同信息, 如何解决显示不同信息呢?使用SRP, OCP和LSP以后,系统还是难以维护,为什么?代码如下:
public interface Messenger {
askForCard();
tellInvalidCard();
askForPin();
tellInvalidPin();
tellCardWasSiezed();
askForAccount();
tellNotEnoughMoneyInAccount();
tellAmountDeposited();
tellBalance();
}
也许你可以增加一个方法到Messenger接口,但是这会引起这个接口的所有子类重新编译,系统几乎需要重新部署,这直接违背了OCP原则。
关键问题出在,这个接口中的方法太散了,几乎没有凝聚性,无法聚焦,我们将这个接口切分成不同的功能接口:
public interface LoginMessenger {
askForCard();
tellInvalidCard();
askForPin();
tellInvalidPin();
}
public interface WithdrawalMessenger {
tellNotEnoughMoneyInAccount();
askForFeeConfirmation();
}
publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {
...
}
为什么ISP
- 否则 - 增加了不同客户端之间的耦合
- 每个客户端对SRP基本上是一个变量
控制反转原则
- 更高层次的模块不应该依赖于低层模块
- 两者都应该依赖于抽象
- 接口或抽象类
- 抽象不应该依赖于细节
DIP是定位在高层次模块不应该依赖低层次模块,它们应该只依赖于抽象,这就能帮助我们实现松耦合,使得设计易于修改,DIP允许测试 数据库细节能够如插件一样插入我们的系统。
案例:
public interface Reader { char getchar(); }
public interface Writer { void putchar(char c)}
class CharCopier {
void copy(Reader reader, Writer writer) {
int c;
while ((c = reader.getchar()) != EOF) {
writer.putchar();
}
}
}
public Keyboard implements Reader {...}
public Printer implements Writer {…}
这里Reader和Writer接口是抽象的,而Keyboard和Printer是具体细节,依赖于抽象,这是通过实现接口完成的。CharCopier是一个明显的Reader和Writer的低层次细节,你只要传入Reader和Writer接口的任何实现子类都可以正常工作。
Why DIP
- 增加松耦合
抽象接口不改变
具体类实现的接口
具体类容易扔掉,更换
- 增加流动性
- 增加隔离
DIP 影响
- 诞生了层 的概念,如三层多层架构
- 基于接口编程
- 分离接口 把接口放在不同的包中而不是实施它
- 依赖注入
举例:使用其他类的老办法先是创建它们,如下,问题是违背DIP原则,应该只依赖接口
public class MyApp
{
public MyApp()
{
authenticator = new Authenticator();//创建类实例
database = new Database();//创建类实例
logger = new Logger();//创建类实例
errorHandler = new ErrorHandler();//创建类实例
}
// More code here...
}
依赖注入
- DIP 说我们应当依赖接口 那么我们如何得到实例呢?
上面代码的多个重构方法
- 选择 1 – 使用工厂生成Authenticator等实例,但是用户依赖工厂factory,而且Factory依赖目标 。
- 选择 2 – Locator/Registry/Directory,组件还是控制配对 ,初始化需要顺序 ,依赖于定位器 JNDI依赖于JavaEE容器
- 选择 3 – Dependency Injection,组装器控制配对
邪恶做法
- 使用Switch statements
- 使用If (type())
- 使用单例Singletons / Global variables全局变量
- 使用Getters
- 使用Helper 类
组合+依赖 vs. 继承
直接用代码实现继承某种程度也是邪恶的,使用组合+依赖替代继承案例 ,下面代码是Angular.js的JS代码,完整见这个帖子
- var Mammal = Backbone.Model.extend({
isAlive: true,
init: function () {
console.log('An animal is born');
},
eat: function (food) {
return 'omnomnom, I\'m eating: ' + food;
},
sleep: function () {
return 'zzzzz' ;
},
});
var Cat = Mammal.extend({
meow: function () {
return 'meow meow';
}
});
var Dog = Mammal.extend({
bark: function () {
return 'woof woof';
}
});
拓展问题
- 猫cat和狗dog继承Mammal,接下来如果需要扩展,比如猫狗是不能用来打猎吃的,如果我们需要豹panther和狼wolf,以及猎人杀死它们,可以看出,豹和狼虽然都是食肉特征,但是它属于不同的分类,添加这些食肉特征会导致重复代码,都有hunt和kill方法:
豹panther和狼wolf
- var Panther = Cat.extend({
hunt: function () {
return 'imma go search for food!';
},
kill: function (animal) {
animal.die();
return animal + ' is dead!';
}
});
var Wolf = Dog.extend({
hunt: function () {
return 'imma go search for food!';
},
kill: function (animal) {
animal.die();
return animal + ' is dead!';
}
});
继承问题
- 根据达尔文的进化论,the traits that adapt to the environment the most make the organisms survive the best. 最适应环境的特征(trait)能让生物生存的最好。
许多生物体的特点可以是相似的,即使它们不属于相同的属或科。
组合实现
- var animals = angular.module('animals', []);
animals.factory('Mammal', function Mammal () {
this.init();
this.init = function () {
this.isAlive = true;
console.log('An animal is born');
};
this.eat = function (food) {
return 'omnomnom, I\'m eating: ' + food;
};
this.sleep = function () {
return 'zzzzz' ;
};
this.die = function () {
this.isAlive = false;
return 'I\'m dead!';
};
});
依赖注入组合
- animals.service('meowingTrait', function () {
this.meow = function () {
return 'meow meow';
}
});
animals.service('barkingTrait', function () {
this.bark = function () {
return 'woof woof';
};
});
animals.service('huntingTrait', function () {
this.hunt = function () {
return 'imma go search for food!';
};
this.kill = function (animal) {
animal.die();
return animal + ' is dead!';
};
});
上述案例证明了“依赖注入>继承” ,依赖注入优于继承。
事件驱动 vs. 依赖注入
聚合 >松耦合>重用 ==> 事件驱动>依赖注入>继承
发现事物内部的凝聚性或一贯性,以聚合为边界切分,能够自然达到松耦合。
否定为松耦合而松耦合
否定为重用而重用
抓住凝聚,纲举目张,松耦合与重用自然解决。比喻:在一群人中找出好人,然后就为找好人而找好人,结果找出来的不一定是好人。换个思路,反者道之动,先找出坏人,剩余的就是好人了。