- Jika nilai
floatsetelah bagian pecahannya dibuang berada di luar rentang tipe integer tujuan, akan terjadi perilaku tak terdefinisi (UB); konversi implisit, cast bergaya fungsi, danstatic_castsemuanya terdampak -Walldan-Wextratidak memberi peringatan untuk ini, dan-Wconversionjuga hanya mendeteksi konversi implisit, sehingga masalah ini mudah terlewat- Fungsi konversi penyempitan aman Microsoft GSL,
gsl::narrow, juga memicu UB pada sebagian input floating-point→integer, sehingga gagal memenuhi perilaku yang didokumentasikan, yaitu melempar exception untuk nilai yang tidak dapat direpresentasikan CVTTSS2SIdi x86 menangani nilai yang tidak dapat direpresentasikan sebagaiINT_MIN, tetapiFCVTZSdi AArch64 melakukan konversi saturasi dan mengubah NaN menjadi 0, sehingga hasilnya dapat berbeda bergantung pada hardware- Agar konversi aman, rentang harus diperiksa sebelum melakukan cast, dan masalah ini dapat dideteksi dengan opsi UBSan Clang/GCC
-fsanitize=float-cast-overflow
Aturan konversi dan batas deteksi
- Menurut aturan konversi floating-point–integer C++, jika setelah bagian pecahan dibuang nilainya tidak masuk ke tipe integer tujuan, hasilnya menjadi perilaku tak terdefinisi
- Sekalipun tujuannya unsigned, aritmetika modular tidak diterapkan
int i0 = f,int(f), danstatic_cast<int>(f)semuanya dapat memicu UB pada sebagian input
- Sulit menemukan semua masalah hanya dengan peringatan compiler umum
-Walldan-Wextratidak memberi peringatan untuk ketiga bentuk konversi tersebut-Wconversionhanya memperingatkan konversi implisit
- Sekalipun program terus berjalan pada prosesor dan compiler saat ini, hasilnya dapat berbeda antarplatform
CVTTSS2SIdi x86 memetakan input yang tidak dapat direpresentasikan keINT_MINFCVTZSdi AArch64 melakukan saturasi dan memetakan NaN ke 0- UB yang sudah dieksekusi dapat menyebabkan kode tiba-tiba salah berfungsi ketika compiler menerapkan konversi lain
Kasus GSL dan penanganan yang aman
gsl::narrowdari Microsoft Guidelines Support Library diposisikan sebagai konversi penyempitan aman yang melempar exception untuk nilai yang tidak dapat direpresentasikan dalam tipe tujuan- Namun pada konversi floating-point→integer yang sebenarnya, sebagian input lebih dulu mengeksekusi UB, sehingga tidak sesuai dengan dokumentasi
- Pihak GSL menilai UB internal tersebut tidak berbahaya karena tidak menyentuh representasi trap hardware pada platform target, dan masalah ini belum diperbaiki sementara logika tersebut tetap tercermin dalam kode
- Solusi yang benar adalah memeriksa rentang sebelum melakukan cast
- Ada library proof-of-concept cpp-clamp-cast yang didasarkan pada pendekatan konversi saturasi Rust
- Di Undefined Behavior Sanitizer Clang dan GCC, UB ini dapat dideteksi dengan
-fsanitize=float-cast-overflow- Disarankan untuk menguji semua kode C++ dengan UBSan
1 komentar
Pendapat di Lobste.rs
Saya sudah tahu banyak perilaku tak terdefinisi yang halus di C dan C++, tetapi kasus ini tetap mengejutkan
Rust juga mewarisi aturan konversi floating-point→integer yang sama dari LLVM IR sehingga sempat memiliki perilaku tak terdefinisi selama beberapa waktu, dan baru pada 2020 diperbaiki agar menghasilkan IR yang lebih kompleks. Karena perbedaan performanya besar, Rust juga menyediakan konversi floating-point→integer tanpa pemeriksaan untuk loop berperforma tinggi ketika diketahui nilainya terbatas dan masuk dalam rentang tipe target
Sungguh tidak masuk akal jika C++ Core Guidelines Library menganggap ini sepele. LLVM menggunakan informasi yang diperoleh dari konversi untuk menghapus pemeriksaan batas, dan akibatnya ada kasus akses di luar batas array meskipun pemeriksaan batas ada. Jika benar-benar serius soal keamanan memori dan menghindari perilaku tak terdefinisi, hal ini tidak bisa diabaikan, dan pernyataan Herb Sutter tentang “perilaku tak terdefinisi yang jinak” juga mengecewakan
Saya tahu C++ punya banyak perilaku tak terdefinisi, tetapi yang ini khususnya mengejutkan. Saya penasaran apakah ini dibiarkan sebagai perilaku tak terdefinisi karena mereka ingin membuat konversinya secepat mungkin, dan tiap arsitektur punya instruksi yang menangani nilai ekstrem ini secara berbeda. Ini tampaknya alasan utama munculnya aturan semacam ini, mirip overflow integer bertanda
Perilaku tak terdefinisi seharusnya dibatasi pada kasus ketika hasil yang konsisten bahkan pada platform yang sama tidak bisa dijamin karena efek di luar mesin abstrak C, seperti use-after-free, atau ketika pada target tertentu bisa memicu trap
Implementasi yang mematuhi standar dapat mendefinisikan sendiri perilaku tak terdefinisi, dan GCC juga melakukan itu untuk beberapa hal. Jika semantik yang stabil dapat dijamin tanpa biaya performa, perilaku instruksi floating-point→integer per target bisa saja didefinisikan apa adanya, tetapi pada implementasi lain tetap menjadi perilaku tak terdefinisi
fctiwdi PowerPC melakukan konversi saturasi, mengubah NaN menjadiINT_MIN, dan juga menyetel flag FPSCR.fctiddi Power ISA 64-bit juga menangani integer yang lebih besar dengan cara yang sama, tetapi keduanya tidak cocok dengan perilaku AArch64 maupun x86Dalam kasus seperti ini, lebih tepat memperlakukannya sebagai perilaku yang ditentukan implementasi atau nilai yang tidak ditentukan. Tidak masuk akal jika seluruh program keluar dari kendali standar C++ hanya karena mengonversi infinity ke
intC++26 telah menghapus beberapa perilaku tak terdefinisi yang tidak masuk akal, dan ini jelas kandidat untuk dihapus juga. Dari eksperimen, GCC dan Clang tampaknya tidak memanfaatkan perilaku tak terdefinisi ini untuk optimisasi, jadi dampak praktisnya terlihat terbatas
Ini satu lagi contoh tidak menyenangkan ketika spesifikasi tidak punya alasan untuk menetapkan operasi ini sebagai perilaku tak terdefinisi
Satu alasan lagi untuk tidak menyukai IEEE 754. Jika tidak benar-benar diperlukan karena library lain atau performa, saya sebisa mungkin memakai integer murni, bilangan rasional dengan pembilang·penyebut integer besar, atau desimal fixed-point alih-alih floating-point
Jika pernah memakai C atau C++, perbedaan antara
float32danint32, bahkanint64, seharusnya bukan hal mengejutkan.float32dengan eksponen besar bisa merepresentasikan nilai integer yang jauh lebih besar daripadaint64Di antara format yang bukan superset satu sama lain dan memiliki kemampuan representasi berbeda, tidak ada alasan untuk menganggap konversi floating-point→integer aman, terlepas dari sintaks bahasa
uint32_tjuga tidak bisa merepresentasikan semua nilaiuint64_t, tetapi semantik konversinya didefinisikan sebagai pemotongan. Di sini, fakta bahwa compiler boleh melakukan apa saja pada input tertentu adalah masalah yang secara mendasar berbedaUntuk bahasa tingkat rendah, bisa juga dipetakan ke instruksi assembly seperti
CVTTSS2SIatauFCVTZS. Karena bahasa tingkat rendah dekat dengan assembly dan berbagai perilaku tak terdefinisi “jinak” lainnya juga sangat kontroversial, justru lebih mengejutkan bahwa konversi ini membolehkan perilaku tak terdefinisiNamun mengejutkan bahwa itu juga membolehkan kompilasi salah pada kode yang tidak terkait, memformat hard drive, atau membuat “setan keluar dari hidung”
Konsep perilaku tak terdefinisi bahwa apa pun bisa terjadi memang masuk akal untuk double free atau penulisan di luar batas array, tetapi C dan C++ terlalu sering menggunakannya bahkan di tempat yang seharusnya bisa diberi aturan lebih ketat seperti nilai sampah yang ditentukan implementasi atau penghentian program. Rust melakukan trap untuk beberapa overflow integer dalam mode debug dan mengembalikan nilai yang tidak ditentukan dalam mode rilis, tetapi tidak membiarkannya merusak kode yang tidak terkait
C++ tidak harus menjadi Java atau Rust, tetapi jika mengurangi perilaku tak terdefinisi yang tidak perlu, jelas bahasanya akan menjadi lebih baik