Unexpected result from short-circuiting (inline if)

I’m quite familiar with using short-circuit evaluation as an inline if, and my understanding was that writing cond && (expression) is exactly identical to the more traditional:

if cond
    expression;
end

However, I found a case where this leads to unexpected behavior. I needed to find the minimum and maximum of each sub-list of a list of lists T, element-wise (among other things). So I wrote something in the same spirit of the following:

T = [[1,2],[0,1],[1,-1]];
limits = [Inf,-Inf,Inf,-Inf];
cond = true;

for t in T
  for i in eachindex(t)
    if cond
      limits[2*i-1:2*i] .= min(limits[2*i-1],t[i]), max(limits[2*i],t[i]);
    end
  end
end

Here the behavior is as expected, with limits = [0.0,1.0,-1.0,2.0]. However, if I substitute the if in the middle with:

cond && ( limits[2*i-1:2*i] .= min(limits[2*i-1],t[i]), max(limits[2*i],t[i]) )

I obtain limits = [0.0,0.0,-1.0,-1.0]!

Why is this the case? By trial-and-error, I found that putting additional parentheses around min and max fixes the issues, but this unexpected result makes me wary of using short-circuit evaluation so freely. Does anybody have any idea?

3 Likes

It’s due to a difference in parsing (I removed the indexing for clarity):

julia> Meta.@lower if cond
             limits .= min(limits,t), max(limits,t);
           end
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope`
1 ─      goto #3 if not cond
    @ REPL[2]:2 within `top-level scope`
2 ─ %2 = limits
│   %3 = min(limits, t)
│   %4 = max(limits, t)
│   %5 = Core.tuple(%3, %4)
│   %6 = Base.broadcasted(Base.identity, %5)
│   %7 = Base.materialize!(%2, %6)
└──      return %7
3 ─      return nothing
))))

julia> Meta.@lower cond && (limits .= min(limits,t), max(limits,t))
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope`
1 ─      goto #3 if not cond
2 ─ %2 = limits
│   %3 = min(limits, t)
│   %4 = Base.broadcasted(Base.identity, %3)
│   %5 = Base.materialize!(%2, %4)
│   %6 = max(limits, t)
│   %7 = Core.tuple(%5, %6)
└──      return %7
3 ─      return false
))))

Note that in the first case, the tuple is destructured & then broadcast, while in the latter case only the first element of the tuple is broadcast. I.e., the latter case constructs the outer tuple due to your parentheses making the whole expression after the && a tuple, and not just the part after the broadcasted assignment.

4 Likes

This looks like a nice one to add to Syntax Surprises.

Observe:

julia> a = 1, 2
(1, 2)

julia> a = (1, 2)
(1, 2)

julia> (a = (1, 2))
(1, 2)

julia> (a = 1, 2)
ERROR: syntax: invalid named tuple element "2" around REPL[228]:1

The basic idea is: in most circumstances, you should expect to have to wrap Tuples in parentheses; excluding the parentheses for standalone assignment statements is the exception, not the rule.

Within parentheses, = binds more tightly than comma—this allows NamedTuples to be formed. Namely, we get to make these:

julia> (a=1, b=2)
(a = 1, b = 2)

But, it comes with the drawback of somewhat unintuitive parsing rules, where the binding tightness of = depends on whether it’s wrapped in parentheses or not.

To illustrate how this influences your case:

julia> b = [0, 0];

julia> if true; b .= (1, 2) end;  b
2-element Vector{Int64}:
 1
 2

julia> true && (b .= 1, 2)
([1, 1], 2)

julia> b
2-element Vector{Int64}:
 1
 1

julia> true && (b .= (1, 2));  b
2-element Vector{Int64}:
 1
 2
8 Likes

Thank you (and @Sukera ) so much! You both made it absolutely clear to why this is the case, and I chose your answer as solution mainly for its accessibility. I love Julia’s flexibility, but sometimes I do feel like I’m allowed to do too much!

1 Like

Thanks! Honestly though, I feel like this one’s a bug so I filed an issue. There’s good reason to disallow (a = 1, 2), because NamedTuples are awesome, but then (b .= 1, 2) should be disallowed too.

It doesn’t help that broadcasting automagically fills dimensions, so it causes a silent error.

2 Likes

Indeed, that’s the worst part in this example

3 Likes

= has special rules for parsing within parentheses. .= is not = and similarity in this context is superficial. b .= 1 is a complete and legal expression.

That said, I had expected the following to be legal:

julia> ((a = 1), 2)
ERROR: syntax: invalid named tuple element "2" around REPL[405]:1

I’d expected that the expression would evaluate to (1,2) and that afterward I would have a bound to 1. Not that it would tunnel through the parentheses and try to build a NamedTuple.

Even more strangely, the outer parentheses were apparently irrelevant to what I had tried:

julia> (a = 1), 2
ERROR: syntax: invalid named tuple element "2" around REPL[406]:1

julia> (a = 1), (b = 2)
(a = 1, b = 2)
5 Likes

In that case, (b .= 1, 2) should parse the same as b .= 1, 2 to mean b .= (1, 2) instead of (b .= 1), 2.

image

cc @c42f

Yes this is a syntax peculiarity and I don’t like it either (basically the same issue as described here Syntax Surprises - #35 by c42f)

Yikes this one is a definite syntax bug, we should not be allowing this!

1 Like

I started a label to track syntax WATs in JuliaSyntax.jl and added an issue for this.

3 Likes