Here's a fun thing

Here is a Lisp-y or Scheme-y, if you prefer, way to deal with deeply nested dicts in Julia. Don’t ask why, but I have an application with deeply nested data structures. I serialize them to and from YAML, which isn’t a markup language I’m told. I like to use symbols as keys in Julia, but YAML doesn’t like map keys that begin with colons (or anything else for that matter).

The YAML package in Julia makes it easy to set the key type on loading, but struggles with writing deeply nested structures, especially when the value is a struct. So, I found myself having to convert the key type of dicts back and forth between strings and symbols for serializing/de-serializing. I was going slightly crazy writing loops for the various data structures, trying to use semantic naming for the keys and values instead of k,v. And when I changed something I had to delve into my mess. Each data structure had it’s own load and write function, which was a lot of messy case-specific code that was horrible to maintain.

Well, this summer I went through much of the Paul Graham ANSI Common Lisp book and Brian Harvey’s Simply Scheme book to get my head around the languages and decide if I was a CL or a Scheme guy. I’m neither. There are good things to learn from either and performance is surprisingly good, especially compared to Python. But, neither can touch Julia for numerical work.

That exercise made me realize that I could solve my nested data structure problem without worrying about the exact structure of my messes: it doesn’t matter what the semantics of the keys are; it only matters if its value is another dict. And it doesn’t matter how deep or ragged the mess is. (I still have to deal with the structs and values that are “flow” vectors… …but this doesn’t matter for switching keys between strings and symbols.)

So, here is the code:

function dict_key_to_symbol(d)
    Dict(Symbol(k)=>
            (!(typeof(v) <: AbstractDict) ? v : dict_key_to_symbol(v))
        for (k,v) in d)
end

function dict_key_to_symbol_v2(d)
    Dict(Symbol(k) =>
        if !(typeof(v) <: AbstractDict)
            v 
        else 
            dict_key_to_symbol_v2(v)
        end
        for (k, v) in d)
end

function dict_key_to_string_v2(d)
    Dict(string(k) =>
        if !(typeof(v) <: AbstractDict)
            v
        else
            dict_key_to_string_v2(v)
        end
        for (k, v) in d)
end

I don’t think we have car, cdr to walk through each dict’s keys so we need some kind of looping rather than 2 levels of recursive calls (1 to go ‘across’ the keys at the top level of each dict; another to ‘go down’ the nested dicts). The dict comprehension version with the ternary operator is great for people who like short code: you can make it one line if you like. I think the if-assignment version is much easier to understand.

Here is a test case:

# a somewhat deeply nested, ragged dict
d3lvl = Dict("l1_a"=>
                    Dict("l2_a"=>
                        Dict("l3_a"=>5)), 
             "l1_b"=>4.0,
             "l1_c"=>
                    Dict("l2_a"=>
                        Dict("l3_a"=>5.0, "l3_b"=>7.0)))

Here is running it and round-tripping:

julia> dict_key_to_symbol_v2(d3lvl)
Dict{Symbol, Any} with 3 entries:
  :l1_b => 4.0
  :l1_a => Dict(:l2_a=>Dict(:l3_a=>5))
  :l1_c => Dict(:l2_a=>Dict(:l3_b=>7.0, :l3_a=>5.0))

julia> dict_key_to_string_v2(dict_key_to_symbol_v2(d3lvl))
Dict{String, Any} with 3 entries:
  "l1_a" => Dict("l2_a"=>Dict("l3_a"=>5))
  "l1_c" => Dict("l2_a"=>Dict("l3_a"=>5.0, "l3_b"=>7.0))
  "l1_b" => 4.0

This was fun and useful.

Anyone want to try replacing the loop across keys with recursion? I thought about and didn’t really try. Somehow we need an iterator that provides next key; doesn’t blow up at the end; instead returning something like the null that Lisp or Scheme would return. Could use get(dict, ???, nothing) and test for nothing–how would you do “get-next”?

2 Likes

Looping is fine in Julia, so no need to replace it by recursion. One could use broadcasting and dispatch (hiding the if) though:

updatekeys(f, v) = v  # Base case, i.e., leave atomic `v` alone
updatekeys(f, v::AbstractDict) = Dict(zip(f.(keys(v)),
                                          updatekeys.(Ref(f), values(v))))
# Works just like your dict_key_to_symbol_v2
updatekeys(Symbol, d3lvl)

Of course looping is fine and very likely to be more efficient and require less memory than recursion. For all the good of lisps, viewing the world through the lens of forward linked lists when there are far better data structures for many (though not all) tasks is a serious limitation. This forces recursion into a (do-something (car input)) (recurse (cdr input)) structure with a test for null at the top.

It’s just an exercise to try this with “pure” recursion. We need to go across the keys of each dict at any level and go down into nested dicts. This is one of the trickier things for recursion.

Ah, ok. You can certainly try to use recursion, but it goes a bit against the nature of the language as well as the dictionary data structure. In contrast to linked lists, dictionaries are rather imperative and not defined recursively.
For sequential data, the closest match is probably the iterator protocol which you can consume recursively:

function myforeach(f, itr)
    step = iterate(itr)
    if isnothing(step)
        nothing  # done
    else
        f(step[1])
        myforeach(f, Base.rest(itr, step[2]))
    end
end

Again, there is no efficient way of rebuilding the data structure on the way up as vectors are not recursively defined as well and don’t have an analog of cons (push! does allow to add elements at the end though).

Well, you are right of course, but it is possible and ugly.

function more_pure_to_symbol(orig_d, new_d=Dict{Symbol, Any}())
    if isempty(orig_d)
        new_d
    else
        pair = pop!(orig_d)
        k = first(pair)
        v = last(pair)
        new_d[Symbol(k)] =
            if !(typeof(v) <: AbstractDict)
                v
            else
                more_pure_to_symbol(v)  # don't pass new_d; the deeper dict starts a new dict
            end
        more_pure_to_symbol(orig_d, new_d)  # pass the current new_d
    end
end

Not that it matters, but can anyone see a nicer idiomatic Lispy or Scheme-y way to do this? I am far from being even remotely good at either language.

What’s ugly? Here’s ugly:

  1. We can’t car/cdr a dict.
  2. We can’t really get the “next” key because that is not defined.
  3. Unlike good religiously correct Scheme, we use the permuting function pop! to get the “car” of the dict, which pop! returns, and the “cdr” of the dict is the permuted value of the input.
  4. Having 2 recursive calls is confusing and is rare in idiomatic Scheme or Lisp.
  5. Unlike the very nice dict comprehension in the idiomatic Julia version, which creates one new dict by looping over the keys/values of the original dict, we need to memo-ize the new dict in the recursive version. We need to incrementally modify the new dict by adding a new key/value pair each time we make another recursive call. The new_d argument starts with a default of an empty dict. In the recursive calls, we pass in the latest resulting value for the new_d. In “nice” recursion, the recursive function would simply return the latest value as input to the next recursive call.
  6. About the only thing that is idiomatically recursive here is that the stopping condition is very obvious. Function isempty() is pretty close to the null predicate functions (nullp in CL; null? in Scheme). But, it feels kind of risky as pop! will cause an error on an empty Dict. It somehow felt safer to use if isnothing(iterate(orig_d)) but both conditions will be met on the same recursive iteration.

Common Lisp, at least as Paul Graham presents it, is a bit less religious about permuting functions and using loops, especially for performance gains. Both Scheme and Lisp seem ok with comprehension or foreach style loops because the end criteria is clear and there is no counter variable with a comparison test to end the loop. At least there is no explicit loop construct so it is “pure” recursion, but as pointed out–it is not at all nice or efficient or reasonable.

But, recursion within the Dict comprehension is quite nice and the whole thing seems like nice clear, idiomatic Julia. For all of the benefits of thinking through how to use Lisp or Scheme (which I forced myself to do), it is sort of sad how conceptually trapped those language communities seem today. Julia has many of the essential benefits without so many limitations.

Done for now! Happy Thanksgiving!

Yours is pretty cool if the container type provides well behaved iterator. But, yours doesn’t build the new output container (as you point out), which is the goal of the exercise.

I am very happy with the idiomatic Julian way to do this, which will enable me to replace a bunch of gnarly code that I originally wrote.

I misunderstood @bertschi’s good suggestion. I incorrectly thought that step[1] and step[2] dereferenced the pair from the dict. But, these deconstruct the tuple returned by Base.iterate:

  • [1] is the next element of the iterator
  • [2] is the state of the iterator

Before we can deconstruct the tuple, we need to check for nothingness.

This is the key to a much better approach than my attempt in post 5:

  • we don’t mutate the input dict, which is much better because pop! empties it completely
  • we get car and cdr for the dict:
    car: the next element of the iterator
    cdr: Base.rest(orig_d, step[2]) => the rest of the elements
function change_key_type(orig_d; T, f, new_d=Dict{T, Any}()) 
    step = iterate(orig_d)  # step is (next_element, iterator_state)
    if isnothing(step)
        new_d
    else
        k = step[1][1]   # step[1] will be a pair for the first element of dict orig_d
        v = step[1][2]   # state of the iterator
        new_d[f(k)] =      
            if typeof(v) <: AbstractDict
                change_key_type(v, T=T, f=f)
            else
                v
            end
        change_key_type(Base.rest(orig_d, step[2]), T=T, f=f, new_d=new_d)
    end
end 

This version, then, is as close to a Lisp-y pure recursive approach. But, it is still complicated because we need the type T and function f arguments in the recursive function calls and new_d in the outer call. The idiomatic Julian way using a dict comprehension is just much nicer.

I didn’t test the performance of any of these because for my use it won’t matter. These are used during the setup phase before running a simulation model, so only run once. I got recursion clearer in my head. I learned more about the power in the iterator interfaces with Bertschi’s input.

I think this could be much simpler. Let’s start with a flat Dict.

julia> d = Dict{String, Any}(["hello" => 5, "bye" => 3])      Dict{String, Any} with 2 entries:
  "bye"   => 3
  "hello" => 5

If I wanted to convert the keys to a symbol, I would do the following.

julia> Dict(Symbol.(keys(d)) .=> values(d))
Dict{Symbol, Int64} with 2 entries:                             
:hello => 5                                                   
:bye   => 3

We can use broadcasting as well using the keys and values iterators to separate the problem.

julia> change_dict_type(notdict, args...) = notdict
change_dict_type (generic function with 2 methods)

julia> function change_dict_type(d::AbstractDict, ::Type{T}) where T
           Dict{T, Any}(
               T.(keys(d)) .=>
               change_dict_type.(values(d),Ref(T))
           )
       end
change_dict_type (generic function with 2 methods)

julia> change_dict_type(d3lvl, Symbol)
Dict{Symbol, Any} with 3 entries:
  :l1_b => 4.0
  :l1_a => Dict{Symbol, Any}(:l2_a=>Dict{Symbol, Any}(:l3_a=>5))
  :l1_c => Dict{Symbol, Any}(:l2_a=>Dict{Symbol, Any}(:l3_b=>7.0, :l3…

julia> change_dict_type(Dict(), Symbol)
Dict{Symbol, Any}()

No manual iteration, if statements, or indexing required. Maybe I missed some nuance above.

1 Like

You didn’t miss anything; you are just better and way more sophisticated.

I never understand the ::Type{T}) where T thing. It is very obscure. There is no argument name here. But, it is T. Can you explain why? (We don’t need the 2nd argument–we only need a variable for the intended type. But, why not T::Datatype?)

Then, you use Ref(T). Are you creating a Ref to T? This is another obscure point. Why is the Ref needed here?

I think I get the recursive argument. Calling with Values(d) will call repeatedly each value of the top level because of the dot for broadcasting. And this will cascade down by recursive calls until there aren’t any values left.

I guess I understand the other method with argument notdict. I think it simply says if the input argument is any type other than an AbstractDict (dispatched to the 2nd method), just return the input. So this method is essential to sort of filter out pair values that aren’t dicts, e.g.–anything else: scalars of any type, other types of containers, etc. Is this right? This method has to be here to prevent errors on non-dict values. It’s also succinct.

So, it’s brilliant. But very non-obvious; sort of obscure though the pieces are all straightforward Julia usage. This kind of coding always impresses, but it is a Julian style that is very off-putting.

What is wrong with obvious code that easily shows its intent? Forgive me–I hope I have convinced you that I acknowledge how smart and concise yours is–but I find this kind of stuff from Julia to be very off-putting, to the point of being insulting. It is as if Julia cognoscenti treat obviousness as a sign of stupidity, from people–like me–who should be disqualified from using Julia because they/we are simply too stupid. Obvious is all we pure sods can figure out.

I agree that the lisp-like recursive approach is incredibly clumsy, but it was obvious I tried it only as an exercise. Is there something particularly bad about the dict comprehension version that I prefer? No indexing. A pretty simple loop. It’s concise and simple, no? What’s wrong with the if statement? It makes the choice very clear. It won’t adversely impact performance. It leaves not dict values unchanged without requiring a distinct method to do so. It’s part of the one and only method so it’s clear what is being done in that one function. Without seeing and understanding the intent behind that notdict method, one could be mislead to suspect that the “business” method that performs the conversion might not work.

Again, I have to acknowledge that you are really are much better at this than I am. But, is there no virtue to obviousness? Is it always correct to assume the preferred function to convert to a type is the constructor? What about string vs String? How about when some explicit function might be needed (granted that would be for more obscure conversions than I intended)?

Nothing personal here. More about how do we make Julia accessible. Shouldn’t we prefer obviousness–unless some seemingly simplistic code is actually cumbersome, slow or error prone (in other words wrong or insufficiently general–which can certainly happen)? It seems we should not want Julia to be viewed as a language full of tricks that trip the unwary because Julia has so many subtle pitfalls that deviously clever techniques are essential for avoiding the pitfalls. Julia already has a bit of that reputation, though that’s entirely unfair to a language that is very clear and capable.

It’s an Occam’s razor thing: obvious over tricky; the shortest code might not always be the best code.

First of all, there is nothing wrong with any of the code here. Julia provides a number of ways to do the same thing. Whatever works is fine. I appreciate that Julia permits a number of approaches to a problem.

You asked for Lisp-y code. I very much consider Julia to be in the Lisp tradition. Reading through Jeff Bezanson’s dissertation will make it clear that Lisp and Scheme were sources of inspiration for Julia. Even Gerald Sussman, one of the co-inventors of Scheme as an author of “Structure and Interpretation of Computer Programs”, was on the dissertation committee. If you are still not convinced, try this Easter egg: Try this Easter egg: run julia --lisp.

Thinking of Julia in the Lisp tradition, I presented code that takes advantage of Julia’s distinctive features. In particular, multiple dispatch and broadcasting. Multiple dispatch has strong roots in Lisp, and I think it could be used here to simplify the code by eliminating explicit branches. I also very much like how multiple dispatch let’s you extend upon a basic implementation with specialized and perhaps optimized code. Broadcasting is a feature that reminds me of map in Scheme.

I will try to address your questions individually.

There are several issues with T::DataType. One is that a type such as Dict without its parameters is not a DataType! It’s actually a UnionAll. Dict is equivalent to Dict{K,V} where {K,V}, which is a union of several types. If we want to match both Dict, Dict{Symbol}, and Dict{Symbol, Any} we would need to use their common ancestor, Type. In this specific case, we only practically wanted to match Symbol, so DataType would have worked. Another important consequence of matching Type{T} is that the Julia function can specialize for T. That would not be the case for DataType since we cannot specialize on a value. Therefore, we tend to use the idiom ::Type{T} as this works for the general case of types and allows for specialization.

julia> g(T::DataType) = 1
g (generic function with 1 method)

julia> g(Dict)
ERROR: MethodError: no method matching g(::Type{Dict})

julia> typeof(Dict)
UnionAll

julia> Dict == Dict{K,V} where {K,V}
true

julia> typeof(Dict{Symbol})
UnionAll

julia> typeof(Dict{Symbol,Any})
DataType

julia> typejoin(DataType, UnionAll)
Type

I used Ref because I thought I might need for broadcasting. It turns out I do not need it since T is considered a scalar here.

julia> function change_dict_type(d::AbstractDict, ::Type{T}) where T
           Dict{T, Any}(
               T.(keys(d)) .=>
               change_dict_type.(values(d), T)
           )
       end

As I started with, there is nothing wrong with your code. My primary critique of your code is that it relies on knowledge of Julia’s iteration protocol. I consider the iteration protocol to be an implementation detail. Using it here seems cut through several more layers of abstraction than necessary. In that sense, I actually the implementation to be not obvious. I need to know that nothing is the stop condition, that iterate returns a Tuple with the second value being the state, etc. I then also need to know that iterating over a Dict means the elements will be a Pair. In comparison, in my code I’m still thinking of keys and values.

Obviousness is subjective. My code is pretty obvious to me. Your code is less obvious to me for reasons I discussed above. I value abstraction barriers to be more valuable in this, but that again is subjective.

convert is a challenging topic. In this case, convert(Symbol, str::String) does not work here. The important thing to realize about convert is that it is for implicit conversion. convert is called autmatically in a few circumstances such as assignment to a struct field or as an element of an array. There are some very good arguments about why we would not want to implicitly convert between a Symbol and String that is beyond the scope that I have time to discuss here.

String with a capital S refers specifically to the String type and its constructor. string is a more generic method. There is less implciation that string has to return a String. It could return some other AbstractString for example.

In summary, I do not find your code obvious or Lisp-y. I prefer to use higher level abstractions such as broadcasting over lower level interfaces such as the iteration protocol. I prefer to think of dictionaries as keys and values. I prefer to use multiple dispatch over if typeof(v) <: AbstractDict. I acknowledge this is somewhat subjective. Reading through the thread more carefully, I realized that @bertschi basically came up with a similar solution to mine a day prior, so I apologize if the conversation was circular.

I do think your code might be more readable to a non-Julia programmer. There is some virtue to that. Perhaps this is what you mean by obvious. If I wrote this in Java, I think my code would be more similar to yours. However, I wrote this in Julia, so I used my favorite features of Julia.

I’ll leave you with one additional implementation that I thought of while responding your questions. I could have used Iterators.map making this almost exactly how I would have implemented this in Scheme.

julia> change_dict_type(d::AbstractDict, ::Type{T}) where T =
           Iterators.map(d) do (key, value)
               T(key) => change_dict_type(value, T)
           end |> Dict{T,Any}
change_dict_type (generic function with 2 methods)
1 Like

That is so cool!!!

I think you are comparing the very lisp-like no loop recursive code. As I pointed out, that is very cumbersome code. The better comparison is to the dict comprehension version.

While I did say performance doesn’t matter, sometimes it is a figure of technical merit. Your version forces Julia into dynamic dispatch. Julia can’t know what type of value will occur for an instance of a pair (from the input dict) and can’t do compile-time method binding to the 2 methods. So, there is dynamic dispatch.

The dict comprehension version runs in just a hair more than 1/4 the time, with about 3/4 as many allocations.

A very cool thing would be using broadcasting without dynamic dispatch. Bound to be possible somehow. Give it a shot.

Julia indeed benefits from some of the coolest things from Scheme:

  • symbols
  • extraordinary macros
  • first class functions
  • higher order functions
  • lots of introspection
  • an amazing REPL
  • homoiconic: the code is data

And Julia goes well beyond Scheme as a much newer language:

  • a vastly better incredibly useful REPL
  • parens after the function name
  • operator and function overloading (amazing that no Scheme or Lisp has this)
  • thus, richer method dispatch
  • not designed around forward linked lists as the foundational data structure
  • pass by value for scalars and pass by reference for containers
  • amazingly wonderful documentation
  • but wait, there’s more!..

This is from memory: I’ll check out that Easter Egg–undoubtedly more gemlike eggs there.

Dynamic dispatch is equivalent to your if statements or ternary operator.

               if !(typeof(v) <: AbstractDict)
                   v
               else
                   dict_key_to_string_v2(v)
               end

If we just directly replaced the if statements with multiple dispatch.

julia> dict_key_to_symbol_v3(d::AbstractDict) =
           Dict(Symbol(k) => dict_key_to_symbol_v3(v) for (k,v) in d)
dict_key_to_symbol_v3 (generic function with 2 methods)

julia> @inline dict_key_to_symbol_v3(v) = v
dict_key_to_symbol_v3 (generic function with 2 methods)

My benchmarks show that the version with “dynamic multiple dispatch” is about the same or perhaps even a bit faster.

julia> using BenchmarkTools

julia> @benchmark dict_key_to_symbol_v2($d3lvl)
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
 Range (min … max):  17.113 μs …  5.258 ms  ┊ GC (min … max): 0.00% … 97.72%
 Time  (median):     21.358 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   21.507 μs ± 52.460 μs  ┊ GC (mean ± σ):  2.39% ±  0.98%

     ▅▇▆▄▂         ▁▃██▆▃                                      
  ▁▃██████▇▆▄▂▂▁▁▂▄██████▇▄▃▃▃▃▃▃▃▂▂▂▂▂▁▁▂▁▂▁▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃
  17.1 μs         Histogram: frequency by time        30.8 μs <

 Memory estimate: 5.78 KiB, allocs estimate: 64.

julia> @benchmark dict_key_to_symbol_v3($d3lvl)
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
 Range (min … max):  15.893 μs …  5.129 ms  ┊ GC (min … max): 0.00% … 97.61%
 Time  (median):     20.041 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   20.345 μs ± 51.180 μs  ┊ GC (mean ± σ):  2.46% ±  0.98%

     ▃▇▆▃▁        ▄▇█▄▁                                        
  ▁▂▅█████▆▃▂▂▁▂▃▇█████▅▄▃▃▃▃▃▃▃▂▂▂▂▂▂▁▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃
  15.9 μs         Histogram: frequency by time        30.5 μs <

 Memory estimate: 5.75 KiB, allocs estimate: 63.

You’re correct that my first change_dict_type does allocate more. It actualizes some intermediate Vectors.

Just a few remarks and additions to your list:

  • a vastly better incredibly useful REPL
    at least Lisp machines and Common Lisp tended to have very good REPLs (Slime probably comes closest nowadays).

  • operator and function overloading (amazing that no Scheme or Lisp has this)
    CLOS (Common Lisp Object System) actually has generic functions and multiple dispatch. It’s an add-on though and not as deeply integrated into the language (see below)

  • pass by value for scalars and pass by reference for containers
    as far as I know, every Lisp/Scheme does that

With all others, I agree, but would like to add

  • parametric types

  • compilation at runtime, but later than all previous languages – including Lisp/Scheme

To me, both are the real game changer in making generic functions fast, i.e., you don’t have to choose between generic (slow) and regular (fast) functions as in Common Lisp. Thereby, setting free Julia’s “unreasonable effectiveness” …

Dict comprehension split into 2 methods is good. Important to inline the non-dict method.

Dynamic dispatch won’t always be faster than the if. There has been a lot of discussion of this over the years. Inlining helps with avoiding the function call overhead. It may depend on how many different types are expected to be encountered. This problem is very simple as my usage will always entail converting exactly 1 type to 1 other. So, an if in source is not much different than an if the compiler puts into the compiled code to dispatch to already jit-compiled methods as you point out: the branch has to be somewhere.

My timings were very different than yours because I was comparing my dict comprehension function using if to your recursive example. Your timings for dict comprehension with if to dynamic dispatch is more fair “apples to apples”.

But, to be honest after looking at these approaches if typeof() is a slow-ish test because of the function call. Also, not a generally recommended approach, though in this example it’s pretty harmless. One reason that might justify an if test–maybe not this one–is if there were other conversions to be done–say, converting the values also (which happens when de-serializing YAML–a whole other kettle of fish).

Your commentary on T was a bit confusing. In this tiny example, T will always apply to the destination type for keys, never to the dict as a whole or even a pair. You are right about using a type argument in the correct way so that it generalizes. I will fix that in my final code.

I am adopting your approach of method dispatch with the dict comprehension with the small change of taking a function argument. I can’t always assume that a type constructor will always be the conversion that is needed.

This is yet another problem where I’d like to suggest a simple optics-based solution (:

julia> using AccessorsExtra

julia> d3lvl
Dict{String, Any} with 3 entries:
  "l1_a" => Dict("l2_a"=>Dict("l3_a"=>5))
  "l1_c" => Dict("l2_a"=>Dict("l3_a"=>5.0, "l3_b"=>7.0))
  "l1_b" => 4.0

julia> @modify(Symbol, d3lvl |> RecursiveOfType(Dict) |> keys(_)[∗])
Dict{Symbol, Any} with 3 entries:
  :l1_b => 4.0
  :l1_a => Dict(:l2_a=>Dict(:l3_a=>5))
  :l1_c => Dict(:l2_a=>Dict(:l3_b=>7.0, :l3_a=>5.0))

After a bit of getting used to the concept and syntax, it’s totally clear what operation is performed.
This is somewhat less performant than manual approaches suggested in this thread, mostly due to type instability. Accessors cares about performance in type-stable operations, often being exactly zero cost – but unstable transformations do have certain overhead.

2 Likes

The whole discussion is interesting, but this point in particular.

On others (the one using Iterators.map or using LISP) I need more time to “study” them.

I had experimented with the use of the multiple dispatching and recursion technique to traverse nested structures on another occasion (where I also learned another important lesson on the use of TYPES [in many cases it is preferable to use DICT instead of NamedTuple to avoid overload of compilation]).
In that case I wasn’t able to take advantage of the abstraction made available by the Accessors package.
I ask @aplavin if AccessorsExtra could be useful for the case covered in that discussion: