推断两个记录中的公共字段的类型

时间:2018-02-26 18:35:59

标签: haskell purescript recordtype row-polymorphism

如果这是一个愚蠢的问题,请耐心等待。如何键入一个带有两个记录并返回其公共字段数组的泛型函数?

让我说我有:

type A = { name :: String, color :: String }
type B = { name :: String, address :: Address, color :: String }

myImaginaryFunction :: ???
-- should return ["name", "color"] :: Array of [name color]

我想编写一个函数,它使 ANY 两种类型的记录并返回一个公共字段数组。一个haskell解决方案也可以。

3 个答案:

答案 0 :(得分:5)

要在Haskell中表达具有公共字段的两种记录类型,您需要GHC扩展名:

{-# LANGUAGE DuplicateRecordFields #-}

并且要反省字段的名称,您需要基于Data类的泛型:

{-# LANGUAGE DeriveDataTypeable #-}
import Data.Data ( Data, Typeable, DataRep(AlgRep), dataTypeRep
                 , dataTypeOf, constrFields)
import Data.List (intersect)
import Data.Proxy (Proxy(..), asProxyTypeOf)

这将允许您使用相同的字段名称定义两种数据类型:

data Address = Address String deriving (Typeable, Data)
data A = A { name :: String, color :: String }
    deriving (Typeable, Data)
data B = B { name :: String, address :: Address, color :: String}
    deriving (Typeable, Data)

然后您可以使用以下方法检索字段名称:

fieldNames :: (Data t) => Proxy t -> [String]
fieldNames t = case dataTypeRep $ dataTypeOf $ asProxyTypeOf undefined t of
  AlgRep [con] -> constrFields con

并获取以下公共字段:

commonFields :: (Data t1, Data t2) => Proxy t1 -> Proxy t2 -> [String]
commonFields t1 t2 = intersect (fieldNames t1) (fieldNames t2)

之后,以下内容将起作用:

ghci> commonFields (Proxy :: Proxy A) (Proxy :: Proxy B)
["name", "color"]
ghci>

请注意,上面fieldNames的实现假定只会考虑具有单个构造函数的记录类型。如果您想要概括它,请参阅Data.Data的文档。

现在,因为你是一个帮助吸血鬼,我知道你会要求一个类型级别的功能,即使你在问题中没有说什么需要一个类型级别的功能!事实上,我可以看到你已经添加了一个关于你如何感兴趣以某种方式返回name | color数组的评论,尽管Haskell中没有这样的东西,即使你在你的问题中明确地说过你期望的学期答复["name", "color"]

尽管如此,可能还有非吸血鬼有类似的问题,也许这个答案会帮助他们。

答案 1 :(得分:4)

对于Haskell,我喜欢K.A. Buhr的答案,但我个人不会使用Typeable而是使用GHC Generics。我认为在这一点上可能会有偏好。

对于PureScript,我在本月早些时候的博文Making Diffs of differently-typed Records in PureScript中写到了这类问题。这种方法与没有行类型的语言完全不同(不,Elm没有这些。除了使用同类字符串映射之外,你真的没有其他解决方案。)

首先,如果您完全熟悉PureScript,可能需要使用Union,但这也不会有用,因为您需要执行以下操作:

Union r1' r r1

r1'将成为您的第一条记录rr1之间共享子类型r2的补充。原因是你在这里有两个未解决的变量,并且Union的功能依赖性要求解决Union的三个参数中的任何两个。

因为我们不能直接使用Union,所以我们必须制定某种解决方案。因为我可以获得按键排序的RowList结构,所以我选择使用它来遍历两个不同的记录'RowLists并走出交叉点:

class RowListIntersection
  (xs :: RowList)
  (ys :: RowList)
  (res :: RowList)
  | xs ys -> res

instance rliNilXS :: RowListIntersection Nil (Cons name ty tail) Nil
instance rliNilYS :: RowListIntersection (Cons name ty tail) Nil Nil
instance rliNilNil :: RowListIntersection Nil Nil Nil
instance rliConsCons ::
  ( CompareSymbol xname yname ord
  , Equals ord EQ isEq
  , Equals ord LT isLt
  , Or isEq isLt isEqOrLt
  , If isEq xty trashty yty
  , If isEq xty trashty2 zty
  , If isEq (SProxy xname) trashname (SProxy zname)
  , If isEq
      (RLProxy (Cons zname zty res'))
      (RLProxy res')
      (RLProxy res)
  , If isEqOrLt
      (RLProxy xs)
      (RLProxy (Cons xname xty xs))
      (RLProxy xs')
  , If isLt
      (RLProxy (Cons xname yty ys))
      (RLProxy ys)
      (RLProxy ys')
  , RowListIntersection xs' ys' res'
  ) => RowListIntersection (Cons xname xty xs) (Cons yname yty ys) res

然后我使用了一个简短的定义来获取生成的RowList的键:

class Keys (xs :: RowList) where
  keysImpl :: RLProxy xs -> List String

instance nilKeys :: Keys Nil where
  keysImpl _ = mempty

instance consKeys ::
  ( IsSymbol name
  , Keys tail
  ) => Keys (Cons name ty tail) where
  keysImpl _ = first : rest
    where
      first = reflectSymbol (SProxy :: SProxy name)
      rest = keysImpl (RLProxy :: RLProxy tail)

所以我可以一起定义一个这样的函数来获取共享标签:

getSharedLabels
  :: forall r1 rl1 r2 rl2 rl
  . RowToList r1 rl1
  => RowToList r2 rl2
  => RowListIntersection rl1 rl2 rl
  => Keys rl
  => Record r1
  -> Record r2
  -> List String
getSharedLabels _ _ = keysImpl (RLProxy :: RLProxy rl)

然后我们可以看到我们期望的结果:

main = do
  logShow <<< Array.fromFoldable $
    getSharedLabels
      { a: 123, b: "abc" }
      { a: 123, b: "abc", c: true }
  -- logs out ["a","b"] as expected

如果您是RowList / RowToList的新手,您可以考虑阅读我的RowList Fun With PureScript 2nd Edition幻灯片。

我把这个答案的代码here

如果所有这些看起来太复杂,那么您的另一个解决方案可能是将记录强制转换为字符串映射并获取密钥的集合。我不知道这是否是Elm中的答案,因为字符串映射的运行时表示可能与Record的不匹配。但对于PureScript,这是一个选项,因为StrMap的运行时表示与Record。相同。

答案 2 :(得分:2)

实际上,在考虑了这个之后,我想 可以做你实际想要在现代Haskell中做什么,如果你实际上是 想要做的是使用类型级别具有命名字段的记录类型,包括使用来自其他两个记录的公共字段来编译新记录类型的编译时。

它有点牵扯,有点丑陋,虽然有些工作得非常好。是的,当然,这是一个如此简单的任务的过多仪式,但请记住,我们正试图实施一个全新的,非平凡的类型级功能(一种依赖的结构类型)。使这个简单任务的唯一方法是从一开始就将该功能烘焙到语言及其类型系统中;否则,它会变得复杂。

无论如何,在我们获得DependentTypes扩展名之前,您必须明确启用少量(ha ha)扩展名:

{-# LANGUAGE AllowAmbiguousTypes       #-}
{-# LANGUAGE GADTs                     #-}
{-# LANGUAGE KindSignatures            #-}
{-# LANGUAGE ScopedTypeVariables       #-}
{-# LANGUAGE TemplateHaskell           #-}
{-# LANGUAGE TypeApplications          #-}
{-# LANGUAGE TypeFamilies              #-}
{-# LANGUAGE TypeInType                #-}
{-# LANGUAGE TypeOperators             #-}
{-# LANGUAGE UndecidableInstances      #-}
{-# OPTIONS_GHC -Wincomplete-patterns  #-}

module Records where

我们会对singletons软件包及其子模块进行相当多的使用:Prelude用于基本类型级函数,例如MapFstLookup; TH模块,用于生成我们自己的单例,并使用Template Haskell拼接来提升函数;和TypeLits用于处理Symbol类型(即类型级别的字符串文字)。

import Data.Singletons.Prelude
import Data.Singletons.TH
import Data.Singletons.TypeLits

我们还需要一些其他的好坏点。只需要Text,因为它是Symbol的未提升(&#34;降级&#34;)版本。

import Data.Function ((&))
import Data.Kind (Type)
import Data.List (intersect)
import qualified Data.Text as Text

我们无法使用通常的Haskell记录。相反,我们将定义Record类型的构造函数。此类型构造函数将由(Symbol, Type)对列表编制索引,其中Symbol给出字段名称,Type给出该字段中存储的值的类型。

data Record :: [(Symbol, Type)] -> Type where

这个设计决定已经有几个主要含义:

  • 不同记录类型中的相同字段名称可以引用不同的字段值类型。
  • 字段在记录中排序,因此如果记录类型具有相同的字段,但具有相同的类型,则在相同的顺序中,记录类型相同。
  • 同一字段可以在记录中多次出现,即使我们提供的访问者功能只能访问一个(最后添加的)。

在依赖类型的程序中,设计决策往往会深入。例如,如果同一个字段不能多次出现,我们需要找到一种方法来反映该类型中的那个,然后确保我们所有的功能都能够提供相应字段不适当的证据。被添加。

无论如何,回到我们的Record类型构造函数。将有两个数据构造函数,一个Record构造函数来创建一个空记录:

  Record :: Record '[]

With构造函数,用于向记录添加字段:

  With :: SSymbol s -> t -> Record fs -> Record ('(s, t) : fs)

请注意,With需要s :: Symbol的运行代表,其形式为符号单SSymbol s。便捷函数with_将使此单例隐式:

with_ :: forall s t fs . (SingI s) => t -> Record fs -> Record ('(s, t) : fs)
with_ = With sing

认为通过允许模糊类型和使用类型应用程序,我们公开了以下合理的succint语法来定义记录。此处不需要显式类型签名,但包括在内以便明确创建的内容:

rec1 :: Record '[ '("bar", [Char]), '("foo", Int)]
rec1 = Record & with_ @"foo" (10 :: Int)
              & with_ @"bar" "Hello, world"
-- i.e., rec1 = { foo = 10, bar = "Hello, world" } :: { foo :: Int, bar :: String }

rec2 :: Record '[ '("quux", Maybe Double), '("foo", Int)]
rec2 = Record & with_ @"foo" (20 :: Int)
              & with_ @"quux" (Just 1.0 :: Maybe Double)
-- i.e., rec2 = { foo = 20, quux = Just 1.0 } :: { foo :: Int, quux :: Maybe Double }

为了证明此记录类型有用,我们将定义类型安全的字段访问器。这是使用显式单例选择字段的那个:

field :: forall s t fs . (Lookup s fs ~ Just t) => SSymbol s -> Record fs -> t
field s (With s' t r)
  = case s %:== s' of
      STrue -> t
      SFalse -> field s r

和一个有牵连单身人士的帮手:

field_ :: forall s t fs . (Lookup s fs ~ Just t, SingI s) => Record fs -> t
field_ = field @s sing

旨在与类似的应用程序一起使用:

exField = field_ @"foo" rec1

请注意,尝试访问不存在的字段不会进行类型检查。错误消息并不理想,但至少它是编译时错误:

-- badField = field_ @"baz" rec1  -- gives: Couldn't match type Nothing with Just t

field的定义暗示了singletons库的强大功能。我们正在使用类型级Lookup函数,该函数已经通过模板Haskell自动生成,其术语级别定义与以下内容完全相同(取自singletons源并重命名为避免冲突):

lookup'                  :: (Eq a) => a -> [(a,b)] -> Maybe b
lookup' _key []          =  Nothing
lookup'  key ((x,y):xys) = if key == x then Just y else lookup' key xys

仅使用上下文Lookup s fs ~ Just t,GHC能够确定:

  1. 因为上下文暗示这个字段会在列表中找到,所以field的第二个参数永远不能是空记录Record,所以没有关于{的不完整模式的警告{1}},实际上,如果您尝试通过添加大小写来处理运行时错误,则会出现类型错误:field

  2. 如果我们在field s Record = error "ack, something went wrong!"分支中,则对field的递归调用是类型正确的。也就是说,GHC已经发现,如果我们能够成功SFalse列表中的密钥Lookup,但它不在头,我们必须能够在尾部查找它。

  3. (这对我来说很神奇,但无论如何......)

    这些是我们记录类型的基础知识。为了在运行时或编译时对内部名称进行内省,我们将引入一个帮助程序,我们将使用模板Haskell将其提升到类型级别(即类型级函数s): / p>

    Names

    请注意,类型级函数$(singletons [d| names :: [(Symbol, Type)] -> [Symbol] names = map fst |]) 可以提供对记录字段名称的编译时访问,例如在假设类型签名中:

    Names

    但更有可能的是,我们希望在运行时使用字段名称。使用data SomeUIType fs = SomeUIType -- a UI for the given compile-time list of fields recordUI :: Record fs -> SomeUIType (Names fs) recordUI _ = SomeUIType ,我们可以定义以下函数来获取记录并将其字段名称列表作为单例返回。在此,NamesSNil是术语SCons[]的单例等价物。

    (:)

    这是一个返回sFields :: Record fs -> Sing (Names fs) sFields Record = SNil sFields (With s _ r) = SCons s (sFields r) 而不是单身的版本。

    [Text]

    现在,如果您只想获得两个记录的公共字段的运行时列表,您可以这样做:

    fields :: Record fs -> [Text.Text]
    fields = fromSing . sFields
    

    如何在编译时创建具有公共字段的类型?好吧,我们可以定义以下函数来获得具有通用名称的左偏置字段集。 (它&#34;左偏倚&#34;在某种意义上,如果两个记录中的匹配字段具有不同的类型,它将采用第一条记录的类型。)同样,我们使用{ {1}}包和模板Haskell将其提升为类型级rec12common = intersect (fields rec1) (fields rec2) -- value: ["foo"] 函数:

    singletons

    这允许我们定义一个带有两个记录的函数,并将第一个记录缩减为与第二个记录中的字段同名的字段集:

    Common

    再一次,单身人士图书馆在这里非常了不起。我使用自动生成的$(singletons [d| common :: [(Symbol,Type)] -> [(Symbol,Type)] -> [(Symbol,Type)] common [] _ = [] common (x@(a,b):xs) ys = if elem a (map fst ys) then x:common xs ys else common xs ys |]) 类型级别函数以及单例级reduce :: Record fs1 -> Record fs2 -> Record (Common fs1 fs2) reduce Record _ = Record reduce (With s x r1) r2 = case sElem s (sFields r2) of STrue -> With s x (reduce r1 r2) SFalse -> reduce r1 r2 函数(在Common包中自动生成,来自术语级别定义sElem函数)。不知何故,通过所有这些复杂性,GHC可以确定如果singletons评估为elem,我必须在公共字段列表中包含sElem,而如果评估为STrue }, 我不应该。尝试摆弄箭头右侧的案例结果 - 如果你弄错了,你就不能让他们打字检查!

    无论如何,我可以将此功能应用于我的两个示例记录。同样,不需要类型签名,但是用于显示正在生成的内容:

    s

    与任何其他记录一样,我有运行时访问其字段名称和字段访问的编译时类型检查:

    SFalse

    请注意,通常,如果常用名称字段的顺序和/或类型不同,rec3 :: Record '[ '("foo", Int)] rec3 = reduce rec1 rec2 -- fields rec3 gives ["foo"], the common field names -- field_ @"foo" rec3 gives 10, the field value for rec1 不仅会返回不同的值,还会返回不同的类型reduce r1 r2reduce r2 r1之间。改变这种行为可能需要重新审视我之前提到的早期和影响深远的设计决策。

    为方便起见,这里是整个程序,使用Stack lts-10.5(单机版2.3.1)测试:

    r1