Credence.Pattern.NoManualListReduce
(credence v0.7.1)
Copy Markdown
Detects hand-rolled recursive list-folding functions that should use
Enum.reduce/3.
Why this matters
The classic two-clause recursive fold — an empty-list base returning the
accumulator, and a [h | t] clause that recurses on the tail with an updated
accumulator — reimplements Enum.reduce/3 with no benefit:
# Flagged (arity 2)
defp sum([], acc), do: acc
defp sum([h | t], acc), do: sum(t, acc + h)
# Flagged (arity 3, extra argument threaded through unchanged)
defp scale([], _factor, acc), do: acc
defp scale([h | t], factor, acc), do: scale(t, factor, acc + h * factor)The fix
Each flagged group collapses to a single clause delegating to Enum.reduce/3,
moving the accumulator-update expression into the reducer function:
defp sum(list, acc) when is_list(list),
do: Enum.reduce(list, acc, fn h, acc -> acc + h end)This is behaviour-preserving for every input:
- Domain. The collapsed clause keeps the original name/arity and guards
when is_list(list). The two-clause original only ever matched[]or[_ | _](and raisedFunctionClauseErrorotherwise).is_list/1is true for both proper and improper lists and false for everything else — exactly the original domain. Maps/ranges/scalars raiseFunctionClauseErroron both sides; an improper list raisesFunctionClauseErroron both sides (Enum.reduceand the manual recursion both fail the same way on the improper tail — verified). - Same walk, same accumulator.
Enum.reduce/3folds the list head-to-tail, callingfun.(element, accumulator). The manual recursion walks the same order, computing the same updated accumulator at each step from the sameh/acc. For[]both return the seed accumulator untouched (the reducer runs zero times). The accumulator-update expression is moved verbatim into the reducer body, so its value, side effects, evaluation count (once per element), and any exception it raises are identical.
Detection scope (only the safely-fixable cases)
Exactly 2 clauses, same name, arity ≥ 2, no guards, where some assignment of the clauses fills these two roles. The accumulator is the last parameter; exactly one other parameter is the list (cons) position; every remaining parameter is a plain variable threaded through unchanged.
- Base —
([], …, acc):[]at the cons position, the last parameter a variable, whose body is a single expression returning exactly that variable. Recurse —
([h | t], …, acc):[h | t](both variables) at the cons position, whose body is exactly the self-callfn(…, t, …, <expr>)— the tailtat the cons position, an accumulator-update<expr>at the last position, and every other argument the matching parameter unchanged.
Why these narrowings (each drops a different-answer case)
- Single-expression bodies only. Each role's body must be its one expression (the accumulator variable / the self-call), never a multi-statement block. A block could carry a side effect that the reducer-based rewrite would drop or move — a different answer.
- No tail reference in the update. The accumulator-update expression must
not mention the tail variable
t. InsideEnum.reduce/3the reducer sees only the element and the accumulator —tis not in scope — so any update that read the remaining tail could not be reproduced. (Every other variable the update can mention — the head, the accumulator, and the threaded parameters — is in scope in the collapsed clause / reducer.) Such groups are left untouched. - Accumulator last, single cons position. Only the shape with the accumulator as the final parameter and exactly one cons (list) parameter is flagged; other layouts are dropped rather than risk a mis-collapse.