摘要

抽象代数结构在 Swift 中的实践。


请注意: 此项目已弃用且不再维护。我正在使用 swift-abstract 对 Swift 中的抽象代数进行新的尝试。


Abstract 是一个 Swift 库,它为常见的 抽象代数结构 定义了协议,并为 Swift 数据类型提供了一些具体的实现。

该库还提供了工具来测试具体类型是否符合每种代数结构所需的公理:然后可以使用基于属性的测试库(如 SwiftCheck)执行测试。


系统要求

Abstract 支持 macOS 10.10+ 和 iOS 8.3+。


设置

要克隆 Abstract,请运行 git clone REPOSITORY_URL --recursive 以正确克隆子模块。

SwiftPM

请将此行添加到您的 Package.swift 文件的 dependencies 部分

.package(url: "https://github.com/typelift/Abstract.git",
         from: Version(0,0,0))

要使用此库中的结构,请将 "Abstract" 添加到您的 target 的 dependencies 中。要使用该框架额外测试代数定律,请将 "Abstract" 作为依赖项添加到相关的 testTarget 中。

Carthage

AbstractCarthage 兼容:请参阅 Carthage 文档,了解如何将 Abstract 添加为项目的依赖项。


如何使用

让我们看一些示例,以便更好地理解 Abstract 的用途。要概述所有这些背后的理论,您可以阅读 原理

Cookie 赞赏

我们正在构建一个应用程序,允许用户请求任意数量的 cookie,然后提供他们是否对 cookie 感到满意的反馈。我们希望在一个会话对象中跟踪用户与应用程序的交互,如下所示

struct UserSession {
    var lastInteractionDate: Date
    var numberOfCookiesRequested: Int
    var maxCookiesPerRequest: Int
    var averageCookiesPerSession: Double
    var alwaysSatisfied: Bool
}

在每次交互时,我们应该更新会话对象。

有两种可能的交互

我们可以向 UserSession 添加 2 个方法来表示每个操作

extension UserSession {
    func orderCookies(_ count: Int) -> UserSession {
        var m = self
        m.lastInteractionDate = Date()
        m.totalCookiesRequested += count
        m.maxCookiesPerRequest = max(m.maxCookiesPerRequest, count)
//        m.averageCookiesPerRequest = ?
        return m
    }
    
    func leaveFeedback(_ positive: Bool) -> UserSession {
        var m = self
        m.lastInteractionDate = Date()
        m.alwaysSatisfied = m.alwaysSatisfied && positive
        return m
    }
}

我们注意到两件事

我们可以尝试定义额外的类型,明确声明我们应该如何更新各种属性。

var lastInteractionDate: Max<Date>
var totalCookiesRequested: Add<Int>
var maxCookiesPerRequest: Max<Int>
var alwaysSatisfied: And

每个属性都是一种类型,该类型指定了我们应该如何组合两个实例:Max<Date> 将始终保留两个日期中较晚的日期,Max<Int> 也是如此,但用于数字;Add<Int> 将通过将数字相加来组合数字,而 And 将对两个 Bool 应用 && 运算。然后,我们可以使用 unwrap 函数获取类型内部包装的值。

遵循此策略,我们实际上可以定义一个 Average 类型,该类型声明一个按预期工作的组合函数

struct Average {
    private var value: Double
    private var weight: Int
    
    var unwrap: Double {
        return value
    }
    
    init(_ value: Double, weight: Int = 1) {
        self.value = value
        self.weight = weight
    }
    
    static func <> (lhs: Average, rhs: Average) -> Average {
        let sum = lhs.value*Double(lhs.weight) + rhs.value*Double(rhs.weight)
        let count = lhs.weight + rhs.weight
        return Average.init(sum/Double(count), weight: count)
    }
}

请注意,我们使用的是 <> 运算符而不是 compose 方法。

现在我们可以像这样重新定义我们的 UserSession

struct UserSession {
    var lastInteractionDate: Max<Date>
    var totalCookiesRequested: Add<Int>
    var maxCookiesPerRequest: Max<Int>
    var averageCookiesPerRequest: Average
    var alwaysSatisfied: And
}

extension UserSession {
    func orderCookies(_ count: Int) -> UserSession {
        var m = self
        m.lastInteractionDate = m.lastInteractionDate <> Max(Date())
        m.totalCookiesRequested = m.totalCookiesRequested <> Add(count)
        m.maxCookiesPerRequest = m.maxCookiesPerRequest <> Max(count)
        m.averageCookiesPerRequest = m.averageCookiesPerRequest <> Average(Double(count))
        return m
    }
    
    func leaveFeedback(_ positive: Bool) -> UserSession {
        var m = self
        m.lastInteractionDate = m.lastInteractionDate <> Max(Date())
        m.alwaysSatisfied = m.alwaysSatisfied <> And(positive)
        return m
    }
}

这看起来很酷但很乏味:由于我们的每种类型都使用 <> 进行组合,因此我们一遍又一遍地重复着基本相同的操作。最好将组合操作扩展到 UserSession 本身,我们在其中通过组合每对属性来组合 2 个会话。

extension UserSession {
    static func <> (lhs: UserSession, rhs: UserSession) -> UserSession {
        return UserSession.init(
            lastInteractionDate: lhs.lastInteractionDate <> rhs.lastInteractionDate,
            totalCookiesRequested: lhs.totalCookiesRequested <> rhs.totalCookiesRequested,
            maxCookiesPerRequest: lhs.maxCookiesPerRequest <> rhs.maxCookiesPerRequest,
            averageCookiesPerRequest: lhs.averageCookiesPerRequest <> rhs.averageCookiesPerRequest,
            alwaysSatisfied: lhs.alwaysSatisfied <> rhs.alwaysSatisfied)
    }
}

请注意,我们只是成对合并属性:这实际上可以用完全通用的方式定义,可以使用通用 Tuple 结构(其中属性可以组合),也可以使用代码生成工具(如 Sourcery)。

现在我们的 orderCookiesleaveFeedback 方法实际上可以重新定义为 static 方法,这些方法生成新的会话以与之前的会话组合。为此,我们需要能够为属性生成值,这些空值对于组合的行为就像对于加法的行为一样,也就是说,它使先前的值保持不变。

extension UserSession {
    static func orderCookies(_ count: Int) -> UserSession {
        return UserSession.init(
            lastInteractionDate: Max(Date()),
            totalCookiesRequested: Add(count),
            maxCookiesPerRequest: Max(count),
            averageCookiesPerRequest: Average(Double(count)),
            alwaysSatisfied: .empty)
    }
    
    static func leaveFeedback(_ positive: Bool) -> UserSession {
        return UserSession.init(
            lastInteractionDate: Max(Date()),
            totalCookiesRequested: .empty,
            maxCookiesPerRequest: .empty,
            averageCookiesPerRequest: .empty,
            alwaysSatisfied: And(positive))
    }
}

现在可以轻松地组合一堆交互,如下所示

let finalSession: UserSession = .orderCookies(3)
    <> .orderCookies(5)
    <> .leaveFeedback(true)
    <> .orderCookies(2)
    <> .orderCookies(1)
    <> .orderCookies(4)
    <> .leaveFeedback(true)
    <> .orderCookies(10)
    <> .leaveFeedback(false)
    
let totalCookiesRequested = finalSession.totalCookiesRequested.unwrap // 25
let maxCookiesPerRequest = finalSession.maxCookiesPerRequest.unwrap // 10
let averageCookiesPerRequest = finalSession.averageCookiesPerRequest.unwrap // 4.167
let alwaysSatisfied = finalSession.alwaysSatisfied.unwrap // false

如果我们还为 UserSession 提供一个 .empty 值,我们实际上可以将所有交互收集到一个 Array 中,然后 reduce 该集合。这绝对更方便且更易读,并且允许我们将数据的收集与其处理分开。UserSession.empty 自然将是一个每个属性都是 .empty 的实例

let sessions: [UserSession] = [
    .orderCookies(3),
    .orderCookies(5),
    .leaveFeedback(true),
    .orderCookies(2),
    .orderCookies(1),
    .orderCookies(4),
    .leaveFeedback(true),
    .orderCookies(10),
    .leaveFeedback(false)
]

let finalSession = sessions.reduce(.empty, <>)

请注意,我们可以在任何集合上调用 .reduce(.empty, <>),其中元素具有以下两个属性

因此,如果我们能够以抽象的方式表示这两个属性,我们就可以简单地为这些类型的集合定义一个 .concatenated() 方法

let finalSession = sessions.concatenated()

一种类型(实际上是一个集合,但在编程中我们真正关心的只是类型)配备了一个组合运算,该运算是闭合的(即非崩溃的)和结合律的,以及一个对组合来说是中性的 .empty 值,通常称为 Monoid:此示例中定义的所有类型都是 monoid,并且 Swift 类型系统足够强大,可以使用 protocol 通用定义 monoid 的接口。此示例中使用的大多数类型和方法已经在 Abstract 中定义,要阅读有关 monoid 的更多信息,您可以参考 Monoid.swift 源文件。

FizzBuzzNess

FizzBuzz 是一个经典的面试问题,用于检查候选人解决代码问题的方式。

要求是编写一个程序,给定一个整数列表,对于每个可被 3 整除的数字打印 Fizz,对于每个可被 5 整除的数字打印 "Buzz",对于每个可被两者整除的数字(因此,可被 15 整除)打印 "FizzBuzz",对于不能被任何一个整除的数字打印数字本身。这是一个很容易用 Swift 中的 for-in 循环和几个 if-else 语句解决的问题,但面试可能会继续询问候选人如何使解决方案更通用,方法是删除检查被 3 和/或 5 整除的重复逻辑,并使其可扩展,以便例如在数字可被 4 整除时轻松引入 "Bazz" 单词,该单词应与其他 2 个单词适当组合(因此,当数字可被 12、20 或 60 整除时)。

这种问题可以用一些抽象代数优雅地解决。可能的抽象代数方法背后的基本直觉是我们正在处理以各种方式将事物组合在一起

让我们将与每个单词关联的数字称为“特殊除数”(最初,"Fizz" 为 3,"Buzz" 为 5)。每个整数可以有任意数量的特殊除数,包括零个,因此我们正在处理两种组合

第一种组合样式是简单的连接;第二种组合样式有点难以看作某种组合,但它实际上是这样一种组合,如果第一个值存在(即使两者都存在),我们只获得第一个值,否则我们获得第二个值,如果都不存在,我们获得一个“空”值。

表示字符串连接的类型很简单,就是 String,它自然在连接上形成一个 monoid,其中 .empty 值只是空字符串。

关于简单的字符串连接,我们想定义一个将单词关联到特殊除数的函数:该函数将接受一个 Int 并返回一个 String,它将是 "Fizz" 或 "Buzz"。但是,我们实际上想要连接返回单词的函数而不是连接单词:如果我们能够组合返回值,我们实际上可以定义一个可组合函数

func associate(divisor: Int, to text: String) -> Function<Int,String> {
    return Function.init { value in
        value % divisor == 0 ? text : .empty
    }
}

Function 类型是一个函数类型(我们使用 .call 方法取回函数),它是一个 monoid,因此我们可以像对 String 值一样组合和连接此函数的实例。

我们可以轻松定义我们的 fizzbuzz 关联

let fizz = associate(divisor: 3, to: "Fizz")
let buzz = associate(divisor: 5, to: "Buzz")

现在我们可以轻松生成一个函数,该函数会将数字转换为单词,正确连接(如数字 15 的 "FizzBuzz"),如果数字没有特殊除数,则为空字符串。

let transform = [fizz, buzz].concatenated().call

对于第二种类型的组合,Swift 已经提供了一种具有正确语义的类型;我们需要优先考虑第一个元素,但前提是它不是 .empty,否则我们产生第二个值(.empty 或不是):这正是 Optional 的组合语义,其中 .empty.none(或 nil),组合运算由 ?? 运算符表示。Abstract 使用 Monoid 协议扩展了 Optional,添加了 .empty 实例和 <> 运算符。我们可以定义一个 getWord 函数,该函数将使用 Optional<String> 在组合中选择一个值

func getWord<T>(for value: T, with association: @escaping (T) -> String) -> String {
	let optionalAssociated = Optional(association(value))
		.flatMap { $0 == .empty ? Optional.empty : Optional($0) }

	let optionalValue = Optional("\(value)")

	return (optionalAssociated <> optionalValue) ?? ""
}

我们可以通过将事物组合在一起来验证结果

let result = (1...100)
    .map { value in
        getWord(for: value, with: transform).unwrap <> "\n"
    }
    .concatenated()

print(result)

现在我们已经分离了此处发生的两种组合,我们可以轻松添加更多单词和关联。例如

let bazz = associate(divisor: 4, to: "Bazz")
let transform = [fizz, buzz, bazz].concatenated().call

此代码将单词 "Bazz" 添加到组合中,用于所有可被 4 整除的数字。请注意,在我们的示例中,对于数字 60,将打印单词 "FizzBuzzBazz":顺序在这里很重要,我们在末尾得到 "Bazz" 是因为我们像 [fizz, buzz, bazz] 这样组合了我们的转换。

顺序很重要(不重要)

假设我们有一个 Process<T> 类型,它封装了一个 run 函数,该函数无需任何输入即可执行,并返回类型为 T 的值。

struct Process<T> {
    let run: () -> T
}

还假设我们有一堆想要运行的进程,然后将所有值组合成一个值。按顺序运行所有进程然后收集所有值可能很乏味且效率低下,但是并行运行它们,也许以分布式方式运行它们,可能很危险、不可预测且难以协调。

我们希望利用 Abstract 中定义的抽象代数结构来简化问题。一切都取决于 T 值:事实证明,如果 T 具有某些属性,我们实际上可以以分布式和高效的方式运行我们的进程,而没有任何风险。

我们有一个这些进程的集合,并且我们希望通过将计算分配给多个单元来运行所有这些进程:进程可能需要不同的时间才能完成,并且我们希望将我们的处理单元分配给不同的线程/队列。

class ProcessingUnit {
    let context: Context
    init(context: Context) {
        self.context = context
    }
    
    func execute<T>(_ process: Process<T>, onComplete: @escaping (T) -> ()) {
        context.execute {
            let value = process.run()
            onComplete(value)
        }
    }
}

ProcessingUnit 使用执行上下文来运行进程:我们可以用 protocol 表示这一点,并使 DispatchQueue 符合它

protocol Context {
    func execute(_ call: @escaping () -> ())
}

extension DispatchQueue: Context {
    func execute(_ call: @escaping () -> ()) {
        async {
            call()
        }
    }
}

Collector 类将接收所有 T 值并将它们组合在一起:在这种情况下,对 T 的要求是它是一个 monoid,因此它具有 .empty 值和 <> 结合律组合运算。

class Collector<T: Monoid> {
    private var current: T = .empty
    func append(_ value: T) {
        current = current <> value
    }
}

最后,Distributor 类会将工作分配给处理单元

class Distributor {
    let serialContext = DispatchQueue.init(label: "serial")
    
    func distribute<T>(process: Process<T>, to collector: Collector<T>) {
        ProcessingUnit.init(context: serialContext).execute(process, onComplete: collector.append)
    }
}

请注意,如果我们对 T 施加的唯一约束是 Monoid(在上面的代码中,要求是隐式的),那么除了在串行队列上分配工作之外,我们做不了太多,因为我们需要进程以与它们传递给 distributor 的顺序相同的顺序运行和完成。

对此的改进是如果 TCommutativeMonoid:在这种情况下,<> 运算被声明为可交换的,这意味着组合的顺序无关紧要。这样我们就可以在并发队列上分配工作:即使一个进程在较早开始的进程之前结束,交换律也将确保组合仍然有意义。

class Distributor {
    let concurrentContext = DispatchQueue.init(label: "concurrent", attributes: .concurrent)
    
    func distribute<T>(process: Process<T>, to collector: Collector<T>) where T: CommutativeMonoid {
        ProcessingUnit.init(context: concurrentContext).execute(process, onComplete: collector.append)
    }
}

为了进一步改进,我们可以考虑在无状态、不协调的上下文中实际分布式执行的情况,这些上下文可能会失败、重启并多次执行。如果 TBoundedSemilattice,我们实际上可以使这种类型的上下文无论如何都能工作,因为组合运算除了可交换之外,还将声明为幂等,也就是说,应用两次与应用一次相同。

class Unreliable: Context {
    func execute(_ call: @escaping () -> ()) {
        /// could call more than once
    }
}

class Distributor {
    let unreliableContext = Unreliable()
    
    func distribute<T>(process: Process<T>, to collector: Collector<T>) where T: BoundedSemilattice {
        ProcessingUnit.init(context: unreliableContext).execute(process, onComplete: collector.append)
    }
}

通过明确定义 T 的组合语义,我们可以做出假设,使我们能够更高效且更少受约束。但是使 T 符合 BoundedSemilatticeCommutativeMonoid 甚至 Monoid 将要求 T 上的组合运算遵守一些定律,例如交换律Abstract 提供了在 Law 命名空间中定义的函数,这些函数将允许人们针对这些定律测试类型,从而证明该类型具有所需的语义。