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.