Sake-lang
the Sake language, pronounced /seɪk/Sake is designed for the sake of finding a new place for types. It keeps Ruby's syntax, but you write the type on each operation, never on a variable. Each line then says exactly which function runs, and every name is checked before the program starts.
name.upcase
3.times { p it }
pt.xString.upcase(name)
Integer.times(3) { p it }
Point.x(pt)- Parsed by Prism
- No types on variables, parameters, or fields
- All names checked before running
- Every operation checks its arguments
The language
The idea
Most languages put types where a value is bound (variables, parameters, fields), and each use has to look back at that declaration. Sake puts the type where the value is used.
| Types on bindings | Types on operations (Sake) | |
|---|---|---|
| Example | x: String; x.upcase | String.upcase(x) |
| Meaning of a line | Needs the declaration | Fixed by the line itself |
| A misspelled operation | Found once the receiver's type is known | Found by name, before running |
| Cost | The type is written once | The type is written at every use; the uses check each other |
The examples on this page are real. Each one is a file in docs/examples/, and the dark panel under it is that file's actual output.
Running a program
puts("Hello, Sake!")
puts(String.upcase("operations carry their type"))
$ sake hello.sake
Hello, Sake!
OPERATIONS CARRY THEIR TYPE| Command | What it does |
|---|---|
bin/sake FILE.sake | Checks the whole program, then runs it |
bin/sake -c FILE.sake | Only checks; does not run |
bin/sake --strict FILE.sake | Checks more before running (level 2, below) |
bin/sake --types FILE.sake | Experimental: prints the inferred types without running |
How strict
| Level | How to ask | Also stops before running |
|---|---|---|
| 0 | --strict=0 | only syntax, names, argument counts, calls on values, and the like |
| 1 | (the default) | type: a value whose type (other than nil) does not fit the operation; rescue: a rescue clause an earlier clause already covers |
| 2 | --strict | nil: a value that may be nil, used without a check; mixed: a type report where values of different types met in a field (a warning at level 1) |
| 3 | --strict=3 | index-nil: the result of x[k], used without a check; exhaustive: a case whose literal branches may miss some value |
| 4 | --strict=4 | unrescued: a raise that may reach the top level |
Items can also be named: --strict=type,nil, or --strict=3,-index-nil. One item is in no level: unused-rescue, a rescue of an exception never raised, is a warning unless named. Whatever is not checked before running is still checked while running, by each operation.
A program can span several files and use Sake's library (Files, libraries, and arguments). To try Sake in a browser, with completion and the inferred types on hover, open the playground at ko1.github.io/sake-lang/playground (ide/ builds it).
The exit status is 0 on success, 1 for an error while running, and 2 for an error found before running (nothing ran).
Operations carry their type
Type.op(subject, args…) calls the operation op of Type. The subject comes first. A call on a value, such as name.upcase, is rejected, and the error shows how to write it with the type. Chains are rewritten as a whole.
name = "sake"
puts(name.upcase)
puts(name.strip.capitalize)
$ sake method_call.sake method_call.sake:2:11: error: method call on a value `name.upcase` is not allowed hint: String.upcase(name) hint: Symbol.upcase(name) method_call.sake:3:17: error: method call on a value `name.strip.capitalize` is not allowed hint: String.strip(name).capitalize hint: String.strip(name).Symbol.capitalize (exit status 2)
Every type and operation name is checked before the program starts, so a typo never fails halfway through a run:
puts(String.upcse("sake"))
puts(Strng.reverse("sake"))
puts(Integer.to_string(42))
$ sake typo.sake typo.sake:1:13: error: undefined function `String.upcse` hint: did you mean `String.upcase`? typo.sake:2:6: error: undefined type or module `Strng` hint: did you mean `String`? typo.sake:3:14: error: undefined function `Integer.to_string` hint: did you mean `Integer.to_int`? (exit status 2)
Nested calls read inside out. Two forms let them read in order, with every step still naming its type: x.T.f(…) is T.f(x, …), and _ is the value of the previous statement.
line = "pear, Apple ,fig"
# Left to right: the value on a step's left is its first argument. A step names its type where the
# type changes (String.split gives an Array), and the others continue with the type named last.
puts(String.split(line, ",")
.Array.map { |s| String.strip(s).downcase }
.sort
.join(" < "))
# Top to bottom: `_` is the previous statement's value.
String.split(line, ",")
Array.map(_) { |s| String.strip(s).size }
puts("longest: #{Array.max(_)}")
$ sake chains.sake
apple < fig < pear
longest: 5Rules
- Built-in names follow Ruby's core library:
Integer.to_s,String.include?,Array.each. Names from other languages, such asInteger.to_string, do not exist. - Calls on values are always rejected, including on literals such as
3.timesand"a".upcase. For a literal, the hint names its type. p.xsuggestsPoint.x(p),p.x = vsuggestsp.Point.x = v, andx.nil?suggestsx == nil.
Numbers and operators
Operators behave as in Ruby: a + b runs the + of a's type. For the built-in types, a fixed table gives the result for each pair of operand types.
puts(7 / 2) # Integer / Integer truncates toward -infinity, as in Ruby
puts(-7 / 2)
puts(7 % 3)
puts(7 / 2.0) # mixing Integer and Float gives Float
puts(2 ** 10)
puts(Float.round(3.14159, 2))
puts(Float.round(2.5)) # without digits: an Integer
puts(Integer.+(1, 2)) # the operator written with its type
puts("ab" * 3)
puts("a" < "b")
p(1/3r + 1/6r)
p(2r ** -1)
$ sake numbers.sake
3
-4
1
3.5
1024
3.14
3
3
ababab
true
(1/2)
(1/2)The table for +
| left \ right | Integer | Float | String | nil |
|---|---|---|---|---|
| Integer | Integer | Float | — | — |
| Float | Float | Float | — | — |
| String | — | — | String | — |
| nil | — | — | — | — |
A pair marked — is reported before running. There is no implicit conversion. Every operation also checks its arguments while running; with --strict=0, the same mistake stops when it runs:
total = 10
label = "items: "
puts(label + total)
$ sake binop_error.sake binop_error.sake:3:6: error: Arithmetic.+: the operands are (String, Integer), which the left operand's type does not support [type] (exit status 2)
$ sake --strict=0 binop_error.sake binop_error.sake:3: in <main>: TypeError: Arithmetic.+: no implementation for (String, Integer); defined for (any two of Integer, Float, Rational, Complex), (String, String), (Array, Array), (Time, Integer), (Time, Float), (Time, Rational) (exit status 1)
Writing the operator with a type, as in Integer.+(a, b), runs Integer's + directly: the left operand must be an Integer, and the right one any type that + takes with it:
puts(Integer.+(1, 2.5)) # Integer's +: a Float on the right is fine
puts(Integer.+(2.5, 1)) # but the left operand must be an Integer
$ sake typed_op_error.sake typed_op_error.sake:2:6: error: Integer.+: argument 1 must be Integer, but is Float [type] (exit status 2)
== compares any two values: by content for Tuples, Arrays, Hashes, Sets, Records and Structs, and false between different types. Tuples and Arrays are ordered element by element, so a Tuple makes a sort key of several parts. -x runs the -@ of x's type, and !x is x ? false : true:
# == compares by content, and is false between different types (as in Ruby)
p([1, "a"] == [1, "a"]) # Tuples, Arrays, Hashes, Sets and Records: by content
p(1 == "1")
p([2, "b"] < [2, "c"]) # Tuples and Arrays are ordered element by element
scores = Any[["ann", 90], ["bob", 72], ["cy", 90]]
p(Array.sort_by(scores) { |name, s| [-s, name] }) # a Tuple as the sort key: score down, name up
x = 5
p([-x, !(x > 3)]) # -x runs the -@ of x's type; !x is `x ? false : true`
$ sake comparing.sake
true
false
true
[["ann", 90], ["cy", 90], ["bob", 72]]
[-5, false]Rules
- * / % **take the same numeric pairs as+(Integer, Float, Rational, Complex).*also takes (String, Integer); Arrays have+ - *as in Ruby.< <= > >=compare two numbers, two Strings, two Symbols, or two Tuples or Arrays element by element.== !=compare any two values. A class withComparableand<=>is equal where<=>gives 0.- Integer
/and%follow Ruby:-7 / 2 == -4. Dividing an Integer by zero raisesZeroDivisionError. Integer ** negativeraisesArgumentError; for a Rational, write2r ** -1.x += 1meansx = x + 1.&&and||short-circuit as in Ruby.
Functions
Functions have no type annotations, and do not need them: what a function accepts follows from its operations (String.upcase(s) needs a String), and each call is checked with the types it is given. A function works with any type its operations accept, so square below works on both Integer and Float.
def square(x) = x * x
def fib(n)
if n < 2
n
else
fib(n - 1) + fib(n - 2)
end
end
def min_max(a, b)
return a, b if a < b
[b, a]
end
puts(square(12))
puts(square(1.5)) # functions without types are polymorphic
puts(fib(20))
lo, hi = min_max(9, 4) # a Tuple is destructured by multiple assignment
puts(lo)
puts(hi)
p(min_max(1, 2))
$ sake functions.sake
144
2.25
6765
4
9
[1, 2]Optional and keyword parameters
As in Ruby, required parameters come first, then optional ones with a default, then keyword parameters, required (w:) or with a default. A default is evaluated at the call and may read the earlier parameters.
# Optional parameters, then keyword parameters, as in Ruby.
def greet(name, greeting = "Hello", punct: "!", times: 1)
String.*("#{greeting}, #{name}#{punct} ", times)
end
def area(w:, h: w) = w * h # w: is required; h's default reads w
puts(greet("Ruby"))
puts(greet("Ruby", "Hi"))
puts(greet("Ruby", times: 2))
puts(greet("Sake", "Yo", punct: "?"))
p(area(w: 3))
p(area(h: 2, w: 5))
times = 3
punct = "."
puts(greet("you", times:, punct:)) # `k:` alone passes the variable k
$ sake params.sake
Hello, Ruby!
Hi, Ruby!
Hello, Ruby! Hello, Ruby!
Yo, Sake?
9
10
Hello, you. Hello, you. Hello, you. Every call's function is known before running, so keyword arguments are matched to their parameters by name then. A misspelled or missing keyword, or one given to a function that has none, is an error before running. There is no implicit Hash argument as in Ruby: write Hash.of(k: v) or a Record {k: v} for that.
def area(w:, h: w) = w * h
def total(xs, init = 0) = Array.sum(xs, init)
p(area(width: 3)) # no such keyword, and w: is missing
p(total(Any[1], 0, 1)) # too many arguments
p(total(Any[1], start: 0))
$ sake params_errors.sake params_errors.sake:3:3: error: area needs keyword argument `w:` params_errors.sake:3:8: error: area has no keyword parameter `width` params_errors.sake:4:3: error: wrong number of arguments for total (given 3, expected 1..2) params_errors.sake:5:17: error: total takes no keyword arguments (exit status 2)
Rules
- The forms are
def f(a, b) … endand, for a short expression,def f(x) = expr. - Parameters: required, then optional (
b = 1), then keywords (c:,d: 2), then a block parameter&b, which only passes the block on (Blocks).*restcollects the remaining positional arguments in an Array and**optsthe other keywords in a Hash. - Arguments are evaluated in the order written.
f(k:)passes the variablek. first, *rest = xsand|a, *rest|collect the rest in an Array.*xsin arguments spreads only into built-ins that take any number of them:puts(*lines),format(fmt, *row),Any[*xs, 0].return a, band the literal[a, b]both make a Tuple.x, y = ttakes it apart, and an Array too: missing elements are nil, extra ones dropped, as in Ruby.- Definitions are collected before anything runs, so a function may be called above its definition.
- Recursion is limited to a depth of 10,000. Deeper calls raise
SystemStackError.
Control flow
if/elsif/else, unless, while, until, the modifier forms, and c ? a : b all work as in Ruby.
def fizzbuzz(n)
if n % 15 == 0
"FizzBuzz"
elsif n % 3 == 0
"Fizz"
elsif n % 5 == 0
"Buzz"
else
Integer.to_s(n)
end
end
i = 1
while i <= 15
print(fizzbuzz(i), " ")
i += 1
end
puts
n = 27
steps = 0
until n == 1
n = Integer.even?(n) ? n / 2 : 3 * n + 1
steps += 1
end
puts("collatz(27) steps: #{steps}")
puts("small") unless steps > 100
puts("ok") if steps == 111
$ sake control.sake
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
collatz(27) steps: 111
okMatching by type
x in Integer tests a value's type. case x with in branches picks the branch by type, literal, or Record shape. Inside a matching branch, the variable has the matching type, so a value that may be an Integer or a String can be used without a type report. There is no ===, so case/when is not supported.
def describe(x)
case x
in Integer | Float then "number #{x}"
in String then "string #{x}"
in nil then "nothing"
in {name:} then "record named #{name}"
in :ok then "ok symbol"
else "other"
end
end
Point = Struct.new(:x, :y)
p(describe(3)); p(describe(2.5)); p(describe("s")); p(describe(nil)); p(describe({name: "r"})); p(describe(:ok)); p(describe(Point.new(1, 2)))
def pick(c) = c ? 1 : ""
v = pick(true)
if v in Integer
p(v + 1)
else
p(String.size(v))
end
p((Point.new(1, 2) in Point))
p(("s" in Integer | Float))
$ sake patterns.sake
"number 3"
"number 2.5"
"string s"
"nothing"
"record named r"
"ok symbol"
"other"
2
true
falsex => Integer asserts a type, as in Ruby: after it, x is an Integer, and a value that does not match raises NoMatchingPatternError. Types are written on operations, never on variables or fields, so this is how a fact such as "a port is an Integer" is stated where the value is stored. It is also a check for nil.
# `x => T` asserts a type: after it, x is a T; a value that does not match raises
# NoMatchingPatternError, and is reported before running when the checker sees it.
class Config
attr_reader host, port
def initialize(c) = @port = 80
def set_port(c, p)
p => Integer # the knowledge "a port is an Integer", on the write
@port = p
end
end
c = Config.new("example.com")
Config.set_port(c, 8080)
p(c)
v = Array.first(Any[3, 4]) # Integer or nil
v => Integer # also a check for nil: v + 1 needs none
p(v + 1)
Config.set_port(c, "eighty")
$ sake pattern_assert.sake pattern_assert.sake:7:5: error: `=> Integer`: the value is String, which does not match [type] hint: reached by the call at line 17 (exit status 2)
The set of types is closed, so a case that may match nothing is found before running:
def only_ints(x)
case x
in Integer then x * 2
end
end
p(only_ints(4))
p(only_ints("a"))
$ sake patterns_exhaustive.sake patterns_exhaustive.sake:2:3: error: case/in: no `in` branch matches String [type] hint: add an `in` branch for it, or an `else` hint: reached by the call at line 7 (exit status 2)
Rules
- Only
nilandfalseare falsy.0and""are truthy. - An
ifwithout anelse, and every loop, givesnil. breakandnextwork insidewhileanduntil.
Blocks
An operation that takes a block is called like any other, with the subject first. A block's single parameter can be named it or _1.
nums = Any[3, 1, 4, 1, 5, 9, 2, 6]
Array.each(Any[1, 2, 3]) { |x| print(x * 10, " ") }
puts
p(Array.map(nums) { |x| x * x })
p(Array.select(nums) { it > 3 }) # `it` names the only parameter
p(Array.sort_by(nums) { -_1 }) # so does _1
puts(Array.reduce(nums, 0) { |acc, x| acc + x })
Array.each_with_index(Any["a", "b"]) { |s, i| puts("#{s}#{i}") }
Integer.times(3) { |i| print(i, " ") }
puts
$ sake blocks.sake
10 20 30
[9, 1, 16, 1, 25, 81, 4, 36]
[4, 5, 9, 6]
[9, 6, 5, 4, 3, 2, 1, 1]
31
a0
b1
0 1 2 Your own functions receive a block through yield. If a block has several parameters and receives one Tuple, the Tuple is taken apart. return inside a block leaves the enclosing function.
def twice(x) = yield(yield(x))
def each_pair(a)
Array.each(a) { |x| yield([x, x * x]) } # yield a Tuple...
a
end
def first_over(a, limit)
Array.each(a) { |x| return x if x > limit } # return leaves first_over
0
end
puts(twice(3) { |x| x + 4 })
each_pair(Any[1, 2, 3]) { |x, sq| puts("#{x} -> #{sq}") } # ...destructured here
puts(first_over(Any[1, 5, 10], 4))
odd_only = Array.map(Any[1, 2, 3, 4]) { |x| next 0 if Integer.even?(x); x }
p(odd_only)
$ sake yield.sake
11
1 -> 1
2 -> 4
3 -> 9
5
[1, 0, 3, 0]Blocks are not values, so the interpreter knows which block each yield runs and checks block use before running:
def twice(x) = yield(yield(x))
def add(a, b) = a + b
puts(twice(1))
puts(add(1, 2) { |x| x })
Array.each(Any[1, 2])
$ sake block_errors.sake block_errors.sake:4:6: error: twice uses `yield` but no block is given hint: a function that checks `block_given?` may be called without a block block_errors.sake:5:6: error: add does not take a block (it has no `yield`) block_errors.sake:6:7: error: Array.each requires a block (exit status 2)
break ends the call the block was given to, which then gives the break's value. A parameter can be taken apart, |(name, n), i|, and |head, *rest| collects the rest:
nums = Any[3, 8, 12, 5]
found = Array.each(nums) { |n| break n if n > 10 } # break ends the call; its value is the call's
p(found)
pairs = Any[["a", 1], ["b", 2]]
Array.each_with_index(pairs) { |(name, n), i| puts("#{i}: #{name}=#{n}") } # a parameter taken apart
Array.each(Any[[1, 2, 3], [4, 5, 6]]) { |head, *rest| p([head, rest]) } # the rest, as an Array
$ sake blocks_more.sake
12
0: a=1
1: b=2
[1, [2, 3]]
[4, [5, 6]]Optional blocks, and passing a block on
A function that checks block_given? may be called with or without a block. The checker knows for each call whether a block was given, so it follows only the branch taken. &b passes the function's own block on to another call; b is never a value.
# block_given?: a function that may be called with or without a block.
def info(msg = nil)
msg = yield if block_given?
puts("INFO #{msg}")
end
info("plain")
info { "built only when logged: #{Array.sum(Any[1, 2, 3])}" }
def total(xs)
return Array.sum(xs) unless block_given?
Array.map(xs) { |x| yield x }.sum
end
p(total(Any[1, 2, 3]))
p(total(Any[1, 2, 3]) { |x| x * x })
# &b passes the function's own block on to another call.
def each_twice(xs, &b)
Array.each(xs, &b)
Array.each(xs, &b)
nil
end
each_twice(Any["a", "b"]) { |s| print(s) }
puts
$ sake block_optional.sake
INFO plain
INFO built only when logged: 6
6
14
ababRules
- A user function takes a block if and only if its body contains
yield(or it takes&b). One that checksblock_given?may also be called without a block; the checker then follows only the branch taken. A built-in operation either requires a block or rejects one. def f(xs, &b) = Array.map(xs, &b)passes the function's block on.bcan only be used as&b. Such a function may be called without a block, as in Ruby; passing none on to a call that needs one is an error.- A
yieldreached without a block is an error before running (level 1), andLocalJumpErrorwhen it runs. - A block with no parameters ignores its arguments. Otherwise, the number of arguments must match. A block with several parameters takes one Tuple or Array apart.
next vgives the block's value;break vthe value of the call it was given to.- A block can read and assign the variables around it.
- A block cannot be stored or returned:
proc,lambda, and->are not available.
Data
Types and instances
A class declares a type with fields; its values are instances. The fields are declared in the body with attr_reader, attr_accessor, and attr_writer, as in Ruby but with the names written bare. The type's operations are its own functions, called with the type's name, the instance first.
class Point # a type with two fields, read-only from outside
attr_reader x, y
def add(a, b) = Point.new(@x + Point.x(b), @y + Point.y(b))
def norm2(p) = @x * @x + @y * @y # @x: field x of the first argument, Point.x(p)
def to_s(p) = "(#{@x}, #{@y})" # used by puts and "#{...}"
end
class Counter
attr_reader name
attr_accessor count
def initialize(c) = @count = 0
def bump(c) = @count += 1 # inside the class, @count += 1 writes the field
end
a = Point.new(1, 2) # an instance: one argument per field
b = Point.add(a, Point.new(3, 4))
puts(b)
p(b) # p shows the fields
p([Point.norm2(b), Point.x(b)])
p(a == Point.new(1, 2)) # == compares the fields
c = Counter.new("hits") # count takes its default
d = c # the same instance: values are shared, not copied
Counter.bump(d)
Counter.set_count(c, Counter.count(c) + 10) # accessor: writable from outside too
p(c)
$ sake instances.sake
(4, 6)
#<struct Point x=4, y=6>
[52, 4]
true
#<struct Counter name="hits", count=11>Declaring a type
| In the body | Fields it declares |
|---|---|
attr_reader x, y | C.x(c) from anywhere; written only inside class C (with @x = v) |
attr_accessor n | C.n(c) and C.set_n(c, v) from anywhere |
attr_writer w | C.set_w(c, v) from anywhere; read only inside the class |
def initialize(c) | runs after new stored the fields: set the others there (@items = Any[]); with it, new may leave out trailing fields, which are nil until initialize sets them. Fields have no default values |
private attr_reader pos | no C.pos outside the class (inside: @pos, or C.pos(other) for another C); new still takes it |
class E < Exception | an exception type: message comes first |
Use attr_reader for every field that is not changed from outside, which is most of them: every write to a field is then in its own class, where the checker finds its type. C = Struct.new(:x, :y) is shorthand for a class C with attr_accessor x, y, and E = Exception.new(:line) for an exception type.
initialize
Types are never written on fields, so a fact such as "a port is an Integer" goes where every value passes: def initialize(c) runs after C.new has stored the fields, as in Ruby. It checks (@port => Integer) or converts (@celsius = Float(@celsius)). The checker runs it for each new call with that call's values, so a wrong argument is reported at the call.
# initialize(c) runs after T.new has stored the fields: checks and conversions on every construction.
class Config
attr_reader host, port
def initialize(c)
@port = 80 if @port == nil
@port => Integer
@host = String.downcase(@host)
end
def next_port(c) = @port + 1
end
p(Config.new("EXAMPLE.com"))
p(Config.new("h", 8080).next_port)
class Temp
attr_reader celsius
def initialize(t) = @celsius = Float(@celsius) # a conversion: the field is always a Float
end
p(Temp.new(20))
p(Temp.new("21.5"))
Config.new("h", "eighty") # reported for this call
$ sake initialize.sake initialize.sake:6:5: error: `=> Integer`: the value is String, which does not match [type] hint: reached by the call at line 20 (exit status 2)
Writing one class's definitions into another
class B < A is shorthand for writing A's definitions in B: A's fields come first, then B's own; A's functions become B's, with unqualified names and @x inside them meaning B's; B's own definition of a function wins. It is not inheritance: afterwards A and B are unrelated, and a B is not an A.
# B < A: A's definitions written again in B. Point3 is not a Point.
class Point
attr_reader x, y
def sum(pt) = @x + @y
def describe(pt) = "#{name(pt)}(#{@x}, #{@y}) sum=#{sum(pt)}"
def name(pt) = "Point"
end
class Point3 < Point
attr_accessor z
def initialize(p)
@z = 0 if @z == nil
end
def sum(pt) = @x + @y + @z
def name(pt) = "Point3"
end
class ParseError < Exception
attr_reader line
end
puts(Point.describe(Point.new(1, 2)))
puts(Point3.describe(Point3.new(1, 2, 3)))
p(Point3.new(4, 5))
begin
raise ParseError.new("bad", 3)
rescue ParseError => e
p([Exception.message(e), e.ParseError.line])
end
$ sake class_paste.sake
Point(1, 2) sum=3
Point3(1, 2) sum=6
#<struct Point3 x=4, y=5, z=0>
["bad", 3]Instances
C.new(a, b)takes one positional argument per field, in the order the fields are declared.- Fields are mutable and instances are shared, not copied: after
d = c, a change throughdis seen throughc. (Ruby'sDatais immutable, which is why Sake's types follow Ruby'sStruct.) ==compares the fields, unless the type defines==(orComparablewith<=>). Instances can be Hash keys and Set elements, except for a type with its own equality.pshows#<struct Point x=4, y=6>, as Ruby does. A type's ownto_s(c)is used byputs,"#{…}",Array.join, and%s; its owninspect(c)byp. Each returns a String.x in Pointandcase x in Pointtest an instance's type.
Functions of a type
Functions written inside class C … end are C's operations: C.f(c, …). By convention the instance comes first. Inside them:
@x,@x = v, and@x += vread and write fieldxof the function's first argument. The type comes from the enclosing class, so@xis still an operation with a type (C.x(c)). There is noself.- Unqualified names, such as
x(the reader) or another function of C, refer to C's operations first. A function named like a field replaces its reader, as in Ruby. def C.f(c)outside the class is shorthand fordef f(c)inside it. A class can be opened again to add functions.
The same with Struct.new and the reader C.x(c) / writer C.set_x(c, v) spelled out:
Point = Struct.new(:x, :y)
class Point
def add(a, b) = Point.new(x(a) + x(b), y(a) + y(b))
def to_s(p) = "(#{x(p)}, #{y(p)})" # used by puts and "#{...}"
end
def Point.scale!(p, k) # same as defining `scale!` inside `class Point`
set_x(p, x(p) * k)
set_y(p, y(p) * k)
p
end
a = Point.new(1, 2)
b = a # b and a are the same value
Point.scale!(b, 10)
puts(a) # uses Point.to_s; the change through b is visible through a
puts("sum: #{Point.add(a, Point.new(5, 5))}")
p(a)
puts(Point.x(a))
$ sake data.sake
(10, 20)
sum: (15, 25)
#<struct Point x=10, y=20>
10@x refers to the first argument, whatever it is; passing something else is reported:
Point = Struct.new(:x, :y)
class Point
def norm2(p) = @x * @x + @y * @y # @x is Point.x(p): p is the first argument
def move(p, dx, dy)
@x += dx # Point.set_x(p, Point.x(p) + dx)
@y += dy
p
end
end
pt = Point.new(3, 4)
puts(Point.norm2(pt))
Point.move(pt, 1, 1)
p(pt)
puts(Point.norm2(7))
$ sake data_shorthand.sake data_shorthand.sake:4:18: error: Point.x: argument 1 must be Point, but is Integer [type] hint: reached by the call at line 16 data_shorthand.sake:4:23: error: Point.x: argument 1 must be Point, but is Integer [type] hint: reached by the call at line 16 data_shorthand.sake:4:28: error: Point.y: argument 1 must be Point, but is Integer [type] hint: reached by the call at line 16 data_shorthand.sake:4:33: error: Point.y: argument 1 must be Point, but is Integer [type] hint: reached by the call at line 16 (exit status 2)
Mistakes
There is no p.x. A read-only field is not written from outside, a field that does not exist is not read, and new takes every field:
class Point
attr_reader x, y
end
pt = Point.new(1, 2)
puts(pt.x)
Point.set_x(pt, 5)
puts(Point.z(pt))
puts(Point.new(1))
$ sake instance_errors.sake instance_errors.sake:5:9: error: method call on a value `pt.x` is not allowed hint: Point.x(pt) instance_errors.sake:6:7: error: field `x` of Point is read-only (attr_reader) hint: inside `class Point`, use `@x = value`; or declare it with `attr_accessor x` instance_errors.sake:7:12: error: undefined function `Point.z` instance_errors.sake:8:12: error: wrong number of arguments for Point.new (given 1, expected 2) (exit status 2)
Passing the wrong instance is reported before running, with a hint pointing back to the call. While running, the same check stops at the exact operation and prints the call chain:
Point = Struct.new(:x, :y)
Line = Struct.new(:from, :to)
def length(l)
dx = Point.x(Line.to(l)) - Point.x(Line.from(l))
dy = Point.y(Line.to(l)) - Point.y(Line.from(l))
Math.sqrt(dx * dx + dy * dy)
end
puts(length(Line.new(Point.new(0, 0), Point.new(3, 4))))
puts(length(Point.new(3, 4)))
$ sake data_runtime_error.sake data_runtime_error.sake:5:16: error: Line.to: argument 1 must be Line, but is Point@L11 [type] hint: reached by the call at line 11 (exit status 2)
$ sake --strict=0 data_runtime_error.sake 5.0 data_runtime_error.sake:5: in length: TypeError: Line.to: argument 1 must be Line, got Point from data_runtime_error.sake:11: in <main> (exit status 1)
Modules
A module is a namespace of functions with no type and no instances. It is used in two ways: as a home for plain functions, and as a mixin that types include.
class C | module M | |
|---|---|---|
| Is a type | Yes: C.new, fields, x in C | No |
| Functions | C's operations, instance first | Module functions, or mixin functions for the types that include M |
| Called as | C.f(c) | M.f(x): directly (module_function), or dispatched to x's type |
| Combined by | include M; class C < A writes A's definitions in C | include of other modules |
module Geometry # a module: functions only, no instances
module_function
def dist2(ax, ay, bx, by) = (ax - bx) ** 2 + (ay - by) ** 2
end
module Shape # a mixin: what includers share, and what they must define
def area(s) = raise(NotImplementedError)
def describe(s) = "area #{area(s)}" # area: the includer's own
end
class Square # a class: a type, with instances
attr_reader side
include Shape
def area(q) = @side * @side
end
class Disc
attr_reader r
include Shape
def area(d) = 3 * @r * @r
end
p(Geometry.dist2(0, 0, 3, 4))
puts(Square.describe(Square.new(2))) # Square's describe, borrowed from Shape
puts(Shape.describe(Disc.new(1))) # dispatch: the describe of the argument's type
$ sake class_module.sake
25
area 4
area 3Module functions
Functions after module_function are called directly: Geometry.dist2(…), Units.km(x). Such a module is a namespace, like Ruby's Math.
Mixins and dispatch
include M copies M's functions into a class, as Ruby's modules do, but statically. Inside a copied function, unqualified names are looked up in the class that includes it, so Shape.describe uses the area of Square or of Disc. Calling a mixin function through its module, as Shape.describe(x), dispatches: it runs the function of x's type, which must include the module. This is the one place besides operators where the function is picked while running, and the module name on the call says so.
module Summary # mixin functions: they need `each` and `count` from the includer
def total(c)
sum = 0
each(c) { |x| sum += x }
sum
end
def average(c) = total(c) / count(c)
end
class Basket # `class` adds operations to a type
attr_reader items
include Summary
def each(b) = Array.each(@items) { |x| yield(x) }
def count(b) = Array.size(@items)
end
class Countdown
attr_reader n
include Summary
def each(c) = Integer.downto(@n, 1) { |i| yield(i) }
def count(c) = @n
end
module Units # `module_function`: called directly, as Units.km
module_function
def km(m) = m / 1000.0
end
b = Basket.new(Any[3, 4, 5])
puts(Basket.total(b)) # static: Basket's total (borrowed from Summary)
puts(Summary.total(Countdown.new(4))) # dispatch: the total of the argument's type
puts(Units.km(2500))
$ sake modules.sake
12
10
2.5A module states what each includer must define with a function whose body is only raise NotImplementedError. A missing definition and a direct call to a function that needs its includer are reported before running:
module Shape # a body of only `raise NotImplementedError`: each includer defines it
def area(s) = raise(NotImplementedError)
def describe(s) = "area #{Shape.area(s)}"
end
Square = Struct.new(:side)
class Square
include Shape
def area(q) = @side * @side
end
Circle = Struct.new(:r)
class Circle
include Shape # forgot area
end
puts(Shape.describe(Square.new(2)))
puts(Shape.describe(Circle.new(1)))
$ sake required_errors.sake required_errors.sake:3:29: error: Shape.area: Circle includes Shape but does not define area, which Shape requires (`raise NotImplementedError`) [type] hint: define `def area(...)` in class Circle hint: reached by the call at line 15 (exit status 2)
module Summary
def total(c) = each(c) + 1
end
module Empty
include Summary
end
puts(Summary.total(1))
$ sake module_errors.sake module_errors.sake:5:3: error: `include Summary` in Empty: Summary.total needs `each`, which Empty does not define (used at line 2) module_errors.sake:7:14: error: Summary.total is a mixin function, and no type includes Summary hint: to call it as Summary.total(...), mark it with `module_function` (exit status 2)
Operators for your own types
An operator belongs to a module: Arithmetic (+ - * / % **, unary - and +), Comparable (<=> and < <= > >=), Bitwise, and Indexable ([], []=). A type joins by including the module and defining the operator. With Comparable, <=> alone gives the comparisons, ==, and Array.sort.
class Money
attr_reader cents
include Arithmetic
include Comparable
def +(a, b) = Money.new(@cents + Money.cents(b))
def <=>(a, b) = @cents <=> Money.cents(b)
def to_s(m) = "$#{@cents / 100}.#{String.rjust(Integer.to_s(@cents % 100), 2, "0")}"
end
a = Money.new(150)
b = Money.new(275)
puts(a + b)
p(a < b)
p(Array.sort(Any[b, a]))
p(Array.max(Any[a, b]))
p(a == Money.new(150))
p(Array.include?(Any[a], Money.new(150)))
class Grid
attr_reader cells
include Indexable
def [](g, k) = Array.at(@cells, k)
def []=(g, k, v)
Array.[]=(@cells, k, v)
end
end
g = Grid.new(Any[1, 2, 3])
g[1] = 20
g[2] += 1
p(Any[g[0], g[1], g[2]])
$ sake operators.sake
$4.25
true
[#<struct Money cents=150>, #<struct Money cents=275>]
#<struct Money cents=275>
true
true
[1, 20, 4]Listing the types on the operation
When a value may be one of a few types that have an operation of the same name, list them on the operation: (A|B).f(x) runs the f of x's type. The list is checked before running:
Leaf = Struct.new(:weight)
Node = Struct.new(:weight, :left, :right)
# The operation lists the types its argument may have.
def weight(t) = (Leaf|Node).weight(t)
def size_of(x) = (String|Array).size(x)
puts(weight(Leaf.new(3)) + weight(Node.new(5, Leaf.new(1), Leaf.new(1))))
puts(size_of("sake") + size_of(Any[1, 2]))
puts(size_of(42))
$ sake union_call.sake union_call.sake:6:18: error: (String|Array).size: argument 1 must be String or Array, but is Integer [type] hint: reached by the call at line 10 (exit status 2)
Unqualified names
A call without a type, f(x), is resolved before running. It searches these places in order, and the first match wins:
- the enclosing
classormodule(and what it includes) - top-level functions
Kernel(puts,print,p,format, …)
The inner definition wins, so common names such as open stay usable. To reach an outer one, write its namespace, as in Kernel.puts(…). Only the innermost class or module is searched: inside A::B, a function of A is called as A.f(…).
def label(x) = "top: #{x}"
module Door
module_function
def open(d) = label("door") # Door.label wins over the top-level label
def label(x) = "door: #{x}"
def show(d) = puts(open(d)) # Door.open wins over the top-level open
end
def open(x) = label("top")
Door.show(1)
puts(open(1))
puts(Door.open(1))
$ sake scope.sake
door: door
top: top
door: doorRules
- The includer's own definition wins over a copied one. Modules included by a module are copied too.
includeis not inheritance:M.f(x)always runs M's ownfor dispatches tox's type.class B < Ais not inheritance either: it only writes A's definitions in B.- Only modules can be included. Every
classis a type, also one without fields; a home for plain functions is amodulewithmodule_function.
Tuples, Records, and arrays
A literal has no operation with a type, so its shape fixes its type when it is created. A growable collection is made by an operation with a type.
| Written as | Is | Element types |
|---|---|---|
[1, "a"] | Tuple | Fixed length and fixed positional types. Take it apart with x, y = t. |
{x: 1, y: "a"} | Record | The set of fields and their types, for example {x: Integer, y: String}. Take it apart with r => {x:, y:}. |
Any[1, "a"] | Array | Not declared. Any value can be added. |
Float[], Point[p] | Array of T | T. Every write is checked. |
pair = [1, "one"] # a literal is a Tuple
n, word = pair
puts(word)
puts(Tuple.size(pair))
xs = Any[1, "two", 3.0] # Any[...] has no declared element type
Array.push(xs, nil)
p(xs)
ws = String.split("to be or not to be", " ")
p(ws)
puts(Array.map(ws) { String.capitalize(it) }.join(" "))
fs = Float[] # T[...] declares the element type
Array.push(fs, 1.5, 2.5)
puts(Array.sum(fs))
Array.push(fs, 3)
$ sake collections.sake collections.sake:17:1: error: Array.push: an element must be Float, but is Integer [type] (exit status 2)
A Record is read with a pattern. r => {mean:, count: n} binds mean and n. Field order does not matter, and a Record is never a value of a class, even when the fields match.
def stats(xs)
total = Array.sum(xs)
{count: Array.size(xs), total: total, mean: total / Array.size(xs)}
end
r = stats(Any[3, 5, 10])
p(r)
r => {mean:, count: n} # take fields apart by name
puts(mean)
puts(n)
p({y: 2, x: 1}) # field order does not matter
r => {median:}
$ sake records.sake records.sake:12:7: error: the pattern needs field `median`, but the value is {count: Integer, mean: Integer, total: Integer} [type] (exit status 2)
In Sake, [] and {} are not growable collections. Ruby code that grows them stops with a hint:
squares = [] # a Tuple of length 0, not an Array
Integer.times(3) { |i| Array.push(squares, i * i) }
$ sake ruby_habits.sake ruby_habits.sake:2:24: error: Array.push: argument 1 must be Array, but is [] [type] hint: `[...]` is a Tuple with a fixed length; for a growable Array, write `Any[...]` (exit status 2)
counts = {}
names = {"a" => 1}
$ sake empty_braces.sake empty_braces.sake:1:10: error: `{}` is an empty Record, not a Hash hint: for a Hash, write `Hash.new`; for a growable list, `Any[]` empty_braces.sake:2:10: error: `{"a" => ...}` is not a Hash in Sake: `{name: value}` makes a Record hint: for a Hash, write `Hash.of("a" => ...)` (exit status 2)
Indexing
x[k] runs the [] of x's type, and x[k] = v its []=. Array, Hash, String, Tuple, and MatchData have them.
- A miss gives
nil, as in Ruby, so with--strictyou checka[i]before using it.Array.fetchraises an error instead. - A Tuple's length is part of its type, so reading outside it is an error.
- Writing a Tuple position needs a value of that position's type.
words = String.split("the quick brown fox", " ")
p(words[0])
p(words[-1])
p(words[10]) # outside the Array: nil, as in Ruby
words[1] = "slow"
p(words)
counts = Any[0, 0, 0]
counts[1] += 1
p(counts)
t = [1, "one"]
t[0] = 2 # a Tuple keeps the type of each position
p(t)
best = nil
best ||= Array.max(Any[3, 9, 4])
p(best)
p(t[2]) # a Tuple's length is part of its type
$ sake indexing.sake indexing.sake:19:3: error: Indexable.[]: the index is outside the Tuple [Integer, String] [type] (exit status 2)
Point[1, 2] means "an Array of Point". It is not Ruby's shorthand for Point.new(1, 2). When the mistake is visible in the source, it is reported before running:
Point = Struct.new(:x, :y)
ps = Point[1, 2]
$ sake typed_array_errors.sake typed_array_errors.sake:2:12: error: Point[]: element 1 must be Point, got Integer hint: Point.new(1, 2) creates one Point; Point[...] is an Array of Point (exit status 2)
More on Arrays: Array.new(n, v) and Array.new(n) { |i| … }, + - *, element assignment as a multiple-assignment target (a swap), two indexes s[start, length], and a start value for sum, which an empty Array gives instead of the Integer 0:
grid = Array.new(3, 0) # three zeros; Array.new(3) { |i| i * i } computes each
squares = Array.new(4) { |i| i * i }
p([grid, squares])
p([Any[1, 2] + Any[3], Any[1, 2, 2, 3] - Any[2], Any[0] * 3])
a = Any[1, 2, 3]
a[0], a[2] = a[2], a[0] # swap: elements (and fields) can be assignment targets
p(a)
p("sake-lang"[0, 4]) # two indexes: start and length
key, value, extra = String.split("lang=sake", "=") # missing elements are nil, as in Ruby
p([key, value, extra])
prices = Float[1.25, 2.5]
p(Array.sum(prices, 0.0)) # the start value: an empty Array sums to 0.0, not 0
$ sake arrays_more.sake
[[0, 0, 0], [0, 1, 4, 9]]
[[1, 2, 3], [1, 3], [0, 0, 0]]
[3, 2, 1]
"sake"
["lang", "sake", nil]
3.75Tuples tagged by a Symbol
An Array of Tuples whose first element is a Symbol tag keeps each kind apart. Testing the tag, after |kind, arg| takes a Tuple apart (or with case t[0]), tells the type of the rest:
events = Tuple[] # an Array of Tuples, filled below
Array.push(events, [:deposit, 500])
Array.push(events, [:note, "rent due"])
Array.push(events, [:withdraw, 120])
balance = 0
Array.each(events) do |kind, arg|
case kind # the Symbol tag tells which Tuple it is,
in :deposit then balance += arg # so here arg is an Integer
in :withdraw then balance -= arg
in :note then puts("note: #{String.upcase(arg)}") # and here a String
end
end
p(balance)
$ sake tagged.sake
note: RENT DUE
380Where a type is worth writing
A function needs no type: its operations say what it accepts. A class's field needs none either: its type comes from the values written to it, which T.new and T.set_x show. The one place a type pays is an Array that is filled later. Made with Any[], its element type is whatever gets pushed, so a wrong value is reported where the elements are used, far from where it went in:
def labels(n)
out = Any[] # any element type: it is whatever gets pushed
Integer.times(n) { |i| Array.push(out, "#{i}") }
out
end
p(Array.sum(labels(3))) # the mistake shows up here, far from the push
$ sake elements_untyped.sake elements_untyped.sake:6:3: error: Array.sum: an element must be Integer|Float|Rational|Complex, but is String [type] (exit status 2)
Made with its element type, Integer[] (or Float[], Point[], Tuple[]), every push is checked where it happens. The type is written on the operation that makes the Array, not on a variable:
def labels(n)
out = Integer[] # an Array of Integer
Integer.times(n) { |i| Array.push(out, "#{i}") } # the mistake shows up where it is made
out
end
p(Array.sum(labels(3)))
$ sake elements_typed.sake elements_typed.sake:3:26: error: Array.push: an element must be Integer, but is String [type] hint: reached by the call at line 6 (exit status 2)
Rules
- An Array of T checks each element on creation and on
push,append, andconcat. There is no conversion, so an Integer cannot go intoFloat[]. map,select,sort, and similar operations return an Array with no declared element type.- The contents of a Tuple or Record may be replaced by values of the same type. A Tuple is written with
t[i] = v; a Record has no write syntax yet. The length is part of the type.
Strings and text
String and Regexp operations follow Ruby's, including the positions that a parser needs: String.index(s, t, start), Regexp.match(re, s, pos) (with \G anchored at pos), String.byteslice, and sub/gsub with a replacement String, a Hash, or a block.
s = "key=value; name=Sake; lang=ruby"
p(String.index(s, "=")) # a position
p(String.index(s, "=", 4)) # searching from position 4
m = Regexp.match(/(\w+)=(\w+)/, s, 11) # matching from a position
p([m[1], m[2]]) if m
p(String.gsub(s, /(\w+)=/) { |kv| String.upcase(kv) }) # a block gives each replacement
p(String.gsub("cat hat", /[ch]at/, Hash.of("cat" => "dog", "hat" => "cap")))
p(String.split(s, "; "))
p(format("%-6s|%5.2f|%03d", "ab", 3.14159, 7))
p(String.bytes("é")) # UTF-8 bytes
p(String.unpack("\x01\x00\x02\x00", "v*")) # binary data, as Ruby's unpack
p(Array.pack(Any[72, 105], "C*"))
$ sake strings.sake
3
15
["name", "Sake"]
"KEY=value; NAME=Sake; LANG=ruby"
"dog cap"
["key=value", "name=Sake", "lang=ruby"]
"ab | 3.14|007"
[195, 169]
[1, 2]
"Hi"Rules
- There are no globals such as
$1or$~. Keep the MatchData:m = String.match(s, re), thenm[1]. "#{x}",puts, andformat("%s")use a type's ownto_s.- Strings of incompatible encodings meeting (binary bytes next to UTF-8 text) raise
EncodingError. Broken UTF-8 given to a regexp raisesArgumentError. String.unpack,unpack1, andArray.packread and write binary data with Ruby's formats.
Hash, Set, Range
Hash, Set, Symbol, and Range work as in Ruby. Their operations carry the type like everything else (Hash.each, Range.to_a), while h[k], 1..5, and :name are written as in Ruby.
Hash.of(k => v)andHash.new(0)make a Hash, because{…}is a Record.- A missing key gives
nil, or the default given toHash.new. - A block over a Hash receives
[key, value], which|k, v|takes apart.
text = "the cat and the hat and the bat"
counts = Hash.new(0)
Array.each(String.split(text, " ")) { |w| counts[w] += 1 }
p(counts)
top = Hash.max_by(counts) { |w, n| n }
p(top)
p(Array.tally(String.chars("banana")))
ages = Hash.of("ann" => 31, "bob" => 25)
ages["cy"] = 40
Hash.each(ages) { |name, age| puts(format("%-4s %3d", name, age)) }
p(ages["nobody"]) # a missing key gives nil
p(Hash.fetch(ages, "nobody", 0))
p(Range.to_a(1..5))
p(Range.select(1..20) { it % 7 == 0 })
p(Any[10, 20, 30, 40][1..2])
seen = Set.new
Array.each(Any[3, 1, 3, 2, 1]) { Set.add(seen, it) }
p(seen)
m = String.match("2026-10-01", /(\d+)-(\d+)-(\d+)/)
p(MatchData.captures(m)) if m
p(String.scan("a1b22c333", /\d+/))
p(:ok)
$ sake ruby_types.sake
{"the" => 3, "cat" => 1, "and" => 2, "hat" => 1, "bat" => 1}
["the", 3]
{"b" => 1, "a" => 3, "n" => 2}
ann 31
bob 25
cy 40
nil
0
[1, 2, 3, 4, 5]
[7, 14]
[20, 30]
Set[3, 1, 2]
["2026", "10", "01"]
["1", "22", "333"]
:okRules
- Hash keys and Set elements compare as
==does: numbers, Strings, Symbols,true,false,nil, Time, and Tuples, Records, Arrays, Hashes, Sets and values of a class made of these. A class that defines its own==(or<=>withComparable) cannot be a key. A Tuple or Record key is copied when stored. Hash.new { … }is not available, because a block cannot be stored.a[1..2]ands[1..3]slice an Array or a String.
nil
A value that may be absent is simply nil: its type is nil | T, with no Option wrapper. Check it with if x, while x, x != nil, or return unless x. When a nil reaches an operation that needs something else, the error tells you where it can come from.
Node = Struct.new(:value, :next)
def build(n)
head = nil
Integer.downto(n, 1) { |i| head = Node.new(i, head) }
head
end
def sum(node)
total = 0
while node # nil and false are the only falsy values
total += Node.value(node)
node = Node.next(node)
end
total
end
def second(node)
rest = Node.next(node)
return 0 if rest == nil
Node.value(rest)
end
list = build(4)
puts(sum(list))
puts(second(list))
puts(Node.next(list) == nil)
puts(Node.next(list).next.next.next.value)
$ sake nil.sake 10 2 false nil.sake:28: in <main>: TypeError: Node.value: argument 1 must be Node, got nil hint: Node.next may be nil (nil is stored at line 5) hint: check the value first: `if x`, `while x`, `return unless x`, or `x != nil` (exit status 1)
By default (level 1), an unchecked nil is caught when it runs. --strict (level 2) reports every unchecked use before running. A local variable you have tested counts as checked. A field you read again does not, because fields are mutable.
Node = Struct.new(:value, :next)
def second(node) = Node.next(node).value
def second_checked(node)
rest = Node.next(node)
rest ? Node.value(rest) : 0
end
list = Node.new(1, Node.new(2, nil))
puts(second(list))
puts(second_checked(list))
$ sake strict.sake
2
2$ sake --strict strict.sake
2
2| You write | The local x is known to be |
|---|---|
if x, while x, x && … | not nil, where x was truthy |
x != nil, x == nil | not nil, or nil, in the matching branch |
return unless x, next if x == nil | not nil after that line |
Exceptions
raise, rescue, else, ensure, and retry work as in Ruby. An exception type is declared with Exception.new: it is a class whose first field is message. class B < A is a copy, so rescue A does not catch B; to catch several types together, include a module in them and write rescue M.
ParseError = Exception.new(:line) # an exception type: message, then its own fields
def parse(s)
raise ParseError.new("empty input", 1) if String.empty?(s)
raise ArgumentError, "too long" if String.size(s) > 5
String.to_i(s)
end
Array.each(Any["", "123456", "42"]) do |s|
begin
p(parse(s))
rescue ParseError => e # list the types to rescue (a module groups several)
puts("line #{ParseError.line(e)}")
rescue ArgumentError => e
puts(Exception.message(e))
ensure
puts("--")
end
end
def safe_div(a, b)
a / b
rescue ZeroDivisionError
0
end
p(safe_div(1, 0))
v = Hash.of("a" => 1).fetch("b") rescue -1
p(v)
$ sake exceptions.sake
line 1
--
too long
--
42
--
0
-1The checker tracks which exceptions each function may raise. It warns about a rescue that can never match (unused-rescue; the program still runs); level 4 also reports a raise that nothing rescues:
$ sake --strict=4 exceptions_flow.sake exceptions_flow.sake:11:1: warning: rescue OtherError: the begin body never raises OtherError [unused-rescue] hint: remove this rescue, or raise OtherError in the body exceptions_flow.sake:5:3: error: raise: ParseError may reach the top level without being rescued [unrescued] hint: rescue it, or check with a level below 4 (exit status 2)
Rules
raise "msg"raisesRuntimeError.raise T, "msg"works when T has onlymessage; otherwise writeraise T.new(…).rescue => ecatches everything rescuable;eis a union, so narrow it withcase e in ….Exception.message(e)reads any exception's message.SystemStackError,LocalJumpError, andNotImplementedErrorare program errors: they cannot be rescued.TypeErrorandNoMatchingPatternError(a failedx => Torcase/in) can be, as in Ruby.
Values computed once
Sake has no value constants such as PI = 3.14: a constant would be a value with no operation naming its type. A function gives the value instead, def pi = 3.14159. When the value is costly to build, such as a table, once { … } computes it the first time its place in the program runs, and keeps it for the whole program:
# Sake has no value constants. `once { ... }` computes a value the first time its place runs and
# keeps it for the whole program: a table built once, read by every call.
def squares = once do
puts("building the table")
Array.map(Range.to_a(0..9)) { |i| i * i }
end
def weekday(i) = once { String["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] }[i % 7]
p(squares[4])
p(Array.sum(squares)) # the same table: "building" is printed once
p(weekday(1))
p(weekday(10))
$ sake once.sake
building the table
16
285
"Mon"
"Wed"Rules
- Each
oncein the source is one value, shared by every call and every thread. - A
oncethat needs its own value while computing it raisesSystemStackError.
Programs
Files, libraries, and arguments
require "lib/web" reads lib/web.sake next to the requiring file, once. When there is no such file, it reads Sake's library, sakelib/. The argument is a string literal and require is a statement at the top of a file, so the files of a program are decided before running. A required file's top-level statements run first, and every message names its file.
# require "json" reads json.sake next to this file, or else Sake's library sakelib/json.sake.
require "json"
require "base64"
data = JSON.parse("{\"name\": \"Sake\", \"tags\": [\"ruby\", \"types\"]}", symbolize_names: true)
p(data)
puts(JSON.generate(Hash.of(ok: true, n: Any[1, 2])))
puts(JSON.pretty_generate(Hash.of("a" => Any[1, 2])))
p(Base64.strict_encode64("Sake"))
p(ARGV) # the program's arguments: bin/sake libraries.sake a b
warn("a warning goes to stderr")
$ sake libraries.sake
{name: "Sake", tags: ["ruby", "types"]}
{"ok":true,"n":[1,2]}
{
"a": [
1,
2
]
}
"U2FrZQ=="
[]
a warning goes to stderrsakelib/ holds ports of Ruby's standard library, each tested against Ruby's own output: json, csv, base64, digest, zlib, strscan, shellwords, uri, cgi, erb, optparse, logger, benchmark, date, matrix, prime, abbrev, and tsort. Their calls are Ruby's with the type in front: JSON.parse(s, symbolize_names: true), CSV.parse(text, headers: true), Digest::SHA256 as SHA256.hexdigest(s). Each library's notes (sakelib/notes/) list what differs from Ruby.
Rules
ARGVis the Array of the program's arguments (bin/sake prog.sake a b). It is written like Ruby's constant but is an operation: every use gives the same Array.exit(status)ends the program (ensureclauses run);warn(msg)writes to stderr.- A
requireinside a function or a block, or with a computed name, is an error before running.
Input and output
puts, print, p, and gets work as in Ruby. To choose where text goes, take an IO value: IO.stdout, IO.stderr, IO.stdin, or a file from File.open. There are no globals such as $stdout; as with ARGV, the value comes from an operation. Files and streams are one type, IO, so one function writes to either.
# IO values: IO.stdin, IO.stdout, IO.stderr, and File.open. One type, IO, for all of them.
def report(io, title, rows)
IO.puts(io, "== #{title}")
Array.each(rows) { |k, v| IO.puts(io, format("%-5s %3d", k, v)) }
end
rows = Any[["ann", 31], ["bob", 25]]
report(IO.stdout, "to stdout", rows)
path = "io_example.tmp"
File.open(path, "w") { |f| report(f, "to a file", rows) } # closed after the block
File.open(path) do |f|
p(IO.gets(f))
p(IO.readlines(f))
end
File.delete(path)
$ sake io.sake
== to stdout
ann 31
bob 25
"== to a file\n"
["ann 31\n", "bob 25\n"]Rules
File.open(path, mode = "r")gives an IO; with a block, it gives the block's value and closes the file after the block.IO.puts,print,write,gets,read,readlines,each_line,eof?,flush, andclosetake the IO first.File.read,File.write, andFile.readlineswork on a path.- Reading or writing a closed IO raises
IOError. Writing toIO.stderrflushes stdout first, so the two keep their order.
Threads and sockets
Thread.new { … } runs a block in a new thread. The block shares the variables around it, as every block does; Queue passes values between threads and Mutex.synchronize guards shared state. TCPServer and Socket give a server and a client, enough for a small web server written in Sake.
# Threads share the variables around their block; a Queue passes values between them.
q = Queue.new
workers = Array.map(Range.to_a(1..3)) do |id|
Thread.new do
sum = 0
Range.each(1..(id * 100)) { |i| sum += i }
Queue.push(q, [id, sum])
end
end
Array.each(workers) { |t| Thread.join(t) }
results = Array.new(3) { Queue.pop(q) }.sort
p(results)
lock = Mutex.new
count = 0
ts = Array.map(Range.to_a(1..4)) { Thread.new { Integer.times(1000) { Mutex.synchronize(lock) { count += 1 } } } }
Array.each(ts) { |t| Thread.join(t) }
p(count)
$ sake threads.sake
[[1, 5050], [2, 20100], [3, 45150]]
4000Rules
Thread.value(t)gives the block's value, and the checker knows its type. An error in a thread is raised again byThread.valueorThread.join.TCPServer.new(host, port),TCPServer.accept,Socket.connect(host, port),Socket.gets,read,write, andclose; network errors raiseIOError.
Seeing the types
--types runs a whole-program type inference without running the program. For every operation that checks its argument, it reports one of the following:
- proven: the check always passes;
- partial: the value's type is a union, so the check is still needed;
- error: the check always fails;
- unknown: the inference could not tell.
It also lists the element type of each Array and the type of each field. None of these types were written in the program. The playground (ide/) shows the same results in its Types tab and on hover.
Item = Struct.new(:name, :price, :qty)
def total(items) = Array.reduce(items, 0) { |acc, it| acc + Item.price(it) * Item.qty(it) }
items = Item[Item.new("apple", 120, 3), Item.new("pear", 200, 1)]
puts(total(items))
cheap = Array.select(items) { Item.price(it) < 150 }
puts(Array.map(cheap) { Item.name(it) }.join(", "))
$ sake types.sake
560
apple$ sake --types types.sake
passes: 2
checks: proven=13 partial=0 error=0 unknown=0
sites: arrays 3, structs 2 (Item 2); instantiations 1
arrays:
L5: declared Item
L7 Array.select: init Item -> Item
L8 Array.map: init String -> String
fields:
Item@L5.name: String
Item@L5.price: Integer
Item@L5.qty: Integer
Item@L5.name: String
Item@L5.price: Integer
Item@L5.qty: IntegerOn the strict program from the nil section, it finds the one unchecked use:
$ sake --types strict.sake
passes: 2
checks: proven=4 partial=0 error=0 unknown=0
sites: arrays 0, structs 2 (Node 2); instantiations 2
arrays:
fields:
Node@L10.value: Integer
Node@L10.next: nil
Node@L10.value: Integer
Node@L10.next: Node@L10A complete program
A bank account with a history. Its fields are read-only from outside (attr_reader); the functions in its class change them through @balance and @history. The history is made with Tuple[] and holds [kind, amount] Tuples tagged by a Symbol, which a do |kind, amount| block takes apart.
class Account # read-only from outside; changed only below
attr_reader owner, balance, history
def open(owner) = Account.new(owner, 0, Tuple[]) # the history holds Tuples
def deposit(acct, amount)
@balance += amount
Array.push(@history, [:deposit, amount])
acct
end
def withdraw(acct, amount)
if @balance < amount
Array.push(@history, [:refused, amount])
return false
end
@balance -= amount
Array.push(@history, [:withdraw, amount])
true
end
def statement(acct)
puts("Statement for #{@owner}")
Array.each(@history) do |kind, amount|
puts(format(" %-10s%6d", kind, amount))
end
puts(format(" %-10s%6d", "balance", @balance))
end
end
alice = Account.open("Alice")
Account.deposit(alice, 500)
Account.withdraw(alice, 120)
Account.withdraw(alice, 1000)
Account.statement(alice)
$ sake bank.sake
Statement for Alice
deposit 500
withdraw 120
refused 1000
balance 380Reference
Notation
Everything Sake writes differently from Ruby, on one page. The rest is Ruby's syntax.
| Written | Means |
|---|---|
require "lib/web" · require "json" · require "sql/*" | Reads lib/web.sake next to this file, else sakelib/json.sake, once, before running; a glob reads every matching file; a string literal at the top level only |
T.op(x, args…) | The operation op of type (or module) T, subject first. A call on a value, x.op, is rejected. |
x.T.op(args…) | The same as T.op(x, args…): a chain that reads left to right |
_ | The value of the previous statement in the same body |
(A|B).op(x) | The op of x's type, which must be A or B (checked before running) |
M.op(x) (M a module) | A module_function of M, or the op of x's type, which includes M (dispatch) |
f(x) | The enclosing class or module's f, else a top-level function, else Kernel's |
a + b, -a, a[k] | The operator of a's type; your own type joins with include Arithmetic (Comparable, Bitwise, Indexable) and def +(a, b) |
!x | x ? false : true |
class C · attr_reader a · attr_accessor b | A type with fields: C.new(a, b), C.a(c), c.C.b = v; field names are written bare; def initialize(c) sets the fields new left out |
def initialize(c) | Runs after C.new stores the fields: checks and conversions |
class B < A | A's definitions written again in B (fields, functions, includes); not inheritance, a B is not an A |
class E < Exception | An exception type: message is its first field |
module A · class B inside it · A::B.f(x) | Namespaces nest: B is A::B; inside A, a bare B means A::B |
Enum.map(x) { } · include Enum | The prelude's Enumerable: dispatches to Array, Hash, Set, Range, or a class with include Enum and def each |
C = Struct.new(:a, :b) | Shorthand for class C with attr_accessor a, b |
E = Exception.new(:line) | An exception type with fields message and line |
@a · @a = v · @a += v | Inside a function of class C: field a of the function's first argument (C.a(c), C.set_a(c, v)) |
def C.f(c) | Shorthand for def f(c) inside class C |
[a, b] | A Tuple: fixed length, a type per position |
{x: a, y: b} | A Record: its fields and their types; read with r => {x:, y:} |
Any[a, b] · T[a, b] | A growable Array; with T, an Array of T whose writes are checked |
Hash.of(k => v) · Set.of(a, b) | A Hash, a Set ({} is a Record) |
x in T · case x in T then … | A type test; the branch knows x is a T |
x => T · r => {x:, y:} | A type assertion (afterwards x is a T); taking a Record apart |
first, *rest = xs · |a, *rest| · f(*xs) | The rest as an Array; a splat only into a rest parameter (a built-in's, or a function's *rest) |
def area(s) = raise(NotImplementedError) (in a module) | Each type that includes the module must define area |
def f(a, b = 1, c:, d: 2) · f(x, d: 5) · f(x, c:) | Optional and keyword parameters, as in Ruby; keywords are matched by name before running |
block_given? · def f(xs, &b) = g(xs, &b) | Whether a block was given; passing the function's block on (never a value) |
once { … } | A value computed the first time this place runs, then kept: in place of constants |
ARGV · IO.stdout · IO.stderr · IO.stdin | The program's arguments and streams, given by operations (no globals) |
What Sake rejects
Sake rejects anything that would hide which code runs, and anything not designed yet. All of it is reported together, before running:
Point = Struct.new(:x, :y)
PI = 3.14159
class Point
def origin = self
end
eval("1 + 1")
$ sake forbidden.sake forbidden.sake:2:1: error: Sake has no value constants; a constant names a class (`Point = Struct.new(:x, :y)`, `Err = Exception.new(:msg)`), and a value goes in a function hint: define a function instead: `def pi = 3.14159` forbidden.sake:4:16: error: Sake has no `self` forbidden.sake:6:1: error: `eval` is not allowed in Sake (it defeats static analysis) (exit status 2)
Rules
- Never allowed:
self,@xoutside a function of a class,send,method_missing,define_method, theevalfamily,instance_variable_get/set,const_get/set, and subtypes: a value has exactly one type. - By design: there are no value constants, and no globals. Define a function instead (
def pi = 3.14159), withonce { … }for a value built once (Values computed once);ARGVandIO.stdoutare operations.
Built-in operations
Names follow Ruby's core library. The subject is the first argument. Operations marked "block" require one.
Kernel
| Operation | Returns | Notes |
|---|---|---|
puts(*xs) | nil | Prints as Ruby does. Arrays and Tuples print one element per line. |
print(*xs) | nil | No newline |
p(x) | x | Ruby's inspect format, e.g. #<struct Point x=1, y=2> |
gets | String or nil | A line of stdin |
format(fmt, *xs) · Integer(s) · Float(s) | String · Integer · Float | As Ruby; a bad number raises ArgumentError |
warn(*xs) · exit(status) · ARGV | nil · — · Array of String | stderr; ends the program; the arguments |
dup(x) | a copy of x | as Ruby's obj.dup; a type that defines dup gets its own |
loop { } · Math::PI · Float::INFINITY | the break's value · Float | constants are read as operations, like ARGV |
Arithmetic.round(x, [n]) · abs · to_f · floor · ceil | per x's type | any of Integer, Float, Rational, as Ruby's x.round |
once { } · block_given? | the block's value · true / false | See once and Blocks |
Integer
| Operation | Returns |
|---|---|
+ - * / % ** (both Integer) | Integer |
< <= > >= == != (both Integer) | true / false |
to_s · to_f | String · Float |
abs · succ · pred | Integer |
even? · odd? · zero? | true / false |
times(n) { |i| } | n (block) |
upto(a, b) { |i| } · downto(a, b) { |i| } | a (block) |
Float
| Operation | Returns |
|---|---|
+ - * / % ** (both Float) | Float |
< <= > >= == != (both Float) | true / false |
to_s | String |
to_i · floor · ceil · round(f) | Integer |
round(f, digits) · abs | Float |
nan? | true / false |
String
| Operation | Returns |
|---|---|
+(a, b) · *(s, n) | String |
== != < <= > >= (both String) | true / false |
length · size · count(s, chars) · to_i | Integer |
to_f | Float |
upcase · downcase · capitalize · swapcase · reverse · strip · lstrip · rstrip · chomp · to_s | String |
sub(s, pat, repl) · gsub(s, pat, repl) | String; pat a String or Regexp, repl a String (with \1), a Hash, or a block |
ljust(s, n, [pad]) · rjust(s, n, [pad]) | String |
empty? · include?(s, t) · start_with?(s, t) · end_with?(s, t) | true / false |
chars · lines · split(s, [sep]) | Array of String |
index(s, t, [start]) · rindex · byteindex | Integer or nil |
match(s, re, [pos]) · match? · scan(s, re) | MatchData or nil · true / false · Array |
bytes · byteslice(s, i, [n]) · unpack(s, fmt) · b | Array of Integer · String or nil · Array · String |
each_char(s) { |c| } | s (block) |
Array
| Operation | Returns |
|---|---|
Any[…] · T[…] | a new Array (of T) |
length · size | Integer |
empty? · include?(a, x) | true / false |
push(a, *xs) · append(a, *xs) · concat(a, b) | a, changed in place |
join(a, [sep]) | String |
sum(a, [start]) | the sum (elements must be numbers); start, default the Integer 0, when empty |
Array.new(n, [v]) · Array.new(n) { |i| } | a new Array of n elements |
reverse · sort · take(a, n) · drop(a, n) | a new Array |
each(a) { |x| } · each_with_index(a) { |x, i| } | a (block) |
map · select · filter · reject · sort_by | a new Array (block) |
any? · all? · none? | true / false (block) |
count(a) { |x| } | Integer (block) |
reduce(a, init) { |acc, x| } · inject | the accumulated value; init is required (block) |
at(a, i) · first · last · min · max · pop · shift | an element, or nil (pop and shift remove it) |
first(a, n) · last(a, n) · fetch(a, i, default) | a new Array · the element or default |
each_cons(a, n) · each_slice(a, n) · each_with_index(a) without a block | an Array of slices / of [x, i] (Ruby's each_cons(2).map) |
fetch(a, i) | the element, or IndexError |
unshift(a, *xs) | a, changed in place |
find · detect · min_by · max_by | an element, or nil (block) |
index(a, x) · find_index(a) { |x| } | Integer or nil |
Hash, Set, Range, Regexp, IO
| Operation | Returns |
|---|---|
Hash.of(k => v) · Hash.new(default) · Set.of(…) | a new Hash or Set |
Hash.fetch(h, k, [default]) · dig · delete · key | the value (or KeyError) · value or nil |
Hash.keys · values · to_a · map · sort_by | Array (pairs are [k, v] Tuples) |
Hash.each · select · transform_values · merge · count · min_by | as in Ruby |
Set.add · include? · union · | & - | as in Ruby |
Range.each · to_a · map · select · sum · include? · step | as in Ruby (iteration needs an Integer start) |
String.match · Regexp.match · String.scan · =~ | MatchData or nil · Array · Integer or nil |
gets · File.read · File.readlines · File.write · File.exist? | String or nil · String · Array · Integer · true/false |
IO.stdin · IO.stdout · IO.stderr · File.open(path, [mode]) { |f| … } | IO values; IO.puts(io, …) · IO.gets(io) · IO.readlines(io) · IO.close(io) … |
Thread.new { } · Thread.value · Queue.new · push · pop · Mutex.synchronize { } | as in Ruby |
Array.tally · group_by · to_h · zip · partition · each_slice · uniq · flat_map | as in Ruby (Tuples where Ruby gives pairs) |
The complete list of the built-in operations is in docs/builtins.md, generated from the interpreter.
Tuple and Math
| Operation | Returns |
|---|---|
Tuple.length · Tuple.size | Integer |
Tuple.max([a, b]) · Tuple.min · Tuple.minmax | the largest / smallest of a few values (Ruby's [a, b].max); never nil |
Math.sqrt · cbrt · sin · cos · tan · atan · exp · log · log2 · log10 (Integer or Float) | Float |
Math.atan2(y, x) · Math.hypot(x, y) | Float |
Errors
Before running (exit status 2)
All errors are reported at once, in source order, and nothing runs.
FILE:LINE:COLUMN: error: MESSAGE hint: SUGGESTION
Errors found before running:
- syntax errors;
- unknown types, operations, and functions;
- a wrong number of arguments, and an unknown, repeated, or missing keyword argument;
- a
requirethat is not a string literal at the top level, or a file that cannot be read; - a block given where none is taken, or missing where one is required;
- calls on values;
- forbidden or unsupported syntax;
- duplicate definitions;
- a literal of the wrong type in
T[…]; - by default (level 1), a value whose type does not fit the operation, such as
"" + 1, and ayieldreached without a block; - with
--strict(level 2), a value that may benilused without a check; - with
--strict=3, the result ofx[k]used without a check, and acasewhose literal branches may miss a value.
While running (exit status 1)
FILE:LINE: in FUNCTION: KIND: MESSAGE from FILE:LINE: in CALLER hint: SUGGESTION
| Kind | When |
|---|---|
TypeError | An operation got the wrong type, an operator the left operand's type does not support, a write to an Array of T has the wrong type, or a multiple assignment is not from a Tuple or an Array |
IndexError | A Tuple index outside the Tuple, Array.fetch outside the Array, or a write past the end of an Array of T |
ArgumentError | A block parameter count mismatch, a negative size, Integer ** negative, or sort on values that cannot be compared |
ZeroDivisionError | Integer / or % by zero |
FloatDomainError · Math::DomainError | NaN or Infinity converted to Integer; for example, Math.sqrt(-1) |
KeyError | Hash.fetch of a missing key; a Record pattern naming a missing field |
IOError · EncodingError | A file or socket error, or a closed IO · Strings of incompatible encodings meeting |
RuntimeError | raise "message" |
SystemStackError | Recursion deeper than 10,000, or a once that needs its own value |
LocalJumpError | A yield without a block (cannot be rescued) |
Not yet supported
Each of these is rejected before running. Most are waiting on a design decision.
- Writing to Record fields
- The type scope
Integer.(a + b) case/when(usecase/in),%w[],%i[]for: not planned; iterate with an operation such asRange.each(1..3) { |i| … }- Patterns other than types, literals, alternatives, Record shapes and Tuple patterns
[P, Q](*restin a Tuple pattern, find patterns, pins, guards,x => Integer => n) - Storing a block:
proc,lambda,->, and&bas a value. A prototype type-checks them at a linear cost, but blocks kept in one container are then checked with each other's argument types, which gives false reports; blocks stay second-class for now