티스토리 뷰

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

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

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


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


저번 포스팅(Block, Scope - http://idea-sketch.tistory.com/39 )에 이어서 다음편이다. 


오늘은 Callable Object에 대해서 알아볼 예정이다. 


Callable Object

저번 포스팅을 다시한번 생각해보면 block 은 2단계로 동작한다. 

먼저 block을 정의하고 두번째로 yield 를 이용해서 block을 실행한다. 

그런데 이렇게 '먼저 정의하고 나중에 실행' 이라는 2단계로 동작하는 방식은 block 에서만 가능한 것은 아니다. 

이번에 소개할 3가지 다른방법들도 block과 마찬가지로 먼저 정의하고 나중에 실행 이라는 방식을 동일하게 사용한다. 

  1. proc
  2. lambda
  3. method

block 을 포함한 위의 3가지 방식들은 Callable Object 라고 부른다. 


Proc Object

 만약 block을 먼저 정의하고 나~중에 block 을 실행하고 싶을때는 어떻게 해야할까?

이러한 요구사항을 만족시키기 위해서 Ruby는 Proc이라는 클래스를 제공하고 있다. 

Proc은 block을 Object로 변경했다고 생각하면 이해하기 쉽다. Proc객체를 생성하면서

block을 정의할 수 있고 이렇게 정의된 Proc은 Proc#call 을 통해서 실행할 수 있다.  

inc = Proc.new {|x| x + 1 }
# more code...
inc.call(2) # => 3

이러한 방식을 Deferred Evaluation(지연된 평가) 라고 한다. 

Ruby에서는 Proc을 생성하는 다른방법이 있는데 lambda, proc 키워드를 이용하는 방법이다.

dec = lambda {|x| x - 1 }
dec.class # => Proc
dec.call(2) # => 1

dec = proc{|x| x + 1 }
dec.class # => Proc
dec.call(2) # => 3

lambda 키워드와 동일한 방법으로 -> 을 사용하는 방법도 있다.

p = ->(x) { x + 1 }

Operator

다음과 같은 2가지 경우에 & Operator 를 사용해야 한다. 

  1. 정의된 block 을 다른 함수로 전달하고 싶을때
  2. block 을 Proc 객체로 변경하고 싶을때

두가지 상황의 공통점은 block을 객체로 사용하고 싶다는 것이다. 

이러한 요구사항이 있을때는 함수정의시 마지막 파라미터에 & Operator 를 이용하면 block을 Proc 객체로 사용할 수 있다.

def my_method(&the_proc)
  the_proc
end

p = my_method {|name| "Hello, #{name}!" }
p.class # => Proc
p.call("Bill") # => "Hello, Bill!"

그러면 반대로 Proc을 block으로 되돌리는 방법은 무엇일까?

block을 proc으로 변경할때와 마찬가지로 & Operator를 이용한다. 하지만 이번에는 Operator를 사용하는 위치가 조금 다르다.

def my_method(greeting)
  "#{greeting}, #{yield}!"
end

my_proc = proc { "Bill" }
my_method("Hello", &my_proc)

Proc을 block으로 되돌릴때는 함수호출시 마지막 파라미터로 & Operator를 사용하면 된다. 

block <-> proc 을 변경하는법을 알게되었으니 이제 우리는  마음대로 가지고 놀 수 있다.

def math(a, b)
  yield(a, b)
end

# block을 proc으로 변경해서 operation 이라는 이름의 객체로 변경했다.
def do_math(a, b, &operation)
  math(a, b, &operation) # math 호출시 proc을 다시 block으로 변경하기 위해 & Operator를 사용했다.
end
do_math(2, 3) {|x, y| x * y} # => 6

Proc vs Lambda

 우리는 block 을 Proc 객체로 변경하는 방법으로 Proc.new, lambda, & Operator 를 이용하면 된다는것을 배웠다.

Proc.new 와 lambda 는 동일하게 Proc 객체를 생성하지만 실제로 두방법으로 생성된 Proc 객체는 조금 다르게 동작한다. 

Proc#lambda? 를 이용하면 이 객체가 lambda 키워드를 이용해서 생성된 것인지 확인할 수 있다.

proc1 = Proc.new{ |x| x+1 }
proc1.class # => Proc
proc1.lambda? # => false

proc2 = proc{ |x| x+1 }
proc2.class # => Proc
proc2.lambda? # => false

lambda1 = lambda{ |x| x+1 }
lambda1.class # => Proc
lambda1.lambda? # => true

lambda2 = ->(x){ x+1 }
lambda2.class # => Proc
lambda2.lambda? # => true


Proc 과 Lambda 의 return

Proc과 Lambda 의 첫번째 차이점은 return 키워드가 서로 다르게 동작한다는 것이다. 

def double(callable_object)
  callable_object.call * 2
end

l = lambda { return 10 }
double(l) # => 20

lambda 에 return 은 우리가 보통 알고 있는 return 과 동일하게 lambda 로 생성된 Proc 객체를 빠져나오게 된다.

그러면 Proc 에서의 return 은 어떻게 다를까? 아래의 예제를 보자. 

def another_double
  p = Proc.new { return 10 }
  result = p.call
  return result * 2 # 절대 실행되지 못하는 코드!
end

another_double # => 10

Proc 에서의 return 키워드는 Proc 객체를 빠져나오는것과 동시에 Proc을 정의한 scope 에서도 빠져나오게 된다

그래서 another_double 은 항상 10을 반환하게 된다. 

Proc을 return을 잘못쓰면 다음과 같은 버그를 발생시킬수도 있다.

def double(callable_object)
 callable_object.call * 2
end

p = Proc.new { return 10 }
double(p) # => LocalJumpError

Proc 에서 return을 호출시 Proc 객체를 빠져나오면서 Proc을 정의한 top-level scope를 탈출하려고 시도한다.

그리고 LocalJumpError 가 발생하게 된다.


Proc 과 Lambda 의 파라미터

 proc 과 lambda 의 두번째 차이점은 파라미터 확인 여부이다.

proc 은 정의한 파라미터와 실제 호출시 파라미터가 다를경우 무시하지만 lambda는 정확히 일치해야만 한다.

p = Proc.new {|a, b| [a, b]}
p.call(1, 2, 3) # => [1, 2]
p.call(1) # => [1, nil]

p = lambda {|a, b| [a, b]}
p.call(1, 2, 3) # => ArgumentError
p.call(1) # => ArgumentError

그렇다면 Proc 과 Lambda 중에 어떤걸 사용하는게 좋을까? 상황에 따라 다르지만 일반적으로는

lambda가 함수와 유사하기 때문에 일반적으로는 lambda 를 사용하고 특별한 경우에만 Proc을 사용하는것을 추천한다. 


Method Object

 Callable Object의 마지막은 method 이다.

Ruby 에서는 클래스의 함수역시 객체로 뽑아낼 수 있다.

class MyClass
  def initialize(value)
    @x = value
  end
  def my_method
    @x
  end
end

object = MyClass.new(1)
m = object.method :my_method
m.class # Method
m.call # => 1

 Kernel#method 를 사용하면 클래스 객체로부터 함수를 함수객체로 뽑아낼수 있고 Method#call 을 이용해서 뽑아낸 함수를 실행할 수 있다. 

Method#to_proc 함수를 사용하면 method 역시 proc으로 변경할 수 있는데 lambda 와 methods 사이에는 중요한 차이점에 존재한다.  lambda 는 정의되는 순간의 scope를 기반으로 binding 하는 클로저(closure) 이지만 method 의 scope는 method를 소유한 object 에 있다. 

method_to_proc = m.to_proc
method_to_proc.class # => Proc
method_to_proc.lambda? # => true


Unbound Methods

Method#unbind 를 이용하면 정의되어 있는 Module 이나 Class로 부터 method를 뽑아내서 UnboundMethod를 획득 할 수 있고 Module#instance_method 를 이용해서도 UnboundMethod를 획득 할 수있다.


module MyModule
  def my_method
    42
  end
end

unbound = MyModule.instance_method(:my_method)
unbound.class # => UnboundMethod

뽑아낸 UnboundMethod 는 UnboundMethod#bind 를 이용하거나 Module#define_method 를 이용해서 

동일 클래스나 혹은 subclass 에 binding 할 수 있다.  

String.class_eval do
  define_method :another_method, unbound
end

"abc".another_method # => 42

unbound method를 어떤 상황에 사용해 볼 수 있을까?


다음과 같은 상황에 쓸 수 있다.

 Active Support GEM 의 'autoloading' 시스템은 Loadable 이라는 Module을 통해서 Kernel#load 을 대체하는 Loadable#load 기능을 제공하고 있다. Loadable#load 는 Kernel#load 보다 하위에 있기 때문에 load 를 호출할경우 Loadable#load 가 먼저 호출된다. 

 그런데 만약 우리가 이미 include 되어있는 Loadable 을 안쓰고 Kernel#load 를 사용하고 싶다면 어떻게 해야할까?

안타깝게도 Ruby는 uninclude 를 제공하고 있지 않기 때문에 ancestors chain 에서 제거할 수는 없다. 

unbound method 를 사용하면 이러한 문제가 있을때 간단하게 해결 할 수 있다.

module Loadable
  def self.exclude_from(base)
    base.class_eval { define_method(:load, Kernel.instance_method(:load)) }
  end
...
end

 만약에 MyClass가 Loadable을 include 하고 있는 상태에서 Loadable#load 를 제거하고 싶다면 Loadable.exclude_from(MyClass) 를 호출하면 된다. 이렇게 할 경우 MyClass 의 load 함수를 새로정의 하는데 MyClass#load 는 사실상 Kernel#load 를 unbound 한다음 MyClass#load를 호출하게 됨으로써 Kernel#load 를 호출하게 된다.


Callable Object 에 대해서 알아본 내용은 다음과 같다. 

  1. Blocks : 정의되는 scope 에서 변수들을 binding 하는 클로저(closure)다
  2. Procs : Proc 클래스의 오브젝트이며 Block 과 동일하게 클로저(closure)이다.
  3. Lambda : Proc 클래스의 또 다른 오브젝트이고 Proc과는 조금 다른다. Proc과 Block 과 같은 클로저(closure)이다.
  4. Method : 객체에 bound 되어 있다. 객체의 scope 에서 binding 하며 다른 객체나 클래스에 unbound 하거나 rebound 할 수 있다.

배운내용으로 DSL(Domain-Specific Language) 만들기

이번포스팅에서 배운 내용들을 바탕으로 DSL(Domain-Specific Language)을 만들어 볼 예정이다. 

다음과 같은 기능의 프로그램을 만들어야 하는 상황이라고 생각해보자.

event 라는 함수를통해 block을 정의하면 해당 event가 block 을 실행시키는 DSL 를 정의한다고 하자. 

그렇다면 아래와같이 함수를 정의하고 사용할 것이다. 

def event(description)
  puts "ALERT: #{description}" if yield
end

event "we're earning wads of money" do
  recent_orders = ... # (read from database)
  recent_orders > 1000
end

이번에는 조금더 여러개의 event 를 정의하면서 각 event 전에 실행되어야 하는 setup을 정의해야 한다고 생각해보자.

setup do
  puts "Setting up sky"
  @sky_height = 100
end
setup do
  puts "Setting up mountains"
  @mountains_height = 200
end
event "the sky is falling" do
  @sky_height < 300
end
event "it's getting closer" do
  @sky_height < @mountains_height
end
event "whoops... too late" do
  @sky_height < 0
end

그러면 setup 과 event 는 다음과 같은 순서로 실행되기를 기대한다. 

Setting up sky
Setting up mountains
ALERT: the sky is falling
Setting up sky
Setting up mountains
ALERT: it's getting closer
Setting up sky
Setting up mountains

이러한 순서대로 setup 과 event가 실행되려면 어떻게 해야할까?

정답은 아래와 같다.

def setup(&block)
  @setups << block
end
def event(description, &block)
  @events << {:description => description, :condition => block}
end
@setups = []
@events = []

@events.each do |event|
  @setups.each do |setup|
    setup.call
  end
  puts "ALERT: #{event[:description]}" if event[:condition].call
end

그리고 여기에 전역변수인 setups, events 를 제거하고 Shared Scope 와 clean room 을 적용해보자.

lambda {
  setups = []
  events = []
  Kernel.send :define_method, :setup do |&block|
    setups << block
  end
  Kernel.send :define_method, :event do |description, &block|
    events << {:description => description, :condition => block}
  end
  Kernel.send :define_method, :each_setup do |&block|
    setups.each do |setup|
      block.call setup
    end
  end
  Kernel.send :define_method, :each_event do |&block|
    events.each do |event|
      block.call event
    end
  end
}.call

each_event do |event|
  env = Object.new
  each_setup do |setup|
    env.instance_eval &setup
  end
  puts "ALERT: #{event[:description]}" if env.instance_eval &(event[:condition])
end

이렇게 하면 우리가원하는 DSL을 구현할 수 있다.

이러한 DSL의 동작은 우리가 이미 사용하고 있는 Rspec(http://rspec.info/) 과 매우유사하다!!

(확인은 안해봤지만 실제 rspec 도 이렇게 동작하지 않을까 싶다...)


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

댓글