6.4 Constraints و Type Sets

6.4 Constraints و Type Sets

۶.۴.۱ مفهوم constraint و نقش آن در ژنریک‌ها #

Constraint (قید یا محدودیت) در ژنریک‌های Go ابزاری است برای کنترل اینکه یک پارامتر نوعی (type parameter) باید چه ویژگی‌هایی داشته باشد.
بدون constraint، هر نوعی می‌تواند جایگزین شود، اما با تعریف constraint، دایره‌ی مجاز را محدود می‌کنیم تا هم ایمنی نوعی بالا رود و هم امکانات بیشتری برای پیاده‌سازی داشته باشیم.

نقش constraint: #

  • جلوگیری از استفاده نادرست از ژنریک‌ها (مثلاً استفاده از عملیات غیرمجاز روی نوع پارامتری)
  • افزایش قابلیت تشخیص خطا در زمان کامپایل
  • امکان تعریف abstractionهای قوی‌تر

مثال ساده:

1func Sum[T Number](a, b T) T { return a + b }
2type Number interface { int | float64 }

در این مثال، فقط انواعی که در constraint Number تعریف شده‌اند، مجاز هستند.


۶.۴.۲ interface constraint و مثال‌های کاربردی #

در Go، constraint معمولاً به صورت یک interface بیان می‌شود؛ این interface می‌تواند شامل متدها یا ترکیبی از انواع (type sets) باشد.

مثال: constraint مبتنی بر متد #

1type Stringer interface {
2    String() string
3}
4
5func PrintString[T Stringer](v T) {
6    fmt.Println(v.String())
7}

هر نوعی که متد String() string را داشته باشد (مثلاً time.Time یا type خودتان)، می‌تواند برای این تابع استفاده شود.

مثال: constraint مبتنی بر type set (union) #

1type Numeric interface { int | int64 | float64 }
2func Max[T Numeric](a, b T) T {
3    if a > b {
4        return a
5    }
6    return b
7}

فقط انواع عددی مجاز به استفاده از Max هستند.

مثال: ترکیبی #

1type ByteString interface {
2    ~[]byte | ~string
3}
4func FirstChar[T ByteString](s T) byte {
5    return s[0]
6}

هر نوعی که underlying آن []byte یا string باشد، مجاز است.


۶.۴.۳ استفاده از کلیدواژه‌های any، comparable و Ordered #

Go چندین constraint از پیش تعریف‌شده دارد:

any #

  • معادل interface{}، یعنی هیچ محدودیتی وجود ندارد:

    1func Identity[T any](v T) T { return v }
    

comparable #

  • فقط نوع‌هایی که می‌توان با == یا != مقایسه کرد (برای map key یا مجموعه‌ها):

    1func Contains[T comparable](slice []T, v T) bool {
    2    for _, item := range slice {
    3        if item == v {
    4            return true
    5        }
    6    }
    7    return false
    8}
    

Ordered (از پکیج cmp، Go 1.21+) #

برای انواعی که می‌توان از <, >, <=, >= استفاده کرد (int, float, string):

1  import "cmp"
2  func Min[T cmp.Ordered](a, b T) T {
3      if a < b {
4          return a
5      }
6      return b
7  }

۶.۴.۴ ساخت constraint سفارشی و ترکیبی (union constraints) #

شما می‌توانید constraint دلخواه بسازید و انواع مختلف را در یک مجموعه (type set) قرار دهید:

مثال: #

1type IDType interface {
2    int | int64 | string
3}
4func ParseID[T IDType](v T) string {
5    return fmt.Sprintf("%v", v)
6}
  • می‌توانید متد هم به آن اضافه کنید:

    1type ToStringer interface {
    2    ~string | ~[]byte
    3    ToString() string
    4}
    

نکته مهم: #

  • علامت ~ در Go به این معنی است که نوع مورد نظر باید underlying type مشخص‌شده را داشته باشد (مثلاً نوع تعریف‌شده‌ای که underlying آن string باشد).

  • در Go 1.24 به بعد می‌توانید حتی constraint alias تعریف کنید:

    1type Num = interface{ int | float64 }
    

۶.۴.۵ Generic Interfaces و قابلیت‌های جدید (بر اساس Go 1.21+ و 1.24) #

ژنتریک اینترفیس‌ها (Generic Interfaces) از Go 1.18 امکان‌پذیر شد و در نسخه‌های جدید، قابلیت‌های قوی‌تری یافته است.

۶.۴.۵.۱ پیاده‌سازی الگوهای abstraction با interface ژنریک #

می‌توانید abstractionهایی بسازید که به طور کلی روی انواع مختلف اعمال شوند:

 1type Comparer[T any] interface {
 2    Compare(T) int
 3}
 4
 5type Sortable[T Comparer[T]] []T
 6
 7func (s Sortable[T]) Sort() {
 8    sort.Slice(s, func(i, j int) bool {
 9        return s[i].Compare(s[j]) < 0
10    })
11}
  • هر نوعی که متد Compare(T) int را داشته باشد، قابل استفاده است.
  • این قابلیت قدرت abstraction و توسعه کتابخانه‌های عمومی را به شدت افزایش داده است.

۶.۴.۵.۲ نکات و چالش‌های پیشرفته (مثلاً مسأله pointer receivers و type inference) #

الف) pointer receivers:

گاهی constraint روی اینترفیس باید به نوع pointer باشد تا متدهای دریافت‌کننده (receiver) به درستی کار کند.

  • اگر متدها روی pointer تعریف شده باشند، باید pointer به عنوان نوع پارامتر بدهید:

    1type Setter[T any] interface {
    2    Set(T)
    3}
    4func Update[T any, S Setter[T]](s S, v T) { s.Set(v) }
    

ب) type inference در چند پارامتر:

در برخی موارد که چندین type parameter وجود دارد (مثلاً برای abstractionهای پیچیده یا ترکیب چند constraint)، ممکن است inference نوع پیچیده شود و لازم باشد type parameters را به صراحت مشخص کنید.

ج) مقایسه با زبان‌های دیگر:

در Go سعی شده تا حد امکان inference ساده و شفاف باشد، اما در abstractionهای خیلی پیچیده (مانند ژنریک تو در تو، pointer receivers یا interface embedding) ممکن است خوانایی امضاها (signature) کمی سخت شود، به خصوص برای توسعه‌دهندگان تازه‌کار.

د) نکته تولیدی:

تا حد امکان constraintها را ساده، گویا و خوانا نگه دارید. constraintهای ترکیبی و abstractionهای ژنریک را فقط زمانی به کار ببرید که واقعاً نیاز است و مستندسازی کافی داشته باشید.

۶.۴.۶ بررسی عمیق constraints در Go #

در Go، هرچقدر آزادی در انتخاب نوع پارامتر بیشتر شود، قدرت استفاده از آن کمتر خواهد بود. بنابراین، از محدودیت‌های دقیق برای افزایش قابلیت‌های ژنریک‌ها استفاده می‌کنیم. در واقع، به جای [T any]، باید constraint مناسب انتخاب شود که کامپایلر بداند چه عملیاتی روی T مجاز است.

قاعده کلی: هرچه interface یا constraint بزرگ‌تر باشد، abstraction ضعیف‌تر است.

✅ چرا [T any] محدودیت دارد؟ #

با تعریف تابع ساده مثل:

1func Add[T any](a, b T) T {
2    return a + b
3}

کامپایلر Go خطا می‌دهد چون از T any نمی‌داند آیا T قابلیت عملگر + را دارد یا خیر. بنابراین نمی‌تواند کدی را که معتبر باشد تولید کند. این نشان می‌دهد که آزادی بیش از حد باعث حذف قابلیت‌های مهم می‌شود.

انواع constraint در Go #

۱. Basic Interface Constraint (محدودیت بر پایه متد) #

این نوع constraint شامل متدهایی است که باید توسط نوع پیاده‌سازی شود.

مثال:

1func Stringify[T fmt.Stringer](v T) string {
2    return v.String()
3}

اینجا کامپایلر می‌داند که v متدی به نام String() دارد، بنابراین می‌تواند آن را بدون خطا اجرا کند.

۲. Type Set Constraint (محدودیت لیستی برای عملگرها) #

برای استفاده از عملگرهایی مثل +، باید T را محدود کنیم تا مجموعه‌ای از انواع مشخص باشد:

1type Numeric interface { int | int64 | float64 }
2
3func Add[T Numeric](a, b T) T {
4    return a + b
5}

اکنون کامپایلر تضمین می‌دهد که T حتماً یکی از انواع عددی است و عمل + معتبر خواهد بود.

همچنین برای اجازه استفاده از ~ برای پذیرش زیرنوع‌ها:

1type Intish interface { ~int }

۳. ترکیب محدودیت‌ها #

می‌توان constraintهایی ساخت که چند محدودیت را همزمان اعمال کنند، مثلاً متد و عملگر:

1type ReadStringer interface {
2    fmt.Stringer
3    io.Reader
4    ~[]byte | ~string
5}

این محدودیت بیان می‌کند که T باید هم String() داشته باشد، هم Read() اجرا کند، و نوع underlying آن []byte یا string باشد.

🔑 اهمیت و پیامدها #

  • خوانایی و اطمینان بالا:
    با محدود کردن دقیق T تنها به انواعی که عملیات مورد نظر را دارند، از بروز خطا جلوگیری می‌کنید.

  • کارایی بدون overhead:
    چون کامپایلر می‌داند دقیقاً چه عملیاتی مجاز است، نیازی به reflect یا بررسی در runtime نیست.

  • ارتقاء abstraction:
    تعریف سلسله‌مراتبی از constraintها مانند Numeric, Ordered، یا ReadStringer امکان reuse و خوانایی بالاتر کد را فراهم می‌کند.