The behavior described here is the same from 10.0.0 up to at least 10.3.
Summary
We can look at the code of DeleteDuplicatesBy
and it turns out it uses GroupBy
. The test cases proposed by Mr.Wizard are all handled by some part of the code of DeleteDuplicatesBy
. Other parts of this code also seem to have some issues. Most of the members of the *By
family of functions seem to have side effects.
How DeleteDuplicatesBy works
It turns out DeleteDuplicatesBy
is not a function written in C. So it's Mr.Wizard's pure-MMA skills vs that of a WRI programmer for this one ;).
Let's see what the definition of DeleteDuplicatesBy
is. From v10.1.0 onwards, this may be done conveniently by using
<<GeneralUtilities`
PrintDefinitions@DeleteDuplicatesBy
If we predict where we will end up for a list, the definition basically says
DeleteDuplicatesBy[expr_, f_] := Values[GroupBy[expr, f, First]]
I guess theoretically the best way to do this would have to involve some kind of hash table. I expect GatherBy
also uses some kind of hash table, but who knows. It does not feel really surprising that an approach using a general purpose hash table like Association
is slower than what is used by GatherBy
. But if Association
was exactly the right kind of hash table for this, I suppose this approach may have been really fast. Unfortunately, it seems Association
is not the best choice for the job, but who knows if it is better for really large expressions (or something).
Results of DeleteDuplicatesBy for "other expressions"
By default we end up in the last branch in the Which
, corresponding to True
. It looks like this code may not give the results we might expect. Example
DeleteDuplicatesBy[
Hold[{a, 2}, {b, 1}, {c, 1}], Function[Null, Last@Unevaluated[#], HoldAll]]
Hold[{a,2},{b,1},{c,1}]
This output is not expected, as we have
Function[Null, Last@Unevaluated[#], HoldAll][{b, 1}] ==
Function[Null, Last@Unevaluated[#], HoldAll][{c, 1}]
True
As an aside, in the last argument of Which
, the following snippet occurs
Table[{f[expr[[i]]], i}, {i, Length[expr]}]
This is kind of an anti pattern. Performance in cases like this is better when using Map
, Range
and Transpose
. We can also see that the snippet does not work when f
has a hold argument, as the code relies on expr
to evaluate.
Side effects of other *By
family members
This is actually what I previously (before edits) thought was going wrong in DeleteDuplicatesBy
. This should not print.
a := Print["hello"]
SortBy[Hold[{a, 2}, {b, 2}, {c, 1}],
Function[Null, Last@Unevaluated[#], HoldAll]]
"hello"
Hold[{c,1},{a,2},{b,2}]
For the new KeySortBy
we have
a := Print["hello"]
KeySortBy[Association@Unevaluated@{a -> 2, 3 -> 4}, Hold]
"hello"
<|3->4, a->2|>
Good old SplitBy
has some side effects
SplitBy[Hold[{a, 1}, {b, 1}, {c, 2}],
Function[Null, Last@Unevaluated@#, HoldAll]]
hello
Hold[Hold[{a,1},{b,1}],Hold[{c,2}]]
MaximalBy
(and I suppose MinimalBy
), GroupBy
and CountsBy
do not have the bonus of working with Unevaluated
without creating side effects
q := Print["arg"]
MaximalBy[Unevaluated@{Hold[a, 3], {q, 2}},
Function[Null, Last@Unevaluated@#, HoldAll]]
arg
{Hold[a,3]}
But at least we can pretend they ignore Unevaluated
rather that they give bad results. CountDistinctBy
and of course GatherBy
seem to works as expected.
Conclusion: DeleteDuplicatesBy
may need a bit of work. I think some functions in the *By
family could be a bit better, some more than others.