Nice strawman. Now, how would you restructure the let bindings (without the println) at https://clojuredocs.org/clojure.core/let#example-542692c7c02... equivalently? Wouldn't the ordering of the function executions matter? Could you re-order the bindings arbitrarily and get the same result? I don't think you can. That makes it imperative-as-opposed-to-declarative, even though it may not be imperative-as-a-synonym-for-side-effecting.
(let [a (take 5 (range))]
(let [{:keys [b c d] :or {d 10 b 20 c 30}} {:c 50 :d 100}]
(let [[e f g & h] ["a" "b" "c" "d" "e"]]
(let [_ (println "I was here!")]
(let [foo 12]
(let [bar (+ foo 100)]
[a b c d e f g h foo bar]))))))
And now you can simply evaluate it using variable substitution again.
The difference is that when you say `(let [a 10])` the variable `a` is a constant now, you can effectively replace all occurrence of it within the scope by `10`, and you can do this at compile time.
That's why people say the symbol `a` is bound to the value 10, and not the variable `a` points to the value 10.
Effectively you cannot change `a` within the scope anymore, all use of `a` in that scope will be equal, and so within that scope you can change their ordering freely.
What I think is confusing you here is that `let` gives you syntax sugar so all the scopes are flattened, but each new binding pair is actually inside a nested scope, and so `a` is still immutable. In this case, it matters almost never, but as I showed, it still can, because in an imperative language you could still try to mutate the variable even in the same expression, and that is impossible to do in Clojure.
> each new binding pair is actually inside a nested scope
Can you point me to something that demonstrates this? I've both decompiled this form and traced its compilation:
(defn foo [a]
(let [b a
b (+ b a)]
b))
and it does create two different symbols with a name of "b", which I get has the same net effect of not mutating the original "b", but I would not consider that "nested scope". It feels more like SSA to me, which is typically quite sequential in nature, which is what I associate with the term "imperative".
(let [a 1
a (+ (inc a) (inc a))
_ (println a)
a (+ a a)]
a)
Can be rewritten simply as a nested composition of anonymous functions:
((fn[a]
((fn[a]
((fn[_]
((fn[a]
a)))
(println a))
(+ a a))
(+ (inc a) (inc a))))
1)
The latter representation you would consider functional programming no? Well the let is just syntactic sugar for it and can be rewritten in terms of only composed anonymous functions (aka lambdas).
And like I said previously, you can reduce it with variable substitution, which gets rid completely of all the variables, at compile time, and the evaluation will give the same result:
In this case you see more clearly that the side-effect causes impurity, since it can't really be reduced, it's only in this case that order dependence matters, and so we can't eliminate the wrapping function, and this relies purely on Clojure's left to right argument evaluation ordering which allows you to mix/match side-effects within pure functions with predictable effect timing.
So here what happens you reduce that function into a do-block (which is Clojure's imperative form):
(do
(println (+ 2 2))
(+ (+ 2 2)
(+ 2 2)))
(do
(println 4)
(+ 4
4))
To our most reducible form:
(do
(println 4)
8)
This reduction can all happen in parallel or in any order, and the result will always be the same.
Ok, and this last bit is very important, this is what people mean when they say that in functional programming the order of execution doesn't matter. The side-effects must still be sequenced in their correct order, but all the computation can happen in arbitrary order, because the computation doesn't rely on a sequence of instructions like it does in the imperative programming paradigm, instead it relies on this "reduction" process I described which as you see you are free to reduce each part in whatever order you want, you'll always end up with the same thing in the end.
It helps me to have my understandings challenged, I could have been wrong, and going through trying to explain myself helps with me better understanding things too, so cheers to you as well!
The thing is you can't refer to the resulting compilation, there is a correspondence between CPS and SSA, and SSA lets the JVM better optimize things.
Let me try and better explain myself. For me, the qualification here is that the form exhibit semantics that are compatible with functional programming, and can be evaluated using a lambda calculus computational model. Those semantics are in turn incompatible with the imperative ones, since it prevents you from doing some things that imperative semantics would allow, as I demonstrated with my prior example.
The let form does dictate a series of expressions to be executed sequentially from top to bottom, but the binding of their result to a name is done functionally, not imperatively.
This still models a data-flow, and the fact it lets you shadow prior names doesn't change the functional nature of it.
The form simply expresses a composition of functions and how their inputs/outputs connects.
That means it doesn't actually require any separate mutable memory location, even if the Clojure implementation for it might use some.
That's inherently the conceptual model of a functional computation, and it is not that of an imperative one.
(let [a 1
b 2
a (+ a b)]
a)
Models a data-flow that you can think of as a multitree:
1 2
\ \
(+, , )
\
3
What that means is you never need the variables, they are simply a syntactic convenience, in fact that's why they are not called variables but bindings, they are simply syntactic labels, there is no real requirement to have a seperate memory location that sequential instructions will be allowed to mutate. So how I see it, that fact makes it functional, and not imperative.
If you look at the multitree, at each level of the multitree, you are free to evaluate the nodes in any order, but if there is an input/output dependency, then you must guarantee that ordering.
At the end of the day, we could argue forever that we just have different taxonomies. I consider imperative programming the computational model where you give instructions in sequence which dictates mutations on memory locations.
I consider functional programming the computational model where you declare a composition of expressions that can be reduced using variable substitutions.
With this in mind, let qualifies as a functional construct, because it defines such a composition and can be reduced through substitution.
Now in practice, the Clojure let form reduces over this sequentially both left to right on a per-form basis, and top/down between each pair of bindings. And since Clojure allows you to mix imperative anywhere, you can have a println in it and know that it'll run after all that came before, and before all that comes after. But semantically it is still functional. And that's why you can't modify the bindings, because they do not conceptually represent memory locations which you can mutate.