[Из песочницы] Design by Introspection
Что, если бы мы умели располагать атомы один за другим как нам угодно?
Ричард Фейнман
Сколько парадигм программирования вы сможете назвать? Список на этой странице википедии содержит ни много ни мало 76 наименований. Этот список может быть пополнен ещё одним подходом, названным Design by Introspection. Его основная идея состоит в активном использовании простых средств метапрограммирования и интроспекции типов (времени компиляции) для создания эластичных компонентов.
Автором данного подхода является Андрей Александреску. В статье были использованы материалы из его выступления на DСonf 2017.
Предыстория
В 2001 году в книге «Современное проектирование на С++» был представлен паттерн под названием policy-based design. В целом, это паттерн «стратегия», но использующий шаблоны и собирающийся во время компиляции. Шаблонный класс host принимает своими параметрами набор типов policy, реализующих каждый какую-то независимую функциональность, и внутри себя использует интерфейсы, предоставляемые этими компонентами:
struct Widget(T, Prod, Error)
{
private T frob;
private Prod producer;
private Error errPolicy;
void doWork()
{
// используем неявные интерфейсы
// обычный duck-typing
}
}
Здесь шаблон описан коротким синтаксисом. (T, Prod, Error)
— его параметры.
Инстанцирование выглядит так:
Widget!(int, SomeProducer, SomeErrorPolicy) w;
Плюсы очевидны: эффективность шаблонов, хорошее разделение и повторное использование кода. Однако компоненты являются цельными, неразборными. Если отсутствуют какая-то часть интерфейса, то это приведёт к ошибке компиляции. Попробуем развить эту схему, придать компонентам «пластичность».
Требования
Итак, для этого нам понадобится:
- интроспекция типов: «Какие у тебя есть методы?», «Поддерживаешь ли ты метод xyz?»
- выполнение кода во время компиляции
- генерация кода
Давайте взглянем, какие средства языка D можно использовать по каждому из пунктов:
.tupleof
,__traits
,std.traits
__traits
— встроенное в компилятор средство рефлексии.std.traits
— библиотечное расширение встроенных трейтов, среди которых нас будет интересовать функцияhasMember
.- CTFE,
static if
,static foreach
Во время компиляции можно исполнять большой класс функций (фактически, любые портируемые и не имеющие глобальных побочных эффектов функции).static if
,static foreach
— этоif
иforeach
времени компиляции. - шаблоны и миксины
Миксины в языке D бывают двух видов: шаблонные и строковые. Первые служат для вставки набора определений (функций, классов и т.п.) в какое-то место программы. Вторые превращают строку, сформированную во время компиляции, непосредственно в код. Строковые миксины используются обычно небольшими порциями.
Опциональные интерфейсы
Важнейшим свойством Design by Introspection являются опциональные интерфейсы. Здесь компонент содержит R обязательных примитивов (может быть 0) и O необязательных. С помощью интроспекции можно выяснить, задан ли определённый примитив, причём знание о пропущенных примитивах так же важно, как и о тех, которые компонент содержит. Число возможных интерфейсов, таким образом, становится равным 2O.
static if
— простое, но мощное средство, делающее «магический форк», удваивая число вариантов использования кода. Оно позволяет писать линейный код с экспоненциальным ростом количества возможных поведений. Экспоненциального же роста генерируемого компилятором кода не происходит: вы заплатите только за те инстансы шаблонов, которые действительно используете в своём приложении.
Пример
В качестве примера использования DbI рассмотрим std.experimental.checkedint — модуль стандартной библиотеки Phobos, реализующий безопасную работу с целыми числами. Какие операции с машинными целыми являются небезопасными?
- +, +=, -, -=, ++, --, , = могут вызвать переполнение
- деление на ноль в / и /=
- -x.min равен самому себе для знаковых типов
- -1 == uint.max, -1 > 2u
- …
Можно честно вставлять проверки после каждой операции, а можно разработать тип, который бы это делал за нас. При этом возникает множество вопросов:
- что тип должен проверять
- что делать в случае нарушения проверки
- какие операции/преобразования запретить
- как сделать всё это эффективным
- в конце концов, как сделать это простым, чтобы к библиотеке не прилагался двадцатистраничный мануал по её использованию
Создадим «оболочку», принимающую шаблонными параметрами базовый тип и «хук», который будет осуществлять наши проверки:
static Checked(T, Hook = Abort) if (isIntegral!T) // Abort по умолчанию
{
private T payload;
Hook hook;
...
}
У хука далеко не всегда есть состояние. Давайте это учтём, использовав static if
:
struct Checked(T, Hook = Abort) if (isIntegral!T)
{
private T payload;
static if (stateSize!Hook > 0)
Hook hook;
else
alias hook = Hook;
...
}
Здесь нам на руку то, что в синтаксисе языка D точка используется и для обращения к полям объекта непосредственно, и через указатель, и к его статическим членам.
Настроим ещё и значение по умолчанию. Это может быть полезно для хуков, определяющих некоторое NaN значение. Здесь мы используем шаблон hasMember
:
struct Checked(T, Hook = Abort) if (isIntegral!T)
{
static if (hasMember!(Hook, "defaultValue"))
private T payload = Hook.defaultValue!T;
else
private T payload;
static if (stateSize!Hook > 0)
Hook hook;
else
alias hook = Hook;
...
}
В качестве примера того, как много поведений может содержать небольшой фрагмент кода, приведу перегруженные операторы инкремента и декремента.
ref Checked opUnary(string op)() return
if (op == "++" || op == "--")
{
static if (hasMember!(Hook, "hookOpUnary"))
hook.hookOpUnary!op(payload);
else
static if (hasMember!(Hook, "onOverflow"))
{
static if (op == "++")
{
if (payload == max.payload)
payload = hook.onOverflow!"++"(payload);
else
++payload;
} else
{
if (payload == min.payload)
payload = hook.onOverflow!"--"(payload);
else
--payload;
}
} else
mixin(op ~ "payload;");
return this;
}
Если хук перехватывает эти операции, делегируем их ему:
static if (hasMember!(Hook, "hookOpUnary"))
hook.hookOpUnary!op(payload);
В противном случае, обработаем переполнение:
else static if (hasMember!(Hook, "onOverflow"))
{
static if (op == "++")
{
if (payload == max.payload)
payload = hook.onOverflow!"++"(payload);
else
++payload;
} else
{
// -- аналогично
}
}
Наконец, если ничего не было перехвачено, применяем операцию как обычно:
else
mixin(op ~ "payload;");
Этот строковый миксин развернётся в ++payload;
или --payload;
в зависимости от операции.
Традиционно, отсутствие какой-то части интерфейса приводит к ошибке. Здесь же это приводит к отсутствию части возможностей:
Checked!(int, void) x; // x ведёт себя, как обычный int
В модуле std.experimental.checkedint
определено несколько стандартных хуков:
- Abort: сделать
assert(0)
- Throw: бросить исключение
- Warn: вывести предупреждение в stderr
- ProperCompare: делать корректные сравнения
- WithNaN: подставить некоторое NaN значение, сигнализирующее, что что-то не так
- Saturate: не выходить за min и max
Хук может содержать:
- статические поля: defaultValue, min, max
- операторы: hookOpCast, hookOpEquals, hookOpCmp, hookOpUnary, hookOpBinary, hookOpBinaryRight, hookOpOpAssign
- обработчики событий: onBadCast, onOverflow, onLowerBound, onUpperBound
А написание собственного займёт меньше 50 строчек кода. Для примера, запретим все сравнения знаковых чисел с беззнаковыми:
struct NoPeskyCmpsEver
{
static int hookOpCmp(Lhs, Rhs)(Lhs lhs, Rhs rhs)
{
static if (lhs.min < 0 && rhs.min >= 0 && lhs.max < rhs.max ||
rhs.min < 0 && lhs.min >= 0 && rhs.max < lhs.max)
{
// ассерт, сработающий во время компиляции
static assert(0, "Mixed-sign comparison of " ~ Lhs.stringof ~ " and " ~ Rhs.stringof ~ " disallowed. Cast one of the operands.");
}
}
return (lhs > rhs) - (lhs < rhs);
}
alias MyInt = Checked!(int, NoPeskyCmpsEver);
Композиция
До этого Checked принимал основным параметром только базовые типы. Обеспечим композицию, позволим ему принимать другой Checked:
struct Checked(T, Hook = Abort)
if (isIntegral!T || is(T == Checked!(U, H), U, H))
{...}
Это открывает интересные возможности:
Checked!(Checked!(int, ProperCompare))
: чинить сравнения, падать в других ситуацияхChecked!(Checked!(int, ProperCompare), WithNaN)
: чинить сравнения, в других ситуациях возвращать «NaN»
а также вносит бессмысленные комбинации:
- Abort, Throw, Warn несовместимы между собой
- Abort/Throw перед ProperCompare/WithNaN/Saturate
и просто странные:
- выдать ворнинг, затем исправить
- вначале исправить, затем выдать ворнинг
- и т.п.
Для решения этого вопроса предлагается использовать «полуавтоматическую» композицию:
struct MyHook
{
alias
onBadCast = Abort.onBadCast,
onLowerBound = Saturate.onLowerBound,
onUpperBound = Saturate.onUpperBound,
onOverflow = Saturate.onOverflow,
hookOpEquals = Abort.hookOpEquals,
hookOpCmp = Abort.hookOpCmp;
}
alias MyInt = Checked!(int, MyHook);
С помощью alias
мы выбрали статические методы из существующих хуков и сделали из них свой, новый хук. Вот так мы можем расположить атомы как нам угодно!
Заключение
Рассмотренный подход существует во многом благодаря static if
. Этот оператор расширяет пространство вариантов использования кода. При масштабировании Design by Introspection потребует некоторой поддержки со стороны инструментов разработчика.