Вступление

Есть такой крупный инвестиционный банк Morgan Stanley, в котором иногда зарождаются и потом выходят на свет интересные IT-продукты. Про один из них упомянули в обсуждении на Hacker News: функциональный язык программирования Hobbes, быстрый, со статической типизацией, похож на Haskell, использовался в проде а теперь выложен в open source. Назван в честь английского философа XVII века, которого по-русски принято называть Томас Гоббс, но я произношу название языка "Хоббс", а вы - как хотите. Я заинтересовался и пошёл посмотреть. Документация, с которой я начал знакомство, оказалась весьма скудной, и понять из неё о возможностях языка у меня получилось очень мало. Что ж, если нет документации, то можно скачать и попробовать поиграть, подумал я, но и тут меня ждал облом: на github нет собранных дистибутивов ни под какую операционку. Реализация языка написана на C++ , который я чуть-чуть знаю. Значит соберу сам, решил я, скачал исходники и, будучи закоренелым пользователем Windows, попробовал собрать при помощи инструментария MinGW, а если быть точнее - варианта gcc+ucrt , который входит в msys2. Получив вместо красивого бинаря огромную портянку разноцветных ошибок, я понял, что это знак судьбы. Мой обычный путь знакомства с реализацией языка начинается с документации, далее идёт через простые приложения и, наконец, приходит к внутренностям, чтобы посмотреть, как оно устроено, потому что это самое интересное. Тут же мне явно предстоял путь через жо реверсера: разобраться в реализации языка, чтобы смочь его собрать и запустить, по внутренностям реализации понять сам язык, и, наконец, дополнить документацию. Ни разу ранее не погружавшись в функциональные языки и зная C++ на очень начальном уровне я не был уверен в успехе, но пре��вкушал прекрасную мозголомку и множество новых знаний на разных фронтах. Чтобы не томить: у меня получилось, язык оказался специфический (но приятный).

Сначала я думал сделать две статьи: одну про устройство реализации с кучей фрагментов кода на C++ , а другую про сам язык, но, если честно, я до сих пор не знаю на 100% ни того, ни другого. Поэтому вот одна статья, в которой я расскажу именно то, с чем сталкивался внутри реализации языка и как это связано с самим языком. Из статьи вы не узнаете ничего нового о функциональном программировании, я не буду рассказывать про функторы и монады, Hobbes вообще не революционный язык. Но если вы немного знакомы с функциональным программированием и вас не пугает C++, то будет интересно. Много слов.

Что такое Hobbes

В инвест-банках в технологиях высокочастотной торговли обычно применяется такой подход: на C++ или Rust разрабатывается фреймворк, который реализует взаимодействие с торговыми площадками и выполнение торговых стратегий, в этот фреймворк встраиваются торговые стратегии, написанные трейдерами. Это позволяет трейдеру не погружаться в низкоуровневые детали многопоточности и биржевых протоколов, при этом он часто может менять и деплоить свои торговые стратегии по много раз в день. В инвестиционном банке, в котором работал я, фреймворки (их было примерно 3-5) писались на C++ и Java, а торговые стратегии - на Matlab (который компилировался в C++), на Java или на C++. В двух последних случаях рядом с трейдером сидел разработчик и помогал ему. Был ещё один вариант: конфигурируемая стратегия с параметрами: стратегию писали опытные разработчик��, а трейдеры крутили параметры, которые подгружались по сети, меньше гибкости но проще в настройке. Язык Hobbes появился в Morgan Stanley как язык написания торговых стратегий, из-за чего он немножко неполноценный: он предназначен для написания небольших кусочков кода и для очень тесного взаимодействия с кодом, написанным на C++. С другой стороны FFI между Hobbes и C++ просто прекрасный в обе стороны, ниже мы посмотрим, как это работает.

Hobbes - функциональный язык программирования со статической типизацией. Он поддерживает тайпклассы и в этом близок к Haskell, но в остальном очень далёк от него: в Hobbes не ленивая, я строгая модель вычислений, при вызове функций сначала слева направо вычисляются её аргументы. Больше того, данные в Hobbes не являются иммутабельными, вы, например, можете поменять элемент в массиве. Пуристы могут заявить, что Hobbes вовсе не функциональный, а процедурный язык, я не вижу смысла в споре о терминах. Чёрт, я даже не уверен, есть ли в нём оптимизация хвостовой рекурсии.

Hobbes реализован как JIT-компилятор на базе LLVM: реализация языка предоставляет функцию compile(), в которую можно передать загруженный текст программы, а функция его компилирует, используя LLVM под капотом. Далее вы связываете полученный исполняемый код со своей обёрткой и взаимодействуете. Рантайм умеет очень мало и без обёртки не очень полезен.

Когда я слышу про функциональные языки, то мне интереснее всего послушать про сборку мусора и управление памятью, потому что функциональные языки без динамической памяти не могут, кушают её очень много и управление памятью влияет на реализацию структур данны��. В Hobbes используется arena memory management: выделяется кусок памяти, внутри которого аллоцируются данные, и в какой-то момент это всё удаляется целиком, без анализа ссылочности. Отлично подходит для order management стратегий, когда из фреймворка для обработки внешнего события вызывается Hobbes-функция, отрабатывает и возвращает результат. Нормально подходит для инструмента командной строки. Плохо подходит для некоторых других применений.

Сборка Hobbes и первые проблемы

Hobbes собирается при помощи CMake. Если кто не знает, то CMake - это такой мета-сборщик для кода на C и C++ , который работает поверх нативных сборщиков и пакетных менеджеров. Например, версия CMake, которая поставляется в msys2, использует пакетный менеджер самого msys2 (это pacman) , а в качестве сборщика можно указать make или ninja. И сам CMake, и нужный сборщик вам также придётся установить, используя пакетный менеджер msys2, у меня есть и make и ninja. Для Hobbes нужно совсем немного зависимостей, и главная из них - это LLVM. Самая первая ошибка, которую я получил, была о том, что CMake не мог скачать требуемую версию LLVM из репозиториев msys2. И неудивительно, Hobbes хотел версию 11, а msys2 - это rolling-дистрибутив, в нём есть некоторый набор свежих версий для библиотек, и актуальной для него были версии LLVM от 17 до 20. Ну что ж, пропишем в cmake-файле версию 20, это даже очень хорошо, будем использовать самый новый LLVM. Это помогло, версия скачалась, сгенерировался build.ninja , можно от мета-сборки перейти к собственно сборке.

На этом этапе я получил ту гигантскую портянку ошибок, про которую написал во вступлении. Почитав очень внимательно тексты ошибок и поглядев в те участки кода, где они возникали, я собрал ошибки в три большие группы. Первая группа - это несовместимости между LLVM 20 и более ранними версиями. Вторая группа ошибок возникала потому, что Hobbes использует POSIX API. Так что зря я раскатал губу, никто мне не обещал, что Hobbes работает на Windows. Но я был настроен очень упрямо и решил победить эти две группы ошибок, тем более что про LLVM я не знал вообще ничего, а про Windows весьма мало. Третья группа ошибок - всякое разное непонятное, с ними я разбирался промеж всего остального.

Миграция на LLVM 20

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

Особенность Hobbes состояла в том, что он поддерживал версии LLVM, начиная с 3.3. Между версиями 3.3 и 11 в LLVM также случались несовместимые изменения, поэтому авторы Hobbes создали файлик llvm.h , в котором собрали функции-обёртки, внутри которых несовместимости LLVM API разруливались при помощи набора define-ов. Для версий старше 11 не было вообще ничего, define-ы этот случай игнорировали. Попытка просто изобразить, что версии старше 11 ничем не отличаются, не сработала, но хотя бы вместо ошибок, что что-то не найдено я получил ошибки компиляции непосредственно в местах вызова затронутых API-функций.

Суть в том, что в ранних версиях LLVM использовала концепцию типизированных указателей: тип указателя включал в себя тип указываемого значения. Со временем разработчики LLVM пришли к мнению, что они хотят отказаться от типизированных указателей и перейти на нетипизированные. Начиная с LLVM 14 появилась поддержка нетипизированных указателей, но типизированные всё ещё поддерживались вплоть до версии 16, в версии же LLVM 17 типизированные указатели окончательно убрали .

Мне очень помогли вот эти материалы:

Давайте посмотрим, что это значит для Hobbes. Вы, может, слышали, что LLVM работает с низкоуровневым промежуточным представлением (intermediate representation, IR) кода, и работать можно несколькими способами: сформировать IR-код на специальном текстовом синтаксисе, сформировать IR-двоичный код, или работать с программной моделью LLVM API, и именно последний способ будет у нас.

Hobbes поддерживает такой тип данных, как структуры c именованными полями, они же записи, аналогичные struct в C и record type в Haskell. Вот так компилятор Hobbes обрабатывает выражение, создающее новую структуру: сначала компилируются выражения, создающие значения полей структуры, и помещаются в мапу vs, после этого компилируется выделение непрерывной памяти под структуру, и, наконец значения полей в цикле копируются в нужные смещения в выделенной памяти.

llvm::Value* with(const MkRecord* v, llvm::IRBuilder<>* b) const override {
  MonoTypePtr mrty = requireMonotype(v->type());
  auto*     rty  = is<Record>(mrty);
  RecordValue vs = compileRecordFields(v->fields());

  llvm::Value* p = compileAllocStmt(sizeOf(mrty), alignment(mrty), toLLVM(mrty, true));

  for (const auto &v : vs) {
    llvm::Value* fv  = v.second;
    llvm::Value* fp  = b->CreateStructGEP(p, rty->alignedIndex(v.first));
    MonoTypePtr  fty = rty->member(v.first);

    if (isLargeType(fty)) {
      memCopy(b, fp, 8, fv, 8, sizeOf(fty));
    } else {
      builder()->CreateStore(fv, fp);
    }
  }
  return p;
}

Вызов функции toLLVM() формирует LLVM-тип структуры, который содержит LLVM-типы для всех её полей. Этот тип передаётся последним аргументом в функцию compileAllocStmt(), и это нужно для того, чтобы вернулся типизированный указатель на структуру, он сохраняется в p. Чтобы записать значение в поле структуры, нужно сначала операцией CreateStructGEP() получить указатель на поле в переменную fp, после чего, в зависимости от размера, вызывается или memCopy(), или CreateStore(). Операция CreateStructGEP умная: поскольку она получает типизированный указатель, то она знает всё про типы полей структуры, и может вычислить указатель на нужное поле, которое выбирается по его порядковому номеру. Полученный указатель на поле тоже типизированный. Весьма важным моментом является то, что возвращается из функции типизированный указатель p.

А вот код, который получился после перехода на нетипизированные указатели:

llvm::Value* with(const MkRecord* v, llvm::IRBuilder<>* b) const override {
  MonoTypePtr mrty = requireMonotype(v->type());
  auto*     rty  = is<Record>(mrty);
  RecordValue vs = compileRecordFields(v->fields());

  llvm::Type* llvmRTy = toLLVM(mrty, false);
  llvm::Value* p = compileAllocStmt(sizeOf(mrty), alignment(mrty), llvm::PointerType::getUnqual());
  
  for (const auto &v : vs) {
    llvm::Value* fv  = v.second;
    llvm::Value* fp  = b->CreateStructGEP(llvmRTy, p, rty->alignedIndex(v.first));
    MonoTypePtr  fty = rty->member(v.first);
    if (isLargeType(fty)) {
      memCopy(b, fp, 8, fv, 8, sizeOf(fty));
    } else {
      builder()->CreateStore(fv, fp);
    }
  }
  return p;
}

Теперь LLVM-тип структуры не нужно передавать в compileAllocStmt(), и указатель p будет нетипизированным. Зато теперь тип структуры нужно передавать в CreateStructGEP(), чтобы она могла правильно вычислить адреса полей, это именно то несовместимое изменение API, которое появилось в LLVM. Но, как видим, наш код пришлось менять не сильно.

Раз уж мы познакомились с тем, как реализованы структуры, то давайте посмотрим, как поменялась реализация обратной операции: получение значения поля структуры. Такая операция в функциональных языках математично называется "проекция".

llvm::Value* with(const Proj* v, llvm::IRBuilder<>* b) const override {
  auto* rty = is<Record>(requireMonotype(v->record()->type()));

  llvm::Value* rec  = compile(v->record());
  MonoTypePtr  fty  = rty->member(v->field());
  llvm::Value* rp = b->CreateStructGEP(rec, rty->alignedIndex(v->field()));

  if (auto* op = is<OpaquePtr>(fty)) {
    if (op->storedContiguously()) {
      return b->CreateBitCast(rp, ptrType(byteType()));
    } else {
      return b->CreateLoad(rp, false);
    }
  } else if (isLargeType(fty)) {
    return rp;
  } else {
    return b->CreateLoad(rp, false);
  }
}

Языковое выражение получения значения поля из стуктуры в Hobbes описывается компиляторной структурой Proj, которая в поле record содержит выражение для стуктуры, а в поле field - указание на читаемое поле. Сначала компилируется выражение для структуры, им может быть, например, рассмотренное выше создание структуры, или переменная, в любом случае типом переменной rec будет типизированный LLVM-указатель. Он передаётся в CreateStructGEP(), который вернёт типизированный указатель на поле, из которого читается значение одним из способов, например CreateLoad().

Для нового LLVM код станет выглядеть вот так:

llvm::Value* with(const Proj* v, llvm::IRBuilder<>* b) const override {
  MonoTypePtr mrty = requireMonotype(v->record()->type());  
  auto* rty = is<Record>(mrty);
  
  llvm::Value* rec  = compile(v->record());
  MonoTypePtr  fty  = rty->member(v->field());
  llvm::Type* llvmRTy = toLLVM(mrty, false);
  llvm::Value* rp = b->CreateStructGEP(llvmRTy, rec, rty->alignedIndex(v->field()));

  if (auto* op = is<OpaquePtr>(fty)) {
    if (op->storedContiguously()) {
      return builder()->CreateBitCast(rp, ptrType());
    } else {
      llvm::Type* llvmFTy = toLLVM(fty, false);
      return b->CreateLoad(llvmFTy, rp, false);
    }
  } else if (isLargeType(fty)) {
    return rp;
  } else {
    llvm::Type* llvmFTy = toLLVM(fty, true);
    return b->CreateLoad(llvmFTy, rp, false);
  }
}

Поскольку структуры теперь ��редставлены нетипизированными указателями, мы не можем полагаться на LLVM-тип переменной rec. Но система типов языка позволяет нам узнать Hobbes-тип стуктуры (в переменной rty), который мы спокойно превращаем в LLVM-тип, вызвав toLLVM(). Далее тип LLVM-структуры мы должны передать в CreateStructGEP(), которая вернёт нам нетипизированный указатель на поле. Чтобы прочитать значение, нам нужно в CreateLoad() передать тип поля, что мы и делаем.

Проверочный вопрос на понимание прочитанного: можно ли в Hobbes создать структуру, полями которой будут структуры? Если да, то что будет храниться в области памяти, занимаемой структурой? Ответ: да, можно, в области памяти будут храниться значения указателей на структуры-поля.

Мы рассмотрели компиляцию работы со структурами, работа с массивами и Variant-ами происходит очень похоже. Примерно 90% изменений для новых версий LLVM относились к переходу на нетипизированные указатели. Тот код, который мы видели выше - это непосредственно кодогенератор языка Hobbes, в котором не было никаких привязок к версиям LLVM, но изменения нужно было делать именно там. Поддерживать в этих функциях и старый подход, и новый означало начать нарезать код на полоски при помощи директив препроцессора. Я очень не хотел это делать, и принял смелое решение отказаться от поддержки ранних версий LLVM.

Когда я начинал эту работу примерно в марте 2025 года проект Hobbes выглядел заброшенным. В августе 2025 у меня заработала версия на Windows. Зимой 2026 разработчики активизировались: сначала добавили поддержку LLVM 14-16, в феврале добавили и более поздние. Но они оставили поддержку и старых версий, вставив те самые директивы препроцессора, которых я так не хотел. Я поревьюил их изменения, всё хорошо кроме одного места, но я оставлю свой форк, он мне нравится, кодогенератор гораздо читаемее.

Типы данных в Hobbes

Раз уж мы затронули в предыдущем разделе структурные типы данных языка Hobbes, то давайте отвлечёмся и поговорим о них.

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

Гораздо более известный язык программирования Haskell позволяет объявлять алгебраические типы данных, используя ключевое слово data в подходе "сумма произведений": вы объявляете конструкторы всех вариантов для типа-суммы, и внутри каждого варианта объявляете поля. Вот примеры объявления чистой суммы, чистого произведения и комбинации (нагло своровано из интернета):

data Color = Red | Green | Blue
data Point = Point { x :: Double, y :: Double }
data Shape = Circle Float | Rectangle Float Float

Также в Haskell имеется ключевое слово type, которое присваивает типу другое имя.

Hobbes также поддерживает алгебраические типы данных, но использует для этого особый синтаксис: фигурные скобки для типов-произведений и вертикальные палки для типов-��умм. Упрощённой формы "сумма произведений" нет, из-за чего объявления сложных структур будут выглядеть громоздко. Ключевое слово type используется для того, чтобы присвоить типу имя, но оно не обязательно, вы вполне можете использовать анонимные алгебраические типы. Синтаксис конструирования алгебраических типов также используется для их деконструирования при паттерн-матчинге, глубина деконструирования может быть большой:

type failureInfo = { text:[char], location:int }
type status = | Success, Failure: failureInfo |

classify :: status -> [char]
classify s = match s with
  | |Success| -> "Succeeded"
  | |Failure={text=_,location=1}  | -> "Case 1"
  | |Failure={text=_,location=err}| -> "Case2: " ++ show(err)

Это работает:

> classify(|Success|)
"Succeeded"
> classify(|Failure={text="bad character",location=1}|)
"Case 1"
> classify(|Failure={text="bad character",location=100}|)
"Case2: 100"

Как можно увидеть, синтаксис конструирования значений алгебраического типа повторяет синтаксис описания типа, а type inference знает, какой именно тип мы создаём.

Кортежи (tuples) являются частным случаем типов-произведений, просто вместо имён полей используются их порядковые номера.

Особенности MinGW

MinGW - это компилятор gcc, который собран так, чтобы генерировать исполняемые файлы для Windows для процессорной архитектуры x86/x64 и динамически линковать программы со стандартной C-библиотекой от Microsoft, которая присутствует в каждой Windows. Следующая большая куча ошибок была вызвана особенностями компиляции под Windows. Например, оказалось, что тип long в MinGW имеет размер 4 байта, такой же, как int.

Система типов Hobbes включает базовые типы для 32-битного знакового целого "int" и 64-битного знакового целого "long". В реализации им соответствуют C++ типы "int" и "long", и всё отлично работает на linux gcc , но сломалось на MinGW. Мне пришлось найти и аккуратно заменить long на int64_t в очень многих местах. Это гораздо проще написать, чем сделать, эта работа растянулась очень надолго: сначала мне помогал компилятор, но не все такие места им выявлялись. Когда я победил все-все ошибки компиляции, я ещё долго сражался с runtime-падениями. Если вы посмотрите на код создания структур, который мы разбирали выше, вы увидите там неприметный вызов sizeOf(), который должен рассчитать размер памяти, выделяемый под структуру на основе размеров её полей, и если этот размер будет посчитан неправильно, то это проявится только в рантайме.

Замена long на int64_t заставила меня познакомиться с тем, как в рантайме Hobbes реализованы низкоуровневые операции. Понятие "функциональный" в применении к языку означает, что он умеет делать буквально две вещи: создавать функции и вызывать функции. Понятие "статически типизированный функциональный" означает, что язык умеет конструировать и деконструировать сложные типы данных, которые мы рассмотрели выше, причём под синтаксисом скрываются те же функции-конструкторы и деконструкторы. А как, собственно, реализованы арифметические операции, например сложение? А это тоже функции, привязанные к арифметическому синтаксису. Рантайм Hobbes предоставляет очень простой механизм, превращающий нативную функцию в Hobbes-фунцию, и сам рантайм использует этот же механизм, добавляя базовые операции:

// simplify binding user functions
template <typename R, typename... Args>
void bind(const std::string &fn, R (*pfn)(Args...)) {
  hlock _;
  bindExternFunction(fn, lift<R(Args...)>::type(*this), rcast<void *>(pfn));
}

void initStdFuncDefs(cc& ctx) {
  ctx.bind("malloc",                &memalloc);
  ctx.bind("mallocz",               &memallocz);
  ctx.bind("printMemoryPool",       &printMemoryPool);
  ctx.bind("getMemoryPool",         &getMemoryPool);}

Тут используется шаблонная магия С++ , которая сама определяет типы и количество аргументов у функции и превращает это в Hobbes-типы. Ключевым моментом является lift , специальный шаблонный класс с кучей специализаций, который "поднимает" (lifts) нативный тип в Hobbes-тип.

template <typename T, bool InStruct = false, typename P = void> struct lift;

// lift the basic primitive types

#define HOBBES_LIFT_PRIMITIVE(T, n) \
  template <bool InStruct> \
    struct lift<T, InStruct> { \
      static MonoTypePtr type(typedb&) { \
        return Prim::make(#n); \
      } \
    }

HOBBES_LIFT_PRIMITIVE(void,               unit);
HOBBES_LIFT_PRIMITIVE(bool,               bool);
HOBBES_LIFT_PRIMITIVE(int,                int);
HOBBES_LIFT_PRIMITIVE(unsigned int,       int);
HOBBES_LIFT_PRIMITIVE(long,               long);
HOBBES_LIFT_PRIMITIVE(unsigned long,      long);
HOBBES_LIFT_PRIMITIVE(long long,          long);
HOBBES_LIFT_PRIMITIVE(unsigned long long, long);

Как можно видеть, примитивные типы языка С++ превращаются в примитивные типы языка Hobbes, внутри представленные как экземпляры класса Prim и строковым именем вроде "unit", "int", "long" . Мне пришлось добавить HOBBES_LIFT_PRIMITIVE для типа long long (он же int64_t), чтобы победить жуткие рантайм-ошибки, вылетающие из initStdFuncDefs().

Вообще lift-подсистема языка Hobbes очень мощная, мы ещё к ней не один раз вернёмся.

Поддержка Win32API в дополнение к POSIX

Итак, MinGW - это gcc, которы�� линкует ваше приложение со стандартной C библиотекой от Microsoft, но Hobbes содержит обвязку, которая использует POSIX API. В MinGW доступно Win32API, его и будем использовать. К сожалению, тут нам не обойтись без препроцессора и нарезки кода через ifdef-ы.

Некоторые вещи делаются просто, например:

std::string exeDir() {
#if defined(BUILD_MINGW)
  char buf[PATH_MAX];
  int len = GetModuleFileNameA(NULL, buf, sizeof(buf) - 1);
  return std::string(buf, len);
#else  
  using namespace hobbes;
  return str::rsplit(readLink("/proc/self/exe"), "/").first;
#endif  
}

Другие вещи делаются сложнее. Например, Hobbes предоставляет программам функции для сериализации и десериализации данных в файлы специального формата. Доступ к файлу производится и через последовательный ввод-вывод read()/write(), и через отображение в память при помощи mmap(). Поверх этой системы построено транзакционное структурное хранилище, этакая база данных в файле, совместно используемом несколькими процессами. Я не буду тут описывать всю эту систему, но покажу пример функции, где отлично видно, как я вклинился:

fregion& createFileRegionMap(imagefile* f, file_pageindex_t page, size_t pages) {
  // leave no gaps in page mappings
  if (!f->mappings.empty()) {
    fregion& mr = f->mappings.rbegin()->second
    size_t pend = mr.base_page + mr.pages;
    if (pend < page) {
      pages += (page - pend);
      page   = pend;
    }
  }

  // adjust our map page count to match the set increment
  pages = align<size_t>(pages, f->mmapPageMultiple);

#if defined(__MINGW64__)
  const HANDLE fh = (HANDLE)_get_osfhandle(f->fd);
  const HANDLE mh = CreateFileMapping(fh, NULL, (f->readonly ? PAGE_READONLY : PAGE_READWRITE), 0, 0, NULL);
  if (mh == 0) {
    // error
  }
  const off_t foff = page * f->page_size;
  const DWORD fileOffsetLow = (sizeof(off_t) <= sizeof(DWORD)) ? (DWORD)foff : (DWORD)(foff & 0xFFFFFFFFL);
  const DWORD fileOffsetHigh = (sizeof(off_t) <= sizeof(DWORD)) ? (DWORD)0 : (DWORD)((foff >> 32) & 0xFFFFFFFFL);
  char* d = reinterpret_cast<char*>(MapViewOfFile( mh, FILE_MAP_READ | (f->readonly ? 0 : FILE_MAP_WRITE), fileOffsetLow, fileOffsetHigh, pages * f->page_size));

#else

  // map the specified file region into memory
  char* d = reinterpret_cast<char*>(mmap(nullptr, pages * f->page_size, PROT_READ | (f->readonly ? 0 : PROT_WRITE), MAP_SHARED, f->fd, page * f->page_size));
  if (d == MAP_FAILED) {
    raiseSysError
    (
      "Failed to map " + hobbes::string::from(pages) +
      " pages from page " + hobbes::string::from(page) +
      " out of " + hobbes::string::from(f->file_size) +
      " bytes with a page size of " + hobbes::string::from(f->page_size) +
      " bytes",
      f->path
    );
  }

#endif

  fregion& r = f->mappings[page];
  r.base_page = page;
  r.pages     = pages;
  r.base      = d;
  r.used      = 0;
  return r;
}

void releaseFileRegionMap(imagefile* f, const fregion& fr) {
#if defined(__MINGW64__)
  if(UnmapViewOfFile(fr.base) == 0) {
    raiseSysError("Failed to unmap page " + hobbes::string::from(fr.base_page) + " from file", f->path);
  }
#else
  if (munmap(fr.base, fr.pages * f->page_size) != 0) {
    raiseSysError("Failed to unmap page " + hobbes::string::from(fr.base_page) + " from file", f->path);
  }
#endif
}

Полоски получились крупные и код остаётся читабельным. Иногда приходилось писать полные замены для некоторых фукнций, например аналог posix_fallocate() на Win32API занял несколько десятков строк кода.

Интерлюдия

Доведя проект до состояния, когда он компилируется, можно запустить интерактивный интерпретатор, который сможет загрузить небольшую стандартную библиотеку и исполнять команды, я проверил на Linux-виртуалке, что я ничего не сломал в Linux-варианте. А потом решил попробовать следующий шаг: скомпилировать его с помощью Microsoft Visual C++. Как будто мало я наупражнялся.

Сборка Hobbes с MSVC

Компиляторная экосистема Microsoft имеет поддержку в CMake, но это должен быть другой CMake, тот, который идёт с Visual Studio. Его отличие в том, что в качестве пакетного менеджера он использует vcpkg, который, в отличие от msys2, содержит пакеты с исходным кодом. То есть вместо готового бинарного пакета скачиваются исходники и компилируются уже на вашей машине. Для меня это означало скачивание целого LLVM и его сборку. Ух, как же долго это происходило! Я потом поглядел и понял, почему так: во-первых, clang является частью LLVM и собирается с ним, а во-вторых, там очень много автотестов.

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

MSVC и исчезнувший int128

Помните, в MinGW оказалось, что long имеет размер 32 бита, а Hobbes имеет тип "long" с размером в 64 бита, и я в туче мест менял long на int64_t. Но авторам Hobbes было мало 64 бит для целого, они захотели ещё и 128 бит. В gcc есть нестандартный __int128 , он работает и на Windows. А вот в MSVC нет такого типа.

Я сначала думал сделать условную поддержку для Int128 , но потом подумал, что как-то это странно, что тип языка где-то не поддерживается. Если нативного типа нет, то его можно эмулировать структурой из пары 64-битных значений.

#if defined(_MSC_VER)
class alignas(16) int128_t {
  public:
    int64_t low, high;
    int128_t() = default;
    int128_t(int i) { low = i; high = 0;}
    bool operator==(const int128_t& rhs) const { return ( low == rhs.low) && (high == rhs.high); }
    bool operator<(const int128_t& rhs) const { return (high < rhs.high) || ((high == rhs.high) && (low < rhs.low)); }
};
#else
using int128_t = __int128;
#endif

Больше всего я опасался за кодогенерацию, но оказалось что зря: LLVM поддерживает 128-битные значения, и он отлично их конструирует из составных частей. Кстати, в gcc значения нативного типа тоже приходилось разбирать на половинки, чтобы передать в LLVM:


#if defined(_MSC_VER)
#define INT128_TO_UINT64_ARRAY_REF_NAMED_a_a_a(x)                              \
  const uint64_t aa_aa_aa[] = {                                                \
      static_cast<uint64_t>(x.low),                                            \
      static_cast<uint64_t>(x.high) };                                         \
  const auto a_a_a = llvm::ArrayRef<uint64_t> { aa_aa_aa }  
#else
#define INT128_TO_UINT64_ARRAY_REF_NAMED_a_a_a(x)                              \
  const uint64_t aa_aa_aa[] = {                                                \
      static_cast<uint64_t>(static_cast<unsigned __int128>(x) &                \
                            0xFFFFFFFFFFFFFFFF),                               \
      static_cast<uint64_t>(static_cast<unsigned __int128>(x) >> 64U)};        \
  const auto a_a_a = llvm::ArrayRef<uint64_t> { aa_aa_aa }
#endif

llvm::ConstantInt* civalue(int128_t x) {
  return withContext([x](llvm::LLVMContext& c) {
    INT128_TO_UINT64_ARRAY_REF_NAMED_a_a_a(x);
    return llvm::ConstantInt::get(c, llvm::APInt(128, a_a_a));
  });
}

Вторая супер-подстава с этим типом ожидала меня сильно позже, когда я уже победил остальные ошибки компиляции и пробовал запускаться. На старте я получал такую ошибку:

Fatal error: Internal error, computed size for variant '(() + int128)' (16+16(+0)) inconsistent with C++ memory layout (size=24)

В разделе про особенности MinGW я начал рассказывать, что некоторые функции, доступные программам на Hobbes, написаны на на самом Hobbes, а на C++, и импортированы при помощи механизма подъёма типов С++ в типы Hobbes. В initStdFuncDefs() производится импортирование таких функций из стандартной библиотеки Hobbes. Ошибка возникала при подъёме типа, который превратился в тип-сумму, он же Variant.

template <typename T>
  struct liftVariant { };

template <typename ... CTys>
  struct liftVariant< variant<CTys...> > {
    static MonoTypePtr type(typedb& tenv) {
      Variant::Members ms;
      liftVarCtors<CTys...>::constructors(tenv, &ms);
      MonoTypePtr result = Variant::make(ms);
      const Variant* vty = is<Variant>(result);
      size_t vsz = vty->size();
      size_t csz = sizeof(variant<CTys...>);
      if (vsz != csz) {
        size_t offset = vty->payloadOffset();
        size_t psz    = vty->payloadSize();
        throw std::runtime_error(
          "Internal error, computed size for variant '" + show(result) + "' (" +
          str::from(offset) + "+" + str::from(psz) + "(+" + str::from(vsz - (offset + psz)) +
          ")) inconsistent with C++ memory layout (size=" + str::from(csz) + ")"
        );
      }
      return result;
    }
  };

Итак, С++ класс variant поднимается в Hobbes-тип Variant, и поднимающий код проверяет, что размеры этих типов совпадают, чтобы при вызовах нативного кода не делать перекладывание, а просто reinterpret_cast указателя. В нашем случае размеры не совпадают.

Поставив точку останова прямо перед выбросом исключения, я получил в отладчике вот такие значения:

offset = 16
psz = 16
vsz = 32
csz = 24

Я понятия не имел, как такое траблшутить. Немного помогало то, что у меня был рабочий вариант Hobbes, собранный MinGW. Я запустил его под отладчиком, убедился, что vsc=32, csz = 32 . Прекрасный GDB содержит удобную операцию sizeof(), которую мы можем применить сначала для всей структуры, а потом для её полей:

sizeof(hobbes::variant<hobbes::unit, __int128>)
32
sizeof(hobbes::variant<hobbes::unit, __int128>::tag)
4
sizeof(__int128)
16

Запомним, что структура имеет размер 32 байта, но состоит из полей, одно из которых 4, а другое - 16 байт. Сравним это с тем, что нам покажет отладчик Microsoft:

sizeof(hobbes::variant<hobbes::unit,int128_t>)
24
sizeof(int128_t)
16
sizeof(hobbes::variant<hobbes::unit,int128_t>::storage)
16
sizeof(hobbes::variant<hobbes::unit,int128_t>::tag)
4

Чудесно, размеры полей полностью совпадают, те же 4 и 16 байт, но размер структуры отличается, он 24 байта. Но постойте, почему у обоих компиляторов размер структуры больше, чем размеры полей, из которых она состоит? Причина этому - выравнивание. Поможет ли нам отладчик это понять? Да, в GDB есть операция alignof()

alignof(hobbes::variant<hobbes::unit, __int128>)
16
alignof(__int128)
16

Ага, второе поле структуры выровнено по 16 байт, то самое, которое занимает 16 байт, так мы и получаем 32 байта. Вдохновившись, я погуглил информацию про отладчик Microsoft, и оказалось, что он тоже так умеет:

__alignof(hobbes::variant<hobbes::unit,int128_t>)
1
__alignof(hobbes::variant<hobbes::unit,int128_t>::tag)
4
__alignof(hobbes::variant<hobbes::unit,int128_t>::storage)
1
__alignof(hobbes::variant<hobbes::unit,int128_t>::maxAlignedT)
1
__alignof(int128_t)
1

Ну теперь вы поняли, да? В gcc тип __int128 атомарный, поэтому компилятор выравнивает его по его размеру, то есть 16 байт. А в MSVC мы заменили его структурой с парой 64-битных полей, и компилятор считает, что эту структуру можно выравнивать по 1 байту.

Язык ��++ настолько суров, что в нём есть средства для управления выравниванием. Добавив в объявление нашего int128_t спецификатор alignas(16) мы получим то, что нужно.

Кстати, а что это за C++ класс такой, hobbes::variant ? В C++ не нашлось подходящего типа, который бы поднимался в тип-сумму Hobbes, поэтому разработчики написали такой C++ класс и сделали для него поддержку в поднимателе типов.

MSVS и macro varargs

Одна из ошибок компиляции происходила изнутри гигантского макроса DEFINE_STRUCT, понять который было выше моих способностей. Исправлять ошибки в макросах вообще сложно, поверьте мне, я ни одной ещё не исправил. Но путь примерно понятен: нужно заставить препроцессор выдать результат своей работы в файл и посмотреть глазами, чего там сгенерировалось.

Как мы помним, CMake - это мета-сборщик, а собственно сборщиком у нас является MSBuild, а файлы сборки для него имеют расширение .vcxproj , это xml, структура которого задокументирована. Где-то там есть возможность выставлять флаги препроцессора, компилятора и линковщика, нам нужен флаг /P , он выставляется тегом <PreprocessToFile>true</PreprocessToFile> . Внедрив эти теги и запустив сборку, я получил кучу файлов с расширением .i , нашёл нужный и стало понятнее.

Подобно функциям макросы могут принимать параметры, и подобно же функциям у макросов есть возможность принимать переменное количество параметров. В этом случае в списке параметров ставится многоточие, а этот хвост из неименованных параметров доступен через символ __VA_ARGS__ , причём сделать с этой штукой можно мало что, зато её можно передать в другой макрос, где эта куча неопределённого количества параметров аккуратно разложится. Это не очень читабельно, поэтому в компиляторе gcc придумали нестандартное расширение: вместо многоточия написать какое-нибудь имя с многоточием, и вместо уродского __VA_ARGS__ использовать это самое имя. MSVC не поддерживает это расширение. Ничего не поделаешь, я заменил везде именованные списки параметров на __VA_ARGS__. Выглядит гораздо более уродски, всё же именованные параметры переменной длины - это удобное расширение в gcc. Зато ошибки починились.

Но всё-таки, что это за страшный макрос, на котором всё ломалось? Я попробовал скормить его нескольким нейронкам с просьбой объяснить, что это и как работает. Нейронки достаточно неплохо справились, что давало высокую вероятность того, что эти макросы не оригинальная придумка автора Hobbes, а какая-то известная техника, по которой есть публикации, нейронкам же надо было на чём-то научиться. Погуглив по терминам, которые употреблялись нейронками, я нарыл ссылки, их чуть позже напишу, сначала расскажу, что это за штука.

Я упоминал, что у Hobbes отличная интеграция с C++ в обе стороны. С одним из аспектов этой интеграции мы уже сталкивались: с возможностью поднять C++ тип данных в Hobbes-тип. Есть и обратный вариант, когда у вас уже есть Hobbes-тип, а нужно сформировать аналогичный C++ тип. Эту технологию назвали reflection, и в Hobbes это реализовано макросами, например DEFINE_STRUCT, который применяется вот, например, так:

DEFINE_STRUCT(SenderState,
  (hobbes::datetimeT,           datetime),
  (size_t,                      sessionHash),
  (hobbes::storage::ProcThread, id),
  (SenderStatus,                status)
);

Чтобы обработать такой список параметров, макрос DEFINE_STRUCT должен фактически исполниться как программа, которая в цикле пройдётся и обработает пары параметров. Но макросы же не содержат логики! Да, но выход нашёлся. Особенности препроцессора позволили сделать на макросах функциональный язык программирования.

Точкой входа является макрос PRIV_HPPF_MAP , который принимает аргумент f и какой-то набор остальных аргументов, и генерирует текстовую последовательность, в которой f будет предшествовать каждому из аргументов переменного списка.

#define PRIV_HPPF_MAP(f, ...) PRIV_HPPF_EVAL(PRIV_HPPF_MAPP(f, __VA_ARGS__))
#define PRIV_HPPF_MAPP(f, H, ...)        \
  f H                                 \
  PRIV_HPPF_IF_ELSE(PRIV_HPPF_HAS_PARGS(__VA_ARGS__))(  \
    PRIV_HPPF_DEFER2(PRIV_HPPF_SMAPP)()(f, __VA_ARGS__) \
  )(                                  \
  )
#define PRIV_HPPF_EMPTY()
#define PRIV_HPPF_SMAPP() PRIV_HPPF_MAPP
#define PRIV_HPPF_DEFER2(m) m PRIV_HPPF_EMPTY PRIV_HPPF_EMPTY()()

#define PRIV_HPPF_EVAL(...) PRIV_HPPF_EVAL256(__VA_ARGS__)
#define PRIV_HPPF_EVAL256(...) PRIV_HPPF_EVAL128(PRIV_HPPF_EVAL128(__VA_ARGS__))
#define PRIV_HPPF_EVAL128(...) PRIV_HPPF_EVAL64(PRIV_HPPF_EVAL64(__VA_ARGS__))

#define PRIV_HPPF_EVAL4(...) PRIV_HPPF_EVAL2(PRIV_HPPF_EVAL2(__VA_ARGS__))
#define PRIV_HPPF_EVAL2(...) PRIV_HPPF_EVAL1(PRIV_HPPF_EVAL1(__VA_ARGS__))
#define PRIV_HPPF_EVAL1(...) __VA_ARGS__

Как известно, в функциональных языках циклы реализуются через рекурсию. В нашем случае рекурсивным является макрос PRIV_HPPF_MAPP , в котором хорошо видно выделение первого аргумента из списка, действие по склеиванию и условную операцию проверки выхода из рекурсии. Поскольку модель вычислений подстановки в макросах не является ленивой, то просто так сделать условное выполнение в PRIV_HPPF_IF_ELSE не выйдет, обе ветки, являясь аргументами, будут исполнены, и препроцессор уйдёт в бесконечную рекурсию. Поэтому нужен трюк с PRIV_HPPF_DEFER2 , который вернёт макрос, который не является изначально валидным, для его раскрытия понадобятся дополнительные проходы раскрытия. Эти проходы обеспечиваются PRIV_HPPF_EVAL , на каждом шаге которого происходит раскрытие.

Таким вот странным способом наличие рекурсии сделало из препроцессорных макросов полноценный язык программирования. Я думаю, что автор Hobbes, будучи хорошо знаком с функциональным программированием и с C++ знал о такой возможности, и нашёл ей хорошее применение. Откуда же он о ней знал? Вот такие ссылки нагуглились:

Так что если вы программист на C или C++, то изучение функционального программирования пригодится вам, чтобы фигачить такие макросы и впечатлять коллег. Если вы знаток функционального программирования, то можете гордиться знакомством с такой мощной техникой, как рекурсия. А если вы и то, и другое, как автор Hobbes, то напишите статью, как вы траблшутите такие навёрнутые макросы?

Помимо DEFINE_STRUCT доступны макросы DEFINE_ENUM , DEFINE_VARIANT_WITH_LABELS .

MSVC и тайна манглинга

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

stdin:116,9-15: Cannot unify types: <char * __ptr64> != <char>
113
114
115 instance Array <char> char where
116   size = cstrlen
117   element = cstrelem
118   elementM x i = getElementByIndex(x,cstrelem, i, cstrlen(x))
119   elements xs i e = defElementsWith(xs, cstrlen(xs), cstrelem, i, e)
120

Фраза "Cannot unify types" означает следующее: в Hobbes используется система типов Hindley-Milner с тайпклассами, в этой системе имеется механизм автоматического выведения типов, в котором есть стадия унификации (unification), на ней и произошла ошибка. Короче говоря, проверка типов выдала ошибку типизации в коде.

Тайпклассы в Haskell и Hobbes - это что-то типа интерфейсов в Java: тайпкласс объявляет набор методов, у которых указывается имя и абстрактный тип. Каждый экземпляр (instance) тайпкласса конкретизирует тип и тело метода. В нашем случае тайпклассом является Array , мы описываем экземпляр тайпкласса для типа <char> и указываем, что методом element будет функция cstrlen. Но что-то идёт не так, тип аргумента функции не совпадает с ожидаемым <char>.

Функция cstrlen очень простая, даже смотреть не на что:

size_t cstrlen(char* x) {
  return strlen(x);
}

Импортируется эта функция из initStdFuncDefs() при помощи ctx.bind() , код которого я уже приводил выше. И это означает, что Hobbes-тип функции формируется механизмом подъёма типов. Что с ним не так на этот раз?

Подъём типов в Hobbes сделан так, что если для нативного типа нет специальной обработки, то он превращается в Hobbes-тип OpaquePtr , то есть непрозрачный указатель. Со значением такого типа можно сделать только одно: передать его в функцию, которая принимает аргументы этого типа (получил значение из фреймворка - передал дальше во фреймворк). Чтобы сделать непрозрачные указатели типизированными, они хранят внутри тип указываемого значения в виде строки.

// the default lift just refers to opaque C++ data (either as pointers or inline data)

template <typename T, bool InStruct> struct opaquePtrLift { };

template <typename T> struct opaquePtrLift<T,true>  { static MonoTypePtr type() { return OpaquePtr::make(str::demangle<T>(), sizeof(T), true); } };

template <typename T> struct opaquePtrLift<T,false> { static MonoTypePtr type() { return OpaquePtr::make(str::demangle<T>(), 0, false); } };

template <typename T, bool InStruct> struct defaultLift { static MonoTypePtr type(typedb&) { return opaquePtrLift<T,InStruct>::type(); } };

template <typename T, bool InStruct> struct defaultLift<const T*, InStruct> : public lift<T*, InStruct> { };

template <typename T, bool InStruct>
  struct defaultLift<T*, InStruct> {
    static MonoTypePtr type(typedb& tenv) {
      return tenv.opaquePtrMonoType(typeid(T*), 0, false);
    }
  };

template <typename T, bool InStruct, typename P>
  struct lift {
    static MonoTypePtr type(typedb& tenv) {
      return defaultLift<T, InStruct>::type(tenv);
    }
  };
  


MonoTypePtr cc::opaquePtrMonoType(const std::type_info& ti, unsigned int sz, bool inStruct) {
  hlock _;
  // strip the pointer char from the name, we assume opaqueptr types are always pointers
  std::string tn = str::demangle(ti.name());
  while (!tn.empty() && tn.back()=='*') {
    tn=tn.substr(0,tn.size()-1);
  }
  return MonoTypePtr(OpaquePtr::make(tn, sz, inStruct));
}

template <typename T>
  std::string demangle() {
    return demangle(typeid(T));
  }

std::string demangle(const std::type_info& ti) {
  const char* tn = ti.name();
  
  int   s   = 0;
  char* dmn = abi::__cxa_demangle(tn, nullptr, nullptr, &s);
  if (dmn == nullptr) {
    return std::string(tn);
  } else {
    std::string r(dmn);
    free(dmn);
    return r;
  }

}

Из кода понятно, что для превращения нативного типа в строку используется механизм С++ под названием std::type_info , значения которого можно получить для любого нативного типа операцией typeid(). Подстава в том, что текстовое описание типа, возвращаемое методом name() , не стандартизовано. Для gcc там будет нечитаемая каша, которую abi::__cxa_demangle превратит в читаемое имя.

Hobbes очень сильно зависит от того, как gcc формирует читаемые имена типов в abi::__cxa_demangle . В коде на Hobbes вы можете создать непрозрачный указатель, указав имя указываемого типа в угловых скобках, мы только что видели это при инстанциировании тайпкласса: <char> соответствует OpaquePtr::make("char") .

У MSVC строка, которую вернёт std::type_info::name() , совсем другая, и это его право. Кстати, этой строке не нужен demangle, она уже читаемая, но содержит некоторые лишние подробности.

Моим первым порывом было поменять OpaquePtr, чтобы тип указываемого значения был не строковым, а каким-то другим. Для type_info есть type_index, значения которого можно сравнивать, но он не сериализуемый и вообще про его стабильность гарантий ещё меньше. Я долго думал над этим вариантом и остался на строках, очень важно иметь возможность в Hobbes-коде использовать эти непрозрачные указатели. Тот вариант, который мне оставался, состоял в том, чтобы заставить MSVC описывать типы точно так же, как это делает gcc.

Итак, мне было нужно, чтобы char * ptr64 превратилось в char. Как несложно догадаться, MSVC дописывает ptr64 только указателям. Значит, ещё до вызова typeid() нужно избавиться от указательности. Поэтому я сделал вот такой финт ушами:

template <typename T, bool InStruct>
  struct defaultLift<T*, InStruct> {
    static MonoTypePtr type(typedb& tenv) {
      return tenv.opaquePtrMonoType(typeid(T), 0, false);
    }
  };

Отличие всего в одном символе: в старом коде было typeid(T*) , я поменял на typeid(T), всё равно opaquePtrMonoType() отрезает эту звёздочку. И всё, проблема побеждена!

Да, моя малая проблема побеждена, но мне нужно полноценное решение, работающее для всех типов данных. Поэкспериментировав, я заметил, что MSVC для составных типов начинает описание с "struct" или "class". Ну что ж, обрежем:

template <typename T>
  std::string demangle() {
    return demangle(typeid(T));
  }

// these prefixes are returned by MSVC std::type_info::name()
const seq msvcTypePrefixes = {"struct ", "class ", "enum ", "union "};

std::string demangle(const std::type_info& ti) {
  const char* tn = ti.name();
  
  int   s   = 0;
#if defined(BUILD_MSVC)
  std::string r(tn);
  return removePrefixes(r, msvcTypePrefixes);
#else  
  char* dmn = abi::__cxa_demangle(tn, nullptr, nullptr, &s);
  if (dmn == nullptr) {
    return std::string(tn);
  } else {
    std::string r(dmn);
    free(dmn);
    return r;
  }
#endif  
}

Что в итоге? Типизированные непрозрачные указатели - это мощный инструмент языка, главная цель которого - interoperability с C++. Автор изящно выкрутился, используя готовый std::type_info::name(), но это поведение не стандартизовано, а значит Hobbes стал зависеть от особенностей gcc. Я боялся, что для MSVC придётся наворотить кода, но обошлось.

Заключение

Итак, теперь у меня есть работающий вариант для Windows, с которым я могу экспериментировать. Я думаю, что я знаю примерно 50% возможностей языка и 30% реализации. Скучно просто читать исходники файл за файлом, гораздо лучше, когда есть конкретный вопрос, ответ на который нужно найти. Пока у меня не компилировалось и не запускалось, у меня были эти конкретные вопросы, теперь нужны другие. Пойду посмотрю, что там с хвостовой рекурсией. Это всё ещё для меня загадка. Чтобы проверить, я запускаю вот такой код:

testtail :: Int -> Int
testtail x = if (x < 100000000) then testtail(x+1) else 128

>testtail(1)
128

и он отрабатывает мгновенно. Я уверен, что это LLVM как-то агрессивно оптимизирует.

В завершение забавный факт. Как вы, может, слышали, в настоящих хардкорных статически типизированных функциональных языках не бывает функций с несколькими аргументами, всегда с одним. А если очень надо? Haskell выкручивается из этого трюком под названием "каррирование" (currying): функция принимает первый аргумент и возвращает функцию, которая принимает второй аргумент и уже возвращает значение. Это позволяет делать частичное применение функции: передали первый аргумент, получили функцию, а второй аргумент передадим попозже. Это очень хорошо сочетается с ленивой природой Haskell.

Hobbes выбрал другой подход: аргументы объединяются в кортеж. Вот примеры таких типов:

> :t cstrelem
(<char> * long) -> char
> :t element
Array a b => (a * long) -> (() + b)
> element("mystring",3L)
't'

Кстати, поэтому вызов функции в Hobbes выглядит как имя и параметры в скобках через запятую, эти скобки полностью повторяют синтаксис создания кортежа. Никаких накладных расходов такая типизация не создаёт, на уровне LLVM это выглядит как вызов функции с двумя аргументами.

Спасибо за внимание!