ALookAtAustral2023

Up to date as of April 2023.

Introduction

So in the last few years, the general success of Rust and Zig have paved the way for a small glut of interesting new-generation systems programming languages: Odin, Hare, and others. However, none that I’ve seen have borrow checkers, and so to me are kinda missing the bus: I am a True Believer, I don’t see why anyone would want to write low-level code without a borrow checker. Need to sometimes, sure. Want to? Nah. So I’m writing a programming language of my own called Garnet, which is intended to be much smaller and simpler than Rust, while still having a borrow checker and all the goodness it produces.

So when I read news of a language called Austral that aimed to be small and simple but also borrow checked, with linear types (which are basically a slight extension of Rust’s move semantics), I was both excited and annoyed. On the one hand, cool! Finally someone besides me cares! On the other hand, my monkey hindbrain automatically starts going “grr, competition! How dare they!” Which is dumb ’cause making novel programming languages isn’t exactly a high-stakes, zero-sum game where the winner defeats the loser. But that’s monkey brains for you.

But several people on the r/PLDev Discord independently pointed me towards Austral and asked what I thought of it, so it went on my List. And once I had a free morning and needed something interesting to fill it, I went through the whole written spec for Austral and took a good hard look at it, and made a ton of notes and responses and comments on it. So now I’m going to marshal these notes into some kind of document vaguely suitable for other people to read. Probably a very rambly document, full of unfiltered thoughts and random tangents, but that’s just what I feel like writing right now. A lot of this will consist of design quotes from the Austral spec and my responses to them. I’m gonna be critical as hell in some places, but I still mean it with love and respect. ’Cause long story short, Austral is a very interesting language that is just full of my pet peeves.

Here we go!

Principles

One thing Austral does well that every serious project needs to do: Have a manifesto. There is an explicit list of design goals, as well as an explicit, longer, list of design anti-goals. This gets its own section because it’s so important. The rest of Austral is explicitly, rigorously guided by these goals. You can see them here: https://austral-lang.org/features

Syntax

So, the syntax is unapologetically styled after Ada, of all things. The author knows this is gonna be unpopular and so puts all the syntax details, justification and stuff right at the front of the spec, where it will chase off the people who will be chased off by an Ada-style syntax. So I’m gonna put my responses to it right up front too; if you don’t want to read my bitching just skip to the next section.

The Ada-ish syntax is really unpleasant to me, and I think it ignores like 50 years of progress in terms of what a readable syntax actually looks like. Remember: Syntax is the UI for your language. A good UI is tailored for a specific kind of workflow. I don’t think the Ada-ish syntax is a good UI because it actively impedes some workflows without really helping what it claims to help. But my brain also doesn’t work the same way as other peoples’ brains, so I’m gonna leave it with that.

Now that I think of it though, there is some fundamental tension in life between writing code and reading or designing code. Writing code benefits from low impedance, succinctness and expressive power: there is little to inhibit the Flow state. Reading code can be similarly Flow-y but is much more analytical: you are not conjuring forth the mental model of what the code should do, but pushing your own mental model into the inflexible mold of what the code actually does. These workflows are not entirely mutually exclusive but also are somewhat at odds. Hmmm, I wonder if reading familiar vs. unfamiliar code further blurs this line in ways that are not well studied? I seldom hear it talked about!

All Austral code lives in a module. Austral modules are split into separate interface and definition files. What this really means is that it doubles the workload of designing or changing anything. The interface can be trivially derived from the implementation by a machine, and keeping the two in sync by hand is just busywork. They say this means that “crucially, a module A that depends on a module B can be typechecked when the compiler only has access to the interface file of module B. That is: modules can be typechecked against each other before being implemented. This allows system interfaces to be designed up-front, and implemented in parallel.” Soooooo… this is done to help a waterfall design flow that impedes iteration. Got it.

Austral modules form a hierarchy, and are effectively disconnected from the filesystem hierarchy. You don’t create a submodule by creating a directory and filling it with files, you just put the module name declarations in your files and the compiler pieces them together. This a bit odd but isn’t particularly unreasonable. C# namespaces do something similar, but that’s the only good language that comes to mind which does it that way, I think? Wait, no, Rust modules technically work the exact same way, but Cargo takes a project layout that has files and directories which conform to the module tree and pieces them together for you, and I’ve never really seen anyone not organize their code that way, at least for any portion of a project larger than an occasional local submodule or two. I haven’t heard many people discuss this sort of thing one way or the other, to be honest, would be interesting to read a rundown of the two styles done by someone with lots of experience in large projects using both.

“…Austral is designed to restrain programmer power and minimize footguns.” This might be a big core of why this language fundamentally irks me. The idea of anything restraining my power rubs me the wrong fucking way. XD The purpose of a machine is to amplify your power. However, any machine also needs to not murder its user and the bystanders around them in the process, so minimizing footguns is laudable.

The syntax is statement based, of course. They look at expression-based syntaxes and hybrids, but say an expression-based syntax “suffers from excess generality in that it is possible to write things like: let x = (* A gigantic, inscrutable expression with multiple levels of let blocks. *) in ... In short, nothing forces the programmer to factor things out.” Interesting. I would argue that you could write gigantic inscrutable statements with multiple levels of blocks just as easily, if not more easily, in a statement based language. The same power that lets a programmer compose expressions together easily also lets them refactor them apart easily, with fewer errors. “Furthermore, in pure expression-oriented languages of the ML family code has the ugly appearance of “hanging in the air”, there is little in terms of delimiters.” I mean, there’s other ways to make an expression syntax than the ML family, but I’ll accept that you have your own sense of aesthetics.

Meanwhile, “mixed [expression and statement based] syntaxes are unprincipled because [they make] it possible to abuse the syntax and write ‘interesting’ code. For example, in Rust, the following: let x = { let y; }”. Yeah, but because x then has the type () you can’t actually do anything with it, so this is still a bug the compiler will find for you. If a bug occurs in the forest, and the type system prevents it from ever having an effect that can be observed, is it still a bug? Mmmmmmmmaybe. Rust tends to say “no but we will produce a compiler warning anyway”.

All in all, in my experience a statement-based syntax is simpler to implement well than an expression-based one, but the benefits of an expression-based syntax are worth it. Having fewer special cases makes writing and moving code easier, and everything having an orthogonal “interface” is just a very nice simplifying assumption. Anywhere you have a value you can have an expression, and anywhere you have anything it returns a value. I don’t hate statement-based syntax, Lua is an example that does it very well and uses its power to minimize noise and ambiguity, but I always reach for pure expressions first.

“In Austral, delimiters include the name of the construct they terminate. This is after Ada (and the Wirth tradition) and is in contrast to the C tradition. The reason for this is that it makes it easier to find one’s place in the code.” Ah, the much-reviled end if and end for. Austral’s claim that it enhances clarity might be true, but the examples it uses to demonstrate its utility are already code I’d hate to have to write:

function f() is
    for (int i = 0; i < N; i++) do
        if (test()) then
            for (int j = 0; j < N; j++) do
                if (test()) then
                    g();
                end if;
            end for;
        end if;
    end for;
end f;

If you nest shit five levels deep you’re already gonna have problems no matter what the language or the syntax is. In that example I still can’t fucking parse the “end if’s” and “end for’s” because there’s too damn many of them in a row; calling out the end type doesn’t help at all. The examples they have of for “most big codebases comment the ends of blocks anyway” like } // access check or </table> // user data table don’t support their argument, precisely because those callouts highlight semantics, not structure. end for; does nothing to assist when you would otherwise write end walk through users list and frobnosticate the doodad; Moreover, the use of those delimiters makes refactoring harder, not easier, so you will more often end up with poorly-factored code that tends to have exactly that kind of structure. It’s a self-fulfilling prophecy, those names are useful precisely because its style encourages them!

On the other hand, there are some things they get right, and I love them:

  • “Using English-language keywords, however, does not mean we use a natural language inspired syntax, like Inform7. The problem with programming languages that use an English-like natural language syntax is that the syntax is a facade: programming languages are formal languages, sentences in a programming language have a rigid and ideally unambiguous interpretation.” Bingo! Useful distinction to call out. This was pretty well hammered down by the 1970’s, but people seem to sometimes still forget it.
  • Semicolons: It puts them in more places than necessary, but it seems to use them as delimiters rather than separators, so it narrowly dodges that criticism. Then it says “for many people, semicolons represent the distinction between an old and crusty language and a modern one, in which case the semicolon serves a function similar to the use of Comic Sans by the OpenBSD project.” Ahahahahha glorious, I love this. MAD props. Apparently it’s referring to this presentation and its associated project, which uses the Comic Sans font as flame-bait to distract the people who aren’t going to do anything helpful anyway.

General semantics

Ok enough about syntax, let’s move on to semantics.

One of Austral’s Big Features, maybe The Big Feature, is linear types. Linear types are basically Rust’s move semantics/RAII, but a bit more strict. In Rust, there is a trait called Drop that lets you define a destructor function on a type, called drop(). When a value of that type goes out of scope, that destructor function is run automatically. rustc figures out where the drop() function calls need to go and inserts them for you. To not implicitly destroy a value you must “move” it somewhere so that it stays accessible, either by returning it from the function, passing it to another function, or by stuffing it into a data structure somewhere. With Austral’s linear types, on the other hand, you must call the drop() function or whatever explicitly, and the compiler checks your work to make sure that you haven’t screwed up. So while in Rust if you have a value with a destructor you must either move the value or let it go poof, in Austral you must move the value. If you want to destroy the value, move it into the function that does the destruction.

I originally disliked linear types quite a lot; if the compiler can always prove that I’ve destroyed the value, then it can always just insert the drop() call for me and I don’t have to ever heckin’ worry about it. If something can be automated perfectly, then let the computer automate it and don’t waste my time! However, I have come to appreciate them after reading a bunch of Aria Beingessner’s stuff on linear types in Rust. (This too; it’s not directly related but matters for error handling.) Turns out that where linear types shine are the edge cases where drop() being called implicitly can’t do what you need to do. You can have a destructor that may fail, you can have a destructor that needs to take some context value to operate properly (hello Vulkan API bindings), you could imagine a slightly weird case somehow where you have more than one destructor for the same type that need to be called in different contexts. Grotty but necessary stuff like that. Rust’s drop() functions can only have one function signature, drop(&mut self) -> (), and if that’s not what you need then you’re just out of luck. There’s ways to hack around it: stick your destructor context into a global var somewhere, make a separate destructor function you call by hand that sets a flag in the value and make the drop() impl panic at runtime if the flag has not been set, stuff like that. I’ve done these things, they’re not the worst thing ever, but they’re still hacks.

So for these situations, linear typing is great! I wanna put it in Garnet now. Have your drop() call insertion handle destruction automatically for the 97% of the time where you don’t need a fancy destructor, and then let the programmer define their own destructors with whatever semantics and function signature they want for the last 3% of the time. Compiler will check that your types have been destroyed properly in either case. Best of both worlds!

That’s not what Austral does though. Austral has no implicit function calls, anywhere, ever. So you have to destroy all your values by hand, or else the compiler will yell at you. Siiiiiiigh. “Tedious” does not even begin to describe it. But Austral is not about compromises. It says no implicit function calls, that includes destructors, so you call your destructors yourself and that’s all there is to it. And you know, I can respect that. I think it’s a bad constraint, but it’s a decision made with eyes open.

So do you need to call drop() on every single i32? Nope: “in a linear type system, the set of types is divided into two universes: the free universe, containing types which can be used any number of times (like booleans, machine sized integers, floats, structs containing free types, etc.); and the linear universe, containing linear types, which usually represent resources (pointers, file handles, database handles, etc.).” Makes sense, but this division twigs my instincts as being either over-complicated or under-generalized. Dividing the universe into two categories is automatically suspect. Maybe this is just paranoia from ML module systems and how much work they put into dividing the universe into two type categories, when that really turns out to just be a misfeature anyway. This duality does exist in Rust too though, expressed through the Copy marker trait: Copy types correspond to the “free” universe, non-Copy types correspond to the “linear” universe. I kinda dislike this too, because it’s a magic division in the language that can’t be expressed by the language itself, but I haven’t actually come up with a better solution yet. So maybe it is fundamental! I would describe it as having the linear type universe containing things you may share and the free type universe containing things it’s not worth sharing. They’re cheap to copy, they have no unique properties, own no resources, etc. Anyway, this thought is only half-formed, let’s move on.

Interesting side note though: “Universes are not the only way to implement linear types. There are three ways: [… and] Rust’s approach, which is a sophisticated, fine-grained ownership tracking scheme.” ??? Is it? I mean, Rust’s move semantics seem 99% identical to your linear types. I don’t know much about the theoretical guts of it though, so. Might have to learn more.

It looks like Austral’s linear typing rules handle destructuring of structs similar to Rust, you can take a struct apart into its fields and it tracks the destruction of each field separately. But I wonder if it has Rust’s issues with partial borrows of structs? Would be interesting to see, that’s a sticky problem. Rust has put a fair amount of work into trying to be smart about multiple things borrowing different parts of the same struct/array. The spec doesn’t have much to say about them either way.

Austral has a borrow checker! That’s what got me interested in it in the first place and now finally we encounter it! How does it work? “Borrowing is stolen lock, stock, and barrel from Rust.” oh. Well then. …good choice. I mean I can’t criticize, that’s pretty much what I’m intending to do with Garnet. Are they Rust 1.0’s scoped borrows, or current Rust’s non-lexical lifetimes? Looks like something much closer to Rust 1.0’s scoped borrows.

There’s a section called “The Cutting Room Floor” that discusses some interesting thoughts around Austral’s linear type design. It mostly compares with Rust’s ownership, which is interesting because it has lots of arguments which, to me, are just incorrect:

  • “The Rust approach [to ownership] prioritizes programmer ergonomics, but it has a downside: the ownership tracking scheme is not a fixed algorithm that is set in stone in a standards document, which programmers are expected to read in order to write code.” Ehhhh, don’t dismiss Rust’s priority on ergonomics, it is literally the only reason Rust has succeeded where Alef, Cyclone etc failed. People are working on pinning Rust’s semantics and borrowing rules down more formally with Polonius and Rustbelt and other projects, but it’s also not a trivial process. For better or worse, Rust is a creation still undergoing evolution.
  • It then goes on to say “Compare this with type checking algorithms: type checking is a simple, inductive process, so much so that programmers effectively run the algorithm while reading code to understand how it behaves. It often does not need documenting because it is obvious whether or not two types are equal or compatible, and the “warts” are generally in the area of implicit integer or float type conversions in arithmetic, and subtyping where present.” Ahahahahahahaha bruh. Have you read Haskell code? Or trait-salad style Rust? That “simple, inductive process” can spiral out of control real fast when people try to push it to its limits.
  • “There are many good reasons to prefer the Rust approach: Programmers care a great deal about ergonomics. The dangling else is a feature of C syntax that has caused many security vulnerabilities. Try taking this away from programmers: they will kick and scream about the six bytes they’re saving on each if statement.” This is a weirdly bad example, given that Rust has taken the dangling else away and lots of people love it and most of the rest seem to not care either way. The C programmers who really yearn for C’s dangling else are not your target demographic. Trust me on that one.

Other fragments from this section:

  • “Like unit tests, [static analysis] can usually show the presence of bugs, but not their absence.” Yes, and Turing completeness is undecidable. You’re not wrong but the problem is much more nuanced than that.
  • “Inside the trust boundary, there is a light amount of unsafe FFI code…” aha, you DO have an unsafe escape hatch! Shame on you, how dare you not make everything perfect without needing a way to subvert it! :-P Nah I’m kidding, a systems language is not the place to attempt such a thing. We’ll talk a little bit more about unsafe later.
  • “…simple enough that it can be understood entirely by a single person reading the specification.” Not the first time you’ve mentioned wanting the language to be understandable by a single person reading the spec. It’s a laudable goal. But seems to ignore the chaos that can arise from very simple rules interacting. A “simple” specification can still produce emergent behavior very easily; simplicity is a guideline, not a panacea.

Error handling

This is a big section that discusses a lot of interesting stuff! However, it starts with: “On July 3, 1940, as part of Operation Catapult, Royal Air Force pilots bombed the ships of the French Navy stationed off Mers-el-Kébir to prevent them falling into the hands of the Third Reich. This is Austral’s approach to error handling: scuttle the ship without delay.”

Bruh. Bruh. This is not the analogy you want to use! The attack on Mers-el-Kébir was a gigantic fuckup that killed over a thousand people and mainly helped prolong WW2 by making neutrals less likely to side with the Good Guys and providing fuel for the Bad Guys’ propaganda. The people actually doing it tried really hard not to have it end that way and it is mainly through stupidity and bloody-minded inflexibility that Mers-el-Kébir ended with all those people murdered. For no reason, given that basically the same situation with the same orders happened at Alexandria just down the road… except with a very different outcome that didn’t kill anyone. I get what you’re trying to say but please find a better analogy!

Ok tangent over. …oooh, the author cites Joe Duffy’s writing on error handling in Midori. Rad! Link is broken though, apparently Joe Duffy’s domain expired at some point recently. So sad! I hope they’re ok? archive.org link: https://web.archive.org/web/20220913071244/http://joeduffyblog.com/2016/02/07/the-error-model/ I should reread that myself someday. I gotta say, I’m pretty impressed with the research the author has done so far. Going through the Austral spec, reading the quotes and citations, and following them to their sources, appears to be a pretty decent fast track to a lot of the Good Stuff in the philosophy of programming and design.

So following those sources, they divide computer failures into 5 categories, which I’m gonna repeat here ’cause it’s a useful model. (I want to call it suspiciously reductive now, but that’s me just trying to be smart.) The categories are:

  • Physical Failure: Pulling the power cord, destroying part of the hardware.
  • Abstract Machine Corruption: A stack overflow, heap corruption, or something else that violates the assumption the compiler makes about the state of the computer.
  • Contract Violations: Due to a mistake the code, the program enters an invalid state. Array out-of-bounds access seems the prototypical example. “You tried to do something invalid and the computer caught it for you.”
  • Memory Allocation Failure: “malloc returns null, essentially. This gets its own category because allocation is pervasive, especially in higher-level code, and allocation failure is rarely modeled at the type level.” Zig appears to be changing that though; let’s keep up the good work!
  • Failure Conditions. “File not found”, “connection failed”, “directory is not empty”, “timeout exceeded”. Basically something in the world disagreed with what the computer was told to do and so it can’t/won’t proceed.

Most of these failure conditions either have obvious solutions, or can’t be solved by systems within the programming language itself. To deal with physical failure you have to expand the scope of your design to the system level, not just software. Abstract machine corruption is something a language by definition can’t detect or fix, the best you can do is try your damndest to make it impossible to do. Memory allocation failure, Austral takes the Zig approach and acknowledges that allocation can fail, merging that category into the previous one. Failure conditions, again by definition can’t be fixed in isolation, they’re a system-level problem, so the best thing the program can do is fail gracefully and informatively. So the bulk of the discussion goes into how to handle “contract violation” type bugs.

So what can you do when they occur? Austral identifies three options:

  • Terminate program. Scream and die, essentially.
  • Terminate thread. Attempt to kill the misbehaving bit of the program while keeping any other part of it lurching along.
  • Throw an exception. The author groups Rust’s panics into this category ’cause the key operation is to unwind the call stack, calling destructors as you go. I’d argue that Rust’s panics are qualitatively different because they generally aren’t intended to be caught, but the mechanism is the same, so sure.

Austral then spends most of its thought on whether or not to take the “throw an exception” option. One problem it has with unwinding the stack is “Hidden Function Calls: Calls to destructors are inserted by the compiler, both on normal exit from a scope and on cleanup. This makes destructors an invisible cost”. Not so if you use a defer statement or normal linear types plus a finally block to make the destructors visible? Eh, I’m fine with either approach.

Next up is the “Double Throw Problem: What do we do when the destructor throws?” In Rust and C++, this causes an abort; essentially we revert back to error handling solution #1. Really there’s just not many other things that you can do in this situation, as far as I can tell: something has unexpectedly fucked up in your unexpected-fuckup-handling mechanism, the most sensible option really does seem to be to stop the situation from escalating any further as fast as possible. However, Austral says “This an unsatisfactory solution: we’re paying the semantic and runtime cost of exceptions, stack unwinding, and destructors, but a bug in the destructor invalidates all of this. If we’re throwing on a contract violation, it is because we expect the code has bugs in it and we want to recover gracefully from those bugs. Therefore, it is unreasonable to expect that destructors will be bug-free.” So basically, why are you putting all this work into making exceptions work when they only work some of the time? My answer would be that making destructors bug-free is usually a lot easier than making the rest of your code bug-free, especially ’cause destructors generally only have one job. Plus, if you want, it’s possible to design your language to statically check whether it’s possible for a particular destructor to throw an exception, so you can make sure double-throws don’t occur in the places they matter most. So aborting in the case of a double-throw seems pretty reasonable to me; you could imagine wanting to abort the thread rather than the entire program as you described earlier, but what I’ve seen of killing threads in Rust makes me believe that the various OS thread API’s are cursed enough that you’re better off not trying. So aborting on error is the default case, and throwing an exception and unwinding is a convenience. Now that I think of it, the “what to do when a destructor has an error” problem exists for Austral anyway, but we’ll get to that.

Under “misuse of exceptions in Rust” it also cites usage of catch_unwind in docs.rs, but the links it refers to are conversations that describe exactly the sort of situations that catch_unwind is for: Isolating unexpected failures to single parts of the program. Essentially, using them to try to implement the “kill offending thread” policy described above. The docs.rs developer there explicitly acknowledges the problems with this approach and says “is this even worth fixing?”, since the intent was to replace it with a better web server Soon(tm) anyway. Seems like an odd take to argue against using a feature by linking to someone saying “yeah we shouldn’t really use this feature but don’t have lots of other choices”.

The spec spends a lot of time and energy talking about the ways that exceptions can break threading, but everything it talks about seems to ignore what might happen if threads can share data. As far as I can tell they just assume threads never share data. Unfortunately, the whole purpose of threads is to share data, one way or another, even if it’s by consuming part of an array and returning a modified one. If you don’t want to share data you use processes; a thread is an optimization so that multiple processes can share data easily.

On possible ways to mark resources as freed in its destructor, it says “the whole point of resource management type systems is the flag exists at compile time. Otherwise we might as well have reference counting.” Sure, but, sometimes we don’t statically know what our resource usage story looks like? You DO realize most kernels refcount open file objects internally, right? Ahahahaha no no you’re right, it’s best to have it solved at compile-time if possible. But it then goes to pooh-pooh exceptions further for “Platform-Specific Runtime Support: Exceptions need support from the runtime, usually involving the generation of DWARF metadata and platform specific assembly.” I meannnnnn… I DO like seeing where my program has crashed, yes. That extra work the compiler does means that you need a debugger a lot less frequently. Does a debugger count as “runtime support”? Or are you saying that if you just make your program Right then you don’t even need to add debug symbols to it? (That’s a joke.)

Ok the author admits that Rust’s implicit destructors have advantages. Thanks, that’s all I want. They then mention their disadvantages as well, most of which are spurious IMO. Yadda yadda let’s get back to the interesting stuff!

“…to our knowledge, this is the only sound approach to doing exception handling in a linearly-typed language that doesn’t involve fanciful constructs using delimited continuations.” oooh, now we’re getting somewhere juicy. They then look at some papers about something called PacLang, which I’ve never heard of before. A linear typed language for network switch and router hardware, it appears? Neat! And there’s a PacLang++: “…which brings linear references into the environment implicitly wherever an exception is caught. [PacLang++’s] type system ensures that the environment starting a catch block will contain a set of references (that are in the scope of the exception handling block) to the exact same linear references that were live at the instant the exception was thrown.” So… exceptions will always have access to the same values when caught as they did when they were thrown. That’s a neat approach, so throwing an exception also implicitly consumes any linear types in scope, the exception object stores them, and it is the responsibility of the catching code to do something sensible with them when the exception is caught. “The authors go on to explain a limitation of this scheme: if two different throw sites have a different environment, the program won’t type check. … While the [example] code is locally sound, one throw site captures x alone while one captures x and y.” Makes sense I suppose, I can see sometimes where that would be a pain but I can’t think of a good way around it. It’s a pretty wild idea, can’t wait to see where Austral goes with it!

And the conclusion to this entire error-handling saga, complete with bibliography, is… very interestingly, that there is no conclusion. “The PacLang solution to integrating linear types and exceptions is essentially returning Result types, so it can be rejected on the grounds that it is too onerous.” I’m hammering F to doubt here ’cause ergonomics are valuable and Result with good ergonomics works very well a lot of the time, but okay. Welp! That leaves them with only two choices for what to do on “contract violation error” like an out of bounds array access: Abort the program or abort the thread. They abort the program for the sake of throughness. Hah! Again, I think that this is the wrong choice, but it’s a valid one.

Ahahhahahaha wow that was a lot of work to come to the conclusion of “there is no good solution”! Still, I have to admire their willingness to stick to their guns rather than try to shoehorn in a solution they find flawed!

Types and other stuff

Well that was a roller-coaster! What’s up next? Okay, to start off there IS some kind of unsafe feature! Let’s take a look. “Specifically, an unsafe module can: Import declarations from the Austral.Memory module. Use the External_Name pragma.” And that’s about it! Austral.Memory allows raw pointer operations, and External_Name allows you to call FFI code. Okay. …looks like you use the Unsafe_Module pragma to declare a module does unsafe things in the module’s impl, not its interface. So module implementations are the unsafe boundary, or to rephrase it, any function within an unsafe module can use unsafe code. So if I’m understanding correctly, every module interface is advertised to the user as “safe”. That’s not the boundary I would have chosen, but ok, interesting choice. This bit of the spec looks a lot more incomplete than what I’ve read so far, so there’s probably some work still in progress. From my experience with Rust API’s you will either need a way to mark individual functions as unsafe, or you will have to go through a lot of work to make every module interface safe, or you will end up with modules pretending to be safe when they aren’t. It will be very interesting to how this develops more.

There’s some stuff around type universe declaration and inference (Linear vs. Free) which I don’t fully understand and I’m getting tired so I’ma skip it.

The general shape of the type system is close enough to Rust’s you can mostly pretend they’re the same. Numbers have specific sizes and don’t transparently convert to each other, there’s fixed size arrays, nothing really surprising– oh my gawd there’s syntactic sugar for reference types. &[T, R] expands to Reference[T, R]. Holy fucking shit this is the only syntactic sugar I’ve seen in the entire language! &![T, R] is sugar for a mutable reference too! Amazing!

So while Rust lets you try to borrow basically anything anywhere, Austral has borrow statements that open blocks. The syntax is borrow X as Xref in R do ... end. The Rust equivalent would be { let Xref = &X; ... } This forces all borrows to be scoped, which I guess in Rust they mostly are anyway, but means that non-lexical lifetimes aren’t an option; it is basically bound to the more strictly scoped borrowing semantics of Rust 1.0, as far as I can tell. I think it was called “stacked borrows?” Been a while, but IIRC NLL was a big ergonomics win and a small expressiveness win when it was implemented. I thought there was no lifetime syntax at first, but that was a mistake: in the type Reference[T,R] the R is a “Region”, which appears to be a lifetime, and it is declared in the borrow block. So it’s flipped from Rust; there is no lifetime elision! A borrow statement introduces a new region which you must name explicitly! That seems like a step backwards, Rust’s lifetime inference is there precisely because not having it annoyed the hell out of the people writing the language. But it’s also how a lot of newbies expect Rust to work, it’d be interesting to compare and contrast.

The mutable form of the borrow statement is borrow! I dunno mang, to me not doing borrow mutable seems like a missed opportunity to be as clear and precise as possible, it’s relatively easy to miss the ! while reading after all! …I’m not even sure I’m being sarcastic anymore, this might be an actual wart for this language.

You also explicitly cannot borrow a Free-universe type like a number, so no &i32; such a thing is a little silly to do but also seems a little silly to disallow. Actually now that I think again it’s worse than that, ’cause at least in Rust lots of things take and return references rather than owned types by default. Not allowing a number or your other Free-universe types to be borrowed means that none of those API’s will work with any Free-universe type. Example: Rust’s Iterator trait returns Option<&T>. If you have an iterator over a sequence of i32’s then it returns Option<&i32>, not Option<i32>. In practice they’re basically identical and I’d expect the compiler to turn one into the other, and honestly it would be nice to have it automatically return a copy of a Copy type when possible instead of forcing you to dereference it. But it seems like Austral’s approach means that you either can’t have an iterator return Option<&T>, or can’t have an iterator of i32, because you can’t have a &i32. Will be interesting to see how this evolves as time goes on.

Typeclasses are typeclasses. You can parameterize generics on them. Surprisingly little discussion about them– oh, some of it happens a little later on in the spec. Ok, Austral’s typeclasses are explicitly lifted from Haskell 98 so probably work mostly the same. Instances are globally unique, they use the same orphan rules as Rust. And… that is pretty much it. I guess they’re less interesting to the author than linear typing and error handling are! They don’t even say how to call or import a typeclass or instance that I can find. Hah, I probably just missed it. I don’t see any way for typeclasses to inherit either; does Haskell do that? I forget.

There’s records, though you don’t seem able to declare individual fields public or not. There’s unions, aka sum types. They don’t appear to be namespaced like Rust unions are, which is a bit of an odd move to me; the examples do let C: Color := RGB(...) rather than let C := Color.RGB(...). Comes out similar in the end, perhaps, but feels a bit chonky? Very ML-y, and in ML it’s a pain in the ass, albeit a minor one. Union constructors don’t appear to be functions the way that (some of) Rust’s constructors are, since they have a special syntax for key-value pairs.

There’s generic functions. Are there generic types? Oh, yes there are; I missed that. Generic types can be parameterized on typeclasses, as normal.

There appear to be no first-class functions????? What millenium is this? Fukkin Pascal and C had first class functions in the 1970’s. How am I supposed to write callbacks? Oh thank gods they are present, I just missed them. The spec barely talks about them at all, but then again they are not closures, just function pointers that live in the Free-type universe. So there’s not a whole lot to talk about.

Little things

Ok I think we’re done with most of the interesting stuff. Any other tasty details? There’s destructuring assignments. Elseif’s are written else if; at least it’s not elsif I suppose. There’s a case statement that pattern-matches on unions. for loops count from a value a to b, where a <= b; no down-to, no-count by, no iterators. Shame on you. In my experience iterators are a fantastic tool for writing robust code. There appears to be no built-in language support for tuples; shame on you twice. They are defined as structs in the stdlib, like they are in C# or C++. There’s constants. The value for a constant can be public or private, which is an interesting touch; if a constant is private you can see its name from outside the module but not what its value is. Unless you… idk, copy it or something. No listed restrictions on what constants can be that I can see, a la Rust’s const or C++’s constexpr; they’ll probably get around to that eventually.

No slices. No string slices. Character set for strings is undefined. Lemme know how that works out for you. Yeah the lang is young and incomplete, this just is one of the first things that I look for since enforcing UTF-8 everywhere is real nice and almost anything else rapidly becomes real bad.

Oh, all function calls may have keyword parameters. Startlingly pragmatic decision, and it means that record/union constructors might be functions after all? Oh, nope, nope, they’re listed as a separate syntax rule, so I am forced to assume they are not interchangeable. There’s no default or optional parameters, which is a conservative but safe decision.

No listed way to disambiguate method calls when they might overlap with function names. Do you do it on module import somehow? Maybe I missed it, I skimmed whatever stuff there was on module imports.

A lot of the later bits of their syntax chapter is currently written in LaTeX math, not code. Even though I’m trying to read about the heckin syntax. Says a lot tbh. They developed the math first and the code later.

There’s a separate if expression. Which has no else if option, it seems. You can compose them, X := if foo then A else if bar then B, but there’s no end delimiter of any kind for it so I bet you then get the Dangling Else Problem if you try hard enough. At least it’s not C’s ternary operator!

!e dereferences the reference e. So… it moves e out of the borrow? Can you do that? I guess that’s a question for the linear type checker. In Rust you can’t move a value out of a borrow unless either it’s a Copy type or you immediately reborrow it, iirc. And in Austral you can’t borrow Free/Copy types. So I’m not really sure what this is supposed to do.

&e borrows the value e as an expression. How does that interact with the borrow statement above? Seems to be an anonymous version of it; “is an expression of type &[T, R], where R is a nameless region.” …Huh, if you have to name such a type then, how do you refer to the lifetime when you declare something else to be the same type? “Borrow expressions cannot escape the statement where they appear. That is, if we write [...code...] This will be rejected by the compiler because the region name R is introduced nowhere.” Ah, so we need no explicit lifetime specifiers for reference expressions because their lifetime can only be a single statement? I think that’s what it’s saying, but I’m not sure. From my reading it basically seems designed for calling functions, so you can do foo(&thing) instead of borrow thing as thingref in R do foo(thingref) end. More syntactic sugar, how dare you!

&! is a mutable borrow. So, uh… that conflicts with your dereference syntax. Buddy, what do you do if you want to dereference and then borrow something? &!!e??? Feels ambiguous. Though with none of Rust’s implicit conversion functions like AsRef there’s not much point to reborrowing, I suppose? Still, it means the expression &!foo could be parsed two different ways. Very surprising flaw in a language that wants clarity-on-reading above all else.

“If T is a type specifier, then sizeof(T) is an expression of type Index that evaluates to the size of the type in bytes.” What happens if T contains a generic? Does it fail to typecheck?

Standard modules listed are minimal and probably WIP. Austral.Memory has a minimal set of raw pointer stuff. No real surprises.

There’s a quote from the delightful Leslie Lamport wayyyyy back in the Austral spec’s introduction that was so thought-provoking I thought I had to stick it somewhere, so here it is:

An automobile runs, a program does not. (Computers run, but I’m not discussing them.) An automobile requires maintenance, a program does not. A program does not need to have its stack cleaned every 10,000 miles. Its if statements do not wear out through use. (Previously undetected errors may need to be corrected, or it might be necessary to write a new but similar program, but those are different matters.) An automobile is a piece of machinery, a program is some kind of mathematical expression.

The thing is though, after a lot of thought… at the risk of being grandiloquent, I think this is incorrect! An actual running program does require maintenance if it does anything particularly complex, and there are tons and tons of people who are paid to maintain these programs against the slings and arrows of outrageous misfortune. (A car running at a constant speed in a climate-controlled clean-room probably doesn’t need much maintenance either.) Why else is bitrot so pervasive a phenomenon? Once it is running and doing things a program is a machine, there is nothing you can do in a computer program that you can’t engineer out of brass cogs and steel springs and electrical wires and whatever else. The source code for it is the platonic ideal of the machine, the same way an automobile’s blueprints are its own platonic ideal.

Oh, one more thing I’d forgotten about. No operator precedence! All operators have the same precedence, and if you need to do some parts of an equation first you parenthesize them explicitly. It’s a design decision that has a bit of a cult following among some langdev people at the moment, while others seem to hate it. Personally I don’t care much either way: having no precedence is simple, consistent, and not too much of a pain to use. But having precedence is also real useful for composing expressions. I just think that if you’re going to throw away legacy convention and make people write that many parens in their expressions anyway, you might as well just go whole-hog with Lisp-style expressions everywhere. (Or Forth-style RPN, but that’s a bit harder to mix with other syntax elements.) Removing operator precedence is going too far? Not yet far enough!

Conclusions

And that’s about it! Wow that was a wild ride. All in all… Very opinionated, which is good even if I disagree with some of its opinions. …A lot of its opinions. Most of them, really. Austral is very much a work in progress, which is fine; the spec website has a copyright starting in 2021, while the source repo starts in 2018, but either way it is certainly not Done. Certain parts of it seem very thoroughly researched and come to conclusions that are reasonable even if they come out distasteful or unsatisfying; it accepts the necessity of them and pays the price willingly. Certain other parts feel head-crushingly naive. All in all, it’s a very interesting language, and one that I would never ever design.

Possible design holes I see as currently unaddressed in Austral:

  • It currently has no story for refcounting/sharing of resources in general. Interior mutability as well. Practically it will probably need these sooner or later, or else take the distasteful dodge of forcing people to implement the things that need them in FFI.
  • Values are mutable by default, it appears. Bit of an oof there. Only really matters for borrows though, but still, having everything be immutable-first is a big win for me.
  • Partial borrowing? Can you write Rust’s std::slice::split_at_mut()? If not, will the stdlib have it somewhere?
  • The aforementioned odd bits where the syntax is somehow not tedious enough; again, I don’t think this is actually a joke anymore, it 100% Feels Wrong.

So I’m feeling the reason Austral rubs me the wrong way so much is it assumes top-down design and implementation is the Right Thing, and it almost never is. “Design from the top down, implement from the bottom up.” This is actually still incomplete; the correct way is to design from the top down, implement from the bottom up, and then iterate, changing the design and implementation in turn until they mesh harmoniously. You very often do not fully understand a problem before you are neck-deep in coding it, and that new information needs to go back into the design… ideally while interrupting Flow as little as possible. But if you start with the same design principles Austral does, it is a pretty sensible language for fulfilling them.

So would I want to actually use Austral? Well… what for?

  • Would I write everyday fun code in Austral: gamedev, data mongling, web backend, noodling around and experimenting? Hell no, that sounds horrible.
  • Would I write day-job code in Austral: gluing together complicated robotics stuff with large libraries and vast frameworks? Nope, still horrible.
  • Would I write OS code in Austral? No, actually. You might expect that it’s the ideal place, but an OS is where all the lovely logic you think you understand collides headlong into messy reality. OS’s are full of fundamentally unsafe stuff, and Austral doesn’t yet seem to have a good story for writing unsafe stuff well. Rust is actually also sub-par for OSdev for this reason, you rely a lot on features that are unstable and underspecified and may never become stable, because Rust’s unsafe story is still a bit of a work in progress. Zig actually has some real advantages over Rust in osdev because of this, though its precise pointer semantics for aliasing and provenance are also still a work in progress.
  • Would I write safety-certified code for cars or aircraft in Austral? …hmmmm, here we start coming to a place where I might conceivably say “yes”. All Austral’s ergonomic disadvantages pale in comparison to the sheer amount of work involved in actually doing the safety assurance, so it may be worth it for that. If, of course, the advantages of its strict design actually turn out to be advantages. I think there could be more workflow-efficient languages for this than Austral, but they don’t exist yet.
  • Would I write low-level embedded code in Austral? Again, maybe yes, I could imagine it being useful for something like a CNC controller or (part of?) a drone flight controller. It runs into some of the “messy reality” problems that OS code does, but those are types of programs where the problem domain is usually quite limited, the story for managing resources is relatively simple, and code is mostly written and read and only occasionally and cautiously revised, so again Austral’s disadvantages are minimized.

So yeah. Austral in its current form seems like a very strange idea, generally executed very well. The author knows what they’re doing and draws from a fantastic set of thought-provoking sources on language design to produce something that is almost, but not quite, entirely opposed to what I want out of a programming language. I think we could have some very interesting discussions over dinner together.

I suck at writing endings, so I’ll just finish off with a quote from the spec: “We take the view that human error is an inescapable, intrinsic aspect of human activity. Human processes such as code review are only as good as the discipline of the people running them, who are often tired, burnt out, distracted, or otherwise unable to accurately simulate virtual machines (a task human brains were not evolved for), or facing business pressure to put expedience over correctness. Mechanical processes — such as type systems, type checking, formal verification, design by contract, static assertion checking, dynamic assertion checking — are independent of the skill of the programmer. Therefore: programmers need all possible mechanical aid to writing good code, up to the point where the implementation/semantic complexity exceeds the gains in correctness.”