Babel Coder

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

beginner

 บทความนี้เป็นส่วนหนึ่งของชุดบทความ ชุดบทความสอนใช้งาน TypeScript

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

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

สารบัญ

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

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

class Human {
  // เกิดเป็นคนไม่มีชื่อได้ยังไง
  name: string
  
  // พารามิเตอร์ของ constructor ฟังก์ชันก็ต้องมีการระบุชนิดข้อมูลเช่นกัน
  constructor(name: string) {
    this.name = name
  }
}

// ประกาศ somchai แบบนี้ TypeScript จะอนุมานได้ว่า
// somchai มีชนิดข้อมูลเป็น Human โดยอาศัยดูจากค่าขวามือ
const somchai = new Human('Somchai')
// หรือ
// บอกให้ชัดเจนไปเลยว่า somchai เป็น**คน**นะเออ
const somchai : Human = new Human('Somchai')

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

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

class Human {

}

class Woman extends Human {
  constructor() {
    // เมื่อคลาสลูกมี constructor อย่าลืมเรียก super นะครับ
    super()
  }
}

Access Modifiers

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

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

class Human {
  // สมาชิกของคลาสตัวนี้ ไม่มีการระบุอะไรทั้งสิ้น จึงมีค่าเท่ากับ
  // public name: string
  // เมื่อเป็น public จึงสามารถเข้าถึงจากที่ไหนก็ได้
  name: string
  
  constructor(name: string) {
    this.name = name
  }
  
  // เมธอดนี้ไม่ได้ระบุ Access Modifiers ใดๆ จึงมีค่าเท่ากับ
  // public printName()
  public printName() {
    // name ซึ่งเป็น public เข้าถึงจากภายในคลาสเองก็ได้
    connsole.log(this.name)
  }
}

const somchai = new Human('Somchai')
// เข้าถึงจากภายนอกคลาสก็ย่อมได้
somchai.name = 'Somsree'

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

class Human {
  age: number
  
  constructor(name: string) {
    this.name = name
  }
}

const somchai = new Human('Somchai')
// จงย้อนวัยไป 99 ปี~~ 
// อ้า ตีนกาข้าหายไปแล้ว ฟินฝุดๆ
somchai.age = -99

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

class Human {
  // จงเป็น private เสียเถอะ
  private age: number
  
  constructor(name: string) {
    this.name = name
  }
  
  // เปิดเมธอดนี้ให้คนภายนอกเข้าถึงเพื่อตั้งค่าอายุ
  setAge(age: number) {
    // เช็คอายุก่อน ถ้าน้อยกว่าเท่ากับศูนย์หรือเกินหนึ่งร้อย คุณไม่ได้ไปต่อครับ
    if(age > 0 && age <= 100) this.age = age
  }
}

const somchai = new Human('Somchai')
// Error: อย่ามาโกงอายุนะสมชาย
somchai.age = -99
// อายุไม่มากกว่าศูนย์เป็นไปไม่ได้ แก้ไขไม่ได้ครับ
somchai.setAge(-99)
// เยี่ยมอายุถูกต้องแล้ว
// แต่คุณชราภาพระดับสูงเลยหละ
somchai.setAge(99)

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

class Human {
  protected name: string
  
  constructor(name: string) {
    this.name = name
  }
  
  printName() {
    // เข้าถึงจากภายในคลาสเองก็ได้
    console.log(this.name)
  }
}

class Man {
  constructor(name: string) {
    super(name)
  }
  
  ordain() {
    // เข้าถึงจากคลาสลูกก็ได้
    console.log(`${this.name} has already been a Buddhist monk!`)
  }
}

const somchai = new Man('Somchai')
// แต่เข้าถึงจากภายนอกไม่ได้
somchai.name

Parameter properties

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

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

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

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

Static Properties

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

class Human {
  constructor(private name: string) { }
}

// somchai เป็นอ็อบเจ็กต์ที่ name เป็น Somchai
const somchai = new Human('Somchai')
// ส่วน name ของ somsree ก็คือ Somsree
// name ทั้งสองไม่เกี่ยวข้องกันเลย
// จึงกล่าวได้ว่า name เป็นคุณสมบัติของอ็อบเจ็กต์
const somsree = new Human('Somsree')

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

class Circle {
  // ค่า PI ในทุกวงกลมเหมือนกันหมด
  // เราจึงไม่ให้ค่านี้เป็นคุณสมบัติของอ็อบเจ็กต์
  // แต่ให้เป็นคุณสมบัติของคลาสแทนเลย
  static PI = 3.14
}

// เพราะมันเป็นคุณสมบัติของคลาส
// เราจึงเข้าถึงได้โดยตรงจากคลาสโดยไม่ต้องผ่านการ new
Circle.PI

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

class Circle {
  static PI = 3.14
}

// เราใช้ circle1: Circle เพื่อบอก TypeScript ว่า
// circle1 ของเรามีชนิดข้อมูลเป็นอ็อบเจ็กต์ของคลาส Circle
const circle1: Circle = new Circle()

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

class Circle {
  static PI = 3.14
}

// circleClass : Circle แบบนี้ผิด
// เพราะเป็นการบอกว่า circleClass มีชนิดข้อมูลเป็๋นอ็อบเจ็กต์ของคลาส Circle
const circleClass : Circle = Circle

// ต้องทำแบบนี้ circleClass : typeof Circle
// เป็นการบอกว่า circleClass ของเรามีชนิดข้อมูลเป็นคลาส Circle เองเลย
const circleClass : typeof Circle = Circle

Accessors

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

class Human {
  private age: number
  
  setAge(age: number) {
    if(age > 0 && age <= 100) this.age = age
  }
}

const somchai = new Human()

// ด้วยวิธีนี้เมื่อเราจะตั้งค่าอายุเราต้องใช้ 
somchai.setAge(99)

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

class Human {
  private _age: number
  
  get age(): number {
    return this._age
  }
  
  set age(age: number) {
    if(age > 0 && age <= 100) this.age = age
  }
}

const somchai = new Human()
// เรียกใช้จากชื่อได้โดยตรง
somchai.age = 99
somchai.age

Abstract Classes

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

abstract class Account {
  // ให้ตั้งค่าเงินในบัญชีเริ่มต้นผ่าน constructor
  constructor(protected balance: number) {
  
  }
  
  // การฝากเงินไม่มีเงื่อนไขและเหมือนกันทั้งสองประเภทบัญชี
  // จึงแยกมาอยู่ในคลาสแม่
  deposit(amount: number) {
  	this.balance += amount
  }
  
  // แต่การถอนเงินนั้นแตกต่างออกไปในสองประเภทบัญชี
  // จึงเป็น abstract method รอคลาสลูกไปเพิ่มโค๊ด (implementation)
  abstract withdraw(amount: number) : void
}

class SavingsAccount extends Account {
  constructor(balance: number) {
    super(balance)
  }
  
  withdraw(amount: number) : void {
    // ถ้าถอนเงินออกไปแล้วบัญชีไม่ติดลบ จึงให้ถอนเงินได้
    if(this.balance - amount >= 0) {
      this.balance -= amount
    }
  }
}

class FixedDepositAccount extends Account {
  private openDate: Date
  
  constructor(balance: number) {
    super(balance)
	this.openDate = new Date()
  }
  
  withdraw(amount: number) : void {
    // ถ้าถอนเงินออกไปแล้วบัญชีติดลบ ไม่ต้องทำอะไร
    if(this.balance - amount < 0) return
    // ถ้าเปิดบัญชีไม่ครบ 6 เดือน ถอนไม่ได้เช่นกัน
	if((new Date).getMonth() - this.openDate.getMonth() < 6) return
	
	this.balance -= amount
  }
}

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


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


Taywan Kamolwilad5 เดือนที่ผ่านมา

มีต่ออีกมั้ยครับ อ่านแล้วเพลินมาก อิอิ