Loon: A functional lang with invisible types, safe ownership, and alg. effects
loonlang.com
loonlang.com
How much of this is actually real?
[0] https://github.com/ecto/loon
This looks like a really neat project/idea; seeing the road map is exciting too, nearly everything I'd want.
I don't love the brackets syntax, or the [op val1 val2] ([* x x]) style, but I appreciate the attempt at clarity and consistency and none of these things are dealbreakers.
I do wonder why they've leaned so hard into talking about the type system being out of sight. Again, not a dealbreaker, but I feel strongly that explicit typing has a place in codebases beyond "describe something because you have to".
Strongly typed languages strike me as providing detailed hints throughout the codebase about what "shape" I need my data in or what shape of data I'm dealing with (without needing to lean on an LSP). I find it makes things very readable, almost self-documenting when done right.
From their docs about their choices: "The reasoning is simple: types exist to help the compiler catch your mistakes. They do not exist to help you express intent, at least not primarily." This strikes me as unnecessarily pedantic; as someone reading more code than I write (even my own), seeing a type distinctly—particular as part of a function signature—helps me understand (or add strong context) to the original author's goal before I even get to reading the implementation.
I find this doubly so when working through monadic types where I may get a typed error, a value, and have it all wrapped in an async promise of some kind (or perhaps an effect or two).
By the same token many languages allow you to leave out type annotations where they may be simple or clearly implied (and/or inferred by the compiler), so again, I'm not understanding the PoV (or need) for these claims. Perhaps Loon simply does it better? Am I missing something? Can I write return types to stub functions?
From the above blog post: "That's how good type inference feels! You write code. The types are just there. Because the language can see where it's going." Again, it feels strongly geared towards a world where we value writing code over reading/maintaining/understanding code, but maybe that's just my own bias/limitations.
Will follow it closely.
Types exist so that the compiler can reason about your code better - but not incidentally, they also help you reason about your code better!
To wit: even when working in dynamic languages, it's often considered a good practice to write down in docstrings the types of objects a function can operate on, even without static enforcement. Thinking about types is helpful for humans, too.
And it's not even just a thing to help you read code in the future - types help me write code, because as I sit down to write a function I know the possible values and states and capabilities of the object I'm working with. In the best of cases I can analytically handle all the possible cases of the object, almost automatically - the code flows out of the structure of the type.
It reminded me of a mathematician in my field who had rather brilliant ideas, but whose papers were largely unreadable due to idiosyncratic symbology and style. Fortunately in that case one can still leverage an information dense symbology that points to a well-specified formalism.
THIS. So much. This observation is extremely intuitive to me.
> You never annotate a function signature unless you want to for documentation purposes.
so it sounds like function annotation is still an option for the purposes of communication, just no longer required in all cases.
[type Shape
[Circle f64]
[Rect f64 f64]
Point
]
[sig test_sig : Shape -> Float]
[fn test_sig [shape]
[match shape
[Circle r] [* 3.14159 [* r r]]
[Rect w h] [* w h]
Point 0.0
]
]
Unfortunately it seems like this doesn't currently work as expected when I use it in the playground, so I'm going to go file an issueI agree that seeing types is helpful, though typing them is also not necessary. Perhaps the solution is an IDE that shows you all the types inferred by the compiler or maybe a linter that adds comments with types on file save.
see "The Editor as Type Viewer" section in the docs: https://loonlang.com/concepts/invisible-types
> You get the benefit of seeing types everywhere without the cost of maintaining them yourself.
[fn consume [xs]
[println [str "got " [len xs] " items"]]]
[let items #[1 2 3]]
[consume items]
; items has been moved — using it here is a compile error
This is described as an example of a setting where the variable 'items' has been moved, so it can no longer be used. However, the consume function does not mutate its argument, so I don't see why 'items' can no longer be used. In addition, it seems to me that the term 'moved' is being used to mean 'mutated'. Is that correct, or do I have it wrong?Another example from the website is the following:
[fn length [xs] [len xs]]
[let items #[1 2 3]]
[println [length items]]
[println [length items]] ; items not consumed
The website explains this example as follows: "If the compiler can prove that a reference will not outlive the owner, it passes a borrow instead of moving." However, there are no references in this example, only the original variable 'items'. Am I missing something? I think the language is cool, but the explanations of the semantics could be improved.It's simple and also has an excellent choice of where to invest in powerful features. It looks like an elegant, minimal selection of things existing languages already do well, while cutting out a lot of cruft.
The site also mentions two differentiating and less established features that make it sound like more than yet another fp remix: type-based ownership and algebraic effects.
While ownership stuff is well explored by Rust and a less explicit variation by Mojo, this sounds like a meaningful innovation and deserves a good write-up! Ownership is an execution-centric idea, where fp usually tries to stay evaluation-centric (Turing v Church). It's hard to make these ideas work will together, and real progress is exciting.
I'm less familiar with algebraic effects, but it seems like a somewhat newer (in the broader consciousness) idea with a lot of variation. How does Loon approach it?
These seem like the killer features, and I'd love to see more details.
(The one technical choice I just can't agree with is multi-arity definitions. They make writing code easier and reading it harder, which is rarely or never the better choice. Teams discourage function overloading all the time for this reason.)
Thanks for sharing!
You're right, ownership and effects are the real differentiators. The idea here is to let the compiler discover ownership rather than it being declared imperatively
Regarding algebraic effects, Loon uses continuations similar to Koka but more restrictive. Effects are declared with operations, and every side effect (IO, failure, async, state) is an effect that propagates through the call graph. The interesting part is `handle`, which lets you intercept effects:
[handle [load-config "app.toml"]
[IO.read-file path] [resume "mock contents"]
[Fail.fail msg] "default"]
Handling an effect subtracts it from the function's effect setso a function that handles all its IO internally is pure from the outside. This replaces exceptions, async/await, DI, and mocking with one mechanism. Testing is just handling effects with test dataMulti-arity: get lost! fork me?
x : Nat x = 5
You just say x = 5
I personally don't like that you don't seem to be able to manually describe the type for a fn/var, because it's very useful when prototyping to write stubs where you provide the typedef but then the actual variable/function is just marked as "todo"
Why the square brackets in particular? Notation is such an annoying part of this stuff, I’m actually leaning towards pushing a lot of structure to the filesystem
That being said I took a look at the roadmap and the next major release is the one that focuses on Effects, so perhaps I'm jumping the gun a tad. Maybe I'll whip this out for AoC this year!
I think I disagree a little bit about the value of type and lifetime annotations, I think if I used this, I would put a lot of [sig ..] into my code.
I think having a similar syntax for lifetimes would be valuable so I as a developer can make sure that some value is not 'static (or has the lifetime of some other object that lives as long as my program). Basically I want to ensure that my values are actually dropped at some point.
One thing the docs could be more explicit about is where this runs. I saw that it has a Wasm backend, but can I also compile this to native code? Do the effects require some runtime (and I don't mean something like the JRE, more like what Go has)? Maybe I missed this, though.
Anyway, I hope I get to play with this soon!
2. The macros examples on the website don't show binding situations. Are the macro hygienic like in Scheme?
3. Why the choice of [] over ()?
1. effects are tracked in the type system as row types, so they compose with HM inference pretty naturally. the tricky part is effect polymorphism. Loon handles that similarly to how koka does it, with row polymorphism. no ambiguity issues so far but idk
2. yes, macros are hygienic! documenting some binding situations would make a great first PR :)
3. easier to type!
> yes, macros are hygienic!
I'm glad. Too many lisps chicken out and don't add them.
> easier to type!
Fair enough.
This seems very interesting, I will check it out.
Oh dear, why? Abrasive aesthetics aside, this is bad for people with certain non-English keyboard layouts. Not me, but many do exist.
They do require worse acrobatics than a shift key on a German keyboard, though - one of the Alt keys is special and needed to trigger them, if memory serves.
Well, that's another argument for everyone to use an English layout for coding, I suppose.
I also tried the the example destructuring from the tour page and these below doesn't run correctly:
[let #[a b c] #[1 2 3]] ; this doesnt compile
[let {name age} {:name "Loon" :age 1}]
[println age] ; age unbounded
Definitely cool in concept, but very performance-intensive and slow.
[fn square [x] [* x x]]
Could very easily be fn square(x) = x * x;
Or something like that, which is much more readable.Also
> Hindley-Milner inference eliminates type annotations.
I think it's pretty widely agreed at this point that global type inference is a bad idea. The downsides outweigh the upsides. Specifically: much worse errors & much less readable code.
Lisp and similar are just "hey it's really easy to write a parser if we just make all programmers write the AST directly!". Cool if the goal of your language is a really simple parser. Not so cool if you want to make it pleasant to read for humans.
That's my impression, at least. Like I said, I've never actually used a Lisp. Maybe I'm put off by the smug superiority of so many Lisp people who presume that using Lisp makes them better at programming, smarter, and probably morally superior to me.
However, code that is mostly function calls is fine for me, since those would have parentheses anyways in C++/Rust/whatever. In that case it makes the language more regular, which is nice for writing macros.
I'd be curious to hear your opinion on wisp (https://srfi.schemers.org/srfi-119/srfi-119.html) and the Readable project (https://srfi.schemers.org/srfi-110/srfi-110.html) which are significant indentation syntaxes for Lisp languages that are still closely related to the AST and allow for easy macro writing.
I devised a well-crafted macro expansion hooking mechanism (public, documented) in support of it.
It works by creating a lexical contour in which infix expressions are recognized without being delimited in any way (no curly brace read syntax translating to a special representation or anything), and transformed to ordinary Lisp.
A translation of the FFT routine from Numerical Recipes in C appears among the infix test cases:
https://www.kylheku.com/cgit/txr/tree/tests/012/infix.tl?id=...
The entire body is wrapped in the (ifx ...) macro and then inside it you can do things like (while (x < 2) ...).
In completing this work, I have introduced an innovation to operator precedence parsing, the "Precedence Demotion Rule" which allows certain kinds of expressions to be written intuitively without parentheses.
Everything is documented in detail:
We deal with most languages (Lisp family and not) via indentation, to indicate the major organization, so that there isn't a lot left to parse in a line of code, (unless someone wants to be "that" programmer).
Yes definitely to some extent, but they aren't perfectly aligned. Most languages make things a bit harder to parse for machines but easier for humans. Some get it wrong (e.g. I would say OCaml is hard to parse for humans, and some of C's syntax too like the mental type declaration syntax). I don't think you could say that e.g. Dart is harder to parse for humans than Lisp, even though it's clearly harder for machines.
Every rule for inferring a hiddnen, implicit structure within a sequence of tokens makes things harder, even on the scale of just a few tokens in one line within an indented structure.
Its not just that, it makes syntax more uniform and it allows adding all sorts of features using the same parens syntax where other languages have to invent all sorts of special symbols to distinguish between things.
It makes it easier to parse for both machines and humans. This is why I asked if you ever wrote in lisps, because it takes some time to adjust but once you do it all makes sense.
Many languages tried to make programming look like a human-readable text, they all failed in one way or another. Because writing program instructions requires specific structure and s-expressions do that extremely well.
I do. The advantage being that if you suddenly realize that you want to do super-duper-multiplication instead of regular multiplication, you can just change the name instead of having to rewrite the entire expression. And honestly, having a few random functions be called differently from others feels gross.