Chapter 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 چیزهایی مثل گریز با بک‌اسلش را پشتیبانی نمی کنند. یک رشته دنباله‌ای از کاراکترها به جز نقل قول جفتی می‌باشد که خود توسط نقل قول جفتی محصور می‌شود. یک عدد برابر با دنباله‌ای از ارقام است. در نام متغیرها می‌توان از هر کاراکتری به جز فضای خالی و کاراکترهایی که معنای خاصی در گرامر زبان دارند استفاده نمود.

کاربردها به همان سبکی که در جاوااسکریپت هستند نوشته می شوند: بعد از یک عبارت پرانتز قرار می‌گیرد که درون آن هر تعداد آرگومان مجاز می‌باشد و به وسیله‌ی ویرگول از هم جدا می‌شوند.

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 را برمی‌گرداند) و متغیر هنوز پیدا نشده باشد، آن متغیر وجود ندارد و باید یک خطا تولید شود.