Stabilisering van Rust's "never"-type

Het retourtype van een functie is bedoeld om aan te geven welk soort gegevens deze produceert.

Rust's "never"-type, aangeduid met een uitroepteken (!), is het type dat de taal gebruikt om een functie te markeren die nooit terugkeert, en voor andere plaatsen waar een waarde nooit kan voorkomen. Lange tijd werd het never-type intern door de compiler gebruikt, maar werd het beschouwd als een onstabiele functie. Op 24 augustus, na meer dan twee jaar werk, is Rust-compiler-contributeur "waffle" erin geslaagd het type te stabiliseren. Dat het zo lang duurde, kwam mede doordat het een kleine breaking change inhield voor eerdere Rust-edities, waarvan de compiler-onderhouders moesten garanderen dat het geen grote impact had op bestaande code.

(Opmerking: Rust gebruikt uitroeptekens ook om aanroepen van macro's aan te geven. Door de manier waarop de syntaxis is opgebouwd, is een plek waar het never-type geldig is, geen geldige plek voor een macro-aanroep, en vice versa.)

Waarom een never-type?

Er zijn twee redenen waarom Rust een never-type heeft: één praktisch en één filosofisch.

De praktische reden is dat het zorgt voor efficiëntere generieke code. Neem bijvoorbeeld de FromStr-trait in de standaardbibliotheek, die wordt gebruikt voor types die kunnen worden geïnstantieerd vanuit een string:

trait FromStr: Sized {
    type Err;
    fn from_str(s: &str) -> Result<Self, Self::Err>;
}

FromStr::from_str() retourneert ofwel een geconverteerd resultaat, of een aangepast fouttype. Bijvoorbeeld: het proberen om "foo" om te zetten naar een integer zal een ParseIntError retourneren. Maar sommige types hebben een onfeilbare (infallible) conversie. Het is bijvoorbeeld altijd mogelijk om een string om te zetten naar een ByteString. Die implementatie van FromStr zou Err kunnen instellen als het never-type. De compiler weet dan dat de fouttak van het geretourneerde Result nooit aanwezig is, en kan alle code die deze aanraakt of controleert optimaliseren en verwijderen.

impl FromStr for ByteString {
    type Err = !;
    fn from_str(s: &str) -> Result<Self, !> { ... }
    // Behoudt dezelfde generieke interface,
    // maar genereert code die equivalent is aan:
    // fn from_str(s: &str) -> Self { ... }
}

De filosofische reden heeft betrekking op correcte type-inferentie. In Rust zijn constructies zoals if-statements en while-loops expressies; hun resultaten kunnen aan een variabele worden toegewezen. De compiler heeft een type nodig om de inferentie te doen voor het resultaat van een oneindige loop, mocht de programmeur er een schrijven. Dit komt in echte code waarschijnlijk niet vaak voor, maar het blijkt de type-inferentie te vereenvoudigen als dit geval uniform behandeld kan worden, in plaats van speciale regels toe te voegen om het af te handelen.

In het bijzonder heeft het never-type een nuttige eigenschap voor het vereenvoudigen van code: het wordt automatisch gecoërcieerd naar elk ander type. Dit klinkt vreemd, maar het is veilig, aangezien het never-type het "resultaat" vertegenwoordigt van een berekening die nooit een waarde zal produceren. Dus overal waar de code claimt een waarde van het never-type te hebben, weet de compiler dat deze code onmogelijk bereikt kan worden, en is het daarom veilig om deze te negeren. Dit is een vorm van type-system-driven dead-code elimination.

Om beide redenen wilden Rust-programmeurs het never-type kunnen gebruiken in stabiele versies van de taal. Om dit te realiseren, moest een bijzonder lastig randgeval worden opgelost.

Never fallback

Vanwege de manier waarop conversies van het never-type naar andere types zijn geïmplementeerd, kan de compiler soms in een situatie terechtkomen waarin hij niet naïef het concrete type van een expressie kan infereren. Beschouw dit voorbeeld, waarin een anonieme functie wordt gedefinieerd (met ||, vergelijkbaar met lambda in Python of LISP) die nooit terugkeert, en die vervolgens wordt aangeroepen op een manier die een concreet fouttype verwacht (met de ?-operator):

let function_that_never_returns = || { loop {} };
function_that_never_returns()?;

Die oneindige loop krijgt een type van !, dat vervolgens impliciet wordt geconverteerd naar wat de functie zou moeten retourneren. Maar omdat de functie lokaal is gedefinieerd en geen expliciet type heeft gekregen, heeft de compiler onvoldoende informatie om te bepalen wat dat type is.

Het probleem zou opgelost kunnen worden door de functie een expliciet retourtype te geven:

let function_that_never_returns = || -> Foo { loop {} };

Aangezien zo'n annotatie echter alleen nodig zou zijn in gevallen waarin de functie niets kan retourneren, zou het zinloos zijn om de programmeur te verplichten er een fictief type aan toe te wijzen. Daarom bevat de compiler een speciale regel: als er, nadat alle andere type-inferentie is voltooid, nog steeds een ambigu type is dat niet bepaald kan worden, wordt er simpelweg aangenomen dat dit het aangewezen fallback-type moet zijn.

Vóór de 2024-editie van Rust was dat fallback-type () (het unit-type, dat precies één mogelijke waarde heeft). In de 2024-editie is het fallback-type gewijzigd naar ! zelf, wat in feite de impliciete conversie tenietdoet. In de interne werking van de compiler wordt het never-type nog steeds geconverteerd naar een onbekend type en vervolgens teruggegrepen op de fallback, maar vanuit het perspectief van de programmeur is het gedrag identiek aan het hebben van een never-type dat alleen impliciete conversie ondergaat wanneer dat nodig is om de types logisch te maken.

Deze wijziging in gedrag was technisch gezien een breaking change. De type-inferentie voor sommige code zou kunnen veranderen, wat op zijn beurt compilatiefouten zou kunnen veroorzaken. Dat is het doel van het editiesysteem van Rust: breaking changes in het front-end ontwerp van de taal toestaan zonder oudere code te breken of het hele ecosysteem in één keer te dwingen te updaten. In dit geval waren er echter redenen om het nieuwe gedrag terug te poorten naar oudere edities.

Never infallible

Al vele jaren heeft de standaardbibliotheek een Infallible-type gehad om de onstabiele aard van het never-type te omzeilen. Het diende hetzelfde semantische doel als het never-type, maar had geen speciale ondersteuning van de compiler. Daarom was code die het gebruikte technisch correct, maar suboptimaal (zoals het hebben van een extra laag tags in een enumeratie of het genereren van dead code), omdat de optimizer niet altijd in staat was om referenties naar Infallible te verwijderen.

Het plan was dat wanneer het never-type uiteindelijk gestabiliseerd zou worden, Infallible een type-alias zou worden voor ! en alle oude code stilzwijgend efficiënter zou worden. Men wees er echter op dat het herdefiniëren van Infallible per ongeluk een breaking change was geworden. Omdat ! impliciete conversies heeft, zou het wijzigen van de definitie van Infallible ertoe kunnen leiden dat bestaande code extra type-specifiers nodig heeft om de type-check te passeren.

Gelukkig vallen het wijzigen van de definitie van Infallible en het wijzigen van het standaard fallback-type, hoewel beide breaking changes zijn, elkaar bijna op. Alle code die direct naar het Infallible-type van de standaardbibliotheek verwijst bij naam zou blijven werken; alleen plekken waar type-inferentie impliciet wordt verwacht Infallible te produceren, vormen een risico. Met het gebrek aan impliciete conversies in de meeste gevallen van Rust, komt dit het vaakst voor op plekken waar het never-type voorheen impliciet werd geconverteerd naar Infallible. Als Infallible een type-alias van het never-type wordt, kunnen die plekken te maken krijgen met never-type fallback, wat op zijn beurt het geïnferred type zou veranderen en een compilatiefout zou veroorzaken als het never-fallback-type niet tegelijkertijd zou worden bijgewerkt.

Omdat beide wijzigingen gelijktijdig plaatsvinden, geloofden de Rust-onderhouders dat bijna alle bestaande Rust-code zou blijven compileren — maar "bijna alle" is geen geruststellende kwalificatie wanneer men te maken heeft met niet-achterwaarts-compatibele wijzigingen. De Rust-community heeft hiervoor een oplossing in de vorm van crater, waarmee alle publiek beschikbare Rust-libraries van crates.io kunnen worden gedownload en gecompileerd om te zoeken naar code die wordt gebroken door een compilerwijziging.

Nooit "nooit" zeggen

Waffle draaide crater in april en ontdekte dat, hoewel er 3.300 crates negatief werden beïnvloed door de wijziging, er slechts zeven volledig defect waren; de rest was defect omdat ze afhankelijk waren van oude versies van libraries die inmiddels waren hersteld. In dat laatste geval zou het probleem theoretisch oplosbaar zijn door backported fixes uit te brengen voor een handvol kernlibraries.

Dit is geen toeval; Rust geeft sinds 2024 een waarschuwing telkens wanneer code een never-type fallback triggert op een manier die zou breken met de nieuwe wijziging, dus de meeste libraries hadden genoeg tijd om uit eigen beweging te updaten. De meest voorkomende resterende fout die door crater is waargenomen, is code die een generieke functie aanroept zonder voldoende type-informatie voor de compiler om een specifiek retourtype te kiezen. Beschouw deze functie:

fn foo<T: Default>() -> Result<T, Error> { ... }

Deze retourneert ofwel een waarde van een door de aanroeper gekozen type T dat de Default-trait moet implementeren, of een fout. Als deze echter wordt aangeroepen zonder een waarde voor T op te geven, kan de type-fallback in werking treden:

// Geen type opgegeven.
foo()?;

Voorheen zou dit de compiler ertoe aanzetten aan te nemen dat T gelijk is aan (), wat Default implementeert, waardoor de code compileert. Na deze wijziging (en in de 2024-editie) neemt de compiler aan dat T gelijk is aan !, wat Default niet implementeert, en veroorzaakt dit dus een compilatiefout. De oplossing is om expliciet het type op te geven dat foo() moet retourneren, ofwel in de aanroep of via een pattern-matching assignement:

foo::<()>()?;
// of
() = foo()?;

Hoewel het geen ingewikkelde wijziging is, waren de Rust-onderhouders niet bereid om 3.300 crates te breken. Waffle werd gevraagd om samen te werken met de onderhouders van veelgebruikte libraries om eenvoudige wijzigingen zoals hierboven terug te poorten (door een nieuwe patch-versie te maken, die veel Rust-buildomgevingen automatisch zullen overnemen), om zo het aantal libraries te verminderen dat afhankelijk is van defecte dependencies. Verschillende library-auteurs waren bereid de backports te maken, maar sommigen weigerden omdat die oude versies hun end of life hadden bereikt. Die onderhouders wezen erop dat gebruikers op een oudere versie van Rust konden blijven of konden updaten naar de onderhouden versie van de library. Desondanks hebben de succesvolle backports 1.553 van de falende crates opgelost.

Na het oplossen van een handvol gerelateerde problemen om het aantal defecte crates verder te verminderen, stemden de Rust-onderhouders er uiteindelijk mee in dat, hoewel er nog steeds wat defecte code zou zijn, het de wijziging waard was om de taal te vereenvoudigen. Dus, vanaf Rust 1.99 zal het never-type stabiel zijn en zal Infallible een type-alias zijn voor het never-type. Gebruikers die merken dat dit hun code breekt, hebben een paar opties:

  • Blijven op Rust-versie 1.98.
  • Hun dependencies updaten naar ondersteunde versies die een fix voor het probleem bevatten.
  • Een patch toevoegen om de retourtypes van beïnvloede functieaanroepen expliciet op te geven.

Aan de ene kant is dit een breaking change, en mensen kunnen code zien die stabiel en werkend was, plotseling weigeren te compileren. Dat zou gezien kunnen worden als een schending van Rust's toezegging tot achterwaartse compatibiliteit. Aan de andere kant is het probleem relatief zeldzaam, zijn er meerdere eenvoudige manieren om het op te lossen, is er al jaren voor gewaarschuwd en maakte het altijd deel uit van het plan voor de taal. Daarnaast hebben de Rust-onderhouders direct met de community samengewerkt om de defecten te vinden en aan te pakken, en zijn ze zelfs zo ver gegaan om te helpen bij het backporten van fixes naar lang geleden stopgezette versies van populaire libraries. Zo kan het hele proces ook worden gezien als een bevestiging van Rust's toezegging tot achterwaartse compatibiliteit.

In de toekomst zullen mensen die de taal leren hopelijk het never-type net iets minder speciaal vinden. Hoe dan ook, de meeste gebruikers van Rust zullen er waarschijnlijk helemaal niet door worden beïnvloed, maar zeg nooit "nooit".