엄격성과 나태성 (Lazy, Stream)

Lazysoul
12 min readSep 26, 2016

--

이번에 정리한 내용은 “스칼라로 배우는 함수형 프로그래밍” 5장을 기본으로 작성한 내용입니다. 이번 장에서는 Stream을 직접 구현해보면서 Stream에 대해 설명합니다.

이 표현식에서 map(_ + 10)은 임시적인 리스트를 생성합니다. 리스트는filter(_ % 2 == 0)으로 전달되며, filter는 새로 목록을 만들어서 map(_ * 3)으로 넘겨줍니다. 그리고 최종 목록이 만들어 집니다. 각 변환은 자신만의 새 목록을 생성하며, 그 목록은 다음 변환의 입력으로만 쓰인 후 즉시 폐기됩니다.

이런 식으로 추적해 보면 각각의 map, filter 호출이 자신의 입력을 어떤 식으로 받는지, 그리고 출력을 위한 리스트를 어떻게 할당하는지 확실하게 알 수 있습니다. 이런 일련의 변환들을 하나의 패스로 융합(fusion)해서, 임시 자료구조들의 생성을 피할 수 있다면 좋을 것 입니다. 이를 위해 직접 while 루프를 작성할 수도 있겠지만, 그 보다는 지금처럼 고수준 합성 스타일을 유지하면서 그러한 통합이 자동으로 일어나게 하는 것이 이상적입니다. 획일적인 루프를 작성하기보다는 , map과 filter 같은 고차 함수를 이용해서 프로그램을 합성하는 것이 바람직한 방식이기 때문입니다.

비엄격성(non-strictness) 좀 더 비공식적으로는 나태성(laziness)을 이용하면 이런 종류의 자동적인 루프 융합이 가능해집니다. 이번 장에서는 비엄격성이 정확히 무엇인지 설명하고 변환들의 순차열을 융합하는 게으른(lazy) 리스트 형식을 구현해 볼 것입니다.

엄격한 함수와 엄격하지 않은 함수

엄격성과 비엄격성이 무엇이고 그런 개념을 스칼라에서 어떻게 표현할까?

비엄격성은 함수의 한 속성입니다. 함수가 엄격하지 않다는 것은 그 함수가 하나 이상의 인수들을 평가하지 않을 수도 있다는 뜻 입니다. 반면 엄격한 함수는 자신의 인수들을 항상 평가합니다. 대부분의 프로그래밍 언어에서는 엄격한 함수가 기본입니다. 스칼라에서도 크게 다르지 않습니다.

square(41.0 + 1.0) 으로 호출하면 , 함수 square는 엄격한 함수이므로 평가된 값인 42.0 을 받게 됩니다. 평가라는 단어는 바로 호출해서 값을 사용한다는 의미로 해석하시면 좋을 것 같습니다.

square(sys.error(“failure”)) 라고 호출하면 square가 실제로 작업을 수행하기도 전에 예외가 발생합니다. sys.error(“failure”))라는 표현식이 square의 본문에 진입하기 전에 평가되기 때문입니다.

비엄격 함수라는 개념에는 이미 익숙할 것입니다. 여러 프로그래밍 언어에서 볼 수 있는 부울 함수 && 와 ||의 단축 평가는 엄격하지 않습니다. && 와 ||를 언어의 일부인 내장 구문이라고 생각하는 데, 이들을 인수의 평가가 생략될 수도 있는 함수라고 생각해도 틀리지 않습니다. &&함수는 Boolean 인수 두 개를 받되 첫째 인수가 true일 때에만 둘 째 인수를 평가합니다.

스칼라에서 if가 내장언어 구조이긴 하지만, 이를 인수가 셋인 함수로 생각할 수도 있습니다. 첫째 인수는 형식이Boolean인 조건 표현식이고 둘째 인수는 그 조건이 true이면 A형식의 값을 돌려주는 표현식, 셋째 인수는 조건이 false이면 같은 A형식의 값을 돌려주는 표현식입니다. if 함수는 자신의 모든 인수를 평가하지는 않는다는 점에서 비엄격 함수입니다. 좀 더 정확히 말하면 if 함수는 조건 매개변수에 대해서는 엄격합니다. 두 분기 중 어떤 것을 취할 것인지 결정하려면 조건을 반드시 평가해야 하기 때문입니다.

스칼라에서는 인수들 중 일부가 평가되지 않아도 호출이 성립하는 비엄격 함수를 작성 할 수 있습니다.

이 예에서 보듯이, 평가되지 않은 채 전달될 인수에는 해당 형식 바로앞에 () => 를 표기해줍니다. () => A는 인수를 받지 않고 A를 돌려주는 함수입니다. 일반적으로, 표현식의 평가되지 않는 형태를 성크(thunk)라고 부릅니다. 나중에 그 성크의 표현식을 평가해서 결과를 내도록 강제할 수 있습니다. onTrue()나 onFalse() 에서처럼 빈 인수 목록을 지정해서 함수를 호출하면 됩니다. 마찬가지로, if2의 호출자는 성크를 명시적으로 생성해야 합니다.

전반적으로 이러한 구문은 각각의 비엄격 매개수에 대해 인수가 없는 함수를 넘겨주고, 함수 본문에서는 그 함수를 명시적으로 호출해서 결과를 얻는다는 작동 방식을 명확하게 표현합니다.

이 구문에서, 평가되지 않은 채로 전달 할 인수에는 그 형식 바로 앞에 화살표 => 만 붙입니다. 함수의 본문에서는 =>로 지정된 인수를 평가하는 데 어떤 특별한 구문이 필요하지 않습니다. 그냥 평소대로 해당 식별자를 참조하면 됩니다. 이 함수를 호출하는 데에도 특별한 구문이 필요하지 않습니다. 그냥 보통의 함수 호출 구문을 사용하면 스칼라가 성크 안의 표현식을 알아서 감싸줍니다.

두 구문 모두에서, 평가되지 않은 채로 함수에 전달되는 인수는 함수의 본문에서 참조된 장소마다 한 번씩 평가됩니다. 즉, 스칼라는 인수 평가의 결과를 캐싱하지 않습니다.

여기서 i는 maybeTwice의 본문 안에서 두 번 참조됩니다. 참조될 때마다 평가된다는 점이 확실히 드러나도록, 위에 예에서는 결과 42를 돌려주기 전에 하나의 부수 효과로서 hi를 출력하는 {println(“hi”); 1+41} 블록을 i 로서 전달했다. 만일 캐싱을 적용해서 결과가 단 한번만 평가되게 하려면 다음과 같이 lazy 키워드를 이용하면 됩니다.

val 선언에 lazy 키워드를 추가하면 스칼라는 lazy val 선언 우변의 평가를 우변이 처음 참조될 때까지 지연한다. 또한 평가 결과를 캐시에 담아 두고, 이후의 참조에서는 평가를 되풀이하지 않습니다.

스칼라에서 비엄격 함수의 인수는 값으로(by value) 전달되는 것이 아니라 이름으로 (by name) 전달됩니다.

확장 예제: 게으른 리스트

함수적 프로그래밍의 효율성을 스트림(stream)과 게으른 리스트(lazy list) 를 이용해서 개선하는 방법을 살펴보겠습니다.

이 형식은 이전의 List와 거의 비슷합니다. 단 Cons 자료 생성자가 보통의 엄격한 값이 아니라 명시적인 성크(() => A와 ()=> Stream[A])를 받는 다는 점이 다릅니다.

Stream 에서 head(없을 수도 있는)를 추출하는 함수. h()를 이용해서 h를 명시적으로 강제해야 함. 실제로 요구된 부분만 평가 하기때문에 tail은 평가하지 않습니다.

스트림의 메모화를 통한 재계산 피하기

Cons 노드가 일단 강제 되었다면 그 값을 캐싱해 두는것이 바람직합니다. 예를들어 다음과 같이 cons 자료 생성자를 직접 사용한다면 expensive(x)가 두 번 계산 됩니다.

일반적으로 이런 문제는 추가적인 불변식(invariant)을 보장하거나 패턴 부함에 쓰이는 ‘실제’ 생성자와는 조금 다른 서명을 제공하는 자료 형식을 생성하는 함수인 똑똑한 생성자를 이용해서 피합니다. head와 tail 을 이름으로 전달받아서 메모화(memorization)를 수행합니다. 이는 흔히 쓰이는 기법으로, 이렇게 하면 성크는 오직 한 번만 평가되고 이후의 강제에서는 캐싱된 lazy val이 반환 됩니다.

empty 의 똑똑한 생성자는 그냥 Empty를 돌려주거나, Empty의 형식이 Stream[A]로 지정되어 있습니다.

인수들을 cons 안에서 성크로 감싸는 작업은 스칼라가 처리해 줍니다. 따라서 as.head 와 apply(as.tail: _*) 표현식은 Stream을 강제할 때까지는 평가되지 않습니다.

스트림의 조사를 위한 보조 함수들

스트림을 좀 더 편하게 조사하기 위한 보조 함수 몇 가지를 작성 해보자.

연습문제 Stream을 List로 변환하되 평가를 강제해서 REPL로 목록의 요소들을 볼 수 있게 하는 함수를 작성 하라. 표준 라이브러리에 있는 정규 List 형식으로 변환하면 된다.

연습문제 Stream의 처음 n개의 요소를 돌려주는 함수 take(n)과 Stream의 처음 n개의 요소를 건너뛴 스트림을 돌려주는 drop(n)을 구현하라.

연습문제 Stream에서 주어진 술어를 만족하는 선행 요소들을 모두 돌려주는 함수 takeWhile을 작성하라.

프로그램 서술과 평가의 분리

함수형 프로그래밍의 주된 주제 중 하나는 관심사의 분리(separation of concerns)입니다. 계산의 서술(description)을 그 계산의 실제 실행과 분리하는 것이 권장 됩니다. 예를 들어 일급 함수는 일부 계산을 자신의 본문에 담고 있으나. 그 계산은 오직 인수들이 전달되어야 실행 됩니다. 또한 Option은 오류가 발생했다는 사실을 담고 있을 뿐, 오류에 대해 무엇을 수행할 것인가는 그와는 분리된 관심사입니다. 그리고 Stream을 이용하면 요소들의 순차열을 생성하는 계산을 구축하되 계산 단계들의 실행은 실제로 요소가 필요할 때까지 미룰 수 있습니다.

좀 더 일반화해서 말하면, 나태성을 통해서 표현식의 서술을 그 표현식의 평가와 분리 할 수 있습니다. 그러면 필요한 것보다 ‘더 큰’ 표현식을 서술하되 그 표현식의 일부만 평가할 수 있습니다.

한 예로 Stream의 요소들 중 Boolean 함수와 부합하는 것이 하나라도 있는지 점검하는 함수 exists를 생각해 보면 다음과 같습니다.

||는 둘째 인수에 대해 엄격하지 않습니다. 만일 p(h())가 true 를 돌려준다면 exists 는 스트림을 더 훑지 않고 true를 돌려줍니다. 또한 스트림의 꼬리가 lazy val 이라는 점도 기억하기 바랍니다.

이 exists 함수의 구현은 명시적 재귀를 이용합니다. 그러나 제 3장의 List에서 보았듯이 일반적으로 재귀를 foldRight의 형태로 구현할 수 있습니다.

이 함수는 List에 대해 작성했던 fopldRight와 아주 비슷해 보이지만, 결합 함수 f가 둘째 매개변수에 대해 엄격하지 않다는 점에 주목하기 바랍니다.

true를 돌려준다면 b는 결코 평가되지 않으며, 계산이 일찍 종료됩니다.

연습문제 Stream의 모든 요소가 주어진 술어를 만족하는지 점검하는 forAll 함수를 구현하라. 만족하지 않는 값을 만나면 즉시 순회를 마쳐야 한다.

이 구현들이 점진적임을 주목하기 바랍니다. 즉, 이들은 결과 전체를 생성하지 않습니다. 결과 Stream의 요소들을 다른 어떤 계산이 참조하는 시점이 되어서야 그 Stream을 생성하는 계산이 실제로 진행됩니다.

Stream(1,2,3,4).map(_ + 10).filter(_ % 2 ==0)의 간단한 프로그램 추적을 살펴보자.

이 추적에서 주목할 것은 filter 변환들과 map 변환들이 엇갈리는 방식입니다. map 의 출력의 한 요소를 생성하는 계산과 그 요소가 2로 나누어지는지 filter로 판정하는 계산이 번갈아 수행됩니다.

filter는 위처럼 전체 스트림을 변환하긴 하지만 그 변환은 게으르게 일어납니다. find는 부합하는 요소를 발견하는 즉시 종료됩니다.

스트림 변환의 점진적 본성은 메모리 사용에도 중요한 영향을 미칩니다. 중간 스트림들이 생성되지 않으므로 메모리가 절약 됩니다.

무한 스트림과 공재귀

지금까지 작성한 함수들은 점진적이라서 무한 스트림(infinite stream)에도 사용할 수 있습니다. 다음은 1이 무한히 나열되는 stream의 예입니다.

ones는 무한하지만, 지금까지 작성한 함수들은 요구된 출력을 산출하는데 필요한 만큼만 스트림을 조사합니다.

그외에도 몇가지 더 시험 해 볼수 있습니다.

  • ones.map(_ + 1).exists(_ % 2 ==0)
  • ones.taskWhile(_ ==1)
  • ones.forAll(_ != 1)

세 경우 모두 결과가 즉시 나옵니다. 단 결코 종료되지 않거나 스택에 안전하지 않은 표현식이 만들어지기 쉬우니 조심해야 합니다.

그럼 스트림을 생성하는 다른 함수들도 살펴보자.

연습문제 ones를 조금 일반화해서, 주어진 값의 무한 Stream을 돌려주는 함수 constant를 구현하라

연습문제- n에서 시작해서 n +1, n+2 등으로 이어지는 무한 정수 스트림을 생성하는 함수를 작성하라.

연습문제- 무한 피보나치 수 0, 1, 1, 2, 3, 5, 8, …으로 이루어진 무한 스트림을 생성하는 함수 fibs를 작성하라.

연습문제 좀 더 일반화된 스트림 구축 함수 unfold를 작성하라. 이 함수는 초기상태 하나와 다음 상태 및 다음 값(생성된 스트림 안의)을 산출하는 함수 하나를 받아야 한다.

Option은 Stream이 종료되는 시점을 지정하는 데 쓰입니다.

unFold 함수는 소위 공재귀(corecursive)함수의 예입니다. 재귀 함수는 자료를 소비하지만 공재귀 함수는 자료를 생산합니다. 그리고 재귀는 점점 더 작은 입력으로 재귀하다가 종료되지만, 공재귀 함수는 생산성(procdutivity)을 유지하는 한 종료될 필요가 없습니다.

연습문제 - unfold를 이용해서 fibs, from, constant, ones를 작성하라

연습문제 unfold를 이용해서 map, take, takeWhile, zipWith, zipAll을 구현하라. zipAll 함수는 스트림에 요소가 더 있는 한 순회를 계속해야 한다. 각 스트림이 소진되었는지는 Option을 이용해서 지정한다.

연습문제 앞에서 작성한 함수들을 이용해서 startsWith를 구현하라. 이 함수는 한 Stream이 다른 한 Stream의 선행 순차열인지 점검해야 한다. 예를 들어 (1,2,3) startsWith Stream(1,2)는 true가 되어야 한다.

연습문제 unfold를 이용해서 tails를 구현하라. tails는 주어진 입력Stream과 그 후행 순차열 (suffix, 접미사) 들로 이루어진 스트림을 돌려준다. 예를들어 (Stream(1,2,3), Stream(2,3), Stream(3), Stream())을 돌려주어야 한다.

지금까지 작성한 함수들로 hasSubsequence를 구현할 수 있다.

연습문제(어려움) tails를 일반화한 scanRight 함수를 작성하라. 이 함수는 중간 결과들의 스트림을 돌려주는 foldRight와 비슷하다. 예:

결론

항상 그렇듯이 무엇인가가 제일 좋다. 는 없는 것 같습니다. 앞으로도 그럴것 같고요. 언제나 나태성이 좋다는 아니지만 나태성이 어떤 의미를 가지고 알고 있어야 적절 하게 사용할 수 있을 것입니다. 엄격성과 나태성을 적절한 상황에 맞게 사용한다면 메모리, 시간을 좀 더 효율적으로 사용 할 수 있을것 같습니다.

참조할만한 링크

Stream과 List를 비교

Contents

--

--