Introduction
Henceforth is a stack-based programming language that emphasizes direct stack manipulation, in a more modern way than its predecessors, like Forth or Joy, while also giving users the convenience of imperative programming. Unlike traditional languages with nested function calls and expressions, Henceforth operations work with a stack data structure, giving programmers fine-grained control over the program's execution, while also being user-friendly.
Key Characteristics
Stack-based execution
At the core of Henceforth is the stack. Instead of complex expressions being evaluated and stored into variables, Henceforth has a more step-by-step approach, and variables are nothing more than wrappers for simple values. This makes data processing more explicit, predictable and natural.
Explicit stack manipulation
By integrating imperative programming features into a stack-based language, it might get a little confusing whether or not something affects the stack. Henceforth uses special notation to fix this issue, making it explicit when something is manipulating the stack.
Seamless context switching
Unlike Forth, where everything is in one stack, Henceforth creates a new stack for every function, merging the callee and caller's stacks when the function is done executing, all done in a way that feels natural. Control flow is also handled by the compiler, so that, for example, every branch of an if statement starts with the same stack.
Strong Static Typing
Despite its low-level feel, Henceforth is statically typed with types like i32, f32, string, and bool. The type system ensures stack operations are safe and well-defined.
Quick Feature Overview
Functions with Stack-Based Arguments
fn add: (i32 i32) -> (i32) {
@(+);
}
Stack Blocks for Direct Fine-grained Manipulation
@(1 3 + 2 *) // Push 1, push 3, add, push 2, multiply
Several Ways to Call Functions
@(5 3) :> add; // copies the arguments
@(5 3) &> add; // moves the arguments
Lexical Structure
This section describes the basic lexical elements that make up Henceforth source code.
Comments
Line comments are indicated by //. Block comments are opened with /* and closed with. */, and can be nested.
// this is a comment
/*
this is also a comment
*/
/*
/*
this is a nested comment
*/
this is still a comment
*/
Identifiers
Function and variable identifiers can contain a-z, A-Z, 0-9 and _, except for the first character, which can't be a number.
add // valid
getValue2 // valid
_private // valid
2var // invalid (starts with number)
my-function // invalid (has hyphen)
hello world // invalid (has space)
Literals
Integer literals represent whole numbers:
42
-17
1000000
Floating-point literals represent decimal numbers:
3.14
-0.5
2.0
String literals are enclosed in double quotes:
"Hello, World!"
""
"Line 1\n Line 2"
The following escape sequences are allowed:
\n newline
\t tab
\\ backslash
\" double quote
\r carriage return
Boolean literals represent truth values:
true
false
Tuple literals group other literals together:
(5 3 "string")
(5.25 "test" true)
Keywords
The following identifiers are reserved as keywords and can't be used as an identifier.
fn
while
continue
break
return
let
if
else
false
true
i32
f32
str
bool
@pop
@pop_all
@dup
@swap
@over
@rot
@rrot
@nip
@tuck
Operators
Henceforth provides several categories of operators.
Arithmetic Operators
+ addition
- subtraction
* multiplication
/ division
% modulo
Comparison Operators
== equal to
!= not equal to
< less than
> greater than
<= less than or equal to
>= greater than or equal to
Logical Operators
&& logical AND
|| logical OR
! logical NOT
Special Syntax
@ stack block indicator
() tuple constructor/stack block delimiter
: function signature separator
-> return type indicator/argument redirection
{} block delimiters
; statement terminator
:= copy top value to variable
&= pop to variable
:> copy arguments to function call
&> move arguments to function call
Types
Henceforth's simple type system with static typing separates it from other stack-based languages, allowing for a more user-friendly experience.
Primitive Types
Henceforth has four basic types:
i32- 32-bit signed integerf32- 32-bit floating-pointstr- UTF-8 encoded textbool-trueorfalse
For grouping values, there are also tuples, denoted by parentheses around values.
Type Annotations
Henceforth doesn't have type inference, and all types must be specified.
fn foo: (i32 str) -> (bool) { ... }
^^^^^^^ ^^^^
parameters return type
Type Conversions
There are only three standard library functions to convert types:
fn i32_to_str: (i32) -> str {...}
fn f32_to_str: (f32) -> str {...}
fn bool_to_str: (bool) -> str {...}
Operators implicitly convert between i32 and f32.
Type Compatibility
Being statically typed, Henceforth validates stack operations and function calls before running the program.
Stack Operations
Being a stack-based language, the stack is the core of Henceforth. This data structure has a LIFO policy, where the last element that went in the stack is the first element to come out.
Key differences from other stack-based languages
- In Henceforth, every function has its own stack, and these stacks can be seamlessly merged.
- Since not every operation affects the stack, Henceforth explicitly states whether something is changing it.
- For convenience, the interpreter handles some of the stack manipulation when switching contexts.
Stack blocks
Stack blocks are Henceforth's way of separating imperative from stack-based code. They are delimited by @( and ). They don't have to be terminated by semicolons.
Pushing values to the stack
To push a value to the stack, just write a literal or a variable inside a stack block.
@(5) // pushes 5
@(5.0) // pushes 5.0
@(true) // pushes true
@((1 2 3)) // pushes (1 2 3)
@("string") // pushes "string"
@(var) // pushes the value of var, if var is a variable and has a value
Stack Operators
The following operators are allowed on the stack:
+ // pops two numbers, pushes their sum OR pops two strings, pushes their concatenation
- // pops two numbers, pushes their difference
* // pops two numbers, pushes their product
/ // pops two numbers, pushes their quotient
% // pops two numbers, pushes their integer division remainder
== // pops two numbers, pushes true if they're equal and false otherwise
!= // pops two numbers, pushes true if they're not equal and false otherwise
< // pops two numbers, pushes true if the left value is less than the right value and false otherwise
> // pops two numbers, pushes true if the left value is greater than the right value and false otherwise
<= // pops two numbers, pushes true if the left value is less than or equal to the right value and false otherwise
>= // pops two numbers, pushes true if the left value is greater than or equal to the right value and false otherwise
! // pops a bool, pushes its inverse
|| // pops two bools, pushes true if any of them are true and false otherwise
&& // pops two bools, pushes true if both are true and false otherwise
Stack manipulation builtins
There are several keywords for working with the stack. These are denoted by the @ prefix and work with the stack despite not always being in a stack block.
@pop // removes the argument from the stack
@pop_all // removes everything from the stack
@dup // duplicates its argument
@swap // swaps its arguments
@over // copies second element to top of stack
@rot // moves third element to the top of the stack
@rrot // moves first element to the third position of the stack (equivalent to calling rot twice)
@nip // pops second element from the stack
@tuck // copies first element to the third position of the stack
Common mistakes
Some common mistakes when using the stack include:
@(+) // ERROR: stack underflow
A stack underflow occurs when an operator expects more values than there are on the stack.
@(5 "hello" +) // ERROR: type mismatch
A type mismatch occurs when an operators expects values of a certain type but is given other types.
Variables
Variables are one of the many imperative features that make Henceforth so powerful. Instead of relying entirely on the stack, they allow for keeping state locally.
Variable declaration
Variables can be declared with the following syntax:
let x: i32;
let y: (i32 string);
Variables can't be initialized on declaration, they must use values on the stack.
let x: i32 = 5; // not valid
How variables interact with the stack
Variables can be assigned values directly from the stack:
:=copies the top value from the stack into the variable&=moves the top value from the stack, popping it, into the variable
let var: i32;
@(1 2 3) := var; // stack: [1, 2, 3], var = 3
let var: i32;
@(1 2 3) &= var; // stack: [1, 2], var = 3
The same rules apply to tuples:
let var: (i32 i32);
@((1 2)) := var; // stack: [(1 2)], var = (1 2)
let var: (i32 i32);
@((1 2)) &= var; // stack: [], var = (1 2)
Variables can be pushed onto the stack like any other literal.
let var: bool;
@(5); // stack: [5]
@(6); // stack: [5 6]
@(>); // stack: [false]
:= var; // stack: [false], var = false
@(var); // stack: [false, false], var = false
Scoping rules
Variables are always local to their scope, and you can't declare variables on a global scope.
let x: i32; // not valid
fn foo: () -> () {
let x: i32; // valid
@(x); // valid
...
}
fn main: () -> () {
@(x); // invalid
...
if @(...) {
let y: i32; // valid
}
@(4) := y; // invalid
}
Functions
Unlike other stack-based languages, functions in Henceforth are statically typed, giving a better abstraction than the normal approach of "pop from stack, push to stack".
Declaring functions
Functions can be declared with the following syntax:
fn function_name: (i32) -> (str bool) {
...
}
/*
fn <name>: (<argument types>) -> (<return types>) {
<body>
}
*/
They must be declared on a global scope.
How function calls affect the stack
There are two operators to call a function:
@(1 2 3) :> foo;
@(1 2 3) &> foo;
The :> operator copies the arguments, keeping them in the stack, and the &> operator moves the arguments, removing them from the stack. This syntax closely resembles the := and &= operators, and their behavior is similar.
How context switches between function calls
In Henceforth, functions have separate stacks, and these stacks are merged between function calls.
Here's an example of a function that mimics the functionality of the @dup keyword.
fn duplicate: (i32) -> (i32 i32) {
let temp: i32;
:= temp;
@(temp);
}
fn main: () -> () {
@(5) &> duplicate;
@pop_all;
}
Now we can trace the program's execution:
- When
main(the entry point for any Henceforth program) starts running, the stack is empty. - When calling
duplicatewith the argument5, the following happens:5is pushed into the stack ofmain, then we call dup by moving its argument with&>.
- When switching to the context of
duplicate, themainstack is now empty, and the popped arguments are pushed intoduplicate's stack, which now contains[5]. - The function then creates a variable, copies the top of the stack (
5) into it, and pushes the variable on the stack, essentially duplicating the value. Now the stack ofduplicatecontains[5, 5] - When finishing
duplicate's execution,returnis not necessary, since the function will just return whatever is in the stack when the execution finishes. Thereturnkeyword is only necessary for early returns. - When switching context, like before, we put it into the stack of
main, so now it contains[5, 5]. - If we tried returning now, the stack would be compared against the return type of main and it would fail. To fix that we use
@pop_allto remove everything from the stack, so we can safely return.
Control Flow
In other stack-based languages, control flow is handled within the stack, leading to some questionable syntax (looking at you, Forth). Henceforth fixes this by handling it imperatively, leading to a more familiar way of doing things.
While loops
while loops are the primary way of repeating things in Henceforth, since for loops don't exist.
They are used in the following way:
while @(<condition>) {
<body>
}
As expected, the body of the loop will be repeated while the condition is still true.
The one thing Henceforth does differently from other stack-based languages is that, when looping, it pops the result of evaluating the condition when entering the body, and when leaving the body after the condition is evaluated as false.
In Henceforth, a while loop must not change the depth of the stack.
This means that, if something changes the stack inside the loop's body, it must be stored in a variable before the next iteration.
The following keywords can be used inside while loops to change its execution:
break- leaves the loop entirelycontinue- skips the rest of the body's execution, starting the next iteration
If statements
if statements can be used to conditionally perform operations.
They are used in the following way:
if @(<condition1>) {
<if-body>
} else if @(<condition2>) {
<else-if-body>
} else if ... {
...
} else {
<else-body>
}
This syntax mirrors imperative syntax from languages like C, instead of the confusing <condition> if <body> then ... of Forth.
Like in while statements, whenever a new branch is entered, the stack is restored to its previous state before the loop, and the result of evaluating the condition is always popped automatically.
In an if statement, all branches must have the same stack depth.
Example
This all may seem very confusing, let's look at a simple FizzBuzz program.
fn fizz_buzz: (i32) -> (str) {
if @(15 % 0 ==) {
@("fizzbuzz");
} else if @(3 % 0 ==) {
@("fizz");
} else if @(5 % 0 ==) {
@("buzz");
} else {
@pop;
@("no fizzbuzz");
}
}
Let's trace the program's execution:
- When entering the
ifstatement the stack contains only the argument. - The condition's stack block is executed, leaving
trueorfalsein the stack. - Assuming the evaluated condition was true, when entering the body, this value is popped, so we add
"fizzbuzz"to the stack and return with a stack that matches the expected return type. - If the condition was false, we move on to the next branch. The stack is restored to just having the argument, and the condition is evaluated in the same way as before.
- When reaching the
else, the stack only contains the argument, and since there is no condition, it stays there while entering the body, so we have to pop it. - We then push the string
"no fizzbuzz"and return as expected.
Essentially, control flow boils down to these rules:
- Every branch of an
ifstatement and every iteration of awhileloop, starts with the same exact stack - All branches of an
ifstatement must have the same final stack depth - A
whileloop must maintain stack depth - When entering the
elsebranch of anifstatement, since there's no condition, everything stays there
Standard Library Functions
Henceforth contains many functions in the standard library that allow for more abstraction out-of-the-box.
String Manipulation and IO Functions
Currently, the Henceforth standard library only contains three functions for string manipulation:
fn i32_to_str: (i32) -> str;
fn i32_to_str: (f32) -> str;
fn bool_to_str: (bool) -> str;
This converts an i32, f32 or bool into a string.
A function like this goes hand-in-hand with IO, for which Henceforth contains two functions:
fn print: (str) -> (str);
fn print_stack: () -> ();
print_stack differs from other functions as it affects the caller's entire stack, without explicitly passing it, printing it in reverse order (bottom to top), and works with any type.
print simply prints its argument.