- Go 1.22 mengubah variabel loop
for dari cakupan seluruh loop menjadi cakupan per iterasi, untuk mengurangi salah satu kesalahan paling khas di Go, yaitu closure yang keliru menangkap variabel yang sama
- Dalam semantik lama, bahkan tanpa goroutine pun, fungsi yang dijalankan setelah iterasi selesai dapat merujuk ke
v atau i yang sama, sehingga hanya melihat nilai terakhir atau membuat pengujian lolos secara keliru
- Analyzer
loopclosure di go vet dan gopls hanya menangkap kasus yang benar-benar pasti sehingga masih ada false negative, sementara pemeriksa yang lebih agresif bisa menambah kode x:= x yang tidak perlu karena false positive
- Semantik baru ini hanya berlaku untuk modul yang mendeklarasikan
go 1.22 atau lebih tinggi di go.mod, dan di Go 1.21 pratinjau-nya bisa dijalankan dengan GOEXPERIMENT=loopvar
- Google telah memaksa mode ini untuk semua build di toolchain Go internal mereka sejak awal Mei 2023, dan selama 4 bulan tidak ada laporan masalah produksi, meski pengujian yang ditulis keliru akhirnya terungkap
Jebakan penangkapan variabel pada loop for lama
- Variabel loop
for pada Go lama memiliki cakupan seluruh loop, sehingga kode yang merujuk variabel itu setelah iterasi selesai bisa melihat nilai yang berbeda dari yang dimaksud
- Jika menelusuri
values := []string{"a", "b", "c"} sambil membuat tiga goroutine, setiap goroutine akan mencetak variabel v yang sama, bukan v per iterasi
- Masalah yang sama juga bisa terjadi tanpa konkurensi
- Jika
func() { fmt.Println(i) } disimpan ke dalam slice di dalam iterasi lalu dijalankan nanti, setiap fungsi akan merujuk ke i yang sama, bukan nilai per iterasi
Gangguan produksi dan keterbatasan analyzer
- Kesalahan seperti ini telah menyebabkan masalah produksi di sejumlah perusahaan, dan isu publik Let’s Encrypt adalah salah satu contohnya
- Dalam kasus Let’s Encrypt, saat melakukan iterasi map,
k memang disalin dengan kCopy := k, tetapi karena modelToAuthzPB(&v) memakai pointer ke field milik v saat membentuk hasil, v juga perlu disalin secara terpisah
- Karena penangkapan variabel melibatkan beberapa fungsi, masalahnya sulit dikenali
- Alat analisis statis sulit menentukan apakah variabel akan tetap hidup setelah iterasi, sehingga harus berkompromi antara false positive dan false negative
- Analyzer
loopclosure di go vet dan gopls hanya melaporkan masalah yang pasti, dengan konsekuensi menerima false negative
- Pemeriksa yang lebih agresif bisa menandai kode yang benar sebagai kode yang salah
- Jika melihat commit di kode Go open source yang menambahkan baris
x := x, terlihat bahwa selain perbaikan bug yang nyata, banyak juga perubahan yang sebenarnya tidak perlu
- Ada situasi ketika developer menambahkan kode yang tidak perlu hanya untuk memuaskan pemeriksa
- Dari dua diff seperti
informer := informer dan a := a, hanya satu yang benar-benar memperbaiki bug dan yang lain tidak perlu, tetapi tanpa informasi tipe dan fungsi, sulit membedakannya
Semantik loop baru di Go 1.22
- Di Go 1.22, variabel loop
for akan diubah agar memiliki cakupan terpisah pada setiap iterasi
- Contoh-contoh sebelumnya tidak lagi menjadi program Go yang mengandung bug, dan masalah produksi serta kebutuhan akan alat pemeriksa yang kurang akurat juga akan berkurang
- Demi kompatibilitas mundur, semantik baru ini hanya berlaku pada package dalam modul yang mendeklarasikan
go 1.22 atau lebih tinggi di go.mod
- Ini memungkinkan migrasi bertahap tanpa harus mengubah seluruh codebase sekaligus
- Kontrol per file juga dimungkinkan melalui baris
//go:build
- Kode lama tetap mempertahankan makna seperti sekarang
- Perubahan hanya berlaku pada kode baru atau kode yang telah diperbarui
- Developer bisa mengontrol kapan semantik berubah pada package tertentu
Pengaman di versi Go sebelumnya
- Sesuai pekerjaan forward compatibility di Go, Go 1.21 tidak akan mengompilasi kode yang mendeklarasikan
go 1.22 atau lebih tinggi
- Rilis point Go 1.20.8 dan Go 1.19.13 juga menyertakan penanganan khusus yang menghasilkan efek yang sama
- Setelah Go 1.22 dirilis, kode yang ditulis dengan ketergantungan pada semantik baru tidak akan dikompilasi dengan semantik lama, kecuali jika menggunakan versi Go yang sudah sangat lama dan tidak didukung
Menjalankan pratinjau di Go 1.21
- Go 1.21 menyertakan pratinjau perubahan cakupan loop
- Jika dikompilasi dengan mengatur
GOEXPERIMENT=loopvar, semua loop akan memakai semantik baru dengan mengabaikan baris go di go.mod
- Untuk memeriksa apakah package dan seluruh dependensinya tetap lolos pengujian di bawah semantik loop baru, jalankan seperti berikut
GOEXPERIMENT=loopvar go test
- Di Go Playground, Anda bisa mencoba semantik baru dengan menambahkan komentar
// GOEXPERIMENT=loopvar di bagian paling atas program
- Toolchain Go internal Google telah ditambal agar memaksa mode ini di semua build sejak awal Mei 2023, dan setelah itu selama 4 bulan tidak ada laporan masalah pada kode produksi
Bug pengujian yang terungkap oleh semantik baru
- Semantik loop baru tidak menimbulkan masalah pada kode produksi, tetapi justru mengungkap pengujian yang selama ini lolos secara keliru
- Dalam contoh subtest yang memakai
t.Parallel, Go 1.21 menahan tiap subtest hingga seluruh loop selesai, lalu menjalankannya secara paralel
- Setelah loop selesai,
v selalu bernilai 6, sehingga semua subtest memeriksa apakah 6 adalah bilangan genap dan akhirnya lolos
- Padahal test case sebenarnya berisi
1, jadi pengujian seharusnya gagal
- Di Go 1.21, presisi analyzer
loopclosure telah ditingkatkan sehingga bisa mengidentifikasi dan melaporkan masalah ini
- Contoh laporan di Go Playground: contoh program
- Jika
go vet melaporkan masalah seperti ini dalam pengujian, memperbaikinya akan membantu persiapan menuju Go 1.22
- Alat dan contoh untuk menemukan loop yang menyebabkan kegagalan pengujian tertentu setelah semantik baru diterapkan dirangkum di FAQ
Bacaan lanjutan
1 komentar
Pendapat Hacker News
Mungkin ada contoh yang jauh lebih awal, tetapi peringatan tertua tentang perilaku ini yang saya temukan lewat pencarian 60 detik adalah comp.lang.lisp FAQ yang diposting pada 1992, lebih dari 30 tahun lalu
Di sana dijelaskan bahwa
DOTIMES,DOLIST, danDOmenggunakan assignment, bukan binding, saat memperbarui variabel iterasi, sehingga jikalambdamenangkapnseperti pada contoh, kesepuluh closure semuanya dibuat di atas nilai variabelNyang samaJika menangkap melalui referensi, sebenarnya itu perilaku yang memang diharapkan
Meski begitu, setelah mempelajari cara kerjanya sekali, ini tidak lagi menjadi masalah, dan jika perlu kita bisa memilih form lalu melakukan macro expansion untuk memeriksa cara implementasinya
Tim bahasa C# juga mengalami masalah yang sama setelah memperkenalkan closure ringan di C# 4.0, dan segera terlihat bahwa ini adalah jebakan
Pengguna hampir selalu menggunakan variabel loop dengan keliru, dan di C# 5.0 mereka memasukkan perubahan yang memutus kompatibilitas
Eric Lippert menulis artikel yang menjelaskan dengan baik “mengapa” dari sudut pandang itu: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
Tulisan pengumuman asli C# 5 sulit ditemukan; semoga tidak hilang selama berbagai migrasi blog di domain Microsoft setelah 2012
Mengingat perubahan tipe string saja saat berpindah dari Python 2 ke 3 sudah menimbulkan kekacauan, rasanya perubahan ini tidak akan masuk sebelum Python 4.0
Lalu mungkin akan ada orang yang mengatakan Python buruk karena tidak memperbaiki hal seperti ini, lalu kembali mencaci Python karena skrip yang dibuat pada 2003 tidak berjalan
Menurut saya ia berperan besar dalam melewati hambatan “tolak dulu” yang pada dasarnya harus dimiliki proposal perubahan bahasa
Hal lain yang sangat meyakinkan adalah hasil pemindaian codebase open source untuk melihat keseimbangan antara bug yang diperbaiki dan bug baru yang muncul
Karena pass-by-value, ini menangkap keadaan variabel pada saat pemanggilan dan mengurangi ambiguitas kode
Jika mencoba menangkap variabel dengan cara yang aneh, misalnya koleksi yang diakumulasi untuk mengubah array menjadi map dan variabel yang dideklarasikan akan berperilaku berbeda satu sama lain
Go tampaknya mencoba menyeimbangkan ini dengan menerapkan perilaku semacam itu hanya pada counter loop, tetapi tetap saja sebagian variabel akan berperilaku aneh
Saya terutama penasaran apa yang akan terjadi jika mendefinisikan beberapa variabel loop untuk memindai input secara langsung
for(let)https://eli.thegreenplace.net/2019/go-internals-capturing-lo... tampaknya menjelaskan masalah ini dengan lebih detail
i := ibekerja ternyata sama sekali berbeda dari yang saya kiraAwalnya saya mengira karena
ibaru diteruskan ke goroutine, escape analysis menandainya sebagai keluar dari lexical scope, sehingga dialokasikan di heap, dan setiap iterasi menghasilkan satu alokasi heap sehingga tiap goroutine merujuk ke lokasi memori yang unikKenyataannya, compiler Go punya heuristik untuk memilih capture by reference atau capture by value, dan ada kondisi bahwa nilai yang tidak diperbarui setelah inisialisasi akan ditangkap sebagai nilai
iyang baru berada dalam scope bodyfordan tidak diperbarui oleh loop itu sendiri, sehingga dinilai sebagai nilai yang tidak diperbarui setelah inisialisasi, lalu kode yang dihasilkan menangkapnya sebagai nilai tanpa alokasi heapSaya paham bahwa yang terakhir lebih baik, tetapi saya ingin mendengar dari orang yang sangat memahami Go mengapa cara yang pertama tidak ikut terjadi
Apakah perubahan ini tidak akan merusak program yang bergantung pada perilaku saat ini?
go 1.22atau lebih baru digo.modPada tingkat berkas, keputusan juga bisa dibuat menggunakan baris
//go:buildJanji itu mengatakan bahwa program yang ditulis berdasarkan spesifikasi Go 1 harus terus dikompilasi dan berjalan dengan benar tanpa perubahan selama masa hidup spesifikasi tersebut; suatu saat mungkin ada spesifikasi Go 2, tetapi sampai saat itu program Go yang berjalan hari ini harus tetap berjalan di rilis poin seperti Go 1.1 dan Go 1.2
Mereka berpikir jumlah orang yang tanpa sengaja membuat bug karena desain ini jauh lebih banyak daripada jumlah orang yang terdampak oleh perbaikannya
Seingat saya, dikatakan bahwa di basis kode Google maupun kode GitHub, hampir tidak ada kasus di mana perubahan ini merusak perilaku yang diharapkan
Keputusan untuk melanggar kompatibilitas mundur baru diambil setelah memastikan betapa sedikitnya basis kode yang terdampak, lalu membuat mekanisme yang mengharuskan kode diperbarui secara aktif lewat penentuan versi di
go.modagar memakai perilaku baruhttps://twitter.com/go100and1/status/1690412229135601664
https://twitter.com/go100and1/status/1690587305806057472
https://twitter.com/go100and1/status/1690589791686119424
https://twitter.com/go100and1/status/1690591234715492352
https://twitter.com/go100and1/status/1690593184857145344
https://twitter.com/go100and1/status/1691456732151889920
Sebagian besar sama sekali tidak disebutkan dalam dokumen proposal
Python juga pernah mengalami masalah ini, meski bukan baru-baru ini
Saya tidak yakin apakah Python yang berubah, atau saya yang mulai menyadari masalahnya
Bahwa ini masih bisa menjadi masalah di Python sudah cukup terlihat dari kode ini saja:
funcs = [(lambda: x) for x in range(3)];funcs[0]()mencetak2Python dulu lebih buruk lagi, karena cakupannya bahkan dibagikan sampai ke luar list comprehension
Saat memakai lambda di dalam list comprehension atau loop, yang ditangkap bukan nilai
xsaat ini, melainkan referensi ke variabelxSaat
funcs[0]()dipanggil,xsudah diatur ke nilai terakhir darirange, yaitu2Untuk mendapatkan perilaku yang diinginkan, teruskan
xsebagai argumen default lambda:funcs = [(lambda x=x: x) for x in range(3)]Saya hanya sedikit memakai Go dan paham masalah umum yang diselesaikan perubahan ini, tetapi saya kurang memahami contoh yang lebih halus seperti kasus letsencrypt atau
"range c.informerMap"versus"range alarms"Dalam
for k, v := range someMap, apakahvadalah tipe nilai map, dan ada satu binding untuk seluruh loop yang disalin pada setiap iterasi? Kalau begitu masalahnya bisa dijelaskan, tetapi saya mengiravadalah referensi yang menunjuk ke bagian dalam mapSaya sempat membaca sekilas “For statements with range clause” di spesifikasi dan tidak menemukan jawabannya; mungkin karena saya hampir tidak pernah menyentuh Go, saya melihat bagian yang salah: https://go.dev/ref/spec#For_statements
Sunting: ternyata jawabannya ada di tabel berformat blok kode. Sepertinya saya melewatkannya seperti banner. Mengejutkan bahwa
vadalah nilai yang disalin, bukan referensiPointer ke slot array didukung, tetapi
for rangemenyalin nilainya alih-alih memberikan pointer ke setiap slotvadalahintItu nilai, bukan pointer ke
intKalau penasaran, silakan cek: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Pada dasarnya, compiler mengubah
go a.Monitor(b)menjadi(&a).Monitor(b)karena dereferensi otomatisSaya penasaran bagaimana bagian “sebagai hasil dari pekerjaan kompatibilitas ke depan, Go 1.21 tidak akan mencoba mengompilasi kode yang mendeklarasikan
go 1.22atau lebih tinggi. Kami juga memasukkan penanganan khusus dengan efek yang sama ke rilis poin Go 1.20.8 dan Go 1.19.13, sehingga setelah Go 1.22 dirilis, kode yang ditulis dengan bergantung pada semantik baru tidak akan pernah dikompilasi dengan semantik lama, kecuali menggunakan versi Go yang sangat tua dan tidak lagi didukung” ini bekerjaKalau suatu paket dipatok ke 1.22 dan saya mengompilasinya dengan 1.18, apakah akan berhasil dikompilasi atau muncul error bahwa compiler 1.22 diperlukan?
Karena di Go 1.21 mereka mengubah format nomor versi di berkas
go.mod, jika mencoba build dengan Go 1.18 akan muncul error sepertigo.mod:3: invalid go version '1.21.0': must match format 1.23Namun ini hanya terjadi kalau modul dibuat dengan
go mod init; kalau menulisgo 1.21secara manual digo.mod, build berjalan tanpa protesIni fitur yang cukup keren, tetapi perilakunya mengejutkan, dan saya agak ragu karena ia terhubung ke server yang dikendalikan Google untuk mengambil binary
Bersama proxy modul, ini salah satu fitur Go yang paling membuat saya ambivalen; rasanya saya akan jauh lebih tenang kalau Go dikelola oleh yayasan yang hanya dimiliki sebagian oleh Google
Edit: setelah dipikir-pikir, ini soal ketika modul saat ini yang mendeklarasikannya, bukan ketika dependensi mendeklarasikan versi lain, jadi berbeda dari pertanyaan aslinya
Jadi penggunaan Go 1.18 menjadi berbahaya secara aktif
Di Go 1.19 kemungkinan akan muncul error compiler
Bagaimanapun, Go tidak memberi perbaikan bug keamanan untuk rilis lama dan pustaka standarnya, jadi menurut saya memakai versi seperti itu sendiri sudah berbahaya
Namun meskipun dikompilasi dengan Go 1.22, kode Anda tetap memiliki semantik Go 1.18
Go dalam beberapa hal adalah bahasa yang sangat aneh
Ia bahasa yang sangat berpendirian kuat, tetapi pada saat yang sama tampak seperti bahasa yang terlalu tidak punya pendirian
Saya tidak sepenuhnya yakin apa perbedaan antara kode yang mengiterasi
c.informerMapdan kode yang mengiterasialarms, tetapi kalau menebak, variabel loop di satu sisi mungkin pointer dan di sisi lain nilaiKarena pemanggilan metodenya memakai pointer receiver, mungkinkah ketika berupa nilai compiler otomatis memasukkan referensi ke receiver?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Perbedaannya adalah pada yang satu,
informeradalah interface, sehingga pemanggilan metode langsung ditafsirkan sebagaiinformer.Rundan tidak bermasalahPada yang lain,
aadalah structAlarmdan disalin sebagai nilai, sementara metodeMonitormenerima pointer receiverJadi compiler pada dasarnya mengubah
go a.Monitor(b)menjadigo (&a).Monitor(b), dan ini membuat referensi ke variabel loop sehingga menimbulkan masalahSaya menduga pada yang kedua, karena
apada akhirnya hanya memegang nilai elemen terakhir darialarms, masalah asli yang dijelaskan di tulisan itu terjadiPengetahuan internal saya hanya sampai di situ, tetapi slice memiliki backing array di heap, sehingga pointer atau referensi sampai tingkat tertentu saling terkait
Membaca ini membuat saya sangat lega
Salah satu cacat terbesar di Go sedang diperbaiki
Jika menulis seperti
foo, err := getFoo(); if err != nil ...lalubar, err := getBar(); fmt.Println(bar), pemeriksaan error darigetBarbisa terlewatKarena aturan scope, pola
if foo, err := getFoo(); err != nilmenjadi sulit ditangani begitu nesting sedikit saja bertambah dalamIni juga memperkenalkan keadaan yang tidak valid. Saat
getFoomengembalikan error, apa yang harus dikembalikan? Kita jadi harus mempertimbangkan apakah mengubah API agar mengembalikan pointer dan mengembalikannil, atau menaruh objek yang sebagian dibuat dalam keadaan tidak valid