Today's fun macro: type-safe PubSub

169 views
Skip to first unread message

Jeff Ward

unread,
Jun 8, 2016, 6:31:39 PM6/8/16
to Haxe
Inspired by Juraj's talk, today I wrote a macro to make my PubSub message bus (a favorite little utility of mine) type safe.

Often pubsub APIs are similar to:

publish(channel:String, data:Dynamic):Void
subscribe(channel:String, handler:Function):Void

With perhaps a set of static string channels sitting somewhere:

class MessageTypes {
  public static const APP_START:String = "app_start";
  public static const APP_RESET:String = "app_reset";
}

The obvious problems: you don't have type safety, and you do have useless verbosity where you don't need it - in the messages class.

This system screams to use enums - the expression of messages is succinct, they can be parameterized and typed. Yes, yes, it all sounds lovely. Here's how it might look:

publish(e:MyEnum):Void
subscribe(e:MyEnum, handler:Function):Void

enum MyEnum {
  APP_START(t0:Float); // pass start-up timestamp
  APP_RESET;
}

But there's a few catches:
  • Every handler then has the signature handler(e:MyEnum) and must switch on the enum to get the data out. Hrmph. Sure, it's only a few lines of code, but it's tedious.
  • There's an oddity around identifying an enum instance or an enum constructor -- while the publish function really wants to send an enum instance, the subscribe function wants to identify an enum constructor (technically functions) without specifically generating an instance. But there are no utilities in Type which can identify an enum constructor, only enum instances. (Looping through all enum constructors and testing function equality works in js/neko, but not static cpp.)
This is where my macro comes in. Long story short, the subscribe call is a static macro (but with static extension, it's invoked on a PubSub instance just like a member method.) It replaces the subscribe call with a wrapper which makes the compiler type check the enum constructor function against the handler function:

_pubsub.subscribe(MyEnum.APP_START, handle_start);

Becomes:

_pubsub.dynamic_subscribe('APP_START', function(e) {
  switch(e) {
    case MyEnum.APP_START(arg1): handle_start(arg1);
    default:
      throw "Unexpected message: "+e;
  }
});

As you can see, the pub sub bus is itself still channel:String / dynamic as it was before, but the enum is passed through the wrapper, and the user's handler function must match the signature of the enum -- in this case handle_start(Float) -- it generates the appropriate number of args for any enum. It also stringify's the enum name -- necessary for the channel parameter under the hood.

Anyway, this was a fun exercise. Thinking of everything as expressions is mind bending, but I could see me getting decent at it. I find Context.parse, Expr.toString(), and all the macro Tools classes incredibly useful.

If you're interesting in more info & code, I could write it up on my blog.

Cheers,
-Jeff
@jeff__ward


Jeff Ward

unread,
Jun 8, 2016, 6:40:05 PM6/8/16
to Haxe
Oh, and I almost forgot one other nice feature -- the PubSub class is generic:

class PubSub<T>

So you instantiate it with an Enum type:

var _app_pubsub = new PubSub<AppMessages>;

And it's a compile-time error if you try to subscribe to any EnumValue except those in AppMessages.


Jeff Ward

unread,
Jun 9, 2016, 7:56:12 AM6/9/16
to Haxe
Fascinating, Brendon is pointing me to a few examples on Twitter of similar stuff ("typed strings" and type-safe JS extern listeners, both by Dan / nadako). Very cool - I hadn't thought about trying abstract macro nor seen the enum extraction macro (thanks Anders).

Jeff Ward

unread,
Jun 9, 2016, 8:17:26 AM6/9/16
to Haxe

In a bit more detail:


Here's the public API of my PubSub:


class PubSub<T> {
  public function publish(msg:T);
  public static macro function subscribe(ps_instance:Expr, enum_constructor:Expr, handler:Expr);
}


And a usage example:


So you can only publish enum values, and the subscribe macro ensures you can only add listeners to each enum value type (aka enum constructor) that have the proper listeners, like this:


using PubSub; // <-- sets ps_instance above

enum UIEvents {
  APP_START(t0:Float);
  ON_BUTTON_CLICK(btn:Button);
  MOUSE_MOVE(x:Int, y:Int);
  APP_TERMINATING;
}

var ps = new PubSub<UIEvents>;

// Publish takes only the enum values:
ps.publish(APP_START(new Date.getTime());

// Subscribe takes an enum constructor (not an instance), and a handler that
// must match the type of the enum parameters:
ps.subscribe(ON_BUTTON_CLICK, handle_click);
ps.subscribe(APP_TERMINATING, goodbye);
ps.subscribe(MOUSE_MOVE, handle_mouse_move); // compile error, this function doesn't take Int->Int

function handle_click(b) { // b is Button, same parameters as ON_BUTTON_CLICK
  trace('User clicked ${b.name}');
}

function goodbye() { }

function handle_mouse_move() {
}


Richard Jewson

unread,
Jun 9, 2016, 2:47:34 PM6/9/16
to Haxe
Awesome, love to see the code!

Jeff Ward

unread,
Jun 10, 2016, 12:06:18 AM6/10/16
to Haxe
Thanks, Richard. It belongs to my work, but I'll see if I can release it.

Now that I'm working with the PubSub, I'm having a few thoughts:

1) Macros (which are static) don't play nicely with class extension. I need to think about the abstract enum stuff Brendon / Dan made.

2) Another problem with my PubSub -- an enum that defines events is a "ball of mud" dependency mess -- it needs to know about all types required for constructing enums, and all classes that want to use the events reference it. 

So I may move to Class-based events, but still with type-safe handlers.

Mark Knol

unread,
Jun 10, 2016, 4:44:27 AM6/10/16
to Haxe
Hey, not completely the same thing as you are creating, but did you see this one:


Reply all
Reply to author
Forward
0 new messages