[Pitch] `@OptionSet` macro

That's not an OptionSet though. If you have to specify the backing storage type anyway, why not macro-generate values for a property wrapper instead? (This compiles. I like the lack of explicit typing.)

struct ShippingOptions: OptionSet {
  @Option(0) static var nextDay
  @Option(after: _nextDay) static var secondDay
  @Option(3) static var priority
  @Option(after: _priority) static var standard

  let rawValue: UInt8
}
/// An "option", represented by a single bit flag.
@propertyWrapper public struct OptionSetOption<Options: OptionSet>
where Options.RawValue: BinaryInteger {
  public init(_ bitFlagIndex: Options.RawValue) {
    wrappedValue = .init(rawValue: 1 << bitFlagIndex)
  }

  public let wrappedValue: Options
}

public extension OptionSetOption {
  /// An option with the next higher bit flag.
  init(after previous: Self) {
    wrappedValue = .init(rawValue: previous.wrappedValue.rawValue << 1)
  }
}

public extension OptionSet where RawValue: BinaryInteger {
  typealias Option = OptionSetOption<Self>
}

Macro-fication would maybe be like

@Option static var nextDay
public extension OptionSetOption {
  public init(_: MacroMagic = HoweverMacrosWork) {
    self.init(🪄 as Options.RawValue)
  }
}

(I still think this is all uglier than the tuple solution. It's probably a better use of time to switch to making it so that property wrappers can apply to multiple variables, integrating with variadic generics.)

For whatever it's worth: I would, and I have never written a line of compiler code in my life, and I consider myself an app developer. ¯\_(ツ)_/¯

6 Likes

I don't think that there are any technical arguments for either singular or plural spelling. Both works from a technical point of view, non? I think the only argument here is whether one or the other "looks unpleasant", and that is a matter of opinion, not fact.

Personally, I think the suggested plural spelling "looks pretty unpleasant", but that's just my opinion.

However, I really like this pitch. Would use! Would recommend to others! <3

1 Like

What about improving the compiler to check all options have unique bit masks as It does with RawRepresentable enums?

It will also be convenient if automatic conformance of CaseIterable or some respectful analog (like OptionsIterable) will appear for OptionSet.

In my opinion, the best syntax for this would be something like:

@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]
}

Would it be possible to use a macro to enable something like this? If not, then should we consider making it possible?

1 Like

I really like this approach. Using an enumeration as a schema sounds good on the surface, but it doesn’t capture .express in this example.

This might be cool if a macro could double as a protocol with a synthesized implementation. It would need to go further then just synthesizing protocol conformance in this case, but maybe that is ok. Particularly if it is otherwise just synthesizing unimplemented declarations. It would help with documentation of synthesized methods too.

Another idea is this could be sugar for a macro and a protocol with the same name.

1 Like

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