面向对象设计原则
开放封闭原则
基本描述
一个设计良好的应用程序应该做到对扩展开放,对修改封闭。换言之:当系统需要添加一个新的模块时,尽可能少地修改已有的代码(对修改封闭),通过添加新的类型(class)以增加新的功能(对扩展开放)。
举例说明
假设要开发一个二目运算类Calculater,其不考虑扩展性的设计如下:
/*
* Created by SharpDevelop.
* User: Joey
* Date: 2015/5/26
* Time: 23:55
*/
using System;
namespace OCP
{
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
int result = new Calculater().Calculate(1,1,"+");
Console.WriteLine(result);
Console.Write("Press any key to continue . . . ");
Console.ReadKey(true);
}
}
class Calculater
{
public int Calculate(int p1, int p2, String calculateType)
{
if (calculateType == "+") {
return p1 + p2;
}
if (calculateType == "-") {
return p1 - p2;
}
throw new Exception("未知的计算");
}
}
}
上述设计中,如果要增加新的运算符号,那么就要对Calculater进行修改,添加更多的if语句。下面的设计是经过重构后满足开闭原则的代码:
/*
* Created by SharpDevelop.
* User: Joey
* Date: 2015/5/26
* Time: 23:55
*/
using System;
namespace OCP
{
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
AbsCalculater calculater = GetCalculater("+");
int result = calculater.Calculate(1,1);
Console.WriteLine(result);
Console.Write("Press any key to continue . . . ");
Console.ReadKey(true);
}
public static AbsCalculater GetCalculater(String calculateType)
{
if (calculateType == "+")
{
return new AddCalculater();
}
if (calculateType == "-")
{
return new SubCalculater();
}
return null;
}
}
abstract class AbsCalculater
{
abstract public int Calculate(int p1, int p2);
}
class AddCalculater : AbsCalculater
{
public override int Calculate(int p1, int p2)
{
return p1 + p2;
}
}
class SubCalculater : AbsCalculater
{
public override int Calculate(int p1, int p2)
{
return p1 - p2;
}
}
}
这样重构以后,如果要添加新的运算方法,只需添加新的继承至类AbsCalculate
即可。当然GetCalculater
这个方法需要修改,但是可以通过依赖注入或反射代替之,而先前的设计是无法实现的。
目的
满足开闭原则与否决定了代码是否优雅,符合开闭原则有如下好处:
- 添加新功能(模块)时不会影响旧有代码,减少出错可能性,易扩展
- 使应用程序表现出
多态
特性,灵活应对变化
单一职责原则
基本描述
一个类应该尽量被设计为仅有一个职责,即只干一件事情。衡量一个类是否具有多个职责的依据是:如果引起类变化(此处的变化可以理解对类中代码进行修改)的原因仅有一个,那么可以说这个类仅有一个职责;反之,引起类变化的原因有多个,那么,可以说这个类具有多个职责。
举例说明
举例说明,比如存在一个实体类User,一个操作User的UserOperator(点击链接查看类图)类,该类明显违反单一职责模式,其存在两个职责:一个是对用户的操作,一个是对数据库的操作。经过优化后的类图(点击链接查看类图)增加了一个类DbAcess专门负责操作数据库,UserOperator通过组合的关系调用DbAcess。
目的
- 代码更清晰可读
- 有利于复用(该例中DbAcess显示可以在很多地方复用)
- 维护代码更容易(职责太多的话难免有时会因为修改一个职责而对另一个职责产生影响)
扩充
不仅是类,接口的职责也要做到单一,也就是后面要说到的“接口隔离原则”。方法的职责更应该单一,尽量避免长篇大论的代码段,努力控制代码行数,将长方法按照职责的不同分为数个小方法。比如,有一个从Txt文件,Excel文件导入数据的方法LoadDataFromFile()
,也许可以将该方法拆分为两个方法:LoadDataFromTxt()
和LoadDataFromExcel()
:
class DataLoder
{
pirvate void LoadDataFromFile()
{
//60行代码
}
}
修改为:
class DataLoader
{
pirvate void LoadDataFromFile()
{
LoadDataFromTxt();
LoadDataFromExcel();
}
private void LoadDataFromTxt()
{
//30行代码
}
private void LoadDataFromExcel()
{
//30行代码
}
}
这样处理的好处也是显而易见的,想象一下60行代码中如果有4个嵌套循环8个if,找对称的左右大括号就很让人郁闷了......
另外,在一个应用系统中,每个层次都有自己明确的一个职责,每个模块都有自己明确的一个职责,不可以有除了这个明确职责之外的职责,比如数据访问层就只管读写数据(而不在数据库访层中添加和业务相关的代码),业务逻辑层就只管业务逻辑处理(而不在业务逻辑层中拼SQL)。
适可而止
单一职责应是所以面向对象设计原则中最不易把握的一个,职责的划分粒度本来就没有什么标准可依,正如第一个例子,也可以说AddUser方法和DeleteUser方法也是两个不同的职责,可以再划分,但实际上大家肯定很少这么做,职责的粒度太细会引起类数量上的膨胀和工作量的增加。总之,单一职责需要适可而止,可以通过判断引起类变化的可能因素是否超过一个这条原则把控职责的粒度划分。
里氏替换原则
基本描述
设有程序P,父类型T1,子类型T2:
- 程序P中,将任何T1类型的对象替换为T2类型的对象后,程序行为仍然正常
- 程序P不能察觉出T1类型和T2类型的区别
举例说明
且看经典的矩形和正方形示例:
/*
* Created by SharpDevelop.
* User: Joey
* Date: 2015/5/22
* Time: 0:48
*/
using System;
namespace LSP
{
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("LSP test!");
Rectangle rect = GetRectangle();
rect.Width = 10;
rect.Height = 5;
int area = GetArea(rect);
Console.WriteLine(area);//预想的结果是50,实际上是25
Console.Write("Press any key to continue . . . ");
Console.ReadKey(true);
}
private static int GetArea(Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
private static Rectangle GetRectangle()//假设这个是个工厂
{
//return new Rectangle();
return new Square();
}
}
class Rectangle
{
public virtual int Height { get; set; }
public virtual int Width { get; set; }
}
class Square:Rectangle
{
int _width;
public override int Width
{
get { return _width; }
set
{
_width = value;
_height = value;
}
}
int _height;
public override int Height
{
get { return _height; }
set
{
_height = value;
_width = value;
}
}
}
}
上述例子违反基本描述中的第一条:程序P中,将任何T1类型的对象替换为T2类型的对象后,程序行为仍然正常。
再看一个例子:
/*
* Created by SharpDevelop.
* User: Joey
* Date: 2015/5/23
* Time: 8:33
*
* To change this template use Tools | Options | Coding | Edit Standard Headers.
*/
using System;
namespace LSP2
{
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
Bird bird = GetBird();
if (bird is Bird)
{
bird.FlyTo("home");
}
if (bird is Chicken)
{
(bird as Chicken).RunTo("home");
}
Console.Write("Press any key to continue . . . ");
Console.ReadKey(true);
}
private static Bird GetBird()//工厂
{
return new Chicken();
}
}
class Bird
{
public virtual void FlyTo(String position)
{
Console.WriteLine("the bird can fly to " + position);
}
}
class Chicken : Bird
{
public override void FlyTo(String position)
{
;
}
public void RunTo(String position)
{
Console.WriteLine("the chicken can run to " + position);
}
}
}
该例中,Main方法调用Bird时必须要判断Bird的实例类型,这违反了基本描述中的第二条:程序P不能察觉出T1类型和T2类型的区别
目的
里氏替换原则是实现开闭原则的一个条件,如果违反里氏替换原则就无法做到"对修改封闭"。如上面的第二个例子,如果在程序P中将Bird对象替换为Chicken对象后,程序需要增加判断条件,没有做到“对修改封闭”。
依赖倒置原则
基本描述
很多时候,在一个应用设计方案中,高层模块调用低层模块,低层模块的变化会导致高层模块的修改,系统不是很稳定。使用依赖倒置原则可以缓解这种情况,该原则强调:高层模块不应该依赖于低层模块,两者都应该依赖于抽象;具体的实现细节应该依赖于抽象,而不是抽象依赖于具体。还有一种更容易理解的说法叫不要针对具体编程,而应该针对抽象编程。
类型和类型之间的耦合按照类型是否为具体类型或抽象类型可划分为如下情况:
- 具体类型和具体类型之间的依赖
- 具体类型和抽象类型之间的依赖
其中,由于抽象类型较之具体类型是不容易改变、相对稳定的部分,所以引入对抽象类的依赖可以减小两个具体类之间的耦合程度,在该原则中则表现为:在高层模块和低层模块间引入抽象层模块以减少高层模块和低层模块之间的耦合度。
举例说明
假设某君TGL欲开发一款山寨星际2,该君主族是Terran,于是先定义了人类王牌防御工事——地堡类如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DIP
{
class Bunker
{
public void Attack(Zealot enemy)
{
enemy.HP--;
}
public void Attack(Stalker enemy)
{
enemy.HP--;
}
}
}
Bunker类(点击查看类图)依赖于具体类Zealot和Stalker。且说开局Protoss欲4BG速Rush,无奈TGL利用地形快起地堡,置4枪兵于内防守,外加两工程师维修,Protoss一波流终于被打退,然对手也非等闲之辈,双方运营后打后期,10分钟后,Protoss两不朽、两巨像、若干狂战士外加哨兵一顿突突,TGL放弃抵抗,打出GG。究其原因,原来是Bunker类太依赖于对方具体单位,而致其灵活性较差。虽然Bunker目前能很好得地打击狂战士和追猎者,但当新兵种来临时,Bunker类必须修改,增加正对不朽等新的Attack方法,由于此次攻击兵种较多,TGL君APM本来就低,还要调整较多的代码,于是GG。
痛定思痛后TGL决定遵循依赖倒置原则,重新设计代码如下(点击查类图):
using System;
using System.Collections.Generic;
using System.Data.OracleClient;
using System.Data.SqlClient;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DIP
{
class Bunker
{
public void Attack(AbsEnemy enemy)
{
enemy.HP--;
}
}
abstract class AbsEnemy
{
public abstract int HP { get; set; }
//其它方法...
}
class Zealot: AbsEnemy
{
public override int HP
{
get;//略
set;//略
}
//其它方法...
}
class Stalker:AbsEnemy
{
public override int HP
{
get;//略
set;//略
}
//其它方法...
}
}
目的
化对易变的具体的依赖为对稳定的抽象的依赖,其中,“倒置”这个词有点难于理解,几年前,在伟大的博客园上和那些不以为我傻逼的园友进行了深入的交流,对“倒置”略有所得,连接在此:依赖倒置原则的“倒置”体现在哪里,”依赖倒置“为什么不叫”依赖转移“而叫”倒置“(高人勿入)
接口隔离原则
基本描述
不应该为客户提供用不着的接口
举例说明
假设为客户A和B提供了接口X,接口X中包括了方法f1,f2,和f3,其中A需要调用f1和f2,而B需要调用f2和f3。这个时候正确的做法应该是为A提供一个接口Y,只包含方法f1和f2,为B提供一个接口,只包含f2和f3。
目的
-
单一职责原则在抽象层的体现
-
避免庞大臃肿的接口给客户带来选择上的混乱