This commit is contained in:
Priec
2026-03-06 23:12:52 +01:00
parent 34082dd908
commit b9da02c3be

585
2_types_variables.md Normal file
View File

@@ -0,0 +1,585 @@
# Chapter 02 — Types & Variables
> **Prerequisites:** Chapter 01 complete. You can build and run a Zig project.
> **Goal:** Master Zig's type system — integers, floats, booleans, optionals, type casting, comptime types, and the strict no-implicit-coercion rules that will bite you coming from Rust.
---
## 2.1 No Implicit Coercion — The Golden Rule
This is the single most important thing to internalize before anything else.
In Rust, some coercions are implicit:
```rust
let x: u32 = 5;
let y: u64 = x; // OK in Rust — implicit coercion from u32 to u64
```
**In Zig, this is a compile error.** Every type conversion must be explicit.
```zig
const x: u32 = 5;
const y: u64 = x; // ERROR: expected type 'u64', found 'u32'
```
There are exactly two exceptions:
1. **`comptime_int` and `comptime_float`** — untyped compile-time constants coerce freely to any compatible concrete type.
2. **Peer type resolution** — in specific places (like `if`/`else` arms), Zig picks a common type if one side can coerce to the other.
Everything else: you cast explicitly. This is a feature. It means you always know exactly what's happening to your bits.
---
## 2.2 Integer Types
### Fixed-width integers
```zig
const a: u8 = 255;
const b: u16 = 65535;
const c: u32 = 4_294_967_295; // underscore separators are fine
const d: u64 = 18_446_744_073_709_551_615;
const e: u128 = 0;
const f: i8 = -128;
const g: i16 = -32768;
const h: i32 = -2_147_483_648;
const i: i64 = -9_223_372_036_854_775_808;
```
### Arbitrary-width integers — embedded gold
This is something Rust doesn't have natively. Zig lets you declare integers of any bit width from `u0` to `u65535`:
```zig
const nibble: u4 = 0b1010; // 4-bit unsigned — perfect for BCD
const flags: u3 = 0b101; // 3-bit field
const reg: u12 = 0xABC; // 12-bit ADC value
const addr: u20 = 0xFFFFF; // 20-bit address bus
// Signed arbitrary-width
const offset: i4 = -8; // 4-bit signed two's complement
```
In embedded, this is huge. A 12-bit ADC reading is actually `u12`, not `u16` with wasted bits. Packed structs (Chapter 06) use these to map hardware registers exactly.
### `usize` and `isize`
```zig
const ptr_size: usize = @sizeOf(*u8); // pointer-sized unsigned
const offset: isize = -1; // pointer-sized signed
```
Same semantics as Rust's `usize`/`isize`. Use `usize` for lengths, indices, and memory offsets.
### Integer literals
```zig
const dec = 1_000_000; // decimal
const hex = 0xFF_AB_12; // hexadecimal
const oct = 0o777; // octal
const bin = 0b1101_0010; // binary
```
---
## 2.3 `comptime_int` and `comptime_float`
When you write a literal without a type annotation, Zig gives it a special **comptime-only type**:
```zig
const x = 42; // type is comptime_int — has arbitrary precision!
const y = 3.14; // type is comptime_float
```
`comptime_int` is not a runtime type. It's a big-integer that only exists at compile time. It will coerce to any concrete integer type as long as the value fits:
```zig
const big = 1_000_000_000_000; // comptime_int, no overflow
const a: u32 = big; // ERROR: value doesn't fit in u32
const b: u64 = big; // OK: fits in u64
const c: i64 = big; // OK: fits in i64
```
This is why you can write `var x: u32 = 0;` without worrying about `0` being the "wrong integer type" — `0` is `comptime_int` and coerces freely.
```zig
// These are all fine — comptime_int coerces to the annotated type
var a: u8 = 0;
var b: u32 = 0;
var c: i64 = 0;
```
---
## 2.4 Explicit Casting
Since nothing is implicit, Zig gives you precise casting builtins:
### `@intCast` — between integer types (must fit)
```zig
const big: u32 = 300;
const small: u8 = @intCast(big); // runtime panic if > 255 in Debug/ReleaseSafe
// silent truncation in ReleaseFast
```
In Debug mode, `@intCast` validates the value fits. This is your safety net. In embedded ReleaseFast builds, it's a no-op cast — you're responsible.
### `@truncate` — deliberately cut bits
```zig
const wide: u32 = 0xDEADBEEF;
const low: u8 = @truncate(wide); // 0xEF — no panic, explicit truncation
const nibble: u4 = @truncate(wide); // 0xF
```
Use `@truncate` when you *want* the lower bits. Use `@intCast` when you *expect* the value to fit.
### `@as` — reinterpret/coerce the type
```zig
const x: u32 = @as(u32, 42); // explicit widening, always safe
```
`@as` is for cases where you want to be explicit about the target type without asserting anything about range. It's the "I know what I'm doing" cast for widening or same-size reinterpretation.
### `@bitCast` — reinterpret raw bits
```zig
const f: f32 = 1.0;
const bits: u32 = @bitCast(f); // same bit pattern, different type
// bits == 0x3F800000
const back: f32 = @bitCast(bits); // round-trip
```
For embedded: this is how you inspect the raw IEEE 754 representation, pack/unpack register values, etc. Equivalent to Rust's `f32::to_bits()` / `f32::from_bits()`, but it's a general builtin.
### `@floatCast` — between float widths
```zig
const d: f64 = 3.14159265358979;
const s: f32 = @floatCast(d); // precision loss, explicit
```
### `@floatFromInt` / `@intFromFloat`
```zig
const i: u32 = 42;
const f: f32 = @floatFromInt(i); // 42 → 42.0
const g: f64 = 9.7;
const j: i32 = @intFromFloat(g); // truncates toward zero → 9
```
No implicit `as f32` like Rust. Every int↔float conversion is named and explicit.
### Casting cheatsheet
| What you want | Builtin |
|---|---|
| Widen/shrink integer (value must fit) | `@intCast` |
| Truncate integer (keep low bits) | `@truncate` |
| Reinterpret bits (same size) | `@bitCast` |
| Explicit type annotation/widening | `@as` |
| Float ↔ float (different width) | `@floatCast` |
| Int → float | `@floatFromInt` |
| Float → int (truncates) | `@intFromFloat` |
| Enum ↔ int | `@intFromEnum` / `@enumFromInt` |
| Pointer ↔ int | `@intFromPtr` / `@ptrFromInt` |
---
## 2.5 Float Types
```zig
const a: f16 = 1.5; // half precision (useful in ML, some embedded)
const b: f32 = 3.14; // single precision
const c: f64 = 2.71828; // double precision
const d: f80 = 1.0; // extended (x86 only)
const e: f128 = 1.0; // quad precision
```
Float literals without annotation are `comptime_float` — arbitrary precision at compile time, coerced to a concrete type when needed.
```zig
const pi = 3.141592653589793; // comptime_float, full precision
const pf: f32 = pi; // rounded to f32 precision at compile time
const pd: f64 = pi; // rounded to f64 precision at compile time
```
---
## 2.6 Booleans
```zig
const a: bool = true;
const b: bool = false;
// Boolean operators
const and_result = a and b; // false — short-circuits
const or_result = a or b; // true — short-circuits
const not_result = !a; // false
// Comparison operators produce bool
const eq = (1 == 1); // true
const neq = (1 != 2); // true
const lt = (3 < 5); // true
```
No truthiness. `if (1)` is a compile error — you must produce an actual `bool`.
---
## 2.7 Optionals — Zig's Nullable Types
Zig has no null pointers by default. To express "this might not have a value", you use an **optional type**: `?T`.
```zig
const maybe: ?u32 = null;
const value: ?u32 = 42;
```
This maps directly to Rust's `Option<T>`. The syntax difference:
| Rust | Zig |
|---|---|
| `Option<u32>` | `?u32` |
| `Some(42)` | `42` (coerces automatically) |
| `None` | `null` |
| `x.unwrap()` | `x.?` |
| `if let Some(v) = x` | `if (x) \|v\|` |
| `x.unwrap_or(0)` | `x orelse 0` |
### Unwrapping optionals
```zig
const maybe: ?u32 = 42;
// Method 1: .? — panics if null (like Rust's .unwrap())
const val = maybe.?;
// Method 2: orelse — provide a default (like Rust's .unwrap_or())
const safe = maybe orelse 0;
// Method 3: orelse with a block (like Rust's .unwrap_or_else())
const computed = maybe orelse blk: {
// do work...
break :blk 99;
};
// Method 4: if capture — safe unwrap (like Rust's if let Some(v) = ...)
if (maybe) |v| {
std.debug.print("got: {d}\n", .{v});
} else {
std.debug.print("was null\n", .{});
}
// Method 5: while capture — iterate until null
var cursor: ?u32 = 0;
while (cursor) |c| {
std.debug.print("{d}\n", .{c});
cursor = if (c < 5) c + 1 else null;
}
```
### Optional pointers are free
A `?*T` in Zig has the same size as `*T` — null is represented as address zero. No extra byte wasted.
```zig
const ptr: ?*u32 = null;
std.debug.print("size of ?*u32: {d}\n", .{@sizeOf(?*u32)});
// Same as @sizeOf(*u32) — zero cost
```
This is identical to how `Option<&T>` is optimized in Rust using the null pointer optimization.
---
## 2.8 The `undefined` Value
`undefined` is a special value you can assign to any type to declare it without initializing:
```zig
var buf: [256]u8 = undefined; // memory is allocated, not zeroed
var x: u32 = undefined;
```
In **Debug** builds, Zig writes `0xAA` into all `undefined` memory. This deliberately makes bugs visible — a read of uninitialized memory produces an obviously wrong value rather than a lucky zero.
In **ReleaseFast**, `undefined` means whatever bits were already there. Using an undefined value is **undefined behavior**.
```zig
// Pattern: declare undefined, then initialize before use
var result: u32 = undefined;
result = computeSomething(); // now it's valid
```
This is the common pattern for output parameters and large stack buffers. Don't use `undefined` and then immediately read — initialize before any read path.
---
## 2.9 Type Reflection with `@TypeOf` and `@typeInfo`
Zig lets you inspect types at compile time. This is the foundation of comptime generics (Chapter 08), but even now it's useful for understanding what types you actually have.
```zig
const x = 42;
const T = @TypeOf(x); // comptime_int
std.debug.print("type: {s}\n", .{@typeName(T)}); // prints "comptime_int"
const y: u32 = 42;
const U = @TypeOf(y); // u32
std.debug.print("type: {s}\n", .{@typeName(U)}); // prints "u32"
```
### `@sizeOf` and `@bitSizeOf`
Critical for embedded work:
```zig
std.debug.print("u8 = {d} bytes\n", .{@sizeOf(u8)}); // 1
std.debug.print("u32 = {d} bytes\n", .{@sizeOf(u32)}); // 4
std.debug.print("u3 = {d} bytes\n", .{@sizeOf(u3)}); // 1 (rounded up)
std.debug.print("u3 = {d} bits\n", .{@bitSizeOf(u3)}); // 3
// In packed structs (Chapter 06), u3 actually takes exactly 3 bits
```
### `@alignOf`
```zig
std.debug.print("u32 align = {d}\n", .{@alignOf(u32)}); // 4
std.debug.print("u8 align = {d}\n", .{@alignOf(u8)}); // 1
```
---
## 2.10 Type Aliases and Named Types
In Zig, types are values. A type alias is just a `const` that holds a type:
```zig
const MyU32 = u32; // alias
const Count = usize; // semantic alias
var x: MyU32 = 42;
var n: Count = 0;
```
But these are *true aliases*`MyU32` and `u32` are the same type, completely interchangeable.
For distinct types (like Rust's newtype pattern), you use a struct:
```zig
// Rust newtype: struct Meters(f32);
// Zig equivalent:
const Meters = struct { value: f32 };
const Seconds = struct { value: f32 };
const d = Meters{ .value = 100.0 };
const t = Seconds{ .value = 9.58 };
// d and t are incompatible types — can't mix them up
```
Full struct syntax in Chapter 06. The point here: Zig's type system is built on a simple foundation — types are comptime values.
---
## 2.11 Peer Type Resolution
The one place Zig does implicit coercion. When two branches produce values that must unify (in `if/else`, `switch`, etc.), Zig tries to find a common type:
```zig
const x: u8 = 10;
const y: u32 = 1000;
// Peer type resolution: u8 can coerce to u32, so result is u32
const z = if (true) x else y;
// @TypeOf(z) == u32
```
Rules:
- Integer types: the "wider" type wins if the narrower can fit.
- If neither can coerce to the other → compile error.
- Optional: `T` and `?T` → result is `?T`.
- Error union: `T` and `!T` → result is `!T`.
```zig
const a: ?u32 = 42;
const b: u32 = 0;
const c = if (true) a else b;
// @TypeOf(c) == ?u32
```
---
## 2.12 Variable Shadowing
Unlike Rust, Zig **does not allow shadowing** within the same scope:
```zig
const x = 1;
const x = 2; // ERROR: redefinition of 'x'
```
You can shadow in an inner scope:
```zig
const x = 1;
{
const x = 2; // OK — different scope
_ = x;
}
```
This is stricter than Rust (where `let x = x + 1;` is common). Plan your variable names accordingly.
---
## 2.13 Unused Variables and `_`
Zig **refuses to compile** if you declare a variable and never use it:
```zig
const x = 42; // ERROR: unused local constant
```
To explicitly discard a value, assign to `_`:
```zig
const x = computeSomething();
_ = x; // "I know, I'm intentionally discarding this"
```
This is stricter than Rust's `#[allow(unused)]`. It forces you to be deliberate. In embedded this is valuable — you can't accidentally ignore a return value.
For function parameters you don't use:
```zig
fn callback(data: u32, _: *anyopaque) void {
// use data, ignore the second param
_ = data;
}
```
Or just prefix with `_`:
```zig
fn callback(_data: u32, _ctx: *anyopaque) void {
// both discarded — prefixing with _ suppresses the warning
}
```
---
## Exercises
---
### Exercise 1 — Cast Gauntlet
Write a function `castDemo()` that demonstrates each of the following, printing the result each time:
1. A `u32` value of `300` cast to `u8` using `@truncate` — print the result and explain in a comment why it's not 300.
2. A `u8` value of `200` widened to `u32` using `@intCast` — print the size of the result type using `@sizeOf(@TypeOf(result))`.
3. An `f32` of `3.99` converted to `i32` using `@intFromFloat` — print the result and note the truncation direction.
4. A `u32` bit-cast to `f32` using `@bitCast` — use the value `0x3F800000` and verify the result is `1.0`.
5. A `f64` value narrowed to `f32` using `@floatCast`.
No panics should occur when run in Debug mode — choose your values carefully.
---
### Exercise 2 — Optional Chain
Model a simple sensor read pipeline using optionals. Write three functions:
```zig
fn readRaw() ?u16 // returns null 30% of the time (use a counter/flag, not random)
fn validate(raw: u16) ?u16 // returns null if raw > 4000 (out of range)
fn toMillivolts(raw: u16) u32 // converts: millivolts = raw * 3300 / 4095
```
In `main`, call them chained using `orelse` and `if` captures. Print either the millivolt result or a descriptive error message indicating which stage failed.
**Bonus:** Rewrite the chain using a single `if` with nested captures:
```zig
if (readRaw()) |raw| {
if (validate(raw)) |valid| { ... }
}
```
---
### Exercise 3 — Arbitrary Width Integers
You're modeling a hardware register for a made-up microcontroller. The register is 16 bits wide with this layout:
```
Bits 15-12: MODE (4 bits, unsigned)
Bits 11-8: SPEED (4 bits, unsigned)
Bits 7-4: FLAGS (4 bits, unsigned)
Bits 3-0: ADDRESS (4 bits, unsigned)
```
Using only `u4` fields and bitwise operations on `u16`:
1. Declare four `u4` constants: `mode = 0b1010`, `speed = 0b0011`, `flags = 0b1100`, `address = 0b0101`.
2. Pack them into a single `u16` using shift (`<<`) and bitwise OR (`|`).
3. Print the packed value in hex.
4. Unpack the `u16` back into four `u4` values using shift (`>>`) and `@truncate`.
5. Verify (with `std.debug.assert`) all four unpacked values match the originals.
*(Packed structs in Chapter 06 will make this much cleaner — this exercise shows you why they exist.)*
---
### Exercise 4 — Type Inspector
Write a function `inspect(comptime T: type) void` that prints:
- The name of the type (`@typeName`)
- Its size in bytes (`@sizeOf`)
- Its size in bits (`@bitSizeOf`)
- Its alignment (`@alignOf`)
Call it with: `u8`, `u32`, `u3`, `f64`, `bool`, `?u32`, `*u8`.
Observe the output for `u3` (size rounds up to 1 byte but bitSize is 3) and `?u32` (size is 8 bytes — why?).
---
### Exercise 5 — Newtype Pattern
Implement a minimal newtype pattern for a distance calculation:
1. Define `Meters` and `Kilometers` as single-field structs wrapping `f32`.
2. Write `fn toKilometers(m: Meters) Kilometers`.
3. Write `fn toMeters(km: Kilometers) Meters`.
4. Write `fn addMeters(a: Meters, b: Meters) Meters`.
5. In `main`, create some values, convert between them, add them, and print results.
6. Try to accidentally add a `Meters` and a `Kilometers` directly — observe the compile error, then delete that line.
The goal is to see how Zig enforces type safety through structs even without a dedicated newtype syntax.
---
## Summary
By the end of this chapter you should be able to:
- [ ] Explain why Zig has no implicit integer coercion and what `comptime_int` is
- [ ] Choose the right cast builtin for any integer/float conversion
- [ ] Use arbitrary-width integers like `u4`, `u12` for embedded register modeling
- [ ] Declare and unwrap optional types using `orelse`, `.?`, and `if` capture
- [ ] Use `undefined` safely — declare early, initialize before read
- [ ] Inspect types at compile time using `@TypeOf`, `@sizeOf`, `@alignOf`, `@typeName`
- [ ] Implement the newtype pattern using single-field structs
---
**Next:** Chapter 03 — Control Flow — deep dive into `switch` with exhaustive matching, labeled blocks that return values, `inline for`, and `comptime` branches.