Bab 12 dari Eloquent JavaScript. Sebelumnya: 11 Pemrograman Asinkron. Berikutnya: 13 JavaScript dan Browser.

Evaluator, yang nentuin makna ekspresi di bahasa pemrograman, cuma program lain.

— Hal Abelson dan Gerald Sussman, Structure and Interpretation of Computer Programs

Ilustrasi telur berlubang, yang nunjukin telur lebih kecil di dalamnya, yang pada gilirannya punya telur lebih kecil lagi di dalamnya, dan seterusnya

Ngebangun bahasa pemrogramanmu sendiri itu ternyata gampang (selama kamu gak nargetin terlalu tinggi) dan sangat mencerahkan.

Hal utama yang saya mau tunjukin di bab ini adalah gak ada sihir yang terlibat dalam ngebangun bahasa pemrograman. Saya sering ngerasa beberapa penemuan manusia begitu cerdas dan rumit sampai saya gak bakal pernah bisa paham. Tapi dengan sedikit baca dan bereksperimen, mereka sering ternyata cukup biasa.

Kita bakal ngebangun bahasa pemrograman bernama Egg. Dia bakal jadi bahasa yang mungil dan sederhana, tapi yang cukup kuat buat ngekspresiin perhitungan apa pun yang bisa kamu pikirin. Dia bakal ngebolehin abstraksi sederhana berbasis fungsi.

Parsing

Bagian yang paling langsung terlihat dari bahasa pemrograman adalah sintaks, atau notasinya. Parser adalah program yang baca sepotong teks dan ngehasilin struktur data yang nyerminin struktur program yang terkandung di teks itu. Kalau teksnya gak membentuk program yang valid, parser-nya harus nunjukin error-nya.

Bahasa kita bakal punya sintaks yang sederhana dan seragam. Semua di Egg adalah ekspresi. Ekspresi bisa berupa nama binding, angka, string, atau application (penerapan). Application dipakai buat pemanggilan fungsi tapi juga buat konstruksi kaya if atau while.

Biar parser-nya sederhana, string di Egg gak mendukung apa pun kaya escape backslash. String cuma deretan karakter yang bukan kutip ganda, dibungkus kutip ganda. Angka adalah deretan digit. Nama binding bisa terdiri dari karakter apa pun yang bukan whitespace dan yang gak punya makna khusus di sintaksnya.

Application ditulis dengan cara mereka di JavaScript, dengan naruh tanda kurung setelah ekspresi dan punya berapa pun argumen di antara tanda kurung itu, dipisah koma.

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

Keseragaman bahasa Egg berarti hal-hal yang jadi operator di JavaScript (kaya >) adalah binding biasa di bahasa ini, diterapin sama kaya fungsi lain. Karena sintaksnya gak punya konsep block, kita butuh konstruksi do buat mewakili ngelakuin beberapa hal berurutan.

Struktur data yang bakal dipakai parser buat menggambarkan program terdiri dari objek ekspresi, yang masing-masing punya properti type yang nunjukin jenis ekspresinya dan properti lain buat menggambarkan isinya.

Ekspresi bertipe "value" mewakili string atau angka literal. Properti value mereka berisi nilai string atau angka yang mereka wakili. Ekspresi bertipe "word" dipakai buat identifier (nama). Objek kaya gitu punya properti name yang megang nama identifier-nya sebagai string. Terakhir, ekspresi "apply" mewakili application. Mereka punya properti operator yang nunjuk ke ekspresi yang lagi diterapin, juga properti args yang megang array ekspresi argumen.

Bagian >(x, 5) dari program sebelumnya bakal diwakili kaya gini:

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

Struktur data kaya gitu disebut syntax tree (pohon sintaks). Kalau kamu ngebayangin objeknya sebagai titik dan link di antaranya sebagai garis di antara titik-titik itu, kaya yang ditunjukin di diagram berikut, strukturnya punya bentuk kaya pohon. Fakta bahwa ekspresi berisi ekspresi lain, yang pada gilirannya mungkin berisi lebih banyak ekspresi, mirip cara cabang pohon terbelah dan terbelah lagi.

Diagram yang menunjukkan struktur syntax tree buat program contoh. Akarnya dilabeli 'do' dan punya dua anak, satu dilabeli 'define' dan satu dilabeli 'if'. Itu pada gilirannya punya anak lagi, yang menggambarkan isinya.

Bandingin ini dengan parser yang kita tulis buat format file konfigurasi di Bab 9, yang punya struktur sederhana: dia mecah input jadi baris dan nanganin baris itu satu per satu. Cuma ada beberapa bentuk sederhana yang boleh dimiliki sebuah baris.

Di sini kita harus nemuin pendekatan berbeda. Ekspresi gak dipisah jadi baris, dan mereka punya struktur rekursif. Ekspresi application berisi ekspresi lain.

Untungnya, masalah ini bisa diselesaiin dengan sangat baik dengan nulis fungsi parser yang rekursif dengan cara yang nyerminin sifat rekursif bahasanya.

Kita definisikan fungsi parseExpression yang ngambil string sebagai input. Dia ngembaliin objek yang berisi struktur data buat ekspresi di awal string, bareng bagian string yang tersisa setelah mem-parse ekspresi ini. Waktu mem-parse subekspresi (argumen ke application, misalnya), fungsi ini bisa dipanggil lagi, ngasih ekspresi argumennya sekaligus teks yang tersisa. Teks ini pada gilirannya mungkin berisi lebih banyak argumen atau mungkin tanda kurung penutup yang ngakhirin daftar argumen.

Ini bagian pertama parser-nya:

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);
}

Karena Egg, kaya JavaScript, ngebolehin whitespace berapa pun di antara elemen-elemennya, kita harus berulang kali motong whitespace dari awal string program. Fungsi skipSpace ngebantu dengan ini.

Setelah ngelewatin spasi di depan, parseExpression pakai tiga regular expression buat nemuin tiga elemen atomik yang didukung Egg: string, angka, dan word. Parser-nya nyusun jenis struktur data berbeda tergantung ekspresi mana yang cocok. Kalau inputnya gak cocok dengan salah satu dari tiga bentuk ini, dia bukan ekspresi valid, dan parser-nya ngelempar error. Kita pakai constructor SyntaxError di sini. Ini class exception yang didefinisikan standar, kaya Error, tapi lebih spesifik.

Kita lalu motong bagian yang cocok dari string program dan ngasih itu, bareng objek buat ekspresinya, ke parseApply, yang ngecek apakah ekspresinya application. Kalau iya, dia mem-parse daftar argumen dalam tanda kurung.

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));
}

Kalau karakter berikutnya di program bukan tanda kurung buka, ini bukan application, dan parseApply ngembaliin ekspresi yang dia dikasih. Kalau gak, dia ngelewatin tanda kurung buka dan bikin objek syntax tree buat ekspresi application ini. Dia lalu rekursif manggil parseExpression buat mem-parse setiap argumen sampai tanda kurung penutup ditemuin. Rekursinya gak langsung, lewat parseApply dan parseExpression yang saling manggil.

Karena ekspresi application sendiri bisa diterapin (kaya di multiplier(2)(1)), parseApply harus, setelah dia mem-parse application, manggil dirinya sendiri lagi buat ngecek apakah pasangan tanda kurung lain ngikutin.

Ini semua yang kita butuhin buat mem-parse Egg. Kita bungkus dalam fungsi parse yang praktis yang memverifikasi bahwa dia udah nyampe akhir string input setelah mem-parse ekspresinya (program Egg adalah satu ekspresi), dan yang ngasih kita struktur data programnya.

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}]}

Jalan! Dia gak ngasih kita informasi yang terlalu membantu waktu dia gagal dan gak nyimpen baris dan kolom tempat setiap ekspresi mulai, yang mungkin membantu waktu ngelaporin error nanti, tapi cukup bagus buat tujuan kita.

Evaluator

Apa yang bisa kita lakuin dengan syntax tree buat sebuah program? Jalanin, tentu aja! Dan itulah yang dilakuin evaluator. Kamu ngasih dia syntax tree dan objek scope yang ngaitin nama dengan nilai, dan dia bakal ngevaluasi ekspresi yang diwakili pohonnya dan ngembaliin nilai yang dihasilin ini.

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.");
      }
    }
  }
}

Evaluator-nya punya kode buat setiap jenis ekspresi. Ekspresi nilai literal ngehasilin nilainya. (Misalnya, ekspresi 100 dievaluasi jadi angka 100.) Buat binding, kita harus ngecek apakah dia beneran didefinisikan di scope-nya dan, kalau iya, ngambil nilai bindingnya.

Application lebih rumit. Kalau mereka special form, kaya if, kita gak ngevaluasi apa pun, kita cuma ngasih ekspresi argumennya, bareng scope-nya, ke fungsi yang nanganin bentuk ini. Kalau dia pemanggilan normal, kita ngevaluasi operatornya, memverifikasi bahwa dia fungsi, dan manggilnya dengan argumen yang udah dievaluasi.

Kita pakai nilai fungsi JavaScript biasa buat mewakili nilai fungsi Egg. Kita bakal balik ke ini nanti, waktu special form fun didefinisikan.

Struktur rekursif evaluate mirip struktur parser-nya, dan keduanya mencerminkan struktur bahasanya sendiri. Bisa juga ngegabungin parser dan evaluator jadi satu fungsi dan ngevaluasi selama parsing, tapi misahin mereka dengan cara ini bikin programnya lebih jelas dan fleksibel.

Ini beneran semua yang dibutuhin buat menafsirkan Egg. Sesederhana itu. Tapi tanpa mendefinisikan beberapa special form dan nambahin beberapa nilai berguna ke environment, kamu belum bisa ngelakuin banyak dengan bahasa ini.

Special Form

Objek specialForms dipakai buat mendefinisikan sintaks khusus di Egg. Dia ngaitin word dengan fungsi yang ngevaluasi bentuk kaya gitu. Dia saat ini kosong. Kita tambahin 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);
  }
};

Konstruksi if Egg ngarepin persis tiga argumen. Dia bakal ngevaluasi yang pertama, dan kalau hasilnya bukan nilai false, dia bakal ngevaluasi yang kedua. Kalau gak, yang ketiga yang dievaluasi. Bentuk if ini lebih mirip operator ternary ?: JavaScript daripada if JavaScript. Dia ekspresi, bukan statement, dan dia ngehasilin nilai, yaitu, hasil dari argumen kedua atau ketiga.

Egg juga beda dari JavaScript dalam cara dia nanganin nilai kondisi ke if. Dia bakal cuma memperlakukan nilai false sebagai false, bukan hal kaya nol atau string kosong.

Alasan kita perlu mewakili if sebagai special form bukannya fungsi biasa adalah semua argumen ke fungsi dievaluasi sebelum fungsinya dipanggil, sedangkan if harus cuma ngevaluasi entah argumen kedua atau ketiganya, tergantung nilai yang pertama.

Bentuk while serupa.

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);
  }
 
  // Karena undefined gak ada di Egg, kita return false,
  // buat gantiin hasil yang bermakna
  return false;
};

Blok bangunan dasar lainnya adalah do, yang ngeksekusi semua argumennya dari atas ke bawah. Nilainya adalah nilai yang dihasilin argumen terakhir.

specialForms.do = (args, scope) => {
  let value = false;
  for (let arg of args) {
    value = evaluate(arg, scope);
  }
  return value;
};

Biar bisa bikin binding dan ngasih mereka nilai baru, kita juga bikin bentuk bernama define. Dia ngarepin word sebagai argumen pertamanya dan ekspresi yang ngehasilin nilai buat di-assign ke word itu sebagai argumen kedua. Karena define, kaya semuanya, adalah ekspresi, dia harus ngembaliin nilai. Kita bakal bikin dia ngembaliin nilai yang di-assign (sama kaya operator = 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;
};

Environment

Scope yang diterima evaluate adalah objek dengan properti yang namanya sesuai dengan nama binding dan yang nilainya sesuai dengan nilai yang diikatkan ke binding itu. Kita definisikan objek buat mewakili scope global.

Biar bisa pakai konstruksi if yang baru kita definisikan, kita harus punya akses ke nilai Boolean. Karena cuma ada dua nilai Boolean, kita gak perlu sintaks khusus buat mereka. Kita cuma ngikat dua nama ke nilai true dan false dan pakai mereka.

const topScope = Object.create(null);
 
topScope.true = true;
topScope.false = false;

Kita sekarang bisa ngevaluasi ekspresi sederhana yang menegasi nilai Boolean.

let prog = parse(`if(true, false, true)`);
console.log(evaluate(prog, topScope));
// → false

Buat nyediain operator aritmetika dan perbandingan dasar, kita juga bakal nambahin beberapa nilai fungsi ke scope. Demi ngejaga kodenya pendek, kita bakal pakai Function buat mensintesis sekumpulan fungsi operator dalam loop bukannya mendefinisikan mereka satu per satu.

for (let op of ["+", "-", "*", "/", "==", "<", ">"]) {
  topScope[op] = Function("a, b", `return a ${op} b;`);
}

Berguna juga punya cara buat ngeluarin nilai, jadi kita bakal bungkus console.log dalam fungsi dan nyebutnya print.

topScope.print = value => {
  console.log(value);
  return value;
};

Itu ngasih kita cukup alat dasar buat nulis program sederhana. Fungsi berikut nyediain cara praktis buat mem-parse program dan ngejalaninnya di scope yang segar:

function run(program) {
  return evaluate(parse(program), Object.create(topScope));
}

Kita bakal pakai rantai prototype objek buat mewakili scope bersarang biar programnya bisa nambahin binding ke scope lokalnya tanpa ngubah scope level atas.

run(`
do(define(total, 0),
   define(count, 1),
   while(<(count, 11),
         do(define(total, +(total, count)),
            define(count, +(count, 1)))),
   print(total))
`);
// → 55

Ini program yang udah kita lihat beberapa kali sebelumnya yang ngitung jumlah angka 1 sampai 10, diekspresikan di Egg. Dia jelas lebih jelek dari program JavaScript yang setara, tapi gak buruk buat bahasa yang diimplementasiin dalam kurang dari 150 baris kode.

Fungsi

Bahasa pemrograman tanpa fungsi emang bahasa pemrograman yang buruk. Untungnya, gak susah buat nambahin konstruksi fun, yang memperlakukan argumen terakhirnya sebagai badan fungsi dan pakai semua argumen sebelum itu sebagai nama parameter fungsinya.

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);
  };
};

Fungsi di Egg dapet scope lokal mereka sendiri. Fungsi yang dihasilin bentuk fun bikin scope lokal ini dan nambahin binding argumen ke dia. Dia lalu ngevaluasi badan fungsinya di scope ini dan ngembaliin hasilnya.

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

Kompilasi

Yang udah kita bangun adalah interpreter. Selama evaluasi, dia beraksi langsung pada representasi program yang dihasilin parser.

Kompilasi adalah proses nambahin langkah lain antara parsing dan menjalankan program, yang mentransformasi programnya jadi sesuatu yang bisa dievaluasi lebih efisien dengan ngelakuin sebanyak mungkin kerjaan di awal. Misalnya, di bahasa yang didesain dengan baik jelas, buat setiap penggunaan binding, binding mana yang dirujuk, tanpa beneran ngejalanin programnya. Ini bisa dipakai buat ngehindarin nyari binding berdasarkan nama setiap kali dia diakses, malah langsung ngambilnya dari suatu lokasi memori yang udah ditentuin.

Secara tradisional, kompilasi melibatkan ngonversi program jadi machine code, format mentah yang bisa dieksekusi processor komputer. Tapi proses apa pun yang ngonversi program jadi representasi berbeda bisa dianggap kompilasi.

Bisa aja nulis strategi evaluasi alternatif buat Egg, yang pertama ngonversi programnya jadi program JavaScript, pakai Function buat manggil compiler JavaScript di situ, dan ngejalanin hasilnya. Waktu dilakuin dengan benar, ini bakal bikin Egg jalan sangat cepat sambil tetap cukup sederhana buat diimplementasiin.

Kalau kamu tertarik sama topik ini dan mau ngabisin waktu buat itu, saya mendorong kamu nyoba ngimplementasiin compiler kaya gitu sebagai latihan.

Curang

Waktu kita mendefinisikan if dan while, kamu mungkin nyadar bahwa mereka kurang lebih pembungkus sepele di sekitar if dan while JavaScript sendiri. Sama juga, nilai di Egg cuma nilai JavaScript biasa. Menjembatani kesenjangan ke sistem yang lebih primitif, kaya machine code yang dipahami processor, butuh lebih banyak usaha, tapi cara kerjanya mirip apa yang kita lakuin di sini.

Walaupun bahasa mainan di bab ini gak ngelakuin apa pun yang gak bisa dilakuin lebih baik di JavaScript, ada situasi di mana nulis bahasa kecil ngebantu ngerjain kerjaan nyata.

Bahasa kaya gitu gak harus mirip bahasa pemrograman tipikal. Kalau JavaScript gak dilengkapi regular expression, misalnya, kamu bisa nulis parser dan evaluatormu sendiri buat regular expression.

Atau bayangin kamu ngebangun program yang bikin mungkin buat cepat bikin parser dengan nyediain deskripsi logis dari bahasa yang perlu mereka parse. Kamu bisa mendefinisikan notasi tertentu buat itu, dan compiler yang ngompilasinya jadi program parser.

expr = number | string | name | application
 
number = digit+
 
name = letter+
 
string = '"' (! '"')* '"'
 
application = expr '(' (expr (',' expr)*)? ')'

Ini yang biasanya disebut domain-specific language (bahasa spesifik domain), bahasa yang disesuaiin buat ngekspresiin domain pengetahuan yang sempit. Bahasa kaya gitu bisa lebih ekspresif dari bahasa serba-guna karena dia didesain buat menggambarkan persis hal-hal yang perlu digambarkan di domainnya dan gak lebih.

Ringkasan

Bab ini nunjukin bahwa ngebangun bahasa pemrograman sendiri gak sesihir yang dibayangkan.

Poin-poin pentingnya:

  1. Parser baca teks program dan ngehasilin syntax tree (struktur objek dengan type). Egg punya tiga jenis ekspresi: value (string/angka), word (nama), dan apply (penerapan).
  2. Parser Egg itu rekursif (parseExpression dan parseApply saling manggil), nyerminin struktur rekursif bahasanya.
  3. Evaluator ngambil syntax tree + scope, lalu ngejalaninnya. Value ngehasilin nilainya, word dicari di scope, apply manggil fungsi (atau special form).
  4. Special form (if, while, do, define, fun) beda dari fungsi biasa karena argumennya gak otomatis dievaluasi dulu.
  5. Environment (scope) diwakili objek; scope bersarang pakai rantai prototype.
  6. Egg “curang” dengan minjem mekanisme JavaScript (nilai, if, while, closure). Ini prinsip yang sama dengan menjembatani ke sistem yang lebih primitif.
  7. Bahasa kecil (domain-specific language) berguna buat ngekspresiin satu domain sempit dengan lebih ekspresif dari bahasa serba-guna.

Latihan

Array

Tambahin dukungan buat array ke Egg dengan nambahin tiga fungsi berikut ke top scope: array(...values) buat ngonstruksi array yang berisi nilai argumen, length(array) buat dapet panjang array, dan element(array, n) buat ngambil elemen ke-n dari array.

// Modifikasi definisi ini...
 
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

Petunjuk:

Cara termudah ngelakuin ini adalah mewakili array Egg dengan array JavaScript.

Nilai yang ditambahin ke top scope harus fungsi. Dengan pakai argumen rest (dengan notasi tiga titik), definisi array bisa sangat sederhana.

Solusi:

topScope.array = (...values) => values;
 
topScope.length = array => array.length;
 
topScope.element = (array, i) => array[i];
 
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

Cara kita mendefinisikan fun ngebolehin fungsi di Egg ngerujuk scope di sekitarnya, ngebolehin badan fungsi pakai nilai lokal yang terlihat di waktu fungsinya didefinisikan, sama kaya fungsi JavaScript.

Program berikut ngilustrasiin ini: fungsi f ngembaliin fungsi yang nambahin argumennya ke argumen f, artinya dia perlu akses ke scope lokal di dalam f biar bisa pakai binding a.

run(`
do(define(f, fun(a, fun(b, +(a, b)))),
   print(f(4)(5)))
`);
// → 9

Balik ke definisi bentuk fun dan jelasin mekanisme mana yang bikin ini jalan.

Petunjuk / Solusi:

Lagi-lagi, kita numpang mekanisme JavaScript buat dapet fitur setara di Egg. Special form dikasih scope lokal tempat mereka dievaluasi biar mereka bisa ngevaluasi subform mereka di scope itu. Fungsi yang dikembaliin fun punya akses ke argumen scope yang dikasih ke fungsi yang ngelingkupinya dan pakai itu buat bikin scope lokal fungsinya waktu dipanggil.

Ini berarti prototype dari scope lokalnya bakal jadi scope tempat fungsinya dibikin, yang bikin mungkin ngakses binding di scope itu dari fungsinya. Ini semua yang perlu buat ngimplementasiin closure (walaupun buat ngompilasinya dengan cara yang beneran efisien, kamu perlu ngelakuin kerjaan lebih).

Komentar

Bakal bagus kalau kita bisa nulis komentar di Egg. Misalnya, setiap kali kita nemuin tanda hash (#), kita bisa memperlakukan sisa barisnya sebagai komentar dan ngabaikannya, mirip // di JavaScript.

Kita gak harus bikin perubahan besar ke parser buat mendukung ini. Kita bisa cuma ngubah skipSpace buat ngelewatin komentar seolah mereka whitespace biar semua titik tempat skipSpace dipanggil sekarang juga bakal ngelewatin komentar. Bikin perubahan ini.

// Ini skipSpace yang lama. Modifikasi...
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: []}

Petunjuk:

Pastiin solusimu nanganin beberapa komentar berturut-turut, dengan whitespace mungkin di antara atau setelahnya.

Regular expression mungkin cara termudah buat nyelesaiin ini. Tulis sesuatu yang cocok dengan “whitespace atau komentar, nol kali atau lebih”. Pakai method exec atau match dan lihat panjang elemen pertama di array yang dikembaliin (seluruh kecocokan) buat nyari tahu berapa karakter yang harus dipotong.

Solusi:

function skipSpace(string) {
  let skippable = string.match(/^(\s|#.*)*/);
  return string.slice(skippable[0].length);
}
 
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: []}

Memperbaiki Scope

Saat ini, satu-satunya cara nge-assign nilai ke binding adalah define. Konstruksi ini bertindak sebagai cara buat mendefinisikan binding baru sekaligus ngasih yang udah ada nilai baru.

Ambiguitas ini bikin masalah. Waktu kamu nyoba ngasih binding non-lokal nilai baru, kamu bakal berakhir mendefinisikan yang lokal dengan nama yang sama sebagai gantinya. Beberapa bahasa bekerja kaya gini secara desain, tapi saya selalu ngerasa itu cara yang canggung buat nanganin scope.

Tambahin special form set, mirip define, yang ngasih binding nilai baru, ngupdate binding di scope luar kalau dia belum ada di scope dalam. Kalau bindingnya gak didefinisikan sama sekali, lempar ReferenceError (jenis error standar lain).

Teknik mewakili scope sebagai objek sederhana, yang udah bikin hal-hal praktis sejauh ini, bakal sedikit ngehalangimu di titik ini. Kamu mungkin mau pakai fungsi Object.getPrototypeOf, yang ngembaliin prototype dari objek. Juga ingat kamu bisa pakai Object.hasOwn buat nyari tahu apakah objek tertentu punya properti.

specialForms.set = (args, scope) => {
  // Kodemu di sini.
};
 
run(`
do(define(x, 4),
   define(setx, fun(val, set(x, val))),
   setx(50),
   print(x))
`);
// → 50
run(`set(quux, true)`);
// → Semacam ReferenceError

Petunjuk:

Kamu bakal harus ngeloop satu scope dalam satu waktu, pakai Object.getPrototypeOf buat pergi ke scope luar berikutnya. Buat setiap scope, pakai Object.hasOwn buat nyari tahu apakah bindingnya, yang ditunjukin properti name dari argumen pertama ke set, ada di scope itu. Kalau iya, setel dia ke hasil ngevaluasi argumen kedua ke set lalu return nilai itu.

Kalau scope terluar dicapai (Object.getPrototypeOf ngembaliin null) dan kita belum nemuin bindingnya, dia gak ada, dan error harus dilempar.

Solusi:

specialForms.set = (args, env) => {
  if (args.length != 2 || args[0].type != "word") {
    throw new SyntaxError("Bad use of set");
  }
  let varName = args[0].name;
  let value = evaluate(args[1], env);
 
  for (let scope = env; scope; scope = Object.getPrototypeOf(scope)) {
    if (Object.hasOwn(scope, varName)) {
      scope[varName] = value;
      return value;
    }
  }
  throw new ReferenceError(`Setting undefined variable ${varName}`);
};
 
run(`
do(define(x, 4),
   define(setx, fun(val, set(x, val))),
   setx(50),
   print(x))
`);
// → 50
run(`set(quux, true)`);
// → Semacam ReferenceError