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

Nuttavut Thongjor

โลกของภาษา 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 ขึ้นมา ทำให้ภาพรวมของการรวมกลุ่มของข้อมูลและเมธอดชัดเจนมากขึ้น

JavaScript
1class SavingAccount {
2 balance = 0
3
4 deposit(amount) {
5 this.balance += amount
6 }
7
8 withdraw(amount) {
9 const newBalance = this.balance - amount
10
11 if (newBalance >= 0) this.balance = newBalance
12 }
13}

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

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

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

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

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

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

JavaScript
1function SavingAccount() {
2 // ประกาศตัวแปรให้มีขอบเขตอยู่ใน constructor
3 let balance = 0
4}
5
6// เป็นผลให้ตัวแปรดังกล่าวไม่สามารถอ้างถึงได้จากโลกภายนอก
7new SavingAccount().balance // undefined

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

JavaScript
1// balance มีขอบเขตอยู่แค่ในฟังก์ชันนี้
2function SavingAccount(balance = 0) {
3 this.deposit = function (amount) {
4 balance += amount
5 }
6
7 this.withdraw = function (amount) {
8 const newBalance = balance - amount
9
10 if (newBalance >= 0) balance = newBalance
11 }
12
13 this.getBalance = function () {
14 return balance
15 }
16}
17
18const acc = new SavingAccount(500)
19
20console.log(acc.getBalance()) // 500

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

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

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

JavaScript
1// ตัวอย่างนี้เราพบว่า foo คือฟังก์ชัน
2// เพราะค่าขวามือของเครื่องหมายเท่ากับคือฟังก์ชัน เพียงแต่ครอบด้วยวงเล็บเฉยๆ
3const foo = function () {
4 return 'Foo!!'
5}
6
7// เราทราบว่าการใส่วงเล็บต่อท้ายฟังก์ชันคือการเรียกใช้ฟังก์ชัน
8// foo ในตัวอย่างนี้จึงไม่ได้เก็บฟังก์ชันอีกต่อไป
9// แต่เก็บค่าที่คืนกลับมาจากฟังก์ชันนั่นก็คือ Foo!!
10// สิ่งนี้หละที่เราเรียกว่า IIFE
11const foo = (function () {
12 return 'Foo!!'
13})()
14
15// ใช้ Fat Arrow ของ ES2015 หน้าตาก็จะเป็นแบบนี้
16const foo = (() => {
17 return 'Foo!!'
18})()

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

JavaScript
1const SavingAccount = (() => {
2 let _balance = 0
3
4 function SavingAccount(balance) {
5 _balance = balance
6 }
7
8 // ตอนนี้เราสามารถย้ายเมธอดของเราไปเชื่อมกับ prototype ได้แล้ว~
9 SavingAccount.prototype = {
10 ...SavingAccount.prototype,
11
12 deposit(amount) {
13 _balance += amount
14 },
15
16 withdraw(amount) {
17 const newBalance = this.balance - amount
18
19 if (newBalance >= 0) _balance = newBalance
20 },
21
22 getBalance() {
23 return _balance
24 },
25 }
26
27 return SavingAccount
28})()
29
30const acc = new SavingAccount(500)
31console.log(acc.getBalance()) // 500

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

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

JavaScript
1const acc = new SavingAccount(500)
2const acc2 = new SavingAccount(300)
3console.log(acc.getBalance()) // 300
4console.log(acc2.getBalance()) // 300

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

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

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

JavaScript
1// อ็อบเจ็กต์ที่ทำตัวเป็น Hash จับคู่ระหว่าง ID กับค่าของมัน
2let privateData = {}
3// กำหนดค่าเริ่มต้นให้ ID เป็น 1
4let id = 1
5
6// โค้ดภายใต้คอนสตรัคเตอร์
7// หลังกำหนดค่าแล้ว ให้เพิ่มค่า ID ขึ้นอีก 1
8privateData[id++] = {
9 // กำหนดค่า balance ให้จำเพาะกับ ID นั้นๆ
10 // โดย ID จะใช้เป็นสื่อกลางแทนอ็อบเจ็กต์
11 balance: balance,
12}

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

JavaScript
1const SavingAccount = (() => {
2 let privateData = {}
3 let id = 1
4
5 function SavingAccount(balance) {
6 // ฝังค่าให้อ็อบเจ็กต์มี ID เป็นค่าตามที่ระบุ
7 this.id = id
8
9 privateData[id++] = {
10 balance,
11 }
12 }
13
14 SavingAccount.prototype = {
15 ...SavingAccount.prototype,
16
17 deposit(amount) {
18 privateData[this.id].balance += amount
19 },
20
21 withdraw(amount) {
22 const self = privateData[this.id]
23 const newBalance = self.balance - amount
24
25 if (newBalance >= 0) self.balance = newBalance
26 },
27
28 getBalance() {
29 return privateData[this.id].balance
30 },
31 }
32
33 return SavingAccount
34})()
35
36const acc = new SavingAccount(500)
37const acc2 = new SavingAccount(300)
38console.log(acc.getBalance()) // 500
39console.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 ได้

JavaScript
1const SavingAccount = (() => {
2 let privateData = new WeakMap()
3
4 function SavingAccount(balance) {
5 // Key คืออ็บเจ็กต์ this
6 // Value คืออ็อบเจ็กต์ที่เก็บค่าของ balance ไว้
7 privateData.set(this, { balance })
8 }
9
10 SavingAccount.prototype = {
11 ...SavingAccount.prototype,
12
13 deposit(amount) {
14 privateData.get(this).balance += amount
15 },
16
17 withdraw(amount) {
18 const self = privateData.get(this)
19 const newBalance = self.balance - amount
20
21 if (newBalance >= 0) self.balance = newBalance
22 },
23
24 getBalance() {
25 return privateData.get(this).balance
26 },
27 }
28
29 return SavingAccount
30})()

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

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

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

JavaScript
1const SavingAccount = (() => {
2 let privateData = new WeakMap()
3
4 return class {
5 constructor(balance) {
6 privateData.set(this, { balance })
7 }
8
9 deposit(amount) {
10 privateData.get(this).balance += amount
11 }
12
13 withdraw(amount) {
14 const self = privateData.get(this)
15 const newBalance = self.balance - amount
16
17 if (newBalance >= 0) self.balance = newBalance
18 }
19
20 getBalance() {
21 return privateData.get(this).balance
22 }
23 }
24})()

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

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

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

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

JavaScript
1class SavingAccount {
2 constructor(balance) {
3 // _balance เป็นของสูง
4 // ขึ้นหิ้งเอาไว้ เรียกใช้ได้แต่ภายในคลาส
5 // ห้ามเขียนโค้ดจากข้างนอกมาเรียกใช้มัน
6 this._balance = balance
7 }
8
9 deposit(amount) {
10 this._balance += amount
11 }
12
13 withdraw(amount) {
14 const newBalance = this._balance - amount
15
16 if (newBalance >= 0) this._balance = newBalance
17 }
18
19 getBalance() {
20 return this._balance
21 }
22}

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

ECMAScript Private Fields Proposal

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

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

JavaScript
1class SavingAccount {
2 #balance = 0
3
4 constructor(balance) {
5 #balance = balance
6 }
7
8 deposit(amount) {
9 #balance += amount
10 }
11
12 withdraw(amount) {
13 const newBalance = #balance - amount
14
15 if(newBalance >= 0) #balance = newBalance
16 }
17
18 getBalance() {
19 return #balance
20 }
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
  • เอกสารอ้างอิง