با معرفی جنریکها در گو ۱.۱۸، این زبان از یک ابزار صرفاً ساختاری به یک پلتفرم مهندسی بیانگر و ایمن از نظر نوع تبدیل شده است. برای توسعهدهندگان متوسط و پیشرفته، این موضوع تنها درباره راحتی نیست؛ بلکه درباره حذف کدهای تکراری در حالی است که تایپ استاتیک قوی که گو به خاطر آن معروف است، حفظ میشود.
در این پست، بررسی خواهیم کرد که چگونه میتوان سه الگوی طراحی بنیادین—سازنده (Builder)، کارخانه (Factory) و استراتژی (Strategy)—را با استفاده از جنریکهای مدرن گو پیادهسازی کرد. این الگوها که زمانی نیاز به اینترفیسهای پیچیده یا بازتاب (Reflection) داشتند، اکنون میتوانند با ایمنی در زمان کامپایل و به شکلی زیبا پیادهسازی شوند.
الگوی سازنده (Builder) با جنریکها
الگوی سازنده برای ساخت اشیاء پیچیده به صورت گامبهگام ضروری است. قبل از وجود جنریکها، توسعهدهندگان اغلب از الگوی «گزینههای تابعی» (functional options) استفاده میکردند یا به اینترفیسهای خالی تکیه مینمودند که ایمنی نوع را قربانی میکرد. با جنریکها، میتوانیم تضمین کنیم که روشهای سازنده ما، یک ساختار (struct) با یک پارامتر نوع جنریک خاص را برمیگردانند که امکان ایجاد APIهای روان (fluent) را بدون از دست دادن اطلاعات نوع فراهم میکند.
بیایید یک ساختار پیکربندی برای اتصال به پایگاه داده را در نظر بگیرید. با استفاده از یک سازنده جنریک، میتوانیم الزام کنیم که خروجی نهایی از نوع صحیح باشد.
package main
import "fmt"
// ساختار Config جنریک
type Config[T any] struct {
Driver string
Params map[string]T
}
// سازنده جنریک
type Builder[T any] struct {
config *Config[T]
}
func NewConfigBuilder[T any]() *Builder[T] {
return &Builder[T]{
config: &Config[T]{
Params: make(map[string]T),
},
}
}
func (b *Builder[T]) Driver(d string) *Builder[T] {
b.config.Driver = d
return b
}
func (b *Builder[T]) AddParam(key string, val T) *Builder[T] {
b.config.Params[key] = val
return b
}
func (b *Builder[T]) Build() *Config[T] {
return b.config
}
// نحوه استفاده
func main() {
// نوع T به عنوان int استنتاج میشود
cfg := NewConfigBuilder[int]().
Driver("postgres").
AddParam("max_connections", 100).
AddParam("timeout", 30).
Build()
fmt.Println(cfg.Params["max_connections"]) // خروجی: 100
}
این رویکرد تضمین میکند که `AddParam` تنها نوع مشخصشده `T` را میپذیرد و از خطاهای زمان اجرا (panics) مرتبط با `interface{}` جلوگیری میکند.
الگوی کارخانه: حذف دستورات Switch
الگوی کارخانه، ایجاد شیء را انتزاع میکند. در گو سنتی، این موضوع اغلب شامل دستورات switch بزرگ یا نقشههای ثبت (registration maps) بود. جنریکها به ما امکان میدهند یک کارخانه جنریک ایجاد کنیم که میتواند انواع مختلف را بر اساس یک تابع سازنده ارائهشده instantiate کند که این امر وابستگیها را کاهش داده و قابلیت آزمایش (testability) را بهبود میبخشد.
package main
import "fmt"
// نوع کارخانه جنریک
type Factory[T any] struct {
creators map[string]func() T
}
func NewFactory[T any]() *Factory[T] {
return &Factory[T]{
creators: make(map[string]func() T),
}
}
// Register امکان افزودن انواع عینی یا توابع کارخانه را فراهم میکند
func (f *Factory[T]) Register(name string, creator func() T) {
f.creators[name] = creator
}
// Create نوع را با استفاده از تابع ثبتشده instantiate میکند
func (f *Factory[T]) Create(name string) (T, error) {
creator, ok := f.creators[name]
if !ok {
var t T
return t, fmt.Errorf("unknown type: %s", name)
}
return creator(), nil
}
// مثال استفاده با ساختارها
type Animal struct {
Name string
}
func DogFactory() Animal {
return Animal{Name: "Dog"}
}
func CatFactory() Animal {
return Animal{Name: "Cat"}
}
func main() {
animalFactory := NewFactory[Animal]()
animalFactory.Register("dog", DogFactory)
animalFactory.Register("cat", CatFactory)
animal, _ := animalFactory.Create("dog")
fmt.Println(animal.Name) // خروجی: Dog
}
این الگو بهویژه در معماریهای پلاگین مفید است که در آنها پیادهسازیهای خاص در زمان کامپایل شناخته نمیشوند اما باید از یک اینترفیس یا تعریف ساختار جنریک پیروی کنند.
الگوی استراتژی با محدودیتهای نوع
الگوی استراتژی خانوادهای از الگوریتمها را تعریف میکند، هر کدام را کپسوله میکند و آنها را قابل تعویض میسازد. اگرچه اینترفیسها همیشه راه گو برای این کار بودهاند، اما جنریکها با جلوگیری از هزینههای dispatch اینترفیس و امکان بهینهسازی بهتر توسط کامپایلر (inlining)، مزیت عملکردی ارائه میدهند.
ما میتوانیم از محدودیتهای نوع استفاده کنیم تا اطمینان حاصل کنیم که استراتژی ما تنها برای انواعی اعمال میشود که عملیات خاصی را پشتیبانی میکنند.
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// NumericStrategy یک الگوریتم را روی هر نوع عددی اعمال میکند
type NumericStrategy[T constraints.Integer | constraints.Float] interface {
Process(items []T) T
}
type MaxFinder[T constraints.Integer | constraints.Float] struct{}
func (m MaxFinder[T]) Process(items []T) T {
if len(items) == 0 {
var zero T
return zero
}
max := items[0]
for _, item := range items[1:] {
if item > max {
max = item
}
}
return max
}
func main() {
strategy := MaxFinder[int]{}
result := strategy.Process([]int{1, 5, 3, 9, 2})
fmt.Println(result) // خروجی: 9
floatStrategy := MaxFinder[float64]{}
fResult := floatStrategy.Process([]float64{1.1, 5.5, 3.3})
fmt.Println(fResult) // خروجی: 5.5
}
نتیجهگیری
جنریکها در گو راهحل جادویی نیستند، اما ابزار قدرتمندی برای کاهش تکرار کد و افزایش ایمنی نوع هستند. با اعمال الگوهای Builder، Factory و Strategy همراه با جنریکها، میتوانید کدی بنویسید که نه تنها عملکرد بهتری دارد، بلکه نگهداری و آزمایش آن نیز آسانتر است. با بالغتر شدن اکوسیستم گو، انتظار میرود این الگوها به یک رویه استاندارد در برنامههای با مقیاس بالا تبدیل شوند. جنریکها را بپذیرید، اما همیشه خوانایی و سادگی را در اولویت قرار دهید.