Haskell的构造函数(data constructors)构造什么?

18

Haskell可以使用类型构造器和数据构造器构建代数数据类型。例如,

data Circle = Circle Float Float Float

我们被告知这个数据构造器(右侧的圆)是一个函数,当给定数据(例如x、y和半径)时可以构造一个圆。

Circle :: Float -> Float -> Float -> Circle 

我的问题是:

  1. 具体地说,这个函数实际上构造了什么?

  2. 我们能够定义构造函数吗?

我见过智能构造函数,但它们似乎只是额外的函数,最终调用常规构造函数。

作为一个面向对象的背景,构造函数当然有命令式的定义。在Haskell中,它们似乎是系统定义的。


1
总结答案如下:1. 三个浮点数值的普通记录,标记为“Circle”;2. 我们刚刚使用了data声明。 - Will Ness
3个回答

19
在Haskell中,不考虑底层实现,数据构造器通过声明来创建一个值。程序员可能会说:“让有一个圆形”,然后就有了一个圆形。问 Circle 1 2 3 创建了什么类似于在 Python 或 Java 中问字面量 1 创造了什么。空元构造器更接近于你通常认为的字面量。Boolean 类型是从字面上定义的。
data Boolean = True | False

TrueFalse不是Haskell语法中定义的字面量,而是数据构造函数。

数据类型也是构造函数的定义;由于一个值除了构造函数名称和参数之外没有什么东西,所以简单地说明它就是定义。通过使用三个参数调用数据构造函数Circle来创建Circle类型的值,就是这样。

所谓的“智能构造函数”只是调用数据构造函数的函数,可能还有一些其他逻辑来限制可以创建哪些实例。例如,考虑一个简单的Integer包装器:

newtype PosInteger = PosInt Integer
构造函数是PosInt; 一个智能构造函数可能如下所示。
mkPosInt :: Integer -> PosInteger
mkPosInt n | n > 0 = PosInt n
           | otherwise = error "Argument must be positive"

使用mkPosInt,无法创建具有非正参数的PosInteger值,因为只有正参数实际上调用数据构造函数。当智能构造函数被模块导出时,它比数据构造函数更加合理,这样典型用户就无法创建任意实例(因为数据构造函数不存在于模块外部)。


13

好问题。如您所知,根据定义:

data Foo = A | B Int

这个定义了一个带有(零元)类型构造器Foo和两个数据构造器AB的类型。

每一个数据构造器都会在被完整应用后(在A的情况下不接受参数,在B的情况下接受一个Int参数),构建出一个Foo类型的值。所以,当我写下:

a :: Foo
a = A

b :: Foo
b = B 10
ab这两个名称都绑定到类型为Foo的两个值。

因此,类型Foo的数据构造函数构造类型为Foo的值。

那么,什么是类型Foo的值呢?首先,它们与任何其他类型的值都不同。其次,它们完全由它们的数据构造函数定义。对于每个数据构造函数和传递给该数据构造函数的一组不同参数的组合,类型Foo都有一个独特的值,不同于Foo的所有其他值。也就是说,如果两个类型为Foo的值使用相同的数据构造函数给出了相同的参数集进行构造,则它们是相同的。 (此处的“相同”意味着与“相等”不同,在给定类型Foo的情况下可能没有定义,但让我们不深入探讨这个问题。)

这也是Haskell中的数据构造函数与函数不同的地方。如果我有一个函数:

bar :: Int -> Bool

可能会出现bar 1bar 2的值完全相同的情况。例如,如果通过以下方式定义了bar

bar n = n > 0

那么显而易见的是,bar 1bar 2(以及 bar 3)都是完全相同的 True。无论对于其参数的不同取值,bar 的取值是否相同将取决于函数的定义。

相反,如果 Bar 是一个构造函数:

data BarType = Bar Int

那么Bar 1Bar 2是相同的值的情况永远不会出现。按定义,它们将是不同的值(类型为BarType)。

顺便提一下,认为构造函数只是一种特殊类型的函数是一种常见观点。我个人认为这种说法不准确并且容易引起混淆。尽管构造函数通常可以像函数一样使用(特别是在表达式中时它们的行为非常类似于函数),但在我看来,这种观点经不起仔细的审查--构造函数在语言的表面语法中以不同的方式表示(具有大写标识符),可以用于函数无法用于的上下文中(例如模式匹配),在编译代码中以不同的方式表示等等。

因此,当你问“我们是否可以定义构造函数”时,答案是“不”,因为没有构造函数。相反,像ABBarCircle这样的构造函数就是它们自己--与函数不同的东西(有时候具有一些特殊的额外属性,可以像函数一样使用),能够构造属于数据构造函数所属类型的值。

这使得Haskell的构造函数与面向对象的构造函数非常不同,但这并不奇怪,因为Haskell值与面向对象的对象非常不同。在面向对象语言中,您通常可以提供一个构造函数来处理对象的构建,因此在Python中,您可能会编写:

class Bar:
    def __init__(self, n):
        self.value = n > 0

然后在此之后:

bar1 = Bar(1)
bar2 = Bar(2)

我们有两个不同的对象bar1bar2(满足bar1!= bar2),它们配置了相同的字段值,并且在某种意义上是“相等”的。 这在某种程度上介于上面的情况之间,bar 1bar 2创建了两个相同的值(即True ),以及Bar 1Bar 2创建了两个不同的值,根据定义,这些值不可能以任何方式“相同”。

您永远无法在Haskell构造函数中出现此情况。 您应该将Haskell构造函数视为附加到值的被动标记(可能还包含零个或多个其他值,具体取决于构造函数的arity),而不是将其视为运行某些基础功能来“构造”对象的标签,其中可能涉及一些酷炫的处理和派生字段值。

因此,在您的示例中,Circle 10 20 5并不会通过运行某些函数“构造”Circle类型的对象。 它直接创建一个带有标记的对象,它在内存中看起来类似于:

<Circle tag>
<Float value 10>
<Float value 20>
<Float value 5>

(或者你至少可以假装这是内存中的样子)。

在Haskell中,最接近面向对象构造函数的方式是使用智能构造函数。正如您所指出的,最终智能构造函数只会调用常规构造函数,因为那是创建特定类型值的唯一方法。无论您构建什么样的奇怪智能构造函数来创建一个Circle,它构造出的值看起来都必须像:

<Circle tag>
<some Float value>
<another Float value>
<a final Float value>

你需要使用普通的Circle构造函数来构建它。没有其他智能构造函数可以返回仍然是一个Circle的内容。这就是Haskell的工作原理。
有帮助吗?

是的,谢谢。我猜这是情况,但无法轻易地在任何地方找到确认。我认为可能有一个比构造函数更好的词可以用(特别是针对那些来自面向对象编程的人)。实际上,我认为将所有数据构造函数视为文字可能更容易,只是在一个太大以至于无法枚举的空间中:-)例如,Circle 1.0 2.0 3.0作为一个文字? - Ashley Aitken
2
是的,那似乎相当准确。当然,你可以写成 Circle (10/10) (1+1) (sqrt 9) 这样看起来不太字面意义上的,但它在很大程度上与 JavaScript 数组或对象字面量或 Python 元组字面量一样是一个字面量。 - K. A. Buhr
1
在表达式上下文中,构造函数确实是一个函数(或值)。最终,非零元构造函数被编译为(内联)分配堆对象的代码。这与另一个函数有何不同?当然,在模式上下文中,构造函数对编译器具有完全不同的含义。 - dfeuer
2
明智的人可能会有不同的看法,但我坚持认为构造函数不是一个函数,并将其称为函数(这包括声称它在一个上下文中确实是一个函数,但在另一个上下文中不是)会使差异变得微不足道,并且 - 正如这个问题所证明的那样 - 大多数情况下只会引起混淆。 - K. A. Buhr
我已经更新了我的答案,避免声称“构造函数作为函数”的观点是一个“错误”。 - K. A. Buhr
1
......在面向对象编程中,最接近 Haskell 构造函数的语言可能是 C++,使用复合数据类型,也就是记录、结构体等。伪代码如下:struct {float x,y,r} circle; main(){ struct circle c1; /* here c1 is created with its fields still _unassigned_ */ c1.x = 0.0; /* etc. */ }。Haskell 中没有“未分配”的概念,因此 c1 = Circle 0.0 1.0 2.0 可以一步创建它。对于和类型,我们可以在上述基础上添加标记联合。SICP 称之为“标记列表”;EOPL 则使用 variant-case 处理这些情况,等等。 - Will Ness

13

我打算用一个有些绕弯子的例子来回答这个问题,希望能说明我的观点,即在Haskell中,将OOP中通过“类”概念耦合在一起的几个不同想法进行了解耦。理解这一点将有助于你更轻松地将经验从OOP转化为Haskell。下面是OOP伪代码的例子:

class Person {

    private int id;
    private String name;

    public Person(int id, String name) {
        if (id == 0)
            throw new InvalidIdException();
        if (name == "")
            throw new InvalidNameException();

        this.name = name;
        this.id = id;
    }

    public int getId() { return this.id; }

    public String getName() { return this.name; }

    public void setName(String name) { this.name = name; }

}
在Haskell中:
module Person
  ( Person
  , mkPerson
  , getId
  , getName
  , setName
  ) where

data Person = Person
  { personId :: Int
  , personName :: String
  }

mkPerson :: Int -> String -> Either String Person
mkPerson id name
  | id == 0 = Left "invalid id"
  | name == "" = Left "invalid name"
  | otherwise = Right (Person id name)

getId :: Person -> Int
getId = personId

getName :: Person -> String
getName = personName

setName :: String -> Person -> Either String Person
setName name person = mkPerson (personId person) name

注意:

  • Person 类已经被翻译成一个 模块,该模块导出了与之同名的数据类型 —— 类型(用于表示域和不变量)与 模块(用于命名空间和代码组织)分离。

  • class 定义中规定为 private 的字段 idname 被翻译成了普通(公共)字段,在 Haskell 中它们被从 Person 模块的导出列表中省略而成为私有字段 —— 定义可见性分离。

  • 构造函数已被翻译成两部分:一部分是简单地初始化字段的 Person 数据构造函数,另一部分是执行验证的 mkPerson 函数 ——分配 & 初始化验证分离。由于 Person 类型被导出,但其构造函数未被导出,这是客户端构建 Person 的唯一方式——这是一种“抽象数据类型”。

  • 公共接口已被翻译成函数,这些函数由 Person 模块导出,并且之前曾经更改Person 对象的 setName 函数已成为返回一个与旧 ID 相同的新 Person 数据类型实例的函数。 面向对象编程代码中有一个bug:在 setName 中应包括 name != "" 不变量的检查;Haskell 代码可以通过使用 mkPerson 智能构造函数来避免这种情况,以确保所有 Person 值都是通过构造而有效的。所以状态转换验证也是分离的——你只需要在构建值时检查不变量,因为之后它不会再改变。

所以关于您真正的问题:

  1. 此函数实际上构造了什么?

数据类型的构造函数为值分配标记和字段的空间,将标记设置为该构造函数用于创建值的标记,并将字段初始化为构造函数的参数。你不能覆盖它,因为这个过程是完全机械化的,而且没有理由(在正常安全的代码中)这么做。这是语言和运行时的一个内部细节。

  1. 我们可以定义构造函数吗?

不可以——如果您想执行额外的验证来强制执行不变量,则应使用“智能构造函数”调用更低级别的数据构造函数。因为 Haskell 值默认情况下是不可变的,所以值可以通过构造正确;也就是说,当你没有变异时,你不需要强制执行所有状态转换是正确的,只需要所有状态本身都被正确地构造即可。而且通常您可以安排自己的类型,使得不需要智能构造函数。

您唯一可以更改生成的数据


SO的问题很严重,太依赖时间了。即使你的高质量答案只有很少的浏览量!(其中有不止一个是我): [我们添加的答案越多,未来它们变得更难被发现。并且没有机制可以让“社区”分配权重/重要性给条目,以便系统可以更好地传播它们。如果我们的目标是一组规范的Q&A条目-更不用说实际的知识发现和维护系统-那么SO肯定不行。(在我看来,根本原因是“一人一票”的态度。) cf. meta.stackoverflow.com/q/366192 :) - Will Ness
此外,我们需要对条目进行分类,以便根据其目标受众水平进行分类——新手、初学者..专家(更好的是,段落(自动)可折叠,取决于读者的水平!)。但是,SO太过严格,而meta受到不同社区的不同态度的影响,没有任何有意义的改变的希望。回顾过去,如果所有这些努力都花在了被长期放弃的Haskellwiki / Haskell Wikibook上,也许整个Haskell社区会因此受益更多。(可能需要添加某种声誉跟踪系统才能实现这一点)。唉。 - Will Ness
@WillNess:谢谢,是的,我感觉到了 - 有时候我甚至会在回答问题后暂时不点赞,这样当我通过点赞来推动问题时,我的答案就能再次获得关注。如果一个问题/答案看起来足够有用或有趣,像/r/haskell这样的更广泛的受众也可以分享它。 - Jon Purdy
不知道仅仅点赞就可以提升问题的排名。它在哪里进行?我通常会打开基于标签的搜索页面,然后选择“最新”或“活跃”。我不认为它会显示在那里。 - Will Ness
@WillNess:我认为投票算作“活动”——可能会有误。当我搜寻问题时,我通常会按“最新”浏览haskell标签的问题 - Jon Purdy

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