Programming in Scala 17장

17장부터는 음슴체를 사용하지 않으려 합니다.

17 Collections

17.1 Sequences

시퀀스는 순서대로 나열된 데이터 그룹에 대해 동작한다. 정돈된 요소들이기 때문에 첫 번째 요소, 두 번째 요소, 103번째 요소 등을 요구할 수 있다.

List

리스트는 리스트의 시작 부분에 빠른 삽입과 삭제를 지원한다. 그러나 임의의 인덱스에 대한 빠른 접근은 제공하지 않는데, 그 구현이 리스트를 통해 연속적으로 반복되어야 하기 때문이다. (※. 뭔소리야? )

이런 특징들의 조합이 이상하게 들리겠지만, 이들은 많은 알고리즘에 잘 동작하는 sweet spot을 찾아낸다. 시작 요소의 빠른 삽입과 삭제는 패턴매칭에 잘 동작하고, 리스트의 불변성은 복사본을 만들 필요가 없기 때문에 정확하고 효율적인 알고리즘을 개발하는데 좋다.

다음은 리스트를 어떻게 초기화하고 어떻게 리스트의 머리와 꼬리에 접근하는지 보여주는 예제이다.

1
2
3
val colors = List("red", "blue", "green") // List(red, blue, green)
colors.head // red
colors.tail // List(blue, green)

리스트 소개는 3.8장, 리스트의 사용은 16장, 리스트의 구현은 22장을 참조한다.

Arrays

배열은 0부터 시작하는 인덱스로 요소를 획득하고 갱신하는 모든 임의 요소에 대해 접근성이 좋다. 다음은 크기는 알고 있지만, 아직 요소의 값은 모를 때 배열을 생성하는 예제이다.

1
val fiveInts = new Array[Int](5) // Array(0, 0, 0, 0, 0)

다음은 요소의 값을 알고 있을 때 배열을 초기화하는 예제이다.

1
val fiveToOne = Array(5, 4, 3, 2, 1) // Array(5, 4, 3, 2, 1)    

Scala에서 배열은 Java처럼 꺽쇠괄호가 아닌 둥근괄호로 억세스한다. 다음은 배열 요소를 접근하고 갱신하는 예제이다.

1
fiveInts(0) = fiveToOne(4) // Array(1, 0, 0, 0, 0)

Scala 배열은 Java 배열처럼 같은 방식으로 표현된다. Java 매소드를 사용해서 배열을 반환할 수 있다. Scala 배열과 Java 배열의 차이점은 19.3장에서 다룬다.

List Buffers

클래스 List는 리스트 꼬리가 아닌 머리에 대한 접근이 빠르다. 따라서, 리스트 마지막에서부터 append해서 리스트를 만들어 갈 경우, 리스트 앞에 prepend한 후 reverse를 호출하는 게 좋다.

reverse를 회피하는 대안으로는 ListBuffer를 사용하는 것이다. ListBuffer는 변경 가능한 객체로, 상수 시간(constant time)이 소요되는 append와 prepend 연산을 제공한다.

append는 += 연산자를, prepend는 +=: 연산자를 사용한다. 다 만들어지면 toList를 호출하여 List를 획득한다.

1
2
3
4
5
6
7
8
import scala.collection.mutable.ListBuffer
  
val buf = new ListBuffer[Int]
buf += 1 // ListBuffer(1)
buf += 2 // ListBuffer(1, 2)
buf // ListBuffer(1, 2)
3 +=: buf // ListBuffer(3, 1, 2)
buf.toList // List(3, 1, 2)

ListBuffer를 사용하는 다른 이유는 잠재적인 스택 오버플로우(stack overflow)를 방지하기 위함이다. prepend할 때 요구되는 알고리즘이 tail recursion이 아닐 경우에 forwhile의 반복문 그리고 ListBuffer를 사용할 수 있다. (※. 8.9 Tail recursion(p. 159)을 참고. 기타 라 스칼라 코딩단 Tail Recursion 논의)

ArrayBuffer

ArrayBuffer는 시쿼스의 처음과 끝에서 요소를 삽입/삭제할 수 있다는 것을 제외하면 배열과 비슷하다. 모든 Array 오퍼레이션을 사용할 수 있으나 약간 느리다. 새로운 삽입과 삭제는 평균적으로 상수 시간(constant time)이 소요되나 버퍼의 내용이 담긴 새로운 배열을 할당하기 때문에 가끔 선형적이다.

ArrayBuffer를 사용하려면, 먼저 가변 콜렉션 패키지에서 이를 임포트한다.

1
import scala.collection.mutable.ArrayBuffer

ArrayBuffer를 생성할 때는 반드시 타입 매개변수를 기술해야 한다. 그러나 길이는 기술하지 않아도 된다. ArrayBuffer는 필요에 따라 할당된 공간이 자동으로 조정된다.

1
val buf = new ArrayBuffer[Int]()

+= 메소드로 append할 수 있다.

1
2
3
buf += 12 // buf.type = ArrayBuffer(12)
buf += 15 // buf.type = ArrayBuffer(12, 15)
buf // ArrayBuffer(12, 15)

일반적인 배열 메소드를 모두 사용할 수 있다. 예를 들면, 다음과 같다.

1
2
buf.length // 2
buf(0) // 12

String (via StringOps)

많은 시퀀스 메소드가 구현된 StringOps 시퀀스를 주목해 보자. PredefString에서 StringOps로 암묵적인 변환을 수행하기 때문에 다음 예제처럼 어떤 문자열도 시퀀스처럼 다룰 수 있다.

1
2
3
4
def hasUpperCase(s: String) = s.exists(_.isUpper)
  
hasUpperCase("Robert Frost") // true
hasUpperCase("e e commings" // false

hasUpperCase 메소드 바디에 있는 문자열 s에 호출된 exists 메소드는 문자열 객체에 존재하지 않는다. Scala 컴파일러가 암묵적으로 s를 이 메소드를 가진 StringOps로 변환하고, exists 메소드는 문자열을 캐릭터 시퀀스로 다룬다.

17.2 Sets and maps

기본적인 set과 map은 3.10장에서 살펴보았다.

Scala에서는 가변/불변 set과 map을 제공한다. set의 계층 구조는 48쪽 그림 3.2에, map의 계증 구조는 50쪽의 그림 3.3에 나타나 있다. 그림에서 보다시피 SetMap이라는 이름은 각각 다른 패키지에 존재하며 3개의 trait에 의해 사용된다.

set 계층구조

set 계층구조

SetMap이라고 쓰면 기본적으로 불변 객체를 얻게 된다. 가변 객체는 명시적으로 임포트해야 한다. Scala에서는 모든 소스에 암묵적으로 임포트되는 Predef 객체로 다음과 같이 불변 객체에 쉽게 접근할 수 있다.

1
2
3
4
5
6
7
object Predef {
  type Map[A, +B] = collection.immutable.Map[A, B]
  type Set[A] = collection.immutable.Set[A]
  val Map = collection.immutable.Map
  val Set = collection.immutable.Set
  // ...
}

type 키워드는 긴 FQN(fully qualified name)에 대한 별칭으로 SetMap을 정의하기 위해 Predef에서 사용되고 있다. MapPredef.Map과 같고, Predef.Mapcollection.immtuable.Map과 같다. 이는 Map 타입과 객체에 대해 동일하다.

한 소스 파일에서 가변/불변 set 또는 map을 같이 사용하려면 가변형(mutable variant)를 지닌 패키지 이름을 임포트한다.

1
import scala.collection.mutable // import scala.collection.mutable

이제 불변 set은 Set으로 참조, 가변 set은 mutable.Set으로 참조된다.

1
val mutaSet = mutable.Set(1, 2, 3) // Set(3, 1, 2)

Using sets

set의 키(key)는 == 에 의해 결정되고 많아봐야 (set 내에서) 하나뿐인 객체임을 보장하는 특성이 있다.

예제로 한 문자열 내에 다른 단어들의 개수를 세기 위해 set을 사용해보자.

단어 구분자로 공백과 구두점을 기술할 경우, 문자열에서 split 메소드는 한 문자열을 여러 단어로 분리할 수 있다. 구분자 정규표현식은 [!,.]+로 충분하다.

1
2
val text = "See Spot run. Run, Spot. Run!"
val wordArray = text.split("[!,.]+")  // Array(See, Spot, run, Run, Spot, Run)

고유한 단어의 개수를 세기 위해, 이들을 같은 케이스로 변환한 다음, 이들을 set에 추가한다. set은 중복된 값들을 배제하기 때문에 각각 구분된 단어는 set에서 정확하게 한번 나타난다.

먼저, empty 메소드를 사용해서 빈 set을 생성해보자.

1
val words = mutable.Set.empty[String] // scala.collection.mutable.Set[String] = Set()

이후 for 문으로 단어에 대해 반복해가며 각 단어를 소문자로 변환하고 이를 += 연산자로 가변 set을 추가한다.

1
2
3
for (word <- wordsArray)
  words += word.toLowerCase
words // Set(spot, run, see)

따라서, 이 텍스트는 정확하게 3개의 구분된 단어 (spot, run, see)를 포함한다는 것을 알 수 있다.

가변/불변 set에서 가장 일반적으로 사용되는 메소드가 표 17.1에 제시되어 있다. (p. 346)

Using maps

map은 콜렉션의 각 요소와 값을 연결 짓는다. map은 0부터 시작하는 정수가 아닌 어떤 종류의 key를 쓸 수 있는 것을 제외하면 배열을 사용하는 것과 비슷하다.

예제로 scala.collection.mutable 패키지를 임포트해서, 빈 가변 map을 생성해보자.

1
val map = mutable.Map.empty[String, Int] // scala.collection.mutable.Map[String, Int] = Map()

map을 생성할 때는 두 개의 타입을 기술해야 한다. 첫째 타입은 map의 key에 대해 두 번째 타입은 value에 대한 것이다.

map의 엔트리를 세팅하는 것은 배열의 엔트리를 세팅하는 것과 비슷하다.

1
2
3
map("hello") = 1
map("there") = 2
map // Map(hello -> 1, there -> 2)

map을 읽는 것도 배열을 읽는 것과 비슷하다.

1
map("hello") // 1

모두 조립하면 문자열에서 각 단어가 나타나는 회수를 세는 메소드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def countWords(text: String) = {
  val counts = mutable.Map.empty[String, Int]
  for(rawWord <- text.split("[,!.]+")) {
      val word = rainWord.toLowerCase
      val oldCount =
          if (counts.contains(word)) counts(word)
          else 0
      counts += (word -> (oldCount + 1))
  }
  counts
}

countsWords("See Spot run! Run, Spot, Run!") // Map(see -> 1, run -> 3, spot -> 2)

Defaults sets and maps

대개의 경우는 `Set(), scala.collection.mutable.Map()‘ 등의 팩토리 메소드에 의해 제공되는 가변/불변 set과 map의 구현체들로 충분하다. 이런 팩토리 메소드에 의해 제공된 구현체들은 해쉬 테이블을 포함, 빠른 룩업 알고리즘을 사용하여 콜렉션에 객체가 있는지 없는지 재빨리 결정한다.

예를 들어, scala.collection.mutable.Set() 팩토리 메소드는 해쉬테이블을 내부적으로 사용하는 scala.collection.mutable.HashSet를 반환하고, 비슷하게 scala.collection.mutable.Map() 팩토리 메소드는 scala.collection.mutable.HashMap을 반환한다.

불변 set과 map은 좀 더 많은 팩토리 메소드가 있다. 예를 들어, scala.collection.immutable.Set() 팩토리 메소드에 의해 반환되는 클래스는 표 17.3 (p. 350) 처럼 얼마나 많은 요소를 전달하는지에 따라 달라진다.

5개의 요소보다 작은 set의 경우, 성능을 최대화하기 위한 특별한 클래스가 각 특정 크기의 set에 대해 전적으로 할당된다. 5개나 혹은 그 이상의 요소를 지닌 set을 요청할 경우, 팩토리 메소드는 해싱을 사용하는 구현체(HashSet)를 반환한다.

비슷하게 scala.collection.immutable.Map() 팩토리 메소드는 표 17.4 (p. 350)처럼 전달하는 key-value 쌍이 얼마나 많은 지에 따라 다른 클래스를 반환한다. map도 성능을 최대화하기 위해 set과 마찬가지로 5개의 요소보다 작은 map의 경우 각 특정 크기의 map이 전적으로 할당된다. 5개 이상의 요소를 지난 map을 요청할 경우, 팩토리 메소드는 해싱을 사용하는 구현체(HashMap)를 반환한다.

EmtpySet에 요소를 하나 추가하면, Set1를 반환한다. Set1에 요소를 추가하면 Set2를 반환한다. Set2에서 요소를 하나 삭제하면, 다른 Set1을 얻게 된다.

Sorted sets and maps

가끔 set과 map의 반복자가 특정한 순서대로 요소를 반환하는 필요가 있다. 이를 위해 Scala 콜렉션 라이브러리는 SortedSetSortedMap을 제공한다. 이런 traitTreeSetTreeMap 클래스에 의해 구현된다. 이들은 순서대로 요소나 키를 유지하는 레드-블랙(red-black) 트리를 사용한다.

순서는 Ordered 트레이트에 의해 결정되는데, 이들은 set의 요소 타입 혹은 map의 키 타입이며 반드시 섞여지거나 암묵적으로 변경 가능하는 것들이어야 한다. 이런 클래스는 가변형만 제공하고 있다.

TreeSet 예제

1
2
3
import scala.collection.immutable.TreeSet
val ts = TeeSet(9, 3, 1, 8, 0, 2, 7, 4, 6, 5)
val cs = TreeSet('f', 'u', 'n')

TreeMap 예제

1
2
3
4
import scala.collection.immutable.TreeMap
var tm = TreeMap(3 -> 'x'. 1 -> 'x', 4 -> 'x')
tm += (2 -> 'x')
 // (Map 1 -> x, 2 -> x, 3 -> x,  4 -> x)

17.3 Selecting mutable versus immutable collections

불변 콜렉션을 가변 콜렉션으로 또는 엮으로 스위칭하는 쉬운 방법은 Scala의 문법적 도구(syntactic sugar)를 이용하는 것이다. 가변 set과 map이 += 메소드를 제공하지 않지만, Scala는 +=에 대해 선택적인 해석을 한다. a += b를 쓰면, a+=라는 이름의 메소드를 제공하지 않으므로 Scala는 a = a + b처럼 해석하려고 한다.

예를 들면, 불변 set은 += 연산자를 제공하지 않는다.

1
2
val people = Set("Nancy", "Jane") // Set(Nancy, Jane)
people += "Bob" // error: reassignment to val

그러나 val 대신에 var로 선언하면, 불변 콜렉션이라고 해도 += 연산자에 의해 갱신될 수 있다. 새 콜렉션이 생성되면, people은 새로운 콜렉션을 참조하기 위해 재할당된다.

1
2
3
var people = Set("Nancy", "Jane") // Set(Nancy, Jane)
people  += "Bob"
people // Set(Nancy, Jane, Bob)

이후 people변수는 추가된 문자열 “Bob”을 포함하는 새로운 불변 set을 참조한다.

동일한 방법을 += 메소드가 아닌 =로 끝나는 모든 메소드에 적용할 수 있다. 다음은 set의 요소를 삭제하는 -= 연산자와 set에 콜렉션을 추가하는 ++= 연산자를 사용한 같은 문법이다.

1
2
3
people  -= "Jane"
people ++= List("Tom", "Harry")
people // Set(Nancy, Bob, Tom, Harry)

얼마나 유용한 지 보기 위해, 1.1장의 Map 예제를 보자.

1
2
3
var capital = Map("US" -> "Washington", "France" -> "Paris")
capital += ("Japan" -> "Tokyo")
println(capital("France))

이 코드는 불변 콜렉션을 사용한다. 가변 콜렉션을 사용하고 싶다면, Map의 가변 버전을 임포트하면 되는데, 이는 불변 Map의 기본 임포트를 오버라이딩한다.

1
2
3
4
import scala.collection.mutable.Map // 이것만 바꾸면 됨.
var capital = Map("US" -> "Washington", "France" -> "Paris")
capital += ("Japan" -> "Tokyo")
println(capital("France"))

그런데 이러한 문법적인 조치는 콜렉션이 아닌 모든 종류의 값에 동작한다. 예를 들면, 다음은 부동소수점 숫자에 사용하였다.

1
2
3
4
var roughlyPi = 3.0 // Double 3.0
roughlyPi += 0.1
roughlyPi += 0.04
roughlyPi // 3.14

이 확장의 효과는 Java의 할당 연산자인 +=, -=, *=와 비슷하나 좀 더 제너럴한데, 왜냐하면 =로 끝나는 모든 연산자들이 변경될 수 있기 때문이다.

17.4 Initializing collections

컴패니언 객체(Companion Object) 이름 뒤의 중괄호안에 요소를 적으면, Scala 컴파일러는 이를 그 동료 객체에 apply 메소드를 호출하도록 변환한다. (※. 참고 Companion Object)

1
2
3
4
5
6
7
List(1, 2, 3)
Set('a', 'b', 'c')

import scala.collection.mutable
mutable.Map("hi" -> 2, "there" -> 5)

Array(1.0, 2.0, 3.0)

대부분 Scala 컴파일러가 자신의 팩토리 메소드로 전달되는 요소로 콜렉션의 요소 타입을 추론하나, 콜렉션을 만든 후, 컴파일러가 선택하는 다른 타입을 기술하고 싶을 때도 있다.

이는 가변 객체에 대해선 다음과 같은 이슈를 제기한다.

1
2
3
import scala.collection.mutable
val stuff = mutable.Set(42)
stuff += "abracadabra" // error: type mismatch;

여기서 문제는 stuffInt 타입이 주어진 것이다. Any 타입을 가지고 싶으면, 아래처럼 꺽쇠 괄호에 요소의 타입을 넣어줌으로써 명시적으로 말할 필요가 있다.

1
val stuff = mutable.Set[Any](42)

다른 특별한 경우는, 다른 콜렉션으로 콜력션을 초기화하고 싶을 경우이다. 예를 들면, 리스트가 있고 이 리스트의 요소를 지니는 TreeSet을 원할 경우이다.

1
val colors = List("blue", "yellow", "red", "green")

TreeSet에 팩토리 메소드로 colors 리스트를 전달할 수 없다.

1
2
import scala.collection.immutable.TreeSet
val treeSet = TreeSet(colors) // error: cloud not find implicit value for parameter ord: Ordering[List[java.lang.String]]

대신에, 빈 TreeSet[String]을 만들고 여기에 TreeSet++ 연산자로 리스트 요소를 추가할 수 있다.

1
val treeSet = TreeSet[String]() ++ colors

Converting to array or list

콜렉션으로 리스트나 배열을 초기화하는 것은 간단하다. 콜렉션의 toListtoArray를 호출하면 된다.

1
2
3
treeSet.toList // List(blue, green, red, yellow)
  
treeSet.toArray // Array(blue, green, red, yellow)

원래 colors 리스트는 정렬되지 않았지만, TreeSettoList를 호출하여 생성된 리스트의 요소들은 알파벳 순서대로 정렬된다.

콜렉션에 toListtoArray를 호출하면 결과 리스트나 배열 요소의 순서는 콜렉션의 elements의 호출하여 얻어지는 반복자에 의해 생성된 요소의 순서와 동일하다. 왜냐하면 TreeSet[String]의 반복자가 알파벳 순서로 문자열들을 생성하기 때문이다.

리스트나 배열로의 변환은 콜렉션의 모든 요소를 복사해야 하므로 큰 콜렉션에 대해서 느릴 수 있다는 것을 기억하자.

Converting between mutable and immutable sets and maps

가변 set과 map을 불변의 것으로 또는 그 엮으로 변경할 필요가 있다. 이를 위해 리스트의 요소를 TreeSet을 초기화하기 위해 이전의 테크닉을 사용할 수 있다.

empty 메소드를 사용해서 새로운 타입의 콜렉션을 생성하고, ++ 혹은 ++=를 사용해서 타겟 콜렉션 타입에 대해 적절한 새로운 요소를 추가한다.

1
2
3
4
import scala.collection.mutable
treeSet // TreeSet(blue, green, red, yellow)
val mutaSet = mutable.Set.empty ++= treeSet // Set(yellow, blue, red, green)
val immutaSet = Set.empty ++ mutaSet // Set(yello, blue, red, green)

가변과 불변 map에도 같은 변경 테크닉을 사용할 수 있다.

1
2
val muta = mutable.Map("i" -> 1, "ii" -> 2)
val immu = Map.empty ++ muta // Map(li -> 2, i -> 1)

17.5 Tuples

3.9장에 설명된 것처럼, 튜플은 고정된 개수의 아이템을 조합하여 전체를 싸잡아 전달할 수 있다. 다음은 정수, 문자열, 그리고 console을 가지는 튜플이다.

1
(1, "hello", Console)

단순하기 그지없는 육중한 데이터(data-heavy) 클래스를 정의하는 지루한 작업은 튜플로 피할 수 있다. 튜플은 클래스 이름을 고른다든지, 클래스가 정의될 범위를 고른다든지, 클래스 멤버의 이름을 고르는 노력을 덜어준다.

다른 타입의 객체를 조합할 수 있기 때문에, 튜플은 Traversable을 상속하지 않는다. 하나의 정수와 정확히 하나의 문자열의 그룹을 만들 때는 ListArray가 아닌 튜플이면 된다.

튜플의 일반적인 응용은 메소드가 여러 개의 값들을 반환할 때이다. 예를 들면, 다음은 콜렉션에서 가장 긴 단어를 찾고 그 인덱스를 반환하는 메소드이다.

1
2
3
4
5
6
7
8
9
10
def longestWord(words: Array[String]) = {
  var word = words(0)
  var idx = 0
  for ( i <- 1 until words.length)
      if(words(i).length > word.length) {
          word = words(i)
          idx = i
      }
  (word, idx)
}

이 메소드는 다음과 같이 사용한다.

1
val longest = longestWord("The quick brown forx".split(" ")) // (quick, 1)

longestWord 함수는 두 개의 아이템을 계산한다. 배열에서 가장 긴 단어인 word와 그 단어의 인덱스인 idx가 그것이다.

문제를 단순화하기 위해 함수는 리스트에 적어도 하나의 단어가 있다고 가정하고 리스트에서 빨리 나오는 단어를 선택하는 방법으로 끈(tie)을 끊는다. 함수가 반환할 단어와 인덱스를 선택하면, 튜플 문법 (word, idx)를 사용해서 이들 모두를 함께 반환한다.

튜플 요소에 억세스하기 위해 첫번째 요소는 _1 메소드로, 두번째 요소는 _2 메소드 등등을 사용한다.

1
2
longest._1 // quick
longest._2 // 1

또한, 튜풀의 각 요소를 고유의 변수에 할당할 수 있다.

1
2
val (word, idx) = longest
word // quick

그러나 괄호를 없애면 다른 결과를 얻게 된다.

1
val word, idx = longest // word: (String, int) = (quick, 1) idx: (String, Int) = (quick, 1)

이 문법은 같은 표현을 중복해서 정의한다. 각 변수는 오른쪽 표현식을 각각 평가하여 초기화된다. 두 변수 모두 완전한 형태로써 튜플에 대해 초기화된다.

튜플은 “A와 B” 그 이상의 의미가 없는 데이터를 조합하는데 좋다. 그러나 조합이 어떤 의미를 가지게 되어 메소드가 추가될 때는 좀 더 나아가 클래스를 생성하는 것이 좋다. 예를 들면 년, 월, 일의 조합하는데 3개짜리 튜플을 사용하지 말고 사람과 컴파일러 모두 가독성과 실수를 잡기에 좋도록 좀 더 명확한 의도를 지닌 Date 클래스를 만들자.

17.6 Conclusion

Scala 콜렉션에 대해 좀 더 자세한 정보는 24장과 25장을 살펴보길 바란다. 이제부터 다음장에서는 Scala 라이브러리에서 돌아와 언어 (자체)에 주의를 집중하여 불변 객체에 대한 Scala의 지원을 논의할 것이다.

The Mores

Copyright © 2014 - Patrick Yoon. Powered by Octopress