PONYλM2Modula-2

Rust.CodeCompared.To/Forth

An interactive executable cheatsheet comparing Rust and Forth

Rust 1.99.0 Forth 2012 (WAForth 0.20.1)
Output & Running It
Hello, World
A Forth program is a sequence of words separated by spaces. : begins a definition and ; ends it, so HELLO becomes a new word that the next line then runs — there is no main and nothing calls you.
fn main() { println!("Hello, World!"); }
: HELLO ." Hello, World!" CR ; HELLO
The definition and the call are two separate acts, and the second one is what a reader would type at a prompt on the device. ." and CR only work inside a definition, which is why the greeting is wrapped in one rather than written at the top level.
Printing In Another Base
Rust picks the base at each call site with a format specifier. Forth has one printing word, ., and a global variable BASE that decides what it means — HEX and DECIMAL just store 16 or 10 into it.
fn main() { let value = 255; println!("{}", value); println!("{:X}", value); println!("{}", value); }
255 DUP . CR HEX DUP . CR DECIMAL . CR
The base is global mutable state that changes how every number is both printed and read from that moment on, which is why the last line switches back to DECIMAL. Forgetting to switch back is a classic Forth bug and has no Rust equivalent, because {:X} cannot leak into the next statement.
Inspecting What You Have
Rust needs names before it can show you anything, so the three values are bound and then printed as a tuple. Forth's values are already sitting somewhere visible, and .S prints the whole data stack without consuming it.
fn main() { let first = 10; let second = 20; let third = 30; println!("{:?}", (first, second, third)); }
10 20 30 .S CR
This is the debugging tool a Forth programmer reaches for first, and it has no Rust counterpart because Rust has no single place where in-flight values live. Note that .S leaves the stack exactly as it found it — unlike ., which consumes.
Compiling At Run Time
EVALUATE takes the address and length of a string and interprets it as Forth source, right now, using the same interpreter that read the rest of the file. The compiler is a word like any other and is present on the device.
fn main() { // Rust has no eval: the compiler ran before this program existed. let source = "2 3 +"; println!("cannot run: {}", source); }
: RUN-SOURCE S" 2 3 + . CR" EVALUATE ; RUN-SOURCE
There is no build step to separate compile time from run time, so a program can extend itself while it runs — which is how a Forth system is debugged on hardware with no debugger attached. Rust deliberately gives this up in exchange for knowing the whole program before it ships.
Values Without Owners
Moving And Dropping
Rust tracks which binding owns a value and refuses to let you use the old name after a move. Forth has no bindings at all: a value is on the stack or it is not, and DROP is how you say you are finished with one.
fn main() { let message = String::from("owned"); let moved = message; // println!("{}", message); // error: borrow of moved value: `message` println!("{}", moved); }
: SHOW ( n -- ) . CR ; 7 DUP SHOW DROP .( nothing is left ) CR
Nothing in Forth is freed when a value is dropped, because nothing was allocated — the cell is simply no longer addressed by the stack pointer. The discipline Rust enforces at compile time is the programmer's own responsibility here, and the compiler will not mention it.
Borrowing Versus Duplicating
A Rust borrow lets two names refer to one value without copying it, and the compiler proves the reference cannot outlive what it points at. DUP copies the cell instead, so there is a second, independent value.
fn main() { let count = 21; let borrowed = &count; println!("{} {}", count, borrowed); }
21 DUP . . CR
Forth has no way to express "another view of the same value" for something on the stack, so a copy is the only option and aliasing questions never arise. That is also why there are no lifetimes to annotate: nothing on the stack points at anything.
Returning Two Values
Rust packages two results into a tuple and destructures it at the call site. A Forth word simply leaves two cells where it found its arguments, so returning several values costs nothing and needs no type to describe it.
fn divide(numerator: i32, denominator: i32) -> (i32, i32) { (numerator / denominator, numerator % denominator) } fn main() { let (quotient, remainder) = divide(17, 5); println!("{} {}", quotient, remainder); }
: DIVIDE ( numerator denominator -- remainder quotient ) /MOD ; 17 5 DIVIDE . . CR
The stack comment is the whole interface, and it is a comment — nothing checks it. Note that it reads -- remainder quotient: /MOD leaves the quotient on top, so that is the one . prints first, and writing the comment the other way round would be a lie nothing would catch.
An Address Outlives Nothing
A Rust slice is a borrow with a lifetime, and the compiler will not let it escape the array it points into. SLICE is just arithmetic on an address, and Forth has no idea what that address refers to or how long it stays valid.
fn main() { let buffer = [3, 1, 4, 1, 5]; let slice: &[i32] = &buffer[1..3]; println!("{} {}", slice[0], slice[1]); }
CREATE BUFFER 3 , 1 , 4 , 1 , 5 , : SLICE ( -- addr ) BUFFER 1 CELLS + ; SLICE @ . SLICE CELL+ @ . CR
The dictionary entry BUFFER lives as long as the program does, so this particular address happens to stay good — but nothing said so, and a word returning the address of a temporary would compile and run exactly the same way. Lifetimes are the thing Rust adds; they are not a thing Forth removed.
Depth Instead Of Scope
Rust's inner let shadows the outer one for the length of the block, and the original is visible again afterwards. Forth has no scopes: the earlier value is still on the stack, underneath the new one.
fn main() { let value = 1; { let value = value + 10; println!("{}", value); } println!("{}", value); }
1 DUP 10 + . CR . CR
Shadowing and stack depth look similar here and are not the same idea — Rust's inner binding disappears at the closing brace whether or not you use it, while the value on the stack stays until something consumes it. A word that forgets to consume leaves rubbish for its caller.
Words Instead Of Functions
fn Versus :
Both define a reusable operation, but the Forth version names no parameter and declares no types — DUP * takes whatever is on top of the stack, copies it, and multiplies.
fn square(value: i32) -> i32 { value * value } fn main() { println!("{}", square(9)); }
: SQUARE ( n -- n*n ) DUP * ; 9 SQUARE . CR
The body is point-free in the way a Rust iterator chain sometimes is, except that it is the only style available. There is no place to write i32, so SQUARE is equally willing to square an address, a character code, or a boolean flag.
Argument Order Is Stack Order
Rust names the parameters, so their order in the signature is the order at the call site and nothing else depends on it. In Forth the arguments are pushed left to right and the word finds them in that order, with the last one pushed on top.
fn subtract(left: i32, right: i32) -> i32 { left - right } fn main() { println!("{}", subtract(10, 3)); }
: SUBTRACT ( left right -- difference ) - ; 10 3 SUBTRACT . CR
This is why - subtracts the top of the stack from the cell beneath it: it is the spelling that makes 10 3 - mean what a reader expects. Getting the order wrong produces a perfectly valid program that computes the wrong thing.
Recursion Needs A Keyword
A Rust function can call itself by name because the name is in scope throughout the body. A Forth definition is not added to the dictionary until ; runs, so its own name does not yet mean anything — RECURSE is the word that refers to the definition being compiled.
fn factorial(value: u32) -> u32 { if value > 1 { value * factorial(value - 1) } else { 1 } } fn main() { println!("{}", factorial(5)); }
: FACTORIAL ( n -- n! ) DUP 1 > IF DUP 1 - RECURSE * ELSE DROP 1 THEN ; 5 FACTORIAL . CR
This is a direct consequence of the dictionary being built as the source is read. Writing FACTORIAL inside its own body would find an earlier word of that name if one existed, and otherwise fail — which is occasionally what you want, and is how a redefinition can be written in terms of the version it replaces.
Definition Order Matters
Rust resolves names across the whole module, so main can call a function written below it. Forth reads strictly top to bottom and a word must already be in the dictionary when the word that uses it is compiled.
fn main() { println!("{}", double(21)); } fn double(value: i32) -> i32 { value * 2 }
: DOUBLE ( n -- n ) 2 * ; : MAIN 21 DOUBLE . CR ; MAIN
Swapping the two Forth definitions makes the file fail with "undefined word: DOUBLE", which is the single most common error when porting code into a Forth. It is not a style rule — it falls out of there being no separate resolution pass.
The Signature Is A Comment
Rust's signature is checked at every call. The Forth stack comment between parentheses is ignored entirely by the compiler — ( is a word that skips text up to the next ).
fn midpoint(low: i32, high: i32) -> i32 { (low + high) / 2 } fn main() { println!("{}", midpoint(10, 20)); // midpoint(10); // error: takes 2 arguments // let _: String = midpoint(10, 20); // error: mismatched types }
: MIDPOINT ( low high -- middle ) + 2 / ; 10 20 MIDPOINT . CR : WRONG ( low -- middle ) MIDPOINT ; .( WRONG compiles fine and will misbehave ) CR
The comment is still the most important thing in a Forth definition, because it is the only statement of what the word expects and leaves. WRONG above compiles without complaint and consumes a cell it was never given, which will surface as a wrong answer somewhere else entirely.
One Cell, No Types
Many Widths Versus One Cell
Rust makes you choose a width and a signedness for every integer. Forth has exactly one numeric type, the cell, and 1 CELLS reports how many bytes it takes — four in this browser Forth.
fn main() { let small: i8 = -128; let byte: u8 = 255; let word: i32 = i32::MAX; println!("{} {} {}", small, byte, word); }
-128 . 255 . 2147483647 . CR 1 CELLS . CR
Because the width is a property of the system rather than of the value, a program that assumes 32 bits is a program that will behave differently on a 64-bit Forth. Gforth on a desktop has 64-bit cells, so this row genuinely prints something else there.
Overflow: Panic Versus Wrap
Rust makes overflow a decision: + panics in a debug build, and you write wrapping_add when wrapping is what you meant. Forth's + is the wrapping one and there is no other.
fn main() { let biggest = i32::MAX; println!("{}", biggest.wrapping_add(1)); // println!("{}", biggest + 1); // panics in debug, wraps in release }
2147483647 1 + . CR
The two columns agree here only because the Rust side spells out the behavior Forth gives you unconditionally. An embedded Rust programmer already knows this distinction from choosing between wrapping_, checked_ and saturating_; Forth offers the first and expects you to build the others.
Casting Is Masking
Rust's as truncates to the target width, and the type system then remembers how wide the value is. Forth has no narrower type to cast to, so keeping a value inside a byte means masking it yourself with AND.
fn main() { let value: i32 = 300; println!("{}", value as u8); println!("{}", (value as u8) as i32); }
300 255 AND . CR 300 255 AND . CR
The mask is the whole of the conversion, and nothing afterwards remembers that the value is supposed to be a byte — storing it with C! would truncate again, and storing it with ! would not. In Rust the type carries that intent from the cast to every later use.
Truncating Versus Flooring
Rust's / truncates toward zero and div_euclid gives the Euclidean one, which is the floored answer whenever the divisor is positive, as it is here. Forth names the arithmetic rather than the intent: SM/REM is symmetric, FM/MOD is floored, and /MOD is whichever of the two the system happens to be built on.
fn main() { println!("{} {}", -7 / 2, -7 % 2); println!("{} {}", (-7i32).div_euclid(2), (-7i32).rem_euclid(2)); }
-7 2 /MOD . . CR : FLOORED ( n d -- remainder quotient ) >R S>D R> FM/MOD ; -7 2 FLOORED . . CR
🚨 /MOD's rounding is implementation-defined, and Forths genuinely differ: this one truncates and prints -3 -1, while gforth floors and prints -4 1 for the same line. Code that cares must say SM/REM or FM/MOD and take the double-cell dividend that S>D supplies — which is why the second line spells it out.
Fixed Point Instead Of f32
Rust on a microcontroller often avoids floating point deliberately, and this is the idiom it reaches for. This Forth has no floating point at all, so the idiom is the only option — and the printing words exist to serve it.
fn main() { let millivolts = 3300; let whole = millivolts / 1000; let thousandths = millivolts % 1000; println!("{}.{:03}", whole, thousandths); }
: .VOLTS ( millivolts -- ) S>D <# # # # [CHAR] . HOLD #S #> TYPE ; 3300 .VOLTS CR
The <# … #> sequence is Forth's pictured numeric output: it builds the text right to left, one digit per #, with HOLD inserting the point — which is exactly what {:03} asks for. It has to build the whole number, not just the fraction, because . prints a trailing space and would put one in the middle.
Memory You Address Yourself
Vec Versus ALLOT
CREATE makes a dictionary entry whose name pushes its own address, and ALLOT reserves bytes after it. There is no length, no bounds check and no element type — READING is a word you write to turn an index into an address.
fn main() { let mut readings = [0i32; 5]; readings[0] = 10; readings[1] = 20; readings[2] = 30; println!("{}", readings[0] + readings[1] + readings[2]); }
CREATE READINGS 5 CELLS ALLOT : READING ( index -- addr ) CELLS READINGS + ; 10 0 READING ! 20 1 READING ! 30 2 READING ! 0 READING @ 1 READING @ 2 READING @ + + . CR
A fixed-size array is the closest Rust analogue because nothing here is growable: ALLOT moves a pointer at the moment the source is read, so the size is fixed when the word is defined. Indexing past the end reads whatever word was defined next.
Dereferencing With @ And !
VARIABLE reserves one cell and defines a word that pushes its address. ! stores the second-from-top value at the address on top, and @ replaces an address with what is in it.
fn main() { let mut cell = 0i32; let pointer = &mut cell; *pointer = 42; println!("{}", *pointer); }
VARIABLE CELL-STORE 42 CELL-STORE ! CELL-STORE @ . CR
A Rust &mut guarantees it is the only live reference to that location; the address CELL-STORE pushes carries no such promise and can be copied freely. The two symbols read as "store" and "fetch", and getting their argument order backwards writes to the value instead of the address.
Bytes And Cells Are Different Words
Rust picks the access width from the element type of the array. Forth picks it from the word you use: C@ and C! move one byte, @ and ! move a whole cell, and the address itself says nothing about which is right.
fn main() { let mut bytes = [0u8; 4]; bytes[0] = 0xDE; bytes[1] = 0xAD; println!("{} {}", bytes[0], bytes[1]); }
CREATE BYTES 4 ALLOT BYTES 4 ERASE 222 BYTES C! 173 BYTES 1 + C! BYTES C@ . BYTES 1 + C@ . CR
Using @ on this buffer would read four bytes at once and produce a number that depends on the machine's byte order. ERASE zeroes the region first because ALLOT does not — freshly allotted space holds whatever was there before.
There Is No Allocator
HERE pushes the address of the next free byte in the dictionary, so taking it before and after a definition measures everything that definition cost — the eight bytes asked for and the header and name that CREATE laid down. That pointer only ever moves forward.
fn main() { let boxed = Box::new(99); println!("{}", *boxed); // boxed is freed here, by the allocator. }
HERE CREATE SCRATCH 8 ALLOT HERE SWAP - . CR SCRATCH 8 ERASE 99 SCRATCH ! SCRATCH @ . CR
Nothing is ever freed, which is the point: a Forth system's memory use is decided when the source is read, and there is no allocator to fail, fragment, or need a heap size chosen up front. The exact total is this system's own — a Forth with wider cells or a larger header reports more — so read it as "the eight bytes plus the bookkeeping", not as a number to depend on.
Filling And Copying
FILL takes an address, a count and a byte. MOVE takes source, destination and a count — in that order, which is the opposite of C's memcpy and worth checking every time.
fn main() { let mut source = [0u8; 4]; source.fill(7); let mut destination = [0u8; 4]; destination.copy_from_slice(&source); println!("{} {}", destination[0], destination[3]); }
CREATE SOURCE 4 ALLOT CREATE DESTINATION 4 ALLOT SOURCE 4 7 FILL SOURCE DESTINATION 4 MOVE DESTINATION C@ . DESTINATION 3 + C@ . CR
Neither word knows how large either buffer is, so a count larger than the destination quietly overwrites whatever follows it. copy_from_slice panics on a length mismatch, which is the whole difference between the two columns.
Structs Are Offsets
A Struct Is An Offset Table
A Rust struct is a type with named fields and a layout the compiler chooses. In Forth the layout is the definition: POINT-X and POINT-Y are words that produce addresses, and the struct is the convention that those two cells belong together.
struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; println!("{} {}", point.x, point.y); }
CREATE POINT 2 CELLS ALLOT : POINT-X ( -- addr ) POINT ; : POINT-Y ( -- addr ) POINT CELL+ ; 3 POINT-X ! 4 POINT-Y ! POINT-X @ . POINT-Y @ . CR
Nothing stops another word from writing a cell into POINT-Y that is not a coordinate, and nothing records that these two cells are one object. What Rust calls a field access is here an addition that happens to have a name.
CREATE … DOES> Writes Words
COLOR is a defining word: running it creates a new word. The part before DOES> runs at definition time and lays the three bytes down; the part after it is the code the new word runs when it is used.
struct Color(u8, u8, u8); impl Color { fn channels(&self) -> (u8, u8, u8) { (self.0, self.1, self.2) } } fn main() { let plum = Color(128, 64, 200); let (red, green, blue) = plum.channels(); println!("{} {} {}", red, green, blue); }
: COLOR ( blue green red -- ) CREATE C, C, C, DOES> ( -- addr ) ; 200 64 128 COLOR PLUM PLUM C@ . PLUM 1 + C@ . PLUM 2 + C@ . CR
This is the closest thing Forth has to a macro that generates an impl, and it is an ordinary word rather than a separate language. A Rust programmer coming from macro_rules! should note that nothing is expanded — PLUM is built at run time by running COLOR.
An Array Of Structs Is A Stride
CONSTANT gives the record size a name, by long convention written with a leading slash to read as "bytes per sample". Indexing is then a multiply and an add, which is exactly what the Rust compiler emits for samples[1].
#[derive(Clone, Copy)] struct Sample { time: i32, value: i32, } fn main() { let samples = [ Sample { time: 1, value: 100 }, Sample { time: 2, value: 200 }, ]; println!("{} {}", samples[1].time, samples[1].value); }
2 CELLS CONSTANT /SAMPLE CREATE SAMPLES 2 /SAMPLE * ALLOT : SAMPLE ( index -- addr ) /SAMPLE * SAMPLES + ; 1 0 SAMPLE ! 100 0 SAMPLE CELL+ ! 2 1 SAMPLE ! 200 1 SAMPLE CELL+ ! 1 SAMPLE @ . 1 SAMPLE CELL+ @ . CR
Writing the stride as a named constant rather than a literal 8 is the discipline that keeps this maintainable, since adding a field means changing one definition. Rust gets the same effect from size_of::<Sample>(), which it computes for you.
Constructors Are Just Words
There is no impl block and no namespace, so a constructor is a word whose name says which record it fills. The arguments are consumed top-down, which is why millivolts is stored before channel.
struct Reading { channel: i32, millivolts: i32, } impl Reading { fn new(channel: i32, millivolts: i32) -> Self { Reading { channel, millivolts } } } fn main() { let reading = Reading::new(2, 3300); println!("{} {}", reading.channel, reading.millivolts); }
CREATE READING 2 CELLS ALLOT : NEW-READING ( channel millivolts -- ) READING CELL+ ! READING ! ; 2 3300 NEW-READING READING @ . READING CELL+ @ . CR
Storing in reverse order is the small piece of bookkeeping that a named-field struct literal does for you. Every Forth program of any size grows a naming convention to stand in for the namespaces it does not have — NEW- here, and / for sizes above.
Strings Carry Their Length
A &str Is An Address And A Length
A Rust &str is a fat pointer: an address and a length, travelling together as one value. S" leaves exactly those two things as two separate cells, and every word that takes a string takes both.
fn main() { let text = "borrowed slice"; println!("{}", text.len()); println!("{}", text); }
: TEXT ( -- addr len ) S" borrowed slice" ; : SHOW TEXT NIP . CR TEXT TYPE CR ; SHOW
The two cells are not bound together by anything, so NIP can throw the address away and keep the length. Rust makes the pair indivisible and guarantees the bytes are valid UTF-8; here they are bytes, and nothing checks what is in them.
The Other String Layout
Forth has a second string layout: a counted string, which is one address pointing at a length byte followed by the characters. C@ reads that byte, and COUNT turns the single address into the address-and-length pair the printing words want.
fn main() { let text = "Forth"; let length = text.len() as u8; println!("{}", length); println!("{}", text); }
: NAME ( -- addr ) C" Forth" ; NAME C@ . CR NAME COUNT TYPE CR
Having two representations means every string-taking word must document which it expects, and COUNT is the bridge between them. The length byte caps a counted string at 255 characters, which is why the address-and-length form won and is what modern Forth code uses.
Comparing Without An Operator
Rust's == on two string slices compares lengths and then bytes. This Forth has no COMPARE word, so the comparison is written out: reject unequal lengths first, then walk the bytes.
fn main() { let left = "stop"; let right = "stop"; println!("{}", left == right); }
: SAME? ( addr1 len1 addr2 len2 -- flag ) ROT OVER <> IF DROP 2DROP FALSE EXIT THEN 0 DO OVER I + C@ OVER I + C@ <> IF 2DROP UNLOOP FALSE EXIT THEN LOOP 2DROP TRUE ; : CHECK S" stop" S" stop" SAME? . CR ; CHECK
EXIT returns from the word early, and UNLOOP is required before exiting from inside a DO loop because the loop's index and limit are sitting on the return stack. Forgetting UNLOOP leaves them there and the word returns to the wrong place.
Parsing Digits
>NUMBER is the digit-accumulating half of the interpreter, exposed as a word. It takes a starting double-cell value and a string, and returns the accumulated value plus whatever it could not convert.
fn main() { let text = "1234"; let parsed: i32 = text.parse().unwrap(); println!("{}", parsed + 1); }
: PARSED ( -- n ) 0 0 S" 1234" >NUMBER 2DROP DROP ; PARSED 1 + . CR
It returns rather than fails: the leftover string is how you discover it stopped early, which is the whole of the error reporting. Rust's parse returns a Result that names the failure, and unwrap is the choice to ignore it.
Control Flow
Conditionals Are Postfix
The condition is computed before IF runs, because everything is postfix — 25 > leaves a flag, and IF consumes it. THEN is not a keyword of the true branch; it marks where the two branches rejoin.
fn main() { let temperature = 30; if temperature > 25 { println!("warm"); } else { println!("cool"); } }
: REPORT ( temperature -- ) 25 > IF ." warm" ELSE ." cool" THEN CR ; 30 REPORT
Reading THEN as "and then carry on" makes the shape make sense, and it is the word most often misread by newcomers as the start of the consequent. Any non-zero value counts as true, so a flag is not a distinct type the way Rust's bool is.
for Over A Range
DO takes the limit and the starting index in that order, so 5 0 DO counts from 0 up to but not including 5 — the same half-open range Rust writes as 0..5. I pushes the current index.
fn main() { for index in 0..5 { print!("{} ", index); } println!(); }
: COUNT-TO ( limit -- ) 0 DO I . LOOP CR ; 5 COUNT-TO
The limit is pushed first and the start second, which is the reverse of how the range reads aloud and is a reliable source of off-by-everything bugs. Here COUNT-TO receives the limit and pushes its own 0, so the order is visible in one place.
A Range That Is Already Empty
Rust's 0..0 yields nothing, as you would expect. Forth's plain DO does not: it tests at the bottom, so 0 0 DO runs the body once and then wraps all the way round.
fn main() { for index in 0..0 { print!("{} ", index); } println!("done"); }
: SAFE-COUNT ( limit -- ) 0 ?DO I . LOOP ." done" CR ; 0 SAFE-COUNT
?DO is the version that tests first and is what you almost always want when the count comes from a variable. This is the single most dangerous default in the language for someone arriving from a language with real iterators.
while And Its Parts
BEGIN marks the top, WHILE consumes a flag and leaves the loop when it is false, and REPEAT jumps back. The condition sits between BEGIN and WHILE rather than in a header.
fn main() { let mut remaining = 8; while remaining > 1 { print!("{} ", remaining); remaining /= 2; } println!(); }
: HALVE-DOWN ( n -- ) BEGIN DUP 1 > WHILE DUP . 2 / REPEAT DROP CR ; 8 HALVE-DOWN
The value being tested stays on the stack the whole time, so the loop ends with it still there and DROP has to clean up — the bookkeeping a let mut binding does for free. Forgetting that DROP is how a Forth word silently returns one cell too many.
Leaving A Loop Early
LEAVE ends the innermost DO loop, like break. It does not exit the word, and the loop's cleanup still happens — which is why no UNLOOP is needed here, unlike the EXIT in the string comparison above.
fn main() { let readings = [3, 8, 15, 4]; for reading in readings { if reading > 10 { break; } print!("{} ", reading); } println!(); }
CREATE READINGS 3 , 8 , 15 , 4 , : SCAN ( -- ) 4 0 DO READINGS I CELLS + @ DUP 10 > IF DROP LEAVE THEN . LOOP CR ; SCAN
The DROP before LEAVE is required because the reading is still on the stack and nothing else will remove it. A Rust break takes no such care, because the value belonged to a binding that is simply going out of scope.
Result, Option And A Flag
Option Versus A Value And A Flag
The Forth convention is to return the value and a flag, with the flag on top, so the caller tests first and only then finds a value underneath. The stack comment ( -- value true | false ) is how that shape is written down.
fn find_even(values: &[i32]) -> Option<i32> { for value in values { if value % 2 == 0 { return Some(*value); } } None } fn main() { match find_even(&[3, 7, 8, 9]) { Some(found) => println!("found {}", found), None => println!("none"), } }
CREATE VALUES 3 , 7 , 8 , 9 , : FIND-EVEN ( -- value true | false ) 4 0 DO VALUES I CELLS + @ DUP 2 MOD 0= IF TRUE UNLOOP EXIT THEN DROP LOOP FALSE ; : REPORT FIND-EVEN IF ." found " . ELSE ." none" THEN CR ; REPORT
This is Option without the type: the two outcomes leave different numbers of cells, so a caller that forgets to test is not just wrong, it is unbalanced. Rust makes the two cases the same size and the compiler refuses to let you read the value without checking.
The ? Operator By Hand
? returns early when the value is None, so quarter reads as if failure could not happen. In Forth the test is written out, and the ELSE FALSE branch is the part ? would have generated.
fn halve(value: i32) -> Option<i32> { if value % 2 == 0 { Some(value / 2) } else { None } } fn quarter(value: i32) -> Option<i32> { let half = halve(value)?; halve(half) } fn main() { println!("{:?} {:?}", quarter(20), quarter(10)); }
: HALVE ( n -- n/2 true | false ) DUP 2 MOD 0= IF 2 / TRUE ELSE DROP FALSE THEN ; : QUARTER ( n -- n/4 true | false ) HALVE IF HALVE ELSE FALSE THEN ; : SHOW ( n -- ) QUARTER IF . ELSE ." none" THEN SPACE ; 20 SHOW 10 SHOW CR
Every level of the chain has to repeat that test, and a level that forgets it reads a value that was never produced. What ? really buys is not brevity but the impossibility of skipping the check.
unwrap And ABORT"
ABORT" throws away both stacks and stops the program with a message, which is as close as this Forth gets to a panic. CHECKED is defined here to show the shape, and SAFE is what the row actually runs.
fn main() { let readings = [12, 30]; for reading in readings { if reading > 25 { println!("out of range"); } else { println!("{}", reading); } } }
: CHECKED ( n -- n ) DUP 25 > ABORT" out of range" ; : SAFE ( n -- ) DUP 25 > IF ." out of range" ELSE DUP . THEN DROP CR ; 12 SAFE 30 SAFE
Running 30 CHECKED would end the whole program, and because this Forth has no CATCH there is nothing a caller could do about it. That is why a Forth library tends to return a flag rather than abort — the decision to give up belongs to the application, not the word.
There Is Nothing To Catch
Rust's Result carries the reason for the failure, and match forces the caller to deal with both arms. The Forth flag carries one bit, so the reason has to live somewhere else — here, in the caller that already knows what went wrong.
fn risky(value: i32) -> Result<i32, String> { if value < 0 { Err(String::from("negative")) } else { Ok(value * 2) } } fn main() { match risky(-1) { Ok(doubled) => println!("{}", doubled), Err(message) => println!("recovered: {}", message), } }
: RISKY ( n -- n*2 true | false ) DUP 0< IF DROP FALSE ELSE 2 * TRUE THEN ; : MAIN -1 RISKY IF . ELSE ." recovered: negative" THEN CR ; MAIN
This Forth has no CATCH and THROW, so there is no stack unwinding to recover from an abort either. Standard Forth does define them; an example that needs them must define its own or say plainly that it cannot be done here. The two columns print the same words and mean quite different things by them.
Traits, Dispatch And DEFER
A Trait Versus A Naming Convention
A trait gives two unrelated types one shared method name, and the compiler picks the right body from the type. Forth has no types to dispatch on, so the two operations get two names and the caller chooses.
trait Describe { fn describe(&self) -> &'static str; } struct Sensor; struct Relay; impl Describe for Sensor { fn describe(&self) -> &'static str { "sensor" } } impl Describe for Relay { fn describe(&self) -> &'static str { "relay" } } fn main() { println!("{} {}", Sensor.describe(), Relay.describe()); }
: SENSOR-DESCRIBE ." sensor" ; : RELAY-DESCRIBE ." relay" ; SENSOR-DESCRIBE SPACE RELAY-DESCRIBE CR
The prefix convention is doing the work a trait does: it groups the words that belong to SENSOR and keeps them from colliding with RELAY's. What it cannot do is let a third word accept "anything describable", which is the next row.
dyn Trait Versus DEFER
DEFER creates a word whose behavior is decided later. ' pushes the execution token of a word — its address, the nearest thing Forth has to a function pointer — and IS installs it.
trait Output { fn emit(&self); } struct Serial; struct Silent; impl Output for Serial { fn emit(&self) { println!("serial"); } } impl Output for Silent { fn emit(&self) { println!("silent"); } } fn main() { let mut sink: &dyn Output = &Serial; sink.emit(); sink = &Silent; sink.emit(); }
: SERIAL ." serial" CR ; : SILENT ." silent" CR ; DEFER EMIT ' SERIAL IS EMIT EMIT ' SILENT IS EMIT EMIT
This is a vtable with one entry, built by hand, and it is how a Forth driver is retargeted without recompiling its callers. Rust's &dyn Output carries the pointer and the table together and proves the type implements the trait; IS will happily install a word with the wrong stack effect.
Passing A Word As A Value
EXECUTE runs the word whose execution token is on top of the stack. Together with ' that is the whole mechanism — there is no separate function-pointer type, because a token is just a number.
fn double(value: i32) -> i32 { value * 2 } fn triple(value: i32) -> i32 { value * 3 } fn apply(value: i32, operation: fn(i32) -> i32) -> i32 { operation(value) } fn main() { println!("{} {}", apply(5, double), apply(5, triple)); }
: DOUBLE ( n -- n ) 2 * ; : TRIPLE ( n -- n ) 3 * ; : APPLY ( n xt -- n ) EXECUTE ; 5 ' DOUBLE APPLY . 5 ' TRIPLE APPLY . CR
A token being an ordinary cell means it can be stored in an array, passed through several words, or arrived at by arithmetic — and also that executing a number that was never a token will do something undefined. Rust's fn(i32) -> i32 is checked at the call site.
match Versus A Table Of Tokens
The three tokens are stored in a table with ,, which lays one cell into the dictionary. DISPATCH indexes the table and executes what it finds, which is the jump table a match on small integers usually compiles into.
fn main() { for command in 0..3 { match command { 0 => println!("stop"), 1 => println!("start"), _ => println!("reset"), } } }
: STOP ." stop" CR ; : START ." start" CR ; : RESET ." reset" CR ; CREATE COMMANDS ' STOP , ' START , ' RESET , : DISPATCH ( index -- ) CELLS COMMANDS + @ EXECUTE ; : RUN-ALL 3 0 DO I DISPATCH LOOP ; RUN-ALL
This Forth has no CASE statement, so a table is the idiomatic answer once there are more than two or three branches. There is no default arm and no bounds check: an index of 3 reads the cell after the table and executes it.
Macros Versus IMMEDIATE
const Versus CONSTANT
CONSTANT is a defining word: it consumes the value on the stack and creates a word that pushes it back. The value is computed once, when this line is read.
const BAUD_RATE: i32 = 9600; fn main() { println!("{}", BAUD_RATE / 100); }
9600 CONSTANT BAUD-RATE BAUD-RATE 100 / . CR
Because the value is computed at definition time by ordinary Forth, the expression can be as involved as you like — there is no separate const-evaluation language with its own rules about what is allowed. Rust's const fn exists precisely to widen that boundary.
Computing While Compiling
SAMPLES-PER-WINDOW is an ordinary word, and the line below it runs immediately as the source is read — so WINDOW ends up holding a number that was computed before the program proper started.
const fn samples_per_window(rate: i32, milliseconds: i32) -> i32 { rate * milliseconds / 1000 } const WINDOW: i32 = samples_per_window(48000, 20); fn main() { println!("{}", WINDOW); }
: SAMPLES-PER-WINDOW ( rate ms -- samples ) * 1000 / ; 48000 20 SAMPLES-PER-WINDOW CONSTANT WINDOW WINDOW . CR
Nothing marks the word as usable at compile time, because in Forth there is no such distinction to mark: the same interpreter is running either way. const fn is Rust naming a subset of the language that the compiler is willing to run itself.
macro_rules! Versus IMMEDIATE
An IMMEDIATE word runs when it is compiled rather than when the word containing it runs. POSTPONE LITERAL is how it hands a value to the definition being built, since a bare number is not a word that could be postponed.
macro_rules! half_the_answer { () => { 21 }; } fn main() { println!("{}", half_the_answer!() * 2); }
: HALF-THE-ANSWER 21 POSTPONE LITERAL ; IMMEDIATE : ANSWER HALF-THE-ANSWER 2 * ; ANSWER . CR
This is a macro made of the same material as everything else: it is a word, it runs on the same stack, and it can do anything a word can do, including read ahead in the source. A Rust macro operates on token trees in a separate language with its own matcher.
Rolling The Dictionary Back
MARKER defines a word that, when run, discards every definition made after it — including itself. The dictionary pointer moves back to where it was.
fn main() { // Rust has no way to un-define an item at run time. let temporary = "defined and used"; println!("{}", temporary); println!("nothing can be forgotten"); }
MARKER FORGET-TEMP : TEMPORARY ." defined and used" CR ; TEMPORARY FORGET-TEMP .( TEMPORARY is gone now ) CR
This is the tool for interactive work on a device: load an experiment, try it, roll it back, and the system is exactly as it was. Rust has no equivalent because there is no dictionary and nothing is added at run time in the first place.
Embedded Without A Runtime
no_std Versus Nothing To Strip
An embedded Rust program opts out of the standard library, supplies a panic handler and names its own entry point. A Forth system has no standard library to opt out of and no entry point to name — the words that exist are the ones that were compiled into it.
// A real no_std binary needs #![no_std], a panic handler and an entry point. // This runs on the playground, which is std, but uses nothing from it. fn main() { let mut total = 0; let mut index = 0; while index < 5 { total += index; index += 1; } println!("{}", total); }
: TOTAL ( limit -- sum ) 0 SWAP 0 ?DO I + LOOP ; 5 TOTAL . CR
The interactive prompt is the runtime, and on a microcontroller it is typically a few kilobytes. That is the trade the two languages make in opposite directions: Rust removes what it does not need at build time, and Forth never had it.
Writing To A Register
Rust reaches a memory-mapped register through a raw pointer, and every access has to be inside unsafe and marked volatile so the optimizer leaves it alone. In Forth ! is the only store there is, and it is never elided.
fn main() { let mut register: u32 = 0; let pointer = &raw mut register; unsafe { core::ptr::write_volatile(pointer, 0b0000_1010); println!("{}", core::ptr::read_volatile(pointer)); } }
VARIABLE GPIO-DIRECTION 10 GPIO-DIRECTION ! GPIO-DIRECTION @ . CR
A real device address would be a CONSTANT holding the number from the datasheet, and the store would look exactly like this one. There is no unsafe keyword because there is no safe subset to be outside of — which is the honest summary of the whole comparison.
Setting And Testing Bits
The same bit arithmetic, given names. BIT turns a position into a mask, and SET and SET? read as English at the call site — which is the usual way a Forth program gets readable without a type system.
fn main() { let mut flags: u32 = 0; flags |= 1 << 3; flags |= 1 << 5; println!("{}", flags); println!("{}", (flags & (1 << 3)) != 0); }
: BIT ( position -- mask ) 1 SWAP LSHIFT ; : SET ( value position -- value ) BIT OR ; : SET? ( value position -- flag ) BIT AND 0<> ; 0 3 SET 5 SET DUP . CR 3 SET? . CR
Rust prints true where Forth prints -1, because a Forth true flag is all bits set, which is what makes AND and OR work on flags as well as on numbers. That is worth knowing before comparing a flag against 1.
A Ring Buffer With No Allocator
A fixed buffer, an index, and a modulo — the structure an embedded program uses when there is no allocator and no growth. Both columns write six values into four slots, so the first two are overwritten.
fn main() { const SIZE: usize = 4; let mut buffer = [0i32; SIZE]; let mut write_index = 0; for value in [10, 20, 30, 40, 50, 60] { buffer[write_index % SIZE] = value; write_index += 1; } println!("{} {} {} {}", buffer[0], buffer[1], buffer[2], buffer[3]); }
4 CONSTANT SIZE CREATE BUFFER SIZE CELLS ALLOT VARIABLE WRITE-INDEX 0 WRITE-INDEX ! : PUSH ( value -- ) WRITE-INDEX @ SIZE MOD CELLS BUFFER + ! WRITE-INDEX @ 1 + WRITE-INDEX ! ; : SHOW-ALL SIZE 0 DO BUFFER I CELLS + @ . LOOP CR ; 10 PUSH 20 PUSH 30 PUSH 40 PUSH 50 PUSH 60 PUSH SHOW-ALL
The Rust version is bounds-checked on every write and the Forth version is not, which for this code costs Rust a comparison and buys it the guarantee that a wrong SIZE panics instead of corrupting whatever follows the buffer. The rest is identical.
Gotchas For Rust Developers
The Built-In Words Are Uppercase
This Forth is case-sensitive and its built-in words are spelled in capitals — CR is a word and cr is an error. Your own definitions keep whatever case you gave them.
fn main() { // Rust is case-sensitive too, but its keywords are lowercase // and nothing about a name's case changes whether it resolves. let value = 42; println!("{}", value); }
: SHOUT ." forty-two" CR ; SHOUT 42 . CR
Most desktop Forths fold case, so an example typed in lowercase works there and fails here — which means a snippet copied from a book or from gforth will often need its case corrected. Defining : Greet does not define GREET.
Redefining Does Not Reach Back
Defining SCALE a second time adds a new dictionary entry and hides the old one from anything compiled afterwards. APPLY-SCALE was compiled earlier and still calls the first one.
fn main() { let scale = 2; let doubled = |value: i32| value * scale; println!("{}", doubled(10)); let scale = 100; let scaled = |value: i32| value * scale; println!("{} {}", doubled(10), scaled(10)); }
: SCALE ( n -- n ) 2 * ; : APPLY-SCALE ( n -- n ) SCALE ; 10 APPLY-SCALE . CR : SCALE ( n -- n ) 100 * ; 10 APPLY-SCALE . 10 SCALE . CR
Rust's closure prints the same pair for the same reason: it captured the first scale, and the later let shadowed the name without reaching back. The difference is that Forth does this to every word in the system, so fixing a bug in a low-level word does not fix its callers until they are recompiled.
Nobody Is Counting
The 99 on the second line is left over from earlier work, the way a cell often is. 5 TAKES-TWO then supplies one argument, takes the 99 as the other, and answers 104 without complaint.
fn takes_two(first: i32, second: i32) -> i32 { first + second } fn main() { println!("{}", takes_two(1, 2)); // takes_two(1); // error: this function takes 2 arguments but 1 argument was supplied }
: TAKES-TWO ( a b -- sum ) + ; 99 1 2 TAKES-TWO . CR 5 TAKES-TWO . CR
Checking the stack with .S after any word you are unsure of is the habit this produces. Rust's arity check is one of the things an embedded programmer gives up entirely when moving to Forth, and no amount of care fully replaces it — a wrong answer is the only symptom.
An Address Is Just A Number
Rust never does arithmetic on the address a reference holds: pointer + 1 reaches through to the value and gives 43. Moving a raw pointer takes an explicit method such as wrapping_add, which counts in elements rather than bytes. In Forth an address is a cell like any other, so SLOT 1 + is ordinary addition — the second line subtracts the original address back off to show the difference is simply 1.
fn main() { let value = 42; let pointer = &value; println!("{}", *pointer); println!("{}", pointer + 1); // 43: the arithmetic reaches the value, never the address }
VARIABLE SLOT 42 SLOT ! SLOT @ . CR SLOT 1 + SLOT - . CR
Nothing distinguishes an address from a measurement, a character code or a flag, so a word that expects one and gets another produces a number rather than a complaint. This is the root of most of the differences on this page, and it is also what makes the language fit in a few kilobytes.

Thank you — anything else?