Optional
is an enum, and if we think of chaining ?
onto an optional value as shorthand for .some?
, then the syntax plays nicely with other enums.
Yes technically it's an enum, but I would say that's an implementation detail. I don't think most developers ever think of optionals that way. It seems much more natural to think along the lines of "this value might not be here, so I have to put a ?
before calling a property or method on it.
Actually I'm not sure I even understand the argument. Sure, when chaining we could write
view.superview.some?.backgroundColor
which would have
view.superview?.backgroundColor
as retroactively motivated sugar. But that still doesn't seem to motivate standalone
view.superview.some?
since we don't use
view.superview?
It's very late here so I might be confused about what the motivation actually was.
Another thing, wouldn't this also mean we have to introduce a whole new concept? Are there any existing situations where a statement is invalid without an operator at the end?
asset.remote
would have to be a syntax error, right?
Trailing ?
and .some?
doesn't exist today, but we could think of them as an expansion of optional chaining syntax. .some?
would be a kind of "identity" operation for optionals in a way similar to how .self
is an "identity" operation for values.
So while optional-chaining is limited to chaining into another property today, and has optional-reading, non-optional-writing semantics:
view.superview?.backgroundColor // Optional<Color>
view.superview?.backgroundColor = .red // β
view.superview?.backgroundColor = nil // π
But we could expand ?
syntax to generally mean "open an enum payload," and when attached to an optional, could expand to .some?
automatically.
This allows us to use the same optional-chaining semantics to distinguish how we access and modify enum values (and by extension, optional values):
view.superview // Optional<UIView>
view.superview = label // β
view.superview = nil // β
view.superview.some? // Optional<UIView>
view.superview.some? = label // β
view.superview.some? = nil // π
// or, shorthand:
view.superview? // Optional<UIView>
view.superview? = label // β
view.superview? = nil // π
The thing I like about using ?
is we're not introducing a whole new concept, but instead are extending an existing syntax in a way that will hopefully feel natural.
asset.remote
is invalid today, and in the syntax I'm theorizing it would require a trailing ?
to be valid.
So if Iβm understanding correctly, the trailing ?
would enforce optional reading, non-optional writing of values, which would apply the same way to KeyPaths (e.g. \UIView.superview?
).
If thatβs so, then am I correct in assuming that the base class for case key paths would be a subclass that offers optional reading, non optional writing semantics? As in, would that be the only key path type available capable of representing enum cases?
If so, does that pose concern for supporting backwards compatibility with older runtimes?
The composition of enum case access and property access would be the key path type that @Joe_Groff referred to here: Enum Case KeyPaths - #4 by Joe_Groff
And this key path type would be between KeyPath
and CasePath
:
class KeyPath<Root, Value>: PartialKeyPath<Root> { β¦ }
class OptionalWritableKeyPath<Root, Value>: KeyPath<Root, Value?> { β¦ }
class CasePath<Root, Value>: OptionalWritableKeyPath<Root, Value> { β¦ }
I think Joe mentioned that case paths and optional paths could back-deploy as KeyPath<Root, Value?>
.
That makes sense.
So in summary:
\Asset.remote?.url // KeyPath<Asset, URL?>
\Asset.remote? // OptionalWritableKeyPath<Asset, Remote>
\Asset.remote // CasePath<Asset, Remote>
asset.remote?.url = nil // β
what does it do? No-op?
asset.remote? = nil βοΈ
\Asset.remote.embed(Remote(url)) β
equivalent to Asset.remote(Remote(url))
Is this all correct? Iβm a bit confused about how the chaining with payload properties would work.
I think it'd be more like:
\Asset.remote?.url // OptionalWritableKeyPath<Asset, URL>
\Asset.remote? // CasePath<Asset, Remote>
\Asset.remote // π invalid unless `Asset` has computed `var remote`
asset.remote?.url = nil // π invalid, just like optional chaining today
asset.remote? = nil // π invalid
(\Asset.remote?).embed(Remote()) // β
equivalent to `Asset.remote(Remote())`
I see now. Makes sense! Iβm a bit bummed that the parenthesis are still needed for embedding but technically it makes sense.
One last question, if we had a longer chain like \Asset.remote?.urlComponents.query
β¦ where query
is a String?
itself, would the type be OptionalWritableKeyPath<Asset, String?>
?. If so, would the read value be String??
? Should the compiler be flattening the entire thing to WritableKeyPath<Asset, String?>
in that case? Or does it make sense to maintain the type with a double optional?
I believe all optional-chaining semantics would be preserved, so it should behave the same as the same optional chain on an actual value. If writable, it'd be an OptionalWritableKeyPath<Asset, String?>
, if read-only it'd still flatten to KeyPath<Asset, String?>
.
Wouldn't this actually prevent Asset from having a computed, optional .remote
property? I mean what would asset.remote?.absoluteString
refer to then, the property or the case?
Not necessarily. There could be inference and disambiguation rules.
// Disambiguation:
(asset.remote)?.absoluteString // disambiguate optional `var`
asset.remote.some?.absoluteString // disambiguate optional `var`
(asset.remote?)?.absoluteString // disambiguate `case`
// Default inference:
asset.remote?.absoluteString
// * Prefer `var remote` for source compatibility,
// fall back to `case remote` if it exists, or fail.
// * Prefer `case remote`,
// require `.some?` or parentheses for disambiguation.
// ...or some other rule
There's also always the option to disallow var remote
where case remote
exists. It could be source-incompatible, but may be deemed OK, especially if most overlap today is for the exact same functionality, or it could be introduced with Swift 6, which would allow for more source compatibility breakage.
In any case, wouldn't it make sense to have people opt in to these case paths? Useful as they are, most of the time you don't actually need them.
AFAIK, Swift usually tries to stay apart of this kind of configurable behavior, as this leads in one way or another to the creation of different dialects. The only exceptions I'm aware of are temporary, in order to preserve backward compatibility (like it's currently the case for the bare regex syntax).
I just meant a pseudo-protocol along the lines of CaseIterable
.
I don't think CaseIterable
is a pseudo-protocol. While Swift provides automatic conformances where appropriate, e.g. enums with no associate values, that seems more similar to how Equatable
, Hashable
, Codable
, RawRepresentable
, etc., conformances are opt-in and can often be trivially synthesized. You can also write a custom conformance to CaseIterable
for any data type.
Meanwhile, I'm not sure I see how this transfers to key path functionality. There's no opt-in/out mechanism for property key paths, and I don't see a motivation to not provide the same functionality to all enum cases when it's possible to do so. Can you explain your reasoning?
Whatever CaseIterable
is I think the situation here is pretty similar. You conform to a protocol and get synthesized properties. For CasePaths we synthesize several sort-of-properties, one for each case, and the names are also more likely to clash with what you already have.
So if we think it makes sense to have to manually ask for .allCases
, why shouldn't it be the same for CasePaths
? Just like with CaseIterable
, CasePaths
can be very useful, but it's also true that in 95% of enums it's not needed, so it does seem a bit frivolous to always add it. Unless it's not even considered as synthesized properties I guess.
Consider the hypothetical scenario where a CasePath<Enum, Value>
and PartialCasePath<Enum>
had been introduced to the language, and SE-0914 came along with both CaseIterable
and CasePathIterable
.
If we look at it from this perspective, we can find a pretty convincing argument for case paths to be first class citizens in the language, as they're in fact the equivalent of supercharged enum discriminators!
Synthesized enum discriminators are one of the most requested features for enums, second only to extracting payloads from enum cases more easily. Both of these problems are solved in the same way: by introducing a way to refer to the discriminator of an enum case and operate with it, aka being able to refer to a "Case Path".
In the case of CasePathIterable
, you'd get something like:
enum Foo: CasePathIterable {
case a, b, c
// synthesized
static var allCasePaths: [CasePath<Bar, Void>] // contains \Foo.a?, \Foo.b?, and \Foo.c?, which actually act as discriminators!
// equivalent to:
enum Discriminator: CaseIterable {
case a, b, c
}
static var allDiscriminators: [Discriminator] // contains Discriminator.a, Discriminator.b, Discriminator.c
}
enum Bar: CasePathIterable {
case a(String)
case b(Int)
case c(URL?)
// synthesized
static var allCasePaths: [PartialCasePath<Bar>] // contains \Bar.a?, \Bar.b?, and \Bar.c?, which actually act as discriminators!
// equivalent to:
enum Discriminator: CaseIterable {
case a, b, c
}
static var allDiscriminators: [Discriminator] // contains Discriminator.a, Discriminator.b, Discriminator.c
}
CasePath
and PartialCasePath
could even be made exhaustively switchable at compiler level since they refer to a specific enum type, so there's always a finite amount of known paths for a given Root
type.
As you can see, these are not part of an opt-in protocol, these are first class citizens leveraged by the better opt-in protocol: CasePathIterable
.
Interesting, I didn't realise CasePaths could be used as discriminators, how does that work?
Examples:
let bar = Bar.a("hello")
let barDiscriminator = PartialCasePath(bar) // PartialCasePath<Bar>
switch barDiscriminator {
case \.a?: // ...
case \.b?: // ...
case \.c?: // exhaustive!
}
enum PaymentMethod: CasePathIterable {
case creditCard(CreditCardBrand)
case paypal
}
extension PartialCasePath where Root == PaymentMethod {
var title: String {
switch self {
case \.creditCard?: return "Credit Card"
case \.paypal?: return "PayPal"
}
}
}
let allPaymentMethodsTitles = PaymentMethod.allCasePaths.map(\.title) // ["Credit Card", "PayPal"]
func setPaymentMethodKind(_ paymentMethodKind: PartialCasePath<PaymentMethod>) {
self.paymentMethodKind = paymentMethodKind
self.label.text = paymentMethodKind.title
}
setPaymentMethodKind(\.creditCard?)
// even...
func setCreditCardBrand(_ creditCardBrand: CreditCardBrand) {
if let creditCard = self.paymentMethodKind as? CasePath<PaymentMethod, CreditCardBrand> {
self.paymentMethod = creditCard.embed(creditCardBrand)
}
// or:
if self.paymentMethodKind == \.creditCard? {
self.paymentMethod = .creditCard(creditCardBrand)
}
}
setPaymentMethodKind(\.paypal?)
setCreditCardBrand(.masterCard) // self.paymentMethod is still .paypal
setPaymentMethodKind(\.creditCard?)
setCreditCardBrand(.masterCard) // self.paymentMethod is now .creditCard(.masterCard)
I think we'll eventually want to support inout
bindings in pattern matches and conditionals, which would allow for things like:
// Replace β with your favorite case projection syntax
if inout payload = enumValue.βcaseName {
modify(&payload)
}
switch enumValue {
case .caseName(inout payload):
modify(&payload)
case .otherCaseName(inout otherPayload):
modify(&payload)
}
and so if we have optionally-writable key paths, it'd be cool to have if inout payload = value[keyPath: kp]
work as well.