【问题标题】:What is a polymorphic lambda?什么是多态 lambda?
【发布时间】:2013-11-22 19:26:02
【问题描述】:

lambdas(匿名函数)的概念对我来说非常清楚。而且我知道类方面的多态性,运行时/动态调度用于根据实例的最派生类型调用适当的方法。但是 lambda 究竟如何是多态的呢?我是另一位试图了解更多关于函数式编程的 Java 程序员。

【问题讨论】:

  • 我猜它是这样一个 lambda 可以“一般地”处理,你有一些东西可以接受一个函数,并且可以像其他函数类型一样处理它。不过,这个功能的实际实现对我来说有点模糊。

标签: function haskell lambda functional-programming polymorphism


【解决方案1】:

你会发现我在下面的回答中并没有过多地谈论 lambdas。请记住,在函数式语言中,任何函数都只是一个绑定到名称的 lambda,所以我所说的函数转换为 lambda。

多态性

请注意,多态性并不真正需要 OO 语言通过覆盖虚拟方法的派生类实现的那种“调度”。那只是one particular kind of polymorphism, subtyping

多态本身仅仅意味着一个函数不仅允许一个特定类型的参数,而且能够对任何允许的类型采取相应的行动。最简单的例子:你根本不关心类型,而只是简单地处理传入的任何内容。或者,为了让它不相当那么琐碎,将它包装在一个单元素容器中。你可以在 C++ 中实现这样的功能:

template<typename T> std::vector<T> wrap1elem( T val ) {
  return std::vector(val);
}

但您无法将其实现为 lambda,因为 C++(编写时间:C++11)不支持多态 lambda。

无类型值

...至少不是这样,就是这样。 C++ 模板以一种不同寻常的方式实现多态性:编译器实际上为任何人传递给函数的每个类型生成一个单态函数,在它遇到的所有代码中。这是必要的,因为 C++ 的 值语义:当传入一个值时,编译器需要知道确切的类型(它在内存中的大小、可能的子节点等)以便生成一个副本。

在大多数较新的语言中,几乎所有内容都只是对某个值的引用,当您调用函数时,它不会获得参数对象的副本,而只是对已经-现有的。较旧的语言要求您将参数显式标记为引用/指针类型。

引用语义的一大优点是多态性变得更加容易:指针始终具有相同的大小,因此相同的机器代码可以处理对任何类型的引用。这使得非常丑陋的1,即使在 C 中也可以实现多态容器包装器:

typedef struct{
  void** contents;
  int size;
} vector;

vector wrap1elem_by_voidptr(void* ptr) {
  vector v;
  v.contents = malloc(sizeof(&ptr));
  v.contents[0] = ptr;
  v.size = 1;
  return v;
}
#define wrap1elem(val) wrap1elem_by_voidptr(&(val))

这里,void* 只是一个指向任何未知类型的指针。由此出现的明显问题:vector 不知道它“包含”什么类型的元素!所以你不能对这些对象做任何有用的事情。 除非你知道它是什么类型

int sum_contents_int(vector v) {
  int acc = 0, i;
  for(i=0; i<v.size; ++i) {
    acc += * (int*) (v.contents[i]);
  }
  return acc;
}

显然,这是非常费力的。如果类型是 double 怎么办?如果我们想要产品,而不是总和怎么办?当然,我们可以手写每个案例。不是一个好的解决方案。

如果我们有一个将指令做什么作为额外参数的通用函数,我们会更好! C 有函数指针

int accum_contents_int(vector v, void* (*combine)(int*, int)) {
  int acc = 0, i;
  for(i=0; i<v.size; ++i) {
    combine(&acc, * (int*) (v.contents[i]));
  }
  return acc;
}

然后可以像这样使用

void multon(int* acc, int x) {
  acc *= x;
}
int main() {
  int a = 3, b = 5;
  vector v = wrap2elems(a, b);
  printf("%i\n", accum_contents_int(v, multon));
}

除了仍然很麻烦之外,上面所有的 C 代码都有一个大问题:完全没有检查容器元素是否真的有正确的类型!来自*void 的演员很乐意在任何类型上触发,但毫无疑问,结果将是完全垃圾2

类和继承

这个问题是 OO 语言解决的主要问题之一,它试图将您可能执行的所有操作与数据一起在对象中作为方法进行捆绑。在编译您的类时,类型是单态的,因此编译器可以检查操作是否有意义。当您尝试使用这些值时,如果编译器知道如何找到方法,就足够了。特别是,如果您创建派生类,编译器会知道“啊哈,即使在派生对象上也可以从基类调用该方法”。

不幸的是,这意味着您通过多态实现的所有功能都等同于组合数据并简单地在单个字段上调用(单态)方法。要为不同类型实际获得不同的行为(但受控!),OO 语言需要虚拟方法。这基本上是该类具有带有指向方法实现的指针的额外字段,很像我在C示例中使用的指向combine函数的指针——不同之处在于你只能通过添加一个派生类来实现一个覆盖方法,编译器再次知道所有数据字段的类型等,你很安全。

复杂的类型系统,检查参数多态性

虽然基于继承的多态性显然有效,但我不能不说它只是疯狂愚蠢3确实有点限制。如果您只想使用一个碰巧没有作为类方法实现的特定操作,则需要创建一个完整的派生类。即使您只是想以某种方式改变一个操作,您也需要派生和覆盖该方法的稍微不同的版本。

让我们重温一下我们的 C 代码。从表面上看,我们注意到完全有可能使它成为类型安全的,没有任何方法捆绑的废话。我们只需要确保没有类型信息丢失——至少在编译时不会丢失。想象一下(将∀T读作“适用于所有类型的T”)

∀T: {
  typedef struct{
    T* contents;
    int size;
  } vector<T>;
}

∀T: {
  vector<T> wrap1elem(T* elem) {
    vector v;
    v.contents = malloc(sizeof(T*));
    v.contents[0] = &elem;
    v.size = 1;
    return v;
  }
}

∀T: {
  void accum_contents(vector<T> v, void* (*combine)(T*, const T*), T* acc) {
    int i;
    for(i=0; i<v.size; ++i) {
      combine(&acc, (*T) (v[i]));
    }
  }
}

观察,尽管 signatures 看起来很像这篇文章顶部的 C++ 模板(正如我所说,它实际上只是自动生成的单态代码),实现实际上几乎只是普通的 C。那里没有 T 值,只是指向它们的指针。无需编译多个版本的代码:在运行时,不需要类型信息,我们只处理通用指针。 在编译时,我们确实知道类型并且可以使用函数头来确保它们匹配。即,如果你写了

void evil_sumon (int* acc, double* x) { acc += *x; }

并尝试做

vector<float> v; char acc;
accum_contents(v, evil_sumon, acc);

编译器会因为类型不匹配而抱怨:在accum_contents 的声明中,它说类型可能会有所不同,但所有出现的T 都需要解析为相同 输入

这正是参数多态性在 ML 家族以及 Haskell 语言中的工作原理:这些函数实际上对它们正在处理的多态数据一无所知。但是他们被赋予了具有这种知识的专业运算符,作为参数

在像 Java 这样的语言(在 lambdas 之前)中,参数多态并没有给你带来太多好处:因为编译器故意让定义“只是一个简单的辅助函数”变得困难,以支持只有类方法,你可以简单地立即采用派生类的方式。但在函数式语言中,定义小型辅助函数是可以想象到的最简单的事情:lambda!

因此您可以在 Haskell 中编写令人难以置信的简洁代码:

Prelude> 折叠 (+) 0 [1,4,6]
11
Prelude> foldr (\x y -> x+y+1) 0 [1,4,6]
14
Prelude> let f start = foldr (\_ (xl,xr) -> (xr, xl)) start
前奏> :t f
f :: (t, t) -> [a] -> (t, t)
Prelude> f("左", "右") [1]
("右","左")
Prelude> f("左", "右") [1, 2]
("左","右")

请注意,在我定义为 f 的助手的 lambda 中,我对 xlxr 的类型一无所知,我只是想交换这些元素的元组,这需要类型要相同。所以这将是一个多态 lambda,其类型为

\_ (xl, xr) -> (xr, xl)   ::  ∀ a t.  a -> (t,t) -> (t,t)

1除了奇怪的显式malloc 东西,类型安全等:这样的代码在没有垃圾收集器的语言中非常难以使用,因为有人总是需要清理一次内存它不再需要了,但是如果您没有正确注意是否有人仍然持有对数据的引用并且实际上可能仍然需要它。这在 Java、Lisp、Haskell 中你不必担心......

2对此有一种完全不同的方法:选择一种动态语言。在这些语言中,每个操作 都需要确保它适用于任何类型(或者,如果不可能,则引发明确定义的错误)。然后你可以任意组合多态操作,一方面是“非常无故障”(虽然不像 Haskell 那样非常聪明的类型系统那样无故障),但 OTOH 会产生相当大的开销,因为即使是原始操作也需要类型决策和保护措施。

3我当然在这里不公平。 OO 范式不仅仅是类型安全的多态性,它还支持许多事情,例如带有 Hindler-Milner 类型系统的旧 ML 无法做到(即席多态性:Haskell 有类型类,SML 有模块),甚至一些在 Haskell 中相当困难的事情(主要是,将不同类型的值存储在一个可变大小的容器)。但是你越习惯于函数式编程,你就越不需要这些东西。

【讨论】:

  • 当时没有为 C++14 提出多态 lambda 表达式吗?
  • 您在 Untyped Values 标题下的代码不是合法的 C++ 且无法编译。
  • @ThomasMcLeod 是的,我有一些指针解引用错误。此外,我从未声称这是合法的 C++,它实际上是 C。
  • 哦,好吧,但它也不合法。
  • 您知道您可以自己纠正其他人帖子中的语法错误。最重要的是,这是一个概念性示例。
【解决方案2】:

在 C++ 中,从 C++14 开始的多态(或泛型)lambda 是一个可以将任何类型作为参数的 lambda。基本上它是一个具有 auto 参数类型的 lambda: auto lambda = [](auto){};

【讨论】:

    【解决方案3】:

    您是否听说过“多态 lambda”一词?我们也许可以更具体。

    lambda 可以是多态的最简单方法是接受类型与最终结果(部分)无关的参数。

    例如拉姆达

    \(head:tail) -> tail
    

    具有[a] -&gt; [a] 类型——例如它在列表的内部类型中是完全多态的。

    其他简单的例子是

    \_ -> 5      :: Num n => a -> n
    \x f -> f x  :: a -> (a -> b) -> b
    \n -> n + 1  :: Num n => n -> n
    

    等等

    (注意Num n 涉及类型类调度的示例)

    【讨论】:

    • 谷歌搜索“多态 lambda”会引发关于 C++11 lambda 及其缺乏对模板的支持的讨论 (for instance, this question)。这表明这个答案将与 OP 相关。
    • 实际上,上下文是关于 Rust 编程语言如何支持多态 lambda 的讨论,这是它非常适合函数式编程的原因之一。
    猜你喜欢
    • 1970-01-01
    • 2011-06-06
    • 1970-01-01
    • 2012-11-20
    • 1970-01-01
    • 2011-04-21
    相关资源
    最近更新 更多