Awesome, thank you!
Doug
Awesome, thank you!
Doug
Yes, that a possible use case for this, here is a bit modified example which works with my PR:
import Foundation
struct Column<Value> {
var name: String
}
@dynamicMemberLookup struct Row<Schema> {
let schema: Schema
var output: [String: Any] = [:]
subscript<Value>(dynamicMember keyPath: KeyPath<Schema, Column<Value>>) -> Value? {
get {
let column = schema[keyPath: keyPath]
return output[column.name] as? Value
}
set {
let column = schema[keyPath: keyPath]
output[column.name] = newValue
}
}
}
struct User {
let id = Column<UUID>(name: "id")
let name = Column<String>(name: "name")
let age = Column<Int>(name: "age")
}
var user: Row<User> = Row(schema: User(), output: ["name": "Pavel"])
print(user.name)
I love this proposal! It would allow one to wrap an instance into an observable container and then subscribe to the mutations made through the container. For example:
@dynamicMemberLookup
class Observable<T> {
var value: T
private var observers: [AnyKeyPath: (Any) -> Void] = [:]
init(_ value: T) {
self.value = value
}
subscript<U>(dynamicMember member: WritableKeyPath<T, U>) -> U {
get { return value[keyPath: member] }
set {
value[keyPath: member] = newValue
observers[member]?(newValue)
}
}
func observe<U>(_ keyPath: KeyPath<T, U>, observer: @escaping (U) -> Void) {
observers[keyPath] = { observer($0 as! U) }
}
}
struct User {
var name: String
}
let user = Observable(User(name: "Jim"))
user.observe(\.name) { (newName) in
print(newName)
}
user.name = "James" // prints: James
This has a potential to enable great improvements in Redux architectures in a way that observers could observe only parts of the state they are interested in, eliminating the need for complex diffing algorithms.
There has been a lot of talk of combining this feature with @dynamicMemberLookup
but I think this is a pretty bad idea. They have dramatically different compile-time behavior. Wouldnât the compiler be able to produce static errors for @keyPathMemberLookup
when the member accessed is not a valid key path as determined by the root of the expected key path argument?
The statically checkable @keyPathMemberLookup
looks very interesting. I would use this feature if the compiler would indeed reject invalid member accesses. But I do not intend to touch @dynamicMemberLookup
(anyone interested in my opinions about that can see the discussion and review threads for that proposal).
How is the key path chain handled in the case of a lookup type that does not return another instance of Self from the subscript. For example, given a type
@keyPathMemberLookup
class Observable<T> {
...
subscript<U>(keyPathMember member: WritableKeyPath<T, U>) -> U
}
and some data
struct Address {
var city: String
}
struct User {
var name: String
var address: Address
}
let user = Observable(User(name: "Jim", address: Address(city: "SF")))
how would the following be resolved?
user.address.city
I see two options:
user[keyPathMember: \.address.city]
user[keyPathMember: \.address].city
The fact the the argument is named keyPathMember
indicates that the second option would be used, but it would be great if the proposal would clarify this.
That's is exactly what is going to happen with key path based member lookup, invalid members are going to be rejected as well as attempt to access read-only members with writable key path.
Thanks for bringing this up! I'm working on the actual proposal which is going to clarify that approach we are going to take is single step just like user[keyPathMember: \.address].city
you mentioned.
Right, which is why it has no place piggybacking on the @dynamicMemberLookup
attribute. It is very valid to want to be able to use key path lookup without going anywhere near dynamic member lookup (or anything dynamic for that matter). The features are only very loosely and incidentally related.
Since the feature cannot be retroactively added to types, you don't have to vend both types of dynamic member lookup regardless of how the features are spelled.
Key paths provide dynamic representations of properties: it is very much fair to say that members which are looked up by key path are dynamic members. In fact, recalling that we still have the old-style stringly typed #keyPath
, it would also be fair to say that both the existing dynamic member lookup feature and the proposed one here allow lookup "by key path."
That may be true but it really rubs me the wrong way to use @dynamicMemberLookup
for a statically checked feature. I ban @dynamicMemberLookup
in codebases I work in but I still want to be able to use key path lookup. If these features share the same keyword it is no longer a simple task to have a linter ban @dynamicMemberLookup
.
I strongly disagree with this. This is a distortion of what âdynamicâ means in Swift similar to when people use âsafeâ in ways that doesnât refer to memory safety. Dynamic means runtime. Key path member lookup has nothing to do with runtime. The fact that there is a legacy feature supporting Objective-C interop is irrelevant IMO (and as far as I know it is completely orthogonal to the proposed feature).
It's statically checked, but unless I'm entirely mistaken it's still a dynamic lookup (i.e., at runtime). That sometimes the compiler may be able to elide some runtime operations is of a kind with the myriad other optimizations that the compiler performs, but this feature very much has everything to do with the runtime.
We already use the term "dynamic" in this way to contrast with static dispatch, and here it can be rightly said that not only is the lookup dynamic, but the member literally is not a member of the type at compile time. To demonstrate, consider the example of Lens<Rectangle>
above: suppose Lens
is vended by a resilient library--you'll still be able to ask for the .topLeft
member of a Lens<Rectangle>
, when clearly .topLeft
could not possibly have been a member of Lens
at compile time. This is, in my view, the major point of having any dynamic member lookup feature and the key reason why both the existing and proposed features are appropriately unified under the same name.
That the existing feature was designed to accommodate interop with more dynamic languages where the usual degree of static checking available in Swift could not be guaranteed doesn't take away from the major commonalities here. And this is why I bring up the legacy Obj-C interop key path feature: we have two key path features in the language, and one of them is stringly typed for interop purposes and has less static checking; it is almost an exact parallel that we should have two dynamic member lookup features, with one of them stringly typed for interop purposes and with less static checking.
Given that a stated goal in the design of Swift is to avoid dialects of the language, I would give zero consideration during the process of language design as to ease of banning subsets of features. In fact, adding features specifically in ways to facilitate their non-use would be contrary to that stated goal. Of course users should feel free to code however they like, but whether implementing a linter rule to ban a feature is easy or nigh impossible should, in my view, be entirely disregarded in this decision-making process.
Youâre right - I shouldnât have said it has nothing to do with runtime. It is really a hybrid (as you said - statically checked with resolution at runtime). Nevertheless, I still maintain that a feature which is statically checked and not prone to runtime errors caused by a typo in a member name is dramatically different from a feature that is not statically checked and is prone to these issues.
What is gained by forcing these features to share an attribute? Sure, there is one less attribute in the langauge. But it becomes more complex, having more options for which specific lookup features a given type supports. IMO having separate attributes for these two features is an advantage, not a liability. It increases clarity about what lookup features are supported by a type and makes it easier to teach each of the features independently. What exactly is the argument for forcing them to share an attribute (besides distaste for introducing a new attribute)?
IMO, I should not be forced to touch a feature designed primarily to accomodate interop with more dynamic languages when I am designing a Swift-native API. Quite simply, statically checked Swift-native features should not be coupled in any way to dynamically checked features intended to support dynamic langauge interop.
The argument is that these features are fundamentally the same: they facilitate dynamic member lookup.
The amount of static checking available is a function of interop limitations, and as we become more sophisticated (e.g., with constexpr-like features) it is to be expected that @dynamicMemberLookup
would gain static checking to the maximum degree possible. Put another way, I would expect that if someone used the current @dynamicMemberLookup
feature not for interop but for "Swift-to-Swift" dynamic member lookup, then the diagnostics should (in the fullness of time) be indistinguishable from the proposed feature here.
I have argued elsewhere that the design of Swift has elided distinctions in how much compiler optimization or compile-time checking is available. This is, as I've just said above, the case with the naming of the two key path features. There are of course other examples.
I believe that this makes teaching the feature easier rather than harder, because it makes plain up front that the features serve the same purpose. Users who know about key paths will already know that they offer static checking as an advantage over strings. Therefore, how much compile-time checking is available is made plain. What would be counterintuitive, on the other hand, is trying to teach in what way a "dynamic member lookup" is distinct from a "key path member lookup," when in fact the nature of the lookup and of the membership is not meaningfully different, and when in fact both are looked up by key paths of one sort or another and looked up dynamically.
As I said, you would not be required in any way to vend both types of dynamic lookup.
This argument you make about "coupling," on the other hand, was made regarding the use of .
as the operator for dynamic member lookup and was rejected: that is, it has already been settled that @dynamicMemberLookup
will look indistinguishable from ordinary lookup. Let's not litigate that again here.
I'd like to help @anandabits be understood, because I, too, like to distinguish constructs that can be checked at compile-time from constructs that can not. Regardless of eventual runtime support.
Unless I'm mistaken, in the Lens example provided by the OP, the compiler will accept the first line below, but reject the second one with a compile-time error:
let lens: Lens<Rectangle> = ...
// Compiles, because \.topLeft is a valid Rectangle key-path
lens.topLeft
// Does not compile, because \.foo is not a valid Rectangle key-path
lens.foo
This is quite unlike dynamic member lookup, which would accept both lines at compile time, and give whatever results are implemented at runtime.
This is the difference @anandabits wants to stress out, if I got him well. It's quite a big difference indeed!
This does not mean the two features could not be "merged" together. We just have to wonder how we want to teach those features, find a pegagogy which supports the apis, and apis that support the pedagogy, in a virtuous circle. Language design, in a nutshell ;-)
#keyPath
still rejects nonexistent paths at compile time, so this isnât a very good excuse.
Why canât you ban subscript(dynamicMember: String)
instead? Is there a technical barrier, or are your developers such rules lawyers that theyâll use a typealias or ExpressibleByStringLiteral
conformance to evade a very sensible safety rule?
Maybe it's buried in the discussion, but in piggybacking off @dynamicMemberLookup
would that mean relaxing the current compiler requirement?
![]()
@dynamicMemberLookup
attribute requires 'Foo
' to have a 'subscript(dynamicMember:)
' method with an 'ExpressibleByStringLiteral
' parameter
Seems like unnecessary compiler gymnastics.
I'm also with @anandabits on finding the overloading of concepts a bit strange.
The distinction is completely evident by the type of the argument, is it not? KeyPath versus String seems pretty clear-cut. I don't see what you gain by repeating "key path" in the attribute name if it isn't clear already, and removing the word "dynamic" is confusing because the looking up and the membership are dynamic.
As I wrote, it's not inherent to the current feature known as @dynamicMemberLookup
that both lines would be accepted at compile time forever and in all circumstances. If it's used to interop with a language where the members can be exhaustively enumerated at compile time, then when our constexpr-like features make it possible, we should be able to express that.
Put another way, the ways in which the current and proposed feature are "quite unlike" each other should narrow over time. Where compile-time checking will be forever impossible, that is inherent to the languages with which we are interoperating, not the @dynamicMemberLookup
feature themselves.
Concrete example: a PyValue
type in Swift will not have compile-time checking, but not because it uses strings instead of key paths for dynamic member lookup; rather, it is because it must not have compile-time checking that it must use strings instead of key paths to implement dynamic member lookup.
Again, and as I expounded on above, in the correct circumstances (e.g., when used for interop with a less dynamic language), the current @dynamicMemberLookup
by analogy and in keeping with Swift's overall design absolutely should reject nonexistent members at compile time when it is possible to express that in the language.
I agree with your broader point that subscript(dynamicMember:)
should admit validating overloads; I just disagree that #keyPath()
has any more to do with this discussion than #function
or readLine()
. subscript(dynamicMember:)
doesnât use #keyPath()
and nobody is suggesting it ought to.
Is this true and are there plans to allow hooking into @dynamicMemberLookup
with more compile-time assurances in the future?
As far as PyValue
goes, would typing
be a good candidate for bridging Python type information over to Swift in the future?