Array Languages #16: How X_eTaL Evolved, Decision by Decision
2988 words • 15 min read • Abstract

X_eTaL is an array language in the APL tradition that you can type on an ordinary keyboard. Its source is plain ASCII, r_ev with an underscore after a letter, and the tools draw it as notation, r̲ev with the letter underlined, ← for :=, ⍺ and ⍵ for a lambda’s arguments, as you type. From APL it takes arrays as the only data, right-to-left reading with no precedence, and quads for the system. From BQN and J, the leading axis and the atop. From Haskell it takes the types, inference and numeric classes, but not the evaluation: X_eTaL is strict, with laziness only where a parameter asks for it. From Rust it takes the macros, raw strings, doc tests, a clippy-style linter and error messages that point at the line. Functions are curried, every function takes its function arguments first, and Smullyan’s birds are a library. And where state did not fit in arrays, it took tuples. K and Uiua appear on X_eTaL’s comparison stone, not as sources of its design.
| Resource | Link |
|---|---|
| The decisions | lang-choices.md and the register in design.md (section 9, D1 to D185) |
| The history | history.md and the asks ledger |
| The series | Array Languages: every post |
| Comments | Discord |
How it changed: page faults
The first evening settled the core, one question at a time: fifty numbered questions, a few sub-questions and six review items, each answered by me and recorded as a decision. The register of decisions had 20 entries at the end of that first day and has 185 three and a half weeks later. Almost none of the later ones came from a plan. They came the way a page fault is resolved: a program tried to do something, and the language either could not or made it clumsy. A library needed an error of its own; a macro dropped part of its text and nobody noticed; a training loop packed three arrays into one vector to get them through a loop. Each time the language answered in one of three ways: a feature, a choice among alternatives written down with the reasons, or a refusal.
The mechanism is a ledger. Every sibling repository, the libraries, the machine learning, the extensions, the games, files its needs as asks with a code, and X_eTaL answers each one: landed, planned, or declined with a reason. Today the ledger holds 38 landed, 12 partly landed, 20 planned, 11 declined and 20 not yet decided. The rule the history states is short: “a workaround copied in more than one place is evidence, and a general mechanism moves down into the core.”
The choices, in order
| # | Choice | Why, or instead of what |
|---|---|---|
| 1 | Source is ASCII; decoration is display only | APL needs a special keyboard; J and K’s digraphs are hard to read |
| 2 | An underlined letter makes a name a function: r_ev |
letters mainly; no overstrike; one underline per name |
| 3 | Built-ins are words, r_eshape, one name each |
a single letter cannot stand for every word that starts with it; no aliases |
| 4 | Right to left, no precedence; := drawn as ← |
APL’s reading, typed in ASCII |
| 5 | Namespaces are typed prefixes, u:, l:, drawn raised; the importer picks the alias |
prefixes are typeable and unambiguous |
| 6 | One arity per function; dyadic use is currying | instead of APL’s ambivalent functions |
| 7 | A quoted function is an operand: '+ r_/ v; function arguments first |
instead of a separate class of operators |
| 8 | Leading axis by default | as in J and BQN, instead of APL’s last axis |
| 9 | Index origin 1, not configurable | APL’s origin, and Julia’s, without APL’s ⎕IO setting |
| 10 | = is exact; e̲q~ compares with a fixed tolerance |
instead of APL’s ⎕CT setting |
| 11 | Hindley-Milner inference, Haskell-style numeric classes, checked before running | Haskell’s types, with no annotations required |
| 12 | Strict evaluation, with ~ for a lazy parameter |
so the Y combinator works without making everything lazy |
| 13 | Trains in brackets: [F G H] a fork, [F G] an atop |
instead of implicit trains, and instead of J’s hook |
| 14 | Values are immutable; a name with ! is mutable |
instead of a mut keyword |
| 15 | Macros are names ending in <; imports are "k:" u̲se< "Lib" |
instead of a use keyword; the alias is always required |
| 16 | Lambda arguments drawn ⍺ and ⍵ |
the first drawing, subscript l and r, was unreadable in a terminal font |
| 17 | Nested arrays with a static Box type, shown like APL2’s DISPLAY |
static depth, so the type checker still sees the structure |
| 18 | Strings and comments may hold any Unicode; code stays ASCII | so a library could greet with its drawn logo |
| 19 | Smullyan’s birds as a library | combinators as ordinary functions |
| 20 | A steppable evaluator, so a program can wait for input in the browser | chosen over re-running per line and over a sleeping worker; no WASI build |
| 21 | Macro libraries, .xtlm, exporting under m: |
asked for by the libraries, ML and extensions alike |
| 22 | System macros written in X_eTaL, modeled on Rust’s: f̲ormat<, d̲bg<, a̲ssert< |
which ones: “the test Rust’s std uses” |
| 23 | Errors of one’s own: signal, trap, retry, recover | a library asked for assert, raise and catch; typed, not an untyped exception |
| 24 | Doc comments with examples that run as tests | as rustdoc runs them |
| 25 | h: for a library’s private names |
a private helper could silently hide a future built-in |
| 26 | Tuples, (w, m, v, k), taken apart by patterns only |
a training state of several arrays had been packed into one vector |
| 27 | Names bound once per scope | instead of shadowing, which hid mistakes |
| 28 | A linter, levels in ordinary comments, fixes git can undo | clippy’s model |
| 29 | Macro hygiene by choice, with fresh names on request | instead of automatic renaming, which guessed wrong |
| 30 | One body per function, checked once | instead of a copy per argument type, as Julia makes |
| 31 | Raw strings, r"...", by Rust’s rules |
multi-line macro arguments without escapes |
| 32 | Equations and pipelines as libraries of macros | no compiler change; kept out of the grammar |
| 33 | Plain names: ᵉe̲q<, not a pun |
a name must read under any alias |
| 34 | Macro contracts: ## takes:, ## uses:, ## binds: |
after a macro dropped part of its text in silence |
| 35 | Errors at file, line and column, with the spot underlined | as rustc does |
| 36 | Code strings, c"...", checked as X_eTaL |
a macro’s X_eTaL argument was opaque to every tool |
| 37 | A macro’s notation drawn by its own library | an equation shown as typeset math |
| 38 | _u, drawn ▁, for a parameter nothing reads |
instead of a lint directive on every such line |
Rows 1 to 19 were settled in the first days; the rest arrived as the libraries and other repositories found something missing. A few deserve more than a row.
Laziness, only where asked for
I wanted the Y combinator to work in its textbook shape, Y f = f (Y f). In a strict language that recurses forever before it does anything, and the usual fix is a different combinator, Z, which hides the recursion behind an extra lambda. The other fix is to make the whole language lazy, as Haskell is, and pay for it everywhere. Neither was what I wanted. The answer was a mark on one parameter: ~ makes that argument lazy, evaluated where the body first reads it and never if it does not. Everything else stays strict. The factorial, from a lambda that receives itself:
ᵘf̲n ← { ~s̲elf n → n ≤ 1 ? 1◆ n × s̲elf n − 1 }
'ᵘf̲n ᶜY̲ 5
That gives 120. The same mark later turned out to be useful for an argument a function does not need at all, below.
Trains, marked
Trains came early: a fork, three functions where the outer two see the argument and the middle one joins their results, and an atop. APL and J write a train as functions side by side, and what it means depends on how many there are. In J, two functions in a row are a hook, a different combinator from three. X_eTaL marks a train with brackets instead, and takes BQN’s and Dyalog’s atop for two:
ᵘa̲vg ← ['+ r̲/ ÷ t̲ally]
That is the mean, the sum divided by the count. The brackets cost two characters, and buy one rule: a row of names without them always means application, right to left. A train becomes ordinary application before it runs, so the evaluator never sees one. Named dyadic trains were asked for and declined: a named two-argument function is a lambda.
Names bound once
At first, binding a name again made a new binding that hid the old one, as in most functional languages. Two weeks in, a survey of X_eTaL and its sibling repositories found 235 bindings that a stricter rule would break, and 106 of them were throwaway names. Shadowing had been hiding mistakes. The rule became: a name without ! is bound once per scope, _ := expr keeps a value only for its effect, and in an interactive session []E_X removes a name so it can be bound again. Each sibling repository got a notice listing its sites to change.
Macros: from hooks to hygiene to contracts
Macros started as one hard-coded import macro. The libraries, the machine learning and the extensions all asked for macro libraries of their own, and that is where most of the language’s later growth came from. Three changes in particular came straight out of library work.
Hygiene. At first a macro’s names were renamed automatically, Scheme’s way, so they could not collide with the caller’s. Building the Tags library broke it: when a macro took its argument apart, the renaming changed a name it wrote but not the reference to it, because it had to guess from bytes which text the macro had copied. Hygiene became a choice, as in Common Lisp: a macro asks for a fresh name when it needs one, and lints catch the captures the renaming used to prevent.
Contracts. The Equations library summed (y[i] - p[i])^2 as y[i] - p[i]: its reader dropped the ^2, and the answer was wrong in silence. The response had a limit set on purpose: I did not want “a whole other meta language”, only to “prevent preventable errors”. A macro now declares how it is called, ## takes:, and whether it copies its text or parses it, ## uses:. For a copying macro the runner checks that every name and number of the call reaches the expansion; for a parsing one, a new Parsing library makes every reader use all of its text or refuse.
Code strings and notations. A macro’s argument that is itself X_eTaL was one string to every tool: not drawn, not formatted, not linted. A c in front, c"...", now says its inside is code, so it is drawn and checked like the rest of the file. And a macro side can be declared a notation, drawn by its own library, which is how an equation written as a string appears as typeset math.
Tuples, and no more
The machine learning repository packed a training state, 99 weights and Adam’s two running averages, into one vector of 298 numbers to get it through a loop, and a demo packed a game’s state the same way. Tuples arrived for that: (w, m, v, k), taken apart by patterns and never confused with arrays. The rule I gave them was narrow: use a tuple only when it adds value over an array. Records, sum types and pattern matching in the ML style were planned next and then not wanted: the language stays array-first and small.
_u, for an argument nothing reads
A function that must accept an argument and ignore it, a combinator such as the Kestrel, or a lazy guard that is never forced, used to need a name and a lint directive to silence the unused-name warning: the warning was wrong, and the source had to say so. Now _u, drawn ▁, is a parameter nothing can read, so there is nothing to warn about. With ~ it is also never computed:
ᵘk̲eep ← { a ~▁ → a }
7 ᵘk̲eep 1 ÷ 0 gives 7, and the division never happens.
What it refused, to stay small
Most of what X_eTaL left out was left out to keep it from growing without a reason. Some of the refusals, with the reason recorded at the time:
- A notation string,
n"...", beside the code string: “declined as a creeping feature”. It would have cost a lexer kind, a formatter case and a lint to carry one fact the macro’s contract already states. - A raw code string,
cr"...", and nested code strings: one kind of code string, one level deep, was enough. - Keywords. No
mutand nouse: mutation is a!on a name, and an import is a macro. “Avoid introducing keywords” was the rule from the first evening. - Aliases for built-ins: one name per built-in, so there is one thing to learn and to read.
- Equations in the grammar: a library of macros instead, with no change to the compiler.
- Records, sums and matching in the ML style: not wanted; the language stays array-first.
- Lisp’s reader macros, and macros in the style of Rust’s syn and quote: the first lets a program redefine the language by accident, the second is more machinery than the problem needs; contracts sit in between.
- Macros used because they can be: a macro in a demo, or an extension, has to earn its place.
- A copy of each function per argument type, as Julia makes: one body, checked once.
- Shapes in types: asked for three times, declined three times; array types do not carry their rank, and shapes are checked when the program runs.
- Regular expressions in the core: a library can bring them, from a Rust crate.
- APL’s del editor, and a build that needs Node: a terminal editor in Rust, and nothing else in the build.
- Implicit trains and J’s hook: a second reading for a row of names; the brackets keep one. Named dyadic trains: a lambda says it. Haskell’s sections: a function given one argument is an error with the fixes named.
- Settings for the index origin and the comparison tolerance, which APL has: X_eTaL fixes the origin at 1 and puts the tolerance in a function of its own,
e̲q~, so no setting changes what a program means.
Still open
Some questions are written down and left open, each to be decided the same way: whether a̲ssert< should stop the program, as Rust’s assert! does; algebraic data in an array-first form; complex numbers, which the FFT is waiting for; macros defined in a program itself rather than only in a macro library it imports; and how libraries are shared. For now there is no package manager: libraries come in through u̲se<, with an alias chosen by the program, and the sibling repositories are the ecosystem.
Part 16 of the Array Languages series. View all parts
Comments or questions? SW Lab Discord or YouTube @SoftwareWrighter.