The purpose of this documenation is to briefly describe every aspect of the Juniper programming language, and provide usage examples. If you're already proficient with another functional programming language this documentation should be sufficient to get you up to speed on Juniper. Juniper is a ML family language, so knowing another ML family language (ex: Haskell, OCaml, SML, F#) will be of great benefit.
All language features can be seen in the language grammar file.
Juniper modules are used to organize code into logical groups and prevent naming collisions. Each module is defined in its own .jun file. A Juniper module should always start with the module keyword, followed by the module name. The name of the module should be the same as the name of the .jun file. The open() declaration is then used to import all exported declarations from another module into a current module. Otherwise, the module name followed by a colon and the declaration name ModuleName:declarationName can be used to refer to a declaration in another module.
Single line comments in Juniper begin with two slashes //. A multiline comment begins with /* and ends with */.
Juniper functions are declared using the fun keyword, followed by the name of the function, and an optional return type. Let's take a look at a simple function which will double a number.
fun doubleMe(x : int16) : int16 = x + x
As you can see in the example above, the parameters of the functions are placed in parentheses and the colons indicate type constraints.
The general structure of a function declaration is as follows:
fun funname(a1 : t1, a2: t2, ..., an : tn) : rettype where constraint1, ..., constraintM = expr
If expressions are used to control whether or not the program enters a section of code. In Juniper, if statements return values, much like the ternary operators in imperative languages like Javascript and C++. If expressions without an else branch return unit.
Let's take a look at a simple if statement which is a slight modification of the previous function.
fun doubleSmallNumber(x : int16) : int16 = if x > 100 x else 2*x
The general structure of an if statement is as follows:
if condition truebranch else falsebranch
if condition truebranch
Juniper has support for many common operations:
Keyword | Unary/Binary | Meaning |
---|---|---|
&& |
Binary |
Logical and |
|| |
Binary |
Logical or |
! |
Unary |
Logical not |
+ |
Binary |
Addition |
- |
Binary |
Subtraction |
* |
Binary |
Multiplication |
/ |
Binary |
Division |
% |
Binary |
Modulo |
>= |
Binary |
Greater than or equal |
<= |
Binary |
Less than or equal |
> |
Binary |
Greater than |
< |
Binary |
Less than |
== |
Binary |
Equal |
!= |
Binary |
Not equal |
& |
Binary |
Bitwise and |
| |
Binary |
Bitwise or |
^ |
Binary |
Bitwise xor |
<< |
Binary |
Bitshift left |
>> |
Binary |
Bitshift right |
~ |
Unary |
Bitwise not |
- |
Unary |
Negate |
* |
Unary |
Dereference |
The general structure of a binary operation is:
expr binary-op expr
The general structure of a unary operation is:
unary-op expr
Parentheses can be used to group operators in different ways.
Juniper has a number of different primitive types built into the language: int8, uint8, int16, uint16, int32, uint32, int64, uint64, float, double, bool, unit, ptr and rcptr
The intN types represent a signed integer of size N bits. The uintN types represent an unsigned integer of size N bits. Values of integer types can be constructed simply by typing the number (ex: 6, 480). The type of a numeric integer literal is a where a : num, which means that literals are polymorphic. Depending on their context of use they will be inferred to be some numeric type. Integer literals can have a suffix appended to treat them as literals of a specific type. This suffix is uN (for unsigned integers) or iN (for signed integers). For example, 123u8 is an unsigned 8 bit literal.
float and double are two floating point types with different precisions. The float type takes up 32 bits and the double type takes up 64 bits. The advantage of the double type is that it is more precise than the float type. Floating point types can be constructed by typing the number with a decimal point (ex: 42.0, 3.14). Floating point literals are also polymorphic, and they have type a where a : real. A suffix of f or d can be appended to force the literal to be of float or double type respectively.
The bool type represents a boolean value which can either have the values true or false.
The unit type is a type with only one value, and it can be thought of as a zero element tuple. The value for the unit type can be constructed using empty parentheses ().
The ptr type is equivalent to a void * type, and is used for wrapping C/C++ library.
The rcptr type represents a reference counted smart pointer. The pointer type is really only useful when interacting with other C++ code. If you're writing a wrapper around a C++ library, you may come across situations in which it is necessary to use this type. This type is rarely encountered in typical Juniper programs. There is more information on how to construct elements of this type later in this page.
Tuple types are an ordered collection of values of different types. Tuples are constructed using parentheses with expressions separated by commas.
(expr1, expr2, ..., exprN)
The type of a tuple can be written as parentheses enclosed comma separated list:
(t1, t2, ..., tN)
For example, the two element tuple
(true, ())
Has type (bool, unit)
Record types represent aggregates of named values. Record types are structural, and are determined by the field names and field types. A record type is defined by a list of field names separated by commas and enclosed in curly braces. Each field name has a type associated with it, which is indicated by the colon followed by the type.
The general structure of a record type expression is:
{ fieldName1 : t1, fieldName2 : t2, ..., fieldNameN : tN }
A record can be constructed using a record expression. A record is constructed by a pair of curly braces enclosing field names and field initializing expressions.
The general structure of a record expression is as follows:
{ fieldName1 := expr1, fieldName2 := expr2, ..., fieldNameN := exprN }
Note that the fields do not have to appear in any particular order.
To access a particular field of a record, use a dot followed by the field name.
expr.fieldName
The type of a record is structurally based on the field names and types of fields of the record being created.
Records may be packed, in which case the order of the fields are important. Internally, a record which is packed is represented as a C/C++ packed struct. This makes a packed record useful for networking/communication situations, in which the field/byte ordering is important. To use a packed record, use the packed keyword before the record type and record expression.
The alias keyword is very useful when working with record types. See the section on type aliases below.
Algebraic data types in Juniper are variant types representing a tagged union. Each named variant has its own value constructor, which is just a function whose return type is the name of the algebraic data type.
Algebraic data types can be declared with the type keyword, followed by the type name and an optional template. This is followed by an equal sign and a pipe separated list of value constructors. Each value constructor has a name, followed by parenthesis and the argument types.
The general structure of an algebraic data type declaration is:
type typeName<template> = valCon1(t11, t12, ...) | valCon2(t21, t22, ...) | ... | valConN(tN1, tN2, ...)
The type of each value constructor is defined by the type of the arguments. Each value constructor may have 0 or more types as arguments. The closure type of a value constructor is empty.
Here is an example of an algebraic data type representing shapes. The circle value constructor takes in values representing the position of the center of the circle and its radius. The rectangle value constructor takes in values representing the position of the top left corner of the rectangle and its width and height.
type shape = circle(float, float, float) | rectangle(float, float, float, float)
circle is a function with the following type:
(float, float, float) -> shape
rectangle is a function with the following type:
(float, float, float, float) -> shape
Type aliases can be written using the alias keyword, followed by the alias name, an optional template, equal sign and a type expression. Type aliases in particular are very useful for record types, where we wish to assign a name to a particular record type (recall that record types are structural types).
alias name<template> = typeExpression
Beginning in Juniper 3.0, the closure of a function is stored in the function's type. This allows closures in Juniper to be completely stack allocated, and represents Juniper's solution to the funarg problem. Duly note that closure types can be used anywhere a type expression is accepted. A closure type is very similar to a record type, except that pipes "|" are used to enclose the type instead of curly braces. The field names in the closure type are the variable names of variables being captured, and the types are the types of these variables. Like other types in Juniper, closures can be automatically inferred by the type checking engine.
|varName1 : varTy1, ..., varNameN : varTyN|
The closure of a function appears in function types. The closure should be enclosed by parentheses before the argument type list. The syntax for a function type is:
(closureTy)(argTy0, ... argTyN) -> retTy
Note that top level functions and value constructors have empty closures ||.
Beginning in Juniper 4.0, when writing function types the closure may be ommitted, thereby allowing the type checking engine to do all inference. A function type without a closure is syntax sugar for use of a wildcard _ in a type expression. The following two types are equivalent:
(argTy0, ... argTyN) -> retTy
(_)(argTy0, ... argTyN) -> retTy
Beginning in Juniper 4.0, type expressions may contain wilcards via an underscore _, which tells the type checker that any type is acceptible in this position.
A sequence is a list of expressions enclosed in curly braces and separated by newlines. The return value of a sequence is the value returned by the last expression in the sequence. The return type of the sequence is the value returned by the last expression. For example, the following sequence is of type int32 and returns a value of 2.
{ true 2i32 }
The general structure of a sequence is:
{ expr1 expr2 ... exprN }
Sequences are often used when binding a variable using a let expression, which are discussed in the next section.
A let expression is used to bind a variable for later use. A let expression begins with the keyword let, followed by the variable name, and optionally a colon and the type of the variable. This is followed by an equals sign and an expression whose value will be assigned to the variable. Consider the following example:
{ let x : int32 = 5 let y = 3 x + y }
In this code, the variable x is bound, making it available in later expressions in the sequence. Then the variable y is bound, making it available as well. The return value of this expression is 8, and the return type is int32.
Type constraints can also be added to let expressions. Although not necessary in let expressions, a constraint may aid in catching type errors. A type constraint can be added by inserting a colon followed by the type after the variable name.
The general form of a simple let expression in a sequence is:
{ ... let varName = expr ... }
{ ... let varName : typeConstraint = expr ... }
In reality, let expressions are much more powerful than the simple examples presented above. See the section on pattern matching for more details.
Variables can be marked as mutable by inserting the mut keyword in front of the variable name. By default variables are immutable, which means that they cannot be changed after they are bound.
The mut keyword is usually used in conjunction with a set expression. A set expression begins with a left assign expression, an equal sign and then the new value. Here is a simple example of using mutation to sum an array of integers:
fun sum(arr : int32[n]) : int32 = { let mut sum = 0 for i in 0 .. n { sum += arr[i] } sum }
Set expressions can mutate certain fields of a record or a certain value in an array using the record dot notation or array square bracket notation.
Here is an example of mutating the field of a point record.
{ let mut p = { x := 2i32, y := 3i32 } p.x = 5 p }
Here is another example that uses the array square bracket notation. This function will add make a copy of the input array, add one to every value in the array, and then return that array.
fun addOne(arr : int32[n]) : int32[n] = { let mut ret = arr for i in 0 .. n { ret[i] = ret[i] + 1 } ret }
A reference is a pointer to a location in memory (on the heap). A value of type bool ref is a pointer to a location in memory, where the location in memory contains a boolean. It's similar to bool* in C/C++. A ref is like a box that can store a single value. The value inside of a reference can be accessed by placing an asterisk (*) before the reference expression. The contents of a reference can be updated by using an assignment/set expression keywords in a similar manner to the ordinary mutable variables above.
Here is an example of a function that will double the contents of a float ref. Notice that the return type is unit in this function, so this function is used just for its side effect.
fun double(x : float ref) : unit = { *x = 2.0 * (*x) () }
References can be created using the ref keyword. In this example we pass a reference to the function we defined above. The return value of this expression is 8.0.
{ let x : float ref = ref 4.0 double(x) *x }
In general a reference can be created using:
ref expr
The value of a reference can be retrieved using:
*expr
The contents of the reference can be changed using:
*exprL = exprR
Internally, the memory for references are managed by a reference counting system (also called a smart pointer in the C++ world). This means that if a cycle is created among references, the memory will fail to be freed.
Beginning in Juniper 4.0, a ref to a record can have its fields accessed or mutated using ->. For example:
{ let p = ref { x := 2i32, y := 3i32 } p->x = 5 p }
The var keyword was added in Juniper 3.0 and allows the declaration of a variable without having to initialize it. This is very useful in certain situations in which the variable will be initialized by C++ code. The syntax of var is as follows:
var name : tyExpr
The type constraint is optional and may be automatically inferred.
Here is a real world use of var from a Bluetooth low energy library wrapper, where we read a generic type from a Bluetooth low energy characteristic (which is a sort of shared "whiteboard" if you are not familiar with BLE):
fun readGeneric(c) : t = { let characterstic(p) = c var ret : t #((BLECharacteristic *) p)->read((void *) &ret, sizeof(t));# ret }
Functions are first class entities in Juniper. Lambdas (also called anonymous functions) are used extensively in Juniper. When combined with higher order functions, they allow very powerful methods of abstraction to be used. Functions in Juniper also support closures. However, variables marked as mutable will not be mutable inside of the lambda closure. Therefore a closure contains a "snapshot" of the variable environment at the point at which the lambda is declared.
Lambdas are declared using a pair of parentheses containing the arguments of the lambda, then the return type (optional), a right fat arrow => then the lambda body.
Here is an example of using a lambda in conjunction with the List:fold higher order function in order to sum a list of numbers.
fun sum(lst) = List:fold((x, total) => x + total, 0, lst)
Here is another example of the addOne example used in the previous section.
fun addOne(lst) = List:map((x) => x + 1, lst)
Here is an example of using a closure in Juniper. The return value of this expressions is 42. In this example the type of myFun is (|theAnswer : uint32|)() -> int32.
{ let myFun = { let theAnswer = 42u32 () => theAnswer } myFun() }
Juniper includes a number of different imperative loops. The loops supported are for loops, while loops and do while loops.
The general structure of a while loop and a do while loop are respectively:
while conditionExpr bodyExpr
do bodyExpr while conditionExpr
There are two types of for loops. To first is a simple linear increasing iteration that is very common in programming (note that the type for the index variable is optional):
for indexVar : type in lowExpr .. highExpr bodyExpr
This is roughly equivalent to the following C++ code:
for (type indexVar = lowExpr; indexVar < highExpr; i++) { bodyExpr }
For more advanced C/C++ style iteration, Juniper also contains generic for loops:
for initExpr; condExpr; updateExpr body
initExpr is typically a let expression initializing some variables, if condExpr is true the loop body is executed, and updateExpr is used to mutate the initialized variables.
The return type of all loops is unit.
Pattern matching is a powerful feature in many functional programming languages. Pattern matching ensures that some value conforms to some form while also providing a way to deconstruct it. Pattern matching in Juniper happens in a match expression and in the let expression.
A match expression in Juniper begins with the keyword match, followed by the value to pattern match on and then curly braces. This is followed by a list of whitespace separated matching clauses. Each match clause consists of a pattern followed by a fat arrow => then an expression to execute if the pattern matches. The underscore character _ can be used in a pattern to indicate a wildcard pattern match. Pattern matching can be used on numbers, value constructors, tuples and records.
In general the syntax is:
match valueExpr { pattern1 => expr1 pattern2 => expr2 ... patternN => exprN }
let pattern = valueExpr
Here is an example of deconstructing a tuple using pattern matching and the let expression. This function returns the third element of any tuple.
fun third(tup : (a, b, c)) : c = { let (_, _, x) = tup x }
Here is an equivalent function that uses a match expression:
fun third(tup : (a, b, c)) : c = match tup { (_, _, x) => x }
Of course we can always omit the type annotations and allow the inference engine to infer everything for us:
fun third(tup) = { let (_, _, x) = tup x }
Here is an example of using pattern matching on value constructors:
type color = red() | green() | blue() fun nextColor(c : color) : color = match c { red() => green() green() => blue() blue() => red() }
In the following example, a distance function is declared which gives the distance between two 2D points.
alias point = { x : float, y : float } fun distance(p1 : point, p2 : point) : float = { let { x := x1, y := y1 } = p1 let { x := x2, y := y2 } = p2 let dx = x1 - x2 let dy = y1 - y2 Math:sqrt_((dx * dx) + (dy * dy)) }
In Juniper, templates enable parametric polymorphism. Parametric polymorphism enables functions and types to be written which are generic over other types. Type variables are used to express this genericity. A template is declared using angle brackets. Inside is a comma separated list of type variables. These templates can be declared just after the name of a algebraic data type or alias.
As an example of a templated algebraic data type, here is the declaration for the maybe type, as declared in the Prelude module:
type maybe<a> = just(a) | nothing()
Here is an example of a function which operates on the maybe type, as declared in the Maybe module. The map function takes in another function, and uses it to map the value contained in the maybe if it is not nothing().
fun map(f : (a) -> b, maybeVal : maybe<a>) : maybe<b> = match maybeVal { just(val) => just(f(val)) _ => nothing() }
As we can see in the above example, types are templated in Juniper. Beginning in Juniper 4.0, functions cannot be explicitly templated. Instead the type inference engine implicitly universally quantifies all free variables. In the previous example, the types a and b are automatically templated.
Capacity expressions put compile time constraints on the sizes of data structures. In particular, the built in array type uses a capacity variable to determine the amount of space occupied in memory at compile time. Consider the following example:
let myBoolArray : bool[5] = [true, false, false, true, true]
In the above example, the number 5 is hard coded. Obviously hard coding the number will not be useful in general. To get around the need for hard coding, capacity variables are used. Capacity variables in template declarations are given by annotating that variable with : int. For example, the list record in the Prelude Juniper standard library module is defined as:
alias list<a, n : int> = { data : a[n], length : uint32 }
And here is the function last, as defined in the List module. In this case the capacity variable is n.
fun last(lst : list<t, n>) : t = lst.data[lst.length - 1]
Capacity variables are a restricted form of dependent types. This flattenSafe function takes in a list of lists and returns a list of capacity m*n.
fun flattenSafe(listOfLists : list<list<t, m>, n>) : list<t, m*n> = { let mut ret = zeros() let mut index = 0u32 for i : uint32 in 0u32 .. listOfLists.length { for j : uint32 in 0u32 .. listOfLists.data[i].length { ret[index] = listOfLists.data[i].data[j] index += 1 } } {data := ret, length := index} }
Integers in the type level can be used in type level expressions, as well as in the value level. However value level integers may not be used in types. Therefore Juniper does not support all features of a full dependently typed system. Type level integers when used in the value level have a type of int32.
The arithmetic operations that can be used on capacity variables includes multiplication, addition, subtraction and division. Parentheses can be used to group arithmetic operators.
An array is a series of elements of the same type that can be individually referenced by using an index as a unique identifier. In Juniper, an array also has a capacity associated with its type, which determines how much memory the array consumes.
Arrays can be created using an array literal syntax. The array literal syntax consists of a list of comma separated expressions surrounded by square brackets. The type of the expressions in the array literal must be the same. The array literal syntax is as follows:
[expr1, expr2, ..., exprn]
Which has type t[n], where t is the type of expr1, expr2, ..., exprn.
Arrays can be created using the Prelude:array(elem) function, which creates an array filled with a constant value. The Prelude:zeros() function may be used to create an array cleared with zero bytes.
Values of an array can be accessed using the square bracket array access notation:
arrayExpr[indexExpr]
There is currently no support for a list literal syntax. However, additional support for lists are planned for future releases. Currently a list is just a record type as defined in the Prelude module:
alias list<a, n : int> = { data : a[n], length : uint32 }
Using existing C++ libraries in Juniper is perhaps the most tricky and error prone part of the language. Typically if you wish to use a C++ library in your Juniper project, you should begin by writing a wrapper module around the library. The quickest way to get started is to take a look at already existing wrappers. Typically wrappers all follow the same pattern, so modifying an existing wrapper should be fairly straightforward. Here are some example wrappers bundled with the Juniper distribution:
Juniper allows C++ code to be written inline wherever an expression can be written. Inline C++ code is wrapped inside of an immediately invoked function, which means it is impossible to introduce variables into the current function scope. The return value of the immediately invoked function is unit, which means that the return value of any inline C++ code is unit. Inline C++ code is written between two hashtag # symbols. Therefore inline C++ code is executed for its side effects.
#Insert your C++ code here#
Juniper performs no name mangling of variable names, type names, or function names. This means that inline C++ can safely use these entities without restriction.
Beginning in Juniper 3.0 you can also write C++ in the top level module which is very useful for declaring global variables. Note that this C++ code is placed by the Juniper compiler before any top level Juniper global variables.
In Juniper there are two different types for passing around C++ pointers. These are the rcptr and ptr types. ptr simply compiles down to void * and is the easiest to use. Beginning in Juniper 3.0, the null keyword has type ptr and is equivalent to writing C++ nullptr. Typically ptr variables are marked as mutable, and then set inside a C++ code block. This acts to initialize the ptr variable.
The rcptr type is more complex and is used for automatically
managing the lifetime of a C++ object. The rcptr type
is roughly equivalent to a C++ shared_ptr
makerc(ptrval, finalizer)
Where ptrval has type ptr and finalizer has type (||)(ptr) -> unit. As the name suggests, the finalizer is a function that will clean up the object if the reference count drops to 0. Typically the finalizer is implemented using a C++ block, in which the ptr passed to the finalizer is casted to the proper type and is deleted with the C++ delete keyword. Also note that the closure of the finalizer must be empty.
To extract the underlying ptr from a rcptr, use the Prelude:extractptr function.
Headers can be included into Juniper compiled source code by using the include() declaration. An include declaration takes in a list of double quoted C++ header files to #include in the Juniper source code. For example, in the Neopixel wrapper library, this include declaration is used:
include("<Adafruit_NeoPixel.h>")
include("\"Adafruit_LSM303.h\"")
Juniper has a robust type inference algorithm and can infer most types in a program. The inference engine even includes an engine for solving type level arithmetic operations when dealing with capacities. One exception where explicit type annotations is if the body of a function contains a free type variable that cannot be deduced from an input parameter or return type. In this case the compiler will halt with a type error.
In Juniper 2.1.0 strings and character lists were added to the language. Strings can be created by placing extended ASCII characters inside double quotes. Character lists can be created by placing extended ASCII characters inside single quotes. Strings are pointers to a location in the program memory space, whereas character lists are stored on the stack. If you need to print debug messages, use strings. If your program needs to manipulate text, use character lists. Character lists are just lists of type uint8.
In Juniper 2.1.0 the pipe operator was added. It functions similarly to the pipe operator in F# and Elixir. The syntax for the pipe operator is:
expr |> myFunc(arg1, arg2, ..., argN)
which is equivalent to:
myFunc(arg1, arg2, ..., argN, expr)
The pipe operator takes an expression on its left side and a function invocation on its right side. The expression on the left is placed as the last argument of the function call on the right. This is useful for chaining a sequence of transformations of a signal or a list. The pipe operator is left associative.
Beginning in Juniper 3.0, constraints over types were introduced to allow for type safe numerical operators and type safe record access operators. These constraints over types operate very similarly to single parameter built in type classes. The easiest to understand constraints over types are num (satisfied by any numerical type), int (satisfied by any integer type), and real (satisfied by the float and double types). A constraint can be explicitly added by writing it after the where keyword in the function declaration. The constraint is made by writing the type expression to be constrained, followed by a colon, then the constraint. For example, the following is the definition of the add function in the Prelude module:
fun add(numA : a, numB : a) : a where a : num = numA + numB
Record constraints are slightly more advanced. Record constraints are written by writing the type expression to be constrained, followed optionally by packed, curly braces, then field names and type expression pairs within. The purpose of the record constraint is to constrain a type to have fields of certain names. Note that the types of the fields can even refer to type variables that are not explicitly used in parameter or return type annotations. This is because the concrete type of a record also uniquely determines the types of its fields. For example:
fun myExample(arg : a) : b where a : {myField0 : b, myField1 : int8} = arg.myField0
In the above example, the type a is constrained to be a record that must have fields named myField0 of any type and myField1 of type int8. Note that a can have more fields than this, but these are the fields it must have at a minimum.
Beginning in Juniper 4.0, inout parameters were introduced which nearly entirely alleviates the need to use ref types. inout parameters were added with the goal of completely removing the need to use the heap in most Juniper programs. Additionally inout parameters allow efficient in-place mutation of data structures such as lists. inout parameters are implemented using C++ references. In Juniper, a function parameter may be marked as an inout parameter by adding inout before the parameter name. When that function is called, the caller must place the inout keyword before the argument. For example, this implementation of List:set allow for efficient mutation of a Juniper list:
fun set(index : uint32, elem : t, inout lst : list<t, n>) : unit = if index < lst.length { lst.data[index] = elem }
Here is an example of mutating a specific location in the list using the inout functionality:
fun useListSet() = { let mut myList : list<_, 5> = List:replicate(5, false) List:set(0, true, inout myList) myList }
As you can see in the above example, inout can be used to mutate variables not local to the given function. Note that the contents of a reference cell can be mutated via an dereferencing and inout:
... myFun(inout *myRef) ...