Scala의 Stream 클래스는 리스트와 같은 컬렉션을 지연 평가를 이용해 효율적으로 다루도록 해줍니다. Scala By Example에도 소개되어 있지만, 스칼라 스터디[각주:1] 진행 당시 급하게 지나간 느낌이 있어 블로그를 통해 Stream의 기본적인 내용을 보다 차분하게 공유해보려 합니다. 본문에서는 존칭을 생략합니다.

Stream 객체의 생성

 백문이 불여일견이니 일단 예제를 보고 시작하자.
scala> Stream.cons(1, Stream.cons(2, Stream.empty))        // (1)
res0: Stream.Cons[Int] = Stream(1, ?)

scala> Stream.range(1,10)                                  // (2)
res1: scala.collection.immutable.Stream[Int] = Stream(1, ?)
 Stream의 생성은 기본적으로 Stream.cons( head:A, tail:Stream[A])의 형태로 이루어진다. List의 cons(::)에 익숙하다면 List와 유사하나 Nil 대신 Stream.empty를, :: 대신 Stream.cons를 사용했다고 보면 된다. 하지만 Stream은 List와는 달리 실제 호출하기 전까지는 tail을 평가하지 않는다는 특징을 가지고 있는데, 이를 이용하면 기존 List에서는 할 수 없던 일들이 가능해진다.

 실제로 (1),(2)의 출력 결과를 살펴보면 (1, ?)의 형태로 tail에 해당하는 부분은 ?이 출력되어 아직 평가되지 않았음을 알려준다. 그러면 (1)의 결과에 tail을 호출해 ?부분의 평가 결과를 확인해보자.
scala> res0.tail                 // res0 = Stream.cons(1, Stream.cons(2, Stream.empty))
res2: scala.collection.immutable.Stream[Int] = Stream(2, ?)
(1)에서 ?로 표시되었던 부분이 다시 (head, tail)로 평가되어 (2, ?)가 출력됐다.

(2)에서 Stream.range()가 제공된다는 사실을 확인했지만, 다음과 같이 직접 구현해도 된다.
def range(start: Int, end: Int): Stream[Int] =
  if (start >= end) Stream.empty   
  else Stream.cons(start, range(start + 1, end))
 range(1,3) 의 경우, range의 재귀호출을 따라가면 Stream.cons(1, range(2,3)) == Stream.cons(1, Stream.cons(2, range(3,3))... 가 되어 결국 결국 Stream.cons와 Stream.empty를 사용한 생성 구문이 된다.

call-by-name을 이용한 지연 평가

Stream은 지연 평가를 어떻게 구현했을까? Stream의 소스 코드를 살펴보면 간단하게 확인할 수 있다.
object Stream {
  object cons {
    def apply[A](head : A, tail : => Stream[A]) : Stream[A] {
       // ...
    }
  }
  // ...
}
 cons가 메서드[각주:2]가 아닌 inner object로 구현되어 있다는 사실도 흥미롭다. 내부의 cons object에 apply를 정의해 마치 cons를 메서드처럼 활용했다. 하지만 핵심은 무엇보다도 apply의 인자 tail이 by-name 타입으로 정의되어 있다는 점이다. 따라서 인자 tail은 호출 전까지는 평가되지 않는다.  

  tail의 타입인 => Stream[A]은 Stream 객체를 반환하는 인자 없는 함수 타입, 즉 ()=>Stream과 유사하다고 이해해도 되는데, 함수의 경우 호출하기 전까지는 코드 블럭을 수행하지 않으므로 태생부터 지연 평가에 적합하다는 특성을 갖고 있다. tail의 by-name 타입은 여러가지로 ()=> Stream[A]과 유사하지만, 함수가 아닌 Stream 평가식을 인자로 전달해야 한다는 차이가 있다. Stream 객체를 인자 tail로 전달하는 것은 문제가 없지만, tail에 함수 타입인 ()=>Stream형 인자를 전달하면 type mismatch 에러가 발생한다.  

Stream 활용 예

실제로 Stream을 이용해 작업을 수행해보자. 1~10000의 정수에서 짝수를 작은 순서 대로 5개를 고르는 작업을 Stream을 이용하면 다음과 같이 처리하면 된다.
scala> Stream.range(1,10000)  // 1~10000 범위를 갖는 Stream 생성
res1: scala.collection.immutable.Stream[Int] = Stream(1, ?)

scala> res1.filter( _ % 2 == 0 )   // predicate을 전달하여 짝수인 경우만 필터링
res2: scala.collection.immutable.Stream[Int] = Stream(2, ?)

scala> res2.take(5)    // 앞에서 5개를 취함
res3: scala.collection.immutable.Stream[Int] = Stream(2, ?)

scala> res3.print      // 해당 내용을 출력
2, 4, 6, 8, 10, empty

Stream의 메서드는 List가 제공하는 메서드와 상당히 유사하다. List와 Stream은 모두 LinearSeq를 구현한 immutable한 컬렉션이기 때문이다. 아래 그림은 Scala 컬렉션의 기본적인 구조[각주:3]이다. Immutable/mutable 컬렉션이 모두 공유하는 구조이므로 참고하자.


 List를 활용한 것과 다른 점은 무엇인지 확인하기 위해 Stream 대신 List를 사용해 각 단계를 재현해보자.
scala> List.range(1,10000)
res4: List[Int] = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35
, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54
, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73
, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92
, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, ... 
시작부터가 Stream과 다르다. 1~10000 범위 전체를 담은 List가 우선 생성된다. 그 다음은 필터링 차례이다.
scala> res4.filter( _ % 2 == 0)
res5: List[Int] = List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30,
 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 
70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, ...
이 경우도 1~10000의 범위에 존재하는 짝수로 이루어진 List가 생성된다. 앞에서 고작 5개를 얻기 위해 사전에 너무 많은 작업을 수행했다는 사실을 확인할 수 있다. Stream을 활용한 작업이 훨씬 효율적이다.

지연 평가를 활용하는 Stream은 우선 List가 감당하기 힘든 크기의 수열을 표현할 수 있다.
scala> Stream.range(1, Integer.MAX_VALUE - 1)    
res6: scala.collection.immutable.Stream[Int] = Stream(1, ?)
더 나아가 자연수 전체와 같은 무한 수열 또한 표현 가능하다.
scala> def rangeFrom(x:Int):Stream[Int] = {
     |   Stream.cons(x, rangeFrom(x+1)) }

scala> val naturalNumber = rangeFrom(1)
naturalNumber: Stream[Int] = Stream(1, ?)
다음과 같이 표현할 수도 있다.
scala> val naturalNumber: Stream[Int] = Stream.cons(1, naturalNumber.map(_ + 1))
naturalNumber: Stream[Int] = Stream(1, ?)
Stream을 활용해 푸는 수학 문제가 여럿 있는데, 피보나치 수열[각주:4]의 경우 다음과 같이 비교적 간단하게 표현할 수 있다. Stream의 zip, map 또한 그 결과가 Stream이라는 사실에 유의하며 살펴보면 이해가 빠르다.
scala> val fibonacci: Stream[Int] = Stream.cons(0, Stream.cons(1, 
     |   fibonacci.zip(fibonacci.tail).map(zipped => zipped._1 + zipped._2)))
fibonacci: Stream[Int] = Stream(0, ?)

scala> fibonacci.take(10).print
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, empty
  1. 스터디 멤버 중 아웃사이더 님은 http://blog.outsider.ne.kr에서 왕성한 블로그 활동 중 [본문으로]
  2. Stream.cons는 T #:: Stream[T]와 같이 #:: 로도 표현 가능함 [본문으로]
  3. http://www.scala-lang.org/docu/files/collections-api/collections.html 에서 인용 [본문으로]
  4. 0, 1로 시작하며 앞의 두 수의 합으로 이루어진 수열 [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
« PREV : 1 : 2 : 3 : 4 : 5 : 6 : 7 : ··· : 87 : NEXT »

티스토리 툴바