Tcl Part I · Module 7 · Dictionaries and Arrays
Nested Dictionaries
Real configuration is not flat. A run has stages, each stage has settings, and some of those settings have settings of their own. Because a dictionary is a value, a dictionary can hold dictionaries, and Tcl gives you a way to reach straight through the whole structure in one command rather than unpacking a level at a time. This chapter teaches that key path, shows that intermediate levels are created for you, and then spends real time on the rule that catches people out: what you pull out of a nested dictionary is a copy, so changing it changes nothing until you put it back.
Intermediate12 min readTclPart IDictionariesNestingConfiguration
Part I · Module 7 · Chapter 7.6 · Nested Dictionaries
1. A dictionary can hold a dictionary
Nothing special is required. A dictionary value is a value, so it can be another dictionary:
set parse [dict create timeout 60 retries 2]
set cfg [dict create stages [dict create parse $parse]]
puts $cfgstages {parse {timeout 60 retries 2}}Read the braces as structure. stages holds one pair; that pair's value, parse, holds two.
2. The key path
To read something two levels down, you do not need two calls. dict get takes as many keys as you like:
puts [dict get $cfg stages parse timeout]60Each key after the first descends one level. Stopping early gives you the whole sub-dictionary:
puts [dict get $cfg stages parse]
puts [dict get $cfg stages]timeout 60 retries 2
parse {timeout 60 retries 2}The long way works too, and there is no reason to write it:
# avoid — same result, three commands
puts [dict get [dict get [dict get $cfg stages] parse] timeout]603. dict set creates the levels for you
You do not have to build the structure before you fill it:
set cfg [dict create]
dict set cfg stages parse timeout 60
dict set cfg stages parse retries 2
dict set cfg stages build timeout 300
puts $cfgstages {parse {timeout 60 retries 2} build {timeout 300}}cfg did not exist, stages did not exist, parse did not exist. dict set made all of them. The last argument is the value; everything between the variable name and it is a key path.
This goes as deep as you like:
set deep [dict create]
dict set deep a b c d e 5
puts $deep
puts [dict get $deep a b c d e]a {b {c {d {e 5}}}}
54. What you cannot do
You can create a level under a key that does not exist. You cannot create one under a key whose value is an ordinary scalar:
set d [dict create k 1]
dict set d k sub 2missing value to go with keyk holds 1. To put sub inside it, Tcl would have to read 1 as a dictionary, and a single word is an odd number of elements. Replace the value wholesale instead:
set d [dict create k 1]
dict set d k [dict create sub 2]
puts $dk {sub 2}5. Testing and removing, at depth
dict exists takes a path too, and it is the safe way to probe:
set cfg [dict create stages [dict create parse [dict create timeout 60]]]
puts [dict exists $cfg stages parse timeout]
puts [dict exists $cfg stages nope timeout]1
0The second returns 0 rather than erroring, because stages really is a dictionary — nope is simply not one of its keys. (Chapter 7.3 §6 covers the case where it does error.)
dict unset also takes a path, and removes only the leaf:
set g [dict create s [dict create p 1 q 2]]
dict unset g s p
puts $gs {q 2}Removing a key that is not there is silently fine:
set h [dict create a 1]
dict unset h zz
puts $ha 1And a deep dict get through a missing key is an error that names the key:
set cfg [dict create stages [dict create parse [dict create timeout 60]]]
puts [dict get $cfg stages nope timeout]key "nope" not known in dictionary6. Iterating a nested structure
dict for gives you each sub-dictionary as the value, and you can loop again inside:
set cfg [dict create \
parse [dict create timeout 60 retries 2] \
build [dict create timeout 300 retries 0]]
dict for {stage settings} $cfg {
puts "$stage:"
dict for {k v} $settings {
puts [format " %-8s %s" $k $v]
}
}parse:
timeout 60
retries 2
build:
timeout 300
retries 0The backslashes continue the command across lines — Chapter 2.10.
7. A sub-dictionary is a copy
Here is the rule that decides whether your updates land. Everything in Tcl is a value, and values are copied, so what dict get hands you is not a window into the parent:
set cfg [dict create stages [dict create parse [dict create timeout 60]]]
set p [dict get $cfg stages parse]
dict set p timeout 999
puts "my copy : $p"
puts "original: [dict get $cfg stages parse]"my copy : timeout 999
original: timeout 60The original is untouched. This is exactly the behaviour Chapter 6.6 established for lists, applied one level down, and it is not a flaw — it is what makes dictionaries safe to pass around.
Two correct ways to actually change the nested value. The direct one:
dict set cfg stages parse timeout 999
puts [dict get $cfg stages parse timeout]999Or pull out, modify, and put back:
set p [dict get $cfg stages parse]
dict set p retries 5
dict set cfg stages parse $p
puts [dict get $cfg stages parse]timeout 999 retries 58. A worked example
A nested configuration with defaults applied at depth, then a report:
proc apply_default {cfgName stage key value} {
upvar 1 $cfgName cfg
if {![dict exists $cfg $stage $key]} {
dict set cfg $stage $key $value
}
}
set cfg [dict create]
dict set cfg parse timeout 60
dict set cfg build timeout 300
foreach stage [dict keys $cfg] {
apply_default cfg $stage retries 1
apply_default cfg $stage verbose 0
}
dict for {stage settings} $cfg {
puts "$stage ([dict size $settings] settings)"
foreach k [lsort [dict keys $settings]] {
puts [format " %-8s %s" $k [dict get $settings $k]]
}
}parse (3 settings)
retries 1
timeout 60
verbose 0
build (3 settings)
retries 1
timeout 300
verbose 0Note the upvar in apply_default: the procedure has to modify the caller's dictionary, so it takes the name. A procedure that only reads would take $cfg. That is the same distinction as Chapter 7.1, now showing up in your own code.
9. Exercises
Exercise 1. What does this print?
set d [dict create]
dict set d a b c 1
puts $dExercise 2. Given the cfg from section 6, write one line that prints the build stage's timeout.
Exercise 3. Why does dict set d k sub 2 fail when d is k 1, but succeed when d is empty?
Exercise 4. Predict the output and explain it:
set c [dict create s [dict create t 1]]
set inner [dict get $c s]
dict set inner t 99
puts [dict get $c s t]Exercise 5. Write deep_get — a procedure taking a dictionary, a list of keys, and a default, returning the value at that path or the default if any step is missing. It must not raise an error.
Exercise 6. Build a two-level dictionary of three modules, each with width and depth, then print a table with a row per module.
Answers
1. a {b {c 1}}. Both intermediate levels were created automatically.
2.
puts [dict get $cfg build timeout]3003. When d is empty there is nothing in the way — dict set creates k as a dictionary. When k already holds 1, Tcl must read that value as a dictionary to put sub inside it, and a single word is an odd-length dictionary.
4. It prints 1. dict get returned a copy, so dict set inner t 99 changed only the copy. The original is untouched.
5.
proc deep_get {d path {default ""}} {
foreach k $path {
if {[catch {dict exists $d $k} ok] || !$ok} { return $default }
set d [dict get $d $k]
}
return $d
}
set c [dict create s [dict create t 1]]
puts [deep_get $c {s t} none]
puts [deep_get $c {s zz} none]
puts [deep_get $c {zz t} none]1
none
noneThe catch around dict exists matters: one step at a time means the current d may be a scalar, and dict exists errors on that rather than returning 0 — exactly the case from Chapter 7.3 §6.
6.
set mods [dict create]
dict set mods fifo width 8
dict set mods fifo depth 16
dict set mods cache width 32
dict set mods cache depth 256
dict set mods regs width 16
dict set mods regs depth 4
puts [format "%-8s %6s %6s" MODULE WIDTH DEPTH]
dict for {name m} $mods {
puts [format "%-8s %6d %6d" $name [dict get $m width] [dict get $m depth]]
}MODULE WIDTH DEPTH
fifo 8 16
cache 32 256
regs 16 410. Remember
- A dictionary can hold dictionaries, because a dictionary is a value.
dict get $d k1 k2 k3descends a key path in one call. Stopping early returns the sub-dictionary.dict set name k1 k2 key valuecreates every intermediate level — which also means a typo silently builds a new branch.- You cannot graft a level onto a scalar value —
missing value to go with key. Replace the value with a dictionary instead. dict existsanddict unsettake paths too.dict unsetremoves only the leaf, and is silent about keys that were never there.- A deep
dict getthrough a missing key names it:key "nope" not known in dictionary. - What
dict getreturns is a copy. Modifying a retrieved sub-dictionary does not touch the parent — use the key path, or put it back explicitly. - A procedure that modifies a dictionary needs the name and
upvar; one that only reads takes the value.
Where this fits
Part of the Tcl curriculum.
