Private Fields ใน JavaScript: จาก ES5 สู่ ECMAScript Proposal
โลกของภาษา 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
ขึ้นมา ทำให้ภาพรวมของการรวมกลุ่มของข้อมูลและเมธอดชัดเจนมากขึ้น
1class SavingAccount {2 balance = 034 deposit(amount) {5 this.balance += amount6 }78 withdraw(amount) {9 const newBalance = this.balance - amount1011 if (newBalance >= 0) this.balance = newBalance12 }13}
เมื่อตัวยาสำคัญของเราอยู่ในแคปซูล เราคงไม่อยากให้มดแสนสกปรกเจาะแคปซูลเข้าไปถึงตัวยาได้โดยง่าย เช่นเดียวกันอ็อบเจ็กของบัญชีออมทรัพย์ (SavingAccount) เราก็คงไม่อยากให้ใครภายนอกไปแก้ไขจำนวนเงินฝาก (balance) ได้เองเช่นกัน ไม่งั้นละก็จะใส่เข้าไปซักร้อยล้าน เอาให้ชิตังเม โป้ง รวย ในชาตินี้เลยหละ~
เพื่อให้ balance ของเราปลอดภัยจากการเข้าถึงจากโลกภายนอก เราจึงต้องซ่อนข้อมูลดังกล่าวเอาไว้ด้วยระดับการเข้าถึงที่ต่างกัน โดยปกติทั้งเมธอดและข้อมูล (field) ใน JavaScript จะ "เสมือน" เป็น public
ทำให้เข้าถึงได้จากทุกที่ #แม้จะอยู่ที่ดาวอังคารก็ตาม
ในโลกของ OOP ในภาษาอื่นเช่น C++ เรามักได้ยินคำว่า Access Modifiers
ได้แก่ public, private และ protected แล้ว private
สำหรับ JavaScript หละ ไปตกหล่นอยู่ที่ไหน?
มีหลายความพยายามที่จะปกปิดการเข้าถึงข้อมูล เช่น ...
ซ่อนข้อมูลด้วยฟังก์ชัน Constructor และ Closures
การประกาศตัวแปรไว้ใต้ Constructor ย่อมเป็นผลให้ไม่สามารถเข้าถึงได้จากโลกภายนอก
1function SavingAccount() {2 // ประกาศตัวแปรให้มีขอบเขตอยู่ใน constructor3 let balance = 04}56// เป็นผลให้ตัวแปรดังกล่าวไม่สามารถอ้างถึงได้จากโลกภายนอก7new SavingAccount().balance // undefined
แล้วถ้าเราต้องการแก้ไขค่าของ balance ละจะทำอย่างไร? ง่ายมากครับ เราก็แค่โยนเมธอดใส่ไว้ใน constructor เช่นกัน ดังนี้
1// balance มีขอบเขตอยู่แค่ในฟังก์ชันนี้2function SavingAccount(balance = 0) {3 this.deposit = function (amount) {4 balance += amount5 }67 this.withdraw = function (amount) {8 const newBalance = balance - amount910 if (newBalance >= 0) balance = newBalance11 }1213 this.getBalance = function () {14 return balance15 }16}1718const acc = new SavingAccount(500)1920console.log(acc.getBalance()) // 500
โปรดสังเกต ด้วยวิธีการดังกล่าวเราต้องประกาศเมธอดไว้ภายใต้ constructor นั่นหมายความว่าทุกครั้งที่เราสร้างอ็อบเจ็กต์ใหม่ผ่าน new
เราก็จะเรียกฟังก์ชัน constructor ทุกครั้ง เป็นผลให้เกิดเมธอดเหล่านั้นซ้ำๆกันหลายๆชุดในหน่วยความจำของเรา แน่นอนว่าเราไม่สามารถย้ายเมธอดเหล่านี้ไปผูกกับ prototype
ได้ เพราะถ้าทำเช่นนั้นก็จะไม่สามารถเข้าถึง balance ที่มีขอบเขตอยู่แค่ในฟังก์ชัน constructor ได้ครับ
กักกันข้อมูลด้วย IIFE
IIFE ย่อมาจาก Immediately-Invoked Function Expression ถอดรหัสเป็นคำไทยได้ว่านิพจน์ของฟังก์ชันที่เรียกใช้ทันที เป็นไงหละยิ่งแปลยิ่งงง~ เพื่อความเข้าใจมากขึ้นลองดูตัวอย่างกันดีกว่า
1// ตัวอย่างนี้เราพบว่า foo คือฟังก์ชัน2// เพราะค่าขวามือของเครื่องหมายเท่ากับคือฟังก์ชัน เพียงแต่ครอบด้วยวงเล็บเฉยๆ3const foo = function () {4 return 'Foo!!'5}67// เราทราบว่าการใส่วงเล็บต่อท้ายฟังก์ชันคือการเรียกใช้ฟังก์ชัน8// foo ในตัวอย่างนี้จึงไม่ได้เก็บฟังก์ชันอีกต่อไป9// แต่เก็บค่าที่คืนกลับมาจากฟังก์ชันนั่นก็คือ Foo!!10// สิ่งนี้หละที่เราเรียกว่า IIFE11const foo = (function () {12 return 'Foo!!'13})()1415// ใช้ Fat Arrow ของ ES2015 หน้าตาก็จะเป็นแบบนี้16const foo = (() => {17 return 'Foo!!'18})()
หากเราประกาศตัวแปรภายใต้ฟังก์ชัน ตัวแปรนั้นย่อมมีขอบเขตไม่เกินฟังก์ชันนั้น อาศัยความจริงนี้เราจึงสามารถซ่อนข้อมูลภายใต้ IIFE ได้
1const SavingAccount = (() => {2 let _balance = 034 function SavingAccount(balance) {5 _balance = balance6 }78 // ตอนนี้เราสามารถย้ายเมธอดของเราไปเชื่อมกับ prototype ได้แล้ว~9 SavingAccount.prototype = {10 ...SavingAccount.prototype,1112 deposit(amount) {13 _balance += amount14 },1516 withdraw(amount) {17 const newBalance = this.balance - amount1819 if (newBalance >= 0) _balance = newBalance20 },2122 getBalance() {23 return _balance24 },25 }2627 return SavingAccount28})()2930const acc = new SavingAccount(500)31console.log(acc.getBalance()) // 500
ด้วยการผสานพลังของทั้ง IIFE และฟังก์ชัน constructor ตอนนี้เราโยกเมธอดไปผูกความสัมพันธ์กับ prototype
ได้แล้ว ฮูเล่~ นั่นหมายความว่าเมธอดของเราจะมีเพียงชุดเดียว ไม่ได้มีเยอะแยะตามจำนวนอ็อบเจ็กต์ที่สร้างผ่าน new
อีกต่อไป
ทว่า... กับดักยังคงมีอยู่ ทุกครั้งที่เราสร้างอ็อบเจ็กต์ใหม่ เราสามารถส่งค่าเข้าไปในฟังก์ชัน constructor ได้ เป็นผลให้ค่าของ _balance
ถูกเขียนใหม่ทุกครั้ง นั่นหมายความว่าค่าของ balance จากอ็อบเจ็กต์ที่สร้างขึ้นใหม่จะไปทับของเก่า ดังนี้
1const acc = new SavingAccount(500)2const acc2 = new SavingAccount(300)3console.log(acc.getBalance()) // 3004console.log(acc2.getBalance()) // 300
พังทลายดังโคร่ม หมดกันค่าแรงขั้นต่ำของฉ้านนน~
ปกปิดข้อมูลด้วยการเก็บค่าลงอ็อบเจ็กต์
เมื่อไม่ต้องการให้ค่าข้อมูลใช้งานร่วมกัน เราต้องระบุว่าข้อมูลตัวใดเป็นของอ็อบเจ็กต์ตัวไหน วิธีการของเราจึงต้องกำหนดค่า ID ที่แตกต่างให้กับแต่ละอ็อบเจ็กต์ก่อน จากนั้นจึงกำหนดค่าข้อมูลโดยระบุเพิ่มว่าข้อมูลนี้เป็นของอ็อบเจ็กต์ที่มี ID เป็นอะไร
1// อ็อบเจ็กต์ที่ทำตัวเป็น Hash จับคู่ระหว่าง ID กับค่าของมัน2let privateData = {}3// กำหนดค่าเริ่มต้นให้ ID เป็น 14let id = 156// โค้ดภายใต้คอนสตรัคเตอร์7// หลังกำหนดค่าแล้ว ให้เพิ่มค่า ID ขึ้นอีก 18privateData[id++] = {9 // กำหนดค่า balance ให้จำเพาะกับ ID นั้นๆ10 // โดย ID จะใช้เป็นสื่อกลางแทนอ็อบเจ็กต์11 balance: balance,12}
หน้าตาของโค้ดที่เปลี่ยนไปหลังใช้รูปแบบนี้แสดงได้ดังนี้
1const SavingAccount = (() => {2 let privateData = {}3 let id = 145 function SavingAccount(balance) {6 // ฝังค่าให้อ็อบเจ็กต์มี ID เป็นค่าตามที่ระบุ7 this.id = id89 privateData[id++] = {10 balance,11 }12 }1314 SavingAccount.prototype = {15 ...SavingAccount.prototype,1617 deposit(amount) {18 privateData[this.id].balance += amount19 },2021 withdraw(amount) {22 const self = privateData[this.id]23 const newBalance = self.balance - amount2425 if (newBalance >= 0) self.balance = newBalance26 },2728 getBalance() {29 return privateData[this.id].balance30 },31 }3233 return SavingAccount34})()3536const acc = new SavingAccount(500)37const acc2 = new SavingAccount(300)38console.log(acc.getBalance()) // 50039console.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 ได้
1const SavingAccount = (() => {2 let privateData = new WeakMap()34 function SavingAccount(balance) {5 // Key คืออ็บเจ็กต์ this6 // Value คืออ็อบเจ็กต์ที่เก็บค่าของ balance ไว้7 privateData.set(this, { balance })8 }910 SavingAccount.prototype = {11 ...SavingAccount.prototype,1213 deposit(amount) {14 privateData.get(this).balance += amount15 },1617 withdraw(amount) {18 const self = privateData.get(this)19 const newBalance = self.balance - amount2021 if (newBalance >= 0) self.balance = newBalance22 },2324 getBalance() {25 return privateData.get(this).balance26 },27 }2829 return SavingAccount30})()
เมื่อเราใช้ WeakMap เราจึงไม่จำเป็นต้องสร้าง ID ขึ้นมา นั่นเพราะเราใช้อ็อบเจ็กต์ this
เป็น Key แล้วนั่นเอง
จากฟังก์ชันคอนสตรัคเตอร์สู่คลาส
นี่มันปี 2017 แล้ว มันต้องใช้คลาสซิถึงจะดูมีระดับ
1const SavingAccount = (() => {2 let privateData = new WeakMap()34 return class {5 constructor(balance) {6 privateData.set(this, { balance })7 }89 deposit(amount) {10 privateData.get(this).balance += amount11 }1213 withdraw(amount) {14 const self = privateData.get(this)15 const newBalance = self.balance - amount1617 if (newBalance >= 0) self.balance = newBalance18 }1920 getBalance() {21 return privateData.get(this).balance22 }23 }24})()
ค่อยรู้สึกคุ้มกับประกันสังคมที่จ่ายทุกเดือนหน่อย เพราะเราใช้ class
เราจึงดูมีคลาสขึ้นมาอย่างผิดหูผิดตา
สูงสุดคืนสู่สามัญด้วยการใช้ Prefix
เพื่อให้ได้มาซึ่ง private fields เราต้องงัดสารพัดวิธีมาแก้ปัญหา นั่นเพราะ JavaScript ไม่มีคอนเซ็ปต์ของ private fields มาแต่แรกนั่นเอง แม้เราจะใช้ WeakMap แล้วก็ตามความซับซ้อนของโค้ดก็ไม่ได้ลดลง
กาลครั้งหนึ่งไม่นานเท่าไหร่ เรานิยมตั้งชื่อ private fields เหล่านี้ด้วยการใส่ _
(underscore) ด้วยติ๊ต่างเอาเอ่งว่าทุกคนในทีมจะเข้าใจร่วมกันว่าฟิลด์ไหนนำหน้าด้วย _
ถือว่าเป็นของสูง อย่าสะเออะเขียนโค้ดให้ภายนอกมาแตะต้องมัน
1class SavingAccount {2 constructor(balance) {3 // _balance เป็นของสูง4 // ขึ้นหิ้งเอาไว้ เรียกใช้ได้แต่ภายในคลาส5 // ห้ามเขียนโค้ดจากข้างนอกมาเรียกใช้มัน6 this._balance = balance7 }89 deposit(amount) {10 this._balance += amount11 }1213 withdraw(amount) {14 const newBalance = this._balance - amount1516 if (newBalance >= 0) this._balance = newBalance17 }1819 getBalance() {20 return this._balance21 }22}
แน่นอนว่าวิธีนี้คือการมโนล้วนๆครับ ต้องตกลงกับคนในทีมให้ดีๆว่าห้ามให้โค้ดภายนอกเข้าถึงมันนะ เพราะความจริงแล้วการใส่ _
ไม่ได้ช่วยปกปิดอะไรอย่างแท้จริงเลยนะซิ!
ECMAScript Private Fields Proposal
มาตรฐานของ ECMAScript ได้มีการเสนอเรื่องการใช้งาน private fields แน่นอนว่าตอนนี้ยังไม่เป็นมาตรฐาน แต่ผมจะนำมากล่าวถึงเพื่อให้เพื่อนๆพอเห็นภาพรวมของสิ่งที่อาจจะเพิ่มขึ้นในอนาคต
ตามข้อกำหนดนี้ private fields สามารถสร้างได้ด้วยการเติมเครื่องหมาย #
นำหน้าชื่อฟิลด์และสามารถอ้างอิงถึงฟิลด์นี้ได้ผ่านเครื่องหมาย #
และชื่อฟิลด์เช่นกัน ดังนี้
1class SavingAccount {2 #balance = 034 constructor(balance) {5 #balance = balance6 }78 deposit(amount) {9 #balance += amount10 }1112 withdraw(amount) {13 const newBalance = #balance - amount1415 if(newBalance >= 0) #balance = newBalance16 }1718 getBalance() {19 return #balance20 }21}
ดูง่ายขึ้นเยอะเลยใช่ไหมละ!
เนื่องจากข้อเสนอนี้ยังไม่เป็นมาตรฐานทางภาษาจึงอาจมีการปรับแก้ในอนาคต ผมจึงไม่ขอลงรายละเอียดมากไปกว่านี้ สำหรับเพื่อนๆที่สนใจว่าทำไมต้องใช้เครื่องหมาย #
แทนที่จะใช้คีย์เวิร์ด 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
สารบัญ
- เมื่อต้องการซ่อนข้อมูลด้วย Private Fields
- ซ่อนข้อมูลด้วยฟังก์ชัน Constructor และ Closures
- กักกันข้อมูลด้วย IIFE
- ปกปิดข้อมูลด้วยการเก็บค่าลงอ็อบเจ็กต์
- เหนือชั้นกว่าด้วยการซ่อนข้อมูลผ่าน WeakMaps
- จากฟังก์ชันคอนสตรัคเตอร์สู่คลาส
- ECMAScript Private Fields Proposal
- เอกสารอ้างอิง