Pada artikel ini, kita akan mengolok-olok bahasa pemrograman Rust, dan khususnya, objek sifat.
Ketika saya berkenalan dengan Rust, salah satu detail implementasi objek tipe tampak menarik bagi saya. Yaitu, tabel fungsi virtual tidak dalam data itu sendiri, tetapi di pointer "tebal" untuk itu. Setiap pointer ke objek tipe) berisi pointer ke data itu sendiri, serta tautan ke tabel virtual di mana alamat fungsi yang mengimplementasikan objek tipe ini untuk struktur tertentu akan ditempatkan (tetapi karena ini adalah detail implementasi, perilaku dapat berubah.
Mari kita mulai dengan contoh sederhana yang menunjukkan pointer tebal. Kode berikut akan ditampilkan pada arsitektur 64-bit 8 dan 16:
fn main () { let v: &String = &"hello".into(); let disp: &std::fmt::Display = v; println!(" : {}", std::mem::size_of_val(&v)); println!(" -: {}", std::mem::size_of_val(&disp)); }
Mengapa ini menarik? Ketika saya terlibat dalam perusahaan Java, salah satu tugas yang cukup sering muncul adalah adaptasi objek yang ada untuk diberikan antarmuka. Yaitu, objek sudah ada, dikeluarkan sebagai tautan, tetapi harus disesuaikan dengan antarmuka yang ditentukan. Dan Anda tidak dapat mengubah objek input, itu adalah apa adanya.
Saya harus melakukan sesuatu seperti ini:
Person adapt(Json value) {
Ada berbagai masalah dengan pendekatan ini. Misalnya, jika objek yang sama "beradaptasi" dua kali, maka kita mendapatkan dua Person
berbeda (dari sudut pandang perbandingan tautan). Dan fakta bahwa Anda harus membuat objek baru setiap kali entah bagaimana jelek.
Ketika saya melihat objek ketik di Rust, saya punya ide bahwa di Rust itu bisa dilakukan jauh lebih elegan! Anda juga dapat mengambil dan menetapkan tabel virtual lain ke data dan mendapatkan objek sifat baru! Dan jangan mengalokasikan memori untuk setiap instance. Pada saat yang sama, seluruh logika "pinjaman" tetap ada - fungsi adaptasi kami akan terlihat seperti sesuatu seperti fn adapt<'a>(value: &'a Json) -> &'a Person
(yaitu, kami semacam meminjam dari sumber data).
Bahkan lebih dari itu, Anda dapat "memaksa" jenis yang sama (misalnya, String
) untuk mengimplementasikan objek jenis kami beberapa kali, dengan perilaku yang berbeda. Mengapa Tetapi Anda tidak pernah tahu apa yang mungkin diperlukan dalam perusahaan ?!
Mari kita coba implementasikan ini.
Pernyataan masalah
Kami menetapkan tugas dengan cara ini: membuat fungsi annotate
, yang "menetapkan" objek tipe berikut ke tipe String
biasa:
trait Object { fn type_name(&self) -> &str; fn as_string(&self) -> &String; }
Dan fungsi annotate
itu sendiri:
Mari kita menulis ujian segera. Pertama, pastikan bahwa tipe "yang ditugaskan" cocok dengan yang diharapkan. Kedua, kami akan memastikan bahwa kami bisa mendapatkan baris asli dan itu akan menjadi baris yang sama (dari sudut pandang pointer):
#[test] fn test() { let input: String = "hello".into(); let annotated1 = annotate(&input, "Widget"); let annotated2 = annotate(&input, "Gadget");
Pendekatan nomor 1: dan setelah kita setidaknya banjir!
Pertama, mari kita coba untuk membuat implementasi yang sepenuhnya naif. Hanya bungkus data kami dalam "pembungkus", yang juga akan mengandung type_name
:
struct Wrapper<'a> { value: &'a String, type_name: String, } impl<'a> Object for Wrapper<'a> { fn type_name(&self) -> &str { &self.type_name } fn as_string(&self) -> &String { self.value } }
Belum ada yang istimewa. Semuanya seperti di Jawa. Tetapi kami tidak memiliki pemulung, di mana kami akan menyimpan bungkus ini? Kita perlu mengembalikan tautan, agar tetap valid setelah memanggil fungsi annotate
. Kami akan memasukkan sesuatu yang menakutkan ke dalam Box
sehingga Wrapper
disorot di heap. Dan kemudian kita akan mengembalikan tautannya. Dan agar pembungkus tetap hidup setelah memanggil fungsi annotate
, kita akan "membocorkan" kotak ini:
fn annotate<'a>(input: &'a String, type_name: &str) -> &'a dyn Object { let b = Box::new(Wrapper { value: input, type_name: type_name.into(), }); Box::leak(b) }
... dan tes berlalu!
Tapi ini beberapa keputusan yang meragukan. Kita tidak hanya mengalokasikan memori dengan setiap "anotasi", sehingga memori bocor ( Box::leak
mengembalikan tautan ke data yang disimpan di heap, tetapi pada saat yang sama "melupakan" kotak itu sendiri, yaitu, tidak akan ada rilis otomatis )
Pendekatan 2: Arena!
Untuk mulai dengan, mari kita coba untuk menyimpan pembungkus ini di suatu tempat sehingga mereka tetap dirilis di beberapa titik. Tetapi pada saat yang sama mempertahankan tanda tangan yang annotate
seperti itu. Artinya, mengembalikan tautan dengan penghitungan referensi (misalnya, Rc<Wrapper>
) tidak berfungsi.
Pilihan paling sederhana adalah membuat struktur tambahan, "sistem tipe", yang akan bertanggung jawab untuk menyimpan pembungkus ini. Dan ketika kita selesai, kita akan melepaskan struktur ini dan semua pembungkusnya.
Sesuatu seperti itu. Perpustakaan typed-arena
digunakan untuk menyimpan pembungkus, tetapi Anda bisa bertahan dengan jenis Vec<Box<Wrapper>>
, yang utama adalah untuk memastikan bahwa Wrapper
tidak bergerak di mana pun (di malam hari Karat Anda dapat menggunakan pin API untuk ini):
struct TypeSystem { wrappers: typed_arena::Arena<Wrapper>, } impl TypeSystem { pub fn new() -> Self { Self { wrappers: typed_arena::Arena::new(), } }
Tetapi ke mana parameter yang bertanggung jawab untuk masa pakai tautan untuk tipe Wrapper
pergi? Kami harus menyingkirkannya, karena kami tidak dapat mengaitkan masa hidup tetap dalam jenis typed_arena::Arena<Wrapper<'?>>
. Setiap bungkus memiliki parameter unik, tergantung pada input
!
Sebagai gantinya, kami menaburkan sedikit Rust yang tidak aman untuk menyingkirkan parameter seumur hidup:
struct Wrapper { value: *const String, type_name: String, } impl Object for Wrapper { fn type_name(&self) -> &str { &self.type_name }
Dan tes-tes itu berlalu lagi, dengan demikian memberi kita kepercayaan akan kebenaran keputusan. Selain merasa tidak nyaman dengan yang unsafe
(sebagaimana mestinya, lebih baik tidak bercanda dengan Rust yang tidak aman!).
Namun, bagaimana dengan opsi yang dijanjikan, yang tidak membutuhkan alokasi memori tambahan untuk pembungkus?
Pendekatan # 3: Biarkan Gerbang Neraka Terbuka
Ide Untuk setiap "tipe" ("Widget", "Gadget") yang unik, kami akan membuat tabel virtual. Tangan selama pelaksanaan program. Dan kami menempatkannya pada tautan yang diberikan kepada kami oleh data itu sendiri (yang, seingat kami, hanyalah String
).
Pertama, deskripsi singkat tentang apa yang perlu kita dapatkan. Jadi, referensi ke objek tipe, bagaimana cara mengaturnya? Sebenarnya, ini hanya dua pointer, satu ke data itu sendiri, dan yang lainnya ke tabel virtual. Jadi kami menulis:
#[repr(C)] struct TraitObject { pub data: *const (), pub vtable: *const (), }
( #[repr(C)]
kita perlu menjamin lokasi yang benar dalam memori).
Tampaknya semuanya sederhana, kami akan menghasilkan tabel baru untuk parameter yang diberikan dan "mengumpulkan" tautan ke objek tipe! Tapi apa yang terdiri dari tabel ini?
Jawaban yang benar untuk pertanyaan ini adalah "ini adalah detail implementasi." Tetapi kami akan melakukannya; buat file rust-toolchain
di root proyek kami dan tulis di sana: nightly-2018-12-01
. Lagi pula, perakitan tetap dapat dianggap stabil, bukan?
Sekarang kita telah memperbaiki versi Rust (pada kenyataannya, kita akan memerlukan perakitan malam untuk salah satu perpustakaan di bawah).
Setelah beberapa pencarian di Internet , kami menemukan bahwa format tabelnya sederhana: pertama ada tautan ke destruktor, kemudian dua bidang yang terkait dengan alokasi memori (jenis ukuran dan perataan), dan kemudian fungsi berjalan satu demi satu (urutannya ada pada kebijaksanaan kompiler, tetapi kami memiliki hanya dua fungsi, jadi kemungkinan tebakannya cukup tinggi, 50%).
Jadi kami menulis:
#[repr(C)] #[derive(Clone, Copy)] struct VirtualTableHeader { destructor_fn: fn(*mut ()), size: usize, align: usize, } #[repr(C)] struct ObjectVirtualTable { header: VirtualTableHeader, type_name_fn: fn(*const String) -> *const str, as_string_fn: fn(*const String) -> *const String, }
Demikian pula, #[repr(C)]
diperlukan untuk menjamin lokasi yang benar dalam memori. Saya dibagi menjadi dua struktur, sedikit kemudian akan bermanfaat bagi kita.
Sekarang mari kita coba menulis sistem tipe kita, yang akan menyediakan fungsi annotate
. Kita perlu melakukan cache tabel yang dihasilkan, jadi mari kita cache:
struct TypeInfo { vtable: ObjectVirtualTable, } #[derive(Default)] struct TypeSystem { infos: RefCell<HashMap<String, TypeInfo>>, }
Kami menggunakan keadaan internal RefCell
sehingga fungsi TypeSystem::annotate
dapat menerima &self
sebagai tautan bersama. Ini penting, karena kita "meminjam" dari TypeSystem
untuk memastikan bahwa tabel virtual yang kita hasilkan hidup lebih lama daripada referensi ke objek tipe yang kita kembalikan dari annotate
.
Karena kami ingin dapat memberi anotasi pada banyak contoh, kami tidak dapat meminjam &mut self
sebagai tautan yang dapat diubah.
Dan kami akan membuat sketsa kode ini:
impl TypeSystem { pub fn annotate<'a: 'b, 'b>( &'a self, input: &'b String, type_name: &str ) -> &'b dyn Object { let type_name = type_name.to_string(); let mut infos = self.infos.borrow_mut(); let imp = infos.entry(type_name).or_insert_with(|| unsafe {
Dari mana kita mendapatkan tabel ini? Tiga entri pertama di dalamnya akan cocok dengan entri untuk tabel virtual lainnya untuk jenis yang ditentukan. Karena itu, ambil dan salin saja. Pertama, mari kita dapatkan tipe ini:
trait Whatever {} impl<T> Whatever for T {}
Berguna bagi kita untuk mendapatkan "tabel virtual lainnya" ini. Dan kemudian, kami menyalin tiga entri ini darinya:
let whatever = input as &dyn Whatever; let whatever_obj = std::mem::transmute::<&dyn Whatever, TraitObject>(whatever); let whatever_vtable_header = whatever_obj.vtable as *const VirtualTableHeader; let vtable = ObjectVirtualTable {
Pada dasarnya, kita bisa mendapatkan ukuran dan perataan melalui std::mem::size_of::<String>()
dan std::mem::align_of::<String>()
. Tapi dari mana lagi destruktor itu bisa "dicuri", saya tidak tahu.
Ok, tapi dari mana kita mendapatkan alamat fungsi-fungsi ini, as_string_fn
dan as_string_fn
? Anda mungkin memperhatikan bahwa as_string_fn
umumnya tidak diperlukan, penunjuk data selalu berjalan sebagai catatan pertama dalam representasi objek tipe. Artinya, fungsi ini selalu sama:
impl Object for String {
Tetapi dengan fungsi kedua itu tidak mudah! Itu juga tergantung pada nama kami "type", type_name
.
Tidak masalah, kami hanya dapat menghasilkan fungsi ini di runtime. Mari kita ambil perpustakaan dynasm
untuk ini (saat ini, membutuhkan pembangunan malam Rust). Baca tentang
konvensi fungsi panggilan .
Untuk kesederhanaan, anggaplah kita hanya tertarik pada Mac OS dan Linux (setelah semua transformasi yang menyenangkan ini, kompatibilitas tidak lagi mengganggu kita, bukan?). Dan, ya, secara eksklusif x86-64, tentu saja.
Fungsi kedua, as_string
, mudah diimplementasikan. Kami berjanji bahwa parameter pertama akan ada di register RDI
. Dan kembalikan nilainya ke RAX
. Artinya, kode fungsinya akan seperti:
dynasm!(ops ; mov rax, rdi ; ret );
Tetapi fungsi pertama agak rumit. Pertama, kita perlu mengembalikan &str
, yang merupakan pointer tebal. Bagian pertama adalah pointer ke string, dan bagian kedua adalah panjang slice string. Untungnya, konvensi di atas memungkinkan Anda mengembalikan hasil 128-bit menggunakan register EDX
untuk bagian kedua.
Masih ada tempat untuk mendapatkan tautan ke string slice yang berisi string type_name
. Kami tidak ingin bergantung pada type_name
(meskipun melalui anotasi seumur hidup kami dapat menjamin bahwa type_name
akan hidup lebih lama dari nilai yang dikembalikan).
Tapi kami memiliki salinan dari baris ini, yang kami taruh di tabel hash. Melintasi jari kami, kami akan membuat asumsi bahwa lokasi slice string yang String::as_str
tidak akan String::as_str
tidak berubah dari memindahkan String
(dan String
akan dipindahkan dalam proses mengubah ukuran HashMap
mana string ini disimpan oleh kunci). Saya tidak tahu apakah perpustakaan standar menjamin perilaku ini, tetapi apakah kami hanya memainkannya dengan mudah?
Kami mendapatkan komponen yang diperlukan:
let type_name_ptr = type_name.as_str().as_ptr(); let type_name_len = type_name.as_str().len();
Dan tulis fungsi ini:
dynasm!(ops ; mov rax, QWORD type_name_ptr as i64 ; mov rdx, QWORD type_name_len as i64 ; ret );
Dan akhirnya, kode annotate
akhir:
pub fn annotate<'a: 'b, 'b>(&'a self, input: &'b String, type_name: &str) -> &'b Object { let type_name = type_name.to_string();
Untuk keperluan dynasm
kita juga perlu menambahkan bidang buffer
ke struktur TypeInfo
kita. Bidang ini mengontrol memori yang menyimpan kode fungsi-fungsi kami yang dihasilkan:
#[allow(unused)] buffer: dynasmrt::ExecutableBuffer,
Dan semua tes lulus!
Selesai, tuan!
Jadi dengan mudah dan alami Anda dapat menghasilkan implementasi Anda sendiri dari objek tipe dalam kode Rust!
Solusi terakhir secara aktif bergantung pada detail implementasi dan oleh karena itu tidak direkomendasikan untuk digunakan. Tetapi pada kenyataannya, Anda harus melakukan apa yang harus Anda lakukan. Masa putus asa membutuhkan tindakan putus asa!
Namun, ada satu (lebih) satu fitur yang saya andalkan di sini. Yaitu, aman untuk membebaskan memori yang ditempati secara virtual oleh tabel setelah tidak ada referensi ke objek tipe yang menggunakannya. Di satu sisi, logis bahwa Anda dapat menggunakan tabel virtual hanya melalui referensi objek tipe. Di sisi lain, tabel yang disediakan oleh Rust memiliki masa pakai 'static
. Sangat mungkin untuk mengasumsikan beberapa kode yang akan memisahkan tabel dari tautan untuk beberapa tujuannya (Anda tidak pernah tahu, misalnya, untuk beberapa trik kotornya ).
Kode sumber dapat ditemukan di sini .