Sökresultat för

När Rust får panik gör C++ ett undantag

31 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
När Rust får panik gör C++ ett undantag

Om du frågar någon som inte är en rutinerad Rust-programmerare vad en panic! är, är svaret nästan alltid "Rusts motsvarighet till en C++-exception". Rimlig gissning kanske — båda avbryter det normala flödet, båda avvecklar (rullar tillbaka) stacken (eng. unwind), båda kan krascha programmet om inget fångar dem. Jag tänkte utgå från just den uppfattningen och se hur långt det resonemanget håller.

Fyra jämförelser, i tur och ordning:

  1. panic! kontra throw — samma sorts krasch
  2. Drop respektive destruktor — städning under stackavveckling
  3. En panic i en tråd (thread) stannar i tråden
  4. panic ligger närmare terminate än throw

...och på vägen dit landar vi i att uppfattningen ovan var fel på ett intressant sätt, inte bara ett pedantiskt dito.


1. panic! kontra throw — samma sorts krasch

Börjar vi där uppfattningen stämmer bäst. En funktion som vägrar fortsätta på grund av felaktig indata, och inget som fångar upp det:

int parse_positive(int n) {
    if (n < 0) {
        throw std::invalid_argument("expected a non-negative number, got " + std::to_string(n));
    }
    return n;
}

int main() {
    std::cout << "parsed: " << parse_positive(5) << std::endl;
    std::cout << "parsed: " << parse_positive(-3) << std::endl;
    std::cout << "unreachable" << std::endl;
}
$ g++ -std=c++23 -Wall -Wextra parse.cxx -o parse-c++ && ./parse-c++
parsed: 5
terminate called after throwing an instance of 'std::invalid_argument'
  what():  expected a non-negative number, got -3

Processen avslutas med exitkod 134 (SIGABRT). std::invalid_argument propagerar ut ur main ofångad, std::terminate() kallas, programmet terminerar abnormalt (eng. abort).

fn parse_positive(n: i32) -> i32 {
    if n < 0 {
        panic!("expected a non-negative number, got {n}");
    }
    n
}

fn main() {
    println!("parsed: {}", parse_positive(5));
    println!("parsed: {}", parse_positive(-3));
    println!("unreachable");
}
$ rustc --edition 2024 parse.rs -o parse-rust && ./parse-rust
parsed: 5

thread 'main' (19873) panicked at parse.rs:3:9:
expected a non-negative number, got -3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Exitkod 101. Precis samma form: flödet avbryts, processen dör med ett felmeddelande som pekar rakt på källan. Hittills stämmer bilden av panic! som "Rusts throw" perfekt.

2. Drop respektive destruktor — städning under stackavveckling

Nästa fråga: om något faktiskt fångar felet, städas resurserna upp på vägen ut? En Trace-typ som loggar när den skapas och när den städas, två anrop djupt, där det innersta anropet kastar/panikar och något längst upp fångar det.

struct Trace {
    std::string name;
    explicit Trace(std::string n) : name(std::move(n)) {
        std::cout << "  enter " << name << std::endl;
    }
    ~Trace() {
        std::cout << "  exit " << name << std::endl;
    }
};

void inner() {
    auto t = Trace("inner");
    throw std::runtime_error("something went wrong in inner()");
}

void outer() {
    auto t = Trace("outer");
    inner();
}

int main() {
    try {
        outer();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << std::endl;
    }
    std::cout << "still running" << std::endl;
}
$ g++ -std=c++23 -Wall -Wextra unwind.cxx -o unwind-c++ && ./unwind-c++
  enter outer
  enter inner
  exit inner
  exit outer
caught: something went wrong in inner()
still running

Destruktorerna körs i omvänd ordning mot konstruktionen medan stacken avvecklas, sen körs catch-blocket, sen fortsätter programmet normalt.

struct Trace {
    name: &'static str,
}
impl Trace {
    fn new(name: &'static str) -> Self {
        println!("  enter {name}");
        Trace { name }
    }
}
impl Drop for Trace {
    fn drop(&mut self) {
        println!("  exit {}", self.name);
    }
}

fn inner() {
    let _t = Trace::new("inner");
    let reason = "something went wrong in inner()";
    panic!("{reason}");
}

fn outer() {
    let _t = Trace::new("outer");
    inner();
}

fn main() {
    let result = panic::catch_unwind(|| outer());
    match result {
        Ok(()) => println!("no panic"),
        Err(payload) => {
            let reason: Option<&String> = payload.downcast_ref();
            match reason {
                Some(reason) => println!("caught: {reason}"),
                None => println!("caught: unknown panic"),
            }
        }
    }
    println!("still running");
}
$ rustc --edition 2024 unwind.rs -o unwind-rust && ./unwind-rust
  enter outer
  enter inner

thread 'main' (28108) panicked at unwind.rs:23:5:
something went wrong in inner()
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  exit inner
  exit outer
caught: something went wrong in inner()
still running

Samma städordning via Drop, samma fortsättning efteråt via std::panic::catch_unwind. Ett kvarvarande gissningsbrott, om man ska vara petig: Rust skriver panic-diagnostiken till stderr oavsett om något faktiskt fångar den. En fångad C++-exception ger inget eget utskrift alls om inte catch-blocket loggar den själv. Men i stort: hittills håller gissningen.

Några rader kod ovan förtjänar en snabb förklaring. std::panic::catch_unwind tar en closure — Rusts motsvarighet till en C++ lambda expression — kör den, och om en panic inträffar under körningen fångas avvecklingen upp istället för att fortsätta propagera. Här är closuren || outer(): inga parametrar, kroppen anropar bara outer och returnerar dess resultat. (outer hade faktiskt dugt som argument på egen hand också, eftersom Rust konverterar en funktion till en closure automatiskt — men det är inte uppenbart om man inte redan vet det, så jag skriver en explicit closure.)

Returtypen är Result<T, Box<dyn Any + Send>>Ok(t) om inget panikade, annars Err(payload) med panic-värdet typraderat i en Box<dyn Any>. För att få tillbaka något utskrivbart måste man fråga payloaden "är du faktiskt den här konkreta typen?" — det är vad downcast_ref gör. Jag har låtit reason-variabelns typannotering, Option<&String>, styra vilken typ det frågas efter, istället för att skriva ut typen i själva anropet — samma resultat, men frågan om panic!-payloadens typ hör egentligen till ett separat ämne (turbofish-syntaxen ::<T>) som får en egen artikel senare i höst. Anledningen till att jag skriver let reason = "..."; panic!("{reason}"); istället för att bara skriva panic!("...") direkt: en panic! med ett rent stränglitteral ger en payload av typen &str, medan en panic! som interpolerar något (som {reason} här, eller {n} i avsnitt 1) går via samma formateringsmaskineri som format!/println! och ger en String istället. Jag föredrar att downcasta till String här, vilket innebär att meddelandet byggs med interpolation och liknar övriga utskrifter i artikeln.

3. En panic i en tråd stannar i tråden

Här börjar det knaka. Tre arbetartrådar, en av dem misslyckas halvvägs.

void worker(int id) {
    std::this_thread::sleep_for((3 - id) * 100ms);
    if (id == 1) {
        throw std::runtime_error("worker 1 hit a bad value");
    }
    std::cout << "worker " << id << " done" << std::endl;
}

int main() {
    auto workers = std::vector<std::thread>{};
    for (int id = 0; id < 3; ++id) {
        workers.emplace_back(worker, id);
    }
    for (auto& t : workers) {
        t.join();
    }
    std::cout << "main: all workers joined" << std::endl;
}
$ g++ -std=c++23 -Wall -Wextra threads.cxx -o threads-c++ -pthread && ./threads-c++
worker 2 done
terminate called after throwing an instance of 'std::runtime_error'
  what():  worker 1 hit a bad value

Exitkod 134. worker 0 (medvetet fördröjd att bli klar sist) hinner aldrig skriva ut sin rad, main hinner aldrig förbi det första join()-anropet — exceptionen som läcker ut ur trådens startfunktion kallar std::terminate(), och det river ner hela processen, övriga trådar inräknat.

fn worker(id: i32) {
    thread::sleep(Duration::from_millis((3 - id as u64) * 100));
    if id == 1 {
        let reason = "worker 1 hit a bad value";
        panic!("{reason}");
    }
    println!("worker {id} done");
}

fn main() {
    let workers: Vec<_> = (0..3).map(|id| thread::spawn(move || worker(id))).collect();

    for (id, handle) in workers.into_iter().enumerate() {
        match handle.join() {
            Ok(()) => println!("main: worker {id} joined normally"),
            Err(payload) => {
                let reason: Option<&String> = payload.downcast_ref();
                match reason {
                    Some(reason) => println!("main: worker {id} panicked: {reason}"),
                    None => println!("main: worker {id} panicked: unknown panic"),
                }
            }
        }
    }
    println!("main: all workers joined");
}
$ rustc --edition 2024 threads.rs -o threads-rust && ./threads-rust
worker 2 done

thread '' (29966) panicked at threads.rs:8:9:
worker 1 hit a bad value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
worker 0 done
main: worker 0 joined normally
main: worker 1 panicked: worker 1 hit a bad value
main: worker 2 joined normally
main: all workers joined

Exitkod 0. worker 0 och worker 2 blir klara. JoinHandle::join() för den panikande tråden ger Err med panic-payloaden istället för Ok(()) — felet stannar i tråden det inträffade i, och main bestämmer själv vad som ska hända med den informationen. Det här är ingen detalj. En ofångad exception i en std::thread är, praktiskt sett, en väg att terminera hela programmet från en enda arbetartråd. Motsvarigheten i Rust terminerar bara en enstaka tråd.

4. panic ligger närmare terminate än throw

Föregående avsnitts C++-krasch och det här avsnittets undersökning hänger ihop: vad händer i C++ när ingen fångar exceptionen, och vad händer i Rust vid samma situation? Samma Trace/inner/outer-program som i avsnitt 2, men utan try/catch respektive catch_unwind.

int main() {
    outer();   // nothing catches this
    std::cout << "unreachable" << std::endl;
}
$ g++ -std=c++23 -Wall -Wextra uncaught.cxx -o uncaught-c++ && ./uncaught-c++
  enter outer
  enter inner
terminate called after throwing an instance of 'std::runtime_error'
  what():  something went wrong in inner()

Exitkod 134. Inga exit-rader — GCC kallar std::terminate() utan att avveckla stacken, så Trace-objektens destruktorer körs aldrig.

fn main() {
    outer(); // nothing catches this
    println!("unreachable");
}
$ rustc --edition 2024 uncaught.rs -o uncaught-rust && ./uncaught-rust
  enter outer
  enter inner

thread 'main' (28206) panicked at uncaught.rs:21:5:
something went wrong in inner()
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  exit inner
  exit outer

Exitkod 101, inte abortad. Trots att inget fångar den avvecklar Rusts standardstrategi ändå och kör Drop på vägen ut — städningen sker oavsett om någon lyssnar. Kompilerar man samma fil med -C panic=abort istället:

$ rustc --edition 2024 -C panic=abort uncaught.rs -o uncaught-abort-rust && ./uncaught-abort-rust
  enter outer
  enter inner

thread 'main' (28234) panicked at uncaught.rs:21:5:
something went wrong in inner()
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Exitkod 134 — samma som C++. Inga exit-rader här heller: med panic=abort sker ingen stackavveckling alls, Drop körs aldrig, processen bara abortar. Det är det här läget som faktiskt matchar C++:s beteende för en ofångad exception. Rusts standardläge, panic=unwind, gör mer jobb än C++:s std::terminate() någonsin gör.

Och det är där gissningen från början av artikeln faller isär på riktigt. panic! är inte designad för att bli fångad och hanterad som ett normalt kontrollflöde — catch_unwind finns, men dokumentationen avråder uttryckligen från att använda den så, av samma skäl man inte skulle bygga applikationslogik ovanpå std::terminate() i C++. En panic betyder "det här är ett programmeringsfel, en bruten invariant, något som aldrig skulle hänt" — närmare ett assert()-fel eller ett ofångat undantag i C++ än ett avsiktligt kastat och fångat throw.


Den riktiga motsvarigheten: Result<T, E> och std::expected<T, E>

Om panic! egentligen svarar mot "programmet är trasigt, avsluta", vad svarar då mot C++:s vanliga användning av exceptions — fel som anroparen förväntas hantera, inte krascha på? Samma uppgift, båda språken: tolka en sträng till ett icke-negativt heltal, dubbla det, rapportera ett felmeddelande annars.

auto parse_number(const std::string& s) -> std::expected<int, std::string> {
    auto value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::unexpected("not a number: " + s);
    }
    if (value < 0) {
        return std::unexpected("must be non-negative: " + s);
    }
    return value;
}

auto compute(const std::string& s) -> std::expected<int, std::string> {
    return parse_number(s).and_then([](int n) -> std::expected<int, std::string> {
        return n * 2;
    });
}

int main() {
    for (const auto& input : {"21", "-5", "abc"}) {
        auto result = compute(input);
        if (result.has_value()) {
            std::cout << std::quoted(input) << " -> " << result.value() << std::endl;
        } else {
            std::cout << std::quoted(input) << " -> error: " << result.error() << std::endl;
        }
    }
}
$ g++ -std=c++23 -Wall -Wextra compute.cxx -o compute-c++ && ./compute-c++
"21" -> 42
"-5" -> error: must be non-negative: -5
"abc" -> error: not a number: abc

std::expected<int, std::string> (C++23) bär felet i returtypen istället för att kasta det. .and_then() kedjar ett steg som kan misslyckas på ett annat, utan ett manuellt if (!result) return ... mellan varje steg.

fn parse_number(s: &str) -> Result<i32, String> {
    let value: i32 = s.parse().map_err(|_| format!("not a number: {s}"))?;
    if value < 0 {
        return Err(format!("must be non-negative: {s}"));
    }
    Ok(value)
}

fn compute(s: &str) -> Result<i32, String> {
    let n = parse_number(s)?;
    Ok(n * 2)
}

fn main() {
    for input in ["21", "-5", "abc"] {
        match compute(input) {
            Ok(n) => println!("{input:?} -> {n}"),
            Err(e) => println!("{input:?} -> error: {e}"),
        }
    }
}
$ rustc --edition 2024 compute.rs -o compute-rust && ./compute-rust
"21" -> 42
"-5" -> error: must be non-negative: -5
"abc" -> error: not a number: abc

Identisk utskrift — {input:?} (Rusts Debug-formatering för &str) sätter citattecken runt en textsträng på samma sätt som std::quoted gör på C++-sidan. parse_number(s)? inuti compute gör samma jobb som .and_then()-kedjan: propagera Err direkt, fortsätt bara på Ok. Ingen panic, ingen throw, ingen stackavveckling någonstans i den här filen — felet är bara ett värde, precis som std::expected gör det till ett värde istället för ett kontrollflödeshopp.


Och vad blir kvar av det?

panic! liknar throw på ytan — båda avbryter flödet, båda avvecklar stacken, båda kan krascha programmet ofångat, och avsnitt 1 och 2 visade att likheten håller ganska långt. Men avsnitt 3 och 4 visar var den faller isär: en C++-exception som läcker ut ur en tråd tar hela processen med sig, en Rust-panic tar med sig tråden den hände i. Och panic=abort avslöjar vad panic! egentligen är designad för — samma sak som std::terminate(), ett nödstopp för brutna invarianter, inte ett verktyg för normal felhantering.

Den jämförelsen man faktiskt vill göra för "fel anroparen förväntas hantera" är Result<T, E> respektive std::expected<T, E> (C++23) — två språk som landat i påfallande lika lösningar på samma problem: gör felet till ett värde i returtypen istället för ett dolt kontrollflöde.

Om något i den här artikeln var värt att ta med sig är det just det: fråga inte "vad är Rusts motsvarighet till en C++-exception", fråga "vad är felet till för" — och svara på den frågan innan du väljer verktyg i endera språket.

Detta var ytterligare ett exempel på vad jag beskrev i min första artikel, nämligen att standardiseringskommittén för C++ aktivt fokuserar på safety och lånar språkelement från Rust.