Babel Coder

การใช้ Lazy Evaluation ใน JavaScript

intermediate

ศิลปะของความขี้เกียจ

กาลครั้งหนึ่งนานมาแล้ว มีครูประจำชั้นสายโหดท่านหนึ่ง เรียกเด็กชายเอให้ยืนขึ้นพร้อมท่อง ก-ฮ เวลาผ่านไปสามนาทีกว่า บทสวดพยัญชนะไทยจึงจบลงพร้อมกับคำถามจากคุณครูวัยเกษียณว่า ที่เธอท่องมาทั้งหมด อักษรไทยสามตัวแรก มีอะไรบ้าง?

นี่เป็นสถานการณ์ที่ CPU จะอึดอัดในหัวใจมาก หากคุณเขียนโค๊ดให้โปรแกรมคำนวณชุดตัวเลขยาวๆขึ้นมา แล้วบอกมันว่า ขอแค่สามตัวแรกเองหวะแก ดังนี้

(1..Float::INFINITY).collect { |n| n*n }.first(3)

ตัวอย่างข้างบนเป็นภาษา Ruby เราสร้างชุดตัวเลขที่มีค่าตั้งแต่ 1 ถึง Infinity โดยแต่ละตัวเลขให้ยกกำลังสองก่อน เมื่อเสร็จขั้นตอนจึงขอแค่สามตัวบน… เดี๋ยว ไม่ใช่หวย ปัดโถ่!

แน่นอนว่าโปรแกรมนี้จนชาติหน้าก็รันไม่เสร็จ เพราะไม่สามารถคำนวณเลขไปถึง Infinity ได้ เมื่อเราดูคำสั่งหลังสุดคือ first พบว่าเราต้องการแค่สามตัวแท้ๆ ทำไมเราไม่เลือกตัวเลขมาเลยหละ? ทำไมเราต้องรอให้ประโยคก่อนหน้าสร้างเลขมากมายที่ไม่ได้ใช้มาด้วย?

ตรรกะคนสันหลังยาว

เรารู้ถึงความไม่แน่นอนของโค๊ดที่เราเขียน ว่าผลลัพธ์ควรเป็นเช่นไร จนกว่าจะอ่านถึงข้อคำสั่งตัวสุดท้าย เหตุนี้เราจึงยังไม่ควรคำนวณตัวเลขออกมาตั้งแต่แรก แต่รอดูอย่างขี้เกียจไปก่อนว่าสุดท้ายต้องการอะไร มากน้อยแค่นี้ หลักการนี้เรียกว่า Lazy Evaluation โชคดีหน่อยที่หลายๆภาษามีคุณสมบัติข้อนี้อยู่ในตัวภาษาเองเลย เช่น Stream ใน Elixir

กลับมาที่โค๊ด Ruby ของเรากัน เพียงแค่คุณเติม lazy เข้าไป สันหลังคุณก็จะยาวขึ้นทันที 1 นิ้ว

# ได้ผลลัพธ์เป็น [1, 4, 9] โดยไม่ต้องคำนวณไปถึง Infinity
(1..Float::INFINITY).lazy.collect { |n| n*n }.first(3)

สร้างความขี้เกียจฉบับ JavaScript

แม้โลกความจริงจะไม่มีใครรันตัวเลขไปถึง Infinity แต่ถ้าเราสามารถลดปริมาณการคำนวณได้ โดยดูว่าต้องการผลลัพธ์ถึงแค่ไหน ก็จะทำให้โปรแกรมเราทำงานได้เร็วขึ้น บทความนี้เน้นการใช้งาน Lazy Evaluation บนฐานของภาษา JavaScript ที่ไม่ได้มี Syntax อะไรพิเศษเอื้อต่อการทำงาน

const upto = (from, to) => {
  return Array(to).fill().map((_, i) => i + from)
}

console.log(upto(0, 1000).slice(0, 3))

ตัวอย่างข้างบนให้ผลลัพธ์เป็น [0, 1, 2] แต่กว่าจะได้เลขสามตัวนี้ออกมา โปรแกรมของเราต้องสร้าง Array ตั้ง 1,000 จำนวนขึ้นมาก่อน ลองเปลี่ยนโค๊ดนี้นิดหน่อยดังนี้ครับ

const upto = (from, to) => {
  return [from, () => upto(from + 1, to)]
}

const iterate = (stream, n, cb) => {
  while(n-- > 0) {
    cb(stream.shift())
    stream = stream[0]()
  }
}

const display = (stream, n) => {
  iterate(stream, n, (item) => {
    console.log(item)
  })
}

console.log(display(upto(0, 1000), 3))

โค๊ดนี้อาศัยความเข้าใจที่ว่า Function จะไม่ทำงานทันทีถ้าเราไม่เรียก เมื่อเราเรียกฟังก์ชัน upto มันจะคืนค่าเป็นอาร์เรย์สองช่อง ช่องแรกคือค่าก่อนหน้า ส่วนช่องหลังคือฟังก์ชัน สมมติเราเรียก upto(0, 1000) เราจะได้อาร์เรย์ลักษณะนี้ [0, () => [1, () => [2, () => {...}]]]

อาร์เรย์ชั้นนอกสุดมี2ช่องคือช่องแรกเก็บค่า0 ช่องหลังเก็บฟังก์ชัน () => [1, ...] ตัวข้างในก็ซ้อนกันไปเรื่อยๆ ทีนี้เมื่อเราบอกว่า display แค่สามค่านะ โปรแกรมนี้ก็จะไปแกะเอาเฉพาะอาร์เรย์ช่องแรกจนกว่าจะครบสามตัว เมื่อครบแล้วก็หยุดเพราะขี้เกียจ

โปรแกรมนี้ดูสมบูรณ์แบบขึ้นมาแล้ว แต่จริงๆมันยังมีข้อผิดพลาดนะครับ ไม่เชื่อลองใส่ display(upto(0, 1), 5) ดูซิ ทำไมมันคืนค่ามา5ตัว ทั้งๆที่เราบอกในประโยคแรกว่าให้สร้างอาร์เรย์แค่1ตัว จะเห็นว่ายากพอควรเหมือนกันถ้าเราจะเขียน Lazy Evaluation ด้วยตนเองให้ทั้งถูกต้องและครอบคลุม

เพิ่มความเกียจคร้านให้ถึงขีดสุด

ไหนๆก็ไหนๆแล้ว ถ้าคิดจะขี้เกียจก็ต้องทำให้ถึงที่สุด เราจะมานั่งเขียนฟังก์ชันเองอยู่ทำไม ในเมื่อ Library ก็มีให้ใช้ เอาหละ เรามาลองเล่น lazy.js กันเถอะ

var result = Lazy(people)
  .pluck('lastName')
  .filter(function(name) { return name.startsWith('Smith'); })
  .take(5);

ตัวอย่างข้างบนนี้คัดลอกมาจากตัวอย่างของ Lazy.js ครับ ถ้าเรามีลิสต์ของก้อนอ็อบเจ็กต์ person จำนวนมาก ต้องการเฉพาะนามสกุลโดยมีเงื่อนไขว่านามสกุลต้องขึ้นต้นด้วย Smith สุดท้ายต้องการแค่5อ็อบเจ็กต์เท่านั้น เราสามารถใช้ Lazy.js ช่วยทำ Lazy Evaluation โดยไม่ต้องแงะหานามสกุลของคนทั้งหมด


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


No any discussions