Summary
The confusion we observe here is largely due to ambiguous use of the symbol Association
as an expression head. On the one hand, Association
can be used as a constructor function to build an association object. On the other hand, it serves as the symbolic head of a constructed association object. The difference between these two uses is normally hard to spot since the FullForm
of a constructor expression is visually indistinguishable from the synthetic full-form of a constructed association object. The two uses have different semantics, as observed in the question.
Many atomic types of Wolfram Language suffer from this same ambiguity.
Discussion (current as of V12)
Notwithstanding the ideal in Wolfram Language that everything is an expression, the basic head-with-elements composite expression is not always a good representation for some data types. There might be efficiency issues or the representation might include details that are too distracting for the user to see.
The way to deal with such issues is to introduce new optimized types of expression to represent the challenging data types. These optimized types are usually atomic, but some go so far as to fully simulate composite expressions (e.g. packed arrays). These custom objects might be built into the kernel (e.g. associations or images) or they may be implemented in high-level WL code (e.g. datasets). Either way, the internal subparts of these optimized types are generally not observable to the usual part access and pattern-matching facilities in the language. Not observable, that is, unless the developer of the feature provided purpose-built functions to simulate such access.
Associations use of this kind of optimization. The optimized object is a handle to a kernel-provided hash-trie implementation that offers both memory and speed advantages over an equivalent but unoptimized high-level expression. ByteArray
and Image
are just two of many other examples of similarly motivated optimizations.
The constructor expression for an association is composite, but the produced object is atomic:
AtomQ[Unevaluated@<| 1 -> 2 |>]
(* False *)
AtomQ[<| 1 -> 2 |>]
(* True *)
The implementation of association provides a synthetic FullForm
for these atoms:
<| 1 -> 2 |> // FullForm
(* Associaton[Rule[1, 2]] *)
... but the synthetic Part
implementation does not match that synthetic FullForm
:
Part[<| 1 -> 2 |>, 1]
(* 2, but if the full form were true then it should be 1 -> 2 *)
There are good practical reasons for this mismatch, but they can lull one into thinking that an Association
atom is just a normal composite expression.
Associations are not unique with respect to such anomalies. Almost all atomic optimizations of expressions involve mismatches of this kind. What is more, the language does not enforce consistency -- it is up to the developer of each optimization to decide how fully to simulate basic expression behaviour.
Here are some things to watch out for:
- It is not possible to tell if an expression is atomic just by looking at its input form or even its full form.
- It is not possible to tell if an expression is optimized just by looking at its head. Even though some constructor functions return an object with a different head (e.g.
Interpolation
-> InterpolationFunction
), most do not (e.g. Association
-> Association
). The design choice of using the same head for distinct expression types is an interesting one but will not be taken up in detail here.
- A strong clue that an expression has been optimized is that part access or pattern-matching gives surprising results. For associations, the initial implementation did not support pattern-matching at all. A simulation was added in later releases, but a scan of pattern-matching vs. association questions on this site will show that the simulation is not perfect. Graph objects provide subelement access through purpose-built property functions and do not support the regular part and matching mechanisms.
- The input syntax and display forms of an optimized type may not be symmetric. As examples, try examining the input forms of
Dataset[{1}]
or Image[{{1}}]
or ByteArray[{1}]
.
- The display forms of optimized expressions sometimes leave out critical information required to reconstitute the object's state (e.g. association examples from the question or copy-paste-evaluating a packed array's input form).
- The display forms of the unoptimized version of an optimized expression will generally not use short input syntax. For example,
HoldForm[Complex[1, 2] // InputForm]
or similar expressions using Rational
or Association
.
- Atomic expressions can be written in high-level WL code using SetNoEntry. Such expressions are opaque to most forms of pattern-matching (but not all). Some built-in functionality use this (e.g.
Dataset
).
- When investigating the transition from unoptimized to optimized expression forms, beware that any evaluation will disturb the observations. We must write expressions like
AtomQ[Unevaluated@...]
. TreeForm
in particular is known to have evaluation leaks that require doubling up constructions such as Unevaluated
to see the actual structure (e.g. TreeForm[Unevaluated@Unevaluated@<|1 -> 2|>]
).
Association
is to useRuleDelayed
. E.g.<|1:>Print[1], 2:>Print[2]|>
$\endgroup$