Thin Features in Programming Languages

I'm a bit of a programming language nerd, I'm willing to admit that. But I'm sat here on my couch fighting off two cats and a dog and casually going through the ziglings exercises just to see what the fuss is about, and I find myself thinking the same thing I was thinking this afternoon writing some Go at work: I need y'all to get over the idea of methods, it's a thin feature.

Some background. I've written a lot of different programming languages professionally (Go, TypeScript, JavaScript, Rust, Java, and PHP to be exact). I've also messed around with quite a few more in my spare time, but of course writing personal code in a language and working on a team in a language are two different things.

When you work on a team, you don't pick and choose the best parts of a language to work with. You also might be switching language contexts a lot (maybe you use a different language on the front end vs the back end, or are in the middle of a migration to a new language, or work on micro services whose authors got to choose the language the service was written in).

In these situations, it really helps when the design of the language you're using prioritizes having a small set of powerful but orthogonal features that mesh well together. In other words, it has one way to do a thing.

When a programming language has more than one way to do a thing, it means introducing the complexity of extra syntax for perceived convenience rather than truly new functionality. These are "thin features", additions to a programming language that increase our capabilities by a pretty small margin.

In a moment I'm going to produce a listicle shaped rant of some thin features in various programming languages. I ought to clarify first that the languages I'm criticizing here are technical marvels with incredibly talented and dedicated people working on them, that empower people to build awesome stuff. I'm aware that what I'm describing here is a matter of taste.

Thin Feature #1: Methods

Some folks are going to be upset about this right off the bat, because methods are so ingrained in most developers brains it can be difficult to tell the difference between what methods actually empower vs. what some popular language implementations happen to utilize methods for.

Generally speaking, a "method" refers to a procedure that is name spaced by a type and has a syntax for being accessed from an instance of that type, like "dot syntax" aka foo.bar() or arrow syntax aka foo->bar() as opposed to the horror of bar(foo) .

As a feature, I think methods have absolutely no idea why they're there. Is it about name spacing? Autocomplete? Method chaining? Inheritance ( calling methods of a base class/embedded struct anyone )? In fact none of these require us to namespace functions beneath a type:

  • If you really want inheritance, your function can just accept an interface as a parameter.
  • If you really want method chaining, you should checkout pipe operators in languages like Elixir and Gleam, they're way more flexible and don't require you to change the way your code is organized into modules to take advantage of them.
  • Autocomplete is an implementation detail of LSPs. I see no reason why we couldn't provide powerful autocomplete for pipelines with a bit of elbow grease.
  • If you really want name spacing, have a module system, but don't tie it to how functions get called, because why on earth would those two things be related?

Having all these concerns bound up in the same piece of syntax comes with complexity cost. Pick any two programming languages from the list I gave above and the semantics of foo.bar() (or $foo->bar() in PHP's case) are probably different, because you have to know the manner in which foo is passed to bar (by reference? by value? is it mutable?). The best implementations give you control over this behavior but critically it's not visible from the call site. You'd have to go to definition to find out.

The complexity goes further, because when methods are our primary vehicle for code discoverability (riding that autocomplete), we start defining new types just to have a place to hang our methods. In Go, it's fairly common practice to define a type like type Foos []Foo to hang methods for working with slices of Foos from. Go will even let you use a Foos in a for loop, but again critically, you wouldn't know that unless you went to look at Foos type definition.

For these reasons I think methods are a vestigial appendage from a time when a namespace was equivalent to a class file.

Thin Feature #2: Tagged Unions in Trench Coats

I mentioned I was doing the ziglings exercises. Zig handles errors using a ! to combine an error enum and some other type into a tagged union with two variants (it's either an error of some kind or a successful value of some kind). It handles optional values in a very similar way, with a ? operator for defining types that can either be null or some other type.

Zig introduced special syntax for "either error or some T` and then more special syntax for "either null or some T" instead of simply having syntax for "either some T or some V". My suspicion is by adding special syntax for these two very common cases, they are adding some optimizations under the hood, or perhaps intentionally trying to prevent developers from being overly fancy with their types, but imagine if they just used | to say "this function can return a or b". Function can fail? MyError|u8. Function might return null? null|u8. You don't have to go full tagged union to recognize that whether or not a function can have multiple return types doesn't have to be coupled to the particular types in question.

In Go, a combination of multiple return values from functions (which you may or may not remember to check) and downcasting interfaces (which may or may not be the concrete type you expect, in a switch case that doesn't enforce exhaustiveness checking) is used to avoid having to provide tagged unions. I'll admit, I've been known to get a bit overly invested in "making invalid states impossible to represent", but its worth acknowledging a lot of complexity budget and space for foot guns gets spent avoiding tagged unions in a language design as well.

Thin Feature #3: Defer

I think this is maybe the take people will disagree with the most, but defer is a thin feature, not because it's not useful, but because it introduces even more complexity and (imo) encourages bad API design.

Lets be sure we give defer a fair shake. It's a really neat feature, that does one thing pretty well: it lets us specify cleanup functionality to run after a function body finishes executing.

Here's why I think it's a thin feature:

1. We can just use a callback.
2. A callback is harder to fuck up.

If you are utilizing some kind of resource that needs cleaning up, why not have a function that takes the operation you want to perform as an argument, allocates the resource, does the thing, and then cleans up the resource?

I think the main reason we don't do this more often is a well placed fear of callback hell, as most of the time we're working in languages where a callback implies nesting (if you'd like to see an example where it doesn't, check out use syntax in the Gleam programming language).

But what do we give up to avoid the indentation?

By not providing a callback based API, we give our users the opportunity to forget to cleanup the resource.
We also can no longer read the function from top to bottom, and in longer functions it can be difficult to tell in what order deferred procedures run.
We can also run into strange bugs around move semantics (like defer foo(bar) passing bar by value vs by reference).

It might not seem it at first, but a callback is simpler.

Thin Feature #4: Variadic Arguments

I feel like I shouldn't even have to say this one but just pass in an array folks, it's not that complicated. I truly don't understand what this feature buys us, why is taking away control over how an array argument to a function gets constructed a good thing? And why do I have to spread an array that's going to be an array again any second now just to pass it to a function?

The saving grace here is although as far as I can tell they don't provide any value variadic arguments don't really cause much trouble either. They make function signatures a bit more confusing, especially in a language otherwise without function overloading.

Honorable Mention: Overloading field access

Operator overloading isn't really my cup of tea, but I don't think it's a thin feature. There is however a specific kind of operator overloading, where you overload field access syntax (usually dot syntax) to call a magic method under the hood, that I find truly insane. According to Google they're called property observers in Swift (like setting an event handler on the field that runs when the field is set), and I think you do them with one of the "magic methods" in Python. I'm just gonna put this on the record: If I do foo.bar = baz on some instance of a class you wrote and it makes a POST request I'm showing up at your house.

Anyway that's my rant. I'm curious to know what features you think are thin and which ones you think are thick. I think generics and the aforementioned use syntax in Gleam are pretty damn thick. I'm on the fence about interfaces, leaning toward thin.

Subscribe to BenIsOnTheInternet

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe