While perhaps appearing as peripheral language design issue, I think clarifying the varieties of in-place, L-assignment further illuminates the much more consequential notion of mutability/immutability within the WL.
My conclusion is that yes the difference noted between []
and [[]]
assignments (both in Unset
and more generally) is somewhat incoherent with the current []
implementation representing the most consistent, in-place interpretation. Accordingly, extending this interpretation to all other varieties would add coherency (A "bug" label where in-place assignment doesn't align with []
's idiom however, doesn't seem warranted so I'll instead label such deviations, a "mite").
Let's start with List
s by way of background:
ls = {a, b, c};
ls[[2]] = B;
ls
(*
{a, B, c}
*)
As pointed out by Leonid Shifron in the question comments, Set
is being used in two subtly different ways here; in the first instance a variable is being created; in the second instance a part is being modified. While not keeping these usages separate lies behind subsequent differences (in this periphery at least), IMO an overriding imperative here is pedagogy; the use of Set
and its infix form "="
is so ingrained, its application so universal, that the described dual usage is both natural and an integral part of Mathematica's learning curve. Further, existing coherency levels bear enhancement as to be outlined.
Firstly note that the creative usage of Set
affects WL's evaluation sequence in addition to (or as part of) defining a new structure:
<< GeneralUtilities` (* needed for Definitions *)
Definition@ls
Definitions@ls
(*
ls = {a, B, c}
{HoldPattern[ls] :> {a, B, c}}
*)
but as Set
giveth, Unset
taketh
ls =.
Definition@ls
Definitions@ls
(*
Null
{}
*)
This Unset
usage at a global or "whole structure" level can be viewed as both undoing Set
's actions or else removing a structure. In the latter case, given Set
's dual usage, coherency posits that a similar part removal should also be available - in other words can one taketh away part of a structure using this syntax? Alas and unfortunately, for List
's the answer is no (although the situation is different with Associations
as we will soon see)
ls = {a, b, c}
ls[[2]] =.
ls
(*
{a, b, c}
Unset::write: Tag Part in {a,b,c}[[2]] is Protected. >>
$Failed
{a, b, c}
*)
but I regard this as another "mite". I maintain that it is perfectly reasonable and natural to expect that instead {a,c}
be returned given ls[[2]]=.
's removal semantics. Conceptually I don't see too great a distance between removing a whole structure from a namespace and removing a part (while recognizing that the former is also accompanied with the removal of a rule). We can view these operations through the lens of a coherency matrix where we observe that the outstanding action not yet considered is the creative action of Set
on parts (an implementation of which ensures that UnSet
consistently undo's Set
's actions).

This suggests that
ls = {a, b, c}
ls[[4]] = d;
ls
(*
Set::partw: Part 4 of {a,b,c} does not exist. >>
{a, b, c}
*)
could instead return {a,b,c,d}
(which, that it doesn't, I regard as another "mite").
When it comes to Association
s however, all the coherency just described is nicely implemented as we can observe by stepping through each cell in the coherency matrix.

IMO, a gain in coherency would result from squishing all outstanding mites by extending []
's Set
semantics to ls[[]]
, assoc[[]]
and dataset[]
.
Note that in relation to creative part applications of Set
, previous examples relied on the part extending the structure "continuously". Discontinuous part creations require the insertion of missing elements semantics for which are already provided by Missing[]
and SparseArrays
. For example,
ls = {a, b, c};
ls[[5]] = e;
ls
(*
{a, b, c, Missing[], e}
*)
Currently, this insertion does not occur even for assoc[]
since while
assoc = <|1 -> a, 2 -> b, 3 -> <|4 -> c|>|>;
assoc[3, 5] = d
assoc
(*
d
<|1 -> a, 2 -> b, 3 -> <|4 -> c, 5 -> d|>|>
*)
we have that
assoc = <|1 -> a, 2 -> b, 3 -> <|4 -> c|>|>;
assoc[3, 5, 2] = d;
assoc
(* Set::kval: The value for the key 5 does not exist. >>
<|1 -> a, 2 -> b, 3 -> <|4 -> c|>|>
*)
We return to the connection between immutability and mutability. This exposition and suggested unification of Set
's semantics, is by no means intended to emphasise or promote it as the dominant or even preferred method for the frequent changing of data structures. Set
is ideal for beginners, for initiating structures, for changing single elements in the simple immutable fashion and finally for recording chains of changes. There are, of course, more flexible functions for handling richer mutations. For example, Set
can't readily handle changes that involve a function of the part's value as provided by MapAt
.
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
MapAt[Capitalize@ToString@# &, assoc, 2]
(*
<|1 -> a, 2 -> "B", 3 -> c|>
*)
nor can it, for example, singly handle multiple modifications in the manner of ReplacePart
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
ReplacePart[assoc, {1 -> "A", 3 -> "C"}]
(*
<|1 -> "A", 2 -> b, 3 -> "C"|>
*)
Finally, Set
doesn't enjoy the ready compositions via operator forms within a Query
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
Query[MapAt[Capitalize@ToString@# &, 2]@*ReplacePart[{1 -> "A", 3 -> "C"}]]@assoc
(*
<|1 -> "A", 2 -> "B", 3 -> "C"|>
*)
Not only are these richer modifications but they are also done so mutably with the base definition of assoc
remaining unaffected. This is a powerful and important feature of the WL that is, IMO, essential to encouraging and facilitating seamless experimentation. In fact, as touched on elsewhere the interplay between mutable/immutable structures captures scientific practice and even the unfolding of evolution. More locally, much of Mathematica's prototyping ability IMO stems from WL's mutable nature but structures eventually need augmentation to record finds, a fact that I think explains recurring questions on immutable modifications. Currently, a whole/global Set
is required
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
assoc = Query[MapAt[Capitalize@ToString@# &, 2]@*ReplacePart[{1 -> "A", 3 -> "C"}]]@assoc
(*
<|1 -> "A", 2 -> "B", 3 -> "C"|>
*)
Eventually repeated application of this construction starts to invoke "boilerplateness". For some users it may also be a little disconcerting minimally changing a large legacy data structure by apparently, expensively copying the entire structure (a misconception not perhaps easily disabused for those less versed in mutable/immutable distinctions). But is there a more natural, infix approach to immutable assignment? I think so. We can take our cue from AddTo
or TimesBy
, a+=1
or b*=2
whereby the content immediately wrapping the =
defines an operator set to operate on the data structure defined by the first symbol.
IMO, the relevant, "infix operator" begging for a call to action here is "@=
"; behold the "naturalness" of the following:
assoc @= MapAt[Capitalize@ToString@# &, 2];
assoc @= ReplacePart[2 -> B];
assoc @= ReplacePart[{1 -> "A", 3 -> "C"}];
assoc @= Query[MapAt[Capitalize@ToString@# &, 2]@* ReplacePart[{1 -> "A", 3 -> "C"}]];
Its generality can be seen how existing idioms get covered
a += 1;
a @= # + 1 &
or in an answer to dataset in-place assignment
dataset[Select[#age > 30 &], "salary"] *= 2
dataset @= Query[All, MapAt[If[#age > 30, 2#&, Identity], #, "salary"] &]
or by "rendering redundant" existing immutable variants such as AppendTo
,PrependTo
, KeyDropFrom
(while reducing the need for future variants )
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
assoc @= Append[4 -> d];
assoc @= KeyDrop[2]
This is yet another example of the power of operator forms. Note that in these immutable versions there is usually a sense of one large, legacy structure, being augmented by adding/changing a single element. Hence operator forms, which require a natural target structure to operate on, naturally emerge by considering the original structure as constituting such a target. Other functions, such as Join
, on the other hand, have traditionally considered joinable elements to have equal prominence so that without an obvious target no such operator form emerges. The notion of an immutable, infix assignment alters this whereby the following becomes more natural:
assoc = <|1 -> a, 2 -> b, 3 -> c|>;
assoc @= Join[<|4 -> d|>];
We can see how common immutable, infix assignment improves code readability and data structure tracking
capitalizeMiddle = MapAt[Capitalize@ToString@# &, 2];
replaceBookends = ReplacePart[{1 -> "A", 3 -> "C"}];
logicallyExtend = Join[<|4 -> d, 5 -> e|>]; (* no current operator form *)
capIt = Prepend[0 -> z];
drop3 = KeyDrop@3;
addSuffix = StringJoin["Suffix"] (* no current operator form *)
assoc @= capitalizeMiddle;
assoc @= replaceBookends;
assoc @= Query[capitalizeMiddle@*replaceBookends];
assoc @= logicallyExtend;
assoc @= capIt;
assoc @= drop3;
st @= addSuffix;
Currently there does not exist such an infix operator, @=
, and while it could be introduced by the Notation
utility, general package development doesn't gel with such integration; we can however, currently implement it by instead borrowing an existing, if mildly related, infix operator, \[DotEqual]
SetAttributes[DotEqual, HoldFirst];
DotEqual[a_, b_] := a = b@a;
assoc = < 1 -> a, 2 -> b, 3 -> c|>;
j = 4;
{
assoc \[DotEqual] capitalizeMiddle,
assoc \[DotEqual] capIt,
assoc \[DotEqual] drop3,
j \[DotEqual] (2#&)
};
{assoc, j}
(*
{<|0 -> z, 1 -> a, 2 -> "B"|>, 8}
*)
whose visual sense is better provided by an actual visual

For Join
/StringJoin
, operator forms need defining (since as mentioned, in this idiom's absence, no target operand presents) and not wanting to tinker with core, native definitions we can instead create o
operator forms when these are not built-in
oJoin[h_] := Function[{expr}, Join[expr, h], HoldAll];
oStringJoin[h_] := Function[expr, StringJoin[expr, h], HoldAll];
logicallyExtend = oJoin[<|4 -> d, 5 -> e|>];
addSuffix = oStringJoin@"Suffix";
z = {1}; st = "stem";
z \[DotEqual] logicallyExtend;
st \[DotEqual] addSuffix;
{z, st}
(*
{<|0 -> z, 1 -> a, 2 -> "B"|>, 8}
*)
Again visually

Set
naturally sets things off in mutable, experimental playgrounds; it then helps record interesting finds, something more naturally done with immutable, infix assignment.
Unset
behaviour might be different forAssociation
compared with aList
given the former is a "symbolically indexed list" with a different notion of mutability (n.b. removing a list element in this way also fails since no particular symbol is being unset) but I also tend to think that it is perhaps natural to expect similar behaviour. $\endgroup$Unset
works for the[ ]
syntax for assocs - I wasn't aware of that. While I can understand that it has some syntactical nicety, I don't feel that this sits well with the standard sematnics ofUnset
, which previously was used strictly for global rules. This looks like a case where syntax wins over semantics, and a rather questionable design decision, in my view. $\endgroup$Unset
, this isn't clear-cut case to me. We may try to think of anUnset
as of reverse toSet
orSetDelayed
. Now,Set
serves two purposes: 1. create global rules (like SetDelayed, but immediate), and 2. Efficient in-place modifications of parts of expressions stored in symbols, when used withPart
. The first use in general and conceptual. The second use is special, and comparatively low-level. I think it is rather unfortunate that the same operator (Set
) is used in both cases... $\endgroup$Set
would only create rules, while there would be a separateSetPart
for in-place part modifications. Now,Unset
would be exact opposite ofSet
, that is, used only to destroy global rules. Now, if we think about it, usingUnset
on a part makes no sense. You can either delete a part, or modify it, but not unset. So, logically, we'd need aDeletePartFrom
operation, which actually exists for assocs (KeyDropFrom
). But, such operation can't be general, because general expressions are based on sequences, and .. $\endgroup$