اذهب إلى المحتوى

كيفية استخدام الأنواع المعممة Generics في لغة جو Go


هدى جبور

قدمت لغة جو في الإصدار 1.18 ميزةً جديدة تُعرف باسم الأنواع المُعمّمة generic types -أو اختصارًا generics- والتي كانت في قائمة أمنيات مُطوّري هذه اللغة لبعض الوقت. والنوع المُعمّم في لغات البرمجة، هو نوع يمكن استخدامه مع أنواع متعددة أخرى.

سابقًا، عندما كنا نرغب في استخدام نوعين مختلفين لنفس المتغير في لغة جو، كنا نحتاج إما لاستخدام واجهة مُعرّفة بأسلوب معين مثل io.Reader، أو استخدام {}interface الذي يسمح باستخدام أي قيمة. المشكلة في أن استخدام {}interface يجعل التعامل مع تلك الأنواع صعبًا، لأنه يجب التحويل بين العديد من الأنواع الأخرى المحتملة للتفاعل معها (عليك تحويل القيم بين أنواع مختلفة من البيانات للتعامل معها). على سبيل المثال، إذا كان لدينا قيمة من {}interface تمثل عددًا، يجب علينا تحويلها إلى int قبل أن تتمكن من إجراء العمليات الحسابية عليها. هذه العملية قد تكون معقدة وتستلزم كتابة الكثير من التعليمات الإضافية للتعامل مع تحويل الأنواع المختلفة، أما الآن وبفضل الأنواع المُعمّمة، يمكننا التفاعل مباشرةً مع الأنواع المختلفة دون الحاجة للتحويل بينها، مما يؤدي إلى شيفرة نظيفة سهلة القراءة.

نُنشئ في هذا المقال برنامجًا يتفاعل مع مجموعة من البطاقات، إذ سنبدأ بإنشائها بحيث تستخدم {}interface للتفاعل مع البطاقات الأخرى، ثم نُحدّثها من أجل استخدام الأنواع المُعمّمة، ثم سنضيف نوعًا ثانيًا من البطاقات باستخدام الأنواع المُعمّمة، ثم سنُحدّثها لتقييد نوعها المُعمّم، بحيث تدعم أنواع البطاقات فقط. نُنشئ أخيرًا دالةً تستخدم البطاقات الخاصة بنا وتدعم الأنواع المُعمّمة.

المتطلبات الأولية

لمتابعة هذا المقال التعليمي، سنحتاج إلى:

التجميعات 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.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...