Why Programming Languages are not 'Just Tools'
or Why Your Manager is Wrong About Their Choice of Programming Language
Introduction
Four years ago I was tasked with a project that roughly translated to ‘take a JSON and run a set of subprocess commands based on that JSON.’ I was a junior software engineer at the time and I thought “Great, an awesome time to try out a new language, Go.” The broader team used exclusively C/C++ creating a disagreement between “why would I write a program in C when Go solves these problems easily?” (me) vs. “programming languages are tools” (my team). Four years later I still believe I made the right call—and this post is about why the old “programming languages are tools” adage no longer holds up in modern software development.
If you haven’t already, you will eventually hear some variation of “programming languages are tools so it doesn’t matter which you pick” or “why are you complaining about language X it solved the problem.” This is superficially true since programming languages (and some configuration languages) are Turing complete, allowing them to solve any problem that a computer can solve. However, it begs the question of what kind of tool a programming language is. Is it a hammer when you need to actually paint a wall? Is it a chainsaw when you need a box cutter for a precision cut?
In this post, we will analyze the idea that programming languages are interchangeable and how that falls through in the modern/corporate context.
How Languages Shape Problem-Solving
The way that programming languages influence how you think is often presented in a programming language class between procedural vs declarative, functional vs object-oriented, or lisp vs every other language. These ‘first-order’ distinctions are important because
in a pure functional language you don’t have access to mutation or side-effects forcing you to think outside of classical ways of solving problems.
Declarative configurations such as terraform (opentofu for our open source enthusiast) prevent you from using complex for or while loops like you could in something like Pulumi affecting the way you represent your infrastructure.
; however, an under-discussed dynamic that impacts code design is idiomatic vs non-idiomatic. You may see this concept if you’ve ever thought “this code doesn’t look like or feel like code that I would write in this language.” Is this python code ‘pythonic’? Is it C#/Java’s extensive use of object-oriented principles that leads you to look through four different factory classes before you finally see the base class? Is it Haskell’s functional purity that increases your IQ by 10 points if you can read the code?
I come mostly from a Go background so this idea of ‘idiomatic Go’ is deeply ingrained in the way that I write Go code. Go tries to solve problems in specific ways, resulting in one main way of solving them. This allows an experienced Go developer to look at code and ‘feel’ whether it’s idiomatic. This is compounded by the fact that Go is a highly opinionated compiler/formatter that will not compile your code properly unless it follows the exact format.
Rust takes this idea a step further by enforcing idiomatic code via its robust type system, strict compiler, and helpful companion, Clippy. This strictness of the compiler leads to one of the core theses of a RustConf 2018 talk from Catherine West ‘Using Rust for Game Developer’
Rust, by design, makes certain programming patterns more painful than others. This is a GOOD thing! It turns out that, for game development, the patterns that end up being the easiest to deal with in Rust closely mirror the patterns that are easiest for game development in any language.
Some people dislike Go’s ‘implicitly’ opinionated design or Rust’s ‘explicitly’ opinionated compiler. I am not saying these are necessarily better designs; however, they shape how you solve problems, whether you want them to or not. I for one find parsing nested classes in C# incredibly challenging, but you may be able to parse them in seconds. This is because the programming languages we’ve used and skills we were taught have trained our brains in fundamentally different ways.
Our brains are trained from interacting with language designs and implementing them to solve real-world problems. A common pattern of programming languages is ‘Resource Acquisition Is Initialization’ (RAII). The simplified way of thinking about RAII is when you initialize a struct it may allocate some resource it needs to perform some actions and when the object is no longer needed it automatically cleans up that resource.
Taking examples from https://hzget.github.io/programming/basic/comparison.html, we see that this looks different between languages
use std::fs::File;
fn main() {
let _file = File::create(”example.txt”).unwrap();
} // File is automatically closed when `_file` goes out of scope
package main
import “os”
func main() {
file, _ := os.Create(”example.txt”)
defer file.Close() // Must use `defer` to ensure cleanup
}
The Rust version seems cleaner because it’s handled entirely by the author of the library instead of by the developer
Rust - creator of
Fileneeds to make sure they implement theDroptrait to close the file when it goes out of scopeGo - developer needs to remember to call the
Close()function on the file.
What about when we want to run custom behavior whenever a function returns?
struct CleanupGuard;
impl Drop for CleanupGuard {
fn drop(&mut self) {
println!(”Cleanup code executed!”);
}
}
fn my_function() {
let _guard = CleanupGuard; // Create an instance of the cleanup guard
// ... your function logic ...
// The cleanup code in `drop` will run when `_guard` goes out of scope,
// which happens at the end of `my_function` or on early return/panic.
}
package main
import “fmt”
func main() {
defer func(){
fmt.Println(”Cleanup code executed!”)
}()
// ... your function logic ...
// The defer function is now run
}
With a simple tweak of our objective, Go’s defer pattern starts to feel better because it has less design ramifications. What if in the Rust case I want to access a variable that is mutated over the course of the function? This means I may have to completely redesign the function or not rely on this Drop constraint at all which affects the design of my API and/or function. This may push developers in Rust to try to encapsulate more of the logic inside of the libraries while Go may push the logic more inside of the program directly. The route you prefer depends on your style and the problem you want to solve.
The choices that were made during the language’s design now have downstream effects on my application’s design and structure which again cuts against the idea of languages being strictly ‘interchangeable.’
From Simple Tools to Software Ecosystems
The introduction states ‘programming languages being just tools falls apart in the modern context’ because I think this idea used to be true. BASIC, C, and Pascal all have different syntax but similar feature sets such as functions, variables, keywords, and (maybe) classes. The features of each language are a simple translation layer between the programmer and the underlying assembly you write.
The interchangeability problem begins when we talk about
the size of the program
the ‘standard library’ (stdlib) - a collection of functions for doing tasks common across many programmers (or more broadly the ‘batteries’ that come included with the language).
I am going to ignore (1) because I think it’s self-evident that a large project is always hard to change/port.
For (2), Go, Rust, and other ‘modern languages’ build on this idea with integrated language server protocols (LSPs), linters, package managers, and others. It’s more accurate to say, ‘languages are ecosystems.’ Choosing one is like choosing a brand of power tools—you’re not just picking a drill, you’re picking batteries, accessories, and future compatibility.
Let’s write an HTTP web server in two different languages. To draw out the differences, I will pick C and Go, but know that this really isn’t a fair comparison. Starting with the Go example
package main
import (
“fmt”
“log”
“net/http”
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, “Hello, you’ve requested: %s”, r.URL.Path)
}
func main() {
http.HandleFunc(”/”, handler)
log.Println(”Starting server on :8080”)
log.Fatal(http.ListenAndServe(”:8080”, nil))
}
This will perform all of the basic functions we want such as (1) registering the TCP socket with the operating system, (2) reading the request and running the ‘handler’ with our custom code, and (3) printing whenever we get a request to the console.
The C example
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 4096
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// HTTP response string
const char *http_response =
“HTTP/1.1 200 OK\\r\\n”
“Content-Type: text/plain\\r\\n”
“Content-Length: 13\\r\\n”
“\\r\\n”
“Hello, world!”;
// Create socket file descriptor
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror(”socket failed”);
exit(EXIT_FAILURE);
}
// Forcefully attach socket to the port
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror(”setsockopt failed”);
exit(EXIT_FAILURE);
}
// Define address
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // listen on all interfaces
address.sin_port = htons(PORT);
// Bind the socket to the port
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0) {
perror(”bind failed”);
exit(EXIT_FAILURE);
}
// Start listening for connections
if (listen(server_fd, 3) < 0) {
perror(”listen failed”);
exit(EXIT_FAILURE);
}
printf(”HTTP server is running on port %d...\\n”, PORT);
while (1) {
// Accept an incoming connection
if ((new_socket = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen))<0) {
perror(”accept failed”);
exit(EXIT_FAILURE);
}
// Read incoming request
read(new_socket, buffer, BUFFER_SIZE);
printf(”Received request:\\n%s\\n”, buffer);
// Send HTTP response
write(new_socket, http_response, strlen(http_response));
// Close the connection
close(new_socket);
}
// Close the server socket (never reached in this loop)
close(server_fd);
return 0;
}
If your objective is to create a simple HTTP web server, you probably want to pick the Go example since the C example doesn’t even do route handling, but what if we take the ecosystem into account? If you want to use
gRPC instead of http? Then you’d probably want to pick Go because of its rich gRPC integration
XDP or DPDK to accelerate this webserver? Then you’d probably want to pick C because the bindings are written for C
Any ‘good’ developer can make anything work with handwritten, high-performance libraries, but should you? How long will this take for the developer to do bug-free? Even if they make it bug-free, could a new developer pick up that code with the same effectiveness? The sensible answer to the question of ‘which language you should pick’ depends on (1) your experience in that language which can help mitigate any ecosystem differences and (2) the ecosystem that the language has developed.
Conclusion
Modern languages have cast aside the concept that programming languages are simply tools and have become entire ecosystems. Should you use Go to create a front-end when there is React/Svelte/Solid? Maybe, but each ecosystem has its own pros and cons making the choice important.
The hardcore among you may still be thinking, ‘I can still solve any problem you put in front of me using language X because I am the best language X developer out there’ and you’re probably right. You can build a game engine in X, you can build an operating system in X, you can build medical devices in X. The question isn’t just, “Can language X solve the problem?” but rather, “Is it sensible to use X given the constraints, ecosystem, and maintainability?” The best tool isn’t just the one that works, but rather the one that fits.
If you liked this post, be sure to check out our website stratos.host or our previous article


