无法以无标签最终风格(类型类与实例)实现组件
这是“如何条件性声明实例?”的具体案例:
在我的小型项目中,玩家可以创建/加入/观看游戏会话或列出进行中的会话,一个简易Yesod应用提供了对应功能的端点。
步骤1:定义Host类型类
我抽象了“能够托管多个进行中游戏”的概念,定义了Host类型类:
class Host m k s where create :: s -> m k read :: k -> m s write :: k -> s -> m () update :: (s -> s) -> k -> m () delete :: k -> m ()
该类型类的实例表示m可托管以k为标识的s类型游戏状态。
步骤2:实现InMemory托管实例
我实现了基于共享Map的InMemory托管实例:
newtype InMemory k s a = InMemory {run :: TVar (M.Map k s) -> STM a}
并为其实现了Host实例,完成了内存中游戏状态的增删改查逻辑。
步骤3:尝试自动推导实例遇到的问题
我希望让所有能获取到对应TVar的m都自动成为Host实例(比如Yesod的HandlerFor),但尝试声明的实例逻辑错误:
instance R.MonadReader (TVar (M.Map k s)) m => Host m GameId s where
这段代码实际表达的是“所有Host类型的m必须满足MonadReader上下文”,而非我想要的“满足上下文的m可作为Host,不满足则无影响”。我无法在Haskell中实现该逻辑,因此有以下疑问:
- 有哪些可行的解决方案?
- 我的思考过程中哪里出错导致陷入死胡同?
- 正确的设计思路是什么?
1. 可行的解决方案
方案一:用新type包装器隔离实例
不要直接给所有MonadReader实例声明Host,而是定义一个自己的新type来包装满足条件的m,再给这个新type写Host实例:
newtype ReaderHost m k s a = ReaderHost { unReaderHost :: m a } instance (R.MonadReader (TVar (M.Map k s)) m, MonadIO m) => Host (ReaderHost m) k s where create s = ReaderHost $ do tvar <- R.ask liftIO $ atomically $ do currentMap <- readTVar tvar let newId = -- 此处实现GameId的生成逻辑 updatedMap = M.insert newId s currentMap writeTVar tvar updatedMap return newId -- read、write、update、delete方法同理基于TVar实现
使用时,用unReaderHost将操作解包到目标m(比如Yesod Handler)中即可。
方案二:声明具体类型的实例
针对实际需要支持的m(比如HandlerFor App)单独声明实例,而非尝试写通用开放实例:
instance Host (HandlerFor App) GameId GameState where create s = do tvar <- getGameTVar -- 从Yesod App环境中获取对应TVar的逻辑 liftIO $ atomically $ do currentMap <- readTVar tvar let newId = -- GameId生成逻辑 updatedMap = M.insert newId s currentMap writeTVar tvar updatedMap return newId -- 其余方法同理实现
这种方式符合Haskell的实例设计规范,避免开放实例带来的类型歧义。
方案三:用约束同义词简化重复上下文
如果有多个类似m需要支持,可通过约束同义词减少代码重复:
type HasGameHost m k s = (R.MonadReader (TVar (M.Map k s)) m, MonadIO m) -- 针对具体m声明实例时复用约束 instance HasGameHost (HandlerFor App) GameId GameState => Host (HandlerFor App) GameId GameState where -- 实现对应方法
2. 思考过程中的错误
之前试图声明的是开放的带上下文实例,这违反了Haskell的实例解析规则。Haskell的实例声明是全域性的——那段代码会被GHC解读为:所有类型m,只要满足MonadReader (TVar (M.Map k s))约束,就必须是Host m GameId s的实例。这和你想要的“满足约束的m可选成为Host实例”完全相反,还会导致后续类型歧义(比如出现满足约束但你不想让它成为Host的m时,GHC会报错)。
本质问题是混淆了“约束推导”和“实例声明”的边界:你期望的是“如果m满足X约束,就自动拥有Host实例”,但Haskell的类型类系统不支持这种自动推导的开放实例——实例必须显式声明,要么针对具体类型,要么通过新type包装间接实现多态。
3. 正确的设计思路
思路一:优先使用具体实例,避免开放多态
Haskell类型类设计鼓励具体、明确的实例,而非试图覆盖所有可能类型。针对实际用到的m(比如Yesod Handler、测试用STM)单独声明Host实例,既清晰又不会产生歧义。
思路二:将托管能力作为依赖注入,而非类型类实例
如果觉得类型类实例限制过多,可以把Host的操作封装成记录类型,作为依赖注入到m的环境中:
data HostOps m k s = HostOps { createOp :: s -> m k , readOp :: k -> m s , writeOp :: k -> s -> m () , updateOp :: (s -> s) -> k -> m () , deleteOp :: k -> m () } -- 针对InMemory实现HostOps inMemoryHostOps :: TVar (M.Map k s) -> HostOps STM k s inMemoryHostOps tvar = HostOps { createOp = \s -> do currentMap <- readTVar tvar let newId = -- GameId生成逻辑 updatedMap = M.insert newId s currentMap writeTVar tvar updatedMap return newId -- 其余操作同理实现 } -- 在Yesod中,将HostOps放入App环境 data App = App { gameHostOps :: HostOps (HandlerFor App) GameId GameState -- 其他环境字段 }
这种方式更灵活,无需依赖类型类实例,通过传递具体操作集实现托管能力,还能轻松切换不同实现(比如从内存改为数据库)。
思路三:结合MTL风格的默认方法
如果坚持用类型类,可以利用GHC的默认方法特性,给Host类定义基于MonadReader的默认实现,再针对具体m显式声明实例:
class MonadIO m => Host m k s where create :: s -> m k -- 定义基于MonadReader的默认实现 default create :: R.MonadReader (TVar (M.Map k s)) m => s -> m k create s = do tvar <- R.ask liftIO $ atomically $ do currentMap <- readTVar tvar let newId = -- GameId生成逻辑 updatedMap = M.insert newId s currentMap writeTVar tvar updatedMap return newId -- 其余方法同理定义默认实现 -- 针对Yesod Handler声明实例,复用默认实现 instance (R.MonadReader (TVar (M.Map GameId GameState)) (HandlerFor App), MonadIO (HandlerFor App)) => Host (HandlerFor App) GameId GameState where -- 无需手动实现方法,直接用默认逻辑
这种方式既复用了通用逻辑,又避免了开放实例的问题。
内容的提问来源于stack exchange,提问作者Ashkan Kh. Nazary

