为什么要设计一种具有独特匿名类型的语言?

Why design a language with unique anonymous types?

这是一直困扰我的C++ lambda表达式的一个特性:C++ lambda表达式的类型是唯一的和匿名的,我根本写不下来。即使我创建了两个在语法上完全相同的 lambda,生成的类型也被定义为不同的。结果是,a) lambda 只能传递给允许编译时间的模板函数,不可描述的类型与对象一起传递,以及 b) lambda 只有在通过 std::function<> 擦除类型后才有用.

好吧,但这就是 C++ 的工作方式,我准备将其作为该语言的一个令人厌烦的特性而注销。然而,我刚刚了解到 Rust 似乎也是这样做的:每个 Rust 函数或 lambda 都有一个唯一的匿名类型。现在我想知道:为什么?

那么,我的问题是:
从语言设计者的角度来看,将唯一的匿名类型的概念引入语言有什么好处?

首先,没有捕获的 lambda 可以转换为函数指针。所以它们提供了某种形式的通用性。

现在为什么带捕获的 lambda 不能转换为指针?因为函数必须访问 lambda 的状态,所以这个状态需要作为函数参数出现。

C++ lambdas 需要 不同的类型用于不同的操作,因为 C++ 是静态绑定的。它们只有 copy/move-constructable,所以大多数情况下您不需要命名它们的类型。但这只是一个实现细节。

我不确定 C# lambda 是否有类型,因为它们是“匿名函数表达式”,并且它们会立即转换为兼容的委托类型或表达式树类型。如果是的话,它可能是一个无法发音的类型。

C++ 也有匿名结构,其中每个定义都会导致一个唯一的类型。这里的名字不是不可读的,就标准而言,它根本不存在。

C# 有 anonymous data types,它小心地禁止从它们定义的范围中逃脱。该实现也为它们提供了一个独特的、无法发音的名称。

匿名类型向程序员发出信号,告诉他们他们不应该在他们的实现中四处闲逛。

旁白:

可以为 lambda 的类型命名。

auto foo = []{}; 
using Foo_t = decltype(foo);

如果你没有任何捕获,你可以使用函数指针类型

void (*pfoo)() = foo;

(添加到 Caleth 的回答中,但太长无法放入评论。)

lambda 表达式只是匿名结构(Voldemort 类型,因为你不能说出它的名字)的语法糖。

您可以在这段代码中看到匿名结构和 lambda 的匿名性之间的相似性:

#include <iostream>
#include <typeinfo>

using std::cout;

int main() {
    struct { int x; } foo{5};
    struct { int x; } bar{6};
    cout << foo.x << " " << bar.x << "\n";
    cout << typeid(foo).name() << "\n";
    cout << typeid(bar).name() << "\n";
    auto baz = [x = 7]() mutable -> int& { return x; };
    auto quux = [x = 8]() mutable -> int& { return x; };
    cout << baz() << " " << quux() << "\n";
    cout << typeid(baz).name() << "\n";
    cout << typeid(quux).name() << "\n";
}

如果这对于 lambda 仍然不令人满意,那么它对于匿名结构也应该同样不令人满意。

有些语言允许一种更灵活的鸭子类型,即使 C++ 的模板并不能真正帮助从具有可替换 lambda 的成员字段的模板创建对象直接使用而不是使用 std::function 包装器。

Lambda 不仅仅是函数,它们是函数和状态。因此,C++ 和 Rust 都将它们实现为带有调用运算符的对象(C++ 中的 operator(),Rust 中的 3 Fn* 特征)。

基本上,C++ 中的 [a] { return a + 1; } 会脱糖成

struct __SomeName {
    int a;

    int operator()() {
        return a + 1;
    }
};

然后在使用 lambda 的地方使用 __SomeName 的实例。

在 Rust 中,|| a + 1 在 Rust 中会脱糖成

{
    struct __SomeName {
        a: i32,
    }

    impl FnOnce<()> for __SomeName {
        type Output = i32;
        
        extern "rust-call" fn call_once(self, args: ()) -> Self::Output {
            self.a + 1
        }
    }

    // And FnMut and Fn when necessary

    __SomeName { a }
}

这意味着 大多数 lambdas 必须不同的 类型。

现在,我们有几种方法可以做到这一点:

  • 使用匿名类型,这是两种语言都实现的。另一个结果是 all lambdas must 有不同的类型。但是对于语言设计者来说,这有一个明显的优势:Lambdas 可以使用语言中其他已经存在的更简单的部分来简单地描述。它们只是语言中已有部分的语法糖。
  • 使用一些特殊语法来命名 lambda 类型:然而,这不是必需的,因为 lambda 已经可以与 C++ 中的模板或泛型和 Rust 中的 Fn* 特征一起使用。这两种语言都不会强制您对 lambda 进行类型擦除以使用它们(在 C++ 中使用 std::function 或在 Rust 中使用 Box<Fn*>)。

另请注意,两种语言都同意不捕获上下文的普通 lambda 可以 转换为函数指针。


使用更简单的功能来描述语言的复杂功能是很常见的。例如,C++ 和 Rust 都有 range-for 循环,它们都将它们描述为其他功能的语法糖。

C++ 定义

for (auto&& [first,second] : mymap) {
    // use first and second
}

相当于

{

    init-statement
    auto && __range = range_expression ;
    auto __begin = begin_expr ;
    auto __end = end_expr ;
    for ( ; __begin != __end; ++__begin) {

        range_declaration = *__begin;
        loop_statement

    }

} 

Rust 定义

for <pat> in <head> { <body> }

相当于

let result = match ::std::iter::IntoIterator::into_iter(<head>) {
    mut iter => {
        loop {
            let <pat> = match ::std::iter::Iterator::next(&mut iter) {
                ::std::option::Option::Some(val) => val,
                ::std::option::Option::None => break
            };
            SemiExpr(<body>);
        }
    }
};

虽然对于人类来说它们看起来更复杂,但对于语言设计者或编译器来说都更简单。

Why design a language with unique anonymous types?

因为在某些情况下名称是无关紧要的,没有用甚至适得其反。在这种情况下,抽象出它们的存在的能力是有用的,因为它减少了名称污染,并解决了计算机科学中的两个难题之一(如何命名事物)。出于同样的原因,临时对象也很有用。

lambda

唯一性不是什么特殊的 lambda 事物,甚至不是匿名类型的特殊事物。它也适用于语言中的命名类型。考虑以下:

struct A {
    void operator()(){};
};

struct B {
    void operator()(){};
};

void foo(A);

请注意,我无法将 B 传递给 foo,即使 class 是相同的。 属性 同样适​​用于未命名类型。

lambdas can only be passed to template functions that allow the compile time, unspeakable type to be passed along with the object ... erased via std::function<>.

lambda 子集还有第三种选择:非捕获 lambda 可以转换为函数指针。


请注意,如果匿名类型的限制对用例来说是一个问题,那么解决方案很简单:可以改用命名类型。 Lambda 不会做任何不能用命名 class.

做的事情

许多标准(尤其是 C++)都采用最小化它们对编译器的要求的方法。坦率地说,他们的要求已经够多了!如果他们不必指定某些东西来使其工作,他们倾向于让它实现定义。

如果 lambda 不是匿名的,我们就必须定义它们。这将不得不说很多关于如何捕获变量的信息。考虑 lambda [=](){...} 的情况。该类型必须指定 lambda 实际捕获了哪些类型,这可能很难确定。另外,如果编译器成功优化了一个变量怎么办?考虑:

static const int i = 5;
auto f = [i]() { return i; }

优化编译器可以很容易地识别出可以捕获的 i 的唯一可能值是 5,并将其替换为 auto f = []() { return 5; }。但是,如果类型不是匿名的,这可能会更改类型 强制编译器优化较少,存储 i 即使它实际上并不需要它。这是一大堆复杂性和细微差别,对于 lambda 的目的来说根本不需要。

而且,在您确实需要非匿名类型的情况下,您始终可以自己构造闭包 class,并使用函子而不是 lambda 函数。因此,他们可以让 lambda 处理 99% 的情况,让您在 1% 中编写自己的解决方案。


Deduplicator 在评论中指出,我没有像处理匿名性那样处理唯一性。我不太确定唯一性的好处,但值得注意的是,如果类型是唯一的(动作将被实例化两次),下面的行为是明确的。

int counter()
{
    static int count = 0;
    return count++;
}

template <typename FuncT>
void action(const FuncT& func)
{
    static int ct = counter();
    func(ct);
}

...
for (int i = 0; i < 5; i++)
    action([](int j) { std::cout << j << std::endl; });

for (int i = 0; i < 5; i++)
    action([](int j) { std::cout << j << std::endl; });

如果类型不是唯一的,我们必须指定在这种情况下应该发生什么行为。这可能很棘手。在这种情况下,关于匿名主题提出的一些问题也为了唯一性而提出丑陋的头。

避免名称与用户代码冲突。

即使两个具有相同实现的 lambda 也会有不同的类型。这没关系,因为即使它们的内存布局相同,我也可以有不同类型的对象。

很好,但我认为关于可实施性还有更重要的一点。

假设我有两个不同的翻译单元,“one.cpp”和“two.cpp”。

// one.cpp
struct A { int operator()(int x) const { return x+1; } };
auto b = [](int x) { return x+1; };
using A1 = A;
using B1 = decltype(b);

extern void foo(A1);
extern void foo(B1);

foo 的两个重载使用相同的标识符 (foo) 但具有不同的错位名称。 (在 POSIX-ish 系统上使用的 Itanium ABI 中,损坏的名称是 _Z3foo1A,在这种特殊情况下,_Z3fooN1bMUliE_E。)

// two.cpp
struct A { int operator()(int x) const { return x + 1; } };
auto b = [](int x) { return x + 1; };
using A2 = A;
using B2 = decltype(b);

void foo(A2) {}
void foo(B2) {}

C++编译器必须确保“two.cpp”中void foo(A1)的错位名称与[=22=的错位名称相同] in "one.cpp",这样我们就可以link把两个目标文件放在一起。这就是两种类型“同一类型”的物理意义:它本质上是关于单独编译的目标文件之间的 ABI 兼容性。

C++ 编译器不需要 来确保B1B2 是“同一类型”。 (事实上​​ ,需要确保它们是不同的类型;但现在这并不重要。)


编译器使用什么物理机制来确保A1A2是“同一类型”?

它只是通过 typedef 进行挖掘,然后查看类型的完全限定名称。这是一个名为 A 的 class 类型。 (好吧,::A,因为它在全局命名空间中。)所以在这两种情况下它都是相同的类型。这很容易理解。更重要的是,实施 很容易。要查看两个 class 类型是否为同一类型,您可以获取它们的名称并执行 strcmp。要将 class 类型转换为函数的转换名称,您需要在其名称中写入字符数,然后是这些字符。

因此,命名类型很容易被破坏。

编译器可能使用什么物理机制来确保B1B2在需要C++的假设世界中是“同一类型”他们是同一类型吗?

嗯,它不能使用类型的名称,因为类型没有名称。

也许它可以以某种方式对 lambda 主体的 text 进行编码。但这有点尴尬,因为实际上“one.cpp”中的 b 与“two.cpp”中的 b 略有不同:“one.cpp”有 x+1,“two.cpp”有 x + 1。所以我们必须想出一个规则来说明这个空白差异 重要,或者它 确实 (使它们毕竟不同的类型),或者 可能确实如此 (也许程序的有效性是实现定义的,或者它可能是“格式错误,不需要诊断”)。无论如何,在多个翻译单元中以相同的方式修改 lambda 类型肯定比修改 named 类型(如 A.

更难。

摆脱困难的最简单方法就是简单地说每个 lambda 表达式产生唯一类型的值。那么在不同的翻译单元中定义的两个lambda类型肯定是不同的类型。在单个翻译单元中,我们可以通过从源代码的开头计算来“命名”lambda 类型:

auto a = [](){};  // a has type $_0
auto b = [](){};  // b has type $_1
auto f(int x) {
    return [x](int y) { return x+y; };  // f(1) and f(2) both have type $_2
} 
auto g(float x) {
    return [x](int y) { return x+y; };  // g(1) and g(2) both have type $_3
} 

当然这些名字只有在这个翻译单元内才有意义。此 TU 的 $_0 始终与其他一些 TU 的 $_0 类型不同,即使此 TU 的 struct A 始终与其他一些 TU 的 struct A.[=49= 相同类型]

顺便说一下,请注意我们的“对 lambda 的文本进行编码”的想法还有另一个微妙的问题:lambdas $_2$_3 由完全相同的 text,但它们显然不应被视为相同的 type!


顺便说一句,C++ 确实要求编译器知道如何破坏任意 C++ 表达式 的文本,如

template<class T> void foo(decltype(T())) {}
template void foo<int>(int);  // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_

但是 C++(还)不要求编译器知道如何破坏任意 C++语句decltype([](){ ...arbitrary statements... }) 即使在 C++20 中仍然是病式的。


另请注意,使用 typedef/using 可以很容易地 一个未命名类型的本地别名。我感觉你的问题可能是因为尝试做一些可以像这样解决的事情。

auto f(int x) {
    return [x](int y) { return x+y; };
}

// Give the type an alias, so I can refer to it within this translation unit
using AdderLambda = decltype(f(0));

int of_one(AdderLambda g) { return g(1); }

int main() {
    auto f1 = f(1);
    assert(of_one(f1) == 2);
    auto f42 = f(42);
    assert(of_one(f42) == 43);
}

编辑添加:通过阅读您对其他答案的一些评论,您似乎想知道为什么

int add1(int x) { return x + 1; }
int add2(int x) { return x + 2; }
static_assert(std::is_same_v<decltype(add1), decltype(add2)>);
auto add3 = [](int x) { return x + 3; };
auto add4 = [](int x) { return x + 4; };
static_assert(not std::is_same_v<decltype(add3), decltype(add4)>);

那是因为无捕获 lambda 是默认可构造的。 (在 C++ 中仅自 C++20 起,但它始终概念上 正确。)

template<class T>
int default_construct_and_call(int x) {
    T t;
    return t(x);
}

assert(default_construct_and_call<decltype(add3)>(42) == 45);
assert(default_construct_and_call<decltype(add4)>(42) == 46);

如果您尝试 default_construct_and_call<decltype(&add1)>t 将是一个默认初始化的函数指针,您可能会出现段错误。就像,没用。

为什么要使用匿名类型?

对于由编译器自动生成的类型,选择是 (1) 满足用户对类型名称的请求,或者 (2) 让编译器自己选择一个。

  1. 在前一种情况下,每次出现此类构造时,用户都应明确提供一个名称(C++/Rust:每当定义 lambda;Rust:每当定义函数)。用户每次都需要提供这些细节,这是一个乏味的细节,而且在大多数情况下,该名称再也不会被提及。因此,让编译器自动为其命名并使用现有功能(例如 decltype 或类型推断)在少数需要的地方引用该类型是有意义的。

  2. 在后一种情况下,编译器需要为类型选择一个唯一的名称,这可能是一个晦涩难懂的名称,例如 __namespace1_module1_func1_AnonymousFunction042。语言设计者可以精确地指定这个名称是如何在光荣而精致的细节中构建的,但这不必要地向用户公开了一个任何明智的用户都不能依赖的实现细节,因为这个名称在面对即使是微小的重构时无疑是脆弱的。这也不必要地限制了语言的发展:未来的功能添加可能会导致现有名称生成算法发生变化,从而导致向后兼容性问题。因此,简单地省略这个细节并断言用户无法说出自动生成的类型是有意义的。

为什么要使用唯一(不同)类型?

如果一个值具有唯一类型,那么优化编译器可以在其所有使用站点中跟踪唯一类型,并保证保真度。作为必然结果,用户然后可以确定编译器完全知道此特定值的出处的地方。

举个例子,编译器看到的瞬间:

let f: __UniqueFunc042 = || { ... };  // definition of __UniqueFunc042 (assume it has a nontrivial closure)

/* ... intervening code */

let g: __UniqueFunc042 = /* some expression */;
g();

编译器完全相信 g 一定来自 f,甚至不知道 g 的出处。这将允许对 g 的调用去虚拟化。用户也会知道这一点,因为用户已经非常小心地通过导致 g.

的数据流来保留 f 的唯一类型。

这必然会限制用户使用 f 可以做什么。用户不可随意写:

let q = if some_condition { f } else { || {} };  // ERROR: type mismatch

因为这会导致两种不同类型的(非法)统一。

要解决此问题,用户可以将 __UniqueFunc042 向上转换为非唯一类型 &dyn Fn()

let f2 = &f as &dyn Fn();  // upcast
let q2 = if some_condition { f2 } else { &|| {} };  // OK

这种类型擦除的权衡是 &dyn Fn() 的使用使编译器的推理复杂化。鉴于:

let g2: &dyn Fn() = /*expression */;

编译器必须煞费苦心地检查 /*expression */ 以确定 g2 是否源自 f 或某些其他函数,以及该出处成立的条件。在许多情况下,编译器可能会放弃:也许人类可以在所有情况下判断 g2 确实来自 f,但是从 fg2 的路径太复杂了编译器进行解密,导致对 g2 的虚拟调用具有悲观的性能。

当将此类对象传递给通用(模板)函数时,这一点变得更加明显:

fn h<F: Fn()>(f: F);

如果在 f: __UniqueFunc042 处调用 h(f),则 h 专用于一个唯一实例:

h::<__UniqueFunc042>(f);

这使编译器能够为 h 生成专用代码,为 f 的特定参数量身定制,并且对 f 的分派很可能是静态的,如果不是的话内联。

在相反的情况下,用 f2: &Fn() 调用 h(f)h 被实例化为

h::<&Fn()>(f);

类型 &Fn() 的所有函数共享。在 h 中,编译器对 &Fn() 类型的不透明函数知之甚少,因此只能保守地使用虚拟分派调用 f。要静态分派,编译器必须在其调用站点内联对 h::<&Fn()>(f) 的调用,如果 h 太复杂则无法保证。