Broadcasting setindex! is a noobtrap

Here’s a simple (slightly oversimplified) example about why broadcasting combined with setindex is really difficult.

Suppose we want to allow indexing of some custom matrix type, but we want it to be under a type hierarchy other than AbstractArray

using CategoricalArrays
struct CustomMatrix <: AbstractSomethingOtherThanArray
    pool::CategoricalPool
    mat::Matrix 
end 

I want getindex to return the relevant elements of mat. That’s easy enough:

const IndexingBaseCases = Union{Colon, UnitRange, Integer, Vector{I} } where I <: Integer
struct IndexConverter
    pool:CategoricalPool 
end 
(ic::IndexConverter)(x::IndexingBaseCases) = x  # generic method for standard indexing is identity
(ic::IndexConverter)(x::String) = ic.pool.invindex[x] # look up the integer index values corresponding 
(ic::IndexConverter)(v::Vector) = ic.(v)
(ic::IndexConverter)(v::Vector{Vector}) = vcat(v...)

import Base.getindex 
getindex(cm::CustomMatrix, i::IndexingBaseCases, j::IndexingBaseCases) = cm.mat[i, j]
getindex(cm::CustomMatrix, i, j) = cm[IndexConverter(cm)(i), IndexConverter(cm)(j)]

import Base.setindex! 
setindex!(cm::CustomMatrix, val, i::IndexingBaseCases, j::IndexingBaseCases) = cm.mat[i, j] = val
setindex!(cm::CustomMatrix, val, i, j) = cm[IndexConverter(cm)(i), IndexConverter(cm)(j)] = val

Great! now getindex and setindex on my custom matrix type will work with strings represented in pool (tangentially, this approach makes it easy to avoid methodambiguity errors and stackoverflow errors that are more common when you to use multiple dispatch for all sorts of types directly in getindex( .). This approach makes things easy to extend. If i want getindex and setindex to work with regular expressions, I can add one method to the IndexConverter functor.). This was really easy! Julia is great!

But if I try broadcasting setindex!,

cm = CustomMatrix([1 2; 3 4]), CategoricalPool(["A", "B"]) ) 
cm["A", :] . = 100 

The code will run but will not actually modify cm. This is a hidden semantic error! What caused this to happen? Well, as far as I can tell, by default, broadcasted assignment “.=” evaluates the left-hand-side before applying broadcast!. It’s not lazy and does not by default operate on a view. What has been evaluated is a copy, because that is what getindex returns. Thus, cm.mat is never modified.

To elaborate

My mental model of broadcasting over setindex, such as with

cm["A", :] = x 

is

broadcast!(identity, view(cm, "A", :), x)

In fact, I believe, it evaluates like this:

broadcast!(identity, getindex(cm, "A", :), x)

getindex returns a copy, so modifications don’t do anything (or at least I think… it’s hard to know if this is really what is going on under the hood).

This should not be default behavior. setindex! is supposed to mutate something. If Julia cannot know what needs to be mutated, then Julia should throw an exception. Otherwise, julia should actually be mutating an argument.

How can I make it work? Oh boy…
Julia documentation at Interfaces · The Julia Language provides an example in which 12 methods must be implemented. The complete example is trying to do a lot more than I want or is appropriate for my use case. Even so, I tried – and failed – to get it to work. Maybe I need CustomMatrix to be a subtype of AbstractMatrix (which I can’t do for other reasons)? It’s hard to know without understanding how all of the internals work: For me to really understand, I would need to dig deeply into the internals of, e.g., how does BroadcastStyle work? What do all of the styles do? How is it that these methods bypass the apparently default behavior of broadcasted setindex!?

I’m sure there are good reasons behind these design choices. But I’ve now spent half a day trying to figure out something that I thought would take 30 seconds.

I gave up. Instead of figuring out how to make setindex! work with broadcasting, I opted to write:

Base.view(mm::CustomMatrix, i::CM_BaseCaseTypes, j::CM_BaseCaseTypes) = view(mm.mat, i, j)
Base.view(mm::CustomMatrix, i, j) = view(mm, _IndexConverter(mm)(i), _IndexConverter(mm)(j) )

@warn """broadcasting not supported for setindex! on CustomMatrix
Instead, use: 
view(cm::CustomMatrix, ids...) .= value

""" 

This gives me the intended behavior of broadcasting setindex! and takes fewer lines. The downside is that I am departing from standard syntax, and anyone using my code will be surprised. If I could even get

cm[a, b] .= c 

to throw an exception with a warning to use @view instead, that would be nice for preventing user error.

Julia doesn’t know that your type — which is not an AbstractArray — supports views. To fix this, you just need to teach Base.dotview(A::CustomMatrix, I...) = view(A, I...). I think that’ll fix it.

Much of broadcasting’s more fancy infrastructure (and in particular its documentation) was written with AbstractArrays in mind.

4 Likes

Thank you so much! This is great!

To help others, do you think Julia could add some documentation in proximity to either the documentation on interfaces at Interfaces · The Julia Language or near method documentation for setindex! or broadcast! ?

Alternatively or additionally, it might be useful if broadcast! threw “MethodError: no method matching view( . )” in situations like this.

Julia is an excellent language. It is so easy to write concise, extensible, readable, and performant code. But finding these solutions often requires detailed knowledge that can be difficult to acquire. I guess building up documentation is an interative process.