Chapter 3 — Numbers, Text, and Truth
Three kinds of value will carry most of your programs: numbers, strings, and booleans. Ingle has firm opinions about all three, and the opinions are mostly “be explicit, and don’t let anything happen behind your back.”
Numbers
For everyday work there are two number types:
int— a 64-bit signed integer. (It’s an alias fori64.)float— a 64-bit floating-point number. (An alias forf64.)
Use those by default. When you genuinely care about width — packing bytes, matching a file
format, squeezing memory — the full family is there: i8 i16 i32 i64, u8 u16 u32 u64, and
f32 f64. Each is a distinct type.
let n = 42 // int
let big: i64 = 42 // the same thing, spelled out
let small: u8 = 200 // an 8-bit unsigned byte
let x = 200u8 // or pin the width with a suffix
Two rules will save you a great deal of confusion:
1. There is no implicit coercion. Ingle will never quietly turn an i32 into an i64, or
an int into a float, to make an expression type-check. Mixing them is an error you must
resolve on purpose:
let bad = 1 + 2.0 // error: you can't add an int and a float
To cross between types you convert explicitly. Integer widths convert with a type-name call —
u8(x), i32(x), i64(x) — which is range-checked and traps if the value won’t fit.
Between integers and floats you use to_float and to_int (the latter truncates toward
zero):
let a: int = 7
let b: float = to_float(a) // 7.0
let c: int = to_int(3.9) // 3
let d: u8 = u8(200) // fine; u8(300) would trap at runtime
There’s a neat split worth knowing: an out-of-range literal is caught at compile time —
let x: u8 = 300 simply won’t build (“integer literal is out of range for its type”) — while
an out-of-range conversion like u8(300) is a runtime trap. The compiler catches what it
can see at compile time; the runtime guards the rest.
2. Overflow traps; it does not wrap. If an integer calculation overflows its width, the program stops with a clear runtime error rather than silently rolling over to a wrong answer:
fn main() -> int {
var x = 200u8
x = x + 100u8 // 300 doesn't fit in a u8
return 0
}
inglec: runtime error: integer overflow
This is a deliberate safety choice. A silent wrap is one of the great sources of catastrophic, hard-to-find bugs; Ingle would rather halt and tell you. (Floating-point follows the usual IEEE-754 rules — dividing by zero gives infinity, not a trap.)
One small display quirk worth knowing now, so it doesn’t startle you later: a float that
happens to be a whole number prints without a trailing .0. A distance of five prints as
5, not 5.0. It’s still a float under the hood; it just doesn’t show off about it.
Strings
A string is text in double quotes. The familiar escapes work — \n, \t, \r, \\, \" —
and to write a literal brace (so it isn’t read as interpolation) you escape it: \{ and \}.
That { } is interpolation — "value is {x}" splices x into the string. A hole renders a
number, a string, or a bool on its own, and any value of your own type once it carries a
fn show(self) -> string method (Chapter 7 shows how).
+ joins strings, and == / != compare them by content, not by identity — two strings
that spell the same thing are equal:
let greeting = "Hello, " + "world"
let same = ("ab" == "a" + "b") // true
Strings carry a handful of built-in methods. Ingle strings are UTF-8 — multi-byte Unicode
characters are fully supported. Most methods operate in bytes (fast, ASCII-safe), and a separate
family of cp_* functions lets you work in code points when you need Unicode-correct behaviour:
s.len()— the number of bytes (not code points; they differ for non-ASCII text).s.chars()— an array of strings, one per code point (not per byte).s.bytes()— an array ofu8values, one per byte.s.char_count()— the number of Unicode code points.s.split(sep)— split into an array of strings on a separator.s.parse_int()— try to read the string as an integer; gives backSome(n)orNone(see Chapter 9).
For Unicode-correct editing and slicing, the cp_* builtins index by code point:
cp_count(s), cp_at(s, i), cp_slice(s, start, end), cp_prefix(s, n),
cp_insert(s, idx, ins), cp_delete(s, idx).
fn main() -> int {
let parts = "12,34,56".split(",")
println("{parts.len()} parts") // 3 parts
match parts[0].parse_int() {
case Some(n) { println("first is {n}") } // first is 12
case None { println("not a number") }
}
return 0
}
There’s a fuller set of string operations — trim, to_upper, contains, replace,
join, and friends — but those live in the standard library and arrive in
Chapter 15. The four above are baked into the
language itself.
A gotcha worth the ink. Building a big string by repeatedly doing
out = out + piecein a loop is quadratic — each+copies everything so far, because strings are immutable. For a handful of pieces, who cares. For thousands, collect them in an array and join once. The standard library’s string builders do exactly this. Immutability is a lovely property right up until you fight it in a hot loop.
Truth
bool is its own type, with exactly two values, true and false. It is not a number,
and this matters more than it sounds:
fn main() -> int {
if 1 { return 1 } // nope
return 0
}
error: 'if' condition must be a bool
There is no “truthiness” in Ingle. 1 is not “truthy,” 0 is not “falsy,” the empty
string is just a string. A condition must be an actual bool. This feels strict for about a
day and then feels like a handrail for the rest of your life — a whole category of “oops, that
was zero” bugs simply cannot be written.
The logical operators are && (and), || (or), and ! (not). && and || short-circuit,
just as you’d expect: the right side isn’t evaluated if the left already decides the answer.
Working at the bit level
When you want to manipulate the actual bits of an integer — packing flags, masking, hashing,
writing a codec — Ingle has the full set of bitwise operators: & (and), | (or), ^ (xor),
~ (not), and the shifts << and >>. These are integer-only and width-aware: they refuse
floats, and they respect the type’s width, so ~ on a u8 flips eight bits, not sixty-four.
fn main() -> int {
let a: u8 = 6 // 0000 0110
let b: u8 = 3 // 0000 0011
println("and {a & b}") // 2 — bits set in both
println("or {a | b}") // 7 — bits set in either
println("xor {a ^ b}") // 5 — bits set in exactly one
println("shl {a << 1}") // 12 — shifted left one place
println("shr {a >> 1}") // 3 — shifted right one place
println("not {~b}") // 252 — every bit of a u8 flipped
return 0
}
and 2
or 7
xor 5
shl 12
shr 3
not 252
=> 0
Two details that keep shifts honest. The shift amount must land in [0, width) — shifting a
u8 by 8 or more is a runtime trap, not a quiet zero — and >> is arithmetic (sign-preserving)
on signed types but logical (zero-filling) on unsigned ones, so a negative i32 >> 1 stays
negative while a u32 >> 1 always pulls in zeros. The shipped examples/12_bits.ig puts all this
to work twice over: a complete xorshift PRNG built from nothing but <<, >>, and ^, and a set
of Unix-style permission flags packed into a single byte and toggled with masks.
A built-in safety.
&is bits and&&is logic, and the two never overlap — bitwise operators are never defined onbool— so the classic slip of writing&when you meant&&can’t quietly compile. The overflow discipline from earlier in this chapter applies here too: shifts are range-checked, and arithmetic traps rather than wrapping.
When you actually want wrap-around. Trapping on overflow is the right default, but a whole family of primitives — non-cryptographic hashes like FNV-1a and MurmurHash, multiply-mixing PRNGs like PCG — depend on integer arithmetic wrapping round at the type’s width. So Ingle gives you exactly that, explicitly, through three builtins:
wrapping_add,wrapping_sub, andwrapping_muleach take two integers of one width and compute modulo 2ʷ with no trap (there’s no wrapping/or%— overflow there isn’t a real thing). It’s the same philosophy asmove: the dangerous-looking operation is available, but you have to name it, so nobody — and no model — reaches for silent wraparound by accident. With it, a real FNV-1a hash is a six-line loop in pure Ingle; without it you’d be back in C. (Shift-and-xor designs like xorshift never overflow at all, so they need none of this —examples/12_bits.igstays library-free.)
The precedence table, once, so you never wonder
From loosest-binding to tightest, all binary operators grouping left-to-right:
| Tightness | Operators |
|---|---|
| loosest | \|\| |
&& |
|
\| (bitwise or) |
|
^ (bitwise xor) |
|
& (bitwise and) |
|
== != |
|
< <= > >= |
|
<< >> (shift) |
|
+ - |
|
| tightest | * / % |
Prefix !, unary -, and ~ (bitwise not) bind tighter than all of those; the postfix forms
(calling a function f(...), reaching into a value a.b, indexing a[i], and the ? operator)
bind tightest of all. When in doubt, parenthesise — it’s free and it’s kind to the next reader.
This mirrors C exactly, which means it inherits C’s one genuinely surprising row: the bitwise
operators bind looser than the comparisons, so a & b == c parses as a & (b == c), almost
never what you meant. The fix is the same as in C — parenthesise mixed bitwise-and-comparison
expressions — and Ingle’s strict typing tends to catch the rest, since b == c is a bool and
& wants integers.
Fireside trivia. The
%operator is “remainder,” and it insists on integer operands — no%on floats. The percent sign for “modulo” is one of computing’s odder inheritances: it has nothing to do with percentages. It was chosen in early languages mostly because it was a spare symbol on the keyboard that wasn’t doing anything else. Half of programming syntax is just whatever punctuation happened to be unemployed at the time.