Halo, Habr! Saya mempersembahkan kepada Anda terjemahan artikel "Perils of Constructors" oleh Aleksey Kladov.
Salah satu posting blog Rust favorit saya adalah Things Rust Shipped Without oleh Graydon Hoare . Bagi saya, kurangnya fitur dalam bahasa yang dapat menembak di kaki biasanya lebih penting daripada ekspresif. Dalam esai yang sedikit filosofis ini, saya ingin berbicara tentang fitur favorit saya yang hilang dari Rust - tentang konstruktor.
Apa itu konstruktor?
Konstruktor biasanya digunakan dalam bahasa OO. Tugas konstruktor adalah menginisialisasi objek secara penuh sebelum seluruh dunia melihatnya. Sekilas, ini sepertinya ide yang sangat bagus:
- Anda mengatur invarian di konstruktor.
- Setiap metode menjaga konservasi invarian.
- Bersama-sama, kedua properti ini berarti bahwa Anda dapat menganggap objek sebagai invarian, dan bukan sebagai kondisi internal tertentu.
Konstruktor di sini memainkan peran sebagai basis induksi, menjadi satu-satunya cara untuk membuat objek baru.
Sayangnya, ada lubang dalam argumen ini: perancang sendiri mengamati objek dalam keadaan yang belum selesai, yang menciptakan banyak masalah.
Nilai ini
Ketika konstruktor menginisialisasi objek, itu dimulai dengan keadaan kosong. Tetapi bagaimana Anda mendefinisikan keadaan kosong ini untuk objek yang arbitrer?
Cara termudah untuk melakukan ini adalah dengan mengatur semua bidang ke nilai default: false untuk bool, 0 untuk angka, null untuk semua tautan. Namun pendekatan ini mengharuskan semua jenis memiliki nilai default, dan memperkenalkan null yang terkenal ke dalam bahasa. Ini adalah jalan yang diambil Java: pada awal penciptaan objek, semua bidang adalah 0 atau nol.
Dengan pendekatan ini, akan sangat sulit untuk menghilangkan nol setelahnya. Contoh yang baik untuk dipelajari adalah Kotlin. Kotlin menggunakan tipe yang tidak dapat dibatalkan secara default, tetapi terpaksa bekerja dengan semantik JVM yang sudah ada. Desain bahasanya menyembunyikan fakta ini dan bisa diterapkan dalam praktiknya, tetapi tidak bisa dipertahankan . Dengan kata lain, menggunakan konstruktor, dimungkinkan untuk melewati pemeriksaan nol di Kotlin.
Fitur utama Kotlin adalah dorongan untuk menciptakan apa yang disebut "konstruktor primer" yang secara bersamaan mendeklarasikan sebuah bidang dan memberikan nilai padanya sebelum kode kustom dijalankan:
class Person( val firstName: String, val lastName: String ) { ... }
Pilihan lain: jika bidang tidak dinyatakan dalam konstruktor, programmer harus segera menginisialisasi:
class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" }
Mencoba menggunakan bidang sebelum inisialisasi ditolak secara statis:
class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName)
Tetapi dengan sedikit kreativitas, siapa pun dapat menghindari cek ini. Misalnya, pemanggilan metode cocok untuk ini:
class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x)
Juga mengambil ini dengan lambda (yang dibuat di Kotlin sebagai berikut: {args -> body}) juga cocok:
class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x)
Contoh-contoh seperti ini tampak tidak realistis dalam kenyataan (dan memang demikian), tetapi saya menemukan kesalahan serupa dalam kode nyata (aturan probabilitas Kolmogorov 0-1 dalam pengembangan perangkat lunak: dalam basis data yang cukup besar, setiap bagian kode hampir dijamin ada, setidaknya jika tidak dilarang secara statis oleh kompiler; dalam hal ini, hampir pasti tidak ada).
Alasan Kotlin mungkin ada dengan kegagalan ini sama dengan array kovarian di Jawa: pemeriksaan masih terjadi di runtime. Pada akhirnya, saya tidak ingin menyulitkan sistem jenis Kotlin untuk membuat kasus-kasus di atas salah pada tahap kompilasi: mengingat keterbatasan yang ada (semantik JVM), rasio harga / manfaat validasi dalam runtime jauh lebih baik daripada yang statis.
Tetapi bagaimana jika bahasa tersebut tidak memiliki nilai default yang masuk akal untuk setiap jenis? Misalnya, dalam C ++, di mana tipe yang ditentukan pengguna tidak harus referensi, Anda tidak bisa hanya menetapkan null untuk setiap bidang dan mengatakan bahwa ini akan berhasil! Alih-alih, C ++ menggunakan sintaks khusus untuk menetapkan nilai awal untuk bidang: daftar inisialisasi:
#include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; };
Karena ini adalah sintaks khusus, seluruh bahasa tidak berfungsi dengan sempurna. Sebagai contoh, sulit untuk menempatkan operasi arbitrer ke dalam daftar inisialisasi, karena C ++ bukan bahasa yang berorientasi ekspresi (yang itu sendiri normal). Untuk bekerja dengan pengecualian yang terjadi dalam daftar inisialisasi, Anda perlu menggunakan fitur lain dari bahasa tersebut .
Metode Memanggil dari Konstruktor
Sebagai contoh dari Kotlin memberi petunjuk, semuanya hancur menjadi chip segera setelah kami mencoba memanggil metode dari konstruktor. Pada dasarnya, metode berharap bahwa objek yang dapat diakses melalui ini sudah sepenuhnya dibangun dan benar (konsisten dengan invarian). Tetapi di Kotlin atau Jawa, tidak ada yang mencegah Anda dari memanggil metode dari konstruktor, dan dengan cara ini kita dapat secara tidak sengaja beroperasi pada objek semi-konstruksi. Perancang berjanji untuk membuat invarian, tetapi pada saat yang sama ini adalah tempat termudah untuk kemungkinan pelanggaran mereka.
Terutama hal-hal aneh terjadi ketika konstruktor kelas dasar memanggil metode yang ditimpa dalam kelas turunan:
abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x)
Coba pikirkan: kode kelas arbitrer dieksekusi sebelum memanggil konstruktornya! Kode C ++ yang serupa akan menghasilkan hasil yang lebih menarik. Alih-alih memanggil fungsi kelas turunan, fungsi kelas dasar akan dipanggil. Ini tidak masuk akal karena kelas turunan belum diinisialisasi (ingat, kita tidak bisa hanya mengatakan bahwa semua bidang adalah nol). Namun, jika fungsi di kelas dasar adalah virtual murni, panggilannya akan mengarah ke UB.
Tanda Tangan Desainer
Pelanggaran invarian bukan satu-satunya masalah bagi desainer. Mereka memiliki tanda tangan dengan nama tetap (kosong) dan tipe kembali (kelas itu sendiri). Hal ini membuat kelebihan desain sulit untuk dipahami orang.
Mengisi pertanyaan: apa yang berhubungan dengan std :: vector <int> xs (92, 2)?
a. Vektor dua panjang 92
b. [92, 92]
c. [92, 2]
Masalah dengan nilai pengembalian muncul, sebagai suatu peraturan, ketika tidak mungkin untuk membuat objek. Anda tidak bisa hanya mengembalikan Hasil <MyClass, io :: Error> atau null dari konstruktor!
Ini sering digunakan sebagai argumen yang mendukung fakta bahwa menggunakan C ++ tanpa pengecualian adalah sulit, dan bahwa menggunakan konstruktor juga memaksa Anda untuk menggunakan pengecualian. Namun, saya tidak berpikir argumen ini benar: metode pabrik menyelesaikan kedua masalah ini, karena mereka dapat memiliki nama arbitrer dan mengembalikan tipe arbitrer. Saya percaya bahwa pola berikut kadang-kadang dapat berguna dalam bahasa OO:
Buat satu konstruktor pribadi yang mengambil nilai-nilai semua bidang sebagai argumen dan hanya menetapkannya. Dengan demikian, konstruktor seperti itu akan berfungsi sebagai struktur literal di Rust. Itu juga dapat memeriksa setiap invarian, tetapi seharusnya tidak melakukan hal lain dengan argumen atau bidang.
metode pabrik publik disediakan untuk API publik dengan nama dan tipe pengembalian yang sesuai.
Masalah serupa dengan konstruktor adalah konstruktornya spesifik dan karenanya tidak dapat digeneralisasi. Dalam C ++, "ada konstruktor default" atau "ada konstruktor salin" tidak dapat diekspresikan lebih dari " sintaks tertentu". Bandingkan ini dengan Rust, di mana konsep-konsep ini memiliki tanda tangan yang sesuai:
trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; }
Hidup tanpa desainer
Karat hanya memiliki satu cara untuk membuat struktur: untuk memberikan nilai untuk semua bidang. Fungsi pabrik, seperti baru yang diterima secara umum, memainkan peran konstruktor, tetapi, yang paling penting, mereka tidak memungkinkan Anda untuk memanggil metode apa pun sampai Anda memiliki setidaknya contoh bangunan yang kurang lebih benar.
Kerugian dari pendekatan ini adalah bahwa kode apa pun dapat membuat struktur, sehingga tidak ada satu tempat, seperti konstruktor, untuk mempertahankan invarian. Dalam praktiknya, ini mudah diselesaikan dengan privasi: jika bidang struktur bersifat pribadi, maka struktur ini hanya dapat dibuat dalam modul yang sama. Dalam satu modul, tidak sulit untuk mematuhi perjanjian "semua metode membuat struktur harus menggunakan metode baru". Anda bahkan dapat membayangkan ekstensi bahasa yang memungkinkan Anda menandai beberapa fungsi dengan atribut # [constructor], sehingga sintaks literal struktur hanya tersedia dalam fungsi yang ditandai. Tetapi, sekali lagi, mekanisme linguistik tambahan tampak berlebihan bagi saya: mengikuti konvensi lokal membutuhkan sedikit usaha.
Secara pribadi, saya percaya bahwa kompromi ini terlihat persis sama untuk pemrograman kontrak secara umum. Kontrak seperti "bukan nol" atau "nilai positif" paling baik dikodekan dalam jenis. Untuk invarian yang kompleks, cukup tulis assert! (Self.validate ()) di setiap metode tidak begitu sulit. Di antara kedua pola ini ada sedikit ruang untuk kondisi # [pra] dan # [pos] diimplementasikan pada tingkat bahasa atau berdasarkan makro.
Bagaimana dengan Swift?
Swift adalah bahasa lain yang menarik yang patut dilihat di mekanisme desain. Seperti Kotlin, Swift adalah bahasa aman nol. Tidak seperti Kotlin, pemeriksaan nol Swift lebih kuat, jadi bahasa tersebut menggunakan trik menarik untuk mengurangi kerusakan yang disebabkan oleh konstruktor.
Pertama , Swift menggunakan argumen bernama, dan sedikit membantu dengan "semua konstruktor memiliki nama yang sama." Secara khusus, dua konstruktor dengan jenis parameter yang sama tidak menjadi masalah:
Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15)
Kedua , untuk menyelesaikan masalah "konstruktor memanggil metode virtual kelas objek yang belum sepenuhnya dibuat" Swift menggunakan protokol inisialisasi dua fase yang dipikirkan dengan matang. Meskipun tidak ada sintaks khusus untuk daftar inisialisasi, kompiler secara statis memeriksa bahwa tubuh konstruktor memiliki bentuk yang benar dan aman. Sebagai contoh, metode memanggil hanya mungkin setelah semua bidang kelas dan turunannya diinisialisasi.
Ketiga , di tingkat bahasa, ada dukungan untuk konstruktor, yang panggilannya mungkin gagal. Konstruktor dapat ditetapkan sebagai nullable, yang menjadikan hasil memanggil kelas opsi. Konstruktor juga dapat memiliki pengubah lemparan, yang berfungsi lebih baik dengan semantik inisialisasi dua fase di Swift daripada dengan sintaksis daftar inisialisasi di C ++.
Swift berhasil menutup semua lubang di konstruktor yang saya keluhkan. Ini, bagaimanapun, datang pada harga: bab inisialisasi adalah salah satu yang terbesar dalam buku Swift.
Ketika konstruktor benar-benar dibutuhkan
Melawan segala rintangan, saya dapat memunculkan setidaknya dua alasan mengapa konstruktor tidak dapat diganti dengan struktur literal, seperti di Rust.
Pertama , pewarisan, sampai taraf tertentu, memaksa bahasa untuk memiliki konstruktor. Anda dapat membayangkan ekstensi sintaksis struktur dengan dukungan untuk kelas dasar:
struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } }
Tapi ini tidak akan bekerja dalam tata letak objek khas bahasa OO dengan warisan sederhana! Biasanya, objek dimulai dengan judul yang diikuti oleh bidang kelas, dari pangkalan ke yang paling diturunkan. Dengan demikian, awalan objek dari kelas turunan adalah objek yang benar dari kelas dasar. Namun, agar tata letak seperti itu berfungsi, perancang harus mengalokasikan memori untuk seluruh objek sekaligus. Itu tidak bisa hanya mengalokasikan memori hanya untuk kelas dasar, dan kemudian melampirkan bidang yang diturunkan. Tetapi alokasi memori seperti itu diperlukan jika kita ingin menggunakan sintaks untuk membuat struktur di mana kita bisa menentukan nilai untuk kelas dasar.
Kedua , tidak seperti sintaks literal struktur, konstruktor memiliki ABI yang berfungsi dengan baik dengan menempatkan objek subobjek dalam memori (ABI yang ramah penempatan). Konstruktor bekerja dengan pointer ke ini, yang menunjuk ke area memori yang harus ditempati objek baru. Yang paling penting, sebuah konstruktor dapat dengan mudah meneruskan pointer ke sub-objek konstruktor, sehingga memungkinkan penciptaan pohon nilai kompleks "pada tempatnya". Sebaliknya, di Rust, membangun struktur secara semantik mencakup beberapa salinan, dan di sini kami berharap untuk rahmat pengoptimal. Bukan kebetulan bahwa Rust belum memiliki proposal kerja yang diterima mengenai penempatan sub-proyek dalam memori!
Pembaruan 1: memperbaiki kesalahan ketik. Mengganti "tulis literal" dengan "struktur literal".