tiko is a compiled, statically typed programming language for building fast, self-contained programs. This book teaches it to programmers who already know another language.
Every program printed here was compiled and run — on Windows and on Linux — by the exact compiler named in the preface, before the text around it was written.
The text of this book is licensed CC BY-NC-SA 4.0. The example code is MIT, so you can use it in your own programs, including at work.
Preface
Who this book is for
For programmers who already know a language and want tiko. It does not assume you know BASIC, and it does not assume you have met FreeBASIC. Where tiko differs from FreeBASIC in a way that would surprise someone who knows it, a sidebar says so.
How this book is organized
Conventions
The version this book describes
Every program in this book was compiled and run, on Windows and on Linux, with one pinned version of the compiler. The pin, and the exact toolchains, are recorded in the book's repository.
The reference: the tiko wiki
This book teaches the language; it does not list every keyword, option and library function. That is the job of the tiko wiki, github.com/PaulSquires/tiko-lang/wiki, the language's reference documentation. Keep it open beside the book. The pages you will use most:
- Feature Index — every language feature and library, one line each, with a link to its reference page.
- Function Finder — every function, grouped by what you want to do, with one line on what each one does.
- Keyword Index — every keyword, statement and operator, A to Z, each with its full definition.
- Compatibility — for code brought from FreeBASIC: what differs, and what is the same.
The wiki also carries tutorials (concurrency, packages and modules, translating C headers) and a page for each part of the toolchain. Where a chapter shows one form of a statement or a few members of a library, the wiki has the rest.
The example programs
Getting Started
tiko is a compiled, statically typed language for applications and systems programming. It produces native executables with no garbage collector, no virtual machine and no runtime for your users to install: a finished program is one file you can copy to a machine and run.
Under the hood the compiler translates your program to LLVM IR and calls clang to assemble and link it. That matters for exactly one reason, which this chapter gets out of the way first: clang has to be on the machine, and tiko is particular about where it looks.
This chapter installs the compiler, builds three small programs, reads a diagnostic, and turns on the runtime checks you should leave on while you work through the book.
Installing tiko
A tiko installation is a directory. Unpack it wherever you like; nothing is written to the registry, no environment variable has to be set, and nothing else on the machine is touched. Moving the directory later is fine — the compiler finds its own headers and libraries relative to the executable.
The compiler is tiko.exe at the root. Beside it, inc\ holds the headers and
lib\windows-x86_64 the libraries.
The compiler is bin/tiko, with headers under inc/ and libraries in lib/macos-arm64. It runs
on Apple Silicon Macs with macOS 13 or newer.
A browser marks what it downloads, and macOS then refuses the unsigned compiler: it hangs instead of running. Clear the mark once, right after unpacking:
xattr -dr com.apple.quarantine tiko-macos
Check that it runs:
tiko --version
It prints the compiler's version and the target it builds for by default. Keep that version handy: this book was written against one specific release, and the exact one is named in the preface.
The one thing you must install: clang
tiko contains no assembler and no linker. It emits LLVM IR, then runs clang to turn that into
an object file and lld to link it. You need LLVM 18 or newer installed, and it is not
bundled.
On Windows, install the LLVM package; on Linux, your distribution's clang package; on macOS,
Homebrew's (brew install llvm), together with Xcode's Command Line Tools
(xcode-select --install), which hold the SDK and the linker every macOS link uses. Then check:
clang --version
If clang is installed in the usual place, there is nothing to configure. tiko looks for it in four places, and takes the first that exists:
| Where | Why it comes first | |
|---|---|---|
| 1 | The path you pass on the command line | Someone who names a compiler has a reason, and it is never second-guessed |
| 2 | The TIKO_CLANG environment variable | Settles it for a whole shell or a CI job |
| 3 | The conventional install: C:\Program Files\LLVM\bin\clang.exe, /usr/bin/clang, or Homebrew's /opt/homebrew/opt/llvm/bin/clang on macOS | See the pitfall below |
| 4 | PATH | Catches a portable LLVM, a scoop install, or anything under /usr/local |
So a clang on PATH is found — but only when there is no clang at the conventional location.
The conventional install outranks PATH deliberately, and it is worth knowing which clang you
are actually getting. On Windows the clang on PATH is as often a stale copy belonging to some
other toolchain as it is the one you meant, and a backend that quietly used a different clang from
the one its data layout was measured against produces the kind of failure that takes days to find.
If you have several, name the one you want rather than reordering PATH:
export TIKO_CLANG=/home/you/.local/bin/clang
llvm-ar, llvm-rc and libclang are then taken from that same directory, never searched for
separately — so one setting settles all of them. When no clang is found at all you get diagnostic
TKO0111, "no usable toolchain", and the process exits with status 3.
Hello, tiko
Source files are .tiko, and the headers you write yourself are .ti. Here is the whole program:
'' hello.tiko -- the smallest complete tiko program.
print "hello, tiko"
Output
hello, tiko
Compile and run it:
tiko hello.tiko -o hello
./hello
-o names the output. Without it, the executable takes the source file's name. Two other options
are worth knowing on day one: -c compiles to an object file without linking, and -v echoes
every clang and linker command tiko runs, which is the quickest way to see what a build actually
did.
When a build finishes, the exit status tells you what happened, which matters as soon as you put tiko in a script:
| Status | Meaning |
|---|---|
| 0 | success |
| 1 | errors in your program |
| 2 | a bad command line |
| 3 | no usable toolchain — usually clang |
| 4 | an internal compiler error |
| 5 | clang or the linker failed |
Status 4 is a bug in the compiler. It is worth reporting, with the source that caused it.
Reading the command line
Programs get their arguments through command, which returns one argument by position, counting
from 1:
'' greet.tko -- a program that reads its command line.
dim as ustring who = command( 1 )
if len( who ) = 0 then who = "world"
print "hello, " & who
./greet tiko
hello, tiko
Two things in those four lines are worth naming now, because every later chapter assumes them.
ustring is the string type. It holds UTF-16 text, its length is O(1), and it behaves the
same on every platform. Chapter 9 covers text properly, including the older string type that
holds bytes and the two cases where you still want it.
Types say their width. int32, int64, float64 and their siblings mean what they say on
every target — no guessing whether int is 32 or 64 bits today. Chapter 4 lists all ten, plus
integer, the pointer-sized type that deliberately has no fixed-width name.
Choosing a target
tiko builds for two targets, and the default is the machine you are on.
| Target | What it is | Default on |
|---|---|---|
windows-x86_64 | Microsoft's ABI, linked against ucrtbase.dll | Windows |
linux-x86_64 | The System V ABI, linked against the system's C library | Linux |
Name one explicitly with -target:
tiko hello.tiko -target windows-x86_64 -o hello
Targets are not interchangeable at link time: an object file or static library built for one will not link into a program for the other. Chapter 31 covers what each target links and how to ship the result.
Reading a diagnostic
Every message tiko prints carries a stable identifier: TKO followed by four digits. The number
never changes meaning and is never reused, so it is worth searching for, and worth quoting in a
bug report. Appendix B lists them all.
This program has a type error:
'' oops.tko -- deliberately broken, to show a diagnostic.
dim as int32 count
count = "seven"
Compiling it says so:
oops.tiko(3,1): error TKO0702: a string and a number do not convert into each other
|
3 | count = "seven"
| ^
The parts are the file, the line and column, the severity, the identifier, the message, and then the offending line with a caret under the position. Five options shape this output:
| Option | Effect |
|---|---|
--diagnostics-format gnu | file:line:col: error: message [TKO0702], the shape most editors and CI tools expect |
--color always / never | Force colour on or off, rather than deciding by whether output is a terminal |
-Wno-TKO0830 | Silence one warning, by identifier |
-Werror | Treat every warning as an error |
-fmax-errors 1 | Stop after the first error, instead of listing everything |
Turn the checks on now
By default, a tiko program does no bounds checking. Reading past the end of an array is undefined: usually it quietly returns nonsense, occasionally it corrupts something, and it always wastes an afternoon.
-exx turns on array-bounds and null-pointer checking, and the cost is irrelevant while you are
learning. Use it for everything in this book.
A program can carry its own build options, which is how the listings in this book stay
self-contained — the first source file may hold a #cmdline line, and the compiler restarts
itself with those options applied:
'' bounds.tko -- what a checked build catches.
#cmdline "-exx"
dim as int32 a( 1 to 3 )
dim as int32 i = 5
a( i ) = 42 '' index 5 is outside 1..3
print "not reached"
Running it stops at the bad index rather than carrying on with a corrupted array:
Aborting due to runtime error 6 (out of bounds array access) at line 7 of bounds.tiko::(),
'A' accessed with invalid index = 5, must be between 1 and 3
The process exits with status 6. Without -exx the same program writes 42 somewhere it should
not and prints not reached, which is the outcome the check exists to prevent.
The check covers every array, including the ones that grow while the program runs, which Chapter 8
introduces. The null-pointer check is partial: it catches *p, but not yet p->field or p[i],
which Chapter 11 covers.
There are three levels, and they stack: -e enables error checking, -ex adds to it, and -exx
adds array bounds and null-pointer checks on top. Chapter 18 comes back to them alongside the
error-handling model.
An editor
None of this book requires a particular editor; every example is a file and a command line. But two tools come with tiko, and both are worth the five minutes:
- tkEdit is the editor in the
tiko-langfolder. It builds and runs your program with one key, lists the compiler's errors and takes you to each one, and shows a procedure's parameters as you type its call. - tiko-lsp is tiko's language server. It gives the editor you already use — VS Code, Zed, Neovim, Sublime Text and others — errors as you type, completion, go to definition, rename and formatting.
Chapter 2 sets up both.
Looking things up
The reference for everything this book does not spell out — every keyword's full syntax, every compiler option, every function in the standard library — is the tiko wiki, at github.com/PaulSquires/tiko-lang/wiki. Two pages answer most questions: the Feature Index, one line per language feature and library, and the Function Finder, every function grouped by the job it does. The wiki's Quick Start covers building a toolchain from source, which this chapter does not.
Summary
- A tiko installation is a directory you unpack anywhere; the compiler locates its own headers and libraries.
- LLVM 18+ must be installed. tiko finds clang in four places, in order: an explicit path,
TIKO_CLANG, the conventional install location, thenPATH. - Source files are
.tiko; headers you write are.ti. tiko hello.tiko -o hellocompiles and links in one step;-cstops at the object file and-vshows every command.- Two targets:
windows-x86_64(the Windows default, and no Visual Studio needed) andlinux-x86_64(the Linux default). Objects from different targets do not link together. - Diagnostics carry stable
TKOnnnnidentifiers;--diagnostics-format gnusuits tools, and-Werrorsuits discipline. - Build with
-exxwhile you learn, and let a listing carry its own options with#cmdline. ustringis the string type, and numeric types say their width:int32,int64,float64.- The reference for every keyword, option and function is the tiko wiki; start from its Feature Index or Function Finder.
Exercises
- Write a program that reports which compiler built it and which version that compiler is.
__TIKO_COMPILER__is defined by tiko and by nothing else, and__TIKO_VERSION__holds the version as a string. Test the first with#ifdef. {solution="ex01-01"} - Make a program greet you by name and then exit with status 3, and check the status from your
shell (
echo $?, orecho %ERRORLEVEL%incmd). {solution="ex01-02"} - Call a procedure that was never declared, and note the identifier of the diagnostic. Then look it up in Appendix B. {solution="ex01-03"}
- Compile
bounds.tikoagain with the#cmdlineline deleted, and run it. What does it print, and what is its exit status? That is the difference-exxmakes.
Editors: tkEdit and the Language Server
The compiler tells you about a mistake when you build. That is a round trip: save, switch to a
terminal, run tiko, read a line number, go back and find the line. For a missing end if the
trip costs more than the fix.
An editor that understands tiko shortens it. It underlines the missing end if while you are
still on the line, shows a procedure's parameters while you type its call, takes you to a
declaration with one key, and builds and runs the program with another.
tiko comes with two tools for this. tkEdit is an editor of its own, in the same folder as the compiler. tiko-lsp, the tiko language server, gives the editor you already use — VS Code, Zed, Neovim, Sublime Text and others — the same understanding. This chapter sets up both. Neither is required by anything else in this book: every example is a file and a command line.
What the editor knows, and what only the compiler knows
Both tools read your code as you type, and both do it without compiling it. They find the
declarations — which names a file declares and where, what type a variable was declared with,
which members that type has, what an #include or an #import brings in — in well under the
time between two keystrokes: the language server reads a 900-line file in about a millisecond.
That is enough for completion, codetips and going to a declaration. The language server also
checks the code's shape — a function with no end function, a string with no closing
quote — and underlines a mistake in it as you type.
Neither knows what only compiling tells you: whether a name used in an expression was ever declared, whether a string is being assigned to a number, whether a call has the right number of arguments. Those errors come from the compiler, when you build. Both tools then show the compiler's errors in the source and in a list, beside their own, so the difference is easy to forget. The Pitfall at the end of the chapter shows what that costs.
This program is the one the chapter edits:
'' The program Chapter 2 edits: a type, a procedure that
'' uses it, and two built-ins.
type Rect
w as int32
h as int32
end type
function Area( byref r as Rect ) as int32
return r.w * r.h
end function
dim as Rect box
box.w = 3
box.h = 4
dim as ustring label = "box"
print ucase( left( label, 1 ) ) & ": " & Area( box )
B: 12
tkEdit
tkEdit is a small, keyboard-driven editor for tiko, on Windows, Linux and macOS. It comes with the toolchain, so there is nothing else to install:
| System | tkEdit |
|---|---|
| Windows | tiko-lang\tkedit.exe |
| Linux | tiko-lang/tkedit |
| macOS | tiko-lang/tkEdit.app |
Start it with the files to edit, or with none to restore the last session:
tkedit rect.tiko
The editor fills the window, with a tab for each open file above it and no toolbar. The menu bar
appears when you press Ctrl+Shift+M and goes again when you pick a command. On a Mac, every
Ctrl chord in this section is Cmd.
tkEdit finds the compiler beside itself, in the same tiko-lang folder, or on your PATH; if it
cannot, it asks for it once, when it starts.
Building and running
| Key | What it does |
|---|---|
F5 | Build, then run |
Ctrl+F5 | Build only |
Shift+F5 | Run the last build |
Ctrl+Alt+F5 | Rebuild everything |
Ctrl+Shift+F5 | Set the arguments the program is run with |
F7 | Choose a build configuration: Debug (-g -exx, the default) or Release (-O2) |
Alt+PgDn / Alt+PgUp | Go to the next or previous error |
What F5 builds depends on what you have open. With a project — a folder with a tiko.toml,
opened by opening that file — it builds the project's target, as tiko build would. Without
one, it builds the file you are editing. Modified source files are saved first.
When the build fails, the compiler's errors open in the output pane at the bottom of the window,
each with its TKOnnnn identifier, and are underlined in the source; Alt+PgDn takes you from
one to the next. A console program runs in a terminal of its own, which waits for a key when the
program ends, so its output stays on the screen.
Debugging
tkEdit can stop your program on a line and show you its variables. It does this through
lldb-dap, the LLVM debugger that editors talk to, reading the DWARF debug information that
-g writes — the same information Chapter 19 reads with lldb from a terminal.
| Key | What it does |
|---|---|
F9 | Set or remove a breakpoint on the cursor's line |
F6 | Build for debugging and start; in a session, continue to the next stop |
F10 | Step over: the next line, running any call on this one to its end |
F11 | Step into: the next line, inside a procedure this one calls |
Shift+F11 | Step out: run until the current procedure returns |
Ctrl+F10 | Run to the cursor's line |
Ctrl+F6 | Break: stop the running program where it is |
Shift+F6 | Stop debugging; the program ends |
Ctrl+Shift+F9 | Remove every breakpoint |
The same commands are on the Debug menu, after Build.
A breakpoint is a red circle in the margin. F9 sets it; a click in the margin sets a bookmark
instead, unless the setting margin_click = "breakpoint" is in the [editor] table of
settings.toml. Breakpoints are kept with the session, so they are still there tomorrow.
F6 builds with your configuration's options, without its -O and with -g -exx added, because
an optimised program has lost the variables you want to look at. Then it runs the program in its
terminal, as F5 does, and stops on the first breakpoint it reaches — or, with no breakpoint set,
on the program's first statement. A yellow arrow in the margin marks the line about to run, and the
output pane opens on its Debug tab:
- The buttons along the top are the commands in the table: Continue, Break, Stop, Step, Step
Over, Step Out, Run to Cursor. The status line at the bottom of the window says why the
program stopped and where —
Stopped - breakpoint rect.tiko:10 in Area. - Locals, Globals and Watch share the left side, with their tabs along its bottom.
Each variable has a row: its name, its value and its type. A record opens to its fields and an
array to its elements, named the way the program names them —
rs(1),rs(2),rs(3)for an array declaredrs( 1 to 3 ). A large array opens in groups of a hundred elements first. A value that changed since the last stop is shown in red, and what you opened stays open as you step. A click on a column heading sorts by it. - Call Stack and Breakpoints share the right side. A click on a frame of the call stack shows that procedure's locals and goes to its line. The Breakpoints list has every breakpoint in every open file, with the text of its line; a double click goes to it. Of the two buttons at the end of its tab row, the first removes the selected breakpoint and the second removes them all.
On the Watch tab, the + button adds a row; type a name to watch, written as in the program —
r.w, rs(2).sensor, in any case — and press Enter. Its value is read again at every stop.
A double click on the row edits it, and emptying it removes it.
A watch is a variable, a field or an element, not a calculation: r.w * 2 shows
<not in scope>, as does a name the stopped procedure cannot see.
While the program is stopped, rest the mouse on a name in the source and its value appears beside it.
tkEdit looks for lldb-dap on your PATH, then where LLVM installs it. If it cannot find one, it
says so in the status line; set its path as adapter in the [debug] table of settings.toml.
-
Windows: lldb-dap is in LLVM's
binfolder, beside clang. It needs Python 3.10 (64-bit) on thePATH, because it loadspython310.dll; tkEdit checks for it before it starts. -
Linux: install your distribution's
lldbpackage, which provideslldb-dapor a numberedlldb-dap-NN; tkEdit takes the newest. -
macOS: lldb-dap comes with Apple's Command Line Tools. macOS lets one program control another only with Developer Mode on, or when you allow it in the dialog it shows. Turn it on once with
sudo DevToolsSecurity -enableWithout it, the status line says so, and no session starts.
Finding your way
| Key | What it does |
|---|---|
F12 | Go to the declaration of the name under the cursor |
Alt+Left / Alt+Right | Go back, or forward again |
Ctrl+P | Search every file and declaration of the project by name |
Ctrl+Shift+P | List the procedures of this file, or of every open file |
Ctrl+Shift+F | Search the text of every project file |
Ctrl+E | List the open files; Ctrl+Tab switches to the last one |
Ctrl+\ | Split the window; Ctrl+Tilde moves between the panes |
Codetips and completion
Type Area( in rect.tiko and tkEdit shows the procedure's parameter list, read from its
declaration. Type left( and it shows Left(string, n): the built-in procedures have codetips
too, square brackets marking the arguments you may leave out. Type box. and the members of
Rect are offered; type three letters of a name and the names that start with them are offered,
and after as, the types. Ctrl+Space completes a word at any time. The setting codetips
turns all of this off.
Settings
tkEdit is configured with three text files, and saving one applies it:
| Key | File |
|---|---|
Ctrl+Comma | settings.toml — fonts, tabs, the build, the language of the menus |
Ctrl+K | keybindings.toml — every key in this section can be moved |
Ctrl+Shift+T | the theme, light or dark |
The language server
tiko-lsp speaks the Language Server Protocol, which most editors understand. The editor starts it in the background and asks it questions — what is under the cursor, what could be typed here, where is this declared — and it answers in terms of tiko. One server, so every editor gets the same answers.
| Feature | What it does |
|---|---|
| Errors as you type | Underlines a mistake in the code's shape, such as an if with no end if |
| Completion | Variables, procedures and types in scope; fields after .; a module's exports after strings. |
| Hover | The declaration of the name under the mouse |
| Signature help | The parameter list while you type a call — built-ins included — with the current one marked |
| Go to definition | Across files, into #included headers and #imported modules |
| Find references, rename | Every use in the project; a rename it cannot do safely is refused, with the reason |
| Formatting | Re-indents a file; it never touches a file that has errors |
| Outline, workspace symbols | The procedures and types of a file, or of the whole project, by name |
VS Code
- In VS Code, open the Extensions view (
Ctrl+Shift+X), search for tiko and install Tiko. VSCodium, Cursor and the other editors built on VS Code install it the same way, from their own Extensions view. - Open the folder your program is in, with File > Open Folder. The server reads the whole folder, so opening a single file gives you less.
- Open
rect.tiko. HoverArea; pressF12on it; typebox.on a new line and seewandhoffered.
The extension carries its own copy of the server, for Windows, Linux and macOS on Apple silicon.
Building and running. Press Ctrl+F5, or click the ▶ button at the top right of the editor,
to run the program: the extension runs tiko run in VS Code's terminal, so the program can read
input. Tiko: Build builds without running; Ctrl+Shift+B offers the same build. As in tkEdit,
a tiko.toml at or above the file is built as a project, and otherwise the file itself is. The
compiler's errors go to the Problems view (Ctrl+Shift+M); click one to go to it.
Finding the toolchain. For the standard library and the system headers — #import "std/strings", #include "windows.bi" — the server needs the toolchain. It finds it by itself
if tiko is on your PATH, or if the tiko-lang folder is in your home folder or your
Downloads folder. Otherwise VS Code says so, once, with a Locate Toolchain… button: choose
the tiko-lang folder and the extension saves it as the setting tiko.toolchainPath. The
command Tiko: Show Language Server Output names the toolchain in use.
Files. .tiko, .ti and .tko files are tiko. The .bas, .bi and .inc extensions of
older tiko code are also used by other languages, so the extension leaves them alone until you
claim them, in the folder's .vscode/settings.json:
"files.associations": { "*.bas": "tiko", "*.bi": "tiko", "*.inc": "tiko" }
Other editors
Zed, Neovim, Sublime Text, Helix and any other editor that speaks the protocol run the same server, and every tiko release carries it beside the compiler:
| System | The server |
|---|---|
| Windows | tiko-lang\tiko-lsp.exe |
| Linux, macOS | tiko-lang/bin/tiko-lsp |
So if tiko is on your PATH, tiko-lsp is too. The tiko packages for Zed, Neovim and Sublime
Text find it there, or in the tiko-lang folder you name in their settings; the server then
takes the headers and packages of that same toolchain. Their instructions are in the
editors folder of the tiko-lsp repository. Any
other editor needs three things: the command tiko-lsp --stdio, the extensions .tiko, .ti
and .tko mapped to a language called tiko, and — if tiko is not on your PATH — the
toolchain folder as the option toolchainPath. For Helix, in languages.toml:
[language-server.tiko-lsp]
command = "tiko-lsp"
args = ["--stdio"]
config = { toolchainPath = "/home/you/tiko-lang" }
[[language]]
name = "tiko"
scope = "source.tiko"
file-types = ["tiko", "ti", "tko"]
roots = ["tiko.toml", ".git"]
comment-token = "'"
language-servers = ["tiko-lsp"]
Updating
The server in your other editors is the one in the toolchain, so installing a newer tiko updates it; restart the server, or the editor, afterwards. VS Code updates its extension, and the server inside it, by itself.
When something is wrong
- No colour, no completion. The editor has not recognised the file as tiko. In VS Code, the language mode at the bottom right should say Tiko. If VS Code shows Restricted Mode, the folder is not trusted, and the extension does not start a program in an untrusted folder.
std/andwindows.binames do not complete. The server has not found the toolchain. Name thetiko-langfolder (in VS Code, with Tiko: Locate Toolchain…) and restart the server.- An underline on code that compiles. That is a defect in the server. Report it at the tiko-lsp repository with the smallest program that shows it.
- You want to see what the server is doing. Start it with
--log-level debug --log-fileand a path; it writes nothing else to disk.tiko-lsp --versionnames the version you have.
A clean editor is not a clean build
This file is complete. Every block is closed, every statement ends where it should, and neither tkEdit nor the language server underlines anything in it:
'' Every block is closed and every statement is complete,
'' so the editor underlines nothing. The compiler still
'' refuses it: `totl` was never declared.
dim as int32 total = 10
print totl
It does not compile:
typo.tiko(6,7): error TKO0743: 'totl' is not declared
|
6 | print totl
| ^
The editor reads the code's shape; whether totl names anything is a question for the compiler.
A file with no underline has not been checked the way a build checks it. Build before you
believe it — F5 in tkEdit, Ctrl+F5 in VS Code — and read the errors that come back.
What it costs
- The first look at a large header takes seconds.
windows.biand what it includes declare about 190,000 names. The server reads them once, in the background, when a project uses them; after that, completion costs tens of milliseconds a keystroke instead of a few. - The reader does not run the preprocessor. Both branches of an
#ifdefare read, so completion offersMessageBoxAandMessageBoxWalike, and a name made by#defineis not offered at all. - Rename works by name. It follows what each file can see, and refuses where a local variable shares the name it would change, but two unrelated declarations with one name in files that see each other are one name to it.
- Codetips for the built-ins are a fixed list, shipped with the tools; a procedure you declare is described by its own declaration instead, and wins over a built-in of the same name.
Summary
- tkEdit and tiko-lsp read your code as you type, without compiling it. They catch mistakes in its shape; the compiler catches the rest, when you build.
- tkEdit comes with tiko (
tiko-lang/tkedit).F5builds and runs — atiko.tomlproject, or else the current file — andAlt+PgDnwalks the compiler's errors. F12goes to a declaration,Ctrl+Pfinds any name, codetips show parameters as you type, and three TOML files hold every setting.- tiko-lsp gives any editor that speaks the Language Server Protocol completion, hover, signature help, go to definition, references, rename, formatting and errors as you type.
- In VS Code, install the Tiko extension: it carries the server, runs your program with
Ctrl+F5, and puts the compiler's errors in Problems. - Every tiko release carries
tiko-lspbeside the compiler, for every other editor; a newer tiko updates it. F9sets a breakpoint andF6runs to it;F10,F11andShift+F11step. The Debug tab shows locals, globals and watches beside the call stack and the breakpoints. It needs lldb-dap, and on macOS Developer Mode.- No underline is not a successful build. Build, and read what the compiler says.
Exercises
- Open
rect.tikoin tkEdit and run it withF5. Delete the lineend function, build again, and useAlt+PgDnto reach the error. Which line does it point at, and why that one? - In tkEdit, put the cursor on
Areain the last line, go to its declaration withF12, and come back withAlt+Left. Then, in an editor with the language server, renameRecttoBox2(in VS Code,F2), and check that the declaration and thedimchanged together. - In an editor with the language server, start typing
print mid(and watch the parameter list. Type an argument and a comma at a time: which parameter is marked after each comma, and what happens after the third? - Copy
typo.tikointo your editor. Find out, without building, which tool would tell you abouttotl, then build and compare the identifier you get with the one in this chapter. - In tkEdit, set a breakpoint on line 10 of
rect.tiko(return r.w * r.h) and pressF6. Openron the Locals tab. Then select the second row of the Call Stack: what does Locals show now, and where is the editor's cursor? Step out withShift+F11and watch the arrow.
A Tour of tiko
The last chapter got a compiler working. This one writes something with it — a program that reads a file, counts the words in it, and reports the three most common. It is small enough to hold in your head and big enough to need most of what the language is made of: the standard library, strings, a hash map, iteration, sorting, a lambda, and errors that are values rather than surprises.
Nothing here is explained completely. The point is to see the shape of a tiko program before taking it apart, and every idea below is picked up properly in a later chapter. Read it the way you would read someone else's code: get the sense of it, and let the details wait.
The file we are counting is three lines:
the quick brown fox
jumps over the lazy dog
the dog sleeps
Reaching the standard library
tiko's library comes in modules, and a program says which ones it wants:
'' step1.tiko -- read the file, and say so if it is not there.
#import "std/fs"
var loaded = fs.ReadAllText( "words.txt" )
if loaded.IsOk( ) = false then
print "cannot read words.txt"
end 1
end if
var text = loaded.Value( )
print "characters: " & len( text )
characters: 59
#import "std/fs" makes the filesystem module available under the name fs, and every name it
provides is reached through that prefix — fs.ReadAllText. That prefix is not decoration; it is
how a reader of your code knows where a function came from.
The interesting part is what ReadAllText gives back. Not a string: a Result, which is
either the text or the reason there is no text. There is no exception to catch and no error code
to remember to check, because the value itself will not hand over its contents until you have
asked whether it has any:
loaded.IsOk( )— did it work?loaded.Value( )— the text, once you know it worked.
A program that forgets to check does not silently read nonsense; it fails to compile in the shape
you would have wanted. Chapter 18 is about designing with Result.
var declares a variable and takes its type from the value, the way dim as ustring text = …
would if you wrote the type out. Use var when the type is obvious from the line, and the full
form when it is not.
Splitting the text into words
'' step2.tiko -- split the text into words.
#import "std/fs"
#import "std/strings"
var loaded = fs.ReadAllText( "words.txt" )
if loaded.IsOk( ) = false then
print "cannot read words.txt"
end 1
end if
var flat = strings.Replace( loaded.Value( ), !"\n", " " )
var words = strings.Split( flat, " " )
print "words: " & words.Count
for each w in words
if len( w ) > 0 then print " [" & w & "]"
next
words: 13
[the]
[quick]
[brown]
[fox]
[jumps]
[over]
[the]
[lazy]
[dog]
[the]
[dog]
[sleeps]
Two library calls do the work. strings.Replace flattens the newlines into spaces, and
strings.Split cuts the result on spaces and hands back an array. words.Count is how many it
found.
for each w in words walks the array without an index, without a bound, and without the
off-by-one that an index loop invites. The loop variable's type comes from the array, so w is a
ustring — tiko's string type, which holds text rather than bytes and is the same on every
platform.
The if len( w ) > 0 is there because splitting on a single space leaves empty pieces wherever
two spaces met. Real text is untidy; the program says so rather than pretending.
Counting
'' step3.tiko -- count each word.
#import "std/containers"
#import "std/fs"
#import "std/strings"
var loaded = fs.ReadAllText( "words.txt" )
if loaded.IsOk( ) = false then
print "cannot read words.txt"
end 1
end if
var flat = strings.Replace( loaded.Value( ), !"\n", " " )
var words = strings.Split( flat, " " )
dim counts as containers.Map( of ustring, int32 )
for each w in words
if len( w ) > 0 then counts[ LCase( w ) ] += 1
next
print "distinct words: " & counts.Count
print "the: " & counts[ "the" ]
print "dog: " & counts[ "dog" ]
distinct words: 9
the: 3
dog: 2
containers.Map( of ustring, int32 ) is a hash map from text to a number. The ( of … ) is
tiko's generics syntax: this is not a map of "anything", it is a map whose keys are ustring and
whose values are int32, checked as thoroughly as if you had written the type by hand. Chapter 17
is about writing your own.
counts[ LCase( w ) ] += 1 does the whole count. Indexing a map with a key that is not there
inserts it with a zero value, so the += 1 works the first time a word appears as well as the
fifth. LCase folds the case, so The and the are one word.
Ranking, with a lambda
A map has no order, so the last step copies the counts into an array and sorts it:
dim table as containers.Array( of Tally_ )
for each entry in counts
dim as Tally_ t = ( entry.Key, entry.Value )
table.Push( t )
next
containers.SortBy( table, _
function( byref a as Tally_, byref b as Tally_ ) as boolean
return a.count > b.count
end function )
for each entry in counts walks the map, and each entry carries a Key and a Value.
containers.SortBy takes the array and a lambda — a function written where it is used, with
no name. This one answers a single question: does a come before b? Sorting by descending count
is a.count > b.count, and changing the ranking means changing that one line. Chapter 20 is about
lambdas, including the ones that capture variables from around them.
The _ at the end of the SortBy line continues the statement onto the next line. tiko has no
statement terminator, so a line break ends a statement unless you say otherwise.
Reporting
print "the three most common words"
for i as int32 = 0 to 2
print using " \ \ #"; table[ i ].word; table[ i ].count
next
print using formats output against a picture: \ \ is a text field of that width, and # is
a digit. It is the one piece of this program that will look unfamiliar to most readers, and it is
worth the surprise — lining up columns with string concatenation is miserable.
The whole program
#import "std/containers"
#import "std/fs"
#import "std/strings"
type Tally_
as ustring word
as int32 count
end type
var loaded = fs.ReadAllText( "words.txt" )
if loaded.IsOk( ) = false then
print "cannot read words.txt"
end 1
end if
var flat = strings.Replace( loaded.Value( ), !"\n", " " )
var words = strings.Split( flat, " " )
dim counts as containers.Map( of ustring, int32 )
for each w in words
if len( w ) > 0 then counts[ LCase( w ) ] += 1
next
dim table as containers.Array( of Tally_ )
for each entry in counts
dim as Tally_ t = ( entry.Key, entry.Value )
table.Push( t )
next
containers.SortBy( table, _
function( byref a as Tally_, byref b as Tally_ ) as boolean
return a.count > b.count
end function )
print "the three most common words"
for i as int32 = 0 to 2
print using " \ \ #"; table[ i ].word; table[ i ].count
next
the three most common words
the 3
dog 2
quick 1
Forty lines, no memory management, no build file, and one command to build it:
tiko wordcount.tiko -o wordcount
./wordcount
The result is a native executable that depends on nothing you have to ship beside it.
read is not available as a variable name — it belongs to the data/read statement pair that
tiko keeps for compatibility. The compiler says TKO0600: 'read' is already declared in this scope, which is accurate but startling when you were not thinking about data at all.
The same applies to other short, tempting names: str, left, mid, window, log. When a
name is refused for no reason you can see, that is usually why — pick another.
What you have seen
Each of these has a chapter of its own:
| In this program | Chapter |
|---|---|
#import and the std modules | 14 |
Result, and errors as values | 17 |
ustring and the string library | 8 |
Map, Array, SortBy | 22 |
for each | 18 |
generics, the ( of … ) syntax | 16 |
| lambdas | 19 |
print using and console output | 23 |
Summary
- A program reaches the library with
#import "std/…", and uses the module's name as a prefix. - Fallible calls return a
Result: askIsOk( ), then takeValue( ). varinfers a variable's type;dim as Tstates it.for eachwalks an array without an index; the loop variable's type comes from the elements.Map( of K, V )is a checked hash map, and indexing a missing key inserts it.- A lambda is a function written where it is used —
SortBytakes one to decide the order. - One command compiles and links a native executable.
Exercises
- Report the longest word in the file as well as the most common ones. {solution="ex03-01"}
- List the words that appear exactly once, in alphabetical order.
OrderedMapkeeps its keys sorted, whereMapdoes not. {solution="ex03-02"} - Make the program take the file name from the command line, falling back to
words.txtwhen none is given. Chapter 1 showedcommand( 1 ). - The ranking prints three words. Make it print the top three counts, so that words tied for third all appear.
Types, Variables and Constants
Every value in a tiko program has a type, fixed when the program is compiled and never changing afterwards. That is what lets the compiler catch a whole class of mistake before you run anything, and it is why a tiko program runs at the speed it does: the machine code knows exactly what it is working with.
This chapter is the vocabulary the rest of the book assumes — how to write values, how to name them, and what happens when one type becomes another.
Writing the program
A few rules apply everywhere, and they are quickly stated.
Case does not matter to the compiler. Count, count and COUNT are one name. It matters
very much to readers, so the book follows one convention throughout: keywords lowercase, and types
and procedures in PascalCase.
Comments start with '' and run to the end of the line. A block comment is /' … '/, and
those nest.
One statement per line, with no terminator to forget. A line can be continued with a trailing
_, and two short statements can share a line with : between them — the book uses that only for
setup lines that read better together.
Writing values
'' literals.tiko -- how values are written.
print "hex &hFF = " & &hFF
print "octal &o755 = " & &o755
print "binary &b1011 = " & &b1011
print
print "a suffix types the literal: " & sizeof( 1LL ) & " bytes for 1LL"
print
'' A plain string is literal; !"..." reads escapes; $"..." takes none.
print !"escaped:\tone tab, then a newline\n"
print $"raw: \t stays two characters"
print
print "true and false are values: " & true & " " & false
hex &hFF = 255
octal &o755 = 493
binary &b1011 = 11
a suffix types the literal: 8 bytes for 1LL
escaped: one tab, then a newline
raw: \t stays two characters
true and false are values: true false
Numbers can be written in hexadecimal, octal or binary with &h, &o and &b. A suffix pins a
literal's type where it matters — 1LL is 64-bit.
Strings come in three spellings, and the difference is what a backslash means:
| Written | \t is |
|---|---|
"plain" | two characters, a backslash and a t |
!"escaped" | a tab |
$"raw" | two characters, explicitly |
true and false are ordinary values of type boolean.
Numbers that state their width
tiko's numeric types say how big they are:
'' widths.tiko -- each type states its size in bytes.
print "int8 " & sizeof( int8 ) & " uint8 " & sizeof( uint8 )
print "int16 " & sizeof( int16 ) & " uint16 " & sizeof( uint16 )
print "int32 " & sizeof( int32 ) & " uint32 " & sizeof( uint32 )
print "int64 " & sizeof( int64 ) & " uint64 " & sizeof( uint64 )
print "float32 " & sizeof( float32 ) & " float64 " & sizeof( float64 )
print
print "integer " & sizeof( integer ) & " -- pointer-sized"
print "long " & sizeof( long ) & " -- 32 bits, despite the name"
int8 1 uint8 1
int16 2 uint16 2
int32 4 uint32 4
int64 8 uint64 8
float32 4 float64 8
integer 8 -- pointer-sized
long 4 -- 32 bits, despite the name
int8, int16, int32, int64 and the uint forms cover the integers; float32 and float64
cover the rest. Use these. A reader never has to ask how wide int32 is, and the answer is
the same on every target.
Two types deliberately have no fixed-width name:
integeris pointer-sized — 8 bytes on the 64-bit targets. Reach for it when a value has to match the machine's word, such as an index into something that could be huge.uintegeris its unsigned twin.
You will also meet the older names in existing code: byte, short, long, longint, single,
double, and their unsigned forms. They are what the fixed-width names are made of — int32 is
long — so the two mix freely.
long is 32 bits. It is the one name most likely to mislead a C or Java programmer, who will
read it as 64. The fixed-width spellings exist partly to make that impossible to get wrong: write
int64 when you mean 64 bits.
The fixed-width names are typedefs the compiler predeclares rather than reserved words, which has a visible consequence:
'' shadow.tiko -- the fixed-width names are typedefs, not keywords,
'' not keywords, so a program is free to take one for itself.
dim as int32 n = 1
scope
dim as int32 int32 = 7 '' a variable named after its own type
print "shadowed: " & int32
end scope
print "outside: " & n
shadowed: 7
outside: 1
A program can declare a variable called int32 and it wins inside its scope. That is not an
oversight: existing headers already declare names like INT32, and making them keywords would
have broken every one of them.
Naming values
'' declare.tiko -- naming values.
dim as int32 counted = 3 '' the type, stated
var inferred = 3 '' inferred from the value
var text = "hello" '' a ustring
dim as float64 ratio = 1.5
print counted & " " & inferred & " " & text & " " & ratio
'' Several names of one type, and one left deliberately uninitialised.
dim as int32 x, y, z
dim as int32 scratch = any '' left uninitialised
x = 1 : y = 2 : z = 3
print x + y + z
'' A constant cannot be assigned to, and its type comes from the value.
const MaxRetries = 3
const Greeting as ustring = "hi"
print MaxRetries & " " & Greeting
'' An enumeration names related constants.
enum Colour_
Red
Green = 10
Blue
end enum
print Red & " " & Green & " " & Blue
3 3 hello 1.5
6
3 hi
0 10 11
dim as T name declares a variable of a stated type. var name = value declares one and takes
the type from the value. The book's rule: var when the type is obvious from the line, dim as
when it is not.
Other forms in that listing:
dim as int32 x, y, zdeclares several at once.= anyleaves a variable uninitialised, holding whatever was in that memory. It is a deliberate, visible choice, not the default.constnames a value that cannot be assigned to. Its type comes from the value unless you state one.enumnames a set of related constants; values follow on from the last one given, soBlueis 11.
Every variable must be declared. There are no implicit variables, which means a typo is caught rather than silently becoming a second variable:
'' implicit.tiko -- every variable is declared. There is no implicit
'' creation, so a typo is a compile error, not a second variable.
dim as int32 total = 0
totl = total + 1 '' a typo, not a new variable
print totl
implicit.tiko(5,1): error TKO0743: 'totl' is not declared
|
5 | totl = total + 1 '' a typo, not a new variable
| ^
A type-suffix sigil does not give a variable a type: dim count% is error TKO0320. Next to an
explicit type, as in dim as int32 count%, the suffix is ignored, with warning TKO0206: the
type comes from as int32. Write dim as int32 count. Chapter 32 covers code that still relies
on suffixes.
One type becoming another
Assigning a value of one numeric type to another converts it, and conversions between integer types are the obvious ones: the value is kept when it fits and wraps when it does not.
Converting a float to an integer is where tiko surprises people who come from C:
'' rounding.tiko -- converting a float to an integer ROUNDS.
dim as float64 half = 2.5
dim as float64 up = 2.6
dim as float64 neg = -2.5
print "cint( 2.5 ) = " & cint( half ) '' rounds to even
print "cint( 3.5 ) = " & cint( 3.5 )
print "cint( 2.6 ) = " & cint( up )
print "cint( -2.5 ) = " & cint( neg )
print
print "int( 2.6 ) = " & int( up ) '' towards minus infinity
print "int( -2.5 ) = " & int( neg )
print "fix( 2.6 ) = " & fix( up ) '' towards zero, like C
print "fix( -2.5 ) = " & fix( neg )
print
dim as int32 assigned = up '' an assignment rounds too
print "dim as int32 = 2.6 gives " & assigned
cint( 2.5 ) = 2
cint( 3.5 ) = 4
cint( 2.6 ) = 3
cint( -2.5 ) = -2
int( 2.6 ) = 2
int( -2.5 ) = -3
fix( 2.6 ) = 2
fix( -2.5 ) = -2
dim as int32 = 2.6 gives 3
It rounds. C truncates. cint( 2.6 ) is 3, not 2 — and a plain assignment into an integer
variable rounds in exactly the same way.
And a half goes to the even neighbour: cint( 2.5 ) is 2 while cint( 3.5 ) is 4. That is
C's lrint( ), not its round( ), which takes halves away from zero and gives 3 and 4 — the
answer most people expect.
When you want C's behaviour, say so: fix( ) truncates towards zero. int( ) is a third
answer again — it rounds towards minus infinity, so it and fix( ) agree on positive numbers and
differ on negative ones.
The c* family converts explicitly: cint, clng, clngint, csng, cdbl, cbool, cbyte,
cshort, and the unsigned forms. cast( T, value ) converts to any type you name, and cunsg /
csign reinterpret a value's sign without changing its bits.
Asking about a type
sizeof( T )is the size in bytes, as the listing above used.len( x )is the length of a string, or the size of anything else.typeof( x )is the type of an expression, usable where a type is expected.
fblimits.bi names the minimum and maximum of each type, if you would rather not write
2147483647 and hope.
Summary
- Types are fixed at compile time; every variable is declared, so a typo is an error.
- Prefer the width-stating names:
int8…int64,uint8…uint64,float32,float64. integeranduintegerare pointer-sized and have no fixed-width spelling — that is the reason to use them.longis 32 bits. The older names still appear in existing code, and mix freely with the new ones.- The fixed-width names are typedefs, so a program may shadow one.
varinfers a type from the value;dim as Tstates it;constnames a value;enumnames a set.- Float to integer rounds, half to even —
fix( )truncates like C,int( )floors. - Numbers may be written in hex, octal or binary;
!"…"reads escapes and$"…"takes none.
Exercises
- Print the largest value each signed integer type can hold, then add one to the
int32maximum and explain what you see.std/numeric'sAddCheckedgives a second opinion. {solution="ex04-01"} - Build a table comparing
cint,intandfixover 2.4, 2.5, 2.6 and their negatives. Which pair agrees on positive numbers but not negative ones? {solution="ex04-02"} - Declare a variable with
= any, print it before assigning anything, and run the program twice. Is the value the same both times? Should you ever rely on it? const Pi = 3.14159— what type doesPiget? Usesizeof( typeof( Pi ) )to find out, then state the type explicitly and compare.
Operators and Expressions
Operators are the part of a language people assume they already know, which is exactly why they are worth a chapter. Most of tiko's behave as you expect. A handful do not, and each of those is a bug waiting in code that was written by assumption rather than by reading.
Everything in this chapter was measured against the compiler. Where tiko's grouping differs from the language you came from, the listing prints both readings so you can see which one won.
Arithmetic
'' arithmetic.tiko -- the operators, and the three that surprise people.
print "7 / 2 = " & ( 7 / 2 ) '' always a float
print "7 \ 2 = " & ( 7 \ 2 ) '' integer division
print "7 mod 2 = " & ( 7 mod 2 )
print
'' mod takes the sign of the LEFT operand: a remainder, not a modulus.
print "-7 mod 2 = " & ( -7 mod 2 )
print " 7 mod -2 = " & ( 7 mod -2 )
print
'' '^' is floating point even when both operands are whole numbers.
print "2 ^ 10 = " & ( 2 ^ 10 ) & _
" and it is " & sizeof( typeof( 2 ^ 10 ) ) & " bytes wide"
7 / 2 = 3.5
7 \ 2 = 3
7 mod 2 = 1
-7 mod 2 = -1
7 mod -2 = 1
2 ^ 10 = 1024 and it is 8 bytes wide
Three of those need naming.
/ is always floating point. 7 / 2 is 3.5 even though both operands are whole numbers.
Integer division is a separate operator, \, and it truncates: 7 \ 2 is 3.
mod is a remainder, not a modulus. It takes the sign of its left operand, so -7 mod 2 is
−1 rather than 1. When you want the mathematical modulus — always non-negative for a positive
divisor — std/numeric's FloorMod is the one to reach for. This matters the moment you use
mod to wrap an index that might be negative.
^ produces a float. 2 ^ 10 is 1024, but it is a float64 that happens to hold a whole
number, and it was computed in floating point. For integer powers where exactness matters,
std/numeric has IPow.
Precedence, and its four surprises
tiko's precedence table was established by compiling 484 operator pairs and reading what the compiler did with each. Four of the answers differ from what a C, Python or Java programmer expects, and this listing prints each one beside both possible groupings:
'' precedence.tiko -- four places the table is not what people expect.
dim as int32 a = 3, b = 0
'' 1. '^' groups to the LEFT. Most languages group it to the right.
print "2 ^ 3 ^ 2 = " & ( 2 ^ 3 ^ 2 )
print " (2^3)^2 = " & ( ( 2 ^ 3 ) ^ 2 )
print " 2^(3^2) = " & ( 2 ^ ( 3 ^ 2 ) )
print
'' 2. 'shl' and 'shr' bind TIGHTER than '+'.
print "1 shl 2 + 3 = " & ( 1 shl 2 + 3 )
print " (1 shl 2) + 3 = " & ( ( 1 shl 2 ) + 3 )
print " 1 shl (2 + 3) = " & ( 1 shl ( 2 + 3 ) )
print
print "not a = b = " & ( not a = b )
print " not (a = b) = " & ( not ( a = b ) )
print "not a = b = " & ( not a = b )
print " not (a = b) = " & ( not ( a = b ) )
print " (not a) = b = " & ( ( not a ) = b )
print
'' 4. 'andalso' and 'orelse' share one level: they group left to right.
print "true orelse false andalso false = " & _
( true orelse false andalso false )
print " (true orelse false) andalso false = " & _
( ( true orelse false ) andalso false )
2 ^ 3 ^ 2 = 64
(2^3)^2 = 64
2^(3^2) = 512
1 shl 2 + 3 = 7
(1 shl 2) + 3 = 7
1 shl (2 + 3) = 32
not a = b = -1
not (a = b) = -1
not a = b = -1
not (a = b) = -1
(not a) = b = 0
true orelse false andalso false = false
(true orelse false) andalso false = false
| Expression | tiko groups it | Many languages |
|---|---|---|
2 ^ 3 ^ 2 | (2 ^ 3) ^ 2 = 64 | right, giving 512 |
1 shl 2 + 3 | (1 shl 2) + 3 = 7 | + first, giving 32 |
not a = b | not (a = b) | (not a) = b |
x orelse y andalso z | left to right | andalso first |
not is looser than the comparisons. not a = b asks "is it not the case that a equals b",
which is usually what was meant — but it is the opposite of C, where !a == b compares !a
against b.
andalso and orelse share one precedence level, so they group strictly left to right. In C,
Python and nearly everything else, and binds tighter than or, so x or y and z means
x or (y and z). Here it means (x orelse y) andalso z, which is a different answer.
Both are cases where code that looks right and compiles clean can be wrong. Parenthesise any
mixed andalso/orelse expression, every time.
Two families of logical operator
'' logic.tiko -- two families of operator that look alike and are not.
declare function Loud( byval v as boolean ) as boolean
function Loud( byval v as boolean ) as boolean
print " (Loud was called)"
return v
end function
print "andalso stops once the answer is known:"
if false andalso Loud( true ) then print "unreachable"
print "and always evaluates both:"
if false and Loud( true ) then print "unreachable"
print
'' On integers the bitwise family works bit by bit.
print "12 and 10 = " & ( 12 and 10 )
print "12 or 10 = " & ( 12 or 10 )
print "12 xor 10 = " & ( 12 xor 10 )
print "not 12 = " & ( not 12 )
andalso stops once the answer is known:
and always evaluates both:
(Loud was called)
12 and 10 = 8
12 or 10 = 14
12 xor 10 = 6
not 12 = -13
andalso and orelse short-circuit: once the answer is known the right-hand side is never
evaluated. and, or, xor and not do not — they are bitwise operators that also serve for
logic, and they always evaluate both sides.
The difference matters whenever the right-hand side has an effect, or would fail:
if p <> 0 andalso p->count > 0is safe.if p <> 0 and p->count > 0dereferences a null pointer.
Use andalso and orelse for conditions, and reserve and/or/xor/not for bits.
tiko refuses to mix the two families silently. Combining a boolean and a non-boolean in an
andalso chain draws a warning, because the result is almost never what was meant — hoist the
value into a boolean first.
Working with bits
'' bits.tiko -- shifting, masking, and the single-bit helpers.
dim as uint32 flags = 0
flags = bitset( flags, 0 ) '' turn bit 0 on
flags = bitset( flags, 3 )
print "flags = " & flags & " (binary " & bin( flags ) & ")"
print "bit 3 set? = " & cbool( bit( flags, 3 ) <> 0 )
flags = bitreset( flags, 3 )
print "after reset = " & bin( flags )
print
print "1 shl 8 = " & ( 1 shl 8 )
print "256 shr 4 = " & ( 256 shr 4 )
print
'' Picking halves out of a wider value.
dim as uint32 packed = &hDEADBEEF
print "packed = " & hex( packed )
print "low word = " & hex( loword( packed ) )
print "high word = " & hex( hiword( packed ) )
print "low byte = " & hex( lobyte( packed ) )
flags = 9 (binary 1001)
bit 3 set? = true
after reset = 1
1 shl 8 = 256
256 shr 4 = 16
packed = DEADBEEF
low word = BEEF
high word = DEAD
low byte = EF
shl and shr shift; bitset, bitreset and bit turn one bit on, off, or read it; lobyte,
hibyte, loword and hiword pull a piece out of a wider value. bin( ) and hex( ) print a
value in a base where the bits are visible, which is most of the battle when debugging a mask.
Remember that shifts bind tighter than arithmetic. 1 shl n + 1 shifts by n and then adds — not
what the spacing suggests.
Joining, choosing, and shorthand
'' expressions.tiko -- joining text, choosing a value, and shorthand.
dim as ustring who = "world"
'' '&' joins text. '+' also adds numbers, so '&' always means join.
print "hello, " & who
print "1 & 2 gives the text " & ( 1 & 2 )
print "1 + 2 gives the number " & ( 1 + 2 )
print
'' iif picks between two values inside an expression.
dim as int32 stock = 0
print "in stock: " & iif( stock > 0, "yes", "no" )
print
'' Compound assignment, and swapping two variables.
dim as int32 total = 10
total += 5 : total *= 2
print "total = " & total
dim as ustring left_ = "first", right_ = "second"
swap left_, right_
print left_ & " " & right_
hello, world
1 & 2 gives the text 12
1 + 2 gives the number 3
in stock: no
total = 30
second first
& joins text; + adds numbers. + will also join two strings, which is exactly why the
book always uses &: 1 & 2 is the text 12, while 1 + 2 is the number 3, and & never has
to be guessed at.
iif( condition, a, b ) chooses between two values inside an expression, where an if
statement would need several lines. Both arms are evaluated, so do not use it where one of them
has an effect.
Compound assignment — +=, -=, *=, /=, \=, &= — and swap a, b, which exchanges
two variables of the same type without a temporary.
Summary
/is floating point,\is integer division, and^produces afloat64.modis a remainder and takes its left operand's sign;FloorModinstd/numericis the mathematical one.^groups to the left, andshl/shrbind tighter than+.notis looser than the comparisons, andandalso/orelseshare one level, grouping left to right — parenthesise mixed conditions.andalso/orelseshort-circuit;and/or/xor/notare bitwise and always evaluate both.&joins text and never means anything else;iifchooses within an expression;swapexchanges two variables.
Exercises
- Build a permissions value from three named bit positions, then read one back. Do it twice —
once with
bitset/bit, once with1 shl nmasks — and check the two agree. {solution="ex05-01"} - Write expressions where parentheses change the answer, and one where they only make the existing grouping obvious. Which of the four surprises above would have bitten you? {solution="ex05-02"}
7 mod -2and-7 mod 2give different signs. Predict both before running them, then work out whatFloorModwould give instead.- Rewrite
if p <> 0 and p->count > 0so it is safe, and explain in one sentence what the original does whenpis null.
Control Flow, Scope and defer
Programs choose and repeat. tiko's constructs for both will look familiar, and the reason they
get a chapter is the third thing here: defer, which arranges for something to happen on the way
out of a block — and which has one rule that catches everybody once.
Choosing
'' branching.tiko -- if, and select case for more than two ways.
dim as int32 score = 72
if score >= 90 then
print "excellent"
elseif score >= 60 then
print "a pass"
else
print "not yet"
end if
'' A whole if on one line needs no 'end if'.
if score > 0 then print "there is a score"
'' select case compares one value against ranges, lists and open tests.
select case score
case 90 to 100
print "band: top"
case 60 to 89
print "band: middle"
case 0, 1, 2
print "band: barely started"
case is < 0
print "band: impossible"
case else
print "band: low"
end select
a pass
there is a score
band: middle
if / elseif / else / end if needs no parentheses around the condition. A complete if can
sit on one line, and then it takes no end if — useful for a guard, and confusing if you try to
extend it later, so keep those short.
select case compares one value against many possibilities, and the cases are more expressive
than a C switch:
| Case | Matches |
|---|---|
case 5 | one value |
case 0, 1, 2 | any in a list |
case 60 to 89 | a range, ends included |
case is < 0 | an open comparison |
case else | everything remaining |
Cases are tested in order and the first match wins; there is no fall-through and nothing to
break out of.
select case as const compiles to a jump table, which is faster for a dense set of integer
cases — and it accepts only constant values, so case is < 0 inside one is TKO0419.
select case true runs the first case whose test is true. case ( n mod 3 ) = 0 works
there: the comparison's −1 or 0 is converted to the boolean subject, as every case value is.
When each branch asks a different question, though, if / elseif is usually the clearer shape.
Repeating
'' loops.tiko -- counting, repeating, and leaving early.
for i as int32 = 1 to 3
print "for " & i
next
for i as int32 = 10 to 0 step -5
print "down " & i
next
dim as int32 n = 0
do while n < 2
print "while " & n
n += 1
loop
n = 0
do
print "until " & n
n += 1
loop until n = 2
'' exit leaves the loop; continue skips to the next pass.
for i as int32 = 1 to 6
if i = 2 then continue for
if i = 4 then exit for
print "picked " & i
next
for 1
for 2
for 3
down 10
down 5
down 0
while 0
while 1
until 0
until 1
picked 1
picked 3
for … next counts, and step sets the stride — negative to count down. Declaring the counter in
the loop, for i as int32 = 1 to 3, keeps it out of the surrounding scope, which is what you want
almost always.
do … loop carries its condition at either end, with either sense:
do while c … loop— test first, may run zero timesdo until c … loop— the same, inverteddo … loop while canddo … loop until c— test last, so the body always runs once
exit for and exit do leave a loop; continue for and continue do skip to the next pass.
Naming the loop kind makes nested loops readable.
An older form, while … wend, also exists and behaves as do while … loop. Existing code uses it;
new code has no reason to.
Blocks that exist to bound a lifetime
'' scopeblock.tiko -- a block that exists only to bound a lifetime.
dim as int32 outer = 1
scope
dim as int32 inner = 2
defer print " leaving the scope"
print " inside: outer " & outer & ", inner " & inner
end scope
'' 'inner' is gone here, and the defer has already run.
print "outside: outer " & outer
inside: outer 1, inner 2
leaving the scope
outside: outer 1
scope … end scope introduces a block with no condition and no loop — just a boundary. Variables
declared inside it end there. It is how you keep a temporary from leaking into the rest of a long
procedure, and, as the next section shows, how you control exactly when a defer fires.
defer
defer takes a statement and runs it when the enclosing block is left — however it is left:
'' deferorder.tiko -- defer runs on the way out, in reverse order.
sub Work( byval failEarly as boolean )
print " open A"
defer print " close A"
print " open B"
defer print " close B"
if failEarly then
print " leaving early"
exit sub
end if
print " doing the work"
end sub
print "normal path:"
Work( false )
print
print "early exit:"
Work( true )
normal path:
open A
open B
doing the work
close B
close A
early exit:
open A
open B
leaving early
close B
close A
Two things to read off that output. Deferred statements run in reverse order, so resources are
released in the opposite order to the one they were acquired in — B before A. And they run on
every path out, including the early exit sub, which is the entire point: the cleanup is
written next to the thing it cleans up, and cannot be skipped by a return you add later.
That makes defer the answer to the oldest bug in resource handling — the path that forgot to
close something:
open "data.txt" for input as #1
defer close #1
Now the rule that catches everyone.
defer belongs to the nearest enclosing block, not to the procedure. An if, a loop body and
a scope are all blocks.
'' defertrap.tiko -- defer belongs to the nearest enclosing BLOCK.
sub Guarded( )
print " start"
if true then
print " acquired"
defer print " released" '' runs at end if, not end sub
print " still inside the if"
end if
print " after the if -- already released"
end sub
sub EachIteration( )
for i as int32 = 1 to 3
defer print " end of pass " & i
print " pass " & i
next
end sub
print "a defer inside an if:"
Guarded( )
print
print "a defer inside a loop body:"
EachIteration( )
a defer inside an if:
start
acquired
still inside the if
released
after the if -- already released
a defer inside a loop body:
pass 1
end of pass 1
pass 2
end of pass 2
pass 3
end of pass 3
Read the first half: the release happens at end if, while the procedure is still running. A
guard written as acquire inside an if, then defer releases the resource immediately and leaves
the rest of the procedure using something already closed — and nothing in the code looks wrong.
The fix is to leave early instead of nesting: test the failure case, exit sub, and put the
acquire-and-defer pair in the procedure's own body. Exercise 2 shows both side by side.
In a loop body the same rule is exactly what you want — the deferred statement runs at the end of each pass, as the second half of the output shows.
defer needs a block to be on the way out of, so it cannot appear at module level:
'' modulelevel.tiko -- defer needs a scope to leave, and a module
'' body is not one. Does not build.
defer print "this never had a scope to leave"
print "hello"
modulelevel.tiko(3,1): error TKO0814: 'defer' is not allowed at module level; put it
in a procedure or a block
Labels and goto
tiko has goto and line labels. They exist for two reasons: compatibility with older code, and
on error goto, which Chapter 18 covers.
Nothing in this book uses goto for control flow. Every loop construct above, plus exit and
continue, covers what it was for — and a goto that jumps across a defer is refused
(TKO0827), because the compiler cannot say what should have run on the way.
Summary
if/elseif/else; a one-lineiftakes noend if.select casematches values, lists, ranges and open comparisons, first match wins, no fall-through.as constbuilds a jump table and takes only constants.- A comparison yields an integer, not a
boolean— wrap withcboolbefore comparing one againsttrue. for … nextwithstep;do … loopwith the test at either end;exitandcontinue.scope … end scopebounds a lifetime deliberately.deferruns on the way out of its block, in reverse order, on every path — including the end of each loop pass, and including anifyou wrapped it in.
Exercises
- Write FizzBuzz for 1 to 15. Which reads better here,
if/elseiforselect case, and why? {solution="ex06-01"} - Write a procedure that acquires something inside an
ifand defers its release, and a second that guards first and defers in the procedure body. Print from both and compare the order. {solution="ex06-02"} - Put a
deferin a loop body that prints the loop counter. Before running it, predict how many times it fires and with which values. - Rewrite a
do … loop untilas ado while … loopthat behaves identically, including when the condition is already true at the start. Which needed more care?
Procedures
A procedure is the unit you give a name to, and naming a piece of work is most of what programming is. tiko's are ordinary — until you look at how arguments get in and results get out, where three things behave differently from what the syntax suggests.
Start with the reason parameters exist at all. A procedure cannot see the variables around it:
'' sealed.tiko -- a procedure cannot see the module's variables.
dim as int32 counter = 5
sub ShowIt( )
print counter '' error: procedures start with an empty slate
end sub
ShowIt( )
sealed.tiko(5,11): error TKO0743: 'counter' is not declared
|
5 | print counter '' error: procedures start with an empty slate
| ^
A module-level variable is visible to the module's own statements and to nothing else. Adding
shared to the declaration would open it up to every procedure in the file, which is the reason
this book almost never does: a procedure whose inputs all arrive as parameters can be read on its
own, and tested on its own.
Subs, functions, and three ways to answer
'' basics.tiko -- a sub does something; a function answers something.
sub Banner( byref title as ustring )
print "== " & title & " =="
end sub
'' Three ways to hand back a result. They compile to the same thing.
function Doubled( byval n as int32 ) as int32
return n * 2
end function
function Tripled( byval n as int32 ) as int32
function = n * 3
end function
function Quadrupled( byval n as int32 ) as int32
Quadrupled = n * 4
end function
Banner( "results" )
print Doubled( 5 ), Tripled( 5 ), Quadrupled( 5 )
'' A function called for its answer and ignored is still called.
function Noisy( ) as int32
print "Noisy ran"
return 0
end function
Banner( "called for effect" )
Noisy( )
== results ==
10 15 20
== called for effect ==
Noisy ran
A sub does something. A function answers something, and has three spellings for handing that
answer back:
return value— sets the result and leaves immediately.function = value— sets the result and carries on. Useful when the answer is refined further down.Name = value, the function's own name — the oldest form, and the one most likely to be misread as a recursive call.
They compile to the same thing. This book uses return, and function = only where the procedure
really does keep working after the answer is settled.
A function may also be called and its answer discarded, as Noisy( ) is above — nothing forces you
to use it.
The answer is usually a single value, but it can be a whole array: write ( ) after the result type,
as in function Squares( ... ) as int32( ). Chapter 8 covers what such a function may return and who
owns the result.
A function that reaches end function without ever setting its result returns a zero-initialised
value, silently. There is no warning. If a branch can fall off the end, put an explicit return
there and say what the answer should be.
declare, for a name the compiler has not read yet
The compiler reads a file once, top to bottom, and a name has to exist before it is used. Two procedures that call each other cannot both be written first:
'' forward.tiko -- declare names a procedure before it exists.
''
'' IsEven and IsOdd call each other, so whichever is written first
'' refers to a name the compiler has not read yet.
declare function IsOdd( byval n as int32 ) as boolean
function IsEven( byval n as int32 ) as boolean
if n = 0 then return true
return IsOdd( n - 1 )
end function
function IsOdd( byval n as int32 ) as boolean
if n = 0 then return false
return IsEven( n - 1 )
end function
for i as int32 = 0 to 4
print i; " even="; IsEven( i ); " odd="; IsOdd( i )
next
0 even=true odd=false
1 even=false odd=true
2 even=true odd=false
3 even=false odd=true
4 even=true odd=false
declare states the signature and nothing else. Once it has been seen, the name can be called
anywhere below, and the body can arrive whenever it likes. Without the declare, the call is
TKO0743: '…' is not declared — the same error a typo gets, because to the compiler it is the same
mistake.
When you name a procedure you are competing with the names the compiler predeclares.
function Double( ) does not compile — double is one of the classic type names — and it fails
with TKO0600: 'double' is already declared in this scope rather than with anything about types.
Appendix A lists the names that collide.
How arguments get in
This is the part to read twice. tiko lets you write a parameter with no byval or byref, and
what that means depends on the parameter's type:
'' passing.tiko -- what a parameter with no byval or byref means.
''
'' Every procedure here writes to its parameter. Whether the caller
'' sees that write is the whole question.
sub PlainInt( n as int32 )
n = 99
end sub
sub PlainStr( s as ustring )
s = "changed"
end sub
'' The same two, written the other way round.
sub ByrefInt( byref n as int32 )
n = 99
end sub
sub ByvalStr( byval s as ustring )
s = "changed"
end sub
dim as int32 a = 1
PlainInt( a )
print "int32 with no keyword: "; a
dim as ustring t = "kept"
PlainStr( t )
print "ustring with no keyword: " & t
dim as int32 b = 1
ByrefInt( b )
print "int32 byref: "; b
dim as ustring u = "kept"
ByvalStr( u )
print "ustring byval: " & u
int32 with no keyword: 1
ustring with no keyword: changed
int32 byref: 99
ustring byval: kept
With no keyword, an int32 was copied — the caller still holds 1. A ustring was shared —
the caller's text changed under it. Both procedures are written identically.
The rule the compiler applies:
| Parameter type | Default |
|---|---|
Numbers, boolean, pointers, enums | byval — a copy |
string, ustring | byref — the caller's own variable |
| Types you define | byref — the caller's own variable |
An omitted keyword means two different things. The same spelling copies a number and aliases a
string, so a procedure that tidies up its own parameter is harmless with an int32 and quietly
corrupts the caller's data with a ustring.
It is the direction of the surprise that makes it expensive. Nobody writes
sub Fix( n as int32 ) expecting to change the caller's number. Plenty of people write
sub Fix( s as ustring ) and use s as scratch space — and the damage then appears somewhere
else entirely, in a variable they were not thinking about.
Write byval or byref on every parameter. Every listing in this book does. It costs six
characters and it removes the question.
byref is not a wart to be avoided — it is how you pass something large without copying it, and
how a procedure reports more than one answer. Exercise 1 builds a MinMax that returns two values
that way, which is the tool to reach for until Chapter 20 introduces tuples.
const, for a reference you promise not to write through
byref gives speed and gives up safety: the callee can now write to the caller's variable. Adding
const takes the safety back while keeping the speed.
'' constparam.tiko -- const says the procedure will not write to it.
sub Bump( byval n as const int32 )
n += 1 '' error: that is what const forbids
end sub
Bump( 1 )
constparam.tiko(3,5): error TKO0617: cannot modify 'n': it is a constant
|
3 | n += 1 '' error: that is what const forbids
| ^
byref x as const T is the right shape for any parameter you only intend to read, and it is
documentation the compiler checks — for every type, text included: byref s as const ustring may not
be assigned either.
Arguments you can leave out
'' optional.tiko -- defaults, and leaving an argument out of the middle.
sub Label( byref who as ustring, byval times as int32 = 1, _
byref mark as ustring = "!" )
for i as int32 = 1 to times
print who & mark
next
end sub
Label( "once" )
Label( "twice", 2 )
Label( "marked", , "?" ) '' times keeps its default
once!
twice!
twice!
marked?
A parameter with = value may be omitted at the call. Optional parameters must come after the
required ones, and an argument in the middle can be skipped by leaving its slot empty —
Label( "marked", , "?" ) takes the default for times and supplies mark.
The empty slot is easy to misread. Two of them in a row, Label( "x", , ), is legal and tells the
reader nothing; when a call needs more than one hole, the parameters are in the wrong order.
One name, several signatures
'' overloads.tiko -- one name, several signatures.
declare function Area overload ( byval side as float64 ) as float64
declare function Area overload ( byval w as float64, _
byval h as float64 ) as float64
declare function Area overload ( byref tag as ustring ) as float64
function Area overload ( byval side as float64 ) as float64
print " (square)"
return side * side
end function
function Area overload ( byval w as float64, _
byval h as float64 ) as float64
print " (rectangle)"
return w * h
end function
function Area overload ( byref tag as ustring ) as float64
print " (by name: " & tag & ")"
return 0.0
end function
'' Each call prints which overload ran, then its answer.
var square = Area( 3.0 )
print "Area( 3.0 ) ="; square
var rect = Area( 2.0, 5.0 )
print "Area( 2.0, 5.0 ) ="; rect
var widened = Area( 3 )
print "Area( 3 ) ="; widened
var named = Area( "lot" )
print "Area( ""lot"" ) ="; named
(square)
Area( 3.0 ) = 9
(rectangle)
Area( 2.0, 5.0 ) = 10
(square)
Area( 3 ) = 9
(by name: lot)
Area( "lot" ) = 0
overload says that a name is deliberately shared. It is required: declaring a second Area
without it is TKO0600: 'area' is already declared in this scope, the same error as any duplicate
name — which is the right default, because most repeated names are accidents.
Note what happened to Area( 3 ). There is no int32 overload, so the argument was converted to
float64 and the one-parameter version ran. The compiler scores the conversions each candidate
would need and takes the cheapest total. When two candidates tie, it refuses rather than guessing:
'' ambiguous.tiko -- neither overload is a better match than the other.
declare function Pick overload ( byval a as int32, _
byval b as float64 ) as int32
declare function Pick overload ( byval a as float64, _
byval b as int32 ) as int32
function Pick overload ( byval a as int32, _
byval b as float64 ) as int32
return 1
end function
function Pick overload ( byval a as float64, _
byval b as int32 ) as int32
return 2
end function
print Pick( 1, 1 ) '' error: int32 converts to float64 either way
ambiguous.tiko(17,11): error TKO0660: the call to 'pick' matches more than one overload
|
17 | print Pick( 1, 1 ) '' error: int32 converts to float64 either way
| ^
Scoring counts conversions; it does not rank them by whether they can lose information. Exercise 2
shows an int64 argument picking an int32 overload — narrowing — in preference to widening to
float64. If a width matters, give it an overload of its own.
Recursion, and locals that remember
'' gcd.tiko -- recursion, and a static local that outlives the call.
function Gcd( byval a as int64, byval b as int64 ) as int64
static as int32 calls = 0
calls += 1
if b = 0 then
print " reached the base case after"; calls; " calls"
calls = 0
return a
end if
return Gcd( b, a mod b )
end function
print "Gcd( 1071, 462 ) ="; Gcd( 1071, 462 )
print "Gcd( 270, 192 ) ="; Gcd( 270, 192 )
Gcd( 1071, 462 ) = reached the base case after 4 calls
21
Gcd( 270, 192 ) = reached the base case after 5 calls
6
A procedure may call itself; each call gets its own parameters and its own locals. static is the
exception — a static local is created once, keeps its value between calls, and so is shared by
every level of a recursion. That is what makes it usable as a call counter, and it is also why
Gcd has to reset it before returning: the count belongs to one top-level call, and nothing else
will clear it.
static initialisers run once, on the first call. A static is the smallest thing that gives a
procedure memory, and the smallest thing that stops it being safe to call from two threads at once
— Chapter 23 returns to that.
Returning a place instead of a value
A function can hand back a reference to storage, which the caller may then assign to. Write
byref after the parameter list:
'' byrefresult.tiko -- a function that returns a place, not a value.
function SlotAt( byval i as int32 ) byref as int32
static as int32 first = 0
static as int32 second = 0
if i = 0 then return first
return second
end function
print "start: "; SlotAt( 0 ); SlotAt( 1 )
'' The obvious spelling. Watch what it does.
SlotAt( 0 ) = 42
print "after SlotAt(0)=42:"; SlotAt( 0 ); SlotAt( 1 )
'' Parenthesise the call, and the assignment lands.
( SlotAt( 0 ) ) = 42
print "after (SlotAt(0))=42:"; SlotAt( 0 ); SlotAt( 1 )
'' Or bind the reference to a name first, which reads better.
dim byref as int32 slot = SlotAt( 1 )
slot = 7
print "after slot = 7: "; SlotAt( 0 ); SlotAt( 1 )
start: 0 0
after SlotAt(0)=42: 0 0
after (SlotAt(0))=42: 42 0
after slot = 7: 42 7
Read the second line of that output before the third.
SlotAt( 0 ) = 42 does nothing, and says nothing.
Inside an argument list = is the equality operator, so the compiler reads SlotAt( 0 = 42 ): a
call with one boolean argument, whose result is thrown away. It is a complete, legal statement.
The assignment you wrote is gone, and the program carries on with the old value.
Two spellings work, both in the listing above:
( SlotAt( 0 ) ) = 42 '' parenthesise the call
dim byref as int32 slot = SlotAt( 1 ) '' or bind it to a name
slot = 7
The second is worth preferring. It names the thing being modified, and it cannot be broken later by someone removing a pair of parentheses that looked redundant.
The compiler says so, at least: it warns TKO0931: '=' is read as a comparison inside the argument of 'slotat', not as an assignment to its byref result. Read that warning as an error. (With two or
more arguments there is no ambiguity — ( a, b ) = v is not an expression — and f( a, b ) = v
assigns through the result as written.)
Any number of arguments
A Sum that adds two numbers is easy, and so is one that adds three. A Sum that adds however
many it is given is the one you actually want, and overloads cannot get there: there would have to
be one per count. An array parameter can, but then every caller builds an array before every call.
A typed variadic is the array parameter with the caller's convenience:
'' typedvariadic.tiko -- any number of arguments, every one typed.
''
'' The '...' after the type makes nums an array parameter that the
'' caller fills with a plain list. Each argument is converted to
'' int32 exactly as it would be for an ordinary int32 parameter.
function Sum( nums() as const int32 ... ) as int64
dim as int64 total = 0
for i as int32 = lbound( nums ) to ubound( nums )
total += nums( i )
next
return total
end function
'' Fixed parameters come first; the variadic is always the last.
sub Report( byref title as const ustring, _
nums() as const int32 ... )
print title; ":"; ubound( nums ) - lbound( nums ) + 1; _
" values, sum"; Sum( nums( ) )
end sub
print "Sum( 10, 20, 30 ) ="; Sum( 10, 20, 30 )
print "Sum( 7 ) ="; Sum( 7 )
print "Sum( ) ="; Sum( )
Report( "three", 1, 2, 3 )
Report( "none" )
'' A whole array of the element type IS the list -- nothing is
'' copied, and no count has to be passed alongside it.
dim as int32 week( 0 to 6 ) = { 3, 5, 2, 8, 6, 4, 7 }
Report( "week", week( ) )
Sum( 10, 20, 30 ) = 60
Sum( 7 ) = 7
Sum( ) = 0
three: 3 values, sum 6
none: 0 values, sum 0
week: 7 values, sum 35
nums() as const int32 ... is an array parameter with ... after its type. The caller writes a
plain list. The compiler converts each argument to int32, exactly as it would for an ordinary
int32 parameter, and packs them into a temporary array that lives until the end of the
statement. Inside, nums is an ordinary array: lbound is 0, and ubound is -1 when nothing was
passed, so the loop above needs no special case for Sum( ).
Fixed parameters come first and the variadic is always last — anywhere else is TKO0973. It takes
no default value (TKO0974), because no arguments already means an empty array.
A whole array of the element type is the list itself. Report( "week", week( ) ) passes
week as it stands: nothing is copied and no count travels alongside it, because the array
carries its own bounds. That is the form to use when the values are already in an array, or when
the list is built while the program runs.
Every argument is checked, which is the whole difference from the C mechanism further down:
'' variadictyped.tiko -- every packed argument is checked.
function Sum( nums() as const int32 ... ) as int64
dim as int64 total = 0
for i as int32 = lbound( nums ) to ubound( nums )
total += nums( i )
next
return total
end function
print Sum( 1, "two", 3 ) '' error: "two" is no int32
variadictyped.tiko(11,15): error TKO0698: this argument does not convert to the parameter's type
|
11 | print Sum( 1, "two", 3 ) '' error: "two" is no int32
| ^
It is the same error a non-converting argument to an ordinary parameter gets, at the argument that caused it.
An overload that takes its arguments without packing them always wins over one that needs to:
'' variadicpick.tiko -- an exact match beats a packed one.
sub Show overload ( byval n as int32 )
print "one int32:"; n
end sub
sub Show overload ( nums() as const int32 ... )
print "a list of"; ubound( nums ) - lbound( nums ) + 1
end sub
Show( 5 ) '' the plain overload: no packing needed
Show( 5, 6 ) '' only the list can take two
Show( ) '' ... or none
one int32: 5
a list of 2
a list of 0
So adding a variadic overload never changes which procedure an existing call runs. Among candidates that all pack, each packed argument is scored like any other argument.
A packed list is a copy; a spread array is the caller's own. Without const, a variadic
procedure may write to its array, and where the writes land depends on how it was called:
'' variadicalias.tiko -- a packed list is a copy; a spread array
'' is the caller's own.
sub Bump( nums() as int32 ... ) '' no const: it may write
for i as int32 = lbound( nums ) to ubound( nums )
nums( i ) += 100
next
end sub
dim as int32 a = 1, b = 2
Bump( a, b ) '' packed into a temporary
print "after Bump( a, b ): "; a; b
dim as int32 both( 0 to 1 ) = { 1, 2 }
Bump( both( ) ) '' the array itself
print "after Bump( both( ) ): "; both( 0 ); both( 1 )
after Bump( a, b ): 1 2
after Bump( both( ) ): 101 102
Bump( a, b ) changed a temporary, which disappeared at the end of the statement; a and b are
untouched. Bump( both( ) ) changed both. The procedure is the same and the call looks almost
the same, and the two do different things without a word from the compiler.
Declare the variadic const unless writing to the caller's array is the point. Then the
question cannot arise.
The element type can be one of your own, and the conversion then goes through that type's
constructors — Chapter 12. That is how strings.Format in Chapter 9 takes values of every type in
one list: its element type has a constructor for each.
C's ...
'' varargs.tiko -- a procedure taking any number of arguments.
''
'' This is C's mechanism, so it needs C's calling convention and
'' none of the type checking you get everywhere else.
#include once "crt/stdarg.bi"
function SumOf cdecl ( byval count as int32, ... ) as int64
dim as cva_list args
dim as int64 total = 0
cva_start( args, count )
for i as int32 = 1 to count
total += cva_arg( args, int32 )
next
cva_end( args )
return total
end function
print "SumOf( 3, 10, 20, 30 ) ="; SumOf( 3, 10, 20, 30 )
print "SumOf( 1, 7 ) ="; SumOf( 1, 7 )
print "SumOf( 0 ) ="; SumOf( 0 )
SumOf( 3, 10, 20, 30 ) = 60
SumOf( 1, 7 ) = 7
SumOf( 0 ) = 0
A bare ... as the last parameter, with no name and no type, is C's mechanism, with C's
consequences: the procedure must be cdecl, there is no type checking, and the count is recorded
nowhere — SumOf is told how many to read because there is no other way for it to find out.
Reading one argument too many reads whatever happened to be next.
Declaring the procedure without cdecl is refused: TKO0781: '...' needs a 'cdecl' procedure, and this one is 'stdcall'.
C's variadics exist so that C libraries can be called; printf is the reason the machinery is
there. In tiko code, use a typed variadic. Chapter 29 covers cva_list in full, alongside the rest
of C interoperation.
Procedures as values
'' callbacks.tiko -- a procedure held in a variable.
function Twice( byval x as float64 ) as float64
return x * 2.0
end function
function Square( byval x as float64 ) as float64
return x * x
end function
'' The parameter type names a shape, not a procedure.
sub Tabulate( byref title as ustring, _
byval f as function( byval as float64 ) as float64 )
print title
for i as int32 = 1 to 3
print " f("; i; " ) ="; f( i )
next
end sub
Tabulate( "doubling", @Twice )
Tabulate( "squaring", procptr( Square ) )
'' A variable of that type can be reassigned like any other.
dim as function( byval as float64 ) as float64 op = @Twice
print "op( 4 ) ="; op( 4.0 )
op = @Square
print "op( 4 ) ="; op( 4.0 )
doubling
f( 1 ) = 2
f( 2 ) = 4
f( 3 ) = 6
squaring
f( 1 ) = 1
f( 2 ) = 4
f( 3 ) = 9
op( 4 ) = 8
op( 4 ) = 16
A procedure has a type, spelled the way it is declared minus the names:
function( byval as float64 ) as float64. A variable or parameter of that type holds any procedure
matching the shape, and is called with ordinary call syntax.
Two spellings get the address: @Twice and procptr( Twice ). They are the same thing, and
procptr is the one to use for an overloaded name, where the signature picks which one you meant.
A procedure pointer is the plainest form of the idea that the caller decides what to do and the procedure decides when. Chapter 21 extends it to lambdas, which add the ability to capture variables from around them — the one thing a procedure pointer cannot do, because a pointer is a single address with no room for anything else.
What it costs
A call is cheap and not free: arguments are placed, a return address is pushed, the callee may save registers. None of that matters at ordinary granularity, and all of it matters in a loop running millions of times.
The costs worth knowing are the ones you choose:
byvalon a large value copies it, every call. That is whatbyref ... as constis for.byrefon a small value adds an indirection to save copying eight bytes. It is a loss.- A procedure pointer cannot be inlined, because the target is not known until it runs. A direct call can be.
- A packed variadic call builds a temporary array, every call. Passing an array you already have costs nothing extra.
staticlocals make a procedure stateful, which is a correctness cost before it is ever a performance one.
Summary
- A procedure sees only what it is given; module-level variables need
shared, which is rarely worth it. return,function =and assigning the procedure's own name are three spellings of one thing. A function that sets no result returns zero, silently.declareintroduces a name before its body, which mutual recursion requires.- With no keyword, numbers are passed
byval, and strings and your own typesbyref. Write the keyword on every parameter. byref x as const Tis a reference you cannot write through, whatever T is.- Optional parameters follow the required ones, and a middle argument can be left empty.
overloadis required to share a name; resolution scores conversions and refuses ties withTKO0660. It does not avoid narrowing.staticlocals persist between calls and are shared by every level of a recursion.- Assigning to a one-argument
byreffunction result needs( f( x ) ) = vor adim byrefname — the bare form parses as an equality test, andTKO0931warns that it did. - A typed variadic,
nums() as const T ..., takes any number of arguments, each converted toTand checked; the procedure sees an ordinary array. A whole array ofTis the list itself, with no copy. - An overload that needs no packing beats one that does. A packed list is a copy and a spread
array is the caller's own — declare the variadic
const. - C's bare
...needscdecland does no type checking; it is for calling C. - Procedure pointers hold any procedure of a matching shape;
@Pandprocptr( P )are the same. - The costs you choose: a
byvalcopy of something large, abyrefindirection to save eight bytes, a procedure pointer that cannot be inlined, and astaticthat makes a procedure stateful.
Exercises
- Write
MinMax, which takes threeint32values and reports the smallest and largest throughbyrefparameters. Which of its five parameters arebyvaland whichbyref, and what happens if you omit the keywords entirely? {solution="ex07-01"} - Overload
Describeforint32,float64andustring, then call it with anint16and anint64. Predict which overload each picks before you run it. {solution="ex07-02"} - Write a function with a
byrefresult, then assign to it with the baref( i ) = vform. Before running the program, say what you expect; afterwards, explain what the compiler actually compiled. - Give a recursive procedure a
staticcall counter and don't reset it. Call the procedure twice and explain the second number. - Take
Tabulatefromcallbacks.tikoand add a third operation without touchingTabulate. Then do the same with an overload instead of a procedure pointer. Which change would you rather review? - Write
Average( xs() as const float64 ... ). Decide whatAverage( )should return, and make it do that. Then pass it a wholeint32array: why is that refused, whenAverage( 1, 2, 3 )withint32arguments is not?
Arrays
An array is many values of one type under one name, reached by number. It is the oldest data structure there is and still the one to reach for first: the elements sit next to each other in memory, so walking an array is the fastest thing a processor does.
tiko's arrays carry their bounds with them — not a length, but a first and last index, which the compiler and the runtime both know. That one decision explains most of what follows.
Bounds, not length
'' bounds.tiko -- an array's bounds are part of its declaration.
dim as int32 a( 10 ) '' how many elements is that?
dim as int32 b( 1 to 5 )
dim as int32 c( -2 to 2 )
print "a( 10 ) "; lbound( a ); " to"; ubound( a ); _
" elements:"; ubound( a ) - lbound( a ) + 1
print "b( 1 to 5 ) "; lbound( b ); " to"; ubound( b ); _
" elements:"; ubound( b ) - lbound( b ) + 1
print "c( -2 to 2 ) "; lbound( c ); " to"; ubound( c ); _
" elements:"; ubound( c ) - lbound( c ) + 1
'' Every element starts at zero. Nothing is left uninitialised.
print "a( 7 ) before anything is written:"; a( 7 )
'' An initialiser gives the values in order.
dim as int32 d( 1 to 3 ) = { 10, 20, 30 }
print "d ="; d( 1 ); d( 2 ); d( 3 )
a( 10 ) 0 to 10 elements: 11
b( 1 to 5 ) 1 to 5 elements: 5
c( -2 to 2 ) -2 to 2 elements: 5
a( 7 ) before anything is written: 0
d = 10 20 30
lbound( a ) and ubound( a ) are the first and last valid index. Every loop over an array in
this book is written between them, and never between 0 and some remembered count.
dim as int32 a( 10 ) declares eleven elements, not ten. A single number is the upper bound,
and the lower bound defaults to 0 — so the valid indices are 0 through 10.
Anyone arriving from C, Java, Python or Rust will read that line as "ten elements" and write a loop from 0 to 9, quietly ignoring the last one. The reverse mistake is worse: read it as "indices 0 to 9" in a language where the eleventh exists, and a bug hides in the element nobody looks at.
Write both bounds. dim as int32 a( 0 to 9 ) says exactly what it means, in the same number of
keystrokes, and it cannot be misread.
Elements start at zero — or empty, for a string — so there is no such thing as an uninitialised array element. An initialiser in braces fills them in order.
A lower bound other than 0 is worth using when the index means something. band( 0 to 9 ) in the
histogram below is indexed by tens, and temperature( 1900 to 2000 ) indexed by year needs no
arithmetic at the point of use, which is where arithmetic goes wrong.
Arrays that change size
'' dynamic.tiko -- an array whose size is decided while running.
dim as int32 a( ) '' declared, but with no room yet
print "empty: lbound="; lbound( a ); " ubound="; ubound( a )
print " ubound < lbound is how an empty array reads"
redim a( 1 to 3 )
a( 1 ) = 10 : a( 2 ) = 20 : a( 3 ) = 30
print "filled:"; a( 1 ); a( 2 ); a( 3 )
'' redim on its own does NOT keep what was there.
redim a( 1 to 5 )
print "after redim( 1 to 5 ):";
for i as int32 = lbound( a ) to ubound( a ) : print a( i ); : next
print
redim a( 1 to 3 )
a( 1 ) = 10 : a( 2 ) = 20 : a( 3 ) = 30
redim preserve a( 1 to 5 )
print "after redim preserve:";
for i as int32 = lbound( a ) to ubound( a ) : print a( i ); : next
print
'' Shrinking with preserve keeps the elements that still fit.
redim preserve a( 1 to 2 )
print "after shrinking:";
for i as int32 = lbound( a ) to ubound( a ) : print a( i ); : next
print
erase a
print "after erase: lbound="; lbound( a ); " ubound="; ubound( a )
empty: lbound= 0 ubound=-1
ubound < lbound is how an empty array reads
filled: 10 20 30
after redim( 1 to 5 ): 0 0 0 0 0
after redim preserve: 10 20 30 0 0
after shrinking: 10 20
after erase: lbound= 0 ubound=-1
dim as int32 a( ) declares an array with no room yet. Its ubound is below its lbound,
which is how an empty array reads — and why for i = lbound( a ) to ubound( a ) over an empty
array runs zero times instead of needing a guard.
redim gives it a size. redim preserve gives it a size and keeps what was there, growing
with zeros and shrinking by truncation.
redim without preserve discards every element. It is not a resize; it is a fresh array at
the same name.
That is easy to accept when you read it here and easy to miss in code, because nothing about
redim a( 1 to 5 ) looks destructive, and the array is still there afterwards — just full of
zeros. The listing above shows 10 20 30 becoming 0 0 0 0 0.
Reach for redim preserve by default, and use plain redim only when you mean to start over.
erase does two different things, and which one depends on the array:
'' erasefixed.tiko -- erase means two different things.
dim as int32 fixed_( 1 to 3 ) = { 7, 8, 9 }
dim as int32 grown( )
redim grown( 1 to 3 )
grown( 1 ) = 7 : grown( 2 ) = 8 : grown( 3 ) = 9
erase fixed_
erase grown
print "fixed after erase: ubound="; ubound( fixed_ ); _
" values:"; fixed_( 1 ); fixed_( 2 ); fixed_( 3 )
print "dynamic after erase: ubound="; ubound( grown ); _
" (the storage is gone)"
fixed after erase: ubound= 3 values: 0 0 0
dynamic after erase: ubound=-1 (the storage is gone)
On a dynamic array, erase frees the storage and leaves it empty. On a fixed array there
is nothing to free, so it resets the elements and the bounds stay. One keyword, two meanings,
neither surprising once you know which array you are holding.
An array indexed by what it counts
'' histogram.tiko -- an array indexed by the thing you are counting.
'' Sixteen measurements, each a score from 0 to 100.
dim as int32 scores( 1 to 16 ) = { 93, 71, 55, 88, 64, 79, 100, 42, _
67, 85, 90, 73, 58, 96, 81, 77 }
'' One counter per band of ten. The bounds say what the index MEANS:
'' band 4 counts 40-49. A perfect score is clamped into the top band.
dim as int32 band( 0 to 9 )
for i as int32 = lbound( scores ) to ubound( scores )
dim as int32 b = scores( i ) \ 10
if b > ubound( band ) then b = ubound( band )
band( b ) += 1
next
for b as int32 = ubound( band ) to lbound( band ) step -1
dim as ustring bar = ""
for n as int32 = 1 to band( b ) : bar &= "#" : next
print b * 10; "-"; b * 10 + 9; " "; bar
next
90- 99 ####
80- 89 ###
70- 79 ####
60- 69 ##
50- 59 ##
40- 49 #
30- 39
20- 29
10- 19
0- 9
This is the shape worth recognising: the index is the data. band( score \ 10 ) needs no search
and no comparison chain — the value computes its own slot, and counting is one addition.
The bounds document the meaning. band( 0 to 9 ) says there are ten bands of ten; the clamp says
what happens to a perfect score. Both are visible at the declaration rather than implied by a loop
somewhere below.
More than one subscript
'' matrix.tiko -- more than one subscript, and how it sits in memory.
dim as int32 m( 0 to 1, 0 to 2 )
dim as int32 n = 0
for r as int32 = lbound( m, 1 ) to ubound( m, 1 )
for c as int32 = lbound( m, 2 ) to ubound( m, 2 )
m( r, c ) = n
n += 1
next
next
print "dimension 1:"; lbound( m, 1 ); " to"; ubound( m, 1 )
print "dimension 2:"; lbound( m, 2 ); " to"; ubound( m, 2 )
print "as a grid:"
for r as int32 = lbound( m, 1 ) to ubound( m, 1 )
print " ";
for c as int32 = lbound( m, 2 ) to ubound( m, 2 )
print m( r, c );
next
print
next
'' The same storage, read as one flat run of int32.
dim as int32 ptr p = @m( 0, 0 )
print "in memory:";
for i as int32 = 0 to 5
print p[ i ];
next
print
dimension 1: 0 to 1
dimension 2: 0 to 2
as a grid:
0 1 2
3 4 5
in memory: 0 1 2 3 4 5
Dimensions are separated by commas, and lbound/ubound take the dimension number as a second
argument: ubound( m, 2 ) is the last column index. An array may have up to eight dimensions.
The last line of that output is the one to keep. Reading the same storage as a flat run of int32
gives 0 1 2 3 4 5 — the order the elements were written, row by row. The last subscript varies
fastest. A row is contiguous; a column is strided.
That is not a detail of the implementation you are free to ignore, because it decides how fast the loop runs. Iterating with the last subscript innermost walks memory forwards and every cache line that is fetched gets used. Swapping the loops reads one element per cache line, and on a large array that is the difference between fast and slow, with identical source.
Filling an array with braces
'' braces.tiko -- a brace list fills an array: one value per element,
'' and one level of braces per dimension.
dim as int32 primes( 0 to 4 ) = { 2, 3, 5, 7, 11 }
dim as int32 grid( 0 to 1, 0 to 2 ) = { { 1, 2, 3 }, { 4, 5, 6 } }
'' '...' as the upper bound: the list decides how many there are.
dim as int32 squares( 1 to ... ) = { 1, 4, 9, 16 }
print "primes:";
for i as int32 = lbound( primes ) to ubound( primes )
print primes( i );
next
print
print "grid, row 1:";
for c as int32 = lbound( grid, 2 ) to ubound( grid, 2 )
print grid( 1, c );
next
print
print "squares:"; ubound( squares ); " of them, the last";
print squares( ubound( squares ) )
primes: 2 3 5 7 11
grid, row 1: 4 5 6
squares: 4 of them, the last 16
A brace list gives the elements in index order, from lbound up. An array with more than one
dimension takes one level of braces per dimension, in the order the last section described: the
first inner list is row 0, so { { 1, 2, 3 }, { 4, 5, 6 } } puts 4 5 6 in row 1.
... as the upper bound lets the list decide the size. squares( 1 to ... ) has four elements
because four values were given, so adding a fifth value cannot leave the bounds behind.
Braces are for arrays, and only at the declaration. A single value takes = and the value. A brace
list on anything that is not an array is refused:
'' bracescalar.tiko -- a brace list initializes an ARRAY. A single
'' value takes '=' and the value.
dim as int32 total = { 0 } '' refused
print total
bracescalar.tiko(4,22): error TKO0845: 'total' is not an array, so it cannot take a '{ ... }' initializer
|
4 | dim as int32 total = { 0 } '' refused
| ^
A brace list is not a value either, so it cannot be assigned later:
'' bracelater.tiko -- braces belong to the declaration. Filling an
'' array again is a loop, or one element at a time.
dim as int32 scores( 0 to 2 ) = { 70, 85, 90 }
scores = { 0, 0, 0 } '' refused
print scores( 0 )
bracelater.tiko(5,10): error TKO0846: a '{ ... }' list can only initialize an array, and nothing here is one
|
5 | scores = { 0, 0, 0 } '' refused
| ^
To refill an array, loop over it, or assign the elements one at a time. A container from the standard library is the one other thing a brace list can fill; Chapter 17 shows how.
fbc refuses both lines too, with its errors 63 and 73.
Passing an array to a procedure
'' params.tiko -- an array argument carries its bounds with it.
sub FillWith( a( ) as int32, byval v as int32 )
for i as int32 = lbound( a ) to ubound( a )
a( i ) = v
next
end sub
function SumOf( a( ) as int32 ) as int64
dim as int64 total = 0
for i as int32 = lbound( a ) to ubound( a )
total += a( i )
next
return total
end function
'' A parameter's rank is written with commas, and nothing else.
sub Describe2D( m( , ) as int32 )
print " rows"; lbound( m, 1 ); " to"; ubound( m, 1 ); _
", columns"; lbound( m, 2 ); " to"; ubound( m, 2 )
end sub
dim as int32 a( 5 to 8 )
FillWith( a( ), 3 )
print "bounds inside were"; lbound( a ); " to"; ubound( a )
print "sum ="; SumOf( a( ) )
dim as int32 grid( 1 to 2, 0 to 3 )
print "Describe2D saw:"
Describe2D( grid( ) )
bounds inside were 5 to 8
sum = 12
Describe2D saw:
rows 1 to 2, columns 0 to 3
A parameter is written a( ) as int32 and the argument is written a( ) — the empty parentheses
are required at both ends. An array is always passed by reference; there is no copy, and writes
inside the procedure are writes to the caller's array.
The bounds travel with it. FillWith was given an array indexed 5 to 8 and saw exactly that, which
is why a procedure that works from lbound and ubound works on any array of the right type
without being told anything else.
A multi-dimensional parameter states its rank with commas and nothing more: m( , ) as int32
takes any two-dimensional int32 array. Rank is fixed at compile time; bounds are not.
Returning an array
A function that builds a table has to give it to its caller somehow, and the old answers are both
awkward. The caller can create the array first and let the function fill it in, which means the
caller must know the size before the function has decided it. Or the array can be wrapped in a
container just to cross one return. tiko lets a function return the array itself: write ( )
after the result type.
'' returning.tiko -- a function can hand back a dynamic array.
'' A brace list: the array is sized to the list and filled.
function FirstPrimes( ) as int32( )
return { 2, 3, 5, 7, 11 }
end function
'' A local array, built in a loop and moved out. Nothing is copied,
'' and the bounds the function chose travel with it.
function Squares( byval n as int32 ) as int32( )
dim r( ) as int32
redim r( 1 to n )
for i as int32 = 1 to n
r( i ) = i * i
next
return r
end function
'' Any element type works.
function Greetings( ) as ustring( )
return { "hello", "bonjour", "hola" }
end function
sub ShowSum( a( ) as int32 )
dim as int32 total = 0
for i as int32 = lbound( a ) to ubound( a )
total += a( i )
next
print "sum of " & lbound( a ) & " to " & ubound( a ) & " = " & total
end sub
'' var takes the result as it is: type, bounds and all.
var p = FirstPrimes( )
print "primes " & lbound( p ) & " to " & ubound( p ) & _
", p(4) = " & p( 4 )
var sq = Squares( 5 )
print "squares " & lbound( sq ) & " to " & ubound( sq ) & _
", sq(5) = " & sq( 5 )
'' A result can go straight to a parameter that takes an array.
ShowSum( Squares( 4 ) )
'' for each walks a result, and the array is freed afterwards.
for each g as ustring in Greetings( )
print g
next
'' Assigning moves the new array in and frees the old one.
dim as int32 m( )
m = FirstPrimes( )
m = Squares( 3 )
print "m is now " & lbound( m ) & " to " & ubound( m )
primes 0 to 4, p(4) = 11
squares 1 to 5, sq(5) = 25
sum of 1 to 4 = 30
hello
bonjour
hola
m is now 1 to 3
The result is an ordinary dynamic array. It is what dim a( ) makes, it is indexed the same way,
and a parameter written a( ) accepts it. It is not a container. Only an empty ( ) means an
array: as int32( 3 ) is refused (TKO0836), where it used to be quietly read as a plain int32.
return takes three things:
- A brace list. The array is sized to the list, indexed from 0, and filled. A list inside a
list is refused (
TKO0839); write it flat. Any element type works, and each element is built once. - A local array, as
Squaresdoes. It is moved, not copied: the function's own variable is left empty and the caller receives the array it built, bounds included.sqruns from 1 to 5 becauseSquaressaid so. - A call that returns an array, handed straight through.
function = value takes the same three and carries on afterwards, as it does for any result. A
function that never sets one returns an empty array: ubound is −1 and a loop over it runs zero
times.
The caller owns what comes back. var p = FirstPrimes( ) takes the array itself. m = Squares( 3 )
frees what m held and moves the new array in. ShowSum( Squares( 4 ) ) and for each over a call
use the array and free it when the statement ends, and a result nobody uses is freed there too. Two
calls never share a buffer, so there is nothing to release by hand.
Only an array that belongs to the function can be moved out. A parameter, a shared or static
array and a field all belong to somebody else: handing one back would leave its owner pointing at
freed memory, and the other way to do it is a copy, which tiko does not make for you yet.
'' returnparam.tiko -- an array that is not the function's own cannot
'' be handed back: its owner would be left holding freed memory.
function Same( a( ) as int32 ) as int32( )
return a
end function
returnparam.tiko(5,5): error TKO0840: 'a' is not a local array of this procedure, so returning it would need a copy, which is not supported yet; return a local array or a brace list
|
5 | return a
| ^
To hand back a copy, build a local array element by element and return that.
The same care applies at the call. The name of a function that returns an array needs its
parentheses, so var b = FirstPrimes is TKO0841 rather than a call. An array is not a number, so
dim as int32 x = FirstPrimes( ) is TKO0842. And dim a( ) as int32 = FirstPrimes( ) is
TKO0612, as it is for any initialised dynamic array: var takes the result, dim does not.
Two limits remain. A function type cannot return an array (as function( ) as int32( ) is
TKO0837), and array results of properties and operators have not been tried.
Walking it
'' walking.tiko -- three ways across an array, and what each can do.
dim as int32 a( 1 to 4 ) = { 10, 20, 30, 40 }
print "by index:";
for i as int32 = lbound( a ) to ubound( a )
print a( i );
next
print
print "for each:";
for each v as int32 in a
print v;
next
print
'' for each hands you a COPY. Writing to it changes nothing.
for each v as int32 in a
v = 0
next
print "after writing to the copy:";
for each v as int32 in a : print v; : next
print
'' for each byref hands you the element itself.
for each byref v as int32 in a
v *= 2
next
print "after for each byref:";
for each v as int32 in a : print v; : next
print
by index: 10 20 30 40
for each: 10 20 30 40
after writing to the copy: 10 20 30 40
after for each byref: 20 40 60 80
Three ways across, and the difference between the last two matters:
- By index,
for i = lbound( a ) to ubound( a ). You have the position as well as the value, which you need whenever the answer depends on where you are. for each v as T in ahands you a copy of each element. Writing tovchanges nothing — the listing proves it.for each byref v as T in ahands you the element itself, so writes land in the array.
Use for each when you only need the values, and byref when you mean to modify them. The byref
form also avoids copying each element, which matters when the elements are large.
for each will not walk a grid:
'' foreach2d.tiko -- there is no single order to walk a grid in.
dim as int32 m( 0 to 1, 0 to 2 )
for each v as int32 in m '' refused: which way, rows or columns?
print v;
next
foreach2d.tiko(4,1): error TKO0608: 'for each' over a multi-dimensional array has no defined order
|
4 | for each v as int32 in m '' refused: which way, rows or columns?
| ^
The compiler could have picked row-major and said nothing. Refusing is the better answer: an iteration order over a grid is a decision the code should state, not one to inherit silently from the memory layout.
When the index is wrong
An index outside the bounds reads or writes memory that belongs to something else. Chapter 1
introduced -exx, which adds a check to every array access and stops the program with runtime
error 6 rather than letting it continue with nonsense.
The check covers a dynamic array as well as a fixed one. Its bounds are not in the source, so the
check reads them from the array's descriptor at run time, on every access, and a redim that
shrinks the array moves the limit with it:
'' exxdynamic.tiko -- -exx checks a dynamic array too: its bounds are
'' read from the descriptor, at run time, on every access.
#cmdline "-exx"
dim as int32 a( )
redim a( 1 to 3 )
a( 3 ) = 30 '' fine: 3 is inside 1 to 3
redim preserve a( 1 to 2 ) '' the limit moves with the array
a( 3 ) = 42 '' stops here, with runtime error 6
print "not reached"
Aborting due to runtime error 6 (out of bounds array access) at line 10 of exxdynamic.tiko::(),
'A' accessed with invalid index = 3, must be between 1 and 2
The program stops with exit code 6 at the first bad write, before it can corrupt anything.
In a build without -exx, the check you write yourself is the one you can rely on:
'' guard.tiko -- checking an index yourself, which is always available.
function ValueAt( a( ) as int32, byval i as int32, _
byval fallback as int32 ) as int32
if i < lbound( a ) orelse i > ubound( a ) then return fallback
return a( i )
end function
dim as int32 a( )
redim a( 1 to 3 )
a( 1 ) = 10 : a( 2 ) = 20 : a( 3 ) = 30
print "ValueAt( 2 ) ="; ValueAt( a( ), 2, -1 )
print "ValueAt( 9 ) ="; ValueAt( a( ), 9, -1 )
print "ValueAt( 0 ) ="; ValueAt( a( ), 0, -1 )
redim preserve a( 1 to 2 )
print "after shrinking, ValueAt( 3 ) ="; ValueAt( a( ), 3, -1 )
ValueAt( 2 ) = 20
ValueAt( 9 ) =-1
ValueAt( 0 ) =-1
after shrinking, ValueAt( 3 ) =-1
Comparing against lbound and ubound costs two comparisons and works on every array, of every
kind, in every build. Where an index comes from outside the program — a file, an argument, a
calculation you are not certain of — that guard is not optional.
One array is not assigned to another. a = b between arrays is refused —
TKO0930: a whole array cannot be assigned or swapped — and so is swap a, b, because the two
may not even be the same size. To copy an array, loop over its elements.
Text stored in an array
'' textarray.tiko -- an array of text that grows as rows arrive.
dim as ustring names( )
'' Each append makes room for one more and writes into the new slot.
dim as ustring incoming( 1 to 3 ) = { "Ada", "Grace", "Barbara" }
for i as int32 = lbound( incoming ) to ubound( incoming )
redim preserve names( 0 to ubound( names ) + 1 )
names( ubound( names ) ) = incoming( i )
next
'' Take the count into a variable of its own before printing it.
dim as int32 count = ubound( names ) - lbound( names ) + 1
print "count ="; count
for i as int32 = lbound( names ) to ubound( names )
print i; ": " & names( i )
next
count = 3
0: Ada
1: Grace
2: Barbara
Growing an array one element at a time — redim preserve to ubound + 1, then write the new
slot — is the pattern for building a list whose length is not known in advance. It is correct, and
it is also the slow way to do it: each redim preserve may copy the whole array, so appending n
items can cost n² work.
For a handful of rows that does not matter. For a file of them it does, and the answer is either to grow in chunks — double the size when it fills — or to use the container that already does exactly that, which is the next section.
The descriptor
'' descriptor.tiko -- what the compiler keeps alongside the elements.
#include once "fbc-int/array.bi"
dim as int32 a( 1 to 4 )
dim as int32 grid( 0 to 1, 0 to 2 )
dim as int32 empty_( )
print "a( 1 to 4 ) len ="; FB.ArrayLen( a( ) ); _
" bytes ="; FB.ArraySize( a( ) )
print "grid( 0..1, 0..2 ) len ="; FB.ArrayLen( grid( ) ); _
" bytes ="; FB.ArraySize( grid( ) )
print "an empty array len ="; FB.ArrayLen( empty_( ) )
dim as FBC.FBARRAY ptr d = FBC.ArrayDescriptorPtr( a( ) )
print "descriptor: dimensions ="; d->dimensions; _
" element_len ="; d->element_len
print " dimTb( 0 ) ="; d->dimTb( 0 ).lbound; " to"; _
d->dimTb( 0 ).ubound
a( 1 to 4 ) len =4 bytes =16
grid( 0..1, 0..2 ) len =6 bytes =24
an empty array len =0
descriptor: dimensions =1 element_len =4
dimTb( 0 ) = 1 to 4
An array variable is a descriptor: a pointer to the elements plus the bounds, the element size
and the number of dimensions. That is what travels when an array is passed, and what lbound reads.
fbc-int/array.bi exposes it. FB.ArrayLen is the total element count across all dimensions and
FB.ArraySize the total bytes; FBC.ArrayDescriptorPtr hands over the record itself. You will not
often need these, and when you do — writing a procedure that takes an array of any type, checking
what a redim actually allocated — nothing else will do.
When to reach for something else
An array is the right answer when the index is meaningful and the size is known, or grows rarely. It is the wrong answer when you find yourself writing the bookkeeping around it: a separate count of how many slots are really in use, a doubling strategy, a search to find where something is.
That bookkeeping is what containers.Array( of T ) already contains, along with Map, Set and
the rest of the containers in Chapter 24. The rule is the ordinary one: use the raw array while it
is simpler, and stop as soon as it is not.
What it costs
- Indexing is an address calculation, not a search. Cost does not depend on which element.
redim preservemay copy everything. Growing one at a time is quadratic; grow in chunks.- Row-major order is a performance fact, not trivia — put the last subscript in the inner loop.
-exxcosts a comparison per access. It is worth it while you are writing the program; measure before you decide it is not worth it afterwards.for each byrefavoids copying each element, which is free to write and matters for large ones.- Returning an array moves it. The cost is one descriptor, a fixed few hundred bytes, whatever the length of the array; the elements are never copied.
Summary
- An array carries bounds, not a length:
lboundanduboundare the first and last index. a( 10 )is eleven elements, indexed 0 to 10. Write both bounds.- Elements are zero-initialised; an initialiser in braces fills them in order, one level of braces
per dimension, and
1 to ...lets the list set the size. - Braces initialize arrays and nothing else: not a single value (
TKO0845), and not an assignment after the declaration (TKO0846). dim a( )is empty, withuboundbelowlbound, so loops over it run zero times.redimdiscards the contents;redim preservekeeps them.erasefrees a dynamic array and zeroes a fixed one.- Dimensions are comma-separated,
ubound( m, 2 )names one, and the last subscript varies fastest. - Arrays pass by reference as
a( ), carrying their bounds; a rank-2 parameter is writtenm( , ). - A function returns an array with
as int32( ).returntakes a brace list, a local array (moved, never copied) or another call, and the caller owns the result:vartakes it,m = f( )replaces whatmheld. A parameter orsharedarray cannot be returned (TKO0840). for eachgives copies,for each byrefgives the elements, and neither will walk a multi-dimensional array (TKO0608).-exxchecks every subscript, fixed or dynamic, against the bounds the array has at that moment. Without it, compare againstlboundanduboundyourself when the index is not obviously safe.- An array is not assigned or swapped whole (
TKO0930); copy it element by element. - The array variable is a descriptor;
fbc-int/array.biexposesFB.ArrayLen,FB.ArraySizeand the record itself. - Stop using a raw array when you start writing bookkeeping around it — a live count, a doubling
strategy, a search.
containers.Array( of T )and the containers in Chapter 24 already contain it, and growing one element at a time withredim preserveis quadratic.
Exercises
- Write
Reverse, which reverses an array in place and works whatever its bounds — test it on arrays indexed from 1, from 0 and from −2, and on an empty one. Use no literal 0 and no literal length. {solution="ex08-01"} - Transpose a two-dimensional array into a second array whose dimensions are the original's, swapped. Where do the new bounds come from? {solution="ex08-02"}
- Predict how many elements
dim as int32 a( 5 )has before running anything, then printlbound,uboundand the count. Rewrite the declaration so the answer is obvious. - Take the
redimline indynamic.tiko, addpreserve, and say in one sentence what each version is for. - Build a 200 × 200 array and sum it twice: once with the last subscript innermost, once with the loops swapped. Both give the same total. Which do you expect to be faster, and why?
- Write
Evens( byval n as int32 ), returning the firstneven numbers as an array indexed from 1, andDoubled( a( ) as int32 ), returning a new array of the same bounds with every element doubled. Takevar e = Evens( 4 )and printDoubled( e( ) )withfor each. Why canDoublednot simplyreturn a?
Strings and Text
Text is where a program meets people, and it is where most of the assumptions a programmer carries turn out to be wrong. A string is not a sequence of characters. A character is not a byte. The length of a word depends on what you are counting.
This chapter is long because that is the honest size of the subject, and because getting it wrong is silent: text that looks right on your machine arrives as nonsense on someone else's.
The short version, which the rest of the chapter earns: use ustring, and keep bytes and text
apart in your head.
Five ways to hold text
'' types.tiko -- five ways to hold text, and what len counts in each.
dim as string a = "hello" '' bytes, length-counted, grows
dim as ustring b = "hello" '' UTF-16 code units, grows
dim as zstring * 16 c = "hello" '' bytes, NUL-terminated, fixed room
dim as wstring * 16 d = "hello" '' wide characters, fixed room
dim as string * 8 e = "hello" '' bytes, fixed length, PADDED
print "string len ="; len( a )
print "ustring len ="; len( b )
print "zstring * 16 len ="; len( c )
print "wstring * 16 len ="; len( d )
print "string * 8 len ="; len( e ); " <- not 5"
print "string * 8 = ["; e; "]"
'' A string variable is a descriptor, not the characters themselves.
print "sizeof( string ) ="; sizeof( string )
print "sizeof( ustring ) ="; sizeof( ustring )
string len = 5
ustring len = 5
zstring * 16 len = 5
wstring * 16 len = 5
string * 8 len = 8 <- not 5
string * 8 = [hello ]
sizeof( string ) = 24
sizeof( ustring ) = 24
| Type | Holds | Sized |
|---|---|---|
ustring | UTF-16 code units | grows |
string | bytes | grows |
zstring * n | bytes, NUL-terminated | fixed room |
wstring * n | wide characters | fixed room |
string * n | bytes, padded to n | fixed length |
ustring is the one to reach for. It is the type this book uses everywhere, it holds text
rather than bytes, and len on it is a single read rather than a walk.
string is the byte type. It is right for file contents, network payloads and anything you do not
intend to interpret — and wrong for anything a person will read, unless you also know its encoding.
zstring and wstring exist for talking to C, which Chapter 29 covers. They are the shapes a C
library expects: a pointer to bytes ending in a NUL, or the platform's wide character.
A string * n is always exactly n characters. Assigning "hello" to a string * 8 gives
you "hello " — padded with spaces — and len reports 8, not 5.
That is the point of the type, and it is a trap when one arrives from a record layout or a
fixed-width file and gets compared against text that was not padded. "hello " = "hello" is
false. Wrap it in rtrim( ) at the boundary, once, rather than at every comparison.
A wstring character is 2 bytes on Windows and 4 bytes on Linux, because it follows the
platform's wchar_t. len counts characters on both, so code that uses len is portable and code
that assumes a byte width is not.
ustring is UTF-16 on every target, which is one reason to prefer it: its storage does not change
underneath you when the target does.
Text that spans lines
A line ends a statement in tiko, so an ordinary string literal ends where its line does. A query,
a usage message or a block of JSON is several lines of text, and writing it as one literal per
line, glued with & and chr( 10 ), buries the text under the punctuation. A block string
holds it the way it reads:
dim as ustring query = """
select id, name
from users
where name = "Ada"
"""
print query
print len( query )
dim as ustring poem = """
roses
are
red
"""
print poem;
print "---"
dim as ustring shifted = """
indented
"""
print "["; shifted; "]"
dim as ustring row = !"""
name\tvalue
one long line, \
continued
"""
print row
print instr( row, chr( 9 ) )
select id, name
from users
where name = "Ada"
48
roses
are
red
---
[ indented]
name value
one long line, continued
5
""" at the end of a line opens one, and the first line that starts with """ closes it.
Only spaces may follow the opening """. Anything after the closing one is ordinary code, so a
block string can sit inside an expression or an argument list like any other literal.
The closing line's indentation is the margin. It is removed from the front of every line, so
the text indents with the code around it, while indentation that belongs to the text — the two
extra spaces before from — is kept. A blank line is always allowed. Any other line that starts
left of the margin is an error:
'' A line that starts left of the closing line is an error.
dim as ustring note = """
first
second
"""
print note
blockmargin.tiko(4,1): error TKO0208: block string line does not start with the closing line's indentation
|
4 | second
| ^~~~~~~~~
The line breaks at the two ends are not part of the text. query is 48 characters and does
not end in a line feed. To end with one, leave an empty line before the closing line, as poem
does — which is why print poem; still put --- on a line of its own.
Inside the block, nothing needs escaping. A " is an ordinary character, so "Ada" is
written as it reads. Every line break is a single line feed, chr( 10 ), whatever line endings
the file was saved with, so the same source gives the same string on every machine.
!""" reads escapes, exactly as !"…" does: \t became the tab instr found at position 5.
A backslash at the very end of a line joins it to the next with no line break, for a long line
you want to wrap in the source.
The closing line sets the margin, not the first line. shifted closes at the left edge, so
nothing is removed: the four spaces that were only there to indent the code are now part of the
text. It compiles, and the output above shows them, inside the brackets. When a block string comes
out shifted right, look at where its closing """ sits.
The operations that come with the language
'' intrinsics.tiko -- the operations that come with the language.
dim as ustring s = "The quick brown fox"
print "len ="; len( s )
print "left( s, 3 ) = ["; left( s, 3 ); "]"
print "right( s, 3 ) = ["; right( s, 3 ); "]"
print "mid( s, 5, 5 ) = ["; mid( s, 5, 5 ); "]"
print "mid( s, 11 ) = ["; mid( s, 11 ); "]"
print "instr( s, ""brown"" ) ="; instr( s, "brown" )
print "instr( 5, s, ""o"" ) ="; instr( 5, s, "o" )
print "instrrev( s, ""o"" ) ="; instrrev( s, "o" )
print "instr( s, ""zebra"" ) ="; instr( s, "zebra" ); " <- not found"
print "ucase = ["; ucase( s ); "]"
print "lcase = ["; lcase( s ); "]"
dim as ustring padded = " spaces "
print "trim = ["; trim( padded ); "]"
print "ltrim = ["; ltrim( padded ); "]"
print "rtrim = ["; rtrim( padded ); "]"
print "trim any = ["; trim( "xxcorexx", any "x" ); "]"
print "space( 3 ) = ["; space( 3 ); "]"
print "string( 5, ""-"" ) = ["; string( 5, "-" ); "]"
print "chr( 65 ) = ["; chr( 65 ); "]"
print "asc( ""A"" ) ="; asc( "A" )
len = 19
left( s, 3 ) = [The]
right( s, 3 ) = [fox]
mid( s, 5, 5 ) = [quick]
mid( s, 11 ) = [brown fox]
instr( s, "brown" ) = 11
instr( 5, s, "o" ) = 13
instrrev( s, "o" ) = 18
instr( s, "zebra" ) = 0 <- not found
ucase = [THE QUICK BROWN FOX]
lcase = [the quick brown fox]
trim = [spaces]
ltrim = [spaces ]
rtrim = [ spaces]
trim any = [core]
space( 3 ) = [ ]
string( 5, "-" ) = [-----]
chr( 65 ) = [A]
asc( "A" ) =65
Two conventions to absorb, because everything else follows from them.
Positions start at 1. mid( s, 5, 5 ) means five characters starting at the fifth. This is not
an accident to be worked around — it is consistent across every intrinsic here, and mixing it with
zero-based thinking is the single commonest source of off-by-one bugs in string code.
Not found is 0. instr returns 0 when the needle is absent, which is never a valid position,
so if instr( s, x ) > 0 is the test. There is no separate "not found" value to remember.
mid also works on the left of an assignment, where it overwrites in place:
'' midstatement.tiko -- mid on the left of an assignment overwrites
'' in place, without rebuilding the string.
dim as string s = "abcdef"
mid( s, 2, 3 ) = "XYZ"
print "mid( s, 2, 3 ) = ""XYZ"" -> ["; s; "]"
dim as string t = "abcdef"
mid( t, 2 ) = "XY" '' no length: as much as will fit
print "mid( t, 2 ) = ""XY"" -> ["; t; "]"
'' It never changes the length. A replacement longer than the room
'' available is truncated rather than growing the string.
dim as string u = "abc"
mid( u, 2 ) = "ZZZZZZ"
print "overlong replacement -> ["; u; "] len ="; len( u )
mid( s, 2, 3 ) = "XYZ" -> [aXYZef]
mid( t, 2 ) = "XY" -> [aXYdef]
overlong replacement -> [aZZ] len = 3
The mid statement never changes the length. A replacement longer than the room available is
truncated, as the third case shows. That is what makes it cheap: nothing is reallocated. It works
the same on a ustring, counting UTF-16 units.
Numbers and text
'' numbers.tiko -- text to numbers and back.
print "str( 42 ) = ["; str( 42 ); "]"
print "str( -7 ) = ["; str( -7 ); "]"
print "str( 3.5 ) = ["; str( 3.5 ); "]"
print "print puts a space where the sign would be:"; 42
print "val( ""42"" ) ="; val( "42" )
print "val( ""42abc"" ) ="; val( "42abc" ); " <- stops at 'a'"
print "val( ""abc"" ) ="; val( "abc" ); " <- and a word gives 0"
print "valint( ""42"" ) ="; valint( "42" )
print "vallng( ""4200000000"" ) ="; vallng( "4200000000" )
print "valuint( ""&hFF"" ) ="; valuint( "&hFF" )
print "hex( 255 ) = ["; hex( 255 ); "]"
print "hex( 255, 4 ) = ["; hex( 255, 4 ); "]"
print "oct( 8 ) = ["; oct( 8 ); "]"
print "bin( 5 ) = ["; bin( 5 ); "]"
print using "pi to two places: ###.##"; 3.14159
print using "a whole number: ###"; 42
str( 42 ) = [42]
str( -7 ) = [-7]
str( 3.5 ) = [3.5]
print puts a space where the sign would be: 42
val( "42" ) = 42
val( "42abc" ) = 42 <- stops at 'a'
val( "abc" ) = 0 <- and a word gives 0
valint( "42" ) = 42
vallng( "4200000000" ) = 4200000000
valuint( "&hFF" ) =255
hex( 255 ) = [FF]
hex( 255, 4 ) = [00FF]
oct( 8 ) = [10]
bin( 5 ) = [101]
pi to two places: 3.14
a whole number: 42
str converts a number to text with no padding. print is different — it reserves a column for
the sign, which is why printed positive numbers appear with a leading space throughout this book.
hex, oct and bin render a value in another base, and a second argument sets the width:
hex( 255, 4 ) is 00FF.
print using lays out a line against a picture: ###.## is a number with two decimals, & is a
string. It is the quickest way to get a column to line up. For a line that mixes widths, bases and
decimals, strings.Format, later in this chapter, says it in one place.
val( ) cannot fail. It reads as far as it can and returns what it got, so val( "42abc" ) is
42, and val( "abc" ) is 0 — indistinguishable from the text "0".
That is fine for input you produced and dangerous for input you did not. When a program has to know
whether the text was a number, val alone cannot tell it. strings.IsNumeric( ) answers that
question, and Chapter 18's Outcome type carries the answer and the failure together, which is the
shape to reach for once parsing can go wrong in more than one way.
What len is actually counting
Here is the idea the rest of the chapter depends on. A string has three different lengths, and which one you get depends on the type holding it.
'' codeunits.tiko -- what len actually counts, in each type.
''
'' The text is built from explicit bytes so that nothing here depends
'' on how this file was saved: these ARE the UTF-8 bytes for the two
'' strings, listed one by one.
'' "cafe" with an e-acute: 63 61 66 C3 A9
dim as string cafeBytes = chr( &h63, &h61, &h66, &hC3, &hA9 )
'' "a", U+1F600 GRINNING FACE, "b": 61 F0 9F 98 80 62
dim as string faceBytes = chr( &h61, &hF0, &h9F, &h98, &h80, &h62 )
dim as ustring cafe = cafeBytes
dim as ustring face = faceBytes
print "cafe: as string (UTF-8 bytes) len ="; len( cafeBytes )
print " as ustring (code units) len ="; len( cafe )
print " code units:";
for i as int32 = 1 to len( cafe ) : print asc( cafe, i ); : next
print
print "face: as string (UTF-8 bytes) len ="; len( faceBytes )
print " as ustring (code units) len ="; len( face )
print " code units:";
for i as int32 = 1 to len( face ) : print asc( face, i ); : next
print
print " -- 55357 and 56832 are ONE character, stored as a pair"
'' Converting back is lossless.
dim as string roundTrip = face
print "round trip back to string: len ="; len( roundTrip ); _
" equal = "; cbool( roundTrip = faceBytes )
cafe: as string (UTF-8 bytes) len = 5
as ustring (code units) len = 4
code units: 99 97 102 233
face: as string (UTF-8 bytes) len = 6
as ustring (code units) len = 4
code units: 97 55357 56832 98
-- 55357 and 56832 are ONE character, stored as a pair
round trip back to string: len = 6 equal = true
The listing builds its text from explicit byte values, so nothing depends on how the file was saved — those really are the UTF-8 bytes.
Read the two cases:
caféis 4 characters. As UTF-8 bytes it is 5 —éneeds two. As austringit is 4 code units. Here characters and code units agree.a+ U+1F600 +bis 3 characters. As UTF-8 bytes it is 6. As austringit is 4 code units — because U+1F600 is outside the Basic Multilingual Plane and UTF-16 stores it as a surrogate pair, two units that mean one character.
len on a ustring counts code units, not characters, and mid indexes them.
For the Basic Multilingual Plane — which is every European, Cyrillic, Greek, Hebrew, Arabic, CJK and Hangul character — those are the same number, which is why the bug hides. Emoji, historic scripts, many mathematical symbols and some rarer CJK are outside it, and there one character is two units.
So mid( face, 2, 1 ) hands back half a surrogate pair: a value that is not a character and
renders as the replacement character U+FFFD. Splitting a ustring at an arbitrary position can cut a character in two.
When the positions come from searching — instr, Split, a delimiter you put there yourself —
you are safe, because those land on boundaries. When they come from arithmetic, such as "the first
20 characters", they are not.
Conversion between string and ustring is UTF-8 in both directions, on every target, and it
is lossless: the round trip in the listing compares equal.
That is the rule worth memorising. A ustring assigned to a string is encoded as UTF-8; a
string assigned to a ustring is decoded as UTF-8. There is no locale involved and no target
dependence.
Bytes that are not UTF-8
'' ansibytes.tiko -- a byte string is not automatically UTF-8.
''
'' Both of these hold "cafe" with an e-acute. One was saved by a
'' program that wrote Latin-1; the other is UTF-8. Converting each to
'' ustring shows what the difference costs.
dim as string latin1 = chr( &h63, &h61, &h66, &hE9 )
dim as string utf8 = chr( &h63, &h61, &h66, &hC3, &hA9 )
dim as ustring fromLatin1 = latin1
dim as ustring fromUtf8 = utf8
print "Latin-1 bytes:"; len( latin1 ); " -> ustring units:"; _
len( fromLatin1 )
print " code units:";
for i as int32 = 1 to len( fromLatin1 )
print asc( fromLatin1, i );
next
print
print "UTF-8 bytes: "; len( utf8 ); " -> ustring units:"; _
len( fromUtf8 )
print " code units:";
for i as int32 = 1 to len( fromUtf8 ) : print asc( fromUtf8, i ); : next
print
print
print "65533 is U+FFFD, the replacement character: the byte was not"
print "valid UTF-8, so the text it stood for is gone."
Latin-1 bytes: 4 -> ustring units: 4
code units: 99 97 102 65533
UTF-8 bytes: 5 -> ustring units: 4
code units: 99 97 102 233
65533 is U+FFFD, the replacement character: the byte was not
valid UTF-8, so the text it stood for is gone.
Both of those strings hold the word café. One was written by something that used Latin-1, where
é is the single byte E9. The other is UTF-8, where it is C3 A9.
Converting each to ustring decodes as UTF-8 — because that is the only rule — so the Latin-1 byte
is not valid input and becomes 65533, U+FFFD, the replacement character. The text it stood for
is gone, and nothing was reported.
A string does not know its encoding, and the conversion does not ask.
Bytes from a file written years ago, from a Windows API that returns the ANSI code page, or from a
database column configured for Latin-1, are not UTF-8 — and assigning them to a ustring destroys
every non-ASCII character in them, silently and unrecoverably.
Convert at the boundary, with knowledge of what the bytes are, and keep everything inside the
program as ustring. utf_conv.bi has the routines for encodings other than UTF-8. The one thing
never to do is assume.
The standard library
'' stdstrings.tiko -- what the standard library adds.
#import "std/strings"
dim as ustring s = "alpha,beta,gamma"
'' Split hands back an Array( of ustring ) -- the container from
'' Chapter 24. Here it is only asked for its count and its elements.
var parts = strings.Split( s, "," )
print "Split gave"; parts.Count( ); " parts:";
for i as int32 = 0 to parts.Count( ) - 1
print " ["; parts[ i ]; "]";
next
print
print "Join = ["; strings.Join( parts, " | " ); "]"
print "Replace = ["; strings.Replace( s, "beta", "BETA" ); "]"
print "Between = ["; strings.Between( "name(value)", "(", ")" ); "]"
print "StartsWith = "; strings.StartsWith( s, "alpha" )
print "EndsWith = "; strings.EndsWith( s, "gamma" )
print "Contains = "; strings.Contains( s, "bet" )
print "IsNumeric = "; strings.IsNumeric( "42" ); _
strings.IsNumeric( "4a" )
print "PadLeft = ["; strings.PadLeft( "7", 4, "0" ); "]"
print "PadRight = ["; strings.PadRight( "ab", 5, "." ); "]"
print "PadCenter = ["; strings.PadCenter( "ab", 6, "." ); "]"
print "Repeat = ["; strings.Repeat( 3, "ab" ); "]"
print "Reverse = ["; strings.Reverse( "abc" ); "]"
print "Wrap = ["; strings.Wrap( "hi", "<", ">" ); "]"
print "Escape = ["; strings.Escape( !"a\tb" ); "]"
Split gave 3 parts: [alpha] [beta] [gamma]
Join = [alpha | beta | gamma]
Replace = [alpha,BETA,gamma]
Between = [value]
StartsWith = true
EndsWith = true
Contains = true
IsNumeric = truefalse
PadLeft = [0007]
PadRight = [ab...]
PadCenter = [..ab..]
Repeat = [ababab]
Reverse = [cba]
Wrap = [<hi>]
Escape = [a\tb]
#import "std/strings" brings in around forty-five operations that the language itself does not
have. The ones above are the everyday set; the module also carries Extract, Remain, Tally,
InsertAt, DeleteAt, Shrink, VerifySet, the *Chars family that works on sets of characters
rather than substrings, and Unescape/Unwrap to undo their opposites.
Split hands back an Array( of ustring ) — the container Chapter 24 covers. Here it is only
asked for .Count( ) and [ i ], which is all you need to walk it.
Join( Split( s, d ), d ) gives back s, for every s and every non-empty d. That round-trip
property is worth knowing, because it tells you what Split does with the awkward cases: "a,,c"
is three fields with an empty one in the middle, and "a," is two.
Part of a string, without a copy
mid, left, right and Split all hand back new strings. Each one allocates and copies, and a
program that takes a line apart word by word pays that once per word. Most of those words are
looked at once and thrown away. A slice names part of a string where it already is:
'' slices.tiko -- naming part of a string without copying it.
dim as ustring s = "hello, world"
dim as uslice word = s[7 to 11] '' units 7 to 11, both included
print "word = ["; word; "]"
print "len( word ) = "; str( len( word ) )
print "word[0] = "; str( word[0] ) '' a code unit
print "word[1 to 3] = ["; word[1 to 3]; "]"
print "s[0 to 4] = ["; s[0 to 4]; "]"
print "mid( s, 1, 5 ) = ["; mid( s, 1, 5 ); "]"
'' A slice compares, joins and prints like the text it names.
print "word = ""world"" is "; cbool( word = "world" )
print "<" & word & ">"
'' Assigning it to a ustring makes an owned copy.
dim as ustring copy = word
copy &= "!"
print "copy = ["; copy; "] s = ["; s; "]"
'' A slice of a mutable string writes straight into it.
word[0] = asc( "W" )
print "s is now ["; s; "]"
'' An empty slice: hi is one less than lo.
print "s[3 to 2] has "; str( len( s[3 to 2] ) ); " units"
word = [world]
len( word ) = 5
word[0] = 119
word[1 to 3] = [orl]
s[0 to 4] = [hello]
mid( s, 1, 5 ) = [hello]
word = "world" is true
<world>
copy = [world!] s = [hello, world]
s is now [hello, World]
s[3 to 2] has 0 units
s[7 to 11] is the units from 7 to 11, both ends included, counting from 0 — the same counting
as s[i], which reads one unit. That is the one place in this chapter where positions start at 0,
and it is why the listing prints mid( s, 1, 5 ) beside s[0 to 4]: the same five characters,
one counted from 1 and copied, the other counted from 0 and not.
There are three slice types, one for each width of text:
| Type | Slices | Unit |
|---|---|---|
uslice | ustring | a UTF-16 code unit |
slice | string, zstring, a zstring ptr | a byte |
wslice | wstring, a wstring ptr | the platform's wide character |
A slice is small — a pointer, a length and one more word the runtime checks use — and it is never
a copy. It prints, compares and joins with & as the text it names, and len on it is a single
read. Indexing it gives a code unit, a number, as indexing a ustring does. Slicing a slice gives
a smaller view of the same text.
A slice is as writable as the string it came from. word[0] = asc( "W" ) changed s, because
word is part of s. A slice of a const string, a literal or a temporary is a const uslice,
and writing through it is refused. Going the other way — from const back to writable — is error
TKO1052.
To keep the text, assign the slice to a ustring. That makes the copy, once, at the point where
you decide the text is worth keeping.
s[3 to 2] is empty: a slice whose end is one before its start has no units. So
s[0 to len( s ) - 1] is the whole of s even when s is empty. Anything else outside the
string is runtime error 6 — checked whenever a slice is made, in every build — and a range the
compiler can see is wrong, such as 5 to 2 or past the end of a string * 4, is TKO1051 before
the program runs.
Slices in the standard library
std/strings has a set of functions that take and return slices, so a whole parse can run
without a single copy:
'' slicestd.tiko -- reading settings with no copies at all.
#import "std/strings"
dim as ustring config = !" name = tiko \n\n version=0.1 \n bad line"
for each line_ as const uslice in _
strings.SplitSlices( config[0 to len( config ) - 1], !"\n" )
dim as const uslice t = strings.TrimSpace( line_ )
if len( t ) = 0 then continue for
dim as int32 eq = strings.Find( t, "=" )
if eq < 0 then
print "skipped: ["; t; "]"
continue for
end if
dim as const uslice key = strings.TrimSpace( t[0 to eq - 1] )
dim as const uslice value = _
strings.TrimSpace( t[eq + 1 to len( t ) - 1] )
print "["; key; "] -> ["; value; "]"
next
[name] -> [tiko]
[version] -> [0.1]
skipped: [bad line]
TrimSpace, TrimStart and TrimEnd return a slice of their argument with the spaces, tabs,
carriage returns and line feeds removed from the ends. Find returns the position of the first
match, counting from 0, or -1 when there is none — slice counting, not instr's. Equals,
StartsWith and EndsWith compare, with an optional true to ignore the case of A to Z.
SplitSlices is Split without the array. It hands the parts to a for each one at a time, each
a slice of the original. An empty part is still a part, so the blank line in config arrives as an
empty slice — that is what the len( t ) = 0 test is for.
Every one of these takes a const slice, and any string can be passed for one. A string of the
same width is viewed where it is; any other is converted once, for the call.
SplitSlices( config[0 to len( config ) - 1], !"\n" ) passes a uslice, not the ustring
itself. A ustring and a plain literal together leave the compiler two equally good choices —
view the first and convert the second, or the other way round — and it refuses to guess
(TKO0660). Slicing the whole string first settles it.
A slice cannot outlive its text
A slice points into memory it does not own. If that memory is freed or moved while the slice is still in use, the slice reads whatever is there now. The compiler keeps track of where every slice came from and refuses each way that could happen:
'' sliceescape.tiko -- a slice of a local cannot leave its function.
function FirstWord( byval s as ustring ) as const uslice
return s[0 to 2] '' error: s is this call's own copy
end function
print FirstWord( "the end" )
sliceescape.tiko(4,13): error TKO1059: cannot return a slice of local 's'
|
4 | return s[0 to 2] '' error: s is this call's own copy
| ^
s is passed byval, so it belongs to the call and is gone when the call returns. Make it
byref s as const ustring and the function compiles: the text then belongs to the caller, which
still has it after the call.
The other rules follow from the same idea. A slice may be a local variable, a parameter or a
result. It may not be shared or static, an array element, an element of a container, or
allocated with new (TKO1054), because any of those can outlive the text with nothing to say
when. A slice of a string declared in an inner block cannot be kept in a variable declared outside
it (TKO1057). A slice of a temporary — (a & b)[0 to 3] — can be printed or passed in the same
statement, and cannot be kept at all (TKO1058).
The last rule is about the text changing underneath the slice:
'' slicelock.tiko -- a string cannot grow while a slice reads it.
dim as ustring s = "hello"
dim as uslice first3 = s[0 to 2]
s &= ", world" '' error: may move the text first3 points into
print first3
slicelock.tiko(6,1): error TKO1062: 's' cannot be changed while slice 'first3' borrows from it (last used at line 7)
|
6 | s &= ", world" '' error: may move the text first3 points into
| ^
Appending to a ustring may move its text to a bigger block of memory, which would leave first3
pointing at the old one. So from the moment a slice is made until the last line that reads it, the
string it came from cannot be assigned, appended to, swapped, read into, or passed byref to a
procedure that might do any of those. Move the print above the &= and the program compiles: the
slice is finished with before the string grows. Writing single units in place — s[i] = x, the
mid statement — never moves the text, and stays allowed.
Types that hold a slice
A field of an ordinary type can outlive anything, so a slice field is refused there (TKO1055). A
type declared as view may hold one, and is then held to the same rules as a slice:
'' lexer.tiko -- a type that holds a slice is a view.
type Lexer as view
src as const uslice '' the text being read, not a copy
pos as int32
end type
'' The next run of non-blank units, or an empty slice at the end.
function NextWord( byref lx as Lexer ) as const uslice
while lx.pos < len( lx.src ) andalso lx.src[lx.pos] = 32
lx.pos += 1
wend
dim as int32 start = lx.pos
while lx.pos < len( lx.src ) andalso lx.src[lx.pos] <> 32
lx.pos += 1
wend
return lx.src[start to lx.pos - 1]
end function
dim as ustring text = " let total = price * 3 "
dim as Lexer lx
lx.src = text[0 to len( text ) - 1]
do
dim as const uslice w = NextWord( lx )
if len( w ) = 0 then exit do
print "<"; w; ">";
loop
print
<let><total><=><price><*><3>
Lexer walks the text without copying any of it, and NextWord returns a slice of the text the
lexer holds — which is the caller's, so it may. A Lexer cannot be shared, put in an array or
stored in an ordinary type, exactly as a slice cannot, and a type that holds a Lexer must say
as view too.
What the compiler cannot see
The compiler follows a string through one procedure. It does not follow it into another, so it
cannot know that Note appends to the same logText that head points into:
'' slicecheck.tiko -- what -exx catches that the compiler cannot.
#cmdline "-exx"
dim shared as ustring logText
sub Note( byref msg as const ustring )
logText &= msg & " -- long enough to move the text elsewhere"
end sub
sub Report( )
logText = "start"
dim as const uslice head = logText[0 to 4]
Note( "grown" ) '' grows logText behind head's back
print head '' head now points at freed memory
end sub
Report( )
Aborting due to runtime error 6 (out of bounds array access) at line 14 of slicecheck.tiko::REPORT()
Every slice of a string or ustring remembers which string it came from. Under -exx, each read
of the slice first checks that its text is still inside that string, and stops the program with
runtime error 6 when it is not. A v[i] past the end of a slice is caught the same way.
Without -exx, slicecheck.tiko does not stop. It prints whatever is in the memory head
points at. On a good day that is still start, because nothing has reused the freed block yet.
That is the worst outcome: the program looks right, and the bug waits for a longer log.
Build with -exx while you develop, as Chapter 1 recommends. It costs a few compares per read of a
slice, which is the price of being told. A slice of a zstring ptr has no string to check against
at all — the pointer is trusted as it is everywhere else.
Laying out a line
str, hex and print using each do one job. A line that wants a name padded to a width, a
number in hexadecimal and a price to two decimals becomes three calls glued with &, and the shape
of the line disappears into them. strings.Format takes the shape as one string — a format
string — and the values to put in it:
'' format.tiko -- text laid out by a format string.
#import "std/strings"
dim as ustring who = "Quinn"
dim as int32 n = 42
dim as float64 x = 3.14159265358979
'' {} takes the next value; {n} takes value n, counting from 0.
print strings.Format( "Hello, {}!", who )
print strings.Format( "{1} before {0}", "b", "a" )
'' Fill, alignment and width, after a ':'.
print strings.Format( "[{:>6}] [{:<6}] [{:^6}]", n, n, n )
print strings.Format( "[{:*^9}] [{:.3}]", "hi", who )
'' Integers in other bases, a sign, zero padding.
print strings.Format( "{:x} {:#x} {:08b} {:+}", 255, 255, 5, n )
print strings.Format( "[{:+06}] [{:c}]", n, 65 )
'' Floats: fixed, exponent, and the default -- the shortest text
'' that reads back as the same value.
print strings.Format( "{:.2f} {:e} {}", x, x, 0.1 + 0.2 )
print strings.Format( "{} {:d}", true, true )
print strings.Format( "{{}} is a literal pair of braces" )
Hello, Quinn!
a before b
[ 42] [42 ] [ 42 ]
[***hi****] [Qui]
ff 0xff 00000101 +42
[+00042] [A]
3.14 3.141593e+00 0.30000000000000004
true 1
{} is a literal pair of braces
Each {} is replaced by the next value. {0}, {1} and so on name a value by position, counting
from 0, so the same value can appear twice or the order can change; a format string uses one style
or the other, never both. {{ and }} are literal braces. Values left over are not an error.
After a : comes the spec, a run of optional fields in a fixed order:
| Field | Meaning |
|---|---|
| fill and align | < left, > right, ^ centre, padded with the fill character — a space unless one is written before the alignment, as in {:*^9} |
| sign | + puts a sign on every number |
# | 0x, 0b or 0 before a hexadecimal, binary or octal number |
0 | pads a number with zeros after its sign: {:+06} |
| width | the least number of characters |
.precision | digits after the point for a float; the most characters taken from a string |
| type | d, b, o, x, X and c for integers; f, e, g and a for floats; s for text |
Numbers align right and everything else left unless the spec says otherwise. A boolean is the
word true or false, or 1 and 0 under d.
The spec language is C++'s std::format, so a format string written for one reads the same in the
other.
A float with no type is printed as the shortest text that reads back as the same value. That
is why 0.1 + 0.2 appears with its last digit, 0.30000000000000004: the sum is not 0.3, and
Format does not hide that. Give it a precision — {:.2f} — when a reader wants two decimals.
A width can come from a value as well, with {:{}}. That is what makes a table line up when the
widest entry is known only at run time:
'' formattable.tiko -- columns that line up, whatever the values.
#import "std/strings"
dim as ustring item( 0 to 2 ) = { "tea", "biscuits", "jam" }
dim as int32 qty( 0 to 2 ) = { 2, 12, 1 }
dim as float64 price( 0 to 2 ) = { 3.5, 0.25, 4.75 }
'' The width can come from a value: {:{}} takes it from the next one.
dim as int32 w = 10
print strings.Format( "{:<{}}{:>5}{:>9}", "item", w, "qty", "each" )
for i as int32 = 0 to 2
print strings.Format( "{:<{}}{:>5}{:>9.2f}", _
item( i ), w, qty( i ), price( i ) )
next
item qty each
tea 2 3.50
biscuits 12 0.25
jam 1 4.75
Checked when it compiles
A format string written as a literal is read by the compiler, and checked against the values that follow it:
'' formatcheck.tiko -- a literal format string is checked when the
'' program is compiled.
#import "std/strings"
dim as int32 done = 3
print strings.Format( "{} of {} done", done ) '' error: one value
formatcheck.tiko(6,30): error TKO0851: format string: no argument 1 (1 given)
|
6 | print strings.Format( "{} of {} done", done ) '' error: one value
| ^
The caret is on the field that has no value. Every way a format string can be wrong has its own id,
TKO0847 to TKO0858 in Appendix B: a { with no }, a spec that does not parse, a type the value
cannot take — {:x} on text, {:f} on an integer — and a precision on an integer, among others.
A format string held in a variable is checked only when it runs — and a bad one stops the program.
The compiler can read a literal. It cannot read a variable, so a format string that arrives in one
— built by concatenation, loaded from a file — is not checked until Format meets it. Format
then treats it as the bug it would have been in a literal: it writes the reason and the offset to
the error output and stops the program with runtime error 1, which Chapter 18 explains.
That is right for a format string the program wrote. It is wrong for one that came from outside, where a mistake is somebody else's and the program should carry on.
Format strings that are data
A format string read from a translation file or a user's settings is data, and a bad one is not a
bug in the program. strings.TryFormat returns the text or the reason, and never stops anything:
'' tryformat.tiko -- a format string that is data, not code.
#import "std/strings"
#import "std/outcome"
sub Render( byref template as const ustring )
var r = strings.TryFormat( template, "Quinn", 3 )
if r.IsOk( ) then
print "ok: "; r.Value( )
else
print "refused: "; r.Failure( ).message; _
" (offset"; r.Failure( ).offset; ")"
end if
end sub
'' Templates as they might arrive from a translation file.
Render( "{} has {} messages" )
Render( "{1} messages for {0}" )
Render( "{} has {} messages, {}" )
Render( "{:d} has {} messages" )
ok: Quinn has 3 messages
ok: 3 messages for Quinn
refused: no argument 2 (2 given) (offset 20)
refused: 'd' is no presentation type for text (offset 2)
The answer is a Result — Chapter 18's type for a value or the reason there isn't one. IsOk( )
says which, Value( ) is the text, and Failure( ) carries a message, an offset into the
format string counted from 0, and a code to test against. TryFormat takes a plain ustring, so
even a literal passed to it is left for run time.
Reading a table
'' csvreport.tiko -- reading a table and printing a report.
#import "std/strings"
dim as ustring text = _
"name,city,year" & chr( 10 ) & _
"Ada,""London, UK"",1815" & chr( 10 ) & _
"Grace,New York,1906" & chr( 10 ) & _
"Barbara,""Wilmington, DE"",1925"
var rows = strings.SplitCsv( text )
print "rows read:"; rows.Count( ); " (including the header)"
print
'' Row 0 is the header. Its field names become the column labels.
var header = rows[ 0 ].fields
for r as int32 = 1 to rows.Count( ) - 1
var f = rows[ r ].fields
for c as int32 = 0 to f.Count( ) - 1
print strings.PadRight( header[ c ], 6, " " ); ": "; f[ c ]
next
print
next
'' A quoted field keeps its comma. Writing one back out re-quotes it.
print "field 1 of row 1 = ["; rows[ 1 ].fields[ 1 ]; "]"
rows read: 4 (including the header)
name : Ada
city : London, UK
year : 1815
name : Grace
city : New York
year : 1906
name : Barbara
city : Wilmington, DE
year : 1925
field 1 of row 1 = [London, UK]
CSV looks like a job for Split and is not. A field may be quoted, and a quoted field may contain
the delimiter — "London, UK" is one field, not two. Anything that splits on commas gets that
wrong, and gets it wrong on exactly the rows a test file does not contain.
strings.SplitCsv( text ) parses a whole table and returns rows, each with a .fields array.
strings.SplitCsvRow( line ) parses a single record and returns the fields directly. Both handle
quoting, embedded delimiters and embedded newlines. Going the other way, strings.JoinCsvRow
quotes each field that needs it, and strings.CsvWriter writes rows to a file (Chapter 25).
A string as bytes
'' binary.tiko -- a string as a container of bytes.
'' mk* packs a number into its raw bytes; cv* reads it back.
dim as string packed = mkl( 70000 ) & mkd( 3.5 ) & mkshort( 300 )
print "packed bytes ="; len( packed )
print "cvl ="; cvl( mid( packed, 1, 4 ) )
print "cvd ="; cvd( mid( packed, 5, 8 ) )
print "cvshort ="; cvshort( mid( packed, 13, 2 ) )
'' Fixed-length strings can be aligned within their room.
dim as string * 10 slot
lset slot, "left"
print "lset = ["; slot; "]"
rset slot, "right"
print "rset = ["; slot; "]"
'' strptr and sadd reach the bytes themselves.
dim as string s = "hello"
print "first byte via sadd ="; peek( ubyte, sadd( s ) )
print "asc of it ="; asc( s, 1 )
packed bytes = 14
cvl = 70000
cvd = 3.5
cvshort = 300
lset = [left ]
rset = [ right]
first byte via sadd =104
asc of it =104
The mk* family packs a number into its raw bytes and cv* reads it back: mkl/cvl for 32-bit,
mkshort/cvshort for 16, mkd/cvd and mks/cvs for floating point, mki/cvi for the
pointer-sized integer. This is how a record gets written to a binary file, which Chapter 25 comes
back to.
lset and rset align text within a fixed-length string. strptr and sadd give the address of
the characters themselves, which is what a C function needs — and what makes it your
responsibility to keep the string alive while the pointer is in use.
Bytes as text: base64, hex and URLs
Some places take only text: a JSON field, an e-mail body, a URL, a configuration file. Bytes
that are not text — a hash, a key, an image — have to be spelled in characters that survive the
trip. std/codec has the three common spellings:
#import "std/codec"
#import "std/containers"
'' Four bytes that no text format can carry as they are.
dim raw as containers.Array( of ubyte )
dim b( 0 to 3 ) as uint8 = { &h00, &hFB, &hFF, &h7F }
for i as int32 = 0 to 3
raw.Push( b( i ) )
next
print "hex: "; codec.HexEncode( raw )
print "base64: "; codec.Base64Encode( raw )
print "url-safe "; codec.Base64Encode( raw, codec.B64_URL, false )
'' Text is encoded as its UTF-8 bytes, and decodes back the same.
dim word as ustring = "na" & wchr( &hEF ) & "ve"
dim enc as ustring = codec.Base64Encode( word )
print "naive: "; enc
dim back as ustring = codec.Base64DecodeText( enc ).Value( )
print "same: "; cbool( back = word )
hex: 00fbff7f
base64: APv/fw==
url-safe APv_fw
naive: bmHDr3Zl
same: true
Hex writes each byte as two digits: twice the size, and readable. Base64 packs three bytes
into four characters, a third bigger than the bytes; its URL-safe alphabet swaps + and / for
- and _, and usually drops the = padding. Base32 (Base32Encode) uses only capital
letters and digits, for codes that people type. Each has an …Encode, a …Decode that fills a
containers.Array( of ubyte ), and a …DecodeText that returns a ustring — text is encoded as
its UTF-8 bytes, so …DecodeText gives back exactly the text that went in.
A decoder can be lenient or exact. These are exact:
#import "std/codec"
'' A decoder that guesses hides corrupt data. This one refuses.
dim inputs( 0 to 3 ) as ustring = _
{ "aGVsbG8=", "aGVsbG8", "aGVs bG8=", "aGVsbG9=" }
for each t in inputs
var r = codec.Base64DecodeText( t )
if r.IsOk( ) then
print t; " -> "; r.Value( )
else
print t; " -> "; r.Failure( ).Message( )
end if
next
aGVsbG8= -> hello
aGVsbG8 -> Base64DecodeText: invalid padding
aGVs bG8= -> Base64DecodeText: invalid character at 5
aGVsbG9= -> Base64DecodeText: non-zero trailing bits at 7
The second input lost its padding, the third gained a space, and the fourth has a final character
whose unused low bits are not zero — three different damages, each refused with the position of
the first bad character. A lenient decoder would have returned something for all three, and the
fourth would have decoded to hello, so a corrupted value would have compared equal to the
original. When the input is known to be loose, say so: DECODE_IGNORE_WHITESPACE and
DECODE_ALLOW_MISSING_PAD, combined with or, relax exactly those two rules.
Text inside a URL needs a different treatment. Percent-encoding keeps letters, digits and
- . _ ~, and writes every other byte of the UTF-8 as % and two hex digits:
#import "std/codec"
dim dish as ustring = "fish & chips"
print "component: "; codec.PercentEncode( dish )
print "form: "; codec.PercentEncode( dish, codec.PCT_QUERY )
print "path: "; _
codec.PercentEncode( "menu/" & dish, codec.PCT_PATH )
var r = codec.PercentDecode( "fish+%26+chips", codec.PCT_QUERY )
print "decoded: "; r.Value( )
component: fish%20%26%20chips
form: fish+%26+chips
path: menu/fish%20&%20chips
decoded: fish & chips
The mode decides what else survives. PCT_COMPONENT, the default, is for one value placed inside a
URL, and escapes / and & because they would end it. PCT_PATH keeps the path's own /.
PCT_QUERY is the encoding an HTML form submits, with a space as +. Decode with the mode you
encoded with: + means a space only in PCT_QUERY.
Working with letters
'' caesar.tiko -- shifting letters, which is arithmetic on asc codes.
function Shift( byref text as ustring, byval by as int32 ) as ustring
dim as ustring built = ""
for i as int32 = 1 to len( text )
dim as int32 c = asc( text, i )
if c >= asc( "a" ) andalso c <= asc( "z" ) then
c = asc( "a" ) + ( ( c - asc( "a" ) + by + 26 ) mod 26 )
elseif c >= asc( "A" ) andalso c <= asc( "Z" ) then
c = asc( "A" ) + ( ( c - asc( "A" ) + by + 26 ) mod 26 )
end if
built &= chr( c )
next
return built
end function
dim as ustring plain = "Attack at dawn!"
dim as ustring coded = Shift( plain, 3 )
print "plain : "; plain
print "shifted : "; coded
print "back : "; Shift( coded, -3 )
print "round trip holds: "; cbool( Shift( coded, -3 ) = plain )
plain : Attack at dawn!
shifted : Dwwdfn dw gdzq!
back : Attack at dawn!
round trip holds: true
asc and chr move between a character and its number, which turns text into arithmetic. The
+ 26 before the mod is there because Chapter 5's rule still applies: mod takes the sign of its
left operand, so shifting backwards past a would otherwise give a negative answer.
This works for ASCII letters and stops there. It is a cipher exercise, not a text-processing
technique — ucase on a ustring already knows about every alphabet, and hand-rolled arithmetic
does not.
A value with no printable form
print "X["; Box( 7 ); "]Y" '' refused
A type that has no conversion to text has nothing for print to show, whether it arrives as a
variable or as a temporary like Box( 7 ): both are refused with TKO0871: a 'box' has no printable form. Give the type an operator cast( ) as ustring — Chapter 13 — and it prints.
What it costs
lenon austringis O(1). So islenon astring. Neither walks the text.ustringuses 2 bytes per code unit, so ASCII text costs twice what it would as astring. That is the price of not having to think about encoding, and it is almost always worth paying.&builds a new string. Appending in a loop copies what is there each time; for a large result, build into one string and overwrite with themidstatement, or let the library do it.- A slice costs nothing to make — no allocation, no copy — and
lenon it is O(1). Keeping its text, by assigning it to austring, is the copy you skipped; under-exxeach read of a slice is also checked against its source. - Converting between
stringandustringis a re-encode, not a cast. Do it at the boundary, once, not inside a loop. instris a scan. Finding n things in a long string costs n scans unless you carry the position forward withinstr( start, s, x ).Formatreads its format string on every call, and copies each text value it is given. In a loop over millions of lines that is measurable; for a report it is not.
Summary
- Five types:
ustringfor text,stringfor bytes,zstring/wstringfor C,string * nfixed and space-padded. wstringis 2 bytes per character on Windows and 4 on Linux;ustringis UTF-16 everywhere.- A block string opens with
"""at the end of a line and closes at the first line starting with"""; that closing line's indentation is the margin removed from every line.!"""reads escapes, and every line break inside ischr( 10 ). - Positions start at 1, and "not found" is 0.
- The
midstatement overwrites in place and never changes the length, onstringandustringalike. strhas no padding;printreserves a column for the sign.hex/oct/bintake a width.val( )cannot fail — it returns 0 for text that is not a number. UseIsNumeric, or Chapter 18'sOutcome, when it matters.lenon austringcounts code units. Outside the BMP one character is two units, so arithmetic positions can cut a character in half.- Converting between
stringandustringis UTF-8 in both directions, on every target, and lossless. - Bytes that are not UTF-8 become U+FFFD, silently. Convert at the boundary, knowing what you have.
#import "std/strings"adds ~45 operations includingSplit/Join,Replace, thePad*family,Betweenand the CSV parsers.Join( Split( s, d ), d )iss.strings.Formatlays out a line from a format string:{}or{n}for each value, then:and a spec — fill, alignment, sign, width, precision, type. A float with no type is the shortest text that reads back exactly.- A literal format string is checked when the program compiles (
TKO0847toTKO0858). One held in a variable is checked when it runs, and a bad one stops the program; for format strings that are data,TryFormatreturns aResultinstead. s[lo to hi]is a slice: part ofs, counted from 0, both ends included, never copied.uslice,sliceandwslicematchustring,stringandwstring. A slice is as writable as its source; assigning it to austringkeeps a copy.strings.TrimSpace,Find(0-based, -1 for none),EqualsandSplitSliceswork on slices without copying.- A slice cannot outlive its text. It cannot be returned from the function that owns the
text (
TKO1059), stored where it could outlive it (TKO1054,TKO1057,TKO1058), or kept while its string grows (TKO1062). A type that holds one is declaredas view. -exxcatches what crosses a procedure: a slice read after its string moved stops with runtime error 6. Without it, the slice reads freed memory.- CSV is not a
Splitproblem — useSplitCsvorSplitCsvRow, which understand quoting. #import "std/codec":HexEncode,Base64Encode(standard or URL-safe) andBase32Encodespell bytes as text;…Decodeand…DecodeTextare strict and report the position of the first bad character.PercentEncodemakes text safe in a URL — component, path or form query.mk*/cv*pack numbers into bytes;lset/rsetalign in a fixed-length string;strptr/saddreach the characters for C.- A value with no printable form is refused by
print(TKO0871), variable or temporary.
Exercises
- Write
TitleCase, which uppercases the first letter of each word and lowercases the rest, then check it againststrings.MCaseon a sentence, a shouted one, one with double spaces and an empty string. {solution="ex09-01"} - Parse a block of
key = valuelines into pairs, skipping blanks and lines with no=. A value may itself contain an=; make sure yours survives that. {solution="ex09-02"} - Take the
cafébytes fromcodeunits.tikoand printlenof thestringand of theustring. Then do the same for a string of pure ASCII. When do the two numbers agree, and why? mid( face, 2, 1 )on the emoji text returns half a character. Write a loop that walks austringand prints each character, treating a surrogate pair as one. What does it need to check?- Read a CSV line with
SplitCsvRow, then split the same line on commas withSplit, and print both field counts. Construct a line where they differ. - Print a multiplication table for 1 to 12 with
strings.Format, every column right-aligned. Then make the column width depend on the largest product, so the table still lines up for 1 to 99. - Rewrite
lexer.tiko'sNextWordso that it also returns=,*and the other punctuation as words of their own, still without copying. Then moveprint "<"; w; ">";into a procedure that takesbyval w as const uslice, and call it with austringand with a literal as well as with a slice. - Encode the bytes 0 to 255 in base64, then decode the result with one character changed in each position in turn. Does every change get refused? Which ones decode, and to what?
User-Defined Types and Layout
Part I worked with values one at a time: a number, a string, an array of numbers. Real data comes in groups — a point has an x and a y, a reading has a station and a temperature — and a program that keeps the parts in separate variables keeps them in step by hand, which means sometimes out of step.
A type puts the parts under one name. This chapter covers what that gives you, and then what it
costs in memory, because a type is also a decision about where bytes go. That second half is what
lets tiko talk to file formats, network protocols and C libraries without translation.
Chapter 12 turns these types into objects with methods. Everything here still applies then; it is the foundation, not a beginner's version.
Grouping values
'' pointrect.tiko -- a type groups values that belong together.
type Point
x as int32
y as int32
end type
type Rect
topLeft as Point '' a type inside a type
size as Point = ( 10, 20 ) '' fields can start with a value
title as ustring = "untitled"
visible as boolean = true
end type
dim as Rect r
print "a fresh Rect: size"; r.size.x; r.size.y; _
", title "; r.title; ", visible "; r.visible
r.topLeft.x = 5 '' reach in with dots
r.title = "window"
print "after edits: topLeft"; r.topLeft.x; r.topLeft.y; _
", title "; r.title
'' A literal in parentheses fills the fields in order.
dim as Point origin = ( 0, 0 )
dim as Point corner = ( 640, 480 )
print "corner:"; corner.x; corner.y; " origin:"; origin.x; origin.y
a fresh Rect: size 10 20, title untitled, visible true
after edits: topLeft 5 0, title window
corner: 640 480 origin: 0 0
A type lists its fields, each with a name and a type. Fields are reached with a dot, and a type
can contain another type, so r.topLeft.x reads the way the data is shaped.
A field may carry an initialiser — size as Point = ( 10, 20 ) — and every variable of the type
starts with it. Fields without one start at zero, or empty for a string, exactly as variables do. A
value in parentheses, ( 640, 480 ), fills a type's fields in declaration order.
Parentheses, not braces. Braces are how an array is filled (Chapter 8), and a type is not an array:
'' bracesudt.tiko -- a type is filled with parentheses. Braces are for
'' arrays, and a Point is not one.
type Point
as int32 x, y
end type
dim as Point good = ( 3, 4 )
dim as Point bad = { 3, 4 } '' refused
print good.x, bad.x
bracesudt.tiko(9,20): error TKO0845: 'bad' is not an array, so it cannot take a '{ ... }' initializer
|
9 | dim as Point bad = { 3, 4 } '' refused
| ^
fbc refuses it in the same place, with its error 63. Braces are for arrays, parentheses for types.
name is a reserved word — the name statement renames a file — so it cannot name a variable or
a procedure. A field may still be called name, and b.name reads it: a field's name is only
read after . or ->. Appendix A lists the reserved words and the few that cannot name a field.
A type is a value
'' copying.tiko -- assignment copies the whole value, text included.
type Account
owner as ustring
balance as int64
end type
dim as Account a
a.owner = "Ada"
a.balance = 100
dim as Account b = a '' a complete, independent copy
b.owner = "Grace"
b.balance = 250
print "a: "; a.owner; a.balance
print "b: "; b.owner; b.balance
a: Ada 100
b: Grace 250
Assigning one variable of a type to another copies every field, including the text: changing
b.owner afterwards leaves a.owner alone. The two are independent values, the way two int32s
are, and nothing is shared.
Comparison is a different matter:
'' noequal.tiko -- a type can be copied, but not compared.
type Point
x as int32
y as int32
end type
dim as Point a = ( 1, 2 ), b = ( 1, 2 )
if a = b then print "equal" '' refused: what would equal mean?
noequal.tiko(8,6): error TKO0720: no operator = compares this type
|
8 | if a = b then print "equal" '' refused: what would equal mean?
| ^
The compiler will not guess what equality means for your type — whether two accounts with the same owner are equal, or only ones with the same balance too. Chapter 13 shows how to say.
Passing and returning
Chapter 7's table said that a type you define is passed by reference when no keyword is given. Here is the measurement:
'' passing.tiko -- a type you define is passed by reference unless
'' you say otherwise. Chapter 7 said so; this is the proof.
type Point
x as int32
y as int32
end type
sub NoKeyword( p as Point ) '' the default
p.x = 99
end sub
sub Copied( byval p as Point ) '' a private copy
p.x = 77
end sub
function Shifted( byval p as Point, byval dx as int32 ) as Point
p.x += dx '' changes the copy ...
return p '' ... and hands it back
end function
dim as Point a = ( 1, 2 )
NoKeyword( a )
print "after NoKeyword:"; a.x; " <- the caller's Point changed"
a.x = 1
Copied( a )
print "after Copied: "; a.x
dim as Point b = Shifted( a, 10 )
print "Shifted gave"; b.x; ", and a is still"; a.x
after NoKeyword: 99 <- the caller's Point changed
after Copied: 1
Shifted gave 11, and a is still 1
With no keyword, the procedure wrote straight into the caller's Point. With byval it worked on
a copy. A function can return a type, and Shifted returns its modified copy while the original
stays put.
The rule from Chapter 7 holds with more force here, because a type can be large: write byref
or byval on every parameter. byref ... as const is the right shape for a type you only read —
no copy, and the compiler stops you writing to it.
Where the bytes go
'' layout.tiko -- where the bytes go, and what decides it.
'' Each field sits at a multiple of its own size, so a 4-byte int32
'' after a 1-byte field leaves a 3-byte gap.
type Loose
a as uint8
b as int32
c as uint8
end type
'' The same fields, largest first: the gaps disappear.
type Ordered
b as int32
a as uint8
c as uint8
end type
'' field = n caps the alignment at n bytes.
type Packed field = 1
a as uint8
b as int32
c as uint8
end type
print "Loose sizeof"; sizeof( Loose ); _
" b at"; offsetof( Loose, b ); " c at"; offsetof( Loose, c )
print "Ordered sizeof"; sizeof( Ordered ); _
" a at"; offsetof( Ordered, a ); " c at"; offsetof( Ordered, c )
print "Packed sizeof"; sizeof( Packed ); _
" b at"; offsetof( Packed, b ); " c at"; offsetof( Packed, c )
'' field = packs the INSIDE of a type. Put that type inside another,
'' and the outer one still aligns it.
type Holder
tag as uint8
p as Packed
end type
type PackedHolder field = 1
tag as uint8
p as Packed
end type
print "Holder p at"; offsetof( Holder, p ); _
" sizeof"; sizeof( Holder )
print "PackedHolder p at"; offsetof( PackedHolder, p ); _
" sizeof"; sizeof( PackedHolder )
Loose sizeof 12 b at 4 c at 8
Ordered sizeof 8 a at 4 c at 5
Packed sizeof 6 b at 1 c at 5
Holder p at 4 sizeof 12
PackedHolder p at 1 sizeof 7
sizeof is a type's size in bytes, and offsetof( T, field ) is where a field starts. Read the
first three lines together.
Loose holds 6 bytes of data and takes 12. Each field is placed at a multiple of its own size, so
the int32 after a one-byte field starts at offset 4, leaving three unused bytes — and the whole
type is rounded up to a multiple of its largest field so that an array of them keeps every element
aligned. That padding is not waste for its own sake: the processor reads aligned values in one step
and misaligned ones in two, or not at all.
Ordered has the same fields, largest first, and takes 8. Ordering fields from largest to
smallest is the cheapest optimisation in this book — no code changes, and every array of the type
gets smaller. Exercise 2 puts a number on it.
field = 1 turns alignment off: Packed is 6 bytes with no gaps. Reach for it when the layout is
dictated from outside — a file format, a network header, a C struct declared with packing — and not
otherwise, because the misaligned reads it creates are slower.
field = packs the inside of a type, not the outside. Packed has no gaps between its own
fields, but put it inside Holder and it still starts at offset 4, with three bytes of padding in
front of it — because Holder aligns its members normally.
When a whole structure must match a byte-exact format, every type in it needs its own
field = 1. PackedHolder has one, and its p lands at offset 1. Missing one level is the
classic way to produce a header that is right in isolation and wrong in the file.
Seeing the layout
The compiler will print the layout it chose. From the directory holding the listing:
tiko --dump-udt layout.tiko
loose size 12 align 4
loose off a 0
loose off b 4
loose off c 8
ordered size 8 align 4
ordered off b 0
ordered off a 4
ordered off c 5
packed size 6 align 4
packed off a 0
packed off b 1
packed off c 5
holder size 12 align 4
holder off tag 0
holder off p 4
packedholder size 7 align 4
packedholder off tag 0
packedholder off p 1
object size 8 align 8
object off vptr 0
udts: 6
Names appear in lower case, because tiko's names are not case-sensitive. The align column is the
alignment a type asks for when it is placed inside another — which is why packed still says 4,
and why the pitfall above happens. object is the built-in base type Chapter 14 introduces; it is
listed whether or not you use it.
--dump-udt stops after printing, without building anything. This output was captured at the pin
and is identical on both targets.
Several views of the same bytes
'' unions.tiko -- several views of the same bytes.
union Bits32
asFloat as float32
asInt as uint32
bytes( 0 to 3 ) as uint8
end union
dim as Bits32 u
u.asFloat = 1.0
print "sizeof( Bits32 ) ="; sizeof( Bits32 )
print "1.0 as float32 is the bit pattern &h"; hex( u.asInt, 8 )
print "its highest byte is "; u.bytes( 3 )
'' An anonymous union inside a type: one slot, two meanings, and a
'' field beside it that says which meaning is current.
type Value
isFloat as boolean
union
i as int64
f as float64
end union
end type
dim as Value v
v.isFloat = true
v.f = 2.5
print "Value: sizeof"; sizeof( Value ); ", i and f both at offset"; _
offsetof( Value, i ); offsetof( Value, f )
if v.isFloat then print "holding the float"; v.f
sizeof( Bits32 ) = 4
1.0 as float32 is the bit pattern &h3F800000
its highest byte is 63
Value: sizeof 16, i and f both at offset 8 8
holding the float 2.5
A union is declared like a type, but its fields all start at offset 0: they are different
interpretations of one block of memory, and the union is as large as its largest field. Writing
asFloat and reading asInt shows the IEEE 754 bit pattern of 1.0, which is the classic use — and
also the only kind of use where reading a different field than the one last written is meaningful.
An anonymous union inside a type gives one slot two meanings. Value stores either an int64
or a float64 in the same eight bytes, and the isFloat field beside it records which one is
current. That pairing is the whole discipline of a union: something must say which field is
live, because the union cannot.
Fields narrower than a byte
'' packetheader.tiko -- fields narrower than a byte.
'' The first 32 bits of an IPv4 header, as bitfields. Each field
'' says how many bits it occupies; together they fill one uint32.
type Header
version : 4 as uint32
ihl : 4 as uint32
tos : 8 as uint32
length : 16 as uint32
end type
dim as Header h
h.version = 4
h.ihl = 5
h.tos = 0
h.length = 1500
print "sizeof( Header ) ="; sizeof( Header )
print "version "; h.version; " ihl "; h.ihl; " length "; h.length
'' The whole thing, read as one number. The first field declared
'' is in the LOWEST bits: version is the last hex digit.
dim as uint32 ptr whole = cast( uint32 ptr, @h )
print "as one uint32: &h"; hex( *whole, 8 )
'' A value too wide for its field is cut down to fit, silently.
h.version = 17
print "after version = 17, version is "; h.version
sizeof( Header ) = 4
version 4 ihl 5 length 1500
as one uint32: &h05DC0054
after version = 17, version is 1
version : 4 as uint32 declares a bitfield: a field four bits wide, carved out of a uint32.
Consecutive bitfields share their containing integer, so four of them describe the first 32 bits of
an IPv4 header in exactly 4 bytes.
Reading the whole word back shows the allocation order: &h05DC0054 has version (4) in the
lowest four bits and length (1500, &h5DC) in the highest sixteen. The first field declared
takes the least significant bits. That is the order the targets in this book use, and the order C
compilers use on them — but it is not the order a network protocol transmits, which is why a real
header parser reads bytes and shifts rather than overlaying a bitfield type on the wire.
--dump-udt reports bitfields at the byte offset of their container, so it cannot show bit
positions; the listing measures them by reading the word back.
A value too large for a bitfield is truncated, silently. version is four bits, so it holds 0
to 15, and assigning 17 stores 17's low four bits — 1. No warning, no runtime error, even under
-exx.
Check the range before you store, wherever the value comes from outside.
Naming a type once
'' withblock.tiko -- naming the thing once.
type Settings
width as int32
height as int32
title as ustring
resizable as boolean
end type
dim as Settings s
with s
.width = 800
.height = 600
.title = "Report"
.resizable = false
end with
print s.title; ":"; s.width; " x"; s.height; ", resizable "; s.resizable
'' An alias is another name for the same type, not a new type.
type Dimensions as Settings
dim as Dimensions d = s
print "through the alias: "; d.title; d.width
Report: 800 x 600, resizable false
through the alias: Report 800
Inside with s … end with, a field written with a leading dot belongs to s. It is shorthand for
filling in a type with many fields, and it reads well for exactly that. Inside a long block it
reads less well, because the reader has to remember which variable the dots refer to.
type Dimensions as Settings makes a second name for an existing type. It is the same type, not a
new one — a Dimensions and a Settings can be assigned to each other freely. Aliases are for
giving a type a name that fits the context, or a shorter one, and Chapter 17 relies on them to name
instantiated generics.
Types that refer to each other
'' linked.tiko -- types that point at each other.
'' A type may point at itself: the compiler only needs to know the
'' size of a pointer, which it always does.
type Node
value as int32
next_ as Node ptr
end type
dim as Node a, b, c
a.value = 1 : b.value = 2 : c.value = 3
a.next_ = @b : b.next_ = @c : c.next_ = nullptr
dim as Node ptr p = @a
print "the list:";
do while p <> nullptr
print p->value;
p = p->next_
loop
print
'' Two types that point at EACH OTHER need one of them named before
'' it is declared. A forward alias does that.
type ChildFwd as Child
type Parent
firstChild as ChildFwd ptr
end type
type Child
owner as Parent ptr
end type
dim as Parent mum
dim as Child kid
mum.firstChild = @kid
kid.owner = @mum
print "the child's owner is the parent: "; cbool( kid.owner = @mum )
the list: 1 2 3
the child's owner is the parent: true
A type can hold a pointer to its own kind, which is how linked structures are built. It cannot hold a value of its own kind — that would be infinitely large — but a pointer is 8 bytes on every target this book uses, so the compiler needs nothing more. Chapter 11 covers pointers properly; here they are only the arrows between records.
Two types that point at each other are a different problem. Whichever is written first mentions
the other before it is declared, and the compiler refuses that with
TKO0784: the type 'child' is used above its declaration. A forward alias —
type ChildFwd as Child — names the type before it exists, and a pointer to it can be used straight
away.
A forward alias that is never completed is an opaque type:
'' opaque.tiko -- a type known only by name.
type Handle as HandleImpl '' never declared in this file
dim as Handle ptr h = nullptr '' a pointer is fine
dim as Handle whole '' a value is not: how big is it?
opaque.tiko(5,8): error TKO0810: 'handleimpl' is an incomplete type: nothing declares it, so only a pointer to it may be used here
|
5 | dim as Handle whole '' a value is not: how big is it?
| ^
That refusal is the feature. A library can hand out Handle ptr values while keeping the contents
of HandleImpl to itself, and callers cannot depend on what they cannot see. Chapter 29 meets the
same pattern from C's side.
Arrays of records
'' udtarray.tiko -- an array whose elements are records.
type Reading
station as ustring
celsius as float64
end type
'' A small function that builds one record keeps the call sites to a
'' single readable line each.
function MakeReading( byref station as ustring, _
byval celsius as float64 ) as Reading
dim as Reading r
r.station = station
r.celsius = celsius
return r
end function
dim as Reading log_( 1 to 4 )
log_( 1 ) = MakeReading( "north", 11.5 )
log_( 2 ) = MakeReading( "south", 18.0 )
log_( 3 ) = MakeReading( "east", 14.25 )
log_( 4 ) = MakeReading( "west", 9.75 )
'' By index, when the position matters.
dim as int32 warmest = lbound( log_ )
for i as int32 = lbound( log_ ) to ubound( log_ )
if log_( i ).celsius > log_( warmest ).celsius then warmest = i
next
print "warmest: "; log_( warmest ).station; log_( warmest ).celsius
'' for each byref, when it does not. Each record is used in place,
'' without being copied.
dim as float64 total = 0
for each byref r as Reading in log_
total += r.celsius
next
print "mean:"; total / ( ubound( log_ ) - lbound( log_ ) + 1 )
'' byref also lets the loop change them.
for each byref r as Reading in log_
r.celsius = r.celsius * 9 / 5 + 32
next
print "north in fahrenheit:"; log_( 1 ).celsius
warmest: south 18
mean: 13.375
north in fahrenheit: 52.7
An array of a type works like any array, and Chapter 8's advice carries over: index when the
position matters, for each byref when it does not. For records, byref is more than a style
choice, because the alternative copies every field — text included — on every pass.
MakeReading builds one record per call, which keeps each line of setup readable. Chapter 12 turns
that function into a constructor, which is where it belongs.
A record that holds text is not built with type( ... ). log_( 2 ) = type( "south", 18.0 )
over a Reading holding a ustring is refused — TKO0929: type( ... ) cannot build a 'reading' —
because only a constructor can fill a var-len string. Build such records through a function like
MakeReading, or — from Chapter 12 — with a constructor, which type( ... ) then calls.
What it costs
- A type costs exactly its
sizeof. There is no hidden header and no per-object allocation until Chapter 12 adds virtual methods, and even then it is one pointer. - Padding is real memory. Ordering fields largest first is free and can save a third.
field = 1trades speed for exactness. Use it where a format demands it, nowhere else.- Assignment copies everything, and a
ustringfield is a separate allocation copied each time. Pass large typesbyref. - A union costs its largest member, and the field that says which member is live.
Summary
typegroups named fields; a field may have an initialiser, and may itself be a type.( a, b )fills fields in order. Braces are for arrays —{ a, b }on a type isTKO0845- Assignment copies the whole value, text included.
=does not compare types —TKO0720— until Chapter 13 defines it. - With no keyword, a type is passed
byref.byvalcopies; a function may return a type. sizeofandoffsetofmeasure layout. Fields align to their own size, and the type rounds up to its largest field — order fields largest first.field = ncaps alignment — for the inside of that type only; nested types need their own.tiko --dump-udt file.tikoprints the layout the compiler chose.- A
unionoverlays its fields at offset 0; something beside it must record which one is live. - Bitfields pack narrow fields into an integer, first field in the lowest bits, and truncate an oversized value silently.
withshortens repeated access;type A as Bis an alias for the same type.- A type may point at itself; types that point at each other need a forward alias, and an alias never completed is an opaque type usable only through a pointer.
- Arrays of types: index for position,
for eachotherwise —for each byreffor records, which the by-value form would copy field by field. type( ... )cannot build a record holding text (TKO0929); build it through a function or a constructor.
Exercises
- Declare a 16-byte record header — magic, version, flags, length, checksum — that must match a published binary format exactly, and have the program verify every offset against the spec instead of trusting it. {solution="ex10-01"}
- Take a type with six fields of mixed sizes declared carelessly, reorder them largest first, and print both sizes and what a million of each would cost. {solution="ex10-02"}
- Put a
field = 1type inside an ordinary one and print the offset of the inner type. Then fix the outer type so the whole thing has no padding. How manyfield =did it take? - Write a
unionholding afloat64and an array of eightuint8, store −0.0 and 0.0, and print the bytes of each. Which byte differs, and why does=on the two floats still say they are equal? - Build a three-element linked list of
Nodes and write a procedure that prints it given only the first node's address. What should the procedure do if it is handed0?
Pointers, References and Memory
Every variable so far has lived somewhere the compiler chose and lasted as long as the compiler decided. That is the right default, and most of a tiko program never needs anything else.
This chapter is about the rest: memory you ask for while the program runs, structures that link to each other, and addresses you hand to code that expects them — C libraries, the operating system, a buffer shared between two parts of a program. A pointer is the address of a value, and with it comes the responsibility the compiler was carrying for you: knowing that the address is valid, and giving the memory back.
Chapter 22 shows the tools that carry most of that responsibility again — destructors, defer,
shared pointers. Learn the raw form first, because every one of those tools is built on it.
An address, and what lives at it
'' basics.tiko -- an address, and what lives at it.
dim as int32 n = 10
dim as int32 ptr p = @n '' @ takes the address
print "*p ="; *p '' * reads what p points at
*p = 20 '' ... or writes it
print "n is now"; n
print "varptr( n ) is the same address: "; cbool( varptr( n ) = p )
'' A pointer to a type reaches its fields with ->.
type Point
x as int32
y as int32
end type
dim as Point pt = ( 3, 4 )
dim as Point ptr pp = @pt
pp->x = 30
print "pt.x ="; pt.x; ", and (*pp).y ="; ( *pp ).y
'' nullptr is the null pointer: it points at nothing.
dim as int32 ptr nothing_ = nullptr
print "nothing_ is null: "; cbool( nothing_ = nullptr )
*p = 10
n is now 20
varptr( n ) is the same address: true
pt.x = 30, and (*pp).y = 4
nothing_ is null: true
| Written | Means |
|---|---|
int32 ptr | the type: an address of an int32 |
@n or varptr( n ) | the address of n |
*p | the value at p — readable and writable |
p->x | field x of the type at p; the same as ( *p ).x |
nullptr | the null pointer: an address of nothing |
Writing through *p changed n, because p holds n's address — there is one value and two ways
to reach it. That sharing is the whole point of a pointer, and the source of every pointer bug.
nullptr, not 0
0 works as a null pointer too: p = 0 and if p = 0 are legal, and existing code is full of
them. But 0 is an integer, and wherever the compiler has to choose a type for it, it chooses an
integer's:
'' nullptr.tiko -- the null pointer, spelled as a pointer.
sub Show overload ( byval n as integer )
print " Show( integer )"
end sub
sub Show overload ( byval p as any ptr )
print " Show( any ptr )"
end sub
print "0 is an integer, so it picks the integer overload:"
Show( 0 )
print "nullptr is a pointer:"
Show( nullptr )
'' var takes the type of what it is given.
var a = 0
var b = nullptr
print "var from 0, then var from nullptr:"
Show( a )
Show( b )
0 is an integer, so it picks the integer overload:
Show( integer )
nullptr is a pointer:
Show( any ptr )
var from 0, then var from nullptr:
Show( integer )
Show( any ptr )
nullptr is an any ptr whose value is 0, so it converts to every pointer type — typed pointers,
zstring ptr, procedure pointers — and an overload, var and a generic all see a pointer. Against
two pointer overloads it matches both equally and the call is an error; cptr( zstring ptr, nullptr ) names the one you mean. This book writes nullptr everywhere.
Arithmetic counts elements
'' arithmetic.tiko -- pointer arithmetic counts elements, not bytes.
dim as int32 a( 0 to 4 ) = { 10, 20, 30, 40, 50 }
dim as int32 ptr p = @a( 0 )
print "p[2] ="; p[2]; ", *( p + 3 ) ="; *( p + 3 )
'' How far does + 1 move? It depends on what the pointer points at.
dim as uint8 ptr b = cast( uint8 ptr, p )
dim as any ptr v = p
print "int32 ptr + 1 moves"; cast( integer, p + 1 ) - cast( integer, p )
print "uint8 ptr + 1 moves"; cast( integer, b + 1 ) - cast( integer, b )
print "any ptr + 1 moves"; cast( integer, v + 1 ) - cast( integer, v )
'' Walking a run of elements with a pointer.
dim as int32 ptr last_ = @a( 4 )
dim as int32 ptr q = p
print "walked:";
do while q <= last_
print *q;
q += 1
loop
print
p[2] = 30, *( p + 3 ) = 40
int32 ptr + 1 moves 4
uint8 ptr + 1 moves 1
any ptr + 1 moves 1
walked: 10 20 30 40 50
p + 1 does not mean "one byte later". It means the next element, so on an int32 ptr it moves
four bytes, on a uint8 ptr one, and on an any ptr — which has no element size — one. p[2] is
*( p + 2 ), which is why a pointer to an array's first element can be indexed like the array.
@v + 3 is not three bytes past v. If v is an int32, @v is an int32 ptr, and + 3
moves three int32s — twelve bytes, well outside v. Reading there returns whatever happens to
be next in memory, and nothing reports it.
To step in bytes, step through a byte pointer: cast( uint8 ptr, @v ) + 3. The peekpoke.tiko
listing later in this chapter does exactly that — and its first draft did not, which is how this
pitfall got its example.
Comparison works as you would expect — q <= last_ walks a run of elements — and subtracting two
pointers into the same array gives the distance between them in elements. Comparing pointers into
different blocks of memory tells you nothing useful.
Changing what a pointer points at
'' conversions.tiko -- any ptr, and changing what a pointer points at.
dim as int32 n = 70000
'' any ptr holds an address of anything. Every pointer converts to
'' it, and it converts back to any pointer type, without a cast.
dim as any ptr raw = @n
dim as int32 ptr back = raw
print "through any ptr and back:"; *back
'' Between two typed pointers, say what you mean with cptr or cast.
dim as uint8 ptr bytes = cptr( uint8 ptr, @n )
print "the bytes of 70000:";
for i as int32 = 0 to 3 : print " "; bytes[i]; : next
print
dim as uint16 ptr halves = cast( uint16 ptr, @n )
print "as two uint16: "; halves[0]; " "; halves[1]
print "and 4464 + 1 * 65536 = 70000 -- the low half comes first"
through any ptr and back: 70000
the bytes of 70000: 112 17 1 0
as two uint16: 4464 1
and 4464 + 1 * 65536 = 70000 -- the low half comes first
any ptr is an address with no type attached. Every pointer converts to it, and it converts back
to any pointer type without a cast — which is what lets one procedure accept the address of
anything, and what allocate returns.
Between two typed pointers the compiler wants you to say what you mean. cptr( T ptr, p ) and
cast( T ptr, p ) reinterpret the address; without one, assigning a float64 ptr from an
int32 ptr draws TKO0830: suspicious pointer assignment — a warning, because sometimes it is
exactly what you want, and usually it is a bug.
The listing reads the four bytes of 70000 and then the two 16-bit halves. Both show the low part first: on every target in this book the least significant byte has the lowest address.
An any ptr cannot be dereferenced. It has no element type, so there is nothing defined to
read: *a and a[ i ] are refused with TKO0933. Convert to a typed pointer first:
*cptr( int32 ptr, a ).
const on either side
'' constptr.tiko -- const on either side of ptr.
dim as int32 n = 5, m = 6
dim as const int32 ptr readOnly = @n '' can move; cannot write
dim as int32 const ptr fixed_ = @n '' cannot move; can write
readOnly = @m '' fine
*fixed_ = 9 '' fine
*readOnly = 1 '' refused
fixed_ = @m '' refused
constptr.tiko(11,1): error TKO0617: cannot modify 'fixed_': it is a constant
|
11 | fixed_ = @m '' refused
| ^
constptr.tiko(10,1): error TKO0617: cannot modify '*readonly': it is a constant
|
10 | *readOnly = 1 '' refused
| ^
const int32 ptr is a pointer to constant data: it may be pointed elsewhere, but not written
through. int32 const ptr is a constant pointer: it always points at the same place, but what is
there may change. Read the declaration from the right — "a const pointer to an int32", "a pointer
to a const int32" — and the rule falls out.
Asking for memory
'' buffer.tiko -- memory the program asks for, and gives back.
'' allocate returns raw memory: uninitialised, and yours to free.
dim as int32 ptr p = allocate( 4 * sizeof( int32 ) )
if p = nullptr then
print "out of memory"
end 1
end if
for i as int32 = 0 to 3 : p[i] = ( i + 1 ) * 10 : next
print "allocated:"; p[0]; p[1]; p[2]; p[3]
'' reallocate grows (or shrinks) a block, keeping its contents. It
'' may MOVE the block, so the old pointer must not be used again.
dim as int32 ptr grown = reallocate( p, 6 * sizeof( int32 ) )
if grown = nullptr then
deallocate( p ) '' on failure the old block survives
end 1
end if
p = grown
p[4] = 50 : p[5] = 60
print "reallocated:"; p[0]; p[3]; " and new"; p[4]; p[5]
deallocate( p )
p = nullptr '' so nothing can use it by mistake
'' callocate zeroes the memory it returns.
dim as int32 ptr z = callocate( 4, sizeof( int32 ) )
print "callocated:"; z[0]; z[1]; z[2]; z[3]
'' clear sets every BYTE to a value -- here 255, so each int32 is -1.
clear( *z, 255, 4 * sizeof( int32 ) )
print "after clear( 255 ): "; z[0]; " "; z[3]
deallocate( z )
deallocate( nullptr ) '' freeing null does nothing, safely
print "done"
allocated: 10 20 30 40
reallocated: 10 40 and new 50 60
callocated: 0 0 0 0
after clear( 255 ): -1 -1
done
| Call | Gives |
|---|---|
allocate( bytes ) | uninitialised memory, or nullptr if there is none |
callocate( count, size ) | the same, zeroed |
reallocate( p, bytes ) | the block resized, contents kept — possibly at a new address |
deallocate( p ) | the memory back; deallocate( nullptr ) does nothing |
clear( dest, byte, count ) | count bytes at dest set to byte |
Three habits are in that listing, and each prevents a class of bug:
- Check for null. Allocation can fail, and a null pointer used as a buffer fails much later and much less clearly.
- Keep
reallocate's result in a new variable. On failure it returns null and leaves the old block alone; writing the result straight over the only pointer you had leaks that block. - Set the pointer to
nullptrafterdeallocate. A freed pointer still holds an address, and using it is the worst kind of bug: it usually works.
clear sets bytes, so clear( *z, 255, … ) makes each int32 −1, not 255. It is for zeroing
and for byte buffers, not for filling an array with a value.
Freeing on every way out
Those habits make each call correct. They do not make the procedure correct: a procedure with three
returns after its allocate needs three deallocates, and the fourth return someone adds later
leaks. defer, from Chapter 6, writes the release once, beside the allocation, and runs it on every
way out of the block:
'' deferfree.tiko -- defer pairs each allocate with its deallocate.
'' The sum of the squares 1, 4, 9 ... of the first n numbers, built in
'' a scratch buffer. Every return after the allocate frees it.
function SumOfSquares( byval n as int32 ) as int64
if n < 1 then return 0 '' nothing allocated yet
dim as int64 ptr squares = allocate( n * sizeof( int64 ) )
defer deallocate( squares ) '' runs on every return below
if squares = nullptr then return -1 '' freeing null does nothing
for i as int32 = 0 to n - 1
squares[i] = cast( int64, i + 1 ) * ( i + 1 )
next
dim as int64 total = 0
for i as int32 = 0 to n - 1 : total += squares[i] : next
return total
end function
'' A deferred statement reads its variables when it RUNS, not when it
'' is written: when reallocate moves the block, defer frees the new one.
function Collect( byval count as int32, byref slots as int32 ) as int32
slots = 2
dim as int32 ptr items = allocate( slots * sizeof( int32 ) )
defer deallocate( items )
if items = nullptr then return -1
for i as int32 = 0 to count - 1
if i = slots then
dim as int32 wanted = slots * 2
dim as int32 ptr grown = _
reallocate( items, wanted * sizeof( int32 ) )
if grown = nullptr then return -1 '' items still freed
items = grown
slots = wanted
end if
items[i] = i * i
next
dim as int32 total = 0
for i as int32 = 0 to count - 1 : total += items[i] : next
return total
end function
print "Sum of squares through a scratch buffer"
print " n = 0: " & SumOfSquares( 0 )
print " n = 3: " & SumOfSquares( 3 )
print " n = 10: " & SumOfSquares( 10 )
dim as int32 slots
dim as int32 total = Collect( 5, slots )
print "Collect 5 squares, starting with 2 slots"
print " total " & total & " in " & slots & " slots"
Sum of squares through a scratch buffer
n = 0: 0
n = 3: 14
n = 10: 385
Collect 5 squares, starting with 2 slots
total 30 in 8 slots
- Write the
deferon the line after theallocate, before the check for null.deallocate( nullptr )does nothing, so the failure path is covered too, and the pair cannot drift apart. - A deferred statement reads its variables when it runs, at the end of the block, not when the
deferline is reached. That is whyCollectcan storereallocate's result initemsand still free the right block. It also means a block you free early by hand needs its pointer set tonullptrafterwards, or thedeferfrees it a second time. - Allocate and defer in the procedure's own body, never inside an
if. Chapter 6's pitfall applies with full force: the block is freed atend if, and the pointer still holds its address.
Typed allocation
'' newdelete.tiko -- typed allocation.
'' new knows the type: no sizeof, no cast, and the memory is zeroed.
dim as int32 ptr one = new int32( 42 )
print "new int32( 42 ):"; *one
delete one
dim as float64 ptr several = new float64[ 3 ]
several[0] = 1.5 : several[2] = 2.5
print "new float64[ 3 ]:"; several[0]; several[1]; several[2]
delete[] several '' [] matches the [] in new
type Point
x as int32
y as int32
end type
dim as Point ptr pt = new Point
print "new Point starts at"; pt->x; pt->y
pt->x = 3 : pt->y = 4
print "and is now"; pt->x; pt->y
delete pt
new int32( 42 ): 42
new float64[ 3 ]: 1.5 0 2.5
new Point starts at 0 0
and is now 3 4
new T allocates one T, zeroed; new T[ n ] allocates n of them. No sizeof, no cast, and the
pointer comes back typed. Pair them exactly: memory from new goes back with delete, memory from
new[] with delete[], and memory from allocate with deallocate. Mixing the families is
undefined, and Chapter 12 shows the version where it visibly breaks — once a type has a destructor,
delete runs it and deallocate does not.
new takes a value too. For a type with no constructor the arguments fill the fields in order, as
dim … = ( 3, 4 ) does, and any left out are zero:
'' newargs.tiko -- new with a field list.
''
'' For a type with no constructor, the arguments to new fill its
'' fields in order, as they do in a dim. This prints 3 4.
type Point
x as int32
y as int32
end type
dim as Point ptr pt = new Point( 3, 4 )
print pt->x; pt->y
delete pt
3 4
new int32( 42 ) works the same way for a single value. A type with a constructor takes its
arguments through that instead — Chapter 12.
Structures that link
'' linkednodes.tiko -- a list built on the heap, walked, and freed.
type Node
value as int32
next_ as Node ptr
end type
'' Push a value on the front. The new node points at the old head,
'' and becomes the head.
function Push( byval head as Node ptr, byval v as int32 ) as Node ptr
dim as Node ptr n = new Node
n->value = v
n->next_ = head
return n
end function
dim as Node ptr head = nullptr
for i as int32 = 1 to 5
head = Push( head, i * i )
next
print "the list:";
dim as Node ptr p = head
do while p <> nullptr
print p->value;
p = p->next_
loop
print
'' Free every node. Read next_ BEFORE deleting the node it is in.
dim as int32 freed = 0
do while head <> nullptr
dim as Node ptr after = head->next_
delete head
head = after
freed += 1
loop
print "freed"; freed; " nodes"
the list: 25 16 9 4 1
freed 5 nodes
A linked list is the canonical heap structure: each node allocated separately, each pointing at the
next, the end marked by a null. Push puts a new node at the front in constant time, which is why
the list prints in reverse order of insertion.
Freeing it shows the rule that matters for every linked structure: read the link before you
delete the node that holds it. head->next_ after delete head reads freed memory. It will
often produce the right answer, which is exactly why it ships.
References
'' references.tiko -- a second name for an existing variable.
'' A byref parameter is a reference the caller sets up.
sub Double_( byref n as int32 )
n *= 2
end sub
dim as int32 a = 21
Double_( a )
print "after Double_:"; a
'' dim byref makes a reference inside a procedure or module. It must
'' be bound when it is declared, and it stays bound to that variable.
dim as int32 b = 5
dim byref as int32 alias_ = b
alias_ = 9
print "b ="; b; ", same address: "; cbool( @alias_ = @b )
'' A reference reads like a variable; a pointer reads like an
'' address. Same machine code, different promises.
dim as int32 ptr ptr_ = @b
*ptr_ = 11
print "b ="; b; " via a pointer, and"; alias_; " via the reference"
after Double_: 42
b = 9, same address: true
b = 11 via a pointer, and 11 via the reference
A reference is a second name for an existing variable. byref parameters are references the
caller sets up; dim byref makes one directly, and it must be bound when declared — there is no
null reference and no rebinding.
Underneath, a reference and a pointer are the same machine address. The difference is the promise: a reference always refers to something, and is used like the variable it names. Prefer a reference whenever "nothing" is not a valid answer, and a pointer when it is.
The memory behind a string
'' emptystrptr.tiko -- an empty string has no characters to point at.
dim as ustring full = "hi"
dim as ustring empty_ = ""
print "strptr( full ) is null: "; cbool( strptr( full ) = nullptr )
print "strptr( empty_ ) is null: "; cbool( strptr( empty_ ) = nullptr )
'' Emptying a string by assignment releases its characters too.
dim as ustring was = "something"
was = ""
print "after was = """", strptr is null: "; _
cbool( strptr( was ) = nullptr )
strptr( full ) is null: false
strptr( empty_ ) is null: true
after was = "", strptr is null: true
strptr( s ) is the address of a string's characters, which is what a C function wants. A string
owns that memory, and moves or frees it whenever it changes — so the pointer is valid only until
the next modification of the string, and only while the string exists.
strptr of an empty string is null. An empty string owns no characters, so there is no address
to give. Assigning "" releases the characters a string had, so this applies to a string that was
emptied, not only one that started empty.
A C function handed that pointer as "a string" will usually crash on it. Check for an empty string at the boundary and pass a pointer to a real empty literal instead — Chapter 29 shows the pattern.
Reading and writing raw memory
'' peekpoke.tiko -- reading and writing a value of a stated type at
'' an address.
dim as int32 v = &h01020304
'' The lowest-addressed byte is the least significant: little-endian.
'' @v is an int32 ptr, so @v + 3 would be 12 bytes on. Step in bytes
'' through a uint8 ptr instead.
dim as uint8 ptr at = cast( uint8 ptr, @v )
print "byte 0 = "; peek( uint8, at ); _
" byte 3 = "; peek( uint8, at + 3 )
poke uint8, @v, &hFF '' overwrite one byte
print "after poking byte 0: &h"; hex( v, 8 )
poke int32, @v, 7 '' overwrite all four
print "after poking an int32:"; v
byte 0 = 4 byte 3 = 1
after poking byte 0: &h010203FF
after poking an int32: 7
peek( T, address ) reads a T at an address, and poke T, address, value writes one. They are
the explicit spellings of *cptr( T ptr, address ), useful when the type at an address is decided
by the code rather than by a declaration — parsing a binary format, for instance.
The output confirms what conversions.tiko showed: byte 0 of &h01020304 is 4. These targets are
little-endian.
When the pointer is null
'' nullcheck.tiko -- following a null pointer, under -exx.
#cmdline "-exx"
dim as int32 ptr p = nullptr
print *p
print "not reached"
Aborting due to runtime error 7 (null pointer access) at line 5 of nullcheck.tiko::()
Under -exx, reading or writing through a null pointer with * stops the program with runtime
error 7, and the process exits with status 7, instead of crashing somewhere further on. Without
-exx the same program crashes with an access violation.
The same check covers -> and indexing — p->x and p[i] through null stop with error 7 too.
That is the common case: a null object pointer is reached through -> far more often than
through *.
'' nullmember.tiko -- a field through a null pointer, under -exx.
''
'' -exx checks p->x as it checks *p, and stops with the same runtime
'' error 7 that nullcheck.tiko shows.
#cmdline "-exx"
type Point
x as int32
end type
dim as Point ptr p = nullptr
print p->x
print "not reached"
Aborting due to runtime error 7 (null pointer access) at line 12 of nullmember.tiko::()
In both listings the error is the only output. A print placed before the bad access can appear
after the error message when output is redirected, because the message goes to the error
stream immediately and the print waits in a buffer. Debugging with a print just before a crash
needs that in mind.
What it costs
- A pointer costs 8 bytes and one indirection on every access. Following a chain of them —
a->b->c— is a chain of dependent memory loads, which is slower than walking an array. - Every allocation has overhead in time and bookkeeping. A thousand small
news cost more than onenew[]of a thousand. - A linked list scatters its nodes across memory, so walking it misses the cache where an array would not. Use one when you need constant-time insertion in the middle, not by default.
- Memory you allocate is memory you must free. Every exit path, including errors.
defercovers the paths out of one procedure; Chapter 22 covers memory that outlives it.
Summary
T ptris an address;@takes one,*follows it,->reaches a field, andnullptris null.- Write
nullptr, not0. Both are null, but0is an integer to an overload,varand a generic. - Pointer arithmetic counts elements:
p + 1movessizeof( T )bytes. Step in bytes through auint8 ptr. any ptrconverts to and from every pointer type; between typed pointers usecptrorcast.- An
any ptris not dereferenced (TKO0933); convert it to a typed pointer first. const T ptrcannot write through;T const ptrcannot be moved.allocate/callocate/reallocate/deallocate: check for null, keepreallocate's result separately, null the pointer after freeing.clearsets bytes.defer deallocate( p )on the line after theallocatefrees on every way out. It readspwhen it runs, so it follows areallocate.new/deleteandnew[]/delete[]allocate typed and zeroed memory — pair each family with its own release.new T( a, b )fills a constructor-less type's fields in order.- Read a node's link before deleting the node.
- References are bound once and never null; prefer them when "nothing" is not a valid answer.
strptrof an empty string is null, and anystrptris valid only until the string changes.peek/pokeread and write a stated type at an address. The targets are little-endian.-exxtraps*p,p->xandp[i]through null with runtime error 7. Still test a pointer that may be null before you follow it.
Exercises
- Write a growable
int32buffer that doubles its capacity when it fills, usingreallocate, and report each growth. Append twenty items and say how many times the buffer was copied. {solution="ex11-01"} - Reverse a singly linked list in place by changing the links, not the values. Make sure an empty list and a one-node list both survive. {solution="ex11-02"}
- Print the address of each element of an
int64array and of auint8array. What is the difference between consecutive addresses in each, and why? - Allocate a block, free it, and then read through the old pointer. Run it several times. What does it print, and why is "it printed the right value" not evidence that the code is correct?
- Write a procedure that takes a
stringand passesstrptrof it to a procedure expecting azstring ptr, which prints the text. What must it do when the string is empty?
Objects
Chapter 10's types held data and left every rule about that data to the code around them. An account balance could be set negative from anywhere; a buffer's length could disagree with its memory; a record could be built half-filled. Nothing in the type said otherwise, because the type could not say anything.
An object is a type that carries its rules with it. It has procedures of its own, it decides how it is created and destroyed, and it can keep its fields to itself so that the only way to change them is through code that keeps them consistent. None of that costs anything at run time: a method is an ordinary procedure with the object passed in, and an object is exactly as large as its fields.
A type with its own procedures
'' account.tiko -- a type that looks after its own data.
type Account
declare constructor( byref owner as ustring, _
byval opening as int64 )
declare destructor( )
declare sub Deposit( byval amount as int64 )
declare function Withdraw( byval amount as int64 ) as boolean
declare function Balance( ) as int64
owner_ as ustring
balance_ as int64
end type
'' Bodies live outside the type, named Type.Member.
constructor Account( byref owner as ustring, byval opening as int64 )
this.owner_ = owner '' this is the object being built
balance_ = opening '' ... and "this." may be left off
print " opened "; owner_
end constructor
destructor Account( )
print " closed "; owner_; " with"; balance_
end destructor
sub Account.Deposit( byval amount as int64 )
balance_ += amount
end sub
function Account.Withdraw( byval amount as int64 ) as boolean
if amount > balance_ then return false
balance_ -= amount
return true
end function
function Account.Balance( ) as int64
return balance_
end function
scope
dim as Account ada = Account( "Ada", 100 )
dim as Account grace = Account( "Grace", 20 )
ada.Deposit( 50 )
print "Ada withdraws 500: "; ada.Withdraw( 500 )
print "Ada withdraws 30: "; ada.Withdraw( 30 )
print "Ada's balance:"; ada.Balance( )
end scope '' both destructors run here
print "after the scope"
opened Ada
opened Grace
Ada withdraws 500: false
Ada withdraws 30: true
Ada's balance: 120
closed Grace with 20
closed Ada with 120
after the scope
A method is declared inside the type and defined outside it, named Type.Member. Inside, the
object it was called on is this, and this. may be left off when nothing else has the same name —
balance_ and this.balance_ are the same field.
The constructor runs when an object is created and the destructor when it is destroyed. For an object in a scope, that is at the end of the scope, in the reverse order of creation: Grace was opened after Ada and closed before her. That ordering is deliberate — something created later may depend on something created earlier, never the other way round.
The trailing underscore on the fields is a convention, not syntax. It marks the data the methods look after, and keeps names free for the methods that expose it.
Constructors
'' constructors.tiko -- several ways in, one of them implicit.
type Temp
declare constructor( )
declare constructor( byval c as float64 )
declare constructor( byref text as ustring )
celsius as float64
end type
constructor Temp( )
celsius = 20
print " default constructor"
end constructor
constructor Temp( byval c as float64 )
celsius = c
print " from a float64"
end constructor
'' Delegation: hand the work to another constructor, then add to it.
constructor Temp( byref text as ustring )
this.constructor( val( text ) )
print " ... reached through the text constructor"
end constructor
sub Show( byref t as const Temp )
print " Show sees"; t.celsius
end sub
print "dim as Temp a"
dim as Temp a
print "dim as Temp b = Temp( 5.0 )"
dim as Temp b = Temp( 5.0 )
print "dim as Temp c = Temp( ""31.5"" )"
dim as Temp c = Temp( "31.5" )
'' A one-argument constructor is also a CONVERSION: the compiler will
'' call it on its own whenever a float64 is offered where a Temp is
'' wanted.
print "dim as Temp d = 7.5"
dim as Temp d = 7.5
print "Show( 12.0 )"
Show( 12.0 )
print "values:"; a.celsius; b.celsius; c.celsius; d.celsius
dim as Temp a
default constructor
dim as Temp b = Temp( 5.0 )
from a float64
dim as Temp c = Temp( "31.5" )
from a float64
... reached through the text constructor
dim as Temp d = 7.5
from a float64
Show( 12.0 )
from a float64
Show sees 12
values: 20 5 31.5 7.5
A type may have as many constructors as it needs, distinguished by their parameters like overloaded
procedures. The one with no parameters is the default constructor, used by a plain dim.
One constructor may hand its work to another: this.constructor( val( text ) ) runs the float64
constructor and then carries on. Put shared setup in one constructor and delegate to it, rather
than repeating it.
A one-parameter constructor is also an implicit conversion. Because Temp has a constructor
taking a float64, the compiler will call it on its own whenever a float64 appears where a Temp
is wanted — dim as Temp d = 7.5 and Show( 12.0 ) both did, silently, in the listing.
That is convenient for a type that genuinely is a number with extra meaning, and surprising for
one that merely takes a number to get started. A Buffer( 1024 ) constructor makes Show( 1024 )
compile for a procedure that wants a buffer. Nothing turns the conversion off, so give such a
constructor a second parameter, or create the object through a named function instead. There is
no explicit keyword to switch it off: declare explicit constructor is refused (TKO0936).
Copying an object that owns something
Chapter 10's types copied themselves correctly: every field was copied, strings included. That stops being true the moment a type holds a pointer to memory it owns. Copying the pointer gives two objects the same block — and two destructors that each free it.
Measured, with a Buffer that has a constructor and destructor but nothing else, after
dim as Buffer b = a:
a.data_[0] = 99 same block: true
freeing 31008
freeing 31008
The write through b appeared in a, both destructors freed the same address — the number printed
differs from run to run; that it is the same number twice does not — and the process then
died — 0xC0000374, heap corruption, on Windows, and free(): double free on Linux. fbc behaves
identically: this is the language's rule, not a defect.
The fix is to say what copying means:
'' copying.tiko -- a type that owns memory must say how to copy it.
type Buffer
declare constructor( byval n as int32 )
declare constructor( byref other as const Buffer ) '' copy
declare operator let( byref other as const Buffer ) '' assign
declare destructor( )
items as int32 ptr
size as int32
end type
constructor Buffer( byval n as int32 )
items = callocate( n, sizeof( int32 ) )
size = n
end constructor
'' Copy construction: a brand-new object, so there is nothing to free.
constructor Buffer( byref other as const Buffer )
size = other.size
items = allocate( size * sizeof( int32 ) )
for i as int32 = 0 to size - 1 : items[i] = other.items[i] : next
print " copy constructor"
end constructor
'' Assignment: this object already owns memory. Free it first -- but
'' not when assigning an object to itself, or the source goes too.
operator Buffer.let( byref other as const Buffer )
if @this = @other then
print " let: assigned to itself, nothing to do"
exit operator
end if
deallocate( items )
size = other.size
items = allocate( size * sizeof( int32 ) )
for i as int32 = 0 to size - 1 : items[i] = other.items[i] : next
print " let"
end operator
destructor Buffer( )
deallocate( items )
end destructor
scope
dim as Buffer a = Buffer( 4 )
a.items[0] = 1
dim as Buffer b = a '' copy constructor
b.items[0] = 99
print "a[0] ="; a.items[0]; " b[0] ="; b.items[0]; _
" separate memory: "; cbool( a.items <> b.items )
dim as Buffer c = Buffer( 2 )
c = a '' operator let
print "c now has size"; c.size
c = c '' must not destroy itself
end scope
print "every buffer freed exactly once"
copy constructor
a[0] = 1 b[0] = 99 separate memory: true
let
c now has size 4
let: assigned to itself, nothing to do
every buffer freed exactly once
Two procedures are involved, because there are two ways an object gets a copy:
- The copy constructor builds a new object from an existing one —
dim as Buffer b = a, or passingbyval. There is nothing to clean up first, because the object did not exist. operator letassigns into an object that already exists —c = a. It must release what the object holds before taking the copy, and it must do nothing when assigned to itself, or it frees the very data it is about to copy.
The rule of three: a type that needs a destructor almost always needs a copy constructor and an
operator let as well. The destructor is there because the type owns something; the copy operations
decide what happens to that something when the object is duplicated. Write one without the others
and every copy is a double free waiting for its scope to end.
The better answer, where it is available, is the rule of zero: build the type from fields that already copy themselves — strings, other well-behaved objects — so that it needs none of the three. The string builder later in this chapter does exactly that.
Properties
'' properties.tiko -- methods that read like fields.
type Thermostat
declare property Celsius( ) as float64 '' get
declare property Celsius( byval c as float64 ) '' set
declare property Fahrenheit( ) as float64 '' get only
declare property Reading( byval i as int32 ) as float64
declare property Reading( byval i as int32, byval v as float64 )
private:
c_ as float64
readings( 0 to 2 ) as float64
end type
property Thermostat.Celsius( ) as float64
return c_
end property
'' The setter is where a type defends its own rules.
property Thermostat.Celsius( byval c as float64 )
if c < -273.15 then c = -273.15
c_ = c
end property
property Thermostat.Fahrenheit( ) as float64
return c_ * 9 / 5 + 32
end property
property Thermostat.Reading( byval i as int32 ) as float64
return readings( i )
end property
property Thermostat.Reading( byval i as int32, byval v as float64 )
readings( i ) = v
end property
dim as Thermostat t
t.Celsius = 21.5
print "celsius"; t.Celsius; ", fahrenheit"; t.Fahrenheit
t.Celsius = -500
print "asked for -500, got "; t.Celsius
t.Reading( 1 ) = 3.5 '' an indexed property
print "reading 1 is"; t.Reading( 1 )
celsius 21.5, fahrenheit 70.7
asked for -500, got -273.15
reading 1 is 3.5
A property is a pair of methods that read and write like a field. t.Celsius = 21.5 calls the
setter; t.Celsius in an expression calls the getter. The point is the setter: it is where the type
enforces its own rules, so asking for −500 °C gives absolute zero rather than an impossible
temperature.
A property with only a getter is read-only — Fahrenheit is computed from the same field rather than
stored, so it can never disagree with Celsius. A property may take an index, as Reading( i )
does, which reads like an array whose elements the type controls.
A property with only a setter is write-only: reading it is refused, TKO0934.
t.Celsius += 2.5 works on a property with both accessors: it calls the getter, applies the
operator and calls the setter. So does swap, and a property passed to a byref parameter is
written back through its setter when the call returns.
Auto-properties
Most properties only store a value. An auto-property says so in its declaration, by ending it with its accessors; the compiler writes both over a hidden field:
type Person
declare property Name as ustring get, set '' read and written like a field
declare property Age as int32 = 18 get, private set '' anyone reads, only Person writes
declare property Id as int32 get, init '' set once, by the constructor
declare constructor( byval newId as int32 )
declare sub Birthday( )
end type
constructor Person( byval newId as int32 )
Id = newId
end constructor
sub Person.Birthday( )
Age += 1
end sub
dim as Person p = Person( 42 )
p.Name = "Ann"
p.Birthday( )
print p.Name; ", age"; p.Age; ", id"; p.Id
Ann, age 19, id 42
get, set behaves like a plain field. get, private set can be read anywhere but written only by
the type's own code — the setter may be less visible than the getter, never more. get, init (or
get alone) can be assigned only by a constructor, which makes an identifier that never changes
after the object is built. = 18 gives the hidden field its first value. An auto-property has no
code of its own: as soon as the setter has a rule to keep, write an ordinary property.
Property blocks
A property block keeps the getter and setter together in one definition:
type Thermostat
declare property Celsius( ) as float64
declare property Celsius( byval value as float64 )
declare property Fahrenheit( ) as float64
private:
celsius_ as float64
end type
'' Both accessors in one block. In set, value is what was assigned;
'' in get, value is what will be returned.
property Thermostat.Celsius as float64
get
value = celsius_
end get
set
if value < -273.15 then value = -273.15
celsius_ = value
end set
end property
property Thermostat.Fahrenheit as float64
get
return celsius_ * 9 / 5 + 32
end get
end property
dim as Thermostat t
t.Celsius = 21.5
t.Celsius += 2.5 '' get, add, set
print "celsius"; t.Celsius; ", fahrenheit"; t.Fahrenheit
t.Celsius = -500
print "asked for -500, got "; t.Celsius
celsius 24, fahrenheit 75.2
asked for -500, got -273.15
Inside both sections, value is the value crossing the boundary: in set the value being assigned,
which may be adjusted before it is stored; in get the result, which starts at the type's default
and is returned at end get. return works too, and either section may be left out. get and
set open a section only when they stand alone on their line, so get #1, , buffer in an ordinary
getter is still the file statement.
A property may also take more than one index, be static (Grid.Created, belonging to the type),
or live in a namespace with no object at all. These forms, and the auto-property and block above,
are tiko's own: FreeBASIC has none of them (Appendix D).
Keeping fields private
Everything after private: in a type is reachable only from the type's own methods:
'' access.tiko -- private means private.
type Thermostat
declare property Celsius( ) as float64
private:
c_ as float64
end type
property Thermostat.Celsius( ) as float64
return c_
end property
dim as Thermostat t
t.c_ = 5 '' refused: c_ is private
access.tiko(13,2): error TKO0625: 'c_' is not accessible here
|
13 | t.c_ = 5 '' refused: c_ is private
| ^
That refusal is what made the setter's rule real. Without it, t.c_ = -500 would bypass the clamp
and the type's guarantee would be a suggestion. public: reopens access, and protected: — private
to everyone except types derived from this one — matters from Chapter 14.
Access applies to the calls the compiler makes for you as well as the ones you write:
'' privatector.tiko -- access applies to the calls you never write.
type Fortress
x as int32
private:
declare constructor( )
declare destructor( )
end type
constructor Fortress( )
end constructor
destructor Fortress( )
end destructor
dim as Fortress f '' needs both, and may call neither
privatector.tiko(15,17): error TKO0633: 'f' needs a constructor or destructor that is not accessible here
|
15 | dim as Fortress f '' needs both, and may call neither
| ^
privatector.tiko(15,17): error TKO0633: 'f' needs a constructor or destructor that is not accessible here
|
15 | dim as Fortress f '' needs both, and may call neither
| ^
A plain dim calls the constructor and, at the end of the scope, the destructor — so a type with
both private cannot be declared from outside at all. The message appears once for each. Making a
constructor private is how a type insists on being created through a function of its own choosing.
Members that belong to the type
'' statics.tiko -- members that belong to the type, and methods that
'' promise not to change the object.
type Ticket
declare constructor( )
declare static function Issued( ) as int32
declare const function Describe( ) as ustring
static issued_ as int32 '' one, shared by every Ticket
number_ as int32 '' one per Ticket
end type
dim as int32 Ticket.issued_ = 0 '' a static field is defined once
constructor Ticket( )
issued_ += 1
number_ = issued_
end constructor
'' A static method has no this: it is called on the type.
static function Ticket.Issued( ) as int32
return issued_
end function
'' A const method may read the object but not change it.
const function Ticket.Describe( ) as ustring
return "ticket #" & number_
end function
dim as Ticket a, b, c
print "issued so far:"; Ticket.Issued( )
print b.Describe( )
print c.Describe( )
issued so far: 3
ticket #2
ticket #3
A static field exists once, shared by every object of the type, and needs a definition outside
the type — dim as int32 Ticket.issued_ = 0 — in exactly one place. A static method has no
this and is called on the type itself, Ticket.Issued( ).
A const method promises not to change the object. It may read fields and call other const
methods, and the compiler holds it to the promise:
'' constmethod.tiko -- a const method that tries to change the object.
type Tally
declare const sub Bump( )
n as int32
end type
const sub Tally.Bump( )
n += 1 '' refused: Bump promised not to
end sub
constmethod.tiko(8,5): error TKO0664: 'n' cannot be written from a const method
|
8 | n += 1 '' refused: Bump promised not to
| ^
Objects on the heap
'' heapobjects.tiko -- objects that do not live in a scope.
type Noisy
declare constructor( )
declare constructor( byval n as int32 )
declare destructor( )
n as int32
end type
constructor Noisy( )
n = -1
print " default constructor"
end constructor
constructor Noisy( byval n as int32 )
this.n = n
print " constructor"; n
end constructor
destructor Noisy( )
print " destructor"; n
end destructor
'' new runs the constructor; delete runs the destructor, then frees.
print "new and delete:"
dim as Noisy ptr one = new Noisy( 7 )
delete one
'' An array in a scope constructs every element, and destroys them
'' all when the scope ends.
print "an array in a scope:"
scope
dim as Noisy pair( 1 to 2 )
pair( 2 ).n = 20
end scope
'' Placement new builds an object in memory you already have, and you
'' must run the destructor yourself before freeing that memory.
print "placement new:"
dim as any ptr raw = allocate( sizeof( Noisy ) )
dim as Noisy ptr placed = new( raw ) Noisy( 42 )
print " placed->n ="; placed->n
placed->destructor( )
deallocate( raw )
print "done"
new and delete:
constructor 7
destructor 7
an array in a scope:
default constructor
default constructor
destructor 20
destructor-1
placement new:
constructor 42
placed->n = 42
destructor 42
done
new T( … ) allocates and runs the matching constructor; delete runs the destructor and then
frees. An array of objects in a scope constructs every element, using the default constructor, and
destroys them all — last first — when the scope ends.
Placement new — new( address ) T( … ) — runs a constructor in memory you already have,
without allocating. It is for memory pools and for objects that must live at a particular address.
Because nothing was allocated, delete is wrong here: you call the destructor yourself, explicitly,
and free the memory the way you got it.
new T[ n ] and delete[] do the same for an array on the heap: a constructor for each element,
then a destructor for each, last first.
'' deletearray.tiko -- delete[] on an array of objects.
''
'' new[] runs a constructor for each element. delete[] must run the
'' destructor for each, or everything the objects own is leaked.
type Noisy
declare constructor( )
declare destructor( )
n as int32
end type
constructor Noisy( )
n = -1
print " constructor"
end constructor
destructor Noisy( )
print " destructor"; n
end destructor
dim as Noisy ptr three = new Noisy[ 3 ]
three[1].n = 10
delete[] three
print "done"
constructor
constructor
constructor
destructor-1
destructor 10
destructor-1
done
delete[] knows how many elements there are because, for a type with a destructor, new[]
recorded the count just before the first one. That is one more reason the families must not be
mixed: delete or deallocate on such memory starts at the wrong address.
A builder that needs none of the three
'' stringbuilder.tiko -- building text without copying it every time.
''
'' Joining with &= in a loop copies everything built so far on every
'' pass. A builder keeps spare room and copies only when it runs out.
type StringBuilder
declare sub Add( byref piece as string )
declare property Length( ) as int32
declare property Capacity( ) as int32
declare function Text( ) as string
private:
buf_ as string '' the room, mostly unused
len_ as int32 '' how much of it holds text
end type
sub StringBuilder.Add( byref piece as string )
dim as int32 n = len( piece )
if n = 0 then exit sub
'' Out of room: at least double it, so growth stays rare.
if len_ + n > len( buf_ ) then
dim as int32 grow = iif( len( buf_ ) = 0, 16, len( buf_ ) )
do while len_ + n > len( buf_ ) + grow : grow *= 2 : loop
buf_ &= space( grow )
end if
mid( buf_, len_ + 1, n ) = piece '' overwrite in place: no copy
len_ += n
end sub
property StringBuilder.Length( ) as int32
return len_
end property
property StringBuilder.Capacity( ) as int32
return len( buf_ )
end property
function StringBuilder.Text( ) as string
return left( buf_, len_ )
end function
dim as StringBuilder sb
for i as int32 = 1 to 12
sb.Add( "item" & i & ";" )
next
print "length"; sb.Length; ", capacity"; sb.Capacity
print left( sb.Text( ), 40 ); "..."
'' Every field copies itself correctly, so the type needs no copy
'' constructor, no operator let and no destructor of its own.
dim as StringBuilder copy_ = sb
copy_.Add( "more" )
print "original"; sb.Length; ", copy"; copy_.Length
length 75, capacity 128
item1;item2;item3;item4;item5;item6;item...
original 75, copy 79
Joining text with &= in a loop copies everything built so far on every pass. StringBuilder keeps
spare room in its buffer, writes each piece into it with Chapter 9's mid statement — which
overwrites in place and copies nothing — and grows by doubling, so growth is rare.
The copy at the end is the point. StringBuilder owns a buffer, and yet it has no copy constructor,
no operator let and no destructor — because its buffer is a string, which already knows how to
copy and free itself. That is the rule of zero in practice: let each field manage its own resource,
and the type composed of them needs no management at all.
What it costs
- A method call is a procedure call with the object passed by reference. No lookup, no
indirection — until Chapter 14 adds
virtual, which adds one. - An object is the size of its fields. Methods, properties and
staticfields add nothing to each instance. - A property is a call, but a short one that the optimiser usually inlines. Use them freely.
- A copy constructor runs on every
byvalpass and every return. For a type that owns memory, that is an allocation each time — pass itbyref ... as const. - Constructors and destructors run in scope order — free at run time, but only correct if they are written to be.
Summary
- Methods are declared in the type and defined as
Type.Member;thisis the object, andthis.is optional. - Constructors run on creation, destructors at the end of the scope, in reverse order of creation.
- Constructors overload;
this.constructor( … )delegates. A one-parameter constructor is an implicit conversion, with no way to switch it off. - Rule of three: a type with a destructor needs a copy constructor and an
operator let, and theletmust survive self-assignment. Rule of zero: better still, build from fields that copy themselves. - Properties are methods that read like fields; the setter enforces the type's rules. A property
with only a setter cannot be read (
TKO0934). An auto-property (get, private set,get, init) writes both accessors; a property block keeps them in one definition, withvaluecrossing the boundary. private:fields are reachable only from the type's own methods; access is enforced on the constructor and destructor calls the compiler makes too.staticfields are shared and defined once;staticmethods have nothis.constmethods may not change the object, and aconstobject may call onlyconstmethods (TKO0935).new/deleterun constructor and destructor; placementnewconstructs in existing memory, and you call the destructor yourself.new[]/delete[]run them for every element.
Exercises
- Write an
IntStackthat keeps its items in memory fromreallocate, with a constructor, destructor, copy constructor andoperator let. Show that a copied stack and an assigned stack are both independent of the original. Route both copy paths through one private helper. {solution="ex12-01"} - Write a
Temperaturethat stores kelvin and exposesCelsiusandFahrenheitas read-write properties, with the absolute-zero check in exactly one place. {solution="ex12-02"} - Give
Accounta private constructor and astatic function Create( … ) as Accountthat refuses a negative opening balance. What can callers no longer do, and why is that the point? - Remove the self-assignment check from
copying.tiko'soperator letand runc = c. What happens, and which line ofletis responsible? Tempconverts silently fromfloat64. Write a procedure taking aTemp, call it with a plain number, and decide whether that call should have compiled. How would you redesignTempif not?
Operator Overloading
Chapter 10 left a gap: two Points could be copied but not compared, because the compiler would not
guess what equality meant. Chapter 12 gave types methods, so a vector could have an Add method —
and code that reads a.Add( b.Scale( 2 ) ) where a mathematician would write a + 2b.
An operator overload is a procedure that the compiler calls when it sees an operator applied to your type. It is ordinary code with an unusual name, and it costs exactly what the same code costs as a method. What it buys is notation: a type that behaves like a number can be written like one.
The chapter ends with the other side of that — what not to overload — because notation that lies about what it does is worse than a method name that tells the truth.
A value type that reads like a number
'' vec2.tiko -- a value type that reads like a number.
type Vec2
x as float64
y as float64
declare operator +=( byref o as Vec2 ) '' members: they
declare operator cast( ) as ustring '' change or convert
end type '' this object
'' Binary operators are global: neither operand is "this".
operator + ( byref a as Vec2, byref b as Vec2 ) as Vec2
return type( a.x + b.x, a.y + b.y )
end operator
operator - ( byref a as Vec2, byref b as Vec2 ) as Vec2
return type( a.x - b.x, a.y - b.y )
end operator
operator - ( byref a as Vec2 ) as Vec2 '' unary minus
return type( -a.x, -a.y )
end operator
'' Scaling needs both orders, or 2 * v will not compile.
operator * ( byref a as Vec2, byval k as float64 ) as Vec2
return type( a.x * k, a.y * k )
end operator
operator * ( byval k as float64, byref a as Vec2 ) as Vec2
return a * k
end operator
operator = ( byref a as Vec2, byref b as Vec2 ) as boolean
return a.x = b.x andalso a.y = b.y
end operator
operator <> ( byref a as Vec2, byref b as Vec2 ) as boolean
return not ( a = b )
end operator
operator abs ( byref a as Vec2 ) as float64 '' the length
return sqr( a.x * a.x + a.y * a.y )
end operator
operator Vec2.+=( byref o as Vec2 )
x += o.x
y += o.y
end operator
operator Vec2.cast( ) as ustring
return "(" & x & ", " & y & ")"
end operator
dim as Vec2 a = ( 1, 2 ), b = ( 3, 4 )
print "a + b = "; a + b
print "b - a = "; b - a
print "-a = "; -a
print "2 * a = "; 2 * a
print "a + b * 2 = "; a + b * 2; " (* still binds first)"
print "abs( b ) ="; abs( b )
print "a = b: "; a = b; ", a <> b: "; a <> b
a += b
print "a += b -> "; a
a + b = (4, 6)
b - a = (2, 2)
-a = (-1, -2)
2 * a = (2, 4)
a + b * 2 = (7, 10) (* still binds first)
abs( b ) = 5
a = b: false, a <> b: true
a += b -> (4, 6)
An operator is defined like a function whose name is the operator: operator + ( a, b ) as Vec2.
From then on, a + b on two Vec2s is a call to it.
A few things in that listing are deliberate:
- Scaling needs both orders.
a * 2and2 * aare two different operators — the left operand's type differs — so the second is defined in terms of the first. Leave one out and the other order does not compile. - Unary minus is its own operator, with one parameter.
=and<>are separate. Defining one does not define the other, and they must agree, so<>is written asnot ( a = b ).absis overloadable, and for a vector the natural meaning is the length.- Precedence does not change.
a + b * 2still multiplies first, because the compiler parses the expression before it decides which procedures to call.
operator cast( ) as ustring is what makes print a + b work: print takes the vector's conversion
to text. It is also what makes "x" & v work, an assignment to a ustring, and the string
functions — ucase( v ), left( v, 2 ) — treat the vector as its text.
Member or global
Some operators are declared inside the type and some outside it, and the compiler enforces which:
'' wrongplace.tiko -- a binary operator declared as a member.
type Vec2
x as float64
y as float64
declare operator +( byref o as Vec2 ) as Vec2 '' refused
end type
wrongplace.tiko(5,13): error TKO0790: operator '+' cannot be a member of a type
|
5 | declare operator +( byref o as Vec2 ) as Vec2 '' refused
| ^
The rule follows from what each operator acts on:
| Kind | Where | Why |
|---|---|---|
+ - * / \ mod ^, comparisons, and or xor, unary - not, abs | global | neither operand is privileged; either side may be another type |
+= and the other compound assignments | member | they change the object on the left |
let, cast | member | they assign into or convert from this object |
[], @ | member | they reach into this object |
->, * (dereference) | global | they take the object as their operand |
for, step, next | member | the loop's counter is the object |
new, delete, new[], delete[] | member | they allocate for this type |
@ is the opposite case from +: as a global it is refused with
TKO0697: operator '@' must be a member of a type.
A global operator is not a method, so it sees only the public parts of the type. That is usually right — an operator built from the type's public interface cannot break its invariants — and when it is not, the operator can call a method that does the work.
Conversions in both directions
'' fixedpoint.tiko -- exact decimals, stored as whole thousandths.
''
'' 0.1 + 0.2 is not 0.3 in floating point. Stored as an integer count
'' of thousandths, it is exactly 300.
type Fixed
raw as int64 '' thousandths
declare operator let( byval v as float64 )
declare operator cast( ) as float64
declare operator cast( ) as string
end type
'' let from another type: assigning a float64 rounds it once, here.
operator Fixed.let( byval v as float64 )
raw = cint( v * 1000 )
end operator
operator Fixed.cast( ) as float64
return raw / 1000
end operator
operator Fixed.cast( ) as string
dim as int64 whole = raw \ 1000
dim as int64 part = abs( raw ) mod 1000
dim as ustring sign = iif( raw < 0 andalso whole = 0, "-", "" )
return sign & whole & "." & right( "00" & part, 3 )
end operator
operator + ( byref a as Fixed, byref b as Fixed ) as Fixed
dim as Fixed r
r.raw = a.raw + b.raw
return r
end operator
operator * ( byref a as Fixed, byref b as Fixed ) as Fixed
dim as Fixed r
r.raw = ( a.raw * b.raw ) \ 1000
return r
end operator
operator < ( byref a as Fixed, byref b as Fixed ) as boolean
return a.raw < b.raw
end operator
operator = ( byref a as Fixed, byref b as Fixed ) as boolean
return a.raw = b.raw
end operator
dim as Fixed p, q, sum, want
p = 0.1
q = 0.2
want = 0.3
sum = p + q
print "fixed: 0.1 + 0.2 = "; sum; " equal to 0.3: "; sum = want
dim as float64 fp = 0.1 + 0.2
print "float64: 0.1 + 0.2 ="; fp; " equal to 0.3: "; cbool( fp = 0.3 )
dim as Fixed price, qty
price = 19.99
qty = 3
print "19.99 * 3 = "; price * qty
print "as a float64:"; cast( float64, price * qty )
dim as Fixed twenty
twenty = 20
print "19.99 < 20: "; price < twenty
fixed: 0.1 + 0.2 = 0.300 equal to 0.3: true
float64: 0.1 + 0.2 = 0.3 equal to 0.3: false
19.99 * 3 = 59.970
as a float64: 59.97
19.99 < 20: true
Fixed stores an amount as a whole number of thousandths, so 0.1 + 0.2 is exactly 300 thousandths
and compares equal to 0.3. The float64 line beside it compares unequal — although print shows
it as 0.3, because print rounds to fifteen significant digits. The difference is real and
invisible; that is the whole case for a fixed-point type.
Two conversions make it convenient:
operator let( byval v as float64 )lets afloat64be assigned to aFixed. The rounding happens once, there, and nowhere else.operator cast( ) as float64lets aFixedbe used where afloat64is wanted.cast( float64, price * qty )calls it explicitly; passing aFixedto a procedure that takes afloat64calls it implicitly.
A type may have several cast operators — Fixed has two — distinguished by the type they produce.
The built-in maths functions take it too. sqr, abs, int and the rest accept a type with a
numeric cast, convert, and work on the number:
'' castmath.tiko -- a built-in maths function given a type with a cast.
''
'' Fixed converts itself to float64, so sqr( f ) is sqr( 2.5 ):
'' 1.58113883008419.
type Fixed
raw as int64
declare operator cast( ) as float64
end type
operator Fixed.cast( ) as float64
return raw / 1000
end operator
dim as Fixed f
f.raw = 2500
dim as float64 r = sqr( f )
print "sqr( f ) ="; r
sqr( f ) = 1.58113883008419
A type with no numeric cast — and no operator sqr of its own — is refused: TKO0928.
A cast operator is an implicit conversion, like a one-parameter constructor. Chapter 12's
warning applies in the other direction: once Fixed converts to float64, it converts wherever a
float64 would be accepted, including places nobody intended.
Two conversions are worse than one. Give a type casts to both float64 and int64, call a procedure
overloaded for both, and the call is genuinely ambiguous — there is no reason to prefer either — so
it is refused, TKO0660. Adding a second cast can therefore turn a line that compiled into one
that does not.
Give a type the conversions its users genuinely need, and prefer a named method — ToFloat( ) —
for the rest.
Reaching into an object
'' indexing.tiko -- [] , let from another type, and smart handles.
type Grid
declare constructor( byval w as int32, byval h as int32 )
declare destructor( )
declare operator []( byval i as int32 ) byref as int32
declare operator let( byval fill as int32 )
declare operator @( ) as int32 ptr
w as int32
h as int32
private:
cells as int32 ptr
end type
constructor Grid( byval w as int32, byval h as int32 )
this.w = w
this.h = h
cells = callocate( w * h, sizeof( int32 ) )
end constructor
destructor Grid( )
deallocate( cells )
end destructor
'' Returning byref is what lets g[4] appear on the LEFT of =.
operator Grid.[]( byval i as int32 ) byref as int32
return cells[i]
end operator
'' let from an int32: "g = 7" fills every cell.
operator Grid.let( byval fill as int32 )
for i as int32 = 0 to w * h - 1 : cells[i] = fill : next
end operator
'' @g gives the first cell, which is what a C function would want.
operator Grid.@( ) as int32 ptr
return cells
end operator
dim as Grid g = Grid( 3, 2 )
g = 7
g[4] = 42
print "g[0], g[4], g[5] ="; g[0]; g[4]; g[5]
print "via @g:"; ( @g )[4]
'' A handle that forwards -> and * to the Grid it refers to. These
'' two are global operators, taking the handle as their operand.
type GridHandle
target as Grid ptr
end type
operator -> ( byref hnd as GridHandle ) byref as Grid
return *hnd.target
end operator
operator * ( byref hnd as GridHandle ) byref as Grid
return *hnd.target
end operator
dim as GridHandle hd
hd.target = @g '' careful: this is Grid's own @, so a cell!
print "hd.target is the cells, not the Grid: "; _
cbool( hd.target = cast( Grid ptr, @g ) )
hd.target = varptr( g ) '' varptr is never overloaded
print "through ->: w ="; hd->w; ", through *: h ="; ( *hd ).h
g[0], g[4], g[5] = 7 42 7
via @g: 42
hd.target is the cells, not the Grid: true
through ->: w = 3, through *: h = 2
operator [] returning byref is what lets g[4] appear on the left of an assignment: the
caller receives the element itself, not a copy of it. A [] that returns by value gives a read-only
index.
operator let( byval fill as int32 ) is let from a different type: g = 7 fills every cell.
let from the type's own type is the copy assignment Chapter 12 covered; let from anything else
is a conversion into the object.
GridHandle forwards -> and * to the Grid it holds, which is the shape of every "smart"
handle — a type that behaves like a pointer while doing something extra, such as counting references,
which Chapter 21's SharedPtr does.
The middle of the listing is a warning about @. Because Grid overloads @ to return its cells,
@g no longer means the address of g — and assigning it to a Grid ptr hands the handle the
cells instead of the grid. The compiler noticed:
indexing.tiko(61,1): warning TKO0830: suspicious pointer assignment: a 'grid ptr' from a 'int32 ptr'
varptr( g ) is never overloaded, and always means the address of g, which is why the listing uses
it for the real assignment.
A type that counts
'' rangecounter.tiko -- a type that a for loop can count with.
''
'' for e as T = first to last [step s] calls five operators on T:
'' for (once, at the start), next (should the loop go on?) and step
'' (advance) -- each in a form with and without the step value.
type Every
declare constructor( byval startAt as int32, byval stepBy as int32 )
declare operator for( )
declare operator for( byref stp as Every )
declare operator step( )
declare operator step( byref stp as Every )
declare operator next( byref last as Every ) as integer
declare operator next( byref last as Every, _
byref stp as Every ) as integer
declare operator cast( ) as string
value as int32
by_ as int32
end type
constructor Every( byval startAt as int32, byval stepBy as int32 )
value = startAt
by_ = stepBy
end constructor
operator Every.for( ) '' nothing to prepare
end operator
operator Every.for( byref stp as Every )
end operator
operator Every.step( ) '' no step clause: own stride
value += by_
end operator
operator Every.step( byref stp as Every ) '' step clause given
value += stp.value
end operator
operator Every.next( byref last as Every ) as integer
return value <= last.value
end operator
operator Every.next( byref last as Every, _
byref stp as Every ) as integer
return value <= last.value
end operator
operator Every.cast( ) as string
return str( value )
end operator
print "every third from 1 to 12:";
for e as Every = Every( 1, 3 ) to Every( 12, 0 )
print " "; e;
next
print
print "0 to 20, step 5:";
for e as Every = Every( 0, 1 ) to Every( 20, 0 ) step Every( 5, 0 )
print " "; e;
next
print
every third from 1 to 12: 1 4 7 10
0 to 20, step 5: 0 5 10 15 20
for e as T = first to last step s works for any type that implements the counting protocol:
| Operator | Called | Job |
|---|---|---|
for | once, before the first pass | prepare; usually nothing |
next( last ) | before each pass | return non-zero to keep going |
step | after each pass | advance |
Each comes in two forms, with and without the step value, matching whether the for has a step
clause. Nothing in the protocol assumes a direction or even a number — next decides when to stop —
so a counter can walk dates, a range of keys or a countdown, as Exercise 2 does.
For walking the elements of a container rather than counting, Chapter 20's for each and its
iterator protocol are the better tool.
Taking over allocation
'' allocators.tiko -- a type that controls where its objects live.
type Tracked
declare operator new( byval size as uinteger ) as any ptr
declare operator delete( byval p as any ptr )
n as int32
end type
dim shared as int32 live '' a count every Tracked shares
'' new and delete are static members: there is no object yet in new,
'' and none left in delete. They allocate; they do not construct.
operator Tracked.new( byval size as uinteger ) as any ptr
live += 1
print " allocating "; size; " bytes"
return allocate( size )
end operator
operator Tracked.delete( byval p as any ptr )
live -= 1
print " releasing"
deallocate( p )
end operator
dim as Tracked ptr a = new Tracked
dim as Tracked ptr b = new Tracked
print "live objects:"; live
delete a
delete b
print "live objects:"; live
allocating 4 bytes
allocating 4 bytes
live objects: 2
releasing
releasing
live objects: 0
operator new and operator delete replace the allocation step for one type. They do not construct
or destroy — the constructor and destructor still run, before and after — they only decide where the
bytes come from and go back to. That makes them the place for a memory pool, an allocation counter
as here, or a check that every object was freed.
They are implicitly static: in new the object does not exist yet, and in delete it no longer
does, so there is no this — and neither this nor a bare field may be used in either
(TKO0937).
What not to overload
An operator is a promise about meaning that the reader will hold you to without reading the code. Break it and every expression using the type becomes a puzzle.
- Keep the meaning.
+combines,-takes away,=is equality and nothing else. A+that appends to a log or a*that means "repeat" makes correct-looking code wrong. - Keep the algebra where it applies. If
a + bexists,a - b,-aanda += bshould exist and agree with it. If=exists,<>must be its negation. - Keep the cost obvious.
a + breads as cheap. If it allocates, copies a megabyte or touches a disk, give it a name that says so. - Do not overload
@unless the type genuinely is a handle to something else — the listing above shows what it does to every@in the program. - Do not overload
and,orandnotfor logic. The built-inandalsoandorelseshort-circuit; an overloadedandcannot, and a reader cannot tell which they are looking at. - When the operation has no single obvious meaning, use a method.
Dot( a, b )andCross( a, b )are better than picking one of them to be*.
What it costs
- An operator is a procedure call with the same cost as the method it replaces, and short ones are inlined. Notation is free.
- Returning a type by value copies it. For a small value type like
Vec2that is the right design; for a type that owns memory, eacha + bconstructs a temporary and runs its copy constructor. - Conversions run silently. An implicit
castorletin a loop is a call per iteration that nothing in the source shows.
Summary
operator +and its kind are procedures the compiler calls for your type. Precedence and associativity never change.- Binary and unary arithmetic, comparisons,
->and*are global; compound assignment,let,cast,[],@, theforprotocol andnew/deleteare members — the compiler enforces it (TKO0790,TKO0697). - Define both orders of a mixed-type operator, and define
<>alongside=. cast( ) as ustringmakes a type printable, and usable with&and the string functions.letfrom another type andcastto another type are conversions in and out; the built-in maths functions use a numericcast.[]returningbyrefgives an assignable index.- Overloading
@changes the meaning of every@;varptris never overloaded. for,stepandnextmake a type aforcounter, in either direction.operator new/deletechoose where memory comes from; construction and destruction still happen around them.- Overload only where the meaning is the obvious one, and keep the algebra and the cost honest.
Exercises
- Write
Money, stored in whole cents, with+,-, multiplication by a count,<,=and acastto text like$15.30. Add a thousand ten-cent amounts and check the total is exactly $100. Why is there noMoney * Money? {solution="ex13-01"} - Write a
Countdowntype that aforloop counts down with, printingliftoffat zero. What happens when the start is below the end? {solution="ex13-02"} - Add
operator *toVec2as a dot product, then try to add a second*for the cross product. What stops you, and what does that tell you about overloading*here at all? - Give
Fixedanoperator /. What should1 / 3give, and where exactly does the rounding happen? - Remove
operator <>fromvec2.tikoand keep thea <> bline. What does the compiler say, and would you rather it had derived<>from=for you?
Inheritance and Polymorphism
A drawing program has circles, rectangles and squares, and somewhere in it a loop that has to ask
each one for its area. Written with the tools of Chapter 12, every shape is its own type, and the
loop needs a select case on some "kind" field to know which code to run. The next shape means
editing that loop, and every other loop like it.
Inheritance lets one type start as another and add to it. Polymorphism lets a single call reach the right code for whatever object is really there, so the loop asks "what is your area?" and never needs to know what it is talking to.
tiko gives you two ways to build them, and this chapter is in two parts. Part 1 is the classic model, the one C++ and FreeBASIC share: a base type with virtual methods, extended by every shape, and the three places it goes wrong: an object deleted through the wrong pointer, an object passed by value, and a relationship that was never really "is a". Part 2 is interfaces: a contract that any type can take on, as many as it needs, without spending its one base on it. The two mix freely, and the chapter ends with how to choose.
Part 1: The classic model
A derived type starts as its base and adds to it, and a method the base marks virtual can be
replaced by each derived type. Everything in this part works the same whether the type is
declared with type … extends or, as Part 2 shows, with class.
Extending a type
'' extends.tiko -- a derived type starts as its base, and adds to it.
type Vehicle
declare constructor( byval wheels as int32 = 4 )
declare function Describe( ) as ustring
wheels as int32
speed as int32
end type
constructor Vehicle( byval wheels as int32 )
this.wheels = wheels
end constructor
function Vehicle.Describe( ) as ustring
return wheels & " wheels at " & speed & " km/h"
end function
type Bicycle extends Vehicle
declare constructor( byval gears as int32 )
declare function Describe( ) as ustring
gears as int32
end type
constructor Bicycle( byval gears as int32 )
base( 2 ) '' build the Vehicle part first
this.gears = gears
end constructor
function Bicycle.Describe( ) as ustring
return base.Describe( ) & ", " & gears & " gears"
end function
dim as Bicycle bike = Bicycle( 21 )
bike.speed = 25 '' a field it inherited
print bike.Describe( )
dim as Vehicle car
print car.Describe( )
print "sizes:"; sizeof( Vehicle ); sizeof( Bicycle )
print "offsets:"; offsetof( Bicycle, wheels ); _
offsetof( Bicycle, speed ); offsetof( Bicycle, gears )
2 wheels at 25 km/h, 21 gears
4 wheels at 0 km/h
sizes: 8 12
offsets: 0 4 8
type Bicycle extends Vehicle makes a Bicycle a Vehicle with more fields. It has wheels and
speed without declaring them, and the offsets show where they live: the base's fields come first,
exactly as they are laid out in a Vehicle, and gears follows them. That layout is the point. A
Bicycle starts with a complete Vehicle, so anything that works on a Vehicle can be handed one.
base( 2 ) runs the base's constructor first. The Vehicle part of a Bicycle is built
before the Bicycle constructor's own body runs, and base( … ) chooses which Vehicle
constructor builds it and with what. Written without a base( … ) call, the base's default
constructor runs instead.
base.Describe( ) calls the base's version of a method from inside the derived one. Bicycle's
Describe has the same name as Vehicle's and hides it: on a Bicycle, Describe means
Bicycle.Describe. Reusing the base's text through base. means the two descriptions cannot
drift apart.
A base without a default constructor
Vehicle's constructor takes wheels = 4 as a default, so it can be called with nothing at all.
That default is load-bearing:
'' nodefault.tiko -- a base with no default constructor leaves the
'' derived type with no way to copy itself.
type Vehicle
declare constructor( byval wheels as int32 )
wheels as int32
end type
constructor Vehicle( byval wheels as int32 )
this.wheels = wheels
end constructor
type Bicycle extends Vehicle
declare constructor( )
gears as int32
end type
constructor Bicycle( )
base( 2 )
end constructor
dim as Bicycle bike
print bike.wheels
nodefault.tiko(13,1): error TKO0926: 'bicycle' needs a copy constructor: its base has no constructor that takes no argument, so none can be made for it
|
13 | type Bicycle extends Vehicle
| ^
A derived type gets a copy constructor for free, and that free one works by building a fresh base part with the base's default constructor, then copying every field across. With no default constructor in the base, there is nothing to build the fresh part with, so the derived type must write its own copy constructor. fbc refuses the same program with its error 188.
Give the base a constructor that takes no arguments, or default its parameters as extends.tiko
does, and every type derived from it can be copied without writing anything.
base( … ) passes whatever the base's constructors take, chosen the way any overloaded call is.
Here it hands a label up, and the base's default value for it, "unnamed", is what keeps
Sensor copyable:
'' basestring.tiko -- passing text up to the base's constructor.
type Labelled
declare constructor( byref label as ustring = "unnamed" )
label as ustring
end type
constructor Labelled( byref label as ustring )
this.label = label
end constructor
type Sensor extends Labelled
declare constructor( byref label as ustring, _
byval reading as float64 )
reading as float64
end type
constructor Sensor( byref label as ustring, byval reading as float64 )
base( label )
this.reading = reading
end constructor
dim as Sensor s = Sensor( "boiler", 81.5 )
print s.label; " reads"; s.reading
boiler reads 81.5
Calling through the base
A Bicycle can be handed to anything that takes a Vehicle. What happens to its methods when it
is?
'' hiding.tiko -- without virtual, the call is chosen by the type the
'' compiler sees, not by the object that is really there.
type Vehicle
declare function Kind( ) as ustring
wheels as int32
end type
function Vehicle.Kind( ) as ustring
return "vehicle"
end function
type Bicycle extends Vehicle
declare function Kind( ) as ustring
end type
function Bicycle.Kind( ) as ustring
return "bicycle"
end function
sub Show( byref v as Vehicle )
print " Show sees a "; v.Kind( )
end sub
dim as Bicycle bike
print "directly: "; bike.Kind( )
Show( bike )
directly: bicycle
Show sees a vehicle
Show declares its parameter as a Vehicle, so inside Show the compiler knows only that it has
a Vehicle, and v.Kind( ) is compiled as a call to Vehicle.Kind. Which object arrives at run
time does not enter into it. Hiding a method changes what the name means on the derived type; it
changes nothing for code that sees the object as its base.
That is the right behaviour for most methods, and it costs nothing. But it is exactly wrong for the drawing program's loop, which sees every shape as "a shape" and needs each one's own answer.
Virtual methods
'' shapes.tiko -- one loop over different shapes, each one answering
'' for itself.
type Shape extends Object
declare abstract function Area( ) as float64
declare virtual function Kind( ) as ustring
declare function Describe( ) as ustring
end type
function Shape.Kind( ) as ustring
return "shape"
end function
'' Not virtual, yet the calls inside it are.
function Shape.Describe( ) as ustring
return Kind( ) & ", area " & Area( )
end function
type Circle extends Shape
declare constructor( byval r as float64 )
declare function Area( ) as float64 override
declare function Kind( ) as ustring override
r as float64
end type
constructor Circle( byval r as float64 )
this.r = r
end constructor
function Circle.Area( ) as float64
return 3.14159 * r * r
end function
function Circle.Kind( ) as ustring
return "circle"
end function
type Rect extends Shape
declare constructor( byval w as float64 = 1, _
byval h as float64 = 1 )
declare function Area( ) as float64 override
'' virtual AND override: a type derived from Rect may override
'' it again.
declare virtual function Kind( ) as ustring override
w as float64
h as float64
end type
constructor Rect( byval w as float64, byval h as float64 )
this.w = w : this.h = h
end constructor
function Rect.Area( ) as float64
return w * h
end function
function Rect.Kind( ) as ustring
return "rectangle"
end function
type Square extends Rect
declare constructor( byval side as float64 )
declare function Kind( ) as ustring override
end type
constructor Square( byval side as float64 )
base( side, side )
end constructor
function Square.Kind( ) as ustring
return "square"
end function
dim as Circle c = Circle( 1 )
dim as Rect r = Rect( 2, 3 )
dim as Square s = Square( 2 )
dim as Shape ptr all( 0 to 2 ) = { @c, @r, @s }
for i as int32 = lbound( all ) to ubound( all )
print all( i )->Describe( )
next
circle, area 3.14159
rectangle, area 6
square, area 4
Four words do the work.
extends Object.Objectis the built-in root type. Extending it gives every object of the type one hidden field, a pointer to the type's table of virtual methods, and that pointer is how a call finds the right code at run time.virtualmarks a method that derived types may replace. A call to it looks the method up in the object's table, so it reaches the version belonging to the object that is really there.overridemarks a method that replaces a virtual one. The compiler checks that there is something to replace:overrideon a method the base did not make virtual isTKO0621.abstractdeclares a virtual method with no body at all.Shapecannot say what its area is, so it says only that every shape has one.
Shape.Describe is not virtual, yet its output is different for every shape, because the two calls
inside it are. That is a common and useful shape: the base writes the procedure once, and the
derived types supply the parts that differ.
An override is not itself virtual. Circle.Kind replaces Shape.Kind, but a type derived
from Circle could not override it again. Rect declares its Kind as virtual … override,
which both replaces Shape's and opens it to Rect's own descendants. That is what lets Square
override it in turn. Square keeps Rect's Area, because a square's area is a rectangle's.
Leave virtual off, and the next level down cannot override:
'' notvirtual.tiko -- an override is not itself virtual. A type
'' derived from Dog that writes Speak again hides it; it does not
'' override it.
type Animal extends Object
declare virtual function Speak( ) as ustring
end type
function Animal.Speak( ) as ustring
return "..."
end function
type Dog extends Animal
declare function Speak( ) as ustring override
end type
function Dog.Speak( ) as ustring
return "woof"
end function
type Puppy extends Dog
declare function Speak( ) as ustring '' hides Dog's
end type
function Puppy.Speak( ) as ustring
return "yip"
end function
sub Listen( byref a as Animal )
print " through Animal: "; a.Speak( )
end sub
dim as Puppy pup
print "directly: "; pup.Speak( )
Listen( pup )
directly: yip
through Animal: woof
Dog.Speak is an override but not virtual, so Puppy's Speak hides it without replacing it.
Called on a Puppy directly it answers yip; called through an Animal it reaches Dog's.
Writing override on Puppy.Speak would not help: there is no open slot for it to fill, and the
compiler says so (TKO0621). The fix is one word in Dog, virtual … override, exactly as
Rect does in shapes.tiko.
C++ programmers expect the opposite, because there an override stays virtual all the way down. tiko follows fbc, where it does not, so code moves between the two compilers unchanged.
Abstract types
'' abstract.tiko -- a type with an unwritten method cannot be created.
type Shape extends Object
declare abstract function Area( ) as float64
end type
dim as Shape s
print s.Area( )
abstract.tiko(7,14): error TKO0620: 's' has an abstract type, which cannot be instantiated
|
7 | dim as Shape s
| ^
A type with an abstract method is incomplete on purpose, so it cannot be created: there would be
nothing to run when someone asked for its area. A derived type that fills in every abstract method
can be created, and one that leaves any of them unwritten is abstract too, with the same error.
What can exist is a Shape ptr or a byref Shape parameter, pointing at a complete type.
Seeing the table
tiko --dump-vtable prints each type's table of virtual methods. For shapes.tiko:
object vtable 0 slot(s)
shape vtable 2 slot(s)
slot 0 <abstract>
slot 1 _ZN5SHAPE4KINDEv
circle vtable 2 slot(s)
slot 0 _ZN6CIRCLE4AREAEv
slot 1 _ZN6CIRCLE4KINDEv
rect vtable 2 slot(s)
slot 0 _ZN4RECT4AREAEv
slot 1 _ZN4RECT4KINDEv
square vtable 2 slot(s)
slot 0 _ZN4RECT4AREAEv
slot 1 _ZN6SQUARE4KINDEv
vtables: 5
Every type in the family has the same two slots in the same order, Area then Kind. A derived
type copies its base's table and replaces the entries it overrides, so a call through a Shape
always looks in slot 0 for Area, whichever table it finds. Shape's slot 0 is <abstract>,
which is why a Shape cannot exist; Square's slot 0 still names Rect.Area, which it inherited.
The names are mangled the way C++ mangles them: _ZN6SQUARE4KINDEv is SQUARE::KIND().
The dump is the place to look when a call reaches the wrong method. It shows what the compiler built, and comparing it with what you meant is usually enough to find the mistake.
Virtual destructors
An object created with new and deleted through a pointer to its base is destroyed by whichever
destructor that pointer's type names:
'' vdtor.tiko -- delete through a base pointer runs the destructor the
'' pointer's type names, unless that destructor is virtual.
type Plain
declare destructor( )
id as int32
end type
destructor Plain( )
print " ~Plain"
end destructor
type PlainFile extends Plain
declare destructor( )
handle as int32
end type
destructor PlainFile( )
print " ~PlainFile: the file is closed"
end destructor
type Careful extends Object
declare virtual destructor( )
end type
destructor Careful( )
print " ~Careful"
end destructor
type CarefulFile extends Careful
declare destructor( )
handle as int32
end type
destructor CarefulFile( )
print " ~CarefulFile: the file is closed"
end destructor
print "plain destructor:"
dim as Plain ptr p = new PlainFile
delete p
print "virtual destructor:"
dim as Careful ptr q = new CarefulFile
delete q
plain destructor:
~Plain
virtual destructor:
~CarefulFile: the file is closed
~Careful
delete through a base pointer skips the derived destructor unless the base's is virtual.
delete p ran ~Plain and nothing else. PlainFile's destructor, the one that closes the file,
never ran, and nothing said so: the program compiled cleanly, and the leaked handle surfaces
somewhere else, later.
With declare virtual destructor( ) in the base, delete q finds the right destructor through the
table, runs it, and then runs the base's, in that order. A type meant to be derived from and
deleted through its base declares a virtual destructor, even an empty one.
The rule from the previous section applies to destructors as well, and this is where it costs most. A destructor is virtual only if it says so, so a middle level that declares a plain one closes the chain below it:
'' vdtormiddle.tiko -- 'virtual' is not inherited by a destructor that
'' does not say it. Each level that will be deleted through a base
'' pointer declares its destructor virtual.
type Resource extends Object
declare virtual destructor( )
end type
destructor Resource( )
print " ~Resource"
end destructor
type File extends Resource
declare destructor( ) '' not virtual
handle as int32
end type
destructor File( )
print " ~File"
end destructor
type LogFile extends File
declare destructor( )
lines as int32
end type
destructor LogFile( )
print " ~LogFile: flushed"
end destructor
type SafeFile extends Resource
declare virtual destructor( ) '' virtual again
handle as int32
end type
destructor SafeFile( )
print " ~SafeFile"
end destructor
type SafeLog extends SafeFile
declare destructor( )
lines as int32
end type
destructor SafeLog( )
print " ~SafeLog: flushed"
end destructor
print "middle destructor not virtual:"
dim as Resource ptr r = new LogFile
delete r
print "middle destructor virtual:"
r = new SafeLog
delete r
middle destructor not virtual:
~File
~Resource
middle destructor virtual:
~SafeLog: flushed
~SafeFile
~Resource
LogFile's destructor never ran, although Resource's is virtual and LogFile declares one of its
own. File declared a destructor without virtual, and from there down nothing overrides.
SafeFile says virtual again, and its descendant is destroyed properly. The same happens when
the middle type declares no destructor at all: the one the compiler writes for it is not virtual.
Every level that may be deleted through a base pointer declares virtual destructor.
Asking what an object really is
Sometimes the code holding an Animal needs something only a Dog has:
'' isdown.tiko -- asking what an object really is, and treating it as
'' that type once the answer is yes.
type Animal extends Object
declare virtual function Speak( ) as ustring
end type
function Animal.Speak( ) as ustring
return "..."
end function
type Dog extends Animal
declare virtual function Speak( ) as ustring override
tricks as int32
end type
function Dog.Speak( ) as ustring
return "woof"
end function
type Puppy extends Dog
declare function Speak( ) as ustring override
end type
function Puppy.Speak( ) as ustring
return "yip"
end function
sub Inspect( byref a as Animal )
print a.Speak( );
if a is Dog then
'' a really is a Dog (or something derived from one)
dim as Dog ptr d = cast( Dog ptr, @a )
print ", knows"; d->tricks; " tricks";
end if
if a is Puppy then print ", and is a puppy";
print
end sub
dim as Animal cat
dim as Dog rex
rex.tricks = 3
dim as Puppy pup
pup.tricks = 1
Inspect( cat )
Inspect( rex )
Inspect( pup )
...
woof, knows 3 tricks
yip, knows 1 tricks, and is a puppy
a is Dog is true when the object is a Dog or anything derived from one, which is why the
puppy passes both tests. Once it is true, cast( Dog ptr, @a ) treats the same object as a Dog.
The cast itself checks nothing, so always guard it with is.
is needs the type to extend Object, because the answer is read from the hidden table pointer,
and it only accepts a type derived from the variable's own type: asking whether an Animal is
an Animal is refused (TKO0798), and asking it of a type with no table is TKO0799. fbc refuses
both too.
Use is sparingly. A chain of if a is Dog … elseif a is Cat … is the select case this chapter
set out to remove, wearing different clothes. If the question is "what should this animal do", the
answer is a virtual method.
Passing by value
'' slicing.tiko -- a derived object passed by value arrives as a copy of
'' its base part, and only that.
type Animal extends Object
declare virtual function Speak( ) as ustring
legs as int32
end type
function Animal.Speak( ) as ustring
return "..."
end function
type Dog extends Animal
declare function Speak( ) as ustring override
tricks as int32
end type
function Dog.Speak( ) as ustring
return "woof"
end function
sub ByReference( byref a as Animal )
print "byref: "; a.Speak( ); ", a Dog? "; ( a is Dog )
end sub
sub ByValue( byval a as Animal )
print "byval: "; a.Speak( ); ", a Dog? "; ( a is Dog )
end sub
dim as Dog rex
rex.tricks = 3
ByReference( rex )
ByValue( rex )
dim as Animal copy = rex
print "copied: "; copy.Speak( )
'' The hidden field: one pointer, to the type's table of methods.
print "sizes:"; sizeof( Object ); sizeof( Animal ); sizeof( Dog )
byref: woof, a Dog? -1
byval: ..., a Dog? 0
copied: ...
sizes: 8 16 24
Passing a derived object by value keeps only its base part. ByValue received a fresh
Animal, made by copying the Animal part of rex, and that copy is an Animal through and
through. It speaks ..., it is not a Dog, and tricks is gone. The same happens on assignment:
dim as Animal copy = rex is a plain Animal.
Nothing warns about this, because nothing is wrong with the program from the compiler's point of
view: an Animal was asked for and an Animal was made. Pass an object of a type with virtual
methods byref, or by pointer, never byval.
The last line of the output is the hidden field from the previous section made visible. Object
is eight bytes, the table pointer and nothing else. Animal adds legs and rounds up to 16, and
Dog adds tricks and rounds to 24.
Composition, and abstract types as contracts
Inheritance says "is a". A Bicycle is a Vehicle, and a Square is a Rect. When the
relationship is "has a" or "uses a", inheritance is the wrong tool, even when it would save typing:
a Circle that extends a Point to borrow its x and y can be passed to every procedure that
expects a point, and none of them meant a circle. Give the Circle a Point field instead.
'' pluginhost.tiko -- an abstract type as the contract, and a host that
'' is built from parts rather than derived from one.
'' The contract: anything that can transform a line of text.
type Plugin extends Object
declare abstract function Name( ) as ustring
declare abstract function Apply( byref text as ustring ) as ustring
declare virtual destructor( )
end type
destructor Plugin( )
end destructor
type Shout extends Plugin
declare function Name( ) as ustring override
declare function Apply( byref text as ustring ) as ustring override
end type
function Shout.Name( ) as ustring
return "shout"
end function
function Shout.Apply( byref text as ustring ) as ustring
return ucase( text ) & "!"
end function
type Reverse extends Plugin
declare function Name( ) as ustring override
declare function Apply( byref text as ustring ) as ustring override
end type
function Reverse.Name( ) as ustring
return "reverse"
end function
function Reverse.Apply( byref text as ustring ) as ustring
dim as ustring result
for i as int32 = len( text ) to 1 step -1
result &= mid( text, i, 1 )
next
return result
end function
'' The host HAS plugins; it is not one. It owns them, so it deletes
'' them -- through Plugin ptr, which is why the destructor is virtual.
type Host
declare destructor( )
declare sub Add( byval p as Plugin ptr )
declare function Run( byref text as ustring ) as ustring
plugins( any ) as Plugin ptr
end type
destructor Host( )
for i as int32 = lbound( plugins ) to ubound( plugins )
delete plugins( i )
next
end destructor
sub Host.Add( byval p as Plugin ptr )
redim preserve plugins( 0 to ubound( plugins ) + 1 )
plugins( ubound( plugins ) ) = p
end sub
function Host.Run( byref text as ustring ) as ustring
dim as ustring t = text
for i as int32 = lbound( plugins ) to ubound( plugins )
t = plugins( i )->Apply( t )
print " after "; plugins( i )->Name( ); ": "; t
next
return t
end function
scope
dim as Host h
h.Add( new Reverse )
h.Add( new Shout )
print h.Run( "plugins" )
end scope
after reverse: snigulp
after shout: SNIGULP!
SNIGULP!
Plugin is all contract: two abstract methods and a virtual destructor, with no data. Any type that
extends it and fills in Name and Apply is a plugin, and Host can run it without knowing what
it is. Written this way, the contract is an abstract type.
Host is the other half. It is not a plugin and does not extend one. It has plugins: it owns
them, runs them in order, and deletes them in its destructor. The deletes go through Plugin ptr,
which is why Plugin's destructor is virtual. Adding a third plugin means writing one new type;
Host does not change.
A type extends exactly one base, and that is where this model runs out. A Plugin that must
also be something else, a second contract on the same type, a small value that should follow the
contract without carrying a table pointer: none of them fits in one chain. Part 2 is the answer.
Part 2: Interfaces
Part 1's contracts are base types, and a type has one base. A Drawing that is both a shape and a
document, a Point that should be printable without carrying a table pointer, a library type that
already extends something else: none of them fits. An interface is a contract that is not a
base. Any type can take on as many as it needs, and keep its own base if it has one.
A contract any type can take on
'' ifaceshapes.tiko -- the shapes loop again, with an interface as the
'' contract and classes that implement it.
interface IShape
function Area( ) as float64
function Kind( ) as ustring
end interface
class Circle
implements IShape
declare constructor( byval r as float64 )
declare function Area( ) as float64 implements IShape.Area
declare function Kind( ) as ustring implements IShape.Kind
r as float64
end class
constructor Circle( byval r as float64 )
this.r = r
end constructor
function Circle.Area( ) as float64
return 3.14159 * r * r
end function
function Circle.Kind( ) as ustring
return "circle"
end function
class Rect
implements IShape
declare constructor( byval w as float64, byval h as float64 )
declare function Area( ) as float64 implements IShape.Area
declare function Kind( ) as ustring implements IShape.Kind
w as float64
h as float64
end class
constructor Rect( byval w as float64, byval h as float64 )
this.w = w : this.h = h
end constructor
function Rect.Area( ) as float64
return w * h
end function
function Rect.Kind( ) as ustring
return "rectangle"
end function
'' Written once, against the contract only.
sub Describe( byval s as IShape )
print s.Kind( ); ", area"; s.Area( )
end sub
dim as Circle c = Circle( 1 )
dim as Rect r = Rect( 2, 3 )
dim as IShape shapes( 0 to 1 )
shapes( 0 ) = c '' an object converts to an interface it
shapes( 1 ) = @r '' implements, and so does a pointer to one
for i as int32 = 0 to 1
Describe( shapes( i ) )
next
print "an IShape value is"; sizeof( IShape ); " bytes"
circle, area 3.14159
rectangle, area 6
an IShape value is 16 bytes
interface IShape lists what a shape can do and nothing else: no fields, no code, no
constructor. Every member is public and abstract, so there is nothing to write on one but its
signature. An interface is never created by itself.
class Circle is a type that always extends Object. It is the same as
type Circle extends Object, written so that the object model is the default. Its body starts with
the contracts it takes on, implements IShape, and then each method says which member of the
contract it provides: declare function Area( ) as float64 implements IShape.Area. The mapping is
explicit. A public method called Area does not become IShape.Area until it says so, and a
member the class forgets is an error that names it:
'' missing.tiko -- a member of the contract with no method mapped
'' to it.
interface IShape
function Area( ) as float64
function Kind( ) as ustring
end interface
class Circle
implements IShape
declare function Area( ) as float64 implements IShape.Area
r as float64
end class
function Circle.Area( ) as float64
return 3.14159 * r * r
end function
missing.tiko(10,16): error TKO1000: 'circle' does not implement 'ishape.kind'; add 'implements ishape.kind' to one of its methods
|
10 | implements IShape
| ^
missing.tiko(6,5): note: 'kind' is required by interface 'ishape', declared here
|
6 | function Kind( ) as ustring
| ^
An interface value is a reference. dim as IShape shapes( 0 to 1 ) holds two of them, and each
is the address of an object together with that object's table for IShape: sixteen bytes, as the
last line says. A Circle converts to IShape implicitly, and so does a pointer to one. Members
are called with ., never ->, because the value is not a pointer you can follow. Describe
is written against IShape alone, and runs unchanged for every type that implements it.
Two contracts at once
'' twocontracts.tiko -- one type, two contracts, and a member name the
'' two contracts both use for different things.
interface IShape
function Area( ) as float64
function Name( ) as ustring '' what the shape is called
end interface
interface IDocument
function Name( ) as ustring '' the file it is saved in
sub Save( )
sub Close( )
end interface
interface IResource
sub Close( )
end interface
class Drawing
implements IShape, IDocument, IResource
declare function Area( ) as float64 implements IShape.Area
declare function Title( ) as ustring implements IShape.Name
declare function FileName( ) as ustring implements IDocument.Name
declare sub Close( ) implements IDocument.Close, IResource.Close
w as float64 = 4
h as float64 = 2
private:
declare sub WriteOut( ) implements IDocument.Save
end class
function Drawing.Area( ) as float64
return w * h
end function
function Drawing.Title( ) as ustring
return "floor plan"
end function
function Drawing.FileName( ) as ustring
return "plan.txt"
end function
sub Drawing.Close( )
print "closed"
end sub
sub Drawing.WriteOut( )
print "saving "; FileName( )
end sub
dim as Drawing d
dim as IShape s = d
dim as IDocument doc = d
dim as IResource res = d
print s.Name( ); " / "; doc.Name( ); ", area"; s.Area( )
doc.Save( ) '' reaches the private WriteOut
res.Close( )
floor plan / plan.txt, area 8
saving plan.txt
closed
Drawing takes on three interfaces on one implements line. Part 1 could not express any of this:
- Two contracts both have a
Name, and they mean different things. The shape's name is its title, the document's is a file name. They are different members, soDrawingmaps each to its own method:TitleimplementsIShape.Name,FileNameimplementsIDocument.Name. A method's name does not have to match the member's. - One method can provide several members.
CloseimplementsIDocument.CloseandIResource.Closeat once, because closing the drawing means the same thing to both. - A private method can implement a member.
WriteOutis underprivate:, so code outsideDrawingcannot call it directly (TKO0625), yetdoc.Save( )reaches it. Saving is something you do to a document, not to a drawing that happens to be one.
Small values
A type that implements an interface does not need a table pointer of its own. The table travels in
the interface value instead, so a plain type can implement one:
'' ifacevalue.tiko -- a plain type implements an interface and stays
'' the size of its fields.
interface IShow
function Show( ) as ustring
end interface
type Point
implements IShow
x as int32
y as int32
declare function Show( ) as ustring implements IShow.Show
end type
function Point.Show( ) as ustring
return "(" & x & ", " & y & ")"
end function
'' The same contract written as an abstract base type.
type Showable extends Object
declare abstract function Show( ) as ustring
end type
type OldPoint extends Showable
x as int32
y as int32
declare function Show( ) as ustring override
end type
function OldPoint.Show( ) as ustring
return "(" & x & ", " & y & ")"
end function
dim as Point p = ( 3, 4 )
dim as IShow v = p
print v.Show( )
print "Point:"; sizeof( Point ); " bytes"
print "OldPoint:"; sizeof( OldPoint ); " bytes"
(3, 4)
Point: 8 bytes
OldPoint: 16 bytes
Point is eight bytes, its two fields. OldPoint follows the same contract written as an abstract
base type, and every one of them pays eight bytes more for its table pointer. An array of a million
points is 8 MB one way and 16 MB the other. The interface's sixteen bytes are paid only where an
interface value is actually held.
Asking an interface what it holds
'' ifacequery.tiko -- asking what is behind an interface value.
interface IShow
function Show( ) as ustring
end interface
interface ISize
function Size( ) as int32
end interface
class Note
implements IShow, ISize
declare function Show( ) as ustring implements IShow.Show
declare function Size( ) as int32 implements ISize.Size
text as ustring = "buy milk"
end class
function Note.Show( ) as ustring
return text
end function
function Note.Size( ) as int32
return len( text )
end function
class Label
implements IShow
declare function Show( ) as ustring implements IShow.Show
end class
function Label.Show( ) as ustring
return "label"
end function
sub Inspect( byval v as IShow )
print v.Show( ); ":"
'' A cross cast: does the same object ALSO implement ISize?
dim as ISize sz = trycast( ISize, v )
if sz <> nullptr then print " size"; sz.Size( )
'' A downcast: is it a Note?
if v is Note then
dim as Note ptr np = cast( Note ptr, v )
print " a note saying "; np->text
end if
print " is ISize: "; cbool( v is ISize )
end sub
dim as Note n
dim as Label l
Inspect( n )
Inspect( l )
buy milk:
size 8
a note saying buy milk
is ISize: true
label:
is ISize: false
is works with an interface on either side. v is Note asks whether the object behind v is a
Note, or derives from one; v is ISize asks whether it implements ISize. Neither needs the
object's type to be related to IShow.
trycast answers the same questions with a value. trycast( ISize, v ) is a cross cast:
the same object, seen through another interface it implements, or an empty interface value, equal
to nullptr, when it implements no such thing. trycast( Note ptr, v ) is the checked downcast.
cast( Note ptr, v ) is the unchecked one, which is why the listing asks v is Note first: a
wrong cast is undefined behaviour, exactly as it is in Part 1.
Who owns the object
An interface value refers to an object. It does not own it, copy it or keep it alive. The compiler refuses the one case it can see, a temporary that would be gone by the next statement:
'' temporary.tiko -- an interface value refers to an object, and a
'' temporary would be gone before the reference was used.
interface IShow
function Show( ) as ustring
end interface
type Tag
implements IShow
text as ustring
declare function Show( ) as ustring implements IShow.Show
end type
function Tag.Show( ) as ustring
return text
end function
function MakeTag( ) as Tag
dim as Tag t
t.text = "new"
return t
end function
dim as IShow v = MakeTag( )
print v.Show( )
temporary.tiko(24,14): error TKO1012: an interface reference cannot refer to a temporary; store the object in a variable first
|
24 | dim as IShow v = MakeTag( )
| ^
delete through an interface is refused too (TKO1013): the interface does not know how the
object was made, or whether it was made with new at all.
An interface value can outlive its object. Return an IShape that refers to a local Circle,
or keep one in a list after the Circle it refers to has gone out of scope, and it compiles
cleanly. The next call through it reads whatever is in that memory by then.
Keep the owning variable, or the owning pointer, as the object's own type, and make sure it lives at least as long as every interface value that refers to it. A host that holds plugins as interfaces is given them by code that owns them; it does not own them itself.
Classes and interfaces together
Interfaces do not replace class inheritance. They sit beside it, and a call through an interface honours every override below the class that implements it:
'' ifaceclass.tiko -- a class hierarchy behind an interface: the call
'' through the interface reaches the most derived override.
interface ISpeaker
sub Speak( )
end interface
class Animal
implements ISpeaker
declare virtual sub Speak( ) implements ISpeaker.Speak
end class
sub Animal.Speak( )
print "..."
end sub
class Dog
inherits Animal
declare virtual sub Speak( ) override
end class
sub Dog.Speak( )
print "woof"
end sub
class Puppy
inherits Dog
declare sub Speak( ) override
end class
sub Puppy.Speak( )
print "yip"
end sub
dim as Animal a
dim as Dog d
dim as Puppy p
dim as ISpeaker speakers( 0 to 2 )
speakers( 0 ) = a
speakers( 1 ) = d
speakers( 2 ) = p
for i as int32 = 0 to 2
speakers( i ).Speak( )
next
...
woof
yip
class Dog names its base with an inherits line, the class's spelling of extends, at the top
of its body. Animal implements ISpeaker.Speak with a virtual method, so an ISpeaker that
refers to a Dog or a Puppy reaches their overrides. The rule from Part 1 still holds: Dog
writes virtual … override so that Puppy can override it in turn.
Use this only when you want a base class that supplies code its subclasses replace. The default
style is the one in the previous sections: each class implements the interfaces it needs, with no
base class and no virtual anywhere, and shared code goes in a procedure that takes the interface.
Generic interfaces and constraints
An interface can take type parameters. Each instance, IComparable( of Money ) or
IComparable( of Date ), is an interface of its own. And a generic procedure or type can require
its type argument to implement one, with as in its parameter list:
'' ifacegeneric.tiko -- a generic interface, and a generic procedure
'' that requires one of its type argument.
interface IComparable( of T )
function CompareTo( byref other as T ) as int32
end interface
class Money
implements IComparable( of Money )
declare constructor( byval cents as int32 = 0 )
declare function CompareTo( byref other as Money ) as int32 _
implements IComparable( of Money ).CompareTo
cents as int32
end class
constructor Money( byval cents as int32 )
this.cents = cents
end constructor
function Money.CompareTo( byref other as Money ) as int32
return sgn( cents - other.cents )
end function
'' T must implement IComparable( of T ); the body calls T's own
'' CompareTo directly.
function Largest( of T as IComparable( of T ) )( a( ) as T ) as T
dim as int32 best = lbound( a )
for i as int32 = lbound( a ) + 1 to ubound( a )
if a( i ).CompareTo( a( best ) ) > 0 then best = i
next
return a( best )
end function
dim as Money prices( 0 to 2 ) = _
{ Money( 250 ), Money( 1999 ), Money( 75 ) }
print "largest:"; Largest( prices( ) ).cents
largest: 1999
of T as IComparable( of T ) is a constraint: whatever T is, it must implement
IComparable of itself. Several are written in braces, of K as { IComparable( of K ), IShow }.
The constraint is checked once, where Largest is used, and a type argument that falls short is
refused there:
'' constraint.tiko -- a type argument that does not implement what the
'' parameter requires is refused where the generic is used.
interface IComparable( of T )
function CompareTo( byref other as T ) as int32
end interface
function Largest( of T as IComparable( of T ) )( a( ) as T ) as T
return a( lbound( a ) )
end function
dim as int32 counts( 0 to 2 ) = { 3, 1, 2 }
print Largest( counts( ) )
constraint.tiko(13,14): error TKO1022: type argument 'int32' does not implement 'icomparable( of int32 )', which 'largest( of t )' requires
|
13 | print Largest( counts( ) )
| ^
Without the constraint, Largest( counts( ) ) would fail inside the body, at a( i ).CompareTo,
with an error about a member that int32 does not have, and the reader would have to work out why
from there. A constraint moves the error to the call that caused it.
A constraint does not change how the body runs. Largest is still compiled once for each type
it is used with, and inside it a( i ).CompareTo( … ) is an ordinary call to Money.CompareTo: no
interface value and no table. Chapter 17 covers generics in full.
Choosing between them
| You want | Use |
|---|---|
| A contract that unrelated types follow | an interface |
| A type to follow two contracts, or to keep its own base while following one | interfaces |
| A small value type that follows a contract | a plain type that implements an interface |
| A base class that supplies code its subclasses replace | extends (or inherits) with virtual |
is and delete through the base, with the base owning nothing else | the classic model, with a virtual destructor |
Generic code that needs something of T | a constraint, of T as IFoo |
| A layout that C or COM defines | the classic model: an abstract type that extends Object |
The two mix freely. A class can inherit a base and implement interfaces, a type can extend a
class, and a classic base can implement an interface with virtual methods that its descendants
override.
What it costs
- Every object of a type that extends
Objectcarries one pointer, eight bytes, plus whatever padding it causes. A million small objects pay eight megabytes for it. - A virtual call is an indirect call: load the table pointer, load the slot, call through it. That is a few instructions, but it prevents the call from being inlined, which matters inside a hot loop over small methods.
- A non-virtual method costs nothing extra, inherited or not. Make methods virtual because a derived type needs to replace them, not by default.
iswalks the chain of base types, so it costs a little more the deeper the hierarchy.- Abstract types and
overrideare compile-time only. They cost nothing at run time. - An interface value is sixteen bytes, the object's address and its table for that interface, and a call through it is one indirect call, the same as a virtual call. A class whose implementing method is virtual adds a short hop through its own table, so an override is reached.
- Implementing an interface adds nothing to an object. A plain
typestays the size of its fields; the table is in the interface value. trycastandiswith an interface search a small table at run time. Converting an object to an interface, or an interface to one it inherits, does not.- Mappings and constraints are compile-time only. A constrained generic calls
T's methods directly.
Summary
type B extends AstartsBas a completeA, base fields first.base( … )chooses the base constructor, which runs before the derived body;base.Method( )calls the base's version.- A base with no default constructor forces every derived type to write a copy constructor
(
TKO0926). Default the base constructor's parameters instead. - Without
virtual, a call is chosen by the type the compiler sees. A method of the same name on a derived type hides the base's; it does not replace it for code that sees the base. extends Objectadds a table pointer.virtualmethods are looked up in it at run time;overridereplaces one (TKO0621if there is nothing to replace);abstractdeclares one with no body. An override is not virtual unless you also writevirtual, so a type derived from it hides the method instead of overriding it.- A type with an abstract method cannot be created (
TKO0620).--dump-vtableshows the table the compiler built. - Give a base that will be deleted through a pointer a virtual destructor, or the derived
destructor never runs, and repeat
virtualat every level below it: a plain destructor in the middle stops the chain. a is Tis true forTand anything derived from it; guard everycast( T ptr, @a )with it. Prefer a virtual method to a chain ofistests.- Passing by value slices an object to its base part. Pass objects with virtual methods
byref. - Use inheritance for "is a" and composition for "has a". A type extends one base, so an abstract type as a contract uses that base up.
- An
interfacelists methods and properties with no data or code. Atypeorclasstakes it on with animplementsline and maps each member withimplements IFoo.Member, explicitly; a missing one isTKO1000.classis a type that always extendsObject, with its base on aninheritsline. - A type may implement any number of interfaces. Members of the same name in two interfaces are different members; a method's name need not match its member's, one method may provide several, and a private method may provide one.
- An interface value is a 16-byte reference. It converts from any implementing object or pointer,
is called with
., and does not own its object: temporaries are refused (TKO1012),deletethrough it is refused (TKO1013), and the object must outlive it. isandtrycasttake interfaces on either side;trycastanswers null instead of guessing.- A plain
typecan implement an interface without a table pointer of its own. - Generic interfaces are interfaces per instance. A constraint,
of T as IFoo, is checked where the generic is used (TKO1022) and costs nothing in the body.
Exercises
- Add a
Trianglewith a base and a height to the shapes program, without changingShape,Circleor the loop. What did you have to write, and what did you not? {solution="ex14-01"} - Write a
Counterplugin whoseApplyappends the length of the text and counts its calls, and whose destructor prints the count. Create it withnew, use it through aPlugin ptr, and delete it through that pointer. Which line makes the destructor run? {solution="ex14-02"} - Remove
virtualfromvdtor.tiko'sCarefuldestructor. Predict the output, then run it. - Change
ByValueinslicing.tikoto take its parameterbyref. Which lines of output change, and why? - A
Stackthat extendsArray, or aStackthat has anArrayfield: argue for one. What can a caller do to the first that a stack should not allow? - Add a
Triangletoifaceshapes.tiko. Then giveRecta second interface,IScalable, withsub Scale( byval k as float64 ), and scale a rectangle through it. Check withisthat a triangle is not scalable. Which existing code did you change? {solution="ex14-03"} - Rewrite
pluginhost.tikowith anIPlugininterface. Who deletes the plugins now, and why can the host no longer do it through the interface?
Modules and Packages
Sooner or later a program uses two libraries written by people who never met, and both of them
define Parse. With #include there is nothing to be done about it. A header is text pasted
into the file that includes it, so both Parses land in the same scope and the program does not
compile. That is not the only cost. A header's helpers are visible to everyone who includes it.
Its code is compiled again by every file that includes it. And when two libraries each need
setting up before main runs, the order they are set up in depends on how the program happened
to be linked.
A module is tiko's answer to all four. It is a directory of source files that compiles as one
unit, shows the world only what it marks public, keeps its names behind its own name, is built
once and cached, and is initialised in a fixed order: after everything it uses, before anything
that uses it. A package is a set of modules shipped together, with a small tiko.toml saying
what it is called and what it depends on.
This chapter builds a module, then several, then a package. Everything in it is a tiko extension:
#include still works exactly as Chapter 16 describes, and a program that never writes
#import compiles as if modules did not exist.
What #include cannot do
Here are two small header libraries. csv.ti counts the fields in a line; ini.ti finds where a
value starts. Each is reasonable on its own:
'' csv.ti -- a header library: its code is pasted into every includer.
function Parse( byval s as ustring ) as int32
'' the number of fields in a comma-separated line
var n = 1
for i as int32 = 1 to len( s )
if mid( s, i, 1 ) = "," then n += 1
next
return n
end function
'' ini.ti -- another header library, by another author.
function Parse( byval s as ustring ) as int32
'' where the value starts in a "key=value" line
return instr( s, "=" ) + 1
end function
A program that wants both cannot have them:
#include once "headers/csv.ti"
#include once "headers/ini.ti"
print Parse( "a,b,c" )
headers/ini.ti(2,1): error TKO0600: 'parse' is already declared in this scope
|
2 | function Parse( byval s as ustring ) as int32
| ^
headers/csv.ti(2,1): note: declared here
|
2 | function Parse( byval s as ustring ) as int32
| ^
Neither author did anything wrong. The only fix available to them is to wrap their library in a
namespace, and every library author has to remember to do it. The problem is the mechanism:
#include puts every name a library declares into the scope of whoever includes it.
A module is a directory
hello.tiko
greet/
greet.tiko
#module "greet"
public function Hello( byval who as ustring ) as ustring
return "Hello, " & Capitalise_( who ) & "!"
end function
function Capitalise_( byval s as ustring ) as ustring
return ucase( left( s, 1 ) ) & mid( s, 2 )
end function
#import "./greet"
print greet.Hello( "world" )
Hello, World!
Three rules are at work.
- Every file of a module begins with
#module "name", and the name is the directory's name. Comments may come first; any other directive isTKO0360. A name that does not match its directory isTKO0361. #importcomes before any code. The path is quoted, uses/on every platform, and./greetis relative to the file that imports it. An#importafter any statement — even after a#defineor a#cmdline— isTKO0362.- The module's names are reached through the module's name. The import binds
greet, the last segment of the path, and the program callsgreet.Hello.
Because a module's name becomes a name in the program, no segment of an import path may be a
keyword: #import "./sub/inner" is TKO0364, 'sub' is a keyword. Name modules for what they
hold — text, report — not after statements.
There is no list of files on the command line and no build script. tiko hello.tiko reads the
#import, finds greet/, compiles it, and links it in.
Who can see what
Hello is marked public; Capitalise_ is not. Reaching for it from outside is refused, and the
message names the rule:
#import "./greet"
print greet.Capitalise_( "world" ) '' refused
notpublic.tiko(3,13): error TKO0860: 'capitalise_' is not public in module 'main.greet'
|
3 | print greet.Capitalise_( "world" ) '' refused
| ^
main.greet is the module's chain: its package's name followed by its path. A program with no
tiko.toml is the package main. The chain is how tiko tells two modules called greet apart.
A module has three levels of visibility, and the default is the middle one:
| You write | Visible to | Use it for |
|---|---|---|
public | every file that imports the module | the module's API |
| nothing | every file of this module | helpers the module's files share |
private | this module, and nothing reached from outside it | helpers nothing else may ever call |
That default is the opposite of a classic program's, where a procedure is visible everywhere
unless it says private. It is the right way round for a library: a helper you forgot to mark
never becomes part of your API, so you are free to change it later.
The difference between the last two is what each one means to the linker. Measured on the object
tiko -c writes for a module named gen with one procedure at each level:
| Level | Name in the object | Linux (ELF) | Windows (COFF) |
|---|---|---|---|
public | _ZN4MAIN3GEN3APIEv | global | external |
| module-private | _ZN4MAIN3GEN7SHARED_Ev | global, hidden | external |
private | _ZN4MAIN3GEN7SECRET_Ev | local | local |
Every name is spelled from the chain, so two packages' Parse procedures can never collide in
the linker either. A module-private procedure is still an external symbol, because code compiled
outside the module sometimes has to call it: a generic's body, and an exported macro's
expansion, are compiled in the importer. A private one is local to the module's object and
cannot be reached that way at all. The section on generics shows the difference.
A public procedure may not name a type that is not public. An importer should never be handed
a value of a type it cannot name, so public function Make( ) as Secret, where Secret has no
public, is refused: TKO0868: 'secret' is module-private but appears in the public declaration of 'make'. Every type that appears in a public signature is public too.
A module with several files
A module may be as many files as it needs, and all of them form one module. They see each
other's non-private names freely, in any order:
#module "report"
public function Row( byval label as ustring, _
byval value as int32 ) as ustring
return PadRight_( label ) & Format_( value )
end function
#module "report"
'' Both helpers are module-private: report.tiko calls them, and
'' nothing outside the module can.
function PadRight_( byval s as ustring ) as ustring
return left( s & space( LABEL_WIDTH ), LABEL_WIDTH )
end function
function Format_( byval n as int32 ) as ustring
return right( space( 6 ) & str( n ), 6 )
end function
const LABEL_WIDTH = 10
#import "./report"
print report.Row( "apples", 12 )
print report.Row( "pears", 3075 )
apples 12
pears 3075
report.tiko calls PadRight_ and Format_, which are declared in another file. PadRight_
uses LABEL_WIDTH, which is declared below it. None of that needs a declare line, and there
is no internal header gluing the files together, because a module is compiled as a single unit
and its top-level declarations are collected before any of them is checked.
Inside a procedure body nothing changes: a local variable is still declared before it is used. And code outside any module keeps declare-before-use for everything, as Chapter 7 described.
Macros do not cross between the files of a module. A #define in report.tiko is not visible
in format.tiko; each file starts from the same clean slate. A macro several files need belongs
in a small header that each of them includes.
Imports do not cross between the files either. An import belongs to the file that writes it,
so each file says where its own names come from. A sibling file that writes util. without an
#import "../util" of its own gets TKO0743: 'util' is not declared, with a note pointing at the
file that did import it: 'util' is imported by another file of this module; an import belongs to the file that writes it.
Importing
#import has two optional parts, and the imported names can be brought into scope with using:
#module "shapes"
public const PI_ = 3.14159265358979
public type Circle
r as float64
declare function Area( ) as float64
end type
function Circle.Area( ) as float64
return PI_ * r * r
end function
public function UnitCircle( ) as Circle
return type( 1.0 )
end function
#import "./shapes" as sh
#import "./greet"
using greet
dim c as sh.Circle
c.r = 2
print c.Area( )
var u = sh.UnitCircle( )
print u.r, sh.PI_
print Hello( "using" )
12.56637061435916
1 3.14159265358979
Hello, Using!
as shrenames the module for this program. Use it when two imports end in the same name, or when a name is long. The module is stillshapes; only the spelling here changes.- Types are reached the same way as procedures:
dim c as sh.Circle. A value of a module's type behaves exactly as one of your own:c.Area( )needs no qualification, because a method belongs to its type. using greetmakesgreet's public names usable bare, asusingdoes for a namespace.
When two modules you are using both supply a name, a bare use of it is refused rather than
guessed:
#import "./tag"
#import "./label"
using tag
using label
print Kind( ) '' refused
ambiguous.tiko(6,7): error TKO0919: 'kind' is ambiguous: more than one USING in effect declares it; qualify it
|
6 | print Kind( ) '' refused
| ^
Write tag.Kind( ) or label.Kind( ) and move on. That is the qualified name doing its job: the
question "which Kind?" has an answer written in the source.
Passing an import on
An import is private to the module that writes it. A module can pass one on to its own importers
by adding public to the #import:
#module "kit"
#import "../greet" public '' re-exported: importers see kit.greet
#import "../shapes" '' kit's own business
public function Banner( ) as ustring
return greet.Hello( "kit" ) & " pi=" & left( str( shapes.PI_ ), 4 )
end function
#import "./kit"
print kit.Banner( )
print kit.greet.Hello( "again" )
Hello, Kit! pi=3.14
Hello, Again!
kit re-exports greet, so an importer of kit reaches it as kit.greet. shapes is imported
without public, so it stays kit's business:
#import "./kit"
print kit.shapes.PI_ '' refused
noleak.tiko(3,11): error TKO0745: the namespace 'kit' has no member named 'shapes'
|
3 | print kit.shapes.PI_ '' refused
| ^
That is what makes a module's dependencies its own to change. Swapping shapes for something
else inside kit cannot break a program that imports kit, because no such program could have
used it.
Packages
So far every module has sat beside the program that uses it. Once modules are meant for more
than one program, they become a package: a directory tree of modules with a tiko.toml at its
root.
acme/
tiko.toml
config/config.tiko
text/pad/pad.tiko
app/
tiko.toml
main.tiko
# The package acme: two modules, and one setting with a default.
[package]
name = "acme"
version = "0.3.0"
[defines]
ACME_COLUMNS = "8"
#module "config"
'' ACME_COLUMNS comes from the package's tiko.toml, or from the
'' program that depends on acme -- never from the program's code.
public function Columns( ) as int32
return ACME_COLUMNS
end function
#module "pad"
#import "acme/config"
public function Cell( byval s as ustring ) as ustring
return "|" & left( s & space( config.Columns( ) ), _
config.Columns( ) )
end function
pad imports acme/config by its full path. Inside a package, a path that starts with the
package's own name means "this package". The module pad's chain is acme.text.pad, and the
program still calls it pad: the last segment.
The program says where to find acme:
[package]
name = "app"
[dependencies]
acme = { path = "../acme" }
#import "acme/text/pad"
print pad.Cell( "tiko" ); pad.Cell( "modules" ); "|"
|tiko |modules |
A tiko.toml is optional — the programs before this section had none — and you add one for four
things:
- A stable name. Without one, a package is called
main. With one, the name is written in the code and goes wherever the code goes. - A root. The directory holding
tiko.tomlis where the package starts. A relative import may not climb above it (TKO0142). - Dependencies that travel with the project.
acme = { path = "../acme" }is in the repository, not in anyone's environment, so a fresh checkout builds with a baretiko main.tiko. - Settings for a library. That is
[defines], and it needs a section of its own.
The file is a small subset of TOML: tables, strings, integers, booleans, inline tables and arrays
of strings. Anything it does not recognise is TKO0144, naming the file and line. version is
recorded for a future package manager and not otherwise used.
Where an import is looked for
For #import "acme/text/pad", tiko tries these in order and takes the first that has it:
| Where | Example | |
|---|---|---|
| 1 | a relative path, from the importing file | #import "./greet" |
| 2 | the current package, when the first segment is its name | inside acme: #import "acme/config" |
| 3 | a dependency in the current package's tiko.toml | acme = { path = "../acme" } |
| 4 | each -i directory, then each directory in TIKO_PATH | TIKO_PATH=C:\tikolibs |
| 5 | the toolchain's own pkg/ directory | std |
Nothing found is TKO0140, and the message lists every place it looked. std is special: it is
always the toolchain's, so no project can replace the standard library by accident. And one build
may not find two different directories both claiming the same package name (TKO0149).
Settings that cross the boundary, and settings that do not
acme's config module reads ACME_COLUMNS, which no source file defines. It comes from the
package's [defines], and a program that depends on acme can override it in its own manifest:
[package]
name = "tuned"
[dependencies]
acme = { path = "../acme" }
[dependencies.acme.defines]
ACME_COLUMNS = "4"
|tiko|modu|
The same main.tiko now prints four-column cells. That is the only way in. A module never sees
the macros of the program that imports it — not its #defines, and not its -d options either.
This is deliberate, and it is what lets a module be compiled once and cached: if a program's
macros could reach into a module, every program would need its own copy of it.
-d does not reach a module. A module that tests a symbol with #ifdef is compiled without
any symbol the importing program defines:
#module "cfg"
public function Mode( ) as ustring
#ifdef VERBOSE
return "verbose"
#else
return "quiet"
#endif
end function
#import "./cfg"
#cmdline "-d VERBOSE"
#ifdef VERBOSE
print "main: VERBOSE is defined"
#endif
print "cfg: "; cfg.Mode( )
main: VERBOSE is defined
cfg: quiet
Both lines compile and run, and the module quietly takes its #else branch. Nothing warns you,
because from the module's side nothing is wrong. A setting a module is meant to take belongs in
its package's [defines], which a program overrides in tiko.toml; or, better, in an ordinary
parameter, which the compiler checks.
Initialisation
A module may have top-level statements, shared variables with constructors, and constructor
and destructor subs. tiko runs them in one order, the same on every target and whatever order
the objects are linked in:
A module is initialised after every module it imports, and before any code that imports it. Shutdown is the exact reverse.
#module "logger"
type Sink
lines as int32
declare constructor( )
declare destructor( )
end type
constructor Sink( )
print " logger: sink opened"
end constructor
destructor Sink( )
print " logger: sink closed after"; lines; " line(s)"
end destructor
dim shared sink_ as Sink
print " logger: top-level code"
public sub Info( byval s as ustring )
sink_.lines += 1
print " log: "; s
end sub
#module "store"
#import "../logger"
logger.Info( "store opening" )
sub Close_( ) destructor
logger.Info( "store closing" )
end sub
public function Count( ) as int32
return 3
end function
#import "./store"
sub Early( ) constructor 101
print "constructor 101"
end sub
print "main: store has"; store.Count( ); " items"
constructor 101
logger: sink opened
logger: top-level code
log: store opening
main: store has 3 items
log: store closing
logger: sink closed after 2 line(s)
Read the output top to bottom. logger is set up completely — its shared Sink constructed, then
its top-level code — before store's top-level code calls logger.Info. store is set up before
main runs. At the end, store's destructor runs while logger is still there to log it, and
only then is the Sink destroyed. Each module is initialised once, however many modules import it.
That is the guarantee classic code cannot make. Without modules, two libraries' setup code runs in the order the linker happened to place it, and that order differs between Windows and Linux.
Numbered constructors keep their meaning. constructor 101 asks for a slot by number, and
those slots run before any module is initialised — which is why Early printed first. A
number must be from 101 to 65535 (TKO0422). Use one only for something that genuinely must
precede everything, and prefer the import order, which says why one thing comes before another.
Macros and generics across modules
Exporting a macro
A module's macros are its own unless it exports them by name, after defining them:
#module "check"
#macro Ensure( cond )
if ( cond ) = 0 then Fail_( #cond, __FILE__, __LINE__ )
#endmacro
#export Ensure
#define MAX_ITEMS 10
#export MAX_ITEMS
sub Fail_( byval what as ustring, byval f as ustring, _
byval ln as int32 )
print "check failed: "; what; " ("; f; " line"; ln; ")"
end sub
#import "./check"
'' A Fail_ of this program's own, which the macro must not call.
sub Fail_( byval what as ustring, byval f as ustring, _
byval ln as int32 )
print "wrong Fail_"
end sub
var items = 12
check.Ensure( items <= check.MAX_ITEMS )
check.Ensure( items > 0 )
print "done"
check failed: items <= 10 (macros.tiko line 10)
done
- An exported macro is used qualified, as
check.Ensure( … ). A bareEnsure( … )is not a macro at all, so importing a module can never capture a name of yours. - Its body means what it meant in the module.
Fail_inEnsure's body ischeck'sFail_, even though the program cannot name it and has aFail_of its own. Names the module does not declare —items, the macro's argument — are left as the caller wrote them. __FILE__and__LINE__expand where the macro is used, as they always do. And#condshows the argument after expansion, socheck.MAX_ITEMSappears as10.- The module's own headers keep their macros too. When a public declaration names a type
from one of the module's headers, your program reads that header to get the type, but the
header's macros stop there. A module that wants a header shared whole, macros and all, says
public #include. Headers from outside the package, such aswindows.bi, are read as if you had included them yourself.
Exporting a generic
#module "coll"
public type Stack( of T )
declare sub Push( byval v as T )
declare function Pop( ) as T
declare function Count( ) as int32
private:
items( any ) as T
end type
sub Stack( of T ).Push( byval v as T )
redim preserve items( ubound( items ) + 1 )
items( ubound( items ) ) = v
Counted_( )
end sub
function Stack( of T ).Pop( ) as T
var v = items( ubound( items ) )
if ubound( items ) = 0 then
erase items
else
redim preserve items( ubound( items ) - 1 )
end if
return v
end function
function Stack( of T ).Count( ) as int32
return ubound( items ) + 1
end function
'' Module-private, yet reached from the importer: Push's body is
'' instantiated there, and calls it.
sub Counted_( )
pushes += 1
end sub
public dim shared pushes as int32
#import "./coll"
dim nums as coll.Stack( of int32 )
nums.Push( 1 )
nums.Push( 2 )
print nums.Pop( ); nums.Count( )
dim names as coll.Stack( of ustring )
names.Push( "ada" )
print names.Pop( ); names.Count( )
print "pushes:"; coll.pushes
2 1
ada 0
pushes: 3
A generic is instantiated by the program that uses it, so its body travels with the module's
interface and is compiled in the importer. That is why Counted_ is module-private rather than
private: Push's body calls it from the program's own code, and a module-private name is an
external symbol that code can reach. Make the helper private and the instantiation fails with
TKO0743: 'counted_' is not declared, because a private name does not exist outside the
module's object.
Building
tiko main.tiko does everything: it finds the imports, builds each module once, and links the
result. Four options show or change what it did.
--print-graph lists the modules a program imports, in the order they are built, and stops.
Each line is a chain, the directory it came from, and what it imports:
> tiko --print-graph main.tiko
acme.config ../acme/config
acme.text.pad ../acme/text/pad [acme.config]
-v reports, per module, whether the build cache had it. Three builds of the two programs above:
module acme.config: miss (new)
module acme.text.pad: miss (new)
module acme.config: hit
module acme.text.pad: hit
module acme.config: miss (define ACME_COLUMNS=4)
module acme.text.pad: miss (define ACME_COLUMNS=4)
The cache is one directory shared by every project of yours: %LOCALAPPDATA%\tiko\modcache
on Windows, ~/.cache/tiko/modcache on Linux (or under $XDG_CACHE_HOME), and wherever
TIKO_MODCACHE names if you set it. Nothing is written into the project, so there is nothing to
add to .gitignore, and a module two projects build the same way is compiled once for both. A
build with different settings is a different entry, as the third build shows. The key is the
module's content, not file dates, so touching a file changes nothing.
What a change rebuilds, measured on acme:
| You change | Rebuilt |
|---|---|
a procedure's body in pad | pad only |
something public in config | config, and pad, because it imports config |
a setting in [defines], or -g, -exx, the target | every module it affects |
If you ever doubt the cache, tiko --clean-module-cache empties it, and
--no-module-cache builds without reading or writing it. Nothing is lost but time.
--emit-tki prints a module's interface, the declarations an importer is compiled against,
as ordinary tiko source:
> tiko --emit-tki thermo
'' tiko-interface 1
'' module main.thermo
namespace __tko_mod_main_thermo
declare function Round1_( byval x as float64 ) as float64
public declare function ToFahrenheit( byval c as float64 ) as float64
end namespace
(The real output carries a #line before each declaration, pointing back at the source.) The
module-private Round1_ is in it, without public: that is how a generic or macro body
compiled in the importer can reach it, while the importer's own code cannot.
Objects, libraries and DLLs
The other output kinds work on a program that imports:
tiko -c -m main main.tiko -o out/main.ocompiles to objects, writing each module beside the output as its chain —out/main.store.o,out/main.logger.o. Linkingout/main.oalone later finds the modules' objects through a record inside it.tiko -libputs every module's object into the archive, so the library is complete on its own.tiko -dlllinks the modules in and exports only what the root marksexport. A module's names never are.
__TKO_MODULE__ is defined while a module is compiled and not otherwise, and __TIKO_COMPILER__
is defined by tiko always: the test for "am I being compiled by tiko", for code that must also
build elsewhere.
The standard library as a package
The standard library is the package std, shipped in the toolchain's pkg/ directory. Its
modules are strings, fs, containers, chrono, tasks, outcome, numeric, money,
logs, tui and memory, and the library chapters (Part IV) use them throughout:
#import "std/strings"
#import "std/fs"
print strings.Replace( "a-b-c", "-", "+" )
print strings.StartsWith( "tiko-lang", "tiko" )
print strings.Reverse( "tiko" )
print fs.PathStem( "notes.txt" ); " "; fs.PathExtension( "notes.txt" )
print fs.PathFileName( "logs/today.txt" )
a+b+c
true
okit
notes .txt
today.txt
These are ordinary modules, built the way this chapter describes: compiled once, cached, and initialised in import order. Everything in the chapter applies to them:
using stringsmakesReplacea bare name, as for any module. Twousings that supply the same name --strings.Containsandcontainers.Contains-- areTKO0919when the name is used bare; qualify it.- Their macros are exported, and used qualified:
logs.LogInfo( "started" ). - A statement may need one.
spawnandselect channellower tostd/tasks, so a file that uses them imports it (TKO0423otherwise), and builds with-mt.
Converting a header library into a module
Most existing libraries are headers. Moving one to a module is mostly mechanical, and the result is easier to use and cheaper to build. Here is a header library, the classic way:
'' thermo.ti -- a header library, the classic way.
#pragma once
namespace Thermo
private function Round1_( byval x as float64 ) as float64
return int( x * 10 + 0.5 ) / 10
end function
private function ToFahrenheit( byval c as float64 ) as float64
return Round1_( c * 9 / 5 + 32 )
end function
end namespace
#include once "classic/thermo.ti"
print Thermo.ToFahrenheit( 21.5 )
And the same library as a module, with a program using it:
'' thermo -- the same library, as a module.
#module "thermo"
function Round1_( byval x as float64 ) as float64
return int( x * 10 + 0.5 ) / 10
end function
public function ToFahrenheit( byval c as float64 ) as float64
return Round1_( c * 9 / 5 + 32 )
end function
#import "./thermo"
print thermo.ToFahrenheit( 21.5 )
Both programs print the same line:
70.7
The recipe, file by file:
| Step | Header library | Module |
|---|---|---|
| Place | thermo.ti | thermo/thermo.tiko, beginning #module "thermo" |
| Guard | #pragma once | gone: a module file is never included |
| Namespace | namespace Thermo … end namespace | gone: the module is the namespace |
| API | private function ToFahrenheit (every includer gets a copy) | public function ToFahrenheit |
| Helpers | private function Round1_ | no keyword: module-private |
| Library-internal includes | #include once "other.ti" | #import of the module that holds it |
| API macros | #define | #define then #export; or, better, a procedure |
| Configuration macros | "define this before you include me" | [defines] in the package's tiko.toml |
Two steps need judgement. Headers that include each other cannot become two modules, because
imports may not form a cycle (TKO0141, which names the cycle); merge them into one module, or
move what they share into a third. And a system header your public signatures mention — a
Windows handle type, say — stays an #include in the module. The module's interface records that
it needs the header, and including it again in the importer is harmless: it is the same file, read
once.
What it costs
- A module is compiled once and cached, so the first build of a program that imports a large module is slow and every later build is not. A header library is compiled again by every file that includes it, every time.
- Changing a public declaration rebuilds every module that imports it, directly or through others. Changing a body rebuilds one module. Keep public interfaces stable and the rebuild stays small.
- Calls across a module boundary are ordinary calls. A module is not a separate binary at run time; the linker joins everything into one executable, as before.
- Generics are instantiated in the importer, so each program that uses
coll.Stack( of int32 )compiles that instance itself, and a program that instantiates many pays in compile time and binary size exactly as Chapter 17 described. - The cache costs disk space and is never cleaned on its own.
--clean-module-cacheempties it.
Summary
#includepastes a library into its includer: two libraries' names collide (TKO0600), helpers are visible to all, and the code is compiled again by every includer.- A module is a directory; every file begins
#module "name", matching the directory (TKO0360,TKO0361).#import "./greet"comes before any code (TKO0362) and bindsgreet. - Module-private is the default.
publicexports;privatekeeps a name inside the module's object. Names are spelled from the module's chain, so they never collide in the linker either. Reaching a module-private name isTKO0860. - Inside a module, declaration order does not matter, across files or within one. Macros do not cross between its files.
#import … as xrenames;usingmakes public names bare, and a name twousings supply isTKO0919.#import … publicre-exports; otherwise imports never leak (TKO0745).- A package is a directory tree with a
tiko.toml: a name, a root,pathdependencies, and[defines]a program overrides through[dependencies.NAME.defines]. - A module never sees its importer's macros or
-doptions, which is what makes it cacheable, and which silently takes a module's#elsebranch if you expect otherwise. - Modules initialise after what they import and finalise in reverse, once each, the same on every target. Numbered constructors run first.
#export NAMEshares a macro, used qualified and hygienic. An exported generic is instantiated in the importer, so its helpers must be module-private, notprivate.--print-graph,-v,--no-module-cache,--clean-module-cacheand--emit-tkishow and control the build;-c,-liband-dllwork on programs that import.stdis a package in the toolchain, and its modules are ordinary modules.- Converting a header library: add
#module, drop the guard and the namespace, mark the APIpublic, unmark the helpers, and turn configuration macros into[defines].
Exercises
- Write a module
tempwith publicCToFandFToCand a module-private rounding helper, and a program that prints a table from −10 to 30 degrees in steps of 10, converting each value there and back. {solution="ex15-01"} - Write a module
counterwhose top-level code prints that it is initialising and whosedestructorsub prints that it is done, with a public sharedhits. Import it from two modules,leftandright, that each add one tohits, and import all three from a program. Predict how oftencounterinitialises, then run it. {solution="ex15-02"} - Move
report'sLABEL_WIDTHinto a third file of the module. What did you have to change elsewhere, and why? - Make
acme's column count a parameter ofpad.Cellinstead of a[defines]setting. What does the program gain, and what does the package lose? - Take a header library of your own and convert it with the recipe. Which step needed a decision rather than an edit?
Namespaces, Headers and the Preprocessor
A program outgrows one file long before it outgrows one author. The geometry goes in one file, the measuring in another, the program that uses them in a third, and each has to know enough about the others to call them. Chapter 15 builds that out of modules, which is what new code should do. This chapter is the machinery underneath: files compiled one at a time and joined by the linker, headers that tell each file what the others provide, namespaces that keep their names apart, and the preprocessor, which rewrites the source before the compiler reads a word of it.
You need all of it anyway. Every binding to a C library is a header. Every program that must behave differently on Windows and Linux says so with the preprocessor. And a module is itself built from these parts, so the rules here are the ones that hold underneath Chapter 15.
A program in several files
Here is a small program in three source files. The part they share goes in a header, a .ti
file that declares what exists without defining it:
'' geometry.ti -- what the three files of the program share.
#pragma once
type Point
x as float64
y as float64
end type
'' Defined in shapes.tiko.
declare function Distance( byval a as Point, _
byval b as Point ) as float64
declare function Perimeter( pts() as Point ) as float64
'' Defined in measure.tiko, read everywhere.
extern measured as int32
declare promises a procedure that some other file defines; extern promises a variable. The
two source files keep those promises. shapes.tiko defines the procedures, and measure.tiko owns
the counter:
'' shapes.tiko -- distances and perimeters.
#include once "geometry.ti"
private function Square( byval v as float64 ) as float64
return v * v
end function
function Distance( byval a as Point, byval b as Point ) as float64
measured += 1
return sqr( Square( a.x - b.x ) + Square( a.y - b.y ) )
end function
function Perimeter( pts() as Point ) as float64
dim total as float64 = 0
for i as int32 = lbound( pts ) to ubound( pts )
var j = iif( i = ubound( pts ), lbound( pts ), i + 1 )
total += Distance( pts( i ), pts( j ) )
next
return total
end function
'' measure.tiko -- owns the counter that geometry.ti declares.
#include once "geometry.ti"
dim shared measured as int32
The main file includes the same header and uses all of it:
'' geometry.tiko -- the main module.
#include once "geometry.ti"
dim box( 0 to 3 ) as Point = { ( 0, 0 ), ( 4, 0 ), ( 4, 3 ), ( 0, 3 ) }
print "diagonal: "; Distance( box( 0 ), box( 2 ) )
print "perimeter:"; Perimeter( box() )
print "distances measured:"; measured
diagonal: 5
perimeter: 14
distances measured: 5
Build it by naming every source file; the first one named is the program:
tiko geometry.tiko shapes.tiko measure.tiko -o geometry
Each .tiko file is compiled on its own, as if the others did not exist. That is why each one
includes the header: a file knows only what it has been told. The linker then joins the compiled
files and connects every declare and extern to the one definition behind it. dim shared measured in measure.tiko is that definition; extern measured in the header lets the other two
files use it.
#include pastes a file's text in place. #include once pastes it only the first time, and
#pragma once at the top of a header says the same thing from the inside, so the header is safe
however many times it is reached. geometry.ti says both ways, which costs nothing.
Where #include looks
'' platform.tiko -- one source, two platforms.
#include once "include/platform.ti"
print "platform: "; PLATFORM
print "pointers:"; POINTER_BITS; " bits"
print "a path: logs"; PATH_SEP; "today.txt"
#ifdef __FB_MSVC__
print "target: windows-x86_64"
#endif
'' platform.ti -- what this program needs to know about where it runs.
#pragma once
#include once "separator.ti"
#if defined( __FB_WIN32__ )
const PLATFORM = "Windows"
#elseif defined( __FB_LINUX__ )
const PLATFORM = "Linux"
#elseif defined( __FB_DARWIN__ )
const PLATFORM = "macOS"
#else
#error "platform.ti: this platform is not supported"
#endif
#ifdef __FB_64BIT__
const POINTER_BITS = 64
#else
const POINTER_BITS = 32
#endif
'' separator.ti -- found beside platform.ti, which includes it.
#pragma once
#ifdef __FB_WIN32__
const PATH_SEP = "\"
#else
const PATH_SEP = "/"
#endif
platform.tiko names include/platform.ti, a path relative to itself. platform.ti then
includes separator.ti with no directory at all, and finds it beside platform.ti: a name is
looked for first beside the file that includes it, not beside the program's first file. That is
what lets a header bring its own helpers along without knowing where it will be used from.
When the file is not there, tiko tries the directory the compiler was run in, then each directory
given with -i, in the order they were given, and finally its own inc/, which holds the C
bindings and the classic headers that ship with the toolchain. The first place that has the file
wins. So a header of your own in the working directory, named like one of the toolchain's, is the
one your files get when they include that name, in place of the toolchain's. Give your headers
names of their own, or include them by a path relative to the file that needs them.
Headers or modules?
Chapter 15 opens with the two problems headers cannot solve: every name a header declares lands in the scope of whoever includes it, and its code is compiled again by every file that does. For your own code, a module is the better tool. Headers remain the right one for a binding to a C library, for macros meant to be shared between files, and for existing code that is already written as headers.
Linkage
Two files compiled on their own can both define a name, and neither compile notices. The linker does:
'' clash.tiko -- two files, each with a sub called Init.
sub Init( )
print "report ready"
end sub
Init( )
'' clashother.tiko -- the other file.
sub Init( )
print "export ready"
end sub
lld-link: error: duplicate symbol: INIT
clang: error: linker command failed with exit code 1 (use -v to see invocation)
tiko-lang: error TKO0116: link failed (exit 1)
(The linker's two >>> defined at lines, which name the temporary object files, are left out.)
A procedure is public by default: its name is visible to the linker, so every other file can
call it, and two files cannot both have one. private keeps a procedure inside its own file.
Make one of the two Inits private and the program links. The file that owns it reaches it
through a public procedure:
'' privatefixother.tiko -- its Init is its own business.
private sub Init( )
print "export ready"
end sub
sub StartExport( )
Init( )
end sub
'' privatefix.tiko -- two subs called Init, and the program links.
declare sub StartExport( )
sub Init( )
print "report ready"
end sub
Init( )
StartExport( )
report ready
export ready
Make helpers private as a habit. A public helper is a name another file can collide with, and an
entry point nobody meant to offer.
Private by default, for a region
Marking every helper costs a word on each one. #pragma push( private, true ) turns the default
around until the matching #pragma pop( private ). Inside the region a procedure is private unless
it says public:
'' pragmaother.tiko -- one public entry point over private helpers.
#pragma push( private, true )
sub Validate( byval n as int32 )
print "validating"; n
end sub
sub Store( byval n as int32 )
print "storing"; n
end sub
'' Inside the region, but it says so: visible to the program.
public sub Flush( )
print "flushing"
end sub
#pragma pop( private )
sub Save( byval n as int32 )
Validate( n )
Store( n )
end sub
'' pragma.tiko -- Save and Flush are all this file can reach.
declare sub Save( byval n as int32 )
declare sub Flush( )
Save( 42 )
Flush( )
validating 42
storing 42
flushing
pragma.tiko can reach Save, which follows the region, and Flush, which says public inside
it. Declaring Validate there and calling it would fail at the link: no file offers a public
Validate.
Shared variables
You have seen one way to share a variable: dim shared in exactly one file and extern in the
header. The other is common. Every file that declares the same common shared variable gets
the same storage, and no file has to be the owner:
'' tally.tiko -- both files declare the same common variable.
common shared hits as int32
declare sub Hit( )
Hit( )
Hit( )
Hit( )
print "hits:"; hits
'' tallyother.tiko -- the same declaration, the same storage.
common shared hits as int32
sub Hit( )
hits += 1
end sub
hits: 3
Prefer extern. With extern, one file says where the variable lives and the header says what it
is, so a mismatch is caught when the files are compiled. common makes every file restate the
declaration, and nothing checks that they agree: declared int32 in one file and float64 in
the other, the program still links, and counts wrong.
Startup and shutdown
A program sometimes has work to do before its first statement, and after its last. A sub marked
constructor runs before the main program; one marked destructor runs after it. A
number after the keyword is a priority:
'' startup.tiko -- the main module, with the log's setup and teardown.
private sub OpenLog( ) constructor 101
print "101: open the log"
end sub
private sub CloseLog( ) destructor 101
print "101: close the log"
end sub
print "main runs"
'' startupconfig.tiko -- the configuration, which needs the log.
private sub LoadConfig( ) constructor 102
print "102: load the config"
end sub
private sub SaveConfig( ) destructor 102
print "102: save the config"
end sub
'' Top-level code in a file that is not the main module.
print "startupconfig.tiko: top-level code"
101: open the log
102: load the config
startupconfig.tiko: top-level code
main runs
102: save the config
101: close the log
Constructors run lowest priority first, and destructors run in the opposite order, so whatever started first is shut down last: the configuration is saved before the log it writes to is closed. Give anything that depends on something else a higher number than the thing it depends on.
Look at where startupconfig.tiko's own top-level code ran: before the main program, after the
constructors. Only one file is the main module, whose top-level code is the program. Every
other file's top-level code runs as part of startup. The main module is the first file named,
unless -m names another:
'' whichmain.tiko -- compiled first, but not the main module.
#cmdline "-m whichmainother"
print "whichmain.tiko: top-level code"
'' whichmainother.tiko -- named by -m, so its code is the program.
print "whichmainother.tiko: the main program"
whichmain.tiko: top-level code
whichmainother.tiko: the main program
#cmdline "-m whichmainother" puts the option in the source, so the listing builds the same way
whoever compiles it.
Namespaces
A private procedure is hidden from other files, but that does not help when two parts of the
same program both want a function called Area. A namespace gives names a family name:
namespace Geo
const ORIGIN = 0
function Area( byval w as float64, byval h as float64 ) as float64
return w * h
end function
namespace Units
const CM_PER_M = 100
end namespace
end namespace
'' A namespace can be reopened, here or in another file.
namespace Geo
function Perimeter( byval w as float64, _
byval h as float64 ) as float64
return 2 * ( w + h )
end function
end namespace
print "area, perimeter and centimetres per metre:"
print Geo.Area( 2, 3 ), Geo.Perimeter( 2, 3 )
print Geo.Units.CM_PER_M
sub Report( )
using Geo
print Area( 4, 5 ), Units.CM_PER_M
end sub
Report( )
area, perimeter and centimetres per metre:
6 10
100
20 100
- Outside the namespace, a member is written qualified:
Geo.Area,Geo.Units.CM_PER_M. - Namespaces nest, and the path is written out in full.
- A namespace can be reopened. The second
namespace GeoaddsPerimeterto the same namespace, and another file can do the same. using Geomakes Geo's members usable without the prefix, from theusingto the end of the block that contains it: here, the subReport. Written at the top of a file, it lasts to the end of the file.
How a name is found
using makes a namespace's names available. It does not make them preferred:
namespace Defaults
const LIMIT = 20
function Label( ) as ustring
return "from Defaults"
end function
end namespace
const LIMIT = 10
function Label( ) as ustring
return "from the module"
end function
sub Show( )
using Defaults
print LIMIT, Label( )
end sub
print "inside Show, under using Defaults:"
Show( )
print "qualified:"
print Defaults.LIMIT, Defaults.Label( )
inside Show, under using Defaults:
10 from the module
qualified:
20 from Defaults
Inside Show, LIMIT and Label are both declared twice: once in Defaults, which using
brings in, and once at module level. A name declared on the way out — in the procedure, then in
the enclosing scopes, then the module — beats every using. The namespace's members are reached
only where nothing closer has the name. To mean the namespace's, qualify it.
When two usings both supply a name and nothing closer declares it, tiko does not pick one:
namespace Report
function Title( ) as ustring
return "Report"
end function
end namespace
namespace Archive
function Title( ) as ustring
return "Archive"
end function
end namespace
using Report, Archive
print Title( )
ambiguous.tiko(14,7): error TKO0919: 'title' is ambiguous: more than one USING in effect declares it; qualify it
|
14 | print Title( )
| ^
Macros
The preprocessor reads the source before the compiler does, and macros are its main tool. A macro is a name for a piece of text, and the preprocessor replaces every use of the name with the text:
#define LIMIT 100
#define SQUARE( x ) ( ( x ) * ( x ) )
#define AS_TEXT( x ) #x
#define PASTE( prefix, n ) prefix##n
#macro SWAP_VALUES( a, b )
scope
var t_ = a
a = b
b = t_
end scope
#endmacro
dim as int32 width1 = 3, width2 = 8
print "limit:"; LIMIT
print "square of 1 + 2:"; SQUARE( 1 + 2 )
print "as text: "; AS_TEXT( width1 + width2 )
print "pasted:"; PASTE( width, 2 )
SWAP_VALUES( width1, width2 )
print "after the swap:"; width1; width2
limit: 100
square of 1 + 2: 9
as text: width1 + width2
pasted: 8
after the swap: 8 3
#define LIMIT 100is an object-like macro:LIMITbecomes100wherever it appears.#define SQUARE( x ) ...takes arguments. The text of each argument is substituted for the parameter, unevaluated.#xturns an argument into a string literal, soAS_TEXT( width1 + width2 )is the textwidth1 + width2, not the sum.prefix##npastes two pieces into one token:PASTE( width, 2 )becomes the identifierwidth2.#macro…#endmacrodefines a macro that spans several lines, which is how a macro expands to whole statements.SWAP_VALUESwraps its temporary in ascopeso that two uses in one procedure do not both declaret_.
A macro cannot take the name of a reserved word: #define print 1 is TKO0371, "'print' cannot be
defined as a macro: it is a reserved word".
A macro substitutes text, not values. The parentheses in SQUARE are not decoration:
#define BAD_SQUARE( x ) x * x
#define SQUARE( x ) ( ( x ) * ( x ) )
print "BAD_SQUARE( 1 + 2 ) ="; BAD_SQUARE( 1 + 2 )
print "SQUARE( 1 + 2 ) ="; SQUARE( 1 + 2 )
print "100 / SQUARE( 5 ) ="; 100 / SQUARE( 5 )
BAD_SQUARE( 1 + 2 ) = 5
SQUARE( 1 + 2 ) = 9
100 / SQUARE( 5 ) = 4
BAD_SQUARE( 1 + 2 ) becomes 1 + 2 * 1 + 2, which is 5. Wrap every parameter in parentheses,
and the whole body too: without the outer pair, 100 / SQUARE( 5 ) would divide by the first 5
and multiply by the second. A function has neither problem, and should be the first thing you
reach for. Use a macro for what a function cannot do.
What a function cannot do is generate code. This macro writes an enum and a function that names
its values, from one list:
'' One list of names, written once: the enum and its spelling.
#macro DEFINE_ENUM( name, a, b, c )
enum name
a
b
c
end enum
function name##_Name( byval v as name ) as ustring
select case v
case a : return #a
case b : return #b
case c : return #c
end select
return "?"
end function
#endmacro
DEFINE_ENUM( Colour, RED, GREEN, BLUE )
DEFINE_ENUM( Size, SMALL, MEDIUM, LARGE )
print Colour_Name( GREEN ); " "; Size_Name( LARGE )
for c as Colour = RED to BLUE
print c; " "; Colour_Name( c )
next
GREEN LARGE
0 RED
1 GREEN
2 BLUE
Each value is written once, so the names can never disagree with the enum. name##_Name builds
the function's name from the enum's, and #a turns each value into its own spelling.
The built-in macros
A family of built-in macros works on macro arguments themselves:
#define SIZES 8, 16, 32, 64
print "count:"; __FB_ARG_COUNT__( SIZES )
print "third:"; __FB_ARG_EXTRACT__( 2, SIZES )
#define PAIR key = value
print "left of =: "; __FB_QUOTE__( __FB_ARG_LEFTOF__( PAIR, = ) )
print "right of =: "; __FB_QUOTE__( __FB_ARG_RIGHTOF__( PAIR, = ) )
print "evaluated:"; __FB_EVAL__( 6 * 7 )
dim as int32 total32 = 32
print "joined:"; __FB_JOIN__( total, 32 )
__FB_UNQUOTE__( "print ""unquoted into code""" )
'' A name no other use of the macro can collide with.
#macro TIMES_TWO( v )
__FB_UNIQUEID_PUSH__( tmp )
var __FB_UNIQUEID__( tmp ) = v
print "twice"; v; " is"; __FB_UNIQUEID__( tmp ) * 2
__FB_UNIQUEID_POP__( tmp )
#endmacro
TIMES_TWO( 5 )
TIMES_TWO( 9 )
count: 4
third: 32
left of =: key
right of =: value
evaluated: 42
joined: 32
unquoted into code
twice 5 is 10
twice 9 is 18
| Macro | Gives |
|---|---|
__FB_ARG_COUNT__( ... ) | how many comma-separated arguments it was given |
__FB_ARG_EXTRACT__( n, ... ) | the argument at position n, counting from 0 |
__FB_ARG_LEFTOF__( arg, sep ), __FB_ARG_RIGHTOF__( arg, sep ) | the text on either side of sep |
__FB_QUOTE__( text ), __FB_UNQUOTE__( "text" ) | text as a string literal, and a string literal as text |
__FB_EVAL__( expr ) | the constant expression's value, worked out by the preprocessor |
__FB_JOIN__( a, b ) | a and b pasted into one token, like ## |
__FB_UNIQUEID_PUSH__( id ), __FB_UNIQUEID__( id ), __FB_UNIQUEID_POP__( id ) | a name no other expansion can produce |
The last row is what makes a macro safe to use twice. TIMES_TWO needs a temporary, and a fixed
name would be declared twice the second time the macro is used. __FB_UNIQUEID__( tmp ) names a
fresh one for each expansion.
Conditional compilation
#if, #ifdef, #elseif, #else and #endif choose which lines are compiled at all. A line
in a branch not taken is never compiled, so it can call things that do not exist on that platform.
platform.ti, above, is the usual use: one source, a different constant on each platform. The
platform symbols tiko defines:
| Symbol | Defined on |
|---|---|
__FB_WIN32__ | Windows |
__FB_LINUX__, __FB_UNIX__ | Linux |
__FB_64BIT__ | every 64-bit target |
__FB_MSVC__ | the windows-x86_64 target |
__TIKO_VERSION__ | always: tiko's version, as a string |
You define your own on the command line: -d NAME=value is #define NAME value before the first
line of every file.
#ifdef asks whether a name is defined, not what it is.
'' TRACE is 0 to turn tracing off -- or so the author thought.
#define TRACE 0
#ifdef TRACE
print "#ifdef TRACE: tracing is on"
#endif
#if TRACE
print "#if TRACE: tracing is on"
#else
print "#if TRACE: tracing is off"
#endif
#ifdef TRACE: tracing is on
#if TRACE: tracing is off
TRACE is defined, as 0, so every #ifdef TRACE in the program is true and the tracing it was
meant to turn off is compiled in. Choose one convention per switch and keep to it: either a switch
is defined or not, and you test it with #ifdef, or it always has a value, and you test it with
#if. #if defined( NAME ) andalso NAME <> 0 covers a name that might be either.
Compile-time checks
The preprocessor can also ask questions about the program, and stop the build when the answer is wrong:
'' Questions answered while compiling, not while running.
dim as int32 count = 0
dim as int64 total = 0
#if defined( __FB_64BIT__ ) andalso defined( __TIKO_VERSION__ )
print "a 64-bit build, by tiko"
#endif
#if typeof( count ) = typeof( int32 )
print "count is an int32"
#endif
#if typeof( total ) <> typeof( count )
print "total is not the same type as count"
#endif
'' The build stops here if the record's layout ever changes.
type Header
magic as uint32
size as uint32
end type
#assert sizeof( Header ) = 8
print "Header is"; sizeof( Header ); " bytes"
a 64-bit build, by tiko
count is an int32
total is not the same type as count
Header is 8 bytes
defined( NAME )is true whenNAMEhas been declared — as a macro, or as anything else above the line — and it combines withandalso,orelseandnot.#if typeof( a ) = typeof( b )compares two types exactly:int32is notint64, and a pointer is not what it points to.#assertstops the build when its condition is false.Headeris a record written to files; if a field is ever added, the build stops at the#assert, before a file in the old layout is misread.
#error stops the build with a message of your own:
'' This program needs a configuration chosen on the command line.
#ifndef CONFIG
#error "build with -d CONFIG=1 (development) or 2 (release)"
#else
print "configuration"; CONFIG
#endif
unsupported.tiko(3,5): error TKO0309: #error: "build with -d CONFIG=1 (development) or 2 (release)"
|
3 | #error "build with -d CONFIG=1 (development) or 2 (release)"
| ^
Built with -d CONFIG=1, the same file prints configuration 1. The print sits in the #else
branch for a reason: #error reports, and the compiler carries on reading the rest of the file
for more errors, so a print that used CONFIG after the #endif would add a second error,
about a name that is not declared. Keep the code that needs the setting in the branch that has it.
#print is the gentle form. #print the build is starting reports its text as a note,
TKO0315, and the build goes on.
Source information and pragmas
The compiler knows where every line came from, and tells you:
sub Report( )
print "in "; __FUNCTION__; ", line"; __LINE__
end sub
print "line:"; __LINE__
Report( )
print "an ISO date has 10 characters:"; len( __DATE_ISO__ )
#line 1000 "generated.tiko"
print "now:"; __LINE__; " of "; __FILE__
#ifdef __TIKO_VERSION__
print "compiled by tiko"
#endif
line: 5
in REPORT, line 2
an ISO date has 10 characters: 10
now: 1001 of generated.tiko
compiled by tiko
__LINE__is the line it is written on, and__FILE__the file's name.__FUNCTION__is the enclosing procedure's name, in upper case.__DATE_ISO__is the date of the build asyyyy-mm-dd, which is why the listing prints only its length: the date changes every day, and an expected output cannot.#line 1000 "generated.tiko"tells the compiler that the next line is line 1000 ofgenerated.tiko. A program that writes tiko source for another program uses it, so errors point at the file a person edits rather than the generated one.
Diagnostics use the same information, so after a #line they point where it says.
Pragmas are instructions to the compiler. You have met #pragma once and #pragma push( private, true ). #pragma reserve keeps a name from being declared at all, which lets a library claim a
name now and fill it in later:
'' Keep a name free for a later version of the library.
#pragma reserve Checksum
function Checksum( byval s as ustring ) as uint32
return len( s )
end function
reserved.tiko(4,1): error TKO0769: 'checksum' is reserved by '#pragma reserve' and cannot be declared
|
4 | function Checksum( byval s as ustring ) as uint32
| ^
#lang "fb" names the language a file is written in. It is the only one tiko accepts, so you
never need to write it; any other is TKO0317.
What it costs
- A header is compiled again by every file that includes it. That is nothing for a few declarations and a great deal for a large C binding included by fifty files. Modules exist to pay that cost once (Chapter 15).
- Linking is where separately compiled files meet, so a clash or a missing definition is found last, after every file has compiled, and reported by the linker in the linker's terms.
- A macro has no type and no scope. It is checked only after it is expanded, so a mistake in a macro is reported at every use, in terms of the expanded text.
- A branch of conditional compilation is compiled only on the platform that takes it. Code for Linux that is never built on Linux is code nobody has compiled. Build every target you claim to support.
- Namespaces cost nothing when the program runs. A namespace changes the name the linker sees, not the code.
Summary
- A program can be several files, each compiled on its own and joined by the linker. A header
(
.ti) declares what the files share:declarefor procedures,externfor variables. Exactly one file defines each. #include onceand#pragma oncemake a header safe to reach more than once. A header is looked for first beside the file that includes it, then in the working directory, then in each-idirectory in turn, then in tiko'sinc/.- Procedures are public by default.
privatekeeps one in its file, and#pragma push( private, true )…#pragma pop( private )makes private the default for a region. dim sharedin one file withexternin the header shares a variable;common sharedmakes every declaring file share one, unchecked.constructoranddestructorsubs run before and after the main program, lowest priority first and last respectively. Other files' top-level code runs during startup;-mchooses the main module.- A namespace qualifies names. It nests and can be reopened;
usingmakes its members available to the end of the enclosing block, but any closer declaration of the name wins, and twousings supplying the same name isTKO0919. - Macros substitute text: parenthesise parameters and bodies, use
#to quote,##to paste, and#macrofor whole statements. Prefer a function unless the macro generates code. #iftests a value and#ifdeftests a definition. Platform symbols include__FB_WIN32__,__FB_LINUX__,__FB_64BIT__and__FB_MSVC__;-ddefines your own.defined( ),#if typeof,#assertand#errorcheck the program while it compiles.#errorreports and the compiler reads on.__FILE__,__LINE__,__FUNCTION__and__DATE_ISO__describe the source;#linerenumbers it.#pragma reserveclaims a name.
Exercises
- Write
CUBE( x )so thatCUBE( 1 + 1 )is 8 and100 \ CUBE( 2 )is 12. Then call it with a function that prints each time it runs. How many times does it run, and why? {solution="ex16-01"} - Write a
#macroDEFINE_COUNTER( name )that defines a functionNext<name>returning 1, 2, 3 … on successive calls, so thatDEFINE_COUNTER( Order )givesNextOrder( ). Define two counters and show they count separately. {solution="ex16-02"} - Write a program that prints the name of the environment variable that holds the temporary
directory:
TEMPon Windows,TMPDIRon Linux. Make it refuse to build anywhere else. {solution="ex16-03"} - Put a function
Areain a namespaceDisc(one radius) and another in a namespaceRect(width and height). Call the first without its prefix from a sub, and the second qualified. {solution="ex16-04"} - Take
clash.tikoandclashother.tikoand make them link with#pragma push( private, true )instead of the wordprivate. Which file did you change, and why that one?
Generics
A Stack of integers and a Stack of names are the same code twice. Before generics you had
three ways to avoid writing it twice, and all of them cost something: a macro, which the compiler
checks only after it has been pasted somewhere; a container of any ptr, which forgets what it
holds and makes you remember; or copy and paste, which works until the two copies drift.
A generic is the fourth way. You write the code once with a placeholder for the type, and the compiler produces a real, separately checked version for each type you ask for. The code inside a generic is ordinary tiko — the same parser, the same type checking, the same errors — and what comes out is as fast and as strongly typed as the version you would have written by hand.
A type with a parameter
of T after the type's name declares a type parameter, and T is then usable anywhere a type
can appear:
'' box.tiko -- a generic type, and one type per argument list.
type Box( of T )
as T value
end type
dim b as Box( of int32 )
dim s as Box( of ustring )
dim n as Box( of Box( of int32 ) ) '' nesting is fine
b.value = 42
s.value = "hello"
n.value.value = 7
print b.value
print s.value
print n.value.value
42
hello
7
Box( of int32 ) and Box( of ustring ) are two distinct types, as unrelated as any two
types you wrote by hand. One will not assign to the other and one will not pass where the other is
expected. Nesting is ordinary too: Box( of Box( of int32 ) ) is a box holding a box.
Each distinct argument list produces one instantiation, and writing Box( of int32 ) in two
different files produces the same type in both — they link.
A procedure with a parameter
Procedures take type parameters the same way, and you usually do not have to state them:
'' minmax.tiko -- a generic procedure, inferred and given explicitly.
function Larger( of T )( byval a as T, byval b as T ) as T
if a > b then return a
return b
end function
print Larger( 3, 9 ) '' T inferred from the arguments
print Larger( of float64 )( 1.5, 2.5 ) '' or stated
print Larger( "apple", "pear" )
9
2.5
pear
The first call says nothing about T; the compiler infers int32 from the arguments. The second
states it, Larger( of float64 )( … ), which you need when the arguments would infer something
else — here, two literals that would otherwise have been integers.
Inference reads the arguments, and nothing else. It cannot work backwards from what you do with the result, so a generic whose type parameter appears only in the return type must always be given its argument explicitly.
Members, and bodies written out of line
A generic type can declare the full range of members — methods, properties, constructors,
operators — and their bodies can live away from the declaration, which is how you keep a long type
readable. The body repeats the ( of T ):
'' stack.tiko -- a generic type with members, defined out of line.
type Stack( of T )
public:
declare sub Push( byval item as T )
declare function Pop( ) as T
declare function IsEmpty( ) as boolean
declare property Count( ) as int32
private:
as T items( any )
as int32 count_
end type
sub Stack( of T ).Push( byval item as T )
if this.count_ > ubound( this.items ) then
redim preserve this.items( this.count_ * 2 + 3 )
end if
this.items( this.count_ ) = item
this.count_ += 1
end sub
function Stack( of T ).Pop( ) as T
this.count_ -= 1
return this.items( this.count_ )
end function
function Stack( of T ).IsEmpty( ) as boolean
return this.count_ = 0
end function
property Stack( of T ).Count( ) as int32
return this.count_
end property
dim words as Stack( of ustring )
words.Push( "first" )
words.Push( "second" )
print "count: " & words.Count
print words.Pop( )
print words.Pop( )
print "empty: " & words.IsEmpty( )
count: 2
second
first
empty: true
Two details in that listing are worth naming. redim preserve this.items( … ) grows the storage,
so T must be a type an array can hold — which every type is. And declaration order does not matter: the member bodies come after the type, and a generic may be used before its bodies are
written, because the body is not compiled until something asks for an instantiation.
More than one parameter is the obvious extension:
'' pair.tiko -- two type parameters, and an out-of-line member.
type Pair( of K, V )
declare function Describe( ) as ustring
as K key
as V value
end type
function Pair( of K, V ).Describe( ) as ustring
return "(" & this.key & ", " & this.value & ")"
end function
dim score as Pair( of ustring, int32 )
score.key = "gold"
score.value = 3
print score.Describe( )
dim ratio as Pair( of ustring, float64 )
ratio.key = "pi"
ratio.value = 3.14
print ratio.Describe( )
(gold, 3)
(pi, 3.14)
Inheritance and virtual methods
An instantiation is an ordinary type, so it can extend another type, and it can override virtual methods. Dispatch through a base pointer reaches the instantiation's override:
type Base_ extends Object
declare virtual function Name( ) as ustring
end type
function Base_.Name( ) as ustring
return "base"
end function
type Holder( of T ) extends Base_
declare virtual function Name( ) as ustring
as T v
end type
function Holder( of T ).Name( ) as ustring
return "holder"
end function
dim h as Holder( of int32 )
dim p as Base_ ptr = @h
print p->Name( ) '' virtual dispatch into an instantiation
holder
This is what makes a generic usable inside an object-oriented design rather than beside one: a
Holder( of int32 ) is a Base_, and code that knows only about Base_ can hold one.
A type parameter promises nothing
This is the part of generics that surprises people coming from languages with constraints, and it shapes how you design with them.
of T does not say what T can do. It is a blank. The compiler therefore cannot check the body
of a generic when it reads it — it has no idea whether T has a > operator — and instead checks
the body once per instantiation, against the real type. A demand the type cannot meet is an
error at that point:
'' unconstrained.tiko -- a type parameter promises nothing, so the
'' body's demands are checked only once a type goes in. Fails to build.
type Ranked( of T )
declare function Best( byval a as T, byval b as T ) as T
as T dummy
end type
function Ranked( of T ).Best( byval a as T, byval b as T ) as T
if a > b then return a '' needs '>' on T
return b
end function
type Opaque_
as int32 x
end type
dim r as Ranked( of Opaque_ ) '' Opaque_ has no '>'
That program does not build:
unconstrained.tiko(9,10): error TKO0720: no operator > compares this type
|
9 | if a > b then return a '' needs '>' on T
| ^
unconstrained.tiko(17,10): note: in instantiation of 'ranked( of opaque_ )'
|
17 | dim r as Ranked( of Opaque_ ) '' Opaque_ has no '>'
| ^
The error is in the generic's body; the cause is usually the instantiation. The first line
points at Ranked's >, which is correct code. The mistake is line 17, where Opaque_, a type
with no >, was handed to it. Read only the first line and you start editing a generic that was
never wrong.
The note: in instantiation of line underneath names the instantiation and the line that wrote it.
Read to the end of an error before you edit. And comment a generic's demands where a reader will
see them, "T needs >" above the type, so the next person reaches line 17 first.
The upside of the same rule is that a generic costs nothing to declare. Stack( of T ) works for
every type that can be copied, and you never had to enumerate what those are.
Making your own type fit a generic's demands
The standard containers state their demands in their documentation rather than in the language.
Map( of K, V ) needs two things of a key: that K = K compiles, and that the key can hash
itself. You supply both:
#import "std/containers"
using containers
'' The hash contract: extend Hashable and override its HashOf( ).
type Point_ extends Hashable
as int32 x, y
declare constructor( byval x as int32 = 0, byval y as int32 = 0 )
declare function HashOf( ) as ulongint override
end type
constructor Point_( byval x as int32, byval y as int32 )
this.x = x : this.y = y
end constructor
function Point_.HashOf( ) as ulongint
return containers.HashOf( x ) * 31 + containers.HashOf( y )
end function
operator = ( byref a as Point_, byref b as Point_ ) as boolean
return ( a.x = b.x ) andalso ( a.y = b.y )
end operator
dim seen as Map( of Point_, ustring )
dim as Point_ a = Point_( 1, 2 ), b = Point_( 1, 2 )
seen[ a ] = "first"
print "keys: " & seen.Count & " lookup: " & seen[ b ]
keys: 1 lookup: first
The key extends Hashable and overrides its HashOf( ), because the container's code lives in
the std/containers module: a free HashOf overload of your own would be invisible to it, and a
module's namespace cannot be re-opened to add one. The built-in types are hashable already, and
containers.HashOf( x ) is how the override hashes its fields.
operator = for a key type is not optional. A hash gets you to the right bucket; equality decides
which entry in that bucket is yours, and a hash that agrees while equality disagrees produces a map
that stores duplicates and finds nothing.
Filling a container from a list
A container starts empty, and filling it used to take one statement per element. A brace list says the same thing at the declaration, the way it does for an array:
'' fill.tiko -- a brace list fills a container: one call per element,
'' in order, after the container is constructed.
#import "std/containers"
'' Your own generic takes part by having an Add, a PushBack or a Push.
type Bag( of T )
declare sub Add( byval item as T )
as T items( any )
end type
sub Bag( of T ).Add( byval item as T )
redim preserve this.items( ubound( this.items ) + 1 )
this.items( ubound( this.items ) ) = item
end sub
dim primes as containers.Array( of int32 ) = { 2, 3, 5, 7 }
dim queue as containers.LinkedList( of ustring ) = { "a", "b", "c" }
dim ages as containers.Map( of ustring, int32 ) = _
{ { "Ada", 36 }, { "Grace", 85 } }
dim bag as Bag( of float64 ) = { 0.5, 1.5 }
print "primes:"; primes.Count( ); ", last"; primes[3]
for each s as ustring in queue
print s;
next
print
print "Grace is"; ages["Grace"]
print "bag holds"; ubound( bag.items ) + 1; ", last"; bag.items( 1 )
primes: 4, last 7
abc
Grace is 85
bag holds 2, last 1.5
Each element becomes one call, in order, after the container is constructed. primes gets four
calls to Push, queue three to PushBack, and ages two to Add. An element that is itself a
brace list supplies the arguments of one call, which is how { "Ada", 36 } becomes
ages.Add( "Ada", 36 ). They are ordinary calls, so the ordinary conversions apply: "Ada" is a
string literal and arrives as the ustring key the map wants.
The demand is the same kind this chapter has been describing: a shape, not a declaration. A
brace list fills a type that needs constructing and has a member called Add, PushBack or
Push, looked for in that order. Nothing says "I am a collection". Bag( of T ) takes part
because it has an Add, and because its dynamic array is a field that needs constructing.
A type that needs constructing but has none of the three methods is refused:
'' fillnone.tiko -- a type that needs constructing but has no Add,
'' PushBack or Push cannot take a brace list.
type Counter
declare constructor( )
as int32 n
end type
constructor Counter( )
this.n = 100
end constructor
dim c as Counter = { 1, 2 } '' refused
print c.n
fillnone.tiko(13,20): error TKO0843: 'c' cannot take an initializer list: its type has no Add, PushBack or Push method
|
13 | dim c as Counter = { 1, 2 } '' refused
| ^
Overloading, and when a second generic is unreachable
Two generic procedures may share a name when they take different numbers of arguments, and the call picks the right one — the usual overloading rules apply to the instantiations.
Two limits are worth knowing before you lean on it:
- A second generic with a different number of type parameters does not resolve. Declaring
both
Make( of T )( a as T )andMake( of K, V )( k as K, v as V )compiles, but the two-argument call fails withTKO0632: no overload of 'make<8>' matches these arguments. - An unconstrained
of Tparameter matches everything, so an overload that differs only in parameter type is unreachable. GivenWiden( of T )( a as T )andWiden( of T )( a as T ptr ), a pointer argument still selects the first —Tbinds to the pointer type. Distinguish overloads by arity, or give them different names.
Instantiation has a depth limit
A generic that instantiates itself, directly or through another generic, would otherwise recurse until memory ran out. The compiler stops at 64 levels:
type Wrap( of T )
as T v
end type
'' each instantiation asks for another, one level deeper
type Deep( of T )
as Wrap( of Deep( of Wrap( of T ) ) ) w
end type
dim d as Deep( of int32 )
print sizeof( d )
toodeep.tiko(6,26): error TKO0829: generic instantiation of 'wrap' is too deep
(the limit is 64; -maxinstdepth raises it)
Almost always this means a mistake — a type whose definition mentions itself without a pointer or
an indirection to stop the recursion. When the depth is genuinely needed, -maxinstdepth raises
the ceiling.
What it costs
Each distinct argument list produces its own code, so Array( of int32 ) and Array( of ustring )
are two separate compiled copies. That is the deal generics make: you pay in binary size for code
that runs with no indirection, no boxing and no run-time type checks. A handful of instantiations
is nothing; a generic instantiated over forty types in a large program is worth noticing.
Compared with the alternatives, a generic is checked where an any ptr container is not, and it
produces real diagnostics where a macro produces diagnostics about the expansion.
Summary
type Box( of T )andfunction Larger( of T )( … )declare type parameters; each distinct argument list is a separate, fully checked type or procedure.- Type arguments are inferred from a call's arguments, and stated explicitly when they are not — inference never works backwards from the return type.
- Members may be defined out of line, repeating the
( of T ). Declaration order does not matter. - An instantiation is an ordinary type: it can extend a base and override virtual methods.
- A type parameter promises nothing. The body is checked per instantiation; the error lands
in the body, and a
note: in instantiation ofnames the instantiation that caused it. - A container states its demands in its docs:
Map's key needs=, and aHashOf( )override from extendingcontainers.Hashable. - A brace list fills a container: one
Add,PushBackorPushper element, after construction, and{ k, v }supplies one call's arguments. Any type that needs constructing and has one of those methods takes part. - Instantiation stops at 64 levels deep;
-maxinstdepthraises it.
Exercises
- Write
Queue( of T )— first in, first out — withEnqueue,Dequeueand aCountproperty, and use it with two different element types. {solution="ex17-01"} - Write
Swap_( of T )( byref a as T, byref b as T ). Then try calling it with anint32and afloat64in the same call, and read the error: what does it tell you about how inference decidesT? {solution="ex17-02"} - Make a
Version_type withmajorandminorfields usable as aMapkey, so that two separately built values of the same version find the same entry. {solution="ex17-03"} - Take the
Ranked( of T )listing above and make it compile by givingOpaque_the operator it needs. Which line did you have to change — the generic, or the type you put in it?
Errors: Optional, Result and Runtime Checks
Every program meets things that go wrong: a file that is not there, a line that does not parse, a divisor that turns out to be zero. The question is never whether failure happens but where the program finds out about it, and whether the code that finds out is the code that can do something about it.
tiko gives you two kinds of answer. Values that say they might have failed — Optional and
Result from the standard library — put the possibility of failure into a function's type, so the
caller cannot use the answer without first deciding what to do when there is none. Runtime
errors — the error statement, on error goto, and the checks -exx adds — stop the normal
flow and jump to a handler. The first kind is for failures a caller is expected to handle. The
second is for the ones it is not.
This chapter covers both, then the checks the compiler can add for you and the assertions you can add yourself.
A value that means "nothing"
The oldest way to report failure is a value that cannot otherwise happen:
'' The coldest reading, or -1 when there are none.
function Coldest( temps() as int32 ) as int32
if ubound( temps ) < lbound( temps ) then return -1
var lowest = temps( lbound( temps ) )
for i as int32 = lbound( temps ) + 1 to ubound( temps )
if temps( i ) < lowest then lowest = temps( i )
next
return lowest
end function
dim none() as int32
dim winter( 0 to 2 ) as int32 = { 3, -1, 2 }
print "no readings:"; Coldest( none() )
print "a cold day: "; Coldest( winter() )
no readings:-1
a cold day: -1
A sentinel is only safe while it cannot be a real answer. Coldest returns −1 to mean "there
were no readings", and −1 is also a perfectly good temperature. Both calls print −1; one means
"nothing", the other means "minus one degree", and the caller has no way to tell them apart.
It compiles, it runs, and it is wrong exactly when the data reaches the sentinel — the case a test is least likely to cover. The fix is not a cleverer sentinel (−9999 is a legal reading somewhere); it is a return type that has room for "no answer" beside every possible answer.
Optional
Optional( of T ) is a T that might not be there. It lives in the standard library's outcome
module:
#import "std/outcome"
'' Where v is in a, if it is anywhere.
function FindIndex( a() as int32, byval v as int32 ) _
as outcome.Optional( of int32 )
for i as int32 = lbound( a ) to ubound( a )
if a( i ) = v then return outcome.Some( i )
next
return outcome.None( of int32 )( )
end function
dim xs( 0 to 3 ) as int32 = { 4, 8, 15, 16 }
var found = FindIndex( xs(), 15 )
if found.HasValue( ) then print "15 is at"; found.Value( )
var missing = FindIndex( xs(), 99 )
print "99 is at "; str( missing.ValueOr( -1 ) ); ", which means nowhere"
dim nothing as outcome.Optional( of ustring )
print "a new Optional has a value: "; nothing.HasValue( )
15 is at 2
99 is at -1, which means nowhere
a new Optional has a value: false
| Member or procedure | Does |
|---|---|
outcome.Some( v ) | an Optional holding v |
outcome.None( of T )( ) | an Optional holding nothing |
o.HasValue( ) | whether there is a value |
o.Value( ) | the value — a runtime error if there is none |
o.ValueOr( d ) | the value, or d if there is none; never raises |
FindIndex can now say "15 is at index 2" and "99 is not here" without inventing an index that
means nothing. The caller has to ask: there is no way to get an int32 out of the result without
choosing between HasValue, Value and ValueOr.
A few details matter in practice:
- A new
Optionalis empty.dim nothing as outcome.Optional( of ustring )holds no value, and there is no separate "uninitialised" state to get wrong. SomeinfersTfrom its argument, and a literal is aninteger.outcome.Some( 42 )is anOptional( of integer ), which does not convert to anOptional( of int32 ). Writeoutcome.Some( of int32 )( 42 )when the type has to be exact.Nonehas no argument to infer from, so its type is always written.Value( )on an emptyOptionalis a bug at the call site, not a recoverable condition: it raises runtime error 1, "illegal function call". AskHasValuefirst, or useValueOr. The section on runtime errors shows the error being caught.
Result
Optional says that there is no answer. Often the caller also needs to know why, and that is
Result( of T, E ): a T on success, or an E that explains the failure.
#import "std/outcome"
type DigitResult as outcome.Result( of int32, ustring )
function ParseDigit( byval s as ustring ) as DigitResult
if ( len( s ) = 1 ) andalso ( s >= "0" ) andalso ( s <= "9" ) then
return outcome.Ok( of int32, ustring )( asc( s ) - 48 )
end if
var why = "'" & s & "' is not a digit"
return outcome.Fail( of int32, ustring )( why )
end function
dim inputs( 0 to 2 ) as ustring = { "7", "x", "42" }
for each s as ustring in inputs
var r = ParseDigit( s )
if r.IsOk( ) then
print s; " ->"; r.Value( )
else
print s; " -> "; r.Failure( )
end if
next
dim blank as DigitResult
print "a new Result succeeded: "; blank.IsOk( )
7 -> 7
x -> 'x' is not a digit
42 -> '42' is not a digit
a new Result succeeded: false
| Member or procedure | Does |
|---|---|
outcome.Ok( of T, E )( v ) | a success holding v |
outcome.Fail( of T, E )( f ) | a failure holding f |
r.IsOk( ) | whether it succeeded |
r.Value( ) | the value — a runtime error if it failed |
r.Failure( ) | the reason — a runtime error if it succeeded |
r.ValueOr( d ) | the value, or d on failure |
Ok and Fail each mention only one of T and E in their parameters, so the compiler cannot
work out the other: both type arguments are always written. That is why the result type is
usually named once with a type … as, here DigitResult, and the long spelling appears only
where a value is built.
A new Result is a failure. dim blank as DigitResult reports IsOk( ) false, and its
Failure( ) is a default-constructed E. That is the safe default — code that forgets to set a
Result cannot accidentally claim success — and it has a consequence for your own error types:
E needs a constructor that takes no arguments, because every Result builds one.
Passing a failure on
A function that calls another fallible function usually wants to stop at the first failure and pass it up, adding what it knows:
#import "std/outcome"
type Quotient as outcome.Result( of float64, ustring )
function Divide( byval a as float64, byval b as float64 ) as Quotient
if b = 0 then
return outcome.Fail( of float64, ustring )( "division by zero" )
end if
return outcome.Ok( of float64, ustring )( a / b )
end function
'' The average of a total over a count, which can fail the same way.
function Average( byval total as float64, byval n as int32 ) as Quotient
var q = Divide( total, n )
if q.IsOk( ) = false then
var why = "no average: " & q.Failure( )
return outcome.Fail( of float64, ustring )( why )
end if
return q
end function
print Average( 10, 4 ).ValueOr( 0 )
var bad = Average( 10, 0 )
print bad.IsOk( ); " "; bad.Failure( )
2.5
false no average: division by zero
Average asks IsOk( ), and on failure builds its own failure that wraps the reason it was
given. On success it returns Divide's result unchanged, because it is already the right type.
Each layer either handles a failure or passes it on, and either way it says so in its code.
An error type of your own
A ustring is enough to tell a person what went wrong. A program that wants to react to the
failure — retry, skip the line, ask again — needs something it can branch on. The standard
library's fs.FsError is the model: a code, the operation and path involved, and a message. A
configuration parser built the same way:
#import "std/outcome"
'' WHY A LINE WAS REFUSED, modelled on fs.FsError: a code to branch
'' on, the line it happened on, and a message for people.
enum ConfigCode
CFG_OK
CFG_NO_EQUALS
CFG_EMPTY_KEY
CFG_NOT_A_NUMBER
end enum
type ConfigError
code as ConfigCode
lineNo as int32
declare constructor( )
declare constructor( byval c as ConfigCode, byval n as int32 )
declare function Message( ) as ustring
end type
constructor ConfigError( )
end constructor
constructor ConfigError( byval c as ConfigCode, byval n as int32 )
code = c : lineNo = n
end constructor
function ConfigError.Message( ) as ustring
dim as ustring what
select case code
case CFG_NO_EQUALS : what = "expected key=value"
case CFG_EMPTY_KEY : what = "the key is empty"
case CFG_NOT_A_NUMBER : what = "the value is not a number"
end select
return "line " & str( lineNo ) & ": " & what
end function
type Setting
key as ustring
value as int32
end type
type SettingResult as outcome.Result( of Setting, ConfigError )
function Refuse( byval c as ConfigCode, byval n as int32 ) _
as SettingResult
var e = ConfigError( c, n )
return outcome.Fail( of Setting, ConfigError )( e )
end function
function ParseLine( byval text as ustring, byval n as int32 ) _
as SettingResult
var eq = instr( text, "=" )
if eq = 0 then return Refuse( CFG_NO_EQUALS, n )
dim s as Setting
s.key = trim( left( text, eq - 1 ) )
if len( s.key ) = 0 then return Refuse( CFG_EMPTY_KEY, n )
var v = trim( mid( text, eq + 1 ) )
if ( len( v ) = 0 ) orelse ( str( valint( v ) ) <> v ) then
return Refuse( CFG_NOT_A_NUMBER, n )
end if
s.value = valint( v )
return outcome.Ok( of Setting, ConfigError )( s )
end function
'' Every line, or the first failure: the error travels up unchanged.
function ParseConfig( lines() as ustring, settings() as Setting ) _
as outcome.Result( of int32, ConfigError )
for i as int32 = lbound( lines ) to ubound( lines )
var r = ParseLine( lines( i ), i + 1 )
if r.IsOk( ) = false then
return outcome.Fail( of int32, ConfigError )( r.Failure( ) )
end if
redim preserve settings( ubound( settings ) + 1 )
settings( ubound( settings ) ) = r.Value( )
next
return outcome.Ok( of int32, ConfigError )( ubound( settings ) + 1 )
end function
sub Load( lines() as ustring )
dim settings() as Setting
var r = ParseConfig( lines(), settings() )
if r.IsOk( ) then
print "loaded"; r.Value( ); " settings:";
for each s as Setting in settings
print " "; s.key; "="; str( s.value );
next
print
else
print "refused, "; r.Failure( ).Message( )
end if
end sub
dim good( 0 to 2 ) as ustring = _
{ "width = 80", "height=24", "tabs = 4" }
dim bad( 0 to 2 ) as ustring = _
{ "width = 80", "height=tall", "tabs = 4" }
Load( good() )
Load( bad() )
loaded 3 settings: width=80 height=24 tabs=4
refused, line 2: the value is not a number
ConfigError carries a ConfigCode for programs and a Message( ) for people, and it has the
constructor with no arguments that Result needs. ParseLine fails in three distinct ways, and
ParseConfig stops at the first failing line and passes its error up unchanged. The caller sees
exactly which line failed and why, and nothing below it had to print anything.
The standard library's own errors work the same way. Reading a file that is not there:
#import "std/fs"
var r = fs.ReadAllText( "no-such-file.txt" )
if r.IsOk( ) = false then
var e = r.Failure( )
if e.Code( ) = fs.FS_NOT_FOUND then print "not found"
print e.Operation( ); " "; e.Path( ); ": "; e.Message( )
end if
not found
ReadAllText no-such-file.txt: no such file or directory
ReadAllText returns a Result whose failure is an fs.FsError. Code( ) is what the program
branches on — fs.FS_NOT_FOUND here, the same on Windows and Linux — and the text is for the
person reading the output.
Statements that return an error code
Some of the runtime's oldest statements have a function form that returns an error number
instead of raising a runtime error. open and close are the ones you will meet most:
dim as int32 f = freefile
'' The function form of OPEN returns an error code; it does not raise.
print "missing:"; open( "no-such-file.txt" for input as #f )
print "present:"; open( "notes.txt" for input as #f )
print "close:"; close( #f )
print "again:"; close( #f )
missing: 2
present: 0
close: 0
again: 1
0 means success. Any other value is a runtime error number: 2 is "file not found", and closing a file number that is no longer open is 1, "illegal function call".
Written as a statement — open "notes.txt" for input as #f — the same failure is silent
unless the program was compiled with -e, and then it stops the program with runtime error 2. A
missing file that nothing reports is the worst outcome of the three, which is the reason to use
the function form whenever a missing file is an expected event rather than a bug.
Runtime errors
A runtime error is a numbered failure that stops the normal flow of the program. The runtime
raises them itself — a file that cannot be opened, an Optional with no value — and your code can
raise one with error n. If nothing handles it, the program prints the error and ends with that
number as its exit code. To handle one, install a handler with on error goto:
#cmdline "-exx"
function Ratio( byval a as int32, byval b as int32 ) as int32
if b = 0 then error 11 '' 11: division by zero
return a \ b
end function
sub Report( byval a as int32, byval b as int32 )
on local error goto failed
dim as int32 r = Ratio( a, b )
print a; " /"; b; " ="; r
exit sub
failed:
'' Copy the error out first: the next runtime call may clear it.
dim as int32 code = err, where_ = erl
print "error"; code; " at line"; where_; " in "; *erfn( )
end sub
Report( 12, 4 )
Report( 1, 0 )
12 / 4 = 3
error 11 at line 4 in RATIO
on local error goto failed installs a handler for the rest of Report. When Ratio raises
error 11, control jumps to failed:, where four functions describe what happened:
| Function | Gives |
|---|---|
err | the error number |
erl | the line it happened on |
erfn( ) | the procedure it happened in, as a zstring ptr |
ermn( ) | the module (source file) it happened in, as a zstring ptr |
erfn and ermn need the error-location information -exx compiles in; without it erfn( )
is empty. And read err before doing anything else. The next call into the runtime may clear
it: print "error"; err printed 0 in testing, because printing the text came first. Copying it to
a variable on the handler's first line is the reliable form.
An empty Optional raises in exactly this way:
#import "std/outcome"
sub ShowValue( byval o as outcome.Optional( of int32 ) )
on local error goto empty
dim as int32 v = o.Value( )
print "value:"; v
exit sub
empty:
dim as int32 code = err
print "no value: runtime error"; code
end sub
ShowValue( outcome.Some( of int32 )( 42 ) )
ShowValue( outcome.None( of int32 )( ) )
value: 42
no value: runtime error 1
What the jump skips
When an error jumps out of a procedure, that procedure's destructors and defers do not run.
type Connection
handle as int32
declare constructor( )
declare destructor( )
end type
constructor Connection( )
print " connection opened"
end constructor
destructor Connection( )
print " connection closed"
end destructor
sub Transfer( )
dim c as Connection
defer print " transfer finished"
error 5
end sub
on error goto failed
Transfer( )
end
failed:
dim as int32 code = err
print "handler: error"; code
connection opened
handler: error 5
Transfer opens a Connection and schedules a defer. The error leaves Transfer for the
handler at module level, and neither runs: the connection is never closed, and nothing reports
it. A handler inside the procedure is different — Report above returns normally from its own
handler, and its locals are cleaned up as usual. The danger is the error that crosses a procedure
boundary on its way to a handler further out. Keep handlers next to the resources they protect,
or report failures with a Result, which unwinds like any other return.
tiko has no resume. In a handler you can inspect the error, clean up and leave; you cannot jump
back into the code that failed. resume is refused as TKO0409: 'RESUME' is not available in the fb dialect. A loop around a Result does what a resume would have, and says so where it
happens.
Runtime checking
Some mistakes can only be caught while the program runs: an array index out of range, a null pointer followed. The compiler adds checks for them when asked, and three options control how many:
| Option | Adds |
|---|---|
-e | error checking: runtime errors report and end the program, and handlers work |
-ex | the same, plus the support resume would need in FreeBASIC |
-exx | the same, plus array bounds, null pointers and the error's location |
Chapters 8 and 11 showed what -exx catches. A program can ask how it was compiled through
__FB_ERR__, a bit mask:
#cmdline "-exx"
'' Which runtime checks this program was compiled with.
print "__FB_ERR__ ="; __FB_ERR__
print "error checking: "; iif( __FB_ERR__ and 1, "on", "off" )
print "array bounds: "; iif( __FB_ERR__ and 8, "on", "off" )
print "null pointers: "; iif( __FB_ERR__ and 16, "on", "off" )
__FB_ERR__ = 1823
error checking: on
array bounds: on
null pointers: on
The values are 0 with no option, 513 with -e, 515 with -ex and 1823 with -exx. Bit 1 is
error checking, 8 array bounds and 16 null pointers; the rest are documented with the
compiler's options.
Each check also has an option of its own, for a build that wants one check and not the rest:
-earray (8), -enullptr (16), -elocation, -eassert, -edebug, -edebuginfo, -eunwind and
-earraydims, each setting only its own bit. -g sets 32, 64 and 128 — assertions, debug
information and __FB_DEBUG__ — so __FB_ERR__ is 224 with -g alone and 2047 with -g -exx.
Checks cost speed. -exx adds a comparison to every array access and a test to every pointer
dereference. Use it while developing and testing, and decide deliberately whether a release
build keeps it.
Assertions
An assertion states something that must be true, and stops the program if it is not. tiko has two kinds.
#assert is checked by the compiler, against constants:
const SLOTS = 12
#assert SLOTS mod 4 = 0 '' holds
#assert SLOTS <= 8 '' refused
print "built"
staticassert.tiko(4,1): error TKO0604: #assert condition failed
|
4 | #assert SLOTS <= 8 '' refused
| ^
It costs nothing at run time and catches a configuration mistake before the program exists.
assert is checked while the program runs, and only in a build with -g. Without -g it
compiles to nothing at all, so it can stay in code that ships:
#cmdline "-g"
function Average( byval total as int32, byval n as int32 ) as int32
assert( n > 0 )
return total \ n
end function
dim as int32 a = Average( 10, 0 )
assertfail.tiko(4): assertion failed at AVERAGE: n > 0
The failure names the file, the line, the procedure and the condition as written, and the program
exits with status 1. assertwarn does the same and carries on.
An assertion is for a mistake in the program — a caller passing 0 where the documentation says
"never 0". A condition the program should expect, like a missing file, is a Result.
What it costs
- An
Optional( of T )is aTplus a flag, and theTis default-constructed even when there is no value. AResult( of T, E )holds both aTand anE. For small types this is nothing; for a large one, return it through a parameter or a pointer instead. - Checking a
Resultis a branch at every call. That is the point: the cost is paid where the decision is made. on error gotocosts nothing until an error happens, and then skips destructors on its way out of a procedure, which is a cost of a different kind.-exxslows every array access and pointer dereference.assertcosts nothing without-g, and a comparison with it.
Summary
- A sentinel return value is ambiguous whenever it can also be a real answer.
Optional( of T )holds a value or nothing:HasValue,Value(raises when empty),ValueOr.Some( v )andNone( of T )( )build one; a new one is empty.Result( of T, E )holds a value or a reason:IsOk,Value,Failure,ValueOr.OkandFailneed both type arguments, so name the type withtype … as. A newResultis a failure, andEneeds a default constructor.- Pass failures up by returning them; give the error a code to branch on, as
fs.FsErrordoes. open( … )andclose( … )used as functions return an error number instead of raising.error nraises a runtime error;on [local] error gotohandles it;err,erl,erfn,ermndescribe it. Copyerrfirst.- An error that leaves a procedure for an outer handler skips that procedure's destructors and
defers. tiko has noresume. -e,-exand-exxadd runtime checks;__FB_ERR__says which were used.#assertchecks constants at compile time (TKO0604);assertchecks at run time under-g.
Exercises
- Write
ToInt( s )returning anOptional( of int32 )that accepts digits with an optional leading minus and refuses anything else. Use it to total the valid entries of a list and count the refused ones. {solution="ex18-01"} - Write
DivideandRoot, each returning aResult( of float64, ustring ), and aRootOfRatio( a, b )that stops at the first failure and passes it on. Run it over a success and one failure of each kind. {solution="ex18-02"} - Give
ConfigErroraCFG_DUPLICATEcode, and makeParseConfigrefuse a key that appears twice. Which function has to know about the other lines, and why? - Move the handler in
escape.tikointoTransfer. What changes in the output, and why? - Compile
runtimeerrors.tikowith-einstead of-exx. Which part of the handler's output changes?
Testing, Debugging and Tooling
A program that compiles is a program the compiler could read. Whether it does what you meant is a separate question, and the sooner you find out the cheaper the answer is. A failed test costs a minute. A wrong number found in a report a month later costs the month.
This chapter is about the tools that shorten that distance. Tests check behaviour you have decided on, every time you build. Tracing and a debugger show you what a program did when a test, or a user, finds it doing something else. And the compiler's own options — its warnings, its diagnostics file and its ways of showing you the code it generated — are tools too.
Unit tests with fbcunit
tiko ships a unit-test library, fbcunit. A test is a block of code that checks something with an
assertion; tests are grouped into suites; one call runs them all and prints a summary. No module
fronts the library, so it is reached with #include:
'' A stack of int32, and the tests that pin its behaviour down.
#include once "fbcunit.bi"
type Stack
declare sub Push( byval v as int32 )
declare function Pop( ) as int32
declare function Count( ) as int32
private:
dim items( any ) as int32
end type
sub Stack.Push( byval v as int32 )
redim preserve items( ubound( items ) + 1 )
items( ubound( items ) ) = v
end sub
function Stack.Pop( ) as int32
var top = items( ubound( items ) )
if ubound( items ) = 0 then
erase items
else
redim preserve items( ubound( items ) - 1 )
end if
return top
end function
function Stack.Count( ) as int32
return ubound( items ) + 1
end function
SUITE( lifo )
TEST( starts_empty )
dim s as Stack
CU_ASSERT_EQUAL( s.Count( ), 0 )
END_TEST
TEST( pops_in_reverse )
dim s as Stack
s.Push( 1 ) : s.Push( 2 ) : s.Push( 3 )
CU_ASSERT_EQUAL( s.Pop( ), 3 )
CU_ASSERT_EQUAL( s.Pop( ), 2 )
CU_ASSERT_EQUAL( s.Count( ), 1 )
END_TEST
TEST( counts_pushes )
dim s as Stack
s.Push( 7 )
CU_ASSERT_EQUAL( s.Count( ), 2 ) '' wrong on purpose
END_TEST
END_SUITE
end iif( fbcu.run_tests( ), 0, 1 )
firsttests.tiko(48) : error : TESTS.LIFO.COUNTS_PUSHES CU_ASSERT_EQUAL(s.Count( ),2)
SUMMARY
Asserts Passed Failed Suite Tests
-------- -------- -------- -------------------------------------- --------
5 4 1 lifo 3
-------- -------- -------- -------------------------------------- --------
5 4 1 Total 3
SUITE( lifo ) … END_SUITE groups the tests; each TEST( name ) … END_TEST is one test with a
fresh Stack of its own. fbcu.run_tests( ) runs every test, prints each failed assertion with
its file, line and the assertion as written, then the summary, and returns true only if
everything passed. The last line turns that into the program's exit code — 1 here, because the
third test is wrong on purpose — which is what a build script or a CI job checks.
The assertions you will use most:
| Macro | Passes when |
|---|---|
CU_ASSERT( c ) | c is true |
CU_ASSERT_EQUAL( a, b ) | a = b |
CU_ASSERT_NOT_EQUAL( a, b ) | a <> b |
CU_ASSERT_TRUE( c ), CU_ASSERT_FALSE( c ) | c is true, or false |
CU_ASSERT_DOUBLE_EQUAL( a, e, g ) | a is within g of e |
CU_FAIL( msg ) | never: marks code that must not be reached |
A failed assertion does not stop its test: the test carries on, and every failed assertion is
listed before the summary. That is why one TEST can hold several related checks, as
pops_in_reverse does.
A suite is a namespace, so it cannot share a name with something it sits beside.
SUITE( stack ) next to a Stack type is refused, and the message lands on the type's methods —
TKO0669: 'stack.push' adds a new name to a namespace from outside it — rather than on the suite.
Name a suite after the behaviour it checks, not after the type.
Tests in a multi-module project
In a real project the code under test lives in a module, and the tests are a separate program that
imports it. Here is the stack as Chapter 15's kind of module, stacks/stacks.tiko:
#module "stacks"
'' A last-in, first-out stack of int32.
public type Stack
declare sub Push( byval v as int32 )
declare function Pop( ) as int32
declare function Peek( ) as int32
declare function Count( ) as int32
private:
dim items( any ) as int32
end type
sub Stack.Push( byval v as int32 )
redim preserve items( ubound( items ) + 1 )
items( ubound( items ) ) = v
end sub
function Stack.Pop( ) as int32
var top = items( ubound( items ) )
if ubound( items ) = 0 then
erase items
else
redim preserve items( ubound( items ) - 1 )
end if
return top
end function
function Stack.Peek( ) as int32
return items( ubound( items ) )
end function
function Stack.Count( ) as int32
return ubound( items ) + 1
end function
and its tests:
'' The stacks module's tests: a program of their own.
#import "./stacks"
#include once "fbcunit.bi"
SUITE( lifo )
TEST( starts_empty )
dim s as stacks.Stack
CU_ASSERT_EQUAL( s.Count( ), 0 )
END_TEST
TEST( pops_in_reverse )
dim s as stacks.Stack
s.Push( 1 ) : s.Push( 2 ) : s.Push( 3 )
CU_ASSERT_EQUAL( s.Pop( ), 3 )
CU_ASSERT_EQUAL( s.Pop( ), 2 )
CU_ASSERT_EQUAL( s.Count( ), 1 )
END_TEST
END_SUITE
SUITE( peeking )
TEST( leaves_the_top )
dim s as stacks.Stack
s.Push( 7 )
CU_ASSERT_EQUAL( s.Peek( ), 7 )
CU_ASSERT_EQUAL( s.Count( ), 1 )
END_TEST
END_SUITE
end iif( fbcu.run_tests( ), 0, 1 )
SUMMARY
Asserts Passed Failed Suite Tests
-------- -------- -------- -------------------------------------- --------
4 4 0 lifo 2
2 2 0 peeking 1
-------- -------- -------- -------------------------------------- --------
6 6 0 Total 3
The test program sees the module exactly as any other client does: stacks.Stack and its public
members, and nothing private. That is a feature. Tests written against the public surface keep
passing while you rearrange the inside, and they fail when you change what callers depend on —
which is the change you wanted to hear about.
Suites do not run in the same order on every platform. On Windows the same program prints its summary the other way round:
SUMMARY
Asserts Passed Failed Suite Tests
-------- -------- -------- -------------------------------------- --------
2 2 0 peeking 1
4 4 0 lifo 2
-------- -------- -------- -------------------------------------- --------
6 6 0 Total 3
Each SUITE registers itself with a module constructor, and module constructors run in the order
each platform's start-up code walks them — declaration order on Linux, the reverse on Windows,
exactly as fbc does it. Nothing is wrong while every test stands alone. A test that quietly relies
on state an earlier suite left behind — a file it wrote, a global it set — passes on one platform
and fails on the other. Give every test its own setup.
Tracing
Sometimes the quickest way to see what a program did is to have it tell you. Three predefined
names make that useful: __FILE__ is the source file being compiled, __LINE__ the line,
and __FUNCTION__ the procedure — in capitals, and __FB_MAINPROC__ at module level. Wrapped
in a macro, they report where the macro was used, not where it was defined:
'' trace.ti -- TRACE( msg ) reports where it was called from, on
'' stderr. Without TRACING defined it compiles to nothing.
#pragma once
#ifdef TRACING
#define TRACE( msg ) _
TraceAt( __FILE__, __LINE__, __FUNCTION__, msg )
#else
#define TRACE( msg )
#endif
sub TraceAt( byval file as ustring, byval lineNo as int32, _
byval proc as ustring, byval msg as ustring )
var f = freefile( )
open err for output as #f
print #f, file & "(" & lineNo & ") " & proc & ": " & msg
close #f
end sub
#define TRACING
#include once "trace.ti"
function Mean( values( ) as float64 ) as float64
var n = ubound( values ) - lbound( values ) + 1
TRACE( "count " & n )
dim total as float64
for i as int32 = lbound( values ) to ubound( values )
total += values( i )
next
TRACE( "total " & total )
return total / n
end function
dim readings( 0 to 2 ) as float64 = { 3.5, 4.0, 5.5 }
var m = Mean( readings( ) )
TRACE( "mean " & m )
tracing.tiko(6) MEAN: count 3
tracing.tiko(11) MEAN: total 13
tracing.tiko(17) __FB_MAINPROC__: mean 4.333333333333333
Two decisions in trace.ti are worth copying:
- Trace output goes to stderr.
open erropens the standard error stream as a file number. The program's real output stays on stdout, soprogram > result.txtstill captures exactly the result, and the trace stays on the screen or goes to a file of its own with2> trace.txt. - Tracing is switched off by the preprocessor, not by an
if. Remove the#define TRACINGline andTRACE( … )expands to nothing: the calls are gone from the generated code, and so is the cost of building their messages. TheTraceAtprocedure itself is still compiled; it is never called.
Do not mix trace lines and ordinary output in one stream and expect their order to hold. stdout
and stderr are separate streams with separate buffers; when both go to the same place, how the
lines interleave depends on the platform and on what is reading them. That is why everything
tracing.tiko prints goes through TRACE.
Debugging with -g
Tracing answers questions you thought to ask before you ran the program. A debugger answers the
ones you think of while it is stopped. Compile with -g and tiko writes DWARF debug
information into the executable: line tables, procedures, and every variable with its type. Any
DWARF debugger can then stop the program and show you its state. The one that comes with the LLVM
install tiko already uses is lldb.
'' A small program to stop inside: build it with -g.
type Reading
sensor as ustring
value as float64
end type
function Total( rs( ) as Reading ) as float64
dim sum as float64
for i as int32 = lbound( rs ) to ubound( rs )
sum += rs( i ).value
next
return sum
end function
dim rs( 1 to 3 ) as Reading
rs( 1 ).sensor = "north" : rs( 1 ).value = 3.5
rs( 2 ).sensor = "south" : rs( 2 ).value = 4.0
rs( 3 ).sensor = "east" : rs( 3 ).value = 5.5
dim label as ustring = "total"
print label; Total( rs( ) )
total 13
Build it with tiko -g readings.tiko, then run it under lldb, stopping at line 10:
(lldb) breakpoint set -f readings.tiko -l 10
(lldb) run
* thread #1, stop reason = breakpoint 1.1
frame #0: readings.exe`Total(rs=0x0000005c4975f7e0) at readings.tiko:10
9 for i as int32 = lbound( rs ) to ubound( rs )
-> 10 sum += rs( i ).value
11 next
(lldb) frame variable sum i
(double) sum = 0
(long) i = 1
(lldb) up
frame #1: readings.exe`main at readings.tiko:20
-> 20 print label; Total( rs( ) )
(lldb) frame variable label
(ustring) label = (data = u"total", len = 5, size = 16)
(lldb) frame variable rs
(Reading(1 to 3)) rs = {
[0] = {
sensor = (data = u"north", len = 5, size = 18)
value = 3.5
}
[1] = {
sensor = (data = u"south", len = 5, size = 18)
value = 4
}
[2] = {
sensor = (data = u"east", len = 4, size = 18)
value = 5.5
}
}
The breakpoint is set by file and line, the stop shows the source around it, and up moves to the
caller. Variables keep their types: a record shows its fields, a ustring its text and length, and
the array its declared bounds, Reading(1 to 3). lldb spells some types the way C would — an
int32 is a long and a float64 a double — because that is the vocabulary it has.
Two kinds of value read less well. An array parameter, such as rs inside Total, is passed
as a descriptor, and lldb shows the descriptor's fields — the data pointer, the element size, and
eight dimension slots of which only the first dimensions mean anything. And a ustring shows its
bookkeeping beside its text.
lldb's [ ] counts from zero, whatever bounds the array was declared with.
(lldb) p rs[1]
(Reading) {
sensor = (data = u"south", len = 5, size = 18)
value = 4
}
rs is declared 1 to 3, so rs( 1 ) in the program is "north" — but lldb's rs[1] is the
second element, "south". lldb reads the expression as C, and C arrays start at zero. The listing
labels the elements [0] to [2] for the same reason. Subtract the lower bound before you index,
or read the element out of the full listing.
On windows-x86_64 the DWARF is placed inside the .exe; tiko asks the linker for that itself under
-g (-debug:dwarf). No .pdb is written and no CodeView is emitted, so Visual Studio and
WinDbg cannot read a tiko program's variables. lldb, and lldb-dap for editors, are in the same
LLVM bin directory as clang.
gdb, which Ubuntu installs by default, reads the same DWARF. break readings.tiko:10, run and
info locals stop in Total and show sum and i; print rs in the caller shows the three
records with their strings. Unlike lldb, gdb honours the declared lower bound: its print rs[1] is
the element the program calls rs( 1 ), "north".
The same session needs no commands in tkEdit (Chapter 2). Put the cursor on line 10, press F9
for a breakpoint and F6 to build with -g and run. The program stops in Total, and the
Locals tab of the output pane lists rs, sum and i. There rs is (1 to 3) and opens to rs(1), rs(2) and
rs(3), each element named by the subscript the program uses: tkEdit reads the array descriptor,
so the zero-based pitfall above does not apply. A string shows its text, u"north", without the
bookkeeping, and a watch takes the program's spelling, rs(2).sensor.
Warnings and the diagnostics workflow
An error stops the build. A warning does not: the program is built, and it runs. That is exactly what makes warnings worth your attention.
A warning is often a wrong answer the compiler noticed first.
'' It compiles. It prints the wrong numbers. The compiler said so.
sub Fill( byval p as int32 ptr, byval n as int32 )
for i as int32 = 0 to n - 1
p[i] = i * i
next
end sub
dim squares( 0 to 3 ) as int64
Fill( @squares( 0 ), 4 )
print squares( 0 ); squares( 1 ); squares( 2 ); squares( 3 )
ptrwarn.tiko(9,7): warning TKO0833: passing different pointer types: a 'int64 ptr' for a 'int32 ptr' parameter
|
9 | Fill( @squares( 0 ), 4 )
| ^
4294967296 38654705668 0 0
Fill writes four int32s, sixteen bytes. squares is four int64s. The first two squares land
in squares( 0 ) and squares( 1 ) as halves of 64-bit numbers, and the last two elements are
never touched. The build succeeded and the exit code is 0. Only the warning said anything.
Three options decide what warnings do to a build:
| Option | Effect |
|---|---|
-Werror | every warning becomes an error, and the build fails |
-Wno-TKOnnnn | that one diagnostic is not reported |
--diagnostics-format gnu | the location as file:line:col:, the form many tools parse |
With -Werror the same program stops at error TKO0833 and the exit code is 1. Turn it on in any
build where you would rather fix a warning than discover it. Use -Wno- for a single, understood
warning, by id, and never wider: every diagnostic has a permanent number (Appendix B), so the
option says exactly what it silences.
The diagnostics file
The console text is for a person. A tool that needs the diagnostics — an editor, a build script, a
CI job — asks for --diagnostics-file path, and tiko writes every diagnostic of the run there
as well, one tab-separated record per line:
tiko-diagnostics 1
warning TKO0833 C:\work\ptrwarn.tiko 9 7 9 8 passing different pointer types: a 'int64 ptr' for a 'int32 ptr' parameter
end 0 1 0
The first line names the format and its version. Each record is the kind — after -Werror and
-Wno- have been applied — the id, the absolute path, the start and end of the range (line and
column, the end exclusive), and the message. The end record carries the error count, the warning
count and the exit code; a file with no end record means the compiler did not finish. The
console output is unchanged. This is what tkEdit reads after every build, to list the errors
and underline them in the source.
Looking inside a build
When you need to know what the compiler made of your code, ask it. One listing to look at:
'' Three fields, six bytes of data, twelve bytes of record.
type Packet
kind as uint8
length as uint32
flags as uint8
end type
function Square( byval x as int32 ) as int32
return x * x
end function
print sizeof( Packet ); Square( 9 )
12 81
--dump-udt prints each record's size, alignment and field offsets, and stops:
packet size 12 align 4
packet off kind 0
packet off length 4
packet off flags 8
Six bytes of fields take twelve bytes of record. length is aligned to 4, so three bytes of
padding follow kind, and the record's size is rounded up to its alignment after flags. Chapter
9's advice — order fields from largest to smallest — is the fix, and --dump-udt is how you check
it worked.
--emit-ir writes the LLVM IR tiko hands to clang, and stops. Without -o it goes to
stdout:
define i32 @"SQUARE"(i32 %p0) nounwind uwtable(sync) {
L0:
%s0 = alloca i32, align 4
store i32 0, ptr %s0, align 4
%s1 = alloca i32, align 4
store i32 %p0, ptr %s1, align 4
%t4 = load i32, ptr %s1, align 4
%t5 = load i32, ptr %s1, align 4
%t6 = sext i32 %t4 to i64
%t7 = sext i32 %t5 to i64
%t8 = mul i64 %t6, %t7
%t9 = trunc i64 %t8 to i32
ret i32 %t9
}
--emit-asm writes the machine code as assembly, to layout.s unless -o names another
file, and stops. With -O2 the IR above becomes three instructions:
SQUARE:
movl %ecx, %eax
imull %ecx, %eax
retq
The IR is written plainly — every parameter goes to memory and comes back, the multiplication is done at 64 bits and narrowed — and the optimiser removes all of it. Read the IR to see what your code means; read optimised assembly to see what it costs.
| Option | Shows |
|---|---|
--dump-udt | records' sizes, alignment and field offsets |
--dump-vtable | each type's virtual method table (Chapter 14) |
--dump-mangle | the symbol name each procedure is emitted under |
--emit-ir | the LLVM IR, then stops |
--emit-asm | the assembly, then stops |
--keep-temps | builds normally and keeps the .ll and .o files beside the output |
-v | the clang and linker command lines tiko runs |
The full list, with the front end's own --dump-* options, is Appendix C.
What it costs
- Tests are code you maintain. They pay for themselves when they check behaviour callers depend on, and cost you when they pin down details nobody relies on. Test through the public surface.
- Tracing costs nothing when it is compiled out, and a call, a string build and a write per
TRACEwhen it is in. -gmakes the executable larger: the debug information is stored beside the code. Keep it in every development build. Optimisation works against it — variables live in registers and lines are reordered — so debug an unoptimised build.-Werrorcosts a little friction, paid when the warning appears rather than when the bug it predicted does.
Summary
- fbcunit:
#include once "fbcunit.bi",SUITE/TESTblocks,CU_ASSERT*macros, andfbcu.run_tests( ), whose result becomes the exit code. A suite is a namespace; name it after a behaviour. - Test a module from a separate program that imports it, through its public surface.
- Suites run in a platform-dependent order; no test may depend on another.
__FILE__,__LINE__and__FUNCTION__inside a macro report the place of use. Trace to stderr withopen err, and switch tracing off with the preprocessor.-gwrites DWARF with variables and types. lldb shows locals, records, strings and fixed arrays; its[ ]counts from zero. On Windows the DWARF is inside the.exe; there is no.pdb.- tkEdit's debugger (
F9,F6) reads the same DWARF and names elements by the program's own subscripts. - A warning can be a wrong answer:
-Werrorfails the build on one,-Wno-TKOnnnnsilences one by id,--diagnostics-filegives tools the records. --dump-udt,--emit-ir,--emit-asm,--keep-tempsand-vshow what the compiler made of your code.
Exercises
- Write
Clamp( v, lo, hi )and an fbcunit suite for it with three tests: a value inside the range, values on both edges, and values outside on both sides. {solution="ex19-01"} - Add
TRACEVAL( x )totrace.ti's family: it prints the expression as written,=, and its value. (#xturns a macro argument into a string.) {solution="ex19-02"} - Make
stacktests.tikofail on one platform only, by letting thepeekingsuite rely on somethinglifodoes. Then remove the dependency. - Reorder
Packet's fields so that--dump-udtreports a size of 8. Why not 6? - Run
readings.tikounder lldb and print the elementrs( 3 )the program means. Which expression did you need?
Tuples, Iterators and For Each
Two small problems turn up in almost every program. A function has two answers to give — a minimum and a maximum, a quotient and a remainder — and a procedure returns one value. And a loop over a collection needs an index it does not care about, with a lower bound and an upper bound to get wrong.
This chapter solves both. A tuple is a value made of other values, with no type to declare
first: a function can return (lo, hi) and the caller can take it apart in one line. for each walks arrays, strings and collections without an index, and the iterator protocol lets
your own types be walked the same way — including sequences that are computed as they are walked
and never stored.
Returning more than one value
'' One function, two answers, no out-parameters.
function MinMax( values( ) as int32 ) as (lo as int32, hi as int32)
var lo = values( lbound( values ) ), hi = lo
for each v in values
if v < lo then lo = v
if v > hi then hi = v
next
return (lo, hi)
end function
dim temps( 0 to 4 ) as int32 = { 12, -3, 25, 7, 0 }
var (lo, hi) = MinMax( temps( ) ) '' two new variables
print "range"; lo; " to"; hi
var r = MinMax( temps( ) ) '' one tuple
print "by name:"; r.lo; r.hi
print "by position:"; r._1; r._2
range-3 to 25
by name:-3 25
by position:-3 25
MinMax returns (lo as int32, hi as int32), a tuple type: a parenthesised list of two or
more types, written wherever a type can go. return (lo, hi) builds a value of it. The caller has
two ways to receive it:
- Destructure it.
var (lo, hi) = MinMax( … )declares two variables and gives each one element, in order. - Keep it whole.
var r = MinMax( … )holds the tuple, and its elements are reached by the names the function's declaration gave them,r.loandr.hi, or by position,r._1andr._2. The positional names_1…_Nalways work, named or not.
A tuple has at least two elements. (x) is an expression in parentheses, as it always was, and
there is no one-element tuple to write.
Tuple types
A tuple type can be written anywhere a type can: a variable, a parameter, a field, an array
element, a function result. Elements nest, ((int32, int32), ustring), and a typedef gives a
tuple type a name when it is used often: type Point as (x as int32, y as int32).
Underneath, a tuple is an ordinary record with the same size, alignment and field offsets as the
equivalent type. A ustring element is constructed, copied and destroyed exactly as a
ustring field is.
Identity is structural
Two tuple types are the same type when they have the same element types in the same order. What the elements are called, and whether the type was spelled through a typedef, does not enter:
'' Two tuple types are the same when their element types match,
'' in order. Names and spellings do not enter.
type Point as (x as int32, y as int32)
dim p as Point = (3, 4)
dim span as (lo as int32, hi as int32) = p '' the same type
dim plain as (int32, int32) = span
print span.lo; span.hi; plain._1; plain._2
print p = plain; p <> plain '' element by element
plain._2 = 5
print p = plain; p <> plain
print sizeof( Point ); sizeof( (int32, int32, int64) )
3 4 3 4
-1 0
0-1
8 16
Point, (lo as int32, hi as int32) and (int32, int32) are one type, so each assigns to the
others without a conversion. Each variable is read with the names of its own declaration: span
has lo and hi, plain only _1 and _2. = and <> compare element by element and give
−1 or 0, as any comparison does. sizeof takes a tuple type directly or through its typedef.
Element names are documentation for the reader, not a contract the compiler checks.
function ScreenSize( ) as (w as int32, h as int32)
return (1920, 1080)
end function
'' Names are not part of the type: this copies by position.
dim s as (h as int32, w as int32) = ScreenSize( )
print "width"; s.w; " height"; s.h
'' Destructuring is by position too.
var (h, w) = ScreenSize( )
print "width"; w; " height"; h
width 1080 height 1920
width 1080 height 1920
ScreenSize returns (w, h); s is declared (h, w). Both are (int32, int32), so the
assignment copies by position — the width into the element s calls h — and both lines print
the dimensions the wrong way round. Destructuring is positional in the same way: the names in
var (h, w) are new variables, not a request for the elements called h and w.
A tuple groups values by position at a call boundary. When the meaning of each element matters
— a width that must never be taken for a height — declare a type with named fields: two
different types never convert into each other.
Tuple values
A tuple literal is built at the type it is assigned to. Each element is converted as it is
stored, in the four places where the target type is known: a declaration, an assignment, a
return and an argument.
'' A tuple literal is built at the type it is assigned to.
dim reading as (float64, ustring) = (21, "north")
print reading._1; " "; reading._2
function Origin( ) as (x as float64, y as float64)
return (0, 0) '' each 0 becomes a float64
end function
var o = Origin( )
print o.x; o.y
'' Assigning to a list of places: the right side is read first.
dim a as int32 = 1, b as int32 = 2
(a, b) = (b, a)
print a; b
dim slots( 0 to 2 ) as int32
type Cell
row as int32
col as int32
end type
dim c as Cell
(c.row, c.col, slots( 2 )) = (4, 7, 9)
print c.row; c.col; slots( 2 )
'' Tuples nest.
dim span as ((int32, int32), ustring) = ((9, 17), "office hours")
print span._2; ":"; span._1._1; " to"; span._1._2
21 north
0 0
2 1
4 7 9
office hours: 9 to 17
(21, "north") becomes a float64 and a ustring; return (0, 0) builds two float64s. The
assigning form (a, b) = … takes any list of places — variables, fields, array elements — and
reads the right-hand side completely before writing anything, which is why (a, b) = (b, a)
swaps rather than copying one value into both. The number of places must match the number of
elements exactly: var (x, y, z) = (1, 2) is TKO0825: the tuple has 2 elements, and 3 are written.
What tuples do not do
A literal adapts to its target, but two tuple values of different types never convert:
dim whole as (int32, int32) = (1, 2)
dim exact as (float64, float64) = whole '' refused
tuplemismatch.tiko(2,35): error TKO0738: no conversion between these two types
The rest of the limits, each one measured:
| Written | Result |
|---|---|
a = b with a an (int32, int32) and b a (float64, float64) | TKO0720: no operator = compares this type |
a = (1, 2) with a an (int32, int32) | TKO0720 too: the literal's numbers are integers, and a comparison has no target type to build it at |
a < b, and <=, >, >= | TKO0720: tuples have equality, not order |
print a | TKO0871: a '(int32, int32)' has no printable form |
The missing ordering is deliberate. "Compare the first elements, then the second" is only
sometimes what a program means, and a sort that silently used it the rest of the time would be
wrong without a word. Write the comparison you mean. To compare against fixed values, declare
them at the tuple's type first: dim target as (int32, int32) = (1, 2).
The shapes of for each
for each walks a collection without an index. The loop declares its own variable, which exists
only inside the loop body:
#import "std/containers"
'' An array, whatever its bounds. The variable is a copy.
dim scores( 1 to 4 ) as int32 = { 70, 85, 90, 62 }
for each s in scores
print s;
next
print
'' byref binds to the element itself.
for each byref s in scores
s += 5
next
'' 'as' gives the element a type; the usual conversions apply.
for each s as float64 in scores
print s / 10;
next
print
'' exit for and continue for work as in any for loop.
for each s in scores
if s < 80 then continue for
if s > 94 then exit for
print "pass"; s
next
'' Library collections are walked the same way.
dim queue as containers.LinkedList( of ustring ) = _
{ "build", "test", "ship" }
for each task in queue
print task; " ";
next
print
70 85 90 62
7.5 9 9.5 6.7
pass 90
build test ship
| Form | The loop variable is |
|---|---|
for each x in c | a copy of each element, its type inferred |
for each x as E in c | a copy converted to E, as dim x as E = … would |
for each byref x in c | the element itself; writing to x writes to the collection |
for each byref x as E in c | the same, with the type stated |
The loop ends with a plain next. Naming the variable after it is
TKO0819: 'next' cannot name a FOR EACH variable, because the variable is not a counter and does
not exist after the loop. exit for and continue for work as they do in any for loop.
An array is walked in index order from its lower bound, whatever that bound is. A
multi-dimensional array is refused with TKO0608: there is no order that would be right for
every program, so you choose one with ordinary for loops.
The collection expression is evaluated once, before the first pass. Over an array, for each
compiles to the same counted loop you would have written by hand.
Strings
A ustring cannot be walked:
dim word as ustring = "tiko"
for each ch in word
print ch
next
ustringwalk.tiko(2,1): error TKO0688: 'for each' cannot walk a ustring; index it with 'for' and 'mid' instead
A string can — and that is the trap.
for each over a string yields bytes, not characters.
'' A string is walked one byte at a time.
dim word as string = "caf" & chr( &hC3, &hA9 ) '' e-acute in UTF-8
var n = 0
for each b in word
print b;
n += 1
next
print
print n; " steps for"; len( word ); " bytes"
9997102195169
5 steps for 5 bytes
The word has four letters and the loop runs five times: é is two bytes in UTF-8, 195 and 169.
Each b is a ubyte, so the loop is right for parsing bytes and wrong for anything a reader
would call a letter, and no diagnostic says so. For text, keep the ustring and index it with
for and mid, as the TKO0688 message says.
Binding a string's bytes by reference is refused: for each byref b in word is TKO0941: 'for each byref' cannot walk a string: a character has no stable address.
Taking pairs apart
When a collection's elements are tuples, for each can destructure each one into names:
#import "std/containers"
dim ages as containers.OrderedMap( of ustring, int32 )
ages.Put( "grace", 85 )
ages.Put( "ada", 36 )
ages.Put( "edsger", 72 )
'' Pairs( ) yields tuples, which for each takes apart.
for each (who, age) in ages.Pairs( )
print who; " is"; age
next
'' The map itself yields named key/value pairs.
for each kv in ages
print kv.key; "="; kv.value; " ";
next
print
ada is 36
edsger is 72
grace is 85
ada= 36 edsger= 72 grace= 85
Pairs( ) yields (key, value) tuples, and for each (who, age) declares one variable per
element. Iterating the map itself yields a pair record with named fields, key and value. An
OrderedMap walks its keys in sorted order; a plain containers.Map walks them in an order you
must not depend on.
The name list has its own rules:
| Written | Diagnostic |
|---|---|
for each (k, v) as int32 in … | TKO0410: each name is typed by its own element, so there is no as |
for each byref (k, v) in … | TKO0411: bind the whole element byref instead and reach through _1, _2 |
for each (k) in … | TKO0412: a name list needs at least two names |
The names are copies. Destructuring costs one copy of each element.
The iterator protocol
for each walks your own types too, and there is nothing to inherit to make that happen. A type
is iterable when it has a GetIterator( ) function, and what that returns is an iterator
when it has three members:
| Member | Job |
|---|---|
function GetIterator( ) as I | on the collection: hand out a new iterator I |
function IsValid( ) as boolean | on the iterator: is there a current element? |
function Value( ) as E | the current element; E is the loop variable's type |
sub MoveNext( ) | advance to the next element |
'' The smallest iterable: a collection and its iterator.
type CountdownIterator
n as int32
declare function IsValid( ) as boolean
declare function Value( ) as int32
declare sub MoveNext( )
end type
function CountdownIterator.IsValid( ) as boolean
return n > 0 '' still on an element?
end function
function CountdownIterator.Value( ) as int32
return n '' the current element
end function
sub CountdownIterator.MoveNext( )
n -= 1 '' advance
end sub
type Countdown
from as int32
declare function GetIterator( ) as CountdownIterator
end type
function Countdown.GetIterator( ) as CountdownIterator
print "(GetIterator)"
return type( from ) '' already on the first element
end function
dim launch as Countdown = type( 5 )
for each t in launch
print t;
next
print " lift-off"
(GetIterator)
5 4 3 2 1 lift-off
The loop calls GetIterator( ) once, then, for as long as IsValid( ) is true, binds Value( )
to the loop variable, runs the body and calls MoveNext( ). Three rules make an iterator behave:
- A new iterator is already on the first element, or
IsValid( )is false at once for an empty collection. There is no "before the first element" state and no initialMoveNext( ). IsValid( )is a question, not an action. Asking twice without aMoveNext( )between gives the same answer, andValue( )may be read more than once for the same element.Value( )decides whatbyrefcan do. Declaredbyref as E, it hands out the element itself and afor each byrefloop can write to it. Declared by value, it hands out a copy.
The protocol is matched by name, at compile time. There is no interface and no virtual call: the
loop calls the three members directly, exactly as if you had written the calls yourself. An
iterator is an ordinary value returned by value — usually a pointer and a count — and if it holds
a resource, its destructor runs when the loop ends, by exit for or otherwise.
Near misses
Matching by name has one cost: a misspelling does not produce a "you meant" message from a type system — it produces a type that does not match. tiko says which member is missing:
type Ticker
n as int32
declare function GetIterator( ) as Ticker
declare function IsValid( ) as boolean
declare function Value( ) as int32
declare sub MoveNxt( )
end type
function Ticker.GetIterator( ) as Ticker
return this
end function
function Ticker.IsValid( ) as boolean
return n > 0
end function
function Ticker.Value( ) as int32
return n
end function
sub Ticker.MoveNxt( )
n -= 1
end sub
dim t as Ticker = type( 3 )
for each x in t
print x
next
nearmiss.tiko(23,1): error TKO0607: 'ticker' is not an iterator: it has no 'MoveNext( )'
A collection with no GetIterator( ) at all gets TKO0606: 'bag' cannot be walked by 'for each': it has no GetIterator( ). Both point at the loop, and both name the member to add.
Ticker is also its own iterator: its GetIterator( ) returns a copy of itself. That is a
reasonable shape for a one-shot sequence. It is a poor one for a collection, because the
"collection" and its cursor are then the same object.
Writing an iterable generic collection
With generics, the iterator is written once for every element type. A ring buffer keeps the newest N values and forgets the oldest:
'' A ring buffer keeps the newest N values; for each walks them
'' oldest first. Value( ) is byref, so byref loops can write.
type RingIterator( of T )
buf as T ptr
cap as int32
at as int32
left as int32
declare function IsValid( ) as boolean
declare function Value( ) byref as T
declare sub MoveNext( )
end type
function RingIterator( of T ).IsValid( ) as boolean
return left > 0
end function
function RingIterator( of T ).Value( ) byref as T
return buf[at]
end function
sub RingIterator( of T ).MoveNext( )
at = ( at + 1 ) mod cap
left -= 1
end sub
type Ring( of T )
declare constructor( byval capacity as int32 )
declare sub Add( byval v as T )
declare function GetIterator( ) as RingIterator( of T )
private:
dim items( any ) as T
dim head as int32 '' the next slot to write
dim count as int32
end type
constructor Ring( of T )( byval capacity as int32 )
redim items( 0 to capacity - 1 )
end constructor
sub Ring( of T ).Add( byval v as T )
var cap = ubound( items ) + 1
items( head ) = v
head = ( head + 1 ) mod cap
if count < cap then count += 1
end sub
function Ring( of T ).GetIterator( ) as RingIterator( of T )
dim it as RingIterator( of T )
it.cap = ubound( items ) + 1
it.buf = @items( 0 )
it.at = ( head - count + it.cap ) mod it.cap
it.left = count
return it
end function
dim words( 0 to 4 ) as ustring = _
{ "alpha", "beta", "gamma", "delta", "epsilon" }
var recent = Ring( of ustring )( 3 )
for each w in words
recent.Add( w )
next
for each w in recent
print w; " ";
next
print
var nums = Ring( of int32 )( 4 )
nums.Add( 1 ) : nums.Add( 2 )
for each byref n in nums
n *= 100
next
for each n in nums
print n;
next
print
var none = Ring( of int32 )( 2 )
for each n in none
print "never printed"
next
print "an empty ring walks zero times"
gamma delta epsilon
100 200
an empty ring walks zero times
Ring( of T )'s GetIterator( ) returns a RingIterator( of T ) — an instance of a different
generic, which is the usual shape and the one the standard containers use. The iterator holds a
pointer to the storage, where to start, and how many elements are left; it knows nothing about
how they got there. Because Value( ) is byref as T, the second loop multiplies the stored
numbers in place. And the empty ring needs no special case: its iterator starts with nothing
left, so the loop body never runs.
Laziness
An iterator does not have to walk anything stored. Value( ) can compute the element, and
MoveNext( ) can compute the next one:
'' A sequence that is computed as it is walked, never stored.
type FibIterator
a as int64
b as int64
limit as int64
declare function IsValid( ) as boolean
declare function Value( ) as int64
declare sub MoveNext( )
end type
function FibIterator.IsValid( ) as boolean
return a <= limit
end function
function FibIterator.Value( ) as int64
return a
end function
sub FibIterator.MoveNext( )
var nxt = a + b
a = b
b = nxt
end sub
type Fibonacci
upTo as int64
declare function GetIterator( ) as FibIterator
end type
function Fibonacci.GetIterator( ) as FibIterator
return type( 0, 1, upTo )
end function
dim small as Fibonacci = type( 100 )
for each f in small
print f;
next
print
'' Effectively endless: only the values the loop asks for exist.
dim all as Fibonacci = type( 9223372036854775807 )
var steps = 0
for each f in all
steps += 1
if f > 1000000 then
print "first above a million:"; f; ", term"; steps
exit for
end if
next
0 1 1 2 3 5 8 13 21 34 55 89
first above a million: 1346269, term 32
Fibonacci holds one number, its upper limit. Its iterator holds the two most recent terms, and
each MoveNext( ) computes the next. Nothing is stored, so a sequence bounded only by the largest
int64 costs the same to set up as one that stops at 100 — the second loop asks for 32 terms,
exit for ends it, and the rest are never computed. This is a lazy sequence: the work is done
when an element is asked for, and only for the elements that are.
What invalidates a loop
A loop and the collection it walks share the collection's memory, and changing the collection's shape during the loop breaks that sharing.
'' The loop reads an array's bounds once, before the first pass.
dim queue( any ) as int32
redim queue( 0 to 2 )
queue( 0 ) = 1 : queue( 1 ) = 2 : queue( 2 ) = 3
for each q in queue
print q;
if q = 2 then
redim preserve queue( 0 to 3 ) '' a fourth element...
queue( 3 ) = 4
end if
next
print
print "elements now:"; ubound( queue ) + 1 '' ...never visited
1 2 3
elements now: 4
Over an array, for each reads the bounds once, before the first pass, exactly as the counted
loop it compiles to would. Growing the array inside the loop does not extend the walk: the fourth
element exists afterwards and is never visited. The same rule cuts the other way: shrink the
array inside its loop and the loop still runs to the bound it read, past the elements that are
left. And resizing moves the elements, so a byref loop variable bound before the resize refers
to where an element used to be.
The rule for collections is the same and stricter. Changing a collection's structure — adding or
removing elements — while an iterator walks it invalidates the iterator; the iterator may hold a
pointer into storage that has since moved. Changing an element's value through a byref
binding is fine; that is what byref is for. To add or remove while walking, collect the changes
in a second collection and apply them after the loop.
What it costs
- A tuple is a record. Returning one costs what returning the equivalent
typecosts, and a tuple of twoint32s is eight bytes. Destructuring copies each element once. for eachover an array is the counted loop, with the same code at-O2.- An iterator costs three direct calls per element, which the optimiser can inline: there is no virtual dispatch, because the protocol is matched at compile time.
- A by-value
Value( )copies each element. For austringor a large record, that is an allocation or a copy per pass; abyrefValue( )avoids it. - A lazy sequence stores nothing, and recomputes everything each time it is walked.
Summary
- A tuple type is
(T1, T2, …), at least two elements, written wherever a type can go. A literal(a, b)is built at its target's type. - Elements are reached as
_1…_N, or by the names in the declaration the value came from.var (a, b) = …declares;(x, y) = …assigns, reading the right-hand side first. - Identity is structural: same element types, same order, same type. Names are not checked, so a tuple is the wrong tool where meaning must be enforced.
- Different tuple types never convert. Tuples have
=and<>, no ordering, and noprint. for each x in c, withas Eandbyref;nextalone;exit forandcontinue forwork. Arrays in index order; a multi-dimensional array is refused.- A
stringis walked as bytes; austringcannot be walked. for each (k, v)destructures tuple elements, with noasand nobyref.- The iterator protocol:
GetIterator( ), thenIsValid( ),Value( ),MoveNext( ), matched by name. A new iterator is on the first element; abyrefValue( )permitsbyrefloops.TKO0606andTKO0607name the missing member. - An iterator can compute its elements: a lazy sequence.
- Bounds are read once; changing a collection's structure invalidates its iterators.
Exercises
- Write
DivMod( a, b )returning the quotient and remainder as one tuple, and use it both destructured and whole. {solution="ex20-01"} - Write a
Rangetype holdingfrom,upToandstepBy, walkable byfor eachwith a positive or negative step, and empty when the step points away from the limit. {solution="ex20-02"} - Give
Ring( of T )aCount( )member and test the iterator on a ring that has wrapped round more than once. Which field of the iterator does the wrapping? - Change
fibseq.tiko'sValue( )tobyref as int64and write to the loop variable in abyrefloop. What changes, and why is it a poor idea for a computed sequence? - Rewrite the
swapnames.tikoexample with atype Sizeholdingwandh. Which line now refuses to compile, and what does it say?
Lambdas and Closures
A sort needs to know which of two elements comes first. A callback needs to know what to do when it is called. Both are a few lines of code that only make sense at one place in the program, and until now each of them had to be a named procedure written somewhere else, with any state it needed passed in by hand or left in a global.
A lambda is a procedure written where it is used, as an expression. One that uses only its parameters is an ordinary procedure with an address. One that uses variables from around it — captures them — becomes a closure: a small object that carries those variables, or references to them, along with the code. This chapter covers both, the rule that every capture states how it is held, and where the two kinds can and cannot go.
Lambdas in expressions
'' A lambda is a procedure written where it is used.
type Transform as function( byval x as int32 ) as int32
sub Apply( values( ) as int32, byval f as Transform )
for i as int32 = lbound( values ) to ubound( values )
values( i ) = f( values( i ) )
next
end sub
'' Stored in a procedure-pointer variable...
dim twice as Transform = function( byval x as int32 ) as int32
return x * 2
end function
print twice( 21 )
'' ...or in a var, whose type it gives; the body can be one line.
var square = function( byval x as int32 ) as int32 : return x * x : _
end function
print square( 7 )
'' ...or passed straight to the procedure that wants it.
dim readings( 0 to 2 ) as int32 = { 1, 2, 3 }
Apply( readings( ), function( byval x as int32 ) as int32
return x + 10
end function )
print readings( 0 ); readings( 1 ); readings( 2 )
var greet = sub( byval who as ustring )
print "hello, "; who
end sub
greet( "tiko" )
42
49
11 12 13
hello, tiko
A lambda is spelled like a procedure without a name: function( parameters ) as Type … end function, or sub( parameters ) … end sub when there is nothing to return. It can appear wherever
an expression can:
- assigned to a procedure-pointer variable whose type it matches, like
twice; - given to
var, which takes its type from it, likesquareandgreet; - passed as an argument, like the one
Applyreceives.
The body is ordinary statements. Because : separates statements, a short body fits on one line:
function( byval x as int32 ) as int32 : return x * x : end function. A lambda is called like any
procedure: square( 7 ).
A lambda that captures nothing is a procedure pointer
A lambda that uses only its own parameters and locals is compiled to an ordinary procedure, and the
expression's value is that procedure's address. It is interchangeable with @SomeProcedure and
converts to any procedure-pointer type with the same signature — which is exactly what a C library
callback wants:
'' The C library's qsort takes a comparer by address. A lambda that
'' captures nothing is exactly that: a procedure and its address.
#include once "crt/stdlib.bi"
dim scores( 0 to 5 ) as int32 = { 42, 7, 19, 88, 3, 56 }
qsort( @scores( 0 ), 6, sizeof( int32 ), _
function cdecl( byval a as const any ptr, _
byval b as const any ptr ) as long
return *cptr( int32 ptr, a ) - *cptr( int32 ptr, b )
end function )
for each s in scores
print s;
next
print
3 7 19 42 56 88
qsort takes the address of a comparison function that returns a negative number, zero or a
positive number. The lambda is that function, written at the one place it is needed. A calling
convention goes after the keyword, as on a declared procedure: function cdecl( … ). On both of
tiko's targets the C convention is the only one in use, so cdecl here states what the C
library expects rather than changing the code; write it anyway, because it is part of the contract
the callback signs.
Capturing variables
A lambda that needs a variable from the code around it lists it in square brackets after the keyword, and says how it holds it:
#import "std/containers"
dim prices as containers.Array( of float64 ) = { 4.5, 12.0, 7.25 }
'' byref: the lambda works on the caller's own variable.
dim total as float64
containers.ForEach( prices, sub[ byref total ]( byref p as float64 )
total += p
end sub )
print "total"; total
'' byval: the lambda keeps its own copy. Mixed modes are fine.
dim as float64 rate = 0.25
dim as int32 taxed = 0
containers.ForEach( prices, _
sub[ byval rate, byref taxed ]( byref p as float64 )
p += p * rate '' writes the array's element
taxed += 1
end sub )
print taxed; " prices taxed:"; prices[0]; prices[1]; prices[2]
total 23.75
3 prices taxed: 5.625 15 9.0625
| Capture | The closure holds | Changes the lambda makes |
|---|---|---|
byref total | the address of the caller's total | reach the caller's variable |
byval rate | its own copy of rate, taken when the lambda is evaluated | stay in the copy |
The first ForEach adds every price into the caller's total. The second captures rate by value
and taxed by reference, and mixes the two freely. Its parameter p is byref, so p += p * rate changes the array's elements themselves; that is the parameter's doing, not the capture's.
There is no default mode. A capture written without byval or byref is refused:
dim as int32 n = 1
var show = sub[ n ]( )
print n
end sub
show( )
nomode.tiko(2,17): error TKO0413: every lambda capture needs an explicit 'byval' or 'byref'
The mode is not a detail to infer. byref means the closure holds an address, and an address is
only good while the variable exists; byval means the closure is self-contained. One word at the
capture puts that decision where the next reader will look for it. An empty list, sub[ ]( … ),
captures nothing and means exactly what sub( … ) means.
A byval capture is a snapshot, taken when the lambda is evaluated — not when it is called.
dim as int32 bias = 10
var addBias = function[ byval bias ]( byval x as int32 ) as int32
return x + bias
end function
var addBiasNow = function[ byref bias ]( byval x as int32 ) as int32
return x + bias
end function
bias = 1000
print addBias( 5 ) '' the copy taken when the lambda was made
print addBiasNow( 5 ) '' the variable as it is now
15
1005
bias is 1000 by the time either lambda runs. addBias copied it when the var line ran, so it
still adds 10; addBiasNow holds the variable's address and sees 1000. Both are correct
programs, and only one of them is the program you meant. A lambda written early and called late —
a handler, a comparer kept for later — is where this bites. Capture byref when the lambda must
see the variable as it is at the call, and byval when it must not.
A byval capture of a container is a copy of the whole container, made when the lambda is
evaluated. A lambda that pushes onto it changes its copy, and the caller's container keeps its
old contents.
A capture must name a variable that exists. In sub[ byref totl ]( … ), where only total
exists, the capture itself is TKO0743: 'totl' is not declared, and a use of total in the body
is reported too, because the capture did not bring it in.
A closure is not a procedure pointer
A lambda that captures something cannot become a procedure pointer: a pointer is an address and nothing else, and a closure has state that the address has nowhere to keep.
type Scale as function( byval x as int32 ) as int32
dim as int32 factor = 3
dim times3 as Scale = _
function[ byval factor ]( byval x as int32 ) as int32
return x * factor
end function
notaptr.tiko(3,5): error TKO0738: no conversion between these two types
A closure reaches a procedure through a generic parameter instead. ForEach and SortBy take
their callable as byref f as F, with F a type parameter, so they accept a closure and a plain
lambda alike — each call instantiates the procedure for the exact type it was given. Your own
procedures that take callbacks should do the same; Repeat below is one.
Closures carry state
'' A closure carries its own state. Each one is a separate object.
sub Repeat( of F )( byval times as int32, byref action as F )
for i as int32 = 1 to times
action( )
next
end sub
dim as int32 clicks = 0, pings = 0
var click = sub[ byref clicks ]( ) : clicks += 1 : end sub
var ping = sub[ byref pings ]( ) : pings += 1 : end sub
Repeat( 3, click )
Repeat( 2, ping )
click( )
print "clicks"; clicks; ", pings"; pings
'' A closure is a value: copying one copies its captures.
var again = click
again( )
print "clicks"; clicks
clicks 4, pings 2
clicks 5
click and ping are two closures, each holding the address of its own counter, and each call
increments the counter it holds. Repeat( of F ) calls whatever it is given, the number of times
it is told; it neither knows nor cares that action carries state.
A closure is an ordinary value. var again = click copies it — and copying a closure copies its
captures. again holds the same address click does, so calling again increments clicks.
A byval capture would have been copied instead, and the two closures would then count
separately.
How long a closure lives
The closure object lives in the scope where the lambda was written, like any local variable, and is destroyed with it. There is no heap allocation and nothing to free.
A byref capture holds the address of a variable in some frame, and is only valid while that
frame is running. tiko's closures are built so that the usual way of breaking that rule cannot be
written: a closure's type has no name, so no function can declare that it returns one. Closures
travel down, into the procedures they are passed to, which return before the frame that made the
closure does. They do not travel up, out of the frame whose variables they reference.
Two things remain your responsibility. A pointer to a closure, taken with @, carries no such
protection: it can be stored, and it is invalid once the closure's scope ends. And the same holds
for anything a closure is copied into that you keep beyond that scope.
Comparers and predicates
The standard containers take callables wherever a decision belongs to the caller: SortBy and
StableSortBy take a comparer, ForEach takes an action, and an action that tests and
counts is a predicate in all but name:
#import "std/containers"
type Person
name as ustring
age as int32
end type
sub Show( byref people as containers.Array( of Person ) )
for each p in people
print p.name; p.age; " ";
next
print
end sub
dim people as containers.Array( of Person )
dim p as Person
p.name = "grace" : p.age = 85 : people.Push( p )
p.name = "ada" : p.age = 36 : people.Push( p )
p.name = "edsger" : p.age = 72 : people.Push( p )
p.name = "alan" : p.age = 41 : people.Push( p )
'' The comparer answers one question: does a come before b?
containers.SortBy( people, _
function( byref a as Person, byref b as Person ) as boolean
return a.age < b.age
end function )
Show( people )
'' A capturing comparer: the direction comes from outside.
dim as boolean oldestFirst = true
containers.SortBy( people, _
function[ byval oldestFirst ]( byref a as Person, _
byref b as Person ) as boolean
if oldestFirst then return a.age > b.age
return a.age < b.age
end function )
Show( people )
'' A predicate: how many are over forty?
dim as int32 over40 = 0
containers.ForEach( people, sub[ byref over40 ]( byref q as Person )
if q.age > 40 then over40 += 1
end sub )
print over40; " over forty"
ada 36 alan 41 edsger 72 grace 85
grace 85 edsger 72 alan 41 ada 36
3 over forty
A comparer answers one question — "does a come before b?" — with true or false, and the
sort does the rest. It must answer consistently: if a comes before b, then b must not come
before a, and nothing comes before itself. Returning true for equal elements breaks that, and
a sort given such a comparer may put elements in any order.
The second comparer captures oldestFirst, so the direction is decided by the code around it
rather than written into the lambda. That is the everyday use of a capture: a fixed piece of logic
with one setting supplied from outside.
Lambdas inside generics
A lambda written inside a generic procedure is instantiated with it, so it can use the generic's type parameter:
#import "std/containers"
'' A lambda that captures nothing may use T freely.
sub SortDown( of T )( byref xs as containers.Array( of T ) )
containers.SortBy( xs, function( byref a as T, byref b as T ) _
as boolean
return a > b
end function )
end sub
'' A capturing lambda may capture a T, but not take one as a parameter:
'' give it a concrete parameter type instead.
function CountAbove( of T )( byref xs as containers.Array( of T ), _
byval limit as T ) as int32
dim as int32 n = 0
var check = sub[ byref n, byval limit ]( byval v as float64 )
if v > limit then n += 1
end sub
for i as int32 = 0 to xs.Count( ) - 1
check( xs[i] )
next
return n
end function
dim marks as containers.Array( of int32 ) = { 55, 91, 70, 38 }
SortDown( marks )
for each m in marks
print m;
next
print
print CountAbove( of int32 )( marks, 60 ); " above 60"
91 70 55 38
2 above 60
SortDown's comparer captures nothing, so its parameters may be of type T. CountAbove's lambda
captures a T — limit — and that is allowed too. What a capturing lambda cannot do is take a
parameter of type T:
#import "std/containers"
function CountAbove( of T )( byref xs as containers.Array( of T ), _
byval limit as T ) as int32
dim as int32 n = 0
containers.ForEach( xs, sub[ byref n, byval limit ]( byref x as T )
if x > limit then n += 1
end sub )
return n
end function
dim marks as containers.Array( of int32 ) = { 55, 91, 70, 38 }
print CountAbove( of int32 )( marks, 60 )
genericlimit.tiko(3,25): error TKO0921: the capturing lambda's parameter 'x' cannot be of the generic's type parameter 't'
A capturing lambda is itself built as a generic, over the types of its captures, and its parameter
list cannot also refer to the enclosing generic's T. The workaround is the one CountAbove
uses: give the lambda's parameter a concrete type the elements convert to — float64 for any
number — or call it from an ordinary loop over the collection.
What it costs
- A lambda that captures nothing costs what a named procedure costs, and calling it through a pointer costs what any indirect call costs.
- A closure is a small record — one address per
byrefcapture, one copy perbyvalcapture — on the stack of the scope that wrote it. Making one allocates nothing. - A
byvalcapture copies when the lambda is evaluated. For austringor a container, that is an allocation and a copy every time the lambda expression runs; capturebyrefwhen the copy is not the point. - Each generic call site is instantiated for the closure's type. Passing ten different closures
to
Repeatmakes ten copies ofRepeat, as passing ten different types to any generic does. That is also why the call is direct and can be inlined.
Summary
- A lambda is
function( … ) as T … end functionorsub( … ) … end sub, written as an expression;vartakes its type, and:fits a short body on one line. - A lambda that captures nothing is a procedure and its address: it converts to a matching procedure-pointer type and serves as a C callback. A calling convention follows the keyword.
sub[ byref a, byval b ]( … )captures. Every capture saysbyreforbyval(TKO0413).byrefholds the variable's address;byvalcopies it when the lambda is evaluated.- A capturing lambda is not a procedure pointer (
TKO0738). It reaches procedures through a genericof Fparameter, asForEachandSortBytake it. - A closure is a value; copying it copies its captures. It lives in the scope that wrote it, and because its type has no name, no function can return one.
- A comparer answers "does
acome beforeb?" consistently; capture its settings. - Inside a generic, a capturing lambda may capture a
Tbut not take one as a parameter (TKO0921).
Exercises
- Count the words in a
containers.Array( of ustring )that are at leastminLenletters long, withForEachand one closure. Which capture isbyref, whichbyval, and why? {solution="ex21-01"} - Sort names by length, and names of equal length alphabetically, with one comparer. {solution="ex21-02"}
- Change
snapshot.tikoso that both lambdas print 1005 without changing their capture modes. What did you have to move? - Write
Times( of F )( byval n as int32, byref f as F )that callsf( i )with the counter, and use it with a closure that sums the squares of 1 to 10. - Give
sortpeople.tikoa comparer that returnstruefor equal ages too. Run it a few times with more people of the same age. What does the order of equal elements depend on now?
Resource Management
A program borrows things from the system it runs on: memory, open files, sockets, locks, window
handles. Each one has to be given back, exactly once, after the last use — and the code paths
between taking and giving back multiply with every if, every early return and every runtime
error.
tiko's answer is deterministic destruction: an object is destroyed at a point you can read off
the source, when the block that owns it ends. This chapter builds on that rule. It recaps the rule,
then shows how to choose between defer and a type with a destructor, how to write a handle type
that owns one resource, and how to share ownership of an object between several owners with
SharedPtr and WeakPtr.
The rule
Chapter 6 introduced defer and Chapter 12 destructors. They follow one rule:
'' Destructors and defers share one rule: when a block ends, everything
'' it set up is undone, newest first.
type Resource
tag as ustring
declare constructor( byref t as ustring )
declare destructor( )
end type
constructor Resource( byref t as ustring )
tag = t
print "open "; tag
end constructor
destructor Resource( )
print "close "; tag
end destructor
sub Work( )
dim a as Resource = Resource( "a" )
defer print "deferred 1"
dim b as Resource = Resource( "b" )
defer print "deferred 2"
print "working"
end sub
Work( )
open a
open b
working
deferred 2
close b
deferred 1
close a
When a block ends, everything it set up is undone in reverse order — destructors and deferred
statements interleaved, newest first. b was opened after deferred 1 was scheduled, so it is
closed before that statement runs. The order is the order you wrote things in, reversed; nothing
depends on a collector, a timer or the order objects happen to be found in memory.
Every way out of a block follows the rule: falling off its end, exit sub, return, exit for.
The one exception is the runtime error that leaves a procedure for a handler further out, which
skips that procedure's cleanup — Chapter 18 showed it, and it is the reason to keep handlers close
to the resources they protect.
defer, or a type with a destructor
Both run cleanup at the end of a block. They differ in where the knowledge of the cleanup lives.
deferkeeps it at the call site.open … as #ffollowed bydefer close #fis right when the resource is used in one procedure and nowhere else: the release is one line, next to the acquisition, and there is nothing to name or declare.- A destructor keeps it in a type. When the same resource is acquired in many places, passed
between procedures or stored in other objects, writing
deferat every acquisition is the same line repeated — and a line someone will forget. A type whose destructor releases the resource makes the release impossible to forget, because there is nothing to write.
The rule of thumb: defer for a one-off, a type for a resource your program has more than one
of.
Writing a handle type
A handle type owns one resource for exactly as long as it lives. A file is the usual example:
'' A handle type: the file is open exactly as long as the object lives.
type TextWriter
declare constructor( byref path as ustring )
declare destructor( )
declare sub WriteLine( byref text as ustring )
private:
declare constructor( byref other as TextWriter ) '' no copies
declare operator let( byref other as TextWriter )
dim channel as int32
end type
constructor TextWriter( byref path as ustring )
channel = freefile( )
open path for output as #channel
end constructor
destructor TextWriter( )
close #channel
print "(closed)"
end destructor
sub TextWriter.WriteLine( byref text as ustring )
print #channel, text
end sub
sub SaveReport( byref path as ustring )
dim w as TextWriter = TextWriter( path )
w.WriteLine( "rows: 3" )
w.WriteLine( "status: ok" )
end sub '' w is closed here, on every way out
SaveReport( "report.txt" )
dim as int32 f = freefile( )
open "report.txt" for input as #f
dim as ustring lineText
do until eof( f )
line input #f, lineText
print "read: "; lineText
loop
close #f
kill "report.txt"
(closed)
read: rows: 3
read: status: ok
The constructor opens the file and the destructor closes it, so SaveReport contains no cleanup
code at all and still closes the file on every way out. The program then reads the file back —
which it can only do because SaveReport has already closed it.
The two private declarations matter as much as the destructor. A TextWriter that could be
copied would give two objects the same channel, and two destructors would close it twice — the
double-release from Chapter 12, wearing a file instead of memory. Declaring the copy constructor
and operator let private, and never defining them, means there is only ever one owner:
type TextWriter
declare constructor( byref path as ustring )
private:
declare constructor( byref other as TextWriter )
dim channel as int32
end type
constructor TextWriter( byref path as ustring )
end constructor
dim a as TextWriter = TextWriter( "a.txt" )
dim b as TextWriter = a '' a second owner? refused
nocopy.tiko(12,5): error TKO0633: 'b' needs a constructor or destructor that is not accessible here
This is the third answer to Chapter 12's rule of three. A type that owns something can copy it properly, be built from parts that copy themselves, or refuse to be copied. For a handle to something the system gave you once — a file, a socket, a lock — refusing is usually right.
Shared ownership: SharedPtr
Sometimes one object really does have several owners — a session that several connections use, a
texture several sprites draw — and none of them knows when the others are finished. SharedPtr( of T ) answers that with a count of owners: each copy of the pointer adds one, each copy that goes
away subtracts one, and the object is destroyed when the count reaches zero.
Smart pointers are the std/memory module: #import "std/memory", and its names are written
memory.SharedPtr, memory.MakeShared and so on (or bare, after using memory).
'' One object, several owners; the last owner to go destroys it.
#import "std/memory"
type Session
user as ustring
id as int32
declare destructor( )
end type
destructor Session( )
print "session"; id; " closed"
end destructor
dim s as memory.SharedPtr( of Session ) = memory.MakeShared( of Session )( )
s->user = "ada"
s->id = 1815
scope
dim t as memory.SharedPtr( of Session ) = s '' a second owner
print "owners:"; t.UseCount( )
t->id = 1816
end scope '' t goes; s remains
print s->user; s->id; ", owners:"; s.UseCount( )
s.Reset( ) '' the last owner lets go
print "after reset, null: "; s.IsNull( )
owners: 2
ada 1816, owners: 1
session 1816 closed
after reset, null: true
| Member | Does |
|---|---|
memory.MakeShared( of T )( ) | a new T on the heap, owned by the returned pointer |
p->field, *p | the object, as through a raw pointer — no check for null |
p.Ref( ) | the object, checked: a runtime error on an empty pointer |
p.UseCount( ) | how many SharedPtrs own the object |
p.Reset( ) | this pointer stops owning; the object goes if it was the last owner |
p.IsNull( ) | whether this pointer owns nothing |
p.Get( ) | the raw T ptr, for code that needs one |
A pointer can also adopt an object that already exists: memory.SharedPtr( of T )( new T( … ) ).
After that, the SharedPtr owns it — never delete it yourself.
Passing one around
#import "std/memory"
type Config
name as ustring
end type
sub Show( byref c as memory.SharedPtr( of Config ) )
print "byref: owners"; c.UseCount( )
end sub
sub Keep( byval c as memory.SharedPtr( of Config ) )
print "byval: owners"; c.UseCount( )
end sub
dim cfg as memory.SharedPtr( of Config ) = memory.MakeShared( of Config )( )
Show( cfg )
Keep( cfg )
print "after: owners"; cfg.UseCount( )
'' Ref( ) is the checked way in: on an empty handle it raises.
on local error goto failed
dim nothing as memory.SharedPtr( of Config )
print nothing.Ref( ).name
end
failed:
dim as int32 code = err
print "runtime error"; code
byref: owners 1
byval: owners 2
after: owners 1
runtime error 1
Passing a SharedPtr byval makes a copy — a real new owner — and costs an increment on the way
in and a decrement on the way out. A procedure that only uses the object should take it byref.
Take it byval only when the procedure keeps a copy that outlives the call.
The second half of the listing is Ref( ) on an empty pointer: runtime error 1, "illegal function
call", which an on error handler can catch. -> and * do not check, exactly as a raw pointer
does not; use Ref( ) where an empty pointer is possible and you want it caught.
-> reaches a field, so it needs T to be a type with fields. Over a SharedPtr( of int32 ),
*count works and count->value does not:
#import "std/memory"
dim count as memory.SharedPtr( of int32 ) = memory.MakeShared( of int32 )( )
*count = 3 '' fine: * works for any T
print count->value '' -> needs a type with fields
scalararrow.tiko(5,14): error TKO0739: 'sharedptr' has no member named 'value'
Cycles, and WeakPtr
Reference counting has one failure it cannot detect on its own: two objects that own each other.
#import "std/memory"
#import "std/containers"
'' Children own each other upward AND downward: a cycle.
type LeakyNode
name as ustring
kids as containers.Array( of memory.SharedPtr( of LeakyNode ) )
parent as memory.SharedPtr( of LeakyNode )
declare destructor( )
end type
destructor LeakyNode( )
print "freed "; name
end destructor
'' The parent link observes instead of owning.
type Node
name as ustring
kids as containers.Array( of memory.SharedPtr( of Node ) )
parent as memory.WeakPtr( of Node )
declare destructor( )
end type
destructor Node( )
print "freed "; name
end destructor
scope
var root = memory.MakeShared( of LeakyNode )( )
root->name = "leaky root"
var kid = memory.MakeShared( of LeakyNode )( )
kid->name = "leaky kid"
kid->parent = root
root->kids.Push( kid )
end scope
print "-- leaky tree out of scope"
scope
var root = memory.MakeShared( of Node )( )
root->name = "root"
var kid = memory.MakeShared( of Node )( )
kid->name = "kid"
kid->parent = root
root->kids.Push( kid )
var up = kid->parent.Lock( ) '' a real owner, or null
if up.IsNull( ) = false then print "kid's parent: "; up->name
end scope
print "-- tree out of scope"
-- leaky tree out of scope
kid's parent: root
freed root
freed kid
-- tree out of scope
In the first tree, the root owns the kid through kids and the kid owns the root through parent.
When the scope ends, each local SharedPtr lets go — and each node still has one owner: the other
node. Neither count reaches zero, neither destructor runs, and nothing reports it. The memory is
leaked for the rest of the program.
The second tree makes the back link weak. A WeakPtr( of T ) observes an object without owning
it: it does not count, so the kid's parent no longer keeps the root alive. When the scope ends,
the root's count reaches zero, the root is destroyed, its kids array goes with it, and the kid
follows.
A WeakPtr is read through Lock( ), which returns a real SharedPtr — an owner for as long as
you hold it — or an empty one if the object has already gone. There is no way to reach the object
through a WeakPtr without that check, and that is the point: between "is it still there?" and
"use it", the last owner could let go, and Lock( ) answers both questions at once.
The rule for designing with them: ownership flows one way. Pick the direction that owns —
parent to child, container to element, window to control — make every link that way a SharedPtr,
and make every link back a WeakPtr.
SpCast and the virtual destructor
A SharedPtr( of Circle ) does not convert to a SharedPtr( of Shape ) on its own. The conversion
is written out, with both types named:
var s = memory.SpCast( of Circle, Shape )( c )
Both pointers now own the same Circle, and the count is shared.
The last owner destroys the object as its own T. If that last owner is a SharedPtr( of Shape ), the object is destroyed as a Shape:
#import "std/memory"
type Shape extends object
declare destructor( ) '' not virtual
end type
destructor Shape( )
print "shape gone"
end destructor
type Circle extends Shape
r as float64
declare destructor( )
end type
destructor Circle( )
print "circle gone"
end destructor
scope
var c = memory.MakeShared( of Circle )( )
var s = memory.SpCast( of Circle, Shape )( c ) '' derived to base
c.Reset( )
print "the Shape handle is the last owner"
end scope
the Shape handle is the last owner
shape gone
Circle's destructor never runs. Here it only prints; in a real type, it is where a file is
closed or a buffer freed, and nothing reports that it was skipped. The fix is the one Chapter 14
gave for deleting through a base pointer — make the base's destructor virtual:
#import "std/memory"
type Shape extends object
declare virtual destructor( )
end type
destructor Shape( )
print "shape gone"
end destructor
type Circle extends Shape
r as float64
declare destructor( )
end type
destructor Circle( )
print "circle gone"
end destructor
scope
var c = memory.MakeShared( of Circle )( )
var s = memory.SpCast( of Circle, Shape )( c ) '' derived to base
c.Reset( )
print "the Shape handle is the last owner"
end scope
the Shape handle is the last owner
circle gone
shape gone
Any type you intend to share through a pointer to its base needs a virtual destructor. The
compiler cannot check that for you: it is a contract of SpCast.
Threads
Under -mt, the counts inside a SharedPtr are updated with atomic operations, so several
threads may copy and drop pointers to the same object at the same time. Without -mt there are no
threads, and the counts are plain arithmetic. That guarantee covers the count, never the
object: two threads that change the object itself still need a lock of their own. Chapter 23
covers threads and locks.
What it costs
- A handle type costs nothing beyond its resource. Its destructor is a call at the end of the block, the same call you would have written by hand.
defercosts nothing at run time; the deferred statement is placed on each way out.- A
SharedPtris two allocations — the object and a small block holding the counts — and each copy is an increment and, later, a decrement. Under-mtthose are atomic operations, which are slower again. Passbyrefwhere the copy is not needed. - A
WeakPtrkeeps the count block alive, though not the object, until the last weak pointer goes.
Summary
- When a block ends, its destructors and deferred statements run in reverse order of setup, on every way out except an error that leaves for an outer handler.
deferfor a one-off resource in one procedure; a type with a destructor for a resource the program has many of or passes around.- A handle type acquires in its constructor, releases in its destructor, and usually refuses to be
copied: a private copy constructor and
operator let(TKO0633at any copy). #import "std/memory"—memory.MakeShared,->,*,Ref( )(checked),UseCount( ),Reset( ). Passbyrefunless the callee keeps a copy.- Two objects that own each other are never freed. Make back links
WeakPtrs and read them withLock( ). memory.SpCast( of Derived, Base )converts explicitly; the base needs a virtual destructor, or the derived part is never destroyed.- Under
-mtthe counts are atomic; the object itself is not protected.
Exercises
- Write a
ScratchFiletype that creates a file in its constructor and deletes it in its destructor, and cannot be copied. Show that the file exists inside ascopeand not after it. {solution="ex22-01"} - Keep a
WeakPtr( of Image )as a cache entry. Show a hit while an owner exists, and an emptyLock( )andExpired( )after the last owner has gone. {solution="ex22-02"} - Rewrite
filehandle.tiko'sSaveReportwithopenanddeferand no type. Which version would you rather have if ten procedures wrote reports? - In
treeparents.tiko, make the root'skidsweak instead of the kid'sparent. What is freed when, and why is that wrong for a tree? - Add a third level to the tree and print each node's path to the root by following
parentlinks withLock( ).
Concurrency
A program that does one thing at a time wastes the machine it runs on. Downloads wait on the network, a scan waits on the disk, a calculation could use every core — and a window has to keep answering the user while all of that happens. Doing several things at once is how a program stops waiting on itself.
It is also the easiest way to write a program that is right on every run but one. Two threads that change the same variable without coordinating lose updates; a thread that waits for a message nobody sends waits forever; and none of it shows up until the timing is unlucky.
tiko gives you two levels. OS threads, with mutexes and condition variables, are the
foundation: explicit, flexible and easy to get wrong. Goroutines and channels, from the
std/tasks module, are built on top: you start work with spawn, and the pieces talk by sending
values rather than by sharing variables. This chapter covers both, then the tools for coordinating
them, and the shared state that remains your problem either way.
The thread-safe runtime
Every program that runs more than one thread must be built with -mt, which links the
runtime that is safe to call from several threads at once. Put it in the program, where it cannot
be forgotten:
#import "std/tasks"
#cmdline "-mt"
#cmdline comes after any #import lines: an #import must come before everything else in the
file (TKO0362). A program can ask how it was built: __FB_MT__ is −1 under -mt and 0
without it.
std/tasks refuses a build without -mt, and the refusal matters. #import "std/tasks"
without -mt stops at once: TKO0309: #error: "std/tasks needs the multithreaded runtime: build with -mt". Before the library was a module its headers could be included directly, unchecked, and
built without -mt the goroutine program in the next section printed its correct result and then,
run three times, crashed once with a segmentation fault and hung twice at exit. That is the worst
kind of failure: the output looks right.
Every listing in this chapter carries #cmdline "-mt". Do the same in yours.
OS threads
Threads and a mutex
threadcreate( @Proc, arg ) starts a new operating-system thread running Proc( arg ), where
Proc takes one byval … as any ptr. threadwait( t ) waits for it to finish. A mutex makes
a section of code run in one thread at a time:
#cmdline "-mt"
'' Four OS threads add to one total. The mutex makes each += whole.
type Counter
lock as any ptr
total as int64
end type
sub AddMany( byval p as any ptr )
var c = cptr( Counter ptr, p )
for i as int32 = 1 to 100000
mutexlock( c->lock )
c->total += 1
mutexunlock( c->lock )
next
end sub
dim c as Counter
c.lock = mutexcreate( )
dim workers( 0 to 3 ) as any ptr
for i as int32 = 0 to 3
workers( i ) = threadcreate( @AddMany, @c )
next
for i as int32 = 0 to 3
threadwait( workers( i ) ) '' join: wait for it to end
next
mutexdestroy( c.lock )
print "total:"; c.total
total: 400000
c->total += 1 is three steps — read, add, write back. Two threads can read the same value, both
add one and both write, and one increment is lost. Inside mutexlock … mutexunlock only one
thread runs those steps at a time, so the total is exact. The same program with the two mutex lines
removed printed 149321, 188315 and 133439 on three runs: more than half the work lost, and a
different amount each time.
The argument is an any ptr, so everything a thread needs travels in one record whose address is
passed — here the counter and its lock together. A mutex is created with mutexcreate( ) and
must be destroyed with mutexdestroy( ) when no thread can use it any longer.
Waiting for a condition
A condition variable lets a thread sleep until another thread tells it something has changed:
#cmdline "-mt"
'' One thread waits until another says the data is ready.
type Mailbox
lock as any ptr
ready as any ptr '' the condition
full as boolean
message as ustring
end type
sub Deliver( byval p as any ptr )
var box = cptr( Mailbox ptr, p )
mutexlock( box->lock )
box->message = "parcel"
box->full = true
condsignal( box->ready )
mutexunlock( box->lock )
end sub
dim box as Mailbox
box.lock = mutexcreate( )
box.ready = condcreate( )
var courier = threadcreate( @Deliver, @box )
mutexlock( box.lock )
do until box.full '' re-test: a wake-up is only a hint
condwait( box.ready, box.lock )
loop
print "received: "; box.message
mutexunlock( box.lock )
threadwait( courier )
conddestroy( box.ready )
mutexdestroy( box.lock )
received: parcel
condwait( cond, mutex ) releases the mutex while it sleeps and takes it back before it returns,
so the flag it is waiting on can be changed by the other thread in the meantime. Always test the
condition in a loop: a wake-up says only that something may have changed — the thread can be
woken with nothing to do, or after another thread has already consumed the change.
threadcall
threadcall Proc( args ) is meant to start a thread for an ordinary procedure, with ordinary
arguments. In this version it does not: it compiles with a warning,
TKO0797: 'threadcall' is not supported: 'hello' will not run, and the thread handle is 0, and
the procedure never runs. Use threadcreate with a record of arguments, or — better — spawn.
Goroutines
spawn
A goroutine is a procedure call that runs alongside the rest of the program. spawn starts
one:
#import "std/tasks"
#cmdline "-mt"
'' A goroutine returns nothing: the channel is its way back.
sub Square( byval n as int32, _
byval results as tasks.SendChan( of int32 ) )
results.Send( n * n )
end sub
var results = tasks.Channel( of int32 )( 8 )
for i as int32 = 1 to 5
spawn Square( i, results ) '' i is copied, here
next
dim as int32 total = 0, v
for i as int32 = 1 to 5
results.Receive( v )
total += v
next
print "sum of squares 1..5:"; total
sum of squares 1..5: 55
spawn Square( i, results ) returns at once; Square runs later, on one of the runtime's worker
threads. A goroutine has no return value, so Square sends its answer down a channel, and the
main program receives five answers. They may arrive in any order, which is why the program adds
them up rather than printing them.
A goroutine is a work item on a pool of threads, not a thread of its own. Starting one is a queue
push, so thousands are fine; the pool grows when goroutines block in system calls, up to 256
workers. And nothing waits for a goroutine at program exit: one still running when the
program ends is abandoned. A channel, as here, or the WaitGroup below is how you wait.
Arguments are copied
The arguments to a spawned procedure are evaluated and copied at the spawn statement:
#import "std/tasks"
#import "std/containers"
#cmdline "-mt"
sub Report( byval id as int32, byval label as ustring, _
byval out_ as tasks.SendChan( of ustring ) )
out_.Send( label & " #" & id )
end sub
dim results as tasks.Channel( of ustring ) = _
tasks.Channel( of ustring )( 8 )
dim as ustring label = "job"
for i as int32 = 1 to 4
spawn Report( i, label, results ) '' its own i and label
next
label = "changed" '' too late to matter
dim got as containers.Array( of ustring )
dim as ustring s
for i as int32 = 1 to 4
results.Receive( s )
got.Push( s )
next
containers.Sort( got ) '' arrival order varies
for each g in got
print g
next
job #1
job #2
job #3
job #4
Each goroutine gets the i and the label that existed when it was spawned. Changing label
afterwards changes nothing they see, however late they run. The results arrive in whatever order
the goroutines finish, so the program sorts them before printing.
Copying is what makes this safe, and it rules out a byref parameter: the goroutine would hold a
reference into the spawning procedure's frame, which may be gone by the time it runs.
#import "std/tasks"
#cmdline "-mt"
sub Fill( byref total as int32 )
total = 42
end sub
dim as int32 answer
spawn Fill( answer )
spawnbyref.tiko(9,1): error TKO0812: 'spawn fill' cannot pass its byref parameter 'total'; a string or UDT parameter is byref unless it is written 'byval'
Note the second half of the message: a ustring or record parameter written without either keyword
is byref, so a procedure you mean to spawn writes byval on every parameter. When the goroutine
must change something of yours, pass a pointer and own that decision.
spawn takes a procedure declared at module level — a sub, or a function whose result is
discarded. A method, a lambda and a procedure pointer cannot be spawned.
Anything else is refused: with var greet = sub( ) … end sub, spawn greet( ) is
TKO0943: 'spawn' needs a sub or function declared at module level, and 'greet' is not one.
Channels
A Channel( of T ) is a typed queue between goroutines. It is created with a capacity:
#import "std/tasks"
#cmdline "-mt"
dim box as tasks.Channel( of int32 ) = tasks.Channel( of int32 )( 2 )
'' Try* never blocks: the third send finds the buffer full.
print box.TrySend( 10 ); box.TrySend( 20 ); box.TrySend( 30 )
print "count"; box.Count( ); ", capacity"; box.Capacity( )
box.Close( ) '' no more sends; what is queued stays
for each v in box '' drains, then ends because closed
print v;
next
print
dim as int32 x = -1
print "receive after drain: "; box.Receive( x ); ", x still"; x
dim quiet as tasks.Channel( of int32 ) = tasks.Channel( of int32 )( 1 )
print "receive with a 50 ms limit: "; quiet.ReceiveFor( x, 50 )
truetruefalse
count 2, capacity 2
10 20
receive after drain: false, x still-1
receive with a 50 ms limit: false
| Operation | Does |
|---|---|
Send( v ) | waits until there is room, then queues v |
Receive( v ) | waits for a value; false once the channel is closed and empty |
TrySend( v ), TryReceive( v ) | never wait; false if they would have to |
SendFor( v, ms ), ReceiveFor( v, ms ) | wait at most ms milliseconds |
Close( ) | no more sends; queued values remain to be received |
for each v in ch | receives until the channel is closed and drained |
Count( ), Capacity( ), IsClosed( ), IsNil( ) | snapshots, for reporting — not for deciding |
A capacity of 0 is a rendezvous: Send waits until a receiver takes the value, so a
send is also a meeting point. A capacity of N buffers N values, and Send waits only when the
buffer is full — which is what stops a fast producer from running ahead of a slow consumer
without limit.
Receive returning false at the end is not an error; it is how a consumer learns that the
producer has finished, and the for each form is built on it. x keeps its old value when that
happens. Sending to a closed channel, or closing one twice, is runtime error 1: it means two parts
of the program disagree about who produces.
A channel is a handle: copying it gives a second handle to the same queue, which is how one
channel is shared between goroutines. A channel declared without a capacity is nil, and every
send and receive on it waits forever — IsNil( ) exists to check your own setup.
Pipelines, and channels with one end
Channels chain goroutines into a pipeline, each stage reading from one channel and writing to the next:
#import "std/tasks"
#cmdline "-mt"
'' Two stages, each a goroutine, joined by channels; the main program is
'' the third. Each stage closes its output when its input runs dry.
sub Generate( byval upTo as int32, _
byval out_ as tasks.SendChan( of int32 ) )
for i as int32 = 1 to upTo
out_.Send( i )
next
out_.Close( )
end sub
sub Square( byval in_ as tasks.RecvChan( of int32 ), _
byval out_ as tasks.SendChan( of int32 ) )
dim as int32 v
do while in_.Receive( v )
out_.Send( v * v )
loop
out_.Close( )
end sub
var numbers = tasks.Channel( of int32 )( 0 ) '' rendezvous
var squares = tasks.Channel( of int32 )( 0 )
spawn Generate( 6, numbers )
spawn Square( numbers, squares )
for each s in squares
print s;
next
print
1 4 9 16 25 36
Generate closes numbers when it has sent everything; Square sees Receive return false,
closes squares in turn; and the main program's for each ends. The close flows down the
pipeline, and no stage needs to know how many values there are.
The parameter types say which end each stage holds. A SendChan( of T ) can send and close; a
RecvChan( of T ) can only receive. A Channel converts to either when it is passed. A receiver
that tries to close its input is refused, because closing is the producer's decision:
#import "std/tasks"
#cmdline "-mt"
sub Consume( byval inbox as tasks.RecvChan( of int32 ) )
inbox.Close( ) '' only the sending side may close
end sub
recvclose.tiko(5,11): error TKO0739: 'recvchan' has no member named 'close'
The one-ended types state intent at the boundary where a reader looks for it. They are not a lock:
the code that created the Channel still holds both ends.
Waiting on several channels: select channel
select channel waits until one of several channel operations can proceed, and runs that case.
It is part of the language, and its runtime support is in std/tasks, which the file imports
already (TKO0423 says so when it does not):
#import "std/tasks"
#cmdline "-mt"
sub SlowAnswer( byval delayMs as int32, _
byval out_ as tasks.SendChan( of int32 ) )
sleep delayMs, 1
out_.Send( 42 )
end sub
dim answers as tasks.Channel( of int32 ) = _
tasks.Channel( of int32 )( 1 )
dim as int32 v
'' case else: do not wait at all.
select channel
case receive v from answers : print "got"; v
case else : print "nothing ready yet"
end select
'' case timeout: wait, but only so long.
spawn SlowAnswer( 2000, answers )
select channel
case receive v from answers : print "got"; v
case timeout 100 : print "gave up after 100 ms"
end select
'' This time the answer arrives while we wait.
select channel
case receive v from answers : print "got"; v
case timeout 10000 : print "gave up"
end select
nothing ready yet
gave up after 100 ms
got 42
| Case | Fires when |
|---|---|
case receive v from ch | a value can be received into v (declared beforehand) |
case receive from ch | a value can be received; it is discarded |
case send v to ch | v can be sent |
case timeout ms | nothing else became ready within ms milliseconds |
case else | nothing is ready right now — the select does not wait at all |
A select may have one timeout or one else, not both — writing both is TKO0942 — because
else would fire first every time, and the timeout could never be reached. When
several cases are ready at once, one is chosen in a varying order, so a channel that is always
ready cannot starve the others. A closed channel counts as ready to receive, and a nil channel
never does — assigning a nil channel to a variable is how a case is switched off.
Coordinating goroutines
WaitGroup
A WaitGroup counts goroutines in flight. Add( ) raises the count, each goroutine calls
Done( ) as it finishes, and Wait( ) returns when the count reaches zero:
#import "std/tasks"
#cmdline "-mt"
sub Worker( byval jobs as tasks.RecvChan( of int32 ), _
byval results as tasks.SendChan( of int32 ), _
byval wg as tasks.WaitGroup ptr )
dim as int32 job
do while jobs.Receive( job ) '' until the jobs channel closes
results.Send( job * 2 )
loop
wg->Done( )
end sub
sub CloseWhenDone( byval wg as tasks.WaitGroup ptr, _
byval results as tasks.Channel( of int32 ) )
wg->Wait( )
results.Close( )
end sub
var jobs = tasks.Channel( of int32 )( 16 )
var results = tasks.Channel( of int32 )( 16 )
dim wg as tasks.WaitGroup
for w as int32 = 1 to 3
wg.Add( ) '' before the spawn, never inside
spawn Worker( jobs, results, @wg )
next
spawn CloseWhenDone( @wg, results )
for j as int32 = 1 to 10
jobs.Send( j )
next
jobs.Close( )
dim as int32 total = 0, count = 0
for each r in results
total += r
count += 1
next
print count; " results, total"; total
10 results, total 110
This is a worker pool: three workers share one jobs channel, so each job goes to whichever
worker is free. The main program does not know which worker finishes last, so a small goroutine,
CloseWhenDone, waits on the group and then closes results — and the main program's
for each ends exactly when the last result is in.
Two rules. Add before spawn, never inside the goroutine: otherwise Wait can run before
the goroutine has counted itself, and return early. And a WaitGroup cannot be copied — a copy
would be a second counter — so it is passed by pointer.
Context and cancellation
A context asks goroutines to stop. WithCancel makes one that Cancel( ) cancels;
WithTimeout makes one that cancels itself after a number of milliseconds:
#import "std/tasks"
#cmdline "-mt"
'' Works until its context says stop, then reports how it ended.
sub Poll( byval ctx as tasks.Context, _
byval done_ as tasks.SendChan( of ustring ) )
do
select channel
case receive from ctx.Done( ) : exit do
case timeout 5 : '' one round of work
end select
loop
done_.Send( "stopped: " & ctx.Err( ) )
end sub
dim finished as tasks.Channel( of ustring ) = _
tasks.Channel( of ustring )( 1 )
dim as ustring why
'' Cancelled by hand.
dim byHand as tasks.CancelContext = tasks.WithCancel( tasks.Context( ) )
spawn Poll( byHand, finished )
sleep 50, 1
byHand.Cancel( )
finished.Receive( why )
print why
'' Cancelled by its own deadline.
dim byClock as tasks.CancelContext = _
tasks.WithTimeout( tasks.Context( ), 50 )
spawn Poll( byClock, finished )
finished.Receive( why )
print why
stopped: cancelled
stopped: deadline exceeded
ctx.Done( ) is a channel that is closed when the context is cancelled — and a closed channel
is always ready to receive, so cancellation becomes one more case in a select. Err( ) says why:
"cancelled" or "deadline exceeded". Cancelling a context cancels every context derived from
it, and Cancel may be called any number of times.
Cancellation is a request. Nothing is ever stopped by force: a goroutine that never looks at
its context runs to the end, and one blocked in a system call cannot see the request until the call
returns. That is deliberate — stopping a thread from outside would skip its destructors and its
defers, the cleanup the language promises.
tasks.InterruptContext( ) is a context that Ctrl-C cancels (Chapter 25's os.CatchInterrupt,
started for you). Derive from it and every goroutine that watches its context stops on Ctrl-C with
no code of its own:
#import "std/tasks"
#cmdline "-mt"
'' Works until its context says stop.
sub Work( byval ctx as tasks.Context, byval wg as tasks.WaitGroup ptr )
while ctx.IsCancelled( ) = false
sleep 5
wend
print "stopped: "; ctx.Err( )
wg->Done( )
end sub
'' Cancelled by Ctrl-C, or after 100 ms, whichever comes first.
dim ctx as tasks.CancelContext = tasks.WithTimeout( tasks.InterruptContext( ), 100 )
dim wg as tasks.WaitGroup
wg.Add( )
spawn Work( ctx, @wg )
wg.Wait( )
stopped: deadline exceeded
Run without a key press it ends with deadline exceeded; press Ctrl-C within the 100 ms and it
says cancelled. Every call returns the same context, so a library can ask for it too.
Shared state
Channels move values between goroutines; they do not protect anything the goroutines share. When two threads or goroutines do touch the same variable, it needs a mutex — or, for a counter, an atomic operation:
#cmdline "-mt"
#include once "fbatomic.bi"
'' No mutex: each add is one indivisible step.
sub Bump( byval p as any ptr )
for i as int32 = 1 to 100000
FB.AtomicFetchAddLong( cptr( long ptr, p ), 1 )
next
end sub
dim as long hits
dim workers( 0 to 3 ) as any ptr
for i as int32 = 0 to 3
workers( i ) = threadcreate( @Bump, @hits )
next
for i as int32 = 0 to 3
threadwait( workers( i ) )
next
print "hits:"; FB.AtomicLoadLong( @hits )
hits: 400000
fbatomic.bi provides three operations on a 32-bit long: FB.AtomicFetchAddLong adds and
returns the old value in one indivisible step, FB.AtomicCasLong stores a value only if the
current one is what you expected, and FB.AtomicLoadLong reads with the ordering a counter
written by another thread needs. Each is a full barrier, and each is a call into the runtime.
Some state is not shared at all, and that can be a surprise too.
Some of the runtime's own state belongs to the thread, and a goroutine runs on whichever thread is free.
#import "std/tasks"
#cmdline "-mt"
function Found( byref entry as ustring ) as ustring
return iif( len( entry ) > 0, "a file", "nothing" )
end function
sub NextName( byval out_ as tasks.SendChan( of ustring ) )
out_.Send( "[" & dir( ) & "]" ) '' continue... which listing?
end sub
open "part1.dat" for output as #1 : close #1
open "part2.dat" for output as #1 : close #1
print "main's first match: "; Found( dir( "part*.dat" ) )
dim names as tasks.Channel( of ustring ) = _
tasks.Channel( of ustring )( 1 )
spawn NextName( names )
dim as ustring fromGoroutine
names.Receive( fromGoroutine )
print "goroutine's next: "; fromGoroutine
print "main's next match: "; Found( dir( ) )
kill "part1.dat" : kill "part2.dat"
main's first match: a file
goroutine's next: []
main's next match: a file
dir( pattern ) starts a directory listing and dir( ) continues it — on the same thread.
The goroutine's dir( ) continues nothing, because the listing was started on the main thread,
and returns an empty string. The main thread's listing is untouched. The same holds for err and
the other runtime state that a statement leaves behind for the next one. Keep a sequence of calls
that depend on each other inside one goroutine.
Threads or goroutines?
- Reach for goroutines and channels first. They copy arguments, pass results back by value, and make the order of events visible in the code. Most concurrent programs — fan-out work, pipelines, timeouts — are shorter and easier to get right this way.
- Use an OS thread for something that must own its thread: a loop that blocks for a long time, code that keeps per-thread state, or anything that belongs to one thread for its whole life.
- Never touch a window from a goroutine. A window belongs to the thread that created it, and a
goroutine runs on an arbitrary worker. Compute in the goroutine, send the result through a
channel, and let the window's own thread take it with
TryReceive, which never blocks the message loop.
What it costs
-mtmakes every runtime call that touches shared state take a lock, so a single-threaded program built with it is somewhat slower. Use it where threads are.- A goroutine costs a queue push and a copy of its arguments; a thread costs an operating-system thread. Thousands of goroutines are fine; millions are not.
- Every channel operation copies the value, and a buffered send copies twice. For a large record, send a pointer and decide who owns it.
- A mutex costs little when nobody else holds it and a great deal when everyone does. A lock
around the inner loop of four threads, as in
threads.tiko, makes them take turns.
Summary
- Build every multi-threaded program with
-mt—#cmdline "-mt"after the imports. Without it the program compiles and then crashes or hangs. threadcreate/threadwaitstart and join OS threads;mutexlock/mutexunlockmake a section exclusive;condwaitsleeps on a condition, always in a loop.threadcalldoes not run (TKO0797).spawn Proc( args )runs a module-level procedure as a goroutine; its arguments are copied at the spawn, and abyrefparameter is refused (TKO0812). Nothing waits for goroutines at exit.Channel( of T )( capacity ): 0 is a rendezvous.Receiveisfalseat the end of a closed stream;for eachdrains one.SendChan/RecvChangive one end each.select channelwaits on several operations, withtimeoutorelse; likespawn, it needs#import "std/tasks"in the file (TKO0423).WaitGroup:Addbeforespawn,Donein the goroutine,Waitto join.Context: cooperative cancellation through aDone( )channel, by hand, by deadline, or by Ctrl-C (InterruptContext).- Shared variables need a mutex or an atomic; per-thread runtime state such as
dir( )'s listing does not follow a goroutine.
Exercises
- Sum the integers 1 to 1,000,000 in four goroutines, one quarter each, and combine the partial sums through a channel. {solution="ex23-01"}
- Two producers send three numbers each into one channel. Use a
WaitGroupand a closing goroutine so that the consumer'sfor eachends exactly when both have finished. {solution="ex23-02"} - Add a third stage to
pipeline.tikothat keeps only the even squares. - Give
workerpool.tiko's workers aContext, and cancel it after the first five results. What must the workers do for the cancellation to take effect? - Remove the mutex calls from
threads.tikoand run it several times. Then replace the increment withFB.AtomicFetchAddLong. What changes, and what does each version cost?
Containers and Sorting
A fixed array holds a number of elements decided when it is declared. Most real collections are
not like that: a list of orders grows as orders arrive, a word count gains a key for every new
word, a set of visited cells answers "have I been here?" a million times. Writing those from
arrays and redim preserve each time is how programs get slow and wrong.
The standard library's std/containers module has nine collections, each built for one kind of
access, plus the procedures that sort, search and rearrange them. They are ordinary generic types, written in
tiko — nothing about them is built into the compiler — and they share one shape: construct one,
fill it, walk it with for each.
#import "std/containers"
Arrays that grow: Array( of T )
#import "std/containers"
sub Show( byref label as ustring, _
byref xs as containers.Array( of int32 ) )
print label; ":";
for each x in xs
print x;
next
print " (count"; xs.Count( ); ", capacity"; xs.Capacity( ); ")"
end sub
dim xs as containers.Array( of int32 ) = { 5, 3, 9 }
Show( "start", xs )
xs.Push( 7 ) '' append
Show( "push 7", xs )
print "pop gives"; xs.Pop( ); ", xs[0] is"; xs[0] '' indices from 0
xs.Insert( 0, 1 ) '' shifts everything after it
Show( "insert at 0", xs )
xs.Remove( 1 ) '' keeps the order
Show( "remove at 1", xs )
xs.RemoveSwap( 0 ) '' moves the last element into the gap
Show( "removeswap 0", xs )
xs.Reserve( 100 ) '' room for 100 without regrowing
Show( "reserve 100", xs )
xs.Clear( ) '' drops elements, keeps capacity
Show( "clear", xs )
xs.Shrink( ) '' gives the capacity back
Show( "shrink", xs )
start: 5 3 9 (count 3, capacity 8)
push 7: 5 3 9 7 (count 4, capacity 8)
pop gives 7, xs[0] is 5
insert at 0: 1 5 3 9 (count 4, capacity 8)
remove at 1: 1 3 9 (count 3, capacity 8)
removeswap 0: 9 3 (count 2, capacity 8)
reserve 100: 9 3 (count 2, capacity 128)
clear: (count 0, capacity 128)
shrink: (count 0, capacity 0)
An Array( of T ) keeps a count — how many elements there are — and a capacity — how many
fit before it must grow. Push appends; when the capacity runs out it doubles, so a long run of
pushes reallocates only a handful of times and each push costs, on average, a constant amount.
That is the difference from redim preserve, which reallocates on every call and makes a loop of
appends quadratic.
| Member | Does | Cost |
|---|---|---|
xs[i] | the element at i, which can be assigned | O(1) |
Push( v ), Pop( ) | add at the end; remove and return the last | O(1) on average |
Insert( i, v ), Remove( i ) | add or remove at i, keeping the order | O(n) |
RemoveSwap( i ) | remove at i by moving the last element into the gap | O(1) |
Count( ), Capacity( ), IsEmpty( ) | sizes | O(1) |
Reserve( n ), Shrink( ) | grow capacity ahead of time; release the spare | O(n) |
Clear( ) | remove every element, keeping the capacity | O(n) |
Indices start at 0, whatever an ordinary array's dim a( 1 to 10 ) habit says: xs[0] is the
first element and xs[xs.Count( ) - 1] the last. RemoveSwap is the fast way to remove when the
order does not matter. Reserve is worth calling when you know how many elements are coming: one
allocation instead of several.
Copying an Array copies every element — dim b as … = a, assignment, and a byval parameter
all make an independent array. Pass containers byref unless a copy is what you want.
Searching
#import "std/containers"
dim scores as containers.Array( of int32 ) = { 70, 85, 90 }
'' Searching is a free procedure, not a member.
dim as int32 wanted = 85, missing = 60
print containers.IndexOf( scores, wanted )
print containers.IndexOf( scores, missing )
print containers.Contains( scores, wanted )
1
-1
true
IndexOf returns the first index holding the value, or −1; Contains says whether there is one.
Both are free procedures, not members, and that is deliberate. They need = on the element
type, and every member of a generic type is compiled for every element type it is used with — so a
member IndexOf would make an Array of any type without = impossible to declare. As free
procedures, the requirement lands only on the calls that make it.
Their element type is inferred from the arguments, exactly, with no conversions:
#import "std/containers"
dim scores as containers.Array( of int32 ) = { 70, 85, 90 }
print containers.IndexOf( scores, 85 ) '' 85 is an integer, not int32
indexofliteral.tiko(4,25): error TKO0809: cannot infer the type arguments of 'indexof'; write them, as 'indexof( of ... )( ... )'
85 is an integer and the array holds int32s, so no single T fits both. Pass a variable of
the element type, as indexof.tiko does, or write the type: containers.IndexOf( of int32 )( scores, 85 ).
Searching a sorted array
IndexOf looks at every element until it finds one. When the array is sorted, a binary search
halves the range at each step instead — about 20 looks for a million elements:
#import "std/containers"
'' A sorted array answers "where?" by halving, not by scanning.
dim xs as containers.Array( of int32 ) = { 12, 15, 15, 15, 22, 30 }
dim as int32 v = 15, hit = 22, miss = 20
print "first 15 at"; containers.LowerBound( xs, v )
print "after the 15s:"; containers.UpperBound( xs, v )
print "how many 15s:"; _
containers.UpperBound( xs, v ) - containers.LowerBound( xs, v )
print "22 found at"; containers.BinarySearch( xs, hit )
dim r as int32 = containers.BinarySearch( xs, miss )
print "20 gives "; r; ", so it belongs at"; -( r + 1 )
first 15 at 1
after the 15s: 4
how many 15s: 3
22 found at 4
20 gives -5, so it belongs at 4
| Procedure | Returns |
|---|---|
LowerBound( xs, v ) | the first index whose element is not less than v: where v would go, before any equal elements |
UpperBound( xs, v ) | the first index whose element is greater than v: just past the last equal one |
BinarySearch( xs, v ) | the index of the first element equal to v; when there is none, -( insertion point ) - 1 |
PartitionPoint( xs, pred ) | the first index where pred is false, for an array where every true comes first |
UpperBound minus LowerBound counts the copies of a value. A miss in BinarySearch is always
negative and still says where the value belongs: -( r + 1 ) turns it back into the index at which
an Insert keeps the array sorted.
All four require the array sorted by the same order they search with; on an unsorted array
they return some index without complaint. They infer T from both arguments exactly, as IndexOf
does, which is why the listing names v, hit and miss as int32 variables. Each has a
...By twin taking a comparer, for an array sorted with SortBy.
Hashed containers
Map( of K, V )
A Map stores values under keys, and finds the value for a key in constant time on average:
#import "std/containers"
dim text( 0 to 8 ) as ustring = _
{ "to", "be", "or", "not", "to", "be", "that", "is", "it" }
dim counts as containers.Map( of ustring, int32 )
for each word in text
counts[ word ] += 1 '' a missing key starts at 0
next
print counts.Count( ); " distinct words"
'' TryGet reads without inserting.
dim as int32 n
if counts.TryGet( "be", n ) then print "be:"; n
if counts.TryGet( "maybe", n ) = false then print "maybe: absent"
'' A Map has no order; sort the keys to print them in one.
var keys = counts.Keys( )
containers.Sort( keys )
for each k in keys
print k; "="; counts[ k ]; " ";
next
print
7 distinct words
be: 2
maybe: absent
be= 2 is= 1 it= 1 not= 1 or= 1 that= 1 to= 2
| Member | Does |
|---|---|
m[k] | the value for k, which can be assigned — inserting a default value if k is absent |
TryGet( k, v ) | true and the value in v if k is present; inserts nothing |
Contains( k ) | whether k is present; inserts nothing |
Add( k, v ) | inserts only if k is absent |
Put( k, v ) | inserts, or replaces the value of an existing key |
Remove( k ) | true if something was removed |
Keys( ), Values( ) | the keys or values, copied into an Array |
Pairs( ) | (key, value) tuples for for each |
counts[ word ] += 1 works on the first sighting of a word because a missing key is inserted with
the value type's default, 0, before the += 1. That is the reason the indexer inserts. The cost of
that convenience is the next listing.
A Map keeps no order: for each visits its entries in whatever order the table holds them,
and that order changes as the map grows. To print in a stable order, take Keys( ) and sort it,
as wordcount.tiko does — or use an OrderedMap, below.
Reading a Map with [ ] inserts every key it does not find.
#import "std/containers"
dim stock as containers.Map( of ustring, int32 )
stock[ "apples" ] = 12
stock[ "pears" ] = 0
'' Reading through [ ] looks harmless...
if stock[ "plums" ] = 0 then print "no plums"
if stock[ "kiwis" ] = 0 then print "no kiwis"
'' ...but each miss inserted a zero.
print "items in stock map:"; stock.Count( )
no plums
no kiwis
items in stock map: 4
Two items were stocked; the two ifs asked about two more, and each question inserted a zero. The
program printed the right answers and quietly made the map hold four entries — so a later
Count( ), a for each over the stock, or a report of "every fruit we carry" is now wrong. Use
[ ] to write, and TryGet or Contains to read.
Add and Put differ in what happens when the key is already there:
#import "std/containers"
dim ages as containers.Map( of ustring, int32 )
print ages.Add( "ada", 36 ) '' true: inserted
print ages.Add( "ada", 99 ) '' false: already there, unchanged
print ages[ "ada" ]
print ages.Put( "ada", 37 ) '' false: was there, now replaced
print ages[ "ada" ]
print ages.Remove( "ada" ); ages.Count( )
true
false
36
false
37
true 0
Add refuses to replace and says so; Put replaces and tells you whether the key was new.
Set( of T )
A Set holds each value at most once and answers "is it in?" in constant time on average:
#import "std/containers"
sub Show( byref label as ustring, _
byref s as containers.Set( of int32 ) )
var items = s.Items( ) '' a Set has no order either
containers.Sort( items )
print label; ":";
for each x in items
print x;
next
print
end sub
dim evens as containers.Set( of int32 ) = { 2, 4, 6, 8 }
dim small as containers.Set( of int32 ) = { 1, 2, 3, 4 }
print "add 4 again: "; evens.Add( 4 ) '' false: already present
dim both as containers.Set( of int32 ) = evens
both.IntersectWith( small )
Show( "evens and small", both )
dim either as containers.Set( of int32 ) = evens
either.UnionWith( small )
Show( "evens or small", either )
dim onlyEven as containers.Set( of int32 ) = evens
onlyEven.ExceptWith( small )
Show( "evens not small", onlyEven )
add 4 again: false
evens and small: 2 4
evens or small: 1 2 3 4 6 8
evens not small: 6 8
Add returns false for a value already present. IntersectWith, UnionWith and ExceptWith
change the set they are called on, so the listing copies evens first — copying a Set, like
every container here, copies its contents. Like a Map, a Set has no order; Items( ) gives an
Array to sort.
Keys of your own
Every built-in type can be a key. A record type needs two things: =, to say when two keys are
the same, and a hash — a number computed from the key, which decides where in the table it is
stored:
#import "std/containers"
'' A key extends Hashable and overrides HashOf( ). Hash only what
'' '=' compares: equal keys must hash equally.
type Cell extends containers.Hashable
row as int32
col as int32
declare constructor( byval row as int32 = 0, byval col as int32 = 0 )
declare function HashOf( ) as uint64 override
end type
constructor Cell( byval row as int32, byval col as int32 )
this.row = row : this.col = col
end constructor
function Cell.HashOf( ) as uint64
return containers.HashInt( containers.HashOf( row ) ) xor containers.HashOf( col )
end function
'' ...and '='.
operator = ( byref a as Cell, byref b as Cell ) as boolean
return ( a.row = b.row ) andalso ( a.col = b.col )
end operator
dim terrain as containers.Map( of Cell, ustring )
dim spot as Cell = Cell( 2, 3 )
terrain[ spot ] = "forest"
dim probe as Cell = Cell( 2, 3 ) '' a different variable
dim found as ustring
if terrain.TryGet( probe, found ) then print "( 2, 3 ) is "; found
( 2, 3 ) is forest
probe is a different variable from spot, with the same contents, and the map finds it: the two
are equal, so they hash equally and land in the same place. The hash is the key's own HashOf( ),
an override of the abstract one in containers.Hashable, which every key type extends; the module's
containers.HashOf already has an overload for every built-in type, which is what
containers.HashOf( row ) calls. containers.HashInt scrambles the row's hash before the two
are combined with xor, so that the row and the column are not interchangeable: ( 2, 3 ) and
( 3, 2 ) get different hashes.
One rule makes it work: equal keys must produce equal hashes. Compute the hash from exactly the
fields that = compares. A hash that included a field = ignores would put two equal keys in
different places, and the map would lose track of entries without any error.
Ordered containers
OrderedMap and OrderedSet keep their contents sorted. They are trees rather than tables, so
each operation costs O(log n) rather than O(1) — and in exchange they can answer questions about
order that a hashed container cannot:
#import "std/containers"
'' An OrderedMap keeps its keys sorted: here, scores.
dim board as containers.OrderedMap( of int32, ustring )
board.Put( 120, "ada" )
board.Put( 95, "alan" )
board.Put( 140, "grace" )
board.Put( 110, "edsger" )
for each (score, who) in board.Pairs( ) '' always in key order
print score; " "; who
next
dim as int32 k
dim as ustring who
if board.TryFirst( k, who ) then print "lowest:"; k; " "; who
if board.TryLast( k, who ) then print "highest:"; k; " "; who
'' The neighbours of a value that is not a key.
if board.TryFloor( 130, k ) then print "best at or below 130:"; k
if board.TryCeiling( 130, k ) then print "first at or above 130:"; k
print "anything at or above 200? "; board.TryCeiling( 200, k )
95 alan
110 edsger
120 ada
140 grace
lowest: 95 alan
highest: 140 grace
best at or below 130: 120
first at or above 130: 140
anything at or above 200? false
for each visits the keys from smallest to largest, every time. TryFirst and TryLast give the
smallest and largest entries. TryFloor( k, … ) finds the largest key at or below k, and
TryCeiling the smallest at or above — the nearest neighbours of a value that need not be a
key at all, which is what a schedule, a price ladder or a leaderboard asks. Each returns false
when there is no such key.
An ordered container needs only < on its key type, not a hash. Two keys are treated as the same
when neither is less than the other. OrderedSet( of T ) is the same tree with no values:
Add, Contains, Remove, the set operations, and the four Try neighbours.
Reach for an ordered container when you need the order, not by default: the hashed ones are faster for everything else.
LinkedList( of T )
#import "std/containers"
'' A LinkedList is a queue at both ends.
dim jobs as containers.LinkedList( of ustring )
jobs.PushBack( "build" )
jobs.PushBack( "test" )
jobs.PushFront( "fetch" ) '' jumps the queue
print "next: "; jobs.Front( ); ", last: "; jobs.Back( )
do until jobs.IsEmpty( )
print "doing "; jobs.PopFront( )
loop
print "empty: "; jobs.IsEmpty( )
next: fetch, last: test
doing fetch
doing build
doing test
empty: true
A LinkedList adds and removes at both ends in constant time: PushBack, PushFront,
PopBack, PopFront, with Front( ) and Back( ) to look without removing. That makes it a
queue, a stack, or both at once.
Popping an empty list returns the element type's default value — 0, or an empty string — rather
than an error, so test IsEmpty( ) first when the difference matters, as the loop above does. A
LinkedList has no index: reaching the middle means walking to it.
Deque( of T )
A Deque — say "deck", short for double-ended queue — does what a LinkedList does at both ends,
and adds what it cannot: d[ i ] reaches any element in constant time.
#import "std/containers"
'' The last three readings: in at the back, out at the front.
dim recent as containers.Deque( of int32 )
dim readings( 0 to 5 ) as int32 = { 20, 22, 21, 25, 30, 28 }
for i as int32 = 0 to 5
recent.PushBack( readings( i ) )
if recent.Count( ) > 3 then recent.PopFront( )
dim total as int32 = 0
for each r in recent
total += r
next
print "reading"; readings( i ); ": oldest"; recent[ 0 ]; _
", average"; total \ recent.Count( )
next
recent.PushFront( 99 )
print "front"; recent.Front( ); ", back"; recent.Back( )
reading 20: oldest 20, average 20
reading 22: oldest 20, average 21
reading 21: oldest 20, average 21
reading 25: oldest 22, average 22
reading 30: oldest 21, average 25
reading 28: oldest 25, average 27
front 99, back 28
The listing keeps a sliding window: each reading goes in at the back, and once there are more
than three the oldest comes off the front. recent[ 0 ] is always the front, and for each walks
front to back.
A Deque keeps its elements in one block of memory used as a ring: the front can sit anywhere in
the block, and the elements wrap round its end. When the block fills, its size doubles. So
PushBack, PushFront, PopBack and PopFront each cost a constant amount on average, nothing
is allocated per element, and a walk reads one contiguous block, as it does for an Array. Prefer it to a
LinkedList for queues; keep the LinkedList for the one thing the ring cannot give, an iterator
that stays valid while you add elsewhere.
The Pop forms return a default value on an empty deque, as LinkedList's do; TryPopFront( v )
and TryPopBack( v ) return false instead. d[ i ], Front( ) and Back( ) need an index from
0 to Count( ) - 1, and the deque does not grow to make a bad one valid.
PriorityQueue( of T )
A queue serves in arrival order. A priority queue serves whatever matters most first, however late it arrived:
#import "std/containers"
type Job
priority as int32
name as ustring
end type
'' Does a leave the queue before b? Lower number = more urgent.
type JobFirst as function( byref a as Job, byref b as Job ) as boolean
dim urgent as JobFirst = function( byref a as Job, _
byref b as Job ) as boolean
return a.priority < b.priority
end function
dim queue as containers.PriorityQueueBy( of Job, JobFirst ) = _
containers.PriorityQueueBy( of Job, JobFirst )( urgent )
dim j as Job
j.priority = 3 : j.name = "tidy logs" : queue.Push( j )
j.priority = 1 : j.name = "restart db" : queue.Push( j )
j.priority = 2 : j.name = "rotate keys" : queue.Push( j )
print "next up: "; queue.Peek( ).name
j.priority = 0 : j.name = "page on-call" : queue.Push( j )
do until queue.IsEmpty( )
j = queue.Pop( )
print j.priority; " "; j.name
loop
next up: restart db
0 page on-call
1 restart db
2 rotate keys
3 tidy logs
PriorityQueue( of T ) hands back its smallest element first, by T's own <. For any other
order — largest first, or by one field of a record, as here — PriorityQueueBy( of T, TLess )
takes a comparer when it is constructed: less( a, b ) is true when a must leave before b,
the question a SortBy comparer answers. The second type argument is the comparer's type. A plain
lambda or @MyProc has a procedure-pointer type, which the listing names JobFirst; a capturing
lambda's type is written typeof( name ).
Push and Pop cost O(log n), and Peek looks at the next element without removing it.
MakeHeap( xs ) builds a queue from an Array in O(n), cheaper than pushing the elements one at a
time. Two elements that compare equal leave in no particular order: give records a sequence number
and compare on it second when the order among equals matters.
for each over a priority queue is not priority order.
#import "std/containers"
dim q as containers.PriorityQueue( of int32 )
dim nums( 0 to 4 ) as int32 = { 5, 1, 4, 2, 3 }
for i as int32 = 0 to 4
q.Push( nums( i ) )
next
print "for each:";
for each n in q
print n;
next
print
print "popping:";
do until q.IsEmpty( )
print q.Pop( );
loop
print
for each: 1 2 4 5 3
popping: 1 2 3 4 5
The queue is a binary heap: an array arranged so that each element is no greater than the two
below it. That arrangement is what makes Push and Pop cheap, and it is not sorted — so a loop
that prints the queue, or takes "the first three" from it, gets heap order, which looks almost
right. Take elements in priority order with Pop, or call ToSortedArray( ), which sorts a copy
and leaves the queue as it was.
BitSet
A BitSet is a set of small whole numbers stored one bit each, so a million flags fit in 125 KB
and testing one costs a shift and a mask:
#import "std/containers"
const LIMIT = 50
'' Sieve of Eratosthenes: one bit per number below LIMIT.
dim composite as containers.BitSet = containers.BitSet( LIMIT )
for n as int32 = 2 to LIMIT - 1
if composite.Test( n ) = false then
for m as int32 = n * n to LIMIT - 1 step n
composite.Set( m )
next
end if
next
dim primes as containers.BitSet
for n as int32 = 2 to LIMIT - 1
if composite.Test( n ) = false then primes.Set( n )
next
print "primes below"; LIMIT; ":"; primes.CountOnes( )
for each p in primes
print p;
next
print
'' Set algebra, one machine word at a time.
dim odd as containers.BitSet
for n as int32 = 1 to LIMIT - 1 step 2
odd.Set( n )
next
dim even as containers.BitSet = primes
even.DifferenceWith( odd )
print "even primes:"; even.CountOnes( ); _
", the first is"; even.FirstSet( )
primes below 50: 15
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47
even primes: 1, the first is 2
Set( i ) grows the set when i is beyond its Length( ), and Test of a bit beyond the length
is false, so a BitSet can start empty; BitSet( n ) starts with n clear bits instead.
Length( ) counts addressable bits, CountOnes( ) the ones that are set. for each visits the
set bits in ascending order; FirstSet( from ) and NextClear( from ) find the next set or clear
bit.
UnionWith, IntersectWith, DifferenceWith and SymmetricDifferenceWith work 64 bits at a time
and change the set they are called on — which is why the listing copies primes before taking the
odd numbers away. Compare two sets with a.Equals( b ): it compares the bits that are set, so sets
of different lengths can be equal.
Filling a container from a list
Every container but BitSet takes a brace list when it is declared, one entry per element:
#import "std/containers"
'' A brace list fills a container as it is declared.
dim primes as containers.Array( of int32 ) = { 2, 3, 5, 7 }
dim tags as containers.Set( of ustring ) = { "new", "sale" }
dim route as containers.LinkedList( of ustring ) = _
{ "home", "shop", "home" }
'' A map takes one { key, value } pair per entry.
dim ages as containers.OrderedMap( of ustring, int32 ) = _
{ { "ada", 36 }, { "alan", 41 }, { "grace", 85 } }
print primes.Count( ); tags.Count( ); route.Count( ); ages.Count( )
for each (who, age) in ages.Pairs( )
print who; age
next
4 2 3 3
ada 36
alan 41
grace 85
A map's entries are { key, value } pairs inside the outer braces. The list is a convenience for
starting values; the container is an ordinary one afterwards.
Sorting
#import "std/containers"
type Player
name as ustring
team as ustring
points as int32
end type
sub Show( byref ps as containers.Array( of Player ) )
for each p in ps
print p.team; " "; p.name; p.points
next
print "--"
end sub
dim ps as containers.Array( of Player )
dim p as Player
p.name = "ada" : p.team = "red" : p.points = 31 : ps.Push( p )
p.name = "bob" : p.team = "blue" : p.points = 27 : ps.Push( p )
p.name = "cy" : p.team = "red" : p.points = 27 : ps.Push( p )
p.name = "dee" : p.team = "blue" : p.points = 35 : ps.Push( p )
'' Best first: a comparer says whether a goes before b.
containers.SortBy( ps, function( byref a as Player, _
byref b as Player ) as boolean
return a.points > b.points
end function )
Show( ps )
'' Then by team, STABLY: within a team, best-first is kept.
containers.StableSortBy( ps, function( byref a as Player, _
byref b as Player ) as boolean
return a.team < b.team
end function )
Show( ps )
dim nums as containers.Array( of int32 ) = { 9, 4, 7 }
print containers.IsSorted( nums );
containers.Sort( nums )
print containers.IsSorted( nums ); nums[0]; nums[1]; nums[2]
blue dee 35
red ada 31
blue bob 27
red cy 27
--
blue dee 35
blue bob 27
red ada 31
red cy 27
--
falsetrue 4 7 9
| Procedure | Sorts | Stable | Order by |
|---|---|---|---|
Sort( xs ) | an Array, in place | no | < on the element |
SortBy( xs, less ) | an Array, in place | no | a comparer |
StableSort( xs ), StableSortBy( xs, less ) | an Array, in place | yes | <, or a comparer |
SortList( l ), SortListBy( l, less ) | a LinkedList | yes | <, or a comparer |
IsSorted( xs ), IsSortedBy( xs, less ) | — | — | answers whether it is |
A comparer answers "does a go before b?", as Chapter 21 described. A stable sort keeps
elements that compare equal in the order they arrived. The listing sorts by points first, then by
team stably — so within each team, the best-first order from the first sort survives. With the
unstable SortBy for the second step, the order within a team would be whatever the algorithm
left.
Sort is an introsort: O(n log n) in the worst case, in place, not stable. StableSort is a merge
sort: O(n log n) too, stable, and it needs a scratch buffer as large as the array. Both move
elements by swapping, so sorting an array of strings or records copies no element.
Rearranging in place
Often a full sort answers more than the question asked. The three fastest times, or the median, need only part of the order:
#import "std/containers"
dim times as containers.Array( of int32 ) = _
{ 41, 17, 88, 23, 17, 65, 9, 52, 23 }
'' The three fastest, in order; the rest are left unsorted.
dim fast as containers.Array( of int32 ) = times
containers.PartialSort( fast, 3 )
print "fastest:"; fast[ 0 ]; fast[ 1 ]; fast[ 2 ]
'' The median, without sorting everything.
dim middle as containers.Array( of int32 ) = times
dim k as int32 = middle.Count( ) \ 2
containers.NthElement( middle, k )
print "median:"; middle[ k ]
'' Distinct values: sort, then drop neighbours that repeat.
dim distinct as containers.Array( of int32 ) = times
containers.Sort( distinct )
print "repeats removed:"; containers.Dedup( distinct )
print "distinct values:"; distinct.Count( )
'' A turn order that moves along one seat each round.
dim seats as containers.Array( of ustring ) = { "ana", "ben", "cai" }
for r as int32 = 1 to 3
print "round"; r; ": "; seats[ 0 ]; " starts"
containers.RotateArray( seats, 1 )
next
fastest: 9 17 17
median: 23
repeats removed: 2
distinct values: 7
round 1: ana starts
round 2: ben starts
round 3: cai starts
| Procedure | Leaves the array | Cost |
|---|---|---|
PartialSort( xs, k ) | the k smallest at the front, sorted; the rest in no order | O(n log k) |
NthElement( xs, k ) | at xs[k] what a full sort would put there; nothing greater before it, nothing less after | O(n) on average |
Dedup( xs ) | without consecutive repeats; returns how many it removed | O(n) |
ReverseArray( xs ), RotateArray( xs, k ) | reversed; rotated left by k | O(n) |
NextPermutation( xs ) | the next ordering in sorted order; false after the last | O(n) |
MinIndex( xs ), MaxIndex( xs ) | unchanged; the index of the smallest or largest, or -1 | O(n) |
Dedup removes runs, so Sort first when every repeat should go. Each procedure that compares has
a ...By twin taking a comparer, and every move is a swap, as in the sorts.
ReverseArray and RotateArray carry the type in their names because std/strings has its own
Reverse and std/numeric its own Rotate: a program that brings in both modules with using
would find the bare names ambiguous.
Choosing a container
| Container | Find by key or value | Add | Remove | Order | Use it for |
|---|---|---|---|---|---|
Array | O(n) search, O(1) by index | O(1) at the end | O(n), or O(1) with RemoveSwap | insertion | most lists |
Map | O(1) average | O(1) average | O(1) average | none | lookup by key |
Set | O(1) average | O(1) average | O(1) average | none | membership, no duplicates |
OrderedMap | O(log n) | O(log n) | O(log n) | sorted by key | sorted lookup, nearest key |
OrderedSet | O(log n) | O(log n) | O(log n) | sorted | sorted unique values |
LinkedList | O(n) | O(1) at either end | O(1) at either end | insertion | iterators that survive adds |
Deque | O(n) search, O(1) by index | O(1) at either end | O(1) at either end | insertion | queues, sliding windows |
PriorityQueue | O(1), the smallest only | O(log n) | O(log n), the smallest only | by priority, through Pop | scheduling, "most urgent next" |
BitSet | O(1) | O(1) | O(1) | ascending | flags, sets of small numbers |
When in doubt, start with an Array. It is the most compact and the fastest to walk, and it is
the right answer more often than its O(n) search suggests: for a few dozen elements, a linear scan
over contiguous memory is cheap, and can cost less than hashing the key.
What invalidates a loop
Chapter 20's rule holds here, container by container. Changing a container's structure while a
for each walks it is not allowed, and nothing detects it:
Array:Push,Insert,Remove,RemoveSwap,Clear,Reserve,Shrinkand a sort may move every element. Assigning toxs[i], or through afor each byrefvariable, does not.Map,Set: adding can rehash the whole table, and removing invalidates the walk. ASetelement must never be changed in place, even throughbyref: its hash changes, and it can no longer be found.OrderedMap,OrderedSet: adding or removing changes the tree under the loop.LinkedList: adding at either end leaves a walk valid; removing the node being visited does not.Deque,PriorityQueue: any push or pop may move every element.BitSet: setting a bit at or beyondLength( )grows the set under the loop.
Collect the changes in a second container during the loop, and apply them after it.
What it costs
- Every container copies its contents when it is copied. A
byvalparameter or an assignment of a large container is a large copy; passbyref. - A hashed container trades memory for speed: its table has spare room so that lookups stay constant-time.
- An ordered container allocates per element, a node at a time, and pays O(log n) for order.
Keys( ),Values( )andItems( )build a newArrayevery time they are called.
Summary
#import "std/containers".Array,Map,Set,OrderedMap,OrderedSet,LinkedList.Array: zero-based,Push/Popamortised O(1),Insert/RemoveO(n),RemoveSwapO(1),Reserveto pre-size.IndexOfandContainsare free procedures, and inferTexactly — a bare literal does not match anint32array (TKO0809).Map:m[k]inserts on a miss — read withTryGet/Contains.Addwill not replace;Putwill. No order: sortKeys( )to print.Set: unique values, withIntersectWith,UnionWith,ExceptWith.- A custom key needs
=, and extendscontainers.Hashablewith aHashOf( )override; equal keys must hash equally. OrderedMap/OrderedSet: sorted, O(log n),TryFirst/TryLast/TryFloor/TryCeiling; need only<.LinkedList: O(1) at both ends; popping an empty list returns a default value.Deque: O(1) at both ends and by index — the queue to reach for first.PriorityQueue: smallest first, or any order withPriorityQueueByand a comparer.for eachis heap order, not priority order: usePoporToSortedArray( ).BitSet: one bit per number, grows onSet, set algebra a word at a time,Equalsto compare.- On a sorted array,
LowerBound,UpperBoundandBinarySearchfind in O(log n).PartialSortandNthElementanswer "the k smallest" and "the median" without a full sort. - Brace lists fill any container but
BitSetat its declaration; a map takes{ key, value }pairs. Sort/SortByare fast and unstable;StableSort/StableSortBykeep equal elements in order.- Do not change a container's structure inside a
for eachover it.
Exercises
- Count the words of a text in a
Mapand print the three most frequent, ties broken alphabetically so the output never varies. {solution="ex24-01"} - Keep the booked appointment times in an
OrderedSet. For each requested time, report whether it is booked, and if not, how long it is free until. {solution="ex24-02"} - Rewrite
mapgrows.tikoso it prints the same messages and the map still holds two entries. - Remove every odd number from an
Array( of int32 )without changing the array while walking it. Then do it again withRemoveSwap, walking from the end. Which keeps the order? - Give
Cella third field,label, that=ignores. What mustHashOfstill use, and what goes wrong if it also hasheslabel? - Change
window.tikoto keep the last five readings and also print the largest reading in the window each time.MaxIndextakes anArray, not aDeque: decide whether to copy the window or walk it. - Give
Joba sequence number and makeJobFirstbreak ties with it, so two jobs of equal priority leave in the order they arrived.
Files, Processes and the Environment
Almost every useful program reads something it did not make and leaves something behind: a configuration file, a log, a folder of results, an exit code a build script checks. This chapter is about that boundary — the files a program reads and writes, the directories around them, the console it talks through, the other programs it starts, and the machines it talks to over the network.
Files are the std/fs module: an open File and the streams layered on it, a path algebra, a
single Stat call for every fact about a file, directory walks with for each, whole-file reads
and writes, and the directories a program keeps its own files in. Configuration files in TOML and
INI are std/config; addresses, sockets and URLs are std/net. Every operation that can fail
returns a Result (Chapter 18) rather than raising a runtime error, so a missing file is a value
the program looks at, not a crash.
#import "std/fs"
Files and streams
Text files
A File is opened in place and closes itself when it goes out of scope. A TextWriter
writes text to it, and ReadLines reads a file back a line at a time with for each:
#import "std/fs"
'' Write a text file, add to it, read it back a line at a time.
scope
dim f as fs.File
f.Create( "notes.txt" ) '' creates, or empties, the file
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "first line" )
w.WriteLine( "second line" )
end scope '' the file closes here
scope
dim f as fs.File
f.OpenAppend( "notes.txt" ) '' keeps what is there
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "third line" )
end scope
dim as int32 n = 0
for each ln in fs.ReadLines( "notes.txt" )
n += 1
print n; ": "; ln.Value( )
next
fs.RemoveFile( "notes.txt" )
1: first line
2: second line
3: third line
Create makes the file, or empties one that is there; OpenAppend keeps what is there and writes
at the end; OpenRead opens for reading only. The scope blocks are where each File ends: its
destructor closes the handle, and the TextWriter declared after it is destroyed first, so nothing
is left unwritten. A File cannot be copied — pass it byref.
WriteLine ends each line the way the platform does: carriage return and line feed on Windows,
line feed alone on Linux. Set the writer's NewLine to chr( 10 ) for files that must be the
same bytes everywhere. ReadLines accepts either ending, removes it, and yields one
Result( of ustring, FsError ) per line — ln.Value( ) is the text. It holds one line in memory at
a time, whatever the size of the file, and leaving the loop early closes the file.
Text is UTF-8 unless you say otherwise, and a TextReader and ReadLines also honour a byte-order
mark at the start of a file, for UTF-8 and UTF-16 text written by other programs.
Text in other encodings
Not every file is UTF-8. Windows tools save UTF-16, and older programs save in a code page such as Windows-1252, one byte per character, in which the same letters are different bytes. Every text call in std/fs takes an encoding as an extra, last argument:
#import "std/fs"
'' Two programs saved the same name: one in Windows-1252, one in UTF-16.
dim as ustring name_ = "M" & wchr( &hFC ) & "ller" '' u with an umlaut
fs.WriteAllText( "legacy.txt", name_, fs.CodePage( 1252 ) )
fs.WriteAllText( "unicode.txt", name_, fs.Utf16LE( ) )
'' Read both without knowing which is which: a BOM, else UTF-8 if the
'' bytes are valid UTF-8, else the fallback named here.
dim as fs.TextDetect guess = fs.TextDetect( fs.CodePage( 1252 ) )
dim as fs.TextEncoding found
dim as ustring a = fs.ReadAllText( "legacy.txt", found, guess ).Value( )
print "legacy.txt: "; found.Name( )
dim as ustring u = fs.ReadAllText( "unicode.txt", found, guess ).Value( )
print "unicode.txt: "; found.Name( )
print "the same text: "; iif( a = u, "yes", "no" )
'' Windows-1252 has no arrow, so writing one is refused, and the
'' file is left as it was.
var r = fs.WriteAllText( "legacy.txt", name_ & wchr( &h2192 ), _
fs.CodePage( 1252 ) )
print "an arrow in 1252: "; iif( r.IsOk( ), "written", "refused" )
dim as ustring now_ = _
fs.ReadAllText( "legacy.txt", fs.CodePage( 1252 ) ).Value( )
print "legacy.txt unchanged: "; iif( now_ = name_, "yes", "no" )
fs.RemoveFile( "legacy.txt" )
fs.RemoveFile( "unicode.txt" )
legacy.txt: windows-1252
unicode.txt: utf-16le
the same text: yes
an arrow in 1252: refused
legacy.txt unchanged: yes
An encoding is a TextEncoding. Utf8( ) is the default; Utf16LE( ) and Utf16BE( ) write a
byte-order mark unless passed false; CodePage( n ) is a Windows code page by number, and
Ansi( ) the one the machine is set to, on Windows only; BinaryMode( ) reads byte n as character
n, so any file reads and writes back unchanged. ReadAllText, ReadAllLines, WriteAllText,
AppendAllText, WriteAtomic, ReadLines, TextReader and TextWriter all take one, and so do
Stream.WriteText and MemoryStream.ToText for bytes in memory.
A read with an encoding believes it: a byte-order mark that matches is skipped, and any other
bytes are text. Bytes that are not valid in the encoding become U+FFFD, or fail under TEXT_STRICT.
When the encoding is not known, pass a TextDetect instead, as the listing does: a byte-order mark
decides, else UTF-8 if the bytes are valid UTF-8, else the fallback it names, and found says which
was used. A file that starts with a UTF-32 mark is refused with FS_UNSUPPORTED, never misread.
A write is strict by default. A character the encoding cannot hold fails with
FS_INVALID_DATA before the file is opened, so the file keeps what it had. Pass TEXT_REPLACE to
write a ? in its place instead; the write then succeeds and its value is false, meaning "written,
but not exactly". AppendAllText with an encoding refuses to add UTF-16 to a file that starts with
a UTF-8 mark, or the other way round.
Opening a file
Every open returns a Result( of boolean, FsError ), and the error says why:
#import "std/fs"
'' Every way of opening a file can fail, and says why.
dim f as fs.File
var r = f.OpenRead( "missing.txt" )
print "open missing: "; r.IsOk( ); ", not found: "; _
cbool( r.Failure( ).Code( ) = fs.FS_NOT_FOUND )
'' CreateNew refuses a file that is already there.
dim o as fs.OpenOptions
o.Write = true
o.CreateNew = true
print "first create: "; f.Open( "once.txt", o ).IsOk( )
f.Close( )
var again = f.Open( "once.txt", o )
print "second create: "; again.IsOk( ); ", exists: "; _
cbool( again.Failure( ).Code( ) = fs.FS_ALREADY_EXISTS )
'' A File refuses what it was not opened for.
f.OpenRead( "once.txt" )
var w = f.WriteUtf8( "x" )
print "write to a read-only File: "; w.IsOk( )
f.Close( )
fs.RemoveFile( "once.txt" )
open missing: false, not found: true
first create: true
second create: false, exists: true
write to a read-only File: false
| Method | Opens for | An existing file is | A missing file |
|---|---|---|---|
OpenRead( p ) | reading | read | fails, FS_NOT_FOUND |
Create( p ) | writing | emptied | created |
CreateNew( p ) | writing | refused, FS_ALREADY_EXISTS | created |
OpenAppend( p ) | writing at the end | kept | created |
Open( p, options ) | what OpenOptions says | as the options say | as the options say |
An OpenOptions names what the program needs: Read, Write, Append, Truncate, Create and
CreateNew. A combination that makes no sense — Append with Truncate, CreateNew without
Write — is refused before anything is opened. An operation the File was not opened for fails
too: writing to a file opened for reading is a failure, not a silent nothing.
Other programs may still read, write, rename and delete a file you have open; on Windows the
ShareRead, ShareWrite and ShareDelete options narrow that. A handle is not passed to the
programs this one starts unless Inheritable is set.
Fields in a line
The simplest file format a program can save and read again is one record per line with its fields separated by commas:
#import "std/fs"
#import "std/strings"
'' One record per line, fields separated by commas. A field that holds
'' a comma is quoted, and SplitCsvRow takes the quotes off again.
dim f as fs.File
f.Create( "people.csv" )
scope
dim w as fs.TextWriter = fs.TextWriter( f )
w.WriteLine( "ada,36,1.5" )
w.WriteLine( """hopper, grace"",85,2" )
end scope
f.Close( )
for each ln in fs.ReadLines( "people.csv" )
var cols = strings.SplitCsvRow( ln.Value( ) )
dim as ustring who = cols[0]
dim as int32 age = valint( cols[1] )
dim as float64 rating = val( cols[2] )
print who; " |"; age; " |"; rating
next
fs.RemoveFile( "people.csv" )
ada | 36 | 1.5
hopper, grace | 85 | 2
A field that contains a comma is written inside double quotes, and strings.SplitCsvRow splits a
line into its fields with the quotes removed, so hopper, grace survives the round trip. valint
and val turn the text fields back into numbers. For anything that other programs must read,
prefer a format both sides agree on.
Records at a position
A file of fixed-size records can be read and written by number, without touching the records
around the one you want. A RecordFile( of T ) is such a file, of records of the type T:
#import "std/fs"
'' A file of fixed-size records, read and written by number.
type Item
id as int32
qty as int32
label as string * 8 '' fixed width: every record the same
end type
dim stock as fs.RecordFile( of Item )
stock.Open( "stock.dat" ) '' read and write; created if missing
dim it as Item
for n as int32 = 1 to 3
it.id = n
it.qty = n * 10
it.label = "part" & n
stock.Append( it ) '' becomes record n
next
print "records:"; stock.Count( ).Value( ); _
", each"; stock.RecordLength( ); " bytes"
stock.GetRecord( 2, it )
print "record 2:"; it.qty; " "; rtrim( it.label )
it.qty -= 5
stock.PutRecord( 2, it )
stock.GetRecord( 2, it )
print "record 2 now:"; it.qty
print "record 4 there? "; stock.GetRecord( 4, it ).Value( )
stock.Close( )
fs.RemoveFile( "stock.dat" )
records: 3, each 16 bytes
record 2: 20 part2
record 2 now: 15
record 4 there? false
Open opens the file for reading and writing and creates it if it is missing; Open( p, options )
takes OpenOptions instead. Records are numbered from 1, and record n begins
( n - 1 ) * sizeof( Item ) bytes into the file. Append writes after the last record and returns
the new record's number, GetRecord and PutRecord read and write record n, Count( ) is the
number of whole records, and for each r in stock walks them, one Result per record. Reading a
record that is not all there — past the end, or a short last record — is not a failure: the value
is false and the record is left untouched. Writing past the end grows the file, and the records
in between read as zeros.
Every field must have a fixed size for this to work, which is why label is a string * 8 rather
than a ustring: a variable-length string is a pointer, and a pointer written to a file means
nothing when it is read back. Nothing checks this for you. The bytes are the record's bytes in
memory, padding included; field = 1 removes the padding.
Underneath are File.ReadAt( position, buffer, count ) and WriteAt, which read and write bytes
at any byte position without moving the file's position, and may be called from several threads on
one File. Use them for a file that mixes records with other data, and ReadRecord( of T ),
WriteRecord( of T ) and RecordCount( of T ) for records in a File you have opened yourself.
Binary formats
A record of fixed-size fields is one kind of binary file. Most binary formats — images, archives, network messages — are laid out byte by byte instead, with each number in a stated width and byte order:
#import "std/codec"
#import "std/containers"
'' A small binary format: a magic number, a version, then a count
'' and the values, each as short as it can be.
dim buf as containers.Array( of ubyte )
codec.AppendU32BE( buf, &h544B4F31 ) '' "TKO1"
codec.AppendU16LE( buf, 2 )
dim values( 0 to 3 ) as int64 = { 5, 300, -2, 1000000 }
codec.AppendUVarint( buf, 4 )
for i as int32 = 0 to 3
codec.AppendVarint( buf, values( i ) )
next
print buf.Count( ); " bytes: "; codec.HexEncode( buf, true )
'' Reading it back, field by field.
print "magic "; hex( codec.GetU32BE( buf, 0 ) )
print "version "; codec.GetU16LE( buf, 4 )
dim at as int32 = 6
dim n as uint64 = codec.ReadUVarint( buf, at ).Value( )
dim shown as ustring = "values"
for i as int32 = 1 to n
shown &= " " & codec.ReadVarint( buf, at ).Value( )
next
print shown
print "read up to byte"; at
14 bytes: 544B4F310200040AD8040380897A
magic 544B4F31
version 2
values 5 300 -2 1000000
read up to byte 14
codec.AppendU32BE appends a 32-bit number big-endian, most significant byte first — the order
a person reads, and the one network protocols use. AppendU16LE writes little-endian, the
order x86 processors keep in memory and most file formats on Windows use. The format decides; the
code must match it field by field, which is why every name carries its width and order: Get,
Put and Append, for U16 to U64, I16 to I64, F32 and F64, each LE or BE. A Get
past the end of the buffer is a run-time error, the same as indexing an array past its end.
The values are varints: each byte carries seven bits and a flag saying whether another byte
follows, so 5 takes one byte and a million three. AppendVarint first folds the sign in, so that
-2 stays as short as 2. ReadUVarint( buf, at ) and ReadVarint read one value at at and move
at past it, which is what lets the loop walk the list without knowing where each value ends.
The buffer is a containers.Array( of ubyte ), so fs.WriteAllBytes and fs.ReadAllBytes move it
to and from a file whole, and MemoryStream and File.Write take its bytes as they are.
Streams
A File is a Stream: something that bytes can be read from, written to, or both. A
MemoryStream is another — a growable buffer in memory — and everything that works on one works on
the other:
#import "std/fs"
'' Build the text in memory, then copy it to a file in one step.
dim m as fs.MemoryStream
scope
dim w as fs.TextWriter = fs.TextWriter( m )
w.NewLine = chr( 10 )
for i as int32 = 1 to 3
w.WriteLine( "line " & i )
next
end scope
print "in memory:"; m.Length( ); " bytes"
m.Seek( 0 ) '' Copy starts at the position
dim f as fs.File
f.Create( "out.txt" )
print "copied:"; fs.Copy( m, f ).Value( ); " bytes"
f.Close( )
'' Read it back through a TextReader, which works on any stream.
f.OpenRead( "out.txt" )
dim r as fs.TextReader = fs.TextReader( f )
dim as ustring ln
do while r.ReadLine( ln ).ValueOr( false )
print "["; ln; "]"
loop
f.Close( )
fs.RemoveFile( "out.txt" )
in memory: 21 bytes
copied: 21 bytes
[line 1]
[line 2]
[line 3]
fs.Copy( source, destination ) copies from the source's current position to its end, which is why
the listing seeks the MemoryStream back to 0 first. A TextReader reads lines from any stream,
here the File; ReadLine returns false when there are no more.
| Type | Is |
|---|---|
File | a file on disk; unbuffered: each Read and Write is one call to the system |
MemoryStream | bytes in memory; Length( ), ToUtf8Text( ) |
BufferedReader, BufferedWriter | a buffer over another stream, so many small reads or writes become a few large ones |
TextReader, TextWriter | text over another stream: ReadLine, WriteLine |
A wrapper keeps a pointer to the stream it was given, so that stream must outlive it — declare the
stream first. Read may return fewer bytes than asked, and Ok( 0 ) means the end; ReadExact,
WriteAll and Copy are the loops that make short reads and writes invisible.
A stream of your own extends fs.Stream and overrides the operations it supports:
#import "std/fs"
#import "std/outcome"
using outcome
'' A stream of your own: it keeps nothing, and counts what passes.
type Counter extends fs.Stream
as int64 bytes = 0
as int64 lines = 0
declare const function CanWrite( ) as boolean override
declare function Write( byval buf as const ubyte ptr, _
byval n as longint ) as fs.FsResultI64 override
end type
const function Counter.CanWrite( ) as boolean
return true
end function
function Counter.Write( byval buf as const ubyte ptr, _
byval n as longint ) as fs.FsResultI64
if ( n < 0 ) orelse ( ( buf = nullptr ) andalso ( n > 0 ) ) then
return fs.FsFail( of longint )( fs.FS_INVALID_ARGUMENT, "Write", "" )
end if
for i as longint = 0 to n - 1
if buf[i] = 10 then this.lines += 1
next
this.bytes += n
return Ok( of longint, fs.FsError )( n )
end function
dim c as Counter
dim w as fs.TextWriter = fs.TextWriter( c )
w.NewLine = chr( 10 )
w.WriteLine( "one" )
w.WriteLine( "two" )
w.Write( "three" )
print c.lines; " lines,"; c.bytes; " bytes"
print "can read: "; c.CanRead( )
2 lines, 13 bytes
can read: false
Read, Write, Seek and Flush are virtual and return a Result. Counter overrides Write
and says so with CanWrite; the operations it does not override fail with FS_UNSUPPORTED, and
CanRead( ) answers false. Anything written for a Stream — TextWriter, Copy, a buffer —
now works with it.
Paths, metadata and directories
Paths
Paths are strings, and std/fs has the functions to take them apart and build them without
touching the disk:
#import "std/fs"
'' The path algebra: functions over strings, no file system involved.
print fs.PathJoin( "data", "2026", "report.txt" )
print fs.PathFileName( "logs/2026/report.tar.gz" )
print fs.PathStem( "report.tar.gz" ); " "; _
fs.PathExtension( "report.tar.gz" )
print fs.PathStem( ".gitignore" ); " ["; _
fs.PathExtension( ".gitignore" ); "]"
print fs.PathParent( "logs/2026/report.txt" )
print fs.PathNormalize( "logs/./2026/../report.txt" )
print fs.PathChangeExtension( "report.txt", ".md" )
On Windows:
data\2026\report.txt
report.tar.gz
report.tar .gz
.gitignore []
logs/2026
logs\report.txt
report.md
On Linux:
data/2026/report.txt
report.tar.gz
report.tar .gz
.gitignore []
logs/2026
logs/report.txt
report.md
| Function | Gives |
|---|---|
PathJoin( a, b, … ) | the parts joined with the platform's separator |
PathParent( p ), PathFileName( p ) | everything before the last separator; the last part |
PathStem( p ), PathExtension( p ) | the file name without, and only, its last extension |
PathNormalize( p ) | . and .. resolved, separators made uniform |
PathChangeExtension( p, ext ) | p with a different extension |
PathIsAbsolute( p ), PathStartsWith( p, prefix ) | tests |
PathRelativeTo( p, base ) | p written relative to base |
The separator is \ on Windows and / on Linux and macOS, and the functions use the platform's own when
they build a path. A name that begins with a dot, .gitignore, has no extension: its stem is the
whole name.
PathJoin with an absolute second part throws the first part away.
#import "std/fs"
'' Build a path inside "uploads" from a name somebody else chose.
dim as ustring folder = "uploads"
dim names( 0 to 2 ) as ustring = _
{ "photo.png", "/etc/passwd", "C:\Windows\win.ini" }
for each n in names
var full = fs.PathJoin( folder, n )
var inside = fs.PathStartsWith( fs.PathNormalize( full ), folder )
print n; " -> "; full; " inside: "; inside
next
On Windows:
photo.png -> uploads\photo.png inside: true
/etc/passwd -> /etc/passwd inside: false
C:\Windows\win.ini -> C:\Windows\win.ini inside: false
On Linux:
photo.png -> uploads/photo.png inside: true
/etc/passwd -> /etc/passwd inside: false
C:\Windows\win.ini -> uploads/C:\Windows\win.ini inside: true
Joining "uploads" with a name a user supplied looks like it keeps the file inside uploads. It
does not: an absolute name replaces the folder, and the program reads or writes wherever the name
points. Which names count as absolute differs by platform — on Linux the Windows-style name is a
harmless file called C:\Windows\win.ini inside uploads; on Windows it escapes. Check
containment after joining, with PathStartsWith on the normalized result, before using a path
built from somebody else's input.
Facts about a file: Stat
#import "std/fs"
fs.WriteAllText( "hello.txt", "hello" & chr( 10 ) )
'' One call, every fact about the file.
var st = fs.Stat( "hello.txt" )
if st.IsOk( ) then
var info = st.Value( )
print "size:"; info.Size( ); ", file: "; info.IsFile( ); _
", directory: "; info.IsDirectory( )
end if
'' A file that is not there is a failure with a reason, not a zero.
var gone = fs.Stat( "no-such-file.txt" )
if gone.IsOk( ) = false then
var code = gone.Failure( ).Code( )
print "missing file: "; _
iif( code = fs.FS_NOT_FOUND, "not found", "other" )
end if
'' The yes/no predicates fold every failure into false.
print fs.Exists( "hello.txt" ); fs.IsFile( "hello.txt" ); _
fs.IsDir( "hello.txt" )
fs.RemoveFile( "hello.txt" )
print fs.Exists( "hello.txt" )
size:6, file: true, directory: false
missing file: not found
truetruefalse
false
fs.Stat( path ) returns a Result holding a FileInfo — size, kind, times, read-only and hidden
flags — gathered in one call. A path that is not there is a failure whose FsError says
FS_NOT_FOUND, which is a different answer from FS_ACCESS_DENIED. Exists, IsFile and
IsDir answer yes or no and fold every failure into false: convenient in an if, and unable to
tell "absent" from "not allowed to look". Use Stat when the difference matters.
Every std/fs operation that can fail returns a Result in the same way. Value( ) on a failure
is a runtime error, so test IsOk( ) first — the habit from Chapter 18.
Directories: walking, globbing, copying
#import "std/fs"
#import "std/containers"
'' A small tree in a fresh temporary directory.
var made = fs.CreateTempDir( "book" )
if made.IsOk( ) = false then end 1
var root = made.Value( )
fs.CreateDirAll( fs.PathJoin( root, "src", "lib" ) )
fs.WriteAllText( fs.PathJoin( root, "readme.txt" ), "read me" )
fs.WriteAllText( fs.PathJoin( root, "src", "main.tiko" ), "print 1" )
fs.WriteAllText( fs.PathJoin( root, "src", "lib", "util.tiko" ), "" )
fs.WriteAllText( fs.PathJoin( root, "src", "lib", "todo.txt" ), "" )
'' Every entry below root, at any depth. Each element is a Result.
dim found as containers.Array( of ustring )
for each r in fs.WalkDir( root )
if r.IsOk( ) then
var e = r.Value( )
found.Push( e.Depth( ) & " " & e.Name( ) & _
iif( e.IsDirectory( ), "/", "" ) )
end if
next
containers.Sort( found ) '' directory order is not defined
for each s in found
print s
next
'' Every .tiko file, at any depth.
dim hits as containers.Array( of ustring )
fs.Glob( fs.PathJoin( root, "**", "*.tiko" ), hits )
containers.Sort( hits )
for each h in hits
print "glob: "; fs.PathFileName( h )
next
print "copied:"; fs.CopyDir( fs.PathJoin( root, "src" ), _
fs.PathJoin( root, "backup" ) ).Value( )
print "removed:"; fs.RemoveDirAll( root ).Value( ); _
", still there: "; fs.Exists( root )
0 readme.txt
0 src/
1 lib/
1 main.tiko
2 todo.txt
2 util.tiko
glob: util.tiko
glob: main.tiko
copied: 5
removed: 12, still there: false
CreateTempDir creates a directory with a fresh, unpredictable name and returns its path — the
safe way to get a scratch area. ReadDir( p ) walks one directory; WalkDir( p ) walks a whole
tree, a directory before its contents, with Depth( ) counting from 0 for the root's own
children. Each element is a Result, because a walk can fail part-way — a directory it may not
open is reported as a failed element, and the walk carries on. Directory order is whatever the
file system gives, so the listing sorts before printing.
Glob( pattern, results ) finds the paths that match a pattern: * and ? within one part of a
path, and ** for any number of directories, including none. CopyDir copies a tree and
RemoveDirAll deletes one; each returns how many objects it handled. RemoveDirAll deletes a
link inside the tree as a link, without following it, and it opens each directory relative to the
one it is already in, so even another program swapping a directory for a link while the delete
runs cannot send it outside the tree. Given a path that is itself a link, it refuses with
FS_NOT_A_DIRECTORY and deletes nothing.
Whole files, written atomically
#import "std/fs"
#import "std/containers"
'' Replace the whole file in one step: a reader sees the old file or
'' the new one, never half of either.
var ok = fs.WriteAtomic( "app.ini", _
"[main]" & chr( 10 ) & "mode=fast" & chr( 10 ) )
print "written: "; ok.IsOk( )
dim lines as containers.Array( of ustring )
var n = fs.ReadAllLines( "app.ini", lines )
print n.Value( ); " lines: "; lines[0]; " / "; lines[1]
print "as text: "; fs.ReadAllText( "app.ini" ).Value( );
var missing = fs.ReadAllText( "nowhere/app.ini" )
if missing.IsOk( ) = false then
var why = missing.Failure( )
print "could not read: "; _
iif( why.Code( ) = fs.FS_NOT_FOUND, "not found", "other" )
end if
fs.RemoveFile( "app.ini" )
written: true
2 lines: [main] / mode=fast
as text: [main]
mode=fast
could not read: not found
ReadAllText reads a file into one ustring; ReadAllLines splits it into an Array, removing
the line endings of either platform; WriteAllText writes one. Text is UTF-8 unless the file
starts with a byte-order mark that says otherwise.
WriteAtomic writes the new content to a temporary file in the same directory and then
replaces the target with it. A program reading the file at the same moment sees the old contents
or the new, never half of each, and a crash during the write leaves the old file intact. That is
the right way to save a configuration or a document. What it does not promise is survival of a
power failure at that instant, which needs the operating system's cooperation beyond any library.
Mapped files, locks and temporary files
#import "std/fs"
fs.WriteAllText( "word.txt", "tiko" )
'' Map the file into memory and change a byte in place.
dim m as fs.MemoryMappedFile
if m.Open( "word.txt", true ).IsOk( ) then
print "mapped"; m.Size( ); " bytes"
m.Data( )[0] = asc( "T" )
m.Flush( )
m.Close( ) '' Data( ) is invalid after this
end if
print fs.ReadAllText( "word.txt" ).Value( )
'' An advisory lock: it binds only programs that also ask for it.
dim guard as fs.FileLock
print "locked: "; guard.Acquire( "word.txt" ).IsOk( ); guard.IsHeld( )
guard.Release( )
print "held after release: "; guard.IsHeld( )
fs.RemoveFile( "word.txt" )
'' A temporary file is created for you, under a fresh name.
var tmp = fs.CreateTempFile( "book" )
print "temp file exists: "; fs.IsFile( tmp.Value( ) )
fs.RemoveFile( tmp.Value( ) ) '' nothing removes it for you
mapped4 bytes
Tiko
locked: truetrue
held after release: false
temp file exists: true
A MemoryMappedFile makes a file's bytes appear in memory: Data( ) is a pointer to them, and a
write through it changes the file. It is the fastest way to read a large file at random, and it is
a raw pointer — invalid after Close( ), and a fault if another program shortens the file while it
is mapped.
A FileLock keeps other cooperating programs away from a file while it is held. It is advisory:
on Linux it binds only programs that also ask for the lock, and the library promises no more than
that on Windows either. CreateTempFile creates a file under a fresh name and returns the path;
nothing deletes it for you.
std/fs also has a FileWatcher, which reports files created, changed, renamed and deleted under
a directory when the program calls its Poll method. Its events depend on the platform and on
timing — a single save can arrive as several events — so treat each batch as a reason to look
again rather than as an exact log.
Where a program keeps its files
#import "std/fs"
'' Where a program keeps its files. Nothing is created: each call says
'' where the directory should be, and the program adds its own name.
var cfg = fs.ConfigDir( )
if cfg.IsOk( ) then
var mine = fs.PathJoin( cfg.Value( ), "myapp", "settings.ini" )
print "settings under the config dir: "; fs.PathIsAbsolute( mine )
end if
print "cache dir found: "; fs.CacheDir( ).IsOk( )
'' The program's own file, however it was started.
var exe = fs.ExecutablePath( ).ValueOr( "" )
print "running: "; fs.PathStem( exe )
'' ~ means the home directory, and only at the start.
var home = fs.HomeDir( ).ValueOr( "" )
print cbool( fs.ExpandHome( "~/notes.txt" ).ValueOr( "" ) = _
fs.PathJoin( home, "notes.txt" ) )
print fs.ExpandHome( "a/~/b" ).ValueOr( "" )
settings under the config dir: true
cache dir found: true
running: userdirs
true
a/~/b
| Function | Windows | Linux | macOS |
|---|---|---|---|
HomeDir( ) | the user's profile folder | $HOME, else the user's account entry | $HOME, else the user's account entry |
ConfigDir( ) | AppData\Roaming | $XDG_CONFIG_HOME, else ~/.config | $XDG_CONFIG_HOME, else ~/Library/Application Support |
DataDir( ) | AppData\Local | $XDG_DATA_HOME, else ~/.local/share | $XDG_DATA_HOME, else ~/Library/Application Support |
CacheDir( ) | AppData\Local | $XDG_CACHE_HOME, else ~/.cache | $XDG_CACHE_HOME, else ~/Library/Caches |
StateDir( ) | AppData\Local | $XDG_STATE_HOME, else ~/.local/state | $XDG_STATE_HOME, else ~/Library/Application Support |
ExecutablePath( ) | the program's own file | the program's own file | the program's own file |
Nothing is created and no program name is added. Each call says where the directory should
be; join your own program's name, and CreateDirAll it before writing there. On Linux and macOS an
XDG_ variable counts only when it holds an absolute path. ExecutablePath( ) is the full path of the
running program however it was started, which command( 0 ) is not — the way to find a file
installed beside the program. ExpandHome replaces a leading ~ with the home directory and leaves
every other path as it is.
Checksums and digests
A digest is a short, fixed-size value computed from any amount of data: change one byte and it
changes. std/hash computes them:
#import "std/hash"
#import "std/fs"
#import "std/codec"
'' One call: the algorithm, then the data.
print "sha256: "; hash.DigestHex( hash.HASH_SHA256, "abc" )
print "crc32: "; hash.DigestHex( hash.HASH_CRC32, "123456789" )
'' In pieces: the same answer as all at once.
dim h as hash.Sha256
h.UpdateText( "a" )
h.UpdateText( "bc" )
print "pieces: "; cbool( h.FinishHex( ) = hash.DigestHex( hash.HASH_SHA256, "abc" ) )
'' A file, read in blocks however large it is.
dim scratch as ustring = fs.CreateTempDir( ).Value( )
dim path as ustring = fs.PathJoin( scratch, "data.txt" )
fs.WriteAllText( path, "abc" )
print "file: "; codec.HexEncode( hash.DigestFile( hash.HASH_SHA256, path ).Value( ) )
fs.RemoveDirAll( scratch )
'' A keyed hash: only someone with the key can produce it.
print "hmac: "; hash.HmacHex( hash.HASH_SHA256, "key", _
"The quick brown fox jumps over the lazy dog" )
sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
crc32: cbf43926
pieces: true
file: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
hmac: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
DigestHex( algorithm, data ) is the one-call form. Every algorithm is also a type that takes its
data in pieces — UpdateText, UpdateBytes, or Update( pointer, count ) — and gives the same
answer as the one call, so a file of any size is hashed a block at a time; DigestFile does that
for you. HmacHex mixes in a key, so only someone holding the key can produce the value: it is how
a server checks that a message came from someone it trusts.
| Algorithms | Use them for |
|---|---|
HASH_CRC32, HASH_CRC32C, HASH_ADLER32 | catching accidental corruption: zip, PNG, network frames |
HASH_FNV1A32, HASH_FNV1A64, HASH_XXHASH64 | fast hashing of keys and large data |
HASH_SHA256, HASH_SHA224, HASH_SHA512, HASH_SHA384 | anything someone might tamper with: downloads, signatures, content addresses |
HASH_MD5, HASH_SHA1 | only to match a format or protocol that already uses them |
The first two rows are not security tools: anyone can make two different files with the same CRC
or FNV value on purpose, and MD5 and SHA-1 have the same weakness. To compare two MACs, use
hash.ConstantTimeEquals, which takes the same time whatever the first differing byte is.
Configuration files and CSV
A program that people install needs settings they can edit, and a program that produces data is
often asked for a spreadsheet. std/config reads and writes the two configuration formats most
programs meet, TOML and INI; the CSV writer sits in std/strings, beside SplitCsv.
Reading TOML
TOML is a configuration format with real types: strings, integers, floats, booleans, dates and
times, arrays, and tables that nest. config.ParseToml turns the text into a tree of
TomlValues, and config.LoadToml( path ) does the same for a file:
#import "std/config"
dim doc as ustring = """
# the service's settings
title = "inventory"
debug = false
[server]
host = "0.0.0.0"
port = 8080
started = 2026-10-19T09:30:00Z
[[users]]
login = "ada"
roles = [ "admin", "dev" ]
[[users]]
login = "lin"
roles = [ "dev" ]
"""
dim r as config.ConfigResultToml = config.ParseToml( doc )
if r.IsOk( ) = false then
print r.Failure( ).Message( )
end 1
end if
dim cfg as config.TomlValue = r.Value( )
'' The value at a path, or an empty TomlValue when there is none,
'' whose AsInt( ), AsString( ) and the rest are then empty too.
function Setting( byref t as config.TomlValue, byref p as ustring ) _
as config.TomlValue
return t.Path( p ).ValueOr( config.TomlValue( ) )
end function
print "title: " & Setting( cfg, "title" ).AsString( ).ValueOr( "?" )
print "port: " & Setting( cfg, "server.port" ).AsInt( ).ValueOr( 80 )
print "debug: " & Setting( cfg, "debug" ).AsBool( ).ValueOr( true )
print "wait: " & Setting( cfg, "server.wait" ).AsInt( ).ValueOr( 30 )
dim started as config.TomlValue = Setting( cfg, "server.started" )
print "started: " & started.AsDateTime( ).Value( ).ToIsoString( )
'' [[users]] is an array of tables: index it, or walk it with Count.
dim users as config.TomlValue = Setting( cfg, "users" )
for i as int32 = 0 to users.Count( ) - 1
print Setting( users[i], "login" ).AsString( ).Value( ) _
& ": " & Setting( users[i], "roles" ).Count( ) & " role(s)"
next
print "second user's first role: " _
& Setting( cfg, "users.1.roles.0" ).AsString( ).ValueOr( "?" )
title: inventory
port: 8080
debug: false
wait: 30
started: 2026-10-19T09:30:00Z
ada: 2 role(s)
lin: 1 role(s)
second user's first role: dev
Every lookup answers a value or nothing, never a crash. Path( "server.port" ) follows keys
separated by dots, with a number indexing an array ("users.1.roles.0"), and returns an
Optional; AsInt( ), AsString( ), AsBool( ), AsFloat( ) return one too, empty when the
value is of another kind. Setting folds the two steps into one, so a missing key such as
server.wait falls through to the default the program chose. A default-constructed TomlValue
has the kind TOML_NONE, and every As… on it is empty.
| TOML | Kind( ) | Read with |
|---|---|---|
"text", 'text', """…""" | TOML_STRING | AsString( ) |
42, 0xFF, 1_000 | TOML_INT | AsInt( ), a 64-bit longint |
3.14, 5e+22, inf, nan | TOML_FLOAT | AsFloat( ), which also reads an integer |
true, false | TOML_BOOL | AsBool( ) |
2026-10-19T09:30:00Z | TOML_OFFSET_DATETIME | AsDateTime( ), a chrono.DateTime with an offset |
2026-10-19T09:30:00 | TOML_LOCAL_DATETIME | AsDateTime( ), without one |
2026-10-19, 09:30:00 | TOML_LOCAL_DATE, TOML_LOCAL_TIME | AsLocalDate( ), AsLocalTime( ) |
[ 1, 2 ], [[users]] | TOML_ARRAY | Count( ) and [ i ] |
[server], { x = 1 } | TOML_TABLE | Get, Find, Path, Keys, KeyAt( i ) |
A table keeps its keys in the order the file wrote them, so Keys( ) and KeyAt( i ) walk
it the way a reader of the file would. [[users]] is an array of tables: each [[users]]
header starts one more table in the users array.
When a document is wrong
ParseToml refuses a document that breaks the format's rules, and the ConfigError says where:
#import "std/config"
dim docs( 0 to 3 ) as ustring = { _
!"port = 80\nport = 8080", _
!"[server]\nhost = \"a\"\n[server]", _
!"[fruit]\napple.color = \"red\"\n[fruit.apple]", _
"size = 9223372036854775808" }
print "Rejected documents:"
for i as int32 = 0 to 3
dim r as config.ConfigResultToml = config.ParseToml( docs( i ) )
print r.Failure( ).Message( )
next
Rejected documents:
line 2, column 1: duplicate key: 'port'
line 3, column 1: duplicate key: table 'server' already defined
line 3, column 1: duplicate key: table 'fruit.apple' already defined
line 1, column 8: number out of range: 9223372036854775808
A key, or a table, is defined once. The third document is the subtle one: apple.color
under [fruit] defines the table fruit.apple through its dotted key, so a later
[fruit.apple] header is a second definition. An integer that does not fit in 64 bits is an
error rather than a float that has quietly lost digits. Code( ) gives the reason as a
ConfigErrorCode — CFG_SYNTAX, CFG_DUPLICATE_KEY, CFG_TYPE_REDEFINED,
CFG_INVALID_ESCAPE, CFG_NUMBER_RANGE, CFG_INVALID_DATETIME — and Line( ) and Column( )
the position, so a program can point its user at the line to fix.
Changing and writing TOML
The tree can be changed and written back:
#import "std/config"
dim r as config.ConfigResultToml = config.ParseToml( """
# edited by the program below
[server]
port = 8080
title = "inventory"
""" )
dim cfg as config.TomlValue = r.Value( )
'' Change a value, add a key and a whole table.
dim server as config.TomlValue ptr = cfg.Find( "server" )
server->SetKey( "port", config.TomlValue.FromInt( 9090 ) )
server->SetKey( "tls", config.TomlValue.FromBool( true ) )
dim logging as config.TomlValue = config.TomlValue.NewTable( )
logging.SetKey( "level", config.TomlValue.FromString( "info" ) )
dim files as config.TomlValue = config.TomlValue.NewArray( )
files.Push( config.TomlValue.FromString( "app.log" ) )
files.Push( config.TomlValue.FromString( "audit.log" ) )
logging.SetKey( "files", files )
cfg.SetKey( "logging", logging )
print config.ToTomlText( cfg ).Value( );
[server]
port = 9090
title = "inventory"
tls = true
[logging]
level = "info"
files = ["app.log", "audit.log"]
Find( key ) returns a pointer into the tree, so server->SetKey changes the table in place;
Get( key ) returns a copy. SetKey replaces a key's value or adds the key at the end, Push
appends to an array, and RemoveKey deletes. FromInt, FromString, FromBool, FromFloat,
FromDateTime, FromLocalDate, FromLocalTime, NewTable and NewArray make the values.
config.ToTomlText( root ) writes the tree out: each table's plain keys first, then its
sub-tables as [section] headers, in the order they were added. The comment is gone. The
tree holds values, not the text they came from, so a program that rewrites a hand-edited file
loses its comments. Write settings the program owns to a file of their own, and leave the
user's file to the user.
INI files
INI is older and simpler: sections of key = value lines, every value text.
#import "std/config"
dim r as config.ConfigResultIni = config.ParseIni( """
; written by hand
[Database]
Host = db.example.org
port: 5432
motd = " spaces kept "
""" )
dim ini as config.IniFile = r.Value( )
print "Settings:"
'' Section and key names ignore case.
print ini.Get( "database", "host" ).ValueOr( "?" )
print "[" & ini.Get( "Database", "motd" ).ValueOr( "" ) & "]"
print ini.Get( "Database", "timeout" ).ValueOr( "30" ) '' a default
ini.SetValue( "Database", "port", "5433" )
ini.SetValue( "Cache", "size", "64" )
print ini.ToText( );
Settings:
db.example.org
[ spaces kept ]
30
[Database]
Host = db.example.org
port = 5433
motd = " spaces kept "
[Cache]
size = 64
Names ignore case unless IniOptions.CaseSensitiveKeys says otherwise, so
Get( "database", "host" ) finds Host under [Database]. key: value works as well as
key = value. A line beginning with ; or # is a comment, but a ; later in a line belongs
to the value. Spaces around a value are dropped unless the value is in double quotes, and the
quotes themselves are removed. Keys before the first [section] belong to the section "".
SetValue replaces a value or adds the key, and the section, at the end; Remove( section, key )
deletes a key and Remove( section ) a whole section. ToText( ) writes the file back, quoting
the value that needed its quotes. A repeated key replaces the first one, which is what most INI
readers do; IniOptions.Duplicates = config.INI_DUP_ERROR makes it an error instead.
config.LoadIni( path ) and config.SaveIni( ini, path ) read and write the file itself.
Writing CSV
strings.JoinCsvRow turns an Array( of ustring ) into one line of CSV, and CsvWriter
writes rows to any fs.Stream:
#import "std/strings"
#import "std/containers"
#import "std/fs"
using containers
dim header as Array( of ustring ) = { "item", "note", "qty" }
dim row1 as Array( of ustring ) = { "bolt", "M6, steel", "200" }
dim row2 as Array( of ustring ) = { "nut", !"says \"hex\"", "150" }
print strings.JoinCsvRow( row1 )
'' Any stream will do: a file, a pipe, or memory, as here.
dim buffer as fs.MemoryStream
dim w as strings.CsvWriter = strings.CsvWriter( buffer, ",", false )
w.WriteRow( header )
w.WriteRow( row1 )
w.WriteRow( row2 )
print "written: "; w.Finish( ).IsOk( )
print buffer.ToUtf8Text( );
'' SplitCsv reads back exactly what was written.
dim text as ustring = buffer.ToUtf8Text( )
dim rows as Array( of strings.CsvURow ) = strings.SplitCsv( text )
print rows.Count( ) & " rows; row 2, field 1: " & rows[2].fields[1]
bolt,"M6, steel",200
written: true
item,note,qty
bolt,"M6, steel",200
nut,"says ""hex""",150
3 rows; row 2, field 1: says "hex"
A field is quoted only when it has to be: when it holds the separator, a double quote or a
line break, or starts or ends with a space. A quote inside a field is doubled. That is the
standard CSV form (RFC 4180), and SplitCsv reads it back field for field, as the last line
shows. Records end with CR LF, the standard's line ending, unless the third argument is
false; the listing uses LF so that its output prints cleanly. CsvWriter keeps the first
failure: later rows are not written, and Finish( ) reports it.
Coming from the file statements
tiko still compiles the file statements of the BASIC family — open, print #, line input #,
get #, put #, kill, mkdir and the rest — so older programs keep working. New code uses
std/fs, where a failure is a value rather than a runtime error or a silent nothing:
| Statement | Does | std/fs |
|---|---|---|
open … for output / append / input | open a text file | File.Create, OpenAppend, OpenRead |
print #, line input #, eof | write and read lines | TextWriter.WriteLine, ReadLines, TextReader.ReadLine |
write #, input # | comma-separated fields | WriteLine of the fields, strings.SplitCsvRow |
open … for random, get #, put # | fixed-size records | RecordFile: GetRecord, PutRecord, Append |
open … encoding "utf-16" | text in another encoding | an encoding argument: ReadAllText( p, Utf16LE( ) ) |
lof( f ), seek | length, position | File.Size( ), Seek |
mkdir p, rmdir p | create or remove a directory | CreateDir, CreateDirAll, RemoveDir, RemoveDirAll |
chdir p, curdir( ) | change or read the current directory | SetCurrentDir, CurrentDir |
kill p | delete a file | RemoveFile |
name old as new | rename | MoveFile (which also crosses drives) |
dir( pattern ), dir( ) | first match, then next match | ReadDir, Glob |
exepath( ) | the folder the program is in | PathParent( ExecutablePath( ).Value( ) ) |
dir( ) keeps one listing per thread, so two listings cannot be interleaved and a goroutine cannot
continue the main program's (Chapter 23). ReadDir has neither limit.
The console as a device
The console is something a program can draw on — but only when there is a console. When the output is redirected to a file or a pipe, colour and cursor movement become stray control bytes in someone else's data:
#include once "fbio.bi"
'' Colour and cursor positions are for a person at a console. When the
'' output goes to a file or a pipe, print plain text instead.
if IsRedirected( ) then
print "report (plain): 3 warnings"
else
cls
color 14
locate 2, 4
print "report: 3 warnings"
color 7
end if
report (plain): 3 warnings
IsRedirected( ), from #include once "fbio.bi", is true when standard output is not a console;
IsRedirected( true ) asks the same about standard input. The listing's output above is the
redirected branch, because that is how the book's harness runs it. At a console, cls clears the
screen, color sets the text colour, locate row, column moves the cursor, and width sets the
console's size in characters.
For keyboard input, inkey( ) returns the key waiting to be read, or an empty string at once if
there is none — the right call inside a loop that must keep working. getkey( ) waits for a key
and returns its code. sleep ms pauses for that many milliseconds. open cons and open err open
the console's output and its error stream as file numbers, for code written against print #
(Chapter 19 traced to open err).
Processes and the environment
Arguments and environment variables
'' command( n ) is the n-th argument; command( ) is all of them.
dim as int32 i = 1
do while len( command( i ) ) > 0
print i; ": "; command( i )
i += 1
loop
print "all: "; command( )
'' Environment variables: set one, read it, read one that is not set.
setenviron "REPORT_MODE=draft"
print "REPORT_MODE="; environ( "REPORT_MODE" )
print "UNSET_VARIABLE=["; environ( "UNSET_VARIABLE_XYZ" ); "]"
Run as envreport alpha "two words" 3:
1: alpha
2: two words
3: 3
all: alpha two words 3
REPORT_MODE=draft
UNSET_VARIABLE=[]
command( n ) is the n-th argument, with quotes already removed, and an empty string past the
last one; command( 0 ) is the program's own path, and command( ) all the arguments as one
string. environ( name ) reads an environment variable — an empty string if it is not set — and
setenviron "NAME=value" sets one for this process and the programs it starts.
In the main module, __FB_ARGC__ is the number of arguments counting the program itself, and
__FB_ARGV__ the C array of them, a zstring ptr ptr — the form a C library that parses its own
command line expects. They belong to the main module's top level, as in fbc: inside a procedure
they are TKO0743: '__fb_argc__' is not declared, and another module's top level reads 0.
The std/os module
command and environ are short to write, but each loses something: command( n ) cannot show
an empty argument, because an empty string already means "past the last one", and environ cannot
tell a variable that is not set from one set to nothing. std/os keeps both apart:
#import "std/os"
#import "std/containers"
#import "std/outcome"
'' Every argument, split as the shell passed it; an empty one is kept.
dim args as containers.Array( of ustring ) = os.Args( )
for i as int32 = 1 to args.Count( ) - 1
print i; ": ["; args[ i ]; "]"
next
'' GetEnv tells a variable that is not set from one set to nothing.
os.SetEnv( "REPORT_MODE", "" )
dim m as outcome.Optional( of ustring ) = os.GetEnv( "REPORT_MODE" )
print "REPORT_MODE set? "; m.HasValue( ); ", value ["; m.ValueOr( "?" ); "]"
print "UNSET_VARIABLE_XYZ set? "; os.GetEnv( "UNSET_VARIABLE_XYZ" ).HasValue( )
'' $NAME and ${NAME} inside a string.
os.SetEnv( "REPORT_MODE", "draft" )
print os.ExpandEnv( "mode: $REPORT_MODE, file: ${REPORT_MODE}.txt" )
'' A refused name is a failure value, not a crash.
dim r as os.OsResultBool = os.SetEnv( "A=B", "x" )
print r.Failure( ).Message( )
'' Facts about the machine.
print "cpus > 0? "; cbool( os.CpuCount( ) > 0 ); ", page: "; os.PageSize( )
Run as osenv alpha "two words" "":
1: [alpha]
2: [two words]
3: []
REPORT_MODE set? true, value []
UNSET_VARIABLE_XYZ set? false
mode: draft, file: draft.txt
SetEnv 'A=B': invalid argument
cpus > 0? true, page: 4096
os.Args( ) is every argument in an Array( of ustring ), the program's own path first, Unicode
on both systems. os.GetEnv returns an Optional: empty when the variable is not set, holding
"" when it is set to nothing. SetEnv and UnsetEnv return a Result, and refuse a name that
is empty or contains = with an OsError rather than passing it to the system. ExpandEnv
replaces $NAME and ${NAME} — and on Windows %NAME% — with their values. Environment( ) is
a sorted snapshot of every variable, as an OrderedMap.
The module also answers questions about the machine: OS_NAME and ARCH_NAME (constants),
OsVersion( ), Hostname( ), UserName( ), ProcessId( ), PageSize( ), TotalMemory( ),
IsTerminal( fd ), and CpuCount( ) — the processors this process may use, which inside a
container or under an affinity mask is fewer than the machine has. That is the number to size a
pool of workers by.
Set the environment before starting threads. On Linux and macOS, changing a variable while another thread reads the environment is not safe, whichever function does it.
Running other programs
'' Run a command through the shell, and read its output through a pipe.
var code = shell( "exit 3" )
print "shell returned"; code
var f = freefile( )
open pipe "echo from the child" for input as #f
dim as ustring got
line input #f, got
close #f
print "read: "; trim( got )
shell returned 3
read: from the child
| Statement | Runs | Returns |
|---|---|---|
shell( "command" ) | a command line, through the system shell | its exit code |
exec( program, args ) | a program, directly | its exit code |
chain( program ) | a program | its exit code |
run program | a program | nothing: the current program ends |
open pipe "command" for input as #f | a command whose output the program reads | — |
shell goes through cmd.exe on Windows and /bin/sh on Linux and macOS, so the command line is in that
shell's language; exec starts a program with no shell in between, which is safer when an argument
came from a user. open pipe … for input reads what the command prints, a line at a time, as if it
were a file; for output sends lines to its input instead.
Exit codes
'' The exit code is what a script or a build tool checks.
dim as int32 errors = 4
print errors; " errors"
if errors > 0 then end errors '' the process exits with status 4
print "never printed"
4 errors
end n ends the program with exit status n. Zero means success to every shell and build tool;
anything else is a failure whose number the caller can test — $? in a Unix shell, %ERRORLEVEL%
in cmd.exe, or the return of shell and exec in another tiko program. A program that can fail
should say so through its exit code, not only through what it prints.
os.ExitWith( n ) from std/os is end n under a name a procedure can pass around, and os.Abort( )
ends the program at once, skipping every destructor and every buffered write.
Stopping cleanly on Ctrl-C
Ctrl-C normally ends a console program wherever it happens to be — halfway through writing a file,
with a temporary directory left behind. os.CatchInterrupt( ) turns it into a flag instead:
#import "std/os"
'' From here on, Ctrl-C sets a flag instead of ending the program.
os.CatchInterrupt( )
dim as int32 done_ = 0
while os.Interrupted( ) = false andalso done_ < 20
sleep 5 '' one unit of work
done_ += 1
wend
if os.Interrupted( ) then
print "stopped early after"; done_; " steps"
os.ExitWith( 130 )
end if
print "finished all"; done_; " steps"
Left alone, it prints:
finished all 20 steps
Press Ctrl-C while it runs and the loop ends at the next check, prints stopped early after and
exits with 130, the code a shell reports for a program stopped by Ctrl-C. The program decides
where it is safe to stop; nothing of yours runs at the moment the key is pressed.
os.Interrupted( ) asks without waiting, os.WaitInterrupt( ms ) waits, and os.LastInterrupt( )
says which kind arrived: INT_CTRL_C, INT_BREAK (Ctrl-Break, Windows), INT_TERMINATE (SIGTERM
on Linux and macOS; the console window closing, logoff or shutdown on Windows) or INT_HANGUP (SIGHUP).
A second Ctrl-C ends the program at once, with code 130, so a program that is stuck can still
be stopped; os.CatchInterrupt( false ) turns that off. On Windows a closing console window gives
the program about five seconds before the system ends it regardless.
Talking over the network
std/net holds the three things a program needs to talk to another machine: addresses to say
where, sockets to carry the bytes — TCP for a connection, UDP for single messages — and
URLs, the text form in which addresses usually arrive.
Addresses
#import "std/net"
'' Text in, the canonical form out: IPv6 in its shortest lower-case spelling.
dim texts( 0 to 4 ) as ustring = { "192.168.1.20", "2001:0DB8:0:0:0:0:0:1", _
"::ffff:10.0.0.7", "fe80::1%eth0", "010.0.0.1" }
for i as long = 0 to 4
dim r as net.NetResultIp = net.ParseIp( texts( i ) )
if r.IsOk( ) then
dim a as net.IpAddress = r.Value( )
print texts( i ); " -> "; a.ToString( ); iif( a.IsPrivate( ), " (private)", "" ); _
iif( a.IsLinkLocal( ), " (link-local)", "" ); iif( a.IsV4Mapped( ), " (IPv4 as IPv6)", "" )
else
print texts( i ); " -> "; r.Failure( ).Message( )
end if
next
'' An address and a port.
dim sa as net.SocketAddress = net.ParseSocketAddress( "[::1]:8080" ).Value( )
print sa.Ip.IsLoopback( ), sa.Port, sa.ToString( )
192.168.1.20 -> 192.168.1.20 (private)
2001:0DB8:0:0:0:0:0:1 -> 2001:db8::1
::ffff:10.0.0.7 -> ::ffff:10.0.0.7 (private) (IPv4 as IPv6)
fe80::1%eth0 -> fe80::1%eth0 (link-local)
010.0.0.1 -> ParseIp '010.0.0.1': invalid address
true 8080 [::1]:8080
An IpAddress is a value, IPv4 or IPv6, that prints in one canonical form whatever way it was
written: IPv6 in lower case with the longest run of zero groups as ::. Parsing is strict.
010.0.0.1 is refused rather than read — some systems read a leading zero as octal and reach
8.0.0.1, others as decimal, and a filter that checks one reading while a connection uses the other
is a security hole. The Is… functions classify an address — IsLoopback, IsPrivate,
IsLinkLocal, IsMulticast, IsGlobalUnicast — and look through an IPv4 address written as IPv6.
A SocketAddress adds a port; written as text an IPv6 one needs brackets, [::1]:8080, because
the address itself is full of colons.
net.Resolve( "example.com", 443 ) asks the system's resolver for a name's addresses and returns
them all, each with the port. An address written as text comes back as itself without a lookup.
A TCP connection
#import "std/net"
#import "std/fs"
'' A server: port 0 lets the system pick a free one.
dim server as net.TcpListener
if server.Bind( net.SocketAddress( net.Loopback( ), 0 ) ).IsOk( ) = false then end 1
dim where as net.SocketAddress = server.LocalAddress( ).Value( )
'' A client connects; the system completes it before Accept is called,
'' so one thread can play both parts.
dim client as net.TcpStream
dim r as net.NetResultBool = client.Connect( where, 2000 )
if r.IsOk( ) = false then
print r.Failure( ).Message( )
end 1
end if
dim conn as net.TcpStream
server.Accept( conn, 2000 )
'' Both ends are streams: WriteUtf8 out, ReadExact in.
client.WriteUtf8( "hello" )
dim as string buf = space( 5 )
conn.ReadExact( cptr( ubyte ptr, strptr( buf ) ), 5 )
print "server got: "; buf
conn.WriteUtf8( ucase( buf ) )
client.ReadExact( cptr( ubyte ptr, strptr( buf ) ), 5 )
print "client got: "; buf
'' Shutdown( write ) ends the data: the server's next read returns 0 bytes.
client.Shutdown( net.SHUTDOWN_WRITE )
dim as ubyte one( 0 to 0 )
print "server read after shutdown: "; conn.Read( @one( 0 ), 1 ).Value( ); " bytes"
'' Nothing more is coming, so a timed read fails instead of waiting for ever.
client.SetReadTimeout( 100 )
dim t as fs.FsResultI64 = client.Read( @one( 0 ), 1 )
print "client read: "; t.Failure( ).Message( )
'' A port nothing listens on any more.
server.Close( )
dim late as net.TcpStream
print "refused? "; cbool( late.Connect( where, 5000 ).Failure( ).Code( ) = net.NET_CONNECTION_REFUSED )
server got: hello
client got: HELLO
server read after shutdown: 0 bytes
client read: timed out
refused? true
A TcpListener is a server: Bind to an address and port — port 0 asks the system for a free one,
which LocalAddress( ) then reports — and Accept each connection into a TcpStream. A client
TcpStream calls Connect with a SocketAddress, or ConnectHost( "example.com", 80 ), which
resolves the name and tries its addresses in turn. Each of them takes a timeout in milliseconds;
without one a call waits as long as the system lets it.
A TcpStream is a stream, the same fs.Stream as a File: WriteUtf8, WriteAll,
ReadExact, ReadToEnd, a BufferedReader or a TextReader all work on a connection. A read
returns what has arrived, which may be less than asked for — TCP carries a stream of bytes, not
messages, so ReadExact or a line reader is what puts the messages back together. A read of 0
bytes means the other side has finished sending, as Shutdown( SHUTDOWN_WRITE ) shows. Reads and
writes report FsErrors like a file's: FS_TIMED_OUT after SetReadTimeout,
FS_CONNECTION_RESET when the peer drops the connection. Everything else — connecting, binding,
accepting — reports a NetError, whose code says what happened: NET_CONNECTION_REFUSED,
NET_TIMED_OUT, NET_ADDRESS_IN_USE, NET_HOST_NOT_FOUND.
A UdpSocket sends single datagrams instead: Bind, then SendTo( p, n, address ) and
ReceiveFrom( p, n, sender ). A datagram arrives whole or not at all, and one longer than the
buffer fails with NET_MESSAGE_TOO_LONG rather than arriving cut short without a word.
Every socket call blocks until it is done or its timeout ends. A goroutine (Chapter 23) in a socket call holds its worker thread while it waits, so a server that gives each connection a goroutine should give every connection a timeout, or serve no more connections at once than it has workers.
URLs
#import "std/net"
dim u as net.Url = net.ParseUrl( "https://ann@example.com:8443/docs/a%20b.html?q=tiko+lang&page=2#intro" ).Value( )
print "scheme "; u.Scheme
print "user "; u.User
print "host "; u.Host; " port "; u.Port
print "path "; u.Path '' decoded
print "query "; u.RawQuery '' as written
print "q "; u.Query( ).Get( "q" ).ValueOr( "" )
print "fragment "; u.Fragment
'' A link on that page, made absolute.
print u.Resolve( "../img/logo.png" ).Value( ).ToString( )
print u.Resolve( "?page=3" ).Value( ).ToString( )
'' Built from fields: each part is escaped as it is printed.
dim q as net.QueryValues
q.Add( "name", "Zoë Wu" )
q.Add( "tags", "a&b" )
dim v as net.Url
v.Scheme = "http"
v.Host = "localhost"
v.Port = 8080
v.Path = "/search results"
v.RawQuery = q.Encode( )
print v.ToString( )
'' A bad escape is an error, not a guess.
print net.ParseUrl( "http://example.com/100%" ).Failure( ).Message( )
scheme https
user ann
host example.com port 8443
path /docs/a b.html
query q=tiko+lang&page=2
q tiko lang
fragment intro
https://ann@example.com:8443/img/logo.png
https://ann@example.com:8443/docs/a%20b.html?page=3
http://localhost:8080/search%20results?name=Zo%C3%AB+Wu&tags=a%26b
ParseUrl 'http://example.com/100%': invalid URL
ParseUrl follows RFC 3986. The fields of a Url hold the parts decoded — Path is
/docs/a b.html, not a%20b — except RawQuery, kept as written because only its own parser
knows where its pairs begin and end; Query( ) parses it into QueryValues, an ordered list of
pairs in which a name may repeat. ToString( ) escapes each part again as it prints it, so a field
can be set to any text. Resolve turns a relative link into an absolute one the way a browser does,
removing . and ... An invalid escape such as a lone % is NET_INVALID_URL rather than a guess
at what was meant.
Fetching a web page
#cmdline "-mt"
#import "std/http"
#import "std/net"
#import "std/fs"
'' A server on this machine that answers each request on one connection with
'' the request line it received, until the client goes away.
function ReadHead( byref c as net.TcpStream, byref head as string ) as boolean
dim as ubyte ch
head = ""
while right( head, 4 ) <> !"\r\n\r\n"
dim got as fs.FsResultI64 = c.Read( @ch, 1 )
if got.IsOk( ) = false orelse got.Value( ) = 0 then return false
head &= chr( ch )
wend
return true
end function
sub Serve( byval p as any ptr )
dim as net.TcpListener ptr lis = p
dim c as net.TcpStream
if lis->Accept( c, 5000 ).IsOk( ) = false then exit sub
dim as string head
while ReadHead( c, head )
dim as long at_ = instr( lcase( head ), "content-length:" )
if at_ > 0 then
dim as long n = valint( mid( head, at_ + 15 ) )
dim as string body = space( n )
c.ReadExact( cptr( ubyte ptr, strptr( body ) ), n )
end if
dim as string reply = "you sent: " & left( head, instr( head, !"\r\n" ) - 1 )
c.WriteUtf8( !"HTTP/1.1 200 OK\r\nContent-Length: " & len( reply ) & !"\r\n\r\n" & reply )
wend
end sub
sub Main( )
dim lis as net.TcpListener
lis.Bind( net.SocketAddress( net.Loopback( ), 0 ) )
dim as ustring site = "http://127.0.0.1:" & lis.LocalAddress( ).Value( ).Port
dim th as any ptr = threadcreate( @Serve, @lis, 1024 * 1024 )
dim c as http.Client
c.TimeoutMs = 5000
dim r as http.HttpResultResponse = c.Fetch( site & "/notes?day=mon" )
print r.Value( ).StatusCode; " "; r.Value( ).Text( )
r = c.Post( site & "/notes", "text/plain", "buy milk" )
print r.Value( ).StatusCode; " "; r.Value( ).Text( )
print "idle connections kept:"; c.IdleCount( )
c.CloseIdle( ) '' the server sees the client go
threadwait( th )
end sub
Main( )
200 you sent: GET /notes?day=mon HTTP/1.1
200 you sent: POST /notes HTTP/1.1
idle connections kept: 1
std/http is an HTTP/1.1 client over those sockets, and std/tls encrypts the connection when the
URL says https. The listing serves itself, on a second thread, so it runs without a network — the
Serve routine is all a server needs to say to an HTTP client: a status line, headers, a blank
line and a body of the length the headers announced.
http.Fetch( url ) makes one request with the defaults. An http.Client holds settings —
TimeoutMs, MaxRedirects, UserAgent, DefaultHeader, TlsOptions — and keeps connections
open: both requests above travelled over one connection, which is still kept for the next request
to the same site. Send takes a Request built by hand, with any method, headers and body;
Fetch, Head and Post are shorthands for it. A Response holds StatusCode, Header, the
Body bytes and Text( ). For a body too large to hold, Open returns a ResponseStream, an
fs.Stream like a File or a TcpStream.
A status is not an error. A 404 is a request that worked and an answer the program has to look
at, so it comes back as a Response. An HttpError means there was no answer: HTTP_CONNECT_FAILED,
HTTP_TIMED_OUT, HTTP_BAD_RESPONSE, or HTTP_TLS_FAILED when the server's certificate is not
trusted, out of date, or for another name — the error's TlsCode( ) says which. Redirects are
followed for you, up to ten.
What it costs
- A
Fileholds an operating-system handle until it is closed or goes out of scope. Keep it in the narrowestscopethat needs it. CallClose( )yourself when you need to know whether the close itself succeeded: it returns aResult, and a destructor has nowhere to report one. - A
Fileis unbuffered: everyReadandWriteis a system call. Many small writes belong in aBufferedWriter, aTextWriterover one, or aMemoryStreamwritten once. Statis one system call;Existsfollowed by a size check is two, and the file can change between them.WalkDirholds one directory open at a time and cannot exhaust handles, however deep the tree.ReadAllTextandReadAllLineshold the whole file in memory.WriteAtomicwrites the file twice over — a temporary and a rename — which is the price of never leaving it half-written.- A
TomlValueis a value:Get( key )and copying a table copy the whole subtree under it.Find( key )and[ i ]reach into the tree without a copy. Looking a key up scans its table, which is what a configuration file's tables can afford. - An
http.Clientkeeps up to eight idle connections open for reuse, each an operating-system handle (and, forhttps, a TLS session).CloseIdle( )lets them go; so does the client going out of scope. - A socket holds an operating-system handle and, while a call waits, a thread. Close connections in the narrowest scope that needs them, and give every network read a timeout: without one a peer that stops answering stops the program with it.
Summary
#import "std/fs". AFileopens withOpenRead,Create,CreateNew,OpenAppendorOpen( p, OpenOptions ), closes itself, and reports every failure as anFsError.TextWriter.WriteLinewrites lines;for each ln in ReadLines( p )reads them back.strings.SplitCsvRowsplits a line of comma-separated fields.- Every text call takes an encoding:
Utf8( ),Utf16LE( ),Utf16BE( ),CodePage( n ),Ansi( ),BinaryMode( ). Reads believe it;TextDetectguesses; writes refuse what the encoding cannot hold unless givenTEXT_REPLACE. RecordFile( of T )reads and writes fixed-size records by number, from 1, withGetRecord,PutRecord,Appendandfor each; every field must be fixed-size.ReadAtandWriteAtare the byte-level calls underneath.std/codeclays out binary formats in a byteArray:Get/Put/Append+ width +LE/BE, and LEB128 varints withAppendUVarint/ReadUVarint.- A
Fileis aStream, likeMemoryStream; buffers, text readers and writers, andCopywork on any stream, including one you write by extendingfs.Stream. - The
Path*functions;Statfor every fact at once;ReadDir,WalkDir,Glob;ReadAllText/ReadAllLines/WriteAllText;WriteAtomic. ConfigDir,DataDir,CacheDir,StateDir,HomeDirandExecutablePathsay where a program keeps its files.PathJoinwith an absolute second part discards the first — check containment withPathStartsWithbefore using a path built from input.- The file statements (
open,print #,kill,mkdir,dir…) still compile for older programs; each has astd/fscounterpart that reports failure as a value. IsRedirected( )decides whether to draw on the console.inkeynever waits;getkeydoes.command( n ),environ,setenviron;shell,exec,chain,run,open pipe;end nsets the exit code.std/os:Args( )keeps empty arguments,GetEnvtells unset from empty,SetEnv/UnsetEnv/ExpandEnv; host facts such asCpuCount( );CatchInterrupt( )turns Ctrl-C into a flag the program checks withInterrupted( ).std/hash:DigestHex( alg, data ), the same in pieces,DigestFile,HmacHex. CRC and FNV catch accidents; only SHA-2 stands up to tampering.std/config:ParseToml/LoadTomlgive aTomlValuetree read withPathandAs…, each anOptional;ToTomlTextwrites it back, without comments.ParseIni/LoadInigive anIniFilewithGet,SetValueandToText. Errors areConfigErrors with a line and column.strings.JoinCsvRowandstrings.CsvWriterwrite CSV thatSplitCsvreads back.std/net:ParseIp(strict) andIpAddress;SocketAddress;Resolve.TcpListener.BindandAccept,TcpStream.Connect/ConnectHost— aTcpStreamis anfs.Stream.UdpSocketfor datagrams. Every call blocks; give it a timeout.ParseUrlgives aUrlof decoded fields,Resolvemakes links absolute,QueryValuesreads and writes query strings.std/http:http.Fetch, or anhttp.Clientthat reuses connections and follows redirects;SendaRequest, read aResponse— a 404 is a response, not an error.std/tlsencryptshttps, and checks the server's certificate against the system's trusted roots.
Exercises
- Walk a directory tree and count its files by extension, printing the counts in a stable order. {solution="ex25-01"}
- Write
Sell( stock, record, n )for the stock file ofrecords.tiko: it reduces the quantity of recordrecordbynand refuses, returningfalse, if there are not enough. {solution="ex25-02"} - Make
pathescape.tikorefuse any name that would leaveuploads, and print which names were refused, on both platforms. - Rewrite
textfile.tikowithfs.WriteAllTextandfs.ReadAllLines. Which version would you want for a file of a few lines, and which for a log of a few gigabytes? - Write a program that runs
exitcode.tiko's executable withexecand prints a different message for exit status 0 and for anything else. - Print the SHA-256 digest of every file under a directory, sorted by path, in the format
sha256sumuses: the digest, two spaces, the path. - Give
interrupt.tikoa temporary directory that it creates at the start and removes at the end, and check that it is removed when you press Ctrl-C too. - Write
binformat.tiko's buffer to a file withfs.WriteAllBytes, read it back withfs.ReadAllBytes, and check the magic number before reading anything else. What should a reader do with a file whose version it does not know? - Give
tomlconfig.tikoa[server]keyport = "8080", written as a string. What does it print, and how would you report the mistake to the user who wrote the file? - Read a TOML file of
[[items]]tables, each with anameand aprice, and write them to a CSV file withCsvWriter, a header row first. - Change
tcpecho.tikoso that the client sends three lines and the server answers each with its length, reading with aTextReaderon the server's side. What happens if the client sends all three before the server reads any? - Write
LinkReport( pageUrl, links ): resolve each link of a page against its URL and print the ones that leave the page's host. - Make
httpget.tiko's server answer/oldwith302 Foundand aLocation: /notesheader, and fetch/old. What doesr.Value( ).Urlhold afterwards? Setc.MaxRedirects = 0and fetch it again.
Dates and Times
A date looks like the simplest data a program handles, and is not. Months have four different
lengths. The same moment has a different wall-clock time in every city, and the difference
changes twice a year in many of them. "Tomorrow at this time" and "24 hours from now" are the same
thing on most days and not on two. A date written 03/04/2025 means March in one country and
April in the next.
tiko's answer is the std/chrono module. It gives each of those ideas its own type, so a program
says which one it means, and it refuses ambiguous input instead of guessing. This chapter covers
the types, what makes a value valid, reading and writing dates as text, calendar arithmetic, time
zones, the clocks a program can read, and the older datetime.bi functions that chrono sits
beside.
The types and the tick
#import "std/chrono"
'' Five value types, one clock tick: 100 nanoseconds.
var day = chrono.LocalDate( 2025, 3, 4 )
var tod = chrono.LocalTime( 14, 30, 5, 250 )
var when = chrono.DateTime( 2025, 3, 4, 14, 30, 5, 250 )
var at = when.WithOffset( 60 ).ToInstant( )
var span = chrono.TimeSpan( 1, 2, 3, 4, 500 )
print "LocalDate "; day.ToIsoString( )
print "LocalTime "; tod.ToIsoString( )
print "DateTime "; when.ToIsoString( )
print "Instant "; at.ToIsoString( )
print "TimeSpan "; span.ToIsoString( )
'' The parts are read with methods; 1 = Monday.
print "year"; day.Year; ", month"; day.Month; ", day"; _
day.Day; ", weekday"; day.DayOfWeek
print "one second ="; chrono.TimeSpan.FromSeconds( 1 ).Ticks; _
" ticks"
print "first: "; chrono.DateTime.MinValue( ).ToIsoString( )
print "last: "; chrono.DateTime.MaxValue( ).ToIsoString( )
LocalDate 2025-03-04
LocalTime 14:30:05.2500000
DateTime 2025-03-04T14:30:05.2500000
Instant 2025-03-04T13:30:05.2500000Z
TimeSpan P1DT2H3M4.5S
year 2025, month 3, day 4, weekday 2
one second = 10000000 ticks
first: 0001-01-01T00:00:00
last: 9999-12-31T23:59:59.9999999
#import "std/chrono" brings in five value types, reached as chrono.LocalDate and so on:
| Type | Holds | Example |
|---|---|---|
LocalDate | a calendar date | 2025-03-04 |
LocalTime | a time of day | 14:30:05.2500000 |
DateTime | a date and a time, with or without a UTC offset | 2025-03-04T14:30:05+01:00 |
Instant | a point on the UTC timeline | 2025-03-04T13:30:05Z |
TimeSpan | a fixed length of time | P1DT2H3M4.5S |
Every one of them counts in ticks of 100 nanoseconds, so a second is ten million ticks, and the range runs from the first moment of year 1 to the last tick of year 9999. The resolution is the same at both ends of that range; a value does not lose precision by being far from some epoch.
The values are immutable. No method changes a date in place: day.AddDays( 1 ) returns a new
date and leaves day alone, so a value can be passed around and stored without anyone changing it
underneath. The parts are read with properties — Year, Month, Day, Hour and so
on. DayOfWeek numbers the days the ISO 8601 way, 1 for Monday through 7 for Sunday.
The constructors take their fields from largest to smallest: LocalDate( y, mo, d ),
LocalTime( h, mi, s, ms ), DateTime( y, mo, d ) or DateTime( y, mo, d, h, mi, s, ms ), and
for a span, TimeSpan( h, mi, s ) or TimeSpan( d, h, mi, s, ms ). TimeSpan also has
FromDays, FromHours, FromMinutes, FromSeconds and FromMilliseconds.
A call whose argument count no constructor takes is refused: chrono.TimeSpan( 0, 1, 30, 0 ) — four
arguments, where the constructors take three or five — is
TKO0632: no overload of 'timespan.constructor' matches these arguments.
Validity
#import "std/chrono"
'' Out-of-range parts are refused, never rolled into March.
var feb30 = chrono.LocalDate( 2025, 2, 30 )
print "2025-02-30 valid: "; feb30.IsValid
print "as text: ["; feb30.ToIsoString( ); "]"
'' A default date is Invalid; a default span is a real zero.
dim d as chrono.LocalDate
dim s as chrono.TimeSpan
print "default date valid: "; d.IsValid
print "default span zero: "; s.IsZero
'' Invalid absorbs arithmetic, and equals nothing, not even itself.
print "Invalid + 10 days valid: "; feb30.AddDays( 10 ).IsValid
print "Invalid = Invalid: "; ( feb30 = feb30 )
print "CompareTo:"; feb30.CompareTo( chrono.LocalDate( 2025, 3, 1 ) )
'' The ends of the range do not wrap around.
var last = chrono.DateTime.MaxValue( )
print "MaxValue + 1 tick valid: "; last.AddTicks( 1 ).IsValid
2025-02-30 valid: false
as text: []
default date valid: false
default span zero: true
Invalid + 10 days valid: false
Invalid = Invalid: false
CompareTo:-2
MaxValue + 1 tick valid: false
There is no 30 February, and chrono does not pretend there is. A constructor given an
out-of-range part does not roll the extra days into March; it returns the Invalid value of
its type, and IsValid says so. Rolling over would make a typo and a real date look the same
one line later.
Invalid behaves like a NaN in floating point:
- It absorbs arithmetic. Adding days, months or a span to an Invalid value gives Invalid, so a chain of calculations can be checked once at the end.
- It equals nothing, not even itself.
feb30 = feb30is false. Test withIsValid, never by comparing withInvalid( ). CompareToreturns -2 when either side is Invalid, which is not an ordering: -1, 0 and 1 are.- It prints as an empty string.
ToIsoString( )never fails; for Invalid it has nothing to say.
Arithmetic never wraps. Stepping one tick past MaxValue( ) gives Invalid, not year 1.
A LocalDate, Instant or DateTime declared with no initialiser is Invalid, because no date is
a sensible default. A TimeSpan and a LocalTime start at zero and at midnight, which are real
values.
Offsets and instants
#import "std/chrono"
'' No offset: a reading on some clock, nobody has said which.
var naive = chrono.DateTime( 2025, 3, 4, 9, 0, 0, 0 )
print "naive "; naive.ToIsoString( ); _
" has offset: "; naive.HasOffset
print "ToInstant valid: "; naive.ToInstant( ).IsValid
print "AssumeUtc "; naive.AssumeUtc( ).ToIsoString( )
'' WithOffset says which clock; ToOffset moves to another one.
var toronto = naive.WithOffset( -300 )
print "Toronto "; toronto.ToIsoString( )
print "in UTC "; toronto.ToOffset( 0 ).ToIsoString( )
print "in Kolkata "; toronto.ToOffset( 330 ).ToIsoString( )
print "relabelled "; toronto.WithOffset( 330 ).ToIsoString( )
'' The same moment, read on two clocks, is one Instant.
var a = toronto.ToInstant( )
var b = toronto.ToOffset( 330 ).ToInstant( )
print "same instant: "; ( a = b )
naive 2025-03-04T09:00:00 has offset: false
ToInstant valid: false
AssumeUtc 2025-03-04T09:00:00Z
Toronto 2025-03-04T09:00:00-05:00
in UTC 2025-03-04T14:00:00Z
in Kolkata 2025-03-04T19:30:00+05:30
relabelled 2025-03-04T09:00:00+05:30
same instant: true
A DateTime is a reading of a wall clock, and it is in one of three states:
- UTC, offset 0, printed with a
Z. - An offset from UTC, in minutes, up to 18 hours either way:
-300is five hours behind UTC, printed-05:00. - Unspecified: the time on some clock, with nothing to say which.
HasOffsetis false and the text has no suffix.
An Instant is a single point in time, the same everywhere. An unspecified DateTime cannot
become one without a guess, so ToInstant( ) on it is Invalid. AssumeUtc( ) makes the
guess, and makes it visible in the code.
Two methods put an offset on a DateTime, and they do different things:
| Call | Keeps | Changes |
|---|---|---|
WithOffset( m ) | the wall-clock reading | the moment it refers to |
ToOffset( m ) | the moment | the wall-clock reading |
WithOffset says "this 09:00 was read in Toronto". ToOffset says "what did the clocks in
Kolkata read at that moment?" The last line of the listing shows the difference: relabelling
Toronto's 09:00 as Kolkata's names a moment ten and a half hours earlier.
To compare readings taken in different places, convert them to Instant first. Two DateTime
values for the same moment in different offsets are different readings; their instants are equal.
WithOffset where ToOffset was meant compiles, runs, and moves every timestamp by the
difference between the two offsets. If the value already has an offset and the goal is to show it
somewhere else, the call is ToOffset.
Dates as text
ISO 8601
#import "std/chrono"
dim inputs( ... ) as ustring = { _
"2025-03-04T14:30:05Z", "2025-03-04 14:30:05+05:30", _
"20250304T143005Z", "2025-W10-2", "2025-063", _
"2025-03-04T14:30:05.5-05:00", _
"03/04/2025", "25-03-04", "2025-03-04T24:00:00" }
'' One canonical form out, several ISO 8601 forms in.
for i as int32 = 0 to ubound( inputs )
dim d as chrono.DateTime
dim shown as ustring = "refused"
if chrono.DateTime.TryParseIso( inputs( i ), d ) then
shown = d.ToIsoString( )
end if
print left( inputs( i ) & space( 28 ), 28 ); shown
next
'' Durations: weeks are accepted, months are not.
dim spans( ... ) as ustring = { "PT90M", "P1W", "P1DT0.25S", "P1M" }
for i as int32 = 0 to ubound( spans )
dim span as chrono.TimeSpan
dim shown as ustring = "refused"
if chrono.TimeSpan.TryParseIso( spans( i ), span ) then
shown = span.ToIsoString( )
end if
print left( spans( i ) & space( 12 ), 12 ); shown
next
2025-03-04T14:30:05Z 2025-03-04T14:30:05Z
2025-03-04 14:30:05+05:30 2025-03-04T14:30:05+05:30
20250304T143005Z 2025-03-04T14:30:05Z
2025-W10-2 2025-03-04T00:00:00
2025-063 2025-03-04T00:00:00
2025-03-04T14:30:05.5-05:00 2025-03-04T14:30:05.5000000-05:00
03/04/2025 refused
25-03-04 refused
2025-03-04T24:00:00 refused
PT90M PT1H30M
P1W P7D
P1DT0.25S P1DT0.25S
P1M refused
ToIsoString( ) writes the ISO 8601 form, and one value has exactly one string: a capital
T, a four-digit year, Z for UTC, ±HH:MM for an offset, nothing for unspecified. A fraction
of a second is written with all seven digits or not at all, so the strings are all the same width
and sort in time order as plain text — the property that matters in a log file or a database key.
Parsing accepts more than it writes. TryParseIso takes the basic format without separators
(20250304T143005Z), ISO week dates (2025-W10-2, the Tuesday of week 10), ordinal dates
(2025-063, the 63rd day), a space in place of the T as RFC 3339 and SQL databases write it,
and a comma as the decimal point. It refuses anything else: there is no guessing at 03/04/2025,
no two-digit years, and no 24:00, which ISO allows but which means the same as the next day's
00:00.
Every type has the same pair:
TryParseIso( text, result )returnstrueand setsresult, or returnsfalseand setsresultto Invalid. The result is always written, so a stale value is never left behind. It never raises a runtime error.ParseIso( text )returns the value, or Invalid. Use it for text known to be good, such as a literal in the program.
A TimeSpan reads and writes ISO 8601 durations: P1DT2H3M4.5S is one day, two hours, three
minutes and four and a half seconds. P1W is accepted as seven days. P1M is refused, because a
month is not a fixed length of time — the same reason there is no TimeSpan.FromMonths.
DateTime.TryParse is TryParseIso under another name; it is not a lenient parser.
Custom patterns
#import "std/chrono"
var d = chrono.DateTime( 2025, 3, 4, 14, 30, 5, 0 ).WithOffset( 60 )
'' Custom patterns: invariant English, never the machine's locale.
print d.ToString( "dddd, d MMMM yyyy" )
print d.ToString( "dd/MM/yy h:mm tt" )
print d.ToString( "yyyy-MM-dd'T'HH:mm:ssK" )
print d.ToString( "R" )
'' An unknown letter is an error, not a literal: quote the T.
dim text as ustring
print "TryFormat with a bare T: "; _
d.TryFormat( "yyyy-MM-ddTHH:mm", text )
'' Parsing with a pattern says which number is the day.
dim p as chrono.DateTime
if chrono.DateTime.TryParseExact( "04.03.2025 17:45", _
"dd.MM.yyyy HH:mm", p ) then
print "parsed: "; p.ToIsoString( )
end if
'' Redundant fields are cross-checked: 4 March 2025 is a Tuesday.
print "Monday 04.03.2025: "; chrono.DateTime.TryParseExact( _
"Monday 04.03.2025", "dddd dd.MM.yyyy", p )
Tuesday, 4 March 2025
04/03/25 2:30 PM
2025-03-04T14:30:05+01:00
Tue, 04 Mar 2025 13:30:05 GMT
TryFormat with a bare T: false
parsed: 2025-03-04T17:45:00
Monday 04.03.2025: false
ToString( pattern ) builds the text from pattern letters:
| Letters | Field | For 2025-03-04 14:30:05 +01:00 |
|---|---|---|
yyyy, yy | year | 2025, 25 |
M, MM, MMM, MMMM | month | 3, 03, Mar, March |
d, dd, ddd, dddd | day, weekday name | 4, 04, Tue, Tuesday |
H, HH, h, hh | hour, 24- and 12-hour | 14, 14, 2, 02 |
mm, ss | minute, second | 30, 05 |
fff, FFF | fraction; F drops trailing zeros | |
tt | AM or PM | PM |
zzz, K | offset; K writes Z for UTC and nothing when unspecified | +01:00 |
Text in single quotes is copied as it is, and so is any character that is not a letter. The names
are always English, whatever the machine's language, so the output depends only on the value
and the pattern — what a file or a protocol needs. A pattern of one character is a named format:
"o" round-trip ISO, "s" sortable, "u" universal, "R" RFC 1123 for HTTP headers (converted
to GMT first, as the listing shows), "d" the date, "T" the time.
A letter that is not a pattern letter is an error, not a literal. The T of an ISO timestamp
has to be quoted, 'T'; written bare, ToString returns an empty string and TryFormat returns
false. A typo in a pattern shows up at once instead of in the output a week later.
TryParseExact( text, pattern, result ) reads with a pattern, so a program says whether
04.03.2025 is the fourth of March or the third of April. Fields that appear twice are checked
against each other: "Monday" does not parse beside a date that fell on a Tuesday.
For text a person will read on their own machine, ToLocaleDateString, ToLocaleTimeString and
ToLocaleString ask the operating system, in the user's language and order. Their output differs
from machine to machine, so never write it to a file or compare it.
Every chrono member that produces text without taking any — ToIsoString, ToString( ), the
locale family, MonthName, WeekdayName and the zone names — returns a ustring. A Spanish
"miércoles" therefore prints intact whatever the console's code page, and assigning the result to
a string still works, as UTF-8.
Calendar arithmetic
#import "std/chrono"
'' Months have no fixed length, so month arithmetic clamps.
var jan31 = chrono.LocalDate( 2025, 1, 31 )
print "Jan 31 + 1 month: "; jan31.AddMonths( 1 ).ToIsoString( )
print " and back 1 month: "; _
jan31.AddMonths( 1 ).AddMonths( -1 ).ToIsoString( )
print "Jan 31 + 2 months: "; jan31.AddMonths( 2 ).ToIsoString( )
print " as two steps of 1: "; _
jan31.AddMonths( 1 ).AddMonths( 1 ).ToIsoString( )
print "2024-02-29 + 1 year: "; _
chrono.LocalDate( 2024, 2, 29 ).AddYears( 1 ).ToIsoString( )
'' Periods: start and end. End is the LAST tick, not the next day.
var t = chrono.DateTime( 2025, 3, 4, 14, 30, 0, 0 )
print "StartOfWeek "; t.StartOfWeek( ).ToIsoString( )
print "EndOfDay "; t.EndOfDay( ).ToIsoString( )
print "StartOfMonth "; t.StartOfMonth( ).ToIsoString( )
print "EndOfYear "; t.EndOfYear( ).ToIsoString( )
'' NextWeekday is strictly later: a Tuesday's next Tuesday.
print "next Tuesday "; t.NextWeekday( 2 ).ToIsoString( )
'' The ISO week belongs to the ISO week-year, not to Year.
var dec29 = chrono.LocalDate( 2025, 12, 29 )
print "2025-12-29: Year"; dec29.Year; ", week"; _
dec29.IsoWeek; " of"; dec29.IsoWeekYear
Jan 31 + 1 month: 2025-02-28
and back 1 month: 2025-01-28
Jan 31 + 2 months: 2025-03-31
as two steps of 1: 2025-03-28
2024-02-29 + 1 year: 2025-02-28
StartOfWeek 2025-03-03T00:00:00
EndOfDay 2025-03-04T23:59:59.9999999
StartOfMonth 2025-03-01T00:00:00
EndOfYear 2025-12-31T23:59:59.9999999
next Tuesday 2025-03-11T14:30:00
2025-12-29: Year 2025, week 1 of 2026
Adding time comes in two kinds, and the names say which:
- Exact:
Add( span ),AddDays,AddHours,AddMinutes,AddSeconds.AddDays( 1 )is exactly 86 400 seconds later. - Calendar:
AddMonthsandAddYears, and onDateTimealsoAddCalendarDays,AddCalendarMonthsandAddCalendarYears. These work on the calendar's fields and then build a date from them.
A month has no fixed length, so month arithmetic clamps at the end of the month: 31 January
plus one month is 28 February. That is the only sensible answer, but it loses information. Going
back a month from there gives 28 January, not 31; and two steps of one month end on 28 March,
where one step of two months ends on 31 March. Add the whole amount in one call rather than
building it up a step at a time. AddYears is AddMonths of twelve, so 29 February 2024 plus a
year is 28 February 2025.
The difference between AddDays and AddCalendarDays shows only across a daylight-saving change.
AddDays( 1 ) is 24 hours later, and the wall-clock time moves by an hour. AddCalendarDays( 1 )
keeps the wall-clock time and works out the new offset, so it is 23 or 25 hours later. "24 hours
from now" is AddDays; "the same time tomorrow" is AddCalendarDays.
The period helpers keep the offset and set the time:
StartOfDay,StartOfWeek,StartOfMonth,StartOfYearare the first tick of the period.StartOfWeektakes the first day of the week, and defaults to Monday.EndOfDay,EndOfMonth,EndOfYearare the last tick,23:59:59.9999999, not the start of the next period. Compare against them with<=.NextWeekday( n )andPrevWeekday( n )are strictly later and earlier: the next Tuesday after a Tuesday is a week away.
The ISO week belongs to the ISO week-year, not to Year. 29 December 2025 is in week 1 of
2026. A program that prints Year beside IsoWeek writes "week 1 of 2025" for it, a date a
year off. Print IsoWeekYear with IsoWeek, always.
Spans and differences
#import "std/chrono"
'' Totals are the whole span; components are the pieces.
var span = chrono.TimeSpan( 1, 2, 3, 4, 500 )
print span.ToIsoString( )
print "TotalHours"; span.TotalHours; ", Hours"; span.Hours
'' A negative span has negative pieces, which add back up.
var back = chrono.TimeSpan( 1, 30, 0 ).Negate( )
print back.ToIsoString( ); ": hours "; back.Hours; _
", minutes "; back.Minutes
'' Subtracting two moments gives a span ...
var dep = chrono.Instant.ParseIso( "2025-03-04T09:00:00-05:00" )
var arr = chrono.Instant.ParseIso( "2025-03-04T21:40:00+00:00" )
var flight = arr - dep
print "flight "; flight.ToIsoString( ); " ="; _
flight.TotalMinutes; " minutes"
'' ... and subtracting two dates gives a count of days.
var today = chrono.LocalDate( 2025, 3, 4 )
var xmas = chrono.LocalDate( 2025, 12, 25 )
print "days to Christmas:"; xmas - today
P1DT2H3M4.5S
TotalHours 26.05125, Hours 2
-PT1H30M: hours -1, minutes -30
flight PT7H40M = 460 minutes
days to Christmas: 296
A TimeSpan has two families of readers. TotalHours and its siblings give the whole span
in one unit, with a fraction: 26.05 hours. Hours and its siblings give the pieces: the 2 in
"1 day, 2 hours". Mixing them up gives numbers that look plausible and are wrong. The pieces of a
negative span are all negative, so they always add back up to the whole.
Subtracting gives a difference of the right kind:
| Expression | Gives |
|---|---|
instant - instant, datetime - datetime | TimeSpan |
localtime - localtime | TimeSpan |
localdate - localdate | a count of days, as a number |
instant + span, datetime - span | the same type as the left side |
The flight in the listing leaves at 09:00, five hours behind UTC, and lands at 21:40 UTC. Both are converted to instants when parsed, so the subtraction is correct without any offset arithmetic in the program.
A worked example: ages and birthdays
#import "std/chrono"
'' Whole years from 'born' to 'today': the birthday may not have
'' come round yet this year.
function AgeOn( byval born as chrono.LocalDate, _
byval today as chrono.LocalDate ) as int32
dim as int32 years = today.Year - born.Year
if born.AddYears( years ) > today then years -= 1
return years
end function
'' The next birthday on or after 'today'.
function NextBirthday( byval born as chrono.LocalDate, _
byval today as chrono.LocalDate ) _
as chrono.LocalDate
dim as int32 years = today.Year - born.Year
if born.AddYears( years ) < today then years += 1
return born.AddYears( years )
end function
'' A fixed 'today' keeps the output reproducible; a real program
'' would use chrono.WallClock.Today( ).
var today = chrono.LocalDate( 2025, 3, 4 )
dim births( ... ) as ustring = { _
"1990-03-04", "1990-03-05", "2004-02-29", "1969-07-20" }
for i as int32 = 0 to ubound( births )
var born = chrono.LocalDate.ParseIso( births( i ) )
var nb = NextBirthday( born, today )
print born.ToIsoString( ); " age"; AgeOn( born, today ); _
" next birthday "; nb.ToIsoString( ); _
", days away:"; nb - today
next
1990-03-04 age 35 next birthday 2025-03-04, days away: 0
1990-03-05 age 34 next birthday 2025-03-05, days away: 1
2004-02-29 age 21 next birthday 2026-02-28, days away: 361
1969-07-20 age 55 next birthday 2025-07-20, days away: 138
An age is the difference in years, less one if this year's birthday has not come yet. AddYears
does the hard part: it lands on the birthday in the target year, and for someone born on
29 February it clamps to the 28th in a year with no 29th. Whether a leap-day birthday falls on
28 February or 1 March in other years is a rule that varies by country; this code chooses the 28th
because that is what AddYears gives, and a program that must follow a particular law should
write that rule out.
The fixed today keeps the output the same on every run. A real program would use
chrono.WallClock.Today( ).
Time zones
#import "std/chrono"
'' The offset depends on the moment: ask for it AT that instant.
var jan = chrono.Instant.ParseIso( "2025-01-15T12:00:00Z" )
var jul = chrono.Instant.ParseIso( "2025-07-15T12:00:00Z" )
dim as int32 offJan = chrono.TimeZoneInfo.LocalOffsetAt( jan )
dim as int32 offJul = chrono.TimeZoneInfo.LocalOffsetAt( jul )
'' What this machine says varies; these facts do not.
print "January offset in range: "; cbool( abs( offJan ) <= 18 * 60 )
print "July offset in range: "; cbool( abs( offJul ) <= 18 * 60 )
'' Local wall time for a moment, and back to the same moment.
var localJul = chrono.DateTime.FromInstant( jul, offJul )
print "round trip: "; ( localJul.ToInstant( ) = jul )
'' Now, as a DateTime, carries the offset it was taken under.
var now = chrono.WallClock.Now( )
print "Now has an offset: "; now.HasOffset
dim as int32 nowOff = _
chrono.TimeZoneInfo.LocalOffsetAt( now.ToInstant( ) )
print "Now matches the zone: "; cbool( now.OffsetMinutes = nowOff )
January offset in range: true
July offset in range: true
round trip: true
Now has an offset: true
Now matches the zone: true
Time zones come from the operating system. TimeZoneInfo.LocalOffsetAt( instant ) returns the
machine's offset from UTC, in minutes, at that instant — January and July can differ.
TimeZoneInfo.LocalOffset( ) is the offset right now, which is the wrong one for converting a
timestamp from another season and the most common time-zone bug there is.
IsDaylightSavingTime( instant ), SupportsDaylightSavingTime( ), StandardName( ) and
DaylightName( ) complete the set. WallClock.Now( ) records the offset it was read under, so it
converts back to the right instant even across a change.
There is no database of named zones. chrono cannot answer "what time is it in Tokyo?" from the
name alone, and has no rule for a wall time that occurs twice when the clocks go back. A program
that must know an exact point in time should carry an Instant, store and send it in UTC, and
convert to local time only for display.
The output of this listing is the same everywhere because it prints only facts that hold in every zone; the offsets themselves depend on the machine.
Clocks
#import "std/chrono"
'' Two ways to look values up in a sorted array.
function FindLinear( a( ) as int32, byval v as int32 ) as boolean
for i as int32 = 0 to ubound( a )
if a( i ) = v then return true
next
return false
end function
function FindBinary( a( ) as int32, byval v as int32 ) as boolean
dim as int32 lo = 0, hi = ubound( a )
do while lo <= hi
dim as int32 mid_ = ( lo + hi ) \ 2
if a( mid_ ) = v then return true
if a( mid_ ) < v then lo = mid_ + 1 else hi = mid_ - 1
loop
return false
end function
dim as int32 sorted( 0 to 199999 )
for i as int32 = 0 to ubound( sorted )
sorted( i ) = i * 3
next
'' A Stopwatch reads the monotonic clock: it never jumps.
dim as int32 hitsA, hitsB
var sw = chrono.Stopwatch.StartNew( )
for k as int32 = 0 to 999
if FindLinear( sorted( ), k * 600 ) then hitsA += 1
next
var linearTime = sw.Elapsed
sw.Restart( )
for k as int32 = 0 to 999
if FindBinary( sorted( ), k * 600 ) then hitsB += 1
next
var binaryTime = sw.Elapsed
print "same hits: "; cbool( hitsA = hitsB ); ","; hitsA
print "binary faster: "; ( binaryTime < linearTime )
'' Stop_ pauses it; time spent stopped is not counted.
sw.Restart( )
sw.Stop_( )
sleep 300, 1
sw.Start( )
sw.Stop_( )
print "paused 300 ms, counted under 100 ms: "; _
cbool( sw.ElapsedMilliseconds < 100 )
'' CpuClock counts processor time, which sleeping does not use.
var wall = chrono.Stopwatch.StartNew( )
var cpu0 = chrono.CpuClock.ProcessTime( )
sleep 300, 1
var cpuUsed = chrono.CpuClock.ProcessTime( ) - cpu0
print "slept: wall >= 250 ms: "; _
cbool( wall.Elapsed.TotalMilliseconds >= 250 )
print "slept: CPU < 100 ms: "; _
cbool( cpuUsed.TotalMilliseconds < 100 )
same hits: true, 1000
binary faster: true
paused 300 ms, counted under 100 ms: true
slept: wall >= 250 ms: true
slept: CPU < 100 ms: true
Three clocks answer three questions:
WallClock— what time is it?UtcNow( )gives anInstant,Now( )aDateTimewith the local offset,Today( )aLocalDate. The wall clock can jump backwards: the operating system corrects it, and users change it.timer, the classic function, reads the same clock, and neither should be used to measure how long something took.Stopwatch— how long did it take? It reads a monotonic clock that only ever moves forward.StartNew( )creates one running;Elapsedis aTimeSpanandElapsedMillisecondsa number;Restart( )zeroes it and runs on.Stop_( )has a trailing underscore becausestopis a statement. A stopwatch adds up the time it runs, so stopping and starting it again leaves the paused time out.CpuClock— how much processor time did it use?ProcessTime( )is the time the process has spent running, which asleepdoes not add to. It moves in steps of about 15 milliseconds on Windows, so a short measurement reads zero.
The listing's output is a set of comparisons, not times, because times differ from run to run and machine to machine. For a real measurement, run the work several times and keep the fastest, and measure a build with optimisation on.
The legacy bridge
#import "std/chrono"
#include "datetime.bi"
'' datetime.bi keeps a date as a double: days since 1899-12-30.
dim as float64 serial = dateserial( 2025, 3, 4 ) _
+ timeserial( 14, 30, 0 )
var d = chrono.DateTime.FromSerial( serial )
print "from serial: "; d.ToIsoString( )
print "and back: "; cbool( d.Serial = serial )
'' The two count the days of the week differently.
print "weekday( ): "; weekday( serial ); " (1 = Sunday)"
print "DayOfWeek: "; d.DayOfWeek; " (1 = Monday)"
'' dateserial rolls over; the chrono constructors refuse.
dim as float64 rolled = dateserial( 2025, 13, 40 )
print "dateserial( 2025, 13, 40 ): "; year( rolled ) & "-" & _
month( rolled ) & "-" & day( rolled )
print "DateTime( 2025, 13, 40 ) valid: "; _
chrono.DateTime( 2025, 13, 40 ).IsValid
'' Unix time is one more epoch, both ways.
var epoch = chrono.Instant.FromUnixSeconds( 1741098600 )
print "unix 1741098600: "; epoch.ToIsoString( )
from serial: 2025-03-04T14:30:00
and back: true
weekday( ): 3 (1 = Sunday)
DayOfWeek: 2 (1 = Monday)
dateserial( 2025, 13, 40 ): 2026-2-9
DateTime( 2025, 13, 40 ) valid: false
unix 1741098600: 2025-03-04T14:30:00Z
Older code keeps a date as a float64: the whole part counts days from 30 December 1899 and the
fraction is the time of day. #include "datetime.bi" provides the functions for it —
dateserial, timeserial, now, year, month, day, weekday and the rest — and
vbcompat.bi adds format. They still work, and both styles can be used in one program.
DateTime.FromSerial( serial ) and .Serial convert between the two. The conversion is exact
to the millisecond; below that, a float64 holding a present-day date has no digits left to keep.
Three differences catch programs moving across:
weekday( )counts from Sunday = 1;DayOfWeekfrom Monday = 1.dateserialrolls over: month 13, day 40 of 2025 is 9 February 2026. The chrono constructors refuse it.- A serial has no offset.
nowreturns local time with nothing to say so;WallClock.Now( )records the offset.
The other common epochs are one call away: Instant.FromUnixSeconds and UnixSeconds for Unix
time, FromFileTime and FileTime for Windows FILETIME, FromOleDate and OleDate for
COM, and FromJulianDay and JulianDay.
A worked example: merging logs
#import "std/chrono"
'' Log lines from three servers, each stamped in its own offset.
dim lines( ... ) as ustring = { _
"2025-03-04T09:15:02-05:00 build started", _
"2025-03-04T15:14:40+01:00 tests started", _
"2025-03-04T14:20:11Z build finished", _
"2025-03-04T19:59:58+05:30 tests finished", _
"not-a-time something odd" }
type Entry
as chrono.Instant at
as ustring text
end type
dim entries( ) as Entry
for i as int32 = 0 to ubound( lines )
dim as int32 sp = instr( lines( i ), " " )
dim t as chrono.Instant
if chrono.Instant.TryParseIso( left( lines( i ), sp - 1 ), t ) then
redim preserve entries( ubound( entries ) + 1 )
entries( ubound( entries ) ).at = t
entries( ubound( entries ) ).text = mid( lines( i ), sp + 1 )
else
print "skipped: "; lines( i )
end if
next
'' Instants sort by the moment itself, whatever offset they came in.
for i as int32 = 1 to ubound( entries )
var e = entries( i )
dim as int32 j = i - 1
do while j >= 0 andalso entries( j ).at > e.at
entries( j + 1 ) = entries( j )
j -= 1
loop
entries( j + 1 ) = e
next
'' Log in UTC, with the gap since the previous line.
for i as int32 = 0 to ubound( entries )
dim as ustring gap
if i > 0 then
var since = entries( i ).at - entries( i - 1 ).at
gap = "+" & since.ToIsoString( )
end if
print entries( i ).at.ToIsoString( ); " "; _
left( gap & space( 10 ), 10 ); entries( i ).text
next
skipped: not-a-time something odd
2025-03-04T14:14:40Z tests started
2025-03-04T14:15:02Z +PT22S build started
2025-03-04T14:20:11Z +PT5M9S build finished
2025-03-04T14:29:58Z +PT9M47S tests finished
Three machines wrote these lines, each in its own offset. Read as text they are out of order: the
line stamped 09:15 was written after the one stamped 15:14. Parsed into Instant values they
sort by the moment itself, and the gaps between them are plain subtractions. The line whose stamp
does not parse is reported, not given a made-up time.
Instant.TryParseIso insists on an offset or a Z in its input, because a time with neither
cannot be placed on the UTC timeline. That is the check that keeps an unlabelled timestamp out of a
merged log.
What it costs
- The values are small and copied like numbers: an
Instant,LocalTimeorTimeSpanis 8 bytes of ticks, aLocalDateis a 4-byte day number, and aDateTimeis 16 — its ticks and its offset. - Conversions are constant time. Turning ticks into year, month and day is arithmetic with no loops, so the last date in the range costs the same as the first.
- Formatting allocates the result string once; parsing allocates nothing.
Stopwatchreads a high-resolution counter;CpuClockasks the operating system, which costs more and is far coarser.
Summary
#import "std/chrono":LocalDate,LocalTime,DateTime,Instant,TimeSpan. Immutable, 100 ns ticks, years 1 to 9999,DayOfWeek1 = Monday.- Out-of-range parts give Invalid, which absorbs arithmetic, equals nothing and prints as
"". Test withIsValid. Arithmetic never wraps. - A
DateTimeis UTC, offset or unspecified.WithOffsetrelabels;ToOffsetconverts;AssumeUtcturns an unspecified reading into anInstant. ToIsoStringwrites one canonical form.TryParseIsoaccepts the ISO 8601 variants and refuses everything else;ParseIsoreturns Invalid on failure.ToString( pattern )andTryParseExactuse invariant English. An unknown pattern letter is an error: quote literal letters.AddDaysis exact;AddMonthsandAddYearsclamp at month end and do not reverse.EndOf*is the last tick. PrintIsoWeekYearwithIsoWeek.- Time zones are the operating system's:
TimeZoneInfo.LocalOffsetAt( instant ). Stopwatchfor elapsed time,CpuClockfor processor time,WallClockfor the time of day.FromSerial/Serialbridge todatetime.bi.
Exercises
- Write
WorkingDays( first, last ), the number of Monday-to-Friday days fromfirstup to but not includinglast, and check it on a week, a month and a year. {solution="ex26-01"} - Read a list of lap times written as ISO 8601 durations, report any that do not parse, and print the total and the mean. {solution="ex26-02"}
- Extend
age-calc.tikoto give an age in years, months and days. - Change
AgeOnso that someone born on 29 February has their birthday on 1 March in years with no 29 February. Which line has to change, and why isAddYearsno longer enough? iso-log-times.tikoprints its times in UTC. Print them instead in the offset of the first line of the log, usingDateTime.FromInstant.
Numbers
Chapter 4 introduced the numeric types and their operators. This chapter is about what a program does with them: the built-in functions, the library that fills the gaps in them, rounding, overflow, the special floating-point values, random numbers, statistics, and — because binary floating point cannot hold a cent exactly — the types for money.
Two modules carry the library. #import "std/numeric" brings in the mathematics: rounding
and selection, exact integer work, the floating-point environment, random numbers, statistics,
and strict conversion between numbers and text. #import "std/money" brings in Decimal,
Currency and the financial functions. Both add functions under their own prefix
(numeric.Round, money.Decimal) and change nothing that already exists: ^, mod, val,
str and rnd behave exactly as they always have. Where one of them has a defect, the library
adds a correct neighbour under another name.
The built-in functions
'' The built-in numeric functions need no import.
print "abs( -3.5 ) ="; abs( -3.5 ); ", sgn( -2 ) ="; sgn( -2 )
'' int rounds down, fix rounds toward zero, frac is what fix drops.
print "int( -2.5 ) ="; int( -2.5 ); ", fix( -2.5 ) ="; fix( -2.5 ); _
", frac( -2.5 ) ="; frac( -2.5 )
print "sqr( 2 ) ="; sqr( 2 )
print "atn( 1 ) * 4 ="; atn( 1 ) * 4
print "exp( 1 ) ="; exp( 1 )
print "log( 10 ) ="; log( 10 ); " (natural log)"
print "sin( 0.5 )^2 + cos( 0.5 )^2 ="; sin( 0.5 ) ^ 2 + cos( 0.5 ) ^ 2
'' mod truncates: the remainder takes the sign of the left side.
print "-7 mod 3 ="; -7 mod 3; ", -7 \ 3 ="; -7 \ 3
abs( -3.5 ) = 3.5, sgn( -2 ) =-1
int( -2.5 ) =-3, fix( -2.5 ) =-2, frac( -2.5 ) =-0.5
sqr( 2 ) = 1.414213562373095
atn( 1 ) * 4 = 3.141592653589793
exp( 1 ) = 2.718281828459045
log( 10 ) = 2.302585092994046 (natural log)
sin( 0.5 )^2 + cos( 0.5 )^2 = 1
-7 mod 3 =-1, -7 \ 3 =-2
These need no import:
| Function | Gives |
|---|---|
abs( x ), sgn( x ) | the magnitude; -1, 0 or 1 |
int( x ) | the largest whole number not above x — it rounds down, so int( -2.5 ) is -3 |
fix( x ), frac( x ) | the whole part toward zero, and what is left |
sqr( x ) | the square root |
sin, cos, tan, asin, acos, atn, atan2 | trigonometry, in radians |
exp( x ), log( x ) | e to the power x, and the natural logarithm |
int is a floor and fix truncates, but the names do not say so, and code that uses int
believing it truncates is wrong for every negative number. The library's Floor, Ceil and
Truncate say which they mean.
mod truncates too: the remainder takes the sign of the left-hand side, so -7 mod 3 is -1,
not the 2 that a wrapping index or a day-of-week calculation wants. \ is integer division
with the same rule.
Exact integer arithmetic
#import "std/numeric"
'' ^ always works in float64; above 2^53 the answer is wrong.
dim as int64 b = 3, e = 38
dim as int64 viaPow = b ^ e
print "3 ^ 38 ="; viaPow
print "IPow( 3, 38 ) ="; numeric.IPow( b, e )
'' Three kinds of remainder, each named for what it does.
print "-7 mod 3 ="; -7 mod 3
print "FloorMod( -7, 3 ) ="; numeric.FloorMod( -7, 3 )
print "EuclidMod( -7, -3 ) ="; numeric.EuclidMod( -7, -3 )
'' A quotient and a remainder from one division, as a tuple.
var (pages, left_) = numeric.DivMod( 47, 10 )
print "47 items, 10 a page:"; pages; " full pages and"; left_; " over"
'' Exact integer square root, greatest common divisor, least common
'' multiple.
print "ISqrt( 99 ) ="; numeric.ISqrt( 99 ); _
", GCD( 12, 18 ) ="; numeric.GCD( 12, 18 ); _
", LCM( 4, 6 ) ="; numeric.LCM( 4, 6 )
3 ^ 38 = 1350851717672992000
IPow( 3, 38 ) = 1350851717672992089
-7 mod 3 =-1
FloorMod( -7, 3 ) = 2
EuclidMod( -7, -3 ) = 2
47 items, 10 a page: 4 full pages and 7 over
ISqrt( 99 ) = 9, GCD( 12, 18 ) = 6, LCM( 4, 6 ) = 12
^ always works in float64: it converts both sides, calls the floating-point power
function and converts back. A float64 has 53 bits of precision, so above 2^53 the answer is
rounded, even when the true result fits in an int64. IPow multiplies integers and is exact.
^ on integers is not exact above 2^53. 3 ^ 38 is off by 89 in the listing, and nothing
says so. A literal such as 2 ^ 62 happens to come out right because the compiler works it out
before the program runs, so the bug appears only once the operands are variables. Use
numeric.IPow for integer powers.
The three remainders differ only for negative operands, and each is named for its rule:
FloorMod takes the sign of the divisor, EuclidMod is never negative, and mod takes the sign
of the dividend. FloorDiv and EuclidDiv are the matching quotients. DivMod( a, b ) returns
the quotient and remainder together as a tuple (Chapter 20). ISqrt is the exact integer square
root, which int( sqr( n ) ) is not for large n; GCD and LCM complete the set.
Overflow
#import "std/numeric"
dim as int32 top = 2147483647
'' Stored back in an int32, a sum past the top wraps, silently.
dim as int32 wrapped = top + 1
print "top + 1, as int32 ="; wrapped
'' Checked: an empty Optional instead of a wrong number.
var sum = numeric.AddChecked( top, 1 )
print "AddChecked has a value: "; sum.HasValue( )
var ok = numeric.AddChecked( top, -1 )
print "AddChecked( top, -1 ) ="; ok.Value( )
'' Saturating: stop at the end of the range.
print "AddSaturating ="; numeric.AddSaturating( top, 1 )
'' Wrapping: what + does, spelled so the reader knows it was meant.
print "AddWrapping ="; numeric.AddWrapping( top, 1 )
'' abs of the most negative value has no positive partner.
dim as int64 lo = -9223372036854775807 - 1
print "abs( lo ) ="; abs( lo )
print "AbsChecked has a value: "; numeric.AbsChecked( lo ).HasValue( )
'' The bit functions.
dim as uint32 flags = &b10110000
print "PopCount ="; numeric.PopCount( flags ); _
", TrailingZeros ="; numeric.TrailingZeros( flags ); _
", NextPow2( 100 ) ="; numeric.NextPow2( 100ul )
top + 1, as int32 =-2147483648
AddChecked has a value: false
AddChecked( top, -1 ) = 2147483646
AddSaturating = 2147483647
AddWrapping =-2147483648
abs( lo ) =-9223372036854775808
AbsChecked has a value: false
PopCount = 3, TrailingZeros = 4, NextPow2( 100 ) =128
Integer arithmetic wraps when a result does not fit: one past the largest int32 is the
smallest. Nothing reports it. The library offers three ways to say what should happen instead,
for every integer type:
- Checked —
AddChecked,SubChecked,MulChecked,DivChecked,NegChecked,AbsChecked,IPowChecked— return anOptional(Chapter 18) that is empty on overflow. - Saturating —
AddSaturatingand its siblings — stop at the end of the range. - Wrapping —
AddWrappingand its siblings — do exactly what the operators do. They exist so that deliberate wrapping, in a hash or a checksum, says it is deliberate.
abs of the most negative value is the classic case: it has no positive partner, so abs
returns the same negative number. AbsChecked reports it.
These protect the calls a programmer thought to protect. Overflow elsewhere is still silent.
The bit functions work on the unsigned types: PopCount, LeadingZeros, TrailingZeros,
BitWidth, Parity, RotateLeft, RotateRight, ByteSwap, BitReverse, IsPow2 and
NextPow2.
Rounding and selection
#import "std/numeric"
'' Half away from zero, and half to even (banker's rounding).
print "Round: "; numeric.Round( 2.5 ); numeric.Round( 3.5 ); _
numeric.Round( -2.5 )
print "RoundHalfEven: "; numeric.RoundHalfEven( 2.5 ); _
numeric.RoundHalfEven( 3.5 ); numeric.RoundHalfEven( -2.5 )
'' Floor, Ceil and Truncate say which way they go.
print "Floor( -1.5 ) ="; numeric.Floor( -1.5 ); _
", Ceil( -1.5 ) ="; numeric.Ceil( -1.5 ); _
", Truncate( -1.5 ) ="; numeric.Truncate( -1.5 )
'' Rounding to places scales by a power of ten first.
print "Round( 3.14159, 2 ) ="; numeric.Round( 3.14159, 2 )
print "Round( 2.675, 2 ) ="; numeric.Round( 2.675, 2 )
'' Selection.
print "Min ="; numeric.Min( 3, 8 ); ", Max ="; numeric.Max( 3, 8 ); _
", Clamp( 15, 0, 10 ) ="; numeric.Clamp( 15, 0, 10 )
print "Lerp( 0, 200, 0.25 ) ="; numeric.Lerp( 0.0, 200.0, 0.25 )
print "Sign( -0.5 ) ="; numeric.Sign( -0.5 )
Round: 3 4-3
RoundHalfEven: 2 4-2
Floor( -1.5 ) =-2, Ceil( -1.5 ) =-1, Truncate( -1.5 ) =-1
Round( 3.14159, 2 ) = 3.14
Round( 2.675, 2 ) = 2.68
Min = 3, Max = 8, Clamp( 15, 0, 10 ) = 10
Lerp( 0, 200, 0.25 ) = 50
Sign( -0.5 ) =-1
Round takes a half away from zero. RoundHalfEven takes a half to the even neighbour, which is
banker's rounding: across many values it rounds up as often as down, where Round always pushes
the halves outward. Both take an optional number of decimal places.
Round( 2.675, 2 ) is 2.68, though the nearest float64 to 2.675 is slightly below it.
Rounding to places multiplies by a power of ten first, and 2.675 × 100 comes out at exactly
267.5, which is a tie. No scale-and-round method avoids this. For amounts of money, use
Decimal, below.
Min, Max and Clamp( v, lo, hi ) work on every numeric type. Lerp( a, b, t ) moves from
a toward b by the fraction t. Sign is like sgn but always returns an int32, so its
result can index an array or drive a select case. numeric.PI, TAU, E, SQRT2, LN2 and
LN10 are the usual constants.
The special values
#import "std/numeric"
'' The special values are functions, not constants.
var nan_ = numeric.NaN( )
var inf = numeric.Infinity( )
'' A NaN equals nothing, not even itself: ask with IsNaN.
print "NaN = NaN: "; cbool( nan_ = nan_ )
print "IsNaN: "; numeric.IsNaN( nan_ )
print "IsInfinite: "; numeric.IsInfinite( inf )
print "IsFinite( 1e308 * 10 ): "; numeric.IsFinite( 1e308 * 10 )
'' Classify names the kind of value.
print "Classify( 0 ) is FP_ZERO: "; _
cbool( numeric.Classify( 0.0 ) = numeric.FP_ZERO )
print "Classify( inf ) is FP_INFINITE: "; _
cbool( numeric.Classify( inf ) = numeric.FP_INFINITE )
'' NaN survives Min instead of quietly disappearing.
print "Min( NaN, 1 ) is NaN: "; _
numeric.IsNaN( numeric.Min( nan_, 1.0 ) )
'' Two zeros: equal as numbers, different in the sign bit.
dim as float64 negZero = -0.0
print "-0 = 0: "; cbool( negZero = 0.0 ); _
", IsNegative( -0 ): "; numeric.IsNegative( negZero )
NaN = NaN: false
IsNaN: true
IsInfinite: true
IsFinite( 1e308 * 10 ): false
Classify( 0 ) is FP_ZERO: true
Classify( inf ) is FP_INFINITE: true
Min( NaN, 1 ) is NaN: true
-0 = 0: true, IsNegative( -0 ): true
A float64 can hold more than numbers: positive and negative infinity, a NaN ("not a number",
the result of 0/0 and similar), and a zero with a sign. numeric.NaN( ) and
numeric.Infinity( ) make them; they are functions because a constant cannot be initialised by
dividing by zero.
A NaN is not equal to anything, itself included, so x = x is false for a NaN. Ask
IsNaN( x ). IsInfinite, IsFinite, IsNormal and IsSubnormal complete the questions, and
Classify( x ) names the kind: FP_NAN, FP_INFINITE, FP_ZERO, FP_SUBNORMAL or
FP_NORMAL.
The library passes a NaN on rather than dropping it: Min( NaN, 1 ) is NaN. Losing the NaN
there would turn an error that could be found into a wrong answer that cannot.
-0.0 and 0.0 are equal as numbers and differ in the sign bit; IsNegative reads the bit.
How print shows a NaN or an infinity depends on the platform's runtime, so print a test of the
value, as the listing does, rather than the value itself.
The floating-point environment
#import "std/numeric"
'' One divided by three, under a chosen rounding mode. The mode is
'' put back when 'r' goes out of scope, however the function ends.
function Third( byval mode as int32 ) as float64
dim r as numeric.ScopedRounding = numeric.ScopedRounding( mode )
dim as float64 one = 1.0, three = 3.0
return one / three
end function
print "toward +inf: "; _
numeric.ToString( Third( numeric.ROUND_TOWARD_POSITIVE ) )
print "toward -inf: "; _
numeric.ToString( Third( numeric.ROUND_TOWARD_NEGATIVE ) )
print "nearest: "; _
numeric.ToString( Third( numeric.ROUND_TO_NEAREST ) )
print "restored: "; _
cbool( numeric.GetRoundingMode( ) = numeric.ROUND_TO_NEAREST )
'' Exception flags record what went wrong, without stopping anything.
numeric.ClearFpExceptions( )
dim as float64 x = 1.0, zero = 0.0
var r = x / zero
print "divide by zero flagged: "; _
numeric.TestFpException( numeric.FP_DIVBYZERO )
print "result is infinite: "; numeric.IsInfinite( r )
toward +inf: 0.33333333333333337
toward -inf: 0.3333333333333333
nearest: 0.3333333333333333
restored: true
divide by zero flagged: true
result is infinite: true
The processor rounds every floating-point result to the nearest float64 unless told otherwise.
ScopedRounding( mode ) changes that for as long as the variable lives and puts the old mode
back when it goes out of scope, however the scope is left. The modes are ROUND_TO_NEAREST,
ROUND_TOWARD_POSITIVE, ROUND_TOWARD_NEGATIVE and ROUND_TOWARD_ZERO. Rounding the same
calculation both ways gives a lower and an upper bound for the true value, which is the basis of
interval arithmetic.
The processor also keeps exception flags: invalid operation, division by zero, overflow,
underflow and inexact result. They do not stop the program; they record that something happened.
ClearFpExceptions( ) resets them, TestFpException( flag ) reads one, with FP_INVALID,
FP_DIVBYZERO, FP_OVERFLOW, FP_UNDERFLOW and FP_INEXACT.
The rounding mode belongs to the thread that set it. And because the optimiser may move calculations, a change of mode is only reliable around work the compiler cannot move past it — which is why the listing puts the division in its own function.
Random numbers
#import "std/numeric"
'' A seeded generator: the same seed gives the same numbers.
dim rng as numeric.Rng = numeric.RngSeeded( 12345 )
print "ten dice:";
for i as int32 = 1 to 10
print rng.NextInt( 1, 6 );
next
print
print "a double in [0, 1): "; numeric.ToString( rng.NextDouble( ) )
'' Shuffle every element into a random order, each order equally likely.
dim deck( 1 to 8 ) as int32 = { 1, 2, 3, 4, 5, 6, 7, 8 }
numeric.Shuffle( rng, deck( ) )
print "shuffled:";
for i as int32 = 1 to 8
print deck( i );
next
print
'' An Rng is a value: a copy carries on from the same point.
var copy_ = rng
print "original and copy agree: "; _
cbool( rng.NextInt( 1, 1000 ) = copy_.NextInt( 1, 1000 ) )
ten dice: 5 1 6 1 4 1 1 2 3 6
a double in [0, 1): 0.7818442024444772
shuffled: 8 3 2 4 1 7 6 5
original and copy agree: true
The classic pair still works: randomize seeds the generator and rnd returns a value from 0 up
to but not including 1. #include "fbprng.bi" adds a second argument to randomize that picks
one of several algorithms. Two things are wrong with that pair for serious use. There is one
generator for the whole program, so a test cannot have a reproducible stream of its own without
disturbing everyone else's. And the usual way to get a whole number, int( rnd * n ), is very
slightly biased: some results come up more often than others.
numeric.Rng fixes both. An Rng is a value: each one is its own stream, and copying one
makes a second stream that continues from the same point. RngSeeded( seed ) gives the same
numbers every time, on every platform, which is what tests and simulations need;
RngFromEntropy( ) seeds from the operating system for a different stream each run.
| Member | Gives |
|---|---|
NextInt( lo, hi ) | a whole number from lo to hi, both included, without bias |
NextDouble( ) | a float64 from 0 up to but not including 1 |
NextBoolean( ), NextBooleanP( p ) | true half the time, or with probability p |
NextNormal( ), NextNormalP( mean, sd ) | the bell curve |
NextExponential( λ ), NextPoisson( λ ) | waiting times and counts |
Shuffle( rng, a( ) ) puts an array in random order with every order equally likely, and
Choice( rng, a( ) ) picks one element. For keys and tokens, where unpredictability matters
more than reproducibility, use CryptoBytes and CryptoULongInt instead: they have no seed and
are not an Rng.
Identifiers
A record that is made in more than one place — two servers, an offline phone, a program that merges files — cannot be numbered by a counter, because no one counter sees every record. A UUID is a 128-bit number chosen so that the chance of two being equal is negligible, made anywhere with no coordination:
#import "std/uuids"
#import "std/containers"
'' Random identifiers: no two alike, nothing to guess.
dim a as uuids.Uuid = uuids.NewV4( )
dim b as uuids.Uuid = uuids.NewV4( )
print "version"; a.Version( ); ","; len( a.ToString( ) ); " characters"
print "a = b? "; a = b
'' Time-ordered identifiers sort in the order they were made.
dim older as uuids.Uuid = uuids.NewV7( )
dim newer as uuids.Uuid = uuids.NewV7( )
print "version"; older.Version( ); ", in order? "; older < newer
print "made this century? "; cbool( older.UnixMillis( ) > 946684800000 )
'' Parsing accepts the common spellings, and prints one.
var r = uuids.Parse( "{F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6}" )
print r.Value( ).ToString( )
'' A Uuid is a key like any other.
dim owners as containers.Map( of uuids.Uuid, ustring )
owners[ a ] = "ada"
print "owner of a: "; owners[ a ]
version 4, 36 characters
a = b? false
version 7, in order? true
made this century? true
f81d4fae-7dec-11d0-a765-00a0c91e6bf6
owner of a: ada
uuids.NewV4( ) fills 122 bits from the operating system's secure generator, the one behind
CryptoBytes: nothing about the value can be predicted from another. uuids.NewV7( ) puts the time
in milliseconds first and random bits after, so identifiers made later sort later. As database
keys, v7 values arrive in order and keep an index compact where v4 values scatter across it; within
one program, each NewV7 is greater than the one before even when many are made in the same
millisecond. The price is that a v7 value tells anyone who sees it when it was made —
UnixMillis( ) reads it back.
Parse accepts the common spellings — with or without hyphens and braces, any case, or a
urn:uuid: prefix — and ToString( ) always writes the lower-case 8-4-4-4-12 form. A Uuid has
= and < and a hash, so it is a Map or Set key like any built-in type.
Statistics
#import "std/numeric"
#import "std/containers"
'' Roll two dice 36000 times with a fixed seed, so every run agrees.
dim rng as numeric.Rng = numeric.RngSeeded( 2026 )
dim totals as containers.Array( of float64 )
dim counts( 2 to 12 ) as int32
for i as int32 = 1 to 36000
var t = rng.NextInt( 1, 6 ) + rng.NextInt( 1, 6 )
totals.Push( cdbl( t ) )
counts( t ) += 1
next
'' A sideways bar chart, one # for every 100 rolls.
for t as int32 = 2 to 12
print right( " " & t, 2 ); right( space( 6 ) & counts( t ), 6 ); _
" "; string( counts( t ) \ 100, "#" )
next
'' Summary statistics over the whole array.
print "mean "; numeric.Round( numeric.Mean( totals ), 3 )
print "median "; numeric.Median( totals )
print "stddev "; numeric.Round( numeric.StdDev( totals ), 3 )
2 1004 ##########
3 1990 ###################
4 3023 ##############################
5 3894 ######################################
6 5070 ##################################################
7 5955 ###########################################################
8 5094 ##################################################
9 3942 #######################################
10 3041 ##############################
11 1972 ###################
12 1015 ##########
mean 7.004
median 7
stddev 2.415
The statistics functions take a containers.Array (Chapter 24) of numbers: Mean, Median,
Percentile, Variance and StdDev (sample, dividing by n - 1), VariancePopulation and
StdDevPopulation (dividing by n), MinOf, MaxOf, Range, Covariance and Correlation.
Median and Percentile copy the data before sorting it, so asking a question does not reorder
the array. The mean of an empty array is NaN, not zero, because zero is a real mean.
#import "std/numeric"
#import "std/containers"
'' One pass, constant space: RunningStats sees each value once.
dim temps( ... ) as float64 = { 21.5, 23.0, 19.5, 25.0, 22.0, 24.5 }
dim acc as numeric.RunningStats
for i as int32 = 0 to ubound( temps )
acc.Add( temps( i ) )
next
print "count "; acc.Count( ); ", mean"; acc.Mean( ); _
", min"; acc.MinValue( ); ", max"; acc.MaxValue( )
print "stddev"; numeric.Round( acc.StdDev( ), 4 )
'' Adding many small values to a large one: the plain loop loses them.
dim values as containers.Array( of float64 )
values.Push( 1e16 )
for i as int32 = 1 to 100000
values.Push( 1.0 )
next
dim as float64 loopTotal = 0
for each v in values
loopTotal += v
next
print "loop: "; numeric.ToString( loopTotal )
print "NaiveSum: "; numeric.ToString( numeric.NaiveSum( values ) )
print "Sum: "; numeric.ToString( numeric.Sum( values ) )
count 6, mean 22.58333333333333, min 19.5, max 25
stddev 2.0351
loop: 1e+16
NaiveSum: 1e+16
Sum: 10000000000100000
RunningStats sees each value once and keeps only a few numbers, so it can summarise a
stream too large to hold. Add, then Count, Mean, Variance, StdDev, MinValue and
MaxValue; Merge combines two accumulators exactly, so separate threads can each summarise a
share and then combine the results.
Sum is not a loop. Adding a small number to a large float64 can lose the small one
entirely: 1e16 + 1 is 1e16. A plain loop, and NaiveSum, lose all hundred thousand ones in the
listing. Sum keeps a running correction and gets the exact answer, at a few extra operations
per element.
Numbers as text
#import "std/numeric"
'' val cannot fail: garbage and a real zero look the same.
print "val( ""42abc"" ) ="; val( "42abc" ); _
", val( ""abc"" ) ="; val( "abc" )
'' TryParseDouble takes the whole string or nothing.
dim inputs( ... ) as ustring = { "2.5", "42abc", " 7", "1e3", "" }
for i as int32 = 0 to ubound( inputs )
var v = numeric.TryParseDouble( inputs( i ) )
print left( """" & inputs( i ) & """" & space( 8 ), 8 ); _
iif( v.HasValue( ), str( v.Value( ) ), "refused" )
next
'' str rounds for show; ToString gives digits that read back exactly.
dim as float64 x = 0.1, y = 0.2
print "str: "; str( x + y )
print "ToString: "; numeric.ToString( x + y )
var back = numeric.TryParseDouble( numeric.ToString( x + y ) ).Value( )
print "str reads back: "; cbool( val( str( x + y ) ) = x + y ); _
", ToString reads back: "; cbool( back = x + y )
'' A NumFormat says how a number should look.
dim f as numeric.NumFormat
f.style = numeric.NUM_FIXED
f.digits = 2
f.grouping = true
print numeric.ToString( 1234567.891, f )
val( "42abc" ) = 42, val( "abc" ) = 0
"2.5" 2.5
"42abc" refused
" 7" refused
"1e3" 1000
"" refused
str: 0.3
ToString: 0.30000000000000004
str reads back: false, ToString reads back: true
1,234,567.89
val cannot fail. It reads as much of the text as looks like a number and ignores the rest, so
"42abc" is 42 and "abc" is 0 — the same as a real zero. TryParseDouble and its
siblings TryParseSingle, TryParseLong, TryParseLongInt and the unsigned forms return an
Optional that is empty unless the whole text is a number. They do not trim spaces unless
asked: a second argument of PARSE_TRIM or PARSE_ALLOW_TRAILING relaxes them.
str rounds a float64 to fewer digits than it holds, which is easy to read and does not
always read back as the same value: 0.1 + 0.2 prints as 0.3, which is a different number.
numeric.ToString( x ) writes the shortest text that reads back exactly. For display,
ToString( x, format ) takes a NumFormat that sets the style (NUM_FIXED, NUM_SCIENTIFIC,
NUM_PERCENT and others), the number of digits, thousands grouping and the separators. The
separators are whatever the program says — never the machine's locale, so a file written on one
machine reads correctly on another.
Money
#import "std/money"
'' Binary floating point cannot hold 0.1 exactly.
dim as float64 a = 0.1, b = 0.2
print "float64: 0.1 + 0.2 = 0.3 is "; cbool( a + b = 0.3 )
'' Decimal holds decimal fractions exactly.
var d1 = money.DecimalParse( "0.1" ).Value( )
var d2 = money.DecimalParse( "0.2" ).Value( )
print "Decimal: 0.1 + 0.2 = "; ( d1 + d2 ).ToString( ); " is "; _
( d1 + d2 = money.DecimalParse( "0.3" ).Value( ) )
'' Currency: four places, fixed, in eight bytes.
var price = money.CurrencyParse( "19.99" ).Value( )
print "3 at 19.99 = "; ( price * 3 ).ToString( )
print "2 / 3 = "; ( money.CurrencyFromInt( 2 ) / 3 ).ToString( )
'' Text is parsed strictly: a fifth place is refused, not dropped.
print "parse 1.23456 as Currency: "; _
money.CurrencyParse( "1.23456" ).HasValue( )
'' A Decimal keeps the scale it was written with.
var rate = money.DecimalParse( "0.0825" ).Value( )
var amount = money.DecimalParse( "59.97" ).Value( )
print "tax at 8.25% = "; ( amount * rate ).ToString( ); _
", to cents "; ( amount * rate ).Round( 2 ).ToString( )
'' Split 100.00 three ways so the parts add back to the total.
dim weights( 0 to 2 ) as int64 = { 1, 1, 1 }
dim parts( ) as money.Decimal
money.DecimalApportion( money.DecimalParse( "100.00" ).Value( ), 2, _
weights( ), parts( ) )
print "100.00 in three:";
for i as int32 = 0 to ubound( parts )
print " "; parts( i ).ToString( );
next
print
float64: 0.1 + 0.2 = 0.3 is false
Decimal: 0.1 + 0.2 = 0.3 is true
3 at 19.99 = 59.9700
2 / 3 = 0.6667
parse 1.23456 as Currency: false
tax at 8.25% = 4.947525, to cents 4.95
100.00 in three: 33.34 33.33 33.33
A float64 stores binary fractions, and most decimal fractions are not one: 0.1 is stored as a
nearby value, and 0.1 + 0.2 is not quite 0.3. For measurements that error is far too small to
matter. For money it is a cent that does not reconcile.
Never keep money in a float64. Each amount is very slightly wrong, and the errors add up
across a ledger until a total is off by a cent — or, in the loan below, by 97 of them.
#import "std/money" provides two exact types:
Currency | Decimal | |
|---|---|---|
| holds | an int64 counting ten-thousandths | a 96-bit whole number, a scale and a sign |
| decimal places | 4, always | 0 to 28, per value |
| size | 8 bytes | 16 bytes |
| speed | as fast as an integer | slower: done in software |
Use Currency when four places are enough; use Decimal for exchange rates, tax rates
and anything else that needs more. DecimalFromCurrency and ToCurrency( ) convert between them.
Both are made by named functions, not constructors: DecimalParse( text ) and
CurrencyParse( text ) return an Optional, and DecimalFromInt, CurrencyFromInt,
DecimalFromDouble and CurrencyFromDouble say what they convert from. A constructor taking
an int64 would also accept a float64 without a word and drop its fraction. Parsing is strict:
CurrencyParse( "1.23456" ) is refused rather than cut to four places.
The arithmetic follows the same rules in both:
- Addition, subtraction and multiplication by a whole number are exact.
- Division rounds half to even, never truncates: 2 / 3 is 0.6667.
- A
Decimalkeeps its scale. 59.97 × 0.0825 is 4.947525, exactly;Round( 2 )makes it cents when cents are wanted.1.10and1.1are equal and print differently. - Overflow gives an Invalid value, which absorbs arithmetic and equals nothing, as chrono's
does (Chapter 26). Test with
IsValid( ). A value declared with no initialiser is zero.
Currency × Currency does not exist: money times money is not money. Decimal is a number that
happens to be decimal, and it multiplies and divides by itself freely.
Splitting an amount is where hand-written code goes wrong: 100.00 in three is 33.33 three
times, which is 99.99. DecimalApportion( total, scale, weights( ), parts( ) ) and Apportion
for Currency hand the leftover cents out so the parts always add up to the total. The weights
are an array of int64.
A Decimal array passed for the weights is refused:
TKO0687: a bydesc parameter takes an array of exactly its element type.
Loans
#import "std/money"
'' The usual float64 amortization: round the payment, round each
'' month's interest, subtract. The balance does not end at zero.
dim as float64 bal = 400000, i = 0.0595 / 12
dim as float64 pmt = bal * i / ( 1 - ( 1 + i ) ^ -360 )
pmt = int( pmt * 100 + 0.5 ) / 100
for k as int32 = 1 to 360
dim as float64 interest = int( bal * i * 100 + 0.5 ) / 100
bal -= pmt - interest
next
print "float64 payment"; pmt; ", final balance"; bal
'' The same loan in Decimal, with the last payment absorbing the rest.
var p = money.DecimalParse( "400000" ).Value( )
var r = money.DecimalParse( "0.0595" ).Value( )
var t = money.LoanTermsUsMortgage( p, r, 30 )
dim as int32 n = 0
dim as money.Decimal last
for each row in money.LoanSchedule( t ).Value( )
n += 1
last = row.ClosingBalance_
next
var payment = money.LoanPayment( t ).Value( )
print "Decimal payment "; payment.ToStringFixed( 2 ); ","; n; _
" payments, final balance "; last.ToStringFixed( 2 )
'' A Canadian mortgage compounds half-yearly but is paid monthly.
var ca = money.LoanTermsCanadianMortgage( p, r, 30 )
print "Canadian payment "; _
money.LoanPayment( ca ).Value( ).ToStringFixed( 2 )
float64 payment 2385.36, final balance-0.9699999996669249
Decimal payment 2385.36, 360 payments, final balance 0.00
Canadian payment 2366.81
The first half of the listing is how a loan is usually computed: work out the payment, round it to cents, round each month's interest to cents, subtract. The payment is right. After 360 payments the balance is not zero but a negative 97 cents, plus a trace of binary error — the borrower has overpaid, because the rounding of each payment was never accounted for.
money.LoanSchedule does the same calculation in Decimal and lets the final payment absorb
whatever is left, so the closing balance is exactly zero. Each row's principal is the payment
minus the interest, so every row adds up. LoanPayment( terms ) gives the payment alone;
LoanTermsUsMortgage and LoanTermsCanadianMortgage set up the two common cases, which differ
in how often interest compounds — monthly in the United States, half-yearly in Canada — though
both are paid monthly.
std/money goes much further: present and future value, internal rate of return, depreciation
schedules, day-count conventions and portfolio returns. The calls that can fail return a
Result (Chapter 18) whose error says why.
What it costs
- The
numericfunctions are ordinary function calls. None of them is worked out when the program is compiled, even on constant arguments. IPowmultiplies: about as many multiplications as the exponent has bits.- Checked arithmetic adds a comparison to each operation it guards.
Sumcosts a few more operations per element than a loop;Mediancopies and sorts.Currencyadds like anint64.Decimalarithmetic is done in software, and is many times slower; it is still fast compared with anything a program does with the result.- Integer,
Currency,DecimalandRngresults are the same on every platform. Square roots, logarithms and trigonometry come from each platform's maths library and may differ in the last bit.
Summary
- The intrinsics need no import.
intis a floor,fixtruncates, andmodtakes the sign of the dividend. #import "std/numeric":Round,RoundHalfEven,Floor,Ceil,Truncate,Min,Max,Clamp,Lerp,Sign, and the constants.^works infloat64;IPowis exact.FloorMod,EuclidMod,DivMod,ISqrt,GCD.- Overflow wraps silently.
*Checkedreturns an emptyOptional;*Saturatingstops at the limit;*Wrappingsays the wrap is meant. - A NaN equals nothing: use
IsNaNandClassify. NaN survivesMinandMax. ScopedRoundingsets the rounding mode for a scope; the exception flags record what happened.Rngis a value:RngSeededfor reproducible streams,NextIntwith both ends included.#import "std/uuids":NewV4is random,NewV7sorts by creation time;Parse,ToString, andUuidworks as a map key.- Statistics take a
containers.Array;RunningStatsworks in one pass;Sumis exact where a loop is not. TryParseDoubletakes the whole text or nothing;ToStringreads back exactly.#import "std/money": never keep money in afloat64.Currencyfor four places,Decimalfor up to 28; division rounds half to even;DecimalApportionsplits exactly;LoanScheduleends at exactly zero.
Exercises
- Put 1000.00 in a savings account at 4% a year, credit and round the interest to cents each
year, and print the balance for ten years in
Decimal. {solution="ex27-01"} - Estimate π by throwing a million random points at a unit square and counting how many land
inside the quarter circle. Use a seeded
Rng, so the answer is the same every run. {solution="ex27-02"} - Rewrite
dice-stats.tikowithRunningStatsinstead of an array. Which of its three statistics can no longer be computed, and why? - Write
Average( a, b )for twoint32values that cannot overflow, and test it with both values near the top of the range. - Change the
float64loop inloan-payment.tikoso its last payment absorbs the remainder. Does the balance now end at exactly zero? - Make 1000
NewV7identifiers in an ordinary array, put them in random order withnumeric.Shuffleand a seededRng, copy them into acontainers.ArrayandSortit, and check they come back in the order they were made. What would the same test show forNewV4?
Logging, Terminal UIs and SQLite
Three libraries cover three things most programs end up doing. A program that runs for any length
of time needs to say what it did, and std/logs writes that record properly: with levels,
typed fields, more than one destination and no lines torn apart by threads. A tool that a person
drives from a terminal can do better than print and input, and std/tui builds full-screen
interfaces from a grid of cells, a layout solver and some thirty widgets. A program that keeps data
between runs usually wants a database rather than files of its own invention, and
vendor/sqlite3 puts SQLite, the most widely deployed database there is, an #import away.
The chapter ends with a map of the finance library, the rest of the std/money module from
Chapter 27.
Logging
#import "std/logs"
'' A renderer with no timestamp, so the output is the same every run.
dim tr as logs.TextRenderer
tr.stamp = logs.TS_NONE
'' Logs normally go to stderr; this listing sends them to stdout.
dim con as logs.ConsoleSink = logs.ConsoleSink( logs.CT_STDOUT, @tr )
dim lg as logs.Logger
lg.MinLevel = logs.LOG_DEBUG
lg.AddSink( @con )
'' One call per record. Below MinLevel, nothing is built at all.
lg.Info( "server started" )
lg.Debug( "cache warm" )
lg.Trace( "not shown: trace is below debug" )
'' Fields are typed, and chained; Msg( ) sends the record.
lg.Warn( ).AddText( "user", "quinn" ).AddInt( "attempts", 3 ) _
.Msg( "login failed" )
'' A child logger carries fields of its own into every record.
var db = lg.WithField( "component", logs.LogValue( "db" ) )
db.Info( ).AddInt( "rows", 42 ).AddFloat( "ms", 1.5 ) _
.Msg( "query complete" )
'' Levels as text, both ways.
dim lv as logs.LogLevel
if logs.LevelFromName( "warn", lv ) then
print "warn is level"; lv; ", "; logs.LevelName( logs.LOG_ERROR ); _
" is"; logs.LOG_ERROR
end if
INFO server started
DEBUG cache warm
WARN login failed user=quinn attempts=3
INFO query complete component=db rows=42 ms=1.5
warn is level 40, ERROR is 50
Three objects make a log. A Logger is what the program calls. A sink is where records
go — ConsoleSink to the terminal, FileSink to a file — and a logger may have several. A
renderer turns a record into text: TextRenderer for people, JsonRenderer for programs.
lg.AddSink( @con ) connects them. The logger does not own its sinks: it keeps a pointer, so
a sink must live at least as long as every logger that uses it.
Levels
Every record has a level, from least to most severe: LOG_TRACE, LOG_DEBUG, LOG_INFO,
LOG_WARN, LOG_ERROR and LOG_FATAL (10 to 60, ten apart so a program can define its own in
between), plus LOG_OFF. MinLevel discards everything below it, and a discarded record is
never built — its fields are not converted, and nothing is allocated. Its arguments are still
evaluated, though: lg.Trace( ).AddText( "dump", Expensive( ) ) calls Expensive whatever the level.
LOG_FATAL is a severity and nothing more. It does not end the program; a library that called
end on its host's behalf would be a bad library. Write the end yourself.
LevelName, LevelNameShort and LevelFromName convert to and from text, so a level can come
from a configuration file or the command line. LevelFromName leaves its output unchanged when the
name is not a level — a typo in a setting does not silently switch logging off.
Records with fields
lg.Info( "message" ) logs a line. For anything with data in it, lg.Warn( ) without a
message starts a record, each field method adds a name and a value, and Msg( "text" )
finishes and sends it: AddText, AddInt, AddUInt, AddFloat, AddBool, AddPtr, and AddTime and AddSpan for
Chapter 26's Instant and TimeSpan. The values keep their types all the way to the renderer, so a
JSON renderer writes 3 and not "3". A record that is never finished is never sent: forgetting
Msg( ) costs a missing line, which is noticed, rather than a stray one.
WithField( name, value ) returns a child logger that adds that field to every record it
writes, with the parent's level and sinks — db above marks everything from the database layer.
The child is a copy: changing the parent later does not change it.
Files, JSON and the default logger
#import "std/logs"
'' JSON lines in a file: one object per record, for other programs.
dim js as logs.JsonRenderer
dim file_ as logs.FileSink = logs.FileSink( "orders.log", @js )
dim lg as logs.Logger
lg.AddSink( @file_ )
lg.Info( ).AddText( "user", "quinn" ).AddInt( "items", 3 ) _
.Msg( "order placed" )
lg.Error( ).AddText( "card", "visa" ).Msg( "payment declined" )
file_.Close( )
'' Each line starts with the time, which differs on every run, so this
'' prints each record from its level onwards.
dim as int32 f = freefile( )
open "orders.log" for input as #f
dim as ustring record
do until eof( f )
line input #f, record
print "{"; mid( record, instr( record, """level""" ) )
loop
close #f
kill "orders.log"
{"level":"INFO","msg":"order placed","user":"quinn","items":3}
{"level":"ERROR","msg":"payment declined","card":"visa"}
FileSink( path, @renderer ) appends to a file. It can also rotate the file — set
policy to RP_SIZE, RP_DAILY or RP_BOTH, with maxbytes and maxbackups — so a long-running
program's log does not grow without bound. JsonRenderer writes one JSON object per line, which
log-collecting tools read directly; each object begins with the time, which the listing skips
because it is different on every run.
#import "std/logs"
'' std/logs holds one process-wide default logger, so any procedure,
'' in any module, can log without being handed a logger.
sub SaveSettings( byref path as ustring )
logs.DefaultLogger( ).Info( ).AddText( "path", path ).Msg( "saved" )
end sub
dim tr as logs.TextRenderer
tr.stamp = logs.TS_NONE
dim con as logs.ConsoleSink = logs.ConsoleSink( logs.CT_STDOUT, @tr )
logs.LogInit( @con, logs.LOG_INFO )
logs.DefaultLogger( ).Info( "starting" )
SaveSettings( "settings.ini" )
logs.LogShutdown( )
INFO starting
INFO saved path=settings.ini
Passing a logger to every procedure that might log is tedious. The default logger is one
logger for the whole program, and std/logs holds it, so there is nothing to define:
LogInit attaches a sink and sets the level, DefaultLogger( ) reaches it from anywhere, and
LogShutdown flushes and closes its sinks. It is not called for you at exit, so a program
calls it last, or risks losing its final lines.
A sink that cannot write — a full disk, a deleted folder — does not stop the program or return an
error to every log call. It counts what it dropped (DroppedCount( )) and reports the failure
through a handler that SetLogErrorHandler replaces. AsyncSink wraps another sink and writes
from a background thread, so a slow destination does not slow the program down; its policy
decides what happens when its queue is full — wait (OP_BLOCK), or drop the newest or oldest
record and count it.
Every sink is safe to share between threads. Each one holds a lock while it renders and writes
a record, so two threads' records never interleave inside a line — which print # to a shared
file does not promise. And a ConsoleSink writes to stderr by default: logs are diagnostics,
and a program whose output is piped into another must not mix them into the pipe. It colours the
level word on a terminal and never when its output is redirected.
Terminal user interfaces
std/tui draws full-screen terminal programs: editors, dashboards, wizards, the menus of an
installer. It works on a grid of cells, and three ideas carry the whole library.
The application is three methods. A TuiApp has Init, which sets up state; Update, which
receives an event (a key, the mouse, a resize, a timer tick) and changes state; and View, which
draws the screen from state. Update never draws and View never changes anything.
The screen is redrawn from scratch every frame, and only the difference is written. There is no
tree of widget objects to keep in step with the data. View describes the whole screen each time;
the library compares that with the previous frame and sends the terminal only the cells that
changed. An unchanged frame costs nothing.
Widgets are values; their state is yours. A widget is built in View and thrown away. What must
survive between frames — a list's selection, a text box's contents, which widget has the focus —
lives in a state record that your app owns and the widget points at.
#import "std/tui"
'' An application is three methods: Init, Update and View.
type HelloApp extends tui.TuiApp
as int32 presses
declare function Init( ) as tui.TuiCmd override
declare function Update( byref e as tui.TuiEvent ) _
as tui.TuiCmd override
declare sub View( byref f as tui.TuiFrame ) override
end type
function HelloApp.Init( ) as tui.TuiCmd
presses = 0
return tui.TuiNone( )
end function
'' Update changes state and says what should happen next.
function HelloApp.Update( byref e as tui.TuiEvent ) as tui.TuiCmd
if e.kind = tui.TUIEVENT_KEY then
if e.key.code = tui.TUIKEY_ESCAPE then return tui.TuiQuit( 0 )
presses += 1
return tui.TuiRedraw( )
end if
return tui.TuiNone( )
end function
'' View draws the whole screen from state, every frame.
sub HelloApp.View( byref f as tui.TuiFrame )
dim as tui.TuiBlock b
b.borderKind = tui.TUIBORDER_ASCII
b.sides = tui.TUISIDE_ALL
b.AddTitle( " hello ", tui.TuiStyleDefault( ), _
tui.TUISIDE_TOP, tui.TUIALIGN_CENTER )
b.Render( f, f.Area( ) )
dim as tui.TuiParagraph p
p.align = tui.TUIALIGN_CENTER
p.text = "keys pressed: " & str( presses )
p.Render( f, b.Inner( f.Area( ) ) )
end sub
'' Print what a screen buffer holds, one row per line.
sub Dump( byref scr as tui.TuiScreen )
dim as tui.TuiBuffer ptr bf = scr.Front( )
for y as int32 = 0 to bf->rows - 1
dim as ustring row
for x as int32 = 0 to bf->cols - 1
dim as tui.TuiCell ptr c = bf->At( x, y )
if c->width > 0 then row &= c->text
next
print rtrim( row )
next
end sub
'' With no terminal: render once into a 30 x 4 screen.
dim as HelloApp app
dim as tui.TuiScreen scr
scr.Resize( 30, 4 )
app.Init( )
var first = tui.TuiRenderOnce( app, scr )
Dump( scr )
'' Feed Update a key press, as the loop would, and render again.
dim as tui.TuiEvent k
k.kind = tui.TUIEVENT_KEY
k.key.code = tui.TUIKEY_CHAR
k.key.ch = asc( "a" )
var cmd = app.Update( k )
print "Update asked to redraw: "; cbool( cmd.kind = tui.TUICMD_REDRAW )
var second = tui.TuiRenderOnce( app, scr )
Dump( scr )
'' Unchanged state renders nothing.
var third = tui.TuiRenderOnce( app, scr )
print "bytes written:"; len( first ); len( second ); len( third )
'' A real program hands the app to the loop instead:
'' tui.TuiRun( app, tui.TuiTermOptionsDefault( ) )
+---------- hello -----------+
| keys pressed: 0 |
| |
+----------------------------+
Update asked to redraw: true
+---------- hello -----------+
| keys pressed: 1 |
| |
+----------------------------+
bytes written: 158 14 0
A real program ends with tui.TuiRun( app, tui.TuiTermOptionsDefault( ) ), which takes over the
terminal, runs the loop — wait for events, apply them all, draw once — and restores the terminal
on the way out, even after a crash. It returns a Result: run with its output redirected, it
fails with a message instead of drawing a dashboard into a file.
The listing does what a test does instead. TuiRenderOnce( app, screen ) draws one frame into
a screen with no terminal at all and returns the bytes it would have sent, and screen.Front( )
holds the cells, which Dump prints. Update is a plain method, so feeding it an event is just a
call. The byte counts show the difference engine at work: the first frame draws everything, the
second only the changed digit, the third nothing.
Update returns a command: TuiNone( ), TuiRedraw( ), TuiQuit( code ), or
TuiTick( ms ) to receive a timer event — one tick per call, so an animation re-arms it on each
tick.
Layout and widgets
#import "std/tui"
'' Widget STATE outlives the frame, so it lives in the app.
type FilesApp extends tui.TuiApp
as tui.TuiListState listState
as tui.TuiTableState tableState
declare function Init( ) as tui.TuiCmd override
declare function Update( byref e as tui.TuiEvent ) _
as tui.TuiCmd override
declare sub View( byref f as tui.TuiFrame ) override
end type
function FilesApp.Init( ) as tui.TuiCmd
listState.selected = 1
return tui.TuiNone( )
end function
function FilesApp.Update( byref e as tui.TuiEvent ) as tui.TuiCmd
return tui.TuiNone( )
end function
function Boxed( byref title as ustring ) as tui.TuiBlock
dim as tui.TuiBlock b
b.borderKind = tui.TUIBORDER_ASCII
b.sides = tui.TUISIDE_ALL
b.AddTitle( title, tui.TuiStyleDefault( ), _
tui.TUISIDE_TOP, tui.TUIALIGN_LEFT )
return b
end function
sub FilesApp.View( byref f as tui.TuiFrame )
'' Layout: a body that fills, over a status bar one row high ...
dim as tui.TuiConstraintArray rowsC
rowsC.Push( tui.TuiFill( ) )
rowsC.Push( tui.TuiLength( 1 ) )
dim as tui.TuiRectArray band = tui.TuiSplitV( f.Area( ), rowsC )
'' ... and the body split into a 14-column list and the rest.
dim as tui.TuiConstraintArray colsC
colsC.Push( tui.TuiLength( 14 ) )
colsC.Push( tui.TuiFill( ) )
dim as tui.TuiRectArray col = tui.TuiSplitH( band[0], colsC )
var lb = Boxed( " files " )
lb.Render( f, col[0] )
dim as tui.TuiList lst
lst.items.Push( "notes.txt" )
lst.items.Push( "todo.md" )
lst.items.Push( "data.csv" )
lst.state = @listState
lst.selectPrefix = "> "
lst.Render( f, lb.Inner( col[0] ) )
var tb = Boxed( " sizes " )
tb.Render( f, col[1] )
dim as tui.TuiTable t
t.headers.Push( "Name" ) : t.widths.Push( tui.TuiFill( ) )
t.headers.Push( "Bytes" ) : t.widths.Push( tui.TuiLength( 6 ) )
dim as tui.TuiRow r
r.cells.Push( "notes.txt" ) : r.cells.Push( "1204" )
t.rows.Push( r )
r.cells.Clear( )
r.cells.Push( "todo.md" ) : r.cells.Push( "88" )
t.rows.Push( r )
t.state = @tableState
t.showHeader = true
t.Render( f, tb.Inner( col[1] ) )
dim as tui.TuiStatusBar sb
sb.Add( " q quit ", tui.TuiStyleDefault( ), tui.TUIALIGN_LEFT )
sb.Add( "2/3 ", tui.TuiStyleDefault( ), tui.TUIALIGN_RIGHT )
sb.Render( f, band[1] )
end sub
dim as FilesApp app
dim as tui.TuiScreen scr
scr.Resize( 40, 7 )
app.Init( )
tui.TuiRenderOnce( app, scr )
dim as tui.TuiBuffer ptr bf = scr.Front( )
for y as int32 = 0 to bf->rows - 1
dim as ustring row
for x as int32 = 0 to bf->cols - 1
if bf->At( x, y )->width > 0 then row &= bf->At( x, y )->text
next
print rtrim( row )
next
+ files -----++ sizes -----------------+
| notes.txt ||Name Bytes |
|> todo.md ||notes.txt 1204 |
| data.csv ||todo.md 88 |
| || |
+------------++------------------------+
q quit 2/3
Layout divides a rectangle by constraints: TuiLength( n ) for exactly n cells,
TuiPercent, TuiRatio, TuiMin_ and TuiMax_, and TuiFill( ) for a share of what is left.
TuiSplitV stacks the pieces and TuiSplitH puts them side by side, and splitting a piece again
builds any screen. The result is the same for the same sizes on every platform.
The listing uses four widgets: TuiBlock (a border with titles, whose Inner is the space
inside), TuiList, TuiTable with columns sized by the same constraints, and
TuiStatusBar. The rest of the set includes TuiParagraph, TuiTabs, TuiGauge and
TuiSpinner; the form widgets TuiTextInput, TuiTextArea, TuiCheckbox, TuiRadioGroup and
TuiSelect, with a TuiFocusRing that moves the focus on Tab; the overlays TuiPopup,
TuiMessageBox and TuiMenuBar; TuiTree; and the charts TuiGraph, TuiSparkline,
TuiBarChart and TuiMeter.
The listings draw TUIBORDER_ASCII borders so that the output prints anywhere. A real program
usually wants TUIBORDER_ROUNDED or TUIBORDER_PLAIN, drawn with the box-drawing characters,
and colour: a TuiStyle sets foreground, background and attributes, in 24-bit colour that the
library steps down to what the terminal supports.
A focusable widget must outlive the frame. A focus ring holds pointers to widgets, so a
TuiTextInput declared inside View is gone by the time Tab moves to it, and the form cannot be
navigated. Make every widget that takes the focus a field of the app, as the state records are.
SQLite
SQLite is a database in a library: no server, one file per database (or none, in memory), and
the SQL language. tiko ships it as vendor/sqlite3, one of the toolchain's vendor
packages: a C library with its header already translated into tiko and its compiled code beside
it, reached with one #import. What you call is SQLite's own C interface, so every function is
written the way SQLite's documentation writes it: sqlite3_open, not sqlite3.sqlite3_open.
Chapter 30 shows how a package like this is made, for any C library.
'' SQLite's own C interface, from the toolchain's vendor package.
#import "vendor/sqlite3"
'' SQLite reports failure as a result code; this turns one into a stop.
sub Check( byval rc as int32, byval db as sqlite3 ptr )
if rc <> SQLITE_OK andalso rc <> SQLITE_DONE then
print "SQLite error"; rc; ": "; *sqlite3_errmsg( db )
end 1
end if
end sub
dim db as sqlite3 ptr
Check( sqlite3_open( ":memory:", @db ), db )
Check( sqlite3_exec( db, "create table contact( " & _
"id integer primary key, name text not null, email text )", _
nullptr, nullptr, nullptr ), db )
'' Values go in as parameters, never pasted into the SQL text.
dim as string names( ... ) = { "Grace", "Ada", "Edsger" }
dim as string emails( ... ) = { "grace@example.org", _
"ada@example.org", "" }
dim ins as sqlite3_stmt ptr
Check( sqlite3_prepare_v2( db, _
"insert into contact( name, email ) values( ?, ? )", _
-1, @ins, nullptr ), db )
for i as int32 = 0 to ubound( names )
sqlite3_bind_text( ins, 1, names( i ), -1, SQLITE_TRANSIENT )
sqlite3_bind_text( ins, 2, emails( i ), -1, SQLITE_TRANSIENT )
Check( sqlite3_step( ins ), db )
sqlite3_reset( ins )
next
sqlite3_finalize( ins )
print "last id:"; sqlite3_last_insert_rowid( db )
'' A query is stepped through one row at a time.
dim sel as sqlite3_stmt ptr
Check( sqlite3_prepare_v2( db, "select id, name, email from " & _
"contact where email <> '' order by name", -1, @sel, nullptr ), db )
do while sqlite3_step( sel ) = SQLITE_ROW
print sqlite3_column_int64( sel, 0 ); " "; _
*cast( zstring ptr, sqlite3_column_text( sel, 1 ) ); " "; _
*cast( zstring ptr, sqlite3_column_text( sel, 2 ) )
loop
sqlite3_finalize( sel )
'' A transaction: everything in it happens, or nothing does.
Check( sqlite3_exec( db, "begin", nullptr, nullptr, nullptr ), db )
Check( sqlite3_exec( db, "delete from contact", _
nullptr, nullptr, nullptr ), db )
Check( sqlite3_exec( db, "rollback", nullptr, nullptr, nullptr ), db )
Check( sqlite3_prepare_v2( db, "select count(*) from contact", _
-1, @sel, nullptr ), db )
sqlite3_step( sel )
print "after the rollback:"; sqlite3_column_int( sel, 0 ); " contacts"
sqlite3_finalize( sel )
'' A failure is a result code, and the connection keeps the message.
dim errMsg as zstring ptr
if sqlite3_exec( db, "insert into contact( email ) values( 'x' )", _
nullptr, nullptr, @errMsg ) <> SQLITE_OK then
print "refused: "; *errMsg
sqlite3_free( errMsg )
end if
sqlite3_close( db )
last id: 3
2 Ada ada@example.org
1 Grace grace@example.org
after the rollback: 3 contacts
refused: NOT NULL constraint failed: contact.name
A connection is a sqlite3 ptr. sqlite3 is a type SQLite declares but never defines, so a
program can only hold a pointer to one, and sqlite3_open fills it in through its address,
@db. The name :memory: opens a private database that lives as long as the connection; any
other name is a file, created when it does not exist. sqlite3_close closes it.
Almost every function returns a result code: SQLITE_OK for success, anything else for a
failure, and then sqlite3_errmsg( db ) says what went wrong. Nothing raises a runtime error, so
a code that is ignored is a failure that is ignored. The listing's Check turns every failure
into a message and a stop, which is enough for a short program; a larger one decides call by call.
sqlite3_exec runs SQL that returns no rows. Its last three arguments are a callback that
would receive rows, an argument for that callback, and the address of an error message. Given
that address, as the last statement gives it, SQLite stores its message there, and the program
frees it with sqlite3_free. That is how the last line of output arrives: a not null
constraint refusing a row.
Prepared statements
Everything else goes through a statement, in four steps:
sqlite3_prepare_v2compiles the SQL once, into asqlite3_stmt ptr.sqlite3_bind_text,sqlite3_bind_int64,sqlite3_bind_nulland their siblings give a value to each?, counting from 1.sqlite3_stepruns it:SQLITE_ROWfor each row of a result,SQLITE_DONEat the end. On a row,sqlite3_column_text,sqlite3_column_int64and the others read its columns, counting from 0.sqlite3_resetmakes the statement ready to run again with new values.sqlite3_finalizefrees it.
Never build SQL by pasting values into the text. "... where name = '" & name & "'" breaks on
the first name with an apostrophe in it, and lets anyone who controls the value rewrite the query.
Write ? where each value goes and bind the values: they travel separately from the SQL and
cannot change what it means.
The last argument of sqlite3_bind_text says how long SQLite may keep the text.
SQLITE_TRANSIENT asks it to take a copy, which is always safe; SQLITE_STATIC promises the text
will not change while the statement uses it, and saves the copy. The -1 before it means "the
text ends at its zero byte".
Text crosses as UTF-8. SQLite's _text functions take and return C's char *, holding
UTF-8. A ustring passed where the C function wants a const char * is converted to UTF-8 for
the call; a string, tiko's byte string, passes its bytes as they are. These listings keep their
values in string because they are bytes that go to SQLite and come back unchanged. The other
way, sqlite3_column_text returns a pointer to bytes SQLite owns, valid until the next step, and
*cast( zstring ptr, … ) copies them into a string before then; a string holding UTF-8, assigned
to a ustring, is read as UTF-8. Chapter 35's ledger keeps its text in ustring and works this
way.
A transaction groups statements: begin, then commit to keep everything or rollback to
undo everything, as the listing does with its delete. They are SQL like any other, run with
sqlite3_exec. A transaction is also how many inserts become fast: each statement outside one
is its own transaction, written to disk separately.
Importing CSV
name,city,phone
"Lovelace, Ada",London,555-0100
Grace Hopper,"New York",
"Linus ""the"" Torvalds",Portland,555-0199
'' Loading a CSV file: std/strings parses it, SQLite stores it.
#import "vendor/sqlite3"
#import "std/strings"
'' The file's bytes, as they are: UTF-8, which is what SQLite takes.
dim as string text
dim f as int32 = freefile
open "people.csv" for binary access read as #f
text = space( lof( f ) )
get #f, , text
close #f
var rows = strings.SplitCsv( text )
dim db as sqlite3 ptr
sqlite3_open( ":memory:", @db )
sqlite3_exec( db, "create table people( name text, city text, " & _
"phone text )", nullptr, nullptr, nullptr )
'' Row 0 is the header. Every other row goes in, in one transaction.
dim ins as sqlite3_stmt ptr
sqlite3_prepare_v2( db, "insert into people values( ?, ?, ? )", _
-1, @ins, nullptr )
sqlite3_exec( db, "begin", nullptr, nullptr, nullptr )
for r as int32 = 1 to rows.Count( ) - 1
for c as int32 = 0 to 2
dim as string value = rows[ r ].fields[ c ]
if len( value ) = 0 then
sqlite3_bind_null( ins, c + 1 ) '' an empty field is NULL
else
sqlite3_bind_text( ins, c + 1, value, -1, SQLITE_TRANSIENT )
end if
next
sqlite3_step( ins )
sqlite3_reset( ins )
next
sqlite3_exec( db, "commit", nullptr, nullptr, nullptr )
sqlite3_finalize( ins )
print rows.Count( ) - 1; " rows imported"
dim sel as sqlite3_stmt ptr
sqlite3_prepare_v2( db, "select name, city, phone from people " & _
"order by name", -1, @sel, nullptr )
do while sqlite3_step( sel ) = SQLITE_ROW
dim as string phone = "(none)"
if sqlite3_column_type( sel, 2 ) <> SQLITE_NULL then
phone = *cast( zstring ptr, sqlite3_column_text( sel, 2 ) )
end if
print *cast( zstring ptr, sqlite3_column_text( sel, 0 ) ); " | "; _
*cast( zstring ptr, sqlite3_column_text( sel, 1 ) ); " | "; _
phone
loop
sqlite3_finalize( sel )
sqlite3_close( db )
3 rows imported
Grace Hopper | New York | (none)
Linus "the" Torvalds | Portland | 555-0199
Lovelace, Ada | London | 555-0100
Reading CSV properly is Chapter 9's strings.SplitCsv: quoted fields, commas and doubled
quotes inside them, line breaks inside quotes. Given a string, it returns rows whose fields are
strings as well, the same UTF-8 bytes the file holds, so they are bound with no conversion. The
listing reads the whole file as bytes, takes row 0 as the header, binds an empty field as SQL's
NULL with sqlite3_bind_null, and loads every row inside one transaction.
sqlite3_column_type tells a NULL from text when it is read back.
Static or dynamic
#import "vendor/sqlite3" links SQLite dynamically: the program calls into SQLite's shared
library, sqlite3.dll on Windows, and tiko copies that library beside the program when it
builds it, so the program starts wherever the two travel together. To have SQLite's code linked
into the program instead, which is then one file, say so on the import:
#import "vendor/sqlite3" link static
Nothing else in the program changes. A program links a library one way only: two imports asking
for different modes are an error, TKO0157.
The finance library
Chapter 27 used Decimal, Currency and loan schedules from #import "std/money". The same
module carries the rest of a finance library, one source file per subject, all built on Decimal:
| File | Covers |
|---|---|
fincore.tiko | interest, rates and the conventions they are quoted in; compounding and payment frequency; day counts |
fintvm.tiko | the time value of money: present and future value, annuities |
finloan.tiko | loans, mortgages and amortization schedules |
fincash.tiko | cash flows: net present value, internal rate of return, payback |
findeprec.tiko | depreciation, by six methods |
finport.tiko | portfolio returns and risk: volatility, drawdown, Sharpe and Sortino ratios |
finsolve.tiko | the one root finder behind every implied rate |
finerror.tiko | FinanceError, which every fallible call returns in its Result |
Each calculation is exact where it can be — growth over a whole number of periods, every sum and
product of money — and uses float64 only where a fractional power or a solver makes that
impossible, converting back to Decimal at a fixed scale. The comments in pkg/std/money document
every function, and all of them are reached as money..
What it costs
- A disabled log level costs one comparison. An enabled record costs its fields, the rendering, and a lock per sink.
- A frame costs its drawing plus a cell-by-cell comparison of the whole screen; an unchanged frame writes nothing, and an idle interface uses no processor time.
- Each SQL statement outside a transaction is a separate write to disk. Thousands of inserts belong in one transaction; a prepared statement saves compiling the SQL each time.
vendor/sqlite3costs a second file or a larger program. Linked dynamically, SQLite's shared library, 3.5 MB on Windows, travels beside the program; linked statically, the program grows by about 1.1 MB. Either way nothing needs installing on the machine that runs it.
Summary
#import "std/logs": aLoggerwithMinLeveland sinks;Info( "text" ), orWarn( ).AddText( … ).AddInt( … ).Msg( "text" )for fields;WithFieldfor a child logger.- Sinks:
ConsoleSink(stderr by default),FileSinkwith rotation,AsyncSink; renderers:TextRenderer,JsonRenderer. The logger does not own its sinks. LogInit,DefaultLogger( )andLogShutdownfor the one program-wide logger, whichstd/logsholds.#import "std/tui": aTuiAppwithInit,UpdateandView;TuiRundrives it, andTuiRenderOncedraws a frame with no terminal. Layout with constraints, widgets as values, state in the app.#import "vendor/sqlite3": SQLite's own C interface, unqualified:sqlite3_open,sqlite3_exec; statements withsqlite3_prepare_v2,?andsqlite3_bind_*, thensqlite3_step,sqlite3_column_*andsqlite3_finalize. Every call returns a result code, andsqlite3_errmsgexplains a failure. Text is UTF-8, so values travel asstring.link staticorlink dynamicon the import chooses how SQLite is linked; dynamic, the default, copies the shared library beside the program.strings.SplitCsvreads CSV to load.std/moneyalso carries rates, the time value of money, loans, cash flows, depreciation and portfolio measures.
Exercises
- Store a list of sales in a table, then print each region's total and number of sales, largest first, with one SQL query. {solution="ex28-01"}
- Set a logger's minimum level from a level name given on the command line, falling back to
INFOfor a name that is not a level. {solution="ex28-02"} - Add a
FileSinktolog-demo.tikobeside the console, so every record goes to both. What happens to the file if the program is run twice? - Make
tui-widgets.tikorespond to the Down key by moving the list's selection, and check it the waytui-hello.tikochecks its key press. - Import a CSV file of your own, then, inside a transaction, delete the rows that fail a check,
and roll back if more than half of them would go.
sqlite3_changes( db )says how many rows the last statement changed.
Interfacing with C
Most of the code a program could use is written in C: the operating system's own interfaces, the C runtime, compression, databases, graphics, cryptography. tiko calls C directly, with no wrapper layer between: a C function is declared, and called like any other. This chapter covers declaring C functions by hand and having the compiler read a C header instead, matching C's types and layouts, passing strings and callbacks across, calling conventions, linking a library — including one shipped inside the project's own tree — and loading one while the program runs.
Every listing here runs on both targets. Several print different answers on each, and those differences are the point.
Declaring a C function
'' Two ways to declare a C function. The C runtime is always linked,
'' so neither needs a library named.
'' One at a time: 'cdecl' for C's convention, 'alias' for its exact
'' name, so the tiko name can differ from it.
declare function CLength cdecl alias "strlen" _
( byval s as const zstring ptr ) as uinteger
'' A block: everything inside is cdecl, and keeps its name as written.
extern "C"
declare function toupper( byval c as int32 ) as int32
declare function abs_ alias "abs" ( byval n as int32 ) as int32
end extern
dim as zstring * 16 word = "interface"
print "strlen: "; CLength( @word )
for i as int32 = 0 to len( word ) - 1
word[ i ] = toupper( word[ i ] )
next
print "toupper: "; word
print "abs:"; abs_( -42 )
strlen: 9
toupper: INTERFACE
abs: 42
A C function is declared with declare, like a tiko one, plus three things C needs:
cdecl: C's calling convention. On x86-64 every convention is the same, but sayingcdeclkeeps the declaration honest and matches what the C header says.alias "name": the exact name the linker looks for. tiko's names are case-insensitive and C's are not, soaliasfixes the spelling, and it also lets the tiko name differ:CLengthabove is C'sstrlen.lib "name"(optional): the library the function lives in, which is then linked. The C runtime is always linked, so the listing names none.
extern "C" … end extern declares a block at once. Everything inside is cdecl, and every
name keeps the exact spelling it was written with, so toupper links to C's toupper with no
alias. Inside the block, alias still renames: abs_ is C's abs, renamed because abs is a
tiko function already. extern "C" lib "name" names a library for the whole block.
A declaration is a promise the compiler cannot check: it believes every parameter type and the result type, and a mistake shows up when the program runs, not when it compiles. Copy the types from the C header carefully, which is the first thing the next section is about — or let the compiler read the header itself, which is the section after it.
Matching C's types
#include once "crt/long.bi"
#include once "crt/stddef.bi"
'' C's types do not have the same sizes everywhere.
print "int "; sizeof( int32 )
print "long "; sizeof( clong )
print "long long "; sizeof( int64 )
print "size_t "; sizeof( size_t )
print "void * "; sizeof( any ptr )
print "wchar_t "; sizeof( wchar_t )
print "wstring character "; sizeof( wstring * 1 )
'' The compiler says which C runtime it is building against.
#ifdef __FB_MSVC__
print "C runtime: Microsoft's"
#else
print "C runtime: the system's"
#endif
On Windows:
int 4
long 4
long long 8
size_t 8
void * 8
wchar_t 2
wstring character 2
C runtime: Microsoft's
On Linux:
int 4
long 8
long long 8
size_t 8
void * 8
wchar_t 4
wstring character 4
C runtime: the system's
On macOS:
int 4
long 8
long long 8
size_t 8
void * 8
wchar_t 4
wstring character 4
C runtime: the system's
Most C types have one tiko equivalent on both platforms:
| C | tiko |
|---|---|
char, signed char, unsigned char | byte / ubyte |
short, unsigned short | int16 / uint16 |
int, unsigned int | int32 / uint32 |
long long, unsigned long long | int64 / uint64 |
float, double | float32 / float64 |
size_t, intptr_t | uinteger / integer, or size_t from crt/stddef.bi |
T * | T ptr; void * is any ptr |
const char * | const zstring ptr |
| a function pointer | a type alias for a function cdecl( … ) or sub cdecl( … ) |
Two do not:
- C's
longis 4 bytes on Windows and 8 on Linux and macOS. Useclongandculongfromcrt/long.bi, which follow the platform. Writingint32for alongworks on Windows and breaks everywhere else. wchar_tis 2 bytes on Windows (UTF-16) and 4 on Linux and macOS (UTF-32). tiko'swstringfollows the same rule, so awstring ptris awchar_t *everywhere.
long is not int32. A C function returning long declared as int32 loses the top half of
every value on Linux and macOS, and nothing complains — the Windows build, where the two agree, works. The
solution to exercise 2 uses strtol, which returns long, for that reason.
__FB_MSVC__ is defined when the program is built against Microsoft's C runtime — the default
on Windows, the windows-x86_64 target. __FB_WIN32__, __FB_LINUX__ and __FB_DARWIN__ name the platform. The
crt/ headers (crt/string.bi, crt/stdlib.bi, crt/math.bi and the rest, or all of them at
once through crt.bi) declare the C runtime by hand, ready to use.
Structs and their layout
'' A struct C will read must match C's layout byte for byte.
'' Natural alignment: 'value' starts on a 4-byte boundary, as in C.
type Natural
as ubyte tag
as int32 value
end type
'' 'field = 1' packs it, as C's #pragma pack(1) does.
type Packed field = 1
as ubyte tag
as int32 value
end type
print "Natural: size"; sizeof( Natural ); _
", value at"; offsetof( Natural, value )
print "Packed: size"; sizeof( Packed ); _
", value at"; offsetof( Packed, value )
'' A fixed-size zstring is an inline char array, like C's char[8].
type Record
as zstring * 8 name_
as int32 id
end type
print "Record: size"; sizeof( Record ); _
", id at"; offsetof( Record, id )
Natural: size 8, value at 4
Packed: size 5, value at 1
Record: size 12, id at 8
A type lays its fields out the way C lays out a struct on the same platform: each field on a
boundary of its own size, with padding between where needed, and the whole rounded up to its
largest alignment. So a type with the same fields in the same order as a C struct has the same
size and the same offsets, and a pointer to one can be handed to C.
field = 1 packs a type with no padding, as C's #pragma pack(1) does; field = 2 and
field = 4 limit the alignment the same way. Use them only to match a C struct that is packed —
a packed type is slower to use, and an unpacked C struct read through a packed type is garbage.
A fixed-size zstring * n is an inline array of n characters, like C's char name[n]; a
ustring field is not, since it holds a pointer to the text, and never belongs in a struct C
will read. offsetof( T, field ) checks an offset against the C header.
Strings across the boundary
extern "C"
declare function strlen( byval s as const zstring ptr ) as uinteger
declare function strchr( byval s as const zstring ptr, _
byval c as int32 ) as zstring ptr
declare function malloc( byval n as uinteger ) as any ptr
declare sub free( byval p as any ptr )
declare function strcpy( byval dst as zstring ptr, _
byval src as const zstring ptr ) _
as zstring ptr
end extern
'' Into C: a string's own bytes, ending in a zero, through strptr.
dim as string s = "key=value"
print "length from C: "; strlen( strptr( s ) )
'' Out of C: a pointer into memory the C side owns. '*p' copies the
'' characters into a tiko string at once.
dim as zstring ptr eq = strchr( strptr( s ), asc( "=" ) )
if eq <> nullptr then
dim as string rest = *( eq + 1 )
print "after '=': "; rest
end if
'' Memory C allocates, C must free.
dim as zstring ptr copy_ = malloc( len( s ) + 1 )
strcpy( copy_, strptr( s ) )
dim as ustring u = *copy_
free( copy_ )
print "copied into a ustring: "; u
length from C: 9
after '=': value
copied into a ustring: key=value
C's strings are bytes ending in a zero byte, and that is how a tiko string and a zstring are
stored. Into C: strptr( s ) is a pointer to a string's own bytes, valid until the string
next changes; @z does the same for a zstring. Out of C: a zstring ptr points at C's
memory, and *p reads the characters into a tiko string, which copies them. Copy at once; C may
reuse or free that memory.
Memory C allocates, C must free. The listing copies into memory from malloc and then reads
it into a ustring — a copy — before free. A pointer from a C library comes with rules about
who frees it, and the library's documentation says which; the C runtime's free is right only for
memory from the C runtime's malloc.
A ustring holds UTF-8, so *p from a C function that returns UTF-8 gives the right text. A C
interface that takes wchar_t * takes a wstring ptr on both platforms; on Windows that is
UTF-16, which is what the W functions of the Windows API expect.
Callbacks
'' C's qsort calls back into the program to compare two elements.
type Compare as function cdecl( byval a as const any ptr, _
byval b as const any ptr ) as int32
extern "C"
declare sub qsort( byval base_ as any ptr, byval n as uinteger, _
byval size as uinteger, byval cmp as Compare )
end extern
'' A named procedure, cdecl, with exactly the parameters C passes.
function Ascending cdecl( byval a as const any ptr, _
byval b as const any ptr ) as int32
return *cptr( const int32 ptr, a ) - *cptr( const int32 ptr, b )
end function
dim nums( 0 to 5 ) as int32 = { 42, 7, 19, 3, 88, 21 }
qsort( @nums( 0 ), 6, sizeof( int32 ), @Ascending )
for i as int32 = 0 to 5
print nums( i );
next
print
'' A lambda that captures nothing is a plain function too.
qsort( @nums( 0 ), 6, sizeof( int32 ), _
function cdecl( byval a as const any ptr, _
byval b as const any ptr ) as int32
return *cptr( const int32 ptr, b ) - _
*cptr( const int32 ptr, a )
end function )
for i as int32 = 0 to 5
print nums( i );
next
print
3 7 19 21 42 88
88 42 21 19 7 3
A C function that takes a function pointer calls back into the program: qsort calls its
comparison function once per comparison. A tiko procedure can be the callback if it is
cdecl and takes exactly the parameters C passes, with the right types. @Ascending is its
address.
A lambda that captures nothing (Chapter 21) is an ordinary function, so it converts to a function pointer and can be passed straight to C, as the second call does. A lambda that captures variables cannot: it carries the captured values with it, and C has nowhere to put them.
C's way to pass state into a callback is the extra void * parameter that most callback APIs
have — user, context or arg. The program passes a pointer to its own data, and the callback
casts it back. The geom listings below use one to count visits.
Variadic functions
#include once "crt/stddef.bi"
'' Calling a variadic C function: declare the fixed part, then '...'.
extern "C"
declare function snprintf( byval buf as zstring ptr, _
byval n as size_t, _
byval fmt as const zstring ptr, _
... ) as int32
end extern
dim as zstring * 64 buf
var n = snprintf( @buf, 64, "%d items at %.2f each: %s", _
3, 4.5, "ok" )
print buf; " ("; n; " characters)"
'' Writing one: cva_list walks the arguments after the fixed ones.
'' The caller has to say how many there are -- here, the first one.
function Total cdecl( byval count as int32, ... ) as int32
dim args as cva_list
cva_start( args, count )
dim as int32 sum = 0
for i as int32 = 1 to count
sum += cva_arg( args, int32 )
next
cva_end( args )
return sum
end function
print "Total:"; Total( 4, 10, 20, 30, 40 )
'' A cva_list is a pointer on Windows and a 24-byte record on Linux.
dim probe as cva_list
print "cva_list is"; sizeof( probe ); " bytes"
On Windows:
3 items at 4.50 each: ok ( 24 characters)
Total: 100
cva_list is 8 bytes
On Linux the last line is:
cva_list is 24 bytes
A variadic C function, like printf and snprintf, is declared with its fixed parameters
followed by .... The arguments after the fixed ones are passed by C's rules: integers as at
least an int, float32 as a float64. Nothing checks them against the format string, so a %d
given a float64 prints nonsense.
A tiko procedure can be variadic too, for C to call or for tiko's own use: declare it cdecl with
..., then walk the arguments with a cva_list: cva_start( list, last ) starts after the
last fixed parameter, cva_arg( list, T ) takes the next one as type T, and cva_end( list )
finishes. Nothing says how many arguments there are or what types they have; the fixed
parameters must, as count does here and the format string does for printf.
A cva_list is 8 bytes on Windows (a pointer) and 24 on Linux (a small record), so it is not
something to store or pass by value across platforms. Pass it to C's v functions — vsnprintf
and the rest — which take a va_list.
sizeof( cva_list ) names the type directly and gives the same 8 and 24.
Calling conventions and names
On 32-bit x86, C had several calling conventions — cdecl, stdcall, pascal, fastcall and
thiscall — that differed in which side cleaned up the stack and in what order the arguments
went. x86-64 has one per platform: Microsoft's on Windows and the System V convention on
Linux. tiko compiles only for x86-64, so cdecl and stdcall mean the same thing and can be used
interchangeably; a Windows header's WINAPI (which is stdcall) and a plain cdecl describe
the same call.
The exception is pascal, which passes its arguments in reverse order on every platform.
Assigning a pascal procedure to a cdecl function pointer is warning TKO0830, because called
through the pointer, its arguments arrive backwards.
Each convention also decides the name the linker sees:
'' Three linkages for one kind of function. --dump-mangle shows the
'' symbol each becomes.
function Plain( byval x as int32 ) as int32
return x + 1
end function
extern "C"
function c_style( byval x as int32 ) as int32
return x + 2
end function
end extern
extern "C++"
namespace geo
function cpp_style( byval x as int32 ) as int32
return x + 3
end function
end namespace
end extern
print Plain( 1 ); c_style( 1 ); geo.cpp_style( 1 )
2 3 4
tiko --dump-mangle mangle.tiko prints the symbol each procedure becomes, the same on both
targets:
PLAIN
c_style
_ZN3geo9cpp_styleEi
A plain procedure's symbol is its name in capitals, because tiko's names are case-insensitive.
Inside extern "C" the name is kept exactly as written, which is what a C library expects to
find. Inside extern "C++" it is mangled the way a C++ compiler would, with the namespace
and the parameter types folded into the symbol, so a tiko declaration can link to a C++ function
that is not extern "C": geo::cpp_style( int ) is _ZN3geo9cpp_styleEi. Only functions link
this way; C++ classes, templates and exceptions do not cross. --dump-mangle is the way to find
out why a link fails with an unresolved symbol: compare its spelling with what the library
exports.
Linking
A declaration says a function exists; the linker needs the library that contains it. There are three ways to name one:
#inclib "name"in the source links the libraryname:libname.aorlibname.soon Linux,name.libon Windows.#libpath "dir"adds a directory to search.-l nameand-p diron the command line do the same from outside.lib "name"on a declaration, or on anexternblock, links the library too, as if#inclibhad been written.
The linker searches the directories from -p and #libpath first, in the order given, then the
toolchain's own library directory, and the current directory last, so a stray library there
cannot replace the runtime that was shipped with the compiler. #libpath directories are relative
to where the compiler runs, not to the source file — which is what the next section fixes.
A library inside the project: #foreignlib
'' A hand-written binding: the library, and a declaration per function.
#foreignlib GEOM static "geom/geom"
type geom_point
as float64 x, y
end type
type geom_visit as sub cdecl( byval p as const geom_point ptr, _
byval user as any ptr )
extern "C" lib GEOM
declare function geom_version( ) as const zstring ptr
declare function geom_distance( byval a as geom_point, _
byval b as geom_point ) as float64
declare function geom_centroid( byval pts as const geom_point ptr, _
byval n as int32, _
byval out_ as geom_point ptr ) _
as int32
declare sub geom_each( byval pts as const geom_point ptr, _
byval n as int32, byval fn as geom_visit, _
byval user as any ptr )
end extern
'' A callback C will call: cdecl, and only the parameters C passes.
sub Visit cdecl( byval p as const geom_point ptr, _
byval user as any ptr )
dim as int32 ptr count = user
*count += 1
print " visit"; *count; ": ("; p->x; ","; p->y; " )"
end sub
print *geom_version( )
dim pts( 0 to 2 ) as geom_point = _
{ ( 0, 0 ), ( 4, 0 ), ( 4, 3 ) }
print "distance:"; geom_distance( pts( 0 ), pts( 2 ) )
dim c as geom_point
if geom_centroid( @pts( 0 ), 3, @c ) then
print "centroid: ("; c.x; ","; c.y; " )"
end if
dim as int32 seen = 0
geom_each( @pts( 0 ), 3, @Visit, @seen )
print "visited"; seen
geom 1.0
distance: 5
centroid: ( 2.666666666666667, 1 )
visit 1: ( 0, 0 )
visit 2: ( 4, 0 )
visit 3: ( 4, 3 )
visited 3
geom is a small C library that ships with this chapter: geom.h and geom.c, a build.sh
that builds it, and one prebuilt static library per platform:
geom/geom.h
geom/geom.c
geom/build.sh
geom/geom_windows_x86_64.lib
geom/geom_linux_x86_64.a
#foreignlib GEOM static "geom/geom" binds the name GEOM to that library. Three things set
it apart from #inclib:
- The path is relative to the file the directive is in, not to wherever the compiler is run. A binding can sit in a subdirectory of a project, with its libraries beside it, and work from any working directory.
- The platform picks the file. The compiler adds the target to the name, most specific
first:
geom_windows_x86_64,geom_windows,geom_x86_64, then plaingeom, each with the platform's extensions. One directive serves every platform the project ships libraries for, with no#ifdef. staticordynamicmust be said, and limits which files can match:.aand.libfor static;.so,.dll,.dll.aand.libfor dynamic. On Windows a.libcan be either kind — a static library or the import library of a DLL — so it matches both.
The bound name then goes where a library name would: extern "C" lib GEOM. A #foreignlib
library is linked whether or not anything names it, as #inclib's is.
A spec beginning system: names an installed library instead of a file: "system:m" links
Linux's maths library, exactly as #inclib "m" would, and its static or dynamic is not
checked.
When nothing matches, the error lists every file that was tried. On Windows:
'' A library spec that matches no file: the error lists every name
'' that was tried, most specific first.
#foreignlib SHAPES static "geom/shapes"
extern "C" lib SHAPES
declare function shapes_version( ) as const zstring ptr
end extern
print *shapes_version( )
nolib.tiko(3,1): error TKO0329: #foreignlib found no matching library
file: SHAPES static geom/shapes; tried:
geom/shapes_windows_x86_64.a
geom/shapes_windows_x86_64.lib
geom/shapes_windows.a
geom/shapes_windows.lib
geom/shapes_x86_64.a
geom/shapes_x86_64.lib
geom/shapes.a
geom/shapes.lib
(The first line is one line; it is broken here to fit the page.)
The other #foreignlib errors are about the directive itself: TKO0327 and TKO0328 when the
name, static/dynamic or the quoted spec is missing, and TKO0330 when one name is bound twice
to different libraries.
The geom listing passes a geom_point by value to geom_distance. That works because the
type has C's layout, and tiko passes it by the platform's rules for a 16-byte struct — by hidden
reference on Windows, in two floating-point registers on Linux — exactly as a C compiler would.
Reading the C header: #cinclude
'' The same library, with the declarations taken from its own header.
#cinclude once "geom/geom.h"
#foreignlib GEOM static "geom/geom"
sub Visit cdecl( byval p as const geom_point ptr, _
byval user as any ptr )
print " ("; p->x; ","; p->y; " )"
end sub
print *geom_version( ); ", major version"; GEOM_VERSION_MAJOR
dim pts( 0 to 2 ) as geom_point = _
{ ( 0, 0 ), ( 4, 0 ), ( 4, 3 ) }
print "distance:"; geom_distance( pts( 0 ), pts( 2 ) )
geom_each( @pts( 0 ), 3, @Visit, nullptr )
geom 1.0, major version 1
distance: 5
( 0, 0 )
( 4, 0 )
( 4, 3 )
Writing a binding by hand means copying every type and declaration out of the header, and
getting each one right. #cinclude reads the header itself: it hands geom.h to clang,
the C compiler, and turns what clang reports into tiko declarations, for the platform being
built. The listing has no type, no declare and no extern: geom_point, geom_visit, all
four functions and the constant GEOM_VERSION_MAJOR come from the header. #foreignlib still
does the linking; the two do separate jobs.
What comes across: structs and unions, enums, typedefs, functions (variadic ones included),
function pointer types, global variables, and #defines that are constants. What does not: macros
with parameters, which are C code rather than declarations; C++ headers; and NULL, which is 0 —
write nullptr. Every name keeps its C spelling through alias, and a name that would collide with
a tiko keyword gets an underscore — out becomes out_.
The layout is checked, not trusted. The importer writes tiko declarations and lets tiko lay out each struct, then compares every size, alignment and field offset against clang's. A struct whose layout disagrees is left out rather than imported wrong.
A quoted header is looked for in two places: first under the directory the compiler runs in, then
beside the file that says #cinclude. So this listing compiles from any directory, as
#foreignlib's path does. A header that includes others with angle brackets, as in
#include <lib/other.h>, is a different matter: clang looks for those only in the directories
given with -cinclude, and in the system's own.
A header under the working directory wins over the one beside the file. If the directory you
compile from happens to hold a geom/geom.h of its own, an older copy or another library's, that
is the header #cinclude imports, and nothing says so. #include searches the other way round,
beside the file first. Keep a vendored header's path specific to its library.
#cinclude needs libclang, which comes with the LLVM installation tiko already uses; a program
without #cinclude never loads it. The result is cached, so a large header such as windows.h
is slow the first time and quick after.
Steering the importer
The importer takes options of its own, each passed to clang:
| Option | Does |
|---|---|
-cinclude dir | adds a directory clang searches for headers (C's -I) |
-cdefine name[=value] | defines a C macro before the header is read (C's -D) |
-csysroot dir | the root of the C headers and libraries for the target |
-copt arg | passes any other argument to clang unchanged |
-cnomacros | leaves #define constants out |
-cnoverify | skips the layout check — there is no good reason to |
-cnocache, -ccachedir dir | skip the cache, or keep it somewhere else |
To see what #cinclude would produce, -cimport header -cimport-out file writes the binding
as a file instead (- for the screen), and -cimport-manifest file adds clang's layout of every
struct. For geom.h it writes an extern "C" block holding the struct —
type geom_point
x as double
y as double
end type
— a type geom_visit as sub cdecl( … ) for the callback, one declare … cdecl alias "…" per
function, and const GEOM_VERSION_MAJOR = 1. They are the declarations geom-hand.tiko wrote by
hand. A generated binding can be kept in the project and compared with the next version of the
header, which shows exactly what the library changed.
On Windows, #define UNICODE before #cinclude once "windows.h" makes the unsuffixed Windows
names such as MessageBox mean the W (UTF-16) functions; without it they mean the A ones.
Both suffixed forms are always there.
Loading a library at run time
'' Load a library while the program runs, and find a function in it.
#if defined( __FB_WIN32__ )
const LIBNAME = "ucrtbase.dll"
#elseif defined( __FB_DARWIN__ )
const LIBNAME = "libm.dylib"
#else
const LIBNAME = "libm.so.6"
#endif
type MathFn as function cdecl( byval x as float64 ) as float64
dim as any ptr lib_ = dylibload( LIBNAME )
if lib_ = nullptr then
print "could not load the library"
end 1
end if
'' dylibsymbol returns an address, or nullptr for a name it lacks.
dim as MathFn cosine = dylibsymbol( lib_, "cos" )
dim as MathFn missing = dylibsymbol( lib_, "no_such_function" )
print "found cos: "; cbool( cosine <> nullptr ); _
", found no_such_function: "; cbool( missing <> nullptr )
if cosine <> nullptr then
print "cos( 0 ) ="; cosine( 0.0 )
print "cos( pi ) ="; cosine( atn( 1 ) * 4 )
end if
dylibfree( lib_ )
found cos: true, found no_such_function: false
cos( 0 ) = 1
cos( pi ) =-1
A library linked at build time must be present for the program to start. dylibload( name )
opens a shared library while the program runs and returns a handle, or nullptr if it cannot. A
program can then decide what to do when a plugin or an optional library is missing.
dylibsymbol( handle, "name" ) finds a function or variable by name and returns its address,
or nullptr; assigning it to a function pointer of the right type makes it callable.
dylibfree closes the library, after which every address taken from it is invalid.
The names differ by platform: name.dll on Windows, libname.so (often with a version, as in
libm.so.6) on Linux, libname.dylib on macOS. The listing loads the C runtime's maths library
on each — ucrtbase.dll, libm.so.6 and libm.dylib — and calls its cos.
dylibsymbol returns an untyped address, so the function pointer's type is a promise exactly as a
declare is. Nothing checks it. A plugin system typically agrees on one entry point with a fixed
signature, looks that up, and receives everything else from it.
A tiko program can be the library: tiko -dll plugin.tiko builds a DLL or shared object, and
tiko -lib a static library, whose procedures C or another tiko program can call.
What it costs
- A call to C costs what a call to tiko costs. There is no wrapper and no conversion: the arguments go in registers and on the stack exactly as C would put them.
- Strings are copied when they come back.
*pcopies C's characters into a tiko string.strptrgoing in copies nothing. #cincludecosts compile time on the first build, seconds for a large header, and almost nothing after, from the cache.dylibloadanddylibsymbolcost a search at run time. Look a symbol up once and keep the pointer.
Summary
declare … cdecl alias "name", or anextern "C"…end externblock, declares a C function.lib "name"links its library.intisint32,long longisint64,doubleisfloat64. C'slongisclong: 4 bytes on Windows, 8 on Linux.wchar_tis 2 bytes on Windows, 4 on Linux, likewstring.- A
typehas C's struct layout;field = npacks it. Austringnever belongs in a struct C reads. strptrpasses a string's bytes to C;*pcopies C's back. Memory C allocates, C frees.- A callback is a
cdeclprocedure, or a lambda that captures nothing. ...declares a variadic C function;cva_list,cva_start,cva_arg,cva_endwrite one.- x86-64 has one calling convention per platform;
pascalalone reverses its arguments.extern "C++"mangles names as C++ does;--dump-mangleshows them. #inclib,#libpath,-l,-plink installed libraries.#foreignlib name static|dynamic "path"links one inside the project, relative to the declaring file, picked per platform.#cinclude "header.h"turns a C header into declarations through clang, layout-checked; the header is found under the working directory, then beside the file, then in-cincludedirectories.dylibload,dylibsymbolanddylibfreeload a library while the program runs.
Exercises
- Bind C's
bsearchand use it to look values up in a sorted array, with acdeclcomparison function. {solution="ex29-01"} - Bind
strtol, using its end pointer to read every number at the start of a string and report where the numbers stopped. Use the right type for C'slong. {solution="ex29-02"} - Add a function
geom_areatogeom.cthat returns the area of a polygon, rebuild the library withbuild.sh, and call it through#cinclude. What did you have to change on the tiko side? - Rewrite
callbacks.tikoso the comparison sorts by distance from a point chosen at run time. Why can the comparison not be a capturing lambda, and how does theuserpointer ofgeom_eachsolve the same problem? - Build a tiko procedure into a DLL or shared object with
-dll, and load it from another program withdylibload.
Using C libraries
Chapter 28 used SQLite through vendor/sqlite3, one of the toolchain's vendor packages: a C
library that arrives ready to #import. The toolchain ships a handful of those. Every other C
library — and there are thousands, for databases, compression, graphics, sound, networking —
you connect yourself.
Real libraries arrive as an archive from a project's website, with the files arranged the way their authors chose, built for compilers you may not have, in several variants whose differences the archive does not explain. Before the first line of your program compiles you have to choose a package, unpack it, give tiko the library's declarations, decide which file to link, and make sure the program can find the library again when it runs, on your machine and on someone else's.
tiko does it in two steps, and they are the steps every vendor package was made with:
tiko c2ttranslates the library's C header, once, into a tiko header: a.tifile that reads like the C it came from, which you look over, finish where it says so, and keep with your code.#foreignliblinks the library's compiled code, statically or dynamically, choosing the right file for each platform.
This chapter takes four of the most widely used C libraries through both steps:
| Library | Version | What it is for | Licence |
|---|---|---|---|
| SQLite | 3.54.0 | an SQL database in a single file, with no server | public domain |
| raylib | 6.0 | games and graphics: windows, drawing, input, sound | zlib |
| GLFW | 3.5.1 | a window and an OpenGL context, and the input that goes with them | zlib |
| SDL3 | 3.4.18 | windows, rendering, audio, input and more, for games and media | zlib |
Each one is taken from its download page to a running program, linked both statically and dynamically, kept either inside the project or installed once for every program on the machine, on Windows and on Linux. The programs are the libraries' own first examples, translated into tiko, so you can put them beside the library's documentation and read the two together.
The SQLite programs run on macOS too: fetch-libs.sh builds SQLite's static archive and its
.dylib there. raylib, GLFW and SDL link Apple's frameworks — Cocoa, IOKit, OpenGL — and tiko
cannot name a framework on its link line yet, so their programs in this chapter are for Windows
and Linux. On a Mac, the toolchain's vendor/SDL3 links Homebrew's SDL instead.
Every listing in this chapter was compiled and run on both platforms, against exactly the versions in the table.
Before you start
Building a program that uses a translated header needs nothing more than tiko and LLVM, as
Chapter 1 set up. Translating a header needs more, because tiko c2t hands the header to
Clang and Clang has to read every header it includes:
- libclang, Clang as a library, which the LLVM installation provides.
- The Windows headers, on Windows. c2t reads a header once for each of tiko's targets. For the Windows pass Clang reads the C runtime's and the Windows SDK's headers from the Visual Studio Build Tools: install them, free, with Desktop development with C++.
- The Linux headers, for the Linux pass. On Linux they are already there, with the
-devpackage of any library a header includes. On Windows, the headers of a Linux installation in WSL serve, named with--sysroot.
All of that is needed only by whoever translates. The .ti it writes is an ordinary tiko
file: anyone who builds the program afterwards, on either platform, needs no Clang, no Visual
Studio and no C headers. This chapter translates on Windows, with Ubuntu in WSL for the Linux
pass. On Linux, c2t translates a header that includes nothing beyond C's freestanding headers
(stddef.h, stdint.h, stdbool.h, stdarg.h), as sqlite3.h and raylib.h do; one that
includes <GL/gl.h> or <stdio.h> fails its Windows pass there, for want of the Windows
headers.
Two more tools are used to build libraries from their source: CMake, from cmake.org or your
package manager, and on Linux a C compiler and the development packages a library lists,
named in each library's section.
The shell commands in this chapter are written for bash, and the same commands work on both
platforms: on Windows, run them in Git Bash, which comes with Git for Windows. The LLVM tools they
use (clang, llvm-lib, llvm-dlltool) are in LLVM's bin directory, C:\Program Files\LLVM\bin
by default; add it to PATH, or write the full path.
What a library package contains
Open any C library's download and you find the same few kinds of file, under different names.
| File | Windows | Linux | What it is for |
|---|---|---|---|
| header | .h | .h | the declarations: what tiko c2t translates |
| static library | .lib | .a | the library's compiled code, copied into your program when it links |
| shared library | .dll | .so, .so.6, .so.6.0.0 | the compiled code as a separate file, loaded when your program starts |
| import library | .lib | none | Windows only: a small file that tells the linker which DLL holds each function |
On Windows, .lib means two different things. A static .lib holds the library's code; an
import .lib holds none, only the names of the functions and the name of the DLL they live
in. You cannot tell them apart by the name, only by the size and by the package's documentation:
raylib's raylib.lib is 5 MB of code, its raylibdll.lib 400 KB of names.
The linker never reads a .dll. To link with a DLL, you link with its import library, and the
DLL is used only when the program runs. If a package ships a DLL without one, #foreignlib makes
one for you when the program links; the SQLite section shows it.
Choosing the right package
Libraries publish several builds of the same version. Two choices matter for tiko:
- 64-bit only. tiko builds x86-64 programs. A package marked
win32,x86,i386ori686cannot be linked into them. - On Windows, the Visual C++ build. tiko's Windows target,
windows-x86_64, uses Microsoft's conventions and Microsoft's C runtime, the same as Visual C++. Packages are labelledmsvc,VC,vc2022or similar. Themingwpackages are built for GCC's conventions on Windows, and are not the ones this chapter uses.
On Linux the choice is simpler: a library built by your distribution's compiler, for x86-64.
Translating a header
Chapter 29's #cinclude reads a C header at every build and keeps what it learns in a cache
for the compiler alone. tiko c2t reads the header once and writes a tiko header that a
person can read:
tiko c2t vendor/sqlite/sqlite3.h --verify
It writes vendor/sqlite/sqlite3.ti, beside the header, in about three seconds. Here is a piece
of sqlite3.h, the type every connection is and two of its result codes:
typedef struct sqlite3 sqlite3;
SQLITE_API int sqlite3_close(sqlite3*);
#define SQLITE_ROW 100 /* sqlite3_step() has another row ready */
#define SQLITE_DONE 101 /* sqlite3_step() has finished executing */
and the same lines in sqlite3.ti:
type sqlite3__fwd as sqlite3
declare function sqlite3_close( byval as sqlite3 ptr ) as int32
#define SQLITE_ROW 100 /' sqlite3_step() has another row ready '/
#define SQLITE_DONE 101 /' sqlite3_step() has finished executing '/
Every declaration is where the C put it, with its comments, its blank lines and SQLite's
documentation around it, so the .ti reads alongside the library's manual. C's types become
tiko's fixed-width ones — int is int32, const char * is const zstring ptr — the same on
both targets. SQLITE_API, the macro SQLite puts before every function, is recognised as
decoration and dropped. A struct the header declares but never defines, like sqlite3, is an
opaque type: a program can hold a pointer to one and nothing else.
Two platforms in one file
c2t parses the header twice, once as Clang would compile it for windows-x86_64 and once for
linux-x86_64, and merges the two. What is the same on both is written once. What differs is
written under a guard the translation makes, #if defined(TIKO_OS_WINDOWS) … #endif, and the
header's own #if lines are kept, with C's platform tests spelled as tiko's: _WIN32 becomes
TIKO_OS_WINDOWS, __linux__ becomes TIKO_OS_LINUX. One .ti serves both platforms, which is
why it can be committed and shared.
A branch neither platform takes — an #ifdef SQLITE_ENABLE_SESSION for an optional feature, an
#elif for macOS — is translated as well, inside its #if. Define the macro before including
the .ti, and the program sees what a C program that defined it would see.
The banner
Every translation opens with a banner, which is the first thing to read:
'' sqlite3.ti -- tiko header translated by c2t (tiko 0.1.0-dev)
'' from the C header "sqlite3.h"
'' source sha256 : cd20a2877bc82b94...c581aac6e240925535cbd90166
'' clang : clang version 21.1.0
'' passes : x86_64-pc-windows-msvc, x86_64-pc-linux-gnu
'' options : --verify
''
'' Generated from C. Search for C2T-TODO and translate those parts by
'' hand.
''
'' Renamed:
'' parameter sqlite3_bind_text64.encoding
'' -> sqlite3_bind_text64.encoding_ (tiko reserved word)
'' ...
'' constant double -> double_ (tiko reserved word)
''
'' 1 item needs manual translation:
'' line 11414 sqlite3.h:11450 C2T-TODO(macro) double
(Wrapped here to fit the page, with the hash shortened and five renames left out; and this is the banner of a plain
--verify run, before the --shared-macro pass that Variables a DLL exports adds.) The SHA-256
names the exact header that was translated, so the .ti says which version of the library it
belongs to. There is no date in it: translating the same header again gives the same bytes, and
after an upgrade, a diff of the .ti shows exactly what the library changed.
Renamed lists every C name that is a tiko word. encoding and type are keywords in tiko,
so a parameter called that gets an underscore. Parameter names do not affect a call, so these
renames cost nothing; a renamed function or type would be called by its new name.
Items that need manual translation are the parts c2t could not carry. Each is marked where it stands, the C quoted underneath:
#ifdef SQLITE_OMIT_FLOATING_POINT
'' C2T-TODO(macro) sqlite3.h:11450 -- 'double' is a tiko keyword:
'' #undef would remove it
'' # undef double
#endif
(The TODO's first line is one line in the file.)
The exit status says how it went: 0, clean; 2, translated, with TODOs to read; 1, nothing written. A TODO is API that is missing until someone writes it by hand, and the reason in brackets says why. Most fall into three kinds:
- A C feature tiko cannot express: a variadic macro (
__VA_ARGS__), a macro that pastes tokens with##, astatic inlinefunction, whose body c2t quotes for porting because there is no symbol in the library to link against. - A system header with no tiko counterpart. c2t knows C's standard headers (
<stdio.h>becomescrt/stdio.bi), Windows' (<windows.h>becomeswindows.bi) and OpenGL's; anything else is a TODO. - A branch Clang cannot parse even when it is switched on: C++ under
#ifdef __cplusplus, or a Vulkan type in a header that expects Vulkan's own to have been included first.
Read the list and decide, item by item, whether your program needs it. SQLite's one TODO sits in a branch that only a build without floating point would take, and nothing a program calls is missing. GLFW, below, has twenty-two, and none of them is on its way.
A NOTE is information rather than a gap: the translation is complete, and something deserves a
look. SQLite's SQLITE_TRANSIENT is a C macro that casts -1 to a function pointer; c2t
translates it, cast(sqlite3_destructor_type, (-1)), and notes that Clang gives no value for a
pointer to check it against.
--verify compiles the translation for both targets inside c2t and compares every struct's
size, alignment and member offsets, and every integer macro's value, with Clang's. A struct that
differs is refused and marked, never written wrong. It costs almost nothing — sqlite3.h takes
three seconds with it and without — so use it every time.
A translation describes one version of the library. Link sqlite3.ti, made from 3.54.0's
header, with an older SQLite, and a function the old library lacks is a link error, which is
noticed. A struct that changed shape between versions is not: the program compiles, links and
runs, and reads the wrong bytes. Translate the header that came in the same package as the
library you link, and translate again whenever you upgrade either. The banner's SHA-256 says
which header a .ti was made from.
Finishing a translation
When a TODO is something your program needs, write it by hand — but not in the generated
file, where the next translation would overwrite it. Put it in a file of its own beside it,
sqlite3-residue.ti say, included after the translation, with a line saying why each item is
there. The vendor packages do exactly this: vendor/sqlite3 is the translated sqlite3.ti, a
sqlite3-residue.ti, and the line that links the library.
Variables a DLL exports
Most C libraries export only functions, and a translated header calls them the same way however
the library is linked. A variable the library exports is different on Windows: a program
linked to the DLL must reach it through the import table, which tiko writes extern import, and a
program linked statically must not. SQLite has three, among them sqlite3_version. Without help,
a dynamic program that reads one fails to link:
lld-link: error: undefined symbol: sqlite3_version
c2t solves it with one more pass, given the library's shared-build switch: the macro that
makes the header declare things for a DLL. raylib's is USE_LIBTYPE_SHARED. SQLite has none of
its own, so the switch is its export macro defined the way a DLL build would define it:
tiko c2t vendor/sqlite/sqlite3.h --verify \
--shared-macro "SQLITE_API=__attribute__((dllimport))"
Each exported variable is then written both ways, the import form under
defined(TIKO_LINK_DYNAMIC). A program linked to the DLL defines that before it includes the
translation:
#define TIKO_LINK_DYNAMIC 1
#include once "vendor/sqlite/sqlite3.ti"
#foreignlib SQLITE dynamic "vendor/sqlite/sqlite3"
and print sqlite3_version prints 3.54.0. A vendor package defines it for you, from the
link on its #import.
Headers that include each other
c2t translates one header per run. An #include of another of the library's headers becomes
#include once of that header's .ti, at the same relative path, so translating each header
into the same tree gives a tree of .ti files that include one another as the .h files did.
--verify compiles each translation, so the headers a header includes must be translated first.
For a library with many headers, a script walks them in that order; SDL's section shows one.
Because the includes are relative, a translated library needs no include directory on the
command line, wherever it is kept. Its .ti files find each other as they found each other in C.
When to use c2t
#cinclude and tiko c2t share tiko's C importer, so a type comes across the same way in both.
Use #cinclude for a quick experiment, or a header you never want to look at. Use tiko c2t for
a library your program depends on: the translation is a file you can read, search, review in a
pull request and diff on an upgrade; it builds on machines that have no Clang; and the parts that
did not translate are written down where they are, rather than discovered at a call.
Static or dynamic
Every library in this chapter can be linked either way. The difference is where its code ends up.
Linked statically, the library's code is copied into your program. The program is one file; nothing can go missing when it is copied to another machine; and only the parts of the library the program uses are copied in. It is larger, and updating the library means relinking it.
Linked dynamically, your program holds only references to the library, and the library's own
file, the DLL or .so, must be present when the program starts. The program is small, several
programs can share one copy of the library, and the library can be updated without relinking.
But the program now comes in two parts, and the second part has to be found.
Measured on the programs in this chapter, each linked both ways, in kilobytes of 1,000 bytes:
| Program | Windows, static | Windows, dynamic | Linux, static | Linux, dynamic |
|---|---|---|---|---|
sqlite-quick | 1,168 | 38 + 3,519 | 1,114 | 44 + 1,197 |
raylib-window | 608 | 27 + 1,905 | 996 | 35 + 2,100 |
glfw-window | 227 | 29 + 234 | 331 | 35 + 353 |
sdl-clear | 2,745 | 29 + 2,848 | 3,604 | 40 + 4,096 |
The second figure in each dynamic column is the shared library that has to travel with the program. A static program is smaller than the program plus its library, because the linker copies in only what is called.
All four licences allow either way. That is not true of every library: under the LGPL, for instance, linking statically brings obligations that linking dynamically does not. Read a library's licence before you decide.
#foreignlib says which, after the name it gives the library:
#foreignlib SQLITE dynamic "vendor/sqlite/sqlite3"
#foreignlib SQLITE static "vendor/sqlite/sqlite3-static"
The path is relative to the file the directive is in, and names the library without its platform's prefix or extension. The rest of this section is about what happens after the link.
Where a program finds its shared libraries
On Windows, a program looks for a DLL first in its own directory, then in the system
directories, then in the current directory and the directories on PATH. Put the DLL beside the
.exe and it is found. When it is not, the program does not start; from a console it exits at
once with code -1073741515, which is 0xC0000135, DLL not found, and prints nothing.
On Linux, a program looks for a .so in the directories listed in LD_LIBRARY_PATH, then
in the system's library directories (/usr/lib, /usr/local/lib and the others the system's
ldconfig knows), and not in its own directory. When it fails, it says so:
./raylib-dll: error while loading shared libraries: libraylib.so.600:
cannot open shared object file: No such file or directory
and exits with code 127. The name it looks for is the library's soname, a name recorded
inside the .so when it was built (libraylib.so.600), not the name of the file you linked.
That is why a Linux package carries the same library under three names, such as
libraylib.so, libraylib.so.600 and libraylib.so.6.0.0: the first is for the linker, the
second is the soname the program will ask for, and the third is the real file.
For a library installed on the system, nothing more is needed. For one inside your project, there
are two ways to say where it is. The quick one is to run the program with the project's directory
on LD_LIBRARY_PATH:
LD_LIBRARY_PATH=. ./sqlite-quick
The lasting one is an rpath: a directory recorded inside the program, which the loader searches
before the system's own directories. The usual value is $ORIGIN, which the loader reads as "the
directory this program is in", so a program and its .so files can be copied anywhere together.
It is given to the linker, through tiko's -Wl option, which passes its value to the linker
unchanged, split at commas:
#cmdline "-x raylib-rpath -Wl -rpath,$ORIGIN"
#include once "vendor/raylib/raylib.ti"
#foreignlib RAYLIB dynamic "vendor/raylib/raylibdll"
This listing is raylib-dll with one more line. It runs with no LD_LIBRARY_PATH at all:
drew 60 frames
Typed in a shell, $ORIGIN must be in single quotes, or the shell replaces it with an empty
variable before tiko sees it: tiko game.tiko -Wl '-rpath,$ORIGIN'. In a #cmdline, as here, and
in a tiko.toml's options, no shell is involved and it needs no quoting. On Windows, where a
program looks beside itself anyway, -Wl passes options to lld-link instead: -Wl /STACK:8388608
reserves an 8 MB stack.
On Linux, a missing library is not always an error. Ubuntu has libsqlite3.so.0 installed
for its own use. A program linked with the SQLite 3.54.0 in this chapter's project asks for that
same soname, so run without LD_LIBRARY_PATH it starts without complaint, using the system's
copy, an older version, and nothing says so. The same program prints SQLite 3.54.0 with
LD_LIBRARY_PATH=. and SQLite 3.45.1 without it. A program that depends on a newer feature
fails much later, and somewhere that looks unrelated. An rpath of $ORIGIN puts your copy first;
and when it matters which copy is loaded, check it the way the listings here do: ask the library
for its version.
A static library brings its dependencies with it
A static library is the library's code and nothing else. Whatever that code calls becomes your
program's to link. raylib calls Windows' multimedia timer, so a program that links raylib
statically must also link winmm; SDL asks Windows about the devices plugged in, so it needs
setupapi. A shared library carries those links inside itself, which is why the dynamic listings
in this chapter name no system libraries.
#foreignlib takes them after the library itself, each as "system:name":
#foreignlib RAYLIB static "vendor/raylib/raylib", _
"system:winmm", "system:gdi32", "system:user32", "system:shell32"
To find the list for a library, look first in its documentation: most say what a static build needs. If they do not, link without any and read what the linker cannot find:
lld-link: error: undefined symbol: __declspec(dllimport) SetupDiGetClassDevsA
lld-link: error: undefined symbol: __declspec(dllimport) CoInitializeEx
lld-link: error: undefined symbol: __declspec(dllimport) SysFreeString
Each name is a function of the operating system. On Windows, Microsoft's documentation for the
function names the library in its Requirements table: SetupDiGetClassDevsA is in
setupapi.lib, CoInitializeEx in ole32.lib, SysFreeString in oleaut32.lib. On Linux,
the manual page or the package that provides the function says which library holds it. Add one
system: entry for each library, and link again until nothing is missing.
Vendored or installed
Separately from static or dynamic, you choose where the library lives.
Vendored means the project carries its own copy, in a directory of its own, usually
vendor/. Anyone who checks out the project can build it with nothing to install; the version is
the one you tested with, and stays that way until you change it; and two projects can use two
different versions without meeting. The cost is that each project carries its own copy.
Installed means one copy, in a shared place, used by every program on the machine. On Linux that place exists already, and the package manager fills it. On Windows there is no such place for C libraries, so you make one.
A vendored library
A translated header and #foreignlib were both made for vendoring. #include finds a quoted name
beside the file that includes it first, and #foreignlib resolves its path relative to the file
that contains the directive, so a program finds the library's declarations and its files wherever
it is built from.
#foreignlib also chooses the file for the target. Given "vendor/raylib/raylib", it tries
raylib_windows_x86_64, raylib_windows, raylib_x86_64 and plain raylib, each with the
target's extensions. So both platforms' files can sit side by side, and one directive serves
both — as one .ti does:
vendor/raylib/raylib.h the C header
vendor/raylib/raylib.ti its translation, committed
vendor/raylib/raylib_windows_x86_64.lib static, for Windows
vendor/raylib/raylib_linux_x86_64.a static, for Linux
vendor/raylib/raylibdll_windows_x86_64.lib import lib for raylib.dll
vendor/raylib/raylibdll_linux_x86_64.so libraylib.so.600, renamed
raylib.dll beside the program
libraylib.so.600 beside the program
The target tags are not optional when both platforms share a folder. On Windows a static search
tries .a before .lib, so an untagged raylib.a built for Linux is found first, and the link
fails with an error that does not say why: lld-link: error: unknown file type: rcore.o.
Notice which files are renamed and which are not. You rename the files the linker reads, to
give them their target tags. You never rename the files the program loads when it runs: the
DLL keeps the name its import library records (raylib.dll) and the .so keeps its soname
(libraylib.so.600), because those are the names the program will ask for.
Of all those files, only the .ti is source. The libraries are built artefacts, recreated from the
download, and the C header is only an input to c2t. A project commits its translations and fetches
the rest; this chapter's examples do exactly that. The script examples/ch30/fetch-libs.sh builds
the vendor/ directory: it downloads each package, checks it against a SHA-256 checksum, and
copies and renames the files, building the ones that have no download. Run it once on each
platform:
bash fetch-libs.sh
The script examples/ch30/translate.sh then holds the c2t commands for all four libraries, the
ones this chapter shows. Between them, the two scripts are the exact record of every step in the
chapter, in order. When the prose says "copy this file there", the scripts are where to look to
see it done.
An installed library
On Linux, the package manager installs a library's header into /usr/include and the library
into /usr/lib/x86_64-linux-gnu. A library you build yourself and install with
sudo cmake --install build goes to /usr/local/include and /usr/local/lib. The linker
searches all of them by default, so a program links an installed library by name alone:
#include once "sqlite3.ti"
#foreignlib SQLITE dynamic "system:sqlite3"
The header still needs translating, once: tiko c2t /usr/include/sqlite3.h -o sqlite3.ti, and
the .ti lives with the project. It describes the version the machine had when it was made, so
retranslate when the distribution upgrades the library.
"system:sqlite3" hands sqlite3 to the linker the way #inclib "sqlite3" would, and the
linker finds libsqlite3.so. Two things to know about it: the static or dynamic before a
system: spec is not checked, and when a directory has both libsqlite3.so and
libsqlite3.a, the linker takes the .so. After installing a library into /usr/local/lib
yourself, run sudo ldconfig once, so that programs can find it when they start.
On Windows you choose a folder, such as C:\tiko-libs, and put each library in it the same
way as vendor/, its translation beside it. A program then needs to be told where that folder is,
which is the job of a binding header, at the end of this chapter.
SQLite
SQLite is an SQL database engine in a library: no server to install or start, and a whole database is one ordinary file. Its authors describe it as the most widely deployed database engine in the world, and it is inside many of the programs you already use.
tiko already ships SQLite, as vendor/sqlite3, and Chapter 28 uses it. This section makes the
same library from its download, which is what you do for a newer SQLite than the toolchain's, or
SQLite built with options of your own — and it is the pattern for every library that has no
vendor package.
Downloading
Everything is on the download page, https://sqlite.org/download.html:
| File | What is in it |
|---|---|
sqlite-amalgamation-3540000.zip | sqlite3.c and sqlite3.h: the whole library as one C file, with its header |
sqlite-dll-win-x64-3540000.zip | sqlite3.dll and sqlite3.def, the library already built for 64-bit Windows |
sqlite-autoconf-3540000.tar.gz | the same sqlite3.c and sqlite3.h, with a build script, for Unix |
The number in each name is the version: 3.54.0 is 3540000.
SQLite is unusual in shipping its source as one file. It is meant to be compiled into your program directly, which makes a static library easy to build yourself.
The header
sqlite3.h goes to vendor/sqlite/, and is translated there, with the shared-build pass from
Variables a DLL exports:
tiko c2t vendor/sqlite/sqlite3.h --verify \
--shared-macro "SQLITE_API=__attribute__((dllimport))"
sqlite3.h includes nothing but <stdarg.h>, which tiko has built in, so it needs no
--sysroot, and c2t translates it on Linux as well. Exit status 2, and the one TODO from The
banner.
Windows: the dynamic library
The DLL zip has sqlite3.dll and sqlite3.def, a text file that lists every function the DLL
exports, but no import library. You do not have to make one. Put the DLL into the project
under its target-tagged name, and the original beside the program:
vendor/sqlite/sqlite3_windows_x86_64.dll the DLL, for the link
sqlite3.dll the same file, for the program to load
When #foreignlib SQLITE dynamic "vendor/sqlite/sqlite3" finds only a .dll, the link reads the
DLL's own table of exports and makes the import library from it, with llvm-dlltool from the
LLVM installation tiko already uses, then deletes it with the other intermediate files. Run with
-v, the llvm-dlltool command is printed before the link.
The import library names the DLL as the DLL names itself, the name it was built with,
sqlite3.dll, and not by the name of the file it was read from. That is the Windows counterpart
of a soname: the copy in vendor/ can carry its target tag, and the program still loads
sqlite3.dll. A DLL that is not 64-bit x86-64 is TKO0172, with the reason:
error TKO0172: cannot make an import library for 'lib/w32_windows_x86_64.dll':
it is not a 64-bit x86-64 DLL (machine &h014C)
(One line on the screen, its path shortened here.) That is the 32-bit download, taken by mistake.
Windows: an import library of your own
To keep an import library in the project instead, so the link does not make one each time, make
it from the .def with llvm-dlltool:
llvm-dlltool -m i386:x86-64 -d sqlite3.def -D sqlite3.dll \
-l vendor/sqlite/sqlite3_windows_x86_64.lib
-m i386:x86-64 asks for a 64-bit import library, -d names the export list, -l the library
to write, and -D the DLL that the program will load. The import library records that name, and
the program asks for exactly that file when it starts.
An import library with no DLL name links, and the program then cannot start. sqlite3.def
has no LIBRARY line naming its DLL, so the name must come from the command line. Made with
llvm-lib /def:sqlite3.def and no name, the import library is accepted, the program links
cleanly, and running it fails at once with 0xC0000135: Windows was asked to load a DLL called
nothing. llvm-readobj --coff-imports on the .exe shows it, an import block with an empty
Name:. Give the name with -D sqlite3.dll, or add a first line LIBRARY sqlite3.dll to the
.def.
A .lib beside the DLL under one of the names #foreignlib tries is found before the .dll, so
when both are there, the import library you made is the one that is linked.
Windows: the static library
Compile the amalgamation with clang and put the one object in a library with llvm-lib:
clang -c -O2 -o sqlite3.obj sqlite3.c
llvm-lib /out:vendor/sqlite/sqlite3-static_windows_x86_64.lib sqlite3.obj
It takes about twenty seconds; sqlite3.c is over nine megabytes of C. On Windows clang compiles
for Microsoft's conventions by default, the same as tiko, and finds the C runtime's headers in
the Visual Studio Build Tools. Since a static .lib and an import .lib cannot share a name, the
static one is called sqlite3-static.
Linux
The same two libraries, from the autoconf archive's sqlite3.c:
gcc -O2 -fPIC -c -o sqlite3.o sqlite3.c
ar rcs vendor/sqlite/sqlite3-static_linux_x86_64.a sqlite3.o
gcc -shared -Wl,-soname,libsqlite3.so.0 \
-o vendor/sqlite/sqlite3_linux_x86_64.so sqlite3.o -lm
cp vendor/sqlite/sqlite3_linux_x86_64.so libsqlite3.so.0
-fPIC makes code that works at any address, which a shared library needs. -Wl,-soname,…
records the soname, the name a program linked with the library will look for. Without one, the
program would record the path it was linked with, vendor/sqlite/sqlite3_linux_x86_64.so, and
work only from the directory it was built in. The last line puts the library beside the
program under its soname.
To install SQLite for every program instead, use the distribution's package, which installs both the header and the library:
sudo apt install libsqlite3-dev
A first program
SQLite's documentation opens with a short C program, SQLite In 5 Minutes Or Less: it opens the database named by its first argument, runs the SQL in its second, and prints every row of the result. Here it is in tiko, linked dynamically:
#include once "vendor/sqlite/sqlite3.ti"
#foreignlib SQLITE dynamic "vendor/sqlite/sqlite3"
'' Called once for each row of the result.
function Callback cdecl( byval notUsed as any ptr, _
byval argc as int32, byval argv as zstring ptr ptr, _
byval colName as zstring ptr ptr ) as int32
for i as int32 = 0 to argc - 1
dim value as ustring = "NULL"
if argv[i] <> nullptr then value = *argv[i]
print *colName[i]; " = "; value
next
print
return 0
end function
if command( 2 ) = "" then
print "Usage: sqlite-quick DATABASE SQL-STATEMENT"
end 1
end if
dim db as sqlite3 ptr
if sqlite3_open( command( 1 ), @db ) <> SQLITE_OK then
print "Can't open database: "; *sqlite3_errmsg( db )
sqlite3_close( db )
end 1
end if
dim errMsg as zstring ptr
if sqlite3_exec( db, command( 2 ), @Callback, nullptr, @errMsg ) _
<> SQLITE_OK then
print "SQL error: "; *errMsg
sqlite3_free( errMsg )
end if
sqlite3_close( db )
Run with an in-memory database and four statements, one string that the shell lets run over several lines:
./sqlite-quick :memory: "create table tbl1(one text, two int);
insert into tbl1 values('hello!', 10);
insert into tbl1 values('goodbye', 20);
select * from tbl1"
one = hello!
two = 10
one = goodbye
two = 20
The two directives at the top are the whole connection to SQLite: the translation for the
declarations, #foreignlib for the code. Everything else is the C program, line for line, and it
shows the parts of Chapter 29 that a real library uses most:
- Opaque handles.
sqlite3is a struct the header declares but never defines, so a program can hold only a pointer to one.sqlite3_openfillsdbthrough its address,@db, the way the C passes&db. - Strings into C.
command( 2 )is a tiko string; passed where the header saysconst char *, the compiler passes a pointer to its bytes. - Strings out of C.
sqlite3_errmsgreturnsconst char *, and*sqlite3_errmsg( db )copies the text into a tiko string. - A callback.
sqlite3_execcallsCallbackonce per row, soCallbackiscdecland takes exactly the parameters the translated declaration says:any ptr,int32, and twozstring ptr ptr.argv[i]is theith column's text, or a null pointer for SQL'sNULL. - Memory C allocated. On failure,
sqlite3_execpointserrMsgat a message SQLite allocated; SQLite's documentation says to free it withsqlite3_free, so the program does.
Prepared statements, linked statically
Gluing values into SQL text is how SQL injection happens, and the text has to be parsed again
for every row. Real programs prepare a statement once, with ? where the values go, and then
bind a value to each ?. This program does that, linked statically:
#include once "vendor/sqlite/sqlite3.ti"
#foreignlib SQLITE static "vendor/sqlite/sqlite3-static"
sub Check( byval rc as int32, byval db as sqlite3 ptr )
if rc <> SQLITE_OK andalso rc <> SQLITE_DONE then
print "SQLite error "; rc; ": "; *sqlite3_errmsg( db )
end 1
end if
end sub
print "SQLite "; *sqlite3_libversion( )
dim db as sqlite3 ptr
Check( sqlite3_open( ":memory:", @db ), db )
Check( sqlite3_exec( db, "create table city( name text, pop int )", _
nullptr, nullptr, nullptr ), db )
dim names( 0 to 2 ) as string = _
{ "St. John's", "Gander", "Corner Brook" }
dim pops( 0 to 2 ) as int32 = { 110525, 11880, 19316 }
dim ins as sqlite3_stmt ptr
Check( sqlite3_prepare_v2( db, "insert into city values( ?, ? )", _
-1, @ins, nullptr ), db )
for i as int32 = 0 to 2
'' -1: the text ends at its zero byte. SQLITE_STATIC: the string
'' outlives the statement's use of it, so SQLite need not copy it.
Check( sqlite3_bind_text( ins, 1, names( i ), -1, SQLITE_STATIC ), _
db )
Check( sqlite3_bind_int( ins, 2, pops( i ) ), db )
Check( sqlite3_step( ins ), db )
sqlite3_reset( ins )
next
sqlite3_finalize( ins )
dim sel as sqlite3_stmt ptr
Check( sqlite3_prepare_v2( db, _
"select name, pop from city where pop > ? order by pop desc", _
-1, @sel, nullptr ), db )
sqlite3_bind_int( sel, 1, 15000 )
do while sqlite3_step( sel ) = SQLITE_ROW
print *cast( zstring ptr, sqlite3_column_text( sel, 0 ) ); ": "; _
str( sqlite3_column_int( sel, 1 ) )
loop
sqlite3_finalize( sel )
sqlite3_close( db )
SQLite 3.54.0
St. John's: 110525
Corner Brook: 19316
Only the #foreignlib line differs from the dynamic program:
#include once "vendor/sqlite/sqlite3.ti"
#foreignlib SQLITE static "vendor/sqlite/sqlite3-static"
static instead of dynamic, and the static library's base name. The program now needs no DLL:
copied to another machine, it runs. The translation is the same file: it does not care how the
library is linked.
Chapter 28 covered SQLite's interface itself — preparing, binding, stepping, finalizing. Two
points belong to the translation. SQLITE_STATIC, which promises SQLite the string outlives the
statement's use of it, is one of the pointer macros c2t translated with a NOTE,
cast(sqlite3_destructor_type, 0). And sqlite3_column_text returns const unsigned char *, so the
translation declares it const uint8 ptr; cast it to a zstring ptr to read it as text.
raylib
raylib is a library for making games, designed to be easy to learn: one header, 600 functions with plain names, and no setup beyond opening a window. It draws with OpenGL and handles the keyboard, mouse, gamepads, sound, textures, fonts and 3D models.
Downloading
raylib publishes prebuilt packages on GitHub, https://github.com/raysan5/raylib/releases. For
6.0, two are for tiko:
| File | What is in it |
|---|---|
raylib-6.0_win64_msvc16.zip | include/raylib.h (and raymath.h, rlgl.h); lib/raylib.lib, static; lib/raylibdll.lib, the import library; lib/raylib.dll |
raylib-6.0_linux_amd64.tar.gz | the same headers; lib/libraylib.a, static; lib/libraylib.so.6.0.0 with its links libraylib.so.600 and libraylib.so |
The other packages on the page are for 32-bit Windows (win32), the MinGW compiler, ARM, macOS
and the web.
Installing it in a project
From the Windows package:
| From the package | Into the project |
|---|---|
include/raylib.h | vendor/raylib/raylib.h |
lib/raylib.lib | vendor/raylib/raylib_windows_x86_64.lib |
lib/raylibdll.lib | vendor/raylib/raylibdll_windows_x86_64.lib |
lib/raylib.dll | raylib.dll, beside the program |
From the Linux package:
| From the package | Into the project |
|---|---|
include/raylib.h | vendor/raylib/raylib.h (the same file) |
lib/libraylib.a | vendor/raylib/raylib_linux_x86_64.a |
lib/libraylib.so.6.0.0 | vendor/raylib/raylibdll_linux_x86_64.so |
lib/libraylib.so.6.0.0 | libraylib.so.600, beside the program |
The dynamic base name is raylibdll on both platforms, after the Windows import library, so that
one #foreignlib line finds the right file on each. Then translate the header:
tiko c2t vendor/raylib/raylib.h --verify
Exit status 0: all of raylib's 600 functions, its structs, enums and colours, with nothing left
to finish. raylib exports no variables, so it needs no --shared-macro.
Installing it for every program
On Linux, recent distributions package raylib as libraylib-dev; Ubuntu 24.04 does not.
Without a package, copy the prebuilt files into /usr/local and refresh the loader's cache:
sudo cp include/*.h /usr/local/include/
sudo cp -P lib/libraylib.* /usr/local/lib/
sudo ldconfig
cp -P copies the two links as links. A program then includes its translation of raylib.h and
writes #foreignlib RAYLIB dynamic "system:raylib".
On Windows, see A binding header below.
A first window
raylib's examples start with core_basic_window, which opens a window and writes one line of
text in it:
#include once "vendor/raylib/raylib.ti"
#ifdef __FB_WIN32__
#foreignlib RAYLIB static "vendor/raylib/raylib", _
"system:winmm", "system:gdi32", "system:user32", _
"system:shell32"
#else
#foreignlib RAYLIB static "vendor/raylib/raylib", _
"system:GL", "system:m", "system:pthread", "system:dl", _
"system:rt", "system:X11"
#endif
const ScreenWidth = 800
const ScreenHeight = 450
dim maxFrames as int32 = valint( command( 1 ) ) '' 0: until closed
SetTraceLogLevel( LOG_WARNING ) '' raylib logs to stdout otherwise
InitWindow( ScreenWidth, ScreenHeight, _
"raylib [core] example - basic window" )
SetTargetFPS( 60 )
dim frames as int32 = 0
do until WindowShouldClose( )
BeginDrawing( )
ClearBackground( RAYWHITE )
DrawText( "Congrats! You created your first window!", _
190, 200, 20, LIGHTGRAY )
EndDrawing( )
frames += 1
if frames = maxFrames then exit do
loop
CloseWindow( )
print "drew "; str( frames ); " frames"
drew 60 frames

Everything after the directives is raylib's example as it is written in C. A few lines are new:
- Linked statically, raylib needs a different set of system libraries on each platform, so
the
#foreignlibline is chosen with#ifdef __FB_WIN32__. On Linux it is the list raylib's documentation gives: OpenGL, the maths library, threads,dl,rtand X11. SetTraceLogLevel( LOG_WARNING )comes first because raylib reports everything it does, about forty lines when a window opens, on standard output. The listings keep it quiet so that their output is the program's own.maxFrameslets the program stop by itself after a number of frames given on the command line, so this book's checks can run it. Started with no argument, it runs until you close the window, as raylib's example does.
Two things in the translation are more than declarations. RAYWHITE and LIGHTGRAY are C macros
that build a struct with a C compound literal:
#define RAYWHITE CLITERAL(Color){ 245, 245, 245, 255 }
c2t asks Clang for each field's value and writes the tiko equivalent:
#define RAYWHITE type<Color>(245, 245, 245, 255)
And DrawText takes that Color by value, a four-byte struct passed the way C passes it, as
Chapter 29 described for geom_point.
Linked dynamically
The same program, with its first lines replaced:
#include once "vendor/raylib/raylib.ti"
#foreignlib RAYLIB dynamic "vendor/raylib/raylibdll"
No system libraries this time: raylib.dll and libraylib.so.600 already link everything they
need. The program is 27 KB instead of 608 KB on Windows, and the DLL must travel with it.
GLFW
GLFW does less than raylib, on purpose. It creates windows, gives each one an OpenGL context, the surface OpenGL draws on, and reports keyboard, mouse, gamepad and window events. All drawing is left to OpenGL, or to Vulkan. It is the library to reach for when you want to write the graphics code yourself.
Downloading
On Windows, take 64-bit Windows binaries from https://www.glfw.org/download.html,
glfw-3.5.1.bin.WIN64.zip. It holds include/GLFW/glfw3.h and one folder of libraries per
compiler:
| Folder | What is in it |
|---|---|
lib-vc2022 | the one to use: glfw3.lib, static; glfw3dll.lib, the import library; glfw3.dll |
lib-vc2017, lib-vc2019, lib-vc2026 | the same, from other Visual C++ versions |
lib-static-ucrt | a DLL built with the C runtime linked into it |
lib-mingw-w64 | for the MinGW compiler |
lib-vc2022 also has glfw3_mt.lib. GLFW's own README.md explains the difference: glfw3.lib
is for programs that use the C runtime as a DLL, and glfw3_mt.lib for programs that link it
statically. tiko's programs use the C runtime DLL, ucrtbase.dll, so take glfw3.lib.
On Linux, GLFW publishes source only: glfw-3.5.1.zip from the same page. Its documentation
lists what building it needs, for both of Linux's window systems, X11 and Wayland:
sudo apt install libwayland-dev libxkbcommon-dev xorg-dev
Then build it twice with CMake, once as a static library and once as a shared one (a \ at
the end of a line continues the command on the next):
cmake -S glfw-3.5.1 -B build-static -DCMAKE_BUILD_TYPE=Release
cmake --build build-static
cmake -S glfw-3.5.1 -B build-shared -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=ON
cmake --build build-shared
build-static/src/libglfw3.a becomes vendor/glfw/glfw3_linux_x86_64.a, and
build-shared/src/libglfw.so.3 becomes both vendor/glfw/glfw3dll_linux_x86_64.so and, beside
the program, libglfw.so.3. To build for Wayland alone, without the X11 packages, add
-DGLFW_BUILD_X11=OFF; this book's checks were built that way.
To install GLFW for every program on Linux, sudo apt install libglfw3-dev gives the
distribution's version, which on Ubuntu 24.04 is 3.3.10; or run sudo cmake --install build-shared after the build above.
The header
The package's include/ goes to vendor/glfw/include/. glfw3.h includes OpenGL's header,
<GL/gl.h>, and the Linux pass has to read it, so on Windows it needs the Linux headers:
tiko c2t vendor/glfw/include/GLFW/glfw3.h \
--sysroot //wsl.localhost/Ubuntu --verify
//wsl.localhost/Ubuntu is the root of the Ubuntu installation in WSL, as Windows sees it, and
GL/gl.h comes from its Mesa development package (sudo apt install libgl-dev). Without
--sysroot, c2t stops and says what it needs:
tiko-lang: error TKO0120: unable to import C header
vendor/glfw/include/GLFW/glfw3.h (x86_64-pc-linux-gnu)
note: clang: vendor/glfw/include/GLFW/glfw3.h:241:12: fatal error:
'GL/gl.h' file not found
note: the Linux pass needs Linux's system headers: give --sysroot
(on Windows, the WSL root, e.g. //wsl.localhost/Ubuntu)
(Three lines on the screen.)
Reading the Linux headers through WSL is slow, so the translation takes about twenty seconds.
OpenGL itself is not translated: c2t maps <GL/gl.h> to tiko's own GL/gl.bi, which the
toolchain ships.
Exit status 2, with twenty-two TODOs, and none of them is in this program's way. Sixteen are
headers c2t has no counterpart for — Vulkan's, OpenGL ES's, macOS's and GLU's — each under the
#if that asks for it; four are functions whose parameters are Vulkan types, which Clang cannot
parse without Vulkan's header; and two are the lines around a GLAPIENTRY definition that other
OpenGL headers use. A program that uses GLFW with Vulkan would translate Vulkan's header first.
This one draws with OpenGL, and loses nothing.
A window and an OpenGL context
GLFW's documentation shows this program first: a window, an OpenGL context, and a loop that clears the window until it is closed.
#include once "vendor/glfw/include/GLFW/glfw3.ti"
#ifdef __FB_WIN32__
#foreignlib GLFW static "vendor/glfw/glfw3", "system:opengl32", _
"system:gdi32", "system:user32", "system:shell32"
#else
#foreignlib GLFW static "vendor/glfw/glfw3", "system:GL", _
"system:m"
#endif
'' GLFW reports every error here, as it happens.
sub OnError cdecl( byval code as int32, _
byval description as const zstring ptr )
print "GLFW error "; hex( code ); ": "; *description
end sub
dim maxFrames as int32 = valint( command( 1 ) ) '' 0: until closed
glfwSetErrorCallback( @OnError )
if glfwInit( ) = GLFW_FALSE then end 1
dim win as GLFWwindow ptr = _
glfwCreateWindow( 640, 480, "Hello World", nullptr, nullptr )
if win = nullptr then
glfwTerminate( )
end 1
end if
glfwMakeContextCurrent( win )
dim frames as int32 = 0
do until glfwWindowShouldClose( win )
glClear( GL_COLOR_BUFFER_BIT )
glfwSwapBuffers( win )
glfwPollEvents( )
frames += 1
if frames = maxFrames then exit do
loop
glfwTerminate( )
dim as int32 major, minor, revision
glfwGetVersion( @major, @minor, @revision )
print "GLFW "; str( major ); "."; str( minor ); "."; str( revision ); _
": drew "; str( frames ); " frames"
GLFW 3.5.1: drew 60 frames
glClear and GL_COLOR_BUFFER_BIT are not GLFW's. They are OpenGL's, declared in GL/gl.bi,
which glfw3.ti includes as glfw3.h includes GL/gl.h. But the code behind them is OpenGL's,
in opengl32.dll on Windows and libGL.so on Linux, so the program links OpenGL itself:
"system:opengl32" and "system:GL". A program that called only GLFW would not need them.
OnError is a callback GLFW calls whenever something fails, with an error code and a
description. glfwSetErrorCallback( @OnError ) installs it before glfwInit, so that even a
failure to start is explained, which is why GLFW's documentation sets it first.
The dynamic version keeps the OpenGL library, for the same reason:
#include once "vendor/glfw/include/GLFW/glfw3.ti"
#ifdef __FB_WIN32__
#foreignlib GLFW dynamic "vendor/glfw/glfw3dll", "system:opengl32"
#else
#foreignlib GLFW dynamic "vendor/glfw/glfw3dll", "system:GL"
#endif
Through opengl32.dll, Windows offers only OpenGL 1.1, the version from 1997, which is where
glClear comes from. The functions of every later version are looked up while the program runs,
with glfwGetProcAddress, usually by a loader library such as glad. That is OpenGL's design on
every platform, not something tiko adds.
SDL3
SDL, the Simple DirectMedia Layer, is the oldest and broadest of the four: windows, 2D rendering, a GPU interface, audio, gamepads, cameras, file dialogs, threads and timers, on desktop and mobile platforms alike. Many commercial games and emulators are built on it. SDL3 is its current version.
Downloading
From https://github.com/libsdl-org/SDL/releases, release 3.4.18:
| File | What is in it |
|---|---|
SDL3-devel-3.4.18-VC.zip | for Visual C++: include/SDL3/*.h, and in lib/x64/ the import library SDL3.lib and SDL3.dll |
SDL3-3.4.18.tar.gz | the source, for Linux and for a static library on Windows |
SDL3-3.4.18-win32-x64.zip | SDL3.dll alone, for people who run a program, not for building one |
There is no static library in the Windows package, and no Linux package at all: SDL is built from
its source for both. Into the project go include/ as vendor/sdl3/include/, SDL3.lib as
vendor/sdl3/SDL3_windows_x86_64.lib, and SDL3.dll beside the program.
On Linux, SDL's docs/README-linux.md lists the packages its build uses; it uses whatever it
finds, so more packages give more features. Then:
cmake -S SDL3-3.4.18 -B build -DCMAKE_BUILD_TYPE=Release \
-DSDL_STATIC=ON -DSDL_SHARED=ON
cmake --build build
build/libSDL3.a becomes vendor/sdl3/SDL3-static_linux_x86_64.a; build/libSDL3.so.0 becomes
vendor/sdl3/SDL3_linux_x86_64.so and, beside the program, libSDL3.so.0. To install it for
every program, sudo cmake --install build; distributions newer than Ubuntu 24.04 package it as
libsdl3-dev.
A library of headers
SDL's interface is not one header but dozens. SDL.h brings in fifty-seven others, and they
include one another the way C programs include them:
#include <SDL3/SDL_stdinc.h>
c2t translates one header per run, so SDL is translated by a loop: start at SDL.h, visit each
header it includes before the header itself, and translate them in that order, so that every
header's includes are already translated when --verify compiles it. -I names SDL's include
directory, where Clang finds <SDL3/...>:
tiko c2t vendor/sdl3/include/SDL3/SDL_stdinc.h -I vendor/sdl3/include \
--sysroot //wsl.localhost/Ubuntu --verify
is one step of it; translate.sh has the whole loop. Two of SDL's headers are left out:
SDL_begin_code.h and SDL_close_code.h only wrap the others in compiler decorations, which c2t
recognises and strips where they are used, and neither can be parsed on its own.
SDL_main.h is not among SDL.h's includes, and a program that uses SDL's main callbacks needs
it. That makes fifty-nine translations in all, in under five minutes. In C it supplies the program's main function; a tiko program has its own beginning, so it is
translated with SDL's macro for that case, SDL_MAIN_HANDLED, defined:
tiko c2t vendor/sdl3/include/SDL3/SDL_main.h -I vendor/sdl3/include \
--sysroot //wsl.localhost/Ubuntu -D SDL_MAIN_HANDLED --verify
A -D is written into the translation as a #define, so SDL_main.ti reads the macro exactly as
Clang did.
Twenty-six of the fifty-nine end with exit status 2, and their banners list 1,704 TODOs between
them. The number looks worse than it is. 1,175 are in SDL_oldnames.h, a table of SDL2's names
kept to help port SDL2 programs: in C it turns each old name into a compile error that names its
replacement, and without it an old name is still an error in tiko, an undeclared one. Most of the
rest are in SDL_stdinc.h and its neighbours, SDL's portable C library — #if tests on what the
C compiler predefines, printf format macros, alloca — the plumbing SDL is built from rather
than the interface a program calls. The TODOs that are API are a short list: twenty-eight static inline functions, among them SDL_rect.h's rectangle tests such as SDL_PointInRect, quoted for
porting. A program that needs one writes it in a residue file of its own.
Each SDL3/X.h gets an SDL3/X.ti beside it, and #include <SDL3/SDL_stdinc.h> became #include once "SDL_stdinc.ti", relative to the header that includes it. So a program includes SDL by its
path in the project and needs no include directory at all.
The main callbacks
SDL3 can run a program for you: instead of writing the main loop, you give SDL four functions,
and it calls the first once at the start, the second for every event, the third once per frame,
and the fourth at the end. It is SDL's recommended way to write a program, because it works the
same on platforms where a program cannot own its main loop, such as phones and the web. SDL's
example renderer/01-clear is written that way; here it is in tiko:
#include once "vendor/sdl3/include/SDL3/SDL.ti"
#include once "vendor/sdl3/include/SDL3/SDL_main.ti"
#foreignlib SDL3 dynamic "vendor/sdl3/SDL3"
'' Everything the callbacks share, reached through SDL's appstate.
type AppState
window as SDL_Window ptr
renderer as SDL_Renderer ptr
frames as int32
maxFrames as int32
end type
function AppInit cdecl( byval appstate as any ptr ptr, _
byval argc as int32, byval argv as zstring ptr ptr ) _
as SDL_AppResult
SDL_SetAppMetadata( "Example Renderer Clear", "1.0", _
"com.example.renderer-clear" )
if SDL_Init( SDL_INIT_VIDEO ) = false then
print "Couldn't initialize SDL: "; *SDL_GetError( )
return SDL_APP_FAILURE
end if
var st = new AppState
st->maxFrames = valint( command( 1 ) ) '' 0: until closed
*appstate = st
if SDL_CreateWindowAndRenderer( "examples/renderer/clear", _
640, 480, SDL_WINDOW_RESIZABLE, _
@st->window, @st->renderer ) = false then
print "Couldn't create window/renderer: "; *SDL_GetError( )
return SDL_APP_FAILURE
end if
SDL_SetRenderLogicalPresentation( st->renderer, 640, 480, _
SDL_LOGICAL_PRESENTATION_LETTERBOX )
return SDL_APP_CONTINUE
end function
function AppEvent cdecl( byval appstate as any ptr, _
byval event as SDL_Event ptr ) as SDL_AppResult
if event->type = SDL_EVENT_QUIT then return SDL_APP_SUCCESS
return SDL_APP_CONTINUE
end function
function AppIterate cdecl( byval appstate as any ptr ) as SDL_AppResult
var st = cast( AppState ptr, appstate )
var now = SDL_GetTicks( ) / 1000.0
var red = 0.5 + 0.5 * SDL_sin( now )
var green = 0.5 + 0.5 * SDL_sin( now + SDL_PI_D * 2 / 3 )
var blue = 0.5 + 0.5 * SDL_sin( now + SDL_PI_D * 4 / 3 )
SDL_SetRenderDrawColorFloat( st->renderer, red, green, blue, _
SDL_ALPHA_OPAQUE_FLOAT )
SDL_RenderClear( st->renderer )
SDL_RenderPresent( st->renderer )
st->frames += 1
if st->frames = st->maxFrames then return SDL_APP_SUCCESS
return SDL_APP_CONTINUE
end function
sub AppQuit cdecl( byval appstate as any ptr, _
byval result as SDL_AppResult )
var st = cast( AppState ptr, appstate )
if st <> nullptr then
print "drew "; str( st->frames ); " frames"
delete st
end if
end sub
'' SDL calls AppInit once, then AppEvent and AppIterate until one of
'' them returns something other than SDL_APP_CONTINUE, then AppQuit.
var rc = SDL_EnterAppMainCallbacks( 0, nullptr, @AppInit, _
@AppIterate, @AppEvent, @AppQuit )
end rc
drew 60 frames
The connection to SDL comes first:
#include once "vendor/sdl3/include/SDL3/SDL.ti"
#include once "vendor/sdl3/include/SDL3/SDL_main.ti"
#foreignlib SDL3 dynamic "vendor/sdl3/SDL3"
SDL.tibrings the whole of SDL's interface, through the.tifiles it includes.SDL_main.tideclaresSDL_EnterAppMainCallbacks, the function that runs the callbacks. It is whatSDL_main.h'smainwould have called, and the program calls it at the end, passing the four procedures by address.
The four callbacks are cdecl and take exactly the parameters SDL's header gives their types,
SDL_AppInit_func and the rest. Get one wrong, say int32 ptr where SDL passes void *, and
the compiler says so with warning TKO0833: passing different pointer types, but builds the
program anyway. Treat that warning as an error: called with the wrong parameter types, a callback
reads garbage.
State without globals. The callbacks share a window and a renderer, and a C program would
keep them in global variables, as SDL's example does. SDL offers a better place: AppInit gets
the address of a pointer, appstate, and whatever it stores there is passed to every later
callback. The listing creates one AppState with new, stores it, and AppQuit deletes it.
bool results. SDL3's functions return C's bool, which the translation declares
boolean, and the listing compares them with false: if SDL_Init( SDL_INIT_VIDEO ) = false then.
Constants. SDL_PI_D and SDL_ALPHA_OPAQUE_FLOAT are #defines in SDL's headers with
number values, and they are #defines in the translation, with the same values.
SDL reads the environment variable SDL_VIDEO_DRIVER to choose its video system, and the value
dummy gives it one that draws nowhere. A program that opens windows can then run where there is
no screen, such as on a build server, and still exercise all of its code:
SDL_VIDEO_DRIVER=dummy ./sdl-clear 60.
SDL linked statically
SDL's Windows package has no static library, so build one from the source archive, with CMake and the Visual Studio Build Tools:
cmake -S SDL3-3.4.18 -B build -DSDL_STATIC=ON -DSDL_SHARED=OFF
cmake --build build --config Release
build/Release/SDL3-static.lib, 13 MB, becomes vendor/sdl3/SDL3-static_windows_x86_64.lib.
SDL uses more of Windows than the other three, and linked statically it needs five system
libraries, found by the method from A static library brings its dependencies with it:
#include once "vendor/sdl3/include/SDL3/SDL.ti"
#include once "vendor/sdl3/include/SDL3/SDL_main.ti"
#ifdef __FB_WIN32__
#foreignlib SDL3 static "vendor/sdl3/SDL3-static", _
"system:cfgmgr32", "system:ole32", "system:oleaut32", _
"system:setupapi", "system:shell32"
#else
#foreignlib SDL3 static "vendor/sdl3/SDL3-static"
#endif
On Linux the static library needs nothing more: SDL finds the window system and the sound
system while the program runs, rather than linking them. On both platforms the program now runs
with no SDL3.dll or libSDL3.so.0 beside it.
A binding header
Each program so far has named its library's files itself. That is fine for one program, but it repeats in each, with the platform's system libraries every time, and it ties every program to where the files are. Move those lines into a header of their own, a binding header, and a program needs one line:
'' raylib-binding.ti -- raylib 6.0 for tiko: its translated header,
'' and the library to link, in one file a program includes. Both paths
'' are relative to this file, so the folder it sits in can live
'' anywhere: inside one project, or in a single place that every
'' program shares.
#pragma once
#include once "raylib/raylib.ti"
#ifdef __FB_WIN32__
#foreignlib RAYLIB static "raylib/raylib", _
"system:winmm", "system:gdi32", "system:user32", _
"system:shell32"
#else
#foreignlib RAYLIB static "raylib/raylib", _
"system:GL", "system:m", "system:pthread", "system:dl", _
"system:rt", "system:X11"
#endif
Both paths in raylib-binding.ti are relative to the binding header itself: #include looks
beside the file that includes, and #foreignlib always resolves that way. So the folder holding
raylib-binding.ti and raylib/ can be anywhere: inside one project, or in one place that every
program on the machine shares.
A program includes it by name, and the project says where the folder is with -i, which adds a
directory to the ones #include searches:
# A program that uses raylib without saying where raylib is: the
# -i below names the folder of bindings, relative to this file.
[package]
name = "game"
[target.game]
main = "main.tiko"
options = ["-i", "../vendor"]
#include once "raylib-binding.ti"
const ScreenWidth = 800
const ScreenHeight = 450
dim maxFrames as int32 = valint( command( 1 ) ) '' 0: until closed
SetTraceLogLevel( LOG_WARNING )
InitWindow( ScreenWidth, ScreenHeight, _
"raylib [core] example - keyboard input" )
dim ball as Vector2 = type( ScreenWidth / 2, ScreenHeight / 2 )
SetTargetFPS( 60 )
dim frames as int32 = 0
do until WindowShouldClose( )
if IsKeyDown( KEY_RIGHT ) then ball.x += 2
if IsKeyDown( KEY_LEFT ) then ball.x -= 2
if IsKeyDown( KEY_UP ) then ball.y -= 2
if IsKeyDown( KEY_DOWN ) then ball.y += 2
BeginDrawing( )
ClearBackground( RAYWHITE )
DrawText( "move the ball with arrow keys", 10, 10, 20, DARKGRAY )
DrawCircleV( ball, 50, MAROON )
EndDrawing( )
frames += 1
if frames = maxFrames then exit do
loop
CloseWindow( )
print "ball at "; str( ball.x ); ", "; str( ball.y ); _
" after "; str( frames ); " frames"
tiko build game, then out/debug/game 60:
ball at 400, 225 after 60 frames
This is raylib's example core_input_keys, which moves a ball with the arrow keys. Its ball is
a Vector2, a struct of two floats, filled with type( … ) and passed by value to
DrawCircleV; KEY_RIGHT and the other keys are the values of a C enum.
That is the Windows answer to "install once for every program". Put each library's folder and
its binding header in one place, say C:\tiko-libs:
C:\tiko-libs\raylib-binding.ti
C:\tiko-libs\raylib\raylib.ti
C:\tiko-libs\raylib\raylib_windows_x86_64.lib
Then every project that uses raylib writes #include once "raylib-binding.ti" and adds
options = ["-i", "C:/tiko-libs"] to its tiko.toml, or -i C:\tiko-libs on the command
line. Notice what is not there: the C header. Once translated, it is no longer needed to build.
For a library linked dynamically, its DLL still has to be found when the program runs: copy it
beside each program, or add its folder to PATH.
A binding module
A binding can also be a module, which Chapter 15 introduced: a directory a program #imports.
A module's names are its own unless it marks them public, and that is true of an #include
inside one too. Written as public #include, the header travels with the module, whole, and
every program that imports it sees its names exactly as if it had included the header itself:
'' raylib as a module. 'public #include' makes the names of raylib.ti,
'' the translation of raylib.h, the names of every program that imports
'' the module, and the module's own #foreignlib links the library into
'' each of them. Both paths are relative to this file.
#module "raylib"
public #include once "../../vendor/raylib/raylib.ti"
#ifdef __FB_WIN32__
#foreignlib RAYLIB static "../../vendor/raylib/raylib", _
"system:winmm", "system:gdi32", "system:user32", _
"system:shell32"
#else
#foreignlib RAYLIB static "../../vendor/raylib/raylib", _
"system:GL", "system:m", "system:pthread", "system:dl", _
"system:rt", "system:X11"
#endif
# A program that imports raylib as a module: the -i below names the
# folder that holds module folders, relative to this file.
[package]
name = "modgame"
[target.modgame]
main = "main.tiko"
options = ["-i", "../modules"]
#import "raylib"
dim maxFrames as int32 = valint( command( 1 ) ) '' 0: until closed
SetTraceLogLevel( LOG_WARNING )
InitWindow( 800, 450, "raylib [core] example - basic window" )
SetTargetFPS( 60 )
dim frames as int32 = 0
do until WindowShouldClose( )
BeginDrawing( )
ClearBackground( RAYWHITE )
DrawText( "Congrats! You created your first window!", _
190, 200, 20, LIGHTGRAY )
EndDrawing( )
frames += 1
if frames = maxFrames then exit do
loop
CloseWindow( )
print "drew "; str( frames ); " frames"
tiko build modgame, then out/debug/modgame 60:
drew 60 frames
The program writes #import "raylib" and nothing else: InitWindow, the LOG_WARNING constant
and the RAYWHITE macro come from the module, and the module's #foreignlib links the library
into the program. The names are bare, as an #include would make them, not raylib.InitWindow,
because a C header's #defines and struct macros are preprocessor names, which no namespace can
hold.
This is exactly the shape of the toolchain's vendor packages. vendor/sqlite3 is a module whose
file holds a public #include of its translated sqlite3.ti, another of its hand-written
sqlite3-residue.ti, and a #foreignlib for each link mode, chosen by the link its importer
wrote. Chapter 28's one line, #import "vendor/sqlite3", is this section's binding module, made
by the people who ship tiko.
A plain #include in a module stays private: the module can use a C library to do its work
without handing the whole C interface to everyone who imports it. public #include outside a
module file is TKO0369.
To install a binding module for every program, put its folder in one directory and name that
directory in TIKO_PATH, the list of directories #import searches, separated by ; on
Windows and : on Linux. Because a translated library's headers include one another by relative
path, any library can be a module this way, SDL's fifty-nine headers included.
When something goes wrong
Every failure in this table happened while this chapter was being written.
| What you see | What it means | What to do |
|---|---|---|
c2t: TKO0120: unable to import C header, fatal error: 'X.h' file not found | Clang cannot find a header the header includes | for one of the library's own, add -I; for a system header in the Linux pass on Windows, --sysroot |
| c2t exits with status 2 | the translation has TODOs | read the banner's list; finish what you need in a file of its own |
c2t: TKO0120 on a header that included without matching another | the header cannot stand alone | leave it out: it is meant only to be included |
TKO0329: #foreignlib found no matching library file, with a list of names tried | no file has any of the names in the list | rename the library to one of them, usually with its target tag |
TKO0172: cannot make an import library for '…': it is not a 64-bit x86-64 DLL | the DLL is the 32-bit build | download the 64-bit package |
undefined symbol: … when linking statically | a system library the static library needs is not linked | add it as "system:name"; see A static library brings its dependencies with it |
undefined symbol: a variable, when linking to a DLL | the program reads a variable the DLL exports | translate with --shared-macro; define TIKO_LINK_DYNAMIC before the include |
the program exits at once, code -1073741515 (0xC0000135) | Windows cannot find a DLL, or the import library names none | put the DLL beside the program; check the import library has the DLL's name |
error while loading shared libraries: libX.so.N and exit 127 | Linux cannot find the .so | LD_LIBRARY_PATH=., an rpath, or install the library |
| the program runs but reports an older version of the library | Linux found the system's copy, not yours | LD_LIBRARY_PATH=. or an rpath; ask the library its version |
| a C library prints pages of messages | many libraries log to standard output | look for a log level in the library: raylib's is SetTraceLogLevel |
warning TKO0833: passing different pointer types where a callback is passed | the callback's parameters differ from the C type | make them match the translated declaration exactly |
What it costs
- Calling a C library costs what calling C costs, as in Chapter 29: no wrapper, no conversion beyond what the C function itself would need.
- Translating costs seconds, once.
sqlite3.htook three seconds,glfw3.htwenty, through WSL; SDL's fifty-nine headers, run by the loop, under five minutes. A build after that reads a.tilike any tiko header and runs no Clang. - A translation costs upkeep: it belongs to one version of the library, and an upgrade means translating again and reading the diff.
- Static linking costs size: the table in Static or dynamic shows from 200 KB to 2.7 MB added to each program. Dynamic linking costs a second file that must be shipped and found.
- A vendored library costs space in every project that uses it, and keeping it current is your job. An installed library costs reproducibility: the program is built and run with whatever version the machine has.
Summary
- A C library comes as a header, a static library, a shared library and, on Windows, an import library. On Windows take the 64-bit Visual C++ package.
tiko c2t x.h --verifytranslates a header once intox.ti, for both targets in one file, laid out like the C, with layouts checked against Clang's. Only the person translating needs Clang, the Visual Studio Build Tools and, on Windows, a Linux sysroot through--sysroot.- The banner names the header by SHA-256 and lists every rename and every TODO. Exit 2
means TODOs: read them, and write what you need in a file of its own, never in the
.ti. - One header per run; includes become relative
#include once "x.ti", so a library's translations find each other with no include directory.-Iand-Dpass C's options;--shared-macrowrites a DLL's variables forTIKO_LINK_DYNAMIC. #foreignlib NAME static|dynamic "path"links the code, relative to its own file, choosingname_windows_x86_64.liborname_linux_x86_64.aby target. A static link also needs the system libraries the library calls, each as"system:name".- Linked dynamically, the library must be found when the program starts: beside the
.exeon Windows; on Linux, in the system's directories, onLD_LIBRARY_PATH, or in an rpath:-Wl -rpath,$ORIGINrecords "beside the program". A Linux program asks for its library by soname, and a missing one may silently be replaced by the system's own copy. - A vendored library lives in the project: commit the
.ti, fetch the binaries. An installed library on Linux is linked with"system:x"; its header still gets translated. - SQLite: compile the amalgamation for a static library; its DLL needs no import library. raylib: prebuilt, translates clean. GLFW: needs the Linux headers to translate; its TODOs are Vulkan's and GLES's. SDL3: fifty-nine headers, translated by a loop in include order.
- A binding header holds a library's
#includeand#foreignlib, relative to itself. A binding module does the same withpublic #include, and is installed throughTIKO_PATH— which is exactly what a vendor package is.
Exercises
- Run
sqlite-quickwith a file name instead of:memory:, once to create a table and insert a row, and again to select it. Then open the file with SQLite's ownsqlite3command-line tool, from the precompiled binaries on the download page. - Link
sqlite-quickstatically and compare its size and its start-up with the dynamic version. On Linux, run both withoutLD_LIBRARY_PATHand explain the difference in what they print with aselect sqlite_version(). - Translate
raymath.h, which is invendor/raylib/besideraylib.h, and read its TODOs. Which kind of C is most of them, and which of raymath's functions can a program still call? - In
raylib-window.tiko, draw the frame number in the window with raylib'sTextFormat, which is variadic like C'sprintf. What type must the argument have for%i? - Add a key callback to
glfw-window.tikowithglfwSetKeyCallback, so that Escape closes the window by callingglfwSetWindowShouldClose. - Make
sdl-clear.tiko'sAppEventreturnSDL_APP_SUCCESSwhen Escape is pressed: an event whosetypeisSDL_EVENT_KEY_DOWNcarries the key inevent->key.key, to compare withSDLK_ESCAPE. - Make SDL a binding module, the way
modules/raylibmakes raylib one, and atiko buildproject that uses it to runsdl-clear. Which-idoes the project need, and why does it need nothing for SDL's fifty-nine headers?
Building and Shipping
Every program so far was built the same way: one command, one source file, one executable. Real projects outgrow that. They are split into parts compiled separately, they produce libraries for other programs, they need a Windows program with no console window or an icon, a debug build and a fast one, and in the end a set of files that runs on someone else's machine. This chapter is about the command line that does all of that, and about what a shipped program depends on.
Targets
tiko builds for three targets, chosen with -target:
| Target | Builds | Default on |
|---|---|---|
windows-x86_64 | a Windows x86-64 executable, linked against Microsoft's Universal C Runtime | Windows |
linux-x86_64 | a Linux x86-64 executable, linked against the system's C library | Linux |
macos-arm64 | a macOS executable for Apple Silicon, linked by Apple's linker against the macOS SDK | macOS |
The compiler turns tiko into LLVM's intermediate form and hands it to clang, which produces
machine code and drives the linker. tiko looks for clang in the place LLVM installs it, then on
the PATH; the environment variable TIKO_CLANG names a particular one instead. Its
runtime library, the code behind print, strings and files, comes with the compiler, under its
lib directory; TIKO_LIBPATH points it at another copy, and TIKO_INCLUDE does the same
for the headers.
'' What the compiler says about the target, through predefined symbols.
#ifdef __FB_WIN32__
print "platform: Windows"
#endif
#ifdef __FB_LINUX__
print "platform: Linux"
#endif
#ifdef __FB_DARWIN__
print "platform: macOS"
#endif
#ifdef __FB_MSVC__
print "C runtime: Microsoft's Universal CRT"
#else
print "C runtime: the system's C library"
#endif
print "pointer size:"; sizeof( any ptr ); " bytes"
print "backend: "; __FB_BACKEND__
On Windows:
platform: Windows
C runtime: Microsoft's Universal CRT
pointer size: 8 bytes
backend: llvm
On Linux:
platform: Linux
C runtime: the system's C library
pointer size: 8 bytes
backend: llvm
On macOS:
platform: macOS
C runtime: the system's C library
pointer size: 8 bytes
backend: llvm
The predefined symbols are how a program adapts to its target: __FB_WIN32__, __FB_LINUX__
and __FB_DARWIN__ for the platform, __FB_MSVC__ when the C runtime is Microsoft's, __FB_BACKEND__ for the code
generator. Code inside an #ifdef that does not apply is never compiled, so it may call
functions that do not exist on the other platform.
Separate compilation
'' Compiled on its own with 'tiko -c greet.tiko'; main.tiko calls it.
sub Greet( byref who as ustring )
print "hello, "; who
end sub
'' The top level of a module that is not main runs first, as a module
'' constructor.
print "greet.tiko is set up"
'' Linked with the object file greet.o: 'tiko main.tiko greet.o'.
declare sub Greet( byref who as ustring )
print "main starts"
Greet( "world" )
greet.tiko is set up
main starts
hello, world
tiko -c greet.tiko compiles without linking and writes greet.o, an object file.
tiko main.tiko greet.o compiles main.tiko and links the two. An object file is
recompiled only when its source changes, so a large program is rebuilt a part at a time — the
same job the module system (Chapter 15) does by itself for code split into modules.
One module owns the program's main code, the top level that runs as the program. By default
it is the first file named on the command line; -m name chooses another. Every other
module's top-level code becomes a module constructor and runs before main, which is why
greet.tiko is set up comes first. tiko greet.tiko main.tiko, with the files the other way
round, makes greet.tiko the main module: main.tiko's lines run first and greet.tiko is set up last.
With -c, no module is main unless -m says so. Compiling every file with -c and linking
only object files ends in a linker error about a missing entry point — on Windows,
lld-link: error: subsystem must be defined. Compile the main module with -c -m name, or
leave it for the final command, as main.tiko is here.
Static libraries
'' A small library, built with 'tiko -lib mathlib.tiko'.
function Gcd( byval a as int64, byval b as int64 ) as int64
do while b <> 0
dim as int64 t = a mod b
a = b
b = t
loop
return abs( a )
end function
function Factorial( byval n as int32 ) as int64
dim as int64 r = 1
for i as int32 = 2 to n
r *= i
next
return r
end function
'' Links libmathlib.a, found in the current directory.
#inclib "mathlib"
declare function Gcd( byval a as int64, byval b as int64 ) as int64
declare function Factorial( byval n as int32 ) as int64
print "Gcd( 84, 36 ) ="; Gcd( 84, 36 )
print "Factorial( 20 ) ="; Factorial( 20 )
Gcd( 84, 36 ) = 12
Factorial( 20 ) = 2432902008176640000
tiko -lib mathlib.tiko compiles the file and packs the result into a static library,
libmathlib.a, on both targets. A program names it with #inclib "mathlib" (or -l mathlib),
without the lib prefix or the extension, and the linker copies in the parts the program uses.
The program then carries that code inside it, and runs with nothing more beside it.
The library's user needs a declaration of each procedure, written out as use-static.tiko does
or, better, kept in a header (mathlib.ti) that the library ships beside itself and every user
includes. The linker searched the current directory for libmathlib.a; -p dir or
#libpath "dir" adds others, searched first (Chapter 29).
Shared libraries
'' The same library as a DLL or shared object: 'tiko -dll mathlib.tiko'.
'' 'export' puts a procedure in the library's table of exports.
function Gcd( byval a as int64, byval b as int64 ) as int64 export
do while b <> 0
dim as int64 t = a mod b
a = b
b = t
loop
return abs( a )
end function
function Factorial( byval n as int32 ) as int64 export
dim as int64 r = 1
for i as int32 = 2 to n
r *= i
next
return r
end function
'' Links mathlib.dll through libmathlib.dll.a on Windows, libmathlib.so on
'' Linux, or libmathlib.dylib on macOS. The library must be found again at
'' run time.
#inclib "mathlib"
declare function Gcd( byval a as int64, byval b as int64 ) as int64
declare function Factorial( byval n as int32 ) as int64
print "Gcd( 84, 36 ) ="; Gcd( 84, 36 )
print "Factorial( 20 ) ="; Factorial( 20 )
Gcd( 84, 36 ) = 12
Factorial( 20 ) = 2432902008176640000
tiko -dll mathlib.tiko (or -dylib) builds a shared library: one file that every
program using it loads when it starts, so it is stored once and can be replaced without
rebuilding the programs. On Windows that file is mathlib.dll, and beside it tiko writes
libmathlib.dll.a, an import library that tells the linker what the DLL contains. On Linux
it is libmathlib.so, and on macOS libmathlib.dylib. The program links it exactly as it linked the static one, with
#inclib "mathlib".
export after a procedure's declaration puts it in the library's table of exported names.
On Windows only exported procedures can be called from outside the DLL: built without export,
the client fails to link with undefined symbol: GCD. Mark every procedure that is part of the
library's interface, and nothing else.
A shared library must be found again when the program runs. Linking only records its name.
Windows looks in the program's own directory, the system directories, the current directory and
the PATH. Linux looks in the system's library directories and in LD_LIBRARY_PATH, and a tiko
executable records no directory of its own — run from beside the library, the client still fails
with error while loading shared libraries: libmathlib.so: cannot open shared object file. This
chapter's listing runs it with LD_LIBRARY_PATH=.; a shipped program installs the library where
the system looks, or ships a script that sets the variable. macOS loads the library by the name
it gives itself, @rpath/libmathlib.dylib, and stops the program with dyld: Library not loaded
until it is found: DYLD_LIBRARY_PATH=. does that, as the listing does, and so does a run path
in the program, #cmdline "-Wl -rpath,@loader_path", which keeps the library beside it.
A variable that a C DLL exports is reached with extern import, which tells the compiler to
go through the pointer Windows fills in when the DLL loads:
extern import MyDll_Data alias "MyDll_Data" as int32.
Against a C DLL exporting int MyDll_Data = 0x1234, that line reads &h1234, and a write or an @
reaches the DLL's own copy. On Linux, extern import compiles as a plain extern.
Windows programs
'' A Windows program with no console window: the GUI subsystem.
#cmdline "-s gui"
#include once "windows.bi"
'' A real program would create a window here. This one shows that it
'' was started without a console, and writes to its standard output,
'' which still works when something has redirected it.
print "has a console window: "; cbool( GetConsoleWindow( ) <> nullptr )
has a console window: false
A Windows executable belongs to a subsystem. The console subsystem, the default, gets a
console window when it is started from the desktop, and shares the one it was started from in a
terminal. -s gui builds for the GUI subsystem instead: no console appears, which is
what a program with windows of its own wants. Its print output goes nowhere unless something has
redirected it, as the book's test harness does here. On Linux there is no such distinction, and
-s gui is accepted and changes nothing.
Resources
// strings.rc -- a resource script, compiled into the executable.
#define IDS_GREETING 101
STRINGTABLE
BEGIN
IDS_GREETING "Hello from a resource"
END
'' Built with 'tiko strings.tiko strings.rc': the .rc is compiled by
'' llvm-rc and linked into the executable.
#include once "windows.bi"
const IDS_GREETING = 101
dim as wstring * 64 buf
var n = LoadStringW( GetModuleHandle( nullptr ), IDS_GREETING, _
@buf, 64 )
dim as ustring text = buf
print "string"; IDS_GREETING; ","; n; " characters: "; text
string 101, 21 characters: Hello from a resource
A Windows executable can carry resources inside it: icons, version information, dialog
layouts, string tables, the manifest that asks for modern controls and high-DPI awareness. They are
written in a resource script, a .rc file, and named on the command line with the sources —
tiko strings.tiko strings.rc. tiko compiles it with llvm-rc, which comes with LLVM, and links
the result in. The program reads a string table entry back with the Windows function
LoadStringW; an ICON line gives the executable the icon Explorer shows.
Build profiles
The same source can be built for finding bugs or for speed.
'' A checked build: -exx adds bounds and null-pointer checks.
#cmdline "-exx"
dim scores( 1 to 3 ) as int32 = { 70, 85, 90 }
dim as int32 i = 4
dim as int32 total = scores( i )
Aborting due to runtime error 6 (out of bounds array access) at line 6 of checked.tiko::(),
'SCORES' accessed with invalid index = 4, must be between 1 and 3
(The program ends with exit code 6.)
| Option | Does |
|---|---|
-e | checks for runtime errors: a failed open stops the program with a message |
-exx | -e, plus array bounds and null-pointer checks, and where each error happened |
-earray, -enullptr, -elocation | one part of -exx at a time |
-g | debug information for a debugger, and turns assert on |
-O0 … -O3 | how hard the optimiser works; -O0, none, is the default |
-mt | the thread-safe runtime, needed with threads (Chapter 23) |
A checked build (-exx -g) stops at the first bad index with its line and the bounds, as
above. Without -exx nothing checks the index: the program reads whatever memory lies past the
end of the array, and carries on with it. A release build (-O2, or -O3) leaves the checks out and lets the optimiser rearrange
the code; it runs faster, and a bug that the checks would have caught becomes a wrong
answer or a crash somewhere else. Test with the checks on, ship with them off — or, for a program
where a wrong answer costs more than speed buys, ship checked.
Build settings in the source
'' Build settings can live in the source, so the file alone says how
'' it must be built.
#cmdline "-mt -O2"
print "thread-safe runtime: "; cbool( __FB_MT__ )
#if __FB_DEBUG__
print "debug build"
#else
print "release build"
#endif
thread-safe runtime: true
release build
#cmdline "options" puts compiler options in the source file, so the file alone says how it
must be built and nobody has to remember. When the compiler meets it, it starts the compilation
again with those options added. Two rules: it must come after any #import — an #import
below it is TKO0362: '#import' must come before any code — and
it counts only in the first source file named on the command line; anywhere else it is warning
TKO0334 and ignored. Every listing in this book that needs an option carries it this way.
__FB_ERR__, __FB_MT__ and __FB_DEBUG__ let the code see what it was given.
-x name names the program, as -o does: tiko hello.tiko -x greeter.exe writes
greeter.exe. The name is used exactly as written, so give the extension yourself. Where the two
meet, -x wins over -o, and of several -x the last one counts. With -c it is ignored, and
each object file keeps its own name. In #cmdline it lets the source name its own program:
'' The source names its own program. -x in #cmdline wins over any -o
'' or -x on the command line, so 'tiko named.tiko' writes renamed.exe.
#cmdline "-x renamed.exe"
print "built as renamed.exe"
built as renamed.exe
tiko named.tiko writes renamed.exe. So does tiko named.tiko -x other.exe: like every
option, the one in the source wins over the command line's.
Projects
The command lines in this chapter grow with the program. Every file, every option and the name
of the output are typed again for each build, and the compiler starts from nothing each time. A
project writes them down once, in the tiko.toml that Chapter 15 used for packages, and
tiko build reads it.
# One program, built from main.tiko and the module it imports.
[package]
name = "greeter"
[target.greeter]
main = "main.tiko"
[target.greeter.defines]
TIMES = "2"
'' The main file of a project. tiko.toml beside it says how to build
'' it, so 'tiko build' needs no file names and 'tiko run' no -o.
#import "./text"
dim as ustring who = "world"
if len( command( 1 ) ) > 0 then who = command( 1 )
for i as int32 = 1 to TIMES
print text.Greeting( who )
next
#if __FB_DEBUG__
print "debug build"
#else
print "release build"
#endif
#module "text"
public function Greeting( byref who as ustring ) as ustring
return "Hello, " & who & "!"
end function
tiko run -- tiko, in the project's directory:
Hello, tiko!
Hello, tiko!
debug build
[target.greeter] declares a target: something the project builds, here an executable
called greeter whose main file is main.tiko. tiko build, run in the directory that
holds tiko.toml, builds every target the file declares. It takes no file names, because the
target names them, and no -o: the program is written to out/debug/greeter.exe on Windows and
out/debug/greeter on Linux, beside tiko.toml. The module that main.tiko imports is found
and compiled as in Chapter 15. [target.greeter.defines] passes TIMES as though
-d TIMES=2 had been typed, which is why the greeting appears twice. tiko build greeter builds
the one target by name.
tiko run builds the project's one executable target and runs it. Everything after -- goes
to the program, so tiko run -- tiko greets tiko, and the exit code is the program's. A project
with more than one executable says which: tiko run greeter -- tiko.
A target takes these keys:
| Key | Does |
|---|---|
main | the main file, which owns the program's top-level code; required |
sources | more files compiled with it, as though named after it on the command line |
kind | "exe", the default, "dll" or "lib" — -dll and -lib |
options | any further compiler options, ["-mt"] |
libs, libpaths | libraries to link and where to look — -l and -p |
resources | .rc files, on Windows |
subsystem | "gui" or "console" — -s |
output | where to write the result, instead of out/<profile>/<name> |
Keys that hold for one platform only go in a [target.greeter.windows], [target.greeter.linux] or [target.greeter.macos] table. It takes sources, resources, libs, libpaths and options, which add to the target's own in a build for that platform and are skipped in any other. A program's icon is the usual case: its .rc file goes under windows, because a Linux build refuses a resource script, and the project then builds on both.
Every path in tiko.toml is relative to tiko.toml, an -i or -p inside options
included, so the project builds the same whatever directory the command is typed in.
Profiles
The debug in out/debug is the profile: a named set of compiler options. tiko build
uses debug, which is -g -exx -O0, a checked build, and so does tiko run. --profile release
builds with -O2 instead, into out/release, and the program above then prints
release build. Both are built in; a [profile.name] table changes one or declares another:
[profile.fast]
inherits = "release"
options = ["-mt"]
inherits starts from another profile; opt sets the optimiser level (0 to 3), errors the
checks ("e", "ex", "exx" or ""), debug whether -g is given, and options adds the
rest. Each profile has its own directory under out, so a debug build and a release build of the
same project sit side by side.
What it does not do twice
tiko build remembers what it compiled. Each main file, with its options, is stored in the same
cache that holds compiled modules (Chapter 15), and a second tiko build with nothing changed
compiles nothing; a third does not even link. A change to one file recompiles that file. A
change inside a module's procedures recompiles that module, but not the files that import it,
because what they see of it — its public declarations — is unchanged. Files that miss are
compiled in parallel; -j 4 sets how many at once.
tiko check reports every error and warning a build would, and writes nothing — no program,
no objects. A file the last build compiled cleanly is not looked at again, so after a build a
check is quick. It is what an editor runs on save. tiko clean removes the program a build
of the same profile wrote.
tiko build --message-format json reports a build as one JSON object per line — each file
compiled or found in the cache, each program written, the totals at the end — and
tiko metadata prints the whole project, targets, profiles and modules, without compiling.
An editor's build task, a language server and a CI script read these instead of tiko.toml.
Shipping
A program built for windows-x86_64 needs only what every supported Windows already has:
DLL Name: ucrtbase.dll
DLL Name: KERNEL32.dll
— the Universal C Runtime and the Windows kernel interface. tiko's own runtime is linked into
the executable, so there is no tiko DLL to ship: the executable, plus any DLLs and data files of
its own, is the whole program. On Linux the same program needs libc.so.6, libm.so.6 and
libtinfo.so.6, the C library, the maths library and the terminal database, which every desktop
and server distribution installs. A program built on one Linux distribution runs on others with
the same or a newer C library — build on the oldest one to be supported. On macOS a tiko program
needs libSystem and libncurses, which are part of the system, and runs on macOS 13 or newer.
A program that reaches another Mac through a browser arrives quarantined. tiko's programs
are signed ad hoc by Apple's linker, not notarised, and a quarantined one is refused: it does not
start. Clearing the mark lets it run: xattr -d com.apple.quarantine program. A program copied
with scp, a USB stick or curl is not marked.
The runtime's licence travels with the executable, because its code is inside. tiko's runtime is licensed under the LGPL 2.1 with a static-linking exception, the same terms as FreeBASIC's runtime: a program compiled by tiko and linked with it may be distributed under any licence its author chooses, with no obligation to publish its source. The exception covers the runtime only. Every other library a program links — a vendored C library, a DLL — comes with its own licence, and that licence's conditions apply.
What it costs
- An executable carries the runtime parts it uses.
target.tikois 24 KB on Windows and 118 KB on Linux, built at-O0. - A static library is copied into every program that uses it; a shared one is stored once, and costs a lookup when each program starts.
-exxmakes array-heavy code slower: every index is compared with both bounds, and every pointer with null.-O2and-O3make the compiler slower and the program faster. For a quick edit-and-run loop, the default-O0builds fastest.
Summary
-target windows-x86_64(Windows' default),linux-x86_64ormacos-arm64.TIKO_CLANG,TIKO_LIBPATHandTIKO_INCLUDEpoint the compiler at other tools, runtimes and headers.-ccompiles to an object file; the first file named is main unless-msays otherwise; the other modules' top levels run first, as constructors.-libbuildslibname.a;-dllbuilds a DLL andlibname.dll.a, orlibname.so, orlibname.dylib, with the procedures markedexport. Link either with#inclibor-l.- A shared library must be found at run time: beside the program on Windows, in the system's
directories or
LD_LIBRARY_PATHon Linux, throughDYLD_LIBRARY_PATHor an@loader_pathrun path on macOS. -s guibuilds a Windows program with no console; a.rcon the command line adds resources.-exxand-gfor checked builds,-O2/-O3for release;#cmdlinekeeps the options in the source.-x namenames the program exactly, over-o; the last-xwins, and one in#cmdlinewins over the command line's.- A project's
tiko.tomldeclares its targets;tiko buildbuilds them intoout/<profile>,tiko runbuilds and runs,tiko checkreports without writing. Paths in it are relative to it. Profilesdebug(the default) andrelease, and your own, set the options. A second build compiles only what changed. - A
windows-x86_64program needs onlyucrtbase.dllandKERNEL32.dll. The runtime's licence lets a compiled program be distributed on any terms.
Exercises
- Build a static library with
Gcdand anLcmbuilt on it, and use it to find the smallest number that every number from 1 to 20 divides. {solution="ex31-01"} - Write a program that reports, from the predefined symbols, whether it was built with error checking, with the thread-safe runtime, and against which C runtime. {solution="ex31-02"}
- Write
mathlib.ti, a header with the two declarations, and change both clients to include it instead of declaring the functions themselves. - Build
use-shared.tiko, then deletemathlib.dll(orlibmathlib.so) and run it. What does each platform say? - Add an
ICONline tostrings.rcwith an.icofile of your own, and check that Explorer shows it for the executable. - Turn the static-library example into a project: a
[target.mathlib]withkind = "lib", and a[target.use-static]that links it withlibsandlibpaths. Which target musttiko buildbuild first, and how do you make sure it does?
Classic BASIC and Compatibility
BASIC is older than most of the languages around it, and a great deal of code was written in its
earlier forms: QuickBASIC programs from the 1980s, FreeBASIC programs written in its qb
compatibility mode, and every generation of code since that kept the old habits. This chapter is
for reading and bringing over that code. It covers the classic features tiko keeps — data and
read, computed jumps, the classic date and time functions, common and chain — and, just as
important, the ones it refuses, with the diagnostic each produces and what to write instead.
The listings here end in .bas, as the code they stand for usually does. tiko compiles a .bas
file exactly as it compiles a .tiko one.
One dialect
FreeBASIC has four dialects, chosen with #lang at the top of a file (fbc also has a -lang
option; tiko does not): fb, the modern
language; fblite and qb, which keep QuickBASIC's rules; and deprecated, an older version of
fb. tiko implements fb only. Asking for another is refused:
'' Asking for another dialect is refused outright.
#lang "qb"
print "hello"
dialect.bas(2,1): error TKO0317: unsupported language mode 'qb';
tiko-lang implements "fb" only
(One line in the compiler's output, broken here to fit the page.)
Everything this book teaches is the fb dialect, and everything below that compiles is legal in
it. Code written for qb or fblite has to be brought over, and most of this chapter is how.
Inline data: data, read and restore
'' A quiz whose questions live in DATA statements.
dim as string question, answer
dim as long count
'' The first DATA item is how many questions follow.
read count
for i as long = 1 to count
read question, answer
print "Q"; i; ": "; question; " -- "; answer
next
'' RESTORE to a label starts reading from that label's DATA.
restore bonus
read question, answer
print "Bonus: "; question; " -- "; answer
'' RESTORE with no label goes back to the first DATA in the file.
restore
read count
print "questions in the main set:"; count
data 3
data "Capital of France?", "Paris"
data "Largest planet?", "Jupiter"
data "Square root of 81?", "9"
bonus:
data "Year the first BASIC ran?", "1964"
Q 1: Capital of France? -- Paris
Q 2: Largest planet? -- Jupiter
Q 3: Square root of 81? -- 9
Bonus: Year the first BASIC ran? -- 1964
questions in the main set: 3
data statements hold a list of constant values — numbers and strings — anywhere in the
module. read takes the next values from that list, in the order the data statements
appear in the file, into the variables it names, converting each to the variable's type. Reading
past the last item is not an error, even with -exx: the variable gets 0 or an empty string,
as fbc's does.
restore moves the reading position back: with no argument to the first data in the
module, and with a label to the first data after that label. Labels in tiko are names
followed by a colon on a line of their own, as bonus: is; the old numbered lines are not legal in
the fb dialect.
data is a compact way to put a table in a program, and it is easy to get wrong: nothing checks
that a read takes values of the types the data holds, or that each record has the number of
fields the code expects, and one missing field shifts every later read by one. In new code an array
with an initialiser (Chapter 8) or a file (Chapter 25) holds the same table, with types the
compiler checks.
Computed jumps
'' ON n GOTO jumps to the n-th label in its list.
for choice as long = 0 to 4
print "choice"; choice; ": ";
on choice goto add, list, quit
'' Out of range -- 0, or past the end of the list -- falls through.
print "no such option"
goto next_choice
add:
print "adding a record"
goto next_choice
list:
print "listing the records"
goto next_choice
quit:
print "quitting"
next_choice:
next
choice 0: no such option
choice 1: adding a record
choice 2: listing the records
choice 3: quitting
choice 4: no such option
goto label jumps to a label in the same procedure, and on n goto a, b, c jumps to the
nth label in its list — the first for 1, the second for 2. A value of 0, a negative value or one
past the end of the list jumps nowhere: the statement does nothing, and execution carries on with
the next line, which is how menu.bas catches choices 0 and 4.
on … goto is how menus and state machines were written before select case. In new code,
select case choice with a case per option says the same thing without the labels and without a
goto in every branch, and the compiler can see where each one ends.
Classic dates and times
'' The classic date functions keep a date as a double: whole days since
'' 30 December 1899, with the time of day as the fraction.
#include once "vbcompat.bi"
dim as double d = dateserial( 2025, 3, 4 ) + timeserial( 14, 30, 0 )
print "serial:"; d
print "parts:"; year( d ); month( d ); day( d ); hour( d ); minute( d )
print "weekday:"; weekday( d ); " (1 = Sunday)"
print "formatted: "; format( d, "yyyy-mm-dd hh:nn:ss" )
'' Adding months clamps at the end of the month; differences in days.
dim as double jan31 = dateserial( 2025, 1, 31 )
dim as double feb = dateadd( "m", 1, jan31 )
print "Jan 31 + 1 month: "; format( feb, "yyyy-mm-dd" )
dim as double today = dateserial( 2025, 3, 4 )
dim as double xmas = dateserial( 2025, 12, 25 )
print "days to Christmas:"; datediff( "d", today, xmas )
'' DATE and TIME are the clock, as text: "mm-dd-yyyy" and "hh:mm:ss".
print "date is"; len( date ); " characters, time is"; len( time )
serial: 45720.60416666666
parts: 2025 3 4 14 30
weekday: 3 (1 = Sunday)
formatted: 2025-03-04 14:30:00
Jan 31 + 1 month: 2025-02-28
days to Christmas: 296
date is 10 characters, time is 8
The classic date functions keep a date as a double: the whole part counts days from
30 December 1899, and the fraction is the time of day — 0.5 is noon. #include "datetime.bi"
declares them, and #include "vbcompat.bi" includes that and adds format, dateadd and
datediff, the Visual Basic names.
| Function | Gives |
|---|---|
dateserial( y, m, d ), timeserial( h, m, s ) | a serial date, or a time to add to one |
year, month, day, hour, minute, second | the parts of a serial |
weekday( d ) | the day of the week, 1 for Sunday |
now | the current date and time, as a serial |
dateadd( unit, n, d ), datediff( unit, a, b ) | arithmetic in "d", "m", "yyyy" and other units |
format( d, pattern ) | text; "yyyy-mm-dd hh:nn:ss", with nn for minutes |
date, time | the clock as text, "mm-dd-yyyy" and "hh:mm:ss" |
setdate, settime | set the computer's clock |
setdate and settime change the clock of the whole computer, not just the program's view of
it, and need administrator rights on most systems; the listing does not call them.
The named formats follow the machine's language. format( d, "dddd d mmmm" ) and
monthname( 3 ) ask the operating system for the names, so the same program prints Tuesday on
one computer and mardi on another. Use the numeric patterns for anything written to a file.
Chapter 26's std/chrono is the modern replacement, and the differences are worth knowing when
moving code across: a serial date has no time zone, dateserial( 2025, 13, 40 ) rolls over into
the next year instead of being refused, weekday counts from Sunday where chrono's DayOfWeek
counts from Monday, and DateTime.FromSerial and .Serial convert between the two.
common and chain
'' COMMON SHARED: one variable, declared in every module that uses it.
'' Built with its second module: 'tiko common.bas common-hit.bas'.
common shared hits as long
declare sub Hit( )
hits = 10
Hit( )
Hit( )
print "hits ="; hits
'' The second module of common.bas. Its COMMON names the same variable.
common shared hits as long
sub Hit( )
hits += 1
end sub
hits = 12
common shared declares a variable that every module of a program can share, by writing the
same common line in each. Built together with tiko common.bas common-hit.bas, the two modules
use one hits. In new code, declare the variable once with dim shared in one module and
extern in a header the others include, so the compiler checks that every module
agrees on its type.
'' CHAIN runs another program, waits for it, and returns its exit code.
#ifdef __FB_WIN32__
const CHILD = "child.exe"
#else
const CHILD = "./child"
#endif
print "parent: chaining"
dim as long rc = chain( CHILD )
print "parent: back; the child's exit code was"; rc
print "a missing program gives"; chain( "no-such-program" )
parent: chaining
child: running
parent: back; the child's exit code was 3
a missing program gives-1
chain runs another program, waits for it to finish and returns its exit code, or -1 when
the program cannot be started. In QuickBASIC, chain with common passed variables to the next
program. Here the two programs are separate processes that share nothing: anything one tells the
other goes through the command line, a file or the exit code. run starts a program and
does not come back. Chapter 25 covers exec and shell, which do the same with more control.
What tiko refuses
Each of these compiles in fbc's older dialects and not in fb. The table gives the diagnostic tiko
reports and the replacement.
| Classic code | tiko says | Write instead |
|---|---|---|
gosub label … return | TKO0409: 'GOSUB' is not available in the fb dialect | a sub, with what it changed passed byref |
resume, resume next | TKO0409: 'RESUME' is not available … | handle the error where it is caught, or return a Result (Chapter 18) |
dim count% | TKO0320: 'count' has no type … | dim as int32 count — every variable is declared with its type |
defint a-z and the other def statements | TKO0409: 'DEFINT' is not available … | the same: declare each variable with its type |
#lang "qb" | TKO0317: unsupported language mode 'qb' | port the code to fb |
asm … end asm | TKO0407: inline assembly is not supported | a C function (Chapter 29), or compile that module with fbc |
10 print "x" | TKO0944: a line number is not available in the fb dialect | labels: start: |
'' GOSUB belongs to the qb and fblite dialects. tiko implements fb only.
dim as long total = 0
gosub add_one
print total
end
add_one:
total += 1
return
gosub.bas(3,7): error TKO0409: 'GOSUB' is not available in the fb dialect
gosub jumps to a label and return comes back to the line after the gosub. It is a
subroutine without parameters or local variables, so everything it touches is shared with its
caller, and it ends wherever a return happens to be. Exercise 1 rewrites the listing with a
sub: the variable the subroutine changed becomes a byref parameter.
'' A type suffix gives a variable its type in the qb dialect only.
dim count%
count% = 3
print count%
suffix.bas(2,5): warning TKO0206: suffix ignored in 'count%'
suffix.bas(3,1): warning TKO0206: suffix ignored in 'count%'
suffix.bas(4,7): warning TKO0206: suffix ignored in 'count%'
suffix.bas(2,5): error TKO0320: 'count' has no type; tiko-lang implements
#lang "fb" only, where default types and suffixes are not valid
(Again one line.) The warnings are each % being thrown away; the error is what stops the build,
because without its suffix count has no type at all.
In the old dialects a suffix gave a variable its type — % an integer, & a long, ! a
single, # a double, $ a string — and defint, deflng, defsng, defdbl and defstr set a
type for every name starting with given letters. In fb every variable is declared, with its
type, before it is used; a typo then becomes an error instead of a new, empty variable.
'' Inline assembly: fbc compiles it; tiko does not.
function Twice( byval n as long ) as long
asm
mov eax, [n]
add eax, eax
mov [function], eax
end asm
end function
print Twice( 21 )
asm.bas(7,5): error TKO0407: inline assembly is not supported; compile that module with fbc
tiko produces LLVM's intermediate form, not assembly text, so there is nowhere for written assembly to go. A routine that needs particular instructions can be written in C, where clang's intrinsics reach them, and called through Chapter 29's interface — or that one module can be compiled with fbc and linked in.
Several more classic statements are refused in the fb dialect — by fbc with its error 146,
"only valid in -lang deprecated or fblite or qb", and by tiko with TKO0409 naming the keyword:
let in front of an assignment, call in front of a procedure call, lprint to the
printer, the option statements (option explicit, option base), on n gosub, and the
'$ metacommands ('$include, '$dynamic, '$static). Drop let and
call — k = 5 and Hello( ) say the same — open the printer as a file to print to it,
#include a file, and give arrays explicit bounds.
'' LET in front of an assignment: only in the older dialects.
dim as long k
let k = 5
print k
let.bas(3,1): error TKO0409: 'LET' is not available in the fb dialect
let is still a keyword of the fb dialect in one place: let ( a, b ) = t assigns a type's
fields to several variables at once, and that form compiles.
Bringing old code across
A QuickBASIC or fblite program comes across in the same few steps, most of them mechanical:
- Declare every variable, with its type. The suffixes and
defintlines tell you which type each one had. - Replace numbered lines with labels, and delete the numbers that nothing jumps to.
- Turn each
gosubsubroutine into asuborfunction, passing what it reads and changes. - Drop
letandcall; replace'$includewith#includeandoption base 1with explicit bounds. - Replace
on error … resumewith structured handling, where the error is caught by the code that can do something about it. - Compile with
-exx(Chapter 31) and run the program's tests, so an index out of bounds stops the program at the line that caused it.
Then, when the program works, the modern forms in this chapter's notes — arrays for data,
select case for on goto, std/chrono for serial dates — can come in one at a time.
What it costs
- Each
readconverts one item to the variable's type. - Serial dates are
doubles: cheap to store, and precise to about a millisecond for present-day dates. chainstarts a whole new process, which costs milliseconds; calling a procedure costs nanoseconds.
Summary
- tiko implements the
fbdialect only:#lang "qb"and the rest areTKO0317. data,readandrestore [label]keep a table in the program; nothing checks its types.goto labelandon n goto a, b, c; an index out of range falls through.datetime.biandvbcompat.bi: serial dates asdoubles,weekdayfrom Sunday,formatwith numeric patterns for anything stored.common sharedshares a variable between modules;chainruns a program and returns its exit code, sharing nothing.- Refused:
gosub,resume,let,call,lprint,option,defintand the'$metacommands (TKO0409), suffixes (TKO0320),asm(TKO0407), other dialects (TKO0317) and numbered lines (TKO0944).
Exercises
- Rewrite
gosub.baswithoutgosub, so that it compiles. {solution="ex32-01"} - Read a price list from
datastatements until a sentinel item"END", printing each line and the total. {solution="ex32-02"} - Rewrite
menu.baswithselect case. Which version makes it easier to see that every branch ends? - Rewrite
quiz-data.basso the questions live in an array of atypewith two fields, initialised with= { … }. What can the compiler now check that it could not before? - Write
classic-dates.basagain withstd/chrono(Chapter 26), and compare the weekday each prints for 4 March 2025.
Capstone: a CLI Search Tool
This chapter builds a complete program: tsearch, a small cousin of grep. It walks a
directory tree, reads every file in it, and prints the lines that contain a pattern. There is
nothing in it the earlier chapters have not shown; what is new is putting the pieces together and
making the decisions a real tool needs — what goes in a module and what stays in the program, how a
failure reaches the user, what the exit code means, and how the parts are tested.
The specification
tsearch [-i] [-n] [-l] [-g glob] pattern [path ...]
- Print every line, in every file under each path, that contains pattern, as
path:line text. With no path, search the current directory. -iignores case, for letters outside ASCII as well as inside it.-nadds the line number:path:number:line text.-lprints only the names of files that match.-g globsearches only files whose names match a glob pattern such as*.txt; case matters in the glob on every platform.- Paths are printed with
/between components on every platform, and files are searched in the sorted order of the paths as printed, so the output is the same everywhere. - When the output goes to a terminal, the file name is coloured; when it is redirected to a file or a pipe, it is plain text.
- Problems — a bad option, an empty argument, a path that does not exist, a file or directory that cannot be read — go to stderr, never into the results.
- The exit code says what happened, as
grep's does: 0 if anything matched, 1 if nothing did, 2 if something went wrong. A script can test it.
The layout
ch33/
tiko.toml the package
search/search.tiko the module: everything that can be tested on its own
tsearch.tiko the program: the command line, the output, the exit code
searchtests.tiko the module's tests
sample/ files to search
notes/readme.md
notes/todo.txt
poems/fox.txt
The work is split along one line: everything that does not touch the command line or the screen
goes in the module. Parsing the arguments takes an array and returns options; matching takes two
strings; finding files takes a root and returns paths. None of them prints anything, so the tests
can call each one directly and check what it returns. The program is what is left: reading
command( ), printing, and choosing the exit code.
tiko.toml makes the directory a package (Chapter 15), which lets the exercise solutions in
solutions/ reach the module as "../search" — a relative import may not leave its package:
[package]
name = "tsearch"
The module
#module "search"
#import "std/fs"
#import "std/containers"
The module uses std/fs for the file system and std/containers for its arrays, and declares two
public types:
'' What one run of tsearch was asked to do.
public type Options
as ustring pattern
as containers.Array( of ustring ) roots
as boolean ignoreCase
as boolean lineNumbers
as boolean filesOnly
as ustring nameGlob '' "" matches every file name
end type
'' One matching line.
public type Hit
as int32 lineNo
as ustring text
end type
Options is everything the command line said, already interpreted: the program builds one, and
every other procedure reads it. Hit is one result. Neither has methods: they are plain records
passed between the procedures, which keeps each procedure testable with values a test can write out.
Parsing the arguments
'' Read the arguments into opts. Returns "" when they make sense, and
'' otherwise what is wrong with them, for the usage message. An empty
'' argument is refused: it is almost always a variable left unset.
public function ParseArgs _
( byref args as containers.Array( of ustring ), _
byref opts as Options ) as ustring
dim as int32 i = 0
do while i < args.Count( )
var a = args[ i ]
if len( a ) = 0 then return "an argument is empty"
select case a
case "-i" : opts.ignoreCase = true
case "-n" : opts.lineNumbers = true
case "-l" : opts.filesOnly = true
case "-g"
i += 1
if i >= args.Count( ) then return "-g needs a pattern"
if len( args[ i ] ) = 0 then return "-g needs a pattern"
opts.nameGlob = args[ i ]
case else
if left( a, 1 ) = "-" then return "unknown option " & a
if len( opts.pattern ) = 0 then
opts.pattern = a
else
opts.roots.Push( a )
end if
end select
i += 1
loop
if len( opts.pattern ) = 0 then return "no pattern given"
if opts.roots.Count( ) = 0 then opts.roots.Push( "." )
return ""
end function
ParseArgs reads an array rather than calling command( n ) itself, so a test can hand it any
argument list. It returns an error message, or an empty string — the one thing the caller needs
to know — and fills opts in place. A flag that takes a value, -g, moves the index past it, and
says so if the value is missing. Anything else starting with - is refused rather than searched
for: a misspelt option should be an error, not a silent search for -x. The first other argument
is the pattern and the rest are roots, with . as the default.
An empty argument is refused as well, with an argument is empty, and an empty -g value as a
missing one. An empty argument is almost always a shell variable that was never set —
tsearch "$WORD" notes — and taken at face value it would be a pattern that says nothing or a path
that names nothing. Refusing it says which.
Matching
'' Does line contain pattern? lcase lowers every letter that has a
'' single lower-case form, not only A to Z.
public function LineMatches( byref line_ as ustring, _
byref pattern as ustring, _
byval ignoreCase as boolean ) as boolean
if ignoreCase then
return instr( lcase( line_ ), lcase( pattern ) ) > 0
end if
return instr( line_, pattern ) > 0
end function
A case-insensitive match compares both strings in lower case. For a ustring, lcase lowers
more than A to Z: ÄRGER matches ärger, and Greek and Cyrillic capitals lower too. It maps
one character to one character, though, which is lower-casing and not full case folding, so a few
pairs a reader would call equal stay apart: STRASSE does not match straße, a Greek capital
sigma lowers to σ and never to the final ς, and the Turkish capital İ is left as it is. The
tests pin down both sides. Lower-casing a whole line for every comparison is not the fastest
possible way — the pattern could be lowered once — but it is easy to check, which is the right
first version.
Finding the files
'' Does a file name match the -g glob? The POSIX grammar, on every
'' platform, so the answer is the same on Windows and Linux.
function NameMatches( byref nm as ustring, _
byref nameGlob as ustring ) as boolean
if len( nameGlob ) = 0 then return true
return fs.MatchPattern( nm, nameGlob, fs.FB_PATH_POSIX )
end function
'' Appends to files every file under root whose name matches nameGlob,
'' as display paths in sorted order, so the output depends neither on
'' the order the file system returns them nor on the platform. Returns
'' how many it appended. What could not be read goes into problems,
'' one message each, naming the path that failed.
public function FindFiles _
( byref root as ustring, byref nameGlob as ustring, _
byref files as containers.Array( of ustring ), _
byref problems as containers.Array( of ustring ) ) as int32
dim found as containers.Array( of ustring )
if fs.IsFile( root ) then
found.Push( DisplayPath( root ) )
else
for each r in fs.WalkDir( root )
if r.IsOk( ) = false then
var e = r.Failure( )
problems.Push( DisplayPath( e.Path( ) ) & ": " & _
e.Message( ) )
elseif r.Value( ).IsFile( ) then
if NameMatches( r.Value( ).Name( ), nameGlob ) then
found.Push( DisplayPath( r.Value( ).Path( ) ) )
end if
end if
next
end if
containers.Sort( found )
for each f in found
files.Push( f )
next
return found.Count( )
end function
A root that is a file is searched as it is. A directory is walked with fs.WalkDir
(Chapter 25), which yields one Result per entry: a successful entry that is a file is kept when
its name matches the glob, using fs.MatchPattern — the same matcher fs.Glob uses, applied
to one name.
NameMatches passes fs.MatchPattern a grammar, fs.FB_PATH_POSIX, rather than leaving it
the default. The default is the platform's own grammar, which ignores case on Windows and respects
it on Linux, so -g "*.TXT" would find todo.txt on one and not on the other. With the POSIX
grammar named, case matters on both, and the same glob gives the same answer.
A failed entry — a directory that cannot be opened, or a root that does not exist — becomes a
message in problems, so the program can report it and still search everything else. The message
names FsError.Path( ), the path that failed: for a missing root that is the root,
nowhere: no such file or directory, and for an unreadable directory deep in the tree it is that
directory, sample/notes: access denied, not the root the user typed.
The paths are sorted before they are returned, and sorted as they will be printed. A file
system lists a directory in whatever order it likes, and that order differs between Windows and
Linux; a tool whose output changes between runs on the same files is a tool whose output cannot be
compared. Sorting the native paths would not be enough, because \ sorts after the capital
letters and / before the digits: d\f.txt comes after dA\f.txt, but d/f.txt before it. So
each path goes through DisplayPath first, and the sort sees the same strings on both platforms.
Windows accepts / as a separator, so the display path is also the one the program opens.
FindFiles appends what it finds to files, in that order, and returns how many it appended,
whether the root is a file or a directory.
Searching a file
'' The matching lines of one file. A file that cannot be read is an
'' FsError in the Result, for the caller to report.
public function SearchFile( byref path as ustring, _
byref opts as Options, _
byref hits as containers.Array( of Hit ) ) _
as fs.FsResultLong
dim lines as containers.Array( of ustring )
var got = fs.ReadAllLines( path, lines )
if got.IsOk( ) = false then return got
for i as int32 = 0 to lines.Count( ) - 1
if LineMatches( lines[ i ], opts.pattern, opts.ignoreCase ) then
dim h as Hit
h.lineNo = i + 1
h.text = lines[ i ]
hits.Push( h )
end if
next
return got
end function
fs.ReadAllLines returns a Result (Chapter 18): the number of lines, or an FsError that
says why the file could not be read. SearchFile passes that Result straight back, so the caller
learns about a failure in the same value that carries success, and cannot forget to look: there is
no error number to check separately and no runtime error to trap.
'' A path as tsearch prints it: '/' between components everywhere.
'' Only Windows separates them with '\'; on Linux a '\' is part of
'' a file name, and stays as it is.
function DisplayPath( byref path as ustring ) as ustring
dim as ustring p = path
#ifdef __FB_WIN32__
for i as int32 = 0 to len( p ) - 1
if p[ i ] = asc( "\" ) then p[ i ] = asc( "/" )
next
#endif
return p
end function
DisplayPath is the last piece of the "same output everywhere" promise: Windows writes \ between
path components, and the tool prints / on both. It converts only on Windows. On Linux \ is an
ordinary character a file name may contain — a\b.txt is one file — and printing it as a/b.txt
would name a different one. It is not public: FindFiles applies it, and nothing outside the
module needs to.
The program
'' tsearch -- print the lines of files that contain a pattern.
''
'' tsearch [-i] [-n] [-l] [-g glob] pattern [path ...]
''
'' Exit code: 0 if anything matched, 1 if nothing did, 2 on an error.
#import "./search"
#import "std/containers"
#include once "fbio.bi"
#ifdef __FB_WIN32__
#include once "windows.bi"
#endif
const EXIT_FOUND = 0, EXIT_NONE = 1, EXIT_ERROR = 2
sub Complain( byref message as ustring )
dim as int32 f = freefile( )
open err for output as #f
print #f, "tsearch: "; message
close #f
end sub
'' Colour only when a person is watching; never into a pipe or a file.
'' A Windows console draws escape sequences only once it is asked to,
'' and one that cannot be asked gets plain text.
function ColourWanted( ) as boolean
if IsRedirected( ) then return false
#ifdef __FB_WIN32__
'' ENABLE_VIRTUAL_TERMINAL_PROCESSING, which windows.bi lacks
const VT_PROCESSING = &h0004
var h = GetStdHandle( STD_OUTPUT_HANDLE )
dim as ulong mode
if GetConsoleMode( h, @mode ) = 0 then return false
return SetConsoleMode( h, mode or VT_PROCESSING ) <> 0
#else
return true
#endif
end function
'' The arguments, without the program's own name. An empty one is
'' still an argument, and ParseArgs decides what it means.
dim args as containers.Array( of ustring )
for n as int32 = 1 to __FB_ARGC__ - 1
args.Push( command( n ) )
next
dim opts as search.Options
var problem = search.ParseArgs( args, opts )
if len( problem ) > 0 then
Complain( problem )
Complain( "usage: tsearch [-i] [-n] [-l] [-g glob] " & _
"pattern [path ...]" )
end EXIT_ERROR
end if
dim as boolean colour = ColourWanted( )
dim as int32 matched = 0, failures = 0
for each root in opts.roots
dim files as containers.Array( of ustring )
dim problems as containers.Array( of ustring )
search.FindFiles( root, opts.nameGlob, files, problems )
for each p in problems
Complain( p )
failures += 1
next
for each path in files
dim hits as containers.Array( of search.Hit )
var got = search.SearchFile( path, opts, hits )
if got.IsOk( ) = false then
Complain( path & ": " & got.Failure( ).Message( ) )
failures += 1
continue for
end if
if hits.Count( ) = 0 then continue for
matched += hits.Count( )
'' The file name, in magenta when a person is watching.
dim as ustring shown = path
if colour then shown = !"\27[35m" & path & !"\27[0m"
if opts.filesOnly then
print shown
continue for
end if
for each h in hits
dim as ustring where_ = shown & ":"
if opts.lineNumbers then where_ &= h.lineNo & ":"
print where_; h.text
next
next
next
if failures > 0 then end EXIT_ERROR
end iif( matched > 0, EXIT_FOUND, EXIT_NONE )
The program reads like the specification. It collects the arguments — command( 1 ) to
command( __FB_ARGC__ - 1 ) (Chapter 25) — and hands them to ParseArgs; a bad command line
prints the problem and the usage line to stderr through Complain, and ends with exit code 2.
The loop counts rather than stopping at the first empty command( n ): that would read
tsearch fox "" sample as tsearch fox and quietly search ..
IsRedirected( ) (from fbio.bi, Chapter 25) is true when standard output is not a terminal.
The program colours only when it is false, so tsearch fox > hits.txt writes plain text and a
person at a terminal sees the file names in magenta, in -l output as well. The colour itself is
an ANSI escape sequence, ESC [35m to start and ESC [0m to stop.
A Linux terminal draws those sequences as they arrive. A Windows console draws them only once it
has been asked to, and the runtime does not ask on the program's behalf, so ColourWanted does:
it switches on the console's virtual-terminal mode through windows.bi, the Windows API
header, and gives up colour if the console refuses. windows.bi does not name the
ENABLE_VIRTUAL_TERMINAL_PROCESSING flag, so the listing defines it. Both #include lines sit
below the #import lines, because an import must come before any code and a header brings some:
above them, an import fails with TKO0362: '#import' must come before any code, in the compiled file.
Every failure is counted, and reported where it happens, without stopping the search: one unreadable file should not hide the matches in all the others. Only at the end does the program choose the exit code: 2 if anything failed, otherwise 0 or 1 by whether anything matched.
Running it
Run from the chapter's directory as tsearch -n -i fox sample:
sample/notes/readme.md:2:The fox example lives in poems/.
sample/notes/todo.txt:2:Read the chapter on the quick brown FOX
sample/poems/fox.txt:1:The quick brown fox
sample/poems/fox.txt:3:A Fox is not a dog,
sample/poems/fox.txt:4:and a dog is not a fox.
A few more runs, on either platform, with the exit code after each:
$ tsearch -l -g "*.txt" fox sample
sample/poems/fox.txt
exit 0
$ tsearch zebra sample
exit 1
$ tsearch fox nowhere
tsearch: nowhere: no such file or directory
exit 2
$ tsearch -x fox
tsearch: unknown option -x
tsearch: usage: tsearch [-i] [-n] [-l] [-g glob] pattern [path ...]
exit 2
The second run prints nothing at all and still says something, through its exit code: a script
can write if tsearch -l secret . ; then … and never look at the output.
The glob is quoted because a Unix shell expands an unquoted *.txt against the current directory
before tsearch sees it. On Windows, quoting is not enough.
On Windows the runtime expands * and ? in every argument before the program starts — quoted or
not, from cmd, PowerShell or any other shell — against the files in the current directory, or in
the directory the argument names. Run from sample/notes, which holds todo.txt,
tsearch -l -g "*.txt" fox .. receives -g todo.txt, searches only files named todo.txt,
prints nothing and exits 1. Nothing reports the change. With two .txt files there, the second
would become the pattern and the real pattern a path.
An argument that matches no file is passed on unchanged, and the expansion knows only * and ?.
So write one character of the glob as a class: -g "*.tx[t]" matches no file name literally,
reaches tsearch as typed, and still means *.txt to fs.MatchPattern. It needs quoting in a Unix
shell too, where it is otherwise a glob for the shell. The runtime asks for the expansion in its
startup code, and the pinned version has no switch to turn it off.
Testing
'' tsearch's tests: the search module, without the command line.
#import "./search"
#import "std/containers"
#include once "fbcunit.bi"
function Args3( byref a as ustring, byref b as ustring, _
byref c as ustring ) as containers.Array( of ustring )
dim r as containers.Array( of ustring )
if len( a ) then r.Push( a )
if len( b ) then r.Push( b )
if len( c ) then r.Push( c )
return r
end function
SUITE( arguments )
TEST( pattern_and_default_root )
dim o as search.Options
var problem = search.ParseArgs( Args3( "-i", "fox", "" ), o )
CU_ASSERT_EQUAL( problem, "" )
CU_ASSERT( o.ignoreCase )
CU_ASSERT_EQUAL( o.pattern, "fox" )
CU_ASSERT_EQUAL( o.roots[ 0 ], "." )
END_TEST
TEST( refuses_what_it_cannot_use )
dim o1 as search.Options, o2 as search.Options
dim o3 as search.Options
CU_ASSERT_EQUAL( search.ParseArgs( Args3( "-x", "", "" ), _
o1 ), "unknown option -x" )
CU_ASSERT_EQUAL( search.ParseArgs( Args3( "-n", "", "" ), _
o2 ), "no pattern given" )
CU_ASSERT_EQUAL( search.ParseArgs( Args3( "fox", "-g", "" ), _
o3 ), "-g needs a pattern" )
END_TEST
TEST( refuses_an_empty_argument )
dim o as search.Options
dim a as containers.Array( of ustring )
a.Push( "fox" )
a.Push( "" )
CU_ASSERT_EQUAL( search.ParseArgs( a, o ), _
"an argument is empty" )
END_TEST
END_SUITE
SUITE( matching )
TEST( case_matters_unless_asked )
CU_ASSERT( search.LineMatches( "a quick fox", "fox", false ) )
CU_ASSERT( search.LineMatches( "A FOX", "fox", false ) = false )
CU_ASSERT( search.LineMatches( "A FOX", "fox", true ) )
END_TEST
TEST( folding_is_not_only_ascii )
CU_ASSERT( search.LineMatches( !"STRA\u00DFE UND \u00C4RGER", _
!"\u00E4rger", true ) )
END_TEST
TEST( folding_is_one_letter_at_a_time )
'' Lower case is not case folding: SS does not become sharp s.
CU_ASSERT( search.LineMatches( "STRASSE", !"stra\u00DFe", _
true ) = false )
END_TEST
END_SUITE
SUITE( files )
TEST( finds_and_filters_in_order )
dim all_ as containers.Array( of ustring )
dim txt as containers.Array( of ustring )
dim problems as containers.Array( of ustring )
CU_ASSERT_EQUAL( search.FindFiles( "sample", "", all_, _
problems ), 3 )
CU_ASSERT_EQUAL( search.FindFiles( "sample", "*.txt", txt, _
problems ), 2 )
CU_ASSERT_EQUAL( txt[ 0 ], "sample/notes/todo.txt" )
CU_ASSERT_EQUAL( problems.Count( ), 0 )
END_TEST
TEST( globs_match_case_on_every_platform )
dim upper as containers.Array( of ustring )
dim problems as containers.Array( of ustring )
CU_ASSERT_EQUAL( search.FindFiles( "sample", "*.TXT", upper, _
problems ), 0 )
END_TEST
TEST( reports_line_numbers )
dim o as search.Options
o.pattern = "dog"
dim hits as containers.Array( of search.Hit )
var got = search.SearchFile( "sample/poems/fox.txt", o, hits )
CU_ASSERT( got.IsOk( ) )
CU_ASSERT_EQUAL( hits.Count( ), 3 )
CU_ASSERT_EQUAL( hits[ 2 ].lineNo, 4 )
END_TEST
TEST( a_missing_file_is_an_error_value )
dim o as search.Options
o.pattern = "x"
dim hits as containers.Array( of search.Hit )
var got = search.SearchFile( "sample/absent.txt", o, hits )
CU_ASSERT( got.IsOk( ) = false )
END_TEST
END_SUITE
end iif( fbcu.run_tests( ), 0, 1 )
On Windows:
SUMMARY
Asserts Passed Failed Suite Tests
-------- -------- -------- -------------------------------------- --------
9 9 0 files 4
5 5 0 matching 3
8 8 0 arguments 3
-------- -------- -------- -------------------------------------- --------
22 22 0 Total 10
On Linux the same suites run in another order (Chapter 19):
SUMMARY
Asserts Passed Failed Suite Tests
-------- -------- -------- -------------------------------------- --------
8 8 0 arguments 3
5 5 0 matching 3
9 9 0 files 4
-------- -------- -------- -------------------------------------- --------
22 22 0 Total 10
The tests are a program of their own, built with fbcunit (Chapter 19), that imports the module just
as tsearch.tiko does. Each test calls one procedure with values it chose and checks what came back:
the argument parser with good and bad command lines, an empty argument among them; matching with
and without case, lower-casing beyond ASCII, and where lower-casing stops; finding files in
sample/, with and without a glob, in sorted order, and a glob whose case does not match on either
platform; line numbers; and a missing file, which must come back as a failed Result rather than
a crash. Nothing here needs a terminal or anything outside sample/, so it can run on every build.
The program itself is tested the way the runs above were: by running it on known files and comparing its output and exit code with what they should be.
What it costs
- Every file is read whole with
ReadAllLines, so a search holds one file in memory at a time. A tool for very large files would read a line at a time withline input #. - Case-insensitive matching lower-cases every line, one copy per line. Lowering the pattern once and comparing without copying is the obvious optimisation, and the tests are what make it safe to try.
- Sorting the file list costs a little time for a large tree, and buys output that does not depend on the file system.
Summary
- Put everything that does not touch the command line or the screen in a module, and test it directly; keep the program a thin layer over it.
- Parse arguments from an array into an options record, counting to
__FB_ARGC__, and report problems as messages; refuse unknown options and empty arguments. fs.WalkDirandfs.MatchPatternfind the files; name the POSIX grammar for the same glob answer on both platforms, and sort the paths as printed for the same order.fs.ReadAllLinesreturns aResultwhoseFsErrorsays why a file could not be read, andPath( )which file.lcaselowers letters beyond ASCII one character at a time; it is not full case folding.- On Windows the runtime expands
*and?in arguments even inside quotes; write one character of a glob as a class,*.tx[t], to get it through. - Diagnostics go to stderr;
IsRedirected( )decides about colour, and a Windows console needs its virtual-terminal mode switched on first; the exit code — 0, 1 or 2 — says what happened. - fbcunit tests the module's procedures one by one, on both platforms.
Exercises
- Write a program that uses the
searchmodule to print how many lines match in each file, aspath: count. {solution="ex33-01"} - Add whole-word matching, so that
dogmatchesthe lazy dog.andhot-dogbut notdogmaorunderdog. Count a letter outside ASCII as part of a word, so thatSchädogdoes not match either. {solution="ex33-02"} - Add a
-coption that prints only the number of matching lines in each file, with tests for its parsing. - Add
-v, which prints the lines that do not match. Which other options does it interact with, and how should-l -vbehave? - Make the match cheaper: lower-case the pattern once, outside the loop, and check that every test still passes.
Capstone: a Concurrent Pipeline
The second capstone is thash, which finds duplicate files. It walks a directory tree, reads
every file, hashes the contents, and reports the files whose contents are the same. Chapter 33's
tool did one thing after another; this one does several things at once. Walking the tree, reading
files and hashing them become stages that run as goroutines, joined by channels (Chapter 23).
The work is what the earlier chapters showed: fs.WalkDir and fs.ReadAllBytes (Chapter 25), a
Map (Chapter 24), a Stopwatch (Chapter 26), RunningStats (Chapter 27) and a Logger
(Chapter 28). What is new is fitting them into a pipeline. That means deciding who closes which
channel, how full a channel may get, how the whole thing stops early, and how output that is
produced in no fixed order can still be the same on every run.
The specification
thash [-j workers] [-t ms] [-v] directory
- Hash every file under directory and print the groups of files with identical contents. Each group gives its size and hash, then its paths in sorted order. The groups come in order of their first path, and a total says how many bytes the extra copies take.
- Print a line of statistics first: how many files, how many bytes, and the smallest, largest and mean sizes. When no file was read, the line stops at the two counts.
-jsets how many files are hashed at once (default 4).-t msstops after that many milliseconds and reports what was done so far.-vlogs every file as it is hashed, and the time the run took.- Paths are printed with
/between components. The output does not depend on how many workers there are or on the order in which they finish. - A file that cannot be read is reported on stderr and does not stop the run.
- Exit code: 0 if every file was hashed, 1 if some could not be read or the run was stopped early, and 2 for a bad command line.
The shape
paths results
Walk ──────────▶ Hasher ×N ──────────▶ collect ──▶ report
(1 goroutine) (N goroutines) (main program)
There are three stages, and each one owns the end of a channel. Walk is the only sender on
paths, so it is the one that closes it. N hashers share paths as receivers and results
as senders. No single hasher can close results, because the others may still be sending, so a
small goroutine waits on a WaitGroup and closes it once the last hasher is done. The main
program receives from results with for each, which ends when the channel closes. Every
channel is closed exactly once, by whoever knows that nothing more will be sent. That one rule is
what lets each loop end by itself.
The program is one file, thash.tiko, shown here a section at a time. sample/ holds nine small
files. Seven of them form three groups of copies: three of one image, two of a readme and two of
some notes. The other two are unique:
ch34/
thash.tiko
sample/archive/2025/IMG_0042.img copy of photos/lighthouse.img
sample/archive/2025/readme.txt copy of docs/readme.txt
sample/archive/notes-old.txt copy of docs/notes.txt
sample/docs/notes.txt
sample/docs/plans.txt
sample/docs/readme.txt
sample/photos/harbour.img
sample/photos/lighthouse-copy.img copy of photos/lighthouse.img
sample/photos/lighthouse.img
The records
#import "std/tasks"
#cmdline "-mt"
#import "std/fs"
#import "std/containers"
#import "std/numeric"
#import "std/chrono"
#import "std/logs"
Every concurrent listing starts like this. #import "std/tasks" comes first, then #cmdline "-mt"
so that the program links the thread-safe runtime. The import also brings select channel.
'' What one run of thash was asked to do.
type Options
as ustring root
as int32 workers = 4
as int32 timeoutMs = 0 '' 0: no time limit
as boolean verbose
end type
'' What a hasher found out about one file.
type FileHash
as ustring path
as int64 size
as ulongint hash
as ustring problem '' "" when the file was read
end type
Options is the command line, already interpreted. FileHash is the message that travels from
the hashers to the collector: a whole record, with a ustring in it, sent through a channel by
value. A file that could not be read still produces a record, with problem filled in. The
collector must hear about every file, or it could not count the failures.
Stage 1: walking the tree
'' Stage 1: every file under root, into paths. Stops early when the
'' context is done; closes paths either way, so the hashers finish.
'' A select case needs the channel itself, not a SendChan view of it.
sub Walk( byval root as ustring, byval ctx as tasks.Context, _
byval paths as tasks.Channel( of ustring ) )
for each r in fs.WalkDir( root )
if r.IsOk( ) andalso r.Value( ).IsFile( ) then
dim as ustring p = r.Value( ).Path( )
select channel
case send p to paths : '' on to the next
case receive from ctx.Done( ) : exit for
end select
end if
next
paths.Close( )
end sub
Walk sends each file's path into paths. It does not use a plain paths.Send( p ), because a
send can wait: if the channel is full, the walker sits there until a hasher takes something
out. The select channel waits on two things at once. Either the send goes through, or the
context's Done( ) channel closes, which means that someone has said stop. Whichever happens
first wins. Stopping leaves the loop, and the Close after it still runs, so the hashers see the
end of paths either way.
paths is declared as a whole tasks.Channel, not a SendChan. A select case needs the
channel itself. The helpers behind select channel are written for Channel( of T ), so a
SendChan or RecvChan view of it does not fit. Declare paths as a SendChan here and the
compiler stops at the select channel line with TKO0809: cannot infer the type arguments of 'selectcommitsend'. The narrower types are still the right choice wherever a stage only calls
Send or Receive, as the hashers do.
Stage 2: hashing, N at a time
'' Stage 2, run by several goroutines at once: read each file whole and
'' hash its bytes. A file that cannot be read is reported, not dropped.
sub Hasher( byval ctx as tasks.Context, _
byval paths as tasks.RecvChan( of ustring ), _
byval results as tasks.SendChan( of FileHash ), _
byval lg as logs.Logger ptr, _
byval wg as tasks.WaitGroup ptr )
dim as ustring p
do while paths.Receive( p )
if len( ctx.Err( ) ) > 0 then exit do
dim fh as FileHash
fh.path = p
dim bytes as containers.Array( of ubyte )
var got = fs.ReadAllBytes( p, bytes )
if got.IsOk( ) = false then
fh.problem = got.Failure( ).Message( )
lg->Warn( ).AddText( "path", DisplayPath( p ) ) _
.Msg( fh.problem )
else
fh.size = got.Value( )
if fh.size = 0 then
fh.hash = containers.HashBytes( nullptr, 0 )
else
fh.hash = containers.HashBytes( @bytes[ 0 ], fh.size )
end if
lg->Debug( ).AddText( "path", DisplayPath( p ) ) _
.AddInt( "bytes", fh.size ).Msg( "hashed" )
end if
results.Send( fh )
loop
wg->Done( )
end sub
'' Closes results once every hasher has finished: the collector's
'' 'for each' ends there.
sub CloseWhenDone( byval wg as tasks.WaitGroup ptr, _
byval results as tasks.Channel( of FileHash ) )
wg->Wait( )
results.Close( )
end sub
Several Hasher goroutines run the same loop over the same paths channel, and each path goes to
exactly one of them: whichever is free first. That is the whole of the fan-out. Each hasher
reads a file whole with fs.ReadAllBytes and hashes the bytes with containers.HashBytes,
a 64-bit FNV-1a hash, the one HashOf uses when a Map has string keys. The result goes into
results, which all of the hashers send to. That is the fan-in.
When paths is closed and empty, Receive returns false, the loop ends, and the hasher calls
wg->Done( ). The main program calls wg.Add( ) for each hasher just before spawning it, and
spawns CloseWhenDone only after the last one, so the count is complete before anyone waits on
it. CloseWhenDone waits for the count to reach zero and only then closes results. A hasher also
checks the context before each file, so a stopped run does not read every file left in the
channel's buffer.
Logging from goroutines
The hashers log through one Logger, passed to each of them as a pointer. A logger can be shared
between threads. Each record is built by the thread that calls Warn( ) or Debug( ). A sink
takes one lock for the whole of a write, so two goroutines logging at the same moment produce two
whole lines, never one line with the other's text inside it (Chapter 28). With -v, the logger's
MinLevel is LOG_DEBUG and every file produces a line. Without it, the level is LOG_WARN,
and only failures are written. A Debug( ) record is then switched off by one comparison, and
nothing is formatted or locked. Its arguments are still evaluated, though: DisplayPath( p )
still builds a string, and AddText, AddInt and Msg are still called, each returning at
once. A call cannot avoid working out what it is passed.
Back-pressure
A channel's capacity is how far its sender may get ahead of its receiver. When the buffer is full,
Send waits. That is back-pressure: a fast stage cannot run away from a slow one and fill
memory with work that nobody has started yet.
#import "std/tasks"
#cmdline "-mt"
#include once "fbatomic.bi"
'' Sends as fast as it can, counting each value the channel accepted.
sub Produce( byval out_ as tasks.SendChan( of int32 ), _
byval sent as long ptr )
for i as int32 = 1 to 6
out_.Send( i )
FB.AtomicFetchAddLong( sent, 1 )
next
out_.Close( )
end sub
dim as long sent
var queue = tasks.Channel( of int32 )( 2 ) '' room for two values
spawn Produce( queue, @sent )
'' Nobody receives yet: the producer fills the buffer, then waits.
sleep 200, 1
print "sent before anyone received:"; FB.AtomicLoadLong( @sent )
dim as int32 total = 0
for each v in queue
total += v
next
print "received them all, total"; total; _
", sent"; FB.AtomicLoadLong( @sent )
sent before anyone received: 2
received them all, total 21, sent 6
The producer wants to send six values, and the channel has room for two. For the first 200 milliseconds nobody receives, so the producer stops after two sends and waits inside the third. It finishes only as the main program drains the channel.
In thash, paths holds workers * 2 paths, so the walker can stay a little ahead of the hashers
and never leave them idle. It cannot list a whole disk into memory while they read the first file.
results holds workers records. If the collector were slow, the hashers would wait for it rather
than pile up finished work.
Stopping early
A Context carries the decision to stop. tasks.WithCancel makes one that stops when its
Cancel( ) is called. tasks.WithTimeout makes one that also stops by itself after a number of
milliseconds. A stage that could be left waiting for ever once the others stop puts a
case receive from ctx.Done( ) beside its real work.
#import "std/tasks"
#cmdline "-mt"
'' Would send a million values; stops as soon as its context is done.
sub Produce( byval ctx as tasks.Context, _
byval out_ as tasks.Channel( of int32 ), _
byval done_ as tasks.SendChan( of ustring ) )
dim as int32 sent = 0
for i as int32 = 1 to 1000000
select channel
case send i to out_ : sent += 1
case receive from ctx.Done( ) : exit for
end select
next
out_.Close( )
done_.Send( "producer sent " & sent & ", then " & ctx.Err( ) )
end sub
var ctx = tasks.WithCancel( tasks.Context( ) )
var values = tasks.Channel( of int32 )( 0 ) '' no buffer: a handover
var finished = tasks.Channel( of ustring )( 1 )
spawn Produce( ctx, values, finished )
dim as int32 v
for k as int32 = 1 to 3
values.Receive( v )
print "got"; v
next
ctx.Cancel( ) '' enough
dim as ustring why
finished.Receive( why )
print why
got 1
got 2
got 3
producer sent 3, then cancelled
The producer would send a million values. The channel has no buffer, so each send is a handover
that completes only when the main program receives. After three, the main program cancels. The
producer is waiting in its select for a fourth handover that will never come, Done( ) closes,
and it stops, having sent exactly three. ctx.Err( ) says why a context stopped: cancelled
here, and deadline exceeded when a timeout ran out.
In thash, only the walker needs that select. When the run stops, the hashers stop taking paths,
so a walker waiting on a full paths would wait for ever. The other stages wait without one: a
hasher in a plain paths.Receive and results.Send, and CloseWhenDone in wg->Wait( ). Each
of those waits is sure to end, because of who closes what. The walker closes paths whether or
not it was stopped, which ends every Receive. The collector keeps receiving until results
closes, so no Send waits long. Every hasher calls Done( ) once its loop ends, which releases
Wait( ). A hasher checks the context only before each file, so that a stopped run reads no
more of them.
Stage 3: collecting
dim opts as Options
var problem = ParseArgs( __FB_ARGC__, opts )
if len( problem ) > 0 then
dim as int32 e = freefile( )
open err for output as #e
print #e, "thash: "; problem
print #e, "usage: thash [-j workers] [-t ms] [-v] directory"
close #e
end 2
end if
'' Diagnostics go to stderr; -v adds a line for every file hashed.
dim tr as logs.TextRenderer
tr.stamp = logs.TS_NONE
dim con as logs.ConsoleSink = logs.ConsoleSink( logs.CT_STDERR, @tr )
dim lg as logs.Logger
lg.MinLevel = iif( opts.verbose, logs.LOG_DEBUG, logs.LOG_WARN )
lg.AddSink( @con )
dim ctx as tasks.CancelContext
if opts.timeoutMs > 0 then
ctx = tasks.WithTimeout( tasks.Context( ), opts.timeoutMs )
else
ctx = tasks.WithCancel( tasks.Context( ) )
end if
'' Buffered channels: a full one makes its sender wait.
var paths = tasks.Channel( of ustring )( opts.workers * 2 )
var results = tasks.Channel( of FileHash )( opts.workers )
dim wg as tasks.WaitGroup
var clock = chrono.Stopwatch.StartNew( )
spawn Walk( opts.root, ctx, paths )
for w as int32 = 1 to opts.workers
wg.Add( )
spawn Hasher( ctx, paths, results, @lg, @wg )
next
spawn CloseWhenDone( @wg, results )
'' Stage 3, here in the main program: collect.
dim byHash as containers.Map _
( of ulongint, containers.Array( of ustring ) )
dim bytesOf as containers.Map( of ulongint, int64 )
dim sizes as numeric.RunningStats
dim as int64 total = 0
dim as int32 unreadable = 0
for each fh in results
if len( fh.problem ) > 0 then
unreadable += 1
else
byHash[ fh.hash ].Push( DisplayPath( fh.path ) )
bytesOf[ fh.hash ] = fh.size
sizes.Add( fh.size )
total += fh.size
end if
next
var stopped = ctx.Err( )
ctx.Cancel( )
'' With no sizes there is no smallest, largest or mean to print.
print sizes.Count( ); " files,"; total; " bytes";
if sizes.Count( ) > 0 then
print "; smallest"; sizes.MinValue( ); ", largest"; _
sizes.MaxValue( ); ", mean"; numeric.Round( sizes.Mean( ), 1 )
else
print
end if
Report( byHash, bytesOf )
if unreadable > 0 then
print unreadable; " files could not be read"
end if
if len( stopped ) > 0 then print "stopped early ("; stopped; ")"
if opts.verbose then
lg.Info( ).AddInt( "ms", clock.ElapsedMilliseconds ) _
.Msg( "done" )
end if
end iif( unreadable > 0 orelse len( stopped ) > 0, 1, 0 )
The main program is the last stage. It parses the command line and sets up the logger with
CT_STDERR, so diagnostics never mix with the report. It makes a context, with a deadline if -t
asked for one. It creates the channels and the WaitGroup, starts the Stopwatch, and spawns
everything. Then it collects:
byHash, aMapfrom hash to the paths with that hash, already passed throughDisplayPath.byHash[ h ]creates an empty array the first time a hash is seen, so.Push( )works for the first path and every later one.bytesOf, the size that goes with each hash.sizes, anumeric.RunningStats(Chapter 27), which keeps the count, minimum, maximum and mean in constant space, one value at a time.
After the loop, ctx.Err( ) says whether the deadline cut the run short. It is read before
ctx.Cancel( ), which every context gets when it is finished with, and which would otherwise
change the answer to cancelled.
The statistics line prints the smallest, largest and mean sizes only when there are some. With no
sizes at all, from an empty directory or one where nothing could be read, RunningStats has none
to give: it answers NaN, which would print as a different string on each target. The line stops at
0 files, 0 bytes instead, and the exit code is still 0 for an empty directory and 1 when files
could not be read.
Turning arrival order into stable output
The records arrive in whatever order the hashers finish, and that order changes from run to run. Nothing in the report depends on it:
'' Groups of identical files, each sorted, the groups in order of their
'' first path; then how much space the extra copies take.
sub Report( byref byHash as containers.Map _
( of ulongint, containers.Array( of ustring ) ), _
byref bytesOf as containers.Map( of ulongint, int64 ) )
dim heads as containers.Array( of ustring )
dim headHash as containers.Map( of ustring, ulongint )
for each kv in byHash
if kv.value.Count( ) > 1 then
var group = kv.value
containers.Sort( group )
heads.Push( group[ 0 ] )
headHash[ group[ 0 ] ] = kv.key
end if
next
containers.Sort( heads )
dim as int64 wasted = 0
for each h in heads
var group = byHash[ headHash[ h ] ]
containers.Sort( group )
dim as int64 size = bytesOf[ headHash[ h ] ]
print group.Count( ); " copies of"; size; " bytes, ";
print "hash "; lcase( hex( headHash[ h ], 16 ) )
for each p in group
print " "; p
next
wasted += size * ( group.Count( ) - 1 )
next
print heads.Count( ); " groups;"; wasted; " bytes in extra copies"
end sub
Each group's paths are sorted. Each group is named by its first path, and the groups are printed
in sorted order of that name. The totals are sums, which come out the same in any order. The
Stopwatch time differs on every run, so it is logged only with -v, on stderr, and never
appears in the report.
The paths being sorted are the ones that are printed. The collector stores each one as
DisplayPath gives it, so the order cannot depend on the target. Sorting the raw paths would let
it: / sorts before the digits and \ after them, so on Windows s2\a0.txt would come before
s2\a\z.txt, and on Linux s2/a/z.txt before s2/a0.txt. DisplayPath is the same as Chapter
32's. It turns \ into / only on Windows, because on Linux \ is an ordinary character in a
file name:
'' A path as thash prints it: '/' between components everywhere. Only
'' Windows writes '\' there; on Linux it is an ordinary file-name
'' character and is left alone.
function DisplayPath( byref path as ustring ) as ustring
dim as ustring p = path
#ifdef __FB_WIN32__
for i as int32 = 0 to len( p ) - 1
if p[ i ] = asc( "\" ) then p[ i ] = asc( "/" )
next
#endif
return p
end function
ParseArgs reads every argument. The main program passes it __FB_ARGC__, which exists only at
the main module's top level (Chapter 25). It refuses an unknown option, a second directory, an
empty argument, and a -j or -t without a whole number above 0 after it.
numeric.TryParseLong (Chapter 27) takes the whole text or nothing, where valint( "2x" ) would
quietly give 2:
'' Read the argc - 1 arguments into opts; "" if they make sense, else
'' why not.
function ParseArgs( byval argc as int32, byref opts as Options ) _
as ustring
dim as int32 i = 1
do while i < argc
dim as ustring a = command( i )
select case a
case "-j", "-t"
i += 1
var n = numeric.TryParseLong( command( i ) )
if n.HasValue( ) = false orelse n.Value( ) < 1 then
return a & " needs a whole number above 0"
end if
if a = "-j" then
opts.workers = n.Value( )
else
opts.timeoutMs = n.Value( )
end if
case "-v" : opts.verbose = true
case "" : return "an empty argument"
case else
if left( a, 1 ) = "-" then return "unknown option " & a
if len( opts.root ) > 0 then return "only one directory"
opts.root = a
end select
i += 1
loop
if len( opts.root ) = 0 then return "no directory given"
if fs.IsDir( opts.root ) = false then
return opts.root & " is not a directory"
end if
return ""
end function
Running it
Run from the chapter's directory as thash -j 4 sample:
9 files, 395 bytes; smallest 22, largest 78, mean 43.9
3 copies of 27 bytes, hash d957e1846cbc67d7
sample/archive/2025/IMG_0042.img
sample/photos/lighthouse-copy.img
sample/photos/lighthouse.img
2 copies of 78 bytes, hash a6a3f3d0cd5a4640
sample/archive/2025/readme.txt
sample/docs/readme.txt
2 copies of 56 bytes, hash a2991430a071803d
sample/archive/notes-old.txt
sample/docs/notes.txt
3 groups; 188 bytes in extra copies
The output is the same with -j 1 and with -j 8, on Windows and on Linux: the harness runs it on
both, and it was also run 30 times with 1 to 8 workers. With -v on Windows, the log lines go to
stderr in the order the files were hashed, which changes between runs:
$ thash -v -j 2 sample
DEBUG hashed path=sample/archive/2025/readme.txt bytes=78
DEBUG hashed path=sample/archive/2025/IMG_0042.img bytes=27
…
INFO done ms=1
On Linux, with one file made unreadable (chmod 000 sample/docs/plans.txt), the warning goes to
stderr, the report covers the other eight files, and the exit code is 1:
$ thash sample
WARN access denied path=sample/docs/plans.txt
8 files, 373 bytes; smallest 24, largest 78, mean 46.6
…
3 groups; 188 bytes in extra copies
1 files could not be read
exit 1
On a larger tree of several thousand files, -t 20 stops part of the way through:
$ thash -t 20 tests
589 files, 766691 bytes; smallest 13, largest 51770, mean 1301.7
0 groups; 0 bytes in extra copies
stopped early (deadline exceeded)
How far it gets depends on the machine and on how busy it is. That is the nature of a time limit, and the reason the report says plainly that it is incomplete.
What it costs
- Every file is read whole.
fs.ReadAllBytesreads the file into one buffer and then copies it into theArray, so for a moment a hasher holds the file twice, and N hashers up to 2N files. AnArray's count is a 32-bitlong, so a file of 2 GiB or more cannot be read this way at all. For large files, read and hash in fixed-size blocks instead. - Equal hashes are evidence, not proof. Two different files with the same 64-bit FNV-1a hash are very unlikely, but possible, and FNV is not designed to resist someone making a collision on purpose. A tool that deletes copies must compare the bytes first (Exercise 1).
- Every file is hashed, even when no other file has the same size. Grouping by size first and hashing only the sizes that occur more than once would skip most files on a real disk.
- More workers is not always faster. Hashing a small file costs little; reading it is the slow
part, and a disk serves only so many reads at once. Measure with
-vbefore choosing-j.
Summary
- A pipeline is stages joined by channels. Each channel is closed once, by the stage that knows
nothing more will be sent; when several stages send, a
WaitGroupand a closer goroutine decide when. - Several goroutines receiving from one channel is a fan-out; several sending to one channel is a fan-in.
- A channel's capacity limits how far a sender can get ahead: back-pressure.
- A
Contextstops a pipeline. A stage that could wait for ever once the others stop putscase receive from ctx.Done( )beside that wait; a wait that the close discipline is sure to end does not need one. Readctx.Err( )to learn why it stopped. Aselectcase needs a wholeChannel, not aSendChanorRecvChan(TKO0809). - A
Loggercan be shared between goroutines; each record is written whole. - Output that arrives in no fixed order becomes stable by sorting it in the form it is printed in, and by keeping times and other run-dependent values out of it.
Exercises
- Write
SameBytes( a, b ), which compares two files byte by byte, and check it on the groups thatthashreports forsample/. {solution="ex34-01"} - Start two walkers, one for
sample/docsand one forsample/photos, both sending to a single channel, and print every file name they find in sorted order. Who closes the channel? {solution="ex34-02"} - Group the files by size first, and hash only the files whose size is shared by another file. Print how many files that skipped.
- Add
-min bytes, which ignores files smaller than the given size, and decide whether the walker or the hashers should apply it. - Add a fourth stage between the hashers and the collector that confirms each group with
SameBytesbefore it is reported.
Capstone: a TUI Ledger
The last capstone is tledger, a personal ledger: the money that came in and went out, kept
in a database, loaded from a bank's CSV export, shown and edited on terminal screens, and summed
into a monthly report. It joins the parts of the book that deal with real data:
Decimalfor money that must be exact (Chapter 27);LocalDatefor days (Chapter 26);- SQLite through
vendor/sqlite3(Chapter 28), and CSV throughstrings.SplitCsv(Chapter 9); - an
OrderedMapfor the report (Chapter 24); - the TUI library for the screens (Chapter 28);
- modules and a package to hold it together (Chapter 15).
The specification
tledger [--import file.csv]... [--list | --report] ledger.db
- The ledger is one SQLite file. Each transaction has a date, a payee, a category and an
amount: positive for money in, negative for money out.
:memory:gives a ledger that disappears when the program ends. --import file.csvloads a bank export with adate,payee,category,amountheader, and may be given more than once. All or nothing: if any record is wrong, none of the file is kept, and every wrong record is reported with its number in the file. The file can then be fixed and loaded again without making duplicates. Blank lines are skipped, and a file with no records imports nothing, without complaint.--listprints every transaction, oldest first.--reportprints income, spending and net for each month, and the totals. Asking for both is a mistake.- With neither, the screens open: a table of transactions, a form for a new one, and the monthly report. F1, F2 and F3 switch between them, and Esc leaves.
- Amounts print as
$1,234.56, the same on every machine, whatever its locale. - Exit code: 0; 1 if an import was refused; 2 for a bad command line, a ledger that cannot be opened, or no terminal to open the screens on.
The layout
ch35/
tiko.toml the package
ledger/ledger.tiko the data: model, storage, import, report -- and all of the SQL
ui/ui.tiko the screens: a TuiApp
tledger.tiko the program: the command line, the text output, the exit code
screens.tiko the screens driven by a script, with no terminal
schema.tiko migrations, run twice
bank.csv, bad.csv a bank export, and one with mistakes in it
The split follows the rule from Chapter 33, with one more reason behind it. ledger is the
only file that imports vendor/sqlite3, and nothing from SQLite is public: the rest of the
program sees transactions and totals, never a sqlite3 ptr or a statement. Swapping SQLite for
something else would change one file.
The model
#module "ledger"
#import "std/chrono"
#import "std/money"
#import "std/containers"
#import "std/strings"
'' This module is the only place that imports SQLite, and nothing
'' from it is public. Linked statically, it is inside the program.
#import "vendor/sqlite3" link static
'' One line of the ledger. Money in is positive, money out negative.
public type Txn
as longint id
as chrono.LocalDate day
as ustring payee
as ustring category
as money.Decimal amount
end type
'' One month of the report.
public type MonthTotals
as money.Decimal income
as money.Decimal spending '' zero or negative
end type
'' Months by "YYYY-MM". A generic is named through an alias.
public type MonthMap as containers.OrderedMap( of ustring, MonthTotals )
A Txn holds its money as a Decimal, never as a float64. 0.1 + 0.2 is not 0.3 in
binary floating point (Chapter 27), and a ledger that is a cent out after a year of additions is
wrong. The date is a LocalDate: a calendar day, with no time and no zone, which is all a
bank statement says. MonthTotals is one row of the report. MonthMap names the generic the
report fills, so the screens and the program can write it without repeating its type arguments.
One check for every way in
'' Check four fields of text and build a Txn from them: "" if they make
'' a transaction, else what is wrong. The import and the entry form
'' both come through here, so they refuse exactly the same things.
public function ParseTxn _
( byref day as ustring, byref payee as ustring, _
byref category as ustring, byref amount as ustring, _
byref t as Txn ) as ustring
'' TryParseIso also reads the compact 20260105. A ledger keeps one
'' spelling, so the dashes are required.
dim as boolean dashed = len( day ) = 10 andalso _
mid( day, 5, 1 ) = "-" andalso _
mid( day, 8, 1 ) = "-"
if dashed = false orelse _
chrono.LocalDate.TryParseIso( day, t.day ) = false then
return "'" & day & "' is not a date (YYYY-MM-DD)"
end if
t.payee = trim( payee )
if len( t.payee ) = 0 then return "the payee is empty"
t.category = lcase( trim( category ) )
if len( t.category ) = 0 then t.category = "other"
var d = money.DecimalParse( trim( amount ) )
if d.HasValue( ) = false then
return "'" & amount & "' is not an amount"
end if
if d.Value( ).Scale( ) > 2 then
return "'" & amount & "' has more than two decimal places"
end if
t.amount = d.Value( )
return ""
end function
Text becomes a transaction in one place. The import calls ParseTxn for each line of the
CSV, and the entry form calls it for what was typed. The two can never disagree about what a
valid transaction is. It refuses a date that does not exist (LocalDate.TryParseIso knows that
April has thirty days) or is not written YYYY-MM-DD, an empty payee, and an amount that is not a
number or has more than two decimal places. TryParseIso also reads the compact ISO form,
20260105, so the dashes are checked first: a ledger keeps one spelling of a date, and the
message promises that one. A Decimal keeps the scale it was written with, so Scale( ) says how many places
the text had. Categories are folded to lower case, so Food and food are one category.
Storage
public sub CloseLedger( )
sqlite3_close( db ) '' closing a null connection is fine
db = nullptr
end sub
'' Open (or create) a ledger file, or ":memory:" for one that is gone
'' when the program ends. Returns "" or why it could not.
public function OpenLedger( byref path as ustring ) as ustring
if sqlite3_open( path, @db ) <> SQLITE_OK then
var why = LastError( )
CloseLedger( )
return why
end if
return Migrate( )
end function
The module keeps one database for the whole program, a connection in a module variable, and four small procedures that every other part of the module goes through:
'' The program's one database. OpenLedger and CloseLedger bracket it.
private dim shared as sqlite3 ptr db
'' What SQLite says went wrong with the last call on the connection.
function LastError( ) as ustring
dim as string msg = *sqlite3_errmsg( db )
return msg
end function
'' Run SQL that returns no rows: "" or SQLite's reason.
function RunSql( byref sql as ustring ) as ustring
if sqlite3_exec( db, sql, nullptr, nullptr, nullptr ) <> SQLITE_OK _
then return LastError( )
return ""
end function
'' Compile SQL into st: "" or SQLite's reason.
function Prepare( byref sql as ustring, _
byref st as sqlite3_stmt ptr ) as ustring
if sqlite3_prepare_v2( db, sql, -1, @st, nullptr ) <> SQLITE_OK _
then return LastError( )
return ""
end function
'' A column of the current row as text. SQLite's text is UTF-8, and a
'' string holding UTF-8 converts to a ustring correctly.
function ColText( byval st as sqlite3_stmt ptr, _
byval col as int32 ) as ustring
dim as string bytes
var p = sqlite3_column_text( st, col )
if p <> nullptr then bytes = *cast( zstring ptr, p )
return bytes
end function
The variable is private for a reason that goes beyond tidiness. A module's interface describes
its module-level variables to the files that import it, so that a generic's body can use them. A
variable that is not private would put sqlite3 ptr into that description, and SQLite would
reach every importer. That is exactly what the module exists to prevent. private keeps the
variable, and its type, inside the module.
The four procedures turn SQLite's C conventions into the module's own. SQLite reports a failure
as a result code and keeps the reason on the connection; every procedure here returns "" or
that reason, as text, so the callers read like the rest of the program. RunSql runs SQL that
returns no rows, Prepare compiles a statement, LastError fetches the reason, and
ColText reads a column back as a ustring. (Exec and Field would have been shorter
names, and both are taken: tiko predeclares field and exec, and declaring either as these
procedures are is an error, TKO0600 and TKO0793.)
Text crosses in UTF-8 both ways. A ustring passed where SQLite's C wants const char * is
converted to UTF-8 for the call, so RunSql hands its ustring straight to sqlite3_exec.
Coming back, sqlite3_column_text points at UTF-8 bytes SQLite owns; ColText copies them into a
string, and a string assigned to a ustring is read as UTF-8.
The schema, and migrations
'' The schema, one step per version. A step that has shipped is never
'' edited -- some database has already run it -- so a change to the
'' schema is always a new step at the end.
const LATEST_SCHEMA = 2
function SchemaStep( byval v as int32 ) as ustring
select case v
case 1
return "create table txn( " & _
"id integer primary key, " & _
"day text not null, " & _
"payee text not null, " & _
"category text not null, " & _
"amount text not null )"
case 2
return "create index txn_day on txn( day )"
end select
return ""
end function
'' The version the file is at, in v: "" or why it cannot be read --
'' "file is not a database", for a file that is something else.
function ReadVersion( byref v as int32 ) as ustring
dim st as sqlite3_stmt ptr
var why = Prepare( "pragma user_version", st )
if len( why ) > 0 then return why
defer sqlite3_finalize( st )
if sqlite3_step( st ) <> SQLITE_ROW then return LastError( )
v = sqlite3_column_int( st, 0 )
return ""
end function
public function SchemaVersion( ) as int32
dim as int32 v = -1
ReadVersion( v )
return v
end function
'' Bring the schema up to date, one transaction per step, so a failure
'' leaves the database at the last version that completed.
function Migrate( ) as ustring
dim as int32 current
var why = ReadVersion( current )
if len( why ) > 0 then return why
if current > LATEST_SCHEMA then
return "the database is from a newer tledger (schema " & _
str( current ) & ")"
end if
for v as int32 = current + 1 to LATEST_SCHEMA
why = RunSql( "begin" )
if len( why ) = 0 then why = RunSql( SchemaStep( v ) )
if len( why ) = 0 then
why = RunSql( "pragma user_version = " & v )
end if
if len( why ) = 0 then why = RunSql( "commit" )
if len( why ) > 0 then
RunSql( "rollback" ) '' undo whatever the step did
return "schema step " & str( v ) & ": " & why
end if
next
return ""
end function
A program that keeps data outlives the first version of its schema. Migrations are how it changes without losing what is already stored. Each version is one step, applied in order, and the database remembers how far it has got.
SQLite keeps a number for exactly that purpose: pragma user_version, an integer in the
database file's header that SQLite itself never uses. A new file has 0. Migrate reads it, runs
every step above it, and sets it after each one, inside the same transaction. A step that fails
rolls back with its version number, so the file is always at a version that completed. A file
whose version is above the program's is refused: it was written by a newer tledger, and this
one cannot know what the newer steps did.
Reading the version is also the first time SQLite looks inside the file. sqlite3_open succeeds
on any file, because SQLite reads nothing until it must, so a ledger path that names a text file fails
here, and Migrate returns SQLite's own reason: tledger notes.txt prints
tledger: notes.txt: file is not a database and exits with 2. SchemaVersion, the public
question, says -1 for the same file.
Every statement in a step is checked, begin and commit included: they are SQL like the step
itself, run by RunSql. On any failure the step runs rollback, which undoes whatever it had
done. commit can fail too — a file locked by another program refuses it — and then the
transaction is still open, which is why the rollback follows a failed commit as well.
The rule that makes this work is in the comment: a step that has shipped is never edited.
Version 2 here added an index. Adding a column next year is a version 3 that says
alter table txn add column …. Editing step 1 instead would change nothing in every database
that has already run it.
'' Migrations: a new file is brought to the latest schema; opening it
'' again finds nothing to do.
#import "./ledger"
kill "schema-demo.db" '' start from nothing
print "open: ["; ledger.OpenLedger( "schema-demo.db" ); "]"
print "schema version"; ledger.SchemaVersion( )
ledger.CloseLedger( )
print "reopen: ["; ledger.OpenLedger( "schema-demo.db" ); "]"
print "schema version"; ledger.SchemaVersion( )
ledger.CloseLedger( )
kill "schema-demo.db"
open: []
schema version 2
reopen: []
schema version 2
Rows in and out
'' Dates are stored as ISO text, which sorts in date order; amounts as
'' the Decimal's own text, which is exact. SQL never does arithmetic on
'' them: sum( ) would turn them into binary floating point.
function Insert( byref t as Txn ) as ustring
dim st as sqlite3_stmt ptr
var why = Prepare( "insert into txn( day, payee, category, " & _
"amount ) values( ?, ?, ?, ? )", st )
if len( why ) > 0 then return why
defer sqlite3_finalize( st )
sqlite3_bind_text( st, 1, t.day.ToIsoString( ), -1, _
SQLITE_TRANSIENT )
sqlite3_bind_text( st, 2, t.payee, -1, SQLITE_TRANSIENT )
sqlite3_bind_text( st, 3, t.category, -1, SQLITE_TRANSIENT )
sqlite3_bind_text( st, 4, t.amount.ToString( ), -1, _
SQLITE_TRANSIENT )
if sqlite3_step( st ) <> SQLITE_DONE then return LastError( )
t.id = sqlite3_last_insert_rowid( db )
return ""
end function
public function AddTxn( byref t as Txn ) as ustring
return Insert( t )
end function
'' Every transaction, oldest first.
public function ListTxns( byref txns as containers.Array( of Txn ) ) _
as ustring
dim st as sqlite3_stmt ptr
var why = Prepare( "select id, day, payee, category, amount " & _
"from txn order by day, id", st )
if len( why ) > 0 then return why
defer sqlite3_finalize( st )
var rc = sqlite3_step( st )
do while rc = SQLITE_ROW
dim t as Txn
t.id = sqlite3_column_int64( st, 0 )
t.day = chrono.LocalDate.ParseIso( ColText( st, 1 ) )
t.payee = ColText( st, 2 )
t.category = ColText( st, 3 )
var amount = money.DecimalParse( ColText( st, 4 ) )
t.amount = amount.Value( )
txns.Push( t )
rc = sqlite3_step( st )
loop
if rc <> SQLITE_DONE then return LastError( )
return ""
end function
Two storage decisions are deliberate:
- Dates are ISO text,
2026-01-05. They sort as text in date order, soorder by dayneeds nothing else. A month is the first seven characters. - Amounts are the Decimal's own text,
-1450.00, which is exact. SQLite could store a number instead, but itssum( )would add in binary floating point, and the exactness the model bought would be lost at the last step. So SQL stores and sorts, and arithmetic happens in the program, in Decimal.
Every value travels as a ? parameter (Chapter 28), so a payee called O'Brien is just text.
Nothing is pasted into SQL. Each statement is finalized by a defer written straight after it is
prepared (Chapter 22), so every return below it, the early ones included, frees it.
ListTxns keeps sqlite3_step's last result: a loop that stops at anything but SQLITE_ROW
would otherwise treat a failure halfway through as the end of the rows.
Importing a bank's CSV
'' Field i of a CSV record, or "" when the record is shorter.
function FieldAt( byref rec as strings.CsvRow, byval i as int32 ) _
as ustring
if i < 0 orelse i >= rec.fields.Count( ) then return ""
return rec.fields[ i ]
end function
'' Check every record and insert the good ones, in one transaction
'' that is committed only if every record was good.
function AddRecords _
( byref path as ustring, _
byref rows as containers.Array( of strings.CsvRow ), _
byref problems as containers.Array( of ustring ) ) as int32
'' Row 0 is the header: it says which column holds what.
dim as int32 cDate = -1, cPayee = -1, cCategory = -1, cAmount = -1
for c as int32 = 0 to rows[ 0 ].fields.Count( ) - 1
select case lcase( trim( FieldAt( rows[ 0 ], c ) ) )
case "date": cDate = c
case "payee": cPayee = c
case "category": cCategory = c
case "amount": cAmount = c
end select
next
if cDate < 0 orelse cPayee < 0 orelse cAmount < 0 then
problems.Push( path & ": the header needs date, payee " & _
"and amount" )
return 0
end if
var why = RunSql( "begin" )
if len( why ) > 0 then
problems.Push( path & ": " & why )
return 0
end if
dim as int32 added = 0
for r as int32 = 1 to rows.Count( ) - 1
var day = FieldAt( rows[ r ], cDate )
var payee = FieldAt( rows[ r ], cPayee )
var category = FieldAt( rows[ r ], cCategory )
var amount = FieldAt( rows[ r ], cAmount )
'' A blank line -- most often the last one -- is a record with
'' every field empty. It is not a mistake, and adds nothing.
if len( day & payee & category & amount ) = 0 then continue for
dim t as Txn
why = ParseTxn( day, payee, category, amount, t )
if len( why ) = 0 then why = Insert( t )
if len( why ) > 0 then
'' Row r is record r + 1 of the file: the header was 1.
problems.Push( path & ":" & str( r + 1 ) & ": " & why )
else
added += 1
end if
next
if problems.Count( ) = 0 then
why = RunSql( "commit" )
if len( why ) > 0 then problems.Push( path & ": " & why )
end if
if problems.Count( ) > 0 then
RunSql( "rollback" ) '' keep none of the file
return 0
end if
return added
end function
'' Load a bank's CSV export (date,payee,category,amount). All or
'' nothing: one bad record and no record is kept, so the file can be
'' fixed and loaded again without making duplicates. Returns the number
'' of rows added; problems get one message per bad record.
public function ImportCsv _
( byref path as ustring, _
byref problems as containers.Array( of ustring ) ) as int32
'' The file's bytes, as they are: UTF-8, which SplitCsv reads.
dim as string text
dim f as int32 = freefile
if open( path for binary access read as #f ) <> 0 then
problems.Push( path & ": cannot be read" )
return 0
end if
text = space( lof( f ) )
get #f, , text
close #f
var rows = strings.SplitCsv( text )
'' A file with no records -- a header alone, or nothing at all --
'' adds nothing, and is not a mistake.
if rows.Count( ) < 2 then return 0
return AddRecords( path, rows, problems )
end function
ImportCsv reads the file's bytes whole, and strings.SplitCsv (Chapter 9) does the
parsing: quoted fields such as "Smith, Jones & Co", doubled quotes, either kind of line ending.
The header row says which column is which, so a bank that puts amount first still loads.
AddRecords then sends every record through ParseTxn and inserts the good ones, all inside one
transaction. If any record was refused, it runs rollback, and the ledger is exactly as it
was.
Three cases are easy to miss:
- A blank line, most often the last one, is a record whose fields are all empty. It is not
a mistake, so it is skipped rather than refused as
'' is not a date. - A file with no records — a header alone, or an empty file — gives
SplitCsvfewer than two rows, andImportCsvreturns before starting a transaction, so the program reportsimported 0 transactionseither way. - The number in a message is the record's: row 0 is the header, record 1 of the file, so row
ris recordr + 1. That is the line number for every file a bank exports. A quoted field may hold a line break, though, and then every record after it sits further down the file than its number says.
date,payee,category,amount
2026-01-02,Harbour Property Management,rent,-1450.00
2026-01-05,Northwind Ltd,salary,3200.00
2026-01-09,Corner Grocer,food,-86.42
2026-01-17,"Smith, Jones & Co",utilities,-112.30
2026-01-24,Corner Grocer,food,-64.15
2026-02-02,Harbour Property Management,rent,-1450.00
2026-02-05,Northwind Ltd,salary,3200.00
2026-02-11,City Transit,transport,-98.00
2026-02-14,Corner Grocer,food,-71.80
2026-02-20,"Smith, Jones & Co",utilities,-104.95
2026-03-02,Harbour Property Management,rent,-1450.00
2026-03-05,Northwind Ltd,salary,3200.00
2026-03-06,Tax refund,income,412.60
2026-03-12,Corner Grocer,food,-93.27
The report
'' Income and spending per month, keyed "YYYY-MM". An OrderedMap keeps
'' its keys sorted, so the months come out in calendar order.
public sub SumByMonth( byref txns as containers.Array( of Txn ), _
byref months as MonthMap )
for each t in txns
var key = left( t.day.ToIsoString( ), 7 )
if t.amount.IsNegative( ) then
months[ key ].spending = months[ key ].spending + t.amount
else
months[ key ].income = months[ key ].income + t.amount
end if
next
end sub
'' The report over the whole ledger: "" or why it could not be read.
public function MonthlyReport( byref months as MonthMap ) as ustring
dim txns as containers.Array( of Txn )
var why = ListTxns( txns )
if len( why ) = 0 then SumByMonth( txns, months )
return why
end function
'' Money as the reports print it. Every field is set, so the text is
'' the same whatever the locale of the machine running the program.
public function FormatMoney( byref d as money.Decimal ) as ustring
dim as money.MoneyFormat mf = d
mf.Symbol = "$"
mf.DecimalPoint = "."
mf.ThousandsSep = ","
mf.Grouping = "3;0" '' threes, repeated: 1,234,567
mf.FracDigits = 2
mf.SymbolPrecedes = true
mf.SpaceBySymbol = false
mf.Negative = money.MONEY_NEG_MINUS
return mf.ToString( )
end function
MonthlyReport reads every transaction, and SumByMonth adds each to its month, as income or as
spending. The two are separate so that the screens, which already hold the list, can sum it
without reading the ledger again. months[ key ] creates a zero MonthTotals the first time a month is seen, as a Map does.
Because the map is an OrderedMap, iterating it visits the keys in sorted order. YYYY-MM
sorts as the calendar does, so the months come out in order without a separate sort.
MoneyFormat turns a Decimal into money text. By default it follows the user's locale:
symbol, separators, grouping, and where the minus sign goes. That is right for a program showing a
person their own money, and wrong for a book whose output must be the same on every machine. So
FormatMoney sets every field itself. To follow the locale instead, leave the fields unset.
Grouping is a list of group sizes, read from the right, and it does not repeat by itself.
"3" makes one group of three and stops, so -1234567.89 prints as -$1234,567.89: every
amount under a million looks right, and the first large one does not. A trailing 0 means
"repeat the last size", so "3;0" gives -$1,234,567.89, and the Indian "3;2;0" gives
-$12,34,567.89.
The screens
#module "ui"
#import "std/tui"
#import "std/containers"
#import "std/money"
#import "../ledger"
public enum Page
PAGE_LIST
PAGE_ENTRY
PAGE_REPORT
end enum
'' A generic is named through an alias before it can be a field.
public type TxnArray as containers.Array( of ledger.Txn )
'' Everything the screens show is drawn from this state, every frame.
public type LedgerApp extends tui.TuiApp
as Page page
as TxnArray txns
as ledger.MonthMap months '' the report
as tui.TuiTableState listState
'' The form's four fields -- date, payee, category, amount -- and
'' which of them has the keys.
as tui.TuiTextInputState inputs( 0 to 3 )
as int32 focus
as ustring message '' the status line
declare function Init( ) as tui.TuiCmd override
declare function Update( byref e as tui.TuiEvent ) _
as tui.TuiCmd override
declare sub View( byref f as tui.TuiFrame ) override
declare sub Reload( )
declare sub StartEntry( )
declare sub Edit( byref e as tui.TuiEvent )
declare sub Save( )
end type
LedgerApp is a TuiApp (Chapter 28): Init, Update and View. Its fields are everything
the screens show: which page is up, the transactions, the report's months, the table's
selection, the four fields of the form, and the status line. Update changes them and draws
nothing. View draws from them and reads nothing else, not even the database: Reload reads the
ledger once, and sums the report from the list it read, so a frame costs no query. That separation
is what lets the screens be tested without a terminal, below.
View does write one thing back. TuiTable.Render keeps the selection inside the rows that
exist and moves listState.offset, the first row shown, so that the selected row stays in view.
That has to happen while drawing, because only then is the table's height known. It is state the
table owns, and the app only stores it.
Update
function LedgerApp.Init( ) as tui.TuiCmd
page = PAGE_LIST
Reload( )
return tui.TuiNone( )
end function
'' Read the ledger into the state: the list, and the report summed
'' from it. View draws from these and never reads the database.
sub LedgerApp.Reload( )
txns.Clear( )
months.Clear( )
var why = ledger.ListTxns( txns )
if len( why ) > 0 then message = why
ledger.SumByMonth( txns, months )
end sub
sub LedgerApp.StartEntry( )
for i as int32 = 0 to 3
tui.TuiTextInputStateInit( inputs( i ) )
next
focus = 0
message = "Tab: next field Enter: save Esc: cancel"
page = PAGE_ENTRY
end sub
'' A key or a paste for the field that has the focus.
sub LedgerApp.Edit( byref e as tui.TuiEvent )
'' A widget is a value: make one over the state, hand it the
'' event, and let it edit the text.
dim as tui.TuiTextInput box
box.state = @inputs( focus )
box.isFocused = true '' or it ignores the event
box.HandleEvent( e )
'' This box was never drawn, so it does not know how wide the field
'' is and scrolls as if one column were. The next frame's box knows:
'' let it do the scrolling.
inputs( focus ).scrollX = 0
end sub
'' The form's text goes through the same check as an imported line.
sub LedgerApp.Save( )
dim t as ledger.Txn
var why = ledger.ParseTxn( inputs( 0 ).text, inputs( 1 ).text, _
inputs( 2 ).text, inputs( 3 ).text, t )
if len( why ) = 0 then why = ledger.AddTxn( t )
if len( why ) > 0 then
message = why '' stay on the form, fix, retry
return
end if
Reload( )
for i as int32 = 0 to txns.Count( ) - 1
if txns[ i ].id = t.id then listState.selected = i
next
message = "added: " & t.payee
page = PAGE_LIST
end sub
function LedgerApp.Update( byref e as tui.TuiEvent ) as tui.TuiCmd
'' Pasted text arrives as one event, not as keys.
if e.kind = tui.TUIEVENT_PASTE then
if page <> PAGE_ENTRY then return tui.TuiNone( )
Edit( e )
return tui.TuiRedraw( )
end if
if e.kind <> tui.TUIEVENT_KEY then return tui.TuiNone( )
select case e.key.code
case tui.TUIKEY_F1 : page = PAGE_LIST : message = ""
case tui.TUIKEY_F2 : StartEntry( )
case tui.TUIKEY_F3 : page = PAGE_REPORT : message = ""
case tui.TUIKEY_ESCAPE
if page = PAGE_LIST then return tui.TuiQuit( 0 )
page = PAGE_LIST
message = ""
case else
if page = PAGE_LIST then
if e.key.code = tui.TUIKEY_UP then listState.selected -= 1
if e.key.code = tui.TUIKEY_DOWN then listState.selected += 1
if listState.selected >= txns.Count( ) then
listState.selected = txns.Count( ) - 1
end if
if listState.selected < 0 then listState.selected = 0
elseif page = PAGE_ENTRY then
if e.key.code = tui.TUIKEY_TAB then
focus = ( focus + 1 ) mod 4
elseif e.key.code = tui.TUIKEY_ENTER then
Save( )
else
Edit( e )
end if
end if
end select
return tui.TuiRedraw( )
end function
F1, F2 and F3 work everywhere. Esc goes back to the list, or quits from the list. On the list, Up and Down move the selection, clamped to the rows that exist. On the form, Tab moves between the fields and Enter saves. Every other key goes to the field that has the focus.
So does a paste. TuiTermOptionsDefault asks the terminal for bracketed paste, and on a
terminal that has it, pasted text arrives as one TUIEVENT_PASTE event carrying all of it, not as
a run of keys. An Update that looks only at TUIEVENT_KEY drops every paste without a sound, so
this one sends a paste on the form to the focused field, and ignores it on the other pages.
A widget is a value. Edit makes a TuiTextInput over the state of the focused field, hands it
the event, and lets it go. The text, the cursor and the scroll live in the TuiTextInputState,
which the app owns. Two details are worth knowing:
isFocusedmust be set, or the input ignores the event.- A box that has never been drawn does not know how wide its field is, so it scrolls the text
as if the field were one column wide. Setting
scrollXback to 0 leaves the scrolling to the next frame's box, which is drawn into a real area and knows.
Save sends the four fields through ledger.ParseTxn. If they are refused, the message goes into
the status line and the form stays, so the mistake can be fixed. If they are accepted, the list is
reloaded and the new row is selected.
View
sub LedgerApp.View( byref f as tui.TuiFrame )
dim as tui.TuiConstraintArray rowsC
rowsC.Push( tui.TuiLength( 1 ) ) '' the page tabs
rowsC.Push( tui.TuiFill( ) ) '' the page
rowsC.Push( tui.TuiLength( 1 ) ) '' the status line
dim as tui.TuiRectArray band = tui.TuiSplitV( f.Area( ), rowsC )
dim as tui.TuiStatusBar tabs
tabs.Add( " F1 list F2 add F3 report Esc quit", _
tui.TuiStyleDefault( ), tui.TUIALIGN_LEFT )
tabs.Add( "tledger ", tui.TuiStyleDefault( ), tui.TUIALIGN_RIGHT )
tabs.Render( f, band[0] )
select case page
case PAGE_LIST : ViewList( this, f, band[1] )
case PAGE_ENTRY : ViewEntry( this, f, band[1] )
case PAGE_REPORT : ViewReport( this, f, band[1] )
end select
dim as tui.TuiStatusBar sb
sb.Add( " " & message, tui.TuiStyleDefault( ), tui.TUIALIGN_LEFT )
if page = PAGE_LIST andalso txns.Count( ) > 0 then
sb.Add( str( listState.selected + 1 ) & "/" & _
str( txns.Count( ) ) & " ", _
tui.TuiStyleDefault( ), tui.TUIALIGN_RIGHT )
end if
sb.Render( f, band[2] )
end sub
Three bands: the page tabs, the page, and a status line. Each page is a procedure:
sub ViewList( byref app as LedgerApp, byref f as tui.TuiFrame, _
byval area as tui.TuiRect )
var b = Boxed( " transactions " )
b.Render( f, area )
dim as tui.TuiTable t
'' A table puts no gap between columns: the widths leave one.
t.headers.Push( "Date" ) : t.widths.Push( tui.TuiLength( 11 ) )
t.headers.Push( "Payee" ) : t.widths.Push( tui.TuiFill( ) )
t.headers.Push( " Category" ) : t.widths.Push( tui.TuiLength( 11 ) )
t.headers.Push( RightIn( "Amount", 13 ) )
t.widths.Push( tui.TuiLength( 13 ) )
for each x in app.txns
dim as tui.TuiRow r
r.cells.Push( x.day.ToIsoString( ) )
r.cells.Push( x.payee )
r.cells.Push( " " & x.category )
r.cells.Push( MoneyCell( x.amount, 13 ) )
t.rows.Push( r )
next
t.state = @app.listState
t.showHeader = true
t.Render( f, b.Inner( area ) )
end sub
A table leaves no gap between its columns, so the widths leave one: the date is 10 characters
in a column of 11, the category starts with a space, and RightIn always puts at least one space
in front of what it aligns. Amounts are right-aligned with MoneyCell, so their decimal points
line up for any amount of up to one character less than the column: 12 characters in the list's
13, which is -$999,999.99. A longer one is cut off, and ends in .... The report has more
room, and gives its amounts 15 columns, as does the text that --list and --report print.
sub ViewEntry( byref app as LedgerApp, byref f as tui.TuiFrame, _
byval area as tui.TuiRect )
var b = Boxed( " new transaction " )
b.Render( f, area )
var inner = b.Inner( area )
dim as ustring labels( 0 to 3 ) = _
{ "Date", "Payee", "Category", "Amount" }
dim as ustring hints( 0 to 3 ) = _
{ "YYYY-MM-DD", "who was paid, or who paid", "food, rent ...", _
"-12.50 out, 12.50 in" }
for i as int32 = 0 to 3
if i * 2 >= inner.h then exit for
dim as tui.TuiParagraph lab
lab.text = iif( i = app.focus, "> ", " " ) & labels( i )
dim as int32 y = inner.y + i * 2
lab.Render( f, tui.TuiRectOf( inner.x, y, 12, 1 ) )
dim as tui.TuiTextInput box
box.state = @app.inputs( i )
box.placeholder = hints( i )
box.isFocused = ( i = app.focus )
box.Render( f, tui.TuiRectOf( inner.x + 12, y, _
inner.w - 12, 1 ) )
next
end sub
sub ViewReport( byref app as LedgerApp, byref f as tui.TuiFrame, _
byval area as tui.TuiRect )
var b = Boxed( " monthly report " )
b.Render( f, area )
dim as tui.TuiTable t
t.headers.Push( "Month" ) : t.widths.Push( tui.TuiLength( 7 ) )
t.headers.Push( RightIn( "Income", 15 ) )
t.widths.Push( tui.TuiLength( 15 ) )
t.headers.Push( RightIn( "Spending", 15 ) )
t.widths.Push( tui.TuiLength( 15 ) )
t.headers.Push( RightIn( "Net", 15 ) )
t.widths.Push( tui.TuiLength( 15 ) )
for each m in app.months
dim as tui.TuiRow r
r.cells.Push( m.key )
r.cells.Push( MoneyCell( m.value.income, 15 ) )
r.cells.Push( MoneyCell( m.value.spending, 15 ) )
var net = m.value.income + m.value.spending
r.cells.Push( MoneyCell( net, 15 ) )
t.rows.Push( r )
next
t.showHeader = true
t.Render( f, b.Inner( area ) )
end sub
Testing the screens without a terminal
TuiRun needs a real terminal, and a test has none. But a frame depends only on the app's state,
and the state changes only through Update, apart from the table's scroll offset, which
rendering itself moves. So a test can feed Update the events a person would cause, render with
TuiRenderOnce, and print the screen:
'' The three screens, drawn with no terminal: keys are fed to Update
'' as TuiRun would feed them, and each frame is printed as text.
#import "./ledger"
#import "./ui"
#import "std/tui"
#import "std/containers"
sub Dump( byref scr as tui.TuiScreen )
dim as tui.TuiBuffer ptr bf = scr.Front( )
for y as int32 = 0 to bf->rows - 1
dim as ustring row
for x as int32 = 0 to bf->cols - 1
dim as tui.TuiCell ptr c = bf->At( x, y )
if c->width > 0 then row &= c->text
next
print rtrim( row )
next
print
end sub
sub Press( byref app as ui.LedgerApp, byval code as tui.TuiKeyCode )
app.Update( tui.TuiKeyEvent( code ) )
end sub
sub TypeText( byref app as ui.LedgerApp, byref s as ustring )
for i as int32 = 0 to len( s ) - 1
app.Update( tui.TuiCharEvent( s[ i ] ) )
next
end sub
'' A paste comes as one event carrying all of the text.
sub Paste( byref app as ui.LedgerApp, byref s as ustring )
dim as tui.TuiEvent e
e.kind = tui.TUIEVENT_PASTE
e.text = s
app.Update( e )
end sub
ledger.OpenLedger( ":memory:" )
dim problems as containers.Array( of ustring )
ledger.ImportCsv( "bank.csv", problems )
dim app as ui.LedgerApp
dim as tui.TuiScreen scr
scr.Resize( 64, 13 )
app.Init( )
for k as int32 = 1 to 3
Press( app, tui.TUIKEY_DOWN )
next
tui.TuiRenderOnce( app, scr )
Dump( scr )
'' F2, then a line typed into the form -- with a date that is wrong --
'' and the payee pasted.
Press( app, tui.TUIKEY_F2 )
TypeText( app, "2026-03-32" ) : Press( app, tui.TUIKEY_TAB )
Paste( app, "Corner Cafe" ) : Press( app, tui.TUIKEY_TAB )
TypeText( app, "Food" ) : Press( app, tui.TUIKEY_TAB )
TypeText( app, "-4.75" )
Press( app, tui.TUIKEY_ENTER )
tui.TuiRenderOnce( app, scr )
Dump( scr )
'' Back to the date: Tab round to it, rub out the 32, save again.
Press( app, tui.TUIKEY_TAB )
Press( app, tui.TUIKEY_END )
Press( app, tui.TUIKEY_BACKSPACE ) : Press( app, tui.TUIKEY_BACKSPACE )
TypeText( app, "13" )
Press( app, tui.TUIKEY_ENTER )
tui.TuiRenderOnce( app, scr )
Dump( scr )
Press( app, tui.TUIKEY_F3 )
tui.TuiRenderOnce( app, scr )
Dump( scr )
ledger.CloseLedger( )
F1 list F2 add F3 report Esc quit tledger
+ transactions ------------------------------------------------+
|Date Payee Category Amount|
|2026-01-02 Harbour Property Management rent -$1,450.00|
|2026-01-05 Northwind Ltd salary $3,200.00|
|2026-01-09 Corner Grocer food -$86.42|
|2026-01-17 Smith, Jones & Co utilities -$112.30|
|2026-01-24 Corner Grocer food -$64.15|
|2026-02-02 Harbour Property Management rent -$1,450.00|
|2026-02-05 Northwind Ltd salary $3,200.00|
|2026-02-11 City Transit transport -$98.00|
+--------------------------------------------------------------+
4/14
F1 list F2 add F3 report Esc quit tledger
+ new transaction ---------------------------------------------+
| Date 2026-03-32 |
| |
| Payee Corner Cafe |
| |
| Category Food |
| |
|> Amount -4.75 |
| |
| |
+--------------------------------------------------------------+
'2026-03-32' is not a date (YYYY-MM-DD)
F1 list F2 add F3 report Esc quit tledger
+ transactions ------------------------------------------------+
|Date Payee Category Amount|
|2026-02-11 City Transit transport -$98.00|
|2026-02-14 Corner Grocer food -$71.80|
|2026-02-20 Smith, Jones & Co utilities -$104.95|
|2026-03-02 Harbour Property Management rent -$1,450.00|
|2026-03-05 Northwind Ltd salary $3,200.00|
|2026-03-06 Tax refund income $412.60|
|2026-03-12 Corner Grocer food -$93.27|
|2026-03-13 Corner Cafe food -$4.75|
+--------------------------------------------------------------+
added: Corner Cafe 15/15
F1 list F2 add F3 report Esc quit tledger
+ monthly report ----------------------------------------------+
|Month Income Spending Net |
|2026-01 $3,200.00 -$1,712.87 $1,487.13 |
|2026-02 $3,200.00 -$1,724.75 $1,475.25 |
|2026-03 $3,612.60 -$1,548.02 $2,064.58 |
| |
| |
| |
| |
| |
+--------------------------------------------------------------+
The first frame is the list, three rows down. The second is the form after a first attempt with the 32nd of March: the status line says why, and the text is still there, the pasted payee with the rest. The third is the list after the date was corrected, with the new row at the bottom and selected (15 of 15). The last is the report, which now includes the coffee.
The program
'' tledger -- a personal ledger in a terminal.
''
'' tledger [--import file.csv]... [--list | --report] ledger.db
''
'' With --list or --report it prints and exits; otherwise it opens the
'' interactive screens. Exit code: 0, 1 if an import was refused, 2 for
'' a bad command line, a ledger that cannot be opened, or no terminal
'' for the screens.
#import "./ledger"
#import "./ui"
#import "std/tui"
#import "std/containers"
sub Complain( byref msg as ustring )
dim as int32 e = freefile( )
open err for output as #e
print #e, "tledger: "; msg
close #e
end sub
sub PrintReport( )
dim months as ledger.MonthMap
var why = ledger.MonthlyReport( months )
if len( why ) > 0 then Complain( why ) : return
print "Month "; ui.RightIn( "Income", 15 ); _
ui.RightIn( "Spending", 15 ); ui.RightIn( "Net", 15 )
dim total as ledger.MonthTotals
for each m in months
var mt = m.value
print m.key; ui.MoneyCell( mt.income, 15 ); _
ui.MoneyCell( mt.spending, 15 ); _
ui.MoneyCell( mt.income + mt.spending, 15 )
total.income = total.income + mt.income
total.spending = total.spending + mt.spending
next
print "total "; ui.MoneyCell( total.income, 15 ); _
ui.MoneyCell( total.spending, 15 ); _
ui.MoneyCell( total.income + total.spending, 15 )
end sub
dim imports as containers.Array( of ustring )
dim as ustring mode, path
dim as int32 i = 1
do while len( command( i ) ) > 0
dim as ustring a = command( i )
select case a
case "--import"
i += 1
if len( command( i ) ) = 0 then
Complain( "--import needs a file" )
end 2
end if
imports.Push( command( i ) )
case "--list", "--report"
if len( mode ) > 0 andalso mode <> a then
Complain( "--list and --report cannot be combined" )
end 2
end if
mode = a
case else
if left( a, 1 ) = "-" orelse len( path ) > 0 then
Complain( "usage: tledger [--import file.csv]... " & _
"[--list | --report] ledger.db" )
end 2
end if
path = a
end select
i += 1
loop
if len( path ) = 0 then Complain( "no ledger file given" ) : end 2
var why = ledger.OpenLedger( path )
if len( why ) > 0 then Complain( path & ": " & why ) : end 2
dim as int32 status = 0
for each f in imports
dim problems as containers.Array( of ustring )
var added = ledger.ImportCsv( f, problems )
for each p in problems
Complain( p )
next
if problems.Count( ) > 0 then
Complain( f & ": nothing imported" )
status = 1
else
print "imported"; added; _
iif( added = 1, " transaction", " transactions" ); _
" from "; f
end if
next
select case mode
case "--list"
dim txns as ui.TxnArray
ledger.ListTxns( txns )
for each t in txns
print t.day.ToIsoString( ); " "; ui.LeftIn( t.payee, 28 ); _
ui.LeftIn( t.category, 12 ); ui.MoneyCell( t.amount, 15 )
next
case "--report"
PrintReport( )
case else
dim app as ui.LedgerApp
var r = tui.TuiRun( app, tui.TuiTermOptionsDefault( ) )
if r.IsOk( ) = false then Complain( "no terminal" ) : status = 2
end select
ledger.CloseLedger( )
end status
The program reads like the specification: arguments, opening the ledger, each import in order
with its problems on stderr, then --list, --report or the screens. Asking for both --list
and --report is refused rather than letting the last one win, because a person who typed both
expected both. ledger.OpenLedger runs the migrations, so a new file needs no separate set-up
step.
Running it
Run from the chapter's directory as tledger --import bank.csv --report :memory::
imported 14 transactions from bank.csv
Month Income Spending Net
2026-01 $3,200.00 -$1,712.87 $1,487.13
2026-02 $3,200.00 -$1,724.75 $1,475.25
2026-03 $3,612.60 -$1,543.27 $2,069.33
total $10,012.60 -$4,980.89 $5,031.71
A file with mistakes is refused whole, with a reason for each line, and the exit code is 1:
date,payee,category,amount
2026-04-01,Corner Grocer,food,-41.10
2026-04-31,Harbour Property Management,rent,-1450.00
2026-04-05,,salary,3200.00
2026-04-09,City Transit,transport,-12.505
$ tledger --import bad.csv --list :memory:
tledger: bad.csv:3: '2026-04-31' is not a date (YYYY-MM-DD)
tledger: bad.csv:4: the payee is empty
tledger: bad.csv:5: '-12.505' has more than two decimal places
tledger: bad.csv: nothing imported
exit 1
Line 2 was valid, and it was not kept either: the list prints nothing. With --list and the good
file:
$ tledger --import bank.csv --list :memory:
imported 14 transactions from bank.csv
2026-01-02 Harbour Property Management rent -$1,450.00
2026-01-05 Northwind Ltd salary $3,200.00
2026-01-09 Corner Grocer food -$86.42
…
2026-03-12 Corner Grocer food -$93.27
Given a file name instead of :memory:, the ledger persists, and tledger ledger.db opens the
screens on it.
Shipping
ledger imports SQLite with link static, so SQLite's code is linked into the program:
on Windows, tledger.exe imports nothing but KERNEL32.dll and the C runtime, ucrtbase.dll.
tledger is one executable with no database server and no shared library to install. Copy it,
and it runs. Without link static, vendor/sqlite3 links SQLite's shared library and copies it
beside the program, and the two would have to travel together. The ledger is one ordinary file,
which can be backed up by copying it while the program is not running, or at any time with SQL's
vacuum into 'copy.db'.
#foreignlib (Chapter 30) is for the other case: a library the project ships, beside its
sources, one file per platform. SQLite does not need it, because the toolchain's vendor package
already carries it.
What it costs
- The report reads every transaction and adds in the program. For a personal ledger — some
thousands of rows a year — that is nothing. For millions, store amounts as integer cents, so that
SQL's
sum( )stays exact, and let the database group by month. - The list loads every transaction into memory, and the screens draw only what fits. A very
large ledger would query one screenful at a time with
limitandoffset. - One database per program, in a private module variable. That keeps SQLite out of every other file, at the price of not opening two ledgers at once.
- All-or-nothing imports make the user fix a file before any of it counts. That is the safe choice for money: a half-imported statement is worse than none.
Summary
- Keep a library you may want to replace, such as SQLite, in one module. Make nothing from
it public, and make module variables of its types
private, so that it stays out of the interface. A few procedures that turn its C conventions — result codes, UTF-8 text — into the module's own keep the rest of the module readable. - Hold money in
Decimaland days inLocalDate. Store them as text that sorts and round-trips exactly, and do the arithmetic in the program. - Migrate with
pragma user_version: one step per version, one transaction per step, and never edit a step that has shipped. - Import through a staging table and one transaction, validate every row with the same function
the form uses, and roll back on any error. Skip blank records, treat a file with none as
empty, and check the result of
beginandcommitas well as of each statement. - An
OrderedMapkeyedYYYY-MMgives a monthly report in calendar order.MoneyFormatwith every field set prints money the same way everywhere, and itsGroupingmust end in;0to repeat. - Give every column at least one space and enough width for the amounts it will hold, and say what that limit is.
- A
TuiAppwhoseViewonly draws, from state thatUpdateandReloadfill, can be tested by feedingUpdateevents and rendering withTuiRenderOnce. RouteTUIEVENT_PASTEas well as keys to a text input.
Exercises
- Print the spending in each category over the whole ledger, the largest first. {solution="ex35-01"}
- Accept a quick-entry line,
date;payee;category;amount, and check it withParseTxn. {solution="ex35-02"} - Add a migration, version 3, that adds a
notecolumn, and a fifth field to the form for it. Open a version 2 file and check that it is upgraded and keeps its rows. - Refuse an import line that is already in the ledger (same date, payee and amount), so that loading the same statement twice adds nothing. Compare only with the rows that were there before this import began: two identical lines in one statement, such as two coffees on the same day, are both real.
- Add a fourth screen that shows the transactions of the month selected in the report.
Capstone: a Notepad for Windows
The other capstones run in a terminal. This one has windows. tpad is a plain-text editor in
the shape of the Notepad that comes with Windows: a menu bar, a page of text, a status bar, the
usual keys, and a question before it throws away unsaved work.
It is built from PsControls, the vendor package of owner-drawn Windows controls that comes with
the toolchain, and from PsApp, the small framework in the same package that runs the message
loop, the theme and the DPI scaling. With those doing the work Windows usually asks of a program,
tpad is about 800 lines, a sixth of them its self-test, and much of the rest is about text
files rather than windows. It joins:
- modules and a package (Chapter 15), with the part that needs no window kept apart from the part that does;
- procedure pointers and lambdas (Chapters 7 and 21), which is how every control reports back;
newanddeletefor state that lives as long as a window (Chapter 11);- files, text encodings and atomic writes through
std/fs(Chapters 9 and 25); - a C API — Windows itself — called directly where no wrapper is needed (Chapter 29);
- the GUI subsystem, resources and build profiles (Chapter 31).
This is not a Windows programming primer. Where a Win32 call appears, the text says in a sentence what it does and moves on.
The window is Windows-only: PsControls draws with GDI and GDI+. The text-file module and its test,
filetest, compile and run on Linux and macOS like every other listing, and so do the exercise
solutions.

The specification
tpad [file]
tpad --selftest
- One window: a menu bar, the text, a vertical scroll bar beside it, and a status bar.
- File: New (Ctrl+N), Open (Ctrl+O), Save (Ctrl+S), Save As (Ctrl+Shift+S), Exit. Edit: Undo, Cut, Copy, Paste and Select All, on their usual keys; Find (Ctrl+F) and Find Next (F3).
- Text is UTF-8. A file keeps the line ending it was written with, CR LF or LF, and its
byte-order mark if it had one, when it is saved. A file with no line break is saved with CR LF.
A file that is not UTF-8 opens with U+FFFD in place of what could not be read, and
tpadsays so. - The status bar shows the caret's line and column, the line ending, and the encoding.
- The title bar shows the file's name, with a
*in front while there are unsaved changes. - Before anything throws away unsaved text — New, Open, closing the window —
tpadasks: Save, Don't Save, or Cancel. - Find ignores case unless asked not to, and wraps around from the end to the top.
- The window follows Windows' light or dark setting, and the DPI of the monitor it is on.
--selftestdrives the window with nobody at it, prints what happened, and exits.
The layout
ch36/
tiko.toml the package, and the target tpad with its resources
tpad.rc, tpad.manifest the manifest, compiled into the program
textfile/textfile.tiko the document: load, save, find, line and column
pad/pad.tiko the window: controls, menus, commands
tpad.tiko the program, and its self-test
filetest.tiko textfile on its own, on both targets
The rule is the one from Chapter 33: the part that does not need a window does not import one.
Everything tpad knows about text files is in textfile, which imports std/fs and
std/strings and nothing from Windows. It can be tested on both targets, by a program that prints,
and it could serve a Linux editor unchanged. pad is the window, and tpad.tiko starts it.
The document
#module "textfile"
#import "std/fs"
#import "std/strings"
'' Nothing here knows about windows: this module is the part of tpad
'' that compiles, and is tested, on every target.
public enum LineEnding
CrLf
Lf
end enum
'' A text file as the editor sees it. Inside, every line break is one
'' LF, so an offset into Text is an offset into the editor control,
'' which also counts a line break as one character.
public type Document
as ustring path '' "" until the first Save As
as ustring text
as LineEnding ending = LineEnding.CrLf
as boolean hasBom '' the file began with a byte-order mark
as boolean lossy '' some bytes were not UTF-8
end type
A Document is what was read from the file, and what is needed to write it back the same way:
its path, its text, its line ending, and whether it began with a byte-order mark. lossy records
that some of its bytes could not be read. (The module is textfile and not document because a
module may not export a name it shares, in any case, with its own:
TKO0870: public 'document' has the same name as its module 'document'.)
The comment on text is the most important line in the module. Inside a Document, a line
break is one character, LF. The editor control counts a line break as one character too, so an
offset into text and an offset into the control are the same number, and a match found in one
can be selected in the other. ToLf makes that true of any text, whatever its line breaks:
'' CR LF and a lone CR both become LF.
public function ToLf( byref s as const ustring ) as ustring
return strings.Replace( strings.Replace( s, !"\r\n", !"\n" ), _
!"\r", !"\n" )
end function
The text control does not hand its text back in the form it counts it. Measured with a
multiline PsTextBox holding ab, CR LF, cd, LF, ef:
PsTextBox_GetText 10 characters: every line break comes back as CR LF
PsTextBox_SetSel( 0, -1 ) selects 0 to 8: every line break counts as one
Search the text GetText returns and select what you found, and the selection lands one
character early for every line above the match — right on the first line, wrong everywhere
else. Convert with ToLf first, as pad does in its one function that reads the control's text.
Loading
'' Did the file start with a byte-order mark? Only its first three
'' bytes are read.
private function StartsWithBom( byref p as const ustring ) as boolean
dim as fs.File f
if f.OpenRead( p ).IsOk( ) = false then return false
dim as ubyte b( 0 to 2 )
var got = f.Read( @b( 0 ), 3 )
if got.IsOk( ) = false then return false
dim as longint n = got.Value( )
if n >= 3 andalso b( 0 ) = &hEF andalso b( 1 ) = &hBB andalso _
b( 2 ) = &hBF then return true
return n >= 2 andalso b( 0 ) = &hFF andalso b( 1 ) = &hFE
end function
'' Reads a file into doc. Returns "" or what went wrong; on failure doc
'' is left as it was.
public function Load( byref p as const ustring, _
byref doc as Document ) as ustring
var r = fs.ReadAllText( p )
if r.IsOk( ) = false then return r.Failure( ).Message( )
dim as Document d
d.path = p
dim as ustring raw = r.Value( )
'' The first line break says how the file writes them.
dim as int32 lf = instr( raw, !"\n" ), cr = instr( raw, !"\r" )
if lf > 0 andalso cr <> lf - 1 then d.ending = LineEnding.Lf
d.text = ToLf( raw )
d.hasBom = StartsWithBom( p )
d.lossy = instr( d.text, wchr( &hFFFD ) ) > 0
doc = d
return ""
end function
fs.ReadAllText does most of the work, and two things it does decide the rest of the function:
- It removes a byte-order mark, UTF-8's or UTF-16's, and decodes what follows. That is what a
program reading text wants, but an editor must write the mark back, so
StartsWithBomopens the file and reads its first three bytes itself. - It replaces bytes that are not UTF-8 with U+FFFD, the replacement character, on both
targets. A file in an old 8-bit encoding opens, but its accented letters are gone from the
text, and saving writes the replacement characters.
Loadrecords that inlossy, and the window warns before anyone edits the file.
The line ending is the file's first one: an LF with a CR before it means CR LF. A file that mixes
the two is saved with the first kind throughout. Load fills a local Document and copies it to
doc only when everything has worked, so a failed open leaves the document that was there.
fs.ReadAllBytes has a three-argument form, and it is not "read the first n bytes". The third
argument is a limit: a file larger than it is refused with an error, and nothing is read.
fs.ReadAllBytes( p, head, 3 ) '' fails on any file over 3 bytes
The first draft of StartsWithBom was that line. It compiled, every file was "too large", the
function returned false, and the byte-order mark was quietly dropped on every save.
filetest caught it: its first file has a mark. Read a prefix with a File and Read, as the
listing does.
Saving
'' Writes doc.text to doc.path in the file's own line ending. The old
'' file is replaced only once the new one is complete on disk.
public function Save( byref doc as Document ) as ustring
dim as ustring s = doc.text
if doc.ending = LineEnding.CrLf then
s = strings.Replace( s, !"\n", !"\r\n" )
end if
if doc.hasBom then s = wchr( &hFEFF ) & s
var r = fs.WriteAtomic( doc.path, s )
if r.IsOk( ) = false then return r.Failure( ).Message( )
return ""
end function
Save undoes what Load did: LF back to CR LF if that is how the file was written, and the mark
back on the front. The mark is the character U+FEFF, which UTF-8 writes as the three bytes
EF BB BF. fs.WriteAtomic writes a temporary file beside the real one and renames it into
place only when the write is complete (Chapter 25), so a full disk or a crash in the middle of a
save leaves the old file whole, never half of the new one.
Names, finding, line and column
'' The file's name without its folder, or "Untitled".
public function DisplayName( byref doc as Document ) as ustring
if len( doc.path ) = 0 then return "Untitled"
dim as int32 i = len( doc.path )
do until i = 0
dim as ustring c = mid( doc.path, i, 1 )
if c = "\" orelse c = "/" then exit do
i -= 1
loop
return mid( doc.path, i + 1 )
end function
'' The title bar: "*notes.txt - tpad" while there are unsaved changes.
public function Caption( byref doc as Document, _
byval modified as boolean ) as ustring
return iif( modified, "*", "" ) & DisplayName( doc ) & " - tpad"
end function
'' The offset of the next match at or after start, wrapping around to
'' the top; -1 when there is none. Offsets count from 0, as the editor
'' control's do.
public function FindNext( byref text as const ustring, _
byref needle as const ustring, _
byval start as int32, _
byval matchCase as boolean ) as int32
if len( needle ) = 0 then return -1
dim as ustring hay = text, pin = needle
if matchCase = false then
hay = lcase( hay )
pin = lcase( pin )
end if
dim as int32 at = instr( start + 1, hay, pin )
if at = 0 then at = instr( hay, pin )
return at - 1
end function
FindNext takes and returns offsets counted from 0, as the control's selection is, and converts at
its edges: instr counts from 1. It searches from start to the end, then from the top, so a
search always wraps around once. Without match case, it lowers the case of both strings and
searches the copies; that keeps the offsets right, because lcase changes no string's length.
'' The 1-based line and column of an offset, for the status bar.
public sub LineColumn( byref text as const ustring, _
byval offset as int32, _
byref ln as int32, byref col as int32 )
ln = 1
dim as int32 lineStart = 1, at = instr( text, !"\n" )
do until at = 0 orelse at > offset
ln += 1
lineStart = at + 1
at = instr( at + 1, text, !"\n" )
loop
col = offset + 2 - lineStart
end sub
Testing it on both targets
'' The textfile module on its own, with no window: load, find, the
'' status-bar numbers, and a save that keeps the file's own format.
#import "./textfile"
#import "std/fs"
#import "std/containers"
'' Writes exact bytes, so the test does not depend on how this source
'' file's own line endings were checked out.
sub WriteBytes( byref p as ustring, bytes( ) as ubyte )
dim as containers.Array( of ubyte ) buf
for i as int32 = lbound( bytes ) to ubound( bytes )
buf.Push( bytes( i ) )
next
fs.WriteAllBytes( p, buf )
end sub
function HexOf( byref p as ustring ) as ustring
dim as containers.Array( of ubyte ) buf
fs.ReadAllBytes( p, buf )
dim as ustring s
for i as int32 = 0 to buf.Count( ) - 1
s &= hex( buf[i], 2 ) & " "
next
return rtrim( s )
end function
print "== a Windows file with a byte-order mark"
'' BOM, "Hi", CR LF, "hi there"
dim as ubyte win( ... ) = { &hEF, &hBB, &hBF, 72, 105, 13, 10, _
104, 105, 32, 116, 104, 101, 114, 101 }
WriteBytes( "win.tmp", win( ) )
dim as textfile.Document doc
print "load: ["; textfile.Load( "win.tmp", doc ); "]"
print "crlf: "; cbool( doc.ending = textfile.LineEnding.CrLf ); _
", bom: "; doc.hasBom; ", lossy: "; doc.lossy
print "length: "; len( doc.text ); ", title: "; _
textfile.Caption( doc, false )
print "== finding"
dim as int32 at = textfile.FindNext( doc.text, "hi", 0, false )
print "'hi' from 0, any case: "; at
at = textfile.FindNext( doc.text, "hi", at + 1, false )
print "next one: "; at
at = textfile.FindNext( doc.text, "hi", at + 1, false )
print "next wraps to the top: "; at
print "'hi' from 0, exact case:"; _
textfile.FindNext( doc.text, "hi", 0, true )
print "'bye': "; _
textfile.FindNext( doc.text, "bye", 0, false )
print "== line and column"
dim as int32 ln, col
dim as int32 offsets( ... ) = { 0, 2, 3, 11 }
for each offset as int32 in offsets
textfile.LineColumn( doc.text, offset, ln, col )
print "offset"; offset; " -> Ln"; ln; ", Col"; col
next
print "== save keeps the BOM and CR LF"
doc.text &= !"\nthird"
doc.path = "copy.tmp"
print "save: ["; textfile.Save( doc ); "] "; _
textfile.Caption( doc, true )
print HexOf( "copy.tmp" )
print "== a Linux file that is not UTF-8"
'' "caf", the Latin-1 byte for e-acute, LF
dim as ubyte latin( ... ) = { 99, 97, 102, &hE9, 10 }
WriteBytes( "latin.tmp", latin( ) )
print "load: ["; textfile.Load( "latin.tmp", doc ); "]"
print "lf: "; cbool( doc.ending = textfile.LineEnding.Lf ); _
", bom: "; doc.hasBom; ", lossy: "; doc.lossy
print "last character: U+"; hex( asc( doc.text, 4 ), 4 )
print "== a file that is not there"
dim as ustring e = textfile.Load( "missing.tmp", doc )
print "refused: "; cbool( len( e ) > 0 ); ", doc kept: "; _
textfile.DisplayName( doc )
== a Windows file with a byte-order mark
load: []
crlf: true, bom: true, lossy: false
length: 11, title: win.tmp - tpad
== finding
'hi' from 0, any case: 0
next one: 3
next wraps to the top: 0
'hi' from 0, exact case: 3
'bye': -1
== line and column
offset 0 -> Ln 1, Col 1
offset 2 -> Ln 1, Col 3
offset 3 -> Ln 2, Col 1
offset 11 -> Ln 2, Col 9
== save keeps the BOM and CR LF
save: [] *copy.tmp - tpad
EF BB BF 48 69 0D 0A 68 69 20 74 68 65 72 65 0D 0A 74 68 69 72 64
== a Linux file that is not UTF-8
load: []
lf: true, bom: false, lossy: true
last character: U+FFFD
== a file that is not there
refused: true, doc kept: latin.tmp
The test writes its files byte by byte, so that what it reads does not depend on how this source
file's own line endings were checked out. The Windows file keeps its mark and its CR LF through a
load, an edit and a save. The Latin-1 file opens with LF, as lossy, with U+FFFD where the
e-acute was. A file that is not there is refused, and doc still holds the file before it.
The window
An application and a form
#module "pad"
#import "vendor/PsControls/PsApp"
#import "vendor/PsControls/PsMenuBar"
#import "vendor/PsControls/PsPopupMenu"
#import "vendor/PsControls/PsTextBox"
#import "vendor/PsControls/PsVScrollBar"
#import "vendor/PsControls/PsStatusBar"
#import "vendor/PsControls/PsMessageBox"
#import "vendor/PsControls/PsButton"
#import "vendor/PsControls/PsCheckBox"
#import "../textfile"
using PsApp
using PsTheme
using PsBufferPaint
using PsMenuBar
using PsPopupMenu
using PsTextBox
using PsVScrollBar
using PsStatusBar
using PsMessageBox
using PsButton
using PsCheckBox
using gui
Each control is a module of its own, and a program imports the ones it uses and links only those.
using makes their names bare, so a call is PsTextBox_SetText, as the package's documentation
writes it, rather than PsTextBox.PsTextBox_SetText. gui is sys/win/gui, which PsApp imports
for the program, and which holds the file dialogs.
PsApp supplies two types. A PsApplication is the program: its Init sets up COM, GDI+, the
DPI awareness and the theme, and its Run is the message loop, which runs until the main window
closes. A PsForm is a window. It reports what happens to it through procedure pointers set
by OnSize, OnClose, OnCommand and the rest, each of which receives the form first; a
message with no handler gets Windows' default behaviour.
'' Every command the menus and the keyboard can give.
public enum Cmd
CmdNew = 1000
CmdOpen
CmdSave
CmdSaveAs
CmdExit
CmdUndo
CmdCut
CmdCopy
CmdPaste
CmdSelectAll
CmdFind
CmdFindNext
end enum
'' The two things a person answers: a question in a message box, and
'' a file name in a file dialog. Each is reached through a pointer, so
'' a test can answer in the person's place.
public type AskProc as function( byval f as PsForm ptr, _
byref text as ustring, byval buttons as long ) as long
public type PickProc as function( byval f as PsForm ptr, _
byval saving as boolean, byref suggest as ustring ) as ustring
'' One open file and its window. The form's Tag points here.
public type Editor
as PsForm ptr form
as HWND text, scroll, status
as textfile.Document doc
as ustring findWhat
as boolean matchCase
as AskProc ask
as PickProc pick
end type
enum
IDC_BAR = 100
IDC_TEXT
IDC_SCROLL
IDC_STATUS
IDC_WHAT
IDC_CASE
end enum
'' Posted to the form after a key or a click in the text, so the
'' status bar is read once the control has moved the caret.
const WM_CARETMOVED = WM_APP + 1
function EditorOf( byval h as HWND ) as Editor ptr
return PsForm.FromHwnd( h )->Tag
end function
Editor is everything tpad keeps about one window, and the form holds a pointer to it in its
Tag. That is the answer to a question every callback raises. A control's callback receives the
control's window handle and nothing else; PsForm.FromHwnd finds the form that handle is on, and
the form's Tag finds the Editor. EditorOf does both. So no part of the program needs a
dim shared variable, and a second tpad window would be a second Editor with nothing shared.
Cmd names every command once. A menu item, a key and the self-test all end up passing one of
these numbers to the same procedure.
The last two fields are the subject of a later section. ask and pick are the two places a
person answers tpad — a question in a message box, and a file name in a file dialog. Both are
procedure pointers, and Create points them at the real thing:
'' The real answers come from a person.
function AskPerson( byval f as PsForm ptr, byref text as ustring, _
byval buttons as long ) as long
return PsMessageBox_Show( f->hWindow, text, "tpad", _
iif( buttons = MBX_BTN_OK, MBX_ICON_INFO, MBX_ICON_QUESTION ), _
buttons )
end function
function PickFile( byval f as PsForm ptr, byval saving as boolean, _
byref suggest as ustring ) as ustring
if saving then
dim as CISaveFileDialog d
d.AddFileType( "Text files", "*.txt" )
d.AddFileType( "All files", "*.*" )
d.SetFileTypes( )
d.SetDefaultExtension( "txt" )
d.SetFileName( suggest )
if d.ShowSave( f->hWindow ) <> S_OK then return ""
return d.GetResultString( SIGDN_FILESYSPATH )
end if
dim as CIOpenFileDialog d
d.AddFileType( "Text files", "*.txt" )
d.AddFileType( "All files", "*.*" )
d.SetFileTypes( )
if d.ShowOpen( f->hWindow ) <> S_OK then return ""
return d.GetResultString( SIGDN_FILESYSPATH )
end function
PsMessageBox_Show puts up a themed message box and waits for an answer. Its button sets are
named presets; MBX_BTN_SAVE_DONTSAVE_CANCEL returns IDYES, IDNO or IDCANCEL, because Windows
has no "Save" result and a save question is a yes-no-cancel question with better captions.
CISaveFileDialog and CIOpenFileDialog are the Windows file dialogs, from sys/win/gui, and
SIGDN_FILESYSPATH asks for the result as an ordinary path.
The text and its scroll bar
sub BuildText( byval f as PsForm ptr, byval ed as Editor ptr )
ed->text = PsTextBox_Create( f->hWindow, IDC_TEXT, true )
PsTextBox_SetFont( ed->text, f->Font( PSFONT_MONO ) )
PsTextBox_SetLimitText( ed->text, &h7FFFFFFE )
PsTextBox_SetMargins( ed->text, f->ScaleX( 6 ), f->ScaleX( 6 ) )
'' The person typed, cut, pasted or undid something.
PsTextBox_SetChangeCallback( ed->text, _
sub( byval h as HWND )
ShowTitle( EditorOf( h ) )
ShowStatus( EditorOf( h ) )
end sub )
'' The caret may have moved: look once the control has acted.
PsTextBox_SetMessageCallback( ed->text, _
function( byval m as PSTEXTBOX_MESSAGEINFO ptr ) as boolean
select case m->uMsg
case WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP
PostMessageW( GetParent( m->hTextBox ), _
WM_CARETMOVED, 0, 0 )
end select
return false
end function )
f->OnMessage( WM_CARETMOVED, _
function( byval f as PsForm ptr, byval uMsg as UINT, _
byval wParam as WPARAM, byval lParam as LPARAM, _
byref result as LRESULT ) as boolean
ShowStatus( f->Tag )
return true
end function )
'' The control draws no scroll bar of its own; it tells one.
ed->scroll = PsVScrollBar_Create( f->hWindow, IDC_SCROLL )
PsTextBox_SetScrollChangedCallback( ed->text, _
sub( byval h as HWND )
dim as integer total, page, top
PsTextBox_GetVScrollInfo( h, total, page, top )
PsVScrollBar_SetRange( EditorOf( h )->scroll, total, _
page, top )
end sub )
PsVScrollBar_SetScrollCallback( ed->scroll, _
sub( byval h as HWND, byval newPos as integer )
PsTextBox_ScrollToLine( EditorOf( h )->text, newPos )
end sub )
end sub
The third argument to PsTextBox_Create makes a multiline box: Enter starts a new line, Tab
inserts a tab, and long lines wrap. f->Font( PSFONT_MONO ) is the form's monospaced font,
Cascadia Mono where it is installed and Consolas where it is not. The text limit is raised from
the control's default to nearly 2^31 characters.
Four callbacks, each a lambda, connect the box to the rest of the window:
- The change callback fires when the person changes the text: typing, cutting, pasting,
undoing. It never fires for
PsTextBox_SetText. Every PsControls setter is silent in the same way, so a program can set a control's value inside its own handler without being called back in the middle of it.tpadrelies on that when it loads a file. - The message callback sees the box's keyboard and mouse messages before the box acts on
them. A key that moves the caret has not moved it yet, so reading the caret position here gives
the old one. Instead the callback posts
WM_CARETMOVEDto the form: a posted message waits in the queue until the current one is finished, by which time the caret has moved, and the form's handler for it updates the status bar.falselets the box handle the message as usual. - The scroll callbacks connect the box to a
PsVScrollBar. The box draws no scroll bar of its own. It reports its line count, page size and top line whenever they may have changed, and the scroll bar reports a new position when it is dragged.
The status bar
sub BuildStatus( byval f as PsForm ptr, byval ed as Editor ptr )
ed->status = PsStatusBar_Create( f->hWindow, IDC_STATUS )
PsStatusBar_AddPanel( ed->status, "" ) '' Ln, Col: springs
PsStatusBar_AddPanel( ed->status, "" ) '' line ending
PsStatusBar_AddPanel( ed->status, "" ) '' encoding
PsStatusBar_SetExpandPanel( ed->status, 0 )
'' The bar lays the panels out; what is inside one, it leaves to
'' the program. Without this, the panels are blank.
PsStatusBar_SetPaintCallback( ed->status, _
sub( byval p as PSSTATUSBAR_PAINTINFO ptr )
dim as PSTHEME_COLORS t = PsApp_Instance( )->Theme( )
p->b->SetColors( t.TextDim, t.Window )
p->b->PaintRect( @p->rc )
dim as RECT r = p->rc
r.left += PsStatusBar_GetPadding( p->hStatusBar )
p->b->SetFont( PsStatusBar_GetFont( p->hStatusBar ) )
p->b->PaintText( p->wszCaption, @r, DT_LEFT )
end sub )
end sub
PsStatusBar lays its panels out: each is as wide as its text, and SetExpandPanel makes the
first one take the space left over. It does not draw what is in a panel. That is the paint
callback's job, and without one the bar is a blank strip. This one paints in the theme's colours,
which it asks the application for each time, so a switch between light and dark needs nothing
more.
'' The editor's text, with one LF per line break, as textfile wants.
function TextOf( byval ed as Editor ptr ) as ustring
return textfile.ToLf( PsTextBox_GetText( ed->text ) )
end function
sub ShowTitle( byval ed as Editor ptr )
SetWindowTextW( ed->form->hWindow, textfile.Caption( ed->doc, _
PsTextBox_GetModify( ed->text ) ) )
end sub
sub ShowStatus( byval ed as Editor ptr )
dim as integer first, last
PsTextBox_GetSel( ed->text, first, last )
dim as int32 ln, col
textfile.LineColumn( TextOf( ed ), last, ln, col )
PsStatusBar_SetText( ed->status, 0, _
"Ln " & ln & ", Col " & col )
PsStatusBar_SetText( ed->status, 1, _
iif( ed->doc.ending = textfile.LineEnding.CrLf, _
"Windows (CRLF)", "Unix (LF)" ) )
PsStatusBar_SetText( ed->status, 2, _
iif( ed->doc.hasBom, "UTF-8 with BOM", "UTF-8" ) )
end sub
TextOf is the one place pad reads the control's text, and it converts to the one-LF form at
once. ShowTitle reads the box's modified flag, which the control sets on every edit the
person makes and pad clears after a load or a save. ShowStatus takes the end of the selection
as the caret, and lets textfile count lines.
Layout
sub Main_OnSize( byval f as PsForm ptr, byval cx as long, _
byval cy as long )
dim as long barH = f->ScaleY( 30 ), statusH = f->ScaleY( 26 )
dim as long sbW = f->ScaleX( 14 )
dim as long textH = cy - barH - statusH
SetWindowPos( f->Item( IDC_BAR ), null, 0, 0, cx, barH, _
SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDC_TEXT ), null, 0, barH, cx - sbW, _
textH, SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDC_SCROLL ), null, cx - sbW, barH, sbW, _
textH, SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDC_STATUS ), null, 0, cy - statusH, cx, _
statusH, SWP_NOZORDER or SWP_SHOWWINDOW )
end sub
PsControls are created hidden and zero-sized. Nothing is on screen until the form's size
handler places each one and shows it; SWP_SHOWWINDOW in SetWindowPos does both at once. The
handler runs whenever the window's size changes, including the first time it is shown. Every
literal is passed through ScaleX or ScaleY, which turn device-independent units into pixels
at the form's DPI. Thirty units is thirty pixels on an ordinary monitor and sixty on one at 200%.
Menus and keys
function AddMenu( byval f as PsForm ptr, byval bar as HWND, _
byref title as ustring ) as HWND
dim as long i = PsMenuBar_AddItem( bar, title, 0 )
dim as HWND m = PsPopupMenu_Create( f->hWindow )
PsMenuBar_SetDropDown( bar, i, m )
return m
end function
sub BuildMenus( byval f as PsForm ptr )
dim as HWND bar = PsMenuBar_Create( f->hWindow, IDC_BAR )
PsMenuBar_SetCommandCallback( bar, _
sub( byval h as HWND, byval id as long )
Execute( EditorOf( h ), id )
end sub )
dim as HWND m = AddMenu( f, bar, "File" )
PsPopupMenu_AddItem( m, CmdNew, "New", "Ctrl+N" )
PsPopupMenu_AddItem( m, CmdOpen, "Open...", "Ctrl+O" )
PsPopupMenu_AddItem( m, CmdSave, "Save", "Ctrl+S" )
PsPopupMenu_AddItem( m, CmdSaveAs, "Save As...", "Ctrl+Shift+S" )
PsPopupMenu_AddSeparator( m )
PsPopupMenu_AddItem( m, CmdExit, "Exit" )
m = AddMenu( f, bar, "Edit" )
PsPopupMenu_AddItem( m, CmdUndo, "Undo", "Ctrl+Z" )
PsPopupMenu_AddSeparator( m )
PsPopupMenu_AddItem( m, CmdCut, "Cut", "Ctrl+X" )
PsPopupMenu_AddItem( m, CmdCopy, "Copy", "Ctrl+C" )
PsPopupMenu_AddItem( m, CmdPaste, "Paste", "Ctrl+V" )
PsPopupMenu_AddItem( m, CmdSelectAll, "Select All", "Ctrl+A" )
PsPopupMenu_AddSeparator( m )
PsPopupMenu_AddItem( m, CmdFind, "Find...", "Ctrl+F" )
PsPopupMenu_AddItem( m, CmdFindNext, "Find Next", "F3" )
'' The text control handles Ctrl+Z, X, C, V and A itself. These are
'' the keys only tpad knows.
f->AddAccelerator( FVIRTKEY or FCONTROL, asc( "N" ), CmdNew )
f->AddAccelerator( FVIRTKEY or FCONTROL, asc( "O" ), CmdOpen )
f->AddAccelerator( FVIRTKEY or FCONTROL, asc( "S" ), CmdSave )
f->AddAccelerator( FVIRTKEY or FCONTROL or FSHIFT, asc( "S" ), _
CmdSaveAs )
f->AddAccelerator( FVIRTKEY or FCONTROL, asc( "F" ), CmdFind )
f->AddAccelerator( FVIRTKEY, VK_F3, CmdFindNext )
f->CreateAcceleratorTable( )
f->OnCommand( _
function( byval f as PsForm ptr, byval id as long, _
byval code as long, byval hCtl as HWND ) as boolean
if id < CmdNew orelse id > CmdFindNext then return false
Execute( f->Tag, id )
return true
end function )
end sub
A PsMenuBar holds the titles; each title drops down a PsPopupMenu. The text after an item —
"Ctrl+N" — is only shown. What makes the key work is the accelerator table: AddAccelerator
maps a key and its modifiers to a command number, and the message loop turns the key into a
WM_COMMAND for the form before the text box sees it. The form's command callback hands any Cmd
to Execute, and returns false for anything else so that it gets the default handling.
The edit keys are left out of the table on purpose. The text control already handles Ctrl+Z, X, C, V and A, wherever the keyboard focus is in it; an accelerator would take the key away from it.
Commands
'' The one place a command is carried out, wherever it came from.
public sub Execute( byval ed as Editor ptr, byval id as long )
select case id
case CmdNew
if MayDiscard( ed ) then
dim as textfile.Document blank
ShowDocument( ed, blank )
end if
case CmdOpen
if MayDiscard( ed ) then
dim as ustring p = ed->pick( ed->form, false, "" )
if len( p ) then OpenPath( ed, p )
end if
case CmdSave
SaveDocument( ed, false )
case CmdSaveAs
SaveDocument( ed, true )
case CmdExit
ed->form->Close( )
case CmdUndo
PsTextBox_Undo( ed->text )
case CmdCut
PsTextBox_Cut( ed->text )
case CmdCopy
PsTextBox_Copy( ed->text )
case CmdPaste
PsTextBox_Paste( ed->text )
case CmdSelectAll
PsTextBox_SelectAll( ed->text )
case CmdFind
AskWhatToFind( ed )
case CmdFindNext
if len( ed->findWhat ) then
FindAgain( ed )
else
AskWhatToFind( ed )
end if
end select
end sub
One procedure carries out every command, whether it came from a menu, a key or the self-test.
The menus and the keys say which command; Execute says what it does. Adding a command means
adding a Cmd, an item, perhaps a key, and a case.
Files, and the question before discarding
'' Puts doc in the window, replacing what was there.
sub ShowDocument( byval ed as Editor ptr, _
byref doc as textfile.Document )
ed->doc = doc
PsTextBox_SetText( ed->text, doc.text )
PsTextBox_SetModify( ed->text, false )
PsTextBox_SetSel( ed->text, 0, 0 )
ShowTitle( ed )
ShowStatus( ed )
end sub
'' Opens path; says why not if it cannot.
public function OpenPath( byval ed as Editor ptr, _
byref path as ustring ) as boolean
dim as textfile.Document doc
dim as ustring e = textfile.Load( path, doc )
if len( e ) then
ed->ask( ed->form, e, MBX_BTN_OK )
return false
end if
ShowDocument( ed, doc )
if doc.lossy then
ed->ask( ed->form, textfile.DisplayName( doc ) & _
" is not UTF-8. Characters that could not be read show" & _
" as U+FFFD, and saving writes them that way.", MBX_BTN_OK )
end if
return true
end function
'' Saves, asking for a name when there is none (or when asked to).
function SaveDocument( byval ed as Editor ptr, _
byval newName as boolean ) as boolean
if newName orelse len( ed->doc.path ) = 0 then
dim as ustring p = ed->pick( ed->form, true, _
textfile.DisplayName( ed->doc ) )
if len( p ) = 0 then return false
ed->doc.path = p
end if
ed->doc.text = TextOf( ed )
dim as ustring e = textfile.Save( ed->doc )
if len( e ) then
ed->ask( ed->form, e, MBX_BTN_OK )
return false
end if
PsTextBox_SetModify( ed->text, false )
ShowTitle( ed )
return true
end function
'' True when it is all right to throw the text away: it is saved,
'' the person saved it now, or said not to.
function MayDiscard( byval ed as Editor ptr ) as boolean
if PsTextBox_GetModify( ed->text ) = false then return true
select case ed->ask( ed->form, "Do you want to save changes to " & _
textfile.DisplayName( ed->doc ) & "?", _
MBX_BTN_SAVE_DONTSAVE_CANCEL )
case IDYES
return SaveDocument( ed, false )
case IDNO
return true
end select
return false
end function
MayDiscard is the specification's question, in one place. New and Open call it before they
replace the text, and the form's close handler, below, calls it before the window goes. Save
in the answer saves, and continues only if the save worked: a save that is cancelled at the file
dialog, or fails, keeps the window and its text.
SaveDocument reads the text out of the control into the document, saves, and clears the modified
flag only after textfile.Save reports success. An error from either module comes back as text,
and goes to the person as a message box with an OK button.
Find
sub FindAgain( byval ed as Editor ptr )
if len( ed->findWhat ) = 0 then exit sub
dim as integer first, last
PsTextBox_GetSel( ed->text, first, last )
dim as int32 at = textfile.FindNext( TextOf( ed ), ed->findWhat, _
last, ed->matchCase )
if at < 0 then
ed->ask( ed->form, "Cannot find """ & ed->findWhat & """", _
MBX_BTN_OK )
exit sub
end if
PsTextBox_SetSel( ed->text, at, at + len( ed->findWhat ) )
SendMessageW( ed->text, EM_SCROLLCARET, 0, 0 )
ShowStatus( ed )
end sub
'' The Find box: a modal form that only collects the search.
sub FindBox_OnSize( byval f as PsForm ptr, byval cx as long, _
byval cy as long )
'' Two rows, each as tall as a button: the search and its button,
'' then the option and Cancel.
dim as long pad = f->ScaleX( 12 ), bw, bh
PsButton_GetIdealSize( f->Item( IDOK ), bw, bh )
dim as long w = cx - 3 * pad - bw
SetWindowPos( f->Item( IDC_WHAT ), null, pad, pad, w, bh, _
SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDC_CASE ), null, pad, 2 * pad + bh, _
w, bh, SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDOK ), null, cx - pad - bw, pad, bw, bh, _
SWP_NOZORDER or SWP_SHOWWINDOW )
SetWindowPos( f->Item( IDCANCEL ), null, cx - pad - bw, _
2 * pad + bh, bw, bh, SWP_NOZORDER or SWP_SHOWWINDOW )
end sub
sub FindBox_Ok( byval h as HWND, byval id as long )
dim as PsForm ptr f = PsForm.FromHwnd( h )
dim as Editor ptr ed = f->Tag
ed->findWhat = PsTextBox_GetText( f->Item( IDC_WHAT ) )
ed->matchCase = PsCheckBox_GetChecked( f->Item( IDC_CASE ) )
f->EndModal( IDOK )
end sub
sub AskWhatToFind( byval ed as Editor ptr )
dim as PsForm ptr f = PsApp_Instance( )->NewForm( "Find", 420, _
150, WS_POPUP or WS_CAPTION or WS_SYSMENU or WS_CLIPCHILDREN, _
WS_EX_CONTROLPARENT or WS_EX_DLGMODALFRAME, ed->form->hWindow )
f->Tag = ed
dim as HWND hWhat = PsTextBox_Create( f->hWindow, IDC_WHAT )
PsTextBox_SetText( hWhat, ed->findWhat )
PsTextBox_SetSelectOnFocus( hWhat, true )
dim as HWND hCase = PsCheckBox_Create( f->hWindow, IDC_CASE )
PsCheckBox_SetText( hCase, "Match case" )
PsCheckBox_SetChecked( hCase, ed->matchCase )
dim as HWND hOk = PsButton_Create( f->hWindow, IDOK )
PsButton_SetText( hOk, "Find Next" )
PsButton_SetClickCallback( hOk, @FindBox_Ok )
dim as HWND hCancel = PsButton_Create( f->hWindow, IDCANCEL )
PsButton_SetText( hCancel, "Cancel" )
PsButton_SetClickCallback( hCancel, _
sub( byval h as HWND, byval id as long )
PsForm.FromHwnd( h )->EndModal( IDCANCEL )
end sub )
f->OnSize( @FindBox_OnSize )
f->SetClientSize( 420, 112 )
if f->ShowModal( ed->form->hWindow ) = IDOK then FindAgain( ed )
end sub
The Find box is a modal form. ShowModal disables the main window, runs the message loop
until the box ends it, enables the main window again, destroys the box and returns the code passed
to EndModal. Esc and the close box end it with IDCANCEL. The box only collects what to look
for, into the Editor its Tag points at; FindAgain does the searching, so F3 can repeat a
search without the box. A match is selected and EM_SCROLLCARET scrolls it into view. That is a
classic edit-control message, and a PsTextBox passes such messages to its edit control unchanged.
Creating it all
'' Builds the main window, with an empty, untitled document.
public function Create( byref app as PsApplication ) as Editor ptr
dim as Editor ptr ed = new Editor
ed->ask = @AskPerson
ed->pick = @PickFile
dim as PsForm ptr f = app.NewForm( "tpad", 720, 480 )
ed->form = f
f->Tag = ed
BuildMenus( f )
BuildText( f, ed )
BuildStatus( f, ed )
f->OnSize( @Main_OnSize )
'' Asked before the window closes, however it is closed.
f->OnClose( _
function( byval f as PsForm ptr ) as boolean
return MayDiscard( f->Tag )
end function )
'' A new DPI gives every control the form's UI font: put the
'' monospaced one back.
f->OnDpiChanged( _
sub( byval f as PsForm ptr, byval oldDpi as long, _
byval newDpi as long )
dim as Editor ptr ed = f->Tag
PsTextBox_SetFont( ed->text, f->Font( PSFONT_MONO ) )
end sub )
f->OnDestroy( _
sub( byval f as PsForm ptr )
delete cast( Editor ptr, f->Tag )
f->Tag = 0
end sub )
ShowTitle( ed )
ShowStatus( ed )
return ed
end function
Create allocates the Editor with new, because it must outlive the call: it lives exactly as
long as the window, and the form's destroy handler deletes it. The close handler is the
specification's question again; returning false from it cancels the close, so Cancel keeps the
window open. The DPI handler is the subject of this chapter's last pitfall:
When a form moves to a monitor with a different DPI, PsApp re-creates its fonts at the new size
and gives every control the form's UI font — including a control that was given another font.
Without the DPI handler, tpad's text turns from Cascadia Mono into Segoe UI the first time the
window is dragged to a monitor at another scale, and nothing else changes. The self-test below
checks it, by sending the form the message Windows sends:
== moved to a monitor at twice the DPI
the text is still monospaced: true
With the handler's one line commented out, that line reads false.
The program
#cmdline "-s gui"
#import "./pad"
#import "std/fs"
#import "std/strings"
#import "vendor/PsControls/PsApp"
#import "vendor/PsControls/PsTextBox"
#import "vendor/PsControls/PsStatusBar"
#import "vendor/PsControls/PsMessageBox"
using PsApp
using PsTextBox
using PsStatusBar
using PsMessageBox
dim as PsApplication app
app.Init( )
dim as pad.Editor ptr ed = pad.Create( app )
if command( 1 ) = "--selftest" then end SelfTest( ed )
if len( command( 1 ) ) then pad.OpenPath( ed, command( 1 ) )
end app.Run( ed->form )
#cmdline "-s gui" builds for the GUI subsystem (Chapter 31), so starting tpad from Explorer
opens no console window. app.Run shows the main window and runs the message loop until the window
is destroyed, and its result is the program's exit code. A file named on the command line is
opened before the loop starts; if it cannot be, the person is told and gets an empty window.
The package's tiko.toml names the program and its resources:
# tpad: `tiko build tpad` writes out/debug/tpad.exe
[package]
name = "tpad"
[target.tpad]
main = "tpad.tiko"
resources = ["tpad.rc"]
[target.tpad.defines]
# Every Win32 name means its W (UTF-16) form.
UNICODE = "1"
UNICODE is defined for the whole build, as the PsControls package requires: it makes every bare
Win32 name, such as SetWindowText or MessageBox, mean the function that takes UTF-16 text.
The manifest that tpad.rc compiles in declares per-monitor DPI awareness and asks for version 6
of the Windows common controls.
Testing a window with nobody at it
The harness that checks this book's listings runs every program and compares its output with what
the book prints. A window waits for a person. So tpad takes --selftest, which drives the real
window — the real controls, the real callbacks, the real Execute — and prints what it saw.
The only things it cannot drive are the two that wait for a person: the message box and the file
dialog. That is why they are behind ask and pick. The test points them at a script:
'' The person, scripted: what the test answers to each question.
function ScriptedAsk( byval f as PsForm ptr, byref text as ustring, _
byval buttons as long ) as long
static as long replies( ... ) = { IDCANCEL, IDNO, IDNO }
static as int32 n
print " asked: "; text
if buttons = MBX_BTN_OK then return IDOK
n += 1
print " answered: "; iif( replies( n - 1 ) = IDNO, _
"Don't Save", "Cancel" )
return replies( n - 1 )
end function
function ScriptedPick( byval f as PsForm ptr, byval saving as boolean, _
byref suggest as ustring ) as ustring
print " picked: lf.tmp"
return "lf.tmp"
end function
'' Lets posted messages arrive, as the message loop would.
sub Pump( )
dim as MSG m
do until PeekMessageW( @m, null, 0, 0, PM_REMOVE ) = 0
TranslateMessage( @m )
DispatchMessageW( @m )
loop
end sub
'' A key, delivered where the keyboard would deliver it.
sub Press( byval ed as pad.Editor ptr, byval vk as long )
SendMessageW( PsTextBox_GetRichEditHandle( ed->text ), _
WM_KEYDOWN, vk, 0 )
Pump( )
end sub
sub Type_( byval ed as pad.Editor ptr, byref s as ustring )
for i as int32 = 1 to len( s )
SendMessageW( PsTextBox_GetRichEditHandle( ed->text ), _
WM_CHAR, asc( s, i ), 0 )
next
Pump( )
end sub
sub Report( byval ed as pad.Editor ptr )
'' Win32 fills a wstring buffer. Printed to a pipe or a file, a
'' wstring is written as raw UTF-16, so it is copied to a ustring,
'' which is written as UTF-8.
dim as wstring * 260 buffer
GetWindowTextW( ed->form->hWindow, buffer, 260 )
dim as ustring title = buffer
print " title: "; title
print " status: "; PsStatusBar_GetText( ed->status, 0 ); " | "; _
PsStatusBar_GetText( ed->status, 1 ); " | "; _
PsStatusBar_GetText( ed->status, 2 )
end sub
Press and Type_ send the text control's own edit window the messages the keyboard would:
WM_KEYDOWN for a key that moves, WM_CHAR for a character. They take the same path a key takes,
through the message callback and the change callback. Pump then delivers whatever was posted,
which is how WM_CARETMOVED reaches the form; the self-test runs before app.Run, so there is no
message loop doing it.
function SelfTest( byval ed as pad.Editor ptr ) as long
ed->ask = @ScriptedAsk
ed->pick = @ScriptedPick
fs.WriteAllText( "lf.tmp", !"one\ntwo\nthree\n" )
print "== a new window"
Report( ed )
print "== the layout, at 600 x 400"
ed->form->SetClientSize( 600, 400 )
dim as RECT client, text, bar, status
GetClientRect( ed->form->hWindow, @client )
GetWindowRect( ed->text, @text )
GetWindowRect( ed->scroll, @bar )
GetWindowRect( ed->status, @status )
print " text meets the scroll bar: "; _
cbool( text.right = bar.left )
print " text meets the status bar: "; _
cbool( text.bottom = status.top )
print " status bar is as wide as the window: "; _
cbool( status.right - status.left = client.right )
print "== File > Open"
pad.Execute( ed, pad.CmdOpen )
Report( ed )
print "== Down, Down, End, and type ""!"""
Press( ed, VK_DOWN )
Press( ed, VK_DOWN )
Press( ed, VK_END )
Type_( ed, "!" )
Report( ed )
print "== Find Next, for ""T"", twice"
ed->findWhat = "T"
pad.Execute( ed, pad.CmdFindNext )
Report( ed )
pad.Execute( ed, pad.CmdFindNext )
Report( ed )
print "== Find Next, for ""four"""
ed->findWhat = "four"
pad.Execute( ed, pad.CmdFindNext )
print "== File > Save"
pad.Execute( ed, pad.CmdSave )
Report( ed )
dim as ustring saved = fs.ReadAllText( "lf.tmp" ).Value( )
print " on disk: "; strings.Escape( saved )
print "== type, then File > New, twice"
Type_( ed, "?" )
pad.Execute( ed, pad.CmdNew )
Report( ed )
pad.Execute( ed, pad.CmdNew )
Report( ed )
print "== moved to a monitor at twice the DPI"
dim as HWND w = ed->form->hWindow
dim as long dpi = GetDpiForWindow( w )
dim as RECT r
GetWindowRect( w, @r )
SendMessageW( w, WM_DPICHANGED, MAKEWPARAM( 2 * dpi, 2 * dpi ), _
cast( LPARAM, @r ) )
print " the text is still monospaced: "; _
cbool( PsTextBox_GetFont( ed->text ) = _
ed->form->Font( PSFONT_MONO ) )
print "== type, then close the window"
Type_( ed, "x" )
ed->form->Close( )
print " window closed: "; cbool( IsWindow( w ) = 0 )
return 0
end function
== a new window
title: Untitled - tpad
status: Ln 1, Col 1 | Windows (CRLF) | UTF-8
== the layout, at 600 x 400
text meets the scroll bar: true
text meets the status bar: true
status bar is as wide as the window: true
== File > Open
picked: lf.tmp
title: lf.tmp - tpad
status: Ln 1, Col 1 | Unix (LF) | UTF-8
== Down, Down, End, and type "!"
title: *lf.tmp - tpad
status: Ln 3, Col 7 | Unix (LF) | UTF-8
== Find Next, for "T", twice
title: *lf.tmp - tpad
status: Ln 2, Col 2 | Unix (LF) | UTF-8
title: *lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
== Find Next, for "four"
asked: Cannot find "four"
== File > Save
title: lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
on disk: one\ntwo\nthree!\n
== type, then File > New, twice
asked: Do you want to save changes to lf.tmp?
answered: Cancel
title: *lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
asked: Do you want to save changes to lf.tmp?
answered: Don't Save
title: Untitled - tpad
status: Ln 1, Col 1 | Windows (CRLF) | UTF-8
== moved to a monitor at twice the DPI
the text is still monospaced: true
== type, then close the window
asked: Do you want to save changes to Untitled?
answered: Don't Save
window closed: true
The window is never shown. The test sizes it first, and that matters twice over. A key sent to a zero-sized text box is accepted and does nothing: in the first draft, which sent its keys before sizing the window, Down and Down left the caret on line 1. And the layout itself is checked by comparing edges, which is the same at any DPI, rather than pixel counts, which are not.
Report copies the title from a wstring buffer into a ustring before printing it. That is
not tidiness. Printed to a pipe or a file — and the harness reads a pipe — a wstring is written
as its raw UTF-16 code units, a zero byte after every ASCII letter, while a ustring is written as
UTF-8. On a console the two print the same, which is why the difference shows up only when the
output is redirected.
What the self-test cannot see
The first version of tpad had no status-bar paint callback. Its self-test passed: Report
reads each panel's text back with PsStatusBar_GetText, and the text was right. On screen the
panels were empty, because nothing painted them. A test that reads the model does not see
pixels. The status bar was caught by looking at the window, and the screenshot at the top of
this chapter is the check that it now paints. What the self-test proves is that the commands, the
callbacks and the files behave; that the window looks right is still a matter of looking at it.
Running it
From the chapter's directory:
tiko build tpad
out/debug/tpad.exe notes.txt
tiko build tpad builds the target tpad with the debug profile into out/debug;
tiko run -- notes.txt builds and starts it in one step. The first build compiles every PsControls module
the program imports, and takes a few minutes. After that, a change to tpad's own files is
rebuilt in under half a minute.
Shipping
tiko build tpad --profile release builds an optimised out/release/tpad.exe of 316,416 bytes.
It imports only libraries that come with Windows: KERNEL32, USER32, GDI32, gdiplus,
COMCTL32, ole32, ADVAPI32, VERSION, dwmapi and the C runtime ucrtbase. There is no
installer and no runtime to install. Copy the file, and it runs.
The debug build is ten times the size, 3,101,696 bytes, because it carries -exx's checks and the
debugger's information (Chapter 31). Ship the release build.
What it costs
- Every caret move reads the whole text.
ShowStatuscopies the text out of the control, converts its line breaks and counts lines up to the caret. Measured on a 1 MB file, in a release build: about 16 ms per update, of which 7 ms isPsTextBox_GetTextalone. A notes file costs nothing; a 10 MB log would make the caret lag. The cheaper way asks the control for the line withEM_EXLINEFROMCHARandEM_LINEINDEX, but those count lines as they are displayed, wrapped, and the status bar wants lines as they are in the file. - The text always wraps. A multiline
PsTextBoxwraps long lines and has no horizontal scroll bar, so Notepad's Word Wrap switch has nothing to switch. - The whole file is in memory, twice while it is saved: once in the control and once in the document being written.
- Text that is not UTF-8 is not preserved.
tpadwarns, and saving writes U+FFFD where the unreadable bytes were. Keeping them would mean reading the file in its own encoding, which needs the encoding to be known or guessed. - A self-test is code in the shipped program. It is a few dozen lines that do nothing unless asked, and the price buys a window that is checked on every build.
Summary
- Keep the part of a program that needs no window in a module that imports none.
textfileis tested on Linux and Windows by a program that prints; onlypadneeds Windows. - Give text one line-break convention inside the program — one LF, counted as one character — and
convert at the edges:
ToLfon the way in from the file and from the control, the file's own ending on the way out. fs.ReadAllTextremoves a byte-order mark and replaces bytes that are not UTF-8 with U+FFFD. An editor that must write a file back the same way reads the mark itself.fs.ReadAllBytes's third argument is a size limit, not a prefix length.- Save with
fs.WriteAtomic, so a failed save leaves the old file whole. PsApplicationis the program and its message loop; aPsFormis a window that reports through procedure pointers. Keep per-window state in anewed type that the form'sTagpoints at, and reach it from any control's callback withPsForm.FromHwnd.- PsControls setters are silent: only a person's actions call back. Read the caret after a key by
posting a message to yourself, and give a
PsStatusBara paint callback or its panels stay blank. - Controls are created hidden and zero-sized. Place them in
OnSize, scale every literal withScaleX/ScaleY, and set your own fonts again inOnDpiChanged. - Route menus, keys and tests to one
Execute. Put the accelerator keys the text control does not handle itself in the form's table. - Ask the person in one place, before anything is thrown away, and wire the close handler to it.
- Put the person behind procedure pointers, and a test can drive the real window: send the control the messages the keyboard would, pump the queue, and print what changed. Then look at the window as well.
Exercises
- Add Find Previous (Shift+F3) to
textfileand to the Edit menu: the last match that ends at or before the start of the selection, wrapping around to the bottom. {solution="ex36-01"} - Show a word count in a fourth status-bar panel. Write the counting function in
textfile, so that it can be tested on both targets. {solution="ex36-02"} - Add Replace (Ctrl+H): a modal box with Find what, Replace with, Replace and Replace All.
Do the replacing in
textfile, so that it can be tested on both targets. - Add Time/Date (F5), which inserts the local date and time at the caret, with
std/chrono. - Open a file dropped on the window from Explorer.
DragAcceptFilesturns dropping on, the form receivesWM_DROPFILES(OnMessage), andDragQueryFileWgives the path. AskMayDiscardfirst. - Extend the self-test to Save As: point
pickat a new name, and check the title and the file on disk afterwards.
Capstone: a Cross-Platform Notepad
The tpad of Chapter 36 runs on one operating system, because every control in it draws with GDI
and every message it handles is a Windows message. What would it take to run the same editor
on Linux and on a Mac, and is the answer a port or a rewrite?
This chapter finds out. It builds tpad again, on tkControls, the vendor package of
controls that draw into SDL3 windows, and one source builds and runs on Windows, Linux and
macOS. The menus, the Find box, the status bar and the question before unsaved work is thrown
away are the same. So is the document module, which does not change by a byte.
The window module is new, and most of what is interesting here is the short list of things that only show up once a program leaves Windows: a file dialog that does not stop your code, a text control that counts in bytes, a command key that is not Ctrl, and a Quit that arrives from the system and never touches your window. It joins:
- the module split of Chapter 36, tested by reusing its result;
- procedure pointers and lambdas (Chapters 7 and 21), which is still how every control reports back;
- a C library's callback, and the thread it may arrive on (Chapters 23 and 30);
- text as units and as bytes (Chapter 9);
- conditional compilation by platform (Chapter 16);
tiko build, and what a program built on shared libraries takes with it (Chapter 31).
Read Chapter 36 first. This chapter says what is different and why, and does not repeat what is the same.

The specification
tpad [file]
tpad --selftest
tpad --shot out.bmp [file]
The specification of Chapter 36 stands, with these changes:
- It runs on Windows, Linux and macOS, from one source with no per-platform files.
- The command key is Cmd on macOS and Ctrl elsewhere, for
tpad's own keys and the text box's, and the menus say which. - Text is any UTF-8: selection, Find and the line and column are right after accented letters and other characters that are not ASCII.
- The question before discarding also covers the system's Quit: Cmd+Q on a Mac, or a logout.
--shotrenders the window once and saves it as a picture, so that two systems can be compared.
The layout
ch37/
tiko.toml the package, and the target tpad
textfile/textfile.tiko the document: identical to Chapter 36's
offsets/offsets.tiko UTF-8 bytes and ustring units
offsettest.tiko offsets on its own, on every target
pad/pad.tiko the window: controls, menus, commands
tpad.tiko the program, and its self-test
# tpad on tkControls: `tiko build tpad` writes out/debug/tpad
[package]
name = "tpad"
[target.tpad]
main = "tpad.tiko"
That is the whole manifest. Chapter 36's needed a UNICODE define, a resource file and a
manifest for DPI awareness; SDL3's strings are UTF-8 on every system, and SDL3 makes a window
DPI-aware by itself.
The document
textfile/textfile.tiko is a copy of Chapter 36's, and cmp finds no difference between the two.
That was the point of keeping the part that needs no window in a module that imports none: the
module never knew which controls would show it, so it had nothing to change. Everything Chapter 36
said about it holds, including the rule that matters most here, that inside a Document a line
break is one character, LF.
What a window is made of
#module "pad"
#import "../textfile"
#import "../offsets"
#import "vendor/tkControls/tkApp"
#import "vendor/tkControls/tkMenuBar"
#import "vendor/tkControls/tkPopupMenu"
#import "vendor/tkControls/tkTextBox"
#import "vendor/tkControls/tkVScrollBar"
#import "vendor/tkControls/tkStatusBar"
#import "vendor/tkControls/tkMessageBox"
#import "vendor/tkControls/tkButton"
#import "vendor/tkControls/tkCheckBox"
using tkApp
using tkHost
using tkPaint
using tkTheme
using tkMenuBar
using tkPopupMenu
using tkTextBox
using tkVScrollBar
using tkStatusBar
using tkMessageBox
using tkButton
using tkCheckBox
One program-level idea replaces several. In Chapter 36 every control was a Windows window with a
handle, and a form found its controls by their ids. A tkControls window is one SDL window, and
everything in it is a TK_WIDGET: a record in a tree that the library draws and sends events to.
There is no handle to look up, so the program keeps a pointer to each control it talks to:
'' Every command the menus and the keyboard can give.
public enum Cmd
CmdNew = 1000
CmdOpen
CmdSave
CmdSaveAs
CmdExit
CmdUndo
CmdCut
CmdCopy
CmdPaste
CmdSelectAll
CmdFind
CmdFindNext
end enum
'' The two things a person answers: a question in a message box, and
'' a file name in a file dialog. Each is reached through a pointer, so
'' a test can answer in the person's place.
public type AskProc as function( byval f as TK_FORM ptr, _
byref text as ustring, byval buttons as long ) as long
public type PickProc as function( byval f as TK_FORM ptr, _
byval saving as boolean, byref suggest as ustring ) as ustring
'' One open file and its window. The form's Tag points here.
public type Editor
as TK_FORM ptr form
as TK_WIDGET ptr bar, text, scroll, status
as textfile.Document doc
as ustring findWhat
as boolean matchCase
as boolean picking '' a file dialog is waiting
as AskProc ask
as PickProc pick
end type
'' The Find box's controls. Its form's Tag points here.
type FindBox
as Editor ptr ed
as TK_WIDGET ptr what, matchCase, ok, cancel
end type
public declare sub Execute( byval ed as Editor ptr, byval id as long )
public declare sub ShowTitle( byval ed as Editor ptr )
public declare sub ShowStatus( byval ed as Editor ptr )
'' A control's callback receives only the control: its form's Tag is
'' the Editor.
function EditorOf( byval w as TK_WIDGET ptr ) as Editor ptr
return tkForm_FromWidget( w )->Tag
end function
The shape is Chapter 36's. An Editor holds everything about the window, the form's Tag points
at it, and a control's callback gets from the control to the Editor through EditorOf:
tkForm_FromWidget finds the form, and the form's Tag finds the Editor. Cmd is unchanged.
The new field is picking, which a later section needs.
The message box is the same call with a different prefix:
'' The real answers come from a person.
function AskPerson( byval f as TK_FORM ptr, byref text as ustring, _
byval buttons as long ) as long
return tkMessageBox_Show( tkForm_Widget( f ), text, "tpad", _
iif( buttons = MBX_BTN_OK, MBX_ICON_INFO, MBX_ICON_QUESTION ), _
buttons )
end function
A dialog that does not stop your code
The file dialog is where the two systems' habits part. On Windows, CIOpenFileDialog does not
return until the person has chosen. SDL3's dialogs return at once and answer later, through a
callback, because on some systems a dialog cannot be run any other way. Worse for a program that
wants to read an answer, the callback may run on another thread.
tpad's commands want a function that returns a path, so PickFile builds one:
'' What the dialog's callback leaves for the code waiting on it.
type PickWait
as SDL_AtomicInt answered
as zstring ptr path '' SDL's copy of the answer, or null
end type
'' May run on another thread, so it touches nothing but C memory.
sub PickAnswered( byval userdata as any ptr, _
byval filelist as const zstring const ptr ptr, _
byval filter as long )
dim as PickWait ptr w = userdata
if filelist <> 0 then
if filelist[0] <> 0 then w->path = SDL_strdup( filelist[0] )
end if
SDL_SetAtomicInt( @w->answered, 1 )
end sub
'' Shows the system's file dialog and returns the path, or "".
function PickFile( byval f as TK_FORM ptr, byval saving as boolean, _
byref suggest as ustring ) as ustring
dim as SDL_DialogFileFilter filters( 0 to 1 )
filters( 0 ).name = @"Text files" : filters( 0 ).pattern = @"txt"
filters( 1 ).name = @"All files" : filters( 1 ).pattern = @"*"
dim as string where = suggest
dim as PickWait w
dim as SDL_Window ptr win = tkForm_Host( f )->win
if saving then
SDL_ShowSaveFileDialog( @PickAnswered, @w, win, @filters( 0 ), _
2, iif( len( where ), strptr( where ), 0 ) )
else
SDL_ShowOpenFileDialog( @PickAnswered, @w, win, @filters( 0 ), _
2, 0, false )
end if
do until SDL_GetAtomicInt( @w.answered ) = 1
tkApp_Pump( )
SDL_Delay( 10 )
loop
dim as ustring picked
if w.path <> 0 then
picked = *w.path
SDL_free( w.path )
end if
return picked
end function
'' Every question for a path comes through here. The event loop runs
'' while the dialog is up, so nothing else may start until it answers.
function PickPath( byval ed as Editor ptr, byval saving as boolean, _
byref suggest as ustring ) as ustring
ed->picking = true
dim as ustring p = ed->pick( ed->form, saving, suggest )
ed->picking = false
return p
end function
It starts the dialog, then turns the event loop by hand until the answer arrives, so the window keeps painting and SDL keeps delivering events.
Everything the callback does is chosen for the thread it may be on. It is handed @w, so it
needs no global. It copies the path with SDL_strdup, which is C memory and owes nothing to tiko's
string runtime, and it signals with an atomic integer (Chapter 23). Only back on the waiting thread
does the copy become a ustring. The order matters: path is written first, and the atomic's write
is what tells the waiting thread that it is safe to read.
If the system has no dialog to show, SDL calls back with no list at all, and PickFile returns
"", as if the person had cancelled. On Linux the dialog comes from the desktop's portal or from
zenity, so a stripped-down machine can reach that path.
A dialog that returns at once leaves the event loop running, and a running event loop will deliver
anything. If the window is not blocked for you, the person can choose File > Exit while the dialog
is open. MayDiscard finds nothing unsaved, the form is destroyed, its OnDestroy deletes the
Editor, and when the dialog does answer, the code waiting for it carries on with a pointer to
memory that has been freed.
Whether a system blocks the parent window is the system's decision, so PickPath does not depend
on it. It sets picking; Execute starts nothing while it is set, and the form's close handler
refuses. The self-test tries it, with a scripted person who closes the window from inside the
dialog:
== File > Exit while the file dialog is open
window closed: false
With the close handler's one line removed, that line reads true.
The text, and the callback that runs afterwards
'' Queued from the message callback: by the time it runs, the text box
'' has acted on the key or the click and the caret has moved.
sub CaretMoved( byval payload as any ptr )
ShowStatus( payload )
end sub
sub BuildText( byval f as TK_FORM ptr, byval ed as Editor ptr )
ed->text = tkTextBox_Create( tkForm_Widget( f ), 101, true )
tkTextBox_SetFont( ed->text, tkForm_Font( f, TKFONT_MONO ) )
tkTextBox_SetLimitText( ed->text, &h7FFFFFFE )
tkTextBox_SetMargins( ed->text, tkForm_Scale( f, 6 ), _
tkForm_Scale( f, 6 ) )
'' The person typed, cut, pasted or undid something.
tkTextBox_SetChangeCallback( ed->text, _
sub( byval h as TK_WIDGET ptr )
ShowTitle( EditorOf( h ) )
ShowStatus( EditorOf( h ) )
end sub )
'' The caret may be about to move: look once the control has acted.
tkTextBox_SetMessageCallback( ed->text, _
function( byval m as TKTEXTBOX_MESSAGEINFO ptr ) as boolean
select case m->msg
case TKM_KEYDOWN, TKM_KEYUP, TKM_LBUTTONDOWN, TKM_LBUTTONUP
tkApp_RunOnUI( @CaretMoved, EditorOf( m->hTextBox ) )
end select
return false
end function )
'' The box and its scroll bar report to each other.
ed->scroll = tkVScrollBar_Create( tkForm_Widget( f ), 102 )
tkTextBox_SetScrollChangedCallback( ed->text, _
sub( byval h as TK_WIDGET ptr )
dim as integer total, page, top
tkTextBox_GetVScrollInfo( h, total, page, top )
tkVScrollBar_SetRange( EditorOf( h )->scroll, total, _
page, top )
end sub )
tkVScrollBar_SetScrollCallback( ed->scroll, _
sub( byval h as TK_WIDGET ptr, byval newPos as integer )
tkTextBox_ScrollToLine( EditorOf( h )->text, newPos )
end sub )
end sub
The third argument makes a multiline box, as it did in Chapter 36, and tkForm_Font( f, TKFONT_MONO )
is the form's monospaced font, which the package ships.
The four callbacks are the four from Chapter 36, and the change callback still fires only for a
person's edits. The one with a different mechanism is the message callback. It runs before the
box acts on a key, so the caret has not moved yet. Chapter 36 posted itself a Windows message;
tkApp_RunOnUI is the same idea without Windows. It queues CaretMoved to run when the
current event has been handled, by which time the caret is where the key put it.
The status bar
sub BuildStatus( byval f as TK_FORM ptr, byval ed as Editor ptr )
ed->status = tkStatusBar_Create( tkForm_Widget( f ), 103 )
tkStatusBar_AddPanel( ed->status, "" ) '' Ln, Col: springs
tkStatusBar_AddPanel( ed->status, "" ) '' line ending
tkStatusBar_AddPanel( ed->status, "" ) '' encoding
tkStatusBar_SetExpandPanel( ed->status, 0 )
end sub
There is less here than in Chapter 36, because a tkStatusBar draws its own panels. Setting a
panel's text is all there is to do, and its Pitfall about blank panels has no counterpart.
Two ways of counting
'' The editor's text, with one LF per line break, as textfile wants.
function TextOf( byval ed as Editor ptr ) as ustring
return textfile.ToLf( tkTextBox_GetText( ed->text ) )
end function
public sub ShowTitle( byval ed as Editor ptr )
dim as string title = textfile.Caption( ed->doc, _
tkTextBox_GetModify( ed->text ) )
SDL_SetWindowTitle( tkForm_Host( ed->form )->win, strptr( title ) )
end sub
public sub ShowStatus( byval ed as Editor ptr )
dim as integer first, last
tkTextBox_GetSel( ed->text, first, last )
dim as ustring body = TextOf( ed )
dim as int32 ln, col
textfile.LineColumn( body, offsets.UnitsIn( body, last ), ln, col )
tkStatusBar_SetText( ed->status, 0, "Ln " & ln & ", Col " & col )
tkStatusBar_SetText( ed->status, 1, _
iif( ed->doc.ending = textfile.LineEnding.CrLf, _
"Windows (CRLF)", "Unix (LF)" ) )
tkStatusBar_SetText( ed->status, 2, _
iif( ed->doc.hasBom, "UTF-8 with BOM", "UTF-8" ) )
end sub
ShowTitle hands SDL the caption as UTF-8: title is a string, and assigning a ustring to it
stores the text's UTF-8 bytes. ShowStatus is Chapter 36's with one call added, and that call is
the subject of this section.
A tkTextBox holds its text in UTF-8 and its offsets count bytes. A ustring counts UTF-16
units (Chapter 9), and so do len, instr and everything in textfile. While every character
before the offset is ASCII, the two numbers are equal. The first character that is not, and they
are not:
#module "offsets"
'' Two ways of counting the same position in a text. A ustring counts
'' UTF-16 units; a tkTextBox counts UTF-8 bytes. They agree only while
'' everything before the position is ASCII.
''
'' A string assigned from a ustring holds its UTF-8 bytes, and a ustring
'' assigned from a string decodes them, so each conversion is a prefix
'' turned into the other form and measured.
'' How many ustring units the first `count` UTF-8 bytes of text make.
public function UnitsIn( byref text as const ustring, _
byval count as int32 ) as int32
dim as string utf8 = text
dim as ustring head = left( utf8, count )
return len( head )
end function
'' How many UTF-8 bytes the first `count` ustring units of text make.
public function BytesIn( byref text as const ustring, _
byval count as int32 ) as int32
dim as string head = left( text, count )
return len( head )
end function
The two functions do the conversion by letting tiko do it: take the prefix in one form, turn it into the other, measure. They need no window, so they get a module and a test of their own:
'' The two counts of a position, with no window. U+00E9 is two bytes
'' and one unit; U+1F600 is four bytes and two units.
#import "./offsets"
dim as ustring ascii = "plain text"
dim as ustring accent = "caf" & wchr( &hE9 ) & " au lait"
dim as ustring emoji = wchr( &h1F600 ) & " smile"
print "== ASCII: the two counts agree"
print "byte 5 is unit"; offsets.UnitsIn( ascii, 5 )
print "unit 5 is byte"; offsets.BytesIn( ascii, 5 )
print "== 'au' in cafe-acute au lait"
dim as int32 at = instr( accent, "au" ) - 1
print "unit offset:"; at
print "byte offset:"; offsets.BytesIn( accent, at )
print "and back: "; offsets.UnitsIn( accent, _
offsets.BytesIn( accent, at ) )
print "== 'smile' after U+1F600"
at = instr( emoji, "smile" ) - 1
print "unit offset:"; at
print "byte offset:"; offsets.BytesIn( emoji, at )
print "and back: "; offsets.UnitsIn( emoji, _
offsets.BytesIn( emoji, at ) )
print "== the edges"
print "offset 0: "; offsets.BytesIn( accent, 0 ); _
offsets.UnitsIn( accent, 0 )
dim as int32 units = len( accent )
dim as int32 count = offsets.BytesIn( accent, units )
print "the end: "; count; offsets.UnitsIn( accent, count )
== ASCII: the two counts agree
byte 5 is unit 5
unit 5 is byte 5
== 'au' in cafe-acute au lait
unit offset: 5
byte offset: 6
and back: 5
== 'smile' after U+1F600
unit offset: 3
byte offset: 5
and back: 3
== the edges
offset 0: 0 0
the end: 13 12
An e-acute is one unit and two bytes. U+1F600 is two units and four bytes. Both directions land on a boundary between characters, and the box snaps an offset to one in any case.
Nothing about this mistake is visible with ASCII text, and every test written in English passes.
Take out the three conversions in pad, and open a file whose first line is café au lait and
whose second is naïve lait. Find Next for lait selects:
== text that is not ASCII, Find Next for "lait", twice
selected: lai
status: Ln 1, Col 13 | Unix (LF) | UTF-8
selected: e la
status: Ln 2, Col 11 | Unix (LF) | UTF-8
The search worked and the selection is wrong, by one byte for each character before it that took two. The status bar is right, for the wrong reason: the position it measured was the one the selection had been given. A file that is only slightly non-English looks fine until someone uses Find.
This is the same mistake as Chapter 36's Pitfall about line breaks, in another unit. Each control counts in its own way, so give the program one way, and convert where the two meet.
Layout, menus, keys
sub Main_OnSize( byval f as TK_FORM ptr, byval cx as long, _
byval cy as long )
dim as Editor ptr ed = f->Tag
dim as long barH = tkForm_Scale( f, 30 )
dim as long statusH = tkForm_Scale( f, 26 )
dim as long sbW = tkForm_Scale( f, 14 )
dim as long textH = cy - barH - statusH
tkWidget_SetPos( ed->bar, 0, 0, cx, barH )
tkWidget_SetPos( ed->text, 0, barH, cx - sbW, textH )
tkWidget_SetPos( ed->scroll, cx - sbW, barH, sbW, textH )
tkWidget_SetPos( ed->status, 0, cy - statusH, cx, statusH )
end sub
Controls start with no size and OnSize places them. tkForm_Scale takes the place of ScaleX and
ScaleY: an SDL window has one scale.
function AddMenu( byval f as TK_FORM ptr, byval bar as TK_WIDGET ptr, _
byref title as ustring ) as TK_MENU ptr
dim as long i = tkMenuBar_AddItem( bar, title, 0 )
dim as TK_MENU ptr m = tkPopupMenu_Create( tkForm_Host( f ) )
tkMenuBar_SetDropDown( bar, i, m )
return m
end function
'' The command key, as the menus spell it: Cmd on macOS, Ctrl
'' elsewhere. TK_KMOD_PRIMARY is the same key as a modifier mask.
#if defined( TIKO_OS_MACOS )
const PrimaryKey = "Cmd+"
#else
const PrimaryKey = "Ctrl+"
#endif
function FormKey( byval f as TK_FORM ptr, byval key as long, _
byval mods as long ) as boolean
dim as boolean primary = (mods and TK_KMOD_PRIMARY) <> 0
dim as boolean shift = (mods and SDL_KMOD_SHIFT) <> 0
dim as long id = 0
if primary then
select case key
case SDLK_N : id = CmdNew
case SDLK_O : id = CmdOpen
case SDLK_S : id = iif( shift, CmdSaveAs, CmdSave )
case SDLK_F : id = CmdFind
end select
elseif key = SDLK_F3 then
id = CmdFindNext
end if
if id = 0 then return false
Execute( f->Tag, id )
return true
end function
sub BuildMenus( byval f as TK_FORM ptr, byval ed as Editor ptr )
dim as TK_WIDGET ptr bar = _
tkMenuBar_Create( tkForm_Widget( f ), 100 )
ed->bar = bar
tkMenuBar_SetCommandCallback( bar, _
sub( byval h as TK_WIDGET ptr, byval id as long )
Execute( EditorOf( h ), id )
end sub )
dim as TK_MENU ptr m = AddMenu( f, bar, "File" )
dim as ustring k = PrimaryKey
tkPopupMenu_AddItem( m, CmdNew, "New", k & "N" )
tkPopupMenu_AddItem( m, CmdOpen, "Open...", k & "O" )
tkPopupMenu_AddItem( m, CmdSave, "Save", k & "S" )
tkPopupMenu_AddItem( m, CmdSaveAs, "Save As...", k & "Shift+S" )
tkPopupMenu_AddSeparator( m )
tkPopupMenu_AddItem( m, CmdExit, "Exit" )
m = AddMenu( f, bar, "Edit" )
tkPopupMenu_AddItem( m, CmdUndo, "Undo", k & "Z" )
tkPopupMenu_AddSeparator( m )
tkPopupMenu_AddItem( m, CmdCut, "Cut", k & "X" )
tkPopupMenu_AddItem( m, CmdCopy, "Copy", k & "C" )
tkPopupMenu_AddItem( m, CmdPaste, "Paste", k & "V" )
tkPopupMenu_AddItem( m, CmdSelectAll, "Select All", k & "A" )
tkPopupMenu_AddSeparator( m )
tkPopupMenu_AddItem( m, CmdFind, "Find...", k & "F" )
tkPopupMenu_AddItem( m, CmdFindNext, "Find Next", "F3" )
tkForm_OnKeyDown( f, @FormKey )
end sub
A tkMenuBar holds the titles and each drops down a tkPopupMenu, as in Chapter 36. There is no
accelerator table. The text box handles the primary key with Z, X, C, V and A itself and leaves
every other key unclaimed, and an unclaimed key reaches the form's OnKeyDown. Returning true
says that it was used.
The primary key is the first thing in this chapter that is written differently for one system.
TK_KMOD_PRIMARY, from tkHost, is the Cmd key on macOS and Ctrl elsewhere, and the text box tests
it too, so tpad writes that name once. Only the menu's label needs the platform spelled out,
so that is the only #if in the program (Chapter 16).
Commands
'' The one place a command is carried out, wherever it came from.
public sub Execute( byval ed as Editor ptr, byval id as long )
if ed->picking then exit sub
select case id
case CmdNew
if MayDiscard( ed ) then
dim as textfile.Document blank
ShowDocument( ed, blank )
end if
case CmdOpen
if MayDiscard( ed ) then
dim as ustring p = PickPath( ed, false, "" )
if len( p ) then OpenPath( ed, p )
end if
case CmdSave
SaveDocument( ed, false )
case CmdSaveAs
SaveDocument( ed, true )
case CmdExit
tkForm_Close( ed->form )
case CmdUndo
tkTextBox_Undo( ed->text )
case CmdCut
tkTextBox_Cut( ed->text )
case CmdCopy
tkTextBox_Copy( ed->text )
case CmdPaste
tkTextBox_Paste( ed->text )
case CmdSelectAll
tkTextBox_SelectAll( ed->text )
case CmdFind
AskWhatToFind( ed )
case CmdFindNext
if len( ed->findWhat ) then
FindAgain( ed )
else
AskWhatToFind( ed )
end if
end select
end sub
Execute is Chapter 36's, with its first line new: while a file dialog is waiting, no command
starts.
Files, and Find
'' Puts doc in the window, replacing what was there.
sub ShowDocument( byval ed as Editor ptr, _
byref doc as textfile.Document )
ed->doc = doc
tkTextBox_SetText( ed->text, doc.text )
tkTextBox_SetModify( ed->text, false )
tkTextBox_SetSel( ed->text, 0, 0 )
ShowTitle( ed )
ShowStatus( ed )
end sub
'' Opens path; says why not if it cannot.
public function OpenPath( byval ed as Editor ptr, _
byref path as ustring ) as boolean
dim as textfile.Document doc
dim as ustring e = textfile.Load( path, doc )
if len( e ) then
ed->ask( ed->form, e, MBX_BTN_OK )
return false
end if
ShowDocument( ed, doc )
if doc.lossy then
ed->ask( ed->form, textfile.DisplayName( doc ) & _
" is not UTF-8. Characters that could not be read show" & _
" as U+FFFD, and saving writes them that way.", MBX_BTN_OK )
end if
return true
end function
'' Saves, asking for a name when there is none (or when asked to).
function SaveDocument( byval ed as Editor ptr, _
byval newName as boolean ) as boolean
if newName orelse len( ed->doc.path ) = 0 then
dim as ustring p = PickPath( ed, true, _
textfile.DisplayName( ed->doc ) )
if len( p ) = 0 then return false
ed->doc.path = p
end if
ed->doc.text = TextOf( ed )
dim as ustring e = textfile.Save( ed->doc )
if len( e ) then
ed->ask( ed->form, e, MBX_BTN_OK )
return false
end if
tkTextBox_SetModify( ed->text, false )
ShowTitle( ed )
return true
end function
'' True when it is all right to throw the text away: it is saved,
'' the person saved it now, or said not to.
function MayDiscard( byval ed as Editor ptr ) as boolean
if tkTextBox_GetModify( ed->text ) = false then return true
select case ed->ask( ed->form, "Do you want to save changes to " & _
textfile.DisplayName( ed->doc ) & "?", _
MBX_BTN_SAVE_DONTSAVE_CANCEL )
case MBX_IDYES
return SaveDocument( ed, false )
case MBX_IDNO
return true
end select
return false
end function
Apart from where the path comes from, this is Chapter 36's code with the prefixes changed.
tkTextBox_SetText is silent, as a setter
should be, and the modified flag is cleared after a load and a successful save.
sub FindAgain( byval ed as Editor ptr )
if len( ed->findWhat ) = 0 then exit sub
dim as integer first, last
tkTextBox_GetSel( ed->text, first, last )
dim as ustring body = TextOf( ed )
dim as int32 at = textfile.FindNext( body, ed->findWhat, _
offsets.UnitsIn( body, last ), ed->matchCase )
if at < 0 then
ed->ask( ed->form, "Cannot find """ & ed->findWhat & """", _
MBX_BTN_OK )
exit sub
end if
'' SetSel scrolls the caret into view itself.
tkTextBox_SetSel( ed->text, offsets.BytesIn( body, at ), _
offsets.BytesIn( body, at + len( ed->findWhat ) ) )
ShowStatus( ed )
end sub
'' The Find box: a modal form that only collects the search.
sub FindBox_OnSize( byval f as TK_FORM ptr, byval cx as long, _
byval cy as long )
dim as FindBox ptr box = f->Tag
dim as long pad = tkForm_Scale( f, 12 ), bw, bh
tkButton_GetIdealSize( box->ok, bw, bh )
dim as long w = cx - 3 * pad - bw
tkWidget_SetPos( box->what, pad, pad, w, bh )
tkWidget_SetPos( box->matchCase, pad, 2 * pad + bh, w, bh )
tkWidget_SetPos( box->ok, cx - pad - bw, pad, bw, bh )
tkWidget_SetPos( box->cancel, cx - pad - bw, 2 * pad + bh, bw, bh )
end sub
sub FindBox_Accept( byval box as FindBox ptr )
box->ed->findWhat = tkTextBox_GetText( box->what )
box->ed->matchCase = tkCheckBox_GetChecked( box->matchCase )
tkForm_EndModal( tkForm_FromWidget( box->what ), TKAPP_IDOK )
end sub
sub AskWhatToFind( byval ed as Editor ptr )
dim as TK_FORM ptr f = tkApp_NewForm( "Find", 420, 112, ed->form )
dim as FindBox box
box.ed = ed
f->Tag = @box
box.what = tkTextBox_Create( tkForm_Widget( f ), 104 )
tkTextBox_SetText( box.what, ed->findWhat )
tkTextBox_SetSelectOnFocus( box.what, true )
tkTextBox_SetEnterPressedCallback( box.what, _
sub( byval h as TK_WIDGET ptr )
FindBox_Accept( tkForm_FromWidget( h )->Tag )
end sub )
box.matchCase = tkCheckBox_Create( tkForm_Widget( f ), 105 )
tkCheckBox_SetText( box.matchCase, "Match case" )
tkCheckBox_SetChecked( box.matchCase, ed->matchCase )
box.ok = tkButton_Create( tkForm_Widget( f ), TKAPP_IDOK )
tkButton_SetText( box.ok, "Find Next" )
tkButton_SetClickCallback( box.ok, _
sub( byval h as TK_WIDGET ptr, byval id as long )
FindBox_Accept( tkForm_FromWidget( h )->Tag )
end sub )
box.cancel = tkButton_Create( tkForm_Widget( f ), TKAPP_IDCANCEL )
tkButton_SetText( box.cancel, "Cancel" )
tkButton_SetClickCallback( box.cancel, _
sub( byval h as TK_WIDGET ptr, byval id as long )
tkForm_EndModal( tkForm_FromWidget( h ), TKAPP_IDCANCEL )
end sub )
tkForm_OnSize( f, @FindBox_OnSize )
'' Esc ends a modal form with IDCANCEL by itself.
if tkForm_ShowModal( f ) = TKAPP_IDOK then FindAgain( ed )
end sub
FindAgain converts twice: the selection's end into units for textfile, and the match back into
bytes for the box. tkTextBox_SetSel scrolls the caret into view itself, so there is nothing after
it. Enter in the search box is Find Next, through the box's Enter callback, and Esc ends a modal
form with TKAPP_IDCANCEL without being asked. Because tkForm_ShowModal returns only once the box
is closed, its FindBox can live on the stack of AskWhatToFind.
A Quit that is not a close
The close button goes through OnClose. The system's own Quit does not.
SDL gives every Mac application a menu with Quit on it, and Cmd+Q reaches the program as
SDL_EVENT_QUIT, an event that belongs to no window. A logout can arrive the same way. tkControls
responds by marking every window closed, and the program ends with the question never asked.
'' The event hook is handed only the event, so the one window it
'' protects is kept where the hook can reach it.
function MainEditor( byval ed as Editor ptr = 0 ) as Editor ptr
static as Editor ptr kept
if ed <> 0 then kept = ed
return kept
end function
'' The system asking the whole program to quit (Cmd+Q, logging out)
'' becomes an ordinary close, which asks first.
function QuitHook( byval e as SDL_Event ptr ) as boolean
if e->type <> SDL_EVENT_QUIT then return false
tkForm_Close( MainEditor( )->form )
return true
end function
tkHost_AddEventHook is called for every event before the library looks at it, and a hook that
returns true has dealt with it. QuitHook turns the system's Quit into an ordinary close of the
main window, which runs OnClose, which asks. An event hook is handed the event and nothing else,
so MainEditor keeps a pointer in a static for it. It is the one piece of state in the program
that is not in the Editor, and it is there because the C-shaped callback left no place for it.
The window's close button is an event for the window, and it is the one you remember to handle.
With the hook removed, the self-test's Quit closes the window with unsaved text in it and no
question: the two lines asked: and answered: vanish, and window closed: reads true. On
Windows nothing a person ordinarily does produces this event, which is why a program developed
there does not find out.
Creating it all
'' Builds the main window, with an empty, untitled document.
public function Create( byval forcedScale as single = 0 ) as Editor ptr
dim as Editor ptr ed = new Editor
ed->ask = @AskPerson
ed->pick = @PickFile
dim as TK_FORM ptr f = tkApp_NewForm( "tpad", 720, 480, 0, _
forcedScale )
ed->form = f
f->Tag = ed
BuildMenus( f, ed )
BuildText( f, ed )
BuildStatus( f, ed )
tkForm_OnSize( f, @Main_OnSize )
'' Asked before the window closes, however it is closed.
tkForm_OnClose( f, _
function( byval f as TK_FORM ptr ) as boolean
dim as Editor ptr ed = f->Tag
if ed->picking then return false
return MayDiscard( ed )
end function )
tkForm_OnDestroy( f, _
sub( byval f as TK_FORM ptr )
tkHost_RemoveEventHook( @QuitHook )
delete cast( Editor ptr, f->Tag )
f->Tag = 0
end sub )
MainEditor( ed )
tkHost_AddEventHook( @QuitHook )
ShowTitle( ed )
ShowStatus( ed )
return ed
end function
Chapter 36's Create needed a DPI handler, because PsApp gives every control the form's UI font
when the scale changes. Here a scale change re-sizes the fonts each control was given and hands out
no others, so there is nothing to put back. The self-test still checks it, because the fact is the
library's and could change. forcedScale pins a scale instead of the display's.
The program
#cmdline "-s gui"
#import "std/fs"
#import "std/strings"
#import "./pad"
#import "vendor/tkControls/tkApp"
#import "vendor/tkControls/tkTextBox"
#import "vendor/tkControls/tkStatusBar"
#import "vendor/tkControls/tkMessageBox"
using tkApp
using tkHost
using tkTextBox
using tkStatusBar
using tkMessageBox
'' tpad [file] run it
'' tpad --selftest drive the real window, print what it saw
'' tpad --shot out.bmp [file] render once, dark theme, and save it
dim as string first = command( 1 )
dim as boolean testing = (first = "--selftest")
dim as boolean shooting = (first = "--shot")
dim as TKAPP_OPTIONS opts
if testing orelse shooting then opts.ThemeMode = TKAPP_THEME_DARK
if tkApp_Init( opts ) = false then
print "tkApp_Init: " & *SDL_GetError( )
end 1
end if
'' 0 means the display's own scale. A test pins it, so that its layout
'' is the same on every machine.
dim as single uiScale = val( environ( "TPAD_SCALE" ) )
if testing then uiScale = 1.0
if shooting then uiScale = 1.75
dim as pad.Editor ptr ed = pad.Create( uiScale )
if testing then
dim as long result = SelfTest( ed )
tkApp_Shutdown( )
end result
end if
if shooting then
if len( command( 3 ) ) then pad.OpenPath( ed, command( 3 ) )
TakeShot( ed, command( 2 ) )
tkApp_Shutdown( )
end 0
end if
if len( first ) then pad.OpenPath( ed, first )
end tkApp_Run( ed->form )
#cmdline "-s gui" matters on Windows, where it means no console window; the other two systems
make no such distinction. tkApp_Run shows the form and runs the event loop until it closes, and
its result is the exit code. Under --selftest and --shot the loop never runs.
Testing on three systems
The test is Chapter 36's idea, and it is more valuable here. It drives the real window, with the
person behind ask and pick:
'' The person, scripted: what the test answers to each question.
function ScriptedAsk( byval f as TK_FORM ptr, byref text as ustring, _
byval buttons as long ) as long
static as long replies( ... ) = { MBX_IDCANCEL, MBX_IDNO, _
MBX_IDCANCEL, MBX_IDNO }
static as int32 n
print " asked: "; text
if buttons = MBX_BTN_OK then return MBX_IDOK
n += 1
print " answered: "; iif( replies( n - 1 ) = MBX_IDNO, _
"Don't Save", "Cancel" )
return replies( n - 1 )
end function
function ScriptedPick( byval f as TK_FORM ptr, _
byval saving as boolean, _
byref suggest as ustring ) as ustring
print " picked: lf.tmp"
return "lf.tmp"
end function
'' A person who tries to close the window while the dialog is up.
function ImpatientPick( byval f as TK_FORM ptr, _
byval saving as boolean, _
byref suggest as ustring ) as ustring
pad.Execute( f->Tag, pad.CmdExit )
tkForm_Close( f )
print " window closed: "; cbool( tkForm_Host( f )->bQuit )
return ""
end function
'' Lets queued events arrive, as the loop would.
sub Pump( )
tkApp_Pump( )
end sub
'' Keeps the loop turning for a while.
sub PumpFor( byval ms as uint64 )
dim as uint64 start = SDL_GetTicks( )
do until SDL_GetTicks( ) - start >= ms
Pump( )
SDL_Delay( 10 )
loop
end sub
'' A key, delivered where the keyboard would deliver it.
sub Press( byval ed as pad.Editor ptr, byval key as long )
dim as SDL_Event e
e.type = SDL_EVENT_KEY_DOWN
e.key.windowID = tkForm_Host( ed->form )->winId
e.key.key = key
e.key.down = true
SDL_PushEvent( @e )
Pump( )
end sub
'' Typed text, as the system hands it over after the keys: UTF-8. The
'' event points at this string, so it has to outlive the event.
sub TypeText( byval ed as pad.Editor ptr, byref s as ustring )
static as string typed
typed = s
dim as SDL_Event e
e.type = SDL_EVENT_TEXT_INPUT
e.text.windowID = tkForm_Host( ed->form )->winId
e.text.text = strptr( typed )
SDL_PushEvent( @e )
Pump( )
end sub
sub Report( byval ed as pad.Editor ptr )
dim as ustring title = _
*SDL_GetWindowTitle( tkForm_Host( ed->form )->win )
print " title: "; title
print " status: "; tkStatusBar_GetText( ed->status, 0 ); " | "; _
tkStatusBar_GetText( ed->status, 1 ); " | "; _
tkStatusBar_GetText( ed->status, 2 )
end sub
Where Chapter 36 sent WM_KEYDOWN and WM_CHAR to a window, this one pushes SDL events onto the
queue, where the system would put them. Three things follow from that:
- Pump after every event.
SDL_PushEventonly queues it. - Size the window, then wait. A system applies a new window size when it gets to it, and a
key sent to a text box with no size moves
nothing.
PumpForgives it time, and the layout is checked by comparing edges rather than pixels. - An event's text has to outlive the event.
SDL_EVENT_TEXT_INPUTpoints at its string, soTypeTextkeeps it in astatic.
function SelfTest( byval ed as pad.Editor ptr ) as long
ed->ask = @ScriptedAsk
ed->pick = @ScriptedPick
fs.WriteAllText( "lf.tmp", !"one\ntwo\nthree\n" )
dim as TK_HOST ptr h = tkForm_Host( ed->form )
tkForm_Show( ed->form )
Pump( )
print "== a new window"
Report( ed )
print "== the layout, at 600 x 400"
SDL_SetWindowSize( h->win, 600, 400 )
PumpFor( 300 )
print " text meets the scroll bar: "; _
cbool( ed->text->x + ed->text->cw = ed->scroll->x )
print " text meets the status bar: "; _
cbool( ed->text->y + ed->text->ch = ed->status->y )
print " status bar is as wide as the window: "; _
cbool( ed->status->cw = h->root->cw )
print "== File > Open"
pad.Execute( ed, pad.CmdOpen )
Report( ed )
print "== Down, Down, End, and type ""!"""
tkHost_SetFocus( h, ed->text )
Press( ed, SDLK_DOWN )
Press( ed, SDLK_DOWN )
Press( ed, SDLK_END )
TypeText( ed, "!" )
Report( ed )
print "== Find Next, for ""T"", twice"
ed->findWhat = "T"
pad.Execute( ed, pad.CmdFindNext )
Report( ed )
pad.Execute( ed, pad.CmdFindNext )
Report( ed )
print "== Find Next, for ""four"""
ed->findWhat = "four"
pad.Execute( ed, pad.CmdFindNext )
print "== File > Save"
pad.Execute( ed, pad.CmdSave )
Report( ed )
dim as ustring saved = fs.ReadAllText( "lf.tmp" ).Value( )
print " on disk: "; strings.Escape( saved )
print "== type, then File > New, twice"
TypeText( ed, "?" )
pad.Execute( ed, pad.CmdNew )
Report( ed )
pad.Execute( ed, pad.CmdNew )
Report( ed )
print "== moved to a monitor at twice the DPI"
h->fScaleForced = 2 * h->fScale
dim as SDL_Event e
e.type = SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED
e.window.windowID = h->winId
SDL_PushEvent( @e )
Pump( )
print " the text is still monospaced: "; _
cbool( tkTextBox_GetFont( ed->text ) = _
tkForm_Font( ed->form, TKFONT_MONO ) )
print "== text that is not ASCII, Find Next for ""lait"", twice"
fs.WriteAllText( "lf.tmp", "caf" & wchr( &hE9 ) & !" au lait\n" & _
"na" & wchr( &hEF ) & "ve lait" )
pad.Execute( ed, pad.CmdOpen )
ed->findWhat = "lait"
pad.Execute( ed, pad.CmdFindNext )
print " selected: "; tkTextBox_GetSelText( ed->text )
Report( ed )
pad.Execute( ed, pad.CmdFindNext )
print " selected: "; tkTextBox_GetSelText( ed->text )
Report( ed )
print "== File > Exit while the file dialog is open"
ed->pick = @ImpatientPick
pad.Execute( ed, pad.CmdOpen )
ed->pick = @ScriptedPick
print "== type, then the system asks the program to quit"
TypeText( ed, "x" )
e.type = SDL_EVENT_QUIT
SDL_PushEvent( @e )
Pump( )
print " window closed: "; cbool( h->bQuit )
print "== type, then close the window"
TypeText( ed, "x" )
tkForm_Close( ed->form )
print " window closed: "; cbool( h->bQuit )
fs.RemoveFile( "lf.tmp" )
return 0
end function
== a new window
title: Untitled - tpad
status: Ln 1, Col 1 | Windows (CRLF) | UTF-8
== the layout, at 600 x 400
text meets the scroll bar: true
text meets the status bar: true
status bar is as wide as the window: true
== File > Open
picked: lf.tmp
title: lf.tmp - tpad
status: Ln 1, Col 1 | Unix (LF) | UTF-8
== Down, Down, End, and type "!"
title: *lf.tmp - tpad
status: Ln 3, Col 7 | Unix (LF) | UTF-8
== Find Next, for "T", twice
title: *lf.tmp - tpad
status: Ln 2, Col 2 | Unix (LF) | UTF-8
title: *lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
== Find Next, for "four"
asked: Cannot find "four"
== File > Save
title: lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
on disk: one\ntwo\nthree!\n
== type, then File > New, twice
asked: Do you want to save changes to lf.tmp?
answered: Cancel
title: *lf.tmp - tpad
status: Ln 3, Col 2 | Unix (LF) | UTF-8
asked: Do you want to save changes to lf.tmp?
answered: Don't Save
title: Untitled - tpad
status: Ln 1, Col 1 | Windows (CRLF) | UTF-8
== moved to a monitor at twice the DPI
the text is still monospaced: true
== text that is not ASCII, Find Next for "lait", twice
picked: lf.tmp
selected: lait
title: lf.tmp - tpad
status: Ln 1, Col 13 | Unix (LF) | UTF-8
selected: lait
title: lf.tmp - tpad
status: Ln 2, Col 11 | Unix (LF) | UTF-8
== File > Exit while the file dialog is open
window closed: false
== type, then the system asks the program to quit
asked: Do you want to save changes to lf.tmp?
answered: Cancel
window closed: false
== type, then close the window
asked: Do you want to save changes to lf.tmp?
answered: Don't Save
window closed: true
The book's harness compares that output with what the program prints on all three systems, and
there is one .out file for all of them. On Linux it also holds under both SDL video drivers, X11
and Wayland.
The first 36 lines are Chapter 36's, byte for byte. The two self-tests are the same script run against two window systems, so that agreement says more about both programs than either test says alone. The sections after it are the ones this chapter added: text that is not ASCII, the dialog, the system's Quit.
What the self-test cannot see
The self-test reads the model, and Chapter 36's lesson stands: it cannot see pixels. --shot is
for that:
'' Renders the window once and saves what it drew.
sub TakeShot( byval ed as pad.Editor ptr, byref file as ustring )
tkForm_Show( ed->form )
PumpFor( 300 )
dim as TK_HOST ptr h = tkForm_Host( ed->form )
tkHost_Render( h )
tkHost_RenderTo( h, false )
dim as SDL_Surface ptr s = SDL_RenderReadPixels( h->renderer, 0 )
dim as string path = file
if s then
SDL_SaveBMP( s, strptr( path ) )
SDL_DestroySurface( s )
end if
end sub
It runs at a fixed scale, in the dark theme, and saves a bitmap; the picture at the top of the
chapter is one. Reading the pixels back needs the second argument of tkHost_RenderTo to be
false, because on Metal the back buffer is black once it has been presented. With the same scale
on two systems you can compare the files, and what remains is small: mostly a level or two of
anti-aliasing, from one platform's build of SDL3_ttf to another's.
The real file dialogs and the real message box are the other thing the test does not reach. They
are behind pick and ask because they wait for a person, and that person has to try them once on
each system.
Running it
From the chapter's directory:
tiko build tpad
out/debug/tpad notes.txt
On Windows the program is out/debug/tpad.exe. The first build compiles the tkControls modules the
program imports; later builds compile only what changed. tiko run -- notes.txt builds and starts it
in one step.
Shipping
tiko build tpad --profile release writes an optimised program, and the build copies what it needs
beside it. What that is differs by system:
| The program | Beside it | The folder | |
|---|---|---|---|
| Windows | 337,920 bytes | the fonts, SDL3.dll, SDL3_ttf.dll, SDL3_image.dll | 10.3 MB |
| Linux | 348,888 bytes | the fonts | 5.0 MB |
| macOS | 361,480 bytes | the fonts | 5.0 MB |
On Windows the folder is the installation: the toolchain ships the three SDL libraries and copies them. The fonts are Inter, Cascadia Mono and Fluent UI System Icons, with their licences, and they are why the program looks the same everywhere.
On Linux and macOS the program links the system's SDL3 and needs it on the machine that runs it.
On a Mac that is brew install sdl3 sdl3_ttf sdl3_image. On Linux it is the distribution's SDL3
packages, and where a distribution has none yet,
examples/tkControls/tools/build-linux-libs.sh, which comes with the toolchain, builds them.
Tell the link where they are with tiko build tpad --options "-p /path/to/libs", and the program
with LD_LIBRARY_PATH or an installed copy.
What changed from Chapter 36
| Chapter 36, PsControls | Here, tkControls |
|---|---|
PsApplication, PsForm, f->Item( id ) | tkApp_Init, TK_FORM; the Editor keeps its widget pointers |
PsForm.FromHwnd( h )->Tag | tkForm_FromWidget( w )->Tag |
Posting WM_CARETMOVED to the form | tkApp_RunOnUI( @CaretMoved, ed ) |
| An accelerator table | tkForm_OnKeyDown: keys the text box leaves unclaimed |
A modal CIOpenFileDialog | SDL_ShowOpenFileDialog, waited for by PickFile |
SetWindowTextW, in UTF-16 | SDL_SetWindowTitle, in UTF-8 |
Offsets that match ustring units | UTF-8 offsets, converted by offsets |
EM_SCROLLCARET after SetSel | Nothing: SetSel scrolls into view |
| A status-bar paint callback | Nothing: tkStatusBar draws its panels |
OnDpiChanged restores the monospaced font | Nothing: a scale change keeps each control's font |
| Ctrl | TK_KMOD_PRIMARY: Cmd on macOS |
| The window's close | The close, and the system's SDL_EVENT_QUIT through a hook |
tpad.rc, a manifest, UNICODE | Nothing |
WM_KEYDOWN, WM_CHAR in the self-test | SDL_EVENT_KEY_DOWN, SDL_EVENT_TEXT_INPUT |
What it costs
- A larger installation. Chapter 36's
tpadis one 316 KB file that imports only what comes with Windows. This one is about the same size, and it brings 10 MB of libraries and fonts on Windows and needs SDL3 installed elsewhere. Portability is paid for in what you ship. - Every caret move reads the whole text, and now converts it. Measured on a 1 MB file in a
release build on Windows,
ShowStatustakes about 10 ms, of which under 2 ms istkTextBox_GetTextand the rest is copying and counting. A notes file costs nothing; a large log would make the caret lag. - The controls are drawn, not provided. A
tkTextBoxlooks and behaves the same on every system, which is the reason for using it, and for the same reason it is nothing the system recognises. There is no accessibility interface, so a screen reader sees an empty window. The menu bar is in the window, on a Mac too, not in the Mac's menu bar at the top of the screen. - The column counts UTF-16 units. A character outside the Basic Multilingual Plane, such as U+1F600, moves the column by two. Exercise 1 fixes it.
- A new document is saved with CR LF on every system, because
textfilewas written for Windows, and the status bar says so honestly. A file that was read keeps its own ending. Exercise 2 fixes it. - The text always wraps, as in Chapter 36: a multiline box wraps and has no horizontal scroll bar.
- The same limits as Chapter 36 on the file in memory, and on text that is not UTF-8.
Summary
- A module that imports no window moves to a new window system unchanged.
textfileis the same file in both chapters. - tkControls replaces handles and messages with a
TK_WIDGETtree in one SDL window. Keep a pointer to each control you talk to, and reach the program's state from a callback throughtkForm_FromWidgetand the form'sTag. - SDL3's file dialogs return at once and answer through a callback that may run on another thread. Wait for the answer by turning the event loop, and have the callback touch nothing but C memory and an atomic.
- While a loop turns under a dialog, anything can be delivered. Refuse new commands and the close
until the dialog answers, or an
Editorcan be freed underneath the code waiting on it. tkTextBoxoffsets are UTF-8 bytes and austringcounts UTF-16 units. They agree for ASCII and nowhere else. Convert at the edge, in one place, and test with text that is not English.tkApp_RunOnUIruns code after the current event, as a posted message did.TK_KMOD_PRIMARYis Cmd on macOS and Ctrl elsewhere. Only the menu's label needs#if.- A system's Quit is
SDL_EVENT_QUIT, an event that belongs to no window. Turn it into a close with an event hook, or it skips the question before discarding. - One self-test and one expected output, run on every system, is what shows that the program is one
program. Push SDL events, pump after each, and wait after resizing.
--shotcovers the pixels. - A program built on SDL3 ships its libraries and fonts on Windows, and asks for SDL3 on Linux and macOS.
Exercises
- Make the status bar's column count characters, so that U+1F600 counts as one. A ustring holds such a character as two units, and the second is a low surrogate. {solution="ex37-01"}
- Make a new document use the line ending of the system it was started on, through a
NewDocumentfunction and#if, while a file that was read keeps its own. {solution="ex37-02"} - Port Find Previous (Shift+F3) from Chapter 36's exercises. Mind which count each side uses.
- On macOS, make File > Exit read Quit, with Cmd+Q as its key, while Windows and Linux keep Exit.
- Open a file dropped on the window. Add an event hook for
SDL_EVENT_DROP_FILE, whosedatafield is the path in UTF-8, and callMayDiscardfirst. - Remember the window's size between runs, in a file under the folder
SDL_GetPrefPathreturns, and check that the same code finds the right place on all three systems.
Appendix A: Keywords and Operator Precedence
A name in tiko is one of five things, and they follow different rules. A reserved word is never a name. A runtime name may not be reused at module level. A contextual word is a keyword only where its statement expects it. A fixed-width type name, like the constant nullptr, is predeclared and may be replaced. Everything else is free. The rules below were measured by compiling each word in each position with tiko and with fbc-modern 1.20, and the two give the same answer for every word in every position.
Reserved words (247)
A reserved word cannot name a variable, a constant or a procedure, at module level, inside a procedure or inside a namespace. Case does not matter. The diagnostic is usually TKO0600: '…' is already declared in this scope, because the word is declared by the language itself; where the word starts a statement of its own, the parser reports that statement's error instead.
__fastcall | cos | draw | let | print | tan |
__thiscall | cptr | dynamic | lib | private | then |
abs | cshort | else | line | procptr | threadcall |
abstract | csign | elseif | local | property | to |
access | csng | encoding | lock | protected | trim |
acos | cubyte | end | log | pset | type |
alias | cuint | endif | long | ptr | typeof |
and | culng | enum | longint | public | ubound |
andalso | culngint | eqv | loop | put | ubyte |
any | cunsg | erase | lprint | random | ucase |
append | cushort | err | lset | read | uinteger |
as | cva_arg | error | ltrim | redim | ulong |
asc | cva_copy | exit | mid | rem | ulongint |
asin | cva_end | exp | mkd | restore | union |
asm | cva_start | explicit | mki | resume | unlock |
atan2 | cvd | export | mkl | return | unsigned |
atn | cvi | extends | mklongint | rset | until |
base | cvl | extern | mks | rtrim | ushort |
binary | cvlongint | field | mkshort | sadd | using |
boolean | cvs | fix | mod | scope | ustring |
byref | cvshort | for | name | screen | va_first |
byte | data | frac | namespace | seek | var |
byval | declare | function | new | select | varptr |
call | defbyte | get | next | sgn | view |
case | defdbl | gosub | not | shared | virtual |
cast | defined | goto | on | shl | wchr |
cbool | defint | if | open | short | wend |
cbyte | deflng | iif | operator | shr | while |
cdbl | deflongint | imagecreate | option | sin | width |
cdecl | defshort | imp | or | single | window |
chr | defsng | implements | orelse | sizeof | winput |
cint | defstr | import | output | spc | with |
circle | defubyte | include | overload | sqr | write |
class | defuint | input | paint | static | wstr |
clng | defulng | instr | palette | stdcall | wstring |
clngint | defulongint | instrrev | pascal | step | xor |
close | defushort | int | peek | str | zstring |
color | delete | integer | point | string | |
common | destructor | is | pointer | strptr | |
const | dim | lbound | poke | sub | |
constructor | do | lcase | preserve | swap | |
continue | double | len | preset | tab |
The list includes every statement and function word of the classic language — print, open, len, read, name, width, lock — and every built-in type name, double and string included. function Double( ) is refused, and so is a variable called read.
Keywords as field names
A field may be named by most reserved words, because a field's name is only ever read after . or ->, or in its type's body. A type can have a field called name, type, len or constructor, and t.name reads it. The exceptions:
- The operator words
and,andalso,eqv,imp,mod,not,or,orelse,shl,shrandxor, anddeleteandnew, are never field names. - In the type-first spelling,
as T name,const,ptrandpointerare part of the type, not a name;ptr as longis a field. - In the name-first spelling,
name as T, the declaration wordsconst,declare,dim,enum,redim,static,private,protectedandpublicstart a declaration instead, andtypeandunioncannot take array bounds (type( 0 to 2 ) as long). - In the type-first spelling,
aliasis not a name either.alias as longis fine. remstarts a comment where a statement can end, sorem as longis a comment.as long remdeclares a field namedrem, andt.remreads it.
Runtime names (104)
These are the runtime library's procedures. They are not reserved words. The runtime declares them in the global namespace, so a declaration of the same name at module level collides with it: TKO0813: '…' is already declared by the runtime library. That covers a variable, a constant, a procedure, a for counter, a local inside a module-level procedure, and a parameter of a procedure's definition. The same name is fine inside a namespace or a method, as a field, a member procedure or an enum member, and as a parameter of a prototype.
allocate | csrlin | fre | multikey | rnd | settime |
beep | curdir | freefile | mutexcreate | run | shell |
bload | date | getjoystick | mutexdestroy | screencopy | space |
callocate | deallocate | getkey | mutexlock | screenevent | stop |
chain | dylibfree | imageconvertrow | mutexunlock | screenlist | system |
chdir | dylibload | imagedestroy | out | screenlock | threadcreate |
clear | environ | inkey | pcopy | screenptr | threadwait |
cls | eof | inp | pmap | screenres | time |
command | erfn | kill | pointcoord | screenset | timer |
condbroadcast | erl | loc | pos | screensync | wait |
condcreate | ermn | locate | randomize | screenunlock | windowtitle |
conddestroy | exec | lof | reallocate | setdate | wspace |
condsignal | exepath | lpos | reset | setenviron | |
condwait | flip | mkdir | rmdir | setmouse |
These also take a new overload: sub Hex( ) compiles beside the runtime's hex, but dim as long hex does not.
bin | getmouse | oct | screeninfo | vallng | whex |
bsave | hex | right | sleep | valuint | woct |
dir | imageinfo | screencontrol | val | valulng | |
dylibsymbol | left | screenglproc | valint | wbin |
#define is the exception: tiko accepts #define run 5, where fbc refuses it as a duplicated definition (Appendix D).
Contextual words
FBC-Modern's extensions added these words without reserving them, so existing code that uses them as names keeps compiling. Each one is a keyword only in the position shown, and an ordinary name everywhere else: each one compiles as a variable, a procedure, a field, a local and a namespace member.
| Word | A keyword in |
|---|---|
channel | select channel (Chapter 23) |
defer | defer statement, when a name, a keyword other than as, or the end of the statement follows (Chapter 6) |
each | for each (Chapter 20) |
from | case receive v from ch in select channel |
in | for each x in … |
of | type arguments: Array( of int32 ) (Chapter 17) |
override | after a method's declaration: declare sub M( ) override (Chapter 14) |
receive | case receive in select channel |
send | case send v to ch in select channel |
spawn | spawn Proc( args ) (Chapter 23) |
timeout | case timeout ms in select channel |
Fixed-width type names
tiko predeclares ten type names that fbc does not have. They are typedefs, not reserved words (Chapter 4): a program's own declaration of one of them replaces tiko's without a diagnostic, so code that already declares INT32 — as win/basetsd.bi does — compiles unchanged.
| Name | Same type as | Name | Same type as |
|---|---|---|---|
int8 | byte | uint8 | ubyte |
int16 | short | uint16 | ushort |
int32 | long | uint32 | ulong |
int64 | longint | uint64 | ulongint |
float32 | single | float64 | double |
The predeclared constant
tiko predeclares one constant that fbc does not have, const nullptr as any ptr = 0: the null pointer, as a pointer (Chapter 11). It is replaced on the same terms as the type names above, so a program's own nullptr — fbc's own test suite has a field by that name — compiles unchanged.
Operator precedence
Tightest first. Operators on one row bind equally, and every binary operator is left-associative, ^ included. The member operators . and ->, calls ( ) and indexing [ ] bind tighter than everything here.
| Level | Operators |
|---|---|
| 1 | ^ |
| 2 | - (sign) + (sign) |
| 3 | * / |
| 4 | \ |
| 5 | mod |
| 6 | shl shr |
| 7 | + - |
| 8 | is & |
| 9 | = <> < <= > >= |
| 10 | not |
| 11 | and |
| 12 | or |
| 13 | imp eqv xor |
| 14 | orelse andalso |
The table is FreeBASIC's, measured operator pair by operator pair against fbc, and several rows differ from C and from intuition (Chapter 5). Each line below prints the same with tiko and with fbc-modern:
2 ^ 3 ^ 2 64 ^ is left-associative: (2 ^ 3) ^ 2
-2 ^ 2 -4 ^ binds tighter than the sign
1 shl 2 + 1 5 shl binds tighter than +, unlike C's <<
8 \ 4 * 2 1 * binds tighter than \: 8 \ (4 * 2)
2 * 3 mod 4 2 mod is looser than * and \
12 or 10 xor 6 8 or binds tighter than xor
1 or 2 and 0 1 and binds tighter than or
0 andalso 1 orelse 1 -1 andalso and orelse share one level
not 1 = 0 -1 not is looser than the comparisons: not (1 = 0)
not 0 and 3 3 ...and tighter than and: (not 0) and 3
& binds looser than + and -, so "a" & 1 + 2 means "a" & (1 + 2), and both compilers print a3.
Appendix B: Diagnostics
Every diagnostic the compiler can emit, as of the pinned version: 602 in all 567 errors, 14 notes, 21 warnings.
A diagnostic's number is stable forever. Retiring one leaves its row in place rather than reusing the number, so an id in an old build log still means what it meant then. Suppress a warning with -Wno-TKOnnnn, and turn warnings into errors with -Werror.
Text like ‹arg1› marks where the compiler substitutes a name, type or path.
Source and file handling (TKO0001–TKO0099)
| Id | Severity | Message |
|---|---|---|
TKO0001 | error | cannot open source file '‹arg1›': ‹arg2› |
TKO0002 | error | source file '‹arg1›' is empty |
TKO0003 | warning | source file '‹arg1›' is not valid UTF-8; bytes preserved verbatim |
Driver and command line (TKO0100–TKO0199)
| Id | Severity | Message |
|---|---|---|
TKO0100 | error | unknown option '‹arg1›' |
TKO0101 | error | option '‹arg1›' requires an argument |
TKO0102 | error | no input files |
TKO0103 | error | unknown target '‹arg1›' |
TKO0104 | error | 32-bit targets are not supported; x86-64 only |
TKO0105 | warning | unknown diagnostic id '‹arg1›' |
TKO0106 | error | '‹arg1›' is not a recognised value for '‹arg2›' |
TKO0108 | error | '‹arg1›' is a Windows resource script; the target is not Windows |
TKO0109 | error | no resource compiler: ‹arg1› |
TKO0110 | error | compiling resource '‹arg1›' failed (exit ‹arg2›) |
TKO0111 | error | no usable toolchain: ‹arg1› |
TKO0112 | error | cannot write intermediate file '‹arg1›' |
TKO0113 | error | clang failed on '‹arg1›' (exit ‹arg2›) |
TKO0114 | error | '‹arg1›' is a ‹arg2› object, which target triple '‹arg3›' does not make |
TKO0115 | error | archiving failed (exit ‹arg1›) |
TKO0116 | error | link failed (exit ‹arg1›) |
TKO0117 | error | cannot write the diagnostics file '‹arg1›' |
TKO0118 | error | -cimport needs -cimport-out |
TKO0119 | error | importing a C header needs libclang: ‹arg1› |
TKO0120 | error | unable to import C header ‹arg1› |
TKO0121 | error | cannot write the binding '‹arg1›' |
TKO0122 | error | the binding generated for ‹arg1› does not compile; this is a bug in the C importer |
TKO0123 | error | -pic makes position-independent code for a Unix shared library; target '‹arg1›' is not a Unix |
TKO0124 | warning | LLVM is not installed: no clang was found, so this build cannot produce code -- install LLVM 18 or newer, or set TIKO_CLANG |
TKO0140 | error | cannot find module '‹arg1›' (searched: ‹arg2›) |
TKO0141 | error | import cycle: ‹arg1› |
TKO0142 | error | relative import '‹arg1›' leaves the package root '‹arg2›' |
TKO0144 | error | malformed tiko.toml: ‹arg1› |
TKO0145 | error | '‹arg1›' is in module directory '‹arg2›' but does not begin with '#module' |
TKO0146 | error | dependency '‹arg1›': ‹arg2› |
TKO0147 | error | module directory '‹arg1›' contains no .tiko or .bas files |
TKO0148 | error | package name '‹arg1›' is reserved for the toolchain |
TKO0149 | error | two packages named '‹arg1›': ‹arg2› and ‹arg3› |
TKO0150 | warning | module cache disabled: ‹arg1› |
TKO0154 | error | '‹arg1›' cannot be used with '‹arg2›' |
TKO0155 | error | 'link ‹arg1›' on '#import "‹arg2›"': package '‹arg3›' links nothing |
TKO0156 | error | 'link ‹arg1›': package '‹arg2›' ships no ‹arg1› link of module '‹arg3›' |
TKO0157 | error | module '‹arg1›' is imported with 'link ‹arg2›' here and 'link ‹arg3›' elsewhere; a program links it one way |
TKO0160 | error | no target named '‹arg1›' (targets: ‹arg2›) |
TKO0161 | error | target '‹arg1›': '‹arg2›' is a module directory; modules are reached through #import, not listed |
TKO0162 | error | target '‹arg1›': '‹arg2›' not found |
TKO0163 | error | no profile named '‹arg1›' (profiles: ‹arg2›) |
TKO0164 | error | profile '‹arg1›' inherits itself: ‹arg2› |
TKO0165 | error | targets '‹arg1›' and '‹arg2›' write the same output '‹arg3›' |
TKO0167 | error | compiling '‹arg1›' crashed with exit code ‹arg2› |
TKO0168 | error | 'tiko run' needs an exe target; '‹arg1›' is a ‹arg2› |
TKO0170 | error | 'tiko run' runs one target; name one of: ‹arg1› |
TKO0171 | error | could not copy '‹arg1›' to '‹arg2›' |
TKO0172 | error | cannot make an import library for '‹arg1›': ‹arg2› |
TKO0176 | error | module '‹arg1›' needs '‹arg2›' at run time, and there is no '‹arg3›' |
TKO0177 | error | cannot write the debug symbols of '‹arg1›': ‹arg2› |
TKO0173 | error | 'tiko c2t' translates one header per run; '‹arg1›' is a second |
TKO0174 | error | cannot read the C header '‹arg1›' |
TKO0175 | error | the translation of ‹arg1› does not compile; this is a bug in tiko c2t |
TKO0169 | note | '‹arg1›' is not cached: ‹arg2› |
TKO0166 | error | another build of '‹arg1›' is running (it holds ‹arg2›) |
TKO0153 | error | '‹arg1›' was compiled against module object '‹arg2›', which is not there; compile it again with -c |
TKO0152 | error | '--emit-tki' writes a module's interface, and needs one module directory as its input |
Lexer (TKO0200–TKO0299)
| Id | Severity | Message |
|---|---|---|
TKO0201 | warning | missing closing quote in string literal |
TKO0202 | error | unterminated block comment |
TKO0203 | error | '‹arg1›' is not followed by any digits |
TKO0205 | warning | numeric literal is too large for 64 bits, truncated |
TKO0206 | warning | suffix ignored in '‹arg1›' |
TKO0207 | error | block string has no closing line; one must start with three quotes |
TKO0208 | error | block string line does not start with the closing line's indentation |
Preprocessor (TKO0300–TKO0399)
| Id | Severity | Message |
|---|---|---|
TKO0300 | error | unknown directive '#‹arg1›' |
TKO0301 | error | cannot open include file '‹arg1›' |
TKO0302 | error | '#‹arg1›' without a matching '#if' |
TKO0303 | error | '#if' has no matching '#endif' |
TKO0304 | error | preprocessor nesting exceeded ‹arg1› levels |
TKO0305 | error | macro '‹arg1›' is already defined |
TKO0306 | error | macro '‹arg1›' takes ‹arg2› argument(s) |
TKO0307 | error | unterminated argument list for macro '‹arg1›' |
TKO0308 | error | invalid expression in '#‹arg1›' |
TKO0309 | error | #error: ‹arg1› |
TKO0310 | error | '#‹arg1›' is not implemented in this build |
TKO0311 | error | '#macro' without '#endmacro' |
TKO0312 | warning | unknown '#pragma ‹arg1›' |
TKO0314 | error | builtin macro '‹arg1›' is not implemented in this build |
TKO0315 | note | #print ‹arg1› |
TKO0316 | error | expected an identifier after '#‹arg1›' |
TKO0317 | error | unsupported language mode '‹arg1›'; tiko-lang implements "fb" only |
TKO0318 | error | cannot pop '#pragma ‹arg1›': the stack is empty |
TKO0319 | error | FB_UNIQUEID_POP( ‹arg1› ) has no matching push |
TKO0320 | error | '‹arg1›' has no type; tiko-lang implements #lang "fb" only, where default types and suffixes are not valid |
TKO0321 | error | expected a type parameter name |
TKO0322 | error | duplicated type parameter '‹arg1›' |
TKO0323 | error | '‹arg1›' does not name a type here, so it cannot own this constructor or destructor |
TKO0324 | error | argument ‹arg1› of '‹arg2›' must be an integer constant |
TKO0325 | error | argument of '‹arg1›' must be a constant expression |
TKO0326 | error | '#‹arg1›' takes a library name in quotes |
TKO0327 | error | expected a name, STATIC or DYNAMIC, then a quoted spec after #foreignlib |
TKO0328 | error | expected STATIC or DYNAMIC after the #foreignlib name, ‹arg1› |
TKO0329 | error | #foreignlib found no matching library file: ‹arg1›; tried: ‹arg2› |
TKO0330 | error | #foreignlib name already bound to a different library set, ‹arg1› |
TKO0331 | error | #line takes a line number, then an optional quoted file name |
TKO0332 | error | unexpected token after #line |
TKO0333 | error | #cmdline takes one quoted argument string |
TKO0334 | warning | #cmdline is ignored outside the first source file |
TKO0335 | error | #cinclude needs libclang: ‹arg1› |
TKO0336 | error | unable to import C header ‹arg1› |
TKO0337 | error | the binding generated for ‹arg1› does not compile; this is a bug in the C importer |
TKO0338 | error | ‹arg1› defines '‹arg2›', which an earlier #cinclude in this unit left opaque; #cinclude ‹arg1› first |
TKO0339 | warning | #cinclude could not write its cache entry under '‹arg1›'; the binding is used from memory |
TKO0340 | error | 'public #cinclude' needs the binding cache, which -cnocache turns off: ‹arg1› is not a file an importer can include |
TKO0341 | error | cannot open embed file '‹arg1›' |
TKO0342 | error | malformed '#embed': ‹arg1› |
TKO0344 | error | embed file '‹arg1›' is empty; an array cannot have no elements |
TKO0345 | error | embed file '‹arg1›' is ‹arg2› bytes; the limit is 256 MiB |
TKO0346 | error | embed file '‹arg1›' changed during the build: ‹arg2› bytes when read, ‹arg3› when first seen |
TKO0360 | error | '#module' must be the first directive in the file |
TKO0361 | error | '#module "‹arg1›"' does not match its directory '‹arg2›' |
TKO0362 | error | '#import' must come before any code, in the compiled file |
TKO0363 | error | the condition around '#import' uses '‹arg1›', which is not predefined |
TKO0364 | error | malformed '‹arg1›': ‹arg2› |
TKO0365 | error | '#export ‹arg1›': no macro of that name is defined here |
TKO0366 | error | ‹arg1› is not allowed in a module file |
TKO0367 | error | '‹arg1›' is part of module '‹arg2›' and cannot be #included |
TKO0371 | error | '‹arg1›' cannot be defined as a macro: it is ‹arg2› |
TKO0369 | error | ‹arg1› is allowed only in a module file |
TKO0370 | error | ‹arg1› here, but ‹arg2› |
TKO0372 | error | the preprocessed program exceeded ‹arg1› tokens; a macro is expanding without bound |
Parser (TKO0400–TKO0599)
| Id | Severity | Message |
|---|---|---|
TKO0400 | error | expected an expression |
TKO0401 | error | expected '‹arg1›' |
TKO0402 | error | expected a member name after '.' or '->' |
TKO0403 | error | expression nesting exceeded ‹arg1› levels |
TKO0404 | error | unexpected token after expression |
TKO0405 | error | expected 'end ‹arg1›' |
TKO0406 | error | expected an identifier |
TKO0407 | error | inline assembly is not supported; compile that module with fbc |
TKO0408 | error | expected a statement |
TKO0409 | error | '‹arg1›' is not available in the fb dialect |
TKO0410 | error | 'for each' with a name list takes no 'as' clause; each name is typed by its own element |
TKO0411 | error | 'for each byref' cannot take a name list; bind the whole element byref instead |
TKO0412 | error | 'for each' with a name list needs at least two names |
TKO0413 | error | every lambda capture needs an explicit 'byval' or 'byref' |
TKO0414 | error | 'select channel' needs at least one case |
TKO0415 | error | expected receive, send, timeout or else after 'case' |
TKO0416 | error | expected '‹arg1›' in this 'select channel' case |
TKO0417 | error | only one 'case timeout' and one 'case else' are allowed |
TKO0418 | error | 'select case' needs at least one 'case' |
TKO0419 | error | 'is' is not allowed in a 'select case as const' |
TKO0420 | error | expected the end of the statement |
TKO0421 | error | 'next' without a 'for': no loop is open for '‹arg1›' |
TKO0422 | error | a module constructor or destructor priority must be from 101 to 65535 |
TKO0423 | error | '‹arg1›' needs '#import "std/tasks"' in this file (and -mt) |
TKO0424 | error | a typed variadic parameter is an array parameter: write '‹arg1›( ) as T ...' |
TKO0425 | error | '‹arg1›' cannot be written on the array parameter '‹arg2›': an array is always passed by descriptor |
TKO0426 | error | a destructor takes no parameters |
TKO0427 | error | a type extends one base type |
TKO0428 | error | an interface holds only 'inherits' lines and member declarations; '‹arg1›' cannot appear in one |
TKO0429 | error | '‹arg1›' cannot be written on an interface member: every member is public and abstract |
TKO0430 | error | 'implements' names an interface member, as 'IName.Member' |
TKO0432 | error | a ‹arg1› names its base with an 'inherits' line in its body, not 'extends' |
TKO0433 | error | a type names its base with 'extends' on its header; 'inherits' lines are for classes and interfaces |
TKO0434 | error | an interface member has no body; write the body in a type that implements it |
TKO0435 | error | '‹arg1›' lines come first in the body, before any member |
TKO0436 | error | write 'implements' on its own line in the body of '‹arg1›', not on its header |
TKO0437 | error | a class cannot be '‹arg1›' |
TKO0438 | error | 'implements' is written on the method's declaration inside its type |
TKO0598 | error | a sub has no result type; declare it as a function to return a value |
TKO0590 | error | static property '‹arg1›' is init-only: only its initialiser sets it |
TKO0591 | error | field '‹arg1›' cannot be read in another field's initialiser: only static members can |
TKO0592 | error | a property block's getter returns 'value', a local: it cannot return byref |
TKO0593 | error | in a get/set block 'property' names a backing field, and this property has none: use 'value' or 'return' |
TKO0594 | error | '‹arg1›' is an auto-property: its accessors are synthesised and cannot be written by hand |
TKO0595 | error | auto-property '‹arg1›': ‹arg2› |
TKO0596 | error | property '‹arg1›' is init-only: only a constructor of '‹arg2›' may assign it |
TKO0597 | error | 'swap' over the ‹arg1› property '‹arg2›' is not supported; swap through a temporary |
TKO0599 | error | property '‹arg1›' has no address: its getter returns a value, not a reference |
Semantic analysis (TKO0600–TKO0899)
| Id | Severity | Message |
|---|---|---|
TKO0860 | error | '‹arg1›' is not public in module '‹arg2›' |
TKO0862 | error | alias '‹arg1›' is already bound in this file (by '#import "‹arg2›"') |
TKO0863 | error | alias '‹arg1›' conflicts with a declaration of the same name |
TKO0864 | error | '‹arg1›' belongs to imported module '‹arg2›'; only that module can define it |
TKO0865 | note | '‹arg1›' here is the procedure '‹arg2›', which hides ‹arg3› |
TKO0866 | note | '‹arg1›' is also declared here, in a header an '#import' reads |
TKO0867 | error | constant definitions form a cycle: ‹arg1› |
TKO0869 | error | '‹arg1›' is initialised from '‹arg2›', which is declared later |
TKO0868 | error | '‹arg1›' is module-private but appears in the public declaration of '‹arg2›' |
TKO0870 | error | public '‹arg1›' has the same name as its module '‹arg2›' |
TKO0600 | error | '‹arg1›' is already declared in this scope |
TKO0602 | error | '‹arg1›' is a ‹arg2›, not a procedure |
TKO0603 | error | calling convention does not match the declaration of '‹arg1›' |
TKO0604 | error | #assert condition failed |
TKO0605 | error | expected a constant expression |
TKO0606 | error | '‹arg1›' cannot be walked by 'for each': it has no GetIterator( ) |
TKO0607 | error | '‹arg1›' is not an iterator: it has no '‹arg2›' |
TKO0608 | error | 'for each' over a multi-dimensional array has no defined order |
TKO0609 | error | 'for each' cannot walk a fixed-length string: it carries no length |
TKO0610 | error | 'for each' with a name list needs a tuple element; '‹arg1›' is not one |
TKO0611 | error | 'for each' binds ‹arg1› names but the element has ‹arg2› |
TKO0612 | error | var-len array '‹arg1›' cannot be initialized |
TKO0613 | error | array '‹arg1›' is too big |
TKO0614 | error | 'select case as const' needs a constant case value |
TKO0615 | error | invalid 'case' range: the start value is greater than the end value |
TKO0616 | error | 'select case as const' spans more than ‹arg1› values |
TKO0617 | error | cannot modify '‹arg1›': it is a constant |
TKO0618 | error | too many expressions in the initializer for '‹arg1›' |
TKO0619 | error | '‹arg1›' is declared inside a procedure and cannot have methods |
TKO0620 | error | '‹arg1›' has an abstract type, which cannot be instantiated |
TKO0621 | error | '‹arg1›' is marked 'override' but overrides no virtual method |
TKO0622 | error | '‹arg1›' overrides a virtual method with a different return type |
TKO0623 | error | '‹arg1›' and the virtual method it overrides disagree about 'const' |
TKO0624 | error | '‹arg1›' overrides a virtual method with a different parameter mode |
TKO0625 | error | '‹arg1›' is not accessible here |
TKO0626 | error | '‹arg1›' takes a string |
TKO0627 | error | a pointer to a derived type casts only along its own chain |
TKO0628 | error | var-len string '‹arg1›' has static storage and cannot be initialized |
TKO0629 | error | 'delete' takes a pointer |
TKO0630 | error | '.' reaches a member of a value; this is a pointer, so use '->' |
TKO0631 | error | '->' reaches a member through a pointer to a type; this is not one |
TKO0632 | error | no overload of '‹arg1›' matches these arguments |
TKO0633 | error | '‹arg1›' needs a constructor or destructor that is not accessible here |
TKO0634 | error | a procedure pointer cannot be dereferenced or used in arithmetic |
TKO0635 | error | '&=' concatenates, so its target must be a string |
TKO0636 | error | parameter '‹arg1›' cannot be a fixed-length string |
TKO0637 | error | an array is not a subscript |
TKO0638 | error | static member '‹arg1›' is only allowed in a top-level type |
TKO0639 | error | 'byref' belongs before 'as', not after the return type |
TKO0640 | error | a parameter default cannot name '‹arg1›', which has automatic storage |
TKO0641 | error | this parameter takes a whole array, not an element |
TKO0642 | error | 'swap' needs two variables, not a literal or a constant |
TKO0643 | error | '‹arg1›' is byref, so its initializer needs an address |
TKO0644 | error | '‹arg1›' is byref and must be bound where it is declared |
TKO0645 | error | '‹arg1›' is virtual, but its type does not extend 'object' |
TKO0646 | error | '‹arg1›' is abstract and cannot have a body |
TKO0647 | error | 'operator new' and 'operator delete' are implicitly static and cannot be virtual |
TKO0648 | error | a 'put' blend mode is a word, not a string |
TKO0649 | error | an array name without a subscript is not a value |
TKO0650 | error | a byref result cannot return a local |
TKO0651 | error | the definition of '‹arg1›' disagrees with its declaration about 'byref' |
TKO0652 | error | a literal cannot be assigned to |
TKO0653 | error | '‹arg1›' and its initializer disagree about being a string |
TKO0654 | error | the base of '‹arg1›' has no constructor that takes no argument |
TKO0655 | error | the two arms of 'iif' have incompatible types |
TKO0656 | error | '‹arg1›' is extern or common and cannot be initialized |
TKO0657 | error | the type of '‹arg1›' has no constructor that takes no argument |
TKO0658 | error | '‹arg1›' is indexed with the wrong number of subscripts |
TKO0659 | error | dynamic array '‹arg1›' cannot be a member of a union |
TKO0660 | error | the call to '‹arg1›' matches more than one overload |
TKO0661 | error | '‹arg1›' was not declared as a static member variable |
TKO0662 | error | '‹arg1›' cannot reach its base type's private default constructor |
TKO0663 | error | '‹arg1›' cannot reach its base type's private destructor |
TKO0664 | error | '‹arg1›' cannot be written from a const method |
TKO0665 | error | a const method cannot call the non-const method '‹arg1›' |
TKO0666 | error | the type declares no '‹arg1›' to call |
TKO0667 | error | a byref result must be assigned something of exactly its type |
TKO0668 | error | a shared initialiser cannot reference '‹arg1›', which is not shared |
TKO0669 | error | '‹arg1›' adds a new name to a namespace from outside it |
TKO0670 | error | a pointer and a floating-point value do not convert into each other |
TKO0671 | error | division by a constant zero |
TKO0672 | error | the ALIAS name string is empty |
TKO0673 | error | the LIB name string is empty |
TKO0674 | error | this array bound is not known at compile time |
TKO0675 | error | a parameter default names '‹arg1›', which is not declared |
TKO0676 | error | '‹arg1›' mixes RETURN with 'FUNCTION =' over a result type that has constructors |
TKO0677 | error | '‹arg1›' is already declared with these parameter types |
TKO0678 | error | nothing may follow VIRTUAL or ABSTRACT but the procedure keyword |
TKO0679 | error | VIRTUAL and ABSTRACT belong to a member of a type |
TKO0681 | error | a structor cannot be declared this way |
TKO0682 | error | CONST is for a member procedure, which has a THIS to be const |
TKO0683 | error | STATIC is for a member procedure |
TKO0684 | error | this operator is implicitly STATIC and cannot be CONST |
TKO0685 | error | the bitfield '‹arg1›' is wider than its type |
TKO0686 | error | these pointer types do not convert into each other |
TKO0687 | error | a bydesc parameter takes an array of exactly its element type |
TKO0688 | error | 'for each' cannot walk a ustring; index it with 'for' and 'mid' instead |
TKO0689 | error | a string does not convert to a pointer |
TKO0690 | error | the dynamic array field '‹arg1›' cannot have a const element type |
TKO0691 | error | the array field '‹arg1›' cannot take an ellipsis bound |
TKO0692 | error | the array '‹arg1›' cannot have a const element type |
TKO0693 | error | base( ) is given more values than the base type has fields |
TKO0694 | error | '‹arg1›' names neither a namespace nor a type |
TKO0695 | error | '‹arg1›' is not an array and cannot be redimensioned |
TKO0696 | error | ‹arg1› is written on this definition but not on its declaration |
TKO0697 | error | operator '‹arg1›' must be a member of a type |
TKO0698 | error | this argument does not convert to the parameter's type |
TKO0699 | error | a const value cannot be passed to a non-const byref parameter |
TKO0700 | error | '[ ]' indexes a pointer, and this is not one |
TKO0701 | error | only a module-level definition or a static member can be a module constructor |
TKO0702 | error | a string and a number do not convert into each other |
TKO0703 | error | operator [] cannot be STATIC |
TKO0704 | error | operator [] needs an explicit result type |
TKO0705 | error | 'new' of a bare zstring or wstring allocates nothing |
TKO0706 | error | 'new' cannot allocate a fixed-length string |
TKO0707 | error | 'not' over a pointer; compare it with 0 instead |
TKO0708 | error | the image target of a graphics statement must be a pointer |
TKO0709 | error | this type has no conversion to a pointer for a graphics statement to draw into |
TKO0710 | error | the byref function '‹arg1›' never sets its result |
TKO0711 | error | a byref result must be assigned something with an address |
TKO0712 | error | the alias on the definition of '‹arg1›' does not match its declaration |
TKO0713 | error | '‹arg1›' is a type, not a member with a value |
TKO0714 | error | this case value is not the kind the select subject is |
TKO0715 | error | 'with' takes a UDT value, not a number or a pointer |
TKO0716 | error | '->' through a value of a type that has no operator -> |
TKO0717 | error | 'base.‹arg1›( )' calls an abstract method, which has no body |
TKO0718 | error | a union cannot be unpacked by let( ) |
TKO0719 | error | 'erase' over this array needs a plain parameterless cdecl constructor and destructor |
TKO0720 | error | no operator ‹arg1› compares this type |
TKO0721 | error | the dynamic array field '‹arg1›' must write its rank: '(any)' |
TKO0722 | error | the type '‹arg1›' never declared this constructor, destructor or property |
TKO0723 | error | only a var-len array can be redimensioned |
TKO0724 | error | a union is initialised with one value, not a group |
TKO0725 | error | '‹arg1›' has static storage and must be initialised from a constant, not a variable |
TKO0726 | error | '‹arg1›' is shared, which belongs at module level, not inside a block or procedure |
TKO0727 | error | the bounds of '‹arg1›' disagree with its EXTERN declaration |
TKO0728 | error | '‹arg1›' is declared EXTERN with a different number of dimensions |
TKO0729 | error | '‹arg1›' is byref and its initializer must have exactly its type |
TKO0730 | error | '‹arg1›' is a static reference and cannot be bound to a local |
TKO0731 | error | a byref procedure type must be a 'function' with an explicit result type |
TKO0732 | error | '‹arg1›' returns 'any', which is not a value |
TKO0733 | error | every parameter of a definition needs a name |
TKO0734 | error | CONST is not needed on a constructor or destructor |
TKO0735 | error | 'base' must be followed by '.' or an argument list |
TKO0736 | error | the result type of '‹arg1›' has no default constructor |
TKO0737 | error | 'erase' over this array needs a default constructor |
TKO0738 | error | no conversion between these two types |
TKO0739 | error | '‹arg1›' has no member named '‹arg2›' |
TKO0740 | error | '‹arg1›' overrides a virtual method with an array parameter of a different rank |
TKO0741 | error | a field name cannot carry a type suffix |
TKO0742 | error | 'on ... goto' needs a numeric selector, not a string |
TKO0743 | error | '‹arg1›' is not declared |
TKO0744 | error | '‹arg1›' cannot be sized by 'ubound' inside its own initializer |
TKO0745 | error | the namespace '‹arg1›' has no member named '‹arg2›' |
TKO0746 | error | the ‹arg1› of '‹arg2›' is private or protected and cannot be reached from here |
TKO0747 | error | this drops a pointee's 'const' |
TKO0748 | error | a constant cannot have the type of '‹arg1›' |
TKO0749 | error | 'threadcall' runs a SUB; '‹arg1›' is not one |
TKO0750 | error | 'threadcall' cannot run '‹arg1›': ‹arg2› |
TKO0751 | error | a REDIM that declares '‹arg1›' must write its type |
TKO0752 | error | REDIM needs the array's bounds: '‹arg1›( ... )' |
TKO0753 | error | '‹arg1›' is a bitfield and has no address to bind byref |
TKO0754 | error | the type '‹arg1›' is too big |
TKO0755 | error | '‹arg1›' is byref with a default, which needs a complete type |
TKO0756 | error | a cast needs a SIZED string type, as in 'zstring * n' |
TKO0757 | error | '‹arg1›' holds a var-len string, which an initializer list cannot fill |
TKO0758 | error | '‹arg1›' has static storage and holds a bitfield, which its initializer cannot fill |
TKO0759 | error | 'swap' needs two operands of the same kind |
TKO0767 | error | '‹arg1›' overrides a virtual method with a different calling convention |
TKO0768 | error | '‹arg1›' inherits a virtual destructor whose calling convention an implicit one cannot match |
TKO0769 | error | '‹arg1›' is reserved by '#pragma reserve' and cannot be declared |
TKO0770 | error | '‹arg1›' is reserved by '#pragma reserve' and cannot be used |
TKO0771 | error | '‹arg1›' is a var-len string, which cannot be declared '= any' |
TKO0772 | error | this expression is not a procedure pointer and cannot be called |
TKO0773 | error | this call passes ‹arg1› argument(s), but the procedure pointer takes ‹arg2› |
TKO0774 | error | argument ‹arg1› is left out, and the procedure pointer's declaration gives it no default |
TKO0775 | error | this call passes ‹arg1› argument(s), but '‹arg2›' takes ‹arg3› |
TKO0776 | error | argument ‹arg1› of '‹arg2›' is left out, and it has no default |
TKO0760 | error | too many levels of pointer indirection (the limit is 8) |
TKO0761 | error | '‹arg1›' is declared SHARED from outside its namespace |
TKO0762 | error | '‹arg1›' is already defined from outside |
TKO0763 | error | '#pragma reserve' takes 'asm' or 'extern', not '‹arg1›' |
TKO0764 | error | '‹arg1›' is already reserved for that use |
TKO0765 | error | '‹arg1›' mixes 'any' with written bounds; use one or the other |
TKO0766 | error | a type's array field is declared with 'dim', not 'redim' |
TKO0777 | error | '‹arg1›' is already the name of a type with members, which no value may share |
TKO0778 | error | '‹arg1›' has static storage and must be initialised from a constant expression |
TKO0779 | error | '‹arg1›' is declared in a namespace and cannot be removed with #undef |
TKO0780 | error | '‹arg1›' takes '...' and cannot be overloaded |
TKO0781 | error | '...' needs a 'cdecl' procedure, and this one is '‹arg1›' |
TKO0782 | error | '...' cannot be the first parameter |
TKO0783 | error | '‹arg1›' is not declared, so 'procptr' has no procedure to take |
TKO0784 | error | the type '‹arg1›' is used above its declaration |
TKO0785 | error | '‹arg1›' is not a procedure, so 'procptr' cannot take it |
TKO0786 | error | a property getter takes no parameter, or one index |
TKO0787 | error | a property setter takes its value, after any indices |
TKO0788 | error | a property cannot be static |
TKO0789 | error | operator '‹arg1›' takes ‹arg2› parameter(s) here |
TKO0790 | error | operator '‹arg1›' cannot be a member of a type |
TKO0791 | error | 'procptr' takes the name of a procedure |
TKO0792 | error | '‹arg1›' cannot be overloaded |
TKO0793 | error | the result of '‹arg1›' does not match its declaration |
TKO0794 | error | '‹arg1›' was declared with ‹arg2› parameter(s) |
TKO0795 | error | parameter ‹arg1› of '‹arg2›' does not match its declaration |
TKO0796 | error | no declaration of '‹arg1›' matches this definition |
TKO0797 | warning | 'threadcall' is not supported: '‹arg1›' will not run, and the thread handle is 0 |
TKO0798 | error | 'is ‹arg1›' needs a type derived from '‹arg2›', and '‹arg1›' is not one |
TKO0799 | error | 'is' needs run-time type information, and '‹arg1›' has none: it does not extend 'object' |
TKO0800 | error | 'is' needs an object and a type that extends 'object', not '‹arg1›' |
TKO0801 | error | '‹arg1›' does not take these arguments |
TKO0802 | error | '‹arg1›( string, ... )' cannot take a wide string; 'str( ... )' converts one |
TKO0803 | error | '‹arg1›( string, ... )' needs a var-len STRING, and this is ‹arg2› |
TKO0804 | error | '‹arg1›' to a pointer needs a pointer or a pointer-sized integer, and this is ‹arg2› |
TKO0805 | error | '‹arg1›' of a pointer needs a pointer or a pointer-sized integer type, and this names ‹arg2› |
TKO0825 | error | the tuple has ‹arg1› elements, and ‹arg2› are written |
TKO0826 | error | a tuple is expected here, and '‹arg1›' is not one |
TKO0827 | error | the jump to '‹arg1›' crosses a 'defer', which would still run when its scope ends |
TKO0828 | error | 'defer' needs a statement on the same line |
TKO0829 | error | generic instantiation of '‹arg1›' is too deep (the limit is ‹arg2›; -maxinstdepth raises it) |
TKO0830 | warning | suspicious pointer assignment: a '‹arg1›' from a '‹arg2›' |
TKO0831 | error | a '‹arg1›' does not convert to the tuple element '‹arg2›' |
TKO0832 | error | '‹arg1›( n, x )' takes a character code or a ‹arg2› string as x, not ‹arg3› |
TKO0833 | warning | passing different pointer types: a '‹arg2›' for a '‹arg1›' parameter |
TKO0834 | error | '‹arg1›' is already the name of an enum, which no variable may share |
TKO0835 | error | '‹arg1›' is a value of an enum type, not the enum, so it has no members |
TKO0871 | error | a '‹arg1›' has no printable form; '‹arg2›' cannot take it |
TKO0872 | error | '‹arg1›' needs an argument list |
TKO0873 | error | '‹arg1›' is a sub and has no value |
TKO0874 | error | a value alone is not a statement; expected '=' |
TKO0875 | error | property '‹arg1›' opens a statement with empty parentheses; expected an argument |
TKO0876 | error | this ‹arg1› cannot be empty |
TKO0877 | error | an anonymous ‹arg1› cannot sit directly inside a ‹arg1›; they must alternate |
TKO0878 | error | '‹arg1›' cannot hold itself: it is still being declared, and only a pointer to it may be a field |
TKO0879 | error | '‹arg1›' cannot be a module ‹arg2›: it is ‹arg3›, and only a public static member may be one |
TKO0880 | error | the jump to '‹arg1›' crosses the definition of '‹arg2›', which needs constructing |
TKO0881 | error | the jump to '‹arg1›' crosses the temporary holding ‹arg2›, which needs constructing |
TKO0882 | error | iif cannot build a '‹arg1›': it has constructors, but neither a default nor a copy one |
TKO0883 | error | GET cannot fill an array element reached through a function call; expected a pointer |
TKO0884 | error | a static member cannot be declared in an anonymous ‹arg1› |
TKO0885 | error | 'any' cannot leave a new STRING uninitialized |
TKO0886 | error | '‹arg1›' cannot be declared here: only a namespace nested inside this one may be named |
TKO0887 | error | invalid array subscript in '‹arg1›': a lower bound is above its upper bound |
TKO0888 | error | '‹arg1›' is a shared or static reference: it must be bound to a variable, or to a constant element of a fixed array |
TKO0889 | error | the definition of '‹arg1›' does not match its declaration in the type |
TKO0890 | error | '‹arg1›' cannot convert a '‹arg2›' |
TKO0891 | error | no constructor of the base takes ‹arg1› argument(s) |
TKO0892 | error | base( ) needs a value: the base type has no constructor |
TKO0893 | error | '‹arg1›' is a keyword and cannot name a type |
TKO0894 | error | '‹arg1›' is a COMMON array, which is dynamic and cannot be given bounds |
TKO0895 | error | '‹arg1›' has an ellipsis bound, which needs an initializer to count |
TKO0896 | error | '‹arg1›' is dynamic: a REDIM cannot take an ellipsis bound |
TKO0897 | error | '‹arg1›' is const, so it needs a value: '= any' leaves it unset |
TKO0898 | error | '‹arg1›' cannot be of type ANY: only a pointer to it can |
TKO0899 | error | '‹arg1›' is a typedef defined in terms of itself |
TKO0836 | error | an array result is written 'as T( )', with nothing between the parentheses |
TKO0837 | error | a procedure type cannot return an array; return it from a named procedure |
TKO0838 | error | this function returns an array: 'return' takes a brace list, a local array, or a call that returns an array of the same element type |
TKO0839 | error | a nested brace list cannot be returned as an array result; write a flat list |
TKO0841 | error | '‹arg1›' returns an array, so it must be called with parentheses: '‹arg1›( )' |
TKO0842 | error | this call returns an array, which cannot be converted to another type |
TKO0843 | error | '‹arg1›' cannot take an initializer list: its type has no Add, PushBack or Push method |
TKO0844 | error | an initializer list on the static member '‹arg1›' is not supported yet; fill it with calls after its definition |
TKO0845 | error | '‹arg1›' is not an array, so it cannot take a '{ ... }' initializer |
TKO0846 | error | a '{ ... }' list can only initialize an array, and nothing here is one |
TKO0847 | error | format string: ‹arg1› |
TKO0848 | error | format string: ‹arg1› |
TKO0849 | error | format string: ‹arg1› |
TKO0850 | error | format string: ‹arg1› |
TKO0851 | error | format string: ‹arg1› |
TKO0852 | error | format string: ‹arg1› |
TKO0853 | error | format string: ‹arg1› |
TKO0854 | error | format string: ‹arg1› |
TKO0855 | error | format string: ‹arg1› |
TKO0856 | error | format string: ‹arg1› |
TKO0857 | error | format string: ‹arg1› |
TKO0858 | error | format string: ‹arg1› |
TKO0840 | error | '‹arg1›' is not a local array of this procedure, so returning it would need a copy, which is not supported yet; return a local array or a brace list |
TKO0824 | error | a tuple cannot be a base type; name a type after 'extends' |
TKO0821 | error | a tuple needs at least two elements |
TKO0822 | error | the tuple element name '‹arg1›' is used twice |
TKO0823 | error | a tuple element cannot be 'any' or a fixed-length string |
TKO0820 | error | no operator '‹arg1›' takes these operand types |
TKO0819 | error | 'next' cannot name a FOR EACH variable: it is scoped to the loop body |
TKO0818 | error | 'for each byref' needs a byref Value( ), and '‹arg1›.Value( )' returns by value |
TKO0817 | error | the type parameters written for '‹arg1›' do not match its declaration, 'of ‹arg2›' |
TKO0815 | error | '‹arg1›' is generic; write its type arguments, as '‹arg1›( of ... )' |
TKO0816 | error | '‹arg1›' takes ‹arg2› type argument(s), and ‹arg3› are written here |
TKO0814 | error | 'defer' is not allowed at module level; put it in a procedure or a block |
TKO0813 | error | '‹arg1›' is already declared by the runtime library |
TKO0812 | error | 'spawn ‹arg1›' cannot pass its byref parameter '‹arg2›'; a string or UDT parameter is byref unless it is written 'byval' |
TKO0811 | error | '‹arg1›' is still incomplete here: it is declared below this use, and only a pointer to it may be used above that |
TKO0810 | error | '‹arg1›' is an incomplete type: nothing declares it, so only a pointer to it may be used here |
TKO0809 | error | cannot infer the type arguments of '‹arg1›'; write them, as '‹arg1›( of ... )( ... )' |
TKO0808 | error | the type '‹arg1›' is not declared |
TKO0807 | error | '‹arg1›' is a keyword and cannot name ‹arg2› |
TKO0806 | error | '‹arg1›' cannot be initialized from a ustring: no constructor of '‹arg2›' takes one, and a ustring does not convert to 'zstring ptr' here |
TKO0601 | error | '‹arg1›' is a ‹arg2›, not a property: call it as '‹arg1›( ... )' instead of assigning to it |
TKO0680 | error | property '‹arg1›' has no setter taking ‹arg2›, so it cannot be assigned this way |
TKO0859 | error | property '‹arg1›' is read without parentheses: '()' would be an index |
Notes (TKO0900–TKO0989)
| Id | Severity | Message |
|---|---|---|
TKO0962 | error | clang reported success but wrote no object '‹arg1›' |
TKO0908 | error | a constructor( ) chain call must be the first statement of the constructor |
TKO0909 | error | operator -> must return a UDT, by value or byref |
TKO0910 | error | a pointer does not fit in a '‹arg1›'; only a pointer-sized integer takes one |
TKO0911 | error | type<‹arg1›>( ) needs values: the type has no constructor to call |
TKO0912 | error | no constructor of '‹arg1›' takes these values: a type that extends OBJECT is not filled field by field |
TKO0913 | error | '‹arg1›' is an array of '‹arg2›', whose destructor is not cdecl; an array can only destroy through a cdecl one |
TKO0914 | error | '‹arg1›' is an array: GET # and PUT # take an element, or the whole array written '‹arg1›( )' |
TKO0915 | error | '‹arg1›' takes no name: the next thing is FOR or AS |
TKO0916 | error | this type has more than one conversion to a pointer, so the graphics statement cannot choose one |
TKO0917 | error | '‹arg1›' has no constructor, so '‹arg1›( ... )' is no call |
TKO0918 | error | a store through the address of a temporary: the value is gone at the end of the statement |
TKO0919 | error | '‹arg1›' is ambiguous: more than one USING in effect declares it; qualify it |
TKO0920 | error | '#pragma reserve (asm)' belongs at module level, not inside a block |
TKO0921 | error | the capturing lambda's parameter '‹arg1›' cannot be of the generic's type parameter '‹arg2›' |
TKO0922 | error | '‹arg1›' is not visible here: it is declared inside a namespace; qualify it |
TKO0923 | error | '‹arg1›' is no namespace declared before this USING |
TKO0924 | error | recursive #define: '‹arg1›' names itself in its own body |
TKO0925 | error | type( ... ) has no type to build here: write type |
TKO0926 | error | '‹arg1›' needs a copy constructor: its base has no constructor that takes no argument, so none can be made for it |
TKO0927 | error | this constructor of '‹arg1›' must call base( ... ): its base has no constructor that takes no argument |
TKO0928 | error | '‹arg1›' takes a number, not a '‹arg2›' |
TKO0930 | error | a whole array cannot be assigned or swapped: index it, as in 'a( i )' |
TKO0931 | warning | '=' is read as a comparison inside the argument of '‹arg1›', not as an assignment to its byref result; to assign, write '( ‹arg1›( ... ) ) = value' |
TKO0932 | error | '‹arg1›' returns its result by value, so a call to it cannot be assigned to |
TKO0933 | error | an 'any ptr' points at nothing with a type; cast it to a typed pointer before dereferencing it |
TKO0934 | error | '‹arg1›' is a property with only a setter: it can be assigned, not read |
TKO0935 | error | '‹arg1›' is not a const method, so a const object cannot call it |
TKO0936 | error | '‹arg1›' is not a procedure modifier; 'declare' takes const, static, virtual or abstract before the procedure keyword |
TKO0937 | error | '‹arg1›' needs an object, and '‹arg2›' has none: it is a static member |
TKO0938 | error | '‹arg1›' cannot take a '‹arg2›': convert it explicitly first |
TKO0939 | error | '‹arg1›' is a function: its result can be assigned only inside it |
TKO0941 | error | 'for each byref' cannot walk a string: a character has no stable address; walk it by value, or index it with 'for' |
TKO0942 | error | 'case timeout' and 'case else' cannot both appear in one 'select channel': 'else' fires first, so the timeout is never reached |
TKO0943 | error | 'spawn' needs a sub or function declared at module level, and '‹arg1›' is not one |
TKO0944 | error | a line number is not available in the fb dialect; name the line with a label |
TKO0945 | error | '‹arg1›' is already a constant or procedure of the enclosing scope, which no local may share |
TKO0946 | error | iif cannot choose between a ‹arg1› and a ‹arg2›: both results must be the same kind of string |
TKO0947 | error | '‹arg1›' is not inside ‹arg2› |
TKO0948 | error | there is no outer ‹arg2› for '‹arg1›' to leave |
TKO0949 | error | there is no label '‹arg1›' in this procedure |
TKO0950 | error | '*' dereferences a pointer, and this is not one |
TKO0951 | error | '@' takes the address of a variable, and a literal is not one |
TKO0952 | error | this procedure has no result, so its 'return' takes no value |
TKO0957 | error | 'as any' declares a parameter of any type; a definition must name the type |
TKO0958 | error | an 'operator cast' cannot convert to the type it is a member of |
TKO0959 | error | GET # and PUT # take no amount for a string or a whole array: its length is its own |
TKO0960 | error | the subscript ‹arg1› is outside the array's bounds, ‹arg2› to ‹arg3› |
TKO0961 | error | '‹arg1›' is already a namespace of the enclosing scope, which no local may share |
TKO0963 | error | a bare zstring or wstring is not passed or returned by value; use a pointer or byref |
TKO0964 | error | 'select case' cannot take a '‹arg1›'; select on a field or a conversion of it |
TKO0966 | error | a condition needs a number or a pointer, and this is a '‹arg1›' |
TKO0967 | error | 'line input' reads into a string, and this is a '‹arg1›' |
TKO0968 | warning | implicit conversion: a '‹arg1›' from a '‹arg2›'; cast( ) it if that is meant |
TKO0970 | warning | passing a '‹arg2›' for a '‹arg1›' parameter; cast( ) it if that is meant |
TKO0971 | error | '‹arg1›' is a type, not a value |
TKO0972 | error | '‹arg1›' has no member named '‹arg2›': a '‹arg3›' has no members |
TKO0973 | error | a typed variadic parameter ('...') must be the last parameter |
TKO0974 | error | a typed variadic parameter ('...') takes no default value |
TKO0975 | error | '‹arg1›' is declared with a typed variadic last parameter ('...') in one place and without it in another |
TKO0976 | error | a TYPE's 'extern' clause takes "c", not "‹arg1›" |
TKO0977 | error | 'select case as const' already has a case for the value ‹arg1› |
TKO0978 | error | 'select case as const' needs an integer subject, and this is a '‹arg1›' |
TKO0979 | warning | overflow in constant conversion: the case value ‹arg1› does not fit in a '‹arg2›' |
TKO0980 | error | '‹arg1›' cannot take a ustring source; convert it with str( ) first |
TKO0981 | error | '‹arg1›' is not supported on this target; read a variadic argument list with 'cva_list', 'cva_start' and 'cva_arg' |
TKO0982 | error | 'base( )' must be the first statement of the constructor |
TKO0983 | error | '‹arg1›' is ambiguous: more than one enum declares it; qualify it |
TKO0984 | warning | overflow in constant conversion: ‹arg1› does not fit in a '‹arg2›' |
TKO0985 | error | '‹arg1›' has a field with a default value, which an initializer list cannot fill |
TKO0965 | error | '‹arg1›' has a field with a constructor, which an initializer list cannot fill |
TKO0955 | error | 'end ‹arg1›' closes no '‹arg1›' block |
TKO0956 | error | a second 'case else' -- a select has one, and it comes last |
TKO0954 | error | the diagnostics file '‹arg1›' is one of the inputs, and writing it would destroy it |
TKO0953 | error | a FOR counter must be a scalar variable, not a field, element, dereference or byref parameter |
TKO0929 | error | type( ... ) cannot build a '‹arg1›': its field '‹arg2›' is a var-len string, which only a constructor can fill |
TKO0900 | note | ‹arg1› |
TKO0901 | note | declared here |
TKO0903 | note | in instantiation of '‹arg1›' |
TKO0902 | note | '‹arg1›' is required by interface '‹arg2›', declared here |
TKO0986 | note | in expansion of '‹arg1›' here |
TKO0905 | note | unclosed '‹arg1›' opened here |
TKO0906 | note | imported here |
TKO0969 | note | 'nullptr' converts to every pointer type equally; name the one meant, as 'cptr( T ptr, nullptr )' |
TKO0907 | note | did you mean '‹arg1›'? '#import "‹arg2›"' binds the name '‹arg1›' |
TKO0940 | note | '‹arg1›' is imported by another file of this module; an import belongs to the file that writes it |
Compiler limits and internal errors (TKO0990–TKO0999)
| Id | Severity | Message |
|---|---|---|
TKO0990 | error | too many errors; stopping |
TKO0991 | error | input exceeds a compiler limit: ‹arg1› |
TKO0992 | error | not implemented in this build: ‹arg1› |
TKO0993 | error | internal compiler error in emission: ‹arg1› |
TKO0994 | error | internal compiler error: this ‹arg2› was not compiled (‹arg1› in this module); the build stops rather than leave it out |
TKO0995 | warning | internal compiler error: this ‹arg2› was not compiled (‹arg1› in this module) and is left out of the build |
TKO0996 | error | '‹arg1›' is a bare ‹arg2›, which has no length; write '‹arg2› * N' or '‹arg2› ptr' |
TKO0997 | error | '‹arg1›' is a procedure, not a type |
TKO0998 | error | '‹arg1›' cannot be a member of a union: ‹arg2› |
TKO0999 | error | '‹arg1›' is not a static member; call it through an object of its type |
Interfaces (TKO1000–TKO1049)
| Id | Severity | Message |
|---|---|---|
TKO1000 | error | '‹arg1›' does not implement '‹arg2›.‹arg3›'; add 'implements ‹arg2›.‹arg3›' to one of its methods |
TKO1001 | error | '‹arg1›' cannot implement '‹arg2›': ‹arg3› differ |
TKO1002 | error | '‹arg1›' is not an interface |
TKO1003 | error | interface '‹arg1›' has no member named '‹arg2›' |
TKO1004 | error | '‹arg1›' implements '‹arg2›.‹arg3›', but does not list '‹arg2›' after 'implements' |
TKO1005 | error | '‹arg2›.‹arg3›' is implemented twice in '‹arg1›' |
TKO1006 | error | interface '‹arg1›' is listed more than once |
TKO1008 | error | interface '‹arg1›' inherits itself |
TKO1009 | error | '‹arg1›' does not convert to '‹arg2›'‹arg3› |
TKO1007 | error | interface '‹arg1›' cannot be created; create a type that implements it |
TKO1012 | error | an interface reference cannot refer to a temporary; store the object in a variable first |
TKO1013 | error | 'delete' cannot destroy through interface '‹arg1›'; cast to the object's type first |
TKO1010 | error | '‹arg1›' is ambiguous: both '‹arg2›' and '‹arg3›' declare it; cast to one of them |
TKO1015 | error | interface type '‹arg1›' cannot appear in a foreign declaration |
TKO1018 | warning | '‹arg1›' implements '‹arg2›.‹arg3›' with different default arguments; calls through '‹arg2›' use the interface's |
TKO1011 | error | '‹arg2›.‹arg3›' is already implemented by base type '‹arg1›'; override that method instead |
TKO1014 | error | a static method cannot implement an interface member |
TKO1016 | warning | '‹arg1›' is decided at compile time: it is always ‹arg2› |
TKO1021 | error | 'trycast' converts to an interface or to a pointer to a type, not to '‹arg1›' |
TKO1022 | error | type argument '‹arg1›' does not implement '‹arg2›', which '‹arg3›' requires |
TKO1023 | error | the argument of '‹arg1›' must be an array, not '‹arg2›' |
String slices (TKO1050–TKO1099)
| Id | Severity | Message |
|---|---|---|
TKO1050 | error | '‹arg1›' cannot be sliced: it is not a string, a string slice or a zstring/wstring pointer |
TKO1051 | error | slice ‹arg1› is outside '‹arg2›' (0 to ‹arg3›) |
TKO1052 | error | a const ‹arg1› cannot convert to a mutable ‹arg1› |
TKO1053 | error | a mutable ‹arg1› parameter needs a ‹arg2› variable, not '‹arg3›' |
TKO1054 | error | a ‹arg1› cannot be ‹arg2› |
TKO1055 | error | type '‹arg1›' holds slice field '‹arg2›' and must be declared 'as view' |
TKO1056 | error | cannot take the address of slice '‹arg1›' except to pass it |
TKO1057 | error | '‹arg1›' outlives '‹arg2›', which this slice borrows from |
TKO1058 | error | this slice borrows from a temporary that ends with the statement |
TKO1059 | error | cannot return a slice of ‹arg1› |
TKO1060 | error | ‹arg1› cannot capture slice '‹arg2›' |
TKO1061 | error | a slice stored through byref parameter '‹arg1›' must outlive the call |
TKO1062 | error | '‹arg1›' cannot be changed while slice '‹arg2›' borrows from it (last used at line ‹arg3›) |
Appendix C: Command-line reference
All 95 options of the pinned compiler, from the table that also drives tiko --help.
Environment variables
| Variable | What it does |
|---|---|
TIKO_CLANG | Full path to clang. Without it tiko looks at C:\Program Files\LLVM\bin\clang.exe on Windows or /usr/bin/clang on Linux, and searches PATH only when that file is absent. llvm-ar and llvm-rc are taken from the same directory. |
TIKO_INCLUDE | Replaces the default include directory (<exe>/inc). -i paths are searched first either way. |
TIKO_LIBPATH | Replaces the bundled library directory (<exe>/lib/<target>). -p paths and #libpath are searched first either way. |
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | errors in the program |
| 2 | command-line error |
| 3 | no usable toolchain (clang not found) |
| 4 | internal compiler error |
| 5 | clang or the linker failed |
How arguments are parsed
- An option matches exactly (
-g), as--option=value, or with the value attached (-O2,-Wno-TKO0201). - An optional value must be attached with
=:--dump-tokens=pp. --ends option parsing; everything after it is an input file.- Inputs are classified by extension:
.ois an object,.rcis a resource script, anything else is source. - A
#cmdline "..."line in the first source file restarts the compile with those options, and the source wins over the command line.
Subcommands
A subcommand is the first word on the command line. It builds a project from its tiko.toml, as Chapter 31 shows; every other command line is the compiler driver described by the rest of this appendix.
| Command | What it does |
|---|---|
tiko build | build the targets of tiko.toml, or the roots given -- cached |
tiko clean | remove what 'tiko build' would write |
tiko metadata | print the project as JSON: targets, profiles, modules |
tiko run | build one exe target, then run it with the arguments after -- |
tiko check | every diagnostic a build would give, through lowering; nothing written |
tiko c2t | translate a C header into a tiko header: tiko c2t <in.h> [-o <out.ti>|-] |
General
| Option | What it does |
|---|---|
--help[=‹category›] | show help; optionally for one category |
--version | show version and exit |
-v | echo every tool invocation |
-restart | accepted and ignored; a '#cmdline' restarts by itself |
--print-graph | print the modules the inputs import, in build order, and stop |
--emit-tki | print the interface of the module directory given, and stop |
--frontend-only | report every diagnostic a build would, through lowering; write no object, link nothing ('tiko check') |
--no-module-cache | build imported modules without reading or writing the module cache |
--clean-module-cache | empty the module cache and stop |
-z ‹option› | fbc extended option; accepted and ignored |
-maxinstdepth ‹n› | how deep one generic instantiation may drive another (default 64) |
Input
| Option | What it does |
|---|---|
-i ‹dir› | add an include search path |
--pkg-dir ‹dir› | the package tree for #import std/... is found in (else TIKO_PKG, else |
-d ‹name=val› | define a preprocessor symbol |
-p ‹dir› | add a library search path |
-l ‹name› | link against a library |
Output
| Option | What it does |
|---|---|
-Wl ‹a,b,c› | pass options to the linker, split at commas, as fbc's -Wl |
-norpath | no $ORIGIN run path when a Linux link takes a shared library |
-nocopylibs | do not copy vendor libraries or modules' runtime files beside the output |
-o ‹file› | output path |
-x ‹file› | the linked output's path, as fbc's -x: over -o, the last one winning; ignored with -c |
-c | compile only; do not link |
-dll | build a DLL rather than an executable |
-dylib | same as -dll |
-lib | build a static library |
-e | enable runtime error checking |
-ex | as -e: RESUME, which fbc adds here, does not exist in the fb dialect |
-exx | -ex plus array bounds and null-pointer checking |
-g | emit debug information |
-earray | check array bounds (one part of -exx) |
-enullptr | check pointer dereferences for null (one part of -exx) |
-elocation | record the procedure and module an error happens in (one part of -exx) |
-earraydims | check that an array is dimensioned, and with the rank used (one part of -exx) |
-eassert | enable assert and assertwarn (one part of -g) |
-edebug | define FB_DEBUG as -1 (one part of -g) |
-edebuginfo | emit debug information alone (one part of -g) |
-eunwind | unwind information (one part of -e; always emitted) |
-O‹level› | optimisation level 0-3 |
-mt | link the thread-safe runtime |
-pic | position-independent code, for a Unix shared library (Linux only) |
-m ‹name› | which module owns main; the others become module constructors |
Target
| Option | What it does |
|---|---|
-target ‹name› | windows-x86_64 (the Windows default), linux-x86_64 or macos-arm64 |
-m64 | 64-bit target (the only supported width) |
-m32 | rejected: 32-bit is not supported (x86-64 only) |
| `-s ‹console | gui›` |
-t ‹kbytes› | stack reserve and commit of a Windows executable or DLL, in KB (default 2048, minimum 64) |
Diagnostics
| Option | What it does |
|---|---|
-Werror | treat warnings as errors |
-Wno-‹id› | suppress one diagnostic id |
-w ‹level› | fbc's -w: all, pedantic, none, error, or n -- hide fbc's warnings below level n |
-fmax-errors ‹n› | stop after n errors |
--color ‹when› | always, never or auto |
--diagnostics-format ‹fmt› | fbc or gnu |
--diagnostics-file ‹path› | also write every diagnostic to |
--allow-unlowered | warn (TKO0995) instead of failing when a statement could not be compiled |
C header import (#cinclude and -cimport)
| Option | What it does |
|---|---|
-cinclude ‹dir› | add a C include directory (clang's -I) for #cinclude |
-cimport ‹header› | import a C header as a FreeBASIC binding; needs -cimport-out |
-cimport-out ‹file› | where -cimport writes the binding; - for standard output |
-cimport-manifest ‹file› | write clang's layout of every imported record |
-cdefine ‹name[=value]› | define a C macro (clang's -D) |
-csysroot ‹dir› | the C sysroot (clang's --sysroot) |
-copt ‹arg› | pass an argument to clang verbatim |
-cnomacros | do not import object-like macros as constants |
-cnoverify | do not check tiko's layout of each imported record against clang's |
-cnocache | #cinclude neither reads nor writes the binding cache |
-ccachedir ‹dir› | where #cinclude keeps its bindings (default: the user's cache directory) |
--print-cincludes | import each #cinclude the inputs reach, print where its binding is as JSON, and stop |
Developer and compiler introspection
| Option | What it does |
|---|---|
--dump-source | list loaded source files and stop |
| `--dump-tokens[=‹raw | pp›]` |
--lex-only | lex every input and report counts; no dump |
--pp-only | preprocess every input and report counts; no dump |
--dump-ast | dump the syntax tree |
--dump-types | dump the type table and its layout |
--dump-symbols | collect declarations and dump the symbol table |
--dump-scan | run the front end for an editor and dump its symbol records |
--resolve | collect and resolve; report reference binding |
--dump-mangle | print the emitted symbol name of each procedure |
--check | type expressions; report the type of each PRINT argument |
--dump-decisions | type expressions; list the overload Check chose for each node that implies one |
--dump-udt | print each UDT's size, alignment and field offsets |
--cimport-probe ‹header› | load libclang, parse one C header, print what clang saw |
--dump-vtable | print each UDT's virtual method table |
--dump-itable | print each interface's flattened members (docs/tiko-interfaces) |
--dump-rtl | print the runtime inventory and stop |
--parse-expr | parse each line as one expression and dump it |
--parse-only | parse every input and report counts; the corpus gate |
--dump-ir | dump MIR |
--dump-ir-pre-abi | dump MIR as the frontend made it, before the ABI pass |
--verify-selftest | run the MIR verifier over modules built to fail it |
--emit-ir | write LLVM IR and stop |
--emit-asm | write assembly and stop |
--keep-temps | retain intermediate files |
--stats | report counts and memory use |
--time-passes | report wall time per phase |
--build-only-unit ‹n› | internal to 'tiko build': compile root n alone into the cache, and link nothing |
--bootstrap-subset-check | reject constructs outside the self-hosting subset |
Appendix D: Coming from FreeBASIC
tiko compiles FreeBASIC. Its dialect is FreeBASIC 1.20's #lang "fb" plus tiko's extensions,
which are most of what Parts III to V of this book describe. The aim
is the same accept/reject decision as fbc and the same observable behaviour: what a program
prints, returns and does. Existing code moves over, and mixes freely with tiko code in one program.
This appendix is the list of what is different. Every row was measured on 2026-10-04 by compiling the same program with the toolchain this book is pinned to and with fbc 1.20 (Windows unless a row says otherwise), and comparing what each did. Where the two agree, the appendix says so too, because "does tiko do this the same way?" is the question it exists to answer.
Dialects and the features they carry
tiko implements one dialect, fb. A file that asks for another one is refused, not quietly
compiled as fb:
| Program | fbc | tiko |
|---|---|---|
#lang "qb", "fblite", "deprecated" | compiles in that dialect | TKO0317: unsupported language mode |
'$lang: "qb" | compiles as qb | TKO0317 |
-lang qb on the command line | compiles as qb | TKO0100: unknown option |
inline asm … end asm | compiles | TKO0407: inline assembly is not supported |
The features of the older dialects are refused by both compilers in -lang fb. fbc calls each
one error 146, "Only valid in -lang deprecated or fblite or qb", and tiko TKO0409 (TKO0944 for a
line number):
| Construct | Write instead (Chapter 32) |
|---|---|
gosub / return, on k gosub | a sub |
line numbers, 10 print 1 | a label, here: |
call Foo( ) | Foo( ) |
let x = 1 | x = 1 |
option explicit | nothing: fb is always explicit |
option base 1 | bounds written out, dim a( 1 to n ) |
defint a-z and the other def types | declare each variable's type |
'$include: 'x.bi', '$dynamic | #include "x.bi"; arrays are dynamic when declared so |
lprint | open lpt and print # |
threadcall
threadcall Proc( args ) is meant to start a thread for an ordinary procedure. tiko does not
implement it: it warns, the procedure does not run, and the handle is 0.
warning TKO0797: 'threadcall' is not supported: 'hello' will not run, and the thread handle is 0
The fbc build used for these measurements did not run the procedure either: it also
returned a handle of 0, without a warning. Use threadcreate with a parameter block, or spawn
(Chapter 23).
assert
The same in both compilers. assert( expr ) checks nothing unless the program is compiled with
-g or -eassert. When it does check and the expression is false, it prints the file, the line,
the procedure and the expression, and ends the program with exit code 1:
assert.bas(2): assertion failed at __FB_MAINPROC__: x = 3
The intrinsic defines
The defines a program can test with #ifdef, and their values. The same in both:
__FB_VERSION__ ("1.20.0"), __FB_LANG__ ("fb"), __FB_64BIT__, __FB_X86__,
__FB_WIN32__ on Windows and __FB_LINUX__ and __FB_UNIX__ on Linux, __FB_ASM__,
__FB_PCOS__ on Windows, __FB_FPU__, __FB_FPMODE__, __FB_DEBUG__, __FB_ERR__, __FB_MT__,
__FB_VER_MAJOR__, __FB_VER_MINOR__ and __FB_VER_PATCH__ (1, 20, 0), __FB_GUI__ (-1
under -s gui), __FB_OPTIMIZE__ (the -O level), __FB_PROFILE__ and __FB_VECTORIZE__ (0),
__FB_MAIN__ in the module that becomes main, __FB_ARGC__ and __FB_ARGV__ (main's own
parameters, usable in module-level code only), an empty __FB_BUILD_SHA1__, every
__FB_OPTION_…__ and __FB_OUT_…__, and the macro-like ones: __FB_ARG_COUNT__ and the other __FB_ARG_…__,
__FB_EVAL__, __FB_IIF__, __FB_JOIN__, __FB_QUOTE__, __FB_UNQUOTE__ and the
__FB_UNIQUEID…__ family.
Different:
| Define | fbc | tiko |
|---|---|---|
__TIKO_COMPILER__ | not defined | 1: how a source tree asks "is this tiko?" |
__TIKO_VERSION__ | not defined | "0.1.2" |
__FB_SIGNATURE__ | "FreeBASIC 1.20.0" | "tiko-lang" |
__FB_BACKEND__ | "gcc" | "llvm" |
__FB_GCC__ | -1 | 0 |
__FB_MIN_VERSION__( … ) | defined | not defined: #if __FB_MIN_VERSION__( 1, 20, 0 ) is TKO0308 |
__FB_BUILD_FORK_ID__ | the fork id fbc was built with | "tiko-lang" |
__FB_BUILD_DATE__, __FB_BUILD_DATE_ISO__ | the date fbc was built | the date tiko was built |
__FB_QUERY_SYMBOL__( … ) | defined | not defined: it queries fbc's symbol table while preprocessing, and tiko preprocesses before any symbol exists |
Command-line options
The options that mean the same thing in both compilers: -c, -d, -dll, -dylib, -e, -ex,
-exx, -g, -i, -l, -lib, -m, -mt, -p, -s, -v, -x, and the extension options
-maxinstdepth, -cinclude, -cdefine and -cimport (with -cimport-out). -z is accepted and
ignored.
Those spelled differently:
| fbc | tiko |
|---|---|
-o file after a source: that source's object file | -o file names the program too, when there is no -x |
-O 2 | -O2, the level joined to the option |
-w all, -w pedantic | -Werror, -Wno-TKOnnnn |
-maxerr n | -fmax-errors n |
-r, -rr, -R, -RR | --emit-ir, --emit-asm, --keep-temps |
-C | --keep-temps |
-pp | none: --pp-only preprocesses and reports counts without writing a file |
Everything else is TKO0100: unknown option, because tiko has one backend, one dialect and its own
linker driver: -a, -arch, -asm, -b, -export, -forcelang, -gen, -include, -lang,
-map, -nodeflibs, -noerrline, -nolib, -noobjinfo, -nostrip, -pic, -prefix,
-print, -profile, -profgen, -showincludes, -static, -strip, -sysroot, -t, -title,
-vec, -Wa, -Wc and -Wl. Appendix C lists tiko's own options.
Diagnostics
tiko's messages are its own. Each has a stable id, TKOnnnn (Appendix B), and an id is never
reused, so a build log stays meaningful. fbc's error numbers survive only where a running program
can see them, through err: the runtime errors are fbc's, number for number.
The usual mistakes, side by side:
| Mistake | fbc | tiko |
|---|---|---|
| a variable or procedure never declared | 42 Variable not declared | TKO0743 |
| a string assigned to a number | 181 Invalid assignment/conversion | TKO0702 |
| the wrong number of arguments | 1 Argument count mismatch | TKO0775 |
| the same name declared twice | 4 Duplicated definition | TKO0600 |
| a name expected, something else found | 14 Expected identifier | TKO0406 |
a block not closed: if without end if | 32 Expected 'END IF' | TKO0405 |
a for without next | 13 Expected 'NEXT' | TKO0401 |
| a member the type does not have | 18 Element not defined | TKO0739 |
| an unknown type name | 14 Expected identifier | TKO0808 |
| a constant division by zero | 100 Division by zero | TKO0671 |
| an include file not found | 23 File not found | TKO0301 |
redim of a fixed-size array | 54 Expected var-len array | TKO0723 |
| calling something that is not a procedure | 10 Expected '=' | TKO0772 |
| a string initialising a pointer | 24 Invalid data types | TKO0653 |
end function closing a sub | 125 Expected 'END SUB' | TKO0405 |
| a string returned from a numeric function | 181 Invalid assignment/conversion | TKO0702 |
exit for outside any for | 239 Illegal outside a FOR compound statement | TKO0947 |
exit for, for inside only one for | 247 No outer FOR compound statement found | TKO0948 |
goto to a label the procedure does not have | 48 Undefined label (104 at module level) | TKO0949 |
Other differences
- tiko accepts
#define run 5.runis a runtime procedure (Appendix A), and fbc refuses to define a macro over it, error 4. tiko defines it, andprint runprints 5. - A virtual call's object is evaluated once. fbc evaluates the object expression of a virtual
method call twice:
GetP( )->VM( )callsGetPtwice under fbc and once under tiko. Only a program whose object expression has a side effect can tell. - A
"""that ends its line starts a block string (Chapter 9). In fbc it is a string missing its closing quote, warning 12."""abc"""and""""mean what they always meant, in both. deferis a contextual keyword, and so are the other words in Appendix A's list. Where they are not in their statement's position they are ordinary names, as they are in fbc 1.20.- Properties do more. fbc allows at most one index, no
staticproperty, no property outside a type, and refusesswapon a property. tiko allows any number of indices,staticand namespace properties, auto-properties (get, private set), property blocks (Chapter 12),swap, and writes a property passedbyrefback through its setter, which fbc does not. - The ten fixed-width type names,
int8tofloat64, are predeclared typedefs. A program's own declaration of one replaces tiko's. - Graphics is Windows-only. The Linux and macOS toolchains ship no graphics library, so a
program that uses
screenbuilds on Windows only. - tiko targets x86-64 on Windows and Linux, and arm64 on macOS (
macos-arm64, Apple Silicon). There it defines__FB_DARWIN__,__FB_UNIX__and__FB_ARM__, and not__FB_X86__.-m32isTKO0104.
What fbc 1.20 does not have
Generics, for each, tuples, defer, lambdas, ustring, the fixed-width type names, spawn and
channels, #cinclude, #foreignlib, #import and modules, auto-properties and property blocks are tiko's. Code that uses them does
not compile under FreeBASIC's own fbc, and that is not a compatibility difference:
it is the language this book teaches. A source tree that must build with both tests
#ifdef __TIKO_COMPILER__ and keeps the extensions on one side of it.
Appendix F: Compiler Internals
This appendix is a map of the compiler for readers who want to know what happens between
tiko hello.tiko and hello.exe, or who want to read the source. tiko is written in tiko —
.tiko sources and .ti headers, built by tiko itself — and it lives in one repository,
tiko-lang, with its runtime, its graphics library and this book. The figures here were measured
at the commit the book is pinned to.
The pipeline
source ─► lexer ─► preprocessor ─► parser ─► Collect ─► Resolve ─► Check ─► late checks
│ │
AST typed AST + symbols
│
executable ◄─ clang + lld ◄─ clang -c ◄─ LLVM IR ◄─ ABI pass ◄─ MIR ◄─ lowering
Each stage is a separate pass over a separate representation, and each one hands the next a
finished product: the lexer does not ask the parser anything, and the backend never sees the AST.
-v shows the last two steps as the commands they are:
clang.exe -target x86_64-pc-windows-msvc -c "hello.exe.1.o.ll" -o "hello.exe.1.o" ...
clang.exe -target x86_64-pc-windows-msvc -fuse-ld=lld "hello.exe.1.o" -o "hello.exe" ... libfb.a ...
Lexer (src/lexer). Bytes to tokens, one file at a time. The keyword table, keywords.bi,
is the 247 words of Appendix A. A token is 16 bytes: its kind, flags (did a space precede it, did a
macro make it, was it a :), a source location, its length in the source and a payload — an
interned name or an index into the literal pool. A source file may be UTF-8, with or without a byte-order mark, or UTF-16 or UTF-32 with one;
anything else is read as plain bytes. Each file is detected on its own and converted to UTF-8
before it is lexed.
Preprocessor (src/pp). Pulls tokens from the lexer and gives the parser one stream: macros
expanded, #included files spliced in, #if branches chosen. It is also where #import is
handled: the imported module's interface — a .tki file, written in tiko source — is spliced in
as a frame of its own, with the headers its public declarations need (Chapter 15).
Parser (src/parser). Recursive descent, statement by statement, into an AST kept in an
arena: nodes are indices, not pointers. Operator precedence comes from one table, prectable.bi,
the only place in the compiler that states it. Nearly every diagnostic in this book with an id in
TKO0400–TKO0599 comes from here; the two exceptions are TKO0409, which the preprocessor
reports, and TKO0423, which comes from name resolution.
Collect, Resolve and Check (src/symbols, src/types). Collect enters every declaration into
the symbol table. Resolve binds every name to its symbol and works out every type, instantiating
generics as it meets them. Check judges what Resolve found: conversions, overloads, arguments,
assignments. A last group of passes — visibility, abstract types, override promises, the rules
about what may be initialised how — runs once everything is known. Together they are the largest
part of the frontend, and the source of most TKO06xx–TKO08xx diagnostics.
Lowering to MIR (src/mir). MIR is tiko's one intermediate representation: functions of basic
blocks, each a list of typed instructions ending in exactly one terminator. Lowering is where
FreeBASIC's semantics are eliminated — strings and their descriptors, dynamic arrays, print,
for's exact iteration rules, constructors and destructors, defer, closures, goroutine frames —
so that what comes out is plain code over plain values. A verifier checks every function before
it goes on. This is the biggest directory in the compiler.
The ABI pass (src/codegen). Rewrites each signature and call for the target's calling
convention — the Microsoft x64 convention on Windows, System V on Linux, Apple's AAPCS64 on
macOS — so the IR that follows
has no implicit ABI behaviour: which structures travel in registers, which by hidden pointer, how
a function returns one. --dump-ir shows MIR after this pass, so what will really be passed is
visible without reading LLVM.
LLVM IR and clang. The emitter writes LLVM IR as text, one .ll file per unit, and clang
compiles it. clang is not bundled: tiko runs whatever TIKO_CLANG names, else
C:\Program Files\LLVM\bin\clang.exe on Windows, /usr/bin/clang on Linux or Homebrew's
/opt/homebrew/opt/llvm/bin/clang on macOS, else the first
clang on the PATH, and it needs clang 18 or newer. The link
goes through clang with lld, or on Linux the system linker when there is no ld.lld; on macOS
it goes through Apple's linker with the macOS SDK.
-O1 to -O3 are clang's optimisations; tiko's own optimiser is deliberately thin.
The driver (src/driver). Reads the command line, decides what is being built, builds the
modules a program imports in dependency order, and runs everything above. A module is compiled
once and cached — its object and its interface, keyed by its sources, its options and the
interfaces of everything it imports — so a second build that
imports std/containers does not compile it again. #cmdline restarts the driver with the new
options. #cinclude (Chapter 29) loads libclang the first time a program uses it, to read the C
header itself.
Diagnostics (src/diag). Every message the compiler can emit is one row in one table,
diagids.bi, which is what Appendix B is generated from. Nothing else may invent a message, and an
id is never reused.
Where the code is
At the pin, counting source files (.tiko, .ti, and the few .bas, .c and .h files), tests
included:
| Directory | Files | Lines | Holds |
|---|---|---|---|
src/support, src/source | 18 | 2,437 | arenas, interning, text, files and source locations |
src/diag | 9 | 1,945 | the diagnostic table and its rendering |
src/lexer | 5 | 1,979 | the lexer and the keyword table |
src/pp | 15 | 8,402 | the preprocessor and #import |
src/parser, src/ast | 29 | 12,141 | the parser and the AST |
src/symbols, src/types | 126 | 47,419 | Collect, Resolve, Check and the late checks; the type table |
src/mir | 89 | 55,880 | MIR and lowering |
src/codegen | 21 | 6,213 | the ABI pass and the LLVM IR emitter |
src/driver | 43 | 9,980 | the driver, modules and linking |
src/build | 8 | 1,380 | tiko build |
src/cimport | 12 | 3,541 | #cinclude |
src/api | 2 | 397 | the parser library an editor loads |
lib/rt | 363 | 87,238 | tiko-rt, the runtime, and its tests |
lib/gfx | 99 | 23,450 | libtkogfx, the graphics library, and its A/B tools |
The compiler is about 152,000 lines; with its runtime and graphics library, about 262,000. The directories are layered: a module may include only modules below it, the frontend never includes anything from MIR or the backend, and a lint pass enforces that, along with a limit of 1,500 lines per source file.
The runtime: tiko-rt
A compiled program calls a runtime library for everything the language does that the machine does
not: strings, arrays, files, the console, errors, threads. tiko's is tiko-rt (lib/rt), and it
is a reimplementation of FreeBASIC's libfb written in tiko: 229 .tiko and .ti files and
nine small C files for what the language cannot express — atomics, the jump behind on error, the
start-up and shutdown hooks, the Microsoft C runtime's start-up glue, the base Object's
constructor and type record, and the two port I/O instructions behind inp and out on Linux.
It is ABI-compatible with libfb: the same FBSTRING and array descriptor layouts, the same
fb_* function names and signatures. That is what makes tiko's runtime errors fbc's, number for
number, and lets an object fbc compiled call into it. The toolchain ships two builds of it,
libfb.a and the thread-safe libfbmt.a, and the driver picks the second by itself when a program
creates a thread, whether or not -mt was given (Chapter 23). Linux adds position-independent
variants for shared libraries. tiko-rt is built with tiko, like the compiler, and is licensed
LGPL-2.1-or-later with the static-linking exception: a program tiko compiles may carry any licence.
The graphics library: libtkogfx
libtkogfx (lib/gfx) is FreeBASIC's graphics library, libfbgfx, reimplemented in tiko: the
same fb_Gfx* entry points, so it is a drop-in replacement. Its A/B tests (lib/gfx/tools) run
drawing primitives through both libraries and compare what they drew. It is
derived from FreeBASIC's gfxlib2, under the same LGPL licence and exception. It is built for
windows-x86_64 only, which is why graphics programs build on Windows only (Appendix D).
Self-hosting
tiko compiles itself. build/bootstrap.sh (and bootstrap.bat on Windows) runs the whole cycle:
stage0 the seed builds tiko -> stage1
stage1 compiles tiko's own sources -> stage2
stage2 compiles them again -> stage3
The seed is a packaged tiko compiler built from an earlier commit, which the repository pins;
no other compiler takes part.
The script asserts five things: that stage1 lowers every statement of every module (a
statement the compiler declines would otherwise vanish from the compiler it is building); that
stage2 and stage3 are byte-identical files, with nothing masked, which is a reproducible link
as well as a fixpoint; that stage1 and stage2 are identical too, because the seed builds stage1
with exactly the command stage1 builds stage2 with; that stage3 compiles a program that prints what
the seed's build of it prints; and, with --gate, that the test suite fails against stage2 exactly
what it fails against stage1. A successful run reports each step (the target line that opens it
is left out here):
bootstrap: stage1 built by <seed directory> in <t> s, <size> bytes
bootstrap: stage1 lowers every statement of all <n> modules; all verify
bootstrap: stage2 built by stage1 in <t> s, <size> bytes
bootstrap: stage3 built by stage2 in <t> s, <size> bytes
bootstrap: fixpoint stage1 == stage2 as well: the seed reproduces this commit's compiler
bootstrap: fixpoint stage2 == stage3, sha256 <first 16 hex digits>...
bootstrap: stage3 builds hello.tiko, and it prints what the seed's build prints
bootstrap: ok
A fixpoint proves the compiler reproduces itself. It proves nothing about code the compiler's own source never uses, which is why the last check runs the suite.
How it is tested
- The test suite: 2,046 tests at the pin, each a small program with its expected diagnostics, IR or output. A fix lands with the test that would have caught it.
- fbc's own test corpus, vendored in
tests/vendor/and never edited, is the acceptance gate: every file's diagnostics are recorded, and a change that moves any of them is examined before it lands. - Differential probes. Every compatibility fact in the compiler — and in this book — was established by compiling a program with fbc and with tiko and comparing the results. The source carries the measurement beside the code that depends on it, so a later change has to argue with it.
- This book's listings, compiled and run on both targets against the pinned toolchain before each release of the book.
Appendix G: Solutions
75 of the book's 202 exercises have a worked solution. Each is a program in the companion examples, beside the chapter's listings, under examples/chNN/solutions/, with its expected output in a .out file next to it. The solutions are not printed here: the listing harness compiles and runs every one of them on both targets, exactly as it does the chapters' listings, so the files are the version that is known to work.
A solution is one way to do the exercise, not the only one. Several build on a chapter's own listings or modules and import them, and run from their solutions/ directory. A solution marked blocked depends on a compiler fix that the toolchain this book is checked against does not have yet; its .out file is the result it gives once the fix is in.
| Exercise | Chapter | Task | Solution |
|---|---|---|---|
| 1.1 | Getting Started | Write a program that reports which compiler built it and which version that compiler is. | ch01/solutions/ex01-01.tiko |
| 1.2 | Getting Started | Make a program greet you by name and then exit with status 3, and check the status from your shell (echo $?, or echo %ERRORLEVEL% in cmd). | ch01/solutions/ex01-02.tiko |
| 1.3 | Getting Started | Call a procedure that was never declared, and note the identifier of the diagnostic. | ch01/solutions/ex01-03.tiko |
| 3.1 | A Tour of tiko | Report the longest word in the file as well as the most common ones. | ch03/solutions/ex03-01.tiko |
| 3.2 | A Tour of tiko | List the words that appear exactly once, in alphabetical order. | ch03/solutions/ex03-02.tiko |
| 4.1 | Types, Variables and Constants | Print the largest value each signed integer type can hold, then add one to the int32 maximum and explain what you see. | ch04/solutions/ex04-01.tiko |
| 4.2 | Types, Variables and Constants | Build a table comparing cint, int and fix over 2.4, 2.5, 2.6 and their negatives. | ch04/solutions/ex04-02.tiko |
| 5.1 | Operators and Expressions | Build a permissions value from three named bit positions, then read one back. | ch05/solutions/ex05-01.tiko |
| 5.2 | Operators and Expressions | Write expressions where parentheses change the answer, and one where they only make the existing grouping obvious. | ch05/solutions/ex05-02.tiko |
| 6.1 | Control Flow, Scope and defer | Write FizzBuzz for 1 to 15. | ch06/solutions/ex06-01.tiko |
| 6.2 | Control Flow, Scope and defer | Write a procedure that acquires something inside an if and defers its release, and a second that guards first and defers in the procedure body. | ch06/solutions/ex06-02.tiko |
| 7.1 | Procedures | Write MinMax, which takes three int32 values and reports the smallest and largest through byref parameters. | ch07/solutions/ex07-01.tiko |
| 7.2 | Procedures | Overload Describe for int32, float64 and ustring, then call it with an int16 and an int64. | ch07/solutions/ex07-02.tiko |
| 8.1 | Arrays | Write Reverse, which reverses an array in place and works whatever its bounds — test it on arrays indexed from 1, from 0 and from −2, and on an empty one. | ch08/solutions/ex08-01.tiko |
| 8.2 | Arrays | Transpose a two-dimensional array into a second array whose dimensions are the original's, swapped. | ch08/solutions/ex08-02.tiko |
| 9.1 | Strings and Text | Write TitleCase, which uppercases the first letter of each word and lowercases the rest, then check it against strings.MCase on a sentence, a shouted one, one with double spaces and an empty string. | ch09/solutions/ex09-01.tiko |
| 9.2 | Strings and Text | Parse a block of key = value lines into pairs, skipping blanks and lines with no =. | ch09/solutions/ex09-02.tiko |
| 10.1 | User-Defined Types and Layout | Declare a 16-byte record header — magic, version, flags, length, checksum — that must match a published binary format exactly, and have the program verify every offset against the spec instead of trusting it. | ch10/solutions/ex10-01.tiko |
| 10.2 | User-Defined Types and Layout | Take a type with six fields of mixed sizes declared carelessly, reorder them largest first, and print both sizes and what a million of each would cost. | ch10/solutions/ex10-02.tiko |
| 11.1 | Pointers, References and Memory | Write a growable int32 buffer that doubles its capacity when it fills, using reallocate, and report each growth. | ch11/solutions/ex11-01.tiko |
| 11.2 | Pointers, References and Memory | Reverse a singly linked list in place by changing the links, not the values. | ch11/solutions/ex11-02.tiko |
| 12.1 | Objects | Write an IntStack that keeps its items in memory from reallocate, with a constructor, destructor, copy constructor and operator let. | ch12/solutions/ex12-01.tiko |
| 12.2 | Objects | Write a Temperature that stores kelvin and exposes Celsius and Fahrenheit as read-write properties, with the absolute-zero check in exactly one place. | ch12/solutions/ex12-02.tiko |
| 13.1 | Operator Overloading | Write Money, stored in whole cents, with +, -, multiplication by a count, <, = and a cast to text like $15.30. | ch13/solutions/ex13-01.tiko |
| 13.2 | Operator Overloading | Write a Countdown type that a for loop counts down with, printing liftoff at zero. | ch13/solutions/ex13-02.tiko |
| 14.1 | Inheritance and Polymorphism | Add a Triangle with a base and a height to the shapes program, without changing Shape, Circle or the loop. | ch14/solutions/ex14-01.tiko |
| 14.2 | Inheritance and Polymorphism | Write a Counter plugin whose Apply appends the length of the text and counts its calls, and whose destructor prints the count. | ch14/solutions/ex14-02.tiko |
| 14.6 | Inheritance and Polymorphism | Add a Triangle to ifaceshapes.tiko. | ch14/solutions/ex14-03.tiko |
| 15.1 | Modules and Packages | Write a module temp with public CToF and FToC and a module-private rounding helper, and a program that prints a table from −10 to 30 degrees in steps of 10, converting each value there and back. | ch15/solutions/ex15-01.tiko |
| 15.2 | Modules and Packages | Write a module counter whose top-level code prints that it is initialising and whose destructor sub prints that it is done, with a public shared hits. | ch15/solutions/ex15-02.tiko |
| 16.1 | Namespaces, Headers and the Preprocessor | Write CUBE( x ) so that CUBE( 1 + 1 ) is 8 and 100 \ CUBE( 2 ) is 12. | ch16/solutions/ex16-01.tiko |
| 16.2 | Namespaces, Headers and the Preprocessor | Write a #macro DEFINE_COUNTER( name ) that defines a function Next<name> returning 1, 2, 3 … on successive calls, so that DEFINE_COUNTER( Order ) gives NextOrder( ). | ch16/solutions/ex16-02.tiko |
| 16.3 | Namespaces, Headers and the Preprocessor | Write a program that prints the name of the environment variable that holds the temporary directory: TEMP on Windows, TMPDIR on Linux. | ch16/solutions/ex16-03.tiko |
| 16.4 | Namespaces, Headers and the Preprocessor | Put a function Area in a namespace Disc (one radius) and another in a namespace Rect (width and height). | ch16/solutions/ex16-04.tiko |
| 17.1 | Generics | Write Queue( of T ) — first in, first out — with Enqueue, Dequeue and a Count property, and use it with two different element types. | ch17/solutions/ex17-01.tiko |
| 17.2 | Generics | Write Swap_( of T )( byref a as T, byref b as T ). | ch17/solutions/ex17-02.tiko |
| 17.3 | Generics | Make a Version_ type with major and minor fields usable as a Map key, so that two separately built values of the same version find the same entry. | ch17/solutions/ex17-03.tiko |
| 18.1 | Errors: Optional, Result and Runtime Checks | Write ToInt( s ) returning an Optional( of int32 ) that accepts digits with an optional leading minus and refuses anything else. | ch18/solutions/ex18-01.tiko |
| 18.2 | Errors: Optional, Result and Runtime Checks | Write Divide and Root, each returning a Result( of float64, ustring ), and a RootOfRatio( a, b ) that stops at the first failure and passes it on. | ch18/solutions/ex18-02.tiko |
| 19.1 | Testing, Debugging and Tooling | Write Clamp( v, lo, hi ) and an fbcunit suite for it with three tests: a value inside the range, values on both edges, and values outside on both sides. | ch19/solutions/ex19-01.tiko |
| 19.2 | Testing, Debugging and Tooling | Add TRACEVAL( x ) to trace.ti's family: it prints the expression as written, =, and its value. | ch19/solutions/ex19-02.tiko |
| 20.1 | Tuples, Iterators and For Each | Write DivMod( a, b ) returning the quotient and remainder as one tuple, and use it both destructured and whole. | ch20/solutions/ex20-01.tiko |
| 20.2 | Tuples, Iterators and For Each | Write a Range type holding from, upTo and stepBy, walkable by for each with a positive or negative step, and empty when the step points away from the limit. | ch20/solutions/ex20-02.tiko |
| 21.1 | Lambdas and Closures | Count the words in a containers.Array( of ustring ) that are at least minLen letters long, with ForEach and one closure. | ch21/solutions/ex21-01.tiko |
| 21.2 | Lambdas and Closures | Sort names by length, and names of equal length alphabetically, with one comparer. | ch21/solutions/ex21-02.tiko |
| 22.1 | Resource Management | Write a ScratchFile type that creates a file in its constructor and deletes it in its destructor, and cannot be copied. | ch22/solutions/ex22-01.tiko |
| 22.2 | Resource Management | Keep a WeakPtr( of Image ) as a cache entry. | ch22/solutions/ex22-02.tiko |
| 23.1 | Concurrency | Sum the integers 1 to 1,000,000 in four goroutines, one quarter each, and combine the partial sums through a channel. | ch23/solutions/ex23-01.tiko |
| 23.2 | Concurrency | Two producers send three numbers each into one channel. | ch23/solutions/ex23-02.tiko |
| 24.1 | Containers and Sorting | Count the words of a text in a Map and print the three most frequent, ties broken alphabetically so the output never varies. | ch24/solutions/ex24-01.tiko |
| 24.2 | Containers and Sorting | Keep the booked appointment times in an OrderedSet. | ch24/solutions/ex24-02.tiko |
| 25.1 | Files, Processes and the Environment | Walk a directory tree and count its files by extension, printing the counts in a stable order. | ch25/solutions/ex25-01.tiko |
| 25.2 | Files, Processes and the Environment | Write Sell( stock, record, n ) for the stock file of records.tiko: it reduces the quantity of record record by n and refuses, returning false, if there are not enough. | ch25/solutions/ex25-02.tiko |
| 26.1 | Dates and Times | Write WorkingDays( first, last ), the number of Monday-to-Friday days from first up to but not including last, and check it on a week, a month and a year. | ch26/solutions/ex26-01.tiko |
| 26.2 | Dates and Times | Read a list of lap times written as ISO 8601 durations, report any that do not parse, and print the total and the mean. | ch26/solutions/ex26-02.tiko |
| 27.1 | Numbers | Put 1000.00 in a savings account at 4% a year, credit and round the interest to cents each year, and print the balance for ten years in Decimal. | ch27/solutions/ex27-01.tiko |
| 27.2 | Numbers | Estimate π by throwing a million random points at a unit square and counting how many land inside the quarter circle. | ch27/solutions/ex27-02.tiko |
| 28.1 | Logging, Terminal UIs and SQLite | Store a list of sales in a table, then print each region's total and number of sales, largest first, with one SQL query. | ch28/solutions/ex28-01.tiko |
| 28.2 | Logging, Terminal UIs and SQLite | Set a logger's minimum level from a level name given on the command line, falling back to INFO for a name that is not a level. | ch28/solutions/ex28-02.tiko |
| 29.1 | Interfacing with C | Bind C's bsearch and use it to look values up in a sorted array, with a cdecl comparison function. | ch29/solutions/ex29-01.tiko |
| 29.2 | Interfacing with C | Bind strtol, using its end pointer to read every number at the start of a string and report where the numbers stopped. | ch29/solutions/ex29-02.tiko |
| 31.1 | Building and Shipping | Build a static library with Gcd and an Lcm built on it, and use it to find the smallest number that every number from 1 to 20 divides. | ch31/solutions/ex31-01.tiko |
| 31.2 | Building and Shipping | Write a program that reports, from the predefined symbols, whether it was built with error checking, with the thread-safe runtime, and against which C runtime. | ch31/solutions/ex31-02.tiko |
| 32.1 | Classic BASIC and Compatibility | Rewrite gosub.bas without gosub, so that it compiles. | ch32/solutions/ex32-01.bas |
| 32.2 | Classic BASIC and Compatibility | Read a price list from data statements until a sentinel item "END", printing each line and the total. | ch32/solutions/ex32-02.bas |
| 33.1 | Capstone: a CLI Search Tool | Write a program that uses the search module to print how many lines match in each file, as path: count. | ch33/solutions/ex33-01.tiko |
| 33.2 | Capstone: a CLI Search Tool | Add whole-word matching, so that dog matches the lazy dog. and hot-dog but not dogma or underdog. | ch33/solutions/ex33-02.tiko |
| 34.1 | Capstone: a Concurrent Pipeline | Write SameBytes( a, b ), which compares two files byte by byte, and check it on the groups that thash reports for sample/. | ch34/solutions/ex34-01.tiko |
| 34.2 | Capstone: a Concurrent Pipeline | Start two walkers, one for sample/docs and one for sample/photos, both sending to a single channel, and print every file name they find in sorted order. | ch34/solutions/ex34-02.tiko |
| 35.1 | Capstone: a TUI Ledger | Print the spending in each category over the whole ledger, the largest first. | ch35/solutions/ex35-01.tiko |
| 35.2 | Capstone: a TUI Ledger | Accept a quick-entry line, date;payee;category;amount, and check it with ParseTxn. | ch35/solutions/ex35-02.tiko |
| 36.1 | Capstone: a Notepad for Windows | Add Find Previous (Shift+F3) to textfile and to the Edit menu: the last match that ends at or before the start of the selection, wrapping around to the bottom. | ch36/solutions/ex36-01.tiko |
| 36.2 | Capstone: a Notepad for Windows | Show a word count in a fourth status-bar panel. | ch36/solutions/ex36-02.tiko |
| 37.1 | Capstone: a Cross-Platform Notepad | Make the status bar's column count characters, so that U+1F600 counts as one. | ch37/solutions/ex37-01.tiko |
| 37.2 | Capstone: a Cross-Platform Notepad | Make a new document use the line ending of the system it was started on, through a NewDocument function and #if, while a file that was read keeps its own. | ch37/solutions/ex37-02.tiko |