/jakt

The Jakt Programming Language

Primary LanguageC++BSD 2-Clause "Simplified" LicenseBSD-2-Clause

The Jakt programming language

Jakt is a memory-safe systems programming language.

It currently transpiles to C++.

NOTE: The language is under heavy development.

NOTE If you're cloning to a Windows PC (not WSL), make sure that your Git client keeps the line endings as \n. You can set this as a global config via git config --global core.autocrlf false.

Usage

The transpilation to C++ requires clang. Make sure you have that installed.

jakt file.jakt
./build/file

Building

See here.

Goals

  1. Memory safety
  2. Code readability
  3. Developer productivity
  4. Executable performance
  5. Fun!

Memory safety

The following strategies are employed to achieve memory safety:

  • Automatic reference counting
  • Strong typing
  • Bounds checking
  • No raw pointers in safe mode

In Jakt, there are three pointer types:

  • T (Strong pointer to reference-counted class T.)
  • weak T? (Weak pointer to reference-counted class T. Becomes empty on pointee destruction.)
  • raw T (Raw pointer to arbitrary type T. Only usable in unsafe blocks.)

Null pointers are not possible in safe mode, but pointers can be wrapped in Optional, i.e Optional<T> or T? for short.

Note that weak pointers must always be wrapped in Optional. There is no weak T, only weak T?.

Math safety

  • Integer overflow (both signed and unsigned) is a runtime error.
  • Numeric values are not automatically coerced to int. All casts must be explicit.

For cases where silent integer overflow is desired, there are explicit functions that provide this functionality.

Code readability

Far more time is spent reading code than writing it. For that reason, Jakt puts a high emphasis on readability.

Some of the features that encourage more readable programs:

  • Immutable by default.
  • Argument labels in call expressions (object.function(width: 10, height: 5))
  • Inferred enum scope. (You can say Foo instead of MyEnum::Foo).
  • Pattern matching with match.
  • Optional chaining (foo?.bar?.baz (fallible) and foo!.bar!.baz (infallible))
  • None coalescing for optionals (foo ?? bar yields foo if foo has a value, otherwise bar)
  • defer statements.
  • Pointers are always dereferenced with . (never ->)
  • Trailing closure parameters can be passed outside the call parentheses.
  • Error propagation with ErrorOr<T> return type and dedicated try / must keywords.

Function calls

When calling a function, you must specify the name of each argument as you're passing it:

rect.set_size(width: 640, height: 480)

There are two exceptions to this:

  • If the parameter in the function declaration is declared as anon, omitting the argument label is allowed.
  • When passing a variable with the same name as the parameter.

Structures and classes

There are two main ways to declare a structure in Jakt: struct and class.

struct

Basic syntax:

struct Point {
    x: i64
    y: i64
}

Structs in Jakt have value semantics:

  • Variables that contain a struct always have a unique instance of the struct.
  • Copying a struct instance always makes a deep copy.
let a = Point(x: 10, y: 5)
let b = a
// "b" is a deep copy of "a", they do not refer to the same Point

Jakt generates a default constructor for structs. It takes all fields by name. For the Point struct above, it looks like this:

Point(x: i64, y: i64)

Struct members are public by default.

class

  • basic class support
  • private-by-default members
  • inheritance
  • class-based polymorphism (assign child instance to things requiring the parent type)
  • Super type
  • Self type

Same basic syntax as struct:

class Size {
    width: i64
    height: i64

    public function area(this) => .width * .height
}

Classes in Jakt have reference semantics:

  • Copying a class instance (aka an "object") copies a reference to the object.
  • All objects are reference-counted by default. This ensures that objects don't get accessed after being deleted.

Class members are private by default.

Member functions

Both structs and classes can have member functions.

There are three kinds of member functions:

Static member functions don't require an object to call. They have no this parameter.

class Foo {
    function func() => println("Hello!")
}

// Foo::func() can be called without an object.
Foo::func()

Non-mutating member functions require an object to be called, but cannot mutate the object. The first parameter is this.

class Foo {
    function func(this) => println("Hello!")
}

// Foo::func() can only be called on an instance of Foo.
let x = Foo()
x.func()

Mutating member functions require an object to be called, and may modify the object. The first parameter is mut this.

class Foo {
    x: i64

    function set(mut this, anon x: i64) {
        this.x = x
    }
}

// Foo::set() can only be called on a mut Foo:
mut foo = Foo(x: 3)
foo.set(9)

Shorthand for accessing member variables

To reduce repetitive this. spam in methods, the shorthand .foo expands to this.foo.

Arrays

Dynamic arrays are provided via a built-in Array<T> type. They can grow and shrink at runtime.

Array is memory safe:

  • Out-of-bounds will panic the program with a runtime error.
  • Slices of an Array keep the underlying data alive via automatic reference counting.

Declaring arrays

// Function that takes an Array<i64> and returns an Array<String>
function foo(numbers: [i64]) -> [String] {
    ...
}

Shorthand for creating arrays

// Array<i64> with 256 elements, all initialized to 0.
let values = [0; 256]

// Array<String> with 3 elements: "foo", "bar" and "baz".
let values = ["foo", "bar", "baz"]

Dictionaries

  • Creating dictionaries
  • Indexing dictionaries
  • Assigning into indexes (aka lvalue)
function main() {
    let dict = ["a": 1, "b": 2]

    println("{}", dict["a"])
}

Declaring dictionaries

// Function that takes a Dictionary<i64, String> and returns an Dictionary<String, bool>
function foo(numbers: [i64:String]) -> [String:bool] {
    ...
}

Shorthand for creating dictionaries

// Dictionary<String, i64> with 3 entries.
let values = ["foo": 500, "bar": 600, "baz": 700]

Sets

  • Creating sets
  • Reference semantics
function main() {
    let set = {1, 2, 3}

    println("{}", set.contains(1))
    println("{}", set.contains(5))
}

Tuples

  • Creating tuples
  • Index tuples
  • Tuple types
function main() {
    let x = ("a", 2, true)

    println("{}", x.1)
}

Enums and Pattern Matching

  • Enums as sum-types
  • Generic enums
  • Enums as names for values of an underlying type
  • match expressions
  • Enum scope inference in match arms
  • Yielding values from match blocks
  • Nested match patterns
  • Traits as match patterns
  • Support for interop with the ?, ?? and ! operators
enum MyOptional<T> {
    Some(T)
    None
}

function value_or_default<T>(anon x: MyOptional<T>, default: T) -> T {
    return match x {
        Some(value) => {
            let stuff = maybe_do_stuff_with(value)
            let more_stuff = stuff.do_some_more_processing()
            yield more_stuff
        }
        None => default
    }
}

enum Foo {
    StructLikeThingy (
        field_a: i32
        field_b: i32
    )
}

function look_at_foo(anon x: Foo) -> i32 {
    match x {
        StructLikeThingy(field_a: a, field_b) => {
            return a + field_b
        }
    }
}

enum AlertDescription: i8 {
    CloseNotify = 0
    UnexpectedMessage = 10
    BadRecordMAC = 20
    // etc
}

// Use in match:
function do_nothing_in_particular() => match AlertDescription::CloseNotify {
    CloseNotify => { ... }
    UnexpectedMessage => { ... }
    BadRecordMAC => { ... }
}

Generics

  • Generic types
  • Generic type inference
  • Traits

Jakt supports both generic structures and generic functions.

function id<T>(anon x: T) -> T {
    return x
}

function main() {
    let y = id(3)

    println("{}", y + 1000)
}
struct Foo<T> {
    x: T
}

function main() {
    let f = Foo(x: 100)

    println("{}", f.x)
}

Namespaces

  • Namespace support for functions and struct/class/enum
  • Deep namespace support
namespace Greeters {
    function greet() {
        println("Well, hello friends")
    }
}

function main() {
    Greeters::greet()
}

Type casts

There are two built-in casting operators in Jakt.

  • as? T: Returns an Optional<T>, empty if the source value isn't convertible to T.
  • as! T: Returns a T, aborts the program if the source value isn't convertible to T.

The as cast can do these things (note that the implementation may not agree yet):

  • Casts to the same type are infallible and pointless, so might be forbidden in the future.
  • If both types are primitive, a safe conversion is done.
    • Integer casts will fail if the value is out of range. This means that promotion casts like i32 -> i64 are infallible.
    • Float -> Integer casts truncate the decimal point (?)
    • Integer -> Float casts resolve to the closest value to the integer representable by the floating-point type (?). If the integer value is too large, they resolve to infinity (?)
    • Any primitive -> bool will create true for any value except 0, which is false.
    • bool -> any primitive will do false -> 0 and true -> 1, even for floats.
  • If the types are two different pointer types (see above), the cast is essentially a no-op. A cast to T will increment the reference count as expected; that's the preferred way of creating a strong reference from a weak reference. A cast from and to raw T is unsafe.
  • If the types are part of the same type hierarchy (i.e. one is a child type of another):
    • A child can be cast to its parent infallibly.
    • A parent can be cast to a child, but this will check the type at runtime and fail if the object was not of the child type or one of its subtypes.
  • If the types are incompatible, a user-defined cast is attempted to be used. The details here are not decided yet.
  • If nothing works, the cast will not even compile.

Additional casts are available in the standard library. Two important ones are as_saturated and as_truncated, which cast integral values while saturating to the boundaries or truncating bits, respectively.

Traits

To make generics a bit more powerful and expressive, you can add additional information to them:

trait Hashable<Output> {
    function hash(self) -> Output
}

class Foo implements(Hashable<i64>) {
    function hash(self) => 42
}

Traits can be used to add constraints to generic types, but also provide default implementations based on a minimal set of requirements - for instance:

trait Fancy {
    function do_something(this) -> void
    function do_something_twice(this) -> void {
        .do_something()
        .do_something()
    }
}

struct Boring implements(Fancy) {
    function do_something(this) -> void {
        println("I'm so boring")
    }

    // Note that we don't have to implement `do_something_twice` here, because it has a default implementation.
}

struct Better implements(Fancy) {
    function do_something(this) -> void {
        println("I'm not boring")
    }

    // However, a custom implementation is still valid.
    function do_something_twice(this) -> void {
        println("I'm not boring, but I'm doing it twice")
    }
}

Traits can have methods that reference other traits as types, which can be used to describe a hierarchy of traits:

trait ConstIterable<T> {
    function next(this) -> T?
}

trait IntoIterator<T> {
    // Note how the return type is a reference to the ConstIterable trait (and not a concrete type)
    function iterator(this) -> ConstIterable<T>
}

Safety analysis

(Not yet implemented)

To keep things safe, there are a few kinds of analysis we'd like to do (non-exhaustive):

  • Preventing overlapping of method calls that would collide with each other. For example, creating an iterator over a container, and while that's live, resizing the container
  • Using and manipulating raw pointers
  • Calling out to C code that may have side effects

Error handling

Functions that can fail with an error instead of returning normally are marked with the throws keyword:

function task_that_might_fail() throws -> usize {
    if problem {
        throw Error::from_errno(EPROBLEM)
    }
    ...
    return result
}

function task_that_cannot_fail() -> usize {
    ...
    return result
}

Unlike languages like C++ and Java, errors don't unwind the call stack automatically. Instead, they bubble up to the nearest caller.

If nothing else is specified, calling a function that throws from within a function that throws will implicitly bubble errors.

Syntax for catching errors

If you want to catch errors locally instead of letting them bubble up to the caller, use a try/catch construct like this:

try {
    task_that_might_fail()
} catch error {
    println("Caught error: {}", error)
}

There's also a shorter form:

try task_that_might_fail() catch error {
    println("Caught error: {}", error)
}

Rethrowing errors

(Not yet implemented)

Inline C++

For better interoperability with existing C++ code, as well as situations where the capabilities of Jakt within unsafe blocks are not powerful enough, the possibility of embedding inline C++ code into the program exists in the form of cpp blocks:

mut x = 0
unsafe {
    cpp {
        "x = (i64)&x;"
    }
}
println("{}", x)

References

Values and objects can be passed by reference in some situations where it's provably safe to do so.

A reference is either immutable (default) or mutable.

Reference type syntax

  • &T is an immutable reference to a value of type T.
  • &mut T is a mutable reference to a value of type T.

Reference expression syntax

  • &foo creates an immutable reference to the variable foo.
  • &mut foo creates a mutable reference to the variable foo.

Dereferencing a reference

To "get the value out" of a reference, it must be dereferenced using the * operator, however the compiler will automatically dereference references if the dereferencing is the single unambiguous correct use of the reference (in practice, manual dereferencing is only required where the reference is being stored or passed to functions).

function sum(a: &i64, b: &i64) -> i64 {
    return a + b
    // Or with manual dereferencing:
    return *a + *b
}

function test() {
    let a = 1
    let b = 2
    let c = sum(&a, &b)
}

For mutable references to structs, you'll need to wrap the dereference in parentheses in order to do a field access:

struct Foo {
    x: i64
}
function zero_out(foo: &mut Foo) {
    foo.x = 0
    // Or with manual dereferencing:
    (*foo).x = 0
}

References (first version) feature list:

  • Reference types
  • Reference function parameters
  • No reference locals
  • No references in structs
  • No references in return types
  • No mutable references to immutable values
  • Allow &foo and &mut foo without argument label for parameters named foo
  • Auto-dereference references where applicable

References TODO:

  • (unsafe) references and raw pointers bidirectionally convertible
  • No capture-by-reference in persistent closures

Closures (first version) feature list:

  • Function as parameter to function
  • Functions as variables
  • No returning functions from functions
  • Lambdas can throw
  • Explicit captures

Closures TODO:

  • [] Return function from function

Compiletime Execution

Compiletime Function Execution (or CTFE) in Jakt allows the execution of any jakt function at compiletime, provided that the result value may be synthesized using its fields - currently this only disallows a few prelude objects that cannot be constructed by their fields (like Iterator objects and StringBuilders).

Any regular Jakt function can be turned into a compiletime function by replacing the function keyword in its declaration with the comptime keyword, which will force all calls to that specific function to be evaluated at compile time.

Invocation Restrictions

Comptime functions may only be invoked by constant expressions; this restriction includes the this object of methods.

Throwing in a comptime context

Throwing behaves the same way as normal error control flow does, if the error leaves the comptime context (by reaching the original callsite), it will be promoted to a compilation error.

Side effects

Currently all prelude functions with side effects behave the same as they would in runtime. This allows e.g. pulling in files into the binary; some functions may be changed later to perform more useful actions.

comptime TODO

  • Implement execution of all Jakt expressions