티스토리 뷰

Ruby

[Ruby] 루비 메타프로그래밍(3) - Method

강씨아저씨 2018. 6. 30. 12:04

오늘의 포스팅 내용은 Ruby Metaprogramming 이다. 

포스팅의 내용은 사내에서 Ruby Metaprogramming 에 대한 발표를 위해서 아래의 책을 읽고 내용들을 정리한것이다.

https://books.google.co.kr/books/about/Metaprogramming_Ruby_2.html?id=V0iToAEACAAJ&source=kp_cover&redir_esc=y


내용이 많기때문에 시리즈로 소개할 예정이다. 


저번 포스팅(Ancestors chain - http://idea-sketch.tistory.com/37 )에 이어서 다음편이다. 


오늘은 예제를 기반으로 어떻게 하면 Ruby에서 Metaprogramming 을 사용하여 중복함수를 효율적으로 

제거 할 수 있을지에 대해 알아본다.  다음과 같은 기능을 제공하는 클래스가 있다고 생각해보자. 

class DS
  def initialize # connect to data source...
  def get_cpu_info(workstation_id) # ...
  def get_cpu_price(workstation_id) # ...
  def get_mouse_info(workstation_id) # ...
  def get_mouse_price(workstation_id) # ...
  def get_keyboard_info(workstation_id) # ...
  def get_keyboard_price(workstation_id) # ...
  def get_display_info(workstation_id) # ...
  def get_display_price(workstation_id) # ...
  # ...and so on


DS 클래스는 workstation_id 을 넣으면 해당 workstation_id 의 정보를 반환하는 역할을 하는 클래스이다.

DS 클래스의 동작 예시는 아래와 같다.

ds = DS.new
ds.get_cpu_info(42) # => "2.9 Ghz quad-core"
ds.get_cpu_price(42) # => 120
ds.get_mouse_info(42) # => "Wireless Touch"
ds.get_mouse_price(42) # => 60

 

그런데 DS 의 workstation_id 의 정보중 가격이 100 원을 넘을경우 상품정보에 *를 추가하는 기능이 필요하다는 

추가요구사항이 생겼다.

그러면 요구사항에 맞춰서 DS클래스를 수정하면 아주 간단하지만 여기서 한가지 문제점은 DS 클래스는 

라이브러리화 되어있어서 소스코드를 수정 할 수 없고 이미 여러곳에서 사용하는 있어서 함부로 수정 할 수가 없다.  


그래서 우리는 Computer라는 클래스를 생성후 DS를 Wrapping 해서 추가요구사항을 만족시키려고 한다. 

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end
  def mouse
    info = @data_source.get_mouse_info(@id)
    price = @data_source.get_mouse_price(@id)
    result = "Mouse: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end
  def cpu
    info = @data_source.get_cpu_info(@id)
    price = @data_source.get_cpu_price(@id)
    result = "Cpu: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end
  def keyboard
    info = @data_source.get_keyboard_info(@id)
    price = @data_source.get_keyboard_price(@id)
    result = "Keyboard: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end
  # ...
end


중복되는 함수를 어떻게 해결할까?

Computer 라는 클래스를 만들면서 요구사항은 만족했는데 한가지 마음에 안드는게 있다...

DS의 부품정보(mouse, cpu, keyboard...)가 하나씩 생겨날때마다 7라인씩 Computer의 코드가 증가한다!

거기다가 증가하는 코드는 매우 비슷하다! 


Dynamic Dispatch

우리는 일반적으로 클래스의 함수를 사용할때 dot(.) 을 이용하지만 Object#send 함수를 이용해서도 함수를 사용할 수 있다. 

class MyClass
  def my_method(my_arg)
    my_arg * 2
  end
end
obj = MyClass.new

# dot 을 사용할때
obj.my_method(3) # => 6

# Object#send 를 사용할때
obj.send(:my_method, 3) # => 6

그러면 dot을 이용해서도 충분히 클래스의 함수를 사용 할 수 있는데 왜 send를 사용해야 할까?

그 이유는 dot을 이용할때는 이미 호출할 함수의 이름이 결정되어 있어서 변경할 수 없지만 

send를 사용할때는 Ruby의 런타임중에 호출할 함수의 이름을 수정해서 결정 할 수 있기 때문이다.

이렇게 send를 이용해서 동적으로 함수를 사용하는 방법을 Dynamic Dispatch 라고 부른다.


하지만 Dynamic Dispatch 를 이용했을때의 장점만 있을까? 아니다! Dynamic Dispatch는 아주 유용한 기능이지만

Private 함수도 사용이 가능하기때문에 사용할때 주의를 해야한다.


Dynamic Methods

Ruby에서는 Module#define_method 를 이용해서 동적으로 함수를 정의 할 수 있다.

class MyClass
  define_method :my_method do |my_arg|
    my_arg * 3
  end
end

obj = MyClass.new
obj.my_method(2) # => 6

require_relative '../test/assertions'
assert_equals 6, obj.my_method(2)

이렇게 동적으로 함수를 정의 하는 방법을 Dynamic Methods 라고 부른다.

그리고 기존의 def 를 사용하지 않고 Dynamic Methods 를 사용했을때의 장점은 

런타임중에 정의할 함수의 이름을 결정 할 수 있다는 것이다.


이제부터 우리는 Dynamic Dispatch 와 Dynamic Methods 를 사용해서 처음의 중복코드를 효율적으로 수정해보겠다.

Solution1 - Step1. Dynamic Dispatch를 사용하자.

Dynamic Dispatch를 이용해서 내부적으로 같은함수를 호출하게 수정해보자. 

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end
➤ def mouse
➤   component :mouse
➤ end

➤ def cpu
➤   component :cpu
➤ end

➤ def keyboard
➤   component :keyboard
➤ end

➤ def component(name)
➤   info = @data_source.send "get_#{name}_info", @id
➤   price = @data_source.send "get_#{name}_price", @id
➤   result = "#{name.capitalize}: #{info} ($#{price})"
➤   return "* #{result}" if price >= 100
➤   result
➤ end
end

위와 같이 수정했을때 mouse 함수를 호출하면 내부적으로는 component 함수를 호출하게 되고 

component 함수에서는 Dynamic Dispatch를 이용해서 함수 이름을 결정하고 해당함수를 호출한다.


Dynamic Dispatch를 적용하고 기존보다는 많이 개선되었지만 한가지 아쉬운 점이 있다.

바로 def cpu, mouse, keyboard 등의 함수가 동일하게 component 함수를 호출하고 있다는 것이다.

그리고 이렇게 component 함수를 호출하는 코드도 역시나 중복 코드이다. 


Solution1 - Step2. Dynamic Methods 를 이용하자.

Dynamic Methods 를 이용해서 위에서 아쉬웠던 문제점을 수정해보자. 

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end
➤ def self.define_component(name)
➤   define_method(name) do
➤     info = @data_source.send "get_#{name}_info", @id
➤     price = @data_source.send "get_#{name}_price", @id
➤     result = "#{name.capitalize}: #{info} ($#{price})"
➤     return "* #{result}" if price >= 100
➤     result
➤   end
➤ end
➤
➤ define_component :mouse
➤ define_component :cpu
➤ define_component :keyboard
end

이제는 component 라는 함수를 사용하지 않고 define_component 라는 함수를 이용해서 

동적으로 함수를 만들게 수정했다.

그리고 define_component 함수 내부에서는 Dynamic Dispatch를 이용해서 기존과 동일하게 동작하고 있다. 

두번의 수정으로 인해 기존보다 훨씬더 유연한 구조로 코드를 수정했지만 아직도 한가지 아쉬운점이 있다.

바로 DS의 함수가 추가될때마다 Computer의 define_component를 계속해서 추가해줘야 한다는 것이다. 


Solution1 - Step3. DS의 변화에 대응하게 하자.   

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
➤ data_source.methods.grep(/^get_(.*)_info$/) { Computer.define_component $1 }
  end
  def self.define_component(name)
    define_method(name) do
    # ...
    end
  end
end

initialize 시에 DS의 함수들을 조회하고 조건에 일치하는 함수들을 define_component 함수를 사용해서 정의하게 했다.

이제는 DS의 함수가 추가되어도 Computer를 더 수정 할 필요가 없다!!


지금까지 우리는 Dynamic Dispatch 와 Dynamic Methods 를 사용해서 처음의 중복코드를 효율적으로 수정했다!

그렇다면 중복코드를 수정하는 방법은 Dynamic Dispatch 와 Dynamic Methods만 있는걸까? 아니다!

다른방법으로도 중복코드를 효과적으로 수정 할 수 있다!


Ghost Methods

Ruby에서는 실제 함수가 존재하지 않더라도 함수를 호출을 시도할 수 있다.

( 물론 존재하지 않는 함수를 호출했기때문에 예외가 발생한다.)

class Lawyer; end
nick = Lawyer.new
nick.talk_simple
❮ NoMethodError: undefined method `talk_simple' for #

저번 포스팅에서 배웠듯이 Ruby에서 함수를 호출하면 ancestors chain을 확인하게 된다. 

그런데 일치하는 함수가 없을경우에는 BasicObject#method_missing 함수를 호출하고 NoMethodError 를 반환한다!

method_missing 은 BasicObject 의 private_method 이며 vm_eval.c 에서 method_missing을 정의했다.

vm_eval.c
void
Init_vm_eval(void)
{
    rb_define_global_function("eval", rb_f_eval, -1);
    ....
    rb_define_method(rb_cBasicObject, "instance_eval", rb_obj_instance_eval, -1);
    rb_define_method(rb_cBasicObject, "instance_exec", rb_obj_instance_exec, -1);
    rb_define_private_method(rb_cBasicObject, "method_missing", rb_method_missing, -1);
    ....
#if 1
    rb_add_method(rb_cBasicObject, rb_intern("__send__"),
		  VM_METHOD_TYPE_OPTIMIZED, (void *)OPTIMIZED_METHOD_TYPE_SEND, METHOD_VISI_PUBLIC);
    rb_add_method(rb_mKernel, rb_intern("send"),
		  VM_METHOD_TYPE_OPTIMIZED, (void *)OPTIMIZED_METHOD_TYPE_SEND, METHOD_VISI_PUBLIC);
#else
    rb_define_method(rb_cBasicObject, "__send__", rb_f_send, -1);
    rb_define_method(rb_mKernel, "send", rb_f_send, -1);
#endif
    rb_define_method(rb_mKernel, "public_send", rb_f_public_send, -1);

    rb_define_method(rb_cModule, "module_exec", rb_mod_module_exec, -1);
    rb_define_method(rb_cModule, "class_exec", rb_mod_module_exec, -1);
    rb_define_method(rb_cModule, "module_eval", rb_mod_module_eval, -1);
    rb_define_method(rb_cModule, "class_eval", rb_mod_module_eval, -1);
    ....
}


우리는 이 method_missing 함수를 오버라이딩 해서 NoMethodError가 아닌 우리가 원하는 작업을 할 수 있다.

class Lawyer
  def method_missing(method, *args)
    puts "You called: #{method}(#{args.join(', ')})"
    puts "(You also passed it a block)" if block_given?
  end
end

bob = Lawyer.new
bob.talk_simple('a', 'b') do
  # a block
end
#=> You called: talk_simple(a, b)
#=> (You also passed it a block)

이렇게 존재하지 않는 함수를 method_missing 을 이용해서 마치 존재하듯이 사용하는 방식을 Ghost Methods 라고 한다. 

Ghost Methods 를 실제로 적용한 라이브러리(https://github.com/intridea/hashie) 를 한번 확인해보자.

require 'hashie'
icecream = Hashie::Mash.new
icecream.flavor = "strawberry"
icecream.flavor # => "strawberry"

Hashie::Mash 객체는 실제로 flavor 라는 함수를 가지고있지 않다.

하지만 위의 코드를 보면 flavor 라는 함수에 값을 저장하고 읽어올수 있다.

이렇게 존재하지 않는 함수가 마치 존재하는듯이 동작할 수 있던 이유는 아래와 같다!

module Hashie
  class Mash < Hashie::Hash
    def method_missing(method_name, *args, &blk)
      return self.[](method_name, &blk) if key?(method_name)
      match = method_name.to_s.match(/(.*?)([?=!]?)$/)
      case match[2]
      when "="
        self[match[1]] = args.first
      # ...
      else
        default(method_name, *args, &blk)
      end
    end
    # ...
  end
end

Mash는 Hash 객체를 상속받고 있으며 method_missing 함수가 호출되었을때 

자신이 관리하는 key 들중에 해당 method_name이 존재하면 저장되어 있는 값을 가져오고 

함수가 setter 처럼 값을 저장하는 형식이면 관리하는 key에 추가하고 값을 저장한다.

  

Dynamic Proxies

Ghost Methods 를 사용하면서 일부 함수들을 선택적으로 다른 객체의 함수 호출로 전환할 수 있는데 

이를 Dynamic Proxies 라고 한다. Dynamic Proxies 를 실제로 적용한 예를 보면 아래와 같다. 

require "ghee"
gh = Ghee.basic_auth("usr", "pwd") # Your GitHub username and password
all_gists = gh.users("nusco").gists
a_gist = all_gists[20]
a_gist.url # => "https://api.github.com/gists/535077"
a_gist.description # => "Spell: Dynamic Proxy"
a_gist.star

Ghee 는 Github에 쉽게 접근하도록 도와주는 라이브러리이다. (https://github.com/huboard/ghee)

위의 코드를 보면 github 에 접근해서 gists 목록을 가져온다음에 특정 gists의 url과 description를 읽어오고

star 라는 함수를 호출한다. 

Ghost Methods 를 이용해서 실제 존재하지 않는 함수를 마치 존재하는 함수처럼 사용하게 하는일은

Ghee::ResourceProxy 클래스에서 담당하고 있다.

class Ghee
  class ResourceProxy
    # ...
    def method_missing(message, *args, &block)
      subject.send(message, *args, &block)
    end
    def subject
      @subject ||= connection.get(path_prefix){|req| req.params.merge!params }.body
    end
  end
end

그리고 ResourceProxy 클래스를 Proxy 클래스가 상속받고 있다.

class Ghee
  module API
    module Gists
      class Proxy < ::Ghee::ResourceProxy
        def star
          connection.put("#{path_prefix}/star").status == 204
        end
        # ...
      end
   end
end

코드를 확인해보면 알겠지만 실제로 gists 함수를 호출했을때 반환되는 정보들은 Proxy 클래스의 객체들이다.

Proxy 클래스에는 url 이나 description 함수가 없다. 그런데 실제로 url 함수나 description 함수를 호출하면 

실제로 함수가 존재하는 것처럼 동작한다. 

이는 url, description 호출시 존재하지 않는 함수이기 때문에 ResourceProxy#method_missing 함수를 호출하게되고 

method_missing 내부에서 Hashie::Mash와 유사한 subject를 획득한뒤 url 과 description의 값을 가져오게 된다.


위의 예제처럼 Dynamic Proxies 는 method_missing 시 직접처리하지 않고 다른 Object로 함수호출을 전환한다.


그렇다면 Ghost Methods 와 Dynamic Proxies 를 이용해서 처음의 코드를 다시한번 개선해보자.

Solution2 - Step1. Ghost Methods 와 Dynamic Proxies 적용하기

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end
➤ def method_missing(name)
➤   super if !@data_source.respond_to?("get_#{name}_info")
➤   info = @data_source.send("get_#{name}_info", @id)
➤   price = @data_source.send("get_#{name}_price", @id)
➤   result = "#{name.capitalize}: #{info} ($#{price})"
➤   return "* #{result}" if price >= 100
➤   result
➤ end
end

위와같이 변경하면 method_missing 시 data_source에 해당함수를 사용 할 수 있는지 확인(respond_to?)하고 

함수가 존재하면 Dynamic Dispatch를 이용해서 기존과 동일하게 함수가 동작한다. 

my_computer = Computer.new(42, DS.new)
my_computer.cpu # => * Cpu: 2.9 Ghz quad-core ($120)

하지만 추가적으로 한가지는 꼭 해줘야 하는 작업이 있다.


Solution2 - Step2. respond_to_missing? 함수 오버라이딩 

우리는 Step1을 통해서 data_source의 함수들을 사용할 수있게 되었다. 그러면 Computer 를 사용하는 

다른 개발자가 mouse 라는 함수가 있는지 확인하기 위해 respond_to? 를 사용하면 어떻게 될까? 

cmp = Computer.new(0, DS.new)
cmp.respond_to?(:mouse) # => false

우리의 기대와는 다르게 respond_to? 함수를 호출해서 해당 함수를 Computer에서 사용할 수 있는지를 

확인해보면 false를 반환한다!! 

respond_to? 함수를 호출하면 respond_to_missing? 함수를 호출하면서 해당 클래스가 함수를 사용할 수 있는지 

확인하는데 별도의 작업을 하지 않은 Computer 클래스의 respond_to_missing? 함수는 Computer 에 존재하는 함수중 

mouse 가 있는지만을 확인하기때문에 false를 반환하고 있다. 

우리의 기대에 맞게 동작하게 하려면 아래와 같은 코드를 추가해야한다.

class Computer
# ...
➤ def respond_to_missing?(method, include_private = false)
➤   @data_source.respond_to?("get_#{method}_info") || super
➤ end
end

cmp = Computer.new(0, DS.new)
cmp.respond_to?(:mouse) # => true

Solution2 - Step3. Blank Slates 클래스로 만들기

이제 Computer 클래스는 우리의 기대에 맞게 동작한다. 하지만 여기에는 한가지 버그가 숨어있다.

아래의 코드를 실행해보자. 

my_computer = Computer.new(42, DS.new)
my_computer.display # => nil

DS 클래스에는 get_display_info 함수와 get_display_price 함수가 존재하는데 왜 nil을 반환할까!? 

그 이유는 Object 클래스에 이미 display 라는 함수가 존재하기 때문이다.

Object.instance_methods.grep /^d/ # => [:dup, :display, :define_singleton_method]

이미 Object에 display 함수가 있기때문에 method_missing 이 호출되지 않고 그로인해 Ghost Methods 를 사용할 수 없게 되었다.

이러한 버그를 피하기 위해서는 Computer가 Object를 상속받지 않고 Blank Slates 상태로 만들어야 한다. 

Blank Slates는 최소한의 함수들만 상속받는 클래스를 만들기 위한 개념이다. 


Computer를 Blank Slates 로 만들기 위해서 Computer는 Object가 아닌 BasicObject를 상속받아야 한다.

class Computer < BasicObject
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end
  def method_missing(name, *args)
    super if !@data_source.respond_to?("get_#{name}_info")
    info = @data_source.send("get_#{name}_info", @id)
    price = @data_source.send("get_#{name}_price", @id)
    result = "#{name.capitalize}: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end
  def respond_to_missing?(method, include_private = false)
    @data_source.respond_to?("get_#{method}_info") || super
  end
end

이번에 우리는 Ghost Methods 와 Dynamic Proxies 를 사용해서 다시한번 중복코드를 효율적으로 수정했다!


오늘은 중복코드를 효과적으로 개선하기위한 2가지 방법을 배웠다. 

  1. Dynamic Dispatch 와 Dynamic Methods를 이용해서 개선한다.
  2. Ghost Methods 와 Dynamic Proxies 를 이용해서 개선한다.
그렇다면 어떠한 경우에  첫번째 방법 혹은 두번째 방법을 사용해야 할까?
책의 저자는 가능하면 Dynamic Methods 를 사용하고 꼭 필요한 경우에만 Ghost Methods를 사용하라고 한다. 

오늘은 여기까지~
누군가에게 도움이 되었길 바라면서 오늘의 포스팅 끝~


댓글