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

cma*_*ter 92 c++ types language-design rust

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

好的,但这只是 C++ 的方式,我准备将它写为该语言的一个令人讨厌的功能。然而,我刚刚了解到 Rust 似乎也做同样的事情:每个 Rust 函数或 lambda 都有一个唯一的匿名类型。现在我想知道:为什么?

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

Cor*_*ica 81

许多标准(尤其是 C++)采取的方法是尽量减少对编译器的要求。坦白说,他们的要求已经够多了!如果他们不必指定某些东西来使其工作,他们倾向于将它的实现定义。

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

static const int i = 5;
auto f = [i]() { return i; }
Run Code Online (Sandbox Code Playgroud)

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

而且,在您确实需要非匿名类型的情况下,您始终可以自己构造闭包类,并使用函子而不是 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; });
Run Code Online (Sandbox Code Playgroud)

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

  • @ComicSansMS 当您不必使您的实现符合其他人的标准时,在实现编译器时将这些东西放在一起要容易得多。从经验来看,对于标准维护者来说,过度指定功能通常“远”容易,而不是尝试找到指定的最小数量,同时仍然从您的语言中获得所需的功能。作为一个优秀的案例研究,看看他们花了多少工作来避免过度指定 memory_order_consume,同时仍然使其有用(在某些架构上) (2认同)

mca*_*ton 69

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

基本上,[a] { return a + 1; }在 C++ 中,脱糖类似于

struct __SomeName {
    int a;

    int operator()() {
        return a + 1;
    }
};
Run Code Online (Sandbox Code Playgroud)

然后使用使用__SomeNamelambda的实例。

在 Rust 中,|| a + 1在 Rust 中会脱糖为类似的东西

struct __SomeName {
    int a;

    int operator()() {
        return a + 1;
    }
};
Run Code Online (Sandbox Code Playgroud)

这意味着大多数lambda 表达式必须具有不同的类型。

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

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

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


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

C++ 定义

{
    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 }
}
Run Code Online (Sandbox Code Playgroud)

相当于

{

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

        range_declaration = *__begin;
        loop_statement

    }

} 
Run Code Online (Sandbox Code Playgroud)

和 Rust 定义

for (auto&& [first,second] : mymap) {
    // use first and second
}
Run Code Online (Sandbox Code Playgroud)

相当于

{

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

        range_declaration = *__begin;
        loop_statement

    }

} 
Run Code Online (Sandbox Code Playgroud)

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

  • @cmaster-reinstatemonica 考虑传递 lambda 作为排序函数的比较器参数。您真的想在这里施加虚函数调用的开销吗? (15认同)
  • @cmaster-reinstatemonica *任何* 可以重新指向要调用的函数的机制都会出现运行时开销的情况。这不是 C++ 的方式。您选择使用“std::function” (9认同)
  • @cmaster-reinstatemonica 因为在 C++ 中默认没有任何东西是虚拟的 (5认同)
  • @cmaster - 你的意思是强制 lambda 的所有用户为动态 dipatch 付费,即使他们不需要它? (4认同)
  • @cmaster-reinstatemonica 你能得到的最好的结果就是选择虚拟。猜猜看,`std::function` 是做什么的 (4认同)
  • @cmaster 那么您会依赖去虚拟化(一种众所周知的不可靠的优化形式),而不是简单地使用现有的语言功能来保证静态调度?或者你是在建议基本上它现在的工作方式,但是在语法中使用类型名称,这样它就不再是“匿名的”——这对我来说似乎 99% 无用? (3认同)
  • @StoryTeller-UnslanderMonica 不,绝对不是。我的意思是对如何处理该类型进行类比。“虚拟”正是我用 C++ 语言表达它的方式。如果它直接构建到语言中,则将直接传递指向代码和捕获对象的指针对。但请注意两件事:1)虚拟调度正是 `std::function&lt;&gt;` 的作用。2)即使使用我给出的定义,当对象的类型确切地知道是“__SomeName”时,编译器也会优化虚拟调度。 (2认同)

Elj*_*jay 12

(添加到 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";
}
Run Code Online (Sandbox Code Playgroud)

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

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

  • 谢谢,这确实让我们对 C++ 中 lambda 定义方式背后的推理有了一些了解(我必须记住术语“Voldemort 类型”:-))。然而,问题仍然存在:在语言设计者眼中,这有什么“优势”? (3认同)
  • @cmaster-reinstatemonica • *推测...* C++ 的其余部分的行为方式也是如此。让 lambda 使用某种“表面形状”鸭子类型将与该语言的其他部分非常不同。在 lambda 语言中添加这种功能可能会被认为是对整个语言的泛化,这将是一个潜在的巨大突破性变化。仅对 lambda 省略这样的功能适合 C++ 其余部分的强类型。 (2认同)

Quu*_*one 11

Cort Ammon 接受的答案很好,但我认为关于可实施性还有一个更重要的观点。

假设我有两个不同的翻译单元,“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);
Run Code Online (Sandbox Code Playgroud)

两个重载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) {}
Run Code Online (Sandbox Code Playgroud)

C++ 编译器必须确保void foo(A1)“two.cpp”中的重整名称与“one.cpp”中的重整名称相同extern void foo(A2),以便我们可以将两个目标文件链接在一起。这是两种类型“相同类型”的物理含义:它本质上是关于单独编译的目标文件之间的 ABI 兼容性。

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


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

它只是深入 typedef,然后查看类型的完全限定名称。这是一个名为A. (嗯,::A因为它在全局命名空间中。)所以在这两种情况下它是相同的类型。这很容易理解。更重要的是,它很容易实现。要查看两个类类型是否相同,您可以使用它们的名称并执行strcmp. 要将类类型改造成函数的改名名称,请在其名称中写入字符数,然后是这些字符。

因此,命名类型很容易处理。

什么物理机制可能编译器使用,以确保B1B2“相同类型”,在一个假想的世界里,C ++要求他们具有相同的类型?

那么,它不能使用类型的名称,因为该类型不具有名称。

也许它可以以某种方式对lambda 主体的文本进行编码。但这会有点尴尬,因为实际上b“one.cpp”中的与“two.cpp”中的略有不同b:“one.cpp” hasx+1和“two.cpp” has x + 1。因此,我们不得不拿出,说要么这个空白差异的规则没有的事,或者说,它不会(毕竟使它们不同类型的),或者也许它(也许该程序的有效性是指实现的,或者可能是“格式错误,不需要诊断”)。反正,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
} 
Run Code Online (Sandbox Code Playgroud)

当然,这些名称只在这个翻译单元中有意义。此 TU 的$_0类型始终与其他一些 TU 的类型不同$_0,即使此 TU 的struct A类型始终与其他一些 TU 的类型相同struct A

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


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

template<class T> void foo(decltype(T())) {}
template void foo<int>(int);  // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_
Run Code Online (Sandbox Code Playgroud)

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


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

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);
}
Run Code Online (Sandbox Code Playgroud)

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

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)>);
Run Code Online (Sandbox Code Playgroud)

那是因为无捕获的 lambda 表达式是默认可构造的。(仅在 C++ 20 之后的 C++ 中,但它在概念上一直是正确的。)

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);
Run Code Online (Sandbox Code Playgroud)

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


eer*_*ika 10

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

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

拉姆达

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

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

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

void foo(A);
Run Code Online (Sandbox Code Playgroud)

请注意,我不能传递Bfoo,即使类是相同的。这个相同的属性适用于未命名的类型。

lambdas 只能传递给允许编译时、不可描述的类型与对象一起传递的模板函数......通过 std::function<> 擦除。

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


请注意,如果匿名类型的限制是用例的问题,那么解决方案很简单:可以使用命名类型。Lambda 不会做任何命名类无法完成的事情。


Cal*_*eth 9

C++ lambdas需要不同的类型来进行不同的操作,因为 C++ 是静态绑定的。它们只能复制/移动构造,因此大多数情况下您不需要命名它们的类型。但这都是一个实现细节。

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

C++ 也有匿名结构,其中每个定义都指向一个唯一的类型。这里的名称并非不可发音,就标准而言,它根本不存在。

C# 有匿名数据类型,它小心地禁止从它们定义的范围中逃逸。该实现也为那些提供了一个独特的、不可发音的名称。

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

在旁边:

可以为 lambda 的类型命名。

auto foo = []{}; 
using Foo_t = decltype(foo);
Run Code Online (Sandbox Code Playgroud)

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

void (*pfoo)() = foo;
Run Code Online (Sandbox Code Playgroud)


Ruf*_*ind 6

为什么要使用匿名类型?

对于编译器自动生成的类型,选择是 (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();
Run Code Online (Sandbox Code Playgroud)

编译器有充分的信心,这g一定来自f,甚至不知道g. 这将允许调用去g虚拟化。用户也会知道这一点,因为用户已经非常小心地保留了f导致g.

必然地,这限制了用户可以使用f. 用户不能随意写:

let q = if some_condition { f } else { || {} };  // ERROR: type mismatch
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

let g2: &dyn Fn() = /*expression */;
Run Code Online (Sandbox Code Playgroud)

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

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

fn h<F: Fn()>(f: F);
Run Code Online (Sandbox Code Playgroud)

如果调用h(f)where f: __UniqueFunc042,则h专门用于唯一实例:

h::<__UniqueFunc042>(f);
Run Code Online (Sandbox Code Playgroud)

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

在相反的情况,其中一个电话h(f)f2: &Fn()中,h被实例化

h::<&Fn()>(f);
Run Code Online (Sandbox Code Playgroud)

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