3- Maps, Arrays, and Slices | Go Öğreniyorum

16 Mar 2020 · 19 dk okuma süresi

Şimdiye kadarki derslerde bazı basit tip ve yapıları gördük. Şimdi ise array, slice ve map yapılarına değineceğiz.

Arrays

Eğer; Python, Ruby, Perl, JavaScript veya PHP gibi dillere aşinaysanız, muhtemelen dinamik dizileri kullanarak programlama yaptınız. Bu dinamik diziler yeni veri eklendikçe kendini yeniden boyutlandırır. Birçok diğer dilde olduğu gibi Go’da da diziler sabittir. Bir dizi bildirimi yaparken boyutunu da bildirmemiz gerekir ve boyutu bildirildikten sonra dizi genişletilemez.

var scores [10]int
scores[0] = 339

Yukarıdaki dizi ilk eleman scores[0] ve son eleman scores[9] olmak üzere 10 tane skor bilgisini tutabilir. Dizinin boyutunu aşan bir aralığa erişmeye çalışmak, derleyici veya çalışma zamanı hatasına sebep olur.

Dizileri değer vererek de oluşturabiliriz:

scores := [4]int{9001, 9333, 212, 33}

len fonksiyonunu kullanarak dizi boyutunu getirebiliriz. range ile aşağıdaki gibi diziyi baştan sonra tarayabiliriz:

for index, value := range scores {

}

Diziler verimli ama katıdır. Genellikle kaç tane elemana ihtiyaç duyacağımızı bilemeyiz. Bu nedenle, slice yani dilim kullanmaya ihtiyaç duyarız.

Slices

Go’da, dizileri doğrudan kullanmamız nadirdir. Onun yerine, dilimleri (slices) kullanırız. Dilimler, dizilerin bir bölünümünü baz alan ve temsil eden hafif yapılardır. Dilim oluşturmanın birkaç yolu mevcut, hangi yolu ne zaman kullanacağımızı daha sonra göreceğiz. İlk göreceğimiz yol, dizi oluşturmaya oldukça benzeyen bir yöntem:

scores := []int{1,4,293,4,9}

Dizi tanımlamasının aksine, dilimizi tanımlarken köşeli parantezler arasında boyutunu belirlemedik. Her ikisinin farkını daha iyi anlamak için bir diğer yöntem olan make ifadesi ile tanımlamayı görelim:

scores := make([]int, 10)

Burada new ifadesi yerine make kullandık, çünkü dilim oluşturmak, new ifadesi ile yapılan RAM’de bir alan ayırma işleminden daha fazlasıdır. Bir dilimi oluşturmak için temel olarak ihtiyacımız olan şey, baz alınan dizi için hafızada alan ayırmak ve dilimi oluşturmaktır. Üstteki kod bloğunda boyutu ve kapasitesi 10 olan bir dilim(slice) oluşturduk. Uzunluk, dilimin boyutudur; kapasite, dilimimiz için baz alınan dizinin boyutudur. make ifadesi ile hem dilimin uzunluğunu hem de kapasitesini ayrı ayrı belirleyebiliriz:

scores := make([]int, 0, 10)

Bu kod, uzunluğu 0, kapasitesi 10 olan bir dilim oluşturur.

Uzunluk ve kapasitesi arasındaki etkileşimi daha iyi anlamak için birkaç örneğe göz atalım:

func main() {
  scores := make([]int, 0, 10)
  scores[7] = 9033
  fmt.Println(scores)
}

Bu ilk örneğimiz hataya sebep olur. Peki neden? Çünkü dilimimizin uzunluğu 0. Evet, baz alınan dizinin boyutu 10, ancak bu elemanlara erişebilmemiz için dilimimizi açıkça genişletmeliyiz. Bir dilimi genişletmek için kullanılan bir yöntem append ifadesidir:

func main() {
  scores := make([]int, 0, 10)
  scores = append(scores, 5)
  fmt.Println(scores) // Çıktı: [5]
}

Ancak, bu kod orjinal kodumuzun amacını değiştiriyor. append ile uzunluğu 0 olan dilime ilk eleman eklenmiş oluyor. Üstte, 7. indisin değerini değiştirmek isterken hataya sebep olan kod parçacağı, hataya sebep olmadan bu işlemi yapabilsin diye dilimi yeniden bölümlendirebiliriz:

func main() {
  scores := make([]int, 0, 10)
  scores = scores[0:8]
  scores[7] = 9033
  fmt.Println(scores)
}

Bir dilimi ne kadar büyütebiliriz? Bu dilimin kapasitesine bağlıdır, bu örnekte kapasitemiz 10, şimdi diyebilirsiniz ki; dilimler, dizilerde bulunan ön tanımlı boyut problemini tam anlamıyla çözmüyor. Ancak, işte tam bu sırada append fonksiyonunun sihri devreye giriyor. Eğer baz alınan dizi dolarsa, append fonksiyonu daha büyük bir dizi oluşturur ve verileri oraya kopyalar (PHP, Python, Ruby, JavaScript gibi dillerdeki dinamik dizilerde olduğu gibi). İşte, bu nedenle append kullandığımız örnek kodlarda, append fonksiyonundan dönen değeri scores değişkenine atadık. Çünkü, append fonksiyonu geçerli kaynağımızda yeterli alan yoksa yeni bir kaynak oluşturacaktır.

Size Go’nun, dizileri 2x algoritması kullanarak büyüttüğünü söylesem aşağıdaki kodun çıktısını tahmin edebilir misiniz?

func main() {
  scores := make([]int, 0, 5)
  c := cap(scores)
  fmt.Println(c)
  
  for i := 0; i < 25; i++ {
    scores = append(scores, i)
    // Eğer kapasitemiz değişirse,
    // Go yeni verileri eklemek için dizimizi büyütmek zorunda kalır
    if cap(scores) != c {
      c = cap(scores)
      fmt.Println(c)
    }
  }
}

scores değişkeninin başlangıç kapasitesi 5. Kapasitenin üstündeki 20 değeri tutulabilmesi için kapasite sırasıyla, 10, 20 ve 40 olmak üzere 3 kere genişletilmek zorundadır.

Son örnek olarak şöyle bir kod yazdığınızı varsayalım:

func main() {
  scores := make([]int, 5)
  scores = append(scores, 9332)
  fmt.Println(scores)
}

Bu kodun çıktısı [0, 0, 0, 0, 0, 9332] olacaktır. Belki çıktının [9332, 0, 0, 0, 0] şeklinde düşünmüş olabilirsiniz. İnsan mantığıyla baktığımızda ikinci çıktı mantıklı görünebilir. Ancak derleyiciye göre, 5 tane elemanı tutan bir dilime yeni bir eleman eklenmesi istenmiştir.

Bir dilim tanımlamak için yaygın olarak kullanılan dört yöntem vardır:

names := []string{"leto", "jessica", "paul"}
checks := make([]bool, 10)
var names []string
scores := make([]int, 0, 20)

Hangisini, hangi durumda kullanmalıyız? İlk satırdaki kullanım pek bir açıklama gerektirmiyor aslında, değerlerini önceden bildiğiniz diziler için kullanırsınız.

İkinci satırdaki kullanım, dilimin belirli bir indisine atama yapacağınız durumlarda kullanışlıdır. Örneğin:

func extractPowers(saiyans []*Saiyans) []int {
  powers := make([]int, len(saiyans))
  for index, saiyan := range saiyans {
    powers[index] = saiyan.Power
  }
  return powers
}

Üçüncü satırdaki kullanım, başlangıç değeri boş yani nil olan dilim tanımlamasıdır ve eleman sayısının bilinmediği durumlarda append ile birlikte kullanılır.

Son satırdaki kullanım ise; başlangıç kapasitesi belirtmemize olanak sağlar, kaç tane elemana ihtiyacımız olduğuna dair genel bir fikre sahip olduğumuz zamanlarda kullanışlıdır.

append ifadesini, tam olarak kaç elemana ihtiyaç duyacağınızı bilseniz dahi kullanabilirsiniz. Bu büyük ölçüde bir tercih meselesidir:

func extractPowers(saiyans []*Saiyans) []int {
  powers := make([]int, 0, len(saiyans))
  for _, saiyan := range saiyans {
    powers = append(powers, saiyan.Power)
  }
  return powers
}

Dilimler(slices), dizilerin sarmalayıcısı olmasından dolayı güçlü bir konsepttir. Ayrıca, Ruby’de [BAŞLANGIÇ..BİTİŞ] veya Python’da [BAŞLANGIÇ:BİTİŞ] şeklinde dilimleri alabilirsiniz. Ancak, bu dillerde dilimler; varolan bir diziden ilgili değerleri kopyalayarak, yeni oluşturulan bir dizidir. Ruby’de aşağıdaki kodun çıktısı ne olur?

scores = [1,2,3,4,5]
slice = scores[2..4]
slice[0] = 999
puts scores

Kodun çıktısı [1, 2, 3, 4, 5] olur. Çünkü slice değerlerin kopyasından oluşturulan tamamen yeni bir dizidir. Şimdi aynı kodun Go için eşdeğerini yazalım:

scores := []int{1,2,3,4,5}
slice := scores[2:4]
slice[0] = 999
fmt.Println(scores)

Bu kodun çıktısı ise [1, 2, 999, 4, 5] olur.

Bu, kodlama alışkanlığınızı değiştirir. Örneğin, bazı fonksiyonlar bir tane pozisyon parametresi alır. JavaScript’te, eğer bir dizgideki ilk 5 karakterden sonraki ilk boşluk karakterini bulmak istersek, şu kodu yazarız:

haystack = "the spice must flow";
console.log(haystack.indexOf(" ", 5));

Go’da ise dilimleri kullanırız:

strings.Index(haystack[5:], " ")

Üstteki örnekten de görebileceğimiz gibi, [X:] ifadesi, X’ten sona kadar; [:X] ifadesi ise baştan X’e kadar anlamında bir kısayoldur. Diğer dillerin aksine Go, negatif değerleri desteklemiyor. Eğer Go’da, sondaki değer hariç tüm değerleri almak istiyorsak, bu şekilde yaparız:

scores := []int{1, 2, 3, 4,  5}
scores = scores[:len(scores)-1]

Ayrıca üstteki kod, bütün haldeki bir dilimden, bir değer silmenin en verimli yöntemin temelini oluşturmaktadır:

func main() {
  scores := []int{1, 2, 3, 4, 5}
  scores = removeAtIndex(scores, 2)
  fmt.Println(scores)
}

func removeAtIndex(source []int, index int) []int {
  lastIndex := len(source) - 1
  //swap the last value and the value we want to remove
  source[index], source[lastIndex] = source[lastIndex], source[index]
  return source[:lastIndex]
}

Sonunda dilimler hakkında fikir sahibi olduk, şimdi ise bir başka sıkça kullanılan dahili copy fonksiyonuna değinebiliriz. copy fonksiyonu, dilimlerin kodlama alışkanlıklarımızı nasıl değiştirdiğini vurgulayan fonksiyonlardan birisidir. copy fonksiyonu normalde, bir diziden bir başka diziye kopyalama yapmak için; source, sourceStart, count, destination ve destinationStart olmak üzere 5 tane parametreye ihtiyaç duyar, dilimleri kullanınca sadece 2 tane yeterli oluyor.

import (
  "fmt"
  "math/rand"
  "sort"
)

func main() {
  scores := make([]int, 100)
  for i := 0; i < 100; i++ {
    scores[i] = int(rand.Int31n(1000))
  }
  sort.Ints(scores)
  
  worst := make([]int, 5)
  copy(worst, scores[:5])
  fmt.Println(worst)
}

Üstteki kodla pratik yapmak için kendinize zaman ayırın. Varyasyonları deneyin. Üstteki koddaki kopyalama işlemini; copy(worst[2:4], scores[:5]) şeklinde veya worst içerisine 5 değerinden daha az ya da daha çok değeri kopyalayacak şekilde değiştirin ve neler olacağını gözlemleyin.

Maps

Diğer dillerdeki hastables ve dictionaries yapılarının karşılığı Go’da maps yani map‘lerdir. Çalışma mantığı da beklediğiniz gibi: anahtar ve değer çiftinden oluşan bu yapı ile değerleri alabilir, ayarlayabilir veya silebilirsiniz.

Map‘ler, dilimler gibi make fonksiyonu ile oluşturulur. Şu örneğe bakalım:

func main() {
  lookup := make(map[string]int)
  lookup["goku"] = 9001
  power, exists := lookup["vegeta"]

  // Çıktı: 0, false
  // 0 integer tipi için varsayılan değerdir
  fmt.Println(power, exists)
}

Anahtarların sayısını almak için len fonksiyonunu kullanırız. Bir değeri anahtarını baz alarak silmek için delete fonsksiyonunu kullanırız:

// 1 döndürür
total := len(lookup)

// geri dönüş değeri yoktur, parametre olarak var olmayan anahtarı da belirtebiliriz
delete(lookup, "goku")

Map‘ler dinamik olarak büyür. Ancak, make için ikinci bir parametre vererek başlangıç boyutu belirlenebilir:

lookup := make(map[string]int, 100)

Eğer map‘inizin kaç tane anahtar içereceği hakkında fikriniz varsa, başlangıç boyutu belirtmek performans açısından faydalı olabilir.

Map‘i, bir yapının alanı olarak kullanmanız gerektiğinde, bu şekilde tanımlarsınız:

type Saiyan struct {
  Name string
  Friends map[string]*Saiyan
}

Yukarıdaki tanımlanan tipe şu şekilde atama yapabiliriz:

goku := &Saiyan{
  Name: "Goku",
  Friends: make(map[string]*Saiyan),
}
goku.Friends["krillin"] = ... // Krillin'in değerini alın veya oluşturun

Go’da, tanımlama ve atama yapmanın bir başka yolu daha var. make fonksiyonu gibi bu yaklaşım da map‘lere ve dizilere özgüdür:

lookup := map[string]int{
  "goku": 9001,
  "gohan": 2044,
}

Map üzerinde range anahtar kelimesini de kullanarak, for döngüsü ile gezinebiliriz:

for key, value := range lookup {
  ...
}

Map‘lerin üzerinde gezinme sıralı değildir. Her gezinme işleminde anahtar-değer çifti rastgele sırada gelecektir.

Pointers versus Values

Serinin ikinci bölümünü atama ve parametre verme işlemlerinde, işaretçileri mi (pointers) yoksa değerleri mi (values) kullanmamız gerektiğine değinmiştik. Şimdi ise aynı değerlendirmeyi dizi ve map için de yapacağız. Aşağıdaki ifadelerden hangisini kullanmalıyız?

a := make([]Saiyan, 10)
// veya
b := make([]*Saiyan, 10)

Çoğu geliştirici parametre veya geri dönüş değeri olarak b‘nin gönderilmesinin daha verimli olacağını düşünecektir. Ancak, parametre veya geri dönüş değeri olarak iletilen şey, aslında bir referans olan dilimin kopyasıdır. Yani, dilimin kendisini kullandığınız takdirde, hangi yöntemle ileteceğinizin bir önemi yoktur.

Yapacağınız seçimin önemi, dilimin veya map‘in değerini değiştireceğinizde ortaya çıkıyor, bu durumda; serinin ikinci bölümünde gördüğümüz mantık burada da geçerli. Yani; işaretçileri mi değerleri mi kullanarak tanımlama yapmalıyız sorusunun cevabı, diziyi veya map‘i nasıl kullanacağınıza göre değil, tekil değerleri nasıl kullanacağınıza göre şekillenir.


Kitap kaynağı: https://github.com/karlseguin/the-little-go-book