A Nagy Rejtély: élettartamok a Rust nyelvben

2020.09.12. · tudomány

Egy korábbi írásomban bemutattam a Rust programozási nyelv alapjait. Akkor nem szóltam a Rustnak arról a vonásáról, ami a legtöbbeket elriaszt a használatától. Pedig erre van szükség ahhoz, hogy olyan pompás nyelv lehessen, és a lényege nem is olyan rejtélyes, mint gondolhatnánk. Csak le kell rántani róla a fátylat: erre vállalkozom itt. Az interneten már számtalan szerző vállalkozott rá, én egyik magyarázattal se voltam nagyon elégedett, mert nem sikerül érthetőbbé tenniük a dolgot, például mert olyan technikai részletekbe is belemennek, amik tényleg bonyolultak. Szerintem egyszerűbben is lehet.

A Rust és a memória

A Rust többféle szempontból is csodálatos programozási nyelv. Az egyik legcsodálatosabb tulajdonsága, hogy gyakorlatilag minden programozási hibát már a program előzetes feldolgozása, vagyis fordítása (kompilálása) alatt megtalál, amit egyáltalán meg lehet. Ettől nem függetlenül nagyon informatívak és világosak a hibaüzenetei. Mindez az alapvetően újító jellegéből fakad, ami azzal függ össze, ahogy hogyan kezeli a programok futása során a memóriában tárolt adatokat. A következőkben csak a Rust legalapvetőbb vonásainak az ismeretét veszem adottnak, de remélem, az is hasznát veheti az alábbi magyarázatoknak, aki már többet tud róla.

A rövidség kedvéért azt mondhatjuk, hogy alapesetben, amikor egy adatot eltárolunk (azzal, hogy egy névhez, szimbólumhoz rendeljük értékként), akkor egyben döntést hozunk az élettartamáról. Vagyis arról, hogy mikor fog az illető adat „meghalni” (elérhetetlenné válni). (Néha úgy fogalmaznak, hogy maga a szimbólum „hal meg”, de az csak informális rövidítés.) Az ilyen elérhetetlen adatok által elfoglalt memóriaterületet a program automatikusan újrafelhasználhatónak jelöli. Ettől Rust nyelven nagyon hatékony, gyors programokat lehet írni, mert más programozási nyelvek esetében az elérhetetlen memóriarészek megtalálása és újrafelhasználása elég bonyolult és időigényes lehet.

Az adat élettartama a bevezetésével kezdődik (vagyis azon a ponton, amikor értékként egy szimbólumhoz rendeljük, akkor foglal helyet számára a program), és annak a blokknak a végéig tart, amiben bevezettük. (Az egyszerűség kedvéért azt mondhatjuk, hogy a blokk olyan egység, ami egy „{” jellel kezdődik, és a hozzá tartozó „}” jellel végződik, legalábbis a program szövegében. Amikor általánosabban magára az élettartam fogalmáról beszélünk, akkor inkább a hatókör – angolul: scope – szót használjuk.) Az adatok „halálának” a sorrendje a blokk végén az ellenkezője a bevezetésük sorrendjének.

Amit most elmondtam, az a sarokköve annak, ahogy a Rust nyelv a memóriát kezeli. A „komplikációk” (valójában az extra lehetőségek) akkor kezdődnek, amikor egy speciális adatfajtával kezdünk dolgozni, aminek a neve referencia. A referenciák tulajdonképpen címek vagy mutatók, amik arra a memóriarészre utalnak, ahol valamilyen adat van tárolva. Mindjárt látni fogjuk, hogy a referenciák azért nagyon fontosak, mert lehetővé teszik, hogy közvetett módon utaljunk adatokra, és úgy dolgozzunk velük, hogy ne kelljen még egy példányban lemásolni őket. Csak egyetlen nagy gond van a referenciákkal (na jó, nemcsak egy, de most ezzel az eggyel fogunk foglalkozni): Csak addig szabad használni őket, amíg az adat, amire utalnak, még elérhető. Ha az adat már „meghalt”, akkor kiszámíthatatlan, hogy mit tartalmaz a helye a memóriában. Maga a főszabály ilyen egyszerű.

A referenciák

Szóval a referenciákat sajátos adatfajtának tekinthetjük, amiknek ugyanúgy élettartamuk van, mint minden más adatnak, ami a bevezetésüktől a blokk végéig tart. A Rust nyelvben a referenciák bevezetését és használatát (és a nekik megfelelő adattípusokat is) a „&” jel jelöli:

{ // itt kezdődik a blokk
// adatot vezetünk be, a típusát is megadjuk:
let adatom: usize = 5;
// referenciát vezetünk be, a típusát is megadjuk:
let referenciam: &usize = &adatom;
// egy eljárás hívása (a törzsének a végrehajtása),
// amiben a paraméter egy referencia:
csinalj_vele_valamit( referenciam );
// de nyilvánvalóvá is tehetjük, hogy
// referencia a paraméter:
csinalj_vele_valamit( &referenciam );
} // itt a blokk vége

Mivel az adatok hatókörének a vége a blokk végén a kezdetük fordítottja, először a referenciam szimbólumhoz rendelt adat lesz elérhetetlen, aztán pedig az adatom-hoz rendelt. A csinalj_vele_valamit nevű eljárásnak egy referenciát adunk át paraméterként, nem pedig magát az adatom-hoz tartozó adatot, ezért az eljárás törzsében nem az adatok másolatával dolgozunk.

Látható, hogy ez a példa csak egy nagyon buta illusztráció, egyáltalán nem világos belőle, hogy egyáltalán miért jó referenciákat használni. Vannak programozási nyelvek, mint például a Python, amikben egyáltalán nem jelöljük írásban a referenciákat. Az ilyen rendszerek maguk próbálják kitalálni, hogy mikor érdemes referenciát használni, és mikor magukat az adatokat. De az ilyen rendszereknek nagy árat kell fizetniük a rugalmasságukért, méghozzá abban, ahogy a program futása közben a memóriát kezelik, és ahogy a fordítás alatt a hibákat próbálják megtalálni.

A referenciák használatának egy nagyon fontos értelme van. Megspórolhatjuk vele azt, hogy a memóriában tárolt adatokat át kelljen másolni valahova máshova a memóriába, ami nemcsak idő- és helypazarlás lenne, hanem megnehezítené azt is, hogy – ha akarjuk – vigyázzunk rá, hogy a két helyen mindig ugyanazok az adatok legyenek. Szóval ez az egyetlen legfontosabb értelmük a referenciáknak az, hogy a program több különböző részlete ugyanazokon az adatokon osztozzon.

A referenciákkal kapcsolatos másik nagy bonyodalom, az, amiről itt nem fogok részletesen beszélni, hogy mikor megengedett, hogy a program módosítsa az adatokat, amikre egy referencia utal. Az előző példában nem megengedett, szóval ha a csinalj_vele_valamit eljárás megpróbálná megváltoztatni annak a memóriaterületnek a tartalmát, amihez a referenciam-on keresztül közvetve hozzáfér, az már a fordítás alatt hibát jelentene. Ahhoz, hogy a referencia által jelölt memóriarészt megváltoztathassuk, a „& mut” jelölést kellene használnunk a „&” helyett, akkor is, amikor bevezetjük a referenciát, és akkor is, amikor paraméterként átadjuk. (A mut az angol mutable ‘megváltoztatható’ rövidítése.) De szigorú szabályai vannak annak, hogy ezt mikor lehet megtenni. Egy megváltoztatható referencia hatókörén belül tilos bármilyen referenciát bevezetni, ami az érintett memóriarészre utal, és egy nem megváltoztatható referencia hatókörén belül nem szabad olyan megváltoztathatót bevezetni, ami ugyanarra a memóriarészre utal. Nagyon könnyen megérthető, hogy miért szükségesek ezek a megszorítások, de most nem megyek bele.

Azt hiszem, hogy mindez meglehetősen egyszerű, szóval továbbléphetünk eggyel. Tartsuk észben az aranyszabályt: nem használhatunk referenciát akkor, ha az az adat, amire utal, már nem elérhető. Láttuk, milyen könnyű ezt betartani, ha egyetlen blokkon belül maradunk. Láttuk a szabályokat, könnyű betartani őket. De vannak trükkösebb esetek is.

Eljárások és referenciák

A leggyakoribb eset, amikor az ember kísértésbe esik, hogy megsértse az aranyszabályt, az, amikor azt akarjuk, hogy az eljárásunk értéke (a visszatérési érték, amit a végrehajtás során kiszámolunk) referencia legyen. Ez mindig hibához vezet, ha annak az adatnak, amire a referencia utalna, az eljárás törzsén belül van a hatóköre, ha ott vezetjük be. (Az eljárás törzse mint blokk azon a blokkon belül van, amiben az eljárást „meghívjuk”.) Mire ugyanis az eljárás visszaadná azt a bizonyos értéket, addigra a törzsében bevezetett adatok már meghaltak, ezért a rájuk utaló referencia nem használható. Egyszóval: ha egy eljárás értéke nem referencia, akkor biztonságban vagyunk.

Vajon együtt tudunk-e élni egy ilyen szigorú megszorítással? Lehet, hogy együtt tudnánk, de nem fogunk. Igenis lesznek időnként eljárások, amiknek referencia lesz az értékük, de ezek egy szigorú szabálynak engedelmeskednek: Csak akkor lehet egy referencia az eljárás értéke, ha olyan adatra utal, amire utaló referenciát eleve átadtunk az eljárásnak, mint paramétert. Ebben az esetben ugyanis az adat élettartama már az eljárás végrehajtása előtt tart, és utána is, szóval ezzel nem sértjük meg az aranyszabályt.

Például megtehetjük, hogy egy karaktersorozatra utaló referenciát (&str típusút) adunk át paraméterként egy eljárásnak, úgy, hogy az eljárás végrehajtásának az értéke ennek a karaktersorozatnak egy részére mutató referencia legyen. Ez a fentiek értelmében teljesen megengedett. Itt egy buta példa: az eleje() nevű eljárás a paraméterként megadott karaktersorozat első két karakteréből álló sorozatra mutató referenciát ad értékül:

fn eleje( bemenet: &str ) -> &str
{
return &bemenet[0..2]
}

fn main()
{
let karakterek: &str = "abcde";
println!( "{}", eleje( karakterek ) );
}

A fordítás sikeres lesz, ha pedig futtatjuk a programot, azt fogja kiírni, hogy ab, ahogy várjuk is. Az az adat, amire a paraméter mutat, már él, amikor az eleje() eljárást végrehajtjuk (hiszen a program, a main() törzsének első sorában már bevezetjük), és csak az egész program lefutása után, a main() törzsének végén fog meghalni.

Azt hiszem, mindeddig ez csodálatosan elegáns és világos. A bonyodalmak akkor kezdődnek, amikor bonyolultabb adatokról kezdünk beszélni, amikor az adatok nem egyszerűen számok vagy karaktersorozatok, hanem olyan összetett adatok, amik tartalmazhatnak akár más adatokra utaló referenciákat is. Ezt próbáljuk meg megérteni a következőkben.

Adatokon belül referenciák

Akárcsak más programozási nyelvek, a Rust is lehetővé teszi, hogy az adatainkat összetett struktúrákba csoportosítsuk, amiknek több komponensük is lehet. (Ezek közül a legegyszerűbbek a sorozatok, pl. tömbök, vektorok, listák; amit a következőkben mondok, rájuk is vonatkozik.) Az ilyen összetett adatszerkezetek maguk is adattípusok lehetnek (a Rust a többivel, például a primitív adattípusokkal egyenrangúnak tekinti őket). A sorozatokon kívül többféle összetett adattípus is létezik, például az, aminek több komponense van egyidejűleg (ezeket struktúrának nevezzük, a Rust nyelvben struct), meg az, aminek többféle komponense lehet, de egyszerre ezek közül csak egy (ezeknek a neve enum). De ez a különbség nem lesz fontos a továbbiakban.

A lényeg az, hogy nem akarjuk kizárni, hogy egy összetett adatszerkezet referenciákat is tartalmazhasson. Például tegyük fel, hogy olyan programot akarok írni, ami bármelyik nyelvről bármelyik másikra fordít. (Nyugalom, nem akarok ilyet írni, és ha akarnék, akkor sem úgy tenném, ahogy a következő példában írom.) Tegyük fel, hogy mindegyik nyelvhez tartozik egy nyelvtan, úgyhogy a fordító eljárás a forrás- és a célnyelv nyelvtanához is hozzáfér:

fn fordito(
forrasnyelvtan: &Nyelvtan,
celnyelvtan: &Nyelvtan,
forditando: &str )
{
// mindegy, hogy mit csinál
}

(Mondanom sem kell, természetes, hogy az eljárásnak csak a nyelvtanokra utaló referenciákat adjuk meg paraméterként, hiszen a nyelvtanok hatalmas adatszerkezetek, és értelmetlen lenne minden alkalommal lemásolni őket, amikor a fordito() eljárást elindítjuk. Vagyis azt akarjuk, hogy az eljárás törzse osztozzon másokkal a máshol megadott adatokon, és a referenciák éppen erre valók.) Mint látható, itt feltételeztem egy Nyelvtan nevű adattípust; a típus példányainak (instanciáinak), a konkrét nyelvtanoknak, amik ebbe a típusba tartoznak, mindent tartalmazniuk kellene, amit egy-egy nyelvről tudunk.

A nyelvtanok típusát nyilván úgy fogom meghatározni, hogy összetett legyen, például olyan komponenseket tartalmazzon, mint a nyelv írásrendszere, a hangalakjainak (fonológia) és a szóalakjainak (morfológia) a szabályszerűségei, a mondatmintái (szintaxis), az alapelemei (szókincs, lexikon) és így tovább. Ezek a komponensek maguk is igen bonyolultak, szóval feltehetően saját adattípusaik lesznek. A Rust nyelven ezt így fejezhetjük ki:

struct Nyelvtan
{
iras: Irasrendszer,
fon: Fonologia,
szin: Szintaxis,
lex: Lexikon
}

ahol az Irasrendszer, a Fonologia, a Szintaxis és a Lexikon olyan adattípusok, amiket majd még definiálnunk kell (itt persze nem fogom ezt megtenni), az iras, fon, szin és lex szimbólumok pedig arra valók, hogy a nekik megfelelő adatokhoz az egész struktúrán belül hozzáférjünk (ezeket a Nyelvtan nevű struktúra tagjainak vagy mezőinek nevezik).

És most jön az egésznek a lényege: Mi történjen, ha valamiért úgy döntök, hogy a konkrét nyelvtanaim (a Nyelvtan nevű típus instanciái) csak referenciákon keresztül utaljanak a komponenseikre (az írásrendszerekre, a fonológiai rendszerre stb.)? Például lehetséges, hogy két különböző nyelvnek ugyanaz az írásrendszere, akkor miért kellene két példányban tárolni ugyanazt? És egyszerűbb lenne, hogy ha az illető írásrendszerben valamit javítanom kell, az mindkét nyelv írásrendszerére automatikusan alkalmazódna.

Ahogy feljebb írtam, jó okunk van rá, hogy megengedjük, hogy az összetett adatok referenciákat is tartalmazhassanak, és a Rust ezt meg is engedi. Ennek a leírásához is a „&” jelet fogjuk használni, de ezen kívül szükségünk lesz még egy kis plusz jelölésre. Elmagyarázom miért.

Az összetett adatok élettartama

Kezdjük megint az aranyszabállyal: Semmilyen referencia nem élheti túl azt az adatot, amire utal. Ez az összetett adatokra is igaz: Ha tovább élnének, mint azok az adatok, amikre a bennük levő referenciák utalnak, akkor nagy bajba kerülhetnénk. Ezért a leírt programban ki kell kötnünk, méghozzá minden referencia esetében, ami az összetett adatainkban szerepel, hogy az adatoknak, amire utalnak, olyan élettartamuknak, hatókörüknek kell lenniük, ami tartalmazza magának az összetett adatnak az élettartamát. Sőt, még azt is jelölhetjük, hogy pontosabban milyen hosszan kell élniük.

Erre mindenkinek ez a természetes reakciója: A programot fordító rendszernek tudnia kell, hogy hogy hol vezetjük be az összetett adatainkat, és azok mikor válnak elérhetetlenné (a blokk végén), szóval maga a fordító is le tudná ellenőrizni, hogy azok az adatok, amikre a benne foglalt referenciák utalnak, túléljék az összetett adatainkat. Akkor mi a fenének kell, ahogy épp említettem, a program szövegében bármit kikötnünk?

Emberek tömegei tették fel már ezt a kérdést maguknak és másoknak. A válasz sajnos bonyolultabb, mint maga a Rust használata, és az ember kísértésbe esik, hogy belemenjen a fordítás folyamatának technikai részleteibe, hogy elmagyarázza. Szerintem elég annyit elmondani, hogy a dolog a fordítás bonyolultságával függ össze, és azzal a céllal, hogy a fordító a lehető legkorábban megtalálja az esetleges hibákat. Közelebbről: Lehetetlen lenne a programokat kisebb, külön lefordított részekből (modulokból, könyvtárakból stb.) összeépíteni, és mégis korán elcsípni a hibákat, ha minden ponton, ahol a fordító összetett adatokkal találkozik, tudnia kellene, hogy a programunk hol máshol használja őket, és hogyan.

Egyébként ugyanez igaz az eljárások deklarálására és definíciójára is. Ott is általában szükséges a plusz jelölés, ha a paramétereik és (vagy) az értékük referenciákat tartalmaz, és ott is ugyanezek az okok. Az eleje() eljárás, amit feljebb példának használtam, azon ritka kivételek egyike, ahol nem feltétlenül szükséges a plusz jelölés, mert a Rust fejlesztői úgy döntöttek, hogy az ilyen esetek annyira gyakoriak, hogy érdemes egy külön elhagyhatósági szabályt bevezetni. (Emlékeztetőül: ott egyszerűen a &str típusnevet használtam a paraméter és az érték típusának a megnevezésére, plusz jelölés nélkül.)

Az élettartamok jelölése

Ha az olvasó alapismeretekkel rendelkezik a Rust nyelvről, akkor valószínűleg hallott már a generikus típusokról, mert ezek lényeges részei a nyelvnek. Hasonlítanak a C++ nyelv „template”-jeiben szereplő típusváltozókhoz. Maguk a Rust generikus eljárásai és adattípusai is hasonlítanak a C++ templátumaihoz. Még a leírási módjuk is hasonlít: az eljárások és adattípusok neve után vesszőkkel elválasztva egy listában lehet megadni a generikus típusokat, az egész listát a „<” és „>” jelekkel körülvéve. Amikor generikus eljárást vagy adattípust használunk (tehát olyat, aminek a deklarációjához ilyen típuslistát csatoltunk), akkor a programozónak meg kell határoznia, hogy azon a konkrét helyen milyen konkrét típusokat kell a típusváltozók helyébe behelyettesíteni (ezt is instanciálásnak nevezik). Ezenkívül még korlátokat (angolul: bound) is csatolhatunk a típusváltozókhoz (úgy, hogy egy „:” jelet írunk utánuk, és utánaírjuk a korlátot); így korlátozhatjuk, hogy milyen feltételei vannak az instanciálásuknak.

A Rust nyelv tervezői úgy döntöttek, hogy az élettartamok jelölését ebbe a jelölésmódba integrálják. Vagyis azt mondhatjuk, hogy az olyan eljárások és összetett adattípusok megadása, amik referenciákat tartalmaznak, fogalmilag szintén „generikus”, abban az értelemben, hogy amikor használjuk őket, instanciálni kell őket. Ahogy feljebb írtam, az élettartamok minden konkrét esetben kiolvashatók a program szövegéből (az illető adat bevezetésétől a blokk végéig tartanak), az instanciálás mechanikus, ezért a fordító el tudja végezni (nagyjából úgy, ahogy legtöbbször a típusváltozók esetében is). Mármint ha az instanciálás egyáltalán lehetséges. Ha nem, az hibát eredményez.

Ahhoz, hogy az instanciálás elvégezhető legyen, helyesen kell jelölni az élettartamokat. Ezek a jelölések, amiket meg kell adnunk, hatókör-paraméterek (pontosabban hatókör-változók, egyetlen kivétellel, mert van közöttük egy, ami konstans). Az értelmezésük a következő: Akármikor, akárhol vezetünk be olyan típusú adatot, amilyet most éppen meghatározunk (vagyis akármikor, akárhol instanciáljuk a típust), vagy írjuk elő az éppen definiált eljárás végrehajtását, annak az instanciának (vagy eljárásnak) az élettartamát magába kell foglalnia annak a hatókörnek, ami a hatókör-paraméter konkrét értéke lesz. Ezt az egyetlen feltételt kell jól megértenünk. A szabályt gyakran így fogalmazzák meg: Lehetségesnek kell olyan értéket rendelni a hatókör-paraméterekhez (a típus instanciálásának vagy az eljárás végrehajtásának helyén), hogy az adatok, amire illető referenciák utalnak, túléljék az instanciaként létrehozott adatokat (vagy az eljárás törzsét).

A hatókör-paraméterek szimbólumok, ami elé aposztrófot („'”) írunk. Az a szokás, hogy 'a, 'b, 'c stb. a nevük. Az egyetlen konstans hatókör-paraméter neve 'static: ez a lehető legnagyobb elképzelhető hatókört jelöli, vagyis az egész program futását, ami a main() nevű eljárás elejétől a végéig tart. Tehát ha egy referenciát úgy adunk meg, hogy a hatóköre a 'static, akkor a program egész futása alatt életben kell maradnia. Ezeket tulajdonképpen a fordító vezeti be. Ezért a konstansok (például a karaktersorozat-konstansok, amiket dupla idézőjellel írunk bele a programba) akkor is ilyen hatókörűek, ha nem közvetlenül a main() eljárás törzsében vezetjük be őket.

Szóval ha olyan Nyelvtan struktúrát szeretnénk, ami olyan, mint a feljebb szereplő, de ami referenciákat tartalmaz más struktúrákra, nem pedig azok másolatainak a „tulajdonosa”, akkor azt így határozhatjuk meg:

struct Nyelvtan<'a>
{
iras: &'a Irasrendszer,
fon: &'a Fonologia,
szin: &'a Szintaxis,
lex: &'a Lexikon
}

Mint látható, a struktúra olyan, mint egy generikus struktúra, de nem generikus típust, hanem az 'a hatókör-paramétert csatoltunk hozzá (egyébként ezt a zárójelen belül még generikus típusok is követhetnék, ha akarnánk). Ezenkívül a benne található referenciákat is ugyanazzal a hatókör-paraméterrel díszítettük fel, ami azt fejezi ki, hogy az adatoknak, amire utalnak, legalább azon a hatókörön belül életben kell lenniük, ami majd az 'a instanciája lesz, ha bárhol bevezetünk egy Nyelvtan típusú struktúrát, és annak a hatókörnek magába kell foglalnia annak a Nyelvtan-instanciának az élettartamát.

Ebben a példában a hatókör-változóval való jelölés elég feleslegesnek látszik: a fordító magától is odatehetné (vagy -képzelhetné) azt az 'a változót. De ha belegondolunk, ez valójában nem igaz. A Nyelvtan struktúrának ez a meghatározása azt köti ki, hogy egyetlen hatókörnek kell léteznie, amit azok az adatok, amikre a struktúrában szereplő referenciák utalnak, mind túlélnek. Ez pedig azt jelenti, hogy a struktúra referenciákat tartalmazó tagjai nem teljesen függetlenek egymástól. A Rust fejlesztői nem láttak okot arra, hogy ez legyen az alapeset, és ezért el lehessen hagyni a hatókör-paramétert. (Valójában még akkor sem lehet elhagyni, ha az összetett adatunk csak egyetlen referenciát tartalmaz, pedig akkor igazán redundáns a hatókör-változó. De mostanáig a fejlesztők nem akartak egy ilyen elhagyhatósági szabályt bevezetni, mert ez annyira nem gyakori eset.)

Ezzel szemben könnyen lehetséges olyan eset, amikor az összetett adatban szereplő referenciák teljesen függetlenek egymástól (abban az értelemben, hogy az adatok, amire utalnak, egymástól független élettartamúak). Ebben az esetben a Nyelvtan típust így kellene meghatároznunk:

struct Nyelvtan<'a, 'b, 'c, 'd>
{
iras: &'a Irasrendszer,
fon: &'b Fonologia,
szin: &'c Szintaxis,
lex: &'d Lexikon
}

Továbbá még azt is kifejezhetjük, hogy az illető adatok élettartama között mindenféle függőség van, méghozzá úgy, hogy korlátokat adunk meg az élettartam-paraméterekhez, olyasféle formában, ahogy a típusváltozók mellett is megadhatunk korlátokat. Például ezt is írhatjuk:

struct Nyelvtan<'a, 'b: 'a, 'c: 'a, 'd: 'b>
{
iras: &'a Irasrendszer,
fon: &'b Fonologia,
szin: &'c Szintaxis,
lex: &'d Lexikon
}

Ezt a következőképpen kell kiolvasni. A négy hatókör, amivel az 'a, 'b, 'c és 'd hatókör-paramétert instanciálni lehet, nem teljesen független egymástól. Akármi lesz is a 'b-nek megfelelő hatókör, annak tágabbnak kell lennie az 'a-val jelöltnél (és a 'c-nek is), és ugyanilyen kapcsolatnak kell lennie a 'd és a 'b instanciája között is. (Egyébként típusváltozóknak is lehet hatókör-paraméter a korlátai között.)

Csak elég bonyolult példákkal tudnám illusztrálni ennek a kifinomult eszköztárnak a használatát. De aki az eddigieket figyelmesen elolvasta, tudni fogja, amikor szembesül vele, hogy olyan finom különbségtételeket kell alkalmaznia, mint az élettartamok közötti függőségek. Nekem mindenesetre szólnom kellett róluk. Nagyon valószínű, hogy sosem kell majd: a legtöbb esetben az élettartamokról szóló alapismeretek elegendőek a feladatok megoldására. A saját tapasztalatom az, hogy ha a dolog tényleg nagyon bonyolulttá kezd válni, az majdnem biztosan azt jelenti, hogy valamit túlkomplikáltam valamit, amit sokkal egyszerűbben is meg lehetne oldani, ezért a legjobb megoldás, ha az egészet elölről kezdem.

Kapcsolódó cikkek a Qubiten:

link Forrás
link Forrás