1. Introduction
今天我们想谈谈两个世界之间的联系:Swift类型和代数。我们对这两个世界都很熟悉,但事实证明,它们有着非常深刻、美丽的联系。使用这种对应关系,我们可以更好地理解是什么给我们的数据结构增加了复杂性,甚至使用我们从代数中获得的直觉来更好地塑造我们的数据,这样不可能的状态是不可表示的,因此不能编译!
2. The algebra of structs
首先,让我们看看一些足够简单的东西:结构体。事实上,这个结构:
struct Pair<A, B> {
let first: A
let second: B
}
这是最通用的,非平凡的结构。 它有两个字段,每个字段只包含一段通用数据。 让我们做一些奇怪的事情,对于A和B的特定值,让我们计算Pair< A, B>有多少值:
Pair<Bool, Bool>(first: true, second: true)
Pair<Bool, Bool>(first: true, second: false)
Pair<Bool, Bool>(first: false, second: true)
Pair<Bool, Bool>(first: false, second: false)
Pair<Bool, Bool>正好包含四个值。不可能构造任何其他有效的、正在编译的Swift程序的值。让我们试试另一个。 我要创建一个只有3个值的lil类型,并用Bool数其对:
enum Three {
case one
case two
case three
}
我们可以用Bool和Three创建多少对?
Pair<Bool, Three>(first: true, second: .one)
Pair<Bool, Three>(first: true, second: .two)
Pair<Bool, Three>(first: true, second: .three)
Pair<Bool, Three>(first: false, second: .one)
Pair<Bool, Three>(first: false, second: .two)
Pair<Bool, Three>(first: false, second: .three)
这个有六个值!有趣!
Swift有一种奇怪的类型叫Void。 事实上,它的奇怪至少有两个原因。首先,你可以用同样的方式来引用类型和值:
_: Void = Void()
_: Void = ()
_: () = ()
这就引出了另一件奇怪的事。 Void只有一个值! 正因为如此,()的实质内容根本不重要。它只是一个值,表示我们在Void中有这个东西,但你不能用()做任何事。 这也是为什么没有返回值的函数会偷偷返回Void,即使没有明确指定:
func foo(_ x: Int) /* -> Void */ {
// return ()
}
Swift编译器会在你之后return()。
让我们尝试将Void插入到我们的pair中,看看会发生什么:
Pair<Bool, Void>(first: true, second: ())
Pair<Bool, Void>(first: false, second: ())
它只包含两个值。
那么一对Void和Void呢?
Pair<Void, Void>(first: (), second: ())
Only 1 value!
有趣! 我们别无选择,只能为第二个字段插入(),因此它实际上并没有改变太多类型。
Swift还有另一种奇怪的类型:Never。它的定义非常简单:
enum Never {}
这意味着什么? 它是一个没有case的enum。这就是所谓的“uninhabited type”:不包含值的类型。没有办法做这样的事情:
_: Never = ???
这里没有任何东西可以让Swift编译这个程序。
当我们将Never插入Pair时会发生什么?
Pair<Bool, Never>(first: true, second: ???)
没有我们可以投入的东西了?!
Never类型还会得到编译器的特殊处理,在编译器中,返回Never的函数被认为是不返回的函数。例如,fatalError函数返回Never。编译器知道在执行此语句之后的所有代码行和分支都不会发生,并可以使用这一点来证明穷尽性。
考虑到所有这些例子,A和B中的值的数量与Pair<A, B>中的值的数量之间的关系是什么?
// Pair<Bool, Bool> = 4
// Pair<Bool, Three> = 6
// Pair<Bool, Void> = 2
// Pair<Void, Void> = 1
// Pair<Bool, Never> = 0
一种模式开始出现了:乘法!
// Pair<Bool, Bool> = 4 = 2 * 2
// Pair<Bool, Three> = 6 = 2 * 3
// Pair<Bool, Void> = 2 = 2 * 1
// Pair<Void, Void> = 1 = 1 * 1
// Pair<Bool, Never> = 0 = 2 * 0
Pair使值的数量相乘,即Pair<A, B> 中的值的数量是A中的值乘以B中的值的数量。
这个现象还有另一种代数解释:logical conjunction, a.k.a. and。 Pair类型封装了接受两种类型的“和”的含义,即Pair< A, B>的值就是类型A的值和另一个类型B的值。
这适用于任何结构体和元组,而不仅仅是Pair。让我们来看一个例子
enum Theme {
case light
case dark
}
enum State {
case highlighted
case normal
case selected
}
struct Component {
let enabled: Bool
let state: State
let theme: Theme
}
代数的Component是多少?
// Bool * Theme * State = 2 * 3 * 2 = 12
Component有12个值!
根据这种直觉,让我们删除所有类型的名称,只关注字段中存储的数据。为了做到这一点,我们将创建一个不是有效的Swift代码的符号,但将允许我们更紧凑地看到我们在这里所揭示的代数。 因此,我们以前写Pair<A, B>,现在我们只写A * B,这看起来很奇怪,但它只是帮助指导我们的直觉:
// Pair<A, B> = A * B
// Pair<Bool, Bool> = Bool * Bool
// Pair<Bool, Three> = Bool * Three
// Pair<Bool, Void> = Bool * Void
// Pair<Bool, Never> = Bool * Never
我们称A * B为A和B类型的乘积。现在我们考虑得更抽象一点了,我们甚至可以放松对< A, B>的直觉,它是A和B中元素个数的字面量的乘法。虽然对于具有有限多个值的类型确实是这样,但这并不能真正帮助我们解决以下问题:
// Pair<Bool, String> = Bool * String
我们不再讨论值的数量,因为String有一个无限的数,但我们仍然可以把它看作乘法。
我们甚至可以考虑以下几点:
// String * [Int]
// [String] * [[Int]]
我们将无限种类型相乘!
让我们更进一步,从Void, Never和Bool中删除名称,只通过包含的值的数量来表示这些类型。
// Never = 0
// Void = 1
// Bool = 2
现在我们甚至不考虑具体的类型,只考虑抽象的代数实体。
3. The algebra of enums
现在我们知道了Swift中的结构体对应于类型的乘法。但是有一个对应的“dual”:加法! 这在Swift的类型系统中是什么样子的?
好吧,事实证明Swift支持这样的构造,而这恰恰是一个enum! 让我们考虑一下最通用的、非平凡的枚举:
enum Either<A, B> {
case left(A)
case right(B)
}
让我们使用之前的一些值,看看如何从这个类型构造一些简单的值:
Either<Bool, Bool>.left(true)
Either<Bool, Bool>.left(false)
Either<Bool, Bool>.right(true)
Either<Bool, Bool>.right(false)
我们又得到了四个值。Three?
Either<Bool, Three>.left(true)
Either<Bool, Three>.left(false)
Either<Bool, Three>.right(.one)
Either<Bool, Three>.right(.two)
Either<Bool, Three>.right(.three)
这次我们得到5个值。嗯!Void呢?
Either<Bool, Void>.left(true)
Either<Bool, Void>.left(false)
Either<Bool, Void>.right(Void())
Never呢?
Either<Bool, Never>.left(true)
Either<Bool, Never>.left(false)
Either<Bool, Never>.right(???)
最后一个例子特别有趣。我们看到,通过取一对Never,即Pair<A, Never>,我们使这一对无人居住。然而,对于Either,它只是意味着一个情况是无人居住的,但另一个是自由地在Bool值。
现在我们可以看到一些代数。那么A和B中的值的数量和Either<A, B>中的值的数量之间的关系是什么?
Either<Bool, Bool> = 4 = 2 + 2
Either<Bool, Three> = 5 = 2 + 3
Either<Bool, Void> = 3 = 2 + 1
Either<Bool, Never> = 2 = 2 + 0
从这些例子中,我们可以看到Either<A, B>中的值的数量正好是A中的值的数量加上B的值的数量。所以Either直接对应于取类型的和。 这就是为什么枚举被称为“和类型”。 我们还可以从逻辑的角度解释Either,就像我们对Pair做的那样:Either类型封装了接受两种类型的“或”的含义,即Either< A, B>正好是A类型的值或B类型的值。
和之前一样,让我们用一个新的符号来抽象取类型的和,这个符号在Swift中无效,但它会帮助我们发展直觉。
Either<Bool, Bool> = Bool + Bool = 2 + 2 = 4
Either<Bool, Three> = Bool + Three = 2 + 3 = 5
Either<Bool, Void> = Bool + Void = 2 + 1 = 3
Either<Bool, Never> = Bool + Never = 2 + 0 = 2
4. Word of warning: Void
值得注意的是,有些语言(如Haskell、PureScript、Idris)使用Void来表示uninhabited类型(即Swift所称的Never),因此如果你研究这些语言,可能会导致一些困惑。 事实上,从某种意义上说,这是一个很棒的名字,因为“void”看起来像是一个没有任何东西的空间!
对于只有一个唯一值的类型来说,更好的名称可能是类似Unit的东西。我们可以这样定义它:
struct Unit {}
let unit = Unit()
这很好,因为我们现在为类型Unit和值Unit有了一个独特的名称。另一个关于Unit的实际结构类型的好处是我们可以扩展它:
extension Unit: Equatable {
static func == (lhs: Unit, rhs: Unit) -> Bool {
return true
}
}
现在我们可以把Unit传递给只需要等价值的函数,这很酷。但在Swift的 Void中,这是不可能的。如果你试图扩展它,你会得到这个错误:
Non-nominal type 'Void' cannot be extended
原因是Void被定义为一个空元组:
typealias Void = ()
Swift中的元组是non-nominal 类型,你不能通过名字来引用它们,只能通过结构来引用。这对Swift来说是一件非常不幸的事情,希望有一天能够得到纠正。
5. Empty structs vs. empty enums
但是现在我们想说一些非常奇怪的事情。让我们一起来看看Unit和Never的定义:
struct Unit {}
enum Never {}
显然这里有一些对称:一个没有case的enum和一个没有字段的struct。 为什么没有case的枚举中没有值,而没有字段的结构体却有值? 我们完全有理由认为Unit也没有值。
但是我们能凭直觉理解为什么会这样吗?
利用Swift类型和代数之间的对应关系,我们可以提出一个可能更容易回答的相关问题。我们可以问自己,“空枚举和空结构中有什么值?”这就相当于问:“空数组中整数的和和积是多少?”
假设我们有一个整数数组。如何定义以下函数:
func sum(_ xs: [Int]) -> Int {
fatalError()
}
func product(_ xs: [Int]) -> Int {
fatalError()
}
let xs = [1, 2, 3]
sum(xs)
product(xs)
当然,我们想要循环遍历数组,求和并将所有值相乘:
func sum(_ xs: [Int]) -> Int {
var result: Int
for x in xs {
result += x
}
return result
}
func product(_ xs: [Int]) -> Int {
var result: Int
for x in xs {
result *= x
}
return result
}
因为我们还没有给result一个初始值,所以它目前还不能编译。但是我们应该选择什么呢? 为了回答这个问题,我们需要理解和和乘积应该满足什么性质,这将迫使我们决定需要result从什么开始。 我们想要满足的最简单的属性与sum和product在数组连接方面的行为:
sum([1, 2]) + sum([3]) == sum([1, 2] + [3])
product([1, 2]) * product([3]) == product([1, 2] + [3])
现在,如果我们使用空数组呢?
sum([1, 2]) + sum([]) == sum([1, 2] + [])
product([1, 2]) * product([]) == product([1, 2] + [])
这迫使sum([])为0,product([])为1。没有别的选择。因此empty sum是0, empty product是1。
sum([1, 2]) + 0 == sum([1, 2] + [])
product([1, 2]) * 1 == product([1, 2] + [])
sum([]) == 0
product([]) == 1
现在,将这个概念转换回类型世界,我们自然会得到这样的语句:“empty sum type”没有值(即uninhabited),而“empty product type”只有一个值! 所以我们用代数来解开一个非常棘手的存在困境!
6. Algebraic properties
现在我们已经建立了一些概念来理解Swift类型和代数之间的对应关系,让我们试着展示这些肌肉,看看我们是否能在更高层次上获得一些类型结构的直觉。
让我们从简单入手。回想一下Void对应于1,在代数世界中,我们知道乘以1没有任何作用。它的类型是什么?
// Void = 1
// A * Void = A = Void * A
这意味着在结构体的字段中使用Void值实际上会保持类型不变。
另一方面,Never对应于0,我们知道与它相乘是0。在类型世界中,这看起来像:
// Never = 0
// A * Never = Never = Never * A
因此,将Never放到结构体的字段中,最终会将该结构体转换为Never类型本身。 它完全消灭了它。
但是,加0的结果是保持值不变,在类型中,这对应于:
// A + Never = A = Never + A
我们换一种方法。考虑这个类型表达式:
// 1 + A = Void + A
In terms of Either this is:
// Either<Void, A> {
// case left(())
// case right(A)
// }
这是一种类型,所有A的值都在右边,然后这个特殊的值*left(Void())*是附加的。 什么样的本土的Swift类型有同样的形状? Optionals!
enum Optional<A> {
case none
case some(A)
}
none对应于left(没有关联值的case本质上与具有Void值的case相同),而some对应于right。现在我们看到:
// 1 + A = Void + A = A?
现在,假设你遇到了这个表达:
// Either<Pair<A, B>, Pair<A, C>>
让我们看看它在符号里是什么样子的:
// A * B + A * C
使用基础代数,我们知道如何将其分解成一个更简单的表达式:
A * (B + C)
现在这对应于一个带enum的pair:
Pair<A, Either<B, C>>
这里我们可以看到代数直觉让我们得到了一个更简单的数据结构。
另一方面,如果我们简单地调换Pair和Either的角色,我们有:
Pair<Either<A, B>, Either<A, C>>
And in the math world:
// (A + B) * (A + C)
这个方程不再因式分解了所以不能再简化了。
当然,我们可以把它展开,使它等于
// A * A + A * C + B * A + B * C
这有点像一个有四种情况的enum,每一种情况都是一对。这可能不是你想要的,但也许你想要,你有代数来告诉你怎么做!
我们讨论的每一个数据结构,如果我们只考虑数据而不考虑与数据相关的行为,那都只是产品的总和:从一个case的基本枚举开始,每个case都有一系列的product,这些product又可能包含更多的sum和product。
7. What’s the point?
我们已经写了一堆伪代码,甚至不是有效的Swift,它所能做的只是引导我们的直觉。我们从中得到什么好处了吗?
让我们看看URLSession的一个方法。
// URLSession.shared
// .dataTask(with: url, completionHandler: (data: Data?, response: URLResponse?, error: Error?) -> Void)
completionHandler返回三个可选值。这是一个有3个字段的乘积类型。Swift元组只是乘积。我们用代数表示一下:
// (Data + 1) * (URLResponse + 1) * (Error + 1)
这看起来有点奇怪。如果我们完全扩展它会发生什么?
// (Data + 1) * (URLResponse + 1) * (Error + 1)
// = Data * URLResponse * Error
// + Data * URLResponse
// + URLResponse * Error
// + Data * Error
// + Data
// + URLResponse
// + Error
// + 1
这里有很多可表示的状态是没有意义的。它们甚至会在每一行中跳出来。我们可以得到URLResponse * Error,而URLResponse不应该和Error同时出现。我们还可以得到Data * Error,这也没有意义。我们也可以得到1,也就是Void,或者在这里所有值都是nil。我们还可以得到一切:Data * URLResponse * Error,这是不应该发生的。
当您使用这个接口时,您可能会注意到,如果您忽略了预期的情况,那么不可避免地会出现一个需要fatalError的分支,并且只希望它永远不会被调用。
让我们用新的直觉来表示我们想要的东西:
// Data * URLResponse + Error
我们这种类型的是什么样子?
Either<Pair<Data, URLResponse>, Error>
事实上,Swift社区已经接受了一种允许我们处理这类状态的类型,即Result类型。
// Result<(Data, URLResponse), Error>
在本例中,我们可以使用一个简单的元组来表示Data和URLResponse的乘积,而不是使用Pair。
通过在完成回调中使用适当的类型,我们大大减少了编译时允许的无效状态的数量,从而简化了回调中所需的逻辑。
让我们进一步考虑Result类型。如果我们使用一个返回Result的API,但是带有一个永远不会失败的特定操作呢? 我们可以指定错误类型为Never!
// Result<A, Never>
现在我们可以确定错误情况是不适合的。
如果我们正在处理一个支持取消的异步API呢? 如何将取消案例添加到Result中?
// Result<A, Error>?
我们只是把它变成可选的!
看到我们如何既能解决复杂性,又能自然地将自己引向更适合我们需求的类型,就能更清楚地看到代数直觉如何改善我们的日常代码。 我们还看到,虽然结构体在元组中有轻量级版本,但也许Either是属于我们日常武器库的轻量级enum。我们也不要害怕!既害怕元组又不害怕元组,就好像你害怕加法而不害怕乘法一样。 或者这就像说你害怕“or”,而不是“and”。我们不会只使用乘法(*)和“和”(&&)来编写程序。 我们允许自己使用加法(+)和“或”(||)。所以让我们熟悉求和类型和Either!
我们的代数之旅才刚刚开始。我们还没有看到类型系统如何表示其他概念,比如求幂! 一种类型的能量比另一种类型的能量看起来像什么? 但那得等到下次了。请继续关注!