Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

OptionSet with associated value for each option

Tags:

swift

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?

like image 369
Phlippie Bosman Avatar asked Aug 31 '17 16:08

Phlippie Bosman


2 Answers

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.

like image 91
Charles Srstka Avatar answered Oct 10 '22 14:10

Charles Srstka


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..?

like image 26
jake1981 Avatar answered Oct 10 '22 14:10

jake1981