golang

Funxy два месяца спустя: работа над ошибками, VM и прагматизм

  • четверг, 12 февраля 2026 г. в 00:00:07
https://habr.com/ru/articles/995104/

Два месяца назад я писал на Хабр о первом релизе Funxy — гибридного языка программирования. Тогда это был эксперимент по созданию своего языка с выводом типов, императивного, с функциональными возможностями. Funxy был сырой, интерпретатор мог упасть на валидном коде, производительность хромала, а некоторых привычных вещей просто не было.

С тех пор вышло несколько релизов. Мы исправили много ошибок, переписали рантайм и добавили недостающие инструменты. Хочу рассказать, что изменилось.

TLDR:

  • Стабильность: десятки багфиксов — падения на валидном коде, рекурсия, edge-кейсы VM

  • Рантайм: tree-walk интерпретатор → стековая VM (быстрее, легче по памяти)

  • Язык: const, return, лямбды (\x -> x + 1), list comprehensions, block syntax для DSL

  • Типы: strict mode, flow-sensitive typing

  • Тулинг: LSP и дебаггер

  • Embedding: встраивание Funxy в Go-приложения как скриптовый движок

Стабильность

Самая большая проблема первой версии — нестабильность. Интерпретатор мог упасть на валидном коде, рекурсия ломалась на глубоких вызовах, а некоторые edge-кейсы в VM приводили к непредсказуемому поведению.

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

Изменения под капотом: Stack-based VM

Одним из узких мест первых версий была производительность. Tree-walk интерпретатор (который просто ходит по AST) — это просто и понятно, но медленно.

В версии 0.4.x мы переехали на стековую виртуальную машину (VM).

Что это дало:

  • Скорость: Вычислительные задачи стали выполняться быстрее

  • Память: Примитивы (Int, Float, Bool) теперь живут на стеке (используем Tagged Pointers), что сильно разгрузило Garbage Collector

  • Простор для дальнейших оптимизаций

Конкретные цифры (бенчмарки на Apple M3 Pro):

Тест

Tree-walk

VM

Ускорение

Fibonacci(20), рекурсия

34 ms

4 ms

~8x

Higher-order (foldl/filter/map)

5.2 ms

0.5 ms

~10x

Fibonacci(20), только исполнение*

34 ms/307k allocs

3.7 ms/25 allocs

~9x

* — без учёта парсинг�� и компиляции.

Аллокации — главный выигрыш. В рекурсивных задачах VM делает 25 аллокаций вместо 307 000. Это другой порядок нагрузки на GC.

Язык: ближе к общепринятому

Мы добавили несколько вещей, чтобы писать код было комфортнее.

1. const и return

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

const maxRetries = 5
// Синтаксис maxRetries :- 5 по-прежнему доступен

fun findUser(users, id) {
    for u in users {
        // Ранний выход! Раньше пришлось бы городить вложенные if-ы
        if u.id == id { return Some(u) }
    }
    None
}

2. Лаконичные лямбды

Для map, filter и прочих функциональных радостей синтаксис стал короче:

// Было
map(fun(x) { x + 1 }, list)

// Добавили
map(\x -> x + 1, list)

3. List Comprehensions

Генераторы списков — удобная штука:

// Квадраты четных чисел
squares = [x * x | x <- 1..10, x % 2 == 0]

4. Block Syntax

Для DSL и вложенных структур добавили Block Syntax (похоже на trailing lambdas). Это синтаксический сахар: если последний аргумент функции — список выражений, скобки можно опустить. Это позволяет описывать UI или конфигурацию в декларативном стиле:

// div принимает атрибуты и список дочерних элементов (блок)
fun aboutPage() {
    layout("About", div {
        h2 { text("About") }
        p { text("This example demonstrates:") }
        ul {
            li { text("Clean block syntax for UI components") }
            li { text("Nested component composition") }
            li { text("HTML rendering with kit/ui") }
            li { text("HTTP routing with kit/web") }
        }
        p { a(href: "/") { text("Back to home") } }
    })
}

Типы: строгие, но ненавязчивые

Funxy — статически типизированный язык. Но для обычного прикладного кода аннотации типов почти не нужны. Компилятор выводит их сам:

// Типы нигде не указаны, но компилятор знает всё:
// processOrders : List<{price: Float, qty: Int}> -> Float
fun processOrders(orders) {
    orders
        |> filter(\o -> o.qty > 0)
        |> map(\o -> o.price * o.qty)
        |> foldl(\a, b -> a + b, 0.0)
}

Из нового в системе типов:

  • Strict Mode: Если вам нужна большая строгость, включаете directive "strict_types" — и компилятор перестаёт пропускать потенциально опасные неявные сужения (например, когда значение типа Int | String передаётся как Int без проверки).

    directive "strict_types"
    
    fun plusOne(n: Int) -> Int { n + 1 }
    
    x: Int | String = 10
    // plusOne(x)  // Ошибка в strict mode: нужно явно сузить тип
    
    match x {
        n: Int -> print(plusOne(n))   // OK
        _: String -> Nil
    }
    
  • Flow-Sensitive Typing: Внутри if компилятор понимает, что тип уточнился.

    fun normalize(x: Int | String) -> String {
        if typeOf(x, Int) {
            // В этой ветке x уже Int
            n = x * 10 + 1
            "число: " ++ show(n)
        } else {
            // А здесь x уже String
            "строка: " ++ x
        }
    }
    
    print(normalize(7))      // число: 71
    print(normalize("abc"))  // строка: abc
    
  • Неявное приведение Int к Float: Мелочь, которая экономит нервы. Если функция ожидает Float, можно спокойно передавать Int. Компилятор сам вставит инструкцию конвертации (widening), так что писать 10.0 вместо 10 больше не обязательно.

    fun area(width: Float, height: Float) -> Float { width * height }
    
    // Работает: 10 и 20 (Int) приводятся к Float
    area(10, 20)
    

Инструменты: LSP и дебаггер

Реализовали Language Server (LSP) со следующими опциями:

  • Hover (показать тип под курсором)

  • Go to Definition (переход к определению)

  • Diagnostics (подсветка ошибок на лету)

Появился Debugger. Для пошаговой отладки вместо ручных print можно запустить скрипт с флагом -debug и пройтись по шагам, посмотреть переменные и понять, где именно вы ошиблись.

Встраивание в Go (Embedding)

Одна из ключевых возможностей — встраивание. Язык написан на Go, и его легко интегрировать в ваше Go-приложение.

Зачем? Чтобы вынести бизнес-правила, конфигурацию или сценарии обработки данных в скрипты, которые можно менять без пересборки основного бинарника.

// Go
vm := funxy.New()

// Биндим структуру Go, чтобы она была видна в скрипте
vm.Bind("user", &User{Name: "Alice", Balance: 100})

// Исполняем скрипт
vm.Eval(`
    if user.Balance > 50 {
        user.Name = "Rich " ++ user.Name
    }
`)

// Выполняем функцию Funxy из Go
vm.Call("process_user", "Alice", 50)

Можно вызывать функции Funxy из Go и наоборот. Работает прозрачно.

Где можно применить

  • Скрипты и автоматизация. Один бинарник, ноль зависимостей. В стандартной библиотеке — HTTP, gRPC, JSON, protobuf, SQL, CSV, работа с битами и байтами. Удобно для CLI-утилит, пайплайнов обработки данных и одноразовых скриптов

  • Прототипирование бэкенд-логики. Вывод типов убирает boilerplate, но компилятор всё равно ловит ошибки до запуска. Быстро набросать API-обработчик, проверить идею — и при этом не остаться без типобезопасности

  • Встраивание (Embedding). Использование Funxy как скриптового движка внутри Go-приложений. Бизнес-правила, маршрутизация запросов, конфигурации — всё, что хочется менять без пересборки

Проект жив и развивается. Если вам интересно попробовать что-то новое — заглядывайте.

Ссылки:

Будем рады любым issues, пулл-реквестам и просто фидбеку в комментариях. Что сломалось? Чего не хватает? Пишите, пожалуйста.