Доброго всем времени суток!
Итак, я решил продолжить рассказ о замечательном языке программирования D.
Моя прошлая статья была о мультипарадигменности языка, о том, что он естественным и гармоничным образом поддерживает большинство современных популярных стилей программирования.
В этот раз я задумал осветить другую сторону языка — менее общую и фундаментальную, но не менее полезную. А именно возможности метапрограммирования и compile-time computations.
Начну я, пожалуй со всем привычных вещей — обобщенного программирования (generics, templates). То есть, со знакомых всем нам по C++ шаблонов.
Для начала, что это такое на простом уровне: обобщенное программирование (шаблонные функции и типы данных) — это способ предоставить возможность повторного использования кода. Когда программист пишет код для некоторого обобщенного типа, а потом подставляет конкретные.
В языке D выбран гетерогенный подход. Это значит, что шаблоны в недрах реализации языка — не более, чем типобезопасные макросы, и каждый конкретный тип, подставленный в шаблон просто-напросто генерирует отдельную реализацию. Попробуем описать простую, и, признаю, мало осмысленную шаблонную функцию:
Первым делом замечу, что возвращаемый результат — тоже параметризируется. Очень удобное свойство.
Посмотрим, как она работает:
Все тесты прошли, как и предполагалось! Несомненно, успех. Хотя постойте… попробуем такой код:
Не скомпилировалось? Не удивительно. Ошибка компиляции на строке
и, как следствие, на строке
А теперь представим, что функция эта написана не нами, а зарыта глубоко в библиотеке. Безрадостно, верно?
Однако, D предоставляет очень удобный способ разрешить возникшее недоразумение. Так как одна строчка кода заменит тысячу слов, просто взглянем:
Таким образом, мы просто и элегантно указали компилятору, для каких входных данных предназначена функция, и если он найдет код, который выше давал ошибки сразу в двух строчках, да еще одну прямо в функции, то он проверит условие и сгенерирует ошибку прямо на месте. И, что самое главное, это совершенно бесплатно!
Конечно, под бесплатно, я имею ввиду тот факт, что все подобного рода проверки проходят на этапе компиляции, не отнимая ни одного драгоценного такта в рантайме. Это, кстати, значит, что мы можем писать только те проверки, которые возможно вычислить на этапе компиляции.
А теперь вернемся к коду:
Вам, наверное, это кажется полнейшим шаманством. Как же вычислить на этапе компиляции arr[0] да еще и + 1? Правильный ответ — а никак. Компилятор и не вычисляет это значение. В данном случае typeof не вычисляет собственный аргумент, оно просто выводит тип значения, которое передается ему, как аргумент.
Таким образом
значит лишь то, что:
a) Мы можем к значениям типа typeof(arr[0]) прибавлять значения типа typeof(1) и
b) После прибавления мы снова получим тип typeof(arr[0]).
Здорово, не правда ли?
Итак, мы написали функцию, которая умеет прибавлять ко всем значениям массива единицу, при том она аккуратно докладывает, если прибавлять единицу нельзя в принципе. Вроде неплохо? Однако, дальше — лучше.
Чтобы дойти до лучше, придется еще немного попотеть и модифицировать пример:
Теперь мы прибавляем не единицу, а произвольное число. Проверим нашу функцию:
Успех! Но постойте, мы обошлись с ней слишком мягко, попробуем несколько усложнить задачу:
Упс! Компилятор не смог понять наш восхитительный замысел. Что это: очередная сварливая строгая система типов желает нам зла? Но давайте посмотрим повнимательнее: мы же сами виноваты! В определении четко сказано, что массив и число одного типа. Хорошо. Попробуем еще модифицировать пример, чтобы принимать значения разных типов.
Вот теперь уже окончательный успех:
Замечу так же, что условие в заголовке функции менять не пришлось — мы хотим ровно того же, что и в предыдущем варианте.
Теперь настало время пояснения любителям строгости. Такой вариант записи вызова функции:
Всего лишь результат автоматического вывода типов. А вот как дела обстоят на самом деле:
В D каждая функция имеет два набора аргументов и в общем виде определяется как
Первый набор — набор аргументов времени компиляции, второй — времени выполнения.
Название говорит само за себя: аргументы из первого набора вычисляются во время компиляции, в то время, как из второго — во время выполнения. И то не всегда.
Таким образом все аргументы времени компиляции опять-таки достаются нам «на халяву», но снова мы не можем использовать тут невычислимые в течении компиляции значения.
Для вызова полиморфных функций синтаксис таков:
При этом, если аргумент времени компиляции всего один, то можно опустить скобки:
Аргументами времени компиляции могут быть не только типы, а вообще любые выражения, вычислимые на этапе компиляции. Например, «42».
Но 42 сгодится и в шаблонах C++, тут же все гораздо интереснее: в качестве таких параметров можно использовать даже функции! Рассмотрим пример:
В данном случае мне пришлось помочь компилятору и вручную определить типы для шаблонных параметров. Не уверен, но наверное можно как-то определить эту функцию так, чтобы этого не понадобилось.
Предлагаю устроить конкурс на самую красивую реализацию map в комментах :)
Но с другой стороны — посмотрите как здорово получилось!
Сравним этот подход с подходом C++11 STL на примере широко известного алгоритма sort.
Что тут происходит? Правильно, сортировка массива, при этом чтобы определить, нужно ли менять элементы местами их нужно сравнивать и для этого вызывается наша лямбда-функция (спасибо что хоть теперь лямбда, раньше приходилось функцию писать). Вызывается каждый раз. А в данном (да и, наверное, во многих) случае затраты на вызов функции сравнимы с временем вычисления самой функции, если даже не превосходят его. Непростительная растрата производительности.
В то время, как в D, мы видели, функция — аргумент времени компиляции, а соответственно и вычисляется во время компиляции, а соответственно элементарно инлайнится. Так что, можно сказать, кое в чем D даже превосходит C++ по эффективности, как говорится, by design.
Статью уже разнесло, а ведь я только начал!
Итак, продолжим с вычислениями в процессе компиляции.
Все мы помним, а нерезко и грешим выражениями типа
В D нет препроцессора (кто-то скажет: «и слава богу», кто-то поморщится, но холивар устраивать не будем, а продолжим читать).
Однако, в языке есть конструкции, его заменяющие. Например, вышеприведенную конструкцию, заменит выражение static if. Вообще, мы обошлись с ним очень (поверьте, ну ооочень) несправедливо: static if может несравненно больше.
Тут нужно лирическое отступление. В D есть ключевое слово alias. Оно работает много как, но сейчас оно нам потребуется в качестве typedef. Вот пример:
Итак, static if. Попробую показать на деле:
Мы определили целочисленный тип, размер которого зависит от архитектуры машины, на которой компилируется код.
Теперь можем его использовать как любой другой тип:
static if можно писать буквально где угодно: В глобальном коде, в функциях и даже в определениях классов!
Тут нужно небольшое добавление, выражения «static else» нету! Используйте обычный else, коллизий возникнуть не может, вложенность учитывается.
И в заключение еще одна интересная и полезная особенность. Одним из принципов идеологии языка является: вычислять во время компиляции все, что может быть вычислено.
Рассмотрим такой код:
Язык предполагает, что компилятор проверяет возможность вычислить f() во время компиляции используя интерпритатор, и если это невозможно, генерируется ошибка, иначе значение просто подставляется в код. Для D общепринятая практика — писать функции, которые должны вычислиться во время компиляции.
Еще раз: в отличии от C/C++ static переменные инициализируются не при первом вызове инициализирующего кода, а во время компиляции программы и соответственно должны быть инициализированными выражениями, вычислимыми во время компиляции.
Ну вот наверное на сегодня и все. Внимательный читатель, конечно, заметил, что я не упомянул о обобщенных данных — классах, интерфейсах и структурах, но с точки зрения метапрограммирования разница с обобщенными функциями невелика, поэтому оставлю это на самостоятельное изучение заинтересовавшимся.
Надеюсь, свое дело я сделал хорошо и эта статья сможет заинтересовать людей языком программирования D.
Тем, кто смог дочитать до сюда — надеюсь, вам понравилось и спасибо за внимание!
P.S.
В комментариях к моей прошлой статье было много ворчания в сторону D. И заявления о неэффективности стандартных типов, и жалобы на недостаток инструментов, и даже на поддержку разных архитектур. Прошу, оставьте ворчание при себе — до поры до времени. Я надеюсь, статья вам понравится и я смогу продолжать радовать вас другими статьями про язык D и в свое время обязательно доберусь до каждой озвученной проблемы, изучу их и представлю на суд общественности, и вот тогда уже я смогу компетентно подискутировать по поводу ваших сомнений, будь они оправданными или нет.
Итак, я решил продолжить рассказ о замечательном языке программирования D.
Моя прошлая статья была о мультипарадигменности языка, о том, что он естественным и гармоничным образом поддерживает большинство современных популярных стилей программирования.
В этот раз я задумал осветить другую сторону языка — менее общую и фундаментальную, но не менее полезную. А именно возможности метапрограммирования и compile-time computations.
Начну я, пожалуй со всем привычных вещей — обобщенного программирования (generics, templates). То есть, со знакомых всем нам по C++ шаблонов.
Для начала, что это такое на простом уровне: обобщенное программирование (шаблонные функции и типы данных) — это способ предоставить возможность повторного использования кода. Когда программист пишет код для некоторого обобщенного типа, а потом подставляет конкретные.
В языке D выбран гетерогенный подход. Это значит, что шаблоны в недрах реализации языка — не более, чем типобезопасные макросы, и каждый конкретный тип, подставленный в шаблон просто-напросто генерирует отдельную реализацию. Попробуем описать простую, и, признаю, мало осмысленную шаблонную функцию:
T[] MapInc(T)(T[] arr)
{
auto res = new T[arr.length];
foreach(i, v; arr)
res[i] = v + 1;
return res;
}
Первым делом замечу, что возвращаемый результат — тоже параметризируется. Очень удобное свойство.
Посмотрим, как она работает:
void main()
{
auto ar = [1,2,3,4];
auto ard = [1.0,2.0,3.0,4.0];
assert(MapInc(ar) == [2,3,4,5], "wrong!");
assert(MapInc(ard) == [2.0,3.0,4.0,5.0], "wrong!");
}
Все тесты прошли, как и предполагалось! Несомненно, успех. Хотя постойте… попробуем такой код:
auto ar = ["1","2","3","4"];
MapInc(ar);
Не скомпилировалось? Не удивительно. Ошибка компиляции на строке
res[i] = v + 1;
и, как следствие, на строке
MapInc(ar);
А теперь представим, что функция эта написана не нами, а зарыта глубоко в библиотеке. Безрадостно, верно?
Однако, D предоставляет очень удобный способ разрешить возникшее недоразумение. Так как одна строчка кода заменит тысячу слов, просто взглянем:
T[] MapInc(T)(T[] arr)
if(is(typeof(arr[0] + 1) == typeof(arr[0]))) // вот сюда смотреть.
{
auto res = new T[arr.length];
foreach(i, v; arr)
res[i] = v + 1;
return res;
}
Таким образом, мы просто и элегантно указали компилятору, для каких входных данных предназначена функция, и если он найдет код, который выше давал ошибки сразу в двух строчках, да еще одну прямо в функции, то он проверит условие и сгенерирует ошибку прямо на месте. И, что самое главное, это совершенно бесплатно!
Конечно, под бесплатно, я имею ввиду тот факт, что все подобного рода проверки проходят на этапе компиляции, не отнимая ни одного драгоценного такта в рантайме. Это, кстати, значит, что мы можем писать только те проверки, которые возможно вычислить на этапе компиляции.
А теперь вернемся к коду:
if(is(typeof(arr[0] + 1) == typeof(arr[0])))
Вам, наверное, это кажется полнейшим шаманством. Как же вычислить на этапе компиляции arr[0] да еще и + 1? Правильный ответ — а никак. Компилятор и не вычисляет это значение. В данном случае typeof не вычисляет собственный аргумент, оно просто выводит тип значения, которое передается ему, как аргумент.
Таким образом
typeof(arr[0] + 1) == typeof(arr[0])
значит лишь то, что:
a) Мы можем к значениям типа typeof(arr[0]) прибавлять значения типа typeof(1) и
b) После прибавления мы снова получим тип typeof(arr[0]).
Здорово, не правда ли?
Итак, мы написали функцию, которая умеет прибавлять ко всем значениям массива единицу, при том она аккуратно докладывает, если прибавлять единицу нельзя в принципе. Вроде неплохо? Однако, дальше — лучше.
Чтобы дойти до лучше, придется еще немного попотеть и модифицировать пример:
T[] MapInc(T)(T[] arr, T b)
if(is(typeof(arr[0] + b) == typeof(arr[0])))
{
auto res = new T[arr.length];
foreach(i, v; arr)
res[i] = v + b;
return res;
}
Теперь мы прибавляем не единицу, а произвольное число. Проверим нашу функцию:
void main()
{
auto ar = [1,2,3,4];
assert(MapInc(ar,3) == [4,5,6,7], "wrong!");
}
Успех! Но постойте, мы обошлись с ней слишком мягко, попробуем несколько усложнить задачу:
void main()
{
auto ar = [1.0,2.0,3.0,4.0];
assert(MapInc(ar,1) == [2.0,3.0,4.0,5.0], "wrong!"); // ошибка, несоответствие типов.
}
Упс! Компилятор не смог понять наш восхитительный замысел. Что это: очередная сварливая строгая система типов желает нам зла? Но давайте посмотрим повнимательнее: мы же сами виноваты! В определении четко сказано, что массив и число одного типа. Хорошо. Попробуем еще модифицировать пример, чтобы принимать значения разных типов.
T[] MapInc(T,P)(T[] arr, P b)
if(is(typeof(arr[0] + b) == T))
{
auto res = new T[arr.length];
foreach(i, v; arr)
res[i] = v + b;
return res;
}
Вот теперь уже окончательный успех:
void main()
{
auto ar = [1.0,2.0,3.0,4.0];
assert(MapInc(ar,1) == [2.0,3.0,4.0,5.0], "wrong!"); // ура, выполняется!
}
Замечу так же, что условие в заголовке функции менять не пришлось — мы хотим ровно того же, что и в предыдущем варианте.
Теперь настало время пояснения любителям строгости. Такой вариант записи вызова функции:
MapInc(ar,1);
Всего лишь результат автоматического вывода типов. А вот как дела обстоят на самом деле:
В D каждая функция имеет два набора аргументов и в общем виде определяется как
T f(c1,c2/*, others*/)(r1,r2/*, others*/);
Первый набор — набор аргументов времени компиляции, второй — времени выполнения.
Название говорит само за себя: аргументы из первого набора вычисляются во время компиляции, в то время, как из второго — во время выполнения. И то не всегда.
Таким образом все аргументы времени компиляции опять-таки достаются нам «на халяву», но снова мы не можем использовать тут невычислимые в течении компиляции значения.
Для вызова полиморфных функций синтаксис таков:
auto v = f!(c1,c2/*, others*/)(r1,r2/*, others*/);
При этом, если аргумент времени компиляции всего один, то можно опустить скобки:
auto v = f!c1(r1,r2/*, others*/);
Аргументами времени компиляции могут быть не только типы, а вообще любые выражения, вычислимые на этапе компиляции. Например, «42».
Но 42 сгодится и в шаблонах C++, тут же все гораздо интереснее: в качестве таких параметров можно использовать даже функции! Рассмотрим пример:
P[] Map(alias f,T,P)(T[] arr)
if(is(typeof(f(arr[0])) == P))
{
auto res = new P[arr.length];
foreach(i, v; arr)
res[i] = f(v);
return res;
}
void main()
{
auto ard = [1.0,2.0,3.0,4.0];
auto ar = [1,2,3,4];
assert(Map!((double x) {return x+1;},double,double)(ard) == [2.0,3.0,4.0,5.0], "wrong!");
assert(Map!((int x) {return x+1.0;},int,double)(ar) == [2.0,3.0,4.0,5.0], "wrong!");
assert(Map!((int x) {return x+1;},int,int)(ar) == [2,3,4,5], "wrong!");
assert(Map!((double x) {return x+1.0;},int,double)(ar) == [2.0,3.0,4.0,5.0], "wrong!");
}
В данном случае мне пришлось помочь компилятору и вручную определить типы для шаблонных параметров. Не уверен, но наверное можно как-то определить эту функцию так, чтобы этого не понадобилось.
Предлагаю устроить конкурс на самую красивую реализацию map в комментах :)
Но с другой стороны — посмотрите как здорово получилось!
Сравним этот подход с подходом C++11 STL на примере широко известного алгоритма sort.
auto arr = {1,2,3,4};
sort(arr.begin(), arr.end(), [&](int a, int b) {return a > b && a < 42;});
Что тут происходит? Правильно, сортировка массива, при этом чтобы определить, нужно ли менять элементы местами их нужно сравнивать и для этого вызывается наша лямбда-функция (спасибо что хоть теперь лямбда, раньше приходилось функцию писать). Вызывается каждый раз. А в данном (да и, наверное, во многих) случае затраты на вызов функции сравнимы с временем вычисления самой функции, если даже не превосходят его. Непростительная растрата производительности.
В то время, как в D, мы видели, функция — аргумент времени компиляции, а соответственно и вычисляется во время компиляции, а соответственно элементарно инлайнится. Так что, можно сказать, кое в чем D даже превосходит C++ по эффективности, как говорится, by design.
Статью уже разнесло, а ведь я только начал!
Итак, продолжим с вычислениями в процессе компиляции.
Все мы помним, а нерезко и грешим выражениями типа
#ifdef P
...
#else
...
#endif
В D нет препроцессора (кто-то скажет: «и слава богу», кто-то поморщится, но холивар устраивать не будем, а продолжим читать).
Однако, в языке есть конструкции, его заменяющие. Например, вышеприведенную конструкцию, заменит выражение static if. Вообще, мы обошлись с ним очень (поверьте, ну ооочень) несправедливо: static if может несравненно больше.
Тут нужно лирическое отступление. В D есть ключевое слово alias. Оно работает много как, но сейчас оно нам потребуется в качестве typedef. Вот пример:
alias int MyOwnPersonalInt;
Итак, static if. Попробую показать на деле:
enum Arch {x86, x64};
static Arch arch = x86;
static if(arch == x86)
alias int integer;
else
alias long integer;
Мы определили целочисленный тип, размер которого зависит от архитектуры машины, на которой компилируется код.
Теперь можем его использовать как любой другой тип:
integer Inc(integer n) {return n+2;}
static if можно писать буквально где угодно: В глобальном коде, в функциях и даже в определениях классов!
Тут нужно небольшое добавление, выражения «static else» нету! Используйте обычный else, коллизий возникнуть не может, вложенность учитывается.
И в заключение еще одна интересная и полезная особенность. Одним из принципов идеологии языка является: вычислять во время компиляции все, что может быть вычислено.
Рассмотрим такой код:
static int a = f();
// или даже
enum int b = f();
Язык предполагает, что компилятор проверяет возможность вычислить f() во время компиляции используя интерпритатор, и если это невозможно, генерируется ошибка, иначе значение просто подставляется в код. Для D общепринятая практика — писать функции, которые должны вычислиться во время компиляции.
Еще раз: в отличии от C/C++ static переменные инициализируются не при первом вызове инициализирующего кода, а во время компиляции программы и соответственно должны быть инициализированными выражениями, вычислимыми во время компиляции.
Ну вот наверное на сегодня и все. Внимательный читатель, конечно, заметил, что я не упомянул о обобщенных данных — классах, интерфейсах и структурах, но с точки зрения метапрограммирования разница с обобщенными функциями невелика, поэтому оставлю это на самостоятельное изучение заинтересовавшимся.
Надеюсь, свое дело я сделал хорошо и эта статья сможет заинтересовать людей языком программирования D.
Тем, кто смог дочитать до сюда — надеюсь, вам понравилось и спасибо за внимание!
P.S.
В комментариях к моей прошлой статье было много ворчания в сторону D. И заявления о неэффективности стандартных типов, и жалобы на недостаток инструментов, и даже на поддержку разных архитектур. Прошу, оставьте ворчание при себе — до поры до времени. Я надеюсь, статья вам понравится и я смогу продолжать радовать вас другими статьями про язык D и в свое время обязательно доберусь до каждой озвученной проблемы, изучу их и представлю на суд общественности, и вот тогда уже я смогу компетентно подискутировать по поводу ваших сомнений, будь они оправданными или нет.