在类和记录之间做出选择

34

基础问题:在选择使用类还是使用记录(带有多态字段)时,应遵循哪些设计原则?

首先,我们知道类和记录本质上是相等的(因为在Core中,类被解糖成字典,而字典就是记录)。然而,它们之间存在差异:类是隐式传递的,而记录必须是显式的。

进一步地,当:

  1. 我们有许多不同的“相同事物”的表示,并且
  2. 在实际使用中,可以推断出使用哪种表示的时候,类真正有用。

当我们只有一个数据表示方式(直到参数多态性),但有多个实例时,类会变得笨拙。这导致了使用newtype添加额外标签(仅存在于我们的代码中,因为我们知道这些标签在运行时被删除)的语法噪音,如果我们不想开启所有麻烦的扩展(即重叠或不可判定的实例)。

当然,事情变得更加复杂:如果我想对我的类型加上约束呢?让我们选一个真实的例子:

class (Bounded i, Enum i) => Partition a i where
    index :: a -> i

我同样可以轻松地这样做

data Partition a i = Partition { index :: a -> i}

但现在我丢失了我的约束条件,我需要将它们添加到特定的函数中。

有哪些设计指南可以帮助我?


你也可以在记录中保留约束条件:data Partition a i = Partition { index :: a -> i, iBounded :: Bounded i, iEnum :: Enum i } - Rotsor
@Rotsor - 很好。我想我也可以放入虚拟值(即未定义),只要我能提供约束的证人。我需要进行一些实验,看看以这种方式编码是否“舒适”。 - Jacques Carette
12
使用 GADT 语法来表示约束,data Partition x y where Partition :: (Bounded i, Enum i) => { index :: a -> i } :: Partition a i. (注:GADT 是 Generalized Algebraic Data Type(广义代数数据类型)的缩写,是 Haskell 的一个扩展,用于定义更精确、更灵活的数据类型。上述代码定义了一个名为 Partition 的类型构造器,它有两个类型参数 xy,并且有一个带约束的构造函数 Partition,其中 index :: a -> i 是一个函数,它将类型为 a 的值映射到类型为 i 的索引。) - Daniel Fischer
1
对于大多数用途,我倾向于不将约束条件附加到数据类型上。我发现我很少实际需要它们。当然,你的情况可能不同。此外,您考虑使用隐式参数扩展了吗? - John L
1
@JohnL:没有,我没有。而且我碰巧不喜欢那个特定的扩展。但我同意它在讨论的设计空间内是“在范围内”的。 - Jacques Carette
这里有一篇关于这个问题的文章:https://lukepalmer.wordpress.com/2010/01/24/haskell-antipattern-existential-typeclass/,主张在可能的情况下优先使用函数而不是更高级的类型特性。 - Josh.F
3个回答

7
我倾向于只在函数上要求约束,这样做并没有问题。问题是,你的数据结构可能不再准确地模拟你的意图。另一方面,如果你首先将其视为数据结构,那么这就应该变得不那么重要。
我觉得我对这个问题还没有一个很好的掌握,这是一个非常模糊的问题,但我的经验法则是类型类是遵循规律(或者模拟意义)的东西,而数据类型是编码了一定数量信息的东西。
当我们想以复杂的方式层次化行为时,我发现类型类开始很吸引人,但很快就会变得痛苦起来,并且切换到字典传递可以使事情更加简单明了。也就是说,当我们希望实现具有互操作性时,我们应该回到统一的字典类型。
这是第二次尝试,扩展了一个具体的例子,但仍然只是在思考中...
假设我们想对实数建模概率分布。有两种自然表示方法。
A)基于类型类
class PDist a where
        sample :: a -> Gen -> Double

B) 基于字典的

data PDist = PDist (Gen -> Double)

前者让我们做到

data NormalDist = NormalDist Double Double -- mean, var
instance PDist NormalDist where...

data LognormalDist = LognormalDist Double Double
instance PDist LognormalDist where...

后者让我们能够做到:

mkNormalDist :: Double -> Double -> PDist...
mkLognormalDist :: Double -> Double -> PDist...

在前者中,我们可以写

data SumDist a b = SumDist a b
instance (PDist a, PDist b) => PDist (SumDist a b)...

在后者中,我们可以简单地编写:
sumDist :: PDist -> PDist -> PDist

那么这些权衡有哪些呢?基于类型类可以让我们指定我们所拥有的分布。然而,权衡就是我们必须明确地构建一个分布代数,包括它们组合的新类型。基于数据驱动则不能限制我们所拥有的分布(甚至无论它们是否符合规范),但作为回报,我们可以随心所欲地进行操作。
此外,我们可以相对容易地编写一个parseDist :: String -> PDist,但我们必须经历一些痛苦才能用类型类方法实现等价物。
因此,在某种意义上,这是另一层面上的类型化/非类型化静态/动态权衡。不过我们可以加以改变,并认为类型类连同相关的代数法则指定了概率分布的语义。PDist类型也可以成为类型类PDist的一个实例。同时,我们几乎可以在任何地方都使用PDist类型(而不是类型类),并将其视为与需要使用类型类更“丰富”的实例和数据类型之间的转换关系。
事实上,我们甚至可以将基本的PDist函数定义为类型类函数的“术语”。即mkNormalPDist m v = PDist (sample $ NormalDist m v)。因此,在设计空间中有很多调整两个表示之间的余地...

也许你正在做正确的事情。如果你能用一些例子来说明类和记录的好坏使用,那就更好了。 - Jacques Carette
所以,在设计空间中,在必要时在两种表示之间滑动的空间非常大,这几乎是我的问题起始点!您确实详细说明了一些权衡(除了我提到的一些权衡)。我正在尝试找出指导设计空间中决策过程的设计原则。也许我们可以从显而易见的东西开始:为什么Monad最好作为类型类?一个Person(http://learnyouahaskell.com/making-our-own-types-and-typeclasses)最好作为记录? - Jacques Carette
@Jacques -- 我的观点更多是认为这不是一个非此即彼的选择。问题更多地涉及到动态与静态行为,以及在编译时可以检查多少的权衡。当 X 只有一种方式(最多 iso)或一次成为 Y 的方式时,数据类型似乎是合适的,而当存在(潜在地无限)许多关联 X 和 Y 的方式时,则记录更为适合。这是一个有些主观的奇怪问题,因为我认为你对这些话题的判断力至少和大多数人一样好,甚至更好。 - sclv
当你把所有东西放在一起时,我的意思是,数据是函数,函数是数据,类是记录,类是控制结构,函数是控制结构,数据是控制结构...我发现很难表达出一个正确的方法--这更多地成为了情况和品味的问题。我想,这正是问题所在... - sclv
还有一个想法——最通用的解决方案难道不是无标签的吗? :-) - sclv
是的,这个“品味问题”正是关键所在。虽然我对无标签解决方案有好感,但这意味着放弃了太多宿主语言的特性... - Jacques Carette

4

注意:我不确定是否完全理解了原帖。欢迎提出建议/改进意见!


背景:

当我第一次学习Haskell中的类型类时,我学到的一般经验法则是,与类似Java的语言相比:

  • 类型类类似于接口
  • data类似于类

这里还有一个关于使用接口的指南和答案(也包括使用接口的一些缺点)。我的理解:

  • 记录/Java类是某个东西的实现
  • 接口/类型类是一个具体实现可以扮演的角色
  • 多个不相关的具体实现可以扮演相同的角色

我猜你已经知道所有这些。


我尝试遵循自己代码的准则是:

  • 类型类是用来抽象的
  • 记录是用来实现具体功能的

因此,在实践中,这意味着:

  • 让数据的需求决定记录
  • 让客户端代码决定接口--客户端应该依赖于抽象,从而推动类型类的创建和设计

示例:

类型类Show,带有函数show :: (Show s) => s -> String:用于可以表示为String的数据。

  • 客户端只想将数据转换为字符串
  • 客户端不关心数据(具体实现)是什么--只关心它是否可以表示为字符串
  • 实现数据的角色:可以转换为字符串
  • 如果没有类型类,这是无法实现的--每种数据类型都需要一个不同名称的转换函数,多麻烦啊!

我希望很快能够添加更多的例子并重构答案 -- 我认为我没有很好地表达我的观点。 - Matt Fenwick
谢谢,这似乎是“朝着正确的方向前进”。期待重构和更多的示例。 - Jacques Carette
@JacquesCarette--在重新阅读您的问题后,我觉得我可能回答了错误的问题。您的问题是否特别询问某个数据类型具有类型类的多个实例的情况(例如,列表+ Applicative:还有ZipList)?我认为这更多是一般的类型类与数据问题。糟糕!!! - Matt Fenwick
问题实际上是关于一般情况(即设计指南),而不是示例的特定情况。 - Jacques Carette

1

类型类有时可以提供额外的类型安全性(例如,OrdData.Map.union)。如果您有类似的情况,选择类型类可能会帮助您的类型安全性 - 那么请使用类型类。

我将提供一个不同的例子,我认为类型类不会提供额外的安全性:

class Drawing a where
    drawAsHtml :: a -> Html
    drawOpenGL :: a -> IO ()

exampleFunctionA :: Drawing a => a -> a -> Something
exampleFunctionB :: (Drawing a, Drawing b) => a -> b -> Something

没有什么 exampleFunctionA 能做到而 exampleFunctionB 做不到(我觉得很难解释为什么,欢迎分享见解)。

在这种情况下,我看不到使用类型类的好处。

(根据 Jacques 的反馈和 missingo 的问题进行了编辑)


2
虽然这是一个有趣的例子,但它并不能成为让我做出选择的“设计指南”,不是吗?它只是表明,在某些情况下(你没有定义),类更好。我已经知道这一点了。我正在寻找“设计指南”。 - Jacques Carette
1
当函数表在运行时构建时怎么办?这是否会对您“尽量使用类型类”建议造成限制? - hugomg
1
我的第一条评论是关于答案的一个版本,该版本已被编辑以删除“示例”。 - Jacques Carette
@missingno:我已经修订了我的问题,并用一个示例类来回答你的问题,我认为使用类型类并不能带来任何好处,与记录相比。 - yairchu
@JacquesCarette:我无法提供具体的指导方针(希望其他人能够提供),但我认为示例可以给出“直观感受”,而这种“直观感受”有时可以带来类似于指导方针的好处。 - yairchu
1
@yairchu:我很感激你的尝试。 - Jacques Carette

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