Why is it that Arrays can't be modified using nested index

In this expression

A = ones(3,3);
# this works
A[1,1] = 2;

julia> A
3×3 Matrix{Float64}:
 2.0  1.0  1.0
 1.0  1.0  1.0
 1.0  1.0  1.0

# but this does not
A[:,1][1] = 3;

julia> A
3×3 Matrix{Float64}:
 2.0  1.0  1.0
 1.0  1.0  1.0
 1.0  1.0  1.0

why is A not modified in this instance? I realize that A[:,1] is creating a copy of A[:,1] and then I’m indexing into that copy, for instance I can get it to work by specifying view(A[:,1])

@view(A[:,1])[1] = 3;

julia> A
3×3 Matrix{Float64}:
 3.0  1.0  1.0
 1.0  1.0  1.0
 1.0  1.0  1.0

What I don’t get is why julia is unable to recognize that the user would like to modify A, is there any ambiguity that this is what the user would like to do? Having to use view seems unintuitive.

The tldr is that the decision to make A[:, 1] a copy rather than a view was made back in 0.6 and was made due to the cost of views in 0.6 that was largely removed in 1.5 due to a smarter compiler. If we were making the choice today, we probably would do it differently.

11 Likes

That’s unfortunate, would this be something that could be fixed in 2.0 or is it just too big of change now?

This would be on the very edge of fixable in 2.0, but it would be a really big change.

There are a number of issues and PRs related to views/copies, you can e.g. start at range indexing should produce a subarray, not a copy · Issue #3701 · JuliaLang/julia · GitHub.

Another aspect of this is that

A[:,1][1] = 3

lowers to

setindex!(getindex(A, :, 1), 3, 1)

and at that point the intent is not really visible any more (as the getindex call is evaluated before setindex gets called).

2 Likes

I think it’s pretty risky to go down the path of breaking the rules of how the language works and try to second-guess the user: “You wrote this, but probably meant something else. Let me fix that, quietly, behind your back.”

For those who know that slices make copies, it would be very unintuitive to see that happen.

4 Likes

I guess my point in this case is that from a purely syntax perspective what the user wants is not ambiguous… there is no guessing needed. If the nested index appears on the left hand side of the = then user is trying to modify the variable on the left hand side. Is there a case where this break?

It could be for testing purposes or experimentation or demonstration. I don’t know.

But I think the suggestion is a really bad principle to follow, twisting or even outright changing the meaning of the code in order to guess what is ‘really meant’. I would guess it makes work harder for the compiler too, and as far as I understand it also breaks referential transparency.

And it feels super weird and dangerous. How are you going to be able to look at code and reason about what it does, when basic rules are broken like that?

In cases like this, you should get help to fix the code, for example by a linter, not have the code secretly fixed for you.

2 Likes

I can’t help but think that having a rule that states that all indexing on the left hand side of an equals sign is a view rather than a copy would be a significant benefit:

a = [[1,2],[3,4], [5,6]];
a[1][1] = 0;

julia> a
3-element Vector{Vector{Int64}}:
 [0, 2]
 [3, 4]
 [5, 6]
a = [1 2; 3 4; 5 6]
a[:,1][1] = 0;

julia>a
3×2 Matrix{Int64}:
 1  2
 3  4
 5  6

What does work at present is using @views in front of the expression, which ensures that all indexing happens in-place

Edit: this is not adding anything new

Implicitly creating a copy instead of a subarray view seems to fit this description.

1 Like

I’m sorry, can you explain? Did you mean “explicitly and consistently with how getindex works, as described in the documentation”?

It’s not that I think the current behavior is fantastically useful, but it’s logically consistent.

1 Like

I’d not put things in such strong terms. This sounds more like a set of rules, where indexing on the left-hand side is always wrapped in @views. Currently, a view is created in certain cases while not in others, e.g.

a[:,1] .= 2

will not compute a[:,1] and then overwrite it with 2, rather it will compute @view(a[:,1]) and overwrite it with 2. In your words, this is already trying to guess what is really meant, rather than being consistent in how getindex works. That’s because this is lowered to dotview, and not getindex:

julia> Meta.@lower a[:,1] .= 2
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope`
1 ─ %1 = Base.dotview(a, :, 1)
│   %2 = Base.broadcasted(Base.identity, 2)
│   %3 = Base.materialize!(%1, %2)
└──      return %3
))))

This isn’t obvious at all from the syntax.

1 Like

I’ve always read this as a setindex! expression, nothing to do with views, so I’m a bit surprised to see that the above is implemented in terms of view.

The wording is probably due to what I interpreted as the logic behind the proposal: Let’s break the rules whenever we think the intention of the code is something other than what is written.

Discussing if setindex! should, for example, handle nested indices, on the other hand, is less problematic.

2 Likes

Sorry, perhaps I shouldn’t have used the word “implicitly,” as ultimately whether something fits that descriptor is a function of whether it’s documented—which this is.

Putting myself back in the shoes of someone freshly learning this aspect of the language, though, it’s not clear that accessing a slice should create a copy. Infact, it’s counterintuitive because it seems inconsistent.

Namely, getindex on a slice creates a copy, while setindex! (quite obviously) doesn’t. While this is straightforward when you consider the lowered code, from the syntax it’s less clear. From the syntax, it’s reasonable to believe slicing on the LHS of = simply doesn’t create copies while on the RHS it does. For the appropriate response to a newbie to be, “Well, first you must understand what this lowers to” is mildly frustrating, because we don’t interact with the lowered code—we interact with the syntax. (and why should an economist or a biologist have to know what lowering is, anyway?) What would have created a stronger sense of consistency would be if neither creates a copy, and both access slices of the array itself, so that nothing would seem different about appearing on the LHS vs. the RHS.

It seems as if the most direct way to resolve this inconsistency would be to make all sliced getindex calls return views, and to require copying to be performed by explicit calls to copy, as proposed in #3701. If that decision had been made, probably many aspects of the language would be quite different, and ripping the bandaid off now will be twenty times more painful than back then. Of course, it’s easy for me to Monday morning quarterback a decade after the fact; I’m sure there were hundreds of other considerations at play back then that have changed by now.

Agree with this. It’s probably just too late.

1 Like

The fact that this uses internal machinery that with the name view in it is entirely beside the point. The basic idea all stems from the concept that a[...] = x will modify a and not the object stored at a[...].

The most frustrating optima are those that we know about, but are just out of reach :sweat_smile:

Many people have brought up the possibility of view-slicing arrays for 2.0, but it seems increasingly unreachable. The possibility of full-Julia arrays has also been raised many times. Maybe if the two ideas are merged—make the full-Julia arrays view-slicing so that that becomes the preferred idiom—the transition to view-slicing arrays in 2.0 can be easier.

2 Likes

Thanks, the rules are certainly clear and consistent. The confusion stems from the fact that a[:,:][1] = 3 is a compound getindex+setindex! operation, which makes this out-of-place. In an operation like this, one may split this into two statements:

b = a[:,:];
b[1] = 3

However, this is not what one may do in a setindex!, despite the superficial similarity in syntax:

a[:] .= 3

is not the same as

b = a[:]
b .= 3

There is no contradiction here, just some getting-used-to.