幻影类型的动机是什么?

48

唐·斯图尔特(Don Stewart)在他的《大规模Haskell》演讲中提到了虚类型

data Ratio n = Ratio Double
1.234 :: Ratio D3

data Ask ccy = Ask Double
Ask 1.5123 :: Ask GBP

我阅读了他关于它们的要点,但我并不理解它们。此外,我还在该主题上阅读了Haskell Wiki。但我仍然没有明白它们的重点。

使用幻影类型的动机是什么?

3个回答

80
为了回答“使用幻影类型的动机是什么”,有两个要点:
  • 使无效状态不可表示,这在Aadit's answer中得到了很好的解释。
  • 在类型层面上携带一些信息。
例如,您可以通过长度单位标记距离:
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype Distance a = Distance Double
  deriving (Num, Show)

data Kilometer
data Mile

marathonDistance :: Distance Kilometer
marathonDistance = Distance 42.195

distanceKmToMiles :: Distance Kilometer -> Distance Mile
distanceKmToMiles (Distance km) = Distance (0.621371 * km)

marathonDistanceInMiles :: Distance Mile
marathonDistanceInMiles = distanceKmToMiles marathonDistance

And you can avoid Mars Climate Orbiter disaster:

>>> marathonDistanceInMiles
Distance 26.218749345

>>> marathonDistanceInMiles + marathonDistance

<interactive>:10:27:
    Couldn't match type ‘Kilometer’ with ‘Mile’
    Expected type: Distance Mile
      Actual type: Distance Kilometer
    In the second argument of ‘(+)’, namely ‘marathonDistance’
    In the expression: marathonDistanceInMiles + marathonDistance

这个“模式”有一些细微的变化。你可以使用 DataKinds 来获得一个封闭的单位集合:

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE DataKinds #-}

data LengthUnit = Kilometer | Mile

newtype Distance (a :: LengthUnit) = Distance Double
  deriving (Num, Show)

marathonDistance :: Distance 'Kilometer
marathonDistance = Distance 42.195

distanceKmToMiles :: Distance 'Kilometer -> Distance 'Mile
distanceKmToMiles (Distance km) = Distance (0.621371 * km)

marathonDistanceInMiles :: Distance 'Mile
marathonDistanceInMiles = distanceKmToMiles marathonDistance

它的工作方式类似:

>>> marathonDistanceInMiles
Distance 26.218749345

>>> marathonDistance + marathonDistance
Distance 84.39

>>> marathonDistanceInMiles + marathonDistance

<interactive>:28:27:
    Couldn't match type ‘'Kilometer’ with ‘'Mile’
    Expected type: Distance 'Mile
      Actual type: Distance 'Kilometer
    In the second argument of ‘(+)’, namely ‘marathonDistance’
    In the expression: marathonDistanceInMiles + marathonDistance

但是现在Distance只能以公里或英里为单位,之后我们无法再增加更多的单位。这在某些情况下可能会很有用。


我们还可以做:

data Distance = Distance { distanceUnit :: LengthUnit, distanceValue :: Double }
   deriving (Show)

在远距离情况下,我们可以计算加法,例如如果涉及不同的单位,则将其转换为公里。但是对于货币等比率随时间变化的内容,这种方法并不适用。
因此,我们可以使用GADTs来代替,这在某些情况下可能是更简单的方法。
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE StandaloneDeriving #-}

data Kilometer
data Mile

data Distance a where
  KilometerDistance :: Double -> Distance Kilometer
  MileDistance :: Double -> Distance Mile

deriving instance Show (Distance a)

marathonDistance :: Distance Kilometer
marathonDistance = KilometerDistance 42.195

distanceKmToMiles :: Distance Kilometer -> Distance Mile
distanceKmToMiles (KilometerDistance km) = MileDistance (0.621371 * km)

marathonDistanceInMiles :: Distance Mile
marathonDistanceInMiles = distanceKmToMiles marathonDistance

现在我们知道这个单位也是在价值层面上的:
>>> marathonDistanceInMiles 
MileDistance 26.218749345

这种方法特别简化了Aadit回答Expr a的示例:

{-# LANGUAGE GADTs #-}

data Expr a where
  Number     :: Int -> Expr Int
  Boolean    :: Bool -> Expr Bool
  Increment  :: Expr Int -> Expr Int
  Not        :: Expr Bool -> Expr Bool

值得注意的是,后一种变体需要非平凡的语言扩展(GADTsDataKindsKindSignatures),这些扩展可能在你的编译器中不受支持。这可能是唐提到的Mu编译器的情况。


@DietrichEpp修改了那部分内容,使其不那么模糊。当我写JavaScript时,例如fn.bind(this),我也遇到类似的问题——等等,bind是什么? - phadej
我在阅读SOF,突然认出了一个熟悉的面孔!感谢Oleg的详细解释! - jhegedus

16
使用幽灵类型的动机是为了特化数据构造函数的返回类型。例如,考虑以下代码:
data List a = Nil | Cons a (List a)
NilCons的返回类型默认为List a(适用于所有类型为a的列表)。请注意,保留了HTML标签。
Nil  ::                List a
Cons :: a -> List a -> List a
                       |____|
                          |
                    -- return type is generalized

请注意,Nil是一个幻构造器(即其返回类型不取决于其参数,在这种情况下虽然无意义但仍然相同)。
由于Nil是一个幻构造器,我们可以将Nil专门用于任何类型(例如Nil :: List IntNil :: List Char)。
在Haskell中,常规的代数数据类型允许您选择数据构造器的参数类型。例如,我们在上面选择了Cons的参数类型(aList a)。
但是,它不允许您选择数据构造器的返回类型。返回类型总是广义化的。这对大多数情况来说是可以接受的。但是也有例外情况。例如:
data Expr a = Number     Int
            | Boolean    Bool
            | Increment (Expr Int)
            | Not       (Expr Bool)

数据构造器的类型为:
Number    :: Int       -> Expr a
Boolean   :: Bool      -> Expr a
Increment :: Expr Int  -> Expr a
Not       :: Expr Bool -> Expr a

如您所见,所有数据构造函数的返回类型都是泛化的。这是有问题的,因为我们知道NumberIncrement必须始终返回Expr Int,而BooleanNot必须始终返回Expr Bool
数据构造函数的返回类型是错误的,因为它们过于泛化。例如,Number不可能返回Expr a,但实际上它确实返回了。这会让您编写错误的表达式,而类型检查器无法捕获。例如:
Increment (Boolean False) -- you shouldn't be able to increment a boolean
Not       (Number  0)     -- you shouldn't be able to negate a number

问题在于我们无法指定数据构造函数的返回类型。
请注意,Expr 的所有数据构造函数都是幻影构造函数(即它们的返回类型不依赖于它们的参数)。所有构造函数都是幻影构造函数的数据类型称为幻影类型。
请记住,像 Nil 这样的幻影构造函数的返回类型可以专门针对我们想要的任何类型进行特化。因此,我们可以如下创建 Expr 的智能构造函数:
number    :: Int       -> Expr Int
boolean   :: Bool      -> Expr Bool
increment :: Expr Int  -> Expr Int
not       :: Expr Bool -> Expr Bool

number    = Number
boolean   = Boolean
increment = Increment
not       = Not

现在我们可以使用智能构造函数而不是常规构造函数,这样问题就解决了:
increment (boolean False) -- error
not       (number  0)     -- error

当您想要专门化数据构造函数的返回类型时,幽灵构造函数非常有用。而幽灵类型是指其构造函数均为幽灵构造函数的数据类型。


请注意,像 LeftRight 这样的数据构造函数也是幽灵构造函数:

data Either a b = Left a | Right b

Left  :: a -> Either a b
Right :: b -> Either a b

原因是尽管这些数据构造函数的返回类型取决于它们的参数,但它们仍然是泛化的,因为它们只部分地依赖于它们的参数。
判断一个数据构造函数是否是幻影构造函数的简单方法是:
检查数据构造函数的返回类型中出现的所有类型变量是否也出现在数据构造函数的参数中。如果是,则它不是幻影构造函数。
希望这有所帮助。

谢谢 - 非常有帮助。当您提到“因此,我们可以按如下方式为Expr创建智能构造函数:”时,这是否意味着您仍将保留data Expr a = Expr Int | ...,但不会公开它们的构造函数?有什么方法可以防止某人仍然使用“非智能”构造函数吗? - Kevin Meredith
1
是的,数据声明保持不变,但构造函数不会被导出,module MyModule (Expr()、number、boolean、increment、not) where。这样可以防止人们直接使用实际的构造函数,强制他们使用智能构造函数。请注意,这也意味着人们将无法进行模式匹配。因此,您需要提供一些方法来允许他们对数据进行解构。 - Aadit M Shah
17
尽管这听起来是个好的解释,但很遗憾它是错误的。你的回答混淆了幽灵类型和广义代数数据类型(GADTs)。明确一下,幽灵类型是一种没有任何值与之相关联的类型。因此,在一个数据类型中,幽灵类型参数是一个没有被任何构造函数使用的参数。不存在所谓的“幽灵构造函数”。 - Andreas Rossberg
1
@AndreasRossberg,我很高兴今天学到了新东西。=) - Aadit M Shah

1
针对Ratio D3,我们使用类似的丰富类型来驱动类型导向的代码。例如,如果您在某个地方有一个类型为Ratio D3的字段,则其编辑器将被分派到一个仅接受数字输入并显示3位精度的文本字段中。相比之下,例如newtype Amount = Amount Double,我们不显示小数位,但使用千位逗号,并将输入解析为'10m',如'10,000,000'。
在底层表示中,两者仍然只是Double

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接