المصفوفات (Arrays) والمصفوفات المرنة (Slices)

المصفوفات (Arrays) والمصفوفات المرنة (Slices)

يمكنك العثور على جميع الشفرات المصدرية لهذا الفصل هنا

تسمح لك المصفوفات بتخزين عناصر متعددة من نفس النوع في متغير بترتيب معين.

عندما يكون لديك مصفوفة، فمن الشائع جدًا أن تقوم بالتكرار عليها. لذلك دعونا نستخدم معرفتنا الجديدة بـ for لإنشاء دالة Sum. “مجموع” والتي ستقوم باخذ مجموعة من الأرقام وأرجع المجموع.

لنستخدم مهاراتنا في TDD

لنكتب الاختبار أولاً

أنشئ مجلدًا جديدًا للعمل فيه. أنشئ ملفًا جديدًا يسمى sum_test.go واكتب ما يلي:

sum_test.go
package main

import "testing"

func TestSum(t *testing.T) {

	numbers := [5]int{1, 2, 3, 4, 5}

	got := Sum(numbers)
	want := 15

	if got != want {
		t.Errorf("got %d want %d given, %v", got, want, numbers)
	}
}

المصفوفات لها سعة ثابتة تحددها عندما تعلن عن المتغير. يمكننا انشاء المصفوفة بطريقتين:

  • [السعة]النوع{القيمة1, القيمة2, …, القيمة} على سبيل المثال. numbers := [5]int{1, 2, 3, 4, 5}
  • […]النوع{القيمة1, القيمة2, …, القيمة} على سبيل المثال. numbers := [...]int{1, 2, 3, 4, 5}

من المفيد في بعض الأحيان أيضًا طباعة مدخلات الدالة في رسالة الخطأ. هنا، نستخدم العنصر النائب %v لطباعة التنسيق “الافتراضي” للنوع، والذي يعمل بشكل جيد مع المصفوفات.

اقرأ اكثر عن كيفية تنسيق النصوص

شغل الاختبار

إذا قمت بأنشاء المشروع باستخدام

terminal
go mod init main

فسوف يظهر لك خطأ

terminal
_testmain.go:13:2: cannot import "main"

ذلك لأنه وفقًا للممارسة الشائعة ستحتوي الحزمة الرئيسية (main) فقط على الحزم الأخرى وليس التعليمات البرمجية القابلة للاختبار للوحدة ومن ثم لن يسمح لك Go باستيراد حزمة بالاسم “main”.

لإصلاح ذلك، يمكنك إعادة تسمية الوحدة الرئيسية في ملف go.mod إلى أي اسم آخر.

بمجرد إصلاح الخطأ أعلاه، إذا قمت بتشغيل go test، فسوف يفشل المترجم مع ارجاع هذه الرسالة ./sum_test.go:10:15: undefined: Sum. يمكننا الآن متابعة كتابة الكود المراد اختباره.

اكتب الحد الأدنى من الكود حتى نتمكن من تشغيل الاختبار لنتحقق من المخرجات الفاشلة

في sum.go

sum.go
package main

func Sum(numbers [5]int) int {
	return 0
}

الاختبار سيفشل الان ويقوم بطباعة رسالة واضحة

sum_test.go:13: got 0 want 15 given, [1 2 3 4 5]

لنقم بكتابة الكود الان حتى ينجح الاختبار

sum.go
func Sum(numbers [5]int) int {
	sum := 0
	for i := 0; i < 5; i++ {
		sum += numbers[i]
	}
	return sum
}

للحصول على القيمة من مصفوفة في مكان معين داخلها، ما عليك سوى استخدام array[index]. (index هو مكان العنصر داخل المصفوفة) في هذه الحالة، نستخدم for للتكرار 5 مرات ونقوم بجلب العنصر من المصفوفة ثم نقوم باضافة كل عنصر إلىsum.

إعادة الكتابة

دعنا نقدم range للمساعدة في تحسين الكود الخاص بنا

sum.go
func Sum(numbers [5]int) int {
	sum := 0
	for _, number := range numbers {
		sum += number
	}
	return sum
}

تتيح لك range “المدى” التكرار عبر مصفوفة. في كل تكرار، تقوم range بارجاع قيمتين الاولى - الفهرس (مكان العنصر داخل المصفوفة) والثانية - القيمة المخزنة في ذلك المكان. قمنا تجاهل قيمة الفهرس باستخدام _ متغير فارغ. _ يعني اننا لا نريد استخدام القيمة التي تكون بداخلة.

المصفوفات وأنواعها

من الخصائص المثيرة للاهتمام للمصفوفات في Go أن الحجم يكون ضمن النوع. إن قمت بتمرير [4]int إلى دالة تتوقع [5]int، فلن يقبل بذلك المترجم. لانهم تختلف انواعهم، لذا فهي تمامًا مثل محاولة تمرير “سلسلة نصية” string إلى دالة تريد int.

ربما تعتقد أنه من المرهق جدًا أن يكون للمصفوفات حجم ثابت.

تحتوي Go على المصفوفات المرنة slices والتي لا تقوم بتضمين حجم المجموعة مع النوع بل ويمكنها ايضا ان يكون لها اي حجم تريد.

سيكون الاختبار التالي هو جمع عناصر مصفوفات ذات أحجام مختلفة.

اكتب الاختبار أولاً

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

mySlice := []int{1,2,3} بدلا من myArray := [3]int{1,2,3}

sum_test.go
func TestSum(t *testing.T) {

	t.Run("collection of 5 numbers", func(t *testing.T) {
		numbers := [5]int{1, 2, 3, 4, 5}

		got := Sum(numbers)
		want := 15

		if got != want {
			t.Errorf("got %d want %d given, %v", got, want, numbers)
		}
	})

	t.Run("collection of any size", func(t *testing.T) {
		numbers := []int{1, 2, 3}

		got := Sum(numbers)
		want := 6

		if got != want {
			t.Errorf("got %d want %d given, %v", got, want, numbers)
		}
	})

}

قم الان بتشغيل الاختبار

لن يسمح لك المترجم بفعل ذلك وسيقوم بطباعة الخطأ التالي

terminal
./sum_test.go:22:13: cannot use numbers (type []int) as type [5]int in argument to Sum

قم بكتابة ما يكفي حتى نرى مخرجات الاختبار الفاشل

المشكلة هنا تكمن في اننا امام خيارين

  • نقوم بتغيير واجهة برمجة التطبيقات الحالية عن طريق تغيير المدخلات إلى Sum لتكون مصفوفة مرنة بدلاً من مصفوفة عادية. عندما نفعل هذا، فمن المحتمل أن ندمر يوم شخص ما لأن اختباراتنا الاخرى لن يقوم المترجم بقبولهم! (على افتراض اننا قمنا ببرمجة برنامج كامل ومستخدم من قبل اناس اخرين)
  • او اننا نقوم بإنشاء دالة جديدة.

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

sum.go
func Sum(numbers []int) int {
	sum := 0
	for _, number := range numbers {
		sum += number
	}
	return sum
}

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

اكتب كود كافي لنجاح الاختبار

اتضح أن إصلاح مشاكل المترجم كان كل ما يتعين علينا القيام به هنا وستنجح الاختبارات الاختبارات!

إعادة الكتابة

لقد قمنا بالفعل بإعادة كتابة Sum - كل ما فعلناه هو استبدال المصفوفات بالمصفوفات المرنة، لذلك لا يلزم إجراء تغييرات إضافية. تذكر أنه يجب علينا ألا نهمل كود الاختبار الخاص بنا في مرحلة إعادة الكتابة - يمكننا تحسين اختبارات sum بشكل أكبر.

sum_test.go
func TestSum(t *testing.T) {

	t.Run("collection of 5 numbers", func(t *testing.T) {
		numbers := []int{1, 2, 3, 4, 5}

		got := Sum(numbers)
		want := 15

		if got != want {
			t.Errorf("got %d want %d given, %v", got, want, numbers)
		}
	})

	t.Run("collection of any size", func(t *testing.T) {
		numbers := []int{1, 2, 3}

		got := Sum(numbers)
		want := 6

		if got != want {
			t.Errorf("got %d want %d given, %v", got, want, numbers)
		}
	})

}

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

في حالتنا، يمكنك أن ترى أن وجود اختبارين لهذه الدالة هو غير ضروري. إذا قمت بكتابة اختبار لمصفوفة من اي حجم، فمن المرجح جدًا أنها ستعمل لمصفوفة من أي حجم اخر (في حدود المعقول).

Go تحتوي على أداة اختبار مدمجة تسمى أداة التغطية. تمكنك هذه الاداة من معرفة مدى تغطية اختباراتك لكودك من خلال اعطائك نسبة مئوية. على الرغم من أن السعي للحصول على 100% من التغطية ليس هو الهدف النهائي، إلا أن أداة التغطية يمكن أن تساعدك في تحديد المناطق التي لم تغطيها اختباراتك. إذا كنت صارمًا في TDD، فمن المرجح أن تكون لديك تقريبًا 100% من التغطية بالفعل.

قم بتجريب تشغيل اداة التغطية على الاختبارات الخاصة بك عن طريق تشغيل الأمر التالي

terminal
go test -cover

سيقوم بطباعة شيء مثل

terminal
PASS
coverage: 100.0% of statements

الان قم بحذف احد الاختبارات وقم بتشغيل اداة التغطية مرة اخرى

الان بعد ان قمت بكتابة الاختبارات والكود الخاص بك، يجب عليك ان تقوم بعمل commit للكود الخاص بك قبل ان تقوم بالمهمة القادمة.

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

على سبيل المثال

SumAll([]int{1,2}, []int{0,9}) ستقوم بأرجاع []int{3, 9}

او

SumAll([]int{1,1,1}) ستقوم بأرجاع []int{3}

لنكتب الاختبار أولاً

sum_test.go
func TestSumAll(t *testing.T) {

	got := SumAll([]int{1, 2}, []int{0, 9})
	want := []int{3, 9}

	if got != want {
		t.Errorf("got %v want %v", got, want)
	}
}

قم بتشغيل الاختبار الان

./sum_test.go:23:9: undefined: SumAll

اكتب الكود الكافي لجعل الاختبار يعمل ويرجع نتيجة الفشل

نحتاج الان لكتابة دالة SumAll وفقا لما يريده اختبارنا.

Go تمكنك من كتابة دوال تأخذ عددا متغير من المدخلات (variadic) التي يمكن أن تأخذ عددًا متغيرًا من المدخلات.

sum.go
func SumAll(numbersToSum ...[]int) []int {
	return nil
}

هذا الكود سليم لكن الاختبارات لن تعمل بعد! بسبب المترجم

./sum_test.go:26:9: invalid operation: got != want (slice can only be compared to nil)

Go لا تسمح لك باستخدام عمليات المقارنة مع المصفوفات المرنة. يمكنك كتابة دالة للتكرار على كل got و want والتحقق من قيمهم ولكن للتسهيل، يمكننا استخدام reflect.DeepEqual والتي تعتبر مفيدة لمعرفة ما إذا كان أي متغيرين متطابقين.

sum_test.go
func TestSumAll(t *testing.T) {

	got := SumAll([]int{1, 2}, []int{0, 9})
	want := []int{3, 9}

	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %v want %v", got, want)
	}
}

تأكد من انك قمت بأستدعاء import reflect في اعلى الملف لكي تتمكن من استخدام DeepEqual

يجب ان تلاحظ ان reflect.DeepEqual ليست “آمنة” - سيتم تجميع وترجمة الكود حتى لو قمت بكتابة شئ غير صحيح. لرؤية ذلك قم بتغيير الاختبار مؤقتًا إلى:

sum_test.go
func TestSumAll(t *testing.T) {

	got := SumAll([]int{1, 2}, []int{0, 9})
	want := "bob"

	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %v want %v", got, want)
	}
}

ما قمنا بفعلة هنا هو محاولة مقارنة مصفوفة مرنة مع سلسلة نصية. هذا لا يمكن ان يحدث او ان يكون منطقياً، ولكن الاختبار يترجم! لذلك، على الرغم من أن استخدام reflect.DeepEqual طريقة مريحة لمقارنة المصفوفات (وأشياء أخرى) يجب أن تكون حذرًا عند استخدامها.

في الاصدار 1.21 من Go، توجد حزمة قياسية تسمى slices، والتي تحتوي على slices.Equal والتي تقوم بعملية مقارنة بسيطة على المصفوفات المرنة، حيث لا تحتاج للقلق بشأن الامان مثل الحالة السابقة. يجب ملاحظة أن هذه الدالة تتوقع أن تكون العناصر قابلة للمقارنة. لذلك، لا يمكن تطبيق هذه الدالة على المصفوفات التي تحتوي على عناصر غير قابلة للمقارنة مثل المصفوفات ثنائية الأبعاد.

قم بأعادة الاختبار مرة اخرى وقم بتشغيله. يجب ان تحصل على نتيجة الاختبار التالية

sum_test.go:30: got [] want [3 9]

لنقم بكتابة كود كافي لجعل الاختبار ينجح

هنا نحتاج إلى التكرار على المدخلات المتغيرة، وحساب المجموع باستخدام دالتنا Sum السابقة، ثم إضافتها إلى المصفوفة التي سنعيدها

sum.go
func SumAll(numbersToSum ...[]int) []int {
	lengthOfNumbers := len(numbersToSum)
	sums := make([]int, lengthOfNumbers)

	for i, numbers := range numbersToSum {
		sums[i] = Sum(numbers)
	}

	return sums
}

اشياء جديدة كثيرة لتعلمها!

هنالك طريقة جديدة لانشاء مصفوفة. make تسمح لك بانشاء مصفوفة بسعة ابتدائية تساوي len (حجم المصفوفة المدخلة) من numbersToSum التي نحتاج العمل عليها.

بأمكاننا الحصول على عنصر داخل المصفوفة المرنة بأستخدام الفهرسة مثلما كنا نفعل مع المصفوفة العادية mySlice[N] للحصول على قيمة العنصر او تعديلة مثلا.

يجب ان ينجح الاختبار الان

اعادة الكتابة

كما ذكرنا، المصفوفات المرنة لها سعة. إذا كان لديك مصفوفة مرنة بسعة 2 وحاولت القيام بـ mySlice[10] = 1 ستحصل على خطأ في وقت التشغيل.

ايضاً يمكنك استخدام الدالة append التي تأخذ مصفوفة مرنة وقيمة جديدة، ثم تعيد مصفوفة مرنة جديدة تحتوي على جميع العناصر فيها.

sum.go
func SumAll(numbersToSum ...[]int) []int {
	var sums []int
	for _, numbers := range numbersToSum {
		sums = append(sums, Sum(numbers))
	}

	return sums
}

بهذه الطريقة يمكنك ان تضيف عناصر جديدة الى المصفوفة المرنة بدون الحاجة الى القلق بشأن السعة. بدأنا بمصفوفة فارغة sums وقمنا بإضافة نتيجة Sum.

متطلبنا القادم هو تغيير SumAll إلى SumAllTails، حيث سيقوم بحساب المجموعات للـ “tails” من كل مصفوفة. الـ tail من مجموعة هو جميع العناصر في المجموعة باستثناء العنصر الاول (الـ “head”).

لنكتب الاختبار اولا

sum_test.go
func TestSumAllTails(t *testing.T) {
	got := SumAllTails([]int{1, 2}, []int{0, 9})
	want := []int{2, 9}

	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %v want %v", got, want)
	}
}

لنقم بتشغيل الاختبار

./sum_test.go:26:9: undefined: SumAllTails

لنقم بكتابة الكود الكافي لجعل الاختبار يفشل

اعد تسمية الدالة الى SumAllTails واعد تشغيل الاختبار

sum_test.go:30: got [3 9] want [2 9]

لنكتب الكود الكافي لجعل الاختبار ينجح

sum.go
func SumAllTails(numbersToSum ...[]int) []int {
	var sums []int
	for _, numbers := range numbersToSum {
		tail := numbers[1:]
		sums = append(sums, Sum(tail))
	}

	return sums
}

المصفوفات المرنة يمكن تقطيعها! الصيغة هي slice[low:high]. إذا قمت بتجاهل القيمة على أحد الجانبين من الـ : فإنه يأخذ كل شيء إلى ذلك الجانب منه. في حالتنا، نقول “خذ من 1 إلى النهاية” باستخدام numbers[1:]. قد ترغب في قضاء بعض الوقت في كتابة اختبارات أخرى حول المصفوفات وتجربة عامل القطع لتتعرف عليه اكثر.

اعادة الكتابة

لا يوجد الكثير لإعادة كتابته هذه المرة.

ما الذي تعتقد أنه سيحدث إذا قمت بتمرير مصفوفة مرنة فارغة إلى دالتنا؟ ما هو “tail” من مصفوفة فارغة؟ ماذا يحدث عندما تطلب من Go ارجاع جميع العناصر من myEmptySlice[1:]؟

لنكتب الاختبار اولا

sum_test.go
func TestSumAllTails(t *testing.T) {

	t.Run("make the sums of some slices", func(t *testing.T) {
		got := SumAllTails([]int{1, 2}, []int{0, 9})
		want := []int{2, 9}

		if !reflect.DeepEqual(got, want) {
			t.Errorf("got %v want %v", got, want)
		}
	})

	t.Run("safely sum empty slices", func(t *testing.T) {
		got := SumAllTails([]int{}, []int{3, 4, 5})
		want := []int{0, 9}

		if !reflect.DeepEqual(got, want) {
			t.Errorf("got %v want %v", got, want)
		}
	})

}

لنجرب تشغيل الاختبار

terminal
panic: runtime error: slice bounds out of range [recovered]
    panic: runtime error: slice bounds out of range

يجب مراعاة أن الاختبار نجح في الترجمة لكن هنالك خطأ في وقت التشغيل.

لنكتب الكود الكافي لجعل الاختبار ينجح

sum.go
func SumAllTails(numbersToSum ...[]int) []int {
	var sums []int
	for _, numbers := range numbersToSum {
		if len(numbers) == 0 {
			sums = append(sums, 0)
		} else {
			tail := numbers[1:]
			sums = append(sums, Sum(tail))
		}
	}

	return sums
}

يجب ان ننتبه لاخطاء وقت التشغيل ايضاً. في هذه الحالة، عندما تكون المصفوفة فارغة، فإن numbers[1:] سيكون خارج الحدود. لذلك، يجب علينا التحقق من طول المصفوفة قبل القيام بذلك.

اعادة الكتابة

اختباراتنا تحتوي على بعض الاكواد المتكررة حول التأكيدات لذا دعونا نستخرج تلك الاكواد الى دالة, بحيث يمكننا اعادة استخدامها.

sum_test.go
func TestSumAllTails(t *testing.T) {

	checkSums := func(t testing.TB, got, want []int) {
		t.Helper()
		if !reflect.DeepEqual(got, want) {
			t.Errorf("got %v want %v", got, want)
		}
	}

	t.Run("make the sums of tails of", func(t *testing.T) {
		got := SumAllTails([]int{1, 2}, []int{0, 9})
		want := []int{2, 9}
		checkSums(t, got, want)
	})

	t.Run("safely sum empty slices", func(t *testing.T) {
		got := SumAllTails([]int{}, []int{3, 4, 5})
		want := []int{0, 9}
		checkSums(t, got, want)
	})

}

كان بالامكان كتابة داله جديدة checkSums كما نفعل عادة، ولكن في هذه الحالة، نقوم بعرض تقنية جديدة، وهي تعيين دالة لمتغير. قد يبدو غريبًا ولكنه ليس مختلفًا عن تعيين متغير لـ string أو int، الدوال في الواقع هي قيم أيضًا.

ليس جلياً هنا ولكن هذه التقنية يمكن أن تكون مفيدة عندما تريد ربط دالة بمتغيرات محلية أخرى في “النطاق الحالي” (اي كود داخل {}). كما أنها تسمح لك بتقليل مساحة واجهة برمجة التطبيقات الخاصة بك.

قيامنا بتعريف الدالة داخل الاختبار يعني انه لا يمكن استخدامها من قبل دوال اخرى في هذه الحزمة. اخفاء المتغيرات والدوال التي لا تحتاج الى ان تكون معروضة للجميع هو امر مهم عند تصميم برنامجك.

ميزة اضافية نحصل عليها هي ان هذه الطريقة تضيف قليلاً من الامان للنوع في كودنا. اذا قام مطور اخر بالخطأ اضافة اختبار جديد مع checkSums(t, got, "dave") فإن المترجم سيوقفة ويقوم بأرجاع.

terminal
$ go test
./sum_test.go:52:21: cannot use "dave" (type string) as type []int in argument to checkSums

ختامًا

قمنا بمراجعة

  • المصفوفات
  • المصفوفات المرنة
    • الطرق المتعددة لانشائها
    • كيف ان لها حجماً معيناً ولكن يمكنك انشاء مصفوفات جديدة من القديمة باستخدام append
    • كيف يمكن تقطيع او تجزئ المصفوفات
  • len لارجاع طول (حجم) المصفوفة
  • أداة التغطية وكيف يمكن استخدامها لمعرفة مدى تغطية اختباراتك
  • reflect.DeepEqual

قمنا باستخدام المصفوفات والمصفوفات المرنة مع الأعداد الصحيحة ولكن يمكن استخدامها مع أي نوع آخر أيضًا، بما في ذلك المصفوفات والمصفوفات نفسها. لذا يمكنك تعريف متغير من [][]string إذا كنت بحاجة إلى ذلك.