Table of Contents

Porting to Rio

By Colin on 2020-02-15

Aura has long been my den for mad science in Haskell. Whenever some new library or technique comes along, I give it a try to get a feel for its true usefulness. The discussion around Extensible Effects (and application monads in general) has been on my radar for a long time, and let me tell you, I've followed the whole story. The timeline for Aura's approach to effects is something like this:

  • 2012: I've never heard of Monad Transformers, and pass a runtime Env manually everywhere. Everything is IO.
  • 2013: The Aura Monad is born (ExceptT over ReaderT over IO). Thank you Real World Haskell!
  • 2015: The Extensible Effects paper is released and we attempt a first pass at using it.
  • 2016: I give a talk on Extensible Effects to a local Haskell group.
  • 2018: Aura moves to freer-simple instead. No more Aura monad.
  • 2019: Joe ports Aura to fused-effects with much effort.
  • 2020: I realize we were trying too hard and port Aura to use Rio.

Yes, Rio! And I should say, the process was quite pleasant. I'm happy with the switch.

What is Rio?

Rio is a "batteries included" alternate Prelude. I usually reach for base-prelude for applications, since it stays out of the way and satisfies my main requirement:

Give me access to everything in base that I'm going to be importing anyway!

I'm looking at you, traverse_.

Rio fills this requirement for me, but also goes a few steps further to make it an ideal choice for terminal-based Haskell applications, which is exactly what Aura is. It gives us:

  • A dead-simple logging system with sensible defaults.
  • Access to the major data types we use anyway (Vector, HashMap, etc.) without needing to depend on their libraries ourselves.
  • Access to the symbols of those datatypes without adding imports. Text, etc., are freely available, so no more import Data.Text (Text) just to avoid the ugly qualified T.Text everywhere.
  • An "opt-in" approach to non-total functions via modules like RIO.List.Partial.
  • Sensible defaults for file IO and character encodings.
  • The RIO effect monad (ReaderT IO) with many convenience functions involving it.
  • A battle-tested approach to handling exceptions.

The Porting Experience

The Prelude Swap

A naive replacement of base-prelude for rio went well. Mostly a find-and-replace of import statements.

When it came to ditching text and bytestring in favour of what rio reexports, I had to reconcile some things which weren't at first intuitive. For instance: rio exposes no way to output Text, say via T.putStrLn. It recommends we go through ByteString, or through its Display typeclass which is optimized for Rio's logging. At first this bothered me, but now I'm okay with it. If you just want simple trace statements for debugging, Rio supplies plenty of those.

List is not given special treatment, so even functions like zip aren't exposed without importing RIO.List. I personally like that List has been "knocked down a peg" in this way.

A few function reexports from microlens were missing, so I needed to pull those myself. Data.Bifunctor is also not reexported, so first and second still come from Control.Arrow and thus don't work with Either.

My aura.cabal got nicely reduced, since there's no need to depend on packages like containers, bytestring, etc. The overall line count of the code dropped by a nice amount too due to cleaner imports and type signatures. Speaking of which...

Extensible Effects

I can't overstate how removing these in favour of RIO Env a simplified my life. Look at this diff:

  - install :: ( Carrier sig m
  -            , Member (Reader Env) sig
  -            , Member (Error Failure) sig
  -            , Member (Lift IO) sig
  -            ) => NESet PackagePath -> m ()
  + install :: NESet PackagePath -> RIO Env ()

This port PR was filled with lines like this, and each one gave me joy. Yes we lose the explicit Error effect, but this is Haskell, where IO can throw exceptions anyway. It's a wart I'm willing to accept in order to have access the other amazing things that Haskell gives me. Suffice to say I gave my Failure type an Exception instance and now throw that as needed (which isn't often).

Now, here's the conclusion I've come to regarding regarding Extensible Effects: somebody somewhere has a use case that benefits from them. That person is not you. Recognize when you're trying too hard just to do a novel thing for novelty's sake.

Logging

Aura uses Rio's logging system too. Here is the simplest form I could reduce its setup boilerplate to:

  import Data.Generics.Product (typed)
  import RIO

  data Env = Env { envLog :: LogFunc, ... }
    deriving stock (Generic)

  -- | The only "glue" that ends up being necessary.
  -- `typed` is from generic-lens.
  instance HasLogFunc Env where
    logFuncL = typed @LogFunc

  main :: IO ()
  main = do
    lopts <- setLogUseLoc False <$> logOptionsHandle stderr True
    withLogFunc lopts $ \logFunc -> do
      let !env = Env logFunc ...
      runRIO env work

  work :: RIO Env ()
  work = do
    logInfo "It works!"

It just works.

Should you use it?

If your Haskell program runs from the terminal and has a runtime environment type, then Rio would bring you a lot of value. If you need a logging system too, then Rio really simplifies your life. In general, it will clean up your imports and your type signatures, and it just keeps things simple.

Otherwise, if you're just looking for an Alternate Prelude, then any other one will do. Rio shines when you use it for what it's meant for.

Thanks to Michael and everyone behind rio! Consider me a happy customer.

Blog Archive