Hukum-hukum Refleksi

Rob Pike
6 September 2011

Pendahuluan

Refleksi dalam domain komputer yaitu kemampuan dari sebuah program untuk mengeksaminasi struktur dirinya sendiri, khususnya lewat tipe-tipe; refleksi adalah suatu bentuk dari pemrograman-meta (metaprogramming).

Dalam artikel ini kita mencoba menjernihkan beberapa hal dengan menjelaskan bagaimana refleksi bekerja pada Go. Setiap model refleksi pada bahasa pemrograman berbeda-beda (dan banyak bahasa pemrograman malah tidak mendukungnya sama sekali), namun artikel ini membahas tentang Go, jadi secara keseluruhan dalam artikel ini kata "refleksi" berarti "refleksi dalam Go".

Tipe dan interface

Karena refleksi dibangun di atas sistem tipe, mari kita mulai dengan menyegarkan kembali ingatan kita tentang tipe dalam Go.

Go bertipe statis. Setiap variabel memiliki sebuah tipe statis, yaitu, hanya satu tipe diketahui pada saat dikompilasi: int, float32, *MyType, []byte, dan seterusnya. Jika kita mendeklarasikan

type MyInt int

var i int
var j MyInt

maka i bertipe int dan j bertipe MyInt. Variabel i dan j memiliki tipe statis yang berbeda dan, walaupun tipe dasarnya sama, nilai mereka tidak bisa dipertukarkan satu sama lain tanpa sebuah konversi.

Salah satu tipe penting adalah tipe interface, yang merepresentasikan sekumpulan method yang tetap. Sebuah variabel interface dapat menyimpan nilai konkret apapun (yang bukan interface) selama nilai tersebut mengimplementasikan method-method dari interface tersebut. Salah satu contoh interface yang cukup dikenal yaitu io.Reader dan io.Writer, paket io:

// Reader is the interface that wraps the basic Read method.
type Reader interface {
	Read(p []byte) (n int, err error)
}

// Writer is the interface that wraps the basic Write method.
type Writer interface {
	Write(p []byte) (n int, err error)
}

Tipe apapun yang mengimplementasikan method Read (atau Write) dengan penanda (tipe argumen dan kembalian yang sama) dikatakan mengimplementasikan io.Reader (atau io.Writer). Artinya adalah sebuah variabel bertipe io.Reader dapat menampung nilai apapun selama tipenya memiliki method Read:

var r io.Reader
r = os.Stdin
r = bufio.NewReader(r)
r = new(bytes.Buffer)
// dan seterusnya

Apapun nilai konkret yang ditampung oleh r, tipe dari r selalu io.Reader: Go bertipe statis dan tipe statis dari r adalah io.Reader.

Contoh yang paling penting dari sebuah tipe interface yaitu interface kosong:

interface{}

Interface kosong merepresentasikan method kosong yang dipenuhi oleh nilai apapun, karena nilai apapun memiliki nol atau lebih method.

Beberapa orang mengatakan bahwa interface pada Go bertipe dinamis, namun pernyataan tersebut keliru. Interface pada Go bertipe statis: sebuah variabel bertipe interface selalu memiliki tipe statis yang sama dan walaupun selama program berjalan nilai yang ditampung dalam variabel interface tersebut berubah tipenya, nilai tersebut akan selalu memenuhi interface tersebut.

Kita perlu lebih rinci mengenai hal ini karena refleksi dan interface berelasi dekat.

Representasi dari sebuah interface

Russ Cox telah menuliskan secara rinci dalam blognya tentang representasi dari nilai interface dalam Go. Kita tidak perlu mengulang rincian yang sama di sini, namun sebuah kesimpulan yang ringkas diperlukan.

Sebuah variabel bertipe interface menyimpan sebuah pasangan: nilai konkret yang ditempatkan ke variabel dan descriptor dari tipe. Lebih rincinya, nilai konkret yaitu item data konkret yang mengimplementasikan interface, dan descriptor tipe yaitu yang menjelaskan tipe dari item tersebut. Misalnya, setelah

var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
	return nil, err
}
r = tty

r berisi pasangan (nilai, tipe), (tty, *os.File). Perhatikan bahwa tipe *os.File mengimplementasikan method-method selain Read; walaupun interface hanya menyediakan akses ke method Read, nilai di dalamnya membawa semua informasi tentang nilai tersebut. Oleh karena itu kita bisa melakukan hal seperti ini:

var w io.Writer
w = r.(io.Writer)

Ekspresi dalam penempatan di atas adalah sebuah asersi tipe (type assertion); yang diasersi yaitu item di dalam r juga mengimplementasikan io.Writer, sehingga kita bisa menempatkannya ke w. Setelah penempatan, w akan berisi pasangan (tty, *os.File). Pasangan yang sama dengan yang ditampung dalam r. Tipe statis dari interface menentukan method apa yang bisa dipanggil dalam sebuah variabel interface, walaupun nilai konkret di dalamnya bisa saja memiliki sekumpulan methods yang lebih banyak.

Selanjutnya, kita dapat melakukan:

var empty interface{}
empty = w

dan interface kosong empty akan memiliki pasangan (tty, *os.File). Hal ini sangat berguna: sebuah interface kosong dapat menampung nilai apapun dan berisi semua informasi yang kita butuhkan tentang nilai tersebut.

(Kita tidak membutuhkan asersi tipe di sini karena secara statis diketahui bahwa w memenuhi interface kosong. Pada contoh di atas kita memindahkan sebuah nilai dari sebuah Reader ke sebuah Writer, dengan cara eksplisit dan menggunakan asersi tipe karena method-method dari Writer bukan subset dari Reader.)

Salah satu detil penting yaitu pasangan di dalam sebuah interface selalu berbentuk (nilai, tipe konkret) dan tidak akan bisa berbentuk (nilai, tipe interface). Interface tidak bisa menampung nilai interface.

Sekarang kita siap untuk refleksi.

Hukum pertama dari refleksi

1. Refleksi berangkat dari nilai interface ke objek refleksi.

Pada dasarnya, refleksi hanyalah sebuah mekanisme untuk memeriksa pasangan tipe dan nilai yang disimpan dalam sebuah variabel interface. Untuk memulai, ada dua tipe yang perlu kita ketahui dalam paket reflect: Type dan Value. Kedua tipe tersebut memberi akses ke isi dari sebuah variabel interface, dan dua fungsi sederhana, dikenal dengan reflect.TypeOf() dan reflect.ValueOf(), mengembalikan reflect.Type dan reflect.Value dari sebuah nilai interface. (Dari reflect.Value kita dengan mudah mendapatkan reflect.Type, namun mari kita pisahkan konsep dari Value dan Type ini terlebih dahulu.)

Mari mulai dengan TypeOf:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	var x float64 = 3.4
	fmt.Println("tipe:", reflect.TypeOf(x))
}

Program tersebut mencetak

tipe: float64

Anda mungkin berpikir di sebelah mana interface-nya? Secara program tampak mengirim variabel x bertipe float64, bukan sebuah nilai interface, ke reflect.TypeOf. Dalam dokumentasinya, parameter dari reflect.TypeOf() adalah sebuah interface kosong:

// TypeOf returns the reflection Type of the value in the interface{}.
func TypeOf(i interface{}) Type

Saat memanggil reflect.TypeOf(x), x pertama disimpan ke dalam sebuah interface kosong, dan kemudian dikirim sebagai argumen; reflect.TypeOf kemudian membuka interface kosong tersebut untuk mendapatkan informasi tipenya.

Fungsi reflect.ValueOf membuka nilai dari interface kosong:

var x float64 = 3.4
fmt.Println("nilai:", reflect.ValueOf(x).String())

mencetak

nilai: <float64 Value>

(Kita memanggil method String() secara eksplisit karena paket fmt memanggil reflect.Value untuk menampilkan nilai kongkret di dalam variabel. Method String() tidak.)

Kedua tipe reflect.Type dan reflect.Value memiliki banyak method yang bisa kita gunakan untuk memeriksa dan memanipulasi mereka. Salah satu contoh penting yaitu Value memiliki method Type() yang mengembalikan Type dari sebuah reflect.Value. Hal penting lainnya yaitu Type dan Value memiliki method Kind() yang mengembalikan sebuah konstan mengindikasikan tipe item yang disimpannya: Uint, Float64, Slice, dan seterusnya. Dan juga method-method pada Value seperti Int() dan Float() dapat kita gunakan untuk mengambil nilai (sebagai int64 dan float64) yang disimpan di dalamnya:

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("tipe:", v.Type())
fmt.Println("kind adalah float64:", v.Kind() == reflect.Float64)
fmt.Println("nilai:", v.Float())

mencetak

tipe: float64
kind adalah float64: true
nilai: 3.4

Ada juga method seperti SetInt() dan SetFloat(), namun untuk menggunakannya kita perlu memahami tentang settability, subjek dari hukum ketiga dari refleksi, yang akan kita bahas di bawah.

Pustaka dari refleksi memiliki sepasang properti khusus. Pertama, supaya API-nya sederhana, method "getter" dan "setter" dari Value beroperasi pada tipe terbesar yang dapat ditampung oleh nilai: int64 untuk semua nilai signed integer, misalnya. Oleh karena itu, method Int() pada Value mengembalikan int64 dan SetInt() menerima nilai int64; maka bila diperlukan nilai tersebut bisa dikonversi ke tipe sebenarnya:

var x uint8 = 'x'
v := reflect.ValueOf(x)
fmt.Println("tipe:", v.Type())                                // uint8.
fmt.Println("kind adalah uint8: ", v.Kind() == reflect.Uint8) // true.
x = uint8(v.Uint())                                           // v.Uint mengembalikan uint64.

Properti kedua yaitu method Kind() dari sebuah objek refleksi mendeskripsikan tipe dasarnya, bukan tipe statisnya. Jika objek refleksi mengandung sebuah nilai dari tipe integer yang didefinisikan sendiri oleh user, seperti

type MyInt int
var x MyInt = 7
v := reflect.ValueOf(x)

maka Kind() dari v adalah reflect.Int, walaupun tipe statis dari x adalah MyInt. Dengan kata lain, Kind() tidak bisa membedakan antara int dengan MyInt walaupun Type() bisa.

Hukum kedua dari refleksi

2. Refleksi berangkat dari objek refleksi ke nilai interface

Seperti cermin, refleksi dalam Go menghasilkan kebalikannya sendiri.

Diberikan sebuah reflect.Value kita dapat membuka nilai interface menggunakan method Interface(); efeknya method tersebut membungkus kembali informasi tipe dan nilainya menjadi sebuah representasi interface dan mengembalikan hasilnya:

// Interface returns v's value as an interface{}.
func (v Value) Interface() interface{}

Konsekuensinya kita dapat menulis

y := v.Interface().(float64) // y akan bertipe float64.
fmt.Println(y)

untuk mencetak nilai float64 yang direpresentasikan oleh objek refleksi v.

Tentu saja, kode di atas bisa dipersingkat. Argumen dari fmt.Println, fmt.Printf dan seterusnya dikirim sebagai nilai interface kosong, yang kemudian dibongkar oleh paket fmt secara internal seperti yang kita lakukan pada contoh sebelumnya. Oleh karena itu yang diperlukan untuk mencetak isi dari sebuah reflect.Value dengan benar yaitu mengirim hasil dari method Interface ke fungsi pencetakan:

fmt.Println(v.Interface())

(Kenapa tidak fmt.Println(v)? Karena v adalah sebuah reflect.Value; kita menginginkan nilai konkret yang ditampungnya.) Karena nilainya adalah sebuah float64, kita bisa menggunakan format floating-point jika mau:

fmt.Printf("nilai adalah %7.1e\n", v.Interface())

dan mendapatkan

3.4e+00

Sekali lagi, tidak perlu asersi tipe untuk hasil dari v.Interface() ke float64; nilai interface kosong mengandung nilai kongkret dari informasi tipe di dalamnya dan Printf akan membukanya.

Secara singkatnya, method Interface() adalah kebalikan dari fungsi ValueOf, kecuali hasilnya selalu bertipe statis interface{}.

Mengulangi kembali: refleksi berangkat dari nilai interface ke objek refleksi dan balik lagi (ke nilai interface).

Hukum ketiga dari refleksi

3. Untuk mengubah objek refleksi, nilainya harus bisa di set

Hukum ketiga yaitu yang paling halus dan membingungkan, namun cukup mudah dipahami bila kita mulai dari prinsip pertama.

Berikut kode yang tidak bisa dieksekusi,

var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1) // Eror: akan panic.

Jika kita jalankan, ia akan panic dengan pesan

panic: reflect.Value.SetFloat using unaddressable value

Permasalahannya bukan karena nilai 7.1 tidak memiliki alamat; tapi karena v tidak bisa di set. Settability adalah sebuah properti dari sebuah refleksi Value, yang tidak dimiliki oleh semua refleksi Value.

Method CanSet() dari Value melaporkan settability dari sebuah Value; dalam kasus di atas,

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("settability dari v:", v.CanSet())

mencetak

settability dari v: false

Adalah sebuah kesalahan bila memanggil method Set() pada Value yang tidak bisa di set. Lalu apa itu settability?

Settability yaitu seperti pengalamatan (memory), tapi lebih ketat. Ia adalah properti yang menyatakan bahwa sebuah objek refleksi dapat diubah nilainya atau tidak. Settability ditentukan dari apakah objek refleksi menampung item aslinya. Saat kita mengatakan

var x float64 = 3.4
v := reflect.ValueOf(x)

kita mengirim salinan dari x ke reflect.ValueOf, sehingga nilai interface yang dikirim sebagai argumen ke reflect.ValueOf adalah sebuah salinan dari x bukan x itu sendiri. Maka, jika perintah

v.SetFloat(7.1)

berjalan dengan sukses, ia tidak akan mengubah nilai x, walaupun v tampak seperti dibuat dari x. Namun, ia hanya akan mengubah salinan dari x yang disimpan dalam nilai objek refleksi dan x itu sendiri tidak terpengaruh. Hal ini bisa membingungkan, sehingga dibuat menjadi ilegal oleh perancang Go, dan settability adalah properti yang digunakan untuk menghindari kasus tersebut.

Jika konsel ini tampak aneh, sebenarnya tidak. Ia sebenarnya situasi yang sering kita temui dalam konsep yang tidak biasa (refleksi). Bayangkan bila kita mengirim x ke sebuah fungsi:

f(x)

Fungsi f() tidak akan bisa mengubah x karena kita mengirim salinan dari nilai x, bukan x itu sendiri. Jika kita ingin supaya f() bisa mengubah nilai x secara langsung, kita harus mengirim alamat dari x (yaitu, sebuah pointer ke x):

f(&x)

Hal ini cukup jelas dan lazim, dan refleksi bekerja dengan cara yang sama. Jika kita ingin mengubah x dengan refleksi, kita harus mengirim pointer ke nilai yang ingin kita ubah.

Mari kita coba. Pertama kita inisialisasi x seperti biasa dan kemudian membuat nilai refleksi yang menunjuknya, katakanlah p.

var x float64 = 3.4
p := reflect.ValueOf(&x) // Catatan: ambil alamat dari x.
fmt.Println("tipe dari p:", p.Type())
fmt.Println("settability dari p:", p.CanSet())

Keluarannya

tipe dari p: *float64
settability dari p: false

Objek refleksi p tidak bisa di set, tapi bukan p yang ingin kita set, namun *p. Untuk mendapatkan apa yang ditunjuk oleh p, kita panggil method Elem dari Value, yang langsung ke pointer, dan menyimpan hasilnya dalam sebuah Value refleksi bernama v:

v := p.Elem()
fmt.Println("settability dari v:", v.CanSet())

Sekarang v adalah objek refleksi yang dapat di set, seperti yang ditunjukan oleh keluaran,

settability dari v: true

dan karena ia merepresentasikan x, kita akhirnya dapat menggunakan v.SetFloat untuk mengubah nilai dari x:

v.SetFloat(7.1)
fmt.Println(v.Interface())
fmt.Println(x)

Keluarannya, seperti yang diharapkan, yaitu

7.1
7.1

Refleksi bisa sangat sulit untuk dipahami namun ia berfungsi seperti yang bahasa Go terapkan, walaupun lewat Type dan Value yang menyamarkan apa yang terjadi. Ingatlah selalu bahwa Value dari refleksi perlu alamat sesuatu untuk dapat mengubah apa yang direpresentasikannya.

Struct

Pada contoh sebelumnya v bukanlah sebuah pointer, ia hanya diturunkan dari pointer. Salah satu situasi umum yang muncul adalah saat menggunakan refleksi untuk mengubah field dari sebuah struktur. Selama kita memiliki alamat dari struktur, kita dapat mengubah nilai dari field-fieldnya.

Berikut sebuah contoh sederhana yang menganalisis nilai sebuah struct, t. Kita buat objek refleksi dengan alamat dari struct karena kita ingin mengubahnya nanti. Kemudian kita set typeOfT berisi tipe dari t dan mengiterasi field-field menggunakan pemanggilan method langsung (lihat paket reflect untuk lebih rinci). Kita juga bisa mengekstrak nama dari field dari tipe struct, namun field itu sendiri adalah objek dari reflect.Value.

type T struct {
	A int
	B string
}
t := T{23, "skidoo"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
	f := s.Field(i)
	fmt.Printf("%d: %s %s = %v\n", i, typeOfT.Field(i).Name, f.Type(),
		f.Interface())
}

Keluaran dari program adalah

0: A int = 23
1: B string = skidoo

Ada satu poin lagi tentang settability yang diperlihatkan dalam contoh di atas: nama field dari T adalah huruf besar (diekspor) karena hanya field-field yang diekspor dari sebuah struct yang bisa di set.

Karena s mengandung objek refleksi yang bisa di set, kita bisa mengubah field-field di dalam struktur.

s.Field(0).SetInt(77)
s.Field(1).SetString("Sunset Strip")
fmt.Println("t sekarang", t)

Dan hasilnya:

t sekarang {77 Sunset Strip}

Jika kita mengubah program sehingga s dibuat dari t, bukan t, pemanggilan ke SetInt dan SetString akan gagal karena field dari t tidak bisa di set.

Kesimpulan

Berikut hukum-hukum refleksi:

  • Refleksi berangkat dari nilai interface ke objek refleksi.

  • Refleksi berangkat dari objek refleksi ke nilai interface.

  • Untuk mengubah objek refleksi, nilainya harus bisa di set.

Saat anda memahami hukum-hukum refleksi dalam Go maka ia akan lebih mudah digunakan. Refleksi adalah perkakas yang kuat yang harus digunakan dengan hati-hati dan kalau bisa dihindari kecuali benar-benar diperlukan.

Ada banyak hal tentang refleksi yang belum kita bongkar -- mengirim dan menerima dari channel, alokasi memory, menggunakan slice dan map, pemanggilan method dan fungsi -- namun artikel ini sekiranya cukup. Kita akan telaah beberapa topik tersebut di artikel selanjutnya.