-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathCh15 Programming with monads.tex
971 lines (705 loc) · 41.8 KB
/
Ch15 Programming with monads.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
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
\documentclass[./main.tex]{subfiles}
\begin{document}
\subsection*{练习:关联列表}
Web 客户端与服务端之间经常通过简单的键值对列表进行信息传输。
\begin{lstlisting}
name=Attila+%42The+Hun%42&occupation=Khan
\end{lstlisting}
这里的编码名为\acode{application/x-www-form-urlencoded},同时非常便于理解。每个键值对都被一个“\&”符号分隔。在一个兼职对中,键为“=”符号之前的所有字符,而值为
之后的所有字符。
显然可以将一个\acode{String}作为键,但是 HTTP 并不清楚该键是否必须跟着一值。可以通过\acode{Maybe String}来表示一个模糊的值。如果值为\acode{Nothing},即无值
可展示。当使用\acode{Just}包裹一个值时则以为着有值。使用\acode{Maybe}让我们可以区分“无值”与“空值”。
Haskell 程序员使用类型为\acode{[(a, b)]}的\textit{关联列表},可以视作关联列表中的每个元素都是键与值的关联。
假设我们想用这些列表中的一个来填充一个数据结构。
\begin{lstlisting}[language=Haskell]
data MovieReview = MovieReview
{ revTitle :: String,
revUser :: String,
revReview :: String
}
\end{lstlisting}
从一个朴素的函数开始:
\begin{lstlisting}[language=Haskell]
simpleReview :: [(String, Maybe String)] -> Maybe MovieReview
simpleReview alist =
case lookup "title" alist of
Just (Just title@(_ : _)) ->
case lookup "user" alist of
Just (Just user@(_ : _)) ->
case lookup "review" alist of
Just (Just review@(_ : _)) ->
Just (MovieReview title user review)
_ -> Nothing -- no review
_ -> Nothing -- no user
_ -> Nothing -- no title
\end{lstlisting}
当关联列表包含了所有必要值且不为空值时,它将返回一个\acode{MovieReview}。
我们对\acode{Maybe}单子已经很熟悉了,因此可以简化一下上述的阶梯式代码:
\begin{lstlisting}[language=Haskell]
maybeReview :: [(String, Maybe [Char])] -> Maybe MovieReview
maybeReview alist = do
title <- lookup1 "title" alist
user <- lookup1 "user" alist
review <- lookup1 "review" alist
return $ MovieReview title user review
lookup1 :: Eq a1 => a1 -> [(a1, Maybe [a2])] -> Maybe [a2]
lookup1 key alist =
case lookup key alist of
Just (Just s@(_ : _)) -> Just s
_ -> Nothing
\end{lstlisting}
尽管这看起来简洁多了,但仍然在重复自身。我们可以利用\acode{MoviewReview}构造函数作为一个普通的纯函数,通过\textit{lifting}它至单子:
\begin{lstlisting}[language=Haskell]
liftedReview :: [(String, Maybe [Char])] -> Maybe MovieReview
liftedReview alist =
liftM3
MovieReview
(lookup1 "title" alist)
(lookup1 "user" alist)
(lookup1 "review" alist)
\end{lstlisting}
这里仍然有一些重复,不过更难简化。
\subsection*{泛化的 lifting}
尽管使用\acode{liftM3}得以简化代码,但是我们无法使用 liftM 家族的函数来解决更泛化的问题,因为标准库仅定义到了\acode{liftM5}。虽然可以自定义此类型的变体函数,
但是这个数量仍然是一个问题。
假设一个构造函数或者纯函数接收十个参数,并决定坚持使用标准库,这可能就不太合适了。
在\acode{Control.Monad}中,有一个名为\acode{ap}的函数拥有着有趣的类型签名。
\begin{lstlisting}[language=Haskell]
ghci> :m +Control.Monad
ghci> :type ap
ap :: (Monad m) => m (a -> b) -> m a -> m b
\end{lstlisting}
我们可能会疑惑谁会将单参数纯函数放在单子中,且为什么。不过回想一下\textit{所有的} Haskell 函数实际上只接受一个参数,这里开始将看到其与\acode{MovieReview}构造函数
的管理。
\begin{lstlisting}[language=Haskell]
ghci> :type MovieReview
MovieReview :: String -> String -> String -> MovieReview
\end{lstlisting}
我们当然可以简单的将类型写作\acode{String -> (String -> (String -> MovieReview))}。如果使用旧的\acode{liftM}将\acode{MovieReview}提升至\acode{Maybe}
单子,那么我们将会得到类型为\acode{Maybe (String -> (String -> (String -> MovieReview)))}的值。现在可以看出来该类型适用于单个参数的\acode{ap}。我们可以
一次将这个传递给\acode{ap},并继续链式执行,直到得到该定义。
\begin{lstlisting}[language=Haskell]
apReview :: [(String, Maybe [Char])] -> Maybe MovieReview
apReview alist =
MovieReview
`liftM` lookup1 "title" alist
`ap` lookup1 "user" alist
`ap` lookup1 "review" alist
\end{lstlisting}
注:以下为步骤拆解后的类型变化。
\begin{lstlisting}[language=Haskell]
MovieReview :: String -> ( String -> String -> MovieReview )
MovieReview `liftM` :: Maybe String -> Maybe ( String -> String -> MovieReview )
MovieReview `liftM` lookup1 "title" alist :: Maybe ( String -> String -> MovieReview )
MovieReview `liftM` lookup1 "title" alist `ap` :: Maybe String -> Maybe ( String -> MovieReview )
MovieReview `liftM` lookup1 "title" alist `ap` lookup1 "user" alist :: Maybe ( String -> MovieReview )
\end{lstlisting}
我们可以像这样把\acode{ap}的应用链接起来,只要有需要就可以多次链接,从而绕过\acode{liftM}系列函数。
看待\acode{ap}的另一种有用的方式是,它是我们熟悉的\acode{(\$)}操作符的一元等价物:可以把\acode{ap}读作\textit{apply}。当比较两者的函数签名时可知:
\begin{lstlisting}[language=Haskell]
ghci> :type ($)
($) :: (a -> b) -> a -> b
ghci> :type ap
ap :: (Monad m) => m (a -> b) -> m a -> m b
\end{lstlisting}
实际上,\acode{ap}通常被定义为\acode{liftM2 id}或是\acode{liftM2 (\$)}。
\subsection*{寻找其它方案}
以下是某人的电话号码:
\begin{lstlisting}[language=Haskell]
data Context = Home | Mobile | Business deriving (Eq, Show)
type Phone = String
albulena :: [(Context, String)]
albulena = [(Home, "+355-652-55512")]
nils :: [(Context, String)]
nils =
[ (Mobile, "+47-922-55-512"),
(Business, "+47-922-12-121"),
(Home, "+47-925-55-121"),
(Business, "+47-922-25-551")
]
twalumba :: [(Context, String)]
twalumba = [(Business, "+260-02-55-5121")]
\end{lstlisting}
假设我们想要通过打电话来联系某人。我们不希望通过商务号码,更倾向于使用家庭号码(如果存在的话)而不是移动电话。
\begin{lstlisting}[language=Haskell]
onePersonalPhone :: [(Context, Phone)] -> Maybe Phone
onePersonalPhone ps =
case lookup Home ps of
Nothing -> lookup Mobile ps
Just n -> Just n
\end{lstlisting}
当然我们可以使用\acode{Maybe}作为返回类型,我们无法考虑到某人可能拥有多个号码的可能性。因此我们需要一个列表:
\begin{lstlisting}[language=Haskell]
allBusinessPhones :: [(Context, Phone)] -> [Phone]
allBusinessPhones ps = map snd numbers
where
numbers =
case filter (contextIs Business) ps of
[] -> filter (contextIs Mobile) ps
ns -> ns
contextIs :: Eq a => a -> (a, b) -> Bool
contextIs a (b, _) = a == b
\end{lstlisting}
注意这两个函数的\acode{case}表达式结构类似:一个替代方法处理第一次查找返回空值,而另一个方法处理非空情况。
\begin{lstlisting}[language=Haskell]
ghci> onePersonalPhone twalumba
Nothing
ghci> onePersonalPhone albulena
Just "+355-652-55512"
ghci> allBusinessPhones nils
["+47-922-12-121","+47-922-25-551"]
\end{lstlisting}
Haskell 的\acode{Control.Monad}模块定义了一个 typeclass,\acode{MonadPlus},它让我们可以出\acode{case}表达式中抽象出公共模式。
\begin{lstlisting}[language=Haskell]
ghci> import Control.Monad
ghci> :i MonadPlus
type MonadPlus :: (* -> *) -> Constraint
class (GHC.Base.Alternative m, Monad m) => MonadPlus m where
mzero :: m a
mplus :: m a -> m a -> m a
-- Defined in ‘GHC.Base’
instance MonadPlus IO -- Defined in ‘GHC.Base’
instance MonadPlus [] -- Defined in ‘GHC.Base’
instance MonadPlus Maybe -- Defined in ‘GHC.Base’
\end{lstlisting}
\acode{mzero}代表一个空值,而\acode{mplus}则是将两个结果合并成一个。我们现在可以使用\acode{mplus}来完全的移除\acode{case}表达式了。
\begin{lstlisting}[language=Haskell]
oneBusinessPhone :: [(Context, Phone)] -> Maybe Phone
oneBusinessPhone ps = lookup Business ps `mplus` lookup Mobile ps
allPersonalPhones :: [(Context, Phone)] -> [Phone]
allPersonalPhones ps =
map snd $
filter (contextIs Home) ps
`mplus` filter (contextIs Mobile) ps
\end{lstlisting}
这些函数中由于我们知道\acode{lookup}返回一个类型为\acode{Maybe}的值,以及\acode{filter}返回一个列表,那么\acode{mplus}使用的版本就很明显了。
更有趣的是,我们可以使用\acode{mzero}与\acode{mplus}来编写任何对\acode{MonadPlus}实例都有用的函数。以下是标准查找函数的例子:
\begin{lstlisting}[language=Haskell]
lookup :: (Eq a) => a -> [(a, b)] -> Maybe b
lookup _ [] = Nothing
lookup k ((x,y):xys) | x == k = Just y
| otherwise = lookup k xys
\end{lstlisting}
我们可以轻易地泛化返回类型至任意\acode{MonadPlus}的实例:
\begin{lstlisting}[language=Haskell]
lookupM :: (MonadPlus m, Eq a) => a -> [(a, b)] -> m b
lookupM _ [] = mzero
lookupM k ((x, y) : xys)
| x == k = return y `mplus` lookupM k xys
| otherwise = lookupM k xys
\end{lstlisting}
如果结果类型是\acode{Maybe},即要么没有结果,要么一个结果;如果类型是列表,即所有结果;或者其它更适合于\acode{MonadPlus}的奇异实例。
\subsubsection*{mplus 并不是加法}
尽管\acode{mplus}函数包含了“plus”,但这并不意味着将两个值求和。
根据单子定义\acode{mplus}\textit{可能}会实现类似于加法的操作。例如立标单子中的\acode{mplus}是作为\acode{(++)}操作符实现的。
\begin{lstlisting}[language=Haskell]
ghci> [1,2,3] `mplus` [4,5,6]
[1,2,3,4,5,6]
\end{lstlisting}
然而切换到其他单子,类似加法的行为并不成立:
\begin{lstlisting}[language=Haskell]
ghci> Just 1 `mplus` Just 2
Just 1
\end{lstlisting}
\subsubsection*{MonadPlus 的规则}
\acode{MonadPlus} typeclass 的实例相较于普通的单子规则还需要遵循某些其他简单的规则。
如果\acode{mzero}出现在绑定表达式的左侧,则实例必须短路。换言之,表达式\acode{mzeor >>= f}的计算结果必须与\acode{mzero}单独计算的结果相同。
\begin{lstlisting}[language=Haskell]
mzero >>= f == mzero
\end{lstlisting}
如果\acode{mzero}出现在序列表达式的\textit{右侧},那么该实例必须短路。
\begin{lstlisting}[language=Haskell]
v >> mzero == mzero
\end{lstlisting}
\subsubsection*{失败安全的 MonadPlus}
早在“The Monad typeclass”章节中提到的\acode{fail}函数,被告知不要使用它:在很多\acode{Monad}中,它被实现为对\acode{error}的调用,这会产生令人不快的后果。
\acode{MonadPlus} typeclass 为我们提供了一种更温和的方式来失败计算,而不会出现\acode{fail}或\acode{error}。上面介绍的规则允许我们在任何需要的地方在代码中
引入\acode{mzero},并在该点上短路。
在\acode{Control.Monad}模块中,标准函数\acode{guard}将此理念打包成了方便的样式。
\begin{lstlisting}[language=Haskell]
guard :: (MonadPlus m) => Bool -> m ()
guard True = return ()
guard False = mzero
\end{lstlisting}
下面是个简单的例子,一个函数接受一个值\acode{x}并计算它对另一个数字\acode{n}取模。如果结果为零返回\acode{x},否则返回当前单子的\acode{mzero}。
\begin{lstlisting}[language=Haskell]
x `zeroMod` n = guard ((x `mod` n) == 0) >> return x
\end{lstlisting}
\subsection*{隐藏管道的冒险}
略。
我们给单子取名为\acode{Supply},将执行函数\acode{runSupply}提供一个列表;需要确保列表中每一个元素都是唯一的。
\begin{lstlisting}[language=Haskell]
runSupply :: Supply s a -> [s] -> (a, [s])
\end{lstlisting}
单子并不会关心内部的值:它们有可能是随机数,临时文件的名称,或是 HTTP cookies 的 ID。
单子内部,消费者每次需求一个值,\acode{next}则会从列表中获取下一个元素并给到消费者。每个值都会被\acode{Maybe}构造函数包装,以防列表长度不够。
\begin{lstlisting}[language=Haskell]
next :: Supply s (Maybe s)
\end{lstlisting}
为了隐藏管道,模块声明时仅导出类型构造函数,执行函数,以及\acode{next}操作函数:
\begin{lstlisting}[language=Haskell]
module Supply (Supply, next, runSupply) where
\end{lstlisting}
管道非常的简单:使用\acode{newtype}声明来包装一个\acode{State}单子:
\begin{lstlisting}[language=Haskell]
import Control.Monad.State
newtype Supply s a = S (State [s] a)
\end{lstlisting}
这里型参\acode{s}是我们将要提供的唯一值类型,而\acode{a}是为了类型成为单子而必须提供的通常类型参数。
我们对\acode{Supply}类型的\acode{newtype}的使用和模块头文件联合起来防止用户使用\acode{State}单子的\acode{get}和\acode{set}操作。由于模块没有导出\acode{S}
的构造函数,所以外部无法通过编程的方式看到包装的\acode{State}单子,也无法访问它。
此刻有了类型\acode{Supply},我们需要它来创建\acode{Monad} typeclass 实例。我们可以遵循\acode{(>>=)}以及\acode{return}的通常模式,但这只是存粹的样板代码。
我们所要做的是包装盒解包\acode{State}单子的\acode{(>>=)}版本,并使用\acode{S}值构造函数返回。
\begin{lstlisting}[language=Haskell]
unwrapS :: Supply s a -> State [s] a
unwrapS (S s) = s
instance Functor (Supply s) where
fmap f s = S $ fmap f $ unwrapS s
instance Applicative (Supply s) where
pure = S . return
f <*> a = S $ unwrapS f <*> unwrapS a
instance Monad (Supply s) where
s >>= m = S $ unwrapS s >>= unwrapS . m
\end{lstlisting}
注:与原文不同,实现单子实例前还需分别实现函子实例与应用函子实例。
Haskell 程序员不喜欢样板文件,且可以肯定的是,GHC 有一个可爱的语言扩展用于消除这些样板。使用它需要再源文件顶部添加:
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
\end{lstlisting}
通常而言,我们只能自动派生一些标准 typeclass 的实例,例如\acode{Show}和\acode{Eq}。顾名思义,\acode{GeneralisedNewtypeDeriving}扩展了派生 typeclass
实例的能力,且它是特定于\acode{newtype}声明的。如果我们包装的类型是任何 typeclass 的实例,扩展可以自动将我们的新类型作为该 typeclass 的实例,如下所示:
\begin{lstlisting}[language=Haskell]
newtype Supply s a = S (State [s] a)
deriving (Monad)
\end{lstlisting}
那么接下来就是\acode{next}与\acode{runSupply}的定义了:
\begin{lstlisting}[language=Haskell]
runSupply :: Supply s a -> [s] -> (a, [s])
runSupply (S m) xs = runState m xs
next :: Supply s (Maybe s)
next = S $ do
st <- get
case st of
[] -> return Nothing
(x : xs) -> do
put xs
return $ Just x
\end{lstlisting}
加载模块至\textbf{ghci}测试一下:
\begin{lstlisting}[language=Haskell]
ghci> :l Supply
[1 of 1] Compiling Supply ( Supply.hs, interpreted )
Ok, one module loaded.
ghci> runSupply next [1,2,3]
(Just 1,[2,3])
ghci> import Control.Monad
ghci> runSupply (liftM2 (,) next next) [1,2,3]
((Just 1,Just 2),[3])
ghci> runSupply (liftM2 (,) next next) [1]
((Just 1,Nothing),[])
\end{lstlisting}
我们还可以验证\acode{State}单子是否泄漏了。
\begin{lstlisting}[language=Haskell]
ghci> :browse Supply
type role Supply nominal nominal
type Supply :: * -> * -> *
newtype Supply s a = S (State [s] a)
runSupply :: Supply s a -> [s] -> (a, [s])
next :: Supply s (Maybe s)
ghci> :info Supply
type role Supply nominal nominal
type Supply :: * -> * -> *
newtype Supply s a = S (State [s] a)
-- Defined at Supply.hs:12:1
instance Applicative (Supply s) -- Defined at Supply.hs:32:10
instance Functor (Supply s) -- Defined at Supply.hs:29:10
instance Monad (Supply s) -- Defined at Supply.hs:36:10
\end{lstlisting}
\subsubsection*{支持随机数}
如果想要使用\acode{Supply}单子作为随机数的源,那么我们将会遇到一个小困难。理想情况下,我们希望能够为它提供无限流式的随机数。我们可以在\acode{IO}单子中获得一个
\acode{StdGen},但是当完成时必须“放回”一个不同的\acode{StdGen}。如果不这么做,那么下一次获取\acode{StdGen}的代码将获得相同的状态,即产生一样的随机数,这可是
灾难性的事故。
从\acode{System.Random}模块中可知,目前的需求很难被调和。我们可以使用\acode{getStdRandom},其类型可以确保获得一个\acode{StdGen}时,又会放回一个。
\begin{lstlisting}[language=Haskell]
ghci> :type getStdRandom
getStdRandom :: (StdGen -> (a, StdGen)) -> IO a
\end{lstlisting}
在给到一个随机值后,可以使用\acode{random}来获取一个新的\acode{StdGen};可以使用\acode{randoms}来获取一个随机数的无限列表。但是我们该怎么得到一个无限的随机数
列表和一个新的\acode{StdGen}呢?
答案就在\acode{RandomGen} typeclass 的\acode{split}函数内,该函数接受一个随机数生成器,并将其转换为两个生成器。像这样拆分随机生成器是最不寻常的事:它在纯函数
设置中显然非常有用,但本质上既不是必须的,也不是由非纯语言提供的。
使用\acode{split}函数时,可以使用\acode{StdGen}来生成一个无限随机数列表用于\acode{runSupply},另一个则是用于\acode{IO}单子:
\begin{lstlisting}[language=Haskell]
import Supply
import System.Random hiding (next)
randomsIO :: (Random a) => IO [a]
randomsIO = getStdRandom $ \g ->
let (a, b) = split g in (randoms a, b)
\end{lstlisting}
如果我们正确的编写了这个函数,那么示例应该在每次调用时打印一个不同的随机数:
\begin{lstlisting}[language=Haskell]
ghci> :l RandomSupply.hs
[1 of 2] Compiling Supply ( Supply.hs, interpreted )
[2 of 2] Compiling RandomSupply ( RandomSupply.hs, interpreted )
Ok, two modules loaded.
ghci> (fst . runSupply next) `fmap` randomsIO
Just (-8154423328023582499)
ghci> (fst . runSupply next) `fmap` randomsIO
Just (-4209314352233312889)
\end{lstlisting}
回忆一下,\acode{runSupply}函数即返回执行一元操作的结果,也返回列表中未使用的剩余部分。由于我们向它传递了一个随机数的无限列表,因此使用\acode{fst}以确保在
\textbf{ghci}尝试打印结果时不会被随机数淹没。
\subsubsection*{再一次尝试}
将函数应用与一对元组中的一个元素,并在不改变另一个原始元素的情况下构造一个新元组的模式,这在 Haskell 代码中很常见,以至于它已经变成了标准代码。
\acode{Control.Arrow}模块中有两个函数,\acode{first}与\acode{second},即实现了该操作。
\begin{lstlisting}[language=Haskell]
ghci> :m +Control.Arrow
ghci> first (+3) (1,2)
(4,2)
ghci> second odd ('a',1)
('a',True)
\end{lstlisting}
\subsection*{从实现中分离接口}
之前的小节中,我们见识到了在使用\acode{State}用于维护\acode{Supply}的状态时,是如何隐藏实现的。
另一个让代码更模块化的方式则是分离其\textit{接口} -- 即代码可以做的,与\textit{实现} -- 即代码如何做的。
\acode{System.Random}模块中的标准随机数生成器是很低效的。如果使用\acode{randomsIO}函数来提供随机数,那么\acode{next}操作的性能不会很好。
一个简单高效的处理方式就是为\acode{Supply}提供一个更好的随机数据源。现在让我们将这个想法放在一旁,而去考虑另一种方式,一种在很多设置中有用的方式。我们将单子
可以执行的操作与它使用的 typeclass 的工作方式分离开来。
\begin{lstlisting}[language=Haskell]
class (Monad m) => MonadSupply s m | m -> s where
next :: m (Maybe s)
\end{lstlisting}
该 typeclass 定义了任何 supply 单子必须实现的接口。他需要仔细检查,因为它使用了几个暂不熟悉的 Haskell 语言扩展。我们将在接下来的章节中逐一介绍。
\subsubsection*{若干参数的 typeclasses}
我们该如何阅读代码切片\acode{MonadSupply s m}这个 typeclass 呢?如果添加圆括号,那么一个相同的表达式就是\acode{(MonadSupply s) m},这更清晰一点。换言之,
给定某身为\acode{Monad}的类型变量\acode{m},我们可以使其成为\acode{MonadSupply s}的实例。有别于通常的 typeclass,它有一个\textit{参数}。
语言扩展允许一个 typeclass 拥有多个参数,其名称为\acode{MultiParamTypeClasses}。参数\acode{s}的作用与同名的\acode{Supply}类型参数相同:它表示下一个函数
传递的值的类型。
注意,我们不需要在\acode{MonadSupply s}的定义中提到\acode{(>>=)}或\acode{return},因为 typeclass 的上下文(superclass)要求\acode{MonadSupply s}
必须是\acode{Monad}。
\subsubsection*{函数式依赖}
回忆一下早前忽略的代码片段,\acode{| m -> s}是一个\textit{函数式依赖},通常称为\textit{fundep}。我们可以将竖线\acode{|}读作“such that”,而箭头\acode{->}
读作“uniquely determines”。函数式依赖建立了关于\acode{m}与\acode{s}之间的\textit{关系}。
功能依赖是由\acode{FunctionalDependencies}的语言 pragma 控制的。
声明关系的目的是帮助类型检查器。回想一下,Haskell 类型检查器本质上是一个定理证明器,且他的操作方式是保守的:它坚持它的证明必须终止。非终止证明会导致编译器放弃或陷入
无限循环。
通过函数式依赖,我们告诉类型检查器,每当它看到\acode{MonadSupply s}的上下文中使用某些单子\acode{m}时,类型\acode{s}是唯一可接受的类型。如果我们忽略函数式依赖,
类型检查器将简单的放弃并显示一条错误信息。
现在看一下这个 typeclass 的实例:
\begin{lstlisting}[language=Haskell]
import qualified Supply as S
instance MonadSupply s (S.Supply s) where
next = S.next
\end{lstlisting}
这里类型变量\acode{m}被类型\acode{S.Supply s}取代。由于函数式依赖,类型检查器知道了当它看到\acode{S.Supply s}时,该类型可以用作 typeclass
\acode{MonadSupply s}的实例。
为了去掉最后一层抽象,试着考虑一下类型\acode{S.Supply Int}。如果没有函数式依赖,我们可以将其声明为\acode{MonadSupply s}的实例。然而在尝试使用该实例时,编译器
将无法确定类型的\acode{Int}参数需要与 typeclass 的\acode{s}参数相同,同时编译器将报告一个错误。
函数式依赖关系可能很难理解,一旦超出了简单使用,它们在实践中将很难使用。幸运的是,函数依赖关系最常见的使用方式就是像上述这样的简单情况,它们造成的麻烦会少很多。
\subsubsection*{完善我们的模块}
当我们将 typeclass 与实例保存在一个名为\acode{SupplyClass.hs}的源文件中,我们需要添加一个模块头如下:
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module SupplyClass
( MonadSupply (..),
S.Supply,
S.runSupply,
)
where
\end{lstlisting}
为了让编译器接受我们的实例声明,必须使用\acode{FlexibleInstances}扩展。这个扩展放宽了某些情况下编写实例的正常规则,在某种程度上仍然让编译器的类型检查器保证它被
终止。这里还需要使用\acode{FlexibleInstances}是因为使用了函数式依赖的关系,不过这些细节超出了本书的范围。
\begin{anote}
如何知道哪个语言扩展是被需要的
如果 GHC 因为需要某些语言扩展被开启才能对某部分代码进行编译,它会直接告诉用户需要那些扩展。例如,如果它认为我们的代码需要灵活实例的支持,它会建议我们使用
\acode{-XFlexibleInstances}选项来进行编译。一个\acode{-X}选项与\acode{LANGUAGE}的效果一样:开启特定的扩展。
\end{anote}
最后请注意,我们将重新导出这个模块中的\acode{runSupply}以及\acode{Supply}。从一个模块导出名称是完全合法的,即使它是在另一个模块中定义的。这个例子中,意味着用户
只需要导入\acode{SupplyClass}模块,而不需要导入\acode{Supply}模块。这减少了用户需要记住的“移动部件”的数量。
\subsubsection*{对单子接口进行编程}
以下是一个简单的函数用于提取\acode{Supply}单子的两个值,并格式化它们成为一个字符串进行返回:
\begin{lstlisting}[language=Haskell]
showTwo :: (Show s) => Supply s String
showTwo = do
a <- next
b <- next
return $ show "a: " ++ show a ++ ", b: " ++ show b
\end{lstlisting}
这段代码通过其结果类型绑定到\acode{Supply}单子。通过修改函数类型,我们可以很容易的泛化到任何实现\acode{MonadSupply}接口的单子。注意函数体保持不变:
\begin{lstlisting}[language=Haskell]
showTwoClass :: (Show s, Monad m, MonadSupply s m) => m String
showTwoClass = do
a <- next
b <- next
return $ show "a: " ++ show a ++ ", b: " ++ show b
\end{lstlisting}
\subsection*{reader 单子}
\acode{State}单子允许我们在代码中探索可变状态。有时我们希望能够传递一些\textit{不可变}的状态,例如程序的配置数据。我们可以使用\acode{State}单子来实现这个目的,但是我们
可能会发现自己意外的修改了应该保持不变的数据。
现在考虑一下具有上述期望的特征的函数应该做什么。它应该接受我们传入的数据某种类型\acode{e}的值,并返回另一种类型\acode{a}的值作为结果,即\acode{e -> a}。
将该类型转换为一个方便的\acode{Monad}实例仅需\acode{newtype}包装:
\begin{lstlisting}[language=Haskell]
newtype Reader e a = R {runReader :: e -> a}
\end{lstlisting}
使其成为\acode{Monad}实例并不需要做太多的工作:
\begin{lstlisting}[language=Haskell]
instance Functor (Reader a) where
fmap f m = R $ f . runReader m
instance Applicative (Reader a) where
pure = R . const
f <*> m = R $ \r -> runReader f r $ runReader m r
instance Monad (Reader e) where
m >>= k = R $ \r -> runReader (k $ runReader m r) r
\end{lstlisting}
注:原文代码过旧。
我们可以把类型\acode{e}看作是计算某个表达式的\textit{环境}。无论环境是什么,\acode{return}操作都应该具有相同效果,即忽略它的环境。
\acode{(>>=)}的定义稍微复杂一些,仅仅是因为需要将环境 -- 这里是变量\acode{r} -- 在当前计算与链接的计算上都适用。
在单子中执行的一段代码如何知道它的环境中有什么?仅需\acode{ask}:
\begin{lstlisting}[language=Haskell]
ask :: Reader e e
ask = R id
\end{lstlisting}
在给定的操作链中,每次调用\acode{ask}都将返回相同的值,因为存储在环境中的值不会改变。在\textbf{ghci}中进行测试:
\begin{lstlisting}[language=Haskell]
ghci> :l SupplyClass.hs
[1 of 2] Compiling Supply ( Supply.hs, interpreted )
[2 of 2] Compiling SupplyClass ( SupplyClass.hs, interpreted )
Ok, two modules loaded.
ghci> runReader (ask >>= \x -> return (x * 3)) 2
6
\end{lstlisting}
\acode{Reader}单子包含在标准的\acode{mtl}库中,这个库通常与 GHC 捆绑在一起。我们可以在\acode{Control.Monad.Reader}模块中找到它。这个单子的动机最初看起来
可能有点单薄,但是它在复杂的代码中很有用。我们经常要访问程序内部深处的配置信息;将这些信息作为普通参数传递进来,则需要对代码进行痛苦的重构。将这些信息隐藏在单子中,
不关心配置信息的中间函数则不需要看到它。
\acode{Reader}单子的最明确动机将出现在第 18 章\textit{单子转换}中的合并几个单子来构建一个新的单子的讨论中。这里我们则是看到了如何更好的控制状态,以便我们的代码
可以通过\acode{State}单子修改一些值,而其他值则通过\acode{Reader}单子保持不变。
\subsection*{返回自动推导}
现在我们了解了\acode{Reader}单子,让我们用它创建\acode{MonadSupply} typeclass。为了保持示例的简单性,在这里违背\acode{MonadSupply}的精神:下一个动作总是
返回相同的值,而不是总返回不同的值。
直接将\acode{Reader}类型转为\acode{MonadSupply}类的实例是一个坏主意,因为这样任何\acode{Reader}都可以充当\acode{MonadSupply},这通常没有任何意义。
创建一个基于\acode{Reader}的\acode{newtype},它隐藏了内部使用\acode{Reader}的事实。现在必须使类型成为我们所关心的两个 typeclasses 实例。启用了
\acode{GeneralizedNewtypeDeriving}扩展后,GHC 将会帮我们做大部分的苦力活。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Control.Monad
import SupplyClass
newtype MySupply e a = MySupply {runMySupply :: Reader e a}
deriving (Functor, Applicative, Monad)
instance MonadSupply e (MySupply e) where
next = MySupply $ Just `liftM` ask
\end{lstlisting}
注:\acode{deriving Monad}之前还要加上\acode{Functor}和\acode{Applicative}。
注意我们必须让类型成为\acode{MonadSupply e}的一个实例,而不是\acode{MonadSupply}。如果省略类型变量,编译器会报错。
测试\acode{MySupply}类型:
\begin{lstlisting}[language=Haskell]
xy :: (Num s, MonadSupply s m, MonadFail m) => m s
xy = do
Just x <- next
Just y <- next
return $ x * y
\end{lstlisting}
注:与原文不同,这里 HLS 加上了\acode{MonadFail m}的约束。
略(原文此处难以理解)。
\subsection*{隐藏 IO 单子}
\acode{IO}单子的优点和缺点是它非常强大。如果谨慎使用类型可以帮助我们避免编程错误,那么\acode{IO}单子应该是一个很大的不安来源。因为\acode{IO}单子对我们能做的
事情没有任何限制,它使我们容易受到各种事故的影响。
我们怎样才能驯服它呢?假设我们想要保证一段代码可以读写本地文件系统上的文件,但它不能访问网络。我们不能使用普通的\acode{IO}单子,因为它不会限制我们。
\subsubsection*{使用 newtype}
现在让我们创建一个模块提供一些用于读写文件的功能。首先是创造一个约束版本的\acode{IO}将其用\acode{newtype}包裹。
\begin{lstlisting}[language=Haskell]
newtype HandleIO a = HandleIO {runHandleIO :: IO a}
deriving (Functor, Applicative, Monad)
\end{lstlisting}
从模块导出类型构造函数和\acode{runHandleIO}执行函数,但不导出数据构造函数。这可以防止在\acode{HandleIO}单子内运行的代码获得它所包装的\acode{IO}单子。
剩下要做的就是包装希望\acode{monad}允许的每个动作。这是一个用\acode{HandleIO}数据构造函数包装每个\acode{IO}。
\begin{lstlisting}[language=Haskell]
import qualified System.IO as IO
openFile :: FilePath -> IO.IOMode -> HandleIO IO.Handle
openFile path mode = HandleIO $ IO.openFile path mode
hClose :: IO.Handle -> HandleIO ()
hClose = HandleIO . IO.hClose
hPutStrLn :: IO.Handle -> String -> HandleIO ()
hPutStrLn h s = HandleIO $ IO.hPutStrLn h s
\end{lstlisting}
现在我们可以使用被约束的\acode{HandleIO}单子了:
\begin{lstlisting}[language=Haskell]
safeHello :: FilePath -> HandleIO ()
safeHello path = do
h <- openFile path IO.WriteMode
hPutStrLn h "hello world"
hClose h
\end{lstlisting}
测试:
\begin{lstlisting}[language=Haskell]
ghci> :l HandleIO.hs
[1 of 1] Compiling HandleIO ( HandleIO.hs, interpreted )
Ok, one module loaded.
ghci> runHandleIO (safeHello "hello_world_101.txt")
\end{lstlisting}
\subsubsection*{为意想不到的用途设计}
\acode{HandleIO}单子有一个小但却重要的问题:它没有考虑到我们可能偶尔需要一个逃生舱口的可能性。如果像这样定义一个单子,可能会偶尔需要执行单子不允许的 I/O 操作。
我们这样定义单子的目的是为了更容易在常见情况下编写可靠的代码,而不是让极端情况变得不可能。
\acode{Control.Monad.Trans}模块定义了一个“标准逃生舱”,\acode{MonadIO} typeclass。它定义了一个单独的函数,\acode{liftIO},让我们可以将一个\acode{IO}
操作镶嵌进另一个单子中。
\begin{lstlisting}[language=Haskell]
ghci> :m +Control.Monad.Trans
ghci> :i MonadIO
type MonadIO :: (* -> *) -> Constraint
class Monad m => MonadIO m where
liftIO :: IO a -> m a
{-# MINIMAL liftIO #-}
-- Defined in ‘Control.Monad.IO.Class’
instance [safe] MonadIO IO -- Defined in ‘Control.Monad.IO.Class’
\end{lstlisting}
这个 typeclass 的实现很简单:仅用构造函数来包装\acode{IO}。
\begin{lstlisting}[language=Haskell]
import Control.Monad.Trans (MonadIO (..))
instance MonadIO HandleIO where
liftIO = HandleIO
\end{lstlisting}
通过明智的使用\acode{liftIO},我们可以摆脱束缚并在必要时调用\acode{IO}操作。
\begin{lstlisting}[language=Haskell]
import System.Directory (removeFile)
tidyHello :: FilePath -> HandleIO ()
tidyHello path = do
safeHello path
liftIO $ removeFile path
\end{lstlisting}
\begin{anote}
自动派生与 MonadIO
通过将 typeclass 添加到\acode{HandleIO}的派生子句中,可以让编译器自动派生\acode{MonadIO}实例。实际上在生产代码中,这是常用的策略。我们在这里避免了这点,
仅仅是为了将早期材料的呈现与\acode{MonadIO}分开。
\end{anote}
\subsubsection*{使用 typeclasses}
将\acode{IO}隐藏在另一个单子中的缺点就是:它仍然与具体的实现绑定到了一起。如果想要将\acode{HandleIO}替换为其他单子,则必须更改使用\acode{HandelIO}的每个操作
类型。
作为替换方法,我们可以创建一个 typeclass 指定从操作文件的单子中获得接口。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module MonadHandle
( MonadHandle (..),
)
where
import qualified System.IO as IO
class (Monad m) => MonadHandle h m | m -> h where
openFile :: FilePath -> IO.IOMode -> m h
hPutStr :: h -> String -> m ()
hClose :: h -> m ()
hGetContents :: h -> m String
hPutStrLn :: h -> String -> m ()
hPutStrLn h s = hPutStr h s >> hPutStr h "\n"
\end{lstlisting}
这里我们选择抽象单子的类型和文件句柄的类型。为了满足类型检查器的要求,我们添加了一个函数依赖:对于\acode{MonadHandle}的任何实例,只能使用一种句柄类型。当我们将
\acode{IO}单子作为该类的一个实例时,我们使用一个普通的\acode{Handle}。
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Control.Monad.Trans (MonadIO (..), MonadTrans (..))
import MonadHandle
import SafeHello
import System.Directory (removeFile)
import System.IO (IOMode (..))
import qualified System.IO as IO
instance MonadHandle IO.Handle IO where
openFile = IO.openFile
hPutStr = IO.hPutStr
hClose = IO.hClose
hGetContents = IO.hGetContents
hPutStrLn = IO.hPutStrLn
\end{lstlisting}
由于任何\acode{MonadHandle}也必须同样是一个\acode{Monad},因此可以使用\acode{do}来操作文件,而无需关心它最终会在那个单子中执行。
\begin{lstlisting}[language=Haskell]
safeHello :: (MonadHandle h m) => FilePath -> m ()
safeHello path = do
h <- openFile path WriteMode
hPutStrLn h "hello world"
hClose h
\end{lstlisting}
因为我们让\acode{IO}成为这个 typeclass 的一个实例,所以可以从\textbf{ghci}执行这个动作:
\begin{lstlisting}[language=Haskell]
ghci> :l MonadHandleIO.hs
[1 of 2] Compiling MonadHandle ( MonadHandle.hs, interpreted )
[2 of 2] Compiling MonadHandleIO ( MonadHandleIO.hs, interpreted )
Ok, two modules loaded.
ghci> safeHello "hello to my fans in domestic surveillance"
ghci> removeFileo "hello to my fans in domestic surveillance"
\end{lstlisting}
typeclass 方法的美妙之处在于,我们可以在不涉及太多代码的情况下将一个底层单子交换为另一个单子,因为大多数代码都不知道或不关心实现。例如,可以用某个写入文件时压缩文件的
单子来代替\acode{IO},
通过 typeclass 定义单子的接口还有一个好处。它允许其他人将我们的实现隐藏在新的类型包装器中,并自动派生他们想要公开的 typeclass 实例。
\subsubsection*{隔离与测试}
实际上,由于\acode{safeHello}函数并没有使用\acode{IO}类型,我们甚至可以用一个无法执行 I/O 的单子。这允许我们在一个完全纯净、可控的环境中测试通常会产生副作用的
代码。
为此我们将创建一个无法执行 I/O 的单子,不过将会记录每个文件相关的事件。
\begin{lstlisting}[language=Haskell]
data Event
= Open FilePath IOMode
| Put String String
| Close String
| GetContents String
deriving (Show)
\end{lstlisting}
我们在之前的章节中开发了\acode{Logger}类型,不过这里将使用标准且更泛用的\acode{Writer}单子。正如其它\acode{mtl}单子,由\acode{Writer}提供的 API 定义在一个
typeclass 中,即\acode{MonadWriter}。它最有用的方法是\acode{tell},即记录一个值。
\begin{lstlisting}[language=Haskell]
ghci> :m +Control.Monad.Writer
ghci> :type tell
tell :: (MonadWriter w m) => w -> m ()
\end{lstlisting}
我们记录的值可以是任何\acode{Monoid}类型。由于列表类型是一个\acode{Monoid},那么记录到\acode{Event}列表。
我们可以让\acode{Writer [Event]}成为\acode{MonadHandle}的一个实例,但它更便宜,更容易,也更安全。
注:书中遗漏了\acode{MonadHandle}的\acode{WriterIO}实例:
\begin{lstlisting}[language=Haskell]
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import MonadHandle (MonadHandle (..))
import MonadHandleIO
instance MonadHandle FilePath WriterIO where
openFile path mode = tell [Open path mode] >> return path
hPutStr h str = tell [Put h str]
hClose h = tell [Close h]
hGetContents h = tell [GetContents h] >> return "fake contents"
\end{lstlisting}
通过\textbf{ghci}测试:
\begin{lstlisting}[language=Haskell]
ghci> :l WriterIO.hs
[1 of 3] Compiling MonadHandle ( MonadHandle.hs, interpreted )
[2 of 3] Compiling MonadHandleIO ( MonadHandleIO.hs, interpreted )
[3 of 3] Compiling WriterIO ( WriterIO.hs, interpreted )
Ok, three modules loaded.
ghci> runWriterIO (safeHello "foo")
((),[Open "foo" WriteMode,Put "foo" "hello world",Put "foo" "\n",Close "foo"])
\end{lstlisting}
\subsubsection*{writer 单子与列表}
每次使用\acode{tell}时,writer 单子都会使用单子的\acode{mappend}函数,由于列表的\acode{mappend}是\acode{(++)},因此列表不是与\acode{Writer}一起使用的
使用选择:重复追加的代价很高。上面的示例使用列表存粹是为了简单。
在生产代码中,如果使用\acode{Writer}单子同时需要一个类似列表这样的方式记录日志,那么请使用更高效的追加特征的容器。这里有一种选择就是之前介绍的差异列表(十三章)。
我们无需自己造轮子,而是可以在 Haskell 的库中去寻找一个合适的替代。另一种方案就是使用由\acode{Data.Sequence}模块所提供的\acode{Seq}类型(十三章)。
\subsubsection*{任意 I/O 的重新访问}
如果使用 typeclass 方式来限制\acode{IO},我们可能仍然希望保留执行任意 I/O 操作的能力。我们可以尝试在 typeclass 上添加\acode{MonadIO}作为约束。
\begin{lstlisting}[language=Haskell]
class (MonadHandle h m, MonadIO m) => MonadHandleIO h m | m -> h
instance MonadHandleIO Handle IO
tidierHello :: (MonadHandleIO h m) => FilePath -> m ()
tidierHello path = do
safeHello path
liftIO $ removeFile path
\end{lstlisting}
不过这种方式存在一个问题:添加的\acode{MonadIO}约束使我们失去了在纯环境中测试代码的能力,因为我们不能再判断测试是否可能具有破坏性的副作用。另一种方法是将这个约束
从 typeclass(它影响所有函数)移到那些真正需要执行 I/O 的函数。
\begin{lstlisting}[language=Haskell]
tidyHello :: (MonadIO m, MonadHandle h m) => FilePath -> m ()
tidyHello path = do
safeHello path
liftIO $ removeFile path
\end{lstlisting}
我们可以对缺少\acode{MonadIO}约束的函数使用纯函数式测试,而对其余的函数使用传统的单元测试。
不幸的是,我们用一个问题替代了另一个问题:我们不能从单独具有\acode{MonadHandle}约束的代码中调用具有\acode{MonadIO}和\acode{MonadHandle}约束的代码。如果我们
发现在\acode{MonadHandle}代码深处的某个地方,我们确实需要\acode{MonadIO}约束,我们必须将它添加到通向这一点的所有代码路径中。
允许任意I/O是有风险的,并且会对我们如何开发和测试代码产生深远的影响。当我们必须在宽容和更容易的推理和测试之间做出选择时,我们通常会选择后者。
\end{document}