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

Types and variables

This chapter covers the values every Pepper program is built from: numbers, booleans and strings, the variables that hold them, and the operators that combine them.

Built-in types

Integers

Pepper has signed and unsigned integers of 8, 16, 32 and 64 bits. Each width has a short name (i32) and a descriptive alias (int). The two names are the same type, so you can use whichever reads better and assign freely between them.

BitsSignedUnsigned
8i8, charu8, byte
16i16, shortu16, ushort
32i32, intu32, uint
64i64, longu64, ulong

int is 32 bits and long is 64 bits on every platform. sizeof<T>() gives the size of a type in bytes.

Integer literals are written in decimal (42) or hexadecimal (0xFF). A literal on its own is an int when its value fits in an int, and a long otherwise, so var big = 3000000000; is a long. It takes another type when you store it in a variable of that type (u64 huge = 9000000000000000000;). A hexadecimal literal is a 64-bit bit pattern: u64 all = 0xFFFFFFFFFFFFFFFF; sets every bit.

# Integer types: widths, signedness, wraparound and conversions.
main(): int {
    # Each width has a short name and a descriptive alias.
    i8 tiny = -100;       # same type as char
    u8 octet = 200;       # same type as byte
    i16 small = -30000;   # same type as short
    u16 port = 65000;     # same type as ushort
    i32 count = 2000000;  # same type as int
    u32 mask = 4000000000; # same type as uint
    i64 big = 9000000000; # same type as long
    u64 huge = 9000000000000000000;  # same type as ulong

    assert(sizeof<char>() == 1 && sizeof<short>() == 2, "8 and 16 bits");
    assert(sizeof<int>() == 4 && sizeof<long>() == 8, "32 and 64 bits");

    # An alias names the same type, so assigning between them is lossless.
    short s = small;
    assert(s == -30000, "short and i16 are one type");

    # Storing a value that does not fit wraps around, as in C.
    u16 wrap = 65535;
    wrap = wrap + 1;
    assert(wrap == 0, "u16 wraps from 65535 to 0");
    i16 overflow = 32767;
    overflow += 1;
    assert(overflow == -32768, "i16 wraps from 32767 to -32768");

    # Widening keeps the value: signed types sign-extend, unsigned zero-extend.
    long widened = small;
    assert(widened == -30000, "i16 widens to long");
    assert((tiny as u8) == 156, "-100 as u8 is 156");
    assert((octet as i8) == -56, "200 as i8 is -56");

    # Narrowing with 'as' keeps the low bits.
    assert((70000 as u16) == 4464, "70000 as u16 keeps the low 16 bits");

    # Hexadecimal literals.
    assert(0xFF == 255, "0xFF is 255");
    assert(count + big > 0 && mask > 0 && huge > 0 && port > 0, "all in range");
    return 0;
}

Storing a value that does not fit in the destination keeps its low bits, as in C: a u16 holding 65535 becomes 0 when you add 1 and store the result, and 70000 as u16 is 4464. Arithmetic that overflows int or a wider type panics instead (see Integer overflow).

A decimal literal larger than the largest long (9223372036854775807), or a hexadecimal literal with more than 64 bits, is an error:

main.pep:2:13: error: integer literal is too large to be represented in a 64-bit integer
    2 |     var f = 99999999999999999999;
      |             ^~~~~~~~~~~~~~~~~~~~

Floating point

float is a 32-bit and double a 64-bit IEEE 754 number (f32 and f64 are aliases). A literal with a decimal point or an exponent, such as 0.25 or 1e3, is a double.

Comparisons follow IEEE 754, as in C: against NaN (0.0 / 0.0), ==, <, <=, > and >= are false and != is true, so x != x is true only for a NaN. As a condition, a float is true unless it equals zero, NaN included.

Reading numbers from text

Every number type has a static parse that reads one from a String: int.parse("42") gives an int? (see optionals), empty when the text is not a number of that type: not digits, or too big or too small for it. The integer types take a base from 2 to 36 as a second argument ("ff" in base 16 is 255), and float and double read decimal notation ("2.5", ".5", "1e-3"). Neither accepts spaces, a 0x prefix, inf or nan. Each spelling of a type is the same type, so i32.parse is int.parse:

# Reading numbers from text with each number type's static parse.

main(): int {
    # An integer type takes an optional base from 2 to 36.
    assert(int.parse("-42").value() == -42, "decimal");
    assert(byte.parse("ff", 16).value() == 255, "hexadecimal");

    # The result is empty when the text is not a number of that type.
    assert(int.parse("4 2").is_empty(), "not an integer");
    assert(byte.parse("256").is_empty(), "too big for a byte");

    # float and double read decimal notation.
    var ratio = double.parse("2.5e-1");
    if (ratio.is_value()) {
        assert(ratio.value() == 0.25, "an exponent");
    }
    return 0;
}

Booleans

bool holds true or false. Comparisons and the logical operators produce a bool. In arithmetic a bool counts as 0 or 1, and its text is true or false ($"{flag}").

Characters

A character literal is one ASCII character between single quotes: 'a', '"', '#'. Its value is the character’s code, and its type is byte (u8), the type of one byte of a UTF-8 string. Like any byte it widens to the other integer types, so it compares with, and stores into, an int or a char as well:

# Character literals are bytes.
is_lower(c: int): bool {
    return c >= 'a' and c <= 'z';
}

main(): int {
    var letter = 'a';   # a byte holding 97
    assert(letter == 97, "a character literal is its ASCII code");
    assert('"' == 34 and '\n' == 10 and '\'' == 39, "escapes work as in strings");

    # Compare the bytes of a String with character literals.
    var text = "x = 1";
    var bytes = text.c_str();
    assert(bytes[0] == 'x' and bytes[2] == '=', "compare raw bytes");
    assert(is_lower(bytes[0]), "a byte widens to int");

    # Arithmetic works as on any integer.
    int digit = bytes[4] - '0';
    assert(digit == 1, "the digit's value");

    print($"{letter}");   # 97
    return 0;
}

The escapes are the same as a string’s (see the table under Strings), plus \xHH for any byte, '\xFF' included. A character literal holds exactly one ASCII character; for anything else use a string:

main.pep:2:13: error: character literal may only contain one character; use a string for more
    2 |     var x = 'ab';
      |             ^~~~
main.pep:2:14: error: character literal must be a single ASCII character; use a string for other characters
    2 |     var x = 'é';
      |              ^

char is an 8-bit signed integer, the same type as i8, used mostly for C strings (__ptr<char>). The ASCII characters have the same value as a char and as a byte; '\xFF' is 255 as a byte but -1 read through a char. Text is a String, and a single character of it is a one-code-point String.

byte and char classify ASCII characters as C’s ctype.h does in the “C” locale: is_digit, is_hex_digit, is_alphabetic, is_alphanumeric, is_upper, is_lower, is_whitespace, is_blank, is_punctuation, is_graphic, is_printable, is_control and is_ascii, plus to_upper, to_lower and digit_value(base). The names are spelled out (is_punctuation for C’s ispunct). A byte above 127, which is a negative char, is never a letter, digit, space or punctuation. Unicode classification is not covered.

# Classify and convert ASCII characters, as C's ctype.h does.
main(): int {
    assert('7'.is_digit() and 'f'.is_hex_digit(), "digits");
    assert('q'.is_alphabetic() and 'Q'.is_upper() and 'q'.is_lower(), "letters");
    assert('\t'.is_whitespace() and '\t'.is_blank(), "whitespace");
    assert(';'.is_punctuation() and !'_'.is_alphanumeric(), "punctuation");
    assert('\n'.is_control() and !'\n'.is_printable(), "control");
    assert('a'.to_upper() == 'A' and '?'.to_upper() == '?', "case");

    # The digit's value in a base, or none when it is not a digit there.
    assert('F'.digit_value(16).value() == 15, "hexadecimal");
    assert('9'.digit_value(8).is_empty(), "not an octal digit");

    # Count the words and digits of a String, a byte at a time.
    var text = "Call 555-0100 at 9 a.m.";
    var words = 0;
    var digits = 0;
    var in_word = false;
    for (var b in text.bytes()) {
        if (b.is_digit()) {
            digits = digits + 1;
        }
        if (b.is_alphanumeric() and !in_word) {
            words = words + 1;
        }
        in_word = b.is_alphanumeric();
    }

    # A char answers the same, and bytes above 127 (negative as a char)
    # are never letters.
    char e_acute = -61;   # the first byte of "é" in UTF-8
    assert(!e_acute.is_alphabetic() and !e_acute.is_ascii(), "not ASCII");

    print($"{words} words, {digits} digits");   # 7 words, 8 digits
    return 0;
}

Strings

String is a UTF-8 string. A string literal such as "hello" is a String value wherever a String is expected, and wherever nothing asks for another type (var s = "hello"; declares a String). Where it is stored or passed as a __ptr<char>, it is the literal’s NUL-terminated bytes (see Passing strings), and where a StringConstant is expected, it is one. + joins two strings into a new one, == and != compare contents, the length property counts code points, size counts bytes, and s[i] gives the code point at index i as a one-code-point String.

# String literals are String values.
main(): int {
    var language = "Pepper";
    assert(language.length == 6, "6 code points");

    # + makes a new String; == compares contents.
    var message = "Hello, " + language;
    assert(message == "Hello, Pepper", "concatenation");
    assert(message != language, "different contents");

    # Strings are UTF-8. `length` counts code points, `size` counts bytes.
    var word = "héllo";
    assert(word.length == 5, "5 code points");
    assert(word.size == 6, "6 bytes: 'é' takes 2");
    assert(word[1] == "é", "indexing yields a one-code-point String");

    # A String is a reference: both names see an append.
    var alias = message;
    message.append("!");
    assert(alias == "Hello, Pepper!", "one object, two references");

    print(message);
    return 0;
}

A String is a reference type: var alias = message; makes a second reference to the same object rather than a copy, so a change made with append shows through both names. Memory and reference counting explains how references work, and the String reference lists every method.

Every place a literal is stored (a variable, a field, an array element, a function’s result) gets a new String, so changing it never changes the literal: after var s = "a"; s.append("b"); the next "a" is still "a". Where a literal is only read, passed to a parameter that the function neither keeps nor changes (builder.append("x"), name.contains("x")), every evaluation shares one constant object and allocates nothing.

String constants

A literal’s own type is StringConstant: a pointer to the literal’s bytes, which never change and are never freed, with their size and length. It is a [Value] type, so copying one steps no reference count, any thread may read it, and a literal stored as one allocates nothing. Its bytes always end in a NUL byte, so c_str() passes it to C as it is (a String’s c_str() does too, but a String built with no data has none).

A StringConstant has String’s read API (length, size, find, split, trim, ==, <, the indexer, for loops and so on); the methods that make new text return a String. It converts to a String wherever a String is expected, as if String had an [Implicit] constructor taking it: the conversion (and String(text)) copies the bytes, so an API that takes a String takes a StringConstant too. Where C expects a char*, it passes its own bytes, as c_str() does. Among overloads, a literal picks a StringConstant parameter over a String one, and a StringConstant value picks a String parameter last, as an implicit conversion.

# A string literal's own type is StringConstant: its constant bytes, with no
# allocation and no reference count.
extern ulong strlen(char* text);

# A `const` initialized from a literal is a StringConstant.
const GREETING = "hello";

shout(text: String): String {
    return text.upper() + "!";
}

main(): int {
    StringConstant name = "pepper";
    assert(name.length == 6, "the read API of String");
    assert(name.starts_with("pep") && name.find("p", 1) == 2, "find counts bytes");

    # Always NUL-terminated: c_str() goes to C with no copy.
    assert(strlen(GREETING.c_str()) == 5, "C reads the literal's bytes");

    # A StringConstant converts to a String wherever one is expected.
    String copy = name;
    copy.append("!");
    assert(copy == "pepper!" && name == "pepper", "the copy has its own bytes");
    print(shout(GREETING));

    # Compared with a String either way round, with no copy.
    String typed = "pepper";
    assert(name == typed && typed == name && GREETING < typed, "mixed comparisons");

    # Copying a StringConstant copies a pointer.
    var also = name;
    assert(also == name, "same text");
    return 0;
}
  • Where a literal is a StringConstant: a variable, field, parameter or return value of type StringConstant, a HashMap<StringConstant, V> key, and a const with no written type (const GREETING = "hello";), so a module-scope text constant allocates nothing and any thread may read it. Everywhere else, var s = "x" included, a literal is a String, as before.
  • Converting chains once: a literal reaches a type that converts from String (io.Path, Error) directly, but a StringConstant variable does not, since conversions never chain. Write String(text) or pass a literal.
  • Comparing: ==, !=, <, <=, > and >= compare a StringConstant with another StringConstant (a literal included) or with a String, either way round, and copy nothing.
  • A literal method call ("a,b".split(",")) calls the String method.

to_string turns any integer into its decimal text. For parsing and generating text, byte_at and substr_bytes work in bytes (the offsets find returns), compare and <, <=, >, >= order strings byte by byte (code-point order for UTF-8), lines() splits on "\n" or "\r\n", trim(), trim_start() and trim_end() drop whitespace, lower() and upper() change the case of ASCII letters, repeat(n) joins n copies, and bytes() copies the UTF-8 bytes into an array. A StringBuilder builds a long String from many pieces without recopying the text built so far on every append:

# Numbers to text, byte-level access, ordering, and building text in pieces.
main(): int {
    # to_string turns any integer into its decimal text.
    var line = 3;
    var where = to_string(line) + ":" + to_string(-7);
    assert(where == "3:-7", "to_string");

    # byte_at and substr_bytes count bytes, so they work with find's offsets.
    var setting = "indent=4";
    var eq = setting.find("=") as ulong;
    assert(setting.byte_at(eq) == '=', "byte_at compares with a character literal");
    assert(setting.substr_bytes(0, eq) == "indent", "the bytes before '='");

    # compare, <, <=, > and >= order strings byte by byte.
    assert("apple" < "banana", "ordering");
    assert("b".compare("a") > 0, "compare is negative, 0 or positive");

    # lines() splits on "\n" or "\r\n"; trim_end() drops trailing whitespace.
    var lines = "one  \r\ntwo\n".lines();
    assert(lines.count == 2, "no empty line after the final newline");
    assert(lines[0].trim_end() == "one", "trim_end");

    # trim() drops whitespace at both ends; lower() and upper() change ASCII case.
    var field = "  Content-Type ".trim();
    assert(field.lower() == "content-type", "lower");
    assert(field.upper() == "CONTENT-TYPE", "upper");

    # repeat joins copies; StringBuilder appends without recopying the text.
    var out = StringBuilder();
    for (var text in lines) {
        out.append(" ".repeat(4)).append(text.trim_end()).append("\n");
    }
    assert(out.to_string() == "    one\n    two\n", "StringBuilder");
    return 0;
}

To search and take text apart, find(needle, from) starts at a byte offset and rfind finds the last match, split(separator) cuts at every separator and split_once at the first (giving an optional pair), separator.join(parts) puts the parts back together, and replace(from, to) swaps every match (to read a number from text, see Reading numbers from text):

# Searching, splitting, joining and replacing text.

main(): int {
    # find takes a byte offset to start from; rfind finds the last match.
    var path = "src/lib/main.pep";
    var first = path.find("/");
    assert(path.find("/", (first as ulong) + 1) == 7, "the second slash");
    assert(path.rfind("/") == 7, "the last slash");

    # split cuts at every separator; split_once at the first, if any.
    var fields = "name,,age".split(",");
    assert(fields.count == 3 && fields[1] == "", "an empty field");
    var header = "Content-Type: text/plain".split_once(": ");
    if (header.is_value()) {
        var (name, value) = header.value();
        assert(name == "Content-Type" && value == "text/plain", "both halves");
    }

    # join puts the separator between the parts; replace swaps every match.
    assert(" | ".join(fields) == "name |  | age", "joined");
    assert("a\r\nb\r\n".replace("\r\n", "\n") == "a\nb\n", "line endings");
    return 0;
}

An interpolated string puts values’ text into a string literal: $"line {line + 1}: {message}". String interpolation covers which values it accepts.

A backslash in a string literal starts an escape sequence, as in C and Rust:

EscapeCharacter
\nnewline
\ttab
\rcarriage return
\0the zero byte
\\a backslash
\"a double quote
\'a single quote
\xHHthe ASCII character with hexadecimal code HH, at most \x7F (any byte in a character literal)
\u{H...}the Unicode code point with hexadecimal code H... (1 to 6 digits)
print("name:\t\"Pepper\"");   # name:	"Pepper"
print("caf\u{e9}");            # café

Any other character after a backslash is an error:

main.pep:2:13: error: unknown escape sequence '\q'
    2 |     print("a\qb");
      |             ^~

Other built-in types

  • void means “no value”. It is the return type of a function that returns nothing.
  • Array<T> is a growable array of T; int[] is shorthand for Array<int>, and int[][] for Array<Array<int>>. See Array.
  • Optional<T> is a value that may be missing; int? is shorthand for Optional<int>. See Optional values.
  • HashMap<K, V> and HashSet<K> are hash tables. See HashMap and HashSet.
  • Result<TValue, TError> and Error describe operations that can fail. See Error handling with Result.
  • async.Atomic<T> is a value that threads can update safely. See Atomics and Threads and shared data.

Raw pointers and __null

For talking to C, Pepper has raw pointers, written __ptr<T> (for example __ptr<char> or __ptr<void>), and the null pointer literal __null:

var p: __ptr<void> = __null;
assert(p == __null, "p is null");

Most programs never need them. Calling C covers pointers in full.

Naming conventions

Primitive types and keywords are lowercase: int, float, bool, var, while. Types, including the ones the runtime provides, are PascalCase: String, Array<T>, HashMap<K, V>, Result<TValue, TError>. Functions, variables and fields are snake_case, and constants are SCREAMING_CASE. Naming conventions has the full rules.

Identifiers are made of ASCII letters, digits and underscores, and cannot start with a digit. Avoid names that start with two underscores: the compiler and runtime use them for their own names, such as __ptr and __null.

Variables

Declare a variable with var and let Pepper infer its type from the initial value, or write the type yourself. Prefer var whenever the type is obvious from the value.

import async;

# Declaring variables: inferred, explicitly typed, constant, and module-level.

# Module-level variables (globals) live for the whole program. Any thread
# can reach them, so they are const and of a thread-safe type: a number, or
# an Atomic for state that changes.
const MAX_REQUESTS = 10;
const request_count = async.Atomic<int>(0);
const total_bytes = async.Atomic<long>(0);

record(bytes: int) {
    request_count.fetch_add(1);
    total_bytes.fetch_add(bytes);
}

main(): int {
    # 'var' infers the type from the initializer.
    var answer = 42;          # int: integer literals are int
    var ratio = 0.25;         # double: floating-point literals are double
    var ready = true;         # bool
    var name = "Pepper";      # String

    # Write the type first, C style...
    int width = 80;
    float scale = 1.5;

    # ...or after the name with a colon.
    var height: int = 24;
    var offset: long = 5000000000;

    # A variable declared without a value starts at zero.
    var hits: int;
    assert(hits == 0, "numbers start at zero");

    # 'const' declares a name that should not be reassigned.
    const LIMIT = 3;

    record(100);
    record(250);
    assert(request_count.load() == 2, "two requests recorded");
    assert(total_bytes.load() == 350, "350 bytes in total");
    assert(request_count.load() < MAX_REQUESTS, "globals are visible in every function");

    assert(answer + width + height == 146, "sum of ints");
    assert(ratio * 4 == 1.0 && scale == 1.5, "floating point");
    assert(ready && name == "Pepper", "bool and String");
    assert(offset > 4000000000 && LIMIT == 3, "long and const");
    return 0;
}

The three forms are:

  • var name = value; infers the type from the value.
  • Type name = value; is the C style, with the type first.
  • var name: Type = value; puts the type after the name.

A variable declared without a value starts out as zero: 0 for numbers, false for bool, and null for a struct type such as String. Give struct variables a value before you use them.

When var infers the type of an arithmetic expression, it takes the type the arithmetic produces under C’s rules (see Arithmetic follows C): var m = 1 + 0.5; is a double holding 1.5, and with u8 small = 250;, var s = small + 10; is an int holding 260.

Constants

const declares a name that you do not intend to change:

const LIMIT = 3;
const RATIO: double = 0.5;

Assigning to a const, with =, a compound assignment, ++ or --, is an error:

main.pep:5:5: error: cannot assign to const variable 'LIMIT'
    5 |     LIMIT = 4;
      |     ^~~~~
main.pep:1:7: note: 'LIMIT' declared const here
    1 | const LIMIT = 3;
      |       ^~~~~

Module-level variables

A variable declared outside any function is a module-level (global) variable. It exists for the whole run of the program and every function in the file can read it. Its initializer can be any expression, including a function call, and runs before main. Like other top-level declarations, a global is private to its file (or module) unless you mark it export (see Visibility).

Any function can run on any thread, so every thread can reach a global. It must therefore be declared const and have a thread-safe type, such as a number, an Atomic<T>, a ThreadLocal<T>, or a [Shared] struct (see Module-scope variables). A global that changes is an Atomic<T>, or a [ThreadLocal] var, which every thread has its own copy of (see Thread-local variables):

const MAX_RETRIES = 3;
const requests = async.Atomic<long>(0);   # changed with requests.fetch_add(1)
[ThreadLocal]
var depth: int = 0;                       # one per thread

Casting with as

value as Type converts a value to another type explicitly, like static_cast in C++:

var ratio = 7.9;
var whole = ratio as int;   # 7
var wide = whole as long;
  • Floating point to integer truncates toward zero: -7.9 as int is -7.
  • Integer to a narrower integer keeps the low bits: 70000 as u16 is 4464.
  • Integer to a wider integer sign-extends a signed source and zero-extends an unsigned one: (-1 as i16) as long is -1, and (65535 as u16) as long is 65535.
  • bool and enums convert like integers: true as int is 1, and an enum converts to the integer it is stored as. An integer does not convert to an enum; name a case instead.
  • A struct converts to a trait it conforms to: dog as Named. The other way, from a trait value, may fail: shape as? Square gives a Square? (see Converting a trait value back).
  • A pointer converts to __ptr<void> and back: p as __ptr<void>, then v as __ptr<int>.
  • A value converts to its own type.

Anything else is a compile error at the as, since it would not be a value conversion but a reinterpretation of memory:

main.pep:3:15: error: cannot cast '__ptr<int>' to 'ulong' with 'as'; use 'unsafe_cast<ulong>(...)' to reinterpret
    3 |     var a = p as ulong;
      |               ^~

Casting between unrelated struct types, or between a struct and a number or pointer, is an error with no alternative (cannot cast 'P' to 'Q').

as binds more tightly than the arithmetic operators, so x * 10 as int means x * (10 as int). Put parentheses around the expression you want to convert: (x * 10) as int. C-style casts such as (int)x do not exist.

Many conversions also happen without as: assigning or passing a number to a variable or parameter of another numeric type converts it, as in C, including narrowing conversions (int n = 2.7; stores 2).

Reinterpreting with unsafe_cast

unsafe_cast<Type>(value) reinterprets a value’s bits as another type, like reinterpret_cast in C++. It is for C interop and low-level code, and nothing checks that the result makes sense:

  • A pointer to a pointer of another type: unsafe_cast<__ptr<u8>>(text.c_str()).
  • A pointer to an integer, and an integer to a pointer: unsafe_cast<ulong>(p), unsafe_cast<__ptr<int>>(address).
  • A number to another number of the same width, keeping its bits: unsafe_cast<uint>(1.0 as float) is 0x3F800000, and unsafe_cast<double>(bits) turns a ulong back into a double.
# `as` converts values; `unsafe_cast<T>(value)` reinterprets bits.

extern void* malloc(ulong size);
extern void free(void* p);

main(): int {
    # A float's bits, and back: the same 32 bits seen as a uint.
    var one = 1.0 as float;
    var bits = unsafe_cast<uint>(one);
    assert(bits == 0x3F800000, "1.0 is 0x3F800000");
    assert(unsafe_cast<float>(bits) == one, "the bits convert back");

    # A double's sign bit is its top bit.
    var negative = unsafe_cast<ulong>(-2.0);
    assert((negative >> 63) == 1, "the sign bit is set");

    # `as` converts a pointer to and from `__ptr<void>`.
    var memory = malloc(8);
    var words = memory as __ptr<uint>;
    words[0] = 0x01020304;
    words[1] = 0;

    # Reading the same memory as bytes reinterprets it.
    var bytes = unsafe_cast<__ptr<u8>>(words);
    assert(bytes[0] == 4 || bytes[0] == 1, "the first byte is the low or the high one");

    # An address is an integer, and an integer is an address.
    var address = unsafe_cast<ulong>(words);
    assert(unsafe_cast<__ptr<uint>>(address + 4)[0] == 0, "the address four bytes on is the second word");

    free(memory as __ptr<void>);
    return 0;
}

It never converts a struct, trait or function value, so it cannot get around reference counting. A conversion as can make is not a reinterpretation, so unsafe_cast<long>(n) on an int is an error that says to use as:

main.pep:3:13: error: 'unsafe_cast' cannot convert 'int' to 'long': the sizes differ; use 'as' to convert the value
    3 |     var w = unsafe_cast<long>(n);
      |             ^~~~~~~~~~~

Arithmetic follows C

Arithmetic on numbers behaves exactly as it does in C (the “usual arithmetic conversions”), so code ported from C gives the same results:

  • Integer promotion. Types narrower than int (char, u8, short, u16, and bool) are converted to int before any arithmetic. The sum of two chars is an int, so 100 + 100 gives 200 rather than wrapping at 8 bits.
  • Mixed widths use the wider type: int + long is a long.
  • Signed and unsigned. When both operands have the same width and one is unsigned, the operation is unsigned: comparing int -1 with uint 1 treats -1 as 4294967295, so -1 < 1 is false for those types.
  • Integer and floating point gives floating point: 3 * 0.5 is 1.5.
  • Division of integers truncates toward zero (-7 / 2 is -3), and % takes the sign of the left operand (-7 % 3 is -1).
  • Conditionals. c ? a : b with numeric arms of different types has their common type by the same rules (c ? 0 : s.length is a ulong), and so does a match expression over its arms. The value converts from that type wherever it goes, and a var it initializes has that type (var n = c ? 0 : s.length is a ulong, not an int).
  • Shifts take the type of the (promoted) left operand. >> keeps the sign for signed types and fills with zeros for unsigned types.
  • Overflow is where Pepper leaves C: it panics, for signed and unsigned types alike (see Integer overflow).
# Arithmetic follows C's rules for promotion and conversion.
main(): int {
    # Integer division truncates toward zero; % takes the sign of the left side.
    assert(7 / 2 == 3, "7 / 2 is 3");
    assert(-7 / 2 == -3, "-7 / 2 is -3");
    assert(-7 % 3 == -1, "-7 % 3 is -1");

    # Types narrower than int are promoted to int before arithmetic,
    # so the sum of two chars does not wrap at 8 bits...
    char a = 100;
    char b = 100;
    int sum = a + b;
    assert(sum == 200, "char + char is computed as int");

    # ...but storing the result back into a char does.
    char c = a + b;
    assert(c == -56, "200 does not fit in a char");

    # Mixing signed and unsigned of the same width is unsigned, as in C.
    int minus_one = -1;
    uint one = 1;
    assert(!(minus_one < one), "-1 converts to 4294967295 when compared with a uint");
    uint zero = 0;
    assert(zero.wrapping_sub(1) == 4294967295, "zero - 1 would panic; wrapping_sub wraps");

    # Mixing an integer with a floating-point value gives floating point.
    int whole = 3;
    double half = 0.5;
    double product = whole * half;
    assert(product == 1.5, "3 * 0.5 is 1.5");

    # 'as' converts explicitly. Floating point to integer truncates toward zero.
    assert((7.9 as int) == 7, "7.9 as int is 7");
    assert((-7.9 as int) == -7, "-7.9 as int is -7");
    assert(((whole as double) / 2) == 1.5, "divide as double");

    # Assignment converts implicitly, including from double to int.
    int truncated = 2.7;
    assert(truncated == 2, "2.7 stored in an int is 2");

    # Shifts: >> is arithmetic for signed types and logical for unsigned types.
    assert(-8 >> 1 == -4, "signed shift keeps the sign");
    uint bits = 0x80000000;
    assert(bits >> 31 == 1, "unsigned shift fills with zeros");
    return 0;
}

Integer overflow

Integer arithmetic whose result does not fit its type stops the program, as in Swift (and Rust’s debug builds), instead of wrapping around silently:

main.pep:3:16: panic: integer overflow in 'int' addition

The message names the operator’s location and the type the operation ran at. These operations check:

  • +, -, * and **, unary -, ++ and --, and the compound assignments (+=, -=, *=). Negating the most negative value of a signed type overflows, and so does negating any nonzero unsigned value or subtracting past 0 (uint 0 - 1).
  • / and % by zero (division by zero), and the most negative value of a signed type divided by -1 (MIN / -1 and MIN % -1), whose result does not fit.
  • << and >> by a count of at least the type’s width, or a negative one: shift count out of range for 'int': must be from 0 to 31.

Arithmetic on types narrower than int runs at int (see above), so it overflows only past int’s range; storing the result back in the narrow type keeps its low bits, like any narrowing conversion. x++ on a u8 holding 255 leaves 0, as x += 1 does. Casts with as never check: 70000 as u16 is 4464.

When both operands are constants, an overflow is a compile error at the operator, and so is a division by a constant 0:

main.pep:2:28: error: integer overflow in 'int' multiplication
    2 |     var seconds = 100000 * 100000;
      |                          ^

Wrapping on purpose

Hashes, checksums and random number generators rely on wrapping. Each integer type has methods that say what to do on overflow instead of panicking:

MethodOn overflow
wrapping_add(x), wrapping_sub(x), wrapping_mul(x)Wraps around: the result modulo 2^bits.
checked_add(x), checked_sub(x), checked_mul(x)Returns Empty (the result is a T?).
saturating_add(x), saturating_sub(x), saturating_mul(x)Clamps to the type’s highest or lowest value.
overflowing_add(x), overflowing_sub(x), overflowing_mul(x)Returns the wrapped result and true as a (T, bool).
# Arithmetic that overflows panics; these methods say what to do instead.
main(): int {
    int big = 2147483647;

    # big + 1 would stop the program: "integer overflow in 'int' addition".
    # wrapping_ wraps around, as C's unsigned arithmetic does.
    assert(big.wrapping_add(1) == -2147483648, "wraps to the lowest int");

    # checked_ returns none on overflow.
    assert(big.checked_add(1).is_empty(), "no int is big + 1");
    assert(big.checked_sub(1).value() == 2147483646, "big - 1 fits");

    # saturating_ clamps to the type's range.
    assert(big.saturating_mul(2) == 2147483647, "clamped to the highest int");
    byte level = 10;
    assert(level.saturating_sub(20) == 0, "a byte stops at 0");

    # overflowing_ returns the wrapped result and whether it overflowed.
    var (product, overflowed) = big.overflowing_mul(2);
    assert(product == -2 && overflowed, "wrapped, and overflowed");

    # A hash multiplies with wrapping on purpose (FNV-1a).
    ulong hash = 0xcbf29ce484222325;
    var text = "pepper";
    for (var i in 0..text.size) {
        hash = (hash ^ (text.byte_at(i) as ulong)).wrapping_mul(0x100000001b3);
    }
    assert(hash == 0xac11131d51a77079, "the FNV-1a hash of 'pepper'");
    return 0;
}

The methods work at their type’s own width: byte 200.checked_add(100) is Empty, while 200 + 100 on two bytes is 300 at int.

Removing the checks

The checks stay on at every optimization level. A build that has measured their cost and accepts the risk can remove kinds of check with --no-runtime-checks, a comma-separated list:

NameRemovesThen
overflowThe overflow checks of +, -, *, **, unary -, ++, --Arithmetic wraps around (never undefined).
divideDivision and remainder by zero, and MIN / -1The result is undefined, as in C.
shiftThe shift count checkThe count is masked to the type’s width (x << 33 on an int shifts by 1).
boundsArray and String index checks (a[i], byte_at)An index out of range reads or writes arbitrary memory.
nullCalls through null function and trait values, and match on a null referenceThe program crashes or misbehaves.
allEvery check above
pepper build -O main.pep --no-runtime-checks=overflow,shift

Removing bounds or null gives up Pepper’s memory safety: an index out of range or a null reference then reads or writes arbitrary memory instead of panicking. The flag does nothing at -O0, the default, where every check stays on and the compiler warns warning: '--no-runtime-checks' has no effect at -O0. It applies from -O1 up, -g included. The setting is part of the build cache’s key, like the optimization level.

To remove checks from one function instead of a whole build, mark it [Unsafe("...")] with the same names, comma-separated in one string: [Unsafe("overflow")], [Unsafe("overflow,bounds")] or [Unsafe("all")]. It removes them from that function’s body only, at every optimization level (-O0 included), so the function behaves the same in every build. Closures written in it lose the same checks. Functions it calls keep theirs, with one exception: bounds also removes the index checks that Array and String make for it, since those run inside their indexers. [Unsafe] goes on a free function, a method or a constructor. An unknown name is an error at the string.

# `[Unsafe("...")]` removes runtime checks from one function's body.

# A hash that wraps on purpose: without the overflow check, `*` and `+`
# wrap around instead of panicking.
[Unsafe("overflow")]
hash(values: Array<int>): uint {
    uint result = 2166136261;
    for (var value in values) {
        result = (result ^ (value as uint)) * 16777619;
    }
    return result;
}

# A loop that already checked its range: the index checks of `a[i]`, made by
# `Array`'s indexer for this caller, are removed too.
[Unsafe("bounds")]
sum(values: Array<int>): long {
    long total = 0;
    ulong i = 0;
    while (i < values.count) {
        total = total + values[i];
        i = i + 1;
    }
    return total;
}

main(): int {
    var values = Array<int>();
    for (var i in 0..100) {
        values.append(i);
    }
    assert(hash(values) != 0, "a hash");
    assert(sum(values) == 4950, "0 + 1 + ... + 99");
    # Outside those functions every check stays: 2147483647 + 1 here would
    # still panic.
    return 0;
}

Bit operations and math functions

Each integer type has methods that compile to a single instruction on most CPUs, through LLVM’s intrinsics, for code that would otherwise loop over bits:

MethodReturns
leading_zeros(), trailing_zeros()The zero bits above the highest set bit, or below the lowest; the type’s width for zero.
count_ones(), count_zeros()How many bits are set, or clear.
swap_bytes()The value with its bytes reversed, to convert between little and big endian.
reverse_bits()The value with its bits reversed.
rotate_left(n), rotate_right(n)The bits rotated by n, modulo the width.
min(x), max(x)The smaller or the larger value.
abs()The value without its sign (signed types). Panics for the lowest value, whose negation does not fit.

Like the overflow methods, they work at their type’s own width: byte 1.leading_zeros() is 7.

# Bit operations on integers: counting, reversing and rotating bits.
main(): int {
    uint mask = 0x00F0;
    assert(mask.trailing_zeros() == 4, "the lowest set bit is bit 4");
    assert(mask.leading_zeros() == 24, "the highest set bit is bit 7");
    assert(mask.count_ones() == 4, "four bits are set");

    # Zero has no set bit: its counts are the type's width.
    assert((0 as ulong).trailing_zeros() == 64, "no set bit");

    # The methods work at their type's own width.
    byte low = 1;
    assert(low.leading_zeros() == 7, "a byte has 8 bits");
    assert(low.rotate_right(1) == 0x80, "the bottom bit comes back at the top");

    # Byte order: swap_bytes converts between little and big endian.
    assert((0x12345678 as uint).swap_bytes() == 0x78563412, "bytes reversed");
    assert((1 as uint).reverse_bits() == 0x80000000, "bits reversed");

    # min, max and abs.
    assert((-7).abs() == 7, "without its sign");
    assert((3).min(5) == 3 && (3).max(5) == 5, "smaller and larger");
    return 0;
}

float and double have square_root(), floor(), ceiling(), truncate(), round() (halfway cases away from zero), abs(), copy_sign(sign), multiply_add(factor, addend) (this * factor + addend rounded once) and min(x) and max(x) (which ignore a NaN argument):

# Floating-point functions: square root, rounding, sign and fused
# multiply-add.
main(): int {
    var side = (2.0).square_root();
    assert(side * side > 1.999 && side * side < 2.001, "close to 2");

    assert((-1.5).floor() == -2.0, "toward negative infinity");
    assert((-1.5).ceiling() == -1.0, "toward positive infinity");
    assert((-1.5).truncate() == -1.0, "toward zero");
    assert((2.5).round() == 3.0, "halfway cases away from zero");

    assert((-4.0).abs() == 4.0, "without its sign");
    assert((4.0).copy_sign(-1.0) == -4.0, "the sign of the argument");
    assert((2.0).multiply_add(3.0, 1.0) == 7.0, "2 * 3 + 1, rounded once");
    assert((1.5).min(-2.0) == -2.0 && (1.5).max(-2.0) == 1.5, "smaller and larger");
    return 0;
}

They also have the functions of C’s <math.h>, with their names spelled out. Angles are in radians. LLVM’s intrinsics compute the ones it has, and the C math library the rest:

MethodsC’s names
sine(), cosine(), tangent()sin, cos, tan
arc_sine(), arc_cosine(), arc_tangent(), y.arc_tangent2(x)asin, acos, atan, atan2(y, x)
hyperbolic_sine(), hyperbolic_cosine(), hyperbolic_tangent()sinh, cosh, tanh
arc_hyperbolic_sine(), arc_hyperbolic_cosine(), arc_hyperbolic_tangent()asinh, acosh, atanh
exponential(), exponential2(), exponential_minus_one()exp, exp2, expm1
logarithm(), logarithm2(), logarithm10(), logarithm_one_plus()log, log2, log10, log1p
cube_root(), hypotenuse(other)cbrt, hypot
remainder(divisor)remainder (the quotient rounded to nearest; % is fmod, rounded toward zero)
is_nan(), is_infinite(), is_finite(), sign_bit()isnan, isinf, isfinite, signbit

Powers are the ** operator: x ** 0.5, or x ** 3 with an integer exponent. The math module has the constants (math.PI, math.TAU, math.E, math.INFINITY, math.NAN, and the epsilon and limits of float and double) and math.gcd and math.lcm for integers:

# Floating-point math: trigonometry, logarithms, powers, classification,
# and the math module's constants and integer functions.
import math;

main(): int {
    var angle = math.PI / 6;
    assert((angle.sine() - 0.5).abs() < 1e-12, "sin 30 degrees is 1/2");
    assert(((1.0).arc_tangent2(1.0) - math.PI / 4).abs() < 1e-12, "45 degrees");

    assert((1024.0).logarithm2() == 10.0, "2 to the 10th is 1024");
    assert((1.0).exponential() == math.E, "e to the 1st");
    assert(2.0 ** 10 == 1024.0 && (-8.0).cube_root() == -2.0, "powers and roots");
    assert((3.0).hypotenuse(4.0) == 5.0, "a 3-4-5 triangle");

    assert(math.NAN.is_nan() && math.NAN != math.NAN, "NaN is not equal to itself");
    assert(math.INFINITY.is_infinite() && !math.INFINITY.is_finite(), "infinite");
    assert((-0.0).sign_bit() && !(-0.0 < 0), "negative zero");

    assert(math.gcd(12, 18) == 6 && math.lcm(4, 6) == 12, "integers");
    return 0;
}

Hints for the optimizer are [Likely] and [Unlikely] on a branch and the prefetches of the hint module. Any other LLVM intrinsic can be declared as an extern; see LLVM intrinsics.

Operators

# Operators on numbers and booleans.
main(): int {
    # Arithmetic.
    assert(2 + 3 * 4 == 14, "* binds tighter than +");
    assert((2 + 3) * 4 == 20, "parentheses group");
    assert(17 % 5 == 2, "remainder");
    assert(-(3) == -3, "negation");

    # Exponentiation is right-associative: 2 ** 3 ** 2 is 2 ** 9.
    assert(2 ** 10 == 1024, "power");
    assert(2 ** 3 ** 2 == 512, "right-associative");
    # With a floating-point operand, the result is floating point.
    assert(2.0 ** -2 == 0.25, "float power");
    assert(9 ** 0.5 == 3.0, "square root");

    # Comparison yields a bool.
    var small = 3 < 5;
    assert(small, "3 < 5");
    assert(5 >= 5 && 4 <= 5 && 6 > 5 && 5 == 5 && 5 != 6, "comparisons");
    # On numbers, `===` and `!==` are the same as `==` and `!=`.
    assert(5 === 5 && 5 !== 6, "identity on numbers");

    # Logical operators; 'and' and 'or' are spellings of && and ||.
    var t = true;
    var f = false;
    assert(t && !f, "and, not");
    assert(f || t, "or");
    assert(t and (f or t), "word forms");

    # Bitwise operators.
    assert((6 & 3) == 2, "and");
    assert((6 | 3) == 7, "or");
    assert((6 ^ 3) == 5, "exclusive or");
    assert(~0 == -1, "complement");
    assert(1 << 4 == 16 && 256 >> 2 == 64, "shifts");

    # Compound assignment and increment.
    var n = 5;
    n += 3;   # 8
    n -= 2;   # 6
    n *= 4;   # 24
    n /= 5;   # 4
    n %= 3;   # 1
    n <<= 3;  # 8
    n >>= 1;  # 4
    n &= 6;   # 4
    assert(n == 4, "compound assignment");
    n++;
    ++n;
    n--;
    assert(n == 5, "increment and decrement");

    # The conditional (ternary) operator.
    var sign = n > 0 ? "positive" : "not positive";
    assert(sign == "positive", "ternary");
    return 0;
}
KindOperators
Arithmetic+ - * / % **, unary - and +
Comparison== != < <= > >=
Identity=== !== (two references to the same object; plain equality for numbers and bool)
Logical&& (or and), || (or or), !
Bitwise& | ^ ~ << >>
Assignment= += -= *= /= %= <<= >>= &= |= ^=
Increment++ --, prefix or postfix
Conditionalcondition ? a : b

&& and || short-circuit: the right operand is evaluated only when the left one does not decide the result, so items.count > 0 && items[0] == 1 never reads items[0] from an empty array.

Unary operators nest: -~x is -(~x), and !!x is !(!x), which turns a number into a bool. A struct can define its own operators, the truth test !! included (see Operators).

** raises a number to a power, and is right-associative: 2 ** 3 ** 2 is 2 ** 9. On two integers the result is an integer, and the exponent can be any integer expression. A negative exponent gives the truncated real result: 1 for a base of 1, 1 or -1 for a base of -1, and 0 otherwise (2 ** -1 is 0).

When either operand is a float or double, the result is floating point, by the usual conversions above: 2.0 ** 0.5 is the square root of 2, 2.0 ** -2 is 0.25, and 4 ** 0.5 is the double 2.0. An integer exponent of at most 32 bits computes by repeated multiplication (LLVM’s powi), which is fast but may differ from C’s pow in the last bit; any other exponent calls pow itself. ** on anything but numbers or a struct that defines it is an error:

main.pep:3:15: error: invalid operands to binary expression ('__ptr<int>' and 'int')
    3 |     var q = p ** 2;
      |               ^~

Operators bind in this order, tightest first:

  1. Postfix: calls f(x), indexing a[i], member access a.b, x++, x--
  2. **
  3. as, as?
  4. Unary +, -, !, ~
  5. Prefix ++, --
  6. *, /, %
  7. +, -
  8. <<, >>
  9. .., ..> (ranges; not associative)
  10. is
  11. <, <=, >, >=
  12. ==, !=, ===, !==
  13. &
  14. |, ^
  15. &&, and
  16. ||, or
  17. ? :
  18. = and the compound assignments (right-associative)

As in C, & binds more loosely than ==, so write (flags & mask) == mask with the parentheses.

An expression can nest at most 256 levels deep, counting each operator of a chain as one level, so a sum of more than about 250 terms is a compile error (see Compiler errors).