Go-shaped rules: immutable by default, option/result, bounds checks, no globals · Mark the Mut · Gate the Option · Let the Index Panic · Fence the Globals
Mark the mut. Gate the option. Let the index panic. Fence the globals.
<!-- hal:authoritative:yaml -->
Mark the mut. Gate the option. Let the index panic. Fence the globals.
§I. Frame
Asr V session 2 of 5, Tue 2026-10-06. Learn-first, contributor-aware, watch-not-adopt. Cites are vlang/v at tag 0.5.2 (7647ce1) only: doc/docs.md and source files at the tag. The lab ran on the lab Mac with the compiler Duha #6 built this morning (~/src/vlang-duha6/v/v, called by absolute path). Every error string below is copied from lab/lab.log and was found again in the 0.5.2 source.
The compare page says V improves on Go with "no err != nil checks", "immutability by default", "no global state", and "no uninitialized variables". Those are author claims. This session tests each one against the compiler.
§II. Four techniques
- Mark the Mut. A binding changes only if it was declared
mut, and a function changes an argument only if both the signature and the call saymut. - Gate the Option. A
?Tor!Tvalue never reaches code that wants a plainTuntil it passes anor {}block, a!propagation, or anif x := ...unwrap. - Let the Index Panic. Array indexing is checked at runtime by default. You opt out per function with
@[direct_array_access]or per build with-no-bounds-checking, and you pay in undefined behavior if you are wrong. - Fence the Globals. Variables live inside functions.
__globalexists, behind-enable-globals, for kernels and drivers.
§III. Mark the Mut
docs.md: := "is the only way to declare variables in V", so every variable has an initial value, and variables "are immutable by default". Three programs, three refusals:
- Assigning to an immutable local (
02_err_no_mut.v):error: `age` is immutable, declare it with `mut` to make it mutable. - Assigning without declaring (
03):error: undefined ident: `age` (use `:=` to declare a variable), plus three cascade errors. - Re-declaring
ain an inner block (04):error: redefinition of `a`. docs.md says shadowing "is not allowed".
Arguments follow the same rule with one twist. The callee declares fn multiply_by_2(mut arr []int), and the caller must write multiply_by_2(mut nums). Leaving mut off the call (06) gives parameter `arr` is `mut`, so use `mut nums` instead. Writing into an argument the signature did not mark (07) gives the same "is immutable" error as a local.
docs.md also says V "doesn't allow the modification of arguments with primitive types". The compiler agrees, and the message tells you what to do instead (08): "mutable arguments are only allowed for arrays, interfaces, maps, pointers, structs or their aliases", then return values instead: `fn foo(mut n int) {` => `fn foo(n int) int {`.
One flag cuts against the headline. v help build at 0.5.2 lists -disable-explicit-mutability, which lets "ordinary variables and mutable call arguments in user code" skip mut. With it, the program that failed in 02 printed 21 and exited 0. Immutability by default is a compiler default, and the binary carries a switch to turn it off. docs.md does not mention the switch.
§IV. Gate the Option
docs.md: ?Type holds a value or none; !Type holds a value or an error. The lab's 09_result_option.v exercised all four handling methods the docs list:
- Default value:
parse_port('nope') or { 3000 }gave3000. - Inspect and recover: inside
or { }the nameerrprintedbad port: "70000". - Propagate:
load()callsparse_port(s)!. A good port came back as8081. A bad one surfaced in the caller'sorblock asbad port: "0". - Unwrap with
if:if port := find_env(env, 'PORT')printedPORT=8080; theHOMElookup fell toelseasnone.
What happens when you skip the gate. An unhandled Result fails at the call (10): parse_port() returns `!int`, so it should have either an `or {}` block, or `!` at the end. An unhandled Option behaves differently in 0.5.2 (11): e := first_even(...) compiled, and the error landed one line later where e + 1 used it, ?int` cannot be used as `int literal`, unwrap the option first. So an Option can sit in a variable as an Option; a Result cannot be left bare. Both stop the build before a T is used.
! inside main has nowhere to propagate; docs.md says it will "panic instead", and 13 printed V panic: result not set (bad port: "zero"), exit 1. There is no .unwrap(); docs.md says to write or { panic(err) }.
"No null" is a compare-page claim. In user code p := nil fails with error: `nil` is only allowed in `unsafe` code (12). docs.md also shows unsafe { nil } reference fields and says nil references "will NOT be supported in the future". The precise statement: nil exists, behind unsafe.
§V. Let the Index Panic
The syllabus left this cell as verify-at-fire. Answer from 14_bounds_panic.v: indexing arr[5] on a three-element array printed before, then V panic: array.get: index out of range (i,a.len):5, 3 and a backtrace, exit 1. after never printed. The panic comes from fn (a array) get in vlib/builtin/array.v, inside a $if !no_bounds_checking guard.
The Maps section of docs.md gives arrays the same option gate as maps. arr[5] or { -1 } returned -1, and arr[i]! inside a !int function propagated array index out of range to the caller (15). Maps differ: a missing key returns the zero value (0) unless you add or {}.
Opting out, shown in generated C (16). In a normal function a[i] compiled to builtin__array_get(a, i). Under @[direct_array_access] the same line became ((int*)a.data)[i], a raw C index. docs.md calls that "unsafe" unless you checked the bounds yourself, and lists "Everywhere else" under When to Avoid. Build-wide, -no-bounds-checking produced the raw index in main too. Its help text says the check usually costs "below 3%"; that figure is the authors' and we did not measure it. -force-bounds-checking puts the check back even inside tagged functions: 22_daa_oob.v then panicked at index 7. Without the flag that program reads past the array in C, which is undefined behavior, so the lab did not run it.
§VI. Fence the Globals, name things, run fmt
__global ( counter int ) without the flag (17): error: use `v -enable-globals ...` to enable globals. With -enable-globals it ran and printed 2. docs.md says the flag is "intended for low-level applications like kernels and drivers" and warns that global access in threaded code "is subject to race conditions".
A top-level count := 0 (18) does not produce a globals error. V reads it as a script statement and then refuses the fn main after it: "all definitions must occur before code in script mode". Asr #1 met that rule from the other side.
Naming is enforced, not advised. fn DoThing() (19): "function names cannot contain uppercase letters, use snake_case instead". An unused local is a warning in dev builds (21 still ran); docs.md says -prod refuses it. Asr #5 measures -prod.
v fmt -verify exited 1 on a scrambled file, v fmt -w rewrote it, and a second verify exited 0. Running verify over the other lab files flagged 04, 08, and 19 as formatting errors, which looked wrong until the output showed why. Those three rules (shadowing, mut primitives, uppercase function names) are enforced in the parser (vlib/v/parser/assign.v, fn.v), and vfmt parses before it formats. The immutability and option errors live in vlib/v/checker/checker.v and do not stop vfmt.
§VII. Contributor note
docs.md code fences carry tags such as failcompile, oksyntax, nofmt, ignore, and globals. cmd/tools/vcheck-md.v reads them: a failcompile block that compiles is an error. CONTRIBUTING at 0.5.2 asks for v check-md on any changed Markdown. Today's run found three gaps a learner could draft into a docs change:
- Arrays never states the default out-of-range behavior. It shows only the
or {}form, under Maps. -no-bounds-checking,-force-bounds-checking, and-disable-explicit-mutabilityappear inv help buildand not in docs.md.- Mutable variables says "Try compiling the program above after removing
mut" without showing the result. Afailcompileblock would make that sentence tested.
Duha #7 and #8 own the fork, v fmt -w, v check-md, and draft-PR steps. Nothing goes upstream from a lesson.
§VIII. Lab
- Predict the exit codes of
02,09,10, and14, then run them with the pinned compiler by absolute path. - Emit C for
16and compare the twosum_bodies. - Run
v fmt -verifyon04and explain why a semantic rule shows up as a formatting error.
Evidence: lab/lab.sh, lab/lab.log, and the 22 .v files beside them.
§IX. Common mistakes
- Writing
muton the parameter and forgetting it at the call, or reaching formut n intinstead of returning the value. - Expecting a bare
?Tassignment to fail at the call, the way!Tdoes. - Using
!inmainand calling it error handling. It is a panic. - Adding
@[direct_array_access]before measuring anything. - Reading "no null" and "immutable by default" as absolutes.
unsafe { nil }and-disable-explicit-mutabilityboth exist.
§X. V next to Go and Rust, session 2
Next to Go, the four compare-page claims held in the 0.5.2 compiler, and two of them (immutability, no globals) come with a flag that switches them off. Next to Rust, ?T/!T with or {} maps onto Option/Result with ? and unwrap_or, minus a forced unwrap; V errors default to a message string, with a custom IError for typed ones. Rust has no flag that turns off mut; V 0.5.2 ships one. Both panic on a bad index by default and both offer an unchecked path. Verdict: the rules are real and readable, and the escape hatches live in help text more than in docs. Learn it, adopt nothing. Asr #3 takes structs, interfaces, sum types, and modules tomorrow.
Related:
Mark the mut. Gate every option. Let the index panic. Close the editor.