Why assignment operators return the right-hand-side

This maybe my biggest syntax gripe in julia. I really think that assignment should have returned the value on the left, not the right, and I find it really unfortunate that we chose the right hand side.

It makes so many of our assignment tricks unpleasant to use. In addition to the one you showed, there’s

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

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

and

julia> (;re) = 1 - im
1 - 1im

julia> re
1

and some other sharp edges. I’ve tried to understand the rationale for returning the RHS but I really just can’t wrap my head around it.

5 Likes

Regarding this argument, I don’t really find it makes much sense to me.

When you see someone write

x = (;a, b) = (;a=1, b=2, c=3, d=4)

do you really read that as

temp = (;a=1, b=2, c=3, d=4)
(;a, b) = temp
x = temp

?

For me, the code realllllly seems to suggest something more of the form

(;a, b) = (;a=1, b=2, c=3, d=4)
x = (;a, b)

Since we’re taught that (;a,b) = coll is shorthand for a, b = coll.a, coll.b, it seems straightforward to me that writing x = (;a, b) = coll would be x = ((;a,b) = coll) which is x = ((a, b) = coll.a, coll.b).


Similarly, I see

x = y::Float64 = 1

as absolutely saying x = (y::Float64 = 1) and so suggesting both x and y should be Float64.

3 Likes

Yes, out of habit (Python). I think it’s better this way: even if we don’t let assignment convert, indexing and properties may do more drastic changes, so x = y[1] = 0 resulting in y[1] = 0; x = 0 is much more predictable than y[1] = 0; x = y[1] # may not be 0. It could have easily gone the other way, I think C does that.

Oh I see. As I mentioned before, primitive types are a bit special because they aren’t composed of anything. The conversion-constructor equivalence isn’t automatic either, for example there are convert methods among Number and AbstractChar that forward to the type constructor and then assert the type:

# number.jl
convert(::Type{T}, x::T)      where {T<:Number} = x
convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T
# char.jl
convert(::Type{AbstractChar}, x::Number) = Char(x) # default to Char
convert(::Type{T}, x::Number) where {T<:AbstractChar} = T(x)::T
convert(::Type{T}, x::AbstractChar) where {T<:Number} = T(x)::T
convert(::Type{T}, c::AbstractChar) where {T<:AbstractChar} = T(c)::T
convert(::Type{T}, c::T) where {T<:AbstractChar} = c
2 Likes

There isn’t really a correct answer here, because there’s no accepted associativity for assignment. You expect it to be right associative: (x = (y = z)), but it’s left associative ((x = y) = z).

The advantage of this choice is that you can pick up different parts of the R-value, like a, b, c, d = struct.a, _, struct.c = 1, 2, 3, 4. There’s less value in only being able to destructure and assign what you’ve already captured. I do find it a bit confusing at the REPL when the repr is of the full R-value, not the L-value I’ve captured with the assignment, but the existing behavior probably wins for actually writing programs.

In any case, if you remember that it’s an associativity thing that should help keep it in mind.

Assignment is right-associative, but the value of an assignment expression is the right-hand side, not the left. (x = y) = z is not a valid expression, because x = y is not an l-value.

4 Likes

Is there a language where chained assignment is left-associative? AFAIK it’s all right-associative, it’s just a tossup whether the value is the right hand side (which becomes the value assigned to every target) or the left hand side (which becomes a chain of possible changes via assignment and access).

1 Like

Note that as of 1.11, Array is mostly implemented in Julia (although the new Memory which Array is now built on still has significant amounts of C)

5 Likes

Thank you, this is what I was attempting to convey. When I said:

It should have been “there’s no accepted standard for which side of an assignment is returned in an expression”.

It’s noteworthy that the set! family of expressions in Scheme return “an unspecified value”, if I’m recalling my lore accurately, the “correct” return value of assignment was a bitter dispute in Lisp circles.

But the semantics of the operator don’t reduce to associativity, so what I said was incorrect.

I guess it’s just that for me, the only way I can really understand syntax like

x::T = y
(a, b) = z
(;a, b) = w

is that these aren’t real assignments, nor are they real pattern matching, but they’re actually transformed-assignments, so it seems weird to me that these transformations don’t get propagated, especially given the right-associativity of =.

1 Like

The reason assignment isn’t propagated is because assignment applies transformations like conversion and destructuring. That means that the several L-values can apply different transformations to the same return value.

Otherwise you would want a syntax like (a = b) = c to be legal, to get around the assignment propagation. That’s what I was trying to explain by describing the semantics of assignment in terms of associativity, but the result wasn’t correct.

2 Likes

No, I was just wrong about that. How would you assign a semantics to b in an expression like (a = b) = c? b is meaningless before c is evaluated.

One might want a syntax like that in a language where the normal return value of assignment is the left side, but that wouldn’t make things truly left associative.

There’s a reason mathematicians dislike = for assignment, assignment has none of the properties of equality suggested by the symbol.

1 Like

17 posts were split to a new topic: On using = vs := for assignment

I really don’t understand this as a justification, it’s circular. We absolutely could propagate these things, and it would (IMO) make a lot of sense.

(;num, den) = ((;re) = (1//2 + im//3))

reads to me quite clearly as saying “take the real part, assign it to re, and then split that into num and den which are the numerator and denominators.”

Instead, we propagate the right hand side, resulting in what I find to be rather surprising behaviour.

Same with

y = (x::Complex = 1)

To me, this very much reads as “convert 1 to a complex number, assign that to x and then assign that complex number to y”, but instead we get that y === 1.

Maybe I just have wonky intuitions, but this is one thing I’ve never really come to accept, it seems just as weird to me today as it did years ago.

Obviously it’s not some big deal or anything, and I don’t want to overstate how much this actually affects or annoys me. This is just my hobby horse I guess.

1 Like

The motivation for y = x evaluating to x is this:

  1. The value that y actually ends up with can be arbitrarily different from x since assignment to y can implicitly call convert, which can do anything.
  2. Since Julia is expression-oriented, we want y = x to be an expression and evaluate to some value.
  3. The classic way to make chained assignment work is to parse z = y = z as z = (y = x) and have y = x evaluate to the value you want to be assigned to z.

If the value of x and the value that y gets are the same, then it doesn’t matter whether y = x evaluates to x or y because they’re the same. But due to point 1, they’re not the same in Julia—this is true in many languages, but it’s worse for us because convert is user-extensible so someone can do something arbitrarily whacky. But even a relatively innocuous conversion like Int to Float64 could be problematic. I’m going to simulate z = y = x where y = x evaluates to y instead of x by writing out y = x; z = y:

function f1()
    # z = y = 1.0
    y = 1.0; z = y
    @assert y === 1.0
    @assert z === 1.0
end

Ok, here’s a version where there’s spooky action at a distance:

function f2()
    local y::Int

    # ... lots of code here ...

    # z = y = 1.0
    y = 1.0; z = y
    @assert y === 1
    @assert z === 1.0
end

Now we get an assertion error because a type annotation on y has “infected” the type of z even though the intention of the code is to initialize both y and z with 1.0 independently. We expect y to be forced to Int but we don’t expect that to affect z.

With typed globals these days, this gets worse because the type annotation could be anywhere at all, e.g.:

# in some other file:
global y::Int

function f3()
    global y
    # z = y = 1.0
    y = 1.0; z = y
    @assert y === 1
    @assert z === 1.0
end

Same assertion error but the spooky action at a distance can be arbitrarily distant.

All that being said, I think a better way to solve the issue would have been to parse chained assignment as a single construct instead of as a binary operation with right associativity. Then we could lower z = y = x like this instead:

tmp = x
y = tmp
z = tmp
z

So the assignements are done independently and the entire expression evaluates to the value of the leftmost assignee. I would consider that a desirable change for (hypothetical) Julia 2.0, and probably not terribly breaking because I don’t think many people will be relying on chained assignment evaluating to the RHS.

14 Likes

I’d definitely be a lot happier with the tmp lowering than our current lowering, but I really just think the spookiness of the user defined convert, and even the spooky action at a distance is not bad.

Fundamentally, if I write

z = y = 1.0

I want z to end up being assigned the value of y, not necessarily the value of 1.0. If I definitely wanted z = 1.0, then I’d have written that instead, not z = y = 1.0. But again, maybe I’m just weird.

I think with all these argument-side transformations we have, I really just want the chain

$w = $x = $y = z

as a sort of pipeline of transformations that build upon eachother, not a series of unrelated transformations.

2 Likes

I think I prefer the current behavior, it means that a = f(z) where f(z) = b = c = z does the same thing as a = b = c = z, the other way of doing it is more statement-oriented than expression-oriented imho. I doubt I’d see the difference in my code, I tend to avoid doing “clever” things like a complex assignment as a return value. I’m sure there are circumstances where this would be preferable.

2 Likes

My two cents is that I slightly prefer the current behavior, although could be persuaded to the “tmp lowering” suggested above.

As an example where convert would get in the way of things, consider

julia> function int_and_float1(x)
               # z::Int64 = y::Float64 = x, with both LHS taking the ultimate RHS as their value
               y::Float64 = x
               z::Int64 = x
               return x
       end;

julia> function int_and_float2(x)
               # z::Int64 = y::Float64 = x, with each LHS taking the adjacent RHS as their value
               y::Float64 = x
               z::Int64 = y
               return z
       end;

julia> int_and_float1(typemax(Int64))
9223372036854775807

julia> int_and_float2(typemax(Int64))
ERROR: InexactError: Int64(9.223372036854776e18)

Another example is that Quaternions.jl provides Quaternion which will convert from Real but not Complex (because it is ambiguous which Quaternion imaginary component should be filled from the Complex’s imag component, although one can debate the merit of this restriction). So x::QuaternionF64 = y::ComplexF64 = 1 would not be valid under “adjacent RHS” assignment. On the other hand, z::QuaternionF64 = y::Float64 = 1+0im is invalid under “ultimate RHS” assignment but valid under “adjacent”. I really see this debate as interminable.

Personally, I almost never use chained assignments (and certainly not where which value was assigned would be consequential) so I don’t have a horse in this race.

EDIT: I now recall that this thread is not about chained assignment. Sorry to contribute to this tangent. It looks like this subthread might benefit from a split.

2 Likes

Not any weirder than writing y = 1.0; z = y, which is unambiguous intent of accepting whatever changes y did to 1.0. convert isn’t the only change to contend with, if you do indexing or dot syntax then you’d involve setindex! and setproperty!.

I still think it could reasonably go either way, but chained assignment saves more typing now: t = Ref(1); y = t; z = t versus y = Ref(1); z = y, and that’s using the shortest temporary variable begrudgingly allowed to stick around. I also think there’s a naive reading of z = y = Ref(1) in 1 line as assigning Ref(1) to z and y in parallel, which is not what really happens but it usually has the same effect now (you could definitely make a pathological convert, setindex!, or setproperty! that mutates the input).

Now that I’m reminded of Python’s walrus operator and Julia’s compound expressions, another example of saving a temporary variable and much typing:

# check the right-hand value but also assign it for later use
if (x = y = rand(Bool)) #=...=# end

# to choose one of the variables instead
begin
   x = y = rand(Bool)
   y
end

whereas if assignments return the value of the left-hand variable after assignment

if begin
     tmp = rand(Bool)
     x = y = tmp # allow chained converts here for brevity
     tmp
   end
  #=...=#
end

begin
   x = y = rand(Bool) # allow chained converts here for brevity
   y
end

One case where the current behavior seems more sensible to me: indexing expressions. If we had x = y = z mean (y = z; x = y; x), then with indexing it would translate to: x[] = y[] = z means y[] = z; x[] = y[]; x[]; this can really do arbitrary things, in addition to being innefficient in the common case where y[] returns z once y[] = z has been evaluated, as it involves two distinct calls (setindex! followed by getindex).

4 Likes

It’s actually not really true that it is less efficient, because it’ll compile to the same code if these are regular loads and stores. LLVM will still compile it to the efficient version:

julia> code_llvm((Base.RefValue{Int}, Base.RefValue{Int}, Int); debuginfo=:none) do x, y, z
           y[] = z
           x[] = y[]
           x[]
       end
define i64 @"julia_#14_1001"({}* noundef nonnull align 8 dereferenceable(8) %0, {}* noundef nonnull align 8 dereferenceable(8) %1, i64 signext %2) #0 {
top:
  %3 = bitcast {}* %1 to i64*
  store i64 %2, i64* %3, align 8
  %4 = bitcast {}* %0 to i64*
  store i64 %2, i64* %4, align 8
  ret i64 %2
}

Notice there is only two store calls, and no load calls at all.

If you have a funky type (like if x and y used atomic loads/stores) then if the user wrote x[] = y[] = z then I’d say they probably intended for that to mean

y[] = z
x[] = y[]
x[]

and it would be bad if we sneakily tried to “help” them by turning that into

y[] = z
x[] = z
z

E.g. here’s the @atomic version of the above code:

julia> mutable struct AtomicRef{T}
           @atomic x::T
       end;

julia> Base.getindex(x::AtomicRef) = @atomic x.x

julia> Base.setindex!(x::AtomicRef, y) = @atomic x.x = y

julia> code_llvm((AtomicRef{Int}, AtomicRef{Int}, Int); debuginfo=:none) do x, y, z
           y[] = z
           x[] = y[]
           x[]
       end
define i64 @"julia_#18_1006"({}* noundef nonnull align 8 dereferenceable(8) %0, {}* noundef nonnull align 8 dereferenceable(8) %1, i64 signext %2) #0 {
top:
  %3 = bitcast {}* %1 to i64*
  store atomic i64 %2, i64* %3 seq_cst, align 8
  %4 = load atomic i64, i64* %3 seq_cst, align 8
  %5 = bitcast {}* %0 to i64*
  store atomic i64 %4, i64* %5 seq_cst, align 8
  %6 = load atomic i64, i64* %5 seq_cst, align 8
  ret i64 %6
}
2 Likes