Pengecualian deterministik dan penanganan kesalahan dalam โ€œC ++ of the futureโ€


Sungguh aneh bahwa di Habrt belum ada proposal kasar untuk standar C ++ yang disebut "Zero-overhead deterministic exceptionions". Memperbaiki kelalaian yang mengganggu ini.


Jika Anda khawatir tentang overhead pengecualian, atau Anda harus mengkompilasi kode tanpa dukungan pengecualian, atau hanya ingin tahu apa yang akan terjadi dengan penanganan kesalahan di C ++ 2b (referensi ke posting terbaru ), saya meminta cat. Anda sedang menunggu untuk memeras segala sesuatu yang sekarang dapat ditemukan pada topik, dan beberapa jajak pendapat.


Diskusi di bawah ini akan dilakukan tidak hanya tentang pengecualian statis, tetapi juga tentang proposal terkait dengan standar, dan tentang segala macam cara lain untuk menangani kesalahan. Jika Anda pergi ke sini untuk melihat sintaksnya, maka ini dia:


double safe_divide(int x, int y) throws(arithmetic_error) { if (y == 0) { throw arithmetic_error::divide_by_zero; } else { return as_double(x) / y; } } void caller() noexcept { try { cout << safe_divide(5, 2); } catch (arithmetic_error e) { cout << e; } } 

Jika jenis kesalahan tertentu tidak penting / tidak diketahui, maka Anda cukup menggunakan throws dan catch (std::error e) .


Senang tahu


std::optional dan std::expected


Mari kita putuskan bahwa kesalahan yang berpotensi muncul dalam fungsi tidak "fatal" cukup untuk melemparkan pengecualian untuk itu. Secara tradisional, informasi kesalahan dikembalikan menggunakan parameter keluar. Sebagai contoh, Filesystem TS menawarkan sejumlah fitur serupa:


 uintmax_t file_size(const path& p, error_code& ec); 

(Jangan membuang pengecualian karena fakta bahwa file itu tidak ditemukan?) Namun demikian, pemrosesan kode kesalahan rumit dan rentan terhadap bug. Kode kesalahan mudah dilupakan untuk diperiksa. Gaya kode modern melarang penggunaan parameter output, sebagai gantinya, disarankan untuk mengembalikan struktur yang berisi seluruh hasil.


Untuk beberapa waktu sekarang, Boost telah menawarkan solusi yang elegan untuk menangani kesalahan "tidak fatal" tersebut, yang dalam skenario tertentu dapat terjadi dalam ratusan program yang benar:


 expected<uintmax_t, error_code> file_size(const path& p); 

Jenis yang expected mirip dengan variant , tetapi menyediakan antarmuka yang nyaman untuk bekerja dengan "hasil" dan "kesalahan". Secara default, hasil yang expected disimpan dalam yang expected . Implementasi file_size mungkin terlihat seperti ini:


 file_info* info = read_file_info(p); if (info != null) { uintmax_t size = info->size; return size; // <== } else { error_code error = get_error(); return std::unexpected(error); // <== } 

Jika penyebab kesalahan tidak menarik bagi kami, atau kesalahan hanya dapat terdiri dari "tidak adanya" hasilnya, maka optional dapat digunakan:


 optional<int> parse_int(const std::string& s); optional<U> get_or_null(map<T, U> m, const T& key); 

Di C ++ 17 dari Boost, opsional sampai ke std (tanpa dukungan untuk optional<T&> ); di C ++ 20, mereka dapat menambahkan yang diharapkan (ini hanya Proposal, terima kasih RamzesXI untuk koreksi).


Kontrak


Kontrak (jangan dikacaukan dengan konsep) adalah cara baru untuk menerapkan batasan pada parameter fungsi, ditambahkan dalam C ++ 20. 3 anotasi ditambahkan:


  • mengharapkan parameter fungsi pemeriksaan
  • memastikan memeriksa nilai kembali fungsi (menganggapnya sebagai argumen)
  • menegaskan - pengganti yang beradab untuk pernyataan makro

 double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]]; double sqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]]; value fetch_single(key e) { vector<value> result = fetch(vector<key>{e}); [[assert result.size() == 1]]; return v[0]; } 

Anda dapat mengonfigurasi pelanggaran kontrak:


  • Disebut Perilaku Tidak Terdefinisi, atau
  • Ia memeriksa dan memanggil pengguna keluar, setelah itu std::terminate

Tidak mungkin untuk terus menjalankan program setelah pelanggaran kontrak, karena kompiler menggunakan jaminan dari kontrak untuk mengoptimalkan kode fungsi. Jika ada keraguan sedikit pun bahwa kontrak akan dipenuhi, ada baiknya menambahkan cek tambahan.


std :: error_code


Pustaka <system_error> , ditambahkan dalam C ++ 11, memungkinkan Anda untuk membakukan penanganan kode kesalahan dalam program Anda. std :: error_code terdiri dari kode kesalahan bertipe int dan sebuah pointer ke objek beberapa kelas turunan std :: error_category . Objek ini, pada kenyataannya, memainkan peran tabel fungsi virtual dan menentukan perilaku std::error_code diberikan.


Untuk membuat std::error_code Anda, Anda harus mendefinisikan std::error_category turunan dan menerapkan metode virtual, yang paling penting di antaranya adalah:


 virtual std::string message(int c) const = 0; 

Anda juga harus membuat variabel global untuk std::error_category . Kesalahan penanganan menggunakan error_code + diharapkan terlihat seperti ini:


 template <typename T> using result = expected<T, std::error_code>; my::file_handle open_internal(const std::string& name, int& error); auto open_file(const std::string& name) -> result<my::file> { int raw_error = 0; my::file_handle maybe_result = open_internal(name, &raw_error); std::error_code error{raw_error, my::filesystem_error}; if (error) { return unexpected{error}; } else { return my::file{maybe_result}; } } 

Penting bahwa dalam std::error_code nilai 0 berarti tidak ada kesalahan. Jika ini bukan kasus untuk kode kesalahan Anda, maka sebelum Anda mengubah kode kesalahan sistem ke std::error_code , Anda harus mengganti kode 0 dengan SUCCESS, dan sebaliknya.


Semua kode kesalahan sistem dijelaskan dalam errc dan system_category . Jika pada tahap tertentu penerusan kode kesalahan secara manual menjadi terlalu suram, maka Anda selalu dapat membungkus kode kesalahan dalam std::system_error dan membuangnya.


Langkah destruktif / Dapat dipindahkan secara sepele


Biarkan Anda perlu membuat kelas objek lain yang memiliki beberapa sumber daya. Kemungkinan besar, Anda ingin membuatnya tidak dapat disalin, tetapi dapat dipindah-pindahkan, karena tidak nyaman untuk bekerja dengan objek yang tidak dapat dipindahkan (sebelum C ++ 17, mereka tidak dapat dikembalikan dari suatu fungsi).


Tapi inilah masalahnya: dalam hal apa pun, objek yang dipindahkan perlu dihapus. Oleh karena itu, diperlukan kondisi "pindah-dari" khusus, yaitu objek "kosong" yang tidak menghapus apa pun. Ternyata setiap kelas C ++ harus memiliki keadaan kosong, yaitu, tidak mungkin untuk membuat kelas dengan invarian (jaminan) kebenaran, dari konstruktor ke destruktor. Sebagai contoh, tidak mungkin untuk membuat kelas open_file benar dari sebuah file yang terbuka sepanjang masa pakainya. Sangat aneh untuk mengamati ini dalam salah satu dari sedikit bahasa yang secara aktif menggunakan RAII.


Masalah lain adalah memusatkan perhatian objek lama ketika bergerak menambahkan overhead: mengisi std::vector<std::unique_ptr<T>> bisa hingga 2 kali lebih lambat dari std::vector<T*> karena tumpukan zeroing dari pointer lama ketika bergerak , diikuti oleh penghapusan boneka.


Pengembang C ++ telah lama menjilat Rust, di mana destruktor tidak dipanggil pada objek yang dipindahkan. Fitur ini disebut Destructive move. Sayangnya, Proposal Trivially relocatable tidak menawarkan untuk menambahkannya ke C ++. Tetapi masalah overhead akan terpecahkan.


Kelas dianggap dapat dipindahkan secara Trivial jika dua operasi: memindahkan dan menghapus objek lama sama dengan memcpy dari objek lama ke yang baru. Objek lama tidak dihapus, penulis menyebutnya "jatuhkan di lantai".


Suatu tipe dapat dipindahkan dari sudut pandang kompiler jika salah satu dari kondisi berikut (rekursif) benar:


  1. Dapat dipindahkan secara sepele + diremehkan secara sepele (mis. int atau struktur POD)
  2. Ini adalah kelas yang ditandai dengan atribut [[trivially_relocatable]]
  3. Ini adalah kelas yang semua anggotanya dapat direlokasi Trivially.

Anda dapat menggunakan informasi ini dengan std::uninitialized_relocate , yang mengeksekusi move init + delete dengan cara biasa, atau dipercepat jika memungkinkan. Disarankan untuk menandai sebagai [[trivially_relocatable]] sebagian besar tipe perpustakaan standar, termasuk std::string , std::vector , std::unique_ptr . Overhead std::vector<std::unique_ptr<T>> dengan ini, Proposal akan hilang.


Apa yang salah dengan pengecualian sekarang?


Mekanisme pengecualian C ++ dikembangkan pada tahun 1992. Berbagai opsi implementasi telah diusulkan. Dari jumlah tersebut, mekanisme tabel pengecualian dipilih yang menjamin tidak adanya overhead untuk jalur utama pelaksanaan program. Karena sejak saat penciptaan mereka, diasumsikan bahwa pengecualian harus dilemparkan sangat jarang .


Kekurangan pengecualian dinamis (mis. Reguler):


  1. Dalam kasus pengecualian yang dilemparkan, biaya overhead rata-rata sekitar 10.000-100.000 siklus CPU, dan dalam kasus terburuk, dapat mencapai urutan milidetik
  2. Ukuran file biner meningkat 15-38%
  3. Ketidakcocokan dengan antarmuka pemrograman C
  4. Pengecualian dukungan lemparan implisit dalam semua fungsi kecuali kecuali. Pengecualian dapat dilemparkan hampir di mana saja dalam program, bahkan di mana penulis fungsi tidak mengharapkannya

Karena kekurangan ini, ruang lingkup pengecualian sangat terbatas. Ketika pengecualian tidak dapat diterapkan:


  1. Di mana determinisme penting, yaitu, di mana tidak dapat diterima bahwa kode "kadang-kadang" bekerja 10, 100, 1000 kali lebih lambat dari biasanya
  2. Ketika mereka tidak didukung di ABI, misalnya, dalam mikrokontroler
  3. Ketika sebagian besar kode ditulis dalam C
  4. Di perusahaan dengan banyak kode lawas ( Panduan Gaya Google , Qt ). Jika ada setidaknya satu fungsi non-pengecualian-aman dalam kode, maka menurut hukum kekejaman, pengecualian akan dilemparkan melalui itu cepat atau lambat dan membuat bug
  5. Di perusahaan mempekerjakan programmer yang tidak tahu tentang keamanan pengecualian

Menurut survei, di tempat kerja 52% (!) Pengembang, pengecualian dilarang oleh aturan perusahaan.


Tetapi pengecualian merupakan bagian integral dari C ++! Dengan menyertakan -fno-exceptions , pengembang kehilangan kemampuan untuk menggunakan bagian penting dari pustaka standar. Ini lebih lanjut menghasut perusahaan untuk menanam "perpustakaan standar" mereka sendiri dan, ya, menciptakan kelas string mereka sendiri.


Tapi ini bukan akhirnya. Pengecualian adalah satu-satunya cara standar untuk membatalkan pembuatan objek di konstruktor dan melemparkan kesalahan. Ketika dimatikan, kekejian seperti inisialisasi dua fase muncul. Operator juga tidak dapat menggunakan kode kesalahan, sehingga diganti dengan fungsi seperti assign .


Proposal: pengecualian untuk masa depan


Mekanisme transfer pengecualian baru


Herb Sutter dalam P709 menjelaskan mekanisme transfer pengecualian baru. Pada prinsipnya, fungsi mengembalikan std::expected , alih-alih sebagai pembeda terpisah dari tipe bool , yang bersama-sama dengan penyelarasan akan menempati hingga 8 byte pada stack, sedikit informasi ini ditransmisikan dalam beberapa cara yang lebih cepat, misalnya, untuk Membawa Bendera.


Fungsi yang tidak menyentuh CF (kebanyakan dari mereka) akan mendapatkan kesempatan untuk menggunakan pengecualian statis secara gratis - baik dalam kasus pengembalian normal, dan dalam kasus melempar pengecualian! Fungsi yang dipaksa untuk menyimpan dan mengembalikannya akan menerima overhead minimal, dan itu masih akan lebih cepat dari std::expected dan kode kesalahan biasa.


Pengecualian statis terlihat seperti ini:


 int safe_divide(int i, int j) throws(arithmetic_errc) { if (j == 0) throw arithmetic_errc::divide_by_zero; if (i == INT_MIN && j == -1) throw arithmetic_errc::integer_divide_overflows; return i / j; } double foo(double i, double j, double k) throws(arithmetic_errc) { return i + safe_divide(j, k); } double bar(int i, double j, double k) { try { cout << foo(i, j, k); } catch (erithmetic_errc e) { cout << e; } } 

Dalam versi alternatif, diusulkan untuk mewajibkan kata kunci try dalam ekspresi yang sama dengan panggilan fungsi throws : try i + safe_divide(j, k) . Ini akan mengurangi jumlah kasus menggunakan fungsi throws dalam kode yang tidak aman untuk pengecualian menjadi hampir nol. Dalam kasus apa pun, tidak seperti pengecualian dinamis, IDE akan dapat menyoroti ekspresi yang melemparkan pengecualian.


Fakta bahwa pengecualian yang dilemparkan tidak disimpan secara terpisah, tetapi diletakkan langsung di tempat nilai yang dikembalikan, memberlakukan pembatasan pada jenis pengecualian. Pertama, itu harus direlokasi sepele. Kedua, ukurannya tidak boleh terlalu besar (tetapi bisa berupa std::unique_ptr ), jika tidak semua fungsi akan menyimpan lebih banyak ruang di stack.


status_code


Pustaka <system_error2> , yang dikembangkan oleh Niall Douglas, akan berisi status_code<T> - "baru, lebih baik" error_code . Perbedaan utama dari error_code :


  1. status_code - jenis templat yang dapat digunakan untuk menyimpan hampir semua kode kesalahan yang dapat dibayangkan (bersama dengan penunjuk ke status_code_category ), tanpa menggunakan pengecualian statis
  2. T harus dengan mudah dipindahkan dan dapat disalin (yang terakhir, IMHO, tidak harus wajib). Saat menyalin dan menghapus, fungsi virtual dipanggil dari status_code_category
  3. status_code tidak hanya dapat menyimpan data kesalahan, tetapi juga informasi tambahan tentang operasi yang berhasil diselesaikan
  4. Fungsi "virtual" code.message() tidak mengembalikan std::string , tetapi string_ref adalah jenis string yang agak berat, yang merupakan " std::string_view " yang mungkin memiliki "virtual". Di sana Anda dapat string_view atau string , atau std::shared_ptr<string> , atau cara gila lainnya untuk memiliki string. Niall mengklaim bahwa #include <string> akan membuat tajuk <system_error2> "berat"

Selanjutnya, errored_status_code<T> dimasukkan - pembungkus status_code<T> dengan konstruktor berikut:


 errored_status_code(status_code<T>&& code) [[expects: code.failure() == true]] : code_(std::move(code)) {} 

kesalahan


Tipe eksepsi default ( throws tanpa tipe), serta tipe dasar pengecualian yang digunakan semua yang lain (seperti std::exception ), adalah error . Ini didefinisikan sesuatu seperti ini:


 using error = errored_status_code<intptr_t>; 

Artinya, error adalah "error" status_code , di mana nilai ( value ) ditempatkan dalam 1 pointer. Karena mekanisme status_code_category memastikan penghapusan, perpindahan, dan penyalinan yang benar, secara teoritis, struktur data apa pun dapat disimpan dalam error . Dalam praktiknya, ini akan menjadi salah satu opsi berikut:


  1. Integer (int)
  2. std::exception_handle , mis. penunjuk ke pengecualian dinamis yang dilemparkan
  3. status_code_ptr , mis. unique_ptr ke status_code<T> sewenang-wenang status_code<T> .

Masalahnya adalah kasus 3 tidak direncanakan untuk memberikan kesempatan untuk membawa error kembali ke status_code<T> . Satu-satunya hal yang dapat Anda lakukan adalah mendapatkan message() status_code<T> dikemas status_code<T> . Untuk mendapatkan kembali nilai error , lemparkan itu sebagai pengecualian dinamis (!), Lalu tangkap dan bungkus error . Secara umum, Niall percaya bahwa hanya kode kesalahan dan pesan string yang harus disimpan dalam error , yang cukup untuk semua program.


Untuk membedakan berbagai jenis kesalahan, disarankan untuk menggunakan operator perbandingan "virtual":


 try { open_file(name); } catch (std::error e) { if (e == filesystem_error::already_exists) { return; } else { throw my_exception("Unknown filesystem error, unable to continue"); } } 

Menggunakan beberapa blok tangkapan atau dynamic_cast untuk memilih jenis pengecualian akan gagal!


Interaksi dengan pengecualian dinamis


Suatu fungsi mungkin memiliki salah satu dari spesifikasi berikut:


  • noexcept : tidak melempar pengecualian
  • throws(E) : hanya melempar pengecualian statis
  • (tidak ada): hanya melempar pengecualian dinamis

throws menyiratkan noexcept . Jika pengecualian dinamis dilemparkan dari fungsi "statis", maka itu dibungkus error . Jika pengecualian statis dilemparkan dari fungsi "dinamis", maka itu dibungkus dalam pengecualian status_error . Contoh:


 void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws { //  arithmetic_errc   intptr_t //     error foo(); } void baz() { // error    status_error bar(); } void qux() throws { // error    status_error baz(); } 

Pengecualian dalam C?!


Proposal memberikan tambahan pengecualian untuk salah satu standar C di masa depan, dan pengecualian ini akan kompatibel dengan ABI dengan pengecualian statis C ++. Struktur yang mirip dengan std::expected<T, U> , pengguna harus mendeklarasikan secara independen, meskipun redundansi dapat dihapus menggunakan makro. Sintaks terdiri dari (untuk kesederhanaan, kami akan menganggap ini) kata kunci gagal, gagal, tangkap.


 int invert(int x) fails(float) { if (x != 0) return 1 / x; else return failure(2.0f); } struct expected_int_float { union { int value; float error; }; _Bool failed; }; void caller() { expected_int_float result = catch(invert(5)); if (result.failed) { print_error(result.error); return; } print_success(result.value); } 

Pada saat yang sama, dalam C ++ juga dimungkinkan untuk memanggil fungsi fails dari C, mendeklarasikannya dalam blok extern C . Dengan demikian, dalam C ++ akan ada seluruh galaksi kata kunci untuk bekerja dengan pengecualian:


  • throw() - dihapus dalam C ++ 20
  • noexcept - specifier fungsi, fungsi tidak membuang pengecualian dinamis
  • noexcept(expression) - noexcept(expression) fungsi, fungsi tidak membuang pengecualian dinamis yang disediakan
  • noexcept(expression) - Apakah ekspresi membuang pengecualian dinamis?
  • throws(E) - specifier fungsi, fungsi melempar pengecualian statis
  • throws = throws(std::error)
  • fails(E) - fungsi yang diimpor dari C melempar pengecualian statis

Jadi, di C ++ mereka membawa (atau lebih tepatnya, mengirim) keranjang alat baru untuk penanganan kesalahan. Selanjutnya, muncul pertanyaan logis:


Kapan menggunakan apa?


Arah umum


Kesalahan dibagi menjadi beberapa tingkatan:


  • Kesalahan pemrogram. Diproses menggunakan kontrak. Mereka mengarah pada pengumpulan log dan penghentian program sesuai dengan konsep gagal-cepat . Contoh: null pointer (saat ini tidak valid); pembagian dengan nol; kesalahan alokasi memori tidak diramalkan oleh programmer.
  • Kesalahan fatal diberikan oleh programmer. Diusir jutaan kali lebih jarang daripada pengembalian normal dari suatu fungsi, yang membuat penggunaan pengecualian dinamis dibenarkan untuk mereka. Biasanya, dalam kasus seperti itu, Anda harus memulai kembali seluruh subsistem program atau memberikan kesalahan saat melakukan operasi. Contoh: koneksi tiba-tiba terputus dengan basis data; kesalahan alokasi memori yang disediakan oleh programmer.
  • Kesalahan yang dapat dipulihkan saat sesuatu mencegah fungsi menyelesaikan tugasnya, tetapi fungsi panggilan mungkin tahu apa yang harus dilakukan dengannya. Ditangani oleh pengecualian statis. Contoh: bekerja dengan sistem file; kesalahan input / output (IO) lainnya; Data pengguna salah vector::at() .
  • Fungsi berhasil menyelesaikan tugasnya, meskipun dengan hasil yang tidak terduga. std::optional , std::expected , std::variant . Contoh: stoi() ; vector::find() ; map::insert .

Di perpustakaan standar, yang paling dapat diandalkan untuk sepenuhnya meninggalkan penggunaan pengecualian dinamis untuk membuat kompilasi "tanpa pengecualian" legal.


errno


Fungsi yang menggunakan errno untuk bekerja dengan cepat dan mudah dengan kode kesalahan C dan C ++ harus diganti dengan fails(int) dan throws(std::errc) , masing-masing. Untuk beberapa waktu, versi lama dan baru dari fungsi perpustakaan standar akan hidup berdampingan, maka yang lama akan dinyatakan usang.


Kehabisan memori


Kesalahan alokasi memori ditangani oleh kait global new_handler , yang dapat:


  1. Menghilangkan kekurangan memori dan melanjutkan eksekusi
  2. Lempar pengecualian
  3. Program kerusakan

Sekarang std::bad_alloc dilemparkan secara default. Disarankan untuk memanggil std::terminate() secara default. Jika Anda membutuhkan perilaku lama, gantilah pawang dengan yang Anda butuhkan di awal main() .


Semua fungsi yang ada dari perpustakaan standar akan menjadi noexcept dan akan crash program ketika std::bad_alloc . Pada saat yang sama, API baru seperti vector::try_push_back akan ditambahkan, yang memungkinkan kesalahan alokasi memori.


logic_error


Pengecualian std::logic_error , std::domain_error , std::invalid_argument , std::length_error , std::out_of_range , std::future_error melaporkan pelanggaran terhadap prasyarat fungsi. Model kesalahan baru harus menggunakan kontrak sebagai gantinya. Jenis pengecualian yang terdaftar tidak akan ditinggalkan, tetapi hampir semua kasus penggunaannya di perpustakaan standar akan diganti oleh [[expects: โ€ฆ]] .


Status Proposal Saat Ini


Proposal sekarang dalam keadaan draft. Ini sudah banyak berubah, dan masih bisa banyak berubah. Beberapa perkembangan tidak berhasil dipublikasikan, jadi API yang diusulkan <system_error2> tidak sepenuhnya relevan.


Proposal dijelaskan dalam 3 dokumen:


  1. P709 - dokumen asli dari lambang Sutter
  2. P1095 - Pengecualian Ditentukan dalam Visi Niall Douglas, Beberapa Saat Berubah, Ditambahkan Kompatibilitas Bahasa C
  3. P1028 - API dari implementasi uji std::error

Saat ini tidak ada kompiler yang mendukung pengecualian statis. Oleh karena itu, tolok ukur mereka belum memungkinkan.


C++23. , , , C++26, , , .


Kesimpulan


, , . , . .


, ^^

Source: https://habr.com/ru/post/id430690/


All Articles