1. Introduction

我们已经讲了一整集关于函数的内容,我们强调了查看函数的输入和输出类型以理解它们是如何组成的重要性,但是一个函数可以做很多事情不仅仅是它的签名。这些东西被称为“side effects”。

Side Effects是代码复杂性的最大来源之一,更糟糕的是,它们很难测试,而且不能很好地编写。我们已经从上一集中看到,我们从使用函数组合中获得了很多好处,但副作用会给它带来麻烦。

在这一集中,我们将介绍几种副作用,说明它们为什么如此难以测试,为什么它们不组合,并尝试以一种良好的方式解决这些问题。

Side Effects是一个相当重载的术语,所以为了定义它,让我们先看看一个没有副作用的函数:

func compute(_ x: Int) -> Int {
  return x * x + 1
}

当调用我们的函数时,我们得到一个返回结果:

compute(2) // 5

没有副作用的函数的一个非常好的特性是,无论我们调用多少次具有相同输入的函数,我们总是得到相同的输出:

compute(2) // 5
compute(2) // 5
compute(2) // 5

这种可预测性使得为它们编写测试变得非常简单。

assertEqual(5, compute(2)) // ✅

如果我们用错误的期望或错误的输入编写测试,它总是会失败。

assertEqual(4, compute(2)) // ❌
assertEqual(5, compute(3)) // ❌

让我们给函数添加一个副作用。

func computeWithEffect(_ x: Int) -> Int {
  let computation = x * x + 1
  print("Computed \(computation)")
  return computation
}

我们在中间插入了一个print语句。

当我们调用computewitheeffect时,输入和之前一样,我们得到相同的输出:

computeWithEffect(2) // 5

我们在中间插入了一个print语句。

当我们调用computewitheeffect时,输入和之前一样,我们得到相同的输出:

computeWithEffect(2) // 5

但如果我们看控制台,这里有一些额外的输出。

Computed 5

如果我们比较函数签名,computewitheeffect和compute是完全一样的,但是我们所做的工作无法通过单独看签名来解释。print函数正在走向世界并做出改变,在这种情况下,打印到我们的控制台。副作用需要了解函数体才能知道它们隐藏在那里。

让我们为这个函数编写一个测试:

assertEqual(5, computeWithEffect(2)) // ✅

它通过了! 但是现在我们在控制台中有了额外的一行。

Computed 5
Computed 5

这是我们无法测试的行为。这里我们只打印到控制台,所以好像不是一个大问题,但如果我们交换打印效果,如写入磁盘,调用一个API请求,或分析跟踪,我们开始关心更多,这种行为发生,我们可以测试它。

副作用也会破坏我们构建的组合直觉。在函数一节中,我们讨论了映射两个函数的数组与映射两个函数的组合的数组是一样的:

[2, 10].map(compute).map(compute) // [26, 10202]
[2, 10].map(compute >>> compute)  // [26, 10202]

现在让我们用computewiththeeffect来试试:

[2, 10].map(computeWithEffect).map(computeWithEffect)
// [26, 10202]
[2, 10].map(computeWithEffect >>> computeWithEffect)
// [26, 10202]

返回值是相等的,但是当我们查看控制台时,行为是不相等的!

Computed 5
Computed 101
Computed 26
Computed 10202
--
Computed 5
Computed 26
Computed 101
Computed 10202

我们再也不能在不考虑副作用的情况下利用这一特性了。我们进行这种重构的能力是真实的性能优化:我们只遍历一次数组,而不是遍历两次数组。但是,如果我们的函数有副作用,它们将不会以相同的顺序执行,而顺序可能是我们所依赖的! 在有副作用的情况下进行这种性能优化可能会破坏我们的代码!

2. Hidden outputs

让我们来看看控制这种副作用的最简单的方法。我们可以返回一个额外的值来描述需要打印的内容,而不是在函数体中执行该效果。 一个函数可以打印很多东西,所以我们将使用一个字符串数组来建模。

func computeAndPrint(_ x: Int) -> (Int, [String]) {
  let computation = x * x + 1
  return (computation, ["Computed \(computation)"])
}

computeAndPrint(2) // (5, ["Computed 5"])

我们得到的结果不仅是计算结果,还包括我们想要打印的日志数组。

让我们编写一个测试:

assertEqual(
  (5, ["Computed 5"]),
  computeAndPrint(2)
)
// ✅

现在我们得到的不仅仅是计算,还有我们想要执行的效果! 如果副作用以一种意想不到的形式出现,我们的测试将会失败。

assertEqual(
  (5, ["Computed 3"]),
  computeAndPrint(2)
)
// ❌

这里的数据非常简单,但请记住,它可能是描述API请求或分析事件的更关键的数据,我们可以断言这些效果是按照我们期望的方式准备的。

从这个角度来看,改变外部世界的副作用只不过是函数的一个隐藏的、隐式的输出。当涉及到编程时,隐式通常不是一件好事。

现在你可能会问,“那是谁做的呢?” 通过将该副作用拉出到返回类型中,我们将该副作用的责任推给了调用该函数的人。例如:

let (computation, logs) = computeAndPrint(2)
logs.forEach { print($0) }

但是,我们可能不希望调用者有副作用,所以它可能也要传递这些副作用。也许它的调用者不想有副作用,等等! 这听起来像是一个混乱的问题,但有很好的方法来解决它。不过,在我们解决这个问题之前,我们需要详细了解这个问题。

似乎我们已经解决了副作用问题:我们只需要在函数的输出中用描述替换它们。不幸的是,我们破坏了函数最重要的特性之一:组合。

我们的compute函数很好,因为它与自身正向组合。

compute >>> compute // (Int) -> Int

我们的computewitheeffect函数其实很好因为它也和自己进行正向合成。

computeWithEffect >>> computeWithEffect // (Int) -> Int

我们可以将值导入它们并得到结果。

2 |> compute >>> compute // 26
2 |> computeWithEffect >>> computeWithEffect // 26

当然,现在我们又回到了computewitheeffect打印到控制台。

Computed 5
Computed 26

与此同时,我们试图解决这个问题computeAndPrint,却没有组合:

computeAndPrint >>> computeAndPrint
// Cannot convert value of type '(Int) -> (Int, [String])' to expected argument type '((Int, [String])) -> (Int, [String])'

computeAndPrint的输出是一个元组(Int, [String]),但输入只是Int。

我们将反复看到这种情况:每当我们有一个函数需要执行一个副作用时,我们将增加返回类型来描述这个效果,并且我们将中断函数的组合。然后我们的工作就是想出某种方法来增强这些功能的构成。

在函数返回元组的情况下,我们可以很好地修复组合,甚至比我们的computeAndPrint函数更普遍。让我们定义一个函数,它的全部工作就是组合这些类型的函数。

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  // …
}

这看起来有点眼熟。它有一个类似于>>>函数的签名:我们看到(a) -> B, (B) - >c,和(a) - >c,但是在旁边有一些额外的信息。

我们可以通过查看函数的类型和我们可以使用的值来实现这个函数。

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}

我们知道我们正在返回一个函数,所以我们从打开它并绑定a开始,我们还有一个函数f,它接受a,所以我们传递它a并绑定返回值,在这个例子中,是b和一些logs。现在我们把b很好地代入到函数g中,它返回c和一些logs。现在我们有了一个c,我们可以从函数中返回它。我们可以返回logs或moreLogs,但在本例中,返回两者的连接是有意义的。

我们来构造函数吧!

compose(computeAndPrint, computeAndPrint)
// (Int) -> (Int, [String])

我们现在创建了一个全新的函数,它调用computeAndPrint两次。 当我们向它输入数据时,我们得到的不仅是最终的计算结果,还有整个过程中每一步的日志。

2 |> compose(computeAndPrint, computeAndPrint)
// (26, ["Computed 5", "Computed 26"])

3. Introducing >=>

似乎我们已经恢复了复合,完全解决了问题,但当我们组合两个以上的函数时,事情就开始变得混乱了。

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)

更糟糕的是,同样的组合有两种不同的方法:

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)
2 |> compose(computeAndPrint, compose(computeAndPrint, computeAndPrint))

括号似乎总是组合的敌人。括号的敌人是什么? 中缀操作符。

我们知道我们想要能够在一行中组合多次,我们知道我们想要能够将值通过管道送到这些组合中,所以让我们定义一个比|>操作符更高的结合优先级组。

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
}

现在我们可以定义一个看起来有点熟悉的中缀运算符。

infix operator >=>: EffectfulComposition

它非常接近>>>,但是我们把内部的箭头换成了管状的=。这个操作符的一个有趣的名字是“fish”操作符。

现在,我们可以重命名compose函数,并且可以将副作用函数粘在一起,而不必考虑括号的噪音和负担。

func >=> <A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}
computeAndPrint >=> computeAndPrint >=> computeAndPrint // (Int) -> (Int, [String])

我们可以通过管道传递值,并使用多行创建从上到下读取良好的管道。

2
  |> computeAndPrint
  >=> computeAndPrint
  >=> computeAndPrint

将合成带入运算符世界的另一件好事是,它能更好地与>>>等现有运算符合作。

2
  |> computeAndPrint
  >=> (incr >>> computeAndPrint)
  >=> (square >>> computeAndPrint)

在这里,我们可以将有效函数的结果应用到没有副作用的函数上,所有这些都是通过组合实现的。我们有一个新的括号问题,但我们可以解决它! 函数组合可能是最强大的组合形式,给定括号的位置,以及输入和输出类型,我们可以得出结论,它应该始终具有更高的优先级。 这意味着我们需要更新effecfulcomposition优先组。

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
  lowerThan: ForwardComposition
}

我们不再需要圆括号,我们可以进一步流水线我们的组合。

2
  |> computeAndPrint
  >=> incr
  >>> computeAndPrint
  >=> square
  >>> computeAndPrint

现在,每行都用提供意义的操作符进行注释。以>>>为前缀的行处理的是一个没有副作用的函数的结果,而以>=>为前缀的行则有点可疑:它们处理的是一个有效计算的结果。

我们已经引入了一个新的操作符,所以现在是时候向代码中添加它了。

  1. 该操作符在Swift中是否存在意义? 不。没有机会重载现有的意义。
  2. 这个操作者是否有现有技术并且它是否有一个漂亮的描述形状? 是的! fish操作符随Haskell和PureScript一起发布,许多其他编程语言社区也在函数库中采用了它。 它的形状很漂亮,特别是在>>>旁边,它的不同足以表明有别的东西在发生。
  3. 这是一个通用操作符还是仅仅解决一个特定领域的问题? 现在定义运算符的方式是专门用于处理元组的,但它所描述的形状在编程中一直都在出现。我们甚至可以在几个Swift类型上定义操作符:
func >=> <A, B, C>(
  _ f: @escaping (A) -> B?,
  _ g: @escaping (B) -> C?
  ) -> ((A) -> C?) {

  return { a in
    fatalError() // an exercise for the viewer
  }
}

我们已经用可选项替换了元组,现在有了一个操作符,可以帮助组合返回可选项的函数。 我们现在可以将几个失败的初始化器链接在一起:

String.init(utf8String:) >=> URL.init(string:)
// (UnsafePointer<Int8>) -> URL?

我们还可以免费获得一个全新的可失败初始化器!

也可以使用该操作符来增强返回数组的函数的组合:

func >=> <A, B, C>(
  _ f: @escaping (A) -> [B],
  _ g: @escaping (B) -> [C]
  ) -> ((A) -> [C]) {

  return { a in
    fatalError() // an exercise for the viewer
  }
}

如果使用Promise或Future类型,则可以使用该操作符来组合返回Promise的函数。

func >=> <A, B, C>(
  _ f: @escaping (A) -> Promise<B>,
  _ g: @escaping (B) -> Promise<C>
  ) -> ((A) -> Promise<C>) {

  return { a in
    fatalError() // an exercise for the viewer
  }
}

我们会看到这个形状一次又一次地出现。在一些具有非常强大的类型系统的语言中,一次性定义这个操作符并立即获得所有这些实现是可能的。Swift还没有这些功能,所以我们必须为新的类型定义它们。尽管如此,我们仍然能够对这个形状建立一种直觉,并将它分享给很多很多类型的人。 现在,当我们看到*>=>*时,我们可以知道它是连锁的,形成某种效应。

4. Hidden inputs

我们已经介绍了导致隐藏输出的副作用,并展示了如何通过在函数中显式地显示输出来控制它,同时保持可组合性。还有另一种副作用有点棘手。

让我们来看一个为用户生成问候语的简单函数。

func greetWithEffect(_ name: String) -> String {
  let seconds = Int(Date().timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greetWithEffect("Blob")
// "Hello Blob! It's 14 seconds past the minute."

当我们再次运行这段代码时,我们可能会得到一个不同的值。这与我们的计算函数的可预测性相反。

如果我们编写一个测试,它几乎总是会失败。

assertEqual(
  "Hello Blob! It's 32 seconds past the minute.",
  greetWithEffect("Blob")
)
// ❌

这是一个特别糟糕的副作用。在前面的例子中,我们至少可以针对它的输出编写断言,只是我们没有覆盖整个故事。在这种情况下,我们甚至不能编写测试,因为输出一直在变化。

我们之前的副作用是print,这是一个接受输入但没有返回值的函数。在本例中,我们有Date,这是一个具有返回值但不接受输入的函数。

我们来看看能否用类似的方法来解决这个副作用。前面我们在compute的返回值中显式地显示了print的效果,这里我们可以将Date的效果显式地作为函数的参数。

func greet(at date: Date, name: String) -> String {
  let seconds = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greet(at: Date(), name: "Blob")

这个函数的行为与以前相同,但有一个关键的区别:我们现在可以控制日期,并有一个始终通过的测试。

assertEqual(
  "Hello Blob! It's 39 seconds past the minute.",
  greet(at: Date(timeIntervalSince1970: 39), name: "Blob")
)
// ✅

我们已经恢复了可测试性,代价是使用了一些样板。 函数的调用者需要显式地传递日期,这在我们的测试之外似乎是不必要的。我们可能会试图通过指定一个默认参数来隐藏这个实现细节,并将这个对当前日期的依赖注入到函数中来清理调用站点。

func greet(at date: Date = Date(), name: String) -> String {
  let s = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(s) seconds past the minute."
}

greet(name: "Blob")

这样读起来更好,但我们有一个更大的问题:我们又破坏了组合。

们的第一个gretwitheeffect函数有一个很好的*(String) -> String*形状,可以与其他返回字符串的函数和接受字符串作为输入的函数组合。

让我们以一个简单的函数为例,将字符串大写:

func uppercased(_ string: String) -> String {
  return string.uppercased()
}

这在greetwitheeffect的两边构成了很好的效果。

uppercased >>> greetWithEffect
greetWithEffect >>> uppercased

我们可以通过管道传入一个名称,并为每个组合获得不同的行为。

"Blob" |> uppercased >>> greetWithEffect
// "Hello BLOB! It's 56 seconds past the minute."
"Blob" |> greetWithEffect >>> uppercased
// "HELLO BLOB! IT'S 56 SECONDS PAST THE MINUTE."

然而,我们的问候功能并不能组合。

"Blob" |> uppercased >>> greet
"Blob" |> greet >>> uppercased
// Cannot convert value of type '(Date, String) -> String' to expected argument type '(_) -> _'

它有两个输入,所以没有办法把函数的输出组合进去。 如果我们忽略Date输入,我们仍然可以看到(String) -> String形状。实际上,我们可以使用一些技巧来从签名中提取Date:我们可以重写greet以接受Date作为输入,但从(String)——> String返回一个全新的函数,它处理实际的问候逻辑:

func greet(at date: Date) -> (String) -> String {
  return { name in
    let s = Int(date.timeIntervalSince1970) % 60
    return "Hello \(name)! It's \(s) seconds past the minute."
  }
}

现在我们可以用日期调用greet函数并得到一个全新的(String) -> String函数。

greet(at: Date()) // (String) -> String

函数组合在一起了!

uppercased >>> greet(at: Date()) // (String) -> String
greet(at: Date()) >>> uppercased // (String) -> String

我们可以通过管道传递值!

"Blob" |> uppercased >>> greet(at: Date())
// "Hello BLOB! It's 37 seconds past the minute."
"Blob" |> greet(at: Date()) >>> uppercased
// "HELLO BLOB! IT'S 37 SECONDS PAST THE MINUTE."

我们恢复了成分,仍然具有可测试性。

assertEqual(
  "Hello Blob! It's 37 seconds past the minute.",
  "Blob" |> greet(at: Date(timeIntervalSince1970: 37))
)
// ✅

所以现在我们遇到了一个无法测试的效果我们可以通过将情境移动到函数的输入中来控制它,这是我们之前遇到的效果的双重版本。我们的第一个效果延伸到世界并做出了改变,这有点像一个隐藏的输出,而这个效果取决于外部世界的某种状态,这有点像一个隐藏的输入! 所有的效果都以这种方式表现出来。

5. Mutation

让我们来看一种非常特殊的效应并真正分析它:mutation。我们都必须处理代码中的mutation,它会导致很多复杂性。幸运的是,Swift提供了一些类型级别的特性来帮助控制变异,并正确地记录变异发生的方式和位置。

这里有一个例子,说明mutation是如何变得混乱的。这个示例的灵感来自于我们过去编写的实际代码,它变得越来越丑陋,越来越痛苦,直到我们最终重写它以控制mutation

let formatter = NumberFormatter()

func decimalStyle(_ format: NumberFormatter) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func currencyStyle(_ format: NumberFormatter) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func wholeStyle(_ format: NumberFormatter) {
  format.maximumFractionDigits = 0
}

这里我们有一个来自Foundation的NumberFormatter和几个用特定样式配置数字格式化器的函数。 要使用这些样式化函数,可以直接将它们应用到格式化程序中。

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,235"

currencyStyle(formatter)
formatter.string(for: 1234.6) // "$1,234"

如果现在重新应用第一组格式化器,就会出现问题。

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,234"

输出从" 1,235 "变成了" 1,234 "改变的原因是什么? mutation。 currencyStyle函数的更改已经影响到格式化程序的其他用途,导致在更大的上下文中可能难以追踪的bug。

这就是为什么mutation如此棘手的一个例子。 我们不可能知道一行在做什么,直到我们回溯到了解每一行之前它做了什么。mutation是我们到目前为止遇到的两种副作用的一种表现,在函数之间传递的可变数据是一个隐藏的输入和输出!

我们看到这种特殊类型的mutation的原因是NumberFormatter是一个“引用”类型。在Swift中,类是引用类型。引用类型的实例是单个对象,当发生突变时,该对象已为持有或可能持有该对象的引用的代码基的任何部分更改。没有简单的方法来跟踪代码库的哪些部分可能保持对对象的相同引用,当涉及到mutation时,这可能会导致很多混乱。如果在应用程序中使用我们的示例代码,并且编写了一个依赖于该格式化程序的新特性,那么微妙的错误可能会渗入到代码库的全新部分。

Swift也有“value”类型。这就是Swift控制mutation的答案。当您赋值时,您将得到一个用于给定范围的全新副本。 所有的mutation都是局部的,任何保持上游相同值的东西都不会看到这些变化。

让我们重构这段代码以使用值。

我们将从一个结构开始,它是一个包装NumberFormatter配置的结构。

struct NumberFormatterConfig {
  var numberStyle: NumberFormatter.Style = .none
  var roundingMode: NumberFormatter.RoundingMode = .up
  var maximumFractionDigits: Int = 0

  var formatter: NumberFormatter {
    let result = NumberFormatter()
    result.numberStyle = self.numberStyle
    result.roundingMode = self.roundingMode
    result.maximumFractionDigits = self.maximumFractionDigits
    return result
  }
}

它有一些很好的默认值和一个formatter computed属性,我们可以使用它派生新的“诚实的”NumberFormatters。将样式化函数更新为使用NumberFormatterConfig会是什么样子?

func decimalStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

func currencyStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .currency
  format.roundingMode = .down
  return format
}

func wholeStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.maximumFractionDigits = 0
  return format
}

每个样式化函数都接受一个NumberFormatterConfig,使用var关键字复制它,并在将其返回给调用者之前更改本地副本。

使用它看起来有点不同。

let config = NumberFormatterConfig()

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

currencyStyle(config)
  .formatter
  .string(for: 1234.6)
// "$1,234"

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

这一次,每当我们将config传递给样式函数时,我们就会得到一个全新的副本,bug也就消失了!

对于引用类型,我们可以使用实现NSCopying并显式返回此副本的类的copy方法来做类似的事情:

func decimalStyle(_ format: NumberFormatter) -> NumberFormatter {
  let format = format.copy() as! NumberFormatter
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

不幸的是,在这里,编译器不能保证我们不会改变原始格式化程序。 在此之上,调用者可能希望得到一个副本,可以自由地进行进一步的更改,并从那里构建复杂性!

因为引用类型不会自动复制,所以它们确实有一些很好的性能优势。幸运的是,Swift提供了一种很好的语义方式来就地更改值:inout关键字。

让我们修改配置样式函数来使用inout

func inoutDecimalStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func inoutCurrencyStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func inoutWholeStyle(_ format: inout NumberFormatterConfig) {
  format.maximumFractionDigits = 0
}

这看起来很像NumberFormatter上原始的、变化的函数。我们可以立即执行mutations,而不必担心复制值或返回值。让我们尝试以使用原始样式函数的方式使用这些样式函数。

let config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)

我们得到一个编译错误!

Cannot pass immutable value as inout argument: 'config' is a 'let' constant

Swift甚至提出为我们解决这个问题,将let转换为var。

var config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)

但这还不够。我们有另一个编译错误!

Passing value of type 'NumberFormatterConfig' to an inout parameter requires explicit '&'

Swift要求我们在调用处标注我们同意让这些数据发生变异。

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,235"

我们可以继续以类似的方式调用不断变化的样式函数。

inoutCurrencyStyle(&config)
config.formatter.string(from: 1234.6) // "$1,234"

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,234"

这又是我们的漏洞,但现在涉及的代码有很多“mutation”的语法,这使得这类漏洞更容易追踪。

Swift为mutation问题提供了一个很好的解决方案,它提供了类型级别的特性来控制突变发生的位置和传播的距离。但是,如果我们想要使用它,我们仍然有一个问题要解决。

我们使用的样式函数返回的全新副本有一个漂亮的形状:

(NumberFormatterConfig) -> NumberFormatterConfig

它们有相同的输入和输出,这意味着它们相互组合,它们与任何其他返回或接受NumberFormatterConfig的函数组合!

decimalStyle >>> currencyStyle
// (NumberFormatterConfig) -> NumberFormatterConfig

现在我们有了一个全新的造型功能,它是由更小的部件组成的。

与此同时,我们的inout函数没有这种形状:它们的输入和输出不匹配,通常它们没有许多函数组成。 不过,这些函数具有相同的逻辑,因此必须有一种方法将内外世界与函数世界连接起来。

我们可以定义一个名为toInout的函数,它将具有相同输入和输出类型的函数转换为inout函数。

func toInout<A>(
  _ f: @escaping (A) -> A
  ) -> ((inout A) -> Void) {

  return { a in
    a = f(a)
  }
}

我们也可以定义一个对偶函数,fromInout,它做逆变换。

func fromInout<A>(
  _ f: @escaping (inout A) -> Void
  ) -> ((A) -> A) {

  return { a in
    var copy = a
    f(&copy)
    return copy
  }
}

我们在这里看到的是(A) -> A函数和(inout A) -> Void函数之间有一个自然的对应关系。来自(A) -> A的函数组合得非常好,因此通过这种通信,我们希望来自(inout A) -> Void的函数能够共享这些组合特性。

6. Introducing <>

即使我们看到(A) -> A函数使用>>>组合,我们不应该重用这个操作符,因为它有太多的自由度。我们看到的是一个更加受限的,单一类型的组合。让我们定义一个新的运算符。让我们从优先级组开始。

precedencegroup SingleTypeComposition {
  associativity: left
  higherThan: ForwardApplication
}

我们来定义算符。

infix operator <>: SingleTypeComposition

这个操作符的一个有趣的名字是“diamond”操作符。

我们可以简单地对*(A) -> A*定义运算符:

func <> <A>(
  f: @escaping (A) -> A,
  g: @escaping (A) -> A)
  -> ((A) -> A) {

  return f >>> g
}

仅仅用一个操作符包装另一个操作符可能看起来很愚蠢,但我们已经限制了它的使用方式,并对其进行了一些编码:当我们看到这个操作符时,我们知道我们在处理单一类型!

让我们为inout函数定义<>:

func <> <A>(
  f: @escaping (inout A) -> Void,
  g: @escaping (inout A) -> Void)
  -> ((inout A) -> Void) {

  return { a in
    f(&a)
    g(&a)
  }
}

我们之前的组合生效了

decimalStyle <> currencyStyle

更好的是,我们可以编写我们的inout样式函数!

inoutDecimalStyle <> inoutCurrencyStyle

当我们开始将值管道化到我们的合成中时会发生什么?

config |> decimalStyle <> currencyStyle
config |> inoutDecimalStyle <> inoutCurrencyStyle

我们的inout版本产生一个错误。

Cannot convert value of type '(inout Int) -> ()' to expected argument type '(_) -> _'

这是因为|>在inout中还不能工作,但是我们可以定义一个重载。

func |> <A>(a: inout A, f: (inout A) -> Void) -> Void {
  f(&a)
}

现在我们可以自由地将值输送到这些可变管道中。

config |> inoutDecimalStyle <> inoutCurrencyStyle

这是伟大的! 我们不必牺牲可组合性来利用一些不错的Swift特性。 我们已经用另一个操作符的代价解决了这个问题,所以是时候检查一下了。

  1. Swift中是否存在该操作符? 没有,所以这里没有混淆的可能。
  2. 有现有技术吗? 是的。它存在于Haskell、PureScript和其他已经采用了它的强大函数社区的语言中。它有一个很好的形状,指向两个方向,某种程度上表示连接在一起。
  3. 这是一个通用操作符还是仅仅解决一个特定领域的问题? 到目前为止我们为(A) - >A函数和(inout) - > Void函数只定义该操作符,但事实证明,*<>*更通常用于将相同类型的两个东西组合成一个,这是一种最基本的计算单位。

7. What’s the point?

是时候放慢脚步,问问自己:“这有什么意义?” 我们遇到过许多将复杂性引入代码并使其更难以测试的效果。我们决定通过做一点前期工作来解决这个问题,使输入和输出类型的效果显式,但随后我们破坏了合成。然后我们介绍了辅助合成的操作符,专门用于合成效果。这值得吗?

我们会这样说! 我们能够提升我们的有效代码,这是不可测试的,并且很难单独推理,进入一个效果是明确的世界,我们可以测试和理解一行,而不需要理解它之前的任何一行。我们在没有破坏组合的情况下完成了这一切。这真的很强大!

与此同时,我们额外的前期工作将为我们节省大量的时间,包括调试包含复杂突变网络的代码,修复散布在各处的副作用漏洞,以及让代码变得可测试的时间。

副作用是一个很大的话题,而我们只触及了表面。我们将在未来探索许多有趣的方法来控制副作用。请继续关注!