If you use a recently assigned variable on the right side of a && expression, the compiler forgets it can't be nil

A little convoluted title, but the example will clear things up:

if (x = 1 + 1) > 0
  p typeof(x)      #=> Int32
end

if true
  if (y = 1 + 1) > 0
    p typeof(y)    #=> Int32
  end
end

if true && (z = 1 + 1) > 0
  p typeof(z)      #=> (Int32 | Nil)   -- why??
end

Is this intended? 2nd and 3rd cases are practically the same thing, aren’t they?

Maybe the thread title should be something like “Non-nil variable assigned in complex if has Nil union type in body” ? (not sure if thread titles can have formatted text like that)


Grabbing that last section of code and adding one more line:

if true && (z = 1 + 1) > 0
  p typeof(z)      #=> (Int32 | Nil)   -- why?
end
p typeof(z)        #=> (Int32 | Nil)   -- sure, yes, seems right

It does make sense to me that the second typeof(z) in my version would return (Int32 | Nil) because I can’t imagine a “real” example where the compiler would know for sure that the left side of that && was true. The conditional above is logically equivalent to if (z = 1 + 1) > 0; the true && really changes nothing to the truthiness. If we used any non-trivial condition instead then it might very well be false, so z would never be assigned.

On the other hand, z will always be assigned in the body of the if. However…

a = do_something(1)
b = do_literally_anything(2)
c = no_seriously_just_do_anything_at_all(3)
if (a > 0 && b < 0) || (c < 10 && (z = 1 + 1) > 0)
  p typeof(z)      #=> (Int32 | Nil)   -- okay, yeah, that's right in this case
end

We can reason through this conditional and see that it’s reasonable to say that z could be unassigned in the if body. But does the compiler need to be able to unravel any possible conditional to determine whether variables assigned in the condition can be Nil in the body? I’m not really sure whether it’s worthwhile or not, but I thought it was worth raising that this could be a complex capability to fully implement and might not be worth the time or additional compiler complexity.

But it’s only when you use it on the expression. If you just assign it, the compiler knows it isn’t nil inside the if-block:

if true && (z = 1 + 1)
  p typeof(z)      #=> Int32
end
p typeof(z)        #=> (Int32 | Nil)

As far as I know, there isn’t any method you can call on a non-nil variable to make it nil, so it shouldn’t matter if you call anything on it after you assign it.

Oh, that’s weird. Huh.

It also works if you write out the full expression:

if true && (z = 1 + 50) && z > 0
  p typeof(z) # => Int32
end

So maybe it has something to do with how the other version works/is expanded.

This looks like an omission in the type filter algorithm. It seems to consider assignments only as direct operands of a logical operator, not when it’s nested within a call.

MainVisitor#visit(Call) sets @type_filters = nil. That would explain this behaviour.

2 Likes

The odd thing is: If I comment that line, no spec breaks… :smirking_face:

1 Like