(defn better-cond
[& pairs]
(fn [& arg]
(label result
(defn argy [f] (if (> (length arg) 0) (f ;arg) (f arg))) # naming is hard
(each [pred body] (partition 2 pairs)
(when (argy pred)
(return result (if (function? body)
(argy body) # calls body on args
body)))))))
Most Lisps have `cond` like this:
(def x 5)
(cond
((odd? x) "odd") ; note wrapping around each test-result pair
((even? x) "even"))
Clojure (and children Fennel and Janet) don't require wrapping the pairs:
(def x 5)
(cond
(odd? x) "odd"
(even? x) "even")
My combinatoresque `better-cond` doesn't require a variable at all and is simply a function call which you can `map` over etc.:
((better-cond
(fn [x] (> 3 x)) "not a number" # just showing that it can accept other structures
odd? "odd"
even? "even") 5)
Of course, it can work over multiple variables too and have cool function output:
(defn recombine # 3 train in APL or ϕ combinator
[f g h]
(fn (& x) (f (g ;x) (h ;x))))
(((better-cond
|(function? (constant ;$&))
|($ array + -)) recombine) 1 2) # |( ) is Janet's short function syntax with $ as vars
But it's not composable as Janet's version, it will fail when mapped over, because it may return a plain value instead of a callable one. In Janet, all values can naturally participate in higher-order contexts due to its uniform treatment of callables, while in Clojure, only actual functions can be composed or mapped.
The drawback is that this approach elevates code blocks to first class. It means that there is a semantical difference between a value that is a block and a value that is a result of a block. This reduces code clarity, because now block def/result is discriminated by context instead of syntax.
- closures get tricky, i.e. having outer scoped variables within a block
- inter-block operators still need special care, e.g. return should return from a function or a nearest block, same for break/continue/etc.
This is interesting, but I'm not convinced it's better than the python it's being compared to. Memorizing and understanding the behavior of functions that perform control flow seems no easier than memorizing and understanding hardcoded syntax/keywords. The additional flexibility of making everything a first-class citizen allows people to write code that is too clever for its own good. I could be wrong but I think there is a broad consensus that reflection is a Bad Idea.
Open to being convinced otherwise
(tangent but related, aren't the "Loops" and "Iteration" examples given for python literally the exact same syntax, with the exception of changing how the iterable is generated?)
I wish they showed the `else` syntax, because the traditional ALGOL-style if-then-else statement doesn't look native when shoved into most function call notations, unless you have something pretty interesting around named parameters and expressions delimiters.
This discussion makes me so happy because people still care about programming languages and not just on stupid Java or whatever is making gobs of money. LISP should have a much larger following than it does, though I fully admit it has its own warts.
You can define its recursion principle by building a higher-order function that receives an element of your type and, for each constructor, receives a function that takes all the parameters of that constructor (with any recursive parameters replaced by `r`) and returns `r`.
For `List` this becomes:
foldr :: (() -> r) -> (a -> r -> r) -> List a -> r
The eliminator for `Nil` can be simplified to `r` as `() -> r` is isomorphic to `r`:
foldr :: r -> (a -> r -> r) -> List a -> r
foldr z f Nil = z
foldr z f (List a xs) = f a (foldr f z xs)
For `Bool`:
data Bool = True | False
We get:
bool :: a -> a -> Bool -> a
bool p q True = q
bool p q False = p
For lambda calculus, the motto is "when everything is a function".
The boolean true is the function λx.λy.x, while false is λx.λy.y.
If b then x else y then simply becomes b x y.
In a functional language like Haskell that is basically a typed lambda calculus with lots of syntactic sugar, we can replicate this with:
type MyBool a = a -> a -> a
myTrue :: MyBool a
myTrue = \x y -> x
myFalse :: MyBool a
myFalse = \x y -> y
myIf :: MyBool a -> a -> a -> a
myIf b myThen myElse = b myThen myElse
main = print $ myIf myTrue "true" "false"
I'm definitely missing some key insight because after reading the article I felt it was a strawman argument.
Python already has conditional expressions, which already allow 'x if (predicate) else y'. Therefore in Python if is already equivalent to a function, and is composable.
Once you realize this, and also understand that Python has logical operators that can short-circuit, all Python examples feel convoluted and required the blogger to go way out of it's way to write nonidiomatic Python. If the goal was to make a point with Python, why not write Python?
Just here to point out that the actor’s name is Bob Odenkirk not Odendirk. In a statically typed language this would be an error at compile time not hacker news comment time.
Interesting that this article makes no mention of eager vs lazy evaluation - isn’t a big reason that if, for etc has to be special forms in an eagerly evaluated language that their arguments need to be lazily evaluated, which of course, deviates from the rule?
Also, lazy evaluation is achieved in an eagerly evaluated language as simply wrapping a block of code in a function, which makes lazy evaluation isomorphic with the contents of the article
When everything is an expression, like rust (I think), that's enough. Most of the control flow are used as statements, but if needed, they can be used directly as expression, and you get basically all the advantages. After seeing how effective this is, using a language that doesn't have this feature can be really annoying.
On the other hand, I would like to explore "when arithmetics is just a function". I think Elm does this well: operators are just functions with two arguments that can be written as "1 + 2", the familiar way, or "(+) 1 2". Then you can compose it like "map ((+) 2)" (currying) so you get a function that adds 2 to every item of a list, and so on.
The Trade-offs section doesn't list the biggest one.
Secretly, all code wants to be spaghetti. You and your team have to put a conscious effort into prevent that from happening. Degrading the core of the language like this is like inoculating your homebrew with sewage and expecting it not to go wrong.
There's more to this that I'd absolutely love to see in a language, and I can't tell if rye supports. If you want an ergonomic `if` you need its scope aspects too.
Consider this example
<beginning of the function>
x = ...
if foo:
y = ...
else:
x = ...
y = ...
<rest of the function>
Critical parts of the ergonomics are that
a) in each branch, we have everything in scope that comes from <beginning of the function>
b) in <rest of the function>, we have everything in scope that was assigned or reassigned in the executed branch
I'd love a language that supports programmable stuff like if, since I'm tired of python autodiff not handling `if` and `for`. But it would really need programmable scope stuff to still allow the ergonomic "scope effects" that make `if` and `for` blocks ergonomic.
Sorry, but I don't understand the example. In Rye, as in REBOL all first level block evaluations (do, if, for, loop, ...) happend in current scope (context).
Rye has first class contexts and it's one of more exciting things to me about it, but I'm not sure it's related to what you describe above. More on it here:
One thing that pops out at the example above is that I wouldn't want to define a y inside if block, because after the if you have y defined or not defined at all, at least to my understanding of your code.
Rye is constant by default, most things don't change and you need less veriables because it's expression based and it has left to right "flow" option. So you only make variables where you really need them, which is less common and specific, it should be obvious why you need a variable.
10 days later... sorry, I didn't see your comment.
The example I gave had a few pieces:
- x is defined prior to the if/else, and overwritten in just one branch
- y is defined in both branches
So in the rest of the function, we have both x and y available, regardless of which branch is taken.
I just took a quick read of the context page and the context basics page, but it's still unclear to me whether you can program how scopes/contexts interact in rye.
In my example, we I'd say we have a few different scopes worth mentioning, and I'm curious how programmably we can make them interact in rye:
Scope 1. Right below the first x = ...: we have names available form <beginning of the function> and have x available as the ... stuff. Presumably the `foo` in `if foo` lives in this scope.
Scope 2T. Right after the true branch's y, we have scope 1 plus y introduced
Scope 2F. Right after the false branch's x and y, we have scope 1 plus x "pointing to" something new and y introduced.
Scope 3. Below the if/else, where <rest of the function> lives. This is either scope 2T or scope 2F. x is either scope 1's x or scope 2F's x, and y is either scope 2T's y or 2F's y.
In the original articles language,
So the scope relationships in an if/else are a diamond DAG taking the names from before it, making them available in each branch's scopes, and then making a sorta disjoint union of the branch's names available afterwards. Could that be programmed in rye, to allow the kinds of naming ergonomics in my previous example, but with the if/else being programmable in the sense of the original article? I'm especially interested in whether we could overload it in the traditional autodiff sense.
Responding to a different part of your comment about using names rarely in rye, I've found that I benefit a ton from handing out names more than most people do in functional languages, just for clarity and more self-documenting code. Like, the ide can say "`apples` is broken" instead of "error in location xyz" and I can rebuild my mental state better too when revisiting code.
Using closures or code blocks when they're unnecessary gets in the way of static analysis and (sometimes) readability. It's often better to avoid using language constructs that are more powerful than needed for the job. You can still use them when doing something more advanced.
30 comments
[ 2.8 ms ] story [ 49.4 ms ] threadI only know about: https://github.com/Engelberg/better-cond in clojure which is different it adds syntax enhancement + control flow convenience.
Similar better-cond can be written in clojure too:
But it's not composable as Janet's version, it will fail when mapped over, because it may return a plain value instead of a callable one. In Janet, all values can naturally participate in higher-order contexts due to its uniform treatment of callables, while in Clojure, only actual functions can be composed or mapped.- closures get tricky, i.e. having outer scoped variables within a block
- inter-block operators still need special care, e.g. return should return from a function or a nearest block, same for break/continue/etc.
Open to being convinced otherwise
(tangent but related, aren't the "Loops" and "Iteration" examples given for python literally the exact same syntax, with the exception of changing how the iterable is generated?)
=if(condition, value-if-true, value-if-false)
For `List` this becomes:
The eliminator for `Nil` can be simplified to `r` as `() -> r` is isomorphic to `r`: For `Bool`: We get: Which is precisely an If statement as a function!:D
Python already has conditional expressions, which already allow 'x if (predicate) else y'. Therefore in Python if is already equivalent to a function, and is composable.
Once you realize this, and also understand that Python has logical operators that can short-circuit, all Python examples feel convoluted and required the blogger to go way out of it's way to write nonidiomatic Python. If the goal was to make a point with Python, why not write Python?
On the other hand, I would like to explore "when arithmetics is just a function". I think Elm does this well: operators are just functions with two arguments that can be written as "1 + 2", the familiar way, or "(+) 1 2". Then you can compose it like "map ((+) 2)" (currying) so you get a function that adds 2 to every item of a list, and so on.
Secretly, all code wants to be spaghetti. You and your team have to put a conscious effort into prevent that from happening. Degrading the core of the language like this is like inoculating your homebrew with sewage and expecting it not to go wrong.
> experimental
These ideas have been tried and tested for 60 years now and result in less spaghetti.
Consider this example
Critical parts of the ergonomics are thata) in each branch, we have everything in scope that comes from <beginning of the function>
b) in <rest of the function>, we have everything in scope that was assigned or reassigned in the executed branch
I'd love a language that supports programmable stuff like if, since I'm tired of python autodiff not handling `if` and `for`. But it would really need programmable scope stuff to still allow the ergonomic "scope effects" that make `if` and `for` blocks ergonomic.
Rye has first class contexts and it's one of more exciting things to me about it, but I'm not sure it's related to what you describe above. More on it here:
https://ryelang.org/meet_rye/specifics/context/
One thing that pops out at the example above is that I wouldn't want to define a y inside if block, because after the if you have y defined or not defined at all, at least to my understanding of your code.
Rye is constant by default, most things don't change and you need less veriables because it's expression based and it has left to right "flow" option. So you only make variables where you really need them, which is less common and specific, it should be obvious why you need a variable.
The example I gave had a few pieces:
- x is defined prior to the if/else, and overwritten in just one branch - y is defined in both branches
So in the rest of the function, we have both x and y available, regardless of which branch is taken.
I just took a quick read of the context page and the context basics page, but it's still unclear to me whether you can program how scopes/contexts interact in rye.
In my example, we I'd say we have a few different scopes worth mentioning, and I'm curious how programmably we can make them interact in rye:
Scope 1. Right below the first x = ...: we have names available form <beginning of the function> and have x available as the ... stuff. Presumably the `foo` in `if foo` lives in this scope.
Scope 2T. Right after the true branch's y, we have scope 1 plus y introduced
Scope 2F. Right after the false branch's x and y, we have scope 1 plus x "pointing to" something new and y introduced.
Scope 3. Below the if/else, where <rest of the function> lives. This is either scope 2T or scope 2F. x is either scope 1's x or scope 2F's x, and y is either scope 2T's y or 2F's y.
In the original articles language,
So the scope relationships in an if/else are a diamond DAG taking the names from before it, making them available in each branch's scopes, and then making a sorta disjoint union of the branch's names available afterwards. Could that be programmed in rye, to allow the kinds of naming ergonomics in my previous example, but with the if/else being programmable in the sense of the original article? I'm especially interested in whether we could overload it in the traditional autodiff sense.
Responding to a different part of your comment about using names rarely in rye, I've found that I benefit a ton from handing out names more than most people do in functional languages, just for clarity and more self-documenting code. Like, the ide can say "`apples` is broken" instead of "error in location xyz" and I can rebuild my mental state better too when revisiting code.
Can you also put code into { 6 }, like { print(6) }?
Does this overwrite R's if or how does the scoping in R work?