-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathCh18 Monad transformers.tex
698 lines (501 loc) · 31.6 KB
/
Ch18 Monad transformers.tex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
\documentclass[./main.tex]{subfiles}
\begin{document}
\subsection*{动机:避免样板}
单子提供了一种强大的方式来构建具有效果的计算。每个标准单子都专门做一件事。在实际代码中,我们经常需要能够一次使用几个效果。
回忆一下第十章开发的\acode{Parse}类型。在介绍单子的时候,我们提到了这个类型是一个伪装的状态单子。我们的单子比标准的\acode{State}单子更加的复杂,因为它使用了
\acode{Either}类型来容忍解析错误。这个例子中,如果一个解析提前出现了,我们是希望停止解析而不是继续带着破损的状态。我们的单子结合了携带状态的效果和提前退出的效果。
正常的\acode{State}单子不允许我们以这种方式逃离;它仅携带状态。它使用\acode{fail}的默认实现:即调用\acode{error},它跑出一个在纯代码中无法捕获的异常。因此,
\acode{State}单子允许失败,而这种能力实际上没有任何用处。(再次声明,我们建议尽量避免使用\acode{fail}!)
如果能够以某种方式采用标准的\acode{State}单子,并在其中添加错误处理,而不需要大量手工构建自定义单子,那将是最为理想的。\acode{mtl}库中的标准单子不允许我们组合
它们。相反,该库提供了一组\textit{单子转换}来实现相同的结果。
一个单子转换类似于普通的单子,但它不是一个独立的实体:相反,它修改底层单子的行为。\acode{mtl}库中的大多数单子都有等效的转换。根据惯例,单子转换的版本具有相同的名称,
末尾有一个\acode{T}。例如,与\acode{State}等价的转换就是\acode{StateT};它将可变状态添加到底层单子中。\acode{WriterT}单子转换使得在堆叠在另一个单子上时
写入数据成为可能。
\subsection*{简单单子转换案例}
在介绍单子转换之前,看一下用已经熟悉的技术编写的函数。下面的函数递归到一个目录树,并返回它在树的每级找到的条目数列表。
\begin{lstlisting}[language=Haskell]
module CountEntries
( listDirectory,
countEntriesTrad,
)
where
import Control.Monad (forM, liftM)
import System.Directory (doesDirectoryExist, getDirectoryContents)
import System.FilePath ((</>))
listDirectory :: FilePath -> IO [FilePath]
listDirectory = liftM (filter notDots) . getDirectoryContents
where
notDots p = p /= "." && p /= ".."
countEntriesTrad :: FilePath -> IO [(FilePath, Int)]
countEntriesTrad path = do
contents <- listDirectory path
rest <- forM contents $ \name -> do
let newName = path </> name
isDir <- doesDirectoryExist newName
if isDir
then countEntriesTrad newName
else return []
return $ (path, length contents) : concat rest
\end{lstlisting}
现在我们来看看如何使用 writer 单子来实现同样的目的。由于这个单子允许我们记录任何需要的值,因此不需要显式的构建结果。
因为我们的函数必须在\acode{IO}单子中执行,这样它才能遍历目录,所以不能直接使用\acode{Writer}单子。相反,我们使用\acode{WriterT}向\acode{IO}添加记录功能。
普通的\acode{Writer}单子有两个类型参数,因此这里更为合适的写法是\acode{Writer w a}。第一个参数\acode{w}是要记录的值的类型,而\acode{a}则是\acode{Monad}
typeclass 通常所需要的类型。因此\acode{Writer [(FilePath, Int)] a}是一个记录目录名称和大小列表的 writer 单子。
\acode{WriterT}转换拥有类似的结构,不过它添加了另一个类型参数\acode{m}:这正是增强其行为的底层单子。\acode{WriterT}的完整签名为\acode{WriterT w m a}。
因为要遍历目录,这需要访问\acode{IO}单子,所以将 Writer 堆栈在\acode{IO}单子的顶部。单子转换和底层单子的组合将具有\acode{WriterT [(FilePath, Int)] IO a}
类型。这个单子转换和单子的堆栈本身就是一个单子。
\begin{lstlisting}[language=Haskell]
module CountEntriesT
( countEntries,
)
where
import Control.Monad
import Control.Monad.Trans
import Control.Monad.Writer
import CountEntries (listDirectory)
import System.Directory (doesDirectoryExist)
import System.FilePath ((</>))
countEntries :: FilePath -> WriterT [(FilePath, Int)] IO ()
countEntries path = do
contents <- liftIO . listDirectory $ path
tell [(path, length contents)]
forM_ contents $ \name -> do
let newName = path </> name
isDir <- liftIO . doesDirectoryExist $ newName
when isDir $ countEntries newName
\end{lstlisting}
这段代码与之前的版本没有太大的不同。我们使用\acode{liftIO}在必要的地方公开\acode{IO}单子,并使用\acode{tell}来记录对目录的访问。
要使代码能被运行还必须使用\acode{WriterT}的一个执行函数:
\begin{lstlisting}[language=Haskell]
ghci> :t runWriterT
runWriterT :: WriterT w m a -> m (a, w)
ghci> :t execWriterT
execWriterT :: Monad m => WriterT w m a -> m w
\end{lstlisting}
这些函数执行操作,移除\acode{WriteT}包装并给出一个封装在底层单子中的结果。\acode{runWriterT}函数既提供操作的结果也提供运行时记录的内容,而\acode{execWriteT}
会丢弃结果,值提供记录的内容。
\begin{lstlisting}[language=Haskell]
ghci> :l CountEntriesT.hs
[1 of 2] Compiling CountEntries ( CountEntries.hs, interpreted )
[2 of 2] Compiling CountEntriesT ( CountEntriesT.hs, interpreted )
Ok, two modules loaded.
ghci> :t countEntries ".."
countEntries ".." :: WriterT [(FilePath, Int)] IO ()
ghci> :t execWriterT (countEntries "..")
execWriterT (countEntries "..") :: IO [(FilePath, Int)]
ghci> take 4 `liftM` execWriterT (countEntries "..")
[("..",6),("../dist-newstyle",2),("../dist-newstyle/cache",8),("../dist-newstyle/tmp",0)]
\end{lstlisting}
我们在\acode{IO}上使用\acode{WriterT},因为没有\acode{IOT}单子转换。每当我们使用一个或多个单子转换的\acode{IO}单子时,\acode{IO}总是在堆栈的底部。
\subsection*{单子和单子转换中的常见模式}
\acode{mtl}库中大多数的单子和单子转换都遵循着一些关于名称和 typeclasses 的通用模式。
为了解释这些规则,我们将专注于一个单子:reader 单子。reader 的 API 由\acode{MonadReader} typeclass 详细说明。大多数\acode{mtl}单子都有着类似命名的
typeclasses:\acode{MonadWriter}定义了 writer 单子,以此类推。
\begin{lstlisting}[language=Haskell]
class (Monad m) => MonadReader r m | m -> r where
ask :: m r
local :: (r -> r) -> m a -> m a
\end{lstlisting}
类型变量\acode{r}表示 reader 单子所携带的不可变状态。\acode{Reader r}单子是\acode{MonadReader}类的一个实例,即\acode{ReaderT r m}单子转换。这个模式同样
也被其他\acode{mtl}单子所用:通常存在实体单子以及一个单子转换,它们每个都是用来定义单子 API 的 typeclass 实例。
回到 reader 单子,我们尚未接触到\acode{local}函数。它通过\acode{r -> r}函数临时改变当前环境,同时在修改后的环境中执行其操作。为了更好的理解,以下是一个简单的
示例:
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Reader
myName :: (MonadReader String m) => String -> m String
myName step = do
name <- ask
return (step ++ ", I am " ++ name)
localExample :: Reader String (String, String, String)
localExample = do
a <- myName "First"
b <- local (++ "dy") (myName "Second")
c <- myName "Third"
return (a, b, c)
\end{lstlisting}
注:需要添加\acode{FlexibleContexts}扩展,否则\acode{myName}无法编译。
在\textbf{ghci}中执行\acode{localExample}操作时,我们可以看到改变环境的效果局限于一处:
\begin{lstlisting}[language=Haskell]
ghci> :l LocalReader.hs
[1 of 2] Compiling Main ( LocalReader.hs, interpreted )
Ok, one module loaded.
ghci> runReader localExample "Fred"
("First, I am Fred","Second, I am Freddy","Third, I am Fred")
\end{lstlisting}
当底层单子\acode{m}是\acode{MonadIO}的一个实例时,\acode{mtl}库提供了\acode{ReaderT r m}的实例,以及其它的一些 typeclasses。例如:
\begin{lstlisting}[language=Haskell]
instance (Monad m) => Functor (ReaderT r m) where
...
instance (MonadIO m) => MonadIO (ReaderT r m) where
...
instance (MonadPlus m) => MonadPlus (ReaderT r m) where
...
\end{lstlisting}
再次声明,大多数\acode{mtl}单子转换都定义了这样的实例,以便我们更容易的使用它们。
\subsection*{堆叠多个单子转换}
正如之前提到的,当我们将单子转换堆叠在普通单子上时,结果便是另一个单子。这表明我们可以再次将单子转换堆叠在合并的单子之上,以提供新的单子,实际上这是一件很常见的事。
在什么情况下可能需要创建这样一个堆栈?
\begin{enumerate}
\item 如果需要与外界沟通,我们将在堆栈的基础上使用\acode{IO};否则得到的是一些正常的单子。
\item 如果添加了一层\acode{ReaderT},我们将获得对配置信息只读的权限。
\item 添加了一层\acode{StateT},将会得到可控修改的全局状态。
\item 当需要将事件输出到日志时,添加一层\acode{WriterT}。
\end{enumerate}
这个方法的强大之处在于,我们可以根据确切需求定制堆栈,指定我们想要支持的效果类型。
下面是一个堆叠单子转换操作的例子,这里是之前开发的\acode{countEntries}函数。我们将修改它,使其递归到目录树的深度不超过给定的量,并记录它达到的最大深度。
\begin{lstlisting}[language=Haskell]
import Control.Monad.Reader
import Control.Monad.State
import System.Directory
import System.FilePath
data AppConfig = AppConfig
{ cfgMaxDepth :: Int
}
deriving (Show)
data AppState = AppState
{ stDeepestReached :: Int
}
deriving (Show)
\end{lstlisting}
我们使用\acode{ReaderT}来存储配置数据,即将要实现的最大的递归深度。同样使用\acode{StateT}来记录递归过程时所达到的最大深度。
\begin{lstlisting}[language=Haskell]
type App = ReaderT AppConfig (StateT AppState IO)
\end{lstlisting}
我们的转换堆叠由\acode{IO}打底,接着是\acode{StateT},最后是\acode{ReaderT}在最上层。这种情况下无论是\acode{ReaderT}还是\acode{WriterT}在上层都没有区别,
但是\acode{IO}必须在最底层。
即使是一小堆单子转换也会很快产生一个笨拙的类型名称。我们可以使用类型别名来减少编写类型签名的长度。
\begin{anote}
缺失的类型参数去哪里了?
你可能已经注意到了\acode{type}别名并没有通常的类型参数\acode{a}用于 monadic 类型:
\begin{lstlisting}[language=Haskell]
type App2 a = ReaderT AppConfig (StateT AppState IO) a
\end{lstlisting}
\acode{App}与\acode{App2}两者作为普通的类型签名都是能正常工作。差别出现在当我们尝试使用它们去构建另一个类型时:假设我们希望添加另一个单子转换到堆栈上:编译器将
允许\acode{WriterT [String] App a},但拒绝\acode{WriterT [String] App2 a}。
这是因为 Haskell 并不允许我们偏应用一个类型别名。\acode{App}的别名并不带有一个类型参数,这就不会造成问题。但是由于\acode{App2}接受一个类型参数,那么当我们希望
使用\acode{App2}来创建另一个类型时,我们就必须为此提供某些类型用于类型参数。
这个约束仅局限于类型别名。当创建一个单子转换堆栈是,我们通常会通过\acode{newtype}(将在后面见到)来包装。这样的话,我们就杜绝了此类型的问题。
\end{anote}
单子堆栈的执行函数很简单:
\begin{lstlisting}[language=Haskell]
runApp :: App a -> Int -> IO (a, AppState)
runApp k maxDepth =
let cfg = AppConfig maxDepth
stt = AppState 0
in runStateT (runReaderT k cfg) stt
\end{lstlisting}
应用中的\acode{runReaderT}移除了\acode{ReaderT}转换的包装,而\acode{runStateT}移除了\acode{StateT}的包装,最后将结果放置到\acode{IO}单子上。
\begin{lstlisting}[language=Haskell]
constrainedCount :: Int -> FilePath -> App [(FilePath, Int)]
constrainedCount curDepth path = do
contents <- liftIO . listDirectory $ path
cfg <- ask
rest <- forM contents $ \name -> do
let newPath = path </> name
isDir <- liftIO $ doesDirectoryExist newPath
if isDir && curDepth < cfgMaxDepth cfg
then do
let newDepth = curDepth + 1
st <- get
when (stDeepestReached st < newDepth) $ put st {stDeepestReached = newDepth}
constrainedCount newDepth newPath
else return []
return $ (path, length contents) : concat rest
\end{lstlisting}
我们可以在单子堆栈中编写应用程序的大部分命令式代码,类似于我们的\acode{App}单子。在实际的程序中,我们会携带更复杂的配置数据,但是我们仍然会使用\acode{ReaderT}来
使其保持只读和需要时的隐藏。我们将有更多的可变状态需要管理,但我们仍然使用\acode{StateT}来封装它。
\subsubsection*{隐藏工序}
我们可以使用\acode{newtype}技巧在自定义单子的实现和它的接口之间建立一个坚实的屏障。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype MyApp a = MyA
{ runA :: ReaderT AppConfig (StateT AppState IO) a
}
deriving
( Functor,
Applicative,
Monad,
MonadIO,
MonadReader AppConfig,
MonadState AppState
)
runMyApp :: MyApp a -> Int -> IO (a, AppState)
runMyApp k maxDepth =
let cfg = AppConfig maxDepth
stt = AppState 0
in runStateT (runReaderT (runA k) cfg) stt
\end{lstlisting}
如果我们从模块中导出\acode{MyApp}类型构造函数与\acode{runMyApp}执行函数,客户端代码将无法判断单子的内部是一对单子转换。
这里的\acode{deriving}需要\acode{GeneralizedNewtypeDeriving}语言扩展。它以某种方式让编译器为我们派生出了这些实例。那么这时如何工作的呢?
前面我们提到\acode{mtl}库为每个单子转换都提供了许多 typeclass 的实例。例如\acode{IO}单子实现了\acode{MonadIO}。如果底层单子是\acode{MonadIO}的一个实例,
\acode{mtl}也会使\acode{StateT}成为一个实例,同理\acode{ReaderT}也是这样。
因此这里并没有什么魔法:堆栈中的顶层单子转换是我们用派生子句重新派生的所有 typeclass 的实例。这是\acode{mtl}提供了一组 typeclass 和实例的结果,它们很好的结合
在了一起。除了可以用\acode{newtype}声明执行的通常的自动派生之外,我们无需做其它任何操作。
\subsection*{向下移动堆栈}
目前为止,我们对单子转换的使用还很简单,并且\acode{mtl}库让我们能够避免构造单子堆栈的细节。实际上我们已经对单子转换有了足够的了解,可以简化很多常见的编程任务。
有一些有用的方法可以让我们脱离\acode{mtl}带来的舒适。大多数情况下,自定义单子位于堆栈底层,或者自定义单子转换位于堆栈中某层。为了理解这里潜在的困难,让我们看一个例子。
假设我们有个自定义的单子转换,\acode{CustomT}
\begin{lstlisting}[language=Haskell]
newtype CustomT m a = ...
\end{lstlisting}
\acode{mtl}提供的框架中,堆栈中的每个单子转换通过提供一些列 typeclass 的实例来使较低层级的 API 可用。我们可以遵循这个模式,并编写一些样板实例。
\begin{lstlisting}[language=Haskell]
instance MonadReader r m => MonadReader r (CustomT m) where
...
instance MonadIO r m => MonadIO r (CustomT m) where
...
\end{lstlisting}
如果底层单子是\acode{MonadReader}的一个实例,我们将为\acode{CustomT}编写一个\acode{MonadReader}实例,其中 API 中的每个函数将传递给底层实例中的相应函数。
这将允许更高层的代码只关心整个堆栈是\acode{MonadReader}的一个实例,而不必知道或关心哪一层提供了真正的实现。
我们可以显式的实现,而不依赖所有这些 typeclass 实例在幕后为我们工作。\acode{MonadTrans} typeclass 定义了一个名为\acode{lift}的有用函数。
\begin{lstlisting}[language=Haskell]
ghci> :m +Control.Monad.Trans
ghci> :i MonadTrans
type MonadTrans :: ((* -> *) -> * -> *) -> Constraint
class (forall (m :: * -> *). Monad m => Monad (t m)) =>
MonadTrans t where
lift :: Monad m => m a -> t m a
{-# MINIMAL lift #-}
-- Defined in ‘transformers-0.6.1.0:Control.Monad.Trans.Class’
\end{lstlisting}
该函数从堆栈的一层接受单元操作,并将其转换为当前单子转换中的操作,换言之,将\acode{lift}为单子转换中的操作。每个单子转换都是\acode{MonadTrans}的一个实例。
基于\acode{fmap}和\acode{liftM}的用途相似性,我们使用\acode{lift}这个名称。在每种情况下,我们都将一些东西从类型系统的较低层级提升到当前工作的层级。
\begin{enumerate}
\item \acode{fmap}将纯函数提升到函子级别;
\item \acode{liftM}将纯函数提升到单子级别;
\item \acode{lift}将一个 monadic 操作从低层级的堆栈转换提升到当前层级。
\end{enumerate}
让我们回到之前定义的\acode{App}单子堆栈:
\begin{lstlisting}[language=Haskell]
type App = ReaderT AppConfig (StateT AppState IO)
\end{lstlisting}
如果想要访问由\acode{StateT}携带的\acode{AppState},我们通常需要依靠\acode{mtl}所提供的 typeclass 以及实例。
\begin{lstlisting}[language=Haskell]
implicitGet :: App AppState
implicitGet = get
\end{lstlisting}
\acode{lift}函数拥有同样的效果,通过\acode{get}将\acode{StateT}提升至\acode{ReaderT}。
\begin{lstlisting}[language=Haskell]
explicitGet :: App AppState
explicitGet = lift get
\end{lstlisting}
显然,当可以使用\acode{mtl}时,我们可以拥有更整洁的代码。
\subsubsection*{当需要显式 lifting 时}
我们必须使用\acode{lift}的一种情况是,当我们创建一个单子转换堆栈时,同一个 typeclass 的实例出现在多个层级上。
\begin{lstlisting}[language=Haskell]
type Foo = StateT Int (State String)
\end{lstlisting}
如果我们尝试使用\acode{MonadState} typeclass 的\acode{put}操作,我们将获得实例\acode{StateT Int},因为它位于堆栈的顶层。
\begin{lstlisting}[language=Haskell]
outerPut :: Int -> Foo ()
outerPut = put
\end{lstlisting}
这种情况下,访问底层\acode{State}单子的\acode{put}方法的仅有方式是通过\acode{lift}。
\begin{lstlisting}[language=Haskell]
innerPut :: String -> Foo ()
innerPut = lift . put
\end{lstlisting}
有时我们需要访问堆栈下面不止一层的单子,这种情况下,我们必须组合调用\acode{lift}。每个组合的\acode{lift}提供更深一层的访问。
\begin{lstlisting}[language=Haskell]
type Bar = ReaderT Bool Foo
barPut :: String -> Bar ()
barPut = lift . lift . put
\end{lstlisting}
当我们需要使用\acode{lift}时,像上面那样编写包装器函数为执行提升并使用这些函数是一种很好的风格。在整个代码中显式的使用\acode{lift}往往看起来很混乱。更糟糕的是,
它将单子堆栈的布局细节硬连到了代码中,这会是的后续的修改变得复杂。
\subsection*{通过构建一个单子转换来了解它}
为了让我们深入了解单子转换的工作原理,我们将创建一个单子转换,并在此过程中描述其机制。我们的目标简单有用。令人惊讶的,它居然没有在\acode{mtl}库中:\acode{MaybeT}。
这个单子转换通过\acode{Maybe}包装其类型参数修改底层单子的行为,以给出\acode{m (Maybe a)}。与\acode{Maybe}单子一样,如果在\acode{MaybeT}单子转换中调用
\acode{fail},执行将提前终止。
为了将\acode{m (Maybe a)}变为一个\acode{Monad}实例,我们必须通过\acode{newtype}声明将其变为独立类型。
\begin{lstlisting}[language=Haskell]
newtype MaybeT m a = MaybeT
{ runMaybeT :: m (Maybe a)
}
\end{lstlisting}
我们现在需要定义三个标准单子函数。最复杂的就是\acode{(>>=)},它的内部结构最能说明实际在做什么。在深入研究它之前,让我们先看一下它的类型:
\begin{lstlisting}[language=Haskell]
bindMT :: (Monad m) => MaybeT m a -> (a -> MaybeT m b) -> MaybeT m b
\end{lstlisting}
为了理解这个类型签名,回忆一下之前的多参数 typeclasses 讨论。我们意图构建的一个\acode{Monad}实例是\textit{偏类型}的\acode{MaybtT m}:它带有单类型参数,
\acode{a},满足\acode{Monad} typeclass 的要求。
理解\acode{(>>=)}实现的技巧是,\acode{do}块中的所有内容都在\textit{底层}单子\acode{m}中执行,无论它是什么。
\begin{lstlisting}[language=Haskell]
x `bindMT` f = MaybeT $ do
unwrapped <- runMaybeT x
case unwrapped of
Nothing -> return Nothing
Just y -> runMaybeT (f y)
\end{lstlisting}
我们的\acode{runMaybeT}函数解包了包含在\acode{x}中的结果。接着,\acode{<-}符号脱糖为\acode{(>>=)}:一个单子转换的\acode{(>>=)}必须使用底层单子的
\acode{(>>=)}。接着则是判断是否要短路或是串联计算。最后看一下函数体的最上方:这里我们必须通过\acode{MaybeT}的构造函数来包装结果,为的就是再一次隐藏底层单子。
上述代码的\acode{do}声明便于阅读,但是它隐藏了依赖底层单子的\acode{(>>=)}实现这个事实。以下是\acode{MaybeT}的一个更理想的\acode{(>>=)}版本,它令逻辑更为清晰。
\begin{lstlisting}[language=Haskell]
altBindMT :: (Monad m) => MaybeT m a -> (a -> MaybeT m b) -> MaybeT m b
x `altBindMT` f =
MaybeT $ runMaybeT x >>= maybe (return Nothing) (runMaybeT . f)
\end{lstlisting}
现在我们理解了\acode{(>>=)}是做什么的了,那么\acode{return}与\acode{fail}的实现则无需解释了,\acode{Monad}实例亦是如此。
\begin{lstlisting}[language=Haskell]
instance (Functor f) => Functor (MaybeT f) where
fmap f x = MaybeT $ fmap f <$> runMaybeT x
instance (Applicative a) => Applicative (MaybeT a) where
pure = MaybeT . pure . Just
f <*> x = MaybeT $ fmap (<*>) (runMaybeT f) <*> runMaybeT x
instance (Monad m) => Monad (MaybeT m) where
(>>=) = bindMT
instance (MonadFail m) => MonadFail (MaybeT m) where
fail _ = MaybeT $ return Nothing
\end{lstlisting}
\subsubsection*{创建一个单子转换}
为了将类型转为一个单子转换,我们必须提供\acode{MonadTrans}类的实例,这样用户才能访问底层单子:
\begin{lstlisting}[language=Haskell]
import Control.Monad
import Control.Monad.Trans
instance MonadTrans MaybeT where
lift m = MaybeT $ Just `liftM` m
\end{lstlisting}
底层单子从\acode{a}的类型参数开始:我们“注入”\acode{Just}构造函数,这样它就会获得我们需要的类型\acode{Maybe a}。然后我们用\acode{MaybeT}构造函数隐藏单子。
\subsubsection*{更多的 typeclass 实例}
一旦我们定义了\acode{MonadTrans}的实例,我们就可以用它来定义无数其他\acode{mtl} typeclass 的实例。
\begin{lstlisting}[language=Haskell]
instance (MonadIO m) => MonadIO (MaybeT m) where
liftIO = lift . liftIO
instance (MonadState s m) => MonadState s (MaybeT m) where
get = lift get
put = lift . put
\end{lstlisting}
由于有几个\acode{mtl} typeclasses 使用函数依赖,所有一些实例声明要求我们放宽 GHC 严格的检查规则。(如果我们忘记了这些指令中的任何一个,编译器会在它的错误消息中
公司我们需要添加哪些指令。)
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE UndecidableInstances #-}
\end{lstlisting}
是显式的使用\acode{lift}更好,还是花时间编写这些样板实例更好?这取决于我们期望用单子转换做什么。如果只在一些受限的情况下使用它,我们可以单独为\acode{MonadTrans}
提供一个实例。这种情况下,多几个实例可能仍然有意义,比如\acode{MonadIO}。另一方面,如果单子转换将在整个代码中不同的情况下出现,那么花上数小时来编写这些实例可能是
一项很好的投资。
\subsubsection*{将 Parse 类型替换为单子堆栈}
现在我们需要开发一个单子转换用于短路退出,例如如果一个解析中途失败了,可以使用它来退出。这样就可以替换掉之前名为“隐式状态”小节中的自定义单子了。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module MaybeTParse
( Parse,
evalParse,
)
where
import Control.Monad.State
import qualified Data.ByteString.Lazy as L
import Data.Int (Int64)
import MaybeT
data ParseState = ParseState
{ string :: L.ByteString,
offset :: Int64
}
deriving (Show)
newtype Parse a = P
{ runP :: MaybeT (State ParseState) a
}
deriving (Functor, Applicative, Monad, MonadState ParseState)
evalParse :: Parse a -> L.ByteString -> Maybe a
evalParse m s = evalState (runMaybeT $ runP m) (ParseState s 0)
\end{lstlisting}
\subsection*{单子转换的堆栈顺序很重要}
从前面使用\acode{ReaderT}与\acode{StateT}等单子转换的例子中,很容易得出这样的结论:单子转换的堆栈顺序无关紧要。
当我们将\acode{StateT}堆叠到\acode{State}上时,我们很清楚的就能知道顺序是有影响的。类型\acode{StateT Int (State String)}与
\acode{StateT String (State Int)}可能携带了相同的信息,但是我们并不能替换的使用他们。顺序决定了我们什么时候需要\acode{lift}来获取一个或是另一个状态。
下面这个例子更生动的说明了顺序的重要性。假设我们有一个可能失败的计算,且想要记录它失败的情况。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Writer
import MaybeT
problem :: (MonadWriter [String] m, MonadFail m) => m ()
problem = do
tell ["this is where i fail"]
fail "oops"
\end{lstlisting}
下面这些单子堆栈的哪个是我们需要的?
\begin{lstlisting}[language=Haskell]
{-# OPTIONS_GHC -Wno-orphans #-}
type A = WriterT [String] Maybe
type B = MaybeT (Writer [String])
a :: A ()
a = problem
instance MonadFail Identity where
fail _ = Identity $ error "oops"
b :: B ()
b = problem
\end{lstlisting}
注:由于\acode{fail}函数不再属于\acode{Monad}的一部分(属于\acode{MonadFail})因此还需要独立为\acode{Identity}实现\acode{MonadFail}实例,另外还需要表头
\acode{\{-# OPTIONS_GHC -Wno-orphans #-\}}。
\textbf{ghci}:
\begin{lstlisting}[language=Haskell]
ghci> :l MTComposition.hs
[1 of 3] Compiling MaybeT ( MaybeT.hs, interpreted )
[2 of 3] Compiling Main ( MTComposition.hs, interpreted )
Ok, two modules loaded.
ghci> runWriterT a
Nothing
ghci> runWriterT $ runMaybeT b
Identity (Nothing,["this is where i fail"])
\end{lstlisting}
结果的差异并不令人惊讶:只需要看看执行函数的签名就知道了:
\begin{lstlisting}[language=Haskell]
ghci> :t runWriterT
runWriterT :: WriterT w m a -> m (a, w)
ghci> :t runWriter . runMaybeT
runWriter . runMaybeT
:: MaybeT (WriterT w Identity) a -> (Maybe a, w)
\end{lstlisting}
我们的\acode{WriterT}叠加\acode{Maybe}的堆栈是由\acode{Maybe}作为底层单子,因此\acode{runWriterT}必须返回一个\acode{Maybe}类型。我们的测试用例中,只有在
没有实际出错的情况下才能看到日志!
堆叠单子转换类似于组合函数。如果我们改变应用函数的顺序,然后得到不同的结果,这并不会让人惊讶,而单子转换亦是如此。
\subsection*{透视单子和单子转换}
现在暂时抛开细节,看看使用单子和单子转换进行编程的优缺点。
\subsubsection*{对于纯代码的干扰}
使用单子最大的实际问题可能是,当我们想用纯代码时,单子的类型构造函数经常会妨碍我们。许多有用的纯函数需要 monadic 对应,只是为了给一些 monadic 类型构造函数附加一个
占位符参数\acode{m}。
\begin{lstlisting}[language=Haskell]
ghci> :t filter
filter :: (a -> Bool) -> [a] -> [a]
ghci> import Control.Monad
ghci> :i filterM
filterM :: Applicative m => (a -> m Bool) -> [a] -> m [a]
-- Defined in ‘Control.Monad’
\end{lstlisting}
然而这覆盖范围并不完整:标准库并不总是提供 monadic 版本的纯函数。
略。
\subsubsection*{超出定义的顺序}
我们使用单子的一个主要原因是它允许我们指定效果的顺序。回顾一下早前写的代码:
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Writer
import MaybeT
problem :: (MonadWriter [String] m, MonadFail m) => m ()
problem = do
tell ["this is where i fail"]
fail "oops"
\end{lstlisting}
因为我们是在单子中执行的,所以可以保证\acode{tell}的效果在\acode{fail}之前发生。问题是即使在不一定需要它的时候,我们也得到了这种排序保证:编译器不能自由的重新
排列 monadic 代码,即使这样做会提高效率。
\subsubsection*{运行时开支}
最后,当我们使用单子和单子转换时,我们得支付效率税。例如\acode{State}单子在闭包中携带其状态。Haskell 中的闭包可能是廉价的,但是它并不是无开销的。
单子转换将自己的开销添加到下面的任何开销中。\acode{MaybeT}转换必须在每次使用\acode{(>>=)}时包装并解包\acode{Maybe}值。因此\acode{StateT}之上的
\acode{MaybeT}堆栈对于每个\acode{(>>=)}都有大量的工作需要做。
一个足够聪明的编译器可能会使这些成本部分或全部消失,但是这种复杂程度还没有广泛应用。
有一些相对简单的技术可以避免这些成本。例如通过使用延续单子,可以避免在\acode{(>>=)}中进行不断的包装和解包,仅仅在使用时才支付开销。这种方法的许多复杂性已经被打包在
库中。
\subsubsection*{笨拙的接口}
如果我们将\acode{mtl}库当做黑盒使用,那么它的所有组件都可以很好的啮合在一起。然而,当我们开始开发自己的单子和单子转换时,并将它们与\acode{mtl}提供的单子转换一起
使用时,就会发现一些不足之处。
例如,如果我们创建了一个新的单子转换\acode{FooT},并遵循\acode{mtl}相同的模式,那么则需要实现 typeclass \acode{MonadFoo}。如果还想要集成\acode{mtl},那么
还需要提供一大堆\acode{mtl} typeclasses 的实例。
除此之外,我们还必须为每个\acode{mtl}单子转换声明\acode{MonadFoo}实例。这些实例中大多数几乎是相同的,写起来相当的枯燥。如果想继续将新的单子转换集成到\acode{mtl}
框架中,我们必须处理的组件的数量则随着新单子转换的\textit{平方}而增加!
略。
\subsubsection*{整合一下}
在处理副作用和类型时,单子绝不是终点。它们是迄今为止我们所达到的最实际的休息点。
尽管我们在使用它时必须做出妥协,但单子和单子转换仍然提供了一定程度的灵活性和控制,这在命令式语言中是没有先例的。只需几个声明,我们就可以重新连接像分号这样基本的东西,
并赋予它新的含义。
\end{document}