Mi az a const fn és miért éri meg fordítási időben számolni?

Ha eddig csak fn-eket írtál, a const fn első ránézésre furcsának tűnhet: ugyanaz a szintaxis, csak egy extra kulcsszóval. A lényeg viszont óriási: egy const fn-t a fordító képes fordítási időben kiértékelni, ha a hívás konstans kontextusban történik (pl. const vagy static deklarációban, tömbméretben, vagy generic const paraméterben).

Ez nem csak elméleti csemege. Ha egy értéket fordítási időben tudsz kiszámolni, akkor:

  • Nulla futásidejű költség – a bináris már a végeredményt tartalmazza, nincs futásidejű ciklus.
  • Korábbi hibadetektálás – ha a számítás panicol (pl. túlcsordulás), azt a cargo build már jelzi, nem kell futtatni a programot.
  • Beágyazott/no_std környezetben különösen hasznos, ahol minden bájt és minden CPU-ciklus számít.
const fn square(x: u32) -> u32 {
    x * x
}

const NINE: u32 = square(3);

fn main() {
    println!("{NINE}");
}

Ez a NINE érték már a fordított binárisban készen van, a square függvény futásidőben soha nem hívódik meg.

Megjegyzés

Egy const fn-t futásidőben is hívhatsz normál függvényként, ha épp nem konstans kontextusban van szükség rá. A const kulcsszó tehát nem korlátoz, hanem lehetőséget ad a fordítónak.

A korábbi lépésszám-korlát és a fájdalmai

A const kiértékelő motor (a rustc MIR interpretere) régóta rendelkezett egy belső lépésszám-korláttal. Ez arra volt jó, hogy egy véletlenül végtelen ciklusba futó const fn ne fagyassza le a fordítást a végtelenségig – helyette egy idő után a fordító feladta, és hibát dobott: evaluation of constant value failed vagy a hírhedt reached the configured maximum number of steps.

A probléma az volt, hogy ez a limit fix és viszonylag alacsony volt, és nem skálázódott jól a valós, hasznos munkával. Ha valaki próbált:

  • egy nagyobb lookup táblát generálni (mondjuk 256 vagy 1024 elemű CRC-táblát),
  • egy komplexebb parser-szerű validációt futtatni fordítási időben,
  • vagy csak egy kicsit nagyobb ciklust írni egy const fn-ben,

könnyen belefutott a limitbe, még akkor is, ha a számítás objektíve gyors lett volna. Az egyetlen kiút a nightly-only #[rustc_const_eval_limit(...)] belső attribútum volt, ami stabil Rust-ban nem volt elérhető – tehát stable toolchain-en egyszerűen nem volt jó megoldás.

Figyelem

Ha korábban belefutottál ebbe a hibába, és emlékeid szerint teljesen normális méretű ciklusnál dobta be a törölközőt, nem hibáztál – ez egy ismert, gyakran panaszolt korlátozás volt a közösségben.

Mi változott a Rust 1.72-ben?

A 1.72-es kiadásban a Rust csapata eltávolította a hard-coded lépésszám-korlátot a const kiértékelőből. Ez nem jelenti azt, hogy a fordítás mostantól garantáltan véges idő alatt lefut minden esetben – egy tényleg végtelen ciklus továbbra is végtelen ciklus marad, csak most nem egy mesterséges szám (mondjuk pár millió lépés) után áll meg a fordító hibaüzenettel, hanem addig fut, amíg a számítás valóban véget nem ér, vagy amíg a fordítási folyamat más erőforrás-korlátba (memória, idő) nem ütközik.

A gyakorlatban ez azt jelenti, hogy a korábban limitbe futó, de véges és hasznos számítások most simán lefordulnak:

// Korábban ez könnyen belefuthatott a lépésszám-korlátba,
// most simán lefordul 1.72-től.
const fn heavy_sum() -> u64 {
    let mut acc: u64 = 0;
    let mut i: u64 = 0;
    while i < 2_000_000 {
        acc = acc.wrapping_add(i);
        i += 1;
    }
    acc
}

const RESULT: u64 = heavy_sum();

fn main() {
    println!("{RESULT}");
}

A két milliós ciklus korábban több scenárióban simán elérte volna a limitet (a pontos szám a MIR-lépések számától függött, nem a forráskód sorainak számától), most viszont gond nélkül lefut a cargo build alatt.

Tipp

A fordítási idő természetesen nő, ha nagyon nagy számítást pakolsz const kontextusba. Ez nem ingyen van – csak most már te döntheted el, hogy megéri-e, nem egy mesterséges korlát dönt helyetted.

Gyakorlati példa: lookup táblák generálása fordítási időben

Ez a változás pontosan azoknak a use case-eknek kedvez, ahol korábban kompromisszumot kellett kötni: vagy build script-et írtál (ami generál egy .rs fájlt), vagy a táblát futásidőben építetted fel lazy_static/OnceCell segítségével, vagy csak kézzel beírtad a konstansokat egy tömbbe.

Nézzünk egy valós, gyakran előforduló esetet: egy CRC-8 lookup tábla generálása. Ez klasszikus embedded/protokoll-kódban felmerülő probléma, és pont az a fajta 256 elemű, egyenként nem trivális számítás, ami korábban simán elérte a régi limitet.

const fn crc8_table() -> [u8; 256] {
    let mut table = [0u8; 256];
    let mut i = 0usize;

    while i < 256 {
        let mut crc = i as u8;
        let mut j = 0;
        while j < 8 {
            if crc & 0x80 != 0 {
                crc = (crc << 1) ^ 0x07;
            } else {
                crc <<= 1;
            }
            j += 1;
        }
        table[i] = crc;
        i += 1;
    }

    table
}

const CRC8_TABLE: [u8; 256] = crc8_table();

fn crc8(data: &[u8]) -> u8 {
    let mut crc = 0u8;
    for &byte in data {
        crc = CRC8_TABLE[(crc ^ byte) as usize];
    }
    crc
}

fn main() {
    let checksum = crc8(b"hello, rust.hu!");
    println!("CRC-8: {checksum:#04x}");
}

Figyeld meg, hogy a crc8_table egy sima while ciklust használ – ez fontos, mert a for ciklus (ami az Iterator trait-re épül) még nem használható const kontextusban, mivel a trait metódusok általánosan nem const-ok. A belső ciklusban mutálható lokális tömböt (table) és mutable local változókat (crc, j, i) használunk, amit a Rust 1.61 óta enged a const kiértékelő.

A CRC8_TABLE konstans már a bináris része lesz – a futásidejű crc8 függvény csak egy tömb-indexelést végez, semmi mást. Ez pontosan az a nulla-overhead absztrakció, amiért a Rust-ot szeretjük.

Jó tudni

A lépésszám-korlát eltűnése nem jelenti azt, hogy minden algoritmust érdemes const fn-be pakolni. Ha a számítás valóban nagy (mondjuk egy nagy prímteszt-táblát generálnál milliós elemszámmal), a fordítási idő drasztikusan megnőhet. Mérd meg a cargo build --timings kimenetét, mielőtt production kódba kerül.

Korlátok, amik továbbra is érvényesek

Fontos leszögezni, hogy a lépésszám-korlát eltűnése nem jelenti azt, hogy a const fn-ek mindenre képesek lettek 1.72-ben. Amik továbbra sem mennek:

  • for ciklusok const kontextusban – az Iterator trait metódusai nem const-stabilak, így a for x in 0..10 szintaxis nem fordul const fn-ben (helyette while ciklust kell írni, ahogy a fenti példákban is tettük).
  • Heap allokáció – nincs Vec::push, nincs dinamikus String építés const kontextusban. A Vec::new() és String::new() const, de a bővítésükhöz szükséges allokátor-hívások nem.
  • Trait metódusok általánosan – csak azok a metódusok hívhatók const fn-ből, amiket a standard könyvtár explicit módon const-stabillá tett (pl. bizonyos u32, i64 aritmetikai metódusok). Egy saját trait-ed metódusát alapból nem hívhatod const kontextusban.
  • Dinamikus dispatch (dyn Trait) – ez a futásidejű polimorfizmus eszköze, fogalmilag nem fér össze a fordítási idejű kiértékeléssel.
  • Lebegőpontos aritmetika bizonyos műveletei – a determinizmus (platformfüggő kerekítés) miatt a float-ok const kontextusú kezelése továbbra is korlátozott, érdemes egész aritmetikára építeni, ha lehet.

Ezen felül a klasszikus szabályok is érvényben maradnak: unsafe blokk const fn-ben csak korlátozottan használható, és a panic! ugyan stabil const kontextusban (Rust 1.57 óta), de a panic-oló const kiértékelés fordítási hibát eredményez – ami tulajdonképpen egy elegáns módja a compile-time assertálásnak.

const fn assert_even(x: u32) -> u32 {
    if x % 2 != 0 {
        panic!("x-nek párosnak kell lennie!");
    }
    x
}

const FOUR: u32 = assert_even(4);
// const FIVE: u32 = assert_even(5); // ez fordítási hibát dobna

Ha amúgy is const fn-ekkel dolgozol, érdemes megnézni a nemrég stabilizált let-else szintaxist is – jól passzol az early-return jellegű validációs logikákhoz, bár maga a let-else nem const-specifikus feature.

Összefoglalás

A Rust 1.72 const-eval limit eltűnése nem egy hangos, marketingfotós feature, de azoknak, akik komolyan használják a const fn-t – lookup táblák, checksum-táblák, statikus validációk generálására –, ez egy valódi minőségi ugrás. Nem kell többé mesterséges korlátokkal bokszolni, csak azért, hogy a fordítási idejű számításod egy kicsit nagyobb legyen a szokásosnál. A const fn ökoszisztéma amúgy is folyamatosan bővül (mutable local const kontextusban, panic const kontextusban, és most a lépésszám-korlát eltűnése), így ha még nem próbáltad ki, most van itt az ideje, hogy compile-time-ba mozgasd a futásidejű számításaidat, ahol csak lehet.