Zig and WASM: your best friend here is the compiler
WASM is coming... everywhere!
Some days ago, MJ Grzymek published an interesting piece on his blog about how Zig can be compiled in WASM and used for efficient web development. It reminded me I’ve wanted to write about Zig and WASM for months.
Not because I’m in love with this language, I find it messy (and I’m not a low level guy). But its compiler is dope! Notably, it allows you to compile C/C++ code to WASM/WASI. And that could be a game changer!
Zig compiler can compile C/C++ too
Once installed, zig help command will show you this message:
info: Usage: zig [command] [options]
Commands:
build Build project from build.zig
init-exe Initialize a `zig build` application in the cwd
init-lib Initialize a `zig build` library in the cwd
ast-check Look for simple compile errors in any set of files
build-exe Create executable from source or object files
build-lib Create library from source or object files
build-obj Create object from source or object files
fmt Reformat Zig source into canonical form
run Create executable and run immediately
test Create and run a test build
translate-c Convert C code to Zig code
ar Use Zig as a drop-in archiver
cc Use Zig as a drop-in C compiler
c++ Use Zig as a drop-in C++ compiler
...As you can see here, zig can be used as a drop-in C/C++ compiler.
For example, you can create a guess.c file:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
int number, guess, attempts = 0;
srand(time(NULL));
number = rand() % 421 + 1;
printf("Guess a number between 1 and 421\n");
do {
scanf("%d", &guess);
attempts++;
if (guess > number) {
printf("Lower number please!\n");
} else if (guess < number) {
printf("Higher number please!\n");
} else {
printf("You guessed it in %d attempts\n", attempts);
}
} while (guess != number);
return 0;
}Then compile it with zig:
zig cc guess.c -o guess
./guessAnd it works! You can do the same with a C++ code in an analyze.cpp file:
#include <string>
#include <sstream>
#include <iostream>
int main() {
std::string line;
int lineCount = 0, charCount = 0, charNoSpacesCount = 0, wordCount = 0;
while (std::getline(std::cin, line)) {
lineCount++;
charCount += line.length();
for (char c : line) {
if (c != ' ') {
charNoSpacesCount++;
}
}
std::stringstream ss(line);
std::string word;
while (ss >> word) {
wordCount++;
}
}
// Display results, formatted as JSON
std::cout << "{" << std::endl;
std::cout << " \"Line count\": " << lineCount << "," << std::endl;
std::cout << " \"Character count (including spaces)\": " << charCount << "," << std::endl;
std::cout << " \"Character count (excluding spaces)\": " << charNoSpacesCount << "," << std::endl;
std::cout << " \"Word count\": " << wordCount << std::endl;
std::cout << "}" << std::endl;
return 0;
}Compile it with zig and run it:
zig c++ analyze.cpp -o analyze
./analyze < analyze.cpp | jqIt will show you the number of lines, characters, words, JSON formatted.
Compile (some of) your C/C++ code to WASM/WASI
But the zig compiler also has a -target option, which can be used to compile previous C/C++ code to WASM/WASI with no changes.
Thus, it can run on any platform with a WASM runtime, such as Wasmtime:
zig cc -target wasm32-wasi guess.c -o guess.wasm
zig c++ -target wasm32-wasi analyze.cpp -o analyze.wasm
wasmtime guess.wasm
wasmtime analyze.wasm < analyze.cpp | jqNote there are some limitations, as I wasn’t able to manipulate files. It’s why I relied on stdin in the previous example. So, the following C++ code compiles and runs, but not with the wasm32-wasi target:
#include <iostream>
#include <fstream>
int main(int argc, char* argv[]) {
std::ifstream file(argv[1]);
if(!file.is_open()) {
std::cerr << "Error: could not open file\n";
return 1;
}
std::string line;
while(std::getline(file, line)) {
std::cout << line << '\n';
}
file.close();
return 0;
}The following C code compiles in WASM, but doesn’t run, I get a could not open file error:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[]) {
FILE *file = fopen(argv[1], "r");
if(file == NULL) {
fprintf(stderr, "Error: could not open file\n");
return 1;
}
char *line = NULL;
size_t len = 0;
ssize_t read;
while((read = getline(&line, &len, file)) != -1) {
printf("%s", line);
}
free(line);
fclose(file);
return 0;
}As WASM/WASI (preview2 was recently announced) and zig compiler evolves, be sure it will be possible to do more and more things.
What about V?
I couldn’t end without trying to compile V code to WASM through zig. For the record, V compiler supports multiple backends (c, go, js, js_browser, js_node, js_freestanding) and wasm. Thus, the following hello.v file:
fn main() {
println('Hello, WASM!')
}Can be compiled to WASM:
v -backend wasm hello.v -o hello.wasm
wasmtime hello.wasmBut this doesn’t work with modules. For example, this analyze.v file:
import os
import json { encode }
struct Stats {
mut:
line_count int
char_count int // Including spaces
char_no_spaces_count int // Excluding spaces
word_count int
}
fn main() {
mut stats := Stats{}
for line in os.get_lines() {
stats.line_count++
stats.char_count += line.len
stats.char_no_spaces_count += line.replace(' ', '').len
words := line.split(' ').filter(it != '')
stats.word_count += words.len
}
// Serialize `stats` to JSON
json_str := encode(stats)
println(json_str)
}Compiles well in V, but not with a wasm backend. I tried with the c backend and then with zig cc… it leads to errors.
Go: the good balance for WASM/WASI?
What’s important here, is to see how WASM/WASI is gaining traction in multiple languages. Rust supports it as a target for a long time, there are movements from languages such as OCaml, Python, Ruby, etc.
Since last summer, and the 1.21 release, Go compiler natively supports WASM/WASI backend. And it works pretty well, it’s my personal choice for multiples WASM projects, easy to bootstrap.
For example, this analyze.go file:
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
)
type Statistics struct {
LineCount int `json:"Line count"`
CharacterCount int `json:"Character count (including spaces)"`
CharacterCountNoSpace int `json:"Character count (excluding spaces)"`
WordCount int `json:"Word count"`
}
func main() {
scanner := bufio.NewScanner(os.Stdin)
var stats Statistics
for scanner.Scan() {
line := scanner.Text()
stats.LineCount++
stats.CharacterCount += len(line)
stats.CharacterCountNoSpace += len(strings.ReplaceAll(line, " ", ""))
stats.WordCount += len(strings.Fields(line))
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "reading standard input: %v", err)
}
jsonData, err := json.MarshalIndent(stats, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "error marshalling stats to JSON: %v", err)
return
}
fmt.Println(string(jsonData))
}Compiles and runs well in WASM:
GOOS=wasip1 GOARCH=wasm go build -o analyze.wasm analyze.go
wasmtime analyze.wasm < analyze.go | jqNote that the built WASM file is 2.5MB, compared to 3.3/3.4MB with zig from C/C++. The binary was 48K in native C++, 177K in native V with JSON module.