ludus/doc/introduction.md
2025-01-11 15:31:37 -05:00

20 KiB

Ludus for programmers

A brief introduction

Ludus is mostly understood by its research and design team as a language for learners. It is a pedagogical language, whose primary purpose is to lead students to critical encounters with the history and present of computing. The design principles, then, lead with learnability as well as making certain key examples in the history of computing easy.

Because of that, Ludus has some weird features. It will likely not feel especially familiar, particularly if you have not written funtional code before. We encourage you to feel disoriented by it, and to lean into that disorientation. Instead of trying to write code like you have in the past, write code like Ludus wants you to.

There are two big influences on Ludus. In terms of historical languages, Ludus draws a lot from Logo and Scheme. In terms of contemporary languages, Ludus has deep affinities with Elixir and Clojure. To put it in an abstraction cluster, Ludus is a dynamically typed, extremely strict functional programming language with persistent data structures, deep immutability, and pattern matching. None of these are especially mainstream.

It is not "batteries included," but rather offers a quite minimalistic set of capabilities. These are devised, as I noted above, to make encountering key concepts from the history of comptuing easy. But beyond that, Ludus is extremely minimal, in the tradition of Scheme and Logo. The profound pedagogical perspective behind Scheme and Logo is that building the things you want is an important motivator for learning how to make computers do things. Ludus follows in this path.

If you've mostly written object-oriented code, Ludus will, frankly, feel weird. And that's awesome.

Ludus is expression based

Ludus has no statements, only expressions. Every expression returns a value, including conditional forms like if, when, and match.

In Ludus, different types of expressions are called forms, riffing on the grand Lisp tradition.

Ludus is dynamically typed

Like its inspirations, Elixir and Clojure and the whole family of Lisps, Ludus is dynamically typed. It is strictly typed, however. Unlike Javascript, Ludus will never convert between values of one type or another. Ludus has the following types:

  • :nil: The type of nil, Ludus's name for nothing.
  • :bool: Boolean--true or false.
  • :number: IEEE-754 64-bit floating point numbers. Ludus does not have an integer type. That said, Ludus avoids NaN as much as possible.
  • :string: UTF-8 strings.
  • :keyword: Keywords are self-identical atoms, evaluating only to themselves. The equivalent of a Symbol in Javascript (or a keyword in Clojure or Elixir). (The types in this list--and in Ludus--are represented as keywords.)
  • :tuple: Fixed-length, fully immutable collections of zero or more values. Tuples are comma-or-newline separated values, surrounded by parentheses: (1, 2, 3).
  • :list: Persistent, immutable ordered list of any number of Ludus values. Lists are comma-or-newline separated values, surrounded by square brackets: [:foo, :bar, :baz].
  • :dict: Persistent, immutable associative collection of keyword keys and any Ludus values. Dicts are comma-or-newline separated keyword-and-value pairs, introduced by #{ and closed with a curly brace: #{:a 1, :b 2}.
  • :fn: Functions!
  • :box: A holder for any value, which can change over time. A cognate of Clojure's atom. This is the only place in Ludus you will find mutable state.

At current, three other types are planned but not implemented: :set, :pkg, :process.

Ludus does not allow creating new types.

Ludus has a weird comment character

It uses the ampersand--&--to introduce comments. It does not have mulitline comments.

Ludus does not have variables, it has bindings

The basic form of assignment in Ludus looks very familiar:

let foo = 42 
let bar = :quux
let baz = "hello, world"

These are let bindings.

Let bindings are extremely immutable

They may not change. In addition, you may not shadow let bindings. You may not shadow a binding, like in Rust, where you can re-use the name and discard the old binding. You may also not bind the same name in a nested scope (e.g., inside a function). Once you bind a name, it is forever bound to that value. The value in this is that language learners need (almost) never wonder what value a name is bound to, since it can never change.

Except, of course, with function calls.

The left-hand side of a let binding is a pattern

Ludus makes extensive use of pattern matching. The left-hand side of a let binding need not be a simple name. A simple name is only one kind of pattern.

For example, this is valid Ludus:

let foo = 42
let 42 = foo
let nil = nil

The second line does nothing except match the value on the left hand side to the value on the right hand side. If a let binding does not match, e.g., let 1 = 2, then Ludus will panic.

Patterns can also be used to destructure all Ludus collections:

let (:ok, x) = (:ok, 42) & tuple pattern: x is now 42
let [l, m, ...] = [1, 2, 3] & list pattern: l = 1, m = 2
let #{a, b} = #{:a 1, :b 2} & dict pattern: a = 1, b = 2

Collection patterns are exact & complete, unless otherwise specified

In the second line in the example above, the pattern [l, m, ...] includes a splat pattern (or splattern). If we had written let [l, m] = [1, 2, 3], Ludus would have panicked with no match. There are three list members on the right, only two on the left. The splat, ... (or ellipsis) matches "anything else in the list." You may also include a name after the splat, which will be bound to "anything else in the list," e.g.

let [head, ...tail] = [1, 2, 3, 4, 5]
head &=> 1
tail &=> [2, 3, 4, 5]

The placeholder is a special pattern

A placholder pattern, _, matches against anything but does not bind a name. Also, you may name your placholders, e.g., _ignored, but that is for the programmer only. Named or unnamed placeholder patterns are strictly equivalent.

Ludus panics

Ludus has exactly one type of runtime error: a panic. Panics will always crash the program. You cannot catch a panic.

You can raise a panic thusly:

panic! "oh shit"

panic! may only take a single value, but that value can be a collection.

Eventually (not long from now!), Ludus will have actor-style concurrency, and a panic will only bring down a process. But this is not yet implemented.

Almost everything is a function

Ludus does not have operators. In the grand Lisp tradition, all operations look like (and, for the most part, substantively are) function calls.

  • To add two numbers in Ludus: add (1, 2) &=> 3.
  • To subtract one number from another: sub (2, 1) &=> 1
  • To determine, are two things equal?: eq? (3, "three") &=> false

The Prelude is loaded before every evaluation

The Ludus Prelude is its standard library, and all functions in the Prelude are available in every Ludus script. Consult the Prelude documentation for information for all functions in Prelude. Everything you'll want to do with Ludus involves the Prelude in some way.

Note that most Prelude function names can, in fact, be shadowed by local bindings in a script. That said, there are several functions that, for optimization reasons, are "builtin," whose names may never be used, e.g., add, sub, eq?, inc, dec, and so on.

Boolean functions are "special forms"

and and or are special, in that they are compiled differently than other functions. Their arguments are evaluated lazily, rather than eagerly, so they can short-circuit (and prevent panics).

Ludus lists and dicts are persistent

Dicts and lists are persistent. This means you cannot mutate them. However, you can still add things to a list--you just get back a new list with the value added:

let foo = [1, 2, 3]
let bar = append (foo, 4) &=> [1, 2, 3, 4]

let baz = #{:a 1, :b 2}
let quux = assoc (baz, :c, 3) &=> #{:a 1, :b 2, :c 3}

foo &=> [1, 2, 3]
baz &=> #{:a 1, :b 2}

Persistent data structures are wonderful, and use a lot of computer science magic to make them competitive in terms of performance: they use "structural sharing" and attempt "opportunistic mutation."

Ludus has three conditional forms

The three conditional forms in Ludus are if, when, and match.

The if form

Ludus's base conditional form is if:

if foo then bar else baz

Does what you'd expect! But with two caveats.

(Before the caveats: you can put newlines before then and else.)

Falsy falues: nil and false

The condition (foo in the example above) is evaluated not strictly as true or false. Ludus "falsy" values are nil and false. Everything else is truthy, including 0 and () (the empty tuple), and "" (the empty string). This holds across anywhere in the language you are dealing with notions of truth and falsity: if and when forms, guard expressions in match forms, and and or, etc.

Both then and else are obligatory

if forms in Ludus must have both then and else branches. This is because every expression in Ludus must return a value. If you want to throw away a value, you can do that, but you'll need something like else nil or else :nothing.

The when form

If you have multiple conditions you'd like to chain together, when forms are what you want. (Ludus does not have an else if form.) when puts multiple clauses together, each of which has a left-hand condition expression and a right-hand body expression: <condition expr> -> <body expr> Ludus will evaluate the left-hand expression, and, if it's truthy, evaluate and return the corresponding right-hand expression:

when {
  eq? (1, 2) -> :nope
  eq? (3, 4) -> :not_this_either
  eq? (0, 0) -> :this!
} &=> :this!

If no clause in a when form has a truthy left-hand side, Ludus panics.

Any truthy value will do if you want the equivalent of an else branch. By convention, :else is used as the catch-all at the end of a match form.

The match form

A match form is the equivalent of a switch statement in C-family languages. It is much more powerful, however. match is much beloved by functional programmers. match forms are similar to when forms, but they require a value--a "scrutinee." And, in place of expressions on the left-hand side of their clauses, they have patterns: <pattern> -> <expr>. They attempt to match the value against each pattern until there is a match. If no clause matches, then Ludus panics.

This is an extremely common pattern in Ludus:

let might_fail = (:ok, 42)
match might_fail with {
  (:ok, value) -> print! ("got {value}!")
  (:err, _) -> print! ("the thing failed")
} &=> :ok, prints "got 42!"
Match clauses may have a guard expression

A match clause may also have a guard expression. Afer the pattern and before the arrow, you may put if <expr>. (Here you may not use then or else.) Bindings made in the pattern are valid in that expression. If the expression is truthy, then that's a match. If it's falsy, no match:

let foo = 42
let bar = 23
match bar with {
  x if even? (x) -> :even
  x if eq? (foo, x) -> :foo
  _ -> :odd_not_foo
} &=> :odd_not_foo

Ludus groups expressions together with blocks

A block groups expressions together. Ludus blocks must have at least one expression (because everything in Ludus must return a value). A block evaluates to its last expression. Expressions are separated by one or more terminators--newlines or semicolons. Use curly braces to form a block:

if true
  then {
    :first; :second & these are two different expressions
    :third
  }
  else {
    :nothing
  } &=> :third

Blocks can go most anywhere expressions can go.

Ludus has synthetic expressions

We have already seen function calls, e.g., add (1, 2). This is a synthetic expression, which is a chained combination of bound names, tuples, and keywords. The root of a synthetic expression may be either a name or a keyword. Subsequent terms must either be tuples or keywords. They are evaluated by applying the second term to the first, then applying the third term to the result of that first application, and applying the fourth to the second result, and so on. Applying a tuple will call something as a function: add (1, 2). Applying a keyword will access the value stored at that key in a dict: foo :bar.

These may be chained arbitrarily. Take, for example, foo :bar (1, 2) :baz. This accesses :bar on foo, applies the arguments (1, 2) to that value (presumably a function), and then access :baz on value returned by that function.

Keywords may be called as functions

Following Clojure's example, you may call a keyword as a function: foo :bar and :bar (foo) are strictly equivalent.

Ludus has function pipelines

In addition to normal function application, Ludus also has function pipelines, equivalent to Elixir's pipelines or Clojure's thread macros. In these, the first term is applied, as a single argument, to the second. The result of that is then applied, as a single argument, to the third, and so on.

Function pipelines are introduced by the reserved word, do. These two expressions are exactly equivalent:

do foo > bar > 
  baz > quux
quux (baz (bar (foo)))

Newlines may be inserted after the > pipeline symbol, not before. Note that a line ending with the pipeline symbol will "eat" the line after it, even if separated by many terminators, so be careful.

Because keywords can be called like functions, bare keywords may be used in function pipelines.

Ludus has partial function application

Any function in Ludus may be partially applied by using the placholder, _, in place of an argument. Doing so returns a function that takes a single argument. When that function is called, it calls the original function with that argument put in the placeholder's position. Here's a simple example:

let double = mult (2, _)
double (3) &=> 6
double (12) &=> 24

Partially applied functions play very nicely with pipelines:

let double = mult (2, _)
let mynums = [1, 2, 3, 4, 5, 6]
do mynums >
  filter (even?, _) > &-> [2, 4, 6]
  map (double, _) &=> [4, 8, 12]

Ludus function definitions

Functions come in three flavours, all of which have a concept of a function clause. A function clause is a special case of a match clause: it has a tuple pattern on its left hand side (since we call functions with tuples). Otherwise,

Anonymous lambdas

An anonymous lambda is the fn reserved word, followed by a function clause:

let double = fn (x) -> mult (x, 2)
double &=> fn anon.
double (13) &=> 26

Named functions

Named functions are exactly the same as anonyomous lambdas, but they have a name between fn and the clause:

fn double (x) -> mult (x, 2)
double &=> fn double
double (-4) &=> -8

Compound functions

Compound functions have multiple clauses, separated off by curly braces:

fn foo? {
  ("foo") -> true
  (:foo) -> true
  (_) -> false
}
foo? (:bar) &=> false

There's a very close relationship between match forms and function definitions.

docstrings

A compound function may, optionally, take a string before any of its clauses, that serves as documentation for the function:

fn foo? {
  "Tells if its argument is a `foo`."
  ("foo") -> true
  (:foo) -> true
  (_) -> false
}

Ludus will print the documentation for a function by means of the doc! function.

Ludus has a convention of "commands": they end with a bang

By convention, Ludus functions that end in an exclamation point have side effects. These are called commands. doc! is a command; so is print!.

Ludus commands typically return the keyword :ok rather than nil.

Much of Ludus involves manipulating turtle graphics commands, forward!, and so on.

Ludus has loops, but you should probably use recursion

Ludus, in the grand Lisp (and Logo) tradition, eschews looping constructs in favour of functional recursion. Ludus is tail-call optimized, which means that recursion, even mutual recursion, is as fast as looping. The loop form, anyway, isn't anything like you're expecting; it's basically function calls.

Two examples of factorial, looping and recurisve:

loop (6, 1) with {
  (0, acc) -> acc
  (n, acc) -> recur (dec (n), mult (n, acc))
} &=> 720

fn fact {
  (n) -> fact (n, 1)
  (0, acc) -> acc
  (n, acc) -> fact (dec (n), mult (n, acc))
}

fact (6) &=> 720

The difference between these is that Ludus will throw a compile error if recur isn't in tail position. In addition, all clauses in a loop form, and all invocations of recur must have the same arity, whereas functions may have clauses of arbitrary arity.

Ludus has multiple "levels" of expressions

Not all Ludus expressions can appear anywhere you need an expression. Ludus has four levels of expressions that restrict where they may go: simple, nonbinding, expressions, and toplevel.

  • Simple expressions include all literals as well as bare names and synthetic expressions. They may go anywhere you expect an expression, e.g. in the condition position in if or when forms. But in these positions, you may not use, say, another conditional form, nor bind a name.
  • Nonbinding forms include all expressions except those that bind a name. These include all simple expressions, as well as conditional expressions (if, when, match), anonymous lambdas, and do pipelines.
  • Expressions (tout court) include all Ludus expressions, including those that bind names: let, named fns, and box.
  • Toplevel expressions may only go at the root scope of a script. At current, the are not yet implemented (pkg, use, test). These are statically checked.

Ludus has carefully managed state

At some point, you need state. (You need far less than you think!) For that, you need a box. A box holds a value that can change over time. It can hold any other Ludus value, including a box. Getting a value out of a box isn't as simple, however, as using its name. The name is bound to the box, not its value. To get the value out of a box, you use the unbox function:

box foo = 42
foo &=> box [42]
unbox (foo) &=> 42

To change the value in a box, you use either the store! command, or the update! command. store! takes a box and a value, and simply puts the new value in the box. update! (not to be confused with the function update) takes a box and a function, and updates the value in the box by applying the function to the value in the box:

box foo = 42 &=> box [42]
store! (foo, 23) &=> box [23]
update! (foo, add(13, _)) &=> box [36]
unbox (foo) &=> 36

Boxes are not variables

We have put the section on boxes last in this introduction because boxes are not variables. Most state can actually be, and within Ludus, absolutely ought to be, modeled not with boxes but with recursive functions.

Consider the factorial example from earlier.

A straightforward Javascript implementation might look like this:

function fact (n) {
  let acc = 1;
  while n > 1 {
    acc = n * acc;
    n--;
  }
  return acc;
}

You'll note that the while statement doesn't have an easy equivalent in Ludus. But if you were really stubborn about wanting to twised boxes into variables, you could do something like this:

fn fact (n) -> {
  box acc = 1
  loop (n) with (m) -> if lt? (m, 1)
    then unbox (acc)
    else {
      store! (acc, mult (m, unbox (acc)))
      recur (dec (m))
    }
}

Let me tell you, this is wild Ludus. The loop there is very weird indeed.

The short version is, if you can possibly avoid it--and you probably can--don't use boxes.

The more complex version is this: The functional and immutable nature of Ludus will change your ideas about programming. This is part of the point. So