고유 한 익명 유형으로 언어를 디자인하는 이유는 무엇입니까? 구별되도록 정의됩니다. 그 결과, a)

이것은 항상 C ++ 람다 식의 기능으로 나를 괴롭히는 것입니다. C ++ 람다 식의 유형은 독특하고 익명이므로 간단히 적을 수 없습니다. 구문 적으로 정확히 동일한 두 개의 람다를 생성하더라도 결과 유형은 구별되도록 정의됩니다. 그 결과, a) 람다는 컴파일 시간을 허용하는 템플릿 함수에만 전달 될 수 있으며, 말할 수없는 유형은 객체와 함께 전달 될 수 있으며 b) 람다는를 통해 유형이 지워진 후에 만 ​​유용합니다 std::function<>.

좋아,하지만 그것이 C ++이하는 방식이다. 나는 그 언어의 성가신 기능으로 쓸 준비가되었다. 그러나 Rust가 겉보기에 똑같은 일을한다는 것을 방금 배웠습니다. 각 Rust 함수 또는 람다는 고유 한 익명 유형을 가지고 있습니다. 그리고 지금 궁금합니다. 왜?

그래서, 제 질문은 이것입니다.
언어 디자이너의 관점에서 언어에 고유 한 익명 유형의 개념을 도입하는 이점은 무엇입니까?



답변

많은 표준 (특히 C ++)은 컴파일러에서 요구하는 양을 최소화하는 접근 방식을 취합니다. 솔직히, 그들은 이미 충분히 요구합니다! 작동하기 위해 무언가를 지정할 필요가 없다면 구현을 정의한 채로 두는 경향이 있습니다.

람다가 익명이 아니었다면 정의해야합니다. 이것은 변수가 캡처되는 방법에 대해 많은 것을 말해야 할 것입니다. 람다의 경우를 고려하십시오 [=](){...}. 유형은 실제로 람다에 의해 캡처 된 유형을 지정해야하는데, 이는 사소하지 않을 수 있습니다. 또한 컴파일러가 변수를 성공적으로 최적화하면 어떻게 될까요? 중히 여기다:

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

최적화 컴파일러는 i캡처 할 수있는 유일한 값 이 5라는 것을 쉽게 인식 할 수 있으며이를 auto f = []() { return 5; }. 그러나 유형이 익명이 아닌 경우 유형이 변경 되거나 컴파일러가 i실제로 필요하지 않더라도 저장하여 덜 최적화하도록 강제 할 수 있습니다. 이것은 람다가 의도 한 일에 단순히 필요하지 않은 복잡성과 뉘앙스의 전체 가방입니다.

그리고 실제로 익명이 아닌 유형이 필요한 오프 케이스에서는 항상 클로저 클래스를 직접 구성하고 람다 함수가 아닌 펑터로 작업 할 수 있습니다. 따라서 그들은 람다가 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는 단순한 함수가 아니라 함수 이자 상태 입니다. 따라서 C ++와 Rust는 모두 호출 연산자 ( operator()C ++에서는 Fn*Rust 의 3 가지 특성) 를 사용하여 객체로 구현합니다 .

기본적으로 [a] { return a + 1; }C ++에서는 다음과 같이 설탕을 제거합니다.

struct __SomeName {
    int a;

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

그런 다음 __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 }
}

이 방법은 대부분의 람다가 있어야다른 유형.

이제 우리가 할 수있는 몇 가지 방법이 있습니다.

  • 익명 유형을 사용하면 두 언어가 모두 구현합니다. 그것의 또 다른 결과는 모든 람다 다른 유형을 가져야 한다는 것입니다. 그러나 언어 설계자에게는 분명한 이점이 있습니다. Lambda는 이미 존재하는 더 단순한 언어 부분을 사용하여 간단히 설명 할 수 있습니다. 그들은 이미 존재하는 언어의 일부에 대한 구문 설탕 일뿐입니다.
  • 람다 유형 이름 지정을위한 몇 가지 특수 구문 : 그러나 람다는 C ++의 템플릿 또는 Fn*Rust의 제네릭 및 특성 과 함께 이미 사용할 수 있기 때문에 필요하지 않습니다 . 두 언어 모두 람다를 입력하여 사용하도록 강제하지 않습니다 ( std::functionC ++ 또는 Box<Fn*>Rust에서).

또한 두 언어 모두 컨텍스트 캡처하지 않는 사소한 람다 를 함수 포인터로 변환 할 수 있다는 데 동의합니다 .


더 간단한 기능을 사용하여 언어의 복잡한 기능을 설명하는 것은 매우 일반적입니다. 예를 들어 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>);
        }
    }
};

인간에게는 더 복잡해 보이지만 언어 디자이너 나 컴파일러에게는 더 간단합니다.


답변

(Caleth의 답변에 추가하지만 주석에 맞추기에는 너무 깁니다.)

람다 식은 익명 구조체 (이름을 말할 수 없기 때문에 Voldemort 유형)에 대한 구문 설탕 일뿐입니다.

이 코드 조각에서 익명 구조체와 람다의 익명 성 사이의 유사성을 확인할 수 있습니다.

#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";
}

람다가 여전히 만족스럽지 않다면 익명 구조체도 만족스럽지 않습니다.

일부 언어는 좀 더 융통성있는 일종의 덕 타이핑을 허용하며, C ++에는 람다를 사용하는 대신 직접 람다를 대체 할 수있는 멤버 필드가있는 템플릿에서 개체를 만드는 데 도움이되지 않는 템플릿이 있지만 std::function싸개.


답변

고유 한 익명 유형 으로 언어를 디자인하는 이유는 무엇 입니까?

이름이 무관하고 유용하지 않거나 심지어 비생산적인 경우가 있기 때문입니다. 이 경우 자신의 존재를 추상화하는 능력은 이름 오염을 줄이고 컴퓨터 과학의 두 가지 어려운 문제 (이름을 지정하는 방법) 중 하나를 해결하기 때문에 유용합니다. 같은 이유로 임시 개체가 유용합니다.

람다

고유성은 특별한 람다가 아니며 익명 유형에 대한 특별한 것도 아닙니다. 언어의 명명 된 유형에도 적용됩니다. 다음을 고려하십시오.

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

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

void foo(A);

클래스가 동일하더라도 B으로 전달할 수 없습니다 foo. 이 동일한 속성이 이름이 지정되지 않은 유형에 적용됩니다.

람다는 컴파일 시간, 말할 수없는 유형이 객체와 함께 전달되도록 허용하는 템플릿 함수에만 전달 될 수 있습니다. std :: function <>을 통해 지워집니다.

람다 하위 집합에 대한 세 번째 옵션이 있습니다. 캡처하지 않는 람다를 함수 포인터로 변환 할 수 있습니다.


익명 형식의 제한이 사용 사례에 문제가되는 경우 솔루션은 간단합니다. 대신 명명 된 형식을 사용할 수 있습니다. Lambda는 명명 된 클래스로 수행 할 수없는 작업을 수행하지 않습니다.


답변

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);

의 두 오버로드 foo는 동일한 식별자 ( foo)를 사용하지만 다른 이름을 엉망으로 만듭니다. (POSIX 시스템에서 사용되는 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 ++ 컴파일러 void foo(A1) “two.cpp”의 변경된 이름이 “one.cpp”의 변경된 이름과 동일한 지 확인 extern void foo(A2)해야 두 개체 파일을 함께 연결할 수 있습니다. 이것은 “동일한 유형”인 두 유형 의 물리적 의미 입니다. 본질적으로 별도로 컴파일 된 개체 파일 간의 ABI 호환성에 관한 것입니다.

C ++ 컴파일러는 및 이 “동일한 유형” 인지 확인하는 데 필요 하지 않습니다 . (사실 서로 다른 유형인지 확인해야하지만 지금은 그렇게 중요하지 않습니다.)B1B2


어떤 물리적 메커니즘을하는 것을 보장하기 위해 컴파일러 사용을 수행 A1하고A2 “동일 유형”인가?

단순히 typedef를 통해 잠복 한 다음 형식의 정규화 된 이름을 확인합니다. 라는 클래스 유형 A입니다. ( ::A글로벌 네임 스페이스에 있기 때문입니다.) 따라서 두 경우 모두 동일한 유형입니다. 이해하기 쉽습니다. 더욱 중요한 것은 쉽다 구현 . 두 클래스 유형이 동일한 유형인지 확인하려면 이름을 가져 와서strcmp . 클래스 유형을 함수의 이름을 변경하려면 이름에 문자 수를 쓰고 그 뒤에 해당 문자를 입력합니다.

따라서 명명 된 유형은 쉽게 조작 할 수 있습니다.

어떤 물리적 인 메커니즘을 수있는 컴파일러 사용을 보장하기 위하여 B1B2C ++가 동일한 유형으로 그들을 필요한 경우 “동일 유형은”가상의 세계에?

글쎄, 유형의 이름을 사용할 수 없습니다. 유형 에는 이름을.

람다 본문의 텍스트 를 어떻게 든 인코딩 할 수 있습니다. 그러나 그것은 다소 어색 할 것입니다. 왜냐하면 실제로 b“one.cpp”는 “two.cpp”에서와 미묘하게 다르기 때문입니다 b: “one.cpp”는 가지고 x+1있고 “two.cpp”는 x + 1. 우리는이 공백 차이가 있음 중 하나라는 규칙을 마련 할 것 그래서 하지 않는 문제, 또는 그 수행 (결국 그들에게 다른 유형을), 또는 어쩌면 않습니다 (아마 프로그램의 유효성이 구현 정의 , 또는 “진단이 필요하지 않은 형식이 잘못되었습니다”). 어쨌든,A

가장 쉬운 방법은 각 람다식이 고유 한 유형의 값을 생성한다고 말하는 것입니다. 그러면 서로 다른 번역 단위로 정의 된 두 개의 람다 유형은 확실히 동일한 유형아닙니다 . 단일 번역 단위 내에서 소스 코드의 시작 부분부터 계산하여 람다 유형을 “이름”할 수 있습니다.

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입니다.

그건 그렇고, 우리의 “람다 텍스트 인코딩”아이디어에는 또 다른 미묘한 문제가 있습니다. 람다 $_2이고 $_3정확히 동일한 텍스트 로 구성 되지만 분명히 동일한 유형 으로 간주되어서는 안됩니다 !


그건 그렇고, 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에서도 여전히 잘못된 형식입니다.


또한 /를 사용하여 이름이 지정되지 않은 유형에 로컬 별칭을 제공 하는 것이 쉽습니다 . 당신의 질문이 이렇게 해결 될 수있는 일을하려고해서 나온 것 같다고 생각합니다.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);
}

추가 편집 : 다른 답변에 대한 귀하의 의견 중 일부를 읽어 보니 이유가 궁금하신 것 같습니다.

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)>);

캡처없는 람다는 기본 구성이 가능하기 때문입니다. (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기본 초기화 함수 포인터가 될 것이며, 당신은 아마 세그 폴트 것입니다. 그것은 유용하지 않은 것 같습니다.


답변

C ++ 람다 는 C ++가 정적으로 바인딩하기 때문에 고유 한 작업을 위해 고유 한 유형이 필요합니다 . 복사 / 이동 만 구성 할 수 있으므로 대부분 유형의 이름을 지정할 필요가 없습니다. 그러나 그것은 모두 다소 구현 세부 사항입니다.

C # 람다는 “익명 함수 식”이므로 형식이 있는지 확실하지 않으며 즉시 호환되는 대리자 형식 또는 식 트리 형식으로 변환됩니다. 그렇다면 아마도 발음 할 수없는 유형일 것입니다.

C ++에는 또한 각 정의가 고유 한 유형으로 이어지는 익명 구조체가 있습니다. 여기에서 이름은 발음 할 수없는 것이 아니라 표준에 관한 한 존재하지 않습니다.

C #에는 익명 데이터 형식 이 있으므로 정의 된 범위에서 이스케이프하는 것을주의 깊게 금지합니다. 구현은 그들에게도 고유하고 발음 할 수없는 이름을 제공합니다.

익명 유형을 갖는 것은 프로그래머에게 구현 내부를 찌르지 말아야한다는 신호를 보냅니다.

곁에:

람다 유형에 이름을 지정할있습니다 .

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

캡처가없는 경우 함수 포인터 유형을 사용할 수 있습니다.

void (*pfoo)() = foo;

답변

익명 유형을 사용하는 이유는 무엇입니까?

컴파일러에 의해 자동으로 생성되는 유형의 경우 (1) 유형 이름에 대한 사용자의 요청을 따르거나 (2) 컴파일러가 자체적으로 선택하도록하는 것입니다.

  1. 전자의 경우 사용자는 이러한 구조가 나타날 때마다 명시 적으로 이름을 제공해야합니다 (C ++ / Rust : 람다가 정의 될 때마다; 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반드시 시작해야하는 완전한 확신을 가지고 있습니다. 이렇게하면 호출 이 비 가상화 될 수 있습니다. 사용자는 데이터 흐름을 통해 고유 한 유형을 보존하기 위해 세심한주의를 기울 였기 때문에이를 알고있을 것입니다.fggfg 입니다.

필연적으로 이것은 사용자가으로 할 수있는 작업을 제한합니다 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모든 상황에서 만에서 경로 f에가 g2도에 가상 호출의 결과, 해독 컴파일러에 대한 뒤얽힌했다 g2비관적 성능을.

이는 이러한 객체가 일반 (템플릿) 함수에 전달 될 때 더욱 분명해집니다.

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

하나를 호출하는 경우 h(f)경우 f: __UniqueFunc042, 다음 h고유 한 인스턴스에 전문입니다 :

h::<__UniqueFunc042>(f);

이를 통해 컴파일러 h는의 특정 인수에 맞게 조정 된에 대한 특수 코드를 생성 할 수 f있으며에 대한 디스패치 f는 인라인되지 않은 경우 정적 일 가능성이 높습니다.

하나의 호출 반대의 시나리오에서 h(f)f2: &Fn()의이 h같은 인스턴스화

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

유형의 모든 기능간에 공유 &Fn()됩니다. 내부 h에서 컴파일러는 불투명 한 유형의 함수에 대해 거의 알지 못 &Fn()하므로 f가상 디스패치를 ​​사용하여 보수적으로 만 호출 할 수 있습니다. 정적으로 디스패치하려면 컴파일러가 h::<&Fn()>(f)호출 사이트에서에 대한 호출 을 인라인해야합니다 . 이는 h너무 복잡 하면 보장되지 않습니다 .