Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

إعادة الهيكلة (Refactoring) لتحسين النمطية (Modularity) ومعالجة الأخطاء (Error Handling)

لتحسين برنامجنا، سنقوم بإصلاح أربع مشكلات تتعلق بهيكلة البرنامج وكيفية معالجته للأخطاء المحتملة. أولاً، تقوم دالة main الخاصة بنا الآن بمهمتين: تحليل الوسائط (parse arguments) وقراءة الملفات (reads files). مع نمو برنامجنا، سيزداد عدد المهام المنفصلة التي تتعامل معها دالة main. عندما تكتسب الدالة مسؤوليات، يصبح من الصعب التفكير فيها، وأصعب في الاختبار، وأصعب في التغيير دون كسر أحد أجزائها. من الأفضل فصل الوظائف بحيث تكون كل دالة مسؤولة عن مهمة واحدة.

ترتبط هذه المشكلة أيضًا بالمشكلة الثانية: على الرغم من أن query و file_path هما متغيرات تهيئة (configuration variables) لبرنامجنا، فإن متغيرات مثل contents تُستخدم لأداء منطق (logic) البرنامج. كلما أصبحت main أطول، زاد عدد المتغيرات التي سنحتاج إلى إحضارها إلى النطاق (scope)؛ وكلما زاد عدد المتغيرات لدينا في الـ scope، زادت صعوبة تتبع الغرض من كل منها. من الأفضل تجميع الـ configuration variables في هيكل (structure) واحد لتوضيح الغرض منها.

المشكلة الثالثة هي أننا استخدمنا expect لطباعة رسالة خطأ عندما تفشل قراءة الملف، لكن رسالة الخطأ تطبع فقط Should have been able to read the file. يمكن أن تفشل قراءة الملف بعدة طرق: على سبيل المثال، قد يكون الملف مفقودًا، أو قد لا يكون لدينا إذن لفتحه. في الوقت الحالي، بغض النظر عن الموقف، سنطبع نفس رسالة الخطأ لكل شيء، والتي لن تعطي المستخدم أي معلومات!

رابعًا، نستخدم expect لمعالجة خطأ، وإذا قام المستخدم بتشغيل برنامجنا دون تحديد وسائط كافية، فسيحصل على خطأ تجاوز حدود الفهرس (index out of bounds) من Rust لا يشرح المشكلة بوضوح. سيكون من الأفضل لو كان كل كود Error Handling في مكان واحد بحيث يكون لدى القائمين على الصيانة في المستقبل مكان واحد فقط للرجوع إليه إذا احتاج منطق Error Handling إلى التغيير. سيضمن وجود كل كود Error Handling في مكان واحد أيضًا أننا نطبع رسائل ذات مغزى لمستخدمينا النهائيين.

دعونا نعالج هذه المشاكل الأربعة عن طريق إعادة هيكلة (refactoring) مشروعنا.

فصل الاهتمامات في المشاريع الثنائية (Binary Projects)

تعد مشكلة التنظيم المتمثلة في تخصيص المسؤولية عن مهام متعددة لدالة main شائعة في العديد من الـ binary projects. ونتيجة لذلك، يجد العديد من مبرمجي Rust أنه من المفيد تقسيم الاهتمامات المنفصلة لبرنامج ثنائي عندما تبدأ دالة main في أن تصبح كبيرة. تتضمن هذه العملية الخطوات التالية:

  • تقسيم برنامجك إلى ملف main.rs وملف lib.rs ونقل منطق برنامجك إلى lib.rs.
  • طالما أن منطق تحليل سطر الأوامر (command line parsing) الخاص بك صغير، يمكن أن يظل في دالة main.
  • عندما يبدأ منطق command line parsing في أن يصبح معقدًا، قم باستخراجه من دالة main إلى دوال أو أنواع أخرى.

يجب أن تقتصر المسؤوليات التي تظل في دالة main بعد هذه العملية على ما يلي:

  • استدعاء منطق command line parsing بقيم الوسائط
  • إعداد أي تهيئة أخرى
  • استدعاء دالة run في lib.rs
  • معالجة الخطأ إذا أرجعت دالة run خطأ

يدور هذا النمط حول فصل الاهتمامات: يتعامل main.rs مع تشغيل البرنامج ويتعامل lib.rs مع كل منطق المهمة قيد التنفيذ. نظرًا لأنه لا يمكنك اختبار دالة main مباشرة، فإن هذا الهيكل يسمح لك باختبار كل منطق برنامجك عن طريق نقله خارج دالة main. سيكون الكود الذي يظل في دالة main صغيرًا بما يكفي للتحقق من صحته عن طريق قراءته. دعونا نعيد صياغة برنامجنا باتباع هذه العملية.

استخراج محلل الوسائط (Argument Parser)

سنقوم باستخراج الوظيفة الخاصة بـ parse arguments إلى دالة ستستدعيها main. تُظهر القائمة 12-5 البداية الجديدة لدالة main التي تستدعي دالة جديدة parse_config، والتي سنقوم بتعريفها في src/main.rs.

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)
}

ما زلنا نجمع وسائط سطر الأوامر في متجه (vector)، ولكن بدلاً من تعيين قيمة الوسيطة في الـ index 1 للمتغير query وقيمة الوسيطة في الـ index 2 للمتغير file_path داخل دالة main، نمرر الـ vector بالكامل إلى دالة parse_config. تحتوي دالة parse_config بعد ذلك على المنطق الذي يحدد الوسيطة التي تذهب إلى أي متغير وتمرر القيم مرة أخرى إلى main. ما زلنا ننشئ المتغيرين query و file_path في main، لكن main لم تعد مسؤولة عن تحديد كيفية توافق وسائط سطر الأوامر والمتغيرات.

قد تبدو إعادة الصياغة هذه مبالغة بالنسبة لبرنامجنا الصغير، لكننا نقوم بـ refactoring في خطوات صغيرة ومتزايدة. بعد إجراء هذا التغيير، قم بتشغيل البرنامج مرة أخرى للتحقق من أن parse arguments لا يزال يعمل. من الجيد التحقق من تقدمك كثيرًا، للمساعدة في تحديد سبب المشاكل عند حدوثها.

تجميع قيم التهيئة (Configuration Values)

يمكننا اتخاذ خطوة صغيرة أخرى لتحسين دالة parse_config بشكل أكبر. في الوقت الحالي، نقوم بإرجاع صف (tuple)، ولكننا بعد ذلك نقوم على الفور بتقسيم هذا الـ tuple إلى أجزاء فردية مرة أخرى. هذه علامة على أنه ربما ليس لدينا التجريد (abstraction) الصحيح بعد.

مؤشر آخر يوضح أن هناك مجالًا للتحسين هو جزء config من parse_config، مما يعني أن القيمتين اللتين نرجعهما مرتبطتان وكلاهما جزء من قيمة تهيئة واحدة. نحن لا ننقل هذا المعنى حاليًا في هيكل البيانات بخلاف تجميع القيمتين في tuple؛ بدلاً من ذلك، سنضع القيمتين في هيكل (struct) واحد ونعطي كل حقل (field) من حقول الـ struct اسمًا ذا مغزى. سيؤدي القيام بذلك إلى تسهيل فهم القائمين على صيانة هذا الكود في المستقبل لكيفية ارتباط القيم المختلفة ببعضها البعض وما هو الغرض منها.

تُظهر القائمة 12-6 التحسينات التي تم إجراؤها على دالة parse_config.

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 }
}

لقد أضفنا struct يسمى Config تم تعريفه ليحتوي على fields تسمى query و file_path. يشير توقيع parse_config الآن إلى أنه يُرجع قيمة Config. في نص parse_config، حيث اعتدنا على إرجاع شرائح string (string slices) تشير إلى قيم String في args، نقوم الآن بتعريف Config لاحتواء قيم String مملوكة. متغير args في main هو المالك لقيم الوسائط ويسمح فقط لدالة parse_config باستعارتها (borrow)، مما يعني أننا سننتهك قواعد الاستعارة (borrowing rules) الخاصة بـ Rust إذا حاول Config أخذ ملكية القيم في args.

هناك عدد من الطرق التي يمكننا من خلالها إدارة بيانات String؛ أسهل طريقة، على الرغم من أنها غير فعالة إلى حد ما، هي استدعاء دالة clone (clone method) على القيم. سيؤدي هذا إلى عمل نسخة كاملة من البيانات لمثيل Config لامتلاكها، الأمر الذي يستغرق وقتًا وذاكرة أكبر من تخزين مرجع لبيانات الـ string. ومع ذلك، فإن عمل clone للبيانات يجعل الكود الخاص بنا مباشرًا للغاية لأننا لسنا مضطرين لإدارة فترات الحياة (lifetimes) للمراجع؛ في هذه الظروف، يعد التخلي عن القليل من الأداء للحصول على البساطة مقايضة تستحق العناء.

المقايضات في استخدام clone

هناك ميل بين العديد من مبرمجي Rust لتجنب استخدام clone لإصلاح مشاكل Ownership بسبب تكلفته في وقت التشغيل. في الفصل 13، ستتعلم كيفية استخدام methods أكثر كفاءة في هذا النوع من المواقف. ولكن في الوقت الحالي، لا بأس في نسخ بعض الـ strings لمواصلة إحراز التقدم لأنك ستقوم بهذه النسخ مرة واحدة فقط ومسار ملفك وسلسلة الاستعلام (query string) صغيران جدًا. من الأفضل أن يكون لديك برنامج يعمل ولكنه غير فعال بعض الشيء بدلاً من محاولة التحسين المفرط للكود في محاولتك الأولى. عندما تصبح أكثر خبرة في Rust، سيكون من الأسهل البدء بالحل الأكثر كفاءة، ولكن في الوقت الحالي، من المقبول تمامًا استدعاء clone.

لقد قمنا بتحديث main بحيث يضع مثيل Config الذي تم إرجاعه بواسطة parse_config في متغير يسمى config، وقمنا بتحديث الكود الذي كان يستخدم سابقًا المتغيرين المنفصلين query و file_path بحيث يستخدم الآن الـ fields الموجودة على struct Config بدلاً من ذلك.

الآن ينقل الكود الخاص بنا بشكل أكثر وضوحًا أن query و file_path مرتبطان وأن الغرض منهما هو تهيئة كيفية عمل البرنامج. أي كود يستخدم هذه القيم يعرف أنه يجب العثور عليها في مثيل config في الـ fields المسماة لغرضها.

إنشاء دالة بناء (Constructor) لـ Config

حتى الآن، قمنا باستخراج المنطق المسؤول عن parse arguments سطر الأوامر من main ووضعناه في دالة parse_config. ساعدنا القيام بذلك في رؤية أن قيم query و file_path مرتبطة، ويجب نقل هذه العلاقة في الكود الخاص بنا. ثم أضفنا struct Config لتسمية الغرض المرتبط بـ query و file_path ولتكون قادرًا على إرجاع أسماء قيم الـ fields كـ struct field names من دالة parse_config.

لذا، الآن بعد أن أصبح الغرض من دالة parse_config هو إنشاء مثيل Config، يمكننا تغيير parse_config من دالة عادية إلى دالة تسمى new مرتبطة بـ struct Config. سيؤدي إجراء هذا التغيير إلى جعل الكود أكثر اصطلاحية (idiomatic). يمكننا إنشاء مثيلات من الأنواع في المكتبة القياسية، مثل String، عن طريق استدعاء String::new. وبالمثل، عن طريق تغيير parse_config إلى دالة new مرتبطة بـ Config، سنتمكن من إنشاء مثيلات Config عن طريق استدعاء Config::new. تُظهر القائمة 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 حيث كنا نستدعي parse_config لاستدعاء Config::new بدلاً من ذلك. لقد قمنا بتغيير اسم parse_config إلى new ونقلناه داخل كتلة impl (impl block)، والتي تربط دالة new بـ Config. حاول تجميع هذا الكود مرة أخرى للتأكد من أنه يعمل.

إصلاح معالجة الأخطاء (Fixing the Error Handling)

الآن سنعمل على إصلاح Error Handling الخاص بنا. تذكر أن محاولة الوصول إلى القيم في الـ vector args في الـ index 1 أو الـ index 2 ستتسبب في ذعر (panic!) البرنامج إذا كان الـ vector يحتوي على أقل من ثلاثة عناصر. حاول تشغيل البرنامج بدون أي وسائط؛ سيبدو كالتالي:

$ 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 هو خطأ…

تحسين رسالة الخطأ (Improving the Error Message)

في القائمة 12-8، نضيف فحصًا في دالة new يتحقق من أن الشريحة (slice) طويلة بما يكفي قبل الوصول إلى الـ index 1 والـ index 2. إذا لم تكن الشريحة طويلة بما يكفي، يحدث panic! للبرنامج ويعرض رسالة خطأ أفضل.

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 }
    }
}

يشبه هذا الكود دالة Guess::new التي كتبناها في القائمة 9-13، حيث استدعينا panic! عندما كانت وسيطة value خارج نطاق القيم الصالحة. بدلاً من التحقق من نطاق من القيم هنا، نتحقق من أن طول args هو 3 على الأقل ويمكن لبقية الدالة أن تعمل بافتراض أن هذا الشرط قد تم الوفاء به. إذا كان args يحتوي على أقل من ثلاثة عناصر، فسيكون هذا الشرط true، ونستدعي الماكرو panic! لإنهاء البرنامج على الفور.

باستخدام هذه الأسطر الإضافية القليلة من الكود في new، دعنا نشغل البرنامج بدون أي وسائط مرة أخرى لنرى كيف يبدو الخطأ الآن:

$ 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

هذا الخرج أفضل: لدينا الآن رسالة خطأ معقولة. ومع ذلك، لدينا أيضًا معلومات زائدة لا نريد إعطاءها لمستخدمينا. ربما التقنية التي استخدمناها في القائمة 9-13 ليست الأفضل للاستخدام هنا: استدعاء panic! أكثر ملاءمة لمشكلة برمجة من مشكلة استخدام، كما نوقش في الفصل 9. بدلاً من ذلك، سنستخدم التقنية الأخرى التي تعلمتها في الفصل 9 - إرجاع نتيجة (Result) تشير إما إلى النجاح أو الخطأ.

إرجاع Result بدلاً من استدعاء panic!

يمكننا بدلاً من ذلك إرجاع قيمة Result ستحتوي على مثيل Config في حالة النجاح وستصف المشكلة في حالة الخطأ. سنقوم أيضًا بتغيير اسم الدالة من new إلى build لأن العديد من المبرمجين يتوقعون ألا تفشل دوال new أبدًا. عندما تتواصل Config::build مع main، يمكننا استخدام النوع Result للإشارة إلى وجود مشكلة. بعد ذلك، يمكننا تغيير main لتحويل متغير Err إلى خطأ أكثر عملية لمستخدمينا دون النص المحيط حول thread 'main' و RUST_BACKTRACE الذي يسببه استدعاء panic!.

تُظهر القائمة 12-9 التغييرات التي نحتاج إلى إجرائها على قيمة الإرجاع للدالة التي نسميها الآن Config::build ونص الدالة اللازم لإرجاع Result. لاحظ أن هذا لن يتم تجميعه حتى نقوم بتحديث main أيضًا، وهو ما سنفعله في القائمة التالية.

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 الخاصة بنا Result مع مثيل Config في حالة النجاح وحرفي string في حالة الخطأ. ستكون قيم الخطأ الخاصة بنا دائمًا string literals لها فترة الحياة (lifetime) 'static.

لقد أجرينا تغييرين في نص الدالة: بدلاً من استدعاء panic! عندما لا يمرر المستخدم وسائط كافية، نُرجع الآن قيمة Err، وقمنا بتغليف قيمة إرجاع Config في Ok. هذه التغييرات تجعل الدالة تتوافق مع توقيع النوع الجديد الخاص بها.

يسمح إرجاع قيمة Err من Config::build لدالة main بمعالجة قيمة Result التي تم إرجاعها من دالة build وإنهاء العملية بشكل أنظف في حالة الخطأ.

استدعاء Config::build ومعالجة الأخطاء

لمعالجة حالة الخطأ وطباعة رسالة سهلة الاستخدام، نحتاج إلى تحديث main لمعالجة Result الذي يتم إرجاعه بواسطة Config::build، كما هو موضح في القائمة 12-10. سنتحمل أيضًا مسؤولية إنهاء أداة سطر الأوامر برمز خطأ غير صفري (nonzero error code) بعيدًا عن panic! وبدلاً من ذلك سنقوم بتطبيقه يدويًا. حالة الخروج غير الصفرية هي اصطلاح للإشارة إلى العملية التي استدعت برنامجنا بأن البرنامج خرج بحالة خطأ.

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 })
    }
}

في هذه القائمة، استخدمنا دالة لم نقم بتغطيتها بالتفصيل بعد: unwrap_or_else (unwrap_or_else method)، والتي تم تعريفها على Result<T, E> بواسطة المكتبة القياسية. يسمح لنا استخدام unwrap_or_else بتعريف بعض Error Handling المخصص وغير panic!. إذا كانت Result قيمة Ok، فإن سلوك هذه الدالة يشبه unwrap: فإنه يُرجع القيمة الداخلية التي يغلفها Ok. ومع ذلك، إذا كانت القيمة قيمة Err، فإن هذه الدالة تستدعي الكود في الدالة المجهولة (closure)، وهي دالة مجهولة نقوم بتعريفها وتمريرها كوسيطة إلى unwrap_or_else. سنغطي الـ closures بمزيد من التفصيل في الفصل 13. في الوقت الحالي، تحتاج فقط إلى معرفة أن unwrap_or_else سيمرر القيمة الداخلية لـ Err، والتي في هذه الحالة هي الـ string الثابت "not enough arguments" الذي أضفناه في القائمة 12-9، إلى الـ closure الخاص بنا في الوسيطة err التي تظهر بين الأنابيب العمودية. يمكن للكود الموجود في الـ closure بعد ذلك استخدام قيمة err عند تشغيله.

لقد أضفنا سطر use جديدًا لإحضار process من المكتبة القياسية إلى الـ scope. الكود الموجود في الـ closure الذي سيتم تشغيله في حالة الخطأ هو سطران فقط: نطبع قيمة err ثم نستدعي process::exit. ستوقف دالة process::exit البرنامج على الفور وتُرجع الرقم الذي تم تمريره كرمز حالة الخروج. هذا مشابه لـ Error Handling القائم على panic! الذي استخدمناه في القائمة 12-8، لكننا لم نعد نحصل على كل الخرج الإضافي. لنجربها:

$ 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

عظيم! هذا الخرج أكثر ودية لمستخدمينا.

استخراج المنطق من main

الآن بعد أن انتهينا من refactoring لـ parse arguments، دعنا ننتقل إلى منطق البرنامج. كما ذكرنا في “فصل الاهتمامات في المشاريع الثنائية”، سنقوم باستخراج دالة تسمى run ستحتوي على كل المنطق الموجود حاليًا في دالة main والذي لا يشارك في إعداد التهيئة أو Error Handling. عندما ننتهي، ستكون دالة main موجزة وسهلة التحقق عن طريق الفحص، وسنكون قادرين على كتابة اختبارات لكل المنطق الآخر.

تُظهر القائمة 12-11 التحسين الصغير والمتزايد لاستخراج دالة run.

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 الآن على كل المنطق المتبقي من main، بدءًا من قراءة الملف. تأخذ دالة run مثيل Config كوسيطة.

إرجاع الأخطاء من run

مع فصل منطق البرنامج المتبقي في دالة run، يمكننا تحسين Error Handling، كما فعلنا مع Config::build في القائمة 12-9. بدلاً من السماح للبرنامج بـ panic! عن طريق استدعاء expect، ستُرجع دالة run قيمة Result<T, E> عندما يحدث خطأ ما. سيسمح لنا هذا بزيادة دمج المنطق حول Error Handling في main بطريقة سهلة الاستخدام. تُظهر القائمة 12-12 التغييرات التي نحتاج إلى إجرائها على توقيع ونص run.

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 إلى Result<(), Box<dyn Error>>. كانت هذه الدالة تُرجع سابقًا نوع الوحدة (unit type)، ()، ونحتفظ بذلك كقيمة مُرجعة في حالة Ok.

بالنسبة لنوع الخطأ، استخدمنا كائن سمة (trait object) Box<dyn Error> (وأحضرنا std::error::Error إلى الـ scope باستخدام عبارة use في الأعلى). سنغطي الـ trait objects في الفصل 18. في الوقت الحالي، ما عليك سوى معرفة أن Box<dyn Error> يعني أن الدالة ستُرجع نوعًا يطبق السمة (trait) Error، ولكن ليس علينا تحديد النوع المعين الذي ستكون عليه قيمة الإرجاع. يمنحنا هذا المرونة لإرجاع قيم خطأ قد تكون من أنواع مختلفة في حالات خطأ مختلفة. الكلمة المفتاحية dyn هي اختصار لـ dynamic.

ثانيًا، قمنا بإزالة الاستدعاء لـ expect لصالح عامل التشغيل ؟ (? operator)، كما تحدثنا في الفصل 9. بدلاً من panic! عند حدوث خطأ، سيُرجع ؟ قيمة الخطأ من الدالة الحالية للمستدعي لمعالجتها.

ثالثًا، تُرجع دالة run الآن قيمة Ok في حالة النجاح. لقد أعلنا عن نوع نجاح دالة run على أنه () في التوقيع، مما يعني أننا بحاجة إلى تغليف قيمة unit type في قيمة Ok. قد يبدو بناء الجملة Ok(()) غريبًا بعض الشيء في البداية. لكن استخدام () بهذه الطريقة هو الطريقة الاصطلاحية للإشارة إلى أننا نستدعي run لآثارها الجانبية (side effects) فقط؛ فهي لا تُرجع قيمة نحتاجها.

عندما تقوم بتشغيل هذا الكود، سيتم تجميعه ولكنه سيعرض تحذيرًا:

$ 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 أن الكود الخاص بنا تجاهل قيمة Result وقد تشير قيمة Result إلى حدوث خطأ. لكننا لا نتحقق مما إذا كان هناك خطأ أم لا، ويذكرنا المترجم (compiler) بأننا ربما قصدنا أن يكون لدينا بعض كود Error Handling هنا! دعونا نصحح هذه المشكلة الآن.

معالجة الأخطاء التي تم إرجاعها من run في main

سنتحقق من الأخطاء ونتعامل معها باستخدام تقنية مماثلة لتلك التي استخدمناها مع Config::build في القائمة 12-10، ولكن مع اختلاف طفيف:

اسم الملف: 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 })
    }
}

نستخدم if let بدلاً من unwrap_or_else للتحقق مما إذا كانت run تُرجع قيمة Err ولاستدعاء process::exit(1) إذا كانت كذلك. لا تُرجع دالة run قيمة نريد unwrap بنفس الطريقة التي تُرجع بها Config::build مثيل Config. نظرًا لأن run تُرجع () في حالة النجاح، فإننا نهتم فقط باكتشاف الخطأ، لذلك لا نحتاج إلى unwrap_or_else لإرجاع القيمة غير المغلفة، والتي ستكون () فقط.

نصوص if let و unwrap_or_else هي نفسها في كلتا الحالتين: نطبع الخطأ ونخرج.

تقسيم الكود إلى صندوق مكتبة (Library Crate)

يبدو مشروع minigrep الخاص بنا جيدًا حتى الآن! الآن سنقوم بتقسيم ملف src/main.rs ووضع بعض الكود في ملف src/lib.rs. بهذه الطريقة، يمكننا اختبار الكود والحصول على ملف src/main.rs بمسؤوليات أقل.

دعنا نحدد الكود المسؤول عن البحث عن النص في src/lib.rs بدلاً من src/main.rs، مما سيسمح لنا (أو لأي شخص آخر يستخدم صندوق المكتبة (library crate) minigrep الخاص بنا) باستدعاء دالة البحث من سياقات أكثر من صندوقنا الثنائي (binary crate) minigrep.

أولاً، دعنا نحدد توقيع دالة search في src/lib.rs كما هو موضح في القائمة 12-13، مع نص يستدعي الماكرو unimplemented!. سنشرح التوقيع بمزيد من التفصيل عندما نملأ التطبيق.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

… يمكننا استخدامها من الـ binary crate الخاص بنا ويمكننا اختبارها!

الآن نحتاج إلى إحضار الكود المحدد في src/lib.rs إلى الـ scope الخاص بالـ binary crate في src/main.rs واستدعائه، كما هو موضح في القائمة 12-14.

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

// --snip--
use minigrep::search;

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);
    });

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

// --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 })
    }
}

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

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

نضيف سطر use minigrep::search لإحضار دالة search من الـ library crate إلى الـ scope الخاص بالـ binary crate. بعد ذلك، في دالة run، بدلاً من طباعة محتويات الملف، نستدعي دالة search ونمرر قيمة config.query و contents كوسيطتين. بعد ذلك، ستستخدم run حلقة for لطباعة كل سطر تم إرجاعه من search يتطابق مع الـ query. هذا أيضًا وقت مناسب لإزالة استدعاءات println! في دالة main التي عرضت الـ query ومسار الملف بحيث يطبع برنامجنا نتائج البحث فقط (إذا لم تحدث أخطاء).

لاحظ أن دالة البحث ستجمع كل النتائج في vector تُرجعه قبل حدوث أي طباعة. قد يكون هذا التطبيق بطيئًا في عرض النتائج عند البحث في ملفات كبيرة، لأن النتائج لا تتم طباعتها عند العثور عليها؛ سنناقش طريقة محتملة لإصلاح ذلك باستخدام المكررات (iterators) في الفصل 13.

يا له من عمل شاق! لقد كان هذا كثيرًا من العمل، لكننا أعددنا أنفسنا للنجاح في المستقبل. أصبح الآن من الأسهل بكثير معالجة الأخطاء، وجعلنا الكود أكثر نمطية (modular). سيتم تنفيذ كل عملنا تقريبًا في src/lib.rs من الآن فصاعدًا.

دعنا نستغل هذه الـ modularity المكتشفة حديثًا من خلال القيام بشيء كان سيكون صعبًا مع الكود القديم ولكنه سهل مع الكود الجديد: سنكتب بعض الاختبارات!