Menghilangkan Pemulung: Cara RAII
Diterbitkan: 2022-03-11Pada awalnya, ada C. Dalam C, ada tiga jenis alokasi memori: statis, otomatis, dan dinamis. Variabel statis adalah konstanta yang disematkan dalam file sumber, dan karena mereka telah mengetahui ukurannya dan tidak pernah berubah, mereka tidak terlalu menarik. Alokasi otomatis dapat dianggap sebagai alokasi tumpukan - ruang dialokasikan ketika blok leksikal dimasukkan, dan dibebaskan ketika blok itu keluar. Fitur terpentingnya terkait langsung dengan itu. Hingga C99, variabel yang dialokasikan secara otomatis diperlukan agar ukurannya diketahui pada waktu kompilasi. Ini berarti bahwa setiap string, daftar, peta, dan struktur apa pun yang diturunkan darinya harus hidup di heap, dalam memori dinamis.
Memori dinamis secara eksplisit dialokasikan dan dibebaskan oleh programmer menggunakan empat operasi dasar: malloc, realoc, calloc, dan free. Dua yang pertama ini tidak melakukan inisialisasi apa pun, memori mungkin berisi cruft. Semuanya kecuali gratis bisa gagal. Dalam hal ini, mereka mengembalikan pointer nol, yang aksesnya adalah perilaku yang tidak ditentukan; dalam kasus terbaik, program Anda meledak. Dalam kasus terburuk, program Anda tampaknya bekerja untuk sementara waktu, memproses data sampah sebelum meledak.
Melakukan hal-hal dengan cara ini agak menyakitkan karena Anda, programmer, memiliki tanggung jawab tunggal untuk memelihara sekelompok invarian yang menyebabkan program Anda meledak ketika dilanggar. Harus ada panggilan malloc sebelum variabel diakses. Anda harus memeriksa bahwa malloc berhasil dikembalikan sebelum menggunakan variabel Anda. Harus ada tepat satu panggilan gratis per malloc di jalur eksekusi. Jika nol, memori bocor. Jika lebih dari satu, program Anda akan meledak. Mungkin tidak ada upaya akses ke variabel setelah dibebaskan. Mari kita lihat contoh seperti apa ini sebenarnya:
int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); } $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576Kode itu, sesederhana itu, sudah berisi satu antipattern dan satu keputusan yang dipertanyakan. Dalam kehidupan nyata, Anda tidak boleh menulis jumlah byte sebagai literal, tetapi gunakan fungsi sizeof. Demikian pula, kami mengalokasikan array char * ke ukuran string yang kami butuhkan dua kali (satu lebih dari panjang string, untuk memperhitungkan penghentian nol), yang merupakan operasi yang cukup mahal. Program yang lebih canggih mungkin membuat buffer string yang lebih besar, memungkinkan ukuran string bertambah.
Penemuan RAII: Harapan Baru
Semua manajemen manual itu tidak menyenangkan, untuk sedikitnya. Pada pertengahan 80-an, Bjarne Stroustrup menemukan paradigma baru untuk bahasa barunya, C++. Dia menyebutnya Akuisisi Sumber Daya Adalah Inisialisasi, dan wawasan mendasarnya adalah sebagai berikut: objek dapat ditentukan untuk memiliki konstruktor dan destruktor yang dipanggil secara otomatis pada waktu yang tepat oleh kompiler, ini menyediakan cara yang jauh lebih nyaman untuk mengelola memori objek yang diberikan membutuhkan, dan teknik ini juga berguna untuk sumber daya yang bukan memori.
Ini berarti contoh di atas, dalam C++, jauh lebih bersih:
int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); } $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0Tidak ada manajemen memori manual yang terlihat! Objek string dibangun, memiliki metode kelebihan beban yang dipanggil, dan secara otomatis dihancurkan ketika fungsi keluar. Sayangnya, kesederhanaan yang sama dapat menyebabkan komplikasi lain. Mari kita lihat contoh dalam beberapa detail:
vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.Itu semua tampak cukup mudah. Garis vektor diisi, dikembalikan, dan dipanggil. Namun, sebagai pemrogram efisien yang peduli dengan kinerja, sesuatu tentang ini mengganggu kami: dalam pernyataan pengembalian, vektor disalin ke vektor baru karena nilai semantik yang dimainkan, sesaat sebelum kehancurannya.
Ini tidak sepenuhnya benar di C++ modern lagi. C++11 memperkenalkan gagasan semantik bergerak, di mana asal dibiarkan dalam keadaan valid (sehingga masih dapat dihancurkan dengan benar) tetapi keadaan tidak ditentukan. Panggilan balik adalah kasus yang sangat mudah bagi kompilator untuk mengoptimalkan untuk memindahkan semantik, karena tahu bahwa asalnya akan segera dihancurkan sebelum akses lebih lanjut. Namun, tujuan dari contoh ini adalah untuk menunjukkan mengapa orang menemukan sejumlah besar bahasa yang dikumpulkan dari sampah di akhir 80-an dan awal 90-an, dan pada saat itu semantik pemindahan C++ tidak tersedia.
Untuk data besar, ini bisa menjadi mahal. Mari kita optimalkan ini, dan kembalikan sebuah pointer. Ada beberapa perubahan sintaks, tetapi selain itu kodenya sama:
Sebenarnya, vektor adalah pegangan nilai: struktur yang relatif kecil yang berisi pointer ke item di heap. Sebenarnya, itu bukan masalah untuk hanya mengembalikan vektor. Contoh akan bekerja lebih baik jika array besar dikembalikan. Karena mencoba membaca file ke dalam array yang telah dialokasikan sebelumnya akan menjadi tidak masuk akal, kami menggunakan vektor sebagai gantinya. Anggap saja itu adalah struktur data yang sangat besar, tolong.
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)Aduh! Sekarang garis adalah sebuah pointer, kita dapat melihat bahwa variabel otomatis bekerja seperti yang diiklankan: vektor dimusnahkan saat ruang lingkupnya ditinggalkan, meninggalkan pointer menunjuk ke lokasi maju dalam tumpukan. Kesalahan segmentasi hanyalah upaya mengakses memori ilegal, jadi kami benar-benar harus mengharapkannya. Namun, kami ingin mendapatkan kembali baris file dari fungsi kami, dan hal yang wajar adalah dengan memindahkan variabel kami keluar dari tumpukan dan ke dalam tumpukan. Ini dilakukan dengan kata kunci baru. Kami cukup mengedit satu baris file kami, di mana kami mendefinisikan baris:
vector<string> * lines = new vector<string>; $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.Sayangnya, meskipun ini tampaknya bekerja dengan sempurna, ia masih memiliki kekurangan: membocorkan memori. Di C++, pointer ke heap harus dihapus secara manual setelah tidak lagi diperlukan; jika tidak, memori itu menjadi tidak tersedia setelah penunjuk terakhir keluar dari ruang lingkup, dan tidak dipulihkan hingga OS mengelolanya saat proses berakhir. C++ modern idiomatis akan menggunakan unique_ptr di sini, yang mengimplementasikan perilaku yang diinginkan. Ini menghapus objek yang ditunjuk ketika pointer keluar dari ruang lingkup. Namun, perilaku itu bukan bagian dari bahasa sampai C++ 11.
Dalam contoh ini, ini dapat dengan mudah diperbaiki:
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }Sayangnya, ketika program berkembang melampaui skala mainan, dengan cepat menjadi lebih sulit untuk memikirkan di mana dan kapan tepatnya sebuah pointer harus dihapus. Ketika suatu fungsi mengembalikan pointer, apakah Anda memilikinya sekarang? Haruskah Anda menghapusnya sendiri setelah selesai, atau apakah itu milik beberapa struktur data yang semuanya akan dibebaskan sekaligus nanti? Salah dalam satu cara dan kebocoran memori, salah dalam cara lain dan Anda telah merusak struktur data yang dimaksud dan kemungkinan yang lain, karena mereka mencoba untuk mereferensikan pointer yang sekarang tidak lagi valid.
"Ke dalam Pengumpul Sampah, flyboy!"
Pengumpul Sampah bukanlah teknologi baru. Mereka ditemukan pada tahun 1959 oleh John McCarthy untuk Lisp. Dengan Smalltalk-80 pada tahun 1980, pengumpulan sampah mulai menjadi arus utama. Namun, tahun 1990-an mewakili perkembangan teknik yang sebenarnya: antara tahun 1990 dan 2000, sejumlah besar bahasa dirilis, yang semuanya menggunakan pengumpulan sampah dari satu jenis atau lainnya: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml , dan C# termasuk yang paling terkenal.
Apa itu pengumpulan sampah? Singkatnya, ini adalah seperangkat teknik yang digunakan untuk mengotomatisasi manajemen memori manual. Ini sering tersedia sebagai perpustakaan untuk bahasa dengan manajemen memori manual seperti C dan C++, tetapi jauh lebih umum digunakan dalam bahasa yang membutuhkannya. Keuntungan besar adalah bahwa programmer tidak perlu memikirkan memori; itu semua diabstraksikan. Misalnya, Python yang setara dengan kode pembacaan file kami di atas hanyalah ini:
def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count)) $ python3 python3.py makefile File makefile contains 38 lines. Array baris muncul saat pertama kali ditugaskan dan dikembalikan tanpa menyalin ke lingkup panggilan. Itu akan dibersihkan oleh Pengumpul Sampah beberapa saat setelah keluar dari ruang lingkup itu, karena waktunya tidak ditentukan. Catatan yang menarik adalah bahwa dalam Python, RAII untuk sumber daya non-memori tidak idiomatis. Itu diperbolehkan - kita bisa saja menulis fp = open(file_name) alih-alih menggunakan blok with , dan biarkan GC membersihkannya sesudahnya. Tetapi pola yang disarankan adalah menggunakan pengelola konteks jika memungkinkan sehingga dapat dirilis pada waktu yang ditentukan.

Sebagus apa pun untuk mengabstraksikan manajemen memori, ada biayanya. Dalam pengumpulan sampah penghitungan referensi, semua penugasan variabel dan keluar lingkup mendapatkan sedikit biaya untuk memperbarui referensi. Dalam sistem mark-and-sweep, pada interval yang tidak terduga, semua eksekusi program dihentikan sementara GC membersihkan memori. Ini sering disebut acara stop-the-world. Implementasi seperti Python, yang menggunakan kedua sistem, mengalami kedua hukuman tersebut. Masalah ini mengurangi kesesuaian bahasa yang dikumpulkan dari sampah untuk kasus di mana kinerja sangat penting, atau aplikasi waktu nyata diperlukan. Seseorang dapat melihat hukuman kinerja beraksi bahkan pada program mainan ini:
$ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015sVersi Python membutuhkan waktu hampir tiga kali lebih banyak daripada versi C++. Meskipun tidak semua perbedaan itu dapat dikaitkan dengan pengumpulan sampah, itu masih cukup besar.
Kepemilikan: RAII Awakens
Apakah itu akhirnya? Haruskah semua bahasa pemrograman memilih antara kinerja dan kemudahan pemrograman? Tidak! Penelitian bahasa pemrograman terus berlanjut, dan kami mulai melihat implementasi pertama dari paradigma bahasa generasi berikutnya. Yang menarik adalah bahasa yang disebut Rust, yang menjanjikan ergonomis seperti Python dan kecepatan seperti C saat membuat pointer menjuntai, pointer nol, dan hal yang tidak mungkin - mereka tidak akan dikompilasi. Bagaimana itu bisa membuat klaim itu?
Teknologi inti yang memungkinkan klaim yang mengesankan ini disebut pemeriksa pinjaman, pemeriksa statis yang berjalan pada kompilasi, menolak kode yang dapat menyebabkan masalah ini. Namun, sebelum masuk terlalu dalam ke implikasinya, kita perlu membicarakan prasyaratnya.
Kepemilikan
Ingat dalam diskusi kami tentang pointer di C++, kami menyentuh gagasan kepemilikan, yang secara kasar berarti "siapa yang bertanggung jawab untuk menghapus variabel ini." Karat memformalkan dan memperkuat konsep ini. Setiap pengikatan variabel memiliki kepemilikan sumber daya yang diikatnya, dan pemeriksa peminjaman memastikan ada satu pengikatan yang memiliki kepemilikan keseluruhan sumber daya. Artinya, cuplikan berikut dari Buku Rust, tidak akan dikompilasi:
let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]); error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^Tugas di Rust memiliki memindahkan semantik secara default - mereka mentransfer kepemilikan. Dimungkinkan untuk memberikan salinan semantik ke suatu tipe, dan ini sudah dilakukan untuk primitif numerik, tetapi itu tidak biasa. Karena itu, pada baris kode ketiga, v2 memiliki vektor yang bersangkutan dan tidak dapat lagi diakses sebagai v. Mengapa ini berguna? Ketika setiap sumber daya memiliki tepat satu pemilik, ia juga memiliki satu momen di mana ia berada di luar cakupan, yang dapat ditentukan pada waktu kompilasi. Ini berarti bahwa Rust dapat memenuhi janji RAII, menginisialisasi dan menghancurkan sumber daya secara deterministik berdasarkan cakupannya, tanpa pernah menggunakan pengumpul sampah atau mengharuskan programmer untuk merilis apa pun secara manual.
Bandingkan ini dengan pengumpulan sampah yang menghitung referensi. Dalam implementasi RC, semua pointer memiliki setidaknya dua informasi: objek yang ditunjuk, dan jumlah referensi ke objek itu. Objek dihancurkan ketika hitungan itu mencapai 0. Ini menggandakan kebutuhan memori pointer dan menambahkan sedikit biaya untuk penggunaannya, karena hitungan secara otomatis bertambah, berkurang, dan diperiksa. Sistem kepemilikan Rust menawarkan jaminan yang sama, bahwa objek dihancurkan secara otomatis ketika mereka kehabisan referensi, tetapi ia melakukannya tanpa biaya runtime. Kepemilikan setiap objek dianalisis dan panggilan penghancuran dimasukkan pada waktu kompilasi.
Peminjaman
Jika memindahkan semantik adalah satu-satunya cara untuk melewatkan data, tipe pengembalian fungsi akan menjadi sangat rumit, sangat cepat. Jika Anda ingin menulis fungsi yang menggunakan dua vektor untuk menghasilkan bilangan bulat, yang tidak menghancurkan vektor setelahnya, Anda harus memasukkannya ke dalam nilai kembalian. Meskipun secara teknis mungkin, sangat buruk untuk digunakan:
fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);Sebaliknya, Rust memiliki konsep pinjaman. Anda dapat menulis fungsi yang sama seperti itu, dan itu akan meminjam referensi ke vektor, mengembalikannya ke pemilik ketika fungsi berakhir:
fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);v1 dan v2 mengembalikan kepemilikan mereka ke cakupan asli setelah fn foo kembali, keluar dari ruang lingkup dan dihancurkan secara otomatis ketika ruang lingkup yang berisi keluar.
Perlu disebutkan di sini bahwa ada pembatasan peminjaman, yang diberlakukan oleh pemeriksa peminjaman pada waktu kompilasi, yang secara ringkas dicantumkan oleh Rust Book:
Setiap pinjaman harus berlangsung untuk ruang lingkup tidak lebih besar dari pemilik. Kedua, Anda mungkin memiliki satu atau yang lain dari dua jenis pinjaman ini, tetapi tidak keduanya sekaligus:
satu atau lebih referensi (&T) ke sumber daya
tepat satu referensi yang bisa berubah (&mut T)
Ini penting karena membentuk aspek penting dari perlindungan Rust terhadap ras data. Dengan mencegah beberapa akses yang dapat diubah ke sumber daya yang diberikan pada waktu kompilasi, ini menjamin bahwa kode tidak dapat ditulis di mana hasilnya tidak dapat ditentukan karena tergantung pada utas mana yang tiba di sumber daya terlebih dahulu. Ini mencegah masalah seperti pembatalan iterator dan penggunaan setelah gratis.
Pemeriksa Pinjam dalam Istilah Praktis
Sekarang setelah kita mengetahui tentang beberapa fitur Rust, mari kita lihat bagaimana kita mengimplementasikan penghitung baris file yang sama yang telah kita lihat sebelumnya:
fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } } Di luar item yang sudah dikomentari dalam kode sumber, ada baiknya menelusuri dan menelusuri masa pakai berbagai variabel. file_name dan file_lines bertahan hingga akhir main(); destruktor mereka dipanggil pada saat itu tanpa biaya tambahan, menggunakan mekanisme yang sama seperti variabel otomatis C++. Saat memanggil read_lines_from_file , file_name dipinjamkan secara permanen ke fungsi itu selama durasinya. Dalam read_lines_from_file , buffer bertindak dengan cara yang sama, dihancurkan ketika berada di luar jangkauan. lines , di sisi lain, tetap ada dan berhasil dikembalikan ke main . Mengapa?
Hal pertama yang perlu diperhatikan adalah karena Rust adalah bahasa berbasis ekspresi, panggilan balik mungkin tidak terlihat seperti itu pada awalnya. Jika baris terakhir dari suatu fungsi menghilangkan tanda titik koma, ekspresi itu adalah nilai kembalian. Hal kedua adalah bahwa nilai kembalian mendapatkan penanganan khusus. Mereka diasumsikan ingin hidup setidaknya selama pemanggil fungsi. Catatan terakhir adalah bahwa karena semantik pemindahan yang terlibat, tidak ada salinan yang diperlukan untuk mentransmutasikan Ok(lines) menjadi Ok(file_lines) , kompiler hanya membuat titik variabel pada bit memori yang sesuai.
“Hanya pada akhirnya Anda menyadari kekuatan sebenarnya dari RAII.”
Manajemen memori manual adalah mimpi buruk yang telah diciptakan oleh programmer untuk dihindari sejak ditemukannya kompiler. RAII adalah pola yang menjanjikan, tetapi lumpuh di C++ karena tidak berfungsi untuk objek yang dialokasikan tumpukan tanpa beberapa solusi yang aneh. Akibatnya, ada ledakan bahasa pengumpulan sampah di tahun 90-an, yang dirancang untuk membuat hidup lebih menyenangkan bagi programmer bahkan dengan mengorbankan kinerja.
Namun, itu bukan kata terakhir dalam desain bahasa. Dengan menggunakan gagasan kepemilikan dan peminjaman yang baru dan kuat, Rust berhasil menggabungkan pola RAII berbasis ruang lingkup dengan keamanan memori pengumpulan sampah; semua tanpa pernah membutuhkan pengumpul sampah untuk menghentikan dunia, sambil membuat jaminan keamanan tidak terlihat dalam bahasa lain. Ini adalah masa depan pemrograman sistem. Lagi pula, "berbuat salah adalah manusia, tetapi penyusun tidak pernah lupa."
Bacaan Lebih Lanjut di Blog Teknik Toptal:
- WebAssembly/Rust Tutorial: Pemrosesan Audio Pitch-sempurna
- Berburu Kebocoran Memori di Jawa
