Deferred construction with metatypes

i want to have some kind of abstraction that looks like

struct Plugins:Sendable
{
    private
    let plugins:[any Plugin]
}

and i could just do that if Plugin: Sendable. but Plugin cannot imply Sendable because most of the plugin types wrap a ClientBootstrap, and well, you get the picture.

so i refactored the Sendable parameters to Plugin.init into an associated Options type, and Plugin now looks like:

public
protocol Plugin<Options>
{
    associatedtype Options:Sendable

    init(options:Options)
}

but you can’t really store a (any Plugin.Type, ???.Options) directly in an array, so the best thing i could think of doing was:

protocol PluginBootstrapBootstrapType<Plugin>:Sendable
{
    associatedtype Plugin:ThisModule.Plugin

    var options:Plugin.Options { get }
}
extension PluginBootstrapBootstrapType
{
    consuming
    func create() -> Plugin { ... }
}
struct PluginBootstrapBootstrap<Plugin>:Sendable 
    where Plugin:ThisModule.Plugin
{
    let options:Plugin.Options
}
extension PluginBootstrapBootstrap:PluginBootstrapBootstrapType
{
}
struct Plugins:Sendable
{
    private
    let plugins:[any PluginBootstrapBootstrapType]
}

this felt like a ton of ceremonial machinery to satisfy a detail of the type system. is there any better way to represent this?

If you really only need one method, you can always use a closure instead. With the approach you took, you can justify each part separately:

  • The struct is effectively being your (type, options) tuple, except the Plugin.Type is stored in the Self type instead of instances. (Which is what gives you access to Plugin.init(options:).)
  • The new protocol lets you store heterogeneous values in the array.

(You've also made your example artificially bad by repeating the word “Bootstrap”.)

Another approach would have been to leave creation out of Plugin, and make a new PluginCreator protocol instead that refines Sendable. That’s more work for plugin implementers, because now they have to provide two types and implement two protocols, but it exposes the separation of concerns (and allows multiple creators for the same Plugin type, which could be useful in some circumstances). In particular, PluginCreator doesn’t need an Options type, because that’s just Self.

a “plugin” in this context is really just a task that runs asynchronously on a server; in a world where Sendable didn’t exist, the full protocol would look like

public 
protocol PluginProtocol:Identifiable<String>
{
    associatedtype StatusPage:AtomicReference 
        where StatusPage.AtomicRepresentation.Value == StatusPage 

    var status:ManagedAtomic<StatusPage> { get }

    func run() async
}

and i would launch the plugins like:

(tasks:inout ThrowingTaskGroup<Void, any Error>) in

for plugin:any PluginProtocol in plugins
{
    self.statusPages[plugin.id] = plugin.status 

    tasks.addTask { await plugin.run() }
}

since plugins are tasklike, having multiple creators for the same PluginProtocol type is not useful to me - the more common case is to have a few options structures that launch multiple “plugins” that run in parallel. (so maybe the name plugin isn’t great, but i did not want to call it *Task since that’s already taken by the standard library.)

i’ll acknowledge this is a problem i created for myself because when i wrote most of the plugin types, i just had them store instances of HTTP clients. setting up a NIO client involves a lot of moving parts and it was easier in the short term to just treat the HTTP clients as values instead of constructing them deliberately in the actual task bodies. a lesson for 2024…

1 Like