วันนี้อยากพูดถึงเทคนิคโปรดสำหรับเพิ่มประสิทธิภาพ มันเป็นแหล่ง "ชัยชนะเล็กๆ" ที่สะสมเป็นผลใหญ่ และโอกาสทำลายแอปน้อยมาก เพียงนิดหน่อย
เทคนิคนี้เรียกว่า "Memoization" ถึงคำศัพท์วิทยาการคอมพิวเตอร์ราคา $10 แต่มันหมายถึง: แทนที่จะทำงานซ้ำทุกครั้งที่เรียกเมธอด ให้บันทึกค่าที่คืนไว้ในตัวแปร และใช้ตัวแปรนั้นแทน
รูปแบบ pseudocode:
def my_method
@memo = if @memo is undefined
return @memo
end
วิธีทำใน Ruby นี่คือวิธีที่แข็งแกร่งที่สุด แต่ค่อนข้าง verbose มีวิธีสั้นกว่าที่ทำได้เกือบเหมือนกัน
class MyClass
def my_method
unless defined?(@my_method)
@my_method = begin
# ทำ calculation, database query
# หรืองานที่ใช้เวลานานที่นี่
end
end
@my_method
end
end
โค้ดข้างบนทำ 3 สิ่ง:
- ตรวจสอบว่ามี instance variable ชื่อ
@my_methodหรือไม่ - หากยังไม่มี ทำงานและเก็บผลลัพธ์ไว้ใน
@my_method - คืนค่า
@my_method
อย่าสับสนจากการมีทั้งเมธอดและ instance variable ชื่อ my_method ผมตั้งชื่อตัวแปรอะไรก็ได้ แต่ convention คือตั้งชื่อตามเมธอดที่ memoize
รุ่นย่อ (Shorthand)
ปัญหาของโค้ดข้างต้นคือ cumbersome ทำให้มักเห็นรุ่นย่อที่ทำเกือบเหมือนกัน:
class MyClass
def my_method1
@my_method1 ||= some_long_calculation
end
def my_method2
@my_method2 ||= begin
# begin-end block ให้ใช้หลายบรรทัดง่ายๆ
end
end
end
ทั้งคู่ใช้ตัวดำเนินการ a ||= b ของ Ruby ซึ่งเป็น shorthand สำหรับ a || (a = b) ซึ่งก็เป็น shorthand อีกทีสำหรับ:
# ไม่ควรใช้ return แบบนี้จริงๆ
# ใช้แค่แสดงความคิดให้มือใหม่เข้าใจว่า conditional evaluates ไปที่ค่าใน `a`
if a
return a
else
a = b
return a
end
แหล่งมาของบั๊ก
หากคุณตั้งใจดู จะเห็นรุ่นย่อ evaluate "truthiness" ของตัวแปร memo แทนการตรวจสอบความมีอยู่ นี่คือข้อจำกัดหลัก: memoize nil หรือ false ไม่ได้
หลาย use case ไม่สำคัญ แต่เป็นข้อเท็จจริงที่น่ารำคาญต้องจดจำตอน memoize
Memoize เมธอดที่มีอาร์กิวเมนต์
จนถึงตอนนี้ memoize ค่าเดียว แต่ฟังก์ชันส่วนใหญ่ไม่คืนค่าเดิมเสมอ ลองดู Fibonacci sequence โจทย์สัมภาษณ์เทคนิคยอดนิยม
คำนวณ Fibonacci แบบ recursive ใน Ruby:
class Fibonacci
def self.calculate(n)
return n if n == 0 || n == 1
calculate(n - 1) + calculate(n - 2)
end
end
Fibonacci.calculate(10) # => 55
ปัญหา: implementation นี้ไม่มีประสิทธิภาพ เพื่อพิสูจน์ ให้เพิ่ม print ดูค่า n
class Fibonacci
def self.calculate(n)
print "#{ n } "
return n if n == 0 || n == 1
calculate(n - 1) + calculate(n - 2)
end
end
Fibonacci.calculate(4)
# Outputs: 4 3 2 1 0 1 2 1 0
เห็นว่า calculate ถูกเรียกซ้ำด้วยค่า n เดียวกันหลายครั้ง จำนวนการเรียกเพิ่มแบบ exponential กับ n
แก้ด้วย memoize ผลลัพธ์ของ calculate ไม่ต่างมากจากตัวอย่างก่อนหน้า
class Fibonacci
def self.calculate(n)
@calculate ||= {}
@calculate[n] ||= begin
print "#{ n } "
if n == 0 || n == 1
n
else
calculate(n - 1) + calculate(n - 2)
end
end
end
end
Fibonacci.calculate(4)
# Outputs: 4 3 2 1 0
ตอน memoized แล้ว จำนวนการเรียกไม่เพิ่ม exponential อีกต่อไป
Fibonacci.calculate(20)
# Outputs: 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
การทำให้หมดอายุ (Invalidation)
Memoization คล้าย caching แต่ผลลัพธ์ memoized ไม่หมดอายุอัตโนมัติ และมักไม่มีวิธีง่ายล้างค่าที่ตั้งไว้
สำหรับ Fibonacci ไม่สำคัญ เพราะ Fibonacci.calculate(10) คืนค่าเดิมตลอด แต่ use cases อื่นสำคัญ
เช่น โค้ดแบบนี้:
# ไม่ใช่ไอเดียดี
class User
def full_name
@full_name ||= [first_name, last_name].join(" ")
end
end
ส่วนตัวผมไม่ memoize ที่นี่ เพราะถ้า first/last name เปลี่ยน full name อาจไม่ update
ที่สามารถคล่องตัวได้คือภายใน Rails controllers มักเห็นโค้ดแบบนี้:
class ApplicationController
def current_user
@current_user ||= User.find(...)
end
end
นี้ OK เพราะ controller instance ถูก destroy หลังแต่ละ web request ไม่น่าจะมีการเปลี่ยน user ระหว่าง request ปกติ
กรณี streaming connections เช่น ActionCable อาจต้องระวังกว่า ผมไม่รู้ ไม่เคยใช้
การใช้มากเกินไป (Overuse)
สุดท้าย ต้องเตือน: อะไรก็ตาม overuse ได้ Memoization ควรใช้กับ expensive operations ที่จะไม่เปลี่ยนตลอดอายุของตัวแปร memo เท่านั้น