Alexander Zhirov fcd25eea52 | ||
---|---|---|
.. | ||
README.md |
README.md
5. Данные и функции. Функциональный стиль
- 5.1. Написание и модульное тестирование простой функции
- 5.2. Соглашения о передаче аргументов и классы памяти
- 5.3. Параметры типов
- 5.4. Ограничения сигнатуры
- 5.5. Перегрузка
- 5.6. Функции высокого порядка. Функциональные литералы
- 5.7. Вложенные функции
- 5.8. Замыкания
- 5.9. Не только массивы. Диапазоны. Псевдочлены
- 5.10. Функции с переменным числом аргументов
- 5.11. Атрибуты функций
- 5.12. Вычисления во время компиляции
Обсуждать данные и функции сегодня, когда даже разговоры об объектах устарели, – это как вернуться в 1970-е. Но, к сожалению, все еще за горами день, когда говоришь компьютеру, что нужно сделать, и он сам выясняет, как это сделать. А пока этот день не настал, функции – обязательный компонент всех основных направлений программирования. По большому счету, любая программа состоит из вычислений, гоняющих данные туда-сюда; возводимые нами замысловатые строительные леса – типы, объекты, модули, фреймворки, шаблоны проектирования – только придают вычислениям нужные нам свойства, такие как модульность, изоляция ошибок или легкость сопровождения. Правильный язык программирования позволяет своему пользователю держаться золотой середины между кодом «для действия» и кодом «для существования». Идеальное соотношение зависит от множества факторов, из которых самый очевидный – размер программы: основная задача короткого скрипта – действовать, тогда как большое приложение вынуждено заниматься поддержкой неисполняемых вещей вроде интерфейсов, протоколов и модульных ограничений.
Благодаря своим мощным средствам моделирования D позволяет создавать объемистые программы; при этом он старается сократить до разумных пределов код «для существования», позволяя сосредоточиться на том, что нужно «для действия». Хорошо написанные функции на D, как правило, соединяют в себе компактность и универсальность, достигая порой ошеломляющей удельной мощности. Так что пристегнитесь, будем жечь резину.
5.1. Написание и модульное тестирование простой функции
Можно с полным основанием утверждать: главное, чем занимаются компьютеры (помимо скучных дел вроде ожидания ввода данных), – это поиск. Серверы и клиенты баз данных – ищут. Программы искусственного интеллекта – ищут. (А надоедливый болтун – банковский автоответчик? Тоже ищет.) Интернет-поисковики… ну, с этими ясно. Да и собственный опыт наверняка говорит вам, что, по сути, многие программы, якобы не имеющие с поиском ничего общего, на самом деле тоже довольно-таки много ищут. Какую бы задачу ни требовалось решить, всегда задействуется поиск. В свою очередь, многие оригинальные решения зависят от интеллектуальности и удобства программного поиска. Как и следовало ожидать, в мире вычислений полно понятий, имеющих отношение к поиску: сопоставление c шаблоном, реляционная алгебра, бинарный поиск, хеш-таблицы, бинарные деревья, префиксные деревья, красно-черные деревья, списки с пропусками… все это нам здесь никак не охватить, так что сейчас поставим цель поскромнее – определим несколько простых функций поиска на D, начав с простейшей из них – функции линейного поиска. Итак, без лишних слов напишем функцию, которая сообщает, содержит ли срез значений типа int
определенное значение типа int
.
bool find(int[] haystack, int needle)
{
foreach (v; haystack)
{
if (v == needle) return true;
}
return false;
}
Отлично. Поскольку это первое наше определение функции на D, опишем во всех подробностях, что именно она делает. Встретив определение функции find
, компилятор приведет ее к более низкоуровневому представлению – скомпилирует в двоичный код. Во время исполнения программы при вызове функции find
параметры haystack
и needle
1 передаются в нее по значению. Это вовсе не означает, что если вы передадите в функцию массив из миллиона элементов, то он будет полностью скопирован; как отмечалось в главе 4, тип int[]
(срез массива элементов типа int
), который также называют толстым указателем (fat pointer), – это на самом деле пара «указатель + длина» или пара «указатель + указатель», которая хранит только границы указанного фрагмента массива. Из раздела 4.1.4 понятно, что передать в функцию find
срез миллионного массива на самом деле означает передать в нее информацию, достаточную для получения адреса начала и конца этого среза. (Язык D и его стандартная библиотека широко поддерживают работу с контейнером через его маленького, ограниченного представителя, который знает, как перемещаться по контейнеру. Обычно такой представитель называется диапазоном.) Так что в итоге в функцию find
из вызывающего ее кода передаются только три машинных слова. Как только управление передано функции find
, она делает свое дело и возвращает логическое значение (обычно в регистре процессора), которое вызвавший ее код уже готов получить. Что ж, как ободряюще говорят в конце телешоу «Большой ремонт», завершив какую-то неимоверно сложную работу: «Вот и все, что нужно сделать».
Если честно, в устройстве find
есть кое-какие недостатки. Возвращаемое значение имеет тип bool
, это очень неинформативно; также требуется информация о позиции найденного элемента, например, для продолжения поиска. Можно было бы возвращать целое число (и какое-нибудь особое значение, например -1
, для случаев «элемент не найден»). Но хотя целые числа отлично подходят для доступа к элементам массива, занимающего непрерывную область памяти, они ужасно неэффективны с большинством других контейнеров (таких как связные списки). Чтобы добраться до n
-го элемента связного списка после того, как функция find
вернула n
, понадобится пройти по списку элемент за элементом, начиная с его головы – то есть проделать почти ту же работу, что и сама операция поиска! Так что возвращать целое число в качестве результата – плохая идея в случае любой структуры данных, кроме массива.
Есть один способ, который сработает с разнообразными контейнерами – массивами, связными списками и даже с файлами и сокетами. Надо сделать так, чтобы функция find
просто отщипывала по одному элементу («соломинке»?) от «стога сена» (haystack
), пока не обнаружит искомое значение, и возвращала то, что останется от haystack
. (Соответственно, если значение не найдено, функция find
вернет опустошенный haystack
.) Вот простая и обобщенная спецификация: «функция find(haystack, needle)
сужает структуру данных haystack
слева до тех пор, пока значение needle
не станет началом, или до тех пор, пока не закончатся элементы в haystack
, и затем возвращает остаток haystack»
. Давайте реализуем эту идею для типа int[]
.
int[] find(int[] haystack, int needle)
{
while (haystack.length > 0 && haystack[0] != needle)
{
haystack = haystack[1 .. $];
}
return haystack;
}
Обратите внимание: функция find
обращается только к первому элементу массива haystack
и последовательно присваивает исходному массиву более узкое подмножество его самого. Эти примитивы потом легко можно заменить, скажем, специфичными для списков примитивами, но обобщением мы займемся чуть позже. А пока проверим, насколько хорошо работает полученная функция find
.
В последние годы большинство методологий разработки уделяют все больше внимания правильному тестированию программного обеспечения. Это верное направление, поскольку тщательное тестирование действительно помогает отслеживать гораздо больше ошибок. В духе времени напишем короткий тест модуля, проверяющий работу нашей функции find
. Просто вставьте следующий код после (как это сделал я) или до (как сделал бы фанат разработки через тестирование) определения функции find
:
unittest
{
int[] a = [];
assert(find(a, 5) == []);
a = [ 1, 2, 3 ];
assert(find(a, 0) == []);
assert(find(a, 1).length == 3);
assert(find(a, 2).length == 2);
assert(a[0 .. $ - find(a, 3).length] == [ 1, 2 ]);
}
Все, что нужно сделать, чтобы получить работающий модуль, – это поместить функцию и тест модуля в файл searching.d
, а затем ввести в командной строке:
$ rdmd --main -unittest searching.d
Если вы запустите компилятор с флагом -unittest
, тесты модулей будут скомпилированы и подготовлены к запуску перед исполнением основной программы. Иначе компилятор проигнорирует все блоки unittest
, что может быть полезно, если требуется запустить уже оттестированный код без задержек на начальном этапе. Флаг --main
предписывает rdmd
добавить ничего не делающую функцию main
. (Если вы забыли написать --main
, не волнуйтесь; компоновщик тут же витиевато напомнит вам об этом на своем родном языке – зашифрованном клингонском.) Заменитель функции main
нужен нам, так как мы хотим запустить только тест модуля, а не саму программу. Ведь наш маленький файл может заинтересовать массу программистов, и они станут использовать его в своих проектах, в каждом из которых определена своя функция main
.
5.2. Соглашения о передаче аргументов и классы памяти
Как уже говорилось, в функцию find
передаются два аргумента (первый – типа int
, а второй – толстый указатель, представляющий массив типа int[]
), которые копируются в ее личные владения. Когда функция find
возвращает результат, толстый указатель копируется обратно в вызывающий код. В этой последовательности действий легко распознать явный вызов по значению. В частности, изменения аргументов не будут «видны» инициатору вызова после того, как управление снова перейдет к нему. Однако остерегаться побочного эффекта все-таки нужно: учитывая, что содержимое среза не копируется, изменения отдельных элементов среза будут видны инициатору вызова. Рассмотрим пример:
void fun(int x) { x += 42; }
void gun(int[] x) { x = [ 1, 2, 3 ]; }
void hun(int[] x) { x[0] = x[1]; }
unittest
{
int x = 10;
fun(x);
assert(x == 10); // Ничего не изменилось
int[] y = [ 10, 20, 30 ];
gun(y);
assert(y == [ 10, 20, 30 ]); // Ничего не изменилось
hun(y);
assert(y == [ 20, 20, 30 ]); // Изменилось!
}
Что же произошло? В первых двух случаях функции fun
и gun
изменили только собственные копии параметров. В частности, во втором случае толстый указатель был перенаправлен на другую область памяти, но исходный массив не был затронут. Однако в третьем случае функция hun
решила изменить один элемент массива, и это изменение отразилось на исходном массиве. Это легко понять, представив, что срез y находится совсем не в том же месте, что и три целых числа, которыми y управляет. Так что если вы присвоите срез целиком, а-ля x = [1, 2, 3]
, то срез, который раньше содержала переменная x
, будет предоставлен самому себе, а x
начнет новую жизнь; но если вы измените какой-то элемент x[i]
среза x
, то другие срезы, которым виден этот элемент (в нашем случае – в коде, вызвавшем fun
), будут видеть и это изменение.
5.2.1. Параметры и возвращаемые значения, переданные по ссылке (с ключевым словом ref)
Иногда нам действительно нужно, чтобы изменения были видны в вызывающем коде. В этом случае поможет класс памяти ref
:
void bump(ref int x) { ++x; }
unittest
{
int x = 1;
bump(x);
assert(x == 2);
}
Если функция ожидает значение по ссылке, то она принимает только «настоящие данные», а не временные значения. Все, что не является l-значением, отвергается во время компиляции. Например:
bump(5); // Ошибка! Нельзя передать r-значение по ссылке
Это предотвращает глупые ошибки – когда кажется, что дело сделано, а на самом деле вызов прошел безрезультатно.
Ключевым словом ref
можно также снабдить результат функции. В этом случае за ним самим будет закреплен статус l-значения. Например, изменим функцию bump
так:
ref int bump(ref int x) { return ++x; }
unittest
{
int x = 1;
bump(bump(x)); // Два увеличения на 1
assert(x == 3);
}
Внутренний вызов функции bump
возвращает l-значение, поэтому такой результат можно правомерно использовать в качестве аргумента при внешнем вызове той же функции. Если бы определение bump
выглядело так:
int bump(ref int x) { return ++x; }
то компилятор отверг бы вызов bump(bump(x))
как незаконную попытку привязать r-значение, возвращенное при вызове bump(x)
, параметру, передаваемому по ссылке при внешнем вызове bump
.
5.2.2. Входные параметры (с ключевым словом in)
Параметр с ключевым словом in считается предназначенным только для чтения, его нельзя изменить никаким способом. Например:
void fun(in int x)
{
x = 42; // Ошибка! Нельзя изменить параметр с ключевым словом in
}
Этот код не компилируется, то есть ключевое слово in
накладывает на код достаточно строгие ограничения. Функция fun
не может изменить даже собственную копию аргумента.
Практически неизменяемый параметр внутри функции, конечно, может быть полезен при анализе ее реализации, но еще более любопытный эффект наблюдается за пределами функции. Ключевое слово in
запрещает даже косвенные изменения параметра, то есть те изменения, которые отражаются на объекте после того, как функция вернет управление вызвавшему ее коду. Это делает неизменяемые параметры невероятно полезными, поскольку они дают гарантии инициатору вызова, а не только внутренней реализации функции. Например:
void fun(in int[] data)
{
data = new int[10]; // Ошибка! Нельзя изменить неизменяемый параметр
data[5] = 42; // Ошибка! Нельзя изменить неизменяемый параметр
}
В первом случае ошибка неудивительна, поскольку она того же типа, что и приведенная выше ошибка с изменением отдельного значения типа int
. Гораздо интереснее, почему возникла вторая ошибка. Неким магическим образом компилятор распространил действие ключевого слова in
с самого массива data
на все его ячейки – то есть in
обладает «глубоким» воздействием.
Ограничение, на самом деле, распространяется на любую глубину, а не только на один уровень. Проиллюстрируем сказанное примером с многомерным массивом:
// Массив массивов чисел имеет два уровня ссылок
void fun(in int[][] data)
{
data[5] = data[0]; // Ошибка! Нельзя изменить неизменяемый параметр
data[5][0] = data[0][5]; // Ошибка! Нельзя изменить неизменяемый параметр
}
Так что ключевое слово in
защищает свои данные от изменений транзитивно, полностью сверху донизу, учитывая все возможности косвенного доступа2. Такое поведение не является специфичным для массивов, оно распространяется на все типы данных языка D. В действительности, ключевое слово in
в контексте параметра – это синоним квалификатора типа const
3, подробно описанного в главе 8.
5.2.3. Выходные параметры (с ключевым словом out)
Иногда параметры передаются по ссылке только для того, чтобы функция с их помощью что-то вернула. В таких случаях можно воспользоваться классом памяти out
, напоминающим ref
, – разница лишь в том, что перед входом в функцию out
инициализирует свой аргумент значением по умолчанию (соответствующим типу аргумента):
// Вычисляет частное и остаток от деления для аргументов a и b.
// Возвращает частное по значению, а остаток – в параметре rem.
int divrem(int a, int b, out int rem)
{
assert(b != 0);
rem = a % b;
return a / b;
}
unittest
{
int r;
int d = divrem(5, 2, r);
assert(d == 2 && r == 1);
}
В этом коде можно было бы с тем же успехом вместо ключевого слова out
использовать ref
, поскольку выбор out
всего лишь извещает инициатора вызова, что функция divrem
не ожидает от параметра rem
осмысленного значения.
5.2.4. Ленивые аргументы (с ключевым словом lazy)4
Порой значение одного из аргументов функции требуется лишь в исключительном случае, а в остальных вычислять его не нужно и хотелось бы избежать напрасных усилий. Рассмотрим пример:
bool verbose; // Флаг, контролирующий отладочное журналирование
void log(string message)
{
// Если журналирование включено, выводим строку на экран
if (verbose)
writeln(message);
}
...
int result = foo(); log("foo() returned " ~ to!string(result));
Как видим, вычислять выражение "foo() returned " ~ to!string(result)
нужно, только если переменная verbose
имеет значение true
. При этом выражение, передаваемое этой функции в качестве аргумента, будет вычислено в любом случае. В данном примере это конкатенация двух строк, которая потребует выделения памяти и копирования в нее содержимого каждой из них. И все это для того, чтобы узнать, что переменная verbose
имеет значение false
и значение аргумента никому не нужно! Можно было бы передавать вместо строки делегат, возвращающий строку (делегаты описаны в разделе 5.6.1):
void log(string delegate() message)
{
if (verbose)
writeln(message());
}
...log({return "foo() returned " ~ to!string(result);});
В этом случае аргумент будет вычислен, только если он действительно нужен, но такая форма слишком громоздка. Поэтому D вводит такое понятие, как «ленивые» аргументы. Такие аргументы объявляются с атрибутом lazy
, выглядят как обычные аргументы, но вычисляются только тогда, когда требуется их значение.
void log(lazy string message)
{
if (verbose)
writeln(message); // Значение message вычисляется здесь
}
5.2.5. Статические данные (с ключевым словом static)
Несмотря на то что ключевое слово static
не имеет отношения к передаче аргументов функциям, разговор о нем здесь к месту, поскольку, как и ref
, атрибут static
данных определяет класс памяти, то есть несколько подробностей хранения этих данных.
Любое объявление переменной может быть дополнено ключевым словом static
. В этом случае для каждого потока исполнения будет создана собственная копия этой переменной. Рациональное обоснование и последствия этого отступления от установленной языком C традиции выделять единственную копию static
-переменной для всего приложения обсуждаются в главе 13.
Статические данные сохраняют свое значение между вызовами функций независимо от места их определения (внутри или вне функции). Выбор размещения статических данных в разнообразных контекстах касается только видимости, но не хранения. На уровне модуля данные с атрибутом static
в действительности обрабатываются так же, как и данные с атрибутом private
.
static int zeros; // Практически то же самое, что и private int zeros;
void fun(int x)
{
static int calls;
++calls;
if (!x) ++zeros;
...
}
Статические данные должны быть инициализированы константами5, вычисляемыми во время компиляции. Инициализировать статические данные уровня функции при первом ее вызове можно с помощью простого трюка, который использует в качестве напарника статическую логическую переменную:
void fun(double x)
{
static double minInput;
static bool minInputInitialized;
if (!minInputInitialized)
{
minInput = x;
minInputInitialized = true;
}
else
{
if (x < minInput) minInput = x;
}
...
}
5.3. Параметры типов
Вернемся к функции find
, определенной в разделе 5.1, поскольку в ней есть немало спорных моментов. Во-первых, эта функция может быть полезна только в довольно редких случаях, поэтому стоит поискать возможность ее обобщения. Начнем с простого наблюдения. Присутствие в find
типа int
– это пример жесткого кодирования, простого и ясного. В логике кода ничего не изменится, если придется искать значения типа double
в срезах типа double[]
или значения типа string
в срезах типа string[]
. Поэтому можно попробовать заменить тип int
некой заглушкой – параметром функции find
, который описывал бы тип, а не значение задействованных сущностей. Чтобы воплотить эту идею, нужно привести наше определение к следующему виду:
T[] find(T)(T[] haystack, T needle)
{
while (haystack.length > 0 && haystack[0] != needle)
{
haystack = haystack[1 .. $];
}
return haystack;
}
Как и ожидалось, тело функции find
не претерпело никаких изменений, изменилась только сигнатура. Теперь в ней две пары круглых скобок: в первой перечислены параметры типов функции, а вторая содержит обычный список параметров, которые могут воспользоваться только что определенными параметрами типов. Теперь можно обрабатывать не только срезы элементов типа int
, но срезы чего угодно (неважно, встроенные это или пользовательские типы). В довершение наш предыдущий тест unittest
продолжает работать, так как компилятор автоматически выводит тип T из типов аргументов. Чисто сработано! Но не станем почивать на лаврах и добавим тест модуля, который бы подтверждал оправданность этих повышенных ожиданий:
unittest
{
// Проверка способностей к обобщению
double[] d = [ 1.5, 2.4 ];
assert(find(d, 1.0) == null);
assert(find(d, 1.5) == d);
string[] s = [ "one", "two" ];
assert(find(s, "two") == [ "two" ]);
}
Что же происходит, когда компилятор видит усовершенствованное определение функции find
? Компилятор сталкивается с гораздо более сложной задачей, чем в случае с аргументом типа int[]
, потому что теперь T
еще неизвестен – это может быть какой угодно тип. А разные типы записываются по-разному, передаются по-разному и щеголяют разными определениями оператора ==
. Решить эту задачу очень важно, поскольку параметры типов действительно открывают новые перспективы и в разы расширяют возможности для повторного использования кода. В настоящее время наиболее распространены два подхода к генерации кода для параметризации типов:
- Гомогенная трансляция: все данные приводятся к общему формату, что позволяет скомпилировать единственную версию
find
, которая подойдет всем. - Гетерогенная трансляция: при каждом вызове
find
с различными аргументами типов (int
,double
,string
и т. д.) компилятор генерирует отдельную версиюfind
для каждого использованного типа.
Гомогенная трансляция подразумевает, что язык обязан предоставить универсальный интерфейс доступа к данным, которым воспользуется find
. А гетерогенная трансляция больше напоминает помощника, пишущего по одному варианту функции find
для каждого формата данных, который вам может встретиться, при этом все варианты он строит по одной заготовке. Очевидно, что у обоих этих подходов есть как преимущества, так и недостатки, о чем нередко ведутся жаркие споры в разных программистских сообществах. Плюсы гомогенной трансляции – универсальность, простота и компактность сгенерированного кода. Например, в чисто функциональных языках все представляется в виде списков, а во многих чисто объектно-ориентированных языках – в виде объектов; в обоих случаях предлагается универсальный доступ к данным. Тем не менее гомогенной трансляции свойственны такие недостатки, как строгость, недостаток выразительности и неэффективность. Гетерогенная трансляция, напротив, отличается специализированностью, выразительной мощью и скоростью сгенерированного кода. Плата за это – распухание готового кода, усложнение языка и неуклюжая модель компиляции (обычный упрек в адрес гетерогенных подходов – что они представляют собой «возвеличенный макрос» [вздох]; а поскольку благодаря C макрос считается чем-то нехорошим, этот ярлык придает гетерогенной компиляции сильный негативный оттенок).
Тут стоит обратить внимание на одну деталь: гетерогенная трансляция включает гомогенную по той простой причине, что «один формат» входит в «множество форматов», а «одна реализация» – в «множество реализаций». На этом основании (все прочие спорные моменты пока отложим) можно утверждать, что гетерогенная трансляция мощнее гомогенной. При наличии средства гетерогенной трансляции ничто не мешает, по крайней мере теоретически, использовать один универсальный формат данных и одну универсальную функцию, когда захочется. Обратное, при использовании гомогенного подхода, просто невозможно. Тем не менее наивно было бы считать гетерогенные подходы «лучшими», поскольку кроме выразительной мощи есть другие аргументы, которые также нельзя упускать из виду.
D использует гетерогенную трансляцию (внимание, ожидается бомбардировка техническими терминами) с поиском статически определенных идентификаторов и отложенной проверкой типов. Это означает, что, встретив определение обобщенной функции find
, компилятор D выполняет синтаксический разбор ее тела, сохраняет результаты, запоминает место определения функции – и больше ничего, до тех пор пока кто-нибудь не вызовет find
. В этот момент компилятор извлекает разобранное определение find
и пытается скомпилировать его, подставив тип, который инициатор вызова передал взамен T
. Если функция использует идентификаторы (символы), компилятор ищет их в том контексте, где была определена эта функция.
Если компилятор не смог сгенерировать функцию find
для этого конкретного типа, генерируется сообщение об ошибке. Что на самом деле довольно неприятно, поскольку исключение может возникнуть из-за незамеченной ошибки в find
. Зато теперь у нас есть веский повод прочесть следующий раздел, потому что find
содержит две ошибки – не функциональные, а связанные с обобщенностью: теперь понятно, что функция find
одновременно и излишне, и недостаточно обобщенна. Посмотрим, как работает этот дзэнский тезис.
5.4. Ограничения сигнатуры
Допустим, у нас есть массив с элементами типа double
, в котором мы хотим найти целое число. Казалось бы, все должно пройти довольно гладко:
double[] a = [ 1.0, 2.5, 2.0, 3.4 ];
a = find(a, 2); // Ошибка! Не определена функция find(double[], int)
Вот мы и в западне. В данной ситуации функция find
ожидает значение типа T[]
в качестве первого аргумента и значение типа T
в качестве второго. Тем не менее find
получает значение типа double[]
и значение типа int
, то есть T = double
и T = int
соответственно. Если мы достаточно пристально вглядимся в этот код, то, конечно же, заметим, что инициатор вызова в действительности хотел использовать в качестве T
тип double
и собирался реализовать свою задумку, рассчитывая на аккуратное неявное приведение значения типа int
к типу double
. Тем не менее заставлять язык пытаться комбинаторно выполнить сразу и неявное преобразование, и вывод типов – в общем случае рискованное предприятие, поэтому D все это проделать не пытается. Раз вы сказали T[]
и T
, то не можете передать double[]
и int
.
Похоже, нашей реализации функции find
недостает обобщенности, поскольку она требует, чтобы типы среза и искомого значения были идентичны. А на самом деле для заданного типа среза мы должны принимать любое значение, сравнимое с элементом среза с помощью оператора ==
.
Один параметр типа – хорошо, а два параметра типа – лучше:
T[] find(T, E)(T[] haystack, E needle)
{
while (haystack.length > 0 && haystack[0] != needle)
{
haystack = haystack[1 .. $];
}
return haystack;
}
Теперь функция проходит тест на ура. Но технически полученная функция find
лжет, поскольку заявляет, что принимает абсолютно любые T
и E
, в том числе их бессмысленные сочетания! Чтобы показать, почему эту неточность нужно считать проблемой, рассмотрим следующий вызов:
assert(find([1, 2, 3], "Hello")); // Ошибка! Сравнение haystack[0] != needle некорректно для int[] и string
Компилятор действительно обнаруживает проблему; однако находит он ее в сравнении, расположенном в теле функции find
. Это может смутить неосведомленного пользователя, поскольку неясно, где именно возникает ошибка: в месте вызова функции find
или в ее теле. (В частности, имя файла и номер строки, возвращенные в отчете компилятора, прямо указывают внутрь определения функции find
.) Если источник проблемы находится в конце длинной цепочки вызовов, ситуация становится еще более запутанной. Хотелось бы это исправить. Итак, в чем же корень всех бед? В переносном смысле, функция find
выписывает чеки, которые ее тело не может обналичить.
В своей сигнатуре (это часть кода до первой фигурной скобки {
) функция find
торжественно заявляет, что принимает срез любого типа T
и значение любого типа E
. Компилятор радостно с этим соглашается, отправляет в find
бессмысленные аргументы, устанавливает типы (T = int
и E = string
) и на этом успокаивается. Но как только дело доходит до тела find
, компилятор смущенно обнаруживает, что не может сгенерировать осмысленный код для сравнения haystack[0] != needle
, и выводит сообщение об ошибке примерно следующего содержания: «Функция find
откусила больше, чем может прожевать». Тело find
в действительности может принять только некоторые из всех возможных сочетаний типов T
и E
– те, которые можно проверять на равенство.
Можно было бы реализовать какой-то страховочный механизм. Но D выбрал другое решение: разрешить автору find
систематически ограничивать применимость функции. Верное место для указания ограничения такого рода – сигнатура функции find
, как раз там, где T
и E
появляются впервые. Для этого в D применяется ограничение сигнатуры (signature constraint):
T[] find(T, E)(T[] haystack, E needle)
if (is(typeof(haystack[0] != needle) == bool))
{
... // Реализация остается той же
}
Выражение if
в сигнатуре во всеуслышание заявляет, что функция find
примет параметр haystack
типа T[]
и параметр needle
типа E
, только если выражение haystack[0] != needle
возвращает логический тип. У этого ограничения есть ряд важных последствий. Во-первых, выражение if
проясняет для автора, компилятора и читателя, чего именно функция find
ждет от своих параметров, избавляя всех троих от необходимости исследовать тело функции (обычно куда более объемное, чем у нашей). Во-вторых, с выражением if
в качестве буксира функция find
теперь легко отклонит вызов при попытке передать параметры, не поддающиеся сравнению, что, в свою очередь, позволяет гладко срабатывать другим средствам языка, таким как перегрузка функций. В-третьих, новое определение помогает компилятору конкретизировать свои сообщения об ошибках: теперь очевидно, что ошибка происходит при обращении к функции find
, а не в ее теле.
Заметим, что выражение, к которому применяется оператор typeof
, никогда не вычисляется во время исполнения программы; оператор лишь определяет тип выражения, если оно скомпилируется. (Если выражение с оператором typeof
не компилируется, то это не ошибка компиляции, а просто сигнал, что рассматриваемое выражение не имеет никакого типа, а «никакого типа» – это не bool
.) В частности, не стоит беспокоиться о том, что в проверку вовлечено значение haystack[0]
, даже если длина haystack
равна нулю. И обратно: в ограничении сигнатуры запрещается использовать условия, не вычислимые во время компиляции программы; например, нельзя ограничить функцию find
условием needle > 0
.
5.5. Перегрузка
Мы определили функцию find
, чтобы определить срез и элемент. А теперь напишем новую версию функции find
, которая сообщает, можно ли найти один срез в другом. Обычный подход к решению этой проблемы – поиск полным перебором, с двумя вложенными циклами. Такой алгоритм не очень эффективен: время его работы пропорционально произведению длин рассматриваемых срезов. Но мы пока не будем беспокоиться об эффективности алгоритма, а сосредоточимся на определении хорошей сигнатуры для только что добавленной функции. Предыдущий раздел снабдил нас практически всем, что нужно. И действительно, сама собой напрашивается реализация:
T1[] find(T1, T2)(T1[] longer, T2[] shorter)
if (is(typeof(longer[0 .. 1] == shorter) : bool))
{
while (longer.length >= shorter.length)
{
if (longer[0 .. shorter.length] == shorter) break;
longer = longer[1 .. $];
}
return longer;
}
Ага! Как видите, на этот раз мы не попали в западню – не сделали функцию слишком специализированной. Не самое лучшее определение выглядело бы так:
// Нет! Эта сигнатура слишком строгая!
bool find(T)(T[] longer, T[] shorter)
{
...
}
Оно, конечно, немного короче, но зато на порядок строже. Наша реализация, не копируя данные, может сказать, содержит ли срез элементов типа int
срез элементов типа long
, а срез элементов типа double
– срез элементов типа float
. Упрощенной сигнатуре эти возможности были просто недоступны. Вам бы пришлось или повсюду копировать данные, чтобы гарантировать наличие на месте нужных типов, или вообще отказаться от затеи с общей функцией и выполнять поиск вручную. А что это за функция, если она хорошо смотрится в игрушечных примерах и не справляется с серьезной нагрузкой!
Поскольку мы добрались до реализации, заметим уже хорошо знакомое сужение среза longer
по одному элементу слева (во внешнем цикле). Задача внутреннего цикла – сравнение массивов longer[0 .. shorter.length] == shorter
, где сравниваются первые shorter.length
элементов среза longer
с элементами среза shorter
.
D поддерживает перегрузку функций: несколько функций могут разделять одно и то же имя, если отличаются числом аргументов или типом хотя бы одного из них. Во время компиляции правила языка определяют, какая именно функция должна быть вызвана. Перегрузка основана на нашей врожденной лингвистической способности избавляться от двусмысленности в значении слов, используя контекст. Это средство языка позволяет предоставить обширную функциональность, избегая соответствующего роста количества терминов, которые должен запомнить инициатор вызовов. С другой стороны, если правила выбора реализации функции при вызове слишком неопределенны, люди могут думать, что вызывают одну функцию, а на самом деле будут вызывать другую. А если упомянутые правила, наоборот, сделать слишком жесткими, программисту придется искажать логику своего кода, объясняя компилятору, какую функцию он имел в виду. D старается сохранить простоту правил, и в этом конкретном случае применяемое правило не является заумным: если вычисление ограничения сигнатуры функции (выражения if
) возвращает false
, функция просто удаляется из множества перегрузки – ее вообще перестают рассматривать как претендента на вызов. Для наших двух версий функции find
соответствующие выражения if
никогда не являются истинными одновременно (с одними и теми же аргументами). Так что при любом вызове find
по крайней мере один вариант перегрузки себя скрывает; никогда не возникает двусмысленность, над которой нужно ломать голову. Итак, продолжим ход своей мысли с помощью теста модуля:
unittest
{
// Проверим, как работает новая версия функции find
double[] d1 = [ 6.0, 1.5, 2.25, 3 ];
float[] d2 = [ 1.5, 2.25 ];
assert(find(d1, d2) == d1[1 .. $]);
}
Неважно, где расположены эти две функции find
: в одном или разных файлах; между ними никогда не возникнет соревнование, поскольку выражения if
в ограничениях их сигнатур никогда не являются истинными одновременно. Продолжая обсуждение правил перегрузки, представим, что мы очень много работаем с типом int[]
и хотим определить для него оптимизированный вариант функции find
:
int[] find(int[] longer, int[] shorter)
{
...
}
В этой записи версия функции find
не имеет параметров типа. Кроме того, вполне ясно, что между обобщенной версией find
, которую мы определили выше, и специализированной версией для целых значений происходит некое состязание. Каково относительное положение этих двух функций в пищевой цепи перегрузки и какой из них удастся захватить вызов ниже?
int[] ints1 = [ 1, 2, 3, 5, 2 ];
int[] ints2 = [ 3, 5 ];
auto test = find(ints1, ints2); // Корректно или ошибка? Обобщенная или специализированная?
Подход D к решению этого вопроса очень прост: выбор всегда падает на более специализированную функцию. Однако в более общем случае понятие «более специализированная» требует некоторого объяснения; оно подразумевает, что существует некоторое отношение порядка специализированности, «меньше или равно» для функций. И оно существует на самом деле; это отношение называется отношением частичного порядка на множестве функций (partial ordering of functions).
5.5.1. Отношение частичного порядка на множестве функций
Судя по названию, без черного пояса по матан-фу с этим не разобраться, а между тем отношение частичного порядка – очень простое понятие. Считайте это распространением знакомого нам числового отношения ≤ на другие множества, в нашем случае на множество функций. Допустим, есть две функции foo1
и foo2
, и нужно узнать, является ли foo1
чуть менее подходящей для вызова, чем foo2
(вместо «foo1
подходит меньше, чем foo2
» будем писать foo1
≤ foo2
). Если определить такое отношение, то у нас появится критерий, по которому можно определить, какая из функций выигрывает в состязании за вызов при перегрузке: при вызове foo
можно будет отсортировать всех претендентов с помощью отношения ≤ и выбрать самую «большую» из найденных функцию foo
. Чтобы частичный порядок работал в полную силу, это отношение должно быть рефлексивным (a
≤ a
), антисимметричным (если a
≤ b
и b
≤ a
, считается, что a
и b
идентичны) и транзитивным (если a
≤ b
и b
≤ c
, то a
≤ с
).
D определяет отношение частичного порядка на множестве функций очень просто: если функция foo1
может быть вызвана с типами параметров foo2
, то foo1
≤ foo2
. Возможны случаи, когда foo1
≤ foo2
и foo2
≤ foo1
одновременно; в таких ситуациях говорится, что функции одинаково специализированны. Например:
// Три одинаково специализированных функции: любая из них
// может быть вызвана с типом параметра другой
void sqrt(real);
void sqrt(double);
void sqrt(float)
Эти функции одинаково специализированны, поскольку любая из них может быть вызвана как с типом float
, так и с double
или real
(как ни странно, это разумно, несмотря на неявное преобразование с потерями, см. раздел 2.3.2).
Также возможно, что ни одна из функций не ≤ другой; в этом случае говорится, что foo1
и foo2
неупорядочены.6 Можно привести множество случаев неупорядоченности, например:
// Две неупорядоченные функции: ни одна из них
// не может быть вызвана с типом параметра другой.
void print(double);
void print(string);
Нас больше всего интересуют случаи, когда истинно ровно одно неравенство из пары foo1
≤ foo2
и foo2
≤ foo1
. Пусть истинно первое неравенство, тогда говорится, что функция foo1
менее специализированна, чем функция foo2
. А именно:
// Две упорядоченные функции: write(double) менее специализированна,
// чем write(int), поскольку первая может быть вызвана с int,
// а последняя не может быть вызвана с double.
void write(double);
void write(int);
Ввод отношения частичного порядка позволяет D принимать решение относительно перегруженного вызова foo(arg1, ..., argn)
по следующему простому алгоритму:
- Если существует всего одно соответствие (типы и количество параметров соответствуют списку аргументов), то использовать его.
- Сформировать множество кандидатов
{foo1, ..., fook}
, которые бы принимали вызов, если бы другие перегруженные версии вообще не существовали. Именно на этом шаге срабатывает механизм определения типов и вычисляются условия в ограничениях сигнатур. - Если полученное множество пусто, то выдать ошибку «нет соответствия».
- Если не все функции из сформированного множества определены в одном и том же модуле, то выдать ошибку «попытка кроссмодульной перегрузки».
- Исключить из множества претендентов на вызов все функции, менее специализированные по сравнению с другими функциями из этого множества; оставить только самые специализированные функции.
- Если оставшееся множество содержит больше одной функции, выдать ошибку «двусмысленный вызов».
- Единственный элемент множества – победитель.
Вот и все. Рассмотрим первый пример:
void transmogrify(uint) {}
void transmogrify(long) {}
unittest
{
transmogrify(42); // Вызывает transmogrify(uint)
}
Здесь нет точного соответствия, можно применить любую из функций, поэтому на сцене появляется частичное упорядочивание. Из него следует, что, несмотря на способность обеих функций принять вызов, первая из них более специализированна, поэтому победа присуждается ей. (Хорошо это или плохо, но int
автоматически приводится к uint
.) А теперь добавим в наш набор обобщенную функцию:
// То же, что и выше, плюс ...
void transmogrify(T)(T value) {}
unittest
{
transmogrify(42); // Как и раньше, вызывает transmogrify(uint)
transmogrify("hello"); // Вызывает transmogrify(T), T=string
transmogrify(1.1); // Вызывает transmogrify(T), T=double
}
Что же происходит, когда функция transmogrify(uint)
сравнивается с функцией transmogrify(T)(T)
на предмет специализированности? Хотя было решено, что T = int
, во время сравнения T
не заменяется на int
, обобщенность сохраняется. Может ли функция transmogrify(uint)
принять некоторый произвольный тип T
? Нет, не может. Поэтому можно сделать вывод, что версия transmogrify(T)(T)
менее специализированна, чем transmogrify(uint)
, так что обобщенная функция исключается из множества претендентов на вызов. Итак, в общем случае предпочтение отдается необобщенным функциям, даже когда для их применения требуется неявное приведение типов.
5.5.2. Кроссмодульная перегрузка
Четвертый шаг алгоритма из предыдущего раздела заслуживает особого внимания. Вот немного измененный пример с перегруженными версиями для типов uint
и long
(разница лишь в том, что задействовано больше файлов):
// В модуле calvin.d
void transmogrify(long) { ... }
// В модуле hobbes.d
void transmogrify(uint) { ... }
// Модуль client.d
import calvin, hobbes;
unittest
{
transmogrify(42);
}
Перегруженная версия transmogrify(uint)
из модуля hobbes.d
является более специализированной; но компилятор все же отказывается вызвать ее, диагностируя двусмысленность. D твердо отвергает кроссмодульную перегрузку. Если бы такая перегрузка была разрешена, то значение вызова зависело бы от взаимодействия множества включенных модулей (в общем случае может быть много модулей, много перегруженных версий и больше сложных вызовов, за которые будет вестись борьба). Представьте: вы добавляете в работающий код всего одну новую команду import
– и его поведение изменяется непредсказуемым образом! Кроме того, если разрешить кроссмодульную перегрузку, читать код явно станет на порядок труднее: чтобы выяснить, какая функция будет вызвана, нужно будет знать, что содержит не один модуль, а все включенные модули, поскольку в каком-то из них может быть определено лучшее соответствие. И даже хуже: если бы имел значение порядок определений на верхнем уровне, вызов вида transmogrify(5)
мог бы в действительности завершиться вызовом различных функций в зависимости от их расположения в файле. Кроссмодульная перегрузка – это неиссякаемый источник проблем, поскольку подразумевает, что при чтении фрагмента кода нужно постоянно держать в голове большой меняющийся контекст.
Один модуль может содержать группу перегруженных версий, реализующих нужную функциональность для разных типов. Второй модуль может вторгнуться, только чтобы добавить что-то новое к этой функциональности. Однако второй модуль может определять собственную группу перегруженных версий. Пока функция в одном модуле не начинает угонять вызовы, которые по праву должны были принадлежать функциям другого модуля, двусмысленность не возникает. До вызова функции нет возможности узнать, существует ли конфликт. Рассмотрим пример:
// В модуле calvin.d
void transmogrify(long) { ... }
void transmogrify(uint) { ... }
// В модуле hobbes.d
void transmogrify(double) { ... }
// В модуле susie.d
void transmogrify(int[]) { ... }
void transmogrify(string) { ... }
// Модуль client.d
import calvin, hobbes, susie;
unittest
{
transmogrify(5); // Ошибка! кроссмодульная перегрузка, затрагивающая модули calvin и hobbes.
calvin.transmogrify(5); // Все в порядке, точное требование, вызвана calvin.transmogrify(uint)
transmogrify(5.5); // Все в порядке, только hobbes может принять этот вызов.
transmogrify("привет"); // Привет от Сьюзи
}
Кельвин, Хоббс и Сьюзи взаимодействуют интересными способами. Обратите внимание, насколько тонки различия между двусмысленностями в примере; первый вызов порождает конфликт между модулями calvin.d
и hobbes.d
, но это совершенно не значит, что эти модули взаимнонесовместимы: третий вызов проходит гладко, поскольку ни одна функция в других модулях не в состоянии обслужить его. Наконец, модуль susie.d
определяет собственные перегруженные версии и никогда не конфликтует с остальными двумя модулями (в отличие от одноименных персонажей комикса7).
Управление перегрузкой
Где бы вы ни встретили двусмысленность из-за кроссмодульной перегрузки, вы всегда можете указать направление перегрузки одним из двух основных способов. Первый – уточнить свою мысль, снабдив имя функции именем модуля, как это показано на примере второго вызова calvin.transmogrify(5)
. Поступив так, вы ограничите область поиска функции единственным модулем calvin.d
. Внутри этого модуля также действуют правила перегрузки. Более очевидный способ – назначить проблемному идентификатору локальный псевдоним. Например:
// Внутри calvin.d
import hobbes;
alias hobbes.transmogrify transmogrify;
Эта директива делает нечто весьма интересное: она свозит все перегруженные версии transmogrify
из модуля hobbes.d
в модуль calvin.d
. Так что если модуль calvin.d
содержит упомянутую директиву, то можно считать, что, помимо собственных перегруженных версий, он определяет все перегруженные версии, которые определял hobbes.d
. Это очень мило со стороны модуля calvin.d
: он демократично советуется с модулем hobbes.d
всякий раз, когда нужно принять решение, какая версия transmogrify
должна быть вызвана. Иначе, если бы модулям calvin.d
и hobbes.d
не повезло и они решили бы игнорировать существование друг друга, модуль client.d
все равно мог бы вызвать transmogrify
, назначив псевдонимы обеим перегруженным версиям (и calvin.transmogrify
, и hobbes.transmogrify
).
// Внутри client.d
alias calvin.transmogrify transmogrify;
alias hobbes.transmogrify transmogrify;
Теперь при любом вызове transmogrify
из модуля client.d
решение о перегрузке будет приниматься так, будто перегруженные версии transmogrify
, определенные в модулях calvin.d
и hobbes.d
, присутствуют в модуле client.d
.
5.6. Функции высокого порядка. Функциональные литералы
Мы уже знаем, как найти элемент или срез в другом срезе. Однако под поиском не всегда подразумевается просто поиск заданного значения. Задача может быть сформулирована и так: «Найти в массиве чисел первый отрицательный элемент». Несмотря на все свое могущество, наша библиотека поиска не в состоянии выполнить это задание.
Основная идея функции find
в том, что она ищет значение, удовлетворяющее некоторому логическому условию, или предикату; до сих пор в роли предиката всегда выступало сравнение на равенство (оператор ==
). Однако более гибкая функция find
может принимать предикат от пользователя и выстраивать логику линейного поиска вокруг него. Если удастся наделить функцию find
такой мощью, она превратится в функцию высокого порядка, то есть функцию, которая может принимать другие функции в качестве аргументов. Это очень мощный подход к решению задач, поскольку объединяя собственную функциональность и функциональность, предоставляемую ее аргументами, функция высокого порядка достигает гибкости поведения, недоступной простым функциям. Чтобы заставить функцию find
принимать предикат, воспользуемся параметром-псевдонимом.
T[] find(alias pred, T)(T[] input)
if (is(typeof(pred(input[0])) == bool))
{
for (; input.length > 0; input = input[1 .. $])
{
if (pred(input[0])) break;
}
return input;
}
Эта новая перегруженная версия функции find
принимает не только «классический» параметр, но и загадочный параметр-псевдоним alias pred
. Параметру-псевдониму можно поставить в соответствие любой аргумент: значение, тип, имя функции – все, что можно выразить знаками. А теперь посмотрим, как вызывать эту новую перегруженную версию функции find
.
unittest
{
int[] a = [ 1, 2, 3, 4, -5, 3, -4 ]; // Найти первое отрицательное число
auto b = find!(function bool(int x) { return x < 0; })(a);
}
На этот раз функция find
принимает два списка аргументов. Первый список отличается синтаксисом !(...)
и содержит обобщенные аргументы. Второй список содержит классические аргументы. Обратите внимание: несмотря на то что функция find
объявляет два обобщенных параметра (alias pred
и T
), вызывающий ее код указывает только один аргумент. Вызов имеет такой вид, поскольку никто не отменял работу механизма определения типов: по контексту автоматически определяется, что T = int
. До этого момента при наших вызовах find
никогда не возникало необходимости указывать какие-либо обобщенные аргументы: компилятор определял их за нас. Однако на этот раз автоматически определить pred
невозможно, поэтому мы указали его в виде функционального литерала. Функциональный литерал – это запись
function bool(int x) { return x < 0; }
где function
– ключевое слово, а все остальное – обычное определение функции, только без имени.
Функциональные литералы (также известные как анонимные функции, или лямбда-функции) очень полезны во множестве ситуаций, однако их синтаксис сложноват. Длина литерала в наше примере – 41 знак, но только около 5 знаков занимаются настоящим делом. Чтобы решить эту проблему, D позволяет серьезно урезать синтаксис. Первое сокращение – это уничтожение возвращаемого типа и типов параметров: компилятор достаточно умен, чтобы определить их все, поскольку тело анонимной функции всегда под рукой.
auto b = find!(function(x) { return x < 0; })(a);
Второе сокращение – изъятие собственно ключевого слова function
. Можно применять оба сокращения одновременно, как это сделано здесь (получается очень сжатая форма записи):
auto b = find!((x) { return x < 0; })(a);
Эта запись абсолютно понятна для посвященных, в круг которых вы вошли пару секунд назад.
5.6.1. Функциональные литералы против литералов делегатов
Важное требование к механизму лямбда-функций: он должен разрешать доступ к контексту, в котором была определена лямбда-функция. Рассмотрим слегка измененный вариант:
unittest
{
int[] a = [ 1, 2, 3, 4, -5, 3, -4 ];
int z = -2;
// Найти первое число меньше z
auto b = find!((x) { return x < z; })(a);
assert(b == a[4 .. $]);
}
Этот видоизмененный пример работает, что уже о многом говорит. Но если, просто ради эксперимента, вставить перед функциональным литералом ключевое слово, код загадочным образом перестает работать!
auto b = find!(function(x) { return x < z; })(a); // Ошибка! Функция не может получить доступ к кадру стека вызывающей функции!
Что же происходит и что это за жалоба о кадре стека? Очевидно, должен быть какой-то внутренний механизм, с помощью которого функциональный литерал получает доступ к переменной z
– он не может чудом добыть ее расположение из воздуха. Этот механизм закодирован в виде скрытого параметра – указателя на кадр стека, принимаемого литералом. Компилятор использует указатель на кадр стека, чтобы осуществлять доступ к внешним переменным, таким как z
. Тем не менее функциональному литералу, который не использует никаких локальных переменных, не требуется дополнительный параметр. Будучи статически типизированным языком, D должен различать эти случаи, и он действительно различает их. Кроме функциональных литералов есть еще литералы делегатов, которые создаются так:
unittest
{
int z = 3;
auto b = find!(delegate(x) { return x < z; })(a); // OK
}
В отличие от функций, делегаты имеют доступ к включающему их фрейму. Если в литерале нет ключевых слов function
и delegate
, компилятор автоматически определяет, какое из них подразумевалось. И снова на помощь приходит механизм определения типов по контексту, позволяя самому сжатому, самому удобному коду еще и автоматически делать то, что нужно.
auto f = (int i) {};
assert(is(f == function));
5.7. Вложенные функции
Теперь можно вызывать функцию find
с произвольным функциональным литералом, что довольно изящно. Но если литерал сильно разрастается или появляется желание использовать его несколько раз, становится неудобно писать тело функции в месте ее вызова (предположительно несколько раз). Хотелось бы вызывать find
с именованной функцией (а не анонимной); кроме того, желательно сохранить право доступа к локальным переменным на случай, если понадобится к ним обратиться. Для этой и многих других задач D предоставляет такое средство, как вложенные функции.
Определение вложенной функции выглядит точно так же, как определение обычной функции, за исключением того, что вложенная функция объявляется внутри другой функции. Например:
void transmogrify(int[] input, int z)
{
// Вложенная функция
bool isTransmogrifiable(int x)
{
if (x == 42)
{
throw new Exception("42 нельзя трансмогрифировать");
}
return x < z;
}
// Найти первый изменяемый элемент в массиве input
input = find!(isTransmogrifiable)(input);
...
// ...и снова
input = find!(isTransmogrifiable)(input);
...
}
Вложенные функции могут быть очень полезны во многих ситуациях. Не делая ничего свыше того, что может сделать обычная функции, вложенная функция повышает удобство и модульность, поскольку расположена прямо внутри функции, которая ее использует, и имеет доступ к ее контексту. Последнее преимущество особенно важно; если бы в рассмотренном примере нельзя было воспользоваться вложенностью, получить доступ к z
было бы гораздо сложнее.
Применив тот же трюк, что и функциональный литерал (скрытый параметр), вложенная функция isTransmogrifiable
получает доступ к фрейму стека своего родителя, в частности к переменной z
. Иногда может понадобиться заведомо избежать таких обращений к родительскому фрейму, превратив isTransmogrifiable
в самую обычную функцию, за исключением места ее определения (внутри transmogrify
). Для этого просто добавьте перед определением isTransmogrifiable
ключевое слово static
(а какое еще?):
void transmogrify(int[] input, int z)
{
static int w = 42;
// Вложенная обычная функция
static bool isTransmogrifiable(int x)
{
if (x == 42)
{
throw new Exception("42 нельзя трансмогрифировать ");
}
return x < w; // Попытка обратиться к z вызвала бы ошибку
}
...
}
Теперь, с ключевым словом static
в качестве буксира, функции isTransmogrifiable
доступны лишь данные, определенные на уровне модуля, и данные внутри transmogrify
, также помеченные ключевым словом static
(как показано на примере переменной w
). Любые данные, которые могут изменяться от вызова к вызову, такие как параметры функций или нестатические переменные, недоступны (но, разумеется, могут быть переданы явно).
5.8. Замыкания
Как уже говорилось, alias
– это чисто символическое средство; все, что оно делает, – придает одному идентификатору значение другого. В нашем предыдущем примере pred
– это не настоящее значение, так же как и имя функции – это не значение; pred
нельзя ничего присвоить. Если требуется создать массив функций (например, последовательность команд), ключевое слово alias
не поможет. Здесь определенно нужно что-то еще, и это не что иное, как возможность иметь осязаемый объект функции, который можно записывать и считывать, сильно напоминающий указатель на функцию в C.
Рассмотрим, например, такую непростую задачу: «Получив значение x
типа T
, возвратить функцию, которая находит первое значение, равное x
, в массиве элементов типа T
». Подобное химически чистое, косвенное определение типично для функций высокого порядка: вы ничего не делаете сами, а только возвращаете то, что должно быть сделано. То есть нужно написать функцию, которая (внимание) возвращает другую функцию, которая, в свою очередь, принимает параметр типа T[]
и возвращает значение типа T[]
. Итак, возвращаемый тип функции, которую мы собираемся написать, – T[] delegate(T[])
. Почему delegate
, а не function
? Как отмечалось выше, вдобавок к своим аргументам делегат получает доступ еще и к состоянию, в котором он определен, а функция – только к аргументам. А наша функция как раз должна обладать некоторым состоянием, поскольку необходимо как-то сохранять значение x
.
Это очень важный момент, поэтому его следует подчеркнуть. Представьте, что тип T[] function(T[])
– это просто адрес функции (одно машинное слово). Эта функция обладает доступом только к своим параметрам и глобальным переменным программы. Если передать двум указателям на одну и ту же функцию одни и те же аргументы, они получат доступ к одному и тому же состоянию программы. Любой, кто пробовал работать с обратными вызовами (callbacks) C – например, для оконных систем или запуска потоков, – знаком с вечной проблемой: указатели на функции не имеют доступа к собственному локальному состоянию. Способ, который обычно применяется в C для того, чтобы обойти эту проблему, – использование параметра типа void*
(нетипизированный адрес), через который и передается информация о состоянии. Другие системы обратных вызовов, вроде старой капризной библиотеки MFC, сохраняют дополнительное состояние в глобальном ассоциативном массиве, третьи, такие как Active Template Library (ATL), динамически создают новые функции с помощью ассемблера. Везде, где необходимо взаимодействовать с обратными вызовами C, применяются некоторые решения, позволяющие обратным вызовам получать доступ к локальным состояниям; это далеко не простая задача.
С ключевым словом delegate
все эти проблемы испаряются. Делегаты достигают этого ценой своего размера: делегат хранит указатель на функцию и указатель на окружение этой функции. Хотя это и больше по весу и порой медленнее, но в то же время и значительно мощнее. Так что в собственных разработках гораздо предпочтительнее использовать делегаты, а не функции. (Конечно же, функция вида function
незаменима при взаимодействии с C через обратные вызовы.)
Теперь, когда уже так много сказано, попробуем написать новую функцию – finder
. Не забудем, что вернуть нужно T[] delegate(T[])
.
import std.algorithm;
T[] delegate(T[]) finder(T)(T x)
if (is(typeof(x == x) == bool))
{
return delegate(T[] a) { return find(a, x); };
}
unittest
{
auto d = finder(5);
assert(d([1, 3, 5, 7, 9]) == [ 5, 7, 9 ]);
d = finder(10);
assert(d([1, 3, 5, 7, 9]) == []);
}
Трудно не согласиться, что такие вещи, как две команды return
в одной строке, для непосвященных всегда будут выглядеть странновато. Что ж, при первом знакомстве причудливой наверняка покажется не только эта функция высокого порядка. Так что начнем разбирать функцию finder
построчно: она параметризирована с помощью типа T
, принимает обычный параметр типа T
и возвращает значение типа T[] delegate(T[])
; кроме того, на T
налагается ограничение: два значения типа T
должны быть сравнимы, а результат сравнения должен быть логическим. (Как и раньше, «глупое» сравнение x == x
здесь только ради типов, а не для каких-то определенных значений.) Затем finder
разумно делает свое дело, возвращая литерал делегата. У этого литерала короткое тело, в котором вызывается наша ранее определенная функция find
, завершающая выполнение условий поставленной задачи. Возвращенный делегат называется замыканием (closure).
Порядок использования функции finder
ожидаем: ее вызов возвращает делегат, который потом можно вызвать и которому можно присваивать новые значения. Переменная d
, определенная в тесте модуля, имеет тип T[] delegate(T[])
, но благодаря ключевому слову auto
этот тип можно не указывать явно. На самом деле, если быть абсолютно честным, с помощью ключевого слова auto
можно сократить и определение finder
; все типы присутствовали в нем лишь для облегчения понимания примера. Вот гораздо более краткое определение функции finder
:
auto finder(T)(T x) if (is(typeof(x == x) == bool))
{
return (T[] a) { return find(a, x); };
}
Обратите внимание на использование ключевого слова auto
вместо возвращаемого типа функции, а также на то, что ключевое слово delegate
опущено; компилятор с радостью позаботится обо всем этом за нас. Тем не менее в литерале делегата запись T[]
указать необходимо. Ведь компилятор должен за что-то зацепиться, чтобы сотворить волшебство, обещанное ключевым словом auto
: возвращаемый тип делегата определяется по типу функции find(a, x)
, который, в свою очередь, определяется по типам a
и x
; в результате такой цепочки выводов делегат приобретает тип T[] delegate(T[])
, этот же тип возвращает функция finder
. Без знания типа a
вся эта цепочка рассуждений не может быть осуществима.
5.8.1. Так, это работает... Стоп, не должно... Нет, все же работает!
Наш тест модуля unittest
помогает исследовать поведение функции finder
, но, конечно же, не доказывает корректность ее работы. Важный и совсем неочевидный вопрос: возвращаемый функцией finder
делегат использует значение x
, а где находится x
после того, как finder
вернет управление? На самом деле, в этом вопросе слышится серьезное опасение за происходящее (ведь D использует для вызова функций обычный стек вызовов): инициатор вызова вызывает функцию finder
, х отправляется на вершину стека вызовов, функция finder
возвращает результат, стек восстанавливает свое состояние до вызова finder
... а значит, возвращенный функцией finder
делегат использует для доступа адрес в стеке, по которому уже нет нужного значения!
«Продолжительность жизни» локального окружения (в нашем случае окружение состоит только из x, но оно может быть сколь угодно большим) – это классическая проблема реализации замыканий, и каждый язык, поддерживающий замыкания, должен ее как-то решать. В языке D применяется следующий подход8. В общем случае все вызовы используют обычный стек. А обнаружив замыкание, компилятор автоматически копирует используемый контекст в кучу и устанавливает связь между делегатом и областью памяти в куче, позволяя ему использовать расположенные в ней данные. Выделенная в куче память подлежит сбору мусора.
Недостаток такого подхода в том, что каждый вызов finder
порождает новое требование выделить память. Тем не менее замыкания очень выразительны и позволяют применить многие интересные парадигмы программирования, поэтому в большинстве случаев затраты более чем оправданны.
5.9. Не только массивы. Диапазоны. Псевдочлены
Раздел 5.3 закончился загадочным утверждением: «функция find
одновременно и излишне, и недостаточно обобщенна». Затем мы узнали, почему функция find
излишне обобщенна, и исправили эту ошибку, наложив дополнительные ограничения на типы ее параметров. Пришло время выяснить, почему эта функция все же недостаточно обобщенна.
В чем смысл линейного поиска? В поисках заданного значения или значения, удовлетворяющего заданному условию, просматриваются элементы указанной структуры данных. Проблема в том, что до сих пор мы работали только с непрерывными массивами (срезами, встречающимися в нашем определении find
в виде T[]
), но к понятию линейного поиска непрерывность не имеет никакого отношения. (Она имеет отношение только к механизмам организации просмотра.) Ограничившись типом T[]
, мы лишили функцию find
доступа ко множеству других структур данных, с которыми может работать алгоритм линейного поиска. Язык, предлагающий, к примеру, сделать find
методом некоторого типа Array
(«массив»), вполне заслуживает вашего скептического взгляда. Это не значит, что решить задачу с помощью этого языка невозможно; просто наверняка поработать пришлось бы гораздо больше, чем это необходимо.
Пора начать все с нуля, пересмотрев нашу базовую реализацию find
. Для удобства приведем ее здесь:
T[] find(T)(T[] haystack, T needle)
{
while (haystack.length > 0 && haystack[0] != needle)
{
haystack = haystack[1 .. $];
}
return haystack;
}
Какие основные операции мы применяем к массиву haystack
и что означает каждая из них?
haystack.length > 0
сообщает, остались ли еще элементы вhaystack
.haystack[0]
осуществляет доступ к первому элементуhaystack
.haystack = haystack[1 .. $]
исключает из рассмотрения первый элементhaystack
.
Конкретный способ, каким массивы реализуют эти операции, непросто распространить на другие контейнеры. Например, проверять с помощью выражения haystack.length > 0
, есть ли в односвязном списке элементы, – подход, достойный премии Дарвина9. Если не обеспечено постоянное кэширование длины списка (что по многим причинам весьма проблематично), то для вычисления длины списка таким способом потребуется время, пропорциональное самой длине списка, а быстрое обращение к началу списка занимает всего лишь несколько машинных инструкций. Применить к спискам индексацию – столь же проигрышная идея. Так что выделим сущность рассмотренных операций, представим полученный результат в виде трех именованных функций и оставим их реализацию типу haystack
. Примерный синтаксис базовых операций, необходимых для реализации алгоритма линейного поиска:
haystack.empty
– для проверкиhaystack
на пустоту.haystack.front
– для получения первого элементаhaystack
.haystack.popFront()
– для исключения из рассмотрения первого элементаhaystack
.
Обратите внимание: первые две операции не изменяют haystack
и потому не используют круглые скобки, третья же операция изменяет haystack
, и синтаксически это отражено в виде скобок ()
. Переопределим функцию find
, применив в ее определении новый блестящий синтаксис:
R find(R, T)(R haystack, T needle)
if (is(typeof(haystack.front != needle) == bool))
{
while (!haystack.empty && haystack.front != needle)
{
haystack.popFront();
}
return haystack;
}
Было бы неплохо сейчас погреться в лучах этого благотворного определения, если бы не суровая реальность: тесты модулей не проходят. Да и могло ли быть иначе, когда встроенный тип среза T[
] и понятия не имеет о том, что нас внезапно осенило и мы решили определить новое множество базовых операций с произвольными именами empty
, front
и popFront
. Мы должны определить их для всех типов T[]
. Естественно, все они будут иметь простейшую реализацию, но они все равно нам нужны, чтобы заставить нашу милую абстракцию снова заработать с тем типом данных, с которого мы начали.
5.9.1. Псевдочлены и атрибут @property
Наша синтаксическая проблема заключается в том, что все вызовы функций до сих пор выглядели как функция(аргумент)
, а теперь мы хотим определить такие вызовы: аргумент.функция()
и аргумент.функция
, то есть вызов метода и обращение к свойству соответственно. Как мы узнаем из следующего раздела, для пользовательских типов они определяются довольно-таки просто, но T[]
– это встроенный тип. Как же быть?
Язык D видит в этом чисто синтаксическую проблему и разрешает ее посредством нотации псевдочленов: если компилятор встретит запись a.функция(b, c, d)
, где функция
не является членом типа значения a, он заменит этот вызов на функция(a, b, c, d)
10 и попытается обработать вызов в этой новой форме. (При этом попытки обратного преобразования не предпринимаются: если вы напишете функция(a, b, c, d)
и это окажется бессмыслицей, версия a.функция(b, c, d)
не проверяется.) Предназначение псевдометодов – позволить вызывать обычные функции с помощью знакомого кому-то из нас синтаксиса «отправить-сообщение-объекту». Итак, без лишних слов реализуем empty
, front
и popFront
для встроенных массивов. Для этого хватит трех строк:
@property bool empty(T)(T[] a) { return a.length == 0; }
@property ref T front(T)(T[] a) { return a[0]; }
void popFront(T)(ref T[] a) { a = a[1 .. $]; }
С помощью ключевого слова @property
объявляется атрибут, называемый свойством (property). Атрибут всегда начинается со знака @
и просто свидетельствует о том, что у определяемого символа есть определенные качества. Одни атрибуты распознаются компилятором, другие определяет и использует только сам программист11. В частности, атрибут «property» распознается компилятором и сигнализирует о том, что функция, обладающая этим атрибутом, вызывается без ()
после ее имени.12
Также обратите внимание на использование в двух местах ключевого слова ref
(см. раздел 5.2.1). Во-первых, оно употребляется при определении возвращаемого типа front
; смысл в том, чтобы позволить вам изменять элементы массива, если вы того пожелаете. Во вторых, ref
использует функция popFront
, чтобы гарантировать непосредственное изменение среза.
Благодаря этим трем простым определениям модифицированная функция find
компилируется и запускается без проблем, что доставляет огромное удовлетворение; мы обобщили функцию find
так, что теперь она будет работать с любым типом, для которого определены функции empty
, front
и popFront
, а затем завершили круг, применив обобщенную версию функции для решения той задачи, которая и послужила толчком к обобщению. Если три базовые функции для работы с T
будут подвергнуты инлайнингу (inlining)13, обобщенная версия find
останется такой же эффективной, как и ее предыдущая ущербная реализация, работающая только со срезами.
Если бы функции empty
, front
и popFront
были полезны исключительно в определении функции find
, то полученная абстракция оказалась бы не особенно впечатляющей. Ладно, нам удалось применить ее к find
, но пригодится ли тройка empty-front-popFront
, когда мы задумаем определить другую функцию, или придется начинать все с нуля и писать другие примитивы? К счастью, обширный опыт показывает, что в понятии обобщенного доступа к коллекции данных определенно есть нечто фундаментальное. Это понятие настолько полезно, что было увековечено в виде паттерна «Итератор» в знаменитой книге «Паттерны проектирования»; библиотека C++ STL усовершенствовала это понятие, определив концептуальную иерархию итераторов: итератор ввода, однонаправленный итератор, двунаправленный итератор, итератор произвольного доступа.
В терминах языка D абстрактный тип данных, позволяющий перемещаться по коллекции элементов, – это диапазон (range). (Название «итератор» тоже подошло бы, но этот термин уже приобрел определенное значение в контексте ранее созданных библиотек, поэтому его использование могло бы вызвать путаницу.) У диапазонов D больше сходства с шаблоном «Итератор», чем с итераторами библиотеки STL (диапазон D можно грубо смоделировать с помощью пары итераторов из STL); тем не менее диапазоны D наследуют разбивку по категориям, определенную для итераторов STL. В частности, тройка empty-front-popFront
определяет диапазон ввода (input range); в результате поиск хорошей реализации функции find
привел нас к открытию сложного отношения между линейным поиском и диапазонами ввода: нельзя реализовать линейный поиск в структуре данных с меньшей функциональностью, чем у диапазона ввода, но было бы ошибкой вдруг потребовать от вашей коллекции большей функциональности, чем у диапазона ввода (например, не стоит требовать массивов с индексированным доступом к элементам). Практически идентичную реализацию функции find
можно найти в модуле std.algorithm
стандартной библиотеки.
5.9.2. Свести – но не к абсурду
Как насчет непростой задачи, использующей только диапазоны ввода? Условия звучат так: определить функцию reduce
14, которая принимает диапазон ввода r
, операцию fun
и начальное значение x
, последовательно рассчитывает x = fun(x, e)
для каждого элемента e
из r
и возвращает x
. Функция высокого порядка reduce
весьма могущественна, поскольку позволяет выразить множество интересных сверток. Эта функция – одно из основных средств многих языков программирования, позволяющих создавать функции более высокого порядка. В них она носит имена accumulate
, compress
, inject
, foldl
и т. д. Разработку функции reduce
начнем с определения нескольких тестов модулей – в духе разработки через тестирование:
unittest
{
int[] r = [ 10, 14, 3, 5, 23 ];
// Вычислить сумму всех элементов
int sum = reduce!((a, b) { return a + b; })(0, r);
assert(sum == 55);
// Вычислить минимум
int min = reduce!((a, b) { return a < b ? a : b; })(r[0], r);
assert(min == 3);
}
Как можно заметить, функция reduce
очень гибка и полезна – конечно, если закрыть глаза на маленький нюанс: эта функция еще не существует. Поставим цель реализовать reduce
так, чтобы она работала в соответствии с определенными выше тестами. Теперь мы знаем достаточно, чтобы с самого начала написать крепкий, «промышленный» вариант функции reduce
: в разделе 5.3 показано, как передать в функцию аргументы; раздел 5.4 научил нас накладывать на reduce
ограничения, чтобы она принимала только осмысленные аргументы; в разделе 5.6 мы видели, как можно передать в функцию функциональные литералы через параметры-псевдонимы; а сейчас мы вплотную подошли к созданию элегантного и простого интерфейса диапазона ввода.
V reduce(alias fun, V, R)(V x, R range)
if (is(typeof(x = fun(x, range.front)))
&& is(typeof(range.empty) == bool)
&& is(typeof(range.popFront())))
{
for (; !range.empty; range.popFront())
{
x = fun(x, range.front);
}
return x;
}
Скомпилируйте, запустите тесты модулей, и вы увидите, что все проверки пройдут прекрасно. И все же гораздо симпатичнее было бы определение reduce
, где ограничения сигнатуры не достигали бы объема самой реализации. Кроме того, стоит ли писать нудные проверки, чтобы удостовериться, что R
– это диапазон ввода? Столь многословные ограничения – это скрытое дублирование. К счастью, проверки для диапазонов уже тщательно собраны в стандартном модуле std.range
, воспользовавшись которым, можно упростить реализацию reduce
:
import std.range;
V reduce(alias fun, V, R)(V x, R range)
if (isInputRange!R && is(typeof(x = fun(x, range.front))))
{
for (; !range.empty; range.popFront())
{
x = fun(x, range.front);
}
return x;
}
Такой вариант уже гораздо лучше смотрится. Имея в распоряжении функцию reduce
, можно вычислить не только сумму и минимум, но и множество других агрегирующих функций, таких как число, ближайшее к заданному, наибольшее число по модулю и стандартное отклонение. Функция reduce
из модуля std.algorithm
стандартной библиотеки выглядит практически так же, как и наша версия выше, за исключением того, что она принимает в качестве аргументов несколько функций для вычисления; это позволяет очень быстро вычислять значения множества агрегирующих функций, поскольку выполняется всего один проход по входным данным.
5.10. Функции с переменным числом аргументов
В традиционной программе «Hello, world!», приведенной в начале книги, для вывода приветствия в стандартный поток использовалась функция writeln
из стандартной библиотеки. У этой функции есть интересная особенность: она принимает любое число аргументов любых типов. В языке D определить функцию с переменным числом аргументов можно разными способами, отвечающими тем или иным нуждам разработчика. Начнем с самого простого.
5.10.1. Гомогенные функции с переменным числом аргументов
Гомогенная функция с переменным числом аргументов, принимающая любое количество аргументов одного типа, определяется так:
import std.algorithm, std.array;
// Вычисляет среднее арифметическое множества чисел, переданных непосредственно или в виде массива.
double average(double[] values...)
{
if (values.empty)
{
throw new Exception("Среднее арифметическое для нуля элементов " ~ "не определено");
}
return reduce!((a, b) { return a + b; })(0.0, values) / values.length;
}
unittest
{
assert(average(0) == 0);
assert(average(1, 2) == 1.5);
assert(average(1, 2, 3) == 2);
// Передача массивов и срезов тоже срабатывает
double[] v = [1, 2, 3];
assert(average(v) == 2);
}
(Обратите внимание на очередное удачное использование reduce
.) Интересная деталь функции average
: многоточие ... после параметра values
, который является срезом. (Если бы это было не так или если бы параметр values
не был последним в списке аргументов функции average
, компилятор диагностировал бы это многоточие как ошибку.)
Вызов функции average
со срезом массива элементов типа double
(как показано в последней строке теста модуля) ничем не примечателен. Однако благодаря многоточию эту функцию можно вызывать с любым числом аргументов, при условии что каждый из них можно привести к типу double
. Компилятор автоматически сформирует из этих аргументов срез и передаст его в average
.
Может показаться, что это средство едва ли не тот же синтаксический сахар, позволяющий компилятору заменить average(a, b, c)
на average([a, b, c])
. Однако благодаря своему синтаксису вызова гомогенная функция с переменным числом аргументов перегружает другие функции в своем контексте. Например:
// Исключительно ради аргумента
double average() {}
double average(double) {}
// Гомогенная функция с переменным числом аргументов
double average(double[] values...) { /* То же, что и выше */ ... }
unittest
{
average(); // Ошибка! Двусмысленный вызов перегруженной функции!
}
Присутствие первых двух перегруженных версий average
делает двусмысленным вызов без аргументов или с одним аргументом версии average
с переменным числом аргументов. Избавиться от двусмысленности поможет явная передача среза, например average([1, 2])
.
Если в одном и том же контексте одновременно присутствуют обе функции – и с фиксированным, и с переменным числом аргументов,– каждая из которых ожидает срез того же типа, что и другая, то при вызове с явно заданным срезом предпочтение отдается функции с фиксированным числом аргументов:
import std.stdio;
void average(double[]) { writeln("с фиксированным числом аргументов"); }
void average(double[]...) { writeln("с переменным числом аргументов"); }
void main()
{
average(1, 2, 3); // Пишет "с переменным числом аргументов"
average([1, 2, 3]); // Пишет "с фиксированным числом аргументов"
}
Кроме срезов можно использовать в качестве аргумента массив фиксированной длины (в этом случае количество аргументов также фиксировано) и класс15. Подробно классы описаны в главе 6, а здесь лишь несколько слов о взаимодействии классов и функций с переменным числом аргументов.
Если написать void foo(T obj...)
, где T
– имя класса, то внутри foo
будет создан экземпляр T
, причем его конструктору будут переданы аргументы, переданные функции. Если для данного набора аргументов конструктора класса T
не существует, будет сгенерирована ошибка. Созданный экземпляр является локальным для данной функции, память под него может быть выделена в стеке, поэтому он не возвращается функцией.
5.10.2. Гетерогенные функции с переменным числом аргументов
Вернемся к функции writeln
. Она явно должна делать не совсем то же самое, что функция average
, поскольку writeln
принимает аргументы разных типов. Для обработки произвольного числа аргументов произвольных типов предназначена гетерогенная функция с переменным числом аргументов, которую определяют так:
import std.conv;
void writeln(T...)(T args)
{
foreach (arg; args)
{
stdout.rawWrite(to!string(arg));
}
stdout.rawWrite('\n');
stdout.flush();
}
Эта реализация немного сыровата и неэффективна, но она работает. T
внутри writeln
– кортеж типов параметров (тип, который группирует несколько типов), а args
– кортеж параметров. Цикл foreach
определяет, что args
– это кортеж типов, и генерирует код, радикально отличающийся от того, что получается в результате обычного выполнения инструкции foreach
(например, когда цикл foreach
применяется для просмотра массива). Рассмотрим, например, такой вызов:
writeln("Печатаю целое: ", 42, " и массив: ", [ 1, 2, 3 ]);
Для такого вызова конструкция foreach
сгенерирует код следующего вида:
// Аппроксимация сгенерированного кода
void writeln(string a0, int a1, string a2, int[] a3)
{
stdout.rawWrite(to!string(a0));
stdout.rawWrite(to!string(a1));
stdout.rawWrite(to!string(a2));
stdout.rawWrite(to!string(a3));
stdout.rawWrite('\n');
stdout.flush();
}
В модуле std.conv
определены версии to!string
для всех типов (включая и сам тип string
, для которого функция to!string
– тождественное отображение), так что функция работает, по очереди преобразуя каждый аргумент в строку и печатая ее «сырые» байты в стандартный поток вывода.
Обратиться к типам или значениям кортежа параметров можно и без цикла foreach
. Если n
– известное во время компиляции неизменяемое число, то выражение T[n]
возвратит n
-й тип, а выражение args[n]
– n
-е значение в кортеже параметров. Получить число аргументов можно с помощью выражения T.length
или args.length
(оба являются константами, известными во время компиляции). Если вы уже заметили сходство с массивами, то не будете удивлены, узнав, что с помощью выражения T[$ - 1]
можно получить доступ к последнему типу в T
(а args[$ - 1]
– псевдоним для последнего значения в args
). Например:
import std.stdio;
void testing(T...)(T values)
{
writeln("Переданных аргументов: ", values.length, ".");
// Обращение к каждому индексу и каждому значению
foreach (i, value; values)
{
writeln(i, ": ", typeid(T[i]), " ", value);
}
}
void main()
{
testing(5, "здравствуй", 4.2);
}
Эта программа напечатает:
Переданных аргументов: 3.
0: int 5
1: immutable(char)[] здравствуй
2: double 4.2
5.10.2.1. Тип без имени
Функция writeln
делает слишком много специфичного, чтобы быть обобщенной: она всегда добавляет в конце '\n'
и затем использует функцию flush
для записи данных буферов потока. Попробуем определить функцию writeln
через базовую функцию write
, которая просто выводит все аргументы по очереди:
import std.conv;
void write(T...)(T args)
{
foreach (arg; args)
{
stdout.rawWrite(to!string(arg));
}
}
void writeln(T...)(T args)
{
write(args, '\n');
stdout.flush();
}
Обратите внимание, как writeln
делегирует запись args
и '\n'
функции write
. При передаче кортеж параметров автоматически разворачивается, так что вызов writeln(1, "2", 3)
делегирует функции write
запись из четырех, а не трех аргументов. Такое поведение немного необычно и не совсем понятно, поскольку практически во всех остальных случаях в D под одним идентификатором понимается одно значение. Этот пример может удивить даже подготовленных:
void fun(T...)(T args)
{
gun(args);
}
void gun(T)(T value)
{
writeln(value);
}
unittest
{
fun(1); // Все в порядке
fun(1, 2.2); // Ошибка! Невозможно найти функцию gun принимающую два аргумента!
}
Первый вызов проходит гладко, чего нельзя сказать о втором. Вы ожидали, что все будет в порядке, ведь любое значение (а значит, и args
) обладает каким-то типом, и потому тип args
должен выводиться функцией gun
. Но что происходит на самом деле?
Все значения действительно обладают типами, которые корректно отслеживаются компилятором. Виновен вызов gun(args)
, поскольку компилятор автоматически расширяет этот вызов, когда бы кортеж параметров ни передавался в качестве аргумента функции. Даже если вы написали gun(args)
, компилятор всегда развернет такой вызов до gun(args[0], args[1], ..., args[$ - 1])
. Под вторым вызовом подразумевается вызов gun(args[0], args[1])
, который требует несуществующей функции gun
с двумя аргументами, – отсюда и ошибка.
Чтобы более глубоко исследовать этот случай, напишем «забавную» функцию fun
для печати типа значения args
.
void fun(T...)(T args)
{
writeln(typeof(args).stringof);
}
Конструкция typeof
– не вызов функции; это выражение всего лишь возвращает тип args
, поэтому можно не волноваться относительно автоматической развертки. Свойство .stringof
, присущее всем типам, возвращает имя типа, так что давайте еще раз скомпилируем и запустим программу. Она печатает:
(int)
(int, double)
Итак, действительно похоже на то, что компилятор отслеживает типы кортежей параметров, и для них определено строковое представление. Тем не менее невозможно явно определить кортеж параметров: типа (int, double)
не существует.
// Бесполезно
(int, double) value = (1, 4.2);
Все объясняется тем, что кортежи в своем роде уникальны: это типы, которые внутренне используются компилятором, но не могут быть выражены в тексте программы. Никаким образом невозможно взять и написать тип кортежа параметров. Потому нет и литерала, порождающего вывод кортежа параметров (если бы был, то необходимость в указании имени типа отпала бы: ведь есть ключевое слово auto
).
5.10.2.2. Тип данных Tuple и функция tuple
Концепция типов без имен и значений без литералов может заинтересовать любителя острых ощущений, однако программист практического склада увидит здесь нечто угрожающее. К счастью (наконец-то! эти слова должны были появиться рано или поздно), это не столько ограничение, сколько способ сэкономить на синтаксисе. Есть замечательная возможность представлять типы кортежей параметров с помощью типа Tuple
, а значения кортежей параметров – с помощью функции tuple
. И то и другое находится в стандартном модуле std.typecons
. Таким образом, кортеж параметров, содержащий int
и double
, можно записать так:
import std.typecons;
unittest
{
Tuple!(int, double) value = tuple(1, 4.2); // Ого!
}
Учитывая, что выражение tuple(1, 4.2)
возвращает значение типа Tuple!(int, double)
, следующий код эквивалентен только что представленному:
auto value = tuple(1, 4.2); // Двойное “ого!"
Тип Tuple!(int, double)
такой же, как и все остальные типы, он не делает никаких фокусов с автоматической разверткой, так что если вы хотите развернуть его до составных частей, нужно сделать это явно с помощью свойства .expand
типа Tuple
. Для примера переплавим нашу программу с функциями fun
и gun
и в результате получим следующий код:
import std.stdio, std.typecons;
void fun(T...)(T args)
{
// Создать кортеж, чтобы "упаковать" все аргументы в одно значение
gun(tuple(args));
}
void gun(T)(T value)
{
// Расширить кортеж и получить исходное множество параметров
writeln(value.expand);
}
void main()
{
fun(1); // Все в порядке
fun(1, 2.2); // Все в порядке
}
Посмотрите, как функция fun
группирует все аргументы в один кортеж (Tuple
) и передает его в функцию gun
, которая разворачивает полученный кортеж, извлекая все, что он содержит. Выражение value.expand
автоматически заменяется на список аргументов, содержащий все, что вы отправили в Tuple
.
В реализации типа Tuple
есть пара тонких моментов, но она использует средства, доступные любому программисту. Изучение определения типа Tuple
(которое можно найти в стандартной библиотеке) было бы полезным упражнением.
5.10.3. Гетерогенные функции с переменным числом аргументов. Альтернативный подход16
Предыдущий подход всем хорош, однако применение шаблонов накладывает на функции ряд ограничений. Поскольку приведенная выше реализация использует шаблоны, для каждого возможного кортежа параметров создается свой экземпляр шаблонной функции. Это не позволяет делать шаблонные функции виртуальными методами класса, объявлять их нефинальными членами интерфейсов, а при невнимательном подходе может приводить к излишнему разрастанию результирующего кода (поэтому шаблонная функция должна быть небольшой, чтобы компилятор счел возможной ее inline-подстановку). Поэтому D предлагает еще два способа объявить функцию с переменным числом аргументов. Оба способа были добавлены в язык до появления шаблонов с переменным числом аргументов, и сегодня считаются небезопасными и устаревшими. Тем не менее они присутствуют и используются в текущих реализациях языка, чаще всего из соображений совместимости.
5.10.3.1. Функции с переменным числом аргументов в стиле C
Первый способ язык D унаследовал от языка C. Вспомним функцию printf
. Вот ее сигнатура на D:
extern(C) int printf(in char* format, ...);
Разберем ее по порядку. Запись extern(C)
обозначает тип компоновки. В данном случае указано, что функция использует тип компоновки C. То есть параметры передаются в функцию в соответствии с соглашением о вызовах языка C. Также в C не используется искажение имен (mangling) функций, поэтому такая функция не может быть перегружена по типам аргументов. Если две такие функции с одинаковыми именами объявлены в разных модулях, возникнет конфликт имен. Как правило, extern(C)
используется для взаимодействия с кодом, уже написанным на C или других языках. in char* format
– обязательный первый аргумент функции, за которым следует переменное число аргументов, что символизирует уже знакомое нам многоточие (...
).
Для начала вспомним, как аргументы передаются функции в языке C. C передает аргументы через стек, помещая в него аргументы, начиная с последнего. За удаление аргументов из стека отвечает вызывающая подпрограмма. Например, при вызове printf("%d + %d = %d", 2, 3, 5)
первым в стек будет помещен аргумент 5, после него 3, затем 2 и последней – строка формата. В итоге строка формата оказывается на вершине стека и будет доступна в вызываемой функции. Для получения остальных аргументов в C используются макросы, определенные в файле stdarg.h
.
В языке D соответствующие функции определены в модуле std.c.stdarg
. Во-первых, в данном модуле определен тип va_list
, который является указателем на список необязательных аргументов. Функция va_start
инициализирует переменную va_list
указателем на начало списка необязательных аргументов.
void va_start(T)( out va_list ap, ref T parmn );
Первый аргумент – инициализируемая переменная va_list
, второй – ссылка на последний обязательный аргумент, то есть последний аргумент, тип которого известен. На основании него вычисляется указатель на первый элемент списка необязательных аргументов. Именно поэтому функция с переменным числом аргументов в C должна иметь хотя бы один обязательный параметр, чтобы va_start
было к чему привязаться. Объявление extern(C) int foo(...);
недопустимо.
Функция va_arg
получает значение очередного аргумента заданного типа. Тип этого аргумента может быть получен в результате каких-то операций с предыдущими аргументами, и проверить правильность его получения невозможно. Указатель на список при этом изменяется так, чтобы он указывал на следующий элемент списка.
T va_arg(T)( ref va_list ap );
Функция va_copy
предназначена для копирования переменной типа va_list
. Если va_list
– указатель на стек функции, выполняется копирование указателя. Если же в вашей системе аргументы передаются через регистры, потребуется выделение памяти и копирование списка.
void va_copy( out va_list dest, va_list src );
Функция va_end
вызывается по завершении работы со списком аргументов. Каждый вызов va_start
или va_copy
должен сопровождаться вызовом va_end
.
void va_end( va_list ap );
Интерфейс stdarg
является кроссплатформенным, а сама реализация функций с переменным числом аргументов может быть различной для разных платформ. В некоторых платформах аргументы передаются через стек, и va_list
– указатель на верхний элемент списка в стеке. В некоторых аргументы могут передаваться через регистры. Также разным может быть выравнивание элементов в стеке и направление роста стека. Поэтому следует пользоваться именно этим интерфейсом, а не пытаться договориться с функцией в обход него. Пример функции для преобразования в строку значения нужного типа:
import std.c.stdarg, std.conv;
extern(C) string cToString(string type, ...)
{
va_list args_list;
va_start(args_list, type);
scope(exit) va_end(args_list);
switch (type)
{
case "int":
auto int_val = va_arg!int(args_list);
return to!string(int_val);
case "double":
auto double_val = va_arg!double(args_list);
return to!string(double_val);
case "complex":
auto re_val = va_arg!double(args_list);
auto im_val = va_arg!double(args_list);
return to!string(re_val) ~ " + " ~ to!string(im_val) ~ "i";
case "string":
return va_arg!string(args_list);
default:
assert(0, "Незнакомый тип");
}
}
unittest
{
assert(cToString("int", 5) == "5");
assert(cToString("double", 2.0) == "2");
assert(cToString("string", "Test string") == "Test string");
assert(cToString("complex", 3.5, 2.7) == "3.5 + 2.7i");
}
В этом примере мы первым аргументом передаем тип следующих аргументов, и на основании этого аргумента функция определяет, каких аргументов ей ждать дальше. Однако если мы допустим ошибку в вызове, то спасти нас уже никто не сможет. В этом и заключается опасность подобных функций: ошибка в вызове может привести к аппаратной ошибке внутри самой функции. Например, если мы напишем:
cToString("string", 3.5, 2.7);
результат будет непредсказуемым. Поэтому, например, функция scanf
может оказаться небезопасной, если строка формата берется из ненадежного источника, ведь с правильно подобранной строкой формата и аргументом можно получить перезапись адреса возврата функции и заставить программу выполнить какой-то свой, наверняка вредоносный код. Поэтому язык D предлагает менее опасный способ создания функций с переменным числом аргументов.
5.10.3.2. Функции с переменным числом аргументов в стиле D
Функцию с переменным числом аргументов в стиле D можно объявить так:
void foo(...);
То есть делается абсолютно то же самое, что и в случае выше, но выбирается тип компоновки D (по умолчанию или явным указанием extern(D)
), и обязательный аргумент можно не указывать. В самой же приведенной функции применяется не такой подход, как в языке C. Внутри такой функции доступны два идентификатора: _arguments
типа TypeInfo[]
и _argptr
типа va_list
. Идентификатор _argptr
указывает на начало списка аргументов, а _arguments
– на массив идентификаторов типа для каждого переданного аргумента. Количество переданных аргументов соответствует длине массива.
Об идентификаторах типов следует рассказать подробнее. Идентификатор типа – это объект класса TypeInfo
или производного от него. Получить идентификатор типа T
можно с помощью выражения typeid(T)
. Для каждого типа есть один и только один идентификатор. То есть равенство typeid(int) is typeid(int)
всегда верно. Полный список параметров класса TypeInfo
следует искать в документации по вашему компилятору или в модуле object
. Модуль object
, объявленный в файле object.di
, импортируется в любом модуле по умолчанию, то есть можно использовать любые объявленные в нем символы без каких-то дополнительных объявлений. Вот безопасный вариант предыдущего примера:
import std.c.stdarg, std.conv;
string dToString(string type, ...)
{
va_list args_list;
va_copy(args_list, _argptr);
scope(exit) va_end(args_list);
switch (type)
{
case "int":
assert(_arguments.length == 1 && _arguments[0] is typeid(int), "Аргумент должен иметь тип int.");
auto int_val = va_arg!int(args_list);
return to!string(int_val);
case "double":
assert(_arguments.length == 1 &&_arguments[0] is typeid(double), "Аргумент должен иметь тип double.");
auto double_val = va_arg!double(args_list);
return to!string(double_val);
case "complex":
assert(_arguments.length == 2 &&
_arguments[0] is typeid(double) &&
_arguments[1] is typeid(double),
"Для типа complex должны быть переданы два аргумента типа double.");
auto re_val = va_arg!double(args_list);
auto im_val = va_arg!double(args_list);
return to!string(re_val) ~ " + " ~ to!string(im_val) ~ "i";
case "string":
assert(_arguments.length == 1 &&_arguments[0] is typeid(string),
"Аргумент должен иметь тип string.");
return va_arg!string(args_list).idup;
default:
assert(0);
}
}
unittest
{
assert(dToString("int", 5) == "5");
assert(dToString("double", 2.0) == "2");
assert(dToString("string", "Test string") == "Test string");
assert(dToString("complex", 3.5, 2.7) == "3.5 + 2.7i");
}
Этот вариант автоматически проверят типы переданных аргументов. Однако не забывайте, что корректность типа, переданного va_arg
, остается за вами – использование неправильного типа приведет к непредсказуемой ситуации. Если вас это беспокоит, то для полной безопасности вы можете использовать конструкцию Variant
из модуля стандартной библиотеки std.variant
:
import std.stdio, std.variant;
void pseudoVariadic(Variant[] vars)
{
foreach (var; vars)
if (var.type == typeid(string))
writeln("Строка: ", var.get!string);
else if (var.type == typeid(int))
writeln("Целое число: ", var.get!int);
else
writeln("Незнакомый тип: ", var.type);
}
void templatedVariadic(T...)(T args)
{
pseudoVariadic(variantArray(args));
}
void main()
{
templatedVariadic("Здравствуй, мир!", 42);
}
При этом функция templatedVariadic
, скорее всего, будет встроена в код путем inline-подстановки, и накладных расходов на лишний вызов функции и разрастание шаблонного кода не будет.
5.11. Атрибуты функций
К функциям на D можно присоединять атрибуты – особые средства, извещающие программиста и компилятор о том, что функция обладает некоторыми качествами. Функции проверяются на соответствие своим атрибутам, поэтому, чтобы узнать важную информацию о поведении функции, достаточно взглянуть на ее сигнатуру: атрибуты предоставляют твердые гарантии, это не простые комментарии или соглашения.
5.11.1. Чистые функции
Чистота функций – заимствованное из математики понятие, полезное как в теории, так и на практике. В языке D функция считается чистой, если все, что она делает, сводится к возвращению результата и возвращаемое значение зависит только от ее аргументов.
В классической математике все функции чистые, поскольку в классической математике нет состояний и изменений. Чему равен √2? Примерно 1,4142; так было вчера, будет завтра и вообще всегда. Можно доказать, что значение √2 было тем же еще до того, как человечество открыло корни, алгебру, числа, и даже до появления человечества, способного оценить красоту математики, и столь же долго пребудет неизменным после тепловой смерти Вселенной. Математические результаты вечны.
Чистота – это благо для функций, пусть даже иногда и с ограничениями, впрочем, как и в жизни. (Кстати, как и в жизни, чистоты не так просто достичь. Более того, по мнению некоторых, излишества в некоторых проявлениях чистоты на самом деле могут раздражать.) В пользу чистоты говорит тот факт, что о чистой функции легче делать выводы. Чистота гарантирует: чтобы узнать, что делает та или иная функция, достаточно взглянуть на ее вызов. Можно заменять эквивалентные вызовы функций значениями, а значения – эквивалентными вызовами функций. Можно быть уверенным, что ошибки в чистых функциях не обладают эффектом шрапнели – они не могут повлиять на что-либо еще помимо результата самой функции.
Кроме того, чистые функции могут выполняться в буквальном смысле параллельно, так как они никаким образом, кроме их результата, не взаимодействуют с остальным кодом программы. В противоположность им, насыщенные изменениями17 нечистые функции при параллельном выполнении склонны наступать друг другу на пятки. Но даже если выполнять их последовательно, результат может неуловимо зависеть от порядка, в котором они вызываются. Многих из нас это не удивляет – мы настолько свыклись с таким раскладом, что считаем преодоление трудностей неотъемлемой частью процесса написания кода. Но если хотя бы некоторые части приложения будут написаны «чисто», это принесет большую пользу, освежив программу в целом.
Определить чистую функцию можно, добавив в начало ее определения ключевое слово pure
:
pure bool leapYear(uint y)
{
return (y % 4) == 0 && (y % 100 || (y % 400) == 0);
}
Например, сигнатура функции
pure bool leapYear(uint y);
гарантирует пользователю, что функция leapYear
не пишет в стандартный поток вывода. Кроме того, уже по сигнатуре видно, что вызов leapYear(2020)
всегда будет возвращать одно и то же значение.
Компилятор также в курсе значения ключевого слова pure
, и именно он ограждает программиста от любых действий, способных нарушить чистоту функции leapYear
. Приглядитесь к следующим изменениям:
pure bool leapYear(uint y)
{
auto result = (y % 4) == 0 && (y % 100 || (y % 400) == 0);
if (result) writeln(y, " – високосный год!"); // Ошибка! Из чистой функции невозможно вызвать нечистую функцию!
return result;
}
Функция writeln
не является и не может стать чистой. И если бы она заявляла обратное, компилятор бы избавил ее от такого заблуждения. Компилятор гарантирует, что чистая функция вызывает только чистые функции. Вот почему измененная функция leapYear
не компилируется. С другой стороны, проверку компилятора успешно проходят такие функции, как daysInYear
:
// Чистота подтверждена компилятором
pure uint daysInYear(uint y)
{
return 365 + leapYear(y);
}
5.11.1.1. «Чист тот, кто чисто поступает»
По традиции функциональные языки запрещают абсолютно любые изменения, чтобы программа могла называться чистой. D ослабляет это ограничение, разрешая функциям изменять собственное локальное и временное состояние. Таким образом, даже если внутри функции есть изменения, для окружающего кода она все еще непогрешима.
Посмотрим, как работает это допущение. В качестве примера возьмем наивную реализацию функции Фибоначчи в функциональном стиле:
ulong fib(uint n)
{
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
Ни один преподаватель программирования никогда не должен учить реализовывать расчет чисел Фибоначчи таким способом. Чтобы вычислить результат, функции fib
требуется экспоненциальное время, поэтому все, чему она может научить, – это пренебрежение сложностью и ценой вычислений, лозунг «небрежно, зато находчиво» и спортивный стиль вождения. Хотите знать, чем плох экспоненциальный порядок? Вызовы fib(10)
и fib(20)
на современной машине не займут много времени, но вызов fib(50)
обрабатывается уже 19 минут. Вполне вероятно, что вычисление fib(1000)
переживет человечество (только смысла в этом никакого, в отличие от примера с √2.)
Хорошо, но как выглядит «правильная» функциональная реализация Фибоначчи?
ulong fib(uint n)
{
ulong iter(uint i, ulong fib_1, ulong fib_2)
{
return i == n ? fib_2 : iter(i + 1, fib_1 + fib_2, fib_1);
}
return iter(0, 1, 0);
}
Переработанная версия вычисляет fib(50)
практически мгновенно. Эта реализация требует для выполнения O(n)18 времени, поскольку оптимизация хвостовой рекурсии (см. раздел 1.4.2) позволяет уменьшить сложность вычислений. (Стоит отметить, что для расчета чисел Фибоначчи существуют и алгоритмы с временем выполнения O(log n)).
Проблема в том, что новая функция fib
как бы утратила былое великолепие. Особенность переработанной реализации – две переменные состояния, маскирующиеся под параметры функции, и вполне можно было с чистой совестью написать явный цикл, который зачем-то был закамуфлирован функцией iter
:
ulong fib(uint n)
{
ulong fib_1 = 1, fib_2 = 0;
foreach (i; 0 .. n)
{
auto t = fib_1;
fib_1 += fib_2;
fib_2 = t;
}
return fib_2;
}
К сожалению, это уже не функциональный стиль. Только посмотрите на все эти изменения, происходящие в цикле. Один неверный шаг – и с вершин математической чистоты мы скатились к неискушенности чумазых низов.
Но подумав немного, мы увидим, что итеративная функция fib
не такая уж чумазая. Если принять ее за черный ящик, то можно заметить, что при одних и тех же аргументах функция fib
всегда возвращает один и тот же результат, а ведь «красив тот, кто красиво поступает». Тот факт, что она использует локальное изменение состояния, делает ее менее функциональной по букве, но не по духу. Продолжая эту мысль, приходим к очень интересному выводу: пока изменяемое состояние внутри функции остается полностью временным (то есть хранит данные в стеке) и локальным (то есть не передается по ссылке другим функциям, которые могут его нарушить), эту функцию можно считать чистой.
Вот как D определяет функциональную чистоту: в реализации чистой функции разрешается использовать изменения, если они временные и локальные. Сигнатуру такой функции можно снабдить ключевым словом pure
, и компилятор без помех скомпилирует этот код:
pure ulong fib(uint n)
{
... // Итеративная реализация
}
Принятые в D допущения, смягчающие математическое понятие чистоты, очень полезны, поскольку позволяют взять лучшее из двух миров: железные гарантии функциональной чистоты и удобную реализацию (если код с изменениями более предпочтителен).
5.11.2. Атрибут nothrow
Атрибут nothrow
сообщает, что данная функция никогда не порождает исключения. Как и атрибут pure
, атрибут nothrow
проверяется во время компиляции. Например:
import std.stdio;
nothrow void tryLog(string msg)
{
try {
stderr.writeln(msg);
} catch (Exception) {
// Проигнорировать исключение
}
}
Функция tryLog
прилагает максимум усилий, чтобы записать в журнал сообщение. Если возникает исключение, она его молча игнорирует. Это качество позволяет использовать функцию tryLog
на критических участках кода. При определенных обстоятельствах было бы глупо позволить некоторой важной транзакции сорваться только из-за невозможности сделать запись в журнал. Устройство кода, представляющего собой транзакцию, основано на том, что некоторые из его участков никогда не порождают исключения, а применение атрибута nothrow
позволяет статически гарантировать это свойство критических участков.
Проверка семантики функций с атрибутом nothrow
гарантирует, что исключение никогда не просочится из функции. Для каждой инструкции внутри функции должно быть истинно одно из утверждений: 1) эта инструкция не порождает исключения (в случае вызова функции это возможно, только если вызываемая функция также не порождает исключения), 2) эта инструкция расположена внутри инструкции try
, «съедающей» исключения. Проиллюстрируем второй случай примером:
nothrow void sensitive(Widget w)
{
tryLog("Начинаем опасную операцию");
try {
w.mayThrow(); // Вызов может породить исключение
tryLog("Опасная операция успешно завершена");
} catch (Exception) {
tryLog("Опасная операция завершилась неудачей");
}
}
Первый вызов функции tryLog
можно не помещать в блок try
, поскольку компилятор уже знает, что эта функция не порождает исключения. Аналогично вызов внутри блока catch
можно не «защищать» с помощью дополнительного блока try
.
Как соотносятся атрибуты pure
и nothrow
? Может показаться, что они совершенно независимы друг от друга, но на самом деле между ними есть некоторая взаимосвязь. По крайней мере в стандартной библиотеке многие функции, например самые трансцендентные (такие как exp
, sin
, cos
), имеют оба атрибута – и pure
, и nothrow
.
5.12. Вычисления во время компиляции
В подтверждение поговорки, что счастье приходит к тому, кто умеет ждать (или терпеливо читать), в этом последнем разделе обсуждается очень интересное средство D. Лучшее в этом средстве то, что вам не нужно много учиться, чтобы начать широко его применять.
Рассмотрим пример, достаточно большой, чтобы быть осмысленным. Предположим, вы хотите создать лучшую библиотеку генераторов случайных чисел. Есть много разных генераторов случайных чисел, в том числе линейные конгруэнтные генераторы. У таких генераторов есть три целочисленных параметра: модуль m > 0, множитель 0 < a < m и наращиваемое значение19 0 < c < m. Начав с произвольного начального значения 0 ≤ x0 < m, линейный конгруэнтный генератор вычисляет псевдослучайные числа по следующей рекуррентной формуле:
xn+1 = (axn + c) mod m
Запрограммировать такой алгоритм очень просто: достаточно сохранять состояние, определяемое числами m, a, c и xn, и определить функцию getNext
для получения следующего значения xn+1.
Но здесь есть подвох. Не все комбинации a, m и c дадут хороший генератор случайных чисел. Для начала, при a = 1 и c = 1 генератор формирует последовательность 0, 1, …, m – 1, 0, ..., m – 1, 0, 1, ..., которую случайной уж никак не назовешь.
С большими значениями a и c таких очевидных рисков можно избежать, однако появляется менее заметная проблема: периодичность. Из-за оператора деления по модулю числа генерируются всегда между 0 и m – 1, так что неплохо было бы сделать значение m настолько большим, насколько это возможно (обычно в качестве значения этого параметра берут степень двойки, чтобы оно соответствовало размеру машинного слова: это позволяет обойтись без затрат на деление по модулю). Проблема в том, что сгенерированная последовательность может обладать периодом гораздо меньшим, чем m. Пусть мы работаем с типом uint
и выбираем m = 232 (тогда нам даже операция деления по модулю не нужна), a = 210, c = 123, а для x0 возьмем какое-нибудь сумасшедшее значение, например 1 780 588 661. Запустим следующую программу:
import std.stdio;
void main()
{
enum uint a = 210, c = 123, x0 = 1_780_588_661;
auto x = x0;
foreach (i; 0 .. 100)
{
x = a * x + c;
writeln(x);
}
}
Вместо пестрого набора случайных чисел мы увидим нечто неожиданное:
1 261464181
2 3367870581
3 2878185589
4 3123552373
5 3110969461
6 468557941
7 3907887221
8 317562997
9 2263720053
10 2934808693
11 2129502325
12 518889589
13 1592631413
14 3740115061
15 3740115061
16 3740115061
17 ...
Начинает генератор вполне задорно. По крайней мере, с непривычки может показаться, что он неплохо справляется с генерацией случайных чисел. Однако уже с 14-го шага генератор зацикливается: по странному стечению обстоятельств, породить которое могла только математика, 3 740 115 061 оказалось (и всегда будет оказываться) точно равным (3 740 115 061 * 210 + 123) mod 232. Это период единицы, худшее из возможного!
Значит, необходимо выбрать такие параметры m, a и c, чтобы сгенерированная последовательность псевдослучайных чисел гарантированно имела большой период. Дальнейшие исследования этой проблемы выявили следующие условия генерации последовательности псевдослучайных чисел с периодом m (наибольший возможный период):
- c и m взаимно просты.
- Значение a – 1 кратно всем простым делителям m.
- Если a – 1 кратно 4, то и m кратно 4.
Взаимную простоту c и m можно легко проверить сравнением наибольшего общего делителя этих чисел с 1. Для вычисления наибольшего общего делителя воспользуемся алгоритмом Евклида20:
// Реализация алгоритма Евклида
ulong gcd(ulong a, ulong b)
{
while (b)
{
auto t = b;
b = a % b;
a = t;
}
return a;
}
Евклид выразил свой алгоритм с помощью вычитания, а не деления по модулю. Для версии с делением по модулю требуется меньше итераций, но на современных машинах %
может вычисляться довольно-таки медленно (видимо, именно это и остановило Евклида).
Реализовать вторую проверку немного сложнее. Можно было бы написать функцию factorize
, возвращающую все возможные простые делители числа с их степенями, и воспользоваться ею, но factorize
– это больше, чем нам необходимо. Стремясь к простейшему решению, которое могло бы сработать, проще всего написать функцию primeFactorsOnly(n)
, возвращающую произведение простых делителей n
, но без степеней. Тогда наша задача сводится к проверке выражения (a - 1) % primeFactorsOnly(m) == 0
. Итак, приступим к реализации функции primeFactorsOnly
.
Есть много способов получить простые делители некоторого числа n. Один из простых: сгенерировать простые числа p1, p2, p3, ..., для каждого значения pk выяснить, делится ли n на pk, и если делится, то умножить pk на значение-аккумулятор r. Когда очередное число pk окажется больше n, вычисления прекращаются. Аккумулятор r содержит искомое значение – произведение всех простых делителей n, взятых по одному разу.
(Догадываюсь, что сейчас вы задаетесь вопросом, имеет ли все это отношение к вычислениям во время компиляции. Ответ: имеет. Прошу немного терпения.)
Более простую версию можно получить, избавившись от генерации простых чисел. Можно просто вычислять n mod k для возрастающих значений k, образующих следующую последовательность (начиная с 2): 2, 3, 5, 7, 9, ... Всякий раз, когда n делится на k, аккумулятор умножается на k, а n «очищается» от всех степеней k: n присваивается значение n / k, пока n делится на k. Таким образом, мы сохранили значение k и одновременно уменьшили число n настолько, что теперь оно не делится на k. Это не выглядит как самый экономный метод, но задумайтесь о том, что генерация простых чисел могла бы потребовать сравнимых трудозатрат, по крайней мере в случае простой реализации. Реализация этой идеи могла бы выглядеть так:
ulong primeFactorsOnly(ulong n)
{
ulong accum = 1;
ulong iter = 2;
for (; n >= iter * iter; iter += 2 - (iter == 2))
{
if (n % iter) continue;
accum *= iter;
do n /= iter; while (n % iter == 0);
}
return accum * n;
}
Команда iter += 2 - (iter == 2)
, обновляющая значение переменной iter
, всегда увеличивает его на 2
, кроме случая, когда iter
равно 2
: тогда значение этой переменной заменяется на 3
. Таким образом, переменная iter
принимает значения 2
, 3
, 5
, 7
, 9
и т. д. Было бы слишком расточительно проверять каждое четное число, например 4
, поскольку число 2
уже было проверено и все его степени извлечены из n
.
Почему в качестве условия продолжения цикла выбрана проверка n >= iter * iter
, а не n >= iter
? Ответ не вполне прямолинеен. Если число iter
больше √n
и отличается от самого числа n
, то есть уверенность, что число n
не делится на число iter
: если бы делилось, должен был бы существовать некоторый множитель k
, такой, что n == k * iter
, но все делители меньше iter
только что были рассмотрены, так что k
должно быть больше iter
, и следовательно, произведение k * iter
– больше n
, что делает равенство невозможным.
Протестируем функцию primeFactorsOnly
:
unittest
{
assert(primeFactorsOnly(100) == 10);
assert(primeFactorsOnly(11) == 11);
assert(primeFactorsOnly(7 * 7 * 11 * 11 * 15) == 7 * 11 * 15);
assert(primeFactorsOnly(129 * 2) == 129 * 2);
}
В завершение нам необходима небольшая функция-обертка, выполняющая три рассмотренные проверки трех потенциальных параметров линейного конгруэнтного генератора:
bool properLinearCongruentialParameters(ulong m, ulong a, ulong c)
{
// Проверка границ
if (m == 0 || a == 0 || a >= m || c == 0 || c >= m) return false;
// c и m взаимно просты
if (gcd(c, m) != 1) return false;
// Значение a - 1 кратно всем простым делителям m
if ((a - 1) % primeFactorsOnly(m)) return false;
// Если a - 1 кратно 4, то и m кратно 4
if ((a - 1) % 4 == 0 && m % 4) return false;
// Все тесты пройдены
return true;
}
Протестируем некоторые популярные значения m
, a
и c
:
unittest
{
// Наш неподходящий пример
assert(!properLinearCongruentialParameters(1UL << 32, 210, 123));
// Пример из книги "Numerical Recipes"
assert(properLinearCongruentialParameters(1UL << 32, 1664525, 1013904223));
// Компилятор Borland C/C++
assert(properLinearCongruentialParameters(1UL << 32, 22695477, 1));
// glibc
assert(properLinearCongruentialParameters(1UL << 32, 1103515245, 12345));
// ANSI C
assert(properLinearCongruentialParameters(1UL << 32, 134775813, 1));
// Microsoft Visual C/C++
assert(properLinearCongruentialParameters(1UL << 32, 214013, 2531011));
}
Похоже, функция properLinearCongruentialParameters
работает как надо, то есть мы справились со всеми деталями тестирования состоятельности линейного конгруэнтного генератора. Так что пора притормозить, заглушить мотор и покаяться. Какое отношение имеет вся эта простота и делимость к вычислениям во время компиляции? Где мясо?21 Где шаблоны, макросы или как там они еще называются? Многообещающие инструкции static if
? Умопомрачительные генерация кода и расширение кода?
На самом деле, вы только что увидели все, что только можно рассказать о вычислениях во время компиляции. Задав константам m
, n
и с
любые числовые значения, можно вычислить properLinearCongruentialParameters
во время компиляции, никак не изменяя эту функцию или функции, которые она вызывает. В компилятор D встроен интерпретатор, который вычисляет функции на D во время компиляции – со всей арифметикой, циклами, изменениями, ранними возвратами и даже трансцендентными функциями.
От вас требуется только указать компилятору, что вычисления нужно выполнить во время компиляции. Для этого есть несколько способов:
unittest
{
enum ulong m = 1UL << 32, a = 1664525, c = 1013904223;
// Способ 1: воспользоваться инструкцией static assert
static assert(properLinearCongruentialParameters(m, a, c));
// Способ 2: присвоить результат символической константе, объявленной с ключевым словом enum
enum proper1 = properLinearCongruentialParameters(m, a, c);
// Способ 3: присвоить результат статическому значению
static proper2 = properLinearCongruentialParameters(m, a, c);
}
Мы еще не рассматривали структуры и классы в подробностях, но отметим, немного опережая события, что типичный вариант использования функции properLinearCongruentialParameters
– ее размещение внутри структуры или класса, определяющего линейный конгруэнтный генератор. Например:
struct LinearCongruentialEngine(UIntType, UIntType a, UIntType c, UIntType m)
{
static assert(properLinearCongruentialParameters(m, a, c), "Некорректная инициализация LinearCongruentialEngine");
...
}
Собственно, эти строки скопированы из одноименной структуры, которую можно найти в стандартном модуле std.random
.
Изменив время выполнения проверки (теперь она выполняется на этапе компиляции, а не во время исполнения программы), мы получили два любопытных последствия. Во-первых, можно было бы отложить проверку до исполнения программы, расположив вызов properLinearCongruentialParameters
в конструкторе структуры LinearCongruentialEngine
. Но обычно чем раньше узнаешь об ошибках, тем лучше, особенно если это касается библиотеки, которая почти не контролирует то, как ее используют. При статической проверке некорректно созданные экземпляры LinearCongruentialEngine
не сигнализируют об ошибках: исключается сама возможность их появления. Во-вторых, используя константы, известные во время компиляции, код имеет хороший шанс работать быстрее, чем код с обычными значениями m
, a
и c
. На большинстве современных процессоров константы в виде литералов могут быть сделаны частью потока команд, так что их загрузка вообще не требует никаких дополнительных обращений к памяти. И посмотрим правде в глаза: линейные конгруэнтные генераторы – не самые случайные в мире, и используют их главным образом благодаря скорости.
Процесс интерпретации на пару порядков медленнее генерации кода, но гораздо быстрее традиционного метапрограммирования на основе шаблонов C++. Кроме того, вычисления во время компиляции (в разумных пределах) в некотором смысле «бесплатны».
На момент написания этой книги у интерпретатора есть ряд ограничений22. Выделение памяти под объекты, да и просто выделение памяти запрещены (хотя встроенные массивы работают). Статические данные, вставки на ассемблере и небезопасные средства, такие как объединения (union
) и некоторые приведения типов (cast
), также под запретом. Множество ограничений на то, что можно сделать во время компиляции, находится под постоянным давлением. Задумка в том, чтобы разрешить интерпретировать во время компиляции все, что находится в безопасном множестве D. В конце концов, способность интерпретировать код вовремя компиляции – это новшество, открывающее очень интересные возможности, которые заслуживают дальнейшего исследования.
-
Функция
find
ищет «иголку» (needle
) в «стоге сена» (haystack
). – Прим. науч. ред. ↩︎ -
Следует подчеркнуть, что проверка выполнения подобных соглашений выполняется на этапе компиляции, и если компилятор обмануть, например с помощью приведения типов, то соглашения можно нарушить. Пример:
(cast(int[])data)[5] = 42;
даст именно то, что ожидается. Но это уже моветон. – Прим. науч. ред. ↩︎ -
На самом деле,
in
означаетscope const
, однако семантикаscope
не до конца продумана и, возможно, в дальнейшемscope
вообще исчезнет из языка. – Прим. науч. ред. ↩︎ -
Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность есть в текущих реализациях языка, мы добавили ее описание. – Прим. науч. ред. ↩︎
-
На самом деле, их можно инициализировать только константами, а можно вообще не инициализировать (тогда они принимают значение по умолчанию). – Прим. науч. ред. ↩︎
-
Именно этот момент делает «частичный порядок» «частичным». В случае отношения полного порядка (например ≤ для действительных чисел) неупорядоченных элементов нет. ↩︎
-
Речь о ежедневном комиксе американского художника Билла Уоттерсона «Кельвин и Хоббс». – Прим. пер. ↩︎
-
Тот же подход используют ML и другие реализации функциональных языков. ↩︎
-
Премия Дарвина – виртуальная премия, ежегодно присуждаемая тем, кто наиболее глупым способом лишился жизни или способности к зачатию, в результате не внеся свой вклад в генофонд человечества (и тем самым улучшив его). – Прим. пер. ↩︎
-
Хотя в приведенном примере о типе аргумента
a
ничего не сказано, текущая на момент выпуска книги версия компилятора 2.057 работает указанным образом только в том случае, еслиa
– массив. В ответ на пример(7).someprop()
для функцииvoid someprop(int a){}
компилятор скажет, что нет свойстваsomeprop
для типаint
. – Прим. науч. ред. ↩︎ -
Версия компилятора 2.057 не поддерживает атрибуты, объявляемые пользователем. В будущем такая поддержка может появиться. – Прим. науч. ред. ↩︎
-
На момент выхода книги такое поведение по умолчанию носило рекомендательный характер. Функция без аргументов и без атрибута
@property
могла вызываться как с пустой парой скобок, так и без. Так сделано из соображений обратной совместимости с кодом, написанным до ввода данного атрибута. Заставить компилятор проверять корректность использования скобок позволяет ключ компиляции-property
(dmd
2.057). В дальнейшем некорректное применение скобок может быть запрещено, поэтому там, где требуется функция, ведущая себя как свойство, следует использовать@property
. – Прим. науч. ред. ↩︎ -
Инлайнинг (inline-подстановка) – подстановка кода функции в месте ее вызова. Позволяет снизить накладные расходы на вызов функции при передаче аргументов, переходе по адресу, обратном переходе, а также нагрузку на кэш памяти процессора. В версиях языка C до C99 это достигалось с помощью макросов, в C99 и С++ появились ключевое слово
inline
и inline-подстановка методов классов, описанных внутри описания класса. В языке D inline-подстановка отдается на откуп компилятору. Компилятор будет сам решать, где рационально ее применить, а где – нет. – Прим. науч. ред. ↩︎ -
Reduce (англ.) – сокращать, сводить. – Прим. науч. ред. ↩︎
-
Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание. – Прим. науч. ред. ↩︎
-
Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. – Прим. науч. ред. ↩︎
-
В данном контексте речь идет об изменениях, которые повлияли бы на последующие вызовы функции, например об изменении глобальных переменных. – Прим. науч. ред. ↩︎
-
«O» большое – математическое обозначение, применяемое при оценке асимптотической сложности алгоритма. – Прим. ред. ↩︎
-
Равенство c нулю также допустимо, но соответствующая теоретическая часть гораздо сложнее, потому ограничимся значениями c > 0. ↩︎
-
Непонятно как, но алгоритм Евклида всегда умудряется попадать в хорошие (хм...) книги по программированию. ↩︎
-
Распространенный в США и Канаде мем, изначально связанный с фаст-фудом. – Прим. ред. ↩︎
-
Многие из этих ограничений уже сняты. – Прим. науч. ред. ↩︎