فصل 12پروژه: نوشتن یک زبان برنامه‌نویسی

ارزیاب، که معنای عبارت‌ها در یک زبان برنامه‌نویسی را مشخص می‌کند، خود نیز یک برنامه است.

Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs
Picture of an egg with smaller eggs inside

نوشتن زبان برنامه‌نویسی خودتان به طرز شگفت‌آوری آسان و آموزنده است (البته تا زمانی که سطح انتظارتان زیاد بالا نباشد).

هدف اصلی من در این فصل این است که به شما نشان دهم نوشتن یک زبان برنامه‌نویسی، چیزی ماوارایی و جادویی نیست. خیلی وقت‌ها احساس می‌کردم که بعضی از اختراعات انسان‌ها بسیار هوشمندانه و پیچیده است و من هرگز نمی‌توانم آن ها را درک کنم. اما با کمی مطالعه و آزمایش، اغلب متوجه می شدم که اتفاقا قابل درک و ساده هستند.

در این فصل یک زبان برنامه‌نویسی به نام 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}
  ]
}

این گونه از ساختار داده را درخت گرامر می گویند. اگر اشیاء را به عنوان نقطه در نظر بگیرید و پیوندهای بین‌شان را به عنوان خطوط بین نقطه‌ها، نمای آن شبیه به درخت خواهد بود. این واقعیت که عبارت‌ها خود از عبارت‌های دیگری تشکیل می‌شوند ، که آن ها هم ممکن است شامل عبارت‌های دیگری باشند ، شبیه به شاخه‌های درخت است که خود دارای شاخه‌های دیگری می‌باشند.

The structure of a syntax tree

این را با تجزیه‌گری که برای فایل تنظیمات در فصل 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

ساده‌ترین روش انجام آن این است که از آرایه‌های خود جاوااسکریپت برای نمایش آرایه‌های Egg بهره ببرید.

مقادیری که به قلمروی بالایی اضافه می ‌شوند باید تابع باشند. با استفاده از یک آرگومان rest (که با سه نقطه نوشته می‌شود)، تعریف array بسیار ساده خواهد شد.

بستار (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.getPrototypeOf استفاده کنید، که پروتوتایپ یک شیء را بر می گرداند. همچنین به خاطر داشته باشید که قلمروها از 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.getPrototypeOf یک قلمرو را پیمایش نمایید تا به قلمروی بیرونی‌تر برسید. برای هر قلمرو، از متد hasOwnProperty برای بررسی وجود متغیر، که با خاصیت name در اولین آرگومان ‍set مشخص شده است، در قلمرو استفاده کنید. اگر وجود داشت، آن را برابر نتیجه‌ی ارزیابی آرگومان دوم set قرار دهید و آن مقدار را بر‌گردانید.

اگر به بیرونی‌ترین قلمرو برسید ( که در این صورت Object.getPrototypeOf مقدار null را برمی‌گرداند) و متغیر هنوز پیدا نشده باشد، آن متغیر وجود ندارد و باید یک خطا تولید شود.