General Computer Science
A from-scratch CS tour that constantly detours to show how Guji and its six companion languages (Go, OCaml, Perl, Raku, Rust, Python) each answer the same core questions - bindings, functions, types, sum types, errors, text, and concurrency.
Values, Bindings, and the Cost of Change
What a variable really is, and why immutable-by-default languages make you ask before you mutate.
Before a program computes anything, it has to name things. A binding associates a name with a value. That sounds trivial, but one design decision hidden inside it shapes the whole feel of a language: can the name be pointed at something new later? A binding you can reassign is mutable; one you cannot is immutable. The general-CS idea is referential transparency - if a name always means the same value, you can reason about code by substitution, the way you do in algebra. Mutation buys convenience (counters, accumulators) at the cost of that guarantee.
Languages sit on a spectrum. Some make mutation the default and immutability the exception; some do the reverse; a few refuse to mutate bindings at all and ask you to transform data instead. Let's see where our languages land.
Guji: immutable by default, mut to opt in
Guji takes the functional-first position. A plain binding is immutable; you write mut only where you genuinely need to reassign.
sub main(): Int {
$x = 5 # immutable
mut $total = 0 # explicitly mutable
for $n in [1, 2, 3, 4] {
$total = $total + $n
}
print("x stays $x, total grew to $total")
0
}
Reassigning a non-mut binding is rejected:
$x = 5
$x = 6 # error: cannot reassign immutable binding $x
Note the sigil: $ marks a scalar, @ a list, % a map (a lineage Guji inherits from Perl, §13.5). The sigil is about shape, not mutability - @items = [1,2,3] is still immutable unless you write mut @items. Because values are immutable too, a "modifying" method returns a new value rather than editing in place, which is what makes data-sharing across tasks safe later on.
Rust: ownership makes mut load-bearing
Rust agrees that bindings are immutable unless marked mut, but layers ownership on top: each value has one owner, and the borrow checker tracks who may read or write it.
fn main() {
let x = 5; // immutable
let mut total = 0; // mutable
for n in [1, 2, 3, 4] {
total += n;
}
println!("x={x}, total={total}");
}
Here mut is not just documentation - try to take two mutable references to the same value and the program will not compile. Immutability is a safety mechanism, not only a clarity one.
OCaml: bindings never change; ref for true mutation
OCaml is stricter still. let introduces a binding that is always immutable; there is no mut. When you genuinely need mutable state you reach for a separate boxed cell, a ref, and the := / ! operators make every mutation visible:
let () =
let x = 5 in (* immutable, like every let *)
let total = ref 0 in (* a mutable cell *)
List.iter (fun n -> total := !total + n) [1; 2; 3; 4];
Printf.printf "x=%d total=%d\n" x !total
total := !total + n reads the cell with !, adds, and stores back. Mutation is possible but visibly out of band - the default path is pure.
Go and Python: mutable by default
The mainstream tradition flips the default. In Go, := declares a variable you can freely reassign; const is the rare opt-out:
x := 5
total := 0
for _, n := range []int{1, 2, 3, 4} {
total += n
}
Python has no notion of a constant binding at all - every name can be rebound, and only a convention (ALL_CAPS) signals "please don't":
x = 5
total = 0
for n in (1, 2, 3, 4):
total += n
The trade is real: Go and Python are immediately familiar and terse, but nothing stops a distant line from reassigning a name you assumed was fixed. Guji, Rust, and OCaml make you say so, trading a little ceremony for the ability to read a binding and know it will not move under you. That single default - who has to ask permission to change - quietly predicts how much of each language's machinery (borrow checkers, ref cells, persistent data structures) exists to manage change.
Further reading: Rust Book - Variables and Mutability.
Functions as Values: From Lambda Calculus to Closures
Why a function is just another value you can pass around, and how each language spells it.
In 1936 Alonzo Church described a tiny formal system - the lambda calculus - in which everything is a function: there are only variables, function definitions, and function application, yet it can compute anything a Turing machine can (the Church–Turing thesis). That idea, that a function is an ordinary value you can name, pass as an argument, return, and store, is the seed of functional programming. A function that takes or returns other functions is called higher-order, and a function that captures variables from the scope where it was defined is a closure.
Modern languages all support this now, but they reveal their heritage in how natural it feels. Let's build the same two things in each: an anonymous function (a lambda) and a closure that remembers a captured value.
Guji: sub spans every function shape
In Guji the keyword sub names a top-level function, a method, and an anonymous lambda - the spec uses one keyword for all of them. A lambda is just a sub with no name; a single-argument "topic block" uses the implicit $_.
sub apply_twice($f, $x: Int): Int { $f($f($x)) }
sub make_adder($n: Int) {
sub($x: Int) { $x + $n } # a closure capturing $n
}
sub main(): Int {
$inc = sub($n: Int) { $n + 1 }
$add5 = make_adder(5)
print(apply_twice($inc, 10)) # 12
print($add5(100)) # 105
@r = [1,2,3,4,5,6].filter({ $_ % 2 == 0 }).map({ $_ * 2 })
print(@r.sum()) # 24
0
}
Two things to notice. First, make_adder returns a function that still sees $n after make_adder has returned - that is the closure. Second, .filter({ ... }).map({ ... }) chains higher-order calls left-to-right; Guji has no separate pipe operator because every call can be written method-style ($x.f() is exactly f($x)), so . is the one composition mechanism.
OCaml: the direct descendant of the lambda calculus
OCaml is an ML, and the ML family is the lambda calculus dressed for work. Functions are curried by default - fun n -> fun x -> ... - so partial application is the native way to build a closure:
let apply_twice f x = f (f x)
let make_adder n = fun x -> x + n (* or simply: let make_adder n x = x + n *)
let () =
let add5 = make_adder 5 in
Printf.printf "%d\n" (apply_twice (fun n -> n + 1) 10); (* 12 *)
Printf.printf "%d\n" (add5 100); (* 105 *)
[1;2;3;4;5;6]
|> List.filter (fun x -> x mod 2 = 0)
|> List.map (fun x -> x * 2)
|> List.fold_left (+) 0
|> Printf.printf "%d\n" (* 24 *)
The |> pipe operator threads a value through functions left-to-right - the same shape as Guji's . chain, written as a binary operator instead of method syntax.
Python and Perl: lambdas as latecomers
Python has first-class functions but a deliberately limited lambda (a single expression, no statements), nudging you toward named defs or comprehensions:
apply_twice = lambda f, x: f(f(x))
def make_adder(n):
return lambda x: x + n # closes over n
add5 = make_adder(5)
print(apply_twice(lambda n: n + 1, 10)) # 12
print(add5(100)) # 105
print(sum(x * 2 for x in range(1, 7) if x % 2 == 0)) # 24
Perl writes an anonymous function as sub { ... } and reaches captured variables through its lexical scope (my):
sub make_adder { my $n = shift; return sub { $_[0] + $n } }
my $add5 = make_adder(5);
print $add5->(100), "\n"; # 105
Rust: closures with explicit capture semantics
Rust closures use |args| body, and because of ownership the compiler must decide how a closure captures - by reference, by mutable reference, or by move (taking ownership), which is what make_adder needs so the returned closure outlives its frame:
fn apply_twice(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(f(x)) }
fn make_adder(n: i32) -> impl Fn(i32) -> i32 { move |x| x + n }
fn main() {
let add5 = make_adder(5);
println!("{}", apply_twice(|n| n + 1, 10)); // 12
println!("{}", add5(100)); // 105
}
The same lambda-calculus idea surfaces in five different costumes: sub in Guji, fun/curry in OCaml, lambda/def in Python, sub {} in Perl, |x| plus move in Rust. Where they differ is exactly where each language's priorities are - Rust's move exposes ownership, OCaml's currying exposes its theoretical roots, and Guji's single sub keyword exposes its taste for "one obvious way."
Further reading: Stanford Encyclopedia of Philosophy - The Lambda Calculus.
Static, Dynamic, and Inferred: When the Machine Figures Out Your Types
What a type system buys you, the static/dynamic split, and how type inference lets you skip the annotations.
A type is a promise about what a value is and what you can do with it. A type system is the set of rules a language uses to keep those promises. The first big axis is when the rules are checked: a statically typed language checks before the program runs (catching "3" + 4 at compile time), while a dynamically typed language checks as it runs (the same mistake becomes a runtime error). Static typing catches a class of bugs early and enables faster code; dynamic typing trades that for flexibility and brevity.
But static typing has a reputation for being verbose - writing int x = 5; feels redundant when 5 is obviously an integer. Type inference is the escape hatch: the compiler deduces the types you did not write. The deepest version, Hindley–Milner inference (from the ML family), can infer the most general type of a whole program with almost no annotations. Let's compare how much typing ceremony each language demands.
Guji: static types you rarely have to write
Guji is statically typed but leans hard on inference. Inside a function body, annotations are optional; the compiler works out the rest:
sub double($x: Int): Int { $x * 2 }
sub main(): Int {
$count = 0 # inferred Int
@names = ["ada"] # inferred List[Str]
%ages = {"ada": 30} # inferred Map[Str, Int]
print(double(21))
0
}
There is one deliberate exception: a pub declaration (one exported from its module) must annotate its parameters and return type, so a module's public interface is always explicit even though its internals are inferred. The type system still tracks everything precisely - @names has type List[Str], so @names[0] is a Str - but you only spell out types at module boundaries.
OCaml: the gold standard of inference
OCaml is where industrial Hindley–Milner inference lives. You can write an entire program with essentially no type annotations and still get full static checking:
let double x = x * 2 (* inferred: int -> int *)
let names = ["ada"] (* inferred: string list *)
let () =
Printf.printf "%d\n" (double 21)
The compiler infers double : int -> int from the single use of *. If you misuse a value, the error appears at compile time, pointing at the conflict - no annotation required. Guji's "annotate only pub" rule is a pragmatic descendant of exactly this.
Rust: inference inside, signatures at the edges
Rust also infers locally but, like Guji, requires explicit types on function signatures - a design choice that keeps public APIs readable and error messages local:
fn double(x: i32) -> i32 { x * 2 } // signature is explicit
fn main() {
let count = 0; // inferred i32
let names = vec!["ada"]; // inferred Vec<&str>
println!("{}", double(21));
}
The pattern "infer bodies, annotate signatures" that Guji and Rust share is a sweet spot: most code is annotation-free, but every function boundary documents itself.
Go: static but inference-light
Go is statically typed yet intentionally minimal about inference. The := operator infers from the right-hand side, but function signatures and struct fields are always written out:
func double(x int) int { return x * 2 }
func main() {
count := 0 // inferred int
names := []string{"ada"}
_ = count
println(double(21))
}
Go deliberately stops short of whole-program inference - a philosophical choice favoring explicitness and fast compilation over brevity.
Python: dynamic, with optional hints
Python is dynamically typed: types live on values at runtime, not on names. The same name can hold an int then a str. Since 3.5 you can add type hints, but they are not enforced by the interpreter (a separate tool like mypy checks them):
def double(x: int) -> int: # a hint, not a runtime check
return x * 2
count = 0
count = "now a string" # perfectly legal at runtime
print(double(21))
The spectrum runs from OCaml (infer almost everything, check everything) through Guji and Rust (infer bodies, annotate boundaries) and Go (infer assignments, annotate the rest) to Python (check nothing until it runs, hints optional). Inference is what lets the strongly-typed end of that spectrum feel as light as the dynamic end while keeping the guarantees - which is exactly the bet Guji makes.
Further reading: Rust Book - Data Types and inference.
Modeling "One Of" - Sum Types and Pattern Matching
Algebraic data types let you say a value is one of several shapes; pattern matching takes them apart, with the compiler checking you covered every case.
Most languages give you product types easily - a struct or record is "this and that and that." The harder, and arguably more important, half is the sum type: a value that is "this or that or that," exactly one at a time. A Shape is a circle or a rectangle; a JSON value is a number or a string or an array or…. Sum types (also called tagged unions or algebraic data types, ADTs) let the type system represent that choice directly, and pattern matching lets you branch on which case you have while pulling out its data in one step.
The killer feature is exhaustiveness checking: the compiler verifies your match handles every variant, so adding a new case later turns into a list of compile errors pointing at exactly the code you must update - instead of a silent fall-through bug. Watch which languages have real sum types and which fake them.
Guji: enum plus exhaustive match
Guji's enum is a true sum type; each variant carries its own typed fields, and the only way to read those fields is to match:
enum Shape {
Circle($radius: Float)
Rect($width: Float, $height: Float)
}
sub area($s: Shape): Float {
match $s {
Circle($r) { 3.14159 * $r * $r }
Rect($w, $h) { $w * $h }
}
}
sub main(): Int {
for $s in [Circle(2.0), Rect(3.0, 4.0)] {
print(area($s))
}
0
}
match is exhaustive: leave out the Rect arm and the program is rejected. Enums can be generic and recursive too, which is how you build trees - enum Tree[T] { Leaf($value: T), Node($left: Tree[T], $right: Tree[T]) } - and a match walks them by structure.
OCaml: where pattern matching was popularized
OCaml's variant types are the construct that made pattern matching famous. The syntax is gloriously terse:
type shape =
| Circle of float
| Rect of float * float
let area = function
| Circle r -> 3.14159 *. r *. r
| Rect (w, h) -> w *. h
let area = function is sugar for a one-argument match. The compiler warns on a non-exhaustive match. (Note *. - OCaml never overloads *, so float multiply is its own operator.) Guji's enum/match is a direct cultural descendant of this.
Rust: ADTs with exhaustiveness as a hard error
Rust's enum is OCaml's idea with C-family syntax and enforced exhaustiveness - a missing case is an error, not a warning:
enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 },
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => 3.14159 * radius * radius,
Shape::Rect { width, height } => width * height,
}
}
Python: structural matching bolted onto classes
Python 3.10 (released October 2021, via PEP 634) added match/case structural pattern matching. It destructures objects nicely - but there is no exhaustiveness check; an unmatched value just falls through and the function returns None:
from dataclasses import dataclass
@dataclass
class Circle: radius: float
@dataclass
class Rect: width: float; height: float
def area(s):
match s:
case Circle(radius=r): return 3.14159 * r * r
case Rect(width=w, height=h): return w * h
Go: no sum type - fake it with an interface
Go has no sum type at all. The idiom is a sealed interface plus a type switch, and crucially the compiler does not check exhaustiveness, so a panic("unreachable") is needed to satisfy the return:
type Shape interface{ isShape() }
type Circle struct{ Radius float64 }
type Rect struct{ Width, Height float64 }
func (Circle) isShape() {}
func (Rect) isShape() {}
func area(s Shape) float64 {
switch v := s.(type) {
case Circle: return 3.14159 * v.Radius * v.Radius
case Rect: return v.Width * v.Height
}
panic("unreachable")
}
The dividing line is exhaustiveness. Guji, OCaml, and Rust make "did you handle every case?" a question the compiler answers; Python and Go leave it to you and your tests. For modeling data that is genuinely "one of several shapes," that compiler check is the difference between a refactor the tool guides and a bug it hides.
Further reading: Python PEP 634 - Structural Pattern Matching.
Errors as Values: Option, Result, and the Case Against Exceptions
Two philosophies of failure - throw-and-catch versus return-a-value - and how making failure visible in the type changes how you write code.
Every non-trivial program has to deal with operations that can fail: parsing a number, opening a file, finding a key. There are two grand traditions for this. Exceptions let a failing operation throw a value that unwinds the stack until something catches it; the happy path stays clean, but failure is invisible in a function's signature - you cannot tell from int parse(string) that it might explode. The alternative is errors as values: a function that might fail returns a value encoding success-or-failure, so the possibility of failure is right there in the type and the compiler makes you deal with it.
Two sum types (lesson 4!) power the second approach: Option (a value, or nothing) for absence, and Result (a value, or an error) for failure. Let's compare.
Guji: no exceptions, Result/Option, and ?
Guji has no exceptions. Operations that can fail return Result[T, E] or Option[T], and the ? operator removes the boilerplate of checking each one - it unwraps a success, or returns the error early:
sub parse_age($s: Str): Result[Int, Str] {
$n = parse_int($s)? # unwrap, or return the Err
if $n >= 0 { Ok($n) } else { Err("age must be non-negative") }
}
sub main(): Int {
match parse_age("42") {
Ok($n) { print("age is $n") }
Err($e) { print("error: $e") }
}
0
}
Every failure is visible in the signature (Result[Int, Str]), the compiler will not let you ignore it, and ? keeps the code that propagates errors almost as short as code that ignores them. For truly unrecoverable bugs - an out-of-bounds index, unwrap of a None - Guji has panic, which aborts the process; there is nothing to catch. Methods like unwrap_or($default) and map let you handle the common cases without writing a full match.
Rust: the same model, with ? born here
Rust pioneered the ? operator and the Result/Option pair that Guji adopts. The shape is nearly identical:
fn parse_age(s: &str) -> Result<i32, String> {
let n: i32 = s.parse().map_err(|e| format!("{e}"))?;
if n >= 0 { Ok(n) } else { Err("age must be non-negative".into()) }
}
The ? propagates the Err upward; on Ok it yields the inner value. Rust also has panic! for unrecoverable states - the exact split Guji uses.
Go: errors as values, but no ? and no enforcement
Go also returns errors as values, but as an ordinary second return value rather than a sum type, and there is no ? - you write the check by hand, every time:
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n < 0 {
return 0, errors.New("age must be non-negative")
}
return n, nil
}
The famous if err != nil { return ... } is explicit and impossible to forget the variable for - but the compiler does not force you to actually inspect err, and the ceremony is verbose. Guji's ? is, in part, an answer to this verbosity.
OCaml: result/option plus optional exceptions
OCaml has both worlds. It offers a result type and an option type used much like Guji's, and a traditional exception mechanism for cases like List.find (Not_found):
let parse_age s =
match int_of_string_opt s with
| None -> Error "not a number"
| Some n when n >= 0 -> Ok n
| Some _ -> Error "age must be non-negative"
int_of_string_opt returns None instead of throwing - the modern, value-oriented style - but the older exception-based APIs still exist alongside it.
Python: exceptions, all the way down
Python is the exception world in full. Failure is signaled by raising, and the idiom is "easier to ask forgiveness than permission" - try the operation, catch what goes wrong:
def parse_age(s):
try:
n = int(s) # raises ValueError on bad input
except ValueError:
return None
return n if n >= 0 else None
Clean on the happy path, but int(s) gives no hint in its signature that it can raise, and an uncaught exception propagates all the way up and crashes the program. The two philosophies trade off in opposite directions: exceptions keep success paths uncluttered at the cost of making failure invisible and easy to forget; Result/Option make failure a typed, compiler-enforced part of every signature at the cost of a little plumbing - plumbing that ? exists to minimize. Guji, Rust, and modern OCaml have decisively chosen the second camp.
Further reading: Rust Book - Recoverable Errors with Result.
Text as a First-Class Concern: Regexes, Grammars, and the Perl Lineage
How languages treat text-processing - from regex-as-library to regex-as-syntax - and why Guji puts grammars in the language itself.
Half of real-world programming is wrangling text: log lines, CSV, config, source code, network protocols. The core tool is the regular expression - a compact pattern language for describing sets of strings. But there's a deep computer-science boundary here: true regular expressions can only match regular languages, and cannot handle nested structure like balanced parentheses or arithmetic. For that you need a grammar (a parser). How a language treats this divide - regex as an afterthought library, regex as syntax, grammars as a built-in - says a lot about what it's for.
This is the Perl lineage's home turf, and the thread runs straight into Guji.
Perl: where regex became a programming culture
Perl (1987, Larry Wall) made regular expressions a first-class operator, not a function call. The match operator =~, the $1 capture variables, and substitution s/// are baked into the syntax, and Perl's regex dialect (PCRE) became so influential that nearly every later language copied it:
my $line = 'ada@example.com';
if ($line =~ /(?<user>\w+)@(?<host>\w+)/) {
print "$+{user} at $+{host}\n"; # ada at example
}
The sigils ($line, @list, %hash) and the regex-as-syntax are exactly the heritage Guji draws on.
Raku: regexes grow up into grammars
Raku (the language formerly called Perl 6 - Larry Wall approved the rename in October 2019) took Perl's text obsession to its logical end: regexes are composable named pieces, and a grammar is a first-class construct for parsing nested structure, the thing plain regex cannot do:
grammar Email {
token TOP { <user> '@' <domain> }
token user { \w+ }
token domain { \w+ '.' \w+ }
}
say Email.parse('ada@example.com')<user>; # ada
This is the direct ancestor of Guji's signature feature.
Guji: regex literals and grammars, both in the language
Guji makes text its headline capability. Regexes are a literal type written between slashes, matched with ~~, yielding Option[Match] so a non-match is handled like any other absence:
sub main(): Int {
$line = 'ada@example.com'
match $line ~~ /(?<user>\w+)@(?<host>\w+)/ {
Some($m) { print("user { $m<user>.unwrap_or('?') }") } # ada
None { print("no match") }
}
0
}
(A subtlety the sigils force: $line is written with single quotes here, because in a double-quoted string @example would try to interpolate a list named @example.) For nested structure, Guji has Raku-style grammars built from token/rule/regex productions, parsing into a Bush tree - recursion that regex deliberately refuses:
grammar Email {
rule TOP { <user> '@' <domain> }
token user { \w+ }
token domain { \w+ '.' \w+ }
}
Crucially, Guji's regexes are Unicode-aware by default: \w matches any Unicode letter, and the matching unit is the Unicode scalar value, with \X to match whole grapheme clusters (so an emoji flag or a skin-toned thumbs-up stays intact).
Python: regex as a respectable library
Python keeps regex in the standard library (re), not the syntax - you compile a pattern string and call methods on it. Capable, but text is a library task, not a language feature:
import re
m = re.match(r"(?P<user>\w+)@(?P<host>\w+)", "ada@example.com")
print(m.group("user"), m.group("host")) # ada example
The r"..." raw-string prefix exists precisely because regex backslashes fight with normal string escapes - friction Perl and Guji avoid by giving regex its own literal syntax.
Rust: regex as an external crate
Rust goes further toward "text is a library problem": regex isn't even in the standard library - you add the regex crate. Its engine is notable for guaranteeing linear-time matching (no catastrophic backtracking), a different design priority from PCRE:
use regex::Regex;
let re = Regex::new(r"(?<user>\w+)@(?<host>\w+)").unwrap();
let caps = re.captures("ada@example.com").unwrap();
println!("{} {}", &caps["user"], &caps["host"]);
So the spectrum runs from "text is the whole point" (Perl, Raku, Guji - regex and grammars in the language) to "text is a library" (Python's re, Rust's regex crate). Guji's bet is that for a language whose tagline is text processing, regexes and grammars belong in the grammar of the language itself - a bet it inherits, almost genetically, from Perl and Raku.
Further reading: Raku Grammars documentation.
Doing Many Things at Once: Shared Memory, Channels, and CSP
The hardest problem in concurrency is shared mutable state; compare locks, channels, ownership, and a GIL.
Concurrency is structuring a program as independently-progressing tasks; parallelism is actually running them at the same time on multiple cores. (As Rob Pike put it, concurrency is dealing with lots of things at once, parallelism is doing lots of things at once.) The central hazard is the data race: two tasks touch the same memory at the same time, at least one of them writing, with no coordination - producing corruption that depends on timing and is brutal to reproduce. Every concurrency model is, at heart, an answer to "how do we avoid data races?"
There are two broad answers. Shared memory + locks: let tasks share data, and use mutexes to take turns - powerful but error-prone (deadlocks, forgotten locks). Message passing / CSP (Communicating Sequential Processes, C. A. R. Hoare, 1978): tasks don't share memory; they send each other messages over channels. Go's slogan captures it: "Do not communicate by sharing memory; instead, share memory by communicating."
Guji: CSP with an immutability twist
Guji's concurrency model (designed, but post-v0) is Go's goroutines-and-channels - with one change that closes the last hole. hatch starts a lightweight task; channels carry values between tasks; but every value crossing a channel is immutable (lesson 1!), so tasks share data only by communicating, and there is no shared mutable state to race on:
sub main(): Int {
$jobs: Chan[Int] = channel()
hatch {
for $n in [1, 2, 3] { $jobs.send($n) }
$jobs.close()
}
for $job in $jobs { # receives until closed and drained
print("got $job")
}
0
}
Where Go can still send a pointer and share mutable memory (re-opening the door to races), Guji structurally cannot - captured values are immutable, so the compiler rejects capturing a mut binding into a hatch. The model also folds Go's "value, ok" receive into recv(): Option[T] (lesson 5): None means "closed and empty."
Go: the model Guji borrows
Go popularized lightweight tasks (goroutines) multiplexed onto OS threads, and channels as the coordination primitive:
func main() {
jobs := make(chan int)
go func() {
for _, n := range []int{1, 2, 3} {
jobs <- n
}
close(jobs)
}()
for job := range jobs {
fmt.Println("got", job)
}
}
go func(){...}() is hatch; make(chan int) is channel(); jobs <- n is $jobs.send(n). The one gap Guji closes: Go lets you send a pointer through a channel, so disciplined programmers can still create races - Go even ships a runtime race detector precisely because the type system doesn't prevent them.
Rust: fearless concurrency via ownership
Rust attacks data races at compile time through ownership (lesson 1). A value has one owner; to share it across threads you must use types the compiler certifies as thread-safe (Arc, Mutex), and the borrow checker statically rejects code that would race:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for n in [1, 2, 3] { tx.send(n).unwrap(); }
});
for job in rx { println!("got {job}"); }
}
Rust supports both channels and shared-memory-with-locks, but its slogan is "fearless concurrency": the ownership rules that prevent use-after-free also prevent data races, caught at compile time rather than by a runtime detector.
Python: the GIL sidesteps the question
CPython has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at a time. This makes threads safe-ish for shared state but means CPU-bound threads do not run in parallel - you reach for multiprocessing (separate processes, message passing) for real parallelism:
import threading, queue
q = queue.Queue()
def worker():
for n in (1, 2, 3): q.put(n)
q.put(None)
threading.Thread(target=worker).start()
while (job := q.get()) is not None:
print("got", job)
(Python 3.13 added an experimental free-threaded build that can disable the GIL - the long-running tension finally being addressed.)
OCaml: domains and effects
OCaml ran single-threaded for decades; OCaml 5.0 (2022) introduced domains for true parallelism and channel-style libraries on top. Its distinctive bet is effect handlers as a concurrency primitive, a more research-forward path than CSP or threads.
Lined up, the strategies are: don't share mutable state at all (Guji - immutable messages; Go - channels by convention), prove the absence of races at compile time (Rust - ownership), or serialize execution to dodge the problem (Python - GIL). Guji's position is the strictest of the message-passing camp: it takes Go's ergonomic, beloved model and removes the one escape hatch - shared mutable memory - that still lets Go programs race.
Further reading: The Go Blog - Share Memory By Communicating.