Anecdotally, programmers dislike "reduce"
41 points by aphaelion
41 points by aphaelion
I think it's because reduce is usually too powerful. Map and filter are very focused operations. But reduce is actually pretty powerful. In fact, it's so powerful, that if you take it's function signature, you can actually just make an equivalent list type: type List a = forall b. (a -> b -> b) -> b -> b! (Good exercises for the reader would be to implement things for that list)
I think you're usually better off defining or finding a better operation than using reduce directly if you can.
The power asymmetry is also clear in that you can easily define the other two in terms of reduce (using foldr and Haskell syntax here since I'm lazy) but not vice versa:
myMap f xs = foldr (\x acc -> (f x):acc) [] xs
myFilter f xs = foldr (\x acc -> if (f x) then x:acc else acc) [] xs
In general it's definitely possible to use reduce in a somewhat confusing way whereas map and filter are always straightforward.
Yes. Graham Hutton wrote a wonderful paper in 1999 called A tutorial on the universality and expressiveness of fold showing how fold/reduce is the universal abstraction for structural recursion over lists (or any inductive data structure, really).
That paper is my favorite FP paper. Here's my take at implementing the SML Basis List functions via fold: https://pzel.name/2023/07/29/Practical-ML-with-sml-sharp-review-chapter-3.html
I think this explains the "reduce is harder to read" factor.
Code with too much expressive power is harder to read. When you are reading and come across map or filter, it narrows the space of possible things the code could do. reduce doesn't do that at all, because it is the most general list function (that's exactly what that Böhm-Berarducci definition of List shows). It could do anything!
Of course, by that argument, you shouldn't replace reduce with a written-out loop (since a loop is even more expressively powerful), so familiarity is also likely a factor.
You should replace it with other common reduces like sum, all, etc., or give your reduce a name.
I think yes and no. The power might be related, but the simple for loop is even more powerful. However, for loops that mimic a simple reduce (e.g. sum, product, etc) can be quite readable.
The worst for loops are quite unreadable, of course. My point is just that this isn’t just about power, it’s also about syntax/pattern recognition.
Loops are a well-learned concept for all programmers.
Generally, highly expressive, abstract concepts work well if they are used a lot.
Reduce, even if idiomatic for your language, is rarer. And rarer than map/filter as well, while being more complicated.
On top of being too powerful, I think the ergonomic tax on working memory is too high. Every language has its own operand order and function syntax, so now when writing your reduce, you have to consider:
That's already a couple slots of working memory that need eviction in order to write the fold. What gets evicted is probably the domain logic that we're trying to express in the fold.
It doesn't help that every language has a slightly different take on the above questions.
I think the other explanations given in this thread are bigger factors, but there's also the fact that reduce is a terrible name. I think fold is marginally better but not by much.
In the Fennel programming language, due to design constraints we can't add functions to the core language, but can add macros. (It's complicated and not really the point here.) When the time came to add a reduce-like operation, we looked at alternative names. I had heard somewhere that Smalltalk used the term inject for this (which I think is even worse than reduce) but also offered the alternative of accumulate, which I think is much better, and we eventually went with that.
However, now that I'm looking for a source for this, it doesn't seem accurate; as far as I can tell Smalltalk only has inject. I'm not sure where I heard this! What other language calls it accumulate?
C++ has std::accumulate. It also has std::reduce and std::ranges::fold_left though and those are all slightly different so....yeah.
Ruby also uses inject which is what I first learned, though reduce makes the most sense to me. In the end I just think of it as "map + state, but return the state". There's good reason to not think of it that way, but it was easier to think about until I grokked it more holistically. As yet another alternative name, Ruby also has Enumerable::each_with_object which is different mechanically but practically gets at the same idea.
C# (or, well, Linq) seems to use Aggregate.
The thing about accumulate is there are other uses of the term that are similar but different. IIRC Python uses accumulate to mean scan, so it returns each intermediate value as it reduces. Julia does the same thing. I actually thought this is what the Smalltalk version you were thinking of was going to turn out to be...but I can't find any reference to it.
due to design constraints we can't add functions to the core language, but can add macros.
wtf, care to say more?
Fennel is a lisp that compiles to Lua. It ships both a runtime loader that compiles code as it's read, but also as an ahead-of-time batch-style compiler. I assume that this is for cases where your Lua-using program is fussy about how it takes your extensions (e.g., mods for proprietary games) and so you can't count on loading some hypothetical Fennel runtime library before loading any of your code.
I'm stealing the keyword accumulate. That is a good word, because it describes the 80% of use cases of reduce.
In APL-family languages, "reduce" is the first "loop-like" construct you encounter. K uses / ("over") for reduction and \ for its counterpart, "scan", which produces all the intermediate results:
+/3 4 5 6
18
+\3 4 5 6
3 7 12 18
Being able to substitute \ for any / and view a trace of computation is very handy for teaching and learning K. A surprising number of languages which offer "reduce" do not also provide a "scan".
A surprising number of languages which offer "reduce" do not also provide a "scan".
I was just asking for this in Roc.
reduceis less elegant in languages I use, like JavaScript, Python, and Swift.
Anecdote from Python-land: reduce used to be a built-in function in Python 2, but in Python 3 it was relegated to functools. Why Guido decided to demote it:
This is actually the one I've always hated most, because, apart from a few examples involving + or *, almost every time I see a reduce() call with a non-trivial function argument, I need to grab pen and paper to diagram what's actually being fed into that function before I understand what the reduce() is supposed to do. So in my mind, the applicability of reduce() is pretty much limited to associative operators, and in all other cases it's better to write out the accumulation loop explicitly.
So Evan is not alone in noticing this!
Contrast with map/filter, whose Pythonic equivalent is comprehensions. Anecdotally, those seem to spark joy more often. (I like them, at least.)
I've noticed the usefulness of reduce is massively reduced when working with imperative data structures. The python explanation says it's most useful involving + or * which makes sense because in python numbers are stable values and data structures are not.
There's also the abstraction ceiling from lambda.
If you want anything semi-complicated you'll need a block instead of a `lambda. Now suddenly you're writing:
def reducer(val, accumulator):
return do_things(val, accumulator)
result = reduce(reducer, some_data, None)
here's the alternative.
result = None
for elt in some_data:
result = do_things(elt, result)
The extracted body is rarely that useful in itself. Meanwhile the alternative is easy to tweak. Things like skipping over elements become a continue call (reducer can of course no-op by returning the accumulator of course). You're just looking at the operation (no indirection due to method naming)
You're right in that in most imperative systems it's trickier because ownership is unclear ("should I copy"?), among other things. I do think tho that in Rust in particular you can get away with it because of ownership tracking (and be quite performant as well?)
I would posit that accumulation as a pattern is simply solved decently in languages like Python in a lot of "canonical" cases and thus the common reduce patterns... become sum if you like method overloading.
I am very fond of reduce but I agree with the anecdotal experience. The problem with very general tools is that they require you to see the world in terms of very general abstractions, and that's rarely actually necessary for most line-of-business stuff and so is a rarely exercised skill.
I've been on a long term quest to eliminate the major use cases from JavaScript - so far Object.fromEntries (wasn't ever actually a good use case for reduce but people kept doing it anyway), Math.sumPrecise (was originally going to be just Math.sum but we ended up with the high-precision version after committee, oh well), and Iterator.prototype.join (not quite merged but probably this month); next up is probably Math.argmax although there's a lot of other things I want to get through first.
IMO, just a skill issue: any experienced programmer should have a sold grasp of folds, why are general, how to read and write code using them, since they are generally better than explicit recursion. But a lot of people don't learn the concepts essential to the trade.
Possibly, but it's an indication of the higher cognitive load in writing and understanding the code.
I can only agree. I'm used to seeing and writing folds quite often, and I more often than not find them easier to comprehend and grasp the intent of than bare loops. It really depends on the paradigm one was socialized in.
Experienced programmers should use the most readable available function. In JavaScript, Python, and Swift that's hardly ever reduce.
Is this a syntax issue? Curiously, the article doesn't give any code examples at all.
In my own language, Varyx, I made these infix operators with terse names: map, ver, and per. Here are some real-world examples:
let hms = [tm.hour, tm.min, tm.sec] map left-pad % "0" % 2 per {a ":" b}
let config_lines = data.lines() ver bool per comma-separated or "()"
let ensemble = [vox1, vox2, vox3, vox4] per multiplex
The infix orientation was a deliberate choice to allow you to chain operations without nested function calls, with data flowing from left to right (in contrast to Perl's map and grep, which have the data on the right). Complex chains can be stacked vertically:
let x = scores ver { v.value == 2 }
map .key
per Math.product
(Making each of these operator names exactly three letters was also intentional.)
I gather from your second example that per returns some kind of nil value in case the collection is empty, and you can use or to supply a default value in this case. Is that accurate?
What if you want to naturally return nil from the reduction for some reason, but you also want to handle empty collections somehow?
Caveat: My experience is mostly Haskell and while I'm sympathetic to lisps, I've done very little with them. I find the contract for clojure.core.reduce rather difficult to follow:
reduce with two args, it behaves kinda like foldl1 f coll:
(f (f x1 x2) x3) etc., with the first elements of the collection being in the first applications to f.(f). (My read is that the library author tried to make reduce total when used in two-arg form, at the cost of needing f to work with zero and two arguments, but IMHO it's really a precondition violation. I feel raising an error would be more appropriate.)f is never called.reduce with three args, it behaves kinda like foldl f val coll:
(((f val x1) x2) x3) etc. As with the two-arg form, earlier elements are applied first.val.The thing that seems most surprising a that (reduce f z [1]) is (f z 1) but (reduce f [1]) is 1; and that (reduce f []) is (f) but (reduce f z []) is z. The reader is forced to care about boundary conditions much more than when using map and filter. It's clear why after you think about what each form of reduce is doing, but I personally would have distinguished between possibly-empty and definitely-non-empty collections and provided reduce and reduce1 functions in the stdlib.
Rich agrees with you. Emphasis mine:
Who knows what the semantics of reduce are when you call it with a collection and no initial value?
[Audience response]
No one, right. No one knows. It's a ridiculous, complex rule. It's one of the worst things I ever copied from Common Lisp was definitely the semantics of
reduce. It's very complex. If there's nothing, it does one thing. If there's one thing, it does a different thing. If there's more than one thing, it does another thing. It's much more straightforward to have it be monoidal and just use f to create the initial value. That's what transduce does, so transduce says, "If you don't supply me any information, f with no arguments better give me an initial value."
Note though, there's no requirement that you support that. You don't have to have that arity. You don't have to allow people to call transduce with no initial value because sometimes there's just no good made up from nothing initial value. Somebody needs to think about an initial value or supply some inputs to your process to get a starting value. Not everything can be made from nothing. It's easy to come up with zero from nothing. But it's not easy to come up with a channel from nothing or other kinds of things, you know, event systems. You don't have to support this.
Interesting citation, thank you.
That's what transduce does, so transduce says, "If you don't supply me any information, f with no arguments better give me an initial value."
So the function is expected to both serve as the combining (<>) operator (aka mappend) when called with two arguments, an as the identity (aka mempty) for the combining function with no arguments? That sounds like it's being asked to serve as the dictionary for a Monoid instance but instead of passing in an explicit interface (or having the compiler do it for you), the function is overloaded to handle both zero and two arguments, but it's okay to not provide the initial value in certain instances? I still feel some heebie-jeebies at that design decision.
the function is overloaded to handle both zero and two arguments
That's right. I imagine it's downstream of the old Lisp convention where (+) yields 0, (*) yields 1, and so on. + and * support any number of arguments, with zero and two as special cases.
I can no longer remember the names, but I distinctly recall one rust contributor observing that sum exists because another early contributor really hates reduce.
I appreciate that person’s contribution.
I think part of the reason is that reduce is less ergonomic in languages without currying, partial application, and a lot of nice combinators lying around in general.
i think reduce is not as intuitive because the output which the structure "reduces to" can be any type in general. that's not the case with maps and filters, which always produce the same structure as output.
this makes reduce or fold a bit too general when we see it for first time.
also, with laziness, as in Haskell (foldr v foldl), it becomes extra confusing to reason operationally about.
I like reduce but I did have to learn it.
This. Nowadays List.foldl (\item acc -> ...) initAcc list (or Dict.foldl or what have you) are a second nature, but it definitely took some time to get used to it.
A funny thing I like (in the Elm ecosystem) is that the order of arguments inside the reducing function is the same as The Elm Architecture's update function: the message coming in, then the model you're updating - update : Msg -> Model -> Model ~ foldFn : item -> acc -> acc.
Rather confusingly, the accumulator argument comes first in Haskell (and I prefer this, because it mirrors the inputs) . One can get used to a specific implementation in a specific language, but for a more polyglot/all over the place enthusiasts like me, details like this are one of the reasons I dislike foldl/foldl'/foldr/foldl1 (especially in Haskell, where their interaction with laziness is so unpredictable).
Fold feels like a very powerful but a complex and leaky abstraction. Its natural use is limited to associative operations in pure code, and even there it still can be awkward in practice.
Interesting. As someone coming from the imperative world who's trying to adapt to functional programming, those three felt like a package deal to me.
I teach people Clojure, and reduce is the first thing novices latch on when they start learning, mainly because it allows them to reproduce for-loop semantics that they are familiar with from imperative languages. I make them avoid reduce no matter what, in order to push them to use higher-level tools from the language and only use it when there is no other choice.
I've been catching up on Sean Parent's talks recently, and one of his big goals for programmers is "No Raw Loops". It comes from C++ world, but I think it's applicable further.
In that light, reduce antipathy is understandable: you have to hold state in your head to ensure that some invariants aren't violated across iterations—in simple maps/filters you have to care about one element only, which shrinks state space considerably.
So the goal (if we're stretching the No Raw Loops further) is to find the actual algorithm and use it—or write one and extract it (and then the implementation may use reduce, OK).
Ironically, I gave this exact feedback to a more senior engineer during a code review many years ago. They wrote something using reduce and I said it was too hard to understand. They were very nice about it, but I wonder if it shouldn't have just been a learning opportunity for me.
In any case, I think it comes down to the function signature. map and filter both act on an array and return an array. reduce acts on an array and returns... any container? It's more powerful, but takes a lot more reading.
I've been more comfortable using it in Typescript recently because the signature is generic so I can be explicit about the output type (and the compiler enforces it).
But I don't like that when returning the accumulator, the simplest thing to do (in JS) is:
acc.someValue = 'whatever'
return acc
Which modifies the original object. In practice this doesn't matter because I'm starting with a blank object. But it's still has the potential for unintended mutation, so the safest thing to do is:
return {
...acc
someValue: 'whatever'
}
But that makes a lot of copies, so it's probably less performant. So then I end up with this whole philosophical debate about how to write a simple function and then I wish I hadn't used reduce in the first place.
Anecdotally, can confirm. My colleague and teammate, a very capable staff developer, likes map and filter (in TypeScript) but says he tends to avoid reduce.
I think reduce is often exposed as an accumulate-like operation that takes one element at a time, which makes it hard. Most C/C++/similar-language programmers use a map-reduce model all of the time: compile is map (source-code file to object-code file), link is map (object-code files to program / shared library). But the reduce phase can't be implemented as an accumulate-like operation, it requires access to each of the objects simultaneously.
I complain about xmake a lot, but I use it because it's less annoyingly stupid than the alternatives. It expresses each high-level build task as a map step and a reduce step, though it calls them compile and link. For a language like Rust, the compile step might be a no-op and the reduce step do the compile-and-link together. For a language like TypeScript, compile might turn TypeScript to JavaScript and then link do nothing. It's a useful abstraction, but the link step isn't written as a lambda that is called once per output from the compile step, it's written as a lambda that takes the complete set of those files as an argument.
Quoting from a note I wrote when we still had juniors in this industry and I wanted them to have a quick reference when to use which array method in JS (especially using reduce in place of flatMap).
I said before, though it is worthy of a repeat, that in my view there are two main ways one can use
.reduce. The reducer can take two values of the same type—in which case it exploits the monoidal nature of arrays, as above—or two values of different types, in which case it might be better to rewrite it first with a.map.
I wrote these two tutorials mostly for my colleagues who were looking for a gentle introduction to FP patterns in JS, so they're a bit specific to that language, and I think they might read as too trivial to even be writing about, but they were useful to some people and I think demystified the way one can think about data structures in a functional way.