Compiled vs. interpreted languages
The traditional distinction between compiled and interpreted languages used to be quite clear: compiled languages like Fortran and COBOL must translate their source code into machine code before they are executed on a processor, while interpreted languages like Lisp and BASIC execute their source code immediately. However, this line has blurred as interpreted languages like Python and Ruby came out which use bytecode internally, alongside compiled languages like Java and C# which also compile to and execute from their own bytecode.
In this fashion, modern interpreters do "compile" their source code, and many modern compilers are tightly developed with an interpreter, such as Java's JVM, or C#'s CLR.
However, there is still a very clear distinction between a language "feeling" like it's compiled or interpreted. The way I see it:
- Compiled languages catch type and semantic errors at compile time, preventing execution until they are resolved.
- Interpreted languages defer most if not all checks to runtime, meaning the program may execute partially before encountering an issue which stops the program.
Then you're probably wondering: When does Ribbon check errors? Interestingly, I've designed it to catch errors both at compile time and at runtime. I really mean it. A compile-time error is also shown at runtime, and similarly a runtime error is shown at compile-time (pending some formal verification). Even beyond that, runtime errors do not terminate the program if they don't have to. Here's an example using actual outputs from my current interpreter:
let x = 34
let y = x + z
print(x)
y
Executing program...
Warning: Undeclared identifier `z`
3 | let y = x + z
| ^
(34)
Returned value:
Error: Undeclared identifier `z`
3 | let y = x + z
| ^
Once Ribbon has a static analysis phase, the warning here will appear before execution starts, but it
will not prevent execution. The error you see there really is the value of y
(the final
expression in a program is its return value). Evaluating y
's declaration didn't even
prevent the next statement print(x)
from running! Isn't that incredible?
Fallibility and error handling
Ribbon has a null
literal which belongs to the type Null
, a subtype of
Error
. Indeed, this makes null
the easiest error to construct. Errors in
Ribbon behave similarly to NaNs in floating-point arithmetic: they are contagious. If an operation
involves an error, its result propagates that error.
Bonus fact: Ribbon does not allow NaN as a valid value of floating-point numbers—it is instead an
instance of Error
. If you did want a type representing floating point numbers and NaN,
you can use Float?
. The question mark denotes fallibility, which acts a lot like an
optional. It either has a non-null value or it has an error value which always compares equal to
null
. (Finally, no need to worry about floats only partially implementing comparison
operators! I'm sure Rust programmers will appreciate that!)
I have a pretty insightful example that I think motivates how I view error handling in Ribbon.
let divide(a: Real, b: Real) -> Real = {
a / b
}
In this sample, divide
is a fallible function. If b
is zero, then
a / b
is undefined, and the return value will not be a member of Real
. The
idea is that this would be detected during static analysis, and consequently any usage of
divide
in other code would be fallible (i.e. the result of divide
could be
an error value, as if the return type was Real?
). At the very least, the declaration of
the divide
function yields a warning: "divide
is a fallible function
because it is undefined for b = 0
". We can suppress this warning simply by making
the return type be Real?
to explicitly state the function is fallible.
However, this is not the best solution. Ribbon supports preconditions, so we can actually express division this way:
let divide(a: Real, b: Real and b != 0) -> Real = {
a / b
}
The parameter b
now has a precondition that it is not 0. Thus, the division
a / b
is always defined. It is now up to the caller to enforce the precondition. If the
caller can prove b
is always non-zero, then from the caller's perspective,
divide
is infallible. But if the caller cannot prove b
is always non-zero,
then from their perspective, divide
actually has the function type
(Real, Real) -> Real?
.
I also want to mention that fallible types can also be expressed as T | E
, for any type
T
and any error type E
, if for any reason you want the error type to be
explicit. You can even chain it: T | E1 | E2 | ...
. This is one of the ways to safely
handle fallible types and make the code as a whole infallible.
There's a few big takeaways from designing Ribbon this way that are worth emphasizing.
- Errors in Ribbon are preferably not handled or "caught". If an error could be avoided,
it should be because there is a precondition that could have been enforced which avoids the
error, just like in the
divide
example above. - If you do really need to handle "errors" (i.e. HTTP 404 and the like), you should first consider using enums. Enums are very handy and easy to use in Ribbon. For example, programmers can easily assume an enum only yields one or a few variants, and not handle the rest. Only in those unhandled cases can an error be created. Basically, it is at the point of usage where a programmer can choose how much they want to handle and if they are willing to create fallible code or not.
- If you really do need to handle and unwrap actual
Error
objects, there are concessions in Ribbon to allow it, but it's a last resort. I'll have to describe this later at some point, perhaps in actual documentation. - Since errors are valid values at runtime, they do not block the program from ever compiling or running. But they do make the program as a whole fallible, and fallible programs are not easily shared as libraries. I'm thinking that users who use a "fallible" library must acknowledge it, and their own project becomes fallible as a result unless all inherited errors are handled.
I also have some ambitious goals for Ribbon with respect to formal verification. For example, I would love if Ribbon could enforce memory usage. I dream of static analysis within the compiler proving that a Ribbon program can be guaranteed to never use more than a given amount of memory (for both the stack and heap individually) for a given platform. This would be amazing for usage in embedded systems and the like.
Summary
In the traditional sense, Ribbon is currently just interpreted for now, but the language is designed to be compiled to CPUs or even GPUs. I am designing it to be suitable for usage as a systems programming language after all. I just haven't gotten there yet with development.
Since Ribbon is designed to detect and catch errors at compile-time and at runtime, I think it is fair to say Ribbon is both a compiled and interpreted language, or at least it certainly feels like it is. These error semantics are very versatile, making prototyping incredibly easy, while also giving Ribbon stringent and robust mechanisms to enforce program correctness. When a project grows past its prototyping phase and wants more reliability, preconditions can be added incrementally to reduce the amount of fallible code over time.
I hope you find these ideas fascinating! I'll see you next week where I'll talk about tuples, structs, and enums in Ribbon. Until then, take care!