Emitter
Typed signals and events for Haxe, with nothing allocated to send them.
An Emitter holds two kinds of listener:
- Signals: typed calls of zero to five arguments. No object is made for them.
- Events: an
Eventobject sent to the listeners of its type, with priorities, bubbling up a tree of parents, stopping and cancelling.
The whole API is seven words, reached through an emitter's events: on, once, off, emit, dispatch,
has and clear. Kept there, they leave a class built on Emitter one name to inherit, and clear, has or
dispatch free for members of its own; events is a view of the emitter, so reaching it costs nothing. It is
tested natively (hxcpp), on the jvm, Node, eval, HashLink and Neko.
Installation
haxelib install emitter
Emitter needs Haxe 4.3 or later and has no dependencies.
Signals
Declare a signal by the arguments it carries, Signal0 to Signal5:
import emitter.Emitter;
import emitter.Signal2;
class Player extends Emitter {
public static inline var MOVED:Signal2<Float, Float> = "moved";
public function new() {
super();
}
}
Listen with events.on, and send with events.emit. Both are checked when they are compiled, and a listener's argument types
come from the signal:
var player = new Player();
var moved = player.events.on(Player.MOVED, (x, y) -> trace(x + y));
player.events.emit(Player.MOVED, 1.5, 2.5); // traces 4
emit is inlined where it is called, returns whether anything listened, and allocates nothing (natively hxcpp
passes a primitive to a function value boxed, and keeps boxes only for whole numbers from -1 to 255, so an Int
outside them or a Float is boxed once per emit).
Events
Declare an event type by the event it carries. The base Event has CHANGE, COMPLETE, CLOSE and CANCEL; a
class of your own extends it:
import emitter.Event;
import emitter.EventType;
class ScoreEvent extends Event {
public static inline var SCORED:EventType<ScoreEvent> = "scored";
public var points:Int;
public function new(points:Int) {
super(SCORED);
this.points = points;
}
}
The same on listens for it, and dispatch sends it:
var game = new Emitter();
game.events.on(ScoreEvent.SCORED, event -> trace(event.points));
game.events.dispatch(new ScoreEvent(10)); // traces 10
A listener can call event.stopPropagation(), event.stopImmediatePropagation() and, on a cancelable event,
event.preventDefault(), which the dispatcher reads back as event.defaultPrevented.
Plain strings
A name need not be declared. A plain string is a signal when its listener is anything but an Event listener,
an untyped Function included, and an event type when its listener takes an Event:
emitter.events.on("scored", (points:Int) -> trace(points)); // a signal
emitter.events.emit("scored", 10);
var callback:haxe.Constraints.Function = (data:Dynamic) -> trace(data);
emitter.events.on("anything", callback); // a signal, untyped
emitter.events.emit("anything", 42);
game.events.on("ready", (event:Event) -> trace(event.type)); // an event type
game.events.dispatch(new Event("ready"));
A plain string with no listener (has("ready"), clear("ready")) names an event type; ask about or clear a
signal by its Signal0 to Signal5 constant. A declared constant is still the better name for a signal used in
more than one place: its arguments are checked wherever it is emitted.
event.target is the emitter the event was dispatched on, and a listener takes it as the class it is, without a
cast:
player.events.on(Event.CHANGE, function(event:Event):Void {
var changed:Player = event.target;
});
It converts as cast does: taken as a class it is not, it goes wrong as a wrong cast does, and
Std.downcast(event.target, Player) answers null instead. A class that is no emitter at all does not compile.
currentTarget is taken the same way. At run time both are the emitter itself (an EventTarget is nothing more),
so taking one as its class costs what the cast would.
Removing a listener
on and once answer with a Subscription. Keep it to remove that listener, a lambda included:
var moved = player.events.on(Player.MOVED, (x, y) -> trace(x + y));
moved.cancel();
trace(moved.active); // false
off removes a listener by its function, which suits a method read again:
class Hud {
public function new(player:Player) {
player.events.on(Player.MOVED, onMoved);
player.events.off(Player.MOVED, onMoved);
}
function onMoved(x:Float, y:Float):Void {}
}
clear(token) removes every listener of one signal or event type, and clear() every listener of both kinds.
has(token) says whether anything listens, and has(token, listener) whether that listener does.
once listens for the next signal or event only. Cancelling a subscription twice, after clear, or after its
once ran does nothing.
Order and duplicates
- A higher priority runs earlier:
on(token, listener, priority). Listeners of one priority run in the order they were added. - Adding an event listener that is already there does nothing, as in ActionScript, and
onanswers with its subscription. SetallowDuplicateson an emitter to add it again instead. A signal listener added twice is called twice, and each subscription removes its own.
Bubbling
An emitter in a tree returns its parent from getEventParent(). An event made with bubbles set then goes up
through each parent after the emitter it was dispatched on, until a listener stops it:
class Node extends Emitter {
public var parent:Null<Node>;
public function new(?parent:Node) {
super();
this.parent = parent;
}
override public function getEventParent():Null<Emitter> {
return parent;
}
}
var root = new Node();
var leaf = new Node(root);
root.events.on(Event.CHANGE, event -> trace(event.target == leaf)); // true
leaf.events.dispatch(new Event(Event.CHANGE, true));
An event that does not bubble never asks for a parent. dispatch sets an event's target only when it has none,
so to dispatch the same object again from scratch, as a program that keeps one event per frame does, call
event.reuse() first.
The rules
- Changes during a dispatch. Each emitter's listeners are taken as they are when a dispatch reaches it. A listener removed or cancelled during the dispatch still runs for it; one added does not, until the next. A listener may remove itself, and add itself again. A listener added to a parent the event has not reached yet runs for it.
- Questions are answered as things are:
hasis exact at every moment, during a dispatch too. Aoncelistener is gone before it is called. - Removal finds what was added on every target: a subscription is the listener itself, and
offfinds a bound method (this.onMoved) where each read of it makes a new closure. - A listener that throws stops the dispatch, and what it threw reaches the caller as it was thrown, with its stack. The emitter is left as it was.
Building a framework on it
A framework extends Emitter and overrides the hooks it needs:
getEventParent(): the next emitter a bubbling event goes to.listenersChanged(type, present): called when an event type gains its first listener or loses its last, by any path (on,once,off, a subscription cancelled, aoncelistener called,clear). A display tree keeps its per-frame registry with it.listenerAdded(type, subscription)andlistenerRemoved(type, subscription): called for each event listener as it is added and as it is taken out, by the same paths, as Node'snewListenerandremoveListenerevents are. A socket that has already closed tells acloselistener added afterwards what it missed.listenersOf(type): the subscriptions listening for an event type, in the order they run, as a copy. Withsubscription.listener, the function each calls, a framework calls them some other way: a server that hands itsconnectlisteners to other threads keeps a copy of them, read again when one comes or goes, and leaves out its own by its subscription. One added withonce(subscription.once) is called for one event and cancelled as it is, as a dispatch would.dispatchContained(event)dispatches asdispatchdoes, except that a listener that throws does not stop the others: what it threw goes tolistenerThrew(error, event), which a framework overrides to report it.dispatchLocal(event)runs one emitter's listeners and nothing else, for a propagation the framework builds itself.new Emitter(owner)makes events dispatched through it sayowneris their target, for a class that keeps an emitter inside rather than extending one.IEmitter(events) is what both have in common, and an event'stargetis taken as either.allowDuplicateskeeps an event listener added twice, for an emitter that holds one per connection.
Names a framework's users already know stay its own, each a one-line inline method on its dispatcher, so its call sites compile unchanged. ActionScript's, as Graphaxe and CrossByte would add them:
class EventDispatcher extends emitter.Emitter {
public inline function addEventListener<T:Event>(type:EventType<T>, listener:T->Void, priority:Int = 0):Void {
events.on(type, listener, priority);
}
public inline function removeEventListener<T:Event>(type:EventType<T>, listener:T->Void):Void {
events.off(type, listener);
}
public inline function hasEventListener(type:String):Bool {
return events.has(type);
}
public inline function dispatchEvent(event:Event):Bool {
return events.dispatch(event);
}
}
An emitter with no listeners holds two null fields and nothing else, and works when made by
Type.createEmptyInstance.
Performance
Dispatch walks the listeners it reached without copying them. A list is copied only when, while a dispatch walks
it, a listener is removed or added anywhere but last. A once listener called by the only dispatch walking its
list comes out where it stands, with no copy. On one machine:
| natively | jvm | Node | |
|---|---|---|---|
| dispatch to one listener | 4.7 ns | 2.2 ns | 3.8 ns |
| emit, one argument | 4.9 ns | 2.1 ns | 3.9 ns |
| emit, nothing listening | 3.1 ns | 0.7 ns | 3.3 ns |
| subscribe and cancel | 14.6 ns | 7.9 ns | 9.6 ns |
once, then emit | 22.5 ns | 12.2 ns | 20.7 ns |
| bubble up three emitters, a listener on each | 14.9 ns | 8.3 ns | 15.6 ns |
| allocated by a dispatch or an emit | 0 | 0 | |
| an emitter with no listeners | 32 bytes | 24 bytes |
haxe bench/all.hxml measures them on yours, beside the same cases for other dispatchers (see bench/RunBench.hx).
Tests
haxe test/all.hxml # every target
haxe test/all.hxml eval node jvm # those named
Run from the repository's root, with utest installed, hxnodejs for Node and hxcpp natively. test/ has one
hxml per target. HL and NEKO name the HashLink and Neko runtimes when they are not on the PATH.
Upgrading from 1.x
SignalTypeandSignalType1toSignalType5becomeSignal0toSignal5, typed by their arguments only:SignalType1<Int->Void, Int>isSignal1<Int>.- The verbs are reached through
events:emitter.on(...)isemitter.events.on(...), and the same foronce,off,emit,hasandclear. onandonceanswer with aSubscriptionrather than the emitter, so calls no longer chain;oncetakes no arity.emitreturns whether anything listened.- Plain strings still name signals, with typed or untyped listeners, as in 1.x (see Plain strings).
removeCallbacks(signal)anddispose()areclear(signal)andclear();hasSignalishas.prepend,emitUntyped,callbackCount,signalCount,totalCallbacks,isReady,Arity,SignalHandler,BaseEmitterandIBaseEmitterare gone. A priority puts a listener first.
License
MIT. See LICENSE.