Hacker News new | past | comments | ask | show | jobs | submit login

There are now 2x as many syntaxes for the same operation, if let and if != null



It's not the same op though. One is a check, the other creates an in-context copy that's unwrapped from the optional. The compiler of course optimizes this out but it's not the same.


Right. In Dart they are the same. In Swift they are distinct.


They have different use cases. if x != nil is to do some work without needing x, because you'd still have to unwrap it if you used it.

That's what if let y = x is for, it creates a safe lexical context to use the unwrapped value.

And then you have guard let y = x which is a stronger guarantee that no code further down in the context is executed if x is nil. This helps to write happy-path code with early returns, avoiding pyramids of doom and encouraging writing code where all the assumptions are listed up front with logic handling exceptional cases before finally arriving at the logic that works under all the assumptions.

Dart feels like a step backwards after seeing the benefits these provide.


> They have different use cases.

But there’s also no harm in combining them. I agree with the OP, TypeScript does what they describe Dart doing and I find it much simpler than the way Swift does it. It’s never confusing.


> But there’s also no harm in combining them

Disagree. I’ve seen people write things like the following:

    if x != nil { x!.foo() }
and then later the x != nil condition is changed to no longer check for nil, or the x!.foo() is moved out of that lexical scope elsewhere, or copypastad, and then the force unwrap crashed. I’ve seen similar yet more subtle bugs with soft unwraps instead of forced: x?.foo()

Not possible if you use if let y = x . If you copypasta the inner scope with the different variable name then it won’t compile.

(ofc these are trivial representative examples, in reality these were lexical scopes with many lines and more complicated code)

It’s not just about how the code looks when you write it, it’s the paths that can be taken by future maintenance programmers.

I‘ve complained about the large surface area of swift syntax, stdlib etc in the past, but I don’t personally find this specific thing confusing.


For removing the null check, that, of course, would cause a compiler error.

For moving the force unwrapped out of the if, that's a problem with force unwrapping, not combining the syntaxes.


Changing the type of something based on a conditional check is not great, because if I wanted a non-optional I would request it. Sometimes a nil check is literally just a nil check. What if I want to set the value back to nil if it is not-nil? Why should I have to wrap things back in an optional if I want to pass it along as such?


The reason why languages promote variable types based on control flow is because developers en masse actually expect that to happen, e.g. facing the code like

    Dog? maybeDog;
    if (maybeDog != null) {
      maybeDog.bark();
    }
If compiler says "sorry, maybeDog might be null", developer (rightfully so) usually responds "but I have just checked and I know it is not, why do you bother me?". So languages chose to accommodate this.

> What if I want to set the value back to nil if it is not-nil?

You can. The type of the variable does not actually change. You could say that the information about more precise type is simply propagated to the uses which are guarded by the control flow. The following will compile just fine:

    Dog? maybeDog;
    if (maybeDog != null) {
      maybeDog.bark();
      maybeDog = null;
    }
> Why should I have to wrap things back in an optional if I want to pass it along as such?

You don't, with a few exceptions. There is a subtype relationship between T and T?: T <: T?, so the following is just fine:

    void foo(Dog? maybeDog);

    Dog? maybeDog;
    if (maybeDog != null) {
      maybeDog.bark();
      foo(maybeDog);  // Dog can be used where Dog? is expected
    }
 
You might need to account for it in places where type inference infers type which is too precise, e.g.

    Dog? maybeDog;
    if (maybeDog != null) {
      // This will be List<Dog> rather than List<Dog?>. 
      final listOfDogs = [maybeDog];
    }
Though I don't think it is that bad of a problem in practice.


See, I don't think languages should accommodate this, because I see it as an ugly solution. It's nice that it works in a few cases but then it very quickly breaks down: a developer finds that their null check is enough to strip an optional, but a test against zero doesn't convert their signed integer to unsigned. Checking that a collection has elements in it doesn't magically turn it into a "NonEmptyCollection" with guaranteed first and last elements. I'm all for compilers getting smarter to help do what programmers expect, but when they can't do a very good job I don't find the tradeoff to be very appealing. Basically, I think pattern matching is a much better solution this problem, rather than repurposing syntax that technically means something else (even though 90% of the time people who reach for it mean to do the other thing).

Also, fwiw, I was mostly talking about things like the last example you gave. I guess it would be possible that in circumstances where T is invalid but T? would be valid, the language actually silently undos the refinement to make that code work. However, I am not sure this is actually a positive, and it doesn't help with the ambiguous cases anyways.


This is the elegant solution.

Where does it break down?

This isn't the same thing as "magically" changing the type.

What does the syntax technically mean?

What refinement is undone?

I think you're a bit too wedded to the idea that there's a type conversion going on or some dark magic or something behind the scenes that's "done" then "undone" like a mechanism. There isn't. It's just static analysis. Same as:

    let x: Animal;
    x = Dog()
    if (x is Dog) { 
      x.bark()
    }

The Zen koan you want to ponder on your end is, why do you want to A) eliminate polymorphism from OOP B) remove the safety of a compiler error if the code is changed to x = Cat()?


I don’t like that either. x is Dog is a boolean expression. Presumably I can write

  let isDog = x is Dog;
And the value whether it is a dog or not goes into that new variable. The fact that I can’t then immediately go

  if (isDog) {
     x.bark()
  }
shows the deficiencies of this static analysis (even if you could do some simple value tracking to make this work, it doesn’t really address the real issue I have with it, which I described above: why can’t I do this refinement for other things?) The conceptual model is one of “we special cased 2-3 cases that we can detect”, which I don’t find very elegant, especially considering that other languages seem to have better solutions that express the operation in what I feel is a better way.

(The equivalent Swift code would be something like this:

  let x = Animal()
  if let dog = x as? Dog {
      dog.bark()
  }
I see this as much superior. One, because x is still available in the scope of I want an Animal and not a Dog for whatever reason. I understand that most of the time you don’t do this but to have it available and not have to do a special case for it is nice. The second thing is that I just like the syntax, since it gives you an opportunity to give a more specific name in that branch as part of the assignment. Third, it composes, because none of the operations are special save for the “if let” binding. This code is also valid:

  let x = Animal()
  let dog /* : Dog? */ = x as? Dog
  if let dog /* = dog */ {
  }
The static analysis for undoing polymorphism is the exact same as the one for binding to optionals, because the idiomatic way to cast results in an optional.)


> The fact that I can’t then immediately go

Who said you can't? :) This actually works in Dart:

    Dog? dog;
    bool isDog = dog is Dog;
    
    if (isDog) {
      dog.bark();
    }
i.e. boolean variables which serve as witnesses for type judgements are integrated into promotion machinery.

I do agree with you that

1. In general there is no limit to developers' expectations with respect to promotion, but there is a limit to what compiler can reasonably achieve. At some point rules become too complex for developers to understand and rely upon. 2. Creating local variables when you need to refine the type is conceptually simpler and more explicit, both for language designers, language developers and language users.

I don't hate excessive typing, especially where it helps to keep things simple and readable - so I would not be against a language that does not do any control flow based variable type promotion. Alas many developers don't share this view of the world and are vehemently opposed to typing anything they believe compiler can already infer.

It's all a matter of personal taste in the end.


Kotlin can't do this, which the language I have more experience with. It's good that Dart does a little better in that regard. And, I think we do agree, I just don't really like the viewpoint of doing this, because I feel like it's not really general enough.


I don't think any of that has to do anything with static analysis deficiencies. The analysis is the same. What Swift requires is for the user to manually help the compiler with a rather verbose and otherwise unnecessary construct.

Type narrowing is not a new idea


It’s not a new idea, but I don’t think it’s a good idea. That’s just it. Swift has a construct to do the equivalent and it’s fewer characters to boot when compared to Dart when you’re going from an optional to non-optional type.


But that's exactly what Swift does: it narrows the types. To do that, it uses a separate construct that only exists to make the parser ever so slightly simpler.


Yes and I think having a separate construct rather than using another one and imbibing it with new meaning is more elegant


The ugly solution is Swift's if let x = x which is literally the same check, but in a more verbose manner.

Yes, compilers should be able to help in this and many other cases, and not just give up and force the programmer to do all the unnecessary manual work


It’s not a check, it’s binding a new variable with a different type (which may or may not have the same name; if it does you can use if let x these days). And solving the other examples I mentioned is generally out of the realm of most programming languages in use today (except maybe TypeScript?) It comes with significant challenges that I’m not sure we are ready to address properly yet.


In Dart if you want to narrow down a nullable final class variable, it is not enough to do

if (value == null) { // value is still nullable here }

instead you need to first capture the value

final value = this.value if (value == null) { // value is non-null }

Swift's guard and if let syntax seems way nicer here. In swift, it's also nice to destructure nested nullable values using if let.

if let value = object.object?.value { // value is non null }

In dart you'd need to first assign this into a local variable and then do the null check which is unnecessarily verbose.


In Dart 3 instead of declaring the variable and then comparing you can simply write an if-case pattern:

    if (value case final value?) {
      // value is a fresh final variable 
    }


So Dart is moving in the Swift direction, which has

    if let value = value {
      // a new binding named ‘value’ refers to value, unwrapped 
    }
or (newer syntax that I initially found even weirder than the older one, but both grew on me)

    if let value {
      // a new binding named ‘value’ refers to value, unwrapped 
    }
(The old version will remain, as it is more flexible, allowing such things as

    if let baz = foo.bar(42) {
      // a new binding named ‘baz’ refers to foo.bar(42), unwrapped 
    }
)


The more you know, I had totally missed this.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: