مشروع : لغة برمجة

”المُقَدِّر، ذاك الَّذي يُحَدِّدُ معنى التعابير في لغة البرمجة، ما هو إلَّا برنامجٌ آخر.“ ـــ هال أبيلسون وَجيرالد سوزمان، هيكليَّة وَتفسير لغات الحاسِب.

11 مَشْرُوْعٌ: لُغَةُ بَرمَجَةٍ

إنَّ بناء لغة برمجةٍ خاصَّةٍ بك بِانذهالٍ أمرٌ سَهلٌ (طالما أنَّك لا تطمح عاليًا!) ومُنيرٌ جدًّا للعقل. الشيء الرئيسي الَّذي أُريد عرضَه في هذا الفصل أنَّهُ لا يوجد سحرٌ منخرط في عمليَّة بناء لغتك الخاصَّة. فقد شَعرتُ غالبًا أنَّ بعض إختراعات البشر ذكيَّة ومُعَقَّدة كثيرًا وأنَّني لن أكون قادرًا على فهمها أبدًا. ولكن بقليلٍ من القراءة والتفكير، تصبح هذه الأشياء عاديَّة تمامًا. سنبني لُغَةَ بَرمَجَةٍ تُدعى "بيضة" Egg. وستكون لغة صغيرة وبسيطة ولكن قويَّة كفايةً لِتُعَبِّر عن أي حَوسَبَةٍ تُفَكِّرُ بِهَا. وستسمح أيضًا بفِكْرِ التجريد abstraction البسيط المُعتمد على الدوال.

الإعراب Parsing

أَوَّلُ جُزءٍ يُرى من لُغَةِ البرمجة هو نَحْوِيَّتُها syntax أو تَرقِيْمُهَا notation. والمُعَرِّبُ parser هو برنامج يقرأ قِطْعَة نَصِّيَّة ويُنْتِجُ هيكليَّة بيانات تعكس بُنية البرنامج المُحتوى في النص. وإن لم يُشَكِّل النص برنامجًا صالِحًا، فيجب عندئذٍ أن يَحتَجَّ المُعَرِّبُ ويُشيرَ إلى الخطأ. سيكون للغتنا نَحْوِيَّة بسيطة وَمُوَحَّدَة. وكل شيء في Egg هو تَعْبِيْرٌ expression. فيمكن للتعبير أن يكون مُتَغَيِّرًا variable، عَدَدًا number، سِلْسِلَةً نَصِّيَّةً string، أو تَطْبِيْقًا application. حيثُ تُستَخدم التطبيقات لإستدعاءات الدالة ولكن أيضًا من أجل التركيبات مثل "إذا" if أو "طالما" while. ولإبقاء المُعَرِّب بسيطًا، فإنَّ السلاسل النصيَّة في Egg لا تدعم أيَّ شيءٍ مِثْلَ مُهَرِّبات الشَّرِطَة الخَلفيَّة <> backslash escapes. وإنَّ السلاسل النصيَّة ببساطة عبارة عن تتَالي من المَحارف وليست إقتباسات مُضَاعَفَة <"> مُغَلَّفَة بِإقتباسات مُضاعفة أخرى. والعدد هو تتَالي من الأرقام. والمُتَغَيِّرات هي أسماءٌ تتألَّف من أي مَحرَف عدا المسافة البيضاء whitespace ولا يجب أن يكون لها معنىً خاص بِنَحَوِيَّةِ اللُّغة syntax. تُكْتَبُ التطبيقات كما هو الحال في الجافا سكرِبت، بوضع أقواس بعد التعبير حاويةً عددًا من الجَدَليَّات بين هذه الأقواس ومفصولةً بفواصِل.

do(define(x, 10),
    if(>(x , 5),
        print("large"),
        print("small")))

الإتِّسَاق في لغة Egg يعني أنَّ الأشياء الَّتي تُعتَبَر مُعَامِلات operators في الجافا سكرِبت (مثل <) هي مُتَغَيِّرَات عاديَّة في هذه اللُّغة، مُطَبَّقَة كما الوظائف الأخرى. وبما أنَّهُ ليس لِلْنَحَوِيَّةِ مفهوم الحاجِز block، فسنحتاج البُنْيَة do للقيام بالعديد من الأمور. هيكليَّة البيانات الَّتي سييتخدمها المُعَرِّب لوصف البرنامج ستتألَّف من كائنات تعبيريَّة، وكل منها له خاصيَّة نوع تشير لصنف التعبير وخواصٌّ أخرى لوصف محتوياتها. تُمَثَّلُ التعابير من نوع "قيمة" "value" سلاسل نَصِّيَّة أو عدديَّة. وتحوي قيمة خاصيَّتها النص أو العدد الَّذي تُمَثَّله. تُستَخدم التعابير من نوع "كلمة" "word" من أجل المُعَرِّفات identifiers (أسماءٌ). وهكذا كائنات لها الخاصيَّة "إسم" name الَّتي تحمل إسم المُعَرِّف كَسلسلة نَصِّيَّة. وأخيرًا، تُمَثِّلُ تعابير "طَبِّق" apply التطبيقات. ولها الخاصيَّة مُعَامِل operator الَّتي تُشير إلى التعبير الَّذي يُطَبَّق، والخاصيَّة "جَدَلِيَّات -حُذفت بعض أحرف الكلمة الإنكليزيَّة-" args الَّتي تُشير إلى مصفوفة array من تعابير جدليَّة. وبذلك، سَيُمَثَّل جزء البرنامج السابق >(x, 5) هكذا:

{
    type:"apply",
    operator:{type:"word", name:">"},
    args:[
        {type:"word", name:"x"},
        {type:"value", value:5}
    ]
}

وهيكليَّة البيانات هذه تُدعى الشجرة النحويَّة syntax tree. فإذا تَخَيَّلتَ الكائنات كَنُقَط dots والروابط بينها كَخُطوط lines (بين هذه النقاط)، عندئذٍ سيكون شكلها شبيه-بالشجرة tree-like. فحقيقة إحتواء التعابير تعابيرًا أخرى، والَّتي بدورها قد تحوي تعابيرًا أكثر، إنَّهُ أمرٌ شبيه بتفرُّع الأغصان مُجَدَّدًا ومُجَدَّدًا.

وهذا بِتَبَايُنٍ مع المُعَرِّب الَّذي كتبناه لإعداد صيغة ملف في الفصل 9، والَّذي له هيكليَّة بسيطة: فهو يفصل الدخل input إلى أسطُر ويستوعِب هذه الأسطر كُلٌّ على حِدَىً. وكان يُسمَح للأسطُر أن تَملُك القليل فقط من الأشكال البسيطة. يجب علينا هُنَا أن نجد نَهْجًا مُختَلِفًا. فالتعابير ليست مفصولةً إلى أسطُر، ولها هيكليَّة تِكْرَارِيَّة recursive. وتحوي تعابير التطبيق تعابيرًا أخرى. وَلِحُسْنِ الحَظِّ، يمكن حَل هذه المُشكلة بِرُقِيٍّ من خلال كتابة دالَّة المُعَرِّب الَّتي هي تكراريَّة بطريقة تعكس طبيعة اللُّغة التكراريَّة. لقد عَرَّفنَا الدالَّة "أعرب التعبير" parseExpression، الَّتي تأخُذ سلسلة نَصِّيَّة كَدخل وتعود بكائن يحوي هيكليَّة بيانات التعبير الَّذي في بداية السلسلة النصيَّة، إضافةً لِجزء السلسلة النصيَّة المُتَبَقِّيَة بعد إعراب هذا التعبير. عند إعراب التعابير الفرعيَّة (كمثال، جدليَّة تعابيرٍ ما)، فإنَّ إعادة إستدعاء الدالَّة يبقى مُمكِنًا، وذلك مُنتِجٌ لِتعبير الجدليَّة بالإضافة إلى النص المُتَبَقِّي. وهذا النص بدوره يحوي المزيد من الجدليَّات أو رُبَّمَا يكون القوس الغالِق الَّذي يُنْهِي قائمة الجدليَّات.

هذا أَوَّلُ جُزءٍ من المُعَرِّب:

{
  program = skipSpace(program);
  var match, expr;
  if (match = /^"([^"]*)"/.exec(program)) 
    expr = {type:"value", value:match[1]};
  else if (match = /^\d+\b/.exec(program))
    expr = {type:"value", value:Number(match[0])};
  else if (match = /^[^\s(),"]+/.exec(program))
    expr = {type:"word", name:match[0]};
  else
    throw new SyntaxError("Unexpected syntax:"+program);
  return parseApply(expr, program.slice(match[0].length));
}
function skipSpace(string){
  var first = string.search(/\S/);
  if ( first == -1) return "";
  return string.slice(first);
}

وَبِسَبَبْ سماح Egg لأي كميَّة من المسافات البيضاء بين عناصِرها، سنحتاج لِقَصِّ المسافات البيضاء بتكرار من بداية سلسلة البرنامج النصيَّة. وهذا ما تُساعِد بِهِ الدالَّة "حذف مسافة" skipSpace. بعد حذف أي مسافة بِدئيَّة، تَستخدِم parseExpression ثلاثة تعابير نمطيَّة regular expressions لِإلقاء الضوء على العناصر الثلاثة البسيطة (المُتناهية الصغر) الَّتي تدعمُها Egg: السلاسل النصيَّة، الأعداد، والكَلِمات. فيقوم المُعَرِّب بِبناء نوع بيانات مُختلف إعتمادًا على الَّذي طابَقَه منها. وإذا لم يُطابِق الدخل أيٍّ من العناصر الثلاثة، سيكون تعابيرًا غير مسموحٍ به، وعندها يرمي المُعَرِّب خَطَأً. "خطأ نحوي" SyntaxError وهو نوع كائن خطأ قياسيٌّ، والَّذي يعتلي عند محاولة تشغيل برنامج جافا سكرِبت غير مسموحٍ به. يمكننا إقتصاص الجزء الَّذي طابقناه من البرنامج وتمرير البرنامج إضافةً لِكائن التعبير إلى "طَبِّق الإعراب" parseApply، والَّتي تتحقَّق فيما إذا كان التعبير تَطْبِيْقًا أم لا. فإذا كان تَطْبِيْقًا، تقوم بإعراب قائمة الجدليَّات المحصورة.

{
  program = skipSpace(program);
  if (program[0] != "(")
    return {expr:expr, rest:program};
  program = skipSpace(program.slice(1));
  expr = {type:"apply", operator:expr, args:[]};
  while (program[0] != ")"){
    var arg = parseExpression(program);
    expr.args.push(arg.expr);
    program = skipSpace(arg.rest);
    if (program[0] == ",")
      program = skipSpace(program.slice(1));
    else if (program[0] != ")")
      throw new SyntaxError("Expected', 'or')'");
  }
  return parseApply(expr, program.slice(1));
}

إذا لم يكن المَحرَف التالي في البرنامج قَوسًا مفتوحًا، فعندها هذا ليس بتطبيقٍ، وببساطة تعود parseApply بالتعبير الَّذي أُعطي لها بدايةً. بخلاف ذلك، فهي تتخطَّى القوس المفتوح وتُنشئ كائن الشَّجرة النحويَّة لتعبير هذا التطبيق. وبعدها تستدعي parseExpression بتكرارٍ لإعراب كل جدليَّة حتَّى إيجاد قوس الإغلاق. ويكون التكرار غير مباشَرٍ عبر parseApply وَإستدعاء parseExpression لنفسها مُجَدَّدًا. بسبب إمكانيَّة تطبيق تعبير التطبيق (كما في multiplier(2)(1))، يجب على parseApply أن تستدعي نفسها مجدَّدًا بعد أن تَعرُب التطبيق؛ للتحقُّق فيما إذا قد أُتبعَت بزوج آخر من الأقواس. هذا كل ما نحتاجُه لإعراب Egg. فقد قُمنا بِلَفِّهَا بدالَّة إعراب مُلائِمة، والَّتي وصلت نهاية السلسلة النصيَّة المُدخَلة بعد إعراب التعبير (فبرنامج Egg عبارة عن تعبيرٍ مُفرَد)، وهذا أعطانا هيكليَّة بيانات البرنامج.

{
  var result = parseExpression(program);
  if (skipSpace(result.rest).length>0)
    throw new SyntaxError("Unexpected text after program");
  return result.expr;
}
console.log(parse("+(a, 10)"));
//→ {type:"apply", 
// operator:{type:"word", name:"+"}, 
// args:[{type:"word", name:"a"}, 
// {type:"value", value:10}]}

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

المُقَدِّر The evaluator

بماذا تفيد الشَّجرة النحويَّة برنامجنا؟ تَشغِيلِهِ، بالطبع! وهذا ما يقوم به المُقَدِّر. فأنت تعطيه الشَّجرة النحويَّة وكائن البيئة الَّذي يربط الأسماء مع القيم، وسيُقَيِّم التعبير الَّذي تُمَثِّلُه الشَّجرة ويعود بالقيم المُنتَجَة.

function evaluate(expr, env){
  switch(expr.type){
    case "value":
      return expr.value;
    case "word":
      if (expr.name in env)
        return env[expr.name];
      else
        throw new ReferenceError("Undefined variable : "+expr.name);
    case "apply":
      if (expr.operator.type == "word" && 
          expr.operator.name in specialForms)
        return specialForms[expr.operator.name](expr.args, env);
      var op = evaluate(expr.operator, env);
      if (typeof op != "function")
         throw new TypeError("Applying a non-function.");
      return op.apply(null, expr.args.map(function(arg){
        return evaluate(arg, env);
      }));
  } 
} 
var specialForms = Object.create(null);

لدى المُقَدِّر رِماز(شِفرة) لكلِّ نوع من التعابير. فهو ببساطة يُنتج قيمة تعبير القيمة المَحرَفيَّة literal. (على سبيل المثال، يُقَدَّرُ التعبير 100 بالرَّقم 100). ومن أجل مُتَغَيِّرٍ ما، يجب أوَّلًا التحقُّق فيما إذا كان مُعَرَّفًا حقًّا في البيئة، فإذا كان كذلك، يقوم بالإيتاء بقيمة المُتَغَيِّر. والتطبيقات مُكْتَنَفة أكثر. فإذا كانت صياغةً خاصَّة، مثل if، لا نقوم بتقييم أي شيء وببساطة نُمَرِّر تعابير الجدليَّات، إضافة للبيئة، إلى الدالَّة المُستَوعِبَة لهذه الصياغة. وإذا كانت إستدعاءً عاديًّا، عندها نُقَدِّر المُعَاْمِل، مِمَّا يؤكِّد أنَّها دالَّة، وَتُستَدعَى عندئذٍ بنتيجة تقدير الجدليَّات. سنستخدم قيم دالَّة جافا سكرِبت بَحْتَة لتمثيل قيم دالَّة Egg. وسنعود لهذا لاحقًا، عندما تُعَرَّفُ الصيغة الخاصَّة الَّتي تُدعى fun. تُماثل البُنية التكراريَّة recursive لِلْمُقَدِّر بنية المُعَرِّب parser. فكلاهما يعكس بنية اللُّغة بحد ذاتها. ومن الممكن دمج المُعَرِّب مع المُقَدِّر وبالتالي التقدير خلال الإعراب، ولكن فصلهما بهذه الطريقة يجعل البرنامج مقروءًا أكثر. حقيقةً هذا كل ما يلزم لتفسير Egg. إنها بهذه البساطة. ولكن دون تعريف القليل من الصيغ الخاصَّة وإضافة بعض القيم المُفيدة إلى البيئة، لا يمكنك القيام بأي شيء بهذه اللُّغة حتَّى الآن.