منطق منظم جيدًا: برنامج تعليمي لـ Golang OOP

نشرت: 2022-03-11

هل Go وجوه المنحى؟ يمكن ان تكون؟ Go (أو "Golang") هي لغة برمجة لما بعد OOP تقترض هيكلها (الحزم ، والأنواع ، والوظائف) من عائلة لغة Algol / Pascal / Modula. ومع ذلك ، في Go ، لا تزال الأنماط الموجهة للكائنات مفيدة في هيكلة البرنامج بطريقة واضحة ومفهومة. سيأخذ برنامج Golang التعليمي هذا مثالًا بسيطًا ويوضح كيفية تطبيق مفاهيم وظائف الربط على الأنواع (الملقب بالفئات) ، والمنشآت ، والتصنيف الفرعي ، وتعدد الأشكال ، وحقن التبعية ، والاختبار باستخدام النماذج.

دراسة حالة في 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 .
  • الجزء الثاني الاختياري من المعرف هو سمة من سمات VINs الأوروبية فقط. ستعيد الوظيفة معرفات خاطئة للسيارات الأمريكية التي تحتوي على 9 كالرقم الثالث من رمز الشركة المصنعة.

لحل هذه المشكلات ، سنقوم بإعادة تشكيلها باستخدام الأنماط الشيئية.

Go OOP: وظائف الربط بنوع

أول إعادة بناء ديون هو جعل VINs من النوع الخاص بها وربط وظيفة 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) }

ثم نقوم بتعديل الاختبار وتقديم مشكلة VINs غير الصالحة:

 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 عند التعامل مع رقم تعريف شخصي غير صالح ، من الممكن إضافة فحوصات الصلاحية إلى وظيفة 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 غير الصالحة من قبل المُنشئ:

 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: تعدد الأشكال بالطريقة الخاطئة

بعد ذلك ، نريد التفريق بين VINs الأوروبية وغير الأوروبية. تتمثل إحدى الطرق في توسيع type struct إلى هيكل وتخزين ما إذا كان VIN أوروبيًا أم لا ، مما يؤدي إلى تعزيز المُنشئ وفقًا لذلك:

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

الحل الأكثر أناقة هو إنشاء نوع فرعي من VIN لـ VINs الأوروبية. هنا ، يتم تخزين العلم ضمنيًا في معلومات النوع ، وتصبح وظيفة Manufacturer() لـ VINs غير الأوروبية لطيفة وموجزة:

 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: تعدد الأشكال بالطريقة الصحيحة

يتعامل برنامج التحويل البرمجي Go مع النوع باعتباره تنفيذًا للواجهة عندما يقوم بتنفيذ الوظائف المُعلنة (كتابة duck). لذلك ، للاستفادة من تعدد الأشكال ، يتم تحويل نوع 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 }

يمر اختبار تعدد الأشكال الآن بتعديل بسيط:

 // 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) أوروبيًا أم لا. لنفترض أننا وجدنا واجهة برمجة تطبيقات خارجية توفر لنا هذه المعلومات ، وقمنا ببناء عميل لها:

 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 }

لقد أنشأنا أيضًا خدمة تتعامل مع أرقام التعريف الشخصية ، ويمكننا على وجه الخصوص إنشاؤها:

 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 معروفة ، يجب أن يكون من الممكن استبدالها بـ mock. لسوء الحظ ، في الكود أعلاه ، تقوم 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 للاختبار. إلى جانب تجنب الاستدعاءات لواجهة برمجة تطبيقات خارجية أثناء الاختبارات ، يمكن أن يعمل النموذج أيضًا كمحقق لجمع البيانات حول استخدام واجهة برمجة التطبيقات. في المثال أدناه ، نتحقق فقط من استدعاء وظيفة 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.

كشريك Google Cloud Partner ، يتوفر خبراء Toptal المعتمدون من Google للشركات عند الطلب لأهم مشاريعهم.