BLOG

Go'da döngü kullanırken dikkat edilmesi gerekenler

27 Tem 2022
#go#döngü#for#alloc

Bu yazıda elimden geldikçe, Go’da listeler (arrays, slices) üzerinde döngü kullanırken dikkatli olmamız gereken noktaları anlatmaya çalışacağım. Keyifli okumalar.

Yazının sonunda konuyu pekiştirebilmek için bir demo hazırladım. Denemeyi unutmayın.


Direkt olarak aşağıdaki gibi bir kod örneği ile başlayalım.

go
cities := []string{
    "İstanbul",
    "Ankara",
    "İzmir",
    "Bursa",
}

fmt.Println("cities:")

for i, v := range cities {
    fmt.Println(i, v)
    cityPointers = append(cityPointers, &v)
}

Çıktımız gayet beklendiği gibi olacaktır.

Çıktı
cities:
0 İstanbul
1 Ankara
2 İzmir
3 Bursa

Bu kod örneğine ufak bir kaç ekleme yapalım.

go
cities := []string{
  "İstanbul",
  "Ankara",
  "İzmir",
  "Bursa",
}

cityPointers := make([]*string, 0, len(cities))

fmt.Println("cities:")

for i, v := range cities {
    fmt.Println(i, v)
    cityPointers = append(cityPointers, &v)
}

fmt.Println("cityPointers:")

for i, v := range cityPointers {
    fmt.Println(i, *v)
}

Devam etmeden önce, yukarıdaki kodun nasıl bir çıktı vereceğini düşünün.

Şimdi de çıktısını görelim.

Çıktı
cities:
0 İstanbul
1 Ankara
2 İzmir
3 Bursa
cityPointers:
0 Bursa
1 Bursa
2 Bursa
3 Bursa

Kodlarımızı çalıştırdığımızda yukarıdaki gibi bir sonuç veriyor. İki listenin döndürülüp yazdırıldığında aynı çıktıyı vereceğini düşünüyorsanız, yanılıyorsunuz. Çünkü işler pek öyle değil.

Peki ilk döngüde v değişkeninin değeri farklı şehir isimleri halinde geliyor da, citiesPointer‘da eklediğimizde neden hepsi Bursa değerinde oluyor.

İlk olarak v olarak isimlendirdiğimiz döngü değişkeninin aslında nasıl çalıştığını anlayalım.

for range kullanarak döndürme işleminin hangi indekste olduğu (i) ve döndürülen indekste bulunan elemanın değerine (v) ulaşabiliyoruz.

Oluşturulan i ve v değişkenlerinin değerlerinin bellekte saklanabilmesi için bir tahsis (malloc) işlemi yapılıyor -yani ram’de yer ayrılıyor. Bu tahsis işleminin döngünün her adımında yeniden yapılması yerine, sadece ilk seferde yapılarak, bütün adımlarda ayrılan bu adresler üzerinde değerler değiştirilerek yapılıyor.

Daha açıklayıcı olması için bir kod örneği görelim.

go
cities := []string{
    "İstanbul",
    "Ankara",
    "İzmir",
    "Bursa",
}

for i, v := range cities {
    fmt.Println(&i, i, &v, v)
}

Çıktımızda aşağıdaki gibi;

Çıktı
0x14000122008 0 0x1400010a230 İstanbul
0x14000122008 1 0x1400010a230 Ankara
0x14000122008 2 0x1400010a230 İzmir
0x14000122008 3 0x1400010a230 Bursa

Çıktıda görebileceğimiz gibi, döngünün her bir adımında i ve v için bellekte alan tahsis edilmesi yerine sadece bir defa alan tahsis ediliyor ve her bir adımda bu alandaki değer güncelleniyor.

cityPointers listemize append (ekleme) yapıyorken &v şeklinde ekleyerek aslında v‘nin bellekteki adresini ekledik.

Döngünün son adımında v‘nin son değeri "Bursa" olarak belirlendiği için, bu adresteki değerde bu şekilde kaldı.

cityPointers listesindeki tüm elemanlar aynı adresi işaret ettiği için hepsinde aynı değeri, yani "Bursa"‘yı görmekteyiz.

Eğer cities içerisindeki elemanları cityPointers listesine farklı değişkenler olarak eklemek isteseydik aşağıdaki gibi ufak bir değişiklik yapmamız yeterli olurdu.

go
for i, v := range cities {
    fmt.Println(i, v)

    c:= v
    cityPointers = append(cityPointers, &c)
}

v değişkenini kullanarak c isminde yeni bir değişken oluşturduk. Bu tahsis işlemi her adımda yeniden gerçekleşeceği için, her adımda yeni bir c değişkeni oluşturulacaktır, bu yüzden oluşturulan her c değişkenin bellekteki adresi farklı olacaktır.

Her döngü adımında i, v ve c değişkenlerinin adreslerini yazdırdığımızda aşağıdaki gibi c‘nin farklı adresleri gösterdiğini görebiliriz.

go
for i, v := range cities {
    c := v

    fmt.Println("i:", &i, "-v:", &v, "-c:", &c)
    cityPointers = append(cityPointers, &c)
}

Çıktımızı görelim:

Çıktı
i: 0x14000122010 -v: 0x1400010a230 -c: 0x1400010a240
i: 0x14000122010 -v: 0x1400010a230 -c: 0x1400010a250
i: 0x14000122010 -v: 0x1400010a230 -c: 0x1400010a260
i: 0x14000122010 -v: 0x1400010a230 -c: 0x1400010a270

Bu duruma dikkat ederek cityPointers listesinin elemanlarını yazdıralım.

go
for i, v := range cityPointers {
    fmt.Println(i, *v)
}

Çıktımızı görelim:

Çıktı
0 İstanbul
1 Ankara
2 İzmir
3 Bursa

Yukarıda bahsedilen senaryodaki yapabileceğimiz dikkatsizliğin, veri bütünlüğünün bozulmasıyla sonuçlanacağı apaçık ortada.

Bahsedilen bu senaryo aslında bir ısınma ve sebebini anlama aşamasıydı.


Senaryoyu bir tık daha evirelim ve neler ile sonuçlanacağını görelim.

Verilen pointer’lı bir değişken ile işlem yapan aşağıdaki gibi bir fonksiyonumuz olduğunu varsayalım.

go
func doSomething(v *string)  {
    for i := 0; i < 5; i++ {
        fmt.Println(*v)
    }
}

Döngünün her adımında go routine kullanarak eş zamanlı olarak bu fonksiyonu kullanalım.

go
cities := []string{
    "İstanbul",
    "Ankara",
    "İzmir",
    "Bursa",
}

fmt.Println("cities:")

for _, v := range cities {
    go doSomething(&v)
}

// go routineleri görebilmek için programı bekletelim
time.Sleep(time.Second * 10)

Çalıştırdığımız zaman tüm işlemlerdeki değerlerin Bursa olduğunu göreceğiz.

Bunun olmaması için, döngü içerisinde aynı şekilde;

go
for _, v := range cities {
    c := v
    go doSomething(&c)
}

şeklinde kullanmamız mantıklı olurdu.

Eğer anonim fonksiyon kullansaydık, iki farklı şekilde bunu yapabilirdik.

go
for _, v := range cities {
    c := v

    go func() {
        doSomething(&c)
    }()
}

veya

go
for _, v := range cities {
    go func(c string) {
        doSomething(&c)
    }(v)
}

kullanarak sorunumuzu çözebilirdik. Tabi sizin için bir sorunsa. İkinci anonim fonksiyon örneğinde parametre olarak c isimli bir string tipinde değer aldığından, zaten orijinali yerine v değerini kopyaladığı için değişkenin adresi farklı olmaktadır.

Performans önerisi

Eğer daha performanslı bir döngü istiyorsak, i değişkeni de her şartta kullanılacaksa, v değişkenini kullanmadan direkt olarak indeks ile liste üzerinden veriyi alabiliriz.

Örnek olarak;

go
for i := range cities {
    fmt.Println(cities[i])
    // i değişkeninin kullanılacağı diğer işlemler...
}

Bu sayede v kullanmayarak tek seferlik tahsis, cities listesinin uzunluğu kadar v değişkenine atama ve gc maliyetinden tasarruf ettik.

Tabi bu önerinin farklı bir senaryoda gereksiz veya mantıksız olabileceğini aklımızda bulundurmalıyız.

Go 1.21.0 versiyonu ile gelen ortam değişkeni

Go’ya yapılan 1.21.0 güncellemesi ile döngümüzde kullandığımız değer değişkeninin (örnekte v dedik) her döngü adımında bellekten yeni adres alması için bir ortam değişkeni (env) eklemesi getirildi. Bu ortam değişkeni varsayılan olarak kullanılmıyor. O yüzden eski döngü mantığımız hala daha geçerlidir.

Aşağıdaki şekilde kodumuzu çalıştırırken veya derlerken ortam değişkeni vermemiz yeterli:

terminal
GOEXPERIMENT=loopvar go run .

İlk örneğimizi bu ortam değişkeni kullanılıyorken çalıştıralım.

İlk örneğimizi hatırlamak için görelim.

go
cities := []string{
  "İstanbul",
  "Ankara",
  "İzmir",
  "Bursa",
}

cityPointers := make([]*string, 0, len(cities))

fmt.Println("cities:")

for i, v := range cities {
    fmt.Println(i, v)
    cityPointers = append(cityPointers, &v)
}

fmt.Println("cityPointers:")

for i, v := range cityPointers {
    fmt.Println(i, *v)
}

Bahsettiğimiz ortam değişkeni ile çalıştırdığımızda aşağıdaki gibi çıktı verecektir.

Çıktı
cities:
0 İstanbul
1 Ankara
2 İzmir
3 Bursa
cityPointers:
0 İstanbul
1 Ankara
2 İzmir
3 Bursa

Aşağıdaki demo ile interaktif olarak deneyebilirsiniz.

Demo

*Aynı renkler aynı bellek adreslerini temsil eder
cities
"İstanbul"
"Ankara"
"İzmir"
"Bursa"
Döngü
for
i = 0
v = "İstanbul"
:= range cities {
cityPtrs = append(cityPtrs,
&v
)
}
cityPtrs
" İstanbul"
*Listeye eklenen her eleman v'nin adresindeki değere eşittir

Kaan Kuscu

Backend Developer

İletişim kurmak için İletişim sayfasını ziyaret edebilirsiniz.