nacyot profile image

루비의 꽃, 열거자 Enumerable 모듈

프로그래밍 2014년 04월 19일 발행

프로그래밍을 배우면 피해갈 수 없는 부분 중 하나가 바로 제어 추상화입니다. 그 중에서도 반복문은 특히 많이 사용되는데, 재미있는 건 루비에서는 다른 언어에서 많이 사용되는 while이나 for 같은 문법을 잘 사용하지 않는다는 점입니다. 이러한 변수 재대입에 의존한 반복문들을 사용하기보다는 컬렉션의 요소 하나하나를 블록에 넘겨 평가하는 each와 같은 열거자(Enumerable) 메서드가 주로 사용됩니다. 이러한 컬렉션 확장 메서들은 처음 사용할 때는 낯설게 느껴질 지도 모르지만, 사실은 컬렉션 없는 반복문이야 말로 특수한 경우이므로 루비의 접근이 합리적이라는 걸 금방 깨닫게됩니다. 나아가 Enumerable은 단순히 each 메서드만 제공하는 게 아닙니다. 다양한 열거자 메서드를 통해 루비에서 컬렉션을 좀 더 자유자재로 다룰 수 있습니다. 이 글에서는 Enumerable 모듈에 포함된 다양한 열거자 메서드들을 소개합니다.

개요

앞서 이야기했듯이 루비에서는 아래 스타일의 반복문을 일부 지원하지만 별로 사용하지 않습니다

83번째 입력:
i = 0
while i < 5 do
  puts i
  i += 1
end
0
1
2
3
4

대신에 루비에서는 컬렉션 객체에서 사용 가능한 Enumerable 모듈을 지원하고 있습니다. 실제로 루비 프로그래밍을 하다보면 기본적인 반복문은 이른바 열거자라고 불리는 each만으로도 별 부족함 없이 프로그래밍할 수 있습니다. 이를 통해서 블럭을 통해 반복문과 비슷한 것들을 할 수 있습니다. each는 다음과 같이 사용합니다

84번째 입력:
[0, 1, 2, 3, 4].each{|i| puts i}
0
1
2
3
4
84번째 평가:
[0, 1, 2, 3, 4]

이러한 반복문에서는 for문이나 while문 같은 반복 자체를 제어하기 위한 별도의 접근을 사용할 수 없다는 단점이 있습니다만, each를 사용하면 컬렉션에 대해서 각각의 요소를 처리하는 로직을 정의하는데 집중할 수 있으며 기존 반복문 조건에서 사용하는 임시 변수의 부작용을 최소화해서 의도치않은 작동을 사전이 미리 차단할 수 있게 됩니다.

단, 루비는 함수형 언어가 아니므로 블럭 내에서 블럭 밖의 변수를 조작하는 부작용에 대해서는 의도치 않은 결과를 초래할 수도 있습니다

arr = [0, 1, 2, 3]
arr.map{|i| arr << 5; puts i}

이러한 반복문을 사용하지 마세요. 무한히 5가 출력됩니다. Fiber를 사용해 이를 확인해보도록 하겠습니다

85번째 입력:
fiber = Fiber.new do
  arr = [0, 1, 2, 3]
  arr.map{|i| arr << 5; Fiber.yield i}
end
85번째 평가:
#<Fiber:0x007effc382a040>
86번째 입력:
# 여기까지는 정상적인 처리와 같습니다만...
4.times do
  puts fiber.resume
end
0
1
2
3
86번째 평가:
4
87번째 입력:
# 여기부터는 5가 출력됩니다.
puts fiber.resume
5
88번째 입력:
puts fiber.resume
5

이와 같이 5가 계속 반복되는 것을 알 수 있습니다. 계속 resume 메시지를 보내면 계속 5가 출력됩니다. 이러한 부작용(side-effect)에 대해서는 명확히 알고 있을 필요가 있습니다

루비의 Enumerable은 이러한 미묘한 단점을 내포하고 있기는 합니다만, 그럼에도 불구하고 강력합니다. 이렇듯 명시적으로 외부 변수에 부작용을 일으키지만 않는다면, 반복 과정에서 블럭을 통해 임시 변수나 내부 변수에 대해서는 부작용을 걱정할 필요가 없으며, 메서드 체인을 통해 유사한 컬렉션 처리를 자연스럽게 연속적으로 적용할 수 있습니다

중요한 건 루비의 Enumerable이 제공하는 열거자는 each가 전부가 아니라는 점입니다. 여기서부터는 Enumerable 모듈에 포함된 블럭을 받는 컬렉션 처리 메서드들을 소개하도록 하겠습니다. 덧붙여 여기에 나온 모든 예제는 irbpry에서 직접 시도해볼 수 있습니다

Enumerable#map, Enumerable#collect

collect -> Enumerator
map -> Enumerator
collect {|item| ... } -> [object]
map {|item| ... } -> [object]

each 다음으로 유명한 열거자는 mapcollect입니다. 이 두 메서드는 실제로는 같고 이름만 다릅니다. 먼저 each는 컬렉션의 각 요소를 순차적으로 블럭에 넘겨 처리합니다

89번째 입력:
str = "Hello".split("")
89번째 평가:
["H", "e", "l", "l", "o"]
90번째 입력:
str.each{|chr| puts chr.upcase}
H
E
L
L
O
90번째 평가:
["H", "e", "l", "l", "o"]

단 여기서 each의 역할은 각 요소를 블럭 인자 chr로 받아서 한번씩 평가해주는 일이지 원래의 컬렉션을 변경한 값을 리턴하지는 않습니다. 즉, each의 평가 결과는 항상 리시버와 동일합니다

하지만 map은 리시버와 평가 결과가 달라집니다. map이 하는 역할은 각 요소들을 블럭 인자 chr로 받아 블럭을 평가한 결과로 원래의 요소를 변경합니다. 이렇게 만들어진 새로운 컬렉션을 리턴합니다(단, 원래의 컬렉션을 변경하지는 않습니다.)

91번째 입력:
str.map{|chr| chr.upcase}
91번째 평가:
["H", "E", "L", "L", "O"]

이러한 차이는 each에서 puts을 사용하지 않아도 마찬가지입니다. each에서 블럭의 평가값은 그냥 버려지는 값이고, each는 그저 리시버를 다시 리턴해줍니다.

92번째 입력:
str.each{|chr| chr.upcase}
92번째 평가:
["H", "e", "l", "l", "o"]

위에서 얘기했듯이 collectmap과 같습니다.

93번째 입력:
str.collect{|chr| chr.upcase}
93번째 평가:
["H", "E", "L", "L", "O"]

이러한 차이로 인해서 map 메서드를 모를 때 each를 사용해서 강제적으로 블럭 외부의 변수(특히 리시버)를 변경하려는 시도를 할 수가 있는데, 위에서 설명했지만 이러한 부작용은 의도치 않은 결과를 초래할 수 있습니다. 컬렉션 자체를 특적한 로직으로 변경하고자 할 때는 map이나 collect를 사용해야합니다

메서드 체인(Method Chain)

여기서 잠깐 다른 메서드를 소개하기 전에 다른 얘기를 하고 넘어가겠습니다. map 메서드를 통해서 컬렉션을 조작하는 일은 매우 흥미로운 일입니다. map은 컬렉션의 요소들을 하나하나 변경합니다. 즉 리시버 컬렉션의 모든 요소들은 넘겨받은 블럭의 로직에 의해서 하나하나 변경됩니다. 중요한 건 이렇게 변경되어 리턴되는 것은 리시버와 마찬가지로 컬렉션이라는 사실입니다. 따라서 배열에 map을 사용하면 다른 배열이 리턴됩니다. 아래 예에서는 배열을 절대값으로 변경해보겠습니다

94번째 입력:
[1.3, -2.8, 3.2, -4.99, -5.1].map{|i| i.abs}
94번째 평가:
[1.3, 2.8, 3.2, 4.99, 5.1]

Enumerable을 사용할 때는 항상 이러한 사실을 의식할 필요가 있습니다. 이렇듯 리턴값의 형식이 리시버와 같다는 것이 보장되는 메서드를 사용할 때는 리시버에 연속적으로 Enumerable 메서드를 호출할 수 있습니다. 예를 들어 위의 예에서 계속해서 반올림을 해보겠습니다

95번째 입력:
[1.3, -2.8, 3.2, -4.99, -5.1].map{|i| i.abs}.map{|i| i.round(0)}
95번째 평가:
[1, 3, 3, 5, 5]

예상한 결과가 나왔나요? 좀 더 자세히 살펴본다면,

[1.3, -2.8, 3.2, -4.99, -5.1].map{|number| number.abs}

의 평가 결과가 [1.3, 2.8, 3.2, 4.99, 5.1]가 되어 아래와 같이 map 메서드가 사용되었습니다.

96번째 입력:
[1.3, 2.8, 3.2, 4.99, 5.1].map{|number| number.round}
96번째 평가:
[1, 3, 3, 5, 5]

이러한 메서드 체인은 계속해서 이어질 수 있습니다

97번째 입력:
[1.3, -2.8, 3.2, -4.99, -5.1].
  map{|number| number.abs}.      # [1.3, 2.8, 3.2, 4.99, 5.1]
  map{|number| number.round}. # [1, 3, 3, 5, 5]
  map{|number| number / 2.0}.    # [0.5, 1.5, 1.5, 2.5, 2.5]
  map{|number| number.to_s}      # ["0.5", "1.5", "1.5", "2.5", "2.5"]
97번째 평가:
["0.5", "1.5", "1.5", "2.5", "2.5"]

위 예제에 별다른 의미는 없습니다만, 이러한 메서드 체인을 기억해둘 필요가 있습니다. Enumerable의 많은 메서드들은 의도적으로 컬렉션을 리턴합니다. 따라서 서로 다른 로직을 따르는 컬렉션 처리를 연쇄적으로 사용할 수 있습니다

따라서 이러한 보장이 되지 않는 메서드들을 사용할 때는 특히 유의가 필요합니다. 예를 들어 아래에서 소개할 select는 컬렉션이 리턴되는 것이 보장되지만 유사해보이는 detect는 이러한 특성이 보장되지 않습니다.

98번째 입력:
[1, 2, 3, 4, 5].select{|number| number.odd?}
98번째 평가:
[1, 3, 5]
99번째 입력:
[1, 2, 3, 4, 5].detect{|number| number.odd?}
99번째 평가:
1

당연한 얘기입니다만, 컬렉션이 아닌 객체에 Enumerable 메서드는 사용할 수 없습니다. 이런 식으로 메서드 체인을 구성해서는 안 됩니다. 검색 결과가 없는 예를 보면 이 이유가 좀 더 명확합니다.

100번째 입력:
[1, 3, 5, 7, 9].select{|number| number.even?}
100번째 평가:
[]
101번째 입력:
[1, 3, 5, 7, 9].detect{|number| number.even?}
# => nil

detect의 결과에 다시 map 보내면 어떻게 될까요? 당연히 nil 객체는 map 메서드를 가지고 있지 않으므로 메서드가 없다는 에러가 발생할 것입니다. 적어도 selectmap에 대해서 아무런 처리는 하지 않더라도 에러는 나지 않습니다

블럭 축약 표현

블럭에서 컬렉션의 요소에 대해서 특정한 메서드를 인자없이 호출할 때는 축약표현을 사용할 수 있습니다. 예를 들어 아래 두 표현은 같습니다. &는 블럭을 의미합니다.

102번째 입력:
[1.3, 2.8, 3.2, 4.99, 5.1].map{|number| number.round}
102번째 평가:
[1, 3, 3, 5, 5]
103번째 입력:
[1.3, 2.8, 3.2, 4.99, 5.1].map &:round
103번째 평가:
[1, 3, 3, 5, 5]

Enumerable#each_with_index

each_with_index -> Enumerator
each_with_index {|item, index| ... } -> self

each를 처음 사용할 때 가장 어려운 부분 중 하나가 forwhile 문을 사용할 때와 달리 현재 몇 번째 요소를 처리하는 지 index를 사용할 수 없다는 점입니다. 분명 루비에서도 이러한 처리는 순차적으로 이루어집니다. 이럴 때 사용할 수 있는 메서드가 each_with_index입니다. 이 메서드는 each와 비슷하나 item(각 요소)과 함께 요소의 순서 index를 넘겨줄 수 있습니다. 따라서 요소 순서에 따른 로직을 처리해야할 때 사용할 수 있습니다

104번째 입력:
"Hello".split("").each_with_index do |item, index|
  puts index.to_s + "번째 위치의 글자: " + item
end
0번째 위치의 글자: H
1번째 위치의 글자: e
2번째 위치의 글자: l
3번째 위치의 글자: l
4번째 위치의 글자: o
104번째 평가:
["H", "e", "l", "l", "o"]

아쉽게도 map_with_index와 같은 메서드는 제공되지 않습니다만, Enumeratorwith_index 메서드를 사용하면 map 함수에서도 index를 사용할 수 있습니다. (참고로 다른 Enumerable 함수에서도 with_index를 사용할 수 있지만 항상 사용할 수 있는 것은 아닙니다)

105번째 입력:
"Hello".split("").map.with_index do |item, index|
  {index: index, char: item}
end
105번째 평가:
[{:index=>0, :char=>"H"}, {:index=>1, :char=>"e"}, {:index=>2, :char=>"l"}, {:index=>3, :char=>"l"}, {:index=>4, :char=>"o"}]

Enumerable#each_with_object

each_with_object(obj) -> Enumerator
each_with_object(obj) {|(*args), memo_obj| ... } -> object

each_with_object는 각 요소를 반복할 때 특정한 객체를 넘겨줄 수 있습니다

106번째 입력:
(1..5).each_with_object(2.0){ |item, num| puts item / num }
0.5
1.0
1.5
2.0
2.5
106번째 평가:
2.0
107번째 입력:
(1..5).each_with_object([]){ |item, array| array << item ** 2 }
107번째 평가:
[1, 4, 9, 16, 25]

Enumerable#each_slice

each_slice(n) -> Enumerator
each_slice(n) {|list| ... } -> nil

each_slice은 컬렉션을 특정한 길이만큼 잘라서 반복합니다. 예를 들어 길이가 10인 컬렉션을 3씩 자르면, 3개의 요소, 3개의 요소, 3개의 요소, 1개의 요소가 되고 이 4개의 컬렉션을 반복합니다

108번째 입력:
(1..10).each_slice(3){|list| p list} 
[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
[10]

이러한 열거자는 특정한 갯수만큼 짝을 지어서 처리할 때 유용합니다. 예를 들어서 15명의 사람이 있고 3명씩 모아 5개의 팀을 만든다고 할 때 다음과 같이 처리할 수 있습니다

109번째 입력:
people = %w{철수 영희 민수 민지 길동 갑순 갑돌 둘리 모모 세리 영자 또또 현아 연지 곤지}
people.sort_by{rand}.each_slice(3).with_index do |list, index| 
  puts "팀" + (index + 1).to_s + ": " + list.join(", ")
end
팀1: 또또, 모모, 길동
팀2: 영자, 연지, 세리
팀3: 민수, 갑순, 영희
팀4: 곤지, 현아, 민지
팀5: 둘리, 갑돌, 철수

아쉽게도 each_slice에 대응하는 map 계열 함수는 없습니다. 단 블럭 없이 실행한 each_slice의 결과를 배열로 변환한 다음 map을 연쇄시키는 방법을 사용할 수 있습니다. 이는 Enumerable을 활용하면 자주 사용하게 되는 테크닉입니다. 이러한 방법은 each 계열 메서드들을 map과 연계시켜서 사용할 수 있게 도와줍니다. 위의 예를 계속 사용해보겠습니다

110번째 입력:
people.each_slice(3).to_a
110번째 평가:
[["철수", "영희", "민수"], ["민지", "길동", "갑순"], ["갑돌", "둘리", "모모"], ["세리", "영자", "또또"], ["현아", "연지", "곤지"]]
111번째 입력:
# 첫번째 멤버를 뽑아냅니다
people.each_slice(3).to_a.map{|team| team.first}
111번째 평가:
["철수", "민지", "갑돌", "세리", "현아"]

Enumerable#each_cons

each_cons(n) -> Enumerator
each_cons(n) {|list| ... } -> nil

each_conseach_slice와 비슷하지만 특정한 길이만큼 자를 때 길이를 겹쳐서 자릅니다. 백문이불여일견, 직접 보면 이해가 바로 될 것입니다. 아래 예제에서 두 메서드를 비교해보겠습니다.

112번째 입력:
(1..10).each_slice(3).to_a
112번째 평가:
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
113번째 입력:
(1..10).each_cons(3).to_a
113번째 평가:
[[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8], [7, 8, 9], [8, 9, 10]]

Enumerable#reverse_each

reverse_each -> Enumerator
reverse_each {|element| ... } -> self

reverse_eacheach와 마찬가지로 각 요소를 반복하되, 순서를 거꾸로 합니다

114번째 입력:
"Hello".split("").each{|item| puts item}
H
e
l
l
o
114번째 평가:
["H", "e", "l", "l", "o"]
115번째 입력:
"Hello".split("").reverse_each{|item| puts item}
o
l
l
e
H
115번째 평가:
["H", "e", "l", "l", "o"]

Enumerable#cycle

cycle(n=nil) -> Enumerator cycle(n=nil) {|obj| ... } -> object | nil

cycle는 리시버를 특정한 횟수(인자)만큼 반복합니다. 인자가 없으면 무한히 반복합니다

116번째 입력:
[100, 200].cycle(3){|item| puts item}
100
200
100
200
100
200

Enumerable#flat_map / Enumerable#collect_concat

flat_map -> Enumerator
collect_concat -> Enumerator
flat_map {| obj | block } -> Array
collect_concat {| obj | block } -> Array

map(collect)에 대해서는 이미 위에서 설명한 바 있습니다. flat_map(collect_concat)는 map과 마찬가지로 각 요소를 반복한 후 flatten해서 리턴합니다. 두 메서드를 비교해보겠습니다.

117번째 입력:
[[1, 3, 5], [2, 4, 6]].map{|item| item * 2}
117번째 평가:
[[1, 3, 5, 1, 3, 5], [2, 4, 6, 2, 4, 6]]
118번째 입력:
[[1, 3, 5], [2, 4, 6]].flat_map{|item| item * 2}
118번째 평가:
[1, 3, 5, 1, 3, 5, 2, 4, 6, 2, 4, 6]

각 값의 순서는 같지만 map의 결과는 2차원, flat_map의 결과는 1차원인 걸 확인할 수 있습니다. flatten하고 map을 하는 게 아니라 map을 하고 flatten한다는 점에 유의해야합니다

Enumerable#inject / Enumerable#reduce

injectreduce는 컬렉션에 연속적으로 함수를 적용한 결과를 리턴합니다. 아래 예제는 리시버 컬렉션을 전부 더한 결과를 리턴합니다

119번째 입력:
[1, 2, 3].inject(0){|sum, item| sum = sum + item}
119번째 평가:
6

여기서 중요한 점은 inject의 블럭 인자로 itemsum 두 개를 받고 있다는 점입니다. 먼저 앞의 itemmap이나 each와 마찬가지로 컬렉션의 요소가 되고 sum은 평가 결과를 저장할 변수입니다. 이 때 inject에 넘겨준 0sum의 초기값이 되며 inject는 최종적으로 sum의 최종 결과를 리턴합니다

이러한 과정을 좀 더 풀어서 설명하자면, 처음에는 item에 1이 넘어가고 sum에는 0이 넘어갑니다. 따라서 다음과 같습니다

120번째 입력:
sum = 0 + 1
120번째 평가:
1

다음은 item이 두번째 요소인 2가 되고, 그 다음은 3이 됩니다

121번째 입력:
sum = sum + 2
121번째 평가:
3
122번째 입력:
sum = sum + 3
122번째 평가:
6

이러한 과정을 거쳐서 [1, 2, 3].inject(0){|item, sum| sum = sum + item}은 6을 리턴합니다. 만약 inject의 인자로 넘겨주는 초기값을 생략하면 리시버 컬렉션의 첫번째 값이 sum의 초기값이 됩니다. 첫 item은 리시버의 두번째 요소가 됩니다. 따라서 결과는 같습니다

123번째 입력:
[1, 2, 3].inject{|item, sum| sum = sum + item}
123번째 평가:
6

앞서서 블럭 축약 표현을 설명한 바 있습니다. inject에서도 누적적으로 적용되는 메서드(연산자)에 대해서 이러한 축약표현을 사용할 수 있습니다. 따라서 아래와 같이 줄일 수 있습니다

124번째 입력:
[1, 2, 3].inject(0, &:+) 
124번째 평가:
6

적용하는 메서드가 단순할 때는 이러한 방법을 좀 더 직관적으로 사용할 수 있습니다. 앞서서 이야기했듯이 여기서 &은 블럭을 대신하는 표현입니다. 재미있는 건 inject 메서드는 이렇게 블럭을 받지 않고 적용할 함수 이름을 직접 받을 수도 있습니다. 함수 이름은 심볼로 받습니다. 따라서 아래 예제는 위의 예제와 같은 결과를 리턴합니다

125번째 입력:
[1, 2, 3].inject(0, :+)
125번째 평가:
6

유의하셔야 할 부분은 위의 두 예제에서 &:+:+이 같은 표현이 아니라는 점입니다. 전자는 블럭을 대신하는 표현이고 후자는 그냥 심볼입니다. 단지 inject 메서드가 메서드 이름을 인자로 받아 처리할 수 있는 기능을 지원할 수 있을 뿐입니다. 따라서 이러한 표현은 다른 메서드에서는 사용할 수 없습니다. 위에서 예로 들었던 map 메서드를 사용해 둘을 비교해보도록 하겠습니다

126번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.map &:length
126번째 평가:
[3, 4, 4, 3, 5, 3]
127번째 입력:
idol.map :length
ArgumentError: wrong number of arguments (1 for 0)

또한 앞서서 이야기했듯이 inject 메서드는 맥락에 따라서 초기값을 생략할 수 있습니다.

128번째 입력:
[1, 2, 3].inject :+
128번째 평가:
6

코드 길이를 줄이는 게 우선인 코드 골프와 같은 데서 이러한 표현은 매우 기본적으로 사용되는 테크닉 중 하나입니다. 일반적으로 이런 식의 축약 표현은 약간의 혼동을 줄 수도 있습니다만, 익숙해지기만 하면 당연한 로직을 한 번 더 따라가는 대신 메서드 이름만으로 어떤 일을 하는 지 의미론적으로 바로 파악이 가능합니다. 즐겨써야 하고 당연히 익혀두어야합니다

Enumerable#count

count -> Integer
count(item) -> Integer
count {|obj| ... } -> Integer

count 메서드는 리시버 컬렉션의 요소수를 리턴합니다.

129번째 입력:
(1..100).count
129번째 평가:
100

count 메서드는 너무나도 기본적이고, 많이 사용되는 메서드입니다. 비슷한 메서드로 length라는 메서드가 있습니다만, Enumerable#count 메서드는 length와는 달리 블럭을 받을 수 있습니다. count 메서드에 블럭을 넘겨주면 블럭을 참으로 만드는 요소의 개수를 리턴합니다

190번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}

idol.length
190번째 평가:
6
191번째 입력:
# 한글 문자열로만 된 문자열의 개수를 셉니다
idol.count{|item| item =~ /\p{hangul}+/ }
191번째 평가:
2

Enumerable#grep

grep(pattern) -> [object]
grep(pattern) {|item| ... } -> [object]

grep 메서드는 주어진 패턴에 매칭되는 결과를 찾습니다. 패턴은 === 연산자로 평가됩니다

131번째 입력:
people = %w{철수 영희 민수 민지 길동 갑순 갑돌 둘리 모모 세리 영자 또또 현아 연지 곤지}
people.grep(//)
131번째 평가:
["민지", "연지", "곤지"]

블럭을 넘겨주면 검색된 결과에 대해 블럭을 평가한 결과를 리턴합니다(map)

132번째 입력:
people.grep(//){|item| item.length }
132번째 평가:
[2, 2, 2]

Enumerable#find / Enumerable#detect

find(ifnone = nil) -> Enumerator
detect(ifnone = nil) -> Enumerator
find(ifnone = nil) {|item| ... } -> object
detect(ifnone = nil) {|item| ... } -> object

find(detect) 메서드는 넘겨진 블럭을 평가해서 평가한 결과가 참인 첫번째 요소를 찾습니다. 인자로 ifnone을 넘기면 블럭을 평가해서 참인 요소가 하나도 없을 때 ifnone을 call한 결과를 리턴합니다.

133번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.find{|item| item =~ /^[0-9]/ }
133번째 평가:
"2ne1"
134번째 입력:
ifnone = proc { puts "item not found" }
idol.find(ifnone){|item| item =~ /^3/ }
item not found

위에서 잠깐 언급했듯이 detect는 리턴값으로 요소 하나가 리턴되기 때문에 메서드 체인을 사용하는 데 유의할 필요가 있습니다. 메서드 체인을 사용하려면 select를 하고 take를 사용하는 등 다른 방법을 사용해야합니다

Enumerable#find_all / Enumerable#select

find_all -> Enumerator
select -> Enumerator
find_all {|item| ... } -> [object]
select {|item| ... } -> [object]

select(find_all)은 각 요소에 넘겨진 블럭을 평가해 참을 리턴한 요소들을 모두 찾습니다. find가 맨 처음 참을 리턴하는 요소를 찾는데 반해서 select이 참이 되는 모든 요소를 컬렉션으로 리턴합니다.

135번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.find{|item| item =~ /^[a-zA-Z]/ }
135번째 평가:
"EXO"
136번째 입력:
idol.select{|item| item =~ /^[a-zA-Z]/ }
136번째 평가:
["EXO", "HOT", "SES"]

앞서 언급했듯이 요소를 차고자 할 때 find 메서드를 사용하면 메서드 체인을 이어갈 수 없습니다. select는 아래와 같이 selecttake(1) 메서드를 호출해 검색한 결과의 첫번째 요소만을 컬렉션으로 가져올 수 있습니다.

137번째 입력:
idol.select{|item| item =~ /^[a-zA-Z]/ }.take(1)
137번째 평가:
["EXO"]

Enumerable#reject

reject -> Enumerator
reject {|item| ... } -> [object]

rejectselect와 반대 메서드로 각 요소를 평가한 결과가 참인 결과를 제외한 컬렉션을 리턴합니다.

138번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.reject{|item| item =~ /^[a-zA-Z]/ }
138번째 평가:
["소녀시대", "2ne1", "슈퍼주니어"]

Enumerable#find_index

find_index(val) -> Integer | nil
find_index {|obj| ... } -> Integer | nil
find_index -> Enumerator

find_indexfind와 마찬가지로 특정한 조건을 만족하는 첫번째 요소를 찾되 컬렉션에서 해당 요소의 위치를 리턴합니다. find_index는 두 가지 방법으로 사용할 수 있습니다. 먼저 메서드의 인자로 val을 넘겨지면 val과 일치하는 요소를 찾습니다. 블럭을 넘겨주면 각 요소를 블럭으로 평가해 처음 참이 되는 요소의 위치를 리턴합니다.

139번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.find_index{|item| item =~ /^[a-zA-Z]/ }
139번째 평가:
0
140번째 입력:
idol.find_index("소녀시대")
140번째 평가:
1

find와 마찬가지로 리턴값이 컬렉션이 아니므로 메서드 체인을 사용하는 데 유의할 필요가 있습니다

Enumerable#first

first -> object | nil
first(n) -> Array

first는 리시버에서 첫번째 요소를 리턴합니다. 인자로 n을 넘겨주면 앞에서부터 n개의 요소를 리턴합니다. 단, 하나의 요소도 없을 때 n값이 없으면 nil을 리턴하고, n값이 있으면 빈 배열을 리턴합니다

141번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.first
141번째 평가:
"EXO"
142번째 입력:
idol.first(3)
142번째 평가:
["EXO", "소녀시대", "2ne1"]
143번째 입력:
[].first
144번째 입력:
[].first(2)
144번째 평가:
[]

first 메서드는 경우에 따라서 컬렉션을 리턴하지 않을 수도 있기 때문에 메서드 체인으로 사용할 때 유의할 필요가 있습니다. 메서드 체인을 할 때는 아래의 take 메서드를 대신 사용하시기 바랍니다

Enumerable#take

take(n) -> Array

take는 앞선 first와 마찬가지로 앞에서부터 n개의 요소를 리턴합니다. 단, first와 달리 항상 배열을 리턴합니다

145번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.take(1)
145번째 평가:
["EXO"]
146번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.take(3)
146번째 평가:
["EXO", "소녀시대", "2ne1"]

take 함수는 항상 Array를 리턴하므로 메서드 체인에서 사용할 수 있습니다.

147번째 입력:
[].first
148번째 입력:
[].take(1)
148번째 평가:
[]

Enumerable#drop

drop(n) -> Array

drop 메서드는 앞에서부터 n개의 요소를 제외한 배열을 리턴합니다.

149번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.drop(3)
149번째 평가:
["HOT", "슈퍼주니어", "SES"]

Enumerable#take_while

take_while -> Enumerator
take_while {|element| ... } -> Array

take_while 메서드는 리시버 컬렉션을 반복하면서 특정 요소로 넘겨진 블럭을 평가했을 때 처음 거짓이 되는 요소까지의 모든 요소를 리턴합니다

150번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.take_while{|item| item.length < 5}
150번째 평가:
["EXO", "소녀시대", "2ne1", "HOT"]

Enumerable#drop_while

drop_while -> Enumerator
drop_while {|element| ... } -> Array

drop_while 메서드는 리시버 컬렉션을 반복하면서 특정 요소로 넘겨진 블럭을 평가했을 때 처음 거짓이 되는 요소부터 모든 요소를 리턴합니다.

151번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.drop_while{|item| item.length < 5}
151번째 평가:
["슈퍼주니어", "SES"]

Enumerable#sort

sort -> [object]
sort {|a, b| ... } -> [object]

sort는 리시버 컬렉션을 정렬해서 정렬된 컬렉션을 리턴합니다.

153번째 입력:
[10, 8, 5, 3, 9, 3, 6, 1].sort
153번째 평가:
[1, 3, 3, 5, 6, 8, 9, 10]

sort는 기본적으로 <=>을 사용해 두 객체의 비교를 수행합니다. 만약 객체 자체의 비교가 아닌 다른 기준으로 비교하고자 할 때는 sort에 비교를 위한 블럭을 넘겨줄 수 있습니다. 이러한 방식은 아래와 같이 사용합니다.

154번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.sort{|a, b| a.length <=> b.length}
154번째 평가:
["EXO", "SES", "HOT", "2ne1", "소녀시대", "슈퍼주니어"]

덧붙여 정렬 순서를 거꾸로할 때는 정렬 후 reverse 메서드를 사용하는 방법 이외에 a, b를 거꾸로 사용하는 방법도 있습니다

155번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.sort{|b, a| a.length <=> b.length}
155번째 평가:
["슈퍼주니어", "소녀시대", "2ne1", "HOT", "EXO", "SES"]

sort 메서드는 블럭이 <=> 방식으로 비교된다고 가정하기 때문에 정수 이외의 타입이 리턴되면 TypeError을 발생시킵니다. 즉, a와 b가 블럭인자로 순서대로 넘겨졌을 때 블럭을 평가한 결과가 0보다 커면 a가 크다가 판단하고, 0보다 작으면 b항이 크다고 판단합니다. 또한 0이면 a, b가 같다고 판단합니다. 또한 레퍼런스 문서에 따르면 비교 결과가 같은 항목이 원래의 순서와 같은 안정 정렬(stable sort)이 아닐 수 있음에 유의할 필요가 있다고 합니다

Enumerable#sort_by

sort_by -> Enumerator
sort_by {|item| ... } -> [object]

sort_by 역시 sort와 마찬가지로 리시버 컬렉션의 요소를 정렬하기 위해서 사용합니다. sort_bysort의 차이점은 sort_by는 항상 블럭을 받으며 블럭을 평가한 결과를 값에 대해서 정렬을 수행하며, sort는 블럭에서 직접 비교함수를 정의한다는 점입니다. 위에서 length를 사용한 sort의 예제를 다시 사용해서 두 메서드를 비교를 해보면 아래와 같습니다.

156번째 입력:
idol.sort{|a, b| a.length <=> b.length}
156번째 평가:
["EXO", "SES", "HOT", "2ne1", "소녀시대", "슈퍼주니어"]
157번째 입력:
idol.sort_by{|item| item.length}
157번째 평가:
["EXO", "SES", "HOT", "2ne1", "소녀시대", "슈퍼주니어"]

sort의 경우에는 비교함수를 직접 정의할 수 있다는 장점이 있습니다. 반면에 sort_by의 경우에는 비교함수를 직접 정의하지 않기 때문에 속도 면에서 유리합니다. 위의 예제에서 length 메서드가 각 요소에 대해서 한 번씩만 실행된다는 게 보장됩니다. 만약 이 비교를 위해 실행되는 로직이 매우 복잡한 연산이라면 속도 차이가 커질 수 있습니다.

Enumerable#max / Enumerable#max_by

max -> object | nil

max는 리시버 컬렉션에서 가장 큰 요소(최대값)를 리턴합니다

158번째 입력:
(1..100).max
158번째 평가:
100
max {|a, b| ... } -> object | n

max에도 블럭을 넘겨 사용할 수 있습니다. 이 때 사용하는 블럭은 sort 메서드가 받는 블럭과 같습니다

159번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.max{|a, b| a.length <=> b.length}
159번째 평가:
"슈퍼주니어"
max_by -> Enumerator
max_by {|item| ... } -> object | nil

maxmax_by의 차이는 sortsort_by의 차이와 같습니다

160번째 입력:
idol.max_by{|item| item.length}
160번째 평가:
"슈퍼주니어"

Enumerable#min / Enumerable#min_by

min -> object | nil
min {|a, b| ... } -> object | nil
min_by -> Enumerator
min_by {|item| ... } -> object | nil

min, min_by는 최소값을 찾습니다. 자세한 설명은 Enumerable#max, Enumerable#max_by 참조하시기 바랍니다

Enumerable#minmax / Enumerable#minmax_by

minmax -> [object, object]
minmax {|a, b| ... } -> [object, object]
minmax_by -> Enumerator
minmax_by {|obj| ... } -> [object, object]

minmax 메서드는 최소값과 최대값을 배열로 리턴합니다

161번째 입력:
(1..100).minmax
161번째 평가:
[1, 100]
162번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.minmax{|a, b| a.length <=> b.length}
162번째 평가:
["EXO", "슈퍼주니어"]
163번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.minmax_by{|item| item.length}
163번째 평가:
["EXO", "슈퍼주니어"]

또한 minmaxminmax_by의 차이는 sortsort_by의 차이와 같습니다

Enumerable#slice_before

slice_before(pattern) -> Enumerator
slice_before {|elt| bool } -> Enumerator
slice_before(initial_state) {|elt, state| bool } -> Enumerator

slice_before 메서드는 패턴이나 블럭의 평가 결과로 요소들을 나눠줍니다. 예를 들어 넘겨받은 블럭의 평가결과가 참이 되는 지점에서 컬렉션을 나눕니다.

164번째 입력:
[1, 3, 4, 5, 7, 9, 11].slice_before{|item| item.even?}.to_a
164번째 평가:
[[1, 3], [4, 5, 7, 9, 11]]

위의 결과에서 알 수 있듯이 평가결과가 참이되는(즉 짝수인) 지점에서 배열이 나눠집니다. 만약 짝수가 2개면 배열은 3개로 나눠집니다.

165번째 입력:
[1, 3, 4, 5, 7, 8, 11].slice_before{|item| item.even?}.to_a
165번째 평가:
[[1, 3], [4, 5, 7], [8, 11]]

블럭 대신 인자에 컬렉션을 자르는 기준이 되는 패턴을 넘겨줄 수도 있습니다.

166번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.slice_before(/^\p{hangul}/).to_a
166번째 평가:
[["EXO"], ["소녀시대", "2ne1", "HOT"], ["슈퍼주니어", "SES"]]

Enumerable#partition

partition -> Enumerator
partition {|item| ... } -> [[object], [object]]

partition 함수는 특정 조건으로 리시버 컬렉션을 분할합니다. 좀 더 정확히는 각 요소를 블럭에 넘겨 평가 한 후 참을 리턴하는 요소들과 거짓을 리턴하는 요소들로 나눕니다. 아래는 1부터 15까지의 숫자를 소수와 수소가 아닌 수로 나누는 예제입니다

167번째 입력:
require 'prime'
(1..15).partition{|item| Prime.prime? item }
167번째 평가:
[[2, 3, 5, 7, 11, 13], [1, 4, 6, 8, 9, 10, 12, 14, 15]]

Enumerable#chunk

chunk {|elt| ... } -> Enumerator
chunk(initial_state) {|elt, state| ... } -> Enumerator

chunk는 블럭을 평가한 결과가 같은 것끼리 모아줍니다. 좀 더 정확히는 리시버 컬렉션의 요소를 하나하나 블럭으로 평가해나가면서 평가 결과가 같은 것들을 하나로 뭉치고 평가 결과가 달라면 새로운 덩어리로 만듭니다. 즉 덩어리를 만다는 방식이 group_by와는 차이가 납니다.

168번째 입력:
%w{루비 GO elixir  줄리아 php 파이썬 펄 C D C++}.chunk{|item| item.length}.to_a
168번째 평가:
[[2, ["루비", "GO"]], [6, ["elixir"]], [3, ["줄리아", "php", "파이썬"]], [1, ["펄", "C", "D"]], [3, ["C++"]]]

Enumerable#group_by

group_by -> Enumerator
group_by {|obj| ... } -> Hash

group_by는 블럭을 평가한 결과가 같은 값들을 해시로 묶어서 리턴합니다

169번째 입력:
(1..10).group_by {|i| i % 3 }
169번째 평가:
{1=>[1, 4, 7, 10], 2=>[2, 5, 8], 0=>[3, 6, 9]}

group_by는 매우 유용한 함수입니다. 단, 리턴값이 해시이기 때문에 메서드 체인을 사용할 때 eachmap에서 해시를 처리하는 방법을 미리 알아둘 필요가 있습니다. 블럭에 블럭 인자를 하나 넘기면 Hash의 각 값은 [key, value] 형식의 배열로 취급됩니다.

170번째 입력:
(1..10).group_by{|i| i % 3 }.each{|item| puts "나머지 " + item[0].to_s + " : " + item[1].join(", ")}
나머지 1 : 1, 4, 7, 10
나머지 2 : 2, 5, 8
나머지 0 : 3, 6, 9
170번째 평가:
{1=>[1, 4, 7, 10], 2=>[2, 5, 8], 0=>[3, 6, 9]}

블럭인자를 두 개 넘겨주면 좀 더 의미를 전달하기 쉬워집니다. 이 때 첫번째 인자는 key가 되고 두번째 인자는 value가 됩니다

171번째 입력:
(1..10).group_by{|i| i % 3 }.each{|k, v| puts "나머지 " + k.to_s + " : " + v.join(", ")}
나머지 1 : 1, 4, 7, 10
나머지 2 : 2, 5, 8
나머지 0 : 3, 6, 9
171번째 평가:
{1=>[1, 4, 7, 10], 2=>[2, 5, 8], 0=>[3, 6, 9]}

group_by 메서드를 사용해서 얻은 결과는 key와 value에 모두 유의미한 정보가 포함되어있으므로, 이러한 접근법을 활용하면 유용합니다. 만약 어느 하나의 값을 사용하지 않는다면 _와 같은 표현을 통해 블럭 내에서 해당하는 값을 사용하지 않음을 전달할 수 있습니다

172번째 입력:
(1..10).group_by{|i| i % 3 }.each{|k, _| puts "그룹 " + k.to_s}
그룹 1
그룹 2
그룹 0
172번째 평가:
{1=>[1, 4, 7, 10], 2=>[2, 5, 8], 0=>[3, 6, 9]}

또한 앞에서 설명했듯이 Hash에 map을 적용할 때도 블럭인자를 하나 넘기면 [key, value] 형식으로 요소가 전달됩니다. 따라서 아래 두 예제는 같습니다.

173번째 입력:
(1..10).group_by{|i| i % 3 }.map{|item| item}
173번째 평가:
[[1, [1, 4, 7, 10]], [2, [2, 5, 8]], [0, [3, 6, 9]]]
174번째 입력:
(1..10).group_by{|i| i % 3 }.map{|k, v| [k, v]}
174번째 평가:
[[1, [1, 4, 7, 10]], [2, [2, 5, 8]], [0, [3, 6, 9]]]

Enumerable#zip

zip(*lists) -> [[object]]
zip(*lists) {|v1, v2, ...| ...} -> nil

zip 메서드는 여러 컬렉션을 교차해서 조합해줍니다. 예를 들어 두 배열이 있으면 같은 위치의 요소들을 조합해줍니다.

175번째 입력:
[1, 2, 3].zip([4, 5, 6])
175번째 평가:
[[1, 4], [2, 5], [3, 6]]

한 개 이상의 컬렉션들을 인자로 넘겨서 조합할 수도 있습니다.

176번째 입력:
[1, 2, 3].zip([4, 5, 6], [100, 101, 102])
176번째 평가:
[[1, 4, 100], [2, 5, 101], [3, 6, 102]]

또한 조합되는 갯수는 리시버 컬렉션을 기준으로 정해집니다.

177번째 입력:
[1, 2, 3, 1000].zip([4, 5, 6], [100, 101, 102])
177번째 평가:
[[1, 4, 100], [2, 5, 101], [3, 6, 102], [1000, nil, nil]]

만약 인자로 넘어간 컬렉션의 길이가 리시버 컬렉션보다 길 경우에 나머지 부분은 버려집니다.

178번째 입력:
[1, 2, 3].zip([4, 5, 6], [100, 101, 102, 1000])
178번째 평가:
[[1, 4, 100], [2, 5, 101], [3, 6, 102]]

Enumerable#all?

all? -> bool
all? {|item| ... } -> bool

all? 메서드는 리시버 컬렉션의 모든 요소가 블럭을 평가했을 때 참을 리턴하는지 결과를 리턴합니다.

179번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.all?{|item| item.class == String}
179번째 평가:
true
180번째 입력:
idol.all?{|item| item.length > 3}
180번째 평가:
false

Enumerable#any?

any? -> bool
any? {|item| ... } -> bool

any? 메서드는 리시버 컬렉션의 요소 중 하나라도 블럭을 평가했을 때 참을 리턴하는지 결과를 리턴합니다.

181번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.any?{|item| item.length >= 5}
181번째 평가:
true
182번째 입력:
idol.any?{|item| item =~ /^\p{hangul}/}
182번째 평가:
true

Enumerable#include? / Enumerable#member?

member?(val) -> bool
include?(val) -> bool

include?(member?) 메서드는 인자로 넘긴 객체가 리시버 객체에 있는지 확인합니다.

183번째 입력:
(1..100).include? 50
183번째 평가:
true
184번째 입력:
(1..100).include? 101
184번째 평가:
false

Enumerable#none?

none? -> bool
none? {|obj| ... } -> bool

none? 메서드는 모든 요소에 대해서 블럭을 평가했을 때 참이 하나도 없으면 참이 됩니다.

185번째 입력:
idol = %w{EXO 소녀시대 2ne1 HOT 슈퍼주니어 SES}
idol.none?{|item| item.length >= 10}
185번째 평가:
true

블럭이 넘겨지지 않았을 때 none?는 리시버 컬렉션의 모든 요소의 평가값이 거짓인지 확인합니다(루비에서는 nil과 false 이외의 모든 값은 참입니다).

186번째 입력:
[].none?
186번째 평가:
true
187번째 입력:
[nil, false].none?
187번째 평가:
true

Enumerable#one?

one? -> bool
one? {|obj| ... } -> bool

one?는 리시버 컬렉션의 평가 결과가 단 하나만 참인지 확인합니다. 블럭이 넘겨지면 블럭을 평가한 결과로 판단합니다.

188번째 입력:
[nil, false, "Hello"].one?
188번째 평가:
true
189번째 입력:
["Hello", "World"].one?
189번째 평가:
false

결론

이걸로 Enumerable 모듈의 거의 모든 메서드를 다뤘습니다. 좀 더 이야기해 볼 주제가 있다면 Enumerable 확장 클래스 만들기, Lazy, Enumerabtor와 외부 반복자, Enumerable 모듈을 일부 확장하는 gempowerpack과 메소드 체인 활용 예제 정도가 있을 것 같은데 기회가 되면 이런 이야기들도 정리해보도록하겠습니다. 아듀~


comments powered by Disqus