Async in Purescript is fun and easy

  • 5
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

This post is targeted to people who are getting started or are interested in Purescript. There are some parallels with things that exist in Javascript, but not really, since there are no real facilities to make such generic interfaces possible in Javascript.

Let's get started.

Callbacks with Eff

This is probably the most common thing you'll need done -- the resolution of a single value. The simplest way to do this is to use a callback, so you can have a value computed and have your program continue when that value is ready.

It's more than likely that you'll be using some existing Javascript libraries that take callbacks for async. For each {name}.purs file you have, a corresponding {name}.js file can be used for foreign functions that you want to use. Like so:

// Main.js
exports.foreignCalculateLength = function(array) {
  return function (callback) {
    return callback(array.length);
  };
};
foreign import foreignCalculateLength :: forall e a.
  Array a ->
  (Int -> Eff e Unit) ->
  Eff e Unit

So you can see that our foreignCalculateLength function takes an Array a to return a function that will then take a function that takes Int to return an Effect of Unit (basically nothing). Registering a callback returns Eff e Unit, which means that we need to run this somewhere that can "unwrap" Eff to pull out our (gooey burrito guts | insert your metaphor here).

This can then be used in a main function:

type Main e = Eff (console :: CONSOLE | e) Unit

mainCallback :: forall e. Main e
mainCallback =
  foreignCalculateLength [1,2,3] (\x -> do
    logShow (x * 2) -- prints "6"
  )

We've calculated the length of our array and passed in a callback to display that length times two!

We can write this Eff callback in pure Purescript also.

calculateLengthEff :: forall e a.
  Array a ->
  (Int -> Eff e Unit) ->
  Eff e Unit
calculateLengthEff l s = do
  s (length l)
  pure unit

Note: You'll see that we are ending our do block with pure unit, which matches the return type of Eff e Unit. This is because Eff has an instance of the Applicative typeclass, which means that the member method pure of Applicative can be used. pure is of type a -> f a, which means that we can "wrap" any value (in this case, unit :: Unit to Eff).

And just like in JS, you can run another callback inside of a callback to combine the results later:

mainCallback2 :: forall e. Main e
mainCallback2 =
  foreignCalculateLength [1,2,3] (\x -> do
    calculateLengthEff [4,5,6] (\y -> do
      logShow (x + y)
    )
  )

We'll learn a better way to do this looking at Affs.

Asynchronous Effects, or "Affects"

Affs are relatively easy to construct, and may remind you of something you've used in JS, but there are some key differences to note. Let's look at how they work and then talk about it. First, some ways they're made:

1) Converting an existing callback Eff to an Aff:

calculateLengthAff :: forall e a. Array a -> Aff e Int
calculateLengthAff l = makeAff \error success -> foreignCalculateLength l success

2) Calling the success handler directly:

calculateLengthAff' :: forall e a. Array a -> Aff e Int
calculateLengthAff' l = makeAff \error success -> do
  success (length l)

3) Using the Applicative instance to easily wrap a value in an Aff:

calculateLengthAff'' :: forall e a. Array a -> Aff e Int
calculateLengthAff'' l = pure (length l)

Sort of like Effs, you can use do blocks with Affs, but you have to use launchAff to use them. Like so:

type MainAff e = Eff (err :: EXCEPTION, console :: CONSOLE | e) (Canceler (console :: CONSOLE | e))

mainAff :: forall e. MainAff e
mainAff = launchAff do
  x <- calculateLengthAff [1,2,3]
  liftEff' (logShow (x * 2))

You'll see that launchAff returns a Canceler, which can be used to cancel Affs if needed. This can become useful in cancelling HTTP requests and other things, but isn't too important for our uses right now.

Remember that example before where we needed to run callbacks in succession? With Affs, that becomes really easy:

mainAff2 :: forall e. MainAff e
mainAff2 = launchAff do
  x <- calculateLengthAff [1,2,3]
  y <- calculateLengthAff' [4,5]
  z <- calculateLengthAff' [6]
  liftEff' (logShow (x + y + z))

And the one where we want to multiply the result by 2? Sure, we could multiply the value where we log the value, but what if we wanted to make it part of the pipeline that could be reused? No problem, as we can use the Functor instance to map/<$> of the type (a -> b) -> f a -> f b.

mainAff3 :: forall e. MainAff e
mainAff3 = launchAff do
  result <- (*) 2 <$> (calculateLengthAff [1,2,3])
  liftEff' (logShow result) -- prints "6"

What about if we wanted to take the three successive Affs and... run them in parallel? That we can, since Aff has an instance of MonadPar from purescript-parallel, which in turn has an instance of Apply, which lets us put these parallel processes together using apply/<*>.

mainAff4 :: forall e. MainAff e
mainAff4 = launchAff do
  result <- runParallel $ (\a b c -> a + b + c)
    <$> parallel (calculateLengthAff [1,2,3])
    <*> parallel (calculateLengthAff' [4,5])
    <*> parallel (calculateLengthAff'' [6])
  liftEff' (logShow result)

So we can see that doing things that are commonly thought to be hard is actually fairly easy with Purescript thanks to the common interfaces available.

In the majority of your Purescript applications and the libraries you use, Aff is the answer for single-value async that allows you to write very simple code using do blocks.

Aside: you have probably noticed that Affs remind you of Promises in Javascript. Unfortunately, Promises are eager and cannot be reused in the same way*. They also expose their own specific methods like most Javascript objects, so there is no way to generically transform Promises in a similar fashion as with Arrays or other structures. Lastly, they do not currently provide any method for cancellation, though there are proposals to dramatically change Javascript and Promises themselves to support cancellation that may land in the following years on newer environments.

* for example, you could not do this with Promises:

mainAff5 :: forall e. MainAff e
mainAff5 = launchAff do
  let aff = calculateLengthAff [1,2,3]
  result <- runParallel $ (\x y z -> x + y + z)
    <$> parallel aff
    <*> parallel ((_ - 1) <$> aff)
    <*> parallel ((_ - 2) <$> aff)
  liftEff' (logShow result) -- prints "6"

Multiple async values with Observables

This is an area in which there are multiple solutions, but my favorite libraries for 0-N async values are reactive programming libraries, especially observable libraries.

Like Affs and other structures, Observable libraries have three stages that the user cares about: 1) Creation (creating an Observable), 2) Subscription (subscribing to an Observable), 3) Disposal (disposing of a subscription). Of course, the first two cases are the most important.

Purescript-observable is one such library for this in Purescript.

Now, there isn't a super straight-forward way to convert an Aff into an Observable, so I made a small library for this. Using this library, we can create Observables in a similar fashion as with Affs:

calculateLengthObs :: forall e a. Array a -> Eff (observable :: OBSERVABLE | e) (Observable Int)
calculateLengthObs l = liftAff (calculateLengthAff l)

calculateLengthObs' :: forall e a. Array a -> Eff (observable :: OBSERVABLE | e) (Observable Int)
calculateLengthObs' l = observable \sink -> do
  sink.next (length l)
  sink.complete
  free [] -- for returning inner subscriptions that must be disposed when this observable is disposed.

calculateLengthObs'' :: forall a. Array a -> Observable Int
calculateLengthObs'' l = pure (length l)

And then we just apply what is basically the same code as with Affs:

type MainObs e = Eff (observable :: OBSERVABLE, console :: CONSOLE | e) (Subscription (console :: CONSOLE | e))

mainObs :: forall e. MainObs e
mainObs = do
  s <- calculateLengthObs [1,2,3]
  subscribe
    { next : \x -> logShow (x * 2)
    , error: message >>> logShow
    , complete: pure unit
    }
    s

mainObs2 :: forall e. MainObs e
mainObs2 = do
  s <- calculateLengthObs [1,2,3]
  subscribe
    { next : logShow
    , error: message >>> logShow
    , complete: pure unit
    }
    ((*) 2 <$> s)

mainObs3 :: forall e. MainObs e
mainObs3 = do
  s1 <- calculateLengthObs [1,2,3]
  s2 <- calculateLengthObs' [4,5]
  let s3 = calculateLengthObs'' [6]
  subscribe
    { next: logShow
    , error: message >>> logShow
    , complete: pure unit
    }
    ((\x y z -> x + y + z) <$> s1 <*> s2 <*> s3)

Note that with Observables, you have three callbacks you must provide. next is for handling each emitted value; error is for handling each emitted error; complete is what must be run after the Observable has signaled that it has completed and has no more values to emit. But otherwise, the ideas are largely the same.

I even wrote a wrapper library around another Observable stream library called purescript-xstream (the original xstream written by a friend who has a love for incredibly cheesy names) which has a similar API with some more things for convenience:

calculateLengthXStream :: forall e a. Array a -> Eff (stream :: STREAM, ref :: REF | e) (Stream Int)
calculateLengthXStream l = fromAff (calculateLengthAff l)

calculateLengthXStream' :: forall a e. Array a -> Eff (stream :: STREAM | e) (Stream Int)
calculateLengthXStream' l =
  create
    { start: \o -> do
        o.next (length l)
        o.complete unit
    , stop: const (pure unit)
    }

mainXStream :: forall e.
  Eff
    ( stream :: STREAM
    , ref :: REF
    , console :: CONSOLE
    | e
    )
    Unit
mainXStream = do
  s1 <- calculateLengthXStream [1,2,3]
  s2 <- calculateLengthXStream' [4,5]
  addListener
    { next: logShow
    , error: message >>> logShow
    , complete: const (pure unit)
    }
    ((+) <$> s1 <*> ((+) 1 <$> s2))

Conclusion

So I hope this has covered the most common async cases that you will run into when using Purescript and that you've been convinced this is pretty fun and fairly easy (and maybe even cool, for some definitions of cool).

If you read this far, thanks for reading! Please let me know if you have any corrections/critiques/comments/reactions/responses/hatemail/concerns/praises/suggestions on twitter.

Thanks!

Links