ในวันนี้การสำรวจ hidden gems ใน standard library ของ Ruby เราจะมาดูเรื่อง delegation
น่าเสียดายที่ term นี้ — เหมือนอีกมากมาย — กลายเป็น somewhat muddled ผ่าน السنوات และหมายถึงสิ่งต่างกันสำหรับคนต่างคน ตาม Wikipedia:
Delegation หมายถึงการ evaluate a member (property หรือ method) ของออบเจ็กต์หนึ่ง (the receiver) ในบริบทของอีกออบเจ็กต์หนึ่งที่เป็นต้นทาง (the sender) Delegation สามารถทำได้ explicitly โดยการส่ง sending object ไปยัง receiving object ซึ่งทำได้ในภาษา object-oriented ใดๆ หรือ implicitly โดย member lookup rules ของภาษา ซึ่งต้องการ language support สำหรับ feature นี้
อย่างไรก็ตาม บ่อยกว่านั้น คนใช้ term นี้เพื่ออธิบายออบเจ็กต์ที่เรียกเมธอดที่ตรงกันของอีกออบเจ็กต์โดยไม่ส่งตัวเองเป็น argument ซึ่งอธิบายได้แม่นยำกว่าว่า "forwarding"
ด้วยข้อที่กล่าวมา เราจะใช้ "delegation" เพื่ออธิบายทั้งสอง patterns นี้ต่อไปในบทความนี้
Delegator
มาเริ่มสำรวจ delegation ใน Ruby โดยดูที่คลาส Delegator ของ standard library ซึ่งให้ delegation patterns หลายแบบ
SimpleDelegator
ตัวที่ง่ายที่สุด และตัวที่ผมเจอบ่อยที่สุดในธรรมชาติ คือ SimpleDelegator ซึ่ง wrap ออบเจ็กต์ที่提供ผ่าน initializer และ delegate เมธอดที่หายไปทั้งหมดไปยังมัน มาดูใน action กัน
require 'delegate'
User = Struct.new(:first_name, :last_name)
class UserDecorator < SimpleDelegator
def full_name
"#{first_name} #{last_name}"
end
end
ก่อนอื่น เราต้อง require 'delegate' เพื่อให้ SimpleDelegator ใช้งานได้ในโค้ดของเรา เรายังใช้ Struct สร้างคลาส User เรียบง่ายกับ accessors first_name และ last_name เราจึงเพิ่ม UserDecorator ซึ่ง define เมธอด full_name รวมส่วนชื่อแต่ละส่วนเป็น string เดียว นี่คือที่ที่ SimpleDelegator เข้ามาเล่น角色: เนื่องจากไม่ได้ define first_name หรือ last_name บนคลาสปัจจุบัน พวกมันจะถูกเรียกบนออบเจ็กต์ที่ wrapped แทน:
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name #=> "John Doe"
SimpleDelegator ยังอนุญาตให้เรา override เมธอดที่ delegated ด้วย super เรียกเมธอดที่ตรงกันบนออบเจ็กต์ที่ wrapped เราสามารถใช้สิ่งนี้ในตัวอย่างเพื่อแสดงแค่ initial แทน first name เต็ม:
class UserDecorator < SimpleDelegator
def first_name
"#{super[0]}."
end
end
decorated_user.first_name #=> "J."
decorated_user.full_name #=> "J. Doe"
Delegator
ขณะอ่านตัวอย่างข้างต้น คุณเคย wonder ไหมว่า UserDecorator ของเรารู้อย่างไรว่าจะ delegate ไปยังออบเจ็กต์ไหน? คำตอบอยู่ที่ parent class ของ SimpleDelegator — Delegator นี่คือ abstract base class สำหรับกำหนด custom delegation schemes โดย提供 implementations สำหรับ __getobj__ และ __setobj__ เพื่อ get และ set delegation target ตามลำดับ ด้วยความรู้นี้ เราสามารถสร้างเวอร์ชันของ SimpleDelegator เองเพื่อการสาธิตได้ง่าย:
class MyDelegator < Delegator
attr_accessor :wrapped
alias_method :__getobj__, :wrapped
def initialize(obj)
@wrapped = obj
end
end
class UserDecorator < MyDelegator
def full_name
"#{first_name} #{last_name}"
end
end
สิ่งนี้แตกต่างจาก implementation จริงของ SimpleDelegator เล็กน้อย ซึ่งเรียก __setobj__ ในเมธอด initialize ของมัน เนื่องจาก custom delegator class ของเราไม่ต้องการมัน เราจึงเว้นเมธอดนั้นไว้โดยสิ้นเชิง
สิ่งนี้ควรทำงานเหมือนตัวอย่างก่อนหน้าทุกประการ และจริงๆ แล้วมันก็ทำงาน:
UserDecorator.superclass #=> MyDelegator < Delegator
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name #=> "John Doe"
DelegateMethod
Delegation pattern สุดท้ายที่ Delegate 提供ให้เราคือ Object.DelegateClass method ที่ named อย่างน่าสนใจ วิธีนี้ generate และส่งคืน delegator class สำหรับคลาสเฉพาะ ซึ่งเราสามารถ inherit จากมันได้:
class MyClass < DelegateClass(ClassToDelegateTo)
def initialize
super(obj_of_ClassToDelegateTo)
end
end
แม้จะดู confusing ตอนแรก — โดยเฉพาะข้อเท็จจริงที่ว่า right-hand side ของ inheritance สามารถมี arbitrary Ruby code — แต่มันจริงๆ แล้ว follow patterns ที่เราสำรวจก่อนหน้านี้ คือ คล้ายกับการ inherit จาก SimpleDelegator
Standard library ของ Ruby ใช้ feature นี้เพื่อ define คลาส Tempfile ซึ่ง delegate ส่วนใหญ่ของงานไปยังคลาส File ในขณะที่ตั้งค่า special rules เกี่ยวกับ storage location และ file deletion เราสามารถใช้ mechanism เดียวกันเพื่อ setup คลาส Logfile แบบ custom ได้เช่นนี้:
class Logfile < DelegateClass(File)
MODE = File::WRONLY | File::CREAT | File::APPEND
def initialize(basename, logdir = '/var/log')
# Create logfile in location specified by logdir
path = File.join(logdir, basename)
logfile = File.open(path, MODE, 0644)
# นี้จะเรียก initialize method ของ Delegator ดังนั้นจากจุดนี้เป็นต้นไป
# เราสามารถเรียกเมธอดใดๆ จาก File บน instances ของ Logfile ได้
super(logfile)
end
end
Forwardable
น่าสนใจพอสมควรที่ standard library ของ Ruby 提供 library อีกอันสำหรับ delegation ในรูปของโมดูล Forwardable และเมธอด def_delegator และ def_delegators ของมัน
มา rewrite ตัวอย่าง UserDecorator เดิมของเราด้วย Forwardable
require 'forwardable'
User = Struct.new(:first_name, :last_name)
class UserDecorator
extend Forwardable
def_delegators :@user, :first_name, :last_name
def initialize(user)
@user = user
end
def full_name
"#{first_name} #{last_name}"
end
end
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name #=> "John Doe"
ความแตกต่างที่โดดเด่นที่สุดคือ delegation ไม่ได้ถูก提供อัตโนมัติผ่าน method_missing แต่ต้อง declare explicitly สำหรับแต่ละเมธอดที่เราต้องการ forward ซึ่งทำให้เรา "ซ่อน" เมธอดของออบเจ็กต์ที่ wrapped ที่เราไม่ต้องการ expose ให้ clients ได้ ซึ่งให้ control มากขึ้น over public interface และเป็นเหตุผลหลักที่ผมชอบ Forwardable มากกว่า SimpleDelegator
Feature ดีอีกอย่างของ Forwardable คือความสามารถในการ rename delegated methods ผ่าน def_delegator ซึ่งรับ optional third argument ที่ระบุ alias ที่ต้องการ:
class UserDecorator
extend Forwardable
def_delegator :@user, :first_name, :personal_name
def_delegator :@user, :last_name, :family_name
def initialize(user)
@user = user
end
def full_name
"#{personal_name} #{family_name}"
end
end
UserDecorator ข้างต้น expose แค่ personal_name และ family_name ที่ aliased แล้ว ในขณะที่ยัง forward ไปยัง first_name และ last_name ของ User object ที่ wrapped:
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.first_name #=> NoMethodError: undefined method `first_name' for #<UserDecorator:0x000000010f995cb8>
decorated_user.personal_name #=> "John"
Feature นี้มาใน handy บางครั้ง ผมเคยใช้มันสำหรับการ migrate code ระหว่าง libraries ที่มี interfaces คล้ายกันแตกต่างใน expectations เกี่ยวกับ method names
Outside the Standard Library
แม้จะมี delegation solutions ใน standard library แล้ว Ruby community ได้พัฒนา alternatives หลายตัวผ่านปีๆ มา เราจะสำรวจสองตัวต่อไป
delegate
ด้วยความนิยมของ Rails เมธอด delegate ของมันอาจจะเป็น delegation form ที่ใช้บ่อยที่สุดโดย Ruby developers นี่คือวิธีที่เราสามารถใช้เพื่อ rewrite UserDecorator เก่าๆ ของเรา:
# ใน Rails app จริง นี่น่าจะเป็น subclass ของ ApplicationRecord
User = Struct.new(:first_name, :last_name)
class UserDecorator
attr_reader :user
delegate :first_name, :last_name, to: :user
def initialize(user)
@user = user
end
def full_name
"#{first_name} #{last_name}"
end
end
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name #=> "John Doe"
สิ่งนี้คล้าย Forwardable พอสมควร แต่เราไม่ต้องใช้ extend เนื่องจาก delegate ถูก define โดยตรงบน Module และดังนั้นพร้อมใช้งานในทุก class หรือ module body (ดีหรือแย่ คุณตัดสินใจ) อย่างไรก็ตาม delegate มี tricks น่าสนใจอยู่บ้าง ก่อนอื่นคือ option :prefix ซึ่งจะ prefix delegated method names ด้วยชื่อของออบเจ็กต์ที่เรา delegate ไป ดังนั้น
delegate :first_name, :last_name, to: :user, prefix: true
จะ generate เมธอด user_first_name และ user_last_name หรือเราสามารถ提供 custom prefix ได้:
delegate :first_name, :last_name, to: :user, prefix: :account
ตอนนี้เราสามารถเข้าถึงส่วนต่างๆ ของชื่อ user ผ่าน account_first_name และ account_last_name
Option น่าสนใจอีกอย่างของ delegate คือ option :allow_nil ถ้าออบเจ็กต์ที่เรา delegate ไปเป็น nil อยู่ — ตัวอย่างเช่นเพราะ unset ActiveRecord relation — เราปกติจะได้ NoMethodError:
decorated_user = UserDecorator.new(nil)
decorated_user.first_name #=> Module::DelegationError: UserDecorator#first_name delegated to @user.first_name, but @user is nil
อย่างไรก็ตาม ด้วย option :allow_nil การเรียกนี้จะ succeed และส่งคืน nil แทน:
class UserDecorator
delegate :first_name, :last_name, to: :user, allow_nil: true
...
end
decorated_user = UserDecorator.new(nil)
decorated_user.first_name #=> nil
Casting
Delegation option สุดท้ายที่เราจะดูคือ gem Casting ของ Jim Gay ซึ่งอนุญาตให้ developers "delegate methods in Ruby and preserve self" นี่อาจเป็น closest to strict definition of delegation เพราะมันใช้ dynamic nature ของ Ruby เพื่อ temporarily rebind receiver ของ method call แทนจะเป็น:
UserDecorator.instance_method(:full_name).bind(user).call #=> "John Doe"
Aspect ที่น่าสนใจที่สุดคือ developers สามารถเพิ่ม behavior ให้ออบเจ็กต์ได้โดยไม่เปลี่ยน superclass hierarchies
require 'casting'
User = Struct.new(:first_name, :last_name)
module UserDecorator
def full_name
"#{first_name} #{last_name}"
end
end
user = User.new("John", "Doe")
user.extend(Casting::Client)
user.delegate(:full_name, UserDecorator)
ที่นี่เรา extend user ด้วย Casting::Client ซึ่งให้เราเข้าถึงเมธอด delegate หรือเราสามารถใช้ include Casting::Client ภายในคลาส User เพื่อให้ความสามารถนี้กับทุก instances
เพิ่มเติม Casting 提供 options สำหรับเพิ่ม behaviors ชั่วคราวสำหรับ lifetime ของบล็อก หรือจนกว่าจะ remove เองอีกครั้ง เพื่อให้สิ่งนี้ทำงาน เราต้อง enable delegation ของ missing methods ก่อน:
user.delegate_missing_methods
เพื่อเพิ่ม behavior สำหรับระยะเวลาของบล็อกเดียว เราสามารถใช้ class method delegating ของ Casting:
Casting.delegating(user => UserDecorator) do
user.full_name #=> "John Doe"
end
user.full_name # NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">
หรือเราสามารถเพิ่ม behavior จนกว่าจะเรียก uncast เอง:
user.cast_as(UserDecorator)
user.full_name #=> "John Doe"
user.uncast
# NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">
แม้จะซับซ้อนกว่า solutions อื่นๆ ที่นำเสนอ Casting 提供 control มากมาย และ Jim สาธิตการใช้งานต่างๆ และเพิ่มเติมในหนังสือ Clean Ruby ของเขา
Summary
Delegation และ method forwarding เป็น patterns ที่มีประโยชน์สำหรับการแบ่ง responsibilities ระหว่างออบเจ็กต์ที่เกี่ยวข้อง ใน plain Ruby projects ทั้ง Delegator และ Forwardable สามารถใช้ได้ ในขณะที่ Rails code มัก傾向ไปที่เมธอด delegate ของมัน สำหรับ maximum control บนสิ่งที่ delegated gem Casting เป็น excellent choice แม้จะซับซ้อนกว่า solutions อื่นๆ เล็กน้อย
Guest Author Michael Kohl มีความรักกับ Ruby มาตั้งแต่ปี 2003 เขายังชอบเขียนและพูดเกี่ยวกับภาษานี้ และ co-organizes Bangkok.rb และ RubyConf Thailand