#Effects I: What the Row Says
Chapter 7 of the guide introduced the effect row:
the <IO> in shout : String -> <IO> Unit says shout may touch the world, and
a function with no row may not. This chapter is about what the compiler is doing
behind that sentence. It covers when an effect is charged, what the compiler
infers when you write no row at all, what each built-in label stands for, and the
rows of things that are not functions.
The tool for the whole topic is medaka check. Besides reporting errors, it prints
the type it inferred for every top-level binding in your file, row included, so
you can see what the compiler concluded rather than guess. When this guide shows
what check printed, the text was pasted from the compiler.
#Latent and immediate
Building a function is pure. Calling it is what performs its effects. The row on an arrow describes the call, not the construction:
makeLogger : String -> String -> <Stdout> Unit
makeLogger prefix = msg => putStrLn "\{prefix}: \{msg}"
main =
let log = makeLogger "app"
putStrLn "logger built, nothing printed yet"
log "first"
log "second"logger built, nothing printed yet
app: first
app: secondmakeLogger "app" returns a closure and prints nothing. The <Stdout> sits on the
last arrow, the one from String to Unit, because that is the arrow whose
application prints. An arrow the function only passes through on the way to
another arrow is pure. (String -> (String -> <Stdout> Unit) is the same type;
the formatter drops the parentheses.)
So an expression has two kinds of effect. Its immediate row is what evaluating
it performs. Its latent rows sit on the arrows in its type and are released when
those arrows are applied. makeLogger "app" has an empty immediate row and a
latent <Stdout>; log "first" has an immediate row of <Stdout>.
#Inference
You do not have to write rows. The compiler infers them the way it infers types, and the rule is simple: the row of a call is the join of the row of the function expression, the rows of the arguments, and the latent row of the arrow being applied. A definition's row is the join of everything its body does.
double : Int -> Int
double n = n * 2
shout : String -> <Stdout> Unit
shout s = println s
greet name = println "hi \{name}"
twice f x = f (f x)
main =
println (double 21)
shout "loud"
greet "you"
println (twice double 5)42
loud
hi you
20medaka check prints:
double : Int -> Int
shout : String -> <Stdout> Unit
greet : Display a => a -> <Stdout> Unit
twice : (a -> <b> a) -> a -> <b> a
main : <Stdout> Unitgreet has no signature and was inferred <Stdout> because println is <Stdout>.
twice was inferred with a variable in its row: twice performs whatever its
argument performs, no more. That is effect polymorphism, and
the next chapter is about it. main has a row too,
the join of everything it calls.
Coming from Koka or Haskell? There is no effect handler and no
IOtype constructor. A row is a static upper bound the compiler checks and then erases; nothing about it exists at runtime, and the program computes the same value with or without its annotations.
#A signature is a bound
Write a row and the compiler checks the body against it. The body may perform less than the row says, never more:
double : Int -> Int
double n =
println "doubling"
n * 2
main = println (double 21)error: rows.mdk:3:10: Effectful value used where <> is allowed, but it performs <Stdout>
|
3 | println "doubling"
| ^<> is the empty row, the spelling of "pure". The message names the row the
signature allows and the row the expression performs. This is the escape check,
the name this topic uses for the comparison of a body's inferred row against its
declared bound, and it is the whole reason to write rows on top-level definitions:
a signature is a promise the compiler holds the body to, transitively. If double
called a helper that called a helper that printed, the same error would appear,
at the call in double's body that lets the effect in.
The other direction is always fine. A pure function may be declared with a row, a
<Stdout> function may be declared <IO>, and a function declared <Clock, IO>
is the same as one declared <IO>. Declaring more than you perform is safe; it
only makes callers permit more than they need.
#The built-in labels
Every label names something in the host environment, and every extern in the standard library's runtime catalog declares which one it performs. This is the vocabulary:
| Label | Performed by |
|---|---|
Stdout |
putStr, putStrLn, flushStdout |
Stderr |
ePutStr, ePutStrLn |
Stdin |
readLine, readLineOpt, readAll, readExactly |
FileRead |
readFile, readFileBytes, fileExists, listDir, statFile, canonicalizePath, … |
FileWrite |
writeFile, appendFile, makeDir, removeFile, rename, fsync, … |
Env |
getEnv, executablePath |
Exec |
runCommand |
Net |
netResolve, netTcpConnect, netTcpListen, netSend, netRecv, … |
Clock |
wallTimeSec, monotonicSec, sleepMs |
Rand |
randomInt, randomBool, randomFloat, randomChar, setSeed, osEntropyBytes |
Signal |
pdsSignalStart, pdsSignalRequested |
FFI |
any extern you declare yourself |
IO is not on the list because it is their abbreviation: <IO> is the join of the
eleven labels above FFI. A bound of <IO> admits all eleven, so a row that performs
<Stdout> fits <IO>, and a function that performs <IO> fits a bound that spells
the eleven labels out (a bound that names only ten refuses it, naming the missing
label). Two things it does not cover: FFI, which has to be named explicitly
because it leaves the language, and any label you declare yourself
(chapter 3).
Some runtime externs perform no label at all. args reads a command line that
was fixed when the process started, and buildCommit, buildDate and
buildFingerprint read constants linked into the binary. Neither is something a
host grants or refuses, so all four are pure.
Narrow labels let a signature say which part of the world a function touches:
say : String -> <Stdout> Unit
say s = putStrLn s
warn : String -> <Stderr> Unit
warn s = ePutStrLn s
now : Unit -> <Clock> Float
now () = monotonicSec ()
both : String -> <Stderr, Stdout> Unit
both s =
say s
warn s
main =
say "to stdout"
both "twice"
let t = now ()
if t >= 0.0 then say "clock read"to stdout
twice
clock readcheck prints main : <Clock, Stderr, Stdout> Unit. The compiler keeps rows
sorted and deduplicated, so <Stderr, Stdout> and <Stdout, Stderr> are the same
row.
A function cannot claim a row that leaves out what it calls. Declaring
say : String -> <Stderr> Unit and defining it as say s = println s is refused:
error: rows.mdk:2:16: Effectful value used where <Stderr> is allowed, but it performs <Stdout>
|
2 | say s = println s
| ^The fix is to declare say at <Stdout>, or to call ePutStrLn.
Several of these labels can carry a parameter that says which file, which
variable, or which host: readFile "cfg/app.toml" performs
<FileRead "cfg/app.toml">, not merely <FileRead>. That is
chapter 4. Until then, read a bare label as "any of it".
#Values have rows too
A top-level binding with no parameters is a value, and a value can still have a row: the row of computing it. The syntax puts the row in front of the type, where there is no arrow to hang it on:
banner : <IO> Unit
banner = println "== banner =="
greeting : String
greeting = "hello"
count : <Stdout> Int
count =
putStrLn "counting"
3
main =
banner
println greeting
println count== banner ==
hello
counting
3A top-level value is computed the first time it is used, not when the program
starts, and the result is kept. So count prints counting once even if it is
read twice. Statically, though, every use is charged: a function that mentions
count performs <Stdout> whether or not it happens to be the first to force it,
because the compiler cannot know which use comes first.
⚠️ A row-less signature on a value is not a promise of purity.
count : Intsays what type the value has and nothing about computing it; the compiler infers the row on its own, andcheckprintscount : <Stdout> Int. To promise a value is computed purely, write the empty row:count : <> Int. That is checked, and aputStrLnin the body is then an error.
This is different from function signatures, where a missing row means <>.
The asymmetry exists because a value signature such as greeting : String has no
arrow to carry a row, and reading every such signature as a purity promise would
make most existing code a type error.
#Statements and discarded values
Inside a block, each line is a statement, and a statement that produces anything
other than Unit is an error:
error: rows.mdk:11:2: this statement's value (String) is silently discarded — only a `Unit`-typed expression may stand alone as a statement
|
11 | "a string statement"
| ^This is not an effect rule, but it is the rule that keeps effect rows honest in
imperative code. A function whose result carries information, such as a
Result from writeFile, has to be matched on or bound with let _ =; it cannot
be dropped by accident. The two rules together mean that reading a block top to
bottom tells you both what it does and what it ignores.
#What is not an effect
The row tracks the boundary between the program and its host. Three things people sometimes expect on it are not there, on purpose.
Mutation. Writing to a Ref cell has no label. A function that allocates a
cell, updates it, and reads it back is pure as far as the row is concerned, and so
is one that writes to a cell it was handed. Chapter 7 of the guide has the
example; the reasoning is that a cell is memory, not the world.
Failure. panic stops the program and exit ends it; neither has a label.
Recoverable failure is a value, Result or Option, and the type system tracks
it there.
Allocation and time spent. The row says nothing about cost.
What is left is exactly the set of things a host could grant or refuse: the console, the filesystem, the environment, subprocesses, the network, the clock, randomness, and foreign code. Chapter 3 shows what a host does with that list.