Bir arkadaşına kütüphanedeki bir kitabı göstermek istediğini düşün. İki yolun var: Kitabın fotokopisini çekip eline verebilir ya da ona kitabın hangi rafta durduğunu yazan küçük bir not bırakabilirsin. Arkadaşın fotokopinin kenarına not alırsa asıl kitap hiç değişmez. Raf numarasını bilen biri ise doğrudan asıl kitaba ulaşır ve sayfalarına dokunabilir. İşaretçi (pointer) tam olarak bu nottur: Bir değerin kendisini değil, o değerin bellekte nerede durduğunu tutar.
Go'da her şey değer ile geçirilir (pass by value). Bir fonksiyona argüman verdiğinde fonksiyon o değerin bir kopyasını alır; bunu Fonksiyonlar dersinde görmüştün. Bu kural programları öngörülebilir kılar ama iki soruyu da beraberinde getirir: Bir fonksiyon, kendisini çağıranın değişkenini nasıl değiştirebilir? Onlarca alanı olan bir struct'ı her çağrıda baştan sona kopyalamaktan nasıl kaçınırız? İki sorunun cevabı da işaretçilerdir.
İyi haber şu ki Go'nun işaretçileri C'dekilere göre çok daha güvenlidir. İşaretçi aritmetiği yoktur, bellek çöp toplayıcı (garbage collector) tarafından yönetilir ve bir fonksiyondan yerel değişkenin adresini döndürmek tamamen güvenlidir. Bu dersin sonunda & ve * operatörlerini bellekte olup biteni çizerek açıklayabilecek, nil işaretçi paniklerinden kaçınabilecek, derleyicinin bir değişkeni neden heap'e taşıdığını okuyabilecek ve bir işaretçinin ne zaman gerçekten gerektiğine kendin karar verebileceksin.
Bellek, adres ve değişken
Bilgisayarın belleğini, her birinin üzerinde bir numara yazan upuzun bir kutu dizisi olarak düşünebilirsin. Kutunun üzerindeki numara onun adresi (address), içindeki şey ise değeridir. x := 42 yazdığında derleyici x için bir int sığacak kadar yer ayırır ve oraya 42 yazar. x adı yalnızca senin için bir etikettir; çalışan program o kutuya adresi üzerinden ulaşır.
adres: 0xc000012000 0xc000012008 0xc000012010
+--------------+--------------+--------------+
bellek: | 42 | ... | ... |
+--------------+--------------+--------------+
^
xBuradaki adresler örnek olsun diye seçildi; gerçek adresler her çalıştırmada farklı olabilir. Önemli olan fikir şu: Her değişkenin bir adresi vardır ve bu adres de sonuçta bir sayıdır, yani başka bir değişkende saklanabilir. Bir adresi saklayan değişkene işaretçi denir.
& ve * operatörleri
& ile adres alma
& operatörü bir değişkenin adresini verir. x'in tipi int ise &x'in tipi *int olur; bunu "int'e işaretçi" diye okursun. Bu adresi başka bir değişkende saklayabilir, hatta yazdırabilirsin:
package main
import "fmt"
func main() {
x := 42
p := &x
fmt.Println("x'in değeri:", x)
fmt.Println("x'in adresi:", &x)
fmt.Println("p'nin değeri:", p)
fmt.Printf("p'nin tipi: %T\n", p)
}x'in değeri: 42 x'in adresi: 0xc000012100 p'nin değeri: 0xc000012100 p'nin tipi: *int
Adresler çalışma zamanına bağlı olduğu için sen çalıştırdığında farklı sayılar göreceksin. Dikkat etmen gereken iki şey var: &x ile p aynı değeri taşıyor ve p'nin tipi *int.
* ile işaretçinin gösterdiği değere ulaşma
* sembolü Go'da iki farklı yerde, iki farklı anlamda kullanılır ve başta kafa karıştırabilir:
- Tip içinde (
*int,*User): "bu tipe işaretçi" demektir. - İfade içinde (
*p): "p'nin gösterdiği adrese git ve oradaki değeri kullan" demektir. Bu işleme dereference (işaretçiyi çözme) denir.
*p hem okumak hem de yazmak için kullanılabilir. Aşağıdaki programda x'i adıyla anmadan değiştiriyoruz:
package main
import "fmt"
func main() {
x := 42
p := &x // p, x'in adresini tutar
fmt.Println(*p) // p'nin gösterdiği değeri oku
*p = 100 // p'nin gösterdiği yere yaz
fmt.Println(x)
x++ // x'i doğrudan değiştir
fmt.Println(*p)
fmt.Println(p == &x)
}42 100 101 true
Adım adım bakalım:
p := &xsatırından sonrap,x'in kutusunu gösteriyor.fmt.Println(*p)o kutudaki değeri okuyor: 42.*p = 100o kutuya 100 yazıyor. Kutux'in kutusu olduğu içinxartık 100.x++aynı kutuyu bu kezxadıyla değiştiriyor;*pde 101 görüyor.p == &xiki adresi karşılaştırıyor. İkisi de aynı kutuyu gösterdiği için sonuçtrue.
p := &x sonrası:
x (int) p (*int)
+----------------+ +----------------+
| 42 | <--------- | 0xc000012000 |
+----------------+ +----------------+
adres: 0xc000012000
*p = 100 sonrası:
x (int) p (*int)
+----------------+ +----------------+
| 100 | <--------- | 0xc000012000 |
+----------------+ +----------------+
x -> 100 değişkenin değeri
&x -> 0xc000012000 x'in adresi
p -> 0xc000012000 p'nin değeri, yani x'in adresi
*p -> 100 p'nin gösterdiği kutudaki değerx ile *p aynı kutunun iki farklı adıdır. Buna takma ad oluşturma (aliasing) denir ve işaretçilerin hem gücü hem de dikkat isteyen yanı budur: Bir yerden yapılan değişiklik, aynı kutuyu gösteren her yerden görünür.
İşaretçi tipleri
İşaretçi tipleri de her şey gibi kesin tiplidir: *int bir float64'ün adresini tutamaz ve Go işaretçi tipleri arasında örtük dönüşüm yapmaz. **int gibi işaretçiye işaretçi tipleri mümkündür ama günlük Go kodunda nadiren gerekir.
new fonksiyonu
Şimdiye kadar hep var olan bir değişkenin adresini aldık. new(T) yerleşik fonksiyonu ise T tipinde yeni ve isimsiz bir değişken oluşturur, onu sıfır değerle (zero value) doldurur ve adresini döndürür. Yani p := new(int) yazmak, var tmp int deyip ardından p := &tmp yazmanın kısa yoludur.
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
n := new(int) // *int, 0 değerini gösterir
fmt.Println(*n)
*n = 7
fmt.Println(*n)
pt := new(Point) // *Point, Point{0, 0} değerini gösterir
pt.X = 3
fmt.Println(pt)
// Struct'lar için daha yaygın yol: bileşik literalin adresini almak
q := &Point{X: 1, Y: 2}
fmt.Println(*q)
// Go 1.26: new, başlangıç değeri olarak bir ifade de alabilir
age := new(30)
fmt.Println(*age)
}0
7
&{3 0}
{1 2}
30Çıktıdaki &{3 0} satırına dikkat et: fmt bir struct işaretçisini yazdırırken adresi değil, başına & koyarak içeriği gösterir. *q yazdığımızda ise struct değerinin kendisini yazdırdığımız için & görünmez.
Struct'lar için pratikte new(Point) yerine &Point{X: 1, Y: 2} biçimini daha sık görürsün; çünkü alanları aynı satırda doldurabilirsin. Temel tipler için ise uzun süre bir sorun vardı: Bir sabitin adresini doğrudan alamazsın.
package main
import "fmt"
func main() {
p := &42 // sabitin adresi olmaz
fmt.Println(p)
}./main.go:6:8: invalid operation: cannot take address of 42 (untyped int constant)
Go 1.26 öncesinde bu yüzden func ptr[T any](v T) *T { return &v } gibi küçük yardımcı fonksiyonlar yazılırdı (bu sözdizimini Jenerikler dersinde göreceksin). Go 1.26 ile new bir ifade de kabul ediyor: new(30), 30 değeriyle başlatılmış yeni bir int değişkeninin adresini döndürüyor. Bu özellikle opsiyonel alanları doldururken işe yarar; birazdan bir örneğini göreceğiz.
nil işaretçiler ve panik
Her tipin bir sıfır değeri vardır ve işaretçilerin sıfır değeri nil'dir: hiçbir yeri göstermeyen işaretçi. var p *int diye tanımladığın bir işaretçi nil olarak başlar. nil bir işaretçinin gösterdiği yere gitmeye çalışmak, program çalışırken panik (panic) oluşturur:
package main
import "fmt"
type User struct {
Name string
}
func main() {
var p *int
fmt.Println(p == nil)
var u *User
fmt.Println(u == nil)
fmt.Println(u.Name) // panik: u hiçbir struct'ı göstermiyor
}true true panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=... addr=0x0 pc=...] goroutine 1 [running]: main.main() /tmp/main.go:16 +0x...
Çıktının anahtar satırı invalid memory address or nil pointer dereference. Hemen altındaki yığın izi (stack trace) de hatanın hangi dosyanın hangi satırında olduğunu söyler. Derleyici bu hatayı yakalayamaz, çünkü bir işaretçinin çalışma anında nil olup olmayacağı çoğu zaman ancak program çalışırken belli olur.
Çözüm, nil olabilecek işaretçiyi kullanmadan önce kontrol etmektir:
func displayName(u *User) string {
if u == nil {
return "misafir"
}
return u.Name
}nil alıcıyla metot çağırmak
İşin ilginç yanı, nil bir işaretçi üzerinden metot çağırmak tek başına panik oluşturmaz. Panik, yalnızca metot içinde işaretçinin gösterdiği değere erişildiğinde olur. Bu sayede bazı metotlar nil alıcıyı anlamlı bir durum olarak ele alabilir. Bağlı listeler ve ağaçlar gibi yapılarda "boş liste" veya "boş alt ağaç" zaten nil işaretçiyle temsil edildiği için bu desen çok kullanışlıdır:
package main
import "fmt"
type Node struct {
Value int
Next *Node
}
// Sum, nil bir alıcıyla çağrılsa bile güvenle çalışır.
func (n *Node) Sum() int {
if n == nil {
return 0
}
return n.Value + n.Next.Sum()
}
func main() {
list := &Node{Value: 1, Next: &Node{Value: 2, Next: &Node{Value: 3}}}
fmt.Println(list.Sum())
var empty *Node
fmt.Println(empty.Sum())
}6 0
Son düğümde n.Next nil'dir ve n.Next.Sum() çağrısı paniğe yol açmadan 0 döndürür. Özyineleme bu sayede ekstra bir kontrol olmadan doğal olarak sonlanır. Bu yapıyı Bağlı Listeler dersinde ayrıntılı olarak kuracaksın.
Fonksiyonlara işaretçi geçirme
Değer ile geçirme: kopya üzerinde çalışmak
İki değişkenin değerini takas eden bir fonksiyon yazmak işaretçileri anlamanın klasik yoludur. İlk denemede parametreleri doğrudan int olarak alalım, ikincisinde işaretçi olarak:
package main
import "fmt"
// swapValues yalnızca kendi kopyalarını takas eder.
func swapValues(a, b int) {
a, b = b, a
}
// swap, işaretçiler aracılığıyla asıl değişkenleri takas eder.
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
x, y := 1, 2
swapValues(x, y)
fmt.Println("swapValues sonrası:", x, y)
swap(&x, &y)
fmt.Println("swap sonrası:", x, y)
}swapValues sonrası: 1 2 swap sonrası: 2 1
Bellekte neler olduğunu çizelim. swapValues çağrıldığında x ve y'nin değerleri fonksiyonun kendi değişkenleri olan a ve b'ye kopyalanır. Takas bu kopyalar üzerinde olur ve fonksiyon dönünce kopyalar yok olur:
swapValues(x, y):
main swapValues
+-------+-------+ kopyala +-------+-------+
| x = 1 | y = 2 | ------------> | a = 1 | b = 2 |
+-------+-------+ +-------+-------+
a, b = b, a -> a = 2, b = 1
main'deki x ve y hiç değişmez.
swap(&x, &y):
main swap
+-------+-------+ +--------+--------+
| x = 1 | y = 2 | <------------ | a = &x | b = &y |
+-------+-------+ gösterir +--------+--------+
*a, *b = *b, *a -> x = 2, y = 1Burada ince ama önemli bir nokta var: swap çağrısında da kopyalama olur. Kopyalanan şey bu kez adreslerdir. Bir adresin kopyası da aynı kutuyu gösterdiği için *a ve *b üzerinden yapılan değişiklikler doğrudan main'deki x ve y'ye yansır. Yani Go'da "referans ile geçirme" diye ayrı bir mekanizma yoktur; işaretçinin kendisi de değer ile geçirilir.
İşaretçinin kendisi de kopyalanır
Bu kuralın bir sonucu, fonksiyon içinde işaretçi parametresine yeni bir değer atamanın çağıranı etkilememesidir. Gösterilen değeri değiştirmekle işaretçiyi değiştirmek arasındaki farkı iyi ayırt etmelisin:
package main
import "fmt"
func resetPointer(p *int) {
p = nil // yalnızca yerel kopyayı değiştirir
}
func resetValue(p *int) {
*p = 0 // gösterilen değeri değiştirir
}
func main() {
x := 10
p := &x
resetPointer(p)
fmt.Println(p == nil, x)
resetValue(p)
fmt.Println(p == nil, x)
}false 10 false 0
resetPointer içindeki p = nil satırı fonksiyonun kendi p kopyasını nil yapar; main'deki p ve x bundan habersizdir. resetValue ise *p = 0 ile adresin gösterdiği kutuya yazar ve x değişir.
Struct işaretçileri ve otomatik dereference
İşaretçiler en çok struct'larla birlikte karşına çıkar. p bir *Account ise, bir alanına erişmek için teknik olarak (*p).Balance yazman gerekir: önce işaretçiyi çöz, sonra alana eriş. Bu yazım hem çirkin hem de yorucudur; bu yüzden Go senin yerine otomatik dereference yapar ve p.Balance yazmana izin verir.
package main
import "fmt"
type Account struct {
Owner string
Balance int
}
// depositCopy bir kopya alır; asıl hesap değişmez.
func depositCopy(a Account, amount int) {
a.Balance += amount
}
// deposit bir işaretçi alır; asıl hesabı günceller.
func deposit(a *Account, amount int) {
a.Balance += amount // (*a).Balance += amount ile aynı
}
func main() {
acc := Account{Owner: "Zeynep", Balance: 100}
depositCopy(acc, 50)
fmt.Println(acc.Balance)
deposit(&acc, 50)
fmt.Println(acc.Balance)
p := &acc
(*p).Balance -= 30 // uzun yazım
p.Balance -= 20 // otomatik dereference
fmt.Println(acc.Balance)
fmt.Println(p)
}100
150
100
&{Zeynep 100}depositCopy bütün struct'ın bir kopyasını alır. Bu örnekte struct yalnızca iki alanlı olduğu için kopyalamanın maliyeti önemsizdir; ama değişiklik kopyada kaldığı için bakiye 100 olarak kalır. deposit ise sadece bir adres alır ve asıl struct'ı günceller.
acc (Account) p (*Account)
+-----------------------------+ +----------------+
| Owner: "Zeynep" | <----- | &acc |
| Balance: 100 | +----------------+
+-----------------------------+
p.Balance == (*p).Balance == acc.BalanceMetot çağrılarında otomatik adres alma
Aynı kolaylık metotlarda da geçerlidir. Struct'lar ve Metotlar dersinde gördüğün işaretçi alıcılı (pointer receiver) bir metodu, adresi alınabilen bir değişken üzerinde çağırdığında Go adresi senin yerine alır. Tersi de doğrudur: Değer alıcılı bir metodu işaretçi üzerinden çağırırsan Go işaretçiyi çözer.
package main
import "fmt"
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ } // işaretçi alıcı
func (c Counter) Value() int { return c.n } // değer alıcı
func main() {
var c Counter
c.Inc() // Go bunu (&c).Inc() olarak yorumlar
c.Inc()
p := &c
p.Inc() // p zaten bir işaretçi
fmt.Println(p.Value()) // Go bunu (*p).Value() olarak yorumlar
fmt.Println(c.Value())
}3 3
Otomatik adres alma yalnızca adresi alınabilen (addressable) değerlerde çalışır. Değişkenler, slice elemanları ve adresi alınabilen bir struct'ın alanları bu gruba girer. Map elemanları ve Counter{} gibi geçici değerler ise girmez: Counter{}.Inc() derleme hatası verir, çünkü geçici bir değerin adresi yoktur. Map elemanlarının adresi alınamaz, çünkü map büyüdükçe elemanlarını bellekte başka yere taşıyabilir.
Slice elemanlarına işaretçi
&users[i] ile bir slice elemanının adresini alabilirsin ve bu, elemanı yerinde değiştirmek için kullanışlıdır. Ama bir tuzağı var: slice'ın alttaki dizisi append ile yeniden ayrılırsa eski işaretçi artık yeni diziyi değil, eski diziyi gösterir.
package main
import "fmt"
func main() {
nums := []int{1, 2, 3} // uzunluk 3, kapasite 3
first := &nums[0]
nums = append(nums, 4) // kapasite yetmez: yeni bir dizi ayrılır
*first = 100 // eski dizideki elemanı değiştirir
fmt.Println(nums[0], *first)
}1 100
append öncesi:
nums --> [ 1 | 2 | 3 ] (kapasite 3)
first --> ^
append sonrası:
first --> [ 100 | 2 | 3 ] (eski dizi, artık yalnızca first kullanıyor)
nums --> [ 1 | 2 | 3 | 4 | _ | _ ] (yeni dizi)Program çökmez; çöp toplayıcı eski diziyi first onu gösterdiği sürece canlı tutar. Asıl tehlike sessizliktir: Değişiklik kaybolur ve hiçbir hata almazsın. Slice'ın büyüyebileceği durumlarda işaretçi yerine indeks saklamak daha güvenlidir. Kapasite büyümesinin ayrıntıları Diziler ve Dilimler dersinde.
Kaçış analizi (escape analysis): stack ve heap
Yerel değişkenin adresini döndürmek güvenlidir
C'de bir fonksiyonun yerel değişkeninin adresini döndürmek ciddi bir hatadır: Fonksiyon dönünce o bellek bölgesi geçersiz olur ve işaretçi "sallanan" (dangling) bir işaretçiye dönüşür. Go'da aynı kod tamamen güvenlidir:
package main
import "fmt"
type User struct {
Name string
Age int
}
// newUser, yerel bir değişkenin adresini döndürür.
func newUser(name string, age int) *User {
u := User{Name: name, Age: age}
return &u
}
// area yalnızca değerlerle çalışır.
func area(w, h int) int {
result := w * h
return result
}
func main() {
u := newUser("Ayşe", 30)
fmt.Println(u.Name, u.Age, area(3, 4))
}Ayşe 30 12
Bunun neden güvenli olduğunu anlamak için Go'nun kullandığı iki bellek bölgesini tanıman gerekiyor.
Stack ve heap
Stack, her goroutine'e ait, fonksiyon çağrılarıyla birlikte büyüyüp küçülen bir bellek bölgesidir. Bir fonksiyon çağrıldığında yerel değişkenleri için bir çerçeve (frame) açılır; fonksiyon dönünce bu çerçeve topluca bırakılır. Stack'te yer ayırmak neredeyse bedavadır ve çöp toplayıcıya hiç iş çıkarmaz. Go'da goroutine stack'leri küçük başlar ve gerektikçe büyütülür.
Heap ise ömrü tek bir fonksiyon çağrısına bağlanamayan değerlerin yaşadığı ortak bölgedir. Buradaki değerleri, artık hiçbir yerden erişilemez hâle geldiklerinde çöp toplayıcı temizler. Heap'te yer ayırmak stack'e göre daha pahalıdır ve her tahsis çöp toplayıcının yükünü artırır.
STACK (goroutine'e ait) HEAP (çöp toplayıcı yönetir)
+------------------------------+
| main çerçevesi | +-----------------------------+
| u (*User) -----------------+-----------> | User{Name: "Ayşe", Age: 30} |
+------------------------------+ +-----------------------------+
| newUser çerçevesi |
| (fonksiyon döndü, |
| çerçeve bırakıldı) |
+------------------------------+u değişkeni newUser'ın çerçevesinde yaşasaydı, fonksiyon dönünce main'deki işaretçi geçersiz bir yeri gösterirdi. Go derleyicisi bunu fark eder ve u'yu baştan heap'e yerleştirir. Böylece main işaretçiyi kullandığı sürece User değeri yaşamaya devam eder.
Derleyici nasıl karar verir?
Bir değişkenin nereye yerleşeceğine sen değil derleyici karar verir. Derleme sırasında yapılan bu çözümlemeye kaçış analizi (escape analysis) denir. Kural özünde basittir: Bir değerin adresi, onu oluşturan fonksiyonun ömründen daha uzun yaşayabilecek bir yere "kaçıyorsa" değer heap'e yerleşir. Adresin döndürülmesi, global bir değişkende saklanması, bir kanala gönderilmesi ya da nereye gideceği derleme anında bilinemeyen bir arayüz değerine konması tipik kaçış yollarıdır. Derleyici değerin kaçmadığını kanıtlayabilirse değer stack'te kalır.
Derleyicinin kararlarını -gcflags=-m bayrağıyla görebilirsin:
go build -gcflags=-m main.go# command-line-arguments ./main.go:11:6: can inline newUser ./main.go:17:6: can inline area ./main.go:23:14: inlining call to newUser ./main.go:24:33: inlining call to area ./main.go:24:13: inlining call to fmt.Println ./main.go:11:14: leaking param: name ./main.go:12:2: moved to heap: u ./main.go:24:13: ... argument does not escape ./main.go:24:15: u.Name escapes to heap ./main.go:24:23: u.Age escapes to heap ./main.go:24:33: ~r0 escapes to heap
Bu çıktı Go 1.26 ile üretildi; derleyici sürümüne göre satırlar biraz farklılaşabilir. Satırları tek tek okuyalım:
can inline newUserveinlining call to ...: Bu fonksiyonlar küçük olduğu için derleyici çağrıları, fonksiyonun gövdesini çağrıldığı yere yerleştirerek (inlining) kaldırabilir. Kaçış analizinden bağımsız bir optimizasyondur.moved to heap: u: Asıl aradığımız satır bu.u'nun adresireturn &uile fonksiyondan kaçtığı içinuheap'e taşındı.leaking param: name:nameparametresinin gösterdiği metin, heap'e taşınanu'nun içine yazıldığı için fonksiyonun dışına "sızıyor".u.Name escapes to heap:fmt.Printlnparametrelerinianytipinde alır. Değerler arayüze paketlenirken derleyiciPrintln'ın onları nerede tutacağını kanıtlayamaz ve kopyalarını heap'e koyar.~r0, satır içine yerleştirilenareaçağrısının dönüş değeri için derleyicinin kullandığı isimdir.areaiçinmoved to heapsatırı yok:resultfonksiyondan değer olarak döndüğü için stack'te kaldı.
İnlining satırları çıktıyı kalabalıklaştırıyorsa -gcflags='-m -l' ile satır içine yerleştirmeyi kapatabilir, -gcflags=-m=2 ile her kararın gerekçesini daha ayrıntılı görebilirsin. Arayüzlerin bellekte nasıl temsil edildiğini Arayüzler dersinde göreceksin.
İşaretçi aritmetiğinin olmaması
C'de bir dizinin ilk elemanının adresini aldıktan sonra p + 1 yazarak bir sonraki elemana geçebilirsin. Bu esnekliğin bedeli ağırdır: dizinin sonunu aşan bir işaretçi, programın başka bir verisinin üzerine sessizce yazabilir. Bellek güvenliği açıklarının önemli bir kısmı bu tür hatalardan doğar. Go bu kapıyı bilinçli olarak kapatır:
package main
import "fmt"
func main() {
nums := [3]int{10, 20, 30}
p := &nums[0]
p = p + 1 // C'de "bir sonraki eleman", Go'da derleme hatası
p++ // bu da derleme hatası
fmt.Println(*p)
}./main.go:8:6: invalid operation: p + 1 (mismatched types *int and untyped int) ./main.go:9:2: invalid operation: p++ (non-numeric type *int)
Bu kısıtın birkaç önemli faydası var:
- Bellek güvenliği: Bir işaretçi ya geçerli bir değişkeni ya da nil'i gösterir. Slice ve dizi erişimleri indeksle yapılır ve sınır kontrolünden geçer; sınırı aşarsan sessiz bir bozulma yerine anlaşılır bir panik alırsın.
- Çöp toplayıcı ile uyum: Çalışma zamanının hangi değerin işaretçi olduğunu ve neyi gösterdiğini kesin olarak bilmesi gerekir. Go, goroutine stack'leri büyürken onları başka bir yere kopyalar ve içlerini gösteren işaretçileri günceller; keyfi adres hesapları bunu güvenle yapmayı imkânsız kılardı.
- Okunabilirlik:
nums[i+1]niyeti*(p + 1)'den çok daha açık anlatır.
Elemanlar arasında gezinmek istediğinde indeks, slice ya da range kullan. Go'da bir unsafe paketi de vardır: unsafe.Pointer ve unsafe.Add gibi araçlarla işaretçi aritmetiği yapılabilir. Bu paket işletim sistemi çağrıları veya C kütüphaneleriyle etkileşim gibi çok alt seviye işler içindir. Paketin kendi belgeleri, unsafe kullanan paketlerin taşınabilir olmayabileceğini ve Go 1 uyumluluk güvencesinin kapsamında olmadığını açıkça belirtir. Uygulama kodunda ona ihtiyacın olmayacak.
İşaretçi ne zaman kullanılmalı?
İşaretçiyi öğrenen herkes bir süre her şeyi işaretçiyle geçirmeye heveslenir. Oysa Go'da çoğu zaman değer semantiği daha basit ve daha hızlıdır. Kararını şu sorulara göre ver:
"Büyük struct" için kesin bir sınır yoktur. Birkaç alanlı struct'ları değerle geçirmek genellikle sorun değildir; performanstan emin değilsen ölçmek her zaman tahminden iyidir. Bir de tutarlılık kuralı var: Bir tipin metotlarından biri işaretçi alıcı kullanıyorsa, genellikle tüm metotlarının işaretçi alıcı kullanması tercih edilir.
Slice ve map zaten referans benzeridir
slice, map ve kanallar kendi içlerinde alttaki veriye bir işaretçi taşır. Bu yüzden onları değer olarak geçirdiğinde bile fonksiyon aynı veriyi görür. Ama slice'ın uzunluğu ve kapasitesi başlığın (header) içinde durduğu için append gibi başlığı değiştiren işlemler çağırana yansımaz:
package main
import "fmt"
func modify(nums []int, ages map[string]int) {
nums[0] = 99 // alttaki diziyi değiştirir: çağıran görür
nums = append(nums, 4) // yalnızca yerel slice başlığını değiştirir
ages["Ali"] = 31 // map'i değiştirir: çağıran görür
}
func main() {
nums := []int{1, 2, 3}
ages := map[string]int{"Ali": 30}
modify(nums, ages)
fmt.Println(nums, len(nums))
fmt.Println(ages)
}[99 2 3] 3 map[Ali:31]
Bir fonksiyonun slice'ı büyütmesi gerekiyorsa *[]int almak yerine yeni slice'ı döndürmesi Go'da daha yaygındır. Yerleşik append fonksiyonu da tam olarak böyle çalışır: nums = append(nums, 4).
Opsiyonel değerler için işaretçi
Bazen sıfır değer geçerli bir değerdir ve "hiç belirtilmemiş" durumundan ayırt edilmesi gerekir. Örneğin yeniden deneme sayısı için 0 "hiç deneme" demekse, kullanıcının bu alanı hiç doldurmadığını nasıl anlarsın? *int burada iyi bir çözümdür ve Go 1.26'daki new(ifade) bu tür alanları doldurmayı çok kolaylaştırır:
package main
import "fmt"
type Options struct {
Retries *int // nil: belirtilmedi, 0: hiç deneme
Verbose bool
}
func retries(o Options) int {
if o.Retries == nil {
return 3 // varsayılan değer
}
return *o.Retries
}
func main() {
fmt.Println(retries(Options{}))
fmt.Println(retries(Options{Retries: new(0)}))
fmt.Println(retries(Options{Retries: new(5)}))
}3 0 5
Bu desen özellikle JSON gibi dış kaynaklardan gelen verilerde, bir alanın gönderilmediğini sıfır değerinden ayırmak için sık kullanılır. Bedeli ise her okumada nil kontrolü yapma zorunluluğudur; bu yüzden sıfır değerin zaten anlamlı bir varsayılan olduğu yerlerde düz değer kullanmak daha iyidir.
Sık yapılan hatalar
- nil işaretçiyi kontrol etmeden kullanmak. Özellikle "bulunamadı" anlamında nil döndüren fonksiyonların sonucunu ve doldurulmamış işaretçi alanlarını kullanmadan önce kontrol et.
rangedeğişkeninin adresini döndürmek.for _, u := range users { return &u }elemanın kendisini değil, döngünün yerel kopyasını gösterir; bu kopyayı değiştirmek slice'ı etkilemez. Elemanın kendisine işaretçi istiyorsanfor i := range usersile&users[i]kullan. (Go 1.22 öncesinde döngü değişkeni tüm turlarda ortak olduğu için bu hata daha da kafa karıştırıcıydı; artık her tur kendi değişkenini alıyor.)- Slice elemanına işaretçi tutarken
appendyapmak. Kapasite aşılırsa işaretçi eski diziyi göstermeye devam eder ve değişiklikler sessizce kaybolur. Büyüyen slice'larda indeks sakla. - İşaretçi parametresine atama yapmak.
p = ¥iDegeryalnızca yerel kopyayı değiştirir. Çağıranın göreceği bir değişiklik için*p = yeniDegeryaz. - İşaretçileri içerik karşılaştırması sanmak.
a == biki işaretçinin aynı adresi gösterip göstermediğini sorar. İçerikleri karşılaştırmak için*a == *byaz. - slice, map ve kanallar için gereksiz işaretçi kullanmak.
*map[string]intveya*[]Userçoğu zaman kodu karmaşıklaştırır; map'i değer olarak geçir, slice'ı büyütüyorsan döndür. - Aynı tipte değer ve işaretçi alıcıları gelişigüzel karıştırmak. Tip değiştirilebilir durum taşıyorsa metotlarını tutarlı olarak işaretçi alıcıyla yaz.
Alıştırmalar
sort3(a, b, c *int) fonksiyonunu yaz. Fonksiyon üç değişkenin değerlerini küçükten büyüğe sıralasın; yani çağrıdan sonra *a <= *b <= *c olsun. Derste yazdığımız swap fonksiyonunu yardımcı olarak kullanabilirsin.
Örnek: x, y, z := 9, 2, 5 için sort3(&x, &y, &z) çağrısından sonra x, y, z değerleri 2 5 9 olmalı.
İpucu
Üç karşılaştırma yeterli: önce a ile b'yi, sonra b ile c'yi, en sonda yeniden a ile b'yi karşılaştırıp gerekirse takas et. a, b ve c zaten işaretçi olduğu için swap'a &a değil a geçirmelisin.
Çözümü göster
package main
import "fmt"
func swap(a, b *int) {
*a, *b = *b, *a
}
// sort3, üç değişkeni küçükten büyüğe yerinde sıralar.
func sort3(a, b, c *int) {
if *a > *b {
swap(a, b)
}
if *b > *c {
swap(b, c) // en büyük değer artık c'de
}
if *a > *b {
swap(a, b)
}
}
func main() {
x, y, z := 9, 2, 5
sort3(&x, &y, &z)
fmt.Println(x, y, z)
x, y, z = 3, 3, 1
sort3(&x, &y, &z)
fmt.Println(x, y, z)
}2 5 9 1 3 3
İlk iki karşılaştırma, en büyük değeri kabarcık gibi c'ye taşır. Son karşılaştırma kalan iki değeri düzenler. sort3 içinde a, b ve c zaten *int tipinde olduğundan swap(a, b) doğrudan adresleri iletir; swap(&a, &b) yazsaydın tipler **int olurdu ve kod derlenmezdi.
Bir ekip arkadaşın aşağıdaki fonksiyonu yazmış. Fonksiyon derleniyor, bulduğu kullanıcının işaretçisini döndürüyor ama döndürülen işaretçi üzerinden yapılan değişiklikler slice'a yansımıyor:
func findUser(users []User, name string) *User {
for _, u := range users {
if u.Name == name {
return &u
}
}
return nil
}Hatanın nedenini açıkla ve fonksiyonu düzelt. Ardından main içinde "Ayşe"nin yaşını bir artır ve olmayan bir kullanıcı arandığında nil döndüğünü göster.
İpucu
range ile gelen u, slice elemanının bir kopyasıdır. Elemanın kendisinin adresine ihtiyacın var: indeks üzerinden dön.
Çözümü göster
package main
import "fmt"
type User struct {
Name string
Age int
}
// findUser, slice içindeki elemanın kendisine işaretçi döndürür.
func findUser(users []User, name string) *User {
for i := range users {
if users[i].Name == name {
return &users[i]
}
}
return nil
}
func main() {
users := []User{{Name: "Ali", Age: 30}, {Name: "Ayşe", Age: 25}}
if u := findUser(users, "Ayşe"); u != nil {
u.Age++
}
if u := findUser(users, "Mehmet"); u == nil {
fmt.Println("Mehmet bulunamadı")
}
fmt.Println(users)
}Mehmet bulunamadı
[{Ali 30} {Ayşe 26}]Hatalı sürümde u, her turda elemanın kopyalandığı yerel bir değişkendir; &u bu kopyanın adresidir. Kopya heap'e kaçar ve program çökmez, ama slice'taki asıl eleman hiç değişmez. Düzeltilmiş sürümde &users[i] doğrudan slice'ın alttaki dizisindeki elemanı gösterir. Dönüş değerinin nil olabileceği belgelenmeli ve çağıran taraf kontrol etmelidir.
Account struct'ı için transfer(from, to *Account, amount int) bool fonksiyonunu yaz. Fonksiyon başarılı olursa true döndürsün ve bakiyeleri güncellesin. Aşağıdaki durumlarda hiçbir bakiyeye dokunmadan false döndürsün:
- Hesaplardan biri nil ise,
fromvetoaynı hesabı gösteriyorsa,- Tutar pozitif değilse veya bakiye yetersizse.
İpucu
İki işaretçinin aynı hesabı gösterip göstermediğini from == to ile anlayabilirsin. Kontrollerin hepsini bakiyeleri değiştirmeden önce yap.
Çözümü göster
package main
import "fmt"
type Account struct {
Owner string
Balance int
}
// transfer, from hesabından to hesabına amount kadar para aktarır.
func transfer(from, to *Account, amount int) bool {
if from == nil || to == nil || from == to {
return false
}
if amount <= 0 || from.Balance < amount {
return false
}
from.Balance -= amount
to.Balance += amount
return true
}
func main() {
a := &Account{Owner: "Deniz", Balance: 100}
b := &Account{Owner: "Ece", Balance: 20}
fmt.Println(transfer(a, b, 30), a.Balance, b.Balance)
fmt.Println(transfer(a, b, 500), a.Balance, b.Balance)
fmt.Println(transfer(a, a, 10), a.Balance)
fmt.Println(transfer(nil, b, 10), b.Balance)
}true 70 50 false 70 50 false 70 false 50
from == to karşılaştırması içerikleri değil adresleri karşılaştırır; aynı sahip ve bakiyeye sahip iki farklı hesap bu kontrole takılmaz, yalnızca gerçekten aynı hesap takılır. Bütün doğrulamaların değişikliklerden önce yapılması, fonksiyonun ya tamamen başarılı olmasını ya da hiçbir şeyi değiştirmemesini garanti eder.
Aşağıdaki Node tipiyle kurulan tek yönlü bir bağlı listeyi, yeni düğüm oluşturmadan yerinde ters çeviren reverse(head *Node) *Node fonksiyonunu yaz. Fonksiyon yeni baş düğümü döndürsün ve boş liste (nil) için de doğru çalışsın.
type Node struct {
Value int
Next *Node
}Örnek: 1 -> 2 -> 3 -> 4 -> nil listesi 4 -> 3 -> 2 -> 1 -> nil olmalı.
İpucu
Üç işaretçiyle yürü: prev (başta nil), curr (başta head) ve next. Her adımda önce curr.Next'i next'e kaydet, sonra curr.Next = prev ile bağlantıyı ters çevir ve prev ile curr'ü birer adım ilerlet.
başlangıç: prev=nil curr=1 -> 2 -> 3 -> nil
1. adım: nil <- 1 prev=1 curr=2 -> 3 -> nil
2. adım: nil <- 1 <- 2 prev=2 curr=3 -> nil
3. adım: nil <- 1 <- 2 <- 3 prev=3 curr=nil (bitti, yeni baş = prev)Çözümü göster
package main
import (
"fmt"
"strings"
)
type Node struct {
Value int
Next *Node
}
// fromSlice, verilen değerlerden bir bağlı liste kurar.
func fromSlice(values []int) *Node {
var head *Node
for i := len(values) - 1; i >= 0; i-- {
head = &Node{Value: values[i], Next: head}
}
return head
}
// format, listeyi "1 -> 2 -> nil" biçiminde yazıya döker.
func format(head *Node) string {
var sb strings.Builder
for curr := head; curr != nil; curr = curr.Next {
fmt.Fprintf(&sb, "%d -> ", curr.Value)
}
sb.WriteString("nil")
return sb.String()
}
// reverse, listeyi yerinde ters çevirir ve yeni başı döndürür.
func reverse(head *Node) *Node {
var prev *Node
curr := head
for curr != nil {
next := curr.Next // sonraki düğümü kaybetmemek için sakla
curr.Next = prev // bağlantıyı ters çevir
prev = curr // prev bir adım ilerler
curr = next // curr bir adım ilerler
}
return prev
}
func main() {
list := fromSlice([]int{1, 2, 3, 4})
fmt.Println(format(list))
list = reverse(list)
fmt.Println(format(list))
fmt.Println(format(reverse(nil)))
}1 -> 2 -> 3 -> 4 -> nil 4 -> 3 -> 2 -> 1 -> nil nil
Her düğümü tam bir kez ziyaret ettiğimiz için zaman karmaşıklığı O(n), yalnızca üç işaretçi kullandığımız için ek alan karmaşıklığı O(1)'dir. Kritik satır next := curr.Next: Bu kaydı yapmadan curr.Next = prev yazarsan listenin geri kalanına giden tek bağlantıyı kaybedersin. Boş listede döngü hiç çalışmaz ve prev nil olarak döner. Bağlı liste işlemlerinin tamamını Bağlı Listeler dersinde bulabilirsin.
Kısa sınav
x := 5; p := &x; *p += 10 satırlarından sonra fmt.Println(x) ne yazdırır?
func reset(p *int) { p = nil } tanımlıyken x := 7; p := &x; reset(p); fmt.Println(p == nil, x) ne yazdırır?
var u *User tanımlandıktan hemen sonra fmt.Println(u.Name) çalıştırılırsa ne olur?
Bir fonksiyonun yerel değişkeninin adresini döndürmesi (return &u) için hangisi doğrudur?
Aşağıdakilerden hangisini bir fonksiyona işaretçi olarak geçirmek genellikle gereksizdir?
a := &Point{1, 2}; b := &Point{1, 2} için fmt.Println(a == b, *a == *b) ne yazdırır?
Özet
- İşaretçi bir değerin kendisini değil, bellekteki adresini tutar.
&xadresi verir,*po adresteki değere ulaşır;*Tise "T'ye işaretçi" tipidir. - Go'da her şey değer ile geçirilir; işaretçinin kendisi de. Bir fonksiyonun çağıranın verisini değiştirebilmesi, adresin kopyasının da aynı yeri göstermesinden gelir.
- İşaretçilerin sıfır değeri
nil'dir. nil işaretçiyi çözmek çalışma zamanında panik oluşturur; nil kontrolünü verinin geldiği sınırlarda yap. new(T)sıfır değerli yeni bir değişkenin adresini döndürür; Go 1.26 ilenew(30)gibi bir ilk değer de verilebilir. Struct'lar için&T{...}daha yaygındır.- Struct işaretçilerinde
p.Fieldyazımı(*p).Fielddemektir; metot çağrılarında da Go adres alma ve çözmeyi otomatik yapar. - Yerel değişkenin adresini döndürmek güvenlidir: Kaçış analizi değişkeni gerekirse heap'e taşır. Kararları
go build -gcflags=-mile görebilirsin;newheap,varstack demek değildir. - Go'da işaretçi aritmetiği yoktur; bu bellek güvenliği ve çöp toplayıcı için bilinçli bir tercihtir. Elemanlar arasında indeks ve slice ile gezin.
- İşaretçiyi değişiklik, büyük struct, opsiyonel değer ve kopyalanmaması gereken tipler için kullan; küçük değerler ile slice, map ve kanallar için değer semantiği genellikle daha iyidir.