Haskell 数据类型之五:Haskell 类型族
原文:Type Families in Haskell (https://mmhaskell.com/haskell-data/type-families)
欢迎来到关于 Haskell 数据类型系列的结尾部分!在本系列中,我们已经讨论了很多内容,展示了 Haskell 简单性。我们将 Haskell 与其他语言进行了比较,在这些语言中我们可以发现其语法更繁琐。在这最后一部分中,将研讨一些更复杂的东西。我们将快速探索类型族的概念。先从一些相关类型的想法演变开始,再看一个简单的示例。
这是一个初学者系列,但最后的这部分的内容会有点复杂。如果代码示例令人困惑,那么先阅读我们的 Monads 系列 (https://www.mmhaskell.com/monads)会有所帮助!但如果您刚刚起步,我们还有很多其他资源可以帮助您!看看我们的入门清单 (https://www.mmhaskell.com/beginners-checklist)或 Liftoff 系列 (https://www.mmhaskell.com/liftoff)!
您可以在 Github 库 (https://github.com/MondayMorningHaskell/HaskellData) 中查阅这些代码示例!只需阅读类型族模块 (https://github.com/MondayMorningHaskell/HaskellData/blob/master/haskell/TypeFamilies.hs)!
类型孔(Type Holes)
到目前为止,在本系列中,就类型或类定义来说,我们已经看到了几种不同的『插入孔』的方法。在本系列的第 3 部分中,我们探讨了参数化类型。这些将类型变量作为其定义的一部分。可以将每个类型变量视为需要用另一个类型填充的孔。
然后在第 4 部分,我们探讨了类型类的概念。对于类型类的任何实例,我们都是在插入该类的函数定义的孔。我们用特定类型的函数实现来填充每个孔。
在最后一部分中,我们将结合这些想法来实现类型族!类型族是一个增强类,其中我们填充的一个或多个『孔』实际上是一个类型!这允许我们将不同的类型相互关联。获得的结果是,我们可以编写特殊类型的多态函数。
示例:Logger
注意,这是一个刻意在本文用于展示的示例。我们想要一个日志类型类。将其称之为 MyLogger
。这个类中有两个主要函数。一个函数是能够按时间顺序获取日志中的所有消息。另一个函数是能够记录一条新消息,这自然是有副作用的,会影响到日志类型。第一个实现可能是这样的:
class MyLogger logger where
prevMessages :: logger -> [String]
logString :: String -> logger -> logger
稍作修改一下,使用 State
monad 而不是将 logger 作为参数传递:
class MyLogger logger where
prevMessages :: logger -> [String]
logString :: String -> State logger ()
但这个类有一个重要的缺陷。我们将无法产生与日志记录相关的任何效果。如果我们想将日志消息保存在数据库中,通过网络发送它,或者将它输出到控制台,该怎么办?如下所示,我们依旧是有方法做到的,同时仍然保持 prevMessages
纯净性:
class MyLogger logger where
prevMessages :: logger -> [String]
logString :: String -> StateT logger IO ()
现在 logString
函数可以使用任意效果。但这里还是有一个明显的缺点,它迫使我们必须使用 IO
monad。如果我们的日志处理不需要 IO
,那么上面的定义强迫使用 IO
就非常之不友好了。那我们该怎么办?
使用 Monad
我们可以做的一点是,让日志器本身变为 monad!那么,获取之前的消息的函数需要转换一下。对此,我们就不一定需要绑定到 State
monad 了:
class (Monad m) => MyLoggerMonad m where
prevMessages :: m [String]
logString :: String -> m ()
假设一下,我们想让用户能够灵活地使用各种类型,而不只是使用字符串列表,来作为消息系统的『状态』。也许他们想要使用时间戳(timestamps)或日志文件信息(log file information)。我们希望将此类型与 monad 本身联系起来,以便可以在不同的函数签名中使用它。也就是说,我们想用一个特定类型来填充我类实例中的『孔』。我们如何做到这一点呢?
类型族基础
一个解决方案是,让类成为一个类型族。我们使用类定义中的 type 关键字来实现这一点。首先,我们需要一些语言编译指令来实现这一点:
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TypeFamilies #-}
现在,我们将在类中创建一个类型,该类型引用我们将使用的状态。我们必须用定义来描述类型的『种类』(kind)。由于我们的状态是一个不带参数的普通类型,所以它的种类是 *
。定义如下所示:
class (Monad m) => MyLoggerMonad m where
type LogState m :: *
retrieveState :: m (LogState m)
logString :: String -> m ()
retrieveState
将生成我们指定为 LogState
的任何类型,而不是始终返回字符串列表。现在的状态更加通用了,将使用函数 retrieveState
来代替原来的 prevMessages
。
实例演示
现在有了类,让我们创建一个 monad 来实现它吧!第一个示例很简单,用 State
包装字符串列表,而不使用 IO
:
newtype ListWrapper a = ListWrapper (State [String] a)
deriving (Functor, Applicative, Monad)
我们将 [String]
指定为有状态类型。然后检索它就像使用 get
一样简单,添加一条消息会将它推到列表的头部。
instance MyLoggerMonad ListWrapper where
type LogState ListWrapper = [String]
retrieveState = ListWrapper get
logString msg = ListWrapper $ do
prev <- get
put (msg : prev)
使用此 monad 的函数可以访问所有 logString
函数,并检索其状态:
produceStringsList :: ListWrapper [String]
produceStringsList = do
logString "Hello"
logString "World"
retrieveState
要运行此单子操作,我们必须回到使用 State
monad 的基础知识了。(同样,我们的 Monads 系列更深入地对细节进行了相关解释)。至此,可以生成一个纯字符串列表了。
listWrapper :: [String]
listWrapper = runListWrapper produceStringsList
runListWrapper :: ListWrapper a -> a
runListWrapper (ListWrapper action) = evalState action []
使用 IO
现在我们可以制作两个不同的版本,都将使用到 IO
。在第一个示例中,我们将使用 map 而不是列表来作为『状态』。每条新消息都会有一个与之关联的时间戳,这将需要 IO
。当记录一个字符串时,我们将获取当前时间,并将该字符串与该时间一起存储在 map 中。
type TimeMsgMap = M.Map UTCTime String
newtype StampedMessages a = StampedMessages (StateT TimeMsgMap IO a)
deriving (Functor, Applicative, Monad)
instance MyLoggerMonad StampedMessages where
type LogState StampedMessages = TimeMsgMap
retrieveState = StampedMessages get
logString msg = StampedMessages $ do
ts <- lift getCurrentTime
lift (print ts)
prev <- get
put (M.insert ts msg prev)
然后我们可以制作另一个版本,将消息记录在文件中。monad 将使用 ReaderT
来跟踪文件名,并在需要记录消息或产生更多输出时打开该文件:
newtype FileLogger a = FileLogger (ReaderT FilePath IO a)
deriving (Functor, Applicative, Monad)
instance MyLoggerMonad FileLogger where
type LogState FileLogger = [String]
retrieveState = FileLogger $ do
fp <- ask
(reverse . lines) <$> lift (readFile fp)
logString msg = FileLogger $ do
lift (putStrLn msg) -- Print message
fp <- ask -- Retrieve log file
lift (appendFile fp (msg ++ "\n")) -- Add new message
我们还可以使用 IO
将消息输出到控制台。
实际使用
如上面定义的类,现在可以编写一个多态函数,其可以使用任何类型的日志器!若在签名中使用约束,可以在签名中使用 LogState
作为另一种类型!
useAnyLogger :: (MyLoggerMonad m) => m (LogState m)
useAnyLogger = do
logString "Hello"
logString "World"
logString "!"
retrieveState
runListGeneric :: [String]
runListGeneric = runListWrapper useAnyLogger
runStampGeneric :: IO TimeMsgMap
runStampGeneric = runStampWrapper useAnyLogger
这真的太棒了,我们的代码现在具体的效果中抽象出来了。不管有或没有 IO
monad,都可以使用它。
与其他语言相比
说到效果(effect),Haskell 的类型系统通常比其他语言更难使用。在 Java 或 Python 中,任意效果都可能产生。因此,我们不必考虑将效果与类型相匹配。
但是我们不要忘记 Haskell 效果系统所产生的好处!代码的所有部分,我们都非常清楚知道哪些代码产生了哪些效果。这使我们能够在编译时就确定哪些地方有可能出现某些问题。
类型族获得了两全其美的效果!它们允许我们编写多态代码,可以使用或不使用 IO
。这真的很酷,尤其是当您想要有不同的测试和开发设置时。
当涉及到相互关联类型和对这些关系应用编译时约束时,Haskell 显然是赢家。在 C++ 中同时可以达到此功能,但语法非常痛苦且标新立异。在 Haskell 中,类型族是一个比较难于理解的复杂概念。但是,一旦您对这个概念有了深入的了解,实际上语法会显示相当直观。它本身源自于现有的类型类机制,这是一个很大的优势。
总结
这就是我们关于 Haskell 数据系统系列的全部内容!现在已经看到了各种各样的概念,从简单到复杂。我们将 Haskell 与其他语言进行了比较。再次声明,在 Haskell 中声明数据并以多态方式使用数据的简单性,对我来说是一个关键的卖点!