Wat ik als Javaan van Rust heb geleerd

“Once you stop learning, you start dying.” Met deze wijze woorden van Albert Einstein in mijn hoofd besloot ik om een nieuwe programmeertaal op te pakken. Ik ben ooit begonnen met Visual Basic gevolgd door Python, waarna ik in een professionele omgeving Java, JavaScript en een beetje Go heb kunnen toepassen.

Door: Pim Otte

Omdat ik graag wat meer low-level talen wilde proberen, was ik al een keer aan C++ begonnen. Ik was er echter nooit door gegrepen. Doordat Rust zich onder andere als alternatief voor C++ positioneert, leek mij dat een geschikte taal om te proberen. In dit artikel beschrijf ik wat mij het meest is opgevallen in mijn reis door Rust, en hoe dat mij als Javaan heeft beïnvloed.

Het feit dat ik Rust heb leren kennen, heeft in grote lijnen twee gevolgen gehad. Het eerste is dat ik een nieuwe waardering heb voor het gebruik van onveranderlijkheid en invarianten in code. Het tweede gevolg is dat ik extra uitkijk naar en al geniet van een aantal features die met name in de context van Project Amber aan de Java-taal worden toegevoegd.

Geheugengebruik

Rust als taal heeft als doel om programmeurs in staat te stellen om efficiënte en betrouwbare software te schrijven. Het meest kenmerkende is de manier waarop Rust correct gebruik van geheugen afdwingt. Waar talen als Java vertrouwen op een garbage collector om het geheugengebruik netjes bij te houden en vrij te geven wanneer het niet meer gebruikt wordt, en talen als C vertrouwen op de programmeur om handmatig (de)allocaties te doen, is het bij Rust de compiler die problemen als memory leaks voorkomt. Als de compiler niet kan bewijzen dat een stuk code correct gebruikmaakt van het geheugen, dan volgt een compilatiefout. De programmeur kan nog wel de compiler de pas afsnijden door het unsafe keyword te gebruiken, maar dit middel wordt doorgaans alleen ingezet als het echt nodig is. Soms kan de compiler namelijk niet aantonen dat een stuk code geldig is, maar kan de programmeur dat wel.

In feite voorkomt de compiler dat de poten onder een stoel weggezaagd worden terwijl er nog iemand op zit. Zolang er alleen mensen op de stoel willen zitten, doet de compiler niks. Zodra er iemand met een zaag aankomt, gaat de compiler controleren of er niemand meer op de stoel kan zitten. Pas als dat met zekerheid gezegd kan worden, mag er aan de stoel geklust worden. Op de stoel zitten komt overeen met leestoegang tot een stuk geheugen; aan de stoel zagen is schrijftoegang tot datzelfde stuk.

Onveranderlijkheid

Ondanks het strenge toezicht van de compiler blijkt dat het nog prima mogelijk is om code te schrijven. De grootste hulp daarbij is onveranderlijkheid. Zonder verdere instructie zijn alle data die door een stuk Rust-code gebruikt worden onveranderlijk, iets wat in Java het final keyword vereist. Op het moment dat de programmeur iets wil muteren, moet dat altijd expliciet aangegeven worden met het mut keyword, waar mut staat voor “mutable”. Dit maakt het ook meteen makkelijker om de code te lezen. De programmeur weet immers ook dat als een stuk data onveranderlijk is, er dan op geen enkele plek wijzigingen aangebracht kunnen worden. Naast het effect op het veilig gebruik van geheugen, blijkt de bescherming die de compiler biedt ook nut te hebben bij concurrency. Ook daar ontstaan problemen doordat data gewijzigd worden op een onverwacht moment. Wederom brengt de door de compiler afgedwongen onveranderlijkheid dan een zekerheid die steun biedt aan de programmeur. Rust formaliseert het delen van data tussen verschillende threads en kan daarmee sommige typen van race conditions (data races) voorkomen.

Toen ik begon met Rust was juist deze onveranderlijkheid iets waar ik mee worstelde. Enerzijds kwam de error cannot borrow `variable` as mutable, as it is not declared as mutable regelmatig terug, wat in eerste instantie tot frustratie heeft geleid. Echter, na meer ervaring begon ik van tevoren na te denken over of ik een variabele zou willen veranderen, en zo ja, dan gaf ik dat aan. Anderzijds kwam ik variable does not need to be mutable als error tegen, terwijl ik dacht dat ik deze wel aanpaste. Inderdaad bleek dat ik een paar regels later een typfoutje had gemaakt. Merk op dat voor Java IntelliSense ook een heel eind komt in het signaleren van dit soort problemen. Naar mijn mening zit de echte winst van dit besef van veranderlijkheid in dat het tot een ‘nieuw normaal’ leidt: onveranderlijk, tenzij anders aangegeven. Dit maakt ook dat ik in Java eerder mijn Lombok @Data annotatie splits, en expliciet alleen setters genereer als een field ook daadwerkelijk aangepast zou moeten worden na het instantiëren van het object.

Pattern matching

Rust heeft het match keyword, wat de mogelijkheid biedt tot pattern matching. Pattern matching lijkt in de basis op een switch-case constructie. Het patroon waartegen gematcht wordt komt overeen met datgene waarover geswitcht wordt. De verschillende patronen die getest worden komen overeen met de cases. Neem bijvoorbeeld de code in Listing 1. Als we hier het geval van het dubbeltje weglaten, zal de compiler ons er met een foutmelding op attenderen dat we een geval missen. Door een tweetal andere mogelijkheden van Rust worden match-expressies nog krachtiger. Ten eerste is het mogelijk om data op te slaan in de varianten van een enum. Ten tweede kunnen die data weer uitgepakt worden met behulp van destructuring. In Listing 2 definiëren we een enum die een optional integer representeert: ofwel de waarde is Some en in dat geval is er ook een bijbehorende integer, ofwel de waarde is None. Daarna definiëren we een functie die pattern matching gebruikt om te bepalen of er al dan niet een getal in de optional zit, en vervolgens 1 toevoegt als dat zo is. Om dit te doen wordt de integer in het Some(i) geval daadwerkelijk uitgepakt, en is deze benaderbaar onder de naam i.

Listing 1:

enum Munt { Cent, Dubbeltje, Kwartje, Gulden  ,}
fn waarde_in_cent(munt: Munt) -> u8 {
    match munt {
           Munt ::Cent => 1,
 Munt::Dubbeltje => 10,
  Munt    ::Kwartje => 25,
      Munt  ::Gulden => 100,
   }
}

Listing 2:

enum OptionalInt { Some(i64), None }
fn plus_een(x: OptionalInt) -> OptionalInt {
    match x {
        None => None,  
     Some(i) => Some(i + 1),
    }
}

Project Amber

Deze voorbeelden lichten maar een tipje van de sluier op van wat er allemaal mogelijk wordt met pattern matching. Gelukkig is er voor de Java-taal goed nieuws. Pattern matching is een van de speerpunten van Project Amber (1). Project Amber legt de focus op kleinere features die de productiviteit van de programmeur verhogen. Een voorbeeld hiervan is dat de match-expressie zoals die in Rust bestaat, beetje bij beetje wordt geïntroduceerd. De mogelijkheid om vanuit een switch-case een waarde terug te geven, is geïntroduceerd als preview in JDK 12 in de vorm van switch expressions met JEP 325 (2), en is vervolgens nog iets verfijnd met JEP 354 (3). Pattern matching voor instanceof bestaat in JDK 14 door JEP 305, die het als preview introduceert. Voor uitbreiding van pattern matching bestaan er ook plannen (4) die onder andere de mogelijkheden voor deconstruction beschrijven. Ten slotte worden de mogelijkheden van de enums van Rust ook beschikbaar gesteld met behulp van records en sealed types (5). Records zijn als preview in JDK 14 beschikbaar met JEP 359. Sealed classes zijn nog onderweg.

De bovenstaande verzameling aan previews geeft aan dat er al volop gewerkt wordt om pattern matching naar Java te brengen, en dat het team dat aan Project Amber werkt, goede stappen maakt. Ik heb ook niet het idee dat er in Rust mogelijkheden bestaan op dit gebied die voor Java niet in (voor)ontwikkeling zijn. Ik zie het feit dat Java deze previews heeft ook als een van de vruchten die afgeworpen is door de snellere releases. Hierdoor kan feedback van developers meegenomen worden door middel van preview features, en dit heeft dus daadwerkelijk geleid tot het bijschaven van de switch-expression feature. Je merkt hier ook de invloed van functionele talen zoals Scala, waar pattern matching al lang een veelgebruikte functionaliteit is. Overigens is het concept van onveranderlijkheid ook in het geval van functionele talen iets wat erg gewaardeerd wordt.

Developer-vriendelijke foutmeldingen

In Rust is de compiler streng: er wordt weinig toegelaten. De compiler kan daarentegen ook behulpzaam zijn: als er een error optreedt, volgt er doorgaans ook een goede hint over wat er zou moeten gebeuren. Zoals in Listing 3, volgt soms een stukje “help” met een suggestie hoe het te corrigeren, en een commando om meer uitleg te verkrijgen over dit type error. Het fijne hieraan is dat je vaak snel compilatiefouten kunt corrigeren. Het nadeel is dat het verleidelijk is om op automatische piloot de suggesties op te volgen. Voor een ontwikkelaar die net met Rust is begonnen, valt het aan te raden om het Rust-boek (6) erbij te houden. Als we kijken naar Java zou je kunnen vermoeden dat de foutmeldingen al duidelijk genoeg zijn, maar uit JEP 358 (7) blijkt dat er nog steeds verbeteringen doorgevoerd worden. In deze JEP zijn de meldingen bij NullPointerExceptions verbeterd.

Listing 3:

mismatched types
expected reference, found struct `std::rc::Rc`
note: expected type `&std::rc::Rc<std::cell::RefCell<parser::Letter>>`
         found type `std::rc::Rc<std::cell::RefCell<parser::Letter>>`
help: consider borrowing here: `&sequence`rustc(E0308)
parser.rs(52, 30): expected reference, found struct `std::rc::Rc`
For more information about this error, try `rustc --explain E0308`.

Kortom, door Rust heb ik een hernieuwde waardering voor het toepassen van onveranderlijkheid in mijn code, in welke taal dan ook. Daarnaast kijk ik erg uit naar de toevoeging van pattern matching en gerelateerde functionaliteiten in Java. Als jij ook niet kunt wachten, probeer ze dan uit met de –enable-preview flag en de nieuwste JDK. Als je ook kennis wilt maken met Rust is de getting-started pagina (8) een goed begin. Het lezen van het boek geeft een brede achtergrond. Voor een aanpak met veel voorbeelden is Rust by Example (9) ook een aanrader.

Links:

https://openjdk.java.net/projects/amber/
https://openjdk.java.net/jeps/325
https://openjdk.java.net/jeps/354
https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html
https://cr.openjdk.java.net/~briangoetz/amber/datum.html
https://doc.rust-lang.org/book/
https://openjdk.java.net/jeps/358
https://www.rust-lang.org/learn/get-started
https://doc.rust-lang.org/stable/rust-by-example/

Pim Otte is een consultant/software developer bij Quintor. Bij ICTU bouwt hij binnen het Discipl team oplossingen om de dienstverlening van overheid naar burger te versoepelen. Daarnaast heeft hij de minoren Full-Stack Java en Blockchain aan de Hogeschool Rotterdam gegeven.