Basic BASIC
- Краен срок:
- 10.01.2023 17:00
- Точки:
- 20
Срокът за предаване на решения е отминал
От носталгия за по-стари, по-прости езици, ще имплементираме наш си интерпретатор за нещо като езика BASIC. Ще искаме една ей такава игричка тип "познай числото" да сработи:
10 PRINT guess_a_number
20 READ Guess
30 IF Guess > 42 GOTO 100
40 IF Guess < 42 GOTO 200
50 IF Guess = 42 GOTO 300
100 PRINT too_high
110 GOTO 10
200 PRINT too_low
210 GOTO 10
300 PRINT you_got_it!
Ах, този GOTO, an elegant weapon for a more civilized age! Все едно съм на стария Правец-8C... Kinda. Някои неща може да изглеждат малко странни, но ще караме стъпка по стъпка и ще ги доизясним.
Базови структури
Ще започнем със структурата за интерпретатора и възможните му грешки (които ще доизясняваме после):
use std::io::{self, Write, Read};
#[derive(Debug)]
pub enum InterpreterError {
IoError(io::Error),
UnknownVariable { name: String },
NotANumber { value: String },
SyntaxError { code: String },
RuntimeError { line_number: u16, message: String },
}
// ВАЖНО: тук ще трябва да се добави lifetime анотация!
pub struct Interpreter<R: Read, W: Write> {
// Тези полета не са публични, така че може да си ги промените, ако искате:
input: R,
output: &mut W,
// Каквито други полета ви трябват
}
impl<R: Read, W: Write> Interpreter<R, W> {
/// Конструира нов интерпретатор, който чете вход от `input` чрез `READ` командата и пише в
/// `output` чрез `PRINT`.
///
pub fn new(input: R, output: &mut W) -> Self {
todo!()
}
}
Има едно важно нещо, което трябва да се отбележи тук -- както е даден, горния код запазва &mut W
, в структурата Interpreter
и съответно тя трябва да има lifetime анотация. Това не би трябвало да е особено трудно, но все пак трябва да го направите, иначе няма да ви се компилира дори и базовия тест. You have been warned.
Навсякъде надолу, където пишем impl<R: Read, W: Write> Interpreter<R, W> {
, приемете, че трябва да промените декларацията с lifetimes, както сте я променили тук.
Допълнително, забележете, че типа R
има само Read
ограничение. Очакваме да четете цели редове, така че това може да е доста досадно (надяваме се, че е очевидно, но ако просто смените trait constraint-а, домашното няма да се компилира). Но може би из лекциите сме говорили за тип от стандартната библиотека, който може да буферира Read
типове и да ви даде да четете по редове?
Добавяне и парсене на редове
// Не забравяйте lifetime анотацията
impl<R: Read, W: Write> Interpreter<R, W> {
/// Тази функция добавя ред код към интерпретатора. Този ред се очаква да започва с 16-битово
/// unsigned число, последвано от някаква команда и нейните параметри. Всички компоненти на
/// реда ще бъдат разделени точно с един интервал -- или поне така ще ги тестваме.
///
/// Примерни редове:
///
/// 10 IF 2 > 1 GOTO 30
/// 20 GOTO 10
/// 30 READ V
/// 40 PRINT V
///
/// В случай, че реда не е валидна команда (детайли по-долу), очакваме да върнете
/// `InterpreterError::SyntaxError` с кода, който е бил подаден.
///
pub fn add(&mut self, code: &str) -> Result<(), InterpreterError> {
todo!()
}
}
Има 4 команди, които ще искаме да имплементирате. Караме една по една:
PRINT <стойност>
: При изпълнение ще напечата дадената стойност и нов ред (символ\n
). Не е нужно да обработвате стойността по време на добавяне -- може да я запазите като низ за интерпретация после. (Ако искате да добавите допълнителна валидация, няма да попречи -- ще подаваме наPRINT
само синтактично-валидни стойности в тестовете)READ <име на променлива>
: При изпълнение, ще прочете точно един ред, премахвайки завършващия\n
символ. Името на променливата трябва да е низ, който започва с главна буква (char::is_uppercase
), иначе тази команда е невалидна и очакваме да върнетеInterpreterError::SyntaxError
. (Да, низа може да е на кирилица, ще тестваме с примерноREAD Щ
)GOTO <номер на ред>
: При изпълнение, променя следващия ред за изпълнение на програмата да бъде дадения ред. Реда трябва да е валидноu16
число, иначе очакваме да върнетеInterpreterError::SyntaxError
.IF <стойност> <оператор> <стойност> GOTO <номер на ред>
: Най-сложната операция, която ще поддържаме. По време на добавяне на реда, може да си запазите<стойност> <оператор> <стойност>
частта както ви е удобно, няма нужда да валидирате какви точно неща има вътре. Номера на ред трябва да е валидноu16
число, иначе очакваме да върнетеInterpreterError::SyntaxError
.
За всички тези команди, може да очаквате, че всеки един от параметрите ще бъде разделен с точно един интервал. Свободни сте да ги разбиете със .split_whitespace
или по един интервал, ваш избор. Винаги ще използваме големи букви за командите, точно както ги даваме в инструкциите и в примерите.
Всички аргументи са задължителни, също и номер на всеки ред -- ако липсва аргумент, това е InterpreterError::SyntaxError
, също ако има твърде много. Примерно, тези редове са синтактично грешни:
10 PRINT A B
20 IF 13 > 18 GOTO
READ X
30
Ред 10 има твърде много компоненти, а на ред 20 му липсват. Ред 30 е напълно празен, което също е невалидно. Между 20 и 30 има ред без номер, което е невалидно. Празен низ също не е валиден ред.
В нашите тестове ще пробваме само и единствено тези 4 команди, така че няма нужда да валидирате неизвестни такива, но можете, ако искате. Можете и да добавите ваши, в случай че усетите изблик на ентусиазъм, който може да бъде потушен само с имплементация на АКО 3 > 2 ХОП 40
. Само внимавайте да не счупите тези, които искаме :) (hint: тестове помагат много)
Когато добавяте ред, може да го запазите като низ, като вектор, като някаква ваша структура -- както го измислите. Редовете са важни, защото както виждате, има команда за скок на ред. Не е необходимо да се вкарват поред -- ако напишете програма, в която първо слагате ред 20, после ред 10, тя ще се изпълни от 10 към 20. Можете да добавите редове с един и същ номер, които ще се презапишат. Тоест:
3 PRINT 1
2 PRINT 2
1 PRINT 3
1 PRINT 4
Втория ред 1 ще презапише първия ред 1, и по време на изпълнение, програмата ще се пренареди. Тоест, горната програма е еквивалентна на:
1 PRINT 4
2 PRINT 2
3 PRINT 1
Както забелязвате, не е нужно да са през 10 :). Причината повечето примери да са през 10 е защото едно време така се правеше, за да има дупки между редовете да сложим код, който сме забравили 😅.
Изпълнение на програмата
След като сме добавили всички редове на програмата, време е да я изпълним. Тук ще трябва да се занимаваме с променливи и вход и изход. Първо, нека да имплементираме една helper функция, която да оцени някой низ като стойност, базирано на специфични правила:
// Не забравяйте lifetime анотацията
impl<R: Read, W: Write> Interpreter<R, W> {
/// Оценява `value` като стойност в контекста на интерпретатора:
///
/// - Ако `value` е низ, който започва с главна буква (съгласно `char::is_uppercase`), търсим
/// дефинирана променлива с това име и връщаме нейната стойност.
/// -> Ако няма такава, връщаме `InterpreterError::UnknownVariable` с това име.
/// - Ако `value` е валидно u16 число, връщаме числото
/// -> Иначе, връщаме `InterpreterError::NotANumber` с тази стойност
///
pub fn eval_value(&self, value: &str) -> Result<u16, InterpreterError> {
todo!()
}
}
Аргумента на PRINT
ще работи по горния начин, с една добавка. Стойностите в условието на IF
-клаузите също ще се оценяват по този начин -- число или променлива, която вади число.
Записването на стойност на променлива става с командата READ
, която чете стойността от input
. Ако запишем втори път същата променлива, просто се презаписва. Няма да си кривим душата -- перфектен use case е за HashMap или BTreeMap, смело си харесайте една от тези структури за да си пазите променливите. Ключовете ще са низове, а стойностите ще са u16
. Освен началната главна буква, ще тестваме само с други букви (малки и големи, включително кирилица), така че дали и каква валидация ще правите е ваша работа.
Ето как изглежда run
функцията:
// Не забравяйте lifetime анотацията
impl<R: Read, W: Write> Interpreter<R, W> {
/// Функцията започва да изпълнява редовете на програмата в нарастващ ред. Ако стигне до GOTO,
/// скача на съответния ред и продължава от него в нарастващ ред. Когато вече няма ред с
/// по-голямо число, функцията свършва и връща `Ok(())`.
///
/// Вижте по-долу за отделните команди и какви грешки могат да върнат.
///
pub fn run(&mut self) -> Result<(), InterpreterError> {
todo!()
}
}
Ако в който и да е момент операция по четене от input
или писане в output
върне грешка, това ще е std::io::Error
и очакваме да я пакетирате в InterpreterError::IoError
и да я върнете. Вероятно ще е удобно да използвате eval_value
, но забележете, че грешките оттам трябва да се преведат до RuntimeError
.
Всички InterpreterError::RuntimeError
очакваме в line_number
да съдържат конкретния номер на ред, където се е счупила програмата, и каквото съобщение си искате в message
.
Една по една, какво прави изпълнението на 4те команди:
PRINT <стойност>
: записва дадената стойност и нов ред (символ\n
) вoutput
-а. Ако стойността започва с голяма буква (спрямоchar::is_uppercase
), третирайте я като променлива и върнетеInterpreterError::RuntimeError
, ако няма такава дефинирана. Ако стойността е число, просто го напечатайте, но тук имаме трети случай -- ако е низ, който започва с малка буква, запишете низа вoutput
. Така можем да пишем числа, идващи от входа, но също и нормални низови съобщения (без да ви караме да имплементирате парсене на кавички, hope you appreciate it).READ <име на променлива>
: Прочита отinput
точно един ред, премахвайки завършващия\n
. Опитва се да прочетеu16
от него, но ако не сработи, очаквамеInterpreterError::RuntimeError
. Както отбелязахме по-горе, това записва променлива с даденото име, като я презаписва, ако вече съществува.GOTO <номер на ред>
: Променя следващия ред за изпълнение на програмата да бъде дадения номер на ред. Ако такъв номер на ред не съществува, очакваме да върнетеInterpreterError::RuntimeError
с номера на реда на самата команда, а не реда, към който се скача.-
IF <стойност> <оператор> <стойност> GOTO <номер на ред>
: Частта, която включваGOTO <номер на ред>
работи по абсолютно същия начин като по-горе откъм грешки и функционалност. GOTO-то се изпълнява (и валидира за грешки) само ако условието наIF
-клаузата е истина.Условието се състои от три части, две стойности и оператор между тях. Операторите, които ще подаваме в тестове, ще са само тези три:
<
,>
, и=
за равенство.Двете стойности се оценяват както е обяснено в
eval_value
-- променливи с главна буква илиu16
числа. Както указахме по-горе, превеждате всякакви грешки доRuntimeError
. Тоест, валидниIF
-клаузи могат да са, примерно:-
IF 2 > 1 GOTO 13
-- скача на ред 13 -
IF 2 < 1 GOTO 31
-- не скача на подадения ред, продължава си на следващия -
IF 3 = 3 GOTO 33
-- скача на ред 33
-
В тестовете ще викаме run
само по веднъж на всеки интерпретатор (макар че за целите на теста, ще викаме eval_value
след run
), така че е ваш избор какво да се случи ако се извика втори път -- може да се занулят променливите и да се почне отначало, може да се resume-не от последния ред, който се е счупил, ние няма да проверяваме този case.
(Hint: Предвид, че консистентно връщате RuntimeError
, може би си заслужава да си направите някакво помощно средство за да ги инстанцирате лесно в контекста на run
.)
Ако няма програма за изпълнение, функцията run
просто не прави нищо и връща успешен резултат.
Допълнителни бележки
Ако ви изглежда като твърде много работа, мисля че ще се изненадате -- просто карайте команда по команда, пишете си по някой и друг тест от примерите, които ви дадохме, и ще се оправите. Спокойно можете да си направите частично решение като имплементирате само PRINT
и в други случаи оставите todo!()
, после имплементирате READ
и т.н..
Искате да си тествате играта от по-горе с истински вход и изход?
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut interpreter = Interpreter::new(stdin, &mut stdout);
interpreter.add("10 PRINT guess_a_number").unwrap();
// interpreter.add(...
interpreter.run().unwrap();
Иначе, ако ви е любопитно да видите как изглежда пълен вариант на езика, може да цъкнете на тези линкове:
- Live-coding на змия: https://www.youtube.com/watch?v=7r83N3c2kPw
- Книги за Правец (препоръчвам "Работа с персонален компютър"): http://pravetz.info/books.html
- Wordle имплементация на BASIC: https://twitter.com/katie_panda/status/1496607013589204998
Нашия интерпретатор не е баш като тези, така че не приемайте официалната документация на Applesoft Basic като канонична за домашното -- следвайте си инструкциите по-горе.
Задължително прочетете (или си припомнете): Указания за предаване на домашни
Погрижете се решението ви да се компилира с базовия тест: