Добро пожаловать на четырнадцатую пилюлю 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>
.