DEV Community

Chakrit Likitkhajorn
Chakrit Likitkhajorn

Posted on

Model เวลาในแบบ Functional programming

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

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

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

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

// 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 หน้าตาข้อมูล ถ้าโมเดลตรงนี้ถูก ทุกอย่างจะง่าย และถ้าโมเดลผิด ทุกอย่างจะยาก

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

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

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

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

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

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 มันส่งเสริมกันได้นะ

Top comments (0)