image


Попался мне на глаза Brainfuck-оподобный язык Cow. И решил я написать для него интерпретатор на новомодном Rust. А так как Rust — мультипарадигменный язык, то стиль написания программы будет функциональный. Чтобы узнать что получилось — прошу под кат.


Повествование постараюсь вести в виде туториала, вопросы в комментариях, ценные замечания о недостаточной функциональности — туда же: будем исправлять! А пока что поехали.


Предварительное проектирование


Состояние виртуальной коровы машины будем хранить в неизменяемой переменной state. Все изменения будут производится с помощью функций, имеющих такую семантику:


fn change(previousState: CowVM) -> CowVM // newState

Похоже на Elm, Redux, может ещё на что-то, чего я и сам не знаю. Не правда ли?


На самом деле, именно то, что с самого начала очень хорошо видно, как хранить данные, делает эту задачу пригодной для функционального подхода.


Действительно, что такое виртуальная машина Cow?


  • Массив команд — самый обычный целочисленный массив;
  • Память — второй такой же самый простой целочисленный массив;
  • Текущая ячейка команды — индекс ячейки в массиве команд;
  • Текущая ячейка памяти — индекс ячейки в массиве памяти;
  • Регистр — почти обычное целое число. Почему почти? Узнаем дальше!
  • ???
  • PROFIT

Уже в самом начале работы над задачей я точно уверен, что все данные на любом этапе работы программы будут храниться именно так. Не появится дополнительных fields или views, заказчик не подкинет парочку модификаций бизнес-логики… Мечта программиста!


Как будем сохранять иммутабельность?


На самом деле сохранять иммутабельность можно только одним способом — каждый раз, когда надо что-то изменить в состоянии нашей программы — создаём заново абсолютно новое состояние, сохраняем его а старое выкидываем на свалку под названием out of the scope. В этом нам помогут следующие волшебные фичи языка Rust:


  • Трейт Copy. Если вы пришли из ООП, то структуры с определённым на них Copy трейтом как бы не reference type а value type.
    Играть можно тут, но если кратко — присваивание не забирает переменную, а копирует её.
  • Волшебный struct update syntax. Эта штука внезапно копирует поля из старой структуры в новую.
    ВАЖНО: К сожалению, оно совершенно не копирует параметры, на которых не определён трейт Copy (в нашем случае это Vec<i32>). Он их просто переносит. И это очень важно, потому что можно попасться на всякие вот такие ошибки. НО только не в нащем случае! Ведь нам плевать на старую структуру — что там из неё перенесено нам не важно — мы никогда её не будем использовать. Вот это халява!!

Модель


Модель, она же структура, будет в нашей программе одна — как раз таки виртуальная машина языка COW:


#[derive(Debug, Default, Clone)]
pub struct CowVM {
    pub program: Vec<u32>,
    pub memory: Vec<i32>,
    pub program_position: usize,
    pub memory_position: usize,
    pub register: Option<i32>
}

Добавим к нашей структуре немного вкусняшек — Debug, Default, Clone. Что это и зачем — можно прочитать в моей предыдущей статье.


Редьюсер


Тут в общем то тоже нету ничего сложного. Следуя семантическому дзену, определённому на этапе предварительного проектирования, клепаем на каждый опкод языка свою отдельную команду, принимающую виртуальную машину и создающую новую, уже изменённую, которую в дальнеёшем и возвращающую.


Для примера рассмотрим функию для очень важного опкода mOo — команда смещает назад на один указатель на ячейку памяти:


pub fn do_mOo(state: CowVM) ->CowVM {
    CowVM{
        memory_position: state.memory_position-1,
        program_position: state.program_position+1,
        ..state
    }
}

Вся функция делает только две вещи — смещает указатель на ячейку памяти на 1 назад — и указатель на ячейку команд на 1 вперёд. Заметим, что не все функции смещают указатель на ячейку программ вперёд, поэтому было принято решение не выделять этот функционал в отдельную функцию. Место это занимает немного — пол строчки, и вызов функции занимал бы приблизительно такое же место, так что в общем-то всё равно…


Ну да ладно, перейдём к более интересным вещам. Помните, я говорил что регистр наш не совсем обычный? То-то и оно — лучший (и пожалуй единственный адекватный) способ хранить nullable значение в Rust — тип Option. Способ, кстати, прямиком из пекла функционального программирования. Что это такое углубляться пожалуй не будем, заметим лишь, что такой подход навязывается самим языком во-первых, а во вторых координально отличается от всех этих ваших языков, в которых есть nil, null и иже с ними. Такие языки обычно называются классическими ООП языками: Java, C#, Python, Ruby, Go… Продолжать в общем-то смысла нету. Просто привыкайте к такому положению вещей.


Вернёмся к нашим баранам. Так как регистр может быть пустой, а может быть не пустой, приходиться работать с ним как с Option. А вот и исходный код нашего редьюсера:


pub fn do_MMM(state: CowVM) -> CowVM {
    match state.register {
        Some(value) => {
            let mut memory = state.memory;
            memory[state.memory_position] = value;
            CowVM{
                register: None,
                program_position: state.program_position+1,
                memory: memory,
                ..state
            }
        },
        None => {
            CowVM{
                register: Some(state.memory[state.memory_position]),
                program_position: state.program_position+1,
                ..state
            }
        },
    }
}

Видите эти 4 закрывающие скобочки вконце? Выглядят страшно. Не зря всётаки многие функциональные языки не пользуются скобками. Бррр…


Заметим по дороге не очень элегантный способ поменять значение в памяти нашей виртуальной машины. Ничего лучшего не придумывается, может вы подскажете в комментариях?


Для честности отметим, что в "чисто функциональных" языках нету массивов. Там есть списки или словари. Замена элемента в списке занимает O(N), в словаре — O(logN), а тут по крайней мере O(1). И это радует. Да и память вида:


{"0": 0, "1": 4, .... "255": 0}

меня пробирает до дрожи. Так что пусть будет так, как будет.


Остальные команды делаем по аналогии — можно в исходнике на них посмотреть.


Основной цикл работы программы


Тут всё просто:


  • Читаем му-му-му исходник,
  • Создаём новую виртуальную машину с пустой памятью и заполненным массивом команд,
  • Выполняем последовательно все команды пока работа программы не закончится.

Так как у нас функциональный подход — делать всё нужно без циклов: рекурсивно. Так и поступим.


Определим основную рекурсивную функцию — execute:


fn execute(state: CowVM) -> CowVM {
    new_state = match state.program[state.program_position] {
        0 => commands::do_moo(state),
        1 => commands::do_mOo(state),
        2 => commands::do_moO(state),
        3 => commands::do_mOO(state),
        4 => commands::do_Moo(state),
        5 => commands::do_MOo(state),
        6 => commands::do_MoO(state),
        7 => commands::do_MOO(state),
        8 => commands::do_OOO(state),
        9 => commands::do_MMM(state),
        10 => commands::do_OOM(state),
        11 => commands::do_oom(state),
        _ => state,
    }
  execute(new_state)
}

Логика простая — смотрим новую команду, выполняем её и повторяем сначала. И так до победного конца.


Вот и всё. Интерпретатор языка COW — готов!


Настоящий основной цикл работы программы


Вы спросите меня — "Это что, шутка такая?" Такой же вопрос задал я сам себе, когда оказалось, что в "мультипарадигменном" (ха-ха!) языке Rust нету Tail Call Optimization. (Что это — читать здесь.)


Без этой занимательной вещицы вы очень быстро узнаете на себе, почему сайт stackoverflow назван именно так.


Что же, придётся переделывать в цикл.


Для этого выкинем рекурсию из функции execute:


fn execute(state: CowVM) -> CowVM {
    match state.program[state.program_position] {
        0 => commands::do_moo(state),
        1 => commands::do_mOo(state),
        2 => commands::do_moO(state),
        3 => commands::do_mOO(state),
        4 => commands::do_Moo(state),
        5 => commands::do_MOo(state),
        6 => commands::do_MoO(state),
        7 => commands::do_MOO(state),
        8 => commands::do_OOO(state),
        9 => commands::do_MMM(state),
        10 => commands::do_OOM(state),
        11 => commands::do_oom(state),
        _ => state,
    }
}

А цикл будем запускать прямо в функции main:


fn main() {
    let mut state = init_vm();
    loop {
        if state.program_position == state.program.len() {
            break;
        }
        state = execute(state);
    }
}

Вы чуствуете всю боль мирового функционального программирования? Мало того, что этот язык заставил нас забыть о красоте родных рекурсий, так ещё и заставил сделать мутабельную переменную!!!


А и на самом деле — к сожалению написать так не получится


fn main() {
    let state = init_vm();
    loop {
        if state.program_position == state.program.len() {
            break;
        }
        let state = execute(state);
    }
}

из-за причин, которые скрыты в сумраке… На самом деле из-за того что созданные внутри loop переменные исчезают при выходе из области видимости (на следующей строчке в этом случае).


Чтение му-му-му исходников


А вот в работе с IO в Rust нету ничего функционального. Совсем. Так что эта часть выходит за рамки этой статьи и может быть найдена в исходниках этого интерпретатора.


Вывод


По субьективным ощущениям, язык Rust успел заржаветь не состарившись. И ООП в нём какое-то не ООП, и ФП — не совсем ФП. Зато — "мультипарадигменность". Хотя, может на стыке этих парадигм получится нечто ВАУ! Остаётся на это надеятся — и писть на Rust.


Впрочем, очевидные плюсы в функциональном подходе есть. Написав целую программу удалось:


  • Не вступить ни разу в коридоры ООП и не создать ни одного класса.
  • Ни разу не поиметь проблем с отдалживанием, переназначением, указаним и бог ещё знает что там есть у Rust при работе с переменными. Да мы вообще не делали никаких ссылок, изменяемых переменных (ну почти), я почти забыл что такое borrow и ownership. Скажу честно, писать не думая о том, что у тебя может не скомпилироваться из-за всего этого — сущее наслаждение.
  • Нам удалось ещё и ни разу не вступить в lifetime параметры — вот уж действительно сумеречная сторона всего Rust. Скажу честно — меня пугают (x: &'a mut i32) и я очень рад, что мог избежать всего этого.
  • Мы не реализовали ни одного трейта. Так себе достижение, но внезапно оказывается что в ФП трейты не так уж и нужны/важны.
  • Все эти функции по сути своей — чистые, и их очень легко тестировать (возможно об этом будет следующая статья, хотя отличие тестирования в ООП и ФП давно известны и легко гуглятся и так).

Послесловие


Спасибо всем, кто дочитал. Приглашаю вас в комментарии к статье, там мы сможем обсудить различные парадигмы программирования в Rust. Замечания, предложения — всё туда же.


Ссылка на исходник


Благодарности


Огромное спасибо @minizinger за разработку особо сложных для меня (потому что самых нефункциональных) мест в программе, вдохновение и поддержку.

Поделиться с друзьями
-->

Комментарии (30)


  1. torkve
    22.05.2017 12:34

    В Rust конечно же есть оптимизация хвостовой рекурсии, вот простейший пример (no_mangle исключительно для читаемости):

    0 ? cat a.rs 
    #[no_mangle]
    pub fn test(a: i32) -> i32 {
        if a == 0 {
            42
                
        } else if a == 7 {
            43
        } else {
            test(a - 1)
        }
    }
    0 ? rustc --crate-type dylib --emit llvm-ir -O a.rs
    

    0 ? cat a.ll
    ; ModuleID = 'a.cgu-0.rs'
    source_filename = "a.cgu-0.rs"
    target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
    target triple = "x86_64-unknown-linux-gnu"
    
    ; Function Attrs: nounwind readnone uwtable
    define i32 @test(i32) unnamed_addr #0 {
    start:
      br label %tailrecurse
    
    tailrecurse:                                      ; preds = %bb4, %start
      %.tr = phi i32 [ %0, %start ], [ %1, %bb4 ]
      switch i32 %.tr, label %bb4 [
        i32 0, label %bb7.loopexit
        i32 7, label %bb7.loopexit2
      ]
    
    bb4:                                              ; preds = %tailrecurse
      %1 = add i32 %.tr, -1
      br label %tailrecurse
    
    bb7.loopexit2:                                    ; preds = %tailrecurse
      br label %bb7
    
    bb7.loopexit:                                     ; preds = %tailrecurse
      br label %bb7
    
    bb7:                                              ; preds = %bb7.loopexit, %bb7.loopexit2
      %_0.0 = phi i32 [ 43, %bb7.loopexit2 ], [ 42, %bb7.loopexit ]
      ret i32 %_0.0
    }
    
    attributes #0 = { nounwind readnone uwtable }
    


    Другое дело, что из вашего кода непонятно, то ли вы его неправильно написали, так как первоначальный вариант функции execute содержит точку с запятой в самом конце, что подразумевает, что последняя инструкция не новый execute(...), а просто (), пустая инструкция, такой код вообще по идее не должен скомпилироваться, потому что возвращаемое значение не совпадает с сигнатурой функции. То ли по какой-то иной причине не отработала оптимизация.


    1. Virviil
      22.05.2017 12:58

      Точка с запятой — это опечатка. Уже исправил.


      Но вот этот кода то выдаёт stack overflow: click. Обьясните тогда, что же делать?


      1. unsafePtr
        22.05.2017 13:09

        Так а где увас выход из рекурсии в примере?

        if i == 0 {
                1
            }
        

        А из маин вы подаете count(1), таким образом мы не сможем выйти из рекурсии


        1. Virviil
          22.05.2017 13:40
          +1

          Оптимизатор не может и не должен об этом знать.


          Что к примеру я мог написать так:


          fn count(i: i32) -> i32 {
              if i == 0 {
                  1
              }
              else {
                  println!("{}", i);
                  count(%рандомное число от 0 до 2**32%)
              }
          }

          и получил бы stack overflow, хотя в идеале он должен писать в консоль до тех пор, пока не нарандомит 0.


      1. torkve
        22.05.2017 13:34

        Конкретно у этого примера, как вам правильно заметили, нет никакого условия выхода. Если на него внимательно посмотреть, то получается, что такой код завязан на переполнение i32 (не помню, есть ли в Rust про это какой-то стандарт, а в C это называлось бы UB), т.е. ваш код выйдет, когда мы переполним i32 и дойдём до 0 с отрицательной стороны. При таких условиях компилятор имеет право нарисовать на экране котика вместо заданного кода. Например, если убрать ваш println!, то вся функция при компиляции превратится в одну строчку: ret i32 1.

        Если же говорить о вашем цикле из статьи, то вероятно, что ему тоже просто необходимо корректное условие выхода из цикла, потому что в функции execute его вообще нет. Мне лень лезть в код, но я подозреваю, что у вас предполагается, что в какой-то момент получение следующей инструкции выкинет панику или прямо сделает abort(), но это как раз плохой и нефункциональный подход.


        1. Virviil
          22.05.2017 13:45
          +2

          Я не эксперт, но на самом деле я понимаю TCO вот так:
          Видим, что функция возвращает вызов функции — не добавляем переход на эту функцию в стек вызовов.
          И в этом случае что бы там не падало, или падало, или не важно — переполнения стека быть не может. Что угодно — но не это.
          Возможно, я конечно ошибаюсь.


          1. Akon32
            22.05.2017 14:04
            +1

            Видим, что функция возвращает вызов функции — не добавляем переход на эту функцию в стек вызовов.

            Какой ещё "возврат вызова функции"?


            Правило такое: Последовательность call addr ; ret заменяем на jmp addr.
            (";" здесь — разделитель инструкций)


          1. torkve
            22.05.2017 14:26

            Я тоже ненастоящий сварщик, но в моём понимании у функции должно быть место, в котором она говорит ret и вызывающему коду возвращается то, что лежит в соответствующем регистре. При TCO бесконечного цикла в коде просто не остаётся ret, который можно было бы позвать. Можно пытаться его как-то эмулировать, воткнув никогда не вызываемый ret после джампа, но это нелегально, потому что у нас нет никакого значения заявленного типа (CowVM), которое можно положить в регистр для возврата.

            При этом можно явно заявить функцию, как не возвращающую значение:

            #[no_mangle]
            pub fn test(a: i32) -> ! {
                println!("a");
                test(a + 1)
            }
            


            И тогда компилятор штатно сгенерирует в конце функции:
            ...
              %6 = add i32 %0, 1
              tail call void @test(i32 %6)
              unreachable
            


            И при этом компилятор как раз предупредит о проблеме:
            warning: function cannot return without recurring
             --> a.rs:2:1
              |
            2 | / pub fn test(a: i32) -> ! {
            3 | |     println!("a");
            4 | |     test(a + 1)
            5 | | }
              | |_^
              |
              = note: #[warn(unconditional_recursion)] on by default
            note: recursive call site
             --> a.rs:4:5
              |
            4 |     test(a + 1)
              |     ^^^^^^^^^^^
              = help: a `loop` may express intention better if this is on purpose
            


            1. Virviil
              22.05.2017 14:56

              Но в функции же есть возврат — если аргумент равно нулю!
              Другое дело, что до этого места никогда не доходит, но откуда об этом может знать компилятор?
              Посмотрите пример с рандомом из соседней ветки — там даже есть реальный выход из функции, другое дело что переполнение стека по идее наступит раньше.


              1. torkve
                22.05.2017 15:40

                откуда об этом может знать компилятор?

                Он умный, такие проверки уже сто лет как есть.

                там даже есть реальный выход из функции

                Вероятно, это баг или разработчики ещё не оптимизировали это место в компиляторе. Попробуйте им зарепортить.


        1. DarkEld3r
          22.05.2017 14:20
          +1

          не помню, есть ли в Rust про это какой-то стандарт, а в C это называлось бы UB

          Стандарта у раста (пока) в принципе нет, но сейчас поведение определено следующим образом: в дебаге переполнения паникуют, в релизе поведение определено как "two's complement".


  1. Akon32
    22.05.2017 13:15
    +2

    TCO, внезапно, нет и в Common Lisp (из-за динамических областей видимости, насколько я понимаю).


    1. kez
      22.05.2017 22:42

      TCO не требуется по стандарту, но популярные реализации (тот же SBCL, CCL) имеют TCO.


      Чуть подробнее по ссылке.


  1. potan
    22.05.2017 16:43

    Я пытался поиграться с TCO — похоже, Rust его умеет, если ему не мешает RAII. То есть перед хвостовым вызовом надо сказать явно drop на все переменные, которые этот вызов не забирает. Но не уверен, что это гарантировано.


    1. ozkriff
      22.05.2017 16:45
      +2

      https://github.com/rust-lang/rfcs/pull/1888 — без этого или аналогичного RFC сам язык гарантий не будет давать, все на милость LLVM.


      1. Virviil
        22.05.2017 17:24

        Так в результате, это уже работает, или ещё нет? А то баг висит, РФЦ висит, но никто ничего не мержит и не принимает… Хотя было бы круто


        1. ozkriff
          22.05.2017 19:46

          не работает, это даже еще не принятый RFC


  1. splav_asv
    22.05.2017 18:58

    Видите эти 4 закрывающие скобочки вконце? Выглядят страшно. Не зря всётаки многие функциональные языки не пользуются скобками. Бррр…

    С if let… {} else {} было бы на один уровень вложенности меньше.


    1. ozkriff
      22.05.2017 19:47
      +1

      или можно вместо None => { CowVM{... было бы написать None => CowVM{...


    1. Virviil
      22.05.2017 20:33

      Это не "пофункциональному". В некоторых языках if вообще нету


      1. red75prim
        22.05.2017 21:02

        Полностью функционально без сборки мусора всё-равно не получится. Так почему-бы не использовать то, что есть?


        1. Virviil
          22.05.2017 21:05

          Я не понял, при чём тут сборка мусора.


          Но в любом случае задача была "сделать максимально по функциональному феншую"


          1. ozkriff
            22.05.2017 21:23

            Я не понял, при чём тут сборка мусора.

            Я вот не знаю ни одного чистого функционального языка общего назначения без сборки мусора — как оно работать-то должно?


            1. Virviil
              22.05.2017 21:48

              Ну программу я же как-то написал. Все "нефункциональные вещи" — это заменил рекурсию на луп изза отсутствия ТСО, да ещё в массив писал через переменную.
              Какая в общем то разница, как оно там внутри работает?


              Вся фишка и программы и этой статьи — "пишем максимально по феншую, авось заработает. Надеемся что ниразу не придётся ни мутить переменную, ни писать лайфтаймы, на имплементить трейты"


              1. ozkriff
                22.05.2017 21:58

                Но это же очень простая программа, я бы не экстраполировал сильно.


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


                Неизменяемые структуры данных без GC или аналога, к примеру, очень быстро перестают быть хоть немного практичными.


                И, кстати, типы высшего порядка так и не завезли все еще и не факт что вообще звезут, как раз из-за отсутствия сборки мусора :( .


                1. splav_asv
                  22.05.2017 22:19

                  Если и завезут, то ATC судя по всему.


                1. Virviil
                  22.05.2017 22:57

                  Так то ООП заносит ещё больше головной боли, как по мне.


                  Осталось только понять, как на нём писать. Я слишком молод, знаю только два подхода — либо ФП либо ООП. И ни один в Расте нормально почему-то не работает.


                  1. splav_asv
                    22.05.2017 23:34
                    +1

                    А если не секрет, как основной функциональный язык? Я из функциональных только Хаскель более менее хорошо знаю, и после него и C++ гибридный вариант очень даже нравится. Как раз получается писать на верхнем уровне используя идеи ООП, а внизу пользоваться многими плюсам функционального подхода. Крупные проекты довольно хорошо иллюстрируют этот подход.


                    1. Virviil
                      23.05.2017 00:46

                      F#, Erlang/Elixir, Elm — из тех на чём писал. Вроде все работает пока что.


      1. ozkriff
        22.05.2017 21:14
        +1

        если что, if let это просто тонкий сахар над matchhttps://doc.rust-lang.org/book/if-let.html