What Makes A Good Programming Language?

I’m kind of obsessed with trying out new programming languages, but it doesn’t feel very productive. Eventually, I’d like to find something that fits just right for the type of programs that I like to write, and get really good at it.

That hypothetical perfect language should be/have most of these things, in rough order of priority:

  • Good tooling/development workflow
  • Coherent type system
  • Easy to deploy
  • Simple package management
  • Expressions over statements
  • Multiple dispatch
  • Macro system

Tooling/Development Workflow

I like to use languages that have a REPL (an interactive shell) because it makes testing out basic ideas so much faster. I also want to be able to run static analysis, code formatting, tests, etc. easily and quickly. If it takes 30 seconds to compile every code update, I’m very likely to spend 3 minutes distracted while that happens (Christopher Rackauckas said it really well).

Type System

I like types. They make my code a lot easier to reason about, and reduce the need for tedious data validation. I’d say that Go’s static typing + simple generics + sum types is the lower limit here. Is that so much to ask?
I actually think there’s a sweet spot between leniency and robustness that needs to be toed carefully. For most programs, the benefits you get from Rust’s ownership model or Pony’s reference capabilities are well past the line of diminishing returns.

Deployment

When I say deployment, I don’t mean “deployment to 20 Kubernetes clusters across 5 continents”, I mean “running the program on my $5 DigitalOcean droplet or AWS Lambda”. So I care about things like baseline memory usage, runtime requirements, etc. Go is the gold standard here, since I can compile from pretty much anywhere and drop a single binary onto the server.

Package Management

Wrangling dependencies isn’t fun, so the more a language’s tooling can deal with it for me, the happier I am. I don’t have super high standards for this. virtualenv and pip are sufficient, CMake and whatever else goes on in the C++ world are not.

Expressions

Expressions have return values, statements do not. I want to be able to write stuff like this:

a = 
  cond do
    condition() -> 1
    other_condition() -> 2
    true -> 3
  end

Instead of this:

var a int
if condition() {
    a = 1
} else if other_condition() {
    a = 2
} else {
    a = 3
}

I find this to be especially useful in the case of return, since it’s easy to miss a deeply nested return foo when skimming over some code.

Multiple Dispatch

I was introduced to this feature through Julia. Basically it allows you to write lots of methods with the same name but differing type signatures. The method that gets run then depends (dispatches) on the types of all the arguments. For example:

f(x::Int, y::Int) = x + y
f(x::Float64, y::Float64) = x - y

Calling f(1, 2) spits out 3, and calling f(2.0, 1.5) spits out 0.5. From my limited knowledge, I think similar stuff is possible in C++ and Common Lisp’s CLOS. A more practical situation that I found myself in when writing some Python code:

get_repository(id::Int) = make_some_http_request(id)
repo_name(r::Repository) = r.name
repo_name(id::Int) = repo_name(get_repository(id))

I like this a lot better than runtime type checking or names such as repo_name_from_id.

Edit: C++ overloading does not really constitute multiple dispatch due to being static.

Macros

Sometimes it’s fun or useful to write a kind of DSL for a problem I’m solving, or just something to reduce boilerplate. For example, suppose I’m creating types for some unpredictable web API and I can never really tell what’s nullable. My code might look like this:

struct SomeType
    field1::Union{Int, Nothing}
    field2::Union{Vector{String}, Nothing}
end

function from_json(::Type{SomeType}, json::Vector{UInt8})
    # Create a SomeType from a JSON response.
end

But all those Unions are going to get repetitive, and so are the parsing functions. Gotta save those bytes! How about this instead:

@write_my_boilerplate struct SomeType
    field1::Int
    field2::Vector{String}
end

In this Julia example, the @write_my_boilerplate macro can read the fields’ types and replace them with Union{T, Nothing} (where T is the original type), add a parsing function from the field names, too. I used a similar strategy for GitForge.jl.


Notably missing from this list is speed. Obviously I need some speed, but I’ve never really wished that Python was faster. I care more about compiler and startup speed.

So what fits the bill?

Julia

Julia is the language that I know best, since it’s the one I’ve spent the most time with professionally. It has a great type system, the package manager is nice, everything is an expression, it has multiple dispatch, and it has macros. I could (and probably will) write a bunch of blog posts on the things I love about Julia. It’s so close to checking all the boxes, but it fails so spectacularly at the ones it doesn’t that I can’t make it my go-to.
The ecosystem is still fairly young (especially in the non-scientific areas that I play around in), so the tooling is really lacking. Juno is pretty good but I don’t want to change my editor for a single language. There aren’t really any static analysis tools, and there is no widely-accepted code formatter. I do believe that this stuff will come eventually, but it’s tough to live without for now.
Compile times are, in a word, awful. I understand that it’s being worked on, but it’s too much to deal with for now. Even loading packages can be a minute-long affair if you have enough code. Revise.jl is awesome, but it can’t quite salvage everything, since unit tests always run in a separate Julia process.
I was actually pleasantly surprised to see that baseline memory is down to just under 100MB, since I remember it being closer to 200MB, but it’s still too much to deploy on a tiny shared server. Startup time has also gotten a lot better (if you don’t load any big packages).

Elixir

I could definitely make do with Elixir. Seriously, go read about its pattern matching and try to imagine something better. However, the type system is a bit weak and I find Dialyzer to be a bit too clunky to fill the gap. Its supervised actor model also lends itself more to server-style applications rather than command-line stuff, and I like to do both. Everything else is top-notch, though.

Python

Python is great. It’s everywhere, and there are tools for everything imaginable. But the type system is lacking (despite me really enjoying mypy lately), there are no macros or multiple dispatch, and I will never not think this code is wrong:

if True:
    x = 1  # This binding should NOT leak out of scope.
print(x + 1)

When I use Python, I basically always get the job done, but I also feel like I’m settling somewhat.

Go

Go is so close to being my perfect language. In fact, I think Go 2 might be, whenever it exists. I want generics and I want less if err != nil boilerplate. Even though Go is not expression-based, doesn’t have multiple dispatch, and has no form of macros (no, go generate doesn’t count), I wouldn’t miss them that much. I’ll keep my fingers crossed.

OCaml

I’ve only spent a short time learning OCaml, but I think that it might be the language for me going forward. It’s statically compiled with a powerful type system and robust tooling, including a REPL. The only box it doesn’t check is multiple dispatch, and I can live without that. I just need some more time to wrap my head around it, because it is admittedly quite complex.

Honourable Mentions

“Lisp”: In quotes because there are many to choose from. I spent a while getting to know Clojure via Advent of Code but wasn’t super satisfied, since pretty much every solution was a clunky reduce (PEBCAK probably applies here). Common Lisp looks promising, since it also includes multiple dispatch. But Lisps tend to be as dynamic as it gets, and I prefer more static type systems.

Rust: I feel like Rust could be the right language if I put enough time into it. It does check pretty much every box (although compile times sort of suck), but I just haven’t been able to get over the initial learning curve and I don’t gain a ton from the safety guarantees that you pay so much for.

Pony: Pony interests me a lot, but I don’t yet have a great use for reference capabilities, its “killer feature”.

Zig: I definitely want to get to know Zig, I just haven’t found the time. I do however think it’s probably more low-level than what I need.


Moral of the story: I should buckle down and get good at OCaml. The other moral of the story, though, is that there will probably never be a “perfect” programming language, even for specific domains. It’s a constant game of tradeoffs.