8

This is probably best explained with a use case.

I have a logger class. It logs messages to outputs at levels.

class Logger {
   var outputs: OutputOptions
   var filter: Level

   func log(_ message: String, at level: Level) {
      if level <= self.filter {
         outputs.log(message)
      }
   }
}

The possible outputs are defined by an OptionSet and determines to which outputs (NSLog, Instabug, etc) messages should be logged. OptionSet is nice here because I can define select multiple outputs and easily check which are selected when logging.

struct OutputOptions: OptionSet {
    let rawValue: Int

    static let console = OutputOptions(1 << 0)
    static let instabug = OutputOptions(1 << 1)

    func log(_ message: String) {
       if self.contains(.console) {
          NSLog(message)
       }

       // etc
    }
}

Levels are defined by an enum and denote levels of message, such as error, warning, info, etc. Loggers can filter out messages above a certain level if we're not interested in getting a verbose output. The logger's filter is also set to a level.

enum Level: Int {none, no logs are shown. */
   case none = 0
   case error = 1
   case warning = 2
   case info = 3
   case verbose = 4
}

I would like to combine output options and levels in some way, allowing me to specify that certain outputs can filter messages to a certain level, while other outputs can filter to other levels. For example, I would like to log verbose messages to console, but only errors to Instabug. On the surface, OptionSets looks like combine-able enums, so my mind immediately went to associated values. If each option could have an associated filter level, I could set a logger's output like this:

let logger = Loggger()
logger.outputs = [.console(filter: .verbose), .instabug(filter: .error)]

In trying to get this to work, I added a filter property to the OutputOptions. My options now look like this:

struct OutputOptions: OptionSet {
   let rawValue: Int
   var filter: Level = .info

   init(rawValue: Int, filter: Level) {
      self.rawValue = rawValue
      self.filter = filter
   }

   static func console(filter: Level = .info) {
      return OutputOptions(rawValue: 1 << 0, filter: filter)
   }

   // etc

BUT I can't figure out how to access the filter variable of an element in log. Based on my experience with enums, I would have expected to be able to do

   func log(_ message: String, at level: Level) {
      if self.contains(.console(let filter)) { // <== does not compile!
         if level <= filter {
            NSLog(message)
         }
      }
   }
}

But that does not compile. In fact, it looks like the filter property is not separately defined for each option, but rather for a whole option set.

SO: Is there a way to associate values with individual options in an option set?

Phlippie Bosman
  • 5,378
  • 3
  • 26
  • 29

3 Answers3

4

But that does not compile. In fact, it looks like the filter property is not separately defined for each option, but rather for a whole option set.

This is because an OptionSet isn't really a set, per se. If I have the following OptionSet:

struct MyOptions: OptionSet {
    let rawValue: Int

    static let foo = MyOptions(1 << 0)
    static let bar = MyOptions(1 << 1)
}

and then I make the set like so:

let opts: MyOptions = [.foo, .bar]

I don't actually have a collection with two MyOptions instances in it. Instead, I have a new instance of MyOptions whose rawValue is set to (.foo.rawValue | .bar.rawValue)—i.e. 3. The original two MyOptions instances are discarded as soon as opts is made.

Similarly, your logger.outputs will be an instance of OutputOptions with rawValue 3 and the default value for filter.

Thus, it's not really possible to do what you want with an OptionSet.

Charles Srstka
  • 16,665
  • 3
  • 34
  • 60
1

This isn't actually true, OptionSet can have associated value(s) just fine but it will require some work.. Luckily not so much and here's a simple sample.

This is OptionSetIterator, which is not actually needed for this, but will aid in demonstration, it's from here.

public struct OptionSetIterator<Element: OptionSet>: IteratorProtocol where Element.RawValue == Int {
    
    private let value: Element
    private lazy var remainingBits = value.rawValue
    private var bitMask = 1

    public init(element: Element) {
        self.value = element
    }
        
    public mutating func next() -> Element? {
        while remainingBits != 0 {
            defer { bitMask = bitMask &* 2 }
            if remainingBits & bitMask != 0 {
                remainingBits = remainingBits & ~bitMask
                return Element(rawValue: bitMask)
            }
        }
        return nil
    }
    
}

extension OptionSet where Self.RawValue == Int {
    
    public func makeIterator() -> OptionSetIterator<Self> { OptionSetIterator(element: self) }
    
}

And here is a actual code sample for OptionSet with associated values:

public struct MyOptions: OptionSet, Equatable, Sequence, CustomStringConvertible {
    
    public let rawValue: Int
    public fileprivate(set) var tag: Int?
    public fileprivate(set) var text: String?
        
    mutating public func formUnion(_ other: __owned MyOptions) {
        self = Self(rawValue: self.rawValue | other.rawValue, tag: other.tag ?? self.tag, text: other.text ?? self.text)
    }

    @discardableResult
    public mutating func insert(
        _ newMember: Element
    ) -> (inserted: Bool, memberAfterInsert: Element) {
        let oldMember = self.intersection(newMember)
        let shouldInsert = oldMember != newMember
        let result = (
            inserted: shouldInsert,
            memberAfterInsert: shouldInsert ? newMember : oldMember)
        if shouldInsert {
            self.formUnion(newMember)
        } else {
            self.tag = newMember.tag ?? self.tag
            self.text = newMember.text ?? self.text
        }
        return result
    }
    
    @discardableResult
    public mutating func remove(_ member: Element) -> Element? {
        let intersectionElements = intersection(member)
        guard !intersectionElements.isEmpty else {
            return nil
        }
        let tag: Int? = self.tag
        let text: String? = self.text
        self.subtract(member)
        self.tag = tag
        self.text = text
        return intersectionElements
    }
    
    private init(rawValue: Int, tag: Int?, text: String?) {
        self.rawValue = rawValue
        self.tag = tag
        self.text = text
    }
    
    public init(rawValue: Int) {
        self.rawValue = rawValue
        self.tag = nil
        self.text = nil
    }

    private static var _tag: Int { 1 << 0 }
    private static var _text: Int { 1 << 1 }

    public static func tag(_ value: Int) -> MyOptions {
        MyOptions(rawValue: _tag, tag: value, text: nil)
    }

    public static func text(_ value: String) -> MyOptions {
        MyOptions(rawValue: _text, tag: nil, text: value)
    }
    
    public var description: String {
        var modes: [String] = []
        self.forEach {
            var text: String = ""
            switch $0.rawValue {
                case MyOptions._tag: text = "tag{" + ( self.tag?.description ?? "nil" ) + "}"
                case MyOptions._text: text = "text=" + ( self.text ?? "nil" )
                default: text = "unknown"
            }
            modes.append(text)
        }
        guard !modes.isEmpty else { return "none" }
        guard modes.count > 1 else { return modes.first ?? "error" }
        return "[" + modes.joined(separator: ", " ) + "]"
    }

}

In this sample, MyOptions does not need conform to Equatable and Sequence, they are for OptionSetIterator. Also CustomStringConvertible is there only for demonstration purposes, as is also variable description.

And here's some tests:

var options: MyOptions = [
.text("hello"),
.text("world"),
.tag(10)
]

options.insert(.tag(9))
options.update(with: .tag(0))
options.remove(.tag(-1))

print("Options: " + options.description)

Results:

Options: text=world

When removing member from set, it's associated values are kept. With small changes, they can be also cleared when member leaves the set, but if you are not storing big amounts of data in your set's associated types... I usually use OptionSet as a configurator for my classes and structs with optional options, so I rarely remove members from set - so I opted that out just out of my laziness..

This is overriding default methods with minor changes. Original source for OptionSet is available at github.com

Any comments or improvements..?

jake1981
  • 303
  • 3
  • 11
1

Actually.. To answer my own query... I wrote something...

This is protocol OptionSetAssociated with it's extensions:

public protocol OptionSetAssociated: OptionSet where RawValue: BinaryInteger {
    var store: [RawValue: Any] { get set }
}

extension OptionSetAssociated {
    
    public init<T>(rawValue: RawValue, value: T) {
        self.init(rawValue: rawValue)
        self.store[rawValue] = value
    }
    
    fileprivate init(rawValue: RawValue, store: [RawValue: Any]) {
        self.init(rawValue: rawValue)
        self.store = store
    }
    
    fileprivate static func combinedStore(_ old: [RawValue: Any], new: [RawValue: Any]) -> [RawValue: Any] {
        new.map {$0.key}.reduce(into: old) {
            $0[$1] = new[$1] ?? old[$1]
        }
    }

    fileprivate static func storeOverride(_ store: [RawValue: Any], member: RawValue?, value: Any?) -> [RawValue: Any] {
        guard let member: RawValue = member else { return store }
        var store: [RawValue: Any] = store
        store[member] = value
        return store
    }

    public func getValue<T>(key: RawValue) -> T? {
        self.store[key] as? T
    }
    
    mutating public func formUnion(_ other: __owned Self) {
        self = Self(rawValue: self.rawValue | other.rawValue, store: Self.combinedStore(self.store, new: other.store))
    }

}

extension OptionSet where Self: OptionSetAssociated, Self == Element {
    
    @discardableResult
    public mutating func insert(
        _ newMember: Element
    ) -> (inserted: Bool, memberAfterInsert: Element) {
        let oldMember = self.intersection(newMember)
        let shouldInsert = oldMember != newMember
        var result = (
            inserted: shouldInsert,
            memberAfterInsert: shouldInsert ? newMember : oldMember)
        if shouldInsert {
            self.formUnion(newMember)
        } else {
            self.store = Self.storeOverride(
                Self.combinedStore(self.store, new: newMember.store),
                member: newMember.rawValue, value: newMember.store[newMember.rawValue])
            result.memberAfterInsert.store[newMember.rawValue] = newMember.store[newMember.rawValue]
        }
        return result
    }

    @discardableResult
    public mutating func remove(_ member: Element) -> Element? {
        var intersectionElements = intersection(member)
        guard !intersectionElements.isEmpty else {
            return nil
        }
        let store: [RawValue: Any] = self.store
        self.subtract(member)
        self.store = store
        self.store[member.rawValue] = nil
        intersectionElements.store = Self.storeOverride([:], member: member.rawValue, value: store[member.rawValue])
        return intersectionElements
    }
    
    @discardableResult
    public mutating func update(with newMember: Element) -> Element? {
        let previousValue: Any? = self.store[newMember.rawValue]
        var r = self.intersection(newMember)
        self.formUnion(newMember)
        self.store[newMember.rawValue] = newMember.store[newMember.rawValue]
        if r.isEmpty { return nil } else {
            r.store = Self.storeOverride([:], member: newMember.rawValue, value: previousValue)
            r.store[newMember.rawValue] = previousValue
            return r
        }
    }
    
}

And here's struct that using this and working just like normal OptionSet does..

public struct TestSet: OptionSetAssociated {
    
    public typealias RawValue = Int

    public let rawValue: Int
    public var store: [RawValue : Any] = [:]
    
    public init(rawValue: RawValue) {
        self.rawValue = rawValue
    }
        
}

extension TestSet { // Members
    
    public static var bool: TestSet { TestSet(rawValue: 1 << 0) }
    public static var int: TestSet { TestSet(rawValue: 1 << 1) }
    public static var string: TestSet { TestSet(rawValue: 1 << 2) }
    public static var optString: TestSet { TestSet(rawValue: 1 << 3) }
    
    public static func int(_ value: Int) -> TestSet {
        TestSet(rawValue: TestSet.int.rawValue, value: value)
    }
    
    public static func string(_ value: String) -> TestSet {
        TestSet(rawValue: TestSet.string.rawValue, value: value)
    }
    
    public static func optString(_ value: String?) -> TestSet {
        TestSet(rawValue: TestSet.optString.rawValue, value: value)
    }
    
}

extension TestSet { // Member options
    
    public var int: Int? {
        self.getValue(key: TestSet.int.rawValue) ?? ( self.contains(TestSet.int) ? Int() : nil ) // Returns default Int() if member is included and no value was given...
    }

    public var string: String? {
        self.getValue(key: TestSet.string.rawValue) ?? ( self.contains(TestSet.string) ? String() : nil )
    }

    public var optString: String? { // Returns nil when value was not given
        self.getValue(key: TestSet.optString.rawValue)
    }

}

And here's extension for some debugging (I opted out previous dependencies to iterator):

extension TestSet: CustomStringConvertible {
    
    public var description: String {
        var members: [String] = []
        var vars: [String] = []

        if self.contains(TestSet.bool) { members.append("bool" ) }
        if self.contains(TestSet.int) { members.append("int") }
        if self.contains(TestSet.string) { members.append("string") }
        if self.contains(TestSet.optString) { members.append("Optional<String>") }
        
        if let int: Int = self.int { vars.append("Int(" + int.description + ")") }
        if let string: String = self.string { vars.append("String(" + string + ")") }
        if let optString: String = self.optString { vars.append("Optional<String>(" + optString + ")")}
                
        return "Members: " + (members.isEmpty ? ["none"] : members).joined(separator: ", ") + "\nVariables: " + vars.joined(separator: ", ")
    }
    
}

Try it. Initialization just like plain OptionSet..

var testset: TestSet = []
testset = [.int, .string("hello")]
testset.remove(.int)
testset.update(with: .string("world"))
testset.insert(.int(10))

print(testset.description)

Result:

Members: int, string Variables: Int(10), String(world)

So, in this version, each member is allowed with one value (unless it's a tuple..) - which is removed when member leaves the set. Values are kept in variable store which is a dictionary with RawValue as it's key element. Because I used dictionary instead of KeyValuePairs<RawValue, Any> - it is required that RawValue conforms to Hashable(BinaryInteger).

In the example, default values are returned if no value is provided for member that does not use optional value, and then there's one member that has optional value. Also one traditional member without value at all.

Creation is identical to normal OptionSet, except that this needs:

public var store: [RawValue : Any] = [:]

Unfortunately, I am sure that I've forgotten to take something to account that makes my implementation still non-perfect, although it's not much or anything too important.. I just awhile ago had it in my mind, but lost it after that as fast as it came :)

jake1981
  • 303
  • 3
  • 11
  • Updated version available from gist: https://gist.github.com/oskarirauta/b2be3039293aeb27dde20797252c8b55 – jake1981 Sep 09 '20 at 22:15