الأرقام الرومانية
يمكنك العثور على كل كود هذا الفصل هنا
قد تطلب بعض الشركات منك أداء تمرين الأرقام الرومانية (Roman Numeral Kata) ضمن عملية المقابلة. وسيوضح لك هذا الفصل كيف تتعامل معه مستعينًا بالتطوير الموجه بالاختبار (TDD).
سنكتب دالة تحوّل عددًا عربيًا (الأعداد من 0 إلى 9) إلى رقم روماني.
إن لم تكن قد سمعت عن الأرقام الرومانية، فهي الطريقة التي كان الرومان يكتبون بها الأعداد.
تبنيها بلصق رموز بعضها ببعض، وتلك الرموز تمثل أعدادًا.
إذًا I تعني “واحد”، وIII تعني ثلاثة.
يبدو الأمر سهلًا، لكن توجد بعض القواعد المثيرة للاهتمام. فـ V تعني خمسة، لكن IV هي 4 (وليست IIII).
وMCMLXXXIV هي 1984. يبدو ذلك معقدًا، ومن الصعب تخيّل كيف يمكننا كتابة كود يستنتج هذا من البداية.
وكما يشدد هذا الكتاب، فمن المهارات الأساسية لمطور البرمجيات أن يحاول تحديد “شرائح رأسية رفيعة” من الوظائف المفيدة ثم يواصل التكرار. ومسار عمل TDD يساعد على تسهيل التطوير التكراري.
لذا بدلًا من 1984، لنبدأ بالرقم 1.
اكتب الاختبار أولًا
func TestRomanNumerals(t *testing.T) {
got := ConvertToRoman(1)
want := "I"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}إن كنت قد وصلت إلى هنا في الكتاب، فمن المفترض أن هذا كله صار مملًا وروتينيًا بالنسبة لك. وهذا شيء جيد.
جرّب تشغيل الاختبار
./numeral_test.go:6:9: undefined: ConvertToRoman
دع المترجم يرشدك في الطريق
اكتب أصغر قدر من الكود ليعمل الاختبار وافحص مخرجاته الفاشلة
أنشئ دالتنا، لكن لا تجعل الاختبار ينجح بعد؛ وتأكد دائمًا أن الاختبار يفشل بالطريقة التي تتوقعها.
func ConvertToRoman(arabic int) string {
return ""
}من المفترض أن يعمل الآن
=== RUN TestRomanNumerals
--- FAIL: TestRomanNumerals (0.00s)
numeral_test.go:10: got '', want 'I'
FAIL
اكتب كودًا كافيًا لنجاح الاختبار
func ConvertToRoman(arabic int) string {
return "I"
}إعادة الهيكلة
لا يوجد الكثير لنعيد هيكلته بعد.
أعلم أن تثبيت النتيجة يدويًا يبدو غريبًا، لكننا في TDD نريد أن نبقى بعيدين عن “الأحمر” لأطول فترة ممكنة. قد يبدو أننا لم ننجز الكثير، لكننا عرّفنا واجهة برمجة التطبيقات (API) الخاصة بنا وحصلنا على اختبار يلتقط إحدى قواعدنا؛ حتى لو كان الكود “الحقيقي” ساذجًا جدًا.
والآن استخدم شعورك بعدم الارتياح لتكتب اختبارًا جديدًا يجبرنا على كتابة كود أقل سذاجة بقليل.
اكتب الاختبار أولًا
يمكننا استخدام الاختبارات الفرعية (subtests) لتجميع اختباراتنا بشكل أنيق.
func TestRomanNumerals(t *testing.T) {
t.Run("1 gets converted to I", func(t *testing.T) {
got := ConvertToRoman(1)
want := "I"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("2 gets converted to II", func(t *testing.T) {
got := ConvertToRoman(2)
want := "II"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
}جرّب تشغيل الاختبار
=== RUN TestRomanNumerals/2_gets_converted_to_II
--- FAIL: TestRomanNumerals/2_gets_converted_to_II (0.00s)
numeral_test.go:20: got 'I', want 'II'
لا مفاجأة كبيرة هنا.
اكتب كودًا كافيًا لنجاح الاختبار
func ConvertToRoman(arabic int) string {
if arabic == 2 {
return "II"
}
return "I"
}أجل، لا يزال يبدو أننا لا نعالج المشكلة فعلًا. لذا نحتاج إلى كتابة المزيد من الاختبارات تدفعنا إلى الأمام.
إعادة الهيكلة
لدينا بعض التكرار في اختباراتنا. وعندما تختبر شيئًا يبدو كأنه مسألة “بمدخل X نتوقع Y”، فالأرجح أنك بحاجة إلى الاختبارات القائمة على الجدول (table based tests).
func TestRomanNumerals(t *testing.T) {
cases := []struct {
Description string
Arabic int
Want string
}{
{"1 gets converted to I", 1, "I"},
{"2 gets converted to II", 2, "II"},
}
for _, test := range cases {
t.Run(test.Description, func(t *testing.T) {
got := ConvertToRoman(test.Arabic)
if got != test.Want {
t.Errorf("got %q, want %q", got, test.Want)
}
})
}
}يمكننا الآن إضافة المزيد من الحالات بسهولة دون كتابة أي كود اختبار متكرر إضافي.
لنواصل ونتجه إلى الرقم 3
اكتب الاختبار أولًا
أضف ما يلي إلى حالاتنا
{"3 gets converted to III", 3, "III"},جرّب تشغيل الاختبار
=== RUN TestRomanNumerals/3_gets_converted_to_III
--- FAIL: TestRomanNumerals/3_gets_converted_to_III (0.00s)
numeral_test.go:20: got 'I', want 'III'
اكتب كودًا كافيًا لنجاح الاختبار
func ConvertToRoman(arabic int) string {
if arabic == 3 {
return "III"
}
if arabic == 2 {
return "II"
}
return "I"
}إعادة الهيكلة
حسنًا، بدأت جمل if هذه لا تعجبني، وإن نظرت إلى الكود بما يكفي فسترى أننا نبني سلسلة من I بناءً على حجم arabic.
نحن “نعلم” أن الأعداد الأكثر تعقيدًا ستتطلب نوعًا من الحساب ودمج النصوص.
لنجرّب إعادة هيكلة واضعين هذه الأفكار في الاعتبار؛ فقد لا تكون مناسبة للحل النهائي، ولا بأس بذلك. يمكننا دائمًا التخلص من كودنا والبدء من جديد، واختباراتنا التي لدينا ترشدنا.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := 0; i < arabic; i++ {
result.WriteString("I")
}
return result.String()
}قد تتذكر strings.Builder من نقاشنا عن قياس الأداء (benchmarking)
يُستخدم Builder لبناء نص بكفاءة عبر دوال Write، ويقلل نسخ الذاكرة إلى الحد الأدنى.
عادةً لا أهتم بمثل هذه التحسينات حتى تواجهني مشكلة أداء حقيقية، لكن حجم الكود ليس أكبر بكثير من الدمج “اليدوي” على النص، لذا لا مانع من استخدام الطريقة الأسرع.
يبدو الكود أفضل في نظري، وهو يصف المجال كما نعرفه الآن.
الرومان أيضًا كانوا يطبّقون DRY…
تبدأ الأمور تصبح أكثر تعقيدًا الآن. رأى الرومان بحكمتهم أن تكرار الرموز سيصير صعب القراءة والعدّ. لذا فمن قواعد الأرقام الرومانية ألا يتكرر الرمز نفسه أكثر من 3 مرات متتالية.
وعوضًا عن ذلك تأخذ الرمز الأعلى التالي ثم “تطرح” بوضع رمز على يساره. وليست كل الرموز صالحة للطرح؛ ففقط I (1) وX (10) وC (100).
مثلًا، 5 في الأرقام الرومانية هي V. ولإنشاء 4 لا تكتب IIII، بل تكتب IV.
ولا يمكن وضع رمز الطرح إلا قبل أحد الرمزين الأعلى التاليين في “عائلته”: فـ I لا تسبق إلا V أو X (4 هي IV، و9 هي IX)، وX لا تسبق إلا L أو C (40 هي XL، و90 هي XC)، وC لا تسبق إلا D أو M (400 هي CD، و900 هي CM). لذا فرغم أن I وX وC كلها رموز طرح صالحة، لا يمكنك مزجها كيفما شئت؛ فـ 99 ليست IC (بل XCIX، أي 90 + 9) و499 ليست ID (بل CDXCIX، أي 400 + 90 + 9).
اكتب الاختبار أولًا
{"4 gets converted to IV (can't repeat more than 3 times)", 4, "IV"},جرّب تشغيل الاختبار
=== RUN TestRomanNumerals/4_gets_converted_to_IV_(cant_repeat_more_than_3_times)
--- FAIL: TestRomanNumerals/4_gets_converted_to_IV_(cant_repeat_more_than_3_times) (0.00s)
numeral_test.go:24: got 'IIII', want 'IV'
اكتب كودًا كافيًا لنجاح الاختبار
func ConvertToRoman(arabic int) string {
if arabic == 4 {
return "IV"
}
var result strings.Builder
for i := 0; i < arabic; i++ {
result.WriteString("I")
}
return result.String()
}إعادة الهيكلة
لا “يعجبني” أننا كسرنا نمط بناء النص، وأريد الاستمرار به.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := arabic; i > 0; i-- {
if i == 4 {
result.WriteString("IV")
break
}
result.WriteString("I")
}
return result.String()
}لكي “يناسب” الرقم 4 تفكيري الحالي، صرت أعدّ تنازليًا من العدد العربي، مضيفًا رموزًا إلى نصنا مع تقدمنا. لست متأكدًا أن هذا سينجح على المدى الطويل، لكن لنرَ!
لنجعل الرقم 5 يعمل
اكتب الاختبار أولًا
{"5 gets converted to V", 5, "V"},جرّب تشغيل الاختبار
=== RUN TestRomanNumerals/5_gets_converted_to_V
--- FAIL: TestRomanNumerals/5_gets_converted_to_V (0.00s)
numeral_test.go:25: got 'IIV', want 'V'
اكتب كودًا كافيًا لنجاح الاختبار
انسخ فقط المقاربة نفسها التي اتبعناها مع 4
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := arabic; i > 0; i-- {
if i == 5 {
result.WriteString("V")
break
}
if i == 4 {
result.WriteString("IV")
break
}
result.WriteString("I")
}
return result.String()
}إعادة الهيكلة
عادةً ما يكون التكرار في حلقات كهذه علامة على تجريد ينتظر أن يُستخرج. فقد تكون الحلقات ذات الخروج المبكر أداة فعالة لتحسين القراءة، لكنها قد تخبرك بشيء آخر أيضًا.
نحن نمر في حلقة على عددنا العربي، وإذا صادفنا رموزًا معينة نستدعي break، لكن ما نفعله حقًا هو الطرح من i بطريقة خشنة.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for arabic > 0 {
switch {
case arabic > 4:
result.WriteString("V")
arabic -= 5
case arabic > 3:
result.WriteString("IV")
arabic -= 4
default:
result.WriteString("I")
arabic--
}
}
return result.String()
}- بالنظر إلى الإشارات التي أقرأها من كودنا، وهي مدفوعة باختباراتنا لسيناريوهات أساسية جدًا، أرى أنني لبناء رقم روماني أحتاج إلى الطرح من
arabicكلما أضفت رمزًا. - لم تعد حلقة
forتعتمد علىi، وبدلًا من ذلك سنواصل بناء نصنا حتى نطرح رموزًا كافية منarabic.
أنا واثق تمامًا أن هذه المقاربة ستصلح أيضًا للأعداد 6 (VI) و7 (VII) و8 (VIII). ومع ذلك أضف الحالات إلى مجموعة اختباراتنا وتحقق (لن أدرج الكود اختصارًا، راجع GitHub للأمثلة إن لم تكن متأكدًا).
يتبع الرقم 9 القاعدة نفسها التي يتبعها 4، إذ ينبغي أن نطرح I من تمثيل العدد التالي. فالرقم 10 يُمثَّل في الأرقام الرومانية بـ X؛ لذا يجب أن يكون 9 هو IX.
اكتب الاختبار أولًا
{"9 gets converted to IX", 9, "IX"},جرّب تشغيل الاختبار
=== RUN TestRomanNumerals/9_gets_converted_to_IX
--- FAIL: TestRomanNumerals/9_gets_converted_to_IX (0.00s)
numeral_test.go:29: got 'VIV', want 'IX'
اكتب كودًا كافيًا لنجاح الاختبار
ينبغي أن نستطيع اتباع المقاربة نفسها السابقة
case arabic > 8:
result.WriteString("IX")
arabic -= 9إعادة الهيكلة
يبدو أن الكود لا يزال يخبرنا أن هناك إعادة هيكلة في مكان ما، لكنها ليست واضحة تمامًا لي، لذا لنواصل.
سأتخطى الكود هنا أيضًا، لكن أضف إلى حالات اختبارك اختبارًا للرقم 10 الذي ينبغي أن يكون X، واجعله ينجح قبل أن تواصل القراءة.
إليك بعض الاختبارات التي أضفتها لأنني واثق أن كودنا سيعمل حتى الرقم 39
{"10 gets converted to X", 10, "X"},
{"14 gets converted to XIV", 14, "XIV"},
{"18 gets converted to XVIII", 18, "XVIII"},
{"20 gets converted to XX", 20, "XX"},
{"39 gets converted to XXXIX", 39, "XXXIX"},إن كنت قد جربت البرمجة الكائنية (OO)، فستعرف أنه ينبغي النظر إلى جمل switch بقليل من الارتياب. فغالبًا ما تلتقط مفهومًا أو بيانات داخل كود أمرّي، مع أنه كان يمكن التقاطها في بنية صنف (class) بدلًا من ذلك.
ليست Go لغة كائنية صارمة، لكن هذا لا يعني أن نتجاهل دروس البرمجة الكائنية تمامًا (مهما أراد بعضهم إقناعك بذلك).
جملة switch لدينا تصف بعض الحقائق عن الأرقام الرومانية إلى جانب السلوك.
يمكننا إعادة هيكلة ذلك بفصل البيانات عن السلوك.
type RomanNumeral struct {
Value int
Symbol string
}
var allRomanNumerals = []RomanNumeral{
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}
func ConvertToRoman(arabic int) string {
var result strings.Builder
for _, numeral := range allRomanNumerals {
for arabic >= numeral.Value {
result.WriteString(numeral.Symbol)
arabic -= numeral.Value
}
}
return result.String()
}هذا أفضل بكثير. لقد أعلنا بعض القواعد حول الأرقام كبيانات بدل إخفائها في خوارزمية، ويمكننا أن نرى كيف نمر على العدد العربي محاولين إضافة الرموز إلى نتيجتنا إن كانت تناسب.
هل يصلح هذا التجريد للأعداد الأكبر؟ وسّع مجموعة الاختبارات لتعمل مع الرقم الروماني 50 وهو L.
إليك بعض حالات الاختبار، جرّب أن تجعلها تنجح.
{"40 gets converted to XL", 40, "XL"},
{"47 gets converted to XLVII", 47, "XLVII"},
{"49 gets converted to XLIX", 49, "XLIX"},
{"50 gets converted to L", 50, "L"},هل تحتاج مساعدة؟ يمكنك رؤية الرموز التي ينبغي إضافتها في هذا الـ gist.
وما بقي!
إليك الرموز المتبقية
| عربي | روماني |
|---|---|
| 100 | C |
| 500 | D |
| 1000 | M |
اتبع المقاربة نفسها مع الرموز المتبقية؛ فالأمر مجرد إضافة بيانات إلى الاختبارات وإلى مصفوفة الرموز لدينا.
هل يعمل كودك مع الرقم 1984 أي MCMLXXXIV؟
إليك مجموعة اختباراتي النهائية
func TestRomanNumerals(t *testing.T) {
cases := []struct {
Arabic int
Roman string
}{
{Arabic: 1, Roman: "I"},
{Arabic: 2, Roman: "II"},
{Arabic: 3, Roman: "III"},
{Arabic: 4, Roman: "IV"},
{Arabic: 5, Roman: "V"},
{Arabic: 6, Roman: "VI"},
{Arabic: 7, Roman: "VII"},
{Arabic: 8, Roman: "VIII"},
{Arabic: 9, Roman: "IX"},
{Arabic: 10, Roman: "X"},
{Arabic: 14, Roman: "XIV"},
{Arabic: 18, Roman: "XVIII"},
{Arabic: 20, Roman: "XX"},
{Arabic: 39, Roman: "XXXIX"},
{Arabic: 40, Roman: "XL"},
{Arabic: 47, Roman: "XLVII"},
{Arabic: 49, Roman: "XLIX"},
{Arabic: 50, Roman: "L"},
{Arabic: 100, Roman: "C"},
{Arabic: 90, Roman: "XC"},
{Arabic: 400, Roman: "CD"},
{Arabic: 500, Roman: "D"},
{Arabic: 900, Roman: "CM"},
{Arabic: 1000, Roman: "M"},
{Arabic: 1984, Roman: "MCMLXXXIV"},
{Arabic: 3999, Roman: "MMMCMXCIX"},
{Arabic: 2014, Roman: "MMXIV"},
{Arabic: 1006, Roman: "MVI"},
{Arabic: 798, Roman: "DCCXCVIII"},
}
for _, test := range cases {
t.Run(fmt.Sprintf("%d gets converted to %q", test.Arabic, test.Roman), func(t *testing.T) {
got := ConvertToRoman(test.Arabic)
if got != test.Roman {
t.Errorf("got %q, want %q", got, test.Roman)
}
})
}
}- أزلت
descriptionلأنني شعرت أن البيانات تصف ما يكفي من المعلومات. - أضفت بعض الحالات الحدّية الأخرى التي وجدتها لأمنح نفسي ثقة أكبر قليلًا. ومع الاختبارات القائمة على الجدول، هذا رخيص جدًا.
لم أغيّر الخوارزمية، وكل ما احتجت إليه هو تحديث مصفوفة allRomanNumerals.
var allRomanNumerals = []RomanNumeral{
{1000, "M"},
{900, "CM"},
{500, "D"},
{400, "CD"},
{100, "C"},
{90, "XC"},
{50, "L"},
{40, "XL"},
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}تحليل الأرقام الرومانية
لم ننتهِ بعد. سنكتب الآن دالة تحوّل من رقم روماني إلى int
اكتب الاختبار أولًا
يمكننا إعادة استخدام حالات اختبارنا هنا بقليل من إعادة الهيكلة
انقل المتغير cases خارج الاختبار ليكون متغير حزمة في كتلة var.
func TestConvertingToArabic(t *testing.T) {
for _, test := range cases[:1] {
t.Run(fmt.Sprintf("%q gets converted to %d", test.Roman, test.Arabic), func(t *testing.T) {
got := ConvertToArabic(test.Roman)
if got != test.Arabic {
t.Errorf("got %d, want %d", got, test.Arabic)
}
})
}
}لاحظ أنني أستخدم خاصية الشريحة (slice) لتشغيل اختبار واحد فقط الآن (cases[:1])، لأن محاولة إنجاح كل تلك الاختبارات دفعة واحدة قفزة كبيرة جدًا.
جرّب تشغيل الاختبار
./numeral_test.go:60:11: undefined: ConvertToArabic
اكتب أصغر قدر من الكود ليعمل الاختبار وافحص مخرجاته الفاشلة
أضف تعريف دالتنا الجديدة
func ConvertToArabic(roman string) int {
return 0
}من المفترض أن يعمل الاختبار الآن ويفشل
--- FAIL: TestConvertingToArabic (0.00s)
--- FAIL: TestConvertingToArabic/'I'_gets_converted_to_1 (0.00s)
numeral_test.go:62: got 0, want 1
اكتب كودًا كافيًا لنجاح الاختبار
أنت تعرف ما عليك فعله
func ConvertToArabic(roman string) int {
return 1
}الآن غيّر فهرس الشريحة في اختبارنا للانتقال إلى حالة الاختبار التالية (مثل cases[:2]). وأنجحه بنفسك بأغبى كود يمكنك التفكير فيه، وواصل كتابة الكود الأغبى (أفضل كتاب على الإطلاق، أليس كذلك؟) مع الحالة الثالثة أيضًا. إليك كودي الأغبى.
func ConvertToArabic(roman string) int {
if roman == "III" {
return 3
}
if roman == "II" {
return 2
}
return 1
}من خلال سذاجة كود حقيقي يعمل نبدأ في رؤية نمط كما حدث سابقًا. نحتاج إلى المرور على المدخل وبناء شيء، وهو في هذه الحالة مجموع.
func ConvertToArabic(roman string) int {
total := 0
for range roman {
total++
}
return total
}اكتب الاختبار أولًا
ننتقل بعد ذلك إلى cases[:4] (IV) الذي يفشل الآن لأنه يعيد 2، وهو طول النص.
اكتب كودًا كافيًا لنجاح الاختبار
// earlier..
var allRomanNumerals = RomanNumerals{
{1000, "M"},
{900, "CM"},
{500, "D"},
{400, "CD"},
{100, "C"},
{90, "XC"},
{50, "L"},
{40, "XL"},
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}
// later..
func ConvertToArabic(roman string) int {
var arabic = 0
for _, numeral := range allRomanNumerals {
for strings.HasPrefix(roman, numeral.Symbol) {
arabic += numeral.Value
roman = strings.TrimPrefix(roman, numeral.Symbol)
}
}
return arabic
}إنها أساسًا خوارزمية ConvertToRoman(int) منفَّذة بالاتجاه المعاكس. هنا نمر في حلقة على نص الرقم الروماني المعطى:
- نبحث عن رموز الأرقام الرومانية المأخوذة من
allRomanNumerals، من الأعلى إلى الأدنى، في بداية النص. - وإذا وجدنا البادئة، نضيف قيمتها إلى
arabicونقتطع البادئة.
وفي النهاية نعيد المجموع كعدد عربي.
تتحقق HasPrefix(s, prefix) مما إذا كان النص s يبدأ بـ prefix، وتزيل TrimPrefix(s, prefix) البادئة prefix من s، حتى نواصل مع بقية رموز الأرقام الرومانية. وهي تعمل مع IV ومع كل حالات الاختبار الأخرى.
يمكنك تنفيذ ذلك كدالة عودية (recursive)، وهي أكثر أناقة في رأيي لكنها قد تكون أبطأ. سأترك الأمر لك ولاختبارات من نوع Benchmark....
الآن وقد أصبحت لدينا دوال تحوّل عددًا عربيًا إلى رقم روماني والعكس، يمكننا أن نأخذ اختباراتنا خطوة أبعد:
مقدمة إلى الاختبارات القائمة على الخصائص (Property Based Tests)
كانت هناك بعض القواعد في مجال الأرقام الرومانية عملنا بها في هذا الفصل:
- لا يمكن أن يكون هناك أكثر من 3 رموز متتالية
- فقط I (1) وX (10) وC (100) يمكن أن تكون “رموز طرح”
- أخذ نتيجة
ConvertToRoman(N)وتمريرها إلىConvertToArabicينبغي أن يعيد لناN
يمكن وصف الاختبارات التي كتبناها حتى الآن بأنها اختبارات قائمة على “الأمثلة”، حيث نقدم أمثلة لتتحقق منها الأدوات.
ماذا لو استطعنا أخذ هذه القواعد التي نعرفها عن مجالنا واختبارها بطريقة ما على كودنا؟
تساعدك الاختبارات القائمة على الخصائص على فعل ذلك بإلقاء بيانات عشوائية على كودك والتحقق من أن القواعد التي تصفها تظل صحيحة دائمًا. يظن كثيرون أن هذه الاختبارات تتعلق أساسًا بالبيانات العشوائية، لكنهم مخطئون. فالتحدي الحقيقي فيها هو امتلاك فهم جيد لمجالك حتى تستطيع كتابة هذه الخصائص.
كفانا كلامًا، لنرَ بعض الكود
⚠️ مستخدمو Linux: من فضلكم لا تشغّلوا الاختبار التالي فورًا. فالأرجح أن يجمّد نظامكم (ما يستلزم إعادة تشغيل قسرية).
اضغط هنا لمعرفة السبب (شرح تقني)
تولّد حزمة
testing/quickأعدادًا صحيحة عشوائية حتى الحد الأقصى لـint64. ويحاول تنفيذنا الحالي الساذج بناء نص بهذا الطول في الذاكرة (كوادريليونات من المحارف).وبينما تتعامل macOS وWindows مع ذلك بلطف غالبًا (تبقى الواجهة مستجيبة)، تواجه نوى Linux عادةً “swap thrashing”، ما يجمّد النظام بأكمله قبل أن يتسنى إنهاء العملية.
func TestPropertiesOfConversion(t *testing.T) {
assertion := func(arabic int) bool {
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}
if err := quick.Check(assertion, nil); err != nil {
t.Error("failed checks", err)
}
}منطق الخاصية
سيتحقق اختبارنا الأول من أننا إذا حوّلنا عددًا إلى روماني، ثم استخدمنا دالتنا الأخرى لإعادته إلى عدد، فسنحصل على ما كان لدينا في الأصل.
- يُعطى عدد عشوائي (مثل
4). - نستدعي
ConvertToRomanبالعدد العشوائي (ينبغي أن يعيدIVإذا كان4). - نأخذ النتيجة أعلاه ونمررها إلى
ConvertToArabic. - وينبغي أن يعطينا ذلك مدخلنا الأصلي (
4).
يبدو هذا اختبارًا جيدًا يمنحنا الثقة، لأنه ينبغي أن يفشل إذا كان في أي من الدالتين خطأ. والطريقة الوحيدة لنجاحه هي أن يكون فيهما الخطأ نفسه؛ وهذا ليس مستحيلًا لكنه يبدو غير مرجّح.
شرح تقني
نستخدم حزمة testing/quick من المكتبة القياسية
نقرأ من الأسفل: نمرر إلى quick.Check دالة سيشغّلها على عدد من المدخلات العشوائية، وإذا أعادت الدالة false فسيُعتبر ذلك فشلًا في التحقق.
دالتنا assertion أعلاه تأخذ عددًا عشوائيًا وتشغّل دوالنا لاختبار الخاصية.
شغّل اختبارنا
جرّب تشغيله؛ قد يتجمد حاسوبك لبعض الوقت، فاقتله عندما تمل :)
ما الذي يحدث؟ جرّب إضافة ما يلي إلى كود التحقق.
assertion := func(arabic int) bool {
if arabic < 0 || arabic > 3999 {
log.Println(arabic)
return true
}
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}ينبغي أن ترى شيئًا كهذا:
=== RUN TestPropertiesOfConversion
2019/07/09 14:41:27 6849766357708982977
2019/07/09 14:41:27 -7028152357875163913
2019/07/09 14:41:27 -6752532134903680693
2019/07/09 14:41:27 4051793897228170080
2019/07/09 14:41:27 -1111868396280600429
2019/07/09 14:41:27 8851967058300421387
2019/07/09 14:41:27 562755830018219185
مجرد تشغيل هذه الخاصية البسيطة جدًا كشف عيبًا في تنفيذنا. فقد استخدمنا int كمدخل، لكن:
- لا يمكنك تمثيل الأعداد السالبة بالأرقام الرومانية
- وبحسب قاعدة 3 رموز متتالية كحد أقصى، لا يمكننا تمثيل قيمة أكبر من 3999 (حسنًا، نوعًا ما) وللـ
intقيمة قصوى أعلى بكثير من 3999.
هذا رائع! لقد أُجبرنا على التفكير بعمق أكبر في مجالنا، وهذه قوة حقيقية للاختبارات القائمة على الخصائص.
من الواضح أن int ليس نوعًا مناسبًا. ماذا لو جرّبنا شيئًا أكثر ملاءمة بقليل؟
uint16
تمتلك Go أنواعًا للأعداد الصحيحة غير الموقّعة (unsigned integers)، أي أنها لا يمكن أن تكون سالبة؛ وهذا يستبعد فورًا فئة كاملة من الأخطاء في كودنا. وإضافة الرقم 16 تعني أنه عدد صحيح بطول 16 بت يمكنه تخزين قيمة قصوى مقدارها 65535، وهي لا تزال كبيرة جدًا لكنها تقربنا مما نحتاجه.
جرّب تحديث الكود ليستخدم uint16 بدل int. لقد حدّثت assertion في الاختبار لإعطاء وضوح أكبر قليلًا.
لاحظ أنك تحتاج أيضًا إلى تغيير المتغير
arabicإلىuint16في كودك (سيخبرك الاختبار بذلك). وقد يتطلب جهدًا أكبر الخطأ الذي ستحصل عليه في السطرarabic += numeral.Value. تحصل على هذا الخطأ لأننا عرّفناarabicفيConvertToArabicبـvar arabic = 0. هذا التعريف صحيح، لكن يُفترض أن نتعامل مع0كقيمة من نوعint. سيفشل الأمر إذا حاولت جمع قيمةintوقيمةuint16. ولأن Go لغة ذات أنواع، فلن ينجح ذلك. لذلك لا تنسَ تغييرvar arabic = 0إلىvar arabic uint16 = 0لتجعل المتغير arabic من نوعuint16.
assertion := func(arabic uint16) bool {
t.Log("testing", arabic)
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}لاحظ أننا الآن نسجّل المدخل باستخدام الدالة (method) log من إطار الاختبار. تأكد من تشغيل أمر go test مع العلم -v لطباعة المخرجات الإضافية (go test -v).
إذا شغّلت الاختبار، فسيُشغَّل فعلًا الآن، ويمكنك رؤية ما يجري اختباره. هذا يمنحني ثقة كبيرة بأن كودنا يعمل كما نريد.
لكن لا تزال هناك فجوة خفية: فـ uint16 يصل إلى 65535، والقيم فوق 3999 ليست أرقامًا رومانية صالحة. وستمرر قيمًا في هذا النطاق — وعندما لا تتعامل دوالنا معها، تنتج الرحلة ذهابًا وإيابًا نتيجة خاطئة بصمت لا نلتقطها. يمكنك الاحتياط باستخدام if arabic > 3999 { return true }، لكن ذلك لا يعدو أن يكون تغطيةً للمشكلة: فتلك التكرارات تصبح بلا فائدة.
المقاربة الأفضل هي أن تطلب من quick.Check توليد القيم التي نهتم بها فعلًا فقط. ويمكننا فعل ذلك عبر الحقل Values في quick.Config:
if err := quick.Check(assertion, &quick.Config{
MaxCount: 1000,
Values: func(args []reflect.Value, r *rand.Rand) {
args[0] = reflect.ValueOf(uint16(r.Intn(4000)))
},
}); err != nil {
t.Error("failed checks", err)
}Values دالة تستقبل شريحة قيم الوسائط التي سيمررها quick.Check إلى دالة التحقق لديك، ومصدرًا عشوائيًا. نملأ args[0] بقيمة uint16 مسحوبة من [0, 3999]. والآن كل تشغيل من تشغيلاتنا الألف يختبر التحويل فعلًا — بلا تكرارات مهدرة.
عمل إضافي
- هل يمكنك كتابة اختبارات خصائص تتحقق من الخصائص الأخرى التي وصفناها؟
- هل يمكنك التفكير في طريقة تجعل من المستحيل على أي شخص استدعاء كودنا بعدد أكبر من 3999؟
- يمكنك إرجاع خطأ
- أو إنشاء نوع جديد لا يستطيع تمثيل قيمة أكبر من 3999
- ما رأيك في الأفضل؟
الخلاصة
مزيد من التدريب على TDD مع التطوير التكراري
هل شعرت في البداية أن فكرة كتابة كود يحوّل 1984 إلى MCMLXXXIV مخيفة؟ لقد شعرت بذلك أنا أيضًا، مع أنني أكتب البرمجيات منذ زمن طويل.
الحيلة، كما دائمًا، هي أن تبدأ بشيء بسيط وأن تسير بخطوات صغيرة.
لم نقم في أي مرحلة من هذه العملية بقفزات كبيرة، أو إعادة هيكلة ضخمة، أو نقع في فوضى.
أكاد أسمع أحدهم يقول بسخرية “هذا مجرد تمرين”. لا أستطيع أن أخالفه، لكنني مع ذلك أتبع المقاربة نفسها في كل مشروع أعمل عليه. فأنا لا أطلق نظامًا موزعًا كبيرًا في خطوتي الأولى أبدًا، بل أجد أبسط شيء يمكن للفريق إطلاقه (عادةً موقع “Hello world”) ثم أكرر على أجزاء صغيرة من الوظائف في قطع يمكن التعامل معها، تمامًا كما فعلنا هنا.
المهارة هي معرفة كيف تقسّم العمل، وهذا يأتي مع التدريب ومع بعض الـ TDD الجميل يعينك في طريقك.
الاختبارات القائمة على الخصائص (Property Based Tests)
- مدمجة في المكتبة القياسية
- إن استطعت التفكير في طرق لوصف قواعد مجالك في كود، فهي أداة ممتازة تمنحك ثقة أكبر
- تدفعك إلى التفكير بعمق في مجالك
- قد تكون إضافة جميلة إلى مجموعة اختباراتك
ملاحظة ختامية
يعتمد هذا الكتاب على ملاحظات قيّمة من المجتمع. وDave عون كبير في كل فصل تقريبًا. لكنه انتقد بشدة استخدامي لعبارة “الأعداد العربية” في هذا الفصل، ومن باب الشفافية الكاملة، إليك ما قاله.
سأكتب فقط سبب أن قيمة من نوع
intليست في الحقيقة “عددًا عربيًا”. قد أكون مبالغًا في الدقة هنا، لذا سأتفهم تمامًا إن طلبت مني أن أغرب عن وجهك.إن الرقم المفرد (digit) محرف يُستخدم في تمثيل الأعداد — وكلمة digit أتت من اللاتينية بمعنى “إصبع”، لأن لدينا عادةً عشرة منها. وفي النظام العربي (المسمى أيضًا الهندي-العربي) للأعداد هناك عشرة منها. وهذه الأرقام العربية المفردة هي:
0 1 2 3 4 5 6 7 8 9أما العدد المكتوب بالأرقام (numeral) فهو تمثيل عدد باستخدام مجموعة من الأرقام المفردة. والعدد العربي عدد مُمثَّل بأرقام عربية في نظام عدّ موضعي أساسه 10. نقول “موضعي” لأن لكل رقم مفرد قيمة مختلفة بحسب موضعه في العدد المكتوب. لذا
1337للرقم
1قيمة ألف لأنه الرقم المفرد الأول في عدد مكوّن من أربعة أرقام.والأرقام الرومانية تُبنى باستخدام عدد محدود من الرموز المفردة (
IوVوما إلى ذلك) بوصفها قيمًا لإنتاج العدد المكتوب أساسًا. وهناك بعض الأمور الموضعية، لكن الغالب أنIتمثل دائمًا “واحد”.إذًا، في ضوء ذلك، هل
int“عدد عربي”؟ إن فكرة العدد ليست مرتبطة بتمثيله على الإطلاق — ونرى ذلك إذا سألنا أنفسنا ما التمثيل الصحيح لهذا العدد:255 11111111 two-hundred and fifty-five FF 377نعم، هذا سؤال فخ. فكلها صحيحة. إنها تمثيل العدد نفسه في الأنظمة العشرية والثنائية والإنجليزية والست عشرية والثمانية على التوالي.
إن تمثيل عدد ما كعدد مكتوب بالأرقام مستقل عن خصائصه كعدد — ونرى ذلك عندما ننظر إلى قيم الأعداد الصحيحة الحرفية في Go:
0xFF == 255 // trueوكيف يمكننا طباعة الأعداد الصحيحة في نص منسّق:
n := 255 fmt.Printf("%b %c %d %o %q %x %X %U", n, n, n, n, n, n, n, n) // 11111111 ÿ 255 377 'ÿ' ff FF U+00FFيمكننا كتابة العدد الصحيح نفسه كعدد ست عشري وكعدد عربي (عشري).
لذا عندما يبدو توقيع الدالة هكذا
ConvertToRoman(arabic int) stringفهو يضع بعض الافتراضات حول طريقة استدعائها. لأنarabicسيُكتب أحيانًا كقيمة عدد صحيح عشري حرفيةConvertToRoman(255)لكنه قد يُكتب أيضًا
ConvertToRoman(0xFF)في الحقيقة، نحن لا “نحوّل” من عدد عربي على الإطلاق، بل “نطبع” — نمثّل — قيمة
intكرقم روماني — والـintليست أعدادًا مكتوبة بالأرقام، عربية كانت أو غيرها؛ إنها مجرد أعداد. ودالةConvertToRomanأشبه بـstrconv.Itoaمن حيث أنها تحوّلintإلىstring.لكن كل النسخ الأخرى من التمرين لا تهتم بهذا التمييز لذا :shrug: