这是一个很好的例子,因为创建一个像你这样Endo
的函数是在无状态语言中处理“状态”的好方法。让我们潜入水中。a -> a
[Film] -> [Film]
所以目标是创建一个类似becomeFan "Joseph" "7 1/2" :: [Film] -> [Film]
“电影数据库更新功能”的功能。要执行此更新,您需要修改电影数据库以更新"7 1/2"
要包含的电影的粉丝列表"Joseph"
。我们假设每个用户的名字都是全局唯一的,并且多次编写这个函数。
现在让我们假设,如果这部电影不在我们的数据库中,则becomeFan
不做任何事情,并且数据库不包含重复项。
首先,我们有直接递归版本。
becomeFan _ _ [] = [] -- empty film database
becomeFan name film (f@(title, cast, year, fans) : fs)
| film == title = (title, cast, year, name:fans) : fs
| otherwise = f : becomeFan name film fs
这只会遍历数据库中的电影列表,并在电影标题与我们正在尝试编辑的标题匹配时进行更新。请注意@
- 语法,它允许我们将正在检查的电影“作为一个整体”进行检查,并且仍然解构它。
然而,这种方法的挑战是无数的——它只是非常复杂!我们有许多与我们实现的方式相关的基本假设,这些假设becomeFan
可能与我们编写的其他函数不同步。幸运的是,Haskell非常擅长解决此类问题。
第一步是引入一些更强大的数据类型。
我们要做的是消除一些类型的同义词,并引入一些更强大的容器类型,特别是Set
其行为类似于数学集合以及Map
类似于字典或散列的行为。
import qualified Data.Set as Set
import qualified Data.Map as Map
我们还为Film
. 记录与元组同构(“功能等同于”),但具有对文档有用的命名字段,并且让我们使用更少的类型同义词。
type Name = String
type Year = Int
data Film = Film { title :: Title, cast :: Set Name, year :: Year, fans :: Set Name)
通过使用 aMap Title Film
来表示我们的数据库,我们还可以保证电影的唯一性(aMap
将键Title
设为零或一Film
,我们不能有多个匹配项)。这里的缺点是我们可能会使Title
inDatabase
键与Title
inFilm
类型本身不同步。
type Database = Map Title Film
那么我们如何becomeFan
在这个新系统中重写呢?
becomeFan name title =
Map.alter update title where
update Nothing = Nothing -- that title was not in our database
update (Just f) = Just (f { fans = Set.insert name (fans f) })
现在我们主要依靠Map.alter :: (Maybe v -> Maybe v) -> k -> Map k v -> Map k v
并Set.insert :: a -> Set a -> Set a
完成繁重的工作并保持各种独特性约束。请注意,第一个参数Map.alter
是一个函数Maybe v -> Maybe v
,它允许我们处理丢失的电影(如果输入是Nothing
)并决定从数据库中删除电影(如果我们返回Nothing
)。
还值得注意的是,我们的内部函数update :: Maybe Film -> Maybe Film
可以更容易地编写为fmap (\f = f { fans = Set.insert name (fans f) })
将“纯”更新步骤提升Maybe
为Functor
.
我们能做得更好吗?当然可以,但这里会让人感到困惑。在大多数情况下,上一个答案可能是您最好的选择。但是,让我们继续前进吧。
我们可以使用Control.Lens中的镜头来使我们访问Map
、Set
、Film
甚至更容易。
为此,我们将导入模块
import Control.Lens
并重写Film
类型,以便库可以使用宏自动生成镜头。
data Film = Film { _title :: Title, _cast :: Set Name, _year :: Year, _fans :: Set Name }
$(makeLenses ''Film)
我们所要做的就是在每个记录字段名称前加上一个下划线,并Control.Lens.makeLenses
自动生成原始名称下的镜头。因此,在那条线之后,我们拥有title :: Lens' Film Title
了我们想要的功能。
然后我们可以使用Map
'At
实例来创建我们的改变函数,和以前一样,但是写成一串镜头操作
becomeFan name film = over (at film) (fmap . over fans . Set.insert $ name)
其中over (at film)
泛化并替换Map.alter
和(fmap . over fans . Set.insert $ name)
替换了update
我们之前定义的内部函数。
我们甚至可以构建一个强大的设置镜头,直接查看某个粉丝列表中某个粉丝的存在Film
。
isFan :: Name -> String -> Setter' Database Bool
isFan name film = at film . mapped . fans . contains name
这些方法起初相当令人讨厌,并且具有非常奇怪(但完全可检查)的类型,但是一旦您习惯了在该抽象级别上工作,它们就会变得非常好。它“读起来像英语”,感觉就像 XPath 的优秀部分。
becomeFan name film = isFan name film .~ True
通过这种结构,我们甚至可以立即将整个过程升级到State
monad。
flip execState initialDB $ do
isFan "Joseph" "7 1/2" .= True
isFan "Steve" "Citizen Kane" .= True
-- oh, wait, nevermind
isFan "Joseph" "7 1/2" .= False
不过,我们可以Control.Monad.State.withState
使用任何定义来做同样的事情becomeFan
。