Ketika merancang dan implenting bahasa pemrograman berorientasi objek, di beberapa titik kita harus membuat pilihan tentang pelaksanaan jenis dasar (seperti int
, float
, double
atau setara) sebagai kelas atau sesuatu yang lain. Jelas, bahasa dalam keluarga C memiliki kecenderungan untuk tidak mendefinisikan mereka sebagai kelas (Java memiliki tipe primitif khusus, C # mengimplementasikannya sebagai struct yang tidak dapat diubah, dll).
Saya dapat memikirkan keuntungan yang sangat penting ketika tipe fundamental diimplementasikan sebagai kelas (dalam sistem tipe dengan hierarki yang disatukan): tipe ini dapat menjadi subtipe Liskov yang tepat dari tipe root. Dengan demikian, kami menghindari mempersulit bahasa dengan tinju / unboxing (baik eksplisit atau implisit), tipe wrapper, aturan varians khusus, perilaku khusus, dll.
Tentu saja, saya sebagian dapat memahami mengapa perancang bahasa memutuskan cara mereka melakukannya: instance kelas cenderung memiliki beberapa overhead spasial (karena instance dapat berisi vtable atau metadata lain dalam tata letak memori mereka), bahwa primitif / struct tidak perlu miliki (jika bahasa tidak mengizinkan pewarisan itu).
Apakah efisiensi spasial (dan peningkatan lokalitas spasial, terutama dalam array besar) satu-satunya alasan mengapa tipe fundamental sering bukan kelas?
Saya biasanya menganggap jawabannya adalah ya, tetapi kompiler memiliki algoritma analisis pelarian dan karenanya mereka dapat menyimpulkan apakah mereka dapat (secara selektif) menghilangkan overhead spasial ketika sebuah instance (contoh apa pun, bukan hanya tipe fundamental) terbukti benar-benar ketat. lokal.
Apakah hal di atas salah, atau ada hal lain yang saya lewatkan?
sumber
Jawaban:
Ya, itu cukup banyak bermuara pada efisiensi. Tetapi Anda tampaknya meremehkan dampaknya (atau melebih-lebihkan seberapa baik berbagai optimasi bekerja).
Pertama, ini bukan hanya "overhead spasial". Membuat primitif kotak / tumpukan dialokasikan memiliki biaya kinerja juga. Ada tekanan tambahan pada GC untuk mengalokasikan dan mengumpulkan benda-benda itu. Ini berjalan dua kali lipat jika "objek primitif" tidak berubah, sebagaimana mestinya. Kemudian ada lebih banyak kesalahan cache (baik karena tipuan dan karena lebih sedikit data yang cocok dengan jumlah cache yang diberikan). Ditambah fakta telanjang bahwa "memuat alamat suatu objek, lalu memuat nilai aktual dari alamat itu" mengambil lebih banyak instruksi daripada "memuat nilai secara langsung".
Kedua, analisis pelarian bukanlah debu peri yang lebih cepat. Ini hanya berlaku untuk nilai-nilai yang, well, jangan luput. Tentu bagus untuk mengoptimalkan perhitungan lokal (seperti penghitung putaran dan hasil perhitungan menengah) dan itu akan memberikan manfaat yang terukur. Tetapi sebagian besar nilai yang lebih besar hidup di bidang objek dan array. Memang, mereka dapat menjadi subjek untuk lepas analisis sendiri, tetapi karena mereka biasanya jenis referensi yang bisa berubah, setiap alias dari mereka menghadirkan tantangan yang signifikan untuk analisis melarikan diri, yang sekarang harus membuktikan bahwa alias-alias itu (1) tidak lepas dari , dan (2) tidak membuat perbedaan untuk tujuan menghilangkan alokasi.
Mengingat bahwa memanggil metode apapun (termasuk getter) atau lewat sebuah objek sebagai argumen untuk setiap metode lain dapat membantu obyek melarikan diri, Anda harus analisis interprosedural di semua tapi yang paling kasus sepele. Ini jauh lebih mahal dan rumit.
Dan kemudian ada kasus di mana hal-hal yang benar-benar melarikan diri dan tidak dapat dioptimalkan secara wajar. Cukup banyak dari mereka, sebenarnya, jika Anda mempertimbangkan seberapa sering programmer C mengalami kesulitan mengalokasikan hal-hal. Ketika sebuah objek yang berisi int melarikan diri, analisis pelarian berhenti berlaku untuk int juga. Ucapkan selamat tinggal pada bidang primitif yang efisien .
Hal ini terkait dengan poin lain: Analisis dan optimalisasi yang diperlukan sangat rumit dan merupakan bidang penelitian aktif. Masih bisa diperdebatkan apakah ada implementasi bahasa yang pernah mencapai tingkat optimasi yang Anda sarankan, dan bahkan jika demikian, itu sudah merupakan upaya yang jarang dan sangat kecil. Tentunya berdiri di atas pundak para raksasa ini lebih mudah daripada menjadi raksasa sendiri, tetapi itu masih jauh dari hal sepele. Jangan berharap kinerja kompetitif setiap saat dalam beberapa tahun pertama, jika pernah.
Itu tidak berarti bahasa seperti itu tidak bisa bertahan. Jelas mereka. Hanya saja, jangan menganggap itu akan line-for-line secepat bahasa dengan primitif khusus. Dengan kata lain, jangan menipu diri sendiri dengan visi kompiler yang cukup pintar .
sumber
Tidak.
Masalah lainnya adalah bahwa tipe fundamental cenderung digunakan oleh operasi fundamental. Compiler perlu tahu bahwa
int + int
tidak akan dikompilasi untuk panggilan fungsi, tetapi untuk beberapa instruksi CPU dasar (atau kode byte yang setara). Pada titik itu, jika Anda memilikiint
objek biasa, Anda harus membuka kotaknya secara efektif.Operasi semacam itu juga tidak benar-benar cocok dengan subtipe. Anda tidak dapat mengirim ke instruksi CPU. Anda tidak dapat mengirim dari instruksi CPU. Maksud saya seluruh titik subtyping adalah agar Anda dapat menggunakan di
D
mana Anda bisa aB
. Instruksi CPU bukan polimorfik. Untuk mendapatkan primitif untuk melakukan itu, Anda harus membungkus operasi mereka dengan logika pengiriman yang harganya beberapa kali lipat jumlah operasi sebagai tambahan sederhana (atau apa pun). Manfaatint
menjadi bagian dari hierarki tipe menjadi sedikit diperdebatkan saat disegel / final. Dan itu mengabaikan semua sakit kepala dengan logika pengiriman untuk operator biner ...Pada dasarnya, jenis primitif akan perlu memiliki banyak aturan khusus di sekitar bagaimana menangani compiler mereka, dan apa yang pengguna dapat lakukan dengan jenis mereka pula , sehingga sering kali mudah untuk hanya memperlakukan mereka sebagai benar-benar berbeda.
sumber
int + int
dapat menjadi operator tingkat bahasa reguler yang menjalankan instruksi intrinsik yang dijamin dapat dikompilasi ke (atau bertindak sebagai) op operasi penambahan integer CPU asli. Manfaatint
mewarisi dariobject
bukan hanya kemungkinan mewarisi jenis lain dariint
, tetapi juga kemungkinanint
berperilaku sebagaiobject
tanpa tinju. Pertimbangkan C # generics: Anda dapat memiliki kovarians dan contravariance, tetapi mereka hanya berlaku untuk tipe kelas - tipe struct secara otomatis dikecualikan, karena mereka hanya bisaobject
melalui tinju (tersirat, dihasilkan kompiler).Hanya ada sedikit kasus di mana Anda membutuhkan "tipe dasar" untuk menjadi objek penuh (di sini, objek adalah data yang berisi pointer ke mekanisme pengiriman atau ditandai dengan jenis yang dapat digunakan oleh mekanisme pengiriman):
Anda ingin tipe yang ditentukan pengguna dapat mewarisi dari tipe fundamental. Ini biasanya tidak diinginkan karena memperkenalkan sakit kepala terkait kinerja dan keamanan. Ini adalah masalah kinerja karena kompilasi tidak dapat mengasumsikan bahwa suatu
int
akan memiliki ukuran tetap tertentu atau bahwa tidak ada metode yang telah ditimpa, dan itu adalah masalah keamanan karena semantikint
s dapat ditumbangkan (pertimbangkan bilangan bulat yang sama dengan angka apa pun, atau yang mengubah nilainya daripada tidak berubah).Tipe primitif Anda memiliki supertipe dan Anda ingin memiliki variabel dengan tipe supertipe tipe primitif. Sebagai contoh, asumsikan
int
s AndaHashable
, dan Anda ingin mendeklarasikan fungsi yang mengambilHashable
parameter yang mungkin menerima objek biasa tetapi jugaint
s.Ini dapat "diselesaikan" dengan membuat tipe seperti itu ilegal: singkirkan subtyping dan putuskan bahwa interface bukan tipe tetapi tipe kendala. Jelas itu mengurangi ekspresif sistem tipe Anda, dan sistem tipe seperti itu tidak akan disebut berorientasi objek lagi. Lihat Haskell untuk bahasa yang menggunakan strategi ini. C ++ setengah jalan di sana karena tipe primitif tidak memiliki supertipe.
Alternatifnya adalah tinju penuh atau sebagian jenis dasar. Jenis tinju tidak harus terlihat oleh pengguna. Pada dasarnya, Anda menentukan jenis kotak internal untuk setiap jenis dasar dan konversi implisit antara jenis kotak dan mendasar. Ini bisa menjadi canggung jika jenis kotak memiliki semantik yang berbeda. Java menunjukkan dua masalah: tipe kotak memiliki konsep identitas sedangkan primitif hanya memiliki konsep kesetaraan nilai, dan tipe kotak dapat dibatalkan sedangkan primitif selalu valid. Masalah-masalah ini sepenuhnya dapat dihindari dengan tidak menawarkan konsep identitas untuk tipe nilai, menawarkan overloading operator, dan tidak membuat semua objek nullable secara default.
Anda tidak memiliki fitur pengetikan statis. Suatu variabel dapat memiliki nilai apa pun, termasuk tipe atau objek primitif. Oleh karena itu semua tipe primitif harus selalu kotak untuk menjamin pengetikan yang kuat.
Bahasa yang memiliki pengetikan statis sebaiknya menggunakan jenis primitif sedapat mungkin dan hanya kembali ke jenis kotak sebagai pilihan terakhir. Meskipun banyak program tidak terlalu sensitif terhadap kinerja, ada beberapa kasus di mana ukuran dan susunan tipe primitif sangat relevan: Pikirkan angka-angka dalam jumlah besar di mana Anda harus memasukkan miliaran titik data ke dalam memori. Beralih dari
double
kefloat
mungkin merupakan strategi optimisasi ruang yang layak dalam C, tetapi tidak akan berpengaruh jika semua tipe numerik selalu berbentuk kotak (dan karenanya menghabiskan setidaknya setengah dari memori mereka untuk penunjuk mekanisme pengiriman). Ketika jenis primitif kotak digunakan secara lokal, cukup mudah untuk menghapus tinju melalui penggunaan intrinsik kompiler, tetapi akan picik untuk bertaruh kinerja keseluruhan bahasa Anda pada "kompilator canggih yang memadai".sumber
int
hampir tidak berubah dalam semua bahasa.int
nilai adalah kekal, tetapiint
variabel tidak.get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer
tapi ini kedengarannya seperti pemrograman berbasis prototipe, yang pasti OOP.Sebagian besar implementasi yang saya sadari memberlakukan tiga batasan pada kelas-kelas seperti itu yang memungkinkan kompiler untuk secara efisien menggunakan tipe primitif sebagai representasi yang mendasari sebagian besar waktu. Batasan ini adalah:
Situasi di mana seorang kompiler perlu mengotakkan primitif ke objek dalam representasi yang mendasarinya relatif jarang, seperti ketika
Object
referensi menunjuk ke sana.Ini menambahkan sedikit penanganan kasus khusus dalam kompiler, tetapi tidak hanya terbatas pada beberapa kompiler super canggih yang mistis. Optimalisasi itu dalam kompiler produksi nyata dalam bahasa utama. Scala bahkan memungkinkan Anda untuk menentukan kelas nilai Anda sendiri.
sumber
Dalam Smalltalk semuanya (int, float, dll.) Adalah objek kelas satu. Satu- satunya kasus khusus adalah bahwa SmallIntegers dikodifikasi dan diperlakukan secara berbeda oleh Virtual Machine demi efisiensi, dan karenanya kelas SmallInteger tidak akan menerima subkelas (yang bukan batasan praktis.) Perhatikan bahwa ini tidak memerlukan pertimbangan khusus pada bagian programmer sebagai perbedaan dibatasi untuk rutinitas otomatis seperti pembuatan kode atau pengumpulan sampah.
Baik Smalltalk Compiler (kode sumber -> VM bytecodes) dan VM nativizer (bytecodes -> kode mesin) mengoptimalkan kode yang dihasilkan (JIT) sehingga dapat mengurangi penalti operasi dasar dengan objek dasar ini.
sumber
Saya sedang mendesain langauge dan runtime OO (ini gagal karena serangkaian alasan yang sama sekali berbeda).
Tidak ada yang salah dengan membuat hal-hal seperti kelas benar int; sebenarnya ini membuat GC lebih mudah untuk dirancang karena sekarang hanya ada 2 jenis heap header (class & array) daripada 3 (class, array, dan primitive) [fakta bahwa kita dapat menggabungkan class & array setelah ini tidak relevan ]
Kasus yang sangat penting, tipe primitif harusnya kebanyakan metode final / tertutup (+ sangat penting, ToString tidak terlalu banyak). Ini memungkinkan kompiler untuk secara statis menyelesaikan hampir semua panggilan ke fungsi itu sendiri dan sebaris mereka. Dalam kebanyakan kasus ini tidak masalah sebagai perilaku penyalinan (saya memilih untuk membuat penyematan tersedia di tingkat bahasa [begitu juga .NET]), tetapi dalam beberapa kasus jika metode tidak disegel kompiler akan dipaksa untuk menghasilkan panggilan ke fungsi yang digunakan untuk mengimplementasikan int + int.
sumber