Building Zerocalc قسمت I – rustc lexer و lexer in rust

بعد از خواندن زبان برنامه نویسی Rust کتاب و اجرای چند تمرین، زمان نوشتن یک برنامه واقعی است. من میخواهم چیزی بنویسم که بتوانم خودم از آن استفاده کنم، که هنوز فهرست «Todo» دیگری نیست. بنابراین تصمیم گرفتم … یک ماشین حساب دیگر بسازم!
ماشینحسابهایی که من از استفاده از آن لذت میبرم، ماشینهای نوتبوک مانند Insect یا Parsify هستند. به نظر من این یک ایده پروژه خوب برای یادگیری یک زبان برنامه نویسی جدید است. ساخت چنین ماشین حسابی به پیاده سازی تجزیه کننده و پیاده سازی UI نیاز دارد. هنگامی که ماشین حساب اولیه انجام شد، پیشرفت ها و اکتشاف فناوری امکانات بی پایانی هستند. یک ویژگی تبدیل ارز را در نظر بگیرید که داده های بلادرنگ را از برخی از سرویس های مبادله ارز آنلاین می گیرد. پیادهسازی آن شامل تماسهای REST API، کدهای همگامسازی و/یا threading میشود.
برای هدایت پیاده سازی خود، از این عبارت بسیار ساده استفاده می کنیم:
123+4
اولین کاری که ماشین حساب ما باید انجام دهد این است که این عبارت را تجزیه کند. یک راه معمول برای انجام آن، تقسیم تجزیه به دو مرحله است:
- پردازش متن در جریانی از نشانه ها
- ساختن نموداری که بیانگر عبارت (درخت نحو انتزاعی) است.
در این پست به توکن سازی می پردازیم. راههای زیادی برای انجام توکنسازی وجود دارد، از جمله استفاده از عبارات منظم یا تولید کد توکنساز با ژنراتورهایی مانند flex. اما اگر بررسی کنیم که چگونه rustc
تجزیه کننده این کار را انجام می دهد؟
Rustc از نمایش توکن ساده استفاده می کند:
pub struct Token {
kind: TokenKind,
len: usize
}
توجه داشته باشید که هیچ مقدار نشانه یا موقعیت رمز ذخیره نشده است. بعداً خواهیم دید که چگونه تجزیه کننده می تواند مقدار توکن را فقط با استفاده از طول توکن بازیابی کند.
TokenKind
نوع توکن را مشخص می کند. Rustc 39 نوع توکن را تعریف می کند. برای بیان ساده ما، به مقدار بسیار کمتری نیاز داریم، با این حال، ایده کلی یکسان است:
pub enum TokenKind {
/// 123, 0.123, "abc" etc.
Literal(LiteralKind),
/// +
Add,
/// not recognized
Unknown,
/// end of input
Eof,
}
برخی از انواع توکن به پارامترهای اضافی نیاز دارند، به عنوان مثال، در مورد literal، باید بدانیم که آیا این یک عدد صحیح، عدد ممیز شناور، رشته و غیره است یا خیر. برای تجزیه عبارت نمونه خود حداقل به اعداد صحیح نیاز داریم:
pub enum LiteralKind {
Int,
}
کلمات واقعی زنگ پیچیدهتر هستند: یک عدد صحیح به اطلاعاتی در مورد پایه خود (برای اعداد باینری، اعشاری، هشتگانه یا هگزا) و طول پسوند آن نیاز دارد تا 12u32
می تواند به درستی به عنوان یک عدد صحیح بدون علامت 32 بیتی شناسایی شود. تمام این اطلاعات در پارامترهای enum تحت اللفظی ذخیره می شود.
توکن سازی توسط ساختاری به نام انجام می شود Cursor
که همچنین بسیار مینیمالیستی است:
pub struct Cursor<'a> {
chars: Chars<'a>,
len_remaining: usize,
}
impl Cursor<'_> {
fn new(input: &str) -> Cursor<'_> {
Cursor {
chars: input.chars(),
len_remaining: input.len(),
}
}
//...
این Chars
نوع را از کتابخانه استاندارد Rust پیاده سازی می کند Iterator
صفت بر الف str
تکه. Cursor
از این تکرار کننده برای پیشروی روی رشته ورودی با استفاده از bump
روش. همچنین از آن برای محاسبه طول نشانه استفاده می کند. طول توکن تفاوت بین طول ورودی قبلی و طول باقی مانده ورودی است. این محاسبه توسط pos_within_token
روش. پس از تجزیه یک نشانه، طول باقی مانده به روز می شود (reset_pos_within_token
).
// impl Cursor continued
fn new(input: &str) -> Cursor<'_> {
Cursor {
chars: input.chars(),
len_remaining: input.len(),
}
}
fn bump(&mut self) -> Option<char> {
self.chars.next()
}
fn pos_within_token(&mut self) -> usize {
self.len_remaining - self.chars.as_str().len()
}
fn reset_pos_within_token(&mut self) {
self.len_remaining = self.chars.as_str().len();
}
}
بیایید ببینیم چگونه این ممکن است برای عبارت ورودی ما کار کند 123+4
. طول اولیه 5 است. اگر در +
، طول باقیمانده 2 خواهد بود، بنابراین pos_within_token
3 را برمی گرداند که طول واقعی آن است 123
تحت اللفظی
قبل از شروع توکنسازی، به یک ابزار دیگر نیاز داریم – توانایی پیشنمایش مخفیانه کاراکتر بعدی قبل از اینکه مکاننما را پیش ببریم. این به تعیین نوع توکن کمک می کند. مثلاً اگر ببینیم /
خوب است بدانید که آیا آنچه در زیر آمده است /
(کامنت معمولی)، //
(نظر سند)، یا *
(بلاک کردن نظر).
// impl Cursor continued
fn first(&self) -> char {
self.chars.clone().next().unwrap_or(EOF_CHAR)
}
fn second(&self) -> char {
let mut iter = self.chars.clone();
iter.next();
iter.next().unwrap_or(EOF_CHAR)
}
fn third(&self) -> char {
let mut iter = self.chars.clone();
iter.next();
iter.next();
iter.next().unwrap_or(EOF_CHAR)
}
به نظر زیاد است، اما ظاهراً شبیه سازی مکان نما و پیشبرد آن به خوبی بهینه می شود. Rust بر روی یک پشته با انواع اندازه های شناخته شده تمرکز می کند که به نظر من تولید کد بهینه شده را آسان تر می کند.
حال بیایید بیان خود را نشانه گذاری کنیم:
impl Cursor<'_> {
fn advance_token(&mut self) -> Token {
let char = match self.bump() {
Some(c) => c,
None => return Token::new(TokenKind::Eof, 0),
};
let token_kind = match char {
'0'..='9' => {
let literal_kind = self.number();
TokenKind::Literal(literal_kind)
}
'+' => TokenKind::Add,
_ => TokenKind::Unknown,
};
let token = Token::new(token_kind, self.current_token_len());
self.update_len_remaining();
token
}
با خواندن کاراکتر بعدی (و همزمان با جلو بردن مکان نما) شروع می کنیم. اگر آن عملیات مقدار را برنگرداند، به این معنی است که به انتهای ورودی رسیدهایم، بنابراین باید نهایی را برگردانیم Eof
نشانه سپس باید تصمیم بگیریم که چه نوع نشانه ای را تجزیه می کنیم. این ممکن است مستلزم نگاه کردن به آینده با یکی از آنها باشد first/second/third
روش هایی مانند نظرات. برای ساده ما 123+4
اگر بررسی کنیم که آیا با یک رقم سروکار داریم یا یک کافی است +
امضا کردن. اگر با چیز غافلگیر کننده ای مواجه شدیم، باز می گردیم Unknown
اجازه دادن به کد مشتری تصمیم بگیرد که چه کاری انجام دهد.
تجزیه Int
تحت اللفظی در مثال ما بسیار ساده است:
fn number(&mut self) -> LiteralKind {
loop {
match self.first() {
'0'..='9' => {
self.bump();
}
_ => break,
}
}
LiteralKind::Int
}
حروف واقعی Rust بسیار پیچیدهتر هستند – انواع رشتهها، نمایش اعداد مختلف، و غیره. lexer Rustc بیش از ورودی پیشرفت میکند و کاراکترهای دریافتی را با الگوها مطابقت میدهد، و درک درستی از نوع تحت اللفظی یا دیگر نوع نشانههایی ایجاد میکند که با آن سروکار دارد. این روش تجزیه از بالا به پایین نامیده می شود تجزیه نزولی بازگشتی.
برای سهولت استفاده از توکنایزر، میتوانیم بپیچیم Cursor
به درون Iterator
صفت یک ابزار خوب در کتابخانه Rust std برای انجام این کار وجود دارد، یعنی iter::from_fn
:
pub fn tokenize(input: &str) -> impl Iterator<Item = Token> + '_ {
let mut cursor = Cursor::new(input);
std::iter::from_fn(move || {
let token = cursor.advance_token();
if token.kind != TokenKind::Eof { Some(token) } else { None }
})
}
چگونه می توان از این توکنایزر استفاده کرد؟ بیایید برای نشان دادن آن یک تست بنویسیم:
#[test]
fn test_cursor() {
let input = "123+4";
let mut pos = 0;
let mut cursor = tokenize(&input);
let Token { kind, len } = cursor.next().unwrap();
let token_val = &input[pos..pos + len];
assert_eq!(TokenKind::Literal(LiteralKind::Int), kind);
assert_eq!(token_val, "123");
pos = pos + len;
let Token { kind, len } = cursor.next().unwrap();
let token_val = &input[pos..pos + len];
assert_eq!(TokenKind::Add, kind);
assert_eq!(token_val, "+");
pos = pos + len;
let Token { kind, len } = cursor.next().unwrap();
let token_val = &input[pos..pos + len];
assert_eq!(TokenKind::Literal(LiteralKind::Int), kind);
assert_eq!(token_val, "4");
assert_eq!(None, cursor.next());
}
کد مشتری باید موقعیت توکن را در رشته ورودی ردیابی کند. این اجازه می دهد تا ارزش واقعی رمز را مستقیماً از رشته ورودی بخوانید و توکن سازی نیازی به کپی کردن هیچ یک از رشته ورودی ندارد.
و همین است! بقیه کدهای توکنایزر rustc عمدتاً اکتشافی هستند که به توکنایزر اجازه میدهد تا توکنها را شناسایی کند.
اولین کار من به عنوان مهندس نرم افزار در یک شرکت کیفیت نرم افزار بود که در آنجا روی یک تحلیلگر کد C++ کار می کردم. ما با تجزیهکنندهای کار کردهایم که با استفاده از ابزارهای شبیه به بیسون/فلکس تولید شده است. از آنجایی که C++ یک زبان حساس به متن است، نمی توان آن را به صورت یک گرامر ساده LL(k) بیان کرد. در نتیجه، تجزیهکننده ما مجموعهای از وصلهها و هکها در بالای کدهای تولید شده خودکار و یک کابوس واقعی برای کار بود. پس از مدتی، ما به یک تجزیه کننده شخص ثالث تغییر مکان دادیم که به عنوان یک تجزیه کننده نزولی بازگشتی پیاده سازی شد. من از وضوح و انعطاف پذیری این رویکرد برای تجزیه کد لذت می برم.