Sigils release v0.21.0 includes selectors -- dynamic runtime methods and protocols!
Sigils has proven very useful to me for a couple of work projects. It provides a way to do dynamic event oriented programming in Nim (e.g. PubSub), similar to QT for C++., using signals and slots. They allow decoupling problems domains or modifying behavior dynamically.
However signals and slots don't return values or handle responder chains, which is what Cocoa builds on from Obj-C. This especially limits some aspects of GUI programming.
So I decided to try building them using Sigils' signals and slots! I ended up calling them selectors and they use a new DynamicAgent type. The idea is to provide runtime methods. They also provide protocols which any DynamicAgent can implement and which can be queried at runtime. You can read more about them in their manual.
What's very useful about this approach over say Nim's native methods is the ability to one-of modify a given GUI element with a new behavior. In a fashion they're just named callbacks, but with more idioms and syntax for dealing with high level needs of GUIs rather than mucking about with callback isNil fields.
Here's a simple example:
import sigils/selectors
type
Post = ref object of DynamicAgent
title: string
draft: bool
protocol PostDisplay:
method displayTitle(): string
method canPublish(): bool
method normalTitle(self: Post): string {.selector.} = self.title
method draftTitle(self: Post): string {.selector.} = "[draft] " & self.title
method postCanPublish(self: Post): bool {.selector.} = not self.draft
let post = Post(title: "Selectors in Sigils", draft: true)
discard post.replaceMethods(PostDisplay, [
displayTitle => normalTitle,
canPublish => postCanPublish,
])
doAssert post.hasAdopted(PostDisplay)
echo post.displayTitle() #=> Selectors in Sigils
discard post.replaceMethod(displayTitle, draftTitle) # Override display behavior for this one post
echo post.displayTitle() #=> [draft] Selectors in Sigils
echo post.canPublish() #=> false
Here PostDisplay is the protocol: it says a conforming object can answer displayTitle and canPublish. The post adopts it when replaceMethods(PostDisplay, ...) installs implementations for the required selectors. Later, replaceMethod overrides only displayTitle for that specific instance.
There's convenience syntax for defining and implementing a protocol in one go:
protocol PostDisplay from Post:
method displayTitle(self: Post): string =
if self.draft:
"[draft] " & self.title
else:
self.title
method canPublish(self: Post): bool =
not self.draft
let post = Post.newProto(title = "Selectors in Sigils", draft = true)
Here's a short example using pushMethod and popMethod helpers. They're useful in GUI programming contexts where you might want to customize some action, but only temporarily in a certain context:
proc addAdminPrefix(self: DynamicAgent, inv: var Invocation, next: DynamicMethod) =
next(self, inv)
if inv.handled: inv.setResult("[admin] " & inv.resultAs(string))
let post = Post.newProto(title = "Selectors in Sigils", draft = true)
echo post.displayTitle()#=> [draft] Selectors in Sigils
block customizeMethod:
let token = post.pushMethod(displayTitle, addAdminPrefix)
echo post.displayTitle()#=> [admin] [draft] Selectors in Sigils
discard token.popMethod()
echo post.displayTitle()#=> [draft] Selectors in SigilsI've tagged Sigils release v0.22.0. This release focused on adding some large performance improvements!
Basic signal & slots calls vs method ratio is down to ~13x which is close to QT's overhead of ~7-9x. Overall the overhead compared to normal procs dropped from ~90x to ~18x.
While still slower than native Nim methods this puts it well within the range of established frameworks for dynamic named calls. The named piece is important rather than just being virtual methods these are based on stringly names, providing much more flexibility.
The selectors and protocols have also been sped up! The selector dispatch paths are roughly 2.0x to 2.25x faster! The pure lookup paths are much larger wins:
Could you- if I may ask- point me to the benefit of sigils and slots, compared to dynamically assigned methods, in a nutshell? I see the solution but I've had a hard time grasping the problem.
Good question, and it's hard to answer well without experience. Many folks after using QT come to appreciate signals & slots (though some hate it too of course!).
Signals & slots provide an implementation of the Observer Pattern in a clean syntax. Combine that with Sigils providing thread safe signaling mechanisms and you switch focus from managing the mechanics of callbacks to how events and listeners flow in your app.
At the core signals & slots are just a list of callbacks. However the idioms and syntax become convenient and add an emergent quality.
For example take:
myWidget.listenEvent = proc(e: WidgetEvent) = ...
Well now let's say this updates some item list. It's all good, but then you want to add a "summary count" at the bottom.
With regular callbacks you're sort of stuck. Do you make the myWidget.listenEvent a list of callbacks? Then you're refactoring a lot of code to:
myWidget.listenEvent.add(proc(e: WidgetEvent) = ...)
But then what if you accidentally add it too many times? Say this is setup in another widget dynamically. Maybe you need checks:
let myListener = proc(e: WidgetEvent) = ...
if myListener notin myWidget.listenEvent:
myWidget.listenEvent.add(myListener)
In contrast signals & slots are generally idempotent and work for both single listeners or multiple. You can do this as many times as you want and it'll result in the same:
let eventCounterDisplay = EventCounter()
...
proc updateCounts(e: WidgetEvent) {.slot.} = ...
connect(myWidget, listenEvent, eventCounterDisplay, updateCounts) # idempotent
Now combine that with the ability to combine them with multi-threading and you start seeing that they can be very helpful. You can make the listener in this case run on another thread or in a thread pool:
let proxy: AgentProxy[Counter] = dst.moveToThread(worker)
connectThreaded(myWidget, listenEvent, proxy, setValue) # local -> remote
Now that worker could be a thread dedicated to running a local db synchronization or whatever.
Here's an example based on real sensor processing code. No callback fields needed to be added to any of these items. It was easy to add optional support for a new devices by connecting events in new ways.
You can see I started with an event sensorDataUpdated with one listener, and then added another over time. E.g. it's more of a mix & match from the outside without modifying say device.auxSensor itself. It's easy to search for sensorDataUpdated signal across the codebase.
Since they have their own threads they can sleep, block, whatever without me worrying about async blocking on some processing taking too long, etc. Especially nice is adding a "connect(lte, lteSignalUpdated, self, logLteSignal)` for quick debugging of an event chain from one file.
device.wsPublisher = initWsPublisherThread(device.state)
device.dataProcessor = runDataProcessorThread(device.predictionMode)
## Start Threaded Handlers
if useLocalManagers:
device.auxSensor = runAuxSensorThread()
device.gpsManager = runGpsManagerThread()
device.lteSignalManager = runLteSignalThread()
else:
info "Skipping local sensor managers for REST backends"
if enableBatteryBle:
device.batteryStatusManager = runBatteryStatusThread()
## Wire Up Events
if useLocalManagers:
connectThreaded(device.auxSensor, sensorDataUpdated,
device.dataProcessor, DataProcessor.setLastAuxReading())
connectThreaded(device.auxSensor, sensorDataUpdated,
device, DeviceManager.updateTemp())
connectThreaded(device.gpsManager, gpsDataUpdated,
device.dataProcessor, DataProcessor.setLastGpsReading())
connectThreaded(device.gpsManager, gpsDataUpdated,
device, DeviceManager.updateGpsSignal())
connectThreaded(device.lteSignalManager, lteSignalUpdated,
device, DeviceManager.updateLteSignal())
if enableBatteryBle:
connectThreaded(device.batteryStatusManager, batteryStatusUpdated,
device, DeviceManager.updateBatteryStatus())
connectThreaded(device.dataProcessor, readingProcessed,
device.wsPublisher, WsPublisher.wsReadingUpdated())Thank you!!!
Okay so I think it's kind of dawning on me- so it's a multithreading-native event handler pattern- unlike addEventListener, which is async/callback native.
If it's just callbacks and locks, where does the performance overhead come from? Mandatory copies, like channels? Lock waits?
Okay so I think it's kind of dawning on me- so it's a multithreading-native event handler pattern- unlike addEventListener, which is async/callback native.
Yes the multi-threading is a big benefit of this approach since the idiom decouples method invocation and the actual method(s).
And Sigils work with ARC/ORC without atomic pointers. You can't send refs in signals in that mode, but the Agent refs themselves are managed using channels and moves. It all passes thread-sanitizer and stress testing benchmarks!
I recently just added a thread pool setup so you just do emit foo.doSomething() and it'll run it on the next available worker thread. Each Agent like foo is serialized to run on one thread at a time to avoid data races.
If it's just callbacks and locks, where does the performance overhead come from? Mandatory copies, like channels? Lock waits?
Bit of both dynamic named lookups and copies/channels.
First it's really named callbacks. E.g. semantically it's Table[SignalName, set[Slots]]. That extra indirection allows runtime dynamic behavior at the 10x cost to methods. Each Agent object can subscribe to any signal with the correct parameter types at runtime (or not).
My earlier implementation copied the signal args even for local calls. That last big performance jump from 100x to only 10x overhead was due to adding a "local" call that uses pointers to the arguments similar to how QT does it.
Threaded Agents (e.g. AgentProxy) use channels and locks with that extra overhead.
Sigils work with ARC/ORC without atomic pointers
That's pretty cool.
The price is tight coupling, right? I can't bring my own thread pool or use arbitrary Nim threads- or can I? Tight coupling is- for me- a big reason whether I can chose a library quickly, or whether it's a more involved process. Loosely coupled it's a nobrainer, but if you start needing to use the library's flavor of further libraries, you get escalating complexity. Of course sometimes it's necessary.
it's really named callbacks [...] allows runtime dynamic behavior at the 10x cost to methods
Do you think it might be possible to do everything at compile time, so the core event logic becomes a switch statement? That wouldn't allow runtime-assigned names, only compiletime-assigned names, but it would be really fast, and keeping all the expressiveness at the price of longer compile time and the macro writing overhead.
Of course I can't know whether this is a good idea for sigil, it's just an interesting compulsion I get whenever I see tables in a hot path.
The price is tight coupling, right? I can't bring my own thread pool or use arbitrary Nim threads- or can I? Tight coupling is- for me- a big reason whether I can chose a library quickly, or whether it's a more involved process.
Sigils does provide its own threads as a convenience but the coupling is fairly loose.
However it’s very easy to start and manage your own threads and manually run the sigils event loop. You can just call Sigils poll, etc. Or just copy the default thread module, it’s 100 LOC.
There’s also a Sigils thread implementation that works with stdlibs selectors so you can integrate with io events or efficient event timers. So Sigils uses the modularity itself.
It all goes around a few method subclasses so it’s easy to subclass and customize. Should be similar for threadpools.
coupled it's a nobrainer, but if you start needing to use the library's flavor of further libraries, you get escalating complexity. Of course sometimes it's necessary.
True, and partly why I don’t like async stuff which always spreads.
The signal / slots paradigm is fairly nice in that it can be used as regular procs still so it doesn’t have to spread.
Do you think it might be possible to do everything at compile time, so the core event logic becomes a switch statement?
Some subset of it probably, but since they're inheritable it'd be really hard to track. Especially as a slot can connect to any signal with the right shape you'd have to track all signal x compatible slots -- could be a lot.
Plus you couldn't do fun things like dynamically create runtime signal names to represent all the states in a state machine! Not that I've done that but it sounds fun. ;)
Of course I can't know whether this is a good idea for sigil, it's just an interesting compulsion I get whenever I see tables in a hot path.
haha yeah I switched out the naive table impl a while back. Subscriptions are a sorted seq using binary search with stack based strings and seems pretty fast! Most objects only have a few subscribtions. Keep in mind 10x a Nim method call is still measured in nanoseconds nowadays. ;)
Plus you couldn't do fun things like dynamically create runtime signal names to represent all the states in a state machine! Not that I've done that but it sounds fun. ;)
And of course QT already did this eons ago! https://doc.qt.io/archives/qt-5.15/statemachine-api.html