Monad Menagerie

在上一篇文章中,我花了一些时间看一下Monads —数据结构提供了称为bindflatMap (或>>>= )的功能。 Monads为我们提供了一种强大的新功能组合方式。

然后,我花了一些时间来尝试了解Monad是什么。

最后,我们看到OptionalsArraysWebData类型都是Monad(并为它们定义了bind )。

在本文中,我将介绍很多基础知识,看看一些我们在Swift中不经常看到的Monad(或至少不经常被视为Monad)的Monad示例: ReadersWriterFutures。

所有示例都可以在Github上找到。 有一个游乐场可以尝试。 请注意,为了使本文中的代码示例简短,我漏掉了一些细节-您可以在Github中查看工作代码。

作家✍️

让我们开始与作家。 Writer可以将一个与有关该值的一些信息打包在一起; 随着我们在后续计算步骤中继续处理该值,我们可以继续添加信息。

它通常用作记录进度或跟踪运行总计的一种方式。 在许多地方,您会使用一个突变状态(例如,一个inout参数,对全局日志记录功能的写入,甚至只是一个print语句),但是这些操作很难测试,并且在多线程环境中无法很好地工作。

Writer有两个类型参数:第一个是“登录”或“累积”的类型; 第二个是计算的当前值。 换句话说,我们有:

 Writer 

V (计算值)可以是任何类型,但是A (累加器)必须是称为Monoid的特殊类型。 Monoids¹只是带有“加法”运算符的类型,表示为和“零”或“空”值。 我不会在本文中介绍Monoid(请查看github代码以获取更多信息),但请注意ArrayInt都是Monoid。

Int的“零”当然是0 ; 而“加法”运算符当然是+ 。 更有趣的是,Array的“零”为[] ,加法为.append+

因此,举例来说,假设我们正在编写一个图形包来处理图片。

 struct Picture { 
let pixels: [UInt8]
...
}

我们希望保留所有操作的记录。 为了保持跟踪而不改变状态,我们需要将记录传递到每个操作函数中,如下所示:

 func makeGreyScale( log: [String] ) → (Picture,[String] ) { 
let greyScaleVersion = self.map { ... }
return (greyScaleVersion, log + ["Convert to grey scale"])
}

这很尴尬。 假设我们要应用一系列操作(例如,应用灰度,缩小并旋转图片)。 由于使用log参数,我们需要在单个步骤中执行此操作:

 let initialPicture = 
Picture(pixels:Array(repeating:100, count:10))
 let greyScaleVersion = 
initialPicture.makeGreyScale(log: [])
 let resizedVersion = 
greyScaleVersion.0.resizePicture(byScale: 0.5, log: greyScaleVersion.1)
 let rotatedVersion = 
resizedVersion.0.rotatePicture(byAngle: 90, log: resizedVersion.1)
 let displayedVersion = 
rotatedVersion.0.displayWithLog(log: rotatedVersion.1)
 print( displayedVersion.1, displayedVersion.0) 

太乱了 为了使这一点更简洁,让我们定义一个表示操作的协议,以及一个可用于记录日志的名称:

 protocol PictureOperation { 
var name: String { get }
func operation(p: Picture) → Picture
}

然后,我们将每个操作写为类型而不是函数:

 struct GreyScaleOperation : PictureOperation { 
let name = "Convert to grey scale"
func operation(p: Picture) → Picture { /* ... */ }
}

现在,我们可以使用Writer monad来调整图片并同时跟踪图片和日志:

 func adjustPicture(do op: PictureOperation, _ picture Picture) → Writer { 
return Writer(writing: [op.name],
value: op.operation(p:picture))

}

这有什么帮助? 好了,现在我们可以使用>>>=来执行以下所有操作并跟踪日志:

 let adjusted : Writer = 
adjustPicture( do: GreyScaleOperation(), initialPicture ) >>>= { p1 in
adjustPicture( do: RotateOperation(angle: 90, p1) >>>= { p2 in
adjustPicture( do: ResizeOperation(scale: 0.5, p2) >>>= { p3 in
Writer(writing:["Displaying picture"], value: p3.display() )
}}}

现在, adjusted.writing (由Writer monad提供)包含日志-“累加值”-, adjusted.value包含最终图片。

 print(adjusted.writing) 
//["Convert to grey scale", "Resize by factor of 0.5", "Rotate picture by angle of 90.0", "Displaying picture"]

这比尝试自己跟踪日志记录要简单得多。

作家作为单子

您可能还记得,Monad的窍门是定义bind函数。 Monad M看起来像这样:

 struct M { 
func bind( f: (A) → M ) → M
}

尽管我们的Writer在这里有2个类型参数AV bind如何工作? 有一个简单的技巧:我们“固定”一个类型参数(总是第一个),而只允许第二个参数在bind函数中变化。 HKT通过DualConstructibleFixedType标签支持此FixedType (并使用Sourcery魔术来满足Monoid要求)

 struct Writer : DualConstructible { 
typealias TypeParameter = V
typealias FixedType = A
let writing: A
let value: V
}

让我们看看如何为DualConstructible类型定义bind 。 我们只在签名中包含FixedType ,如下所示:

 func bind(_ m: (TypeParameter) → M) → M 

鉴于此,我可以为Writer定义bind 。 我们需要做的是将函数m应用于Writer中的 ,以获取新值。 但是我们还需要更新书写部分-例如,将其附加到日志中。 我怎么做?

回想一下Monoid总是有一个写为append运算符(对于Int, append+ ,对于String, append+依此类推 )。 因此,因为我们的文字Monoid ,所以可以使用进行更新:

 func bind(_ m: (TypeParameter) → Writer) → Writer { 
let origWriter = self
let newWriter = m(origWriter)
return Writer
(
writing: origWriter.writing updWriter.writing,
value: newWriter.value )
}

读者📚

Reader monad用于包装函数,因此我们可以将其执行推迟到拥有正确的上下文来操作为止。

读取器(也称为Func或Function类型)有时用于提供依赖项注入 ,其中从应用程序的顶层传入“环境”依赖项(如网络连接,数据库等),因此可以轻松交换测试和生产环境。 但是,可以以更整洁的方式完成这种全局依赖注入(请参见此处的PointFree情节)。 和Readers对于在应用程序的较小部分而不是全局范围内更改上下文更有用。

读取器具有两个类型参数,并表示一个函数 。 换一种说法:

 Reader 

表示从XY的函数。 我们这样定义Reader:

 struct Reader : DualConstructible { 
let g: (X) → Y
init(g: @escaping (X) → Y) { self.g = g }

func apply(_ x: X) → Y {
return g(x)
}
}

建筑bind

令人惊讶的是,如果我们“固定”第一个类型参数( X ),这实际上是Monad,因为我们可以为其定义bind 。 值得一看,这可能会有助于解释Reader的工作方式。 (这有点棘手:您可能想跳到示例!)

请记住,对于Monad M并输入Abind看起来像:

 struct M { 
func bind( f: (A) → M ) → M
}

如果我们说Reader“固定”其第一个类型参数X成为Monad,我可以用Reader代替M

 extension Reader { 
func bind( f: (A) → Reader ) → Reader { ... }
}

但是Reader表示X → A一个函数,因此我可以在其中交换它:

 func bind( f: (A) → ( (X) → B ) ) → (X) → B 

所以; 如果我们有一个X → A的函数(即我们正在扩展的原始Reader ),另一个函数f 取一个 A 并从 X → B 返回一个函数 ; 然后bindX → B获得第三个函数。 综上所述, X是我们要使用的上下文 。 函数X → AX → B作用于上下文的代码,用于为我们提供结果。

因此,按照所有功能箭头我们将看到如何定义bind

 extension Reader { 
func bind(_ f: @escaping (A) → Reader) → Reader {
return Reader{ x in f(self.g(x)).g(x) }
}
}

回到我们的Picture示例,上下文可能是我们想要输出图片的格式。假设我们有几种可能的输出格式:

 enum OutputContext { 
case screen(size: CGSize, colour: Bool)
case printout(size: CGSize)
case asciiArt
}

渲染图片时,我们要执行一些操作-例如为图片应用合适的色调,然后调整其大小以适合输出画布。

 extension Picture { 
func toneMapping( d: CGFloat) → ???
func scaleToFit() → ???
func performRender() → ???
}

这些操作中的每一个都是在输出内容的上下文中完成的。 通过使用Reader ,我们可以定义要预先执行的操作的列表,但是在知道输出上下文是什么之前,我们不需要实际执行它们。 因此,我们的函数如下所示:

 func toneMapping( d:CGFloat ) → Reader {...} 
func scaleToFit() → Reader {...}
func performRender() → Reader {...}

最后一行的Void是因为渲染实际上没有返回值,它只是将图片渲染到OutputContext提供的画布上。

然后我们可以使用>>>=将功能粘合在一起:

 extension Picture { 
func renderToContext() → Reader {
return .pure(self) >>>= { p1 in
p1.toneMapping(d: 0.5) >>>= { p2 in
p2.scaleToFit() >>>= { p3 in
p3.performRender()
}}}
}
}

现在,当我们要实际进行渲染时,我们可以应用正确的上下文( apply由Reader monad提供):

 picture.renderToContext().apply(.asciiArt) 
picture.renderToContext().apply(.printOut(size: pageSize))

您可能想知道toneMappingscaleToFitperformRender函数的实际外观–它们如何返回Reader? 这是toneMapping的示例:

 func toneMapping(d: CGFloat) → Reader { 
return Reader { context in
context.mapTone(d: d, picture: self)
}
}

在这里,Reader的初始化是通过使用OutputContext并使用Picture进行响应的函数完成的。

Reader在HKT中再次定义为DualConstructible类型。

未来🔮

尽管您可能不知道期货也是单子货币,但许多人可能对期货很熟悉。 期货在许多方面与Readers相似,因为它们允许将函数执行推迟到以后的时间。 但是,当内部状态准备就绪时,阅读器将读取上下文并返回结果,而外部事件发生时(例如,从远程服务器加载数据时)将执行Future。

本文中Future的实现是John Sundell出色的博客文章中经过HKT修改的代码版本,因为它很容易理解。 我绝对建议您阅读该书,作为对期货和承诺的出色介绍。

什么是未来?

发生外部事件时,将向Future注册一组回调以供调用。 外部事件的Result的类型为Result

 enum Result { 
case value(Value)
case error(Error)
}

因此,Future的实现看起来像这样(请参阅原始博客文章):

 class Future : Constructible { 
typealias TypeParameter = Value

fileprivate var result: Result? {
// Observe whenever a result is assigned, and report it
didSet { result.map(report) }
}
private lazy var callbacks = [(Result) → Void]()

func observe(with callback: @escaping (Result) → Void) {
callbacks.append(callback)

// If a result has already been set, call the callback directly
result.map(callback)
}
}

期货与Promises结合在一起:Promise表示尚未发生外部事件的Future(换句话说,Promise“承诺”在将来的某个时间返回Value )。 事件发生时,如果发生错误,则诺言将被拒绝;如果成功返回了数据,则诺言将 解决 。 我们不会在此处显示Promise的实现(请参见github代码或John的帖子),但是这是一个示例,说明我添加新的扩展方法request (允许我们返回Future URLSession外观:

 extension URLSession { 
func request(url: URL) → Future {
// Start by constructing a Promise, that will later be
// returned as a Future
let promise = Promise()

// Perform a data task, with the completion handler
dataTask(with: url) { data, _, error in
// Reject or resolve the promise, depending on the result
if let error = error {
promise.reject(with: error)
} else {
promise.resolve(with: data ?? Data())
}
}.resume()
return promise
}
}

URLSession代码中,当dataTask (Foundation类型的URLSession的现有方法)完成时,它运行完成处理程序并相应地拒绝解析 promise。 PromiseFuture的子类。

这为发出网络请求提供了一个不错的界面:

 URLSession.shared.request(url: url).observe { result in 
// Handle result
}

未来的单子

如果我要执行两个网络请求,一个接一个,该怎么办? 我需要检查第一个网络请求的结果; 并且只有在成功的情况下,才使用第一个请求的结果执行第二个请求,并检查第二个结果(最后返回第二个请求的结果,否则返回错误)。

换句话说,如果我有一个Future来执行第一个操作,则需要一个链接函数来获取该结果并执行第二个操作, (A) → Future ,如下所示:

 public func chain(_ m: (A) → Future) → Future 

但这无非是我们的老朋友再次bind ! 使用this和pure的实现(请参见github代码),我们有了Monad定义。

现在,我可以执行顺序网络请求:

 func userLoader( id: String, s: LoaderSession ) → Future { 
return
s.urlSession.requestData(url:s(id)) >>>= { data in
s.urlSession.requestData(url:s.urlForDetailLookupFrom(data: data)) } >>>• { dt in
User(fromData: dt) }
}

看到>>>•快要结束了吗? 那是fmap的运算符版本:所以v >>>• fv.fmap(f)相同。 因为所有Monad都是Functor,所以我们可以为它们定义fmap –这对Future是非常有用的操作,因为它允许我们将Future的结果从一种类型转换为另一种类型。

在这种情况下, >>>• { dt in User(fromData: dt }>>>• { dt in User(fromData: dt }将从Future返回的Future转换为Future

结论

我们已经看到了Monad模式的一些非常强大的用法,您也许可以看到如何使用它们将自己的代码分解为可组合的单元,例如Monads(或Functors或Applicatives)。 如您在最后一个示例中看到的,您还可以将Monad,Functor和Applicatives组合在一起。

如果它激发了您的胃口,您可能需要研究其他Monad,例如:

  • 国家单子
  • 延续单子
  • 输入/输出单子

-…还有许多其他。

如果您喜欢本系列,请查看我的下一个系列,在该系列中,我们将使用这些文章中介绍的功能思想来构建Dyno Dy:一个真实的库,可从Swift连接到Amazon的DynamoDB! https://medium.com/@JLHLonline/introducing-dyno-ac46e1c4de63

    Interesting Posts