6.2 مبانی ژنریک‌ها در Go

6.2 مبانی ژنریک‌ها در Go

۶.۲.۱ تعریف ژنریک (Generic) چیست؟ #

ژنریک (Generic) به معنی “کلی”، “عام” یا “نوع‌پذیر” است؛ مفهومی که به شما امکان می‌دهد یک تابع، نوع داده یا ساختار را به گونه‌ای بنویسید که با انواع مختلف داده‌ها کار کند، بدون اینکه برای هر نوع، پیاده‌سازی جداگانه لازم باشد.
به بیان دیگر، ژنریک‌ها قابلیتی برای بازاستفاده امن و بهینه از کد در سطح زبان برنامه‌نویسی هستند.

یه جمله ای از Ian lancer tailor هست:

زمانی باید از جنریک استفاده کرد که کد شما بواسطه تغییر تایپ تکرار می شود یا اینکه برای کاهش assertion از جنریک استفاده کنیم.

کاربرد ژنریک: #

فرض کنید می‌خواهید یک تابع برای پیدا کردن مقدار بیشینه در یک آرایه بنویسید.
در حالت عادی باید برای هر نوع داده (int، float64، string و …) یک نسخه بنویسید یا از interface{} استفاده کنید که معایب زیادی دارد.
ژنریک‌ها این محدودیت را برطرف می‌کنند و به شما اجازه می‌دهند که فقط یک بار منطق را بنویسید و برای هر نوع داده‌ای از آن استفاده کنید.

نمونه ساده:

1// یک تابع ژنریک برای بازگرداندن بیشینه دو مقدار از هر نوع مرتب‌شونده
2func Max[T cmp.Ordered](a, b T) T {
3    if a > b {
4        return a
5    }
6    return b
7}

اینجا [T cmp.Ordered] می‌گوید T می‌تواند هر نوعی باشد که قابلیت مقایسه داشته باشد (int، float64، string و …).

ویژگی اصلی ژنریک: #

  • تعریف توابع و ساختارهای عمومی (generic) که به صورت type-safe با انواع مختلف کار می‌کنند.
  • کاهش چشمگیر تکرار کد (DRY)
  • ارتقاء خوانایی و نگهداری کد
  • بهبود کارایی نسبت به روش‌های مبتنی بر interface{} و بازتاب (reflect)

۶.۲.۲ واژگان کلیدی ژنریک در Go #

درک مفاهیم کلیدی ژنریک در Go برای استفاده صحیح و حرفه‌ای ضروری است:

۱. Type Parameter (پارامتر نوع) #

  • متغیری است که نوع داده را به صورت پارامتریک مشخص می‌کند.
  • در تعریف تابع یا نوع ژنریک درون کروشه قرار می‌گیرد.

مثال:

1func Print[T any](item T) {
2        fmt.Println(item)
3    }

۲. Constraint (قید/محدودیت) #

  • محدودیتی که مشخص می‌کند پارامتر نوع (T) باید چه ویژگی‌هایی داشته باشد.
  • معمولاً یک اینترفیس است که نوع موردنظر باید آن را پیاده‌سازی کند یا عضو مجموعه‌ای از انواع باشد.

مثال:

1    func Sum[T Number](a, b T) T { ... }
2   type Number interface {
3       int | int64 | float64
4   }

۳. Type Set (مجموعه نوع) #

  • مجموعه‌ای از انواع که یک constraint آن‌ها را مجاز می‌داند.

  • در Go، type set معمولاً به صورت union تعریف می‌شود (مثلاً int | float64)

  • در constraintهای مبتنی بر اینترفیس می‌توانید ترکیبی از method و type را تعیین کنید:

    1type Stringer interface {
    2    String() string
    3}
    4type Numeric interface {
    5    int | int64 | float64
    6}
    

۴. Type Inference (استنتاج نوع) #

  • فرآیندی که در آن کامپایلر Go می‌تواند پارامتر نوع را به صورت خودکار از روی ورودی‌های تابع یا نوع، حدس بزند.

مثال:

1    Max(10, 20)    // T به طور خودکار int فرض می‌شود
2    Max(2.5, 3.8)  // T به طور خودکار float64 فرض می‌شود

۵. Constraint Interface (اینترفیس محدودکننده) #

  • اینترفیس‌هایی که هم می‌توانند method داشته باشند هم مجموعه‌ای از انواع را مشخص کنند. مثال:

    1type Constraint interface {
    2    ~[]byte | ~string
    3    Hash() uint64
    4}
    

۶. any و comparable #

  • any: معادل interface{}، یعنی هر نوعی را مجاز می‌داند.
  • comparable: فقط نوع‌هایی که قابل مقایسه با == و != هستند را می‌پذیرد (برای map key و غیره).

۶.۲.۳ تفاوت ژنریک‌های Go با سایر زبان‌ها (Java، C#، Rust و …) #

ژنریک‌ها مفهومی جهانی هستند، اما نحوه پیاده‌سازی و امکانات آن‌ها در زبان‌های مختلف متفاوت است. در اینجا برخی تفاوت‌های کلیدی آورده شده است:

الف) سینتکس و سادگی #

  • ژنریک‌های Go به صورت پارامتر نوع در کروشه [] تعریف می‌شوند:

    1func Swap[T any](a, b T) (T, T)
    
  • در Java و C#: پارامتر نوع با <> تعریف می‌شود:

    1public <T> void swap(T a, T b)
    

ب) Type Constraint #

  • Go امکان تعریف محدودیت (constraint) به شکل بسیار قوی و صریح با اینترفیس یا مجموعه نوع دارد.
  • در Java، فقط می‌توانید یک superclass یا interface به عنوان محدودیت تعریف کنید.
  • در Rust، با trait bounds، و در C# با constraints (مثل where T: struct).

ج) Type Erasure vs. Monomorphization #

  • در Java، پیاده‌سازی ژنریک‌ها با Type Erasure است؛ یعنی اطلاعات نوع ژنریک در زمان اجرا حذف می‌شود و فقط در زمان کامپایل کنترل می‌شود.
  • در Go (و Rust و ++C)، ژنریک‌ها با Monomorphization پیاده‌سازی می‌شوند؛ یعنی برای هر نوع داده، کد جداگانه‌ای در زمان کامپایل تولید می‌شود (به معنای ایمنی و کارایی بالاتر).
  • C# هم از رویکرد متفاوتی بهره می‌برد که در برخی موارد closer به monomorphization است.

د) پشتیبانی از عملیات #

  • در Go می‌توانید union type تعریف کنید (مثلاً int | float64)
  • در Java این امکان وجود ندارد و باید فقط به یک superclass یا interface محدود کنید.

ه) Specialization و Reflection #

  • Go ژنریک‌ها را به صورت type-safe و بدون بازتاب (reflect) اجرا می‌کند، در حالی که در زبان‌هایی مثل Python و حتی Java، بخشی از قدرت ژنریک‌ها وابسته به بازتاب است.
  • Rust و ++C هم مانند Go، اجرا را type-safe و بدون reflect انجام می‌دهند.

و) تفاوت در قابلیت‌ها #

  • در Go، ژنریک‌ها روی function, struct و interface قابل اعمال هستند.
  • در Rust و ++C حتی macroها و traitهای پیچیده‌تر و specializationهای سطح پایین‌تر ممکن است.
  • در Java و C#، برخی ویژگی‌ها مانند generic constructor یا wildcard support متفاوت‌اند.