Swift SOLID 4. 里氏替换原则

SOLID 原则简介

SOLID 原则是五个面向对象设计的基本原则,旨在帮助开发者构建易于管理和扩展的系统。具体包括:

  1. 单一职责原则(SRP) :一个类,一个职责。
  2. 开放封闭原则(OCP) :对扩展开放,对修改封闭。
  3. 里氏替换原则(LSP) :子类可替代基类。
  4. 接口隔离原则(ISP) :最小接口,避免不必要依赖。
  5. 依赖倒置原则(DIP) :依赖抽象,不依赖具体。

Swift 编程语言中也适用这些原则,遵循这些原则,Swift 开发者可以设计出更加灵活、易于维护和扩展的应用程序。

里氏替换原则

氏替换原则强调子类对象应该能够替换其超类对象被使用,而不破坏程序的正确性。换句话说,程序中的对象应该可以在不改变程序期望行为的情况下,被它们的子类所替换。

示例1: 遵守LSP的设计

错误代码

有一个Shape父类,Shape类是抽象类,两个子类SquareShapeCircleShape

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(),让SquareShapeCircleShape类遵守协议,这样子类与父类就不会有不同的行为,进而遵守了里氏替换原则。

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类有两个属性 widthheight,一个计算面积的方法 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)取决于三个关键方面:

  1. 异常处理的兼容性 :新异常应与父类FileReader的异常处理逻辑兼容,即使它是特殊的异常类型。如果它不迫使调用者改变错误处理策略,这种设计遵守了LSP。例如,corruptedData异常如果能够在现有错误处理框架内被处理,就是兼容的。
  2. 预期行为的保持 :新异常不应改变方法的预期行为。EncryptedFileReader使用者应能透明处理新异常,如同处理FileReader的其他异常一样,不改变基本的异常处理结构。
  3. 透明性和文档 :透明性是LSP的核心,引入新异常时,应通过文档让使用者了解新异常的类型和处理方式。如果EncryptedFileReader文档清晰说明了这些,帮助使用者正确处理异常,则遵循了LSP。

如果EncryptedFileReader在这些方面做到了兼容性、保持预期行为和透明性,其设计就符合LSP。这样的设计增强了代码的可维护性、可扩展性和健壮性,确保了软件组件间依赖关系的稳定性,避免了因扩展或修改导致的问题。

总结

实现里氏替换,开发者应当遵循以下指导原则:

  • 保持接口一致性:子类应保持与基类相同的方法签名,确保在替换时,接口的使用方式保持不变。
  • 确保结果的一致:子类方法执行的结果应与基类方法兼容,避免修改了基类预期的行为或输出格式。
  • 避免子类中的强制类型转换:子类方法中避免使用对基类行为的强制类型转换,这可能会导致运行时错误。

遵循LSP可以带来以下好处:

  • 提高代码的可复用性:通过继承和多态,可以使用通用的超类编写算法,然后通过不同的子类来扩展算法的行为,无需修改原有代码。
  • 增加代码的可维护性:当子类替换超类时,不需修改代码,减少了因扩展和修改引入bug的风险。
  • 提升代码的健壮性:确保了基类和子类之间的行为一致性,有助于避免运行时的错误。
相关推荐
一丝晨光2 天前
继承、Lambda、Objective-C和Swift
开发语言·macos·ios·objective-c·swift·继承·lambda
KWMax2 天前
RxSwift系列(二)操作符
ios·swift·rxswift
Mamong3 天前
Swift并发笔记
开发语言·ios·swift
小溪彼岸3 天前
【iOS小组件】小组件尺寸及类型适配
swiftui·swift
Adam.com3 天前
#Swift :回调地狱 的解决 —— 通过 task/await 来替代 nested mutiple trailing closure 来进行 回调的解耦
开发语言·swift
Anakki4 天前
【Swift官方文档】7.Swift集合类型
运维·服务器·swift
KeithTsui4 天前
集合论(ZFC)之 联合公理(Axiom of Union)注解
开发语言·其他·算法·binder·swift
東三城4 天前
【ios】---swift开发从入门到放弃
ios·swift
文件夹__iOS7 天前
[SwiftUI 开发] @dynamicCallable 与 callAsFunction:将类型实例作为函数调用
ios·swiftui·swift