[Pitch] `@OptionSet` macro

Would it be appropriate to suggest different diagnostic text here or on the PR?

More than that. It also adds a stored property, something that cannot be done in an extension (being able to do so in the same file has been pitched... personally I'm against such a change, see that thread for details).

Now, macros could potentially support going back to the definition if it's in the same file and adding things. But it still doesn't seem like a good idea to add properties from an extension, even via a macro.

3 Likes

This strongly settles this case :+1:

please don't forget about VSCode users! not everyone is using XCode :slight_smile:

5 Likes

+1. Declaring option sets is a bit of a pain, and it would be nice to have it automated.

I think Options is the right choice for the nested type. Since this is an OptionSet, I think it is logical to think of it as containing multiple options. The cases in that type are the available options. It's good.

1 Like

I like the idea of this becoming a macro, but like others here, I'm not a fan of needing the nested type to provide the basic option set. Types leave metadata behind that's still as of yet hard for the compiler and linker to reliably strip away, and if the author of an option set doesn't remember to make the seed option enum private, it seems like could become a point of confusion in the API for their coworkers. If attached macros are able to recognize the use of other macros within the body, would it be possible to do something like:

@OptionSet struct ShippingOptions {
  @Option static var nextDay, secondDay, priority, standard
}

where the @Option annotation takes care of doling out raw values for the options, and choosing the appropriate underlying bits type? That seems to me like it gets fairly close to your "first-class optionset declaration", while still also being less indirect than the proposed behavior, since the author of the code is still directly declaring the static vars that make up the API of the type. An approach like this also lets developers control access to individual options, as in:

@OptionSet public struct ShippingOptions {
  @Option public static var nextDay, secondDay, priority, standard

  @Option private static var topSecretTeleportation
}

which doesn't seem possible using a separate type to derive the options.

22 Likes

That is incorrect though. The conforming OptionSet type is a collection (not Collection) / a set of options, but each individual option is still a single option and not an OptionSet on its own. Options (plural) is therefore logically not right.

5 Likes

An Options suffix makes sense for the OptionSet type, as it can have multiple values in one bitmap. That's the entire point of an OptionSet in the first place. An enum declaring the individual option values can only have one value, so the plural doesn't make much sense. Enums should almost never be plural given the cases are used like a single, exclusive value.

8 Likes

I read it differently. The type itself is just a declarative element which lists the available options, so I think it works just fine as a plural.

Think of it like a function's argument label more than a type name:

optionset(options: [....])

or

OptionSet {
  Options {
    a, b, c
  }
}
1 Like

Still not correct IMHO. The type name mostly represents the kind of an instance you will have in the end.

let options_1: [Option] = ...
let options_2: [Options] = ...

options_1 is a single dimension set of options, but options_2 would suggest that its a multidimensional construct, which it is not.

struct ConfigurationOptions: OptionSet In this case the Options suffix suggests that the value of this type will likely contain multiple options, which @Jon_Shier already greatly explained.

I understand your point of gathering the available options under one name spacing parent type, but it doesn't make sense for an enum to be called Options in this particular case of this pitch.


Another example:

typealias Options = Set<Option>

struct Options: OptionSet {
  // each containing option will remain of type `Option`
}
4 Likes

It needs to be read in the context of declaring an option set. The context is all-important.

Of course, if you put the type's name in a different context, it will look strange and possibly misleading. I don't think that's a significant concern.

The expand macros refactoring action is implemented in SourceKit, so it is at least potentially usable from any editor.

1 Like

-1

Other than a slight further decrease in verbosity, there is no meaningful benefit to this approach over the use of a macro.

Disagree, this is a perfect example of where a macro creates confusion and indirection. In the below code we see a struct with private enum options. If I were just viewing this from an API perspective in code review, this wouldn't make any sense. This is a struct without any public members yet we get public option set behavior when there is nothing public.

@OptionSet
struct ShippingOptions {
    private enum Options: Int {
        case nextDay, secondDay, priority, standard
    }
}

Compared to the alternative: imminently readable, no indirection, no "magic" public types.

optionset ShippingOptions {
  case nextDay, secondDay, priority, standard
}

What is the criteria for choosing a macro here in the std library? Why not implement enums as structs with a macro too?

15 Likes

Why do think the context is more important than the type used to represent the option or even the RawValue?

In fact, the Option type can be exposed for good reasons and the RawValue type doesn't have to be a numeric type at all.

struct Options: OptionSet {
  var rawValue: Set<Option>

  enum Option: Hashable, CaseIterable {
    case a
    case b
  }

  static let a = Options(rawValue: [.a])
  static let b = Options(rawValue: [.b])
  static let all = Options(rawValue: Set(Option.allCases))
  static let empty = Options()

  init() {
    self.rawValue = []
  }

  init(rawValue: Set<Option>) {
    self.rawValue = rawValue
  }

  mutating func formUnion(_ other: __owned Options) {
    rawValue.formUnion(other.rawValue)
  }

  mutating func formIntersection(_ other: Options) {
    rawValue.formIntersection(other.rawValue)
  }

  mutating func formSymmetricDifference(_ other: __owned Options) {
    rawValue.formSymmetricDifference(other.rawValue)
  }
}

OptionSet isn't magic and isn't really hard to implement by hand.

2 Likes

What is the advantage of using static let over static var? Why not generate computed properties instead of stored properties? That is:

struct ShippingOptions {
  typealias RawValue = Int

  var rawValue: RawValue

  init() { self.rawValue = 0 }

  init(rawValue: RawValue) { self.rawValue = rawValue }

  static var nextDay: Self {
    Self(rawValue: 1 << Options.nextDay.rawValue)
  }

  static var secondDay: Self {
    Self(rawValue: 1 << Options.secondDay.rawValue)
  }

  static var priority: Self {
    Self(rawValue: 1 << Options.priority.rawValue)
  }

  static var standard: Self {
    Self(rawValue: 1 << Options.standard.rawValue)
  }

  private enum Options: Int {
    case nextDay, secondDay, priority, standard
  }
}

Aren't such vars optimized to (effectively) lets anyway, or am I misremembering that optimization? In any case, generating the code users would write manually is probably a better starting point unless there's improvements to be made.

Doesn't seem to warrant so much ceremony to me. Variadic generics will make this into one function and it's already usable enough as it is. So any other effort expended would be better spent there.

struct ShippingOptions: OptionSet {
  let rawValue: Int

  static let (nextDay, secondDay) = Self[]
  // this bit is cursed, don't use it
  static let (priority, standard) = Self[startingFlagIndex: 3]
}
A thing that makes options; doesn't matter what it is, really.
public extension OptionSet where RawValue: BinaryInteger {
  /// Provides two options.
  ///
  ///- Parameter startingFlagIndex: shifts 0b11 if > 0
  static subscript(startingFlagIndex startingFlagIndex: RawValue = 0) -> (
    Self,
    Self
  ) {
    ( .init(flagIndex: startingFlagIndex),
      .init(flagIndex: startingFlagIndex + 1)
    )
  }
  
  /// Provides three options.
  ///
  ///- Parameter startingFlagIndex: shifts 0b111 if > 0
  static subscript(startingFlagIndex startingFlagIndex: RawValue = 0) -> (
    Self, Self,
    Self
  ) {
    Tuple[
      Self[startingFlagIndex: startingFlagIndex],
      .init(flagIndex: startingFlagIndex + 2)
    ]
  }
  
  /// Provides four options.
  ///
  ///- Parameter startingFlagIndex: shifts 0b1111 if > 0
  static subscript(startingFlagIndex startingFlagIndex: RawValue = 0) -> (
    Self, Self, Self,
    Self
  ) {
    Tuple[
      Self[startingFlagIndex: startingFlagIndex],
      .init(flagIndex: startingFlagIndex + 3)
    ]
  }
  
  /// Provides five options.
  ///
  ///- Parameter startingFlagIndex: shifts 0b1_1111 if > 0
  static subscript(startingFlagIndex startingFlagIndex: RawValue = 0) -> (
    Self, Self, Self, Self,
    Self
  ) {
    Tuple[
      Self[startingFlagIndex: startingFlagIndex],
      .init(flagIndex: startingFlagIndex + 4)
    ]
  }
  
  /// Provides six options.
  ///
  ///- Parameter startingFlagIndex: shifts 0b11_1111 if > 0
  static subscript(startingFlagIndex startingFlagIndex: RawValue = 0) -> (
    Self, Self, Self, Self, Self,
    Self
  ) {
    Tuple[
      Self[startingFlagIndex: startingFlagIndex],
      .init(flagIndex: startingFlagIndex + 5)
    ]
  }
}

// MARK: - private
private extension OptionSet where RawValue: BinaryInteger {
  private init(flagIndex: RawValue) {
    self.init(rawValue: 1 << flagIndex)
  }
}
Tuple
/// A workaround for not being able to extend tuples.
public struct Tuple<Elements> {
  // This has utility as struct, rather than just a caseless enum, but instance information is irrelevant to this thread.
}

public extension Tuple {
  // MARK: - 2-tuple

  /// Create a new tuple with one more element.
  static subscript<Element0, Element1, Element2>(
    tuple: Elements, element: Element2
  ) -> (Element0, Element1, Element2)
  where Elements == (Element0, Element1) {
    (tuple.0, tuple.1, element)
  }

  // MARK: - 3-tuple

  /// Create a new tuple with one more element.
  static subscript<Element0, Element1, Element2, Element3>(
    tuple: Elements, element: Element3
  ) -> (Element0, Element1, Element2, Element3)
  where Elements == (Element0, Element1, Element2) {
    (tuple.0, tuple.1, tuple.2, element)
  }

  // MARK: - 4-tuple

  /// Create a new tuple with one more element.
  static subscript<Element0, Element1, Element2, Element3, Element4>(
    tuple: Elements, element: Element4
  ) -> (Element0, Element1, Element2, Element3, Element4)
  where Elements == (Element0, Element1, Element2, Element3) {
    (tuple.0, tuple.1, tuple.2, tuple.3, element)
  }

  // MARK: - 5-tuple

  /// Create a new tuple with one more element.
  static subscript<Element0, Element1, Element2, Element3, Element4, Element5>(
    tuple: Elements, element: Element5
  ) -> (Element0, Element1, Element2, Element3, Element4, Element5)
  where Elements == (Element0, Element1, Element2, Element3, Element4) {
    (tuple.0, tuple.1, tuple.2, tuple.3, tuple.4, element)
  }
}

For the record, this is not true. The name CodingKeys was intentionally chosen as a plural name; I won't re-hash the explanation here since it's available on the forums, but the enum doesn't follow what you might consider "regular" naming rules because it exists primarily as a namespace.

Whether or not the same logic applies here, I'll leave for others to have strong opinions on. (Though I personally believe it could.)

4 Likes

Not the best example as "fruit" is already plural (in addition to being singular).

I wonder what design of option set we'd choose if we started completely from scratch. Would it be something like this?

set Fruits {
    case 🍎 🍐 🍍
    yammy = 🍎 + 🍐
    greenYammy = yammy - 🍎
    all = 🍎 + 🍐 + 🍍
}

Fruits.🍎
Fruits.yammy
Fruits.🍐 + 🍍
var fruits = Fruits() // empty set
fruits += 🍎
fruits += 🍎 // no change
fruits += 🍐 // 🍎 + 🍐
if fruits & 🍎 {} // contains
if fruits & ~🍎 {} // doesn't contain

Note the lack of prefix . symbol, I think that's good. I also like + and -, & and |.

While I understand what you are trying to say there, I strongly disagree with that decision. CodingKeys enum or not, does conform to Swift.CodingKey and it does always at any given point of time of the running application and the developer reading the code only a single "key" that is used for en- or decoding the structure.

A type that can be used as a key for encoding and decoding.
Source

Anything else straight ignores the naming guileless (like you mentioned), or in other words abuses the enum structure for a purpose it wasn't necessarily meant for (name spacing).

3 Likes