Saya telah belajar beberapa C ++, dan sering harus mengembalikan objek besar dari fungsi yang dibuat di dalam fungsi. Saya tahu ada pass by reference, mengembalikan pointer, dan mengembalikan solusi tipe referensi, tetapi saya juga membaca bahwa kompiler C ++ (dan standar C ++) memungkinkan untuk optimasi nilai balik, yang menghindari menyalin objek-objek besar ini melalui memori, dengan demikian menghemat waktu dan memori semua itu.
Sekarang, saya merasa bahwa sintaks lebih jelas ketika objek secara eksplisit dikembalikan oleh nilai, dan kompiler umumnya akan menggunakan RVO dan membuat proses lebih efisien. Apakah praktik yang buruk mengandalkan optimasi ini? Itu membuat kode lebih jelas dan lebih mudah dibaca oleh pengguna, yang sangat penting, tetapi haruskah saya berhati-hati dengan menganggap kompiler akan menangkap peluang RVO?
Apakah ini optimasi mikro, atau sesuatu yang harus saya ingat ketika mendesain kode saya?
sumber
Jawaban:
Gunakan prinsip yang paling mengejutkan .
Apakah Anda dan hanya Anda yang akan menggunakan kode ini, dan apakah Anda yakin bahwa Anda yang sama dalam 3 tahun tidak akan terkejut dengan apa yang Anda lakukan?
Lalu, lanjutkan.
Dalam semua kasus lain, gunakan cara standar; jika tidak, Anda dan kolega Anda akan menemui kesulitan untuk menemukan bug.
Misalnya, kolega saya mengeluh tentang kode saya yang menyebabkan kesalahan. Ternyata, ia mematikan evaluasi Boolean hubung singkat dalam pengaturan kompilernya. Saya hampir menamparnya.
sumber
Untuk kasus khusus ini, pasti hanya mengembalikan dengan nilai.
RVO dan NRVO adalah optimasi terkenal dan kuat yang benar-benar harus dibuat oleh kompiler yang layak, bahkan dalam mode C ++ 03.
Pindahkan semantik memastikan bahwa objek dipindahkan dari fungsi jika (N) RVO tidak terjadi. Itu hanya berguna jika objek Anda menggunakan data dinamis secara internal (seperti
std::vector
halnya), tetapi itu harus benar-benar menjadi kasus jika itu yang besar - meluap tumpukan adalah risiko dengan objek otomatis besar.C ++ 17 memberlakukan RVO. Jadi jangan khawatir, itu tidak akan hilang pada Anda dan hanya akan selesai memantapkan dirinya sepenuhnya setelah kompiler up-to-date.
Dan pada akhirnya, memaksakan alokasi dinamis tambahan untuk mengembalikan pointer, atau memaksa tipe hasil Anda menjadi default-konstruktif hanya agar Anda bisa meneruskannya sebagai parameter output adalah solusi yang jelek dan non-idiomatik untuk masalah yang Anda mungkin tidak akan pernah memiliki.
Cukup tulis kode yang masuk akal dan ucapkan terima kasih kepada pembuat kompiler untuk mengoptimalkan kode yang masuk akal.
sumber
Ini bukan sedikit yang diketahui, imut, optimisasi mikro yang Anda baca di beberapa blog kecil yang diperdagangkan, lalu Anda merasa pintar dan unggul dalam menggunakannya.
Setelah C ++ 11, RVO adalah cara standar untuk menulis kode kode ini. Biasa, diharapkan, diajarkan, disebutkan dalam pembicaraan, disebutkan dalam blog, disebutkan dalam standar, akan dilaporkan sebagai bug penyusun jika tidak diterapkan. Dalam C ++ 17, bahasa ini selangkah lebih maju dan mengamanatkan penyalinan salinan dalam skenario tertentu.
Anda harus benar-benar mengandalkan pengoptimalan ini.
Lebih dari itu, return-by-value hanya mengarah ke kode yang lebih mudah dibaca dan dikelola daripada kode yang dikembalikan dengan referensi. Nilai semantik adalah hal yang kuat, yang dengan sendirinya dapat mengarah pada peluang optimisasi yang lebih banyak.
sumber
Ketepatan kode yang Anda tulis tidak boleh bergantung pada pengoptimalan. Seharusnya menampilkan hasil yang benar ketika dieksekusi pada "mesin virtual" C ++ yang mereka gunakan dalam spesifikasi.
Namun, apa yang Anda bicarakan lebih merupakan pertanyaan efisiensi. Kode Anda berjalan lebih baik jika dioptimalkan dengan kompiler yang mengoptimalkan RVO. Tidak apa-apa, untuk semua alasan yang ditunjukkan dalam jawaban lain.
Namun, jika Anda memerlukan pengoptimalan ini (seperti jika konstruktor salinan akan benar-benar menyebabkan kode Anda gagal), sekarang Anda berada di kehendak kompilator.
Saya pikir contoh terbaik dari ini dalam praktik saya sendiri adalah optimasi panggilan ekor:
Ini adalah contoh konyol, tetapi menunjukkan panggilan ekor, di mana suatu fungsi disebut tepat secara rekursif di akhir fungsi. Mesin virtual C ++ akan menunjukkan bahwa kode ini beroperasi dengan benar, meskipun saya dapat menyebabkan sedikit kebingungan mengapa saya repot menulis rutin tambahan seperti itu di tempat pertama. Namun, dalam implementasi praktis C ++, kami memiliki setumpuk, dan memiliki ruang terbatas. Jika dilakukan secara pedantik, fungsi ini harus mendorong setidaknya
b + 1
tumpukan bingkai ke tumpukan seperti halnya penambahannya. Jika saya ingin menghitungsillyAdd(5, 7)
, ini bukan masalah besar. Jika saya ingin menghitungsillyAdd(0, 1000000000)
, saya bisa berada dalam kesulitan besar menyebabkan StackOverflow (dan bukan jenis yang baik ).Namun, kita dapat melihat bahwa begitu kita mencapai garis balik terakhir, kita benar-benar selesai dengan semua yang ada di bingkai tumpukan saat ini. Kami benar-benar tidak perlu menyimpannya. Optimasi panggilan ekor memungkinkan Anda "menggunakan kembali" bingkai tumpukan yang ada untuk fungsi selanjutnya. Dengan cara ini, kita hanya perlu 1 frame stack, daripada
b+1
. (Kita masih harus melakukan semua penambahan dan pengurangan yang konyol itu, tetapi mereka tidak mengambil lebih banyak ruang.) Akibatnya, optimasi mengubah kode menjadi:Dalam beberapa bahasa, optimisasi panggilan ekor secara eksplisit diperlukan oleh spesifikasi. C ++ bukan salah satunya. Saya tidak bisa mengandalkan kompiler C ++ untuk mengenali peluang optimisasi panggilan ekor ini, kecuali saya menggunakan kasus per kasus. Dengan versi Visual Studio saya, versi rilis melakukan optimasi panggilan ekor, tetapi versi debug tidak (menurut desain).
Dengan demikian akan buruk bagi saya untuk bergantung pada kemampuan untuk menghitung
sillyAdd(0, 1000000000)
.sumber
#ifdef
blok, dan memiliki solusi standar yang tersedia.b = b + 1
?Dalam praktiknya, program C ++ mengharapkan beberapa optimasi kompiler.
Lihatlah terutama ke header standar implementasi kontainer standar Anda . Dengan GCC , Anda dapat meminta formulir preproses (
g++ -C -E
) dan representasi internal GIMPLE (g++ -fdump-tree-gimple
atau Gimple SSA dengan-fdump-tree-ssa
) sebagian besar file sumber (unit terjemahan teknis) menggunakan wadah. Anda akan terkejut dengan jumlah optimasi yang dilakukan (dengang++ -O2
). Jadi implementor kontainer bergantung pada optimisasi (dan sebagian besar waktu, implementator perpustakaan standar C ++ tahu apa yang akan terjadi optimasi dan menulis implementasi kontainer dengan yang ada dalam pikiran; kadang-kadang ia juga akan menulis pass optimisasi dalam kompiler untuk berurusan dengan fitur-fitur yang diperlukan oleh perpustakaan C ++ standar saat itu).Dalam praktiknya, optimisasi kompiler yang membuat C ++ dan kontainer standarnya cukup efisien. Jadi Anda bisa mengandalkan mereka.
Dan juga untuk kasus RVO yang disebutkan dalam pertanyaan Anda.
Standar C ++ dirancang bersama (terutama dengan bereksperimen dengan optimasi yang cukup baik sambil mengusulkan fitur-fitur baru) untuk bekerja dengan baik dengan optimasi yang mungkin.
Misalnya, pertimbangkan program di bawah ini:
kompilasi dengan
g++ -O3 -fverbose-asm -S
. Anda akan mengetahui bahwa fungsi yang dihasilkan tidak menjalankanCALL
instruksi mesin apa pun . Jadi sebagian besar langkah-langkah C ++ (konstruksi penutupan lambda, aplikasi berulang, mendapatkanbegin
danend
iterator, dll ...) telah dioptimalkan. Kode mesin hanya berisi satu loop (yang tidak muncul secara eksplisit dalam kode sumber). Tanpa optimasi seperti itu, C ++ 11 tidak akan berhasil.tambahan
(tambah Desember 31 st 2017)
Lihat CppCon 2017: Matt Godbolt “Apa yang Telah Dilakukan Kompiler Saya Akhir-akhir Ini? Membuka kunci Tutup Pengumpul ” bicara.
sumber
Setiap kali Anda menggunakan kompiler, pengertiannya adalah ia akan menghasilkan kode mesin atau byte untuk Anda. Itu tidak menjamin apa pun tentang seperti apa kode yang dihasilkan, kecuali bahwa itu akan mengimplementasikan kode sumber sesuai dengan spesifikasi bahasa. Perhatikan bahwa jaminan ini sama terlepas dari tingkat optimasi yang digunakan, dan, secara umum, tidak ada alasan untuk menganggap satu output lebih 'benar' dari yang lain.
Lebih jauh lagi, dalam kasus-kasus itu, seperti RVO, di mana ia ditentukan dalam bahasa, tampaknya tidak ada gunanya pergi keluar dari cara Anda untuk menghindari menggunakannya, terutama jika itu membuat kode sumber lebih sederhana.
Banyak upaya dilakukan untuk membuat kompiler menghasilkan output yang efisien, dan jelas tujuannya adalah agar kapabilitas tersebut digunakan.
Mungkin ada alasan untuk menggunakan kode yang tidak dioptimalkan (untuk debugging, misalnya), tetapi kasus yang disebutkan dalam pertanyaan ini tampaknya bukan salah satu (dan jika kode Anda gagal hanya ketika dioptimalkan, dan itu bukan konsekuensi dari beberapa kekhasan dari perangkat tempat Anda menjalankannya, maka ada bug di suatu tempat, dan tidak mungkin ada di kompiler.)
sumber
Saya pikir orang lain membahas sudut khusus tentang C ++ dan RVO dengan baik. Ini jawaban yang lebih umum:
Ketika datang ke kebenaran, Anda tidak harus bergantung pada optimisasi kompiler, atau perilaku spesifik kompiler secara umum. Untungnya, Anda sepertinya tidak melakukan ini.
Ketika datang ke kinerja, Anda harus bergantung pada perilaku spesifik kompiler secara umum, dan optimisasi kompiler pada khususnya. Compiler compliant standar bebas untuk mengkompilasi kode Anda dengan cara apa pun yang diinginkan, selama kode yang dikompilasi berperilaku sesuai dengan spesifikasi bahasa. Dan saya tidak mengetahui adanya spesifikasi untuk bahasa umum yang menentukan seberapa cepat setiap operasi harus.
sumber
Optimalisasi kompiler hanya akan mempengaruhi kinerja, bukan hasil. Mengandalkan optimisasi kompiler untuk memenuhi persyaratan yang tidak fungsional tidak hanya masuk akal, itu sering menjadi alasan mengapa satu kompiler dipilih dari yang lain.
Bendera yang menentukan bagaimana operasi tertentu dilakukan (misalnya kondisi indeks atau luapan), sering disamakan dengan optimisasi kompiler, tetapi seharusnya tidak. Mereka secara eksplisit mempengaruhi hasil perhitungan.
Jika optimisasi kompiler menyebabkan hasil yang berbeda, itu adalah bug - bug di kompiler. Mengandalkan bug di kompiler, apakah dalam jangka panjang kesalahan - apa yang terjadi ketika diperbaiki?
Menggunakan flag compiler yang mengubah cara penghitungan kerja harus didokumentasikan dengan baik, tetapi digunakan sesuai kebutuhan.
sumber
x*y>z
acak menghasilkan 0 atau 1 jika terjadi luapan, asalkan ia tidak memiliki efek samping lain , yang mensyaratkan bahwa seorang programmer harus mencegah kelebihan pada semua biaya atau memaksa kompiler untuk mengevaluasi ekspresi dengan cara tertentu akan tidak perlu merusak optimasi vs mengatakan bahwa ...x*y
mempromosikan operan ke beberapa jenis lagi yang sewenang-wenang (sehingga memungkinkan bentuk mengangkat dan mengurangi kekuatan yang akan mengubah perilaku beberapa kasus overflow) Banyak kompiler, bagaimanapun, mengharuskan programmer baik mencegah overflow di semua biaya atau memaksa kompiler untuk memotong semua nilai perantara jika terjadi overflow.Tidak.
Itu yang saya lakukan sepanjang waktu. Jika saya perlu mengakses blok 16 bit sewenang-wenang dalam memori, saya melakukan ini
... dan andalkan kompiler melakukan apa pun untuk mengoptimalkan potongan kode itu. Kode ini berfungsi pada ARM, i386, AMD64, dan praktis pada setiap arsitektur di luar sana. Secara teori, kompiler yang tidak mengoptimalkan sebenarnya dapat memanggil
memcpy
, menghasilkan kinerja yang benar-benar buruk, tetapi itu tidak masalah bagi saya, karena saya menggunakan optimasi kompiler.Pertimbangkan alternatifnya:
Kode alternatif ini gagal berfungsi pada mesin yang membutuhkan penyelarasan yang tepat, jika
get_pointer()
mengembalikan pointer yang tidak selaras. Juga, mungkin ada masalah alias dalam alternatif.Perbedaan antara -O2 dan -O0 saat menggunakan
memcpy
trik itu hebat: 3,2 Gbps kinerja IP checksum versus 67 Gbps kinerja IP checksum. Atas urutan perbedaan besarnya!Terkadang Anda mungkin perlu membantu kompiler. Jadi, misalnya, alih-alih mengandalkan kompiler untuk membuka gulungan, Anda dapat melakukannya sendiri. Baik dengan mengimplementasikan perangkat Duff yang terkenal , atau dengan cara yang lebih bersih.
Kelemahan dari mengandalkan optimisasi kompiler adalah bahwa jika Anda menjalankan gdb untuk men-debug kode Anda, Anda mungkin menemukan bahwa banyak yang telah dioptimalkan. Jadi, Anda mungkin perlu mengkompilasi ulang dengan -O0, yang berarti kinerja akan sangat menyedot ketika debugging. Saya pikir ini adalah kelemahan yang layak diambil, mengingat manfaat dari mengoptimalkan kompiler.
Apa pun yang Anda lakukan, pastikan cara Anda sebenarnya bukan perilaku yang tidak terdefinisi. Tentu saja mengakses beberapa blok memori acak sebagai integer 16-bit adalah perilaku yang tidak terdefinisi karena masalah aliasing dan alignment.
sumber
Semua upaya pada kode efisien yang ditulis dalam apa pun kecuali assembly sangat bergantung pada optimisasi kompiler, dimulai dengan alokasi register efisien paling dasar seperti untuk menghindari tumpahan tumpukan berlebihan di semua tempat dan setidaknya cukup baik, jika tidak bagus, pemilihan instruksi. Kalau tidak kita akan kembali ke tahun 80-an di mana kita harus meletakkan
register
petunjuk di semua tempat dan menggunakan jumlah minimum variabel dalam suatu fungsi untuk membantu kompiler C kuno atau bahkan lebih awal ketikagoto
itu adalah optimasi cabang yang berguna.Jika kami merasa tidak dapat mengandalkan kemampuan pengoptimal kami untuk mengoptimalkan kode kami, kami semua masih akan mengkode jalur eksekusi kinerja-kritis dalam perakitan.
Ini benar-benar masalah seberapa andal Anda merasa optimisasi dapat dibuat yang paling baik disortir dengan profiling dan melihat kemampuan kompiler yang Anda miliki dan bahkan mungkin membongkar jika ada hotspot Anda tidak dapat mengetahui di mana kompiler tampaknya gagal membuat optimasi yang jelas.
RVO adalah sesuatu yang telah ada selama berabad-abad, dan, setidaknya tidak termasuk kasus yang sangat kompleks, adalah sesuatu yang kompiler telah diandalkan untuk usia. Jelas tidak ada gunanya mengatasi masalah yang tidak ada.
Keliru Mengandalkan Pengoptimal, Tidak Takut
Sebaliknya, saya katakan sesat di sisi terlalu mengandalkan optimisasi kompiler daripada terlalu sedikit, dan saran ini datang dari seorang pria yang bekerja di bidang yang sangat kritis terhadap kinerja di mana efisiensi, rawatan, dan kualitas yang dirasakan di antara pelanggan adalah semua satu kekaburan raksasa. Saya lebih suka membuat Anda terlalu percaya diri pada pengoptimal Anda dan menemukan beberapa kasus tepi yang tidak jelas di mana Anda mengandalkan terlalu banyak daripada hanya mengandalkan terlalu sedikit dan hanya mengkodekan ketakutan takhayul sepanjang waktu selama sisa hidup Anda. Setidaknya itu akan membuat Anda meraih profiler dan menyelidikinya dengan benar jika segala sesuatunya tidak berjalan secepat yang seharusnya dan mendapatkan pengetahuan yang berharga, bukan takhayul, di sepanjang jalan.
Anda melakukannya dengan baik untuk bersandar pada pengoptimal. Teruskan. Jangan menjadi seperti orang yang mulai secara eksplisit meminta untuk menyelaraskan setiap fungsi yang dipanggil dalam satu lingkaran bahkan sebelum membuat profil dari ketakutan yang salah kaprah akan kekurangan pengoptimal.
Pembuatan profil
Profiling sebenarnya adalah bundaran tetapi jawaban akhir untuk pertanyaan Anda. Para pemula yang tidak sabar ingin menulis kode yang efisien sering bergumul dengan bukan apa yang harus dioptimalkan, itu yang tidak untuk mengoptimalkan karena mereka mengembangkan semua jenis firasat salah tentang ketidakefisienan yang, meskipun secara manusiawi intuitif, secara komputasi salah. Mengembangkan pengalaman dengan profiler akan mulai benar-benar memberi Anda apresiasi yang tepat tidak hanya pada kemampuan pengoptimalan kompiler yang Anda percayai, tetapi juga kemampuan (serta keterbatasan) perangkat keras Anda. Ada yang lebih berharga dalam membuat profil dalam mempelajari apa yang tidak layak untuk dioptimalkan daripada mempelajari apa yang sebelumnya.
sumber
Perangkat lunak dapat ditulis dalam C ++ pada platform yang sangat berbeda dan untuk banyak tujuan berbeda.
Ini sepenuhnya tergantung pada tujuan perangkat lunak. Haruskah mudah untuk mempertahankan, memperluas, menambal, refactor dll. atau hal lain yang lebih penting, seperti kinerja, biaya atau kompatibilitas dengan beberapa perangkat keras tertentu atau waktu yang diperlukan untuk berkembang.
sumber
Saya pikir jawaban yang membosankan untuk ini adalah: 'itu tergantung'.
Apakah praktik yang buruk untuk menulis kode yang bergantung pada pengoptimalan kompiler yang kemungkinan akan dimatikan dan di mana kerentanan tidak didokumentasikan dan di mana kode tersebut tidak diuji unit sehingga jika rusak Anda akan mengetahuinya ? Mungkin.
Apakah praktik yang buruk untuk menulis kode yang mengandalkan optimisasi kompiler yang tidak mungkin dimatikan , yang didokumentasikan dan unit diuji ? Mungkin tidak.
sumber
Kecuali ada lebih banyak yang tidak Anda katakan kepada kami, ini adalah praktik yang buruk, tetapi bukan karena alasan yang Anda sarankan.
Mungkin tidak seperti bahasa lain yang telah Anda gunakan sebelumnya, mengembalikan nilai suatu objek di C ++ menghasilkan salinan objek. Jika Anda kemudian memodifikasi objek, Anda memodifikasi objek yang berbeda . Artinya, jika saya memiliki
Obj a; a.x=1;
danObj b = a;
, maka saya lakukanb.x += 2; b.f();
, makaa.x
masih sama dengan 1, bukan 3.Jadi tidak, menggunakan objek sebagai nilai alih-alih sebagai referensi atau pointer tidak memberikan fungsi yang sama dan Anda bisa berakhir dengan bug di perangkat lunak Anda.
Mungkin Anda tahu ini dan itu tidak berdampak negatif pada kasus penggunaan khusus Anda. Namun, berdasarkan kata-kata dalam pertanyaan Anda, tampaknya Anda mungkin tidak menyadari perbedaannya; kata-kata seperti "buat objek dalam fungsi."
"buat sebuah objek dalam fungsi" terdengar seperti di
new Obj;
mana "mengembalikan objek dengan nilai" terdengar sepertiObj a; return a;
Obj a;
danObj* a = new Obj;
hal-hal yang sangat, sangat berbeda; yang pertama dapat menyebabkan kerusakan memori jika tidak digunakan dan dipahami dengan benar, dan yang terakhir dapat menyebabkan kebocoran memori jika tidak digunakan dan dipahami dengan benar.sumber
return
pernyataan yang merupakan persyaratan untuk RVO. Selanjutnya, Anda kemudian berbicara tentang kata kuncinew
dan petunjuk, yang bukan tentang RVO. Saya yakin Anda tidak mengerti pertanyaannya, atau RVO, atau mungkin keduanya.Pieter B benar sekali dalam merekomendasikan paling tidak heran.
Untuk menjawab pertanyaan spesifik Anda, apa artinya ini (kemungkinan besar) dalam C ++ adalah Anda harus mengembalikan a
std::unique_ptr
ke objek yang dikonstruksi.Alasannya adalah bahwa ini lebih jelas untuk pengembang C ++ tentang apa yang terjadi.
Meskipun pendekatan Anda kemungkinan besar akan berhasil, Anda secara efektif memberi sinyal bahwa objek tersebut adalah tipe nilai yang kecil padahal sebenarnya tidak. Selain itu, Anda membuang segala kemungkinan untuk abstraksi antarmuka. Ini mungkin OK untuk keperluan Anda saat ini tetapi sering sangat berguna ketika berhadapan dengan matriks.
Saya menghargai bahwa jika Anda berasal dari bahasa lain, semua tanda dapat membingungkan pada awalnya. Tapi hati-hati untuk tidak menganggap bahwa, dengan tidak menggunakannya, Anda membuat kode Anda lebih jelas. Dalam praktiknya, yang terjadi justru sebaliknya.
sumber
std::make_unique
, bukanstd::unique_ptr
langsung. Kedua, RVO bukan esoterik, optimisasi khusus vendor: dimasukkan ke dalam standar. Bahkan ketika itu bukan, itu didukung secara luas dan perilaku yang diharapkan. Tidak ada titik mengembalikan sebuahstd::unique_ptr
ketika pointer tidak diperlukan di tempat pertama.