Nix loves C++


В последнее время много разговоров идет о том, что для C++ нужен свой пакетный менеджер подобный pip, npm, maven, cargo и т.д. Все конкуренты имеют простой и стандартизированный механизм подключения нестандартной библиотеки. В C++ же все действуют как умеют: кто-то прописывает в README список пакетов для Ubuntu, CentOS и других дистрибутивов, кто-то использует git submodule и скрипты для их сборки, кто-то использует CMake ExternalProject, кто-то копирует все исходники в один гигантский репозиторий, кто-то делает образ Docker или Vagrant.


Чтобы решить проблему был даже создан стартап — biicode, но он обанкротился и его будущее неизвестно. Взамен появился conan, дополняя зоопарк конкурентов — nuget, cget, hunter, cpm, qpm, cppget, pacm и даже gradle for c++.


Меня не устраивал ни один из перечисленных способов. Я было начал писать пакеты для Conan, но столкнулся с большим числом хаков, неразвитым API, отсутвием гайдлайнов и, как следствие, низкой вероятностью переиспользования чужих пакетов. И тут вспомнилось, что когда-то мне очень понравились идеи пакетного менеджера в NixOS. И подумал — а зачем плодить пакетный менеджер специально для C++, если те же задачи решает обычный пакетный менеджер? Нужно только чтобы он был достаточно гибким и простым в части описания пакета. И Nix идеально подошел на эту роль.


Итак, что дал нам Nix:


  • Возможность получить готовое к сборке проекта окружение одной командой — nix-shell;
  • 7344 готовых и поддерживаемых пакетов из nixpkgs;
  • Возможность создать производный пакет от пакета из репозитория (не копируя его код);
  • Возможность указывать в зависимостях не только C/C++ библиотеки, но также необходимые инструменты (CMake, GCC), проекты из других экосистем (npm, pip), сервисы (redis);
  • Возможность привязать окружение к коммиту. Это значит, что, например, ветка master может использовать boost 1.55, а devel — 1.60. При переходе от ветки к ветке Nix автоматически настроит окружение под нужную версию, причем это займет менее секунды (если сборка уже есть в кеше);
  • Неинтрузивность — проект не зависит от Nix, его использование — личное дело каждого. Можно собрать все зависимости вручную (или вашим любимым пакетным менеджером), указав все правильные опции для cmake.

Что такое Nix


Nix — это функциональный язык программирования, заточенный под нужды пакетного менеджера (неудивительно, что он получил популярность в сообществе Haskell). Сборка пакета — это вычисление функции в Nix. И как положено функциональному языку программирования — повторные вызовы функции с теми же аргументами порождают одинаковый результат (бинарный пакет). А это значит, что пакеты можно кешировать, что Nix и делает — все сборки хранятся в /nix/store/$HASH-$PKGNAME. Кроме того, можно проверить есть ли у кого-то другого в сети пакет с таким же хэшом, и если есть — скачать бинарный пакет у него.


Таким образом, "пакет" (здесь он называется derivation) в Nix — это функция, а "зависимости" — это аргументы этой функции. Что же такое репозиторий (NixPkgs)? Это тоже функция, у которой нет аргументов, которая возвращает множество пакетов. Получается ли, что для использования репозитория нужно собрать все 7344 пакета? Нет! Nix — ленивый язык, а это значит ничего не будет вычисляться, пока оно явно не потребуется. А "потребовать" пакет можно утилитами.


Минимальное окружение


Итак, прежде чем использовать Nix его нужно установить. Для этого можно либо использовать целый дистрибутив Linux (NixOS), либо установить пакетный менеджер отдельно для вашей любимой ОС (поддерживается Linux и MacOS). Все воздействия Nix будут ограничены каталогом /nix и файлами в домашнем каталоге (~/.nix-channel, .nix-defexpr, .nix-profile).


В ~/.nix-profile хранятся симлинки на пакеты, которые запросил пользователь. Нам же нужно настроить окружение не для пользователя, а для проекта. Для этого используем утилиту nix-shell: она выполняет данное на вход выражение Nix и запускает bash шелл, в котором доступен результат (и только он). Проверяем:


bash-3.2$ nix-shell -p stdenv
[nix-shell:~]$

Здесь в качестве выражения мы используем пакет (-p) stdenv. stdenv — это минимальное окружение, которое содержит компилятор, make и другие самые необходимые вещи.


Окружение для сборки пакета


Если запустить nix-shell без аргументов, то выражение читается из файла default.nix. Создадим его:


{ pkgs ? import <nixpkgs> {} }:
let
  stdenv = pkgs.stdenv;
in rec {
  myProject = stdenv.mkDerivation {
    name = "my-project";
  };
}

Здесь мы написали функцию, которая на вход принимает репозиторий (а если параметр не задан — импортирует стандартный nixpkgs) и возвращает "пакет" окружения нашего проекта. Добавим в него свежие CMake, Boost и Google Test из репозитория NixOS:


# ...
  myProject = stdenv.mkDerivation {
    name = "my-project";
    nativeBuildInputs = [
      pkgs.cmake
    ];
    buildInputs = [
      pkgs.boost
      pkgs.gtest
    ];
  };

Здесь buildInputs — зависимости, которые необходимы для сборки. Зачем еще nativeBuildInputs? Все дело в том, что Nix поддерживает кросс-компиляцию. И здесь мы говорим, что пакеты buildInputs должны быть собраны target тулчейном, а nativeBuildInputs нужно собрать обычным host тулчейном. Есть еще propagatedBuildInputs — он добавляет зависимость всем пользователям пакета.


Теперь при следующем вызове nix-shell, Nix выкачает необходимые бинарные пакеты и установит переменные окружения так, чтобы библиотеки находились стандартными средствами, например, CMake:


find_package(Boost 1.60 REQUIRED
    COMPONENTS system thread)
find_path(GTEST_INCLUDE_DIRS
    NAMES gtest/gtest.h
    PATH_SUFFIXES gtest)

Разработчику остается лишь запустить cmake . && make, о чем мы ему и сообщим при входе в nix-shell:


  myProject = stdenv.mkDerivation {
    # ...
    shellHook = [''
      echo Welcome to myproject!
      echo Run \'mkdir build && cd build && cmake .. && make -j\' to build it.
     ''];
   };

Собираем зависимость, которой нет в nixpkgs


Теперь мы хотим добавить в наш проект cppformat. Сначала ищем его в nixpkgs:


$ nix-env -qaP  | grep cppformat
$ nix-env -qaP  | grep cpp-format

Пусто. Придется писать собственное выражение. Благо это всего 10 строчек. Добавим их в "let":


# ...
let
  stdenv = pkgs.stdenv;
  fetchurl = pkgs.fetchurl;

  cppformat = stdenv.mkDerivation rec {
    version = "2.1.0";
    name = "cppformat-${version}";
    src = fetchurl {
      url = "https://github.com/cppformat/cppformat/archive/${version}.tar.gz";
      sha256 = "0h8rydgwbm5gwwblx7jzpb43a9ap0dk2d9dbrswnbfmw50v5s7an";
    };

    buildInputs = [ pkgs.cmake ];
    enableParallelBuilding = true;
  };
in rec {
# ...
    buildInputs = [
      # ...
      cppformat
    ];
# ...

Теперь при последующем запуске nix-shell, Nix скачает исходники cppformat, соберет их используя cmake (он видит, что проект использует cmake, поэтому вместо стандартного "./configure && make install" будет использован "cmake . && make install") и закеширует результат сборки в /nix/store. Примечательно, что в отличие от утилит большинства других пакетных менеджеров:


  • При неудаче в сборке исходники не будут выкачиваться повторно;
  • Если мы изменили выражение — пакет перекомпилируется. Если потом решили откатить выражение назад, то автоматически будет использован старый пакет из кеша, даже если дата модификации файла изменилась (удобно при смене бранча/коммита).

Модифицируем пакет из репозитория


Иногда нужный пакет в репозитории есть, но собран не так, как нам хочется. Нужно собрать его определенную версию, наложить патч, использовать определенные флаги. Nix позволяет это сделать без необходимости копипастить код из репозитория:


  cpp-netlib = pkgs.cpp-netlib.overrideDerivation(oldAttrs: {
    postPatch = ''
      substituteInPlace CMakeLists.txt         --replace "CPPNETLIB_VERSION_PATCH 1" "CPPNETLIB_VERSION_PATCH 3"
    '';

    cmakeFlags = oldAttrs.cmakeFlags ++ [ "-DCMAKE_CXX_STANDARD=11" ];

    src = fetchFromGitHub {
      owner = "cpp-netlib";
      repo = "cpp-netlib";
      rev = "9bcbde758952813bf87c2ff6cc16679509a40e06"; # 0.11-devel
      sha256 = "0abcb2x0wc992s5j99bjc01al49ax4jw7m9d0522nkd11nzmiacy";
    };
  });

Модифицируем пакет в репозитории


Мы можем собрать производный пакет X' на основе оригинального X из репозитория и использовать его у себя. При этом если какой-то пакет Y в репозитории зависел от X, то он продолжит использовать его старую версию. Но что если нужно изменить пакет внутри репозитория, т.е. так, чтобы его стали использовать 100500 других пакетов? И для этого случая в Nix есть инструменты. Пересоберем буст из nixpkgs, используя GCC5 вместо стандартного GCC 4.9:


{ nixpkgs ? import <nixpkgs> {} }:
let
  overrideCC = nixpkgs.overrideCC;
  stdenv = if ! nixpkgs.stdenv.isLinux
    then nixpkgs.stdenv
    else overrideCC nixpkgs.stdenv nixpkgs.gcc5;
  pkgs = nixpkgs.overridePackages (self: super: {
    boost = super.boost.override { stdenv = stdenv; };
  });

Здесь мы изменили имя аргумента с pkgs на nixpkgs и создаем производный репозиторий pkgs, в котором буст собран так, как мы хотим. Теперь все остальные пакеты зависящие от boost должны быть пересобраны чтобы задействовать нашу сборку. Разумеется, будут (рекурсивно) пересобраны лишь те пакеты, которые используются внутри нашего выражения — ведь Nix ленив.


Интеграция с сторонними пакетными менеджерами и платформами


Тут все опять просто — в Nix есть поддержка сборки пакетов для .NET, Emacs, Go, Haskell, Lua, Node, Perl, PHP, Python и Rust. Для некоторых из них интеграция заключается в том, что Nix может использовать пакеты прямо из нативного пакетного менеджера:


nativeBuildInputs = [ pkgs.cmake pkgs.pkgconfig nodePackages.uglify-js ];

Интегрируем Nix в YouCompleteMe


YouCompleteMe — пожалуй самый популярный движок автодополнения кода для C++, который не является частью IDE. Он вышел из Vim, но уже есть порты для Atom и, возможно, других редакторов. Если раньше разработчики должны были конфигурировать его самостоятельно под свою систему, то теперь мы можем сделать это универсально:


def ExportFromNix():
    from subprocess import Popen, PIPE
    import shlex
    cmd = "nix-shell -Q --pure --readonly-mode --run 'echo $NIX_CFLAGS_COMPILE'";
    proc = Popen(cmd, shell=True, stdout=PIPE)
    out = proc.stdout.read().decode("utf-8")
    return shlex.split(out)

flags += ExportFromNix()

Заключение


Nix — одновременно гибкий, удобный и простой пакетный менеджер, который построен на принципах функционального программирования и претендует на роль пакетного менеджера для всего. Особенно он может быть удобен C/C++ программистам, т.к. позволяет заполнить пустующую у данного языка нишу. Используя его, можно патчить и добавлять библиотеки в проект не вызывая боль и ненавистить у коллег. А новичек, прибывший в команду, не будет тратить свои первые рабочие дни на сборку проекта.

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


  1. akalend
    14.04.2016 19:07

    замечательная статья, спасибо


  1. lockywolf
    14.04.2016 20:43
    +1

    Хм. Мне всегда казалось, что менеджера пакетов для С++ нет ровно по той причине по которой он есть для все других языков. Для всех других языков менеджер пакетов устанавливает только пакеты, написанные на этих языках.
    Ну, в некотором смысле такой «инструмент поддержки инфраструктуры». Нужна тебе библиотека — напиши «cpan ***», и готово.

    С++ же призван работать со всем на свете, в том числе и с миллионом приложений, написанных на других языках. Значит самый естественный менеджер пакетов для С++ — это slackpkg/apt-get/PackageKit.


    1. TargetSan
      14.04.2016 22:24
      +4

      Мой личный опыт показывает, что это все окружающие призваны работать с С++ через C ABI. С++ не стыкуется сам по себе ни с кем кроме С и С++. Все остальные делают C-compatible FFI. А пакетный менеджер не родился КМК из-за того, что производителей компиляторов было несколько. И они в начале развития С++ не очень хорошо дружили друг с другом. Посмотрите хотя бы на «зоопарк» систем сборки. Отсюда, кстати, и отсутствие какого-то единого соглашения на структуру исходников для каждого «компонента». Другие языки появлялись усилиями какой-то одной компании или группы энтузиастов, и только потом могли появляться «форки». Либо просто выходил порт референсного компилятора/рантайма.


  1. JagaJaga
    14.04.2016 21:10
    +3

    Официальный NixOS контрибьютор здесь :) (https://github.com/jagajaga)
    Если интересуют ответы на вопросы — задавайте, постараюсь помочь.


    1. johhy13
      14.04.2016 22:25
      +1

      Пробовал NixOS как то, идея нравится, а вот что необходимых пакетов не было или были устаревшие не обрадовало.
      Хотелось бы прояснить:
      1.NixOS на базе чего Debian Ubuntu etc.
      2.PM Nix не зависит от NixOS и будет работать например на Lubuntu.


      1. snizovtsev
        14.04.2016 22:36

        1. NixOS самостоятельна. По сути это PM + декларативная система конфигурации (с ней я не разбирался).
        2. Да, там все компоненты кроме ядра свои устанавливаются. Даже на MacOS работает (правда Qt не поддерживается).


        1. JagaJaga
          14.04.2016 23:38

          1. Там все просто :) Тоже функции. Плюс дает, имхо, более простой и гибкий вариант конфигурации системы.


    1. prefrontalCortex
      17.04.2016 11:25

      А скажите, nixlang базируется на каком-то уже существующем языке? На ML-семейство больно уж похож.


      1. JagaJaga
        17.04.2016 11:27
        +1

        Нет, он тоже с нуля написан. Это была магисторская работа его автора, вроде.


  1. mbait
    15.04.2016 00:35

    Мельком глянул исходники C++ — ночной кошмар программиста. Весь проект в целом — NIH-синдром в терминальной стадии. Про NixOS пока непонятно.


  1. Camel
    15.04.2016 18:14

    Говоря про Nix нельзя не упомнять Guix — то же самое, только использует Guile (диалект лиспа, конкретно Scheme) вместо специфичного nixlang.


  1. wibotwi
    22.04.2016 05:44

    Могу ли я его локально поставить? Т.е. не имя рут доступа, просто в пользовательскую папку и при этом полноценно использовать?