برنامه نویسیپایتون

نحوه استفاده از Generic در Go

مقدمه

Genericهای Go به شما اجازه می‌دهند کدِ قابل استفاده مجدد و امن-از-نظر-تایپی بنویسید که با چندین تایپ کار کند—بدون از دست دادن بررسی زمان-کامپایل. پارامترهای تایپ (Placeholderهایی برای تایپها) را تعریف کرده و—در صورت تمایل—با Constraintهای تایپی محدودشان می‌کنید؛ تا کامپایلر استفاده درست را الزامی کرده و از Type Assertionها و خطاهای زمان-اجرایی که اغلب با interface{} می‌آیند اجتناب کنید. در این آموزش، برنامه دست-کارت می‌سازید: با Deckای که کارتها را به‌صورت interface{} ذخیره می‌کند شروع کرده؛ سپس برای استفاده از Genericها Refactorش می‌کنید؛ تایپ کارت دومی اضافه، Deck را فقط به تایپهای کارت محدود کرده و تابع Genericای می‌سازید که با هر کارتی کار می‌کند. در پایان می‌دانید کِی از Genericها در Go استفاده کنید و پارامترهای تایپ، Constraintها و استنباط تایپ در عمل چگونه کار می‌کنند.

Go در نسخه ۱.۱۸ Genericها را معرفی کرد و نسخههای بعدی، این قابلیت را پایدارتر کرده‌اند. سینتکس پارامتر تایپ و ایدهٔ Constraint-به‌عنوان-اینترفیس، راهی برای جانشینی تایپهای متفاوت در همان تایپ یا تابع Generic—هم‌زمان با حفظ تایپینگ قوی—فراهم می‌کند. این tutorial فرض می‌کند روی Toolchain استabile فعلی (Go 1.18 یا بعدتر) هستید.

پیش‌نیازها

برای دنبال کردن این tutorial به این موارد نیاز دارید:

  • Go 1.18 یا بعدتر نصب‌شده. برای راه‌اندازی، راهنمای «نحوه نصب Go» را برای سیستم‌عامل‌تان در پارمین کلود دنبال کنید.
  • درک خوبی از زبان Go—مانند متغیرها، «نحوه تعریف و فراخوانی توابع در Go»، «تعریف Structها در Go»، حلقههای for و Sliceها. اگر می‌خواهید درباره این مفاهیم بیشتر بخوانید، سری «نحوه کدنویسی در Go» در پارمین کلود آموزشهای زیادی را پوشش میدهد.

نکات کلیدی

  • Genericهای Go از پارامترهای تایپ در براکت مربعی استفاده می‌کنند (مثلاً [T any]، [C Card])—برای تایپها و توابع. کد امن-از-نظر-تایپی و قابل استفاده مجدد—بدون از دست دادن بررسی Static—فراهم میکنند.
  • Constraintهای تایپی اینترفیسهایی هستند که به‌عنوان مجموعه تایپ یا مجموعه متد استفاده میشوند؛ مشخص میکنند کدام تایپها میتوانند آرگومان تایپی باشند و کدام عملیات مجازند (مثلاً Name()، <، ==).
  • استنباط تایپ اغلب اجازه میدهد آرگومانهای تایپی در محل فراخوانی حذف شوند—وقتی کامپایلر بتواند از آرگومانهای تابع استنباطشان کند.
  • برای انتزاعات رفتاری و باز-انتها، اینترفیس را ترجیح بدهید؛ برای انتزاعات ساختاری و حفظ-تایپ و کانتینرهای امن-از-نظر-تایپی، Generic. Constraintها را محدود نگه داشته و از پارامترهای تایپی اضافی پرهیز کنید.
  • Genericهای Go محدودیتهای عمدی دارند (نبود پارامتر تایپی سطح-متد، نبود Operator Overloading، نبود Specialization). بعد از مهاجرت کد موجود، از go build ./... و go vet ./... استفاده کنید.

Collectionهای Go بدون Generic

Go میتواند تایپهای زیادی را—from طریق اینترفیسها—نمایش دهد و کد زیادی بدون Generic خوب کار میکند. هزینه در محل فراخوانی ظاهر میشود: مقادیر را در interface{} ذخیره کرده؛ سپس برای استفاده باید به تایپ عینی Assert کنید—which بررسی زمان-کامپایل را از دست میدهد و Boilerplate اضافه میکند.

سیستمهای تایپ در زبانهای برنامهنویسی عموماً به دو دسته طبقهبندی میشوند: تایپینگ و بررسی تایپ. زبانی میتواند از تایپینگ قوی یا ضعیف و بررسی Static یا Dynamic استفاده کند. Go کاملاً در دسته زبانهای قوی-تایپ و Static-بررسی جا میگیرد. قوی-تایپ بودن یعنی Go تضمین میکند مقداری در متغیری با تایپ متغیر مطابقت دارد؛ پس مثلاً نمی‌توانید مقدار int را در متغیر string ذخیره کنید. سیستم تایپِ Static-بررسی هم یعنی کامپایلر Go این قواعد را هنگام کامپایل چک میکند—not وقتی برنامه در حال اجراست.

مزیت زبان قوی-تایپ و Static-بررسی مثل Go این است که کامپایلر قبل از انتشار برنامه از اشتباهات بالقوه باخبرتان میکند—which از برخی خطاهای زمان-اجراییِ «تایپ نامعتبر» اجتناب میشود. اما محدودیتی هم اضافه میکند: باید تایپهایی که قصد استفاده دارید را قبل از کامپایل بدانید. راهی برای مدیریت، استفاده از تایپ interface{} (یا Alias یعنی any) است. interface{} با هر مقداری کار میکند—چون هیچ متد الزامی تعریف نمیکند؛ پس هر تایپی آن را ارضا میکند. در عمل، اطلاعات تایپ را در محل فراخوانی از دست میدهید و برای برگشتن به تایپ عینی باید از Type Assertion استفاده کنید—which جایی است که Genericها این الگو را بهبود میبخشند.

شروع برنامه با interface{}

برای شروع ساخت برنامهتان با استفاده از interface{} برای نمایش کارتها، به دایرکتوری برای نگه داشتن پروژه نیاز دارید. در این tutorial از دایرکتوری projects استفاده میکنید.

اول، دایرکتوری projects را ساخته و واردش شوید:

mkdir projects
cd projects

سپس، دایرکتوری پروژه را بسازید و واردش شوید. در این مورد، از دایرکتوری generics استفاده کنید:

mkdir generics
cd generics

درون دایرکتوری generics، با nano یا ویرایشگر مورد علاقهتان فایل main.go را باز کنید:

nano main.go

در فایل main.go، با افزودن تعریف پکیج و Import کردن پکیجهای مورد نیاز شروع کنید:

package main

import (
    "fmt"
    "math/rand"
    "os"
)

تعریف package main به Go میگوید برنامه را به‌صورت باینری کامپایل کند تا مستقیم اجرایش کنید؛ و دستور import به Go میگوید کدام پکیجها را در کد بعدی استفاده خواهید کرد.

حالا، تایپ 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)
}

در این قطعه، Structای به نام PlayingCard با ویژگیهای Suit و Rank تعریف میکنید تا کارتهای دستِ ۵۲تایی بازی را نمایش دهد. Suit یکی از Diamonds، Hearts، Clubs یا Spades است؛ و Rank از A، ۲، ۳ و به همین شکل تا K.

تابع NewPlayingCard را هم—به‌عنوان Constructor برای Struct—و متد String را که خال و رتبه کارت را با fmt.Sprintf برمیگرداند تعریف کردید.

سپس، تایپ Deck را با متدهای AddCard و RandomCard—to‌همراه تابع NewPlayingCardDeck برای ساخت *Deck پرشده با هر ۵۲ کارت بازی—بسازید:

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{} {
    cardIdx := rand.Intn(len(d.cards))
    return d.cards[cardIdx]
}

در Deck، فیلدی به نام cards برای نگهداشتن Sliceای از کارتها میسازید. چون میخواهید Deck بتواند چندین تایپ متفاوت کارت را نگه دارد، نمیتوانید فقط []*PlayingCard تعریفش کنید. به‌جایش []interface{} تعریف میکنید تا هر تایپ کارتی که ممکن است در آینده بسازید را نگه دارد. علاوه بر فیلد []interface{}، متد AddCardای را هم میسازید که همان تایپ interface{} را میپذیرد.

متد RandomCard را هم میسازید که کارت تصادفیای را از Slice cards برمیگرداند. این متد از پکیج math/rand برای تولید Index تصادفی استفاده میکند. از Go 1.20، توابع سراسری rand خودکار Seed میشوند؛ پس Generator جداگانه و Seed دستی لازم نیست. rand.Intn(len(d.cards)) مقدار intای را در بازه [0, len(d.cards)) برمیگرداند.

هشدار: مراقب باشید در برنامههایتان از کدام Generator عدد تصادفی استفاده میکنید. پکیج math/rand امن-از-نظر-رمزنگاری نیست و نباید در برنامههای حساس-به-امنیت استفاده شود. پکیج crypto/rand برای آن موارد، Generator امن-از-نظر-رمزنگاری فراهم میکند.

در نهایت، تابع NewPlayingCardDeck مقدار *Deckای را—پرشده با همه کارتهای دست بازی—برمیگرداند. از دو Slice استفاده میکنید: یکی با همه خالهای موجود و یکی با همه رتبههای موجود؛ سپس روی هر مقدار Loop میزنید تا *PlayingCard جدیدی برای هر ترکیب ساخته و با AddCard به Deck اضافه شود.

تابع main و مشکل Type Assertion

تابع 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)
}

در main، اول دست جدیدی با NewPlayingCardDeck ساخته و به deck انتصاب میکنید. سپس RandomCard را برای گرفتن کارت تصادفی صدا میزنید. چون تایپ متغیر card یعنی interface{} است، باید از Type Assertion برای گرفتن Reference به تایپ اصلی *PlayingCard استفاده کنید. اگر تایپ درون card یعنی *PlayingCard نباشد، ok یعنی false خواهد بود و برنامه پیام خطا چاپ کرده و با کد ۱ خارج میشود.

بعد از ذخیره تغییرات، برنامه را اجرا کنید:

go run main.go

در خروجی، کارتِ تصادفیِ انتخابشده—to‌همراه خال و رتبهاش—را باید ببینید:

--- drawing playing card ---
drew card: Q of Diamonds
card suit: Diamonds
card rank: Q

اگر به‌اشتباه TradingCardای به دست interface{}ای اضافه کنید، Assertion در main شکست میخورد: ok یعنی false و برنامه با خطا خارج میشود:

// عمداً اشتباه: افزودن TradingCard به دست PlayingCard
deck.AddCard(&TradingCard{CollectableName: "Sammy"})
card := deck.RandomCard()
playingCard, ok := card.(*PlayingCard)
if !ok {
    fmt.Printf("card received wasn't a playing card!\n")
    os.Exit(1)
}

وقتی آن کارت اشتباه کشیده شود:

--- drawing playing card ---
card received wasn't a playing card!
exit status 1

کامپایلر این کد را پذیرفت—چون AddCard یعنی interface{} میگیرد؛ شکست فقط در زمان اجرا ظاهر میشود. آن Type Assertion برای هر بار کشیدن مقداری از دست لازم است؛ و اگر کسی تایپ کارت متفاوتی به همان دست اضافه کند، Assertion میتواند در زمان اجرا شکست بخورد. Generic کردن دست، هم Assertion و هم این حالت شکست را حذف میکند.

Collectionهای Go با Generic

پارامترهای تایپ به شما اجازه میدهند یک تایپ Deck بنویسید که با هر تایپ کارتی کار کند—هم‌زمان با تایپینگ کامل: بدون interface{} و بدون Type Assertion در محل فراخوانی. کامپایلر تایپ کارت را در هر استفاده الزامی میکند.

تبدیل Deck به تایپ Generic

فایل main.go را باز کرده و Import پکیج os را حذف کنید:

package main

import (
    "fmt"
    "math/rand"
)

همانطور که در بهروزرسانیهای بعدی میبینید، دیگر لازم نیست از os.Exit استفاده کنید—پس حذف این Import امن است.

سپس، Struct یعنی Deck را برای Generic شدن بهروزرسانی کنید:

type Deck[C any] struct {
    cards []C
}

این بهروزرسانی سینتکس جدیدِ استفادهشده توسط Genericها برای ساخت Placeholderها یا پارامترهای تایپ درون تعریف Struct را معرفی میکند. تقریباً میتوانید این پارامترهای تایپ را شبیه پارامترهای تابع تصور کنید. هنگام فراخوانی تابع، برای هر پارامتر تابعی مقدار فراهم میکنید. همینطور، هنگام ساخت مقدار تایپ Generic، برای پارامترهای تایپ، تایپ فراهم میکنید.

بعد از نام Struct یعنی Deck، Statementای درون براکت مربعی ([]) اضافه کردهاید. این براکتها اجازه تعریف یک یا چند پارامتر تایپ برای Struct را میدهند.

در مورد تایپ Deck، فقط یک پارامتر تایپ به نام C لازم دارید تا تایپ کارتهای دست را نمایش دهد. با تعریف C any، کدتان میگوید: «پارامتر تایپی به نام C بساز که بتوانم در Structام استفاده کنم، و اجازه بده هر تایپی باشد». در پشت صحنه، تایپ any در واقع Alias تایپ interface{} است. دست شما فقط به یک تایپ Generic نیاز دارد؛ اما اگر تایپهای Generic اضافی لازم داشتید، میتوانستید با سینتکس ویرگولجداکننده اضافهشان کنید: C any, F any. نام پارامترهای تایپ میتواند هر چیزی باشد که رزرو نشده—اما معمولاً کوتاه و با حرف بزرگ.

در نهایت، تایپ Slice یعنی cards را در Struct برای استفاده از پارامتر تایپ C بهروزرسانی کردهاید. هنگام استفاده از Genericها، پارامترهای تایپ را هرجا که معمولاً تایپ مشخصی میگذاشتید میتوانید استفاده کنید.

بهروزرسانی متدها برای Generic

متد AddCard را بهروزرسانی کنید:

func (d *Deck[C]) AddCard(card C) {
    d.cards = append(d.cards, card)
}

در بهروزرسانی AddCard، اول پارامتر تایپ Generic یعنی [C] به Receiver متد اضافه کردید. این به Go میگوید نام پارامتر تایپی را که در جای دیگر تعریف متد استفاده میکنید—and از سینتکس براکت مربعی مشابه تعریف Struct پیروی میکند. در این مورد، نیازی به فراهم کردن Constraint یعنی any نیست—چون در تعریف Deck از قبل فراهم شده. سپس، پارامتر تابعی یعنی card را برای استفاده از Placeholder یعنی C—to‌جای interface{}—بهروزرسانی کردید.

متد RandomCard را هم بهروزرسانی کنید:

func (d *Deck[C]) RandomCard() C {
    cardIdx := rand.Intn(len(d.cards))
    return d.cards[cardIdx]
}

این بار—to‌جای استفاده از C به‌عنوان پارامتر تابع—متد را برای برگرداندن C به‌جای interface{} بهروزرسانی کردید. چون cards یعنی []C است، تایپ بازگشتی C است؛ پس Callerها تایپ عینی—بدون Type Assertion—میگیرند.

بهروزرسانی Constructor

به NewPlayingCardDeck برگردید و برای تایپهای *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 همان میماند؛ اما حالا که از نسخه Generic یعنی Deck استفاده میکنید، باید تایپی را که برای C میخواهید مشخص کنید. با ارجاع به تایپ Deck—همانطور که معمولاً میکنید، چه Deck و چه Referenceای مثل *Deck—و سپس فراهم کردن تایپی که باید C را جایگزین کند با همان براکت مربعی.

برای تایپ بازگشتی NewPlayingCardDeck، همچنان از *Deck استفاده میکنید؛ اما این بار براکت مربعی و *PlayingCard را هم شامل میشوید. با فراهم کردن [*PlayingCard]، میگویید میخواهید تایپ *PlayingCard در تعریف و متدهای Deck، مقدار C را جایگزین کند. یعنی تایپ فیلد cards روی Deck عملاً از []C به []*PlayingCard تغییر میکند.

همینطور برای ساخت Instance جدید Deck: به‌جای &Deck{}، &Deck[*PlayingCard]{} مینویسید.

تابع main بدون Type Assertion

حالا تابع main را برای بهرهگیری بهروزرسانی کنید:

func main() {
    deck := NewPlayingCardDeck()

    fmt.Printf("--- drawing playing card ---\n")
    playingCard := deck.RandomCard()
    fmt.Printf("drew card: %s\n", playingCard)
    fmt.Printf("card suit: %s\n", playingCard.Suit)
    fmt.Printf("card rank: %s\n", playingCard.Rank)
}

این بار بهروزرسانی شما حذف کد است—چون دیگر لازم نیست مقدار interface{} را به *PlayingCard تبدیل کنید. وقتی RandomCard یعنی *PlayingCard برمیگرداند، یعنی تایپ playingCard هم *PlayingCard است و میتوانید مستقیم به فیلدهای Suit یا Rank دسترسی پیدا کنید.

برنامه را اجرا کنید:

go run main.go

خروجی مشابه قبل است؛ اما دیگر Type Assertion لازم نیست و کامپایلر اصلاً اجازه افزودن TradingCard به دست *PlayingCard را نمیدهد:

--- drawing playing card ---
drew card: 8 of Hearts
card suit: Hearts
card rank: 8

چون دست روی تایپ کارت Generic است، میتوانید همان تعریف Deck را—بدون تغییر کدش—برای تایپهای کارت دیگر استفاده مجدد کنید.

استفاده از Genericها با چندین تایپ

همان Deck[C] بدون هیچ تغییری در Deck—to‌عنوان *Deck[*TradingCard] کار میکند؛ فقط آرگومان تایپی را هنگام ساخت دست جابهجا میکنید.

برای ساخت تایپ TradingCard، main.go را باز کنید و تعریف را اضافه کنید:

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 دارد.

سپس، Constructor یعنی NewTradingCardDeck را برای Deck پرشده با *TradingCard بسازید:

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 جایگزین کردهاید—but این تنها تغییری است که باید بدهید. متد AddCard همچنان همانطور کار میکند؛ اما این بار مقدار *TradingCard را میپذیرد. اگر مقداری از NewPlayingCard را پاس میدادید، کامپایلر خطا میداد—چون انتظار *TradingCard دارد.

در نهایت، main را برای ساخت دست جدیدی از *TradingCardها بهروزرسانی کنید:

func main() {
    playingDeck := NewPlayingCardDeck()
    tradingDeck := NewTradingCardDeck()

    fmt.Printf("--- drawing playing card ---\n")
    playingCard := playingDeck.RandomCard()
    fmt.Printf("drew card: %s\n", playingCard)
    fmt.Printf("card suit: %s\n", playingCard.Suit)
    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)
}

چون Deck یعنی Generic است، RandomCard وقتی روی tradingDeck صدا زده میشود یعنی *TradingCard برمیگرداند؛ پس میتوانید مستقیم—بدون Type Assertion—به CollectableName دسترسی پیدا کنید.

برنامه را اجرا کنید:

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

تنها تغییر Deck، افزودن Constructor جدید بود؛ پارامتر تایپ بقیه را انجام داد. اما Deck فعلی همچنان از C any استفاده میکند؛ پس هیچ چیزی جلوی Callerِ ساخت &Deck[int]{} را نمیگیرد. برای محدود کردن دست فقط به تایپهای کارت، به Constraint تایپی نیاز دارید.

محدود کردن تایپهای Generic

Constraintها مشخص میکنند کدام تایپها میتوانند آرگومان تایپی باشند—تا کامپایلر عملیاتی را که فقط آن تایپها پشتیبانی میکنند اجازه دهد. وقتی کد Generic شما باید متدهایی را صدا بزند یا عملیاتی استفاده کند که هر تایپی ندارد، به Constraint تنگتری از any نیاز دارید.

main.go را باز کنید و اینترفیس Card را اضافه کنید:

type Card interface {
    fmt.Stringer
    Name() string
}

اینترفیس Card مثل هر اینترفیس Go دیگری تعریف شده—هیچ الزام خاصی برای استفاده با Genericها ندارد. برای اینکه چیزی Card حساب شود، باید fmt.Stringer را پیاده کند (متد String را داشته باشد) و متد Name را هم داشته باشد که string برگرداند.

سپس، تایپهای TradingCard و PlayingCard را برای افزودن متد Name جدید بهروزرسانی کنید:

func (tc *TradingCard) Name() string {
    return tc.String()
}

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 را داشتید—which محدودیت زیادی نبود. حالا که any را با اینترفیس Card جایگزین کردهاید، کامپایلر Go هنگام کامپایل تضمین میکند تایپهای استفادهشده برای C، Card را پیاده کنند.

چون این محدودیت را اضافه کردهاید، حالا میتوانید از هر متدی که Card فراهم میکند درون متدهای Deck استفاده کنید. اگر میخواستید RandomCard نام کارتِ در حال کشیده شدن را هم چاپ کند، به متد Name دسترسی دارد—چون بخشی از اینترفیس Card است.

برنامه را اجرا کنید:

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

خروجی تغییر نکرده. Constraint فقط مشخص میکند کدام تایپها برای C مجازند؛ رفتارِ تایپهایی که از قبل Card را پیاده میکنند را تغییر نمیدهد. با Deck[C Card]، میتوانید با اطمینان Name() و متدهای دیگر Card را روی مقادیری از تایپ C درون متدهای Deck صدا بزنید.

درک Constraintهای تایپی و مجموعههای تایپ

Constraintهای تایپی، اینترفیسهایی هستند که مشخص میکنند پارامتر تایپی چه چیزهایی میتواند باشد؛ میتوانند مجموعه متد (مثل Card) یا مجموعه تایپ (شامل Union Constraintها و Built-inها مثل comparable) باشند.

در کد معمولی Go، اینترفیس مقادیر را توصیف میکند: مجموعهای از متدها تعریف میکند و هر تایپی که آن متدها را پیاده کند، اینترفیس را ارضا میکند. وقتی اینترفیس به‌عنوان Constraint تایپی استفاده میشود، همچنان میتواند مجموعه متد باشد؛ اما میتواند مجموعه تایپ هم باشد: فهرستی از تایپهای مجاز.

اینترفیس به‌عنوان-Constraint در مقابل به‌عنوان-مقدار: وقتی func F[T io.Reader](r T) مینویسید، T فقط میتواند با تایپهایی Instantiate شود که io.Reader را پیاده میکنند؛ درون F میتوانید r.Read() را صدا بزنید. وقتی func G[T any](x T) مینویسید، نمیتوانید هیچ متدی روی x صدا بزنید—چون any هیچ الزامی روی T نمیگذارد. پس Constraint مشخص میکند چه عملیاتی روی پارامتر تایپی مجاز است.

Union Type Constraintها و ~T: میتوانید پارامتر تایپی را به مجموعهای از تایپها با Union محدود کنید—مثلاً int | string یا ~int | ~int64. علامت ~ (Tilde) یعنی «تایپ زیربنایی»: ~int یعنی int و هر تایپی که تایپ زیربناییاش int است (مثلاً type MyInt int). این برای Genericهای عددی یا رشتهمانند—بدون پذیرش هر تایپی—مفید است.

Constraint یعنی comparable: زبان، Constraint از-پیش-اعلامشده یعنی comparable را برای تایپهایی که == و != را پشتیبانی میکنند دارد. وقتی تابع Generic باید دو مقدار از تایپ T را مقایسه کند (مثلاً در map key یا set) لازمش دارید. اگر T any بنویسید و دو مقدار T را مقایسه کنید، کامپایلر رد میکند؛ به‌جایش T comparable بنویسید.

پکیج constraints: کتابخانه استاندارد، پکیج constraints عرضه نمیکند. Constraintهای مرتب و عددی رایج در golang.org/x/exp/constraints زندگی میکنند—which تایپهایی مثل constraints.Ordered (تایپهایی که <، <=، >، >= را پشتیبانی میکنند) و constraints.Integer فراهم میکند.

مثال زیر تابع Generic یعنی Min را با constraints.Ordered نشان میدهد. این مثال را در دایرکتوری جداگانه‌ای بیرون پروژه tutorial اجرا کنید. سپس ماژول را مقداردهی اولیه کرده و وابستگی را بگیرید:

go mod init example/min
go get golang.org/x/exp/constraints

سپس فایل main.go را با این محتوا بسازید:

package main

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Min(3, 5))       // 3
    fmt.Println(Min("apple", "banana")) // "apple"
}

خروجی:

3
apple

تابع Min با هر تایپی که مقایسه با < را پشتیبانی میکند کار میکند. Constraint یعنی constraints.Ordered شامل تایپهای عددی و رشتهای Built-in است؛ پس میتوانید Min را با Integer یا String صدا بزنید—بدون نوشتن توابع جداگانه.

نکته: مجموعههای تایپ و سینتکس Union بخشی از زبان Go 1.18 هستند. پکیج constraints در golang.org/x/exp آزمایشی است و ممکن است تغییر کند؛ برای کد پروداکشن میتوانید اینترفیسهای Constraint کوچک خودتان را تعریف کنید (مثلاً type Ordered interface { ~int | ~int64 | ~float64 }).

ساخت توابع Generic

توابع Generic از همان سینتکس پارامتر-تایپی تایپهای Generic استفاده میکنند: فهرست پارامتر تایپی را در براکت مربعی قبل از پارامترهای معمولی اضافه کنید. Constraint روی آن پارامتر تایپی مشخص میکند کدام متدها را میتوانید روی آرگومان صدا بزنید.

تابع printCard

main.go را باز کنید و این بهروزرسانیها را بدهید:

func printCard[C any](card C) {
    fmt.Println("card name:", card.Name())
}

func main() {
    // ... کد قبلی ...

    fmt.Printf("--- printing cards ---\n")
    printCard[*PlayingCard](playingCard)
    printCard(tradingCard)
}

در تابع printCard، سینتکس براکت مربعی آشنا برای پارامترهای تایپ Generic—to‌دنبالش پارامترهای معمولی تابع در پرانتز—را میبینید. در main، از printCard برای چاپ هر دو *PlayingCard و *TradingCard استفاده میکنید.

ممکن است متوجه شوید که یکی از فراخوانیها پارامتر تایپی [*PlayingCard] را شامل میشود؛ اما دومی همان [*TradingCard] را شامل نمیشود. کامپایلر Go میتواند پارامتر تایپی موردنظر را از مقداری که به پارامترهای تابع پاس میدهید بفهمد؛ پس در مواردی مثل این، پارامترهای تایپی اختیاری هستند.

برنامه را اجرا کنید:

go run main.go

این بار خطای کامپایلر میبینید:

# command-line-arguments
./main.go:87:33: card.Name undefined (type C has no field or method Name)

در پارامتر تایپی تابع printCard، any به‌عنوان Constraint دارید. وقتی Go برنامه را کامپایل میکند، انتظار دارد فقط متدهای تعریفشده توسط اینترفیس any را ببیند—which هیچی نیست. این همان مزیت استفاده از Constraintهای تایپی مشخص روی پارامترهای تایپ است. برای دسترسی به متد Name روی تایپهای کارتتان، باید به Go بگویید تنها تایپهای استفادهشده برای C، Card هستند.

اصلاح Constraint

main.go را برای جایگزینی Constraint یعنی any با Constraint یعنی Card بهروزرسانی کنید:

func printCard[C Card](card C) {
    fmt.Println("card name:", card.Name())
}

سپس ذخیره کرده و اجرا کنید:

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 برای گرفتن نام استفاده میکند. استفاده از C Card به‌جای C any است که اجازه کامپایل شدن card.Name() را میدهد.

نحوه کار استنباط تایپ

کامپایلر پارامترهای تایپی را از تایپهای آرگومانهای تابع استنباط میکند—وقتی بتواند؛ وقتی آرگمانی نیست (یا تایپ مبهم است)، باید آرگومانهای تایپی را صریح پاس بدهید.

وقتی printCard(tradingCard) را صدا میزنید، آرگومان tradingCard تایپ *TradingCard دارد؛ پس Go پارامتر تایپی C را به‌عنوان *TradingCard استنباط میکند و لازم نیست printCard[*TradingCard](tradingCard) بنویسید. وقتی printCard(playingCard) را صدا میزنید، همین applies: playingCard یعنی *PlayingCard؛ پس میتوانید هر دو شکل printCard(playingCard) یا printCard[*PlayingCard](playingCard) را بنویسید. هر دو معتبرند؛ فرم صریح وقتی اختیاری است که تایپ آرگومان، پارامتر تایپی را بهطور یکتا تعیین کند.

وقتی باید پارامتر تایپی را مشخص کنید: تابع Generic بدون پارامتر (مثلاً func ReturnZero[T any]() T { var z T; return z }) یا تابعی که فقط تایپ Generic را برمیگرداند و هیچ آرگومانی از تایپ T ندارد. در آن موارد، محل فراخوانی باید تایپ را فراهم کند—مثلاً ReturnZero[int]().

اگر فراموش کنید آرگومان تایپی را فراهم کنید، کامپایلر گزارش میدهد:

# command-line-arguments
./main.go:NN:NN: cannot infer T

این وقتی رخ میدهد که تابع هیچ پارامتری از تایپ T برای بازرسی نداشته باشد. رفع، همیشه فراهم کردن صریح آرگومان تایپی است: ReturnZero[int]().

ملاحظات کارایی

Genericهای Go از طریق GCShape Stenciling پیاده شدهاند: کامپایلر مجموعه کوچکی از شکلهای زیربنایی تولید میکند (مثلاً یکی برای تایپهای Pointer و یکی برای هر کلاس اندازه تایپ مقداری)—به‌جای پیادهسازی کاملاً جدای هر تایپ مثل Templateهای C++. در عمل یعنی Genericها سربار قابل-اندازهگیریِ کم یا هیچی اضافه نمیکنند وقتی تایپهای Instantiate-شده همان شکل را شریکند.

تایپهای Pointer (مثلاً *PlayingCard، *TradingCard) معمولاً یک پیادهسازی را شریکند—چون نمایش یکسانی دارند. تایپهای مقداری (مثلاً int، string، struct{…}) ممکن است بسته به اندازه و Layout، کپی خودشان را بگیرند. Implicit Boxingی مثل برخی زبانهای دیگر وجود ندارد. وقتی Genericها هزینه اضافه میکنند، معمولاً از کپی اضافی تایپهای مقداری بزرگ یا از Instantiateهای متمایزِ زیاد که حجم باینری را زیاد میکنند، است.

توصیه: اول کد شفاف و درست بنویسید. اگر مسیر داغی از Genericها استفاده میکند، قبل از تغییر طراحی با go test -bench اندازهگیری کنید. مثال:

تابع First را به main.go اضافه کنید:

func First[T any](s []T) T { return s[0] }

فایلی به نام first_test.go در همان دایرکتوری با این Benchmark بسازید:

package main

import "testing"

func BenchmarkFirstInt(b *testing.B) {
    s := make([]int, 100)
    for i := 0; i < b.N; i++ {
        _ = First(s)
    }
}

سپس Benchmark را از دایرکتوری پروژه اجرا کنید:

go test -bench=. -benchmem

در بیشتر کدها، مزیت Genericهای امن-از-نظر-تایپی و قابل استفاده مجدد، بر هر هزینه زمان-اجرایی ناچیزی غالب است.

نکته: از ادعاهای کارایی گسترده بدون اندازهگیری پرهیز کنید. پیادهسازی Genericهای Go ممکن است در Toolchainهای آینده تغییر کند.

مهاجرت کد قبل-از-Generic به Generic

مهاجرت از interface{} یا any به پارامتر تایپی، امنیت تایپی زمان-کامپایل میدهد و Type Assertionها را در محلهای فراخوانی حذف میکند. مثال دست در این tutorial مهاجرت مستقیمی است: Deck اصلی []interface{} ذخیره میکرد و Deck Generic یعنی Deck[C Card] یعنی []C ذخیره میکند.

عنصرقبل (بدون Generic)بعد (با Generic)
Deck structcards []interface{}cards []C با type Deck[C Card] struct
AddCardfunc (d *Deck) AddCard(card interface{})func (d *Deck[C]) AddCard(card C)
RandomCardfunc (d *Deck) RandomCard() interface{}func (d *Deck[C]) RandomCard() C
محل فراخوانیcard := deck.RandomCard(); pc, ok := card.(*PlayingCard)playingCard := deck.RandomCard() (بدون Assertion)

بعد از تغییر امضاها، go build ./... را اجرا و هر محل فراخوانیای که هنوز interface{} پاس میدهد یا به Type Assertion تکیه دارد را رفع کنید. از go vet ./... برای گرفتن الگوهای مشکوک استفاده کنید. اگر API مبتنی بر اینترفیس موجود شفاف و Type Assertionها نادر یا ایزولهاند، مهاجرت شاید ارزشش را نداشته باشد.

هشدار: تغییر RandomCard() interface{} به RandomCard() C برای هر Callerای بیرون پکیج، تغییر APIای شکستدهنده است. مسیر امن در آن مورد: تایپ Generic جدیدی (مثلاً DeckGeneric[C Card]) در کنار موجود معرفی، اطلاعیه Deprecated به تایپ قدیمی بدهید و بگذارید Callerها منتقل شوند. اگر Callerهای خارجی به تایپ Export-شده وابستهاند، آن را در-جای خود تغییر ندهید.

محدودیتهای Generic در Go

طراحی Genericهای Go چندین قابلیت موجود در زبانهای دیگر را حذف کرده تا زبان و Tooling ساده بمانند.

متدها نمیتوانند پارامتر تایپی خودشان را داشته باشند

فقط Receiver (و توابع سطح-بالا) میتوانند Generic باشند. اگر پارامتر تایپی دوم به متد اضافه کنید:

func (d *Deck[C]) Map[U any](f func(C) U) []U {
    out := make([]U, 0, len(d.cards))
    for _, c := range d.cards {
        out = append(out, f(c))
    }
    return out
}

کامپایلر گزارش میدهد:

# command-line-arguments
./main.go:NN:NN: methods cannot have type parameters

راه‌حل: تابع Generic سطح-پکیجی که Receiver را به‌عنوان اولین آرگومان میگیرد:

func MapDeck[C any, U any](d *Deck[C], f func(C) U) []U {
    out := make([]U, 0, len(d.cards))
    for _, c := range d.cards {
        out = append(out, f(c))
    }
    return out
}

به‌جای deck.Map(...) به‌صورت MapDeck(deck, func(c *PlayingCard) string { return c.String() }) صدا بزنید. چون MapDeck به فیلد Unexported یعنی cards روی Deck دسترسی دارد، باید در همان پکیجِ Deck تعریف شود.

نبود Operator Overloading

نمیتوانید + یا * را برای تایپتان تعریف کرده و سپس در تابع Generic استفاده کنید. Constraintهایی مثل constraints.Ordered برای تایپهای Built-in که از قبل Operator دارند کار میکنند؛ تایپهای سفارشی باید متد (مثلاً Add) فراهم کرده و شما آن متدها را در تابع Generic صدا بزنید.

نبود Type Switch روی پارامتر تایپی

نمیتوانید switch x.(type) روی مقداری از تایپ T انجام بدهید که T پارامتر تایپی است:

func Describe[T any](v T) string {
    switch v.(type) {
    case string:
        return "string"
    case int:
        return "int"
    default:
        return "other"
    }
}

کامپایلر گزارش میدهد:

# command-line-arguments
./main.go:NN:NN: cannot use type switch on type parameter value v

راه‌حل: Type Switch روی اینترفیسای که مقدار را نگه میدارد انجام بدهید—یا از Reflection استفاده کنید.

نبود Specialization

نمیتوانید پیادهسازی متفاوتی از تابع Generic برای تایپ خاصی فراهم کنید (مثلاً مسیر سریعتر برای int). یک پیادهسازی کامپایلشده به‌ازای هر GCShape وجود دارد؛ پس کامپایلر مسیر کد سریعتر یا متفاوتی برای آرگومان تایپی خاصی مثل int تولید نمیکند.

Constraintهای تایپی بازگشتی

برخی الگوهای Constraint بازگشتی پشتیبانی نمیشوند:

type Node[T Node[T]] interface {
    Children() []T
}

کامپایلر گزارش میدهد:

# command-line-arguments
./main.go:NN:NN: interface type loop involving Node

راه‌حل: بازگشت را با اینترفیس ساده—بدون پارامتر تایپی خودارجاع—شکنید:

type Node interface {
    Children() []Node
}

برای کانتینرها و الگوریتمهای سرراست، Constraintهای بازگشتی به‌ندرت لازم میشوند.

بهترین روشها برای استفاده از Generic در Go

چه وقت اینترفیس را ترجیح بدهیم

اگر تابع فقط لازم است متدهایی صدا بزند و هرگز لازم نیست تایپ عینی را برگرداند یا ذخیره کند، اینترفیس معمولاً سادهتر از Generic است:

// نسخه Generic
func PrintName[C Card](c C) {
    fmt.Println(c.Name())
}

// نسخه اینترفیس
func PrintName(c interface{ Name() string }) {
    fmt.Println(c.Name())
}

هر دو کار میکنند؛ اما نسخه اینترفیس هیچ پارامتر تایپی ندارد. از اینترفیس استفاده کنید وقتی به تایپ عینی درون تابع اهمیت نمیدهید.

چه وقت Generic را ترجیح بدهیم

از Generic استفاده کنید وقتی لازم است تایپ عینی را حفظ کنید: همان تایپ ورودی را برگردانید، Sliceای از تایپ مشخصی ذخیره کنید یا از Type Assertion در محلهای فراخوانی اجتناب کنید.

Constraintها را تا جای ممکن محدود نگه دارید

اگر C any بنویسید اما درون تابع card.Name() را صدا بزنید، کامپایلر خطای card.Name undefined را گزارش میکند. رفع: C را به اینترفیسای که Name() را تعریف میکند محدود کنید.

نامگذاری پارامترهای تایپ

در APIهای عمومی یا وقتی نقش تایپ روشن نیست، از نامهای معنادار (مثلاً Element، Key، Value) به‌جای حروف منفرد استفاده کنید.

تعداد پارامترهای تایپ

توابع Generic با بیش از دو-سه پارامتر تایپی، خواندن و استفادهشان سخت میشود. Refactor یا گروهکردن پارامترهای مرتبط در Struct کوچک را در نظر بگیرید.

سوالات متداول

۱. Genericها کِی به Go اضافه شدند؟

Go 1.18. زبان از آن زمان از پارامترهای تایپ و Constraintها پشتیبانی کرده؛ و Releaseهای بعدی پیادهسازی را پایدارتر کردهاند.

۲. [T any] در Go چه معنایی دارد؟

پارامتر تایپی به نام T تعریف میکند که میتواند با هر تایپ Go جایگزین شود. any یعنی Alias برای interface{}؛ پس هیچ محدودیتی روی T نمیگذارد.

۳. آیا Genericهای Go کندند؟

در عمل، Genericهای Go سربار قابل-اندازهگیریِ کم یا هیچی برای استفاده معمول اضافه نمیکنند. کامپایلر از GCShape Stenciling استفاده میکند؛ پس تایپهای Pointer اغلب یک پیادهسازی را شریکند. مسیرهای داغ را با go test -bench اندازه بگیرید.

۴. آیا میتوانم از Genericها با متدها استفاده کنم؟

فقط به‌صورت غیرمستقیم: Receiver میتواند Generic باشد (مثلاً func (d *Deck[C]) AddCard(card C))؛ اما متدها نمیتوانند پارامتر تایپی خودشان را تعریف کنند.

۵. Constraintهای تایپی در Go چیستند؟

اینترفیسهایی هستند که محدود میکنند کدام تایپها میتوانند آرگومان تایپی باشند. میتوانند مجموعه متد (مثلاً تایپهایی که Name() string دارند) یا مجموعه تایپ (مثلاً int | string) را مشخص کنند.

۶. چه وقت به‌جای Generic از اینترفیس استفاده کنم؟

از اینترفیس وقتی فقط به رفتار اهمیت میدهید و مجموعه تایپها باز-انتهاست. از Generic وقتی لازم است تایپ مشخصی را حفظ یا با آن کار کنید و امنیت تایپی زمان-کامپایل میخواهید.

۷. آیا Genericها جایگزین اینترفیس در Go میشوند؟

نه. هر کدام مسئلههای متفاوتی را حل میکنند. بسیاری از برنامهها از هر دو استفاده میکنند.

۸. آیا Genericها سازگار-عقبی هستند؟

بله. کدی که از پارامترهای تایپ استفاده نمیکند بدون تغییر کامپایل میشود. میتوانید به‌تدریج با معرفی تایپها یا توابع Generic جدید در کنار کد موجود، مهاجرت کنید.

نتیجه‌گیری

برنامه دست-کارت ساختید که با Sliceای از interface{} و Type Assertion شروع شد؛ سپس به Deck[C Card] و تابع Generic یعنی printCard Refactor شد. Genericها به شما امنیت تایپی زمان-کامپایل، نبود Type Assertion در محلهای فراخوانی و کد قابل استفاده مجدد برای چندین تایپ کارت—هم‌زمان با حفظ پیادهسازی شفاف—دادند.

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا