Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 of let 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
    • 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: Any is the base type for all other types. When you declare a function, its arguments default to type Any. Currently, the Any type 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:

OperatorFunction
&non-lazy and
|non-lazy or
~non-lazy xor
!not
orlazy logical or
andlazy logical and
notlogical 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 Int64 and BigInt to 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

OperatorFunctionSupport augmented assignment [1]Augmentable with not
+Additiontruefalse
-Subtractiontruefalse
unary -Negationtruefalse
*Multiplicationtruefalse
/Division (returns rational for integers)truefalse
\Floor division (integer result, rounds toward negative infinity)truefalse
^Exponentiationtruefalse
%C-style modulo (can be negative)truefalse
%%Remainder of euclidean divisiontruefalse
==Strict equalityfalsetrue
<=Less or equalfalsetrue
<Less thanfalsetrue
>=Greater or equalfalsetrue
>Greater thanfalsetrue
!=Not equalfalsetrue
<=>Comparefalsefalse
>=<Reverse comparefalsefalse
<>Concatenate string valuestruefalse

Integers also support these operations:

OperatorFunctionSupport augmented assignment [1]Augmentable with not
|Bitwise ORtruefalse
&Bitwise ANDtruefalse
~Bitwise XOR, or bitwise NOT in unary positiontruefalse
unary ~bitwise NOTtruefalse
>>Bitshift righttruefalse
<<Bitshift lefttruefalse

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

OperatorFunction
++concatenate two strings
<>coerces both operands into strings before concatenating them
incheck 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 in operator checking if the left operand is a substring of the right operand is different from the behavior of in on 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

OperatorFunction
++Concatenation
<>Coerce operands into strings and concatenate
inChecks if an element is present in the list
not inChecks 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

OperatorFunction
++Concatenation
<>Coerce operands into strings and concatenate
inChecks if an element is present in the list
not inChecks 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

OperatorFunctionSupport augmented assignment [1]Augmentable with not
|Uniontruefalse
&Intersectiontruefalse
~Symmetric differencetruefalse
inTest if lhs is part of the Set or a key in the Maptruetrue
==Equalitytruetrue
!=Inequalitytruetrue

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 Int and String are 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 Call instructions 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 typeCheck performed
ListIs the value a list?
MapIs the value a map?
DequeIs the value a deque?
SequenceIs 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 Any and 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>) vs pop(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)