我们如何管理应用程序和系统中的控制流,从代码执行的速度到调试的难易程度,都会对一切产生巨大影响。 我们代码的控制流本质上是各种函数和语句执行的顺序,以及最终进入的代码路径。
虽然Swift提供了许多定义控制流的工具——比如if、else和While这样的语句,以及optional这样的结构——本周,让我们来看看如何使用Swift的内置错误抛出和处理模型来使我们的控制流更易于管理。
Throwing away optionals
选选项虽然是一种重要的语言特性,也是对可能正常缺失的数据进行建模的一种好方法,但当涉及到给定函数中的控制流时,它往往会成为样板文件的来源。
这里我们写了一个函数,让我们从应用程序的bundle中加载一张图像,然后着色和调整它的大小。由于这些操作当前都返回一个可选的image, 最后我们得到了几个保护语句和函数可以退出的点:
func loadImage(named name: String,
tintedWith color: UIColor,
resizedTo size: CGSize) -> UIImage? {
guard let baseImage = UIImage(named: name) else {
return nil
}
guard let tintedImage = tint(baseImage, with: color) else {
return nil
}
return resize(tintedImage, to: size)
}
上面我们面临的问题是,我们实际上是在使用空值来处理运行时错误——这样做的缺点是迫使我们解包了每个操作的结果,而且还隐藏了错误发生的根本原因。
让我们看看如何通过重构控制流来代替抛出函数和错误来解决这两个问题。首先,我们将为图像处理代码中可能发生的每一个错误定义一个包含案例的枚举——看起来像这样:
enum ImageError: Error {
case missing
case failedToCreateContext
case failedToRenderImage
...
}
然后,我们会改变所有内部函数,在它失败时抛出上述错误之一,而不是返回nil。例如,下面是我们如何快速更新loadImage(named:)来返回一个非可选的UIImage或抛出ImageError.missing:
private func loadImage(named name: String) throws -> UIImage {
guard let image = UIImage(named: name) else {
throw ImageError.missing
}
return image
}
一旦我们对其他图像处理函数进行了同样的处理,我们就可以对顶层函数进行同样的修改——删除所有可选函数,使其要么返回一个具体的图像,要么抛出在操作链中生成的任何错误:
func loadImage(named name: String,
tintedWith color: UIColor,
resizedTo size: CGSize) throws -> UIImage {
var image = try loadImage(named: name)
image = try tint(image, with: color)
return try resize(image, to: size)
}
上面的修改不仅使函数体变得更简单——它还使调试变得更容易,因为我们现在将以一个明确定义的错误结束,以防出现任何错误——而不必找出是什么导致nil返回。
然而,我们可能并不总是对实际处理所有错误感兴趣——因此我们不希望在我们的代码库中到处使用do, try, catch模式 (具有讽刺意味的是,这将导致我们试图避免的许多相同的样板-但在呼叫地点相反)。
好消息是,我们可以在任何需要的时候使用可选函数——甚至在使用抛出函数的时候。我们要做的就是试试?关键字,我们将再次得到一个可选的返回函数:
let optionalImage = try? loadImage(
named: "Decoration",
tintedWith: .brandColor,
resizedTo: decorationSize
)
try?有什么好处,它给了我们两个世界最好的东西。我们能够在调用站点上获得一个可选的——同时仍然允许我们使用抛出和错误的功能来管理内部控制流👍。
Validating input
接下来,让我们看看如何在执行输入验证时使用错误来改进控制流。尽管Swift有一个非常先进和强大的类型系统,但它不能总是确保我们的函数将接收到有效的输入——有时运行时检查是我们唯一的选择。
让我们看一下另一个例子,在这个例子中,我们在注册新帐户时验证用户所选择的凭据。就像之前一样,我们的代码目前为每个验证规则使用了guard语句,并在失败的情况下输出一条错误消息——像这样:
func signUpIfPossible(with credentials: Credentials) {
guard credentials.username.count >= 3 else {
errorLabel.text = "Username must contain min 3 characters"
return
}
guard credentials.password.count >= 7 else {
errorLabel.text = "Password must contain min 7 characters"
return
}
// Additional validation
...
service.signUp(with: credentials) { result in
...
}
}
即使我们只验证上面的两段数据,我们的验证逻辑最终的增长速度也比我们预期的要快得多。让这种逻辑与我们的UI代码(通常在视图控制器中)共存也会让测试变得更加困难——所以让我们看看我们是否能做一些解耦,并在过程中改善我们的控制流。
理想情况下,我们希望验证代码是自包含的。这样,它既可以独立地工作和测试,也可以轻松地在我们的整个代码库中重用。为了实现这一点,让我们首先为所有验证逻辑创建一个专用类型。我们将它命名为Validator,并使其成为一个简单的结构体,用于保存给定值类型的验证闭包:
struct Validator<Value> {
let closure: (Value) throws -> Void
}
使用上面的方法,我们将能够构造验证器,当一个值没有通过验证时抛出一个错误。但是,必须总是为每个验证过程定义一个新的错误类型,这可能会再次生成不必要的样板文件(特别是当我们想要处理一个错误只是将其显示给用户时)。-因此,我们还需要引入一个函数,通过简单地传递Bool条件和一条在失败时显示给用户的消息来编写验证逻辑:
struct ValidationError: LocalizedError {
let message: String
var errorDescription: String? { return message }
}
func validate(
_ condition: @autoclosure () -> Bool,
errorMessage messageExpression: @autoclosure () -> String
) throws {
guard condition() else {
let message = messageExpression()
throw ValidationError(message: message)
}
}
有了上面的内容,我们现在可以将所有的验证逻辑实现为专用的验证器——使用验证器类型上的计算静态属性构造。例如,下面是我们如何实现一个密码验证器:
extension Validator where Value == String {
static var password: Validator {
return Validator { string in
try validate(
string.count >= 7,
errorMessage: "Password must contain min 7 characters"
)
try validate(
string.lowercased() != string,
errorMessage: "Password must contain an uppercased character"
)
try validate(
string.uppercased() != string,
errorMessage: "Password must contain a lowercased character"
)
}
}
}
为了把事情做个总结,让我们创建另一个validate重载,它将作为一个语法糖,通过让我们使用想要验证的值和要使用的验证器来调用它:
func validate
using validator: Validator
try validator.closure(value)
}
所有构建块就绪后,让我们更新调用站点以使用新的验证系统。上面的方法的美妙之处在于,虽然需要一些额外的类型和一些基础设施,它让我们的需要输入验证的代码非常漂亮和干净:
func signUpIfPossible(with credentials: Credentials) throws {
try validate(credentials.username, using: .username)
try validate(credentials.password, using: .password)
service.signUp(with: credentials) { result in
...
}
}
也许更好的是,我们现在可以通过使用do, try, catch模式调用上面的signUpIfPossible函数,在一个地方处理所有验证错误,然后简单地向用户显示任何抛出的错误的本地化描述:
do {
try signUpIfPossible(with: credentials)
} catch {
errorLabel.text = error.localizedDescription
}
值得注意的是,虽然上面的代码示例没有使用任何本地化,但我们总是希望在真实应用中向用户显示所有错误消息时使用本地化字符串。
Throwing tests
围绕可能遇到的错误类型来构造代码的另一个大好处是,它通常会使测试变得容易得多。由于抛出函数本质上有两种不同的可能输出——一个值和一个错误——在许多情况下,添加涵盖这两种情况的测试是非常直接的。
例如,下面是我们如何很容易地为密码验证代码添加测试的方法——通过简单地断言错误情况确实抛出错误,而成功情况不会抛出错误,就涵盖了我们的两个需求:
class PasswordValidatorTests: XCTestCase {
func testLengthRequirement() throws {
XCTAssertThrowsError(try validate("aBc", using: .password))
try validate("aBcDeFg", using: .password)
}
func testUppercasedCharacterRequirement() throws {
XCTAssertThrowsError(try validate("abcdefg", using: .password))
try validate("Abcdefg", using: .password)
}
}
正如您在上面所看到的,因为XCTest支持抛出测试函数——并且每个未处理的错误都被视为失败——为了验证成功案例,我们需要做的就是使用try调用我们的validate函数,如果函数没有抛出,我们的测试将成功👍。
Conclusion
虽然Swift代码的控制流有很多组织方式——对于可能成功或失败的操作,使用错误和抛出函数是一个很好的选择。虽然这样做需要一些额外的仪式(例如引入错误类型和使用try或try?进行所有调用)——它可以给我们带来一些非常好的好处,同时也使我们的代码更加紧凑。
当然,从一些函数中返回可选值仍然是合适的——特别是那些没有任何可抛出的合理错误的函数 - 但是在一些地方,我们需要处理几个不同的可选语句和保护语句-使用错误代替可能会给我们一个更清晰的控制流程。