使用Swift的类型系统从代码中消除不可能的状态

在Swift中,我们可以使用类,结构和枚举,以及选项和结果,所有这些类型对我们编写的代码有不同的含义。

我们在编写代码时有很大的自由,但我们应该考虑选择类型并利用类型系统,以便我们的代码能够精确地模拟我们正在使用的域的数据。

放置文本的库可能会定义各种对齐文本的方法,它可以使用整数来表示,其中0表示左对齐,1表示右对齐,2表示居中。但是使用整数来表示文本对齐允许没有任何意义的值 - 例如,库应该如何处理值27??使用枚举定义可能的值是有意义的,从而确保只存在有效值。

URLSession完成处理程序

)在Foundation中,我们找到一个API,其中使用的类型可能会引起一些混乱。当我们URLSession从URL加载数据时,我们必须提供一个带有三个参数的完成处理程序:

 func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask`
复制代码

这三个参数都是可选的,因此任何或所有参数都可以存在或不存在。这意味着理论上,完成处理程序必须处理八种可能的状态。如果我们以不同的方式写出参数,我们可以更清楚地看到:

struct CallbackInfo {
    var data: Data?
    var response: URLResponse?
    var error: Error?
}
复制代码

实际上,并非所有八种可能状态都可能发生。我们可以阅读文档以获得关于如何调用回调的提示,但是文档并没有像编译器和类型系统那样给我们提供保证。

我们可以考虑哪些国家自己有意义。我们可以假设,如果我们获得数据,那么我们就不会收到错误。反过来说:如果我们收到错误,就没有数据。我们可以将这两种情况建模为枚举:

enum CallbackInfo2 {
    case success(Data, URLResponse)
    case failure(Error)
}
复制代码

但我们不确定这个枚举涵盖了所有可能的状态。在结构的八种可能状态中CallbackInfo,可能会出现一些但CallbackInfo2枚举无法表达的状态。

通过阅读数据任务方法的文档,很难分辨哪些情况可能发生,哪些情况永远不会发生。可以说,基于枚举的方法CallbackInfo2可以更好地向用户说明需要处理哪些情况。

另一方面,我们不能说枚举总是比可选参数更好。如果我们处理四个或五个选项并且可能发生所有可能的组合,那么我们必须定义一个包含16或32个案例的巨大枚举。这样做可能不会使这种API的使用变得更简单。

我们已经可以用自己的例子说明这个问题。假设失败案例还附带一个可选项Data?

enum CallbackInfo2 {
    case success(Data, URLResponse)
    case failure(Data?, Error)
}
复制代码

鉴于这两种情况都可以包含数据,因此将数据作为可选属性提供而不是隐藏在枚举的关联值中会更有意义,因为这会使访问变得更加困难。

枚举并不总是优于一组选项,反之亦然,但它取决于我们试图建模的可能状态。

用户会话

第二个例子来自Apple关于Swift的书,Swift编程语言

struct Session {
    var user: User?
    var expired: Bool
}
复制代码

这里我们有一个用户会话。该user属性是可选的,因为可能没有注册用户。用户会话的这个模型允许四种可能的状态:用户可以存在与否,并且expired 布尔属性可以是truefalse

然后Swift书继续说我们可以选择将会话建模为枚举,从而消除没有发生的状态,我们没有用户和过期的会话:

enum Session1 {
    case loggedIn(User)
    case expired(User)
    case notRegistered
}
复制代码

枚举版本比结构更精确地模拟域,因为它只能表示用户会话的可能状态。

但是和前面的例子一样,我们现在有两个共享相关值的情况,我们必须切换枚举以便User从会话中提取a 。第三种方法是对会话进行建模,这样可以更轻松地访问用户,而不会变得不那么精确:

struct Session {
    var user: User
    var expired: Bool
}

var session: Session?
复制代码

这里我们再次使用一个结构,但这次user属性不是可选的。相反,会话本身存储在可选变量中。一种可能的状态是sessionnil,这意味着相同 notRegistered的情况下Session1枚举。在其他两个状态中,存在会话,因此也是用户,并且会话已过期或未过期。

我们遇到了很多这样的情况:当枚举的多个case共享相同的关联值时,我们通常可以将enum包装在struct中,并将关联的值拉出到struct的属性中。

将文件名映射到数据

让我们看另一个例子。假设我们有一个文件名数组作为字符串,我们正在编写一个映射数组并从文件返回数据的函数。该函数的结果类型应该是什么?

该函数可以简单地返回一个数组Data

func readFiles(_ fileNames: [String]) -> [Data] {
    // ... }
复制代码

这样可行,但如果其中一个文件不存在或者无法读取会发生什么?该函数可以省去该文件的数据并返回其余的数据,但作为用户,我们无法知道哪些文件失败。

结果类型也可以是一个选项数组:

func readFiles(_ fileNames: [String]) -> [Data?] {
    // ... }
复制代码

这样我们就可以尝试找出哪些文件成功加载了,但我们不能完全确定,因为我们无法保证结果数组的排序方式与输入数组相同。

我们可能想要报告有关丢失文件的错误,因此函数可能应该返回文件名以及可选数据值,并结合在元组中:

func readFiles(_ fileNames: [String]) -> [(String, Data?)] {
    // ... }
复制代码

另一个选择是使整个数组可选。这使得结果全部或全部:我们要么从所有请求的文件中获取数据,要么使用其中一个文件失败,我们根本得不到任何结果:

func readFiles(_ fileNames: [String]) -> [(String, Data)]? {
    // ... }
复制代码

即使是像上面这样的简单功能,我们也可以轻松地想到七种变化。例如,我们可以决定返回一个Result而不是一个可选项,或者我们想要包含一个描述不同类型失败的自定义枚举。在类型之间进行选择完全取决于对应用程序最有意义的内容。

精确性与易用性

我们可以更进一步,并尝试强制readFiles函数的输入和输出数组应具有相同的长度。有一些编程语言可以让你表达这一点,但在Swift中我们也有一些可以提供帮助的技巧。

我们可以尝试以某种方式标记一个长度的数组。然后我们可以定义一个保留长度并使用此映射来实现的map函数readFiles。但是我们会推动我们可以将多少信息投入到类型中,我们应该问自己增加的复杂性是否值得。

类型不太严格意味着我们需要更多地信任一段代码的实现。我们总是可以编写测试来检查代码在我们提供大量样本输入时的行为方式。

标准库有很多例子,为了简化使用,使用的类型并不是描述它们所做的最精确的类型。首先,a Array由整数(Int)索引,而不是由无符号整数(UInt)索引,即使索引从不为负数。

对于count数组或字符串也是如此。金额永远不能是负数,因此使用UInt而不是更精确Int。但是这会使count属性更难以使用,因为在大多数情况下,我们必须将类型转换为将其Int传递给其他API。

选择正确的类型意味着要在精确性和易用性之间进行权衡。最好的方法是探索不同的类型,并确定一种最准确,最好的类型描述我们描述的任何类型,但不包含任何可能代表不可能状态的垃圾值。

使用幻影类型

在我们关于 使用Brandon Kase的幻像类型的情节中,我们讨论了标记类型的概念,以使它们更具描述性,并且更具限制性,意图使用类型系统来帮助防止错误地使用我们的API。

我们可以找到在自动布局中使用的幻像类型的实际示例。在那里,视图的 锚点用幻像类型标记以区分水平和垂直锚点。这使得例如将前导锚固定到顶部锚点是不可能的,这是没有意义的。

准确建模特定域数据的问题自动出现在强类型语言的社区中。其中一个主要的榆树开发商理查德·费尔德曼,举行一说起做不可能的状态是不可能的。关于F#,OCaml和Haskell也有类似的讨论,所有讨论如何找到数据表示,只允许你定义有意义的函数。

小编这里推荐一个群:691040931 里面有大量的书籍和面试资料,很多的iOS开发者都在里面交流技术

原文地址:talk.objc.io/episodes/S0…

猜你喜欢

转载自blog.csdn.net/weixin_33938733/article/details/91397997
今日推荐