Simple is niet klein
Hebben we eenvoud nodig?
Aan het einde vroeg mijn vriend Predrag: "Hoe zou je ons adviseren na te denken over het bouwen van tools, zodat dit soort epische debugging-verhalen minder vaak voorkomen?"
Mijn antwoord was: "We moeten prioriteit geven aan eenvoud."
Als je terugkijkt naar mijn coverage-pipeline, zie je veel knooppunten in het diagram. De tooling is ingewikkeld. We moeten heroverwegen hoe onze computing werkt.
Ik ben niet tevreden met dat antwoord.
Unix-pipelines zijn niet eenvoudig
Stel je twee programma's voor die de frequentie van woorden in een bestand berekenen. Eerst een kleine Unix-pipeline:
cat README.md \
| tr --complement --squeeze-repeats '[:alpha:]' '\n' \
| tr A-Z a-z \
| sort \
| uniq --count \
| sort --reverse --numeric-sort
Dit zegt: "lees README.md, vertaal elke woordgrenze naar een nieuwe regel (met samenvoegen van meerdere nieuwe regels), zet hoofdletters om naar kleine letters, tel het aantal voorkomens van elk woord en toon ze vervolgens in volgorde van frequentie."
Ik denk dat dit is waar de meeste mensen aan denken wanneer ze aan "eenvoudig" denken: elk programma is klein, ze zijn ontworpen om ad-hoc op deze manier aan elkaar gekoppeld te worden, het is beknopt en relatief makkelijk te lezen.
Beschouw vervolgens een Clojure-programma:
(->> (slurp "README.md")
(re-seq #"[a-zA-Z]+")
(map str/lower-case)
frequencies
(sort-by val >)
; voor elk (woord, aantal) paar in de sequentie, roep een anonieme functie aan die dit print.
(run! (fn [[word count]] (println count word))))
Dit doet hetzelfde, met een paar extra namen en hogere-orde functies.
Stel nu dat we een kleine wijziging willen aanbrengen: we willen de output in de oorspronkelijke volgorde van het bestand tonen. In Clojure is dit vrij eenvoudig: bewaar een geordende sequentie van de woorden in wordseq, bewaar een map van elk woord naar de frequentie in freqmap, itereer over de sequentie en zoek elk woord op in de map.
(let [word_seq (->> (slurp "README.md")
(re-seq #"[a-zA-Z]+")
(map str/lower-case))
freq_map (frequencies word_seq)]
(->> (distinct word_seq)
; voor elk uniek woord, in oorspronkelijke volgorde, print de frequentie (uit onze `freq` map) en het woord zelf
(run! (fn [w] (println (freq_map w) w)))))
In Bash heb je hiervoor een hele reeks tijdelijke bestanden en lelijke, ondoorzichtige regexes, sorts en joins nodig:
tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
| grep . > words
sort words \
| uniq --count \
| sed --regexp-extended 's/^ *([0-9]+) (.*)/\2 \1/' \
| sort > counts
nl --body-numbering=a words \
| sort --key=2,2 --key=1,1n \
| uniq --skip-fields=1 \
| sort --key=2,2 > firstseen
join -1 2 -2 1 -o 1.1,2.2,1.2 firstseen counts \
| sort --numeric-sort \
| cut --delimiter=' ' --field=2,3
Dat komt omdat ons oorspronkelijke programma weliswaar klein was, maar niet eenvoudig.
Wat is eenvoud?
In Simple Made Easy definieert Rich Hickey "eenvoudig" (simple) vanuit de wortel "sim-plex": het hebben van slechts één vlecht. Hij stelt dit tegenover "com-plex": het samenvechten van meerdere dingen.
In dit artikel zal ik "gekoppeld" (coupled) gebruiken als synoniem voor "complex" om ambiguïteit te voorkomen. Dat geeft ons een taal om te beschrijven wat er aan de hand is met onze eerste Unix-pipeline: hij is klein, maar hij is gekoppeld.
Laten we kijken naar wat dat precies betekent:
tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
| tr A-Z a-z \
| sort \
| uniq --count \
| sort --reverse --numeric-sort
Er zijn veel details waar ik over zou kunnen vallen, maar het belangrijkste onderdeel dat gekoppeld (samengevlochten) is, is sort | uniq --count.
Als we in de man-pagina van uniq kijken, staat er dit: Repeated lines in the input will not be detected if they are not adjacent, so it may be necessary to sort the files first.
Er is geen native Unix-equivalent voor frequencies; deze sort | uniq -c is het dichtste benadering die we kunnen vinden. Niet alleen is dit minder performant (het moet de volledige input in het geheugen laden voordat het verder kan), maar het koppelt aggregatie aan ordening.
Dit is precies wat het "scheiden van ordening en aggregatie" zo moeilijk maakt; we eindigen met een vreemde dans van table-joins via tekstbestanden.
Je hebt misschien de zin "Schrijf programma's die één ding doen en dat goed doen" gehoord in referentie naar Unix-systemen. Misschien heb je het de Unix-filosofie genoemd. Ik denk dat "één ding doen" vaak wordt begrepen als eenvoud, maar in de praktijk gaat het eigenlijk over grootte. Unix-tools zijn klein, maar ze zijn niet eenvoudig.
Groot is niet hetzelfde als gekoppeld
Laten we nu naar de andere kant kijken. Stel dat je Google Drive voor Desktop op je computer hebt draaien. Dit is een massaal groot programma: het is afhankelijk van platformspecifieke file watchers, "al het Google3", een streaming- en syncing-netwerkclient en conflict-resolutie-logica.
Maar voor de gebruiker voelt het vrij eenvoudig: installeer het programma, geef aan welke map moet worden bewaakt en bepaal of de bestanden lokaal of primair op de infrastructuur van Google moeten blijven. De rest wordt door het programma afgehandeld.
Ontkoppeling
Wanneer ik denk aan complexe programma's, denk ik aan koppeling. Programma's zijn complex wanneer verschillende functies aan elkaar gekoppeld zijn, zelfs als dat niet nodig is.
Neem een klein voorbeeld. In Rust kun je namen aan waarden koppelen met een map, of met een struct:
struct HttpResponse {
status: u16,
}
let strukt = HttpResponse { status: 200 };
let mut map = HashMap::new();
map.insert("status", 200);
println!("map: {}", map.get("status").unwrap());
println!("struct: {}", strukt.status);
Het is duidelijk dat een struct je bekende, aanwezige velden geeft. Bij de map moeten we unwrap() aanroepen, omdat de typechecker niet weet welke sleutels er in een map zitten. Bij de struct weet hij dat wel, dus kunnen we de waarde direct benaderen.
Wat hier misschien niet duidelijk is, is dat een struct runtime-informatie verliest. Als je een map wilt itereren, is dat eenvoudig: for (key, val) in map { ... }. Als je een struct wilt itereren... heb je een probleem, of je moet een proc-macro schrijven.
De reden hiervoor is dat een struct in Rust de typecontrole koppelt aan een vaste datarepresentatie. Je kunt het een niet hebben zonder het ander.
Contrastreer dit met Clojure, waar dat wel kan. In Clojure zijn structs maps: in plaats van een type te definiëren, annoteer je welke velden een map mag hebben. Als we onze HttpResponse struct wilden vertalen, zouden we dit kunnen schrijven:
; bind de naam `http-response` aan een lijst van keywords (interned strings).
; dit is een normale lijst die tijdens runtime wordt gemaakt en gemanipuleerd.
(def http-response [:map [:status :int]])
; bind de naam `print-resp` aan een functie.
; `^{}` is een "metadata" map die aan die naam wordt gekoppeld.
; metadata op bindingen kan tijdens runtime worden opgehaald.
(defn ^{:malli/schema [:=> [:cat http-response] :nil]}
print-resp [map]
(println "status:" (:status map)))
Hier hebben we een type-annotatie gemaakt die tijdens runtime wordt gecontroleerd met de functie (malli/instrument!). Opvallend is dat dit wordt gecontroleerd door een bibliotheek (Malli), niet door een compiler; en de annotatie is inspecteerbaar. Je kunt bijvoorbeeld een schema->md functie schrijven die fungeert als je eigen mini-Rustdoc, zonder dat je hoeft te integreren met compiler-API's.
Dit alles werkt zonder in te leveren op typeveiligheid, reflectie of het kunnen itereren over de waarden van de map. Dit werkt omdat Clojure datarepresentaties ontkoppelt van typecontrole. Typed Racket doet een soortgelijk trucje, maar gebruikt macros zodat de typecontrole tijdens het compileren gebeurt in plaats van tijdens runtime.
Wanneer is het nuttig om klein te zijn?
Klein zijn is zinvol wanneer jij als onderhoudende partij niet veel middelen aan je programma kunt besteden. Misschien ben je Brian Kernighan en draait je programma op een letterlijke PDP-11. Misschien ben je een open-source maintainer met slechts een paar uur per maand voor je project. Misschien werk je in een omgeving waar elke kleine overwinning telt en je alleen steun krijgt voor een klein subset van de functies die je eigenlijk wilt bouwen.
Dit zijn allemaal goede redenen om je programma klein te houden. Maar klein is niet hetzelfde als eenvoudig.
Het antwoord op de vraag "wanneer moet je programma eenvoudig zijn?" is: altijd. Er is zeer weinig voordeel aan het introduceren van koppelingen in delen van je programma; het maakt het voor jou als ontwikkelaar moeilijker om het programma te onderhouden en het is minder flexibel voor je gebruikers.
Hoe maken we eenvoudige programma's?
Dit is het moeilijke gedeelte. Om eenvoudige programma's te schrijven, heb je een goed mentaal model van je programma nodig. Je hebt ook "goede smaak" nodig, iets waarvan ik nog niet weet hoe ik het moet aanleren. Soms moet je simpelweg de discipline hebben om het moeilijke werk te doen (Suck It Up And Write The Hard Thing).
CSS en SQL zijn sterk ontkoppeld (grotendeels): je schrijft een declaratieve specificatie van wat je wilt dat het programma doet, en de browser-engine of database-runtime zoekt uit hoe dat moet gebeuren.
Dit is ontzettend moeilijk! SQLite alleen al heeft eeuwen aan mensen-jaren gekost om betrouwbaar te werken. Blink (de renderer van Chrome) heeft waarschijnlijk tienduizenden mensen-jaren gekost. In sommige domeinen is dat wat er nodig is om jou in staat te stellen programma's te schrijven die ontkoppeld zijn.
Het is niet altijd zinvol om zoveel tijd in een programma te steken. Complex technisch werk kan een "mottenlamp-probleem" zijn: het trekt een bepaald type persoon aan die ervan houdt om te dromen over hoe code zou kunnen, zou moeten of zou kunnen werken. Soms is het beter om je gereedschap neer te leggen en een dutje in de zon te doen.
Maar wanneer het werkt— Als we teruggaan naar het begin van het artikel: de coverage-pipeline die ik beschreef werd na de fix eigenlijk groter, niet kleiner. Maar tegelijkertijd werd hij eenvoudiger, omdat er minder verborgen afhankelijkheden waren tussen delen van de dataflow-graph.
Wat nu?
Ik hoop dat dit artikel je aanmoedigt om programma's te schrijven die eenvoudig zijn, niet klein, en om te zoeken naar tools die je gebruikt die onnodig gekoppeld zijn.
In een toekomstig artikel hoop ik deze ideeën uit te breiden:
- hoe je je gevoel voor "smaak" ontwikkelt;
- hoe programma's verticaal geïntegreerd kunnen zijn terwijl ze toch ontkoppeld blijven;
- en hoe je grote systemen bouwt zonder ze complex te maken.
Groetjes,