A programming language that combines features from functional and imperative languages. The main interesting feature is actions, which are intended as an alternative to Haskell's monads.
While many of my other esolangs are based on ideas that would clearly not be good in a non-esoteric language, this one I intended more as a proof-of-concept of something I thought might be able to be developed into a good idea, though in its current form many desirable language features are missing (like integer, string, and list literals).
There are three types of data: functions, records, and actions.
Functions act a lot like in Haskell: put two functions next to each other to apply a function; functions are typically curried; and functions are pure. If an argument to a function is more than just a variable, it will often need to be enclosed in parentheses.
Lambda expressions and function definitions also have a similar syntax to Haskell. Function definitions have the form f x = x
, and lambda expressions have the form \x. x
. The dot may be omitted from lambda expressions if the first thing after it isn't a variable. The original version of the language required braces around the body of the lambda expression (\x {x}
); it may be good to always use braces, since lambda expressions have high precedence (\x. x y
is (\x. x) y
, not \x (x y)
as it would be in Haskell), and since it makes a bit more sense when dealing with actions.
A record is a mapping from names to values. Records are constructed using brackets, with names and values separated by an equals sign and name/value pairs separated by semicolons (with an optional semicolon at the end). Example:
[id = \x. x; const = \x y. x; compose = \f g x {f (g x)}]
Record members may also have function parameters, though keep in mind the function is not bound to local scope, so you can't recurse like that. E.g., the following is equivalent to the previous example:
[id x = x; const x y = x; compose f g x = f (g x)]
The values can be omitted to capture variables from the current scope; e.g. [id; const; compose]
is equivalent to [id = id; const = const; compose = compose]
.
Record values can be accessed with .
, similar to C++, Java, etc. E.g., [id x = x].id
is \x. x
. .
has the highest precedence.
If the interpreter tries to evaluate record.name
but there isn't a value with the name name
, but there's a value called GET
, then the interpreter calls record.GET \x {x.name}
. If there's no value named GET
either, then the interpreter attempts to return record.DEFAULT
(without calling it), and if that fails, it's an error.
There isn't a symbol or enum type; however, they can be simulated by using a function that selects a specific member from a record. There is a special syntax for creating such functions: #something
is equivalent to \x. x.something
.
The equivalent of a switch/case statement is to simply apply the symbol to a record:
color [ red = [r=1; g=0; b=0]; green = [r=0; g=1; b=0]; blue = [r=0; g=0; b=1]; DEFAULT = [r=0; g=0; b=0]; ]
Booleans are represented by #true
and #false
.
There is some support for natural numbers and lists in stdlib.actions
. See that file for details.
null
(#true
if the list is empty); non-empty lists have members head
and rest
as well. Lists created by stdlib.actions
have a number of other members as well for common list functions.
zero
(#true
if the number is zero); nonzero numbers have a member pred
referring to the previous number. Like lists, numbers created by stdlib.actions
have additional members.
Braces are the equivalent to Haskell's let
statement. Inside the braces are variable and function declarations separated by semicolons; the last item in the statement is the return value. Items in the statement can be mutually recursive. Variables already in scope cannot be redeclared. There is no pattern matching. Example:
{ length l = l.null [true = Nat.0; false = (length l.rest).succ]; length getSomeList }
Actions are a type, separate from functions. Like in Haskell, actions take no arguments (but there can be functions that take arguments and return actions) and return some value. If no return value is needed, they can return the empty record. To use an action, a function must have access to that action (e.g., as an argument), and it must return an action; any function that doesn't do that is pure. However, functions can create temporary actions to use internal state, similar to using various non-IO monads in Haskell (in fact, I believe these actions are equivalent to Haskell monads in what they can do).
A function that uses an action will usually be passed that action as an argument. The function can then execute that action using the !
operator, which is placed after the action name. Unlike in Haskell, actions can be composed with other functions without being placed on a separate line, e.g. set (f (get!))!
. Actions are executed left-to-right.
Braces have some additional uses regarding actions. They determine the sequence in which actions are performed, and are sort of like Haskell's do
statement; actions may be mixed in with pure declarations (though recursion is not allowed to go past an action). They also quote actions: the result of { less (x.get!) 10 }
is an action (and x.get!
will be executed when the action executes), whereas less (x.get!) 10
retrieves the value of x
right away and returns whatever less
returns (presumably a boolean).
Note that lambda expressions do not quote actions; \x (v.get!)
will retrieve the value of v
immediately, rather than when the function is called. \x (x.get!)
is an error, since the value of x
is not known yet. This is why I recommended braces above; \x {x.get!}
works as expected.
If !
is used on something that's not an action, it just returns it. This means that you can have something that conditionally returns an action; however, it also means that directly returning an action from an action won't always have the expected effect. Furthermore, if no !
expression is encountered that is actually an action, the result of the expression will not be an action.
There are a few functions in stdlib.action
(and others can be defined) that create local actions. For instance, var
is equivalent to Haskell's State
monad, and try
is sort of like Maybe
. They typically take a function as an argument, which takes an action or a record containing actions and uses those actions. These actions can be used as normal. However, they cannot be used outside the function that you pass; doing otherwise results in an error.
Actions can be created by combining other actions with pure functions, of course. This includes control structures; for instance, while
can be defined in the language. However, more interesting is how to create local actions.
Local actions can be created with PRIM.startAction
. This takes one argument, which is a function that takes a function that takes an action constructor and returns the action to perform. If you pass a value to the action constructor, the action will be suspended and the value passed to the action constructor will be returned from startAction
(wrapped in a record as described below).
startAction
can return one of three things:
done
is #false
, action
is what was passed to the action constructor, and next
is a function to continue the action. next
takes one argument, which is the value to return from the action constructor, and returns the same kind of thing that startAction
can return.
startAction
is called, it returns an action to perform that action and return one of the other things. If you want actions to be able to call other actions, then you can put !
after the calls to startAction
and next
.
done
is #true
and result
is the result of the action.
You do not need to call next
exactly once for each record; you can choose not to call it to terminate the action (like try
, or the Maybe
monad in Haskell) or you can call it multiple times (like the list monad in Haskell).
A record containing IO actions is passed to the main program. Currently the only function is print
, which takes a list of natural numbers, interprets those numbers as Unicode characters, and prints the result to the screen.
Each file contains a single statement (often a let statement), preceded by zero or more import statements.
An import statement has the form + name
, where name is a valid identifier. This will import the file name.actions
from the current directory, evaluate it, and put the result into variable name
.
The result of evaluating a module will usually be a record; this can be created by putting a semicolon-separated list of names to export in square brackets at the end of the module. The result of evaluating a program must be a function that takes an IO object and returns an action to perform.
A few extra features that are useful with modules:
+
, then it will assign multiple variables to the corresponding elements of a record. For instance, +a b = [a = #1; b = #2; c = #3]
is equivalent to a = #1; b = #2
. This can be used to allow unqualified names for functions in modules.
-name;
can be used in a let statement, in place of a declaration. This will replace all remaining instances of that name inside the let block (including as record field names) with a new name, thus allowing one to create a private variable.
+
name expr
!
\
args [.
] expr
.
name
#
name
{
declarations }
[
declarations ]
=
expr
+
names =
expr
-
name (in {}
)
{}
)
[]
)
=
name
Interpreter written in Haskell (updated 2020; source only; download and run; 25K compressed, 184 KiB uncompressed; also includes Data.Supply from here because I didn't want to have to deal with installing packages)
Original interpreter written in Haskell (not compatible with modern versions of GHC; source only; download and run; 21K compressed)
Note that the current directory matters when running the examples.