Implicit Casts for Verified Type Information

I understand you aren't endorsing it, but I appreciate you clarifying it.

as! casts don't change the type. If the object is not of that type, the cast fails. Presently that failure leads to a program abort.

1 Like

I'm now lost at what the design is aiming for. It seems to me that the current design is something along the line of:

  • If the compiler can* proof type narrowing of obj on the path, it applies the casting to the appearances of the obj.

My questions are:

  • Does it apply to every expression in a narrowed context?
  • What if the narrowed type results in an invalid expression and the un-narrowed one is the only valid interpretation? What if it's the other way around?
  • What if the expression is valid for both narrowed and un-narrowed type?

* I'm still quite reluctant to tie a feature to a compiler's capability. But heck, I don't seem to be provided with a better alternative.

2 Likes

Huh. That's exactly what I was defending. Weird.

1 Like

Perhaps this is clearer: The type narrowing caused by is Type or a check for nil creates a context / scope within which an implicit cast is used for the checked variable. Like any cast, it doesn't change the type of the variable. You can answer any question about "How does it handle xyz?" by reframing it as, "How would it handle xyz if the variable was being explicitly cast to the type in the check (e.g. if obj is condition)?" The same is true for failures.

As with UXD, it's both things. There is the language-agnostic surprise (i.e. this is just generally confusing), but you always also have the "this is confusing to me because I'm used to..." variety.

It does remain the same. It's just inserting a cast. When I've just typed if obj is String, I don't find it surprising, under your definition, that I don't have to cast it to String or check if its nil after this check. I just tested if its a String, which is by definition not nil. If you step away from the language, that seems very logical to me.

That is your expectation because you are used to Swift. I share that opinion, but it can be confusing to people who haven't seen type inferencing. It also makes some assumptions. I could have wanted 1 to be UInt, Int64 or Any. The assignment would succeed with any of these (and many more), but it is reasonable to assume I want an Int unless I specify another type. In my case I've indicated the type very specifically with my is String check. My intent is clear.

In short - Seeing unwrapped access to a nullable type after testing it for nil, or String methods accessible to an object that I have tested for is String does not strike me as in any way surprising, especially if I am new to the language and don't have language-specific biases. Additionally, it adheres to the DRY principle.

This thread is getting ridiculous. People are talking past each other without realizing that "the type" is ambiguous without further explanation.

"Type of the variable" != "Type of the expression"

No one is claiming that the (compile time) type of the variable obj is changing.

Everyone is acknowledging that the (compile time) type of the expression to which .count is applied is different when the type narrowing occurs (from what it would be without your proposal).

3 Likes

I agree it is frustrating, but if you re-read the posts over the last day you will see that people were confusing the variable type with the type of a cast expression, or certainly appeared to be confusing them. That is why three of us responded. There were persistent questions that assumed the proposal was to change or effectively change the variable type, leading to unexpected behavior.

Perhaps, if Swift users who frequent this forum are having trouble understanding the proposed feature and finding it frustrating to reason through, then this is a sign that the proposed feature would be hard to understand and use for the broader community.

1 Like

This feature could be scaled back a little. Instead of implicitly inserting a cast, it could let you omit the ! when the compiler can prove statically that the cast would work. For instance:

let s: Any = "abc"
if s is String {
    print(s as String) // no risk of trapping, no need for `as!` here
}

The benefit is we know at a glance the cast can't trap since there's no !, so that part probably can stand on its own.

Once limits of this are well established, then it'll be easier to discuss implicitly inserting the cast in those cases, and how it interacts with overloading. This is the part where I expect many problems to surface.

4 Likes

How does Kotlin handle situations where more than one type could be statically proven? E.g., what is the expected translation of the following in terms of as! casts, and what does it print?

class B {}
class C: B {}
protocol P {}
extension C: P {}

func printStaticType<T>(of t: T) { print(T.self) }

func foo(_ a: Any) {
    if a is P, a is B {
        printStaticType(of: a)
    }
}

foo(C())

I understand why you might draw that conclusion, but an abundance of evidence tells me that, in practice, developers would not find it harder than language features already in Swift. The people in this thread who have taken the time to understand the proposal have varied opinions, which I respect. This sort of proposal always draws dissenters. They might be right. I'm open to that.

As I see it, the issues in this thread are:

  1. I could have explained it better in my initial proposal. I wanted to test the waters with a simple description before investing significant time on the details. This might have been a mistake.
  2. A lot (most?) of the people commenting have never used implicit type narrowing. They are theorizing about it as an abstract concept. For millions of Kotlin developers it is a real thing they use daily.
  3. Some questions seem to assume I'm not familiar with Swift (e.g. Swift does this..., do you know who Chris Lattner is, etc.), presumably because I often reference Kotlin. Given that this proposal is largely borrowed from Kotlin, it was hard to avoid. I am a Swift developer who learned Kotlin a year ago. Some of my work is in language design and implementation (general purpose and DSLs).
  4. The Swift examples I've provided have to be compiled in my head because this feature is a proposal. Sometimes, they aren't perfect. I can't verify them with an actual compiler.

Regarding implicit unwrapping optionals by comparing to nil I certainly wondered about that when I was starting out in Swift. Why do I have to unwrap this $#@% optional when I already checked that it wasn't nil? After a while I deduced that the answer is that there are already more than one way to unwrap optionals and that those syntaxes were explicit. guard let and if let explicitly unwrap the optional making the code more clear than if the optional were somehow implicitly unwrapped by comparison to nil. Optional chaining is also explicit.

I think that a big part of this discussion is about Swift favoring or even requiring that code be clear and explicit vs the opinion that implicit side effects are OK in some cases.

1 Like

You can most definitely provide a simple description of the feature, but it's more important that you at least have a good idea of how the feature entails so that you can answer others' questions about different scenarios. Including:


What happens if it only type-checks with the narrowed type, not the original type, and vice versa?


i.e., what would happen if we do:

var s: String? = ...
if s != nil {
  s.map { print($0) }
}

Does it use String.map or Optional.map? This could be one surprising behaviour.


What about multiple matched types?


What happens to computed properties, functions, subscripts, global variables?

struct A {
  var x: Any { [1 as Any, "123"].randomElement()! }
}
...
let a = A()
if a.x is String {
  a.x.someStringFunction()
}

Apparently not all computed properties are applicable for this feature, but what about Array subscript?

var a = [1 as Any, "123"]
if a[0] is String {
  a[0].someStringFunction()
}

Should it work here? Array subscript is just a normal subscript. So if the answer to the first and the second scenarios are different, then we're whitelisting (allowlisting?) the function Array.subscript(_:) somehow.

Its behaviour would be surprising no matter how we choose.


We now have at least four this-or-that questions, the answers to which could change many people's evaluations of the feature.


Also, I still couldn't figure out why we're trying to add:

if x is String { ... }

when it's not really an improvement over:

if let x = x as? String { ... }

You're saving 8 characters + new name. You don't really gain much if the new name is just very short. You also can't change name like if let can:

if let tmp = someVeryLongName as? String { ... }

and you can't use it when you're using a complex expression:

if let tmp = max(x.count, minimumCount) { ... }

So this feature is far inferior in terms of expressibility. Furthermore, while this feature heavily overlaps the if let construct, it's is different just enough that users need to be aware of the difference. You make a copy in one case, and not in another. It is a difference that does not matter most of the time so programmers would have a hard time deciding which is appropriate.


I said earlier that I definitely see this as a must-have in Kotlin mostly because it lacks the if let syntax (nobody who uses Kotlin corrects me, so I stand corrected). However, you're pitching this as a Swift's feature. It needs to live within a Swift environment, which already solves the same problem in a different way.

9 Likes

I'm happy to do that. I answered @mayoff's question when it was asked. I'm currently travelling. I'll answer the more recent questions when I return in a few days.

It would use String.map to be consistent with the casting behavior I described for @mayoff's question.

... which would makes this feature a breaking change: currently the behavior is to call Optional.map and calling String.map will change the behavior (and in some cases without an error).

Other cases to consider: here I assume the insertion of a closure referencing the variable would cause the compiler to revert to Optional.map instead of String.map:

var s: String? = ...
DispatchQueue.main.async { s = nil }
if s != nil {
  s.map { print($0) }
}

And this last example I assume will call String.map for the first map, Optional.map for the last map, and it could be either one for the middle map:

var s: String? = ...
if s != nil {
  s.map { print($0) }
  s = "abc"
  s.map { print($0) }
  s = nil
  s.map { print($0) }
}
3 Likes

True. In this case, it is. I think the amount of affected code is likely to be very minimal, but you are correct.

I'm against this because:

  1. My understanding is that Swift tries to avoid doing things implicitly. This proposal implicitly casts the variable (only within the scope of the if statement, admittedly, but it still does so). For example:
Example
func talk(_ obj: Any) {
    if obj is String {
        printMe(obj) //the current Swift behavior prints "Hello is Any",
                     //but under this proposal it prints "Hello is String"

        printMe(obj as! Any) //I must cast back to Any if I want to get
                             //the same result as the current Swift behavior.
    }
}

func printMe(_ obj: Any) {
    print("\(obj) is Any")
}

func printMe(_ obj: String) {
    print("\(obj) is String")
}

talk("Hello")

in order to

  1. It's a breaking change (see above example)
  2. It doesn't seem to add anything to the language, IMO. The same can be done with if-let statements (or similar).
  3. It's unexpected. This is actually part of #1, but it doesn't flow well there. Because Swift doesn't implicitly change your variables, the fact that this proposal would change the variable (again, only within the scope of the if statement) is very surprising.
    1. Just to head off more comments about whether the variable actually changes, I'll clarify what I mean by that. I'm aware that the contents of the variable don't change, and that its behavior is identical once you have exited the scope of the statement. However, inside the scope of the statement, the variable behaves differently, gaining and potentially losing functionality (such as methods), so I'm considering it "changed" while in that scope
    2. It could lose functionality if it was implicitly cast to a protocol, for example.

I think that's what I've got for now — I feel like I had something else to say, but I forgot it. I'll put it in a separate post if I remember.

I don't mean to be combative, so my apologies if it came off that way. Also, while I think I'm right (otherwise I wouldn't be posting this), I'm happy to be proven wrong if any of my assumptions are wrong.

6 Likes

I have read the discussion, but I'm still having a naive question. Why are you wishing to use

if obj is String { print(obj.count) } 

instead of

if let obj = obj as? String { print(obj.count) } 

The second variant is currently available and has a predictable behavior.

@Dmitriy_Ignatyev Thank you for the question. It isn't naive. The semantics are slightly different.

In the first example obj is String narrows the type within the current scope if the check is true.

In the second example if let obj = obj as? String { ... } you are assigning a cast expression obj as? String to obj. If the cast fails it returns nil and the if's condition evaluates to false. On success it assigns the value to a new local obj variable.

In this particular example, the results are the same, but they aren't doing the same thing.

While I still believe type narrowing would be very useful in Swift, and that it is no longer an exotic idea thanks to Kotlin, I don't see much support for it in this discussion. Perhaps it is not a good fit for Swift. That is OK :-) No hard feelings. I appreciate everyone who gave it consideration. @Lantua - Thank you for making me aware of the earlier discussion.

3 Likes