500 سطر أو أقل › التحليل الساكن 21 / 23 السابق التالي

التحليل الساكن

لياه هانسون خريجة مميزة من مدرسة هاكر، وتحبّ مساعدة الناس على التعلّم عن جوليا. تكتب مدونتها على http://blog.leahhanson.us/ وتغرّد على @astrieanna.

مقدّمة

ربما تكون على معرفة بمحرّك متطوّر (IDE) فاخر يرسم خطوطًا حمراء تحت أجزاء من شيفرتك التي لا تُصرَّف. ربما شغّلت مدقّق تنسيق (linter) على شيفرتك للتحقق من مشكلات التنسيق أو الأسلوب. ربما تشغّل مُصرِّفك في وضع بالغ التدقيق مع كل التحذيرات مفعّلة. كل هذه الأدوات هي تطبيقات للتحليل الساكن (static analysis).

التحليل الساكن هو طريقة للتحقق من وجود مشكلات في شيفرتك دون تشغيلها. تعني «ساكنة» وقت التصريف (compile time) لا وقت التشغيل (run time)، وتعني «التحليل» أننا نحلّل الشيفرة. وحين استخدمت الأدوات التي ذكرتها أعلاه، ربما شعرت أنها سحر. لكن تلك الأدوات مجرد برامج—فهي مبنيّة من شيفرة مصدرية كتبها إنسان، مبرمج مثلك. في هذا الفصل، سنتحدث عن كيفية تنفيذ بعض فحوص التحليل الساكن. ولكي نفعل ذلك، نحتاج إلى أن نعرف ما نريد أن يفعله الفحص وكيف نريد أن نفّذه.

يمكن أن نكون أكثر تحديدًا فيما تحتاج إلى معرفته بوصف العملية ثلاث مراحل:

1. تحديد ما تريد أن تفحص عنه.

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

  • العثور على أسماء المتغيّرات المكتوبة خطأً
  • العثور على حالات السباق (race conditions) في الشيفرة المتوازية
  • العثور على استدعاءات دوالّ غير مُنفَّذة

2. تحديد كيف تفحص عنه بدقّة.

أثناء ما نستطيع أن نطلب من صديق تنفيذ إحدى المهام المذكورة أعلاه، فإنّها ليست محدّدة بما يكفي لتُشرح لجهاز حاسوب. فمثلًا، ولمصطلح «أسماء المتغيّرات المكتوبة خطأً»، نحتاج إلى أن نقرّر ما الذي يعنيه «مكتوب خطأً» هنا. أحد الخيارات هو أن نطالب بأن تتكوّن أسماء المتغيّرات من كلمات إنجليزية موجودة في القاموس؛ وخيار آخر هو البحث عن متغيّرات لا تُستخدم إلا مرّة واحدة (المرة التي أخطأت فيها في الكتابة).

إذا كنا نعرف أننا نبحث عن متغيّرات لا تُستخدم إلا مرّة واحدة، فيمكننا التحدث عن أنواع استخدامات المتغيّرات (إسناد قيمتها مقابل قراءتها) وعن أي شيفرة تُطلق تحذيرًا أو لا تُطلقه.

3. تفاصيل التنفيذ.

يغطّي هذا الفعل الفعلي لكتابة الشيفرة، والوقت المُقضى في قراءة توثيق المكتبات التي تستخدمها، والبحث عن كيفية الوصول إلى المعلومات التي تحتاجها لإجراء التحليل. وقد يشمل ذلك قراءة ملف شيفرة، وتحليله (parsing) لفهم بنيته، ثم إجراء فحصك المحدّد على تلك البنية.

سنمرّ عبر هذه الخطوات لكل فحص من الفحوص المنفَّذة في هذا الفصل. تتطلب الخطوة الأولى فهمًا كافيًا للغة التي نحلّلها للتعاطف مع المشكلات التي يواجهها مستخدموها. كل الشيفرة في هذا الفصل هي شيفرة جوليا، كُتبت لتحليل شيفرة جوليا.

مقدّمة سريعة جدًا إلى جوليا

جوليا لغة فتيّة تستهدف الحوسبة التقنية. أُصدرت بالإصدار 0.1 في ربيع عام 2012؛ وفي بداية عام 2015 بلغت الإصدار 0.3. عمومًا، تبدو جوليا شبيهة جدًا بايثون، لكن مع بعض شروح الأنواع الاختيارية ودون أيّ أمور موجّهة نحو الكائنات. الميزة التي سيجد معظم المبرمجين جديدة في جوليا هي التوجيه المتعدّد (multiple dispatch)، الذي له تأثير بالغ على تصميم واجهات البرمجة (API) وعلى خيارات التصميم الأخرى في اللغة.

إليك مقتطفًا من شيفرة جوليا:

# A comment about increment
function increment(x::Int64)
  return x + 1
end

increment(5)

تُعرّف هذه الشيفرة طريقة (method) للدالة increment تأخذ وسيطًا واحدًا اسمه x من النوع Int64. تُرجع الطريقة قيمة x + 1. ثم تُستدعى هذه الطريقة المُعرَّفة حديثًا بالقيمة 5؛ وسيُقيَّم استدعاء الدالة، كما خمّنت، إلى 6.

Int64 هو نوع قيمه أعداد صحيحة مُوقّعة تُمثَّل في الذاكرة بـ 64 بت؛ وهي الأعداد الصحيحة التي يفهمها عتادك إذا كان حاسوبك بمعالج 64 بت. تُعرّف الأنواع في جوليا تمثيل البيانات في الذاكرة، فضلًا عن تأثيرها في توجيه الدوال.

يشير الاسم increment إلى دالة عامة (generic function)، قد يكون لها عدّة طرق. لقد عرّفنا طريقة واحدة منها فقط. في كثرة من اللغات، تُستخدم مصطلحا «دالة» و«طريقة» بالتبادل؛ أما في جوليا فلهما معنيان مختلفان. سيصبح هذا الفصل أوضح إن خصّصت وقتك لفهم «الدالة» بوصفها مجموعةً مسمّاة من الطرق، حيث «الطريقة» هي تنفيذ محدّد لتوقيع نوع محدّد.

لنُعرّف طريقة أخرى لدالة increment:

# Increment x by y
function increment(x::Int64, y::Number)
  return x + y
end

increment(5) # => 6
increment(5,4) # => 9

أصبح للدالة increment الآن طريقتان. تقرّر جوليا أيّ طريقة تُنفَّذ لاستدعاء معيّن بناءً على عدد الوسائط وأنواعها؛ وهذا ما يُسمّى التوجيه المتعدّد الديناميكي (dynamic multiple dispatch):

  • ديناميكي لأنه مبنيّ على أنواع القيم المستخدَمة وقت التشغيل.
  • متعدّد لأنه ينظر إلى أنواع جميع الوسائط وترتيبها.
  • توجيه لأنه طريقة لمطابقة استدعاءات الدوالّ مع تعريفات الطرق.

لوضع هذا في سياق اللغات التي قد تعرفها بالفعل، تستخدم اللغات الموجّهة نحو الكائنات التوجيه الأحادي لأنها لا تأخذ في الحسبان إلّا الوسيط الأول. (في x.foo(y)، الوسيط الأول هو x.)

كلٌّ من التوجيه الأحادي والمتعدّد مبنيّ على أنواع الوسائط. أما x::Int64 أعلاه فهو شرح نوع (type annotation) صِرف لأغراض التوجيه فحسب. في نظام الأنواع الديناميكي في جوليا، يمكنك إسناد قيمة من أي نوع إلى x أثناء الدالة دون أن يقع خطأ.

لم نَرَ بعد الجزء «المتعدّد» حقًا، لكن إن كنت فضوليًا تجاه جوليا فستحتاج إلى البحث عن ذلك بنفسك. علينا أن نمضي إلى فحصنا الأول.

فحص أنواع المتغيّرات في الحلقات

كما في معظم لغات البرمجة، يتطلّب كتابة شيفرة سريعة جدًا في جوليا فهمَ كيفية عمل الحاسوب وكيفية عمل جوليا. والجزء المهم من مساعدة المُصرِّف على إنشاء شيفرة سريعة هو كتابة شيفرة مستقرّة الأنواع (type-stable)؛ وهذا مهمّ في جوليا وجافاسكريبت، وهو مفيد أيضًا في لغات JIT الأخرى. عندما يستطيع المُصرِّف أن يرى أن متغيّرًا في قسم من الشيفرة سيحتوي دائمًا على النوع المحدّد نفسه، يستطيع أن يُجري تحسينات (optimizations) أكثر مما لو اعتقد (بصواب أو بخطأ) أن هناك أنواعًا محتملة متعدّدة لذلك المتغيّر. يمكنك قراءة المزيد عن سبب أهمية استقرار الأنواع (والمعروف أيضًا بـ«الوحيدة الشكل»، أي monomorphism) في جافاسكريبت على الويب.

لماذا هذا مهمّ

لنكتب دالة تأخذ Int64 وتزيده بمقدار ما. إذا كان العدد صغيرًا (أقل من 10) فلنزيده بعدد كبير (50)، لكن إن كان كبيرًا فلنزيده بـ 0.5 فقط.

function increment(x::Int64)
  if x < 10
    x = x + 50
  else
    x = x + 0.5
  end
  return x
end

تبدو هذه الدالة بسيطة إلى حدّ كبير، لكنّ نوع x غير مستقرّ. لقد اخترت عددين: 50 وهو Int64، و0.5 وهو Float64. وبناءً على قيمة x، قد يُضاف إلى أحدهما أو الآخر. إن أضفت Int64 مثل 22 إلى Float64 مثل 0.5، ستحصل على Float64 (22.5). ولأنّ نوع المتغيّر في الدالة (x) يمكن أن يتغيّر تبعًا لقيمة وسائط الدالة (x)، فإنّ طريقة increment هذه، وعلى الأخصّ المتغيّر x، غير مستقرّة الأنواع.

Float64 هو نوع يمثّل قيم الفاصلة العائمة المخزَّنة في 64 بت؛ وفي C تُسمّى double. وهذا أحد أنواع الفاصلة العائمة التي يفهمها المعالجات ذات 64 بت.

وكما مع معظم مشكلات الكفاءة، تكون هذه المشكلة أكثر وضوحًا حين تحدث أثناء الحلقات. تُنفَّذ الشيفرة داخل حلقات for وحلقات while مرّاتٍ ومرّاتٍ، لذا فجعلها سريعة أهمّ من تسريع شيفرة لا تُنفَّذ إلّا مرّة أو مرّتين. لذلك فإنّ فحصنا الأول هو البحث عن متغيّرات ذات أنواع غير مستقرّة داخل الحلقات.

أولًا، لننظر إلى مثال لما نريد التقاطه. سننظر إلى دالتين. كلتاهما تجمع الأعداد من 1 إلى 100، لكن بدلًا من جمع الأعداد الصحيحة، تقسم كل واحد منها على 2 قبل جمعه. كلتا الدالتين ستحصلان على الإجابة نفسها (2525.0)؛ وكلتاهما ستُرجع النوع نفسه (Float64). لكن الدالة الأولى، unstable، تعاني من عدم استقرار الأنواع، بينما الثانية، stable، لا تعاني.

function unstable()
  sum = 0
  for i=1:100
    sum += i/2
  end
  return sum
end
function stable()
  sum = 0.0
  for i=1:100
    sum += i/2
  end
  return sum
end

الفرق النصّي الوحيد بين الدالتين هو في تهيئة sum: sum = 0 مقابل sum = 0.0. في جوليا، 0 قيمة حرفية من نوع Int64، و0.0 قيمة حرفية من نوع Float64. كم يمكن لهذا التغيير البسيط أن يُحدث من فرق؟

لأنّ جوليا مُصرَّفة في اللحظة (Just-In-Time، JIT)، فإنّ التشغيل الأول للدالة سيستغرق وقتًا أطول من التشغيلات التالية. (يتضمّن التشغيل الأول الوقت اللازم لتصريف الدالة لهذه الأنواع من الوسائط.) وحين نقيس أداء الدوالّ، يجب أن نتأكّد من تشغيلها مرّة واحدة (أو تصريفها مسبقًا) قبل توقيتها.

julia> unstable()
2525.0

julia> stable()
2525.0

julia> @time unstable()
elapsed time: 9.517e-6 seconds (3248 bytes allocated)
2525.0

julia> @time stable()
elapsed time: 2.285e-6 seconds (64 bytes allocated)
2525.0

تطبع ماكرو @time المدة التي استغرقتها الدالة في التنفيذ وعدد البايتات المخصَّصة أثناء تنفيذها. يزداد عدد البايتات المخصَّصة كلّما احتجنا ذاكرة جديدة؛ ولا ينخفض حين يمتصّ جامع القمامة الذاكرة التي لم تعد مستخدَمة. وهذا يعني أنّ البايتات المخصَّصة مرتبطة بمقدار الوقت الذي نقضيه في تخصيص الذاكرة وإدارتها، لكنّه لا يعني أنّنا استخدمنا كل تلك الذاكرة في الوقت نفسه.

لو أردنا أرقامًا قاطعة لـ stable مقابل unstable، لكان علينا أن نجعل الحلقة أطول بكثير أو أن نشغّل الدوالّ مرّاتٍ كثيرة. لكن يبدو أنّ unstable على الأرجح أبطأ. والأمر الأكثر إثارة للاهتمام هو أنّنا نرى فجوة كبيرة في عدد البايتات المخصَّصة؛ فقد خُصِّصت لـ unstable نحو 3 كيلوبايت من الذاكرة، بينما تستخدم stable 64 بايت.

بما أننا نرى مدى بساطة unstable، يمكننا أن نخمّن أنّ هذا التخصيص يحدث في الحلقة. ولاختبار ذلك، يمكننا أن نجعل الحلقة أطول ونرى إن كانت التخصيصات تزداد تبعًا لذلك. لنجعل الحلقة تذهب من 1 إلى 10000، أي مئة ضعف عدد التكرارات؛ وسنبحث عن أن يزداد عدد البايتات المخصَّصة نحو مئة ضعف أيضًا، أي إلى نحو 300 كيلوبايت.

function unstable()
  sum = 0
  for i=1:10000
    sum += i/2
  end
  return sum
end

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

julia> unstable()
2.50025e7

julia>@time unstable()
elapsed time: 0.000667613 seconds (320048 bytes allocated)
2.50025e7

خُصِّصت لـ unstable الجديدة نحو 320 كيلوبايت، وهو ما نتوقّعه إذا كانت التخصيصات تحدث في الحلقة. ولشرح ما يجري هنا، سننظر إلى كيفية عمل جوليا في الطبقة الداخلية من اليمين.

يحدث هذا الفرق بين unstable وstable لأنّ sum في unstable يجب أن يُصنَّف في صندوق (boxed)، بينما يمكن أن يُخرَج من الصندوق (unboxed) في stable. تتكوّن القيم المصنَّفة في صندوق من وسم نوع وبتات القيمة الفعلية؛ أمّا القيم المخرَجة من الصندوق فلا تملك إلّا بتاتها الفعلية. لكن وسم النوع صغير، فليس هذا سبب تخصيص ذاكرة أكبر بكثير عند تصنيف القيم في صناديق.

يأتي الفرق مما يمكن للمُصرِّف من إجرائه من تحسينات. عندما يكون للمتغيّر نوع ملموس غير قابل للتغيير، يمكن للمُصرِّف أن يُخرجه من الصندوق داخل الدالة. وإن لم يكن كذلك، فيجب تخصيص المتغيّر على الكومة (heap)، وأن يشارك في جامع القمامة. الأنواع غير القابلة للتغيير مفهوم خاصّ بجوليا. لا يمكن تغيير قيمة من نوع غير قابل للتغيير.

الأنواع غير القابلة للتغيير عادةً هي أنواع تمثّل قيمًا لا مجموعات قيم. فمثلًا، معظم الأنواع العددية، بما فيها Int64 وFloat64، غير قابلة للتغيير. (الأنواع العددية في جوليا أنواع عادية وليست أنواعًا أولية خاصّة؛ يمكنك أن تُعرّف MyInt64 جديدة تكون نفسها الموفَّرة.) ولأنّ الأنواع غير القابلة للتغيير لا يمكن تعديلها، يجب أن تُنشئ نسخة جديدة كلّما أردت تغيير واحدة. فمثلًا، يجب أن يُنشئ 4 + 6 قيمة Int64 جديدةً لتخزين النتيجة. وعلى النقيض، يمكن تحديث أعضاء النوع القابل للتغيير في مكانه؛ وهذا يعني أنّك لست مضطرًا إلى إنشاء نسخة من الكل لتُجري تغييرًا واحدًا.

من المحتمل أن تبدو فكرة أنّ x = x + 2 تُخصّص ذاكرة غريبة حقًا؛ فلماذا تجعل عملية بهذه البسيطة بطيئة بجعل قيم Int64 غير قابلة للتغيير؟ هنا تظهر تحسينات المُصرِّف تلك: فاستخدام الأنواع غير القابلة للتغيير لا يُبطئ هذا (عادةً). إن كان لـ x نوع ملموس مستقرّ (مثل Int64)، فالمُصرِّف حرّ في أن يخصّص x على المكدّس ويعدّل x في مكانه. والمشكلة تحدث فقط عندما يكون لـ x نوع غير مستقرّ (فلا يعرف المُصرِّف حجمه ولا نوعه)؛ ومتى صُنِّفت x في صندوق ووُضعت على الكومة، لا يكون المُصرِّف واثقًا تمامًا من أنّ قطعة شيفرة أخرى لا تستخدم القيمة، وبالتالي لا يستطيع تعديلها.

ولأنّ sum في stable له نوع ملموس (Float64)، يعرف المُصرِّف أنّه يستطيع تخزينه محليًا مخرجًا من الصندوق داخل الدالة وتعديل قيمته؛ فلن تُخصَّص sum على الكومة ولن نحتاج إلى إنشاء نسخ جديدة كلّما أضفنا i/2.

ولأنّ sum في unstable ليس له نوع ملموس، يخصّصه المُصرِّف على الكومة. وكلّما عدّلنا sum، خُصِّصت قيمة جديدة على الكومة. وكلّ هذا الوقت المُقضى في تخصيص القيم على الكومة (واسترجاعها كلّما أردنا قراءة قيمة sum) مكلف.

استخدام 0 مقابل 0.0 خطأ يسهل ارتكابه، خاصّةً حين تكون جديدًا على جوليا. والفحص التلقائي من ثبات أنواع المتغيّرات المستخدَمة في الحلقات يساعد المبرمجين على فهم أعمق لأنواع متغيّراتهم في الأقسام الحرجة للأداء من شيفرتهم.

تفاصيل التنفيذ

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

  • كيف نجد الحلقات؟
  • كيف نجد المتغيّرات في الحلقات؟
  • كيف نجد نوع متغيّر؟
  • كيف نطبع النتائج؟
  • كيف نعرف إن كان النوع غير مستقرّ؟

سأتناول السؤال الأخير أولًا، لأنّ هذه المحاولة بأكملها معلّقة عليه. لقد نظرنا إلى دالة غير مستقرّة ورأينا، كمبرمجَين، كيفية تعرّف متغيّر غير مستقرّ، لكننا بحاجة إلى أن يجد برنامجنا هذه المتغيّرات. هذا يبدو وكأنّه يتطلّب محاكاة الدالة للبحث عن متغيّرات قد تتغيّر قيمها—وهو ما يبدو وكأنّه سيستغرق جهدًا كبيرًا. لكنّ من حُسن حظّنا، فإنّ استنتاج الأنواع (type inference) في جوليا يتتبّع أصلًا تنفيذ الدالة لتحديد الأنواع.

نوع sum في unstable هو Union(Float64,Int64). وهذا UnionType، وهو نوع خاصّ يدلّ على أنّ المتغيّر قد يحمل أيًّا من مجموعة من الأنواع. يمكن لمتغيّر من نوع Union(Float64,Int64) أن يحمل قيمًا من نوع Int64 أو Float64؛ ولا يمكن أن لقيمة واحدة إلّا أن يكون لها أحد النوعين. يَجمع UnionType أيّ عدد من الأنواع (مثلًا، UnionType(Float64, Int64, Int32) يجمع ثلاثة أنواع). وما سنبحث عنه هو المتغيّرات التي من نوع UnionType داخل الحلقات.

تحليل الشيفرة إلى بنية تمثيلية عمل معقّد، ويصير أعقد مع نموّ اللغة. في هذا الفصل، سنعتمد على بنى البيانات الداخلية التي يستخدمها المُصرِّف. وهذا يعني أننا لا نضطر إلى قراءة الملفات أو تحليلها، لكنه يعني أيضًا أننا سنضطر إلى العمل مع بنى بيانات ليست تحت سيطرتنا وتبدو أحيانًا ثقيلة أو قبيحة.

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

تُسمّى هذه العملية بفحص شيفرة جوليا من داخل شيفرة جوليا بالتأمل الداخلي (introspection). عندما نُجري أنت أو أنا تأمّلًا داخليًا، فإنّنا نفكّر في كيفية تفكيرنا وإحساسنا ولماذا نفعل ذلك. وعندما يُجري الشيفرة تأمّلًا داخليًا، فإنّها تفحص تمثيل الشيفرة أو خصائص تنفيذها في اللغة نفسها (وربّما شيفرتها هي). وعندما يمتدّ التأمّل الداخلي ليشمل تعديل الشيفرة المفحوصة، فيُسمّى ذلك بالبرمجة الوصفية (metaprogramming)، أي البرامج التي تكتب أو تعدّل برامج.

التأمّل الداخلي في جوليا

تجعل جوليا التأمّل الداخلي سهلًا. هناك أربع دوالّ مدمجة تتيح لنا رؤية ما يفكّر فيه المُصرِّف: code_lowered وcode_typed وcode_llvm وcode_native. وهي مرتّبة حسب المرحلة من عملية التصريف التي يأتي مخرجاتها منها؛ فأوّلها الأقرب إلى الشيفرة التي نكتبها، وآخرها الأقرب إلى ما ينفّذه معالج CPU. وسنتركّز في هذا الفصل على code_typed، التي تعطينا شجرة الصياغة المجرّدة (abstract syntax tree، AST) المحسَّنة والمستنتَجة الأنواع.

تأخذ code_typed وسيطين: الدالة محلّ الاهتمام، وطورًا من أنواع الوسائط. فمثلًا، إن أردنا رؤية شجرة الصياغة المجرّدة لدالة foo عند استدعائها وسيطين من نوع Int64، فعلينا استدعاء code_typed(foo, (Int64,Int64)).

function foo(x,y)
  z = x + y
  return 2 * z
end

code_typed(foo,(Int64,Int64))

هذه هي البنية التي كانت code_typed ستُعيدها:

1-element Array{Any,1}:
:($(Expr(:lambda, {:x,:y}, {{:z},{{:x,Int64,0},{:y,Int64,0},{:z,Int64,18}},{}},
 :(begin  # none, line 2:
        z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64 # line 3:
        return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64
    end::Int64))))

هذا Array؛ وهذا يسمح لـ code_typed بأن تُعيد عدّة طرق مطابقة. فبعض تركيبات الدوالّ وأنواع الوسائط قد لا تحدّد تمامًا أيّ طريقة ينبغي استدعاؤها. فمثلًا، يمكنك تمرير نوع مثل Any (بدلًا من Int64). وAny هو النوع في قمة التسلسل الهرمي للأنواع؛ فجميع الأنواع هي أنواع فرعية من Any (بما في ذلك Any نفسه). فإذا تضمّن طور أنواع وسائطنا Any، وكان هناك عدّة طرق مطابقة، فإنّ Array الآتية من code_typed ستحوي أكثر من عنصر واحد؛ وستحوي عنصرًا واحدًا لكل طريقة مطابقة.

لنُخرج مثال Expr الذي لدينا ليسهل التحدث عنه.

julia> e = code_typed(foo,(Int64,Int64))[1]
:($(Expr(:lambda, {:x,:y}, {{:z},{{:x,Int64,0},{:y,Int64,0},{:z,Int64,18}},{}},
 :(begin  # none, line 2:
        z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64 # line 3:
        return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64
    end::Int64))))

البنية التي تهمّنا موجودة داخل Array: إنها Expr. تستخدم جوليا Expr (وهي اختصار لعبارة expression، أي «تعبير») لتمثيل شجرة الصياغة المجرّدة (AST) الخاصة بها. (شجرة الصياغة المجرّدة هي الطريقة التي يفكّر بها المُصرِّف في معنى شيفرتك؛ إنها تشبه نوعًا ما الوقت الذي كنتَ تُجبَر فيه على رسم مخطّطات للجُمل في المدرسة الابتدائية.) ويمثّل Expr الذي نحصل عليه طريقة واحدة. وهو يحتوي على بعض البيانات الوصفية (عن المتغيّرات التي تظهر في الطريقة) والتعبيرات التي تشكّل جسم الطريقة.

الآن يمكننا طرح بعض الأسئلة حول e.

ويمكن أن نسأل ما الخصائص التي يملكها Expr باستخدام الدالة names، التي تعمل على أيّ قيمة أو نوع في جوليا. فهي تُعيد Array من الأسماء التي يعرّفها ذلك النوع (أو نوع القيمة).

julia> names(e)
3-element Array{Symbol,1}:
 :head
 :args
 :typ 

لقد سألنا e للتو عمّا إذا كان له أسماء، والآن يمكننا أن نسأل ما القيمة التي يقابلها كل اسم. لدى Expr ثلاث خصائص: head وtyp وargs.

julia> e.head
:lambda

julia> e.typ
Any

julia> e.args
3-element Array{Any,1}:
 {:x,:y}                                                                                                                                                                                     
 {{:z},{{:x,Int64,0},{:y,Int64,0},{:z,Int64,18}},{}}                                                                                                                                         
 :(begin  # none, line 2:
        z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64 # line 3:
        return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64
    end::Int64)

لقد رأينا للتو بعض القيم مطبوعة، لكن ذلك لا يخبرنا كثيرًا عمّا تعنيه أو كيف تُستخدم.

  • تخبرنا head بنوع التعبير الذي هو عليه؛ عادةً ما كنت ستستخدم أنواعًا منفصلة لهذا الغرض في جوليا، لكن Expr هو نوع يجسّد البنية المستخدَمة في المحلّل (parser). المحلّل مكتوب لهجةٍ من Scheme، تُنظّم كل شيء كقوائم متداخلة. وتخبرنا head بكيفية تنظيم بقية Expr وبنوع التعبير الذي يمثّله.
  • typ هو نوع الإرجاع المستنتج للتعبير؛ فعند تقييم أيّ تعبير، ينتج عن ذلك بعض القيمة. وtyp هو نوع القيمة التي سيُقيَّم إليها التعبير. بالنسبة لمعظم Exprs تقريبًا، ستكون هذه القيمة Any (وهو صحيح دائمًا، إذ إنّ كل نوع ممكن هو نوع فرعي من Any). ولا يتغيّر هذا إلّا في body للطرق المستنتَجة الأنواع ومعظم التعبيرات داخلها، حيث يُضبط حقل typ فيه على شيء أكثر تحديدًا. (ولأنّ type كلمة محجوزة، لا يمكن لهذا الحقل أن يستخدم تلك الكلمة اسمًا له.)
  • args هي أكثر أجزاء Expr تعقيدًا؛ فبنيتها تتغيّر تبعًا لقيمة head. وهي دائمًا Array{Any} (مصفوفة غير مُنمَّطة)، لكنّ ما وراء ذلك تتغيّر البنية.

في Expr يمثّل طريقة، ستكون هناك ثلاثة عناصر في e.args:

julia> e.args[1] # names of arguments as symbols
2-element Array{Any,1}:
 :x
 :y

الرموز (Symbols) هي نوع خاصّ لتمثيل أسماء المتغيّرات والثوابت والدوالّ والوحدات (modules). وهي نوع مختلف عن النصوص (strings) لأنها تمثّل اسم بنية برمجية (construct) تحديدًا.

julia> e.args[2] # three lists of variable metadata
3-element Array{Any,1}:
 {:z}                                     
 {{:x,Int64,0},{:y,Int64,0},{:z,Int64,18}}
 {}                                       

تحتوي القائمة الأولى أعلاه على أسماء جميع المتغيّرات المحلية؛ ولدينا هنا متغيّر واحد فقط (z). وتحتوي القائمة الثانية على طور لكل متغيّر محلي ولكل وسيط للطريقة؛ وكل طور يحوي اسم المتغيّر ونوعه المستنتج ورقمًا. ويؤدّي هذا الرقم إلى معلومة عن كيفية استخدام المتغيّر، بطريقة صالحة للآلة (لا للبشر). أمّا القائمة الأخيرة فهي أسماء المتغيّرات المُلتقَطة (captured)؛ وهي فارغة في هذا المثال.

julia> e.args[3] # the body of the method
:(begin  # none, line 2:
        z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64 # line 3:
        return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64
    end::Int64)

العنصران الأول والثاني من args هما بيانات وصفية عن الثالث. وبينما البيانات الوصفية مثيرة للاهتمام جدًّا، فإنّها ليست ضرورية الآن. الجزء المهمّ هو جسم الطريقة، وهو العنصر الثالث. وهذه Expr أخرى.

julia> body = e.args[3]
:(begin  # none, line 2:
        z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64 # line 3:
        return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64
    end::Int64)

julia> body.head
:body

رأس هذا Expr هو :body لأنّه جسم الطريقة.

julia> body.typ
Int64

وtyp هو نوع الإرجاع المستنتج للطريقة.

julia> body.args
4-element Array{Any,1}:
 :( # none, line 2:)                                              
 :(z = (top(box))(Int64,(top(add_int))(x::Int64,y::Int64))::Int64)
 :( # line 3:)                                                    
 :(return (top(box))(Int64,(top(mul_int))(2,z::Int64))::Int64)    

تحتوي args على قائمة من التعبيرات: قائمة التعبيرات الموجودة في جسم الطريقة. وهناك عدد قليل من تعليقات أرقام الأسطر (أي :( # line 3:))، لكن معظم الجسم يتضمّن ضبط قيمة z (z = x + y) وإرجاع 2 * z. ولاحظ أنّ هذه العمليات استُبدلت بدوالّ جوهرية (intrinsic) خاصّة بـ Int64. أمّا top(function-name) فيدلّ على دالّة جوهرية؛ أي شيء مُنفَّذ في توليد شيفرة جوليا لا في جوليا نفسها.

لم نَرَ بعد كيف تبدو الحلقة، فجرّب ذلك.

julia> function lloop(x)
         for x = 1:100
           x *= 2
         end
       end
lloop (generic function with 1 method)

julia> code_typed(lloop, (Int,))[1].args[3]
:(begin  # none, line 2:
        #s120 = $(Expr(:new, UnitRange{Int64}, 1, :(((top(getfield))(Intrinsics,
         :select_value))((top(sle_int))(1,100)::Bool,100,(top(box))(Int64,(top(
         sub_int))(1,1))::Int64)::Int64)))::UnitRange{Int64}
        #s119 = (top(getfield))(#s120::UnitRange{Int64},:start)::Int64        unless 
         (top(box))(Bool,(top(not_int))(#s119::Int64 === (top(box))(Int64,(top(
         add_int))((top(getfield))
         (#s120::UnitRange{Int64},:stop)::Int64,1))::Int64::Bool))::Bool goto 1
        2: 
        _var0 = #s119::Int64
        _var1 = (top(box))(Int64,(top(add_int))(#s119::Int64,1))::Int64
        x = _var0::Int64
        #s119 = _var1::Int64 # line 3:
        x = (top(box))(Int64,(top(mul_int))(x::Int64,2))::Int64
        3: 
        unless (top(box))(Bool,(top(not_int))((top(box))(Bool,(top(not_int))
         (#s119::Int64 === (top(box))(Int64,(top(add_int))((top(getfield))(
         #s120::UnitRange{Int64},:stop)::Int64,1))::Int64::Bool))::Bool))::Bool
         goto 2
        1:         0: 
        return
    end::Nothing)

ستلاحظ أنّه لا توجد حلقة for ولا حلقة while في الجسم. فبينما يحوّل المُصرِّف الشيفرة مما كتبناه إلى التعليمات الثنائية التي يفهمها معالج CPU، تُحذف الميزات المفيدة للبشر التي لا يفهمها المعالج (مثل الحلقات). أُعيدت كتابة الحلقة كتعبيرات label وgoto. ويحتوي goto على رقم؛ ولكل label رقم أيضًا. ويقفز goto إلى label ذات الرقم نفسه.

كشف الحلقات واستخراجها

سنجد الحلقات بالبحث عن تعبيرات goto التي تقفز إلى الخلف.

سنحتاج إلى إيجاد التسميات (labels) والقفزات (gotos)، وتخمين أيّها متطابق. سأعطيك التنفيذ الكامل أولًا. وبعد جدار الشيفرة، سنفكّكها ونفحص قطعها.

# This is a function for trying to detect loops in the body of a Method
# Returns lines that are inside one or more loops
function loopcontents(e::Expr)
  b = body(e)
  loops = Int[]
  nesting = 0
  lines = {}
  for i in 1:length(b)
    if typeof(b[i]) == LabelNode
      l = b[i].label
      jumpback = findnext(x-> (typeof(x) == GotoNode && x.label == l) 
                              || (Base.is_expr(x,:gotoifnot) && x.args[end] == l),
                          b, i)
      if jumpback != 0
        push!(loops,jumpback)
        nesting += 1
      end
    end
    if nesting > 0
      push!(lines,(i,b[i]))
    end

    if typeof(b[i]) == GotoNode && in(i,loops)
      splice!(loops,findfirst(loops,i))
      nesting -= 1
    end
  end
  lines
end

والآن لنشرحها قطعةً قطعة:

b = body(e)

نبدأ بالحصول على جميع التعبيرات الموجودة في جسم الطريقة، في Array. وbody هي دالة سبق لي تنفيذها:

  # Return the body of a Method.
  # Takes an Expr representing a Method,
  # returns Vector{Expr}.
  function body(e::Expr)
    return e.args[3].args
  end

ثم:

  loops = Int[]
  nesting = 0
  lines = {}

loops هي Array من أرقام أسطر التسميات التي تقع عندها قفزات تمثّل حلقات. أمّا nesting فتُشير إلى عدد الحلقات التي نحن داخلها حاليًّا. وlines هي Array من طور من الشكل (فهرس، Expr).

  for i in 1:length(b)
    if typeof(b[i]) == LabelNode
      l = b[i].label
      jumpback = findnext(
        x-> (typeof(x) == GotoNode && x.label == l) 
            || (Base.is_expr(x,:gotoifnot) && x.args[end] == l),
        b, i)
      if jumpback != 0
        push!(loops,jumpback)
        nesting += 1
      end
    end

ننظر إلى كل تعبير في جسم e. إن كان تسمية (label)، فنتحقّق ممّا إذا كانت هناك قفزة (goto) تقفز إلى هذه التسمية (وتقع بعد الفهرس الحالي). فإذا كانت نتيجة findnext أكبر من صفر، فإنّ عقدة القفز تلك موجودة، ولذلك سنضيفها إلى loops (وهي Array الحلقات التي نحن داخلها حاليًّا) ونزيد مستوى nesting لدينا.

    if nesting > 0
      push!(lines,(i,b[i]))
    end

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

    if typeof(b[i]) == GotoNode && in(i,loops)
      splice!(loops,findfirst(loops,i))
      nesting -= 1
    end
  end
  lines
end

إن كنّا عند GotoNode، فنتحقّق ممّا إذا كانت نهاية حلقة. فإن كانت كذلك، نزيل المدخل من loops ونخفض مستوى التعشيش.

نتيجة هذه الدالة هي مصفوفة lines، وهي مصفوفة من طور من الشكل (فهرس، قيمة). وهذا يعني أنّ كل قيمة في المصفوفة لها فهرس في جسم Expr الخاص بجسم الطريقة، والقيمة عند ذلك الفهرس. وكل عنصر من عناصر lines هو تعبير وقع داخل حلقة.

إيجاد المتغيّرات ونوعها

لقد فرغنا للتو من الدالة loopcontents التي تُعيد Exprs الموجودة داخل الحلقات. ودالتنا التالية ستكون loosetypes، التي تأخذ قائمة Exprs وتُعيد قائمة بالمتغيّرات ضعيفة النمط. لاحقًا، ستمرّر مخرجات loopcontents إلى loosetypes.

في كل تعبير وقع داخل حلقة، تبحث loosetypes عن ظهورات الرموز (symbols) وأنواعها المرتبطة. وتظهر استخدامات المتغيّرات بوصفها SymbolNodes في شجرة الصياغة المجرّدة؛ وتحمل SymbolNodes اسم المتغيّر ونوعه المستنتج.

لا يمكننا أن نكتفي بفحص كل تعبير جمعته loopcontents لمعرفة إن كان SymbolNode. فالمشكلة أنّ كل Expr قد يحتوي على Expr واحد أو أكثر؛ وكل Expr قد يحتوي على SymbolNodes واحد أو أكثر. وهذا يعني أننا بحاجة إلى استخراج أي Exprs متداخلة، كي نتمكن من البحث في كلٍّ منها عن SymbolNodes.

# given `lr`, a Vector of expressions (Expr + literals, etc)
# try to find all occurrences of a variables in `lr`
# and determine their types
function loosetypes(lr::Vector)
  symbols = SymbolNode[]
  for (i,e) in lr
    if typeof(e) == Expr
      es = copy(e.args)
      while !isempty(es)
        e1 = pop!(es)
        if typeof(e1) == Expr
          append!(es,e1.args)
        elseif typeof(e1) == SymbolNode
          push!(symbols,e1)
        end
      end
    end
  end
  loose_types = SymbolNode[]
  for symnode in symbols
    if !isleaftype(symnode.typ) && typeof(symnode.typ) == UnionType
      push!(loose_types, symnode)
    end
  end
  return loose_types
end
  symbols = SymbolNode[]
  for (i,e) in lr
    if typeof(e) == Expr
      es = copy(e.args)
      while !isempty(es)
        e1 = pop!(es)
        if typeof(e1) == Expr
          append!(es,e1.args)
        elseif typeof(e1) == SymbolNode
          push!(symbols,e1)
        end
      end
    end
  end

تمرّ حلقة while على ما في باطن جميع Exprs، بشكل متكرّر (recursive). وكلّما وجدت الحلقة SymbolNode، تضيفه إلى المتجّه symbols.

  loose_types = SymbolNode[]
  for symnode in symbols
    if !isleaftype(symnode.typ) && typeof(symnode.typ) == UnionType
      push!(loose_types, symnode)
    end
  end
  return loose_types
end

الآن لدينا قائمة بالمتغيّرات وأنواعها، لذا يسهل التحقّق ممّا إذا كان النوع ضعيفًا. وloosetypes تفعل ذلك بالبحث عن نوع خاصّ من الأنواع غير الملموسة، وهو UnionType. فنحن نحصل على نتائج «فاشلة» كثيرة جدًّا حين نتعامل مع جميع الأنواع غير الملموسة على أنها «فاشلة». وذلك لأنّنا نُقيّم كل طريقة بوسائطها الموصوفة بأنواعها، وهي على الأرجح مجرّدة (abstract).

جعل هذا قابلًا للاستخدام

الآن وقد أتممنا الفحص على تعبير، ينبغي أن نجعل استدعاءه على شيفرة المستخدم أسهل. سنُنشئ طريقتين لاستدعاء checklooptypes:

  1. على دالة كاملة؛ وهذا يفحص كل طريقة من طرق الدالة المعطاة.

  2. على تعبير؛ وهذا يعمل إن استخرج المستخدم نتائج code_typed بنفسه.

## for a given Function, run checklooptypes on each Method
function checklooptypes(f::Callable;kwargs...)
  lrs = LoopResult[]
  for e in code_typed(f)
    lr = checklooptypes(e)
    if length(lr.lines) > 0 push!(lrs,lr) end
  end
  LoopResults(f.env.name,lrs)
end

# for an Expr representing a Method,
# check that the type of each variable used in a loop
# has a concrete type
checklooptypes(e::Expr;kwargs...) = 
 LoopResult(MethodSignature(e),loosetypes(loopcontents(e)))

نرى أنّ الخيارين يعملان تقريبًا بالطريقة نفسها لدالة ذات طريقة واحدة:

julia> using TypeCheck

julia> function foo(x::Int)
         s = 0
         for i = 1:x
           s += i/2
         end
         return s
       end
foo (generic function with 1 method)

julia> checklooptypes(foo)
foo(Int64)::Union(Int64,Float64)
	s::Union(Int64,Float64)
	s::Union(Int64,Float64)

julia> checklooptypes(code_typed(foo,(Int,))[1])
(Int64)::Union(Int64,Float64)
	s::Union(Int64,Float64)
	s::Union(Int64,Float64)

الطباعة الجميلة

لقد تجاوزتُ هنا تفصيلًا من تفاصيل التنفيذ: كيف حصلنا على النتائج لتُطبع في REPL؟

أولًا، صنعتُ بعض الأنواع الجديدة. وLoopResults هي نتيجة فحص دالة كاملة؛ فهي تحوي اسم الدالة والنتائج لكل طريقة. أمّا LoopResult فهي نتيجة فحص طريقة واحدة؛ فهي تحوي أنواع الوسائط والمتغيّرات ضعيفة النمط.

تُعيد الدالة checklooptypes كائن LoopResults. ولهذا النوع دالة اسمها show مُعرَّفة له. فيستدعي display على القيم التي يريد عرضها، ثمّ تستدعي display بدورها تنفيذي show لدينا.

هذه الشيفرة مهمة لجعل هذا التحليل الساكن قابلًا للاستخدام، لكنها لا تُجري تحليلًا ساكنًا. ينبغي أن تستخدم الطريقة المفضَّلة لطباعة الأنواع والمخرجات بشكل جميل في لغة التنفيذ التي تختارها؛ فهذه ببساطة طريقة الأمر في جوليا.

type LoopResult
  msig::MethodSignature
  lines::Vector{SymbolNode}
  LoopResult(ms::MethodSignature,ls::Vector{SymbolNode}) = new(ms,unique(ls))
end

function Base.show(io::IO, x::LoopResult)
  display(x.msig)
  for snode in x.lines
    println(io,"\t",string(snode.name),"::",string(snode.typ))
  end
end

type LoopResults
  name::Symbol
  methods::Vector{LoopResult}
end

function Base.show(io::IO, x::LoopResults)
  for lr in x.methods
    print(io,string(x.name))
    display(lr)
  end
end

البحث عن متغيّرات غير مستخدمة

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

يمكنك أن تجد أسماء المتغيّرات المكتوبة خطأً (وغيرها من المتغيّرات غير المستخدمة) بالبحث عن متغيّرات لا تُستخدم إلّا مرّة واحدة—أو لا تُستخدم إلّا بطريقة واحدة.

إليك مثالًا على قطعة شيفرة صغيرة فيها اسم واحد مكتوب خطأً.

function foo(variable_name::Int)
  sum = 0
  for i=1:variable_name
    sum += variable_name
  end
  variable_nme = sum
  return variable_name
end

يمكن لهذا النوع من الأخطاء أن يسبّب مشكلات في شيفرتك لا تُكتشَف إلّا عند تشغيلها. لنفترض أنك تخطئ في كتابة كل اسم متغيّر مرّة واحدة فقط. يمكننا فصل استخدامات المتغيّرات إلى كتابات وقراءات. فإذا كانت الكتابة الخاطئة كتابةً (أي worng = 5)، فلن يُطلَق أي خطأ؛ ستضع القيمة في المتغيّر الخطأ بصمت—وقد يكون إيجاد الخلل مزعجًا. أمّا إذا كانت الكتابة الخاطئة قراءةً (أي right = worng + 2)، فستحصل على خطأ وقت التشغيل عند تشغيل الشيفرة؛ ونودّ أن يكون لدينا تحذير ساكن لهذا، لتتمكّن من إيجاد هذا الخطأ في وقت أقرب، لكنّك ستظل مضطرًّا إلى الانتظار حتى تشغّل الشيفرة لترى المشكلة.

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

الجانب الأيسر والجانب الأيمن

هناك طريقة أخرى للتحدث عن استخدامات «القراءة» و«الكتابة» وهي تسميتهما «الجانب الأيمن» (RHS) و«الجانب الأيسر» (LHS). وهذا يشير إلى موضع المتغيّر بالنسبة لعلامة =.

إليك بعض استخدامات x:

  • الجانب الأيسر:
    • x = 2
    • x = y + 22
    • x = x + y + 2
    • x += 2 (الذي يتحوّل إلى x = x + 2)
  • الجانب الأيمن:
    • y = x + 22
    • x = x + y + 2
    • x += 2 (الذي يتحوّل إلى x = x + 2)
    • 2 * x
    • x

لاحظ أنّ تعبيرات مثل x = x + y + 2 وx += 2 تظهر في القسمين معًا، لأنّ x يظهر على جانبَي علامة =.

البحث عن المتغيّرات المستخدَمة مرّة واحدة

هناك حالتان نحتاج إلى البحث عنهما:

  1. متغيّرات تُستخدم مرّة واحدة.
  2. متغيّرات تُستخدم فقط على الجانب الأيسر أو فقط على الجانب الأيمن.

سنبحث عن جميع استخدامات المتغيّرات، لكنّنا سنبحث عن استخدامات الجانب الأيسر والأيمن على حدة، لتغطية الحالتين.

إيجاد استخدامات الجانب الأيسر

ليكون على الجانب الأيسر، يجب أن يكون للمتغيّر علامة = على يساره. وهذا يعني يمكننا البحث عن علامات = في شجرة الصياغة المجرّدة، ثم النظر إلى يسارها لإيجاد المتغيّر ذي الصلة.

في شجرة الصياغة المجرّدة، يكون = عبارة Expr ذات الرأس :(=). (الأقواس موجودة لتوضيح أنّ هذا هو رمز = وليس معاملًا آخر، :=.) وستكون القيمة الأولى في args هي اسم المتغيّر على جهته اليسرى. ولأنّنا ننظر إلى شجرة صياغة مجرّدة نظّفها المُصرِّف بالفعل، فسيكون هناك (دائمًا تقريبًا) رمز واحد فقط على يسار علامة = لدينا.

لنرَ ما الذي يعنيه ذلك في الشيفرة:

julia> :(x = 5)
:(x = 5)

julia> :(x = 5).head
:(=)

julia> :(x = 5).args
2-element Array{Any,1}:
  :x
 5  

julia> :(x = 5).args[1]
:x

فيما يلي التنفيذ الكامل، يليه الشرح.

# Return a list of all variables used on the left-hand-side of assignment (=)
#
# Arguments:
#   e: an Expr representing a Method, as from code_typed
#
# Returns:
#   a Set{Symbol}, where each element appears on the LHS of an assignment in e.
#
function find_lhs_variables(e::Expr)
  output = Set{Symbol}()
  for ex in body(e)
    if Base.is_expr(ex,:(=))
      push!(output,ex.args[1])
    end
  end
  return output
end
  output = Set{Symbol}()

لدينا مجموعة من الرموز (Symbols)؛ وهذه هي أسماء المتغيّرات التي وجدناها على الجانب الأيسر.

  for ex in body(e)
    if Base.is_expr(ex,:(=))
      push!(output,ex.args[1])
    end
  end

لا نتعمّق أكثر في التعبيرات، لأنّ شجرة الصياغة المجرّدة الناتجة عن code_typed مسطّحة إلى حدّ كبير؛ فقد حُوّلت الحلقات والشروط إلى جمل مسطّحة مع قفزات لتدفّق التحكم. ولن تكون هناك أي إسنادات مختبئة داخل وسائط استدعاءات الدوالّ. وستفشل هذه الشيفرة إن كان على يسار علامة المساواة أيّ شيء غير رمز. وهذا يُفوّت حالتين حدّيتين محدّدتين: المصفوفات (مثل a[5]، التي ستُمثَّل كتعبير :ref) والخصائص (مثل a.head، التي ستُمثَّل كتعبير :.). وسيظل الرمز ذي الصلة هو القيمة الأولى في args لديهما دائمًا، لكن قد يكون مدفونًا بعض الشيء (كما في a.property.name.head.other_property). ولا تعالج هذه الشيفرة تلك الحالات، لكن يمكن إصلاح ذلك بسطرين من الشيفرة داخل جملة if.

      push!(output,ex.args[1])

حين نجد استخدامًا لمتغيّر على الجانب الأيسر، ندفع اسم المتغيّر إلى Set عبر push!. وستتأكّد Set من أنّنا لا نملك إلّا نسخة واحدة من كل اسم.

إيجاد استخدامات الجانب الأيمن

لإيجاد جميع استخدامات المتغيّرات الأخرى، نحتاج أيضًا إلى النظر في كل Expr. وهذا أعقد قليلًا، لأنّنا نهتمّ في الأساس بكل Exprs، لا فقط بتلك من نوع :(=)، ولأنّنا نضطر إلى الحفر داخل Exprs المتداخلة (للتعامل مع استدعاءات الدوالّ المتداخلة).

إليك التنفيذ الكامل، ويليه الشرح.

# Given an Expression, finds variables used in it (on right-hand-side)
#
# Arguments: e: an Expr
#
# Returns: a Set{Symbol}, where each e is used in a rhs expression in e
#
function find_rhs_variables(e::Expr)
  output = Set{Symbol}()

  if e.head == :lambda
    for ex in body(e)
      union!(output,find_rhs_variables(ex))
    end
  elseif e.head == :(=)
    for ex in e.args[2:end]  # skip lhs
      union!(output,find_rhs_variables(ex))
    end
  elseif e.head == :return
    output = find_rhs_variables(e.args[1])
  elseif e.head == :call
    start = 2  # skip function name
    e.args[1] == TopNode(:box) && (start = 3)  # skip type name
    for ex in e.args[start:end]
      union!(output,find_rhs_variables(ex))
    end
  elseif e.head == :if
   for ex in e.args # want to check condition, too
     union!(output,find_rhs_variables(ex))
   end
  elseif e.head == :(::)
    output = find_rhs_variables(e.args[1])
  end

  return output
end

البنية الرئيسية لهذه الدالة هي جملة if-else كبيرة، تتعامل فيها كل حالة مع رمز رأس مختلف.

  output = Set{Symbol}()

output هي مجموعة أسماء المتغيّرات، التي سنُعيدها في نهاية الدالة. وبما أنّنا نهتمّ فقط بحقيقة أنّ كلًّا من هذه المتغيّرات قد قُرئ مرّة واحدة على الأقل، فإنّ استخدام Set يُعفينا من القلق بشأن تفرّد كل اسم.

  if e.head == :lambda
    for ex in body(e)
      union!(output,find_rhs_variables(ex))
    end

هذا هو الشرط الأول في جملة if-else. و:lambda يمثّل جسم دالة. ونستدعي بشكل متكرّر على جسم التعريف، وهو ما ينبغي أن يجمع كل استخدامات المتغيّرات على الجانب الأيمن في التعريف.

  elseif e.head == :(=)
    for ex in e.args[2:end]  # skip lhs
      union!(output,find_rhs_variables(ex))
    end

إذا كان الرأس :(=)، فإنّ التعبير هو إسناد. ونتخطّى العنصر الأول من args لأنّه المتغيّر الذي يجري الإسناد إليه. ومن أجل كلٍّ من التعبيرات المتبقّية، نجد بشكل متكرّر متغيّرات الجانب الأيمن ونضيفها إلى مجموعتنا.

  elseif e.head == :return
    output = find_rhs_variables(e.args[1])

إذا كانت هذه جملة إرجاع، فإنّ العنصر الأول من args هو التعبير الذي ستُعاد قيمته؛ وسنضيف أي متغيّرات موجودة فيه إلى مجموعتنا.

  elseif e.head == :call
    # skip function name
    for ex in e.args[2:end]
      union!(output,find_rhs_variables(ex))
    end

بالنسبة لاستدعاءات الدوالّ، نريد الحصول على جميع المتغيّرات المستخدَمة في جميع وسائط الاستدعاء. ونتخطّى اسم الدالة، وهو العنصر الأول من args.

  elseif e.head == :if
   for ex in e.args # want to check condition, too
     union!(output,find_rhs_variables(ex))
   end

يملك Expr يمثّل جملة شرط قيمة head هي :if. نريد الحصول على استخدامات المتغيّرات من جميع التعبيرات في جسم جملة الشرط، لذا نستدعي بشكل متكرّر على كل عنصر من args.

  elseif e.head == :(::)
    output = find_rhs_variables(e.args[1])
  end

يُستخدم المعامل :(::) لإضافة شروح الأنواع. والوسيط الأول هو التعبير أو المتغيّر الذي يجري وضع الشرح عليه؛ ونتحقّق من استخدامات المتغيّرات في التعبير الموصوف.

  return output

في نهاية الدالة، نُعيد مجموعة استخدامات المتغيّرات على الجانب الأيمن.

هناك المزيد قليلًا من الشيفرة التي تبسّط الدالة أعلاه. ولأنّ النسخة أعلاه لا تتعامل إلّا مع Exprs، بينما إنّ بعض القيم التي تُمرَّر بشكل متكرّر قد لا تكون Exprs، فنحتاج إلى بضع طرق أخرى للتعامل مع الأنواع الممكنة الأخرى على نحو مناسب.

# Recursive Base Cases, to simplify control flow in the Expr version
find_rhs_variables(a) = Set{Symbol}()  # unhandled, should be immediate val e.g. Int
find_rhs_variables(s::Symbol) = Set{Symbol}([s])
find_rhs_variables(s::SymbolNode) = Set{Symbol}([s.name])

تجميع كلّ شيء معًا

الآن وقد عرّفنا الدالتين أعلاه، يمكننا استخدامهما معًا لإيجاد المتغيّرات التي إمّا أن تُقرأ منها فقط أو تُكتب إليها فقط. وسيُسمّى الدالة التي تجدها unused_locals.

function unused_locals(e::Expr)
  lhs = find_lhs_variables(e)
  rhs = find_rhs_variables(e)
  setdiff(lhs,rhs)
end

ستُعيد unused_locals مجموعة من أسماء المتغيّرات. ومن السهل كتابة دالة تحدّد ما إذا كانت مخرجات unused_locals تُعدّ «ناجحة» أو لا. فإن كانت المجموعة فارغة، فإنّ الطريقة تنجح. وإن نجحت كل طرق دالة ما، فإنّ الدالة تنجح. وتُطبّق الدالة check_locals أدناه هذا المنطق.

check_locals(f::Callable) = all([check_locals(e) for e in code_typed(f)])
check_locals(e::Expr) = isempty(unused_locals(e))

الخلاصة

أجرينا تحليلين ساكنين لشيفرة جوليا—أحدهما مبنيّ على الأنواع والآخر مبنيّ على استخدامات المتغيّرات.

اللغات ذات الأنواع الساكنة تؤدّي بالفعل النوع من العمل الذي أدّاه تحليلنا المبنيّ على الأنواع؛ والتحليل الساكن الإضافي المبنيّ على الأنواع مفيد غالبًا في اللغات ذات الأنواع الديناميكية. وقد كانت هناك مشاريع (أغلبها بحثية) لبناء أنظمة استنتاج أنواع ساكنة للغات تشمل بايثون وروبي ولغة Lisp. وتُبنى هذه الأنظمة عادةً حول شروح أنواع اختيارية؛ فيمكنك أن تكون لديك أنواع ساكنة حين تريدها، وترجع إلى التخصيص الديناميكي حين لا تريدها. وهذا مفيد خاصّةً لدمج بعض التخصيص الساكن في قواعد شيفرة قائمة.

الفحوص غير المبنيّة على الأنواع، مثل فحصنا لاستخدامات المتغيّرات، تنطبق على اللغات ذات الأنواع الديناميكية والساكنة على السواء. لكنّ كثيرًا من اللغات ذات الأنواع الساكنة، مثل C++ وجافا، تتطلّب منك تعريف المتغيّرات، وتُعطي أصلًا تحذيرات أساسية مثل التي أنشأناها. وما تزال هناك فحوص مخصّصة يمكن كتابتها؛ فمثلًا، فحوص خاصّة بدليل أسلوب مشروعك أو احتياطيات سلامة إضافية مبنيّة على سياسات أمن.

ولو أنّ جوليا تملك أدوات رائعة لتفعيل التحليل الساكن، فإنّها ليست وحدها. فـ Lisp بالطبع مشهورة بأنّ الشيفرة فيها بنية بيانات من قوائم متداخلة، لذا يسهل عادةً الوصول إلى شجرة الصياغة المجرّدة. كما تكشف جافا شجرتها أيضًا، وإنّ كانت شجرة جافا أكثر تعقيدًا بكثير من شجرة Lisp. وبعض اللغات أو أدوات اللغات غير مصمَّمة للسماح للمستخدمين العاديين بالتنقّل في التمثيلات الداخلية. وبالنسبة إلى أدوات مفتوحة المصدر (خاصّةً جيّدة التوثيق)، فإنّ أحد الخيارات هو إضافة خطّافات (hooks) إلى بيئة العمل تتيح لك الوصول إلى شجرة الصياغة المجرّدة.

وفي الحالات التي لن تنجح فيها هذه الطريقة، فالحلّ الأخير الاحتياطي هو كتابة محلّل خاصّ بك؛ وهذا ما ينبغي تجنّبه متى أمكن. فتغطية القواعد النحوية الكاملة لمعظم لغات البرمجة عملٌ كبير، وستحتاج إلى تحديثها بنفسك مع إضافة ميزات جديدة إلى اللغة (بدلًا من تلقّي التحديثات تلقائيًّا من المصدر الأصلي). وبحسب الفحوص التي تريد إجراءها، قد يكفيك تحليل بعض الأسطر أو مجموعة فرعية من ميزات اللغة فقط، ما سيخفّض إلى حدّ كبير تكلفة كتابة محلّلك الخاصّ.

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