Static property wrappers? (or: attaching static metadata to a stored property)

i’ve got tons of “stats” structures that look like:

@frozen public
struct ByStatus
{
    public
    var ok:Int
    public
    var notModified:Int
    public
    var multipleChoices:Int
    public
    var redirectedPermanently:Int
    public
    var redirectedTemporarily:Int
    public
    var notFound:Int
    public
    var errored:Int
    public
    var unauthorized:Int
}
@frozen public
struct ByLanguage
{
    public
    var zh:Int
    public
    var es:Int
    public
    var en:Int
    public
    var ar:Int
    public
    var hi:Int
    public
    var bn:Int
    public
    var pt:Int
    public
    var ru:Int
    public
    var other:Int
    public
    var none:Int
}

et cetera, et cetera.

each stored property has some associated strings for things like display text and css class, and iterating over wide tuples is awkward and takes a long time to format:

for (state, style, value):(String, String, Int) in
[
    ("Multiple Choices",        "multiple-choices",         self.multipleChoices),
    ("Not Modified",            "not-modified",             self.notModified),
    ("OK",                      "ok",                       self.ok),
    ("Redirected Permanently",  "redirected-permanently",   self.redirectedPermanently),
    ("Redirected Temporarily",  "redirected-temporarily",   self.redirectedTemporarily),
    ("Not Found",               "not-found",                self.notFound),
    ("Errored",                 "errored",                  self.errored),
    ("Unauthorized",            "unauthorized",             self.unauthorized),
]
{
    if  value > 0
    {
        chart.append(.init(
            stratum: stratum,
            state: state,
            value: value,
            class: "status \(style)"))
    }
}

i would much rather have this information tied to the properties themselves, using something like

@frozen public
struct ByStatus
{
    @Statistic(class: "ok", display: "OK")
    public
    var ok:Int

    @Statistic(class: "not-modified", display: "Not Modified")
    public
    var notModified:Int

    ...

but we cannot actually define such a property wrapper using a concrete type Statistic, because we obviously do not want to store a constant string pointer inside every ByStatus structure!

so we need to define a bunch of bespoke type-constants to push this information into the generics system.

extension ByStatus
{
    @frozen public
    enum OK:StatisticalCategory
    {
        static
        var style:String { "ok" }
    }
}
extension ByStatus
{
    @frozen public
    enum NotModified:StatisticalCategory
    {
        static
        var style:String { "not-modified" }
    }
}

...
@frozen public
struct ByStatus
{
    @Statistic<OK>
    public
    var ok:Int

    @Statistic<NotModified>
    public
    var notModified:Int
    ...
}

but by now, you have to wonder if the cure is worse than the disease.

surely there must be a better way?

1 Like

Oh, this is painful... There are several different approaches.

  1. There's a note in the generic manifesto called "Generic value parameters". Basically it would allow you to use compile time constants as generic parameters. And it makes me so sad no one is doing an actual proposal. Apart from your case it would also enable us to have static homogeneous indexed collection (like Array<T>, but Buffer<T, 42>, like in C), instead of tuples (which are heterogeneous, and hardly iterable).

  2. (this is what I used until recently). Threat your structs two distinct ways: 1) as a tree of metadata 2) as an actual storage.
    It goes like this:

@propertyWrapper enum Statistic {
  case meta(class: String, display: String)
  case value(Int)

  init(wrappedValue: Int) { ... }
  init(class: String, display: String) { ... }

  var wrappedValue: Int {
    switch self {
    case let .value(v): v
    case .meta: fatalError() // ouch
    }
  }
}

struct ByStatus {
  @Statistic(class: "ok", display: "OK")
  public var ok:Int
    
  init() {}
  init(ok: Int, ...) {
    _ok = Statistic(wrappedValue: ok)
    ...
  }
}

Then you can make two values:

let meta = ByStatus()
let value = ByStatus(ok: 42)

And then you iterate over keypaths or Mirror.children of value, and mix-in values from meta.
I suppose it's needless to say this isn't prettiest solution, and you have to put a lot of asserts and write a pile of tests for it to be considered robust.
This is kinda what GitHub - apple/swift-argument-parser: Straightforward, type-safe argument parsing for Swift does.

  1. (this is what I use now) Just don't mind the extra costs. You said

we obviously do not want to store a constant string pointer inside every ByStatus structure

But it's still an option. Yes, this isn't great, but it's not always a problem. At least it worth considering. In my case I don't have a lot of instances of my structs, so I'm fine with it.

  1. Maybe macros can help? I'm not 100% sure, but it seems possible to write a macro, that will mimic the behaviour of "Generic value parameters" by generating types of StatisticalCategory inplace. You declare a macro @StatisticHelper that is attached to the struct, and @Statistic that is attached to the members. @StatisticHelper will be run first, so it can analyze uses of @Statistics and emit appropriate static enum helpers.
3 Likes

this is pretty interesting to me, i wonder if you could avoid the runtime assertions by making ByStatus generic over Int, Metadata or something like that. on the other hand, it’s not clear to me how to convert a keypath of type KeyPath<ByStatus<Int>, Int> to KeyPath<ByStatus<Metadata>, Metadata>.

i’m still new to macros, but i’m wondering how this would compose with localizations. for now, i’m just needing a metadata tuple of (display text, css class name), but it won’t be long until there are many possible display texts to choose from.

anyway, a lot to possible directions to investigate…

1 Like