Goblin is a postmodern take on a 90s scripting language. It features:
- dynamic types
- immutable by default
- lightweight objects, without classes or inheritance
- pattern matching
- novel syntax
Goblin is "research language" in the sense that it is really more intended to be an object of study and discussion, rather than a tool one uses to actually make software. But it is a real language with an implementation that does more or less everything that is described in this document.
Literals for numbers & strings:
1 # an integer
1.0 # a float
"Hello, world!" # a string
Send messages to values:
"Hello, world!"{uppercase} # => "HELLO, WORLD!"
"Hello, world!"{from: 0 to: 5} # => "Hello"
Operators are syntactic sugar for sending messages. (TODO: short explanation of operator precedence)
1 + 2 # 1{+: 2}
-1 # 1{-}
let
bindings & identfiers:
let a := 1
let _a long identifier name_ := 2
(TODO: link to more on bindings: destructuring, placeholders, etc)
Parentheses are used to disambiguate operator precedence, but they also create new binding scopes, and can contain statements.
let a := (
let b := 1
let c := 2
b + c
) # => 3
let a := () # => `unit`, an object with no methods
if
expressions:
let result := if count = 0 then
"no items"
else if count = 1 then
"1 item"
else
count{to String} ++ " items"
end
Objects are collections of message handlers:
let greetings := [
on {hello}
"Hello!"
on {hello: name}
"Hello, " ++ name ++ "!"
on {hello twice}
self{hello} ++ " " ++ self{hello}
]
greetings{hello} # => "Hello!"
greetings{hello: "world"} # => "Hello, world!"
greetings{hello twice} # => "Hello! Hello!"
Handlers return the value of their final expression, but you can return
early:
let obj := [
on {results: results}
if results{length} = 0 then
return "no results"
end
return "some results"
]
There are no "classes" in Goblin; instead, we use objects that construct other objects:
let Point := [
on {x: x y: y} [
on {x} x
on {y} y
on {manhattan distance: other}
(x - other{x}){abs} + (y - other{y}){abs}
]
]
let origin := Point{x: 0 y: 0}
let point := Point{x: 1 y: 2}
point{manhattan distance: origin} # => 3
Most values in Goblin are immutable; "setters" instead return a new value:
let Point := [
on {x: x y: y} [
on {x} x
on {y} y
on {x: x'}
Point{x: x' y: y}
on {y: y'}
Point{x: x y: y'}
]
]
let a := Point{x: 1 y: 2}
let b := a{y: 3} # => Point{x: 1 y: 3}
There are no "functions" in Goblin; instead, we use objects with a single handler.
import [_Vec_] := "core"
let items := Vec{}, 1, 2, 3
let mapper := [
on {: value}
value * 2
]
let mapped := items{map: mapper} # => Vec{}, 2, 4, 6
When an object has only one handler, we can elide the on
:
let mapped := items{map: [{: value} value * 2]}
There is no "pattern matching" in Goblin, either; instead, we send a pattern object to a receiver object, and the receiver then sends a message to the pattern object:
let Option := [
on {some: value} [
on {: pattern}
pattern{some: value}
]
on {none} [
on {: pattern}
pattern{none}
]
]
let pattern := [
on {some: value}
value
on {none}
0
]
let a := Option{some: 5}
a{: pattern} # => 5
let b := Option{none}
b{: pattern} # => 0
objects can be "destructured" in let
bindings:
let [x: x y: y] := Point{x: 1 y: 2}
# equivalent to
# let temp := Point{x: 1 y: 2}
# let x := temp{x}
# let y := temp{y}
(TODO: link to more on objects & pattern matching)
Frames are a shorthand for creating simple objects with common behaviors:
let a := [x: 1 y: 2]
a = [x: 1 y: 2] # => true
a{x} # => 1
a{x: 2} # => [x: 2 y: 2]
a{->y: [{: value} value + 1]} # => [x: 1 y: 3]
a{:[
on {x: x y: y}
x + y
on {x: x y: y z: z}
x + y + z
]} # => 3
NOTE: The expression []
produces a frame with a blank key; for an object with no methods, use ()
.
(TODO: link to more on frames)
var
creates a binding that can be reassigned using set
. Only the binding is mutable; the value associated with the binding is immutable, and accessing a var
binding gets its current value:
var x := 1
let a := x # => 1
set x := 2
let b := x # a = 1, b = 2
There is a shorthand for setting a variable "in place":
var p := [x: 1 y: 2]
set p{y: 3} # set p = p{y: 3}
Var bindings cannot be closed over by objects:
var x := 1
let a := x
let obj := [
on {foo}
set x := 2 # compile error
let value := x # compile error
let value := a # ok
]
Handlers can take var
parameters:
var x := 1
let obj := [
on {inc: var counter}
set counter := counter + 1
]
obj{inc: var x} # x = 2
A do
block is an object that uses its parent context instead of creating its own. For example:
self
refers to the outer objectreturn
returns from the outer handlervar
bindings in the outer handler can be accessed andset
Do blocks are written as objects without brackets, and are typically used for pattern matching, or where other languages would use anonymous functions. However, the increased flexibility for the sender has corresponding restrictions on the receiver:
do
parameters must be annotated- a
do
parameter cannot be stored or returned; only sent a message or sent in another message
let List := [
on {nil} [
on {: do match}
match{nil}
on {map: do f}
self
]
on {head: h tail: t} [
on {: do match}
match{head: h tail: t}
on {map: do f}
let h' := f{: h}
let t' := t{map: f}
List{head: h' tail: t'}
]
]
let obj := [
on {find: item in: list}
list{:
on {nil}
return [not found]
on {head: h tail: t}
if h = item then
return [ok]
end
return obj{find: item in: t}
}
on {sum: list}
var sum := 0
list{map: {: item}
set sum{+: item}
}
sum
]
There are no global variables in Goblin -- even true
and false
must be imported from the core library. However, pervasive access to global resources (eg. the current time, logging) or application context (eg. the current user) is often desirable. Goblin enables both of these with provide
and using
, which propagate and access values via dynamic scope:
let log := [
on {: message}
using {logger: l}
l{: message}
]
let mock_logger := [
on {: message}
# drop messages
()
]
log{: "hello"} # logs to system logger
(
provide{logger: mock_logger}
log{: "hello"} # logs to mock_logger
)
log{: "goodbye"} # back to system logger
(TO IMPLEMENT: provide/using vars & do blocks, clearing context, module-level context allowlists)
defer
executes after a handler exits, whether thats by returning itself, returning within a do block, or encountering a runtime error. This is most useful for managing constrained system resources, e.g. file handles:
import [_os_] := "core"
let file := [
on {with: path do: do block}
let handle := os{open: path}
defer
handle{close}
end
block{: handle}
]
the try-send operator ?
allows for a default value if an object does not handle a message:
let defaults := [x: 1 y: 2]
let params := [x: 3]
let x := params{x} ? defaults{x} # => 3
let y := params{y} ? defaults{y} # => 2
(TODO: link to try-send idioms)