Unexpected additional behavior when using zip to construct NamedTuple

Hi, I use Julia1.10.4 in Archlinux.
I try to use zip to dynamically create a named tuple, like this

test = (; zip((:a, :b), fill(zeros(3), 2))...)

=> (a=[0.0, 0.0, 0.0], b=[0.0, 0.0, 0.0])

However, very strange, when I make a change to the a filed, the b is also altered unexpectedly!

test.a[1] = 1.0

=> (a=[1.0, 0.0, 0.0], b=[1.0, 0.0, 0.0])

Is this a feature of zip or simply a bug? Found this really confusing.

If I do the named tuple construction manually instead of using zip, I get (a=[1.0, 0.0, 0.0], b=[0.0, 0.0, 0.0]), which is what I am expecting.

The issue is coming from fill which doesn’t instantiate copies, but fills the container with references to the same input object.

julia> x = fill(zeros(3),2)
2-element Vector{Vector{Float64}}:
 [0.0, 0.0, 0.0]
 [0.0, 0.0, 0.0]

julia> x[1] === x[2]

julia> x[1][1] = 1.0

julia> x
2-element Vector{Vector{Float64}}:
 [1.0, 0.0, 0.0]
 [1.0, 0.0, 0.0]

You can get the expected behavior with map

julia> test = (; zip((:a, :b), map(_ -> zeros(3), 1:2))...)
(a = [0.0, 0.0, 0.0], b = [0.0, 0.0, 0.0])

julia> test.a[1] = 1.0

julia> test
(a = [1.0, 0.0, 0.0], b = [0.0, 0.0, 0.0])
1 Like

Hi @mrufsvold, thanks for such quick and detailed response!

I see the point very clearly. Coming from matlab, I find this copy type of programming error really hard to debug. Took me a whole night locating this in my code.

1 Like

Very smart folks thought through these APIs, and I don’t mean to backseat drive them, but I resonate with friction in places like this!

I have a difficult time thinking of a use case where I actually want a vector of copies of the same thing, seems like fill should default to using copy unless told otherwise.

1 Like

Fill works well with immutable elements, i.e., value types, such as numbers: fill(1.23, 4). For mutable stuff, comprehensions are an easy and readable alternative: [zeros(3) for _ in 1:4] (albeit slightly longer).

1 Like

Totally correct and exactly what the docstring recommends, but I still don’t agree that this is the right design. The fact that the docs need to have a caveat because this problem comes up so much is evidence to me that handling mutable objects differently would be better.

Fair enough, maybe fill could be extended with methods taking factory functions, e.g., fill(() -> zeros(3), 2), or a wrapper type marking copies, e.g., fill(Copied(zeros(3)), 2)?

1 Like

I love the idea of a Copied type! But certainly an anonymous function would be lower friction.

Although, I guess it is technically breaking since you could right now call fill(f, 3) to get an array of functions.