Babel Coder

Metaprogramming คืออะไร? เรียนรู้การใช้งานเบื้องต้นผ่าน JavaScript

advanced

ก่อนอื่นเลยต้องแจ้งไว้ก่อนครับว่าเพื่อนๆคนไหนที่ยังไม่สันทัดกับ JavaScript มากนักควรหลีกเลี่ยงบทความนี้ เพื่อนๆคนไหนยังไม่คุ้นเคยกับ ES2015 แนะนำให้อ่าน พื้นฐาน ES2015 สำหรับการเขียน JavaScript สมัยใหม่ ก่อน ส่วนคนไหนพร้อมทั้งกายและใจแล้วจะอ่านบทความนี้มากกว่าสองรอบก็ได้ครับ เราไม่ห้ามดื่มเกินวันละสองขวดแบบลิโพ!

เพราะโปรแกรมเมอร์นั้นขี้เกียจ

บทความของ Babel Coder นั้นมีสามสถานะคือ drafted, published และ upcoming ดังนั้นถ้าเราต้องการสร้างเมธอดเพื่อตรวจสอบสถานะของบทความ เราต้องนิยามสามเมธอดเพื่อแทนแต่ละสถานะดังนี้

class Article {
  static statuses = ['drafted', 'published', 'upcoming']
  
  constructor(status) {
    this.status = status
  }
  
  isDrafted() {
    return this.status === 'drafted'
  }
  
  isPublished() {
    return this.status === 'published'
  }
  
  isUpcoming() {
    return this.status === 'upcoming'
  }
}

const article = new Article('drafted')
console.log(article.isDrafted())   // true
console.log(article.isPublished()) // false

จากตัวอย่างข้างบนเราพบว่าเมธอดทั้งสามเป็นพี่น้องร่วมสาบานที่มีหน้าตาเหมือนกัน คือประกอบด้วย is บวกด้วยชื่อสถานะเพียงแต่อักษรตัวแรกเป็นตัวใหญ่ โอ๊ะโอ ทั้งๆที่เราประกาศในบรรทัดที่2แล้วว่าสถานะทั้งหมดของเรามีสามค่า เราใช้ประโยชน์จากส่วนนี้ไม่ได้หรอ แทนที่จะต้องมานั่งประกาศเมธอดใหม่ตั้งสามตัว นี่แหละครับความขี้เกียจของโปรแกรมเมอร์ เอาหละลองพยายามอีกครั้ง

// สร้างฟังก์ชันขึ้นมาอันนึงมีหน้าที่แปลงข้อความให้ตัวแรกเป็นอักษรตัวใหญ่
// เช่นเปลี่ยนจาก upcoming เป็น Upcoming
const capitalize = (text) => {
  return text.charAt(0).toUpperCase() + text.slice(1)  
}

class Article {
  static statuses = ['drafted', 'published', 'upcoming']
  
  constructor(status) {
    this.status = status
  }
}

// เมื่อเรามีอาร์เรย์ของสถานะทั้งหลายแล้วจะรอช้าอยู่ใย
// วนลูปรอบมันเพื่อสร้างเมธอดตามจำนวนสถานะที่เรามี
Article.statuses.forEach(status => {
  // แปะเมธอดที่สร้างใหม่เข้าไปในใน prototype
  // ใครไม่รู้จัก prototype ไม่เป็นไรครับ ลืมมันไปซะ
  // คิดซะว่านี่คือวิธีสร้างเมธอดอีกวิธีหนึ่งใน JavaScript แล้วกัน
  Article.prototype[`is${capitalize(status)}`] = function() {
    return this.status === status
  }
})

const article = new Article('drafted')
console.log(article.isDrafted())   // true
console.log(article.isPublished()) // false

ตอนนี้โค๊ดของเราดูมีชั้นเชิงมากขึ้น แค่วนลูปชีวิตก็เปลี่ยน ช่างตอบสนองความสันหลังยาวของพวกเราชาวโปรแกรมเมอร์เสียนี่กระไร (หรือมีผมคนเดียวที่ขี้เกียจแฮะ T__T)

Metaprogramming คืออะไร?

ก่อนจะเข้าใจความหมายในส่วนลึก เราลองมาแยกคำว่า meta ออกจาก programming กันก่อนครับ หลังจากคุ้ยวิกิพีเดียพบว่า meta เป็นคำภาษากรีกแปลว่ายิ่งใหญ่ จึงไม่น่าแปลกใจที่ Metaphysics จะหมายถึงอภิปรัชญา ส่วน metaprogramming นั้นจึงน่าจะเป็นการเขียนโปรแกรมที่เวอร์วังอลังการ แล้วแบบไหนหละที่เรียกว่ายิ่งใหญ่สำหรับการเขียนโปรแกรม?

Metaprogramming คือเทคนิคการเขียนโปรแกรมแบบหนึ่งเพื่อสร้างโค๊ดหรือโปรแกรมที่จะไปสร้าง/เปลี่ยนแปลง/วิเคราะห์โค๊ดหรือโปรแกรมขึ้นมาอีกที ลองพิจารณาสถานะของบทความในตัวอย่างที่แล้วครับ ผมเขียนโปรแกรมขึ้นมาชุดนึง แล้วโปรแกรมชุดนี้จะไปสร้างเมธอดขึ้นมาอีกทีนึง จึงจัดเป็น metaprogramming เพราะเป็นการเขียนโปรแกรมเพื่อให้สร้างโปรแกรมขึ้นมาอีกทีหนึ่ง

Metaprogramming ใน JavaScript

Metaprogramming ใน JavaScript นั้นสามารถแบ่งหลักๆได้เป็น3ประเภท ดังนี้

1. Introspection

เตือนเพื่อนๆไว้ก่อนครับว่าอย่าพยายามหาความหมายของคำนี้จาก Google translate เพราะสิ่งที่คุณจะได้คือ วิปัสสนา หืม? introspection คือความสามารถในการเข้าถึงโค๊ดหรือข้อมูลโดยไม่เปลี่ยนแปลงผลลัพธ์ของโค๊ดต้นฉบับ

Introspection กลุ่มฟังก์ชัน

ฟังก์ชันใน JavaScript นั้นเป็นอ็อบเจ็กต์ ดังนั้นตัวฟังก์ชันเองจึงมีเมธอดให้เราเรียกใช้เพื่อเข้าถึงข้อมูล metadata ของตัวฟังก์ชัน กลุ่มของเมธอดเหล่านี้เองครับที่เป็น introspection

function print(firstName, lastName) {
  console.log(`${firstName} ${lastName}`)
}

console.log(typeof print) // function

// function นั้นเป็นอ็อบเจ็กต์ชนิดหนึ่ง
console.log(print instanceof Object) // true

// เข้าถึงชื่อของฟังก์ชัน
console.log(print.name)   // print

// เข้าถึงข้อมูลว่าฟังก์ชันดังกล่าวรับ arguments กี่ตัว
console.log(print.length) // 2

Introspection กลุ่มอ็อบเจ็กต์

introspection ในกลุ่มอ็อบเจ็กต์นั้นเราใช้บ่อยมาก โดยเฉพาะในสมัย ES5 ที่เราต้องการสร้าง OOP บน JavaScript ผ่าน prototype ตัวอย่างเช่น

const obj = {
  firstName: 'Nuttavut',
  lastName: 'thongjor'
}

Object.keys(obj) // ["firstName","lastName"]
Object.values(obj) // ["Nuttavut","thongjor"]
Object.entries(obj) // [["firstName","Nuttavut"],["lastName","thongjor"]]

// ขอข้อมูลของ property ที่ระบุ
Object.getOwnPropertyDescriptor(obj, 'firstName') // {"value":"Nuttavut","writable":true,"enumerable":true,"configurable":true}

Introspection กลุ่ม operators หรือการดำเนินการ

introspection กลุ่มนี้ได้แก่ typeof และ instanceof

const obj = {}

console.log(typeof obj) // object
console.log(obj instanceof Object) // true

2. Self-modification

Metaprogramming กลุ่มนี้ใช้เพื่อแก้ไขข้อมูลหรือส่วนของโค๊ด ตัวอย่างเช่น delete ที่เป็น operator ดำเนินการสำหรับลบ property ที่ไม่ต้องการ

// สร้างฟังก์ชันสำหรับย้าย property ทั้งหมดของอ็อบเจ็กต์หนึ่งไปยังอีกตัวหนึ่ง
const moveTo = (source, target) => {
  for(let [key, value] of Object.entries(source)) {
    // ย้าย property จาก source ไป target แล้วลบ property ออกจาก source
    target[key] = value
    delete source[key]
  }
}

const obj1 = {
  firstName: 'Nuttavut',
  lastName: 'Thongjor'
}

const obj2 = {}

moveTo(obj1, obj2)

console.log(obj1) // {}
console.log(obj2) // {"firstName":"Nuttavut","lastName":"Thongjor"}

3. Intercession

Intercession นั้นหมายถึงมือที่สามครับ เมื่อความรักเกิดขึ้นย่อมมีมือที่สามมาแทรกกลางระหว่างสองเรา และมีที่สามนี่แหละที่จะมาปั่นป่วนความรัก เริ่มตั้งแต่ป้ายสีให้อีกฝ่ายดูเลว จากนั้นจึงเปิดศึกเปลี่ยนรักสลับคู่ เห็นไหมครับมือที่สามนั้นช่ำชองในด้านการสร้างความหมายใหม่ให้กับความรักเดิมๆ และนั่นหละครับคือ Intercession

เราสามารถใช้ Intercession เพื่อสร้างความหมายใหม่ให้กับสิ่งที่มีอยู่เดิม เป็นการเพิ่มความสามารถของโค๊ดเก่าด้วยการทำตัวเป็นคนคั่นกลาง เช่นในการสร้างอ็อบเจ็กต์เราสามารถใช้ getter และ setter เพื่อตรวจสอบข้อมูลก่อนตั้งค่าให้ property ได้ดังนี้

const obj = {
  // โลก JavaScript นิยมใส่ _ นำหน้า property ที่เราคิดในใจว่าเป็น private property
  _name: null,
  
  get name() {
    // ก่อนเข้าถึง name เราจะตรวจสอบค่า _name ก่อน
    // ถ้า _name ไม่มีค่า จะคืนค่ากลับเป็ย no name
    return this._name ? this._name : 'no name'
  },
  
  set name(value) {
    // ถ้าไม่ระบุค่าเข้ามาจะ throw error
    if(!!value) throw 'Please enter your name.'
    this._name = value
  }
}

obj.name = '' // Please enter your name

obj.name = 'nut'
console.log(obj.name) // nut

ของเล่นใหม่ ES2015 กับ Metaprogramming

การอุบัติแห่ง ES2015 ที่แม้แต่ภูเขาไฟฟูจิยังต้องสั่นสะเทือนมาพร้อมกับของเล่นใหม่สองสิ่งที่ช่วยเพิ่มความสามารถให้กับ metaprogramming ใน JavaScript น้องสาวคนเล็กชื่อ Symbol และน้องชายคนรองชื่อ Reflex และน้องชายคนสุดท้องชื่อ Proxy เราไปทำความรู้จักกับครอบครัวนี้กัน

Symbol: สัญลักษณ์ที่สื่อสารได้รอบโลก

น้อง symbol นั้นเป็นชนิดข้อมูลใหม่ใน ES2015 ครับ คุณสมบัติที่สำคัญของน้องคือ น้องจะมีความเป็นตัวของตัวเองสูง ดังนั้นทุกครั้งที่เราสร้าง symbol ขึ้นมาใหม่ น้องจะเป็นตัวของตัวเองด้วยการไม่เหมือนกับใครเลย

const str1 = 'Hello'
const str2 = 'Hello'

// เราสร้าง symbol ด้วยการเรียก Symbol
// ส่วน Hello นั่นเป็นการใส่คำอธิบาย symbol เฉยๆ
// จะใส่หรือไม่ใส่ก็ได้
// แต่แนะนำให้ใส่เสมอครับ เพื่อประโยชน์ในการ debug โปรแกรม
const sym1 = Symbol('hello')
const sym2 = Symbol('hello')

// string 2 ตัวมีค่าเหมือนกันย่อยเท่ากัน
console.log(str1 === str2) // true

// รอถึงชาติหน้า symbol 2 ตัวก็ไม่เท่ากัน!
console.log(sym1 === sym2) // false

คนเราต่อให้มีความเป็นตัวเองสูงเพียงใดก็ยังต้องการสังคมใช่ไหมครับ น้อง symbol ก็เช่นกัน เราสามารถสร้าง symbol แล้วนำกลับมาใช้ใหม่ได้ด้วยการเรียกใช้ผ่าน global symbol registry ดังนี้ฮะ

const sym1 = Symbol.for('hello')
const sym2 = Symbol.for('hello')

console.log(sym1 === sym2) // true

เนื่องจากรายละเอียดของน้อง symbol นั้นมีค่อนข้างเยอะ ผมจะไม่กล่าวถึงในบทความนี้มากนัก แต่จะมุ่งประเด็นไปที่การใช้ symbol กับ metaprogramming เลยครับ เอาหละพักผ่อนดื่มน้ำปัสสาวะแล้วลุยต่อกันเลย

ES2015 นั้นช่างพิลึกคน มันได้จับกลุ่มของ symbol ชุดหนึ่งที่ได้นิยามเอาไว้แล้วเข้าไปไว้ในตัว array, string และอ็อบเจ็กต์อื่นๆเลย เราเรียก symbol กลุ่มนี้ว่า well known symbols ลองดูตัวอย่างบางตัวจาก symbol กลุ่มนี้กันครับ

Symbol.iterator

เราสามารถใช้ for…of เพื่อวนลูปรอบอาร์เรย์ได้ดังนี้

const arr = [1, 2, 3]

for(let i of arr) {
  console.log(i)
}

// 1, 2, 3

สมมติตอนนี้เรามีคลาสชื่อ Text ทำหน้าที่เก็บคำทั้งหมดที่ต่อกันเป็นประโยค เช่นถ้าประโยคคือ This is a book Text จะเก็บ ['This', 'is', 'a', 'book'] โจทย์ของเราคือเราต้องการให้เมื่อไหร่ก็ตามที่เราเรียกใช้อ็อบเจ็กต์ของคลาสนี้ใน for…of ให้มันวนลูปเพื่อพิมพ์ค่าแต่ละคำออกมา เอาหละลงมือ!

class Text {
  constructor(text) {
    // แตกข้อความยาวๆออกเป็นอาร์เรย์โดยใช้ช่องว่างเป็นตัวหั่น
    this.words = text.split(' ')  
  }
  
  // ส่วนนี้คือ ES2015 Generators ถ้าไม่รู้จักไม่เป็นไรครับ
  // จำรูปแบบการเขียนไปใช้พอ
  *[Symbol.iterator]() {
    for(let word of this.words) {
      yield word
    }
  }
}

const text = new Text('This is a book')

// ทีนี้คลาส Text ของเราก็จะนำไปวนลูปได้แล้ว แหล่มแมวฝุดๆ
for(let i of text) {
  console.log(i)
}

// This
// is
// a
// book

Symbol.match

ก่อนอื่นเราไปทบทวนกันหน่อยดีกว่าว่าใน String#match ของ JavaScript ดั้งเดิมนั้นทำงานยังไง

const str1 = 'hello world'
const regex = /^hello/

// match ใช้เพื่อตรวจสอบว่า string นั้นมีรูปแบบตรงกับ regular expression
// ที่เราระบุไว้หรือไม่ สังเกตสิ่งที่มันคืนค่ากลับมานะครับ
console.log(str1.match(regex)) // ['hello']

เรามีคลาส Word สำหรับใช้เก็บคำที่อาจมี article(a, an, the) ประกอบอยู่ด้วย โจทย์ของเราคือเราต้องการสร้างเมธอด match เพื่อเปรียบเทียบ string กับคลาส Word ของเรา โดยที่ถ้า string นั้นเหมือนกับ Word ของเราตอนที่ไม่มี article จะถือว่าเข้าคู่กัน เช่นถ้าเราเปรียบเทียบ book กับอ็อบเจ็กต์ของคลาส Word ที่เก็บค่า a book ไว้จะถือว่าเท่ากัน เพราะเราไม่คำนึงถึง article

class Word {
  constructor(word) {
    this.word = word
  }
  
  [Symbol.match](string) {
    const index = this.word
        .replace(/^an /, '')
        .replace(/^a /, '')
        .replace(/^the /, '')
        .indexOf(string)
    
    // หลังจากตัด article ออกแล้วถ้าไม่เท่ากันให้คืนค่าเป็น -1
    // แต่ถ้าเท่ากันให้คืนค่าเป็นอาร์เรย์แบบเดียวกับที่ String#match ทำ
    return index === -1 ? null : [this.word]
  }
}

const word = new Word('a book')
console.log('book'.match(word)) // ['a book']

Reflect

ES2015 ได้เพิ่ม Reflect เป็นอ็อบเจ็กต์อีกประเภทหนึ่งในตัวภาษา มีหลายเมธอดภายในคล้ายๆเมธอดของอ็อบเจ็กต์ เราจะมาลองเรียนรู้สามเมธอดต่อไปนี้ของ Reflect กันดังนี้

const obj = {
  name: null,
  age: null
}

// ใช้เพื่อตั้งค่า property ในอ็อบเจ็กต์
Reflect.set(obj, 'name', 'Nuttavut Thongjor')

// ใช้เพื่อดึงค่า property ในอ็อบเจ็กต์
Reflect.get(obj, 'name') // Nuttavut Thongjor

// ใช้เพื่อตรวจสอบว่ามี property นั้นในอ็อบเจ็กต์หรือไม่
Reflect.has(obj, 'age') // true

Proxy

เรื่องสุดท้ายละครับที่ผมจะกล่าวถึงในบทความนี้นั่นคือ Proxy

Proxy นั้นเป็นความสามารถอย่างหนึ่งที่จะทำการห่อหุ่มอ็อบเจ็กต์เอาไว้ จากนั้นจะทำตัวเป็นมือที่สามคั่นกลางระหว่างเราผ่านสิ่งที่เรียกว่า trap

// อ็อบเจ็กต์ตั้งต้นที่เราต้องการเพิ่มความสามารถให้มันด้วยการใช้ Proxy
const target = {}

// handler เป็นอ็อบเจ็กต์ที่นิยามวิธีเพิ่มความสามารถให้อ็อบเจ็กต์ target
// ผ่านเมธอดที่เรียกว่า trap (จะได้พูดถึงต่อไป)
const handler = {}

// วิธีสร้าง Proxy แสนง่ายแค่ส่ง target และ handler เข้าไป
const proxy = new Proxy(target, handler)

trap นั้นคือเมธอดที่ JavaScript มีให้เรานำไปใช้ใน handler ตัวอย่างของ trap เช่น get และ set เราจะมาลองใช้ get เพื่อ log ค่าของ property เมื่อเราเข้าถึงกัน

const target = {}
const handler = {
  // get นี่ละครับคือ trap
  // get ตัวนี้รับพารามิเตอร์3ตัว
  // เราต้องการ log ชือของ property จึงเข้าถึง name
  get: (target, name, receiver) => {
    console.log(`Getting ${name}`)
  }
}

const book = new Proxy(target, handler)
book.title = 'Introduction to ES2015'
book.title // Getting title

ลองดูตัวอย่างสุดคลาสสิกของการใช้ Proxy กันครับ นั่นคือการใช้ Proxy เพื่อตรวจสอบข้อมูล

const validator = {
  // ใช้ trap ชื่อ set
  // เพราะเราต้องการเช็คข้อมูลเมื่อทำการตั้งค่าตัวแปร
  set(object, property, value) {
    switch(property) {
      case 'name':
        // ถ้า name ไม่มีค่าให้ error
        if(value) break
        else throw new TypeError("Can't be null")
      case 'age':
        // ถ้าอายุไม่เป้นตัวเลขจำนวนเต็มให้ error
        if(Number.isInteger(value)) break
        else throw new TypeError("Must be integer")
    }
    
    object[property] = value
    
    // ถ้าสำเร็จต้อง return true กลับออกไปด้วย
    return true
  }
}

const person = new Proxy({}, validator)
person.name = '' // Can't be null
person.age = NaN // Must be integer

จบแล้วครับ ต้องออกตัวก่อนเลยว่าเรื่องของ metaprogramming ใน JavaScript ยังมีอีกเยอะมากและนำไปประยุกต์ต่อได้ในหลายๆทาง หวังว่าบทความชิ้นนี้จะช่วยเบิกทางให้เพื่อนๆอ่านเอกสารในเรื่อง metaprogramming อื่นๆได้เข้าใจมากขึ้นครับ

เอกสารอ้างอิง

getify. Chapter 7: Meta Programming. Retrieved May, 27, 2016, from https://github.com/getify/You-Dont-Know-JS/blob/master/es6 %26 beyond/ch7.md

Dr. Axel Rauschmayer (2015). Classes in ECMAScript 6 (final semantics). Retrieved May, 27, 2016, from http://www.2ality.com/2015/02/es6-classes-final.html

Addy Osmani. Introducing ES2015 Proxies. Retrieved May, 27, 2016, from https://developers.google.com/web/updates/2016/02/es2015-proxies

Keith Cirkel (2015). Metaprogramming in ES6: Symbols and why they’re awesome. Retrieved May, 27, 2016, from http://blog.keithcirkel.co.uk/metaprogramming-in-es6-symbols/

Keith Cirkel (2015). Metaprogramming in ES6: Part 2 - Reflect. Retrieved May, 27, 2016, from http://blog.keithcirkel.co.uk/metaprogramming-in-es6-part-2-reflect/

Dr. Axel Rauschmayer. Metaprogramming with proxies. Retrieved May, 27, 2016, from http://exploringjs.com/es6/ch_proxies.html

Ravi Kiran (2016). Meta Programming in ES6 using Symbols. Retrieved May, 27, 2016, from http://www.dotnetcurry.com/javascript/1265/metaprogramming-javascript-using-es6-symbols

Dr. Axel Rauschmayer (2011). Reflection and meta-programming in JavaScript. Retrieved May, 27, 2016, from http://www.2ality.com/2011/01/reflection-and-meta-programming-in.html


แสดงความคิดเห็นของคุณ


No any discussions