Trying to understand how best deal with side-effects in FP.

I implemented this rudimentary IO implementation:

  trait IO[A] {
    def run: A
  }
  object IO {
    def unit[A](a: => A): IO[A] = new IO[A] { def run = a }
    def loadFile(fileResourcePath: String) = IO.unit[List[String]]{ 
        Source.fromResource(fileResourcePath).getLines.toList }
    def printMessage(message: String) = IO.unit[Unit]{ println(message) }
    def readLine(message:String) = IO.unit[String]{ StdIn.readLine() }
  }

I have the following use case:

- load lines from log file
- parse each line to BusinessType object
- process each BusinessType object
- print process result

Case 1: So Scala code may look like this

val load: String => List[String]
val parse: List[String] => List[BusinessType]
val process: List[BusinessType] => String
val output: String => Unit

Case 2: I decide to use IO above:

val load: String => IO[List[String]]
val parse: IO[List[String]] => List[BusinessType]
val process: List[BusinessType] => IO[Unit]
val output: IO[Unit] => Unit

In case 1 the load is impure because it's reading from file so is the output is also impure because, it's writing the result to console.

To be more functional I use case 2.

Questions:

- Aren't case 1 and 2 really the same thing?
- In case 2 aren't we just delaying the inevitable? 
  as the parse function will need to call the io.run 
  method and cause a side-effect?
- when they say "leave side-effects until the end of the world" 
  how does this apply to the example above? where is the 
  end of the world here?

1 Answers

6
Thilo On Best Solutions

Your IO monad seems to lack all the monad stuff, namely the part where you can flatMap over it to build bigger IO out of smaller IO. That way, everything stays "pure" until the call run at the very end.

In case 2 aren't we just delaying the inevitable? as the parse function will need call the io.run method and cause a side effect?

No. The parse function should not call io.run. It should return another IO that you can then combine with its input IO.

when they say "leave side-effects until the end of the world" how does this apply to the example above? where is the end of the world here?

End of the world would be the last thing your program does. You only run once. The rest of your program "purely" builds one giant IO for that.

Something like

def load(): IO[Seq[String]]    
def parse(data: Seq[String]): IO[Parsed]  // returns IO, because has side-effects
def pureComputation(data: Parsed): Result  // no side-effects, no need to use I/O
def output(data: Result): IO[Unit]

// combining effects is "pure", so the whole thing
// can be a `val` (or a `def` if it takes some input params)
val program: IO[Unit] = for {
      data <- load()    // use <- to "map" over IO
      parsed <- parse()
      result = pureComputation(parsed)  // = instead of <-, no I/O here 
      _ <- output(result)
   } yield ()

// only `run` at the end produces any effects
def main() {
   program.run()
}