본문 바로가기
카테고리 없음

Enumerable 파헤치기4

by 경자꿈사 2024. 9. 23.

오늘은 'Enumerable 파헤치기' 시리즈의 마지막 글로 MyEnumerable 모듈에 min, max, sum 메서드를 만들어 보자.

min 메서드와 max 메서드는 인수 없이 호출하면 각각 가장 작은 값을 갖는 요소 하나와 가장 큰 값을 갖는 요소 하나를 돌려주고, 인수로 정수를 주면 해당하는 수만큼의 요소를 새 배열에 담아 돌려준다.
그리고 호출 시 요소들의 크기를 비교하는 블록을 전달하면 해당 블록의 실행 결괏값을 기준으로 최솟값과 최댓값을 구한다.
sum 메서드는 모든 요소의 합을 구해주는데, 요소가 참조하는 객체에 정의된 + 연산자 메서드를 사용하여 요소끼리 더해지게 된다.
즉, 요소가 참조하는 객체가 숫자라면 수학적인 덧셈을 통해 구한 합이 반환되고 만약 요소가 참조하는 객체가 문자열이라면 문자열들을 이어붙여 만든 새 문자열이 최종 반환값이 된다.
아래 그림을 보면 min, max, sum 메서드가 어떻게 동작하는지 알 수 있다.

min, max, sum 메서드를 포함해서 지금까지 작성한 MyEnumerable 모듈의 전체 코드를 아래에 적어놓았다.
min 메서드와 max 메서드는 코드의 형태가 많이 비슷한데 둘 다 sort 메서드를 사용하여 정렬을 한 후 인수 값에 따라 결괏값을 돌려주는 구조이기 때문이다.
다만 min 메서드는 sort 메서드와 마찬가지로 오름차순 정렬을 기본으로 하기 때문에 블록을 통해 정렬 기준을 변경하더라도 sort 메서드에 해당 블록을 그대로 전달하여 정렬하면 되지만, max 메서드는 내림차순으로 정렬해야 하므로 max 메서드 호출 시 블록을 전달하지 않더라도 내림차순으로 정렬될 수 있도록 블록을 sort 메서드에 전달해야 한다.
그래서 max 메서드 안에서는 lambda를 사용하여 내림차순 정렬 코드를 포함한 블록으로 Proc 객체를 만들어 desc_block 변수에 할당해 놓았다가 max 메서드가 블록 없이 호출 될 경우 이것을 sort 메서드에 블록으로 전달한다.
또한 max 메서드 호출 시 블록이 전달된다면 정렬이 반대로 되도록 해당 블록의 두 파라미터에 값을 맞바꿔 전달하도록 하는 처리도 필요하다.

물론 배열의 reverse 메서드를 이용하면 오름차순(또는 내림차순)으로 정렬된 배열의 순서를 쉽게 반대로 변경할 수 있지만, 가능하면 MyEnumerable에 정의해 놓은 메서드를 사용해서 구현해 보려고 했다.
sum 메서드는 모든 요소들을 하나의 값으로 합치는 것이므로 reduce 메서드를 사용하기에 적합한 경우라고 볼 수 있다.
MyEnumerable 모듈에서 reduce 메서드는 인수로 초깃값을 주지 않았을 때 첫 번째 요소를 초깃값으로 사용하기 때문에, sum 메서드 호출 시 인수 없이 블록을 전달한다면 첫 번째 요소에 대해서도 블록을 실행해야 한다.
그래서 첫 번째 블록 실행인지를 판단하기 위해 is_first라는 변수를 하나 사용하였다.

module MyEnumerable
  def each_with_index
    i = 0
    each do |e|
      yield(e, i)
      i += 1
    end
  end

  def map
    arr = []
    each do |e|
      arr << yield(e)
    end
    arr
  end

  def select
    result = []
    each do |e|
      result << e if yield(e)
    end
    result
  end

  def reduce(accu_val = nil)
    each_with_index do |e, i|
      if i == 0 && accu_val.nil?
        accu_val = e
      else
        accu_val = yield(accu_val, e)
      end
    end
    accu_val
  end

  def sort
    result = []
    reduce(result) do |a, b|
      i = 0
      while i < result.size
        if block_given?
          break if yield(b, result[i]) <= 0
        else
          break if b <= result[i]
        end
        i += 1
      end
      result.insert(i, b)
    end
    result
  end

  def sort_by
    result = []
    reduce(result) do |a, b|
      i = 0
      while i < result.size
        break if yield(b) <= yield(result[i])
        i += 1
      end
      result.insert(i, b)
    end
    result
  end

  def take(num)
    raise ArgumentError.new("attempt to take negative size") if num < 0
    result = []
    each_with_index do |e, i|
      break if i >= num
      result << e
    end
    result
  end

  def take_while
    result = []
    each do |e|
      break if !yield(e)
      result << e
    end
    result
  end

  def drop(num)
    raise ArgumentError.new("attempt to drop negative size") if num < 0
    result = []
    each_with_index do |e, i|
      result << e if i >= num
    end
    result
  end

  def drop_while
    result = []
    found = false
    each do |e|
      next if !found && yield(e)

      found = true
      result << e
    end
    result
  end

  def min(num = nil, &block)
    sorted_arr = sort(&block)

    if num.nil?
      sorted_arr[0]
    else
      sorted_arr[0, num]
    end
  end

  def max(num = nil, &block)
    desc_block = lambda { |a, b| b <=> a }
    desc_block = lambda { |a, b| block.call(b, a) } if block
    sorted_arr = sort(&desc_block)

    if num.nil?
      sorted_arr[0]
    else
      sorted_arr[0, num]
    end
  end

  def sum(init = nil)
    is_first = true
    reduce(init) do |a, b|
      if block_given?
        a = yield(a) if is_first && init.nil?
        b = yield(b)
      end
      is_first = false
      a + b
    end
  end
end

우리가 작성한 min 메서드와 max 메서드의 테스트를 위해 D:/blog/ruby/enumerable 폴더에 아래 코드를  test_min_max.rb 파일에 작성하여 저장한 후, 해당 폴더 위치에서 cmd 창을 열어 test_min_max.rb 파일을 실행해 보자.

require './my_enumerable'

class MyArray
  include MyEnumerable
  
  def each
    yield("b")
    yield("AAA")
    yield("a")
    yield("BB")
  end
end

my_arr = MyArray.new
p my_arr.sort
p my_arr.sort { |a, b| a.length <=> b.length }
p my_arr.min
p my_arr.min(2)
p my_arr.min { |a, b| a.length <=> b.length }
p my_arr.min(2) { |a, b| a.length <=> b.length }

p my_arr.max
p my_arr.max(2)
p my_arr.max { |a, b| a.length <=> b.length }
p my_arr.max(2) { |a, b| a.length <=> b.length }

이번에는 sum 메서드의 테스트를 위해 동일한 폴더에 아래 코드를 test_sum.rb 파일에 작성하여 저장한 후 실행해 보자.

require './my_enumerable'

class MyArray
  include MyEnumerable
  
  def each
    yield(1)
    yield(2)
    yield(3)
    yield(4)
    yield(5)
  end
end

my_arr = MyArray.new
p my_arr.sum                    # 15
p my_arr.sum(10)                # 25
p my_arr.sum { |e| e * e }      # 55
p my_arr.sum(10) { |e| e * e }  # 65

마지막으로 우리가 만든 sum 메서드로 문자열 요소들을 합치는 테스트를 해보자.

아래 코드를 동일한 폴더에 test_sum_string.rb 으로 저장하고 실행하자.

require './my_enumerable'

class MyArray
  include MyEnumerable
  
  def each
    yield("I")
    yield("love")
    yield("you")
  end
end

my_arr = MyArray.new
p my_arr.sum

우리가 직접 만든 min, max, sum 메서드 모두 앞서 배열에 대해 테스트했을 때와 동일하게 잘 동작하는 걸 볼 수 있다.

그런데 문자열이 담긴 배열에 대해 sum 메서드를 테스트했을 때는 인수로 빈 문자열("")을 줘야 했지만(인수 없이 호출하면 예외가 발생하는데 직접 확인해 보길 바란다.), test_sum_string.rb 코드를 보면 인수 없이 sum 메서드를 호출해도 문제 없이 잘 동작한다.
이것은 루비 Enumerable 모듈의 sum 메서드 구현 방식이 우리가 만든 MyEnumerable 모듈의 sum 메서드 구현 방식과 다르기 때문이다.
우리가 작성한 sum 메서드의 코드는 단지 reduce 메서드를 호출하도록 한 것뿐이라서 reduce 메서드의 동작 방식을 그대로 따르게 되는데, 우리가 만든 reduce 메서드의 코드를 보면 인수가 없을 때 첫 번째 요소를 초깃값으로 사용하도록 되어 있다. 그래서 sum 메서드에 인수를 건네지 않아도 예외가 발생하지 않는 것이다.

Enumerable 모듈의 메서드를 만들면서 인수를 주거나 주지 않아도 메서드를 호출할 수 있게 만들 필요가 있었는데, 프로그램을 간단히 하기 위해서 파라미터에 기본값으로 nil을 설정하고 메서드 호출 시 해당 파라미터의 값이 nil이라면 인수 없이 호출된 경우라고 가정하였다.
그러나 메서드에 따라 nil이 인수로서 의미가 있는 값일 수도 있고 실제 nil을 인수로 해서 메서드를 호출할 수도 있기 때문에, 옵셔널 파라미터를 지원하기 위해서는 가변 인수를 사용하는 것이 더 정확하게 구현할 수 있는 방법이다.
아래 그림을 보면 foo 메서드에 val이라는 파라미터가 하나 있는데 앞에 '*'를 붙여 놓았다. 이렇게 하면 foo 메서드를 호출할 때 인수를 주지 않아도 되고 원하는 만큼 여러 개의 인수를 전달해도 된다.
foo 메서드가 호출되면 val은 인수들을 담은 배열을 참조하게 되므로, 해당 배열이 비어 있다면 인수 없이 메서드가 호출되었다고 보면 된다.

 

끝으로, Enumerable 모듈을 인클루드하는 클래스(Array나 Hash 클래스 등)에서 Enumerable 모듈이 정의한 메서드를 그대로 사용하지 않고 성능 향상 등의 목적을 위해 재정의해서 사용하는 메서드들이 있다.
아래 그림을 보면 Enumerable 모듈이 정의한 메서드 중에서 Array 클래스가 재정의한 메서드 목록을 볼 수 있다.

 

아래의 코드를 D:/blog/ruby/enumerable 폴더 아래 array_override.rb 파일에 저장한 후 실행해 보면 Enumerable 모듈에서 정의한 메서드를 그대로 사용하는지 아니면 재정의해 사용하는지를 확실히 알 수 있다.

class Array
  alias_method :each_org, :each

  def each(*args, &blk)
    puts "each is called"
    each_org(*args, &blk)
  end
end

arr = [1, 2, 3, 4, 5]
puts "map".center(30, "-")
p arr.map { |e| e * e }
puts "select".center(30, "-")
p arr.select { |e| e.even? }
puts "find".center(30, "-")
p arr.find { |e| e.even? }

 

배열에 대해 find 메서드를 호출하면 Enumerable 모듈에서 each 메서드를 사용하여 구현한 find 메서드를 호출하기 때문에 'each is called' 메시지가 출력되지만, 
map과 select 메서드는 해당 메시지가 출력되지 않기 때문에 Array 클래스에서 두 메서드를 (each 메서드를 사용하지 않는 방식으로) 재정의한 것이 분명해졌다. 

이것으로 루비에서 Enumerable 모듈이 어떻게 each 메서드 하나로 다른 많은 메서드들을 구현해서 제공하는지를 알아보았다.

 

See you again~~