จับกระเป๋าเดินทางให้แน่นหน่ะครับ เพราะวันนี้เราจะเดินทางขึ้นไปจนถึงปลาย ancestor chain เราจะติดตามการเรียกเมธอดและดูว่ามันขึ้นไปตาม chain อย่างไร รวมถึงดูว่าจะเกิดอะไรขึ้นถ้าเมธอดหายไป และเพราะเราชอบเล่นกับไฟ เราจึงไม่หยุดแค่นั้น แต่อยู่ต่อไปจนถึง การเล่นกับไฟ การ override BasicObject#method_missing ถ้าคุณใส่ใจดู เราอาจจะใช้มันในตัวอย่างปฏิบัติจริง ไม่มีการรับประกันนะครับ เริ่มกันเลย!
Ancestor Chain
มาเริ่มกับกฎพื้นฐานของ ancestor chains ใน Ruby:
- Ruby รองรับ single inheritance เท่านั้น
- แต่ยังอนุญาตให้ออบเจ็กต์ include ชุดของ modules ได้
ใน Ruby ancestor chain ประกอบด้วยการ traverse ทุกคลาสและโมดูลที่สืบทอดสำหรับคลาสที่กำหนด
มา看ตัวอย่างเพื่อดูว่า ancestor chain ถูกจัดการใน Ruby อย่างไร
module Auth
end
module Session
end
module Iterable
end
class Collection
prepend Iterable
end
class Users < Collection
prepend Session
include Auth
end
p Users.ancestors
ผลลัพธ์:
[ Session, Users, Auth, # Users
Iterable, Collection, # Collection
Object, Kernel, BasicObject # Ruby Object Model ]
ก่อนอื่น เราเรียก class method ancestors เพื่อเข้าถึง ancestor chain ของคลาสที่กำหนด
เราสามารถเห็นได้ว่า การเรียก Users.ancestors ส่งคืนอาร์เรย์ของคลาสและโมดูลที่ประกอบด้วยตามลำดับ:
- โมดูลที่ prepend ของ
Users - คลาส
Users - โมดูลที่ include ของคลาส
Users - โมดูลที่ prepend ของคลาส
Collection— โดยตรงจาก parent ของ Users - คลาส
Collection - โมดูลที่ include ของคลาส
Collection— ไม่มี - คลาส
Object— การสืบทอดเริ่มต้นของทุกคลาส - โมดูล
Kernel— included ในObjectและเก็บ core methods - คลาส
BasicObject— root class ใน Ruby
ดังนั้น ลำดับการปรากฏสำหรับคลาสหรือโมดูลที่ traverse ใดๆ จะเป็นดังนี้เสมอ:
- โมดูลที่ prepend
- คลาสหรือโมดูล
- โมดูลที่ include ของมัน
Ancestor chain ถูก traverse หลักโดย Ruby เมื่อมีการ invoke เมธอดบนออบเจ็กต์หรือคลาส
Method Lookup Path
เมื่อส่งข้อความ Ruby จะ traverse ancestor chain ของ message receiver และตรวจสอบว่ามีตัวไหนตอบสนองต่อข้อความที่กำหนดหรือไม่
ถ้าคลาสหรือโมดูลใดใน ancestor chain ตอบสนองต่อข้อความ เมธอดที่เกี่ยวข้องกับข้อความนั้นจะถูก execute และการ traverse ancestor chain จะหยุด
class Collection < Array
end
Collection.ancestors # => [Collection, Array, Enumerable, Object, Kernel, BasicObject]
collection = Collection.new([:a, :b, :c])
collection.each_with_index # => :a
ที่นี่ ข้อความ collection.each_with_index ถูก receive โดยโมดูล Enumerable แล้วเมธอด Enumerable#each_with_index จะถูกเรียกสำหรับข้อความนี้
ที่นี่ เมื่อ collection.each_with_index ถูกเรียก Ruby จะตรวจสอบว่า:
- Collection ตอบสนองต่อข้อความ
each_with_index=> NO - Array ตอบสนองต่อข้อความ
each_with_index=> NO - Enumerable ตอบสนองต่อข้อความ
each_with_index=> YES
ดังนั้น จากที่นี่ Ruby หยุดการ traverse ancestor chain และเรียกเมธอดที่เกี่ยวข้องกับข้อความ ในกรณีนี้คือเมธอด Enumerable#each_with_index
ใน Ruby กลไกนี้เรียกว่า Method Lookup Path
ตอนนี้จะเกิดอะไรขึ้นถ้าไม่มีคลาสและโมดูลใดๆ ที่ประกอบเป็น ancestor chain ของ receiver ตอบสนองต่อข้อความ?
BasicObject#method_missing
พอเล่นดีๆ แล้ว! มาทำลายสิ่งต่างๆ แบบนักพัฒนา: โดยการโยน exception เราจะ implement คลาส Collection และเรียกเมธอดที่ไม่รู้จักบน instance หนึ่งของมัน
class Collection
end
c = Collection.new
c.search('item1') # => NoMethodError: undefined method `search` for #<Collection:0x123456890>
ที่นี่ คลาส Collection ไม่ได้ implement เมธอด search ดังนั้น NoMethodError จึงถูก raise แต่ error นี้มาจากไหน?
Error ถูก raise ในเมธอด BasicObject#method_missing เมธอดนี้ถูกเรียกเมื่อ Method Lookup Path จบลงโดยไม่พบเมธอดใดๆ ที่ตรงกับข้อความที่กำหนด
โอเค... แต่มETHOD นี้แค่ raise NoMethodError ดังนั้นการ override เมธอดนี้ในบริบทของคลาส Collection ของเราจะดีมาก
การ Override เมธอด BasicObject#method_missing
เดาไง? Override method_missing ได้อย่างสมบูรณ์ เพราะเมธอดนี้ก็ยังอยู่ภายใต้กลไกของ Method Lookup Path ความแตกต่างกับเมธอดทั่วไปคือเราแน่ใจว่าจะพบเมธอดนี้อย่างน้อยหนึ่งครั้งโดย Method Lookup Path
แน่นอน เพราะคลาส BasicObject — ซึ่งเป็น root class ของทุกคลาสใน Ruby — กำหนดเวอร์ชัน minimal ของเมธอดนี้ Ruby Magic แบบคลาสสิคใช่ไหม?
มาลอง override เมธอดนี้ในคลาส Collection ของเรา:
class Collection
def initialize
@collection = {}
end
def method_missing(method_id, *args)
if method_id[-1] == '='
key = method_id[0..-2]
@collection[key.to_sym] = args.first
else
@collection[method_id]
end
end
end
collection = Collection.new
collection.obj1 = 'value1'
collection.obj2 = 'value2'
collection.obj1 # => 'value1'
collection.obj2 # => 'value2'
ที่นี่ Collection#method_missing ทำหน้าที่เป็น delegator ไปยัง instance variable @collection จริงๆ แล้วนี่คือวิธีที่ Ruby จัดการ object delegation โดยประมาณ — c.f: library delegate
ถ้าเมธอดที่หายไปเป็น setter method (collection.obj1 = 'value1') ชื่อเมธอด (:obj1) จะถูกใช้เป็น key และ argument ('value1') เป็น value ของ hash entry @collection (@collection[:obj1] = 'value1')
HTML Tag Generator
ตอนนี้เรารู้แล้วว่าเมธอด method_missing ทำงานเบื้องหลังอย่างไร มา implement use case ที่ reproducible กัน
ที่นี่เป้าหมายคือการ define DSL ดังนี้:
HTML.p 'hello world' # => <p>hello world</p>
HTML.div 'hello world' # => <div>hello world</div>
HTML.h1 'hello world' # => <h1>hello world</h1>
HTML.h2 'hello world' # => <h2>hello world</h2>
HTML.span 'hello world' # => <span>hello world</span>
HTML.p "hello #{HTML.b 'world'}" # => <p>hello <b>world</b></p>
เพื่อทำเช่นนั้น เราจะ implement เมธอด HTML.method_missing เพื่อไม่ต้อง define เมธอดสำหรับแต่ละ HTML tag
ก่อนอื่น เรา define โมดูล HTML จากนั้น define class method method_missing ในโมดูลนี้:
module HTML
def HTML.method_missing(method_id, *args, &block)
"<#{method_id}>#{args.first}</#{method_id}>"
end
end
เมธอดของเราจะแค่สร้าง HTML tag โดยใช้ method_id ที่หายไป — :div สำหรับการเรียก HTML.div ตัวอย่างเช่น
โปรดทราบว่า class methods ก็ยังอยู่ภายใต้ Method Lookup Path เช่นกัน
เราสามารถปรับปรุง HTML tag generator ของเราได้โดย:
- ใช้ block argument เพื่อจัดการ nested tags
- จัดการ single tags —
<br/>ตัวอย่างเช่น
แต่โปรดสังเกตว่า ด้วยเพียงไม่กี่บรรทัดของโค้ด เราสามารถ generate HTML tags จำนวนมากได้
สรุป:
method_missing เป็น entry point ที่ดีสำหรับการสร้าง DSLs ที่คำสั่งส่วนใหญ่จะ share ชุดของ patterns ที่ระบุไว้
Conclusion
เราเดินทางขึ้นไปจนถึงปลาย ancestor chain ใน Ruby และดำน้ำเข้าไปใน BasicObject#method_missing BasicObject#method_missing เป็นส่วนหนึ่งของ Ruby Hook Methods ใช้เพื่อ interact กับออบเจ็กต์ในช่วงเวลาที่แน่นอนใน lifecycle ของพวกมัน เหมือน hook methods อื่นๆ Ruby Hook Methods เมธอด hook นี้ต้องใช้อย่างระมัดระวัง และด้วย "อย่างระมัดระวัง" เราหมายถึง ไม่ควรปรับเปลี่ยนพฤติกรรมของ Ruby Object Model — เว้นแต่คุณจะเล่นกับมันหรือเขียนบล็อกเกี่ยวกับมัน ;-)
Voilà!