26. Struct dan metode

Sejauh ini, nilai-nilai yang saling berkaitan hidup terpisah: hadiah quest di satu map dan langkah-langkahnya di map lain, ruangan dan inventaris pemain di dua variabel di luar main. Bab ini memperkenalkan struct, yang menyimpan nilai-nilai yang berkaitan di satu tempat, dan metode, yaitu fungsi yang menjadi milik sebuah tipe. Bagian 6 dibangun di atas dua gagasan ini.

Menyimpan data yang berkaitan bersama-sama

Bab 23 berakhir dengan quest yang disimpan dalam dua map, dan mengatakan bahwa tidak ada yang menjaga keduanya tetap selaras. Bab itu juga mengatakan bahwa satu map punya satu tipe nilai, jadi nama dan hadiah tidak bisa berbagi map. Struct menyelesaikan keduanya.

Struct adalah nilai yang terdiri dari field bernama, dan setiap field punya tipenya sendiri. Kamu mendefinisikannya sekali dengan type, di tingkat teratas file, di samping fungsi-fungsimu:

type quest struct {
    name   string
    reward int
    steps  []string
}

func main() {
    errand := quest{
        name:   "Healer's Errand",
        reward: 50,
        steps:  []string{"Speak to the elder", "Collect 5 herbs", "Return to camp"},
    }

    fmt.Println(errand.reward)     // 50
    fmt.Println(errand.steps[0])   // Speak to the elder
    fmt.Println(len(errand.steps)) // 3
}

Baca sebagai: definisikan tipe baru bernama quest, yang punya tiga field ini. Nilai di dalam main adalah literal struct: tipenya, lalu pasangan field: nilai di dalam kurung kurawal. Seperti map yang ditulis dalam beberapa baris, setiap baris butuh koma di belakangnya, termasuk yang terakhir.

Kamu membaca field dengan titik, dan menugaskan nilai ke field dengan cara yang sama:

errand := quest{name: "Healer's Errand", reward: 50}

errand.reward = 60
fmt.Println(errand.reward) // 60

Inilah penulisan dengan titik yang tidak dimiliki map, seperti yang ditunjukkan Bab 23. Go memeriksa nama field sebelum program berjalan, jadi nama yang salah ketik menghentikannya. Dengan map, kesalahan yang sama diam-diam memberimu nol:

errand := quest{name: "Healer's Errand", rewrd: 50}
fmt.Println(errand)
unknown field rewrd in struct literal of type quest

Nilai nol

Field yang tidak kamu tulis di literal mendapat nilai nol dari tipenya: 0 untuk int, "" untuk string, false untuk bool, dan nil untuk slice atau map. Menulis tipenya dengan kurung kurawal kosong memberikan struct yang semua isinya nilai nol:

blank := quest{name: "Unwritten"}
fmt.Printf("%q %d %d\n", blank.name, blank.reward, len(blank.steps)) // "Unwritten" 0 0

empty := quest{}
fmt.Printf("%q\n", empty.name) // ""

Untuk mencetak seluruh struct, %v memberikan nilainya, dan %+v menambahkan nama field-nya:

errand := quest{name: "Lost Cat", reward: 5}
fmt.Printf("%v\n", errand)  // {Lost Cat 5 []}
fmt.Printf("%+v\n", errand) // {name:Lost Cat reward:5 steps:[]}

Go juga mengizinkan kamu tidak menulis nama field, seperti quest{"Lost Cat", 5, nil}. Semua nilainya harus ada, dan harus urut. Menambah field nantinya akan merusak setiap literal seperti itu, jadi buku ini selalu menulis namanya.

Satu map, bukan dua

Dengan struct, dua map dari Bab 23 menjadi satu. Kuncinya menemukan quest, dan quest menyimpan semua hal tentang dirinya:

quests := map[string]quest{
    "Healer's Errand": {
        name:   "Healer's Errand",
        reward: 50,
        steps:  []string{"Speak to the elder", "Collect 5 herbs", "Return to camp"},
    },
    "Lost Cat": {name: "Lost Cat", reward: 5, steps: []string{"Find the cat"}},
}

fmt.Println(quests["Lost Cat"].reward)            // 5
fmt.Println(len(quests["Healer's Errand"].steps)) // 3

Kedua map tidak bisa lagi menjadi tidak selaras, karena sekarang hanya ada satu. Sebuah quest tidak mungkin punya hadiah tetapi tidak punya entri untuk langkah-langkahnya. Di dalam literal, Go mengizinkan kamu tidak menulis tipe bagian dalamnya, sama seperti pada map bersarang. Contoh-contoh ini ada di exercises/26/01-quest.go.

Satu ruangan dalam satu nilai

Petualangan Teks menyimpan ruangan dalam empat map, karena nama, deskripsi, pintu keluar, dan barang adalah tipe yang berbeda. Struct menyatukan satu ruangan dalam satu nilai. Ini bagian utama dari exercises/26/02-room.go:

type room struct {
    name        string
    description string
    exits       map[string]int
    items       []string
}

func main() {
    rooms := map[int]room{
        1: {
            name:        "Forest Path",
            description: "You are on a moss covered forest path. Birdsong drifts on the air.",
            exits:       map[string]int{"north": 2},
        },
        2: {
            name:        "Clearing",
            description: "A small clearing of wildflowers.",
            exits:       map[string]int{"south": 1, "east": 3},
            items:       []string{"torch"},
        },
    }

    here := rooms[2]
    fmt.Println("--", here.name, "--")
    fmt.Println(here.description)
    fmt.Println("Items here:", strings.Join(here.items, ", "))
    fmt.Println("East leads to room", here.exits["east"])
}

Output:

-- Clearing --
A small clearing of wildflowers.
Items here: torch
East leads to room 3

Semua hal tentang satu ruangan sekarang bepergian bersama. Untuk memberikan ruangan ke sebuah fungsi, kamu memberikan satu nilai, bukan satu id dan empat map.

Fungsi pada struct, cara panjang

Bab 21 menyimpan hitungan antar pemanggilan dengan variabel di luar semua fungsi, dan menjanjikan cara yang lebih rapi. Struct bisa menyimpan hitungan itu, dan fungsi bisa menerima struct lalu mengembalikan versi yang sudah diubah:

type counter struct {
    count int
}

func step(c counter) counter {
    c.count = c.count + 1
    return c
}

func main() {
    c := counter{}
    c = step(c)
    c = step(c)
    c = step(c)
    fmt.Println(c.count) // 3
}

Tidak ada yang hidup di luar fungsi, dan kamu bisa melihat dari setiap pemanggilan apa yang dibaca dan apa yang berubah. Kamu juga bisa punya dua penghitung sekaligus, yang tidak bisa dilakukan oleh satu variabel di luar fungsi. Ini berhasil, tetapi step adalah nama yang terlalu umum, dan setiap pemanggilan harus memberikan penghitungnya secara eksplisit.

Metode dan receiver

Metode adalah fungsi yang menjadi milik sebuah tipe. Go tidak punya class: kamu mendefinisikan tipenya, lalu menulis metode-metodenya sebagai fungsi terpisah, di sampingnya. Yang membuat sebuah fungsi menjadi metode adalah satu parameter tambahan, yang ditulis dalam kurungnya sendiri sebelum nama fungsi:

type counter struct {
    count int
}

func (c counter) show() {
    fmt.Println("Count:", c.count)
}

func main() {
    c := counter{count: 3}
    c.show() // Count: 3
}

Parameter tambahan itu, (c counter), disebut receiver. Receiver adalah nilai di depan titik ketika metode dipanggil. Pada c.show(), Go memberikan nilai c kepada show, dan di dalam badannya nilai itu bernama c, sama seperti parameter apa pun punya nama. Nama receiver biasanya satu atau dua huruf pertama dari nama tipenya.

Di dalam badan metode, receiver adalah variabel biasa: c.count membaca sebuah field. Dua nilai dari satu tipe menjawab untuk dirinya masing-masing, karena setiap pemanggilan menerima nilainya sendiri:

type counter struct {
    count int
}

func (c counter) show() {
    fmt.Println("Count:", c.count)
}

func main() {
    a := counter{count: 1}
    b := counter{count: 2}
    a.show() // Count: 1
    b.show() // Count: 2
}

Metode juga memungkinkan tipe yang berbeda memakai nama yang sama: dog dan door boleh sama-sama punya metode open, karena tipe di depan titik menunjukkan mana yang kamu maksud.

Metode mendapat salinan

Receiver pada (c counter) adalah receiver nilai: metode mendapat salinan struct, sama seperti fungsi mendapat salinan parameter int, seperti kata Bab 21. Jadi metode seperti ini tidak bisa mengubah aslinya:

type counter struct {
    count int
}

func (c counter) stepWrong() {
    c.count = c.count + 1
}

func main() {
    c := counter{}
    c.stepWrong()
    c.stepWrong()
    fmt.Println(c.count) // 0
}

Program ini bisa dikompilasi dan berjalan, dan jawabannya 0. Setiap pemanggilan menambahkan 1 ke salinannya sendiri, dan salinan itu hilang ketika pemanggilan berakhir.

Pada tahap ini, jalan keluarnya sama dengan versi panjang tadi: kembalikan salinan yang sudah diubah, dan tugaskan ke variabel yang sama. Ini kebiasaan yang sama dengan append di Bab 22:

// Chapter 26 example: a counter, with a method that gives back a changed copy.
// Run with: go run exercises/26/03-counter.go

package main

import "fmt"

type counter struct {
    count int
}

func (c counter) show() {
    fmt.Println("Count:", c.count)
}

func (c counter) step() counter {
    c.count = c.count + 1
    return c
}

func main() {
    c := counter{}
    c = c.step()
    c = c.step()
    c = c.step()
    c.show() // Count: 3

    // Try this: add a method reset that gives back a counter with count set
    // to 0, assign its result back to c, and call c.show() again.
}

Program ini mencetak Count: 3.

Buka exercises/26/03-counter.go. Tambahkan metode reset yang mengembalikan penghitung dengan count bernilai 0. Tugaskan hasilnya kembali ke c, lalu panggil c.show() lagi.

Menugaskan kembali memang berhasil, tetapi mudah terlupa. Go menghentikanmu kalau kamu membuang hasil append, tetapi tidak kalau kamu membuang hasil metodemu sendiri. c.step() di satu baris tersendiri bisa dikompilasi, dan tidak melakukan apa-apa pada c. Bab 27 menghilangkan masalah ini dengan receiver pointer, yang memungkinkan metode mengubah aslinya.

Metode yang sudah pernah kamu panggil

Sejak Bab 15 kamu sudah memanggil reader.ReadString('\n'). reader adalah sebuah nilai, dan ReadString adalah metode padanya. Itulah sebabnya dia ingat sudah membaca sampai mana dari satu pemanggilan ke pemanggilan berikutnya.

Tidak semua yang memakai titik adalah metode. Pada strings.ToUpper(name), bagian di depan titik adalah package, dan ToUpper adalah fungsi biasa di dalamnya. String-nya masuk sebagai argumen. String sama sekali tidak punya metode, jadi kode ini tidak bisa dikompilasi:

name := "keiko"
fmt.Println(name.ToUpper())
name.ToUpper undefined (type string has no field or method ToUpper)

error punya metode. err.Error() memberikan pesannya sebagai string. Ini isi main di exercises/26/04-method-or-function.go:

name := "keiko"

// A function from the strings package. The string goes in as an argument.
fmt.Println(strings.ToUpper(name)) // KEIKO

// A method. err is a value, and Error is a method you can call on it.
err := errors.New("out of arrows")
fmt.Println(err.Error()) // out of arrows

Bab 29 menunjukkan mengapa fmt.Println(err) mencetak pesan yang sama.

Nilai kecil untuk game

Struct dan metode paling berguna ketika beberapa keadaan berjalan bersama. Ini seekor anjing dengan nama dan sisa jumlah gonggongan:

// Chapter 26 example: a dog with state, a newDog function and a method.
// Run with: go run exercises/26/05-dog.go

package main

import "fmt"

type dog struct {
    name      string
    barksLeft int
}

func newDog(name string, barksLeft int) dog {
    return dog{name: name, barksLeft: barksLeft}
}

func (d dog) bark() dog {
    if d.barksLeft <= 0 {
        fmt.Printf("%s is hoarse.\n", d.name)
        return d
    }
    fmt.Printf("%s: Woof!\n", d.name)
    d.barksLeft = d.barksLeft - 1
    return d
}

func main() {
    rex := newDog("Rex", 3)
    rex = rex.bark()
    rex = rex.bark()
    rex = rex.bark()
    rex = rex.bark()
}

Output:

Rex: Woof!
Rex: Woof!
Rex: Woof!
Rex is hoarse.

Struct menyimpan keadaannya, dan metode bekerja padanya. Tipe dengan datanya dan metode yang bekerja pada data itu adalah bentuk yang dipakai di sepanjang bagian ini.

Perhatikan newDog. Go tidak punya fungsi khusus yang berjalan saat kamu membuat sebuah nilai. Sesuai kebiasaan, kamu menulis fungsi biasa bernama newThing yang membangun nilainya dan mengembalikannya, seperti yang dilakukan bufio.NewReader. Bahasa Go tidak memperlakukan nama ini secara khusus. Fungsi ini berguna ketika membuat sebuah nilai butuh lebih dari sekadar mengisi field. Bab 30 memakainya untuk memeriksa nilai-nilainya lebih dulu.

Kesalahan umum: melupakan receiver

Kalau receiver tidak ditulis, kamu punya fungsi biasa yang tidak tahu apa-apa tentang c. exercises/26/06-missing-receiver.go sengaja berisi kesalahan ini:

func step() counter {
    c.count = c.count + 1
    return c
}

func main() {
    c := counter{}
    c = c.step()
    fmt.Println(c.count)
}

Menjalankannya berhenti dengan:

# command-line-arguments
exercises\26\06-missing-receiver.go:17:2: undefined: c
exercises\26\06-missing-receiver.go:18:9: undefined: c
exercises\26\06-missing-receiver.go:23:8: c.step undefined (type counter has no field or method step)

Dua error pertama mengatakan bahwa step tidak punya c. Yang terakhir mengatakan bahwa counter tidak punya metode step, karena step tidak terhubung dengannya. Perbaikannya adalah menambahkan receiver: func (c counter) step() counter {.

PR

Starter PR ada di exercises/26/homework/. Setiap starter bisa langsung dijalankan dan mencetak satu baris pengganti. Definisikan tipe dan metodemu di luar main. Semua metode di PR ini memakai receiver nilai, jadi metode yang mengubah sesuatu mengembalikan salinan yang sudah diubah.

Soal 1 — Penghitung

Buka exercises/26/homework/01-counter-object.go. Buat struct counter dengan satu field dan tiga metode:

  • count: field int, yang dimulai dari nilai nolnya.
  • inc(): mengembalikan penghitung dengan count bertambah 1.
  • get(): mengembalikan hitungan saat ini.
  • reset(): mengembalikan penghitung dengan count bernilai 0.

Panggil secara berurutan, dan cetak nilainya di setiap langkah.

Soal 2 — Anjing yang menggonggong

Buka exercises/26/homework/02-dog.go. Buat struct dog dengan field name dan metode bark() yang mencetak <name>: Woof!. Buat anjing kedua dengan nama yang berbeda. Panggil bark() pada keduanya, dan pastikan masing-masing mencetak namanya sendiri.

Soal 3 — Kalkulator

Buka exercises/26/homework/03-calculator.go. Buat struct calc dengan:

  • value: field int, yang dimulai dari nilai nolnya.
  • add(n): mengembalikan calc yang sudah ditambah n.
  • sub(n): mengembalikan calc yang sudah dikurangi n.
  • mul(n): mengembalikan calc yang sudah dikali n.
  • show(): mencetak nilai saat ini dengan label, seperti Value: 10.

Lakukan beberapa pemanggilan, dan pastikan nilainya berubah sesuai harapan.

Tantangan — Stack

Buka exercises/26/homework/04-stack.go. Buat struct stack yang menyimpan string dan berperilaku seperti tumpukan, yaitu item terakhir yang masuk menjadi yang pertama keluar:

  • items: field []string.
  • push(v): mengembalikan stack dengan v di puncak.
  • pop(): mengembalikan dua nilai, stack tanpa item puncak, dan item puncak itu.
  • peek(): mengembalikan item puncak dan true, atau "" dan false kalau stack kosong, seperti bentuk v, ok dari Bab 23.
  • size(): mengembalikan jumlah item yang tersimpan.

Puncak stack adalah ujung slice. Pakai append untuk push, dan pemotongan slice dari Bab 22 untuk pop.

Stuck atau sudah selesai? Buka halaman solusi PR.