ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [FP In Scala] 외부 효과와 입출력
    Software Development/Scala 2021. 6. 6. 19:33

    모나드와 대수적 자료 형식으로 데이터베이스 조회나 파일 기록 같은 외부 효과를 처리하는 방법에 대해 알아본다.

     

    핵심은 효과 부수 효과의 구분이다.

     

    IO 모나드는 I/O효과를 수반한 명령식 프로그램을 참조 투명성(함수 외부의 영향을 받지 않는 것)을 어기지 않고 순수 함수적 프로그램에 내장하는 방법을 제공한다.

    이 모나드는 효과 있는(effectful) 코드, 즉 외부 세계에 일정한 영향을 미쳐야 하는 코드를 프로그램의 나머지 부분과 명확히 분리한다.

    또한 효과 있는 계산의 서술(description)을 순수 함수를 이용해서 계산하고, 그 서술을 개별적인 해석기를 이용해서 실행함으로써 실제로 효과를 수행한다는 것.

     

    이는 명령식 프로그래밍을 위한 내장 영역 국한 언어(embedded domain-specific language, EDSL)를 만드는 것. ex)jQuery, React, Embedded SQL, LINQ

     

    1. 효과의 추출

    아래의 부수 효과를 가진 프로그램(목록 1)의 예를 살펴보자.

    case class Player(name: String, score: Int)
    
    def contest(p1: Player, p2: Player): Unit =
      if (p1.score > p2.score)
        println(s"${p1.name} is the winner!")
      else if (p2.score > p1.score)
        println(s"${p2.name} is the winner!")
      else
        println("It's a draw.")

    contest 함수에는 결과의 표시를 위한 입출력 코드승자 결정을 위한 순수 논리가 엮여 있다.

    승자 결정 논리를 추출해서 개별적인 순수 함수 winner를 만들어 보자.

    // Option: 값이 있거나 또는 없거나 한 상태를 나타낼 수 있는 타입.
    // Some: 값이 담겨져 있는 Option 의 하위 타입은 Some[T], 값이 없으면 None.
    // 승자 결정 또는 무승부 판정을 위한 논리가 담겨 있다. 
    def winner(p1: Player, p2: Player): Option[Player] =
      if (p1.score > p2.score) Some(p1)
      else if (p1.score < p2.score) Some(p2)
      else None
    // 승자를 콘솔에 출력하는 책임을 진다.  
    def contest(p1: Player, p2: Player): Unit = winner(p1, p2) match {
      case Some(Player(name, _)) => println(s"$name is the winner!")
      case None => println("It's a draw.")
    }
    

    순수하지 않은 절차(procedure)에서 순수한 '핵심' 코드를 추출해서 하나의 순수 함수 두 개의 부수 효과 있는 절차를 만들어 내는 것은 항상 가능하다.

    여기서 두 개의  부수 효과 있는 절차란, 순수 함수의 입력을 제공하는 절차와 순수 함수의 출력으로 뭔가를 수행하는 절차이다.

    목록 1에서 contest에서 순수 함수 winner를 추출했다. contest에는 경기 결과 계산, 계산 결과 표시라는 두 가지 임무(책임)이 있다.

    리팩토링 된 코드에서 winner는 승자를 결정한다는 하나의 임무만 수행한다. 콘솔에 출력하는 책임은 contest 메서드에 남아 있다.

     

    contest 함수는 여전히 두 가지 임무를 수행한다.

    하나는 표시할 메세지를 계산하는 것, 하나는 그 메세지를 콘솔에 출력하는 것이다.

    현재의 코드에서 리팩토링을 통해 콘솔 출력 뿐만 아니라 결과를 UI나 파일에 기록할 수 있다면 좋을 것이다. 

    // 적절한 메세지를 결졍하는 책임을 가진 함수.
    def winnerMsg(p: Option[Player]): String = p map {
      case Player(name, _) => s"$name is the winner!"
    } getOrElse "It's a draw."
      
    // 메세지를 콘솔에 출력하는 임무를 가진 절차.
    def contest(p1: Player, p2: Player): Unit =
      println(winnerMsg(winner(p1, p2)))

    부수 효과 println은 프로그램의 최외각 계층에만 존재하며, println 호출의 내부에 있는 것은 순수한 표현식이다.

    단순한 예제이지만, 크고 복잡한 프로그램에서 동일한 원리가 적용되기 때문에 이 과정의 리팩토링을 잘 이해하는 것이 중요하다. 프로그램의 하는 일은 변하지 않았고, 그저 작은 함수들로 분할했다. 

    여기서 포인트는 모든 부수 효과가 있는 함수 안에는 빠져나오려고 하는 순수 함수가 있다는 것이다.

    이를 공식화하면 A => B 형식의 impure(불순) 함수 f가 있을 때, f를 다음 두 함수로 분할할 수 있다.

     

    • A => D 형식의 순수 함수. D는 f의 결과를 서술(description)한다.
    • D => B 형식의 불순 함수. 서술의 해석기(interpreter)에 해당한다.

    나중에 이를 확장하여 '입력' 효과를 처리할 것이다.

    지금의 이 전략을 적용할 때마다 새로운 순수 함수가 만들어지고, 부수 효과들은 점점 더 바깥 계층으로 밀려 나간다. 

    불순 함수들을 프로그램의 순수한 '핵심(core)'을 감싼 '명령식 외피(imperative shell)'라고 부르면 좋을 것이다. 이 전략을 거듭하다 보면 결국에는 String => Unit 형식의 내장 println 절차 같은 부수 효과가 꼭 필요한 함수에 도달한다. 그 시점에서는 어떻게 진행하면 좋을까?

    * Unit: 아무 것도 리턴하지 않음을 의미하는 메소드 리턴 타입

     

    2. 간단한 입출력 형식

    println 같은 절차도 사실 임무(책임)가 하나가 아닌 때가 있다. 새 자료 형식을 도입한다면 앞의 방식과 비슷하게 리팩토링할 수 있다. 새 자료 형식의 이름은 IO로 하자.

    trait IO {  def run: Unit }
    
    def PrintLine(msg: String): IO =
      new IO {  def run = println(msg) }
    
    def contest(p1: Player, p2: Player): IO =
      PrintLine(winnerMsg(winner(p1, p2)))

    contest 함수는 이제 순수 함수다. IO 값을 반환하는데 이 값은 해야할 작업을 서술할 뿐 실제로 실행하지 않는다.

    이러한 contest를 두고 "효과를 가진"함수 또는 "효과 있는" 함수라고 말한다.

    실제로 부수 효과를 가진 것은 IO의 해석기(run 메서드) 뿐이다. 

    contest의 임무(책임)는 프로그램의 여러 부품을 하나로 조립하는 것, 하나밖에 없다. 

    부품 winner는 누가 승자인지 계산하고, 부품 winnerMsg는 출력할 결과 메세지를 계산하고, 부품 PrintLine은 그 메세지가 콘솔에 출력되어야 함을 지정한다.

    효과를 해석해서 실제로 콘솔을 조작하는 책임은 IO의 run 메서드가 지닌다.

    참조 투명성의 요구조건을 기계적으로 만족하는 것 외에 IO 형식이 가진 실질적인 장점은 다소 주관적이다.

     

    다른 자료 형식들과 마찬가지로, IO의 장점은 IO가 제공하는 대수를 고려해서 평가할 수 있다.

    이 IO의 대수가 유용한 연산들과 프로그램들을 많이 정의할 수 있는 흥미로운 대수인가? 그런 프로그램들을 추론하는 데 사용할 수 있는 그럴듯한 법칙들을 이 대수가 가지고 있는가?

    꼭 그렇진 않다.

     

    이 형식에 대해 정의할 수 있는 연산을 살펴보자.

    // self 인수는 이 객체를 this 대신 self로 지징할 수 있게 한다.  
    trait IO { self =>
      def run: Unit
      def ++(io: IO): IO = new IO {
        // self는 외곽의 IO를 지칭한다.      
        def run = { self.run; io.run }
      }
    }
    
    object IO {
      def empty: IO = new IO { def run = () }
    }

    현재의 IO는 하나의 Monoid를 형성한다. empty는 항등원이고 ++ 결합적 연산이다. 

    List[IO]가 있다면 이것을 하나의 IO로 축약할 수 있고, ++가 결합법칙을 따르므로 foldLeft 또는 foldRight 통해 축약을 수행할 수 있다.

    이 자체를 흥미롭지 않다. 하나의 부수 효과가 실제로 실현되는 시점을 지연시키는 능력을 제공할 뿐이다.

     

    여기서 주목할 점은, 주어진 계산을 표현하기 위한 API(프로그램 외부 세계와 상호작용하는 API도 포함)를 결정하는 것은 다름 아닌 프로그래머로 자신이라는 점이다.

    프로그램이 수행하고자 하는 일에 대한 쾌적하고, 유용하고, 합성 가능한 서술들을 만들어 내는 일은 본질적으로 언어 설계에 해당한다.

    프로그래머가 할 일은 다양한 프로그램을 표현할 수 있는 작언 언어와 그에 대한 해석기를 만드는 것이다.

    자신이 만든 언어가 마음이 들지 않으면 바꿀 수 있고, 다른 설계에도 이런 접근방식을 사용하는 것이 적합하다.

     

    2.1 입력 효과의 처리

    현재의 IO 형식은 '출력' 효과만 표현할 수 있다.

    IO에는 여러 지점에서 어떤 외부 출처의 입력을 기다려야 하는 계산을 표현하는 수단이 전혀 없다.

    다음은 화씨 온도를 입력받아 섭씨 온도로 변환한 후 사용자에게 출력하는 프로그램을 작성한 전형적인 명령식 프로그램의 모습이다.

    def fahrenheitToCelsius(f: Double): Double =
      (f - 32) * 5.0/9.0
    
    def converter: Unit = {
      println("Enter a temperature in degrees Fahrenheit: ")
      val d = readLine.toDouble
      println(fahrenheitToCelsius(d))
    }

    converter에 대해서 IO를 돌려주는 순수 함수로 만들려고 하면 문제가 발생한다.

    def converter: IO = {
      val prompt: IO = PrintLine(
        "Enter a temperature in degrees Fahrenheit: ")
      // 이제 어떻게 해야 할까?
    }

    readLine은 콘솔에서 한 줄의 입력을 받아들이는 부수 효과를 가진 함수다. readLine 호출을 IO로 감쌀 수 있지만, 그 결과를 넣을 곳이 없다.

    문제의 핵심은, 현재의 IO 형식은 어떤 의미 있는 형식의 값을 산출하는(yield) 계산을 표현할 수 없다는 것이다.

    IO 해석기는 단지 Unit을 출력으로 산출할 뿐이다.

    다음과 같이 형식 매개변수를 추가해서, 입력이 가능하도록 IO 형식을 확장하면 된다.

    sealed trait IO[A] { self =>
      def run: A
      def map[B](f: A => B): IO[B] =
        new IO[B] { def run = f(self.run) }
      def flatMap[B](f: A => IO[B]): IO[B] =
        new IO[B] { def run = f(self.run).run }
    }
    

    이제 IO 계산이 의미 있는 값을 돌려줄 수 있게 되었다. map과 flatmap 함수를 추가했다. 따라서 IO를 for-함축(comprehension)에서 사용할 수 있다.

    이제 IO는 하나의 Monad를 형성한다.

    object IO extends Monad[IO] {
      def unit[A](a: => A): IO[A] = new IO[A] { def run = a }
      def flatMap[A,B](fa: IO[A])(f: A => IO[B]) = fa flatMap f
      def apply[A](a: => A): IO[A] = unit(a)	
    }
    

    다음은 새 IO를 이용해서 작성한 converter이다.

    def ReadLine: IO[String] = IO { readLine }
    def PrintLine(msg: String): IO[Unit] = IO { println(msg) }
    
    def converter: IO[Unit] = for {
      _ <- PrintLine("Enter a temperature in degrees Fahrenheit: ")
      d <- ReadLine.map(_.toDouble)
      _ <- PrintLine(fahrenheitToCelsius(d).toString)
    } yield ()

    이제는 converter 정의에 부수 효과가 없다. 이 함수는 효과를 가진 계산의 참조 투명한 서술이고, converter.run은 그 효과를 실제로 실행할 해석기이다. 그리고 IO가 Monad를 형성하므로, 이전에 작성한 모든 모나드적 조합기를 사용할 수 있다. 

     

    2.2 단순한 IO 형식의 장단점

    IO같은 입출력용 모나드는 외부 효과를 가진 프로그램의 표현에서 최소 공통 분모에 해당한다.

    이런 모나드의 용법이 중요한 주된 이유는, 이 용법이 순수 코드와 불순 코드를 명확히 분리함으로써 외부 세계와의 상호작용이 일어나는 지점을 명시적으로 드러내는 것이다. 그리고 효과들을 유용한 방식으로 추출하게 된다는 장점도 있다.

     

    그런데 IO 모나드 안에서 프로그래밍하다 보면 보통의 명령식 프로그래밍에서 마주치는 것과 동일한 어려움을 자주 겪게 된다. 

    그래서 함수적 프로그래머들은 효과 있는 프로그램을 좀 더 합성하기 좋은 방식으로 서술하는 방법을 모색했다.

    그렇지만

    이 IO 모나드는 다음과 같은 실질적인 이득을 제공한다.

    • IO 계산은 보통의 이다. 즉, IO 계산을 목록에 저장하고, 함수에 넘겨주고, 동적으로 생성하는 등의 작업이 가능하다. 그 어떤 공통의 패턴도 하나의 함수로 감싸서 재사용할 수 있다.
    • IO 계산을 값으로서 구체화한다는 것은 IO 형식 자체에 박혀 있는 단순한 run 메서드보다 흥미로운 해석기를 만들 수 있음을 의미한다. 또 다양한 해석기들을 만들어 낸다고 해도 converter 예제 같은 클라이언트 코드는 수정하지 않아도 된다는 점이다. IO의 표현은 클라이언트에게 전혀 노출되지 않는다. IO의 표현은 전적으로 IO 해석기의 구현 세부사항일 뿐이다.

    다음과 같은 문제점도 있다.

    • 이 IO를 사용하는 여러 프로그램이 실행 시점 호출 스택이 넘쳐서 StackOverflowError를 던질 것이다. 
    • IO[A] 형식의 값은 완전히 불투명하다. 사실 이는 인수를 하나도 받지 않는, 그냥 게으른 항등 함수이다. run을 호출하면 언젠가는 A 형식의 값이 산출되지만, 프로그래머가 그런 프로그램을 조사해서 그 값을 알아내는 방법은 없다. 아무 일도 하지 않고 영원히 멈추어 있을 수도 있고, 언젠가는 생산적인 일을 할 수도 있지만, 둘 중 어떤 것인지를 미리 알 수 없다. IO는 너무 일반적이라 할 수 있으며, 그래서 IO 값으로 추론할 수 있는 것이 별로 없다. 모나드적 조합기들과 합성하거나 실행할 수 있지만, 그것이 전부다.
    • 단순한 IO 형식은 동시성이나 비동기적 연산에 대해서는 아무것도 알지 못한다. 지금까지 살펴본 기본수단들로는 단지 불투명한 blocking IO 동작들을 순차적으로 실행할 수 있을 뿐이다. 

    3.3 StackOverflowError 방지

    StackOverflowError를 발생하는 다음과 같은 간단한 프로그램을 살펴보자.

    val p = IO.forever(PrintLine("Still going..."))

    p.run을 평가하면 수천 줄의 출력 후에 프로그램이 StackOverflowError를 던지면서 죽는다. 스택 추적 정보를 살펴보면 run이 자신을 거듭 호출함을 알 수 있다.

     

    문제의 원인은 flatMap의 정의에 있다.

    def flatMap[B](f: A => IO[B]): IO[B] =
      new IO[B] { def run = f(self.run).run }

    이 메서드는 새 IO 객체를 생성하는데, 그 객체의 run 메서드는 f를 호출하기 전에 run을 다시 호출한다. 스택에 run 호출이 중첩되어서 결국에는 스택이 넘친다.

     

    3.1 제어의 흐름을 자료 생성자로 구체화

    프로그램의 제어가 그냥 함수 호출들을 따라 흘러가게 단드는 대신, 원하는 방식의 제어 흐름을 자료 형식에 명시적으로 박아 넣으면 된다.

    flatMap을 run을 가진 새 IO를 생성하는 메서드로 두는 대신, 그냥 IO 자료 형식의 자료 생성자로 둘 수도 있다. 그러면 해석기를 꼬리 재귀 루프 형태로 구현하는 것이 가능해진다. 

     

    꼬리 재귀: 재귀 호출이 끝난 후 현재 함수에서 추가 연산을 요구하지 않도록 구현하는 재귀의 형태입니다. 이를 이용하면 함수 호출이 반복되어 스택이 깊어지는 문제를 컴파일러가 선형으로 처리 하도록 알고리즘을 바꿔 스택을 재사용할 수 있게 됩니다.(더이상 값이 변할 여지가 없으므로 스택을 덮어쓸 수 있기 때문) 이러한 꼬리 재귀를 사용하기 위해서는 컴파일러가 이런 최적화 기능을 지원하는지 먼저 확인해야 합니다.[1]

    그러한 해석기는 FlatMap(x, k)같은 생성자를 만나면 그냥 x를 해석하고 그 결과에 대해 k를 호출한다.

    sealed trait IO[A] {
      def flatMap[B](f: A => IO[B]): IO[B] =
        FlatMap(this, f)
      def map[B](f: A => B): IO[B] =
        flatMap(f andThen (Return(_)))
    }
    // 댜른 작업을 진행하지 않고 즉시 A를 돌려주는 순수 계산. run에서 이 생성자를 만났다면 해당 계산이 완료된 것이다.
    case class Return[A](a: A) extends IO[A]
    // 계산의 일시 정지, resume은 인수를 전혀 받지 않지만 어떤 효과를 가지며 결과를 산출하는 함수이다.
    case class Suspend[A](resume: () => A) extends IO[A]
    // 두 단계의 합성, flatMap을 함수가 아니라 자료 생성자로서 구체화한다. run에서 이 생성자를 만났다면
    // 먼저 부분 계산 sub를 처리하고, sub가 결과를 산출하면 k로 넘어가야 한다.
    case class FlatMap[A,B](sub: IO[A], k: A => IO[B]) extends IO[B]

    자료 형식의 해석기가 지원해야 할 세 가지 제어 흐름을 대표하는 세 가지 자료 생성자가 있다.

    Return은 완료된 IO 동작을 나타낸다. 더 이상의 작업 없이 값 a를 돌려주는 제어 흐름이다.

    Suspend는 어떤 효과를 실행해서 굘과를 내는 작업 흐름이다 

    FlatMap은 첫 계산의 결과를 이용해서 둘째 계산을 산출하는 방식으로 작업을 전개 또는 계속하는 흐름을 나타낸다. 

    flatMap 메서드의 구현은 FlatMap 자료 생성자를 호출한 후 즉시 반환하면 된다. 해석기가 FlatMap(sub, k)를 만나면 sub를 해석하고 그 결과에 대해 계속 함수 k를 호출해야 함을 볼 수 있다. k는 프로그램의 실행을 계속한다.

     

    printLine의 새 IO 형식을 이용해서 작성.

    def printLine(s: String): IO[Unit] =
      Suspend(() => Return(println(s)))
    
    val p = IO.forever(printLine("Still going..."))

    이 프로그램이 실제로 생성하는 것은 Stream과 비슷한 무한 중첩 구조이다. Function0은 스트림의 '머리'에 해당하고 나머지 부분은 스트림의 '꼬리'에 해당한다.

    FlatMap(Suspend(() => println(s)),
            _ => FlatMap(Suspend(() => println(s)),
                        _ => FlatMap(...)))

    아래는 이러한 구조를 순회하면서 효과들을 수행하는 꼬리 재귀적 해석기이다.

    @annotation.tailrec def run[A](io: IO[A]): A = io match {
      case Return(a) => a
      case Suspend(r) => r()
      // '=> x match { ... '에서 그냥 run(f(run(x)))라고 할 수도 있지만,
      // 그러면 내부 run 호출이 꼬리 위치가 아니라서 꼬리 재귀가 성립하지 않는다.
      // 대신 x에 대한 부합으로 제어 흐름을 분기한다.
      case FlatMap(x, f) => x match {	
        case Return(a) => run(f(a))
        // 여기서 x는 Suspend(r)이므로, r 성크를 강제하고 그 결과에 대해 f를 호출한다.
        case Suspend(r) => run(f(r()))	
        // 이 경우 io는 FlatMap(FlatMap(y, g), f) 같은 표현식이다.
        // run을 꼬리 위치에서 호출하고 다음 반복에서 y와 부합하기 위해 이를 오른쪽으로 재결합한다.
        case FlatMap(y, g) => run(y flatMap (a => g(a) flatMap f))	
      }
    }
    

    FlatMap(x, f)의  경우에서 run(f(run(x)))라고 하면 꼬리 재귀가 안되기 때문에 x대한 패턴 부합을 적용한다.

    x가 Return이면 그냥 안에 담긴 순수 값으로 f를 호출한다.

    Suspend이면 해당 계속 함수를 호출하고, 그 결과에 대해 f로 FlatMap을 호출하고 재귀를 진행한다.

    x가 FlatMap 생성자 자체이면 io는 FlatMap(FlatMap(y, g), f)처럼 두 FlatMap 생성자가 왼쪽으로 내포된 것이다.

    오른쪽으로 재결합해야 꼬리 재귀를 계속해서 유지할 수 있다. 

    (y flatmap g) flatmap f를 y flatmap ( a => (g(a) flatmap f)의 형태로 해야 한다. 이는 모나드의 결합법칙에 따른 것이다.

     

    실제로 해석될 때 프로그램은 점진적으로 FlatMap 생성자들의 오른쪽 결합 순차열로서 재작성된다.

    FlatMap(a1, a1 =>
      FlatMap(a2, a2 =>
        FlatMap(a3, a3 =>
          ...
          FlatMap(aN, aN => Return(aN)))))

    이제 run 함수는 무한 재귀 IO 프로그램이 주어져도 스택을 넘치게 하지 않는다.

     

    JVM에서 실행되는 프로그램이 함수를 호출하면 호출 프레임 하나가 호출 스택에 쌓인다. 그 프레임에는 함수의 실행이 끝났을 때 다시 돌아가야 할 위치에 대한 정보가 담겨 있다.

    위에서 한 작업은 프로그램 제어 흐름을 IO 자료 형식 안에 명시적으로 정의한 것이다.

     

    run은 IO 프로그램을 해석하면서 그 프로그램이 어떤 효과를 실행하고자 하는지(Suspend(s)의 경우) 또는 서브루틴을 호출하고자 하는지(FlatMap(x, f)의 경우) 판단한다. 

    호출 스택을 사용하는 대신에 run은 x()를 호출하고 그 결과에 대해 f를 호출해서 실행을 계속한다. f는 언젠가 Suspend나, FlatMap, Return 중 하나를 돌려주며, 이에 의해 제어권이 다시 run에게 주어진다.

    IO 프로그램은 run과 협동적으로 실행되는 coroutine이다. IO프로그램은 계속해서 Suspend나 FlatMap을 요청하며, 그럴 때마다 자신의 실행을 정지하고 제어권을 run에게 양보한다. 그리고 그럴 때 마다 프로그램의 실행을 실제로 전진시키는 것은 run이다. 

    run 같은 함수를 트램펄린(trampoline)이라고 부르기도 한다. 스택을 제거하기 위해 하나의 루프로 제어권을 돌려주는 기법을 트램펄린 적용trampolining)이라고 부른다. 

     

    3.2 트램펄린 적용: 스택 넘침에 대한 일반적 해법

    IO 모나드의 resume 함수가 반드시 부수 효과를 실행해야 한다는 법은 없다. 

    순수 계산에도 트램펄린을 적용할 수 있다.

     

    스칼라에서 StackOverflowError 문제는 함수 호출 횟수가 호출 스택이 감당할 수 있는 것보다 더 많은 합성 함수에서 항상 발생한다.

    scala> val f = (x: Int) => x
    f: Int => Int = <function1>
    
    scala> val g = List.fill(100000)(f).foldLeft(f)(_ compose _)	
    g: Int => Int = <function1>
    
    scala> g(42)
    java.lang.StackOverflowError
    

    이보다 더 작은 합성에도 호출 스택은 넘칠 수 있다. 이런 문제는 IO 모나드로 해결된다.

    scala> val f: Int => IO[Int] = (x: Int) => Return(x)
    f: Int => IO[Int] = <function1>
    
    scala> val g = List.fill(100000)(f).foldLeft(f) {
         | (a, b) => x => Suspend(() => ()).flatMap { _ => a(x).flatMap(b)}	
         | }
    g: Int => IO[Int] = <function1>
    
    scala> val x1 = run(g(0))
    x1: Int = 0
    
    scala> val x2 = run(g(42))
    x2: Int = 42
    

    여기에는 어떠한 입출력도 수행되지 않기 때문에 IO라는 이름이 부적합하다. IO는 Suspend가 부수 효과를 담을 수도 있기 때문에 지은 것이다. 

    이는 위 형식이 입출력을 위한 모나드가 아니라 실제로는 꼬리 호출 제거를 위한 모나드다. 

    sealed trait TailRec[A] {
      def flatMap[B](f: A => TailRec[B]): TailRec[B] =
        FlatMap(this, f)
      def map[B](f: A => B): TailRec[B] =
        flatMap(f andThen (Return(_)))
    }
    case class Return[A](a: A) extends TailRec[A]
    case class Suspend[A](resume: () => A) extends TailRec[A]
    case class FlatMap[A,B](sub: TailRec[A],
                           k: A => TailRec[B]) extends TailRec[B]

    TailRec 자료 형식을 이용하면 임의의 함수 A => B에 트랜펄린 기법을 적용할 수 있다. 단 반환 형식을 B에서 TailRec[B]로 바꾸어야 한다.

    TailRec을 이용하면 직접적인 함수 호출보다는 느릴 수 있지만, 스택 사용량을 예측할 수 있다는 장점이 생긴다. 

     

    [1] https://bozeury.tistory.com/entry/%EA%BC%AC%EB%A6%AC-%EC%9E%AC%EA%B7%80-%EC%B5%9C%EC%A0%81%ED%99%94Tail-Recursion

     

     

     

    댓글

Designed by Tistory.