وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

آموزش زبان برنامه‌نویسی Rust – قسمت۱۲- در اعماق Struct

آموزش زبان برنامه‌نویسی Rust – قسمت۱۲- در اعماق Struct

در جلسات قبلی هم با شیوه‌ی تعریف یک struct آشنا شدیم و هم تعریف کردن و استفاده از method ها و associated function ها را یادگرفتیم. الان زمان این است که چند مبحث باقی‌مانده را هم درمورد struct ها با هم یادبگیریم تا بتوانیم بگوییم که همه‌ی چیزهایی که مستقیماً به struct ها مرتبط اند را می‌دانیم. آماده‌ای؟ پس با هم شروع می‌کنیم.

به ارث بردن مقادیر تعریف نشده از یک struct دیگر

فرض‌کنید که مثلاً ۱۰ نمونه از یک struct می‌خواهید بسازید که تنها در یک فیلد با هم اختلاف دارند. خب فرض‌کنید که هر struct خودش ۱۵تا فیلد داشته باشد. می‌خواهید به خاطر همان یک فیلدی که با بقیه فرق دارد،۹ مقدار مشابه دیگر را تکرار کنید؟ یعنی به خاطر ۱۰ داده‌ی متفاوت ۱۴۰ تا داده‌ی شبیه به هم را هی تکرار کنید؟ این کار زمان خیلی زیادی را از آدم می‌گیرد. به علاوه به خاطر افزونگی داده‌ای که پیش می‌آید (data redundency) نگهداری کد بسیار سخت می‌شود. یک راه حل دیگر که ممکن است به ذهن آدم برسد این است که یک تابع بنویسد که فقط مقدار همان یک فیلد متغیّر را بگیرد و struct کامل را به عنوان خروجی بدهد. مشکل این راه حل هم این است که وقتی تعداد این حالات در برنامه زیاد شد، یعنی struct هایی که فقط یک مقدار متفاوت دارند زیاد شدند، تعداد این توابع هم زیاد می‌شود. اینطوری نگهداری این توابع سخت می‌شود. به علاوه تغییردادن کد هم پیچیده خواهد شد. حالا راه حل Rust برای این مشکل چیست؟ فرض‌کنید که یک struct داریم که ۵ مقدار عددی مختلف دارد. از val1 تا val5. حالا ما می‌خواهیم دو تا نمونه از این struct بسازیم که فقط مقدار val5 بین آن‌ها متفاوت است. اوّل کد را ببینید تا با هم خط به خطش را بررسی کنیم:

#[derive(Debug)]
struct TestStruct {
    val1: i32,
    val2: u8,
    val3: i64,
    val4: f64,
    val5: u16
}
fn main() {
    let struct1 = TestStruct {
        val1: -1238,
        val2: 5,
        val3: -6464564564,
        val4: 1234.5678,
        val5: 15
    };
    let struct2 = TestStruct {val5: 236, ..struct1};
    println!("struct1: {:#?}", struct1);
    println!("struct2: {:#?}", struct2);
}

ما اوّل ساختار TestStruct را تعریف می‌کنیم. سپس داخل تابع main، ابتدا struct1 را تعریف می‌کنیم و ۵ مقدار آن را برایش مشخّص می‌کنیم. حالا نوبت تعریف نمونه‌ی دوم از این struct است. متغیّر struct2 را تعریف می‌کنیم و مقدار val5 را که با متغیّر قبلی متفاوت است می‌نویسیم. برای اینکه به Rust بگوییم می‌خواهیم برای مقادیر بقیه‌ی فیلدها از فیلدهای struct1 استفاده کنیم، باید از سینتکس خاصی استفاده می‌کنیم. پس از مقداردهی فیلدهای غیر مشابه، ابتدا .. می‌گذاریم و سپس اسم متغیّری را که می‌خواهیم از مقادیر آن برای پرکردن فیلدهای باقی‌مانده استفاده کنیم می‌آوریم. خب حالا ببینیم نتیجه‌ی کامپایل و اجرای این برنامه چه خواهد بود.

struct1: TestStruct {
    val1: -1238,
    val2: 5,
    val3: -6464564564,
    val4: 1234.5678,
    val5: 15
}
struct2: TestStruct {
    val1: -1238,
    val2: 5,
    val3: -6464564564,
    val4: 1234.5678,
    val5: 236
}

مشکل ارث‌بری مقادیر قبلی با مالکیّت

بیایید فرض‌کنیم ما دو تا دانشجو داریم که فقط شماره‌ی دانشجویی آن‌ها با هم متفاوت است (مثلاً دو نفر هم‌نام که دقیقاً درس‌های مشابهی را پاس کرده اند). می‌خواهیم این کار را با syntax جدیدی که یادگرفتیم انجام بدهیم:

fn main() {

    let courses = [Course {name: String::from("درس۱"), passed: false},
        Course {name: String::from("درس۲"), passed: false},
        Course {name: String::from("درس۳"), passed: false}];

    let student1 = Student {
        name: String::from("اصغر اکبرزاده اصل"),
        id: 9796959493,
        courses
    };

    let student2 = Student {id: 9899969594, ..student1};

    println!("student1: {:#?}", student1);
    println!("student2: {:#?}", student2);
}

خب حالا یک نفش عمیق بکشید و برنامه را کامپایل کنید:

error[E0382]: use of partially moved value: `student1`
  --> src/main.rs:37:33
   |
35 |     let student2 = Student {id: 9899969594, ..student1};
   |                                               -------- value moved here
36 | 
37 |     println!("student1: {:#?}", student1);
   |                                 ^^^^^^^^ value used here after move
   |
   = note: move occurs because `student1.name` has type `std::string::String`, which does not implement the `Copy` trait

اگر هنوز مبحث مالکیّت را به خاطر داشته باشید، حتماً به خاطر دارید که داده‌هایی مثل String کپی نمی‌شوند، بلکه مالکیّتشان منتقل (move) می‌شود. در struct قبلی به چنین مشکلاتی نمی‌خوردیم. چون داده‌های عددی ساده‌تر از آن هستند که لازم باشد مالکیّتشان جابه‌جا شود، به جای آن خود مقدار کپی می‌شود. به همین خاطر می‌توانیم نمونه‌ی جدیدمان از struct را با کپی‌کردن مقادیر struct قبلی بسازیم. ما در struct های Student و Course داده‌هایی از نوع String داریم. به علاوه در Student آرایه‌ای از Course ها داریم که برای این نوع داده (Course) هم رفتار کپی تعریف نشده است. چطوری می‌توانیم این مشکل را برطرف کنیم؟

#[derive(Debug, Clone)]
struct Student {
    name: String,
    id: u32,
    courses: [Course; 3]
}

#[derive(Debug, Clone)]
struct Course {
    name: String,
    passed: bool
}

همانطوری که می‌بینید ما از trait جدیدی به نام clone استفاده کرده‌ایم. نکته: وقتی می‌خواهیم از چندین trait استفاده کنیم، می‌توانیم تمام آن‌ها را درون پرانتز derive قرار بدهیم تا کد کمتری نوشته باشیم. امّا می‌توان از چندین derive مختلف برای این کار استفاده کرد. امّا چرا این کار را می‌کنیم؟

ویژگی clone چیست؟

trait یا ویژگی ها را بعداً به شکل کامل بررسی می‌کنیم. اینجا فقط می‌خواهیم به صورت کلّی ببینیم که clone کردن در Rust یعنی چه. در زبان Rust برخی type ها را می‌توان به صورت ضمنی کپی‌کرد. یعنی وقتی که آن‌ها را به متغیّری assign می‌کنیم یا به تابعی پاس می‌دهیم، بدون اینکه مشکلی پیش بیاید خود کامپایلر می‌تواند آن‌ها را کپی کند. این نوع‌داده‌های ساده نه به اختصاص فضا در heap احتیاج دارند و نه به finalizer ها (بعداً می‌بینیم که چی هستند. فعلاً همان Drop را که در قسمت‌های قبلی دیدیم در نظر بگیرد). برای بقیه‌ی نوع‌داده‌ها که انجام این کار ایمن نیست باید از clone استفاده کنیم. وقتی از Clone استفاده می‌کنیم تمامی کارهای پیچیده‌ای که برای کپی‌کردن دقیق مقدار داده نیاز است توسّط خود حضرت Clone انجام می‌شود و ما لازم نیست نگران چیزی باشیم. وقتی می‌خواهیم یک struct را clone کنیم، علاوه بر اضافه‌کردن ویژگی (trait) Clone، باید هرجایی که می‌خواهیم clone کردن رخ بدهد متد clone را روی struct فراخوانی کنیم. حالا ببینیم با این توضیحات شکل کلّی برنامه چه می‌شود:

#[derive(Debug, Clone)]
struct Student {
    name: String,
    id: u32,
    courses: [Course; 3]
}

#[derive(Debug, Clone)]
struct Course {
    name: String,
    passed: bool
}
fn main() {
    let courses = [Course {name: String::from("درس۱"), passed: false},
        Course {name: String::from("درس۲"), passed: false},
        Course {name: String::from("درس۳"), passed: false}];

    let student1 = Student {
        name: String::from("اصغر اکبرزاده اصل"),
        id: 97959493,
        courses
    };

    let student2 = Student {id: 98999694, ..student1.clone()}; // Changed line

    println!("student1: {:#?}", student1);
    println!("student2: {:#?}", student2);
}

همانطوری که می‌بینید این بار به جای اینکه موقع تعریف کردن student2 از خود student1 استفاده کنیم، از مقدار clone شده‌ی آن استفاده می‌کنیم. یعنی عملاً یک مقدار دقیقاً مشابه student1 می‌سازیم و از آن برای مقداردهی استفاده می‌کنیم. اینطوری مالکیّت مقدار clone شده منتقل می‌شود، نه خود student1. حالا می‌توانیم این برنامه را بدون درد و خونریزی کامپایل و اجرا کنیم:

student1: Student {
    name: "اصغر اکبرزاده اصل",
    id: 97959493,
    courses: [
        Course {
            name: "درس۱",
            passed: false
        },
        Course {
            name: "درس۲",
            passed: false
        },
        Course {
            name: "درس۳",
            passed: false
        }
    ]
}
student2: Student {
    name: "اصغر اکبرزاده اصل",
    id: 98999694,
    courses: [
        Course {
            name: "درس۱",
            passed: false
        },
        Course {
            name: "درس۲",
            passed: false
        },
        Course {
            name: "درس۳",
            passed: false
        }
    ]
}

ساختارهای شبه Tuple

یادتان است که tupleها چه چیزی بودند؟ (اگر نیست روی این نوشته کلیک کنید تا یادتان بیاید.) گفتیم که مهم‌ترین ویژگی struct ها این است که برخلاف tuple ها مقادیرشان نام دارند و به جای اینکه بخواهیم ترتیب داده‌ها را حفظ کنیم، می‌توانیم به آن‌ها با استفاده از key ها دسترسی داشته باشیم. حالا ما می‌خواهیم که یک struct تعریف کنیم که شبیه به تاپل باشد. یعنی داده‌هایی که درونش قرار دارند اسم نداشته باشند:

#[derive(Debug)]
struct TupleLike (u8, u8, u8);

fn main() {
    let mut tuple_like = TupleLike(10, 11, 13);
    println!("tuple like value: {:?}", tuple_like);
    tuple_like.0 = 18;
    println!("tuple like value: {:?}", tuple_like);
}

همانطوری که می‌بینید، برای تعریف یک tuple like struct برخلاف تعریف struct، ما تنها مقابل نام struct و درون پرانتزها به ترتیب نوع داده‌هایی که قرار است ذخیره بشوند را می‌نویسیم. در اینجا دیگر خبری از زوج‌های key:value نیست. برای دسترسی به یک داده‌ی خاص هم کافی است که مقابل نام نمونه‌ی ساخته شده از struct، اندیس داده‌ای که قرار است تغییرکند را پس از علامت نقطه بنویسیم. حالا برنامه را کامپایل و اجرا می‌کنیم تا ببینیم نتیجه چه می‌شود:

tuple like value: TupleLike(10, 11, 13)
tuple like value: TupleLike(18, 11, 13)

چرا باید از tuple like struct ها استفاده کنیم؟

ما زمانی از tuple like struct ها استفاده می‌کنیم که به عملکردی مشابه tuple ها احتیاج داریم، امّا لازم است که این تاپل‌ها type های متمایزی داشته باشند. مثلاً می‌خواهیم مطمئن بشویم که درون برنامه، تاپل چهارتایی‌ای که مقدار رنگ یک پیکسل را در فرمت CMYK نگهداری می‌کند با تاپل چهارتایی‌ای که بخش‌های مختلف یک ip ورژن ۴ را نگهداری می‌کند متمایز اند.

#[derive(Debug)]
struct CMYK (u8, u8, u8, u8);

#[derive(Debug)]
struct IPv4 (u8, u8, u8, u8);

fn main() {
    let red = CMYK(0, 1, 1, 0);
    let local_ip = IPv4(127, 0, 0, 1);
    println!("red color {:?} and local ip {:?}. These values have different types.", red, local_ip);
}

نتیجه‌اش هم می‌شود این:

red color CMYK(0, 1, 1, 0) and local ip IPv4(127, 0, 0, 1). These values have different types.

وقتی که برنامه بزرگ می‌شود و تعداد tupleها زیاد، با جداسازی type احتمال خطا خیلی کمتر می‌شود. به علاوه ما می‌توانیم به struct ها عملکردهای مرتبط را هم سنجاق کنیم. کاری که برای tuple ها نمی‌شد انجام داد.

نوع‌داده‌ی Unit

به () ،unit یا nil هم می‌گویند. Typeی که تنها یک مقدار دارد و آن هم همان () است. از unit وقتی استفاده می‌شود که مقدار معنادار دیگری برای return کردن وجود ندارد. در حقیقت وقتی در تابعی هیچ چیزی را return نمی‌کنیم، داریم () را برمی‌گردانیم. به جز توابعی که چیزی برنمی‌گردانند، از () در زمان‌هایی استفاده می‌کنیم که نوع داده‌ای که با آن کار می‌کنیم برایمان اهمّیّتی ندارد.

ساختارهای شبه Unit

ما می‌توانیم structها را طوری تعریف کنیم که مثل یک unit عمل کنند. یعنی structهایی بسازیم که هیچ فیلدی ندارند. از Unit like structs زمانی استفاده می‌کنیم که می‌خواهیم یک ویژگی (trait) را برای یک type تعریف‌کنیم، امّا نمی‌خواهیم داده‌ای را در آن type ذخیره کنیم. بعداً در بخش‌های مربوط به توضیح trait ها با مثال‌های مختلف این موضوع را بررسی می‌کنیم.

#[derive(Debug)]
struct UnitLikeStruct;

fn main() {
    let my_unit = UnitLikeStruct;
    let same_unit_as_my_unit = UnitLikeStruct {};
    println!("my_unit: {:?}, same_unit_as_my_unit: {:?}", my_unit, same_unit_as_my_unit);
}

نتیجه‌ی این برنامه می‌شود این:

my_unit: UnitLikeStruct, same_unit_as_my_unit: UnitLikeStruct

تعریف Recursive Type

Infinite recursion

یکی از مشکلات بزرگی که برنامه‌نویس‌ها ممکن است به آن بخورند مسئله‌ی Recursive Typing است. یعنی ما دو تا struct داشته باشیم که هرکدام یک فیلد از نوع دیگری دارند. اینطوری به‌صورت چرخشی باید به اندازه‌ی این یکی برای ساخت نمونه‌ای از آن یکی فضا اختصاص داد و برعکس. یعنی به بی‌نهایت حافظه نیاز خواهیم داشت. بیایید با هم یک مثال را بررسی کنیم. فرض‌کنید که یک struct به نام Teacher به برنامه‌ی اوّلیّه اضافه می‌کنیم:

#[derive(Debug, Clone)]
struct Course {
    name: String,
    passed: bool,
    teacher: Teacher
}

#[derive(Debug)]
struct Teacher {
    name: String,
    course: Course
}

ما ساختار Teacher را اضافه کردیم که دو تا فیلد دارد. name که نام استاد را مشخّص می‌کند و course که ساختار درسی که این فرد استاد آن است را نگهداری می‌کند. به علاوه ساختار Course را هم تغییر داده‌ایم تا در فیلد teacher ساختار مربوط به استاد درس را نگهداری کند. خب حالا بیایید یک نمونه استاد و درس بسازیم:

fn main() {
    let course: Course;
     course = Course {
        name: String::from("درس۱"),
        passed: false,
        teacher: Teacher {
            name: Student::from("عین الله"),
            course
        }
    };
}

شاید در نگاه اوّل مشکلی به نظر نرسد، امّا بیایید که برنامه را کامپایل کنیم:

error[E0072]: recursive type `Course` has infinite size
  --> src/main.rs:9:1
   |
9  | struct Course {
   | ^^^^^^^^^^^^^ recursive type has infinite size
...
12 |     teacher: Teacher
   |     ---------------- recursive without indirection
   |
   = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `Course` representable

error[E0072]: recursive type `Teacher` has infinite size
  --> src/main.rs:16:1
   |
16 | struct Teacher {
   | ^^^^^^^^^^^^^^ recursive type has infinite size
17 |     name: String,
18 |     course: Course
   |     -------------- recursive without indirection
   |
   = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `Teacher` representable

کامپایلر به ما ۲ ارور مختلف نشان می‌دهد. یکی از ارورها می‌گوید که نوع‌داده‌ی بازگشتی Course فضای بی‌نهایت احتیاج دارد. ارور دوم هم دقیقاً همین حرف را برای Teacher می‌زند. کامپایلر موقع کامپایل باید بداند که چقدر فضا برای هر متغیّر باید اختصاص بدهد. مشکل این است که کامپایلر نمی‌داند کی باید از حلقه‌ی ایجاد شده برای اختصاص فضا خارج بشود. حلقه‌ای که ایجاد می‌شود شبیه به شکل زیر است:

تایپ بازگشتی در Rust

وقتی که کامپایلر می‌خواهد اندازه‌ی Course را محاسبه کند به اندازه‌ی Teacher نیاز دارد و هرگاه که بخواهد اندازه‌ی Teacher را بفهمد به اندازه‌ی Course نیاز دارد. اینطوری برای اینکه بخواهیم فقط همین برنامه‌ی کوچکی که آن بالا نوشتیم را اجرا کنیم به بی‌نهایت حافظه احتیاج داریم.

چطوری مشکل حافظه‌ی بی‌نهایت را در Recursive Type ها برطرف کنیم؟

اگر به پیام خطایی که بالاتر دیدیم دقّت کنید، می‌بینید که کامپایلر ۳ راه حل جلوی پای ما قرار داده است. در بخش‌های بعدی هرکدام از این ۳ راه حل را با دقّت و به صورت کامل بررسی می‌کنیم. بحث هرکدام مفصّل است و برای بعضی باید مباحث دیگری را هم یادبگیریم. امّا خیلی وقت‌ها ما واقعاً به ایجاد این حلقه احتیاج نداریم. یعنی معماری بد باعث شده است که چنین حلقه‌ای به وجود بیاید. قبل از اینکه به سراغ هرکدام از آن ۳ راه حل بروید (البته الان که بلد نیستید. آن زمانی که یادگرفتیم 🙂 )، ابتدا یک بار دیگر معماری نرم‌افزارتان را بررسی کنید تا ببینید که آیا واقعاً به چنین کاری نیاز است یا نه.

خب تا اینجا تمامی چیزهایی که مستقیماً به struct ها مربوط می‌شدند را بررسی کردیم. الان که کار با struct ها را یادگرفتیم می‌توانیم برنامه‌های کاربردی‌تری را با Rust پیاده‌سازی بکنیم. هنوز مفاهیمی باقی‌مانده‌اند که از آن‌ها هم می‌توان در struct استفاده کرد، امّا بعداً به صورت مجزا به آن بخش‌ها خواهیم پرداخت.

چطوری به کدهای این قسمت دسترسی داشته باشم؟

شما می‌توانید کدهای نهایی این قسمت‌را در مخزن گیتهاب آن در این لینک ببینید. تمامی کدها به صورت یکجا در آن‌جا قرار گرفته‌اند.

خیلی خوب داری پیشرفت می‌کنی. همین الان با کلیک روی این نوشته به قسمت بعدی برو و کار با enum ها را هم یادبگیر.

در قسمت قبلی یادگرفتیم که چطوری به Struct ها Method و Associated Function را اضافه کنیم. اینطوری می‌توانیم بعضی از ویژگی‌های زبان‌های شی‌گرا را به Rust اضافه کنیم. اگر آن قسمت را از دست داده‌ای همین الان با کلیک روی این نوشته به آنجا برو و خیلی سریع این مباحث را یادبگیر.

اوّلین بار است که مجموعه‌ی رایگان آموزش کامل زبان برنامه‌نویسی Rust را می‌بینی؟ با کلیک روی این نوشته به اوّلین قسمت برو و از آنجا یادگیری را شروع کن.

یا حرفه‌ای شو یا برنامه‌نویسی را رها کن.

چطور می‌شود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلف‌کردن خودت را تبدیل به یک نیروی باتجربه بکنی؟

پاسخ ساده است. باید حرفه‌ای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.

«نوشته‌های مرتبط»

یک پاسخ به “آموزش زبان برنامه‌نویسی Rust – قسمت۱۲- در اعماق Struct”

  1. امیرعلی گفت:

    سلام عالی بود کارتون حرف نداره فقط تنها ایرادش اینکه دیر به دیر میزارین
    به هر حال منتظر ادامش هستیم ممنون ازتون

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»