Swift 作为现代、高效、安全的编程言语,其背后有许多高档特性为之支撑。

『 Swift 最佳实践 』系列对常用的言语特性逐一进行介绍,助力写出更简洁、更高雅的 Swift 代码,快速完成从 OC 到 Swift 的转变。

该系列内容首要包括:

  • Optional
  • Enum
  • Closure
  • Protocol
  • Generics
  • Property Wrapper
  • Error Handling
  • Advanced Collections
  • Pattern Matching
  • Metatypes(.self/.Type/.Protocol)
  • High Performance

ps. 本系列不是入门级语法教程,需求有必定的 Swift 基础

本文是系列文章的第七篇,介绍在 Swift 中怎么处理过错 ⚡️ ❌。

Overview


合理、高雅地处理过错关于 App 的体会至关重要,也是对开发人员基本功的考验。

但是在实践项目中对过错的处理不甚抱负,要么完全忽视过错、要么以「过错的办法」处理过错。

如:常见的白屏、履行某个操作 (点击按钮) 没任何反响或许都是对过错处理不妥引起的,用户体会欠安。

Swift 有明晰、完善的过错处理机制帮助开发人员处理过错,本文将对此打开详细介绍。

Kinds of Error


程序运转过程中或许呈现的过错千奇百怪,大约能够分为 3 类:

  • 代码缺点,雅称「bug 」,如代码逻辑上的过错、➗ 0、越界等

    此类问题,除了发版 fix,别无他法

  • 不合法性输入,首要指用户输入的内容不合法、办法的入参不合法等

  • 运转时过错,首要指程序运转过程中呈现的过错,如:网络超时、文件不存在、数据库链接反常、Server 响应内容过错等

本文讨论的首要是后 2 类过错。

依据过错严峻程度 (过错产生后程序是否能够持续运转) 以及复杂性,Swift 供给了多种不同的处理办法:

  • Optional
  • Result
  • throws
  • assert
  • precondition
  • fatalError

Optional


关于一些简单的过错场景,假如用 throws 那套流程处理或许有点重,此刻 Optional 或许是一个不错的挑选。

如:String –> Int 的转化,并不是一切的 String 都能转成对应的 Int,转化失利时能够回来 nil

Swift 在规划 Initializer 时也考虑到此类状况,即初始化或许会失利,并供给了 Failable Initializers 机制,在无法完成初始化时能够挑选回来 nil,代表初始化失利。

界说 Failable Initializer 只需在 init 关键字后加上 ? 即可 (init?),如:

struct Animal {
  let species: String
  // 
  init?(species: String) {
    //                          
    if species.isEmpty { return nil }
    self.species = species
  }
}

Swift Int 供给的从 String 转化 Int 的 init 也是 Failable Initializer:

struct Int {
  public init?(_ description: String)
}

能够看到经过回来 Optional 处理过错时,无法传递过错信息,只知道失利了,不知道为什么失利。

有时过错原因也很重要,能够给用户供给恰当的反应 (关于用户体会也很重要)。

Modeling Errors


为了表明过错,封装过错信息,Swift 供给了 Error 协议:

public protocol Error : Sendable {}

能够看到 Error 是个空协议,首要用于标记过错模型,后文讲到的 Result、throws 都是依据 Error 类型。

public protocol Sendable {}

Sendable 也是个空协议,首要用于并发下的线程安全,概况可参看「 Swift 新并发结构之 Sendable 」。

因为 Error 是个空协议,任何 class、struct、enum 都能够声明完成它。

Using Enumerations as Errors

Swift’s enumerations are well suited to represent simple errors. Create an enumeration that conforms to the Error protocol with a case for each possible error. If there are additional details about the error that could be helpful for recovery, use associated values to include that information.

— Error – Apple Developer Documentation

正如 Swift 最佳实践之 Enum 一文所讲,经过 enum 界说过错模型是一个非常好的挑选:

  • 经过 case 界说不同的过错状况
  • 详细过错信息经过 associated values 附加在相应的 case 上

如,Swift 规范库界说的解码过错模型:

public enum DecodingError : Error {
  public struct Context : Sendable {
    public let codingPath: [CodingKey]
    public let debugDescription: String
    public let underlyingError: (Error)?
    public init(codingPath: [CodingKey], debugDescription: String, underlyingError: (Error)? = nil)
  }
  case typeMismatch(Any.Type, DecodingError.Context)
  case valueNotFound(Any.Type, DecodingError.Context)
  case keyNotFound(CodingKey, DecodingError.Context)
  case dataCorrupted(DecodingError.Context)
}

Including More Data in Errors

Sometimes you may want different error states to include the same common data, such as the position in a file or some of your application’s state. When you do, use a structure to represent errors.

Error – Apple Developer Documentation

假如不同的过错状况间有相同的数据需求相关,那 struct 也是一种挑选。

如,XML 解析失利,无论是哪种失利原因,都需求附带犯错的行号、列号,则能够将XMLParsingError 界说为 struct:

struct XMLParsingError: Error {
  enum ErrorKind {
    case invalidCharacter
    case mismatchedTag
    case internalError
  }
  let line: Int
  let column: Int
  let kind: ErrorKind
}

Result


界说办法 loadImage:经过 url 加载对应的图片,怎么规划?

func loadImage(url: String, completion: (UIImage?, Error?) -> Void)

如上,loadImage 必定是个异步办法,故经过 closure 回来成果。

因为加载或许会失利,故 completion 用了 2 个 Optional 参数别离表明成功(UIImage?)、失利(Error?)

上述规划怎么?

不咋地!

问题首要出在 2 个 Optional 参数上,那有没有更好的办法❓

为了处理此类问题,Swift 5 引入了新类型 Result

/// A value that represents either a success or a failure, including an
/// associated value in each case.
@frozen public enum Result<Success, Failure> where Failure : Error {
  /// A success, storing a `Success` value.
  case success(Success)
  /// A failure, storing a `Failure` value.
  case failure(Failure)
  @inlinable public func map<NewSuccess>(_ transform: (Success) -> NewSuccess) -> Result<NewSuccess, Failure>
  @inlinable public func mapError<NewFailure>(_ transform: (Failure) -> NewFailure) -> Result<Success, NewFailure> where NewFailure : Error
  @inlinable public func flatMap<NewSuccess>(_ transform: (Success) -> Result<NewSuccess, Failure>) -> Result<NewSuccess, Failure>
  @inlinable public func flatMapError<NewFailure>(_ transform: (Failure) -> Result<Success, NewFailure>) -> Result<Success, NewFailure> where NewFailure : Error
  @inlinable public func get() throws -> Success
}

能够看到,Result 是个 enum:

  • case success(Success),表明成功,详细信息存储在相关值中
  • case failure(Failure),表明失利,失利信息也存储在相关值中

经过 ResultloadImage 办法能够改写为:

func loadImage(url: String, completion: (Result<UIImage, Error>) -> Void)

相比之下,Result 优势首要有:

  • 更结构化的表达,将成果一致封装在一个 enum 内,而不是 2 个离散的 Optional

  • Result 供给了 mapflatMap 等快捷的办法

  • 对过错处理带有必定的强制性⚡️、提示性⚠️

    • 调用方拿到的是个 enum,在 switch 时对 2 个 case 都需求处理,而关于 2 个离散的 Optional 完全能够挑选疏忽
    • 关于界说方,在犯错时必须运用 .failure,而不能随意的对 Optional Error 赋个 nil

Result 首要用于异步办法,那同步办法呢?

Throwing Errors


在 Swift 中,关于同步办法的过错处理,除了上面说到的用 Optional 表明,还能够对外显式声明⚡️:「该办法运转过程中或许会抛出过错 ❌」。

如下,readFile 经过在办法界说中增加 throws 关键字表明其或许会抛出过错:

//                                   
func readFile(atPath path: String) throws -> String

throws 声明的办法称为:throwing method

办法界说中没有增加 throws 关键字的,必定不会抛出过错。

throwing method 内部在产生过错时能够经过 throw 抛出过错,如:

struct FileHandler {
  let fileManager: FileManager = .default
  enum FileHandleError: Error {
    case invalidPath
    case notExists
    case unreadable
    case `nil`
  }
  func readFile(atPath path: String) throws -> String {
    guard !path.isEmpty else {
      // 
      throw FileHandleError.invalidPath
    }
    guard fileManager.fileExists(atPath: path) else {
      throw FileHandleError.notExists
    }
    guard fileManager.isReadableFile(atPath: path) else {
      throw FileHandleError.unreadable
    }
    guard let data = fileManager.contents(atPath: path) else {
      throw FileHandleError.nil
    }
    let decoder = JSONDecoder()
    return try decoder.decode(String.self, from: data)
  }
}

Propagating Errors


在许多言语中,如:C++、Java、Python 等,都有反常传达的概念 (Exception Propagation)。

但是,Swift 对 Error 的传递与其他言语反常传达的完成机制有较大差异,故 Swift 竭力防止运用 Exception Propagation 这一词。

但是,关于开发者来说,Swift Error 传递与 Exception Propagation 在了解、运用上并无太大差异。

下面的讨论咱们还是依据 Swift Error Propagation 这个术语打开。

什么是 Error Propagation 呢 ❓

如下图:

  • Error 沿着办法调用链 「逆向」 传达
  • Error Propagation 起于 throwing method,总算 non-throwing method
  • non-throwing method 是不能抛出 Error 的

Swift 最佳实践之 Error Handling

关于 throwing method 的调用需加上关键字 try (或变体 try!try?),如:

//             
let content = try readFile(atPath: "path")

因为或许会抛出过错,故 try 调用只能呈现在以下 2 个当地:

  • throwing method 内,此刻 Error 将向上传达,如:

    //                  
    func loadConfig() throws -> String {
      //
      try readFile(atPath: "path.config")
    }
    func parseConfig() throws -> Config {
      let configStr = try loadConfig()
      return Config(configStr)
    }
    // error propagation chain: readFile -> loadConfig -> parseConfig -> ...
    
  • do...catch 中,一般是过错传达的终点站

OC 中经过 in-out params 来传递 Error「(NSError **)error」,如:

- (nullable NSArray<NSString *> *)contentsOfDirectoryAtPath:(NSString *)path error:(NSError **)error

很明显,OC 的 Error Propagation 没有任何约束性,调用方完全能够疏忽

rethrows

咱们知道 Closure 本质上是匿名函数,故闭包也能够 throw error。

//                                                      
func configModel(transformer: () throws -> ConfigModel) throws -> ConfigModel {
  try transformer()
}

如上,configModel 办法自身不会抛出过错,但其入参 transformer或许 」会抛出过错,导致 configModel 也必须被界说为 throwing method❗️

好像没问题❓❗️

调用 configModel 办法必须按 Propagating Errors 来处理:

do {
  try configModel {
    return ConfigModel()   // , no any errors are thrown!
  }
} catch {
  // ...
}

如上,传给 configModel 的 Closure (transformer) 100% 不会抛出过错,但也必须按 Propagating Errors 来调用 configModel❗️

合理吗?

rethrows 正是用于优化此问题的:

//                                                         
func configModel(transformer: () throws -> ConfigModel) rethrows -> ConfigModel {
  try transformer()
}

关于办法自身不会抛出过错,但入参或许会抛出过错的状况,能够用 rethrows 来声明办法:即对外宣称:「我自身不会抛出任何过错,但传入的 Closure 有或许!」

因为参数闭包是调用方传入,其必定知道闭包会不会抛过错了

故,关于不会抛出过错的闭包,能够像一般办法那样调用 rethrows 办法:

configModel {  // , no need `try`
   ConfigModel()
}

关于 rethrows method,只需入参 Closure 不会抛出过错,就能够像一般办法那样进行调用❗️

Converting Errors to Optional Values

正如上文所述,Optional 有时也能够用于处理过错,try? 正是用于将 Error 转成 Optional,如:

let content = try? readFile(atPath: "path")
// Equivalent to
let content: String?
do {
  content = try readFile(atPath: "path")
} catch {
  content = nil
}
  • 经过 try? 调用办法,回来值为 Optional
  • 当调用办法抛出过错时,try? 将吞下 error,转而回来 nil (Error Propagation 就此停止)

Disabling Error Propagation

假如能 **100% **确定调用的 throwing function 不会抛出过错,也能够运用 try! 来停止 Error Propagation,如:

//             
let content = try! readFile(atPath: "path")  // content 类型为 String

当然了,假如不幸此刻 readFile 还是抛出了过错,那等候你的将是 Cash Crash

在实践开发中应该制止运用 try!❗️⚡️

Handling Errors


过错虽可向上传达,但终归是要处理的 ,不或许无限传递下去 (低微的打工人)

Swift 经过 do...catch 来捕获并处理过错:

do {
  try <#expression#>
  <#statements#>
} catch <#pattern 1#> {
  <#statements#>
} catch <#pattern 2#> where <#condition#> {
  <#statements#>
} catch <#pattern 3#>, <#pattern 4#> where <#condition#> {
  <#statements#>
} catch {
  // matches any error
  // binds the error to a local constant named error
  <#statements#>
}
  • try 调用用 do-block 包起来

  • 经过 catch + PatternMatching 能够匹配特定类型的 Error

    Swift 中 Pattern Matching 功能强大,且无处不在,后续有个专题讨论之

  • 没有指定 Pattern Matching 的 catch 将匹配任意类型的 Error,并将 Error 绑定到名为 error 的本地常量上

如下,在 loadConfig 办法中经过 do...catch 捕获来自 readFile 的过错,并全部处理掉:

// loadConfig is non-throwing function
// do...catch handle all error
//
func loadConfig() -> String? {
  do {
    //     
    return try readFile(atPath: "path.config")
  } catch FileHandleError.notExists {    // 
    print("file not exists!")
    return nil
  } catch FileHandleError.unreadable {   // 
    print("file is unreadable!")
    return nil
  } catch {
    //      
    print(error)
    return nil
  }
}

经过 do...catch 也能够只处理部分过错类型,其他类型的过错持续向上传达,如:

// loadConfig is throwing function
// it only handles FileHandleError type
// other error types continue to propagate upwards 
//                  
func loadConfig() throws -> String {
  do {
    return try readFile(atPath: "path.config")
  } catch let error as FileHandleError {  // ,so also:catch is FileHandleError
    print(error)
    return ""
  }
}

Clean-up actions


因为 Error Propagation 会改动正常的履行流程,在退出当时流程时或许有些整理作业需求履行,如:释放内部、关闭文件、断开 DB 等。

大多数言语经过 try...catch...finally 中的 finally 来履行整理作业,如:

try {
  int[] array = new int[1];
  int i = array[2];
  // this statement will not be executed
  // as the above statement throws an exception
  System.out.println("in try block!");
}
catch(Exception e) {
  System.out.println("exception has been caught!");
}
finally {  // 
  System.out.println("in finally block!");
}

Swift 则是经过 defer 来处理整理作业,如:

func processFile(filename: String) throws {
  let file = open(filename)
  defer {   // 
      close(file)
  }
  while let line = try file.readline() {
      // Work with the file.
  }
  // close(file) is called here, at the end of the scope.
}
  • defer 句子在履行流脱离当时作用域时履行

  • defer 不只能够用于 Error handle 的整理作业中,正常履行流程也能够运用,通常用于需求配对履行的操作中,如:open-close、lock-unlock、alloc-release 等,如:

    {
      lock.lock()
      defer {
        lock.unlock()
      }
      // Do something under the protection of the lock ...
    }
    
  • defer 内部不允许任何或许会将操控流提早转移出 defer-block 的行为,如:return、break、throw (不允许在 defer-block 内抛出过错) 等

    defer {
      print("in defer block")
      // ❌ Errors cannot be thrown out of a defer body
      throw FileHandleError.invalidPath
      lock.unlock()
    }
    defer {
      print("in defer block")
      // ❌ 'return' cannot transfer control out of a defer statement
      return
    }
    

Errors and Polymorphism


依据「 LSP – Liskov Substitution Principle 」:

  • 基类中 non-throwing method,不能在子类中重载为 throwing method

    class Base {
      func test() {}
    }
    class Sub: Base {
      override
      func test() throws {}  // ❌ Cannot override non-throwing instance method with throwing instance method
    }
    
  • 基类中 throwing method,在子类中能够重载为 non-throwing method

    class Base {
      func test() throws {}
    }
    class Sub: Base {
      override 
      func test() {}  // ✅
    }
    
  • 同理,Protocol 中界说是 non-throwing method,那完成时必定不能是 throwing method,反之则能够

    protocol SomeProtocol {
      func test()
    }
    class SomeClass: SomeProtocol { // ❌ Type 'SomeClass' does not conform to protocol 'SomeProtocol'
      func test() throws {}
    }
    
    protocol SomeProtocol {
      func test() throws  // 
    }
    class SomeClass: SomeProtocol {
      func test() {}  // ✅
    }
    

assert、precondition、fatalError

  • assert,俗称断语,只在开发环境 (debug) 下收效,首要用于在开发阶段查看不应该产生的过错,如:对入参的合法性校验,以便尽早发现问题

    assert 能够了解为是对接口语义的补充,如下,ph 值的有用取值范围为 0~14 (无法在接口界说中表达出来),能够经过 assert 的办法表达:

    func setPH(ph: Double) {
      assert(ph >= 0 && ph <= 14.0, "The valid value range of ph value is 0 to 14!")
      //...
    }
    

    因为其只在开发环境下有用,故还需求经过如上文所讲的 Optional、throw 等办法处理出产环境的问题

  • precondition,与 assert 的差异在于其在出产环境 (release) 下也有用 ⚡️

  • fatalError,无条件的停止程序的运转 ❌

precondition、fatalError 一般用于无法恢复的严峻过错、严峻的安全问题 ❌

作为低微的应用开发者,是没有权利运用 precondition、fatalError 的!

小结


合理、高雅地处理过错不是一件容易的工作,几点主张:

  • 可恢复的过错用 Optional、Result、throws 处理

  • 不行恢复的过错用 precondition、fatalError 处理 ⚡️

  • assert 用于开发环境中提早发现不应呈现的过错,能够增强接口语义

  • 在接口界说中将过错语义表达出来,让调用方明晰地知道该接口或许呈现的过错

    • 同步办法用 Optional、throws
    • 异步办法用 Closure + Result

过错会沿调用链逆向传达,在适宜的当地捕获并处理过错也是一件非常重要的工作,遇到过在底层网络请求中处理网络过错并弹 toast 的例子❗️

过错处理同样要遵守「单一职责」准则,只处理自己份内的过错,不该处理的过错往上抛!

参考资料

Swift Docs – Error Handling

Swift Docs – Error Handling Rationale and Proposal

Error – Apple Developer Documentation

Swift Error Handling Implementation

Swift Error Handling Strategies: Result, Throw, Assert, Precondition and FatalError

Understanding, Preventing and Handling Errors in Swift

Failable Initializers