IO
IO
is one of the most useful types in Ribs because it enables us to control
side effects and make it easier to write purely functional programs.
If you're familiar with the IO
type from Cats Effect,
then Ribs IO
should look very similar. In fact, Ribs IO
is a very close
port from Scala to Dart! The API is designed to stay as close as possible to
the original implementation.
IO
Motivation
While Darts Future
type has it's uses, it also suffers from issues that make
it unsuitable for functional programming. Consider the following code:
final rng = Future(() => Random.secure().nextInt(1000));
await rng.then((x) => rng.then((y) => print('x: $x / y: $y')));
If you run this code, you should notice that the value of x
and y
are
always the same! Can you see why this is problematic? Now consider this piece
of code where we replace each reference to fut
with the expression that fut
evaluated to:
// Substitute the definition of fut with it's expression
// x and y are different! (probably)
await Future(() => Random.secure().nextInt(1000)).then((x) =>
Future(() => Random.secure().nextInt(1000))
.then((y) => print('x: $x / y: $y')));
When we do the substitution, the meaning of the program changes which leads us
to the conclusion that Future
is not referentially transparent! That means
that it's insufficient for use in pure functions.
Here is where IO
steps in. It provides lazy, pure capabilities that provide
greater control over execution and dealing with failure. Here's the same
program from above, using IO
instead of Future
.
final rng = IO.delay(() => Random.secure().nextInt(1000));
// x and y are different! (probably)
await rng
.flatMap((x) => rng.flatMap((y) => IO.println('x: $x / y: $y')))
.unsafeRunFuture();
You'll notice there are some differences in the APIs between Future
and IO
but for the sake of this example, you can assume that flatMap
is equivalent
to then
and IO.println
is equivalent to print
. If you squint hard enough,
this IO
version should look pretty similar to the original implementation
where we defined rng
using Future
. However, this piece of code is
pure and referentially transparent because of the way IO
is implemented!
Along with this small but important quality, IO
provides the following
features:
- Asynchronous Execution
- Error Handling
- Safe Resource Handling
- Cancelation
Asynchronous Execution
IO
is able to describe both synchronous and asynchronous effects.
IO.pure
IO.pure
accepts a value. This means that there is no laziness or delaying
of effects. Any parameter is eagerly evaluated.
IO.delay
IO.delay
can be used for synchronous effects that can be evaluated
immediately once the IO
itself is evaluated (within the context of the
IO run loop).
IO.async
IO.async
and IO.async_
is used to describe asynchronous effects that require a
callback to be invoked to indicate completion and resume execution. Using async
, we
can write a function that will convert a lazy Future
into an IO
.
IO<A> futureToIO<A>(Function0<Future<A>> fut) {
IO.async_<A>((cb) {
fut().then(
(a) => cb(Right(a)),
onError: (Object err, StackTrace st) =>
cb(Left(RuntimeException(err, st))),
);
});
throw UnimplementedError();
}
IO
already has this conversion for Future
included but the example
illustrates one case where IO.async_
is useful.
The only difference between IO.async
and IO.async_
is that with IO.async
you can include a cancelation finalizer. Since Future
doesn't have a
mechanism for cancelation (at least at the time of this writing), we can safely
use IO.async_
.
To see an example of using IO.async
, check out the implementation of IO.fromCancelableOperation
.
Error Handling
One of the first recommendations on the Dart Error Handling page
demonstrates using Exception
s paired with try
/catch
/finally
to manage
errors in your programs. But it's alredy been established that throwing
exceptions is a side-effect! This rules out using them in our pure FP programs.
That begs the question on how we create and handle errors using IO
.
// composable handler using handleError
final ioA = IO.delay(() => 90 / 0).handleError((ex) => 0);
final ioB =
IO.delay(() => 90 / 0).handleErrorWith((ex) => IO.pure(double.infinity));
IO<double> safeDiv(int a, int b) => IO.defer(() {
if (b != 0) {
return IO.pure(a / b);
} else {
return IO.raiseError(RuntimeException('cannot divide by 0!'));
}
});
Safe Resource Handling
Let's begin with a fairly typical resource pattern used in Dart program that want's opens a file, writes some data and then wants to make sure the file resource is closed:
final sink = File('path/to/file').openWrite();
try {
// use sink...
} catch (e) {
// catch error
} finally {
sink.close();
}
Now let's write an equivalent program using IO
:
final sink = IO.delay(() => File('path/to/file')).map((f) => f.openWrite());
// bracket *ensures* that the sink is closed
final program = sink.bracket(
(sink) => IO.exec(() => sink.writeAll(['Hello', 'World'])),
(sink) => IO.exec(() => sink.close()),
);
This version using IO
has all the resource safety guarentees of the try
/catch
version but
doesn't use Exception
s to avoid side-effects.
Conversions
IO
comes with a few helper functions to convert common FP types into an IO
.
IO.fromOption(const Some(42), () => Exception('raiseError: none'));
IO.fromEither(Either.right(42));
IO.fromFuture(IO.delay(() => Future(() => 42)));
IO.fromCancelableOperation(IO.delay(
() => CancelableOperation.fromFuture(
Future(() => 32),
onCancel: () => print('canceled!'),
),
));
-
IO.fromOption: Takes an
Option
and will either return a pure synchronousIO
in the case ofSome
or raise an error in the case ofNone
-
IO.fromEither: Returns a pure
IO
if theEither
isRight
or ifLeft
, will raise an error using the value of theLeft
. -
IO.fromFuture: Since
Future
is eagerly evaluated and memoized,fromFuture
takes a parameter of typeIO<Future>
to control the laziness and ensure referential transparency.
Simply using IO
doesn't magically make the Future
parameter referentially transparent!
You must still take care on controlling the evaluation of the Future
.
final fut = Future(() => print('bad'));
// Too late! Future is already running!
final ioBad = IO.fromFuture(IO.pure(fut));
// IO.pure parameter is not lazy so it's evaluated immediately!
final ioAlsoBad = IO.fromFuture(IO.pure(Future(() => print('also bad'))));
// Here we preserve laziness so that ioGood is referentially transparent
final ioGood = IO.fromFuture(IO.delay(() => Future(() => print('good'))));
- IO.fromCancelableOperation: This behaves in the same way as
IO.fromFuture
but is able to take advantage of some of the advanced features ofCancelableOperation
.
Cancelation
IO
also allows you to build cancelable operations.
int count = 0;
// Our IO program
final io = IO
.pure(42)
.delayBy(10.seconds)
.onCancel(IO.exec(() => count += 1))
.onCancel(IO.exec(() => count += 2))
.onCancel(IO.exec(() => count += 3));
// .start() kicks off the IO execution and gives us a handle to that
// execution in the form of an IOFiber
final fiber = await io.start().unsafeRunFuture();
// We immediately cancel the IO
fiber.cancel().unsafeRunAndForget();
// .join() will wait for the fiber to finish
// In this case, that's immediate since we've canceled the IO above
final outcome = await fiber.join().unsafeRunFuture();
// Show the Outcome of the IO as well as confirmation that our `onCancel`
// handlers have been called since the IO was canceled
print('Outcome: $outcome | count: $count'); // Outcome: Canceled | count: 6
This is obviously a contrived example but exhibits that you have a great deal
of power controlling the execution of an IO
.
Also note that an IO
can only be checked for cancelation at it's asynchronous
boundaries. Types of asynchronous boundaries include:
IO.sleep
IO.cede
(or autoCede occurances)IO.async
'Unsafe' Operations
There are a few functions on IO
prefixed with the word unsafe
. These are
what you should be calling at the 'edge(s)' of your program. In a completely
pure program, you should only call an 'unsafe' function once, in the main
method after you've built and described your program using IO.
The reason these functions include the 'unsafe' keyword isn't because your
computer will explode when they're called. They're unsafe because they are
not pure functions and will interpret your IO
and perform side effects.
'Unsafe' is included because you should always take care before deciding
to call these functions.
-
unsafeRunAsync: As the name indicates, this will evaluate the
IO
asynchronously, and the provided callback will be executed when it has finished; -
unsafeRunAndForget: Same as
unsafeRunAsync
but no callback is provided. -
unsafeRunFuture: Evaluates the
IO
and returns aFuture
that will complete with the value of theIO
or any error that is encountered during the evaluation. -
unsafeRunFutureOutcome: Returns a
Future
that will complete with theOutcome
of theIO
, being one of 3 possible states:- The value itself (successful)
- The error encountered (errored)
- Marker indicating the
IO
was canceled before completing (canceled)