Ruby Magic 시리즈로 돌아왔습니다! 이번 시간에는 Ruby에서 가장 마법 같은 기능 중 하나를 살펴봅니다. 바로 Array, Hash, Range처럼 열거 가능한(enumerable) 클래스를 다룰 때 사용하는 대부분의 메서드를 제공하는 기능입니다. 이 과정에서 열거 가능한 객체로 무엇을 할 수 있는지, 열거가 내부적으로 어떻게 동작하는지, 그리고 단 하나의 메서드만 구현해서 객체를 열거 가능하게 만드는 방법까지 차근차근 배워보겠습니다.
Enumerable, #each, 그리고 Enumerator
열거(enumeration)란 객체들을 하나씩 순회하는 것을 의미합니다. Ruby에서는 어떤 객체가 아이템들의 집합과 각 요소를 순회하는 메서드를 함께 정의하고 있을 때, 그 객체를 열거 가능한(enumerable) 객체라고 부릅니다.
내장 열거형들은 Enumerable 모듈을 포함(include)함으로써 열거 기능을 갖추게 됩니다. 이 모듈은 #include?, #count, #map, #select, #uniq 등 다양한 메서드를 제공합니다. 배열이나 해시와 관련된 대부분의 메서드는 사실 해당 클래스 자체에 구현된 것이 아니라, 이 모듈을 통해 포함된 것입니다.
참고: 일부 메서드, 예컨대 Array 클래스의 #count나 #take는 Enumerable 모듈의 것을 쓰지 않고 배열에 특화되어 별도로 구현되어 있습니다. 보통은 연산 속도를 높이기 위해서입니다.
Enumerable 모듈은 #each라는 메서드에 의존하며, 이 모듈을 포함하는 모든 클래스는 반드시 #each를 구현해야 합니다. 배열의 #each를 블록과 함께 호출하면, 배열의 각 요소마다 블록이 실행됩니다.
irb> [1,2,3].each { |i| puts "* #{i}" }
* 1
* 2
* 3
=> [1,2,3]반면 배열의 #each를 블록 없이 호출하면 Enumerator 인스턴스가 반환됩니다.
irb> [1,2,3].each
=> #<Enumerator: [1, 2, 3]:each>Enumerator 인스턴스는 객체를 어떻게 순회할지 기술하는 역할을 합니다. Enumerator를 활용하면 객체를 수동으로 순회하거나, 여러 열거 작업을 체인처럼 연결할 수 있습니다.
irb> %w(dog cat mouse).each.with_index { |a, i| puts "#{a} is at position #{i}" }
dog is at position 0
cat is at position 1
mouse is at position 2
=> ["dog", "cat", "mouse"]#with_index 메서드는 Enumerator의 동작 방식을 잘 보여주는 좋은 예입니다. 위 예제에서는 먼저 배열에 #each를 호출해 Enumerator를 얻고, 이어서 #with_index를 호출해 각 요소에 인덱스를 붙임으로써 요소의 위치를 출력할 수 있게 됩니다.
객체를 열거 가능하게 만들기
내부적으로 #max, #map, #take 같은 메서드들은 모두 #each 메서드에 기반해 동작합니다.
def max
max = nil
each do |item|
if !max || item > max
max = item
end
end
max
end실제로 Enumerable의 메서드들은 C로 구현되어 있지만, 위 예제는 #max가 대략 어떤 식으로 동작하는지 보여줍니다. #each로 모든 값을 순회하면서 최댓값을 기억해 두었다가, 마지막에 그 값을 반환하는 방식입니다.
def map(&block)
new_list = []
each do |item|
new_list << block.call(item)
end
new_list
end#map 함수는 각 요소마다 전달받은 블록을 호출하고, 그 결과를 새 리스트에 담았다가 모든 값의 순회가 끝난 후 반환합니다.
Enumerable의 모든 메서드가 어느 정도 #each에 의존하기 때문에, 커스텀 클래스를 열거 가능하게 만드는 첫걸음은 바로 #each 메서드를 구현하는 것입니다.
#each 구현하기
클래스에 #each를 구현하고 Enumerable 모듈을 포함하면, 그 클래스는 자동으로 열거 가능해지며 #min, #take, #inject 같은 메서드를 공짜로 얻게 됩니다.
대부분의 경우 배열 같은 기존 객체를 활용해 그 객체의 #each를 호출하면 되지만, 이번에는 처음부터 직접 작성해야 하는 상황을 살펴보겠습니다. 여기서는 연결 리스트(linked list)에 #each를 구현해 열거 가능하게 만들어 보겠습니다.
연결 리스트: 배열 없는 리스트
연결 리스트는 각 데이터 요소가 다음 요소를 가리키는 형태의 자료구조입니다. 리스트의 각 요소는 head와 tail이라는 두 값을 가집니다. head는 해당 요소의 값을 담고, tail은 나머지 리스트로 이어지는 링크입니다.
[42, [12, [73, nil]]
세 개의 값(42, 12, 73)을 가진 연결 리스트에서 첫 번째 요소의 head는 42이고, tail은 두 번째 요소를 가리킵니다. 두 번째 요소의 head는 12이며, tail은 세 번째 요소를 담고 있습니다. 세 번째 요소의 head는 73이고, tail은 nil로 리스트의 끝을 나타냅니다.
Ruby에서는 @head와 @tail이라는 두 인스턴스 변수를 가진 클래스로 연결 리스트를 만들 수 있습니다.
class LinkedList
def initialize(head, tail = nil)
@head, @tail = head, tail
end
def <<(item)
LinkedList.new(item, self)
end
def inspect
[@head, @tail].inspect
end
end#<< 메서드는 리스트에 새 값을 추가하는 데 사용됩니다. 전달된 값을 head로, 기존 리스트를 tail로 하는 새 리스트를 반환하는 방식으로 동작합니다.
이 예제에서는 리스트 내부를 들여다볼 수 있도록 #inspect 메서드도 추가했습니다.
irb> LinkedList.new(73) << 12 << 42
=> [42, [12, [73, nil]]]이제 연결 리스트가 준비되었으니 #each를 구현해 보겠습니다. #each 함수는 블록을 받아 객체의 각 값마다 실행합니다. 연결 리스트에 구현할 때는 리스트의 재귀적 특성을 활용해, 리스트의 @head에 전달받은 블록을 호출하고, @tail이 존재하면 tail에 대해 #each를 재귀적으로 호출하면 됩니다.
class LinkedList
def initialize(head, tail = nil)
@head, @tail = head, tail
end
def <<(item)
LinkedList.new(item, self)
end
def inspect
[@head, @tail].inspect
end
def each(&block)
block.call(@head)
@tail.each(&block) if @tail
end
end연결 리스트 인스턴스에 #each를 호출하면, 현재 @head로 블록이 실행됩니다. 그런 다음 tail이 nil이 아닌 한, @tail에 담긴 연결 리스트에 대해 each가 다시 호출됩니다.
irb> list = LinkedList.new(73) << 12 << 42
=> [42, [12, [73, nil]]]
irb> list.each { |item| puts item }
42
12
73
=> nil이제 우리의 연결 리스트가 #each에 응답하므로, include Enumerable을 추가해 리스트를 열거 가능하게 만들 수 있습니다.
class LinkedList
include Enumerable
def initialize(head, tail = nil)
@head, @tail = head, tail
end
def <<(item)
LinkedList.new(item, self)
end
def inspect
[@head, @tail].inspect
end
def each(&block)
block.call(@head)
@tail.each(&block) if @tail
end
endirb> list = LinkedList.new(73) << 12 << 42
=> [42, [12, [73, nil]]]
irb> list.count
=> 3
irb> list.max
=> 73
irb> list.map { |item| item * item }
=> [1764, 144, 5329]
irb> list.select(&:even?)
=> [42, 12]Enumerator 인스턴스 반환하기
이제 연결 리스트의 모든 값을 순회할 수 있게 되었지만, 아직 열거 메서드를 체인처럼 연결할 수는 없습니다. 그러려면 #each가 블록 없이 호출되었을 때 Enumerator 인스턴스를 반환하도록 만들어야 합니다.
class LinkedList
include Enumerable
def initialize(head, tail = nil)
@head, @tail = head, tail
end
def <<(item)
LinkedList.new(item, self)
end
def inspect
[@head, @tail].inspect
end
def each(&block)
if block_given?
block.call(@head)
@tail.each(&block) if @tail
else
to_enum(:each)
end
end
end객체를 Enumerator로 감싸려면 #to_enum 메서드를 호출하면 됩니다. 이때 :each를 인자로 넘기는데, 이는 Enumerator가 내부적으로 사용할 메서드가 each라는 뜻입니다.
이제 블록 없이 #each를 호출해도 열거 작업을 자유롭게 체인으로 연결할 수 있습니다.
irb> list = LinkedList.new(73) << 12 << 42
=> [42, [12, [73, nil]]]
irb> list.each
=> #<Enumerator: [42, [12, [73, nil]]]:each>
irb> list.map.with_index.to_h
=> {42=>0, 12=>1, 73=>2}9줄의 코드와 include 한 줄이면 충분합니다
#each를 구현하고 Enumerable 모듈을 포함한 뒤, 자체적으로 Enumerator 객체를 반환하도록 만드는 것만으로 단 9줄의 코드와 include 한 줄로 우리의 연결 리스트를 강력하게 업그레이드했습니다.
이상으로 Ruby의 enumerable에 대한 개요를 마치겠습니다. 이 글에 대한 생각이나 궁금한 점이 있다면 언제든 알려주세요. 저희는 항상 탐구하고 설명할 주제를 찾고 있으니, Ruby에서 마법 같다고 느껴지는 주제가 있다면 @AppSignal로 망설이지 말고 알려주세요!