Modularity এবং Error Handling উন্নত করার জন্য Refactoring

আমাদের প্রোগ্রামটিকে উন্নত করার জন্য, আমরা চারটি সমস্যা সমাধান করব যেগুলির প্রোগ্রামের structure এবং এটি কীভাবে potential error গুলি handle করছে তার সাথে সম্পর্ক রয়েছে। প্রথমত, আমাদের main function এখন দুটি কাজ করে: এটি argument parse করে এবং file read করে। আমাদের প্রোগ্রাম যত বাড়বে, main function-এর handle করা আলাদা কাজের সংখ্যাও বাড়বে। একটি function-এর responsibility যত বাড়ে, সেটি সম্পর্কে reasoning করা, test করা এবং সেটির কোনো একটি অংশ break না করে পরিবর্তন করা তত কঠিন হয়ে পড়ে। Functionality আলাদা করা সবচেয়ে ভালো যাতে প্রতিটি function একটি কাজের জন্য responsible হয়।

এই সমস্যাটি দ্বিতীয় সমস্যার সাথেও জড়িত: যদিও query এবং file_path আমাদের প্রোগ্রামের configuration variable, contents-এর মতো variable গুলো প্রোগ্রামের logic perform করার জন্য ব্যবহৃত হয়। main যত দীর্ঘ হবে, তত বেশি variable আমাদের scope-এ আনতে হবে; আমাদের scope-এ যত বেশি variable থাকবে, প্রতিটির purpose ট্র্যাক রাখা তত কঠিন হবে। Configuration variable গুলোকে একটি structure-এ group করা সবচেয়ে ভালো যাতে তাদের purpose স্পষ্ট হয়।

তৃতীয় সমস্যাটি হল, file read fail করলে আমরা একটি error message print করার জন্য expect ব্যবহার করেছি, কিন্তু error message টি শুধুমাত্র Should have been able to read the file প্রিন্ট করে। File read করার ক্ষেত্রে বিভিন্নভাবে fail হতে পারে: উদাহরণস্বরূপ, file টি missing থাকতে পারে, অথবা আমাদের এটি open করার permission নাও থাকতে পারে। এখন, পরিস্থিতি যাই হোক না কেন, আমরা সবকিছুর জন্য একই error message প্রিন্ট করব, যা user-কে কোনো information দেবে না!

চতুর্থত, আমরা একটি error handle করার জন্য expect ব্যবহার করি, এবং যদি user পর্যাপ্ত argument specify না করে আমাদের প্রোগ্রাম run করে, তাহলে তারা Rust-এর কাছ থেকে একটি index out of bounds error পাবে যা সমস্যাটি স্পষ্ট ভাবে ব্যাখ্যা করে না। Error-handling code-গুলো এক জায়গায় থাকলে সবচেয়ে ভালো হবে, যাতে future-এ maintainer-দের error-handling logic পরিবর্তন করার প্রয়োজন হলে code-এর শুধুমাত্র একটি জায়গাতেই consult করতে হয়। সমস্ত error-handling code এক জায়গায় থাকলে এটাও নিশ্চিত হবে যে আমরা এমন message প্রিন্ট করছি যা আমাদের end user-দের কাছে অর্থপূর্ণ হবে।

আসুন আমাদের project refactor করে এই চারটি সমস্যার সমাধান করি।

বাইনারি প্রোজেক্টের জন্য Separation of Concerns

main function-এ একাধিক কাজের responsibility allocate করার সাংগঠনিক সমস্যাটি অনেক binary project-এর ক্ষেত্রে common। ফলস্বরূপ, Rust community একটি binary program-এর আলাদা concern গুলোকে split করার জন্য guidelines develop করেছে, যখন main বড় হতে শুরু করে। এই process-টিতে নিম্নলিখিত step গুলো রয়েছে:

  • আপনার প্রোগ্রামকে একটি main.rs file এবং একটি lib.rs file-এ split করুন এবং আপনার প্রোগ্রামের logic-কে lib.rs-এ move করুন।
  • যতক্ষণ আপনার command line parsing logic ছোট থাকে, ততক্ষণ এটি main.rs-এ থাকতে পারে।
  • যখন command line parsing logic জটিল হতে শুরু করে, তখন এটিকে main.rs থেকে extract করুন এবং lib.rs-এ move করুন।

এই process-এর পরে main function-এ যে responsibility গুলো থাকা উচিত সেগুলো নিম্নলিখিতগুলির মধ্যে limited হওয়া উচিত:

  • Argument value-গুলো দিয়ে command line parsing logic call করা
  • অন্যান্য configuration set up করা
  • lib.rs-এ একটি run function call করা
  • যদি run কোনো error return করে তাহলে error handle করা

এই pattern-টি concern গুলোকে আলাদা করার বিষয়ে: main.rs প্রোগ্রাম run করা handle করে এবং lib.rs বর্তমান task-এর সমস্ত logic handle করে। যেহেতু আপনি সরাসরি main function test করতে পারবেন না, তাই এই structure আপনাকে lib.rs-এর function গুলোতে move করে আপনার প্রোগ্রামের সমস্ত logic test করতে দেয়। main.rs-এ যে code অবশিষ্ট থাকে তা এতটাই ছোট হবে যে এটি পড়ে এর সঠিকতা verify করা যাবে। আসুন এই process টি follow করে আমাদের প্রোগ্রামটিকে পুনরায় কাজ করি।

Argument Parser-কে Extract করা

আমরা argument parse করার functionality-টিকে একটি function-এ extract করব যাকে main call করবে command line parsing logic-কে src/lib.rs-এ move করার জন্য প্রস্তুত করতে। Listing 12-5 main-এর নতুন start দেখায় যা parse_config নামক একটি নতুন function call করে, যেটি আমরা আপাতত src/main.rs-এ define করব।

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

আমরা এখনও command line argument-গুলোকে একটি vector-এ collect করছি, কিন্তু main function-এর মধ্যে index 1-এ থাকা argument value-টিকে query variable-এ এবং index 2-এ থাকা argument value-টিকে file_path variable-এ assign করার পরিবর্তে, আমরা পুরো vector-টিকে parse_config function-এ pass করি। parse_config function-টিতে এরপর সেই logic থাকে যা নির্ধারণ করে কোন argument কোন variable-এ যাবে এবং value-গুলো main-এ ফেরত পাঠায়। আমরা এখনও main-এ query এবং file_path variable create করি, কিন্তু command line argument এবং variable গুলো কীভাবে correspond করে তা নির্ধারণ করার responsibility আর main-এর নেই।

আমাদের ছোট প্রোগ্রামের জন্য এই rework টি overkill মনে হতে পারে, কিন্তু আমরা ছোট, incremental step-এ refactor করছি। এই পরিবর্তনটি করার পরে, argument parsing এখনও কাজ করছে কিনা তা verify করার জন্য প্রোগ্রামটি আবার run করুন। আপনার progress প্রায়শই check করা ভালো, যাতে সমস্যা দেখা দিলে তার কারণ সনাক্ত করতে সুবিধা হয়।

Configuration Value গুলো Grouping করা

আমরা parse_config function-টিকে আরও উন্নত করার জন্য আরেকটি ছোট step নিতে পারি। এখন, আমরা একটি tuple return করছি, কিন্তু তারপরে আমরা সেই tuple-টিকে আবার individual part-এ ভেঙে দিচ্ছি। এটি একটি লক্ষণ যে সম্ভবত আমাদের এখনও সঠিক abstraction নেই।

আরেকটি indicator যা দেখায় যে উন্নতির জায়গা রয়েছে তা হল parse_config-এর config অংশটি, যা বোঝায় যে আমরা যে দুটি value return করি সেগুলি related এবং উভয়ই একটি configuration value-এর অংশ। আমরা বর্তমানে data-র structure-এ এই অর্থটি প্রকাশ করছি না, শুধুমাত্র দুটি value-কে একটি tuple-এ group করে; এর পরিবর্তে আমরা দুটি value-কে একটি struct-এ রাখব এবং struct-এর প্রতিটি field-কে একটি অর্থপূর্ণ নাম দেব। এটি করলে future-এ এই code-এর maintainer-দের জন্য এটা বোঝা সহজ হবে যে কীভাবে বিভিন্ন value একে অপরের সাথে related এবং তাদের purpose কী।

Listing 12-6 parse_config function-এর improvement গুলো দেখায়।

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

আমরা Config নামের একটি struct যোগ করেছি যার field-গুলোর নাম query এবং file_path হিসেবে define করা হয়েছে। parse_config-এর signature এখন নির্দেশ করে যে এটি একটি Config value return করে। parse_config-এর body-তে, যেখানে আমরা args-এ String value-গুলোকে reference করা string slice return করতাম, সেখানে এখন আমরা Config-কে define করি owned String value ধারণ করার জন্য। main-এর args variable-টি argument value-গুলোর owner এবং parse_config function-কে শুধুমাত্র সেগুলো borrow করতে দিচ্ছে, যার অর্থ হল যদি Config, args-এর value-গুলোর ownership নেওয়ার চেষ্টা করত তাহলে আমরা Rust-এর borrowing rule violate করতাম।

আমরা String data manage করার জন্য বেশ কয়েকটি উপায় অবলম্বন করতে পারি; সবচেয়ে সহজ, যদিও কিছুটা inefficient, উপায় হল value-গুলোর উপর clone method call করা। এটি Config instance-এর own করার জন্য data-র একটি full copy তৈরি করবে, যা string data-র একটি reference store করার চেয়ে বেশি সময় এবং memory নেয়। যাইহোক, data clone করা আমাদের code-কে আরও straightforward করে তোলে কারণ আমাদের reference-গুলোর lifetime manage করতে হয় না; এই পরিস্থিতিতে, simplicity অর্জনের জন্য performance-এর সামান্য ত্যাগ একটি worthwhile trade-off।

clone ব্যবহারের Trade-Off

অনেক Rustaceans-দের মধ্যে ownership-এর সমস্যা সমাধানের জন্য clone ব্যবহার করা এড়িয়ে যাওয়ার প্রবণতা রয়েছে কারণ এর runtime cost আছে। Chapter 13-এ, আপনি শিখবেন কীভাবে এই ধরনের পরিস্থিতিতে আরও efficient method ব্যবহার করতে হয়। কিন্তু আপাতত, progress চালিয়ে যাওয়ার জন্য কয়েকটি string copy করা ঠিক আছে কারণ আপনি এই copy গুলো শুধুমাত্র একবার করবেন এবং আপনার file path এবং query string খুব ছোট। প্রথম চেষ্টাতেই code hyperoptimize করার চেষ্টা করার চেয়ে একটি working প্রোগ্রাম থাকা ভালো যা কিছুটা inefficient। আপনি Rust-এর সাথে আরও experienced হওয়ার সাথে সাথে, সবচেয়ে efficient solution দিয়ে শুরু করা আরও সহজ হবে, কিন্তু আপাতত, clone call করা perfectly acceptable।

আমরা main update করেছি যাতে এটি parse_config দ্বারা returned Config-এর instance-টিকে config নামের একটি variable-এ রাখে, এবং আমরা সেই code update করেছি যেটি আগে আলাদা query এবং file_path variable ব্যবহার করত যাতে এটি এখন পরিবর্তে Config struct-এর field গুলো ব্যবহার করে।

এখন আমাদের code আরও স্পষ্টভাবে প্রকাশ করে যে query এবং file_path related এবং তাদের purpose হল প্রোগ্রামটি কীভাবে কাজ করবে তা configure করা। এই value গুলো ব্যবহার করে এমন যেকোনো code জানে যে সেগুলিকে config instance-এর মধ্যে তাদের purpose-এর জন্য named field-গুলোতে খুঁজতে হবে।

Config-এর জন্য একটি Constructor তৈরি করা

এখনও পর্যন্ত, আমরা command line argument parse করার জন্য responsible logic-টিকে main থেকে extract করে parse_config function-এ রেখেছি। এটি করতে গিয়ে আমরা দেখতে পেলাম যে query এবং file_path value গুলো related ছিল, এবং সেই relationship আমাদের code-এ প্রকাশ করা উচিত। তারপরে আমরা query এবং file_path-এর related purpose-টির নাম দেওয়ার জন্য এবং parse_config function থেকে value-গুলোর নাম struct field name হিসেবে return করতে সক্ষম হওয়ার জন্য একটি Config struct যোগ করেছি।

সুতরাং এখন যেহেতু parse_config function-টির purpose হল একটি Config instance create করা, তাই আমরা parse_config-কে একটি plain function থেকে Config struct-এর সাথে associated new নামের একটি function-এ পরিবর্তন করতে পারি। এই পরিবর্তনটি code-টিকে আরও idiomatic করে তুলবে। আমরা standard library-তে type-গুলোর instance create করতে পারি, যেমন String, String::new call করে। একইভাবে, parse_config-কে Config-এর সাথে associated একটি new function-এ পরিবর্তন করে, আমরা Config::new call করে Config-এর instance create করতে পারব। Listing 12-7 আমাদের যে পরিবর্তনগুলো করতে হবে সেগুলো দেখায়।

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

আমরা main update করেছি যেখানে আমরা parse_config call করছিলাম, পরিবর্তে Config::new call করার জন্য। আমরা parse_config-এর নাম পরিবর্তন করে new করেছি এবং এটিকে একটি impl block-এর মধ্যে move করেছি, যা new function-টিকে Config-এর সাথে associate করে। এই code টি আবার compile করে দেখুন এটা নিশ্চিত করতে যে এটি কাজ করছে।

Error Handling ঠিক করা

এখন আমরা আমাদের error handling ঠিক করার জন্য কাজ করব। মনে রাখবেন যে vector-এ তিনটির কম item থাকলে args vector-এর index 1 বা index 2-এ value access করার চেষ্টা করলে প্রোগ্রামটি panic করবে। কোনো argument ছাড়া প্রোগ্রামটি run করার চেষ্টা করুন; এটি এইরকম দেখাবে:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

index out of bounds: the len is 1 but the index is 1 লাইনটি programmers-দের জন্য উদ্দিষ্ট একটি error message। এটি আমাদের end user-দের বুঝতে সাহায্য করবে না যে পরিবর্তে তাদের কী করা উচিত। আসুন এখন সেটা ঠিক করি।

Error Message-এর উন্নতি

Listing 12-8-এ, আমরা new function-এ একটি check যোগ করি যা index 1 এবং index 2 access করার আগে verify করবে যে slice-টি যথেষ্ট long কিনা। যদি slice-টি যথেষ্ট long না হয়, তাহলে প্রোগ্রামটি panic করে এবং একটি better error message প্রদর্শন করে।

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

এই code-টি Listing 9-13-এ লেখা আমাদের Guess::new function-এর মতো, যেখানে value argument-টি valid value-গুলোর range-এর বাইরে থাকলে আমরা panic! call করেছিলাম। এখানে value-গুলোর একটি range check করার পরিবর্তে, আমরা check করছি যে args-এর length কমপক্ষে 3 কিনা এবং function-এর বাকি অংশ এই assumption-এর অধীনে operate করতে পারে যে এই condition পূরণ হয়েছে। যদি args-এ তিনটির কম item থাকে, তাহলে এই condition-টি true হবে, এবং আমরা প্রোগ্রামটিকে immediately end করার জন্য panic! macro call করি।

new-তে এই কয়েকটি অতিরিক্ত code line সহ, আসুন আবার কোনো argument ছাড়াই প্রোগ্রামটি run করি এটা দেখতে যে error-টি এখন কেমন দেখায়:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

এই output টি আরও ভালো: আমাদের এখন একটি reasonable error message রয়েছে। যাইহোক, আমাদের কাছে extraneous information-ও রয়েছে যা আমরা আমাদের user-দের দিতে চাই না। সম্ভবত Listing 9-13-এ আমরা যে technique ব্যবহার করেছি সেটি এখানে ব্যবহার করার জন্য সেরা নয়: panic!-এ call করা usage problem-এর চেয়ে programming problem-এর জন্য বেশি উপযুক্ত, যেমন Chapter 9-এ আলোচনা করা হয়েছে। পরিবর্তে, আমরা Chapter 9-এ শেখা অন্য technique টি ব্যবহার করব—একটি Result return করা যা success বা error indicate করে।

panic! Call করার পরিবর্তে একটি Result Return করা

আমরা পরিবর্তে একটি Result value return করতে পারি যাতে successful case-এ একটি Config instance থাকবে এবং error case-এ problem টি describe করবে। আমরা function-টির নামও new থেকে build-এ পরিবর্তন করতে যাচ্ছি কারণ অনেক programmer আশা করেন new function গুলো কখনই fail করবে না। যখন Config::build, main-এর সাথে communicate করছে, তখন আমরা Result type ব্যবহার করে signal দিতে পারি যে একটি problem ছিল। তারপরে আমরা main পরিবর্তন করে Err variant-কে আমাদের user-দের জন্য আরও practical error-এ convert করতে পারি, thread 'main' এবং RUST_BACKTRACE সম্পর্কে আশেপাশের text ছাড়াই যা panic!-এ call করার কারণে ঘটে।

Listing 12-9-এ আমরা এখন যে function টিকে Config::build বলছি, তার return value এবং একটি Result return করার জন্য function-এর body-তে যে পরিবর্তনগুলো করতে হবে সেগুলো দেখানো হলো। মনে রাখবেন যে যতক্ষণ না আমরা main update করি, ততক্ষণ পর্যন্ত এটি compile হবে না, যা আমরা পরবর্তী listing-এ করব।

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

আমাদের build function success case-এ একটি Config instance এবং error case-এ একটি string literal সহ একটি Result return করে। আমাদের error value গুলো সব সময় string literal হবে যাদের 'static lifetime আছে।

আমরা function-এর body-তে দুটি পরিবর্তন করেছি: user পর্যাপ্ত argument pass না করলে panic! call করার পরিবর্তে, আমরা এখন একটি Err value return করি, এবং আমরা Config return value-টিকে একটি Ok-এ wrap করেছি। এই পরিবর্তনগুলো function-টিকে এর new type signature-এর সাথে সঙ্গতিপূর্ণ করে তোলে।

Config::build থেকে একটি Err value return করা main function-কে build function থেকে returned Result value handle করতে এবং error case-এ আরও cleanly process exit করতে দেয়।

Config::build কল করা এবং Error হ্যান্ডেল করা

Error case হ্যান্ডেল করতে এবং একটি user-friendly message প্রিন্ট করতে, আমাদের main update করতে হবে যাতে এটি Config::build দ্বারা returned Result হ্যান্ডেল করতে পারে, যেমনটি Listing 12-10-এ দেখানো হয়েছে। আমরা panic! থেকে nonzero error code সহ command line tool exit করার responsibility-ও সরিয়ে নেব এবং পরিবর্তে এটি নিজে implement করব। একটি nonzero exit status হল এমন একটি convention যা আমাদের প্রোগ্রাম call করা process-কে signal দেয় যে প্রোগ্রামটি একটি error state-এর সাথে exit করেছে।

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

এই listing-এ, আমরা এমন একটি method ব্যবহার করেছি যা আমরা এখনও বিস্তারিতভাবে আলোচনা করিনি: unwrap_or_else, যেটি standard library দ্বারা Result<T, E>-তে define করা হয়েছে। unwrap_or_else ব্যবহার করা আমাদের কিছু custom, non-panic! error handling define করার সুযোগ দেয়। যদি Result একটি Ok value হয়, তাহলে এই method-টির behavior unwrap-এর মতোই: এটি Ok যে inner value-টিকে wrap করে রেখেছে সেটি return করে। যাইহোক, যদি value-টি একটি Err value হয়, তাহলে এই method টি closure-এর মধ্যে থাকা code call করে, যেটি হল একটি anonymous function যা আমরা define করি এবং unwrap_or_else-এ argument হিসেবে pass করি। আমরা Chapter 13-এ closure সম্পর্কে আরও বিস্তারিত আলোচনা করব। আপাতত, আপনার শুধু এটা জানলেই চলবে যে unwrap_or_else, Err-এর inner value, যেটি এই ক্ষেত্রে Listing 12-9-এ যোগ করা static string "not enough arguments", সেটিকে vertical pipe-গুলোর মধ্যে থাকা err argument-এর মাধ্যমে আমাদের closure-এ pass করবে। Closure-এর ভেতরের code তারপর run করার সময় err value-টিকে ব্যবহার করতে পারবে।

আমরা standard library থেকে process কে scope-এ আনার জন্য একটি নতুন use লাইন যোগ করেছি। Error case-এ যে closure-টি run করবে তার code-এ শুধুমাত্র দুটি লাইন রয়েছে: আমরা err value-টি print করি এবং তারপর process::exit call করি। process::exit function প্রোগ্রামটিকে immediately stop করবে এবং exit status code হিসেবে যে number টি pass করা হয়েছিল সেটি return করবে। এটি Listing 12-8-এ ব্যবহৃত panic!-ভিত্তিক handling-এর মতোই, কিন্তু আমরা আর সমস্ত extra output পাচ্ছি না। আসুন চেষ্টা করা যাক:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

দারুণ! এই output টি আমাদের user-দের জন্য অনেক বেশি friendly।

main থেকে Logic Extract করা

এখন যেহেতু আমরা configuration parsing refactor করা শেষ করেছি, আসুন প্রোগ্রামের logic-এর দিকে মনোযোগ দিই। আমরা যেমন “বাইনারি প্রোজেক্টের জন্য Separation of Concerns”-এ উল্লেখ করেছি, আমরা run নামের একটি function extract করব যেটি বর্তমানে main function-এ থাকা সমস্ত logic ধারণ করবে যা configuration set up করা বা error handle করার সাথে জড়িত নয়। যখন আমাদের কাজ শেষ হবে, main সংক্ষিপ্ত হবে এবং inspection-এর মাধ্যমে সহজেই verify করা যাবে, এবং আমরা অন্যান্য সমস্ত logic-এর জন্য test লিখতে পারব।

Listing 12-11-এ extract করা run function দেখানো হয়েছে। আপাতত, আমরা শুধুমাত্র function extract করার ছোট, incremental improvement করছি। আমরা এখনও src/main.rs-এ function-টি define করছি।

use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

run function-এ এখন file read করা থেকে শুরু করে main-এর সমস্ত অবশিষ্ট logic রয়েছে। run function টি Config instance-টিকে argument হিসেবে নেয়।

run Function থেকে Error Return করা

প্রোগ্রামের অবশিষ্ট logic run function-এ আলাদা করার সাথে, আমরা error handling-এর উন্নতি করতে পারি, যেমনটি আমরা Listing 12-9-এ Config::build-এর সাথে করেছিলাম। expect call করে প্রোগ্রামটিকে panic করার অনুমতি দেওয়ার পরিবর্তে, run function টি কোনো কিছু ভুল হলে একটি Result<T, E> return করবে। এটি আমাদেরকে user-friendly উপায়ে error handle করার জন্য logic-কে main-এ আরও consolidate করার সুযোগ দেবে। Listing 12-12 run-এর signature এবং body-তে আমাদের যে পরিবর্তনগুলো করতে হবে সেগুলো দেখায়।

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

আমরা এখানে তিনটি উল্লেখযোগ্য পরিবর্তন করেছি। প্রথমত, আমরা run function-এর return type পরিবর্তন করে Result<(), Box<dyn Error>> করেছি। এই function টি আগে unit type, (), return করত, এবং আমরা Ok case-এ returned value হিসেবে সেটিই রাখছি।

Error type-এর জন্য, আমরা trait object Box<dyn Error> ব্যবহার করেছি (এবং আমরা উপরের দিকে একটি use statement দিয়ে std::error::Error কে scope-এ এনেছি)। আমরা Chapter 18-এ trait object নিয়ে আলোচনা করব। আপাতত, শুধু জেনে রাখুন যে Box<dyn Error> মানে function টি এমন একটি type return করবে যা Error trait implement করে, কিন্তু আমাদের specify করতে হবে না যে return value-টি specific কোন type-এর হবে। এটি আমাদেরকে error value return করার flexibility দেয় যা different error case-এ different type-এর হতে পারে। dyn keyword টি dynamic-এর সংক্ষিপ্ত রূপ।

দ্বিতীয়ত, আমরা Chapter 9-এ আলোচনা করা ? operator-এর পক্ষে expect-এর call সরিয়ে দিয়েছি। কোনো error-এর উপর panic! করার পরিবর্তে, ? current function থেকে error value-টি return করবে যাতে caller সেটি handle করতে পারে।

তৃতীয়ত, run function টি এখন success case-এ একটি Ok value return করে। আমরা signature-এ run function-এর success type () হিসেবে declare করেছি, যার অর্থ হল আমাদের unit type value-টিকে Ok value-তে wrap করতে হবে। এই Ok(()) syntax টি প্রথমে একটু অদ্ভুত লাগতে পারে, কিন্তু এইভাবে () ব্যবহার করা হল idiomatic উপায় এটা indicate করার জন্য যে আমরা run কে শুধুমাত্র এর side effect-গুলোর জন্য call করছি; এটি আমাদের প্রয়োজনীয় কোনো value return করে না।

আপনি যখন এই code run করবেন, এটি compile হবে কিন্তু একটি warning প্রদর্শন করবে:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust আমাদের বলছে যে আমাদের code Result value-টিকে ignore করেছে এবং Result value-টি indicate করতে পারে যে একটি error ঘটেছে। কিন্তু আমরা check করছি না যে কোনো error হয়েছে কিনা, এবং compiler আমাদের মনে করিয়ে দিচ্ছে যে সম্ভবত আমাদের এখানে কিছু error-handling code থাকা উচিত ছিল! আসুন এখনই সেই সমস্যাটি সংশোধন করি।

main-এ run থেকে Returned Error হ্যান্ডেল করা

আমরা error-গুলো check করব এবং Listing 12-10-এ Config::build-এর সাথে ব্যবহৃত পদ্ধতির মতোই একটি technique ব্যবহার করে সেগুলি handle করব, তবে সামান্য ভিন্নতা সহ:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

আমরা unwrap_or_else-এর পরিবর্তে if let ব্যবহার করি এটা check করার জন্য যে run একটি Err value return করে কিনা এবং যদি করে তবে process::exit(1) call করার জন্য। run function এমন কোনো value return করে না যা আমরা unwrap করতে চাই, যেমন Config::build, Config instance return করে। যেহেতু run success case-এ () return করে, তাই আমরা শুধুমাত্র একটি error detect করার বিষয়ে আগ্রহী, তাই unwrapped value return করার জন্য আমাদের unwrap_or_else-এর প্রয়োজন নেই, যেটি শুধুমাত্র () হবে।

if let এবং unwrap_or_else function-গুলোর body উভয় ক্ষেত্রেই একই: আমরা error print করি এবং exit করি।

কোডকে একটি Library Crate-এ Split করা

আমাদের minigrep project-টি এখনও পর্যন্ত ভালো দেখাচ্ছে! এখন আমরা src/main.rs file-টিকে split করব এবং কিছু code src/lib.rs file-এ রাখব। এইভাবে, আমরা code test করতে পারব এবং src/main.rs file-টিতে responsibility কম রাখতে পারব।

আসুন src/main.rs থেকে src/lib.rs-এ সমস্ত code সরিয়ে নিই যা main function-এ নেই:

  • run function definition
  • Relevant use statement-গুলো
  • Config-এর definition
  • Config::build function definition

src/lib.rs-এর contents-এ Listing 12-13-এ দেখানো signature গুলো থাকা উচিত (সংक्षिप्तতার জন্য আমরা function-গুলোর body বাদ দিয়েছি)। মনে রাখবেন যে যতক্ষণ না আমরা Listing 12-14-এ src/main.rs modify করি, ততক্ষণ পর্যন্ত এটি compile হবে না।

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

আমরা pub keyword-টির উদার ব্যবহার করেছি: Config-এ, এর field এবং এর build method-এ এবং run function-এ। আমাদের এখন একটি library crate রয়েছে যার একটি public API রয়েছে যা আমরা test করতে পারি!

এখন আমাদের Listing 12-14-এ দেখানো code-টিকে src/lib.rs-এ সরানো code binary crate-এর scope-এ src/main.rs-এ আনতে হবে।

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}

আমরা library crate থেকে binary crate-এর scope-এ Config type-টি আনার জন্য একটি use minigrep::Config লাইন যোগ করি এবং আমরা run function-টির আগে আমাদের crate-এর নাম prefix করি। এখন সমস্ত functionality সংযুক্ত হওয়া উচিত এবং কাজ করা উচিত। cargo run দিয়ে প্রোগ্রামটি run করুন এবং নিশ্চিত করুন যে সবকিছু সঠিকভাবে কাজ করছে।

উফ! এটা অনেক কাজ ছিল, কিন্তু আমরা ভবিষ্যতে নিজেদের সাফল্যের জন্য প্রস্তুত করেছি। এখন error handle করা অনেক সহজ, এবং আমরা code-টিকে আরও modular করেছি। এখন থেকে আমাদের প্রায় সমস্ত কাজ src/lib.rs-এ করা হবে।

আসুন এই নতুন পাওয়া modularity-র সুবিধা নিই এমন কিছু করে যা পুরানো code-এর সাথে করা কঠিন হত কিন্তু নতুন code-এর সাথে সহজ: আমরা কিছু test লিখব!