diff --git a/2_types_variables.md b/2_types_variables.md new file mode 100644 index 0000000..3c1984d --- /dev/null +++ b/2_types_variables.md @@ -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`. The syntax difference: + +| Rust | Zig | +|---|---| +| `Option` | `?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.