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!"

이것은 Ruby의 inherited이지만 상당히 정적인 메커니즘입니다. 및 extended 후크 메서드는 포함하는 클래스에 따라 몇 가지 다양한 동작을 허용합니다.

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를 사용할 수 있습니다. 필요한 유형 검사를 수행하기 위해 외부 범위에서 개체를 가져옵니다.

적절하게 명명된 접근자 메서드를 추가하는 것은 그다지 어렵지 않습니다.

# 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_ 접두사가 붙습니다. 최종 접근자 이름을 생성합니다. 메서드의 이름을 알게 되면 다시 define_method를 사용합니다. 2단계에 표시된 대로 모듈에 추가합니다.

여기까지의 전체 코드가 있습니다. 유연하고 구성 가능한 Ruby 모듈의 경우 20줄 미만입니다. 나쁘지 않습니다.

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)

Ruby 애플리케이션을 위한 파일 업로드 툴킷을 제공하는 훌륭한 Shrine gem에서 유사한 사용법을 찾을 수 있습니다.

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

이 패턴은 아직 비교적 새롭지만 앞으로 더 많이 볼 수 있을 것으로 예상합니다. 특히 Rails보다 순수한 Ruby 애플리케이션에 더 집중하는 gem에서 더욱 그렇습니다.

요약

이 게시물에서는 모듈 빌더 패턴이라고도 하는 기술인 Ruby에서 구성 가능한 모듈을 구현하는 방법을 살펴보았습니다. 다른 메타프로그래밍 기술과 마찬가지로 이것은 복잡성을 증가시키는 대가를 치르게 되므로 정당한 이유 없이 사용해서는 안 됩니다. 그러나 이러한 유연성이 필요한 드문 경우에 Ruby의 객체 모델은 우아하고 간결한 솔루션을 다시 한 번 허용합니다. 모듈 빌더 패턴은 대부분의 Ruby 개발자에게 자주 필요한 것은 아니지만, 특히 라이브러리 작성자에게 툴킷에 포함하면 좋은 도구입니다.

추신 Ruby Magic 게시물이 언론에 공개되는 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하고 게시물을 놓치지 마세요!