These are some thoughts I’ve had about the programming languages I’ve used. There isn’t a particular point to this writing, other than to finally get these ideas I’ve had in my head out and in a document. Expect some unstructured rambling, reader.
What programming language is the best? I don’t think there is a singular best programming language. Instead, you should pick the language that best suits your needs given the project’s criteria:
.exe, in which case a compiled language would be best.I’ve played around with a few programming languages, learning the basics of those I get curious about. In my opinion, learning a programming language itself is not difficult, but learning the concepts of the language is the challenge. Thankfully, many programming languages share the same concepts, so once you get familiar with one programming language, you have a head start on learning some others.
I don’t advertise myself as an expert of all of these languages. For some of the languages listed, I would not call myself anything but a beginner, but I will write down these thoughts and comparisons regardless.
I’ve only made a few trivial CLI applications with C. What I find enjoyable is that, compared to the other programming languages I’ve used, C naturally gives me a good idea of what the assembly will actually look like. Rather than elaborate on this, I’ll rely on the words of Linus Torvalds in this video: https://www.youtube.com/watch?v=RQ1IfKUD1nw
Another language that, presently, I’ve only made a few trivial utilities with. But, from my limited experience, I’ve found it to be quite a fun language, with an interesting syntax. Some notable things about Gleam:
if statements, relying instead on case statements.for or while), you instead use recursion.For example, if you were to write this in TypeScript:
function checkNumber(n: number): string {
if (n % 2 === 0) {
return "even";
} else {
return "odd";
}
}
for (const n of [1, 2, 3]) {
console.log(checkNumber(n));
}
You might write it this way in Gleam:
import gleam/io
pub fn main() {
check_loop([1, 2, 3])
}
fn check_number(n: Int) -> String {
case n % 2 == 0 {
True -> "even"
False -> "odd"
}
}
fn check_loop(numbers: List(Int)) -> Nil {
case numbers {
[] -> Nil
[head, ..tail] -> {
check_number(head) |> io.println()
check_loop(tail)
}
}
}
Go is pretty easy to learn. It has decent official online documentation to introduce you to the language, and the syntax is simple. It’s a garbage collected language, but you still have explicit pass-by-pointer and pass-by-value, so it helps a learner get used to the concept of pointers, without having to stress about memory management.
What I like most about Go is probably its very simple cross-compilation. Just set GOOS
to the operating system and GOARCH to the architecture you want to compile for. For this reason,
I usually consider writing compiled CLIs in Go. What’s also appealing to me is its standard library,
which is full of utilities for things like image processing, HTTP, and more. Publishing a Go module
is also simple, because you don’t log in and publish to a package manager; instead, you just host the module on GitHub
or on your site.
Go is evolving and improving, so, while I might have been disappointed in the past about a lack of features (like generics), I don’t think it’s worth complaining about that, because those wanted features either have been added or could be added.
I’m not a fan of the standard of using the “easiest” integer type instead of the most accurate integer type. For
example, if you look at the Color interface, you’ll see
that the type for the return values of RGBA() is uint32. At first glance, you might assume that
they’re actually 32-bit numbers, right? But really they’re 16-bit numbers stored in 32-bit values;
the values should never be greater than 0xFFFF. Why do this? To avoid overflow if you multiply by
a blend factor up to 0xFFFF. But if you didn’t look at the documentation, but just the return
type, you might assume that the maximum value is a 32-bit number, 0xFFFFFFFF. That’s just an
example that bugged me a bit, but a more common example is that the index of arrays and slices is
the signed integer type int, despite the fact that an index should obviously never be negative. I
just think it’s better for the type to clearly indicate the possible values, rather than be the
“easiest” type to use. I’d rather be trusted to know to convert to a signed or larger integer type
if I need to.
In some languages with interfaces, you either can or must declare that a type implements an interface. In Go, an interface is implemented as long as it implements all methods correctly. For example:
package main
import "fmt"
func main() {
var implementer Implementer
RunExample(implementer)
}
func RunExample(implemented Implemented) {
implemented.Example()
}
type Implemented interface {
Example()
}
type Implementer struct{}
func (Implementer) Example() {
fmt.Println("Hello, world!")
}
But what if you implement the interface incorrectly?
package main
import "fmt"
func main() {
var implementer Implementer
RunExample(implementer) // A compilation error points to this line
}
func RunExample(implemented Implemented) {
implemented.Example()
}
type Implemented interface {
Example()
}
type Implementer struct{}
func (Implementer) Example() bool {
fmt.Println("Hello, world!")
return true
}
What I take issue with is that, because interface implementations are implicit, the error points to a line where you try to use a type as an interface. In my opinion, it’s better to have an explicit implementation, as the error can then point to where you actually failed to implement the interface incorrectly. Take this TypeScript example:
interface Implemented {
example(): boolean;
}
class Implementer implements Implemented {
// An error is raised on the following line
example(): void {
console.log("Hello, world!");
}
}
function runExample(implemented: Implemented): void {
implemented.example();
}
runExample(new Implementer());
With an explicit implementation, the tooling can point to exactly where you failed to properly implement the interface, rather than only pointing to some other place and only telling you that the type does not satisfy the interface.
Python has a reputation as an easy language, but what I’ve found is that, due to the language’s scoping, there are a few beginner’s traps that a new programmer might not catch.
This is probably what I dislike the most.
global_value = 1
def increment_global():
global_value += 1
return global_value
If you call increment_global, you will receive an error stating that global_value was referenced
before it was assigned. This is because Python sees the += as an assignment, and because of that
thinks that there should be a new variable called global_value, scoped to the function, and
any reference inside increment_global should use that instead of the variable with the same name in
the outer scope.
This is an easy issue to solve:
global_value = 1
def increment_global():
global global_value
global_value += 1
return global_value
So why am I complaining? I believe it’s a symptom of the ambiguity between declaring a variable and assigning a value to a variable. Because you don’t typically use a special keyword or syntax for declaring vs assigning in Python, you can run into issues where you wanted to only assign, but you accidentally declared, too. Consider, for example, how Go’s syntax is very clear:
var x int = 1
func incGlobal() {
x += 1
return x
}
func incInternal() {
// We only have an internal x variable if we've explicitly declared it with `var` or `:=`.
x := 1
x += 1
return x
}
# names is a list of strings
for name in names:
cleaned_name = name.title()
print("Hello", cleaned_name)
What does this do? If you’ve used another programming language, you might assume that the print
statement would fail, with a message like 'cleaned_name' is not defined. Does Python do that?
Yes… But only if len(names) == 0. As long as there is at least one value in names, then
cleaned_name will be defined, and can be used outside of the for loop.
My issue with this is that, typically, it would be a mistake to use a variable defined in a loop outside of the loop. And, since Python uses indentation instead of braces, it’s easier to make this type of mistake by simply accidentally un-indenting or failing to indent enough.
Additionally, regarding scoping and for loops, you might find this behavior surprising if you’ve come from another language, like JavaScript:
lambdas = [lambda: n for n in range(3)]
for fn in lambdas:
print(fn())
# 2
# 2
# 2
def append_value(value, l=[]):
"""
Appends a value to a list. If a list isn't provided, a list with exactly one element
(the value) will be created.
"""
l.append(value)
return l
print(append_value(1))
print(append_value(2))
print(append_value(3))
Will this print the following?
[1]
[2]
[3]
Nope! It will print this:
[1]
[1, 2]
[1, 2, 3]
For what it’s worth, neither JavaScript’s nor Ruby’s default value features behave this way.
Despite my complaints about Python, I actually do like it, and I have used it often at work. If I want to quickly write out a script, I’ll often decide to use Python.
My issue is that it has a reputation as an easy language, and could be appealing to beginners, but I think a beginner could get caught by confusing and unexpected behavior that they wouldn’t know to watch out for.
My takeaway after using Ruby a little bit is that it’s a fun language, and has a lot of opportunities to do something that might feel magic, but might also feel confusing. For example, take a look at this code:
# example.rb
require "./example/helper"
puts with_commas("hello world").exclamation
# hello, world!
Wow, that’s cool! So there’s a built-in function called with_commas and strings have a method
called exclamation? Nope! That comes from the required example/helper.rb. But how do you know
that? If this was Python, you might have from .helper import with_commas, which would make it
much more obvious where the with_commas function comes from. And some languages won’t allow you to
extend a class like that (at least not without an explicit import).
example/helper.rb might look like this:
def with_commas(s)
s.split(" ").join(", ")
end
class String
def exclamation
"#{self}!"
end
end
Rather than the language encouraging you to be clear, it’s more like it’s up to the author to be aware of and follow conventions to make sure usage is not confusing. What I would say about Ruby is that, while some languages emphasize clarity and readability, Ruby makes it much easier to write and use shortcuts instead.
Rust is probably the most complex language I’ve used. But it might also be my favorite. The reason for this is that, when I’m using another language, I often start wishing that I could use a language feature that’s available in Rust.
What may be most confusing to newcomers are the concepts of borrowing, ownership, and lifetimes. Take this code for example:
struct StringWrapper(&str);
This is a struct that has one field, a reference to a string. This will not compile. This is
because StringWrapper is borrowing a str via a reference, and there is no guarantee that the
data that the reference is pointing to will exist in memory as long as StringWrapper does. You
have a few solutions to this. One solution is to own the string using the String type, which
will allocate the underlying data of the string and take ownership of it:
struct StringWrapper(String);
Copying or cloning data is often the easiest way to handle ownership issues. Some other borrowed types and their owned alternatives include:
| borrowed | owned |
|---|---|
&[T] |
Vec<T> |
&Path |
PathBuf |
&OsStr |
OsString |
And if you don’t know if your type will be borrowed or owned, you can either just always make
an owned copy, or optimize with
Cow (clone on write, not 🐮).
So taking ownership is one solution, but what’s the other? Lifetimes.
struct StringWrapper<'a>(&'a str);
The lifetime 'a basically asserts that the contained &'a str should live at least as long
as StringWrapper.
Ironically, since Rust’s ownership concept is considered an alternative to manual memory management, I think the easiest way to understand these concepts is to actually use a language with manual memory management, like C. When you have to think for yourself “I cannot free this memory until I have ensured that no more pointers to it are in use,” I’ve found that you naturally start to understand what borrowing, owning, and lifetimes mean in Rust.
Rust should typically guarantee memory safety, unless you use an unsafe block. I think that this
fact causes some coders to try to avoid unsafe, and consider it to be like a sin. When you write
Rust code, you typically won’t need to use the unsafe keyword, but I don’t think it’s correct to
consider unsafe to be bad. If you look through the documentation (e.g.
OsStr::from_encoded_bytes_unchecked),
you will see valid uses of unsafe. All it means is that the language itself cannot guarantee
memory safety, so it is up to the author to guarantee memory safety through the code’s logic
instead. For example, if you have already asserted that bytes will only contain valid UTF-8 bytes,
the following should actually be safe:
let bytes = b"valid UTF-8";
// SAFETY: We know that `bytes` only contains valid UTF-8.
let safe_os_string = unsafe { OsStr::from_encoded_bytes_unchecked(bytes) };
So, while you shouldn’t overuse unsafe, and you’ll often write an entire project without using
unsafe, I think it’s incorrect to think that a project is unsafe and needs to be fixed just
because it has the unsafe keyword in its code.
I think that Rust’s traits are a really cool feature. They’re a bit like interfaces from other languages, but also allow you to extend the functionality of existing types, kind of like Ruby. When I say to myself “I wish I was using Rust,” it’s usually because I wish that I had access to the trait system and how they interact with generics. Take this example:
fn main() {
print_pretty_type_name::<&str>();
print_accurate_type_name::<&str>();
print_pretty_type_name::<CustomType>();
print_accurate_type_name::<CustomType>();
}
fn print_pretty_type_name<T: PrettyTypeName>() {
println!("{}", T::pretty_type_name())
}
fn print_accurate_type_name<T: AccurateTypeName>() {
println!("Called with a {}", T::type_name())
}
trait PrettyTypeName {
fn type_name() -> &'static str;
fn pretty_type_name() -> String {
format!("I am a {}!", Self::type_name())
}
}
trait AccurateTypeName {
fn type_name() -> &'static str;
}
impl PrettyTypeName for &str {
fn type_name() -> &'static str {
"string"
}
}
impl AccurateTypeName for &str {
fn type_name() -> &'static str {
"&str"
}
}
struct CustomType;
impl PrettyTypeName for CustomType {
fn type_name() -> &'static str {
"custom type"
}
fn pretty_type_name() -> String {
String::from("This is a custom type!")
}
}
impl AccurateTypeName for CustomType {
fn type_name() -> &'static str {
"CustomType"
}
}
This example only scratches the surface of traits. It doesn’t cover:
self.Box<dyn TraitName>, which is somewhat similar to interface in other programming
languages.This example does illustrate what I like about traits:
impl TraitName for Type {}, it is not a conflict if two traits have
associated functions or methods with the same name.PrettyTypeName::pretty_type_name).I like and dislike Rust’s macros for the same reason: you can do pretty much anything with them. If you write a procedural macro, you can even do things like parse JSON at compile-time. That might be handy for making a concise shortcut to generate code, but it can also make your API opaque and hard to understand. I can look at a function signature and get a decent idea of what the output is, but I can’t really know what a macro outputs without reading its documentation and/or source code.
I’m going to talk a lot about JavaScript in this section, because I don’t really see value in talking about JavaScript and TypeScript separately.
To put it simply: if I want something to run in the browser, I probably want to use JavaScript or TypeScript. I can compile Go or Rust to WASM to run in the browser, but if the browser is a main goal then I’m picking TypeScript. The reason I gravitate to TypeScript over JavaScript is simply that I enjoy relying on types to improve code suggestions and to also easily document intent. For example, take this JavaScript:
export function add(a, b) {
return a + b;
}
+ is valid not only for numbers, but also for strings, and other types. So, from this example
alone, it might be hard to tell if it’s intended only for number types, or for all types that
support addition. TypeScript can clarify this a bit:
export function add(a: number, b: number): number;
export function add(a: string, b: string): string;
export function add(a: number | string, b: number | string): number | string {
return a + (b as any);
}
add(1, 2);
add("Hello, ", "World!");
add(1, "two");
The generated type declaration would be this:
export declare function add(a: number, b: number): number;
export declare function add(a: string, b: string): string;
Now we know that if we pass two numbers to add, we get a number, if we pass two strings to
add, we should get a string. The third call to add, which passes one number and one string,
is undefined behavior, and will cause the compiler to raise an error.
You might notice something unfortunate in this example: I used as any. + is valid between
number and number, and + is valid between string and string, but + is not valid
between number | string and number | string. So, if I dropped that as any, the TypeScript
compiler would worry that I might potentially add a number and a string together.
Some developers consider as any to be a sin, because it essentially disables type checking. I
think this is a good time to point this out: I think it’s actually okay to disable type checking
sometimes, as long as the usage is still checked. In other words, I’m not too worried about
type violations in a small utility, but I would be worried if calls to that utility violated
the type. I think that, in this type of example, it’s better to assert proper behavior of the
utility function with tests, rather than depend on the type checker. This is generally my
attitude towards TypeScript. I’m not very interested in using the type system to prevent me from
violating types, but I am interested in adding types to JavaScript to communicate intent.
Tooling (like LSPs) will know that I intend for add to return either a string or a number depending on the
arguments’ type, and can add appropriate type inferences that can improve completions. Human readers
can look at the type of add and understand its intent without needing to read a documentation comment.
I think that you should learn JavaScript if you’re learning programming, partially because it’s popular enough that it appears to me to be a lingua franca. If I’m writing an example, and I want the example to be easily understandable to a wide audience, I’ll often write it in JavaScript or TypeScript.