Bukankah "selalu menginisialisasi variabel" menyebabkan bug penting disembunyikan?

35

Pedoman Inti C ++ memiliki aturan ES.20: Selalu menginisialisasi objek .

Hindari kesalahan yang digunakan sebelum ditetapkan dan perilaku terkait yang tidak terdefinisi. Hindari masalah dengan pemahaman inisialisasi yang kompleks. Sederhanakan refactoring.

Tetapi aturan ini tidak membantu menemukan bug, itu hanya menyembunyikan mereka.
Misalkan suatu program memiliki jalur eksekusi di mana ia menggunakan variabel yang tidak diinisialisasi. Itu adalah bug. Selain perilaku yang tidak terdefinisi, itu juga berarti ada yang tidak beres, dan program mungkin tidak memenuhi persyaratan produknya. Ketika akan digunakan untuk produksi, mungkin ada kerugian uang, atau bahkan lebih buruk.

Bagaimana kita menyaring bug? Kami menulis tes. Tetapi tes tidak mencakup 100% jalur eksekusi, dan tes tidak pernah mencakup 100% dari input program. Lebih dari itu, bahkan tes mencakup jalur eksekusi yang salah - itu masih bisa berlalu. Lagipula itu adalah perilaku yang tidak terdefinisi, variabel yang tidak diinisialisasi dapat memiliki nilai yang agak valid.

Tetapi selain tes kami, kami memiliki kompiler yang dapat menulis sesuatu seperti 0xCDCDCDCD ke variabel yang tidak diinisialisasi. Ini sedikit meningkatkan tingkat deteksi tes.
Bahkan lebih baik - ada alat seperti Address Sanitizer, yang akan menangkap semua pembacaan byte memori yang tidak diinisialisasi.

Dan akhirnya ada analisa statis, yang dapat melihat program dan mengatakan bahwa ada read-before-set pada jalur eksekusi itu.

Jadi kita memiliki banyak alat yang kuat, tetapi jika kita menginisialisasi variabel - pembersih tidak menemukan apa pun .

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Ada aturan lain - jika eksekusi program menemukan bug, program harus mati sesegera mungkin. Tidak perlu tetap hidup, hanya crash, menulis crashdump, berikan kepada insinyur untuk diselidiki.
Inisialisasi variabel tidak perlu sebaliknya - program tetap hidup, ketika itu akan mendapatkan kesalahan segmentasi sebaliknya.

Abyx
sumber
10
Meskipun saya pikir ini adalah pertanyaan yang bagus, saya tidak mengerti teladan Anda. Jika kesalahan baca terjadi, dan bytes_readtidak diubah (jadi dijaga nol), mengapa ini dianggap bug? Program ini masih bisa berlanjut secara waras selama tidak secara implisit mengharapkannya bytes_read!=0setelah itu. Jadi baik-baik saja pembersih tidak mengeluh. Di sisi lain, ketika bytes_readtidak diinisialisasi sebelumnya, program tidak akan dapat melanjutkan dengan cara yang waras, jadi tidak menginisialisasi bytes_readsebenarnya memperkenalkan bug yang tidak ada sebelumnya.
Doc Brown
2
@Abyx: bahkan jika itu adalah pihak ketiga, jika itu tidak berurusan dengan buffer yang dimulai dengan \0itu buggy. Jika didokumentasikan tidak berurusan dengan itu, kode panggilan Anda bermasalah. Jika Anda memperbaiki kode panggilan Anda untuk memeriksa bytes_read==0sebelum menelepon digunakan, maka Anda kembali ke tempat Anda mulai: kode Anda bermasalah jika Anda tidak menginisialisasi bytes_read, aman jika Anda melakukannya. ( Biasanya fungsi seharusnya mengisi parameternya meskipun terjadi kesalahan : tidak juga. Seringkali output dibiarkan sendiri atau tidak ditentukan.)
Mat
1
Apakah ada alasan mengapa kode ini mengabaikan yang err_tdikembalikan oleh my_read()? Jika ada bug di mana saja dalam contoh, itu saja.
Blrfl
1
Mudah: hanya menginisialisasi variabel jika bermakna. Jika tidak maka jangan. Saya bisa setuju bahwa menggunakan data "dummy" untuk melakukannya adalah buruk, karena menyembunyikan bug.
Pieter B
1
"Ada aturan lain - jika eksekusi program menemukan bug, program harus mati sesegera mungkin. Tidak perlu menjaganya tetap hidup, crash, tulis crashdump, berikan ke teknisi untuk diselidiki.": Coba pada penerbangan perangkat lunak kontrol. Semoga berhasil memulihkan crash dump dari reruntuhan pesawat.
Giorgio

Jawaban:

44

Alasan Anda salah pada beberapa akun:

  1. Kesalahan segmentasi jauh dari pasti terjadi. Menggunakan variabel yang tidak diinisialisasi akan menghasilkan perilaku yang tidak terdefinisi . Kesalahan segmentasi adalah salah satu cara perilaku tersebut dapat memanifestasikan dirinya, tetapi tampaknya berjalan normal adalah sama mungkin.
  2. Compiler tidak pernah mengisi memori yang tidak diinisialisasi dengan pola yang ditentukan (seperti 0xCD). Ini adalah sesuatu yang dilakukan oleh beberapa debuggers untuk membantu Anda menemukan tempat di mana variabel yang tidak diinisialisasi digunakan. Jika Anda menjalankan program seperti itu di luar debugger, maka variabel tersebut akan berisi sampah yang sepenuhnya acak. Kemungkinan besar penghitung seperti bytes_readmemiliki nilai 10karena memiliki nilai 0xcdcdcdcd.
  3. Bahkan jika Anda menjalankan debugger yang mengatur memori yang tidak diinisialisasi ke pola yang tetap, mereka hanya melakukannya saat startup. Ini berarti bahwa mekanisme ini hanya bekerja dengan andal untuk variabel statis (dan mungkin dialokasikan-tumpukan). Untuk variabel otomatis, yang dialokasikan pada tumpukan atau hanya tinggal di register, kemungkinan besar variabel tersebut disimpan di lokasi yang digunakan sebelumnya, sehingga pola memori yang ada telah ditimpa.

Gagasan di balik panduan untuk selalu menginisialisasi variabel adalah untuk mengaktifkan kedua situasi ini

  1. Variabel berisi nilai berguna sejak awal keberadaannya. Jika Anda menggabungkannya dengan panduan untuk mendeklarasikan variabel hanya sekali Anda membutuhkannya, Anda dapat menghindari programer pemeliharaan di masa depan yang terjebak dalam perangkap untuk mulai menggunakan variabel antara deklarasi dan penugasan pertama, di mana variabel akan ada tetapi tidak diinisialisasi.

  2. Variabel berisi nilai yang ditentukan yang dapat Anda uji untuk nanti, untuk mengetahui apakah fungsi seperti my_readtelah memperbarui nilai. Tanpa inisialisasi, Anda tidak dapat mengetahui apakah bytes_readbenar-benar memiliki nilai yang valid, karena Anda tidak dapat mengetahui nilai apa yang dimulai.

Bart van Ingen Schenau
sumber
8
1) ini semua tentang probabilitas, seperti 1% vs 99%. 2 dan 3) VC ++ menghasilkan kode inisialisasi seperti itu, untuk variabel lokal juga. 3) variabel statis (global) selalu diinisialisasi dengan 0.
Abyx
5
@Abyx: 1) Dalam pengalaman saya, probabilitasnya ~ 80% "tidak ada perbedaan perilaku yang jelas", 10% "melakukan hal yang salah", 10% "segfault". Adapun (2) dan (3): VC ++ melakukan ini hanya dalam debug build. Mengandalkan itu adalah ide yang sangat buruk karena secara selektif memecah rilis build dan tidak muncul dalam banyak pengujian Anda.
Christian Aichinger
8
Saya pikir "ide di balik panduan" adalah bagian terpenting dari jawaban ini. Pedoman ini sama sekali tidak memberitahu Anda untuk mengikuti setiap deklarasi variabel dengan = 0;. Maksud dari saran tersebut adalah mendeklarasikan variabel pada titik di mana Anda akan memiliki nilai yang berguna untuknya, dan segera berikan nilai ini. Ini dibuat jelas secara eksplisit dalam aturan berikut ES21 dan ES22. Ketiganya harus dipahami sebagai kerja sama; bukan sebagai aturan individu yang tidak terkait.
GrandOpener
1
@GrandOpener Tepat. Jika tidak ada nilai yang berarti untuk ditetapkan pada titik di mana variabel dideklarasikan, ruang lingkup variabel mungkin salah.
Kevin Krumwiede
5
"Compiler tidak pernah mengisi" bukankah itu tidak selalu ?
CodesInChaos
25

Anda menulis "aturan ini tidak membantu menemukan bug, itu hanya menyembunyikan mereka" - yah, tujuan aturan ini bukan untuk membantu menemukan bug, tetapi untuk menghindari . Dan ketika bug dihindari, tidak ada yang disembunyikan.

Mari kita bahas masalah ini dengan contoh Anda: anggap my_readfungsi memiliki kontrak tertulis untuk diinisialisasi bytes_readdalam semua keadaan, tetapi tidak dalam kasus kesalahan, jadi itu salah, setidaknya, untuk kasus ini. Niat Anda adalah menggunakan lingkungan run time untuk menunjukkan bug itu dengan tidak menginisialisasi bytes_readparameter terlebih dahulu. Selama Anda tahu pasti ada pembersih alamat di sana, itu memang cara yang mungkin untuk mendeteksi bug tersebut. Untuk memperbaiki bug, seseorang harus mengubah my_readfungsi secara internal.

Tetapi ada sudut pandang yang berbeda, yang setidaknya sama-sama valid: perilaku yang salah hanya muncul dari kombinasi tidak menginisialisasi bytes_readsebelumnya, dan memanggil my_readsesudahnya (dengan harapan bytes_readdiinisialisasi setelah itu). Ini adalah situasi yang akan sering terjadi dalam komponen dunia nyata ketika spesifikasi tertulis untuk fungsi seperti my_readtidak 100% jelas, atau bahkan salah tentang perilaku jika terjadi kesalahan. Namun, selama bytes_readdiinisialisasi ke nol sebelum panggilan, program berperilaku sama seperti jika inisialisasi dilakukan di dalam my_read, sehingga berperilaku dengan benar, dalam kombinasi ini tidak ada bug dalam program.

Jadi rekomendasi saya yang mengikuti dari itu adalah: gunakan pendekatan non-inisialisasi hanya jika

  • Anda ingin menguji apakah blok fungsi atau kode menginisialisasi parameter tertentu
  • Anda yakin 100% fungsi yang dipertaruhkan memiliki kontrak di mana sudah pasti salah untuk tidak memberikan nilai pada parameter itu
  • Anda 100% yakin lingkungan dapat menangkap ini

Ini adalah kondisi yang biasanya dapat Anda atur dalam kode uji , untuk lingkungan perkakas tertentu.

Dalam kode produksi, bagaimanapun, lebih baik selalu menginisialisasi variabel seperti itu sebelumnya, itu adalah pendekatan yang lebih defensif, yang mencegah bug jika kontrak tidak lengkap atau salah, atau dalam hal pembersih alamat atau langkah-langkah keamanan serupa tidak diaktifkan. Dan aturan "crash-early" berlaku, seperti yang Anda tulis dengan benar, jika eksekusi program menemukan bug. Tetapi ketika menginisialisasi variabel sebelumnya berarti tidak ada yang salah, maka tidak perlu menghentikan eksekusi lebih lanjut.

Doc Brown
sumber
4
Ini persis seperti apa yang saya pikirkan ketika saya membacanya. Itu tidak menyapu benda-benda di bawah karpet, itu menyapu mereka ke tempat sampah!
corsiKa
22

Selalu inisialisasi variabel Anda

Perbedaan antara situasi yang Anda pertimbangkan adalah bahwa case tanpa inisialisasi menghasilkan perilaku yang tidak terdefinisi , sedangkan case di mana Anda meluangkan waktu untuk menginisialisasi menciptakan bug yang terdefinisi dengan baik dan deterministik . Saya tidak bisa menekankan betapa sangat berbeda kedua kasus ini.

Pertimbangkan contoh hipotetis yang mungkin terjadi pada karyawan hipotetis pada program simulasi hipotetis. Tim hipotetis ini secara hipotesis mencoba membuat simulasi deterministik untuk menunjukkan bahwa produk yang mereka jual memenuhi kebutuhan secara hipotesis.

Oke, saya akan berhenti dengan kata suntikan. Saya pikir Anda mendapatkan intinya ;-)

Dalam simulasi ini, ada ratusan variabel tidak diinisialisasi. Salah satu pengembang menjalankan valgrind pada simulasi dan melihat ada beberapa kesalahan "branch on uninitialized value". "Hmm, sepertinya itu bisa menyebabkan non-determinisme, membuatnya sulit untuk mengulang tes ketika kita paling membutuhkannya." Pengembang pergi ke manajemen, tetapi manajemen berada pada jadwal yang sangat ketat, dan tidak dapat menyisihkan sumber daya untuk melacak masalah ini. "Kami akhirnya menginisialisasi semua variabel kami sebelum menggunakannya. Kami memiliki praktik pengkodean yang baik."

Beberapa bulan sebelum pengiriman akhir, ketika simulasi dalam mode churn penuh, dan seluruh tim berlari untuk menyelesaikan semua hal yang dijanjikan manajemen dengan anggaran yang, seperti setiap proyek yang pernah didanai, terlalu kecil. Seseorang memperhatikan bahwa mereka tidak dapat menguji fitur penting karena, untuk beberapa alasan, sim deterministik tidak berperilaku deterministik untuk debug.

Seluruh tim mungkin telah dihentikan dan menghabiskan bagian yang lebih baik dari 2 bulan menyisir seluruh basis kode simulasi untuk memperbaiki kesalahan nilai yang tidak diinisialisasi alih-alih menerapkan dan menguji fitur. Tak perlu dikatakan, karyawan melewatkan "Sudah saya katakan" dan langsung membantu pengembang lain memahami apa nilai yang tidak diinisialisasi. Anehnya, standar pengkodean diubah tak lama setelah kejadian ini, mendorong pengembang untuk selalu menginisialisasi variabel mereka.

Dan ini adalah tembakan peringatan. Ini adalah peluru yang menyerempet hidung Anda. Masalah yang sebenarnya jauh jauh lebih berbahaya dari yang Anda bayangkan.

Menggunakan nilai yang tidak diinisialisasi adalah "perilaku tidak terdefinisi" (kecuali untuk beberapa kasus sudut seperti char). Perilaku tidak terdefinisi (atau UB singkatnya) sangat gila dan sangat buruk bagi Anda, sehingga Anda tidak akan pernah percaya itu lebih baik daripada alternatifnya. Terkadang Anda dapat mengidentifikasi bahwa kompiler khusus Anda mendefinisikan UB, dan kemudian aman untuk digunakan, tetapi sebaliknya, perilaku tidak terdefinisi adalah "perilaku apa pun yang dirasakan oleh kompiler." Ini dapat melakukan sesuatu yang Anda sebut "waras" seperti memiliki nilai yang tidak ditentukan. Mungkin memancarkan opcodes yang tidak valid, berpotensi menyebabkan program Anda rusak sendiri. Ini dapat memicu peringatan pada waktu kompilasi, atau kompilator bahkan dapat menganggapnya sebagai kesalahan.

Atau mungkin tidak melakukan apa-apa sama sekali

Kenari saya di tambang batu bara untuk UB adalah kasing dari mesin SQL yang saya baca. Maafkan saya karena tidak menautkannya, saya gagal menemukan artikel itu lagi. Ada masalah buffer overrun di mesin SQL ketika Anda memberikan ukuran buffer yang lebih besar ke suatu fungsi, tetapi hanya pada versi tertentu dari Debian. Bug itu patuh masuk, dan dieksplorasi. Bagian yang lucu adalah: buffer overrun diperiksa . Ada kode untuk menangani buffer overrun di tempatnya. Itu terlihat seperti ini:

// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
    // If dataLength is very large, we might overflow the pointer
    // arithmetic, and end up with some very small pointer number,
    // causing us to fail to realize we were trying to write past the
    // end.  Check this before we continue
    if (put + dataLength < put)
    {
        RaiseError("Buffer overflow risk detected");
        return 0;
    }
    ...
    // typical ring-buffer pointer manipulation followed...
}

Saya telah menambahkan lebih banyak komentar di rendisi saya, tetapi idenya sama. Jika put + dataLengthmembungkus, itu akan lebih kecil dari putpointer (mereka telah mengkompilasi cek waktu untuk memastikan int unsigned adalah ukuran pointer, untuk yang penasaran). Jika ini terjadi, kita tahu algoritma buffer cincin standar mungkin bingung oleh luapan ini, jadi kita mengembalikan 0. Atau apakah kita?

Ternyata, overflow pada pointer tidak terdefinisi dalam C ++. Karena kebanyakan kompiler memperlakukan pointer sebagai integer, kita berakhir dengan perilaku integer overflow yang khas, yang merupakan perilaku yang kita inginkan. Namun, ini adalah perilaku yang tidak terdefinisi, artinya kompiler diizinkan untuk melakukan apa pun yang diinginkannya.

Dalam hal bug ini, Debian kebetulan memilih untuk menggunakan versi gcc baru yang tidak diperbarui oleh rasa Linux utama lainnya dalam rilis produksinya. Versi baru gcc ini memiliki pengoptimal kode mati yang lebih agresif. Kompilator melihat perilaku yang tidak terdefinisi, dan memutuskan bahwa hasil dari ifpernyataan tersebut adalah "apa pun yang membuat optimalisasi kode," yang merupakan terjemahan UB yang benar-benar legal. Oleh karena itu, ia membuat asumsi bahwa karena ptr+dataLengthtidak akan pernah bisa di bawah ptrtanpa pointer UB kelebihan, ifpernyataan itu tidak akan pernah memicu, dan mengoptimalkan cek buffer overrun.

Penggunaan "waras" UB sebenarnya menyebabkan produk SQL utama untuk mengeksploitasi buffer overrun yang harus dihindari oleh kode tertulis!

Jangan pernah mengandalkan perilaku yang tidak terdefinisi. Pernah.

Cort Ammon - Reinstate Monica
sumber
Untuk membaca yang sangat lucu tentang perilaku Undefined, software.intel.com/en-us/blogs/2013/01/06/… adalah posting yang ditulis dengan sangat baik tentang seberapa buruknya. Namun, pos khusus itu mengenai operasi atom, yang sangat membingungkan bagi kebanyakan orang, jadi saya menghindari merekomendasikannya sebagai primer untuk UB dan bagaimana hal itu bisa salah.
Cort Ammon - Reinstate Monica
1
Saya berharap C memiliki intrinsik untuk mengatur lvalue atau array dari nilai-nilai tak tentu yang tidak diinisialisasi, tidak terperangkap, atau nilai-nilai yang tidak ditentukan, atau mengubah nilai-nilai jahat ke nilai-nilai yang tidak terlalu jahat (non-jebakan tak tentu atau tidak ditentukan) sambil meninggalkan nilai yang ditentukan sendiri. Compiler dapat menggunakan arahan semacam itu untuk membantu optimisasi yang bermanfaat, dan programmer dapat menggunakannya untuk menghindari keharusan menulis kode yang tidak berguna sambil memblokir melanggar "optimisasi" ketika menggunakan hal-hal seperti teknik matriks-jarang.
supercat
@supercat Ini akan menjadi fitur yang bagus, dengan asumsi Anda menargetkan platform di mana itu adalah solusi yang valid. Salah satu contoh masalah yang diketahui adalah kemampuan untuk membuat pola memori yang tidak hanya tidak valid untuk jenis memori, tetapi tidak mungkin untuk dicapai melalui cara biasa. booladalah contoh yang sangat baik di mana ada masalah yang jelas, tetapi mereka muncul di tempat lain kecuali Anda menganggap Anda sedang bekerja pada platform yang sangat membantu seperti x86 atau ARM atau MIPS di mana semua masalah ini dapat diselesaikan pada waktu opcode.
Cort Ammon - Reinstate Monica
Pertimbangkan kasus di mana pengoptimal dapat membuktikan bahwa nilai yang digunakan untuk a switchkurang dari 8, karena ukuran aritmatika bilangan bulat, sehingga mereka dapat menggunakan instruksi cepat yang dianggap tidak ada risiko nilai "besar" masuk. Tiba-tiba sebuah nilai yang tidak ditentukan (yang tidak akan pernah bisa dibangun menggunakan aturan kompiler) muncul, melakukan sesuatu yang tidak terduga, dan tiba-tiba Anda memiliki lompatan besar dari ujung tabel lompatan. Mengizinkan hasil yang tidak ditentukan di sini berarti setiap pernyataan switch dalam program harus memiliki jebakan ekstra untuk mendukung kasus-kasus ini yang "tidak pernah terjadi."
Cort Ammon - Reinstate Monica
Jika intrinsik distandarisasi, penyusun dapat diminta untuk melakukan apa pun yang diperlukan untuk menghormati semantik; jika misalnya beberapa jalur kode menetapkan variabel dan beberapa tidak, dan intrinsik kemudian mengatakan "konversi ke Nilai Tidak Ditentukan jika tidak diinisialisasi atau tidak ditentukan; biarkan lain", kompiler untuk platform dengan register "tidak bernilai" harus diharuskan. masukkan kode untuk menginisialisasi variabel baik sebelum jalur kode apa pun, atau pada jalur kode apa pun yang inisialisasi akan terlewat, tetapi analisis semantik yang diperlukan untuk melakukan itu cukup sederhana.
supercat
5

Saya kebanyakan bekerja dalam bahasa pemrograman fungsional di mana Anda tidak diizinkan untuk menetapkan kembali variabel. Pernah. Itu sepenuhnya menghilangkan kelas bug ini. Ini tampaknya seperti pembatasan besar pada awalnya, tetapi memaksa Anda untuk menyusun kode Anda dengan cara yang konsisten dengan urutan Anda mempelajari data baru, yang cenderung menyederhanakan kode Anda dan membuatnya lebih mudah untuk dipelihara.

Kebiasaan itu juga bisa dibawa ke bahasa-bahasa yang sangat penting. Hampir selalu mungkin untuk memperbaiki kode Anda untuk menghindari menginisialisasi variabel dengan nilai dummy. Itulah yang diperintahkan oleh panduan tersebut untuk Anda lakukan. Mereka ingin Anda meletakkan sesuatu yang bermakna di sana, bukan sesuatu yang hanya akan membuat alat otomatis bahagia.

Contoh Anda dengan API gaya-C sedikit lebih rumit. Dalam kasus tersebut, ketika saya menggunakan fungsi saya akan menginisialisasi ke nol untuk menjaga kompiler dari mengeluh, tetapi suatu kali dalam my_readunit test, saya akan menginisialisasi ke sesuatu yang lain untuk memastikan kondisi kesalahan berfungsi dengan baik. Anda tidak perlu menguji setiap kondisi kesalahan yang mungkin terjadi pada setiap penggunaan.

Karl Bielefeldt
sumber
5

Tidak, itu tidak menyembunyikan bug. Alih-alih itu membuat perilaku menjadi deterministik sedemikian rupa sehingga jika pengguna menemukan kesalahan, pengembang dapat memperbanyaknya.


sumber
1
Dan menginisialisasi dengan -1 sebenarnya bisa bermakna. Di mana "int bytes_read = 0" buruk, karena Anda benar-benar dapat membaca 0 byte, menginisialisasi dengan -1 membuatnya sangat jelas tidak ada upaya untuk membaca byte telah berhasil, dan Anda dapat mengujinya.
Pieter B
4

TL; DR: Ada dua cara untuk membuat program ini benar, menginisialisasi variabel Anda dan berdoa. Hanya satu yang memberikan hasil secara konsisten.


Sebelum saya dapat menjawab pertanyaan Anda, saya harus terlebih dahulu menjelaskan apa artinya Perilaku Tidak Terdefinisi . Sebenarnya, saya akan membiarkan penulis kompiler melakukan sebagian besar pekerjaan:

Jika Anda tidak mau membaca artikel itu, TL; DR adalah:

Perilaku Tidak Terdefinisi adalah kontrak sosial antara pengembang dan kompiler; kompiler mengasumsikan dengan keyakinan buta bahwa penggunanya tidak akan pernah, bergantung pada Perilaku Tidak Terdefinisi.

Pola dasar "Iblis terbang dari hidung Anda" telah gagal menyampaikan implikasi dari fakta ini, sayangnya. Meskipun dimaksudkan untuk membuktikan bahwa apa pun bisa terjadi, itu benar-benar tidak dapat dipercaya sehingga sebagian besar diabaikan.

Yang benar, bagaimanapun, adalah bahwa Perilaku Tidak Terdefinisi mempengaruhi kompilasi itu sendiri, jauh sebelum Anda bahkan mencoba untuk menggunakan program (diinstrumentasi atau tidak, dalam debugger atau tidak) dan benar-benar dapat mengubah perilakunya.

Saya menemukan contoh pada bagian 2 di atas yang mencolok:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

ditransformasikan menjadi:

void contains_null_check(int *P) {
  *P = 4;
}

karena sudah jelas itu Ptidak bisa 0karena sudah dereferensi sebelum diperiksa.


Bagaimana ini berlaku untuk contoh Anda?

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

Nah, Anda telah membuat kesalahan umum dengan mengasumsikan bahwa Perilaku Tidak Terdefinisi akan menyebabkan kesalahan run-time. Mungkin tidak.

Mari kita bayangkan bahwa definisi my_readadalah:

err_t my_read(buffer_t buffer, int* bytes_read) {
    err_t result = {};
    int blocks_read = 0;
    if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
    *bytes_read = blocks_read * BLOCK_SIZE;
    return result;
}

dan melanjutkan seperti yang diharapkan dari kompiler yang baik dengan inlining:

int bytes_read; // UNINITIALIZED

// start inlining my_read

err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
    // nothing
} else {
    bytes_read = blocks_reads * BLOCK_SIZE;
}

// end of inlining my_read

buffer.shrink(bytes_read);

Kemudian, seperti yang diharapkan dari kompiler yang baik, kami mengoptimalkan cabang yang tidak berguna:

  1. Tidak ada variabel yang harus digunakan diinisialisasi
  2. bytes_readakan digunakan tanpa diinisialisasi jika resulttidak0
  3. Pengembang menjanjikan itu resulttidak akan pernah terjadi 0!

Jadi resulttidak pernah 0:

int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Oh, resulttidak pernah digunakan:

int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Oh, kami dapat menunda deklarasi bytes_read:

int blocks_read = 0;
low_level_read(buffer, &blocks_read);

int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Dan di sinilah kita, sebuah transformasi yang mengkonfirmasikan yang asli, dan tidak ada debugger yang akan menjebak variabel yang tidak diinisialisasi karena tidak ada.

Saya sudah di jalan itu, memahami masalah ketika perilaku yang diharapkan dan pertemuan tidak cocok benar-benar tidak menyenangkan.

Matthieu M.
sumber
Terkadang saya pikir kompiler harus mendapatkan program untuk menghapus file sumber ketika mereka mengeksekusi jalur UB. Programmer kemudian akan belajar apa artinya UB bagi pengguna akhir mereka ....
mattnz
1

Mari kita lihat lebih dekat kode contoh Anda:

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Ini adalah contoh yang bagus. Jika kami mengantisipasi kesalahan seperti ini, kami dapat menyisipkan baris assert(bytes_read > 0);dan menangkap bug ini saat runtime, yang tidak mungkin dilakukan dengan variabel yang tidak diinisialisasi.

Tapi misalkan kita tidak melakukannya, dan kami menemukan kesalahan di dalam fungsi use(buffer). Kami memuat program di debugger, memeriksa backtrace, dan mengetahui bahwa itu dipanggil dari kode ini. Jadi kami meletakkan breakpoint di bagian atas cuplikan ini, jalankan lagi, dan mereproduksi bug. Kami satu langkah mencoba menangkapnya.

Jika belum diinisialisasi bytes_read, ini berisi sampah. Itu tidak selalu mengandung sampah yang sama setiap kali. Kami melewati garis my_read(buffer, &bytes_read);. Sekarang, jika nilainya berbeda dari sebelumnya, kami mungkin tidak dapat mereproduksi bug sama sekali! Mungkin berhasil di waktu berikutnya, pada input yang sama, secara tidak sengaja. Jika konsisten nol, kita mendapatkan perilaku yang konsisten.

Kami memeriksa nilainya, bahkan mungkin pada backtrace dalam menjalankan yang sama. Jika nol, kita dapat melihat bahwa ada sesuatu yang salah; bytes_readtidak boleh nol pada kesuksesan. (Atau jika bisa, kita mungkin ingin menginisialisasi ke -1.) Kita mungkin dapat menangkap bug di sini. Namun, jika bytes_readnilai yang masuk akal itu kebetulan salah, akankah kita melihatnya secara sekilas?

Hal ini terutama berlaku untuk pointer: pointer NULL akan selalu jelas dalam debugger, dapat diuji dengan sangat mudah, dan harus segfault pada perangkat keras modern jika kita mencoba untuk menghindarinya. Penunjuk sampah dapat menyebabkan bug kerusakan memori yang tidak dapat diproduksi lagi nanti, dan ini hampir tidak mungkin untuk di-debug.

Davislor
sumber
1

OP tidak mengandalkan perilaku yang tidak terdefinisi, atau setidaknya tidak persis. Memang, mengandalkan perilaku tidak terdefinisi itu buruk. Pada saat yang sama, perilaku suatu program dalam kasus yang tidak terduga juga tidak terdefinisi, tetapi jenis yang berbeda. Jika Anda menetapkan variabel ke nol, tetapi Anda tidak berniat untuk memiliki jalur eksekusi yang menggunakan bahwa nol awal, akan Program berperilaku Anda secara masuk akal ketika Anda memiliki bug dan lakukan memiliki jalan seperti itu? Anda sekarang berada di gulma; Anda tidak berencana untuk menggunakan nilai itu, tetapi Anda tetap menggunakannya. Mungkin itu tidak berbahaya, atau mungkin akan menyebabkan program macet, atau mungkin akan menyebabkan program merusak data secara diam-diam. Kamu tidak tahu.

Apa yang OP katakan adalah bahwa ada alat yang akan membantu Anda menemukan bug ini, jika Anda membiarkannya. Jika Anda tidak menginisialisasi nilainya, tetapi kemudian Anda tetap menggunakannya, ada analisis statis dan dinamis yang akan memberi tahu Anda bahwa Anda memiliki bug. Penganalisa statis akan memberi tahu Anda bahkan sebelum Anda mulai menguji program. Sebaliknya, jika Anda secara buta menginisialisasi nilainya, para analis tidak dapat mengatakan bahwa Anda tidak berencana untuk menggunakan nilai awal itu, sehingga bug Anda tidak terdeteksi. Jika Anda beruntung itu tidak berbahaya atau hanya crash program; jika Anda beruntung itu diam-diam merusak data.

Satu-satunya tempat saya tidak setuju dengan OP adalah di bagian paling akhir, di mana ia mengatakan "ketika itu akan mendapatkan kesalahan segmentasi sebaliknya." Memang, variabel yang tidak diinisialisasi tidak akan menghasilkan kesalahan segmentasi dengan andal. Sebaliknya, saya akan mengatakan bahwa Anda harus menggunakan alat analisis statis yang tidak akan membiarkan Anda sampai pada titik berusaha untuk menjalankan program.

Jordan Brown
sumber
0

Sebuah jawaban untuk pertanyaan Anda perlu dipecah menjadi berbagai jenis variabel yang muncul di dalam suatu program:


Variabel lokal

Biasanya deklarasi harus tepat di tempat variabel pertama mendapatkan nilainya. Jangan predeclare variabel seperti di gaya lama C:

//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;

bar = getBar();
foo = (int)bar;
baz = malloc(foo);


//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);

Ini menghilangkan 99% dari kebutuhan untuk inisialisasi, variabel memiliki nilai akhir langsung dari mati. Beberapa pengecualian adalah ketika inisialisasi tergantung pada beberapa kondisi:

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

Saya percaya bahwa menulis kasus seperti ini adalah ide yang bagus:

Base* ptr = nullptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}
assert(ptr);

Saya. E. secara eksplisit menyatakan bahwa beberapa inisialisasi yang masuk akal dari variabel Anda dilakukan.


Variabel anggota

Di sini saya setuju dengan apa yang dikatakan oleh penjawab lain: Ini harus selalu diinisialisasi oleh konstruktor / daftar penginisialisasi. Kalau tidak, Anda sulit memastikan konsistensi di antara anggota Anda. Dan jika Anda memiliki seperangkat anggota yang tampaknya tidak membutuhkan inisialisasi dalam semua kasus, perbaiki kelas Anda, tambahkan anggota tersebut dalam kelas turunan di mana mereka selalu dibutuhkan.


Buffer

Di sinilah saya tidak setuju dengan jawaban yang lain. Ketika orang menjadi religius tentang menginisialisasi variabel, mereka sering berakhir menginisialisasi buffer seperti ini:

char buffer[30];
memset(buffer, 0, sizeof(buffer));

char* buffer2 = calloc(30);

Saya percaya ini hampir selalu berbahaya: Satu-satunya efek dari inisialisasi ini adalah mereka membuat alat seperti valgrindtidak berdaya. Kode apa pun yang membaca lebih banyak dari buffer yang diinisialisasi daripada seharusnya sangat mungkin bug. Tetapi dengan inisialisasi, bug itu tidak dapat diekspos oleh valgrind. Jadi jangan menggunakannya kecuali Anda benar-benar bergantung pada memori yang diisi dengan nol (dan dalam hal ini, berikan komentar yang mengatakan apa yang Anda butuhkan untuk nol).

Saya juga sangat merekomendasikan menambahkan target ke sistem build Anda yang menjalankan seluruh testuite di bawah valgrindatau alat serupa untuk mengekspos bug sebelum-inisialisasi dan kebocoran memori. Ini lebih berharga daripada semua pra-inisialisasi variabel. Itu valgrindtarget yang harus dijalankan secara rutin, yang paling penting sebelum kode apapun go public.


Variabel Global

Anda tidak dapat memiliki variabel global yang tidak diinisialisasi (setidaknya dalam C / C ++ dll), jadi pastikan inisialisasi ini adalah yang Anda inginkan.

cmaster
sumber
Perhatikan bahwa Anda dapat menulis inisialisasi bersyarat dengan operator ternary, misalnya Base& b = foo() ? new Derived1 : new Derived2;
Davislor
@Lorehead Itu mungkin bekerja untuk kasus-kasus sederhana, tetapi tidak akan bekerja untuk yang lebih kompleks: Anda tidak ingin melakukan ini jika Anda memiliki tiga atau lebih kasus, dan konstruktor Anda mengambil tiga atau lebih argumen, hanya untuk keterbacaan alasan Dan itu bahkan tidak mempertimbangkan perhitungan apa pun yang mungkin perlu dilakukan, seperti mencari argumen untuk satu cabang inisialisasi dalam satu lingkaran.
cmaster
Untuk kasus yang lebih rumit, Anda bisa membungkus kode inisialisasi dalam fungsi pabrik: Base &b = base_factory(which);. Ini sangat berguna jika Anda perlu memanggil kode lebih dari sekali atau jika memungkinkan Anda membuat hasilnya konstan.
Davislor
@Lorehead Itu benar, dan tentu saja cara untuk pergi jika logika yang diperlukan tidak sederhana. Namun demikian, saya percaya bahwa ada area abu-abu kecil di antara di mana inisialisasi via ?:adalah PITA, dan fungsi pabrik masih berlebihan. Kasus-kasus ini sedikit dan jarang, tetapi mereka memang ada.
cmaster
-2

Kompiler C, C ++ atau Objective-C yang layak dengan set opsi kompiler yang tepat akan memberi tahu Anda pada waktu kompilasi jika suatu variabel digunakan sebelum nilainya ditetapkan. Karena dalam bahasa-bahasa ini menggunakan nilai variabel yang tidak diinisialisasi adalah perilaku yang tidak terdefinisi, "tetapkan nilai sebelum Anda gunakan" bukanlah petunjuk, atau pedoman, atau praktik yang baik, itu adalah persyaratan 100%; jika tidak, program Anda benar-benar rusak. Dalam bahasa lain, seperti Java dan Swift, kompiler tidak akan pernah mengizinkan Anda untuk menggunakan variabel sebelum diinisialisasi.

Ada perbedaan logis antara "inisialisasi" dan "setel nilai". Jika saya ingin menemukan tingkat konversi antara dolar dan euro, dan tulis "double rate = 0,0;" maka variabel memiliki nilai yang ditetapkan, tetapi tidak diinisialisasi. 0,0 yang disimpan di sini tidak ada hubungannya dengan hasil yang benar. Dalam situasi ini, jika karena bug Anda tidak pernah menyimpan tingkat konversi yang benar, kompiler tidak memiliki kesempatan untuk memberi tahu Anda. Jika Anda baru saja menulis "tarif ganda;" dan tidak pernah menyimpan tingkat konversi yang berarti, kompiler akan memberi tahu Anda.

Jadi: Jangan menginisialisasi variabel hanya karena kompiler memberitahu Anda itu digunakan tanpa diinisialisasi. Itu menyembunyikan bug. Masalah sebenarnya adalah bahwa Anda menggunakan variabel yang seharusnya tidak Anda gunakan, atau bahwa pada satu jalur kode Anda tidak menetapkan nilai. Perbaiki masalahnya, jangan sembunyikan.

Jangan menginisialisasi variabel hanya karena kompiler mungkin memberi tahu Anda itu digunakan tanpa diinisialisasi. Sekali lagi, Anda menyembunyikan masalah.

Deklarasikan variabel yang dekat untuk digunakan. Ini meningkatkan peluang bahwa Anda dapat menginisialisasinya dengan nilai yang bermakna pada titik deklarasi.

Hindari menggunakan kembali variabel. Saat Anda menggunakan kembali variabel, kemungkinan besar diinisialisasi ke nilai yang tidak berguna saat Anda menggunakannya untuk tujuan kedua.

Telah dikomentari bahwa beberapa kompiler memiliki negatif palsu, dan memeriksa inisialisasi setara dengan masalah penghentian. Keduanya dalam praktiknya tidak relevan. Jika kompiler, seperti dikutip, tidak dapat menemukan penggunaan variabel tidak diinisialisasi sepuluh tahun setelah bug dilaporkan, maka sekarang saatnya untuk mencari kompilator alternatif. Java mengimplementasikan ini dua kali; sekali di kompiler, sekali di verifikasi, tanpa masalah. Cara mudah untuk mengatasi masalah penghentian bukan untuk mengharuskan variabel diinisialisasi sebelum digunakan, tetapi bahwa itu diinisialisasi sebelum digunakan dengan cara yang dapat diperiksa oleh algoritma sederhana dan cepat.

gnasher729
sumber
Ini kedengarannya bagus, tetapi terlalu bergantung pada keakuratan peringatan nilai yang tidak diinisialisasi. Memastikan hal ini benar - benar setara dengan Masalah Pemutusan, dan kompiler produksi dapat dan memang menderita negatif palsu (yaitu mereka tidak mendiagnosis variabel yang tidak diinisialisasi ketika mereka seharusnya); lihat misalnya GCC bug 18501 , yang telah tidak diperbaiki selama lebih dari sepuluh tahun sekarang.
zwol
Apa yang Anda katakan tentang gcc baru saja dikatakan. Sisanya tidak relevan.
gnasher729
Menyedihkan tentang gcc, tetapi jika Anda tidak mengerti mengapa sisanya relevan maka Anda harus mendidik diri sendiri.
zwol