[TypeScript#2] การใช้งานคลาสใน TypeScript

Nuttavut Thongjor

แม้เราจะสามารถใช้งานคลาสใน ES2015 ได้แล้ว ถึงอย่างนั้นผู้ใช้ OOP จากภาษาอื่นก็อาจต้องการอะไรที่มากกว่าสิ่งที่ ES2015 จัดมาให้ TypeScript จาก Microsoft ผู้พัฒนาภาษา C# จึงอัดคุณสมบัติของคลาสและ OOP ใส่มาให้ใน TypeScript แบบแรงชัดจัดเต็ม เรียกได้ว่าใช้ TypeScript คล่องแล้วก็อย่าลืมกลับไปอุดหนุน C# จากเฮียเขาบ้างแล้วกัน

ก่อนที่เพื่อนๆจะอ่านบทความนี้ เราขอแนะนำให้อ่านสองบทความนี้ก่อนครับ

เพิ่มชนิดข้อมูลให้คลาสใน ES2015

ในเบื้องต้นนั้นคลาสของ TypeScript ก็คือการต่อยอดคลาสมาจาก ES2015 ครับ โดยเพิ่มชนิดข้อมูลและความสามารถอื่นใส่เข้าไปด้วย พิจารณาคลาสที่แปะชนิดข้อมูลดังนี้ครับ

TypeScript
1class Human {
2 // เกิดเป็นคนไม่มีชื่อได้ยังไง
3 name: string
4
5 // พารามิเตอร์ของ constructor ฟังก์ชันก็ต้องมีการระบุชนิดข้อมูลเช่นกัน
6 constructor(name: string) {
7 this.name = name
8 }
9}
10
11// ประกาศ somchai แบบนี้ TypeScript จะอนุมานได้ว่า
12// somchai มีชนิดข้อมูลเป็น Human โดยอาศัยดูจากค่าขวามือ
13const somchai = new Human('Somchai')
14// หรือ
15// บอกให้ชัดเจนไปเลยว่า somchai เป็น**คน**นะเออ
16const somchai: Human = new Human('Somchai')

การสืบทอดหรือ Inheritance

เนื่องจาก TypeScript มีไวยากรณ์ที่ครอบทับ ES2015 อีกที เราจึงใช้ extends เพื่อสืบทอดคลาสได้เช่นเดียวกัน ข้อจำกัดของการสืบทอดคลาสคือ ถ้าคลาสลูกมีการนิยาม constructor ไว้ต้องใส่ super เพื่อทำการเรียก constructor ของคลาสแม่ก่อน ก็ยังคงอยู่เช่นกัน

TypeScript
1class Human {}
2
3class Woman extends Human {
4 constructor() {
5 // เมื่อคลาสลูกมี constructor อย่าลืมเรียก super นะครับ
6 super()
7 }
8}

Access Modifiers

ยังจำพวกเราได้ไหม public, private, protected พวกเราคือระดับการเข้าถึง (Access Modifiers) ไง อย่าคิดว่าหนีมาใช้ JavaScript แล้วพวกเราจะไม่ตามมาหลอกหลอนนะ จงกลับมารู้จักกับพวกเราใหม่อีกครั้งใน TypeScript!

โดยปกติสมาชิกภายใต้คลาสจะมีสถานะเป็น public สถานะนี้เป็นการบอกว่าสมาชิกตัวดังกล่าวสามารถเข้าถึงได้จากใครก็ได้ ที่ไหนก็ได้

TypeScript
1class Human {
2 // สมาชิกของคลาสตัวนี้ ไม่มีการระบุอะไรทั้งสิ้น จึงมีค่าเท่ากับ
3 // public name: string
4 // เมื่อเป็น public จึงสามารถเข้าถึงจากที่ไหนก็ได้
5 name: string
6
7 constructor(name: string) {
8 this.name = name
9 }
10
11 // เมธอดนี้ไม่ได้ระบุ Access Modifiers ใดๆ จึงมีค่าเท่ากับ
12 // public printName()
13 public printName() {
14 // name ซึ่งเป็น public เข้าถึงจากภายในคลาสเองก็ได้
15 connsole.log(this.name)
16 }
17}
18
19const somchai = new Human('Somchai')
20// เข้าถึงจากภายนอกคลาสก็ย่อมได้
21somchai.name = 'Somsree'

public นั้นไม่ปลอดภัยเมื่อข้อมูลนั้นเป็นคุณสมบัติของอ็อบเจ็กต์ในคลาส ถ้าใครๆก็เข้าถึงได้ง่ายการแก้ไขข้อมูลก็เป็นไปได้โดยง่าย นึกถึงอายุของมนุษย์ครับ ถ้าเราอนุญาตให้โค๊ดจากภายนอกเข้าไปแก้ไขอายุได้โดยตรง จะเกิดความเสี่ยงเมื่อมีใครซักคนทะลึ่งไปใส่อายุให้เป็น -99

TypeScript
1class Human {
2 age: number
3
4 constructor(name: string) {
5 this.name = name
6 }
7}
8
9const somchai = new Human('Somchai')
10// จงย้อนวัยไป 99 ปี~~
11// อ้า ตีนกาข้าหายไปแล้ว ฟินฝุดๆ
12somchai.age = -99

private เป็นระดับการเข้าถึงที่อนุญาตให้เข้าถึงสมาชิกของคลาสตัวนี้ได้จากข้างในคลาสเท่านั้น ฉะนั้นแล้วใครหน้าไหนก็จะแก้ไขมันจากภายนอกไม่ได้

TypeScript
1class Human {
2 // จงเป็น private เสียเถอะ
3 private age: number
4
5 constructor(name: string) {
6 this.name = name
7 }
8
9 // เปิดเมธอดนี้ให้คนภายนอกเข้าถึงเพื่อตั้งค่าอายุ
10 setAge(age: number) {
11 // เช็คอายุก่อน ถ้าน้อยกว่าเท่ากับศูนย์หรือเกินหนึ่งร้อย คุณไม่ได้ไปต่อครับ
12 if (age > 0 && age <= 100) this.age = age
13 }
14}
15
16const somchai = new Human('Somchai')
17// Error: อย่ามาโกงอายุนะสมชาย
18somchai.age = -99
19// อายุไม่มากกว่าศูนย์เป็นไปไม่ได้ แก้ไขไม่ได้ครับ
20somchai.setAge(-99)
21// เยี่ยมอายุถูกต้องแล้ว
22// แต่คุณชราภาพระดับสูงเลยหละ
23somchai.setAge(99)

ระดับการเข้าถึงตัวสุดท้ายคือ protected ที่อนุญาตให้สมาชิกของคลาสเข้าถึงได้แค่จากตัวมันเอง และจากคลาสลูกของมัน

TypeScript
1class Human {
2 protected name: string
3
4 constructor(name: string) {
5 this.name = name
6 }
7
8 printName() {
9 // เข้าถึงจากภายในคลาสเองก็ได้
10 console.log(this.name)
11 }
12}
13
14class Man {
15 constructor(name: string) {
16 super(name)
17 }
18
19 ordain() {
20 // เข้าถึงจากคลาสลูกก็ได้
21 console.log(`${this.name} has already been a Buddhist monk!`)
22 }
23}
24
25const somchai = new Man('Somchai')
26// แต่เข้าถึงจากภายนอกไม่ได้
27somchai.name

Parameter properties

ในกรณีที่เรารับค่าผ่าน constructor เพื่อกำหนดค่านั้นให้เป็น property ของคลาส เราสามารถประกาศส่วนของ constructor ที่รับค่านั้นเข้ามา จากนั้นจึงกำหนดส่วนของ property ภายในคลาสอีกทีเพื่อเป็นตัวเก็บค่าที่รับเข้ามาผ่าน constructor

TypeScript
1class Human {
2 private name: string
3
4 constructor(name: string) {
5 // รับ name เข้ามาแล้วตั้งค่าให้ name ที่เป็นสมาชิกของคลาสนี้
6 this.name = name
7 }
8}

แค่นี้ก็ปาไป 8 บรรทัดแล้ว ทำใจลำบากเพราะโปรแกรมเมอร์อย่างเราเกิดมาพร้อมกับสกิลยาวไปไม่อ่าน TypeScript จึงอนุญาตให้เราระบุทั้ง modifier และชื่อของ property ใน constructor ซะเลย แล้วมันจะจัดการสร้าง property พร้อมกำหนดค่าให้อย่างสวยงาม

TypeScript
1class Human {
2 // มีค่าเท่ากับตัวอย่างบนครับ
3 constructor(private name: string) {}
4}

Static Properties

เราใช้ new operator เพื่อสร้างอ็อบเจ็กต์ (instance) จากคลาสจึงกล่าวได้ว่า property ทั้งหลายเป็นของอ็อบเจ็กต์

TypeScript
1class Human {
2 constructor(private name: string) {}
3}
4
5// somchai เป็นอ็อบเจ็กต์ที่ name เป็น Somchai
6const somchai = new Human('Somchai')
7// ส่วน name ของ somsree ก็คือ Somsree
8// name ทั้งสองไม่เกี่ยวข้องกันเลย
9// จึงกล่าวได้ว่า name เป็นคุณสมบัติของอ็อบเจ็กต์
10const somsree = new Human('Somsree')

แล้วถ้าเราอยากให้มี property ที่ถือว่าเป็นคุณสมบัติของคลาส ไม่ใช่ของอ็อบเจ็กต์หละ? นั่นหละครับคือสิ่งที่เราจะไปรู้จักกัน... static

TypeScript
1class Circle {
2 // ค่า PI ในทุกวงกลมเหมือนกันหมด
3 // เราจึงไม่ให้ค่านี้เป็นคุณสมบัติของอ็อบเจ็กต์
4 // แต่ให้เป็นคุณสมบัติของคลาสแทนเลย
5 static PI = 3.14
6}
7
8// เพราะมันเป็นคุณสมบัติของคลาส
9// เราจึงเข้าถึงได้โดยตรงจากคลาสโดยไม่ต้องผ่านการ new
10Circle.PI

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

TypeScript
1class Circle {
2 static PI = 3.14
3}
4
5// เราใช้ circle1: Circle เพื่อบอก TypeScript ว่า
6// circle1 ของเรามีชนิดข้อมูลเป็นอ็อบเจ็กต์ของคลาส Circle
7const circle1: Circle = new Circle()

แล้วถ้าโจทย์ของเราเปลี่ยนไปหละ เราต้องการประกาศตัวแปรเพื่อเก็บค่าของคลาส Circle ไว้เลย? วิธีการคือเราจะใช้ typeof <คลาส> แทนครับ เพื่อบอกว่าตัวแปรนี้มีชนิดข้อมูลเป็นคลาสดังกล่าว ไม่ใช่มีชนิดข้อมูลเป็นอ็อบเจ็กต์ของคลาสนี้

TypeScript
1class Circle {
2 static PI = 3.14
3}
4
5// circleClass : Circle แบบนี้ผิด
6// เพราะเป็นการบอกว่า circleClass มีชนิดข้อมูลเป็๋นอ็อบเจ็กต์ของคลาส Circle
7const circleClass: Circle = Circle
8
9// ต้องทำแบบนี้ circleClass : typeof Circle
10// เป็นการบอกว่า circleClass ของเรามีชนิดข้อมูลเป็นคลาส Circle เองเลย
11const circleClass: typeof Circle = Circle

Accessors

จากตัวอย่างก่อนหน้าเรามีคลาส Human ประกอบด้วย age ที่เป็น private และมีเมธอด setAge เพื่อตั้งค่าให้อายุ

TypeScript
1class Human {
2 private age: number
3
4 setAge(age: number) {
5 if (age > 0 && age <= 100) this.age = age
6 }
7}
8
9const somchai = new Human()
10
11// ด้วยวิธีนี้เมื่อเราจะตั้งค่าอายุเราต้องใช้
12somchai.setAge(99)

ถ้าเราไม่อยากมีเมธอดที่ขึ้นต้นด้วย set หรือ get เพื่อเข้าถึงข้อมูลที่ปกปิดภายใน เราสามารถประกาศ getter และ setter แทนได้ดังนี้

TypeScript
1class Human {
2 private _age: number
3
4 get age(): number {
5 return this._age
6 }
7
8 set age(age: number) {
9 if (age > 0 && age <= 100) this.age = age
10 }
11}
12
13const somchai = new Human()
14// เรียกใช้จากชื่อได้โดยตรง
15somchai.age = 99
16somchai.age

Abstract Classes

Abstract Classes คือคลาสที่ไม่สามารถสร้างอ็อบเจ็กต์หรืออินสแตนด์ของมันได้โดยตรงผ่าน new นั่นเป็นเพราะมันมักประกอบด้วย abstract method หรือเมธอดที่ไม่ได้นิยามการทำงานเอาไว้ เมื่อไม่นิยามการทำงานไว้แล้วจะสร้างมันขึ้นได้อย่างไร abstract class จึงใช้เป็นคลาสแม่เพื่อให้คลาสอื่นสืบทอดจากมันอีกที ตัวอย่างเช่น ถ้าเราบอกว่าบัญชีธนาคารมีสองประเภทคือบัญชีออมทรัพย์ (savings account) กับบัญชีฝากประจำ (fixed deposit account) ทั้งสองตัวนี้ล้วนสามารถถอนเงินได้ทั้งคู่ แต่บัญชีฝากประจำต้องฝากตั้งแต่ 6 เดือนขึ้นไปจึงถอนได้ เราสามารถออกแบบคลาสได้ดังนี้

TypeScript
1abstract class Account {
2 // ให้ตั้งค่าเงินในบัญชีเริ่มต้นผ่าน constructor
3 constructor(protected balance: number) {}
4
5 // การฝากเงินไม่มีเงื่อนไขและเหมือนกันทั้งสองประเภทบัญชี
6 // จึงแยกมาอยู่ในคลาสแม่
7 deposit(amount: number) {
8 this.balance += amount
9 }
10
11 // แต่การถอนเงินนั้นแตกต่างออกไปในสองประเภทบัญชี
12 // จึงเป็น abstract method รอคลาสลูกไปเพิ่มโค๊ด (implementation)
13 abstract withdraw(amount: number): void
14}
15
16class SavingsAccount extends Account {
17 constructor(balance: number) {
18 super(balance)
19 }
20
21 withdraw(amount: number): void {
22 // ถ้าถอนเงินออกไปแล้วบัญชีไม่ติดลบ จึงให้ถอนเงินได้
23 if (this.balance - amount >= 0) {
24 this.balance -= amount
25 }
26 }
27}
28
29class FixedDepositAccount extends Account {
30 private openDate: Date
31
32 constructor(balance: number) {
33 super(balance)
34 this.openDate = new Date()
35 }
36
37 withdraw(amount: number): void {
38 // ถ้าถอนเงินออกไปแล้วบัญชีติดลบ ไม่ต้องทำอะไร
39 if (this.balance - amount < 0) return
40 // ถ้าเปิดบัญชีไม่ครบ 6 เดือน ถอนไม่ได้เช่นกัน
41 if (new Date().getMonth() - this.openDate.getMonth() < 6) return
42
43 this.balance -= amount
44 }
45}

TypeScript นั้นช่วยให้เราเขียนคลาสตามรูปแบบของ OOP ได้ง่ายขึ้น โปรแกรมเมอร์ที่มาจาก C# หรือ Java น่าจะคุ้นเคยกับวิธีการเหล่านี้ดี ส่วนตัวแล้วเฉยๆกับคลาสของ TypeScript ครับ เพราะส่วนตัวแล้วไม่ค่อยนิยมการสืบทอดซักเท่าไหร่ protected จึงไม่จำเป็นมากนัก ส่วน private ใน TypeScript นั้นชอบครับ ไม่ต้องไปทำ IIFE หรือใช้ WeakMap จำลอง private ขึ้นมาใน JavaScript อีกต่อไปแล้ว

สารบัญ

สารบัญ

  • เพิ่มชนิดข้อมูลให้คลาสใน ES2015
  • การสืบทอดหรือ Inheritance
  • Access Modifiers
  • Parameter properties
  • Static Properties
  • Accessors
  • Abstract Classes