Bagaimana variabel di C ++ menyimpan tipenya?

42

Jika saya mendefinisikan variabel dari jenis tertentu (yang, sejauh yang saya tahu, hanya mengalokasikan data untuk konten variabel), bagaimana cara melacak jenis variabel itu?

Finn McClusky
sumber
8
Siapa / apa yang Anda maksudkan dengan " itu " di " bagaimana cara melacak "? Kompiler atau CPU atau sesuatu / orang lain seperti bahasa atau program?
Erik Eidt
8
@ErikEidt IMO OP jelas berarti "variabel itu sendiri" oleh "itu." Tentu saja jawaban dua kata untuk pertanyaan itu adalah "tidak".
alephzero
2
pertanyaan bagus! khususnya relevan hari ini mengingat semua bahasa mewah yang memang menyimpan tipenya.
Trevor Boyd Smith
@ alephzero Itu jelas pertanyaan utama.
Luaan

Jawaban:

105

Variabel (atau lebih umum: "objek" dalam arti C) tidak menyimpan tipenya pada saat runtime. Sejauh menyangkut kode mesin, hanya ada memori yang tidak diketik. Sebagai gantinya, operasi pada data ini menginterpretasikan data sebagai tipe tertentu (misalnya sebagai float atau sebagai pointer). Jenis-jenis ini hanya digunakan oleh kompiler.

Sebagai contoh, kita mungkin memiliki struct atau kelas struct Foo { int x; float y; };dan variabel Foo f {}. Bagaimana cara auto result = f.y;kompilasi akses lapangan ? Compiler tahu itu fadalah objek bertipe Foodan tahu tata letak Foo-objects. Bergantung pada detail platform-spesifik, ini mungkin dikompilasi sebagai "Ambil pointer ke awal f, tambahkan 4 byte, lalu muat 4 byte dan interpretasikan data ini sebagai float." Dalam banyak set instruksi kode mesin (termasuk x86-64 ) ada instruksi prosesor yang berbeda untuk memuat float atau int.

Salah satu contoh di mana sistem tipe C ++ tidak dapat melacak tipe untuk kita adalah seperti gabungan union Bar { int as_int; float as_float; }. Serikat pekerja berisi hingga satu objek dari berbagai jenis. Jika kita menyimpan sebuah objek dalam sebuah union, ini adalah tipe aktif dari union. Kita hanya harus mencoba untuk mendapatkan tipe itu kembali dari serikat pekerja, hal lain apa pun adalah perilaku yang tidak terdefinisi. Entah kita "tahu" saat memprogram apa jenis aktifnya, atau kita dapat membuat gabungan yang ditandai di mana kita menyimpan tag jenis (biasanya enum) secara terpisah. Ini adalah teknik umum dalam C, tetapi karena kita harus menjaga penyatuan dan tag jenis dalam sinkronisasi, ini cukup rentan kesalahan. Sebuah void*pointer mirip dengan serikat tapi hanya bisa memegang benda pointer, kecuali fungsi pointer.
C ++ menawarkan dua mekanisme yang lebih baik untuk menangani objek dari tipe yang tidak dikenal: Kita dapat menggunakan teknik berorientasi objek untuk melakukan penghapusan tipe (hanya berinteraksi dengan objek melalui metode virtual sehingga kita tidak perlu tahu tipe sebenarnya), atau kita bisa gunakan std::variant, semacam serikat tipe-aman.

Ada satu kasus di mana C ++ tidak menyimpan jenis objek: jika kelas objek memiliki metode virtual ("tipe polimorfik", alias antarmuka.). Target panggilan metode virtual tidak diketahui pada waktu kompilasi dan diselesaikan pada saat dijalankan berdasarkan pada jenis objek yang dinamis (“pengiriman dinamis”). Kebanyakan kompiler mengimplementasikan ini dengan menyimpan tabel fungsi virtual ("vtable") di awal objek. Vtable juga dapat digunakan untuk mendapatkan jenis objek saat runtime. Kita kemudian dapat menggambar perbedaan antara tipe statis ekspresi waktu kompilasi yang diketahui, dan tipe dinamis suatu objek saat runtime.

C ++ memungkinkan kita untuk memeriksa tipe dinamis suatu objek dengan typeid()operator yang memberi kita std::type_infoobjek. Entah kompiler mengetahui jenis objek pada waktu kompilasi, atau kompiler telah menyimpan informasi jenis yang diperlukan di dalam objek dan dapat mengambilnya saat runtime.

amon
sumber
3
Sangat komprehensif.
Deduplicator
9
Perhatikan bahwa untuk mengakses jenis objek polimorfik, kompiler masih harus tahu bahwa objek tersebut milik keluarga warisan tertentu (yaitu memiliki referensi / pointer yang diketik ke objek, bukan void*).
Ruslan
5
+0 karena kalimat pertama tidak benar, dua paragraf terakhir memperbaikinya.
Marcin
3
Umumnya apa yang disimpan pada awal objek polimorfik adalah pointer ke tabel metode virtual, bukan tabel itu sendiri.
Peter Green
3
@ v.oddou Dalam paragraf saya, saya mengabaikan beberapa detail. typeid(e)mengintrospeksi jenis ekspresi statis e. Jika tipe statis adalah tipe polimorfik, ekspresi akan dievaluasi dan tipe dinamis objek tersebut diambil. Anda tidak dapat menunjuk tipid pada memori tipe yang tidak dikenal dan mendapatkan informasi yang berguna. Misalnya typeid dari serikat menggambarkan serikat, bukan objek di serikat. Typeid dari void*hanya pointer kosong. Dan tidak mungkin untuk melakukan dereferensi void*untuk mendapatkan isinya. Di C ++ tidak ada tinju kecuali diprogram secara eksplisit seperti itu.
amon
51

Jawaban lain menjelaskan dengan baik aspek teknis, tetapi saya ingin menambahkan beberapa umum "bagaimana memikirkan kode mesin".

Kode mesin setelah kompilasi cukup bodoh, dan itu benar-benar hanya mengasumsikan bahwa semuanya berfungsi sebagaimana mestinya. Katakanlah Anda memiliki fungsi sederhana seperti

bool isEven(int i) { return i % 2 == 0; }

Dibutuhkan int, dan mengeluarkan bool.

Setelah Anda mengompilasinya, Anda dapat menganggapnya sebagai sesuatu seperti juicer jeruk otomatis ini:

juicer jeruk otomatis

Dibutuhkan jeruk, dan mengembalikan jus. Apakah itu mengenali jenis objek yang masuk? Tidak, mereka hanya dianggap jeruk. Apa yang terjadi jika apel mendapat jeruk, bukan jeruk? Mungkin itu akan pecah. Tidak masalah, karena pemilik yang bertanggung jawab tidak akan mencoba menggunakannya dengan cara ini.

Fungsi di atas serupa: ia dirancang untuk mengambil int, dan itu dapat merusak atau melakukan sesuatu yang tidak relevan ketika diberi makan sesuatu yang lain. Itu (biasanya) tidak masalah, karena kompiler (umumnya) memeriksa bahwa itu tidak pernah terjadi - dan memang tidak pernah terjadi dalam kode yang terbentuk dengan baik. Jika kompiler mendeteksi kemungkinan bahwa suatu fungsi akan mendapatkan nilai yang diketik salah, ia menolak untuk mengkompilasi kode dan mengembalikan kesalahan ketik sebagai gantinya.

Peringatannya adalah bahwa ada beberapa kasus kode yang salah bentuk yang akan dilewati oleh kompiler. Contohnya adalah:

  • salah jenis-casting: gips eksplisit diasumsikan benar, dan itu adalah pada programmer untuk memastikan bahwa ia tidak pengecoran void*untuk orange*ketika ada sebuah apel di ujung lain dari pointer,
  • masalah manajemen memori seperti pointer nol, pointer menggantung atau penggunaan-setelah-lingkup; kompiler tidak dapat menemukan sebagian besar dari mereka,
  • Saya yakin ada hal lain yang saya lewatkan.

Seperti yang dikatakan, kode yang dikompilasi sama seperti mesin juicer - tidak tahu apa yang diprosesnya, ia hanya menjalankan instruksi. Dan jika instruksinya salah, itu rusak. Itu sebabnya masalah di atas dalam C + + mengakibatkan crash yang tidak terkendali.

Frax
sumber
4
Kompilator mencoba untuk memeriksa bahwa fungsi dilewatkan objek dari tipe yang benar, tetapi baik C dan C ++ terlalu kompleks untuk kompiler untuk membuktikannya dalam setiap kasus. Jadi, perbandingan apel dan jeruk Anda dengan juicer cukup instruktif.
Calchas
@Calchas Terima kasih atas komentar Anda! Kalimat ini memang terlalu disederhanakan. Saya sedikit menguraikan kemungkinan masalah, mereka sebenarnya cukup terkait dengan pertanyaan.
Frax
5
wow metafora yang bagus untuk kode mesin! metafora Anda dibuat 10x lebih baik dengan gambar juga!
Trevor Boyd Smith
2
"Aku yakin ada hal lain yang hilang." - Tentu saja! C void*memaksa foo*, promosi aritmatika yang biasa, uniontipe hukuman, NULLvs nullptr, bahkan hanya memiliki pointer buruk adalah UB, dll. Tapi saya tidak berpikir daftar semua hal itu secara materi akan meningkatkan jawaban Anda, jadi mungkin lebih baik untuk meninggalkan apa adanya.
Kevin
@Kevin Saya rasa tidak perlu menambahkan C di sini, karena pertanyaannya hanya ditandai sebagai C ++. Dan di C ++ void*tidak secara implisit dikonversi ke foo*, dan unionketik punning tidak didukung (memiliki UB).
Ruslan
3

Variabel memiliki sejumlah properti mendasar dalam bahasa seperti C:

  1. Sebuah nama
  2. Sebuah tipe
  3. Ruang lingkup
  4. Seumur hidup
  5. Sebuah lokasi
  6. Nilai

Dalam kode sumber Anda , lokasi, (5), bersifat konseptual, dan lokasi ini disebut dengan namanya, (1). Jadi, deklarasi variabel digunakan untuk membuat lokasi dan ruang untuk nilai, (6), dan di baris sumber lainnya, kami merujuk ke lokasi itu dan nilai yang dimilikinya dengan memberi nama variabel dalam beberapa ekspresi.

Menyederhanakan hanya sedikit, setelah program Anda diterjemahkan ke dalam kode mesin oleh kompiler, lokasi, (5), adalah beberapa lokasi memori atau register CPU, dan ekspresi kode sumber apa pun yang merujuk variabel diterjemahkan ke dalam urutan kode mesin yang merujuk memori itu atau lokasi register CPU.

Jadi, ketika terjemahan selesai dan program berjalan pada prosesor, nama-nama variabel secara efektif dilupakan dalam kode mesin, dan, instruksi yang dihasilkan oleh kompiler hanya merujuk ke lokasi variabel yang ditugaskan (daripada ke mereka nama). Jika Anda men-debug dan meminta debugging, lokasi variabel yang terkait dengan nama, ditambahkan ke metadata untuk program, meskipun prosesor masih melihat instruksi kode mesin menggunakan lokasi (bukan metadata itu). (Ini adalah penyederhanaan berlebihan karena beberapa nama ada dalam metadata program untuk keperluan menghubungkan, memuat, dan mencari dinamis - masih prosesor hanya menjalankan instruksi kode mesin yang diperintahkan untuk program, dan dalam kode mesin ini nama-nama tersebut memiliki telah dikonversi ke lokasi.)

Hal yang sama juga berlaku untuk tipe, cakupan, dan masa pakai. Instruksi kode mesin yang dihasilkan kompiler mengetahui versi mesin lokasi, yang menyimpan nilai. Properti lainnya, seperti tipe, dikompilasi ke dalam kode sumber yang diterjemahkan sebagai instruksi spesifik yang mengakses lokasi variabel. Misalnya, jika variabel yang dimaksud adalah byte 8-bit yang ditandatangani vs. byte 8-bit yang tidak ditandatangani, maka ekspresi dalam kode sumber yang mereferensikan variabel tersebut akan diterjemahkan ke dalam, katakanlah, beban byte yang ditandatangani vs. beban byte yang tidak ditandatangani, sesuai kebutuhan untuk memenuhi aturan bahasa (C). Jenis variabel dengan demikian dikodekan ke dalam terjemahan kode sumber ke dalam instruksi mesin, yang memerintahkan CPU bagaimana menafsirkan memori atau lokasi register CPU masing-masing dan setiap kali menggunakan lokasi variabel.

Intinya adalah bahwa kita harus memberi tahu CPU apa yang harus dilakukan melalui instruksi (dan lebih banyak instruksi) dalam set instruksi kode mesin prosesor. Prosesor mengingat sangat sedikit tentang apa yang baru saja dilakukan atau diberi tahu - prosesor hanya menjalankan instruksi yang diberikan, dan itu adalah tugas programmer kompiler atau bahasa assembly untuk memberikan rangkaian urutan instruksi lengkap untuk memanipulasi variabel dengan benar.

Prosesor secara langsung mendukung beberapa tipe data mendasar, seperti byte / word / int / lama ditandatangani / tidak ditandatangani, float, dobel, dll. Prosesor umumnya tidak akan mengeluh atau keberatan jika Anda secara bergantian memperlakukan lokasi memori yang sama seperti ditandatangani atau tidak ditandatangani, untuk contoh, meskipun itu biasanya kesalahan logika dalam program. Ini adalah tugas pemrograman untuk menginstruksikan prosesor pada setiap interaksi dengan variabel.

Di luar tipe-tipe primitif fundamental, kita harus menyandikan hal-hal dalam struktur data dan menggunakan algoritma untuk memanipulasinya dalam hal primitif tersebut.

Dalam C ++, objek yang terlibat dalam hierarki kelas untuk polimorfisme memiliki pointer, biasanya di awal objek, yang merujuk pada struktur data kelas-spesifik, yang membantu pengiriman virtual, casting, dll.

Singkatnya, prosesor tidak mengetahui atau tidak mengingat tujuan penggunaan lokasi penyimpanan - prosesor menjalankan instruksi kode mesin dari program yang memberitahukan cara memanipulasi penyimpanan dalam register CPU dan memori utama. Pemrograman, kemudian, adalah tugas perangkat lunak (dan pemrogram) untuk menggunakan penyimpanan secara bermakna, dan untuk menyajikan serangkaian instruksi kode mesin yang konsisten kepada prosesor yang dengan setia menjalankan program secara keseluruhan.

Erik Eidt
sumber
1
Hati-hati dengan "ketika terjemahan selesai, namanya dilupakan" ... menghubungkan dilakukan melalui nama ("simbol xy tidak didefinisikan") dan mungkin terjadi pada saat dijalankan dengan tautan dinamis. Lihat blog.fesnel.com/blog/2009/08/19/… . Tidak ada simbol debug, bahkan dilucuti: Anda memerlukan nama fungsi (dan, saya asumsikan, variabel global) untuk penautan dinamis. Jadi hanya nama-nama objek internal yang bisa dilupakan. By the way, daftar properti variabel yang baik.
Peter - Reinstate Monica
@ PeterA.Schneider, Anda benar sekali, dalam gambaran besar berbagai hal, bahwa tautan dan pemuat juga berpartisipasi dan menggunakan nama fungsi dan variabel (global) yang berasal dari kode sumber.
Erik Eidt
Komplikasi tambahan adalah bahwa beberapa kompiler menafsirkan aturan yang, menurut Standar, dimaksudkan untuk membuat kompiler menganggap hal-hal tertentu tidak akan alias memungkinkan mereka menganggap operasi yang melibatkan berbagai jenis sebagai tidak dilakukan, bahkan dalam kasus yang tidak melibatkan aliasing seperti yang tertulis . Diberikan sesuatu seperti useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, dentang dan gcc cenderung mengasumsikan bahwa pointer ke unionArray[j].member2tidak dapat mengakses unionArray[i].member1meskipun keduanya berasal dari yang sama unionArray[].
supercat
Apakah kompiler mengartikan spesifikasi bahasa dengan benar atau tidak, tugasnya adalah untuk menghasilkan urutan instruksi kode mesin yang menjalankan program. Ini berarti bahwa (optimasi modulo dan banyak faktor lainnya) untuk setiap akses variabel dalam kode sumber harus menghasilkan beberapa instruksi kode mesin yang memberi tahu prosesor ukuran dan interpretasi data apa yang akan digunakan untuk lokasi penyimpanan. Prosesor tidak mengingat apa pun tentang variabel sehingga setiap kali seharusnya mengakses variabel, ia harus diinstruksikan dengan tepat bagaimana melakukannya.
Erik Eidt
2

jika saya mendefinisikan variabel dari tipe tertentu bagaimana cara melacak tipe variabel itu.

Ada dua fase yang relevan di sini:

  • Waktu kompilasi

Kompiler C mengkompilasi kode C ke bahasa mesin. Kompiler memiliki semua informasi yang dapat diperoleh dari file sumber Anda (dan perpustakaan, dan hal-hal lain apa pun yang diperlukan untuk melakukan tugasnya). Kompiler C melacak apa artinya apa. Kompiler C tahu bahwa jika Anda mendeklarasikan variabel menjadi char, itu adalah char.

Itu melakukan ini dengan menggunakan apa yang disebut "tabel simbol" yang berisi daftar nama-nama variabel, jenisnya, dan informasi lainnya. Ini adalah struktur data yang agak rumit, tetapi Anda bisa menganggapnya sebagai sekadar melacak apa arti nama yang dapat dibaca manusia. Dalam output biner dari kompiler, tidak ada nama variabel seperti ini yang muncul lagi (jika kita mengabaikan informasi debug opsional yang mungkin diminta oleh programmer).

  • Runtime

Output dari compiler - executable yang dikompilasi - adalah bahasa mesin, yang dimuat ke dalam RAM oleh OS Anda, dan dieksekusi langsung oleh CPU Anda. Dalam bahasa mesin, tidak ada gagasan "ketik" sama sekali - itu hanya memiliki perintah yang beroperasi pada beberapa lokasi dalam RAM. The perintah memang memiliki jenis tetap mereka beroperasi dengan (yaitu, mungkin ada perintah bahasa mesin "menambahkan dua bilangan bulat 16-bit ini disimpan pada lokasi RAM 0x100 dan 0x521"), tetapi tidak ada informasi di mana saja dalam sistem yang byte di lokasi tersebut sebenarnya mewakili bilangan bulat. Tidak ada perlindungan dari kesalahan ketik sama sekali di sini.

AnoE
sumber
Jika kebetulan Anda merujuk ke C # atau Java dengan "bahasa berorientasi kode byte" maka pointer tidak berarti dihilangkan dari mereka; Sebaliknya: Pointer jauh lebih umum di C # dan Java (dan akibatnya, salah satu kesalahan paling umum di Jawa adalah "NullPointerException"). Bahwa mereka disebut "referensi" hanyalah masalah terminologi.
Peter - Reinstate Monica
@ PeterA.Schneider, tentu saja, ada NullPOINTERException, tetapi ada perbedaan yang sangat jelas antara referensi dan pointer dalam bahasa yang saya sebutkan (seperti Java, ruby, mungkin C #, bahkan Perl sampai batas tertentu) - referensi berjalan bersamaan dengan sistem tipenya, pengumpulan sampah, manajemen memori otomatis dll.; biasanya bahkan tidak mungkin untuk secara eksplisit menyatakan lokasi memori (seperti char *ptr = 0x123dalam C). Saya percaya penggunaan kata "penunjuk" harus cukup jelas dalam konteks ini. Jika tidak, silakan beri saya informasi lebih lanjut dan saya akan menambahkan kalimat pada jawabannya.
AnoE
pointer "pergi bersama dengan sistem tipe" di C ++ juga ;-). (Sebenarnya, generik klasik Java kurang kuat diketik daripada C ++.) Pengumpulan sampah adalah fitur yang C ++ memutuskan untuk tidak mengamanatkan, tapi itu mungkin bagi implementasi untuk menyediakannya, dan itu tidak ada hubungannya dengan kata apa yang kita gunakan untuk pointer.
Peter - Reinstate Monica
OK, @ PeterA. Pemula, saya tidak berpikir kita mendapatkan level di sini. Saya telah menghapus paragraf di mana saya menyebutkan pointer, itu tidak melakukan apa pun untuk jawabannya.
AnoE
1

Ada beberapa kasus khusus yang penting di mana C ++ tidak menyimpan tipe saat runtime.

Solusi klasik adalah serikat terdiskriminasi: struktur data yang berisi salah satu dari beberapa jenis objek, ditambah bidang yang mengatakan jenis apa yang dikandungnya saat ini. Versi templated ada di pustaka standar C ++ sebagai std::variant. Biasanya, tag akan menjadi enum, tetapi jika Anda tidak memerlukan semua bit penyimpanan untuk data Anda, itu mungkin bitfield.

Kasus umum lainnya adalah pengetikan dinamis. Ketika Anda classmemiliki virtualfungsi, program akan menyimpan pointer ke fungsi itu dalam tabel fungsi virtual , yang akan diinisialisasi untuk setiap instance classketika dibangun. Biasanya, itu berarti satu tabel fungsi virtual untuk semua instance kelas, dan setiap instance memegang pointer ke tabel yang sesuai. (Ini menghemat waktu dan memori karena tabel akan jauh lebih besar dari satu penunjuk tunggal.) Saat Anda memanggil virtualfungsi itu melalui penunjuk atau referensi, program akan mencari penunjuk fungsi di tabel virtual. (Jika ia tahu tipe persisnya pada waktu kompilasi, ia dapat melewati langkah ini.) Ini memungkinkan kode untuk memanggil implementasi tipe turunan alih-alih kelas dasar.

Hal yang membuat ini relevan di sini adalah: masing ofstream- masing berisi pointer ke ofstreamtabel virtual, masing ifstream- masing ke ifstreamtabel virtual, dan sebagainya. Untuk hierarki kelas, penunjuk tabel virtual dapat berfungsi sebagai tag yang memberi tahu program apa yang dimiliki objek kelas!

Meskipun standar bahasa tidak memberi tahu orang-orang yang merancang kompiler bagaimana mereka harus mengimplementasikan runtime di bawah tenda, ini adalah bagaimana Anda dapat mengharapkan dynamic_castdan typeofbekerja.

Davislor
sumber
"standar bahasa tidak memberi tahu pembuat kode" Anda mungkin harus menekankan bahwa "pembuat kode" yang dimaksud adalah orang yang menulis gcc, dentang, msvc, dll., bukan orang yang menggunakan mereka untuk mengkompilasi C ++ mereka.
Caleth
@Caleth Saran bagus!
Davislor