Всем привет. Со времени моего последнего поста я добавил в свой язык несколько новых функций: теперь появилось больше операторов, таких как модуль (%) и побитовый сдвиг (‹‹ и ››), а также я улучшил систему обработки ошибок. Поскольку кодирование новых операторов мало чем отличается от остальных, я не буду на этом заострять внимание. Этот пост будет посвящен обработке ошибок. Если вы хотите увидеть весь исходный код этого проекта, посетите этот репозиторий GitHub.

Моя цель улучшения обработки ошибок — создать флаг компилятора, который будет включать/отключать подробные ошибки времени выполнения. Если ошибки не детализированы, они не будут содержать строку и столбец, в которых произошла ошибка. Хотя это затрудняет отладку кода, оно также делает исполняемый файл меньше и быстрее. В релизных сборках это может быть полезно для повышения производительности.

Улучшения обработки ошибок я начал с создания структуры Log. Вместо того, чтобы просто возвращать список Strings при возврате ошибок, я возвращаю список Logs, который можно распечатать. Вот как выглядит структура Log:

/// Represents all possible errors as well as helpful debug information when relevant.
#[derive(Clone, PartialEq, Eq)]
pub struct Log
{
    pub log_type: LogType,
    pub line_and_col: Option<(usize, usize)>
}
/// An enum representing anything that can be logged.
#[derive(Clone, PartialEq, Eq)]
pub enum LogType
{
    Warning(WarningType),
    Error(ErrorType)
}

Каждый Log содержит тип журнала, а также дополнительную пару строк/столбцов, используемую для отладки. В настоящее время существуют две разные группы журналов: Warning и Error. Тип Warning используется для обозначения того, что что-то пошло не так, но код все еще может выполняться, а тип Error указывает на то, что программа каким-то образом выйдет из строя. Перечисления WarningType и ErrorType хранят все возможные сообщения, которые могут отображаться в терминале. Это очень длинный список, поэтому мы не будем его здесь приводить. Код форматирования для Logs также очень длинный, но, используя цветной пакет, мы можем создавать красивые сообщения об ошибках, подобные приведенному ниже:

После этого и изменения всего кода для использования этой системы мы получаем некоторые мгновенные преимущества: во-первых, нам больше не нужна переменная can_compile: мы можем просто проверить, содержит ли список Logs какие-либо ошибки. Мы также можем очень легко изменять ошибки, поэтому включение/отключение номеров строк и столбцов будет очень простым.

После этого я добавил новый флаг -detailed_errors в cli_reader.rs. Процесс для этого был таким же, как и для последнего флага, поэтому я перейду к виртуальной машине. Когда я пошел модифицировать виртуальную машину, я заметил, насколько она повторяется. Я решил навести порядок с помощью замыканий — функций, которые можно использовать в качестве аргументов в других функциях. Я создал функцию для унарных и бинарных операторов, и теперь все операторы используют одну из этих двух функций. Унарная функция бесполезна для этого обсуждения, поэтому ниже приведена бинарная функция, а также использована структура RuntimeError.

// Contains info about a runtime error that could happen.
struct RuntimeError<'a, T>
{
    condition: &'a (dyn Fn(T) -> bool),
    error: ErrorType,
    index: &'a mut usize,
    bytecode: &'a Vec<u8>,
}

...

// Performs a binary operation on a pair of ints.
fn binary_int<F>(stack: &mut Vec<u8>, logs: &mut Vec<Log>, func: F, error: Option<RuntimeError<(i32, i32)>>)
    where F: Fn(i32, i32) -> i32
{
    let detailed_err: bool = if let Some(error) = &error
    {
        get_detailed_err(error.bytecode)
    }
    else
    {
        false
    }; 
    if detailed_err && errors_stored_incorrectly(error.as_ref().expect("detailed_err is true"))
    {
        logs.push(Log{log_type: LogType::Error(ErrorType::FatalError), line_and_col: None});
        return;
    }

    let b: Option<i32> = pop_int_from_stack(stack);
    let a: Option<i32> = pop_int_from_stack(stack);
    let mut fail: bool = true;
    if let Some(a) = a
    {
        if let Some(b) = b
        {   
            fail = false;
            if let Some(mut error) = error
            {
                handle_error(&mut error, (a, b), detailed_err, logs)
            }
            let c: i32 = func(a, b);
            stack.append(&mut c.to_le_bytes().to_vec());
        }
    }
    if fail 
    {
        logs.push(Log{log_type: LogType::Error(ErrorType::FatalError), line_and_col: None});
    }
}

Структура RuntimeError хранит условие, при котором возникает ошибка. Тип этой ошибки Fn(T) -> bool, который представляет собой замыкание, которое принимает параметр универсального типа T и выводит логическое значение. Поскольку мы не можем знать размер этого типа во время компиляции, мы используем ключевое слово dyn и заключаем его в ссылку, размер которой нам известен. Мы также сохраняем тип возникшей ошибки, текущий индекс байт-кода, в котором мы находимся, и сам байт-код. Все они, за исключением типа ошибки, являются ссылками с общим параметром времени жизни 'a, который также является временем жизни выходной ссылки. Это просто означает, что если какая-либо из внутренних ссылок будет удалена из памяти, этот объект тоже будет удален.

Первое, что делает этот код, — выясняет, возможна ли детальная ошибка. Он использует значение флага, который хранится в байт-коде, а также независимо от того, равно ли значение ошибки None или нет. При наличии подробной ошибки errors_stored_incorrectly проверяет доступность информации о строках и столбцах, а в противном случае выдает фатальную ошибку. Затем код работает в основном так же, как и раньше. Если код может выдать ошибку, вызывается handle_error:

// Handles runtime errors.
fn handle_error<T>(error: &mut RuntimeError<T>, value: T, detailed_err: bool, logs: &mut Vec<Log>)
{
    let condition: &dyn Fn(T) -> bool = error.condition;
    let index: &mut usize = error.index;
    let bytecode: &Vec<u8> = error.bytecode;
    let ptr_size: usize = get_ptr_size(bytecode);
    if condition(value)
    {
        if detailed_err
        {
            let mut bytes : [u8; (usize::BITS / 8) as usize] = [0; (usize::BITS / 8) as usize];
            for i in 0..ptr_size
            {
                bytes[i] = bytecode[*index];
                *index += 1;
            }
            let line: usize = usize::from_le_bytes(bytes);
            bytes = [0; (usize::BITS / 8) as usize];
            for i in 0..ptr_size
            {
                bytes[i] = bytecode[*index];
                *index += 1;
            }
            let col: usize = usize::from_le_bytes(bytes);
            logs.push(Log{log_type: LogType::Error(error.error.clone()), line_and_col: Some((line, col))});
        }
        else 
        {
            logs.push(Log{log_type: LogType::Error(error.error.clone()), line_and_col: None});
        }
    }
    else if detailed_err
    {
        *index += 2 * get_ptr_size(bytecode);
    }
}

Если для данного значения выполняется условие ошибки, мы регистрируем ошибку. Однако только в случае detailed_err мы сначала собираем информацию о строках и столбцах. Окончание else if гарантирует, что обе возможности готовы прочитать следующую инструкцию после обработки потенциальной ошибки. Это то, что мы хотим: теперь только при установленном флаге номер строки и столбца будет записываться с ошибкой. Обратите внимание, что это применимо только к ошибкам времени выполнения. В случае ошибок во время компиляции всегда отображается информация о строках и столбцах. Не показывать это никогда не поможет, а показать это имеет гораздо больше смысла, поскольку это необходимо исправить, прежде чем код сможет что-либо сделать.

Вот вывод той же ошибки, что и раньше, когда подробные ошибки отключены.

На данный момент информация о строках и столбцах отображается по умолчанию, и в командной строке необходимо установить флаг false, чтобы эта информация не попала в байт-код. Я аргументирую это тем, что удаление этой информации необходимо только в выпускных сборках, которые встречаются реже, чем отладочные сборки.

Это все на сегодня. В следующий раз я добавлю логические значения, что давно собирался сделать. К сожалению, это потребует много переписывания в парсере. Я люблю рефакторинг :).