Hello, Kex!
Kex is a functional-first language: immutable by default, pattern matching for
control flow, and side effects kept behind a type boundary. We will see what
those are in detail.
But, first let's start by printing a line:
IO.printLine("Hello, world!")
Every Kex program has a main entry point, but the REPL (Read-Eval-Print-Loop)
runs each line as you type it, so a bare expression just evaluates. Try editing
the string and pressing Enter:
In the next line it will also print out the type next to the value, which is String in this case.
Now the language's signature feature: any function can be called as if it were
a method on its first argument. x.f() and f(x) mean exactly
the same thing — there's no special "method" kind, only functions.
Both print "olleh". The dot form reads like a method call; the
paren form reads like a function call. Pick whichever makes your code
clearest.
Variables & types
Values are bound via let. Kex infers the type and the REPL prints
it after the colon, name is a String,
count an Int, with no annotations needed.
A let can't be reassigned. When you need something that changes, reach
for var instead and then you can give it a new value:
There's no null in kex. A value that might be missing is an
Optional<T> which is usually shortened to T?. It can either be Just(value) or None. The
function .or(default)
unwraps it, falling back when it's empty.
One more everyday type: 1..10 is a range (Range<Int> in this example), every integer from 1 to 10 without allocating a list up front.
Turn it into one with .to(List), or work with it lazily using the
same
map/filter/sum methods you'll meet next.
Functions
Functions are also defined with let similarly to value bindings, but
they can also take arguments and have function bodies, which can contain multiple
expressions:
To call them you can use the standard f(x) syntax, or the dot form
x.f()
Bigger functions usually consist of multiple expressions, where the last one is
the return value:
let fact(n: Integer) do
return 1 if n <= 1
n * fact(n - 1)
end
fact(4)
You can create functions, without naming them, these are called lambdas. These are very useful; for example, you can pass these as parameters to
other functions, as: { |args| body }:
[1, 2, 3].map { |n| n * 2 }
The above goes through the list and applies the lambda to each element,
returning a new list, doubling each element. The original list is unchanged,
as kex is immutable by default.
[1, 2, 3].filter { |n| n > 1 }
This one on the other hand uses filter
which keeps the ones for which the lambda returns true. Notice there are
no loops here, you describe
what to compute, and the list handles the iteration.
When a lambda just calls one method on its argument, there's a shorthand:
&.method stands in for { |x| x.method }.
It's common enough that you'll see it in almost every chain.
[1, 2, 3].map(&.to(String).or(""))
.to(String) converts each number to a string. Conversions can
fail, so it returns an Option — Just(value) on
success or None otherwise — and .or("")
unwraps it, substituting the default when there's no value.
Pattern matching
Pattern matching is one way of how Kex can branch. A match expression lists
arms, a shape on the left, its result on the right. The first arm
whose shape fits is the one that runs.
match 2 do
1 -> "one"
2 -> "two"
_ -> "many"
end
_ is the wildcard: it matches anything, so it works as a catch-all.
Arms are tried top to bottom, so put specific cases before general ones.
Patterns do more than compare values, they destructure
them. Here a tuple is unpacked into a and b in a single
step, no indexing or temporary variables.
match (3, 4) do
(a, b) -> a + b
end
This is also the honest way to handle an Optional: instead of papering over
the missing case with a single default, you spell out both.
match Just(5) do
Just(x) -> x
None -> 0
end
match None do
Just(x) -> x
None -> 0
end
Pattern matching aren't limited to match. A function can be
defined in multiple
clauses, each matching a different shape of argument. Clauses
are tried top to bottom, same as match arms:
factorial : Integer -> Integer
let factorial(0) = 1
let factorial(n) = n * factorial(n - 1)
factorial(5)
A trailing when narrows a clause with an extra condition — the arm
only fires if the guard holds too:
match 7 do
x when x < 0 -> "negative"
0 -> "zero"
_ -> "positive"
end
Records & Result
Kex models data directly. A record is a product type. Named, typed
fields, built with braces:
record Point do
x : Float
y : Float
end
let p = Point { x: 1.0, y: 2.0 }
A type union describes a value that's one of several shapes. Match
on it exactly like you matched Just/None in the last lesson:
type Shape = Circle(Float) | Square(Float)
let area(s) = match s do
Circle(r) -> Math.PI * r * r
Square(s) -> s * s
end
let c = Circle(2.0)
let s = Square(3.0)
area(c)
area(s)
Result is a union built into the prelude (the standard library
of Kex) for functions that can fail: Ok(value) or Error(reason). Write it as either Result<T, E> or you can use the syntax
sugar T or! E in a signature.
let safeDiv(a, b) -> Int or! String do
return Error("div by zero") if b == 0
return Ok(a / b)
end
safeDiv(10, 2)
Failure is a value, not an exception, match handles both cases explicitly,
the same honest way you handled None.
match safeDiv(10, 0) do
Ok(x) -> x
Error(msg) -> IO.printError("Oops: " + msg)
end
Attaching behavior with make
There's no class keyword in Kex. Behavior attaches to an existing type through make (including built-in types like Integer, [X]).
make Integer do
let dividesBy?(divisor: Integer) -> Bool do
return this.modulo(divisor) == 0
end
end
Operators like +, can also be defined, so + resolves per
receiver type just like any other function:
record Vector2D do
x : Float
y : Float
end
make Vector2D do
let +(other: Vector2D) -> Vector2D do
return Vector2D { x: @x + other.x, y: @y + other.y }
end
end
let v1 = Vector2D { x: 1.0, y: 2.0 }
let v2 = Vector2D { x: 3.0, y: 4.0 }
let v = v1 + v2
Inside make, this is the receiver, and @field is shorthand for this.field. Attach as many methods and
operators as the type needs — there's no inheritance, so behavior always lives
right next to the type it's written for.
You can also do pattern matching on the receiver in make clauses, using
the @ character for the match specifier:
make [X] do
head : X?
let head(@[]) = None
let head(@[x|_]) = Just(x)
end
Function chaining
Because x.f() is just f(x), you can chain calls left
to right. Each function takes the previous result as its first argument. The
whole chain reads like a sentence: "take this, then filter, then map, then
sum."
[1, 2, 3, 4].filter(&.even?).map { |n| n * n }.sum
Walk it step by step: filter keeps the even numbers ([2, 4]), map squares each ([4, 16]), and sum adds
them (20). No intermediate variables, no mutation. The original
list is untouched.
The same dot works on strings and ranges too. A sentence becomes a word count
in one line:
"hello world".split(" ").count
Purity
Functions in Kex are pure by default: the same input always yields
the same output, and calling them changes nothing about the outside world. It's
a strong guarantee, you can reason about any function in isolation, and refactor
freely.
Side effects, like printing, reading a file, talking to the network — must
live in
foul functions instead. The rule that keeps things honest:
pure code cannot call foul code, enforced when the program
type-checks. An effect can't sneak into the middle of your logic unnoticed.
IO.printLine("hello, effects")
The REPL's top level (and every main) is implicitly foul, so a
quick experiment like this just runs. In a real program you'd tuck the IO into
a small foul function at the edge, and keep everything else pure.
Effects run both ways, reading counts too. Click paste and answer the prompt:
A small project
Time to put the pieces together. A classic exercise: sum the numbers from 1 to
100. The imperative answer is a loop with a running total; the kex answer
describes the result directly.
1..100 is a range. reduce walks it carrying an accumulator,
starting at 0, and ~(+) is a reference to the + operator — so each step adds the next number to the running total. Out comes 5050.
Here's another: the sum of the odd squares up to ten, built from the same
ideas:
(1..10).filter(&.odd?).map { |n| n * n }.sum
Range, filter, map, sum, four small functions, one readable line. That's most
of Kex in a nutshell.