Saya menonton ceramah Chandler Carruth di CppCon 2019:
Tidak ada Abstraksi Tanpa Biaya
di dalamnya, dia memberikan contoh bagaimana dia dikejutkan dengan berapa banyak biaya overhead yang Anda keluarkan dengan menggunakan std::unique_ptr<int>
lebih dari satu int*
; segmen itu dimulai pada titik waktu 17:25.
Anda dapat melihat hasil kompilasi contoh pasangan potongannya (godbolt.org) - untuk menyaksikan bahwa, memang, tampaknya kompiler tidak mau memberikan nilai unique_ptr - yang sebenarnya pada intinya adalah hanya alamat - di dalam register, hanya dalam memori lurus.
Salah satu poin yang dibuat oleh Mr. Carruth sekitar pukul 27:00 adalah bahwa C ++ ABI memerlukan parameter nilai-nilai (beberapa tetapi tidak semua; mungkin - tipe non-primitif? Tipe non-trivial-konstruktif?) Untuk dilewatkan dalam memori daripada dalam register.
Pertanyaan saya:
- Apakah ini sebenarnya persyaratan ABI pada beberapa platform? (yang mana?) Atau mungkin itu hanya pesimisasi dalam skenario tertentu?
- Kenapa ABI seperti itu? Yaitu, jika bidang struct / kelas cocok dengan register, atau bahkan satu register - mengapa kita tidak bisa meneruskannya dalam register itu?
- Pernahkah komite standar C ++ membahas hal ini dalam beberapa tahun terakhir, atau tidak pernah?
PS - Agar tidak meninggalkan pertanyaan ini tanpa kode:
Pointer polos:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Pointer unik:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
sumber
this
pointer yang menunjuk pada lokasi yang valid.unique_ptr
memiliki itu. Menumpahkan register untuk tujuan itu agak akan meniadakan seluruh optimasi "pass in a register".Jawaban:
Salah satu contohnya adalah System B Application Interface Binary, AMD64 Architecture Processor Supplement . ABI ini untuk CPU 64-bit x86-kompatibel (Linux x86_64 architecure). Itu diikuti pada Solaris, Linux, FreeBSD, macOS, Windows Subsystem untuk Linux:
Perhatikan, bahwa hanya 2 register tujuan umum yang dapat digunakan untuk melewatkan 1 objek dengan konstruktor salinan sepele dan destruktor trivial, yaitu hanya nilai-nilai objek dengan
sizeof
tidak lebih dari 16 yang dapat diteruskan dalam register. Lihat Konvensi pemanggilan oleh Agner Fog untuk perawatan terperinci dari konvensi pemanggilan, khususnya §7.1 Melewati dan mengembalikan benda. Ada konvensi panggilan terpisah untuk meneruskan tipe SIMD dalam register.Ada ABI yang berbeda untuk arsitektur CPU lainnya.
Ini adalah detail implementasi, tetapi ketika pengecualian ditangani, selama tumpukan dibuka, objek dengan durasi penyimpanan otomatis yang dihancurkan harus dapat dialamatkan relatif terhadap bingkai fungsi stack karena register telah musnah pada saat itu. Stack unwinding code memerlukan alamat objek untuk memanggil destruktor mereka tetapi objek dalam register tidak memiliki alamat.
Pedantically, destructor beroperasi pada objek :
dan sebuah objek tidak bisa ada di C ++ jika tidak ada penyimpanan addressable dialokasikan untuk itu karena identitas objek adalah alamatnya .
Ketika alamat objek dengan konstruktor salinan sepele disimpan dalam register diperlukan, kompiler hanya dapat menyimpan objek ke dalam memori dan mendapatkan alamat. Jika copy constructor bersifat non-sepele, di sisi lain, compiler tidak bisa hanya menyimpannya ke dalam memori, itu lebih baik untuk memanggil copy constructor yang mengambil referensi dan karenanya memerlukan alamat objek dalam register. Konvensi panggilan mungkin tidak dapat bergantung pada apakah pembuat salinan disejajarkan dalam callee atau tidak.
Cara lain untuk memikirkan hal ini, adalah bahwa untuk tipe yang dapat disalin secara sepele, kompiler mentransfer nilai suatu objek dalam register, dari mana suatu objek dapat dipulihkan oleh penyimpanan memori biasa jika perlu. Misalnya:
pada x86_64 dengan System V ABI mengkompilasi ke:
Dalam ceramahnya yang menggugah pemikiran, Chandler Carruth menyebutkan bahwa perubahan ABI yang pecah mungkin diperlukan (antara lain) untuk menerapkan langkah destruktif yang dapat memperbaiki keadaan. IMO, perubahan ABI dapat menjadi non-breaking jika fungsi menggunakan ABI baru secara eksplisit memilih untuk memiliki tautan baru yang berbeda, misalnya mendeklarasikannya dalam
extern "C++20" {}
blok (mungkin, dalam ruang nama inline baru untuk memigrasi API yang ada). Sehingga hanya kode yang dikompilasi terhadap deklarasi fungsi baru dengan tautan baru yang dapat menggunakan ABI baru.Perhatikan bahwa ABI tidak berlaku ketika fungsi yang dipanggil telah digariskan. Seperti halnya dengan pembuatan kode tautan-waktu, kompiler dapat menyejajarkan fungsi-fungsi yang didefinisikan dalam unit terjemahan lain atau menggunakan konvensi panggilan khusus.
sumber
Dengan ABI biasa, destruktor non-sepele -> tidak bisa lewat register
(Ilustrasi titik dalam jawaban @ MaximEgorushkin menggunakan contoh @ harold dalam komentar; dikoreksi sesuai komentar @ Yakk.)
Jika Anda mengkompilasi:
Anda mendapatkan:
yaitu
Foo
objek dilewatkan ketest
dalam register (edi
) dan juga dikembalikan dalam register (eax
).Ketika destructor tidak sepele (seperti
std::unique_ptr
contoh OP) - ABI umum memerlukan penempatan pada stack. Ini benar bahkan jika destruktor tidak menggunakan alamat objek sama sekali.Jadi, bahkan dalam kasus ekstrim penghancur apa-apa, jika Anda mengkompilasi:
Anda mendapatkan:
dengan pemuatan dan penyimpanan yang tidak berguna.
sumber
std::unique_ptr
dalam register yang tidak sesuai.register
kunci itu dimaksudkan untuk membuatnya sepele bagi mesin fisik untuk menyimpan sesuatu dalam register dengan memblokir hal-hal yang secara praktis membuatnya lebih sulit untuk "tidak memiliki alamat" di mesin fisik.Jika sesuatu terlihat di batas unit kepatuhan maka apakah itu didefinisikan secara implisit atau eksplisit itu menjadi bagian dari ABI.
Masalah mendasar adalah register disimpan dan dipulihkan sepanjang waktu saat Anda bergerak ke bawah dan naik ke tumpukan panggilan. Jadi tidak praktis untuk memiliki referensi atau penunjuk kepada mereka.
In-lining dan optimisasi yang dihasilkan dari itu bagus ketika itu terjadi, tetapi seorang desainer ABI tidak dapat mengandalkan itu terjadi. Mereka harus merancang ABI dengan asumsi kasus terburuk. Saya tidak berpikir programmer akan sangat senang dengan kompiler di mana ABI berubah tergantung pada level optimisasi.
Jenis yang dapat disalin sepele dapat dilewatkan dalam register karena operasi penyalinan logis dapat dibagi menjadi dua bagian. Parameter disalin ke register yang digunakan untuk melewatkan parameter oleh pemanggil dan kemudian disalin ke variabel lokal oleh callee. Dengan demikian, apakah variabel lokal memiliki lokasi memori atau tidak, hanya menjadi perhatian callee.
Tipe di mana salinan atau memindahkan konstruktor harus digunakan di sisi lain tidak dapat memiliki operasi salinan itu terpecah dengan cara ini, sehingga harus dilewatkan dalam memori.
Saya tidak tahu apakah badan standar telah mempertimbangkan hal ini.
Solusi yang jelas bagi saya adalah menambahkan gerakan destruktif yang tepat (daripada rumah setengah jalan saat ini dari "negara yang valid tetapi tidak ditentukan") ke langauge, kemudian memperkenalkan cara untuk menandai suatu jenis yang memungkinkan untuk "gerakan destruktif sepele "Bahkan jika itu tidak memungkinkan untuk salinan sepele.
tetapi solusi semacam itu AKAN membutuhkan pemecahan ABI dari kode yang ada untuk menerapkan untuk jenis yang sudah ada, yang dapat membawa sedikit perlawanan (meskipun ABI pecah sebagai akibat dari versi standar C ++ baru yang belum pernah terjadi sebelumnya, misalnya perubahan std :: string di C ++ 11 menghasilkan istirahat ABI ..
sumber
unique_ptr
danshared_ptr
semantik:shared_ptr<T>
memungkinkan Anda memberikan ke ctor 1) ptr x ke objek turunan yang akan dihapus dengan tipe U statis / ekspresidelete x;
(jadi Anda tidak memerlukan virtual dtor di sini) 2) atau bahkan fungsi pembersihan kustom. Itu berarti bahwa keadaan runtime digunakan di dalamshared_ptr
blok kontrol untuk menyandikan informasi itu. OTOHunique_ptr
tidak memiliki fungsi seperti itu dan tidak menyandikan perilaku penghapusan di negara; satu-satunya cara untuk menyesuaikan pembersihan adalah dengan membuat instanciation template lain (tipe kelas lain).Pertama kita perlu kembali ke apa artinya lulus dengan nilai dan referensi.
Untuk bahasa seperti Java dan SML, pass by value sangat mudah (dan tidak ada pass by reference), sama seperti menyalin nilai variabel, karena semua variabel hanyalah skalar dan telah dibangun di dalam semantic copy: mereka adalah apa yang dihitung sebagai aritmatika ketik C ++, atau "referensi" (pointer dengan nama dan sintaks yang berbeda).
Di C kami memiliki tipe skalar dan yang ditentukan pengguna:
Dalam C ++ tipe yang ditentukan pengguna dapat memiliki semantik salin yang ditentukan pengguna, yang memungkinkan pemrograman "berorientasi objek" dengan objek dengan kepemilikan sumber daya dan operasi "salinan dalam". Dalam kasus seperti itu, operasi penyalinan sebenarnya adalah panggilan ke fungsi yang hampir dapat melakukan operasi sewenang-wenang.
Untuk C struct yang dikompilasi sebagai C ++, "penyalinan" masih didefinisikan sebagai memanggil operasi penyalinan yang ditentukan pengguna (baik operator konstruktor atau penugasan), yang secara implisit dihasilkan oleh kompiler. Ini berarti bahwa semantik program subset umum C / C ++ berbeda dalam C dan C ++: dalam C tipe agregat keseluruhan disalin, dalam C ++ fungsi penyalinan yang dihasilkan secara implisit dipanggil untuk menyalin setiap anggota; hasil akhirnya adalah bahwa dalam setiap kasus masing-masing anggota disalin.
(Saya pikir, ada pengecualian ketika struct di dalam sebuah serikat disalin.)
Jadi untuk tipe kelas, satu-satunya cara (di luar salinan union) untuk membuat instance baru adalah melalui konstruktor (bahkan bagi mereka yang memiliki konstruktor kompiler yang dihasilkan sepele).
Anda tidak dapat mengambil alamat nilai melalui operator unary
&
tetapi itu tidak berarti bahwa tidak ada objek nilai; dan suatu objek, menurut definisi, memiliki alamat ; dan alamat itu bahkan diwakili oleh konstruksi sintaks: objek tipe kelas hanya dapat dibuat oleh konstruktor, dan memilikithis
pointer; tetapi untuk jenis sepele, tidak ada konstruktor yang ditulis pengguna sehingga tidak ada tempat untuk meletakkanthis
sampai setelah salinan dibuat, dan dinamai.Untuk jenis skalar, nilai suatu objek adalah nilai dari objek, nilai matematika murni yang disimpan ke dalam objek.
Untuk jenis kelas, satu-satunya gagasan tentang nilai objek adalah salinan lain dari objek, yang hanya dapat dibuat oleh copy constructor, fungsi nyata (walaupun untuk tipe sepele yang fungsinya sangat khusus sepele, kadang-kadang ini bisa menjadi dibuat tanpa memanggil konstruktor). Itu berarti bahwa nilai objek adalah hasil dari perubahan status program global dengan eksekusi . Itu tidak mengakses secara matematis.
Jadi, lulus dengan nilai sebenarnya bukan hal: itu lewat panggilan copy constructor , yang kurang cantik. Copy constructor diharapkan untuk melakukan operasi "copy" yang masuk akal sesuai dengan semantik yang tepat dari tipe objek, dengan menghormati invarian internalnya (yang merupakan properti pengguna abstrak, bukan properti C ++ intrinsik).
Lewati dengan nilai objek kelas berarti:
Perhatikan bahwa masalah tidak ada hubungannya dengan apakah salinan itu sendiri adalah objek dengan alamat: semua parameter fungsi adalah objek dan memiliki alamat (pada tingkat semantik bahasa).
Masalahnya adalah apakah:
Dalam kasus tipe kelas sepele, Anda masih dapat menentukan anggota salinan anggota asli, sehingga Anda bisa menentukan nilai murni asli karena sepele dari operasi penyalinan (copy constructor dan assignment). Tidak demikian halnya dengan fungsi pengguna khusus yang sewenang-wenang: nilai dokumen asli harus berupa salinan yang dibuat.
Objek kelas harus dibangun oleh penelepon; konstruktor secara formal memiliki
this
pointer tetapi formalisme tidak relevan di sini: semua objek secara formal memiliki alamat tetapi hanya mereka yang benar-benar mendapatkan alamatnya digunakan dengan cara yang tidak murni lokal (tidak seperti*&i = 1;
yang menggunakan alamat lokal murni) perlu memiliki definisi yang baik alamat.Objek harus benar-benar melalui alamat jika harus memiliki alamat di kedua fungsi yang dikompilasi secara terpisah ini:
Di sini bahkan jika
something(address)
adalah fungsi murni atau makro atau apa pun (sepertiprintf("%p",arg)
) yang tidak dapat menyimpan alamat atau berkomunikasi dengan entitas lain, kami memiliki persyaratan untuk melewati alamat karena alamat harus didefinisikan dengan baik untuk objek unikint
yang memiliki keunikan identitas.Kami tidak tahu apakah fungsi eksternal akan "murni" dalam hal alamat yang diteruskan ke sana.
Di sini potensi untuk penggunaan nyata dari alamat baik dalam konstruktor non sepele atau destruktor di sisi pemanggil mungkin alasan untuk mengambil rute yang aman dan sederhana dan memberikan objek identitas di pemanggil dan menyampaikan alamatnya, karena itu membuat yakin bahwa setiap penggunaan non-sepele alamatnya di konstruktor, setelah konstruksi dan di destruktor konsisten :
this
harus tampak sama atas keberadaan objek.Konstruktor atau destruktor non trivial seperti fungsi lain dapat menggunakan
this
pointer dengan cara yang membutuhkan konsistensi atas nilainya meskipun beberapa objek dengan hal-hal non sepele mungkin tidak:Perhatikan bahwa dalam kasus itu, meskipun menggunakan pointer secara eksplisit (sintaksis eksplisit
this->
), identitas objek tidak relevan: kompiler dapat menggunakan bitwise untuk menyalin objek sekitar untuk memindahkannya dan melakukan "salin elisi". Ini didasarkan pada tingkat "kemurnian" penggunaanthis
dalam fungsi anggota khusus (alamat tidak lepas).Tetapi kemurnian bukanlah atribut yang tersedia di tingkat deklarasi standar (ada ekstensi kompiler yang menambahkan deskripsi kemurnian pada deklarasi fungsi non-inline), jadi Anda tidak dapat mendefinisikan ABI berdasarkan pada kemurnian kode yang mungkin tidak tersedia (kode mungkin atau mungkin tidak sebaris dan tersedia untuk analisis).
Kemurnian diukur sebagai "tentu murni" atau "tidak murni atau tidak diketahui". Dasar bersama, atau batas atas semantik (sebenarnya maksimum), atau LCM (Least Common Multiple) "tidak diketahui". Jadi ABI memutuskan tidak diketahui.
Ringkasan:
Kemungkinan pekerjaan di masa depan:
Apakah penjelasan kemurnian cukup bermanfaat untuk digeneralisasi dan distandarisasi?
sumber
void foo(unique_ptr<int> ptr)
mengambil objek kelas dengan nilai . Objek itu memiliki anggota pointer, tetapi kita berbicara tentang objek kelas itu sendiri yang dilewatkan oleh referensi. (Karena itu tidak dapat disalin secara trivial maka konstruktor / destruktornya perlu konsistenthis
.) Itulah argumen yang sebenarnya dan tidak terhubung dengan contoh pertama lewat referensi secara eksplisit ; dalam hal ini pointer dilewatkan dalam register.int
: Saya menulis contoh "smart fileno" yang menggambarkan bahwa "kepemilikan" tidak ada hubungannya dengan "membawa ptr".unique_ptr<T*>
, ini adalah ukuran dan tata letak yang sama sepertiT*
dan cocok dalam register. Objek kelas yang dapat disalin secara sepele dapat diteruskan dengan nilai dalam register di Sistem x86-64, seperti kebanyakan konvensi pemanggilan. Hal ini membuat sebuah salinan dariunique_ptr
objek, tidak seperti di Andaint
contoh di mana callee ini&i
adalah alamat dari penelepon itui
karena Anda lewat referensi di C tingkat ++ , bukan hanya sebagai detail implementasi asm.unique_ptr
objek; itu menggunakanstd::move
sehingga aman untuk menyalinnya karena itu tidak akan menghasilkan 2 salinan yang samaunique_ptr
. Tetapi untuk jenis yang dapat disalin secara sepele, ya, ia memang menyalin seluruh objek agregat. Jika itu adalah anggota tunggal, konvensi pemanggilan yang baik memperlakukannya sama seperti skalar dari jenis itu.struct{}
adalah sebuah C ++ struct. Mungkin Anda harus mengatakan "struct sederhana", atau "tidak seperti C". Karena ya, ada perbedaan. Jika Anda menggunakanatomic_int
sebagai anggota struct, C akan secara non-atomik menyalinnya, kesalahan C ++ pada konstruktor salinan yang dihapus. Saya lupa apa yang dilakukan C ++ pada struct denganvolatile
anggota. C akan membiarkan Anda melakukannyastruct tmp = volatile_struct;
untuk menyalin semuanya (berguna untuk SeqLock); C ++ tidak akan.