Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 열거(Enumeration)의 내부 메커니즘 완벽 이해하기

Ruby Magic에 다시 오신 것을 환영합니다! 1년 전 우리는 배열, 범위(Range), 해시 같은 열거 가능한 객체를 다룰 때 사용하는 메서드를 제공하는 Ruby의 Enumerable 모듈에 대해 배웠습니다.

당시에는 LinkedList 클래스를 만들어 #each 메서드를 구현함으로써 객체를 열거 가능하게 만드는 방법을 살펴보았습니다. Enumerable 모듈을 포함(include)시키면 직접 구현하지 않고도 연결 리스트에서 #count, #map, #select 같은 메서드를 호출할 수 있었습니다.

열거자(Enumerable)를 사용하는 방법은 배웠지만, 그것들은 대체로 어떻게 작동할까요? Ruby에서 열거자의 마법 중 일부는 내부 구현에서 비롯됩니다. 핵심은 단 하나의 #each 메서드에 기반한다는 점이며, 나아가 열거자 체이닝까지 가능하게 해줍니다.

오늘은 Enumerable 클래스의 메서드가 어떻게 구현되어 있는지, 그리고 Enumerator 객체가 어떻게 열거 메서드의 체이닝을 가능하게 하는지 알아보겠습니다.

익숙하시겠지만, 이번에도 Enumerable 모듈과 Enumerator 클래스를 직접 구현해 보며 깊이 들어가 보겠습니다. 과잉 엔지니어링 헬멧을 쓰고 함께 출발해 봅시다!

연결 리스트(Linked List)

본격적으로 시작하기 전에, 이전에 작성했던 연결 리스트 클래스의 새 버전부터 살펴보겠습니다.

class LinkedList
  def initialize(head = nil, *rest)
    @head = head
 
    if rest.first.is_a?(LinkedList)
      @tail = rest.first
    elsif rest.any?
      @tail = LinkedList.new(*rest)
    end
  end
 
  def <<(head)
    @head ? LinkedList.new(head, self) : LinkedList.new(head)
  end
 
  def inspect
    [@head, @tail].compact
  end
 
  def each(&block)
    yield @head if @head
    @tail.each(&block) if @tail
  end
end

이전 버전과 달리 이 구현은 빈 리스트 생성과 두 개 이상의 항목을 가진 리스트 생성을 모두 허용합니다. 또한 다른 리스트를 초기화할 때 꼬리(tail) 부분에 연결 리스트를 전달할 수도 있습니다.

irb> LinkedList.new
=> []
irb> LinkedList.new(1)
=> [1]
irb> LinkedList.new(1, 2)
=> [1,[2]]
irb> LinkedList.new(1, 2, 3)
=> [1,[2,[3]]]
irb> LinkedList.new(1, LinkedList.new(2, 3))
=> [1,[2,[3]]]
irb> LinkedList.new(1, 2, LinkedList.new(3))
=> [1,[2,[3]]]

이전에는 LinkedList 클래스에 Enumerable 모듈을 포함시켰습니다. Enumerable의 메서드로 객체를 매핑하면 결과는 배열에 저장됩니다. 이번에는 자체 버전을 구현하여 메서드가 새로운 연결 리스트를 반환하도록 만들어 보겠습니다.

Enumerable 메서드

Ruby의 Enumerable 모듈에는 #map, #count, #select 같은 열거 메서드가 포함되어 있습니다. #each 메서드를 구현하고 클래스에 Enumerable 모듈을 포함시키면 연결 리스트에서 해당 메서드들을 바로 사용할 수 있습니다.

여기서는 대신 DIYEnumerable을 구현하여 Ruby의 버전 대신 이것을 가져오겠습니다. 실제 개발에서는 흔히 하지 않는 일이지만, 열거가 내부적으로 어떻게 작동하는지 명확하게 이해하는 데 큰 도움이 될 것입니다.

#count부터 시작해 봅시다. Enumerable 클래스의 각 메서드는 결과를 계산하기 위해 객체를 순회할 때 LinkedList 클래스에서 구현한 #each 메서드를 사용합니다.

module DIYEnumerable
  def count
    result = 0
    each { |element| result += 1 }
    result
  end
end

이 예제에서는 연결 리스트에 포함시킬 새로운 DIYEnumerable 모듈에 #count 메서드를 구현했습니다. 카운터를 0으로 초기화한 뒤 #each 메서드를 호출하여 매 반복마다 카운터에 1을 더합니다. 모든 요소를 순회한 후 메서드는 최종 카운터 값을 반환합니다.

module DIYEnumerable
  # ...
 
  def map
    result = LinkedList.new
    each { |element| result = result << yield(element) }
    result
  end
end

#map 메서드도 유사한 방식으로 구현됩니다. 카운터 대신 빈 리스트로 시작하는 누산기(accumulator)를 사용합니다. 리스트의 모든 요소를 순회하면서 각 요소에 전달된 블록을 실행(yield)하고, 각 실행 결과를 누산기 리스트에 추가합니다.

입력 리스트의 모든 요소를 순회한 후 메서드는 누산기를 반환합니다.

class LinkedList
  include DIYEnumerable
 
  #...
end

LinkedListDIYEnumerable을 포함시킨 후, 새로 추가한 #count#map 메서드를 테스트해 볼 수 있습니다.

irb> list = LinkedList.new(73, 12, 42)
=> [73, [12, [42]]]
irb> list.count
=> 3
irb> list.map { |element| element * 10 }
=> [420, [120, [730]]]

두 메서드 모두 잘 작동합니다! #count 메서드는 리스트의 항목 수를 정확히 세고, #map 메서드는 각 항목에 대해 블록을 실행한 후 업데이트된 리스트를 반환합니다.

역순 리스트 다루기

그런데 #map 메서드가 리스트를 뒤집은 것처럼 보입니다. 이는 당연한 결과인데, 연결 리스트 클래스의 #<< 메서드는 항목을 리스트 끝에 추가하는 것이 아니라 앞에 붙이기 때문입니다. 이는 연결 리스트의 재귀적 특성에서 비롯된 특징입니다.

리스트의 순서를 유지해야 하는 상황이라면 매핑 시 리스트를 뒤집을 방법이 필요합니다. Ruby는 객체를 역순으로 순회하는 Enumerable#reverse_each를 제공하므로 훌륭한 해결책처럼 들립니다. 하지만 안타깝게도 우리 리스트는 중첩되어 있어 그런 접근 방식을 바로 쓸 수 없습니다. 리스트를 완전히 순회하기 전까지는 리스트의 길이를 알 수 없기 때문입니다.

따라서 리스트를 역순으로 순회하며 블록을 실행하는 대신, 두 단계로 처리하는 #reverse_each 버전을 추가하겠습니다. 먼저 새 리스트를 만들면서 기존 리스트를 순회하여 역순 리스트를 생성하고, 그다음 역순 리스트에 블록을 실행합니다.

module DIYEnumerable
  # ...
 
  def reverse_each(&block)
    list = LinkedList.new
    each { |element| list = list << element }
    list.each(&block)
  end
 
  def map
    result = LinkedList.new
    reverse_each { |element| result = result << yield(element) }
    result
  end
end

이제 #map 메서드에서 #reverse_each를 사용하여 올바른 순서로 결과가 반환되도록 하겠습니다.

irb> list = LinkedList.new(73, 12, 42)
=> [73, [12, [42]]]
irb> list.map { |element| element * 10 }
=> [730, [120, [420]]]

잘 작동합니다! 이제 연결 리스트에서 #map 메서드를 호출하면 원본과 같은 순서의 새 리스트를 얻을 수 있습니다.

Enumerator를 활용한 열거 체이닝

연결 리스트 클래스에 구현한 #each 메서드와 포함된 DIYEnumerable 덕분에 이제 양방향으로 순회하고 연결 리스트를 매핑할 수 있습니다.

irb> list.each { |x| p x }
73
12
42
irb> list.reverse_each { |x| p x }
42
12
73
irb> list.reverse_each.map { |x| x * 10 }
=> [730, [120, [420]]]
=> [420, [120, [730]]]

하지만 리스트를 역순으로 매핑해야 한다면 어떻게 될까요? 현재는 매핑 전에 리스트를 뒤집으므로 항상 원본 리스트와 같은 순서로 반환됩니다. 이미 #reverse_each#map을 모두 구현했으므로, 이를 체이닝하여 역방향 매핑을 할 수 있어야 합니다. 다행히 Ruby의 Enumerator 클래스가 이를 도와줍니다.

지난번에는 LinkedList#each 메서드가 블록 없이 호출될 경우 Kernel#to_enum을 호출하도록 했습니다. 이를 통해 Enumerator 객체를 반환하여 열거 메서드를 체이닝할 수 있었습니다. Enumerator 클래스가 어떻게 작동하는지 알아보기 위해 자체 버전을 구현해 보겠습니다.

class DIYEnumerator
  include DIYEnumerable
 
  def initialize(object, method)
    @object = object
    @method = method
  end
 
  def each(&block)
    @object.send(@method, &block)
  end
end

Ruby의 Enumerator와 마찬가지로 우리의 열거자 클래스는 객체의 메서드를 감싸는 래퍼(wrapper)입니다. 감싸진 객체로 위임함으로써 열거 메서드를 체이닝할 수 있습니다.

이것이 작동하는 이유는 DIYEnumerator 인스턴스 자체가 열거 가능하기 때문입니다. 감싸진 객체를 호출하는 방식으로 #each를 구현하고, DIYEnumerable 모듈을 포함시켜 모든 열거 메서드를 호출할 수 있습니다.

LinkedList#each 메서드에 블록이 전달되지 않으면 DIYEnumerator 클래스의 인스턴스를 반환하도록 수정하겠습니다.

class LinkedList
  # ...
 
  def each(&block)
    if block_given?
      yield @head
      @tail.each(&block) if @tail
    else
      DIYEnumerator.new(self, :each)
    end
  end
end

자체 열거자를 사용하면 이제 #reverse_each 메서드 호출에 빈 블록을 전달하지 않고도 열거를 체이닝하여 원래 순서의 결과를 얻을 수 있습니다.

irb> list = LinkedList.new(73, 12, 42)
=> [73, [12, [42]]]
irb> list.map { |element| element * 10 }
=> [420, [120, [730]]]

즉시(Eager) 열거와 지연(Lazy) 열거

지금까지 Enumerable 모듈과 Enumerator 클래스의 구현을 살펴보았습니다. 몇 가지 열거 메서드가 어떻게 작동하는지, 그리고 열거자가 열거 가능한 객체를 감싸서 체이닝을 돕는 방법을 배웠습니다.

하지만 우리의 접근 방식에는 몇 가지 문제점이 있습니다. 열거는 본질적으로 즉시(eager) 방식으로 동작하기 때문에, 열거 메서드가 호출되는 즉시 리스트를 순회합니다. 대부분의 경우 문제가 되지 않지만, 리스트를 역순으로 매핑하면 리스트가 두 번 뒤집히는데 이는 불필요한 작업입니다.

순회 횟수를 줄이려면 Enumerator::Lazy를 활용하여 순회를 최후의 순간까지 지연시키고, 중복된 리스트 뒤집기를 상쇄시킬 수 있습니다.

다만 그 이야기는 다음 에피소드로 미뤄야겠습니다. 이 내용과 Ruby의 마법 같은 내부 동작에 대한 더 많은 탐험을 놓치고 싶지 않다면, Ruby Magic 이메일 뉴스레터를 구독하세요. 새 글이 발행되는 즉시 받아볼 수 있습니다.