Добро пожаловать на четырнадцатую пилюлю Nix. В предыдущей тринадцатой пилюле мы рассказали о паттерне callPackage и показали, как с его помощью объединять пакеты в репозиторий.

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

О композируемости

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

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

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

debugVersion (applyPatches [ ./patch1.patch ./patch2.patch ] drv)

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

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

Паттерн override (переопределение)

В пилюле 12 мы рассказали о паттерне проектирования входящие. Мы не возвращаем деривацию, выбирая зависимости непосредственно из репозитория; вместо этого мы объявляем входящие параметры и позволяем вызывающим функциям передавать необходимые аргументы.

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

graphviz = import ./graphviz.nix { inherit mkDerivation gd fontconfig libjpeg bzip2; };

Если бы мы хотели создать деривацию graphviz с отличающейся версией gd, нам бы пришлось повторить большую часть приведённого кода, плюс указать альтернативу gd:

{
  mygraphviz = import ./graphviz.nix {
    inherit
      mkDerivation
      fontconfig
      libjpeg
      bzip2
      ;
    gd = customgd;
  };
}

Такой код трудно поддерживать. Использовать callPackage уже проще:

mygraphviz = callPackage ./graphviz.nix { gd = customgd; };

Но мы всё ещё наследуем оригинальный пакет graphviz из репозитория.

Нам бы хотелось избавиться от повторного указания выражения Nix. Вместо этого было бы здорово повторно использоваль оригинальный атрибут graphviz из репозитория и добавить к нему наши переопределения:

mygraphviz = graphviz.override { gd = customgd; };

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

Обратите внимание, что .override не является "методом" в смысле ООП, как вам могло бы показаться. Nix — функциональный язык. Так что .override — это всего лишь атрибут набора.

Реализация паттерна override

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

Давайте начнём с создания функции "makeOverridable". Эта функция получает два аргумента: функцию (которая должна возвращать набор) и набор исходных аргументов, которые надо передать этой функции.

Поместим эту функцию в lib.nix:

{
  makeOverridable =
    f: origArgs:
    let
      origRes = f origArgs;
    in
    origRes // { override = newArgs: f (origArgs // newArgs); };
}

makeOverridable принимает функцию и набор исходных аргументов. Она возвращает исходный набор плюс новый атрибут override.

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

Посмотрим, как это работает в nix repl:

$ nix repl
:l lib.nix
Added 1 variables.
f = { a, b }: { result = a+b; }
f { a = 3; b = 5; }
{ result = 8; }
res = makeOverridable f { a = 3; b = 5; }
res
{ override = «lambda»; result = 8; }
res.override { a = 10; }
{ result = 15; }

Обратите внимание, что, функция f возвращает не сумму двух аргументов. Она возвращает набор, в котором сумма привязана к атрибуту result.

Переменная res содержит результат выполнения функции без переопределения.
Это видно из кода makeOverridable. Кроме того, появился новый атрибут override, значением которого является функция.

Вызов res.override, как и ожидалось, запустит исходную функцию с переопределёнными атрибутами.

Хорошее начало, но мы пока не можем повторно переопределить атрибуты! Всё потому, что возвращаемый набор (с result = 15) не имеет собственного атрибута override. Это плохо, так как мешает композиции функций.

Решение здесь простое: функция .override должна вернуть переопределяемый результат:

rec {
  makeOverridable =
    f: origArgs:
    let
      origRes = f origArgs;
    in
    origRes // { override = newArgs: makeOverridable f (origArgs // newArgs); };
}

Обратите внимание на ключевое слово rec. Оно необходимо, чтобы мы могли вызвать makeOverridable из самой makeOverridable.

Теперь попробуем двойное переопределение:

nix-repl> :l lib.nix
Added 1 variables.
nix-repl> f = { a, b }: { result = a+b; }
nix-repl> res = makeOverridable f { a = 3; b = 5; }
nix-repl> res2 = res.override { a = 10; }
nix-repl> res2
{ override = «lambda»; result = 15; }
nix-repl> res2.override { b = 20; }
{ override = «lambda»; result = 30; }

Получилось! Результат (как и ожидалось) равен 30, потому что a в первом переопределении получила значение 10, а b во втором — 20.

Теперь было бы неплохо, если бы функция callPackage делала наши деривации переопределяемыми. Пусть это останется упражнением для читателя.

Заключение

Паттерн "override" упрощает настройку пакетов, используя в качестве основы существующий набор пакетов. Такой подход открывает целый мир возможностей для использования центрального репозитория, такого как nixpkgs с последующим переопределением пакетов на нашей локальной машине без изменения оригинальных исходников.

Мы можем помечтать об отдельном изолированном окружении nix-shell для тестирования graphviz с альтернативной версией gd:

debugVersion (graphviz.override { gd = customgd; })

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

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

В следующей пилюле

В следующей пилюле мы поговорим о путях поиска Nix.
Под "путём поиска" мы подразумеваем место в файловой системе, где Nix ищет выражения.
Это позволить нам узнать, откуда берётся <nixpkgs>.

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