جنریک ها در گو و بررسی پرفورمنس آنها با گرفتن بنچ مارک

در ماه فبریه 2022 به صورت رسمی ورژن 1.18 گو ریلیز میشه و در این ورژن اولین بار در یک ورژن stable از گو ما generics رو داریم و میتونیم ازش استفاده کنیم؛ فیچری که گوفر ها خیلی وقته منتظرش هستن.


یک مثال از جنریک! جمع اعداد

فرض کن در لحظه بدون جنریک میخوایم توی زبون Go آیتم های یک سری اسلایس رو با هم جمع کنیم و خروجی رو برگردونیم. جواب ساده اینه که نمیتونیم یک تابع داشته باشیم که جامع باشه. باید برای هر تایپ یک تابع بنویسیم. برای مثال:

func SumInts(s []int) int {
   var sum int
   for _, num := range s {
      sum += num
   }
   return sum
}
func SumInt64s(s []int64) int64 {
   var sum int64
   for _, num := range s {
      sum += num
   }
   return sum
}

func main() {
   fmt.Println(SumInts([]int{1, 2, 3}))
   fmt.Println(SumInt64s([]int64{1, 2, 3}))
}

همونطوری که توی کد بالا می بینید ما برای int64 ها باید یک تابع جدید میزدیم و دقیقا همون لاجیک جمع کردن آیتم های int رو کپی میکردیم. حالا ما چه تایپ های عددی ایی داریم؟ int, int8, int32, int64, uint, uint32, float ... . اگه بخوایم با این روش جلو بریم باید برای تک تک این تایپ ها یک تابع جمع بنویسیم.

البته میتونیم با استفاده از interface{} و مقداری تف زدن :) یک تابع داشته باشیم که کار Sum رو برامون انجام بده:

func Sum(s interface{}) float64 {
   switch s.(type) {
   case []int:
      return float64(SumInts(s.([]int)))
   case []int64:
      return float64(SumInt64s(s.([]int64)))
   }
   return -1
}

ولی خب همونطوری که معلومه فقط در ظاهر کسی که از پکیج استفاده میکنه کارش راحت شده (جدا از اینکه چون داریم float64 میکنیم overhead داریم) اما کدمون پیچیده تر میشه و بعدا برای تعریف تایپ جدید علاوه بر تابع جدید و کپی کردن لاجیک باید case جدید هم اضافه کنیم.

نصب ورژن go1.18beta1

روزی که این مقاله رو مینویسم آخرین ورژن گو 1.18 بتا هست و هنوز 1.18 ریلیز نشده. برای نصب این ورژن ابتدا دستور زیر را اجرا کنید:

go install golang.org/dl/go1.18beta1@latest

سپس با اجرای دستور زیر منتظر بمانید تا نصب تکمیل گردد

go1.18beta1 download

جنریک ها توی گو چطور هستن؟

در مرحله اول فرض کنید میخوایم تابع Sum رو تمیز کنیم طوری که بگیم ورودی ما یک آرایه از int یا int64 هست و چون هردو خاصیت عددی دارن گو به ما اجازه میده فرایند جمع رو انجام بدیم و خروجی هم براساس تایپ ورودی برمیگردونیم که overhead نداشته باشیم.

func Sum[T int | int64](s []T) T {
   var sum T
   for _, item := range s {
      sum += item
   }
   return sum
}

func main() {
   s1 := Sum([]int{1, 2, 3})
   fmt.Printf(&quot%T %d\n&quot, s1, s1) // int 6
   s2 := Sum([]int64{1, 2, 3})
   fmt.Printf(&quot%T %d\n&quot, s2, s2) // int64 6
}

اول خروجی ها رو می بینیم که آره. همونطوری که انتظار داشتیم جمع درست انجام شده بود و تایپ خروجی ها براساس ورودی ها داینامیک بود! این خیلی خوبه. حالا ببینیم کدش رو چطور زدیم؟

تابع رو به این شکل تعریف کردیم. Sum[T int | int64](s []T) T توی این خط مشخصه که Sum اسم تابع هست اما بین [] ما تایپ های قابل قبولمون رو با استفاده از constraints ها تعریف میکنیم. مثلا توی این تابع گفتیم ما یک تایپ T داریم که نوعش میتونه int یا int64 باشه. جلوتر گفتیم ورودی s ما از نوع اسلایس T هست و خروجی ما هم از نوع T هست.

حالا وقتی توی ورودی تابع یک اسلایس int64 دادیم متوجه میشه که توی کد ما T=int64 و لاجیک ما برای یک اسلایس int64 اجرا میشه. حالا اگه بخوایم به Sum یک تایپ جدید مثلا float64 اضافه کنیم چی؟

func Sum[T int | int64 | float64](s []T) T {
   var sum T
   for _, item := range s {
      sum += item
   }
   return sum
}

func main() {
   s1 := Sum([]int{1, 2, 3})
   fmt.Printf(&quot%T %d\n&quot, s1, s1) // int 6
   s2 := Sum([]int64{1, 2, 3})
   fmt.Printf(&quot%T %d\n&quot, s2, s2) // int64 6
   s3 := Sum([]float64{1, 2.5, 3})
   fmt.Printf(&quot%T %f\n&quot, s3, s3) // float64 6.500000
}

همونطوری که توی کد واضح هست به T تایپ float64 رو هم اضافه کردیم و خیلی راحت تونستیم از تابع برای float64 ها هم استفاده کنیم.

چیزی که هست اگه توجه کنید ما داریم تعریف تابع sum رو شلوغ تر میکنیم. یعنی جایی که تایپ های T رو مشخص میکنیم طولانی و طولانی تر میشه و متاسفانه reusable نیست کد ما. یعنی اگه یک تابع دیگه برای prod (حاصل ضرب آیتم ها) بخوایم بنویسیم باز باید تک تک int int64 float64 و .. ها رو دوباره بنویسیم.

برای حل این مشکل میتونیم از interface ها اینطور استفاده کنیم:

type numbers interface {
   int | int64 | float64
}

func Sum[T numbers](s []T) T {
   var sum T
   for _, item := range s {
      sum += item
   }
   return sum
}

برای تمیز تر شدن کد توصیه میشه یک پکیج به اسم constrains داشته باشیم و این اینترفیس ها اونجا تعریف شده باشن و جا های مختلف پروژه ازشون استفاده کنیم.

جنریک های Go از لحاظ پرفورمنسی چطور هستن؟

بیاید با هم یک مثال رو در نظر بگیریم. میخوایم یک تابع بنویسیم که بگرده دنبال یک آیتم توی یک اسلایس و بهمون بگه ایندکسش توی اون اسلایس چنده و اگر نتونست پیداش کنه -1 برگردونه.

func Find[T comparable](s []T, search T) int {
   for i, item := range s {
      if item == search {
         return i
      }
   }
   return -1
}

توی این کد از comparable استفاده کردیم که یکی از کانسترین های پیشفرض زبون هست که به ما اجازه مقایسه رو میده. یعنی یک اینتفرفیس از پیش تعریف شده گو برای این منظور که شامل بولین, اعداد, متن و ... میشود:

comparable is an interface that is implemented by all comparable types
(booleans, numbers, strings, pointers, channels, interfaces,
arrays of comparable types, structs whose fields are all comparable types).

خب حالا میدونیم این کد کار میکنه. ولی خوب کار میکنه؟ پرفورمنسی چطوره؟ میخوایم با این دو تابع که مخصوص پیدا کردن یک آیتم توی اسلایس int و string هستن مقایسش کنیم.

func FindStr(s []string, search string) int {
   for i, item := range s {
      if item == search {
         return i
      }
   }
   return -1
}
func FindInt(s []int, search int) int {
   for i, item := range s {
      if item == search {
         return i
      }
   }
   return -1
}

برای بنچ مارک گیری یک اسلایس 1000 عضوی میسازیم و دنبال عضو 500 ام میگردیم.

مقایسه خروجی برای int ها:

BenchmarkFindInt       4918909               244.5 ns/op
BenchmarkFind_ForInt           4918566               245.2 ns/op

همونطوری که مشاهده میکنید تفاوت در حد یک نانوثانیه هست که واقعا قابل چشم پوشی هست. برای رشته ها هم همین حرکت رو انجام بدیم:

BenchmarkFindStr        888519              1267 ns/op
BenchmarkFind_ForStr            943863              1274 ns/op

باز هم می بینیم تفاوت در حد چند نانوثانیه هست که بسیار کم هست و با در نظر گرفتن اینکه چقدر کد ما رو تمیز تر کرده قابل چشم پوشی.

جمع بندی

با اومدن جنریک ها به گو در خیلی از توابع به اصطلاح utility کار ما ساده تر خواهد شد. من خیلی خوشحالم چون طی توسعه خیلی نیازش رو حس میکردم. اما جنریک در گو بسیار ابتدایی میباشد و هنوز باید به رعایت اصول و معماری درست پروژه توجه ویژه ایی داشت چون جنریک با magic هایی که در زبان های Dynamic داریم بسیار متفاوت اصل و صرفا در مواردی جلوی دوباره نویسی لاجیک را میگیرد.