Artikelen · Experiment
Eén krabbel, tien talen
Dezelfde tekenapp, tien keer gebouwd. De kleinste is 383 bytes, de grootste zeven megabyte.
Wat ik me afvroeg
Wat kun je eigenlijk met WebAssembly?
Ik wist dat Figma er veel mee doet. Ik wist ook dat je bestaande code in C++ of Rust er vrij makkelijk naar kunt omzetten. En ik zag steeds vaker dat code wordt herschreven in Rust, omdat dat veiliger zou zijn dan C++.
Dat wilde ik zelf zien. Maar Figma nabouwen is voor een experiment veel te groot. Dus greep ik terug op iets wat ik al kende.
Een krabbel uit 2005
In mei 2005 maakte ik een variant op de klassieke krabbel: je tekent een lijn, en die lijn vervaagt achter je aan. Alleen het laatste stuk blijft staan.
Dit is de code van toen, in C#:
private const int HIS = 40;
private Point[] _p = new Point[HIS];
private int _t;
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (fMouseDown)
{
if (_t<HIS-1)
_t++;
else
_t = 0;
_p[_t].X = e.X;
_p[_t].Y = e.Y;
this.Refresh();
}
}
Veertig plaatsen, en een teller die bij het einde weer vooraan begint. Het nieuwste punt schrijft over het oudste heen. Bij het tekenen loop je terug vanaf de teller, en elk stuk wordt iets lichter:
myColor = Color.FromArgb((255-(i*6)), Color.Black);
Dat heet een ringbuffer. Dat wist ik toen niet.
Wat ik deed
Het was ook een experiment met AI. Vroeger had ik tutorials doorgewerkt en alles zelf moeten ontdekken. Nu vroeg ik om een werkende opzet, en die stond er in één keer: de krabbel uit 2005, in Rust, in de browser.
In dat gesprek kwam ook het voorstel om het in andere talen te proberen. Zo werden het er tien:
- in Rust, de eerste;
- in C, Zig, Go en AssemblyScript, elk gecompileerd naar WebAssembly;
- met de hand, in WebAssembly-tekst, instructie voor instructie;
- in C#, met de .NET-runtime in de browser;
- in TypeScript, zonder WebAssembly, om mee te vergelijken;
- en twee keer buiten de browser: één desktopapp die op elk platform draait (C# met Avalonia) en één Windows-programma dat zo dicht mogelijk op het systeem zit (C++ met Win32).
De afspraak is voor elke taal dezelfde. Onthoud de laatste honderd punten van de pen. Komt er een punt bij, dan valt het oudste eraf. En hoe ouder een stuk van de lijn, hoe verder het vervaagt.
Dat zijn acht kleine functies: een punt erbij, alles wissen, en zes om de punten weer uit te lezen. De pagina tekent, de taal onthoudt. Daardoor kun je in het tekenvlak van taal wisselen terwijl je krabbel blijft staan: de punten verhuizen gewoon mee.
Wat me verbaasde
Hetzelfde, maar achttienduizend keer zo groot
De grootte van wat de browser moet ophalen, onverpakt. De schaal is logaritmisch: elke stap naar rechts is tien keer zo veel.
De handgeschreven versie is 383 bytes. Dat is ongeveer zo veel als zestig woorden tekst. De versie in C# doet precies hetzelfde en is ruim zeven megabyte.
Het verschil is wat er meereist
- WebAssembly-tekst, C en Zig nemen niets mee. Alleen de acht functies en een stukje geheugen.
- AssemblyScript neemt een klein stukje runtime mee: 4,4 kB.
- Rust is hier 47 kB, maar dat is geen eerlijke vergelijking. In die eerste variant doet Rust alles zelf, ook luisteren naar de pen en tekenen. Elke aanroep naar de browser kost lijm.
- Go neemt zijn hele runtime mee: 1,4 MB, voor honderd punten.
- C# neemt .NET mee. Van de zeven megabyte is 8 kB mijn eigen code.
Voor een krabbel schieten C# en Go hun doel voorbij. Ze brengen een heel huis mee om één lijn te onthouden.
De taal bepaalt niet hoe groot je programma is. Wat je ongemerkt meeneemt, bepaalt dat.
De grens oversteken kost ook iets
Elke keer dat de pagina een punt doorgeeft, steekt ze de grens over tussen JavaScript en WebAssembly. Hoe duur dat is, verschilt per taal. Druk bij het tekenvlak op meet: dan gaan er honderdduizend punten doorheen, op jouw apparaat. Wissel daarna van taal en meet opnieuw.
Op mijn computer duurt dat in de meeste talen een paar milliseconden. In Go ruim een seconde: daar loopt elke aanroep via een omweg door JavaScript.
Het dichtst op de machine leest het moeilijkst
Het Windows-programma in C++ zou het snelst moeten zijn: er zit niets tussen de code en het systeem. Dat heb ik niet gemeten. Wat ik wel zag: het is de variant die ik het minst makkelijk lees.
De versie in C is 62 regels. De versie voor Windows is er 317. Het meeste daarvan gaat niet over de krabbel, maar over de afspraken van het systeem: vensters aanmaken, berichten afhandelen, namen als LRESULT CALLBACK en GET_X_LPARAM.
In één keer een werkende opzet
In 2005 kostte de krabbel me een paar avonden, in één taal. Nu stonden er in een paar dagen tien varianten. Niet omdat ik tien talen ken, maar omdat ik wist wat het moest doen en dat kon uitleggen.
Dat verandert wat een experiment is. Vergelijken kost bijna niets meer. De vraag "hoe zou dit in Zig gaan?" hoef je niet meer te laten liggen.
Hoe het werkt
Dit is de hele functie die een punt toevoegt, in Zig:
export fn push(x: f64, y: f64, connected: i32) void {
const h: usize = @intCast(head);
xs[h] = x;
ys[h] = y;
conn[h] = if (connected != 0) 1 else 0;
head = @mod(head + 1, MAX_POINTS);
if (count < MAX_POINTS) count += 1;
}
En dezelfde functie in C:
void push(double x, double y, int connected) {
xs[head] = x;
ys[head] = y;
conn[head] = connected ? 1 : 0;
head = (head + 1) % MAX_POINTS;
if (count < MAX_POINTS) count++;
}
Leg ze naast de code uit 2005. Het is hetzelfde idee: een rij met vaste plaatsen, en een wijzer die rondloopt. Is de rij vol, dan schrijft het nieuwe punt over het oudste heen.
De browser krijgt een bestand met deze functies, zet het klaar in een afgesloten stukje geheugen en roept ze aan alsof het gewone JavaScript is.
De code
De code komt openbaar op GitHub. Binnenkort.
Gemaakt met hulp van AI. De tien varianten, het voorbeeld op deze pagina en de tekst zijn met een AI-assistent gemaakt. De krabbel uit 2005, het idee, de vragen en de keuzes zijn van mij.