vlambda博客
学习文章列表

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 [Stringa)
  deriving (FunctorApplicativeMonad)

我们将 [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 (FunctorApplicativeMonad)

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 (FunctorApplicativeMonad)

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 中声明数据并以多态方式使用数据的简单性,对我来说是一个关键的卖点!