Unwrapping and value binding patterns in the condition of ternary expressions

I've often written code like the following where I only want a variable unwrapped for a simple ternary expression.

subscript(key: String) -> Widget? {
    get {
        return self.wrappers[key]?.widget
    }
    
    set {
        let wrapped: Wrapper<Widget>?
        if let value = newValue {
            wrapped = Wrapper(value)
        } else {
            wrapped = nil
        }
        
        self.wrappers[key] = wrapped
    }
}

The getter looks great. The setter is a bunch of lines of incantation to do something simple. If I weren't against explicit unwrapping, I could have done this:

self.wrappers[key] = newValue == nil ? nil : Wrapper(newValue!)

Map is also available. I would argue it's not exactly intuitive, could make for ugly lines, and it doesn't work on other pattern matches.

self.wrappers[key] = newValue.map { Wrapper($0) }

So getting to the point: I would prefer something a little more concise and consistent with other conditional branches. What I would like to see is a way to unwrap inline for the duration of the true case or bind enum case values for the duration of the true case.

self.wrappers[key] = (let value = newValue) ? Wrapper(value) : nil

This could support case pattern matches too.

self.wrappers[key] = (case let .some(value) = newValue) ? Wrapper(value) : nil

let intVal = (case let .integer(value)) ? value : 0

let isLlama = (case let .animal(type, _) ? type == .llama : false

I'll be the first to say I'm not thrilled by the syntax, but at least it'd be consistent. Despite looking like an expression, if this were implemented it should probably not be able to be used elsewhere.

Anyone else run into this a lot? Have thoughts or better ideas?

3 Likes

If you really don't want to use ! or map then why not just pattern match it?

    set {
        switch(newValue) {
        case let value?: self.wrappers[key] = Wrapper(value)
        case _: self.wrappers[key] = nil
        }
    }

The switch version is about as good as the version in my post. There's duplicative assign statements in the code you have there.

IMO, it's awfully unwieldy to create a switch/if/guard construct every time a developer would like to extract a value from an enum case to use for a single line.

The lack of ternary usage just kind of sticks out, since Swift encourages conciseness and functional-inspired programming.

2 Likes

I just wrote about something similar - my suggested syntax is less robust but more ergonomic:

What about

set {
  self.wrappers[key] = newValue.map(Wrapper.init)
}

?

FWIW, in the case I would favor:

guard let newValue = newValue else {
    wrappers[key] = nil
    return
}
wrappers[key] = Wrapper(newValue)

I may be a controversial opinion, but personally I think adding features to ternary expressions should generally be discouraged. IMO it's pretty much a legacy feature from C-based languages that would not have been added otherwise. It's far from clear, confusing for new users, and easily abused. (That said, I use it somewhat liberally :innocent:)

As for not wanting to use map, I agree it feels unintuitive and a bit confusing. I almost never use it even though It's semantically similar to Kotlin's thing.let{} which I use all the time. I think this is due to it's naming being identical to Collection.map, while functionally having a very different use.

Regarding the issues with pattern matching, IMO that's an issue with the poor ergonomics around it and associatedValue outside of Switches, which will hopefully be improved soon, e.g. Extract Payload for enum cases having associated value - #105 by trs.

Optional.map and Collection.map functionally have the same use, given the right conceptual framing. In both cases, you are unwrapping a value, applying a function to it, and wrapping it back up again. “Functor” is not the first word you learn in an intro programming course, and in fact you can use all sorts of monadic types without caring about the topic conceptually. Still, if you read up on Functors (or Monads) you’ll start to see the connections between Optional.map, Collection.map, Result.map, etc. in a new light.

3 Likes

I would say they're semantically or conceptually similar, but the practical/functional use is different.

Even the documentation is different:

Optional.map:
/// Evaluates the given closure when this Optional instance is not nil,
/// passing the unwrapped value as a parameter.

Array.map:
/// Returns an array containing the results of mapping the given closure
/// over the sequence's elements.

Optional.map is focused on unwrapping, not transforming. IME that's also the primary way/reason it's used.

In addition it only applies to Optional. You can't do 5.map{"\($0)"}, so there's no related use-case for using .map to transform single values. So the use cases are: Collection-Transforms and Unwrap-Optional-with-a-transform-if-you-want.

Yes at a theoretical/"Functional Programming" level the same thing is being accomplished, but the use of each of them is meaningfully different enough that one concept doesn't easily map (:grin:) to the other.

Not to mention the confusing situation where you have an Optional<Collection>:

let foo: [String]? = ["bar"]
foo.map {} // here you have a [String] passed in
foo?.map {} // here you have each Element passed in

Yeah, and from the “monadic” conceptual model this makes sense because 5 is not of a monadic type. Int does not wrap anything.

I’ll grant you that Swift is not really trying to give us monads and the documentation totally avoids the topic for good reason. I’m not suggesting that if you don’t think of these types as Functors then you are thinking about them incorrectly — what I am saying is that when I personally started to see these types as monadic I started to feel much more empowered to work with map/flatMap and I consider that to be a very positive thing for my code writing and reading in general.

3 Likes

Wow, my thread was revived! From almost a year ago! I'm glad there's some interest in the topic.

Much of this discussion is focusing on Optional. It's good that we have map (though it's neither discoverable nor readable), but I would like to see enums generally be more useable.

Enums with associated types are a pretty neat feature of Swift. However, they require more lines and more unique syntax than alternatives - the primary alternative being a closed class hierarchy. Enums save in declaration verbosity at the expense of usage verbosity - and that's the exact opposite of what a developer wants! The only advantages I see are 1) compile time checking (switch statement warnings/errors) 2) specific performance consideration (stack allocation).

If a developer ends up making enough extension methods on the enum to make it reasonably useable, it may as well have been a class to start with, as the class and class hierarchy can at least be extended as the application grows.

Working under the assumption that enums are a type that should be favored when possible, the syntax should not be punitive. if case is pretty unintuitive, IMO, which leaves us with switch and manually written map-like extensions.

For illustration, let's say we have a DatabaseValue type and want to extract a string if the stored type is a string:

enum DatabaseValue {
    case string(_: String)
    case integer(_: Int)
    // ...
}

func printStringValue(_ dbValue: DatabaseValue) {
    let result: String
    if case let .string(strValue) = dbValue {
        result = strValue
    } else {
        result = ""
    }
    print("the string is \(result)")
}

But if DatabaseValue were a root class:

class DatabaseValue {
}

class StringDatabaseValue: DatabaseValue {
    public let stringValue: String
   //...
}

func printStringValue(_ dbValue: DatabaseValue) {
    let result = (dbValue as? StringDatabaseValue)?.stringValue ?? ""
    print("the string is \(result)")
}

The latter usage is denser, but more readable: the reader knows that result is being calculated here and can skim past it. It is only when the reader wants to know how result is being derived that the readability becomes debatable. Since extracting a value from an enum is such an insignificant task when compared to other code, it doesn't deserve vertical space.

Another alternative is Any, if it applies:

func printStringValue(_ dbValue: Any) {
    let result = (dbValue as? String) ?? ""
    print("the string is \(result)")
}

Why would we want to encourage Any over enum types?

The current syntax for enum usage seems to hold the opinion "if you are using an enum, you will want to handle all of its cases everywhere, so a switch statement is just fine". In my experience, this is an unrealistic expectation.

To bring things back around, it is my opinion that unwrapping and using a value from an enum, whether it's Optional or another, should always be possible within a single statement. Once you need block statements or helper extensions to extract a value, the wrapper type's usage semantics are just adding rote signal noise.

2 Likes

There's been effort recently to improve enum ergonomics in DiscriminatedUnion Protocol and Extract Payload for enum cases having associated value

1 Like