[Pitch] `@OptionSet` macro

Remember, key paths can’t reference static properties, so things like \.none (or \Optional<Int>.Type.none) won’t be legal even if this transformation is applied to enums. I have yet to see a good justification for why static key paths are illegal, though.

4 Likes
4 Likes

Note: I accidentally deleted this, so reposting. I revised my take a bit. I'm overall neutral on this, but I think it could be interesting and would work nice with this macro variation suggested by user @1-877-547-7272.

I think this has some interesting implications beyond option sets to anywhere literals are defined.

OptionSet Macro Example
@OptionSet<UInt8>
struct ShippingOptions {
  case nextDay
  case secondDay
  case priority
  case standard = 0b1000_0000

  static let express: ShippingOptions = [.nextDay, .secondDay]
  static let all: ShippingOptions = [.express, .priority, .standard]
}

case as a singleton for constant value types

I think case could be used as a singleton for constant value types that conform to Equatable. It basically already is for enums. It shouldn't be allowed for reference types like classes/actors or for values with nested reference types. I think it might improve readability– particularly for those new to Swift since it is easy to correlate case with singleton values you test against. It might be great metadata for documentation organized by cases or generating case name based coding implementations.

If this were done, I’d say:

case nextDay
case secondDay

roughly transforms to: (the values just can't be calculated at run time)

static let nextDay: Self
static let secondDay: Self

And it should work that way for enums too. Including for use in Key Paths. If this fits in to unifying enum cases with key paths then it might be a great idea.

If this is true, no reason this shouldn’t be allowed too:
(static let might be more appropriate depending on the details)

 // #expression represents whatever the compile time eval macro is
case express = #expression([.nextDay, .secondDay])

There is implied a limited set of options to cases. The compiler uses this to check for bugs in your switch statements. I think this is the biggest reason not to do this since option sets are sets and not enums. It doesn't make sense to match an option set exhaustively.

If exhaustivity wasn't an issue, then it should be extended to other singletons like within NSNotification.Name extensions.

Since case would take its type from the value-type it is contained in, it would not be possible to use top-level or within a function. The same as case in an enum.
​ ​

roughly:

static let nextDay: Self = unsafeBitCast(0 as UInt8, to: Self)
static let secondDay: Self = unsafeBitCast(1 as UInt8, to: Self)

There's also rawValue issues but that's separate.

It actually does make sense... just hasn't been done in swift so far...

Pseudo code:

enum ShippingOptions = { fast, reliable }
let val: OptionSet<ShippingOptions> = ...

switch val {
    case []: print("nope")
    case [.fast]: print("fast")
    case [.reliable]: print("reliable")
    case [.fast, .reliable]: print("fast and reliable")
    // and there is no default case as it can't possibly be anything else
}
3 Likes

I like your pseudo code version of an option set. It might be worth considering as another idea for an option set macro. I like it because it feels more like a set of elements.

OptionSets should support set-based pattern matching in the future

// The literals are specified separately
enum ShippingOptions: Int { case fast, reliable }

// Conforms to FixedSetProtocol for exhaustive pattern matching on sets.
// An associated type for literals is autogenerated in this case.
@OptionSet<ShippingOptions> struct ShippingOptionsSet: FixedSetProtocol

extension ShippingOptionsSet {
  static let all = [.fast, .reliable]
}

Alternatively, I like the idea of this as a type, but that would be yet another kind of macro. Although the implementation would probably be almost the same as declaration macros.

typealias ShippingOptionsSet = @OptionSet<ShippingOptions>

I think it might be important to make sure switch exhaustivity checks could be performed in the future with pattern matching. Option sets would conform to a protocol (FixedSetProtocol above) that turns on exhaustivity checks.

// exhaustive checks with hypothetical set based pattern matching
switch options {
case []: print(“none”)
case .fast: print(“fast”)
case .reliable: print(“reliable”)
case [.fast, .reliable]: print(“fast and reliable”) // or `case .fast & .reliable:`
case .all: print("the kitchen sink") // redundant, just an example
// no default needed since every literal is specified so it will always exit
}

I'm actually starting to see the potential rationale behind creating a new type for option sets.

Option sets sit in an awkward space between enums and structs, as evidenced by the fact that the proposal would require two separate types to perform its goal. Currently, we have a set of values that have a small memory footprint and statically-allocated size. There's awkward boilerplate when it comes to constructing the bitmask part of it, however. In a lot of cases it would also be nice to be able to switch over individual options within that set.

This proposal addresses the awkward boilerplate, but adds an awkward extra type for all the individual cases. This could be used for exhaustive switch statements, but it would be preferable, IMO, if the individual options and the bitmask were unified in a single type.

Although I didn't at first, I actually find myself agreeing with @davedelong that we should instead investigate some alternative. I wouldn't be opposed to a new value type alongside struct and enum.

optionset MyOptions: UInt64 {
  case fast = 0 // refers to bit offset
  case reliable = 1
}

/*
synthesized:
extension MyOptions {
  // both of these could return nil with invalid values
  init?(rawValue: UInt64) { ... }
  init?(bitOffset: some BinaryInteger) { ... }
}
*/

let emptyOptions: MyOptions = []
var bitOffsetOption = MyOptions(bitOffset: 1) // ?[.reliable] 
var multipleOptions = MyOptions(rawValue: 3) // ?[.reliable, .fast]
bitOffsetOption = MyOptions(bitOffset: 42) // nil
multipleOptions = MyOptions(rawValue: 42) // nil

optionset MyNonCompilingOptions: UInt8 {
  case a, b, c, d, e, f, g, h, i // Error: too many options for memory layout
  case j = 9 // Error: bit offset too large
}

There would be a lot to work out with specifics (eg, semantics of switching through an optionset that contains multiple values), but I think this would be preferable to the band-aid solution of a macro.

8 Likes

For the sake of completeness, an OptionSet could also be derived from a bit field:

@OptionSet public struct ShippingOptions {
  public var priority: Bool
  public var air: Bool
  public var topSecretTeleportation: Bool

  public static let express = [.priority, .air]
}

I think it is more accurately a collection type with a literal type attached to it. So two types make sense when viewed from that perspective. The question is do you provide the literals using an enum or do you embed them as part of the type. I'm open to either, but my preference is to make it look like a set as much as possible.

However I see edge cases in future expansion of pattern matching, so I wonder if macros are really the right fit or if they are just a partial solution that will need to be rounded out with a language feature. Possibly they should just be built in to the language since they are pretty heavily used and that will help address all the edge cases that come up.

I think Doug's point from above should be reiterated here:

8 Likes

That is a reasonable limitation. However, I think there should be not a new type, but a language feature eventually to assist with exhaustivity testing of pattern matching of option set types. I think it might be general enough for some other types that are collections of literal elements if such a thing ever comes up. In fact I think enums could basically be a collection of one literal element. I see it as a special protocol the compiler could use to discover sets of literals for the checks.

Anyway, I think this just needs to be thought out since it can't be changed later. It makes sense from a language consistency stance to think about it so we can drop the default: case when it would otherwise be dead code and to find a class of bugs statically.

I wrote about switch exhaustivity testing at #93.

I like this proposal — defining OptionSets is something that must be done from time to time and the boilerplate is annoying. Macros are a good fit for this functionality. I much prefer the struct version for the reasons @Joe_Groff mentioned.

I’d clarify the functionality of the struct version though: the original pitch allowed us to explicitly choose which options corresponded to which bits (nextDay is bit 0, secondDay is bit 1, nobody use bit 2, priority is bit 3, etc.) by specifying a raw value on the Options enum. Is this possible using your alternative implementation? Maybe I missed this, but that sort of thing can be very useful when you need it.

Maybe just by specifying a default value in the static vars?

@OptionSet<UInt8>
struct ShippingOptions {
  static var nextDay: ShippingOptions = 0
  static var secondDay: ShippingOptions = 1
  // Nobody use bit 2 please
  static var priority: ShippingOptions = 3
  static var standard: ShippingOptions = 4

  static let express: ShippingOptions = [.nextDay, .secondDay]
  static let all: ShippingOptions = [.express, .priority, .standard]
}

Can someone make the case for me that option sets should not just be packed structs with a bunch of defaulted stored properties? My sense is that both the nested Option type and all the set algebra are generally just used to get niftier syntax for constructing and amending values in fairly concrete ways, like turning specific options on or off. If that's true, then the whole feature feels like a C-ism to me that we shouldn't be perpetuating, and it would be better for us to put effort into improving this kind of aggregate initialization / amendment for structs in general, e.g. by adding a syntax that desugars into calling a series of setters on a default-constructed value (or a similar syntax to amend an r-value). And packing boolean properties into bit fields is certainly something that we could just be doing in the compiler.

It's hard not to notice that a lot of these examples feel forced into this set-of-flags model. For example, the choice between next-day / second-day / priority / standard shipping is really an enumerated choice: you have to pick exactly one. A representation that pretends otherwise is a bad representation.

17 Likes

I thought OptionSet was always justified as a nicer representation of NS_OPTIONS from Obj-C. So they'll be around for as long as that bridge is necessary. Given the right syntax, this macro seems like an easy win. Perhaps the macro could enable better Swift syntax instead? That would still need changes to the bridge.

2 Likes

A realistic case of this came up during the review of SE-0211: Unicode Scalar Properties. Option sets would have been an interesting API to explore, since that would have mapped nicely to the ICU flag sets for those scalars, but we would have been limited to 64 Boolean properties and IIRC ICU was already exceeding that at the time, or was close to it. So instead, we wrote a bunch of computed properties by hand. (Although, I think another reason it wasn't feasible was because all the ICU properties would have to have been queried individually to form the bit set, so it may have not been a good fit anyway.)

A more Swift-native "set of flags" type that wasn't restricted by the limitations of an explicit raw integer type would certainly be useful.

4 Likes

I can definitely see the argument that this is useful in interop cases, but in interop cases you presumably also need to modify an existing type, not add one from scratch. Having a macro that can introspect an imported type to generate some boilerplate in an extension would be very useful, but that's beyond the currently-proposed capabilities of macros, and I doubt it would be the same macro as this anyway.

Part of my argument here is that the Swift-native "set of flags" concept is not useful. Configuration types are certainly important, and I think having better syntax to make them easy to initialize would be great. But as that configuration expands, it's very likely to grow in ways that fundamentally conflict with the "set of flags" idea, which starts dragging the whole thing back and forcing configuration to be split into the parts that suit a set of flags and the parts that don't. So I feel pretty strongly that it's better to make a simple struct the right tool for the job.

3 Likes

I'm occasionally making use of an extension like this:

extension OptionSet {
	subscript (flag: Element) -> Bool {
		get { contains(flag) }
		set {
			if newValue {
				insert(flag)
			} else {
				remove(flag)
			}
		}
	}
}

This lets me to treat flags as boolean values, almost like properties:

var style: NSWindow.StyleMask = []
style[.borderless] = true
style[.closable].toggle()
style[.resizable] = style[.closable]

I find it's often easier to reason with that than set algebra. So if a macro (or a language mechanism) was to add properties directly so the above can be rewriten as:

var style: NSWindow.StyleMask = []
style.borderless = true
style.closable.toggle()
style.resizable = style.closable

then I'm all for it. But the subscript solution is pretty good too.

9 Likes

No need to go that far, but won't OptionSets be critical for the Foundation in Swift rewrite? So we'll need the capability to write them from Swift and have them appear to Obj-C exactly as if they'd been written there.

3 Likes

The reverse-interop use case is definitely interesting, thanks for reminding me of that. I think the current vision for the Foundation-in-Swift rewrite doesn't completely abandon ObjC headers for that kind of thing, though; e.g. see the pitch for implementing ObjC @interfaces in Swift.

1 Like

Few considerations:

  • you said "structs". Perhaps you could've picked tuples as well? Especially the special case of homogeneous tuple (should we have it one day).
  • we do not have packed structs yet (where Bool fields are packed as bits) - important for C interop.
  • with the struct approach (as well with some other solutions including OptionSet) you may have other unrelated stored variables in the struct. This is different to enum-like approach (you can't have unrelated stored variables).
  • contains([.several, .fields, .at, .once]) would be harder to do with a struct full of bools / bits
  • likewise intersection / union set operations would have to be implemented manually
  • with structs it is hard to express "gaps", e.g. bits 0, 1, 2, <gap here>, 63 - (would you create some dummy unused bits?). could be important for C interop.
  • on the bright side structs can represent more than 64 values.
  • switch exhaustivity might be a factor (doesn't currently work with structs – heck, not even with UInt8! – but works with tuples and enums).
  • I am not against C-ism per se (each feature should be weighted on its own merit) but structs with bits could be seen as another form of C-ism - bit fields.
2 Likes

I really have no idea about that, other than to say that just looks like a random post at the end of thread (I don't know that person's impact) and that the ability suggested there would seem rather antithetical to the rewrite's cross platform goals. If the Apple version of Foundation-in-Swift just calls into some Obj-C implementation, we still have two implementations to deal with, greatly undermining the rewrite's benefits.