Model เวลาในแบบ Functional programming
Tags: functional, typescript
Published: 2019-12-01 05:42:50


ผมมีโจทย์หนึ่งที่น่าสนใจและอยากแชร์

ผมทำงานตัวหนึ่งซึ่งใช้ Game engine และโจทย์คือ

1. เรามี API ตัวหนึ่งที่ต้องยิงเพื่อให้รู้ว่าผู้เล่นคนนี้มีวัตถุในเกมกี่ชิ้น ซึ่งก็จะได้คืนมาเป็น Array ของวัตถุ
2. เรามี Physics loop ที่ทำงานทุกๆ เฟรม
3. เราต้องการให้วัตถุทั้งหมด ถูกเติมเข้าไปในเกมทีละชิ้น ห่างกันชิ้นละ 300 milliseconds ไม่ได้เติมเข้าไปในเกมทีเดียวหมด

ถ้าเราใช้ Imperative (และมีส่วนผสมของ Object-oriented) การโมเดลสิ่งนี้ก็คงจะได้ประมาณนี้

```csharp
// Fetching logic
var matters = Api.getUserMatters(userId);
foreach (var matter of matters) {
  GameEngine.getInstance().Matters.add(matter)
  sleep(300)
}

class GameEngine { // Singleton
   public IEnumerable<Matter> Matters;
   public void ExecuteGameLoop () {
     foreach (var matter in this.Matters) {
       if (this.isRendered(matter)) {
         continue;
       }
       this.render(matter)
     }
   }
}

```

คำถามคือ ถ้าเราจะโมเดลสิ่งนี้ด้วย Functional programming (และมีส่วนผสมของ Object oriented) เราจะทำได้อย่างไร 

หมายเหตุว่า ในโลกความเป็นจริงผมมีเงื่อนไขบางอย่างที่ทำให้จำเป็นต้อง Model แบบนี้ ไม่ใช่ว่าวิธีโมเดลนี้ดีที่สุด

บล็อกนี้ ผมอยากแชร์ให้ดูว่าผมโมเดลมันอย่างไร และเราจะมาดูข้อดีข้อเสียที่เกิดขึ้น

# ออกแบบ Data

ส่วนตัวผมพบว่าในโลกของ Functional programming สิ่งที่สำคัญที่สุดคือการ Model หน้าตาข้อมูล ถ้าโมเดลตรงนี้ถูก ทุกอย่างจะง่าย และถ้าโมเดลผิด ทุกอย่างจะยาก

ผมออกแบบข้อมูลหน้าตาแบบนนี้

```typescript
export interface IWaitAnimatingMatter {
  matter: IMatter
  expected_animated_time: Date | null
}

export interface IDeQueueWaitingMattersResult {
  newQueue: IWaitAnimatingMatter[]
  matterToAnimated?: IMatter
}
```

แล้วสร้าง Function ที่ทำงานบนข้อมูลดังนี้

```typescript
export function deQueueWaitingMatters(
  matterQueue: IWaitAnimatingMatter[],
  now = new Date()
): IDeQueueWaitingMattersResult {
  if (!isNeedAnimated(matterQueue, now)) {
    return { newQueue: matterQueue }
  }
  const nextAnimationTime = moment(now)
    .add(300, 'milliseconds')
    .toDate()
  const newQueue: IWaitAnimatingMatter[] = matterQueue
    .slice(1)
    .map<IWaitAnimatingMatter>(e => ({
      expected_animated_time: nextAnimationTime,
      matter: e.matter
    }))
  return {
    newQueue,
    matterToAnimated: matterQueue[0].matter
  }
}

export function isNeedAnimated(
  matterQueue: IWaitAnimatingMatter[],
  now: Date = new Date()
) {
  const animatingMatter = matterQueue[0]
  if (!animatingMatter) {
    return false
  }
  if (animatingMatter.expected_animated_time === null) {
    return true
  }
  if (moment(now).isBefore(moment(animatingMatter.expected_animated_time))) {
    return false
  }
  return true
}

class GameEngine {
  public matterQueue: IWaitAnimatingMatter[]
  public void GameLopp(){
    const toAnimated = deQueueWaitingMatters(matterQueue)
    if (!toAnimated.matterToAnimated) {
      return entities
    }
    this.matterQueue = toAnimated.newQueue
    this.render(toAnimated.matterToAnimated)
    return entities
  }
}
```

ผมออกแบบ Data ให้มีสองอย่าง คือวัตถุที่ต้องวาด และ expeteced_animated_time หรือเวลาที่ต้องการจะวาด

พอถึงเวลาผม Dequeue มัน โดยฟังก์ชั่น Dequeue ต้องการเวลาปัจจุบัน และคืนค่าวัตถุที่ต้องวาดในเวลานั้นๆ (ถ้ามี) และคิวชุดใหม่

ใน GameEngine ก็แค่ Dequeue แล้ววาดตลอดเวลา

# เรียนรู้อะไรจากการโมเดลแบบนี้

ผมพบว่ามันมีข้อดีข้อเสีย ข้อดีที่ได้คือ

ข้อแรก ผม Unit test ระบบนี้ง่ายมากเลย ในงานที่ผมทำผมเขียน Unit test ครอบคลุมเคสเกือบหมด เทียบกับถ้าเป็น Imperative ผมจะจำลองเวลาและสถานการณ์ได้ยากมาก ผมมีเทสเคสของ deQueue ว่า 

- ถ้าหัวคิวไม่มี expected_animated_time ให้คืนหัวคิวเลย แล้วคิวถัดไป เติมเวลาให้ที่เหลือ 
- ถ้าหัวคิวมี expected_animated_time แล้วเวลาปัจจุบันเลยหรือเท่ากับ expected_animated_time แล้ว ให้คืนหัวคิว แล้วคิวถัดไป เติมเวลาให้ที่เหลือ
- ถ้าหัวคิวมี expected_animated_time แล้วเวลาปัจจุบันน้อยกว่า expected_animated_time แล้ว ให้คืนแค่คิวเดิม (เพราะยังไม่ถึงเวลาวาด)

ทั้งหมดสามารถทำ Testing ได้สบายมาก
ข้อสอง ถ้าผมต้องเอาวัตถุจากหลายๆ API มารวมกัน หรือ API นี้มีการเรียกซ้ำหลายรอบมีการทำ Concurrent

```csharp
var matters = Api.getUserMatters(userId);
foreach (var matter of matters) {
  GameEngine.getInstance().Matters.add(matter)
  sleep(300)
}
```

ถ้าโค้ดนี้มันเกิดตอน User กดปุ่ม และ User กดปุ่มรัวๆ ไม่หยุด มันเป็นไปได้ที่ระบบจะวาดของเร็วกว่า 300 milliseconds ต่อชิ้น เพราะเราจะมี 2 Thread ที่เติมของให้วาดทุกๆ 300 Millisecnds  

แต่ถ้าโมเดลแบบ Functional เรามั่นใจว่าเราเอาของเติมคิวเดียว ยังไงมันก็ค่อยๆ ไล่ทำไปทีละชิ้นแน่นอน 

ข้อสุดท้าย เรามีอิสระที่จะ Explicitly บรรยาย การเกิดของวัตถุ เช่น ถ้าเรามีของขวัญตกจากฟ้าทุกๆ 300 Milliseconds แต่จำนวนของขวัญมาจาก API 3 ตัว และเรามีเมฆเกิดขึ้นทุกๆ 100 milliseconds เราก็สร้างคิวมาสองคิว แต่คิวแรกเอาของขวัญที่ตกมาจากฟ้า จาก 3 API เข้าไปใส่ซะ ส่วนคิวที่สองก็ไว้ใส่เมฆ

ซึ่ง Takeaway ที่สำคัญคือ ถ้าเราแยก Abstraction การจัดการกับเวลาในการวาด และการรับของจาก API เข้าเกม ทำให้เรามีอิสระในการจัดการมันมากขึ้น

ข้อเสียคือ โค้ดยาวขึ้น ไม่ได้เข้าใจง่ายเท่า Imperative คือถ้าผมส่งโค้ด deQueueAnimation ไปให้คนอื่นแก้ ผมคิดว่าเขาคงใช้เวลาพอสมควรถึงจะเข้าใจและสามารถแก้ได้อย่างมั่นใจว่าไม่ทำของเก่าผมพัง

## Takeaway

ผมมี Takeaway 2 อย่างที่อยากสื่อในบทความนี้

1. Functional programming มันเป็นการทำ Data pipeline ส่งต่อๆ กัน ดังนั้น ถ้าคุณโมเดล Intermediate data แต่ที่ส่งไปให้ใน Pipeline ต่างๆ ให้เข้าใจง่าย การเขียน Functional programming ก็จะง่ายด้วย และที่สำคัญเวลาเกิดปัญหาจะแก้ไขง่ายด้วย อย่างกรณีนี้ถ้าระบบมันวาดเพี้ยนผม Inspect expected_animated_time ก็พอจะรู้ละว่าน่าจะเป็นที่ไหน ผมเลยเขียนบล็อกนี้เป็นตัวอย่างการโมเดล Data ถ้าสนใจผมมีอีกโค้ดชุดที่ผมเคยเขียนไว้คิดว่าดีคือการโมเดล Intermediate data ไว้ได้พอใจทีเดียวที่ https://github.com/chrisza4/clojure-poker-kata ซึ่งผมโมเดลข้อมูลที่ต้องใช้ในการเปรียบเทียบมือโป๊กเกอร์

2. ผมมองว่าเวลาเขียน Functional ผมเปลือยข้อมูล Queue ออกมาดังนั้นอาจจะมีคนอุตริไปใส่ expected_waiting_time มั่วๆ ไม่ผ่าน Function ที่ผมสร้างก็ได้ ก็ทำให้เกิด Unexpected bug ซึ่งตรงนี้ภาษาที่มี Object-oriented construct จะออกแบบมาแก้โดยการเอา Data กับ Operation ที่ทำได้บน Data เข้ามาเป็นกลุ่มก้อนเดียวกันที่เรียกว่า Class นั่นก็เป็นวิธีการแก้ปัญหาคนยุ่งกับ Data มั่วๆ ซึ่งเรายังใช้ประโยชน์จากหลักตรงนี้ได้ คือเราอาจจะสร้าง Class ขึ้นมาเพื่อป้องกันเรื่องนี้แล้วเขียนโปรแกรมแบบโมเดลเป็น Function ก็ได้ ดังนั้น Object-oriented กับ Functional programming มันส่งเสริมกันได้นะ