ตรรกะที่มีโครงสร้างดี: บทช่วยสอน Golang OOP
เผยแพร่แล้ว: 2022-03-11Go เป็นเชิงวัตถุหรือไม่? เป็นไปได้ไหม Go (หรือ “Golang”) เป็นภาษาโปรแกรมหลัง OOP ที่ยืมโครงสร้าง (แพ็คเกจ ประเภท ฟังก์ชัน) จากตระกูลภาษา Algol/Pascal/Modula อย่างไรก็ตาม ใน Go รูปแบบเชิงวัตถุยังคงมีประโยชน์สำหรับการจัดโครงสร้างโปรแกรมอย่างชัดเจนและเข้าใจได้ กวดวิชา Golang นี้จะใช้ตัวอย่างง่ายๆ และสาธิตวิธีการใช้แนวคิดของฟังก์ชันการโยงกับประเภท (คลาส aka) ตัวสร้าง การพิมพ์ย่อย ความหลากหลาย การฉีดการพึ่งพา และการทดสอบด้วยการจำลอง
กรณีศึกษาใน Golang OOP: การอ่านรหัสผู้ผลิตจากหมายเลขประจำตัวยานพาหนะ (VIN)
หมายเลขประจำตัวรถที่ไม่ซ้ำกันของรถทุกคันรวมถึง—ข้างหมายเลข “วิ่ง” (เช่น หมายเลขซีเรียล) ข้อมูลเกี่ยวกับรถ เช่น ผู้ผลิต โรงงานผลิต รุ่นรถ และถ้าขับจากซ้ายหรือ ด้านขวามือ
ฟังก์ชันสำหรับกำหนดรหัสผู้ผลิตอาจมีลักษณะดังนี้:
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 }
และนี่คือการทดสอบที่พิสูจน์ว่าตัวอย่าง VIN ใช้งานได้:
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) } }
ดังนั้นฟังก์ชันนี้จึงทำงานได้อย่างถูกต้องเมื่อได้รับอินพุตที่ถูกต้อง แต่มีปัญหาบางประการ:
- ไม่มีการรับประกันว่าสตริงอินพุตเป็น VIN
- สำหรับสตริงที่สั้นกว่าสามอักขระ ฟังก์ชันทำให้เกิด
panic
- ส่วนที่สองที่เป็นตัวเลือกของ ID เป็นคุณลักษณะของ VIN ของยุโรปเท่านั้น ฟังก์ชันจะส่งคืน ID ที่ไม่ถูกต้องสำหรับรถยนต์ในสหรัฐอเมริกาที่มีเลข 9 เป็นหลักที่สามของรหัสผู้ผลิต
ในการแก้ปัญหาเหล่านี้ เราจะจัดโครงสร้างใหม่โดยใช้รูปแบบเชิงวัตถุ
ไป OOP: ผูกฟังก์ชันกับ Type
การปรับโครงสร้างใหม่ครั้งแรกคือการทำให้ VIN เป็นประเภทของตนเองและผูกฟังก์ชัน Manufacturer()
ไว้กับมัน สิ่งนี้ทำให้วัตถุประสงค์ของฟังก์ชันชัดเจนขึ้นและป้องกันการใช้งานที่ไม่จำเป็น
package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }
จากนั้นเราจะปรับการทดสอบและแนะนำปัญหาของ VIN ที่ไม่ถูกต้อง:
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! }
มีการแทรกบรรทัดสุดท้ายเพื่อแสดงวิธีทำให้เกิด panic
ขณะใช้ฟังก์ชัน Manufacturer()
นอกการทดสอบ นี่จะทำให้โปรแกรมที่ทำงานอยู่ขัดข้อง
OOP ใน Golang: การใช้ตัวสร้าง
เพื่อหลีกเลี่ยง panic
เมื่อจัดการกับ VIN ที่ไม่ถูกต้อง คุณสามารถเพิ่มการตรวจสอบความถูกต้องให้กับฟังก์ชัน Manufacturer()
ได้ ข้อเสียคือ การตรวจสอบจะทำได้ทุกครั้งที่เรียกใช้ฟังก์ชัน Manufacturer()
และต้องมีการแนะนำค่าส่งคืน ซึ่งจะทำให้ไม่สามารถใช้ค่าที่ส่งคืนได้โดยตรงโดยไม่มีตัวแปรตัวกลาง (เช่น คีย์แผนที่)
วิธีที่สวยงามกว่าคือใส่การตรวจสอบความถูกต้องในตัวสร้างสำหรับประเภท VIN
เพื่อให้มีการเรียกใช้ฟังก์ชัน Manufacturer()
สำหรับ VIN ที่ถูกต้องเท่านั้น และไม่ต้องการการตรวจสอบและการจัดการข้อผิดพลาด:
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) }
แน่นอน เราได้เพิ่มการทดสอบสำหรับฟังก์ชัน NewVIN
VIN ที่ไม่ถูกต้องถูกปฏิเสธโดย Constructor:
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) } }
การทดสอบสำหรับฟังก์ชัน Manufacturer()
สามารถละเว้นการทดสอบ VIN ที่ไม่ถูกต้องได้ เนื่องจากมันจะถูกปฏิเสธโดยตัวสร้าง NewVIN
แล้ว
Go OOP Pitfall: ความหลากหลายในทางที่ผิด
ต่อไป เราต้องการแยกความแตกต่างระหว่าง VIN ของยุโรปและที่ไม่ใช่ของยุโรป วิธีหนึ่งคือการขยาย type
VIN เป็น struct
และจัดเก็บว่า VIN นั้นเป็นแบบยุโรปหรือไม่ ปรับปรุง Construct ตามลำดับ:
type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }
ทางออกที่หรูหรากว่าคือการสร้างประเภทย่อยของ VIN
สำหรับ VIN ของยุโรป ที่นี่ แฟล็กถูกจัดเก็บโดยปริยายในข้อมูลประเภท และฟังก์ชัน Manufacturer()
สำหรับ VIN ที่ไม่ใช่ของยุโรปจะดีและกระชับ:
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 }
ในภาษา OOP เช่น Java เราคาดว่าประเภทย่อย EUVIN
จะใช้งานได้ในทุกที่ที่ระบุประเภท VIN
ขออภัย สิ่งนี้ใช้ไม่ได้ใน 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) } } }
พฤติกรรมนี้สามารถอธิบายได้ด้วยการเลือกโดยเจตนาของทีมพัฒนา Go ที่จะไม่สนับสนุนการเชื่อมโยงแบบไดนามิกสำหรับประเภทที่ไม่ใช่อินเทอร์เฟซ ช่วยให้คอมไพเลอร์รู้ว่าฟังก์ชันใดจะถูกเรียกในเวลาคอมไพล์ และหลีกเลี่ยงโอเวอร์เฮดของวิธีการจัดส่งแบบไดนามิก ทางเลือกนี้ยังกีดกันการใช้มรดกเป็นรูปแบบองค์ประกอบทั่วไป อินเทอร์เฟซเป็นวิธีที่จะไปแทน (ให้อภัยการเล่นสำนวน)

Golang OOP Success: ความหลากหลายทางที่ถูกต้อง
คอมไพเลอร์ Go ถือว่าประเภทเป็นการนำอินเทอร์เฟซไปใช้เมื่อใช้งานฟังก์ชันที่ประกาศไว้ (การพิมพ์เป็ด) ดังนั้น เพื่อใช้ประโยชน์จากความหลากหลาย ประเภท VIN
จะถูกแปลงเป็นอินเทอร์เฟซที่นำมาใช้โดยประเภท VIN ทั่วไปและแบบยุโรป โปรดทราบว่าไม่จำเป็นสำหรับประเภท VIN ของยุโรปที่จะเป็นประเภทย่อยของประเภททั่วไป
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 }
การทดสอบ polymorphism ตอนนี้ผ่านการดัดแปลงเล็กน้อย:
// 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) } } }
อันที่จริง ขณะนี้สามารถใช้ VIN ทั้งสองประเภทในทุกที่ที่ระบุอินเทอร์เฟซ VIN
เนื่องจากทั้งสองประเภทสอดคล้องกับข้อกำหนดของอินเทอร์เฟซ VIN
Golang เชิงวัตถุ: วิธีใช้การพึ่งพาการฉีด
สุดท้ายแต่ไม่ท้ายสุด เราต้องตัดสินใจว่า VIN นั้นเป็นของยุโรปหรือไม่ สมมติว่าเราพบ API ภายนอกที่ให้ข้อมูลนี้แก่เรา และเราได้สร้างไคลเอนต์สำหรับมัน:
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 }
นอกจากนี้เรายังได้สร้างบริการที่จัดการ VIN และสามารถสร้างได้:
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) }
ใช้งานได้ดีตามการทดสอบที่แก้ไขแล้ว:
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) } }
ปัญหาเดียวที่นี่คือการทดสอบต้องมีการเชื่อมต่อสดกับ API ภายนอก เป็นเรื่องที่น่าเสียดาย เนื่องจาก API อาจออฟไลน์หรือไม่สามารถเข้าถึงได้ นอกจากนี้ การเรียก API ภายนอกต้องใช้เวลาและอาจมีค่าใช้จ่าย
เนื่องจากทราบผลลัพธ์ของการเรียก API จึงควรแทนที่ด้วยการจำลองได้ ขออภัย ในโค้ดด้านบน VINService
สร้างไคลเอ็นต์ API ขึ้นมาเอง ดังนั้นจึงไม่มีวิธีง่ายๆ ในการแทนที่ เพื่อให้เป็นไปได้ ควรเพิ่มการพึ่งพาไคลเอ็นต์ API ลงใน VINService
นั่นคือ มันควรจะถูกสร้างขึ้นก่อนที่จะเรียกตัวสร้าง VINService
แนวทาง Golang OOP ในที่นี้คือ ไม่มีคอนสตรัคเตอร์ใดควรเรียกคอนสตรัคเตอร์อื่น หากใช้อย่างละเอียด ทุกซิงเกิลที่ใช้ในแอปพลิเคชันจะถูกสร้างขึ้นที่ระดับบนสุด โดยทั่วไปแล้ว นี่จะเป็นฟังก์ชันการบูตสแตรปที่สร้างวัตถุที่จำเป็นทั้งหมดโดยการเรียกตัวสร้างตามลำดับที่เหมาะสม โดยเลือกการใช้งานที่เหมาะสมสำหรับฟังก์ชันการทำงานที่ตั้งใจไว้ของโปรแกรม
ขั้นตอนแรกคือการทำให้ VINAPIClient
เป็นอินเทอร์เฟซ:
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 }
จากนั้น ลูกค้าใหม่สามารถถูกฉีดเข้าไปใน 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) }
ด้วยเหตุนี้ จึงสามารถใช้ไคลเอ็นต์ API จำลองสำหรับการทดสอบได้ นอกจากหลีกเลี่ยงการเรียก API ภายนอกระหว่างการทดสอบแล้ว หุ่นจำลองยังสามารถทำหน้าที่เป็นโพรบเพื่อรวบรวมข้อมูลเกี่ยวกับการใช้ API ในตัวอย่างด้านล่าง เราแค่ตรวจสอบว่ามีการเรียกฟังก์ชัน IsEuropean
จริงหรือไม่
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) } }
การทดสอบนี้ผ่าน เนื่องจากโพรบ IsEuropean
ของเราทำงานหนึ่งครั้งระหว่างการเรียก CreateFromCode
การเขียนโปรแกรมเชิงวัตถุใน Go: ชุดค่าผสมที่ชนะ (เมื่อทำถูกต้อง)
นักวิจารณ์อาจพูดว่า "ทำไมไม่ใช้ Java ถ้าคุณทำ OOP อยู่ล่ะ" เนื่องจากคุณได้รับข้อดีอื่น ๆ ของ Go ในขณะที่หลีกเลี่ยง VM / JIT ที่ต้องใช้ทรัพยากรมาก เฟรมเวิร์กที่มีคำอธิบายประกอบวูดู การจัดการข้อยกเว้น และช่วงพักดื่มกาแฟขณะทำการทดสอบ (อย่างหลังอาจมีปัญหาสำหรับบางคน)
จากตัวอย่างข้างต้น จะเห็นได้ชัดว่าการเขียนโปรแกรมเชิงวัตถุใน Go สามารถสร้างโค้ดที่เข้าใจได้ดีกว่าและทำงานเร็วขึ้นได้อย่างไร เมื่อเทียบกับการใช้งานที่เรียบง่ายและจำเป็น แม้ว่า Go จะไม่ใช่ภาษา OOP แต่ก็มีเครื่องมือที่จำเป็นในการจัดโครงสร้างแอปพลิเคชันในลักษณะเชิงวัตถุ เมื่อใช้ร่วมกับฟังก์ชันการจัดกลุ่มในแพ็คเกจ OOP ใน Golang สามารถยกระดับเพื่อให้โมดูลที่นำกลับมาใช้ใหม่ได้เป็นส่วนประกอบสำคัญสำหรับแอปพลิเคชันขนาดใหญ่
ในฐานะ Google Cloud Partner ผู้เชี่ยวชาญที่ผ่านการรับรองจาก Google ของ Toptal พร้อมให้บริการสำหรับบริษัทต่างๆ ที่ต้องการสำหรับโครงการที่สำคัญที่สุดของพวกเขา