Rabbit-hole #7: The Phoenix Combinator, and Why X_eTaL Brackets Its Forks
1783 words • 9 min read • Abstract

LeetCode 1979 is a small problem: given an array of numbers, return the greatest common divisor of its smallest and its largest. For 2 5 6 9 10 that is the GCD of 2 and 10, which is 2. (Not the GCD of the whole array, which is 1: the title says “of array”, but the problem means its two extremes.)
In 2021 Conor Hoekstra solved it in sixteen languages on his code_report channel, with each solution on GitHub. I asked an agent how to write it in X_eTaL, and the answer turned into a question about notation: why does X_eTaL need brackets for something APL and J write with no brackets at all?
| Resource | Link |
|---|---|
| The problem | LeetCode 1979: Find Greatest Common Divisor of Array |
| Sixteen languages | code_report: 1 Problem, 16 Programming Languages and the follow-up; the solutions in codereport/LeetCode (0255_Problem_1.*) |
| X_eTaL | Numbers (g_cd), and trains in lang-choices.md (TR1 to TR4) |
| The series | Array Languages #1: X_eTaL |
| Comments | Discord |
APL and J: three functions side by side
In APL ∨ is GCD (on Booleans it is “or”, and GCD is how it generalizes). Hoekstra’s solution:
findGCD ← ⌈/∨⌊/
Three functions in a row, ⌈/ (the largest), ∨ and ⌊/ (the smallest), with nothing between them, form a train. A train of three is a fork: (f g h) x is (f x) g (h x). That works in Dyalog APL. It does not work in the APL2 dialect that GNU APL implements, which has no trains; there the three are read as ordinary functions and the line is a SYNTAX ERROR. The explicit form runs in both:
{(⌈/⍵)∨⌊/⍵} 2 5 6 9 10
2
Trains come from J, where the hook and the fork first appeared (from Iverson and McDonnell’s “Phrasal Forms”, 1989), and J has the same fork in ASCII: >./ is the largest, <./ the smallest, +. GCD:
findGCD =. <./+.>./
> findGCD each (<2 5 6 9 10),(<7 5 6 8 3),<3 3
2 1 3
The fork is not marked anywhere. What makes the three functions a fork is that they are three functions with nothing after them, and how many there are matters: in J two functions side by side are a hook, (f g) y is y f (g y), a different combinator. So (<./ >./) 2 5 6 9 10 is not the smaller of the largest; it is each number’s smaller with 10:
(<./ >./) 2 5 6 9 10
2 5 6 9 10
BQN, Hoekstra’s third array language, has the same fork, ⌈´GCD⌊´, with a GCD it defines first, since BQN has no GCD primitive.
Haskell: liftM2
import Control.Monad (liftM2)
findGCD = liftM2 gcd minimum maximum
There is no argument at all. For functions, liftM2 f g h is the function \x -> f (g x) (h x): give the same x to g and to h, then combine their results with f. Here that is gcd (minimum xs) (maximum xs). It is the same fork as APL’s and J’s, and combinatory logic has names for it too: the S’ combinator, or Curry’s Phoenix.
Lisp: naming the argument
The direct solution names the array and says what to do with it. Hoekstra’s Racket:
(define (find-gcd nums)
(gcd (apply min nums)
(apply max nums)))
His Clojure is the same, with gcd from clojure.math.numeric-tower. Nothing here is hard to read, and nums appears twice: once for the smallest, once for the largest. The forks above are ways of not writing that name.
X_eTaL: the same fork, in brackets
New: the Phoenix. The fork is also a bird, Curry’s Phoenix, just added to X_eTaL’s Combinators library as ᶜP̲hi (9e66749). With it the solution reads like Haskell’s liftM2 gcd minimum maximum:
"n:" u̲se< "Numbers"
"c:" u̲se< "Combinators"
'['m̲ax r̲/ i̲d] '['m̲in r̲/ i̲d] 'ⁿg̲cd ᶜP̲hi 2 5 6 9 10
A bird takes quoted functions, and a quote touches one name, a lambda or a train, so each reduction is wrapped in a train: ['m̲ax r̲/ i̲d] is an atop, the largest of the array itself (i̲d is the identity).
X_eTaL’s GCD is in the Numbers library, Euclid’s algorithm applied item by item:
ʰe̲uclid ← { a b → b = 0 ? a̲bs a◆ b ʰe̲uclid a m̲od b }
ˡg̲cd ← { a b → a 'ʰe̲uclid e̲ach b }
Imported under the alias n: (the importer chooses it; n: for Numbers), 12 ⁿg̲cd 8 9 10 0 gives 4 3 2 12. The LeetCode function, naming its argument as the Lisp does:
"n:" u̲se< "Numbers"
ᵘf̲indGcd ← { nums → ('m̲in r̲/ nums) ⁿg̲cd 'm̲ax r̲/ nums }
and as a fork, with no name for the argument:
"n:" u̲se< "Numbers"
ᵘf̲indGcd ← ['m̲in r̲/ ⁿg̲cd 'm̲ax r̲/]
ᵘf̲indGcd 2 5 6 9 10
ᵘf̲indGcd 7 5 6 8 3
ᵘf̲indGcd 3 3
2
1
3
'm̲in r̲/ is the minimum reduction (a quoted function given to reduce), 'm̲ax r̲/ the maximum, and ⁿg̲cd combines them. Both versions pass the linter without a finding. Take the brackets away and the line is an error:
error[type-mismatch]: expected a number, found Int -> Int
--> c.xtl:2:1
|
2 | 'm_in r_/ n:g_cd 'm_ax r_/ 2 5 6 9 10
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| --------------------------- this is an Int -> Int
= help: this is a function still waiting for an argument: give it one, or quote it ('f_) to pass the function itself
Why the brackets
Without brackets X_eTaL reads that line the only way it reads any line: right to left, each function applied to what is on its right. 'm̲ax r̲/ takes the array and gives 10. ⁿg̲cd takes 10 as its right argument and is still waiting for its left one, and nothing gives it one. There is no second reading to fall back on.
That is a choice. A fork needs one thing that plain application cannot do: hand the same argument to two functions. Composition does not need it. In X_eTaL n̲eg a̲bs -5 is already “negate the absolute value”, with no brackets, because each function takes the result of the one on its right. Duplicating the argument is the new part, and every language above marks it somehow. APL and J mark it by position: three functions in a row, with no argument between them, are given a meaning they do not have anywhere else, and two in a row are given yet another. Haskell calls liftM2, and X_eTaL can call the Phoenix. The Lisp names the argument and writes the name twice.
X_eTaL marks the fork with brackets (TR1): [F G H] x is (F x) G (H x). The brackets also settle what two functions mean. In X_eTaL [F G] x is F (G x), as in BQN and Dyalog, not J’s hook (TR2), and the same two functions in brackets give the smaller of the largest:
ᵘf̲indGcd ← ['m̲in r̲/ 'm̲ax r̲/]
ᵘf̲indGcd 2 5 6 9 10
10
Longer trains group from the right into forks (TR3), and a train turns into ordinary application before it runs, so the evaluator never sees one. One rule decides this: the language never chooses between two readings of the same text. A run of names means application, always, and the one construction that means something else is written so that it looks different.
Many ways to say it
| Language | Solution | The fork is |
|---|---|---|
| APL (Dyalog) | ⌈/∨⌊/ | three functions in a row |
| J | <./+.>./ | three functions in a row (two would be a hook) |
| Haskell | liftM2 gcd minimum maximum | a library function, liftM2 |
| Lisp (Racket) | (gcd (apply min nums) (apply max nums)) | not point-free: names its argument twice |
| X_eTaL | { nums → ('m̲in r̲/ nums) ⁿg̲cd 'm̲ax r̲/ nums } | not point-free: names its argument twice |
| X_eTaL | ['m̲in r̲/ ⁿg̲cd 'm̲ax r̲/] | a train of three in brackets |
| X_eTaL | '['m̲ax r̲/ i̲d] '['m̲in r̲/ i̲d] 'ⁿg̲cd ᶜP̲hi | the Phoenix, given trains |
| X_eTaL | '{ 'm̲ax r̲/ ⍵ } '{ 'm̲in r̲/ ⍵ } 'ⁿg̲cd ᶜP̲hi | the Phoenix, given lambdas |
The array languages are the shortest, and X_eTaL pays two characters for its brackets. In return, a row of names in X_eTaL always means the same thing, and counting the functions never changes it.
Part 7 of the Down the Rabbit-Hole series. View all parts
Comments or questions? SW Lab Discord or YouTube @SoftwareWrighter.