Spesifikasi C \ C ++ membuat sejumlah besar perilaku terbuka bagi kompiler untuk diimplementasikan dengan cara mereka sendiri. Ada sejumlah pertanyaan yang selalu ditanyakan di sini tentang hal yang sama dan kami memiliki beberapa posting yang sangat baik tentang hal itu:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-afterence-points
Pertanyaan saya bukan tentang apa perilaku tidak terdefinisi itu, atau apakah itu benar-benar buruk. Saya tahu bahaya dan sebagian besar kutipan perilaku tidak terdefinisi yang relevan dari standar, jadi tolong jangan posting jawaban tentang seberapa buruk itu. Pertanyaan ini adalah tentang filosofi di balik membiarkan begitu banyak perilaku terbuka untuk implementasi kompiler.
Saya membaca posting blog yang sangat bagus yang menyatakan bahwa kinerja adalah alasan utama. Saya bertanya-tanya apakah kinerja adalah satu-satunya kriteria untuk mengizinkannya, atau adakah faktor lain yang mempengaruhi keputusan untuk membiarkan hal-hal terbuka untuk implementasi kompiler?
Jika Anda memiliki contoh untuk dikutip tentang bagaimana perilaku tertentu yang tidak terdefinisi menyediakan ruang yang cukup untuk dioptimalkan oleh kompiler, harap daftarkan mereka. Jika Anda mengetahui faktor-faktor lain selain kinerja, silakan balikkan jawaban Anda dengan detail yang cukup.
Jika Anda tidak memahami pertanyaan atau tidak memiliki bukti / sumber yang cukup untuk mendukung jawaban Anda, jangan posting jawaban yang berspekulasi secara luas.
sumber
Jawaban:
Pertama, saya perhatikan bahwa meskipun saya hanya menyebutkan "C" di sini, hal yang sama juga berlaku untuk C ++.
Komentar yang menyebutkan Godel sebagian (tetapi hanya sebagian) tepat sasaran.
Ketika Anda sampai ke sana, perilaku yang tidak terdefinisi dalam standar C sebagian besar hanya menunjukkan batas antara apa yang berusaha didefinisikan oleh standar, dan apa yang tidak.
Teorema Godel (ada dua) pada dasarnya mengatakan bahwa mustahil untuk mendefinisikan sistem matematika yang dapat dibuktikan (dengan aturannya sendiri) menjadi lengkap dan konsisten. Anda dapat membuat aturan Anda sehingga bisa lengkap (kasus yang dia tangani adalah aturan "normal" untuk bilangan asli), atau Anda dapat membuatnya membuktikan konsistensi, tetapi Anda tidak dapat memiliki keduanya.
Dalam hal sesuatu seperti C, yang tidak berlaku secara langsung - sebagian besar, "provabilitas" kelengkapan atau konsistensi sistem bukanlah prioritas tinggi bagi sebagian besar perancang bahasa. Pada saat yang sama, ya, mereka mungkin dipengaruhi (setidaknya sampai taraf tertentu) dengan mengetahui bahwa mustahil untuk mendefinisikan sistem "sempurna" - sistem yang terbukti lengkap dan konsisten. Mengetahui bahwa hal seperti itu tidak mungkin mungkin membuatnya sedikit lebih mudah untuk mundur, bernapas sedikit, dan memutuskan batasan-batasan apa yang akan mereka coba definisikan.
Dengan risiko (lagi-lagi) dituduh sombong, saya akan menganggap standar C sebagai yang diatur (sebagian) oleh dua ide dasar:
Yang pertama berarti bahwa jika seseorang mendefinisikan CPU baru, harus dimungkinkan untuk memberikan implementasi C yang baik, solid, dapat digunakan untuk itu, selama desain jatuh setidaknya cukup dekat dengan beberapa pedoman sederhana - pada dasarnya, jika mengikuti sesuatu pada urutan umum model Von Neumann, dan menyediakan setidaknya sejumlah memori minimum yang masuk akal, yang seharusnya cukup untuk memungkinkan implementasi C. Untuk implementasi "dihosting" (yang dijalankan pada OS) Anda perlu mendukung beberapa gagasan yang sesuai dengan file, dan memiliki set karakter dengan set karakter minimum tertentu (diperlukan 91).
Yang kedua berarti harus mungkin untuk menulis kode yang memanipulasi perangkat keras secara langsung, sehingga Anda dapat menulis hal-hal seperti boot loader, sistem operasi, perangkat lunak tertanam yang berjalan tanpa OS, dll. Pada akhirnya ada beberapa batasan dalam hal ini, sehingga hampir semua sistem praktis operasi, boot loader, dll, mungkin mengandung setidaknya sedikit sedikit kode yang ditulis dalam bahasa assembly. Demikian juga, bahkan sistem tertanam kecil kemungkinan akan menyertakan setidaknya semacam rutin perpustakaan pra-tertulis untuk memberikan akses ke perangkat pada sistem host. Meskipun batas yang tepat sulit untuk didefinisikan, tujuannya adalah bahwa ketergantungan pada kode tersebut harus dijaga agar tetap minimum.
Perilaku tidak terdefinisi dalam bahasa sebagian besar didorong oleh niat untuk bahasa untuk mendukung kemampuan ini. Misalnya, bahasa ini memungkinkan Anda untuk mengkonversi bilangan bulat sembarang menjadi penunjuk, dan mengakses apa pun yang terjadi di alamat itu. Standar tidak berusaha mengatakan apa yang akan terjadi ketika Anda melakukannya (misalnya, bahkan membaca dari beberapa alamat dapat memiliki pengaruh yang terlihat secara eksternal). Pada saat yang sama, tidak ada upaya mencegah Anda dari melakukan hal-hal seperti itu, karena Anda perlu untuk beberapa jenis perangkat lunak yang Anda seharusnya dapat menulis dalam C.
Ada beberapa perilaku tidak terdefinisi yang didorong oleh elemen desain lain juga. Misalnya, satu maksud C lainnya adalah untuk mendukung kompilasi terpisah. Ini berarti (misalnya) bahwa ini dimaksudkan agar Anda dapat "menautkan" potongan-potongan menggunakan tautan yang kira-kira mengikuti apa yang sebagian besar dari kita lihat sebagai model tautan biasa. Secara khusus, harus dimungkinkan untuk menggabungkan modul yang dikompilasi secara terpisah ke dalam program yang lengkap tanpa sepengetahuan semantik bahasa.
Ada tipe lain dari perilaku tidak terdefinisi (yang jauh lebih umum di C ++ daripada C), yang hadir hanya karena batasan pada teknologi kompiler - hal-hal yang pada dasarnya kita tahu adalah kesalahan, dan mungkin ingin kompiler mendiagnosis sebagai kesalahan, tetapi mengingat batas saat ini pada teknologi kompiler, diragukan bahwa mereka dapat didiagnosis dalam semua keadaan. Banyak dari ini didorong oleh persyaratan lain, seperti untuk kompilasi terpisah, sehingga sebagian besar masalah keseimbangan persyaratan yang saling bertentangan, dalam hal ini panitia umumnya memilih untuk mendukung kemampuan yang lebih besar, bahkan jika itu berarti kurangnya mendiagnosis beberapa masalah yang mungkin terjadi, daripada membatasi kemampuan untuk memastikan bahwa semua masalah yang mungkin didiagnosis.
Perbedaan-perbedaan ini dalam niat mendorong sebagian besar perbedaan antara C dan sesuatu seperti Java atau sistem berbasis CLI Microsoft. Yang terakhir ini secara eksplisit terbatas untuk bekerja dengan perangkat keras yang jauh lebih terbatas, atau membutuhkan perangkat lunak untuk meniru perangkat keras yang lebih spesifik yang mereka targetkan. Mereka juga secara khusus berniat untuk mencegah manipulasi langsung perangkat keras, alih-alih mengharuskan Anda menggunakan sesuatu seperti JNI atau P / Invoke (dan kode yang ditulis dalam sesuatu seperti C) untuk melakukan upaya semacam itu.
Kembali ke teorema Godel sejenak, kita dapat menggambar sesuatu yang paralel: Java dan CLI telah memilih alternatif "konsisten secara internal", sementara C telah memilih alternatif "lengkap". Tentu saja, ini analogi yang sangat kasar - saya ragu ada orang yang mencoba bukti formal baik konsistensi internal atau kelengkapan dalam kedua kasus. Meskipun demikian, gagasan umum tidak cukup cocok dengan pilihan yang telah mereka ambil.
sumber
Alasan C menjelaskan
Penting juga manfaat untuk program, tidak hanya manfaat untuk implementasi. Suatu program yang bergantung pada perilaku tidak terdefinisi masih dapat menyesuaikan , jika itu diterima oleh implementasi yang sesuai. Adanya perilaku yang tidak terdefinisi memungkinkan suatu program untuk menggunakan fitur-fitur non-portabel yang secara eksplisit ditandai seperti itu ("perilaku tidak terdefinisi"), tanpa menjadi tidak sesuai. Catatan rasionalnya:
Dan pada 1,7 dicatat
Dengan demikian, program kotor kecil ini yang berfungsi dengan baik pada GCC masih sesuai !
sumber
Masalah kecepatan terutama masalah bila dibandingkan dengan C. Jika C ++ melakukan beberapa hal yang mungkin masuk akal, seperti menginisialisasi array besar tipe primitif, itu akan kehilangan satu ton tolok ukur untuk kode C. Jadi C ++ menginisialisasi tipe datanya sendiri, tetapi membiarkan tipe C seperti sebelumnya.
Perilaku tidak terdefinisi lainnya hanya mencerminkan kenyataan. Salah satu contoh adalah bit-shifting dengan jumlah yang lebih besar dari tipe. Itu sebenarnya berbeda antara generasi perangkat keras dari keluarga yang sama. Jika Anda memiliki aplikasi 16-bit, biner yang sama persis akan memberikan hasil yang berbeda pada 80286 dan 80386. Jadi standar bahasa mengatakan bahwa kita tidak tahu!
Beberapa hal dipertahankan seperti semula, seperti urutan evaluasi subekspresi yang tidak ditentukan. Awalnya ini diyakini membantu kompiler mengoptimalkan penulis dengan lebih baik. Saat ini kompiler cukup baik untuk mengetahuinya, tetapi biaya untuk menemukan semua tempat di kompiler yang ada yang memanfaatkan kebebasan terlalu tinggi.
sumber
Sebagai salah satu contoh, akses pointer hampir tidak dapat ditentukan dan tidak harus hanya untuk alasan kinerja. Misalnya, pada beberapa sistem, memuat register spesifik dengan pointer akan menghasilkan pengecualian perangkat keras. Pada SPARC mengakses objek memori yang tidak selaras akan menyebabkan kesalahan bus, tetapi pada x86 itu akan "hanya" menjadi lambat. Sangat sulit untuk benar-benar menentukan perilaku dalam kasus-kasus tersebut karena perangkat keras yang mendikte menentukan apa yang akan terjadi, dan C ++ bersifat portabel untuk banyak jenis perangkat keras.
Tentu saja itu juga memberikan kebebasan kompiler untuk menggunakan pengetahuan khusus arsitektur. Untuk contoh perilaku yang tidak ditentukan, pergeseran kanan dari nilai yang ditandatangani mungkin logis atau aritmatika tergantung pada perangkat keras yang mendasarinya, untuk memungkinkan penggunaan operasi shift mana saja yang tersedia dan tidak memaksakan emulasi perangkat lunak terhadapnya.
Saya percaya itu juga membuat pekerjaan kompiler-penulis lebih mudah tetapi saya tidak dapat mengingat contohnya sekarang. Saya akan menambahkannya jika saya mengingat situasinya.
sumber
Sederhana: Kecepatan, dan portabilitas. Jika C ++ menjamin bahwa Anda mendapat pengecualian saat Anda membatalkan referensi pointer yang tidak valid, maka itu tidak akan portabel untuk perangkat keras yang disematkan. Jika C ++ dijamin beberapa hal lain seperti primitif selalu diinisialisasi, maka itu akan lebih lambat, dan pada saat asal C ++, lebih lambat adalah hal yang benar-benar buruk.
sumber
C ditemukan pada mesin dengan byte 9bit dan tanpa unit floating point - anggaplah ia mengamanatkan bahwa byte adalah 9bits, kata 18bits dan float harus diimplementasikan menggunakan pra-IEEE754 aritmatic?
sumber
Saya tidak berpikir alasan pertama untuk UB adalah untuk memberikan ruang bagi kompiler untuk mengoptimalkan, tetapi hanya kemungkinan untuk menggunakan implementasi yang jelas untuk target pada saat arsitektur memiliki lebih banyak variasi daripada sekarang (ingat jika C dirancang pada suatu PDP-11 yang memiliki arsitektur yang agak akrab, port pertama adalah ke Honeywell 635 yang jauh kurang dikenal - kata addressable, menggunakan kata-kata 36 bit, 6 atau 9 bit byte, alamat 18 bit ... well setidaknya itu digunakan 2's melengkapi). Tetapi jika optimasi berat bukan target, implementasi yang jelas tidak termasuk menambahkan run-time check untuk overflow, shift menghitung lebih dari ukuran register, yang alias dalam ekspresi memodifikasi beberapa nilai.
Hal lain yang diperhitungkan adalah kemudahan implementasi. Kompiler AC pada saat itu adalah beberapa lintasan menggunakan beberapa proses karena memiliki satu proses menangani semuanya tidak akan mungkin (program akan terlalu besar). Meminta pemeriksaan koherensi yang berat tidak memungkinkan - terutama ketika melibatkan beberapa CU. (Program lain selain kompiler C, lint, digunakan untuk itu).
sumber
i
dann
, sedemikian sehinggan < INT_BITS
dani*(1<<n)
tidak akan meluap, saya akan mempertimbangkani<<=n;
lebih jelas darii=(unsigned)i << n;
; pada banyak platform akan lebih cepat dan lebih kecil darii*=(1<<N);
. Apa yang didapat dari kompiler yang melarangnya?Salah satu kasus klasik awal ditandatangani tambahan bilangan bulat. Pada beberapa prosesor yang digunakan, itu akan menyebabkan kesalahan, dan yang lain hanya akan melanjutkan dengan nilai (kemungkinan nilai modular yang sesuai). Menentukan kedua kasus akan berarti bahwa program untuk mesin dengan gaya aritmatika yang tidak disukai harus memiliki kode tambahan, termasuk cabang bersyarat, untuk sesuatu yang sama seperti penambahan bilangan bulat.
sumber
int
16 bit dan tanda-perpanjangan shift mahal bisa menghitung(uchar1*uchar2) >> 4
menggunakan pergeseran non-tanda-diperpanjang. Sayangnya, beberapa kompiler memperluas inferensi tidak hanya pada hasil, tetapi pada operan.Saya akan mengatakan itu kurang tentang filsafat daripada tentang kenyataan - C selalu menjadi bahasa lintas platform, dan standar harus mencerminkan itu dan fakta bahwa pada saat standar apa pun dirilis, akan ada sejumlah besar implementasi pada banyak perangkat keras yang berbeda. Suatu standar yang melarang perilaku yang diperlukan akan diabaikan atau menghasilkan badan standar yang bersaing.
sumber
Beberapa perilaku tidak dapat didefinisikan dengan cara apa pun yang masuk akal. Maksud saya mengakses pointer yang dihapus. Satu-satunya cara untuk mendeteksinya adalah melarang nilai pointer setelah penghapusan (menghafal nilainya di suatu tempat dan tidak mengizinkan fungsi alokasi mengembalikannya lagi). Tidak hanya menghafal seperti itu akan berlebihan, tetapi untuk program yang berjalan lama akan menyebabkan kehabisan nilai pointer yang diizinkan.
sumber
weak_ptr
dan membatalkan semua referensi ke pointer yang mendapatdelete
... oh tunggu, kami sedang mendekati pengumpulan sampah: /boost::weak_ptr
Implementasi adalah template yang cukup bagus untuk memulai dengan pola penggunaan ini. Daripada melacak dan meniadakan secaraweak_ptrs
eksternal, yangweak_ptr
adil berkontribusi padashared_ptr
hitung lemah, dan hitung lemah pada dasarnya adalah penghitungan ulang ke penunjuk itu sendiri. Dengan demikian, Anda dapat membatalkanshared_ptr
tanpa harus segera menghapusnya. Itu tidak sempurna (Anda masih dapat memiliki banyak kadaluarsaweak_ptr
mempertahankan yang mendasarinyashared_count
tanpa alasan yang baik) tetapi setidaknya itu cepat dan efisien.Saya akan memberi Anda sebuah contoh di mana hampir tidak ada pilihan yang masuk akal selain perilaku yang tidak terdefinisi. Pada prinsipnya, pointer apa pun dapat menunjuk ke memori yang mengandung variabel apa pun, dengan pengecualian kecil variabel lokal yang diketahui kompiler tidak pernah diambil alamatnya. Namun, untuk mendapatkan kinerja yang dapat diterima pada CPU modern, kompiler harus menyalin nilai variabel ke register. Mengoperasikan sepenuhnya dari memori adalah non-starter.
Ini pada dasarnya memberi Anda dua pilihan:
1) Buang semuanya keluar dari register sebelum akses apa pun melalui pointer, kalau-kalau pointer menunjuk ke memori variabel tertentu itu. Kemudian muat semua yang diperlukan kembali ke register, kalau-kalau nilai-nilai diubah melalui pointer.
2) Memiliki seperangkat aturan untuk kapan pointer diizinkan untuk alias variabel dan ketika kompiler diizinkan untuk menganggap bahwa pointer tidak alias variabel.
C memilih opsi 2, karena 1 akan mengerikan untuk kinerja. Tapi kemudian, apa yang terjadi jika pointer alias variabel dengan cara aturan C melarang? Karena efeknya tergantung pada apakah kompilator memang menyimpan variabel dalam register, tidak ada cara bagi standar C untuk secara definitif menjamin hasil spesifik.
sumber
foo
ke 42, dan kemudian memanggil metode yang menggunakan pointer yang dimodifikasi secara tidak sah untuk diaturfoo
ke 44, saya dapat melihat manfaat untuk mengatakan bahwa sampai penulisan "sah" berikutnyafoo
, upaya untuk membacanya mungkin sah menghasilkan 42 atau 44, dan ekspresi sepertifoo+foo
bahkan bisa menghasilkan 86, tapi saya melihat jauh lebih sedikit manfaat untuk memungkinkan kompiler membuat kesimpulan diperpanjang dan bahkan retroaktif, mengubah Perilaku Tidak Terdefinisi yang perilaku "alami" yang masuk akal semuanya akan menjadi jinak, menjadi lisensi untuk menghasilkan kode yang tidak masuk akal.Secara historis, Perilaku Tidak Terdefinisi memiliki dua tujuan utama:
Untuk menghindari mengharuskan penulis kompiler untuk menghasilkan kode untuk menangani kondisi yang seharusnya tidak pernah terjadi.
Untuk memungkinkan kemungkinan bahwa tanpa adanya kode untuk secara eksplisit menangani kondisi seperti itu, implementasi dapat memiliki berbagai jenis perilaku "alami" yang, dalam beberapa kasus, akan berguna.
Sebagai contoh sederhana, pada beberapa platform perangkat keras, mencoba untuk menambahkan bersama dua bilangan bulat bertanda positif yang jumlahnya terlalu besar untuk masuk dalam bilangan bulat yang ditandatangani akan menghasilkan bilangan bulat bertanda negatif tertentu. Pada implementasi lain akan memicu jebakan prosesor. Untuk standar C untuk mengamanatkan perilaku mana pun akan memerlukan bahwa penyusun untuk platform yang perilaku alami berbeda dari standar harus menghasilkan kode tambahan untuk menghasilkan perilaku yang benar - kode yang mungkin lebih mahal daripada kode untuk melakukan penambahan yang sebenarnya. Lebih buruk lagi, itu berarti bahwa programmer yang menginginkan perilaku "alami" harus menambahkan lebih banyak kode tambahan untuk mencapainya (dan bahwa kode tambahan akan lebih mahal daripada penambahan).
Sayangnya, beberapa penulis kompilator telah mengambil filosofi bahwa penyusun harus pergi keluar dari jalan mereka untuk menemukan kondisi yang akan membangkitkan Perilaku Tidak Terdefinisi dan, dengan anggapan bahwa situasi seperti itu mungkin tidak pernah terjadi, menarik kesimpulan panjang dari itu. Jadi, pada sistem dengan 32-bit
int
, diberikan kode seperti:standar C akan memungkinkan kompiler untuk mengatakan bahwa jika q adalah 46341 atau lebih besar, ekspresi q * q akan menghasilkan hasil yang terlalu besar untuk ditampung dalam
int
, akibatnya menyebabkan Perilaku tidak terdefinisi, dan sebagai akibatnya kompiler akan berhak untuk menganggap bahwa tidak dapat terjadi dan dengan demikian tidak akan diperlukan kenaikan*p
jika itu terjadi. Jika kode panggilan digunakan*p
sebagai indikator bahwa ia harus membuang hasil perhitungan, efek dari optimasi mungkin untuk mengambil kode yang akan menghasilkan hasil yang masuk akal pada sistem yang melakukan hampir semua cara yang bisa dibayangkan dengan bilangan bulat bilangan bulat (perangkap mungkin jelek, tapi setidaknya masuk akal), dan mengubahnya menjadi kode yang mungkin berperilaku tidak masuk akal.sumber
Efisiensi adalah alasan yang biasa, tetapi apa pun alasannya, perilaku yang tidak terdefinisi adalah ide yang buruk untuk portabilitas. Akibatnya, perilaku yang tidak terdefinisi menjadi asumsi yang tidak diverifikasi dan tidak dinyatakan.
sumber