ludwig-lang
v0.1.36
Published
The Ludwig Programming Language
Downloads
19
Maintainers
Readme
The Ludwig Programming Language
For the clarity we are aiming at is indeed complete clarity. Ludwig Wittgenstein
All code samples in this document can be executed and edited directly in the browser. Please check out an interactive version of this page.
Ludwig is a high-level multi-paradigm dynamically-typed programming language with a super-simple but human-friendly syntax. It's built upon a minimalistic set of basic concepts, but naturally expands ad inifinitum like a seed becomes a tree. It seamlessly unifies functional and object-oriented styles of programming, encourages single assignment immutability but permits mutable state when necessary, supports lazy evaluation and tail recursion, and provides a uniform API for both "fluent" generators and materialized persistent data structures.
The aim of this project is to explore a possibility of building a practically usable and human-friendly programming language using the fewest number of language constructs.
Ludwig is named after Ludwig Wittgenstein, a prominent Austrian-British philosopher who worked in the fields of philosophy of mathematics and philosophy of language. Many of the ideas realized in Ludwig come from such programming languages like Lisp, Smalltalk, Rebol and Red, but it was no less inspired by the dizzying passion for simplicity and clarity which can be found in the works of Ludwig Wittgenstein.
We believe that after its run-time library and tooling mature, Ludwig can be used as a primary language for writing all kinds of software, from simple scripts to server-side applications. Besides that, the extreme simplicity of the language opens the way for other types of use:
- as a portable target "back-end" language for translation from other languages
- as an educational language, both for the ease of learning and the simplicity of implementation
- as an embedded low-code/rule language
- in genetic programming research and applications
- as an intermediate representation for static analysis and optimization algorithms
Ludwig doesn't have and doesn't need special syntax for such basic constructs as if
or for
statements,
module imports, visibility modifiers, object instantiation, visibility modifiers, and even numerical or boolean literals!
Nonetheless, it does support all the aforementioned features in a very consistent and easy to grasp manner.
Basically, instead of having a fixed set of hard-coded constructs such as the if-then-else
statement, Ludwig allows you to
define new control structures as regular functions. The same can be done in LISP, but Ludwig achieves it without
using LISP macros or any similar metaprogramming technique
and has just two special forms comparing to more than 25 in most LISP realizations.
The reference implementation of Ludwig interpreter is written in Java Script and can be used in both NodeJS and browser applications. Due to the simplicity of the language, implementation of an interpreter or a compiler in other languages including Ludwig itself should be an easy task.
Ludwig contains 0.00% syntax sugar. It means that some Ludwig constructs may look slightly more complex than their equivalents in other languages such as Python, JavaScript or LISP using various flavors of syntax sugar to provide shortcuts to common patterns. On the other hand, Ludwig programs can be much more compact and easier to understand than analogous programs written in such Baroque languages as Java. Just compare "Hello world" written in Ludwig
[println `Hello, world`]
with its Java equivalent:
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println("Hello, world");
}
}
As with LISP's parentheses, some people may find ubiquitous square brackets in Ludwig code annoying and distracting. With its extremely simple and regular (even comparing to Lisp's) syntax, Ludwig is a great candidate for experiments with non-textual structural or projectional editing approaches.
The syntax
I promise you, it will take you no longer than 5 minutes to learn the full syntax. Let's start with something very simple.
# Our very first program
[println `Hello, World`]
The first line contains a comment starting with #
.
The second calls the standard function println
passing the string literal Hello, World
as an argument.
The syntax [function arg1 arg2...]
used for function invocation is similar to LISP's S-expressions.
Ludwig uses square brackets instead of LISP's parentheses for two reasons:
- to make it visually distinguishable from LISP
- for the ease of typing. On most keyboards you need to actually press
Shift-(
to type a parenthesis and just[
for a bracket.
String literals are surrounded with backquotes.
Let's now consider a slightly more complex example:
# Let's define a function first
[= hello [\ [name]
[print `Hello, `]
[println name]
]]
[hello `World`]
It defines a new function, hello
with a single argument, name
(strictly speaking, it binds symbol hello
to an anonymous lambda-function).
The syntax [= symbol value]
is used for all assignments.
All symbols in Ludwig are constants, so you cannot redefine one, but some of them may have mutable "inner" state.
The function body in enclosed with square brackets and consists of the special symbol \
which stands for the Greek letter λ "Lambda", a list of arguments (just one in our case) enclosed with brackets,
and the function's body, consisting in our case of two expressions,
one printing the word Hello
, another printing the argument name
.
The formal syntax for a λ-function is [\ [argument*] expression*]
.
The result of a λ-function is produced by the last expression of its body.
Each expression in the body can be a symbol, a string literal, an assignment, a function invocation or a nested λ-function literal.
Finally, we call the newly defined function, passing World
as an argument.
Congratulations! At this point You've learned Ludwig's syntax in full. The example above contains all the possible Ludwig syntax constructs. Yes, again: this is everything you need to know:
- whitespace doesn't matter
- comments start with
#
- string literals should be put within ` back-quotes `
- function invocations use "square-bracketed" S-expressions
- assignments have the form
[= symbol value ]
- anonymous functions are defined using the λ-syntax:
[\ [argument*] expression*]
- symbols (constants' names) can be anything that doesn't clash with the above rules
Yes, Ludwig does support control structures, numbers and boolean values, mutable state, collections, objects, module imports and much more, but all these features don't require any new syntax constructs.
Types
Null
The constant null
represents a special value signifying an absence of anu other value.
A function with an empty body always returns null
:
[= foo [\[]]]
[print[foo]]
Strings
Strings literals can contain single and double quotes:
`It's a "good" string`
Multi-line strings:
`A
multiline
string`
An empty string literal:
``
Booleans
The "if" function
[if true
[\ [] [println `It's true`]]
[\ [] [println `It's not true`]]
]
[if false
[\ [] [println `It's true`]]
[\ [] [println `It's not true`]]
]
Chained if:
[= sign [\[x]
[if [< x zero]
[\[] [~ one]]
[\[] [if [> x zero]
[\[] one]
[\[] zero]
]]
]
]]
[println [sign [num `-100`]]]
[println [sign [num `0`]]]
[println [sign [num `0.0001`]]]
Numbers
Numbers are first-class objects in Ludwig. However, there is no support for numeric literals in Ludwig's syntax.
Instead, Ludwig provides a few functions for parsing strings into numbers. The most commonly used parsing function is num
which parses a number from its decimal representation.
Valid number formats:
[println [num `123.45`]]
[println [num `+123.45`]]
[println [num `1.2345E2`]]
[println [num `1.2345e+2`]]
[println [num `1_000_000_000`]]
[println [num `NaN`]]
[println [num `Infinity`]]
[println [num `-Infinity`]]
This may seem awful at a first glance but this approach has in fact a number of advantages over syntax-level support for numeric literal. First of all, the fact that the core syntax of the language remains very simple makes it simple to create all kinds of tools like IDE plugins or code highlighters for the language. Finally, most developers actually very rarely need to declare a numeric constant. Most numbers come from IO and require parsing anyway. Most hardcoded constants in a typical codebase are small integer numbers 0, 1, 2. Ludwig defines those numbers as named constants:
[println zero]
[println one]
[println two]
Arithmetic operations:
[= x [num `7`]]
[= y [num `3`]]
[println [+ x y]]
[println [- x y]]
[println [* x y]]
[println [/ x y]]
[println [^ x y]]
# modulo
[println [mod x y]]
[println [mod y x]]
# integer division
[println [div x y]]
[println [div y x]]
# negation
[println [~ x]]
Unlike LISP where you can sum multiple numbers at once, e.g. (+ 1 2 3 4)
, in Ludwig all binary arithmetic operators take exactly two arguments.
Ludwig also uses separate symbols for subtraction (~
) and unary negation (~
).
Functions
The anatomy of a function
Recursion
Tail recursion
Mutually recursive functions
Number of arguments
Function [arity f]
returns the number of arguments of function f
:
[println [arity [\[] [print `booo`]]]]
[println [arity +]]
[println [arity if]]
All Ludwig functions except to ,
(the list constructor) have fixed number of arguments.
# this will produce an error
[+ one]
# this too
[+ one one one]
The list constructor function ,
can accept any number of arguments
[println [arity ,]]
[println [,]]
[println [, one]]
[println [, one two]]
Following the philosophy of the language, we are not planning to introduce any other variadic functions bu ,
.
Even though variadic functions can be convenient, they also introduce unnecessary ambiguities and sometimes lead to hard-to-spot bugs.
Tail recursion
Errors
Functions for everything
Variables
As was said before, all bindings in Ludwig are static. Once a symbol is assigned a value, it cannot be assigned another value in the same lexical context.
[= x `a`] # okay
[= x `b`] # this will fail
This means that all bindings are immutable. That is very good for the correctness of your program, but sometimes you do need mutable state. Ludwig allows for mutability by providing a special kind of container primitive which internal state can be mutated. These containers are quite naturally called "variables" or "vars".
While it's possible to create other high-level mutable objects using var objects, they are the only primitives
allowing for mutability.
A variable object can be created using [var initial-value]
function.
[= x [var zero]]
The value of a variable can be retrieved using let
function and modified using set
:
[= x [var one]]
[println [get x]]
[let x two]
[println [get x]]
Variables containing numerical values can be incremeted or decremented:
[= x [var zero]]
[++ x]
[println [get x]]
[-- x]
[println [get x]]
Variables can be used to create "stateful functions":
[= counter [var zero]]
[= next_id [\[] [++ counter]]]
[println [next_id]]
[println [next_id]]
[println [next_id]]
The example above works fine, but what if we want to hide (encapsulate) its internal state? The old wrapping trick does just that:
[= next_id [[\[]
[= counter [var zero]]
[\[] [++ counter]]
]]]
[println [next_id]]
[println [next_id]]
[println [next_id]]
Generators
Ludwig's approach to iterables, generators, sequences, collections or how you name them is different from other programming languages. A generator is simply a function which takes another single-argument function as an argument. We call the second function consumer. The generator may call the consumer an arbitrary (finite or infinite) number of times, feeding the consumer with values (yielding values).
A generator that yields three values:
[= generator [\[consumer]
[consumer zero]
[consumer one]
[consumer two]
]]
# We pass println as a consumer
[generator println]
A generator that yields 20 values:
[= generator [\[consumer]
[= i [var zero]]
[= iter [\ []
[println [get i]]
[on [< [++ i] [num `20`]]
iter
]
]]
[iter]
]]
[generator println]
A generator that yields nothing:
[= generator [\[consumer]
]]
# Will print nothing
[generator println]
Lists
Lists are materialized generators which store values in memory instead of calculating them on every call.
The easiest way to create a list is by using the list constructor function ,
.
It accepts an arbitrary number of arguments and returns a generator yielding those values.
[= items [, `a` `b` `c`]]
[println items]
[println [size items]]
[println [at zero items]]
[println [at one items]]
[println [at two items]]
[items println]
An empty list:
[,]
Any finite fluent (non-materialized) generator can be converted into a list using [list gen]
function.
[= gen [\[yield]
[println `yielding values`]
[yield zero]
[yield one]
[yield two]
]]
[= items [list gen]] # prints `yielding values`
items
Be careful, if you call list
on an infinite generator, your application will crash with out of memory error!
Again, lists are generators, are "normal" functions. However, list-backed generators have a number of distinctive properties:
[size gen]
and[at index gen]
require constant time, O(1).- lists have nice string representations, e.g.
[ 1, 2, 3 ]
- lists are implemented using persistent data structures, meaning that such operations as addition or deletion of list elemnts involve only limited copying
Sets
Sets are materialized generators producing unique values.
Similarly to lists, sets are implemented using persistent data structures.
Every generator can be converted into a set using the set
function. Please note that the order of elements
in the resulting set may differ from their order in the original generator.
The contains
function takes constant time O(1)
for sets.
[= s [set [, `a` `b` `c` `a`]]]
[println s]
[println [contains s `a`]]
[println [contains s `d`]]
Records
One can look at records from different angles. From one, they a just a convenient way to define a special kind of functions.
Let's define a tabular function f(x) which returns 1 when x = 0, and 0 when x = 1. You cand do that using if
:
[= f [\ [x]
[if [== x zero]
[\[] one]
[\[] [if [== x one]
[\[] zero]
[\[] [error `Unexpected argument value`]]
]
]
]
]]
[println [f zero]]
[println [f one]]
Records provide a less verbose way to achieve the same result:
[= f [record [,
zero one
one zero
]]]
[println [f zero]]
[println [f one]]
You may call f
a recorded function or simply a record.
This works for all kinds of argument anr result types:
[= p [record [,
`x` one
`y` zero
]]]
[println p]
[println [p `x`]]
[println [p `y`]]
If you're going to create more "points" you can create a new function for that:
[= point [\[x y]
[record [,
`x` x
`y` y
]]
]]
[= p [point zero one]]
Wait, it looks as if we've just declared a new type, point
and then created an instance of that type!
Let's make our point class ~~movable~~ mutable:
[= point [\[x y]
[record [,
`x` [var x]
`y` [var y]
]]
]]
[= p [point zero one]]
[let [p `x`] two]
p
Let's add a method:
[= point [\[x y]
[= it [record [,
`x` [var x]
`y` [var y]
`dist` [\[]
[sqrt [+ [* [get [it `x`]] [get [it `x`]]]
[* [get [it `y`]] [get [it `y`]]]]]
]
]]]
]]
[= p [point zero one]]
[println [[p `dist`]]]
[let [p `x`] [num `3`]]
[let [p `y`] [num `4`]]
[println [[p `dist`]]]
We can also hide the mutable state from direct modification (encapsulate it):
[= point [\[x y]
[= my-x [var x]]
[= my-y [var y]]
[record [,
`x` [\[] [get my-x]]
`y` [\[] [get my-y]]
`dist` [\[]
[sqrt [+ [* [get my-x] [get my-x]]
[* [get my-y] [get my-y]]]]
]
`move` [\[x y]
[let my-x x]
[let my-y y]
]
]]
]]
[= p [point zero one]]
[println [[p `dist`]]]
[[p `move`] [num `3`] [num `4`]]
[println [[p `dist`]]]
[= dog [\[name] [record [,
`say` [\[]
[print name]
[println ` says bark`]
]
]]]]
[= cat [\[name] [record [,
`say` [\[]
[print name]
[println ` says mew`]
]
]]]]
[= jack [dog `Jack`]]
[= kitty [cat `Kitty`]]
[= animals [, jack kitty]]
[animals [\[a] [[a `say`]]]]
Memoization
[= fib [memoize [\[n]
[if [< n two]
[\[] one]
[\[] [+ [fib [- n two]] [fib [- n one]]]]
]
]]]
[fib [num `100`]]