原文:SOLID Principles Applied To Swift
作者:Marco Santarossa
翻译:mycstar
SOLID是由Robert C. Martin(Uncle Bob)创建的缩写。它代表面向对象编程的五个原则:单一职责原则,开放/封闭原则,Liskov替换原则,接口隔离原则和依赖反转原则。
应用这些原则,您可以解决架构的以下几个主要问题:
- 脆弱性
- 代码复用性差
- 僵化性
当然,Uncle Bob在他的文章中指出,这些规则并不是严格的,而是提高架构质量的指导方针。
规则不会把坏的程序员变成一个好的程序员。这些原则必须灵活使用。如果生搬硬套,那就像根本不应用一样糟糕。你必须足够聪明才能理解何时应用这些原则。
SOLID原则
单一职责原则(SRP)
“一个类应该有且只有一个变化的原因” -SRP:单一职责原则
每次你创建/改变一个类,你应该问自己:这个类有多少职责?
我们来看一个例子:
class Handler {
func handle() {
let data = requestDataToAPI()
let array = parse(data: data)
saveToDB(array: array)
}
private func requestDataToAPI() -> Data {
// send API request and wait the response
}
private func parse(data: Data) -> [String] {
// parse the data and create the array
}
private func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/…)
}
}
这个类有多少职责?
Handler类从API检索数据,解析API响应,创建一个String数组,并将数组保存在数据库)中。
一旦您认为必须在类Alamofire中以同样的方式处理API请求,使用ObjectMapper进行解析,并将CoreData堆栈保存在数据库中,您将开始了解此类的意义。
你可以把职责转移到小类来解决这个问题:
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 {
// send API request and wait the response
}
}
class ParseHandler {
func parse(data: Data) -> [String] {
// parse the data and create the array
}
}
class DBHandler {
func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/…)
}
}
这个原则可以帮助你保持代码尽可能的干净。此外,在第一个示例中,您无法直接测试requestDataToAPI,parse和saveToDB,因为这些是私有方法。重构后,您可以轻松地测试APIHandler,ParseHandler和DBHandler。
开放封闭原则(OCP)
“软件实体(类,模块,功能等)应该面向扩展开放,面向修改封闭。 – 开放封闭原则
如果要创建一个易于维护的类,它必须具有两个重要特征:
- 面向扩展开放:应该能够扩展或改变类的行为,而不用付出太多精力。
- 面向修改封闭:扩展一个类而不改变类代码的实现。
您可以通过抽象实现这些特性。
作为一个例子,我们有一个类Logger,它遍历数组类Cars,打印出每辆车的细节:
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的实现--破坏了OCP原则:
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)”
}
}
Liskov替换原则(LSP)
“使用基类对象指针或引用的函数必须能够在不了解衍生类的条件下使用衍生类的对象。 - Liskov替换原则
继承可能是危险的,您应该使用组合继承来避免一个凌乱的代码库,如果您以不正确的方式使用继承,更是如此。
这个原则可以帮助你使用继承,而不会弄乱代码。让我们看看破坏LSP原则的主要问题:
前提条件改变
我们有一个类Handler,它负责将一个字符串保存在云服务中。后来,业务逻辑发生变化,有时候,如果字付串长度大于5,则必须保存此字符串。因此,我们决定创建一个子类FilteredHandler:
class Handler {
func save(string: String) {
// Save string in the Cloud
}
}
class FilteredHandler: Handler {
override func save(string: String) {
guard string.characters.count > 5 else { return }
super.save(string: string)
}
}
此示例打破了LSP原则,因为在子类中,我们添加了string长度大于5的前提条件。Handler的客户端不期望FilteredHandler具有不同的前提条件,因为它对于Handler及其子类都应该是相同的。
我们可以解决这个问题,去掉FilteredHandler类,添加一个新的注入参数过滤字付串的最小长度:
class Handler {
func save(string: String, minChars: Int = 0) {
guard string.characters.count >= minChars else { return }
// Save string in the Cloud
}
}
后置条件改变
我们有一个项目,计算一些矩形对象的面积,所以我们创建了类Rectangle。几个月后,我们还需要计算正方形对象的面积,所以我们决定创建一个子类Square。由于在一个正方形中,我们只需要一条边来计算面积,而且我们不想覆盖面积计算方法-我们决定将width的值分配给length:
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
}
}
}
使用这种方法,我们打破LSP原则,因为如果客户端具有以下方法:
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
接口隔离原则(ISP)
“客户不应该被迫依赖于他们不使用的接口。” – 接口隔离原则
这个原则引入了面向对象编程的一个问题:胖接口。
当有太多的成员/方法,这个接口被称为“胖”,它们不是一致的,并且包含比我们真正想要的更多的信息。此问题可能会影响类和协议。
胖接口(协议)
我们从协议GestureProtocol开始,它有方法didTap:
protocol GestureProtocol {
func didTap()
}
一段时间后,您必须向协议添加新的手势,变成:
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}
我们的SuperButton类很乐意实现所需的方法:
class SuperButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
问题是我们的应用程序还有一个PoorButton,它只需要didTap。它必须实现它不需要的方法,打破了ISP原则:
class PoorButton: GestureProtocol {
func didTap() {
// send 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() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
class PoorButton: TapProtocol {
func didTap() {
// send tap action
}
}
胖接口(类)
作为示例,我们使用具有可播放视频的集合的应用程序。此应用程序的video类代表用户集合的视频:
class Video {
var title: String = “My Video”
var description: String = “This is a beautiful video”
var author: String = “Marco Santarossa”
var url: String = “https://marcosantadev.com/my_video”
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
我们将其注入视频播放器:
func play(video: Video) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with 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 = “My Video”
var description: String = “This is a beautiful video”
var author: String = “Marco Santarossa”
var url: String = “https://marcosantadev.com/my_video”
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
func play(video: Playable) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with video.duration
}
这种方法对于单元测试非常有用。我们可以创建一个实现协议的骨架类Playable:
class StubPlayable: Playable {
private(set) var isTitleRead = false
var title: String {
self.isTitleRead = true
return “My Video”
}
var duration = 60
var url: String = “https://marcosantadev.com/my_video”
}
func test_Play_IsUrlRead() {
let stub = StubPlayable()
play(video: stub)
XCTAssertTrue(stub.isTitleRead)
}
依赖反转原则(DIP)
“ - A.高级模块不应该依赖于低级模块,两者都应该取决于抽象。
- B.抽象不应该依赖于细节,细节应该取决于抽象。“ –依赖反转原则
如果您相信可重复使用的组件,则该原则是正确的。
DIP与开放封闭原则非常相似:使用这个原则,具有清晰的体系结构并用解耦依赖关系。可以通过抽象层来实现它。
我们来看一下类Handler,它将一个字符串保存在文件系统中。它在内部调用FilesystemManager来管理在文件系统中如何保存字符串:
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 the string in a table
// Close the connection
}
}
这种方法对测试非常有用。您可以轻松地使用一个骨架类(它实现了Storage),并且测试是否handle调用了注入Storage对象的方法save:
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)
}
}
如果您明智地遵循SOLID原则,您可以提高代码的质量。此外,您的组件可以变得更加可维护和可重用。
掌握这些原则不是成为完美开发人员的最后一步;其实这只是一个开始。您将不得不处理项目中的不同问题,了解最佳方法,最后检查您是否违反了某些原则。
如果你有三个敌人要打败:脆弱性,复用性差和僵化性。SOLID原则是你的武器。请享用!