Meta programming in Javascript

Mở đầu

Trong lập trình chúng ta có thể chia ra 2 mức độ

  • Base level: code xử lí những dữ liệu mà user đưa vào và đưa ra kết quả
  • Meta level: code để xử lí những base-level code ở trên

Thuật ngữ meta-programming thì lần đầu tiên mình nghe thấy là trong ngôn ngữ lập trình ruby, nói 1 cách dễ hiểu là tư tưởng code sinh ra code và lần này mình tò mò xem trong Javascript thì meta-programming nó như thế nào.

Hãy xem xét 1 ví dụ đơn giản

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');

Đoạn code trên là 1 ví dụ về metaprogramming với base programming là ngôn ngữ java còn meta programming bằng javascript (ngôn ngữ của base programming và meta programming có thể khác nhau). Tuy nhiên các ví dụ trong bài viết này sẽ tập trung vào trường hợp base programming và meta programming đều là ngôn ngữ Javascript.

Ví dụ đầu tiên khá trực quan và dễ hiểu về metaprogramming, tuy nhiên có những lúc các xử lí trông không có vẻ giống metaprogramming trên thực tế lại đang làm nhiệm vụ của metaprogramming.

// Base level
const obj = {
  hello() {
    console.log('Hello!');
  }
};

// Meta level
for (const key of Object.keys(obj)) {
  console.log(key);
}

=> hello

Do sự mập mờ giữa programming constructs và data structures trong Javascript mà đoạn code trên trông không giống metaprogramming nhưng trên thực tế bản thân chương trình đã thực thi cấu trúc dữ liệu của nó trong quá trình chạy nên có thể coi đó là 1 kiểu metaprogramming.

Các kiểu metaprogramming

Có thể chia metaprogramming ra làm 3 loại

  • Introspection: chỉ truy cập để đọc cấu trúc chương trinh
  • Self-modification: thay đổi cấu trúc
  • Intercession: thay đổi ngữ nghĩa 1 số toán tử của ngôn ngữ lập trình

Ví dụ thứ 2 trong phần mở đầu chính là ví dụ cho loại Introspection mà cụ thể hơn là lời gọi Object.keys() đã thực hiện việc truy cập cấu trúc.

Trong ES6 thì Javascript cung cấp Proxy để có thể tùy chỉnh các toán tử được thực hiện trong object và đây chính là 1 trong những feature của metaprogramming. Các ví dụ tiếp dưới sẽ tập trung vào khai thác tính năng của Proxy.

Proxy

Proxy làm tác vụ gì

Proxy được tạo ra với 2 tham số đầu vào là handlertarget.

Target chính là object mà chúng ta sẽ thực hiện việc customize các toán tử, còn handler có thể coi như nơi cho phép chúng ta định nghĩa việc customize như thế nào, nơi chúng ta viết code can thiệp vào tác vụ của toán tử. Nhứng method can thiệp này được gọi là trap

const target = {};
const handler = {
  /** Intercepts: getting properties */
  get(target, propKey, receiver) {
    console.log(`GET ${propKey}`);
    return 123;
  },

  /** Intercepts: checking whether properties exist */
  has(target, propKey) {
    console.log(`HAS ${propKey}`);
    return true;
  }
};
const proxy = new Proxy(target, handler);

toán tử in của Javascript sẽ trigger has trong handler còn các lời gọi truy cập thuộc tính của object sẽ trigger get trong handler (tên của các trigger gethas là do Proxy quy ước sẵn)

Sau khi wrap object với handler thì mỗi khi thực hiện 1 toán tử nào đó thì trigger tương ứng trong handler sẽ được kích hoạt. Trong ví dụ này chúng ta đơn giản là thêm log và set kết quả trả về về 1 giá trị cố định (thực tế thì việc này sẽ khá nguy hiểm bởi như vậy thì object sẽ xác nhận là có mọi thuộc tính (yaoming)).

proxy.foo
=>
  GET foo
  123
'hello' in proxy
=>
  HAS hello
  true

Function-specific traps

Nếu target của proxy là 1 function, có 2 toán tử mà chúng ta có thể can thiệp vào:

  • apply: thực hiện function call, được trigger khi thực hiện
    • proxy(...)
    • proxy.call(...)
    • proxy.apply(...)
  • construct: thực hiện constructor call được trigger khi gọi
    • new proxy(...)

Can thiệp vào method calls

Nếu bạn muốn can thiệp vào method call, bạn cần can thiệp vào 2 quá trình

  • get để lấy thông tin về cấu trúc của method
  • apply để thực hiện lời gọi method

Dưới đây sẽ là ví dụ về việc can thiệp vào function call

Trước tiên chúng ta có 1 object với 2 function multiplysquared

const obj = {
  multiply(x, y) {
    return x * y;
  },
  squared(x) {
    return this.multiply(x, x);
  },
};

Bây giờ công việc sẽ là viết 1 proxy để can thiệp vào quá trình gọi 2 function này, xuất ra trace log khi thực hiện function.

function traceMethodCalls(obj) {
  const handler = {
    get(target, propKey, receiver) {
      const origMethod = target[propKey];
      return function (...args) {
        const result = origMethod.apply(this, args);
        console.log(propKey + JSON.stringify(args)
        + ' -> ' + JSON.stringify(result));
        return result;
      };
    }
  };
  return new Proxy(obj, handler);
}

Kết quả

const tracedObj = traceMethodCalls(obj);
tracedObj.multiply(2,7)
=>
  multiply[2,7] -> 14
  14
tracedObj.squared(9)
=>
  multiply[9,9] -> 81
  squared[9] -> 81
  81

Hãy phân tích 1 chút function traceMethodCalls. Khi gọi function multiply hay squared đầu tiên chúng ta sẽ phải chạy qua get trong handler để lấy thông tin về function.

Đoạn code định nghĩa 2 function multiplysquared nếu convert sang ES5 sẽ như sau

"use strict";

var obj = {
  multiply: function multiply(x, y) {
    return x * y;
  },
  squared: function squared(x) {
    return this.multiply(x, x);
  }
};

Như vậy có thể thấy multiplysquared chình là thuộc tính của obj. Trong ví dụ này tham số target chính là obj còn popKey chình là tên của 2 function multiplysquared. target[propKey] sẽ trả về cho chúng ta function cần gọi và lưu lại vào origMethod. Để function multiplysquared hoạt động bình thường thì ta cần trả về kết quả sau khi apply origMethod với các tham số truyền vào. Tại bước này chúng ta sẽ thêm trace log cho function và kết quả thu được là trace log được ghi ra mối khi function được gọi.

Kĩ thuật forward với proxy

Giả sử chúng ta muốn can thiệp vào các toán tử in hay delete trong Javascript bằng cách sử dụng các trap hasdeleteProperty` trong handler như sau.

 const handler = {
   deleteProperty(target, propKey) {
     console.log('DELETE ' + propKey);
     return delete target[propKey];
   },
   has(target, propKey) {
     console.log('HAS ' + propKey);
     return propKey in target;
   },
   // Other traps: similar
 }

Đây là 1 kiểu can thiệp hay được sử dụng, đó là thay vì thay đổi hẳn chức năng của 1 toán tử nào đó thường thì chúng ta muồn bổ sung thêm tính năng (ví dụ như ghi lại log) và giữ nguyên kết quả trả về của các toán tử. Do đó trong các trap deletePropertyhas, lần lượt chúng ta phải gọi return delete target[propKey]; cũng như return propKey in target; để kết quả trả về sau khi chạy qua trap trong handler không thay đổi so với kết quả thông thường. ES6 cung cấp Reflect để làm điều này đơn giản hơn.

Khi sử dụng Reflect đoạn code sẽ trở thành thê này:

const handler = {
  deleteProperty(target, propKey) {
    consoavascriptavascriptle.log('DELETE ' + propKey);
    return Reflect.deleteProperty(target, propKey);
  },
  has(target, propKey) {
    console.log('HAS ' + propKey);
    return Reflect.has(target, propKey);
  },
  // Other traps: similar
}

và sau khi rút gọn do cách viết các trap là tương tự nhau

const handler = new Proxy({}, {
  get(target, trapName, receiver) {
    // Return the handler method named trapName
    return function (...args) {
      console.log(trapName.toUpperCase()+' '+args.slice(1));
      // Forward the operation
      return Reflect[trapName](...args);
    }
  }
});

Ứng dụng của proxy

Proxy có thể ứng dụng vào nhiều tình huống để giúp việc lập trình dễ dàng hơn

  • Trace các thuộc tính được truy cập
  • Cảnh bào, đưa ra exception cho việc truy cập các thuộc tính không được định nghĩa của object
  • Mở rộng phạm vi tính toán của toán tử (ví dụ cho phép sử dụng chỉ số âm trong mảng khi gọi [] thay vì phải sử dụng method khác của Javascript)