Apa saja peringatan penerapan tipe dasar (seperti int) sebagai kelas?

27

Ketika merancang dan implenting bahasa pemrograman berorientasi objek, di beberapa titik kita harus membuat pilihan tentang pelaksanaan jenis dasar (seperti int, float, doubleatau 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?

Theodoros Chatzigiannakis
sumber

Jawaban:

19

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
Ketika berbicara tentang analisis pelarian, saya juga bermaksud mengalokasikan ke penyimpanan otomatis (itu tidak menyelesaikan segalanya, tetapi seperti yang Anda katakan, itu memecahkan beberapa hal). Saya juga mengakui bahwa saya telah meremehkan sejauh mana bidang dan aliasing dapat membuat analisis pelarian lebih sering gagal. Cache missses adalah hal yang paling saya khawatirkan ketika berbicara tentang efisiensi spasial, jadi terima kasih telah mengatasinya.
Theodoros Chatzigiannakis
@TheodorosChatzigiannakis Saya menyertakan perubahan strategi alokasi dalam analisis pelarian (karena jujur ​​itulah satu-satunya hal yang pernah digunakan untuk).
Re paragraf kedua Anda: Objek tidak selalu harus dialokasikan tumpukan atau menjadi tipe referensi. Bahkan, ketika tidak, ini membuat optimisasi yang diperlukan menjadi mudah. Lihat objek-objek yang dialokasikan stack ++ untuk contoh awal, dan sistem kepemilikan Rust untuk cara memanggang analisis pelarian langsung ke dalam bahasa.
amon
@amon Saya tahu, dan mungkin saya seharusnya membuatnya lebih jelas, tetapi tampaknya OP hanya tertarik pada bahasa seperti Java dan C # di mana alokasi heap hampir wajib (dan tersirat) karena semantik referensi dan gips lossless antara subtipe. Poin bagus tentang Rust menggunakan jumlah apa untuk menghindari analisis!
@delnan Memang benar saya sebagian besar tertarik pada bahasa yang mengaburkan detail penyimpanan, tapi jangan ragu untuk memasukkan apa pun yang menurut Anda relevan, bahkan jika itu tidak berlaku dalam bahasa tersebut.
Theodoros Chatzigiannakis
27

Apakah efisiensi spasial (dan peningkatan spasial lokalitas, terutama dalam array besar) satu-satunya alasan mengapa tipe fundamental sering bukan kelas?

Tidak.

Masalah lainnya adalah bahwa tipe fundamental cenderung digunakan oleh operasi fundamental. Compiler perlu tahu bahwa int + inttidak akan dikompilasi untuk panggilan fungsi, tetapi untuk beberapa instruksi CPU dasar (atau kode byte yang setara). Pada titik itu, jika Anda memiliki intobjek 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 Dmana Anda bisa a B. 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). Manfaat intmenjadi 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.

Telastyn
sumber
4
Lihatlah implementasi dari salah satu bahasa yang diketik secara dinamis yang memperlakukan bilangan bulat dan seperti objek. Instruksi akhir primitif CPU dapat dengan sangat baik disembunyikan dalam suatu metode (kelebihan operator) dalam implementasi kelas hanya-agak-istimewa di perpustakaan runtime. Detailnya akan terlihat berbeda dengan sistem tipe statis dan kompiler tetapi tidak ada masalah mendasar. Paling buruk itu hanya membuat segalanya lebih lambat.
3
int + intdapat menjadi operator tingkat bahasa reguler yang menjalankan instruksi intrinsik yang dijamin dapat dikompilasi ke (atau bertindak sebagai) op ​​operasi penambahan integer CPU asli. Manfaat intmewarisi dari objectbukan hanya kemungkinan mewarisi jenis lain dari int, tetapi juga kemungkinan intberperilaku sebagai objecttanpa 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 bisa objectmelalui tinju (tersirat, dihasilkan kompiler).
Theodoros Chatzigiannakis
3
@delnan - tentu saja, meskipun dalam pengalaman saya dengan implementasi yang diketik secara statis, karena setiap panggilan non-sistem bermuara pada operasi primitif, memiliki overhead di sana memiliki dampak dramatis pada kinerja - yang pada gilirannya memiliki efek yang lebih dramatis pada adopsi.
Telastyn
@TheodorosChatzigiannakis - hebat, sehingga Anda bisa mendapatkan varian dan contravariance pada tipe yang tidak memiliki sub / tipe super yang bermanfaat ... Dan menerapkan operator khusus untuk memanggil instruksi CPU masih membuatnya istimewa. Saya tidak setuju dengan ide itu - saya telah melakukan hal yang sangat mirip dalam bahasa mainan saya, tetapi saya telah menemukan bahwa ada gotcha praktis selama implementasi yang tidak membuat hal-hal sebersih yang Anda harapkan.
Telastyn
1
@TheodorosChatzigiannakis Memasuki batas-batas perpustakaan tentu saja mungkin, meskipun ini adalah item lain pada daftar belanjaan "optimasi kelas atas yang ingin saya miliki". Saya merasa berkewajiban untuk menunjukkan bahwa sangat sulit untuk mendapatkan yang benar tanpa menjadi terlalu konservatif sehingga menjadi tidak berguna.
4

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 intakan memiliki ukuran tetap tertentu atau bahwa tidak ada metode yang telah ditimpa, dan itu adalah masalah keamanan karena semantik ints 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 ints Anda Hashable, dan Anda ingin mendeklarasikan fungsi yang mengambil Hashableparameter yang mungkin menerima objek biasa tetapi juga ints.

    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 doublekefloatmungkin 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".

amon
sumber
Sebuah inthampir tidak berubah dalam semua bahasa.
Scott Whitlock
6
@ScottWhitlock Saya melihat mengapa Anda mungkin berpikir begitu, tetapi secara umum tipe primitif adalah tipe nilai yang tidak dapat diubah. Tidak ada bahasa waras yang memungkinkan Anda mengubah nilai angka tujuh. Namun, banyak bahasa memungkinkan Anda untuk menetapkan kembali variabel yang menyimpan nilai tipe primitif ke nilai yang berbeda. Dalam bahasa seperti-C, variabel adalah lokasi memori bernama, dan bertindak seperti pointer. Variabel tidak sama dengan nilai yang ditunjukkannya. Sebuah intnilai adalah kekal, tetapi intvariabel tidak.
amon
1
@amon: Tidak ada bahasa waras; just Java: thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler
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.
Michael
1
@ScottWhitlock pertanyaannya adalah apakah jika Anda kemudian memiliki int b = a, Anda dapat melakukan sesuatu untuk b yang akan mengubah nilai a. Ada beberapa implementasi bahasa di mana ini dimungkinkan, tetapi umumnya dianggap patologis dan tidak diinginkan, tidak seperti melakukan hal yang sama untuk sebuah array.
Acak 832
2

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:

  • Kekekalan
  • Finalitas (tidak dapat diturunkan dari)
  • Pengetikan statis

Situasi di mana seorang kompiler perlu mengotakkan primitif ke objek dalam representasi yang mendasarinya relatif jarang, seperti ketika Objectreferensi 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.

Karl Bielefeldt
sumber
1

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.

Leandro Caniglia
sumber
1

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.

Joshua
sumber