SOLID 原则简介
SOLID 原则是五个面向对象设计的基本原则,旨在帮助开发者构建易于管理和扩展的系统。具体包括:
- 单一职责原则(SRP) :一个类,一个职责。
- 开放封闭原则(OCP) :对扩展开放,对修改封闭。
- 里氏替换原则(LSP) :子类可替代基类。
- 接口隔离原则(ISP) :最小接口,避免不必要依赖。
- 依赖倒置原则(DIP) :依赖抽象,不依赖具体。
Swift 编程语言中也适用这些原则,遵循这些原则,Swift 开发者可以设计出更加灵活、易于维护和扩展的应用程序。
里氏替换原则
氏替换原则强调子类对象应该能够替换其超类对象被使用,而不破坏程序的正确性。换句话说,程序中的对象应该可以在不改变程序期望行为的情况下,被它们的子类所替换。
示例1: 遵守LSP的设计
错误代码
有一个Shape
父类,Shape
类是抽象类,两个子类SquareShape
、CircleShape
。
swift
class Shape { }
class SquareShape: Shape {
func drawSquare() { }
}
class CircleShape: Shape {
func drawCircle() { }
}
func draw(shape: Shape) {
if let square = shape as? SquareShape {
square.drawSquare()
} else if let circle = shape as? CircleShape {
circle.drawCircle()
}
}
另外,还有一个draw(shape:)
方法,参数为Shape
类型。在该方法中,尝试将行参转变为子类:
scss
func draw(shape: Shape) {
if let square = shape as? SquareShape {
square.drawSquare()
} else if let circle = shape as? CircleShape {
circle.drawCircle()
}
}
let square: Shape = SquareShape()
draw(shape: square)
- 入参为子类,则绘制图形。
- 入参为父类,则不做处理。
这个示例不仅违背了 里氏替换 原则,也违背了 开闭原则 。 如果要增加 Triangle
,就需要添加 if-case
语句,以便可以绘制。
优化后代码
创建一个协议,协议声明一个公共方法draw()
,让SquareShape
和CircleShape
类遵守协议,这样子类与父类就不会有不同的行为,进而遵守了里氏替换原则。
swift
protocol Shape {
func draw()
}
class SquareShape: Shape {
func draw() {
// draw the square
}
}
class CircleShape: Shape {
func draw() {
// draw the circle
}
}
func draw(shape: Shape) {
shape.draw()
}
这样也可以让draw(shape:)
方法对于修改关闭。当增加了新图案类型,其必须遵守Shape
协议,进而实现draw()
方法。
csharp
public class TriangleShape: Shape {
public func draw() {
// draw the triangle
}
}
示例2:避免违反LSP的设计
Rectangle
类有两个属性 width
和 height
,一个计算面积的方法 area()
。Square
子类重写了属性set方法,以便设置一个边长的时候另一个边也同样的长度,即满足正方形四边等长。
错误代码
kotlin
class Rectangle {
var width: Int
var height: Int
init(width: Int, height: Int) {
self.width = width
self.height = height
}
func area() -> Int {
return width * height
}
}
class Square: Rectangle {
override var width: Int {
didSet {
super.height = width
}
}
override var height: Int {
didSet {
super.width = height
}
}
}
使用上述类的场景如下,值应该是25 还是 35?
ini
let square = Square(width: 10, height: 10)
let rectangle: Rectangle = square
rectangle.height = 7
rectangle.width = 5
print(rectangle.area())
设置rectangle
对象高为7、宽为5,因为我们不知道其真实类型为Square
,这里预期面积为7*5 = 35。但运行得到面积为25。这里就违背了里氏替换原则,因为Square
子类的行为与Rectangle
父类不一致。
另一个破坏里氏替换原则会来带问题的场景是开发、使用framework。当使用framework时,我们无需、也不想了解其私有结构。当使用其公开结构时,应有一致的行为表现,而不依赖对其私有结构的了解。
优化后代码
为了遵守LSP,我们可以使用组合而非继承,创建一个协议Geometrics
,让不同的实体有相同的行为。
swift
protocol Geometrics {
func area() -> Int
}
public class Rectangle {
public var width: Int
public var height: Int
public init(width: Int, height: Int) {
self.width = width
self.height = height
}
}
extension Rectangle: Geometrics {
public func area() -> Int {
return width * height
}
}
public class Square {
public var edge: Int
public init(edge: Int) {
self.edge = edge
}
}
extension Square: Geometrics {
public func area() -> Int {
return edge * edge
}
}
通常情况下,优先使用组合(composition)而非继承(inheritance),可以解决违背里氏替换原则问题。创建一个协议,让不同的实体有相同的行为,不会调用到不应使用的属性或方法。
css
let rectangle: Geometrics = Rectangle(width: 10, height: 10)
print(rectangle.area())
let rectangle2: Geometrics = Square(edge: 5)
print(rectangle2.area())
示例3:异常处理的补全
在这个例子中,EncryptedFileReader
继承自FileReader
并重写了read
方法。
swift
enum FileError: Error {
case fileNotFound
case unauthorizedAccess
case corruptedData
}
class FileReader {
func read(from path: String) throws -> String {
// 模拟读取文件,假设文件总是存在
return "File content"
}
}
class EncryptedFileReader: FileReader {
override func read(from path: String) throws -> String {
// 在读取之前进行解密处理
// 假设在某些情况下,数据可能被识别为损坏
let decrypted = "Decrypted file content"
let dataCorrupted = false // 通过某种逻辑确定
if dataCorrupted {
throw FileError.corruptedData
}
return decrypted
}
}
引入新异常类型,如EncryptedFileReader
中的FileError.corruptedData
,符合里氏替换原则(LSP)的精神,但这需要在实现和文档化方面进行细心的管理。里氏替换原则要求子类对象能够替换父类对象,而不改变程序的正确性。这不仅仅涉及到接口的一致性,也包括行为的兼容性------即子类在继承和扩展父类功能时,不应该破坏原有的契约和预期行为。
异常类型的引入与里氏替换原则
引入
EncryptedFileReader
的新异常类型是否符合里氏替换原则(LSP)取决于三个关键方面:
- 异常处理的兼容性 :新异常应与父类
FileReader
的异常处理逻辑兼容,即使它是特殊的异常类型。如果它不迫使调用者改变错误处理策略,这种设计遵守了LSP。例如,corruptedData
异常如果能够在现有错误处理框架内被处理,就是兼容的。- 预期行为的保持 :新异常不应改变方法的预期行为。
EncryptedFileReader
使用者应能透明处理新异常,如同处理FileReader
的其他异常一样,不改变基本的异常处理结构。- 透明性和文档 :透明性是LSP的核心,引入新异常时,应通过文档让使用者了解新异常的类型和处理方式。如果
EncryptedFileReader
文档清晰说明了这些,帮助使用者正确处理异常,则遵循了LSP。如果
EncryptedFileReader
在这些方面做到了兼容性、保持预期行为和透明性,其设计就符合LSP。这样的设计增强了代码的可维护性、可扩展性和健壮性,确保了软件组件间依赖关系的稳定性,避免了因扩展或修改导致的问题。
总结
实现里氏替换,开发者应当遵循以下指导原则:
- 保持接口一致性:子类应保持与基类相同的方法签名,确保在替换时,接口的使用方式保持不变。
- 确保结果的一致:子类方法执行的结果应与基类方法兼容,避免修改了基类预期的行为或输出格式。
- 避免子类中的强制类型转换:子类方法中避免使用对基类行为的强制类型转换,这可能会导致运行时错误。
遵循LSP可以带来以下好处:
- 提高代码的可复用性:通过继承和多态,可以使用通用的超类编写算法,然后通过不同的子类来扩展算法的行为,无需修改原有代码。
- 增加代码的可维护性:当子类替换超类时,不需修改代码,减少了因扩展和修改引入bug的风险。
- 提升代码的健壮性:确保了基类和子类之间的行为一致性,有助于避免运行时的错误。