Написание простейшего компилятора brainfuck
Введение
В данной статье я хочу описать поэтапное создание компилятора языка Brainfuck на языке Go. И хоть это и далеко от компиляторов "больших" ЯП, многие принципы работы являются такими же, как и у "взрослых" собратьев Brainfuck.
О языке Brainfuck
Brainfuck — это эзотерический (esolang) язык программирования, созданный Урбаном Мюллером в 1993 году. Он состоит всего из 8 команд: + - > < . , [ ]. Программа работает с массивом из 30.000 ячеек памяти и указателем. Несмотря на крайне простой набор команд, на Brainfuck можно (не надо) реализовать почти любые вычисления.
Команды в языке Brainfuck
>— переместить указатель на ячейку вправо<— переместить указатель на ячейку влево+— увеличить значение текущей ячейки на 1-— уменьшить значение текущей ячейки на 1.— вывести значение текущей ячейки как символ,— считать символ во входную ячейку[— начать цикл, если текущая ячейка не равна 0]— вернуться к[или завершить цикл, если значение равно 0
Понятие компилятора
Компилятор — это программа, которая преобразует исходный код, написанный на одном языке программирования, в эквивалентное представление на другом языке, обычно машинный код или байткод, при этом выявляя ошибки исходной программы.
Структура компилятора
Компилятор обычно состоит из следующих компонентов, вызывающихся поочерёдно:
- Лексический анализатор (Lexer) — разбивает исходный код на токены.
- Синтаксический анализатор (Parser) — проверяет структуру программы и строит AST.
- Семантический анализатор — проверяет смысл программы: типы, переменные, области видимости и т. д.
- Оптимизатор — преобразует код, чтобы сделать его быстрее или компактнее.
- Генератор кода — переводит программу в машинный код, байткод или другой целевой язык.
В нашем случае, в компиляторе фактически будет только парсер и генератор кода. Целевым языком для трансляции будет язык Go.
Создание структуры компилятора
Для начала список токенов:
internal/bf/tokens.go
package bf
var TOKENS = []string{"+", "-", ",", ".", "<", ">", "[", "]"}
И далее создадим структуру компилятора и публичный метод Compile:
internal/bf/compiler.go
package bf
type Compiler struct {
Tokens []string
}
func NewCompiler() *Compiler {
return &Compiler{
Tokens: TOKENS,
}
}
func (compiler *Compiler) Compile(code string) (string, error) {
parsed := compiler.parse(code)
translated, err := compiler.translate(parsed)
return translated, err
}
Парсинг
Реализуем метод для парсинга кода, основываясь на списке допустимых токенов. Если текущий символ содержится в списке, добавляем его в итоговый массив токенов.
internal/bf/parse.go
package bf
import "slices"
func (compiler *Compiler) parse(commands string) []string {
parsed := []string{}
for _, command := range commands {
if slices.Contains(compiler.Tokens, string(command)) {
parsed = append(parsed, string(command))
}
}
return parsed
}
Трансляция
Опишем команды Go, в которые будет транслироваться код Brainfuck:
internal/bf/commands.go
package bf
const BEGIN = `// Generated by brainfuck compiler
package main
import (
"bufio"
"os"
)
func main() {
memory := []rune{}
cursor := 0
for i := 0; i < 30000; i++ {
memory = append(memory, 0)
}
`
const END = `
}
func input() rune {
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
return rune([]byte(input)[0])
}
`
const TAB = " "
var COMMANDS = map[string]string{
"+": "memory[cursor]++",
"-": "memory[cursor]--",
",": "memory[cursor] = input()",
".": "print(string(memory[cursor]))",
"<": "cursor--",
">": "cursor++",
"[": "for memory[cursor] != 0 {",
"]": "}",
}
Далее опишем сам метод трансляции и файл с ошибками. В случае с трансляцией в Go важно помнить про корректное сохранение табуляции.
internal/bf/errors.go
package bf
import (
"errors"
)
var (
ErrNotOpenedCycle = errors.New("cycle not opened but close operator used")
)
internal/bf/translate.go
package bf
import "strings"
func (compiler *Compiler) translate(commands []string) (string, error) {
end := BEGIN
tabs := 1
for _, command := range commands {
end = end + "\n" + strings.Repeat(TAB, tabs) + COMMANDS[command]
if command == "[" {
tabs = tabs + 1
}
if command == "]" {
tabs = tabs - 1
}
if tabs == 0 {
return "", ErrNotOpenedCycle
}
}
end = end + END
return end, nil
}
Главный файл
В главном файле реализуем простейший CLI-интерфейс для работы компилятора, а так же функцию для компиляции итогового Go-кода:
cmd/bf/main.go
package main
import (
"alexdenkk/bf/internal/bf"
"fmt"
"log"
"os"
"os/exec"
)
const HELP_MSG = `
brainfuck compiler
Usage:
build <filename> - compile file
Commands:
, | input cell
. | print cell
> | next cell
< | previous cell
+ | plus 1 to cell value
- | minus to cell value
[ | open cycle
] | close cycle
*Cycles must be closed
Basic expressions:
,>,[<+>-]<. | summation
>,[<->-]<. | subtraction
,>,[<[->>+>+<<<]>>>[-<<<+>>>]<<<>-]<[-]>>. | multiplication`
func main() {
compiler := bf.NewCompiler()
if len(os.Args) > 2 {
bfCode, err := readFile(os.Args[2])
if err != nil {
log.Fatal(err)
}
goCode, err := compiler.Compile(bfCode)
if err != nil {
log.Fatal(err)
}
err = writeFile(os.Args[2][:len(os.Args[2])-3]+".go", goCode)
if err != nil {
log.Fatal(err)
}
err = compileGoFile(os.Args[2][:len(os.Args[2])-3])
if err != nil {
log.Fatal(err)
}
} else {
fmt.Println(HELP_MSG)
}
}
func compileGoFile(filename string) error {
cmd := exec.Command("go", "build", filename+".go")
if err := cmd.Run(); err != nil {
return err
}
return nil
}
func readFile(filename string) (string, error) {
b, err := os.ReadFile(filename)
if err != nil {
return "", err
}
return string(b), nil
}
func writeFile(filename string, text string) error {
return os.WriteFile(filename, []byte(text), 0644)
}
Тестирование
Попробуем скомпилировать две программы на Brainfuck:
hello_world.bf
++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-<.+++.------.--------.>>+.>++.
# Hello World!
sum.bf
,>,[<+>-]<. # Сумма двух чисел
Вывод корректен:
Hello World!
4
4
h
Заключение
В рамках данной статьи мы реализовали работающий компилятор языка Brainfuck. В процессе были рассмотрены основные этапы компиляции: анализ исходного кода, обработка команд языка и генерация результирующего кода. Несмотря на простоту Brainfuck, его реализация позволяет наглядно понять основные принципы работы компиляторов и взаимодействия исходного и машинного представления программы. Полученный проект может служить хорошей основой для дальнейшего изучения компиляции и разработки более сложных языков.
«The language had served its purpose. Or so I thought.» — Urban Müller