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.
| Bits | Signed | Unsigned |
|---|---|---|
| 8 | i8, char | u8, byte |
| 16 | i16, short | u16, ushort |
| 32 | i32, int | u32, uint |
| 64 | i64, long | u64, 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 typeStringConstant, aHashMap<StringConstant, V>key, and aconstwith 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 aString, as before. - Converting chains once: a literal reaches a type that converts from
String(io.Path,Error) directly, but aStringConstantvariable does not, since conversions never chain. WriteString(text)or pass a literal. - Comparing:
==,!=,<,<=,>and>=compare aStringConstantwith anotherStringConstant(a literal included) or with aString, either way round, and copy nothing. - A literal method call (
"a,b".split(",")) calls theStringmethod.
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:
| Escape | Character |
|---|---|
\n | newline |
\t | tab |
\r | carriage return |
\0 | the zero byte |
\\ | a backslash |
\" | a double quote |
\' | a single quote |
\xHH | the 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
voidmeans “no value”. It is the return type of a function that returns nothing.Array<T>is a growable array ofT;int[]is shorthand forArray<int>, andint[][]forArray<Array<int>>. SeeArray.Optional<T>is a value that may be missing;int?is shorthand forOptional<int>. See Optional values.HashMap<K, V>andHashSet<K>are hash tables. See HashMap and HashSet.Result<TValue, TError>andErrordescribe 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 intis-7. - Integer to a narrower integer keeps the low bits:
70000 as u16is 4464. - Integer to a wider integer sign-extends a signed source and zero-extends an
unsigned one:
(-1 as i16) as longis -1, and(65535 as u16) as longis 65535. booland enums convert like integers:true as intis 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? Squaregives aSquare?(see Converting a trait value back). - A pointer converts to
__ptr<void>and back:p as __ptr<void>, thenv 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)is0x3F800000, andunsafe_cast<double>(bits)turns aulongback into adouble.
# `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, andbool) are converted tointbefore any arithmetic. The sum of twochars is anint, so100 + 100gives 200 rather than wrapping at 8 bits. - Mixed widths use the wider type:
int + longis along. - Signed and unsigned. When both operands have the same width and one is
unsigned, the operation is unsigned: comparing
int -1withuint 1treats -1 as 4294967295, so-1 < 1is false for those types. - Integer and floating point gives floating point:
3 * 0.5is 1.5. - Division of integers truncates toward zero (
-7 / 2is -3), and%takes the sign of the left operand (-7 % 3is -1). - Conditionals.
c ? a : bwith numeric arms of different types has their common type by the same rules (c ? 0 : s.lengthis aulong), and so does amatchexpression over its arms. The value converts from that type wherever it goes, and avarit initializes has that type (var n = c ? 0 : s.lengthis aulong, not anint). - 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 / -1andMIN % -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:
| Method | On 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:
| Name | Removes | Then |
|---|---|---|
overflow | The overflow checks of +, -, *, **, unary -, ++, -- | Arithmetic wraps around (never undefined). |
divide | Division and remainder by zero, and MIN / -1 | The result is undefined, as in C. |
shift | The shift count check | The count is masked to the type’s width (x << 33 on an int shifts by 1). |
bounds | Array and String index checks (a[i], byte_at) | An index out of range reads or writes arbitrary memory. |
null | Calls through null function and trait values, and match on a null reference | The program crashes or misbehaves. |
all | Every 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:
| Method | Returns |
|---|---|
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:
| Methods | C’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;
}
| Kind | Operators |
|---|---|
| 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 |
| Conditional | condition ? 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:
- Postfix: calls
f(x), indexinga[i], member accessa.b,x++,x-- **as,as?- Unary
+,-,!,~ - Prefix
++,-- *,/,%+,-<<,>>..,..>(ranges; not associative)is<,<=,>,>===,!=,===,!==&|,^&&,and||,or? :=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).