ماهیت استاتیک نوعها در Rust
Rust یک زبان استاتیک یا به بیان دقیقتر statically typed است و از این نظر نقطهی مقابل زبانهای دینامیک
مانند جاوااسکریپت و پایتون است. استاتیک بودن نوعهای Rust به این معناست که نوع متغیرها باید در زمان کامپایل
مشخص باشد و نمیتوان تعیین نوع را به زمان اجرا موکول کرد. در یک زبان استاتیک، وقتی به یک متغیر مقداری از یک
نوع داده شد، در آینده نمیتوان مقداری از نوع دیگر را به آن متغیر اختصاص داد.
در اغلب زبانهای استاتیک، اعلان نوع (type declaration) اجباری است؛ یعنی هنگام تعریف یک متغیر باید نوع آن را
صراحتاً اعلام کنیم. اما کامپایلر Rust سعی میکند نوع یک متغیر را از روی اولین مقداری که دریافت میکند،
استنتاج کند. تا زمانی که این استنتاج برای کامپایلر ممکن باشد و ابهامی وجود نداشته باشد، نیازی به اعلان نوع
متغیر نداریم. اما در مواردی که کامپایلر قادر به استنتاج نوع نباشد، باید نوع متغیر را با استفاده از Type
annotation اعلام کنیم. مثال زیر یک نمونه از ابهاماتی که میتواند برای گامپایلر رخ بدهد را نشان میدهد.
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 دارد اما از دقت مضاعف نسبت
به آن برخوردار است.
Rust
fn main() {
let x = 2.0;
let y: f32 = 3.0;
}
Rust از اعمال محاسباتی جمع، تفریق، ضرب و تقسیم و محاسبه باقیمانده روی همهی نوعهای عددی پشتیبانی میکند. کد
زیر نحوهی عملکرد این عملگرهای حسابی را روی اعداد اعشاری و صحیح نشان میدهد.
Rust
fn main() {
let sum = 5 + 10;
let difference = 95.5 - 4.3;
let product = 4 * 30;
let quotient = 56.7 / 32.2;
let truncated = -5 / 3;
let remainder = 43 % 5;
}
در اینجا چند گزارهی تخصیص داریم که در هر کدام، یک عمل محاسباتی انجام شده و نتیجه در یک متغیر ذخیره شده است.
نوع بولین
در Rust مثل هر زبان دیگر، یک نوع بولین برای مقادیر دوحالته در نظر گرفته شده که bool نام دارد. متغیرهای بولین
(یا دارای نوع bool) یک بایت فضا اشغال میکنند و میتوانند یکی از دو مقدار true و false را دریافت کنند.
Rust
fn main() {
let t = true;
let f: bool = false;
}
بولینها نقش مهمی در برنامهنویسی دارند. نتیجهی اعمال مقایسهای، یک مقدار بولین است. مثلاً گزارهای مثل let
b = 10 > 5; باعث میشود مقدار true در متغیر b ذخیره شود. اما بولینها بیش از هر چیز در ساختارهای شرطی مانند
if به کار میآیند. این موضوع را در درسهای بعدی همین فصل خواهیم دید.
نوع کاراکتر
در Rust یک نوع کاراکتری با نام char وجود دارد که برای کار با کاراکترهای unicode در نظر گرفته شده است. وقتی
بخواهیم یک کاراکتر را در یک متغیر از این نوع ذخیره کنیم، باید آن کاراکتر را درون آپستروف قرار دهیم.
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 هم استفاده کردهایم، اما انجام این کار لزومی ندارد. چون کامپایلر میتواند از روی
مقادیری که به آیتمهای تاپل دادهایم، نوع آنها را استنتاج کند.
Rust
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
در کد زیر بعد از تعریف تاپل tup از تکنیکی با نام Destructuring استفاده کرده و آیتمهای تاپل را به سه متغیر با
نامهای x، y و z دادهایم. این تکنیک به ما امکان میدهد که به آیتمهای تاپل دسترسی پیدا کنیم. در نهایت، یکی
از آیتمها یا عناصر تاپل چاپ شده است.
Rust
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {y}");
}
البته برای دسترسی به آیتمهای یک تاپل راه سادهتری هم وجود دارد و آن استفاده از اندیس عناصر است. کافیست
اندیس عنصر را بعد از نام تاپل و یک کاراکتر نقطه بیاوریم.
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 از یک جفت براکت [ ] استفاده میشود که شامل لیست
مقادیری است که با کاما از هم جدا شدهاند.
Rust
fn main() {
let a = [1, 2, 3, 4, 5];
}
به طور کلی، آرایهها و تاپلهای Rust با آرایهها و تاپلهای زبانهای دیگر فرق دارند. چون در کمتر زبانی
دیده میشود که سایز این کالکشنها ثابت باشد. اما مسئله این است که در Rust آرایهها و تاپلها کاربرد خاص خودشان را
دارند و برای ذخیرهی دادهها در stack مناسب هستند نه در heap. اگر با این مفاهیم آشنا نیستید، تا فصل چهارم
صبر کنید. اگر به یک کالکشن انعطافپذیرتر نیاز داشته باشیم که سایزش قابل تغییر باشد، باید از نوعی به نام Vec
استفاده کنیم که بخشی از کتابخانهی استاندارد Rust است و در فصل هشتم با آن کار خواهیم کرد.
فرض کنید بخواهیم نام ماههای میلادی را در یک کالکشن ذخیره کنیم. از آنجایی که سایز چنین کالکشنی همیشه ثابت و
برابر با 12 است، یک آرایه انتخاب مناسبی خواهد بود.
Rust
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
اگر بخواهیم یک آرایه را با استفاده از type annotation و اعلام صریح نوع آن ایجاد کنیم، به شکل زیر عمل
میکنیم:
Rust
let a: [i32; 5] = [1, 2, 3, 4, 5];
در اینجا نوع عناصر آرایه i32 است و عدد 5 بعد از سمیکالن نیز سایز آرایه را مشخص میکند.
به علاوه، اگر بخواهیم آرایهای ایجاد کنیم که همهی عناصر آن مقدار اولیهی یکسانی داشته باشند، میتوانیم به
صورت زیر این کار را انجام دهیم:
Rust
آرایهی a از 5 عنصر تشکیل شده که همگی دارای مقدار اولیهی 3 هستند. این کار معادل نوشتن let a = [3,3,3,3,3];
است منتها به شکلی مرتبتر و جمع و جورتر.
برای دسترسی به عناصر یک آرایه، میتوانیم به سادگی از اندیس هر عنصر استفاده کنیم:
Rust
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
در اینجا متغیر first مقدار 1 را دریافت کرده زیرا 1 مقدار اولین عنصر آرایه یعنی عنصر با اندیس صفر است. متغیر
second نیز مقدار 2 را دریافت کرده است.
حالا به مثال زیر نگاه کنید:
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 با انجام یک بررسی زمان اجرا، به جای دسترسی به یک مکان غلط، اجرای برنامه را متوقف میکند و ما
را از این مخاطرات مصون نگه میدارد.