Ini adalah sesuatu yang selalu mengganggu saya sebagai fitur ekspresi lambda C ++: Jenis ekspresi lambda C ++ unik dan anonim, saya tidak bisa menuliskannya. Bahkan jika saya membuat dua lambda yang secara sintaksis persis sama, tipe yang dihasilkan didefinisikan berbeda. Konsekuensinya adalah, a) lambda hanya dapat diteruskan ke fungsi templat yang memungkinkan waktu kompilasi, tipe yang tidak dapat disebutkan untuk diteruskan bersama dengan objek, dan b) lambda itu hanya berguna setelah tipe dihapus melalui std::function<>
.
Oke, tapi begitulah C ++ melakukannya, saya siap untuk menghapusnya hanya sebagai fitur menjengkelkan dari bahasa itu. Namun, saya baru mengetahui bahwa Rust tampaknya melakukan hal yang sama: Setiap fungsi Rust atau lambda memiliki tipe anonim yang unik. Dan sekarang saya bertanya-tanya: Mengapa?
Jadi, pertanyaan saya adalah ini:
Apa keuntungan, dari sudut pandang desainer bahasa, untuk memperkenalkan konsep tipe anonim yang unik ke dalam bahasa?
sumber
std::function
. Lambda yang telah diteruskan ke fungsi template bisa dipanggil secara langsung tanpa melibatkanstd::function
. Kompilator kemudian dapat menyebariskan lambda ke dalam fungsi template yang akan meningkatkan efisiensi waktu proses.{ int i = 42; auto foo = [&i](){ return i; }; } { int i = 13; auto foo = [&i](){ return i; }; }
karena variabel yang dirujuknya berbeda, meskipun secara tekstual keduanya sama. Jika Anda hanya mengatakan bahwa semuanya unik, Anda tidak perlu khawatir untuk mencoba mencari tahu.lambdas_type = decltype( my_lambda);
[](auto) {}
? Haruskah itu memiliki tipe, untuk memulai?Jawaban:
Banyak standar (terutama C ++) mengambil pendekatan untuk meminimalkan berapa banyak yang mereka minta dari compiler. Terus terang, mereka sudah cukup menuntut! Jika mereka tidak harus menentukan sesuatu untuk membuatnya bekerja, mereka cenderung membiarkan implementasinya ditentukan.
Jika lambda tidak anonim, kami harus mendefinisikannya. Ini harus menjelaskan banyak hal tentang bagaimana variabel ditangkap. Pertimbangkan kasus lambda
[=](){...}
. Tipe harus menentukan tipe mana yang benar-benar ditangkap oleh lambda, yang bisa jadi tidak sepele untuk ditentukan. Juga, bagaimana jika kompilator berhasil mengoptimalkan variabel? Mempertimbangkan:static const int i = 5; auto f = [i]() { return i; }
Kompilator yang mengoptimalkan dapat dengan mudah mengenali bahwa satu-satunya nilai yang mungkin dari
i
yang dapat ditangkap adalah 5, dan menggantinya denganauto f = []() { return 5; }
. Namun, jika tipenya tidak anonim, ini bisa mengubah tipe atau memaksa compiler untuk lebih sedikit mengoptimalkan, menyimpani
meskipun sebenarnya tidak membutuhkannya. Ini adalah seluruh kantong kompleksitas dan nuansa yang tidak diperlukan untuk apa yang dimaksudkan oleh lambda.Dan, jika Anda benar-benar membutuhkan tipe non-anonim, Anda selalu dapat membuat kelas closure sendiri, dan bekerja dengan functor daripada fungsi lambda. Dengan demikian, mereka dapat membuat lambda menangani kasus 99%, dan membiarkan Anda membuat kode solusi Anda sendiri dalam 1%.
Deduplicator menunjukkan dalam komentar bahwa saya tidak membahas keunikan sebanyak anonimitas. Saya kurang yakin tentang manfaat keunikan, tetapi perlu dicatat bahwa perilaku berikut ini jelas jika tipenya unik (tindakan akan dibuat dua kali).
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; });
Jika jenisnya tidak unik, kita harus menentukan perilaku apa yang harus terjadi dalam kasus ini. Itu bisa jadi rumit. Beberapa isu yang diangkat pada topik anonimitas juga mengangkat kepala jelek mereka dalam hal ini keunikan.
sumber
Lambda bukan hanya fungsi, mereka adalah fungsi dan status . Oleh karena itu, C ++ dan Rust mengimplementasikannya sebagai objek dengan operator panggilan (
operator()
dalam C ++, 3Fn*
ciri di Rust).Pada dasarnya,
[a] { return a + 1; }
dalam C ++ mendeskripsikan sesuatu sepertistruct __SomeName { int a; int operator()() { return a + 1; } };
kemudian menggunakan contoh di
__SomeName
mana lambda digunakan.Sedangkan di Rust,
|| a + 1
di Rust akan desugar menjadi sesuatu seperti{ 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 } }
Artinya kebanyakan lambda pasti memiliki jenis yang berbeda .
Sekarang, ada beberapa cara untuk melakukannya:
Fn*
ciri - ciri di Rust. Tidak ada bahasa yang memaksa Anda mengetik-hapus lambda untuk menggunakannya (denganstd::function
C ++ atauBox<Fn*>
Rust).Perhatikan juga bahwa kedua bahasa setuju bahwa lambda sepele yang tidak menangkap konteks dapat diubah menjadi penunjuk fungsi.
Mendeskripsikan fitur kompleks dari suatu bahasa menggunakan fitur yang lebih sederhana cukup umum. Misalnya, C ++ dan Rust memiliki loop range-for, dan keduanya mendeskripsikannya sebagai gula sintaks untuk fitur lainnya.
C ++ mendefinisikan
for (auto&& [first,second] : mymap) { // use first and second }
sebagai setara dengan
{ init-statement auto && __range = range_expression ; auto __begin = begin_expr ; auto __end = end_expr ; for ( ; __begin != __end; ++__begin) { range_declaration = *__begin; loop_statement } }
dan Rust mendefinisikan
for <pat> in <head> { <body> }
sebagai setara dengan
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>); } } };
yang meskipun tampak lebih rumit bagi manusia, keduanya lebih sederhana bagi perancang bahasa atau kompiler.
sumber
std::function
apakah itustd::function
(Menambah jawaban Caleth, tapi terlalu panjang untuk memuat komentar.)
Ekspresi lambda hanyalah gula sintaksis untuk struct anonim (tipe Voldemort, karena Anda tidak dapat menyebutkan namanya).
Anda dapat melihat kesamaan antara struct anonim dan anonimitas lambda dalam cuplikan kode ini:
#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"; }
Jika itu masih tidak memuaskan untuk lambda, seharusnya juga tidak memuaskan untuk struct anonim.
Beberapa bahasa memungkinkan jenis pengetikan bebek yang sedikit lebih fleksibel, dan meskipun C ++ memiliki templat yang tidak terlalu membantu dalam membuat objek dari templat yang memiliki bidang anggota yang dapat menggantikan lambda secara langsung daripada menggunakan
std::function
pembungkus.sumber
int& operator()(){ return x; }
ke struct ituauto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }
, karena di luarfoo
tidak ada yang dapat menggunakan pengenalDarkLord
Karena ada kasus dimana nama menjadi tidak relevan dan tidak berguna atau bahkan kontra produktif. Dalam hal ini kemampuan mengabstraksi keberadaan mereka berguna karena mengurangi polusi nama, dan memecahkan salah satu dari dua masalah sulit dalam ilmu komputer (bagaimana menamai sesuatu). Untuk alasan yang sama, objek sementara berguna.
Keunikan bukanlah lambda khusus, atau bahkan hal khusus untuk tipe anonim. Ini berlaku untuk tipe bernama dalam bahasa juga. Pertimbangkan berikut ini:
struct A { void operator()(){}; }; struct B { void operator()(){}; }; void foo(A);
Perhatikan bahwa saya tidak bisa
B
masukfoo
, meskipun kelasnya identik. Properti yang sama ini berlaku untuk jenis tanpa nama.Ada opsi ketiga untuk subset lambda: Lambda yang tidak menangkap bisa dikonversi menjadi penunjuk fungsi.
Perhatikan bahwa jika batasan tipe anonim merupakan masalah untuk kasus penggunaan, maka solusinya sederhana: Tipe bernama dapat digunakan sebagai gantinya. Lambdas tidak melakukan apa pun yang tidak bisa dilakukan dengan kelas bernama.
sumber
Jawaban yang diterima Cort Ammon bagus, tetapi saya pikir ada satu hal penting lagi yang harus dibuat tentang implementabilitas.
Misalkan saya memiliki dua unit terjemahan yang berbeda, "one.cpp" dan "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);
Dua kelebihan
foo
penggunaan menggunakan identifier (foo
) yang sama tetapi memiliki nama yang rusak berbeda. (Dalam Itanium ABI yang digunakan pada sistem POSIX-ish, nama yang rusak adalah_Z3foo1A
dan, dalam kasus khusus ini_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 ++ harus memastikan bahwa nama rusak
void foo(A1)
di "two.cpp" sama dengan nama rusakextern void foo(A2)
di "one.cpp", sehingga kita dapat menautkan dua file objek bersama-sama. Ini adalah arti fisik dari dua jenis yang menjadi "tipe yang sama": ini pada dasarnya tentang kompatibilitas ABI antara file objek yang dikompilasi secara terpisah.Compiler C ++ tidak diperlukan untuk memastikan
B1
danB2
merupakan "tipe yang sama". (Sebenarnya, diperlukan untuk memastikan bahwa mereka berbeda tipe; tapi itu tidak sepenting sekarang.)Mekanisme fisik apa yang digunakan kompilator untuk memastikan bahwa
A1
danA2
merupakan "tipe yang sama"?Itu hanya menggali melalui typedefs, dan kemudian melihat nama tipe yang sepenuhnya memenuhi syarat. Itu adalah tipe kelas bernama
A
. (Yah,::A
karena ini ada di namespace global.) Jadi itu tipe yang sama di kedua kasus. Itu mudah dimengerti. Lebih penting lagi, ini mudah diterapkan . Untuk melihat apakah dua tipe kelas adalah tipe yang sama, Anda mengambil namanya dan melakukan astrcmp
. Untuk mengacaukan tipe kelas menjadi nama fungsi yang rusak, Anda menulis jumlah karakter dalam namanya, diikuti dengan karakter tersebut.Jadi, tipe bernama mudah untuk diatur.
Mekanisme fisik apa yang mungkin digunakan compiler untuk memastikan bahwa
B1
danB2
merupakan "tipe yang sama", dalam dunia hipotetis di mana C ++ mengharuskan mereka untuk menjadi tipe yang sama?Yah, itu tidak bisa menggunakan nama tipe, karena tipe tidak memiliki nama.
Mungkin entah bagaimana itu bisa menyandikan teks tubuh lambda. Tapi itu akan agak canggung, karena sebenarnya
b
di "one.cpp" sedikit berbeda darib
di "two.cpp": "one.cpp" hasx+1
dan "two.cpp" hasx + 1
. Jadi kita harus membuat aturan yang mengatakan bahwa perbedaan whitespace ini tidak penting, atau memang begitu (bagaimanapun juga membuat mereka berbeda tipe), atau mungkin memang begitu (mungkin validitas program ditentukan oleh implementasi , atau mungkin itu "cacat tidak diperlukan diagnostik"). Bagaimanapun,A
Jalan keluar termudah dari kesulitan ini adalah dengan mengatakan bahwa setiap ekspresi lambda menghasilkan nilai dari tipe yang unik. Maka dua jenis lambda yang ditentukan dalam unit terjemahan yang berbeda pasti bukan jenis yang sama . Dalam satu unit terjemahan, kita dapat "memberi nama" jenis lambda hanya dengan menghitung dari awal kode sumber:
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 }
Tentu saja nama-nama ini hanya memiliki arti dalam unit terjemahan ini. TU
$_0
ini selalu berbeda jenisnya dengan TU lain$_0
, meskipun TUstruct A
ini selalu sama jenisnya dengan TU lainstruct A
.Ngomong-ngomong, perhatikan bahwa gagasan "menyandikan teks lambda" kami memiliki masalah halus lainnya: lambda
$_2
dan$_3
terdiri dari teks yang persis sama , tetapi gagasan itu jelas tidak boleh dianggap jenis yang sama !Ngomong-ngomong, C ++ memang membutuhkan compiler untuk mengetahui cara mengacaukan teks dari ekspresi C ++ arbitrer , seperti pada
template<class T> void foo(decltype(T())) {} template void foo<int>(int); // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_
Tapi C ++ tidak (belum) membutuhkan compiler tahu bagaimana mangle sebuah C ++ sewenang-wenang pernyataan .
decltype([](){ ...arbitrary statements... })
bentuknya masih buruk bahkan dalam C ++ 20.Juga perhatikan bahwa mudah memberikan alias lokal ke tipe tanpa nama menggunakan
typedef
/using
. Saya merasa pertanyaan Anda mungkin muncul dari mencoba melakukan sesuatu yang dapat diselesaikan seperti ini.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); }
DIEDIT UNTUK DITAMBAHKAN: Dari membaca beberapa komentar Anda di jawaban lain, sepertinya Anda bertanya-tanya mengapa
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)>);
Itu karena lambda yang tidak dapat ditangkap dapat dibangun secara default. (Dalam C ++ hanya pada C ++ 20, tetapi itu selalu benar secara konseptual .)
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);
Jika Anda mencoba
default_construct_and_call<decltype(&add1)>
,t
akan menjadi penunjuk fungsi yang diinisialisasi default dan Anda mungkin akan segfault. Itu, sepertinya, tidak berguna.sumber
C ++ lambda membutuhkan tipe yang berbeda untuk operasi yang berbeda, karena C ++ mengikat secara statis. Mereka hanya dapat disalin / dipindah-pindah, jadi kebanyakan Anda tidak perlu memberi nama tipenya. Tapi itu semua adalah detail implementasi.
Saya tidak yakin apakah C # lambda memiliki tipe, karena mereka adalah "ekspresi fungsi anonim", dan mereka segera dikonversi ke tipe delegasi yang kompatibel atau tipe pohon ekspresi. Jika ya, itu mungkin jenis yang tidak dapat diucapkan.
C ++ juga memiliki struct anonim, di mana setiap definisi mengarah ke tipe yang unik. Di sini namanya tidak dapat dilafalkan, itu sama sekali tidak ada sejauh menyangkut standar.
C # memiliki tipe data anonim , yang dengan hati-hati melarangnya keluar dari cakupan yang mereka definisikan. Implementasinya juga memberikan nama yang unik dan tidak dapat diucapkan untuk itu.
Memiliki tipe anonim memberi sinyal kepada programmer bahwa mereka tidak boleh melihat-lihat dalam implementasinya.
Ke samping:
Anda dapat memberi nama untuk jenis lambda.
auto foo = []{}; using Foo_t = decltype(foo);
Jika Anda tidak memiliki tangkapan apa pun, Anda bisa menggunakan tipe penunjuk fungsi
void (*pfoo)() = foo;
sumber
Foo_t = []{};
, hanyaFoo_t = foo
dan tidak ada yang lain.Mengapa menggunakan tipe anonim?
Untuk tipe yang secara otomatis dihasilkan oleh kompilator, pilihannya adalah (1) menghormati permintaan pengguna untuk nama tipe tersebut, atau (2) membiarkan kompilator memilihnya sendiri.
Dalam kasus sebelumnya, pengguna diharapkan memberikan nama secara eksplisit setiap kali konstruksi seperti itu muncul (C ++ / Rust: setiap kali lambda didefinisikan; Rust: setiap kali fungsi didefinisikan). Ini adalah detail yang membosankan untuk diberikan pengguna setiap saat, dan dalam sebagian besar kasus, nama tersebut tidak pernah dirujuk lagi. Oleh karena itu, masuk akal untuk membiarkan compiler mencari nama untuk itu secara otomatis, dan menggunakan fitur yang ada seperti
decltype
inferensi atau tipe untuk mereferensikan tipe di beberapa tempat yang dibutuhkan.Dalam kasus terakhir, kompilator perlu memilih nama unik untuk tipe tersebut, yang mungkin merupakan nama yang tidak jelas dan tidak dapat dibaca seperti
__namespace1_module1_func1_AnonymousFunction042
. Perancang bahasa dapat menentukan dengan tepat bagaimana nama ini dibuat dengan detail yang indah dan halus, tetapi ini tidak perlu memperlihatkan detail implementasi kepada pengguna yang tidak dapat diandalkan oleh pengguna yang bijaksana, karena nama tersebut tidak diragukan lagi rapuh dalam menghadapi refaktor kecil sekalipun. Ini juga tidak perlu membatasi evolusi bahasa: penambahan fitur di masa mendatang dapat menyebabkan algoritme pembuatan nama yang ada berubah, yang menyebabkan masalah kompatibilitas ke belakang. Oleh karena itu, masuk akal untuk mengabaikan detail ini, dan menegaskan bahwa jenis yang dibuat secara otomatis tidak dapat diutamakan oleh pengguna.Mengapa menggunakan tipe unik (berbeda)?
Jika suatu nilai memiliki tipe unik, maka compiler pengoptimal dapat melacak tipe unik di semua situs penggunaannya dengan jaminan ketepatan. Sebagai akibatnya, pengguna kemudian dapat yakin di tempat-tempat di mana asal dari nilai khusus ini diketahui kompilator.
Sebagai contoh, saat kompilator melihat:
let f: __UniqueFunc042 = || { ... }; // definition of __UniqueFunc042 (assume it has a nontrivial closure) /* ... intervening code */ let g: __UniqueFunc042 = /* some expression */; g();
compiler memiliki keyakinan penuh yang
g
harus berasal darif
, bahkan tanpa mengetahui asal darig
. Ini akan memungkinkan panggilan untukg
didevirtualisasi. Pengguna akan mengetahui hal ini juga, karena pengguna telah sangat berhati-hati untuk mempertahankan tipe unikf
melalui aliran data yang mengarah keg
.Seharusnya, ini membatasi apa yang dapat dilakukan pengguna
f
. Pengguna tidak bebas menulis:let q = if some_condition { f } else { || {} }; // ERROR: type mismatch
karena hal itu akan mengarah pada penyatuan (ilegal) dua jenis yang berbeda.
Untuk mengatasi ini, pengguna dapat menyalurkan
__UniqueFunc042
ke tipe non-unik&dyn Fn()
,let f2 = &f as &dyn Fn(); // upcast let q2 = if some_condition { f2 } else { &|| {} }; // OK
Kompromi yang dibuat oleh penghapusan jenis ini adalah penggunaan
&dyn Fn()
alasan yang rumit bagi kompilator. Diberikan:let g2: &dyn Fn() = /*expression */;
kompilator harus dengan susah payah memeriksa
/*expression */
untuk menentukan apakahg2
berasal darif
atau beberapa fungsi lain, dan kondisi di mana asalnya berlaku. Dalam banyak situasi, kompilator mungkin menyerah: mungkin manusia dapat mengatakan bahwa itug2
benar - benar datang darif
dalam semua situasi tetapi jalur darif
keg2
terlalu berbelit-belit untuk diuraikan oleh kompilator, mengakibatkan panggilan virtual keg2
dengan kinerja pesimis.Ini menjadi lebih jelas ketika objek seperti itu dikirim ke fungsi generik (template):
fn h<F: Fn()>(f: F);
Jika seseorang memanggil
h(f)
wheref: __UniqueFunc042
, thenh
dikhususkan untuk instance unik:Hal ini memungkinkan kompilator untuk menghasilkan kode khusus untuk
h
, disesuaikan untuk argumen tertentuf
, dan pengiriman kef
kemungkinan besar bersifat statis, jika tidak sebaris.Dalam skenario sebaliknya, di mana seseorang memanggil
h(f)
denganf2: &Fn()
, yangh
dipakai sebagaih::<&Fn()>(f);
yang dibagi di antara semua fungsi tipe
&Fn()
. Dari dalamh
, kompilator hanya mengetahui sedikit tentang fungsi jenis yang tidak tembus cahaya&Fn()
dan karenanya hanya dapat memanggil secara konservatiff
dengan pengiriman virtual. Untuk mengirimkan secara statis, compiler harus melakukan panggilan inline keh::<&Fn()>(f)
di situs panggilannya, yang tidak dijamin jikah
terlalu rumit.sumber
void(*)(int, double)
mungkin tidak memiliki nama, tetapi saya dapat menuliskannya. Saya akan menyebutnya tipe tanpa nama, bukan tipe anonim. Dan saya akan menyebut hal-hal samar seperti__namespace1_module1_func1_AnonymousFunction042
nama mangling, yang jelas tidak termasuk dalam cakupan pertanyaan ini. Pertanyaan ini adalah tentang tipe-tipe yang dijamin oleh standar tidak mungkin untuk ditulis, sebagai kebalikan dari memperkenalkan sintaks tipe yang bisa mengekspresikan tipe-tipe ini dengan cara yang berguna.Pertama, lambda tanpa tangkapan dapat diubah menjadi penunjuk fungsi. Jadi mereka memberikan beberapa bentuk kemurahan hati.
Sekarang mengapa lambda dengan capture tidak dapat diubah menjadi pointer? Karena fungsi harus mengakses status lambda, jadi status ini perlu muncul sebagai argumen fungsi.
sumber
std::function<>
.Untuk menghindari benturan nama dengan kode pengguna.
Bahkan dua lambda dengan implementasi yang sama akan memiliki tipe yang berbeda. Tidak apa-apa karena saya juga dapat memiliki jenis objek yang berbeda meskipun tata letak memorinya sama.
sumber
int (*)(Foo*, int, double)
tidak memiliki risiko benturan nama dengan kode pengguna.void(*)(void)
kevoid*
dan kembali dalam C / C ++ standar.