Skip to content

دوال Go

في Go، الدوال هي مواطن من الدرجة الأولى، والدوال هي المكون الأساسي في Go، وهي أيضاً جوهر Go.

التعريف

صيغة تعريف الدالة كالتالي:

go
func اسم_الدالة([قائمة_المعاملات]) [القيمة_المُرجعة] {
  جسم_الدالة
}

هناك طريقتان لتعريف الدالة، الأولى من خلال الكلمة المفتاحية func مباشرة، والثانية من خلال الكلمة المفتاحية var، كالتالي:

go
func sum(a int, b int) int {
  return a + b
}

var sum = func(a int, b int) int {
  return a + b
}

تتكون توقيع الدالة من اسم الدالة وقائمة المعاملات والقيمة المُرجعة، فيما يلي مثال كامل، اسم الدالة Sum، ولها معاملان من نوع int هما a و b، ونوع القيمة المُرجعة int:

go
func Sum(a int, b int) int {
   return a + b
}

هناك نقطة مهمة جداً، وهي أن دوال Go لا تدعم التحميل الزائد، الكود التالي لن يتم ترجمته:

go
type Person struct {
  Name    string
  Age     int
  Address string
  Salary  float64
}

func NewPerson(name string, age int, address string, salary float64) *Person {
  return &Person{Name: name, Age: age, Address: address, Salary: salary}
}

func NewPerson(name string) *Person {
  return &Person{Name: name}
}

فلسفة Go هي: إذا كان التوقيع مختلفاً فهما دالتان مختلفتان تماماً، فلا يجب أن يكون لهما نفس الاسم، التحميل الزائد للدوال يجعل الكود مربكاً وصعب الفهم. هل هذه الفلسفة صحيحة؟ الأمر يعتمد على وجهة النظر، لكن على الأقل في Go يمكنك معرفة ما تفعله الدالة من اسمها فقط، دون الحاجة للبحث عن أي نسخة محملة هي.

المعاملات

في Go، يمكن عدم تسمية المعاملات، عادةً هذا يُستخدم عند تعريف الواجهات أو أنواع الدوال، لكن لتحسين القراءة يُنصح عموماً بتسمية المعاملات:

go
type ExWriter func(io.Writer) error

type Writer interface {
  ExWrite([]byte) (int, error)
}

بالنسبة للمعاملات من نفس النوع، يمكن تعريف النوع مرة واحدة فقط، بشرط أن تكون متجاورة:

go
func Log(format string, a1, a2 any) {
  ...
}

المعاملات متغيرة الطول يمكنها استقبال 0 قيمة أو أكثر، ويجب تعريفها في نهاية قائمة المعاملات، المثال الأكثر كلاسيكية هي دالة fmt.Printf:

go
func Printf(format string, a ...any) (n int, err error) {
  return Fprintf(os.Stdout, format, a...)
}

تجدر الإشارة إلى أن معاملات الدوال في Go تُمرر بالقيمة، أي عند تمرير المعاملات يتم نسخ قيمة المُعامل الفعلي. إذا كنت تعتقد أن تمرير شريحة أو map سيستنسخ大量 الذاكرة، يمكنني طمأنتك أن لا داعي للقلق، لأن هذين الهيكلين من البيانات هما في الأساس مؤشرات.

القيمة المُرجعة

فيما يلي مثال بسيط لقيمة مُرجعة للدالة، دالة Sum تُرجع قيمة من نوع int:

go
func Sum(a, b int) int {
   return a + b
}

عندما لا يكون للدالة قيمة مُرجعة، لا حاجة لـ void، فقط لا تضع قيمة مُرجعة:

go
func ErrPrintf(format string, a ...any) {
  _, _ = fmt.Fprintf(os.Stderr, format, a...)
}

تسمح Go للدوال بقيم مُرجعة متعددة، وفي هذه الحالة يجب وضع القيم المُرجعة بين قوسين:

go
func Div(a, b float64) (float64, error) {
  if a == 0 {
    return math.NaN(), errors.New("0 لا يمكن أن يكون مقسوماً عليه")
  }
  return a / b, nil
}

تدعم Go أيضاً القيم المُرجعة المُسمَّاة، التي لا يمكن أن تتكرر مع أسماء المعاملات، عند استخدام قيم مُرجعة مُسمَّاة، الكلمة المفتاحية return لا تحتاج لتحديد القيم المُرجعة:

go
func Sum(a, b int) (ans int) {
  ans = a + b
  return
}

كما هو الحال مع المعاملات، عندما يكون هناك عدة قيم مُرجعة مُسمَّاة من نفس النوع، يمكن حذف تعريف النوع المُكرر:

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
  return
}

بغض النظر عن كيفية تعريف القيم المُرجعة المُسمَّاة، القيمة بعد الكلمة المفتاحية return دائماً لها الأولوية القصوى:

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
    // c، d لن يتم إرجاعهما
  return a + b, a * b
}

الدوال المجهولة

الدالة المجهولة هي دالة بدون توقيع، مثلاً الدالة func(a, b int) int في المثال التالي، ليس لها اسم، لذا يمكننا فقط استدعاؤها بوضع أقواس مباشرة بعد جسم الدالة:

go
func main() {
   func(a, b int) int {
      return a + b
   }(1, 2)
}

عند استدعاء دالة يكون معاملها نوع دالة، الاسم لم يعد مهماً، ويمكن تمرير دالة مجهولة مباشرة، كالتالي:

go
type Person struct {
  Name   string
  Age    int
  Salary float64
}

func main() {
  people := []Person{
    {Name: "Alice", Age: 25, Salary: 5000.0},
    {Name: "Bob", Age: 30, Salary: 6000.0},
    {Name: "Charlie", Age: 28, Salary: 5500.0},
  }

  slices.SortFunc(people, func(p1 Person, p2 Person) int {
    if p1.Name > p2.Name {
      return 1
    } else if p1.Name < p2.Name {
      return -1
    }
    return 0
  })
}

هذا مثال لفرز مخصص، slices.SortFunc تقبل معاملين، الأول شريحة، والثاني دالة مقارنة، إذا لم يكن إعادة الاستخدام مهمة، يمكننا تمرير دالة مجهولة مباشرة.

الإغلاق

مفهوم الإغلاق (Closure)، وفي بعض اللغات يُسمى تعبير Lambda، يُستخدم مع الدوال المجهولة، الإغلاق = دالة + مرجع البيئة، انظر المثال التالي:

go
func main() {
  grow := Exp(2)
  for i := range 10 {
    fmt.Printf("2^%d=%d\n", i, grow())
  }
}

func Exp(n int) func() int {
  e := 1
  return func() int {
    temp := e
    e *= n
    return temp
  }
}

المخرجات:

2^0=1
2^1=2
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^7=128
2^8=256
2^9=512

القيمة المُرجعة للدالة Exp هي دالة، سنسميها دالة grow، كلما تم استدعاؤها، المتغير e سينمو بشكل أسي. دالة grow تشير إلى متغيرين من دالة Exp: e و n، وُلدا في نطاق دالة Exp، وفي الظروف العادية مع انتهاء استدعاء دالة Exp، سيتم استعادة ذاكرة هذه المتغيرات مع خروجها من المكدس. لكن لأن دالة grow تشير إليها، لا يمكن استعادتها، بل تهرب إلى الكومة، وحتى لو انتهت دورة حياة دالة Exp، دورة حياة المتغيرين e و n لم تنته، وفي دالة grow يمكن تعديل هذين المتغيرين مباشرة، دالة grow هي دالة إغلاق.

باستخدام الإغلاق، يمكن تنفيذ دالة لحساب متتالية فيبوناتشي ببساطة شديدة، الكود كالتالي:

go
func main() {
    // 10 أعداد فيبوناتشي
  fib := Fib(10)
  for n, next := fib(); next; n, next = fib() {
    fmt.Println(n)
  }
}

func Fib(n int) func() (int, bool) {
  a, b, c := 1, 1, 2
  i := 0
  return func() (int, bool) {
    if i >= n {
      return 0, false
    } else if i < 2 {
      f := i
      i++
      return f, true
    }

    a, b = b, c
    c = a + b
    i++

    return a, true
  }
}

المخرجات:

0
1
1
2
3
5
8
13
21
34

الاستدعاء المؤجل

الكلمة المفتاحية defer يمكنها تأخير استدعاء دالة لفترة من الوقت، قبل عودة الدالة جميع الدوال المؤجلة بـ defer سيتم تنفيذها واحداً تلو الآخر، انظر المثال التالي:

go
func main() {
  Do()
}

func Do() {
  defer func() {
    fmt.Println("1")
  }()
  fmt.Println("2")
}

المخرجات:

2
1

لأن defer يُنفذ قبل عودة الدالة، يمكنك أيضاً تعديل القيمة المُرجعة للدالة في defer:

go
func main() {
  fmt.Println(sum(3, 5))
}

func sum(a, b int) (s int) {
  defer func() {
    s -= 10
  }()
  s = a + b
  return
}

عند وجود دوال متعددة مؤجلة بـ defer، سيتم تنفيذها بترتيب الأخير يدخل أولاً يخرج مثل المكدس:

go
func main() {
  fmt.Println(0)
  Do()
}

func Do() {
  defer fmt.Println(1)
  fmt.Println(2)
  defer fmt.Println(3)
  defer fmt.Println(4)
  fmt.Println(5)
}
0
2
5
4
3
1

الاستدعاء المؤجل يُستخدم عادةً لتحرير موارد الملفات وإغلاق الاتصالات الشبكية وغيرها، وهناك استخدام آخر وهو التقاط panic، لكن هذا سيُذكر في قسم معالجة الأخطاء.

الحلقة

على الرغم من عدم وجود حظر صريح، يُنصح عموماً بعدم استخدام defer في حلقة for، كالمثال التالي:

go
func main() {
  n := 5
  for i := range n {
    defer fmt.Println(i)
  }
}

المخرجات:

4
3
2
1
0

نتيجة هذا الكود صحيحة، لكن العملية قد لا تكون كذلك. في Go، كل إنشاء defer يتطلب حجز مساحة ذاكرة في الكوروتين الحالي. لنفترض أن المثال أعلاه ليس حلقة for n بسيطة، بل عملية معالجة بيانات أكثر تعقيداً، عندما تزداد الطلبات الخارجية فجأة، سيتم إنشاء عدد كبير من defer في وقت قصير، وعندما يكون عدد التكرارات كبيراً أو غير محدد، قد يؤدي ذلك لزيادة مفاجئة في استخدام الذاكرة، وهذا نسميه عادةً تسرب الذاكرة.

الحساب المسبق للمعاملات

هناك بعض التفاصيل غير البديهية للاستدعاء المؤجل، مثلاً المثال التالي:

go
func main() {
  defer fmt.Println(Fn1())
  fmt.Println("3")
}

func Fn1() int {
  fmt.Println("2")
  return 1
}

هذا الفخ خفي جداً، قضيت نصف يوم في البحث عن السبب، يمكن تخمين المخرجات، الجواب كالتالي:

2
3
1

قد يظن الكثيرين أن المخرجات كالتالي:

3
2
1

حسب نية المستخدم، fmt.Println(Fn1()) كان من المفترض تنفيذه بعد انتهاء جسم الدالة، fmt.Println يُنفذ فعلاً في النهاية، لكن Fn1() غير متوقع، المثال التالي يوضح ذلك بشكل أكثر وضوحاً:

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer fmt.Println(sum(a, b))
  a = 3
  b = 4
}

func sum(a, b int) int {
  return a + b
}

مخرجاته ستكون 3 وليس 7، إذا استخدمنا الإغلاق بدلاً من الاستدعاء المؤجل، النتيجة ستختلف:

go
func main() {
  var a, b int
  a = 1
  b = 2
  f := func() {
    fmt.Println(sum(a, b))
  }
  a = 3
  b = 4
  f()
}

مخرجات الإغلاق هي 7، فماذا لو جمعنا الاستدعاء المؤجل والإغلاق؟

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func() {
    fmt.Println(sum(a, b))
  }()
  a = 3
  b = 4
}

هذه المرة صحيح، المخرجات 7. لنعدّل المثال، بدون إغلاق:

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func(num int) {
    fmt.Println(num)
  }(sum(a, b))
  a = 3
  b = 4
}

المخرجات عادت 3. بمقارنة الأمثلة أعلاه可以发现 هذا الكود:

defer fmt.Println(sum(a,b))

يكافئ فعلياً:

defer fmt.Println(3)

Go لن تنتظر للنهاية لاستدعاء دالة sum، دالة sum تم استدعاؤها قبل تنفيذ الاستدعاء المؤجل، وتُمرر كمعامل لـ fmt.Println. الخلاصة هي أنه بالنسبة للدالة التي يعمل عليها defer مباشرة، معاملاتها ستُحسب مسبقاً، وهذا يؤدي للظاهرة الغريبة في المثال الأول، ويجب الانتباه بشكل خاص لهذه الحالة، خاصة عند استخدام القيمة المُرجعة من دالة كمعامل في الاستدعاء المؤجل.

Golang تم تحريره بواسطة www.golangdev.cn