Monad Menagerie
在上一篇文章中,我花了一些时间看一下Monads —数据结构提供了称为bind或flatMap (或>>>=
)的功能。 Monads为我们提供了一种强大的新功能组合方式。
然后,我花了一些时间来尝试了解Monad是什么。
最后,我们看到Optionals , Arrays和WebData类型都是Monad(并为它们定义了bind
)。
在本文中,我将介绍很多基础知识,看看一些我们在Swift中不经常看到的Monad(或至少不经常被视为Monad)的Monad示例: Readers , Writer和Futures。
所有示例都可以在Github上找到。 有一个游乐场可以尝试。 请注意,为了使本文中的代码示例简短,我漏掉了一些细节-您可以在Github中查看工作代码。
作家✍️
让我们开始与作家。 Writer可以将一个值与有关该值的一些信息打包在一起; 随着我们在后续计算步骤中继续处理该值,我们可以继续添加信息。
它通常用作记录进度或跟踪运行总计的一种方式。 在许多地方,您会使用一个突变状态(例如,一个inout参数,对全局日志记录功能的写入,甚至只是一个print语句),但是这些操作很难测试,并且在多线程环境中无法很好地工作。
Writer有两个类型参数:第一个是“登录”或“累积”的类型; 第二个是计算的当前值。 换句话说,我们有:
Writer
V
(计算值)可以是任何类型,但是A
(累加器)必须是称为Monoid的特殊类型。 Monoids¹只是带有“加法”运算符的类型,表示为和“零”或“空”值。 我不会在本文中介绍Monoid(请查看github代码以获取更多信息),但请注意
Array
和Int
都是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个类型参数A
和V
bind
如何工作? 有一个简单的技巧:我们“固定”一个类型参数(总是第一个),而只允许第二个参数在bind
函数中变化。 HKT通过DualConstructible
和FixedType
标签支持此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
表示从X
到Y
的函数。 我们这样定义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
并输入A
, bind
看起来像:
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
返回一个函数 ; 然后bind
从X → B
获得第三个函数。 综上所述, X
是我们要使用的上下文 。 函数X → A
和X → 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))
您可能想知道toneMapping
, scaleToFit
和performRender
函数的实际外观–它们如何返回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。 Promise
是Future
的子类。
这为发出网络请求提供了一个不错的界面:
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 >>>• f
与v.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