Learn Zig Series (#3) - Functions and Control Flow
What will I learn
- You will learn how to define and call functions in Zig with explicit parameter types and return types;
- how Zig handles multiple return values via anonymous structs;
- if/else as expressions that return values (Zig's version of the ternary operator);
- while loops, the continue expression pattern, and break with values;
- for loops for iterating over slices and ranges with captures;
- switch as an exhaustive, expression-based construct with range matching;
- labeled blocks, break with values, and unreachable;
- how all of these pieces fit together in a real program.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Beginner
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow (this post)
Learn Zig Series (#3) - Functions and Control Flow
Welcome back! In episode #2 we got our feet wet with Zig's type system -- const vs var, format strings, arbitrary-width integers, explicit type conversions, and the "no truthy/falsy" philosophy that forces you to say exactly what you mean. We wrote a server metrics calculator that used @floatFromInt, @intCast, and computed real percentages. If you haven't gone through that episode yet, I'd strongly recommend doing so -- everything we build today assumes you're comfortable with those concepts.
Having said that, in ep002 all of our code lived inside main(). One big function doing everything. No reuse, no abstraction, no way to break a problem into smaller pieces and compose them. That's fine for learning types and format strings, but it doesn't scale. The moment your program needs to do the same calculation in multiple places, or the moment you want to test a piece of logic independently, you need functions. And once you have functions, you need control flow -- the ability to make decisions, repeat things, branch on conditions, and route execution through different paths.
Functions and control flow are where you go from "I can print things" to "I can write programs." This is the episode where Zig starts feeling like a real programming language to work with ;-)
Let's dive right in.
Solutions to Episode 2 Exercises
Before we start on new material, let me go through the solutions to last episode's exercises. If you actually typed these out and ran them yourself (and I hope you did!), compare your solutions:
Exercise 1 -- server name, IP, and CPU summary:
const std = @import("std");
pub fn main() void {
const name = "web-prod-03";
const ip = "10.0.1.42";
const cores: u8 = 8;
std.debug.print("Server: {s} ({s}) - {d} cores\n", .{ name, ip, cores });
}
Exercise 2 -- incrementing a counter 5 times:
const std = @import("std");
pub fn main() void {
var counter: u32 = 0;
counter += 1;
std.debug.print("counter = {d}\n", .{counter});
counter += 1;
std.debug.print("counter = {d}\n", .{counter});
counter += 1;
std.debug.print("counter = {d}\n", .{counter});
counter += 1;
std.debug.print("counter = {d}\n", .{counter});
counter += 1;
std.debug.print("counter = {d}\n", .{counter});
}
Repetitive, right? You can feel this code wanting a loop. We'll fix that today.
Exercise 3 -- assigning to const gives: error: cannot assign to constant
Exercise 4 -- if (42) gives: error: expected type 'bool', found 'comptime_int'
Exercise 5 -- unused variable gives: error: local variable is never used. Fix with _ = x;
Exercise 6 -- converting a too-large u32 value to u8 via @intCast triggers a safety panic in debug mode: integer cast truncated bits. Zig catches the overflow at runtime. In a release build this would be undefined behavior -- but in debug (the default), Zig protects you. This is a pattern you'll see throughout the language: safety checks in debug, raw performance in release.
Now, on to functions and control flow!
Defining Functions
Functions in Zig are refreshingly straightforward. No method syntax (that comes later with structs), no overloading, no default parameters, no keyword arguments. Every parameter has an explicit type, and the return type is always declared. The function signature is a complete contract -- you can read it and know exactly what it takes and what it returns without looking at the body.
Create a file called functions.zig:
const std = @import("std");
fn calculatePnL(entry_price: f64, exit_price: f64, quantity: f64) f64 {
return (exit_price - entry_price) * quantity;
}
fn displayResult(label: []const u8, value: f64) void {
std.debug.print("{s}: {d:.2}\n", .{ label, value });
}
pub fn main() void {
const pnl = calculatePnL(64000.0, 68500.0, 0.5);
displayResult("Trade P&L", pnl);
const pnl2 = calculatePnL(3200.0, 3050.0, 4.0);
displayResult("Trade P&L", pnl2);
}
Run it with zig run functions.zig:
Trade P&L: 2250.00
Trade P&L: -600.00
Let me break down the key principles here:
Every parameter has an explicit type. entry_price: f64, exit_price: f64 -- no inference on function parameters, ever. If you're coming from Python where def calculate(entry, exit, qty): lets you pass anything and find out at runtime whether it works... Zig is the opposite. The compiler knows the types before your program ever runs. This means: no runtime type errors. No TypeError: unsupported operand type(s) at 3 AM. The compiler rejects invalid calls at build time.
Return type comes after the parentheses. fn calculatePnL(...) f64 -- that trailing f64 is the return type. void means no return value, and you must declare it explicitly. Zig doesn't let you accidentally forget to return something.
[]const u8 is how you spell "string" in Zig. This is a slice of constant bytes -- we'll go deep on slices in a later episode. For now, just know that string literals in Zig are []const u8, and you use {s} to format them.
Functions not marked pub are private to the file. pub fn main() is public because the Zig runtime needs to call it. Our helper functions are private -- they can only be called from within the same file. This is the default, and it's the right default. Make things public only when something outside the file needs them.
If you've been following my Learn Python Series, you know Python doesn't really have private functions -- there's a _ convention, but nothing enforced. In Zig, the compiler enforces visibility. Private is private. Period.
Functions Calling Functions
Functions can call other functions, of course. Let me build something a little more useful -- a function that computes both the absolute and percentage return:
const std = @import("std");
fn absoluteReturn(entry: f64, exit: f64, qty: f64) f64 {
return (exit - entry) * qty;
}
fn percentReturn(entry: f64, exit: f64) f64 {
return (exit - entry) / entry * 100.0;
}
fn printTradeReport(ticker: []const u8, entry: f64, exit: f64, qty: f64) void {
const abs_ret = absoluteReturn(entry, exit, qty);
const pct_ret = percentReturn(entry, exit);
std.debug.print("{s}: {d:+.2} ({d:+.1}%)\n", .{ ticker, abs_ret, pct_ret });
}
pub fn main() void {
printTradeReport("BTC", 64000.0, 68500.0, 0.5);
printTradeReport("ETH", 3200.0, 3050.0, 4.0);
printTradeReport("SOL", 140.0, 185.0, 25.0);
}
Output:
BTC: +2250.00 (+7.0%)
ETH: -600.00 (-4.7%)
SOL: +1125.00 (+32.1%)
Notice the {d:+.2} format specifier -- the + makes positive numbers show a + sign explicitly. Nice for financial summaries where direction matters. We learned about format specifiers in ep002, and they keep being useful.
Order of function definitions doesn't matter in Zig. printTradeReport calls absoluteReturn and percentReturn, but it's defined after them. You could rearrange these in any order and it would still compile. Zig's compiler processes the entire file before resolving calls. This is different from C where you'd need forward declarations, and different from Python where the function has to be defined before the line that calls it executes.
Multiple Return Values
What if a function needs to return more than one thing? In Python you'd return pnl, pnl_pct and rely on tuple unpacking. In Go you'd use named return values. In C you'd either use pointers (ugly) or pack things into a struct (verbose).
Zig uses anonymous structs -- and it's clean:
const std = @import("std");
fn analyzeTrade(entry: f64, exit: f64, qty: f64) struct { pnl: f64, pnl_pct: f64 } {
const pnl = (exit - entry) * qty;
const pnl_pct = (exit - entry) / entry * 100.0;
return .{ .pnl = pnl, .pnl_pct = pnl_pct };
}
pub fn main() void {
const result = analyzeTrade(64000.0, 68500.0, 0.5);
std.debug.print("P&L: ${d:.2} ({d:.1}%)\n", .{ result.pnl, result.pnl_pct });
}
Output:
P&L: $2250.00 (7.0%)
The return type is literally struct { pnl: f64, pnl_pct: f64 } -- a struct defined inline in the function signature. Named fields, type-safe, zero ambiguity about which value is which. The caller accesses them with result.pnl and result.pnl_pct.
Compare this to Python's return pnl, pnl_pct where the caller has to remember the order and unpack correctly. Swap the return order by accident in Python and you get a silent bug -- the code runs but the numbers are wrong. In Zig, fields have names. You access them by name. No positional mistakes possible.
The .{ .pnl = pnl, .pnl_pct = pnl_pct } syntax is an anonymous struct literal. The . prefix on field names is Zig's way of initializing struct fields. You'll see this pattern constantly -- it was already there in ep002 when we wrote .{ language, version, year } for format arguments. Same syntax, different context.
If / Else
Basic conditional logic -- nothing revolutionary at first glance, but pay attention to the differences from what you're used to:
const std = @import("std");
pub fn main() void {
const price_change: f64 = -3.2;
if (price_change > 5.0) {
std.debug.print("Strong rally!\n", .{});
} else if (price_change > 0.0) {
std.debug.print("Modest gain\n", .{});
} else if (price_change > -5.0) {
std.debug.print("Small dip\n", .{});
} else {
std.debug.print("Crash!\n", .{});
}
}
Three key points:
- Parentheses around the condition are required. Python dropped them, Zig kept them. If you forget them, the compiler will remind you.
- Braces are always required. No single-statement
ifwithout braces. No dangling-else bugs. No ambiguity about whichifanelsebelongs to. This eliminates an entire category of bugs that has plagued C codebases for decades (remember Apple's infamous "goto fail" SSL bug? Caused by a missing brace). - No truthy/falsy. Only
boolworks in conditions. As we covered thoroughly in episode #2,if (42)is a compile error. You writeif (count != 0)-- explicitly. Zig makes you say what you mean.
If as an Expression -- This is a Big Deal
Here's where Zig's design gets really interesting. In most languages, if is a statement -- it executes code but doesn't produce a value. In Zig, if can be an expression that returns a value:
const std = @import("std");
pub fn main() void {
const price_change: f64 = 7.5;
// if-expression: produces a value
const signal = if (price_change > 0) "LONG" else "SHORT";
std.debug.print("Signal: {s} (change: {d:.1}%)\n", .{ signal, price_change });
// Chained if-expression
const risk_level = if (price_change > 10.0 or price_change < -10.0)
"HIGH"
else if (price_change > 5.0 or price_change < -5.0)
"MEDIUM"
else
"LOW";
std.debug.print("Risk: {s}\n", .{risk_level});
}
Output:
Signal: LONG (change: 7.5%)
Risk: MEDIUM
This is Zig's version of the ternary operator (condition ? a : b in C, a if condition else b in Python). But it uses the exact same if/else syntax as regular conditionals -- no special syntax to memorize. Clean, readable, composable.
Notice that signal is const. The if-expression computes a value once, and that value never changes. This is idiomatic Zig: compute a value, bind it to const, move on. You'll see this pattern everywhere in well-written Zig code.
Also notice or and not ||. Zig uses the keywords and and or for logical operators (we covered this briefly in ep002 with the boolean section). More readable, impossible to confuse with bitwise & and |.
While Loops
Time to stop repeating ourselves. Remember that counter exercise from ep002 where we incremented 5 times by hand? Let's do it properly:
const std = @import("std");
pub fn main() void {
// Basic while loop
var price: f64 = 60000.0;
var day: u32 = 0;
while (price < 70000.0) {
price *= 1.02; // 2% daily gain
day += 1;
}
std.debug.print("Reached ${d:.0} after {d} days\n", .{ price, day });
// While with continue expression
var total: f64 = 0.0;
var i: u32 = 0;
while (i < 5) : (i += 1) {
total += @as(f64, @floatFromInt(i)) * 100.0;
std.debug.print(" Step {d}: +${d:.0}, running total=${d:.0}\n", .{
i,
@as(f64, @floatFromInt(i)) * 100.0,
total,
});
}
std.debug.print("Final total: ${d:.0}\n", .{total});
}
Output:
Reached $70687 after 8 days
Step 0: +$0, running total=$0
Step 1: +$100, running total=$100
Step 2: +$200, running total=$300
Step 3: +$300, running total=$600
Step 4: +$400, running total=$1000
Final total: $1000
The : (i += 1) syntax after the while condition is the continue expression. It runs after every iteration -- including iterations that hit continue. This is how Zig replaces the C-style for (int i = 0; i < 5; i++) loop. Instead of cramming three different things into one construct (initialization, condition, step), Zig splits them into separate pieces: the var i declaration, the while (i < 5) condition, and the : (i += 1) step. Each piece does one thing. You can read each piece independently.
Why is this better than C-style for? Because the continue expression runs even when you continue out of the loop body. In a C-style loop, if you continue inside the body, the step expression i++ still runs. But if you manage the counter manually inside the body and forget to increment before a continue... infinite loop. Zig's continue expression is attached to the loop itself, not the body. It always runs. One less bug you can write ;-)
While with Break
const std = @import("std");
pub fn main() void {
var balance: f64 = 10000.0;
var trades: u32 = 0;
while (true) {
balance *= 0.95; // 5% loss each trade
trades += 1;
if (balance < 5000.0) break;
}
std.debug.print("Lost half the account in {d} trades (${d:.2} remaining)\n", .{ trades, balance });
}
Output:
Lost half the account in 14 trades ($4876.75 remaining)
while (true) with a break condition inside -- the classic "loop until something happens" pattern. Works exactly like you'd expect. The break statement exits the innermost loop immediately.
For Loops -- Iteration, Not Counting
This is an important distinction. In C, for is a general-purpose counting loop. In Python, for iterates over collections. Zig follows Python's philosophy here: for is exclusively for iterating over things. If you want a counting loop, use while with a continue expression.
const std = @import("std");
pub fn main() void {
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400 };
// Iterate over values
std.debug.print("Prices: ", .{});
for (prices) |p| {
std.debug.print("${d:.0} ", .{p});
}
std.debug.print("\n", .{});
// Iterate with index
std.debug.print("\nDaily prices:\n", .{});
for (prices, 0..) |p, i| {
const change: f64 = if (i == 0) 0.0 else prices[i] - prices[i - 1];
const arrow = if (change > 0) "^" else if (change < 0) "v" else "-";
std.debug.print(" Day {d}: ${d:.0} ({s}{d:+.0})\n", .{ i, p, arrow, change });
}
// Iterate over a range
std.debug.print("\nRange demo: ", .{});
for (0..5) |i| {
std.debug.print("{d} ", .{i});
}
std.debug.print("\n", .{});
}
Output:
Prices: $64000 $65200 $63800 $67100 $68400
Daily prices:
Day 0: $64000 (-+0)
Day 1: $65200 (^+1200)
Day 2: $63800 (v-1400)
Day 3: $67100 (^+3300)
Day 4: $68400 (^+1300)
Range demo: 0 1 2 3 4
The |p| syntax is a capture -- Zig's way of binding the current element to a name. |p, i| captures both element and index. If you've used Ruby blocks (|x|) or Rust closures (|x|), the syntax will feel familiar.
The [_]f64{ ... } creates an array with compiler-inferred length. The [_] means "you figure out the length, compiler." We could write [5]f64{ ... } explicitly, but letting the compiler count for us is less error-prone. Arrays and slices deserve their own deep dive -- that's coming in a later episode.
The 0..5 is a range -- it generates 0, 1, 2, 3, 4 (exclusive of the end). You can use any start and end values: 3..8 gives 3, 4, 5, 6, 7.
One thing that might trip you up if you're coming from Python: there's no enumerate(). In Python you write for i, p in enumerate(prices):. In Zig you write for (prices, 0..) |p, i| -- the 0.. generates an infinite counter starting at 0, and Zig zips it with the array automatically. Clean, no function call overhead, no wrapping in another object.
Switch -- Exhaustive and Expression-Based
Zig's switch is far more powerful than C's. It's an expression (returns a value), it's exhaustive (the compiler forces you to handle every possible case), and there's no fallthrough (each arm is independent -- no forgotten break statements causing bugs).
const std = @import("std");
fn classifyRisk(score: u8) []const u8 {
return switch (score) {
0 => "no risk",
1...3 => "low",
4...6 => "medium",
7...9 => "high",
10 => "maximum",
else => "invalid",
};
}
pub fn main() void {
const scores = [_]u8{ 0, 2, 5, 7, 10, 15 };
for (scores) |s| {
std.debug.print("Score {d}: {s}\n", .{ s, classifyRisk(s) });
}
}
Output:
Score 0: no risk
Score 2: low
Score 5: medium
Score 7: high
Score 10: maximum
Score 15: invalid
Key points:
1...3is a range. Matches 1, 2, and 3. Three dots, inclusive on both ends.- No fallthrough. Each arm is independent. No
breakneeded. This alone eliminates a major source of C bugs -- the "I forgot the break statement so execution fell through to the next case" class of errors. - Switch is an expression. We're
returning the result directly. The switch produces a[]const u8value, and that's what the function returns. elseis the catch-all. It handles any value not explicitly covered. For au8(which can hold 0 to 255), we've only covered 0-10 explicitly, soelsecatches 11-255.- Exhaustiveness. The compiler forces you to handle every possible value. For enums (which we'll cover in a later episode), this means adding a new variant forces you to update every
switchthat uses it. No forgotten cases, no silent bugs. The compiler does the remembering for you.
Let me show you a more interesting switch -- one that uses it as an expression inside a function:
const std = @import("std");
fn classifyVolatility(daily_change_pct: f64) []const u8 {
// Turn the continuous value into a discrete bucket
const abs_change = if (daily_change_pct >= 0) daily_change_pct else -daily_change_pct;
const bucket: u8 = if (abs_change < 1.0)
0
else if (abs_change < 3.0)
1
else if (abs_change < 5.0)
2
else if (abs_change < 10.0)
3
else
4;
return switch (bucket) {
0 => "calm",
1 => "normal",
2 => "volatile",
3 => "wild",
4 => "extreme",
else => unreachable,
};
}
pub fn main() void {
const changes = [_]f64{ 0.3, -1.5, 4.2, -8.1, 15.0 };
for (changes) |c| {
std.debug.print("{d:+.1}%: {s}\n", .{ c, classifyVolatility(c) });
}
}
Output:
+0.3%: calm
-1.5%: normal
+4.2%: volatile
-8.1%: wild
+15.0%: extreme
Notice how we combined if-expressions with switch -- the if-expression buckets the continuous value, and the switch maps buckets to labels. Both are expressions, both return values, and the code reads top to bottom without any mutable state.
Also notice unreachable in the else branch. Let me explain that.
Unreachable
The unreachable keyword tells the compiler: "this code path should never execute. If it does, something has gone catastrophically wrong."
fn getMultiplier(side: u8) f64 {
return switch (side) {
0 => 1.0, // long
1 => -1.0, // short
else => unreachable,
};
}
In debug builds (the default when you use zig run), hitting unreachable triggers a panic -- your program crashes with a clear error message telling you exactly which unreachable was reached and on which line. This is a safety net. It means your assumption about "this can never happen" was wrong, and Zig tells you immediately in stead of letting the program continue with corrupted state.
In release builds (zig build-exe -OReleaseFast), unreachable becomes actual undefined behavior -- the compiler assumes it truly never happens and optimizes based on that assumption. This is dangerous if you're wrong, but enables aggressive optimizations when you're right.
In the classifyVolatility function above, we used unreachable in the switch's else because we know our bucket variable can only be 0, 1, 2, 3, or 4 -- the if-expression chain guarantees it. The compiler doesn't know that (it just sees a u8 that could be 0-255), so we need the else to make the switch exhaustive. But we tell it: "if we somehow get here, something is horribly wrong."
Use unreachable sparingly and only when you genuinely know a path is impossible. If there's any doubt, use a proper else branch that handles the case gracefully. Defensive programming saves debugging time -- trust me on this one.
Labeled Blocks and Break with Values
Here's a feature that Python and most other languages don't have: blocks can have labels and return values.
const std = @import("std");
pub fn main() void {
const portfolio_value = blk: {
const btc = 0.5 * 68000.0;
const eth = 4.0 * 3200.0;
const sol = 25.0 * 142.0;
break :blk btc + eth + sol;
};
std.debug.print("Portfolio: ${d:.2}\n", .{portfolio_value});
// Another example: complex initialization
const risk_summary = summary: {
const total = portfolio_value;
const btc_ratio = (0.5 * 68000.0) / total * 100.0;
const is_concentrated = btc_ratio > 50.0;
break :summary if (is_concentrated) "concentrated" else "diversified";
};
std.debug.print("Allocation: {s}\n", .{risk_summary});
}
Output:
Portfolio: $50350.00
Allocation: diversified
blk: is the label. break :blk value "returns" a value from that labeled block. The block's result is bound to portfolio_value as a const. The variables inside the block (btc, eth, sol) don't leak out -- they're scoped to the block.
When would you use this? When you need to compute something that requires a few intermediate steps, but you don't want to either (a) create a whole separate function for it, or (b) pollute the surrounding scope with temporary variables. It's a way to keep your namespace clean while doing inline multi-step computations. Not something you'll use every day, but when you need it, it's very handy.
Labeled breaks also work with loops. If you have nested loops and need to break out of the outer one:
const std = @import("std");
pub fn main() void {
const matrix = [_][3]u32{
.{ 1, 2, 3 },
.{ 4, 5, 6 },
.{ 7, 8, 9 },
};
const found = outer: for (matrix) |row| {
for (row) |val| {
if (val == 5) break :outer true;
}
} else false;
std.debug.print("Found 5: {}\n", .{found});
}
The break :outer true exits the outer loop and makes the entire for-expression evaluate to true. The else false at the end is what the for-expression evaluates to if the loop completes without hitting any break. This is remarkably expressive -- a search through a 2D structure that returns a boolean, with no mutable flag variable needed.
Putting It All Together: Trade Journal
Time to combine everything from this episode into one complete program. Functions, multiple returns, if-expressions, for loops, switch, the works:
const std = @import("std");
fn calculateReturn(entry: f64, exit: f64) struct { abs: f64, pct: f64 } {
return .{
.abs = exit - entry,
.pct = (exit - entry) / entry * 100.0,
};
}
fn classifyTrade(pct: f64) []const u8 {
return if (pct > 10.0)
"great"
else if (pct > 0.0)
"profit"
else if (pct > -5.0)
"small loss"
else
"bad trade";
}
fn riskEmoji(classification: []const u8) []const u8 {
// We can't switch on slices directly, so let's use the first char
// (In practice you'd use enums for this -- coming in a later episode!)
return switch (classification[0]) {
'g' => "[OK]",
'p' => "[OK]",
's' => "[!!]",
'b' => "[XX]",
else => "[??]",
};
}
pub fn main() void {
const entries = [_]f64{ 64000, 3200, 140, 0.65 };
const exits = [_]f64{ 68500, 3050, 185, 0.58 };
const tickers = [_][]const u8{ "BTC", "ETH", "SOL", "XRP" };
std.debug.print("=== Trade Journal ===\n\n", .{});
var total_pnl: f64 = 0.0;
var winning: u32 = 0;
var losing: u32 = 0;
for (entries, 0..) |entry, i| {
const r = calculateReturn(entry, exits[i]);
const label = classifyTrade(r.pct);
const indicator = riskEmoji(label);
total_pnl += r.abs;
if (r.pct > 0) {
winning += 1;
} else {
losing += 1;
}
std.debug.print(" {s} {s}: {d:+.2}% (${d:+.2}) -- {s}\n", .{
indicator,
tickers[i],
r.pct,
r.abs,
label,
});
}
const total_trades = winning + losing;
const win_rate = @as(f64, @floatFromInt(winning)) /
@as(f64, @floatFromInt(total_trades)) * 100.0;
std.debug.print("\n--- Summary ---\n", .{});
std.debug.print(" Trades: {d} ({d}W / {d}L)\n", .{ total_trades, winning, losing });
std.debug.print(" Win rate: {d:.0}%\n", .{win_rate});
std.debug.print(" Net P&L: ${d:+.2}\n", .{total_pnl});
const verdict = if (total_pnl > 0) "profitable" else "unprofitable";
std.debug.print(" Verdict: {s}\n", .{verdict});
}
Output:
=== Trade Journal ===
[OK] BTC: +7.03% ($+4500.00) -- profit
[!!] ETH: -4.69% ($-150.00) -- small loss
[OK] SOL: +32.14% ($+45.00) -- great
[XX] XRP: -10.77% ($-0.07) -- bad trade
--- Summary ---
Trades: 4 (2W / 2L)
Win rate: 50%
Net P&L: $+4394.93
Verdict: profitable
Look at everything we used:
- Functions with explicit types:
calculateReturn,classifyTrade,riskEmoji - Anonymous struct return: the
struct { abs: f64, pct: f64 }pattern - If-expressions:
classifyTradereturns a string computed by chained if-expressions; the finalverdictuses an inline if-expression - For loop with captures and index:
for (entries, 0..) |entry, i| - Switch:
riskEmojimaps the first character of the classification to an indicator - Type conversions:
@as(f64, @floatFromInt(winning))from ep002 - Format specifiers:
{d:+.2}for signed decimals,{s}for strings - Mutable state:
var total_pnl,var winning,var losing-- only where we genuinely need mutation
Count the const vs var declarations. In main(), we have 6 const bindings and 3 var bindings. The var ones are accumulators inside a loop -- the only place where mutation makes sense here. Everything else is immutable. This is idiomatic Zig: const is the default, var is the exception.
What We Didn't Cover (Yet)
I want to be honest about what's still ahead, because functions and control flow are foundational but they open doors to deeper topics:
Error handling. In the real world, functions fail. File not found, network timeout, division by zero, memory allocation failure. Zig has one of the most elegant error handling systems I've seen in any language -- error unions, try, catch, and errdefer. It deserves its own episode, and it's coming next.
Comptime. Zig can execute functions at compile time. That @import("std") you write in every file? That's a function call that runs during compilation. Zig's compile-time execution system lets you generate code, validate inputs, and build data structures before your program ever runs. It's mind-bending and incredibly powerful, but we need more fundamentals first.
Closures and function pointers. Zig has function pointers but no closures (no capturing of surrounding variables). This is a deliberate design choice -- closures require hidden memory allocation, and Zig never allocates behind your back. We'll explore this when we get to memory management.
For now, what we have is more than enough to write real programs. Functions, conditionals, loops, switch, labeled blocks -- this is the control flow toolkit that everything else builds on.
Exercises
Actually do these. Every one of them. The muscle memory from typing Zig code is what makes the syntax feel natural. Reading is not enough -- you have to write it, hit compile errors, read the error messages, fix them, and run.
Write a function
fn max(a: f64, b: f64) f64that returns the larger value. Use an if-expression (not an if-statement with avar). Test it with several pairs of values including negatives.Write a function
fn compoundGrowth(principal: f64, rate_pct: f64, periods: u32) f64that computes compound growth using awhileloop with a continue expression. Test with $1000 at 5% for 10 periods.Write a
switchon au8representing an order type: 0="market", 1="limit", 2="stop", 3="stop-limit", else="unknown". Put it in a function, call it with several values.Create an array of 7 daily closing prices and use a
forloop to find the highest price and which day it occurred on. You'll needvarfor tracking the best-so-far.Write a function that takes 5 prices as separate
f64parameters and returns astruct { min: f64, max: f64, avg: f64 }. Use a labeled block to compute the average as an inline calculation.Use nested
forloops and a labeled break to search a 3x3 matrix ofu32values for a specific target. Print whether you found it and at which position.
These exercises might seem straightforward, and they are -- intentionally. We're building vocabulary. Each of these patterns (if-expression returns, while with continue expressions, for with captures, switch exhaustiveness, labeled blocks) will come back when we start building more sophisticated programs. The error handling episode in particular relies heavily on understanding how functions return values -- because in Zig, errors ARE return values. Get comfortable with these building blocks now, and the advanced stuff will click naturally.