A Quick Comparison of Nim vs. Rust
EDIT (Jan-13): Use Nim’s
-d:release
instead of--opt:speed
flag. Use Rust’sregex!
macro, andcollections::HashMap
instead ofBTreeMap
.EDIT (Jan-15): Add Rust results with regular expression
r"[a-zA-Z0-9_]+"
.EDIT (Jan-17): Refine Rust code for Zachary Dremann’s pull requests. Edit Rust’s strengths list.
EDIT (Mar-12): Merge Zachary Dremann’s pull request for the newest version of Rust.
Rust and Nim are the two new programming languages I have been following for a while. Shortly after my previous blog post about Rust, Nim 0.10.2 was out. This led me to take a closer look at Nim, and, naturally, compare it with Rust.
In this blog post, I’m going to share my two simple examples written in Nim and Rust, the rough execution time comparison, and my subjective impressions of coding in both.
Example 1: wordcount
This example exercises file I/O, regular expression, hash table (hash map), and command arguments parsing. As the name suggests, it calculates the word counts from the input files or stdin
. Here is the usage:
Usage: wordcount [OPTIONS] [FILES]
Options:
-o:NAME set output file name
-i --ignore-case ignore case
-h --help print this help menu
If we pipe the above usage to the program with -i
, the output would be:
2 case
1 file
1 files
1 h
2 help
...
Nim version
The Nim version is straightforward. It uses tables.CountTable
to count words, parseopt2.getopt
to parse command arguments, and sequtils.mapIt
for functional mapping operation. For regular expression, I chose pegs which the Nim documentation recommends over re.
The {.raises: [IOError].}
compile pragma at line 3 ensures the doWork
proc only raises IOError
exception. For this, I have to put input.findAll(peg"\w+")
inside a try
statement at line 21 to contain the exceptions it may theoretically raise.
Here is the code snippet from wordcount.nim:
1 proc doWork(inFilenames: seq[string] = nil,
2 outFilename: string = nil,
3 ignoreCase: bool = false) {.raises: [IOError].} =
4 # Open files
5 var
6 infiles: seq[File] = @[stdin]
7 outfile: File = stdout
8 if inFilenames != nil and inFilenames.len > 0:
9 infiles = inFilenames.mapIt(File, (proc (filename: string): File =
10 if not open(result, filename):
11 raise newException(IOError, "Failed to open file: " & filename)
12 )(it))
13 if outFilename != nil and outFilename.len > 0 and not open(outfile, outFilename, fmWrite):
14 raise newException(IOError, "Failed to open file: " & outFilename)
15
16 # Parse words
17 var counts = initCountTable[string]()
18 for infile in infiles:
19 for line in infile.lines:
20 let input = if ignoreCase: line.tolower() else: line
21 let words = try: input.findAll(peg"\w+") except: @[]
22 for word in words:
23 counts.inc(word)
24
25 # Write counts
26 var words = toSeq(counts.keys)
27 sort(words, cmp)
28 for word in words:
29 outfile.writeln(counts[word], '\t', word)
Rust version
To acquaint myself with Rust, I implemented a simple BTreeMap
struct akin to collections::BTreeMap
, but I ended up with using Rust’s collections::HashMap
for fair comparison with Nim. (The code is still left there for your reference.) The getopts crate is used to parse command arguments to my Config
struct. Other parts should be straightforward.
Here is the code snippet from my Rust wordcount project:
1 fn do_work(cfg: &config::Config) -> io::Result<()> {
2 // Open input and output files
3 let mut readers = Vec::with_capacity(std::cmp::max(1, cfg.input.len()));
4 if cfg.input.is_empty() {
5 readers.push(BufReader::new(Box::new(io::stdin()) as Box<Read>));
6 } else {
7 for name in &cfg.input {
8 let file = try!(File::open(name));
9 readers.push(BufReader::new(Box::new(file) as Box<Read>));
10 }
11 }
12 let mut writer = match cfg.output {
13 Some(ref name) => {
14 let file = try!(File::create(name));
15 Box::new(BufWriter::new(file)) as Box<Write>
16 }
17 None => { Box::new(io::stdout()) as Box<Write> }
18 };
19
20 // Parse words
21 let mut map = collections::HashMap::<String, u32>::new();
22 let re = regex!(r"\w+");
23 // let re = regex!(r"[a-zA-Z0-9_]+");
24 for reader in &mut readers {
25 for line in reader.lines() {
26 for caps in re.captures_iter(&line.unwrap()) {
27 if let Some(cap) = caps.at(0) {
28 let word = match cfg.ignore_case {
29 true => cap.to_ascii_lowercase(),
30 false => cap.to_string(),
31 };
32 match map.entry(word) {
33 Occupied(mut view) => { *view.get_mut() += 1; }
34 Vacant(view) => { view.insert(1); }
35 }
36 }
37 }
38 }
39 }
40 // Write counts
41 let mut words: Vec<&String> = map.keys().collect();
42 words.sort();
43 for &word in &words {
44 if let Some(count) = map.get(word) {
45 try!(writeln!(writer, "{}\t{}", count, word));
46 }
47 }
48 Ok(())
49 }
Zachary Dremann’s pull request suggested using find_iter
. I keep using captures_iter
for consistency with the Nim version, but did refine my code a bit.
Execution time comparison
I compiled the code with Nim’s -d:release
and Rust’s --release
flags. The sample 5MB input file was collected from Nim compiler’s C source files:
$ cat c_code/3_3/*.c > /tmp/input.txt
$ wc /tmp/input.txt
217898 593776 5503592 /tmp/input.txt
The command to run the Nim version is like this: (Rust’s is similar)
$ time ./wordcount -i -o:result.txt input.txt
Here is the result on my Mac mini with 2.3 GHz Intel Core i7 and 8 GB RAM: (1x = 0.88 seconds)
Rust | regex! \w | Regex \w | regex! […] | Regex […] | Nim | |
---|---|---|---|---|---|---|
release, -i | 1x | 1.30x | 0.44x | 1.14x | 0.75x | |
release | 1.07x | 1.33x | 0.50x | 1.24x | 0.73x | |
debug, -i | 12.65x | 20.14x | 8.77x | 19.42x | 3.51x | |
debug | 12.41x | 20.09x | 8.84x | 19.33x | 3.25x |
Some notes:
- Rust
regex!
runs faster thanRegex
, andr"[a-zA-Z0-9_]+"
faster thanr"\w+"
. All 4 combinations were tested. - The “debug” version is just for your reference.
- Nim only ran 1-2% slower with
--boundChecks:on
, so I didn’t include its result in this example.
Example 2: Conway’s Game of Life
This example runs Conway’s Game of Life on console with a fix-sized map and pattern. (To change the size or pattern, please edit the source code.) It uses ANSI CSI code to redraw the screen.
The output screen looks like:
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . (). (). . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . (). . . (). . . . . . . . . . .
. . . . . . . . . . . . . . (). . . . . . . (). . . . . . . . . . . . ()().
. . . . . . . . . . . . . ()()()(). . . . (). . . . (). . . . . . . . ()().
. ()(). . . . . . . . . ()(). (). (). . . . (). . . . . . . . . . . . . . .
. ()(). . . . . . . . ()()(). (). . (). . . (). . . (). . . . . . . . . . .
. . . . . . . . . . . . ()(). (). (). . . . . . (). (). . . . . . . . . . .
. . . . . . . . . . . . . ()()()(). . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . (). . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . (). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . (). (). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . ()(). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (). . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (). . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ()()(). . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
n = 300 Press ENTER to exit
The program uses another thread to read stdin
, and aborts the game when any line is read.
Nim version
Here is the code snippet from my Nim conway project:
1 type
2 Cell = bool
3 ConwayMap* = array[0.. <mapHeight, array[0.. <mapWidth, Cell]]
4
5 proc init*(map: var ConwayMap, pattern: openarray[string]) =
6 ## Initialise the map.
7 let
8 ix = min(mapWidth, max(@pattern.mapIt(int, it.len)))
9 iy = min(mapHeight, pattern.len)
10 dx = int((mapWidth - ix) / 2)
11 dy = int((mapHeight - iy) / 2)
12 for y in 0.. <iy:
13 for x in 0.. <ix:
14 if x < pattern[y].len and pattern[y][x] notin Whitespace:
15 map[y + dy][x + dx] = true
16
17 proc print*(map: ConwayMap) =
18 ## Display the map.
19 ansi.csi(AnsiOp.Clear)
20 ansi.csi(AnsiOp.CursorPos, 1, 1)
21 for row in map:
22 for cell in row:
23 let s = if cell: "()" else: ". "
24 stdout.write(s)
25 stdout.write("\n")
26
27 proc next*(map: var ConwayMap) =
28 ## Iterate to next state.
29 let oldmap = map
30 for i in 0.. <mapHeight:
31 for j in 0.. <mapWidth:
32 var nlive = 0
33 for i2 in max(i-1, 0)..min(i+1, mapHeight-1):
34 for j2 in max(j-1, 0)..min(j+1, mapWidth-1):
35 if oldmap[i2][j2] and (i2 != i or j2 != j): inc nlive
36 if map[i][j]: map[i][j] = nlive >= 2 and nlive <= 3
37 else: map[i][j] = nlive == 3
Rust version
Here is the code snippet from my Rust conway project:
1 type Cell = bool;
2
3 #[derive(Copy)]
4 pub struct Conway {
5 map: [[Cell; MAP_WIDTH]; MAP_HEIGHT],
6 }
7
8 impl Conway {
9 pub fn new() -> Conway {
10 Conway {
11 map: [[false; MAP_WIDTH]; MAP_HEIGHT],
12 }
13 }
14
15 pub fn init(&mut self, pattern: &[&str]) {
16 let h = pattern.len();
17 let h0 = (MAP_HEIGHT - h) / 2;
18 for i in 0..(h) {
19 let row = pattern[i];
20 let w = row.len();
21 let w0 = (MAP_WIDTH - w) / 2;
22 for (j, c) in row.chars().enumerate() {
23 self.map[i + h0][j + w0] = c == '1';
24 }
25 }
26 }
27
28 /// Iterate to next state. Return false if the state remains unchanged.
29 pub fn next(&mut self) -> bool {
30 let mut newmap = [[false; MAP_WIDTH]; MAP_HEIGHT];
31 for i in 0..(MAP_HEIGHT) {
32 for j in 0..(MAP_WIDTH) {
33 let mut nlive = 0;
34 for i2 in i.saturating_sub(1)..cmp::min(i+2, MAP_HEIGHT) {
35 for j2 in j.saturating_sub(1)..cmp::min(j+2, MAP_WIDTH) {
36 if self.map[i2][j2] && (i2 != i || j2 != j) {
37 nlive += 1;
38 }
39 }
40 }
41 newmap[i][j] = match (self.map[i][j], nlive) {
42 (true, 2) | (true, 3) => true,
43 (true, _) => false,
44 (false, 3) => true,
45 (false, _) => false,
46 };
47 }
48 }
49 // let changed = self.map != newmap;
50 let changed = true;
51 self.map = newmap;
52 changed
53 }
54 }
55
56 impl fmt::Display for Conway {
57 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
58 for row in self.map.iter() {
59 for cell in row.iter() {
60 try!(write!(f, "{}", if *cell { "()" } else { ". " }));
61 }
62 try!(write!(f, "\n"));
63 }
64 Ok(())
65 }
66 }
In line 49, I intended to determine if the new map has changed, but it turns out a simple comparison self.map != newmap
won’t work for array size > 32, unless you implement PartialEq
trait.
Note that using unsafe libc::exit
in my main.rs is very non-idiomatic in Rust. Zachary Dremann’s pull request elegantly avoided the glaring libc::exit
with select!
and a non-blocking timer receiver. You may want to take a look.
Execution time comparison
To measure the execution time, some code changes are needed:
- Comment out the sleep function in conway.nim and main.rs.
- Change the loop count from 300 to 30000.
- As the map redraw is time-consuming, the measurement is taken both (1) with and (2) without map print (i.e. comment out the map print lines in conway.nim and main.rs).
Here is the result with Nim’s -d:release
and Rust’s --release
flags:
Rust | Nim | Nim/bc:on | n=30000 | |
---|---|---|---|---|
(1) with map print | 1x | 1.75x | 1.87x | 1x=3.33s |
(2) without map print | 1x | 1.15x | 1.72x | 1x=0.78s |
(Since Rust does bounds checking, to be fair, I added a column “Nim/bc:on” for Nim binaries compiled with an additional --boundChecks:on
flag.)
Nim vs. Rust
Although Nim and Rust are both compiled languages aiming for good performance, they are two very different languages. Their limited similarities I can think up include:
- compiled and statically typed
- aiming for good performance (either could run faster depending on the underlying implementations, and further optimizations are probable)
- composition over inheritance (this seems a trend of new languages?)
- easy C binding
- popular language goodies: generics, closure, functional features, type inference, macro, statement as expression…
But their differences are more interesting.
Philosophy: freedom vs. discipline
Coding in Nim often gives me an illusion of scripting languages. It really blurs the line. Nim tries to remove the coding friction as much as possible, and writing in Nim is a great joy.
However, there is a flip side when leaning toward the writer’s freedom too much: the explicitness, clarity or even maintainability could be hampered. Let me give a small example: in Nim, import
will bring in all module symbols to your namespace. A symbol of a module can be qualified with module.symbol
syntax, or you can use from module import nil
to force qualification, but really, who will bother doing this? And this seems not “idiomatic Nim” anyway. The result is, you often cannot tell which symbol comes from which module when reading others’ (or your) code. (Fortunately, there won’t be naming collision bugs because Nim compiler forces you to qualify in such cases.)
Other examples include: UFCS that lets you use len(x)
, len x
, x.len()
or x.len
freely, whichever you like; underline- and case-insensitive, so mapWidth
, mapwidth
and map_width
all map to the same name (I’m glad they enabled the “partial case sensitivity” rule in 0.10.2, therefore Foo
and foo
at least differ); use of uninitialised values is OK; etc. In theory, you can follow strict style guidelines to mitigate the issue, but you tend to be more relaxed when coding in Nim.
On the other hand, Rust honors discipline. Its compiler is very rigid. Everything must be crystal clear. You have to get a lot of things right beforehand. Ambiguity is not an attribute of Rust code… Things like these are usually good for long-lived projects and maintainability, but coding in Rust may feel restrictive and force you to take care of some details you’re not interested of. You are also tempted into memory or performance optimisation even it’s not your priority. You tend to be more disciplined when coding in Rust.
Both have their pros and cons. As a coder, I enjoy coding in Nim more; as a maintainer, I would rather maintain products written in Rust.
Visual style: Python-ish vs. C++-ish
Like Python, Nim uses indentation to delimit blocks and has less sigils. Rust is more like C++. Those {}
, ::
, <>
and &
should be familiar to C++ programmers, plus some new ones like 'a
.
Occasionally, Nim can be too literal. For example, I think Rust’s match
syntax:
match key.cmp(&node.key) {
Less => return insert(&mut node.left, key, value),
Greater => return insert(&mut node.right, key, value),
Equal => node.value = value,
}
looks clearer than Nim’s case
statement:
case key
of "help", "h": echo usageString
of "ignore-case", "i": ignoreCase = true
of "o": outFilename = val
else: discard
But overall, Nim code is less visually noisy. Rust lifetime parameters are particularly cluttering IMO and, unfortunately, unlikely to change since it has passed 1.0 Alpha.
Memory management: GC vs. manual safety
Though Nim allows unsafe (untraced) memory management and provides soft realtime support with more predictable GC behaviour, it’s still a GC language, having all the pros and cons of GC. Nim’s object assignment is value copy. If you can live with GC, Nim memory management should be effortless to you.
Rust provides limited GC support, but more often than not, you’ll rely on Rust’s ownership system for memory management. The abstraction is so thin that you are basically managing the memory on your own. As a Rust programmer, you need to fully comprehend its memory management model (ownership, borrow and lifetimes) before you can code in Rust effectively, which is often the first barrier inflicting Rust newcomers.
On the other hand, it’s Rust’s most unique strength: safe memory management without GC. Rust solved this tough challenge beautifully. This along with resource safety, concurrent data safety and the elimination of null pointers makes Rust an extremely safe, reliable, low (or even no) runtime-overhead programming language.
Depending on your requirements, Nim’s GC can be good enough, or Rust is your only sensible choice.
Other differences
Nim’s strengths:
- Productivity: within the same unit of time, you can crank out more features in Nim
- Ease of learning
- Scripting in a compiled language, good for prototyping, interactive exploration, batch processing etc.
- Language features:
- method overloading
- new operator definition
- named arguments and default values
- powerful compile-time coding
Rust’s strengths:
- A true systems programming language: embeddable, GC-free and bare metal
- Safety, correctness, runtime reliability
- Strong core team and vibrant community
- Language features:
- pattern matching (excellent!)
- enum variants (though Nim’s object variants are good, too)
let mut
instead ofvar
(small thing but matters)- powerful destructuring syntax
Error handling: Nim opts for the common exception mechanism while Rust uses a flexible Result
type (and panic!
). I have no strong preference here, but consider it an important difference worth mentioning.
Release 1.0 is on the way
Both Nim and Rust target 1.0 release at the first half of this year. That’s very exciting! Rust has gained a lot of attention and Nim is becoming better-known, too. Their flavors are very different, but both are great new programming languages. Rust shines when safety, performance or reliability matters. Nim is nimble, expressive, blending the strengths of scripting languages and compiled languages well. They should be great additions to your language selection.
Hope this article has given you a glimpse on them.