Haskell类型与数据构造函数

149

我正在从learnyouahaskell.com学习Haskell。我不太理解类型构造器和数据构造器的区别,例如,我不太理解以下两者之间的区别:

data Car = Car { company :: String  
               , model :: String  
               , year :: Int  
               } deriving (Show) 

还有这个:

data Car a b c = Car { company :: a  
                     , model :: b  
                     , year :: c   
                     } deriving (Show)  

我理解第一个只是使用一个构造函数(Car)来构建类型为Car的数据。但我不太理解第二个。

还有,像这样定义的数据类型是如何定义的:

data Color = Blue | Green | Red

如何使这一切融合在一起?

我理解第三个例子(Color)是一种类型,可以有三个状态:BlueGreenRed。但这与我对前两个示例的理解相矛盾:难道类型Car只能有一个状态,即Car,可以采用各种参数进行构建吗?如果是这样,第二个例子怎么适合呢?

基本上,我正在寻找一个解释,将上述三个代码示例/结构统一起来。


23
你的车辆示例可能有点混淆,因为Car既是类型构造函数(在等号左侧)又是数据构造函数(在右侧)。在第一个示例中,Car类型构造函数不带参数,在第二个示例中需要三个参数。在两个示例中,Car数据构造函数都需要三个参数(但在一个示例中这些参数的类型是固定的,在另一个示例中则是参数化的)。 - sshine
1
第一个是使用一个数据构造器(Car :: String -> String -> Int -> Car)来构建类型为Car的数据。第二个是使用一个数据构造器(Car :: a -> b -> c -> Car a b c)来构建类型为Car a b c的数据。 - Will Ness
6个回答

281

在一个 data 声明中,类型构造器 是等号左边的部分,而 数据构造器 则是等号右边的部分。当需要一个类型时,我们使用类型构造器,而当需要一个值时,我们使用数据构造器。

数据构造器

为了简单起见,我们可以举一个代表颜色的类型的例子。

data Colour = Red | Green | Blue

这里,我们有三个数据构造函数。 Color 是一种类型,Green 是一个构造函数,它包含了一种 Color 类型的值。同样地,RedBlue 都是构造函数,用于构造 Color 类型的值。不过我们可以想象让它更加丰富多彩!

data Colour = RGB Int Int Int

我们仍然只有类型 Colour,但是 RGB 不是一个值 - 它是一个接受三个 Ints 并 返回 值的函数!RGB 的类型为

RGB :: Int -> Int -> Int -> Colour

RGB是一个数据构造函数,它接受一些作为参数,然后使用这些值构造一个新值。如果您有过面向对象编程的经验,应该会认识到这一点。在面向对象编程中,构造函数也接受一些值作为参数并返回一个新值!

在这种情况下,如果我们将RGB应用于三个值,我们将得到一个颜色值!

Prelude> RGB 12 92 27
#0c5c1b

我们已经通过应用数据构造器构建了一个颜色类型的。数据构造器要么包含像变量一样的值,要么以其他值作为其参数并创建一个新的。如果您之前有编程经验,这个概念对您来说应该不会很陌生。

中场休息

如果您想构建一个二叉树来存储字符串,您可以考虑执行以下操作:

data SBTree = Leaf String
            | Branch String SBTree SBTree

这里展示的是一个名为SBTree的类型,它包含两个数据构造函数。换句话说,有两个函数(即LeafBranch)会构造SBTree类型的值。如果您不熟悉二叉树的工作原理,请耐心等待。实际上,您并不需要知道二叉树如何工作,只需知道它以某种方式存储String

此外,我们还可以看到两个数据构造函数都需要一个String参数,这是要在树中存储的字符串。

但是!如果我们还想存储Bool,就必须创建一个新的二叉树。它可能看起来像:

data BBTree = Leaf Bool
            | Branch Bool BBTree BBTree

类型构造器

SBTreeBBTree都是类型构造器。但是有一个明显的问题。你有没有注意到它们有多么相似?这表明您真正需要在某个地方使用参数。

所以我们可以这样做:

data BTree a = Leaf a
             | Branch a (BTree a) (BTree a)
现在我们将一个类型变量a作为类型构造器的参数进行介绍。在这个声明中,BTree已经成为了一个函数。它接受一个类型作为参数,并返回一个新的类型。
需要注意的是,这里重要的区别是具体类型(例如Int、[Char]和Maybe Bool)可以被赋值给程序中的值,而类型构造函数则需要提供一个类型才能赋值给某个值。一个值永远不能是"list"类型,因为它必须是"something的list"类型。同样地,一个值永远不能是"binary tree"类型,因为它必须是"存储something的binary tree"类型。
如果我们将Bool作为BTree的参数传递进去,它将返回类型BTree Bool,它是一个存储Bools的二叉树。替换每个类型变量a为类型Bool,你就会发现它是正确的。
如果你愿意,你可以将BTree视为带有kind的函数。
BTree :: * -> *
Kinds类似于类型——*表示具体类型,因此我们说BTree是从具体类型到具体类型。 数据构造函数使用参数很酷,如果我们想要在值中有轻微变化-我们将这些变化作为参数放入,并让创建值的人决定他们将要放入哪些参数。同样地,如果我们想要类型中有轻微的变化,则带参数的类型构造函数很好用!我们将这些变化作为参数放置,并让创建类型的人决定他们将要放入哪些参数。最后,让我们以Maybe a类型为案例考虑。其定义为
data Maybe a = Nothing
             | Just a

在这里,Maybe是一个返回具体类型的类型构造器。 Just 是一个返回值的数据构造器。 Nothing是包含一个值的数据构造器。如果我们看一下Just的类型,我们会发现

Just :: a -> Maybe a

换句话说,Just 接受一个类型为 a 的值,并返回一个类型为 Maybe a 的值。如果我们查看 Maybe 的种类,我们会发现:

Maybe :: * -> *

换句话说,Maybe接受一个具体类型并返回一个具体类型。

再次强调!具体类型和类型构造函数之间的区别。您不能创建 Maybe 的列表 - 如果尝试执行此操作,则会出现错误。

[] :: [Maybe]

如果你创建一个Maybe类型的列表,你会得到一个错误。然而,你可以创建一个Maybe Int或者Maybe a的列表。这是因为Maybe是一种类型构造函数,但是列表需要包含具体类型的值。Maybe IntMaybe a是具体的类型(或者说是调用返回具体类型的类型构造函数)。


2
在你的第一个示例中,RED、GREEN和BLUE都是不带参数的构造函数。 - OllieB
3
data Colour = Red | Green | Blue中声称“我们根本没有任何构造函数”是完全错误的。类型构造函数和数据构造函数不需要带参数,例如http://www.haskell.org/haskellwiki/Constructor指出,在 data Tree a = Tip | Node a (Tree a) (Tree a) 中,“有两个数据构造函数Tip和Node”。 - Frerich Raabe
1
@CMCDragonkai 在标准的Haskell中,实际上不可能有一个空的数据声明。但是有一个GHC扩展(-XEmptyDataDecls)可以让你这样做。由于,正如你所说,没有该类型的值,例如函数f :: Int -> Z可能永远不会返回(因为它会返回什么?)。然而,它们可以在你想要类型但并不真正关心值时非常有用。 - kqr
1
你的例子中突然出现了十六进制代码,这是怎么回事?Prelude> RGB 12 92 27 #0c5c1b。那只是为了说明吗? - Nima Mousavi
1
@ftor,我知道你在说什么,很抱歉!这是一个非常好的观察结果,可能值得发表一个独立的SO问题。我这样理解:有两个层面的“基于上下文确定具体类型”。一级——类型类——根据运行时上下文确定具体类型。另一个层面——类型推断——涉及到Nothing :: Maybe a 情况,使用已知静态信息。编译器根据上下文为 a 选择类型,但这种类型仅仅是编译时的注释而已。它仍然为所有情况使用相同的 Nothing。所以是和不是? :D - kqr
显示剩余20条评论

51

Haskell有代数数据类型,这是其他很少有的语言所拥有的。这可能是你感到困惑的原因。

在其他语言中,通常可以创建一个“记录”、“结构体”或类似的东西,其中包含一堆具有不同数据类型的命名字段。有时还可以创建一个“枚举”,其中有一组(小)可选值(例如,Red, GreenBlue)。

在Haskell中,您可以同时组合这两种类型。奇怪但却是真实的!

为什么它被称为“代数”?好吧,书呆子们谈论“和类型”和“积类型”。例如:

data Eg1 = One Int | Two String

Eg1 值基本上是一个整数或一个字符串。因此,所有可能的 Eg1 值的集合是所有可能的整数值和所有可能的字符串值的“总和”。因此,技术专家将 Eg1 称为“总和类型”。另一方面:

data Eg2 = Pair Int String

每个 Eg2 值都由一个整数和一个字符串组成。因此,所有可能的Eg2值的集合是所有整数的集合和所有字符串的集合的笛卡尔积。这两个集合相乘,因此这是一种“乘积类型”。
Haskell 的代数类型是“乘积类型的和类型”。您可以给构造函数多个字段来制作乘积类型,并且您可以有多个构造函数来制作和(乘积)。
例如,为了说明为什么这可能很有用,假设您有某个输出数据为 XML 或 JSON,它需要一个配置记录 - 但显然,XML 和 JSON 的配置设置完全不同。所以您可能会像这样做:
data Config = XML_Config {...} | JSON_Config {...}

(当然,需要适当地添加一些字段。)在普通编程语言中无法执行此类操作,这就是为什么大多数人不习惯它的原因。


4
好的!只有一件事,“它们可以用几乎任何语言构建”,维基百科说。在 C++ 等语言中,使用标签标识的 union 就是一种实现方式。 - Will Ness
5
是的,但每次我提到“联合体”时,人们都会看着我,像是在说:“谁会用那个东西?”;-) - MathematicalOrchid
1
我在我的C语言职业生涯中看到了很多union的使用。请不要让它听起来是不必要的,因为事实并非如此。 - daparic

32

从最简单的情况开始:

data Color = Blue | Green | Red

这定义了一个“类型构造器”Color,它不需要任何参数 - 并且有三个“数据构造器”,BlueGreenRed。所有的数据构造器都不需要任何参数。这意味着有三个Color类型: BlueGreenRed

当您需要创建某种值时,可以使用数据构造器,例如:

myFavoriteColor :: Color
myFavoriteColor = Green

使用 Green 数据构造器创建一个名为 myFavoriteColor 的值 - 而且 myFavoriteColor 的类型将是 Color,因为这是由数据构造器产生的值的类型。

当你需要创建某种类型时,可以使用类型构造器。这通常是在编写签名时使用的:

isFavoriteColor :: Color -> Bool
在这种情况下,您正在调用Color类型构造函数(它不需要任何参数)。
你还跟上了吗?
现在,想象一下,你不仅想创建红/绿/蓝值,而且还想指定"强度"。例如,介于0和256之间的值。您可以通过向每个数据构造函数添加一个参数来实现此目的,以便您最终得到:
data Color = Blue Int | Green Int | Red Int

现在,这三个数据构造函数都需要一个Int类型的参数。类型构造函数(Color)仍然不需要任何参数。所以,我最喜欢的颜色是深绿色,我可以写成:

    myFavoriteColor :: Color
    myFavoriteColor = Green 50

然后,它再次调用Green数据构造函数,我就会得到一个Color类型的值。

想象一下,如果你不想规定人们如何表达颜色的强度。有些人可能想要像我们刚才做的那样使用数值。其他人可能只需要一个布尔值来表示“明亮”或“不太明亮”。解决方法是不要在数据构造函数中硬编码Int,而是使用类型变量:

data Color a = Blue a | Green a | Red a

现在,我们的类型构造器接受一个参数(另一个我们称为 a 的类型!),并且所有数据构造器都将接受 a 类型的一个参数(值!)。所以你可以有:

myFavoriteColor :: Color Bool
myFavoriteColor = Green False
或者
myFavoriteColor :: Color Int
myFavoriteColor = Green 50

注意我们如何使用一个参数(另一个类型)调用 Color 类型构造函数来获得将由数据构造函数返回的“有效”类型。这涉及到kind的概念,您可能需要一杯或两杯咖啡的时间去了解。

现在我们已经弄清楚了数据构造函数和类型构造函数是什么,以及数据构造函数可以将其他值作为参数,类型构造函数可以将其他类型作为参数。希望对您有所帮助。


@kqr:数据构造器可以是零元的,但那样它就不再是一个函数了。函数是接受参数并返回值的东西,即在签名中带有->的东西。 - Frerich Raabe
一个值可以指向多种类型吗?还是每个值只与一个类型相关联,就是这样? - CMCDragonkai
1
这对于像我这样的Haskell门外汉来说,绝对是最有用的答案。所以人们可以将类型构造器类比于C#或Java中的泛型? - jrg
2
@jrg 有些重叠,但不是特别因为类型构造函数而是因为类型变量,例如在 data Color a = Red a 中的 aa 是任意类型的占位符。你也可以在普通函数中使用相同的方法,例如类型为 (a,b) -> a 的函数接受两个值的元组(类型为 ab),并返回第一个值。它是一种“通用”函数,因为它不指定元组元素的类型 - 它仅指定函数返回与第一个元组元素相同类型的值。 - Frerich Raabe
1
现在,我们的类型构造器接受一个参数(另一个我们称之为a的类型!),并且所有的数据构造器都将接受一个该类型a的参数(一个值!)。这非常有帮助。 - Jonas

6

正如其他人指出的那样,多态在这里并不是非常有用。让我们看另一个你可能已经熟悉的例子:

Maybe a = Just a | Nothing

这种类型有两个数据构造函数。Nothing有点无聊,它不包含任何有用的数据。另一方面,Just包含一个值a - 无论a可能具有什么类型。让我们编写一个使用此类型的函数,例如获取Int列表的头部,如果有的话(我希望您同意这比抛出错误更有用):

maybeHead :: [Int] -> Maybe Int
maybeHead [] = Nothing
maybeHead (x:_) = Just x

> maybeHead [1,2,3]    -- Just 1
> maybeHead []         -- None

所以在这种情况下,a 是一个 Int,但是它同样适用于任何其他类型。实际上,您可以使我们的函数适用于每种类型的列表(即使不改变实现方式):

maybeHead :: [t] -> Maybe t
maybeHead [] = Nothing
maybeHead (x:_) = Just x

另一方面,您可以编写仅接受特定类型的Maybe的函数,例如:

doubleMaybe :: Maybe Int -> Maybe Int
doubleMaybe Just x = Just (2*x)
doubleMaybe Nothing= Nothing

长话短说,通过多态性(polymorphism),你使自己的类型具有与不同其他类型的值一起工作的灵活性。
在你的例子中,你可能会在某些时候决定使用"String"不足以标识公司,而需要它拥有自己的类型"Company"(它包含了额外的数据,如国家、地址、银行账户等)。你的第一个实现"Car"需要更改,使用"Company"代替"String"作为其第一个值。你的第二个实现很好,你可以将它用作"Car Company String Int",它将像以前一样工作(当然需要更改访问公司数据的函数)。

你能在另一个数据声明的数据上下文中使用类型构造函数吗?比如 data Color = Blue ; data Bright = Color?我在ghci中尝试了一下,似乎类型构造函数中的Color与Bright定义中的Color数据构造函数没有任何关系。这里只有两个Color构造函数,一个是Data,另一个是Type。 - CMCDragonkai
@CMCDragonkai 我认为你无法这样做,而且我甚至不确定你想要以此达到什么目的。你可以使用 datanewtype 来“包装”现有类型(例如 data Bright = Bright Color),或者您可以使用 type 定义同义词(例如 type Bright = Color)。 - Landei
你好,乡下人。我是城市麻雀。作为一个Haskell初学者,我“理解”这个——有点——但只是因为我费尽了周折阅读了《The Little MLer》,它直接进入了这种使用类型、类型构造器的奇怪世界,作为一种递归、“超脱”的糖衣λ演算编程。这些东西并不容易,而且在任何地方都没有很好的解释,即这种突然使用发明的类型作为实际表达式。如果其他方法都失败了,请休息一下,试试《The Little MLer》。即使ML是一种不同的语言,从类型系统的角度来看,它与Haskell几乎相同。 - 147pm

5
第二种方法中有“多态”的概念。
a b c 可以是任何类型。例如,a可以是[String],b可以是[Int],c可以是[Char]。
而第一种方法的类型是固定的:company是String,model是String,year是Int。
汽车示例可能并没有展示使用多态的重要性。但是想象一下你的数据是列表类型。一个列表可以包含String、Char、Int等。在这些情况下,你需要第二种定义数据的方式。
至于第三种方法,我认为它不需要适应之前的类型。这只是Haskell中定义数据的另一种方式。
这是我作为一个初学者的谦虚意见。
顺便说一下:确保你训练好自己的大脑,并对此感到舒适。这是理解Monad的关键。

2

这段内容是关于数据类型的:在第一个例子中,你设置了String类型(用于公司和型号)和Int类型(用于年份)。而在第二个例子中,更加通用。 abc可以与第一个例子中完全相同的类型,也可以是完全不同的类型。例如,将年份作为字符串而不是整数可能会很有用。如果需要,甚至可以使用Color类型。


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