C#接口和Haskell类型类的区别

6

我知道这里有一个类似的问题,但我想看到一个例子,清楚地展示了您不能使用interface而可以使用Type Class做什么。

为了比较,我将给出一个示例代码:

class Eq a where 
    (==) :: a -> a -> Bool
instance Eq Integer where 
    x == y  =  x `integerEq` y

C# 代码:

interface Eq<T> { bool Equal(T elem); }
public class Integer : Eq<int> 
{
     public bool Equal(int elem) 
     {
         return _elem == elem;
     }
}

如果理解不正确,请纠正我的示例


反问:你能提供C#中的默认实现吗?你能创建像Show a => MyClass a这样的约束接口吗?以及能够在容器类型上工作的仿函接口? - epsilonhalbe
我不知道,你告诉我吧 :) 你能给个完整的例子吗?否则我就不明白了。 - giokoguashvili
我相信在Haskell类型类中可以有默认实现 - C#接口本质上是一个合同,规定了类将提供定义的方法/属性。 - PaulF
@PaulF 在这种情况下抽象类怎么样?或者Java接口,我们可以有默认实现,据我所知。 - giokoguashvili
这可能是更贴切的类比 - 抽象类本身不能被实例化,可以定义必须在派生类中定义的抽象方法和可被覆盖的虚方法(=默认实现)。 - PaulF
在Haskell中,我们可以不继承就实例化一个class Eq吗?还是我理解错了什么。我对Haskell不太了解,因此我想看到一个优点的例子。 - giokoguashvili
4个回答

10

类型类是基于类型解析的,而接口分派则针对显式接收器对象进行。类型类参数会隐式提供给函数,而在C#中提供的对象则是显式提供的。例如,您可以编写以下使用 Read 类的 Haskell 函数:

readLine :: Read a => IO a
readLine = fmap read getLine

你可以将其用作:

readLine :: IO Int
readLine :: IO Bool

需要有编译器提供的适当的read实例。

您可以尝试使用接口来模拟C#中的Read类,例如:

public interface Read<T>
{
    T Read(string s);
}

然而,ReadLine的实现需要一个参数来指定你想要的Read<T>“实例”:

public static T ReadLine<T>(Read<T> r)
{
    return r.Read(Console.ReadLine());
}
Eq类型类要求两个参数具有相同的类型,而你的Eq接口则不需要,因为第一个参数隐式地成为接收器的类型。例如,你可以这样写:
public class String : Eq<int>
{
    public bool Equal(int e) { return false; }
}

有一些内容无法使用Eq表示。接口隐藏了接收器的类型,从而隐藏了其中一个参数的类型,这可能会导致问题。假设您有一个类型类和适用于不可变堆数据结构的接口:

class Heap h where
  merge :: Ord a => h a -> h a -> h a

public interface Heap<T>
{
    Heap<T> Merge(Heap<T> other);
}

合并两个二叉堆只需要O(n)的时间复杂度,而合并两个二项式堆可以在O(n log n)内完成,而斐波那契堆则只需要O(1)的时间。实现堆接口的人不知道其他堆的真正类型,因此被迫使用次优算法或使用动态类型检查来发现它。相反,实现Heap类型类的类型则知道表示形式。

2
你能否用C#编写伪代码,这段代码在C#中不可行,但在Haskell中可以实现? - giokoguashvili
据我理解,如果我们有一个 T sort(T[], Comparer<T>) 函数,并且在此之后实现了 Comparer<int> 并调用 sort(integers),编译器会检查是否存在类型为 Comparer<int> 的实现,如果存在,则注入到方法中,否则会出现构建错误。我理解的对吗? - giokoguashvili
我读了一篇关于Scala的文章,其中给出了函数def sort[T](elements: Seq[T])(implicit comparator: Comparer[T]): Seq[T]的正确定义,并且像这样调用该函数sort(integers) - giokoguashvili
4
@kogoia - 是的,Haskell会隐式地为您提供类型类实例。Scala有隐式参数,可以用来模拟类型类,不过Haskell每种类型只允许一个类型类实例,而Scala的方法允许您拥有多个实例,例如您可以有多个Comparer[Int]实例。Haskell要求您为想要定义的每个实例定义一个新类型(例如,请参见Data.Monoid中的ProductSum)。 - Lee
我自己试着回答这个问题,你能看一下并编辑一下解释中是否有错误吗?谢谢您的回复。 - giokoguashvili
显示剩余2条评论

7
一个C#接口定义了必须实现的一组方法。一个Haskell类型类定义了必须实现的一组方法(以及可能对某些方法进行默认实现的一组方法)。因此,它们有很多相似之处。
我想一个重要的区别是,在C#中,接口是一种类型,而Haskell将类型和类型类视为严格分开的东西。
关键的区别在于,在C#中定义类型(即编写类)时,您确定了它实现的接口,并且这个决定是永久性的。在Haskell中,您可以随时向现有类型添加新的接口。
例如,如果我在C#中编写一个新的SerializeToXml接口,那么我就不能使double或String实现该接口。但是在Haskell中,我可以定义我的新SerializeToXml类型类,然后使所有标准内置类型都实现该接口(如Bool、Double、Int等)。
另一件事是Haskell中多态的工作方式。在面向对象的语言中,您会根据调用对象的方法类型进行调度。在Haskell中,方法实现的类型可以出现在类型签名的任何位置。尤其是,read方法在您想要的返回类型上进行调度,这通常在面向对象语言中甚至无法使用函数重载完成。
此外,在C#中很难说“这两个参数必须具有相同的类型”。不过,面向对象是基于Liskov替换原则的;从Customer派生的两个类应该是可互换的,因此为什么要限制两个Customer对象都必须是相同类型的客户呢?
想一想,面向对象语言在运行时进行方法查找,而Haskell在编译时进行方法查找。这并不是显而易见的,但是Haskell多态实际上更像C++模板而不是通常的面向对象多态。(但这与类型类没有特别关系,它只是Haskell执行多态的方式。)

对我来说,实现所有这些都很困难。我们在日常工作中需要多少次?这个优势值得花费多少时间呢? - giokoguashvili
你能否用C#写出伪代码,虽然在C#中无法实现,但在Haskell中却可以实现吗? - giokoguashvili

5

其他人已经提供了出色的答案。

我只想补充一个关于它们之间差异的实际示例。假设我们想要建模一个“向量空间”类型类/接口,其中包含2D、3D等向量的常见操作。

在 Haskell 中:

class Vector a where
   scale :: a -> Double -> a
   add :: a -> a -> a

data Vec2D = V2 Double Double
instance Vector (Vec2D) where
   scale s (V2 x y) = V2 (s*x) (s*y)
   add (V2 x1 y1) (V2 x2 y2) = V2 (x1+x2) (y2+y2)

-- the same for Vec3D

在C#中,我们可能会尝试以下错误的方法(希望我语法没错)
interface IVector {
   IVector scale(double s);
   IVector add(IVector v);
}
class Vec2D : IVector {
   double x,y;
   // constructor omitted
   IVector scale(double s) { 
     return new Vec2D(s*x, s*y);
   }
   IVector add(IVector v) { 
     return new Vec2D(x+v.x, y+v.y);
   }
}

我们有两个问题。
首先,`scale`仅返回一个`IVector`,而不是实际的`Vec2D`子类型。这很糟糕,因为缩放不保留类型信息。
其次,`add`的类型不正确!我们不能使用`v.x`,因为`v`是任意的`IVector`,可能没有`x`字段。
事实上,接口本身是错误的:`add`方法承诺任何向量都必须能够与任何其他向量相加,因此我们必须能够对2D和3D向量求和,这是荒谬的。
通常的解决方案是切换到F-bounded quantification AKA CRTP或者现在被称为什么。
interface IVector<T> {
   T scale(double s);
   T add(T v);
}
class Vec2D : IVector<Vec2D> {
   double x,y;
   // constructor omitted
   Vec2D scale(double s) { 
     return new Vec2D(s*x, s*y);
   }
   Vec2D add(Vec2D v) { 
     return new Vec2D(x+v.x, y+v.y);
   }
}

第一次程序员遇到这个问题时,通常会对看似“递归”的行 Vec2D : IVector<Vec2D> 感到困惑。我当然也是 :) 然后我们习惯了这个,并将其视为惯用解决方案。
类型类在这里可能有一个更好的解决方案。

0
经过长时间的研究,我找到了一个简单易懂的解释方法。至少对我来说很清晰。
想象一下我们有一个签名为这样的方法。
public static T[] Sort(T[] array, IComparator<T> comparator) 
{
    ...
}

IComparator的实现:

public class IntegerComparator : IComparator<int> { }

然后我们可以像这样编写代码:

var sortedIntegers = Sort(integers, new IntegerComparator());

我们可以改进这段代码,首先创建一个Dictionary<Type, IComparator>并填充它:

var comparators = new Dictionary<Type, IComparator>() 
{
    [typeof(int)]    = new IntegerComparator(),
    [typeof(string)] = new StringComparator() 
}

Redesigned IComparator interface so that we could write like above

public interface IComparator {}
public interface IComparator<T> : IComparator {}

接下来让我们重新设计Sort方法的签名

public class SortController
{
    public T[] Sort(T[] array, [Injectable]IComparator<T> comparator = null) 
    {
        ...
    }
}

正如您所了解的,我们将注入 IComparator<T>,并编写以下代码:

new SortController().Sort<int>(integers, (IComparator<int>)_somparators[typeof(int)])

正如你已经猜到的那样,除非我们概述实现并添加Dictionary<Type, IComparator>,否则此代码将无法适用于其他类型。

请注意,我们将只在运行时看到异常

现在想象一下,如果编译器在构建期间为我们完成了这项工作,并且如果它找不到具有相应类型的比较器,则抛出异常。

为此,我们可以帮助编译器并添加一个新关键字,而不是使用属性。我们的Sort方法将如下所示:

public static T[] Sort(T[] array, implicit IComparator<T> comparator) 
{
    ...
}

并实现具体比较器的代码:

public class IntegerComparator : IComparator<int> implicit { }

请注意,我们使用关键字“implicit”,在此之后编译器将能够执行我们上面编写的常规工作,并且异常将在编译时抛出。
var sortedIntegers = Sort(integers);

// this gives us compile-time error
// because we don't have implementation of IComparator<string> 
var sortedStrings = Sort(strings); 

给这种实现方式起个名字,类型类

public class IntegerComparator : IComparator<int> implicit { }

我希望我理解得正确,并且能够清楚地解释。

附注:这段代码并不打算运行。


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