Hari ini kami menemukan penyebab bug buruk yang hanya terjadi sesekali pada platform tertentu. Rebus, kode kami terlihat seperti ini:
class Foo {
map<string,string> m;
void A(const string& key) {
m.erase(key);
cout << "Erased: " << key; // oops
}
void B() {
while (!m.empty()) {
auto toDelete = m.begin();
A(toDelete->first);
}
}
}
Masalahnya mungkin tampak jelas dalam kasus sederhana ini: B
melewatkan referensi ke kunci A
, yang menghapus entri peta sebelum mencoba mencetaknya. (Dalam kasus kami, itu tidak dicetak, tetapi digunakan dengan cara yang lebih rumit) Ini tentu saja perilaku yang tidak terdefinisi, karena key
merupakan referensi yang menggantung setelah panggilan ke erase
.
Memperbaiki ini sepele - kami baru saja mengubah tipe parameter dari const string&
menjadi string
. Pertanyaannya adalah: bagaimana kita bisa menghindari bug ini sejak awal? Tampaknya kedua fungsi melakukan hal yang benar:
A
tidak memiliki cara untuk mengetahui yangkey
mengacu pada hal yang akan dihancurkan.B
bisa membuat salinan sebelum meneruskannyaA
, tetapi bukankah tugas callee untuk memutuskan apakah akan mengambil parameter dengan nilai atau dengan referensi?
Apakah ada aturan yang gagal kita ikuti?
Saya akan mengatakan ya, ada aturan sederhana yang Anda langgar yang akan menyelamatkan Anda: prinsip tanggung jawab tunggal.
Saat ini,
A
dilewatkan parameter yang digunakan untuk menghapus item dari peta, dan melakukan beberapa pemrosesan lainnya (mencetak seperti yang ditunjukkan di atas, tampaknya sesuatu yang lain dalam kode sebenarnya). Menggabungkan tanggung jawab itu bagi saya sepertinya merupakan sumber masalah.Jika kita memiliki satu fungsi yang hanya menghapus nilai dari peta, dan lainnya yang hanya memproses nilai dari peta, kita harus memanggil masing-masing dari kode tingkat yang lebih tinggi, jadi kita akan berakhir dengan sesuatu seperti ini :
Memang, nama-nama yang saya gunakan tidak diragukan lagi membuat masalah lebih jelas daripada nama aslinya, tetapi jika nama-nama itu bermakna sama sekali, mereka hampir pasti akan memperjelas bahwa kami berusaha untuk terus menggunakan referensi setelah itu telah dibatalkan. Perubahan konteks yang sederhana membuat masalahnya jauh lebih jelas.
sumber
A
sebenarnya mencarikey
di dua peta yang berbeda dan, jika ditemukan, menghapus entri ditambah beberapa pembersihan tambahan. Jadi tidak jelas bahwa kamiA
melanggar SRP. Saya ingin tahu apakah saya harus memperbarui pertanyaan pada saat ini.m.erase(key)
memiliki tanggung jawab pertama, dancout << "Erased: " << key
memiliki tanggung jawab kedua, sehingga struktur kode yang ditunjukkan dalam jawaban ini sebenarnya tidak berbeda dari struktur kode dalam contoh, namun dalam dunia nyata masalahnya diabaikan. Prinsip tanggung jawab tunggal tidak melakukan apa pun untuk memastikan, atau bahkan membuatnya lebih mungkin, bahwa urutan tindakan tunggal yang saling bertentangan akan muncul dalam kedekatan dalam kode dunia nyata.Ya, Anda gagal mendokumentasikan fungsi tersebut .
Tanpa deskripsi kontrak parameter-passing (khususnya bagian yang berkaitan dengan validitas parameter - apakah itu di awal panggilan fungsi atau seluruh), tidak mungkin untuk mengetahui apakah kesalahan dalam implementasi (jika kontrak panggilan adalah bahwa parameter tersebut valid ketika panggilan dimulai, fungsi tersebut harus membuat salinan sebelum melakukan tindakan apa pun yang mungkin membatalkan parameter) atau dalam pemanggil (jika kontrak panggilan adalah bahwa parameter harus tetap valid selama panggilan berlangsung, pemanggil tidak dapat berikan referensi ke data di dalam koleksi yang sedang dimodifikasi).
Sebagai contoh, standar C ++ itu sendiri menetapkan bahwa:
tetapi gagal untuk menentukan apakah ini hanya berlaku untuk panggilan instan dibuat, atau seluruh pelaksanaan fungsi. Namun, dalam banyak kasus jelas bahwa hanya yang terakhir yang mungkin dilakukan - yaitu ketika argumen tidak dapat dipertahankan dengan membuat salinan.
Ada beberapa kasus dunia nyata di mana perbedaan ini mulai berlaku. Misalnya, menambahkan
std::vector<T>
ke dirinya sendirisumber
Ya, Anda gagal mengujinya dengan benar. Anda tidak sendirian, dan Anda berada di tempat yang tepat untuk belajar :)
C ++ memiliki banyak Perilaku Tidak Terdefinisi, Perilaku tidak terdefinisi bermanifestasi dengan cara yang halus dan menjengkelkan.
Anda mungkin tidak pernah dapat menulis kode C ++ 100% aman, tetapi Anda tentu dapat mengurangi kemungkinan secara tidak sengaja memperkenalkan Perilaku Tidak Terdefinisi dalam basis kode Anda dengan menggunakan sejumlah alat.
Dalam kasus Anda, saya ragu (1) dan (2) akan banyak membantu, meskipun secara umum saya menyarankan untuk menggunakannya. Untuk sekarang mari kita berkonsentrasi pada dua lainnya.
Baik gcc dan Clang menampilkan
-fsanitize
flag yang instrumen program-program yang Anda kompilasi untuk memeriksa berbagai masalah.-fsanitize=undefined
misalnya akan menangkap bilangan bulat ditandatangani / limpahan, bergeser dengan jumlah yang terlalu tinggi, dll ... Dalam kasus spesifik Anda,-fsanitize=address
dan-fsanitize=memory
kemungkinan akan mengambil masalah ... asalkan Anda memiliki tes memanggil fungsi. Untuk kelengkapannya,-fsanitize=thread
layak digunakan jika Anda memiliki basis kode multi-threaded. Jika Anda tidak dapat mengimplementasikan biner (misalnya, Anda memiliki perpustakaan pihak ke-3 tanpa sumbernya), maka Anda juga dapat menggunakannyavalgrind
meskipun secara umum lebih lambat.Kompiler terbaru juga menampilkan kemungkinan pengerasan kekayaan . Perbedaan utama dengan biner yang diinstrumentasi, adalah bahwa cek pengerasan dirancang memiliki dampak rendah pada kinerja (<1%), membuatnya cocok untuk kode produksi pada umumnya. Yang paling terkenal adalah pemeriksaan CFI (Control Flow Integrity) yang dirancang untuk menggagalkan serangan stack stack dan hi-jacking virtual pointer di antara cara-cara lain untuk menumbangkan aliran kontrol.
Maksud dari keduanya (3) dan (4) adalah untuk mengubah kegagalan yang terputus - putus menjadi kegagalan tertentu : keduanya mengikuti prinsip gagal puasa . Ini berarti:
Menggabungkan (3) dengan cakupan tes yang baik harus menangkap sebagian besar masalah sebelum mereka mencapai produksi. Menggunakan (4) dalam produksi dapat menjadi perbedaan antara bug yang mengganggu dan exploit.
sumber
@ catatan: posting ini hanya menambahkan lebih banyak argumen di atas jawaban Ben Voigt .
Kedua fungsi melakukan hal yang benar.
Masalahnya adalah dalam kode klien, yang tidak memperhitungkan efek samping dari panggilan A.
C ++ tidak memiliki cara langsung untuk menentukan efek samping dalam bahasa.
Ini berarti terserah kepada Anda (dan tim Anda) untuk memastikan hal-hal seperti efek samping terlihat dalam kode (sebagai dokumentasi), dan dipelihara dengan kode (Anda mungkin harus mempertimbangkan untuk mendokumentasikan pra-kondisi, kondisi pasca dan invarian) juga, untuk alasan visibilitas juga).
Perubahan kode:
Dari titik ini Anda memiliki sesuatu di atas API yang memberi tahu Anda bahwa Anda harus memiliki unit test untuknya; Ini juga memberi tahu Anda cara menggunakan (dan tidak menggunakan) API.
sumber
Hanya ada satu cara untuk menghindari bug: berhenti menulis kode. Semuanya gagal dalam beberapa cara.
Namun, pengujian kode di berbagai tingkatan (tes unit, tes fungsional, tes integrasi, tes penerimaan, dll) tidak hanya akan meningkatkan kualitas kode, tetapi juga mengurangi jumlah bug.
sumber