比较Haskell的类型系统与C#,寻找类似物

本文关键字:寻找 类似物 Haskell 类型系统 比较 | 更新日期: 2023-09-27 18:29:00

我是Haskell编程的新手。我正在尝试处理它的类、数据、实例和新类型。以下是我的理解:

data NewData = Constr1 Int Int | Constr2 String Float

与(Java 或 C#(大致相同:

class NewData {
  private int a, b;
  private string c;
  private float d;
  /* get'ers and set'ers for a, b, c and d
  ................
  */
  private NewData() { }
  private NewData(int a, int b) { 
    this.a = a;
    this.b = b;
  }
  private NewData(string c, float d) {
    this.c = c;
    this.d = d;
  }
  public static Constr1(int a, int b) {
    return new NewData(a, b);
  }
  public static Constr2(string c, float d) {
    return new NewData(c, d);
  }
}

class SomeClass a where
  method1 :: [a] -> Bool

interface SomeInterface<T> {
  public bool method1(List<T> someParam);
 }
// or
abstract class SomeClass<T> {
  public abstract bool method1(List<T> someParam);
}

instance SomeClass Int where
  method1 a = 5 == head a -- this doesn't have any meaning, though, but this is correct

   class SomeClassInstance<Int>: SomeClass {
     public bool method1(List<Int> param) {
       return param.first == 5; // I don't remember the method's name exactly, it doesn't matter
     }
   }

这些都正确吗?newtype呢,我如何在C#或Java中表示它?

比较Haskell的类型系统与C#,寻找类似物

正如其他人所说,它更像是一个受歧视的联合 - 这是一个只有C/C++程序员可能听说过的晦涩结构。

你可以在OO语言中模拟这一点,方法是为Haskell的"类型"提供一个抽象基类为每个Haskell的"构造函数"提供一个具体的子类。特别是,您的代码片段说每个NewData对象都有四个字段;这是不正确的。你可以做这样的事情:

data Stuff = Small Int | Big String Double Bool

现在,如果我写Small 5,这是一个Stuff值,里面只有 1 个字段。(它占用了该数量的内存。但是如果我Big "Foo" 7.3 True这也是一个 Stuff 类型的值,但它包含 3 个字段(并占用那么多 RAM(。

请注意,构造函数名称本身是数据的一部分。这就是为什么你可以做类似的事情

data Colour = Red | Green | Blue

现在有三个构造函数,每个构造函数的字段为零。构造函数本身就是数据。现在,C# 可以让你做

enum Colour {Red, Green, Blue}

但这实际上只是说说而已

Colour = int;
const int Red = 0;
const int Green = 1;
const int Blue = 2;

特别注意,您可能会说

Colour temp = 52;

相比之下,在Haskell中,Colour类型的变量只能包含RedGreenBlue,而这些变量绝不是整数。如果您愿意,可以定义一个函数将它们转换为整数,但这不是编译器存储它们的方式。

你对getter和setter的评论说明了这种方法的陷阱;在Haskell中,我们通常不担心getter和setter。只需定义一个类型就足以创建该类型的值并访问其内容。它有点模糊地像一个 C# struct,所有字段都标记为 public readonly 。(当我们担心getter时,我们通常称它们为"投影函数"......

在 OO 中,使用类进行封装。在 Haskell 中,您可以使用模块执行此操作。在模块内部,所有内容都可以访问所有内容(就像类可以访问自身的每个部分一样(。您可以使用导出列表来说明模块的哪些部分对外界是公开的。特别是,您可以公开类型名称,同时完全隐藏其内部结构。然后,创建或操作该类型值的唯一方法是从模块公开的函数。


你问的是newtype

好的,newtype 关键字定义了一个新的类型名称,它实际上与旧类型相同,但类型检查器认为它是新的和不同的东西。例如,Int只是一个普通数字。但如果我这样做

newtype UserID = ID Int

现在UserID是一种全新的类型,与任何事情完全无关。但在幕后,它实际上只是好老Int的另一个名字.这意味着您不能在需要Int的地方使用UserID - 也不能在需要UserID的地方使用Int。因此,您不能仅仅因为它们都是整数而将用户 ID 与其他随机数混淆。

你可以用data做完全相同的事情:

data UserID = ID Int

但是,现在我们有一个无用的UserID结构,它只包含一个指向整数的指针。如果我们使用newtype那么UserID是一个整数,而不是指向整数的结构。从程序员的角度来看,这两个定义是等价的;但在引擎盖下,newtype更有效率。

(小吹毛求疵:实际上要使你说相同

data UserID = ID !Int

这意味着整数字段是"严格"的。先别担心这个。

考虑

Haskell数据结构的另一种方法是C中的这种"可区分联合"结构:

typedef enum { constr1, constr2 } NewDataEnum;
typedef struct {
  NewDataEnum _discriminator;
  union {
      struct { int a,b; }   _ConStr1;
      struct { float a,b; } _ConStr2;
  } _union;
} NewData;

请注意,为了访问 Haskell 类型中的任何 Int 或 Float 值,您必须模式匹配构造函数,这对应于查看_discriminator字段的值。

例如,这个 Haskell 函数:

foo :: NewData -> Bool
foo (ConStr1 a b) = a + b > 0
foo (ConStr2 a b) = a * b < 3

可以实现为这个 C 函数:

int foo(NewData n) {
  switch (n._discriminator) {
    case constr1: return n._union._ConStr1.a + n._union._ConStr1.b > 0;
    case constr2: return n._union._ConStr2.a * n._union._ConStr2.b < 3;
  }
  // will never get here
}

为了完整起见,以下是使用上述 C 定义实现构造函数ConStr1

NewData ConStr1(int a, int b) {
  NewData r;
  r._discriminator = constr1;
  r._union._ConStr1.a = a;
  r._union._ConStr1.b = b;
  return r;
}

Java 和 C# 没有对联合的直接支持。在 C 联合中,联合的所有字段在包含结构中分配相同的偏移量,因此联合的大小是其最大成员的大小。我见过 C# 代码,它不担心浪费空间,只是使用struct进行联合。 这是一篇 MSDN 文章,其中讨论了如何获得 C 样式联合具有的重叠效果。

代数数据类型在很多方面与对象互补 - 一个对象容易做的事情很难用另一个做 - 因此它们不能很好地转换为OO实现也就不足为奇了。任何关于"表达问题"的讨论通常都会强调这两个系统的互补性。

对象、类型类和代数数据类型可以被认为是通过跳转表有效转移控制的不同方法,但在每种情况下,此表的位置都不同。

  • 对于对象,跳转表是对象本身的隐式成员(即_vptr
  • 对于类型类,跳转表作为单独的参数传递给函数 - 即它与数据分开
  • 对于代数数据类型,跳转表直接编译到函数中 - 即上面示例中的 switch 语句

最后,应该强调的是,在Haskell中,您指定的代数数据类型(ADT(的实现细节很少。可判别的联合构造是具体考虑ADT的有用方法,但Haskell编译器不需要以任何特定方式实现它们。

使总和类型像

data NewData = Constr1 Int Int | Constr2 String Float

我通常在 c# 中执行以下操作

interface INewDataVisitor<out R> {
    R Constr1(Constr1 constructor);
    R Constr2(Constr2 constructor);
}
interface INewData {
    R Accept<R>(INewDataVisitor<R> visitor);
}
class Constr1 : INewData {
    private readonly int _a;
    private readonly int _b;
    Constr1(int a, int b) {
         _a = a;
         _b = b;
    }
    int a {get {return _a;} }
    int b {get {return _b;} }
    R Accept<R>(INewDataVisitor<R> visitor) {
        return visitor.Constr1(this);
    }
}
class Constr2 : INewData {
    private readonly string _a;
    private readonly float _b;
    Constr2(string a, float b) {
         _a = a;
         _b = b;
    }
    string a {get {return _a;} }
    float b {get {return _b;} }
    R Accept<R>(INewDataVisitor<R> visitor) {
        return visitor.Constr2(this);
    }
}

这在类型安全方面并不完全相同,因为 INewData 也可以null,可能永远不会对访问者调用方法而只返回default(R),可能会多次调用访问者,或任何其他愚蠢的事情。

一个 c# 接口,如下所示

interface SomeInterface<T> {
  public bool method1(List<T> someParam);
 }

真的更像是哈斯克尔的以下内容:

data SomeInterface t = SomeInterface {
    method1 :: [t] -> bool
}

Haskell的数据类型与任何特定的C#构造都不完全相同。你能希望的最好的结果是模拟一些功能。最好以自己的方式理解Haskell类型。但我会试一试。

我手边没有 C# 编译器,但我指的是文档,希望能产生接近正确的内容。如果有人向我指出错误,我稍后会进行编辑以修复错误。

首先,Haskell中的代数数据类型最接近于OO类族,而不是单个类。父类是完全抽象的,除了区分具体子类的单个字段。该类型的所有公共用户必须接受父类,然后通过鉴别器字段执行案例分析,并对鉴别器指示的更具体的子类进行类型转换。

class NewData {
  // every piece of NewData may take one of two forms:
  static enum Constructor { C1, C2 }
  // each piece of data has a discriminator tag; this is the only structure
  // they all have in common.
  Constructor discriminator;
  // can't construct a NewData directly
  private NewData() {}
  // private nested subclasses for the derived types
  private class Constr1Class : NewData {
    int a, b;
    Constr1Class(int a, int b) {
      this.discriminator = NewData.C1;
      this.a = a;
      this.b = b;
    }
  }
  private class Constr2Class : NewData {
    string c;
    float d;
    Constr2Class(string c, float d) {
      this.discriminator = NewData.C2;
      this.c = c;
      this.d = d;
    }
  }
  // A bunch of static functions for creating and extracting
  // I'm not sure C# will be happy with these, but hopefully it is clear
  // that they construct one of the derived private class objects and
  // return it as a parent class object
  public static NewData Constr1(int a, int b) {
    return new Constr1Class(a, b);
  }
  public static NewData Constr2(string c, float d) {
    return new Constr2Class(c, d);
  }
  // We can't directly get at the members since they don't exist
  // in the parent class; we could define abstract methods to get them,
  // but I think that obscures what's really happening. You are expected
  // to check the discriminator field first to ensure you won't get a
  // runtime type cast error.
  public static int getA(NewData data) {
    Constr1Class d1 = (Constr1Class)data;
    return d1.a;
  }
  public static int getB(NewData data) {
    Constr1Class d1 = (Constr1Class)data;
    return d1.b;
  }
  public static string getC(NewData data) {
    Constr2Class d2 = (Constr2Class)data;
    return d2.c;
  }
  public static float getD(NewData data) {
    Constr2Class d2 = (Constr2Class)data;
    return d2.d;
  }
}

毫无疑问,你会批评这是糟糕的OO代码。当然是! Haskell的代数数据类型并不声称是面向对象意义上的对象。但它至少应该让你了解ADT是如何工作的。

至于类型类,它们与面向对象的类没有任何关系。如果你眯着眼睛,它们看起来有点像 C# 接口,但事实并非如此!首先,类型类可以提供默认实现。类型类分辨率也是纯静态的;它与运行时调度无关,因为将调用的函数都是在编译时确定的。有时,将使用的类型类的实例取决于函数调用的返回类型,而不是任何参数。您最好不要尝试将其翻译成OO术语,因为它们不是一回事。

GHC 的类型类实现实际上是通过创建一个字典来工作的,该字典作为隐式参数传递给在其签名中具有类型类约束的函数。 也就是说,如果类型看起来像Num a => a -> a -> a,编译器将传递一个额外的参数,其中包含用于在该调用站点用作a的实际类型的特定于Num函数的字典。因此,如果使用Int参数调用该函数,它将获得一个额外的字典参数,其中包含来自 Num Int 实例的函数。

实质上,签名表示"只要您可以在 Num 类型类中为要使用的类型提供操作,此函数就是多态的",并且编译器确实将它们作为函数的额外参数提供。

话虽如此,GHC有时能够完全优化整个额外的字典参数,只内联必要的函数。