فصل 12پروژه: نوشتن یک زبان برنامهنویسی
ارزیاب، که معنای عبارتها در یک زبان برنامهنویسی را مشخص میکند، خود نیز یک برنامه است.
نوشتن زبان برنامهنویسی خودتان به طرز شگفتآوری آسان و آموزنده است (البته تا زمانی که سطح انتظارتان زیاد بالا نباشد).
هدف اصلی من در این فصل این است که به شما نشان دهم نوشتن یک زبان برنامهنویسی، چیزی ماوارایی و جادویی نیست. خیلی وقتها احساس میکردم که بعضی از اختراعات انسانها بسیار هوشمندانه و پیچیده است و من هرگز نمیتوانم آن ها را درک کنم. اما با کمی مطالعه و آزمایش، اغلب متوجه می شدم که اتفاقا قابل درک و ساده هستند.
در این فصل یک زبان برنامهنویسی به نام Egg خواهیم ساخت. این زبان بسیار ساده و کوچک خواهد بود – اما به اندازهی کافی قدرتمند میباشد تا بتواند هر محاسبهای که تصور میکنید را انجام دهد. در این زبان میتوان با استفاده از توابع، تجریدهای ساده را به وجود آورد.
تجزیه (Parsing)
در چشم ترین قسمت یک زبان برنامهنویسی، گرامر (syntax) یا شیوهی نشانگذاری آن است. یک تجزیهگر (parser) برنامهای است که متنی را خوانده و ساختار دادهای تولید میکند که نمایانگر ساختار برنامهای است که در آن متن قرار دارد. اگر آن متن یک برنامهی معتبر را شکل نداده باشد، تجزیهگر باید خطایی تولید کند.
زبان برنامهنویسی ما گرامری ساده و یکپارچه خواهد داشت. همه چیز در زبان Egg از جنس عبارت خواهند بود. یک عبارت میتواند نام یک متغیر، یک عدد، یک رشته، یا یک کاربرد (application) باشد. کاربردها برای فراخوانی توابع استفاده میشوند؛ همچنین برای ساختارهایی مثل if
و while
نیز از آنها بهره میبریم.
برای این که تجزیهگر را ساده نگه داریم، رشتهها در زبان Egg چیزهایی مثل گریز (Escape) با بکاسلش را پشتیبانی نمیکنند. یک رشته دنبالهای از کاراکترها به جز نقل قول جفتی میباشد که خود توسط نقل قول جفتی محصور میشود. یک عدد برابر با دنبالهای از ارقام است. در نام متغیرها میتوان از هر کاراکتری به جز فضای خالی و کاراکترهایی که معنای خاصی در گرامر زبان دارند استفاده نمود.
کاربردها به همان سبکی که در جاوااسکریپت هستند نوشته میشوند: بعد از یک عبارت پرانتز قرار میگیرد که درون آن هر تعداد آرگومان مجاز میباشد و به وسیلهی ویرگول از هم جدا میشوند.
do(define(x, 10), if(>(x, 5), print("large"), print("small")))
یکپارچگی موجود در زبان Egg به این معنا است که چیزهایی که در جاوااسکریپت به عنوان عملگر محسوب می شدند (مثل >
)، در این زبان متغیرهایی معمولی میباشند که مثل دیگر قابلیتها به کار گرفته میشوند. و به دلیل اینکه در گرامر این زبان مفهومی به نام بلاک وجود ندارد، به ساختاری به نام 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} ] }
این گونه از ساختار داده را درخت گرامر می گویند. اگر اشیاء را به عنوان نقطه در نظر بگیرید و پیوندهای بینشان را به عنوان خطوط بین نقطهها، نمای آن شبیه به درخت خواهد بود. این واقعیت که عبارتها خود از عبارتهای دیگری تشکیل میشوند ، که آن ها هم ممکن است شامل عبارتهای دیگری باشند ، شبیه به شاخههای درخت است که خود دارای شاخههای دیگری میباشند.
این را با تجزیهگری که برای فایل تنظیمات در فصل 9 نوشتیم مقایسه کنید، که ساختاری ساده داشت: ورودی را به خطوطی تقسیم می کرد و همهی آن خطوط را یکی یکی رسیدگی مینمود. خطوط فقط میتوانستند شکلهای محدودی داشته باشند.
در اینجا باید راه حل دیگری پیدا کنیم. عبارتها توسط خطوط جدا نمیشوند و ساختاری بازگشتی دارند. عبارتهای کاربردی (application) حاوی عبارتهای دیگر میباشند.
خوشبختانه، این مشکل را میتوان به خوبی با نوشتن یک تابع بازگشتی تجزیهگر حل نمود به صورتی که نمایانگر ماهیت بازگشتی زبان باشد.
تابعی به نام 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 مانند جاوااسکریپت امکان استفاده از فضاهای خالی بین عناصر را مجاز می شمرد، می بایست مکررا فضاهای خالی را از ابتدای یک رشتهی برنامه حذف کنیم. این کار توسط تابع skipSpace
صورت میگیرد.
بعد از چشمپوشی از فضاهای خالی ابتدایی، تابع parseExpression
از سه عبارت باقاعده برای شناسایی سه عنصر اساسی که زبان Egg از آنها پشتیبانی میکند، استفاده میکند؛ شامل: رشتهها، اعداد و کلمهها. تجزیهگر با توجه به اینکه کدام یک از آن عناصر تطبیق بخورد ساختاردادهی متفاوتی را تولید میکند. اگر ورودی با هیچ کدام از آن سه شکل تطبیق نخورد، آن عبارت معتبر نخواهد بود و تجزیهگر یک خطا تولید میکند. ما از SyntaxError
به جای Error
به عنوان تابع سازنده استثنا استفاده میکنیم که یک نوع خطای استاندارد دیگر است، به این دلیل که این نوع کمی اختصاصی تر است – همچنین این نوع خطا زمانی تولید میشود که تلاشی برای اجرای یک برنامه نامعتبر جاوااسکریپت صورت گرفته باشد.
سپس آن قسمت که تطبیق خورده است را از رشتهی برنامه حذف میکنیم و رشته را به همراه شیء متعلق به عبارت، به parseApply
ارسال میکنیم که بررسی کند عبارت از نوع کاربرد (application) باشد. اگر بود، تابع قسمت داخل پرانتز را – لیست آرگومانها – تجزیه میکند.
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
ارزیابی میشود) برای یک متغیر، باید بررسی کنیم که در قلمروی مورد نظر تعریف شده باشد و در این صورت مقدار آن متغیر را بدست بیاوریم.
کاربردها (Applications) کمی پیچیدهتر هستند. اگر در شکل خاصی باشند، مانند if
، چیزی را ارزیابی نمیکنیم و عبارتهای آرگومان را به همراه قلمرو به تابعی که این شکل را رسیدگی میکند ارسال میکنیم. اگر یک فراخوانی معمولی باشد، عملگر را ارزیابی کرده، اطمینان حاصل میکنیم که یک تابع باشد، و آن را با آرگومانهای ارزیابی شده فراخوانی میکنیم.
برای نمایش مقدارهای تابع Egg از مقدارهای تابع جاوااسکریپت استفاده خواهیم کرد. در ادامه بعد از اینکه شکل خاصی که fun
نامیده میشود تعریف شده باشد، به این بخش باز خواهیم گشت.
ساختار بازگشتی evaluate
به ساختاری مشابه تجزیهگر نزدیک است و هر دو بازتاب ساختار خود زبان هستند. همچنین میتوانستیم ارزیاب و تجزیهگر را یکپارچه کنیم و در حین تجزیه، ارزیابی را نیز انجام دهیم، اما جدا کردن آن ها به این سبک باعث شفافیت بیشتر برنامه میشود.
این تمام چیزی است که برای تفسیر Egg مورد نیاز است. به همین سادگی. اما هنوز بدون تعریف چندین شکل خاص و افزودن چند مقدار مفید به محیط، نمیتوان کار زیادی با این زبان انجام داد.
شکلهای خاص
شیء specialForm
برای تعریف گرامر ویژه در 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
بیشتر شبیه به عملگر سهتایی ?:
در جاوااسکریپت است تا دستور if
در آن. این یک عبارت است، نه یک دستور و مقداری را تولید میکند که همان نتیجهی آرگومان دوم و سوم میباشد.
Egg همچنین در چگونگی رسیدگی به مقدار شرط در عبارت if
با جاواسکریپت تفاوت دارد. این عبارت چیزهایی مثل صفر یا رشتهی خالی را false
در نظر نمیگیرد، فقط مقدار دقیق 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); } // Since undefined does not exist in Egg, we return false, // for lack of a meaningful result. return false; };
یکی دیگر از بلاکها ساختاری پایه do
است که تمامی آرگومانهایش را از بالا به پایین اجرا میکند. مقدار آن برابر با مقداری است که توسط آرگومان آخر تولید می شود.
specialForms.do = (args, scope) => { let value = false; for (let arg of args) { value = evaluate(arg, scope); } return value; };
برای این که قادر باشیم تا متغیرهایی ایجاد کنیم و مقادیر جدیدی را به آن ها اختصاص دهیم، همچنین نیاز به تعریف شکلی به نام define
داریم. این شکل به عنوان آرگومان اول یک واژه را دریافت میکند و به عنوان آرگومان دوم، عبارتی را که منجر به تولید مقداری میشود که قرار است به آن واژه منتسب شود. به دلیل این که define
مثل هر چیز دیگر، یک عبارت است باید مقداری را برگرداند. ما طوری آن را می سازیم که مقداری که به آن انتساب یافته را برگرداند (درست شبیه عملگر =
در جاوااسکریپت).
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)); }
ما از زنجیرهی پروتوتایپ شیء برای نمایش قلمروهای تودرتو استفاده خواهیم کرد، تا برنامه بتواند متغیرهای خودش را به قلمروی محلی بدون ایجاد تغییر در قلمروی بالادست اضافه کند.
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 نوشته شده است. قطعا ظاهر این برنامه از برنامهی معادل جاوااسکریپتش زشتتر است – اما برای زبان برنامهنویسیای که با کمتر از 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() { if (arguments.length != params.length) { throw new TypeError("Wrong number of arguments"); } let localScope = Object.create(scope); for (let i = 0; i < arguments.length; i++) { localScope[params[i]] = arguments[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
کامپایل کردن
چیزی که تاکنون ساختهایم یک مفسر است. در طول ارزیابی، این مفسر به طور مستقیم روی چیزی که توسط تجزیهگر تولید شده عمل می نماید.
کامپایل کردن روندی است که در آن گامی دیگر بین تفسیر و اجرای برنامه اضافه می شود، که کد برنامه را به چیزی که بتوان با کارایی بیشتری ارزیابی کرد تبدیل میکند و این کار با انجام حداکثر کار ممکن قبل از مرحلهی ارزیابی میسر میشود. به عنوان مثال، در زبانهایی که خوب طراحی شده اند، در زمان استفاده از یک متغیر، به روشنی می توان فهمید که کدام متغیر مورد اشاره است، بدون اینکه نیاز باشد برنامه واقعا اجرا شود. این کار باعث میشود که از جستجوی متغیر بر اساس نام در هر بار استفاده از آن جلوگیری شود، و به جای آن مستقیما آن را از قسمتهای مشخصی از حافظه بهدست بیاوریم.
به طور سنتی، کامپایل شامل تبدیل برنامه به کد ماشین میشود، فرمت خامی که یک پردازندهی کامپیوتر میتواند اجرا کند. اما هر روندی که برنامه را به نمایش متفاوتی تبدیل کند را میتوان به عنوان کامپایل در نظر گرفت.
میتوان یک استراتژی ارزیابی جایگزین برای Egg، نوشت که در آن ابتدا برنامه را به یک برنامهی جاوااسکریپت تبدیل کند، از Function
برای فراخوانی جاوااسکریپت برای کامپایل آن استفاده کند و سپس نتیجه را اجرا نماید. اگر این کار به درستی انجام شود باعث می شود که Egg خیلی سریع تر اجرا شود در حالیکه سادگی پیادهسازی را هنوز با خود دارد.
اگر به این موضوع علاقه دارید و قصد دارید مقداری زمان صرف آن کنید، پیشنهاد می کنم این کامپایلری که ذکر شد را به عنوان یک تمرین پیاده سازی کنید.
تقلب
زمانی که if
و while
را تعریف کردیم، احتمالا متوجه شدید که این دو، پوششی کم و بیش ساده برای if
و while
خود جاواسکریپت بودند. به طور مشابه، مقدارها در Egg همان مقدارهای معمولی جاوااسکریپت هستند.
اگر پیادهسازی Egg را که بر اساس جاوااسکریپت ساخته شده است با میزان کار و پیچیدگی لازم برای ساخت یک زبان برنامهنویسی که مستقیما برپایهی قابلیتهای خام ماشین است، مقایسه کنید، تفاوت خیلی قابل توجه است. صرف نظر از آن، امیدوارم این مثال درکی از روش کارکرد زبانهای برنامهنویسی را به شما منتقل کرده باشد.
و زمانیکه لازم است کاری انجام شود، تقلب کردن موثرتر از این است که همه چیز را خودتان انجام دهید. البته زبانی که برای آموزش در این فصل ایجاد شد کاری را انجام نمیدهد که از معادلش در جاوااسکریپت بهتر عمل کند اما موقعیتهایی وجود دارد که نوشتن زبانهای کوچک برای انجام کارهای واقعی کاربرد دارد.
نیازی نیست این گونه زبانها شبیه به یک زبان برنامهنویسی متداول باشند. مثلا اگر جاوااسکریپت از عبارتهای باقاعده به صورت درونی پشتیبانی نمی کرد، میتوانستید مفسر و ارزیاب خودتان را برای عبارات باقاعده بنویسید.
تصور کنید که در حال ساخت یک دایناسور روباتیک غول پیکر هستید و لازم است تا رفتار آن را برنامهنویسی کنید. جاوااسکریپت ممکن است موثر ترین روش این کار نباشد، ممکن است در عوض به دنبال زبانی شبیه به زیر باشید.
behavior walk perform when destination ahead actions move left-foot move right-foot behavior attack perform when Godzilla in-view actions fire laser-eyes launch arm-rockets
به این گونه زبانها، معمولا زبانی با دامنهی خاص گفته میشود؛ زبانی که برای بیان دامنهی کوچکی از اطلاعات تدارک دیده میشود. این گونه زبانها میتوانند نسبت به یک زبان متداول عام رساتر باشند زیرا طراحی آنها دقیقا برای توصیف چیزهایی بوده است که در دامنهشان وجود دارد نه چیز دیگر.
تمرینها
آرایهها
پشتیبانی از آرایهها را به Egg اضافه کنید و این کار را با افزودن سه تابع پیش رو به قلمروی بالایی انجام دهید: تابع array(...values)
برای ساختن آرایهای که حاوی مقدارهای آرگومان است، length(array)
برای گرفتن طول یک آرایه و element(array, n)
برای به دست آوردن nth عنصر آرایه.
// Modify these definitions... 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
بستار (Closure)
روشی که برای تعریف fun
استفاده کردیم به توابع در Egg این امکان را میدهد که به قلمروی پیرامونشان بتوانند ارجاع دهند، به این صورت که به بدنهی تابع این امکان را میدهد تا از مقدارهای محلی که در زمان تعریف تابع قابل مشاهده بوده اند استفاده کند درست شبیه کاری که توابع در جاوااسکریپت انجام میدهند.
برنامهی پیش رو این مفهوم را نشان میدهد: تابع f
یک یک تابع دیگر برمیگرداند؛ تابعی که آرگومانهایش را با آرگومانهای f
جمع می نماید، به این معنا که برای انجام این کار باید بتواند به قلمروی محلی درون f
دسترسی داشته باشد تا از مقدار متغیر a
استفاده کند.
run(` do(define(f, fun(a, fun(b, +(a, b)))), print(f(4)(5))) `); // → 9
به قسمت تعریف fun
برگردید و توضیح دهید چه مکانیزمی باعث این رفتار شده است.
میدانیم که ما از مکانیزم جاوااسکریپت برای ساخت یک قابلیت مشابه در Egg بهره میبریم. قلمروی محلی به صورتی که ارزیابی میشوند به شکلهای خاص داده میشوند در نتیجه شکلهای خاص میتوانند زیرشکلهای خودشان را در همان قلمرو ارزیابی کنند. تابعی که توسط fun
برگردانده میشود به آرگومان scope
ای (قلمرویی) که به تابع محصورش داده میشود دسترسی دارد و از آن برای ایجاد قلمروی محلیاش در هنگام فراخوانی استفاده میکند.
معنای آن این است که خمیرمایهی قلمروی محلی برابر با قلمرویی خواهد بود که در آن تابع ایجاد شده است، که موجب میشود بتوان به متغیرهای آن از درون تابع دسترسی داشت. این تمام چیزی است که در پیادهسازی بستار وجود دارد (اگرچه برای کامپایل آن به صورتی که بهینه عمل کند لازم است تا کارهای بیشتری انجام شود).
توضیحات
خوب می شد اگر میتوانستیم در Egg توضیحات بنویسیم. مثلا، هر بار که به علامت (#
) برسیم، بقیهی خط را به عنوان یک توضیح در نظر بگیریم شبیه به //
در جاوااسکریپت.
نیازی به ایجاد تغییرات بزرگی در تجزیهگر برای پشتیبانی از توضیحات نیست. می توانیم skipSpace
را تغییر داده تا توضیحات را همان طور که از فضاهای خالی صرف نظر میشود، پردازش نکند؛ بنابراین در تمامی نقاطی که تابع skipSpace
فراخوانی می شود اکنون توضیحات نیز شناسایی و صرف نظر میشوند. این تغییر را اعمال کنید.
// This is the old skipSpace. Modify it... 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.
استفاده کنید، که پروتوتایپ یک شیء را بر می گرداند. همچنین به خاطر داشته باشید که قلمروها از Object.prototype
مشتق نمیشوند، بنابراین اگر می خواهید تا hasOwnProperty
را روی آن ها فراخوانی کنید، باید از این عبارت بدترکیب استفاده کنید.
Object.prototype.hasOwnProperty.call(scope, name);
specialForms.set = (args, scope) => { // Your code here. }; 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.
یک قلمرو را پیمایش نمایید تا به قلمروی بیرونیتر برسید. برای هر قلمرو، از متد hasOwnProperty
برای بررسی وجود متغیر، که با خاصیت name
در اولین آرگومان set
مشخص شده است، در قلمرو استفاده کنید. اگر وجود داشت، آن را برابر نتیجهی ارزیابی آرگومان دوم set
قرار دهید و آن مقدار را برگردانید.
اگر به بیرونیترین قلمرو برسید ( که در این صورت Object.
مقدار null را برمیگرداند) و متغیر هنوز پیدا نشده باشد، آن متغیر وجود ندارد و باید یک خطا تولید شود.