Go Programming

اجرای جنریک‌ها در Go: ساخت مجموعه‌های ایمن از نظر نوع و ساختارهای داده سفارشی

از انتشار Go 1.18، این زبان بزرگ‌ترین تحول را در سیستم نوع خود تجربه کرده است. معرفی جنریک‌ها به توسعه‌دهندگان Go امکان نوشتن کدی قابل استفاده مجدد و ایمن از نظر نوع را داده است که نیاز به کدهای تکراری (boilerplate) یا بررسی‌های نوع ناامن را کاهش می‌دهد. برای توسعه‌دهندگان متوسط و پیشرفته، تسلط بر جنریک‌ها دیگر اختیاری نیست—بلکه برای ساخت برنامه‌های مقاوم و مقیاس‌پذیر ضروری است.

در این پست، ما فراتر از توابع ساده حرکت کرده و به ساخت مجموعه‌های سفارشی و ایمن از نظر نوع می‌پردازیم. ما بررسی خواهیم کرد که چگونه می‌توان از محدودیت‌های نوع (type constraints) برای ایجاد ساختارهای داده‌ای استفاده کرد که به طور یکپارچه با انواع مختلف کار می‌کنند، در حالی که تضمین‌های ایمنی سخت‌گیرانه‌ای که Go برای آن معروف است را حفظ می‌کنند.

درک محدودیت‌های نوع

قبل از ساخت ساختارهای پیچیده، درک محدودیت‌های نوع حیاتی است. در Go، یک محدودیت نوع، مجموعه‌ای از انواعی را تعریف می‌کند که می‌توان با یک نوع یا تابع جنریک استفاده کرد. محدودیت‌ها می‌توانند رابط‌های ساده یا انواع ترکیبی پیچیده‌ای باشند که از اتحادها (|) استفاده می‌کنند.

برای ساختارهای داده ما، اغلب نیاز داریم که انواع قابل مقایسه (برای نقشه‌ها و مجموعه‌ها) یا انواع مرتب‌شده (برای مرتب‌سازی) را پشتیبانی کنیم. Go محدودیت‌های داخلی مانند comparable و ordered را در بسته constraints ارائه می‌دهد، اما اغلب، ما رابط‌های خود را برای حداکثر انعطاف‌پذیری تعریف می‌کنیم.

package main

import (
    "fmt"
)

// Orderable انواعی را تعریف می‌کند که از عملگرهای مقایسه پشتیبانی می‌کنند
type Orderable interface {
    ~int | ~float64 | ~string
}

ساخت یک پشته (Stack) جنریک

بیایید با یک ساختار داده بنیادی شروع کنیم: یک پشته (Stack). به طور سنتی، پیاده‌سازی یک پشته برای هر نوع داده نیاز به یک نمونه جداگانه داشت (مثلاً IntStack، StringStack). با استفاده از جنریک‌ها، می‌توانیم یک پیاده‌سازی واحد ایجاد کنیم که هر نوعی را مدیریت کند.

در اینجا یک پیاده‌سازی ایمن از نظر نوع برای پشته با استفاده از جنریک‌های Go آمده است:

package main

type Stack[T any] struct {
    items []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{}
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    index := len(s.items) - 1
    item := s.items[index]
    s.items = s.items[:index]
    return item, true
}

func (s *Stack[T]) Peek() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) IsEmpty() bool {
    return len(s.items) == 0
}

این پیاده‌سازی زیبا است زیرا پارامتر نوع T برای نوع Stack می‌تواند هر نوعی باشد. با این حال، این موضوع می‌تواند منجر به یک چاله عملکردی هنگام کار با مجموعه‌های داده بزرگ شود.

بهینه‌سازی با محدودیت‌های رابط

در حالی که T any انعطاف‌پذیر است، ذخیره اشاره‌گرها یا ساختارها می‌تواند منجر به تخصیص حافظه غیرضروری شود. برای مجموعه‌هایی مانند پشته‌ها، صف‌ها یا لیست‌ها، اغلب مفید است که T را به انواعی محدود کنیم که رابط interface{} یا رفتارهای خاصی را پیاده‌سازی می‌کنند تا در صورت لزوم از معنای مقدار (value semantics) پشتیبانی شود.

سناریویی را در نظر بگیرید که می‌خواهیم یک صف (Queue) جنریک بسازیم. برخلاف پشته، یک صف باید حذف از ابتدای صف را مدیریت کند که می‌تواند با برش‌ها (slices) ناکارآمد باشد. رویکرد لیست پیوندی ممکن است بهتر باشد، اما برای سادگی به پیاده‌سازی مبتنی بر برش پایبند می‌مانیم و بر ایمنی نوع تمرکز می‌کنیم.

type Queue[T comparable] struct {
    items []T
}

func NewQueue[T comparable]() *Queue[T] {
    return &Queue[T]{
        items: make([]T, 0),
    }
}

func (q *Queue[T]) Enqueue(item T) {
    q.items = append(q.items, item)
}

func (q *Queue[T]) Dequeue() (T, bool) {
    if len(q.items) == 0 {
        var zero T
        return zero, false
    }
    item := q.items[0]
    q.items = q.items[1:]
    return item, true
}

با استفاده از comparable، ما صف را به انواعی محدود می‌کنیم که قابل مقایسه هستند، که اگر قصد اضافه کردن عملیاتی مانند بررسی تکراری‌ها را در آینده داشته باشیم، مفید است.

کاربرد عملی: جستجوی جنریک در یک مجموعه

یکی از قدرتمندترین کاربردهای جنریک‌ها، ایجاد روش‌های کمکی جنریک برای ساختارهای داده شماست. به عنوان مثال، افزودن یک تابع جستجوی جنریک به Stack ما نیاز دارد که نوع comparable باشد.

func (s *Stack[T]) Contains(item T) bool {
    for _, v := range s.items {
        if v == item {
            return true
        }
    }
    return false
}

اگر سعی کنیم این روش را با یک ساختار داده‌ای که از بررسی‌های برابری پشتیبانی نمی‌کند (یعنی حاوی برش‌ها، نقشه‌ها یا توابع است) استفاده کنیم، کامپایلر Go از کامپایل کد جلوگیری می‌کند و ایمنی نوع را در زمان کامپایل تضمین می‌کند.

بهترین شیوه‌ها و نتیجه‌گیری

هنگام کار با جنریک‌های Go، این بهترین شیوه‌ها را در نظر داشته باشید:

  • از مهندسی بیش از حد پرهیز کنید: از جنریک‌ها برای همه چیز استفاده نکنید. زمانی از آن‌ها استفاده کنید که کد تکراری دارید که تنها از نظر نوع متفاوت است.
  • انواع مقدار را ترجیح دهید: در صورت امکان، از انواع مقدار در محدودیت‌ها استفاده کنید تا از سربار غیرمستقیم اشاره‌گر جلوگیری شود، مگر اینکه به طور خاص به معنای مرجع نیاز داشته باشید.
  • نام‌گذاری واضح: از پارامترهای نوع تک‌حرفی (T، E، K) استفاده کنید، مگر اینکه نام وضوح قابل توجهی اضافه کند.

جنریک‌ها در Go ابزاری قدرتمند برای ساخت مجموعه‌های ایمن از نظر نوع و ساختارهای داده سفارشی هستند. با بهره‌گیری از محدودیت‌های نوع، می‌توانید کدی انعطاف‌پذیر، کارآمد و قابل نگهداری بنویسید که با نیازهای برنامه شما مقیاس‌پذیر است. با بالغ‌تر شدن اکوسیستم Go، انتظار می‌رود کتابخانه‌ها و اصطلاحات استاندارد بیشتری در اطراف این الگوها ظاهر شوند.

امروزه شروع به بازنگری کدهای تکراری خود کنید و از افزایش بهره‌وری که جنریک‌های Go ارائه می‌دهند، لذت ببرید.

Share: