web/ko/collections.textile (290 lines of code) (raw):
---
prev: basics2.textile
next: pattern-matching-and-functional-composition.textile
title: 컬렉션
layout: post
---
이번 강좌에서 다루는 내용은 다음과 같다.
* 기본 데이터 구조
** "리스트":#Lists
** "집합":#Sets
** "튜플":#Tuple
** "맵":#Maps
* 함수 콤비네이터(Functional Combinator)
** "map":#map
** "foreach":#foreach
** "filter":#filter
** "zip":#zip
** "partition":#partition
** "find":#find
** "drop과 dropWhile":#drop
** "foldRight과 foldLeft":#fold
** "flatten":#flatten
** "flatMap":#flatMap
** "일반적인 함수 콤비네이터":#generalized
** "Map?":#vsMap
h1. 기본 데이터 구조
스칼라는 유용한 컬렉션을 여러 개 제공한다.
*See Also* 효율적인 스칼라에서도 <a href="https://twitter.github.com/effectivescala/#Collections">컬렉션 사용법</a>에 대해 다루고 있다.
h2(#Lists). 리스트
<pre>
scala> val numbers = List(1, 2, 3, 4)
numbers: List[Int] = List(1, 2, 3, 4)
</pre>
h2(#Sets). 집합
집합에는 중복된 원소가 들어갈 수 없다.
<pre>
scala> Set(1, 1, 2)
res0: scala.collection.immutable.Set[Int] = Set(1, 2)
</pre>
h2(#Tuple). 튜플
튜플을 사용하면 클래스를 정의하지 않고도 여러 아이템을 쉽게 한데 묶을 수 있다.
<pre>
scala> val hostPort = ("localhost", 80)
hostPort: (String, Int) = (localhost, 80)
</pre>
케이스 클래스와 달리 튜플의 억세서(accessor)에는 이름이 없다. 대신 위치에 따라 숫자로 된 억세서가 있다. 첫번째 원소는 0번이 아니고 1번이다(역주: 1번부터 시작한 것은 함수언어의 전통에 따른 것이다).
<pre>
scala> hostPort._1
res0: String = localhost
scala> hostPort._2
res1: Int = 80
</pre>
튜플은 패턴 매칭과 잘 들어맞는다.
<pre>
hostPort match {
case ("localhost", port) => ...
case (host, port) => ...
}
</pre>
두 값을 묶는 튜플을 만드는 특별한 소스로 <code>-></code>가 있다.
<pre>
scala> 1 -> 2
res0: (Int, Int) = (1,2)
</pre>
*See Also* 효율적인 스칼라에서 <a href="https://twitter.github.com/effectivescala/#Functional programming-Destructuring bindings">구조를 허무는 바인딩(destructuring binding)</a>("튜플 벗기기")에 대해 다룬다.
h2(#Maps). 맵
맵에 기본적인 데이터타입을 담을 수 있다.
<pre>
Map(1 -> 2)
Map("foo" -> "bar")
</pre>
위 코드에서 <code>-></code>는 맵을 위한 특별한 문법처럼 보인다. 하지만, 튜플에서 본 것처럼 <code>-></code>는 단지 2-튜플을 만들기 위한 것이다.
또한 위 Map()에는 1강에서 살펴 본 가변 길이 인자 문법이 사용되었다. 따라서 <code>Map(1 -> "one", 2 -> "two")</code>은 실제로는 <code>Map((1, "one"), (2, "two"))</code>가 되고, 리스트에 있는 각 튜플의 첫번째 원소는 키, 두번째 원소는 값이 된다.
맵이 다른 맵이나 함수를 값으로 보관할 수도 있다.
<pre>
Map(1 -> Map("foo" -> "bar"))
</pre>
<pre>
Map("timesTwo" -> { timesTwo(_) })
</pre>
h2(#Option). 옵션
어떤 것(객체)가 존재하거나 존재하지 않을 수 있을 때, <code>Option</code>을 사용해 처리한다.
옵션의 기본 인터페이스는 다음과 같다.
<code>
trait Option[T] {
def isDefined: Boolean
def get: T
def getOrElse(t: T): T
}
</code>
옵션 자체는 일반적 클래스이며, 두 하위클래스 <code>Some[T]</code>와 <code>None</code>이 있다.
이제 옵션을 어떻게 사용하는지 살펴보자.
<code>Map.get</code>은 <code>Option</code>를 반환한다. 옵션을 반환한다는 것은 찾는 값이 없을 수도 있다는 의미이다.
<pre>
scala> val numbers = Map("one" -> 1, "two" -> 2)
numbers: scala.collection.immutable.Map[java.lang.String,Int] = Map(one -> 1, two -> 2)
scala> numbers.get("two")
res0: Option[Int] = Some(2)
scala> numbers.get("three")
res1: Option[Int] = None
</pre>
이제 데이터가 <code>Option</code>에 들어가 있을 것이다. 그럼 그 옵션을 가지고는 무얼 할 수 있을까?
아마도 <code>isDefined</code> 메소드를 사용해 조건부 처리를 하는 것이 가장 먼저 반사적으로 떠오를 것이다.
<code>
// 수에 2를 곱하자. 만약 값이 없으면 0을 반환하자.
val result = if (res1.isDefined) {
res1.get * 2
} else {
0
}
</code>
하지만 그보다는 <code>getOrElse</code>나 패턴 매칭을 사용할 것을 권한다.
<code>getOrElse</code>을 사용하면 기본 값을 쉽게 지정할 수 있다.
<code>
val result = res1.getOrElse(0) * 2
</code>
패턴 매칭도 자연스럽게 <code>Option</code>과 맞아들어간다.
<code>
val result = res1 match {
case Some(n) => n * 2
case None => 0
}
</code>
*See Also* 효율적인 스칼라를 보면 <a href="https://twitter.github.com/effectivescala/#Functional programming-Options">옵션</a>에 대한 글이 있다.
h1(#combinators). 함수 콤비네이터
(역주: 콤비네이터란 이름에 웬지 모를 위압감을 느낄지도 모르겠는데, 콤비네이터는 함수와 컬렉션 등 다른 식을 받아서 적절한 작업을 해주는 조합 장치(함수) 정도로 생각하면 된다.)
<code>List(1, 2, 3) map squared</code>라고 하면 <code>squared</code> 함수를 리스트의 모든 원소에 적용한 다음 새 리스트를 반환한다. 결과는 아마도 <code>List(1, 4, 9)</code>가 될 것이다. <code>map</code>과 같은 함수를 <em>콤비네이터(combinator)</em>라 부른다. (더 나은 정의를 보고픈 사람은 스택 오버플로우의 <a href="https://stackoverflow.com/questions/7533837/explanation-of-combinators-for-the-working-man">콤비네이터에 대한 설명</a>을 참조하라.) 콤비네이터는 보통 표준 데이터 구조에 많이 사용된다.
h2(#map). map
리스트의 모든 원소에 함수를 적용한 결과값으로 이루어진 새 리스트를 반환한다. 원소 갯수는 적용 대상이 된 리스트의 원소 갯수과 동일하다.
<pre>
scala> numbers.map((i: Int) => i * 2)
res0: List[Int] = List(2, 4, 6, 8)
</pre>
또는 부분적용된 함수를 넘길 수도 있다.
<pre>
scala> def timesTwo(i: Int): Int = i * 2
timesTwo: (i: Int)Int
scala> numbers.map(timesTwo _)
res0: List[Int] = List(2, 4, 6, 8)
</pre>
h2(#foreach). foreach
foreach는 맵과 비슷하지만, 반환하는 것이 없다. 따라서 foreach는 부작용(값을 반환하는 것이 아니고 상태를 변화시키는 것)을 위해 사용한다.
<pre>
scala> numbers.foreach((i: Int) => i * 2)
</pre>
위 코드는 아무것도 반환하지 않는다.
반환되는 값을 변수에 넣을 수도 있다. 하지만, 그 타입은 Unit(즉, void)이다.
<pre>
scala> val doubled = numbers.foreach((i: Int) => i * 2)
doubled: Unit = ()
</pre>
h2(#filter). filter
전달된 함수가 거짓을 반환하는 원소들을 제외한 나머지 원소들로 이루어진 리스트를 반환한다. 참/거짓(즉, Boolean 값)을 반환하는 함수를 술어함수(predicate function)라 부르곤 한다.
<pre>
scala> numbers.filter((i: Int) => i % 2 == 0)
res0: List[Int] = List(2, 4)
</pre>
<pre>
scala> def isEven(i: Int): Boolean = i % 2 == 0
isEven: (i: Int)Boolean
scala> numbers.filter(isEven _)
res2: List[Int] = List(2, 4)
</pre>
h2(#zip). zip
zip은 두 리스트의 원소들의 쌍(튜플)으로 이루어진 단일 리스트를 반환한다.
<pre>
scala> List(1, 2, 3).zip(List("a", "b", "c"))
res0: List[(Int, String)] = List((1,a), (2,b), (3,c))
</pre>
h2(#partition). partition
<code>partition</code>은 술어 함수가 반환하는 값에 따라 리스트를 둘로 나눈다.
(역주. 원래 리스트의 모든 원소는 반환되는 두 리스트 중 어느 하나에 꼭 포함되며, 한 원소가 양쪽에 같이 속하는 일도 없다.)
<pre>
scala> val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
scala> numbers.partition(_ % 2 == 0)
res0: (List[Int], List[Int]) = (List(2, 4, 6, 8, 10),List(1, 3, 5, 7, 9))
</pre>
h2(#find). find
find는 리스트에서 술어함수를 만족하는 가장 첫 원소를 반환한다.
<pre>
scala> numbers.find((i: Int) => i > 5)
res0: Option[Int] = Some(6)
</pre>
h2(#drop). drop과 dropWhile
<code>drop</code>은 첫 i개의 원소를 떨군다. 따라서 나머지 (원래 리스트 길이-i)개의 원소만 남는다.
<pre>
scala> numbers.drop(5)
res0: List[Int] = List(6, 7, 8, 9, 10)
</pre>
<code>dropWhile</code>은 리스트의 앞에서 술어함수를 만족하는 원소를 없앤다. 술어함수가 최초로 거짓을 반환하면 그 뒤의 원소들은 살아 남는다.
예를 들어 numbers 리스트에서 홀수를 <code>dropWhile</code>하면 <code>1</code>이 떨어져 나간다. (하지만 <code>3</code>은 <code>2</code>가 "방패막이"가 되기 때문에 결과 리스트에 들어간다).
<pre>
scala> numbers.dropWhile(_ % 2 != 0)
res0: List[Int] = List(2, 3, 4, 5, 6, 7, 8, 9, 10)
</pre>
h2(#fold). foldLeft
<pre>
scala> numbers.foldLeft(0)((m: Int, n: Int) => m + n)
res0: Int = 55
</pre>
0은 시작 값이고(numbers가 List[Int]라는 사실을 기억하라), m은 값을 누적하는 변수 역할을 한다.
(역주: 여기서 누적이란 말은 덧셈으로 합산된다는 의미가 아니다. 물론 본 예제에서는 합산이 되고 있지만, 앞의 원소에 함수를 적용한 결과값이 전달되는 위치가 m이라는 의미이다. 풀어쓰자면, List(a1,a2,...,an).foldLeft(v0)(f)는 f(...f(f(v0,a1),a2),an)이다.)
foldLeft의 작동 과정을 자세히 보면 다음과 같다.
<pre>
scala> numbers.foldLeft(0) { (m: Int, n: Int) => println("m: " + m + " n: " + n); m + n }
m: 0 n: 1
m: 1 n: 2
m: 3 n: 3
m: 6 n: 4
m: 10 n: 5
m: 15 n: 6
m: 21 n: 7
m: 28 n: 8
m: 36 n: 9
m: 45 n: 10
res0: Int = 55
</pre>
h3. foldRight
foldLeft와 마찬가지이지만, 동작 방향이 반대이다. 따라서, n에 값이 누적된다.
(역주: foldLeft에서 쓴 것처럼 쓰면,ㅣ List(a1,...an)(v0)(f) = f(a1, f(a2, f(a3, .... f(an, v0)))) 이다.
<pre>
scala> numbers.foldRight(0) { (m: Int, n: Int) => println("m: " + m + " n: " + n); m + n }
m: 10 n: 0
m: 9 n: 10
m: 8 n: 19
m: 7 n: 27
m: 6 n: 34
m: 5 n: 40
m: 4 n: 45
m: 3 n: 49
m: 2 n: 52
m: 1 n: 54
res0: Int = 55
</pre>
h2(#flatten). flatten
flatten은 내포단계를 하나 줄여서 내포된 리스트의 원소를 상위 리스트로 옮겨준다.
<pre>
scala> List(List(1, 2), List(3, 4)).flatten
res0: List[Int] = List(1, 2, 3, 4)
</pre>
h2(#flatMap). flatMap
flatMap은 map과 flatten을 합성한 것이다. 내포 리스트에 적용할 수 있는 함수를 중첩된 리스트 안의 각 리스트에 적용해서 나온 결과를 하나의 리스트로 합쳐준다.
<pre>
scala> val nestedNumbers = List(List(1, 2), List(3, 4))
nestedNumbers: List[List[Int]] = List(List(1, 2), List(3, 4))
scala> nestedNumbers.flatMap(x => x.map(_ * 2))
res0: List[Int] = List(2, 4, 6, 8)
</pre>
이를 map을 한 다음 flatten하는 것을 간략히 표현한 것으로 이해할 수 있다.
<pre>
scala> nestedNumbers.map((x: List[Int]) => x.map(_ * 2)).flatten
res1: List[Int] = List(2, 4, 6, 8)
</pre>
ㅡ
위의 예는 map과 flatten을 서로 엮어서 콤비네이터로 활용하는 것을 보여준다.
*See Also* 효율적인 스칼라에 <a href="https://twitter.github.com/effectivescala/#Functional programming-`flatMap`">flatMap</a>에 대해 다룬 글이 있다.
h2(#generalized). 일반적인 함수 콤비네이터
지금까지 컬렉션에 작업을 수행시 골라잡을 수 있는 함수를 몇가지 배웠다.
이제 자신만의 함수 콤비네이터를 만들 수 있다면 더 좋을 것이다.
재미있는 사실은, 앞에서 본 모든 콤비네이터가 fold를 기본으로 작성될 수 있다는 점이다. 몇 가지 예를 보자.
<pre>
def ourMap(numbers: List[Int], fn: Int => Int): List[Int] = {
numbers.foldRight(List[Int]()) { (x: Int, xs: List[Int]) =>
fn(x) :: xs
}
}
scala> ourMap(numbers, timesTwo(_))
res0: List[Int] = List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
</pre>
왜 Nil이 아니고 <tt>List[Int]()</tt>를 초기값으로 넣었을까? 이는 스칼라가 빈 Int 리스트에 값을 누적하기를 바란다는 점을 알아낼 정도로 똑똑하지 못하기 때문이다(역주: Nil을 넣으면 스칼라 타입 추론이 실패한다는 의미이다).
h2(#vsMap). Map?
앞에서 본 모든 함수 콤비네이터는 맵에서도 사용 가능하다. 맵을 키와 값을 쌍으로 하는 원소를 가지는 리스트로 생각한다면, 앞의 모든 함수들은 그 위에 동작할 수 있다.
(역주: 여러 콤비네이터는 스칼라 컬렉션의 대부분의 클래스에서 사용 가능하다. 이에 대해서는 <a href="https://www.scala-lang.org/api/current/index.html#scala.collection.package">스칼라 API문서의 컬렉션 부분</a>이나 <a href="https://docs.scala-lang.org/overviews/collections/introduction.html">스칼라 컬렉션 가이드</a>를 참조하라.)
<pre>
scala> val extensions = Map("steve" -> 100, "bob" -> 101, "joe" -> 201)
extensions: scala.collection.immutable.Map[String,Int] = Map((steve,100), (bob,101), (joe,201))
</pre>
이제 내선 번호가 200보다 작은 모든 엔트리를 걸러내 보자.
<pre>
scala> extensions.filter((namePhone: (String, Int)) => namePhone._2 < 200)
res0: scala.collection.immutable.Map[String,Int] = Map((steve,100), (bob,101))
</pre>
filter는 전달된 함수에 투플을 넘긴다. 따라서 키와 값 중 원하는 것을 위치 억세서를 사용해 구분해야 한다. 영 좋지 않다!
다행히도 패턴 매치를 사용해 쉽게 키와 값을 분리할 수 있다.
<pre>
scala> extensions.filter({case (name, extension) => extension < 200})
res0: scala.collection.immutable.Map[String,Int] = Map((steve,100), (bob,101))
</pre>
어떻게 이런 동작이 가능할까? 부분적인 패턴 매치를 마치 함수처럼 전달할 수 있는 이유가 뭘까?
다음 강좌에서 이에 대해 다룰 것이다.