Shoddy Documentation
Eleven short chapters — from your first Print to a program that keeps its data on disk
Welcome! First things first: Shoddy is the language’s name, not a review of it — and it is definitely not a description of you. The name honors the shoddy woolen mills of the West Riding of Yorkshire. Those mills took worn-out rags and reclaimed them into new cloth: Useless Things Made Useful Through Skill. That is the tagline, meant literally. It is the spirit of the whole language. (The heritage of the name tells the full story of the mills — worth the five minutes.)
This guide assumes no programming experience at all. If you’ve written a little code in any language you’ll move faster, but nothing here requires it. (The full language reference lives in spec.html — come back to it later; this is the friendly door in.)
Shoddy is a small programming language. It looks like classic BASIC, and it uses indentation the way Python does. One unusual rule sits at its heart: nothing ever changes. You don’t modify things. You make new things from old things. That one rule gives the language most of its personality. This guide will show you it’s much less strange than it sounds.
Get the mill (the Shoddy toolchain) working first — Setup
is two steps: the .NET runtime, then the VS Code extension, which brings the mill
and the whole library with it. The VS Code page is the
tour of what that gives you: Run button, snippets, and a real debugger. Then put
this in a file called hello.shoddy:
Def Main()
Print("HELLO, WORLD")
Let who = Input("WHAT IS YOUR NAME? ")
Print("NICE TO MEET YOU, " & Upper(who))
Run it — in VS Code click the ▶ Run button, or from a terminal run
bin/mill run hello.shoddy from the repo root (just
mill run hello.shoddy, from anywhere, once bin is on
your PATH). Three things to notice:
Def blocks; Def Main() is where it starts.Main. There’s no end, no }, no ; — when the indentation stops, so does the definition.& glues strings together, and Upper is a function called the way you’d expect: name, parentheses, arguments.Case doesn’t matter (print, Print, and PRINT are all the same word). This guide follows a convention: PascalCase for functions and types (Print, Number, Grade), and camelCase for your own variables (count, nums). Classic BASIC all-caps still works fine, if your caps-lock key has sentimental value.
The kinds of value you’ll use every day:
Let count = 42 ' a Number
Let price = 3.99 ' also a Number (there's only one kind)
Let name = "ADA" ' a String
Let open = True ' a Boolean - True or False, its own type
(' starts a comment, as does Rem.)
Let gives a value a name. Here is the big rule: a name is given its value once, and that’s final. This is not legal:
Let x = 5
Let x = x + 1 ' Error: duplicate binding
Think of names as labels on values, not boxes you put things in. If you want “X, but one bigger,” that is a new value, and it deserves its own name. More often, you’ll pass it to a function and name it there. Every language makes you think about something. Shoddy makes you think about where new values come from. In exchange, whole categories of bugs (“who changed my variable?!”) simply can’t happen.
One more place Let works: at the very left margin, outside any Def. That makes a constant the whole program can see — set up once, before anything runs:
Let taxRate = 0.08
Let menu = { "TEA", "SCONE", "JAM" }
Def WithTax(price As Number) As Number
price * (1 + taxRate)
Math works as expected, including ^ for powers:
Print(2 + 3 * 4) ' 14 - multiplication first
Print(2 ^ 10) ' 1024
Print(17 Mod 5) ' 2 - remainder
Print(Sqr(2)) ' 1.414213562
Strings have a family of helpers: Len, Left, Right, Mid, Upper, Lower, Instr (find a substring), Str (number → string), and Val (string → number). They come straight out of classic BASIC, minus the dollar signs BASIC hung on the string ones. Try a few:
Print(Left("HELLO", 3)) ' HEL
Print(Mid("HELLO", 2, 3)) ' ELL (positions start at 1)
Print(Instr("HELLO", "LL")) ' 3
Print("AGE: " & Str(7 * 6)) ' AGE: 42
To read a single character, reach for CodeAt and not
Mid. They look interchangeable and they are not.
Mid(s, k, 1) cuts out a one-character string, and if
k has run past the end it quietly hands back an empty one — so
the program carries on and falls over somewhere else, usually inside
Asc, which is not where the mistake was.
CodeAt(s, k) answers the character's code as a number and
refuses on the spot when there is no character there, naming the position
and the length. Use Codes when you mean to walk a whole string,
FromCodes to build one back from codes, and
InstrFrom to search from a position rather than from the
start:
Print(CodeAt("HELLO", 1)) ' 72
Print(Codes("HI")) ' Array(72, 73)
Print(FromCodes(ToArray({ 72, 73 }))) ' HI
Print(InstrFrom("HELLO", "L", 4)) ' 4 (Instr, but starting at 4)
Booleans are their own type — a number is never “truthy.” Comparisons (= <> < > <= >=) make booleans; And, Or, Not combine them.
If looks like BASIC, ends like Python:
Def Main()
Let age = Val(Input("HOW OLD ARE YOU? "))
If age >= 18 Then
Print("WELCOME IN")
Else
Print("COME BACK IN " & Str(18 - age) & " YEARS")
One subtlety that pays off everywhere: If is an expression — each branch’s last line is a value, and the If as a whole produces whichever branch ran. That’s why functions don’t need a return statement. Behold your first function:
Def Bigger(a As Number, b As Number) As Number
If a > b Then
a
Else
b
Read the header out loud: “define Bigger, which takes A as a number and B as a number, and results in a number.” The As types document what goes in and out (and the mill checks them while your program runs). The body has no return because the body is the result.
Print(Bigger(3, 7)) ' 7
When one value gets checked against several possibilities, chained IFs get ugly fast. Select Case is the tidy version — straight out of classic BASIC, and also an expression:
Def Describe(n As Number) As String
Select Case n
Case 0
"ZERO"
Case 1, 2, 3 ' any of these
"SMALL"
Case 4 To 10 ' this range, both ends included
"MEDIUM"
Case Is > 10 ' any comparison (the Is is optional)
"BIG"
Case Else
"NEGATIVE"
The value being tested is computed once, the first matching clause wins, and Case Else catches everything left over. You’ll meet it again grading students in chapter 9.
Here’s the twist: Shoddy has no for loop. A loop counter is a variable that changes — i becomes 2, then 3 — and nothing changes here. So how does anything happen more than once? Two ways, and you’ll end up loving both.
Way one: a function that calls itself.
Def Countdown(n As Number) As String
If n = 0 Then
"LIFTOFF"
Else
Str(n) & "... " & Countdown(n - 1)
Print(Countdown(5)) ' 5... 4... 3... 2... 1... LIFTOFF
No counter ever changed — each call gets its own brand-new n, one smaller. This is called recursion, and it is the loop of this language. The classic:
Def Fact(n As Number) As Number
If n = 0 Then
1
Else
n * Fact(n - 1)
Way two (the everyday way): tell a helper what to do to each item. Most “loops” in real programs visit every item in a collection. For that, Shoddy has words that take a function as an argument:
Def Square(n As Number) As Number
n * n
Def Main()
Let nums = { 1, 2, 3, 4, 5 }
Print(Map(nums, Square)) ' [ 1 4 9 16 25 ]
Print(Filter(nums, >=(3))) ' [ 3 4 5 ]
Print(Fold(nums, 0, +)) ' 15
Each(nums, Print) ' prints each on its own line
The big four, in plain words:
Map(list, f) — “make a new list by doing f to every item.”Filter(list, test) — “keep the items that pass the test.”Fold(list, start, f) — “combine everything into one value, starting from start.” Fold(nums, 0, +) is 0+1+2+3+4+5.Each(list, f) — “do f to each item” (for printing and such).Notice the three ways we handed over the “what to do”:
Map(nums, Square), Each(nums, Print), even a bare operator: Fold(nums, 0, +). Naming a function without parentheses passes it along instead of calling it.>=(3) means “≥ with the 3 already filled in” — a ready-made test for “is this at least 3?”. Same trick: *(2) doubles, =("Y") tests for a Y.Fn, when you need something custom, right here, just once:Print(Filter(nums, Fn(x) => x Mod 2 = 0)) ' [ 2 4 ] - the evens
And Range(a, b) makes the list a through b, which brings the for loop’s favorite trick back without the mutable counter:
Print(Fold(Range(1, 100), 0, +)) ' 5050
You’ve met lists: { 1, 2, 3 }. Lists are built for the recursion style — First(xs) is the first item, Rest(xs) is everything after, Prepend(x, xs) puts an item on the front, IsEmpty(xs) asks if it’s empty. Watch those four make a function:
Def MySum(xs As List Of Number) As Number
If IsEmpty(xs) Then
0
Else
First(xs) + MySum(Rest(xs))
That shape — “empty? done. Otherwise handle the first, recurse on the rest” — is the list idiom. You’ll write it many times.
Arrays are the other collection: fixed length, and instantly indexable at any position:
Let a = ToArray({ 10, 20, 30, 40 })
Print(Nth(a, 3)) ' 30 - positions start at 1
Print(SetNth(a, 3, 99)) ' Array(10, 20, 99, 40)
Print(Nth(a, 3)) ' still 30!
Let zeros = Dim(5, 0) ' Array(0, 0, 0, 0, 0)
Look at those last lines carefully: SetNth didn’t change a — it handed back a new array with one spot different. Nothing ever changes, remember? “Updating” always means “getting a new one.” Rule of thumb: recurse on lists, index into arrays. Map, Filter, Fold, Each, Nth, and Length happily accept either.
A student has a name and a score. Gluing those into strings or parallel lists gets old immediately; instead, declare the shape:
Type Student
Name As String
Score As Number
That one declaration (it goes at the left margin, like Def) hands you three tools. A constructor:
Let s1 = Student("ADA", 96)
Let s2 = Student(Name = "GRACE", Score = 91) ' or spell fields out
Accessors — each field name becomes a function:
Print(Name(s1)) ' ada
Print(Score(s1) + Score(s2)) ' 187
And With for “the same, except…” — because records don’t change either:
Let retested = With(s1, Score = 100)
Print(s1) ' Student(Name = "ADA", Score = 96) - untouched
Print(retested) ' Student(Name = "ADA", Score = 100)
Records and the chapter-4 words are best friends, because accessors are just functions you can pass by name:
Let class = { s1, s2, Student("LIN", 78) }
Print(Map(class, Name)) ' [ "ADA" "GRACE" "LIN" ]
Print(Filter(class, Fn(s) => Score(s) >= 90)) ' the honor roll
Print(Fold(class, 0, Fn(t, s) => t + Score(s))) ' 265 - total points
One more trick, now that records and Select Case have both been introduced: a Case can look inside a record. Name the type, list its fields, and the field values are yours to use — with an optional Where condition to be picky:
Def Intro(s As Student) As String
Select Case s
Case Student(n, sc) Where sc >= 90
n & " (STAR STUDENT, " & Str(sc) & " POINTS)"
Case Student(n, sc)
n
Read the first clause as: “if S is a Student — calling its name N and its score SC — and SC is at least 90, then…”. The names last exactly as long as their clause. This is called pattern matching. Many newer languages offer it too, and most charge a lot more syntax for it.
Two more tricks in the same spirit. A Type can offer alternatives:
Type Shape = Circle(r) | Rect(w, h)
Def Area(s As Shape) As Number
Select Case s
Case Circle(r)
Pi() * r ^ 2
Case Rect(w, h)
w * h
A Shape is either a circle or a rectangle, and Select Case sorts out which — no flags, no “type” field you maintain by hand. And patterns nest: Case Pair(Pair(a, b), c) reaches two levels deep in one line, with _ as the polite name for “don’t care.”
Alternatives also answer a question you’ll hit soon enough. Shoddy has no NULL — the “no value here” hole most languages leave open. So how does a function say “I have nothing for you”? By saying it in the type. The conventional pair of names is Some and None — you’ll meet them all over the functional world. Shoddy predeclares them, so there is nothing to write and nothing to include:
' Type Option = Some(Value) | None — already there, don't declare it
Def Halve(n As Number) As Option
If n Mod 2 = 0 Then
Some(n / 2)
Else
None()
Def Describe(o As Option) As String
Select Case o
Case Some(v) Where v >= 100
"BIG: " & Str(v)
Case Some(v)
Str(v)
Case None
"NOTHING THERE"
Halve never returns a number — it returns an Option, which
is either a number wrapped in Some or a None. The
only way to get the number back out is a Select Case, and a
Select Case makes you say what happens on None —
so the “forgot to check for nothing” bug can’t be written. One bit of small
print: building an empty variant takes parentheses
(None() — it’s a constructor like any other), while
matching one doesn’t (Case None).
There’s a second predeclared pair for when failure has a reason:
Ok(Value) and Err(Why, At). Use Option
when the only possible reason is “not there” — a missing key, an empty
list — and Result when the caller could act differently
depending on what went wrong. At is 0 when there’s
no position to report.
Neither pair gives you accessor words, on purpose. There is no
Value(o) to reach past the check with; if there were, calling it
on a None would fail at run time and you’d be back to NULL by
another name. Select Case is the way in.
Time for the reveal. Everything you’ve written so far is sugar — a friendly costume over a much simpler machine. Shoddy’s real engine is a stack: a pile of values. Every operation takes its inputs off the top of the pile and puts its result back on.
When you write Hypot(3, 4), the machine actually runs 3 4 Hypot: push 3, push 4, run Hypot (which eats both and leaves 5). Every piece of surface syntax translates this way:
| You write | The machine runs | Reading the right side |
|---|---|---|
f(a, b) |
a b f |
push A, push B, run F |
2 + 3 * 4 |
2 3 4 * + |
the * happens first |
Let x = 5 |
5 Take x |
push 5, name the top value X |
Map(xs, Square) |
xs [ Square ] Map |
[ ] = code passed, not run |
>=(100) |
[ 100 >= ] |
a test, waiting for its input |
Fn(x) => x * 2 |
[ Take x x 2 * ] |
same, with a named input |
If c Then a Else b |
c [ a ] [ b ] Ifte |
pick one bundle of code and run it |
The brackets are the one core idea with no everyday costume: [ ... ] means “here is code as a thing, don’t run it yet.” That’s what Map receives — a package of code to run on each item.
You can write in machine dialect directly — leave the parentheses off a Def header and the body is stack code:
Def Square ( Number -- Number )
Dup *
( Number -- Number ) documents “takes a number, leaves a number.” Dup copies the top of the pile; * multiplies the top two. So: copy the input, multiply the copies. That’s squaring, in two words, with no variable at all. A handful of these shuffle words (Dup, Swap, Drop…) move values around the pile.
Why would you ever? Because sometimes data just flows left to right, and then the machine dialect is the clearer one:
Def EvenSquareSum(n As Number) As Number
Range(1, n)
Filter(IsEven)
Map(Square)
Fold(0, +)
That’s a pipeline: the range flows into Filter, which flows into Map, which flows into Fold. It works precisely because of the stack — each step’s result is just sitting on the pile waiting for the next step. You now know the language’s secret: BASIC on the outside, a stack machine underneath, and the two mix on any line. (Want to watch it run? The Stack traces this exact function word by word, pile drawn at every step — the full tour of the machine this chapter only waved at.)
Split programs across files with Include (left margin, near the top):
Include "seq.shoddy"
A whole standard library ships with the language — a shed full of machines, every one written in the language. Open them up; they’re readable with what you know now. Four to start with:
Any, All, CountIf, Contains, IndexOf, Taken/DropN (first N / all but), Append, Last, Flatten, Zip/ZipWith.Sum, Average, Maximum, Minimum, Median…) and, when you’re ready for them, real statistics: correlation, regression, and the classic tests.Split, Join, Trim, Replace, StartsWith, EndsWith, StrRep.ReadLines("notes.txt") gives you a list of strings (then Map/Filter away); WriteLines and AppendLine go the other way. Under them sit the builtins ReadFile, WriteFile, AppendFile, FileExists, DeleteFile. Chapter 10 puts all of them to work.And the shed is bigger than that:
Split(line, ",") gets wrong the moment a cell contains a comma.Matrix type, with simplex, mps, and neural on top of it.--allow-net.mill run.Play(1, "T120 O4 L8 C D E F G2").Every machine has its own page — summary, history, guide, and word reference — in the machines catalog.
Taste test:
Include "seq.shoddy"
Include "str.shoddy"
Def Main()
Print(Sort({ 3, 1, 4, 1, 5, 9, 2, 6 })) ' [ 1 1 2 3 4 5 6 9 ]
Print(Join(Split("A,B,C", ","), " AND ")) ' A AND B AND C
Print(CountIf(Range(1, 100), Fn(n) => n Mod 7 = 0)) ' 14
And some personality from inside the library — Flatten is genuinely this one line: Fold(xss, { }, Concat).
Pull in enough of the shed and sooner or later two machines will have had the same good idea about a name. turtle has a Close — it shuts the drawing window. net has a Close too — it hangs up a socket. Include both and Shoddy stops rather than guessing:
ERROR: duplicate definition of CLOSE across machines — include one of them under a namespace (AS)
That's deliberate. Silently picking a winner is how you spend an afternoon wondering why closing a socket redrew your window. The fix is to say which one you meant, by giving one of the two machines a namespace when you include it:
Include "net.shoddy" As Sock
Include "turtle.shoddy"
Def PackUp(t As Turtle) As Turtle
Close(t) ' bare Close — the turtle's
Def Hangup(s As Number)
SockClose(s) ' net's, under its namespace
As Sock puts a Sock in front of everything that file declares, so net's Close becomes SockClose and Connect becomes SockConnect. It collapses to one plain word, so there's a second spelling for the same thing when it reads better with the arguments in view — Close In Sock (s). Use whichever suits the line.
Three things stop this being a nuisance.
Connect, Send and the rest still answer to their plain names. Only Close, the word actually in dispute, needs the prefix.As goes on the machine with the more common-sounding words. net's surface is all plain verbs — Connect, Send, Recv, Listen, Accept. One prefix there settles every argument it will ever have. Qualifying turtle instead would settle only this one.Your own code gets the same kind of scrutiny, from two guards that have nothing to do with namespaces. Declaring a name twice is an error, and the error names the earlier file and line. A Def whose name is already a builtin is refused outright — ROUND is a builtin — a Def of that name would shadow it everywhere. A Def outranks a builtin across the whole program, so Shoddy makes you say you meant it. Write Redef in place of Def when you mean it:
Redef Round(x As Number) As Number ' yes, really, mine
There's more on all of this — the full resolution order, and how namespaced record accessors behave — in the spec.
Everything so far, in ~50 lines. This is gradebook.shoddy in your folder — run it and type a few students in:
Include "seq.shoddy"
Include "stats.shoddy"
Type Student
Name As String
Score As Number
Rem Ask for students until the user types done.
Def ReadStudents(soFar As List Of Student) As List Of Student
Let who = Input("STUDENT NAME (OR DONE)? ")
If Upper(who) = "DONE" Then
soFar
Else
Let pts = Val(Input("SCORE FOR " & who & "? "))
ReadStudents(Append(soFar, Student(who, pts)))
Def Grade(s As Student) As String
Select Case Score(s)
Case >= 90
"A"
Case >= 80
"B"
Case >= 70
"C"
Case Else
"F"
Def ShowStudent(s As Student)
Print(" " & Name(s) & ": " & Str(Score(s)) & " GRADE " & Grade(s))
Def Main()
Print("=== GRADE BOOK ===")
Let roster = ReadStudents({ })
If IsEmpty(roster) Then
Print("NO STUDENTS ENTERED.")
Else
Print("\nROSTER:")
Each(roster, ShowStudent)
Let scores = Map(roster, Score)
Print("CLASS AVERAGE: " & Str(Average(scores)))
Print("TOP SCORE: " & Str(Maximum(scores)))
Let passing = Filter(roster, Fn(s) => Score(s) >= 70)
Print("PASSING: " & Str(Length(passing)) & " OF " & Str(Length(roster)))
Print("HONOR ROLL:")
Each(Filter(roster, Fn(s) => Score(s) >= 90), ShowStudent)
Worth pausing on: the “input loop” is ReadStudents calling itself with a longer list each time — recursion doing the job a while loop would do elsewhere. And the whole report at the bottom is chapter 4’s vocabulary pointed at chapter 6’s records: Map a field out, Filter by a condition, aggregate. Once the roster exists, no line of the report could possibly disturb it.
The grade book has one embarrassment: amnesia. You type in the whole roster, admire the report, the program ends — and the roster is gone. Files are the cure. Shoddy has two kinds: text files, read and written whole, and binary files, reached into byte by byte. One convention before either. Every file word is effectful, like Print and Input: it changes the world outside the program. So keep file words at the edges, on Main’s side, and let pure functions do the thinking in between.
Text, whole files at once. A handful of builtins, no ceremony, no handles (those come with binary files, below) — a text file goes down or comes up in one gulp:
WriteFile("motto.txt", "USELESS THINGS MADE USEFUL") ' replaces the file
AppendFile("motto.txt", " THROUGH SKILL") ' adds to the end
Print(ReadFile("motto.txt")) ' the whole file as one String
DeleteFile("motto.txt")
Paths resolve against the working directory. The rule to respect: ReadFile on a missing file is an error, not an empty string. Each direction has a guarded twin, and the twins are the only way to ask “can this path be read?” or “can this path be written?” FileExists can answer neither question: a directory reports False, and a file you can’t open reports True. TryWriteFile(path, s) is WriteFile answering a Boolean instead of aborting. TryReadFile(path) is ReadFile answering the language’s own Result, because a Boolean would have nowhere to put the text:
Select Case TryReadFile("motto.txt")
Case Ok(text)
Print(text)
Case Err(why, _)
Print(why) ' CANNOT READ 'motto.txt' (NO SUCH FILE)
That is chapter 6’s Result doing what it exists for. The reason comes back as a value, the program carries on, and no FileExists guess stands between you and the answer. The reasons are Shoddy’s own short phrases — NO SUCH FILE, IS A DIRECTORY, ACCESS DENIED, UNREADABLE — never the operating system’s wording. That way a program can compare them, and a test can pin them.
Text, line by line. Most text files are really lists of lines, and file.shoddy (chapter 8) turns them into exactly that: ReadLines gives a List Of String, WriteLines writes one back, AppendLine adds to the end. And once a file is a list, chapter 4 takes over. Here is the grade book’s cure, in three short functions:
Include "file.shoddy" ' brings str.shoddy's Split along for free
Def SaveRoster(path As String, roster As List Of Student)
WriteLines(path, Map(roster, Fn(s) => Name(s) & "," & Str(Score(s))))
Def ParseStudent(line As String) As Student
Let bits = Split(line, ",")
Student(First(bits), Val(Nth(bits, 2)))
Def LoadRoster(path As String) As List Of Student
If FileExists(path) Then
Map(ReadLines(path), ParseStudent)
Else
{ }
Have Main call LoadRoster before asking for students, and SaveRoster after the report. Now the roster survives between runs, as ordinary text (ADA,96 per line) you can open in any editor. Notice the shape: a record becomes a string with &, and a string becomes a record with Split and Val. Map carries the whole roster each way.
But text has a limit: to change one score you load, change, and rewrite the whole file. For thirty students, fine. For a million records, there’s the other kind.
Binary, random access. Straight out of classic BASIC’s record files. BOpen(path) opens a file for reading and writing, creating it if absent. It hands back a handle: a Number that stands for the open file, and that you pass to every other word. Up to 16 files can be open at once, each until its BClose(h). Inside the file every byte has a position, numbered from 1. Seek(h, pos) jumps to one, BPos(h) says where you are, and BSize(h) says how big the file is. Reading and writing go through typed pairs, and each pair advances the position as it works. PutNum/GetNum move a Number as 8 bytes. PutBool/GetBool move a Boolean as 1 byte. PutStr(h, s, Len)/GetStr(h, Len) move a fixed-length field: exactly Len bytes, zero-padded on the way in, padding stripped on the way out. A string too long for its field is an error — Shoddy aborts rather than silently truncate your data. So is reading past the end of the file.
Why fixed lengths? Because they make every record the same size, and same-size records make finding one arithmetic. A Name(12) + Score(8) student is 20 bytes, so record k starts at byte 1 + (k - 1) * 20:
Def WrStudent(f As Number, s As Student)
PutStr(f, Name(s), 12) ' exactly 12 bytes, zero-padded
PutNum(f, Score(s)) ' exactly 8
Def RdStudent(f As Number) As Student
Student(GetStr(f, 12), GetNum(f))
Def SaveClass(path As String, roster As List Of Student)
If FileExists(path) Then
DeleteFile(path) ' BOpen never truncates - start clean
Let f = BOpen(path)
Each(roster, Fn(s) => WrStudent(f, s))
BClose(f)
Def SetScore(path As String, k As Number, score As Number)
Let f = BOpen(path)
Seek(f, 1 + (k - 1) * 20 + 12) ' record k, skip past the name
PutNum(f, score)
BClose(f)
Look at SetScore. It changes student k’s score by writing eight bytes, wherever they live — record 3 of 30 or record 900,000 of a million. It never reads, rewrites, or even glances at the rest of the file. That trick is the foundation every database stands on, and you just wrote it in five lines. When you do this in earnest, recio.shoddy does the offset sums for you (GetRec, PutRec, AppendRec, AllRecs — its page works this very student file). And isam.shoddy goes one further: it finds records by key (“GRACE”) instead of by number.
A file format of your own. Records aren’t the only thing binary files are for. The same words also serialize: they turn a value into bytes today, and back into the same value tomorrow. Every binary format ever designed follows the same recipe. First comes a header, which says what the file is and how much follows. Then comes the payload. And the loader checks the header before trusting a single byte. In full:
Let numsMagic = 12648430 ' C0FFEE, in hexadecimal
Def SaveNums(path As String, xs As List Of Number)
If FileExists(path) Then
DeleteFile(path)
Let f = BOpen(path)
PutNum(f, numsMagic) ' "this is one of ours"
PutNum(f, Length(xs)) ' how many numbers follow
Each(xs, Fn(x) => PutNum(f, x))
BClose(f)
Def LoadNums(path As String) As List Of Number
Let f = BOpen(path)
If BSize(f) < 16 Or GetNum(f) <> numsMagic Then
Error("NOT A NUMS FILE: " & path)
Let n = GetNum(f)
Let xs = Map(Range(1, n), Fn(k) => GetNum(f))
BClose(f)
xs
A magic number is a fixed value planted at the start of a file to say what the file is. Ours is C0FFEE in hexadecimal — base-16, where the digits run 0–9 then A–F, which is how a number gets to spell a word. The magic number is the handshake: every honest nums-file starts with those 8 bytes. So LoadNums fed somebody’s holiday photos stops with a clear error instead of reading garbage. This is no toy pattern. It is exactly how neural.shoddy saves a trained network. NetSaveBin writes a magic number, the three layer sizes, then every weight as raw 8-byte doubles — the same 8 bytes PutNum moves. So NetLoadBin needs nothing but the path, and the round trip is bit-exact. (It even re-checks the file’s byte size against the header before trusting a weight.) The demographics mill ships its trained model in precisely that format — train on Tuesday, predict on Friday. That’s serialization.
There is a lesson in what that file has to contain. Weights alone are not a predictor. Whoever trained the network also decided how to scale the inputs. A program that rebuilds that decision by hand keeps a second copy of it, in another file — and nothing will notice if the two ever stop agreeing. So ModelSave writes the weights, and the scaling, and which kind of answer the network gives, behind its own magic number. ModelPredict then takes raw measurements and applies the scaling the file remembers, as the iris mill does. When you design a format, ask what the reader would otherwise have to know by other means. Put that in the file.
The complete binary-file word list, sizes and all, is one table in quickref.html.
Reading errors. Every runtime error names its line.
unknown word: x — you used a name that isn’t defined (typo, or you forgot an Include, or you used a name before its Def… actually order only matters for Type and Include — DEFs can call later DEFs).If expects a Boolean, got Number (booleans are not numbers...) — you wrote something like If n Then. Spell it out: If n <> 0 Then.stack underflow — mostly a machine-dialect problem: some word wanted more values than the pile held. In surface syntax, suspect a function called with too few arguments.duplicate binding / duplicate definition — remember, one Let per name; and two files can’t both define the same word.Things that surprise people.
f(...) can’t be split mid-parenthesis).Type declarations and Include lines must appear before their first use — habit: includes at the top, types next, then DEFs.Maximum({ }) and First({ }) are errors — empty collections have no first or biggest anything. Check IsEmpty first; And/Or stop early precisely so Not IsEmpty(xs) And First(xs) > 0 is safe.Map(xs, Square) — no parentheses on Square.For BASIC veterans specifically.
a$, no n%. Types live in As declarations; string functions lost their $ (Str, Mid, Left); boolean-returning words use Is... (IsEmpty), as in VB.goto, no gosub, no line numbers, no Dim x(10)-style mutable arrays (Dim(n, v) builds an immutable one). No for/next — that’s chapter 4.Input returns a String, always; convert with Val.Print takes exactly one value — build the line with &.Mod, ^ (with its classic precedence: -n ^ 2 is -(n^2)), Instr, Chr/Asc, Sin Cos Tan Atn Exp Log, Rnd, and Select Case — including Is, To ranges, and Case Else, just now as an expression.One screen of the things you’ll reach for first. The complete word-by-word reference — every builtin, every form — is quickref.html.
Structure Def Name(p As t, ...) As t · body = indented lines · last value = result · Def Main() runs first · Type for records · Include "F.SHODDY" · Rem or ' for comments
Bindings Let x = expr (once per name, immutable)
Operators (loose → tight): Or · And · = <> < > <= >= · + - & · * / Mod · ^ — Not and - prefix; And/Or stop early
Conditionals If cond Then / Else (indented branches; it’s an expression) · inline: Ifte(c, t, f) · Select Case expr with Case v / Case v1, v2 / Case a To b / Case Is >= v / Case Type(n1, n2) Where cond (destructure a record) / Case Else
Collections { 1, 2, 3 } list · ToArray / ToList / Dim(n, v) · First Rest Prepend IsEmpty (lists) · Nth SetNth Length (both, 1-based)
The big four Map(xs, f) · Filter(xs, test) · Fold(xs, start, f) · Each(xs, f) — pass functions as Name, a section >=(100), or Fn(x) => ...; Range(a, b) makes 1..N lists
Records Type + fields · t(v1, v2) or t(f1 = v1, ...) · field name = accessor function · With(r, f = v) copies-with-change
Variants Type Shape = Circle(r) | Rect(w, h) · build with Circle(2) · take apart with Case Circle(r)
Predeclared Some(Value) / None for absence, Ok(Value) / Err(Why, At) for failure with a reason — no Include, no accessor words, Select Case is the way in
Strings & Len Str Val Left Right Mid Instr InstrFrom Chr Asc Codes FromCodes CodeAt Upper Lower · escapes \n \t \" \\ · more (Split, Join, Trim…) in str.shoddy
Math Abs Min Max Sqr Floor Ceil Round Sin Cos Tan Atn Exp Log Pi Rnd
I/O & checks Print(v) · Input(prompt) → String · InputLine(prompt) → [atEof, line] · InKey() non-blocking key · Args() → the command line · text: ReadFile TryReadFile WriteFile TryWriteFile AppendFile FileExists DeleteFile TryDeleteFile (lines: file.shoddy) · binary: BOpen TryBOpen BClose Seek BPos BSize · PutNum/GetNum PutBool/GetBool PutStr/GetStr (records: recio.shoddy) · sockets: TCPConnect … TCPClose (gated behind --allow-net, with NetAllowed and TryTcpConnect the guarded pair that is not; friendly layer: net.shoddy) · time: Ticks Sleep Clock · Error(MSG) · Assert(COND, MSG) — chapter 10 walks through the files; the full table is in QUICKREF
The machines seq · str · regex · stats · math · random · neural · money · dict · json · xml · html · csv · shaker · matrix · file · recio · isam · simplex · mps · clock · net · vt100 · keys · scribbler · turtle · plotter · buzzer — one Include each; every one has a page in the machines catalog
Machine dialect Def Name ( in -- out ) · [ code ] quotes · Dup Drop Swap Over Rot shuffle · Take a, b names stack values · adjacency = pipeline