Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

โมดูล Ruby ที่กำหนดค่าได้:รูปแบบตัวสร้างโมดูล

ในโพสต์นี้ เราจะสำรวจวิธีสร้างโมดูล Ruby ที่ผู้ใช้โค้ดของเรากำหนดค่าได้ ซึ่งเป็นรูปแบบที่ช่วยให้ผู้เขียน gem สามารถเพิ่มความยืดหยุ่นให้กับไลบรารีได้

นักพัฒนา Ruby ส่วนใหญ่คุ้นเคยกับการใช้โมดูลเพื่อแชร์พฤติกรรม ท้ายที่สุด นี่เป็นหนึ่งในกรณีการใช้งานหลัก ตามเอกสาร:

โมดูลให้บริการสองวัตถุประสงค์ในฟังก์ชัน Ruby, เนมสเปซและมิกซ์อิน

Rails ได้เพิ่มน้ำตาลซินแทคติคในรูปแบบของ ActiveSupport::Concern แต่หลักการทั่วไปยังคงเดิม

ปัญหา

การใช้โมดูลเพื่อให้ฟังก์ชันมิกซ์อินมักจะตรงไปตรงมา สิ่งที่เราต้องทำคือรวมวิธีการบางอย่างและรวมโมดูลของเราไว้ที่อื่น:

module HelloWorld
  def hello
    "Hello, world!"
  end
end
class Test
  include HelloWorld
end
Test.new.hello
#=> "Hello, world!"

นี่เป็นกลไกที่ค่อนข้างคงที่ แม้ว่า inherited . ของ Ruby และ extended วิธีการของ hook อนุญาตให้มีพฤติกรรมที่แตกต่างกันไปตามคลาสรวมถึง:

module HelloWorld
  def self.included(base)
    define_method :hello do
      "Hello, world from #{base}!"
    end
  end
end
class Test
  include HelloWorld
end
Test.new.hello
#=> "Hello, world from Test!"

นี่เป็นไดนามิกมากกว่า แต่ก็ยังไม่อนุญาตให้ผู้ใช้โค้ดของเราเปลี่ยนชื่อ hello วิธีการ ณ เวลารวมโมดูล

วิธีแก้ปัญหา:โมดูล Ruby ที่กำหนดค่าได้

ในช่วงไม่กี่ปีที่ผ่านมา มีรูปแบบใหม่ที่แก้ปัญหานี้ ซึ่งบางครั้งผู้คนเรียกว่า "รูปแบบการสร้างโมดูล" เทคนิคนี้อาศัยคุณสมบัติหลักสองประการของ Ruby:

  • โมดูลก็เหมือนกับอ็อบเจ็กต์อื่นๆ ที่สามารถสร้างได้ทันที กำหนดให้กับตัวแปร แก้ไขแบบไดนามิก และส่งผ่านไปยังหรือส่งคืนจากเมธอด

    def make_module
      # create a module on the fly and assign it to variable
      mod = Module.new
     
      # modify module
      mod.module_eval do
        def hello
          "Hello, AppSignal world!"
        end
      end
     
      # explicitly return it
      mod
    end
  • อาร์กิวเมนต์ include หรือ extended การเรียกไม่จำเป็นต้องเป็นโมดูล แต่ก็สามารถเป็นนิพจน์ที่ส่งคืนได้ เช่น การเรียกเมธอด

    class Test
      # include the module returned by make_module
      include make_module
    end
     
    Test.new.hello
    #=> "Hello, AppSignal world!"

การทำงานของตัวสร้างโมดูล

ตอนนี้เราจะใช้ความรู้นี้เพื่อสร้างโมดูลอย่างง่ายที่เรียกว่า Wrapper ซึ่งใช้พฤติกรรมต่อไปนี้:

  1. คลาสที่มี Wrapper สามารถห่อเฉพาะวัตถุบางประเภทเท่านั้น ตัวสร้างจะตรวจสอบประเภทอาร์กิวเมนต์และทำให้เกิดข้อผิดพลาดหากประเภทไม่ตรงกับที่คาดไว้
  2. อ็อบเจ็กต์ที่ห่อหุ้มจะพร้อมใช้งานผ่านเมธอดของอินสแตนซ์ที่เรียกว่า original_<class> , เช่น. original_integer หรือ original_string .
  3. จะช่วยให้ผู้บริโภคโค้ดของเราสามารถระบุชื่ออื่นสำหรับวิธีการเข้าถึงนี้ได้ เช่น the_string .

มาดูกันว่าเราต้องการให้โค้ดของเราทำงานอย่างไร:

# 1
class IntWrapper
 # 2
 include Wrapper.for(Integer)
end
 
# 3
i = IntWrapper.new(42)
i.original_integer
#=> 42
 
# 4
i = IntWrapper.new("42")
#=> TypeError (not a Integer)
 
# 5
class StringWrapper
 include Wrapper.for(String, accessor_name: :the_string)
end
 
s = StringWrapper.new("Hello, World!")
# 6
s.the_string
#=> "Hello, World!"

ในขั้นตอนที่ 1 เรากำหนดคลาสใหม่ที่เรียกว่า IntWrapper .

ในขั้นตอนที่ 2 เรามั่นใจว่าคลาสนี้ไม่เพียงแค่รวมโมดูลตามชื่อ แต่รวมผลลัพธ์ของการเรียกไปยัง Wrapper.for(Integer) แทน .

ในขั้นตอนที่ 3 เรายกตัวอย่างวัตถุของคลาสใหม่ของเราและกำหนดให้กับ i . ตามที่ระบุไว้ วัตถุนี้มีวิธีการที่เรียกว่า original_integer ที่ตรงตามความต้องการของเรา

ในขั้นตอนที่ 4 หากเราพยายามส่งผ่านอาร์กิวเมนต์ประเภทที่ไม่ถูกต้อง เช่น สตริง TypeError ที่เป็นประโยชน์ จะถูกยกขึ้น สุดท้าย มาตรวจสอบว่าผู้ใช้สามารถระบุชื่อตัวเข้าถึงที่กำหนดเองได้

สำหรับสิ่งนี้ เรากำหนดคลาสใหม่ที่เรียกว่า StringWrapper ในขั้นตอนที่ 5 และส่ง the_string เป็นอาร์กิวเมนต์คำหลัก accessor_name ซึ่งเราเห็นในการดำเนินการในขั้นตอนที่ 6

แม้ว่านี่จะเป็นที่ยอมรับว่าเป็นตัวอย่างที่ค่อนข้างประดิษฐ์ แต่ก็มีพฤติกรรมที่แตกต่างกันมากพอที่จะอวดรูปแบบตัวสร้างโมดูลและวิธีใช้งาน

ความพยายามครั้งแรก

จากข้อกำหนดและตัวอย่างการใช้งาน ตอนนี้เราสามารถเริ่มการใช้งานได้แล้ว เรารู้แล้วว่าเราต้องการโมดูลชื่อ Wrapper ด้วยวิธีการระดับโมดูลที่เรียกว่า for ซึ่งรับคลาสเป็นอาร์กิวเมนต์คีย์เวิร์ดเสริม:

module Wrapper
 def self.for(klass, accessor_name: nil)
 end
end

เนื่องจากค่าส่งคืนของวิธีนี้กลายเป็นอาร์กิวเมนต์ของ include จะต้องเป็นโมดูล ดังนั้น เราสามารถสร้างอันที่ไม่ระบุตัวตนใหม่ด้วย Module.new .

Module.new do
end

ตามข้อกำหนดของเรา สิ่งนี้จำเป็นต้องกำหนดคอนสตรัคเตอร์ซึ่งตรวจสอบประเภทของอ็อบเจ็กต์ที่ส่งผ่าน รวมถึงวิธีการระบุชื่อที่เหมาะสม มาเริ่มกันที่ตัวสร้าง:

define_method :initialize do |object|
 raise TypeError, "not a #{klass}" unless object.is_a?(klass)
 @object = object
end

โค้ดชิ้นนี้ใช้ define_method เพื่อเพิ่มวิธีการอินสแตนซ์ให้กับผู้รับแบบไดนามิก เนื่องจากบล็อกทำหน้าที่เป็นตัวปิด จึงสามารถใช้ klass วัตถุจากขอบเขตภายนอกเพื่อทำการตรวจสอบประเภทที่ต้องการ

การเพิ่มเมธอด accessor ที่มีชื่อเหมาะสมนั้นไม่ยากนัก:

# 1
method_name = accessor_name || begin
 klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
 "original_#{klass_name}"
end
 
# 2
define_method(method_name) { @object }

อันดับแรก เราต้องดูว่าผู้เรียกรหัสของเราส่งผ่าน accessor_name . ถ้าใช่ เราก็แค่กำหนดให้ method_name และเสร็จแล้ว มิฉะนั้น เราจะนำคลาสและแปลงเป็นสตริงที่ขีดเส้นใต้ เช่น Integer เปลี่ยนเป็น integer หรือ OpenStruct ลงใน open_struct . klass_nameนี้ ตัวแปรจะถูกนำหน้าด้วย original_ เพื่อสร้างชื่อ accessor สุดท้าย เมื่อรู้ชื่อเมธอดแล้ว เราก็ใช้ define_method . อีกครั้ง เพื่อเพิ่มลงในโมดูลของเราดังที่แสดงในขั้นตอนที่ 2

นี่คือรหัสที่สมบูรณ์จนถึงจุดนี้ น้อยกว่า 20 บรรทัดสำหรับโมดูล Ruby ที่ยืดหยุ่นและกำหนดค่าได้ ไม่เลวเลย

module Wrapper
  def self.for(klass, accessor_name: nil)
    Module.new do
      define_method :initialize do |object|
        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
        @object = object
      end
 
      method_name = accessor_name || begin
        klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
        "original_#{klass_name}"
      end
 
      define_method(method_name) { @object }
    end
  end
end

ผู้อ่านที่สังเกตอาจจำได้ว่า Wrapper.for ส่งคืนโมดูลที่ไม่ระบุชื่อ นี่ไม่ใช่ปัญหา แต่อาจทำให้สับสนเล็กน้อยเมื่อตรวจสอบสายการสืบทอดของวัตถุ:

StringWrapper.ancestors
#=> [StringWrapper, #<Module:0x0000000107283680>, Object, Kernel, BasicObject]

ที่นี่ #<Module:0x0000000107283680> (ชื่อจะแตกต่างกันไปหากคุณติดตาม) หมายถึงโมดูลที่ไม่ระบุตัวตนของเรา

เวอร์ชั่นปรับปรุง

มาทำให้ชีวิตผู้ใช้ของเราง่ายขึ้นด้วยการส่งคืนโมดูลที่มีชื่อแทนโมดูลที่ไม่ระบุตัวตน โค้ดสำหรับสิ่งนี้คล้ายกับที่เราเคยมี โดยมีการเปลี่ยนแปลงเล็กน้อย:

module Wrapper
  def self.for(klass, accessor_name: nil)
    # 1
    mod = const_set("#{klass}InstanceMethods", Module.new)
 
    # 2
    mod.module_eval do
      define_method :initialize do |object|
        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
        @object = object
      end
 
      method_name = accessor_name || begin
        klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
        "original_#{klass_name}"
      end
 
      define_method(method_name) { @object }
    end
 
    # 3
    mod
  end
end

ในขั้นตอนแรก เราสร้างโมดูลที่ซ้อนกันที่เรียกว่า "#{klass}InstanceMethods" (เช่น IntegerInstanceMethods ) นั่นเป็นเพียงโมดูล "ว่างเปล่า"

ดังที่แสดงในขั้นตอนที่ 2 เราใช้ module_eval ใน for เมธอด ซึ่งประเมินกลุ่มโค้ดในบริบทของโมดูลที่เรียกใช้ ด้วยวิธีนี้ เราสามารถเพิ่มพฤติกรรมให้กับโมดูลก่อนที่จะส่งคืนในขั้นตอนที่ 3

หากตอนนี้เราตรวจสอบบรรพบุรุษของคลาสรวมถึง Wrapper ผลลัพธ์จะรวมโมดูลที่มีชื่อถูกต้อง ซึ่งมีความหมายมากกว่าและง่ายต่อการแก้ไขจุดบกพร่องมากกว่าโมดูลที่ไม่ระบุชื่อก่อนหน้า

StringWrapper.ancestors
#=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]

รูปแบบตัวสร้างโมดูลในป่า

นอกจากโพสต์นี้ เราจะหารูปแบบตัวสร้างโมดูลหรือเทคนิคที่คล้ายกันได้จากที่ไหนอีกบ้าง

ตัวอย่างหนึ่งคือ dry-rb ตระกูลอัญมณี เช่น dry-effects ใช้ตัวสร้างโมดูลเพื่อส่งตัวเลือกการกำหนดค่าไปยังตัวจัดการเอฟเฟกต์ต่างๆ:

# This adds a `counter` effect provider. It will handle (eliminate) effects
include Dry::Effects::Handler.State(:counter)
 
# Providing scope is required
# All cache values will be scoped with this key
include Dry::Effects::Handler.Cache(:blog)

เราสามารถพบการใช้งานที่คล้ายกันใน Shrine gem ที่ยอดเยี่ยม ซึ่งมีชุดเครื่องมืออัปโหลดไฟล์สำหรับแอปพลิเคชัน Ruby:

class Photo < Sequel::Model
  include Shrine::Attachment(:image)
end

รูปแบบนี้ยังค่อนข้างใหม่ แต่ฉันคาดว่าเราจะเห็นรูปแบบนี้มากขึ้นในอนาคต โดยเฉพาะอย่างยิ่งในอัญมณีที่เน้นไปที่แอปพลิเคชัน Ruby ล้วนๆ มากกว่า Rails

สรุป

ในโพสต์นี้ เราได้สำรวจวิธีการใช้โมดูลที่กำหนดค่าได้ใน Ruby ซึ่งบางครั้งเทคนิคเรียกว่ารูปแบบตัวสร้างโมดูล เช่นเดียวกับเทคนิค metaprogramming อื่น ๆ สิ่งนี้มาพร้อมกับความซับซ้อนที่เพิ่มขึ้น ดังนั้นจึงไม่ควรใช้โดยไม่มีเหตุผลที่ดี อย่างไรก็ตาม ในกรณีที่ไม่ค่อยพบที่ต้องการความยืดหยุ่นดังกล่าว โมเดลวัตถุของ Ruby ช่วยให้ได้โซลูชันที่หรูหราและรัดกุมอีกครั้ง รูปแบบตัวสร้างโมดูลไม่ใช่สิ่งที่นักพัฒนา Ruby ส่วนใหญ่ต้องการบ่อยๆ แต่เป็นเครื่องมือที่ยอดเยี่ยมที่ควรมีในชุดเครื่องมือของตัวเอง โดยเฉพาะอย่างยิ่งสำหรับผู้เขียนห้องสมุด

ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!