Skip to content

Instantly share code, notes, and snippets.

@WillBAnders
Created September 1, 2020 01:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save WillBAnders/4393fe3e7771312d6ea31321618bd83e to your computer and use it in GitHub Desktop.
Save WillBAnders/4393fe3e7771312d6ea31321618bd83e to your computer and use it in GitHub Desktop.

Rhovas is a programming language for API design and enforcement. Using Rhovas, developers can better express the contracts and intention of their code to help create correct, maintainable software.

As an experimental language, there's a number of features that are in flux and a lot of nuances missing from the implementation. The largest weakness right now is the lack of static analysis, which is where many of Rhovas's features really shine. Only so much you can do in a few weeks!

Overview

Rhovas is a multiparadigm, statically typed language that is most similar to Kotlin. Though Rhovas supports object-oriented code, functional programming concepts are used heavily and is (in general) the preferred style for most applications.

Below is a quick FizzBuzz example. For more examples, run the Rhovas Repl.it linked above (and at the bottom of this post).

func main() {
    range(1, 100, :incl).iterate {
        match([val.mod(3), val.mod(5)]) {
            [0, 0]: print("FizzBuzz");
            [0, _]: print("Fizz");
            [_, 0]: print("Buzz");
            else: print(val);
        }
    }
}

Innovation

Core Concepts

Contracts

Contracts represent the formal API of a function through preconditions (require), postconditions (ensure) and invariants (assert). This serves not only to document the intended API but also to make contract information available for static analysis. Finally, contracts can be selectively enabled or disabled at runtime, including only checking contracts across module boundaries.

An example of require/ensure with a sqrt function, which in this case returns the floor of the true result.

func sqrt(num: Integer): Integer {
    require num >= 0;
    ...
    ensure val * val <= num && (val + 1) * (val + 1) > num;
}

An example of invariants using assert. While the invariant can be broken inside of the rotate method, it is expected to be restored when the function returns.

class UnitVector {

    val x: Decimal;
    val y: Decimal;
    val z: Decimal;

    assert x * x + y * y + z * z == 1;

    ctor(.x, .y, .z) {}

    func +rotate(axis: UnitVector, radians: Decimal) {
        ...
    }

}

Influenced by:

Mutability

Types in Rhovas can have mutability permissions, which provides static restrictions on how an object can be mutated. There are currently three:

  • Readable (Type): Cannot be modified, but is not strictly immutable as other references to the same object may be mutable (c++ const)
  • Mutable (+Type): Can be modified freely
  • Immutable (-Type): Cannot be modified, and it is strictly immutable. However, there is not yet a guarantee for deeply immutable.

Restricting mutability offers benefits such as thread safety, but more importantly clarifies the intent of the API to the developer. Since mutability is part of the type system, it isn't necessary to have complex hierarchies for immutable types as some languages do (which is near impossible to do safely).

func append(list: +List<'T>, element: 'T) {
    list.add(element);
}

Additionally, Rhovas includes mutability permissions for functions for the state of the program. Methods which mutate the object are prefixed with + and pure functions are prefixed with -, as below.

class List<'T> {

    func +add(element: 'T) { ... }

    func -first(): 'T? { ... }

}

Influenced by:

  • Java collections & Guava immutable collections
  • C++ const & C# readonly
  • Pony reference capabilities
  • Functional Programming in general

Exceptions

Rhovas uses a system called 'Declared Exceptions' for error handling. Functions which can throw exceptions have names ending with !, and any exceptions thrown must be caught or declared in the containing function. The standard library is also designed to account for declared exceptions.

try {
    return Integer.parse!(input);
} catch (e: ParseException) {
    ...
}

However, sometimes it can be more convenient to work with result types when failing is a common option. In this case, dropping the ! instead returns a Result, which can be used as needed.

val num = Integer.parse(input) ?: -1;

Influenced by:

  • Java checked exceptions & Kotlin unchecked exceptions
  • F# result types
  • Midori error model

Language Features

Atoms

Atoms are named constants and are the runtime representation of identifiers in Rhovas. While atoms themselves are straightforward, they play an important role in API design through Atom Conversions.

obj = {
    x = 1;
    y = 2;
    z = 3;
}
assert obj.x == obj[:x];

Influenced by:

Structs

Structs in Rhovas are used for data which does not have an invariant (unlike a class) but can still implement interfaces and define methods. Fields of a struct are part of the API, which allows them to be used for pattern matching. Additionally, structs are automatically provided with two constructors (one for an object literal and the other for each field) as wells as copy, hash, equals, and other common functions where appropriate.

struct Vector {

    val x: Decimal;
    val y: Decimal;
    val z: Decimal;

}

val vec = Vector {
    x = 1;
    y = 2;
    z = 3;
}

match (vec) {
    [x, y, z]: print("sequence match");
    {x, y, z}: print("struct match");
}

Influenced by:

Match

Rhovas supports two kinds of match statements - conditional and structural. Conditional match works similar to if/else, while structural match is full pattern matching and destructuring. Rhovas also introduces the else pattern: syntax, which makes it clear the match is intended to be exhaustive.

func reverse(list: List<'T>): List<'T> {
    match (list) {
        []: return [];
        else [head, tail..]: return reverse(tail) + [head];
    }
}

Influenced by:

  • Elixir pattern matching with case & cond

Operator Overloading

Rhovas supports operator overloading for a limited number of operators. All overloads also have names to distinguish meaning between the same operator used in different contexts, such as integer addition vs string concatenation (+). Additionally, most operators (like +) prevent mutating the object.

class List<'T> {

    func op[] -get(index: Integer): 'T {}

    func op[]= +set(index: Integer, value: 'T) {}

    func op+ -concat(other: List<'T>): List<'T> {}

}

Influnced by

Macros

A macro is a function that takes in syntax and returns syntax. Macros in Rhovas are type safe, as shown below. Since macros can modify control flow (and even syntax!), they are prefixed with # so they can be easily identified.

macro #require(cond: Expr<Boolean>, msg: Expr<String>) {
    return #syntax {
        if (!$cond) {
            throw Exception($msg);
        }
    }
}

#require(!path.isEmpty(), "Expected a defined path.")

Influenced by:

Embedded DSLs

Embedded DSLs are a particular case of syntax macros. While a normal macro uses the same syntax of Rhovas, a syntax macro can extend the syntax of Rhovas to include things like JavaScript, SQL, and Regex. The parser delegates to the syntax macro, which can then properly parse and validate the syntax as needed.

val name = "Rhovas";
db.query(#sql {
    SELECT * FROM languages WHERE name = $name
});

Type Extensions

Type extensions are another crucial part of Rhovas as they allow building an API on top of an existing type. A type can be extended as either a dependent type, which can freely convert and inherits all methods, or a tagged type, which must be explicitly converted.

type AsciiString extends Integer where val.chars.all { val.isAscii() } {

    val bytes: List<Byte> = ...;

}

"abc".bytes()
type EmailAddress is String where val.matches("email regex") {

    func -getLocalPart() { ... }

    func -getDomain() { ... }

    func email(message: String) { ... }

}

val string = "local@domain.com";
if (string.is(EmailAddress)) {
    val domain = string.getDomain();
}

Both types are represented as strings at runtime. For tagged type, the is and getDomain methods are converted to static calls.

Static Inheritance

Most languages only allow inheritance over the instance of a type, but it is often desirable to have polymorphic behavior on the type itself. For example:

class Number {

    static abstract func parse!(input: String): This;

}

func parseNumber!(input: String, type: Type<'T: Number>): 'T {
    return type.parse!(input);
}

val num = parseNumber!("10", Integer);

Another use case for this is serialization. Calling deserialize polymorphically would normally require an instance of the object being deserialized, but in Rhovas this can be done statically.

Influenced by:

  • Kotlin companion objects
  • Many serialization systems

Syntax Features

Is/As/To Functions

In Rhovas, type checking, casting, and conversions are all syntactically handled like functions. This is more consistent with other syntax and allows as to be chained without parentheses. Finally, to is also in this category for converting types.

if (obj.is(String)) {
    print(obj.length);
}
print(obj.as!(String).length);
val length = obj.to!(String).length;

One thing that hasn't been fully resolved yet is how from works with this, as there's complexity with where the function is defined compared to the type it's called on.

Influenced by:

  • Rust From/Into traits

Atom Conversions

When calling functions, an :atom can be used in place of boolean parameter to set the parameter with the same name to be true. This is the preferred way to specify options in Rhovas instead of having large chains of arbitrary boolean values or repetitive name = true. For false, !:atom can be used.

func range(start: Integer, end: Integer, incl: Boolean) {
    ...
}

range(0, 10, :incl);

Context Access

Context access is syntactic sugar to access fields/functions relevant to the current code. For example, inside of a class .x can be used to access the field x, and thus is identical to this.x.

However, context access becomes more interesting when paired with enums. When the type of an expression is known, that type is included in the context. Thus, an enum constant can be accessed like .CONSTANT, which sacrifices a bit of clarity for space and readability.

Keyword Variables

In addition to being a keyword, val is also used for implicit variables in lambda functions, such as lambda { val * val }. Extending this idea, in Rhovas other keywords can hold values as well such as for and with.

for (list) {
    print(for.val);
}

There's still a question of whether this a good idea or not, but hey - it's experimental for a reason!

Influenced by:

Pipelining

Pipelining is a special form of a function call that passes the receiver as the first argument to a function instead of calling a method. This allows similar behavior to extension functions but makes it clear it is not a method, which is an important distinction by specifying the function is not from the receiver and does not use dynamic dispatch.

val odds = range(0, 10).|filterOdds();

func filterOdds(list: List<Integer>): List<Integer> {
    return list.filter { return val.mod(2) == 1; }
}

Influenced by:

Next Steps

Moving forward, the next major project to tackle is static analysis and type checking. I'd also like to start working on some larger projects with Rhovas, as the language is intended for applications where the API plays a much more significant role.

Other notable features missing right now are enums, implementation for type extensions, normal macros, and miscellaneous function features such as overloading, named arguments, and default parameters (all requiring static typing to handle the lookup properly).

Thank You

Thanks to everyone who helped make Rhovas a success, especially the members of the r/ProgrammingLanguages discord.

~Blake Anderson

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment