Tabel hash di Go. Detail implementasi

gambar


Kami akan membahas implementasi peta dalam bahasa tanpa obat generik, mempertimbangkan apa tabel hash, bagaimana ini diatur dalam Go, apa pro dan kontra dari implementasi ini, dan apa yang harus Anda perhatikan ketika menggunakan struktur ini.

Detail di bawah potongan.



Perhatian! Jika Anda sudah terbiasa dengan tabel hash di Go, saya menyarankan Anda untuk melewatkan dasar-dasar dan pergi di sini , jika tidak ada risiko bosan saat yang paling menarik.

Apa itu tabel hash


Untuk memulainya, saya akan mengingatkan Anda apa itu tabel hash. Ini adalah struktur data yang memungkinkan Anda untuk menyimpan pasangan nilai kunci, dan, sebagai aturannya, memiliki fungsi:

  • Pemetaan: map(key) → value
  • Sisipan: insert(map, key, value)
  • Penghapusan: delete(map, key)
  • Cari: lookup(key) → value

Tabel hash dalam bahasa go


Tabel hash dalam bahasa go diwakili oleh kata kunci peta dan dapat dideklarasikan dengan salah satu cara di bawah ini (selengkapnya tentang mereka nanti):

  m := make(map[key_type]value_type) m := new(map[key_type]value_type) var m map[key_type]value_type m := map[key_type]value_type{key1: val1, key2: val2} 

Operasi utama dilakukan sebagai berikut:

  • Masukkan:

     m[key] = value 

  • Penghapusan:

     delete(m, key) 

  • Cari:

     value = m[key] 

    atau

     value, ok = m[key] 

Pergi berkeliling meja di mana saja


Pertimbangkan program berikut:

 package main import "fmt" func main() { m := map[int]bool{} for i := 0; i < 5; i++ { m[i] = ((i % 2) == 0) } for k, v := range m { fmt.Printf("key: %d, value: %t\n", k, v) } } 

Peluncuran 1:

 key: 3, value: false key: 4, value: true key: 0, value: true key: 1, value: false key: 2, value: true 

Jalankan 2:

 key: 4, value: true key: 0, value: true key: 1, value: false key: 2, value: true key: 3, value: false 

Seperti yang Anda lihat, output bervariasi dari satu menjalankan ke menjalankan. Dan semua karena peta di Go tidak berurutan, artinya, tidak dipesan. Ini berarti Anda tidak perlu bergantung pada pesanan saat berkeliling. Alasannya dapat ditemukan dalam kode sumber runtime bahasa:

 // mapiterinit initializes the hiter struct used for ranging over maps. func mapiterinit(t *maptype, h *hmap, it *hiter) {... // decide where to start r := uintptr(fastrand()) ... it.startBucket = r & bucketMask(hB)...} 

Lokasi pencarian ditentukan secara acak , ingat ini! Rumor mengatakan bahwa pengembang runtime memaksa pengguna untuk tidak bergantung pada pesanan.

Pergi mencari tabel


Mari kita lihat sepotong kode lagi. Misalkan kita ingin membuat pasangan "angka" - "angka kali 10":

 package main import ( "fmt" ) func main() { m := map[int]int{0: 0, 1: 10} fmt.Println(m, m[0], m[1], m[2]) } 

Kami meluncurkan:

 map[0:0 1:10] 0 10 0 

Dan kami melihat bahwa ketika kami mencoba untuk mendapatkan nilai dua (yang kami lupa letakkan) kami mendapat 0. Kami menemukan baris dalam dokumentasi yang menjelaskan perilaku ini: “Upaya untuk mengambil nilai peta dengan kunci yang tidak ada di peta akan mengembalikan nilai nol untuk jenis entri di peta. ", tetapi diterjemahkan ke dalam bahasa Rusia, ini berarti bahwa ketika kami mencoba untuk mendapatkan nilai dari peta, tetapi tidak ada di sana, kami mendapatkan" nilai tipe nol ", yang dalam hal angka 0. Apa yang harus dilakukan, jika kita ingin membedakan antara kasus 0 dan tidak adanya 2? Untuk melakukan ini, kami datang dengan bentuk khusus "penugasan berganda" - suatu bentuk di mana alih-alih nilai tunggal biasa, peta mengembalikan pasangan: nilai itu sendiri dan Boolean lain yang menjawab pertanyaan apakah kunci yang diminta ada di peta atau tidak "

Potongan kode yang sebelumnya akan terlihat seperti ini:

 package main import ( "fmt" ) func main() { m := map[int]int{0: 0, 1: 10} m2, ok := m[2] if !ok { // somehow process this case m2 = 20 } fmt.Println(m, m[0], m[1], m2) } 

Dan saat startup, kami mendapatkan:

map[0:0 1:10] 0 10 20

Buat tabel di Go.


Misalkan kita ingin menghitung jumlah kemunculan setiap kata dalam sebuah string, mulai kamus untuk ini dan lanjutkan.

 package main func main() { var m map[string]int for _, word := range []string{"hello", "world", "from", "the", "best", "language", "in", "the", "world"} { m[word]++ } for k, v := range m { println(k, v) } } 

Apakah Anda melihat tangkapan gopher ? - Dan dia!

gambar

Ketika kami mencoba memulai program semacam itu, kami mendapatkan kepanikan dan pesan "tugas untuk masuk di peta nil". Dan semua karena mapa adalah tipe referensi dan tidak cukup untuk mendeklarasikan variabel, Anda perlu menginisialisasi:

 m := make(map[string]int) 

Sedikit lebih rendah akan jelas mengapa ini bekerja seperti ini. Pada awalnya, sudah disajikan 4 cara untuk membuat peta, dua di antaranya kami periksa - deklarasi ini sebagai variabel dan pembuatan melalui make. Anda juga dapat membuat menggunakan desain " Komposit literal "

  map[key_type]value_type{} 

dan jika Anda ingin, bahkan segera menginisialisasi dengan nilai-nilai, metode ini juga valid. Adapun pembuatan menggunakan baru - menurut saya, itu tidak masuk akal, karena fungsi ini mengalokasikan memori untuk variabel dan mengembalikan pointer ke itu diisi dengan nilai nol dari jenisnya, yang dalam kasus peta akan nihil (kami mendapatkan hasil yang sama seperti di var, lebih tepatnya sebuah pointer ke sana).

Bagaimana peta diteruskan ke suatu fungsi?


Misalkan kita memiliki fungsi yang mencoba mengubah angka yang diteruskan ke sana. Mari kita lihat apa yang terjadi sebelum dan sesudah panggilan:

 package main func foo(n int) { n = 10 } func main() { n := 15 println("n before foo =", n) foo(n) println("n after foo =", n) } 

Sebuah contoh, saya pikir, cukup jelas, tetapi masih termasuk kesimpulan:

 n before foo = 15 n after foo = 15 

Seperti yang mungkin Anda tebak, fungsi n datang dengan nilai, bukan oleh referensi, sehingga variabel asli tidak berubah.

Mari kita lakukan trik mapa yang serupa:

 package main func foo(m map[int]int) { m[10] = 10 } func main() { m := make(map[int]int) m[10] = 15 println("m[10] before foo =", m[10]) foo(m) println("m[10] after foo =", m[10]) } 

Dan lihatlah:

 m[10] before foo = 15 m[10] after foo = 10 

Nilainya telah berubah. "Yah, Mapa disahkan dengan referensi?", Anda bertanya. Tidak. Tidak ada tautan di Go. Tidak mungkin membuat 2 variabel dengan 1 alamat, seperti dalam C ++ misalnya. Tapi kemudian Anda bisa membuat 2 variabel yang menunjuk ke alamat yang sama (tetapi ini adalah pointer, dan mereka dalam Go).

Misalkan kita memiliki fungsi fn yang menginisialisasi peta m. Pada fungsi utama, kita cukup mendeklarasikan variabel, mengirimkannya untuk menginisialisasi dan melihat apa yang terjadi setelahnya.

 package main import "fmt" func fn(m map[int]int) { m = make(map[int]int) fmt.Println("m == nil in fn?:", m == nil) } func main() { var m map[int]int fn(m) fmt.Println("m == nil in main?:", m == nil) } 

Kesimpulan:

m == nil in fn?: false
m == nil in main?: true

Jadi, variabel m diteruskan oleh nilai , oleh karena itu, seperti dalam kasus meneruskan int reguler ke fungsi, itu tidak berubah (salinan lokal dari nilai dalam fn berubah). Lalu mengapa nilai yang terletak pada m itu sendiri berubah? Untuk menjawab pertanyaan ini, pertimbangkan kode dari runtime bahasa:

 // A header for a Go map. type hmap struct { // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go. // Make sure this stays in sync with the compiler's definition. count int // # live cells == size of map. Must be first (used by len() builtin) flags uint8 B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items) noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details hash0 uint32 // hash seed buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0. oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated) extra *mapextra // optional fields } 

Map in Go hanyalah sebuah penunjuk ke struktur hmap. Ini adalah jawaban untuk pertanyaan mengapa, terlepas dari kenyataan bahwa peta dilewatkan ke fungsi berdasarkan nilai, nilai-nilai itu sendiri di dalamnya berubah - itu semua tentang penunjuk. Struktur hmap juga berisi yang berikut: jumlah elemen, jumlah "ember" (disajikan sebagai logaritma untuk mempercepat perhitungan), seeding untuk hash acak (untuk membuatnya lebih sulit untuk ditambahkan - coba mengambil kunci sehingga ada tabrakan terus-menerus), semua jenis bidang layanan dan yang paling penting, sebuah penunjuk ke kotak tempat nilai disimpan. Mari kita lihat gambarnya:

gambar

Gambar ini menunjukkan gambar skematis dari struktur dalam memori - ada header hmap, penunjuk yang merupakan peta di Go (dibuat ketika dideklarasikan dengan var, tetapi tidak diinisialisasi, yang menyebabkan program macet ketika mencoba memasukkannya). Bidang kotak adalah tempat penyimpanan pasangan nilai kunci, ada beberapa kotak seperti itu, masing-masing berisi 8 pasang. Pertama di "bucket" adalah slot untuk bit hash tambahan (e0..e7 disebut e - karena bit hash tambahan ). Berikutnya adalah kunci dan nilai sebagai daftar semua kunci terlebih dahulu, lalu daftar semua nilai.

Menurut hash dari fungsi, ditentukan di mana "ember" kita beri nilai, di dalam setiap "ember" bisa ada hingga 8 tabrakan, di akhir setiap "ember" ada penunjuk ke yang lain, jika yang sebelumnya meluap.

Bagaimana peta itu tumbuh?


Di kode sumber, Anda dapat menemukan baris:

  // Maximum average load of a bucket that triggers growth is 6.5. 

yaitu, jika ada rata-rata lebih dari 6,5 elemen di setiap bucket, terjadi peningkatan susunan bucket. Pada saat yang sama, array dialokasikan 2 kali lebih banyak, dan data lama disalin ke dalamnya dalam porsi kecil setiap penyisipan atau penghapusan, sehingga tidak membuat penundaan yang sangat besar. Oleh karena itu, semua operasi akan sedikit lebih lambat dalam proses evakuasi data (saat mencari, kami juga harus mencari di dua tempat). Setelah evakuasi yang berhasil, data baru mulai digunakan.

gambar

Mengambil alamat elemen peta.


Hal menarik lainnya - di awal menggunakan bahasa yang ingin saya lakukan seperti ini:

 package main import ( "fmt" ) func main() { m := make(map[int]int) m[1] = 10 a := &m[1] fmt.Println(m[1], *a) } 

Tapi Go mengatakan: "tidak dapat mengambil alamat m [1]". Penjelasan mengapa tidak mungkin untuk mengambil alamat nilai terletak pada prosedur evakuasi data. Bayangkan kita mengambil alamat nilai, dan kemudian mapa tumbuh, memori baru dialokasikan, data dievakuasi, yang lama dihapus, penunjuknya menjadi salah, sehingga operasi seperti itu dilarang.

Bagaimana peta diimplementasikan tanpa obat generik?


Baik antarmuka kosong, maupun pembuatan kode tidak ada hubungannya dengan itu, semuanya adalah untuk menggantikannya pada waktu kompilasi. Pertimbangkan apa fungsi akrab dari Go berubah menjadi:

 v := m["k"] → func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer v, ok := m["k"] → func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) m["k"] = 9001 → func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer delete(m, "k") → func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) 

Kami melihat bahwa ada fungsi akses map untuk mengakses, masing-masing untuk menulis dan menghapus mapassign dan mapdelete. Semua operasi menggunakan unsafe.Pointer, yang tidak peduli apa yang ditunjukkannya, dan informasi tentang setiap nilai dijelaskan oleh deskriptor tipe .

 type mapType struct { key *_type elem *_type ...} type _type struct { size uintptr alg *typeAlg ...} type typeAlg struct { hash func(unsafe.Pointer, uintptr) uintptr equal func(unsafe.Pointer, unsafe.Pointer) bool...} 

MapType menyimpan deskriptor (_type) kunci dan nilai. Untuk deskriptor tipe, operasi (typeAlg) perbandingan, mengambil hash, ukuran, dan sebagainya didefinisikan, jadi kami selalu tahu cara memproduksinya.

Sedikit lagi tentang cara kerjanya. Ketika kita menulis v = m [k] (mencoba mendapatkan nilai v dari kunci k), kompiler menghasilkan sesuatu seperti berikut:

 kPointer := unsafe.Pointer(&k) vPointer := mapaccess1(typeOf(m), m, kPointer) v = *(*typeOfvalue)vPointer 

Yaitu, kita mengambil sebuah pointer ke kunci, struktur mapType, dari mana kita menemukan deskriptor mana dari kunci dan nilai, pointer ke hmap itu sendiri (yaitu, peta) dan meneruskan semuanya ke mapaccess1, yang akan mengembalikan pointer ke nilai. Kami mengarahkan pointer ke tipe yang diinginkan, dereference dan mendapatkan nilainya.

Sekarang mari kita lihat kode pencarian dari runtime (yang sedikit disesuaikan untuk membaca):

 func lookup(t *mapType, m *mapHeader, key unsafe.Pointer) unsafe.Pointer { 

Fungsi mencari kunci di peta dan mengembalikan pointer ke nilai yang sesuai, argumen sudah akrab bagi kita - ini adalah mapType, yang menyimpan deskriptor dari jenis dan nilai kunci, memetakan sendiri (mapHeader) dan pointer ke memori yang menyimpan kunci. Kami mengembalikan pointer ke memori yang menyimpan nilai.

  if m == nil || m.count == 0 { return zero } 

Selanjutnya, kami memeriksa apakah pointer ke header peta tidak nol, jika ada 0 elemen di sana dan mengembalikan nilai nol, jika demikian.

  hash := t.key.hash(key, m.seed) // hash := hashfn(key) bucket := hash & (1<<m.logB-1) // bucket := hash % nbuckets extra := byte(hash >> 56) // extra := top 8 bits of hash b := (*bucket)(add(m.buckets, bucket*t.bucketsize)) // b := &m.buckets[bucket] 

Kami menghitung hash kunci (kami tahu cara menghitung kunci yang diberikan dari deskriptor tipe). Lalu kami mencoba memahami "ember" mana yang perlu Anda tuju (sisa pembagian dengan jumlah "ember", perhitungannya hanya sedikit dipercepat). Kemudian kita menghitung hash tambahan (kita mengambil 8 bit hash paling signifikan) dan menentukan posisi "ember" dalam memori (aritmatika alamat).

  for { for i := 0; i < 8; i++ { if b.extra[i] != extra { // check 8 extra hash bits continue } k := add(b, dataOffset+i*t.key.size) // pointer to ki in bucket if t.key.equal(key, k) { // return pointer to vi return add(b, dataOffset+8*t.key.size+i*t.value.size) } } b = b.overflow if b == nil { return zero } } 

Cari, jika Anda melihat, tidak begitu rumit: kita pergi melalui rantai "ember", pindah ke yang berikutnya, jika Anda tidak menemukannya. Pencarian di "bucket" dimulai dengan perbandingan cepat hash tambahan (itulah sebabnya e0 ... e7 di awal masing-masing adalah hash "mini" dari pasangan untuk perbandingan cepat). Jika tidak cocok, melangkah lebih jauh, jika tidak, maka kami memeriksa lebih hati-hati - kami menentukan di mana kunci yang dicurigai dicari terletak di memori, membandingkan apakah itu sama dengan apa yang diminta. Jika sama, tentukan posisi nilai dalam memori dan kembali. Seperti yang Anda lihat, tidak ada yang supranatural.

Kesimpulan


Gunakan peta, tetapi ketahui dan pahami cara kerjanya! Anda dapat menghindari menyapu dengan memahami beberapa seluk-beluk - mengapa Anda tidak dapat mengambil alamat nilainya, mengapa semuanya jatuh selama deklarasi tanpa inisialisasi, mengapa lebih baik mengalokasikan memori terlebih dahulu, jika jumlah elemen diketahui (kami akan menghindari evakuasi) dan banyak lagi.



Referensi:

"Pergi peta dalam aksi", Andrew Gerrand
"Bagaimana go runtime mengimplementasikan peta secara efisien", Dave Cheney
"Memahami tipe in go", William Kennedy
Implementasi peta di dalam, Keith Randall
kode sumber peta, Go Runtime
golang spec
pergi efektif
gambar gopher

Source: https://habr.com/ru/post/id457728/


All Articles