Skip to content

المكررات

في Go، الكلمة المفتاحية المستخدمة للتكرار على هياكل بيانات محددة هي for range، وقد تم تقديم بعض تطبيقاتها في الفصول السابقة، وهي تعمل فقط على هياكل البيانات المدمجة القليلة في اللغة:

  • المصفوفات
  • الشرائح
  • السلاسل النصية
  • الخرائط (map)
  • القنوات (chan)
  • القيم الصحيحة

بهذه الطريقة، الاستخدام غير مرن جداً ولا يوجد قابلية للتوسع، ودعم الأنواع المخصصة شبه معدوم. لكن لحسن الحظ، بعد تحديث الإصدار go1.23، أصبحت الكلمة المفتاحية for range تدعم range over func، وبذلك أصبح من الممكن إنشاء مكررات مخصصة.

التعرف

دعنا نتعرف على المكررات من خلال مثال. لا أدري إن كنتم تتذكرون مثال إغلاق حساب متتالية فيبوناتشي الذي تم شرحه في قسم الدوال، تنفيذه كالتالي:

go
func Fibonacci(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
  }
}

يمكننا تحويله إلى مكرر، كما يلي، حيث نلاحظ أن الكود قد قل قليلاً:

go
func Fibonacci(n int) func(yield func(int) bool) {
  a, b, c := 0, 1, 1
  return func(yield func(int) bool) {
    for range n {
      if !yield(a) {
        return
      }
      a, b = b, c
      c = a + b
    }
  }
}

مكررات Go من نوع range over func، يمكننا استخدام الكلمة المفتاحية for range مباشرة، والاستخدام أسهل من ذي قبل:

go
func main() {
    n := 8
  for f := range Fibonacci(n) {
    fmt.Println(f)
  }
}

الإخراج:

0
1
1
2
3
5
8
13

كما هو موضح أعلاه، المكرر هو دالة إغلاق، يقبل دالة رد نداء كمعامل، يمكنك حتى رؤية كلمات مثل yield بداخله، وقد كتبها من يكتبون python من قبل وهم على دراية بها، وهي مشابهة جداً للمولدات في python. مكررات Go لا تضيف أي كلمات مفتاحية أو ميزات نحوية جديدة، في المثال أعلاه yield هو مجرد دالة رد نداء، وليس كلمة مفتاحية، المسؤولون اختاروا هذا الاسم لتسهيل الفهم.

المكررات الدافعة

حول تعريف المكررات، يمكننا العثور على التفسير التالي في مكتبة iter:

An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.

المكرر هو دالة تمرر عناصر متتالية من تسلسل إلى دالة رد نداء، تسمى عادةً yield.

يمكننا أن نحدد بوضوح من ذلك أن المكرر هو دالة، يقبل دالة رد نداء كمعامل، وأثناء عملية التكرار يمرر عناصر التسلسل واحداً تلو الآخر إلى دالة رد النداء yield. في المثال السابق استخدمنا المكرر بالطريقة التالية:

go
for f := range Fibonacci(n) {
    fmt.Println(f)
}

حسب التعريف الرسمي، استخدام المكرر Backward في المثال أعلاه يكافئ الكود التالي:

go
Fibonacci(n)(func(f int) bool {
    fmt.Println(f)
    return true
})

جسم الحلقة هو دالة رد النداء yield في المكرر، عندما تُرجع الدالة true يستمر المكرر في التكرار، وإلا يتوقف.

بالإضافة إلى ذلك، عرّفت مكتبة iter القياسية أيضاً نوع المكرر iter.Seq، ونوعه هو دالة:

go
type Seq[V any] func(yield func(V) bool)

type Seq2[K, V any] func(yield func(K, V) bool)

دالة رد النداء لـ iter.Seq تقبل معاملاً واحداً فقط، لذلك في التكرار يكون لـ for range قيمة إرجاع واحدة فقط، كالتالي:

go
for v := range iter {
  // body
}

دالة رد النداء لـ iter.Seq2 تقبل معاملين، لذلك في التكرار يكون لـ for range قيمتا إرجاع، كالتالي:

go
for k, v := range iter {
  // body
}

رغم أن المكتبة القياسية لم تعرف Seq بمعاملات صفرية، لكن هذا مسموح به تماماً، وهو يكافئ:

go
func(yield func() bool)

الاستخدام كالتالي:

go
for range iter {
  // body
}

عدد معاملات دالة رد النداء يمكن أن يكون من 0 إلى 2 فقط، أكثر من ذلك لن يمرر التجميع.

باختصار، جسم الحلقة في for range هو دالة رد النداء yield في المكرر، عدد القيم التي يُرجعها for range يحدد عدد معاملات دالة yield، في كل جولة تكرار، يستدعي المكرر دالة yield، أي ينفذ الكود في جسم الحلقة، ويمرر عناصر التسلسل إلى دالة yield بشكل نشط، هذا النوع من المكررات التي تمرر العناصر بشكل نشط نسميها المكررات الدافعة (pushing iterator)، ومن الأمثلة النموذجية foreach في لغات أخرى مثل js:

javascript
let arr = [1, 2, 3, 4, 5];
arr
  .filter((e) => e % 2 === 0)
  .forEach((e) => {
    console.log(e);
  });

في Go، يتمثل ذلك في أن range يُرجع العناصر المكررة:

go
for index, value := range iterator() {
  fmt.Println(index, value)
}

في بعض اللغات (مثل Java)، يُسمى أيضاً: معالجة تدفق البيانات.

بما أن الكود في جسم الحلقة يُمرر كدالة رد نداء للمكرر، ومن المحتمل أن يكون دالة إغلاق، يحتاج Go أن يجعل دالة إغلاق تتصرف مثل مقطع كود حلقة عادي عند تنفيذ الكلمات المفتاحية defer، return، break، goto وغيرها. فكروا في الحالات التالية.

مثلاً، العودة في حلقة المكرر، فكيف نتعامل مع هذا return في دالة رد النداء yield؟

go
for index, value := range iterator() {
    if value > 10 {
        return
  }
  fmt.Println(index, value)
}

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

go
iterator()(func(index int, value int) bool {
  if value > 10 {
    return false
  }
  fmt.Println(index, value)
})

ومثال آخر، استخدام defer في حلقة المكرر:

go
for index, value := range iterator() {
    defer fmt.Println(index, value)
}

لا يمكن استخدام defer مباشرة في دالة رد النداء أيضاً، لأن القيام بذلك سيؤدي إلى تنفيذ الاستدعاء المؤجل عند انتهاء دالة رد النداء:

go
iterator()(func(index int, value int) bool {
  defer fmt.Println(index, value)
})

وينطبق الشيء نفسه على الكلمات المفتاحية الأخرى مثل break، continue، goto. لحسن الحظ، Go قد عالج هذه الحالات لنا، نحتاج فقط للاستخدام، ويمكن تجاهل هذه التفاصيل مؤقتاً، إذا كنتم مهتمين يمكنكم تصفح الكود المصدري في rangefunc/rewrite.go.

المكررات الجاذبة

المكرر الدافع (pushing iterator) يتحكم في منطق التكرار، والمستخدم يحصل على العناصر بشكل سلبي. في المقابل، المكرر الجاذب (pulling iterator) يتحكم المستخدم في منطق التكرار، ويحصل على عناصر التسلسل بشكل نشط. عادةً، المكررات الجاذبة لها دوال محددة مثل next()، stop() للتحكم في بدء أو إنهاء التكرار، ويمكن أن تكون دالة إغلاق أو هيكلاً.

go
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line, err := scanner.Text(), scanner.Err()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(line)
}

كما هو موضح أعلاه، يحصل Scanner على السطر التالي من النص في الملف من خلال الدالة Text()، ويشير إلى ما إذا كان التكرار قد انتهى من خلال الدالة Scan()، وهذا نمط من أنماط المكررات الجاذبة. يستخدم Scanner هيكلاً لتسجيل الحالة، بينما في مكتبة iter المكرر الجاذب المُعرَّف يستخدم دالة إغلاق لتسجيل الحالة، يمكننا تحويل مكرر دافع قياسي إلى مكرر جاذب من خلال الدالتين iter.Pull أو iter.Pull2، الفرق بين iter.Pull و iter.Pull2 هو أن الثاني له قيمتا إرجاع، توقيعهما كالتالي:

go
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

كلاهما يقبل مكرراً كمعامل، ثم يُرجع دالتين next() و stop()، للتحكم في استمرار وإيقاف التكرار.

go
func next() (V, bool)

func stop()

يُرجع next العنصر المكرر، وقيمة منطقية تشير إلى ما إذا كانت القيمة الحالية صالحة، عند انتهاء التكرار تُرجع دالة next قيمة صفرية للعنصر و false. دالة stop تنهي عملية التكرار، عندما لا يعود المستخدم يستخدم المكرر، يجب استخدام دالة stop لإنهاء التكرار. بالمناسبة، استدعاء دالة next لنفس المكرر من عدة كوروتينات هو ممارسة خاطئة، لأنها ليست آمنة للتزامن.

دعنا نوضح بمثال، وظيفته تحويل مكرر فيبوناتشي السابق إلى مكرر جاذب، كالتالي:

go
func main() {
  n := 10
  next, stop := iter.Pull(Fibonacci(n))
  defer stop()
  for {
    fibn, ok := next()
    if !ok {
      break
    }
    fmt.Println(fibn)
  }
}

الإخراج:

0
1
1
2
3
5
8
13
21
34

بهذه الطريقة يمكننا التحكم في منطق التكرار يدوياً من خلال الدالتين next و stop. قد تعتقدون أن هذا مضيعة للوقت، إذا كنتم تريدون القيام بذلك، فلماذا لا تستخدمون نسخة دالة الإغلاق الأصلية مباشرة؟ يمكنكم التحكم في التكرار بأنفسكم، استخدام دالة الإغلاق كالتالي:

go
func main() {
  fib := Fibonacci(10)
    for {
        n, ok := fib()
        if !ok {
            break
        }
        fmt.Prinlnt(n)
    }
}

عملية التحويل: دالة إغلاق ← مكرر ← مكرر جاذب، استخدام دالة الإغلاق والمكرر الجاذب متشابه جداً، فكرتهما واحدة، لكن الأخير يعاني من تأثيرات في الأداء بسبب معالجات مختلفة. بصراحة، القيام بذلك مضيعة للوقت، وسيناريوهات تطبيقه ليست كثيرة، لكن iter.pull موجود من أجل iter.Seq، أي لتحويل المكررات الدافعة إلى مكررات جاذبة، إذا كنتم تريدون مكرراً جاذباً فقط، وقمتم بتنفيذ مكرر دافع خصيصاً للتحويل، فقبل القيام بذلك فكروا في تعقيد التنفيذ والأداء، كمثال متتالية فيبوناتشي، الدوران حول الدائرة والعودة لنقطة البداية، الميزة الوحيدة قد تكون التوافق مع مواصفات المكررات الرسمية.

معالجة الأخطاء

ماذا نفعل إذا حدث خطأ أثناء التكرار؟ يمكننا تمريره إلى دالة yield لجعل for range تُرجعه، والسماح للمستدعي بمعالجته، كمثال مكرر الأسطر التالي:

go
func ScanLines(reader io.Reader) iter.Seq2[string, error] {
  scanner := bufio.NewScanner(reader)
  return func(yield func(string, error) bool) {
    for scanner.Scan() {
      if !yield(scanner.Text(), scanner.Err()) {
        return
      }
    }
  }
}

TIP

جدير بالذكر أن المكرر ScanLines للاستخدام مرة واحدة فقط، بعد إغلاق الملف لا يمكن استخدامه مرة أخرى.

يمكن ملاحظة أن قيمة الإرجاع الثانية هي من النوع error، الاستخدام كالتالي:

go
for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

هذه المعالجة لا تختلف عن معالجة الأخطاء العادية، وينطبق الشيء نفسه على المكررات الجاذبة:

go
next, stop := iter.Pull2(ScanLines(file))
defer stop()
for {
    line, err, ok := next()
    if err != nil {
        fmt.Println(err)
        break
    } else if !ok {
        break
    }
    fmt.Println(line)
}

إذا حدث panic، استخدم recovery كالمعتاد:

go
defer func() {
    if err := recover(); err != nil {
        fmt.Println("panic:", err)
        os.Exit(1)
    }
}()

for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

وينطبق الشيء نفسه على المكررات الجاذبة، لن نوضحه هنا.

المكتبة القياسية

العديد من المكتبات القياسية تدعم المكررات، الأكثر استخداماً هما المكتبتان slices و maps، فيما يلي نقدم بعض الوظائف العملية.

slices.All

go
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]

slices.All تحول الشريحة إلى مكرر شرائح:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for i, n := range slices.All(s) {
    fmt.Println(i, n)
  }
}

الإخراج:

0 1
1 2
2 3
3 4
4 5

slices.Values

go
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]

slices.Values تحول الشريحة إلى مكرر شرائح، لكن بدون الفهرس:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for n := range slices.Values(s) {
    fmt.Println(n)
  }
}

الإخراج:

1
2
3
4
5

slices.Chunk

go
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]

دالة slices.Chunk تُرجع مكرراً يدفع للمستدعي شرائح من عناصر بحجم n:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for chunk := range slices.Chunk(s, 2) {
    fmt.Println(chunk)
  }
}

الإخراج:

[1 2]
[3 4]
[5]

slices.Collect

func Collect[E any](seq iter.Seq[E]) []E

دالة slices.Collect تجمع مكرر الشرائح في شريحة:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  s2 := slices.Collect(slices.Values(s))
  fmt.Println(s2)
}

الإخراج:

[1 2 3 4 5]

maps.Keys

go
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]

maps.Keys تُرجع مكرراً لجميع مفاتيح الخريطة، ويمكن جمعها مباشرة في شريحة مع slices.Collect:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Keys(m))
  fmt.Println(keys)
}

الإخراج:

[three one two]

بما أن الخريطة غير مرتبة، فالإخراج غير ثابت أيضاً.

maps.Values

go
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]

maps.Values تُرجع مكرراً لجميع قيم الخريطة، ويمكن جمعها مباشرة في شريحة مع slices.Collect:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Values(m))
  fmt.Println(keys)
}

الإخراج:

[3 1 2]

بما أن الخريطة غير مرتبة، فالإخراج غير ثابت أيضاً.

maps.All

func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]

maps.All يمكنها تحويل خريطة إلى مكرر خرائط:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  for k, v := range maps.All(m) {
    fmt.Println(k, v)
  }
}

عادةً لا تُستخدم مباشرة، بل تُستخدم مع دوال معالجة تدفق البيانات الأخرى.

maps.Collect

go
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]V

maps.Collect يمكنها جمع مكرر خرائط في خريطة:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  m2 := maps.Collect(maps.All(m))
  fmt.Println(m2)
}

دالة collect تُستخدم عادةً كدالة إنهاء في معالجة تدفق البيانات.

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

من خلال الدوال التي توفرها المكتبة القياسية أعلاه، يمكننا دمجها لمعالجة تدفق البيانات، مثل ترتيب تدفق البيانات:

go
sortedSlices := slices.Sorted(slices.Values(s))

مكررات Go تستخدم دوال الإغلاق، يمكن فقط تداخل استدعاءات الدوال مثل هذا، وليس لها استدعاء متسلسل ذاتي، عندما يطول سلسلة الاستدعاء تصبح القراءة أسوأ، لكن يمكننا استخدام هيكل لتسجيل المكرر، وبهذا يمكن تحقيق الاستدعاء المتسلسل.

مثال

مثال بسيط للاستدعاء المتسلسل كالتالي، يتضمن الوظائف الشائعة مثل Filter، Map، Find، Some وغيرها:

go
package iterx

import (
  "iter"
  "slices"
)

type SliceSeq[E any] struct {
  seq iter.Seq2[int, E]
}

func (s SliceSeq[E]) All() iter.Seq2[int, E] {
  return s.seq
}

func (s SliceSeq[E]) Filter(filter func(int, E) bool) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      // إعادة تنظيم الفهارس
      i := 0
      for k, v := range s.seq {
        if filter(k, v) {
          if !yield(i, v) {
            return
          }
          i++
        }
      }
    },
  }
}

func (s SliceSeq[E]) Map(mapFn func(E) E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for k, v := range s.seq {
        if !yield(k, mapFn(v)) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Fill(fill E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for i, _ := range s.seq {
        if !yield(i, fill) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Find(equal func(int, E) bool) (_ E) {
  for i, v := range s.seq {
    if equal(i, v) {
      return v
    }
  }
  return
}

func (s SliceSeq[E]) Some(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if match(i, v) {
      return true
    }
  }
  return false
}

func (s SliceSeq[E]) Every(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if !match(i, v) {
      return false
    }
  }
  return true
}

func (s SliceSeq[E]) Collect() []E {
  var res []E
  for _, v := range s.seq {
    res = append(res, v)
  }
  return res
}

func (s SliceSeq[E]) Sort(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortFunc(collect, cmp)
  return collect
}

func (s SliceSeq[E]) SortStable(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortStableFunc(collect, cmp)
  return collect
}

func Slice[S ~[]E, E any](s S) SliceSeq[E] {
  return SliceSeq[E]{seq: slices.All(s)}
}

ثم يمكننا المعالجة من خلال الاستدعاء المتسلسل، انظر بعض حالات الاستخدام.

معالجة قيم العناصر

go
func main() {
  s := []string{"apple", "banana", "cherry"}
  all := iterx.Slice(s).Map(strings.ToUpper).All()
  for i, v := range all {
    fmt.Printf("index: %d, value: %s\n", i, v)
  }
}

الإخراج:

index: 0, value: APPLE
index: 1, value: BANANA
index: 2, value: CHERRY

البحث عن قيمة محددة

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Find(func(i int, e int) bool {
    return e == 3
  })
  fmt.Println(result)
}

الإخراج:

3

ملء الشريحة

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Fill(6).Collect()
  fmt.Println(result)
}

الإخراج:

[6 6 6 6 6]

تصفية العناصر

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  filter := iterx.Slice(s).Filter(func(i int, e int) bool {
    return e%2 == 0
  }).All()
  for i, v := range filter {
    fmt.Printf("Index: %d, Value: %d\n", i, v)
  }
}

الإخراج:

Index: 0, Value: 2
Index: 1, Value: 4

للأسف، Go لا يدعم حالياً الاختصار للدوال المجهولة، مثل دوال السهم في js، rust، java، وإلا كان الاستدعاء المتسلسل يمكن أن يكون أكثر إيجازاً وأناقة.

الأداء

لأن Go قام بالعديد من المعالجات للمكررات، أداؤها بالتأكيد ليس جيداً مثل حلقة for range الأصلية، لنختبر الفرق في الأداء بينهما باستخدام أبسط اجتياز لشريحة، مقسمة إلى الحالات التالية:

  • حلقة for الأصلية
  • مكرر دافع
  • مكرر جاذب

كود الاختبار كالتالي، طول شريحة الاختبار 1000:

go
package main

import (
  "iter"
  "slices"
  "testing"
)

var s []int

const n = 10000

func init() {
  for i := range n {
    s = append(s, i)
  }
}

func testNaiveFor(s []int) {
  for i, n := range s {
    _ = i
    _ = n
  }
}

func testPushing(s []int) {
  for i, n := range slices.All(s) {
    _ = i
    _ = n
  }
}

func testPulling(s []int) {
  next, stop := iter.Pull2(slices.All(s))
  for {
    i, n, ok := next()
    if !ok {
      stop()
      return
    }
    _ = i
    _ = n
  }
}

func BenchmarkNaive_10000(b *testing.B) {
  for range b.N {
    testNaiveFor(s)
  }
}

func BenchmarkPushing_10000(b *testing.B) {
  for range b.N {
    testPushing(s)
  }
}

func BenchmarkPulling_10000(b *testing.B) {
  for range b.N {
    testPulling(s)
  }
}

نتائج الاختبار:

goos: windows
goarch: amd64
pkg: golearn
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkNaive_10000
BenchmarkNaive_10000-16           492658              2398 ns/op               0 B/op          0 allocs/op
BenchmarkPushing_10000
BenchmarkPushing_10000-16         315889              3707 ns/op               0 B/op          0 allocs/op
BenchmarkPulling_10000
BenchmarkPulling_10000-16           2016            574509 ns/op             440 B/op         14 allocs/op
PASS
ok      golearn 4.029s

من النتائج نرى أن المكرر الدافع لا يختلف كثيراً عن حلقة for range الأصلية، لكن المكرر الجاذب أبطأ بمقدار مرتبتين تقريباً من الاثنين السابقين، عند الاستخدام يمكنكم النظر في وضعكم الفعلي.

خلاصة

مثل الأنواع العامة، مكررات Go مثيرة للجدل أيضاً، يرى البعض أن المكررات أدخلت تعقيداً كبيراً، وتتعارض مع فلسفة بساطة Go، فمع كثرة كود دالات الإغلاق للمكررات، قد يكون التصحيح صعباً، والقراءة أكثر إزعاجاً.

يمكنكم رؤية مناقشات حادة حول المكررات في العديد من الأماكن:

النظرة العقلانية لمكررات Go، هي أنها بالفعل تجعل كتابة الكود أكثر راحة، خاصة عند التعامل مع أنواع الشرائح، لكنها في الوقت نفسه تُدخل بعض التعقيد، وستنخفض قراءة كود المكررات. لكن بشكل عام، أعتقد أنها ميزة عملية بالفعل.

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