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> 어떻게 이런 동작이 가능할까? 부분적인 패턴 매치를 마치 함수처럼 전달할 수 있는 이유가 뭘까? 다음 강좌에서 이에 대해 다룰 것이다.