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

إن بناء لغة برمجة خاصة بك سهل بشكل مفاجئ (ما لم تكن تطمح إلى الكثير) ومفيد جدًا لتوسيع مداركك.
الأمر الأساسي الذي أريد إظهاره في هذا الفصل هو أنه لا سحر في بناء لغة برمجة. كثيرًا ما شعرت بأن بعض اختراعات البشر بارعة ومعقدة إلى حد لا أستطيع معه فهمها أبدًا. لكن بقليل من القراءة والتجريب، يتبين غالبًا أنها عادية تمامًا.
سنبني لغة برمجة تُسمى Egg. ستكون لغة صغيرة وبسيطة، لكنها قوية بما يكفي للتعبير عن أي حساب يخطر ببالك. وستتيح تجريدًا بسيطًا قائمًا على الدوال.
التحليل
أكثر ما يتبادر إلى الذهن عند رؤية لغة برمجة هو الصياغة (syntax)، أو طريقة الكتابة. والمحلّل (parser) برنامج يقرأ قطعة نصية وينتج بنية بيانات تعكس بنية البرنامج المضمّن في ذلك النص. وإذا لم يكن النص برنامجًا صالحًا، فينبغي للمحلّل أن يشير إلى الخطأ.
ستكون للغتنا صياغة بسيطة ومنتظمة. كل شيء في Egg تعبير. ويمكن أن يكون التعبير اسم ارتباط، أو عددًا، أو نصًا، أو تطبيقًا (application). تُستخدم التطبيقات لاستدعاءات الدوال، وكذلك لتراكيب مثل if أو while.
لكي يبقى المحلّل بسيطًا، لا تدعم النصوص في Egg أي شيء مثل تسلسلات الهروب بالشرطة المائلة العكسية. فالنص ببساطة سلسلة من المحارف التي ليست علامات اقتباس مزدوجة، محاطة بعلامتي اقتباس مزدوجتين. والعدد سلسلة من الأرقام. ويمكن أن يتكوّن اسم الارتباط من أي محرف ليس مسافة بيضاء وليس له معنى خاص في الصياغة.
تُكتب التطبيقات كما تُكتب في JavaScript، بوضع قوسين بعد تعبير وإدراج أي عدد من المعطيات بين هذين القوسين، مفصولة بفواصل.
do(define(x, 10),
if(>(x, 5),
print("large"),
print("small")))
انتظام لغة Egg يعني أن الأشياء التي تكون معاملات في JavaScript (مثل >) هي ارتباطات عادية في هذه اللغة، تُطبَّق مثل غيرها من الدوال. ولأن الصياغة لا تعرف مفهوم الكتلة، نحتاج إلى تركيب do لتمثيل القيام بعدة أشياء بالتتابع.
تتكوّن بنية البيانات التي سيستخدمها المحلّل لوصف برنامج من كائنات تعبيرات، لكل منها خاصية type تشير إلى نوع التعبير، وخصائص أخرى لوصف محتواه.
تمثّل تعبيرات النوع "value" نصوصًا أو أعدادًا حرفية. وتحتوي خاصية value فيها على قيمة النص أو العدد الذي تمثّله. أما تعبيرات النوع "word" فتُستخدم للمعرّفات (الأسماء). ولهذه الكائنات خاصية name تحمل اسم المعرّف كنص. وأخيرًا، تمثّل تعبيرات "apply" التطبيقات. ولها خاصية operator تشير إلى التعبير الذي يُطبَّق، بالإضافة إلى خاصية args تحمل مصفوفة من تعبيرات المعطيات.
سيُمثَّل الجزء >(x, 5) من البرنامج السابق هكذا:
{
type: "apply",
operator: {type: "word", name: ">"},
args: [
{type: "word", name: "x"},
{type: "value", value: 5}
]
}
تُسمى بنية بيانات كهذه شجرة الصياغة (syntax tree). وإن تخيلت الكائنات نقاطًا والروابط بينها خطوطًا تصل بين تلك النقاط، كما في المخطط التالي، كانت البنية على شكل شجرة. وكون التعبيرات تحتوي تعبيرات أخرى قد تحتوي بدورها تعبيرات أكثر يشبه طريقة تفرّع أغصان الشجرة وتفرّعها من جديد.
قارن هذا بالمحلّل الذي كتبناه لصيغة ملف الإعدادات في الفصل 9، فقد كانت بنيته بسيطة: كان يقسّم المدخل إلى أسطر ويعالج تلك الأسطر سطرًا سطرًا. ولم يكن مسموحًا للسطر إلا بأشكال بسيطة قليلة.
هنا يجب أن نجد مقاربة مختلفة. فالتعبيرات لا تُفصل في أسطر، ولها بنية تعاودية. وتعبيرات التطبيق تحتوي تعبيرات أخرى.
لحسن الحظ، يمكن حل هذه المشكلة حلًا جيدًا بكتابة دالة محلّل تعاودية على نحو يعكس الطبيعة التعاودية للغة.
نعرّف دالة parseExpression تأخذ نصًا مدخلًا. وتُرجع كائنًا يحتوي بنية بيانات التعبير الواقع في بداية النص، مع الجزء المتبقي من النص بعد تحليل هذا التعبير. وعند تحليل تعبيرات فرعية (معطى تطبيق، مثلًا)، يمكن استدعاء هذه الدالة مرة أخرى، فتعطي تعبير المعطى مع النص المتبقي. وقد يحتوي هذا النص بدوره معطيات أكثر أو قد يكون قوس الإغلاق الذي ينهي قائمة المعطيات.
هذا هو الجزء الأول من المحلّل:
function parseExpression(program) {
program = skipSpace(program);
let 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) {
let first = string.search(/\S/);
if (first == -1) return "";
return string.slice(first);
}
لأن Egg، مثل JavaScript، تسمح بأي مقدار من المسافات البيضاء بين عناصرها، علينا أن نقتطع المسافات البيضاء من بداية نص البرنامج مرارًا. وتساعدنا دالة skipSpace في ذلك.
بعد تخطي أي مسافة بادئة، تستخدم parseExpression ثلاثة تعبيرات نمطية لالتقاط العناصر الذرية الثلاثة التي تدعمها Egg: النصوص والأعداد والكلمات. ويُنشئ المحلّل نوعًا مختلفًا من بنية البيانات بحسب التعبير المطابِق. وإن لم يطابق المدخل أحد هذه الأشكال الثلاثة، فهو ليس تعبيرًا صالحًا، ويُطلق المحلّل خطأ. نستخدم هنا البانية SyntaxError. فهذا صنف استثناء معرّف في المعيار، مثل Error، لكنه أكثر تحديدًا.
ثم نقتطع الجزء المطابِق من نص البرنامج ونمرّره، مع كائن التعبير، إلى parseApply، التي تتحقق مما إذا كان التعبير تطبيقًا. وإن كان كذلك، تحلّل قائمة معطيات بين قوسين.
function parseApply(expr, program) {
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] != ")") {
let 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. نلفّه في دالة parse مريحة تتحقق من بلوغ نهاية نص المدخل بعد تحليل التعبير (فبرنامج Egg تعبير واحد)، وتعيد لنا بنية بيانات البرنامج.
function parse(program) {
let {expr, rest} = parseExpression(program);
if (skipSpace(rest).length > 0) {
throw new SyntaxError("Unexpected text after program");
}
return expr;
}
console.log(parse("+(a, 10)"));
// → {type: "apply",
// operator: {type: "word", name: "+"},
// args: [{type: "word", name: "a"},
// {type: "value", value: 10}]}
إنها تعمل! لا تعطينا معلومات مفيدة كثيرًا عند الفشل ولا تخزّن السطر والعمود الذي يبدأ عنده كل تعبير، وهو ما قد يفيد عند الإبلاغ عن الأخطاء لاحقًا، لكنها جيدة بما يكفي لأغراضنا.
المُقيِّم
ماذا يمكننا أن نفعل بشجرة الصياغة لبرنامج؟ تشغيلها، بالطبع! وهذا ما يفعله المُقيِّم. تعطيه شجرة صياغة وكائن نطاق يربط الأسماء بالقيم، فيقيّم التعبير الذي تمثّله الشجرة ويُرجع القيمة التي ينتجها ذلك.
const specialForms = Object.create(null);
function evaluate(expr, scope) {
if (expr.type == "value") {
return expr.value;
} else if (expr.type == "word") {
if (expr.name in scope) {
return scope[expr.name];
} else {
throw new ReferenceError(
`Undefined binding: ${expr.name}`);
}
} else if (expr.type == "apply") {
let {operator, args} = expr;
if (operator.type == "word" &&
operator.name in specialForms) {
return specialForms[operator.name](expr.args, scope);
} else {
let op = evaluate(operator, scope);
if (typeof op == "function") {
return op(...args.map(arg => evaluate(arg, scope)));
} else {
throw new TypeError("Applying a non-function.");
}
}
}
}
للمُقيِّم شيفرة لكل نوع من أنواع التعبيرات. فتعبير القيمة الحرفية ينتج قيمته. (فمثلًا، التعبير 100 يُقيَّم إلى العدد 100.) أما الارتباط، فيجب أن نتحقق مما إذا كان معرّفًا فعلًا في النطاق، وإن كان كذلك نجلب قيمة الارتباط.
التطبيقات أكثر تعقيدًا. فإن كانت صيغة خاصة، مثل if، فلا نقيّم أي شيء، بل نمرّر ببساطة تعبيرات المعطيات، مع النطاق، إلى الدالة التي تعالج هذه الصيغة. وإن كان استدعاءً عاديًا، نقيّم المعامل ونتحقق من أنه دالة ونستدعيه بالمعطيات المقيَّمة.
نستخدم قيم دوال JavaScript العادية لتمثيل قيم دوال Egg. وسنعود إلى هذا لاحقًا، عند تعريف الصيغة الخاصة fun.
تشبه بنية evaluate التعاودية بنية المحلّل، وكلتاهما تعكسان بنية اللغة نفسها. وكان يمكن أيضًا دمج المحلّل والمُقيِّم في دالة واحدة والتقييم أثناء التحليل، لكن فصلهما بهذه الطريقة يجعل البرنامج أوضح وأكثر مرونة.
هذا في الحقيقة كل ما يلزم لتفسير Egg. بهذه البساطة. لكن دون تعريف بضع صيغ خاصة وإضافة بعض القيم المفيدة إلى البيئة، لن تستطيع فعل الكثير بهذه اللغة بعد.
الصيغ الخاصة
يُستخدم كائن specialForms لتعريف صياغة خاصة في Egg. فهو يربط الكلمات بدوال تقيّم تلك الصيغ. وهو فارغ حاليًا. لنضف if.
specialForms.if = (args, scope) => {
if (args.length != 3) {
throw new SyntaxError("Wrong number of args to if");
} else if (evaluate(args[0], scope) !== false) {
return evaluate(args[1], scope);
} else {
return evaluate(args[2], scope);
}
};
يتوقع تركيب if في Egg ثلاثة معطيات بالضبط. سيقيّم الأول، وإن لم تكن النتيجة القيمة false، سيقيّم الثاني. وإلا، يُقيَّم الثالث. وتشبه صيغة if هذه معامل الشرط الثلاثي ?: في JavaScript أكثر من if في JavaScript. فهي تعبير لا جملة، وتُنتج قيمة، أي نتيجة المعطى الثاني أو الثالث.
وتختلف Egg عن JavaScript أيضًا في طريقة تعاملها مع قيمة الشرط في if. فهي تعتبر القيمة false وحدها زائفة، لا أشياء مثل الصفر أو النص الفارغ.
سبب حاجتنا إلى تمثيل if كصيغة خاصة بدلًا من دالة عادية هو أن جميع معطيات الدوال تُقيَّم قبل استدعاء الدالة، بينما ينبغي لـ if أن يقيّم إما معطاه الثاني أو الثالث فقط، بحسب قيمة الأول.
وصيغة while مشابهة.
specialForms.while = (args, scope) => {
if (args.length != 2) {
throw new SyntaxError("Wrong number of args to while");
}
while (evaluate(args[0], scope) !== false) {
evaluate(args[1], scope);
}
// بما أن undefined غير موجود في Egg، نُرجع false
// لانعدام نتيجة ذات معنى
return false;
};
لبنة أساسية أخرى هي do، التي تنفّذ جميع معطياتها من الأعلى إلى الأسفل. وقيمتها هي القيمة التي ينتجها المعطى الأخير.
specialForms.do = (args, scope) => {
let value = false;
for (let arg of args) {
value = evaluate(arg, scope);
}
return value;
};
لكي نستطيع إنشاء ارتباطات وإعطاءها قيمًا جديدة، نُنشئ أيضًا صيغة تُسمى define. تتوقع كلمة كمعطى أول وتعبيرًا ينتج القيمة التي ستُسند إلى تلك الكلمة كمعطى ثان. ولأن define، مثل كل شيء، تعبير، فيجب أن تُرجع قيمة. سنجعلها تُرجع القيمة التي أُسندت (تمامًا مثل معامل = في JavaScript).
specialForms.define = (args, scope) => {
if (args.length != 2 || args[0].type != "word") {
throw new SyntaxError("Incorrect use of define");
}
let value = evaluate(args[1], scope);
scope[args[0].name] = value;
return value;
};
البيئة
النطاق الذي تقبله evaluate كائن خصائصه أسماء تقابل أسماء الارتباطات وقيمها تقابل القيم التي ترتبط بها تلك الارتباطات. لنعرّف كائنًا يمثّل النطاق العام.
لكي نستطيع استخدام تركيب if الذي عرّفناه للتو، يجب أن نصل إلى القيم المنطقية. ولأن القيم المنطقية اثنتان فقط، لا نحتاج إلى صياغة خاصة لهما. نكتفي بربط اسمين بالقيمتين true وfalse واستخدامهما.
const topScope = Object.create(null);
topScope.true = true;
topScope.false = false;
يمكننا الآن تقييم تعبير بسيط ينفي قيمة منطقية.
let prog = parse(`if(true, false, true)`);
console.log(evaluate(prog, topScope));
// → false
لتوفير معاملات حسابية ومقارنات أساسية، سنضيف أيضًا بعض قيم الدوال إلى النطاق. ولكي نبقي الشيفرة قصيرة، سنستخدم Function لتوليد مجموعة من دوال المعاملات في حلقة بدلًا من تعريفها واحدة واحدة.
for (let op of ["+", "-", "*", "/", "==", "<", ">"]) {
topScope[op] = Function("a, b", `return a ${op} b;`);
}
من المفيد أيضًا وجود طريقة لإخراج القيم، لذا سنلفّ console.log في دالة ونسميها print.
topScope.print = value => {
console.log(value);
return value;
};
يعطينا ذلك أدوات أولية كافية لكتابة برامج بسيطة. وتوفّر الدالة التالية طريقة مريحة لتحليل برنامج وتشغيله في نطاق جديد:
function run(program) {
return evaluate(parse(program), Object.create(topScope));
}
سنستخدم سلاسل النماذج الأولية (prototype) للكائنات لتمثيل النطاقات المتداخلة، حتى يستطيع البرنامج إضافة ارتباطات إلى نطاقه المحلي دون تغيير النطاق الأعلى.
run(`
do(define(total, 0),
define(count, 1),
while(<(count, 11),
do(define(total, +(total, count)),
define(count, +(count, 1)))),
print(total))
`);
// → 55
هذا هو البرنامج نفسه الذي رأيناه عدة مرات من قبل، والذي يحسب مجموع الأعداد من 1 إلى 10، معبَّرًا عنه بـ Egg. من الواضح أنه أقبح من برنامج JavaScript المكافئ، لكنه ليس سيئًا للغة منفَّذة في أقل من 150 سطرًا من الشيفرة.
الدوال
لغة برمجة بلا دوال لهي لغة فقيرة حقًا. لحسن الحظ، ليس من الصعب إضافة تركيب fun، الذي يعامل معطاه الأخير كجسم الدالة ويستخدم كل المعطيات قبله كأسماء لوسائط الدالة.
specialForms.fun = (args, scope) => {
if (!args.length) {
throw new SyntaxError("Functions need a body");
}
let body = args[args.length - 1];
let params = args.slice(0, args.length - 1).map(expr => {
if (expr.type != "word") {
throw new SyntaxError("Parameter names must be words");
}
return expr.name;
});
return function(...args) {
if (args.length != params.length) {
throw new TypeError("Wrong number of arguments");
}
let localScope = Object.create(scope);
for (let i = 0; i < args.length; i++) {
localScope[params[i]] = args[i];
}
return evaluate(body, localScope);
};
};
تحصل الدوال في Egg على نطاق محلي خاص بها. فالدالة التي تنتجها صيغة fun تنشئ هذا النطاق المحلي وتضيف إليه ارتباطات المعطيات. ثم تقيّم جسم الدالة في هذا النطاق وتُرجع النتيجة.
run(`
do(define(plusOne, fun(a, +(a, 1))),
print(plusOne(10)))
`);
// → 11
run(`
do(define(pow, fun(base, exp,
if(==(exp, 0),
1,
*(base, pow(base, -(exp, 1)))))),
print(pow(2, 10)))
`);
// → 1024
التصريف
ما بنيناه هو مفسّر (interpreter). فهو أثناء التقييم يعمل مباشرة على تمثيل البرنامج الذي أنتجه المحلّل.
التصريف (compilation) عملية إضافة خطوة أخرى بين تحليل البرنامج وتشغيله، تحوّل البرنامج إلى شيء يمكن تقييمه بكفاءة أكبر بإنجاز أكبر قدر ممكن من العمل مسبقًا. فمثلًا، في اللغات جيدة التصميم يكون واضحًا، عند كل استخدام لارتباط، أي ارتباط يُقصد، دون تشغيل البرنامج فعليًا. ويمكن استخدام ذلك لتجنب البحث عن الارتباط باسمه في كل مرة يُوصَل إليها، والجلب مباشرة من موضع محدد مسبقًا في الذاكرة.
يشمل التصريف تقليديًا تحويل البرنامج إلى شيفرة الآلة (machine code)، وهي الصيغة الخام التي يستطيع معالج الحاسوب تنفيذها. لكن أي عملية تحوّل برنامجًا إلى تمثيل مختلف يمكن اعتبارها تصريفًا.
كان من الممكن كتابة استراتيجية تقييم بديلة لـ Egg، تحوّل البرنامج أولًا إلى برنامج JavaScript، وتستخدم Function لاستدعاء مصرّف JavaScript عليه، ثم تشغّل النتيجة. ولو أُنجز ذلك بإتقان، لجعل تشغيل Egg سريعًا جدًا مع بقاء تنفيذه بسيطًا إلى حد كبير.
إن كان هذا الموضوع يثير اهتمامك وكنت مستعدًا لقضاء بعض الوقت عليه، فأشجعك على محاولة تنفيذ مصرّف كهذا كتمرين.
الغش
عندما عرّفنا if وwhile، لاحظت على الأرجح أنهما غلافان بديهيان إلى حد ما حول if وwhile في JavaScript نفسها. وبالمثل، ليست القيم في Egg سوى قيم JavaScript عادية. أما جسر الفجوة نحو نظام أكثر بدائية، مثل شيفرة الآلة التي يفهمها المعالج، فيتطلب جهدًا أكبر، لكن طريقة عمله تشبه ما نفعله هنا.
رغم أن اللغة اللعبة في هذا الفصل لا تفعل شيئًا لا يمكن فعله بشكل أفضل في JavaScript، فإن هناك حالات يساعد فيها كتابة لغات صغيرة على إنجاز عمل حقيقي.
لا يلزم أن تشبه لغة كهذه لغة برمجة نموذجية. فلو لم تكن JavaScript مزودة بالتعبيرات النمطية، مثلًا، لأمكنك كتابة محلّل ومقيّم خاصين بك للتعبيرات النمطية.
أو تخيل أنك تبني برنامجًا يتيح إنشاء المحلّلات سريعًا بتقديم وصف منطقي للغة التي تحتاج إلى تحليلها. يمكنك تعريف تدوين خاص لذلك، ومصرّف يصرفه إلى برنامج محلّل.
expr = number | string | name | application
number = digit+
name = letter+
string = '"' (! '"')* '"'
application = expr '(' (expr (',' expr)*)? ')'
هذا ما يسمى عادة لغة خاصة بمجال (domain-specific language)، وهي لغة مصممة للتعبير عن مجال معرفي ضيق. ويمكن أن تكون لغة كهذه أكثر تعبيرًا من لغة عامة الأغراض لأنها مصممة لوصف الأشياء التي يلزم وصفها في مجالها بالضبط ولا شيء غيرها.
التمارين
المصفوفات
أضف دعم المصفوفات إلى Egg بإضافة الدوال الثلاث التالية إلى النطاق الأعلى: array(...values) لإنشاء مصفوفة تحتوي قيم المعطيات، وlength(array) للحصول على طول مصفوفة، وelement(array, n) لجلب العنصر رقم n من مصفوفة.
// عدّل هذه التعريفات...
topScope.array = "...";
topScope.length = "...";
topScope.element = "...";
run(`
do(define(sum, fun(array,
do(define(i, 0),
define(sum, 0),
while(<(i, length(array)),
do(define(sum, +(sum, element(array, i))),
define(i, +(i, 1)))),
sum))),
print(sum(array(1, 2, 3))))
`);
// → 6
إظهار التلميح
أسهل طريقة لفعل ذلك هي تمثيل مصفوفات Egg بمصفوفات JavaScript.
يجب أن تكون القيم المضافة إلى النطاق الأعلى دوال. وباستخدام معطى تجميعي (rest argument، بترميز النقاط الثلاث)، يمكن أن يكون تعريف array بالغ البساطة.
الإغلاق
الطريقة التي عرّفنا بها fun تتيح للدوال في Egg الإشارة إلى النطاق المحيط، مما يسمح لجسم الدالة باستخدام قيم محلية كانت مرئية وقت تعريف الدالة، تمامًا كما تفعل دوال JavaScript.
يوضح البرنامج التالي ذلك: الدالة f تُرجع دالة تضيف معطاها إلى معطى f، أي أنها تحتاج إلى الوصول إلى النطاق المحلي داخل f لتتمكن من استخدام الارتباط a.
run(`
do(define(f, fun(a, fun(b, +(a, b)))),
print(f(4)(5)))
`);
// → 9
عُد إلى تعريف صيغة fun واشرح الآلية التي تجعل هذا يعمل.
إظهار التلميح
مرة أخرى، نستفيد من آلية في JavaScript للحصول على الميزة المكافئة في Egg. تُمرَّر إلى الصيغ الخاصة النطاق المحلي الذي تُقيَّم فيه حتى تستطيع تقييم صيغها الفرعية في ذلك النطاق. والدالة التي تُرجعها fun لديها وصول إلى المعطى scope المُعطى لدالتها المحيطة، وتستخدمه لإنشاء النطاق المحلي للدالة عند استدعائها.
هذا يعني أن النموذج الأولي للنطاق المحلي سيكون النطاق الذي أُنشئت فيه الدالة، مما يجعل من الممكن الوصول إلى الارتباطات في ذلك النطاق من داخل الدالة. هذا كل ما يلزم لتنفيذ الإغلاق (وإن كنت تحتاج إلى عمل إضافي لتصريفه على نحو فعّال حقًا).
التعليقات
سيكون جميلًا لو استطعنا كتابة تعليقات في Egg. فمثلًا، كلما وجدنا علامة الهاش (#)، يمكننا معاملة بقية السطر كتعليق وتجاهله، على غرار // في JavaScript.
لا يلزم إجراء تغييرات كبيرة على المحلّل لدعم ذلك. يكفي أن نغيّر skipSpace لتتخطى التعليقات كما لو كانت مسافات بيضاء، بحيث تتخطى جميع المواضع التي تُستدعى فيها skipSpace التعليقات أيضًا. أجرِ هذا التغيير.
// هذه دالة skipSpace القديمة. عدّلها...
function skipSpace(string) {
let first = string.search(/\S/);
if (first == -1) return "";
return string.slice(first);
}
console.log(parse("# hello\nx"));
// → {type: "word", name: "x"}
console.log(parse("a # one\n # two\n()"));
// → {type: "apply",
// operator: {type: "word", name: "a"},
// args: []}
إظهار التلميح
تأكد من أن حلك يعالج تعليقات متعددة متتالية، مع احتمال وجود مسافات بيضاء بينها أو بعدها.
التعبير النمطي هو على الأرجح أسهل طريقة لحل هذا. اكتب شيئًا يطابق «مسافة بيضاء أو تعليقًا، صفر مرة أو أكثر». استخدم طريقة exec أو match وانظر إلى طول العنصر الأول في المصفوفة المُرجعة (المطابقة الكاملة) لمعرفة عدد المحارف التي يجب اقتطاعها.
إصلاح النطاق
حاليًا، الطريقة الوحيدة لإسناد قيمة إلى ارتباط هي define. ويعمل هذا التركيب كوسيلة لتعريف ارتباطات جديدة ولإعطاء الارتباطات الموجودة قيمة جديدة في آن واحد.
يسبب هذا الغموض مشكلة. فعندما تحاول إعطاء ارتباط غير محلي قيمة جديدة، ينتهي بك الأمر إلى تعريف ارتباط محلي بالاسم نفسه بدلًا من ذلك. بعض اللغات تعمل بهذه الطريقة عن قصد، لكني وجدتها دائمًا طريقة ركيكة للتعامل مع النطاق.
أضف صيغة خاصة set، مشابهة لـ define، تعطي ارتباطًا قيمة جديدة، وتحدّث الارتباط في نطاق خارجي إن لم يكن موجودًا بالفعل في النطاق الداخلي. وإن لم يكن الارتباط معرّفًا إطلاقًا، فأطلق ReferenceError (نوع خطأ معياري آخر).
ستقف في طريقك قليلًا هنا تقنية تمثيل النطاقات بكائنات بسيطة، التي جعلت الأمور مريحة حتى الآن. قد ترغب في استخدام الدالة Object.getPrototypeOf، التي تُرجع النموذج الأولي لكائن. وتذكّر أيضًا أنك تستطيع استخدام Object.hasOwn لمعرفة ما إذا كان لكائن معين خاصية ما.
specialForms.set = (args, scope) => {
// شيفرتك هنا.
};
run(`
do(define(x, 4),
define(setx, fun(val, set(x, val))),
setx(50),
print(x))
`);
// → 50
run(`set(quux, true)`);
// → Some kind of ReferenceError
إظهار التلميح
سيكون عليك التنقل عبر النطاقات واحدًا تلو الآخر، باستخدام Object.getPrototypeOf للانتقال إلى النطاق الخارجي التالي. وفي كل نطاق، استخدم Object.hasOwn لمعرفة ما إذا كان الارتباط، المشار إليه بخاصية name في المعطى الأول لـ set، موجودًا في ذلك النطاق. فإن كان موجودًا، عيّنه إلى نتيجة تقييم المعطى الثاني لـ set ثم أعد تلك القيمة.
إذا بلغنا النطاق الأبعد (Object.getPrototypeOf تُرجع null) ولم نجد الارتباط بعد، فهو غير موجود، وينبغي إطلاق خطأ.