Learning a new language with Advent of Code
I love Advent of Code - getting daily programming challenges as part of a Advent Calender is a great way to practice, learn and exchange ideas with others, as I already wrote last October.
Last December, I decided to learn a new language - Nim! It was great fun and I'd like to share some of the take-aways and insights I had.
But why Nim? I traditionally solved AoC in Python before, which is my go-to language for short programs which don't need to be run (and maintained) somewhere. But I always felt like I was missing out a bit against the languages that were compiled, in terms of runtime speed. The interesting bit is that the dynamic, untyped parts are not the things I like in Python (which arguably are a big part of why it is slow) but its expressiveness and simple to read code.
In 2023, I already started to experiment with Codon, which is "kind of" a compiled version of Python, although it's not fully compatible. It gives a nice perfomance boost but still I thought it should be possible to get more performance, by going even a bit further in the direction of a compiled language (and sacrificing some more "dynamic" parts of Python).
Entering Nim!
The good
Similar syntax as Python. Often it feels like you're writing simple, readable Python code. For example (Day 2):
import math
proc part1(data: seq[string]): int =
var count = 0
for line in data:
if isSave(line.split()):
count += 1
return count
proc main() =
var data = strip(readFile("../inputs/day02.txt")).splitLines()
let part1TestResult = part1(testData)
doAssert part1TestResult == 2
We have indentation and an import
just like in Python, we have for ... in ...:
, we have .split()
, we have if _:
, we have types with the data: ...
syntax.
Some things look slightly different, like the proc
instead of def
to define a function, or that we need variable declaration statements like let
or var
.
Strong typing: As I try to write most of my Python code with types added, it wasn't difficult to adopt Nim's stricter typing. For local variables it's okay and maybe a little bit tedious sometimes, but having to define all types on a function header really helps for both the caller and the implementation of the function, which is a big plus in contrast to Python. Example:
proc getDecimal(values: Table[string, int], letter: string): int = ...
Just as in other strictly typed languages, you can already get much more information out of this function header than you would in (untyped) Python.
Operator overloading: Last year, there were especially many puzzles involving coordinates, and in Python I was always a bit torn whether to use a custom class or to just use tuples, but then adding/subtracting coordinates would require a add(a, b)
function. Now in Nim it is possible to overload +
:
proc `+`(a: (int, int), b: (int, int)): (int, int) =
(a[0]+b[0], a[1]+b[1])
...which then makes it possible to do (1, 2) + (-1, 3)
. Neat, right?
Compilation with a release
-flag, having a binary: Coming from Python, being able to compile to a binary feels great, and with the possibility to create an optimized "release"-build even more. This can be achieved with:
nim compile -d:release day02.nim
Running this then generates the following output (thanks to the benchmarking package benchy):
./day02
min time avg time std dv runs name
2.064 ms 2.310 ms ±0.095 x1000 day02
2.3ms runtime, not bad!
Good library support: For most of the things I needed during AoC, I could use libraries available. strutils
and sequtils
for advanced string/list operations, tables
for HashMaps, math
/algorithm
for math stuff, parseutils
for parsing, and even heapqueue
for implementing a Priority Queue (used for Dijkstra).
To use heapqueue
was especially nice because you only need to implement the <
operator for it to work, for example with a custom Path
object (Day 16):
type Path = object
score: int
pos: (int, int)
dir: (int, int)
proc `<`(a, b: Path): bool = a.score < b.score
proc part1(data: seq[string]): int =
...
var queue = initHeapQueue[Path]()
queue.push(Path(score: 0, pos: start, dir: (0, 1)))
while queue.len > 0:
let currentPath = queue.pop()
....
The only thing missing for me was an equivalent for SymPy to do symbolic math. For Day 13, I first solved it in Python with Sympy:
from sympy import solve
from sympy.abc import a, b
solutions = solve([
a * param_a_x + b * param_b_x - result_x,
a * param_a_y + b * param_b_y - result_y,
], a, b)
sol_a = solutions[a]
which unfortunately was not possible in Nim. I later discoverd the manu (Nim Matrix Numeric library) package and simplified the solving to solving a linear equation system A x = b
.
import manu
let A = matrix(vals)
let b = matrix(@[float(result_x), float(result_y)], 2)
let x = A.solve(b)
let r = A * x - b
let sol_a = x[0, 0]
Good documentation of the standard library: I ended up reading quite some amount of general documentation, and kept coming back to the standard documentation, for example for Tables: nim-lang.org/docs/tables.html. I also liked Nim by example.
The different
Python lists = Nim sequences. This was a bit odd in the beginning, but you get used to it quickly. Also that you have to use @
for literal sequence definitions can be a bit strange. Example:
var current: seq[int] = @[-1, -1, -1, -1, -1]
which initializes a list of 5 elements of -1
.
Ambiguous imports: Most of the time, you use imported functions without explicitly specifying where it's from. Example:
import sequtils, strutils
let mySeq = @[1, 2, 3]
let myStr = "hello"
echo mySeq.contains(2) # from sequtils
echo myStr.contains("e") # from strutils
As Nim is strongly typed, this is not a problem for the compiler and Nim can resolve which function is actually meant, but it takes a bit getting used to it. I guess it relates to how you're used to writing Python code, either like
import math
math.ceil(1.2)
or
from math import ceil
ceil(1.2)
Range operator: This took me a while to get used to. If you do for a in range(h)
in Python, you expect it to run up to h. Now if you do for row in 0..h:
in Nim, it will run up to h
inclusive! So the correct way is actually
for row in 0..h-1:
which takes some time to get used to.
You can also use it for slicing nrs[i..i+3]
. Here another catch is getting the last element (or any element from the back), where in Python you would do nrs[1:-1]
, but in Nim it's done using ^
, like this: nrs[1..^1]
.
Arbitrary big numbers: Coming from Python, I was a bit spoilt with using arbitrary big numbers. For example Day 17 is still my only remaining Day in Python, because you need to do more by hand:
- Initializing BigInts with
initBigInt
- Implement custom functions like
pow(BigInt, BigInt)
,mod(BigInt, BigInt)
, ... - Converting between
BigInt
and primitives
It's only when you don't have a feature like arbitrary big numbers in Python anymore that you really appreciate it :-) Maybe one day I'm implementing Day 17 in Nim to see how fast a solution would be compared to Python.
Function on object vs. functional approach: I'm still not completely sure what the best way is to use imported functions, either as methods on the object itself or as function with the object as parameter. Example:
import algorithm
var names = @["Peter", "Anne", "Fredu"]
names.sort() # option 1
sort(names) # option 2
Can you tell which of the options is correct? Well, it turns out, both are! You will also find both ways of writing Nim on example pages. I really like the Python Zen statement "There should be one-- and preferably only one --obvious way to do it", as it helps a lot in learning new languages.
Initialization of certain objects: For some objects from the standard library, you'll find helper methods, like newSeq[int](5)
for sequence
or initHeapQueue
for heapqueue
.
Now for a table
, there is initTable[...]()
, which initializes a new table, like the following:
import tables
var table1 = initTable[string, int]()
table1["abc"] = 3
Now this assigns a new Table which was created with initTable()
to our variable table1
. But what if we only declared the type of a variable, without assigning it an initial value, like this:
var table2: Table[string, int]
table2["abc"] = 3
As it turns out, this does exactly the same - behind the scenes, Nim initializes table2
. Unexpected - as the Python Zen, I would rather have it like "Explicit is better than implicit".
I'm not sure if the main goal of those helper functions is to make it shorter to write, but for me personally, they confused me more than they were helpful.
Casing: In Python, variables and functions are typically named with snake_case, whereas in Nim it seems to be best practice to use camelCase. This can be a bit weird sometimes, because the languages are so similar, but it's a convention one gets used to quite quickly-
# Python
all_parts = split_whitespace(line)
# Nim
let allParts = splitWhitespace(line)
The bad
Missing IDE/IntelliJ support: This was one of the main pain points for me, as I'm really used to write code in IntelliJ. I tried the official Nim plugin but it was indicating that it was not "compatible with the IntelliJ version I was using" and I couldn't get it to work. I then tried VSCode where the support seems better, the syntax higlighting was working and I could run code with a "run" button from the IDE.
Sometimes it's not clear from where you're importing: Although it's listed as one of the advantages of Nim that no explicit imports are needed (like for example math.
Sets: I really struggled with Sets
in Nim. First, the default set type can only deal with int
, char
and enum
, so you'll have to use the std/sets
module.
Now let's look at the docs on how to create a new Set. It says "init:
Initializes a hash set. Starting from Nim v0.20, sets are initialized by default and it is not necessary to call this function explicitly.". Hmm, okay. but the example still says:
var a: HashSet[int]
init(a)
Now, let's add an element to the set. How do we do this? It was only after skimming through about half of the documentation until I found incl()
, instead of something like add()
or append()
.
Also, for custom objects in Sets, things get a bit tedious. You'll have to implement certain functions like hash()
and ==
on the object, but it's not clear from the documentation. On Day 16, I ended up using a table
instead, just because it was easier to use.
Hard to read error messages: Nim can have some error messages which are hard to read. For example, this piece of code generates the following error message:
import sets
let seen = initHashSet[int]()
seen.incl(3)
echo seen
> Error: type mismatch
Expression: incl(seen, 3)
[1] seen: HashSet[system.int]
[2] 3: int literal(3)
Expected one of (first mismatch at [position]):
[1] func incl[T](x: var set[T]; y: T)
[1] proc incl[A](s: var HashSet[A]; key: A)
expression 'seen' is immutable, not 'var'
[1] proc incl[A](s: var HashSet[A]; other: HashSet[A])
expression 'seen' is immutable, not 'var'
[1] proc incl[A](s: var HashSet[A]; other: OrderedSet[A])
expression 'seen' is immutable, not 'var'
[1] proc incl[A](s: var OrderedSet[A]; key: A)
[1] template incl[T](x: var set[T]; y: set[T])
Hidden in the middle of this error block, the information is there: expression 'seen' is immutable, not 'var'
, it is just hard to spot when just reading the first few lines. Why is this called a type mismatch
? This took me some time to get used to.
Also, as we just saw, using let
makes the sequence immutable
, instead of only preventing the variable from being reassigned, which can be a bit confusing. I think other languages solve this problem a bit better, like in Kotlin, where you can do the following:
var a = mutableListOf(1) # a can be reassigned, and list is mutable
val b = mutableListOf(1) # b can not be reassigned (=final), but list is mutable
var c = listOf(1) # c can be reassigned later, but current assigned list is immutable
val d = listOf(1) # d can not be reassigned, and the list is immutable
I like that this is much more explicit and distinguishes between the two features and lets you pick exactly what you want.
Amount of up-to-date documentation: There is quite some good documentation for Nim, but there are not many high-quality sites that are up to date. Sometimes when asking ChatGPT it would use examples from old versions of Nim which is no longer up-to date, or confuse it with other languages, such as using //
for commenting code. Overall it was okay, but the fact that it's still a niche language makes use of AI a bit harder.
Conclusion
Overall I had a great experience with Nim and I can recommend it to anyone looking for a "faster Python". It has some trade-offs and some slightly more complex corners, but I will definitely consider it for computationally heavy scripts I want to write in the future.
You can find my AoC repository here: aoc-2024.
Happy coding!