Tutorials - The language
Learning ArkScript: the basics
In ArkScript, there are two main rules:
- The first rule is that
(a b c ...)
is a function call,a
is the function withb c ...
being the arguments. - The second rule is the only exception to the first rule: when declaring functions like
(fun (arg1 arg2 ...) body)
, the definition of the argument block isn't considered as a function call.
To sum it up: everything is a function call, except the definition of the arguments block of a function.
Creating variables
mutables
and immutables
.
The immutable variables can't be modified or redefined, while mutable variables can.The following three keywords are important when talking about variables:
let
defines animmutable
variablemut
defines amutable
variableset
changes the value of a given mutable variable (will throw an error on immutable variables)
Example:
(let a 12) # immutable variable
(let a "hello") # cannot redefine constant
(set a "world") # cannot modify constant, will throw an error
(mut b [12 42 64]) # mutable variable
(mut b "hello") # no problem, the operation is allowed
(set b "ArkScript is cool!") # no problem, operation is allowed on mutables
Program structure
An ArkScript program is a collection of blocks. A block (function arguments...)
, thus, those are valid programs:
(print "hello world")
(let a 12)
(let foo (fun (a b)
(+ a b)))
(print (foo a a))
Multiple blocks can be put into one by using the begin construction:
(begin
(let a 12)
(print a)
(let b (* a 2))
(print b))
# and can be accessed from outside, begin doesn't create a scope(print a " " b)
(begin)
and {}
are synonyms.
Basic Input/Output
User interactions are a must have in a programming language. To achieve such interaction in a shell (the big black windows where our code is running), we have what we call IO or input/output, through print
and input
. One can write text to the shell, the other can prompt the user and retrieve what they wrote.
Example: (print "hello" " world")
, will print hello world
.
The print function won't put spaces between each element printed, thus we have to do it ourselves.
(let a (input "what is your name?")) # will print what the user wrote,
(print a) # after having validated by pressing Enter
# The prompt is optional, (input) will
# also work on its own.
Conditions
In any programming language, it's useful to control the program flow, to be able to give the user multiple choices (attacking an enemy, befriending it, spying on it...) and those things are achieved through conditions.
Constructing a condition is done like so:
(if condition then else)
The else bloc is optional.
Examples:
(let a 11)
(let b 15)
(if (= a 12)
# then
(print "a is 12")
# else
(print "a is not 12"))
(if (and (< a 12) (> b 14))
(print "a is < 12 AND b is > 14"))
The then and the else parts can be composed of multiple functions by using the begin construction.
Loops
Giving the user a choice is a thing, but repeating an action is another that is very useful in a program as well. For example, if we need to compute the sum of values in a list, we would need to loop over the values of the list. In video games, we would need loops to generate waves of enemies.
Loops in ArkScript are created by using the keyword while.
Example:
(import std.random)
# continue must be a mutable for us the be able to modify it
(mut continue true)
(while continue {
(print "hello")
# 10% chance of stopping the loop
# random returns a number in range [0, 32768[
(if (< (/ (random) 32768) 0.1)
(set continue false)) })
# another example using conditions
(mut i 0)
(while (< i 10) {
(puts i " ") # won't put a \n at the end of the content
(set i (+ 1 i)) })
Functions
Functions are a tool to factorize code, to follow the DRY (don't repeat yourself) principle. Who would want to write 10 times the same 100 lines when they can use a function and call it 10 times inside a loop?
Note: ArkScript was particularly optimized to deal with function using few arguments, thus encouraging code reuse and code split into functions.
A function is composed of 2 parts: the argument lists and the body:
(fun (a b c) (print a b c))
(a b c)
is the argument list, the print bloc is the body.
The value returned by a function is the last evaluated value in the body, if none, nil is returned.
Example:
(let foo (fun (a b) (begin
(print "function got: " a " " b)
# return value:
(+ a b))))
(print (foo 12 14)) # 26
Also, we have a set of builtins functions in the language, available without importing anything ; for example print
or input
, which we used before.
Note that builtins must be called, you can't do things like (let my_print print)
, or (let my_tail tail)
, otherwise it will result in an
error because those functions are special.
Closures
Closure, or function closure, is a way to implementing lexically scoped name binding1. It stores a function along with an environment, explicitly mapped with specified variables. This allows to reuse and modify captured variables each time the closure is called.
(let make_closure (fun (name age) {
(let coolness_factor 12)
# here, we return a closure!
(fun (&name &age &coolness_factor)
# each time it will be called, it will display the captured variables
(print name " " age " " coolness_factor)) }))
(let closure (make_closure "Pietro" 42))
(closure) # prints Pietro 42 12
Closures capture variables explicitly in their arguments' list, by prefixing them with &
. We can access the captured fields through the closure.field
notation, in a read only way:
(let make_closure (fun (name age) {
(let coolness_factor 12)
# here, we return a closure!
(fun (&name &age &coolness_factor)
# each time it will be called, it will display the captured variables
(print name " " age " " coolness_factor)) }))
(let closure (make_closure "Pietro" 42))
(closure) # prints Pietro 42 12
(print closure.age) # 42
(print closure.coolness_factor) # 12
# we can print closures and have their fields and values displayed
(print closure) # prints (.name=Pietro .age=42 .coolness_factor=12)
(let make (fun (a)
(fun (&a) ())))
(let foo (fun () (print "bar")))
(let closure_bis (make foo))
# we can also call captured functions
(closure_bis.a) # prints bar
Finally, you can modify the closure content when using it from the inside, through itself or its captured functions:
(let make (fun (name age) {
(let set-age (fun (new)
(set age new)))
(fun (new-name &name &age &set-age)
(if (not (nil? new-name))
(set name new-name))) }))
(let egg (make "egg" 1))
(print egg.age) # 1
(egg.set-age 2)
(print egg.age " " egg.name) # 2 egg
(egg "not an egg")
(print egg.name " " egg.age) # not an egg 2
Closures can also be compared, the same way you would want to compare structs in C or C++:
(let make (fun (name age)
(fun (&name &age) ())))
(let egg (make "egg" 1))
(let bacon (make "bacon" 1))
(print (= egg bacon)) # false, egg and bacon share the same fields but their values are different
(let make_other (fun (a b c)
(fun (&a &b &c) ())))
(let next (make_other 1 2 3))
(let bis (make_other 1 2 3))
(print (= next egg)) # false, next and egg do not share the same fields
(print (= next bis)) # true, next and bis have the same fields and values
(print (= bis bis)) # true, bis is bis
Importing code
Putting code in multiple files is pretty to make it reusable and more maintainable.
In ArkScript, imported code is copied from the specified file into the current one, with a guarantee: circular includes are detected and prevented, making execution always possible even if you include a lot of files.
Files are imported as follows: (import myfile)
. The path to the target file is relative to the source file, not to the main executed file.
(import package.sub.folder.file)
will import package/sub/folder/file.ark
.
The only exception about paths in import
s is when you import an ArkScript module, ending in .arkm
. Those files are either in the standard
library, thus you can just write their name and ArkScript will find them, or they must be alongside the final executed file.
When importing files from the standard library, you don't need to write the path to the library folder, just the path of the file in it prefixed by std
.
For example: (import std.String)
will work without problems.
Running functions asynchronously
Available in ArkScript v3.4.0
Functions can be pretty useful to define tasks, but sometimes we don't want to block our program to wait for a task to finish, for example fetching an HTML page. With ArkScript async/await you can submit the request and do other work that's waiting while the HTTP request is being processed.
For a simpler example, let's calculate the sum of numbers in a list:
(let size 1000)
(let data (list:fill size 1)) # create a list of a thousand 1's
(let sum (fun (a b src) {
(mut acc 0)
(while (< a b) {
(set acc (+ acc (@ src a)))
(set a (+ 1 a)) })
acc }))
(let task (async sum 0 size data))
# do something else...
# now we need the result, let's retrieve it
(print (await task))
async
can launch any function in a separate thread, given a set of arguments for the functions. Then it will work without you having to do anything,
and you can retrieve the result by using await
. It will check if the function is done, otherwise wait for it (blocking the thread of the caller) and return its result.
Using macros to manipulate code at compile time
Available in ArkScript 3.1.0
A macro is a rule or pattern that specifies how a certain input should be mapped to a replacement output. Applying a macro to an input is name macro expansion2.
In ArkScript, there are 3 different types of macros:
- conditions:
($if condition then else)
- constants:
($ my_const value)
- functions:
($ my_function (foo bar) body)
Constant macros are just associations identifier to value
, the value being whatever you want (even another bloc of code, for example
(let b 12)
). The code is scanned and when such macro is found, it's applied wherever possible.
Macros' scopes are tied to the bloc in which they are defined. At the end of said bloc, the macros defined in it are destroyed. Note that a macro defined a in bloc, which includes other blocs, will be available in all the other sub-blocs.
Named macros can be undefined by using ($undef name)
.
($ a 12)
{
(print a) # will print 12, it works!
($ a 1) # we can shadow macros by defining other macros with the
# same name in sub-blocs
(print a) # 1
($undef a)
(print a) # 12, because we undefined the nearest version of a
}
(print a) # a is still 12 here
Condition macros may only work on compile time expressions, using only other macros. For example:
($ foo 12)
($ bar 14)
# the condition being true will result in the print being inserted in the program
# while the (let a) will be deleted
($if (and (= foo 12) (= bar 14))
(print "foo is 12 and bar is 14")
(let a (+ 12 14)))
In a condition macro we can define other macros and use other conditions macros:
($ a 12)
($if (= a 12)
($ b 14)
($ c 13))
(print b) # prints 14
(print c) # compilation error: unbound variable c
# c is unavailable here because it was never defined
Function macros are evaluated recursively, thus they can call themselves or other macros, and use condition macros. A particularity is that
their arguments can ArkScript code blocs, such as a (let a 12)
or even complex code blocs like
{ (mut i 0) (while continue { (print "hello") (set i (+ 1 i)) (if (> i 12) (set continue false)) }) }
.
Those macros can use a magic pattern ...args
(args being the name of the argument, you can use whatever you want) as the last argument to tell the
compiler that the macros can take any number of arguments. This is called varargs or variadic arguments.
($ foo (a b) {
(print a " " b)
(let c (+ a b))})
(foo 1 2)
(print c) # prints 3
($ bar (a ...args)
(print a " " args))
(bar 1) # prints 1
(bar 1 2) # prints 1 [2]
(bar 1 2 3) # prints 1 [2 3]
Here is a more complex example implementing the thread macro. The first argument is the data, then each function is applied onto it, one after another.
It allows us to write more readable code, instead of the ugly (read-string (slurp (io:file (io:resource filename))))
.
($ -> (arg fn1 ...fn) {
($if (> (len fn) 0)
(-> (fn1 arg) ...fn)
(fn1 arg))})
(let filename "hello.json")
(let io:resource (fun (file) {
(print "io:resource")
file }))
(let io:file (fun (name) {
(print "io:file")
name }))
(let slurp (fun (a) {
(print "io:slurp")
a }))
(let read-string (fun (a) {
(print "read-string")
a }))
(print (-> filename io:resource io:file slurp read-string))
# it will print:
# io:resource
# io:file
# io:slurp
# read-string
# hello.json
Here is the list of the available compile time functions, to work with macros (in macros):
- Comparison operators:
=
,!=
,<
,<=
,>
,>=
- Chaining conditions / inverting them:
not
,and
,or
- Working on lists:
len
,@
,head
,tail
We also have a few predefined macros to work on ArkScript code and ease code generation. For example, one
can generate a new symbol using ($symcat symbol value-or-expression)
, or count the number of arguments of a function with
($argcount function-name)
.
$symcat
: generate a new symbol from a given symbol and a value or expression.(- a 1)
witha
a constant macro (or macro argument) is valid.$argcount
: retrieve at compile time the number of arguments taken by a given function. The function must have been defined before using$argcount
, or must be an anonymous function:($argcount (fun (a b c) ()))
,($argcount my-function)
.$repr
: return the AST representation of a given node, as a string.($repr foobar)
will return"foobar"
,($repr (fun () (+ 1 2 3)))
will return"(fun () (+ 1 2 3))"
. Indentation, newlines and comments are not preserved.$paste
: paste a given node without evaluating it any further. Useful to stop the evaluation of arguments passed to a function macro.
Comments
# text
. This is a comment, only for the developer, and will be totally ignored when compiling and executing.