Signals for Golang.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
Benjamin Shelton 1fe1dc2707 v2 moved to separate branch. 1 month ago
naive Re-home project. 1 year ago
signal-gen Add OnDisconnect and additional unit tests. 1 year ago
unittest Added generated unit test sources. 1 month ago
.gitignore Added generated unit test sources. 1 month ago
LICENSE Updated copyright. 1 month ago
Makefile Rename benchmark -> bench. 1 year ago
README.md Spelling. 1 month ago
go.mod Re-home project. 1 year ago
signal.go.tpl Added signals template for possible external use. 1 year ago
signal_generate_test.go Updated number of attached signals and value comparison for consistency 1 month ago
signals.go Fixed typos in comments and improved clarity, adding notes. 1 year ago
signals.json Added "funonly" for exporting only the functions not the data type. 1 year ago
signals_test.go Updated number of attached signals and value comparison for consistency 1 month ago

README.md

Signals: A Simple Signaling Framework

Signals currently composes two API versions that have diffrent semantics and inspiration. Both will be maintained for the forseeable future as each version addresses a slightly different problem scope. The APIs semantics are incompatible.

Insofar as client code is concerned, integrator usage is mostly analogous between versions. Implementors should pick the version that best fits their requirements.

Signals APIv1 is simpler for library developers and has slightly better performance for most use cases than APIv2 and consists of three implementations: The "default" implementation, which is considered the v1 canonical implementation; the naive implementation containing a simplified API that eschews runtime- and compile-time type safety in favor of reflection (and is slower) but has the propensity to blow up unexpectedly; and finally the generator implementation that uses go generate to create custom signals based on a descriptor file (currently a JSON-formatted list of signals and their types). The generator implementation holds the best performance characteristics of all versions but is the most difficult to use.

The v1 API is, generally, faster than the v2 API with the exception of the naive implementation which is roughly on par with APIv2's worst case performance.

APIv2 is more complex for library developers (implementors) but retains many of the same semantics for library consumers (integrators). However, the v2 API is also significantly more powerful and consists of only a single implementation. Some degree of runtime type safety is provided by the Emit API but runtime type checks are more consistent when using the EmitFunc and EmitFuncContext APIs.

APIv2 provides no compile time type guarnatees. If this is a requirement for your project, you'll have to stick with APIv1's generator implementation.

The most noteable difference between the two API versions lies in the extensibility of the v2 API and its ability to return values from within a signal's call. The v1 API currently lacks any ability to return values at a point where the signal Send is triggered (Emit* in v2) and consequently cannot interrupt the signal's call chain. The v2 API, in contrast, does allow a degree of preemption; when using EmitFuncContext, if the Context instance returns an error or sets its Stop value to true, the signal call chain is interrupted and no further signals are processed.

The v2 API is inspired by Qt's signals and slots. Indeed, care is taken to ensure that any callable can be used as a connection function ("slot" in Qt parlance), and provided EmitFunc* calls are used by implementers, the performance reduction over v1 isn't significant but it does require runtime type checks to validate incoming functions.

New features are prioritized for APIv2. These same features may or may not eventually be ported (or implemented) in APIv1.

Versioning

To use the v1 API in your project:

import (
    "git.destrealm.org/go/signals"
)

To use the v2 API in your project:

import (
    "git.destrealm.org/go/signals/v2"
)

APIv1 Quickstart

This quickstart guide covers only the most basic usage of the v1 API.

To use the v1 API, it is necessary--at a minimum--to define a type encapsulating the expected function call signature for functions that you intend too use as the basis for your signals. In our example, we will demonstrate a type (OnSignalTest) and introduce the general API.

Default; or, our Canonical API

package main

import (
    "fmt"

    "git.destrealm.org/go/signals"
)

type OnSignalTest func(string)
var SigTest = signals.NewSignal()

func main() {
    SigTest.Connect(OnSignalTest(func(s string){
        // Handle the incoming string.
        fmt.Println(s) // Prints "example signal value"
    }))

    // Library code would call the signal as:
    SigTest.Send(func(fn interface{}){
        if f, ok := fn.(OnSignalTest); ok {
            f("example signal value")
        }
    })
}

In this example we introduce a few concepts. First, we declare a new function type OnSignalTest which we use for runtime type validation. Second, we declare a package-level reference to a new siganl SigTest. If we were writing a library using signals, this value would be exported and documented as our primary entrypoint for our package's signals. Thirdly, we introduce the signal's API calls Connect and Send.

Connect attaches a function, cast to the desired type, to handle the signal whenever it is raised.

Send, while called in our main() function, would be triggered for the (hypothetical) library whenever a signal is to be raised and the value passed to it will be passed to all functions registered via Connect.

Connect can be called multiple times across multiple libraries, all for the same signal, and each of the registered functions will receive the same value.

It is possible to disconnect signals after they've been registered by defining a key type and passing the key's value into the Connect function. Modifying our example with this in mind would be implemented as:

package main

import (
    "fmt"

    "git.destrealm.org/go/signals"
)

type OnSignalTest func(string)
var SigTest = signals.NewSignal()

type disconnect int

func main() {
    var disconnectKey disconnect = 1

    SigTest.Connect(OnSignalTest(func(s string){
        // Handle the incoming string.
        fmt.Println(s) // Prints "example signal value"
    }), disconnectKey)

    // Library code would call the signal as:
    SigTest.Send(func(fn interface{}){
        if f, ok := fn.(OnSignalTest); ok {
            f("example signal value")
        }
    })

    // Disconnect our signal.
    SigTest.Disconnect(disconnectKey)
}

The value of the disconnection key will need to be unique for each function that is intended to be disconnectable, a new type must be defined for each package, or both.

Note: Disconnection support is currently only available in the v1 API.

Generator-based API

The generator-based v1 API is virtually identical to the canonical implementation but requires the additional step of generating the signal code for your project using the generator binary and then importing that library into your code.

To build the generator one must run:

$ go build -o generator ./signal-gen

and then create a signals.json file at the top-level of your project.

The schema for this file is outside the scope of this README and will be added to the signal-gen package in the near future. Refer to the top-level signals.json file in this package for an example.

Once your signals code has been generated, it may be imported as with any other package. Our example would then be modified as:

package main

import (
    "fmt"

    "git.destrealm.org/go/signals"
    // Generated signals.
    "yourproject/generated"
)

func main() {
    // We assume we have a generated signal type called SigTest.
    sig := generated.NewSigTest()
    sig.Connect(func(s string){
        // Handle the incoming string.
        fmt.Println(s) // Prints "example signal value"
    })

    // Library code would call the signal as:
    sig.Send(func(fn func(s string){
        fn("example signal value")
    })
}

In this example, our SigTest signal is now a package-level component of our generated code and can be instantiated directly using NewSigTest. Constructor functions are generated automatically and will always follow the semantics of the signal name prefixed with New.

Additionally, all runtime type checking has been abandoned for compile time type safety*, and the API is substantially cleaner than the canonical implementation. Further, runtime type checking has been replaced by *compile* time type checking.

For particularly performance sensitive code or code that focuses on correctness using the generated signal code may be more appropriate.

Naive API

As with the canonical API for our base implementation, the naive API is fairly similar with two exceptions: 1) No function types need to be defined and 2) the Send function has been renamed to Emit to avoid confusion with the other v1 APIs.

Our example above could be rewritten as:

package main

import (
    "fmt"

    "git.destrealm.org/go/signals"
)

var SigTest = signals.NewSignal()

func main() {
    SigTest.Connect(func(s string){
        // Handle the incoming string.
        fmt.Println(s) // Prints "example signal value"
    })

    // Library code would call the signal as:
    SigTest.Send("testing")
}

As you can see, the naive API is significantly simpler.

N.B.: The v1 naive implementation is considered unstable.

There are some important deficiencies with the naive implementation that should preclude its use:

First, the naive implementation is not well-tested, nor are the unit tests for it currently complete. It may not function well (or at all) for every use case.

Second, the naive implementation is about an order of magnitude slower than either the canonical or generator-based implementations.

Use at your own risk.

APIv2 Quickstart

This quickstart guide covers only the most basic usage of the v2 API.

The v2 API borrows from some of the principles underpinning the canonical v1 implementation insofar as defining package-level signals and their respective function types. Additionally, the v2 API combines some of the philosophies of the naive implementation by introducing runtime type checking through heavy use of reflecton (albeit with more restrictions). This means that for most use cases, the v2 API will range from around 1-2 orders of magnitude slower than the v1 API; however, the v2 API reduces some complexity for library implementors. Likewise, performance reductions can be rectified somewhat by using typed signals which we'll cover here as well.

As with the canonical v1 API it is necessary to define both the package-level signal against which functions may be connected and a function type identifying valid signatures for signal clients. Continuing from our previous examples, we might adapt the sample code as follows:

package main

import (
    "fmt"

    "git.destrealm.org/go/signals/v2"
)

type OnSignalTest func(string)
var SigTest = signals.New((OnSignalTest)(nil))

func main() {
    signals.Connect(SigTest, OnTestSignal(func(s string){
        fmt.Println(s) // Prints "example signal value"
    }))

    // Library implementation call:
    signals.Emit(SigTest, "example signal value")
}

As you can see, from an implementer's perspective, emitting signals is significantly easier than it is in the v1 API. Client code remains roughly analogous, but the difference is that interaction with the signals is performed at the module's top level using signals.Connect and signals.Emit. It's possible to use the signal's API directly (Attach and Emit; these names may change in future versions), but using the top-level function calls as above provides some clarity as to the intent.

However, Emit's internal machinery leans heavily on the reflection package to determine passed-in value types and to provide some runtime type safety to ensure that the called function(s) match the expected signature. This is why the argument to signals.New() is a cast to a nil pointer of the expected function type; signals APIv2 requires some forewarning as to the types a signal is expected to handle, and the way to do this is to pass in a nil function pointer to examine with reflect.

As you might imagine, Emit is quite slow, comparatively speaking. This can be rectified using the EmitFunc* calls, as we'll see.

In the following example, we use type conversion to validate the type of the incoming function prior to use. This obviates use of the Go reflection API and wins back some performance that would otherwise be lost:

package main

import (
    "fmt"

    "git.destrealm.org/go/signals/v2"
)

type OnSignalTest func(string)
var SigTest = signals.New(nil)

func main() {
    signals.Connect(SigTest, OnTestSignal(func(s string){
        fmt.Println(s) // Prints "example signal value"
    }))

    signals.EmitFunc(SigTest, func(fn interface{}){
        if f, ok := fn.(OnTestSignal); ok {
            f("example signal value")
        }
    })
}

As you can see, we've adapted the call to Emit and replaced it with EmitFunc which allows implementers to test the incoming function against its expected type. Further, if the implementer uses only the EmitFunc call with no intention of using Emit, calls to signals.New() may replace the function pointer with nil thereby bypassing much of the reflection code used to determine valid incoming types. EmitFunc is also faster than Emit and is roughly on par with v1's default implementation and slightly slower than v1's generated code.

v2's EmitFunc also has another potential advantage (though this is shared with v1's canonical implementation): Functions of different type signatures may be connected to at any given time and the implementor can then use type conversion or type switching to handle differing function types accordingly. Of interest, it should be possible (though this isn't currently tested as of this writing) to pass in a function that itself has functions attached matching some particular inteface which could then be handled differently via a type switch!

APIv1's most critical omission is its inability to pass along signal status in the event of a failure nor does it provide any means for passively preempting signal execution. APIv2 rectifies this by introducing signals.Context which implements an interface providing two important functions: Error and Stop. If an error condition occurs during the signal processing chain, Error can be coerced to return an error. Likewise, Stop may be used when an error condition hasn't occurred, but the signal currently being processed wishes to abort any further processing.

EmitFuncContext is very similar to EmitFunc and can be used creatively to transform how signal processing is managed.

package main

import (
    "fmt"

    "git.destrealm.org/go/signals/v2"
)

type OnSignalTest func(string)
var SigTest = signals.New(nil)

func main() {
    signals.Connect(SigTest, OnTestSignal(func(s string){
        ctx := signals.NewSignalContext()
        if s != "example signal value" {
            ctx.SetError(fmt.Errorf("unexpected value"))
        }
    }))

    signals.EmitFuncContext(SigTest, func(fn interface{}) signals.Context {
        var ctx signals.Context
        if f, ok := fn.(OnTestSignal); ok {
            ctx = f("example signal value")
            if ctx != nil && ctx.Error() != nil {
                // Handle error.
            }
        }
        return ctx
    })
}

As of this writing we acknowledge that the signal context is fairly spartan in its implementation. Further, because signals.EmitFuncContext itself may return a context, library implementations may grow to be somewhat unwieldy. We expect to either eliminate the return from signals.EmitFuncContext or make it somewhat more useful by providing some allowance for context chaining. This API feature should be considered to be in a state of flux.

Documentation

Documentation is covered under the library's NCSA license and may be distributed or modified accordingly provided attribute is given.

Any documentation that appears on the project wiki will also be made available in the source distribution for offline reading.

License

signals is licensed under the fairly liberal and highly permissive NCSA license. We prefer this license as it combines the best of BSD and MIT licenses while also providing coverage for associated documentation and other works that are not strictly considered original source code. Consequently, all signals documentation is likewise covered under the same license as the codebase itself.

As with BSD-like licenses, attribution is, of course, required.