สวัสดีครับทุกท่าน กลับมาพบกันอีกครั้งกับ Ruby Magic! ปีที่แล้วเราได้เรียนรู้เกี่ยวกับโมดูล Enumerable ของ Ruby ซึ่งให้เมธอดต่างๆ ที่ใช้เมื่อทำงานกับออบเจ็กต์ที่วนรอบได้ เช่น อาร์เรย์ รีจ และแฮช
ครั้งนั้นเราสร้างคลาส LinkedList เพื่อแสดงวิธีการทำให้ออบเจ็กต์วนรอบได้ โดยการนำเมธอด #each ไปใช้งาน ด้วยการ include โมดูล Enumerable เราจึงสามารถเรียกเมธอดอย่าง #count, #map และ #select บน linked list ใดๆ ได้โดยไม่ต้องนำเมธอดเหล่านั้นไป implement เอง
เราได้เรียนรู้วิธีใช้ enumerables แล้ว แต่พวกมันทำงานอย่างไร? ส่วนหนึ่งของ "เวทมนตร์" ใน enumerables ของ Ruby มาจากการ implement แบบภายใน ซึ่งทั้งหมดอาศัยเมธอด #each เพียงเมธอดเดียว และยังอนุญาตให้ chain enumerators ได้อีกด้วย
วันนี้เราจะเรียนรู้ว่าเมธอดต่างๆ ในคลาส Enumerable ถูก implement อย่างไร และว่า物件 Enumerator ช่วยให้ chain เมธอดการวนรอบได้อย่างไร
ตามที่คุณคุ้นเคยแล้ว เราจะลงลึกเข้าไปโดยการ implement โมดูล Enumerable และคลาส Enumerator ของตัวเอง ดังนั้น ใส่หมวก "over-engineering" ไว้แล้ว เราเริ่มกันเลย!
Linked Lists
ก่อนเริ่ม เรามาดูเวอร์ชันใหม่ของคลาส linked list ที่เราเขียนไว้ก่อนหน้านี้
class LinkedList
def initialize(head = nil, *rest)
@head = head
if rest.first.is_a?(LinkedList)
@tail = rest.first
elsif rest.any?
@tail = LinkedList.new(*rest)
end
end
def <<(head)
@head ? LinkedList.new(head, self) : LinkedList.new(head)
end
def inspect
[@head, @tail].compact
end
def each(&block)
yield @head if @head
@tail.each(&block) if @tail
end
end
แตกต่างจากเวอร์ชันก่อนหน้า implementation นี้อนุญาตให้สร้างรายการว่างได้ รวมถึงรายการที่มีมากกว่าสองรายการ เวอร์ชันนี้ยังอนุญาตให้ส่ง linked list ไปเป็น tail เมื่อ initialize อีก linked list หนึ่งได้อีกด้วย
irb> LinkedList.new => []
irb> LinkedList.new(1) => [1]
irb> LinkedList.new(1, 2) => [1,[2]]
irb> LinkedList.new(1, 2, 3) => [1,[2,[3]]]
irb> LinkedList.new(1, LinkedList.new(2, 3)) => [1,[2,[3]]]
irb> LinkedList.new(1, 2, LinkedList.new(3)) => [1,[2,[3]]]
ก่อนหน้านี้ คลาส LinkedList ของเรา include โมดูล Enumerable เมื่อ mapping บนออบเจ็กต์ด้วยเมธอดของ Enumerable ผลลัพธ์จะถูกเก็บในอาร์เรย์ ครั้งนี้เราจะ implement เวอร์ชันของตัวเองเพื่อให้เมธอดต่างๆ ส่งคืน linked list ใหม่แทน
เมธอด Enumerable
โมดูล Enumerable ของ Ruby มาพร้อมกับเมธอดการวนรอบอย่าง #map, #count, และ #select โดยการ implement เมธอด #each และ include โมดูล Enumerable ในคลาสของเรา เราจะสามารถใช้เมธอดเหล่านั้นบน linked lists ได้โดยตรง
แทนที่จะทำเช่นนั้น เราจะ implement DIYEnumerable และ import มันแทนเวอร์ชันของ Ruby แม้ว่าจะไม่ได้ทำแบบนี้ในทางปฏิบัติ แต่จะให้เราเห็นภาพชัดเจนว่า enumeration ทำงานภายในอย่างไร
มาเริ่มกับ #count กัน เมธอดที่ import ได้แต่ละเมธอดในคลาส Enumerable ใช้เมธอด #each ที่เรา implement ในคลาส LinkedList เพื่อวนลูปผ่านออบเจ็กต์เพื่อคำนวณผลลัพธ์
module DIYEnumerable
def count
result = 0
each { |element| result += 1 }
result
end
end
ในตัวอย่างนี้ เรา implement เมธอด #count บนโมดูล DIYEnumerable ใหม่ที่เราจะ include ใน linked list มันเริ่มนับจากศูนย์ และเรียกเมธอด #each เพื่อบวกหนึ่งเข้าไปในตัวนับสำหรับทุกรอบ หลังจากวนลูปผ่านทุก element เมธอดจะส่งคืนตัวนับที่ได้
module DIYEnumerable
# ...
def map
result = LinkedList.new
each { |element| result = result << yield(element) }
result
end
end
เมธอด #map ถูก implement คล้ายกัน แทนที่จะเก็บตัวนับ มันใช้ accumulator ที่เริ่มต้นเป็นรายการว่าง เราจะวนลูปผ่านทุก element ในรายการ และ yield บล็อกที่ส่งมาให้แต่ละ element ผลลัพธ์ของแต่ละ yield จะถูก append เข้าไปใน accumulator list
เมธอดจะส่งคืน accumulator หลังจากวนลูปผ่านทุก element ใน input list แล้ว
class LinkedList
include DIYEnumerable
#...
end
หลังจาก include DIYEnumerable ใน LinkedList เราสามารถทดสอบเมธอด #count และ #map ที่เพิ่งเพิ่มเข้ามาได้
irb> list = LinkedList.new(73, 12, 42) => [73, [12, [42]]]
irb> list.count => 3
irb> list.map { |element| element * 10 } => [420, [120, [730]]]
ทั้งสองเมธอดทำงานได้! เมธอด #count นับจำนวนรายการในลิสต์ถูกต้อง และเมธอด #map รันบล็อกสำหรับแต่ละรายการและส่งคืนลิสต์ที่อัปเดตแล้ว
รายการที่กลับด้าน
อย่างไรก็ตาม เมธอด #map ดูเหมือนจะกลับด้านลิสต์ นั่นเข้าใจได้ เพราะเมธอด #<< บนคลาส linked list ของเรา prepend รายการเข้าไปในลิสต์แทนที่จะ append ซึ่งเป็นคุณสมบัติของลักษณะ recursive ของ linked lists
สำหรับสถานการณ์ที่สำคัญที่ลำดับของลิสต์ต้องคงอยู่ เราต้องการวิธีการ reverse ลิสต์เมื่อ mapping ผ่านมัน Ruby implement Enumerable#reverse_each ซึ่งวนลูปผ่านออบเจ็กต์ในทิศทางตรงข้าม 聽起來像是解決方案很棒 但我们不能使用这种方法,因为我们的列表是嵌套的。我们直到完全遍历列表才知道列表有多长。
แทนที่จะรันบล็อกผ่านลิสต์ในทิศทางตรงข้าม เราจะเพิ่มเวอร์ชันของ #reverse_each ที่ทำในสองขั้นตอน มันวนลูปผ่านลิสต์ก่อนเพื่อ reverse โดยการสร้างลิสต์ใหม่ หลังจากนั้นจึงรันบล็อกผ่านลิสต์ที่ reverse แล้ว
module DIYEnumerable
# ...
def reverse_each(&block)
list = LinkedList.new
each { |element| list = list << element }
list.each(&block)
end
def map
result = LinkedList.new
reverse_each { |element| result = result << yield(element) }
result
end
end
ตอนนี้เราจะใช้ #reverse_each ในเมธอด #map ของเรา เพื่อให้แน่ใจว่าผลลัพธ์ส่งคืนในลำดับที่ถูกต้อง
irb> list = LinkedList.new(73, 12, 42) => [73, [12, [42]]]
irb> list.map { |element| element * 10 } => [730, [120, [420]]]
ได้แล้ว! เมื่อเราเรียกเมธอด #map บน linked list เราจะได้ลิสต์ใหม่กลับมาในลำดับเดียวกับลิสต์เดิม
การ Chain Enumeration ด้วย Enumerators
ผ่านเมธอด #each ที่ implement บนคลาส linked list และ DIYEnumerable ที่ include ไป เราสามารถวนลูปได้ทั้งสองทิศทางและ map ผ่าน linked lists
irb> list.each { |x| p x }
73
12
42
irb> list.reverse_each { |x| p x }
42
12
73
irb> list.reverse_each.map { |x| x * 10 }
=> [730, [120, [420]]]
=> [420, [120, [730]]]
อย่างไรก็ตาม ถ้าเราต้องการ map ผ่านลิสต์ในทิศทางตรงข้าม? เนื่องจากตอนนี้เรา reverse ลิสต์ก่อน mapping มันจึงส่งคืนในลำดับเดียวกับลิสต์เดิมเสมอ เราได้ implement #reverse_each และ #map ไปแล้ว ดังนั้นเราควรจะ chain พวกมันเข้าด้วยกันเพื่อ map ย้อนกลับได้ โชคดีที่คลาส Enumerator ของ Ruby ช่วยได้
ครั้งก่อน เราทำให้แน่ใจว่าเรียก Kernel#to_enum ถ้าเมธอด LinkedList#each ถูกเรียกโดยไม่มีบล็อก ซึ่งทำให้สามารถ chain เมธอด enumerable ได้โดยการส่งคืนออบเจ็กต์ Enumerator เพื่อดูว่าคลาส Enumerator ทำงานอย่างไร เราจะ implement เวอร์ชันของตัวเอง
class DIYEnumerator
include DIYEnumerable
def initialize(object, method)
@object = object
@method = method
end
def each(&block)
@object.send(@method, &block)
end
end
เช่นเดียวกับ Enumerator ของ Ruby คลาส enumerator ของเราเป็น wrapper รอบเมธอดบนออบเจ็กต์ ด้วยการ bubble up ไปยังออบเจ็กต์ที่ wrapped เราสามารถ chain เมธอด enumeration ได้
สิ่งนี้ทำงานได้เพราะ instance ของ DIYEnumerator เป็น enumerable เอง มัน implement #each โดยการเรียกออบเจ็กต์ที่ wrapped และ include โมดูล DIYEnumerable ดังนั้นเมธอด enumerable ทั้งหมดจึงสามารถเรียกบนมันได้
เราจะส่งคืน instance ของคลาส DIYEnumerator ถ้าไม่มีบล็อกถูกส่งให้เมธอด LinkedList#each
class LinkedList
# ...
def each(&block)
if block_given?
yield @head
@tail.each(&block) if @tail
else
DIYEnumerator.new(self, :each)
end
end
end
ด้วย enumerator ของตัวเอง เราสามารถ chain enumeration เพื่อได้ผลลัพธ์ในลำดับเดิมโดยไม่ต้องส่งบล็อกว่างให้การเรียกเมธอด #reverse_each
irb> list = LinkedList.new(73, 12, 42) => [73, [12, [42]]]
irb> list.map { |element| element * 10 } => [420, [120, [730]]]
Enumeration แบบ Eager และ Lazy
นี่คือการจบของการสำรวจการ implement ของโมดูล Enumerable และคลาส Enumerator สำหรับตอนนี้ เราได้เรียนรู้ว่าเมธอด enumerable บางส่วนทำงานอย่างไร และว่า enumerator ช่วย chain enumeration ได้อย่างไรโดยการ wrap ออบเจ็กต์ enumerable
แต่ยังมีปัญหาบ้างกับแนวทางของเรา โดยธรรมชาติ enumeration เป็น eager หมายถึงมันวนลูปผ่านลิสต์ทันทีที่หนึ่งในเมธอด enumerable ถูกเรียกบนมัน แม้จะโอเคในกรณีส่วนใหญ่ แต่ mapping ผ่านลิสต์ในทิศทางตรงข้ามจะ reverse ลิสต์สองครั้ง ซึ่งควรจะไม่จำเป็น
เพื่อลดจำนวนลูป เราสามารถใช้ Enumerator::Lazy เพื่อเลื่อนการวนลูปไปตอนสุดท้าย และให้การ reverse ลิสต์ซ้ำกัน cancel ตัวเองออกไป
เราจะเก็บไว้สำหรับตอนหน้า อย่าพลาดตอนต่อไปและการสำรวจเพิ่มเติมเกี่ยวกับการทำงานภายในที่神奇ของ Ruby! สมัครรับจดหมายข่าว Ruby Magic เพื่อรับบทความใหม่ส่งเข้าอีเมลทันทีที่เผยแพร่