Welcome
Welcome to the documentation for Andy C++!
This language is the result of a hobby project that I’ve poured a lot of time and effort into, purely for the fun of exploring new ideas in programming. I was inspired to create this language by Brian Chen, who developed Noulith to participate in (and ultimately win) Advent of Code. I’d like to thank Brian for the inspiration his work provided.
Disclaimers
- To enhance clarity and minimize errors in this documentation, I used AI assistance, hoping it would make the language’s concepts easier to understand. Thank you for checking out Andy C++, I hope you enjoy exploring it as much as I enjoyed creating it!
- This is the first time I attempted to make a programming language, and it’s one of my first Rust projects. You’re welcome to look at the source or even send in contributions but don’t expect it to be easy. I’m just an average guy bashing my head against problems until I stumble into a solution that seems to work. There will be a lot of really bad stuff under the hood.
- I made this language for fun, and it’s meant to be used for fun. Don’t rely on the output of the interpreter to make important decissions. It took me 4 months before I noticed that subtracting floats was actually performing addition.
- The performance of the interpreter is pretty bad, but as long as you come up with smart solutions and offload some of the calculations to functions written in rust you should be fine.
Installation
Prerequisites
Make sure you have Rust and Cargo installed on your system. If not, you can install them by following the instructions at https://rustup.rs/.
Installing
To install Andy C++ using Cargo, run the following command:
cargo install --git https://github.com/timfennis/andy-cpp
This will compile Andy C++ and install it into your Cargo bin directory (usually located at $HOME/.cargo/bin).
Hello, World!
Now that the Andy C++ interpreter is installed we can write our first program.
First create a file called hello.ndc add the code in Listing 1-1.
print("Hello, world!");
In Andy C++ we don’t need a main function. Semicolons are mandatory, just like they are in Rust. If you already know Rust, Andy C++ will feel very familiar.
$ ndc hello.ndc
Hello, world!
Variables and Scopes
In Andy C++, you declare new variables with let, like you do in Rust. Use = to reassign an existing variable.
let x = 1;
print(x); // 1
Shadowing
Andy C++ follows Rust-style shadowing. If you declare a variable with an existing name, the new binding hides the old one in that scope.
let x = 1;
let x = 2;
print(x); // 2
You can create a new scope with curly braces. A binding inside that scope can shadow an outer binding. When the scope ends, the outer binding stays available.
let x = 1;
{
let x = 2;
print(x); // 2
}
print(x); // 1
Scopes
In Andy C++, every block is an expression. End the block with an expression, not a semicolon, and that expression becomes the block’s value.
let x = {
let a = 1;
let b = 2;
a + b
};
print(x); // 3
Reassignment
The = operator can be used to reassign a value to an existing variable. When you reassign a variable to a value of a different type, the variable’s type is widened to the least upper bound (LUB) of the old and new types.
let x = 1; // type is Int
x = 2; // type is still Int
x = 3.14; // type widens to Number (LUB of Int and Float)
let pos = (); // type is ()
pos = (1, 2); // type widens to Sequence<Any>
pos = ("a", "b"); // type is still Sequence<Any>
Tip: For the best type inference, initialize variables with a value that matches the intended type. For example, use
let pos = (0, 0);instead oflet pos = ();if you intend to store a 2-tuple of numbers.
Destructuring
Destructuring works more like Python than Rust. Commas matter more than the delimiters, so [] and () both work.
The statements below are all equivalent:
let a, b = 3, 4;
let [a, b] = 3, 4;
let (a, b) = [3, 4];
let [a, b] = (3, 4);
You can also destructure nested patterns:
let [a, (b, c)] = (1, [2, 3]);
Types
Andy C++ is currently a dynamically typed language, that means that type checks are performed at runtime. Although you currently can’t annotate your variables using type names they do have types at runtime.
The type system is hierarchical with the root type being Any:
- Any
- Option
- Boolean
- Number
- Integer
- Int64 (64bit signed)
- Bigint (unlimited size)
- Float
- Complex
- Rational
- Integer
- Sequence
- String: A mutable list of characters
- List: A mutable list
- Tuple: An immutable list
- Map: A hashmap that associates keys with values
- Deque: A double ended queue
- MinHeap & MaxHeap: Min/max Heap
- Iterator: A type that can be consumed and produces values (Currently only used for range expressions like
5..100)
- Function
Note:
Anyis the base type for all other types. When you declare a function, its arguments default to typeAny. Currently, theAnytype is implicit and does not appear explicitly in the language.
Option
let value = Some(3);
if value.is_some() {
// Some!!
let value = value.unwrap();
}
if value.is_none() {
value.unwrap(); // ERROR!!
}
Some functions have variations with a ? appended to their name that return options instead of throwing errors:
let empty = [];
let fst = empty.first(); // ERROR: list is empty
let fst = empty.first?(); // None
let my_list = [1,2,3];
let fst = my_list.first?; // Some(1)
Note: unfortunately the language doesn’t support pattern matching on options
Boolean
Booleans in Andy C++ represent a binary state, capable of holding either the value true or false.
Unlike some other languages, where booleans are often used as numbers, Andy C++ treats them as a
distinct type. When attempting to perform arithmetic operations with booleans, such as true + true,
Andy C++ will throw an error because these operations are not defined for this type.
Operators defined for booleans:
| Operator | Function |
|---|---|
& | non-lazy and |
| | non-lazy or |
~ | non-lazy xor |
! | not |
or | lazy logical or |
and | lazy logical and |
not | logical not like ! but lower precedence |
See Logical operators for short-circuiting and precedence rules.
Numbers
Andy C++ has four number types:
- Int: which is subdivided into
Int64andBigIntto support arbitrarily large numbers - Float: which is backed by an
f64 - Rational: which consists of two
BigInts and represents a fraction - Complex: which is a pair of two
f64’s
Operators
| Operator | Function | Support augmented assignment [1] | Augmentable with not |
|---|---|---|---|
+ | Addition | true | false |
- | Subtraction | true | false |
unary - | Negation | true | false |
* | Multiplication | true | false |
/ | Division (returns rational for integers) | true | false |
\ | Floor division (integer result, rounds toward negative infinity) | true | false |
^ | Exponentiation | true | false |
% | C-style modulo (can be negative) | true | false |
%% | Remainder of euclidean division | true | false |
== | Strict equality | false | true |
<= | Less or equal | false | true |
< | Less than | false | true |
>= | Greater or equal | false | true |
> | Greater than | false | true |
!= | Not equal | false | true |
<=> | Compare | false | false |
>=< | Reverse compare | false | false |
<> | Concatenate string values | true | false |
Integers also support these operations:
| Operator | Function | Support augmented assignment [1] | Augmentable with not |
|---|---|---|---|
| | Bitwise OR | true | false |
& | Bitwise AND | true | false |
~ | Bitwise XOR, or bitwise NOT in unary position | true | false |
unary ~ | bitwise NOT | true | false |
>> | Bitshift right | true | false |
<< | Bitshift left | true | false |
Integers
Andy C++ uses signed 64-bit integers until an expression overflows. At that point it switches to BigInt and computes the result with the num crate. You can compute very large numbers this way, but code such as a naive solution to Advent of Code 2022 - Day 11 may keep allocating until you run out of memory.
let result = 2 ^ 1024;
// Result: 179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216
Rational numbers and Floats
The math system keeps exact values unless you ask for a float. Dividing integers produces a rational number, not a float. An expression produces a float only when one of its operands is a float.
One exception exists: raising an integer to a rational power produces a float.
For example:
let result = 5^(1/2);
// result is Float because Int^ on Rational results in Float
assert_eq(result, 2.23606797749979); // Using == assertions on floats is risky
Complex numbers
Andy C++ supports complex numbers. Use either i or j as the imaginary unit. Create a complex number by combining a real part with an imaginary part.
let complex = 5.0 + 3.1j;
let result = complex * 1.3; // 6.5+4.03i
let result = complex + 5.3; // 10.3+3.1i
String
In Andy C++ a String is a mutable list of characters. Characters don’t have their own type in the language so if you iterate over a string you get strings of length 1. Just like in Rust strings are guaranteed (and required) to be valid UTF-8. This means that you can’t store arbitrary binary data in a String.
Indexing into a String is done by UTF-8 codepoint (equivalent to Rust’s char) rather than by byte offset.
This means that indexing into a string is O(n) instead of O(1).
let string = "I ❤ Andy C++";
assert_eq(string[2], "❤");
assert_eq(string[4], "A");
The advantage is that it’s a bit easier to guess that A is the 4th character in the example above. The downside is that
depending on which heart you’re using there might be an invisible Variation Selector
after the heart which messes everything up. This specific behavior will probably change in the future.
Raw strings
You may also define raw strings using this syntax borrowed from rust.
let string = r#"raw string with "" lots of "" quotes"#;
and even
let string = r###"raw string with r#"stop doing this"#"###;
Operators
| Operator | Function |
|---|---|
++ | concatenate two strings |
<> | coerces both operands into strings before concatenating them |
in | check if left operand is a substring of right operand |
== | Equality |
!= | Inequality |
> | Greater (lexicographically) |
< | Less (lexicographically) |
>= | Greater equals (lexicographically) |
<= | Less equals (lexicographically) |
<=> | -1, 0, or 1 (lexicographically) |
>=< | -1, 0, or 1 (reverse lexicographically) |
Note: The
inoperator checking if the left operand is a substring of the right operand is different from the behavior ofinon lists.
Examples
let a = "foo" ++ 3; // Error: cannot concatenate string and int
let b = "foo" <> 3; // foo3
let c = 10 <> 5.0; // 105.0
let d = "oo" in "foobar"; // true
List
Lists are mutable in Andy C++.
let my_list = [1,2,3];
// Values inside a list can be changed
my_list[2] = 4;
// You can add elements to the end of a list
my_list.push(99);
// Remove and return the last element of the list
let element = my_list.pop();
Indexing
Lists, strings, and tuples also support negative indexes. Negative indexes count from the end.
let my_list = [1,2,3,4,5,6,7,8,9];
assert_eq(9, my_list[-1]);
Slicing
Use ranges to slice lists. Ranges can be inclusive or exclusive. Negative indices count from the end of the list.
let my_list = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
// Exclusive range: 3 to 6 (does not include index 6)
assert_eq([30, 40, 50], my_list[3..6]);
// Inclusive range: 3 to 6 (includes index 6)
assert_eq([30, 40, 50, 60], my_list[3..=6]);
// Negative indices: Counting from the end of the list
assert_eq([80, 90], my_list[-3..-1]);
Operators
| Operator | Function |
|---|---|
++ | Concatenation |
<> | Coerce operands into strings and concatenate |
in | Checks if an element is present in the list |
not in | Checks if an element is not present in the list |
== | Equality |
!= | Inequality |
> | Greater (lexicographically) |
< | Less (lexicographically) |
>= | Greater equals (lexicographically) |
<= | Less equals (lexicographically) |
Tuple
Tuples are similar to lists, but you cannot change their elements after you create them.
let my_tuple = (1,2,3);
// You can access elements using indexing
assert_eq(my_tuple[0], 1);
// Tuples are immutable, so assignment fails
my_tuple[0] = 99; // ERROR
// You may also iterate over tuples
for item in my_tuple {
// ..
}
Create a 1-element tuple by adding a trailing comma:
assert_eq((1,).len(), 1);
Copy-on-write
Tuples use copy-on-write. If an operation looks like it modifies a tuple, such as appending elements, Andy C++ keeps the original tuple and creates a new one for the updated value.
let a = (1,2,3);
let b = a;
b ++= (4,5);
// A remains the same
assert_eq(a, (1,2,3));
// B was copied and (4,5) was appended
assert_eq(b, (1,2,3,4,5));
Operators
| Operator | Function |
|---|---|
++ | Concatenation |
<> | Coerce operands into strings and concatenate |
in | Checks if an element is present in the list |
not in | Checks if an element is not present in the list |
== | Equality |
!= | Inequality |
> | Greater (lexicographically) |
< | Less (lexicographically) |
>= | Greater equals (lexicographically) |
<= | Less equals (lexicographically) |
Unit
In Andy C++ the unit type is written as () and indicates that there is no value. Statements evaluate
to the unit type for instance when a semicolon is used after the last value.
let result = if x == y {
5 + 5;
} else {
10 + 10;
};
// Result is unit because the last expression in the blocks has a semicolon
assert_eq(result, ());
Unit is also a 0-length tuple:
assert_eq((1,2,3)[0..0], ());
The language does not have null or nil.
Map and Set
Andy C++ does not have separate map and set types. A set is a map whose values all use the unit type ().
Set
Because { is already used to create a block expression, Andy C++ uses %{ to create a set or map.
let my_set = %{5, 3, 2, 1};
Map
let my_map = %{
"apples": 60,
"oranges": 22,
"bananas": 0,
};
Default values
let defaultdict = %{:0};
// Missing keys in defaultdict default to 0
assert(defaultdict[10] == 0);
// You can also mutate a missing key this way
defaultdict[33] += 7; // adds 7 to 0 and associates it to key 33
Note: Lists are copied by reference. If you use
[]as a dictionary default value, every missing key gets the same list instance. Use a default function instead.
Default functions
You can also specify the default value as a function. Andy C++ evaluates that function each time it needs a new value. Use this form when each missing key needs its own list.
let dd = %{:fn() => []};
dd["foo"].push(3);
dd["bar"].push(4);
assert_eq(dd["foo"].len, 1);
assert_eq(dd["bar"].len, 1);
You can still use a function itself as the default value:
let dd = %{:fn() => fn(x) => x * x};
print(dd["test"](5)); // 25
Iteration
Iterate over a map with a for loop. Each element is a (key, value) tuple:
let m = %{"a": 1, "b": 2};
for (k, v) in m {
print(k, v);
}
Iteration order is unspecified. Maps are hash-based, so keys may appear in any order.
Keys are snapshotted at the start of the loop. Mutations to the map during iteration, such as adding or removing keys, are not reflected in the current loop. The set of keys visited is fixed when the for loop begins. Values read during iteration do reflect changes to existing keys.
Operators
| Operator | Function | Support augmented assignment [1] | Augmentable with not |
|---|---|---|---|
| | Union | true | false |
& | Intersection | true | false |
~ | Symmetric difference | true | false |
in | Test if lhs is part of the Set or a key in the Map | true | true |
== | Equality | true | true |
!= | Inequality | true | true |
Note: The union, intersection and symmetric difference operations retain the default value from the left operand
Note: The equality operations ignore the default value
Deque
A double-ended queue backed by Rust’s VecDeque.
This is very useful for implementing algorithms like BFS.
let queue = Deque();
queue.push_back((0, 0));
while not queue.is_empty() {
let cur = queue.pop_front();
// etc.
}
MinHeap & MaxHeap
A data structure backed by Rust’s BinaryHeap that keeps elements in sorted order. This is very useful when implementing algorithms like Dijkstra and A*.
Note: comparisons between types like
IntandStringare undefined and the Heap will treat them as equal. If you like well-defined behavior DO NOT MIX THEM.
Function
Functions are first-class values in Andy C++. You can store them in variables, pass them as arguments, and call them like any other value.
Named functions
Declare named functions with fn.
fn my_function(input) {
if input == something {
return foo;
}
// do some more stuff
// implicitly return the last expression in the block
bar
}
Function declarations are not hoisted. Define a function before you call it.
foo(); // ERROR: no function called foo exists
fn foo() {
print("Hello, World!");
}
Functions close over their environment.
let x = [];
fn my_function(n) {
// add argument n to x
x.push(n);
// return the sum of x
x.sum()
}
assert_eq(10, my_function(10));
assert_eq(15, my_function(5));
assert_eq([10, 5], x);
Anonymous functions
Anonymous functions let you write inline function values.
// An anonymous function that adds its operands together
let my_function = fn(a, b) { a + b };
assert_eq(my_function(5, 3), 8);
fn foo() {
// whatever
}
let my_variable = foo; // Store the foo-function in a variable
map applies an anonymous function to each element in the list:
let my_list = [x for x in 1..10];
let out = my_list.map(fn(x) => x * 10);
Arrow syntax
Use the fat arrow => for functions that return a single expression.
// Works for anonymous functions
let my_function = fn(a, b) => a + b;
// Also works for named functions
fn foo() => "whatever";
// Note that commas have a very low precedence in this example x is a tuple (function, 3)
let x = fn(y) => y, 3;
// If you want to return a tuple from a function written in this way you must use parentheses
let x = fn(y) => (y, 3);
Function overloading
You can overload functions by declaring multiple fn definitions with the same name and different parameter counts.
fn add(n) { n + 1 };
fn add(x, y) { x + y };
assert_eq(10, add(9));
assert_eq(12, add(8, 4));
Declaring two fn definitions with the same name and the same number of parameters in the same scope is an error:
fn foo(a) { a + 1 }
fn foo(a) { a + 2 } // ERROR: redefinition of 'foo' with 1 parameter
Note: The engine can also overload functions by argument type, and the standard library uses that support in a few places. You cannot write those overloads in user code yet because the language does not let you declare argument types in function signatures.
Function shadowing
A fn declaration in a nested scope shadows only the overload with the same parameter count from outer scopes. Other overloads remain available:
fn foo(a) { "outer-one" }
fn foo(a, b) { "outer-two" }
{
fn foo(a) { "inner-one" }
foo("x"); // "inner-one": inner 1-arg shadows outer 1-arg
foo("x", "y"); // "outer-two": outer 2-arg still reachable
}
foo("x"); // "outer-one": shadow is gone after the block
A let binding with a function value replaces all previous bindings with the same name. It does not participate in function overloading:
fn foo(a) { "one" }
fn foo(a, b) { "two" }
let foo = fn(a) => "let";
foo("x"); // "let": both fn overloads are shadowed
A non-function let binding shadows the name for value access, but function calls still resolve to the underlying function:
let len = 300;
len; // 300: the value
len("test"); // 4: calls the stdlib function, skipping the non-function binding
"test".len; // 4: method call also resolves to the function
Method call syntax
In Andy C++, you can call a function as a method on its first argument. You get method-style syntax without defining member functions.
fn add(x, y) {
return x + y;
}
// Normal function call
print(add(3, 5));
// Method-like syntax
print(3.add(5));
Both forms do the same thing.
Implicit call
You can also omit () when you call a 0-ary function.
let input = "some text\nsome more text";
let lines = input.lines;
Control flow
Control flow in Andy C++ uses expressions and keywords instead of punctuation-heavy syntax. This section covers branching, loops, and the lazy logical operators that often appear in conditions.
If/else
If-statements are very similar to how they are in Go and Rust. You don’t need parentheses around the conditions but braces around the body are required.
if x == 3 {
// x is three
} else if y == 4 {
// y is four
} else {
// neither x is three or y is four
}
Expressions
Just like in Rust they are expressions and can be used to return a value when you omit the semicolon at the end of the last statement in the block.
let n = 5;
let x = if n > 0 {
1
} else if n == 0 {
0
} else {
-1
};
While loop
Like in every other language a while loop will run as long as a condition is true.
let n = 1;
while n <= 100 {
if n % 15 == 0 {
print("fizzbuzz");
} else if n % 3 == 0 {
print("fizz");
} else if n % 5 == 0 {
print("buzz");
} else {
print(n);
}
n += 1;
}
For loop
Start with a basic for loop:
for n in 1..=100 {
if n % 15 == 0 {
print("fizzbuzz");
} else if n % 3 == 0 {
print("fizz");
} else if n % 5 == 0 {
print("buzz");
} else {
print(n);
}
}
You can combine multiple iterators in one loop:
let drinks = ["Coffee", "Tea", "Juice"];
let desserts = ["Cake", "Pie", "Ice Cream"];
// Print all combinations of drinks and desserts
for drink in drinks, dessert in desserts {
print(drink <> " and " <> dessert);
}
You can also add one or more guards. This example finds all pairs from 1..10 with an even sum.
for x in 1..10, y in 1..10, if (x + y) % 2 == 0 {
print(x, y, "is even");
}
For comprehensions
You can use the same syntax in a list comprehension.
// Produce a series of perfect squares
let perfect_squares = [x * x for x in 1..10];
assert_eq([1,4,9,16,25,36,49,64,81,100], perfect_squares);
The earlier example can also produce pairs:
let pairs_with_even_sum = [x, y for x in 1..10, y in 1..10, if (x + y) % 2 == 0]
Logical operators
Andy C++ uses the keywords and, or, and not for logical operators. If you are coming from Rust, write and and or instead of && and ||.
let ready = has_input and not failed;
let retry = timed_out or disconnected;
Short-circuiting
and and or are lazy. Andy C++ evaluates the right-hand side only when it needs that value to decide the result.
let x = 0;
true and { x = x + 1; false };
false and { x = x + 1; false };
true or { x = x + 1; false };
false or { x = x + 1; false };
assert_eq(x, 2);
false and ... stops at false, and true or ... stops at true.
Precedence
and binds tighter than or.
let a = true or true and false; // true
let b = false and true or true; // true
That means Andy C++ reads those expressions as:
let a = true or (true and false);
let b = (false and true) or true;
Bitwise operators
Boolean values also support the non-lazy operators &, |, and ~.
Use and and or when you want short-circuiting. Use & and | when you need both sides to run.
Memory Management
Andy C++ uses reference counting to manage memory. Every value that lives on the heap (strings, lists, maps, closures, etc.) is wrapped in a reference-counted pointer. When the last reference to a value is dropped, it is freed immediately — there is no garbage collector and no pause times.
Reference Cycles
Reference counting cannot detect cycles: two or more values that refer to each other. When a cycle exists, the reference count of each value in the cycle never reaches zero, so the memory is never freed.
There are two common ways to create cycles:
Mutable containers that reference each other
{
let m = %{};
let n = %{};
m["n"] = n;
n["m"] = m;
}
// m and n are out of scope, but each map still holds
// a reference to the other — neither can be freed.
Self-referential closures
A closure that captures a variable which holds a reference back to itself creates a cycle:
{
let f = ();
f = fn(x) { if x > 0 then f(x - 1) else 0 };
}
// The upvalue for f is now closed over a closure
// that itself holds a reference to the same upvalue.
Practical Impact
For short-lived scripts this is a non-issue — all memory is reclaimed when the process exits. Cycles only become a problem in long-running programs that repeatedly create and discard cyclic structures. If you find yourself in that situation, break the cycle manually by assigning () to one side before the values go out of scope:
m["n"] = (); // break the cycle
Augmented assignment
Andy C++ supports augmented assignment for operations on existing variables.
This example increments a number with augmented assignment.
let my_number = 3;
my_number += 5;
assert_eq(my_number, 8);
Optimization
You might expect list ++= [1,2,3] to desugar to list = list ++ [1,2,3], but that would waste work. Andy C++ handles some augmented assignments directly. In this case, it appends [1,2,3] without creating an intermediate list.
Flexibility
Note: I stole this feature from Noulith.
Augmented assignment also works with built-in functions and user-defined functions. For example:
let x = 3;
let f = fn (a, b) { a + b }; // simple addition
x f= 5; // similar to: x = f(x, 5);
assert_eq(x, 8);
One common use case is tracking the highest or lowest value in a loop:
let lowest, highest = Inf, -Inf;
for x in 1..100 {
lowest min= g(x);
highest max= g(x);
}
Method call syntax
In Andy C++, you can call a function as a method on its first argument. You get method-style syntax without defining member functions.
fn add(x, y) {
return x + y;
}
// Normal function call
print(add(3, 5));
// Method-like syntax
print(3.add(5));
Both forms do the same thing.
Implicit call
You can also omit () when you call a 0-ary function.
let input = "some text\nsome more text";
let lines = input.lines;
Examples
let l = [50, 40, 20, 40, 10];
// A plain function-call version
let x = reduce(map(sorted(l), fn(x) => x + 5), fn(a, b) => a * b);
// The same code with method call syntax
let y = l.sorted
.map(fn(x) => x + 5)
.reduce(fn(a, b) => a * b);
Slices
Use ranges to slice lists. Ranges can be inclusive or exclusive. Negative indices count from the end of the list.
let my_list = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
// Exclusive range: 3 to 6 (does not include index 6)
assert_eq([30, 40, 50], my_list[3..6]);
// Inclusive range: 3 to 6 (includes index 6)
assert_eq([30, 40, 50, 60], my_list[3..=6]);
// Negative indices: Counting from the end of the list
assert_eq([80, 90], my_list[-3..-1]);
Memoization
Andy C++ supports memoization through the pure keyword. Mark a function as pure when it has no side effects and returns the same output for the same inputs. Andy C++ can then cache and reuse previous results.
Syntax
Mark both named and anonymous functions as pure:
pure fn add(x, y) {
x + y
}
let multiply = pure fn (x, y) { x * y };
Note: The interpreter does not check whether a function is actually pure. You must avoid side effects yourself.
Performance: keep memoization keys small
The cache key is computed by hashing all arguments. For container types like maps and lists this
is an O(n) operation proportional to the number of elements. Passing large containers as arguments
to a pure fn therefore adds hashing overhead on every call, even on cache hits.
If a container is large and does not change between recursive calls, such as a lookup table or graph, capture it as an upvalue instead of passing it as an argument. The upvalue is not part of the cache key, so memoization cost depends only on the arguments that change.
// Slow: `graph` is hashed on every call
pure fn count(graph, node, visited) { ... }
// Fast: `graph` captured as upvalue, only (node, visited) are hashed
let graph = build_graph();
pure fn count(node, visited) {
// use graph here
}
Example: Fibonacci Sequence
pure fn fib (n) {
return if n == 0 {
0
} else if n <= 2 {
1
} else {
fib(n-2) + fib(n-1)
}
}
for x in 1..1000 {
print(x, fib(x));
}
Tracing
Andy C++ has built-in tracing support for inspecting how the bytecode VM executes your program. Tracing is available when the binary is compiled with the trace feature flag and is controlled via command-line flags on the run subcommand.
cargo run --features trace -- run --trace-print program.ndc
Multiple trace flags can be combined in a single invocation.
Trace modes
--trace-print
Prints every VM instruction to stderr as it is dispatched, along with the corresponding source excerpt:
[VM] 0000 GetGlobal(2) assert_eq
[VM] 0001 GetGlobal(87) +
[VM] 0002 Constant(0) 15
[VM] 0003 Constant(1) 3
[VM] 0004 Call(2) +
This is useful for understanding the exact sequence of bytecode operations your program produces.
--trace-histogram
Prints a summary table of how many times each instruction type was dispatched:
--------------------------------------------------
Instruction histogram (179 total)
--------------------------------------------------
Call 43 ( 24.0%)
Constant 44 ( 24.6%)
GetLocal 32 ( 17.9%)
...
--trace-time
Measures cumulative wall-clock time spent per instruction type and prints a summary:
------------------------------------------------------------
Instruction timing (total: 184µs)
------------------------------------------------------------
Call 69µs ( 37.4%)
Constant 33µs ( 18.1%)
...
The time for an instruction is measured from when it starts executing until the next instruction begins.
--trace-span
Renders the source code as a heat map, coloring regions from green (cold) to red (hot) based on how much execution time was spent on the bytecode instructions associated with each source span.
cargo run --features trace -- run --trace-span program.ndc
This gives a visual overview of where your program spends its time. The heat is additive: if a character is covered by multiple overlapping spans (e.g. an expression inside a loop body), all their durations contribute, so hot inner code within a hot loop shows as hotter than the surrounding syntax.
Note: The span-based heat map is a rough profiling aid, not a precise profiler. In particular, recursive function calls can produce misleading results because the function body spans overlap with themselves across call depths, and the timing of
Callinstructions only measures call-setup overhead rather than the total time spent in the callee.
Building with tracing
Tracing is behind a Cargo feature flag so it has zero cost when not compiled in:
# Without tracing (default) — no overhead
cargo build
# With tracing support
cargo build --features trace
The trace flags only appear in --help when compiled with the feature enabled.
Overload dispatch with collections
Background
When Andy C++ can determine at compile time which function overload to call, it does so — the
call is free of any type-checking overhead at runtime. When it cannot (because an argument was
inferred as Any), the VM performs dynamic dispatch: it tests each candidate overload at
runtime to find the best match.
O(1) dispatch guarantee
For dynamic dispatch the VM checks whether a value conforms to the parameter type without iterating the container contents. Specifically:
| Parameter type | Check performed |
|---|---|
List | Is the value a list? |
Map | Is the value a map? |
Deque | Is the value a deque? |
Sequence | Is the value any sequence type? |
String, Int, … | Exact kind check |
This means that dispatch is O(1) regardless of how many elements are in the collection.
Limitation: element types are not checked at runtime
Because the element-type check is skipped, the VM cannot distinguish overloads that differ only in their container element types via dynamic dispatch. For example, two hypothetical overloads:
fn process(List<Int>)
fn process(List<String>)
would both fail to match under dynamic dispatch if the list type cannot be resolved at compile time, because verifying element types would require scanning the entire container.
In practice this limitation is not currently visible:
- User-defined functions cannot yet declare typed container parameters (the syntax is not
implemented), so user overloads always use
Anyand dispatch works correctly. - All standard library overloads on container parameters use
<Any>element types (e.g.List<Any>,Sequence<Any>,Map<Any, Any>), so they also hit the fast path. Overloads that differ by container kind (e.g.pop(List<Any>)vspop(MinHeap<Any>)) are distinguished by the container kind check alone.
Workaround
If you notice that a function call unexpectedly fails to match an overload, move the call to a location where Andy C++ can infer the argument types statically — for example, directly at the call site rather than through an intermediate untyped function parameter:
// The type of `data` is Any here — dynamic dispatch used
fn handle(data) {
process(data)
}
// Preferred: call process() directly where the type is known
process(my_list)