لغات البرمجة عملياً الأدلة التقنية

Rust للمطورين: الأدوات Cargo والأساسيات في يوم واحد

ثبتنا Rust 1.99.1 من الصفر وبنينا مكتبة تحسب قيمة الطلب والضريبة بالهللات، ومررنا عليها بكل أداة ستكتبها يومياً مع المخرج الحقيقي، وما يفاجئ القادم من Python وJava وGo.

Rust للمطورين: الأدوات Cargo والأساسيات في يوم واحد

لنفرض أنك مطور في شركة ناشئة تبيع القهوة والتمور على الإنترنت، وتكتب يومك كله بلغة Python أو Java أو Go، ثم قرر الفريق أن الجزء الذي يحسب قيمة الطلب والضريبة سوف يكتب من جديد بلغة Rust، لأنه يعمل على كل طلب، ولأن الفريق يريد ملفاً تنفيذياً صغيراً يعمل دون بيئة تشغيل، ولأن أحد الزملاء قرأ أن المترجم في Rust يمنع أخطاء كانت تكلفهم ليالي من البحث. وأنت لا تحتاج دورة في البرمجة، فأنت تعرف المتغيرات والدوال والكلاسات والاختبارات، وإنما تحتاج أن تعرف بسرعة: ماذا أثبت على جهازي؟ وما الأوامر التي سوف أكتبها كل يوم؟ وأين يختلف هذا الكود عما أعرفه؟ وما الذي سوف يفاجئني في الأسبوع الأول؟

وهذا المقال هو أول خمسة مقالات عن Rust في مسار «لغات البرمجة عملياً»، وكلها تتبع نفس المتجر. وفي هذا المقال سوف نثبت الأدوات من الصفر على Debian 13 بالإصدار المستقر الحالي Rust 1.99.1، ثم نبني مشروعاً صغيراً يحسب قيمة الطلب مع ضريبة القيمة المضافة بالهللات، ونمر عليه بكل أداة: المترجم ومدير الحزم والمنسق والمدقق والاختبارات والتوثيق، مع المخرج الحقيقي لكل أمر وشرحه. وإذا كنت ما زلت تقرر هل تناسبك Rust أصلاً، فقد ناقشنا ذلك في مقال كيف تختار لغة البرمجة لمشروعك، وإذا كنت جديداً على البرمجة كلها فابدأ من أساسيات البرمجة، لأن هذا المقال يفترض أنك تبرمج بلغة أخرى.

وسوف نناقش في هذا المقال ما يلي:

  • من أين جاءت Rust، ولماذا تصدر نسخة كل ستة أسابيع، وما معنى الإصدار الكبير Edition.
  • تثبيت الأدوات بالأداة rustup، وما الذي ينزل على جهازك، ومشكلة واجهتنا مع Image رسمي ينقصه المنسق والمدقق.
  • أول مشروع بالأمر cargo new، وهيكل المشروع، والملف Cargo.toml.
  • جولة سريعة في اللغة مقارنة بما تعرفه: المتغيرات، والتعبيرات، والهياكل، والتعدادات، وmatch، وOption وResult، والسمات Traits.
  • الاعتماديات من crates.io، والبناء بوضعي التطوير والإصدار مع الأزمنة والأحجام الحقيقية.
  • المنسق rustfmt، والمدقق Clippy، والاختبارات بأنواعها الثلاثة، والتوثيق.
  • ماذا يحدث إذا تجاوز رقم حده، ولماذا يختلف الجواب بين وضعي البناء.
  • جدول الأوامر اليومية، والأخطاء الشائعة، وقائمة تحقق للأسبوع الأول.

من أين جاءت Rust؟ قطار الإصدارات Release Train والإصدار الكبير Edition

بدأت Rust مشروعاً شخصياً للمبرمج Graydon Hoare في Mozilla، ثم تبنتها Mozilla لأنها كانت تبحث عن لغة تكتب بها أجزاء من المتصفح بأداء C++ ودون أخطاء الذاكرة التي كانت مصدر أغلب ثغراته، وصدر الإصدار 1.0 في مايو 2015. ومنذ 2021 تملك اللغة مؤسسة مستقلة هي Rust Foundation، وتطورها فرق مفتوحة. وقصة لماذا صارت أخطاء الذاكرة قضية تتكلم عنها الحكومات شرحناها في مقال أمان الذاكرة Memory Safety، فلن نعيدها هنا، والذي يهمك الآن هو كيف تصل إليك اللغة وكيف تتغير.

وأول شيء يختلف عن لغات مثل Java وPython أن Rust لا تنتظر إصداراً كبيراً كل سنة أو سنتين، وإنما تصدر نسخة مستقرة جديدة كل ستة أسابيع بالضبط، وهو ما يسميه الفريق قطار الإصدارات Release Train: كل ميزة تمر أولاً بالقناة الليلية Nightly، ثم بالقناة التجريبية Beta لستة أسابيع، ثم تصل إلى القناة المستقرة Stable، والقطار يخرج في موعده سواء كانت الميزة جاهزة أم لا، فإذا لم تكن جاهزة انتظرت القطار التالي. ولهذا تجد الإصدار 1.99.0 صدر في 1 أكتوبر 2026، وتلاه الإصدار التصحيحي 1.99.1 في 15 أكتوبر 2026 لإصلاح ثلاثة أخطاء ظهرت بعده، منها خطأ في قيم bool التي ترجعها الدوال إلى كود C على معالجات x86_64. والوعد المهم هنا أن الكود الذي يترجم على إصدار مستقر يترجم على كل إصدار مستقر بعده، ولذلك لا يخاف الناس من التحديث كل ستة أسابيع.

ولكن كيف تضيف لغة كلمات محجوزة جديدة أو تغير سلوكاً قديماً دون أن تكسر وعدها؟ والإجابة على ذلك هي الإصدار الكبير Edition، وهو اختيار تكتبه في ملف المشروع، مثل edition = "2024"، يحدد بعض قواعد اللغة لهذا المشروع وحده. فالمشروع القديم على إصدار 2018 يبقى عليه ويترجم بالمترجم الحديث، والمشروع الجديد يأخذ إصدار 2024، والمكتبات من الإصدارين تعمل معاً في نفس البرنامج. وصدرت حتى الآن أربعة إصدارات كبيرة: 2015 و2018 و2021 و2024، والأخير هو الافتراضي لكل مشروع جديد كما سوف ترى بعد قليل، وتفاصيله في دليل الإصدار 2024.

التثبيت Installation بالأداة rustup Toolchain Manager

قد تبحث عن Rust في مدير حزم نظامك فتجدها، فالأمر apt install rustc cargo يعمل على Debian، ولكنه يعطيك الإصدار الذي جمده النظام عند صدوره، وفي Debian 13 هذا إصدار من بداية 2025، أي متأخر عن الإصدار الحالي بأكثر من عشرة إصدارات، والمكتبات الحديثة في crates.io تطلب غالباً إصداراً قريباً من الحالي فلا تترجم عليه. ولهذا لا ينصح بهذه الطريقة لعملك اليومي، والطريقة الرسمية في صفحة التثبيت هي الأداة rustup، وهي مدير لسلاسل الأدوات Toolchains: تنزل المترجم ومعه أدواته، وتحدثها، وتسمح لك بأكثر من إصدار على نفس الجهاز. وقمنا بتثبيتها في Container نظيف من Debian 13 كما يفعل أي مطور على جهاز جديد، والسكربت كاملاً في lab/install.sh:

apt-get install -y --no-install-recommends curl ca-certificates gcc libc6-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
. "$HOME/.cargo/env"
rustup component add rustfmt clippy

في الأوامر أعلاه لاحظ التالي:

  • الحزمة gcc ليست لترجمة Rust نفسها، وإنما لأن المترجم يحتاج رابطاً Linker ومكتبة C للنظام في آخر خطوة من البناء، وأغلب من يثبت Rust لأول مرة على نظام نظيف يقابل الخطأ linker `cc` not found لأنه نسي هذه الحزمة. وما يفعله الرابط بالضبط شرحناه في مقال الرابط Linker والمحمل Loader.
  • الخيار --profile minimal ينزل المترجم ومدير الحزم والمكتبة القياسية فقط، دون التوثيق المحلي الكبير، والملف الشخصي الافتراضي default يضيف rustfmt وClippy والتوثيق. واخترنا الأصغر ثم أضفنا الأداتين بأنفسنا لنرى ما الذي ينزل بالضبط.
  • السطر . "$HOME/.cargo/env" يضيف المجلد ~/.cargo/bin إلى المتغير PATH في الجلسة الحالية، والمثبت يضيفه إلى ملفات الطرفية لكل جلسة قادمة.

والمخرج بعد التثبيت سوف يكون كما يلي:

rustc 1.99.1 (58cf8f9c3 2026-10-13)
cargo 1.99.1 (5f94df478 2026-08-27)
rustup 1.29.2 (d8c92c676 2026-10-21)
stable-x86_64-unknown-linux-gnu (default)
617M	/root/.rustup
21M	/root/.cargo

والسطر الرابع هو اسم سلسلة الأدوات الكامل: القناة stable، ثم المنصة التي يعمل عليها المترجم x86_64-unknown-linux-gnu، أي معالج 64 بت ونظام Linux ومكتبة glibc، وهذا الاسم الثلاثي يسمى Target Triple، وسوف تقابله عندما تبني لمنصة أخرى مثل aarch64-unknown-linux-gnu أو x86_64-unknown-linux-musl. ولاحظ الحجم: 617 MB في المجلد ~/.rustup حتى مع الملف الشخصي الأصغر، وأغلبها المكتبة القياسية مترجمة مسبقاً ومكتبات LLVM التي يعتمد عليها المترجم. أما المجلد ~/.cargo/bin ففيه هذه الأوامر:

cargo  cargo-clippy  cargo-fmt  cargo-miri  clippy-driver  rls  rust-analyzer
rust-gdb  rust-gdbgui  rust-lldb  rustc  rustdoc  rustfmt  rustup

وهذه ليست برامج كاملة، وإنما وكلاء Proxies صغيرة تضعها rustup، فعندما تكتب cargo تنظر الأداة أولاً أي سلسلة أدوات يطلبها هذا المجلد، ثم تشغل الأمر الحقيقي منها. ولهذا ترى rust-analyzer وcargo-miri في القائمة رغم أننا لم نثبتهما، فإذا شغلت أحدهما سوف تخبرك rustup أن المكون غير مثبت وكيف تضيفه.

تثبيت إصدار المشروع Toolchain File

الوكلاء هي التي تسمح لك بشيء مهم في فريق: أن يحدد كل مشروع إصداره. فإذا وضعت في جذر المشروع ملفاً اسمه rust-toolchain.toml فيه السطرين التاليين، فسوف تستخدم rustup هذا الإصدار في هذا المجلد وحده، وتنزله تلقائياً لأي زميل لا يملكه:

[toolchain]
channel = "1.99.1"
components = ["rustfmt", "clippy"]

وهذا يحل مشكلة «يعمل على جهازي» بين أعضاء الفريق وفي خط CI، لأن الجميع يترجم بنفس المترجم ونفس قواعد Clippy، والتحديث يصبح تعديلاً لسطر واحد في Pull Request يراجعه الفريق. وكل خيارات الملف في توثيق rustup.

المشكلة الأولى: Image رسمي بلا منسق ولا مدقق

في بقية المختبر استخدمنا الـ Image الرسمي rust:1.99.1-slim-trixie بدلاً من التثبيت في كل مرة، وهو 1.27 GB. وأول أمر شغلناه فيه كان cargo fmt، فكان المخرج:

error: 'rustfmt' is not installed for the toolchain '1.99.1-x86_64-unknown-linux-gnu'.
help: run `rustup component add rustfmt` to install it
error: 'cargo-clippy' is not installed for the toolchain '1.99.1-x86_64-unknown-linux-gnu'.

والسبب أن النسخة الصغيرة slim مبنية بالملف الشخصي الأصغر، وفيها فقط cargo وrustc والمكتبة القياسية، وهذا مناسب لمرحلة البناء في Dockerfile ولا يكفي لخط CI يفحص التنسيق. والحل سطر واحد في Dockerfile المختبر، وهو أيضاً في lab/Dockerfile:

FROM rust:1.99.1-slim-trixie
RUN rustup component add rustfmt clippy \
 && apt-get update && apt-get install -y --no-install-recommends time procps curl && rm -rf /var/lib/apt/lists/*

والدرس العملي أن تكتب المكونات التي يحتاجها مشروعك في rust-toolchain.toml كما في القسم السابق، فتثبتها rustup تلقائياً في أي بيئة، بدلاً من أن تكتشف في CI أن الأداة غير موجودة.

سلسلة أدوات Rust: rustup في الأعلى تثبت وتحدث سلسلة الأدوات stable 1.99.1 وفيها rustc المترجم وcargo لإدارة المشروع والحزم وrustfmt للتنسيق وClippy للتدقيق وrustdoc للتوثيق، وcargo يجلب الحزم من crates.io ويكتب Cargo.lock ويخرج الملف التنفيذي في target، والمحرر يتصل بالأداة rust-analyzer
أداة واحدة تثبت الأدوات، وأداة واحدة تديرها كل يوم

أول مشروع: cargo new وهيكل المشروع Project Layout

في Java تختار بين Maven وGradle، وفي Python بين pip وPoetry وuv، وفي JavaScript بين npm وpnpm وyarn، أما في Rust فلا يوجد هذا الاختيار تقريباً، فالأداة Cargo هي مدير الحزم وأداة البناء ومشغل الاختبارات في نفس الوقت، وكل مشروع Rust تقريباً في العالم يبنى بها. ولهذا تفتح أي مشروع مفتوح المصدر فتجد نفس الأوامر ونفس الهيكل. والأمر الأول:

cargo new store
cd store
find . -type f | sort
    Creating binary (application) `store` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
./.git/HEAD
./.git/config
./.git/description
./.git/hooks/README.sample
./.git/info/exclude
./.gitignore
./Cargo.toml
./src/main.rs

والأمر أنشأ مستودع git فارغاً، وملف .gitignore فيه سطر واحد هو /target، لأن هذا المجلد سوف يحتوي كل ناتج البناء ولا يجب أن يدخل المستودع أبداً، وملف المشروع Cargo.toml، وملف src/main.rs فيه دالة main تطبع Hello, world. والكلمة binary في السطر الأول تعني أن المشروع برنامج يشغل، والخيار cargo new --lib ينشئ مكتبة بدلاً منه. وملف المشروع نفسه قصير جداً:

[package]
name = "store"
version = "0.1.0"
edition = "2024"

[dependencies]

ولاحظ السطر edition = "2024" الذي تكلمنا عنه، فالمشروع الجديد يأخذ أحدث إصدار كبير تلقائياً. والقسم [dependencies] فارغ، وسوف نملؤه بعد قليل. وعند أول تشغيل:

cargo run
   Compiling store v0.1.0 (/work/store)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.55s
     Running `target/debug/store`
Hello, world!

والأمر cargo run ترجم المشروع ثم شغله، والسطر الثاني يخبرك بشيء مهم: البناء تم بالملف الشخصي dev، أي دون تحسين Unoptimized ومع معلومات التصحيح Debuginfo، والناتج في target/debug/. وهذا هو الوضع الافتراضي لكل أوامر التطوير، وسوف نرى الفرق بينه وبين وضع الإصدار بالأرقام.

أين تضع ماذا؟ الهيكل المتفق عليه Conventions

Cargo لا يحتاج ملف إعداد يقول أين الكود، لأنه يعتمد على أسماء متفق عليها، وهذا يشبه Maven في Java أكثر مما يشبه Make. والجدول التالي يجمع ما سوف تراه في أغلب المشاريع، والتفاصيل في صفحة هيكل المشروع في كتاب Cargo:

المسارما فيهيقابله في لغة تعرفها
Cargo.tomlاسم المشروع وإصداره واعتمادياته وإعدادات البناءpackage.json أو pom.xml أو pyproject.toml
Cargo.lockالإصدار الدقيق لكل اعتمادية بعد الحل، يكتبه Cargopackage-lock.json أو go.sum
src/main.rsنقطة البداية لبرنامج يشغلالملف الذي فيه main
src/lib.rsجذر مكتبة المشروع، والكود الذي تختبره وتشاركهحزمة Python أو Package في Go
src/bin/*.rsبرامج إضافية في نفس المشروع، لكل ملف برنامجعدة main في Go تحت cmd/
tests/*.rsاختبارات التكامل، تستخدم المكتبة من الخارجsrc/test/java أو tests/ في pytest
examples/ وbenches/أمثلة تشغل بالأمر cargo run --example، وقياسات أداءمجلد الأمثلة في أي مكتبة
target/كل ناتج البناء، ولا يدخل المستودعbuild/ أو dist/

والنمط الذي ينصح به أغلب المطورين أن تضع المنطق كله في lib.rs، وتترك main.rs رفيعاً يقرأ المدخلات ويستدعي المكتبة ويطبع النتيجة، والسبب أن الكود في المكتبة يمكن اختباره من tests/ واستخدامه من برنامج آخر، بينما الكود في main.rs لا يمكن استدعاؤه من خارجه. وهذا ما سوف نفعله في المتجر. وعندما يكبر المشروع إلى عدة حزم، مثل خدمة ومكتبة مشتركة وأداة سطر أوامر، تجمعها في مساحة عمل Workspace بملف Cargo.toml في الجذر يذكر أعضاءها، فتشترك في ملف قفل واحد ومجلد target واحد.

هيكل مشروع store: الملف Cargo.toml يصف المشروع وCargo.lock يثبت الإصدارات، وsrc/lib.rs فيه المنطق والأنواع والاختبارات الداخلية، وsrc/main.rs رفيع يقرأ الملف ويستدعي المكتبة، وtests فيه اختبارات التكامل التي ترى المكتبة من الخارج، وtarget/debug وtarget/release فيهما الناتج ولا يدخلان git
المنطق في المكتبة، والبرنامج غلاف رفيع حولها

جولة في اللغة Syntax Tour لمن يعرف لغة أخرى

قبل أن نكتب كود المتجر، لنمر على ما سوف تقرأه فيه، مقارنة بما تعرفه. ولن نشرح كل شيء، فالتفاصيل في كتاب Rust الرسمي، وإنما نركز على الأماكن التي تختلف فيها Rust عن Python وJava وGo، وهي الأماكن التي يتعثر فيها القادم الجديد. وإذا أردت الصورة الأوسع عن أنماط البرمجة وأنظمة الأنواع فقد شرحناها في مقال أنماط البرمجة وأنظمة الأنواع.

المتغيرات Variables: ثابتة إلا إذا قلت mut

let price = 990;            // i32 by default, cannot change
let mut stock = 10;         // mut: may change
stock -= 1;
let price: i64 = price * 3; // shadowing: a new variable with the same name and a new type
const VAT_PERCENT: i64 = 15;

في Java وPython كل متغير قابل للتغيير إلا إذا قلت final، وفي Rust العكس: كل متغير ثابت إلا إذا قلت mut، فإذا حاولت تغيير price في السطر الأول رفض المترجم البرنامج. والسبب أن أغلب المتغيرات في البرنامج لا تتغير بعد إنشائها، فجعل الثبات هو الافتراضي يجعل القليل الذي يتغير ظاهراً للقارئ، ويساعد المترجم في قواعد الاستعارة التي سوف نشرحها في المقال التالي. والسطر الرابع قد يفاجئك: إعادة تعريف متغير بنفس الاسم Shadowing مسموحة، وتنشئ متغيراً جديداً قد يكون من نوع آخر، وهي شائعة عند تحويل قيمة، مثل نص تقرؤه ثم تحوله إلى رقم بنفس الاسم. ولاحظ أننا لم نكتب النوع في أغلب الأسطر، لأن المترجم يستنتجه Type Inference، ولكن النوع ثابت لا يتغير وقت التشغيل كما في Python، والرقم الصحيح دون نوع صريح يكون i32.

كل شيء تقريباً تعبير Expression

fn shipping(total: i64) -> i64 {
    if total >= 20000 { 0 } else { 1500 }   // the if itself is the value; no `return`, no `;`
}

في Java تكتب return total >= 20000 ? 0 : 1500;، وفي Rust لا يوجد العامل ? : لأن if نفسها تعبير يرجع قيمة، وكذلك match والكتلة { }. وآخر سطر في الدالة دون فاصلة منقوطة هو قيمتها، ولهذا لا ترى return كثيراً في كود Rust إلا للخروج المبكر. وهذه أكثر نقطة تربك القادم الجديد في الأيام الأولى: إذا وضعت فاصلة منقوطة بعد آخر سطر صار السطر جملة لا قيمة لها، ويخبرك المترجم أن الدالة ترجع ()، وهو النوع الفارغ الذي يقابل void، بدلاً من الرقم الذي وعدت به.

الهياكل Structs والدوال المرتبطة impl بدل الكلاسات Classes

pub struct Product {
    pub id: u32,
    pub name_ar: String,
    pub price_halalas: i64,
    pub stock: u32,
}

impl Product {
    pub fn new(id: u32, name_ar: &str, price_halalas: i64) -> Self {
        Product { id, name_ar: name_ar.to_string(), price_halalas, stock: 0 }
    }
    pub fn in_stock(&self) -> bool { self.stock > 0 }
}

لا توجد كلمة class في Rust، وإنما هيكل Struct فيه البيانات فقط، وكتلة impl منفصلة فيها الدوال. ولا توجد دالة بناء Constructor خاصة، فالاسم new مجرد عرف، وهي دالة عادية ترجع Self. والمعامل الأول &self هو ما يقابل this، ويكتب صراحة، وشكله يقول الكثير: &self تقرأ فقط، و&mut self تعدل، وself تأخذ الكائن كله وتستهلكه. والكلمة pub تجعل الحقل أو الدالة مرئية خارج الوحدة Module، وكل شيء دونها خاص بالوحدة، وهذا عكس Python حيث كل شيء عام. ولا توجد وراثة بين الهياكل، والمشاركة في السلوك تتم بالسمات Traits كما سوف نرى.

التعدادات Enums مع match: أقوى ما في اللغة

pub enum OrderError {
    UnknownProduct(u32),
    OutOfStock { id: u32, wanted: u32, left: u32 },
}

match err {
    OrderError::UnknownProduct(id) => println!("no product {id}"),
    OrderError::OutOfStock { id, left, .. } => println!("only {left} left of {id}"),
}

التعداد في Java وC# قائمة أسماء ثابتة، أما في Rust فكل حالة من التعداد قد تحمل بياناتها الخاصة، فالحالة الأولى تحمل رقم المنتج، والثانية تحمل ثلاثة حقول بأسمائها. وهذا ما يسمى في نظرية الأنواع النوع المجموع Sum Type، وهو موجود في Haskell وSwift وKotlin بأشكال مختلفة. والعبارة match تفكك الحالة وتأخذ بياناتها، والأهم أن المترجم يرفض البرنامج إذا نسيت حالة، فإذا أضاف زميلك حالة ثالثة للخطأ بعد شهر، فسوف يظهر خطأ ترجمة في كل match لم يتعامل معها، بدلاً من أن تمر الحالة الجديدة صامتة إلى فرع default كما يحدث في switch.

لا يوجد null: النوعان Option و Result

لا يوجد في Rust الآمنة null ولا nil ولا None الذي يمكن أن يكون في أي متغير، وإنما نوعان في المكتبة القياسية: Option<T> لقيمة قد لا تكون موجودة، وحالتاه Some(v) وNone، وResult<T, E> لعملية قد تفشل، وحالتاه Ok(v) وErr(e). وكلاهما تعداد عادي من النوع الذي رأيته للتو، فلا يمكنك استخدام القيمة داخلهما قبل أن تتعامل مع الحالة الأخرى. والعلامة ? بعد أي عملية ترجع Result تعني: إذا كانت Err فارجع بها من الدالة الآن، وإذا كانت Ok فأعطني القيمة. وهذه الطريقة في الأخطاء، مقارنة بالاستثناءات في Java وقيم الخطأ في Go، شرحناها في مقال كيف تتعامل لغات البرمجة مع الأخطاء، وسوف نعود إليها بالتفصيل في المقال التالي من هذه السلسلة.

السمات Traits بدل الوراثة Inheritance

trait Priced {
    fn price_halalas(&self) -> i64;
    fn price_sar(&self) -> String {                   // a default method
        format!("{:.2}", self.price_halalas() as f64 / 100.0)
    }
}

impl Priced for Product {
    fn price_halalas(&self) -> i64 { self.price_halalas }
}

fn print_price(item: &impl Priced) { println!("{}", item.price_sar()); }

السمة Trait تشبه الواجهة Interface في Java وGo: مجموعة دوال يعد النوع بتنفيذها، وقد يكون لبعضها تنفيذ افتراضي. والفرق عن Go أن التنفيذ صريح بكتلة impl Priced for Product، وليس ضمنياً لأن الدوال تطابقت. والفرق عن Java أنك تستطيع تنفيذ سمتك على نوع لم تكتبه، مثل نوع من المكتبة القياسية. والدالة print_price تقبل أي نوع ينفذ السمة، والمترجم يولد نسخة منها لكل نوع تستدعى به، وهذا اسمه Monomorphization، فلا يوجد ثمن وقت التشغيل لهذا الاستدعاء. وإذا احتجت قائمة فيها أنواع مختلفة تنفذ نفس السمة، فهنا تستخدم Box<dyn Priced>، وهو الاستدعاء الديناميكي الذي يشبه ما تفعله Java مع الواجهات دائماً.

المغلقات Closures والمكررات Iterators

let total: i64 = products
    .iter()
    .filter(|p| p.stock > 0)
    .map(|p| p.price_halalas)
    .sum();

إذا كنت تكتب Streams في Java أو List Comprehensions في Python فهذا مألوف لك، والدالة |p| p.stock > 0 مغلقة Closure تأخذ المنتج وترجع قيمة. والفرق المهم أن هذه السلسلة كلها لا تنشئ أي قائمة وسيطة، ولا تعمل إلا عندما تطلب النتيجة في sum، والمترجم يحولها في وضع الإصدار إلى حلقة عادية بنفس سرعة الحلقة التي تكتبها بيدك. ولهذا يفضل كود Rust المكررات على حلقات for بالفهرس، وهي أيضاً أأمن لأنها لا يمكن أن تخرج عن حدود القائمة.

الماكرو Macros: لماذا ينتهي println بعلامة التعجب؟

عندما ترى اسماً ينتهي بعلامة ! مثل println! وvec! وformat! فهو ماكرو Macro وليس دالة، أي كود يولد كوداً وقت الترجمة. والسبب أن Rust لا تملك دوال بعدد متغير من المعاملات للكود العادي، فالماكرو println!("{} x {}", a, b) يقرأ نص التنسيق وقت الترجمة، ويتأكد أن عدد القيم يساوي عدد الأقواس، ويرفض البرنامج إذا لم يتساويا. فالخطأ الذي يظهر في Python وقت التشغيل عندما تنسى قيمة في format يظهر هنا قبل أن يعمل البرنامج. ويمكنك أيضاً كتابة اسم المتغير داخل الأقواس مباشرة، مثل println!("{price}").

مقارنة سطر بسطر بين Python وJava وGo وRust: المتغير الثابت والمتغير القابل للتغيير، والشرط كتعبير، والكلاس مقابل struct مع impl، والواجهة مقابل trait، وnull مقابل Option، والاستثناء مقابل Result والعلامة ?، والقائمة المعالجة بالمكررات
نفس الأفكار التي تعرفها، بأسماء وقواعد أخرى

كود المتجر Store Library: المكتبة والبرنامج

الآن نكتب الجزء الذي طلبه الفريق: مكتبة تحسب قيمة طلب من قائمة منتجات، مع ضريبة القيمة المضافة 15% بالهللات، وبرنامج يقرأ المنتجات من ملف JSON ويطبع طلبين. ولماذا الهللات؟ لأن الأرقام العشرية في الحاسب لا تمثل 0.1 بدقة، وقد شرحنا ذلك في مقال الأرقام العشرية Floating Point من الداخل، فكل المبالغ في المتجر أرقام صحيحة بالهللة، والريال 100 هللة. وهذا قلب المكتبة في lab/src/lib.rs:

/// VAT at 15% in halalas, rounded half up.
///
/// ```
/// assert_eq!(store::vat_halalas(2970), 446); // 445.5 -> 446
/// ```
pub fn vat_halalas(subtotal: i64) -> i64 {
    (subtotal * 15 + 50) / 100
}

/// Returns (subtotal, vat, total) for a list of (product id, qty).
pub fn order_total(
    products: &[Product],
    items: &[(u32, u32)],
) -> Result<(i64, i64, i64), OrderError> {
    let mut subtotal = 0;
    for &(id, qty) in items {
        let p = products
            .iter()
            .find(|p| p.id == id)
            .ok_or(OrderError::UnknownProduct(id))?;
        if qty > p.stock {
            return Err(OrderError::OutOfStock {
                id,
                wanted: qty,
                left: p.stock,
            });
        }
        subtotal += p.price_halalas * qty as i64;
    }
    let vat = vat_halalas(subtotal);
    Ok((subtotal, vat, subtotal + vat))
}

في الكود أعلاه لاحظ التالي:

  • دالة الضريبة تضرب في 15 وتضيف 50 قبل القسمة على 100، وهذا هو التقريب لأقرب هللة مع رفع النصف Round Half Up لأرقام موجبة، دون أي رقم عشري: 2970 هللة ضريبتها 445.5 فتصبح 446. والقسمة بين رقمين صحيحين في Rust تقطع الكسر كما في Java وC، وليس كما في Python 3 حيث / ترجع رقماً عشرياً.
  • التعليق الذي يبدأ بثلاث شرطات مائلة /// توثيق للدالة، والكود داخله بين علامات ``` ليس زينة، وإنما اختبار حقيقي يشغله cargo test كما سوف ترى.
  • المعامل &[Product] شريحة Slice، أي استعارة لجزء من قائمة دون نسخها، فالدالة تقبل Vec<Product> أو مصفوفة ثابتة أو جزءاً من أي منهما.
  • السطر .ok_or(OrderError::UnknownProduct(id))? يحول نتيجة البحث من Option إلى Result، ثم يخرج من الدالة بالخطأ إذا لم يوجد المنتج. وهذا السطر الواحد يقابل في Go ثلاثة أسطر من if p == nil { return ..., err }.
  • التحويل qty as i64 صريح، لأن Rust لا تحول بين أنواع الأرقام تلقائياً أبداً، فلا يمكنك ضرب i64 في u32 دون أن تقول ذلك. وهذا مزعج في الأيام الأولى، ولكنه يمنع فئة كاملة من أخطاء الإشارة والحجم التي تعرفها C.

أما البرنامج في src/main.rs فيقرأ اسم الملف من المعاملات، ويتعامل مع خطأ القراءة بنفسه:

let text = match fs::read_to_string(&arg) {
    Ok(t) => t,
    Err(e) => {
        eprintln!("cannot read {arg}: {e}");
        process::exit(1);
    }
};
let products: Vec<Product> = serde_json::from_str(&text).expect("products.json is not valid");

والدالة read_to_string لا ترمي استثناء إذا لم يوجد الملف، وإنما ترجع Result، فلا يمكنك أن تصل إلى النص قبل أن تقرر ماذا تفعل بالخطأ، وهنا قررنا أن نطبع رسالة ونخرج برمز 1. أما السطر الثاني فيستخدم expect، وهو يقول: إذا فشل التحويل فأوقف البرنامج بهذه الرسالة، وهو مقبول في أداة صغيرة وغير مقبول في خدمة، كما سوف نرى في مقال الأخطاء الشائعة.

الاعتماديات Dependencies من crates.io

لكي نقرأ JSON نحتاج مكتبة، والمكتبة في Rust اسمها Crate، والسجل العام لها crates.io، كما في قسم النشر في كتاب Cargo. والمكتبة القياسية في Rust صغيرة عمداً، فلا يوجد فيها JSON ولا HTTP ولا قواعد بيانات، والسبب أن ما يدخل المكتبة القياسية يبقى فيها إلى الأبد بنفس الشكل، فيترك الفريق هذه المجالات للمكتبات التي تتطور بسرعتها. والأشهر في JSON هي serde، والأمر cargo add يضيفها إلى الملف:

cargo add serde --features derive
cargo add serde_json
    Updating crates.io index
      Adding serde v1.0.229 to dependencies
    Updating crates.io index
     Locking 7 packages to highest Rust 1.99.1 compatible versions
      Adding proc-macro2 v1.0.107
      Adding quote v1.0.47
      Adding serde v1.0.229
      Adding serde_core v1.0.229
      Adding serde_derive v1.0.229
      Adding syn v3.0.9
      Adding unicode-ident v1.0.27
      Adding serde_json v1.0.154 to dependencies
     Locking 4 packages to highest Rust 1.99.1 compatible versions
      Adding itoa v1.0.18
      Adding memchr v2.8.3
      Adding serde_json v1.0.154
      Adding zmij v1.0.23
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"

في المخرج أعلاه لاحظ التالي:

  • طلبنا مكتبتين فدخلت إحدى عشرة حزمة في ملف القفل Cargo.lock، لأن كل مكتبة تجلب اعتمادياتها. والميزة derive هي التي جلبت serde_derive وsyn وquote، وهي مكتبات تقرأ كود Rust نفسه وقت الترجمة لتولد كود القراءة والكتابة، وهي الأثقل في وقت البناء كما سوف نرى.
  • العبارة highest Rust 1.99.1 compatible versions تعني أن Cargo اختار أحدث إصدار يناسب المترجم الذي عندك، فإذا كانت مكتبة تطلب مترجماً أحدث تجنبها، وهذه ميزة دخلت Cargo حديثاً وتحل مشكلة قديمة كانت تظهر مع المترجمات القديمة.
  • الإصدار "1.0.154" في الملف لا يعني هذا الإصدار بالضبط، وإنما أي إصدار متوافق معه بحسب الإصدار الدلالي SemVer، أي من 1.0.154 حتى ما قبل 2.0.0. والإصدار الدقيق الذي يبنى به المشروع هو المكتوب في Cargo.lock، ولهذا يدخل ملف القفل المستودع في البرامج. وتفاصيل الحل والقفل ومقارنتها بأدوات npm وpip وMaven في مقال مديرو الحزم.

وبعد ذلك يكفي سطر #[derive(Deserialize)] فوق الهيكل Product، فتولد serde كود القراءة من JSON وقت الترجمة، وأي حقل ناقص أو بنوع خاطئ في الملف يرجع خطأ واضحاً بدلاً من قيمة فارغة.

البناء Build: وضع التطوير Debug أم الإصدار Release؟

هذا القسم يجيب عن سؤال سوف تسأله في اليوم الأول: لماذا البناء بطيء؟ وقمنا بقياس ثلاث حالات: بناء نظيف بعد حذف target، وبناء بعد تعديل ملف واحد، وبناء بوضع الإصدار:

cargo clean
/usr/bin/time -f "wall %e s, max RSS %M KB" cargo build
touch src/main.rs && cargo build
cargo build --release
ls -l target/debug/store target/release/store
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 39.22s
wall 39.31 s, max RSS 332008 KB
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.47s
    Finished `release` profile [optimized] target(s) in 26.09s
6900712 target/debug/store
582048 target/release/store

وهذه الأرقام تقول أشياء أكثر مما تبدو:

  • البناء النظيف أخذ 39 ثانية لبرنامج من مئة سطر، وأغلب الوقت ليس في كودنا، وإنما في ترجمة الاعتماديات الإحدى عشرة من المصدر، فـ Cargo لا ينزل مكتبات مترجمة مسبقاً كما يفعل Maven مع ملفات jar، وإنما ينزل كودها ويترجمه على جهازك بنفس المترجم والخيارات. والثمن هو هذا الانتظار في المرة الأولى، والفائدة أن كل شيء مترجم لمعالجك ونظامك بالضبط.
  • بعد تعديل ملف واحد نزل الوقت إلى 1.47 ثانية، لأن Cargo يحفظ المكتبات المترجمة في target ولا يعيدها إلا إذا تغير شيء فيها، والمترجم نفسه يعيد ترجمة الأجزاء المتغيرة فقط من كودك، وهذا اسمه البناء التزايدي Incremental Compilation. وهذا هو الرقم الذي تعيشه في يومك، لا الأول.
  • الملف الناتج بوضع التطوير 6.9 MB، وبوضع الإصدار 582 KB، أي أصغر بحوالي اثنتي عشرة مرة، والفرق في معلومات التصحيح وفي الكود غير المحسن. وفي وضع الإصدار يعمل المحسن Optimizer الذي يحذف ويدمج ويضمن الدوال، ولهذا هو أبطأ في الترجمة وأسرع بكثير في التشغيل، وقد تجد برنامجاً حسابياً أسرع عشر مرات أو أكثر في هذا الوضع.
  • الذاكرة القصوى للبناء 332 MB لهذا المشروع الصغير، وفي المشاريع الكبيرة يصل المترجم إلى عدة جيجابايت، وهذا يهم إذا كان خط CI يعمل على جهاز صغير.

ولأننا شغلنا المختبر على جهاز مشترك تعمل عليه برامج أخرى في نفس الوقت، فأرقام الوقت هنا تقريبية، فقد أخذ نفس البناء النظيف في تشغيل سابق دقيقة وربعاً بسبب ضغط الجهاز. والأحجام لا تتغير بين التشغيلات، ولهذا هي الرقم الذي تعتمد عليه. والقاعدة العملية: استخدم cargo build أثناء التطوير، وcargo build --release لكل ما يقاس أو ينشر، ولا تقارن أداء برنامج Rust بلغة أخرى بالوضع الافتراضي أبداً، فهذا أشهر خطأ في المقارنات المنشورة على الإنترنت.

الفحص دون بناء Type Check: الأمر cargo check

أثناء الكتابة لا تحتاج الملف التنفيذي في كل مرة، وإنما تريد أن تعرف هل يقبل المترجم الكود. والأمر cargo check يمر بكل مراحل الفحص، الأنواع والاستعارة والأخطاء، ولا يولد الكود النهائي، فيكون أسرع من cargo build بوضوح، وهو ما يشغله المحرر في الخلفية مع كل حفظ. وما يحدث في كل مرحلة من مراحل الترجمة، ولماذا توليد الكود هو الجزء الأغلى، شرحناه في مقال المترجم والمفسر وJIT.

ماذا يوجد داخل الملف التنفيذي؟ الربط Linking

ldd target/release/store
	linux-vdso.so.1 (0x00007ffc09121000)
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f8f58756000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8f58562000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f8f587f7000)

الملف لا يعتمد على أي مكتبة Rust وقت التشغيل، فالمكتبة القياسية وserde وكل الاعتماديات ربطت داخله ربطاً ثابتاً Static Linking، ولا يعتمد إلا على مكتبة C في النظام libc.so.6 ومكتبة libgcc_s التي تساعد في فك ال Stack عند التوقف Panic. ولهذا تنسخ الملف إلى أي سيرفر Linux فيه glibc بنفس الإصدار أو أحدث فيعمل، دون تثبيت أي بيئة تشغيل كما في Java وPython، وهذه من أكبر أسباب اختيار Rust للأدوات وال Containers الصغيرة. وإذا أردت ملفاً لا يعتمد حتى على glibc فتبني للمنصة x86_64-unknown-linux-musl، وهذا ما سوف نقارنه في مقال الخدمة الحقيقية.

وضع التطوير مقابل وضع الإصدار لنفس المشروع: البناء النظيف 39.2 ثانية بوضع التطوير و26.1 ثانية بوضع الإصدار بعده، والبناء بعد تعديل ملف 1.47 ثانية، والملف 6.9 MB مقابل 582 KB، وتجاوز u8 للرقم 255 يوقف البرنامج بخطأ في وضع التطوير ويلتف إلى صفر في وضع الإصدار
نفس الكود، وملفان مختلفان، وسلوكان مختلفان عند تجاوز الحد

التشغيل Run: المخرج الحقيقي ورموز الخروج Exit Codes

./target/release/store /lab/products.json; echo "exit $?"
./target/release/store /lab/nope.json; echo "exit $?"
 1  قهوة عربية 250 جرام        9.90 SAR  stock 10
 2  تمر سكري 1 كيلو           45.00 SAR  stock 25
 3  دلة نحاس                 120.00 SAR  stock 3
order: subtotal 74.70 + VAT 11.21 = 85.91 SAR
order rejected: OutOfStock { id: 3, wanted: 5, left: 3 }
exit 0
cannot read /lab/nope.json: No such file or directory (os error 2)
exit 1

الطلب الأول ثلاث قهوات وكيلو تمر: 3 × 990 + 4500 = 7470 هللة، وضريبتها 1120.5 فتقرب إلى 1121، والمجموع 8591 هللة أي 85.91 ريال. والطلب الثاني خمس دلال والمخزون ثلاث، فرجع الخطأ بكل بياناته، وطبعناه بالتنسيق {e:?} الذي يطبع أي قيمة نوعها يشتق Debug. وفي الحالة الثانية لم يجد البرنامج الملف فطبع رسالة النظام وخرج برمز 1 كما كتبنا. ولاحظ أن محاذاة الأعمدة العربية تبدو غير منتظمة، والسبب أن {:<22} يعد الحروف لا عرضها على الشاشة، وهذه مشكلة أي لغة مع النصوص العربية في الطرفية وليست خاصة بـ Rust.

المنسق rustfmt: نقاش واحد أقل في المراجعة Code Review

في Rust منسق رسمي واحد، rustfmt، بإعدادات افتراضية يستخدمها أغلب المجتمع، فلا يضيع الفريق وقته في الخلاف على المسافات والأقواس. ولنرى عمله أضفنا ملفاً مكتوباً بلا أي تنسيق، ثم طلبنا الفحص دون التعديل:

cargo fmt --check; echo "exit $?"
exit 1
Diff in /work/store/src/bin/messy.rs:1:
-fn   discount(total:i64,percent:i64)->i64{ if percent>100{return total} total-total*percent/100 }
-fn main(){println!("{}",discount(1000,10))}
+fn discount(total: i64, percent: i64) -> i64 {
+    if percent > 100 {
+        return total;
+    }
+    total - total * percent / 100
+}
+fn main() {
+    println!("{}", discount(1000, 10))
+}

والخيار --check لا يغير شيئاً، وإنما يطبع الفرق ويخرج برمز 1، وهذا ما تضعه في خط CI ليرفض أي كود غير منسق. ولاحظ أن المنسق أضاف الفاصلة المنقوطة بعد return total، ولم يغير منطق الكود. وبعد cargo fmt دون الخيار تغيرت الملفات وصار الفحص ينتهي برمز 0. والإعدادات القليلة التي قد تحتاجها، مثل عرض السطر، توضع في ملف rustfmt.toml، والنصيحة ألا تغيرها إلا لسبب قوي، لأن قيمة المنسق كله في أن الجميع يرى نفس الشكل.

💡
أول مرة شغلنا الفحص أظهر فرقاً في ملف lib.rs نفسه وليس فقط في الملف الفوضوي، لأن توقيع الدالة order_total تجاوز 100 حرف، وهو الحد الافتراضي لعرض السطر، فقسمه المنسق على عدة أسطر. والكود الذي تراه في هذا المقال هو الكود بعد التنسيق. فإذا كتبت سطراً طويلاً ووجدته مقسماً بعد الحفظ، فهذا هو السبب.

المدقق Clippy: أكثر من 800 قاعدة Lint

المترجم يرفض الكود الخاطئ، والمدقق Clippy ينبهك إلى الكود الصحيح الذي يمكن أن يكون أوضح أو أسرع أو أأمن، وفيه أكثر من 800 قاعدة Lint مقسمة إلى مجموعات. ووضعنا في main.rs سطراً يكتبه كل قادم من Java أو Go:

cargo clippy
warning: length comparison to zero
  --> src/main.rs:26:8
   |
26 |     if products.len() == 0 {
   |        ^^^^^^^^^^^^^^^^^^^ help: using `is_empty` is clearer and more explicit: `products.is_empty()`
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#len_zero
   = note: `#[warn(clippy::len_zero)]` on by default

warning: `store` (bin "store") generated 1 warning (run `cargo clippy --fix --bin "store" -p store -- ` to apply 1 suggestion)

والتحذير يعطيك كل شيء: السطر والعمود، والاقتراح، واسم القاعدة clippy::len_zero، ورابط شرحها، وحتى الأمر cargo clippy --fix الذي يطبق الاقتراح بنفسه. وهذه القاعدة بسيطة، ولكن Clippy يكتشف أيضاً أشياء أخطر، مثل مقارنة أرقام عشرية بالمساواة، أو نسخ قيمة كبيرة بلا حاجة، أو قفل تحتفظ به أطول من اللازم. والممارسة الشائعة في CI هي cargo clippy -- -D warnings، أي اعتبار كل تحذير خطأ يوقف البناء، وأن تتعطل قاعدة معينة في سطر معين بالسطر #[allow(clippy::اسم_القاعدة)] مع تعليق يشرح السبب، بدلاً من تعطيلها في المشروع كله.

وماذا عن المحرر؟ الأداة الرسمية لكل المحررات هي rust-analyzer، وهي خادم لغة LSP يعطي VS Code وNeovim وZed وغيرها الإكمال والتنقل وإظهار الأنواع المستنتجة فوق المتغيرات، وتشغل cargo check أو Clippy مع كل حفظ. وإظهار الأنواع المستنتجة مفيد جداً لمن يتعلم، لأنه يريك ما استنتجه المترجم في كل سطر دون أن تسأله.

الاختبارات Testing: ثلاثة أنواع بأمر واحد

في Java تحتاج JUnit، وفي Python تحتاج pytest، وفي JavaScript تختار بين Jest وVitest، أما في Rust فالاختبارات جزء من اللغة والأداة، دون أي مكتبة. وفي آخر lib.rs وحدة اختبارات صغيرة:

#[cfg(test)]
mod tests {
    use super::*;

    fn coffee() -> Vec<Product> {
        vec![Product { id: 1, name_ar: "قهوة".into(), price_halalas: 990, stock: 10 }]
    }

    #[test]
    fn three_coffees() {
        assert_eq!(order_total(&coffee(), &[(1, 3)]), Ok((2970, 446, 3416)));
    }

    #[test]
    fn out_of_stock() {
        assert_eq!(
            order_total(&coffee(), &[(1, 11)]),
            Err(OrderError::OutOfStock { id: 1, wanted: 11, left: 10 })
        );
    }
}
     Running unittests src/lib.rs (target/debug/deps/store-6726af7bede67d4e)

running 2 tests
test tests::out_of_stock ... ok
test tests::three_coffees ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

     Running unittests src/main.rs (target/debug/deps/store-935728cb88b377ea)

running 0 tests

   Doc-tests store

running 1 test
test src/lib.rs - vat_halalas (line 20) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

في المخرج أعلاه لاحظ التالي:

  • السطر #[cfg(test)] يعني أن هذه الوحدة لا تترجم إلا عند الاختبار، فلا تدخل الملف النهائي. ولأنها داخل نفس الملف فهي ترى الدوال الخاصة غير المعلنة بـ pub، وهذه هي اختبارات الوحدة Unit Tests في Rust.
  • الأمر شغل ثلاث مجموعات: اختبارات المكتبة، واختبارات البرنامج ولا يوجد فيها شيء، واختبارات التوثيق Doc-tests، وهي المثال الذي كتبناه في تعليق دالة الضريبة. فإذا غير أحدهم الدالة وبقي المثال في التوثيق قديماً فسوف يفشل الاختبار، فالتوثيق في Rust لا يكذب.
  • الاختبارات تعمل بالتوازي في خيوط Threads بشكل افتراضي، ولذلك لا يجب أن يعتمد اختبار على حالة يتركها اختبار آخر، وهذا ما سوف يهمنا عندما نختبر قاعدة البيانات في مقال الخدمة.
  • النوع الثالث، اختبارات التكامل Integration Tests، يوضع في المجلد tests/، وكل ملف فيه برنامج مستقل يرى المكتبة من الخارج كما يراها مستخدمها، أي الدوال المعلنة فقط. والفرق بين الأنواع ومتى تحتاج كلاً منها في مقال أنواع الاختبارات.

ولكي ترى كيف يبدو الفشل، كتبنا في tests/failing_test.rs اختباراً يتوقع ضريبة مقطوعة 445 بدلاً من 446، وهو الخطأ الذي يقع فيه من يحسب الضريبة بالقسمة الصحيحة دون تقريب:

#[test]
fn vat_is_truncated() {
    assert_eq!(store::vat_halalas(2970), 445, "VAT on 29.70 SAR");
}
running 1 test
test vat_is_truncated ... FAILED

failures:

---- vat_is_truncated stdout ----

thread 'vat_is_truncated' (1455) panicked at tests/failing_test.rs:4:5:
assertion `left == right` failed: VAT on 29.70 SAR
  left: 446
 right: 445
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--test failing_test`

والرسالة تعطيك الملف والسطر، والقيمتين، والرسالة التي كتبتها. ولاحظ كلمة panicked: الاختبار الفاشل في Rust هو ببساطة توقف Panic داخل خيط الاختبار، والماكرو assert_eq! يوقف الخيط عندما لا تتساوى القيمتان، والمشغل يلتقط التوقف ويسجله فشلاً ثم يكمل باقي الاختبارات. ويمكنك تشغيل اختبار واحد بجزء من اسمه، مثل cargo test three، وإظهار ما تطبعه الاختبارات الناجحة بالخيار cargo test -- --nocapture، لأن المشغل يخفيه افتراضياً.

أنواع الاختبارات في Rust: اختبارات الوحدة داخل lib.rs في الوحدة tests وترى الدوال الخاصة ونجح منها 2، واختبارات التكامل في مجلد tests وترى المكتبة من الخارج وفشل فيها الاختبار الذي توقع 445 بدلاً من 446، واختبارات التوثيق من الأمثلة داخل تعليقات /// ونجح منها 1، وكلها تعمل بالأمر cargo test
أمر واحد يشغل الاختبارات وأمثلة التوثيق

ماذا يحدث إذا تجاوز الرقم حده؟ تجاوز السعة Integer Overflow

هذه التجربة تريك أن وضعي البناء ليسا فقط سرعة وحجماً. وضعنا في البرنامج فرعاً يمثل مخزوناً من النوع u8، أي رقم من 0 إلى 255، قيمته وقت التشغيل 255، ثم نضيف إليه وحدة. وحسبنا القيمة من عدد معاملات البرنامج حتى لا يراها المترجم وقت الترجمة، لأنه إذا رآها رفض البرنامج من البداية:

let stock: u8 = env::args().count() as u8 + 253; // 255 at runtime, the compiler cannot see it
let after_restock = stock + 1;
println!("stock after restock: {after_restock}");
### overflow debug
thread 'main' (104) panicked at src/main.rs:14:29:
attempt to add with overflow
exit 101
### overflow release
stock after restock: 0
exit 0

في وضع التطوير توقف البرنامج برسالة واضحة attempt to add with overflow وبرمز الخروج 101، وهو الرمز الذي تخرج به برامج Rust عند أي Panic في الخيط الرئيسي. وفي وضع الإصدار لم يتوقف، وإنما التف الرقم إلى صفر Wrapping، وطبع البرنامج أن المخزون بعد إعادة التعبئة صفر، وخرج برمز 0 كأن كل شيء بخير. والسبب أن فحص التجاوز في كل عملية جمع له ثمن صغير، فقرر فريق Rust أن يكون مفعلاً في التطوير ومعطلاً في الإصدار، كما يشرح قسم تجاوز السعة في الكتاب. ولاحظ أن هذا ليس سلوكاً غير معرف Undefined Behavior كما في C، فالالتفاف في Rust معرف ومضمون، ولكنه ما زال خطأ منطقياً في برنامجك.

والحل له ثلاث درجات بحسب المكان. الأولى أن تقول صراحة ماذا تريد في العملية نفسها: stock.checked_add(1) ترجع None عند التجاوز، وsaturating_add تتوقف عند 255، وwrapping_add تلتف عمداً. والثانية أن تفعل الفحص في وضع الإصدار للمشروع كله بسطرين في Cargo.toml، وهو قرار يتخذه كثير من الفرق في الكود الذي يتعامل مع المال:

[profile.release]
overflow-checks = true

والثالثة أن تختار نوعاً يتسع للقيم الممكنة من البداية، ولهذا استخدمنا i64 للهللات وليس i32، فأكبر i32 حوالي 21 مليون ريال بالهللة، وهو رقم قد يتجاوزه مجموع مبيعات شهر في متجر متوسط.

التوثيق Documentation: cargo doc

cargo doc --no-deps
   Generated /work/store/target/doc/store/index.html

الأمر يولد موقع توثيق كاملاً من تعليقات ///، بنفس شكل توثيق المكتبة القياسية، فيه كل نوع ودالة معلنة وأمثلتها. والخيار --no-deps يوثق مشروعك وحده، ودونه يوثق كل الاعتماديات أيضاً، وهذا مفيد عندما تعمل دون إنترنت. وكل مكتبة تنشر في crates.io يولد توثيقها تلقائياً في docs.rs، ولهذا تجد توثيقاً بنفس الشكل لكل مكتبة تقريباً، وهو أول مكان تبحث فيه عن طريقة استخدام أي مكتبة.

الأوامر اليومية Daily Commands: جدول المقابلة

الجدول التالي يجمع ما سوف تكتبه كل يوم، وما يقابله في الأدوات التي تعرفها:

ما تريدهRustPythonJava (Maven)Go
مشروع جديدcargo newuv initmvn archetype:generatego mod init
إضافة مكتبةcargo add serdeuv addتعديل pom.xmlgo get
فحص سريع دون بناءcargo checkmypymvn compilego vet
تشغيلcargo runpython main.pymvn exec:javago run .
اختبارcargo testpytestmvn testgo test ./...
تنسيقcargo fmtruff formatإضافة خارجيةgofmt
تدقيقcargo clippyruff checkSpotBugs وغيرهاstaticcheck
بناء للنشرcargo build --releaseلا يوجد ترجمةmvn packagego build
توثيقcargo doc --openSphinxmvn javadoc:javadocgo doc
تحديث الأدواتrustup updateإصدار جديد من PythonJDK جديدإصدار جديد من Go

والفرق الذي يظهر في الجدول أن Rust تجمع كل شيء تحت أمر واحد بأداة واحدة رسمية، مثل Go، بينما تحتاج Python وJava إلى اختيار أدوات من خارج اللغة لنصف الصفوف. وما يعني هذا لأدوات البناء عموماً ناقشناه في مقال أدوات البناء Build Tools.

أخطاء شائعة Common Mistakes في الأسبوع الأول

  • تثبيت Rust من مدير حزم النظام ثم الاستغراب أن المكتبات الحديثة لا تترجم: استخدم rustup، وثبت الإصدار في rust-toolchain.toml.
  • نسيان مترجم C على نظام نظيف، فيظهر الخطأ linker `cc` not found في آخر خطوة: ثبت gcc أو build-essential.
  • قياس الأداء أو نشر البرنامج بوضع التطوير: الملف كان 6.9 MB مقابل 582 KB، والسرعة أبطأ بكثير.
  • افتراض أن تجاوز السعة يوقف البرنامج دائماً: في وضع الإصدار التف المخزون إلى صفر بصمت، وفعل overflow-checks أو استخدم checked_add في الحسابات المهمة.
  • وضع فاصلة منقوطة بعد آخر سطر في دالة ترجع قيمة، فيرجع النوع الفارغ () ويرفض المترجم البرنامج برسالة عن نوع غير متوقع.
  • كتابة المنطق كله في main.rs: لا يمكن اختباره من tests/، فضعه في lib.rs.
  • إضافة target/ إلى المستودع، أو حذف Cargo.lock من مستودع برنامج: الأول عدة مئات من الميجابايت، والثاني يجعل كل بناء قد يأخذ إصدارات مختلفة من المكتبات.
  • تعطيل قاعدة Clippy في المشروع كله بسبب حالة واحدة: عطلها في السطر بالسطر #[allow(...)] مع السبب.
  • القسمة الصحيحة للضريبة دون تقريب: 2970 هللة ضريبتها 445 بالقطع و446 بالتقريب، والاختبار الفاشل أعلاه يبين ذلك.
  • الحكم على اللغة بوقت البناء النظيف الأول: 39 ثانية في المرة الأولى، و1.47 ثانية في كل تعديل بعدها.

قائمة تحقق Checklist لمشروع Rust جديد في فريق

  • ملف rust-toolchain.toml بإصدار محدد ومكونات rustfmt وclippy.
  • المنطق في src/lib.rs، والبرنامج رفيع في src/main.rs.
  • Cargo.lock في المستودع للبرامج والخدمات، و/target في .gitignore.
  • خط CI يشغل بالترتيب: cargo fmt --check ثم cargo clippy -- -D warnings ثم cargo test ثم cargo build --release --locked، والخيار --locked يرفض البناء إذا كان ملف القفل لا يطابق Cargo.toml.
  • قرار مكتوب عن overflow-checks في وضع الإصدار للكود الذي يتعامل مع المال أو المخزون.
  • أنواع الأرقام مختارة لأكبر قيمة ممكنة، والمبالغ بالهللات في i64.
  • المحرر متصل بالأداة rust-analyzer، مع إظهار الأنواع المستنتجة في الأسابيع الأولى.
  • Cache لمجلد target ولسجل Cargo في CI، وإلا دفع كل بناء ثمن البناء النظيف.

تدريب عملي

  • أضف إلى المكتبة دالة shipping ترجع 0 إذا كان المجموع 200 ريال أو أكثر و15 ريالاً غير ذلك، واكتب لها اختبار وحدة ومثالاً في التوثيق، ثم تأكد أن cargo test يشغل الاثنين.
  • أضف إلى التعداد OrderError حالة ثالثة EmptyOrder، ولاحظ أين يرفض المترجم البرنامج حتى تتعامل معها.
  • أضف overflow-checks = true لوضع الإصدار وأعد تجربة المخزون، وقارن حجم الملف قبل وبعد.

حذف المختبر Cleanup

docker rmi slr-rs-dev:1 rust:1.99.1-slim-trixie
docker volume rm slr-rs-cargo slr-rs-target slr-rs-work

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • تصدر Rust نسخة مستقرة كل ستة أسابيع، والإصدار الحالي 1.99.1، والإصدار الكبير Edition يسمح للغة بالتغير دون أن يتوقف الكود القديم، والمشروع الجديد يبدأ على 2024.
  • التثبيت بالأداة rustup، وهي تضع وكلاء للأوامر وتسمح لكل مشروع بإصداره في rust-toolchain.toml، والـ Image الصغير الرسمي لا يحتوي rustfmt ولا Clippy.
  • Cargo أداة واحدة للمشروع والحزم والبناء والاختبار والتوثيق، والهيكل متفق عليه: المنطق في lib.rs والبرنامج في main.rs والتكامل في tests/.
  • المتغيرات ثابتة إلا بالكلمة mut، وif وmatch تعبيرات، والتعدادات تحمل بيانات ويجبرك match على كل حالاتها، ولا يوجد null وإنما Option وResult، والسمات بدل الوراثة.
  • البناء النظيف الأول بطيء لأن الاعتماديات تترجم من المصدر، 39 ثانية هنا، والبناء بعد التعديل 1.47 ثانية، والملف بوضع الإصدار 582 KB مقابل 6.9 MB.
  • الملف التنفيذي لا يحتاج إلا glibc، فينسخ إلى السيرفر ويعمل.
  • تجاوز السعة يوقف البرنامج في التطوير ويلتف بصمت في الإصدار، فاختر نوع الرقم المناسب واستخدم checked_add أو فعل الفحص.
  • الاختبارات بثلاثة أنواع بأمر واحد، وأمثلة التوثيق تختبر نفسها.

وفي المقال التالي من السلسلة سوف ندخل إلى قلب Rust: الأنواع والملكية والاستعارة ومن يحرر الذاكرة، وكيف تتعامل اللغة مع الأخطاء، بتجارب فاشلة حقيقية من المترجم ومن وقت التشغيل.

دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتثبيت Rust 1.99.1 بالأداة rustup 1.29.2 على Debian 13 (trixie)، وتنفيذ التجارب داخل Container من rust:1.99.1-slim-trixie مع rustfmt 1.10.0 وClippy 0.1.99 وserde 1.0.229 وserde_json 1.0.154، على نواة Linux 5.15 (WSL2) ومعالج بثماني أنوية منطقية، على Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.