قدمت لغة جو في الإصدار 1.18 ميزةً جديدة تُعرف باسم الأنواع المُعمّمة generic types -أو اختصارًا generics- والتي كانت في قائمة أمنيات مُطوّري هذه اللغة لبعض الوقت. والنوع المُعمّم في لغات البرمجة، هو نوع يمكن استخدامه مع أنواع متعددة أخرى.
سابقًا، عندما كنا نرغب في استخدام نوعين مختلفين لنفس المتغير في لغة جو، كنا نحتاج إما لاستخدام واجهة مُعرّفة بأسلوب معين مثل io.Reader
، أو استخدام {}interface
الذي يسمح باستخدام أي قيمة. المشكلة في أن استخدام {}interface
يجعل التعامل مع تلك الأنواع صعبًا، لأنه يجب التحويل بين العديد من الأنواع الأخرى المحتملة للتفاعل معها (عليك تحويل القيم بين أنواع مختلفة من البيانات للتعامل معها). على سبيل المثال، إذا كان لدينا قيمة من {}interface
تمثل عددًا، يجب علينا تحويلها إلى int
قبل أن تتمكن من إجراء العمليات الحسابية عليها. هذه العملية قد تكون معقدة وتستلزم كتابة الكثير من التعليمات الإضافية للتعامل مع تحويل الأنواع المختلفة، أما الآن وبفضل الأنواع المُعمّمة، يمكننا التفاعل مباشرةً مع الأنواع المختلفة دون الحاجة للتحويل بينها، مما يؤدي إلى شيفرة نظيفة سهلة القراءة.
نُنشئ في هذا المقال برنامجًا يتفاعل مع مجموعة من البطاقات، إذ سنبدأ بإنشائها بحيث تستخدم {}interface
للتفاعل مع البطاقات الأخرى، ثم نُحدّثها من أجل استخدام الأنواع المُعمّمة، ثم سنضيف نوعًا ثانيًا من البطاقات باستخدام الأنواع المُعمّمة، ثم سنُحدّثها لتقييد نوعها المُعمّم، بحيث تدعم أنواع البطاقات فقط. نُنشئ أخيرًا دالةً تستخدم البطاقات الخاصة بنا وتدعم الأنواع المُعمّمة.
المتطلبات الأولية
لمتابعة هذا المقال التعليمي، سنحتاج إلى:
- إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده.
- تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS.
- تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز.
- فهم قوي لأساسيات لغة جو، مثل المتغيرات والدوال وأنواع البيانات والشرائح والحلقات ..إلخ. كل الأساسيات ومقالات أخرى متقدمة في هذه اللغة تجدها هنا.
التجميعات Collections في لغة جو بدون استخدام الأنواع المعممة
يشير مصطلح التجميعة Collection في هذا السياق إلى حاوية بيانات يمكن أن تحتوي على قيم أو عناصر متعددة، وهو مفهوم ذو مستوى أعلى يشمل أنواعًا مختلفة من هياكل البيانات، مثل المصفوفات والقوائم والمجموعات والروابط maps، مما يسمح بتخزين وتنظيم عدة قيم مرتبطة معًا. توفر التجميعات عمليات وتوابع للوصول إلى العناصر التي تحتويها ومعالجتها وتكرارها. نستخدم مصطلح "التجميعة" هنا لوصف الهيكل أو الحاوية المستخدمة لتخزين وإدارة أوراق اللعب في البرنامج.
تتمتع لغة جو بميزة قوية تتمثّل في قدرتها على تمثيل العديد من الأنواع بطريقة مرنة باستخدام الواجهات interfaces. يمكن للكثير من الشيفرات المكتوبة بلغة جو أن تعمل جيدًا باستخدام دوال الواجهات المتاحة، وهذا هو أحد الأسباب التي جعلت اللغة موجودةً لفترة طويلة دون دعم الأنواع المُعمّمة.
نُنشئ في هذا المقال برنامجًا يحاكي عملية الحصول على بطاقة لعب عشوائية PlayingCard
من مجموعة بطاقات Deck
. سنستخدم في هذا القسم {}interface
للسماح للدستة Deck
بالتفاعل مع أي نوع من البطاقات. نُعدّل لاحقًا البرنامج لاستخدام الأنواع المُعمّمة لنتمكن من فهم الفروق بينهما والتعرف على الحالات التي تكون فيها الأنواع المُعمّمة خيارًا أفضل من غيرها.
يمكن تصنيف أنظمة النوع type systems في لغات البرمجة عمومًا إلى فئتين مختلفتين: النوع typing والتحقق من النوع type checking. تُشير الفئة الأولى إلى كيفية التعامل مع الأنواع في اللغة وكيفية تعريف وتمثيل أنواع البيانات المختلفة. هناك نوعان رئيسيان من أنظمة "النوع": النوع القوي strong والنوع الضعيف weak وكذلك الثابت static والديناميكي dynamic. تُطبّق قواعد صارمة في النوع القوي تضمن توافق الأنواع وعدم وجود تضارب بينها. على سبيل المثال، لا يمكن تخزين قيمة من نوع بيانات ما في متغير من نوع بيانات آخر (لا يمكن تخزين قيمة int
في متغير string
) إلا إذا كانت هناك توافقية بينهما. أما في النوع الضعيف، فقد تسمح اللغة بتحويل تلقائي للأنواع من خلال عمليات تحويل ضمنية.
أما بالنسبة "للتحقق من النوع"، فهذا يشير إلى كيفية التحقق من صحة استخدام الأنواع في البرنامج. هناك نوعان رئيسيان من أنظمة التحقق من النوع: التحقق الثابت statically-checked والتحقق الديناميكي dynamically-checked. تُفحص الأنواع في التحقق الثابت خلال عملية التصريف ويُفحص توافقها وصحتها والتأكد من عدم وجود أخطاء في النوع قبل تنفيذ البرنامج. أما في التحقق الدينامكي، فإن الفحص يجري أثناء تنفيذ البرنامج نفسه، ويمكن للأنواع أن تتغير وتحول ديناميكيًا خلال تنفيذ البرنامج.
تنتمي لغة جو عمومًا إلى فئة اللغات ذات النوع القوي والتحقق الثابت، إذ تستخدم قواعد صارمة للتحقق من توافق الأنواع وتتحقق من صحتها خلال عملية التصريف، مما يساعد في تجنب الأخطاء الناتجة عن تعامل غير صحيح مع الأنواع في البرنامج، لكن يترتب على ذلك بعض القيود على البرامج، لأنه يجب أن تُعرّف الأنواع التي تنوي استخدامها قبل تصريف البرنامج. يمكن التعامل مع هذا من خلال استخدام النوع {}interface
، إذ يعمل نوع {}interface
مع أي قيمة.
لبدء إنشاء البرنامج باستخدام {}interface
لتمثيل البطاقات، نحتاج إلى مجلد للاحتفاظ بمجلد البرنامج فيه، وليكن باسم "projects". أنشئ المجلد وانتقل إليه:
$ mkdir projects $ cd projects
نُنشئ الآن مجلدًا للمشروع، وليكن باسم "generics" ثم ننتقل إليه:
$ mkdir generics $ cd generics
من داخل هذا المجلد، نفتح الملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده:
$ nano main.go
نضع مقتطف الشيفرة التالي داخل هذا الملف، إذ نُصرّح عن الحزمة package
لإخبار المُصرّف أن يحوّل البرنامج إلى ملف ثنائي لتتمكّن من تشغيله مباشرةً. نستورد أيضًا الحزم اللازمة عن طريق تعليمة import
:
package main import ( "fmt" "math/rand" "os" "time" )
نُعرّف النوع PlayingCard
والدوال والتوابع المرتبطة به:
... type PlayingCard struct { Suit string Rank string } func NewPlayingCard(suit string, card string) *PlayingCard { return &PlayingCard{Suit: suit, Rank: card} } func (pc *PlayingCard) String() string { return fmt.Sprintf("%s of %s", pc.Rank, pc.Suit) }
عرّفنا بنية بيانات تُسمى PlayingCard
مع الخصائص نوع البطاقة Suit
وترتيبها Rank
لتمثيل مجموعة ورق اللعب Deck
المكونة من 52 بطاقة. يكون Suit
أحد القيم التالية: Diamonds
أو Hearts
أو Clubs
أو Spades
. أما Rank
يكون أما A
أو 2
أو 3
، وهكذا حتى K
.
عرّفنا أيضًا دالة NewPlayingCard
لتكون باني constructor لبنية البيانات PlayingCard
، وتابع String
ليُرجع ترتيب ونوع البطاقة باستخدام fmt.Sprintf
.
ننشئ الآن النوع Deck
بالدوال AddCard
و RandomCard
، إضافةً إلى الدالة NewPlayingCardDeck
لإنشاء Deck*
مملوء بجميع بطاقات اللعبة التي عددها 52.
... type Deck struct { cards []interface{} } func NewPlayingCardDeck() *Deck { suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"} ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} deck := &Deck{} for _, suit := range suits { for _, rank := range ranks { deck.AddCard(NewPlayingCard(suit, rank)) } } return deck } func (d *Deck) AddCard(card interface{}) { d.cards = append(d.cards, card) } func (d *Deck) RandomCard() interface{} { r := rand.New(rand.NewSource(time.Now().UnixNano())) cardIdx := r.Intn(len(d.cards)) return d.cards[cardIdx] }
في البنية Deck
المُعرّفة أعلاه، أنشأنا حقلًا يُسمّى cards
لاحتواء مجموعة ورق اللعب. نظرًا لأننا نرغب في أن تكون مجموعة البطاقات قادرة على استيعاب أنواع متعددة مختلفة من البطاقات، فلا يمكن تعريفها فقط على أنها PlayingCard*
. لذا عرّفناها على أنها {}interface[]
حتى نتمكن من استيعاب أي نوع من البطاقات التي قد نُنشئها مستقبلًا. بالإضافة إلى الحقل {}interface[]
في Deck
، أنشأنا التابع AddCard
الذي يقبل قيمة من نفس نوع interface{}
لإضافة بطاقة إلى حقل البطاقات في Deck
.
أنشأنا أيضًا التابع RandomCard
الذي يُرجع بطاقةً عشوائية من cards
في Deck
. يستخدم هذا التابع حزمة math/rand
لإنشاء رقم عشوائي بين 0
وعدد البطاقات في المجموعة. يُنشئ سطر rand.New
مولد رقم عشوائي جديد باستخدام الوقت الحالي مثل مصدر للعشوائية، وإلا فإن الرقم العشوائي يمكن أن يكون نفسه في كل مرة. يستخدم السطر ((r.Intn(len(d.cards
مولد الأرقام العشوائية لإنشاء قيمة صحيحة بين 0
والعدد المُقدم. نظرًا لأن التابع Intn
لا يشمل قيمة المعامل ضمن نطاق الأرقام الممكنة، فلا حاجة لطرح 1
من الطول لمعالجة فكرة البدء من 0
(إذا كان لدينا مجموعة ورق لعب تحتوي على 10
عناصر، فالأرقام الممكنة ستكون من 0
إلى 9
). لدينا أيضًا RandomCard
تُرجع قيمة البطاقة في الفهرس المحدد بالرقم العشوائي.
تحذير: كن حذرًا في اختيار مولد الأرقام العشوائية الذي تستخدمه في برامجك. ليست حزمة math/rand
آمنة من الناحية التشفيرية ولا ينبغي استخدامها في البرامج التي تتعلق بالأمان. توفر حزمة crypto/rand
مولد أرقام عشوائية يمكن استخدامه لهذه الأغراض.
بالنسبة للدالة NewPlayingCardDeck
فهي تُرجع القيمة Deck*
مملوءة بجميع البطاقات الموجودة في دستة بطاقات اللعب. نستخدم شريحتين، واحدة تحتوي على جميع الأشكال المتاحة وأخرى تحتوي على جميع الرتب المتاحة، ثم نكرر فوق كل قيمة لإنشاء PlayingCard*
جديدة لكل تركيبة قبل إضافتها إلى المجموعة باستخدام AddCard
. تُرجع القيمة بمجرد إنشاء بطاقات مجموعة ورق اللعب.
بعد إعداد Deck
و PlayingCard
، يمكننا إنشاء الدالة main
لاستخدامها لسحب البطاقات:
... func main() { deck := NewPlayingCardDeck() fmt.Printf("--- drawing playing card ---\n") card := deck.RandomCard() fmt.Printf("drew card: %s\n", card) playingCard, ok := card.(*PlayingCard) if !ok { fmt.Printf("card received wasn't a playing card!") os.Exit(1) } fmt.Printf("card suit: %s\n", playingCard.Suit) fmt.Printf("card rank: %s\n", playingCard.Rank) }
أنشأنا بدايةً مجموعةً جديدة من البطاقات باستخدام الدالة NewPlayingCardDeck
ووضعناها في المتغير deck
، ثم استخدمنا fmt.Printf
لطباعة جملة تدل على أننا نسحب بطاقة. نستخدم التابع RandomCard
من deck
للحصول على بطاقة عشوائية من المجموعة. نستخدم بعد ذلك fmt.Printf
مرةً أخرى لطباعة البطاقة التي سحبناها من المجموعة.
نظرًا لأن نوع المتغير card
هو {}interface
، سنحتاج إلى استخدام توكيد النوع type assertion للحصول على مرجع إلى البطاقة بنوعها الأصلي PlayingCard*
. إذا كان النوع في المتغير card
ليس نوع PlayingCard*
، الذي يجب أن يكون عليه وفقًا لطريقة كتابة البرنامج الحالية، ستكون قيمة ok
تساوي false
وسيطبع البرنامج رسالة خطأ باستخدام fmt.Printf
ويخرج مع شيفرة خطأ 1
باستخدام os.Exit
. إذا كان النوع PlayingCard*
سيطبع البرنامج قيمتي Suit
و Rank
من playingCard
باستخدام fmt.Printf
.
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
يُفترض أن نرى في الخرج بطاقة مُختارة عشوائيًا من المجموعة، إضافةً إلى نوع البطاقة وترتيبها:
--- drawing playing card --- drew card: Q of Diamonds card suit: Diamonds card rank: Q
قد تكون النتيجة المطبوعة مختلفةً عن النتيجة المعروضة أعلاه، لأن البطاقة مسحوبة عشوائيًا من المجموعة، لكن يجب أن تكون مشابهة. يُطبع السطر الأول قبل سحب البطاقة العشوائية من المجموعة، ثم يُطبع السطر الثاني بمجرد سحب البطاقة. يمكن رؤية خرج البطاقة باستخدام القيمة المُرجعة من التابع String
من PlayingCard
. يمكننا أخيرًا رؤية سطري النوع والرتبة المطبوعة بعد تحويل قيمة {}interface
المستلمة إلى قيمة PlayingCard*
مع الوصول إلى حقول Suit
و Rank
.
أنشأنا في هذا القسم مجموعة Deck
تستخدم قيم {}interface
لتخزين والتفاعل مع أي قيمة، وأيضًا نوع PlayingCard
ليكون بمثابة البطاقات داخل هذه الدستة Deck
، ثم استخدمنا Deck
و PlayingCard
لاختيار بطاقة عشوائية من المجموعة وطباعة معلومات حول تلك البطاقة.
للوصول إلى معلومات محددة تتعلق بقيمة PlayingCard*
التي سُحبت، كُنّا بحاجة لعمل إضافي يتجلى بتحويل النوع {}interface
إلى النوع PlayingCard*
مع الوصول إلى حقول Suit
و Rank
. ستعمل الأمور في المجموعة Deck
جيدًا بهذه الطريقة، ولكن قد ينتج أخطاء إذا أُضيفت قيمة أخرى غير PlayingCard*
إلى المجموعة Deck
.
سنُعدّل في القسم التالي المجموعة Deck
، بحيث يمكننا الاستفادة من خصائص الأنواع القوية والتحقق الثابت للأنواع في لغة جو، مع الاحتفاظ بالمرونة في قبول قيم {}interface
، وذلك من خلال استخدام الأنواع المعممة.
التجميعات في لغة جو مع استخدام الأنواع المعممة
أنشأنا في القسم السابق تجميعةً باستخدام شريحة من أنواع {}interface
، ولكن كان علينا إتمام عمل إضافي لاستخدام تلك القيم يتجلى بتحويل القيم من نوع {}interface
إلى النوع الفعلي لتلك القيم. يمكننا باستخدام الأنواع المعممة إنشاء معامل واحد أو أكثر للأنواع، والتي تعمل تقريبًا مثل معاملات الدالة، ولكن يمكن أن تحتوي على أنواع بدلًا من بيانات. توفر الأنواع المعممة بهذا الأسلوب طريقةً لاستبدال نوع مختلف من معاملات الأنواع في كل مرة يجري فيها استخدام النوع المعمم. هنا يأتي اسم الأنواع المعممة؛ فبما أن النوع المعمم يمكن استخدامه مع أنواع متعددة، وليس فقط نوع محدد مثل io.Reader
أو {}interface
، يكون عامًا بما يكفي ليناسب عدة حالات استخدام.
نُعدّل في هذا القسم مجموعة ورق اللعب Deck
لتكون من نوع معمم يمكنه استخدام أي نوع محدد للبطاقة عند إنشاء نسخة من Deck
بدلًا من استخدام {}interface
.
نفتح ملف "main.go" ونحذف استيراد حزمة os
:
package main import ( "fmt" "math/rand" // "os" حذف استيراد "time" )
لن نحتاج بعد الآن إلى استخدام دالة os.Exit
، لذا فمن الآمن حذف هذا الاستيراد. نُعدّل البنية Deck
الآن لتكون نوعًا معممًا:
... type Deck[C any] struct { cards []C }
يقدم هذا التحديث الصيغة الجديدة لتعريف البنية Deck
، بحيث نجعلها تحقق مفهوم النوع المُعمم، وذلك من خلال استخدام مفهوم "الموضع المؤقت Placeholder" أو "معاملات النوع Type parameters". يمكننا التفكير في هذه المعاملات بطريقة مشابهة للمعاملات التي نُضمّنُها في دالة؛ فعند استدعاء دالة، تُقدم قيمًا لكل معامل في الدالة. وهنا أيضًا، عند إنشاء قيمة من النوع المعمم، نُقدّم أنواعًا لمعاملات الأنواع.
نلاحظ بعد اسم البنية Deck
أننا أضفنا عبارة داخل قوسين مربعين ([]
)، إذ تسمح هذه الأقواس المعقوفة لنا بتحديد معامل أو أكثر من هذه المعاملات للبنية.
نحتاج في حالتنا للنوع Deck
إلى معامل نوع واحد فقط، يُطلق عليه اسم C
، لتمثيل نوع البطاقات في المجموعة deck
. من خلال كتابتنا C any
نكون قد صرّحنا عن معامل نوع باسم C
يمكننا استخدامه في البنية struct
ليُمثّل أي نوع (any
). ينوب any
عن الأنواع المختلفة، أي يُعرف نوعه في الزمن الحقيقي، أي وقت تمرير قيمته، فإذا كانت القيمة المُمررة سلسلة يكون سلسلة، وإذا كانت عددًا صحيحًا يكون صحيحًا.
يُعد النوع any
في حقيقة الأمر اسمًا بديلًا للنوع {}interface
، وهذا ما يجعل الأنواع المعممة أكثر سهولة للقراءة، فنحن بغنى عن كتابة {}C interface
ونكتب C any
فقط. يحتاج deck
الخاص بنا إلى نوع معمم واحد فقط لتمثيل البطاقات، ولكن إذا كنا نحتاج إلى أنواع معممة إضافية، فيمكن إضافتها من خلال فصلها بفواصل، مثل F any
, C any
. يمكن أن يكون اسم المعامل المُستخدم لمعاملات النوع أي شيء نرغب فيه إذا لم يكن محجوزًا، ولكنها عادةً قصيرة وتبدأ بحرف كبير.
عدّلنا أيضًا نوع الشريحة cards
في البنية Deck
ليكون من النوع C
. عند استخدام مفهوم الأنواع المعممة، يمكننا استخدام معاملات النوع لتنوب عن أي نوع، أي يمكننا وضعها في نفس الأماكن التي نضع فيها أنواع البيانات عادةً. في حالتنا، نريد أن يمثل المعامل C
كل بطاقة في الشريحة، لذا نضع اسم الشريحة ثم []
ثم المعامل C
الذي سينوب عن النوع.
نُعدّل الآن التابع AddCard
لاستخدام النوع المعمّم الذي عرّفناه، لكن سنتخطى تعديل دالة NewPlayingCardDeck
حاليًا:
... func (d *Deck[C]) AddCard(card C) { d.cards = append(d.cards, card) }
تضمّن التحديث الخاص بالتابع AddCard
في Deck
، إضافة المعامل المُعمّم [C]
إلى المستقبل الخاص بالتابع. يُخبر هذا لغة جو باسم المعامل المُعمّم الذي سنستخدمه في أماكن أخرى في تصريح التابع، ويتيح لنا ذلك معرفة النوع المُمرّر مثل قيمة إلى معامل النوع C
عند استخدام التابع. يتبع هذا النوع من الكتابة بأقواس معقوفة نفس طريقة التصريح عن بنية struct
، إذ يُعرّف معامل النوع داخل الأقواس المعقوفة بعد اسم الدالة والمستقبل. لا نحتاج في حالتنا إلى تحديد أي قيود لأنها مُحددة فعلًا في تصريح Deck
. عدّلنا أيضًا معامل الدالة card
لاستخدام معامل النوع C
بدلًا من النوع الأصلي {}interface
، وهذا يتيح للدالة استخدام النوع C
الذي سيُعرف ما هو لاحقًا.
بعد تعديل التابع AddCard
، نُعدّل التابع RandomCard
لاستخدام النوع المعمم C
أيضًا:
... func (d *Deck[C]) RandomCard() C { r := rand.New(rand.NewSource(time.Now().UnixNano())) cardIdx := r.Intn(len(d.cards)) return d.cards[cardIdx] }
بدلًا من استخدام نوع معمّم C
مثل معامل دالة، عدّلنا التابع ()RandomCard
ليعيد قيمةً من النوع المُعمّم C
بدلًا من معامل الدالة {}interface
. هذا يعني أنه عند استدعاء هذا التابع، سنحصل على قيمة من النوع C
المحدد، بدلًا من الحاجة إلى تحويل القيمة إلى نوع محدد بواسطة "تأكيدات النوع type assertion". عدّلنا أيضًا المستقبل الخاص بالدالة ليتضمن [C]
، وهو ما يخبر لغة جو بأن الدالة هي جزء من Deck
التي تستخدم النوع المعمم C
مثل معامل. لا نحتاج إلى أي تحديثات أخرى في الدالة. حقل cards
في Deck
مُعدّل فعلًا في تصريح البنية struct
ليكون من النوع C[]
، مما يعني أنه عندما يعيد التابع قيمة من cards
، فإنه يعيد قيمةً من النوع C
المحدد، وبالتالي لا حاجة لتحويل النوع بعد ذلك.
بعد تعديلنا للنوع Deck
لاستخدام الأنواع المعممة، نعود للدالة NewPlayingCardDeck
لجعلها تستخدم النوع المعمم Deck
مع الأنواع PlayingCard*
:
... func NewPlayingCardDeck() *Deck[*PlayingCard] { suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"} ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} deck := &Deck[*PlayingCard]{} for _, suit := range suits { for _, rank := range ranks { deck.AddCard(NewPlayingCard(suit, rank)) } } return deck } ...
تبقى معظم الشيفرة في NewPlayingCardDeck
كما هي، باستثناء بعض التغييرات البسيطة، ولكن الآن نستخدم إصدارًا مُعمّمًا من Deck
، ويجب علينا تحديد النوع الذي نرغب في استخدامه مع C
عند استخدام Deck
. نفعل ذلك عن طريق الإشارة إلى نوع Deck
بالطريقة المعتادة، سواء كان Deck
نفسه أو مرجعًا مثل Deck*
، ثم تقديم النوع الذي يجب استبدال C
به باستخدام نفس الأقواس المعقوفة التي استخدمناها عند التصريح عن معاملات النوع في البداية. بمعنى آخر، عندما نُعرّف متغير من نوع Deck
المعمّم، يجب علينا تحديد النوع الفعلي للمعامل C
؛ فإذا كانت Deck
من النوع Deck*
، يجب أن نُوفّر النوع الفعلي الذي يجب استبدال C
به في الأقواس المعقوفة مثل [PlayingCard*]
. هكذا نحصل على نسخة محددة من Deck
لنوع البطاقات التي ترغب في استخدامها، والتي في هذه الحالة هي PlayingCard*
.
يمكن تشبيه هذه العملية لإرسال قيمة لمعامل دالة عند استدعاء الدالة؛ فعند استخدامنا لنوع Deck
المعمّم، فإننا نمرر النوع الذي نرغب في استخدامه مثل معامل لهذا النوع.
بالنسبة لنوع القيمة المُعادة في NewPlayingCardDeck
، نستمر في استخدام Deck*
كما فعلت من قبل، لكن هذه المرة، يجب أن تتضمن الأقواس المعقوفة و PlayingCard*
أيضًا؛ فمن خلال تقديم [PlayingCard*]
لمعامل النوع، فأنت تقول أنك ترغب في استخدام النوع PlayingCard*
في تصريح Deck
والتوابع الخاصة به ليحل محل قيمة C
، وهذا يعني أن نوع حقل cards
في Deck
يتغير فعليًا من C[]
إلى PlayingCard*[]
.
بمعنى آخر، عندما ننشئ نسخةً جديدةً من Deck
باستخدام NewPlayingCardDeck
، فإننا نُنشئ Deck
يمكنه تخزين أي نوع محدد من PlayingCard*
. هذا يتيح لنا استخدام مجموعة متنوعة من أنواع البطاقات في Deck
بدلًا من الاعتماد على {}interface
. بالتالي يمكننا الآن التعامل مباشرةً مع البطاقات بنوعها الفعلي بدلًا من الحاجة إلى استبدال الأنواع والتحويلات التي كنا نستخدمها مع {}interface
في الإصدار السابق من Deck
.
بالمثل، عند إنشاء نسخة جديدة من Deck
، يجب علينا أيضًا توفير النوع الذي يحل محل C
. نستخدم عادةً {}Deck&
لإنشاء مرجع جديد إلى Deck
، ولكن بدلًا من ذلك يجب علينا تضمين النوع داخل الأقواس المعقوفة للحصول على {}[PlayingCard*]&
.
الآن بعد أن عدّلنا الأنواع الخاصة بنا لاستخدام الأنواع المُعمّمة، نُعدّل الدالة main
للاستفادة منها:
... func main() { deck := NewPlayingCardDeck() fmt.Printf("--- drawing playing card ---\n") playingCard := deck.RandomCard() fmt.Printf("drew card: %s\n", playingCard) // Code removed fmt.Printf("card suit: %s\n", playingCard.Suit) fmt.Printf("card rank: %s\n", playingCard.Rank) }
أزلنا هذه المرة بعض التعليمات، لأنه لم تعد هناك حاجة لتأكيد قيمة {}interface
على أنها PlayingCard*
. عندما عدّلنا التابع RandomCard
في Deck
لإعادة النوع C
و NewPlayingCardDeck
لإعادة [Deck[*PlayingCard*
، نكون قد عدّلنا التابع RandomCard
بجعله يعيد PlayingCard*
بدلًا من {}interface
. هذا يعني أن نوع playingCard
هو PlayingCard*
أيضًا بدلًا من interface{}
ويمكننا الوصول إلى حقول Suit
و Rank
مباشرةً.
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
سنشاهد خرجًا مشابهًا لما سبق، ولكن البطاقة المسحوبة قد تكون مختلفة:
--- drawing playing card --- drew card: 8 of Hearts card suit: Hearts card rank: 8
على الرغم من أن الخرج هو نفسه مثل الإصدار السابق من البرنامج عندما استخدمنا {}interface
، إلا أن الشيفرة أنظف وتتجنب الأخطاء المحتملة. لم يعد هناك حاجةٌ لتأكيد النوع PlayingCard*
، مما يُجنّبنا التعامل مع الأخطاء الإضافية، كما أنّه من خلال تحديد أن نسخة Deck
يمكن أن تحتوي فقط على PlayingCard*
، لن يكون هناك أي احتمال لوجود قيمة أخرى غير PlayingCard*
تُضاف إلى مجموعة البطاقات.
عدّلنا في هذا القسم البنية Deck
لتكون نوعًا مُعمّمًا، مما يوفر مزيدًا من السيطرة على أنواع البطاقات التي يمكن أن تحتويها كل نسخة من مجموعة أوراق اللعب. عدّلنا أيضًا التابعين AddCard
و RandomCard
إما لقبول وسيط مُعمم أو لإرجاع قيمة مُعممة. عدّلنا أيضًا NewPlayingCardDeck
لإرجاع Deck*
يحتوي على بطاقات PlayingCard*
. أخيرًا أزلنا التعامل مع الأخطاء في الدالة main
لأنه لم يعد هناك حاجة لذلك.
الآن بعد تعديل Deck
ليكون مُعممًا، يمكننا استخدامه لاحتواء أي نوع من البطاقات التي نرغب. سنستفيد في القسم التالي من هذه المرونة من خلال إضافة نوع جديد من البطاقات إلى البرنامج.
استخدام أنواع مختلفة مع الأنواع المعممة
يمكننا -بإنشاء نوع مُعمّم مثل النوع Deck
- استخدامه مع أي نوع آخر؛ فعندما نُنشئ نسخةً من Deck
، ونرغب باستخدامها مع أنواع PlayingCard*
، فإن الشيء الوحيد الذي نحتاجه هو تحديد هذا النوع عند إنشاء القيمة، وإذا أردنا استخدام نوع آخر، نُبدّل نوع PlayingCard*
بالنوع الجديد الذي نرغب باستخدامه.
نُنشئ في هذا القسم بنية بيانات جديدة تُسمى TradingCard
لتمثيل نوع مختلف من البطاقات، ثم نُعدّل البرنامج لإنشاء Deck
يحتوي على عدة TradingCard*
.
لإنشاء النوع TradingCard
، نفتح ملف "main.go" مرةً أخرى ونضيف التعريف التالي:
... import ( ... ) type TradingCard struct { CollectableName string } func NewTradingCard(collectableName string) *TradingCard { return &TradingCard{CollectableName: collectableName} } func (tc *TradingCard) String() string { return tc.CollectableName }
النوع TradingCard
مشابه لنوع PlayingCard
، ولكن بدلًا من وجود حقول Suit
و Rank
، يحتوي على حقل CollectableName
لتتبع اسم بطاقة التداول، كما يتضمن دالة الباني NewTradingCard
والتابع String
، أي بطريقة مشابهة للنوع PlayingCard
.
نُنشئ الآن الدالة البانية NewTradingCardDeck
لإنشاء Deck
مُعبأة بقيم TradingCards*
:
... func NewPlayingCardDeck() *Deck[*PlayingCard] { ... } func NewTradingCardDeck() *Deck[*TradingCard] { collectables := []string{"Sammy", "Droplets", "Spaces", "App Platform"} deck := &Deck[*TradingCard]{} for _, collectable := range collectables { deck.AddCard(NewTradingCard(collectable)) } return deck }
عند إنشاء أو إرجاع Deck*
في هذه الحالة، فإننا نُبدّل قيم PlayingCard*
بقيم TradingCard*
، وهذا هو الأمر الوحيد الذي نحتاج إلى تغييره في Deck
. هناك مجموعة من البطاقات الخاصة، والتي يمكننا المرور عليها لإضافة كل TradingCard*
إلى Deck
. يعمل التابع AddCard
في Deck
بنفس الطريقة كما في السابق، لكن هذه المرة يقبل قيمة TradingCard*
من NewTradingCard
. إذا حاولنا تمرير قيمة من NewPlayingCard
، سيُعطي المُصرّف خطأ لأنه يتوقع TradingCard*
وليس PlayingCard*
.
نُعدّل الدالة main
لإنشاء Deck
جديدة من أجل عدة TradingCard*
، وسحب بطاقة عشوائية باستخدام RandomCard
، وطباعة معلومات البطاقة:
... func main() { playingDeck := NewPlayingCardDeck() tradingDeck := NewTradingCardDeck() fmt.Printf("--- drawing playing card ---\n") playingCard := playingDeck.RandomCard() ... fmt.Printf("card rank: %s\n", playingCard.Rank) fmt.Printf("--- drawing trading card ---\n") tradingCard := tradingDeck.RandomCard() fmt.Printf("drew card: %s\n", tradingCard) fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName) }
أنشأنا مجموعة جديدة من بطاقات التداول باستخدام NewTradingCardDeck
وخزّناها في tradingDeck
. نظرًا لأننا لا نزال نستخدم نفس النوع Deck
كما كان من قبل، يمكننا استخدام RandomCard
للحصول على بطاقة تداول عشوائية من Deck
وطباعة البطاقة. يمكننا أيضًا الإشارة إلى وطباعة حقل CollectableName
مباشرةً من tradingCard
لأن Deck
المُعمّم الذي نستخدمه قد عرّف C
على أنه TradingCard*
.
يُظهر هذا التحديث أيضًا قيمة استخدام الأنواع المُعمّمة. لدعم نوع بطاقة جديد تمامًا، لم نحتاج إلى تغيير Deck
إطلاقًا، فمن خلال معاملات النوع في Deck
تمكّننا من تحديد نوع البطاقة الذي نريده، وبدءًا من هذه النقطة فصاعدًا، يُستخدم النوع TradingCard*
بدلًا من النوع PlayingCard*
في أية تفاعلات مع قيم Deck
.
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
ليكون الخرج على النحو التالي:
--- drawing playing card --- drew card: Q of Diamonds card suit: Diamonds card rank: Q --- drawing trading card --- drew card: App Platform card collectable name: App Platform
يجب أن نرى نتيجةً مشابهة للنتيجة الموضحة أعلاه (مع بطاقات مختلفة بسبب العشوائية) بعد انتهاء تشغيل البرنامج. تظهر بطاقتين مسحوبتين: البطاقة الأصلية للّعب وبطاقة التداول الجديدة المُضافة.
أضفنا في هذا القسم النوع TradingCard
الجديد لتمثيل نوع بطاقة مختلف عن نوع البطاقة الأصلية PlayingCard
. بمجرد إضافة نوع البطاقة TradingCard
،أنشأنا الدالة NewTradingCardDeck
لإنشاء وملء مجموعة ورق اللعب ببطاقات التداول. أخيرًا عدّلنا الدالة main
لاستخدام مجموعة ورق التداول الجديدة وطباعة معلومات مُتعلقة بالبطاقة العشوائية المستخرجة.
بصرف النظر عن إنشاء دالة جديدة NewTradingCardDeck
لملء الدستة ببطاقات مختلفة، لم نحتاج إلى إجراء أي تحديثات أخرى على مجموعة ورق اللعب لدعم نوع بطاقة جديد تمامًا. هذه هي قوة الأنواع المُعمّمة؛ إذ يمكننا كتابة الشيفرة مرةً واحدةً فقط واستخدامها متى أردنا لأنواع مختلفة من البيانات المماثلة أيضًا. هناك مشكلة واحدة في Deck
الحالي، وهي أنه يمكن استخدامه لأي نوع، بسبب التصريح C any
الذي لدينا. قد يكون هذا هو ما نريده حتى نتمكن من إنشاء مجموعة ورق لعب للقيم int
بكتابة {}[Deck[int&
، ولكن إذا كنا نرغب في أن يحتوي Deck
فقط على بطاقات، سنحتاج إلى طريقة لتقييد أنواع البيانات المسموح بها مع C
.
القيود على الأنواع المعممة
غالبًا لا نرغب أو نحتاج إلى أي قيود على الأنواع المستخدمة في الأنواع المُعمّمة لأننا قد لا نهتم بنوع البيانات المُستخدمة، لكن قد نحتاج في أحيان أخرى إلى القدرة على تقييد الأنواع المستخدمة بواسطة الأنواع المُعمّمة. على سبيل المثال، إذا كُنّا نُنشئ نوعًا مُعمّمًا Sorter
، قد نرغب في تقييد أنواعه المُعمّمة لتلك التي تحتوي على تابع Compare
(أي تقييد الأنواع المستخدمة معه، بحيث تكون فقط الأنواع القابلة للمقارنة)، حتى يتمكن Sorter
من مقارنة العناصر التي يحتوي عليها. بدون هذا القيد، فقد لا تحتوي القيم على التابع Compare
، ولن يعرف Sorter
كيفية مقارنتها.
نُنشئ في هذا القسم واجهةً جديدة تسمى Card
، ثم نُعدّل Deck
للسماح فقط بإضافة أنواع Card
.
لتطبيق التحديثات، افتح ملف "main.go" وضِف الواجهة Card
:
... import ( ... ) type Card interface { fmt.Stringer Name() string }
واجهة Card
مُعرّفة بنفس طريقة تعريف أي واجهة أخرى في لغة جو؛ وليس هناك متطلبات خاصة لاستخدامها مع الأنواع المُعمّمة. نحن نقول في واجهة Card
هذه، أنه لكي نعد شيئًا ما بطاقة Card
، يجب أن يُحقق النوع fmt.Stringer
(يجب أن يحتوي على تابع String
الذي تحتويه البطاقات فعلًا)، ويجب أيضًا أن يحتوي على تابع Name
الذي يعيد قيمة من نوع string
.
نُعدّل الآن أنواع TradingCard
و PlayingCard
لإضافة التابع Name
الجديد، إضافةً إلى التابع String
الموجود فعلًا، لكي نستوفي شروط تحقيق الواجهة Card
:
... ... type TradingCard struct { ... } ... func (tc *TradingCard) Name() string { return tc.String() } ... type PlayingCard struct { ... } ... func (pc *PlayingCard) Name() string { return pc.String() }
يحتوي كُلًا من TradingCard
و PlayingCard
فعليًا على تابع String
الذي يُحقق الواجهة fmt.Stringer
. لذا، لتحقيق الواجهة Card
، نحتاج فقط إلى إضافة التابع الجديد Name
. بما أن fmt.Stringer
يمكنها إرجاع أسماء البطاقات، يمكننا ببساطة إرجاع نتيجة التابع String
في التابع Name
.
نُعدّل الآن Deck
، بحيث يُسمح فقط باستخدام أنواع Card
مع C
:
... type Deck[C Card] struct { cards []C }
كان لدينا C any
قبل هذا التحديث مثل قيد نوع type constraint والمعروف أيضًا بتقييد النوع type restriction، وهو ليس قيدًا صارمًا. بما أن any
هو اسم {}interface
البديل (له نفس المعنى)، فقد سمح باستخدام أي نوع في جو بمثابة قيمة للمعامل C
. الآن بعدما عوّضنا any
بالواجهة Card
الجديدة، سيتحقق المُصرّف في جو أن أي نوع مستخدم مع C
يُحقق واجهة Card
عند تصريف البرنامج.
يمكننا الآن بعد وضع هذا القيد، استخدام أي توابع تقدمها الواجهة Card
داخل توابع النوع Deck
. إذا أردنا من RandomCard
طباعة اسم البطاقة المسحوبة، ستتمكن من الوصول إلى التابع Name
لأنها جزء من الواجهة Card
. سنرى ذلك في الجزء التالي.
هذه التحديثات القليلة هي التحديثات الوحيدة التي نحتاج إليها لتقييد النوع Deck
، بحيث يستخدم فقط لقيم Card
.
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
سيكون الخرج:
--- drawing playing card --- drew card: 5 of Clubs card suit: Clubs card rank: 5 --- drawing trading card --- drew card: Droplets card collectable name: Droplets
نلاحظ أن الناتج لم يتغير من حيث الشكل العام، فنحن لم نُعدّل بالوظيفة الرئيسية للبرنامج؛ وضعنا فقط قيودًا على الأنواع، إذ أضفنا في هذا الجزء الواجهة Card
الجديدة وعدّلنا كلًا من TradingCard
و PlayingCard
لتحقيق هذه الواجهة. عدّلنا أيضًا Deck
بهدف تقييد الأنواع التي يتعامل معها، بحيث يكون النوع المستخدم يُحقق الواجهة Card
.
كل ما فعلناه إلى الآن هو إنشاء نوع بيانات جديد من خلال مفهوم البنى struct
وجعله مُعمّمًا، لكن لغة جو تسمح أيضًا بإنشاء دوال مُعممة، إضافةً إلى إنشاء أنواع مُعممة.
إنشاء دوال معممة
يتبع إنشاء دوال مُعممة في لغة جو طريقة تصريح مشابه جدًا للأنواع المُعممة في لغة جو. يتطلب إنشاء دوال مُعممة إضافة مجموعة ثانية من المعاملات إلى تلك الدوال كما هو الحال في الأنواع المُعممة التي تحتوي على معاملات نوع.
نُنشئ في هذا الجزء دالة مُعممة جديدة باسم printCard
، ونستخدم تلك الدالة لطباعة اسم البطاقة المقدمة.
نفتح ملف "main.go" ونجري التحديثات التالية:
... func printCard[C any](card C) { fmt.Println("card name:", card.Name()) } func main() { ... fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName) fmt.Printf("--- printing cards ---\n") printCard[*PlayingCard](playingCard) printCard(tradingCard) }
نلاحظ أن التصريح عن الدالة printCard
مألوف من ناحية تعريف معاملات النوع (أقواس معقوفة تحتوي على معاملات النوع المعممة. تحدد هذه المعاملات النوع الذي سيجري استخدامه في الدالة)، تليها المعاملات العادية للدالة داخل قوسين، ثم في دالة main
، تستخدم الدالة printCard
لطباعة كل من PlayingCard*
و TradingCard*
.
قد نلاحظ أن أحد استدعاءات printCard
يتضمن معامل النوع [PlayingCard*]
، بينما الاستدعاء الثاني لا يحتوي على نفس معامل النوع [TradingCard*]
. يمكن لمُصرّف لغة جو أن يستدل على معامل النوع المقصود من القيمة التي نمررها إلى المعاملات، لذلك في حالات مثل هذه، تكون معاملات النوع اختيارية. يمكننا أيضًا إزالة معامل النوع [PlayingCard*]
.
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
سيظهر هذه المرة خطأ في التصريف:
# وسطاء سطر الأوامر ./main.go:87:33: card.Name undefined (type C has no field or method Name)
عند استخدامنا للشيفرة المذكورة أعلاه وتصريفها تظهر رسالة الخطأ "card.Name undefined"، وتعني أن Name
غير معرفة في النوع C
. هذا يحدث لأننا استخدمنا any
قيدًا للنوع في معامل النوع C
في دالة printCard
. تعني any
أن أي نوع يمكن استخدامه مع C
، ولكنه لا يعرف عنه أي شيء محدد مثل وجود التابع Name
في النوع.
لحل هذه المشكلة والسماح بالوصول إلى Name
، يمكننا استخدام الواجهة Card
قيدًا للنوع في C
. تحتوي الواجهة Card
على التابع Name
ونُطبقها في النوعين TradingCard
و PlayingCard
. وبذلك تضمن لنا لغة جو أن يكون لدى C
التابع Name
وبالتالي يتيح لنا استخدامها في الدالة printCard
دون وجود أخطاء في وقت التصريف.
نُعدّل ملف "main.go" لآخر مرة لاستبدال القيد any
بالقيد Card
:
... func printCard[C Card](card C) { fmt.Println("card name:", card.Name()) }
لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run
:
$ go run main.go
سيكون الخرج:
--- drawing playing card --- drew card: 6 of Hearts card suit: Hearts card rank: 6 --- drawing trading card --- drew card: App Platform card collectable name: App Platform --- printing cards --- card name: 6 of Hearts card name: App Platform
نلاحظ سحب البطاقتين كما اعتدنا سابقًا، ولكن تطبع الآن الدالة printCard
البطاقات وتستخدم التابع Name
للحصول على الاسم لتجري طباعته.
أنشأنا في هذا القسم دالةً جديدة مُعممة printCard
قادرة على أخذ أي قيمة Card
وطباعة الاسم. رأينا أيضًا أن استخدام قيد النوع any
بدلًا من Card
أو قيمةً أخرى محددة يؤثر على التوابع المتاحة.
الخاتمة
أنشأنا في هذا المقال برنامجًا جديدًا يحتوي على بنية اسمها Deck
تُمثّل نوعًا يمكن أن يُعيد بطاقة عشوائية من مجموعة ورق اللعب بصيغة {}interface
، وأنشأنا النوع PlayingCard
لتمثيل بطاقة اللعب في المجموعة، ثم عدّلنا النوع Deck
لدعم مفهوم النوع المُعمّم وتمكنّا من إزالة بعض عمليات التحقق من الأخطاء لأن النوع المُعمّم يضمن عدم ظهور ذلك النوع من الأخطاء. أنشأنا بعد ذلك نوعًا جديدًا يسمى TradingCard
لتمثيل نوع مختلف من البطاقات التي يمكن أن تدعمها Deck
، وكذلك أنشأنا مجموعة ورق لعب لكل نوع من أنواع البطاقات وأرجعنا بطاقةً عشوائيةً من كل مجموعة. أضفنا أيضًا قيدًا للنوع إلى البنية Deck
لضمان أنه يمكن إضافة أنواع تُحقق الواجهة Card
فقط إلى مجموعة ورق اللعب. أنشأنا أخيرًا دالة مُعمّمة تسمى printCard
يمكنها طباعة اسم أي قيمة من نوع Card
باستخدام التابع Name
.
يمكن أن يُقلل استخدام الأنواع المُعمّمة في الشيفرة الخاصة بنا إلى حد كبير عدد الأسطر البرمجية اللازمة لدعم أنواع متعددة لنفس الشيفرة. يجب أن نُحقق التوازن بين الأداء وسهولة قراءة الشيفرة عند استخدام الأنواع المُعمّمة؛ إذ يؤثر استخدام الأنواع المُعمّمة على الأداء، لكنه يُسهّل القراءة. من جهة أخرى، استخدام الواجهات بدلًا من الأنواع المُعمّمة أفضل من ناحية الأداء، لكنه أسوأ في القراءة. بالتالي، إذا كان بإمكاننا استخدام الواجهة بدلًا من الأنواع المُعمّمة فمن الأفضل استخدام الواجهة.
ترجمة -وبتصرف- للمقال How To Make HTTP Requests in Go لصاحبه Kristin Davidson.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.