Po co projektować język z unikalnymi anonimowymi typami?

90

To jest coś, co zawsze mnie niepokoiło jako cecha wyrażeń lambda w C ++: typ wyrażenia lambda w C ++ jest unikalny i anonimowy, po prostu nie mogę go zapisać. Nawet jeśli utworzę dwie lambdy, które są dokładnie takie same pod względem składniowym, otrzymane typy są definiowane jako różne. Konsekwencją jest to, że a) lambdy mogą być przekazywane tylko do funkcji szablonu, które pozwalają na przekazanie wraz z obiektem czasu kompilacji, niewypowiedzianego typu oraz b) że lambdy są użyteczne tylko wtedy, gdy zostaną usunięte za pomocą std::function<>.

Ok, ale tak po prostu robi to C ++, byłem gotowy zapisać to jako irytującą cechę tego języka. Jednak właśnie dowiedziałem się, że Rust pozornie robi to samo: każda funkcja Rusta lub lambda ma unikalny, anonimowy typ. A teraz zastanawiam się: dlaczego?

A więc moje pytanie brzmi:
jaka jest korzyść, z punktu widzenia projektanta języka, z wprowadzenia do języka koncepcji unikalnego, anonimowego typu?

cmaster - przywróć monica
źródło
6
jak zawsze lepsze pytanie brzmi: dlaczego nie.
Stargateur
31
„że lambdy są użyteczne tylko wtedy, gdy zostaną usunięte za pomocą std :: function <>” - nie, są bezpośrednio przydatne bez std::function. Lambda, która została przekazana do funkcji szablonu, może być wywołana bezpośrednio bez angażowania std::function. Kompilator może następnie wstawić lambdę do funkcji szablonu, co poprawi wydajność działania.
Erlkoenig
1
Domyślam się, że ułatwia to implementację lambdy i sprawia, że ​​język jest łatwiejszy do zrozumienia. Jeśli pozwolisz na zawinięcie dokładnie tego samego wyrażenia lambda do tego samego typu, będziesz potrzebować specjalnych reguł do obsługi, { int i = 42; auto foo = [&i](){ return i; }; } { int i = 13; auto foo = [&i](){ return i; }; }ponieważ zmienna, do której się odnosi, jest inna, nawet jeśli tekstowo są takie same. Jeśli po prostu powiesz, że wszystkie są wyjątkowe, nie musisz się martwić, próbując to rozgryźć.
NathanOliver
5
ale możesz również nadać nazwę typowi lambd i zrobić to samo z tym. lambdas_type = decltype( my_lambda);
idclev 463035818
3
Ale jaki powinien być typ ogólnej lambdy [](auto) {}? Czy na początek powinien mieć typ?
Evg

Odpowiedzi:

78

Wiele standardów (zwłaszcza C ++) przyjmuje podejście polegające na minimalizowaniu tego, ile wymagają od kompilatorów. Szczerze mówiąc, żądają już wystarczająco dużo! Jeśli nie muszą określać czegoś, aby to zadziałało, mają tendencję do pozostawiania zdefiniowanej implementacji.

Gdyby lambdy nie były anonimowe, musielibyśmy je zdefiniować. To musiałoby wiele powiedzieć o sposobie przechwytywania zmiennych. Rozważmy przypadek lambdy [=](){...}. Typ musiałby określać, które typy zostały faktycznie przechwycone przez lambdę, co może być nietrywialne do określenia. A co jeśli kompilator pomyślnie zoptymalizuje zmienną? Rozważać:

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

Optymalizujący kompilator może z łatwością rozpoznać, że jedyną możliwą wartością, iktóra może zostać przechwycona, jest 5, i zastąpić ją auto f = []() { return 5; }. Jeśli jednak typ nie jest anonimowy, może to zmienić typ lub zmusić kompilator do mniejszej optymalizacji, zapisując, imimo że w rzeczywistości tego nie potrzebował. To cały worek złożoności i niuansów, które po prostu nie są potrzebne do tego, do czego miały służyć lambdy.

A poza przypadkiem, gdy faktycznie potrzebujesz typu nieanonimowego, zawsze możesz samodzielnie skonstruować klasę zamknięcia i pracować z funktorem zamiast funkcji lambda. W ten sposób mogą sprawić, że lambdy będą obsługiwać przypadek 99% i pozostawiają kodowanie własnego rozwiązania w 1%.


Deduplicator zwrócił uwagę w komentarzach, że nie mówiłem tak bardzo o wyjątkowości, jak o anonimowości. Jestem mniej pewien co do korzyści wynikających z unikalności, ale warto zauważyć, że zachowanie następujących elementów jest jasne, jeśli typy są unikalne (instancja akcji zostanie wykonana dwukrotnie).

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

Gdyby typy nie były unikalne, musielibyśmy określić, jakie zachowanie powinno mieć miejsce w tym przypadku. To może być trudne. Niektóre z kwestii, które zostały podniesione na temat anonimowości, również podnoszą brzydką głowę w tym przypadku za wyjątkowość.

Cort Ammon
źródło
Zauważ, że tak naprawdę nie chodzi o oszczędzanie pracy dla osoby wdrażającej kompilator, ale o oszczędzanie pracy dla osoby utrzymującej standardy. Kompilator nadal musi odpowiedzieć na wszystkie powyższe pytania dotyczące jego konkretnej implementacji, ale nie są one określone w standardzie.
ComicSansMS
2
@ComicSansMS Połączenie takich rzeczy podczas implementacji kompilatora jest znacznie łatwiejsze, gdy nie musisz dopasowywać swojej implementacji do cudzego standardu. Mówiąc z doświadczenia, często osobie, która utrzymuje standardy , jest dużo łatwiej określić przeszacowanie funkcjonalności, niż próbować znaleźć minimalną ilość do określenia, jednocześnie wydobywając pożądaną funkcjonalność z twojego języka. Jako doskonałe studium przypadku przyjrzyj się, ile pracy poświęcili na unikanie nadmiernego określania parametru memory_order_consume, jednocześnie czyniąc go użytecznym (na niektórych architekturach)
Cort Ammon
1
Jak wszyscy inni, przekonujesz się, że jesteś anonimowy . Ale czy naprawdę dobrym pomysłem jest wymuszanie na nim wyjątkowości ?
Deduplicator
Nie liczy się tu złożoność kompilatora, ale złożoność wygenerowanego kodu. Nie chodzi o to, aby uprościć kompilator, ale aby dać mu wystarczająco dużo miejsca na optymalizację wszystkich przypadków i tworzenie naturalnego kodu dla platformy docelowej.
Jan Hudec
Nie możesz przechwycić zmiennej statycznej.
Ruslan
70

Lambdy to nie tylko funkcje, to funkcja i stan . Dlatego zarówno C ++, jak i Rust implementują je jako obiekt z operatorem wywołania ( operator()w C ++, 3 Fn*cechy w Rust).

Zasadniczo [a] { return a + 1; }w C ++ desugers do czegoś takiego jak

struct __SomeName {
    int a;

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

następnie za pomocą wystąpienia, w __SomeNamektórym używana jest lambda.

Będąc w Rust, || a + 1w Rust zniknie z cukru coś podobnego

{
    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 }
}

Oznacza to, że większość lambd musi mieć różne typy.

Możemy to zrobić na kilka sposobów:

  • Z typami anonimowymi, co implementują oba języki. Inną konsekwencją tego jest to, że wszystkie lambdy muszą mieć inny typ. Jednak dla projektantów języka ma to wyraźną zaletę: lambdy można po prostu opisać za pomocą innych już istniejących prostszych części języka. Są po prostu lukrem składniowym wokół już istniejących fragmentów języka.
  • Z pewną specjalną składnią do nazewnictwa typów lambda: Nie jest to jednak konieczne, ponieważ lambdy mogą być już używane z szablonami w C ++ lub z rodzajami i Fn*cechami w Rust. Żaden język nigdy nie zmusza cię do wymazywania lambd typu, aby ich używać ( std::functionw C ++ lub Box<Fn*>w Rust).

Zauważ również, że oba języki zgadzają się, że trywialne wyrażenia lambda, które nie przechwytują kontekstu, można przekonwertować na wskaźniki funkcji.


Opisywanie złożonych funkcji języków za pomocą prostszej funkcji jest dość powszechne. Na przykład zarówno C ++, jak i Rust mają pętle zakresu dla i oba opisują je jako cukier składniowy dla innych funkcji.

C ++ definiuje

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

jako odpowiednik

{

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

        range_declaration = *__begin;
        loop_statement

    }

} 

i Rust definiuje

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

jako odpowiednik

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

które, choć wydają się bardziej skomplikowane dla człowieka, są prostsze zarówno dla projektanta języka, jak i kompilatora.

mcarton
źródło
15
@ cmaster-reinstatemonica Rozważ przekazanie lambdy jako argumentu porównawczego funkcji sortującej. Czy naprawdę chciałbyś narzucić tutaj narzut wywołań funkcji wirtualnych?
Daniel Langr
5
@ cmaster-reinstatemonica, ponieważ nic nie jest domyślnie wirtualne w C ++
Caleth
4
@cmaster - Masz na myśli zmuszanie wszystkich użytkowników lambd do płacenia za dynamiczny dipatch, nawet jeśli nie będą tego potrzebować?
StoryTeller - Unslander Monica
4
@ cmaster-reinstatemonica Najlepsze, co otrzymasz, to opt-in do wirtualizacji. Zgadnij co, std::functionrobi to
Caleth
9
@ cmaster-reinstatemonica każdy mechanizm, w którym można ponownie wskazać funkcję, która ma zostać wywołana, będzie miał sytuacje z narzutem czasu wykonania. To nie jest sposób C ++. Zdecydować się zstd::function
Caleth
13

(Dodawanie do odpowiedzi Caletha, ale zbyt długie, aby zmieścić się w komentarzu).

Wyrażenie lambda jest po prostu cukrem syntaktycznym dla anonimowej struktury (typu Voldemorta, ponieważ nie możesz powiedzieć jej nazwy).

Możesz zobaczyć podobieństwo między anonimową strukturą a anonimowością lambdy w tym fragmencie kodu:

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

Jeśli to nadal nie jest satysfakcjonujące dla lambdy, powinno być również niezadowalające dla struktury anonimowej.

Niektóre języki pozwalają na nieco bardziej elastyczne pisanie typu kaczego i chociaż C ++ ma szablony, które tak naprawdę nie pomagają w tworzeniu obiektu z szablonu, który ma pole składowe, które może bezpośrednio zastąpić lambdę, zamiast używać std::functionobwoluta.

Eljay
źródło
3
Dziękuję, to rzeczywiście rzuca trochę światła na rozumowanie stojące za sposobem definiowania lambd w C ++ (zapamiętałem termin „typ Voldemorta” :-)). Pozostaje jednak pytanie: jaka jest z tego korzyść w oczach projektanta języka?
cmaster
1
Możesz nawet dodać int& operator()(){ return x; }do tych struktur
Caleth
2
@ cmaster-reinstatemonica • Spekulatywnie ... reszta C ++ zachowuje się w ten sposób. Sprawienie, by lambdy używały jakiegoś rodzaju kaczego „kształtu powierzchniowego”, byłoby czymś zupełnie innym niż reszta języka. Dodanie tego rodzaju ułatwień w języku dla lambd prawdopodobnie byłoby uważane za uogólnione dla całego języka, a to byłaby potencjalnie ogromna przełomowa zmiana. Pominięcie takiego ułatwienia dla samych lambd pasuje do silniejszego typowania reszty C ++.
Eljay
Technicznie rzecz biorąc, byłby to typ Voldemorta auto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }, ponieważ poza fooniczym nie może używać identyfikatoraDarkLord
Caleth
@ cmaster-reinstatemonica, alternatywą byłoby pudełkowanie i dynamiczne wysyłanie każdej lambdy (przydzielanie jej na stercie i usuwanie jej dokładnego typu). Teraz, jak zauważyłeś, kompilator mógł deduplikować anonimowe typy lambd, ale nadal nie byłbyś w stanie ich zapisać i wymagałoby to znacznej pracy przy bardzo niewielkim zysku, więc szanse nie są tak naprawdę korzystne.
Masklinn
10

Po co projektować język z unikalnymi anonimowymi typami?

Ponieważ są przypadki, w których nazwy są nieistotne i nieprzydatne, a nawet przynoszą efekt przeciwny do zamierzonego. W tym przypadku umiejętność abstrahowania od ich istnienia jest przydatna, ponieważ zmniejsza zanieczyszczenie nazw i rozwiązuje jeden z dwóch trudnych problemów w informatyce (jak nazywać rzeczy). Z tego samego powodu przydatne są obiekty tymczasowe.

lambda

Wyjątkowość nie jest specjalną cechą lambda ani nawet specjalną rzeczą dla typów anonimowych. Dotyczy to również nazwanych typów w języku. Rozważ następujące kwestie:

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

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

void foo(A);

Zauważ, że nie mogę przejść Bdo foo, mimo że klasy są identyczne. Ta sama właściwość dotyczy typów nienazwanych.

lambdy mogą być przekazywane tylko do funkcji szablonu, które pozwalają na przekazanie wraz z obiektem czasu kompilacji, niewypowiedzianego typu ... wymazanego przez std :: function <>.

Istnieje trzecia opcja dla podzbioru lambd: lambdy nieprzechwytywane można przekonwertować na wskaźniki funkcji.


Zwróć uwagę, że jeśli ograniczenia typu anonimowego stanowią problem w przypadku użycia, rozwiązanie jest proste: zamiast tego można użyć nazwanego typu. Lambdy nie robią niczego, czego nie można zrobić z nazwaną klasą.

eerorika
źródło
10

Zaakceptowana odpowiedź Corta Ammona jest dobra, ale myślę, że jest jeszcze jedna ważna kwestia, którą należy poruszyć w kwestii implementacji.

Załóżmy, że mam dwie różne jednostki tłumaczeniowe, „one.cpp” i „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);

Dwa przeciążenia fooużywają tego samego identyfikatora ( foo), ale mają różne zniekształcone nazwy. (W Itanium ABI używanym w systemach POSIX-ish, zniekształcone nazwy to _Z3foo1Aiw tym konkretnym przypadku,. _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) {}

Kompilator C ++ musi zapewnić, że zniekształcona nazwa void foo(A1)w „two.cpp” jest taka sama jak zniekształcona nazwa extern void foo(A2)w „one.cpp”, abyśmy mogli połączyć ze sobą dwa pliki obiektowe. To jest znaczenie fizyczne dwóch typów „tego samego typu”: zasadniczo chodzi o zgodność ABI między oddzielnie skompilowanymi plikami obiektowymi.

Kompilator C ++ jest nie zobowiązani do zapewnienia, B1i B2są „tego samego typu.” (W rzeczywistości jest to wymagane, aby upewnić się, że są to różne typy, ale nie jest to teraz tak ważne).


Jakiego mechanizmu fizycznego używa kompilator, aby to zapewnić A1iA2 to „ten sam typ”?

Po prostu zagłębia się w typedef, a następnie sprawdza w pełni kwalifikowaną nazwę typu. To nazwa typu klasy A. (Dobrze,::A ponieważ znajduje się w globalnej przestrzeni nazw.) Więc jest tego samego typu w obu przypadkach. Łatwo to zrozumieć. Co ważniejsze, jest łatwy do wdrożenia . Aby sprawdzić, czy dwa typy klas są tego samego typu, bierzemy ich nazwy i robimystrcmp . Aby zmienić typ klasy w zniekształconą nazwę funkcji, należy wpisać liczbę znaków w jej nazwie, a następnie te znaki.

Tak więc nazwane typy są łatwe do modyfikowania.

Jakiego mechanizmu fizycznego może użyć kompilator, aby to zapewnićB1 i B2to „ten sam typ” w hipotetycznym świecie, gdzie wymagane je C ++, aby być tego samego typu?

Cóż, nie mógł użyć nazwy typu, ponieważ typ nie ma nazwy.

Może mógłby jakoś zakodować tekst ciała lambdy. Ale to byłoby trochę niezręczne, ponieważ w rzeczywistości b„one.cpp” jest subtelnie różne od b„two.cpp”: „one.cpp” ma x+1i „two.cpp” ma x + 1. Musielibyśmy więc wymyślić regułę, która mówi albo że ta różnica białych znaków nie ma znaczenia, albo że ma (w końcu czyniąc je różnymi typami), albo że może tak (być może ważność programu jest zdefiniowana przez implementację , a może jest to „źle uformowane, nie wymaga diagnostyki”). Tak czy inaczej,A

Najłatwiejszym wyjściem z tej trudności jest po prostu stwierdzenie, że każde wyrażenie lambda daje wartości unikalnego typu. Zatem dwa typy lambda zdefiniowane w różnych jednostkach tłumaczeniowych na pewno nietego samego typu . W ramach jednej jednostki tłumaczeniowej możemy „nazwać” typy lambda, licząc od początku kodu źródłowego:

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
} 

Oczywiście nazwy te mają znaczenie tylko w ramach tej jednostki tłumaczeniowej. Ta jednostka JC $_0jest zawsze innego typu niż niektóre inne JT $_0, nawet jeśli ta JC struct Ajest zawsze tego samego typu, co inne JC struct A.

Nawiasem mówiąc, zwróć uwagę, że nasz pomysł „zakoduj tekst lambda” miał inny subtelny problem: lambdy $_2i $_3składają się z dokładnie tego samego tekstu , ale wyraźnie nie powinny być uważane za tego samego typu!


Nawiasem mówiąc, C ++ wymaga, aby kompilator wiedział, jak zmienić tekst dowolnego wyrażenia C ++ , jak w

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

Ale C ++ nie wymaga (jeszcze), aby kompilator wiedział, jak modyfikować dowolną instrukcję C ++ . decltype([](){ ...arbitrary statements... })jest nadal źle sformułowany, nawet w C ++ 20.


Zauważ również, że łatwo jest nadać lokalny alias nienazwanemu typowi za pomocą typedef/ using. Mam wrażenie, że Twoje pytanie mogło powstać w wyniku próby zrobienia czegoś, co można by rozwiązać w ten sposób.

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

ZMIENIONO DO DODANIA: Po przeczytaniu niektórych komentarzy na temat innych odpowiedzi wydaje się, że zastanawiasz się, dlaczego

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

Dzieje się tak, ponieważ lambdy bez przechwytywania są domyślnie konstruowane. (W C ++ tylko od C ++ 20, ale koncepcyjnie zawsze było to prawdziwe).

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

Gdybyś spróbował default_construct_and_call<decltype(&add1)>, tbyłby domyślnie zainicjowanym wskaźnikiem funkcji i prawdopodobnie doszedłbyś do segfault. To jakby nieprzydatne.

Quuxplusone
źródło
W rzeczywistości jest to wymagane, aby upewnić się, że są to różne typy; ale to nie jest tak ważne w tej chwili. ” Zastanawiam się, czy istnieje dobry powód, aby wymusić wyjątkowość, gdyby była równoważnie zdefiniowana.
Deduplicator
Osobiście uważam, że całkowicie zdefiniowane zachowanie jest (prawie?) Zawsze lepsze niż nieokreślone zachowanie. „Czy te dwa wskaźniki funkcji są równe? No cóż, tylko wtedy, gdy te dwie instancje szablonu są tą samą funkcją, co jest prawdą tylko wtedy, gdy te dwa typy lambda są tego samego typu, co jest prawdą tylko wtedy, gdy kompilator zdecydował się je scalić”. Icky! (Ale zauważ, że mamy dokładnie analogiczną sytuację z łączeniem ciągów literalnych i nikt nie jest zaniepokojony sytuacją. Wątpię więc, czy zezwolenie kompilatorowi na scalanie identycznych typów byłoby katastrofalne).
Quuxplusone
Cóż, to fajne pytanie, czy dwie równoważne funkcje (z wyjątkiem sytuacji) mogą być identyczne. Język w standardzie nie jest całkiem oczywisty w przypadku funkcji bezpłatnych i / lub statycznych. Ale to jest poza zakresem tutaj.
Deduplicator
Nieoczekiwanie w tym miesiącu na liście mailingowej LLVM toczyła się dyskusja na temat łączenia funkcji. Kodegen Clanga spowoduje, że funkcje z całkowicie pustymi ciałami zostaną scalone prawie „przez przypadek”: godbolt.org/z/obT55b To jest technicznie niezgodne i myślę, że prawdopodobnie załatają LLVM, aby przestać to robić. Ale tak, zgadzam się, scalanie adresów funkcji też jest rzeczą.
Quuxplusone
Ten przykład ma inne problemy, a mianowicie brak instrukcji powrotu. Czy oni sami nie powodują, że kod jest niezgodny? Poszukam również dyskusji, ale czy wykazali lub założyli, że scalanie równoważnych funkcji jest niezgodne ze standardem, ich udokumentowanym zachowaniem, z gcc, czy po prostu, że niektórzy uważają, że tak się nie dzieje?
Deduplicator
9

Wymagane lambdy języka C ++ odrębnych typów dla odrębnych operacji, ponieważ C ++ wiąże się statycznie. Można je tylko kopiować / przenosić, więc w większości przypadków nie trzeba nazywać ich typu. Ale to wszystko w pewnym sensie szczegół implementacji.

Nie jestem pewien, czy lambdy języka C # mają typ, ponieważ są to „anonimowe wyrażenia funkcji” i natychmiast są konwertowane na zgodny typ delegata lub typ drzewa wyrażeń. Jeśli tak, jest to prawdopodobnie typ niewymawialny.

C ++ ma również anonimowe struktury, w których każda definicja prowadzi do unikalnego typu. Tutaj nazwy nie da się wymawiać, po prostu nie istnieje, jeśli chodzi o standard.

C # ma anonimowe typy danych , które ostrożnie zabrania ucieczki z zakresu, w którym są zdefiniowane. Implementacja nadaje im również unikalną, niemożliwą do wymówienia nazwę.

Posiadanie anonimowego typu sygnalizuje programiście, że nie powinien zaglądać do swojej implementacji.

Na bok:

Ty można nadać nazwę, aby wpisać lambda.

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

Jeśli nie masz żadnych przechwyceń, możesz użyć typu wskaźnika funkcji

void (*pfoo)() = foo;
Caleth
źródło
1
Pierwszy przykładowy kod nadal nie pozwoli na kolejny Foo_t = []{};, tylko Foo_t = fooi nic więcej.
cmaster
1
@ cmaster-reinstatemonica wynika to z tego, że typ nie jest domyślnie konstruowany, a nie z powodu anonimowości. Domyślam się, że jest to tak samo związane z unikaniem posiadania jeszcze większego zestawu przypadków narożnych, o których musisz pamiętać, jak z jakiegokolwiek powodu technicznego.
Caleth
6

Dlaczego warto używać typów anonimowych?

W przypadku typów, które są automatycznie generowane przez kompilator, można wybrać (1) uszanować żądanie użytkownika dotyczące nazwy typu lub (2) pozwolić kompilatorowi samodzielnie wybrać.

  1. W pierwszym przypadku oczekuje się, że użytkownik jawnie poda nazwę za każdym razem, gdy pojawi się taka konstrukcja (C ++ / Rust: za każdym razem, gdy definiowana jest lambda; Rust: za każdym razem, gdy definiowana jest funkcja). Jest to żmudny szczegół, który użytkownik musi podawać za każdym razem, aw większości przypadków nigdy więcej nie odwołuje się do nazwy. Dlatego warto pozwolić kompilatorowi automatycznie ustalić jego nazwę i użyć istniejących funkcji, takich jak decltypelub wnioskowanie o typie, do odniesienia się do typu w kilku miejscach, w których jest potrzebny.

  2. W tym drugim przypadku kompilator musi wybrać unikalną nazwę dla typu, która prawdopodobnie byłaby niejasną, nieczytelną nazwą, taką jak __namespace1_module1_func1_AnonymousFunction042. Projektant języka mógłby precyzyjnie określić, w jaki sposób ta nazwa jest skonstruowana w cudownych i delikatnych szczegółach, ale niepotrzebnie ujawnia to użytkownikowi szczegół implementacyjny, na którym żaden rozsądny użytkownik nie może polegać, ponieważ nazwa jest bez wątpienia krucha w obliczu nawet drobnych refaktorów. To również niepotrzebnie ogranicza ewolucję języka: przyszłe dodatki funkcji mogą spowodować zmianę istniejącego algorytmu generowania nazw, prowadząc do problemów z kompatybilnością wsteczną. Dlatego sensowne jest po prostu pominięcie tego szczegółu i zapewnienie, że typ wygenerowany automatycznie jest niewypowiedziany przez użytkownika.

Po co używać unikalnych (odrębnych) typów?

Jeśli wartość ma unikalny typ, kompilator optymalizujący może śledzić unikalny typ we wszystkich używanych przez siebie witrynach z gwarantowaną dokładnością. W konsekwencji użytkownik może być pewien miejsc, w których pochodzenie tej konkretnej wartości jest w pełni znane kompilatorowi.

Na przykład moment, w którym kompilator widzi:

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

/* ... intervening code */

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

kompilator ma pełne zaufanie, które gmusi koniecznie pochodzić f, nawet nie znając pochodzenia g. Pozwoliłoby to na gdewirtualizację wezwania. Użytkownik też by o tym wiedział, ponieważ dołożył wszelkich starań, aby zachować unikalny typ fprzepływu danych, który doprowadził dog .

Koniecznie ogranicza to to, co użytkownik może zrobić f. Użytkownikowi nie wolno pisać:

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

ponieważ doprowadziłoby to do (nielegalnego) zjednoczenia dwóch różnych typów.

Aby obejść ten problem, użytkownik może przesłać upcast __UniqueFunc042do nieunikalnego typu &dyn Fn(),

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

Kompromisem wynikającym z tego typu wymazywania jest to, że używa &dyn Fn()skomplikowanego rozumowania dla kompilatora. Dany:

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

kompilator musi skrupulatnie zbadać, /*expression */czy g2pochodzi z fjakiejś innej funkcji lub jakiejś innej funkcji oraz warunki, w jakich zachowuje to pochodzenie. W wielu przypadkach kompilator może się poddać: być może człowiek mógłby stwierdzić, że g2tak naprawdę pochodzi z fkażdej sytuacji, ale ścieżka od fdo g2była zbyt skomplikowana, aby kompilator mógł ją odszyfrować, co skutkowało wirtualnym wywołaniemg2 z pesymistyczną wydajnością.

Staje się to bardziej widoczne, gdy takie obiekty są dostarczane do funkcji ogólnych (szablonowych):

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

Jeśli ktoś wywołuje h(f)gdzie f: __UniqueFunc042, to hjest wyspecjalizowany w unikalnej instancji:

h::<__UniqueFunc042>(f);

Dzięki temu kompilator może generować wyspecjalizowany kod dla h, dostosowany do konkretnego argumentu fi wysyłki dof prawdopodobnie będzie statyczna, jeśli nie jest wstawiona.

W przeciwnym scenariuszu, w którym ktoś dzwoni h(f)z f2: &Fn(), hjest tworzony jako

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

który jest wspólny dla wszystkich funkcji typu &Fn(). Wewnątrz hkompilator niewiele wie o nieprzezroczystej funkcji typu, &Fn()więc może tylko konserwatywnie wywołać ją fza pomocą wirtualnej wysyłki. Aby wysłać statycznie, kompilator musiałby wbudować wywołanie h::<&Fn()>(f)w swojej witrynie wywołania, co nie jest gwarantowane, jeśli hjest zbyt złożone.

Rufflewind
źródło
Pierwsza część dotycząca wybierania nazw mija się z celem: typ typu void(*)(int, double)może nie mieć nazwy, ale mogę ją zapisać. Nazwałbym to typem bezimiennym, a nie typem anonimowym. Nazwałbym tajemnicze rzeczy, takie jak __namespace1_module1_func1_AnonymousFunction042zniekształcanie nazw, które zdecydowanie nie wchodzi w zakres tego pytania. To pytanie dotyczy typów, których zapisanie jest gwarantowane przez standard, w przeciwieństwie do wprowadzenia składni typów, która może wyrazić te typy w użyteczny sposób.
cmaster
3

Po pierwsze, lambda bez przechwytywania można zamienić na wskaźnik funkcji. Zapewniają więc jakąś formę hojności.

Dlaczego lambdy z przechwytywaniem nie są konwertowane na wskaźnik? Ponieważ funkcja musi mieć dostęp do stanu lambda, więc ten stan musiałby pojawić się jako argument funkcji.

Oliv
źródło
Cóż, przechwycenia powinny stać się częścią samej lambdy, prawda? Tak jak są zamknięte w pliku std::function<>.
cmaster
3

Aby uniknąć kolizji nazw z kodem użytkownika.

Nawet dwie lambdy z tą samą implementacją będą miały różne typy. Co jest w porządku, ponieważ mogę mieć różne typy obiektów, nawet jeśli ich układ pamięci jest równy.

knivil
źródło
Typ taki int (*)(Foo*, int, double)nie stwarza żadnego ryzyka kolizji nazwy z kodem użytkownika.
cmaster
Twój przykład niezbyt dobrze uogólnia. Chociaż wyrażenie lambda jest tylko składnią, zostanie oszacowane na pewną strukturę, szczególnie z klauzulą ​​przechwytywania. Nazwanie go jawnie może prowadzić do konfliktów nazw już istniejących struktur.
knivil
Ponownie, to pytanie dotyczy projektowania języka, a nie C ++. Z pewnością mogę zdefiniować język, w którym typ lambdy jest bardziej podobny do typu wskaźnika funkcji niż do typu struktury danych. Składnia wskaźnika funkcji w C ++ i składnia typu tablicy dynamicznej w C dowodzą, że jest to możliwe. I to nasuwa pytanie, dlaczego lambdy nie zastosowały podobnego podejścia?
cmaster
1
Nie, nie możesz, z powodu zmiennego curry (przechwytywania). Aby to działało, potrzebujesz zarówno funkcji, jak i danych.
Blindy
@Blindy Och, tak, mogę. Mógłbym zdefiniować lambdę jako obiekt zawierający dwa wskaźniki, jeden dla obiektu przechwytywania, a drugi dla kodu. Taki obiekt lambda byłby łatwy do przekazania według wartości. Albo mógłbym wyciągnąć sztuczki z fragmentem kodu na początku obiektu przechwytywania, który pobiera własny adres przed przejściem do właściwego kodu lambda. To zmieniłoby wskaźnik lambda w pojedynczy adres. Ale to niepotrzebne, jak udowodniła platforma PPC: w PPC wskaźnik funkcji to w rzeczywistości para wskaźników. Dlatego nie można przesyłać void(*)(void)do void*i z powrotem w standardowym C / C ++.
cmaster