go
Go dersleri
Go/Tipler ve Soyutlama

İşaretçiler (Pointers)

Bellek adresleri, & ve * operatörleri, nil ve işaretçi ne zaman kullanılır.

Ders 9 / 2120 dkBaşlangıç
Bu derste öğreneceklerin
  • & ve * operatörleri
  • new fonksiyonu
  • nil işaretçiler ve panik
  • Fonksiyonlara işaretçi geçirme
  • Struct işaretçileri ve otomatik dereference
  • Kaçış analizi (escape analysis): stack ve heap
  • İşaretçi aritmetiğinin olmaması

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      |      ...     |      ...     |
          +--------------+--------------+--------------+
                 ^
                 x

Buradaki 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:

main.go
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)
}
Çıktı
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:

main.go
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)
}
Çıktı
42
100
101
true

Adım adım bakalım:

  1. p := &x satırından sonra p, x'in kutusunu gösteriyor.
  2. fmt.Println(*p) o kutudaki değeri okuyor: 42.
  3. *p = 100 o kutuya 100 yazıyor. Kutu x'in kutusu olduğu için x artık 100.
  4. x++ aynı kutuyu bu kez x adıyla değiştiriyor; *p de 101 görüyor.
  5. p == &x iki 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ğer

x 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

İfadeTipiAnlamı
xintDeğerin kendisi
&x*intx'in adresi
p*intBir int'in adresini tutan değişken
*pintp'nin gösterdiği değer
&p**intp'nin kendi adresi (işaretçiye işaretçi)

İş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.

main.go
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)
}
Çıktı
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.

main.go
package main

import "fmt"

func main() {
	p := &42 // sabitin adresi olmaz
	fmt.Println(p)
}
Çıktı
./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.

YazımSonuçNe zaman
new(T)Sıfır değerli T'ye işaretçiSıfır değer yeterliyse
&T{...}Alanları doldurulmuş struct'a işaretçiStruct, dizi, slice, map literalleri
new(ifade)İfadenin değeriyle başlatılmış değişkene işaretçiGo 1.26+, özellikle *int, *string gibi temel tipler

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:

main.go
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
}
Çıktı
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:

main.go
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())
}
Çıktı
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:

main.go
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)
}
Çıktı
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 = 1

Burada 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:

main.go
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)
}
Çıktı
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.

main.go
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)
}
Çıktı
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.Balance

Metot ç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.

main.go
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())
}
Çıktı
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.

main.go
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)
}
Çıktı
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:

main.go
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))
}
Çıktı
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
Çıktı
# 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 newUser ve inlining 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 adresi return &u ile fonksiyondan kaçtığı için u heap'e taşındı.
  • leaking param: name: name parametresinin gösterdiği metin, heap'e taşınan u'nun içine yazıldığı için fonksiyonun dışına "sızıyor".
  • u.Name escapes to heap: fmt.Println parametrelerini any tipinde alır. Değerler arayüze paketlenirken derleyici Println'ın onları nerede tutacağını kanıtlayamaz ve kopyalarını heap'e koyar. ~r0, satır içine yerleştirilen area çağrısının dönüş değeri için derleyicinin kullandığı isimdir.
  • area için moved to heap satırı yok: result fonksiyondan 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:

main.go
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)
}
Çıktı
./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:

DurumTercihNeden
Fonksiyonun argümanı değiştirmesi gerekiyor*TKopya üzerinde yapılan değişiklik kaybolur
Büyük struct sık sık geçiriliyor*THer çağrıda tüm alanların kopyalanmasını önler
"Değer yok" ile sıfır değeri ayırt etmek gerekiyor*Tnil "belirtilmemiş" anlamı taşıyabilir
Kopyalanmaması gereken tip (ör. sync.Mutex içeren struct)*TKopya ayrı bir kilide dönüşür; go vet bunu uyarır
int, bool, Point{X, Y} gibi küçük değerlerTKopyalamak ucuzdur; nil riski ve heap tahsisi yoktur
slice, map, kanal, fonksiyonTAlttaki veriyi zaten paylaşırlar
Arayüz değerleriT*io.Reader gibi arayüze işaretçi neredeyse hiç gerekmez

"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:

main.go
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)
}
Çıktı
[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:

main.go
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)}))
}
Çıktı
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.
  • range değ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 istiyorsan for i := range users ile &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 append yapmak. 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 = &yeniDeger yalnızca yerel kopyayı değiştirir. Çağıranın göreceği bir değişiklik için *p = yeniDeger yaz.
  • İşaretçileri içerik karşılaştırması sanmak. a == b iki işaretçinin aynı adresi gösterip göstermediğini sorar. İçerikleri karşılaştırmak için *a == *b yaz.
  • slice, map ve kanallar için gereksiz işaretçi kullanmak. *map[string]int veya *[]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

Alıştırma·Üç sayıyı yerinde sırala
Kolay

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
main.go
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)
}
Çıktı
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.

Alıştırma·Kullanıcıyı bul ve güncelle
Orta

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
main.go
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)
}
Çıktı
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.

Alıştırma·Hesaplar arası güvenli transfer
Orta

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,
  • from ve to aynı 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
main.go
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)
}
Çıktı
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.

Alıştırma·Bağlı listeyi yerinde ters çevir
Zor

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
main.go
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)))
}
Çıktı
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

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. &x adresi verir, *p o adresteki değere ulaşır; *T ise "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 ile new(30) gibi bir ilk değer de verilebilir. Struct'lar için &T{...} daha yaygındır.
  • Struct işaretçilerinde p.Field yazımı (*p).Field demektir; 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=-m ile görebilirsin; new heap, var stack 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.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.