2- Structures | Go Öğreniyorum

15 Şub 2020 · 16 dk okuma süresi

Go; C++, Java, Ruby ve C# gibi nesne yönelimli(object-oriented, kısaca OO) değildir. Objeler ve kalıtım mekanizması yoktur. Bu sebeple, OO ile ilişkili polymorphism ve overloading gibi konseptler de yoktur.

Go, metotlarla ilişkilendirilebilecek yapılara(structures) sahiptir. Go ayrıca, basit ama etkili bir yazım biçimini destekler. Genel olarak daha basit bir kod yapısı sunar, ancak, OO’ın sunduğu bazı özelliklerden mahrum kalacağınız durumlar da olacaktır.

Her ne kadar Go, OO olmasa da Go’daki yapılar ile diğer birçok OO dillerdeki sınıflar(class) arasında birçok benzerlik göreceksiniz: Aşağıda, Saiyan isminde bir yapı(structure) örneklendirilmiştir:

type Saiyan struct {
  Name string
  Power int
}

Sınıflarda olduğu gibi yapılara da metot eklemek mümkün elbette, yapılara nasıl metot eklendiğini göreceğiz, ancak öncelikle, deklarasyon (declarations) konusuna geri dönmeliyiz.

Declarations and Initializations

Go Öğreniyorum serisinin ilk bölümündeki Variables and Declarations konusunu işlerken, sadece integer ve string gibi dahili tiplere değindik. Şimdi ise yapılara değiniyoruz ve bu konuyu işaretçileri de (pointers) dahil edecek şekilde genişletiyoruz.

Üstteki yapımızdan bir değer yaratmanın en basit yolu şudur:

goku := Saiyan{
  Name: "Goku",
  Power: 9000,
}

Not: Üstteki yapıda sondaki ‘,’ karakteri gereklidir. Sondaki virgül olmazsa derleyici hata verecektir. Özellikle bu kuralın aksini zorunlu kılan bir dil veya format kullandıysanız, bu kuralı takdir edersiniz.

Bütün alanları(fields) hatta alanlardan herhangi birini dahi belirtmemiz gerekmez. Alttaki iki kullanım da geçerlidir:

goku := Saiyan{}

// veya

goku := Saiyan{Name: "Goku"}
goku.Power = 9000

Atama yapılmayan değişkenlerde olduğu gibi bu alanları da değer atamadan bırakabiliriz.

Ayrıca, deklarasyon sırasını baz alarak alan isimlerini yazmadan da atama yapabilirsiniz ama kod açıklığı için bunu sadece az alana sahip yapılarda kullanmalısınız:

goku := Saiyan{"Goku", 9000}

Üstteki örneklerin tümü goku isminde değişken tanımlayıp ona bir değer atamaktan ibarettir.

Çoğu zaman, bir değişkenin doğrudan bir değeri tutmasını değil, değerimizi tutan değişkenin adresini tutmasını isteriz. İşaretçi(pointer) bir değişkenin bellekteki adresidir, yani değişkenin değerinin bulunduğu adrestir. Kısacası; bir evde bulunmakla, bir eve gidecek adrese sahip olmak arasındaki fark gibidir.

Peki neden doğrudan bir değeri değil de o değere ulaştıracak işaretçiyi kullanmak istiyoruz? Çünkü Go, argümanları metotlara bir kopya olarak gönderir. Bu durumun ne anlama geldiğini biliyor musunuz? Sizce alttaki kod ne çıktı verir?

func main() {
  goku := Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s Saiyan) {
  s.Power += 10000
}

Cevap 9000 olur, 19000 değil. Peki neden? Çünkü Super isimli metot, bizim orjinal goku isimli değişkenimizin kopyasını değiştirdi, böylece Super metodunun içerisinde yapılan değişiklikler, çağrının yapıldığı yere yansımadı. Bu örneğin beklendiği gibi çalışmasını sağlamak için metoda parametre olarak değişkenin işaretçisini göndermeliyiz:

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s.Power += 10000
}

İki tane değişiklik yaptık. İlk değişiklik & operatörü (address of operator olarak bilinir) ile değişkenin adresini almak oldu. Ardından, Super ismindeki metodun parametresinin beklediği tipi değiştirdik. İlk başta metodumuz Saiyan tipinde bir değişken beklerken şimdi ise *Saiyan tipindeki bir değişkenin adresini bekliyor. *X demek X tipindeki değişkenin adresi demektir. Aralarında her ne kadar açık bir benzerlik bulunsa da Saiyan ve *Saiyan iki farklı tiplerdir.

Unutmayın ki Super isimli metoda goku değişkeninin değerini hâlâ bir kopya olarak gönderiyoruz. Ancak bu sefer goku değişkeninin değeri bir adrestir. Yani; kopya olarak gönderdiğimiz adres, orijinal adresle aynı olduğu için parametrenin kopya olarak gitmesi sorun teşkil etmiyor. Örnekle ifade edecek olursak; bir restorantın adresini kopyaladığınızı düşünün, bu kopya sizi nereye götürür? Yine restorantın olduğu yere götürür.

Peki kopya olarak gönderdiğimiz adres değişkeninin değerini değiştirirsek ne olur? Unutmayın ki böyle bir şeye ihtiyaç duymanız pek olası değildir, ancak üstte yazdığımız örneği doğrulamak adına deneyebiliriz:

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s = &Saiyan{"Gohan", 1000}
}

Üstteki kod bloğu, tekrardan, 9000 çıktısını üretir. Bu davranış; Ruby, Python, Java C ve C# için de geçerlidir.

İşaretçiyi kopyalamanın kompleks bir yapıyı kopyalamaktan daha maliyetsiz olduğunu rahatlıkla söyleyebiliriz. 64-bit bir cihazda, bir işaretçi 64 bit büyüklüğündedir. Çok fazla alana sahip bir yapıdan kopyalar üretmek maliyetli olabilir. Super isimli metodun goku değişkeninin kopyasını mı değiştirmesini istersiniz yoksa goku değişkeninin kendisini mi? İşaretçileri değerli kılan şey işte budur, değerleri paylaşmanıza olanak sağlamasıdır.

Tüm bu anlatılanlar demek değildir ki her zaman işaretçileri kullanmak isteyeceğiz. Bu dersin sonunda, -yapılar ile neler yapabileceğimizi biraz daha işledikten sonra- “Pointers versus Values” başlığı altında, işaretçileri mi değerleri mi kullanmalıyız sorusunu tekrar inceleyeceğiz.

Functions on Structures

Bir metodu bir yapı ile ilişkilendirebiliriz:

type Saiyan struct {
  Name string
  Power int
}

func (s *Saiyan) Super() {
  s.Power += 10000
}

Üstteki kodda, Saiyan‘ın Super ismindeki metodun alıcısı olduğunu belirtiyoruz. Böylelikle, Super ismindeki metodu şu şekilde çağırıyoruz:

goku := &Saiyan{"Goku", 9001}
goku.Super()
fmt.Println(goku.Power) // 19001 çıktısını verecektir

Constructors

Yapılar yapıcılara(constructors) sahip değillerdir. Bunun yerine, istenen istenen tipteki bir örneği(instance) döndüren bir fonksiyon oluşturursunuz:

func NewSaiyan(name string, power int) *Saiyan {
  return &Saiyan{
    Name: name,
    Power: power,
  }
}

Fonksiyonumuz işaretçi döndürmek zorunda değildir, bu kullanım da tamamen geçerlidir:

func NewSaiyan(name string, power int) Saiyan {
  return Saiyan{
    Name: name,
    Power: power,
  }
}

New

Go, yapıcı metotlara sahip olmamasına rağmen, bir tip için gereken belleği ayırmak için kullanılan, dahili bir new fonksiyonuna sahiptir. new(X) ile &X{} aynı işi yapmaktadır.

goku := new(Saiyan)
// veya
goku := &Saiyan{}

Hangisini kullanacağınız size kalmıştır, ancak; initialize edilecek alanlar olduğunda çoğu geliştirici, daha okunaklı olması açısından ikinci kullanımı tercih eder:

goku := new(Saiyan)
goku.name = "goku"
goku.power = 9001

//vs

goku := &Saiyan {
  name: "goku",
  power: 9000,
}

Hangi yaklaşımı tercih ederseniz edin, bir önceki başlıkta gördüğümüz deseni kullandığınız takdirde, kodunuzun geri kalanında ne şekilde allocation yaptığınızla ilgilenmek zorunda kalmayacaksınız.

Fields of a Structure

Şimdiye kadar gördüğümüz örneklerde Saiyan; string ve int tiplerinde olmak üzere sırasıyla Name ve Power isminde iki alana sahipti. Alanlar; yapı(struct) tipinde olabileceği gibi henüz keşfetmediğimiz array, map, interface ve fonksiyon gibi herhangi bir tipte de olabilir.

Örneğin, Saiyan yapısını şöyle genişletebiliriz:

type Saiyan struct {
  Name string
  Power int
  Father *Saiyan
}

ve bu şekilde de initialize edebiliriz:

gohan := &Saiyan{
  Name: "Gohan",
  Power: 1000,
  Father: &Saiyan {
    Name: "Goku",
    Power: 9001,
    Father: nil,
  },
}

Composition

Go, bir yapıyı bir diğerine eklemeyi sağlayan bileşim(composition) desteği sunar. Bazı dillerde bunlara özellik(trait) veya karışım(mixin) deniliyor. Bileşim (composition) mekanizmasına sahip olmayan diller bunu uzun yoldan yapabilirler. Örneğin Java’da:

public class Person {
  private String name;
  
  public String getName() {
    return this.name;
  }
}

public class Saiyan {
  // Saiyan is said to have a person
  private Person person;

  // we forward the call to person
  public String getName() {
    return this.person.getName();
  }
  ...
}

Bu yöntem biraz can sıkıcı olabilir. Person sınıfının her metodu, Saiyan içerisinde tekrardan yazılmaya ihtiyaç duyuluyor. Go, bu hantal durumu ortadan kaldırıyor:

type Person struct {
  Name string
}

func (p *Person) Introduce() {
  fmt.Printf("Hi, I'm %s\n", p.Name)
}

type Saiyan struct {
  *Person
  Power int
}

// and to use it:
goku := &Saiyan{
  Person: &Person{"Goku"},
  Power: 9001,
}
goku.Introduce()

Saiyan yapısı *Person tipinde bir alana sahiptir. Dikkat ederseniz, bu tip için bir isim belirtmedik. Dolaylı tipin (composed type) alan ve fonksiyonlarına dolaylı olarak erişebiliriz. Ancak, Go derleyicisi ilgili alanlar için bir isim vermiştir. Alttaki örnekle konuyu daha anlaşılır hâle getirelim:

goku := &Saiyan{
  Person: &Person{"Goku"},
}
fmt.Println(goku.Name)
fmt.Println(goku.Person.Name)

Yukarıdaki her iki çıktı da “Goku” olacaktır.

Şunu da not etmek gerekir: Şayet Saiyan sınıfında da Name isminde bir alan olsaydı, goku.Name ifadesi, Saiyan yapısının Name alanını verecekti.

Peki, sizce bileşim (composition) kalıtımdan (inheritance) daha mı iyi? Çoğu kişi bunun daha iyi bir yöntem olduğunu düşünüyor. Kalıtımda, sınıfınız üst sınıfa sıkı sıkıya bağlıdır, bir süre sonra yapının işlevinden daha çok hiyerarşiye odaklanırsınız.

Overloading

Aşırı yükleme (overloading) yapılara özgü olmasa da ele almaya değer. Özetle, Go aşırı yüklemeyi desteklemiyor. Bundan dolayı; Load, LoadById ve LoadByName gibi birçok fonksiyon görecek ve yazacaksınız.

Ancak, üstte kalın puntoyla bahsettiğim notta, her nasıl Saiyan yapısındaki Name bileşim tipindeki Person yapısının üstüne yazdıysa (overwrite), aynı durum fonksiyonlar için de geçerli. Yani anlayacağınız, bileşim(composition) tipi sadece bir derleyici hilesi olduğundan, “overwrite” özelliğini Go’da kullanabiliyoruz. Örneğin, Saiyan yapısı kendi Introduce fonksiyonuna sahip olabilir:

func (s *Saiyan) Introduce() {
  fmt.Printf("Hi, I'm %s. Ya!\n", s.Name)
}

Bileşim hâlindeki yapıya s.Person.Introduce() şeklinde yine erişim sağlayabiliriz.

Pointers versus Values

Go kodu yazarken, kendinize pointer mı yoksa value mu kullanmalıyım?” diye sormanız normaldir. Size iki iyi haberim var. Birincisi, aşağıdakilerin tümü için cevap aynıdır:

  • Yerel değişken bildirimi
  • Yapının içindeki alan
  • Fonksiyonun geri dönüş değeri
  • Fonksiyonun parametresi
  • Bir metodun alıcısı(receiver)

İkinci iyi haber ise; eğer pointer mı yoksa value mu kullanacağınıza karar veremediyseniz pointer kullanın.

Daha önce gördüğümüz gibi; parametreyi value olarak göndermek, değerin fonksiyondan değiştirilmesini önlerken Pointer olarak göndermek, değerim doğrudan fonksiyon içerisinden değiştirilmesine olanak tanır. Bazen value olarak göndermek isteyebilirsiniz, ancak çoğu zaman bunu istemeyeceksiniz.

Eğer verinin değiştirilmesini istemiyor olsanız bile, verinin klonlanma maliyenin önüne geçmek için pointer kullanmak avantajlı olacaktır. Bunun tersi olarak da çok basit veri yapısına sahip olduğunuzu düşünün:

type Point struct {
  X int
  Y int
}

Bu tür durumlarda, X ve Y alanına, pointer ile dolaylı yoldan erişmek yerine doğrudan erişmek, yapının klonlanma maliyetini telafi edecektir. Tabii bunlar oldukça ince hesaplardır. Binlerce hatta on binlerce Point yapısını aynı anda kullanmadan bu farkı göremeyebilirsiniz.


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