cynllunio er cod croyw
deg achosion i CASAU go! bydd rhif wyth yn synnu di!
hwyl: rhwystredigsoftwarerambleswhat is a programming language?
at its core, software is data piped into silicon to make it dance. but each individual byte of data is taking such small steps that it can get incredibly complex, incredibly fast. so programming languages are tools to make it easier to write – and they do that in a few interrelated ways:
- automating boilerplate, i.e. not requiring you to write the things you always want to do.
- abstracting intent, i.e. letting you define what you want at a higher level than the computer sees.
- preventing mistakes, i.e. making it harder to produce a binary that doesn't work how you want.
for an example of all three, let's look at a pretty simple example: function calls!
at the silicon level, functions are both simple and complicated. the call itself is just a jump – you tell the silicon to start executing bytes from a different part of the program. but if you want your function to jump back when it's done, you need to give it that address somehow. you also need to pass it the info it needs, and make sure it doesn't clobber the data you're working on.
now, in modern assembly, these problems are all sort of solved for you. there are dedicated instructions for call
and pusha
, and standard interfaces for how exactly function calls should work.
but of course, most programming languages, even lower-level ones like c, completely automate that. instead of manually shuffling the data around, you just tell the compiler you want a function call.
that makes it easier to write functions, which in turn helps the language achieve its purpose: easy functions make it easier to encapsulate and abstract your code, so your code gets easier to write. you make fewer mistakes, since you don't have to write all that boilerplate, and your code is easier to read, too, since it doesn't need to be understood as some gestalt.
so the programming language is doing its job. hooray!
here's how go fucks it up
ok, no, i'm not actually that sour on go. i've been using it for a fair while and it's… fine. but i have a lot of frustrations with it, like how error handling works.
here's how it works in go, in short:
func FalliblyGetInt() (int, error)
that defines a function named FalliblyGetInt
which returns an int
and an error
. predictably, that error
contains any errors if they happened, or nil
if no errors happened. so every (fallible) function call looks like this:
updated, err := db.UpdateWidgets(ids, status)
if err != nil {
return nil, err
}
return len(updated), nil
on its own, this isn't bad. but the problem comes when you don't need the list of updated widgets. then you should write:
_, err := db.UpdateWidgets(ids, status)
return err
but this is what you might write, by accident:
db.UpdateWidgets(ids, status)
return nil
so we go back to the point of a programming language. how does go's error handling make software easier to write?
and the answer is that it doesn't, really. it's not unworkable, and at least the error type can carry richer information than c's error codes, but compared to java or python with rich stack traces or rust with compiler-enforced handling it doesn't really help. you still have the potential footgun of forgetting to handle your errors.
plus, there's a more subtle issue: you always get two values. your code will continue, using whatever value the function returned in case of an error. so you might also have something like:
updated, err := db.UpdateWidgets(ids, status)
if err != nil {
slog.Error("couldn't update widgets", "amt", len(ids), "err", err)
// <<- oops! i forgot to write return here!
}
return len(updated), nil // <<- so this returns 0, nil, i.e. success
and now your code still compiles and runs just fine, except that it doesn't realize the order wasn't marked as shipped, and now you've sent someone a dozen packages before anyone noticed the issue.
here's how rust is perfect
ok, no, i'm not actually that evangelical for rust. it's my favorite language, true, but only because i prefer the compromises it's made. but core to its design is that mistakes should be harder to make – that's why its learning curve is so famously steep, actually! because it forces you to structure your thoughts in a way the compiler understands, and that's not a trivial skill to pick up.
the end result of that design philosophy is a language which not only itself prevents mistakes, but also offers devs plenty of tools to make sure apis aren't misused, either. yes, things like generics and lifetimes and traits make the language more complex, but that complexity is all in service of making it harder to make mistakes.
let's look at error handling again. rust has Result<T, E>
, which is returned like this:
fn fallibly_get_int() -> Result<int, MyError>
and used like this:
let updated = db.update_widgets(ids, status)?;
return Ok(updated.len());
i want to point out two things. first, this code is shorter – it's missing the 3 lines for error handling. it's more focused on what's actually being done, and the default, bubbling errors like the go example, is a single character. neither is strictly better, but i prefer rust's brevity. more on that in a sec.
second, it fixes the subtle issue! there is no way to get a value out of an error – this function returns a single value, which is either an Ok(value)
or a Err(cause)
. if you get a Err(cause)
, you can't proceed with your code – you don't have a value to proceed with, not even a garbage one! if you try, you get a compile error:
let updated = db.update_widgets(ids, status);
return updated.len(); // <<- no method named `len` found for enum `Result` in the current scope
of course, you can still explicitly ignore errors and keep going with a default value, if that's what your code should do – but you have to write that code. you don't just proceed with whatever garbage data the function returned.
and that brings us to the actual point of this post:
explicit code
go addicts will claim that their language's error handling is objectively best because it's "more explicit". in more words: rust has this magic question mark, whose behavior isn't immediately obvious if you don't know rust. with go, it's just a simple if
. "there's no magic", they might say. the code is explicit about what it's doing.
generally i do like this quality in a language, but there's some nuance being missed, i think: what counts as "what it's doing"?
consider this: do you care more about the instructions being executed, or the intent of the code? which statement would you prefer to see in your source code: "if that operation returns a non-nil error, the error will be returned", or "the caller handles this error"?
sometimes, the underlying mechanics are the intent, like if you're writing highly optimized code or implementing a specific data structure. in that case, yeah, your code should be very specific to exactly what it's doing! but most code isn't like that – especially not in a language which hides an entire userspace threading system behind the simple keyword go
.
the truth is that you rarely care exactly what's going on under the hood, about the mechanics of the code. but go's error handling forces you to deal with them constantly regardless.
in go, it's not clear whether this block is a mistake:
updated, err := db.UpdateWidgets(ids, status)
if err != nil {
return 0, nil // <<- should that be `0, err`?
}
return len(updated), nil
you're constantly dealing with the lower-level mechanics of error handling, so it's the same structure as usual, but with a tiny, easily-missed change. and if the issue gets caught, say because staticcheck
flags an unused variable, did you typo your boilerplate or change it on purpose? the right thing to do is add a comment explaining why it's like that, and maybe a _ = err
.
in contrast, in rust it's abundantly clear that this was intentional:
match db.update_widgets(ids, status) {
Ok(w) => return w.len(),
Err(_) => return 0,
}
you were obviously intending to write that errors would make the function return 0. sure, that might be the wrong choice, you might still have a bug – but your intent when writing this was obviously that errors make this return 0.
or, in other words: your intent is explicit.
conclusion
this is a core problem in language design. if you want to make your language pleasant to read and write, you need to carefully decide what abstractions you're going to introduce, how they interact and how they're expressed, etc.
but there's a balance to strike here. programming languages aren't magic or psychic, and they can't read dev minds. they need to be predictable interfaces to the underlying software, which means they can't try to be too "magic". things need to work consistently, so they can be understood and used reliably.
all that waffle is really just a way to admit i don't have concrete advice here. but i like to use two specific techniques to make this easier:
- try to write features in terms of other features, first. they don't need to stay that way – and likely shouldn't! – but doing it will help you see the connections between them.
- try to write old features in terms of new ones, second. again, not permanently, but it'll remind you of your old features and help you decide if changes are needed.
happy langdev!