Fungsi secara tidak sengaja membatalkan parameter referensi - apa yang salah?

54

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: Bmelewatkan 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 keymerupakan 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:

  • Atidak memiliki cara untuk mengetahui yang keymengacu pada hal yang akan dihancurkan.
  • Bbisa membuat salinan sebelum meneruskannya A, tetapi bukankah tugas callee untuk memutuskan apakah akan mengambil parameter dengan nilai atau dengan referensi?

Apakah ada aturan yang gagal kita ikuti?

Nikolai
sumber

Jawaban:

35

Atidak memiliki cara untuk mengetahui yang keymengacu pada hal yang akan dihancurkan.

Meskipun ini benar, Atahu hal-hal berikut:

  1. Tujuannya adalah untuk menghancurkan sesuatu .

  2. Dibutuhkan parameter yang sama persis dengan jenis yang akan dihancurkan.

Dengan adanya fakta-fakta ini, dimungkinkan untuk Amenghancurkan parameternya sendiri jika mengambil parameter sebagai pointer / referensi. Ini bukan satu-satunya tempat di C ++ di mana pertimbangan seperti itu perlu diatasi.

Situasi ini mirip dengan bagaimana sifat operator=operator penugasan berarti bahwa Anda mungkin perlu khawatir tentang penugasan mandiri. Itu adalah kemungkinan karena tipe thisdan tipe parameter referensi adalah sama.

Perlu dicatat bahwa ini hanya bermasalah karena Anantinya bermaksud menggunakan keyparameter setelah menghapus entri. Jika tidak, maka itu akan baik-baik saja. Tentu saja, maka semuanya menjadi mudah untuk bekerja dengan sempurna, kemudian seseorang berubah Auntuk menggunakannya keysetelah berpotensi dihancurkan.

Itu akan menjadi tempat yang bagus untuk komentar.

Apakah ada aturan yang gagal kita ikuti?

Dalam C ++, Anda tidak dapat beroperasi dengan asumsi bahwa jika Anda secara buta mengikuti serangkaian aturan, kode Anda akan 100% aman. Kami tidak dapat memiliki aturan untuk semuanya .

Pertimbangkan poin # 2 di atas. Abisa saja mengambil beberapa parameter dari tipe yang berbeda dari kunci, tetapi objek itu sendiri bisa menjadi sub-objek dari kunci di peta. Dalam C ++ 14, finddapat mengambil jenis yang berbeda dari jenis kunci, asalkan ada perbandingan yang valid di antara mereka. Jadi jika Anda melakukannya m.erase(m.find(key)), Anda dapat menghancurkan parameter meskipun tipe parameter bukan tipe kunci.

Jadi aturan seperti "jika tipe parameter dan tipe kuncinya sama, ambil menurut nilainya" tidak akan menyelamatkan Anda. Anda akan membutuhkan lebih banyak informasi dari itu.

Pada akhirnya, Anda perlu memperhatikan kasus penggunaan khusus Anda dan melakukan penilaian, berdasarkan pengalaman.

Nicol Bolas
sumber
10
Nah, Anda dapat memiliki aturan "tidak pernah berbagi keadaan yang bisa berubah" atau itu ganda "tidak pernah bermutasi dengan negara", tetapi kemudian Anda akan berjuang untuk menulis c ++ yang dapat diidentifikasi
Caleth
7
@ Caleth Jika Anda ingin menggunakan aturan-aturan itu C ++ mungkin bukan bahasa untuk Anda.
user253751
3
@Caleth Apakah Anda menggambarkan Karat?
Malcolm
1
"Kita tidak bisa memiliki aturan untuk semuanya." Ya kita bisa. cstheory.stackexchange.com/q/4052
Ouroborus
23

Saya akan mengatakan ya, ada aturan sederhana yang Anda langgar yang akan menyelamatkan Anda: prinsip tanggung jawab tunggal.

Saat ini, Adilewatkan 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 :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

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.

Jerry Coffin
sumber
3
Nah itu pengamatan yang valid, itu hanya berlaku sangat sempit untuk kasus ini. Ada banyak contoh di mana SRP dihormati dan masih ada masalah fungsi berpotensi membatalkan parameternya sendiri.
Ben Voigt
5
@ BenVoigt: Hanya membatalkan parameternya tidak menyebabkan masalah. Ini terus menggunakan parameter setelah tidak valid yang mengarah ke masalah. Tetapi pada akhirnya ya, Anda benar: sementara itu akan menyelamatkannya dalam kasus ini, ada banyak kasus di mana itu tidak cukup.
Jerry Coffin,
3
Saat menulis contoh yang disederhanakan, Anda harus menghilangkan beberapa detail, dan terkadang ternyata salah satu detail itu penting. Dalam kasus kami, Asebenarnya mencari keydi dua peta yang berbeda dan, jika ditemukan, menghapus entri ditambah beberapa pembersihan tambahan. Jadi tidak jelas bahwa kami Amelanggar SRP. Saya ingin tahu apakah saya harus memperbarui pertanyaan pada saat ini.
Nikolai
2
Untuk memperluas pada poin @BenVoigt: dalam contoh Nicolai, m.erase(key)memiliki tanggung jawab pertama, dan cout << "Erased: " << keymemiliki 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.
sdenham
10

Apakah ada aturan yang gagal kita ikuti?

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:

Jika argumen ke suatu fungsi memiliki nilai yang tidak valid (seperti nilai di luar domain fungsi atau pointer yang tidak valid untuk penggunaan yang dimaksudkan), perilaku tidak terdefinisi.

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 sendiri

Ben Voigt
sumber
"Itu gagal menentukan apakah ini hanya berlaku untuk instan panggilan dibuat, atau seluruh pelaksanaan fungsi." Dalam praktiknya, kompiler melakukan hampir semua yang mereka inginkan di seluruh fungsi begitu UB dipanggil. Ini dapat menyebabkan beberapa perilaku aneh jika programmer tidak menangkap UB.
@snowman sambil menarik, penataan ulang UB sama sekali tidak terkait dengan apa yang saya bahas dalam jawaban ini, yang merupakan tanggung jawab untuk memastikan validitas (sehingga UB tidak pernah terjadi).
Ben Voigt
itulah tepatnya poin saya: orang yang menulis kode harus bertanggung jawab untuk menghindari UB untuk menghindari seluruh lubang kelinci yang penuh masalah.
@Snowman: Tidak ada "satu orang" yang menulis semua kode dalam suatu proyek. Itulah salah satu alasan mengapa dokumentasi antarmuka sangat penting. Lain adalah bahwa antarmuka yang didefinisikan dengan baik mengurangi jumlah kode yang perlu dipertimbangkan pada satu waktu - untuk proyek non-sepele, itu tidak mungkin bagi seseorang untuk "bertanggung jawab" untuk memikirkan kebenaran setiap pernyataan.
Ben Voigt
Saya tidak pernah mengatakan satu orang menulis semua kode. Pada satu titik waktu, seorang programmer mungkin melihat suatu fungsi atau menulis kode. Yang ingin saya katakan adalah bahwa siapa pun yang melihat kode perlu berhati-hati karena dalam praktiknya, UB menular dan menyebar dari satu baris kode melintasi cakupan yang lebih luas begitu kompiler terlibat. Ini kembali ke poin Anda tentang melanggar kontrak fungsi: Saya setuju dengan Anda, tetapi menyatakan bahwa itu dapat tumbuh menjadi masalah yang lebih besar.
2

Apakah ada aturan yang gagal kita ikuti?

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.

  1. Peringatan kompiler
  2. Analisis Statis (versi diperpanjang dari peringatan)
  3. Binari Uji Instrumentasi
  4. Binari Produksi Hardened

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 -fsanitizeflag yang instrumen program-program yang Anda kompilasi untuk memeriksa berbagai masalah. -fsanitize=undefinedmisalnya akan menangkap bilangan bulat ditandatangani / limpahan, bergeser dengan jumlah yang terlalu tinggi, dll ... Dalam kasus spesifik Anda, -fsanitize=addressdan -fsanitize=memorykemungkinan akan mengambil masalah ... asalkan Anda memiliki tes memanggil fungsi. Untuk kelengkapannya, -fsanitize=threadlayak 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 menggunakannya valgrindmeskipun 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:

  • selalu gagal saat Anda menginjak ranjau darat
  • gagal segera , mengarahkan Anda pada kesalahan daripada merusak memori secara acak, dll ...

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.

Matthieu M.
sumber
0

@ catatan: posting ini hanya menambahkan lebih banyak argumen di atas jawaban Ben Voigt .

Pertanyaannya adalah: bagaimana kita bisa menghindari bug ini sejak awal? Tampaknya kedua fungsi melakukan hal yang benar:

  • A tidak memiliki cara untuk mengetahui bahwa kunci mengacu pada hal yang akan dihancurkan.
  • B bisa membuat salinan sebelum meneruskannya ke A, tapi bukankah tugas callee untuk memutuskan apakah akan mengambil parameter dengan nilai atau dengan referensi?

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:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

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.

utnapistim
sumber
-4

bagaimana kita bisa menghindari bug ini sejak awal?

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.

BЈовић
sumber
1
Ini omong kosong. Ada tidak hanya salah satu cara untuk menghindari bug. Meskipun sepele benar bahwa satu-satunya cara untuk sepenuhnya menghindari keberadaan bug adalah dengan tidak pernah menulis kode, itu juga benar (dan jauh lebih berguna) bahwa ada berbagai prosedur rekayasa perangkat lunak yang dapat Anda ikuti, baik ketika awalnya menulis kode dan saat mengujinya, itu dapat secara signifikan mengurangi keberadaan bug. Semua orang tahu tentang tahap pengujian, tetapi dampak terbesar sering kali bisa didapat dengan biaya terendah dengan mengikuti praktik desain dan idiom yang bertanggung jawab saat menulis kode di tempat pertama.
Cody Grey