单一原则
每次我们创建或者修改一个类的时候,都会考虑这个类都有哪些职责?
比如:
class Handler {
func handle() {
let data = requestDataToAPI()
let array = parse(data: data)
saveToDB(array: array)
}
private func requestDataToAPI() -> Data {
// 发送API请求等待响应
}
private func parse(data: Data) -> [String] {
// 解析数据返回
}
private func saveToDB(array: [String]) {
// 保存数据到数据库 (CoreData/Realm/...)
}
}
这个类有多少个职责呢?
(1)Handler通过API请求数据 (2)解析响应的数据,并创建字符串数组返回 (3)将数组数据保存到数据库
一旦你考虑到需要用Alamofire去请求api,ObjectMapper用于数据解析,CoreData Stack去保存数据,那么你就开始体会到这个类是干什么的了。
你可以把一些职责添加到这个类里面来解决这个问题:
class Handler {
let apiHandler: APIHandler
let parseHandler: ParseHandler
let dbHandler: DBHandler
init(apiHandler: APIHandler, parseHandler: ParseHandler, dbHandler: DBHandler) {
self.apiHandler = apiHandler
self.parseHandler = parseHandler
self.dbHandler = dbHandler
}
func handle() {
let data = apiHandler.requestDataToAPI()
let array = parseHandler.parse(data: data)
dbHandler.saveToDB(array: array)
}
}
class APIHandler {
func requestDataToAPI() -> Data {
// 发送API请求等待响应
}
}
class ParseHandler {
func parse(data: Data) -> [String] {
// 解析数据返回
}
}
class DBHandler {
func saveToDB(array: [String]) {
// 保存数据到数据库 (CoreData/Realm/...)
}
}
这条原则尽量帮我们保持类的干净。而且在第一个demo里面还不能直接测试requestDataToAPI,parse,saveToDB这三个方法,因为它们都是私有方法。在重构之后,你就很容易的去测试APIHandler,ParseHandler和DBHandler。
开闭原则
如果我们想创建一个类易于维护,那么它必须具备两个重要的特点:
- 对扩展开放(Open for extension):能够不费力气地去扩展和更改类的行为。
- 对修改关闭(Closed for modification):必须在不改变实现的情况下扩展类。
我们可以通过抽象来实现这些特性。
比如,我们有个类Logger,遍历一系列汽车并打印每辆车的详细信息:
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
如果你想去打印一个新的类的详细信息,那么我们每次就需要去改变printData的实现:
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
let bicycles = [
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
bicycles.forEach { bicycles in
print(bicycles.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
我们可以通过创建一个新的协议Printable来解决这个问题,通过类实现。最后,printData将打印一个Printable的数组。
通过这种方式,我们在printData和类之间创建一个新的抽象层来记录,允许打印其他的一些类的信息,就像Bicycle,同时也不会改变printData的实现。
protocol Printable {
func printDetails() -> String
}
class Logger {
func printData() {
let cars: [Printable] = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey"),
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle: Printable {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
里氏替换原则
有时继承可能是危险的,我们应该使用组合而不是继承来避免混乱的代码库。如果不恰当的使用继承的方式,那组合就显得尤为重要了。
这种方式能帮助我们在不破坏继承的方式上使用继承。我们可以先看一下破坏里氏替换原则的一些问题:
先决条件的改变
我们有一个类Handler,它的职责是存储一个字符串在云服务器。有时候,可能业务逻辑会发生变化,需要字符串长度大于5,才能保存字符串。因此,我们决定创建一个子类FilteredHandler:
class Handler {
func save(string: String) {
// 保存字符串在服务器
}
}
class FilteredHandler: Handler {
override func save(string: String) {
guard string.characters.count > 5 else { return }
super.save(string: string)
}
}
这个demo就打破了里氏替换原则,因为在子类中我们添加了一个字符串的长度必须大于5的先决条件。Handler不希望FilteredHandler有不同的先决条件,因为它应该是Handler和它的子类都有相同的条件。
所以我们完全可以去掉FilteredHandler并且添加一个最小值字符串长度的参数去过滤:
class Handler {
func save(string: String, minChars: Int = 0) {
guard string.characters.count >= minChars else { return }
// 保存字符串在服务器
}
}
后置条件改变
比如我们有个需求就是去计算一个矩形的面积,因此我们要创建一个类Rectangle。两个月后,我们要计算正方形的面积,因此我们要去创建一个子类Square。因为正方形,我们只需要知道一条边去计算面积,我们不需要去重载area的计算方法,我们只需要把width赋值给height:
class Rectangle {
var width: Float = 0
var length: Float = 0
var area: Float {
return width * length
}
}
class Square: Rectangle {
override var width: Float {
didSet {
length = width
}
}
}
如果项目中有下面这个方法,就打破了里氏替换原则:
func printArea(of rectangle: Rectangle) {
rectangle.length = 5
rectangle.width = 2
print(rectangle.area)
}
在下面两次调用中,结果应该是相同的:
let rectangle = Rectangle()
printArea(of: rectangle) // 10
// -------------------------------
let square = Square()
printArea(of: square) // 4
相反,第一个打印的是10,第二个打印的是4。这就意味着,在继承之后,我们打破了width的后置条件:((width == newValue) && (height == height))。
那么我们可以使用有一个area方法的协议,被Rectangle和Square实现。将printArea的参数改成实现该协议的对象:
protocol Polygon {
var area: Float { get }
}
class Rectangle: Polygon {
private let width: Float
private let length: Float
init(width: Float, length: Float) {
self.width = width
self.length = length
}
var area: Float {
return width * length
}
}
class Square: Polygon {
private let side: Float
init(side: Float) {
self.side = side
}
var area: Float {
return pow(side, 2)
}
}
// Client Method
func printArea(of polygon: Polygon) {
print(polygon.area)
}
// Usage
let rectangle = Rectangle(width: 2, length: 5)
printArea(of: rectangle) // 10
let square = Square(side: 2)
printArea(of: square) // 4
接口隔离原则
client不应该被迫去使用它们不使用的接口。
这个原则介绍了面向对象的一个问题:臃肿的接口。
一个接口之所以臃肿,就是它里面包含了太多的成员变量和方法,都是些没用结合力,包含了一些比我们真正想要的多的信息。这个问题会影响到类和协议。
臃肿接口(Protocol)
在项目中,开始的时候我们写个包含didTap方法的协议:
protocol GestureProtocol {
func didTap()
}
过来不久,你必须去添加一些新的手势,然后变成了:
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}
比如我们有个超级按钮(SuperButton)需要去实现GestureProtocol,那么它需要:
class SuperButton: GestureProtocol {
func didTap() {
// tap action
}
func didDoubleTap() {
// double tap action
}
func didLongPress() {
// long press action
}
}
那么问题来了,我们的app里面有个类PoorButton,只需要didTap,它必须去实现它不需要的方法,这样就打破了接口隔离原则:
class PoorButton: GestureProtocol {
func didTap() {
// tap action
}
func didDoubleTap() { }
func didLongPress() { }
}
我们可以使用小的协议去代替大的协议来解决:
protocol TapProtocol {
func didTap()
}
protocol DoubleTapProtocol {
func didDoubleTap()
}
protocol LongPressProtocol {
func didLongPress()
}
class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
func didTap() {
// tap action
}
func didDoubleTap() {
// double tap action
}
func didLongPress() {
// long press action
}
}
class PoorButton: TapProtocol {
func didTap() {
// tap action
}
}
臃肿接口(Class)
例如,我们有一个可以播放视频集的应用。这个app有一个类Video,代表一个视频:
class Video {
var title: String = "让子弹飞"
var description: String = "就是子弹想飞就飞"
var author: String = "姜文"
var url: String = "https://baidu.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
同时我们想它注入到视频播放中:
func play(video: Video) {
// 加载播放UI
// 通过video.url加载视频
// 将video.title添加到播放UI的title上
// 用video.duration更新播放进度条
}
可是,我们注入太多信息到play方法中,因为它只需要url,title,duration。
我们可以使用只提供给播放需要的一个协议Playable来解决该问题:
protocol Playable {
var title: String { get }
var url: String { get }
var duration: Int { get }
}
class Video: Playable {
var title: String = "让子弹飞"
var description: String = "就是子弹想飞就飞"
var author: String = "姜文"
var url: String = "https://baidu.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
func play(video: Playable) {
// 加载播放UI
// 通过video.url加载视频
// 将video.title添加到播放UI的title上
// 用video.duration更新播放进度条
}
这种方法对于单元测试也比较有用。我们可以创建该协议的一个子类:
class StubPlayable: Playable {
private(set) var isTitleRead = false
var title: String {
self.isTitleRead = true
return "让子弹飞"
}
var duration = 60
var url: String = "https://baidu.com/my_video"
}
func test_Play_IsUrlRead() {
let stub = StubPlayable()
play(video: stub)
XCTAssertTrue(stub.isTitleRead)
}
依赖倒置原则
- 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
- 抽象不应该依赖于具体。具体应该依赖于抽象。
这条原则就很适用于组件重用。
依赖倒置原则与开闭原则比较相似,使用这种方式,需要有一个干净的体系结构,就是解耦依赖关系,可以通过抽象层来实现它。
例如,有一个Handler类,用于在文件系统中存储字符串。它在内部调用文件系统管理器,管理如何在文件系统中保存字符串:
class Handler {
let fm = FilesystemManager()
func handle(string: String) {
fm.save(string: string)
}
}
class FilesystemManager {
func save(string: String) {
// Open a file
// Save the string in this file
// Close the file
}
}
FilesystemManager是一个低级的模块,可能在很多项目中使用。问题是高级模块Handler,与FilesystemManager紧密耦合,所以不能重用。我们应该能够重用不同类型存储的高级模块,比如数据库、云等等。
我们可以使用协议Storage来解决这个依赖关系。通过这种方式,Handler可以使用这个抽象的协议,而不需要关心所使用的存储类型。通过这种方法,我们可以轻松地从文件系统更改到数据库:
class Handler {
let storage: Storage
init(storage: Storage) {
self.storage = storage
}
func handle(string: String) {
storage.save(string: string)
}
}
protocol Storage {
func save(string: String)
}
class FilesystemManager: Storage {
func save(string: String) {
// Open a file in read-mode
// Save the string in this file
// Close the file
}
}
class DatabaseManager: Storage {
func save(string: String) {
// Connect to the database
// Execute the query to save ...
// Close the connection
}
}
这个原则对于测试也是非常有用的。可以很容易地使用Storage的一个子类,去测试handle方法,将Storage子类的实例注入其中:
class StubStorage: Storage {
var isSavedCalled = false
func save(string: String) {
isSavedCalled = true
}
}
class HandlerTests {
func test_Handle_IsSaveCalled() {
let handler = Handler()
let stubStorage = StubStorage()
handler.handle(string: "test", storage: stubStorage)
XCTAssertTrue(stubStorage.isSavedCalled)
}
}