Meta programming in Javascript
Bài đăng này đã không được cập nhật trong 8 năm
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à handler
và target
.
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 get
và has
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 methodapply
để 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 multiply
và
squared
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 multiply
và squared
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 multiply
và squared
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 multiply
và squared
. target[propKey]
sẽ trả về cho
chúng ta function cần gọi và lưu lại vào origMethod
. Để function multiply
và squared
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 hasvà
deleteProperty` 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 deleteProperty
và has
,
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)
All rights reserved