Syntax Surprises

A fun one for the new year

One of the things I really like about Julia is being able to write code how I like (and the surprisingly large number of language decisions I find myself loving). The syntax is natural, I can add or subtract lines and indent however I feel is most appropriate in the moment—whether for brevity or for verbosity—I don’t need a bunch of extra parentheses and braces, matrix building is amazing, tuple destructuring, macros and generated functions are awesome, string and expression interpolation, builtin big numbers, regular expressions, complex numbers, let’s not forget the type system!.. I can go on for days. It’s amazing how much expressivity Julia’s creators were able to pack into a single language, and a high performance one at that!

But that superpower also comes with some [all things considered, a surprisingly small number of] gotchas that can be unexpected sometimes, and you might spend some time debugging before you figure out what happened. The parsing rules are amazing, in that remarkably concise syntax gets you what you want most of the time and that makes up for the times when you didn’t get what you expected; but it means you have to be careful sometimes.

I’ll list some things I’ve bumped into:

1. Sometimes Subtraction

julia> @show 1-2+3
(1 - 2) + 3 = 2
2

julia> @show 1 -2+3
1 = 1
-2 + 3 = 1
1

julia> @show 1 -2 +3
1 = 1
-2 = -2
3 = 3
3

julia> @show 1- 2+ 3
(1 - 2) + 3 = 2
2


2. Curry Me Maybe

julia> [<(5) cos]
1×2 Matrix{Function}:
Fix2{typeof(<), Int64}(<, 5)  cos

julia> [cos <(5)]
ERROR: MethodError: no method matching isless(::typeof(cos), ::Int64)


3. Symbol Swap

julia> s = 2 > 1 ? :Hello : :World
:Hello

julia> if 2 > 1  s=:Hello  else  s=:World  end
:Hello

julia> s = if 2 > 1  :Hello  else  :World  end
ERROR: UndefVarError: Hello not defined


4. Tuple Trouble

julia> f() = (local a, b = 1, 2; a+b)
f (generic function with 1 method)

julia> f() = (a, b = 1, 2; a+b)
ERROR: syntax: unexpected semicolon in tuple around REPL[2]:1


5. Parenthetically Speaking

julia> for x=1:3  print("$x ") end 1 2 3 julia> for x=(1,2,3) print("$x ")  end
1 2 3
julia> for x=1:3  (print("$x ")) end 1 2 3 julia> for x=(1,2,3) (print("$x "))  end
ERROR: syntax: space before "(" not allowed in "(1, 2, 3) (" at REPL[4]:1


6. Called It

julia> (4)(4)
16

julia> (4)(2*2)
16

julia> (2*2)(4)
ERROR: MethodError: objects of type Int64 are not callable


7. Poly vs Bi

julia> +(1, 2, 3)
6

julia> -(1, 2, 3)
ERROR: MethodError: no method matching -(::Int64, ::Int64, ::Int64)


8. Enter the Matrix

julia> if 1 > 2  A=[1 2; 3 4]  else  A=[4 3; 2 1]  end
2×2 Matrix{Int64}:
4  3
2  1

julia> A = if 1 > 2  [1 2; 3 4]  else  [4 3; 2 1]  end
ERROR: syntax: space before "[" not allowed in "2 [" at REPL[2]:1


Thankfully parsing surprises are better than runtime surprises, and many of these are avoided with a strategic semicolon.

Are there any syntax surprises and gotchas that should be added to the list? Post them below!

16 Likes
2 Likes

Wow, that’s a really good list. I never thought of 2e + 1 versus 2e+1

It feels like shortform integer-multiplied e and f should just be banned for this reason, or at least throw a warning indicating it’s hazardous to nudge you away from it. Depending on the relative magnitude of e and 10^1, mistaking 2e+1 for 2e + 1 could lead to runtime errors that aren’t caught during development à la Mars Climate Orbiter.

Edit: That wouldn’t solve it either because of 2.0e+1, 2e1, and 2.0e1.

1 Like

Syntax Surprises

More like bugs in the syntax, IMHO. Sure, parsing is hard, and designing a grammar isn’t simple either, but shouldn’t these be fixed eventually?

• Syntax for one-line, but multi-statement functions is ambiguous indeed: (local a, b = 1, 2; a+b) does look like a tuple and a named tuple at the same time. IMO, this is plain confusing to read for the programmer.

• Syntax of the form if thing ["a" "mat"; "ri" "x"] else ... (ex. 3, 5, 8) is weird as well: is the first opening brace trying to index into thing? or is it trying to define an array???

• Why would swapping <(5) and cos produce completely different interpretations? This looks like a bug that should be fixed.

• Why allow the programmer to “call” an integer literal like (4)(4) or even 4(4)? Sure, this looks like math notation, and Julia is all about writing code in math notation. If so, let me “call” arbitrary expressions as well and treat that as multiplication, so that (2*2)(4) == 2*2*4. Of course, that wouldn’t work because the expression could evaluate to anything: ("hello")(4) rightfully produces an error message (which takes several seconds to display, BTW). It would seem logical and consistent to make (4)(4) an error as well. Consistency is important!

• Why is there no method matching -(::Int64, ::Int64, ::Int64)? If Julia is mimicking LISP by allowing multi-argument calls to + and *, then why not have multi-argument calls to - and /? LISP has no problems with this:

* (- 1 2 3)
-4
* (* 5 6 7)
210
* (/ 1 2 3)
1/6


Again, this seems inconsistent and unnecessarily confusing.

2 Likes

I thought the variadic + and * were because they’re associative but afaik subtraction isn’t.

I agree on the others.

4 Likes

Correction:

julia> Base.operator_associativity(:+)
:none

julia> Base.operator_associativity(:-)
:left


I mean addition and multiplication are associative mathematically and subtraction isn’t. I think that’s a different meaning of associativity than in parsing.

1 Like

Oops! It skipped my mind that you might be referring to the mathematical property of the operator.

How does this affect the existence of the -(::Int64, ::Int64, ::Int64) method?

• Since + is associative, (a+b)+c == a+(b+c) and (a+(b+c))+d == a+((b+c)+d) and so on, so I could drop parentheses and simply write a+b+c+d == +(a,b,c,d). Makes sense.
• Since - is not associative (a-b)-c != a-(b-c), it’s not possible to unambiguously write a-b-c without parentheses. Thus, -(a,b,c) could mean (a-b)-c or a-(b-c). Makes sense as well.

However, a-b-c-d always means ((a-b)-c)-d since - is left-associative. Why not make -(a,b,c,d) evaluate to a-b-c-d?

For +, @code_llvm (1+2+3+4) and @code_llvm (+(1,2,3,4)) produce:

; %0, %1, %2 and %3 are the four input numbers
%4 = add i64 %1, %0
%5 = add i64 %4, %2
%6 = add i64 %5, %3

ret i64 %6

Order of addition is swapped for floats

Note that the first instruction is %4 = add i64 %1, %0, so we add like %1 + %0, as opposed to %0 + %1, which would seem more natural?

If I use floats instead of integers (@code_llvm (1. + 2. + 3. + 4.)), the order in the first instruction will be swapped:

; The integer version had add i64 %1, %0
%4 = fadd double %0, %1 ; %0 + %1
%5 = fadd double %4, %2
%6 = fadd double %5, %3

ret double %6


Is this more efficient? More resistant to floating-point shenanigans? Seems inconsistent to me. Julia 1.9.0-beta2, commit 7daffeecb8c (2022-12-29 07:45 UTC).

Why not have @code_llvm (-(1,2,3,4)) produce almost the same thing, but with subtraction instead of addition:

; %0, %1, %2 and %3 are the four input numbers
; NOTE: swapped %0 and %1 compared to addition above
%4 = sub i64 %0, %1 ; %4 =  1 - 2 = -1
%5 = sub i64 %4, %2 ; %5 = -1 - 3 = -4
%6 = sub i64 %5, %3 ; %6 = -4 - 4 = -8

ret i64 %6; -8

1 Like

Just a clarification for newcomers that may believe that issue 1 indicates that Julia has a fundamental problem with operator precedence, associativity or commutativity. That is of course not the case. This is a question of how to invoke macros and separate arguments in the call. When a macro is called without parentheses, its arguments are whitespace separated (see documentation).

In a nutshell, @show 1 -2+3 is equivalent to @show(1, -2+3). Adding parentheses resolves the ambiguity in all four examples of issue 1:

julia> @show(1-2+3)
(1 - 2) + 3 = 2
2

julia> @show(1 -2+3)
(1 - 2) + 3 = 2
2

julia> @show(1 -2 +3)
(1 - 2) + 3 = 2
2

julia> @show(1- 2+ 3)
(1 - 2) + 3 = 2
2


And to be extra clear, evaluating the four expressions without a macro call always produces the expected result:

julia> 1-2+3 === 1 -2+3 === 1 -2 +3 === 1- 2+ 3 === 2
true

30 Likes

This would be interesting to consider with GitHub - JuliaLang/JuliaSyntax.jl: A Julia frontend, written in Julia . Ping @Chris_Foster

2 Likes

It’s also a question of how to build matrices, and how to separate the elements of a row within a matrix. To demonstrate using row vectors:

julia> ([1-2+3], [1 -2+3], [1 -2 +3], [1- 2+ 3])
([2], [1 1], [1 -2 3], [2])


Add a comma and it throws an error:

julia> [1 -2 +3,]
ERROR: syntax: unexpected comma in array expression


similarly,

julia> [1 -2, +3]
ERROR: syntax: missing separator in array expression


This is because commas are used only for building one-dimensional arrays, i.e. column Vectors, and that’s incompatible with matrices and other multidimensional arrays.

To do arithmetic on elements of the vector, spaces around the operator must be symmetric:

julia> [1-2, +3]
2-element Vector{Int64}:
-1
3

julia> [1 - 2, +3]
2-element Vector{Int64}:
-1
3


(more precisely, if there’s space on the left-side of the operator, then if there is no space on the right-side of the operator it will not be treated as a binary operator but instead as a unary operator.)

To build a higher-dimensional array such as a matrix, use semicolons or newlines:

julia> [1 -2 +3; -4 +5 -6]
2×3 Matrix{Int64}:
1  -2   3
-4   5  -6


In this expression, it becomes perfectly clear why these parsing rules work this way.

You can also build a column vector using semicolons or newlines instead of commas, because column vectors are just the tiniest subset of multi-dimensional arrays:

julia> [1; 2; 3]
3-element Vector{Int64}:
1
2
3


let’s do some arithmetic:

julia> [1+1; 2+2; 3+3]
3-element Vector{Int64}:
2
4
6


But add funny spaces around elements, and you can get an error:

julia> [1+1; 2 +2; 3+3]
ERROR: ArgumentError: argument count does not match specified shape (expected 3, got 4)


Julia’s parser is very clever for this specific reason—within the bounds of [], it’s assumed that expressions will be space-delimited for matrix-building, and so parsing rules needed to be made to handle this. And then, similar parsing rules are applied when we call macros without parentheses because we’re using spaces again as a separater between arguments.

It’s just something to be mindful of; you’ll run into it sooner or later anyway, either when building arrays or when calling macros, so it’s best to just adopt habits of symmetrical whitespace around operators. Symmetrical whitespace is a good habit anyway for code legibility.

Note that these considerations only apply to plus and minus, which are the only operators which can either be binary or unary; other operators don’t act like this:

julia> ([1/2*3], [1 /2*3], [1 /2 *3], [1/ 2* 3])
([1.5], [1.5], [1.5], [1.5])


Best to adopt good whitespace habits anyway.

Edit:

Note that it could have been decided, instead of having whitespace-dependent rules for when + and - would become unary operators, to simply wrap expressions in parentheses within matrices:

julia> [1 (-2) (+3)]
1×3 Matrix{Int64}:
1  -2  3


Then there would be no room for confusion surrounding this topic, neither within arrays nor in macro calls.

However, building large matrices would become a pain. Julia’s creators, being math-centric folks who love matrices, chose to prioritize succinctness in matrix building even though it calls for some context-dependent whitespace-dependent parsing rules.

I’m partial to math myself (after all, math is the closest thing we have to a universal language), so I can’t fault them for that. But it does demonstrate quite nicely how decisions in a language design will necessarily revolve around the priorities of its authors.

Not just matrices, but anything wrapped in brackets or parentheses:

julia> if true Any[] end
Any[]

julia> if true [x for x=1:3] end
ERROR: syntax: space before "[" not allowed in "true [" at REPL[2]:1

julia> if isodd(1) (1,2,3) end
ERROR: syntax: space before "(" not allowed in "isodd(1) (" at REPL[3]:1


There’s a good chance this is a bug, but I don’t think #3 in the OP is; I suspect I might simply adopt a habit of adding a semicolon after the condition for one-liner ifs, fors, and whiles to avoid it altogether. I’ve already had to adopt a similar habit for one-liner let, do, and try...catch statements anyway.

This behavior I actually don’t find egregious. It’s enough of an edge case (who makes multidimensional arrays of functions anyway? ), and can be disambiguated with [cos (<(5))] or [cos;;<(5)]. Although of course I would prefer, instead of <(5), to use underscore partial application syntax _ < 5.

If you did want to make a multidimensional array of disparate functions though (why??), you’d probably want to roll a new type that maintains type stability by storing them in a Tuple.

I guess this is just highlighting how inconsistent mathematical notation is

In math we can write (a+b)(c+d) and it will mean implicitly (a+b)\times(c+d), yet we can also write (f * g)(t) and it does *not* mean (f * g)\times (t) (where * refers to convolution). The fact that the parsing rules of math depend on context and the type of object—which we use naming conventions and natural language descriptions to disambiguate—makes it impossible to architect a perfectly consistent programming language around it.

Yet we try, and for good enough reason: if the goal is consistency at all costs, using the language becomes difficult not for ambiguity, but for lack of resemblance with anything meaningful to a human (and everything is wrapped fifteen layers deep in parentheses). The extra verbosity to eliminate ambiguity in all edge cases reduces the SNR of the base cases, which then increases error rate. After all, isn’t resemblance to math, at least in part, what brings us to Julia?

Consistency is very valuable, but is not the sole measure of quality. The question then becomes not about how to achieve perfect consistency, but about what set of trade-offs is optimal. As we know about most real-life optimization problems (i.e., high-dimensional, nonlinear, and poorly observable; it has been said that life itself is a POMDP), there’s no knowably optimal answer; we descend gradients, we reason, we experiment, we develop heuristics, and of course we opine, but the ultimate referee is Father Time.

Languages like mathematics and English have spent hundreds of years being refined, even if imperfectly, to balance these demands—to maximize information transfer rate whilst minimizing error rate and maintaining sufficient brevity for experiments and derivations. By Lindy’s Law, these languages should serve as a decent starting point for programming language design; indeed, many successful languages have incorporated some blend of their features, Julia especially so.

Various features can be debated here and there, but 4(4) is pretty unambiguous as multiplication instead of function call so I don’t really see how it should raise too much controversy. For any weirdos who want to call their integers, I think it’s fairly agreeable that they should be forced to write x=4; x(4).

2e + 1, on the other hand, kinda scares me. Same for this:

f2=101
2(f2)  #-> 202
2f2    #-> 200.0f0


Not sure why it took so long; on my system it’s immediate.

To answer this requires a greater mind than mine

2 Likes

I don’t think oldness is the “ultimate referee” — this isn’t the best of all possible worlds. IMO, a lot of math notation is confusing and impairs reading, learning, and understanding, relative to a more consistent, composable language.

1 Like
Getting slightly off-topic here 😅

I wouldn’t argue that it is! (unless I belonged to a species occupying a much more stable niche, like a crocodile )

I’m merely expressing views about optimum finding. In partially-observable nonlinear stochastic high-order dynamical systems (aka “life”), the optimum will invariably contain features that are arguably inconsistent and irreconcilable (I think humans develop humor and cognitive dissonances to handle this without becoming self-destructive). So, it’s not the worst idea to start with what works and iterate from there, because there’s a good chance that what exists has found an optimum you wouldn’t think of or logically agree with.

Just like simulated annealing though, there’s no guarantee you’ve found the global optimum; in all likelihood we haven’t. And in all likelihood, tomorrow’s optimum won’t be the same as yesterday’s. So we keep experimenting and adapting.

To be slightly more technical, the ultimate referee is survivorship despite a barrage of challenges and competitors. Time is a proxy for experience, ceteris paribus. “Ceteris paribus” is the tricky bit.

This could make for an interesting thread!

P.S. your wiki article has a fun easter egg

For instance, it is logically possible that a meteor might have fallen from the sky onto Wikipedia founder Jimmy Wales’s head soon after he was born, killing him. But it is not logically possible that what happens in a given world (e.g. that Jimmy Wales founded Wikipedia) also does not happen in the same world (i.e. that Jimmy Wales did not found Wikipedia). While both of these events are logically possible in themselves , they are not logically possible together , or compossible – so, they cannot form part of the same possible world.

Completely agree. This is exactly what I was just about to reply to @uniment.

I wonder whether programmers really find the ability to premultiply by a numeric literal like 4a + 5b or like (5)(5) useful enough. Does it really improve coding speed that much? Does it help understand code better than the usual notation 4 * a + 5 * b?

Perhaps, but what’s the cost of such resemblance to math? If it’s confusing syntax like in OP or complicated and error-prone context-sensitive parsing, then I’d rather write 4 * a instead of 4(a).

IMO, it’s pretty unambiguous as a function call, because basically all widely used languages treat <thing>(<thing>) as a function call:

• R, the language of statisticians, doesn’t let you call your numbers.
• Python, the language of data scientists, doesn’t let you call numbers either.
• The Wolfram language, arguably the language of mathematicians, does support this syntax:
In[1]:= 4(6)
Out[1]= 24

However, Wolfram uses square brackets [] instead of parentheses () for function calls, so 4(6) can’t be interpreted as a function call and is thus unambiguously multiplication. A function call looks like Length[{2,3,4}] == 3.
• C and Rust, systems programming languages, definitely don’t allow this. Rust’s error message clearly says “call expression requires function” about code like 4(5).

…which is yet another confusing consequence of the same design choice to allow multiplication by calling numbers or prepending numeric literals to identifiers.

The ultimate questions are: do Julia programmers find this syntax useful enough? How many of us prefer it to the usual 4 * a? What are its advantages compared to the usual syntax, except being at most 3 characters shorter? What are its disadvantages? Do the advantages outweigh the disadvantages?

Petition to make syntax like 4a, 4(a) and (4)(a) an error in Julia 2.0.

I think you’ll find that a lot of us like this syntax — it’s less about saving one * character and more that 3x+1 is more readable than 3*x+1 — and it doesn’t seem to have been a practical source of lots of bugs.

This seems like one of those things that is simply not on the table: PSA: Julia is not at that stage of development anymore

12 Likes

I like this syntax as well. I also think that 3x+1 is more readable than 3*x+1.

But when I discovered that this allows “calling numeric literals” like 3(x) + 1, then noooooooo, I don’t like this syntax enough to tolerate this kind of weirdness. It’s not worth it, IMO.

Also, if 4(6) is multiplication, then 4.(6) should be multiplication too! Julia disagrees:

julia> 4.([6,7])
ERROR: syntax: numeric constant "4." cannot be implicitly multiplied because it ends with "."
Stacktrace:
[1] top-level scope
@ none:1


The parser understands that 4. is a “numeric constant”, but refuses to multiply because “it ends with .”?

• This error message doesn’t tell me why it can’t be implicitly multiplied. “it ends with .” - why is this a problem?
• It seems like here the multiplication syntax collides with the “map”-style function calls (like sqrt.([1,2])), so Julia itself can’t quite decide whether to interpret this as multiplication or a function call. However, it’s clearly multiplication, since the first expression is a “numeric constant 4.” and prepending numeric literals like this means multiplication.

4.0([6,7]) is interpreted as multiplication without issues.

We could certainly have a better error message here — feel free to submit a PR. (See also julia#22498 — this error-message code needs updating anyway.)

The original choice to make this an error was in julia#16339 (though the message was later revised) based on the discussion in julia#15731; as I recall, the feeling was that allowing 4.x for 4.0*x is just too confusing, especially in conjunction with dot operators (and later dot calls) and other styles of numeric literals.

5 Likes

I would suggest:

…numeric constant 4. cannot be implicitly multiplied because it ends with ..
Syntax like 4.xyz or 4.(xyz) can be confused with dot calls my_function.(value) and field access my_struct.field, so it was excluded from the implicit multiplication syntax.

This is what all the GitHub issues seem to be saying too. However, it just further highlights that implicit multiplication syntax is confusing. Not sure if that’s the kind of error message developers of Julia want to have in the language. On the other hand, it’s an honest error message which admits that some syntax is confusing…

1 Like