SOLID 原则在Ruby中的应用『译』

原文链接: SOLID Principles in Ruby
转载请注明出处:http://tedyin.github.io/2016/02/27/solid-principles-in-ruby/

作为一名程序员无论你的水平高低,你都会想写出一手优秀的代码,但是想写优秀的代码并不容易,因此怎样才能提高我们的代码质量呢?下面来看下我们今天的主角 SOLID 原则

SOLID 原则是什么

SOLID 不是一个原则,他是一组面向对象设计原则的简称,他代表下面5种设计原则:

  • S ingle Responsibility Principle 单一职责原则
  • O pen/Closed Principle 开闭原则
  • L iskov Substitution Principle 里氏替换原则
  • I nterface Segregation Principle 接口分离原则
  • D ependency Inversion Principle 依赖倒置原则

以上就是SOLID中的5种面向对象设计原则,下面分别看看他们具体指的是什么。

单一职责原则(SPR)

在我看来这个是最简单的一个设计原则,SPR的说明如下:

每一个类或则方法都应该有且仅有一个职责,而且他的这个职责应该被完全封装在这个类里面。

如何去判断你的代码是否符合这一原则的最好方式就是去问问自己:

这个类或者方法到底做了什么?

如果他干了不只一件事情的话,那么他就违反了SPR原则。下面来看一个Student类,每个Student对象都有grades属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Student
attr_accessor :first_term_home_work, :first_term_test,
:first_term_paper
attr_accessor :second_term_home_work, :second_term_test,
:second_term_paper

def first_term_grade
(first_term_home_work + first_term_test + first_term_paper) / 3
end

def second_term_grade
(second_term_home_work + second_term_test + second_term_paper) / 3
end
end

也许有些人已经意识到了,上面的写法是错误的,也许有些人还没有感觉到。不管有没有意识到,上面的代码显然是没有循序SPR原则的,原因就是 Student 类拥有计算每个学期平均分的逻辑,但是Student类是用来封装关于学生信息的而不是用来计算分数的,计算分数的逻辑应当放在Grade类中才对。下面我们遵循SPR原则重构一下代码,重构后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Student
def initialize
@terms = [
Grade.new(:first),
Grade.new(:second)
]
end

def first_term_grade
term(:first).grade
end

def second_term_grade
term(:second).grade
end

private

def term reference
@terms.find {|term| term.name == reference}
end
end

class Grade
attr_reader :name, :home_work, :test, :paper

def initialize(name)
@name = name
@home_work = 0
@test = 0
@paper = 0
end

def grade
(home_work + test + paper) / 3
end
end

重构之后的代码Student类中的计算分数的逻辑移到了Grade类中,Student类中只有对Grade实例的引用。现在所有的类都遵循SPR原则,因为每个类都是职责单一的。

开闭原则(OCP)

开闭原则的定义如下:

一个类或者模块对扩展开放,对修改关闭。

什么意思呢?他的意思就是:一旦一个类已经实现了当时的需求,他就不应该为了去实现接下来的需求而被修改。你是不是觉得这样做没有意义?那我们下面看个例子来说明一下:

1
2
3
4
5
6
7
8
9
class MyLogger
def initialize
@format_string = "%s: %s\n"
end

def log(msg)
STDOUT.write @format_string % [Time.now, msg]
end
end

这是一个简单的logger类,他可以将把给定的msg和当时的时间通过STDOUT格式化输出出来。非常简单对吧,下面来测试一下:

1
2
irb> MyLogger.new.log('test!')
=> 2016-02-28 16:16:32 +0200: test!

测试OK,没什么问题,但是假如在以后的某一天,我们想改变一下输出的格式,想实现下面的日志格式

1
=> [LOG] 2016-02-28 16:16:32 +0200: test!

怎么办呢?假如现在由一个不懂OCP原则的程序员来实现上述格式,实现的代码如下:

1
2
3
4
5
class MyLogger
def initialize
@format_string = "[LOG] %s: %s\n"
end
end

输出的结果如下:

1
2
irb> MyLogger.new.log('test!')
=> [LOG] 2016-02-28 16:16:32 +0200: test!

输出的格式完全符合要求,一切都OK,但是这样做真的就对吗?
仔细想想,假如上面被修改的类是一个App中的核心类,对format_string方法的修改,可能会破坏那些依赖MyLogger类的方法使得他们不能正常的工作。也许在APP中存在许许多多的类都依赖刚才说的那些方法,但是现在我们修改了代码,破坏了这些类和方法。这就是在破坏OCP原则,这会导致灾难性的后果。

既然不遵循OCP原则会有很严重的问题,那么实现上面修改日志格式需求的正确姿势是什么呢?毫无疑问当然是继承或者组合
我们来看看下面使用继承的例子:

1
2
3
4
5
class NewCoolLogger < MyLogger
def initialize
@format_string = "[LOG] %s: %s\n"
end
end

1
2
irb> NewCoolLogger.new.log('test!')
=> [LOG] 2016-02-28 16:16:32 +0200: test!

棒呆!和我们预期的一样,那MyLogger的输出呢?

1
2
irb> MyLogger.new.log('test!')
=> 2016-02-28 16:16:32 +0200: test!

还是棒呆!那么我刚刚都干了些啥呢?我们创建了一个新的NewCoolLogger类,扩展(extend)MyLogger类。那些之前依赖老的logger方法的类和方法依然可以正常的工作,老的logger还是和以前一样提供相同的方法,新的logger则提供新的logger方法,这是我们所期待的。
我刚才说了两种重构方式,下面我们来看看使用另外一种方式组合来重构代码的例子:

1
2
3
4
5
class MyLogger
def log(msg, formatter: MyLogFormatter.new)
STDOUT.write formatter.format(msg)
end
end

我们可以注意到,log方法多了一个可选参数formatter,对于日志格式化的事情本来就应该是MyLogFormatter类的事情,而不应该是logger类的事情。使用上面的方式重构更好,因为这样做了之后MyLogger#log可以接受各种各样不同的格式化方式,而且MyLogger也不在需要去关心具体的格式化格式,因为他只需要一条String,具体是什么格式的则由传入MyLogger#log个格式化类来确定。假如我们又要实现 Error Log 输出,现在简单了只需要传入一个ErrorLogFormatter实例即可输出带有 “[ERROR]” 前缀的日志。

里氏替换原则(LSP)

Barbara LiskovLSP原则的定义如下:

如果S是T的一个子类,那么不需要修改代码中的任何配置和属性,S的实例也可以替换T的实例对象,而且不影响代码的正常运行。

坦白的讲,我觉得这个定义是非常难理解的,因此经过一番思考,总结下来如下:

假如现在有一个Bird类,还有两个实例对象 obj1 和 obj2。obj1 是 Duck 类的对象,Duck 类是 Bird 类的子类,obj2 是 Pigeon 类的对象,Pigeon 类也是Bird 类的子类。LSP原则的意思是,obj2 是Bird子类的实例,obj1 是Bird子类的实例,因此我们应当把 obj1 和 obj2 等同对待,都当做Bird的实例对待。

译者注:其实我觉得上面的定义已经说的很清楚了,上面说的 obj1 之类的例子有点多余。。。

下面我们来看个例子来说明下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person
def greet
puts "Hey there!"
end
end

class Student < Person
def years_old(age)
return "I'm #{age} years old."
end
end

person = Person.new
student = Student.new

# LSP原则的意思是如果我知道 Person 拥有的接口,那么我应该也能猜到 Student 拥有的接口,因为 Student 类是 Person 的子类。
student.greet
# returns "Hey there!"

以上就是对LSP原则的解释

接口分离原则(ISP)

接口分离原则的定义如下:

不应该强迫客户端依赖一些他们用不到的方法或接口。

就像定义那样很简单,我们来看看代码说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Computer
def turn_on
# turns on the computer
end

def type
# type on the keyboard
end

def change_hard_drive
# opens the computer body
# and changes the hard drive
end
end

class Programmer
def use_computer
@computer.turn_on
@computer.type
end
end

class Technician
def fix_computer
@computer.change_hard_drive
end
end

在这个例子中有ComputerProgramerTechnician三个类。其中ProgramerTechnician会使用到电脑,而且是以不同的方式使用,Programer使用的是type方法,Technician用的是change_hard_drive,按照LSP原则要求 不应当强迫客户端依赖一些他们用不到的接口或者方法Programer类用不到change_hard_drive方法,同样的Technician用不到type方法,但是一旦这两个方法发生变化,那么就有可能影响到Programer或者Technician类的正常使用。下面我们重构一下代码,来满足LSP原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Computer
def turn_on
end

def type
end
end

class ComputerInternals
def change_hard_drive
end
end

class Programmer
def use_computer
@computer.turn_on
@computer.type
end
end

class Technician
def fix_computer
@computer_internals.change_hard_drive
end
end

经过重构后Technician使用了ComputerInternals类的对象,这个类封装了从Computer中分离出来的方法change_hard_drive。现在Computer类可以受到Programer类的影响(写代码改变OS),但是再也影响不到Technician类了。

依赖倒置原则(DIP)

依赖倒置原则代表了一种软件模块解耦的方式,他的定义有两部分:

  1. 上层模块不应该依赖下层模块,他们应该都依赖抽象。
  2. 抽象不能依赖具体实现,具体实现应该依赖抽象。

我知道这个理解起来有点绕,但是在开始看具体的例子之前,我希望你不要把 依赖倒置依赖注入 弄混淆,后者是一种软件技巧或者说是一种软件设计模式,而前者是面向对象设计原则的一种。
好了下面来看看具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Report
def initialize
@body = "whatever"
end

def print
XmlFormatter.new.generate @body
end
end

class XmlFormatter
def generate(body)
# convert the body argument into XML
end
end

Report类是用来生成 XML 报表的,在他的初始化方法中,我们设置了报表内容(body),print方法使用XmlFormatter类去将报表内容转换成 XML 格式。

下面我们来看看Report这个类,从这个类的名字我们能看出来他是个普通的类,会返回某种类型的报表(report),但是他没告诉我们会返回哪种格式的报表。事实上对于上面这个例子我们能够很轻松的将Report重命名为XmlReport因为我们知道他的实现细节,知道他只实现了导出 XML 报表的功能,但是与其让Report变的更加具体(丢失更多的扩展性),我们还不如好好想想怎么去将他更好的抽象。

目前我们的类依赖XmlReport类和他的generate方法,Report依赖的是一个具体的实现而不是抽象,只有当提供格式化方法的类是XmlFormatter的时候,我们的Report类才能正常的工作。假如我们现在想导出 CSV 或者 JSON 格式的报表怎么办?那我们就只能提供更多的具体的方法,比如print_xmlprint_csvprint_json等。这意味着Report类和具体实现绑的非常紧,耦合非常高,他依赖格式化类的类型,但却不依赖这些格式化类的抽象。

译者注:Report 类就是只知道有这么多个格式化类,但是却不知道他们之间有什么共同特点,依赖这些具体的类却不依赖他们的共同特点,也就是不依赖抽象。假如现在又有新的格式,Report 还得去了解新的格式类,如果依赖他们共同拥有的一个格式化的接口,那Report就不用去操心你这个格式化的类到底是格式化成啥了,我直接调用这个格式化的方法就行了。

下面我们重构一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Report
def initialize
@body = "whatever"
end

def print(formatter)
formatter.generate @body
end
end

class XmlFormatter
def generate(body)
# convert the body argument into XML
end
end

注意print方法,他知道自己需要一个 formatter,但是他关心的是这个 formatter 的接口。更具体地讲,他只关心这个 formatter 能够给他提供的 generate方法,具体是什么样的 formatter 他不在乎,只要能提供generate方法,帮他完成格式化大业就行。这样设计大家有没有觉得更灵活呢?假如我们现在需要 CSV 格式的报表,我们只需要提供下面这个类就行了。

1
2
3
4
5
class CSVFormatter
def generate(body)
# convert the body argument into CSV
end
end

Report#print方法将会接收一个CSVFormatter类的实例对象,这个实例对象能够将报表内容转换成 CSV 格式。

OK,到此为止 SOLID 五中面向对象设计原则已经讲完了,希望大家在日常编写代码的过程中能好好应用。