컬렉션 메서드
컬렉션 메서드
Scala 컬렉션의 큰 강점은 수십 개의 메서드가 기본으로 딸려 나온다는 점이에요. 그것도 불변 컬렉션이든 가변 컬렉션이든 일관되게 같은 메서드를 쓸 수 있죠. 이번 장에서는 가장 많이 쓰는 컬렉션 메서드들을 하나씩 살펴볼게요.
출처: Scala 3 Book
본문
Scala 컬렉션에는 수십 개의 메서드가 기본으로 마련되어 있어서, 컬렉션을 다룰 때마다 매번 직접 for 루프를 짜지 않아도 돼요. 프로젝트를 옮겨 다닐 때도 같은 메서드들이 쓰이는 걸 발견하게 되죠.
메서드가 정말 많아서 전부 소개하지는 않고, 가장 흔히 쓰이는 것들만 볼게요.
mapfilterforeachheadtailtake,takeWhiledrop,dropWhilereduce
이 메서드들은 List, Vector, ArrayBuffer 등 모든 시퀀스 타입에 적용되지만, 여기서는 따로 언급하지 않는 한 List를 기준으로 예시를 들게요.
아주 중요한 점 하나를 꼭 기억해 주세요.
List의 메서드들은 어느 것도 리스트를 변경(mutate)하지 않아요. 모두 함수형 스타일로 동작해서, 수정된 결과를 담은 새 컬렉션을 반환해요.
흔한 메서드 예시 (Examples of common methods)
다음 절에서 무엇을 보게 될지 감을 잡기 위해, 가장 많이 쓰이는 컬렉션 메서드 몇 가지를 먼저 보여드릴게요. 먼저 람다를 쓰지 않는 메서드들이에요.
val a = List(10, 20, 30, 40, 10) // List(10, 20, 30, 40, 10)
a.distinct // List(10, 20, 30, 40)
a.drop(2) // List(30, 40, 10)
a.dropRight(2) // List(10, 20, 30)
a.head // 10
a.headOption // Some(10)
a.init // List(10, 20, 30, 40)
a.intersect(List(19,20,21)) // List(20)
a.last // 10
a.lastOption // Some(10)
a.slice(2,4) // List(30, 40)
a.tail // List(20, 30, 40, 10)
a.take(3) // List(10, 20, 30)
a.takeRight(2) // List(40, 10)
고차 함수와 람다 (Higher-order functions and lambdas)
다음은 람다(익명 함수)를 인자로 받는 흔한 고차 함수(HOF)들이에요. 먼저 람다 문법의 여러 변형을 볼게요. 가장 긴 형태에서 시작해 점점 간결한 형태로 가는 순서예요.
// 이 함수들은 모두 동등하며, 같은 데이터를 반환해요: List(10, 20, 10)
a.filter((i: Int) => i < 25) // 1. 가장 명시적인 형태
a.filter((i) => i < 25) // 2. `Int`는 필요 없음
a.filter(i => i < 25) // 3. 괄호는 필요 없음
a.filter(_ < 25) // 4. `i`도 필요 없음
이 번호 매긴 예시들을 하나씩 풀어볼게요.
- 첫 번째 예시는 가장 긴 형태예요. 이렇게 장황하게 쓰는 경우는 드물고, 가장 복잡한 용법에서만 필요해요.
- 컴파일러가
a에Int가 들어있다는 걸 알고 있으니, 여기서 다시 명시할 필요가 없어요. i처럼 매개변수가 하나뿐이면 괄호가 필요 없어요.- 매개변수가 하나이고 그게 익명 함수에서 딱 한 번만 나타나면, 그 매개변수를
_로 바꿀 수 있어요.
람다를 줄이는 규칙에 대한 더 자세한 내용은 익명 함수에서 확인할 수 있어요.
이제 간결한 형태를 봤으니, 짧은 형식의 람다 문법을 쓰는 다른 고차 함수 예시를 볼게요.
a.dropWhile(_ < 25) // List(30, 40, 10)
a.filter(_ > 100) // List()
a.filterNot(_ < 25) // List(30, 40)
a.find(_ > 20) // Some(30)
a.takeWhile(_ < 30) // List(10, 20)
고차 함수는 람다뿐 아니라 메서드와 함수도 매개변수로 받을 수 있다는 점도 중요해요. double이라는 메서드를 쓰는 map 고차 함수 예시를 볼게요. 람다 문법의 여러 변형이 다시 등장해요.
def double(i: Int) = i * 2
// 이들은 모두 `List(20, 40, 60, 80, 20)`을 반환해요
a.map(i => double(i))
a.map(double(_))
a.map(double)
마지막 예시에서 익명 함수가 단일 인자를 받는 함수 호출 하나로 이루어져 있으면 인자의 이름을 붙일 필요가 없어서, _조차 필요 없어요.
마지막으로, 고차 함수는 원하는 대로 조합해서 문제를 풀 수 있어요.
// `List(100, 200)`을 만들어요
a.filter(_ < 40)
.takeWhile(_ < 30)
.map(_ * 10)
예시 데이터 (Sample data)
다음 절들의 예시들은 이런 리스트를 사용할게요.
val oneToTen = (1 to 10).toList
val names = List("adam", "brandy", "chris", "david")
map
map 메서드는 기존 리스트의 각 요소를 하나씩 훑으면서, 여러분이 제공한 함수를 각 요소에 적용해요. 그리고 수정된 모든 요소를 담은 새 리스트를 반환하죠.
oneToTen 리스트에 map 메서드를 적용한 예시를 볼게요.
scala> val doubles = oneToTen.map(_ * 2)
doubles: List[Int] = List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
익명 함수를 긴 형태로도 쓸 수 있어요.
scala> val doubles = oneToTen.map(i => i * 2)
doubles: List[Int] = List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
다만 이번 강의에서는 항상 첫 번째(더 짧은) 형태를 쓸게요.
oneToTen과 names 리스트에 map을 적용한 예시를 몇 개 더 볼게요.
scala> val capNames = names.map(_.capitalize)
capNames: List[String] = List(Adam, Brandy, Chris, David)
scala> val nameLengthsMap = names.map(s => (s, s.length)).toMap
nameLengthsMap: Map[String, Int] = Map(adam -> 4, brandy -> 6, chris -> 5, david -> 5)
scala> val isLessThanFive = oneToTen.map(_ < 5)
isLessThanFive: List[Boolean] = List(true, true, true, true, false, false, false, false, false, false)
마지막 두 예시에서 보듯, map으로 원래 타입과 다른 타입의 컬렉션을 반환하는 건 아주 흔하고 합법적인 일이에요.
filter
filter 메서드는 제공된 조건(predicate)을 만족하는 요소만 담은 새 리스트를 만들어요. 이때 조건(predicate)은 Boolean(true 또는 false)을 반환하는 함수예요. 몇 가지 예시를 볼게요.
scala> val lessThanFive = oneToTen.filter(_ < 5)
lessThanFive: List[Int] = List(1, 2, 3, 4)
scala> val evens = oneToTen.filter(_ % 2 == 0)
evens: List[Int] = List(2, 4, 6, 8, 10)
scala> val shortNames = names.filter(_.length <= 4)
shortNames: List[String] = List(adam)
컬렉션의 함수형 메서드가 주는 큰 장점은, 문제를 풀기 위해 메서드들을 체이닝(chaining) 할 수 있다는 거예요. 예를 들어 filter와 map을 이렇게 연결할 수 있어요.
oneToTen.filter(_ < 4).map(_ * 10)
REPL에서 결과를 보면 이렇게 나와요.
scala> oneToTen.filter(_ < 4).map(_ * 10)
val res1: List[Int] = List(10, 20, 30)
foreach
foreach 메서드는 컬렉션의 모든 요소를 반복(loop)할 때 써요. foreach는 정보를 출력하는 것 같은 부수 효과(side-effect) 를 위해 쓰인다는 점을 기억하세요. names 리스트로 예시를 들어볼게요.
scala> names.foreach(println)
adam
brandy
chris
david
head
head 메서드는 Lisp과 그보다 이른 함수형 프로그래밍 언어들에서 왔어요. 리스트의 첫 번째 요소(헤드 요소)에 접근할 때 쓰죠.
oneToTen.head // 1
names.head // adam
String은 문자의 시퀀스로 볼 수 있으니, 리스트처럼 다룰 수도 있어요. head가 문자열에 이렇게 동작하는 이유예요.
"foo".head // 'f'
"bar".head // 'b'
head는 다루기 좋은 메서드지만, 빈 컬렉션에 호출하면 예외를 던질 수 있다는 점을 조심해야 해요.
val emptyList = List[Int]() // emptyList: List[Int] = List()
emptyList.head // java.util.NoSuchElementException: head of empty list
그래서 특히 함수형 스타일로 프로그래밍할 때는 head 대신 headOption을 쓰는 편이 좋아요.
emptyList.headOption // None
보시다시피 예외를 던지지 않고, None 값을 가진 Option 타입을 그냥 반환해요. 이 프로그래밍 스타일에 대해 더 알고 싶다면 함수형 프로그래밍 장을 참고하세요.
tail
tail 메서드도 Lisp에서 왔고, 리스트에서 헤드 요소 뒤의 모든 요소를 얻을 때 써요. 몇 가지 예시로 확인해 볼게요.
oneToTen.head // 1
oneToTen.tail // List(2, 3, 4, 5, 6, 7, 8, 9, 10)
names.head // adam
names.tail // List(brandy, chris, david)
head와 마찬가지로 tail도 문자열에 동작해요.
"foo".tail // "oo"
"bar".tail // "ar"
tail은 리스트가 비어 있으면 java.lang.UnsupportedOperationException을 던져요. headOption과 달리 Scala 컬렉션은 tailOption 메서드를 제공하지 않아요. 컬렉션의 tail을 안전하게 다루려면 패턴 매칭이나 drop(1) 같은 메서드를 쓰면 돼요.
리스트는 패턴 매칭도 가능해서, 이런 표현식을 쓸 수 있어요.
val x :: xs = names
이 코드를 REPL에 넣으면 x가 리스트의 head에, xs가 tail에 할당되는 걸 볼 수 있어요.
scala> val x :: xs = names
val x: String = adam
val xs: List[String] = List(brandy, chris, david)
이런 패턴 매칭은 여러 상황에서 유용해요. 예를 들어 재귀를 쓰는 sum 메서드를 작성할 때가 대표적이죠.
def sum(list: List[Int]): Int = list match {
case Nil => 0
case x :: xs => x + sum(xs)
}
def sum(list: List[Int]): Int = list match
case Nil => 0
case x :: xs => x + sum(xs)
take, takeRight, takeWhile
take, takeRight, takeWhile 메서드는 새 리스트를 만들 때 쓰고 싶은 요소들을 리스트에서 "취하는(taking)" 깔끔한 방법을 제공해요. 먼저 take와 takeRight를 볼게요.
oneToTen.take(1) // List(1)
oneToTen.take(2) // List(1, 2)
oneToTen.takeRight(1) // List(10)
oneToTen.takeRight(2) // List(9, 10)
이 메서드들이 시퀀스에 있는 것보다 더 많은 요소를 요청하거나, 0개를 요청하는 "경계(edge)" 케이스에서 어떻게 동작하는지도 눈여겨보세요.
oneToTen.take(Int.MaxValue) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
oneToTen.takeRight(Int.MaxValue) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
oneToTen.take(0) // List()
oneToTen.takeRight(0) // List()
그리고 이건 조건 함수와 함께 동작하는 takeWhile예요.
oneToTen.takeWhile(_ < 5) // List(1, 2, 3, 4)
names.takeWhile(_.length < 5) // List(adam)
drop, dropRight, dropWhile
drop, dropRight, dropWhile은 기본적으로 "take" 계열과 반대예요. 리스트에서 요소를 버리는 메서드들이죠. 몇 가지 예시를 볼게요.
oneToTen.drop(1) // List(2, 3, 4, 5, 6, 7, 8, 9, 10)
oneToTen.drop(5) // List(6, 7, 8, 9, 10)
oneToTen.dropRight(8) // List(1, 2)
oneToTen.dropRight(7) // List(1, 2, 3)
이 메서드들이 경계 케이스에서 어떻게 동작하는지도 다시 확인해 볼게요.
oneToTen.drop(Int.MaxValue) // List()
oneToTen.dropRight(Int.MaxValue) // List()
oneToTen.drop(0) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
oneToTen.dropRight(0) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
그리고 이건 조건 함수와 함께 동작하는 dropWhile이에요.
oneToTen.dropWhile(_ < 5) // List(5, 6, 7, 8, 9, 10)
names.dropWhile(_ != "chris") // List(chris, david)
reduce
"map reduce"라는 말을 들어봤다면, 여기서 "reduce" 부분이 reduce 같은 메서드를 가리켜요. reduce는 함수(또는 익명 함수)를 받아서 리스트의 연속된 요소들에 그 함수를 적용해요.
reduce를 가장 잘 설명하는 방법은, reduce에 넘길 작은 도우미 메서드 하나를 만드는 거예요. 예를 들어 두 정수를 더하면서 디버그 출력도 함께 해주는 add 메서드가 있어요.
def add(x: Int, y: Int): Int = {
val theSum = x + y
println(s"received $x and $y, their sum is $theSum")
theSum
}
def add(x: Int, y: Int): Int =
val theSum = x + y
println(s"received $x and $y, their sum is $theSum")
theSum
그 메서드와 이 리스트가 있다고 할게요.
val a = List(1,2,3,4)
그러면 add 메서드를 reduce에 넘기면 이렇게 동작해요.
scala> a.reduce(add)
received 1 and 2, their sum is 3
received 3 and 3, their sum is 6
received 6 and 4, their sum is 10
res0: Int = 10
결과에서 보듯, reduce는 add를 써서 리스트 a를 단일 값, 여기서는 리스트 정수들의 합으로 줄여요.
reduce에 익숙해지면 "sum" 알고리즘을 이렇게 작성하게 돼요.
scala> a.reduce(_ + _)
res0: Int = 10
비슷하게 "product" 알고리즘은 이렇게 생겼어요.
scala> a.reduce(_ * _)
res1: Int = 24
reduce에 대해 알아야 할 중요한 개념은, 이름이 뜻하듯 컬렉션을 단일 값으로 줄이는(reduce) 데 쓰인다는 거예요.
더 많은 메서드 (Even more)
Scala 컬렉션 타입에는 말 그대로 수십 개의 추가 메서드가 있어서, for 루프를 더 이상 작성하지 않아도 되게 해줘요. Scala 컬렉션에 대한 훨씬 더 자세한 내용은 가변·불변 컬렉션과 Scala 컬렉션의 구조를 참고하세요.
마지막 참고로, Scala 프로젝트에서 Java 코드를 쓰고 있다면 Java 컬렉션을 Scala 컬렉션으로 변환할 수 있어요. 그렇게 하면 그 컬렉션들을
for표현식에서 쓸 수 있고, Scala의 함수형 컬렉션 메서드도 활용할 수 있죠. 자세한 내용은 Java와 상호작용하기 절을 참고하세요.