Недавно занятная задачка попалась на производстве.
В упрощенном виде выглядит так. Есть фиксированный набор различных типов данных, скажем, строки, целые числа и вещественные. Есть множество функций, как-то с ними работающие. Вроде
foobar :: Int -> String -> String -> String
foobar a b c = if a > 0 then b++c else c++b
fun1 :: Double -> Int -> String
fun2 :: String -> Int
fun3 :: Int -> Double -> Double -> Double
и т.д.
Есть "динамический" тип - сумма вышеназванных, простой алгебраик
data Var = Integer Int | Number Double | Str String
и нужно реализовать такую функцию callFun, которая получает список этих вот Var'ов и одну из ф-й выше, и вызывает переданную функцию с переданными аргументами, распаковав их из списка алгебраиков в конкретные типы. Если, конечно, переданный список значений подходит по типам и количеству к вызываемой ф-ии. Ну а не подходит - так выдать ошибку. Чтобы можно было вызвать ее навроде
callFun foobar [Integer (-3), Str "bb", Str "cc"]
и вернуть ее результат. По сути это такой FFI для интерпретатора.
Как бы вы такое сделали на вашем любимом статически типизированном ЯП?
Вот как решение выглядит на D. Сначала покажу результат, как оно выглядит в использовании:
union MyTypes {
int integer;
double number;
string str;
}
alias Var = Sum!MyTypes;
string foobar(int a, string b, string c) {
return a > 0 ? b~c : c~b;
}
double nsqrt(double x, int n) {
import std.math : pow;
return pow(x, 1.0/n);
}
void main() {
Var a = -3;
Var b = "bbb", c = "ccc";
callFun!foobar([a,b,c]).writeln; // выводит cccbbb
[Var(256.0), Var(4)].callFun!nsqrt.writeln; // выводит 4
}
По мотивам библиотечки taggedalgebraic, описываем типы для суммы в виде union'a. Конструктор типов Sum берет такой юнион и делает из него discriminated union, структуру, где кроме исходного union'a еще тэг, дискриминатор, сделанный типом-перечислением, причем имена тэгов берутся из имен полей данного юниона.
struct Sum(U) {
alias Names = AliasSeq!(__traits(allMembers, U));
mixin(`enum Tag {` ~ [Names].join(", ") ~ `}`);
U data;
Tag tag;
this(T)(T x) {
enum name = Names[staticIndexOf!(T, Fields!U)];
__traits(getMember, data, name) = x;
tag = __traits(getMember, Tag, name);
}
}
Генерик конструктор отыскивает нужный тип в юнионе и устанавливает нужное значение тэга. Теперь мы умеем конструировать Var'ы, надо еще научиться их использовать. Обычно в библиотечных реализациях алгебраиков и вариантов делают ф-ю visit, которая берет разные обработчики для разных типов данных и вызывает один из них, подходящий по типу текущему хранимому значению. Мы же пойдем чуть другим путем: пусть передается всего один обработчик в виде полиморфной лямбды, куда будут попадать значения разных типов, а она уже сама разберется что с ними делать.
auto use(alias fun, A)(A a) {
final switch(a.tag)
foreach(name; A.Names)
case __traits(getMember, A.Tag, name):
return fun(__traits(getMember, a.data, name));
}
Тут мы делаем switch по тэгу, а все возможные варианты тэгов у нас перечисляет foreach по компайл-тайм списку их имен, хранящемуся в переданном алгебраике А. Такой цикл по компайл-тайм списку разворачивается при компиляции, так что на каждой итерации получается своя ветка свича, в разных ветках идет обращение к разным полям юниона, значение извлекается по имени через __traits(getMember), при этом на разных итерациях цикла (ветках свича) у нас разный тип этого значения, и оно передается в хэндлер fun.
Теперь уже можно реализовать callFun:
auto callFun(alias fun)(Var[] args) {
alias PS = Parameters!fun;
assert(PS.length == args.length, "Error: expecting " ~ PS.length.text ~ " arguments.");
Tuple!PS params;
foreach(i, T; PS)
args[i].use!((x) {
static if (is(typeof(x)==T)) params[i] = x;
else throw new Exception("type error: passing " ~ typeof(x).stringof
~ " instead of " ~ T.stringof);
});
return fun(params.expand);
}
callFun получает на вход какую-то ф-ю fun (известна в момент компиляции) и массив из Var'ов.
В первой строчке получаем компайл-тайм список PS типов агрументов переданной функции.
Если по длине не совпадает с переданным набором значений - бросаем ошибку.
Заводим тупл params, содержимое которого по типам совпадает с типами аргументов переданной ф-ии. Т.е. если fun принимает string и int, то params будет иметь тип Tuple!(string, int).
Проходимся разворачивающимся в компайл-тайме циклом по этому списку типов и заодно по массиву переданных Var'ов:
для очередного переданного значения args[i] типа Var (наш алгебраик), передаем его в use вместе с лямбдой, которая получит содержащееся внутри args[i] значение уже конкретного типа. Внутри use будет скомпилирована попытка применения лямбды ко всем возможным типам, которые могут хранится в том алгебраике. Поэтому внутри лямбды мы смотрим на тип аргумента - typeof(x) - и если он совпадает с нужным типом из списка типов аргументов fun, то значит это значение x может быть записано в тупл params. Например, если PS это (string, int), и в массиве Var'ов args элемент с индекcом 1 содержит внутри целое число, то use, сделав switch по тэгу, перейдет в ветку про int и передаст целочисленное значение из юниона в лямбду. Когда оно будет передано в эту лямбду, мы сможем его записать в params[1]. Если же в args[i] было передано значение не того типа, сработотает другая ветка свича по тэгу, то когда то значение попадет в эту лямбду, static if выведет на альтернативную ветку, где мы бросим исключение - переданное значение по типу не подошло.
Ну вот, успешно заполнив таким образом тупл params, где уже сидят значения конкретных типов, осталось вызвать требуемую ф-ию с этими значениями, что и делается в последней строчке. Вот и все!
Несколько строчек, и получился универсальный код, который априори вообще не знает, с какими алгебраиками, какими наборами типов ему придется работать, и работает с любыми. При этом тут нет никакой рантайм рефлексии помимо одного switch'a по тэгу, что мы написали явно. Все проверки вроде typeof(x)==T делаются при компиляции, в рантайме их нет, все имеющиеся циклы тоже разворачиваются в компайл-тайме. В требуемую функцию передаются только правильные типы, это проверяется статически. Но вот что интересно, некоторые части программы не подпадают ни под обычное определение статической типизации, ни под динамической. Тип переменной x в callFun нельзя так просто рядом написать карандашом, как это предполагает статическая типизация. Но и динамическим в обычном смысле он не является. Вот если все вызовы callFun с разными ф-ями и все циклы внутри развернуть, получится уже вполне код уровня привычных статически типизированных языков, где каждой переменной можно статически сопоставить какой-то тип. Такое ощущение, что шаблонов/макросов в типизированных языках не хватает какого-то своего раздела в теории типов / PLT. Когда один терм в исходнике у нас обозначает несколько разных термов после раскрытия шаблона, и у них разные типы.