Babel Coder

Private Fields ใน JavaScript: จาก ES5 สู่ ECMAScript Proposal

intermediate

โลกของภาษา OOP สมัยใหม่มักมีกลุ่มของคีย์เวิร์ดที่ใช้ระบุว่าข้อมูลภายในคลาสมีระดับการเข้าถึงเป็นอย่างไร กลุ่มคีย์เวิร์ดเหล่านี้เราเรียกว่า Access Modifiers โดยทั่วไปที่เราพบเห็นได้บ่อยๆคือ public protected และ private (ภาษาที่แตกต่างกันอาจมี Access Modifiers เพิ่มอีก เช่น internal ใน C#)

แม้การมาของ ES2015 จะทำให้ภาษา JavaScript มีไวยากรณ์สำหรับการสร้างคลาส แต่นั่นก็ไม่ทำให้เราสามารถปกปิดการเข้าถึงข้อมูลภายในด้วยหลักการของ private fields ได้อยู่ดี

บทความนี้เราจะไปดูวิธีการปกปิดการเข้าถึงข้อมูลกันจากวิธีการของ ES5 จนถึงข้อเสนอใหม่ของ TC39 ที่จะทำให้เกิด private fields ในภาษา JavaScript อย่างแท้จริง

เพื่อให้เพื่อนๆสามารถอ่านบทความนี้ได้อย่างลื่นไหล พื้นฐานต่อไปนี้คือเรื่องที่สำคัญ

  • JavaScript Prototypes
  • การสร้างอ็อบเจ็กต์ผ่าน Constructor Function
  • WeakMap

เมื่อต้องการซ่อนข้อมูลด้วย Private Fields

Encapsulation เป็นหนึ่งในหลักการของ OOP ที่สำคัญ ที่ช่วยห่อหุ้มชิ้นส่วนต่างๆที่สัมพันธ์กันเข้าไว้ด้วยกัน เสมือนหนึ่งเม็ดแคปซูลที่รวมตัวยาไว้ด้วยกัน ES2015 ได้เพิ่มไวยากรณ์สำหรับการสร้าง class ขึ้นมา ทำให้ภาพรวมของการรวมกลุ่มของข้อมูลและเมธอดชัดเจนมากขึ้น

class SavingAccount {
  balance = 0
  
  deposit(amount) {
    this.balance += amount
  }
  
  withdraw(amount) {
    const newBalance = this.balance - amount
    
    if(newBalance >= 0) this.balance = newBalance
  }
}

เมื่อตัวยาสำคัญของเราอยู่ในแคปซูล เราคงไม่อยากให้มดแสนสกปรกเจาะแคปซูลเข้าไปถึงตัวยาได้โดยง่าย เช่นเดียวกันอ็อบเจ็กของบัญชีออมทรัพย์ (SavingAccount) เราก็คงไม่อยากให้ใครภายนอกไปแก้ไขจำนวนเงินฝาก (balance) ได้เองเช่นกัน ไม่งั้นละก็จะใส่เข้าไปซักร้อยล้าน เอาให้ชิตังเม โป้ง รวย ในชาตินี้เลยหละ~

เพื่อให้ balance ของเราปลอดภัยจากการเข้าถึงจากโลกภายนอก เราจึงต้องซ่อนข้อมูลดังกล่าวเอาไว้ด้วยระดับการเข้าถึงที่ต่างกัน โดยปกติทั้งเมธอดและข้อมูล (field) ใน JavaScript จะ “เสมือน” เป็น public ทำให้เข้าถึงได้จากทุกที่ #แม้จะอยู่ที่ดาวอังคารก็ตาม

ในโลกของ OOP ในภาษาอื่นเช่น C++ เรามักได้ยินคำว่า Access Modifiers ได้แก่ public, private และ protected แล้ว private สำหรับ JavaScript หละ ไปตกหล่นอยู่ที่ไหน?

มีหลายความพยายามที่จะปกปิดการเข้าถึงข้อมูล เช่น …

ซ่อนข้อมูลด้วยฟังก์ชัน Constructor และ Closures

การประกาศตัวแปรไว้ใต้ Constructor ย่อมเป็นผลให้ไม่สามารถเข้าถึงได้จากโลกภายนอก

function SavingAccount() {
  // ประกาศตัวแปรให้มีขอบเขตอยู่ใน constructor
  let balance = 0
}

// เป็นผลให้ตัวแปรดังกล่าวไม่สามารถอ้างถึงได้จากโลกภายนอก
new SavingAccount().balance // undefined

แล้วถ้าเราต้องการแก้ไขค่าของ balance ละจะทำอย่างไร? ง่ายมากครับ เราก็แค่โยนเมธอดใส่ไว้ใน constructor เช่นกัน ดังนี้

// balance มีขอบเขตอยู่แค่ในฟังก์ชันนี้
function SavingAccount(balance = 0) {  
  this.deposit = function(amount) {
    balance += amount
  }
  
  this.withdraw = function(amount) {
    const newBalance = balance - amount
    
    if(newBalance >= 0) balance = newBalance
  }
  
  this.getBalance = function() {
    return balance
  }
}

const acc = new SavingAccount(500)

console.log(acc.getBalance()) // 500

โปรดสังเกต ด้วยวิธีการดังกล่าวเราต้องประกาศเมธอดไว้ภายใต้ constructor นั่นหมายความว่าทุกครั้งที่เราสร้างอ็อบเจ็กต์ใหม่ผ่าน new เราก็จะเรียกฟังก์ชัน constructor ทุกครั้ง เป็นผลให้เกิดเมธอดเหล่านั้นซ้ำๆกันหลายๆชุดในหน่วยความจำของเรา แน่นอนว่าเราไม่สามารถย้ายเมธอดเหล่านี้ไปผูกกับ prototype ได้ เพราะถ้าทำเช่นนั้นก็จะไม่สามารถเข้าถึง balance ที่มีขอบเขตอยู่แค่ในฟังก์ชัน constructor ได้ครับ

กักกันข้อมูลด้วย IIFE

IIFE ย่อมาจาก Immediately-Invoked Function Expression ถอดรหัสเป็นคำไทยได้ว่านิพจน์ของฟังก์ชันที่เรียกใช้ทันที เป็นไงหละยิ่งแปลยิ่งงง~ เพื่อความเข้าใจมากขึ้นลองดูตัวอย่างกันดีกว่า

// ตัวอย่างนี้เราพบว่า foo คือฟังก์ชัน
// เพราะค่าขวามือของเครื่องหมายเท่ากับคือฟังก์ชัน เพียงแต่ครอบด้วยวงเล็บเฉยๆ
const foo = (function() {
  return 'Foo!!'
})

// เราทราบว่าการใส่วงเล็บต่อท้ายฟังก์ชันคือการเรียกใช้ฟังก์ชัน
// foo ในตัวอย่างนี้จึงไม่ได้เก็บฟังก์ชันอีกต่อไป
// แต่เก็บค่าที่คืนกลับมาจากฟังก์ชันนั่นก็คือ Foo!!
// สิ่งนี้หละที่เราเรียกว่า IIFE
const foo = (function() {
  return 'Foo!!'
})()

// ใช้ Fat Arrow ของ ES2015 หน้าตาก็จะเป็นแบบนี้
const foo = (() => {
  return 'Foo!!'
})()

หากเราประกาศตัวแปรภายใต้ฟังก์ชัน ตัวแปรนั้นย่อมมีขอบเขตไม่เกินฟังก์ชันนั้น อาศัยความจริงนี้เราจึงสามารถซ่อนข้อมูลภายใต้ IIFE ได้

const SavingAccount = (() => {
  let _balance = 0
  
  function SavingAccount(balance) {
    _balance = balance
  }
  
  // ตอนนี้เราสามารถย้ายเมธอดของเราไปเชื่อมกับ prototype ได้แล้ว~
  SavingAccount.prototype = {
    ...SavingAccount.prototype,
    
    deposit(amount) {
      _balance += amount
    },
    
    withdraw(amount) {
      const newBalance = this.balance - amount
      
      if(newBalance >= 0) _balance = newBalance
    },
    
    getBalance() {
      return _balance
    }
  }
  
  return SavingAccount
})()

const acc = new SavingAccount(500)
console.log(acc.getBalance()) // 500

ด้วยการผสานพลังของทั้ง IIFE และฟังก์ชัน constructor ตอนนี้เราโยกเมธอดไปผูกความสัมพันธ์กับ prototype ได้แล้ว ฮูเล่~ นั่นหมายความว่าเมธอดของเราจะมีเพียงชุดเดียว ไม่ได้มีเยอะแยะตามจำนวนอ็อบเจ็กต์ที่สร้างผ่าน new อีกต่อไป

ทว่า… กับดักยังคงมีอยู่ ทุกครั้งที่เราสร้างอ็อบเจ็กต์ใหม่ เราสามารถส่งค่าเข้าไปในฟังก์ชัน constructor ได้ เป็นผลให้ค่าของ _balance ถูกเขียนใหม่ทุกครั้ง นั่นหมายความว่าค่าของ balance จากอ็อบเจ็กต์ที่สร้างขึ้นใหม่จะไปทับของเก่า ดังนี้

const acc = new SavingAccount(500)
const acc2 = new SavingAccount(300)
console.log(acc.getBalance()) // 300
console.log(acc2.getBalance()) // 300

พังทลายดังโคร่ม หมดกันค่าแรงขั้นต่ำของฉ้านนน~

ปกปิดข้อมูลด้วยการเก็บค่าลงอ็อบเจ็กต์

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

// อ็อบเจ็กต์ที่ทำตัวเป็น Hash จับคู่ระหว่าง ID กับค่าของมัน
let privateData = {}
// กำหนดค่าเริ่มต้นให้ ID เป็น 1
let id = 1

// โค้ดภายใต้คอนสตรัคเตอร์
// หลังกำหนดค่าแล้ว ให้เพิ่มค่า ID ขึ้นอีก 1
privateData[id++] = {
  // กำหนดค่า balance ให้จำเพาะกับ ID นั้นๆ
  // โดย ID จะใช้เป็นสื่อกลางแทนอ็อบเจ็กต์
  balance: balance
}

หน้าตาของโค้ดที่เปลี่ยนไปหลังใช้รูปแบบนี้แสดงได้ดังนี้

const SavingAccount = (() => {
  let privateData = {}
  let id = 1
  
  function SavingAccount(balance) {
    // ฝังค่าให้อ็อบเจ็กต์มี ID เป็นค่าตามที่ระบุ
    this.id = id
    
    privateData[id++] = {
      balance
    }
  }
  
  SavingAccount.prototype = {
    ...SavingAccount.prototype,
    
    deposit(amount) {
      privateData[this.id].balance += amount
    },
    
    withdraw(amount) {
      const self = privateData[this.id]
      const newBalance = self.balance - amount
      
      if(newBalance >= 0) self.balance = newBalance
    },
    
    getBalance() {
      return privateData[this.id].balance
    }
  }
  
  return SavingAccount
})()

const acc = new SavingAccount(500)
const acc2 = new SavingAccount(300)
console.log(acc.getBalance()) // 500
console.log(acc2.getBalance()) // 300

เอามือทาบอก โบกมือสวยๆแบบนางงาม แล้วร้องขึ้นว่าหนูรัก JavaScript ค่ะ~

แม้โค้ดชุดนี้จะทำงานได้ดีตามคาดหมาย แต่มีสองสิ่งที่ถือเป็นขวากหนามต่อการเข้าถึง private field ร่างสุดยอด ประการแรกเราพบความยุ่งยากของการตั้งค่า ID และการอัพเดทค่าของมันเพื่อเป็นตัวแทนของอ็อบเจ็กต์ นั่นยังไม่บาปเท่ากับปัญหาที่สองนั่นคืออ็อบเจ็กต์จะไม่ถูกทำลายอย่างสมบูรณ์ (Memory Leak)

privateData ที่เป็นอ็อบเจ็กต์สำหรับเก็บค่าอ็อบเจ็กต์ของ SavingAccount จะยังคงถือครองอ็อบเจ็กต์บัญชีออมทรัพย์ต่อไปเรื่อยๆ แม้พวกมันจะไม่ถูกใช้งานแล้วก็ตาม

ช่างขายหน้ายิ่งนักที่โค้ดของเราเขียนมา 1 หน้ากระดาษ A4 แล้วแต่ยังไม่สมบูรณ์ซักที~

เหนือชั้นกว่าด้วยการซ่อนข้อมูลผ่าน WeakMaps

WeakMap เป็นโครงสร้างข้อมูลชนิดหนึ่งที่เป็น Map แสดงว่าการจัดเก็บข้อมูลของเราต้องประกอบด้วย Key และ Value สำหรับ WeakMap นั้นต้องเป็นอ็อบเจ็กต์เท่านั้น ส่วน Value นั้นหรือ? จะเป็นอะไรก็ได้ใครแคร์?

คีย์ของ WeakMap จะถูกกระบวนการของ Garbage Collector ทำลายเมื่อไม่ได้ใช้งาน ไม่เหมือนการผูกความสัมพันธ์กับอ็อบเจ็กต์ในตัวอย่างก่อนหน้า การใช้ WeakMap จึงช่วยแก้ปัญหา Memory Leak ได้

const SavingAccount = (() => {
  let privateData = new WeakMap()

  function SavingAccount(balance) {
    // Key คืออ็บเจ็กต์ this
    // Value คืออ็อบเจ็กต์ที่เก็บค่าของ balance ไว้
    privateData.set(this, { balance })
  }
  
  SavingAccount.prototype = {
    ...SavingAccount.prototype,
    
    deposit(amount) {
      privateData.get(this).balance += amount
    },
    
    withdraw(amount) {
      const self = privateData.get(this)
      const newBalance = self.balance - amount
      
      if(newBalance >= 0) self.balance = newBalance
    },
    
    getBalance() {
      return privateData.get(this).balance
    }
  }
  
  return SavingAccount
})()

เมื่อเราใช้ WeakMap เราจึงไม่จำเป็นต้องสร้าง ID ขึ้นมา นั่นเพราะเราใช้อ็อบเจ็กต์ this เป็น Key แล้วนั่นเอง

จากฟังก์ชันคอนสตรัคเตอร์สู่คลาส

นี่มันปี 2017 แล้ว มันต้องใช้คลาสซิถึงจะดูมีระดับ

const SavingAccount = (() => {
  let privateData = new WeakMap()
  
  return class {
    constructor(balance) {
      privateData.set(this, { balance })
    }
    
    deposit(amount) {
      privateData.get(this).balance += amount
    }
    
    withdraw(amount) {
      const self = privateData.get(this)
      const newBalance = self.balance - amount
      
      if(newBalance >= 0) self.balance = newBalance
    }
    
    getBalance() {
      return privateData.get(this).balance
    }
  }
})()

ค่อยรู้สึกคุ้มกับประกันสังคมที่จ่ายทุกเดือนหน่อย เพราะเราใช้ class เราจึงดูมีคลาสขึ้นมาอย่างผิดหูผิดตา

สูงสุดคืนสู่สามัญด้วยการใช้ Prefix

เพื่อให้ได้มาซึ่ง private fields เราต้องงัดสารพัดวิธีมาแก้ปัญหา นั่นเพราะ JavaScript ไม่มีคอนเซ็ปต์ของ private fields มาแต่แรกนั่นเอง แม้เราจะใช้ WeakMap แล้วก็ตามความซับซ้อนของโค้ดก็ไม่ได้ลดลง

กาลครั้งหนึ่งไม่นานเท่าไหร่ เรานิยมตั้งชื่อ private fields เหล่านี้ด้วยการใส่ _ (underscore) ด้วยติ๊ต่างเอาเอ่งว่าทุกคนในทีมจะเข้าใจร่วมกันว่าฟิลด์ไหนนำหน้าด้วย _ ถือว่าเป็นของสูง อย่าสะเออะเขียนโค้ดให้ภายนอกมาแตะต้องมัน

class SavingAccount {
  constructor(balance) {
    // _balance เป็นของสูง
    // ขึ้นหิ้งเอาไว้ เรียกใช้ได้แต่ภายในคลาส
    // ห้ามเขียนโค้ดจากข้างนอกมาเรียกใช้มัน
    this._balance = balance
  }
  
  deposit(amount) {
    this._balance += amount
  }
  
  withdraw(amount) {
    const newBalance = this._balance - amount
    
    if(newBalance >= 0) this._balance = newBalance
  }
  
  getBalance() {
    return this._balance
  }
}

แน่นอนว่าวิธีนี้คือการมโนล้วนๆครับ ต้องตกลงกับคนในทีมให้ดีๆว่าห้ามให้โค้ดภายนอกเข้าถึงมันนะ เพราะความจริงแล้วการใส่ _ ไม่ได้ช่วยปกปิดอะไรอย่างแท้จริงเลยนะซิ!

ECMAScript Private Fields Proposal

มาตรฐานของ ECMAScript ได้มีการเสนอเรื่องการใช้งาน private fields แน่นอนว่าตอนนี้ยังไม่เป็นมาตรฐาน แต่ผมจะนำมากล่าวถึงเพื่อให้เพื่อนๆพอเห็นภาพรวมของสิ่งที่อาจจะเพิ่มขึ้นในอนาคต

ตามข้อกำหนดนี้ private fields สามารถสร้างได้ด้วยการเติมเครื่องหมาย # นำหน้าชื่อฟิลด์และสามารถอ้างอิงถึงฟิลด์นี้ได้ผ่านเครื่องหมาย # และชื่อฟิลด์เช่นกัน ดังนี้

class SavingAccount {
  #balance = 0
  
  constructor(balance) {
    #balance = balance
  }
  
  deposit(amount) {
    #balance += amount
  }
  
  withdraw(amount) {
    const newBalance = #balance - amount
    
    if(newBalance >= 0) #balance = newBalance
  }
  
  getBalance() {
    return #balance
  }
}

ดูง่ายขึ้นเยอะเลยใช่ไหมละ!

เนื่องจากข้อเสนอนี้ยังไม่เป็นมาตรฐานทางภาษาจึงอาจมีการปรับแก้ในอนาคต ผมจึงไม่ขอลงรายละเอียดมากไปกว่านี้ สำหรับเพื่อนๆที่สนใจว่าทำไมต้องใช้เครื่องหมาย # แทนที่จะใช้คีย์เวิร์ด private แบบภาษาอื่นๆ สามารถหาคำตอบได้จาก ลิงก์นี้ครับ

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

TC39. A Private Fields Proposal for ECMAScript. Retrieved July, 11, 2017, from https://github.com/tc39/proposal-private-fields

Nicholas C. Zakas (2014). Private instance members with weakmaps in JavaScript. Retrieved July, 11, 2017, from https://www.nczonline.net/blog/2014/01/21/private-instance-members-with-weakmaps-in-javascript/

MDN. Private Properties. Retrieved July, 11, 2017, from https://developer.mozilla.org/en-US/Add-ons/SDK/Guides/Contributor_s_Guide/Private_Properties


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


No any discussions