UnsafePerformBrainIO

HasIt设计模式

原文:https://hackernoon.com/the-has-type-class-pattern-ca12adab70ae

注:这个Has并非Haskell的缩写。

Q1 - Collecting all Images

想象一下你正在用haskell开发一个GUI下的游戏……不行,根本想象不出来!

这样吧, 假设平行世界的你在用haskell开发一个游戏, 现在正写到资源检查,需要确认各个场景的对应图像文件都在。

首先把场景(Scene)的类型设计好。

data Scene = Scene 
  { backgroundImage   :: Text
  , characters        :: [Character]   
  , bewilderedTourist :: Maybe Character
  , objects           :: [Either Rock WoodenCrate]  
  }

data Character = Character
 { hat   :: Maybe DamageArray
 , head  :: DamageArray
 , torso :: DamageArray
 , legs  :: DamageArray
 , shoes :: Maybe DamageArray
 }

data DamageArray = DamageArray
  { noDamage        :: Text
  , someDamage      :: Text
  , excessiveDamage :: Text
  }

data Rock = Rock 
  { weight    :: Double
  , rockImage :: Text
  }

data WoodenCrate = WoodenCrate
  { strength         :: Double
  , woodenCrateImage :: DamageArray  
  }

要做的事情就是把对应图像文件名的Text收集起来,去重,挨个检查。最适合做这个容器的显然是Data.Set

collectImages :: Scene -> Set Text

函数签名设计完毕!

但是我们可以看到,除了Scene本身所存储的图像文件名,还有很多图像文件名的信息是放在Scene所存储的子结构里的。甚至还带有Maybe,Either等上下文。

也许比较合适的方式是写多个函数,把每个子结构都考虑到。

collectImages :: Scene -> Set Text
collectImages Scene {..} 
  =  singleton backgroundImage 
  <> mconcat (map collectCharacterImages characters)
  <> maybe mempty collectCharacterImages bewilderedTourist
  <> mconcat (map (either (singleton . collectRockImage) 
                          collectWoodenCrateImages)     
                  objects)
-- {..}是RecordWildCards扩展提供的语法糖
-- 食用方法: https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
-- 其他几个函数就不写了,多半对你很简单

烦啊! 计算机行业的一大老大难问题不就是命名吗! 是时候用typeclass来把这一堆收拾一下了。

collectImages :: Scene -> Set Text
collectCharacterImages :: Character -> Set Text
collectDamageArrayImages :: DamageArray -> Set Text
collectRockImage :: Rock -> Text
collectWoodenCrateImages :: WoodenCrate -> Set Text

这么一看,其实它们的类型签名服从一个共同的模式: a -> Set Text,但是你可能要说有一个是Rock -> Text,为了一致性,我们包裹一下它的返回值,改成Set Text类型。这会带来性能上的损失,但是也有不少优点。

那么相应的typeclass设计如下:

class HasImages a where
  images :: a -> Set Text

应该开始着手给Scene, Character,DamageArray几个写实例了吗? 先不要! 我们先定义几个派生规则,搞定Maybe,Either,List这几个「上下文」的处理。

instance HasImages a => HasImages [a] where
  images xs = foldr (\x accum -> images x <> accum) mempty xs

instance HasImages a => HasImages (Maybe a) where
  images x = maybe [] images x

instance (HasImages a, HasImages b) => HasImages (Either a b) where
  images x = either images images x

把上下文处理单独拉出来写成实例是有好处的,如果在未来,Scene类型的定义需要修改,又加入了一些新的内容,并且仍然使用了这几个上下文的其中之一–这很有可能,那么就可以复用这几个实例的派生规则, 好处多多。

instance HasImages Scene where
  images Scene {..} 
    =  singleton backgroundImage 
    <> images characters
    <> images bewilderedTourist
    <> images objects

instance HasImages Character where
  images Character {..} 
    =  images hat
    <> images head
    <> images torso
    <> images legs
    <> images shoes

instance HasImages DamageArray where
  images DamageArray {..} = fromList
    [ noDamage
    , someDamage
    , excessiveDamage
    ]

instance HasImages Rock where
  images Rock {..} = singleton rockImage

instance HasImages WoodenCrate where
  images WoodenCrate {..} = images woodenCrateImage

大功告成! 虽然增加了一些语法噪音和缩进问题,但是总的来说,代码更方便使用了(不过,有点不好理解了!)。

(可能有写过Scheme的朋友开始注意到了:怎么看着这么像个元求值器?虽然Scene类型并非一个AST,但是就是有这种感觉!)

如果有一组类型{a, b, c, d, e….},它们都可以提取出类型为μ的值,就可以采用HasIt模式了。下面的例子进一步提供了HasIt模式的一个更加泛化,通用的例子。

Q2 - Convenient Argument Passing

假设要用haskell开发一个数据库应用,存点用户数据之类的东西。

newtype Key a = Key UUID
data Entity a = Entity 
  { entityKey   :: Key a
  , entityValue :: a
  }

UUID 是 通用唯一识别码(Universally Unique Identifier)的缩写,总之就是用户在你这个平台的数据身份证,每个用户的UUID都是唯一的。 现在要写个函数针对某个用户查询ta的好友,是账户的好友不是朋友。

getFriends :: Key User -> [Entity User]

现在你可能会有点疑惑,Key a中的a和值其实没半点瓜葛,为什么不这样定义呢?

newtype Key = Key UUID

这实际上是一种被称为“PhantomType”的设计模式,在类型构造子的参数处刻意多出一或多个根本和值无关的参数,这些额外的类型参数可以自由地用于传递一些信息。当然了,一般来说,如果需要在类型中传递的信息或者信息处理的逻辑较为复杂,那就用GADTs和其他一些扩展。

回到正题,我们可能经常需要从Entity User去unpack出Key User使用,比如找出一个用户的好友的好友

concatMap (getFriends . entityKey) (getFriends user)

那就不如弄个好用一点的API

class HasKey a k | a -> k where
  key :: a -> Key k

这里使用的扩展为:MultiParamTypeClasses, FunctionalDependencies, 前者开启多参数的typeclass,后者则是那个 | a -> k的来源,意思是由参数a可以唯一确定一个对应的k,所以叫类似函数的依赖机制。由类型a可以确定唯一的类型k,听起来就很像函数嘛。

一个简单的使用教程在此: https://ocharles.org.uk/blog/posts/2014-12-14-functional-dependencies.html

一篇复杂的散文在此: https://aphyr.com/posts/342-typing-the-technical-interview

最后实现HasKey的实例和getFriends

instance HasKey (Key a) a where
  key = id

instance HasKey (Entity a) a where
  key = entityKey

getFriends :: HasKey a User => a -> [Entity User]

非常简单,原作者说因为ta的一位同事(coworker应该是协作者,不过不知道是不是同事,可能是社区的朋友吧)很喜欢这个例子,所以仍然发出来了。

原作者还提到HasIt模式很适合结合haskell的MTL库使用,同时我个人推荐一下这篇标题叫做「Data types à la carte」的文章,你会看到Q1中的简单模式在结合上不动点理论,F-Algebra和FreeMonad之后可以做些什么。

http://www.cs.ru.nl/~W.Swierstra/Publications/DataTypesALaCarte.pdf