I want to replicate with Named Tuples what I do with Dictionaries but I can't

I wrote some code that fills an array of dictionaries step by step.
I did not use Int64 for the values because in “real life” I do not know the types of the values in advance.

my_dicts = [Dict{String,Any}() for _ in 1:3]

for i in 1:length(my_dicts)
    my_dicts[i] = Dict("A"=> i, "B" => i + 1)
    println(my_dicts[i])
end

The code above works fine.

I wanted to do something similar with named tuples. So I tried this:

my_tups = [NamedTuple{(:A, :B), Tuple{Int64, Int64}}() for _ in 1:3]

for i in 1:length(my_tups)
    my_tups[i] = (A = i, B = i + 1)
    println(my_tups[i])
end

This did not work even though I specified the type of the second element of each pair. I got the following message in the REPL:

julia> my_tups = [NamedTuple{(:A, :B), Tuple{Int64, Int64}}() for _ in 1:3]
ERROR: MethodError: no method matching NamedTuple{(:A, :B), Tuple{Int64, Int64}}()

Any hints on how to prepare an array to receive the named tuples generated in the for loop?

Thanks.

Try

Vector{NamedTuple{(:A, :B), Tuple{Int64, Int64}}}(undef, 3)

Your version tries to create instances of the named tuples without any data, which does not appear to be supported.

1 Like

Easier syntax: Vector{@NamedTuple{A::Int,B::Int}}(undef, 3)

2 Likes

Dictionaries are mutable (alterable) by nature and by design.

mydict = Dict{String,Int}()
isempty(mydict) === true
mydict["A"] = 1
isempty(mydict) === false
length(mydict) === 1
mydict["B"] = 2
length(mydict) === 2
mydict["B"] === 2
mydict["B"] = 22
length(mydict) === 2
mydict["B"] === 22

NamedTuples are static (immutable, fixed) by nature and by design. NamedTuples really are Tuples with Names. The Names are given to each of the Tuple’s places, in the sequence that the Tuple’s values appear.

You can create an empty NamedTuple, however once created it cannot be modified (you cannot add a new field). If you know the field names that your NamedTuples will use, then you can establish that pattern of field names as a “prototype” of a NamedTuple [here, prototype is a suggestive term that really refers to an incompletely specified Type of a NamedTuple].

fieldnames = (:A, :B, :C) # they must be `Symbols`
proto_nt = NamedTuple{fieldnames}

# get the field's values until you have all of them
first_fieldvalues = [1, 2, 3];  # a Vector of values
# or, if you have all the field's values at once
second_fieldvalues = (11, 22, 33);   # a value Tuple

my_first_nt = proto_nt(first_fieldvalues)
my_second_nt = proto_nt(second_fieldvalues)
julia> my_first_nt = proto_nt(first_fieldvalues)
(A = 1, B = 2, C = 3)

julia> my_second_nt = proto_nt(second_fieldvalues)
(A = 11, B = 22, C = 33)

`I can get you close to what you intended.
We use the fact that ordinary Vectors
are mutable, one may overwrite the value
currently held any of the vector’s indices.

So, the first insight is to use a Vector
to carry the (initially) empty NamedTuples.
Let’s try.
[ (;) is how to write an empty NamedTuple ]

{copy and paste the examples into the REPL}

n_empty_namedtuples(n) = [(;) for i in 1:n]

two_empty_namedtuples = n_empty_namedtuples(2)

the second line gives the result

2-element Vector{NamedTuple{(), Tuple{}}}:
 NamedTuple()
 NamedTuple()

That means two_empty_namedtuples is a
vector of 2 elements, and these elements
are each typed NamedTuple{(), Tuple{}}.

… aside, a look at how the type is given

[while that type looks unusual, it is
just a parameterized type with two
parameters: the first parameter is
a Tuple (here, it is empty) and the
second parameter is a Tuple Type,
(here it is a Tuple Type with no elements).
see the docs about Parametric types.

Let’s look at a NamedTuple that has content:

  nt = (A = 1, B = 2)
  typeof_nt = typeof(nt);

  fieldnames(typeof_nt) == (:A, :B)
  fieldtypes(typeof_nt) == (Int64, Int64)
  
   # the typeof() a NamedTuple is like this
   #
   # NamedTuple{ (fieldnames as symbols), 
   #              Tuple{ fieldtypes }    }
   #
   typeof_nt == 
     NamedTuple{ (:A, :B), 
                  Tuple{Int64, Int64} }

We created two_empty_namedtuples.

Next, you want to replace the empty
NamedTuples with new NamedTuples.
[all sharing the same fieldnames and
fieldtypes … differing in their values

  • all are of the same length,
  • all have the same keys in the same order,
  • each may assign its own values to the keys]

We can try, but this

new_namedtuple = (A = 1, B = 2)
two_empty_namedtuples[1] = new_namedtuple

generates a MethodError

 (1)  "Cannot `convert` an object of type
 (2)   NamedTuple{(:A, :B),Tuple{Int64,Int64}}
 (3)   to an object of type
 (4)   NamedTuple{(),Tuple{}}"

The typeof(new_namedtuple) is
NamedTuple{(:A, :B),Tuple{Int64,Int64}}
and this is written on line (2).
The typeof( (;) ) is
NamedTuple{(), Tuple{}}
and this is written on line (4)

The MethodError says when we tried to
overwrite the empty NamedTuple with
the new_namedtuple, Julia looked at
each value’s type and found they were
incompatable. To use a new NamedTuple
to overwrite one already present within
a Vector, the two NamedTuples must be
of matching type.

For two NamedTuple types to match, both must
have the same length, the same fieldnames,
the same ordering of the fieldnames, and
the same fieldtypes with the same ordering.
Their values may differ, as long as the
value’s types are the same.

Well, the empty NamedTuples certainly
do not have the same lenghts as the
new NamedTuples that are to replace them.

Fortunately, NamedTuple types without
any fieldnames or fieldtypes specified
is a special flavor in the land of types:

This is different from empty NamedTuple
types, we have not constrained the
number of fields that there may be,
only insisted that information is not
present when given the “bare” type NamedTuple (the type absent parameters,
the name for this kind of type is UnionAll
because it represents the union of all
possible legal parameterizations).

That allows us to do this

n_empty_namedtuples(n) =
  NamedTuple[(;) for i in 1:n]

two_namedtuples = n_empty_namedtuples(2)

new_namedtuple = (A = 1, B = 2)
two_namedtuples[1] = new_namedtuple

two_namedtuples[1] == new_namedtuple
two_namedtuples[2] == (;)

How is the MethodError avoided?
Look at what we did:

errorful   = [(;), (;)];
successful = NamedTuple[(;), (;)];

eltype(errorful)   == NamedTuple{(), Tuple{}
eltype(successful) == NamedTuple

The errorful version has an eltype that
is a NamedTuple type with parameters given
[ the parameters are () and Tuple{} ].

The successful version has an eltype that
is a UnionAll NamedTuple type (no parameters).

As a UnionAll type is implicitly gathering
all possible legitimate parameterizations
(the way each parameter is given, is set),
the specific set of parameter values that
happens to belong to typeof(new_namedtuple)
is among the “all possible”, so assignment
(overwriting) works.

n_empty_namedtuples(n) =
  NamedTuple[(;) for i in 1:n]

my_tups = n_empty_namedtuples(3)

ok = all( isempty.(my_tups) ) &&
     length(my_tups) == howmany

for i in 1:length(my_tups)
    my_tups[i] = (A = i, B = i+1)
    println(my_tups[i])
end
# (A = 1, B = 2)
# (A = 2, B = 3)
# (A = 3, B = 4)

As it stands, each element of the vector my_tups remains open
to being overwritten by any other NamedTuple, compatible or not:

copyof_my_tups = deepcopy(my_tups)

incompat1 = (X = 3, Y = 2, Z = 1)
incompat2= (B = 'b', A = 'a')

copyof_my_tups[1] = incompat1
copyof_my_tups[2] = incompat2
copyof_my_tups
# 3-element Vector{NamedTuple}:
#   (X = 3, Y = 2, Z = 1)
#   (B = 'b', A = 'a')
#   (A = 3, B = 4)

Here is a way to obtain a new vector not allowing incompatible overwrites,

typeconstraint = typeof(my_tups[1])
# NamedTuple{(:A, :B), Tuple{Int64, Int64}}

welldressed = typeconstraint[ my_tups... ]
# 3-element Vector...
#   (A = 1, B = 2)
#   (A = 2, B = 3)
#   (A = 3, B = 4)

welldressed[1] = incompat1
# MethodError: Cannot `convert` an object of type ...
3 Likes

That was exactly what I needed. Thanks. Following your suggestion I was able to replace a dictionary with a named tuple, hoping my program was going to run faster because dictionaries are mutable and named tuples are immutable. But I did not notice a difference in speed. The dictionary was the return value of a function that is called many times.

Thanks for the extensive lesson. I read the Types section of the documentation and did not “get” all of it. Reading it again is a priority. After doing it I will come back to read again your post.

In practice my Julia project is moving ahead fine, so not understanding all the complexities of types has not been a big obstacle, thanks to the help I keep getting here in Discourse.

1 Like

Thanks. I learned quite a lot.

It is still not completely clear to me why writing NamedTuple before the square brackets solves the problem. Is this just an arbitrary convention for this particular case or is it an application of a general rule?

On a side note, I also did not understand this: all( isempty(my_tups) ). I believe the return value of isempty(my_tups) will have length 1, so the all is not needed. The ok variable turns out to be false. Doing it this way all(map(isempty, my_tups)) I got it to be true.

I had omitted the broadcasting ‘all( isempty.(my_tups))’, that is corrected. Your approach works well, too. I use both, depending on the emphasis.

It is still not completely clear to me why writing NamedTuple before the square brackets solves the problem. Is this just an arbitrary convention for this particular case or is it an application of a general rule?

It is a more advanced use of the Julia type system. As you may recall,
the generalized type called NamedTuple (without any parameters) is itself a UnionAll type:

julia> nt = (A = 1, B = 2)
(A = 1, B = 2)

julia> typeof(nt)
NamedTuple{(:A, :B), Tuple{Int64, Int64}}

julia> nameof(typeof(nt))
:NamedTuple

julia> typeof(NamedTuple) # or typeof(eval(ans))
UnionAll

UnionAll types are types that implicitly range over all possible specific values of their implicit parameters. So using NamedTuple[ .. ] creates an array of NamedTuples{(<names>), Tuple{<types>}} that conform to the contents of the elements of the vector. For your purposes they should all be the same eltype(vector) .

Think of it as inquiring what kind of NamedTuple the elements’ are, and then placing the (consistently used) names into the first parameter and the (consistently used) types into the second parameter. Since our vector is initialized as a vector of NamedTuple[ .. ] , without any parameters, it will accept all possible NamedTuples – not just all of one given sort (but, really, this is for information only – to use what follows is bad practice in most cases).

julia> two_namedtuples = n_empty_namedtuples(2)
# Vector{NamedTuple}: NamedTuple(), NamedTuple()

julia> two_namedtuples[1] = (A = 1, B = 2);
(A = 1, B = 2)

julia> typeof(anstwo_namedtuples[1])
NamedTuple{(:A, :B), Tuple{Int64, Int64}}

julia> two_namedtuples[2] = 
       (NotA = 'b', NotB = "notb", C = 3);
(NotA = 'b', NotB = "notb", C = 3)

julia> typeof(anstwo_namedtuples[2])
NamedTuple{(:NotA, :NotB, :C), 
           Tuple{Char, String, Int64}}

julia> two_namedtuples
2-element Vector{NamedTuple}:
 (A = 1, B = 2)
 (NotA = 'b', NotB = "notb", C = 3)

julia> typeof(ans)
Vector{NamedTuple} (alias for Array{NamedTuple, 1})