1. You’re mapping or reducing some dataset
2. Your iteration logic does not branch a lot
3. You can express your transformation logic using higher order functions (e.g. mapping a reduction operation across a multidimensional array)
Some domains have a log of this style of work—finance comes to mind—others do not. I suspect this is why I’ve personally seen a lot more of Clojure in finance circles than I have in other industries.
The 1:20+ is definitely not hyperbole though. Using transducers to stream lazy reductions of nested sequences; using case, cond-> and condp->; anywhere where you can lean on the clojure.core library. I don’t know how to give specific examples without giving a whole blog post of context, but 4 or 5 examples from the past year spring to mind.
It’s also often the case that optimizing my clojure code results in a significant reduction of lines of code, whereas optimizing Python code always resulted in an explosion of LoC
Personally I find Python particularly egregious. No map/filter/reduce, black formatting, no safe nested property access. File length was genuinely one of the reasons I stopped using it. The ratio would not be so high with some languages, ie JavaScript
Even with Elixir though, many solutions require 5-10 times the amount of lines for the same thing thing in Clojure. I just converted two functions yesterday that were 6 & 12 lines respectively in Clojure, and they are both 2 pages in Elixir (and would have been much longer in Python)
Usually these are problems where you need to run along a list and check neighboring elements. You can use amap or map-indexed but it's just not ergonomic or Clojure-y (vs for instance the imperative C++ iterator model)
The best short example I can think of is Fibbonacci
https://4clojure.oxal.org/#/problem/26/solutions
I find all the solutions hard to read. They're all ugly. Their performance characteristics are hard to know at a glance
(loop [[a b c & more] coll] (recur (apply list b c more)))
There’s also partition (partition 3 1 coll)
Or if you need to apply more complicated transformations to the neighbors/cycle the neighbors (->> coll cycle rest (map xform) (map f coll))
Using map-indexed to look up related indices is something I don’t think I do anywhere in my codebase. Agreed that it’s not ergonomicEDIT: those Fibonacci functions are insane, even I don’t understand most of them. They’re far from the Clojure I would advocate for, most likely written for funsies with a very specific technical constraint in mind
You could do `(partition 5 1 coll)` and then average each element in the resulting seq.. It's very easy to reason about. But I'm guessing the performance will be abysmal? You're getting a lazy list and each time you access a 5 neighbor set.. you're rerunning down you coll building the 5 unit subsets? Maybe if you start with an Array type it'll be okay, but you're always coercing to seq and to me it's hard
Taking the first 5 elements, recurring on a list with the top element dropped is probably better, but I find the code hard to read. Maybe it's a familiarity issue..
(loop [coll [1 2 3 4 5 6 7 8 9] memo []]
(if (< (count coll) 5) memo ;; Stop once there are less than 5 items
(->> (take 5 coll) ;; Take the first 5 days
(reduce +) ;; Sum them
(* 0.20) ;; Divide by 5
(conj memo) ;; Append to the end of the memo
(recur (rest coll)) ;; Recur for remaining elements
,,,))))
Realistically if performance was a consideration I would probably do: (loop [[a b c d e :as coll] [1 2 3 4 5 6 7 8 9] memo []]
(if-not e memo
(recur (rest coll) (conj memo (/ (+ a b c d e) 5))))))
Should be ~15 times faster to avoid the nested loop. If you want to change the min size it's still pretty clean: (loop [[a b c d e :as coll] [1 2 3 4 5 6 7 8 9] memo []]
(if-not c memo
(recur (rest coll) (conj memo (/ (+ a b c d e)
(cond e 5 d 4 c 3))))))))- What's the nested loop in the first solution that you've avoided? The `reduce`? the `count`?
- `conj` feels very Lispy (I mean in contrast to Clojure, not C++) .. Isn't it going to have to run down the list every time to add an item?
My outstanding concerns are what I think are the constant coercion to lists/vectors. You also in effect know the result's size, but your runtime/compiler doesn't know that. So you aren't preallocating `memo` and it feels .. wrong haha
Just curious to hear your thoughts :)
Its probably impossible to keep everything so nicely abstract and composable, but I wish it was smoother to just work with arrays, with random access. The current way of dealing with array is always a bit unwieldy in Clojure. And everything coerced to lists. Working with vectors, with mapv filterv etc is helpful, but they don't have random access so it's not always the solution you want.
These examples are incredibly easy to grok. It takes me a lot longer to grok any index based solution ;-) Especially handling nils, missing indices, checking bounds... you quickly get buried in obvious issues that aren't a concern in clojure.
If it's more complicated, I use comments like the first example. I would say that when grokking more complicated loops, you definitely read it slower on a per character basis, but as I've stated elsewhere I don't think you're grokking the functionality any slower.
> What's the nested loop in the first solution that you've avoided? The `reduce`? the `count`?
Correct, reduce is a loop
> `conj` feels very Lispy (I mean in contrast to Clojure, not C++) .. Isn't it going to have to run down the list every time to add an item?
No, vectors are closer to hash-maps than lists. There are two main sequential collections in clojure, lists and vectors. Lists are O(n) to append, vectors are O(1). Lists ore O(1) to prepend, I'm not actually sure the perf characteristics of prepending to a vector.
> My outstanding concerns are what I think are the constant coercion to lists/vectors.
There is only one coercion from a vector to a list in the code (the first time `rest` is called on the input vector). That's if your input was a vector to begin with. Chances are it will be a list. Also, I'm not sure it's even technically coercion in the computer science sense.
> You also in effect know the result's size, but your runtime/compiler doesn't know that. So you aren't preallocating `memo` and it feels .. wrong
This is not a concern in JavaScript (it always preallocates a bunch of memory for every vector IIRC) and I'm not sure the Java implications, but given the performance people get out of clojure I'm sure it's not an issue ;-)
> The current way of dealing with array is always a bit unwieldy in Clojure. And everything coerced to lists. Working with vectors, with mapv filterv etc is helpful, but they don't have random access so it's not always the solution you want.
I can count on one hand the number of times I use nth in my project. To me it's crazy that people build solutions based on random access XD
That being said random access in O(1) on vectors, including those returned from filterv and mapv (technically O(log32n))
> There is only one coercion from a vector to a list in the code (the first time `rest` is called on the input vector)
So actually.. the whole thing is operating on linked lists.. Is that like a "yikes" moment..? No happy clever vectors? It does seem to still work fine in this case, b/c we pop from one list and append to another and the example is simple.. but generally you want the O() guarantees of vectors or arrays
I'm also a bit surprised reduce is going to give you so much overhead, and destructuring doesn't. It really hardcodes the function to a window size which is a shame.. Last I had to optimize a hot loop in Clojure I found all destructuring terrible for perf (but I think it had to do with type boxing)
> I can count on one hand the number of times I use nth in my project.
Random access is sometimes necessary, but like you say hash-like-vectors cover you here okay. I guess arrays are more about having contiguous memory access. It generally gives you much better cache locality and it will typically make a very sizeable performance difference in a hot loop
(recur (subvec coll 1) …)
If I needed to maintain the vector throughout the loop. However you need to change your base case and there’s no reason to have a vector at all in this case.There is a reason most of the Clojure core functions return lists and not vectors. When you start learning Clojure, you use vectors for everything — after a while you realize how sufficient a list is for 90% of programming.
Also, I believe you could still get the performance guarantees and dynanicism with a dotimes and a volatile. You can definitely get it using nth on the remaining collection with a conditional if you don’t mind a bit of verbosity. Regardless of the goalposts, I promise you there is an ergonomic solution ;-)
I gotta use transducers, lists and benchmarking more..
Thanks for sharing how you approach these problems
https://ask.clojure.org/index.php/2422/transducer-for-partit...
https://ask.clojure.org/index.php/13187/there-currently-opti...
https://clojure.atlassian.net/browse/CLJ-2797
Hopefully gets added to the language some day..
If you come across a post or an example that shows those differences, I would be very interested!
(defn report [date]
(let [[d w m q y] (-> (comp tier* recall* (partial c/shift date :day))
(map [1 7 30 90 365]))]
(reduce (fn [memo {:keys [card code]}]
(cond-> memo
true (update code (fnil update [0 0 0 0 0 0 0 0 0 0]) (q card) inc)
(<= 4 (d card)) (update-in [code 6] inc)
(<= 4 (w card)) (update-in [code 7] inc)
(<= 4 (m card)) (update-in [code 8] inc)
(<= 4 (y card)) (update-in [code 9] inc)))
{}
(k/index :intels)))))
The elixir code I was able to condense down into: def report(facets, intels, day) do
[d, w, m, q, y] = for x <- [1, 7, 30, 90, 365], do: Date.shift(day, day: x)
Enum.reduce(intels, %{}, fn intel, acc ->
facet = Map.get(facets, intel.uuid, :zero)
[q0, q1, q2, q3, q4, q5, d4, w4, m4, y4] =
acc[intel.code] || [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
quarterly_tier = tier(facet, q)
Map.put(acc, intel.code, [
if(quarterly_tier == 0, do: q0 + 1, else: q0),
if(quarterly_tier == 1, do: q1 + 1, else: q1),
if(quarterly_tier == 2, do: q2 + 1, else: q2),
if(quarterly_tier == 3, do: q3 + 1, else: q3),
if(quarterly_tier == 4, do: q4 + 1, else: q4),
if(quarterly_tier == 5, do: q5 + 1, else: q5),
if(tier(facet, d) >= 4, do: d4 + 1, else: d4),
if(tier(facet, w) >= 4, do: w4 + 1, else: w4),
if(tier(facet, m) >= 4, do: m4 + 1, else: m4),
if(tier(facet, y) >= 4, do: y4 + 1, else: y4),
])
end)
end
It was much longer prior to writing this comment (I originally used multiple arity helper functions), but it was only fair I tried my best to get the elixir version as concise as possible before sharing. Still 2x the lines of effective code, substantially more verbose imho, and required dedicated (minor) golfing to get it this far.Replacing this report function (12 lines) + one other function (6 lines) + execution code (18 lines) is now spread across 3 modules in Elixir, each over 100 lines. It's not entirely apples to oranges, but trying to provide as much context as possible.
This is all just to say that the high effort in reading it is normally a result of information density, not complexity or syntax. There are real advantages to being able to see your entire problem space on a single page.
I'm curious if you have any example of this? Even if it's an hyperbole, I don't really see how.