Hataları Yönetmek

Hataları Yönetmenin Temelleri

Eğer soru işareti operatörünü kullanmazsanız Rust'ta hata yönetimi epey sıkıcı olabilir. Ancak bunu yapabilmek için bazen herhangi bir hatayı barındırabilecek bir Result tipi oluşturabilmemiz gerekir. Bütün hatalar std::error::Error dönebildiğine göre herhangi bir hatayı Box<Error> ile gösterebiliriz.

Düşünün ki hem girdi/çıktı işlemlerinden gelen hatayı hem de karakter dizisini sayıya çevirmekten gelen hatayı aynı fonksiyon içinde dönmek istiyoruz:


#![allow(unused)]
fn main() {
// box-error.rs
use std::fs::File;
use std::io::prelude::*;
use std::error::Error;

fn run(file: &str) -> Result<i32,Box<Error>> {
    let mut file = File::open(file)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents.trim().parse()?)
}
}

Burada girdi/çıktı işlemleri için iki farklı soru işareti operatörü kullanılıyor (Ya dosya açılamazsa? Ya Stringe çevrilemezse?) ve bir de çeviri için ayrıca bir soru işareti operatörü kullanıyoruz. En sonunda da sonucu Ok ile dönüyoruz. Rust, parse üzerinden dönen i32 tipi ile çalışabilir.

Result tipi için bir kısayolu oluşturmak da oldukça olaydır:


#![allow(unused)]
fn main() {
type BoxResult<T> = Result<T,Box<Error>>;
}

Ancak bizim programımızın kendisine özgü hata tipleri olacağı için kendi hata tiplerimizi hazırlamamız gerekecek. Bunun için gereken malzemeler:

  • Tercihen Debug
  • Bir tutam Display
  • Ve son olarak, olmazsa olmazımız Error

Eğer bunlar olmazsa hata tipiniz kafası nasıl eserse öyle çalışabilir.


#![allow(unused)]
fn main() {
// error1.rs
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct MyError {
    details: String
}

impl MyError {
    fn new(msg: &str) -> MyError {
        MyError{details: msg.to_string()}
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f,"{}",self.details)
    }
}

impl Error for MyError {
    fn description(&self) -> &str {
        &self.details
    }
}

// a test function that returns our error result
fn raises_my_error(yes: bool) -> Result<(),MyError> {
    if yes {
        Err(MyError::new("borked"))
    } else {
        Ok(())
    }
}

Sürekli sürekli Result<T, MyError> yazmak biraz yorucu olduğu için çeşitli Rust modüllerinin kendi Result tipleri vardır. Mesela Result<T,io::Error> yazmak yerine io::Result<T> yazabilirsiniz.

Sonraki örneğimizde çevrilemeyecek bir karakter dizisini ondalıklı sayıya çevirirken karşımıza çıkan hatayı kontrol etmemiz gerekecek.

Şimdiye kadar ? ile elimizdeki ifadenin bir hata dönüp dönemeyeceğine bakarak çalıştı. Bunu belirleyen özellik (trait), From özelliğidir. Box<Error> ise From'a sahip bütün Error tiplerini kabul eder.

Devam etmeden önce kendi yarattığınız BoxResult isimlendirmesini kullanabilir ve her şeyi tek elde toplayabilirsiniz, bu kendi yarattığımız hata tipini Box<Error>'a döndürecektir. Ufak uygulamalar için pekâlâ iyi bir tercih olabilir. Ancak ben size diğer hata türlerini kendi hata türümüze dâhil edebileceğiniz daha iyi bir örnek göstereceğim.

ParseFloatError, Error'u içerdiğinden dolayı kendi içinde description()'un da tanımlanmış olması gerek.


#![allow(unused)]
fn main() {
use std::num::ParseFloatError;

impl From<ParseFloatError> for MyError {
    fn from(err: ParseFloatError) -> Self {
        MyError::new(err.description())
    }
}

// and test!
fn parse_f64(s: &str, yes: bool) -> Result<f64,MyError> {
    raises_my_error(yes)?;
    let x: f64 = s.parse()?;
    Ok(x)
}
}

İlk ? için pek bir olay yok. (From ile her tip kendisine dönüştürülür.) İkinci ? ise ParseFloatError hatasını MyError'a çevirir.

Ve sonuç:

fn main() {
    println!(" {:?}", parse_f64("42",false));
    println!(" {:?}", parse_f64("42",true));
    println!(" {:?}", parse_f64("?42",false));
}
//  Ok(42)
//  Err(MyError { details: "borked" })
//  Err(MyError { details: "invalid float literal" })

Birazcık işi yokuşa sürse de hiç de karmaşık değil. İşin tadını kaçıran kısım yazdığımız From dönüşümleri yazdığımız hataların bizim MyError ile iyi anlaşması gerektiği - ya da bunları hiç düşünmeyin Box<Error> kullanın olsun bitsin. Yeni başlayanlar tek bir şeyi birden fazla yapabilmenin yolunu görünce kafaları karışır. Bir avakadoyu soymanın (ya da yeterince manyaksanız bir kediyi yüzmenin) her zaman başka bir yolu vardır. Bu esnekliğin bedeli birden çok seçeneğe sahip olmaktır. Hata kontrolü 200 satırlık bir program için büyük bir programdan daha basit olabilir. Ve eğer bu kıymetli kod atıklarınızı bir Cargo paketine dönüştürmek isterseniz hata işleme çok daha kıymetli bir hâle gelir.

Şu an için soru işareti operatörü yalnızca Result için çalışmakta, Option için değil, ve bu bir özelliktir, bir kısıtlama değil. Option tipinin ok_or_else isminde kendisini Result'a dönüştüren bir metotu vardır. Mesela, düşünün ki bir HashMap'ta aradığımız anahtar bulunmuyor:


#![allow(unused)]
fn main() {
let val = map.get("my_key").ok_or_else(|| MyError::new("my_key not defined"))?;
}

Şimdi hatamız gayet anlaşılır bir şekilde dönmüş oldu! (Bu form içerisinde bir kapama kullanılıyor, böylece buradaki hata ancak gerekirse yaratılırsa olacak.)

Çeviri Notu: Yazarın dediğine karşın, daha sonra soru işareti operatörü Option tipine dönüşebilir bir şekilde güncellendi. Yani artık şu kullanım geçerlidir:

let val = map.get("my_key")?

get metotundan dönen None değerini olduğu gibi ya da bir hata dönmek size kalmış. İki durumunda kendince artıları ve eksileri var. Yazarın bahsettiği gibi, Rust'ta bir şeyi yapmanın birden fazla yolu var. Daha fazla bilgi için Rust'ın referans kitabını ya da kutsal kitabı inceleyebilirsiniz.

error-chain ve hatalarla baş etme sanatı

Çeviri notu: Acı bir şekilde bahsetmeliyim ki, error-chain isimli sandık 2019 gibi tedavülden kaldırıldı. Bunun yerine failure isminde alternatif bir sandığa kişiler yönlendirilmiş ancak o da 2020 yılı gibi tedavülden kaldırılmış. Benzer konseptleri sağlayan iki sandık var:

  • Anyhow: Kabaca hataların tipi ne olursa olsun yönlendirebileceğiniz bir tip sunuyor.
  • thiserror: Bir yapıyı derive(Error) gibi basit bir şekilde hata tipine dönüştürmeye yarar.

Yazının gerisini hem orijinal metni korumak hem belli başlı konseptleri tanıtmak hem de hata yönetiminin geçmişini göstermek için çeviriyorum.

Önemsiz olmayan uygulamalar için error_chain sandığına göz atmalısınız. Minik bir makro bu kadar faydalı olabilir.

cargo new test-error-chain komutuyla çalıştırılabilir bir sandık oluşturun ve oluşturulan dizinin içine girin. Cargo.toml'un sonuna error-chain="0.8.1"'i ekleyin.

error-chain'in olayı elle tek tek yazmanız gereken hata tiplerinin tanımlarını sizin yerinize yazmaktır; yapılar oluşturmak ve Display, Debug, Error gibi bir hata tipi yaratmak için kullanılan özellikleri eklemek. Aynı zamanda öntanımlı olarak From özelliği de dâhil edilir ki normal karakter dizileri de hatalara dönüştürülebilirler.

İlk src/main.rs dosyamız alttakine benzeyecektir. Main içerisinden run fonksiyonu çağrılıyor, hataları satır satır yazıyor ve programı sıfır olmayan bir çıkış kodu ile program sonlandırılıyor. Hepsi bu. error_chain makrosu ile bütün gerekli tanımlar üretilmiş olacaktır, errors modülünü gerekirse daha büyük programlarda kendi dosyasına yerleştirebilirsiniz. errors modülünü global kapsama dağıtmamız gerekti çünkü kodumuzun üretilmiş özellikleri (trait) görebilmesi gerekliydi. Varsayılan olarak, bir adet Error yapısı ve bu hatayla birlikte Result tanımlanacaktır.

Burada foreign_links kullanarak std::io::Error'ün From kullanarak bizim istediğimiz hata tipine dönüşmesini sağlıyoruz

#[macro_use]
extern crate error_chain;

mod errors {
    error_chain!{
        foreign_links {
            Io(::std::io::Error);
        }
    }
}
use errors::*;

fn run() -> Result<()> {
    use std::fs::File;

    File::open("file")?;

    Ok(())
}


fn main() {
    if let Err(e) = run() {
        println!("error: {}", e);

        std::process::exit(1);
    }
}
// error: No such file or directory (os error 2)

foreign_links hayatımızı oldukça kolaylaştırdı zira soru işareti operatörü artık std::io::Error'u nasıl error::Error'a dönüştüreceğini biliyor. (Kaputun altında tam da gerektiği gibi makromuz Form<std::io::Error> dönüşümü tanımlıyor.)

Bütün olay run içerisinde dönüyor; şimdi programa ilk argüman olarak verilen dosyanın ilk on satırını yazdırmayı deneyelim. Ortada verilmiş herhangi bir argüman olmayabilir, bunu bilemeyiz. Tek gereken şey Option<String>'i bir Result<String>'e dönüştürebilmek. Bunu yapabilmek için iki Option metotumuz var ve ben en basit olanını seçtim. Error tipimiz &str için From'u içerdiğinden basitçe bir karakter dizisiyle yeni bir hata oluşturabiliriz.


#![allow(unused)]
fn main() {
fn run() -> Result<()> {
    use std::env::args;
    use std::fs::File;
    use std::io::BufReader;
    use std::io::prelude::*;

    let file = args().skip(1).next()
        .ok_or(Error::from("provide a file"))?;

    let f = File::open(&file)?;
    let mut l = 0;
    for line in BufReader::new(f).lines() {
        let line = line?;
        println!("{}", line);
        l += 1;
        if l == 10 {
            break;
        }
    }

    Ok(())
}
}

bail! isminde hata "fırlatmak" için kullanılan küçük ama etkili makromuzu da görelim. Bunun yerine ok_or kullanabilirdiniz:


#![allow(unused)]
fn main() {
   let file = match args().skip(1).next() {
       Some(s) => s,
       None => bail!("provide a file")
   };
}

Tıpkı ? gibi fonksiyondan erken döner. (early return)

Dönen hata içeriğinde ErrorKind isimli bir enum barındırır, bu bizi farklı türlü hataları seçebilmemizi sağlar. (Error::from(str) şeklinde oluşturduğunuz) her hata Msg isimli varyantla eşleşir. Foreign_links ile tanımladığımız Io ise Girdi/Çıktı hatalarıyla eşleşir:

fn main() {
    if let Err(e) = run() {
        match e.kind() {
            &ErrorKind::Msg(ref s) => println!("msg {}",s),
            &ErrorKind::Io(ref s) => println!("io {}",s),
        }
        std::process::exit(1);
    }
}
// $ cargo run
// msg provide a file
// $ cargo run foo
// io No such file or directory (os error 2)

Yeni tür hatalar eklemek de oldukça basittir. error_chain içerisine errors isimli bir kısım ekleyin:


#![allow(unused)]
fn main() {
    error_chain!{
        foreign_links {
            Io(::std::io::Error);
        }

        errors {
            NoArgument(t: String) {
                display("no argument provided: '{}'", t)
            }
        }

    }
}

Bu oluşturduğumuz yeni tür hata için Display'ın nasıl çalışacağını tanımlar. Ve şimdi "argüman yok" tarzı hataları daha spesifik bir şekilde tanımlamış olduk, ErrorKind::NoArgument'e bir String değeri verebiliriz:


#![allow(unused)]
fn main() {
    let file = args().skip(1).next()
        .ok_or(ErrorKind::NoArgument("filename needed".to_string()))?;
}

Şimdi eşleştirmeniz gereken yeni bir ErrorKind daha var:

fn main() {
    if let Err(e) = run() {
        println!("error {}",e);
        match e.kind() {
            &ErrorKind::Msg(ref s) => println!("msg {}", s),
            &ErrorKind::Io(ref s) => println!("io {}", s),
            &ErrorKind::NoArgument(ref s) => println!("no argument {:?}", s),
        }
        std::process::exit(1);
    }
}
// cargo run
// error no argument provided: 'filename needed'
// no argument "filename needed"

Genellikle mümkün olduğunca hataları spesifikleştirmek daha kullanışlıdır, bilhassa bu bir kütüphane fonksiyonuysa! Bu türe göre eşleştirme tekniği geleneksel hata yönetimine oldukça benzer, sadece burada except ya da catch blokları yerine eşleştirme yöntemlerini kullanıyoruz.

Sonuç olarak, error-chain sizin yerinize bir Error tipi oluşturur ve Result<T>'i std::result::Result<T, Error> olarak tanımlar. Error ise içeriğinde ErrorKind isimli bir enum barındırır ve varsayılan olarak karakter dizilerinden oluşan hatalarla eşleşen Msg'i barındırır. Harici hataları da iki farklı iş yapan foregin_links ile tanımlayabilirsiniz. Birincisi, yeni bir ErrorKind varyantı oluşturabilirsiniz. İkincisi dış hataları kendi hatamıza çeviren From tanımlarını hazırlar. Böylece kolaylıkla çeşitli hata türleri eklenebilir hâle gelir. Böylece artık kalıplaşmış olan pek çok koddan kurtulmuş oluyoruz.

Hataları Zincirlemek

Bu sandığın esas güzelliği hataları zincirlemek.

Bir kütüphane kullanıcısı olarak sadece gelişigüzel bir girdi/çıktı almak biraz can sıkar. Tamam, bir dosyayı açamadık. Ama hangi dosya? En basitinden, benim için önemli olan nokta nedir?

error_chain (Tr: Hata zinciri) bu tarz aşırı genelleme sorununa karşı hata zincirleme çözümünü sunar. Dosyayı açmak istediğimiz zaman tembelce ? kullanma alışkanlığımıza devam edebilir ve io::Error'a dönüştürebiliriz, ya da hatayı zincirleyebiliriz.


#![allow(unused)]
fn main() {
// non-specific error
let f = File::open(&file)?;

// a specific chained error
let f = File::open(&file).chain_err(|| "unable to read the damn file")?;
}

Şimdi programımızın foreign_links kullanmadan yazılan yeni bir versiyonuna bakalım.

#[macro_use]
extern crate error_chain;

mod errors {
    error_chain!{
    }

}
use errors::*;

fn run() -> Result<()> {
    use std::env::args;
    use std::fs::File;
    use std::io::BufReader;
    use std::io::prelude::*;

    let file = args().skip(1).next()
        .ok_or(Error::from("filename needed"))?;

    ///////// chain explicitly! ///////////
    let f = File::open(&file).chain_err(|| "unable to read the damn file")?;

    let mut l = 0;
    for line in BufReader::new(f).lines() {
        let line = line.chain_err(|| "cannot read a line")?;
        println!("{}", line);
        l += 1;
        if l == 10 {
            break;
        }
    }

    Ok(())
}


fn main() {
    if let Err(e) = run() {
        println!("error {}", e);

        /////// look at the chain of errors... ///////
        for e in e.iter().skip(1) {
            println!("caused by: {}", e);
        }

        std::process::exit(1);
    }
}
// $ cargo run foo
// error unable to read the damn file
// caused by: No such file or directory (os error 2)

Görmüş olduğunuz üzere chain_err metotu orijinal hatayı alıyor ve orijinal hatayı barındıran yeni bir hata yaratıyor - bu böyle sonsuza kadar gider. İlgili kapamalar hataya dönüştürülebilen herhangi bir veri dönebilir.

Rust makroları sizi pek çok şey yazmaktan kurtarabilir. error-chain'in main yerine geçebilecek ayrı bir makrosu bile vardır:

quick_main!(run);

(Zaten run bütün olayın gerçekleştiği yerdir.)