Logika Terstruktur dengan Baik: Tutorial OOP Golang

Diterbitkan: 2022-03-11

Apakah Go berorientasi objek? Bisakah? Go (atau "Golang") adalah bahasa pemrograman pasca-OOP yang meminjam strukturnya (paket, jenis, fungsi) dari keluarga bahasa Algol/Pascal/Modula. Namun demikian, di Go, pola berorientasi objek masih berguna untuk menyusun program dengan cara yang jelas dan mudah dipahami. Tutorial Golang ini akan mengambil contoh sederhana dan mendemonstrasikan bagaimana menerapkan konsep fungsi binding ke tipe (alias kelas), konstruktor, subtipe, polimorfisme, injeksi ketergantungan, dan pengujian dengan tiruan.

Studi Kasus di Golang OOP: Membaca Kode Pabrikan dari Nomor Identifikasi Kendaraan (VIN)

Nomor identifikasi kendaraan unik dari setiap mobil mencakup—di samping nomor “berjalan” (yaitu, seri)—informasi tentang mobil, seperti pabrikan, pabrik yang memproduksi, model mobil, dan jika dikemudikan dari kiri- atau sisi kanan.

Fungsi untuk menentukan kode pabrikan mungkin terlihat seperti ini:

 package vin func Manufacturer(vin string) string { manufacturer := vin[: 3] // if the last digit of the manufacturer ID is a 9 // the digits 12 to 14 are the second part of the ID if manufacturer[2] == '9' { manufacturer += vin[11: 14] } return manufacturer }

Dan ini adalah tes yang membuktikan bahwa contoh VIN berfungsi:

 package vin_test import ( "vin-stages/1" "testing" ) const testVIN = "W09000051T2123456" func TestVIN_Manufacturer(t *testing.T) { manufacturer := vin.Manufacturer(testVIN) if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Jadi fungsi ini bekerja dengan benar ketika diberikan input yang tepat, tetapi memiliki beberapa masalah:

  • Tidak ada jaminan bahwa string input adalah VIN.
  • Untuk string yang lebih pendek dari tiga karakter, fungsi tersebut menyebabkan panic .
  • Bagian kedua opsional dari ID adalah fitur VIN Eropa saja. Fungsi tersebut akan mengembalikan ID yang salah untuk mobil AS yang memiliki angka 9 sebagai digit ketiga dari kode pabrikan.

Untuk mengatasi masalah ini, kami akan melakukan refactor menggunakan pola berorientasi objek.

Go OOP: Mengikat Fungsi ke Jenis

Refactoring pertama adalah membuat VIN menjadi tipe mereka sendiri dan mengikat fungsi Manufacturer() padanya. Ini membuat tujuan fungsi lebih jelas dan mencegah penggunaan yang tidak bijaksana.

 package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

Kami kemudian mengadaptasi pengujian dan memperkenalkan masalah VIN yang tidak valid:

 package vin_test import( "vin-stages/2" "testing" ) const ( validVIN = vin.VIN("W0L000051T2123456") invalidVIN = vin.VIN("W0") ) func TestVIN_Manufacturer(t * testing.T) { manufacturer := validVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN) } invalidVIN.Manufacturer() // panic! }

Baris terakhir dimasukkan untuk mendemonstrasikan cara memicu panic saat menggunakan fungsi Manufacturer() . Di luar tes, ini akan membuat crash program yang sedang berjalan.

OOP di Golang: Menggunakan Konstruktor

Untuk menghindari panic saat menangani VIN yang tidak valid, Anda dapat menambahkan pemeriksaan validitas ke fungsi Manufacturer() itu sendiri. Kerugiannya adalah pemeriksaan akan dilakukan pada setiap panggilan ke fungsi Manufacturer() , dan bahwa nilai pengembalian kesalahan harus diperkenalkan, yang akan membuat tidak mungkin untuk menggunakan nilai pengembalian secara langsung tanpa variabel perantara (mis. kunci peta).

Cara yang lebih elegan adalah dengan menempatkan pemeriksaan validitas dalam konstruktor untuk tipe VIN , sehingga fungsi Manufacturer() dipanggil hanya untuk VIN yang valid dan tidak memerlukan pemeriksaan dan penanganan kesalahan:

 package vin import "fmt" type VIN string // it is debatable if this func should be named New or NewVIN // but NewVIN is better for greping and leaves room for other // NewXY funcs in the same package func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

Tentu saja, kami menambahkan tes untuk fungsi NewVIN . VIN yang tidak valid sekarang ditolak oleh konstruktor:

 package vin_test import ( "vin-stages/3" "testing" ) const ( validVIN = "W0L000051T2123456" invalidVIN = "W0" ) func TestVIN_New(t *testing.T) { _, err := vin.NewVIN(validVIN) if err != nil { t.Errorf("creating valid VIN returned an error: %s", err.Error()) } _, err = vin.NewVIN(invalidVIN) if err == nil { t.Error("creating invalid VIN did not return an error") } } func TestVIN_Manufacturer(t *testing.T) { testVIN, _ := vin.NewVIN(validVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Pengujian untuk fungsi Manufacturer() sekarang dapat menghilangkan pengujian VIN yang tidak valid karena sudah akan ditolak oleh konstruktor NewVIN .

Go OOP Pitfall: Polimorfisme dengan Cara yang Salah

Selanjutnya, kami ingin membedakan antara VIN Eropa dan non-Eropa. Salah satu pendekatannya adalah dengan memperluas type VIN ke struct dan menyimpan apakah VIN itu Eropa atau bukan, meningkatkan konstruktor yang sesuai:

 type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }

Solusi yang lebih elegan adalah membuat subtipe VIN untuk VIN Eropa. Di sini, flag secara implisit disimpan dalam informasi tipe, dan fungsi Manufacturer() untuk VIN non-Eropa menjadi bagus dan ringkas:

 package vin import "fmt" type VIN string func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { return string(v[: 3]) } type EUVIN VIN func NewEUVIN(code string)(EUVIN, error) { // call super constructor v, err := NewVIN(code) // and cast to subtype return EUVIN(v), err } func (v EUVIN) Manufacturer() string { // call manufacturer on supertype manufacturer := VIN(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

Dalam bahasa OOP seperti Java, kami berharap subtipe EUVIN dapat digunakan di setiap tempat di mana tipe VIN ditentukan. Sayangnya, ini tidak berfungsi di Golang OOP.

 package vin_test import ( "vin-stages/4" "testing" ) const euSmallVIN = "W09000051T2123456" // this works! func TestVIN_EU_SmallManufacturer(t *testing.T) { testVIN, _ := vin.NewEUVIN(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } // this fails with an error func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // having to cast testVIN already hints something is odd testVINs = append(testVINs, vin.VIN(testVIN)) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

Perilaku ini dapat dijelaskan dengan sengaja memilih tim pengembangan Go untuk tidak mendukung pengikatan dinamis untuk tipe non-antarmuka. Ini memungkinkan kompiler untuk mengetahui fungsi mana yang akan dipanggil pada waktu kompilasi dan menghindari overhead pengiriman metode dinamis. Pilihan ini juga mencegah penggunaan pewarisan sebagai pola komposisi umum. Sebaliknya, antarmuka adalah cara yang harus dilakukan (maafkan permainan kata-kata).

Sukses OOP Golang: Polimorfisme dengan Cara yang Benar

Kompiler Go memperlakukan tipe sebagai implementasi antarmuka ketika mengimplementasikan fungsi yang dideklarasikan (pengetikan bebek). Oleh karena itu, untuk memanfaatkan polimorfisme, tipe VIN diubah menjadi antarmuka yang diimplementasikan oleh tipe VIN umum dan Eropa. Perhatikan bahwa tipe VIN Eropa tidak perlu menjadi subtipe dari yang umum.

 package vin import "fmt" type VIN interface { Manufacturer() string } type vin string func NewVIN(code string)(vin, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return vin(code), nil } func (v vin) Manufacturer() string { return string(v[: 3]) } type vinEU vin func NewEUVIN(code string)(vinEU, error) { // call super constructor v, err := NewVIN(code) // and cast to own type return vinEU(v), err } func (v vinEU) Manufacturer() string { // call manufacturer on supertype manufacturer := vin(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

Tes polimorfisme sekarang lulus dengan sedikit modifikasi:

 // this works! func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // now there is no need to cast! testVINs = append(testVINs, testVIN) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

Faktanya, kedua jenis VIN sekarang dapat digunakan di setiap tempat yang menentukan antarmuka VIN , karena kedua jenis mematuhi definisi antarmuka VIN .

Golang berorientasi objek: Cara Menggunakan Injeksi Ketergantungan

Last but not least, kita perlu memutuskan apakah VIN itu Eropa atau bukan. Misalkan kita telah menemukan API eksternal yang memberi kita informasi ini, dan kita telah membangun klien untuk itu:

 package vin type VINAPIClient struct { apiURL string apiKey string // ... internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &VINAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns correct value return true }

Kami juga telah membangun layanan yang menangani VIN dan, khususnya, dapat membuatnya:

 package vin type VINService struct { client *VINAPIClient } type VINServiceConfig struct { APIURL string APIKey string // more configuration values } func NewVINService(config *VINServiceConfig) *VINService { // use config to create the API client apiClient := NewVINAPIClient(config.APIURL, config.APIKey) return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

Ini berfungsi dengan baik seperti yang ditunjukkan oleh tes yang dimodifikasi:

 func TestVIN_EU_SmallManufacturer(t *testing.T) { service := vin.NewVINService( & vin.VINServiceConfig {}) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Satu-satunya masalah di sini adalah bahwa pengujian memerlukan koneksi langsung ke API eksternal. Ini sangat disayangkan, karena API bisa offline atau tidak dapat dijangkau. Selain itu, memanggil API eksternal membutuhkan waktu dan mungkin memerlukan biaya.

Karena hasil dari panggilan API diketahui, seharusnya dimungkinkan untuk menggantinya dengan tiruan. Sayangnya, pada kode di atas, VINService sendiri yang membuat klien API, jadi tidak ada cara mudah untuk menggantinya. Untuk memungkinkan hal ini, ketergantungan klien API harus dimasukkan ke dalam VINService . Artinya, itu harus dibuat sebelum memanggil konstruktor VINService .

Pedoman OOP Golang di sini adalah bahwa tidak ada konstruktor yang boleh memanggil konstruktor lain . Jika ini diterapkan secara menyeluruh, setiap singleton yang digunakan dalam aplikasi akan dibuat di level paling atas. Biasanya, ini akan menjadi fungsi bootstrap yang membuat semua objek yang diperlukan dengan memanggil konstruktornya dalam urutan yang sesuai, memilih implementasi yang sesuai untuk fungsionalitas program yang dimaksudkan.

Langkah pertama adalah menjadikan VINAPIClient sebagai antarmuka:

 package vin type VINAPIClient interface { IsEuropean(code string) bool } type vinAPIClient struct { apiURL string apiKey string // .. internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &vinAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns something more useful return true }

Kemudian, klien baru dapat disuntikkan ke dalam VINService :

 package vin type VINService struct { client VINAPIClient } type VINServiceConfig struct { // more configuration values } func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService { // apiClient is created elsewhere and injected here return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

Dengan itu, sekarang mungkin untuk menggunakan tiruan klien API untuk pengujian. Selain menghindari panggilan ke API eksternal selama pengujian, tiruan juga dapat bertindak sebagai penyelidikan untuk mengumpulkan data tentang penggunaan API. Pada contoh di bawah ini, kami hanya memeriksa apakah fungsi IsEuropean benar-benar dipanggil.

 package vin_test import ( "vin-stages/5" "testing" ) const euSmallVIN = "W09000051T2123456" type mockAPIClient struct { apiCalls int } func NewMockAPIClient() *mockAPIClient { return &mockAPIClient {} } func (client *mockAPIClient) IsEuropean(code string) bool { client.apiCalls++ return true } func TestVIN_EU_SmallManufacturer(t *testing.T) { apiClient := NewMockAPIClient() service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } if apiClient.apiCalls != 1 { t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls) } }

Tes ini lolos, karena probe IsEuropean kami berjalan sekali selama panggilan ke CreateFromCode .

Pemrograman Berorientasi Objek di Go: Kombinasi yang Menakjubkan (Bila Dilakukan dengan Benar)

Kritikus mungkin berkata, “Mengapa tidak menggunakan Java jika Anda tetap melakukan OOP?” Nah, karena Anda mendapatkan semua keuntungan bagus lainnya dari Go sambil menghindari VM/JIT yang haus sumber daya, kerangka kerja terkutuk dengan voodoo anotasi, penanganan pengecualian, dan rehat kopi saat menjalankan tes (yang terakhir mungkin menjadi masalah bagi sebagian orang).

Dengan contoh di atas, jelas bagaimana melakukan pemrograman berorientasi objek di Go dapat menghasilkan kode yang lebih mudah dipahami dan berjalan lebih cepat dibandingkan dengan implementasi imperatif biasa. Meskipun Go tidak dimaksudkan untuk menjadi bahasa OOP, Go menyediakan alat yang diperlukan untuk menyusun aplikasi dengan cara berorientasi objek. Bersama dengan pengelompokan fungsionalitas dalam paket, OOP di Golang dapat dimanfaatkan untuk menyediakan modul yang dapat digunakan kembali sebagai blok bangunan untuk aplikasi besar.


Lencana Mitra Google Cloud.

Sebagai Partner Google Cloud, para ahli Toptal bersertifikasi Google tersedia untuk perusahaan sesuai permintaan untuk proyek terpenting mereka.