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?
źródło
std::function
. Lambda, która została przekazana do funkcji szablonu, może być wywołana bezpośrednio bez angażowaniastd::function
. Kompilator może następnie wstawić lambdę do funkcji szablonu, co poprawi wydajność działania.{ 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źć.lambdas_type = decltype( my_lambda);
[](auto) {}
? Czy na początek powinien mieć typ?Odpowiedzi:
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ą,
i
któ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,i
mimo ż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ść.
źródło
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 ++, 3Fn*
cechy w Rust).Zasadniczo
[a] { return a + 1; }
w C ++ desugers do czegoś takiego jakstruct __SomeName { int a; int operator()() { return a + 1; } };
następnie za pomocą wystąpienia, w
__SomeName
którym używana jest lambda.Będąc w Rust,
|| a + 1
w 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:
Fn*
cechami w Rust. Żaden język nigdy nie zmusza cię do wymazywania lambd typu, aby ich używać (std::function
w C ++ lubBox<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.
źródło
std::function
robi tostd::function
(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::function
obwoluta.źródło
int& operator()(){ return x; }
do tych strukturauto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }
, ponieważ pozafoo
niczym nie może używać identyfikatoraDarkLord
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.
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ść
B
dofoo
, mimo że klasy są identyczne. Ta sama właściwość dotyczy typów nienazwanych.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ą.
źródło
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
foo
uż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_Z3foo1A
iw 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 nazwaextern 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,
B1
iB2
są „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ć
A1
iA2
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
iB2
to „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 odb
„two.cpp”: „one.cpp” max+1
i „two.cpp” max + 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 nie są tego 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
$_0
jest zawsze innego typu niż niektóre inne JT$_0
, nawet jeśli ta JCstruct A
jest zawsze tego samego typu, co inne JCstruct A
.Nawiasem mówiąc, zwróć uwagę, że nasz pomysł „zakoduj tekst lambda” miał inny subtelny problem: lambdy
$_2
i$_3
skł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)>
,t
byłby domyślnie zainicjowanym wskaźnikiem funkcji i prawdopodobnie doszedłbyś do segfault. To jakby nieprzydatne.źródło
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;
źródło
Foo_t = []{};
, tylkoFoo_t = foo
i nic więcej.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ć.
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
decltype
lub wnioskowanie o typie, do odniesienia się do typu w kilku miejscach, w których jest potrzebny.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
g
musi koniecznie pochodzićf
, nawet nie znając pochodzeniag
. Pozwoliłoby to nag
dewirtualizację wezwania. Użytkownik też by o tym wiedział, ponieważ dołożył wszelkich starań, aby zachować unikalny typf
przepł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
__UniqueFunc042
do 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 */
czyg2
pochodzi zf
jakiejś 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ć, żeg2
tak naprawdę pochodzi zf
każdej sytuacji, ale ścieżka odf
dog2
był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)
gdzief: __UniqueFunc042
, toh
jest wyspecjalizowany w unikalnej instancji:Dzięki temu kompilator może generować wyspecjalizowany kod dla
h
, dostosowany do konkretnego argumentuf
i wysyłki dof
prawdopodobnie będzie statyczna, jeśli nie jest wstawiona.W przeciwnym scenariuszu, w którym ktoś dzwoni
h(f)
zf2: &Fn()
,h
jest tworzony jakoh::<&Fn()>(f);
który jest wspólny dla wszystkich funkcji typu
&Fn()
. Wewnątrzh
kompilator niewiele wie o nieprzezroczystej funkcji typu,&Fn()
więc może tylko konserwatywnie wywołać jąf
za pomocą wirtualnej wysyłki. Aby wysłać statycznie, kompilator musiałby wbudować wywołanieh::<&Fn()>(f)
w swojej witrynie wywołania, co nie jest gwarantowane, jeślih
jest zbyt złożone.źródło
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_AnonymousFunction042
zniekształ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.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.
źródło
std::function<>
.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.
źródło
int (*)(Foo*, int, double)
nie stwarza żadnego ryzyka kolizji nazwy z kodem użytkownika.void(*)(void)
dovoid*
i z powrotem w standardowym C / C ++.