1 · Install and run#
There is no installer and no released binary. You build the compiler from
source, once, and then you have a sciencec that turns a file
into a native executable. This chapter ends with you running a program you
wrote.
What you need first
A Rust toolchain, because the compiler is written in Rust. And LLVM 18.1, because the backend calls LLVM-C directly and links against it. The version matters: the backend is written against 18.1's C API, and a different major version is a link error rather than a warning.
$ winget install LLVM.LLVM --version 18.1.8
That is the Windows command. On macOS it is
brew install llvm@18, and on Debian the LLVM apt repository's
llvm-18-dev. Whichever you use, note where it put its
bin directory — you need it on PATH
in a moment, and the failure when it is missing is silent.
Build the compiler
Two cargo build commands. The order does not matter and both
are needed. The first produces the runtime archive that every compiled
Science program links against. The second produces the compiler, with its
LLVM backend switched on.
$ git clone https://github.com/Angel-Zeledon/sciencec.git
$ cd sciencec
$ cargo build -p science-rt
Compiling science-rt v0.0.1 (/tmp/sciencec/crates/science-rt)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.60s
$ cargo build -p sciencec --features llvm
Compiling science-mir v0.0.1 (/tmp/sciencec/crates/science-mir)
Compiling science-codegen v0.0.1 (/tmp/sciencec/crates/science-codegen)
Compiling salsa-macros v0.28.2
Compiling science-regions v0.0.1 (/tmp/sciencec/crates/science-regions)
Compiling salsa v0.28.2
Compiling science-db v0.0.1 (/tmp/sciencec/crates/science-db)
Compiling sciencec v0.0.1 (/tmp/sciencec/crates/sciencec)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 58s
That is the tail of a build from an empty target/ on one
laptop: two minutes, with the rest of the workspace scrolling past above the
seven lines shown. Both artefacts land in target/debug/ —
sciencec, and beside it science_rt.lib, which is
where build looks for the runtime.
The two failures that cost an afternoon, and how each one looks
--features llvm is not the default. Without
it the backend compiles to a stub, and build stops at
SC0400 saying no backend is compiled in. That is deliberate:
the workspace has to build for a contributor who has no LLVM installed.
cargo build -p science-rt is a separate command
because Cargo builds a dependency's rlib and not its
staticlib. Building sciencec does not
produce science_rt.lib. Skip it and build stops
at SC0402 — the message lists the two directories it
searched and names the command that fixes it.
Put LLVM on PATH, before anything else
A sciencec built with --features llvm loads DLLs
out of LLVM's own bin directory. If that directory is not on
PATH the process exits 127 and prints nothing at
all — no diagnostic, no usage message, no clue. A script that
tests for the absence of an error will call that a pass.
$ export PATH="/c/Program Files/LLVM/bin:$PATH"
$ sciencec --version
sciencec 0.0.1
Check the exit code, never the absence of output.
sciencec check on a clean file prints nothing and exits 0, so
"no output" is the success case and the silent-failure case at once.
Everything on this page was verified by reading $?, which is
also how you should drive it from a script or a CI job.
Your first program
A file is a module, and its top-level statements are its program. There is
no main to declare and no ceremony to write. Put this in
hello.science:
print("hello, world")
# hello, world
$ sciencec check hello.science
$ echo $?
0
$ sciencec build hello.science
$ ./hello
hello, world
$ echo $?
0
The trailing # hello, world is this page's convention: a
comment at the end of a listing holds that program's real stdout. Comments
run from # to the end of the line. Where a listing has no such
comment it was checked and not run, and the chapter says why.
Write the script form, not main
A file may have top-level statements or a main, never
both — two answers to "what runs first" is an error. Both are
legitimate Science, and this tutorial writes every program in the
script form, for one blunt reason:
def main():
print("hello, world")
error[SC0400]: this `sciencec` cannot build an entry point whose `Error?` does not come back through `sret`; stage 1 only knows the two-word interface object
= note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error
That program checks clean and exits 0. It does not build, today,
because the backend's entry-point lowering has not caught up with the
declared main form. The script form has no such gap, so it is
the one every listing here uses — and it is also the shorter, better
thing to write for a program of this size.
Read this before chapter 2: check and build are far apart
sciencec check runs the entire front end — lexing,
parsing, name resolution, type checking, and the borrow checker. It is
complete enough that every listing on this page passes it.
sciencec build emits a real but bounded
subset: the backend admits a construct only once a program using
it has been built, linked, run and checked against its expected output,
and refuses everything else rather than emitting something nobody has
executed.
That rule was not free advice. Five silent miscompiles have been caught by
running the output rather than reading it. So a program can exit 0 from
check and be refused by build with an
SC0400 that names exactly what it could not lower. Six
listings on this page are in that state, each flagged where it appears.
When you hit one of your own, the compiler's message is more current than
anything written here.
2 · Values and bindings#
You cannot learn a language whose output you cannot see, so this chapter
gets you printing first. It introduces one word, be, that does
two jobs, and the string form that lets every later chapter show its work.
One word binds, and the same word assigns
let name be value introduces a name. name be value
assigns to one that already exists. = is not an assignment
operator in Science at all, and writing one is a parse error that names the
word you wanted.
A binding is immutable unless it says mutable. The type
annotation after the name is optional and inferred when you leave it off.
let celsius be 21.5
let kelvin be celsius + 273.15
print(f"{celsius} C is {kelvin} K")
let mutable samples: Int be 0
samples be samples + 1
samples be samples + 1
print(f"{samples} samples")
# 21.5 C is 294.65 K
# 2 samples
f"…" is an interpolating string: anything inside braces
is an expression, evaluated and rendered into the string. This tutorial
leans on it from here on, because it turns any expression into something you
can see.
Three things this chapter has to tell you, because you meet all three today
Print through f"…", always.
print(x) where x is not already a
String checks clean and does not build: print
takes borrowed any Display, and the backend emits no vtables
yet. print(f"{x}") goes through the interpolation builder
instead and works for every type on this page. It is why you will not see
a bare print(n) in any listing here.
mutable is not enforced yet.
let n be 0 followed by n be 3 checks, builds,
and prints 3. The keyword records your intent and a reader can
rely on it; the compiler does not yet. Write it correctly anyway —
the check is coming, and code written without it will not survive it.
Annotate a mutable number you intend to do arithmetic on.
let mutable n be 3 then n be n - 1 checks clean
and then fails to build: the literal's type is left unresolved
and the backend refuses a value whose type it does not have.
let mutable n: Int be 3 builds. That is why every counter on
this page carries its type.
The primitive types, and the literals that reach them
Science has twelve numeric types and no implicit conversion between any two
of them. A literal takes its type from a suffix, from an annotation, or from
what it is used with. Converting is always written out, with
as, including when it loses precision.
let counted be 1_000_000
let mask be 0xFF
let flags be 0b1010
let small be 7u8
let precise be 2.5f32
let avogadro be 6.02e23
let widened be small as I64
let truncated be 2.9 as I64
print(f"{counted} {mask} {flags} {small} {precise} {avogadro}")
print(f"{widened} {truncated}")
# 1000000 255 10 7 2.5 6.02e23
# 7 2
let label be "station-7"
let ready be true
let initial be 's'
print(f"{label} {ready} {initial}")
# station-7 true s
| Type | Suffix | What it is |
|---|---|---|
| I8 I16 I32 I64 | i8 i16 i32 i64 | Signed integers. Int is another name for I64. |
| U8 U16 U32 U64 | u8 u16 u32 u64 | Unsigned integers. |
| F32 F64 | f32 f64 | Floats. Float is another name for F64. |
| F16 BF16 | — | Half precision. No suffix; reached through an annotation. |
| Bool | — | true and false. |
| Char | — | One Unicode scalar, in single quotes: 'x'. |
| String | — | An owned, growable string. Literals are "…". |
| () | — | Unit. One value, written (). What a function with no result returns. |
Two wrinkles worth knowing before they cost you ten minutes.
Int and I64 are the same type but are not yet
interchangeable everywhere in the checker: a function declared
-> I64 whose body returns a String.length() is
SC0525, because length is declared
Int — match the spelling the other side uses. And in the
backend the aliases are not equal either: a value annotated
F64 builds where the same value annotated Float
does not. The String methods —
String.new(), push_str, length,
is_empty — all check and none of them build yet, which is
why every string on this page is a literal.
Where Python is better at this, and it is not close
Python
counted = 1_000_000 small = 7 huge = 2 ** 200 # exact share = counted / 7
Science
let counted be 1_000_000 let small be 7u8 # no arbitrary precision let share be counted / 7
Python has one integer type and one float type. The integer is arbitrary
precision, so 2 ** 200 is exact and nothing overflows, and
there is no suffix to learn and no as to write. For a script
that adds two numbers together, Python is strictly less to know and
cannot get the wrong answer by silent truncation. That is a real
advantage and not a small one.
What Science buys with the twelve types is that an F32 array is
four bytes an element because you said so, and a U8 pixel
buffer is one. That trade is only worth taking when the size of the thing in
memory is part of the problem you are solving. If it is not, Python is the
better tool, and this page would rather say so than pretend otherwise.
Three mistakes you will make in the first hour
Each of these is a real diagnostic, produced by writing the program and
running check. Read the help: line — where a
mechanical fix exists, the compiler carries the replacement text, so an
editor or an agent can apply it without reading the prose.
let n be 3
if n == 3:
print("three")
error[SC0016]: equality is written `is`
--> equality.science:2:6
|
2 | if n == 3:
| ^^ `==` is not an operator in Science
|
= note: there is one spelling for each comparison: `is` and `is not` for identity, and `>`, `<`, `>=`, `<=` for order
= help: write the word instead: `is`
1 error
Identity is written with words and order with symbols, and neither has an
alternative. a is b, a is not b,
a > b, a <= b. Mixing them in one expression
is normal and correct: if name is not "" and hits >= 10:.
let label be "station-7"
print(f"{label.length}")
error[SC0528]: `String` has no field `length`
--> field.science:2:16
|
2 | print(f"{label.length}")
| ^^^^^^ no such field
1 error
Parentheses mean call; their absence means a field. There
is no uniform access in Science and no property syntax:
label.length() is a method call, label.length is a
field access, and they are different things the compiler will not guess
between. This rule is absolute, and it is the one Python and JavaScript
arrivals trip over most.
let shape be 3
print(f"{shape}")
error[SC0102]: expected an identifier, found `shape`, which is reserved for a later phase
--> reserved.science:1:5
|
1 | let shape be 3
| ^^^^^ expected an identifier, found `shape`, which is reserved for a later phase
error[SC0105]: expected an expression, found `shape`, which is reserved for a later phase
--> reserved.science:2:10
|
2 | print(f"{shape}")
| ^^^^^ expected an expression, found `shape`, which is reserved for a later phase
2 errors
Some words are held for phases the language has not reached. The ones you
are most likely to reach for in this domain are shape,
model, tensor, mod,
kernel, yield, union,
assert and import. Write dims,
weights, values, remainder,
window, produced, merged,
check. Deliberately not reserved, because they are
what this audience actually types: grad, dim,
dims, axis, device,
dtype, unit, alias. The full tables
are on the syntax page.
3 · Control flow#
Blocks are indentation, opened by a :. There is no
while. There are two loops and a conditional, and the
conditional is an expression — which removes most of the reasons you
would otherwise reach for a mutable variable.
if, and the inline form
An if may have an indented body, or a body written inline after
the :. The inline body ends where the expression ends,
not at the end of the line — which is why if a: b else: c
parses at all. else cannot continue an expression, so the
then-branch is finished the moment the parser meets it. "Fits on one line" is
a consequence of that rule, not the rule itself.
An if has a value, so it can be the whole body of a function.
def band(celsius: F64) -> I64:
if celsius < 0.0:
return 0i64
if celsius < 25.0: 1i64 else: 2i64
print(f"{band(-4.0)} {band(21.5)} {band(40.0)}")
# 0 1 2
An inline body may be an expression, an assignment, or return,
break or continue. It may not be
a let, which would bind a name nothing could then use. A
dangling else binds to the innermost if, and that
is a language rule rather than an artefact of how the parser is written.
loop, break, continue
loop: runs its body until something breaks out. It takes no
condition. A condition goes in the body, negated, as the first statement.
let mutable reading: Int be 5
loop:
if reading <= 0:
break
if reading is 3:
reading be reading - 1
continue
print(f"reading {reading}")
reading be reading - 1
print("done")
# reading 5
# reading 4
# reading 2
# reading 1
# done
Where while would have been better, and it would have
Python
while reading > 0:
print(reading)
reading -= 1
Science
loop:
if reading <= 0:
break
print(f"{reading}")
reading be reading - 1
Python's header states the loop's condition in the one place a reader looks for it, in the positive, in four words. Science spends three lines, buries the condition one line inside the body, and makes you negate it — and negating a condition is where off-by-one bugs come from. For this loop Python is plainly better and nothing about the Science version is an improvement.
What loop buys is that there is exactly one unbounded loop
form, so a reader never has to work out whether a given loop tests at the
top or the bottom, and a loop that is really a count is pushed into the
range form below, where it belongs. Whether that is worth three lines is a
judgement. The compiler at least tells you which word it wants:
let mutable i: Int be 0
while i < 3:
i be i + 1
error[SC0142]: the unbounded loop is written `loop`
--> loopword.science:2:1
|
2 | while i < 3:
| ^^^^^^^^^^^ `while` is not a keyword in Science, and `loop` takes no condition
|
= note: end it with the condition negated: make `if not …: break` the first statement of the body, or write a count as a range, `for i in 0..n:`
= help: open the loop with: `loop`
1 error
for … in, over a range or a collection
A count is a range. 0..n is half-open and stops one short of
n; 0..=n includes it. Either end may be any
expression. The same for walks a collection.
let mutable total: Int be 0
for i in 0..5:
total be total + i
print(f"{total}")
That listing checks and does not build — the second of six on this page
sciencec check exits 0 on it. sciencec build
refuses it, because the front end leaves the loop's range and iterator
temporaries untyped and the backend will not emit a value whose type it
does not have. Verbatim:
error[SC0400]: this `sciencec` cannot build a value the front end left untyped: its `Ty` is `TyKind::Error` and no diagnostic was reported for it. The three this compiler has met are a call to `print` or `write`, whose signatures `science-resolve`'s `builtins.rs` withdrew; a `for` loop's range and iterator temporaries; and a discriminant temporary, which is handled rather than refused
= note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error
That note is worth reading once, slowly. It is the boundary this whole page
is written against, and it is the compiler's own list rather than somebody's
summary of one. Until the range temporaries are typed, write a
counting loop as a loop with an annotated counter
— which is what the listing above it does, and that one builds and
runs.
break and continue end or skip the innermost loop,
and read the same over a range as over a collection. There is no labelled
break.
4 · Functions#
def declares a function. A parameter's type is mandatory; the
return type follows -> and is omitted when there is nothing
to return. The body's last expression is the function's value, and
return exists for leaving early.
The four things a signature says
Read def kelvin(celsius: F64) -> F64: as: the name, the
parameters with their types, what comes back, and — from
chapter 7 onwards — whether each parameter is
taken or borrowed. That last piece is what makes a Science signature carry
more than most, and it is why borrowed appears below before it
has been explained.
def kelvin(celsius: F64) -> F64:
celsius + 273.15
def announce(label: borrowed String):
print(f"--- {label} ---")
def safe_band(celsius: F64) -> I64:
if celsius < -273.15:
return -1i64
if celsius < 25.0: 1i64 else: 2i64
announce("readings")
print(f"{kelvin(21.5)}")
print(f"{safe_band(-300.0)} {safe_band(21.5)}")
# --- readings ---
# 294.65
# -1 1
announce has no ->, so it returns unit,
(). Writing -> () means the same thing.
kelvin has no return: its last expression is its
value, which is the ordinary way to write a function that computes one
thing.
A function cannot read a script binding. The top-level
statements of a file are a body of their own, and the names they bind are
local to it; a def sees other items — functions, types,
choices, constants — and its own parameters, and nothing else. That is
why everything a function needs above is passed in.
The strict () rule
This came up in chapter 2 as a mistake. Here is the rule it comes from.
A call always takes parentheses, and their absence always means a
field. text.length() calls a method;
doc.title reads a field; doc.title() would call a
method named title, which is a different thing that may not
exist. There is no form in which the parentheses are optional — not
for a zero-argument method, not for a method used as a value, not anywhere.
The payoff is that you can tell from the call site alone, with no knowledge of the type, whether a line runs code. That matters for reading code you did not write, which is most code.
Where Ruby is better at this
Ruby
class Doc
attr_reader :title
def width = title.length
end
doc.title # a field today
doc.width # a method today
# callers cannot tell,
# and do not need to
Science
type Doc:
title: String
Doc has:
def width(self) -> Int:
self.title.length()
doc.title # a field, forever
doc.width() # a method, forever
Ruby's uniform access principle is a genuinely better property for a
library author. A field can become a computed method without
touching a single caller, so the public surface of a type is not frozen by
the day-one decision about how a value is stored. In Science that change is
a breaking change: every doc.title has to become
doc.title().
Science takes the other side because the cost lands on a different person. Ruby moves it from the library author to every reader, who now cannot tell whether a line is a memory read or an arbitrary amount of work — which in numerical code, inside a loop, is the difference that matters. Neither answer is free. This one is at least visible from the call site.
5 · Your own types#
Two declarations cover the shapes data takes. type is a record
— a value with named fields, all present at once. choice
is a sum — a value that is exactly one of several named cases. A
third, has, hangs functions off a type.
type: a record
Fields are declared one per line under the name. Construction takes
named arguments, all of them mandatory, in any order. There
is no positional form and no partial construction, so a record that exists
has every field. A field is read with . and no parentheses.
choice: one of several cases
Variants are listed one per line. A variant may carry a positional payload
or nothing at all. It may be named bare or qualified by its type —
Ground and Station.Ground mean the same thing, and
the qualified form is what disambiguates when two choices in scope share a
variant name.
type Reading:
sample: I64
celsius: F64
choice Station:
Ground
Balloon(F64)
Buoy(F64, F64)
def kelvin(r: borrowed Reading) -> F64:
r.celsius + 273.15
def altitude(station: Station) -> F64:
match station:
Ground: 0.0
Balloon(metres): metres
Buoy(latitude, longitude): 0.0
let r be Reading(sample: 1i64, celsius: 21.5)
print(f"sample {r.sample} at {r.celsius} C = {kelvin(r)} K")
print(f"{altitude(Balloon(1200.0))} {altitude(Ground)}")
# sample 1 at 21.5 C = 294.65 K
# 1200.0 0.0
match is the next chapter. It appears here because there is no
other way to get at a choice's payload, and a chapter that
introduced choice without showing you how to read one would be
teaching half a construct.
has: methods and associated functions
A Type has: block holds functions that belong to the type and
come from no interface. One whose first parameter is self is a
method, called as value.name(). One with no self
is an associated function, called through the type as
Type.name() — which is how a type gets a constructor.
type Reading:
sample: I64
celsius: F64
Reading has:
def at(sample: I64, celsius: F64) -> Reading:
Reading(sample: sample, celsius: celsius)
def kelvin(self) -> F64:
self.celsius + 273.15
let r be Reading.at(1i64, 21.5)
print(f"{r.kelvin()}")
That listing checks and does not build — the third of six
Methods are the construct being worked on right now, and today the
backend cannot resolve the receiver. check exits 0 —
the method is found, the receiver is typed, the borrow checker runs
— and build stops here:
error[SC0400]: this `sciencec` cannot build a call to the method `kelvin`: its receiver is a `Self` this crate cannot resolve to a concrete type, and Decision 11's method lookup does not put what it found in the tree
= note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error
Write methods anyway — they are the right way to
organise a type and they type-check today. But if you want a program that
runs this week, a free function taking borrowed Reading does
the same job and builds, which is why the listing above it is written that
way.
A record whose fields are numbers builds and runs; one with a
String field does not. Storing a string into a record
needs an owned allocation the backend has no slot for yet. It
checks clean. Every record on this page is numeric for that reason,
not because records are meant to be.
| Written | Means |
|---|---|
| type Reading: | A record. Fields indented under it, one per line. |
| type Celsius is F64 | A type alias. A different declaration, told apart by is. |
| choice Station: | A sum type. Variants indented under it, one per line. |
| Reading(sample: 1i64, celsius: 21.5) | Construction. Named, mandatory, any order. |
| Balloon(1200.0) | A variant with a payload. Positional, because a variant's payload is. |
| Reading has: | Inherent functions. A self first parameter makes it a method. |
| type Pair of (A, B): | Generic parameters. Two or more take parentheses; see chapter 9. |
6 · match, patterns, exhaustiveness#
match takes a value and a list of patterns and produces the
body of the first pattern that fits. It is an expression, so it has a value.
And it is exhaustive: if there is a value no arm covers,
that is a compile error rather than a silent fall-through, and the error
names the value you forgot.
The pattern forms
A pattern is a variant with its payload bound to names, a literal, a tuple
of patterns, a record with its fields named, a bare name that catches
everything and binds it, or _ which catches everything and
binds nothing. Alternatives are joined with |. An arm's body is
inline after the :, or indented under it.
choice Station:
Ground
Balloon(F64)
Buoy(F64, F64)
def altitude(station: Station) -> F64:
match station:
Ground: 0.0
Balloon(metres): metres
Buoy(latitude, longitude): 0.0
def quadrant(point: (I64, I64)) -> I64:
match point:
(0, 0): 0i64
(x, 0): 1i64
(0, y): 2i64
(_, _): 3i64
def band(n: I64) -> I64:
match n:
0 | 1 | 2: 0i64
3 | 4: 1i64
_: 2i64
print(f"{altitude(Balloon(1200.0))}")
print(f"{quadrant((0i64, 3i64))}")
print(f"{band(4i64)} {band(9i64)}")
# 1200.0
# 2
# 1 2
Both altitude functions on this page take Station
by value rather than borrowed Station, and that is the
backend's constraint rather than the language's: a match on a
borrowed choice checks clean and does not build, because reading the
discriminant through a borrow is a lowering the backend has not run. Take
the choice by value until it does.
What the compiler says when an arm is missing
Delete the Buoy arm and the program stops compiling. The
diagnostic does not say "non-exhaustive"; it names the shape of the value
that has no answer, so you can paste it back in as the arm you need.
choice Station:
Ground
Balloon(F64)
Buoy(F64, F64)
def altitude(station: Station) -> F64:
match station:
Ground: 0.0
Balloon(metres): metres
print(f"{altitude(Ground)}")
error[SC0250]: `Station` has a value no arm of this `match` covers: `Buoy(_, _)`
--> missing_arm.science:7:11
|
7 | match station:
| ^^^^^^^ this is `Station`
|
= note: a `match` answers for every value or the program has no answer for the rest (§4.5); write an arm for `Buoy(_, _)` — or a `_` arm, which covers everything the arms above leave
1 error
This is the property that makes choice worth
using. Add a fourth variant to Station six months from
now and every match in the program that does not handle it
fails to compile, by name, with a file and a line. A _ arm
switches that off, which is sometimes right and is always a decision you
made on purpose.
Where Rust is better: there are no pattern guards
Rust
match reading {
n if n < 0 => "below",
n if n < 25 => "mild",
_ => "hot",
}
Science
match reading:
n:
if n < 0: "below"
else: if n < 25: "mild"
else: "hot"
Rust lets a condition live in the pattern column, so a match
stays a table you read down: every line is test on the left, result on
the right. Science has no guard — n if n > 0: is a
parse error — so the condition moves into the arm body and the table
collapses into one arm with an if chain inside it.
For a match whose cases are numeric ranges rather than variants,
Rust reads better, and the Science version is worse than the plain
if chain it is pretending not to be.
There is no @ binding either, so you cannot name the whole
value and destructure it in the same pattern. Both absences are on the
syntax page's list, with the diagnostic each one currently produces.
An arm may also not produce a value at all. panic("…") has type
Never, which fits wherever a value is wanted, so one arm can
bail out while the others produce an F64.
7 · Ownership#
This is the chapter the language is for. Science has no garbage collector and no reference counting: every value has exactly one owner, and the compiler releases it at a program point it can name. What is unusual is not that rule — Rust has it — but that you never write a region, ever, anywhere. There is no lifetime syntax in the grammar to write, so there is nothing to elide and nothing to get wrong.
The rules, in the order you will meet them
- Every value has one owner.
- Assigning, passing or returning a value moves it: the old name is empty afterwards.
- A type that implements
Copyis duplicated instead of moved. - A value may be borrowed shared any number of times, or exclusively once, never both at once.
- No borrow outlives what it points at.
- Leaving scope destroys the value, running
Dropif it has one.
A move, and what it costs you
A String owns a heap allocation, so passing one to a function
hands over that allocation. The function now owns it, and the caller's name
is empty. Here is the same call written twice:
def announce(label: String):
print(f"--- {label} ---")
let label be "station-7"
announce(label)
announce(label)
error[SC0301]: use of a moved value
--> moved.science:6:1
|
5 | announce(label)
| --------------- `label` is moved here
6 | announce(label)
| ^^^^^^^^^^^^^^^ ...and moved again here, after the move
|
= note: every value has one owner, and assigning, passing or returning it transfers that owner (§6.1 rules 1 and 2), so the name is empty afterwards (rule 3)
= note: borrow at the earlier use instead of moving, or give the second use a value of its own; a type that is not `Copy` is never copied implicitly
1 error
Read the three parts. The dashes on line 5 are where
ownership left. The carets on line 6 are where it was needed again. The
first note states the rule that was broken and cites it. The second offers
the two fixes, and the first of them is the one you want here:
announce does not need to own the label, it only needs to read
it. Say so in the signature.
def announce(label: borrowed String):
print(f"--- {label} ---")
let label be "station-7"
announce(label)
announce(label)
# --- station-7 ---
# --- station-7 ---
One word changed, in one place, and it is in the signature rather
than at either call site. That is the second half of the design:
a parameter declared borrowed is borrowed for you at
the call site. You write announce(label), not
announce(borrowed label). The signature still says which it is,
so a reader keeps the information and only the noise goes. You write
borrowed yourself where the position is not a call site —
constructing a record that holds a borrow, for instance, or binding one
directly.
Shared and exclusive
borrowed T is a shared borrow: read-only, and any number may
coexist. mutable borrowed T is exclusive: it may write, and
while it is alive nothing else may touch the value at all. Assignment
through a borrow is written plainly, because Science has no dereference
operator — r.celsius be r.celsius + 1i64 reads and writes
through the borrow with nothing written for it.
type Reading:
celsius: I64
def warm(r: mutable borrowed Reading):
r.celsius be r.celsius + 1i64
def report(r: borrowed Reading) -> I64:
r.celsius
let mutable r be Reading(celsius: 21i64)
warm(r)
warm(r)
print(f"{report(r)}")
# 23
The error that settles the whole bet
Rule 4 is the one that is hard to get right without annotations, so it is
the one worth watching the compiler enforce. This program takes a shared
borrow of r, then writes through r itself while
that borrow is still going to be used.
type Reading:
celsius: I64
let mutable r be Reading(celsius: 21i64)
let snapshot be borrowed r
r.celsius be 22i64
print(f"{snapshot.celsius}")
error[SC0330]: `r` is borrowed here and modified before the borrow ends
--> overlap.science:6:14
|
5 | let snapshot be borrowed r
| ---------- `r` is borrowed here, shared
6 | r.celsius be 22i64
| ^^^^^ ...and modified here, which needs it exclusively
7 | print(f"{snapshot.celsius}")
| --------------------- the first borrow is still needed here
|
= note: a shared borrow and an exclusive one cannot overlap (§6.1 rule 4)
= note: the borrow ends at its last use, so moving the last use above the conflicting line is enough; binding what is needed before it is the other fix
1 error
Read this one line by line, because it is the whole argument.
-
Line 5 is where the borrow is born. Not where
ris declared — where this particular borrow starts. Nothing in the source says so; the compiler worked it out. - Line 6 is the violation, and the caret is on the write, not on a second borrow. Writing a place something else holds shared is what rule 4 forbids, and the message names which kind of access each side needed.
- Line 7 is the load-bearing one. "The first borrow is still needed here" is why line 6 is an error at all. A borrow ends at its last use, not at the end of the block, so the compiler had to find that last use before it could know the two overlapped. Delete line 7 and the program compiles.
- The fix is a reordering, and the compiler says which one. Move the last use above the conflicting line. That is not generated from a template; it is a consequence of the same analysis that produced the third span.
type Reading:
celsius: I64
let mutable r be Reading(celsius: 21i64)
let snapshot be borrowed r
print(f"{snapshot.celsius}")
r.celsius be 22i64
print(f"{r.celsius}")
# 21
# 22
Now count the annotations in everything above: there are
none. No 'a, no <'a, 'b>, no
where 'b: 'a. Three spans across three lines, a rule citation
and a mechanical fix — and the only thing you wrote was the word
borrowed in a signature. That is the claim the language is
built on, and this diagnostic is where you can check it rather than take it.
Where Java and Python are better, and for most programs they are
Python
snapshot = reading reading.celsius = 22 print(snapshot.celsius) # prints 22. No error, ever. # The GC decides when to free.
Science
let snapshot be borrowed r
r.celsius be 22i64
print(f"{snapshot.celsius}")
# SC0330. Reorder, or copy.
# Freed at a point you can name.
A garbage collector is the better answer for most programs, and this
page will not pretend otherwise. You never write
borrowed, never reorder two lines to satisfy a checker, never
read SC0330, and nothing you write can use memory after it is
freed. The cost is a pause you cannot schedule and a heap you cannot
predict, and for an enormous amount of software neither of those costs
anything at all.
They cost something when you are holding a forty-gigabyte array on a node you are billed for by the second, which is the situation this language is designed for. That is a narrow claim and it is the honest one. Notice also what the Python column is not doing: it is not copying. Both languages hand you a second name for the same object here and both print through it. The only difference is that one of them checks that the first name has not moved out from under you.
8 · Absence and failure#
Two different problems, deliberately given two different shapes. A value
that might not be there has a nullable type, T?. An
operation that might fail returns what it produced and
what went wrong, as a pair. There is no exception, no try, and
no stack unwinding.
Absence: T? and the postfix ?
Int? is a type whose values are the Ints and
null. There is nothing to wrap and nothing to unwrap. The one
operation it has is the postfix ?, which asks whether a value
is there and evaluates to a Bool.
let mutable measured: Int? be null
print(f"{measured?}")
measured be 42
print(f"{measured?}")
# false
# true
? produces a Bool and never the value, which is
why that prints false and true rather than
null and 42. You reach the value by
narrowing: inside if measured?: the compiler
knows measured is not null, so you write measured
and use it as an Int, with no cast and no unwrap.
Failure: the (T, Error?) pair
A function that can fail returns two things: the value it produced, and an
error that is null when nothing went wrong. The caller unpacks
both with one let, tests the error with ?, and
handles it before touching the value.
choice PortError:
Missing
NotANumber(Int)
def parse_port(text: borrowed String) -> (Int, PortError?):
if text.is_empty():
return (0, PortError.Missing)
if text is not "8080":
return (0, PortError.NotANumber(text.length()))
(8080, null)
def explain(err: PortError) -> String:
match err:
Missing: "no port was given"
NotANumber(n): "the port is not a number"
let port, err be parse_port("")
if err?:
print(explain(err))
else:
print(f"{port}")
That listing checks and does not build — the fourth of six
It exits 0 from check, exhaustiveness test and narrowing and
all. build stops at the first String method:
"cannot build a call to is_empty, which this crate was
given no MIR body for". Write error handling this way; you cannot run
it yet.
Three things happen in the last four lines and all three are worth naming.
The unpack — let port, err be f() —
is the one place destructuring exists in the language, and it exists because
this is the shape every fallible call takes. The test is
if err?:, the same postfix ? the nullable used,
because an error that did not happen is an absent value. The
narrowing means that inside the branch err is a
PortError and not a PortError?, so it goes
straight into explain with nothing written for it.
Forgetting to check is an error, not a habit
The obvious failure of a returned-error model is that you can ignore the error; Go's most-cited flaw is exactly this. Science makes it a compile error, and gives you a way to say you meant it.
choice PortError:
Missing
NotANumber(Int)
def parse_port(text: borrowed String) -> (Int, PortError?):
if text.is_empty():
return (0, PortError.Missing)
(8080, null)
let port, err be parse_port("")
print(f"{port}")
error[SC0140]: `err` is never checked for an error
--> unchecked.science:10:11
|
10 | let port, err be parse_port("")
| ^^^ bound here and never tested
|
= note: test it with `if err?:`, pass it on, or return it — or rename it to `_err` to say the failure is deliberately ignored
1 error
A leading underscore is how you say the failure is deliberately dropped. That is a word you have to type, in the file, where a reviewer will see it.
Why there is no try
There was one, in an earlier revision, and it was removed along with
Result and Option. The short version: the error
path is now written where it happens, in the order it happens, for a reader
rather than for a writer. The cost is that three fallible calls are nine
lines rather than three.
Rust's ? is better at this, and the design notes say so
outright — it composes, and Result makes
ignoring a failure a type error rather than a separate check like
SC0140. The full argument, with the same function written in
five languages and a paragraph on what each of them gets right, is in the
rationale's error model. It is not
repeated here: that is an argument about a decision, and this is a page
about writing the result.
Where Rust is better at absence, too
Rust
let port = config.get("port")
.and_then(|s| s.parse().ok())
.filter(|p| *p > 1024)
.unwrap_or(8080);
Science
let mutable port: Int be 8080
let raw be config.get("port")
if raw?:
let parsed be to_port(raw)
if parsed?:
if parsed > 1024:
port be parsed
Option<T> is an ordinary type with a method set, so a
chain of optional steps is a chain. T? is not a type you can
call methods on: it has ? and nothing else — no
is_some, no unwrap, no unwrap_or, no
map — because there is no wrapper type left for those
methods to hang off. Four optional steps in Rust are four lines; in
Science they are four nested ifs, and the nesting is
real.
What it buys is that T? costs nothing at a boundary: a field, a
parameter and a return may all be nullable and none of them wraps anything.
Whether that is the better trade depends on how much of your code chains
optionals, and in numerical code the answer is usually "very little".
9 · Interfaces and generics#
An interface names a set of methods. A type declares that it
provides them in a separate block, written
Type implements Interface: — the reverse of Rust's
impl Trait for Type, and it reads as a sentence about the type.
A generic function takes a type parameter with of and
constrains it with an interface.
interface and implements
An interface declares method signatures with no bodies. It may also supply a default body, which an implementation inherits unless it overrides it. The implementation block supplies the rest.
interface Volume:
def litres(self) -> F64
type Flask:
millilitres: F64
type Drum:
litres_held: F64
Flask implements Volume:
def litres(self) -> F64:
self.millilitres / 1000.0
Drum implements Volume:
def litres(self) -> F64:
self.litres_held
let f be Flask(millilitres: 250.0)
let d be Drum(litres_held: 200.0)
print(f"{f.litres()} {d.litres()}")
That listing checks and does not build — the fifth of
six, and for the same reason the method block in
chapter 5 does not: the call is a method call, and the
backend cannot yet resolve the receiver. The declarations, the two
implementations and the coherence checking all pass check.
An interface with no methods at all is a marker, implemented on one line
with no block: Position implements Copy. The library interfaces
you will meet first are Copy, Clone,
Eq, Ord, Display (what
print requires), Drop, and the operator interfaces
— Add, Sub, Mul and the rest
— which is how a user type takes part in arithmetic.
Generics: of T, bounds, and where
def name of T(…) declares a function generic over
T. A bound goes after the parameter with a colon, or in a
where clause at the end when the signature gets long. Two or
more type parameters take parentheses, because the comma would otherwise be
ambiguous inside a parameter list:
def swap of (A, B)(…).
interface Volume:
def litres(self) -> F64
type Flask:
millilitres: F64
Flask implements Volume:
def litres(self) -> F64:
self.millilitres / 1000.0
def fuller of T: Volume(a: borrowed T, b: borrowed T) -> F64:
if a.litres() > b.litres(): a.litres() else: b.litres()
def total of T(a: borrowed T, b: borrowed T) -> F64 where T: Volume:
a.litres() + b.litres()
let small be Flask(millilitres: 250.0)
let large be Flask(millilitres: 750.0)
print(f"{fuller(small, large)} {total(small, large)}")
That listing checks and does not build — the last of six
Generics are monomorphised: one specialised copy of the function for each
set of type arguments it is called with. Nothing runs that walk yet, so a
call to a generic function is refused — "cannot build a call to
the generic function fuller, which nothing has
monomorphised". The same applies to an interface's default
method body, whose self is a different concrete type in every
implementation.
of is also how a generic type is written: Array of
Doc, Map of (String, Int), Array of (Map of
(String, Int)). One argument needs no parentheses and two or more do,
and nesting follows the same rule. Naming a generic type before a
. needs its own parentheses too —
(Array of Doc).new(), never Array of Doc.new(),
because the second does not say whether .new belongs to
Doc or to the whole type.
Where Java and TypeScript are better: <T> never needs parentheses
TypeScript
function swap<A, B>(p: Pair<A, B>): Pair<B, A> type Grid = Map<string, Array<number>>;
Science
def swap of (A, B)(p: Pair of (A, B)) -> Pair of (B, A) type Grid is Map of (String, Array of F64)
Angle brackets are self-delimiting and of is not.
<A, B> is unambiguous with one argument or six, at any
nesting depth, and anyone who has written Java, C#, TypeScript or Rust
already reads it without thinking. of needs parentheses the
moment there are two arguments, and again the moment you nest, and that is a
rule you have to remember rather than a shape you can see. For this one
construct the familiar notation is simply better.
What of buys is elsewhere in the grammar: < and
> stay unambiguously comparison operators, so the parser
never needs the disambiguation pass that C++ and Java both carry. That is a
real benefit to the compiler and a mild cost to you, and which side you land
on probably depends on how often you write six-parameter generics.
10 · Talking to C#
Every floating-point routine worth using is already written in C. Science
calls them directly — no binding generator, no wrapper library, no
marshalling layer. An extern "C" block declares what a library
provides, and an unsafe block is where you are allowed to call
it.
The block, and what it claims
unsafe extern "C" library "m": opens a block of declarations
for the C math library. Inside it you write signatures with no bodies, in
Science's own type vocabulary. Nothing in that block is checked
against anything. It is a claim about what is in the library, and
if the claim is wrong the program is wrong in a way no compiler will catch
— which is exactly why the block says unsafe out loud,
and why reaching into it takes a second unsafe at the call
site.
unsafe extern "C" library "m":
def sqrt(x: F64) -> F64
def cos(x: F64) -> F64
def hypotenuse(a: F64, b: F64) -> F64:
unsafe:
sqrt(a * a + b * b)
let straight be unsafe:
cos(0.0)
print(f"{hypotenuse(3.0, 4.0)}")
print(f"{straight}")
# 5.0
# 1.0
That program compiles to a native executable, links against libm, and prints
those two numbers. It is the whole of the FFI story for a function taking
and returning a scalar. An unsafe block is an expression, so it
can be a function's body or the right-hand side of a binding — but it
cannot go inside an f"…", where the : is
read as the start of a format specification.
The wrapper is the interface, and the unsafe block is short
Look at where unsafe sits in hypotenuse: around
the call, and nothing else. The arithmetic is outside it. That is the
discipline the whole design rests on — if an
unsafe block is long, the binding is written wrong
— and it is why the thing you export from a module is never the
extern declaration.
A real binding has three layers. The extern block, which
nothing outside the module calls. A safe Science function whose signature
contains no foreign type at all, which checks what the C function assumes
— that the shapes match, that a length fits, that a handle is open
— and returns an ordinary Science error if they do not. And the
unsafe block inside it, which is the two or three lines that
actually cross the boundary. Callers get the middle layer and never see the
other two.
The vocabulary for the harder cases — ffi.Span of F64 for
a pointer and a length, ffi.CStr, ffi.Complex64,
ffi.OpaqueHandle, symbol "…" to decouple the
Science name from the linker name, via pkg-config,
kind static, and when available for a library that
may not be installed — is in the reference's
foreign function interface section, and
demonstrated across a full OpenBLAS and HDF5 binding in
examples/20_extern.science.
Where Python is better, and for exploratory work it is not close
Python
import numpy as np np.linalg.norm([3.0, 4.0]) # 5.0 # # no compiler, no declaration, # no rebuild, no `unsafe`
Science
unsafe extern "C" library "m":
def sqrt(x: F64) -> F64
def hypotenuse(a: F64, b: F64) -> F64:
unsafe:
sqrt(a * a + b * b)
Every BLAS and LAPACK routine you were going to bind is already bound for you in NumPy and SciPy, and has been for twenty years. You import it, you call it, and you are wrong about a shape at runtime instead of at compile time — which, during exploration, is the better place to be wrong. There is no build step to wait for and no declaration to keep in sync with a header that changed.
Science's version is more work and it buys two things. The wrapper's checks — the shape test, the leading-dimension test — run as ordinary Science errors rather than as a segfault three calls later. And there is no interpreter between the loop and the library, which is the difference that matters when the loop is the program. If you are exploring, use Python. If you are shipping the kernel, this is what it costs.
Putting it together
Here is a program using nearly everything in this tutorial: a record, a
choice, two matches, three shared borrows,
interpolation, and a real call into libm. It builds and runs.
unsafe extern "C" library "m":
def sqrt(x: F64) -> F64
type Reading:
sample: I64
celsius: F64
choice Station:
Ground
Balloon(F64)
Buoy(F64, F64)
def kelvin(r: borrowed Reading) -> F64:
r.celsius + 273.15
def altitude(station: Station) -> F64:
match station:
Ground: 0.0
Balloon(metres): metres
Buoy(latitude, longitude): 0.0
def spread(low: borrowed Reading, high: borrowed Reading) -> F64:
let difference be high.celsius - low.celsius
unsafe:
sqrt(difference * difference)
def label_for(station: Station) -> String:
match station:
Ground: "ground"
Balloon(metres): "balloon"
Buoy(latitude, longitude): "buoy"
let low be Reading(sample: 1i64, celsius: 18.0)
let high be Reading(sample: 2i64, celsius: 21.5)
print(f"sample {low.sample}: {low.celsius} C = {kelvin(low)} K")
print(f"sample {high.sample}: {high.celsius} C = {kelvin(high)} K")
print(f"spread {spread(low, high)} C")
print(f"{label_for(Balloon(1200.0))} at {altitude(Balloon(1200.0))} m")
# sample 1: 18.0 C = 291.15 K
# sample 2: 21.5 C = 294.65 K
# spread 3.5 C
# balloon at 1200.0 m
11 · Where to go next#
You can now read and write Science. What this page deliberately left out is depth, inventory and argument — one page each.
The three pages, and what each is for
tool declarations and the
toolchain. Open it when you know what you want to write and need the
semantics, the method set, or the edge case.
syntax.html
Whether a thing can be written, and how it is spelled.
Every construct with its shape, all 68 words the lexer knows and whether
each means anything today, the precedence table read off the parser, and
— the section to check before you spend an afternoon — what
parses and is not built, with the diagnostic each one produces.
rationale.html
Why it is spelled that way. Twelve decisions, each with
the argument that lost and what it cost, and twenty-two side-by-side
comparisons with Python, Go, Rust, Java, JavaScript and R —
including the places another language does it better. Read it when a
spelling annoys you; the case against it is usually already there.
The other three things worth your time
examples/ in the repository holds twenty-two
programs, one per feature, that are guaranteed to lex and parse — they
are input to the test suite, so a change that breaks one is a bug in the
change rather than a typo in the corpus. If you are unsure how something is
written, look there first. examples/README.md says what each
one covers.
tests/ui/ is the opposite corpus: programs
that must fail, each paired with the exact diagnostic it has to
produce, byte for byte. It is the fastest way to find out what the compiler
says about a mistake before you make it.
The status section on the home
page lists what has been run and what has not, with the command
that settles each claim. The boundary between check and
build moves; that section, and the compiler's own
SC0400 note, are the two places that track it — and the
compiler is always the more current of the two.
The exercise this page is set up for
Take the program at the end of chapter 10 and extend
it. Add a Sonde(F64, F64) variant to Station and
let the compiler tell you every match that now has a hole. Add
a warmer_than(a: borrowed Reading, b: borrowed Reading) ->
Bool. Give spread a PortError-style pair
return for the case where the two readings are from different stations, and
watch SC0140 catch you the first time you forget to check it.
The first two still build and run; the third checks and does not, and it is
the one that teaches you the most.