原文链接: SOLID Principles in Ruby
转载请注明出处:http://www.tedyin.me/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
属性。
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
原则重构一下代码,重构后的代码如下:
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)
开闭原则的定义如下:
一个类或者模块对扩展开放,对修改关闭。
什么意思呢?他的意思就是:一旦一个类已经实现了当时的需求,他就不应该为了去实现接下来的需求而被修改。你是不是觉得这样做没有意义?那我们下面看个例子来说明一下:
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
格式化输出出来。非常简单对吧,下面来测试一下:
irb> MyLogger.new.log('test!')
=> 2016-02-28 16:16:32 +0200: test!
测试OK,没什么问题,但是假如在以后的某一天,我们想改变一下输出的格式,想实现下面的日志格式
=> [LOG] 2016-02-28 16:16:32 +0200: test!
怎么办呢?假如现在由一个不懂OCP
原则的程序员来实现上述格式,实现的代码如下:
class MyLogger
def initialize
@format_string = "[LOG] %s: %s\n"
end
end
输出的结果如下:
irb> MyLogger.new.log('test!')
=> [LOG] 2016-02-28 16:16:32 +0200: test!
输出的格式完全符合要求,一切都OK,但是这样做真的就对吗?
仔细想想,假如上面被修改的类是一个App中的核心类,对format_string
方法的修改,可能会破坏那些依赖MyLogger
类的方法使得他们不能正常的工作。也许在APP中存在许许多多的类都依赖刚才说的那些方法,但是现在我们修改了代码,破坏了这些类和方法。这就是在破坏OCP
原则,这会导致灾难性的后果。
既然不遵循OCP
原则会有很严重的问题,那么实现上面修改日志格式需求的正确姿势是什么呢?毫无疑问当然是继承
或者组合
!
我们来看看下面使用继承的例子:
class NewCoolLogger < MyLogger
def initialize
@format_string = "[LOG] %s: %s\n"
end
end
irb> NewCoolLogger.new.log('test!')
=> [LOG] 2016-02-28 16:16:32 +0200: test!
棒呆!和我们预期的一样,那MyLogger
的输出呢?
irb> MyLogger.new.log('test!')
=> 2016-02-28 16:16:32 +0200: test!
还是棒呆!那么我刚刚都干了些啥呢?我们创建了一个新的NewCoolLogger
类,扩展(extend)
了MyLogger
类。那些之前依赖老的logger方法的类和方法依然可以正常的工作,老的logger还是和以前一样提供相同的方法,新的logger则提供新的logger方法,这是我们所期待的。
我刚才说了两种重构方式,下面我们来看看使用另外一种方式组合
来重构代码的例子:
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 Liskov对LSP
原则的定义如下:
如果S是T的一个子类,那么不需要修改代码中的任何配置和属性,S的实例也可以替换T的实例对象,而且不影响代码的正常运行。
坦白的讲,我觉得这个定义是非常难理解的,因此经过一番思考,总结下来如下:
假如现在有一个Bird
类,还有两个实例对象 obj1 和 obj2。obj1 是 Duck 类的对象,Duck 类是 Bird 类的子类,obj2 是 Pigeon 类的对象,Pigeon 类也是Bird 类的子类。LSP
原则的意思是,obj2 是Bird子类的实例,obj1 是Bird子类的实例,因此我们应当把 obj1 和 obj2 等同对待,都当做Bird的实例对待。
译者注:其实我觉得上面的定义已经说的很清楚了,上面说的 obj1 之类的例子有点多余。。。
下面我们来看个例子来说明下:
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)
接口分离原则的定义如下:
不应该强迫客户端依赖一些他们用不到的方法或接口。
就像定义那样很简单,我们来看看代码说明一下:
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
在这个例子中有Computer
,Programer
,Technician
三个类。其中Programer
,Technician
会使用到电脑,而且是以不同的方式使用,Programer
使用的是type
方法,Technician
用的是change_hard_drive
,按照LSP
原则要求 不应当强迫客户端依赖一些他们用不到的接口或者方法,Programer
类用不到change_hard_drive
方法,同样的Technician
用不到type
方法,但是一旦这两个方法发生变化,那么就有可能影响到Programer
或者Technician
类的正常使用。下面我们重构一下代码,来满足LSP
原则
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)
依赖倒置原则代表了一种软件模块解耦的方式,他的定义有两部分:
- 上层模块不应该依赖下层模块,他们应该都依赖抽象。
- 抽象不能依赖具体实现,具体实现应该依赖抽象。
我知道这个理解起来有点绕,但是在开始看具体的例子之前,我希望你不要把 依赖倒置 和 依赖注入 弄混淆,后者是一种软件技巧或者说是一种软件设计模式,而前者是面向对象设计原则的一种。
好了下面来看看具体的例子:
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_xml
,print_csv
,print_json
等。这意味着Report
类和具体实现绑的非常紧,耦合非常高,他依赖格式化类的类型,但却不依赖这些格式化类的抽象。
译者注:Report 类就是只知道有这么多个格式化类,但是却不知道他们之间有什么共同特点,依赖这些具体的类却不依赖他们的共同特点,也就是不依赖抽象。假如现在又有新的格式,Report 还得去了解新的格式类,如果依赖他们共同拥有的一个格式化的接口,那Report就不用去操心你这个格式化的类到底是格式化成啥了,我直接调用这个格式化的方法就行了。
下面我们重构一下代码:
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 格式的报表,我们只需要提供下面这个类就行了。
class CSVFormatter
def generate(body)
# convert the body argument into CSV
end
end
Report#print
方法将会接收一个CSVFormatter
类的实例对象,这个实例对象能够将报表内容转换成 CSV 格式。
OK,到此为止 SOLID 五中面向对象设计原则已经讲完了,希望大家在日常编写代码的过程中能好好应用。