مقدمه

یک نوع داده (datatype) که گاهی نوع (type) هم گفته می‌شود، مجموعه‌ای است از مقادیر. وقتی گفته می‌شود یک متغیر دارای نوع X است، یعنی مقدار این متغیر می‌تواند یکی از مقادیر مجموعه‌ی X باشد. در Rust هم مثل سایر زبان‌های برنامه‌نویسی، چند نوع داده‌ی پایه یا Primitive داریم که در این درس این نوع‌ها را معرفی و بررسی خواهیم کرد.

ماهیت استاتیک نوع‌ها در Rust

Rust یک زبان استاتیک یا به بیان دقیق‌تر statically typed است و از این نظر نقطه‌ی مقابل زبان‌های دینامیک مانند جاوااسکریپت و پایتون است. استاتیک بودن نوع‌های Rust به این معناست که نوع متغیرها باید در زمان کامپایل مشخص باشد و نمی‌توان تعیین نوع را به زمان اجرا موکول کرد. در یک زبان استاتیک، وقتی به یک متغیر مقداری از یک نوع داده شد، در آینده نمی‌توان مقداری از نوع دیگر را به آن متغیر اختصاص داد.

در اغلب زبان‌های استاتیک، اعلان نوع (type declaration) اجباری است؛ یعنی هنگام تعریف یک متغیر باید نوع آن را صراحتاً اعلام کنیم. اما کامپایلر Rust سعی می‌کند نوع یک متغیر را از روی اولین مقداری که دریافت می‌کند، استنتاج کند. تا زمانی که این استنتاج برای کامپایلر ممکن باشد و ابهامی وجود نداشته باشد، نیازی به اعلان نوع متغیر نداریم. اما در مواردی که کامپایلر قادر به استنتاج نوع نباشد، باید نوع متغیر را با استفاده از Type annotation اعلام کنیم. مثال زیر یک نمونه از ابهاماتی که می‌تواند برای گامپایلر رخ بدهد را نشان می‌دهد.

Copy Icon Rust
let guess: u32 = "42".parse().expect("Not a number!");

در اینجا استنتاج نوع متغیر guess برای کامپایلر ممکن نیست؛ چون متد parse می‌تواند یک رشته را به چندین نوع عددی تبدیل کند و به همین دلیل نوع عددیِ مورد نظرمان را که در اینجا u32 است، به کامپایلر اعلام کرده‌ایم. اگر این کد را بدون اعلان نوع متغیر guess اجرا کنیم، خطای زیر را دریافت خواهیم کرد:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

به بخش هایلایت‌شده در پیغام خطای بالا نگاه کنید که از نیاز به type annotaion حاکی است. در قسمت help هم راه‌حل این مشکل توصیف شده است.
در مجموع می‌توان گفت که کامپایلر تا زمانی که جای ابهامی وجود نداشته باشد، سعی می‌کند نوع متغیر را استنتاج کند اما در صورتی که این کار به هر دلیلی ممکن نباشد، باید از type annotaion برای اعلام نوع متغیر استفاده کنیم.

پلاگین rust-analyzer

پلاگین rust-analyzer که توسط تیم توسعه‌ی Rust توسعه داده شده، امکان پشتیبانی پیشرفته از کدهای Rust را فراهم می‌کند. توصیه می‌کنم این پلاگین را، که برای ادیتورهای مختلف و از جمله vscode در دسترس است، نصب کنید. یکی از کارهای مفیدی که این پلاگین انجام می دهد، این است که نوع متغیرها را نمایش می‌دهد. برای مثال، اگر گزاره‌ای مثل let x = 8; را بنویسیم، عبارت :i32 را بعد از نام x نمایش می‌دهد. البته این به معنای انجام type annotaion نیست و فقط نوع استنتاج‌شده را برای ما گزارش می‌کند اما اگر روی عبارت x:i32 کلیک کنیم، به type annotaion تبدیل می‌شود و نوع متغیر x را روی i32 فیکس می‌کند.

نوع‌های primitive در Rust به دو گروه تقسیم می‌شوند:

  • نوع‌های اسکالر (scalar) که شامل اعداد صحیح و اعشاری، بولین‌ها و کاراکترها هستند.
  • نوع‌های مرکب (compound) که شامل آرایه‌ها و تاپل‌ها هستند.

نوع‌های اسکالر

یک نوع اسکالر (scalar) مجموعه‌ای است از مقادیر منفرد. یعنی یک متغیر از یک نوع اسکالر می‌تواند فقط یک چیز را در خود نگه دارد. در Rust چهار گروه از نوع‌های اسکالر داریم که عبارتند از: نوع‌های صحیح، نوع‌های اعشاری، نوع بولین و نوع کاراکتر. در ادامه، این نوع‌ها را یکی‌یکی بررسی می‌کنیم.

نوع‌های صحیح

می‌دانیم که یک عدد صحیح یا Integer عددی است که فاقد قسمت اعشار است. در Rust مثل سایر زبان‌های استاتیک، چندین نوع برای کار با اعداد صحیح در نظر گرفته شده که هر نوع، بازه‌ای از اعداد صحیح را شامل است. همانطور که جدول زیر نشان می‌دهد، نوع‌‌های صحیح را می‌توان در دو گروه جای داد: نوع‌هایی که با i شروع می شوند و شامل اعداد صحیح مثبت و منفی هستند و نوع‌هایی که با u شروع شده و فقط شامل اعداد صحیح مثبت هستند. عدد موجود در نام هر نوع نیز معرف تعداد بیت‌های آن نوع است.

نوع (type) سایز بازه مقادیر
i8 8-bit [-27, 27 - 1]
i16 16-bit [-215, 215 - 1]
i32 32-bit [-231, 231 - 1]
i64 64-bit [-263, 263 - 1]
i128 128-bit [-2127, 2127 - 1]
isize arch arch
u8 8-bit [0, 28 - 1]
u16 16-bit [0, 216 - 1]
u32 32-bit [0, 232 - 1]
u64 64-bit [0, 264 - 1]
u128 128-bit [0, 2128 - 1]
usize arch arch

به طور کلی یک نوع n بیتی که با i شروع شود، شامل اعداد بین -2n-1 تا 2n-1 - 1 است و یک عدد صحیح n بیتی که با u شروع می‌شود شامل اعداد بین صفر و 2n - 1 است. در ضمن، isize و usize نوع‌هایی هستند که به معماری (architecture) کامپیوتری که برنامه بر روی آن در حال اجراست، بستگی دارند. برای کامپیوترهای 32 بیتی اینها معادل 32 بیت و برای کامپیوترهای 64 بیتی معادل 64 بیت هستند.

مقادیری که مستقیماً در برنامه وارد می‌شوند، لیترال (literal) نامیده می‌شوند. لیترال‌های صحیح را می‌توانیم به هر یک از فرمت‌های مشخص‌شده در جدول زیر در برنامه وارد کنیم.

لیترال صحیح مثال
Decimal 98_922
Hexadecimal 0xff
Octal 0o77
Binary 0b1111_0000
Byte (u8 only) b'A'

لیترال‌های صحیح از نوع پیش‌فرض i32 در نظر گرفته می‌شوند که در خیلی از موارد، انتخاب مناسبی برای نوع متغیرهای صحیح است. اما سایر نوع‌ها هم می‌توانند در برخی شرایط خاص به کار بیایند. برای مثال، در آینده خواهیم دید که نوع‌های isize و usize هنگام کار با کالکشن‌ها مفید هستند.

همانطور که در مثال‌های حدول بالا هم می‌بینید، از کاراکتر _ می‌توان برای جدا کردن ارقام استفاده کرد. این کار با هدف ساده‌تر خوانده‌شدن اعداد چند رقمی انجام می‌شود و کامپایلر این کاراکتر را در بین اعداد نادیده می‌گیرد.

مفهوم Overflow برای اعداد صحیح

اگر به یک متغیر از یک نوع صحیح مقداری بدهیم که خارج از بازه‌ی مقادیرش باشد، چه اتفاقی می‌افتد؟ مثلاً فرض کنید متغیری از نوع u8 داشته باشیم که طبعاً می‌تواند مقادیر بین صفر تا 255 را دریافت کند. حالا اگر به این متغیر مقدار 256 را بدهیم، چه می‌شود؟

در این صورت، اتفاقی که رخ می‌دهد سرریز یا overflow است که Rust قوانین جالبی در خصوص آن دارد. وقتی برنامه را در حالت دیباگ اجرا کنیم و سرریز رخ دهد، اجرای برنامه متوقف شده و اصطلاحاً crash می‌شود. اما اگر برنامه را در حالت release یعنی با استفاده از آپشن --release اجرا کرده باشیم و سرریز اتفاق بیفتد، آنچه رخ می‌دهد Two’s complement wrapping نام دارد. در این وضع، 256 معادل صفر و 257 معادل 1 است و به همین ترتیب 512 معادل صفر خواهد بود. در واقع، اینجا پای حساب پیمانه‌ای (modular) در میان است که باقیمانده‌ی تقسیم عدد بر طول بازه، که در این مثال 256 است، را به متغیر مورد نظر نسبت می‌دهد.

نوع‌های اعشاری

در Rust برای اعداد اعشاری دو نوع f32 و f64 در نظر گرفته شده که سایز آنها به‌ترتیب 32 و 64 بیت است. نوع پیش‌فرض برای لیترال‌های اعشاری f64 است که در پردازشگرهای مدرن سرعت مشابهی با f32 دارد اما از دقت مضاعف نسبت به آن برخوردار است.

Copy Icon Rust
fn main() {
  let x = 2.0; // f64
          
  let y: f32 = 3.0; // f32
}

Rust از اعمال محاسباتی جمع، تفریق، ضرب و تقسیم و محاسبه باقیمانده روی همه‌ی نوع‌های عددی پشتیبانی می‌کند. کد زیر نحوه‌ی عملکرد این عملگرهای حسابی را روی اعداد اعشاری و صحیح نشان می‌دهد.

Copy Icon Rust
fn main() {
  // addition
  let sum = 5 + 10;
          
  // subtraction
  let difference = 95.5 - 4.3;
          
  // multiplication
  let product = 4 * 30;
          
  // division
  let quotient = 56.7 / 32.2;
  let truncated = -5 / 3; // Results in -1
          
  // remainder
  let remainder = 43 % 5;
}

در اینجا چند گزاره‌ی تخصیص داریم که در هر کدام، یک عمل محاسباتی انجام شده و نتیجه در یک متغیر ذخیره شده است.

نوع بولین

در Rust مثل هر زبان دیگر، یک نوع بولین برای مقادیر دوحالته در نظر گرفته شده که bool نام دارد. متغیرهای بولین (یا دارای نوع bool) یک بایت فضا اشغال می‌کنند و می‌توانند یکی از دو مقدار true و false را دریافت کنند.

Copy Icon Rust
fn main() {
  let t = true;
          
  let f: bool = false; // with explicit type annotation
}

بولین‌ها نقش مهمی در برنامه‌نویسی دارند. نتیجه‌ی اعمال مقایسه‌ای، یک مقدار بولین است. مثلاً گزاره‌ای مثل let b = 10 > 5; باعث می‌شود مقدار true در متغیر b ذخیره شود. اما بولین‌ها بیش از هر چیز در ساختارهای شرطی مانند if به کار می‌آیند. این موضوع را در درس‌های بعدی همین فصل خواهیم دید.

نوع کاراکتر

در Rust یک نوع کاراکتری با نام char وجود دارد که برای کار با کاراکترهای unicode در نظر گرفته شده است. وقتی بخواهیم یک کاراکتر را در یک متغیر از این نوع ذخیره کنیم، باید آن کاراکتر را درون آپستروف قرار دهیم.

Copy Icon Rust
fn main() {
  let c = 'z';
  let z: char = 'ℤ'; // with explicit type annotation
  let heart_eyed_cat = '😻';
}

بعضی از زبان‌های برنامه‌نویسی، نوع مجزایی برای کاراکترها در نظر نگرفته‌اند و یک کاراکتر را در رشته‌ای به طول 1 ذخیره می‌کنند. اما در Rust نوع char که برای کاراکترها در نظر گرفته شده، 4 بایت سایز دارد و می‌تواند هر کاراکتر unicode را شامل باشد.

نوع‌های مرکب

نوع‌های مرکب (compound types) بر خلاف نوع‌های اسکالری که تا اینجا دیدیم، می‌توانند بیش از یک مقدار را در خود نگه دارند. در Rust دو نوع مرکب داریم که عبارتند از: نوع تاپل (tuple) و نوع آرایه (array).

نوع تاپل

واژه‌ی تاپل (tuple) یک اصطلاح کلی است که به هر چیز چندتایی اطلاق می‌شود. در برنامه‌نویسی به زبان Rust تاپل روشی است برای دسته‌بندی کردن چند مقدار از نوع‌های مختلف در قالب یک متغیر. تاپل‌ها دارای طول ثابتی هستند و پس از ایجاد یک تاپل، امکان تغییر سایز آن وجود ندارد.

برای ایجاد یک تاپل، باید لیست مقادیر مورد نظر را که با کاما از هم جدا شده‌اند، درون یک جفت پرانتز قرار دهیم. در مثال زیر ما یک تاپل با نام tup ایجاد کرده‌ایم که سه مقدار از سه نوع مختلف را ذخیره کرده است. اگرچه ما در این مثال از type annotation هم استفاده کرده‌ایم، اما انجام این کار لزومی ندارد. چون کامپایلر می‌تواند از روی مقادیری که به آیتم‌های تاپل داده‌ایم، نوع آنها را استنتاج کند.

Copy Icon Rust
fn main() {
  let tup: (i32, f64, u8) = (500, 6.4, 1);
}

در کد زیر بعد از تعریف تاپل tup از تکنیکی با نام Destructuring استفاده کرده و آیتم‌های تاپل را به سه متغیر با نام‌های x، y و z داده‌ایم. این تکنیک به ما امکان می‌دهد که به آیتم‌های تاپل دسترسی پیدا کنیم. در نهایت، یکی از آیتم‌ها یا عناصر تاپل چاپ شده است.

Copy Icon Rust
fn main() {
  let tup = (500, 6.4, 1);
          
  let (x, y, z) = tup;
          
  println!("The value of y is: {y}");
}

البته برای دسترسی به آیتم‌های یک تاپل راه ساده‌تری هم وجود دارد و آن استفاده از اندیس عناصر است. کافیست اندیس عنصر را بعد از نام تاپل و یک کاراکتر نقطه بیاوریم.

Copy Icon Rust
fn main() {
  let x: (i32, f64, u8) = (500, 6.4, 1);
          
  let five_hundred = x.0;
          
  let six_point_four = x.1;
          
  let one = x.2;
}

همانطور که از مثال بالا هم مشخص است، اندیس‌گذاری عناصر از صفر شروع می‌شود؛ یعنی عنصر اول دارای اندیس صفر، عنصر دوم دارای اندیس 1 و به طور کلی عنصر n-ام دارای اندیس n-1 است.

نوع آرایه

نوع مرکب دیگری که متغیرهایش قابلیت ذخیره‌ی چندین مقدار را دارند، نوع array یا آرایه است. آرایه‌های Rust مثل تاپل‌ها دارای اندازه‌ی ثابتی هستند و امکان تغییر سایز آنها وجود ندارد. اما بر خلاف تاپل‌ها، عناصر آرایه باید همه از یک نوع یکسان باشند. برای تعریف یک آرایه در Rust از یک جفت براکت [ ] استفاده می‌شود که شامل لیست مقادیری است که با کاما از هم جدا شده‌اند.

Copy Icon Rust
fn main() {
  let a = [1, 2, 3, 4, 5];
}

به طور کلی، آرایه‌ها و تاپل‌های Rust با آرایه‌ها و تاپل‌های زبان‌های دیگر فرق دارند. چون در کمتر زبانی دیده می‌شود که سایز این کالکشن‌ها ثابت باشد. اما مسئله این است که در Rust آرایه‌ها و تاپل‌ها کاربرد خاص خودشان را دارند و برای ذخیره‌ی داده‌ها در stack مناسب هستند نه در heap. اگر با این مفاهیم آشنا نیستید، تا فصل چهارم صبر کنید. اگر به یک کالکشن انعطاف‌پذیرتر نیاز داشته باشیم که سایزش قابل تغییر باشد، باید از نوعی به نام Vec استفاده کنیم که بخشی از کتابخانه‌ی استاندارد Rust است و در فصل هشتم با آن کار خواهیم کرد.

فرض کنید بخواهیم نام ماه‌های میلادی را در یک کالکشن ذخیره کنیم. از آنجایی که سایز چنین کالکشنی همیشه ثابت و برابر با 12 است، یک آرایه انتخاب مناسبی خواهد بود.

Copy Icon Rust
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];

اگر بخواهیم یک آرایه را با استفاده از type annotation و اعلام صریح نوع آن ایجاد کنیم، به شکل زیر عمل می‌کنیم:

Copy Icon Rust
          
let a: [i32; 5] = [1, 2, 3, 4, 5];
          

در اینجا نوع عناصر آرایه i32 است و عدد 5 بعد از سمی‌کالن نیز سایز آرایه را مشخص می‌کند.

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

Copy Icon Rust
let a = [3; 5];

آرایه‌ی a از 5 عنصر تشکیل شده که همگی دارای مقدار اولیه‌ی 3 هستند. این کار معادل نوشتن let a = [3,3,3,3,3]; است منتها به شکلی مرتب‌تر و جمع و جورتر.

برای دسترسی به عناصر یک آرایه، می‌توانیم به سادگی از اندیس هر عنصر استفاده کنیم:

Copy Icon Rust
fn main() {
  let a = [1, 2, 3, 4, 5];
          
  let first = a[0];
  let second = a[1];
}

در اینجا متغیر first مقدار 1 را دریافت کرده زیرا 1 مقدار اولین عنصر آرایه یعنی عنصر با اندیس صفر است. متغیر second نیز مقدار 2 را دریافت کرده است.

حالا به مثال زیر نگاه کنید:

Copy Icon Rust
fn main() {
    let a = [1, 2, 3, 4, 5];
    let element = a[10];

    println!("The value of the element at index 10 is: {element}");
}

اگر این کد را اجرا کنیم، با خطای زمان اجرای (runtime error) زیر مواجه می‌شویم:

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

این خطا به خاطر استفاده از اندیس نامعتبر رخ داده و این مثال یکی از جنبه‌های امنیت Rust را در عمل نشان می‌دهد. شاید این حرف برای کسانی که با زبان‌های سطح بالا کار کرده‌اند، خیلی قابل درک نباشد، چون در این زبان‌ها، همه‌چیز در زمان اجرا رخ می‌دهد. اما توجه داشته باشید که Rust یک زبان سطح پایین است و در این نوع زبان‌ها مواردی مثل کد بالا منجر به دسترسی به مکان اشتباهی از حافظه می‌شود که می‌تواند با خطرات امنیتی همراه باشد. اما Rust با انجام یک بررسی زمان اجرا، به جای دسترسی به یک مکان غلط، اجرای برنامه را متوقف می‌کند و ما را از این مخاطرات مصون نگه می‌دارد.