Different modification behavior when looping over array of variables of different array types

Example code:

function filterarrays!(d)
    for arr in [d["a"], d["b"]]
        deleteat!(arr, 2)
    end
end

d = Dict("a" => [1, 2, 3], "b" => [1., 2., 3.])
display(d)
filterarrays!(d)
display(d)

With output:

Dict{String, Vector} with 2 entries:
  "b" => [1.0, 2.0, 3.0]
  "a" => [1, 2, 3]
Dict{String, Vector} with 2 entries:
  "b" => [1.0, 3.0]
  "a" => [1, 2, 3]

In the case of the b key of dictionary d, the middle array element is removed as expected. For the values of the a key, no elements are removed. I’m not sure if the reason for the difference is related to this topic, but in the example above the two arrays have different types and the output for the Float64 array (b) is as I would expect.

Can anyone explain why the loop behavior is different here?

(Julia Version 1.11.6)

julia> d = Dict("a" => [1, 2, 3], "b" => [1., 2., 3.])
Dict{String, Vector} with 2 entries:
  "b" => [1.0, 2.0, 3.0]
  "a" => [1, 2, 3]

julia> function filterarrays!(d)
           for arr in Any[d["b"], d["a"]]
               deleteat!(arr, 2) |> display
           end
       end
filterarrays! (generic function with 1 method)

julia> display(d)
Dict{String, Vector} with 2 entries:
  "b" => [1.0, 2.0, 3.0]
  "a" => [1, 2, 3]

julia> filterarrays!(d)
2-element Vector{Float64}:
 1.0
 3.0
2-element Vector{Int64}:
 1
 3

julia> display(d)
Dict{String, Vector} with 2 entries:
  "b" => [1.0, 3.0]
  "a" => [1, 3]

you just use “Any” before [ d[“a”] , d[“b”] ]

Thanks, that is producing the output I expected. When not using Any, does Julia change the Int64 to Float64 so they have common type?

julia> d = Dict("a" => [1, 2, 3], "b" => [1., 2., 3.])
Dict{String, Vector} with 2 entries:
  "b" => [1.0, 2.0, 3.0]
  "a" => [1, 2, 3]

julia> result1 = [d["b"], d["a"]]
2-element Vector{Vector{Float64}}:
 [1.0, 2.0, 3.0]
 [1.0, 2.0, 3.0]

julia> result2 = Any[d["b"], d["a"]]
2-element Vector{Any}:
 [1.0, 2.0, 3.0]
 [1, 2, 3]
1 Like

Note that you could alternatively use a Tuple, which also saves you an allocation:

function filterarrays!(d)
    for arr in (d["a"], d["b"])  # <-- tuple here
        deleteat!(arr, 2)
    end
end

d = Dict("a" => [1, 2, 3], "b" => [1., 2., 3.])
filterarrays!(d)
display(d)
# Dict{String, Vector} with 2 entries:
#   "b" => [1.0, 3.0]
#   "a" => [1, 3]

This seems like a bug to me. Is the statement [d["b"], d["a"]] making a copy of d["a"] with elements promoted to Float64 and leaving d["b"] as a reference to to the original array?

Well, it is documented:

Arrays can also be directly constructed with square braces; the syntax [A, B, C, ...] creates a one-dimensional array (i.e., a vector) containing the comma-separated arguments as its elements. The element type (eltype) of the resulting array is automatically determined by the types of the arguments inside the braces. If all the arguments are the same type, then that is its eltype. If they all have a common promotion type then they get converted to that type using convert and that type is the array’s eltype. Otherwise, a heterogeneous array that can hold anything — a Vector{Any} — is constructed; this includes the literal [] where no arguments are given. Array literal can be typed with the syntax T[A, B, C, ...] where T is a type.

(emphasis mine),

help?> convert
search: convert Base.cconvert const collect

  convert(T, x)

(...)

If T is a collection type and x a collection, the result of convert(T, x) may alias all or part of x.

  julia> x = Int[1, 2, 3];

  julia> y = convert(Vector{Int}, x);

  julia> y === x
  true

I think the conversion to Float64 is okay (and documented like you mention), but it does not seem correct that a copy is made for d["a"] with eltype Float64 while a reference is maintained to the d["b"]. Maybe it is correct behaviour.

I agree it can lead to unintuitive behaviour as in this thread, but it is the logical consequence of the requirements that

  1. You want to promote to a common type (ultimately via convert) in array literals
  2. You want convert(typeof(x), x) === x.

You might disagree with these requirements (though 2 seems pretty essential), but they are certainly intentional design choices. So yeah, I’d say this is correct behaviour.