与 Haskell 参数化类型相比,对比 C# 泛型

21

根据我在StackOverflow上找到的一些建议,我正在学习Haskell。令人高兴的是,Haskell的参数化类型行为非常类似于C#通用类型。两种语言都建议使用单个字母作为类型参数(通常如此),并且两种语言似乎遵循将实际类型替换为类型参数的相似过程。由于这一点,我很快就理解了这个概念。

这导致了这个问题:Haskell的参数化类型与C#泛型类型有哪些不同之处?我知道从学习Ruby中,你可能会在认为你熟悉的一个概念在你新接触的另一种语言中也是一样时陷入麻烦。通常,如果功能确实非常相似,则麻烦更大...因为它们通常并不完全相同。那么,如果我基于对C#泛型的了解来假设我理解了参数化类型,我可能会遇到什么“坑”?

谢谢。


1
“单字母”约定仅适用于.NET中只有一个类型参数的情况 - 当存在两个参数时,您将得到类似Dictionary<TKey,TValue>的结果。不幸的是,我对Haskell了解不够,无法真正回答您的问题。 - Jon Skeet
很酷,谢谢。有时你会看到<T,U>等等。我知道微软也提供了更详细的建议......但这并不是主要问题的核心。 - Charlie Flowers
无论微软怎么建议,我宁愿使用带有<TKey,TValue>的通用类型,而不是<T,U>。生活已经够艰难了。@Charlie:也许你可以编辑你的问题并删除我们的吹毛求疵? - David Schmitt
我起初想这么做,但我无法认为你们两个的评论是废话。 - Charlie Flowers
4个回答

31

这里有一个需要记住的区别:

C#具有子类型,但Haskell没有,这意味着,一方面,通过简单地查看Haskell类型,您可以了解更多信息。

id :: a -> a

这个 Haskell 函数接受一个类型的值,并返回相同类型的同样值。如果你给它一个 Bool,它将返回一个 Bool。给它一个 Int,它将返回一个 Int。给它一个 Person,它将返回一个 Person
在 C# 中,你不能那么确定。这是在 C# 中的 '函数':
public T Id<T>(T x);

现在,由于子类型化的存在,您可以这样调用它:
var pers = Id<Person>(new Student());

虽然 pers 是类型为 Person 的,但是 Id 函数的参数却不是。实际上,pers 可能会有比只是 Person 更具体的类型。 Person 甚至可以是一个抽象类型,保证 pers 将具有更具体的类型。
如您所见,即使是像 id 这样简单的函数,.NET 类型系统已经允许比来自 Haskell 更严格的类型系统更多的东西。虽然这可能对一些编程工作有用,但它也使得通过查看事物的类型来推理程序变得更加困难(在 Haskell 中这是一种乐趣)。
另外一件事是,在 Haskell 中有一种称为“类型类”的机制,通过这种机制可以实现 特定场景下的 多态性(也称为重载)。
equals :: Eq a => a -> a -> Bool

这个函数检查两个值是否相等。但不是任意两个值,而是那些具有Eq类实例的值。这有点像C#中类型参数的约束:

public bool Equals<T>(T x, T y) where T : IComparable

然而,它们之间有一个区别。首先是子类型:您可以使用Person实例化它,并使用StudentTeacher调用它。

但是,编译后的代码也存在差异。C#代码几乎完全编译成其类型所表示的内容。类型检查器确保参数实现了适当的接口,然后您就可以使用它了。

而Haskell代码则编译成类似于以下内容:

equals :: EqDict -> a -> a -> Bool

这个函数会得到一个额外的参数,即一个字典,其中包含了它需要执行 Eq 操作所需的所有函数。下面是如何使用这个函数以及它编译成的代码:

b1 = equals 2 4          --> b1 = equals intEqFunctions 2 4
b2 = equals True False   --> b2 = equals boolEqFunctions True False

这也展示了子类型化为什么如此麻烦,想象一下如果这是可能的。
b3 = equals someStudent someTeacher
     --> b3 = equals personEqFunctions someStudent someTeacher

“personEqFunctions”字典如何判断一个“Student”是否与“Teacher”相等?它们甚至没有相同的字段。

简而言之,Haskell类型约束乍一看可能类似于.NET类型约束,但它们的实现完全不同,并编译为两个非常不同的东西。


1
哦,我从来没有错过子类化。虽然你没有子类型化,但你有封装性。因此,您可以通过将其作为字段而不是子类化它(即使在C#中实现Stack的可怕方式)来在List上方实现Stack - Tom Lokhorst
你可能会想到在C#3.0中,扩展方法可以“悬挂”在接口上的事实。这种效果有点像在接口中具有实现的接口。或者也许还有一些未来计划的功能...这并不让我感到惊讶。 - Charlie Flowers
我认为扩展方法并不是很强大。首先,你不能在实现类中重写它们(这是你可以在抽象超类中使用虚拟抽象方法所能做到的)。此外,你将有效地拥有一堆扩展方法的空接口,这似乎很奇怪。 - Tom Lokhorst
1
在C#中,只有在定义类型时才能实现接口。您可以将类型(类)定义拆分成多个文件(称为部分类)。这非常有用,特别是对于代码生成,但它比为现有类型实现接口的功能要弱。 - Tom Lokhorst
1
@Charlie:在Haskell中,类型类(type class)类似于C#中的接口(interface)。如果说你的数据类型是某个类的实例,那么就意味着该类定义的函数可以在你的数据类型上调用。在Haskell中写“instance MyClass Int”就像在C#中写“class Int32 : IMyInterface {}”。 - Tom Lokhorst
显示剩余4条评论

18
我们现在可以使用Haskell类型类做其他事情。在Haskell中搜索“generics”会打开一个更高阶的多态泛型编程领域,超出了大多数人所认为的标准参数多态性的“generics”范畴。
例如,GHC最近获得了类型族(type families),使得所有种类的有趣类型编程能力成为可能。一个非常简单的例子是针对任意多态容器的逐个数据表示决策。
我可以为例如列表等内容创建一个类。
class Listy a where

    data List a 
             -- this allows me to write a specific representation type for every particular 'a' I might store!

    empty   :: List a
    cons    :: a -> List a -> List a
    head    :: List a -> a
    tail    :: List a -> List a

我可以编写对任何实例化List类型的函数:

map :: (Listy a, Listy b) => (a -> b) -> List a -> List b
map f as = go as
  where
    go xs
        | null xs   = empty
        | otherwise = f (head xs) `cons` go (tail xs)

然而,我们从未给出特定的表示类型。

现在这是一个通用列表的类。我可以根据元素类型提供特定的巧妙表示方法。例如,对于Int列表,我可能会使用数组:

instance Listy Int where

data List Int = UArray Int Int

...

那么你就可以开始进行一些相当强大的通用编程。


2
当然,在类型族旁边,Haskell中有各种“通用编程”库来进行数据类型的通用编程。只是为了向原始提问者明确:虽然这也被称为“泛型”,但与C#泛型几乎没有关系。 - Tom Lokhorst

7
另一个重大的区别在于,C#泛型不允许在类型构造函数(即*以外的种类)上进行抽象,而Haskell可以。尝试将以下数据类型翻译为C#类:
newtype Fix f = In { out :: f (Fix f) }

我觉得我的脑袋要爆炸了。你能展示一些使用Fix的示例代码吗? - Jared Updike
嗨,贾里德,考虑一下“data Expr r = Num Int | Add r r”,然后尝试想出一些类型为“Fix Expr”的值。玩弄这个应该会让你对正在发生的事情有所感觉! - Martijn

6

为了回答这个问题中的“你可能会因为认为从一个语言中熟悉的概念在另一种你不熟悉的语言中也是相同的而陷入大麻烦”的部分:

这里有一个关键区别(与Ruby等其他语言相比),当你使用Haskell类型类时需要理解。例如,给定以下函数:

add :: Num a => a -> a -> a
add x y = x + y

这并不意味着xy都是Num类的任何类型。它意味着xy是完全相同的类型,这个类型属于Num类。"好吧,当然你会说;aa是一样的。"我也这么说,但我花了几个月的时间才停止认为,如果x是一个Inty是一个Integer,那么它就像在Ruby中添加一个FixnumBignum一样简单。相反:

*Main> add (2::Int) (3::Integer)
<interactive>:1:14: Couldn't match expected type `Int' against inferred type `Integer' In the second argument of `add', namely `(3 :: Integer)' In the expression: add (2 :: Int) (3 :: Integer) In the definition of `it': it = add (2 :: Int) (3 :: Integer)

换句话说,子类化(尽管这两个Num实例当然也是Eq的实例)和鸭子类型消失了。

这听起来非常简单和明显,但要训练自己本能地理解这一点,而不仅仅是在理论上理解,至少如果你来自多年的Java和Ruby的话,需要花费相当长的时间。

不,一旦我掌握了这个技巧,我一点也不想念子类化。(好吧,现在和那时候可能有点想念,但我得到的比失去的多。而且当我真正想念它时,我可以尝试滥用存在类型。)


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