dlang-book/05-данные-и-функции-функцио...
Alexander Zhirov 8050d6d385 fix link 2023-02-25 21:12:38 +03:00
..
README.md fix link 2023-02-25 21:12:38 +03:00

README.md

5. Данные и функции. Функциональный стиль

Обсуждать данные и функции сегодня, когда даже разговоры об объектах устарели, это как вернуться в 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 и needle1 передаются в нее по значению. Это вовсе не означает, что если вы передадите в функцию массив из миллиона элементов, то он будет полностью скопирован; как отмечалось в главе 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 в контексте параметра это синоним квали фикатора типа const3, подробно описанного в главе 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» будем писать foo1foo2). Если определить такое отношение, то у нас появится критерий, по которому можно определить, какая из функций выигрывает в состязании за вызов при перегрузке: при вызове foo можно будет отсортировать всех претендентов с помощью отноше ния ≤ и выбрать самую «большую» из найденных функцию foo. Чтобы частичный порядок работал в полную силу, это отношение должно быть рефлексивным (aa), антисимметричным (если ab и ba, считает ся, что a и b идентичны) и транзитивным (если ab и bc, то aс).

D определяет отношение частичного порядка на множестве функций очень просто: если функция foo1 может быть вызвана с типами парамет ров foo2, то foo1foo2. Возможны случаи, когда foo1foo2 и foo2foo1 одновременно; в таких ситуациях говорится, что функции одинаково специализированны. Например:

// Три одинаково специализированных функции: любая из них
// может быть вызвана с типом параметра другой
void sqrt(real);
void sqrt(double);
void sqrt(float)

Эти функции одинаково специализированны, поскольку любая из них может быть вызвана как с типом float, так и с double или real (как ни странно, это разумно, несмотря на неявное преобразование с потерями, см. раздел 2.3.2).

Также возможно, что ни одна из функций не ≤ другой; в этом случае го ворится, что foo1 и foo2 неупорядочены.6 Можно привести множество случаев неупорядоченности, например:

// Две неупорядоченные функции: ни одна из них
// не может быть вызвана с типом параметра другой.
void print(double);
void print(string);

Нас больше всего интересуют случаи, когда истинно ровно одно нера венство из пары foo1foo2 и foo2foo1. Пусть истинно первое неравен ство, тогда говорится, что функция foo1 менее специализированна, чем функция foo2. А именно:

// Две упорядоченные функции: write(double) менее специализированна,
// чем write(int), поскольку первая может быть вызвана с int,
// а последняя не может быть вызвана с double.
void write(double);
void write(int);

Ввод отношения частичного порядка позволяет D принимать решение относительно перегруженного вызова foo(arg1, ..., argn) по следующему простому алгоритму:

  1. Если существует всего одно соответствие (типы и количество пара метров соответствуют списку аргументов), то использовать его.
  2. Сформировать множество кандидатов {foo1, ..., fook}, которые бы принимали вызов, если бы другие перегруженные версии вообще не существовали. Именно на этом шаге срабатывает механизм опреде ления типов и вычисляются условия в ограничениях сигнатур.
  3. Если полученное множество пусто, то выдать ошибку «нет соответ ствия».
  4. Если не все функции из сформированного множества определены в одном и том же модуле, то выдать ошибку «попытка кроссмодуль ной перегрузки».
  5. Исключить из множества претендентов на вызов все функции, менее специализированные по сравнению с другими функциями из этого множества; оставить только самые специализированные функции.
  6. Если оставшееся множество содержит больше одной функции, вы дать ошибку «двусмысленный вызов».
  7. Единственный элемент множества победитель.

Вот и все. Рассмотрим первый пример:

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 и что озна чает каждая из них?

  1. haystack.length > 0 сообщает, остались ли еще элементы в haystack.
  2. haystack[0] осуществляет доступ к первому элементу haystack.
  3. haystack = haystack[1 .. $] исключает из рассмотрения первый эле мент haystack.

Конкретный способ, каким массивы реализуют эти операции, непросто распространить на другие контейнеры. Например, проверять с помо щью выражения haystack.length > 0, есть ли в односвязном списке эле менты, подход, достойный премии Дарвина9. Если не обеспечено по стоянное кэширование длины списка (что по многим причинам весьма проблематично), то для вычисления длины списка таким способом по требуется время, пропорциональное самой длине списка, а быстрое об ращение к началу списка занимает всего лишь несколько машинных инструкций. Применить к спискам индексацию столь же проигрыш ная идея. Так что выделим сущность рассмотренных операций, пред ставим полученный результат в виде трех именованных функций и ос тавим их реализацию типу haystack. Примерный синтаксис базовых опе раций, необходимых для реализации алгоритма линейного поиска:

  1. haystack.empty для проверки haystack на пустоту.
  2. haystack.front для получения первого элемента haystack.
  3. 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. Свести но не к абсурду

Как насчет непростой задачи, использующей только диапазоны ввода? Условия звучат так: определить функцию reduce14, которая принимает диапазон ввода 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 не используется искажение имен (mang ling) функций, поэтому такая функция не может быть перегружена по типам аргументов. Если две такие функции с одинаковыми именами объявлены в разных модулях, возникнет конфликт имен. Как правило, 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 (наибольший возможный период):

  1. c и m взаимно просты.
  2. Значение a 1 кратно всем простым делителям m.
  3. Если 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. В конце концов, способность интерпретировать код во время компиляции это новшество, открывающее очень интересные возможности, которые заслуживают дальнейшего исследования.


  1. Функция find ищет «иголку» (needle) в «стоге сена» (haystack). Прим. науч. ред. ↩︎

  2. Следует подчеркнуть, что проверка выполнения подобных соглашений выполняется на этапе компиляции, и если компилятор обмануть, например с помощью приведения типов, то соглашения можно нарушить. Пример: (cast(int[])data)[5] = 42; даст именно то, что ожидается. Но это уже моветон. Прим. науч. ред. ↩︎

  3. На самом деле, in означает scope const, однако семантика scope не до конца продумана и, возможно, в дальнейшем scope вообще исчезнет из языка. Прим. науч. ред. ↩︎

  4. Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность есть в текущих реализациях языка, мы добавили ее описание. Прим. науч. ред. ↩︎

  5. На самом деле, их можно инициализировать только константами, а можно вообще не инициализировать (тогда они принимают значение по умолчанию). Прим. науч. ред. ↩︎

  6. Именно этот момент делает «частичный порядок» «частичным». В случае отношения полного порядка (например ≤ для действительных чисел) неупорядоченных элементов нет. ↩︎

  7. Речь о ежедневном комиксе американского художника Билла Уоттерсона «Кельвин и Хоббс». Прим. пер. ↩︎

  8. Тот же подход используют ML и другие реализации функциональных языков. ↩︎

  9. Премия Дарвина виртуальная премия, ежегодно присуждаемая тем, кто наиболее глупым способом лишился жизни или способности к зачатию, в результате не внеся свой вклад в генофонд человечества (и тем самым улучшив его). Прим. пер. ↩︎

  10. Хотя в приведенном примере о типе аргумента a ничего не сказано, текущая на момент выпуска книги версия компилятора 2.057 работает указанным образом только в том случае, если a массив. В ответ на пример (7).someprop() для функции void someprop(int a){} компилятор скажет, что нет свойства someprop для типа int. Прим. науч. ред. ↩︎

  11. Версия компилятора 2.057 не поддерживает атрибуты, объявляемые пользователем. В будущем такая поддержка может появиться. Прим. науч. ред. ↩︎

  12. На момент выхода книги такое поведение по умолчанию носило рекомендательный характер. Функция без аргументов и без атрибута @property могла вызываться как с пустой парой скобок, так и без. Так сделано из соображений обратной совместимости с кодом, написанным до ввода данного атрибута. Заставить компилятор проверять корректность использования скобок позволяет ключ компиляции -property (dmd 2.057). В дальнейшем некорректное применение скобок может быть запрещено, поэтому там, где требуется функция, ведущая себя как свойство, следует использовать @property. Прим. науч. ред. ↩︎

  13. Инлайнинг (inline-подстановка) подстановка кода функции в месте ее вызова. Позволяет снизить накладные расходы на вызов функции при передаче аргументов, переходе по адресу, обратном переходе, а также нагрузку на кэш памяти процессора. В версиях языка C до C99 это достигалось с помощью макросов, в C99 и С++ появились ключевое слово inline и inline-подстановка методов классов, описанных внутри описания класса. В языке D inline-подстановка отдается на откуп компилятору. Компилятор будет сам решать, где рационально ее применить, а где нет. Прим. науч. ред. ↩︎

  14. Reduce (англ.) сокращать, сводить. Прим. науч. ред. ↩︎

  15. Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание. Прим. науч. ред. ↩︎

  16. Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. Прим. науч. ред. ↩︎

  17. В данном контексте речь идет об изменениях, которые повлияли бы на последующие вызовы функции, например об изменении глобальных переменных. Прим. науч. ред. ↩︎

  18. «O» большое математическое обозначение, применяемое при оценке асимптотической сложности алгоритма. Прим. ред. ↩︎

  19. Равенство c нулю также допустимо, но соответствующая теоретическая часть гораздо сложнее, потому ограничимся значениями c > 0. ↩︎

  20. Непонятно как, но алгоритм Евклида всегда умудряется попадать в хорошие (хм...) книги по программированию. ↩︎

  21. Распространенный в США и Канаде мем, изначально связанный с фаст-фудом. Прим. ред. ↩︎

  22. Многие из этих ограничений уже сняты. Прим. науч. ред. ↩︎