NURL (lyhenne sanoista Neural Unified Representation Language) on ohjelmointikieli, jonka suunnittelu on suunnattu suurten kielimallien (LLM) tuottamaa ja kuluttamaa koodia varten. Kielen keskeiset suunnitteluvalinnat ovat säännöllinen prefiksi-ariteettikielioppi, paikallisesti jäsennettävä rakenne, deterministinen kääntäjä, yhden omistajan muistinhallinta ja oletuksena päällä oleva staattinen lainaustarkistin sekä LLVM-pohjainen koodingenerointi. NURL-lähdekoodin tiedostopääte on .nu.

NURL käyttää prefiksinotaatiota ja yksittäisten merkkien semanttista merkitystä avainsanojen sijaan. Esimerkiksi funktion määrittely tehdään merkillä @, paluulauseena toimii ^ ja muuttujan sidonta merkitään :-merkillä. Kielen kielioppi mahtuu yhdelle sivulle ja on jäsennettävissä rekursiivisella laskeutumisella korkeintaan neljän tokenin etukäteistarkastelulla (LL(k≤4)).

NURL on käännettävä kieli, jonka kääntäjä (nurlc) tuottaa LLVM-välikoodia. Lopullinen konekoodi generoidaan Clang-kääntäjällä, jolloin kohteena voi olla mikä tahansa LLVM:n tukema alusta. Kääntäjä on itse-isännöivä (engl. self-hosting): se on kirjoitettu NURL-kielellä, ja sen bootstrap-prosessi vaatii, että kahden peräkkäisen itsekääntämiskierroksen tuottama LLVM-välikoodi on tavu tavulta identtinen. Ainoa käännösajan ulkoinen riippuvuus on Clang/LLVM 15 tai uudempi.

Suunnitteluperiaatteet

NURL:n dokumentaatio nimeää neljä suunnitteluperiaatetta: säännöllinen kielioppi, paikallinen rakenne, deterministinen kääntäjä ja täysi alustatuki.

Säännöllinen kielioppi tarkoittaa, että jokaisella operaattorilla on kiinteä ariteetti (engl. arity) eli argumenttien lukumäärä, eikä kielessä ole infiksioperaattoreita eikä operaattorien etuoikeuksia. Paikallinen rakenne tarkoittaa, että kunkin rakenteen muoto – ariteetti ja sisäkkäisyys – on pääteltävissä lyhyestä ympäröivien tokenien ikkunasta ilman pitkän kantaman jäsennysriippuvuuksia; muutamat operaattorit (., &, | ja #) valitsevat kuitenkin generoitavan konekoodinsa operandin tyypin perusteella. Deterministinen kääntäjä tuottaa samasta lähdekoodista aina saman lopputuloksen ilman alustakohtaista vaihtelua, ja itse-isännöivä kääntäjä saavuttaa kiinteäpisteen omasta lähdekoodistaan; poikkeuksena raa'at *T-osoittimet ja arvoalueen ylittävät bittisiirrot noudattavat LLVM:n semantiikkaa. Yhdellä käännöslinjalla tavoitetaan jokainen LLVM:n tukema kohde ilman erillistä porttausta.

Diagnostiikka on projektissa oma suunnittelutavoitteensa: virheilmoituksen tulee kertoa mitä odotettiin, mitä löytyi ja mikä on oikea kirjoitusasu esimerkkeineen, koska kielimallille kääntäjä on ainoa opettaja. Väite on myös mitattu: erillinen kattavuusportti raportoi, kuinka moni kääntäjän 311 diagnoosikohdasta on koskaan tulostunut testikorpuksessa (84 % versiossa 0.64.0), ja toinen portti tarkistaa, että kukin diagnoosi osoittaa itse virheeseen eikä sitä seuraavaan tokeniin.

Ominaisuudet

Syntaksi ja prefiksinotaatio

NURL käyttää prefiksinotaatiota muodossa OP ARG1 ARG2 ... ARGN, ts. operaattori on aina argumenttiensa edessä. Kielessä ei ole pilkkuja missään yhteydessä: parametrit, kentät, argumentit ja luettelovariantit erotetaan välilyönnein. Tyypit merkitään yksittäisillä kirjaimilla: i (etumerkillinen 64-bittinen kokonaisluku), u (tavu eli etumerkitön 8-bittinen kokonaisluku), f (64-bittinen liukuluku), b (Boolen totuusarvo), s (UTF-8-merkkijono), v (tyhjä) ja *T (osoitin). Lisäksi tuetaan kiinteäleveyksisiä tyyppejä i8, i16, i32, i64, u16, u32, u64 ja f32 sekä vektorityyppejä v128 ja v256.

Tavallisimpia operaattorimerkkejä ovat : – muuttujan, tietueen, luettelon tai vakion sidonta = – sijoitus @ – funktion määrittely tai rakenteen literaali → – paluutyyppinuoli ^ – eksplisiittinen paluuarvo (return); ^^ on erillinen bittikohtainen XOR . – jäsenen luku ? – ehdollinen lauseke; ?T Option-tyyppi ?? – tyhjentävä hahmonsovitus ja kanavan select ~ – silmukka, for-each, muuttuvuusetuliite tai bittikohtainen komplementti ! – looginen NOT tai Result-tyypin etuliite muodossa ! T E \ – try-propagate-operaattori ja sulkeumat (lambdat) % – piirteen tai sen toteutuksen määrittely; %Trait on piirreobjektin tyyppi $ – tuontilauseke & – ulkomaisten funktioiden rajapinta (FFI) sekä binäärinen JA # – tyyppikoersio ; – defer, funktion päättyessä suoritettava lohko Z – tyypin koko (sizeof) ` – merkkijonoliteraali

Binäärioperaattorit && ja || ovat vaihtoehtoinen kirjoitusasu totuusarvo-operaattoreille & ja |. Funktiokutsu kirjoitetaan aina sulkein muodossa ( fn args ) – pelkkä tunniste on nimihaku. Esimerkiksi kahden kokonaisluvun yhteenlaskun määrittelevä funktio on muodossa @ add i a i b → i { ^ + a b }. Kieliopin versiosta v2.4 alkaen silmukoissa toimivat myös varatut tunnisteet break ja continue.

Tyyppijärjestelmä

NURL:n tyyppijärjestelmä on staattinen, vahva ja pääteltävä: kaikki tyypit tunnetaan käännösaikana, mutta annotaatiot ovat useimmiten valinnaisia. Järjestelmä on algebrallinen: se tukee summatyyppejä (luettelot, merkitään |) ja tulotyyppejä (tietueet eli structit). Aliyhteyksiä (subtyping) tai implisiittisiä muunnoksia ei ole, vaan kaikki tyyppikoersiot tehdään eksplisiittisesti operaattorilla #, joka tuottaa oikean sext/zext/trunc/fpext/fptrunc-käskyn. Etumerkillisyys kuuluu tyypin esitysmuotoon, joten etumerkitön ja etumerkillinen kokonaislukutyyppi pysyvät erillisinä koko käännöksen ajan.

Geneeriset tietueet ja funktiot laajennetaan käännösvaiheessa konkreettisiksi tyypeiksi (monomorphization). Geneerisiä tyyppiparametreja voi rajata piirteillä (trait bounds, esim. [A: Ord]), ja geneerisyys ulottuu myös sisäkkäisiin tyyppeihin sekä ?T- ja ! T E-tyyppeihin. Hahmonsovitus on tyhjentävää ja tukee vartioita (match guards), tai-hahmoja (or-patterns), literaalirajoitteita ja monikenttäisiä hyötykuormia. Kääntäjä tekee lisäksi käännösaikaista vakioiden taittamista ja häntäkutsujen optimointia.

Versiosta 0.58.0 alkaen jokainen arvon siirtymäkohta – paluuarvo, jokainen argumenttipolku, geneeriset instanssit, piirremetodien lähetys, sulkeumakutsut ja dynaaminen lähetys – päättyy täydelliseen tyyppien yhtäpitävyystarkistukseen: sallittujen koersioiden jälkeen arvon LLVM-tason tyypin on oltava sama kuin ilmoitetun. Ilman tarkistusta virheellinen kutsu kääntyisi kelvolliseksi LLVM-koodiksi, jonka vastaanottaja lukisi roskaa rekistereistä.

Piirteet ja metodien lähetys

Piirteet (engl. traits) määritellään operaattorilla %, ja niissä voi olla oletustoteutuksia, ylipiirteitä (supertraits) ja liitännäistyyppejä (associated types). Metodien lähetys on oletuksena täysin staattista: kutsu ratkaistaan käännösaikana monomorfisoidun tyyppinimen perusteella, eikä konkreettisilla arvoilla ole ajonaikaista piirreidentiteettiä.

Kieliopin versiosta v2.3 alkaen kielessä on lisäksi valinnainen dynaaminen lähetys piirreobjektien avulla. Tyyppi %Trait on läskiosoitin (rakenne { i8*, i8* }), joka yhdistää keko-allokoidun kopion konkreettisesta arvosta ja toteutuskohtaisen metoditaulun (vtable); objekti muodostetaan lausekkeella ( dyn Trait arvo ). Metoditaulu litistää myös ylipiirteiden metodit, ja piirteen käyttö objektina edellyttää objektiturvallisuutta, joka tarkistetaan käännösaikana. Piirreobjekti osallistuu tavalliseen automaattiseen vapautukseen kääntäjän muodostaman Drop-toteutuksen kautta. Versiosta 0.64.0 alkaen piirresopimukset ratkaistaan koko tuontigraafin yli ennen funktiorunkojen generointia, joten määrittelyjen keskinäinen järjestys ei vaikuta lopputulokseen.

Muistinhallinta ja lainaustarkistin

NURL:n sidonnat ovat oletuksena muuttumattomia (engl. immutable): lauseke : i x 0 sitoo nimen x arvoon 0 niin, että uudelleensijoitus aiheuttaa käännösvirheen. Muutoksen mahdollistamiseksi käytetään etuliitettä ~ muodossa : ~ i x 0. Roskienkeruuta ei ole, eikä piilotettua laatikointia (boxing); arvot ovat oletuksena pinossa, ja keko-allokaatiot vapautetaan kääntäjän automaattisesti lisäämällä auto-drop-kutsulla, kun sidonta poistuu näkyvistä. Vapautus kattaa muun muassa keko-allokoidut viipaleet ja merkkijonot, laatikoidut luettelo- ja tietuehyötykuormat, rekursiiviset Drop-toteutukset sekä käyttäjän määrittelemät % Drop-destruktorit.

Parametrien välitystapa on yksi kolmesta: in (oletus, muuttumaton arvonvälitys), inout (yksinomainen muuttuva lainaus, jossa kutsuttu funktio kirjoittaa kutsujan muistiin) ja sink (omistajuuden siirto kutsuttavalle). Funktion viimeisillä parametreilla voi olla oletusarvo, ja ne voidaan välittää nimellä. Lauseella ; merkitty defer-lohko suoritetaan funktion päättyessä käänteisessä järjestyksessä; lohko suoritetaan vain, jos sen ;-lause todella suoritettiin, ja silmukan sisällä olevaa defer-lohkoa ei toisteta kierroksittain.

Kielessä on oletuksena käytössä erillinen staattinen lainaustarkistin (borrow checker), joka on toteutettu kääntäjän diagnoosivaiheena ilman vaikutusta generoituun koodiin. Tarkistin valvoo yhdeksää sääntöä ja havaitsee käännösvirheinä muun muassa käytön siirron jälkeen (engl. use-after-move), kaksoisvapauttamisen aliaksen kautta, sulkeumiin karkaavat pinoviittaukset, proseduurien välisen ja paluuarvon karkaamisen, silmukan yli kantautuvan siirron, iteraattorin invalidoinnin sekä saman sidonnan yhtäaikaisen inout-lainauksen ja luvun samassa kutsussa. Tarkistus voidaan poistaa käytöstä lipulla --no-borrowck tai tiukentaa lipulla --strict-borrowck. Versiosta 0.44.0 alkaen mikään sääntö ei riipu määrittelyjärjestyksestä, ja versiosta 0.53.0 alkaen kattavuutta mitataan käänteisellä oraakkelilla: fuzz-työkalu kirjoittaa yhden omistajuusrikkeen jokaiseen kielen tarjoamaan asiayhteyteen ja vaatii nimenomaan sitä diagnoosia, jonka rike ansaitsee.

Dokumentaatio korostaa, että lainaustarkistin ei ole täydellinen eikä vastaa Rustin tarkistinta: tarkastamatta jäävät muun muassa raakojen *T-osoittimien elinajat, aggregaatin läpi tavoitetut kahvat sekä ehdolliset kaksoisvapautukset, joita ei ilmoiteta, koska jokaisen diagnoosin halutaan osoittavan varma virhe.

Viipale (engl. slice) tyyppiä [T kääntyy LLVM-tasolla läskiosoittimeksi eli rakenteeksi { T*, i64 } (osoitin + pituus).

Virheidenhallinta

Virheitä käsitellään pääasiassa luettelotyypeillä ! T E (variantit Ok ja Err) ja ?T (variantit Some ja None). Try-propagate-operaattori \ purkaa Ok- tai Some-arvon tai propagoi virheen ylöspäin. Standardikirjastossa on lisäksi lehtitason yhdistelijät, kuten expect ja unwrap.

Vakavampia tilanteita varten kielessä on ajonaikainen panic/recover-malli, joka perustuu C:n setjmp/longjmp-pohjaiseen pinon purkuun lähimpään recover-kehykseen. Allokaatiot, jotka pinon purku sivuuttaisi, kerätään säiekohtaisesta allokaatiojournaalista panic-hetkellä, joten ne eivät vuoda. Vain eksplisiittinen panic on toivuttavissa; laitteistovirheet (SIGSEGV/SIGFPE/SIGBUS) johtavat prosessin päättymiseen.

Moduulit ja näkyvyys

NURL-ohjelmat jakautuvat tiedostoihin, jotka tuodaan toisiinsa lausekkeella $ `polku/tiedostoon.nu`. Tuonti on staattinen sisällytys (kuten C:n #include): tuotu tiedosto käännetään samaan LLVM-moduuliin, eikä nimiavaruuksia ole. Sama tiedosto tuodaan enintään kerran käännöstä kohti. Tuonnille voi antaa aliaksen, jolloin tuodun tiedoston ylätason nimet ovat käytettävissä muodossa alias::nimi; FFI-määrittelyjä ja piirremetodeja ei nimetä uudelleen.

Kieliopin versiosta v2.0 alkaen kieleen kuuluu näkyvyydenhallinta avainsanalla pub; sen kattavuus ulottuu @-funktioihin, tietue- ja luettelotyyppeihin, luettelovariantteihin ja ylätason vakioihin. Tiukka näkyvyys aktivoituu tiedostokohtaisesti, kun tiedostossa on yksikin pub-merkintä; tiedostot ilman pub-merkintöjä jäävät taaksepäin yhteensopivaan sallivaan tilaan.

Funktionaalinen ohjelmointi ja sulkeumat