رحلة الكود من الكتابة إلى الّتنفيذ
المعلوماتية >>>> برمجيات
كانت البرمجةُ قديمًا حكرًا على علماءِ الحاسوبِ، لأنّها كانت تتطلّبُ فهمًا عميقًا لمعماريّةِ الحاسوبِ وكيفيّةِ التّحكُّمِ به باستخدامِ لغاتٍ منخفضةِ المستوى Low-level قريبةٍ للغةِ الآلةِ (لغة الأصفار والواحدات). ولكن مع تطوِّرِ البرمجةِ ولغاتِ البرمجةِ أصبحَ بإمكانِ أيِّ شخصٍ أن يتعلَّمَ لغةَ برمجةٍ يستخدمها لكتابةِ برامج للحاسوب دون الحاجة للتّعمُّقِ في بنيةِ الحاسوبِ وكيفيّةِ فهمِهِ للتّعليماتِ وذلكَ بفضلِ المترجمات Compilers والمُفسِّراتِ Interpreters الّتي تقومُ بترجمةِ/تفسيرِ الأسطرِ البرمجيّةِ بشكلٍ يفهمه الحاسوب.
تتشابَهُ المُترجماتُ والمُفسِّراتُ بالبنيةِ والوظيفةِ إلّا أنَّهُ توجدُ بعضُ الفروقاتِ بينهما:
· يقومُ الُمفسِّرُ بأخذِ عبارةٍ واحدةٍ في كلِّ مرَّةٍ، فيترجمُها ثمَّ يأخذُ الّتي تليها، أمّا المُترجمُ فيقومُ بترجمةِ كاملِ البرنامجِ دُفعةً واحدةً.
· يقومُ المُترجمُ بالتَّبليغِ عن الخطأ بعدَ ترجمةِ كاملِ الكودِ بينما يقومُ المُفسِّرُ بإيقافِ التّرجمةِ عندما يُصادِفُ أوّلَ خطأ.
· يستغرقُ المُترجمُ الكثيرَ من الوقتِ في تحليلِ ومعالجةِ الكودِ المكتوبِ مقارنةً بالمُفَسِّرِ الّذي يستغرقً وقتًا أقلّ. في المقابلِ فإنَّ وقتَ التّنفيذِ الكُليِّ لكودٍ ما أسرعَ عندَ استخدامِ المترجمِ مِنه عندَ استخدامِ المُفسِّرِ.
في مقالنا هذا سنركِّزُ على شرحِ بنيةِ المُترجمِ وطريقةِ عملِهِ بشكلٍ أساسيٍّ.
إنَّ المترجمَ هو البرنامجُ الّذي يقومُ بقراءةِ كودٍ مكتوبٍ بلغةٍ عاليةِ المُستوى تُسمَّى لغةُ المصدرِ ويحوّله إلى كودٍ بلغةٍ أقربَ إلى فهمِ الحاسوبِ تُسمَّى لغةُ الهدف. ولكنَّ هذهِ العمليّة ليست بالبساطةِ الّتي نتخيّلُها فهي عمليّةٌ معقّدةٌ تجري على مراحلَ متعدّدةٍ سنشرحُها بالتّفصيل:
المرحلةُ الأولى: التَّحليلُ اللّفظيُّ Lexical Analysis
وفيها يتمُّ التَّعرُّفُ على جميعِ مفرداتِ اللّغةِ والّتي يُطلَقُ عليها مصطلحَ Tokens الموجودةِ ضمنَ الكودِ الّذي نريدُ ترجمتَه وتحديدُ فيما إذا كانت هذه المفرداتُ تنتمي إلى اللّغةِ البرمجيّةِ أم لا. تنقسمُ مفرداتُ اللّغةِ إلى قسمين: الأوّلُ هو الكلماتُ المفتاحيّةُ keywords مثل if-then-else-while…etc وتختلفُ الكلماتُ المفتاحيّةُ من لغةٍ إلى أخرى وطبعًا يوجدُ لكلِّ لغةٍ برمجيّةٍ مُترجمٌ خاصٌّ بها. أمّا عن بقيّةِ المفرداتِ فتُصنَّفُ إلى عددٍ من أنماطِ المفرداتِ token classes مثلَ السّلاسلِ المحرفيّة strings وأسماءِ المتحوّلاتِ identifiers والأعدادِ الصّحيحةِ integers أو العشريّةِ أو أعدادِ الفاصلةِ العائمةِ floating points وغيرها. أمّا إذا وجدَ المُترجمُ ضمن الدّخلِ أيَّةَ مفرداتٍ لا تنتمي إلى الكلماتِ المفتاحيّةِ للّغةِ ولا لأنماطِ المفرداتِ السّابقةِ فيعتبرُها دخلًا خاطئًا وينبِّهُ المستخدمَ إلى ذلك.
تُشبه هذه العمليّةُ طريقةَ تعرُّفِ دماغِنا على مفرداتِ اللُّغةِ المحكيّةِ فمثلًا لو قرأنا الجملةَ التّاليةَ:
" أشرقت الشَّمسُ في الصَّباحِ "عندها يقومُ دماغُنا بالتَّحليلِ اللَّفظيِّ للجملةِ لمعرفةِ إذا كانت تحوي أيَّةُ مفرداتٍ غريبةٍ فينظرُ إلى هذه الجملةِ كالتّالي:
أشرقت: فعلٌ بمعنى بزغت
الشَّمسُ: اسمٌ لجسمٍ مضيءٍ
في: حرفُ جرٍّ
الصَّباحُ: اسمٌ يدُلُّ على وقتٍ من اليومِ
ويعرفُ دماغُنا أنَّ جميع كلماتِ الجملةِ هي كلماتٌ عربيّةٌ موجودةٌ في مُعجمِ الكلماتِ المُخزَّنةِ لدينا، أمّا لو جاءت كلمةٌ غريبةٌ أو خاطئةٌ فسيُلاحظُ دماغُنا أنَّ هذه الكلمةَ لا تنتمي إلى اللُّغةِ العربيّةِ ولن يستطيعَ التَّعرُّفَ عليها، ويجبُ الانتباهُ أنَّه في مرحلةِ التَّحليلِ اللَّفظيِّ نهتمُّ فقط بالمفرداتِ وليسَ بصياغةِ الجُملةِ أو معناها فلو كانت الجملةُ "أشرقت الصّباحُ الشَّمسَ" فهي صحيحةٌ من حيثُ أنَّ مفرادتِها تنتمي إلى اللُّغةِ العربيّةِ وسنهتمُّ بموضوعِ الصِّياغةِ والمعنى في المراحلِ القادمةِ.
في الحقيقةِ الأمرُ ذاتُه ينطبقُ على المُترجمِ، فلنرَ المثالَ التّالي : هذه التّعليمة int C= (A*B)+10;
يمكن أن يتمَّ تحليلُها إلى المفرداتِ tokens التّاليةِ:
“int” كلمةٌ مفتاحيّةٌ keyword
“C” اسمُ متحوِّلٍ Identifier
”=“ إشارةُ مساواةٍ
”(“ قوسٌ يساريٌّ
“A” اسمُ متحوِّلٍ Identifier
"*" إشارةُ عمليّةِ الضَّربِ
“B” اسمُ متحوِّلٍ Identifier
“(” قوسٌ يمينيٌّ
"+" إشارةُ عمليّةِ الجمعِ
"10" عددٌ صحيحٌ Integer
“;” فاصلةٌ منقوطةٌ
قد يتبادرُ إلى ذهنِنا السُّؤالُ التّالي: كيفَ يعرفُ المترجمُ إن كانت هذه الكلمةُ تنتمي إلى اللُّغةِ البرمجيّةِ أم لا؟
بنفسِ الطَّريقةِ الّتي تُعرِّفُ كلماتِ اللُّغةِ المحكيّةِ ضمنَ معجمٍ، تُعرَّفُ مفرداتُ اللُّغةِ البرمجيّةِ ضمنَ ملفٍّ يُدعى lexer يحوي جميعَ مفرداتِ اللُّغةِ وعندما يُصادفُ المُترجمُ مفردةً ما يبحثُ ضمنَ الـ lexer عن وجودِها أو عدمِهِ.
يُمكنُنا تلخيصُ عملِ هذه المرحلةِ بالقولِ أنَّ دخلَها هو الكودُ المصدريُّ وخرجُها هو سلسلةٌ من الـ Tokens.
المرحلةُ الثّانيةُ: التَّحليلُ النَّحويُّ Syntax Analysis
بعد أن عرفنا إذا كانت المفرداتُ صحيحةً وتنتمي إلى اللُّغةِ علينا أن نعرِفَ فيما إذا كانت مُرتَّبةٌ بشكلٍ صحيحٍ بحسب قواعد اللُّغة. قواعدُ اللُّغةِ البرمجيّةِ تُشابهُ قواعدَ اللُّغةِ المحكيّةِ لكنَّ الفرقَ الجوهريَّ بينهما أنَّه في اللُّغةِ المحكيّةِ قد يتغيَّرُ المعنى بحسبِ السِّياقِ فاللُّغاتُ المحكيّةُ هي لغاتٌ ذاتُ سياقٍ ويؤثِّرُ السِّياقُ بالمعنى والتَّعاملُ مع هذا الأمرِ معقَّدٌ للغايةِ ومازالَ قيدَ الدّراسةِ والبحثِ، أمّا اللُّغاتُ البرمجيّةُ فهي أبسطُ حيث أنَّها تُدعى لغاتٌ خارجَ السِّياقِ Context-Free Languages ولا يتغيّرُ معنى المفردةِ أينما أتت ضمنَ الكود. يُعبَّرُ عن قواعدِ اللُّغةِ البرمجيّةِ بشكلٍ يُشابِهُ قواعدَ اللُّغاتِ المحكيّةِ فمثلًا:
في اللُّغةِ العربيّةِ تتألفُ الجملةُ الفعليّةُ من : فعلٍ – فاعلٍ – مفعولٍ به. وبنفسِ الطّريقةِ يمكنُنا القولُ أنَّ في لغةِ البرمجةِ تتألَّفُ الجملةُ الشَّرطيّةُ من : if- then – else وإذا كانَ الكودُ الّذي نترجمه يحوي else- if – then لن يجدَ المُترجمُ ضمنَ قواعدِ اللُّغةِ قاعدةً تقبلُ هذا التّرتيبَ من المفرداتِ وفي هذهِ الحالةِ يُعطينا Syntax Error.
يكونُ خرجُ مرحلةِ الـتّحليلِ النَّحويِّ هو Parse Tree وهي شجرةٌ تربُطُ بينَ المفرداتِ بحسبِ القواعدِ بالشَّكلِ التَّالي:
فلنفرض أنَّ الكودَ الّذي نقومُ بترجمتِهِ هو: if x=y then 1 else 2
يكونُ خرجُ مرحلةِ التَّحليلِ اللَّفظيِّ هو if identifier = identifier then int else int
أمَّا خرجُ مرحلةِ التّحليلِ النَّحويِّ فهو الـ Parse tree التّاليةِ:
Image: syr-res.com
نعودُ للسُّؤالِ الّذي سألناه في المرحلةِ السَّابقةِ: كيفَ يعرفُ المُترجمُ القواعدَ الصَّحيحةَ للُّغةِ؟
يحوي كلُّ مترجمٍ ملفًا تُكتَبُ فيهِ كلُّ قواعدِ اللُّغةِ ويحاولُ المُترجمُ مطابقةَ المفرداتِ الّتي وجدها في المرحلةِ الأولى معَ هذهِ القواعدِ لمعرفةِ فيما إذا كانَ الكودُ صحيحًا قواعديًّا أم لا. ويمكنُنا القولُ أنَّ ملفَّ المفرداتِ والقواعدِ هما الأساسُ في عملِ المُترجمِ وتوصيفِ اللُّغةِ البرمجيّةِ الّتي نريدُ ترجمتَها بدقَّةٍ.
المرحلةُ الثّالثةُ: التَّحليلُ الدّلاليُّ Semantic Analysis
وفي هذه المرحلةِ يتمُّ فهمُ معنى الكودِ والقيامُ بالـ Type Checking أو فحصُ الأنماطِ كأن نفحصَ تطابقَ قيمةِ المتحوِّلِ معَ نمطِهِ وغيرَها منَ الأخطاءِ الّتي تتعلَّقُ بدلالاتِ المُتحوِّلاتِ فمثلًا في لغةِ البرمجةِ الّتي نستخدمُها هنا يجبُ تعريفُ أيِّ متحوّلٍ قبلَ استخدامِهِ، فلو أردنا استخدامَ متحوِّلٍ يُدعى x يجبُ أن نُعرِّفَه قبلَ ذلكَ بالشكلِّ int x مثلًا لكي يفهمَ الحاسوبَ أنَّ لديَّ متحوِّلٌ اسمُهُ x ونوعُهُ عددٌ صحيحٌ أي أستطيعُ أن أُخزِّنَ فيه 0-1-2-3 إلخ، وفي حالِ حاولنا تخزينَ عددٍ عشريٍّ فيه سيعطينا المترجمُ رسالةَ خطأٍ مثلَ Type-Mismatch، و كذلكَ إن حاولنا استخدامَ المتحوِّلِ دونَ تعريفِهِ سيُعطينا رسالةَ خطأٍ أيضًا، أو إذا حاولنا استخدامَ المتحوّلِ x ضمنَ عمليّةٍ معيّنةٍ دونَ إعطائِهِ قيمةً بدائيّةً مثلًا int y = x+5; ونحن لا نعلمُ قيمةَ x بعد سيُعطينا رسالةَ خطأٍ مثلَ local variable 'x' used without been initialized ".
إذاً كيفَ يعرفُ المُترجِمُ ما المتحوِّلاتُ المُعرَّفَةُ وما أنماطُها و قيَمُها ؟ يتمُّ ذلكَ عبرَ ما يُعرَفُ بجدولِ الرُّموزِ Symbol Table فعندَ المرورِ على قواعدِ اللُّغةِ في مرحلةِ Syntax Analysis ومحاولةِ مُطابقةِ المفرداتِ مع القواعدِ يقومُ المُترجمُ بوضعِ كلِّ مايتعلَّقُ بالتَّعريفاتِ مثلَ تعريفاتِ المُتحوِّلاتِ والصُّفوفِ والتَّوابعِ ضمنَ جدولٍ يُدعى جدولُ الرُّموزِ فمثلًا عندَ تعريفِ int x =5; يضعُ المُترجمُ في جدولِ الرُّموزِ رمزَ x نمطَه int قيمتَه 5، وعندَ مرورِ x مرَّةً أُخرى ضمنَ الكودِ يبحثُ في جدولِ الرُّموزِ لمعرفةِ إذا ما كانَ هذا المتحوِّلُ مُعرَّفًا أم لا. يُنجزُ في هذه المرحلةِ بعضَ أنواعِ التَّحققاتِ checks Semantic مثلَ تكرارِ التّعريفِ re-declaration كأن نُعرِّفَ متحوِّلَينِ بالاسمِ x فهذهِ حالةٌ تستدعي أن يُعطينا المُترجمُ رسالةَ خطأٍ وكذلكَ حالاتٍ أُخرى كأن نُعرِّف صفًا class يرثُ صفًا آخرَ غيرَ موجودٍ وغيرَها من الأخطاءِ المنطقيّةِ.
في هذه المرحلةِ يتمُّ بناءُ مايُعرفُ بالـ Abstract Syntax Tree والّتي يُرمزُ لها اختصارًا بـ AST وهي شجرةٌ مشابِهةٌ للـ Parse Tree ولكنَّها مُختصَرَةٌ ومُحسَّنةٌ ومُضافٌ لها جميعُ المعلوماتِ الخاصَّةِ بالـ Semantic Check وهي تُمثِّلُ النَّسخَةَ النِّهائيّةَ والكاملةَ والمُنقَّحَةَ من الكودِ ليتمَّ منها توليدُ الكودِ النِّهائيِّ Code Generation الّذي سيتمُّ فهمَه من قبلِ الحاسوبِ. في هذِهِ الشَّجرةِ:
· يجبُ أن تكونَ أنماطُ المُتحوِّلاتِ معروفةً ومُخزَّنَةً وكذلكَ مواقعُ تعريفِ المُتحوِّلاتِ ضمنَ الكودِ المصدَريِّ.
· يجبُ أن تكونَ التّعليماتُ مرتّبةً ومُمثَّلَةً بشكلٍ كاملٍ.
· يجبُ أن تكونَ المُعامِلاتُ اليمينيّةُ واليساريّةُ للعمليّاتِ الثُّنائيّةِ مُعرَّفَةً بشكلٍ صحيحٍ ومُخزَّنَةً.
· يجبُ أن تكونَ جميعُ أسماءِ المُعرِّفاتِ identifiers وقيمِها مُخزّّنَةً بشكلٍ صحيحٍ.
المرحلةُ الرّابعةُ: مرحلةُ الأمثلةِ Optimization
و هي مرحلةٌ تقعُ منطقيًّا بينَ الـ Semantic Check والـ Code Generation ولكن في بعضِ المُترجماتِ يتمُّ تجاهُلُها لتعقيدِها ولأنّه في بعضِ الحالاتِ تكونُ كلفةُ عمليّةِ الأمثلةِ عاليةً مُقارنةً بالنَّتائجِ الّتي تُعطيها. وقد يتمُّ أيضًا دمْجُها ضمنَ مراحلِ التّرجمةِ الأُخرى، ومهمَّتُها الأساسيّةُ تحسينُ الكودِ المصدريِّ لتقليلِ زمنِ التّنفيذِ، عن طريقِ استبعادِ المتحوِّلاتِ غيرِ المُستخدَمَةِ والاستغناءِ عن الحلقاتِ غيرِ الضَّروريّةِ وغيرِها من العمليّاتِ الّتي قد تُحسِّنُ من زمنِ التّنفيذِ.
المرحلةُ الخامسةُ والأخيرةُ: توليدُ الكودِ Code Generation
ويتمُّ فيها استخدامُ شجرةِ الـ AST لتوليدِ كودٍ قريبٍ لفهمِ الحاسوبِ غالبًا ما يكونُ كودًا بلغةِ التّجميعِ Assembly والّتي لها أدواتٌ تحوِّلُها إلى لغةِ الآلةِ Machine Code ، ويكونُ خرجُ هذهِ المرحلةِ كودًا نهائيًّا قابلًا للتّنفيذِ من قِبَلِ الحاسوبِ دونَ مشاكلَ وبهذا تكونُ قد انتهت مهمَّة المُترجمِ . Compiler
هذه هي رحلةُ الكودِ البرمجيِّ من لحظةِ كتابتكَ له إلى لحظةِ تنفيذِهِ من قِبَلِ الحاسوبِ. الأمرُ أعقدُ ممّا كنتَ تتخيّلُ أليسَ كذلكَ؟
----------------------------------------------------
المصدر:
هنا