[译]开发者须知的SOLID原则

原文:SOLID Principles every Developer Should Know – Bits and Pieces

SOLID Principles every devloper should know

面向对象为软件开发带来了新的设计方式,它使得开发者可以将具有相同目的或功能的数据结组合到一个类中来完成单一的目的,不需要考虑整个应用。

但是,面向对象编程没有减少混乱和不可维护的程序。正是这样,Robert C. Martin发展出了5条指南/准则,让开发者可以易于创建可读且易于维护的程序。

这5条准则就是S.O.L.I.D原则(缩写是Michael Feathers推演出来的)

  • S: Single Responsibilty Principle 单一功能原则
  • O: Open-Closed Principle 开闭原则
  • L: Liskov Substitution Principle 里氏替换
  • I: Interface Segregation Principle 接口分离
  • D: Dependency Inversion Principle 依赖反转

接下来我们详细讨论上述原则。
注意: 本文的大部分例子可能不能满足或者适用现实世界的应用程序。要视你自己的实际设计和使用场景来定。最重要的是理解和掌握如何运用或遵循这些原则。

建议:使用Bit这样的工具来实践SOLID原则,它能帮助你组织,发现和重用构建新应用程序的组件。组件可以在不同项目之间被发现和共享,所以你可以更快地构建应用程序,不妨试试。

单一功能原则 Single Responsibilty Principle

“...You had one job”---Loki to Skurge in Thor: Ragnarok

一个类只做一件工作

一个类只负责一件事。如果一个类有多项责任,它就变耦合了。一个功能的变动会造成另外一个功能改变。

  • 注意: 这条原则不仅仅适用于类,也适用于软件组件和微服务。

例如,考虑这样一个设计:

    class Animal{
      constructor(name: string){}
      getAnimalName(){}
      saveAniamal(a: Animal){}    
    }
    

这里的Animal类是否违背了单一功能原则(SRP)?

怎样违背的?

SRP中说一个类应只含一个功能,现在我们能分出两个功能:动物数据管理和动物特性管理。构造函数和getAnimalName管理动物特性,而saveAnimal负责动物在数据库中的存储。

这个设计将来会引发怎样的问题?

那部分如果应用程序对数据库管理相关函数作变更,使用了动物特性功能的代码也要会受影响并且要重新编译来适应新的变更。

可见这个系统显得很死板,好像一个多米诺骨牌效应,触动一张牌就会影响排列中的所有其他牌。

为了符合SRP,我们创建另一个单一功能的类只负责将一个动物存储到一个数据库中:

class Animal {
  constuctor(name: string) { }
  getAnimalName() { }
}

class AnimalDB {
  getAnimal(a: Animal) { }
  saveAnimal(a: Animal) { }
}
When designing our classes, we should aim to put related features together,
so whenever they tend to change they change for the same reason.
And we should try to separate features if they will change for different reasons. 
我们在设计类的时候,要以将相关的特性放在一起为目标,
当他们需要改变时应当是出于相同的原因,
如果我们发现他们会因为不同的原因改变,则需考虑将特性拆分开来
                                                      ---Steve Fenton

开闭原则 Open-Closed Principle

Software entities(Classes, modules, functions) should be open for extension, not modification.
软件实体(类,模块,函数等)应当对扩展开放,而对变更是封闭的

继续讨论Animal类,

class Animal {
  construtor(name: string) { }
  getAnimalName() { }
}

我们想遍历一个animal列表并且让发出他们的声音。

//...
const animals: Array<Animals> = [
  new Animal('lion'),
  new Animal('mouse')
  ];
  
function AnimalSound(a: Array<Animal>) {
  for(int i = 0; i <= a.length; i++){
    if(a[i].name == 'lion')
      log('roar');
    if(a[i].name == 'mouse')
      log('squeak');
  }
}
AnimalSound(animals);

AnimalSound这个函数并不符合开闭原则,因为它不能对新的动物种类保持闭合。如果我们添加一种新的动物,Snake:

//....
const animals: Array<Animal> = [
  new Animal('lion'),
  new Animal('mouse'),
  new Animal('snake')
];
//...

我们不得不修改AnimalSoound函数

//...
function AnimalSound(a: Array<Animal>) {
  for (int i = 0; i <= a.length; i++){
    if(a[i].name == 'lion')
      log('roar');
    if(a[i].name == 'mouse')
      log('squeak');
    if(a[i].name == 'snake')
      log('hiss');
    }
}

AnimalSound(animals);

可见,没新增一种动物,AnimalSound函数就要增加新的逻辑。这个例子已经十分简单。当应用程序随着变得更大而且更加复杂时,你会发现每当你增加一种新动物,AnimalSound中的if语句将在程序中不断地重复出现。

那怎样使它符合开闭原则(OCP)呢?

class Animal {
  makeSound();
  //...
}

class Lion extends Animal {
  makeSound() {
    return 'roar';
  }
}

class Squirrel extends Animal {
  makeSound() {
    return 'squeak';
  }
}

class Snake extends Animal {
  makeSound() {
    return 'hiss';
  }
}

//...
function AnimalSound(a: Array<Animal>) {
  for(int i = 0; i <= a.length; i++) {
    log(a[i].makeSound());
  }
}

AnimalSound(animals);

现在 Animal类拥有一个虚函数makeSound,我们让每个动物都继承Animal类并且实现自己makeSound的方法。
每种动物都在makeSound添加发声音的实现,遍历动物数组的时候只需要调用它们的makeSound方法。

这样,如果有新动物要添加,AnimalSound不要改变。我们只需要向动物数组中添加新的动物。

再举一例:

假设你有一家商店,你希望给你最喜爱的那些顾客20%的优惠,下面是类实现:

class Discount {
  giveDiscount() {
    return this.price * 0.2;
  }
}

当你决定给VIP用户的折扣翻倍,你可能会这样修改类:

class Discount {
  giveDiscount() {
    if(this.customer == 'fav')
      return this.price * 0.2;
    if(this.customer == 'vip')
      return this.price * 0.4;
  }
}

错!这不符合OCP原则,OCP反对这样做。如果你想提供新的折扣给其他不同的顾客,你就得增加新的逻辑。

为了使它符合OCP,我们需要增加一个类来扩展Discount类,在新的这个类实现它的新行为:

class VIPDiscount: Discount {
  getDiscount() {
    return super.getDiscount() * 2;
  }
}

如果需要给超级VIP顾客80%的优惠,实现方式可能就是这样:

class SuperVIPDiscount: VIPDiscount {
  getDiscount() {
    return super.getDiscount() * 2;
  }
}

这样,不需修改就实现了扩展。

里氏替换 Liskov Substitution Principle

A sub-class must be substitutable for its super class
子类一定能用父级类替换

这条原则就是目的就是确保子类能无差错地代替父类的位置。如果代码发现它还需要检查子类的类型,那么它就不符合这条原则。

用Animal类来举例:

function AnimalLegCount(a: Array<Animal>) {
  for(int i = 0; i <= a.length; i++) {
    if(typeof a[i] == Lion)
      log(LionLegCount(a[i]));
    if(typeof a[i] == Mouse)
      log(MouseLegCount(a[i]));
    if(typeof a[i] == Snake)
      log(SnakeLegCount(a[i]));
  }
}
AnimalLegCount(animals);

这段代码不符合LSP,也不符合OCP。它必须确定每种动物的类型并调用相应的计腿方法。

每当新增一种动物,这个函数都需要做出修改来适应。

//...
class Pigeon extends Animal {

}
const animals[]: Array<Animal>) = [
  //...
  new Pigeon();
];

function AnimalLegCount(a: Array<Animal>) {
  for(int i = 0; i <= a.length; i++) {
    if(typeof a[i] == Lion)
      log(LionLegCount(a[i]));  
    if(typeof a[i] == Mouse)     
      log(MouseLegCount(a[i]));     
    if(typeof a[i] == Snake)       
      log(SnakeLegCount(a[i]));     
    if(typeof a[i] == Pigeon)     
      log(PigeonLegCount(a[i]));
  }
}
AimalLegCount(animals);

要使这个函数符合LSP,需要遵循Steven Fenton 提出的以下要求:

  • 如果父类(Animal)有一个接受父类类型(Animal)的参数的方法,它的子类(Pigeon)应该接受一个父类类型(Animal)或子类类型(Pigeon)作为参数
  • 如果父类返回一个父类类型(Animal),其子类应当返回一个父类类型(Animal)或子类类型(Pigeon)。

现在来重新实现AnimalLegCount函数:

function AnimalLegCount(a: Array<Animal>) {
  for(let i = 0; i <= a.length; i++) {
    log(a[i].LegCount());
  }
}
AnimalLegCount(animals);

AnimalLegCount函数现在更少关心传递的Animal的类型,它只是调用LegCount方法。它只知道传入的参数必须是Animal类型,无论是Animal类型还是他的子类。

Animal类型现在需要实现/定义一个LegCount方法:

class Animal {
  //...
  LegCount();
}

它的子类也需要实现LegCount方法:

class Lion extends Animal{
  //...
  LegCount() {
    //...
  }
}

当它被传递给AnimalLegCount函数时,他将返回一头狮子的腿数。

可见AnimalLegCount函数不需要知道Animal的具体类型,只需要调用Animal类的LegCount方法,因为按约定Animal类的子类都必须实现LegCount函数。

接口分离原则 Interface Segregation Principle

Make fine grained interfaces that are client specific
为特定客户制作细粒度的接口
Clients should not be forced to depend upon interfacees that they do not use
客户应当不会被迫以来他们不会使用的接口

这条原则用于处理实现大型接口时的弊端。来看如下接口IShape:

interface Ishape {
  drawCircle();
  drawSquare();
  drawRectangle();
}

这个接口可以画圆形,方形,矩形。Circle类,Square类,Rectangel类实现IShape接口的时候必须定义drawCircle(),drawSqure(),drawRectangle()方法。

class Circle implements Ishape {
  drawCircle(){
    //...
  }
  
  drawSquare(){
    //...
  }
  
  drawRectangle(){
    //...
  }
}

class Square implements Ishape {
  drawCircle() {
    //...
  }
  
  drawSquare(){
    //...
  }
  
  drawRectangle(){
    //...
  }
}

class Rectangel implements Ishape {
  drawCircle() {
    //...
  }
  
  drawSquare(){
    //...
  }
  
  drawRectangle(){
    //...
  }
}

上面的代码看起来就很怪。Rectangle类药实现它用不上的drawCircle(),drawSquare()方法,Square类和Circle类也同理。

如果我们向Ishape中增加一个接口,如drawTriangle():

interface IShape {
  drawCircle();
  drawSquare();
  drawRectangle();
  drawTriangle();
}

所有子类都需要实现这个新方法,否则就会报错。

也能看出不可能实现一个可以画圆但是不能画方,或画矩形及三角形的图形类。我们可以只是为上述子类都实现所有方法但是抛出错误指明不正确的操作不能被执行。

ISP不提倡IShape的上述实现。客户(这里的Circle, Rectangle, Square, Triangle)不应被强迫依赖于它们不需要或用不上的方法。ISP还指出一个接口只做一件事(与SRP类似),所有其他分组的行为都应当被抽象到其他的接口中。

这里, Ishape接口执行了本应由其他接口独立处理的行为。

为了使IShape符合ISP原则,我们将这些行为分离到不同的接口中去:

interface Ishape {
  draw();
}

interface ICircle {
  drawCircle();
}

interface ISquare {
  drawSquare();
}

interface IRecetangle {
  drawRectangle();
}

interface ITriangle {
  drawTriangle();
}

class Circle implements ICircle {
  drawCircle() {
    //...
  }
}

class Square implements ISquare {
  drawSquare() {
    //...
  }
}

class Rectangle implements IRectangle {
  drawRectangle() {
    //...
  }
}

class Triangle implements ITriangle {
  drawTriangle() {
    //...
  }
}

class CustomShape implements IShape {
  draw() {
    //...
  }
}

ICircle接口只处理圆形绘制,IShape处理任意图形的绘制,ISquare只处理方形的绘制,IRectangle只处理矩形的绘制。

或者

子类可以直接从Ishape接口继承并实现自己draw()方法:

class Circle implements IShape {
  draw() {
    //...
  }
}

class Triangle  implements IShape {
  draw() {
    //...
  }
}

class Square  implements IShape {
  draw() {
    //...
  }
}

class Rectangle  implements IShape {
  draw() {
    //...
  }
}

我现在还可以使用I-接口来创建更多特殊形状,如Semi
circle, Right-Angled Triangle, Equilateral Triangle, Blunt-Edged Rectangle等等。

依赖反转 Dependency Inverse Principle

Dependency should be on abstractions not concretion
依赖于抽象而非具体实例

A. High-level modules should not depend upon low-level modules. Both should depend upon avstractions.
B. Abstractions should not depend on deatils. Details should depend upon abstractions.
A. 上层模块不应该依赖于下层模块。它们都应该依赖于抽象。
B. 抽象不应该依赖于细节。细节应该依赖于抽象。

这对开发由许多模块构成的应用程序十分重要。这时候,我们必须使用依赖注入(dependency injection) 来理清关系、上层元件依赖于下层元件来工作。

class XMLHttpService extends XMLHttpRequestService {}

class Http {
  constructor(private xmlhttpService:XMLHttpService ){ }
  
  get(url: string, options: any) {
    this.xmlhttpService.request(url, 'GET');
  }
  
  post() {
    this.xmlhttpService.request(url, 'POST');
  }
  //...
}

这里Http是上层元件,而HttpService则是下层元件。这个设计违背了DIP原则A: 上层模块不应该依赖于下层模块。它们都应该依赖于抽象。

这个Http类被迫依赖于XMLHttpService类。如果我们想要改变Http连接服务, 我们可能通过Nodejs甚至模拟http服务。我们就要痛苦地移动到所有Http的实例来编辑代码,这将违背OCP(开放闭合)。

Http类应当减少关心使用的Http 服务的类型, 我们建立一个Connection 接口:

interface Connection {
  request(url: string, opts: any);
}

Connection接口有一个request方法。我们通过他传递一个Connection类型的参数给Http类:

class Http {
  constructor(private httpConnection: Connection) {}
  
  get(url: string, options: any) {
    this.httpConnection.request(url, 'GET');
  }
  
  post() {
    this.httpConnection.request(url, 'POST');
    //...
  }
}

现在,无论什么类型的Http连接服务传递过来,Http类都可以轻松的连接到网络,无需关心网络连接的类型。

现在我们可以重新实现XMLHttpService类来实现Connection 接口:

class XMLHttpService implements Connection {
  const xhr = new XMLHttpRequest();
  //...
  request(url: string, opts: any) {
     xhr.open();
     xhr.send();
  }
}

我们可以创建许多的Http Connection类型然后传递给Http类但不会引发任何错误。

class NodeHttpService implements Connection {
  request(url: string, opts: any){
    //...
  }
}

class MockHttpService implements Connection {
  request(url: string, opts:any) {
    //...
  }
}

现在,可以看到上层模块和下层模块都依赖于抽象。 Http类(上层模块)依赖于Connection接口(抽象),而且Http服务类型(下层模块)也依赖于Connection接口(抽象)。

结语

我们讨论了每个软件开发者都需要遵从的五大原则。刚开始的时候要遵守这些原则可能会有点难,但是通过持续的练习和坚持,它将成为我们的一部分并且对维护我们的应用程序产生巨大的影响。

如果您有任何疑问或者有认为需要增加,更正或者移除的内容,尽管在下方留言,我会乐意与您讨论!

原文:SOLID Principles every Developer Should Know – Bits and Pieces

posted @ 2019-06-15 18:34 6oneB 阅读(...) 评论(...) 编辑 收藏