Hurl, the Exceptional Language
hurl.wtf
hurl.wtf
let foo = include "lib/foo.hurl"
foo.init()
it's much easier to reason about then, for example: include "lib/foo.hurl" // side effects
baz(buz) // function and variable that I have no idea if they are in the standard library, or included *somewhere*
That way it's much easier(Don’t confuse this with me thinking this project is worthless, I think it’s art.)
Sometimes I wonder if I'm exceptionally (haha) talented as I personally find the impact of exceptions on flow control pretty easy to grasp. But based on my understanding of other advanced computer language concepts, which is pretty lacking in some regards, I come to the conclusion that it can't be too hard, and people make a lot of fuss about it for no particular reason.
- you’re working in a language that doesn’t have checked exceptions, so the set of potential errors and the set of potentially error-raising calls is infinite but unknowable
- you’re working in a language that has checked exceptions, and you hate that it makes you do work, so you catch-rethrow runtime errors that recreate the first scenario
- you’re working in a language that has checked exceptions, but someone else did the second scenario so you’re in the first scenario anyway
As for myself, I've lived through the hell that are checked exceptions in Java. You learn that compositionality and checked exceptions are at odds when you try to insert a remoting layer into an application that has grown without IOExceptions. Then you learn that it's actually not necessary to know the set of possible errors, just make sure that you're not a bad programmer as in my first paragraph, and everyone will be fine. This is also something that you can learn from Exceptional C++.
At the highest level of your application (and at a few critical places, executors, retrying strategies etc) handle all the exceptions you know of, and implement a sane default for everything you don't know.
Done.
import foo(42, FULL_OF_EELS) as foo
-:1:8: E0012: Initializer of module "foo" has 3 arguments, 2 were provided.Shouldn't be like how parent proposed imports work. Would lead to too much pain.
import "foo/bar" should make foo.* OR bar.* available, not bazz.*. I'm looking at you, Go.
Can you give an example of what you mean?
```go
import (
"net/http"
"os"
"github.com/anacrolix/torrent"
tstor "github.com/anacrolix/torrent/storage"
)
```would import as: http, os, torrent and tstor. That guy is mixed up.
For example you can have github.com/user/go-module as module name, and "module" as package name, so "import github.com/user/go-module" will be imported as "module.*".
There’s a linter that automatically aliases this kind of imports with their actual module name so that it’s non ambiguous when reading the import list.
package main
// imports 'bar', you couldn't possibly know from reading this line
import "github.com/remram44/foo20240527"
func main() {
// ??? where does this function come from ???
bar.SomeFunc()
}I posted a criticism of Go, I don't know what you mean by "this is how it normally works".
I’m so confused by this. Unless you use a dot import, isn’t this just bar.*?
Useful when writing the code but not much use when reading the code.
For that very reason in Elixir `import` is discouraged in favor of `alias`
Instead there is this pattern of naming modules based on the file path they are located in, which is not enforced.
But for example gleam [1] is another language on the BEAM (compiles to Erlang), that has a much nicer approach: All imports must be declared explicitly, and the import path in your local project is based on the file structure so you always know where something is imported from [2].
I forked Ruby to have require that didn't clobber the symbol table but then lost interest in Ruby itself because the ecosystem seems unhinged on shared global mutable state.
Critiquing a joke design is of dubious usefulness, at best :)
Right now, if I have the `foo` module and `bar` function, I can call `arg1.bar()`, or `foo.bar(arg1)`. But if the namespace didn't also use `.`, then it wouldn't be an issue.
For sake of argument, lets choose `/` like Clojure. Then we'd get: `arg1.foo/bar()` so we can specify the namespace and uniform function call syntax is preserved.
"one,two".std/strutils/split(',')
more readable than this? "one,two".split(',')But I think the std/strutils/split counter example isn't strong for two reasons:
1. When you disambiguate in Nim, you use just the module name, not it's full path. So it would still just be `strutils/split`.
2. If we were to introduce such a syntax, we could also introduce an `import foobarbazqux as foo` alias syntax, which is present in many languages (eg JavaScript, Clojure, etc). This would also be useful if we ever had module name collisions, which has never happened to me in Nim, but doesn't seem impossible.
I think this kind of model could be cool if your IDE could dynamically determine all uncaught exceptions for a function, and lets you jump to each possible place where the exception could be thrown. Not sure how you handle coupling though. This seems like it would result in an insanely volatile control flow graph.
However, exceptions that a function can throw are part of the function signature in Java unless they extend RuntimeException (and in that case your program won’t compile if you throw an exception without adding it to the signature). While the circumstances in Java make it much easier for IDEs to report uncaught exceptions, it’s a solvable problem for non-runtime exceptions using static analysis.
On the other hand, returning standardised Ok/Err-wrapped values seems like a simpler approach, both in terms of tooling support and developer convenience.
What are transaction boundaries? Is Java getting transactions?
There is literally (literally!) no difference at all between throwing and exception and returning it as a variable. Except for the fact that in the exception passing style you have to write the boilerplate by hand, instead of letting the compiler do it for you.
Why anybody with a sane and functioning brain would want to do that by hand in 2024 I will never understand.
Having both throw and return is like regex: now you have two problems.
Take this:
```
int bar(int arg); // May throw
int foo(int arg) {
int b = bar(arg);
return b * 5;
}```
VS:
```
Error<int> bar(int arg); // Uses error type
Error<int> foo(int arg) {
auto b = bar(arg);
if(b.ok()) { // Happy Path
return b.unwrap() * 5;
}
return e; // Exceptional Path
}```
In the happy case (no exception) how can the first version be harder to optimize than the second one? In the exceptional case exceptions will probably be slower. You trade fast common case for slow exceptional case so it makes sense to me. Is the slower one the one that is hard to optimize? Is this what we are talking about when optimizations are harder to reason about? Or are there some other things that become harder because of exception? Things like RAII, defer, goto stuff?
If your runtime has to do extra work to support stack-unwinding, it could easily be slower. You're doing work in the happy path in that case just in case the sad path happens. My guess is adding function metadata to some sort of datastructure (linked list?) so that it can figure out whats happening when it long jumps to the handler. I'd bet that allocation has at least one conditional in it.
The alternative would be putting the metadata on the stack, letting the function complete normally, and then check a flag to see which path you're on (same overhead).
To be clear, I have no idea how various runtimes implement this, just that the extra magic could easily have the same, or more, overhead than a conditional check.
Elm is an extreme case where indexing into an array returns a result type instead of an exception when the index is out of bounds, unlike most languages including Haskell.
Maybe my program logic is intended to always access only valid indices in an array, but here I'm given a result type where I have nothing to do in the error case since my code is never intended to reach that case. I would rather let the language throw an out-of-bounds exception here to tell me that my implementation is incorrect while I'm testing.
Same with libraries in a language. It really depends on the use cases of the library you're writing whether results or exceptions are better. The most convenient thing for the user of a library would be to provide both exception-throwing and result-passing alternatives. This is what the OCaml standard library does as well.
I cannot fathom why people think it does error handling well, though. The codebase I work in has _so_ many errors that are completely ignored, and errors are a lot harder to track down.
These problems can be solved by writing better code, but the problem is that it's, of course, hard to write good code.
Java's exception handling has problems, but at least it gives you nice stack traces and you can't forget to propagate an exception.
It doesn't. But it lets me do error handling well if I'm up to it. Which is the worst of all possible worlds except for the one where error handling is done badly.
> Java's exception handling has problems, but at least it gives you nice stack traces and you can't forget to propagate an exception.
Y'know, I actually had the chance to use raw java (and even a modern version of java to boot) for something semi-recentish, and it was pretty great. I was surprised how well it worked out. Unfortunately, this wasn't the experience I got on any sort of enterprise java project though. The stack traces usually truncated before it got out of the framework being used.
I'm not really working in go because I'm rejecting java itself. I have certain disagreements with the design philosophy, especially in its early days, but it's reasonably possible to write decent code in it. I refuse to work in java because of its ecosystem. I know what writing java is like in an enterprise setting, and I'll take 1000 `if err != nil`s over that experience.
Vanilla Java is excellent. I miss some language features, but recent updates have really made the language competitive.
On the other hand, enterprise Java continues to be what everyone thinks of when "Java" is mentioned. It's also terrible.
def main():
try:
my_a = a()
except ExA, ExB:
my_a = None
...
def a() -> A:
...
my_b = b()
...
def b() -> B:
...
If b changes its signature to return type C, then it is a type error in a. main doesn't need to worry about what b returns; only a does.BUT if b begins raising ExC instead of ExB, then that will break main at runtime. That is, main needs to be aware of what b could raise, even though it doesn't directly call it.
If b() changes to throw a different type of exception that main() doesn't know to handle them main will break. And breaking is entirely the correct behaviour.
Maybe b() is an interface and the actual implementation might not even have existed when main() was written. Maybe yesterday it was implemented with a file, tomorrow with a HTTP call, and maybe next week something else. But tomorrow, at least, it can retry the operation when it fails.
My most robust application is a desktop application with a single exception handler at the event loop that merely prints the exception message in an a message box. If you try and save a file to a bad network location or something, just click ok and try again.
Not useful? You've implemented resumable generators! Of course, getting them to do anything except resume immediately might be... exciting. :P Just need to structure your entire codebase as an inside-out stack of `toss`es...
Part of me wonders if it's a bit of a joke, panning a genuinely useful feature (in any other language) as disposable and silly (in this one).
Like yield in C#?
This is really more like passing a callback through a side channel. "toss" is invoking said callback, and "return" is, well, returning from it.
From callsite Foo, I call out to a function with a known name, Bar, with one or more parameters. The runtime (or sufficiently smart compiler) searches upwards in scope, for a handler Baz that provides the function with that name. Baz's Bar is then called, with both the provided parameters, and, crucially, a function that is "the rest of Foo."
So, an implementation of Exception with effects would ignore the resume, and look something like:
define_effect Exception {
// only one function provided by Exception, but could have more
throw(String)
}
define_handler printExceptions {
throw(msg, resume_func): { println(msg) }
}
define_func getPage(url) {
request = http.get(url)
if not request.ok { throw("Could not download") }
return page
}
// main entrypoint
withHandlers printExceptions {
page = getPage("https://cheese.com")
println(page.text)
}
But you could also write an "on error resume next" handler for Exception. Exceptions thrown with this handler would be equivalent to toss. (in real life you'd probably write a different effect, rather than re-using the Exception/throw effect): define_handler onErrorResumeNext {
throw(msg, resume_func): {
println(msg)
// YOLO, call it anyways
withHandlers onErrorResumeNext {
resume_func()
}
}
}
withHandlers onErrorResumeNext {
page = getPage("https://cheese.com")
println(page.text) // Probably uninitialized - a better :P
}
Breaking away from the Exception example, two cool examples are: // Stream - potentially infinite and/or asynchronous iterables
// basically just like python generators
define_effect Stream {
emit(item)
}
define_handler toList(accumulator) {
emit(item, resume_func): {
if item == nil {
return accumulator
} else {
accumulator.append(item)
withHandlers toList { resume_func() }
}
}
}
define_handler take(how_many) {
emit(item, resume_func) {
if how_many == 0 {
emit nil
} else {
withHandlers take(how_many-1) { resume_func() }
}
}
}
define_func fib_stream() {
a, b = 0, 1
loop {
emit a
a, b = b, a+b
}
}
// Usage
first_five = withHandlers toList {
withHandlers take(5) {
fib()
}
}
println("The first five fibonacci numbers are", first_five)
And this one I'm just going to lift from the Unison documentation [1]: Each.toList do
a = Each.range 0 5
-- beginning of resume_func f_A
b = each [1, 2, 3]
-- beginning of resume_func f_B
guard (a < b)
-- beginning of resume_func f_C
(a, b)
-- yields
[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
Note that this has the same semantics as: results = []
for (a = 0; a < 5 ; a++) { // for each a, call f_A(a)
foreach b in [1, 2, 3] { // for each b, call f_B(b)
if not a<b { continue } // if a<b, call f_C(a, b)
results.append((a, b))
}
}
And it is possible to do this, because these resumable functions can be resumed an arbitrary number of times - it's up to the handler. They also can pass parameters back to the resume_func.Exceptions/hurl = exactly 0 resumptions toss = exactly 1 resumption effects = 0..many resumptions
[1]: https://share.unison-lang.org/@unison/base/code/releases/3.5...
They're the goto of our time.
When we have Maybe/Option and Effect/Result, there is really no reason to throw exceptions and having to mentally track where that is being handled.
I'm a bit worried about algebraic effects becoming more popular (and influencing frontend JS - which is already way too complex) because it's promoting throwing "exceptions" to control the flow. All of this to avoid the coloring problem in async/sync? Absolutely not worth it imho.
Speaking more seriously than is perhaps warranted, I’d slightly prefer it if there were syntactically different “catch” constructs for resumable and nonresumable exceptions, which would remove syntactic ambiguity around whether “return” was sending control flow back to the thrower of the nearest immediate exception or not.
Also, the stdlib shouldn’t have chickened out with regular value-returning functions. Just because the dogfood gives you heartburn doesn’t mean you shouldn’t eat it :)
Yes, although an alternative would be to make the syntax if you call the function where a value is expected, then it will automatically catch.
Also, I am concerned about how much Hurl I can write before people start calling me a tosser?
A conditions system would subsume both `toss` and `hurl` (because you can have an unwinding restart), as well as provide more flexibility: the condition can provide multiple user-defined restarts and the handler can pick between them (statically or dynamically), as well as feed content back into the signalling function.
For instance a classic CL restart is `use-value` which is the conventional name of restarts feeding a default value in case of missing or invalid item.
So let's say that you have a hashmap type, on lookup failure it could trigger a condition with a `use-value` handler to feed it a default value (possibly dynamically constructed), or abort/unwind, it could also have a restart to insert-and-return the value (so would behave as a `defaultdict`/`putIfAbsent`)
Note that C# (aka C Sharp) was way ahead of its time by including both options.
This industry pumps out new model languages every week, but is unable to generate new names with simple Markov chains?
Anyone giving a project a name that is already taken is a clown
Maybe a lot of them suck as project names, but name collisions also suck. I'd rather have unique names rather than beautiful but hard to find ones. Collisions are fine if they happen across geographical and cultural boundaries, but the software culture, for better or worse, is pretty global.
So hurl.dev didn't roll dice and got hurl, but consciously got a close sounding word.
In that sense, that makes one claim over the other a little more valid, in my mind, though you're right that clashes will have to occur.
I did get confused extra hard by the url = hurl.wtf, when hurl.dev is so close yet about different topic.