Loose, disorganized notes for a language idea I’m trying to better understand. This will probably not be interesting to anyone except for myself at this stage, so don’t expect much.
I took a lot of inspiration from Io (link) here. This language is semantically nothing like Io, but I thought the syntax works quite well in this context.
It has a fairly traditional “function(call)” syntax, and borrows Io’s method call syntax to desugar “postfix calls()” into “calls(postfix).” This lets code naturally flow from left to right, which I find very pleasant. There are no infix operators, except for the built-in type assertion (“value: type”).
I take the left-to-right flow to an extreme; here’s an example side-by-side with a more common spelling:
chickens count() times(2) let(leg-count);
let leg_count = chickens.count() * 2;I’m not fully decided on the “no operators” thing yet. At the very least it’s possible to use postfix calls to approximate operators:
1 + (2) * (3)Some syntax sugar to get rid of the parentheses for a single-term RHS would make a decent substitute for “proper” operators, if I decide to go down this path.
This language freely blends run-time and compile-time execution, without drawing a clear line between them. Any code that can be executed at compile-time (meaning that it doesn’t depend on runtime values) will be. This is different from Zig (where the “comptime” keyword is sometimes necessary), and Jai (where the “#run” directive is always necessary, as far as I can tell.)
The results of an expression which contains a runtime-only term will get “poisoned,” such that the entire expression is now runtime-only. Expressions that are not needed at runtime will not pass through code generation.
Zig’s allocator model, which they are now extending for more general things like I/O (link), seems like a good fit for this. For example, the runtime filesystem could be obtained and passed around, poisoning anything it touches and making it also runtime-only. Memory allocations could work in a similar way.
Int func(fs: Filesystem,
fs open("my-favorite-file") let(file);
file read-all() parse-int() let(content);
file close();
content plus(7 times(8)) return();
) let(my-favorite-stuff);
my-favorite-stuff(<give it a runtime filesystem>);
my-favorite-stuff(<or a compile-time filesystem>);The first call above would have to go through code generation and happen entirely at runtime, while the second can happen at compile-time and compile to a single integer. I’m ignoring error handling to focus on the relevant part of the example.
Also note the “7 times(8)” expression there; this will be evaluated to a constant integer, then included in the function body. Both versions of the call will use a version of “my-favorite-stuff” where that expression is just a constant integer. This is not because it’s an arithmetic expression, but simply because none of its children depend on any runtime values.
Like Zig, this language has higher-order types. Types are values like any other, accessible at both runtime and compile-time. However, a type has to be available at compile-time in order to be used in a declaration.
I’m not entirely sure yet how polymorphism should work. Zig’s approach is simple, but I’d like something a bit more strict. The current idea is Clojure-style “multimethods” (link), where you could define a multi-function with a number of parameters that have a placeholder type. Any module can then supply an implementation for this multi-function, replacing the placeholders with arbitrary types. Unlike in Clojure, these multi-functions would have static dispatch, similarly to C++ overloaded functions.
Generic types can be implemented in the same way as Zig, by creating functions which operate on types. But what about generic parameters to functions? And more specifically, functions of this shape:
T func(list: List(T)) let(first-element)One idea is to pass the type explicitly:
T func(list: List(T), T: Type) let(first-element)The order of declarations here is backwards, but I’m not sure if that’s a problem. The bigger issue is ergonomics; these functions are kind of annoying to call:
color-ranking first-element(Color) let(favorite-color)Ideally, we wouldn’t have to explicitly pass the type to “first-element.” Zig has a few of these functions (link), but they mostly sidestep this issue by associating “member” functions with the type that they’re part of. Whenever a generic type is instantiated in this way, specialized versions of these functions are automatically generated. Since I only want to have free-standing functions, this is not a solution.
An arbitrary generic type like “Any” could work here, but then that type can’t be referred to anymore:
?? func(list: List(Any)) let(first-element)More than that, the entire system breaks down when you try to express constraints involving ideas like “this generic type should have an implementation of this multifunction.”
Since none of the previous ideas quite work, I’ll list the requirements for the system:
The syntax point specifically is a bit more difficult than it seems. Not only should you be able to express the type signature with this relatively minimal syntax, but the expanded tree should also make sense.
For example, this wouldn’t be acceptable:
generic(T) List(T) func(list: List(T)) let(first-three)It’s obvious why if you convert it into the equivalent (non-postfix) calls:
let(func(List(generic(T), T), list: List(T)), first-three)Notice how “List” swallowed the generic declaration. The syntax needs to not only look good, but also make sense.