Rust: egy programozási nyelv anatómiája

2019.01.13. · tudomány

Már a programozós sorozatom elején elhatároztam, hogy a sok közül valamelyik programnyelvet részletesen is ismertetem majd. Már akkor említettem azt a programozási nyelvet, amelyik szerintem a legalkalmasabb akár a középhaladó programozás-tanulásra is, de akár a legprofibb rendszerprogramozás igényeihez is. A kezdők számára az a legvonzóbb vonása, hogy a programjaink feldolgozása (fordítása) során a lehető legtöbb lehetséges hibaforrást felismeri, és nagyon világosan elmagyarázza, hogy mit és hol rontottunk el.

Ezzel függ össze az is, amit első (és akár a sokadik) találkozásunkkor is túlzott elővigyázatosságnak, pedantériának érzékelhetünk. Sok olyasmit sem enged meg ez a nyelv, ami az adott programban semmiféleképpen sem vezethet futás közbeni hibához, de ha az illető programot tovább írjuk vagy nagyobb programokba beépítjük, akkor viszont mindent elronthatna, és akkor visszamenőleg át kellene írnunk azt, amivel elkezdtük. A túlzottnak látszó szigorúság az egyetlen módja annak, hogy ezt megelőzzük. Más programozási nyelvek futni hagynak az ilyen „mérgezett” programrészletekkel (vagyis hajlandók lefordítani, hogy futtatni tudjuk), de az ő esetükben később ez még sok bosszúsághoz vezethet.

A Rust nevű programozási nyelvről beszélek, amire tehát az jellemző, hogy míg a nagyon segítőkész hibaüzenetek a kezdő programozónak teszik vonzóvá, addig a túlbiztosításnak tűnő, de nagyon hasznos elővigyázatossági intézkedések a nagy rendszerek fejlesztő-csapatait vonzzák – nem véletlen, hogy a nyelv viszonylag rövid élete során (gyakorlatilag 2015 óta tették hozzáférhetővé a Mozilla kutatói), már egy egész operációs rendszert is írtak ezen a nyelven.

Néhány feltűnő jellegzetesség

Kezdem a legkevésbé lényegessel. A Rust is funkcionális nyelv, vagyis az eljárások függvényszerűek: a paramétereiket úgy fogják fel, mint a függvények argumentumait, amikre alkalmazzuk őket (bár nem kell minden eljárásnak értéket „visszaadnia”). Azt az eljárást, ami a program tevékenységét leírja, itt is main-nek hívják, és a leírási konvenciók sok mindenben emlékeztetnek például a C nyelv konvencióira (bár az eljárások neve elé a fn kulcsszót kell írni):

fn main()
{
// amit a program csinál...
}

Az új változók bevezetésénél nem kötelező a típusukat megadni (a nevük után kettőspont után), de csak akkor lehet elhagyni őket, ha azok kikövetkeztethetőek a program további szövegéből. Azt viszont kötelező megadni, hogy a program további részében változhat-e az értékük – ez már a korábban említett szigor egyik összetevője, és nemcsak a bevezetett változókra, hanem több más esetre is érvényes, hogy a változtathatóságot be kell jelenteni. Tehát például rossz a következő program:

fn main()
{
let szam: i32 = 0;
szam += 1;
}

Itt a main törzsének első sora bevezeti a szam nevű változót (a let kulccsó a változó bevezetését jelöli; a szam típusa i32, vagyis 32 bites egész szám), a második sor pedig megpróbálja eggyel növelni az értékét. De ez tilos, mert a változó bevezetésénél nem jelentettük be, hogy az értéke változtatható (angolul ezt úgy mondják, hogy mutable). A helyes változat:

fn main()
{
let mut szam: i32 = 0;
szam += 1;
}

A változó neve elé írt mut kulcsszó jelöli a változtathatóságát.

A Rust a C nyelvhez hasonlóan jelöli a memóriacímeket is (a változó neve elé tett & jellel), illetve azt, ahogy a címekből megkaphatjuk azt az adatot, ami azon a címen éppen a memóriában van (a változó neve elé tett * jellel).

Típusosztályok

A Rust nyelv nem objektumorientált, legalábbis közvetlenül nem az. A sorozatban korábban írtam már arról, hogy az objektumorientált nyelvekben egyetlen eszköz, az osztály (aminek alosztályai és példányai lehetnek) többféle célt szolgál: adattípusok, interfészek, modulok, sőt eljárások funkcióját is betöltheti, és ez sokakat zavar. Akiket zavar, azok között igen nagy népszerűségnek örvend a Haskell programozási nyelv, ami huszárvágással oldotta meg ezt a problémát: meghonosította a típusosztályok fogalmát. A típusosztályok, mint a nevük is mutatja, adattípusok fajtái. Annak felelnek meg, mint más nyelvekben az interfészek, vagyis felsorolják, hogy egy bizonyos adattípushoz milyen eljárások, szolgáltatások stb. tartoznak. Ha kimondjuk, hogy egy bizonyos adattípus beletartozik egy ilyen típusfajtába, akkor konkrétan meg kell adnunk, hogy az a bizonyos adattípus pontosan hogyan valósítja meg ezeket a szolgáltatásokat (ezt nevezzük implementációnak). Amit az objektumorientált programozásban az egyes osztályokhoz tartozó „tagok” (adatok és eljárások, vagyis „módszerek”) csinálnak, az pontosan ugyanaz, mint az egy-egy típusosztályhoz tartozó szolgáltatások (ezek szintén lehetnek adatok és eljárások, sőt akár adattípusok is).

A Rust ezeket a Haskellben bevezetett típusosztályokat használja, csak éppen trait-eknek nevezi őket. Szóval a fogalmak szépen elkülönülnek egymástól, mert ezeken kívül léteznek a Rust nyelvben modulok, adattípusok meg eljárások.

Például tegyük fel, hogy a virtuális világunkban síkbeli alakzatokat ábrázolunk. Ezek nagyon sokfélék lehetnek, de vannak bizonyos közös vonásaik (angolul: trait), például az, hogy síkban le lehet őket rajzolni. Bizonyos fajtáiknak is lehetnek közös vonásaik, például a síkidomoknak van területük és súlypontjuk, a szakaszoknak hosszúságuk, és így tovább. Ráadásul ugyanazt a fajta síkidomot sokszor többféleképpen is meg lehet adni (például a háromszögeket a csúcsok és a szögek többféle kombinációjával). Elég természetes, hogy a Rust nyelvben minden konkrét alakzat minden konkrét ábrázolási módjának egy-egy adattípus (tipikusan struktúra) felel meg, a közös vonásaiknak pedig típusosztályok.

Kezdjük az alapelemekkel, például jól jön egy adattípus a sík pontjainak:

struct Pont
{
x: f32,
y: f32
}

Ez tehát olyan adattípus, amit két 32 bites tizedestört jellemez (ennek az adattípusnak a neve f32; választhattuk volna a kétszer ilyen pontos, 64 bites f64nevű tizedes törteket is). A struktúráról mint adattípusról a múltkor már beszéltem, ez olyan, aminek többféle típusba tartozó tagja is lehet, és a tagjai el vannak nevezve. Aztán mondjuk a Szakasz nevű adattípus olyan, amit két ponttal jellemezhetünk:

struct Szakasz
{
egyik_pont: Pont,
masik_pont: Pont
}

A háromszöget meg mondjuk ábrázoljuk kétféleképpen, három pontjával, vagy egy oldalával, egy másik oldal hosszával, és a két oldal szögével (radiánban, vagyis az ívhossz és a sugár arányában megadva):

struct HaromszogPontokkal
{
a: Pont,
b: Pont,
c: Pont
}
struct HaromszogSzoggel
{
oldal: Szakasz,
masik_oldal_hossza: f32,
szog: f32
}

Mármost ahhoz, hogy kifejezzük, hogy a kétféle háromszög egyaránt háromszögek, először is meg kell adnunk (egy típusosztállyal) azokat a dolgokat, amiket minden háromszögnek tudnia kell, vázlatosan:

trait Haromszog
{
// ... amiket egy háromszögnek tudnia kell
}

És a háromszög-adattípusaink idetartozását egyszerűen úgy fejezzük ki, hogy ezt a Haromszog nevű interfészt implementáljuk mindkét háromszögtípusra, vázlatosan:

impl Haromszog for HaromszogPontokkal
{
// ... ahogy ennél a háromszögnél meg kell valósítani
}

impl Haromszog for HaromszogSzoggel
{
// ... ahogy ennél a háromszögnél meg kell valósítani
}

És így tovább, például a síkidom fogalmát úgy fejezhetjük ki, hogy egy trait-tel megadjuk, mi mindent lehet egy síkidommal csinálni, majd ezt konkrétan lebontjuk (egy-egy implementációval) az egyes síkidom-adattípusokra. Szinte mindent meg tudunk így tenni, amit az emberek az objektumorientált szemléletben szeretnek. Például ha a Sikidom nevű típusosztály (trait, interfész) tartalmazza a terulet, kerulet és sulypont nevű eljárásokat, akkor egy tetszőleges s nevű síkidom esetében (ha a típusára implementálva van a Sikidom), ezek az objektumorientált programozásból ismert módszerek mintájára használhatók: s.terulet() számolja ki az s területét, s.kerulet() a kerületét, s.sulypont() a súlypontját, stb.

Módszereket nemcsak egy típusosztályon keresztül társíthatunk adattípusokhoz, hanem közvetlenül is. Például itt egy módszer a pontok távolságának kiszámítására:

impl Pont
{
fn tavolsag( &self, masik: &Self ) -> f32
{
let vizszintes = self.x - masik.x;
let fuggoleges = self.y - masik.y;
let atfogo_negyzet =
vizszintes.powf( 2.0 ) + fuggoleges.powf( 2.0 );
return atfogo_negyzet.powf( 0.5 )
}
}

Több dolog miatt is tanulságos ez az eljárás. Egyrészt látható két speciális szó, a self, ami arra az adott objektumra utal, amire a módszert majd éppen alkalmazzuk, és a Self, ami az illető objektum adattípusára. (Ebben a fenti példában &Self helyett akár &Pont-ot is írhattam volna). Az is jól látható, hogy a tavolsag nevű függvény (módszer) paraméterei nem maguk az adatok, hanem a címük – lejjebb elmagyarázom, hogy miért ez az általános, amikor Rust programot írunk. Az is látszik, hogyan kell jelölni egy eljárás értékének a típusát (a törzs előtt álló „->” jel után áll, itt f32), meg az is, hogy ez az eljárás nem változtathatja meg a paramétereinek megfelelő adatokat (mert akkor úgy kellett volna írni őket, hogy „&mut self” és/vagy „&mut Self)”. Végül arra is figyeljünk fel, hogy a Rust sokszor megengedi, hogy amikor értelemszerű, akkor a címeket jelölő szimbólumokat ugyanúgy használjuk, mintha magukra az adatokra utalnának, például írhatjuk azt, hogy „self.x” (ahelyett, hogy „(*self).x”), hiszen a címeknek nincsenek tagjaik, módszereik stb.

Pedantéria a címekben

Azóta, hogy először említettem a memóriacímeket, mindig felhívtam a figyelmet arra a veszélyre, hogy milyen hibaforrásokat rejt magában a címek használata. A memória tartalma ugyanis változik, és amikor a programunkban megjegyzünk egy címet (a tartalma miatt), akkor ha nem vigyázunk arra a helyre, aminek a címe, akkor lehet, hogy mire legközelebb arra járunk, és ránézünk arra a helyre a memóriában, már valami egész más van ott. A másik nagy veszély, hogy ha nem szabadítjuk fel a memóriának azt a részét, amit ideiglenesen használtunk valaminek a tárolására, és ez ismétlődik a program során, akkor a programunk előbb-utóbb képes a rengeteg felesleges memória menedzselése miatt lelassulni és más programokat is lelassítani.

A Rust bevallott célja, hogy az ilyen hibák lehetőségét a pedantériájával az abszolút minimumra csökkentse. A legtöbb hibalehetőség már az előzetes feldolgozás, a program fordítása során kiderül, és értesülünk arról, hogy mi a gond. Például ezt szolgálja az, hogy minden lefoglalt memóriát még azon a blokkon belül felszabadít, amiben lefoglaltuk, méghozzá szigorúan a lefoglalással ellenkező sorrendben (amit először foglalunk le, az szabadul fel utoljára). (Kivétel persze az, amikor egy return-nel bevezetett kifejezéssel a lefoglalt memória címét adja értékként egy függvény: annak a memóriarésznek az élettartama az a blokk, amiben a függvény alkalmazása történt.)

Egy memóriaterület „megromlásának” az az egyik tipikus oka, hogy a címét valahol eltároljuk (mondjuk egy másik adatstruktúrában), és a terület úgy szabadul fel, hogy a címe azért még ottmarad megőrizve a másik adatban. Ez az eset Rust-program esetében egyszerűen nem fordulhat elő, mert már az általunk leírt program szövegében kötelesek vagyunk biztosítani, hogy az az adat, ami a másik címét tartalmazza, ne maradhasson tovább a memóriában, mint aminek a címét tartalmazza. Ahogy mondani szokás: az, ami címeket tartalmaz, „ne élhessen túl” semmit, aminek a címét tartalmazza. (Hogy ezt hogyan garantálják, azt itt nem magyarázom el, de ha sikerült megmagyaráznom, hogy ez a cél, akkor az emiatt bevezetett szabályokat a dokumentációkból, tankönyvekből már bárki könnyen megérti.)

Egy másik nagyon érdekes tulajdonsága a Rust nyelvnek, hogy minden adatnak csak egy „tulajdonosa” lehet, csak egyetlen változónak az értéke lehet. Ez pontosan azért van, hogy a program szövegéből mindig egyértelmű legyen, hogy mettől meddig van lefoglalva egy memóriaterület, meddig „él” – az élettartamára vonatkozó játékszabályokat itt feljebb leírtam. Ennek az a furcsa következménye van, hogy egy változó „elhasználódhat”, vagyis használhatatlanná válhat, ha az értékét átadjuk valakinek. Például a következő programrészlet már a fordításkor hibát eredményez:

let p1 = Pont { x: 1.0, y: 4.0 };
let p2 = p1;
println!( "{}", p1.x ); // itt már nincs "p1"!

Az utolsó sor azt jelentené, hogy egy külön sorban ki szeretnénk íratni a képernyőre a p1 pont első koordinátáját. Csakhogy annak a bizonyos pontnak közben új tulajdonosa lett, a p2 nevű változó, a p1 elhasználódott, semmi értelmesre nem utal már. Ezt a Rust zsargonjában úgy hívják, hogy a tartalmát „átmozgattuk” (move) máshova. Egyetlen esetben nem történik ilyenkor mozgatás (és elhasználódás): akkor, ha annak az adatnak a típusa, amit át akarunk adni másnak, a Copy nevű típusosztályba tartozik (ilyenek például a számok, de nem ilyen a mi Pont nevű adattípusunk, hacsak nem implementáljuk a Pont-ra vonatkozóan a Copy típusosztályt). Ha ilyen Copy típusú adatról van szó, akkor sem történik tulajdonos-váltás, hanem az új tulajdonos a régi adatok egy másolatnak válik a tulajdonosává (a memóriában a számára újonnan lefoglalt helyen).

Emiatt a szabály miatt írtam feljebb, hogy a Rust-programokban igen gyakori, hogy az eljárások paraméterei címek, nem pedig konkrét adatok. A címek átadásával ugyanis nem változik az adatok tulajdonjoga: ilyenkor nem „átmozgatjuk”, csak „kölcsönadjuk” (borrow) az adatot. Kölcsönadásnál is jelölni kell (a mut kulcsszóval), ha a változtatást is meg akarjuk engedni. Tehát a fenti (amúgy nem túl értelmes) programrészletet így „javíthatjuk ki”:

let p1 = Pont { x: 1.0, y: 4.0 };
let p2 = &p1; // nem átmozgatás, hanem kölcsönzés
println!( "{}", p1.x );

Végül a teljesség kedvéért el kell még mondanom azt, hogy a kölcsönzésre is nagyon szigorú szabályok vonatkoznak, és ez megintcsak a biztonságot szolgálja. Egy blokkon belül egy változó tartalmát akárhányszor kölcsönadhatjuk, ha ezek a kölcsönzések nem változtathatják meg a tartalmát (vagyis nem változtatható módon adjuk kölcsön, a mut kulcsszó használata nélkül). Ha viszont a blokkon belül akár csak egyszer is változtatható módon adjuk kölcsön valakinek, akkor sem előtte, sem utána nem adhatjuk kölcsön semmilyen módon.

Ez az óvintézkedés okozza a legtöbb bosszúságot annak, aki a Rust-programozást tanulja (sőt, annak is, aki már tudja és műveli), mert itt fordul elő a leggyakrabban az, hogy teljesen ártalmatlan programrészletek is megsértik a szabályt. De amik valóban teljesen ártalmatlanok, azokat át is lehet fogalmazni úgy, hogy ne ütközzenek ebbe az előírásba, amik pedig ellentmondanak neki, azok a program későbbi továbbírásánál vagy máshol való felhasználásánál igenis okozhatnának bajt, tehát a tiltás általánosabb szempontból jogos.

Rust-programozás

Nem véletlen, hogy éppen a Rust nyelvre esett a választásom, hogy éppen ennek a részleteit magyaráztam el ebben a sorozatban. Személyes kedvencem ez a nyelv, a világossága és az egyszerűsége miatt, az összes általam ismert nyelv közül ez teszi lehetővé a legnagyobb fokú modularitást (absztrakciót), és a biztonságos voltát is nagyon becsülöm. És rengeteg olyan csodás vonása is van, amiről fent nem is szóltam (modulrendszer, típusváltozók stb.).

Két hátrányát kell csak említenem, mert nem lenne fair ezeket letagadni. Az egyik, a kisebb, hogy nagyobb projektek esetén muszáj hozzá használni a Cargo nevű segédprogramot, ami nélkül lehetetlen a projektek összeépítése, a külső programkönyvtáraktól való függőségek kezelése, és így tovább. A másik, a súlyosabb, hogy nagyon sok kelléke még mindig hiányzik (vagy legalábbis nincs még standard változatuk), például kevésféle adatbázist lehet vele együtt használni, vagy nincs megoldva a memóriában tárolt lények egyszerű és egységes lemezre mentésének lehetősége. De ezeknek a megoldása meg csak idő kérdése.

A szerző nyelvész, az MTA Nyelvtudományi Intézetének főmunkatársa. A Qubit.hu-n megjelent írásai itt olvashatók.

KAPCSOLÓDÓ CIKKEK