- [5.2. Соглашения о передаче аргументов и классы памяти](#5-2-соглашения-о-передаче-аргументов-и-классы-памяти)
- [5.2.1. Параметры и возвращаемые значения, переданные по ссылке (с ключевым словом ref)](#5-2-1-параметры-и-возвращаемые-значения-переданные-по-ссылке-с-ключевым-словом-ref)
- [5.2.2. Входные параметры (с ключевым словом in)](#5-2-2-входные-параметры-с-ключевым-словом-in)
- [5.2.3. Выходные параметры (с ключевым словом out)](#5-2-3-выходные-параметры-с-ключевым-словом-out)
- [5.2.4. Ленивые аргументы (с ключевым словом lazy)](#5-2-4-ленивые-аргументы-с-ключевым-словом-lazy4)
- [5.2.5. Статические данные (с ключевым словом static)](#5-2-5-статические-данные-с-ключевым-словом-static)
- [5.10.3. Гетерогенные функции с переменным числом аргументов. Альтернативный подход](#5-10-3-гетерогенные-функции-с-переменным-числом-аргументов-альтернативный-подход-16)
Обсуждать данные и функции сегодня, когда даже разговоры об объектах устарели, – это как вернуться в 1970-е. Но, к сожалению, все еще за горами день, когда говоришь компьютеру, что нужно сделать, и он сам выясняет, как это сделать. А пока этот день не настал, функции – обязательный компонент всех основных направлений программирования. По большому счету, любая программа состоит из вычислений, гоняющих данные туда-сюда; возводимые нами замысловатые строительные леса – типы, объекты, модули, фреймворки, шаблоны проектирования – только придают вычислениям нужные нам свойства, такие как модульность, изоляция ошибок или легкость сопровождения. Правильный язык программирования позволяет своему пользователю держаться золотой середины между кодом «для действия» и кодом «для существования». Идеальное соотношение зависит от множества факторов, из которых самый очевидный – размер программы: основная задача короткого скрипта – действовать, тогда как большое приложение вынуждено заниматься поддержкой неисполняемых вещей вроде интерфейсов, протоколов и модульных ограничений.
Благодаря своим мощным средствам моделирования D позволяет создавать объемистые программы; при этом он старается сократить до разумных пределов код «для существования», позволяя сосредоточиться на том, что нужно «для действия». Хорошо написанные функции на D, как правило, соединяют в себе компактность и универсальность, достигая порой ошеломляющей удельной мощности. Так что пристегнитесь, будем жечь резину.
[В начало ⮍](#5-данные-и-функции-функциональный-стиль)
## 5.1. Написание и модульное тестирование простой функции
Можно с полным основанием утверждать: главное, чем занимаются компьютеры (помимо скучных дел вроде ожидания ввода данных), – это *поиск*. Серверы и клиенты баз данных – ищут. Программы искусственного интеллекта – ищут. (А надоедливый болтун – банковский автоответчик? Тоже ищет.) Интернет-поисковики… ну, с этими ясно. Да и собственный опыт наверняка говорит вам, что, по сути, многие программы, якобы не имеющие с поиском ничего общего, на самом деле тоже довольно-таки много ищут. Какую бы задачу ни требовалось решить, всегда задействуется поиск. В свою очередь, многие оригинальные решения зависят от интеллектуальности и удобства программного поиска. Как и следовало ожидать, в мире вычислений полно понятий, имеющих отношение к поиску: сопоставление c шаблоном, реляционная алгебра, бинарный поиск, хеш-таблицы, бинарные деревья, префиксные деревья, красно-черные деревья, списки с пропусками… все это нам здесь никак не охватить, так что сейчас поставим цель поскромнее – определим несколько простых функций поиска на D, начав с простейшей из них – функции линейного поиска. Итак, без лишних слов напишем функцию, которая сообщает, содержит ли срез значений типа `int` определенное значение типа `int`.
```d
bool find(int[] haystack, int needle)
{
foreach (v; haystack)
{
if (v == needle) return true;
}
return false;
}
```
Отлично. Поскольку это первое наше определение функции на D, опишем во всех подробностях, что именно она делает. Встретив определение функции `find`, компилятор приведет ее к более низкоуровневому представлению – скомпилирует в двоичный код. Во время исполнения программы при вызове функции `find` параметры `haystack` и `needle`[^1] передаются в нее по значению. Это вовсе не означает, что если вы передадите в функцию массив из миллиона элементов, то он будет полностью скопирован; как отмечалось в главе 4, тип `int[]` (срез массива элементов типа `int`), который также называют *толстым указателем* (*fat pointer*), – это на самом деле пара «указатель + длина» или пара «указатель + указатель», которая хранит только границы указанного фрагмента массива. Из раздела 4.1.4 понятно, что передать в функцию `find` срез миллионного массива на самом деле означает передать в нее информацию, достаточную для получения адреса начала и конца этого среза. (Язык D и его стандартная библиотека широко поддерживают работу с контейнером через его маленького, ограниченного представителя, который знает, как перемещаться по контейнеру. Обычно такой представитель называется *диапазоном*.) Так что в итоге в функцию `find` из вызывающего ее кода передаются только три машинных слова. Как только управление передано функции `find`, она делает свое дело и возвращает логическое значение (обычно в регистре процессора), которое вызвавший ее код уже готов получить. Что ж, как ободряюще говорят в конце телешоу «Большой ремонт», завершив какую-то неимоверно сложную работу: «Вот и все, что нужно сделать».
Если честно, в устройстве `find` есть кое-какие недостатки. Возвращаемое значение имеет тип `bool`, это очень неинформативно; также требуется информация о позиции найденного элемента, например, для продолжения поиска. Можно было бы возвращать целое число (и какое-нибудь особое значение, например `-1`, для случаев «элемент не найден»). Но хотя целые числа отлично подходят для доступа к элементам массива, занимающего непрерывную область памяти, они ужасно неэффективны с большинством других контейнеров (таких как связные списки). Чтобы добраться до `n`-го элемента связного списка после того, как функция `find` вернула `n`, понадобится пройти по списку элемент за элементом, начиная сего головы – то есть проделать почти ту же работу, что и сама операция поиска! Так что возвращать целое число в качестве результата – плохая идея в случае любой структуры данных, кроме массива.
Есть один способ, который сработает с разнообразными контейнерами – массивами, связными списками и даже с файлами и сокетами. Надо сделать так, чтобы функция `find` просто отщипывала по одному элементу («соломинке»?) от «стога сена» (`haystack`), пока не обнаружит искомое значение, и возвращала то, что останется от `haystack`. (Соответственно, если значение не найдено, функция `find` вернет опустошенный `haystack`.) Вот простая и обобщенная спецификация: «функция `find(haystack, needle)` сужает структуру данных `haystack` слева до тех пор, пока значение `needle` не станет началом, или до тех пор, пока не закончатся элементы в `haystack`, и затем возвращает остаток `haystack»`. Давайте реализуем эту идею для типа `int[]`.
```d
int[] find(int[] haystack, int needle)
{
while (haystack.length > 0 && haystack[0] != needle)
{
haystack = haystack[1 .. $];
}
return haystack;
}
```
Обратите внимание: функция `find` обращается только к первому элементу массива `haystack` и последовательно присваивает исходному массиву более узкое подмножество его самого. Эти примитивы потом легко можно заменить, скажем, специфичными для списков примитивами, но обобщением мы займемся чуть позже. А пока проверим, насколько хорошо работает полученная функция `find`.
В последние годы большинство методологий разработки уделяют все больше внимания правильному тестированию программного обеспечения. Это верное направление, поскольку тщательное тестирование действительно помогает отслеживать гораздо больше ошибок. В духе времени напишем короткий тест модуля, проверяющий работу нашей функции `find`. Просто вставьте следующий код после (как это сделал я) или до (как сделал бы фанат разработки через тестирование) определения функции `find`:
Все, что нужно сделать, чтобы получить работающий модуль, – это поместить функцию и тест модуля в файл `searching.d`, а затем ввести в командной строке:
```d
$ rdmd --main -unittest searching.d
```
Если вы запустите компилятор с флагом `-unittest`, тесты модулей будут скомпилированы и подготовлены к запуску перед исполнением основной программы. Иначе компилятор проигнорирует все блоки `unittest`, что может быть полезно, если требуется запустить уже оттестированный код без задержек на начальном этапе. Флаг `--main` предписывает `rdmd` добавить ничего не делающую функцию `main`. (Если вы забыли написать `--main`, не волнуйтесь; компоновщик тут же витиевато напомнит вам об этом на своем родном языке – зашифрованном клингонском.) Заменитель функции `main` нужен нам, так как мы хотим запустить только тест модуля, а не саму программу. Ведь наш маленький файл может заинтересовать массу программистов, и они станут использовать его в своих проектах, в каждом из которых определена своя функция `main`.
Как уже говорилось, в функцию `find` передаются два аргумента (первый – типа `int`, а второй – толстый указатель, представляющий массив типа `int[]`), которые копируются в ее личные владения. Когда функция `find` возвращает результат, толстый указатель копируется обратно в вызывающий код. В этой последовательности действий легко распознать явный вызов по значению. В частности, изменения аргументов не будут «видны» инициатору вызова после того, как управление снова перейдет к нему. Однако остерегаться побочного эффекта все-таки нужно: учитывая, что *содержимое* среза не копируется, изменения отдельных элементов среза *будут видны* инициатору вызова. Рассмотрим пример:
Что же произошло? В первых двух случаях функции `fun` и `gun` изменили только собственные копии параметров. В частности, во втором случае толстый указатель был перенаправлен на другую область памяти, но исходный массив не был затронут. Однако в третьем случае функция `hun` решила изменить один элемент массива, и это изменение отразилось на исходном массиве. Это легко понять, представив, что срез y находится совсем не в том же месте, что и три целых числа, которыми y управляет. Так что если вы присвоите срез целиком, а-ля `x = [1, 2, 3]`, то срез, который раньше содержала переменная `x`, будет предоставлен самому себе, а`x` начнет новую жизнь; но если вы измените какой-то элемент `x[i]` среза `x`, то другие срезы, которым виден этот элемент (в нашем случае – в коде, вызвавшем `fun`), будут видеть и это изменение.
Если функция ожидает значение по ссылке, то она принимает только «настоящие данные», а не временные значения. Все, что не является l-значением, отвергается во время компиляции. Например:
Ключевым словом `ref` можно также снабдить результат функции. В этом случае за ним самим будет закреплен статус l-значения. Например, изменим функцию `bump` так:
Внутренний вызов функции `bump` возвращает l-значение, поэтому такой результат можно правомерно использовать в качестве аргумента при внешнем вызове той же функции. Если бы определение `bump` выглядело так:
то компилятор отверг бы вызов `bump(bump(x))` как незаконную попытку привязать r-значение, возвращенное при вызове `bump(x)`, параметру, передаваемому по ссылке при внешнем вызове `bump`.
Этот код не компилируется, то есть ключевое слово `in` накладывает на код достаточно строгие ограничения. Функция `fun` не может изменить даже собственную копию аргумента.
Практически неизменяемый параметр внутри функции, конечно, может быть полезен при анализе ее реализации, но еще более любопытный эффект наблюдается *за пределами* функции. Ключевое слово `in` запрещает даже косвенные изменения параметра, то есть те изменения, которые отражаются на объекте после того, как функция вернет управление вызвавшему ее коду. Это делает неизменяемые параметры невероятно полезными, поскольку они дают гарантии инициатору вызова, а не только внутренней реализации функции. Например:
В первом случае ошибка неудивительна, поскольку она того же типа, что и приведенная выше ошибка с изменением отдельного значения типа `int`. Гораздо интереснее, почему возникла вторая ошибка. Неким магическим образом компилятор распространил действие ключевого слова `in`с самого массива `data` на все его ячейки – то есть `in` обладает «глубоким» воздействием.
Так что ключевое слово `in` защищает свои данные от изменений *транзитивно*, полностью сверху донизу, учитывая все возможности косвенного доступа[^2]. Такое поведение не является специфичным для массивов, оно распространяется на все типы данных языка D. В действительности, ключевое слово `in` в контексте параметра – это синоним квалификатора типа `const`[^3], подробно описанного в главе 8.
[В начало ⮍](#5-2-2-входные-параметры-с-ключевым-словом-in) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Иногда параметры передаются по ссылке только для того, чтобы функция с их помощью что-то вернула. В таких случаях можно воспользоваться классом памяти `out`, напоминающим `ref`, – разница лишь в том, что перед входом в функцию `out` инициализирует свой аргумент значением по умолчанию (соответствующим типу аргумента):
В этом коде можно было бы с тем же успехом вместо ключевого слова `out` использовать `ref`, поскольку выбор `out` всего лишь извещает инициатора вызова, что функция `divrem` не ожидает от параметра `rem` осмысленного значения.
Порой значение одного из аргументов функции требуется лишь в исключительном случае, а в остальных вычислять его не нужно и хотелось бы избежать напрасных усилий. Рассмотрим пример:
Как видим, вычислять выражение `"foo() returned " ~ to!string(result)` нужно, только если переменная `verbose` имеет значение `true`. При этом выражение, передаваемое этой функции в качестве аргумента, будет вычислено в любом случае. В данном примере это конкатенация двух строк, которая потребует выделения памяти и копирования в нее содержимого каждой из них. И все это для того, чтобы узнать, что переменная `verbose` имеет значение `false` и значение аргумента никому не нужно! Можно было бы передавать вместо строки делегат, возвращающий строку (делегаты описаны в разделе 5.6.1):
В этом случае аргумент будет вычислен, только если он действительно нужен, но такая форма слишком громоздка. Поэтому D вводит такое понятие, как «ленивые» аргументы. Такие аргументы объявляются с атрибутом `lazy`, выглядят как обычные аргументы, но вычисляются только тогда, когда требуется их значение.
Несмотря на то что ключевое слово `static` не имеет отношения к передаче аргументов функциям, разговор о нем здесь к месту, поскольку, как и `ref`, атрибут `static` данных определяет *класс памяти*, то есть несколько подробностей хранения этих данных.
Любое объявление переменной может быть дополнено ключевым словом `static`. В этом случае *для каждого потока исполнения* будет создана собственная копия этой переменной. Рациональное обоснование и последствия этого отступления от установленной языком C традиции выделять единственную копию `static`-переменной для всего приложения обсуждаются в главе 13.
Статические данные сохраняют свое значение между вызовами функций независимо от места их определения (внутри или вне функции). Выбор размещения статических данных в разнообразных контекстах касается только видимости, но не хранения. На уровне модуля данные с атрибутом `static` в действительности обрабатываются так же, как и данные с атрибутом `private`.
Статические данные должны быть инициализированы константами[^5], вычисляемыми во время компиляции. Инициализировать статические данные уровня функции при первом ее вызове можно с помощью простого трюка, который использует в качестве напарника статическую логическую переменную:
Вернемся к функции `find`, определенной в разделе 5.1, поскольку в ней есть немало спорных моментов. Во-первых, эта функция может быть полезна только в довольно редких случаях, поэтому стоит поискать возможность ее обобщения. Начнем с простого наблюдения. Присутствие в `find` типа `int`– это пример жесткого кодирования, простого и ясного. В логике кода ничего не изменится, если придется искать значения типа `double` в срезах типа `double[]` или значения типа `string` в срезах типа `string[]`. Поэтому можно попробовать заменить тип `int` некой заглушкой – параметром функции `find`, который описывал бы тип, а не значение задействованных сущностей. Чтобы воплотить эту идею, нужно привести наше определение к следующему виду:
Как и ожидалось, тело функции `find` не претерпело никаких изменений, изменилась только сигнатура. Теперь в ней две пары круглых скобок: в первой перечислены параметры типов функции, а вторая содержит обычный список параметров, которые могут воспользоваться только что определенными параметрами типов. Теперь можно обрабатывать не только срезы элементов типа `int`, но срезы *чего угодно* (неважно, встроенные это или пользовательские типы). В довершение наш предыдущий тест `unittest` продолжает работать, так как компилятор автоматически выводит тип T из типов аргументов. Чисто сработано! Но не станем почивать на лаврах и добавим тест модуля, который бы подтверждал оправданность этих повышенных ожиданий:
Что же происходит, когда компилятор видит усовершенствованное определение функции `find`? Компилятор сталкивается с гораздо более сложной задачей, чем в случае с аргументом типа `int[]`, потому что теперь `T` еще неизвестен – это может быть какой угодно тип. А разные типы записываются по-разному, передаются по-разному и щеголяют разными определениями оператора `==`. Решить эту задачу очень важно, поскольку параметры типов действительно открывают новые перспективы и в разы расширяют возможности для повторного использования кода. В настоящее время наиболее распространены два подхода к генерации кода для параметризации типов:
- *Гомогенная трансляция*: все данные приводятся к общему формату, что позволяет скомпилировать единственную версию `find`, которая подойдет всем.
- *Гетерогенная трансляция*: при каждом вызове `find`с различными аргументами типов (`int`, `double`, `string` и т. д.) компилятор генерирует отдельную версию `find` для каждого использованного типа.
Гомогенная трансляция подразумевает, что язык обязан предоставить универсальный интерфейс доступа к данным, которым воспользуется `find`. А гетерогенная трансляция больше напоминает помощника, пишущего по одному варианту функции `find` для каждого формата данных, который вам может встретиться, при этом все варианты он строит по одной заготовке. Очевидно, что у обоих этих подходов есть как преимущества, так и недостатки, о чем нередко ведутся жаркие споры в разных программистских сообществах. Плюсы гомогенной трансляции – универсальность, простота и компактность сгенерированного кода. Например, в чисто функциональных языках все представляется в виде списков, а во многих чисто объектно-ориентированных языках – в виде объектов; в обоих случаях предлагается универсальный доступ к данным. Тем не менее гомогенной трансляции свойственны такие недостатки, как строгость, недостаток выразительности и неэффективность. Гетерогенная трансляция, напротив, отличается специализированностью, выразительной мощью и скоростью сгенерированного кода. Плата за это – распухание готового кода, усложнение языка и неуклюжая модель компиляции (обычный упрек в адрес гетерогенных подходов – что они представляют собой «возвеличенный макрос» [вздох]; а поскольку благодаря C макрос считается чем-то нехорошим, этот ярлык придает гетерогенной компиляции сильный негативный оттенок).
Тут стоит обратить внимание на одну деталь: гетерогенная трансляция включает гомогенную по той простой причине, что «один формат» входит в «множество форматов», а «одна реализация» – в «множество реализаций». На этом основании (все прочие спорные моменты пока отложим) можно утверждать, что гетерогенная трансляция мощнее гомогенной. При наличии средства гетерогенной трансляции ничто не мешает, по крайней мере теоретически, использовать один универсальный формат данных и одну универсальную функцию, когда захочется. Обратное, при использовании гомогенного подхода, просто невозможно. Тем не менее наивно было бы считать гетерогенные подходы «лучшими», поскольку кроме выразительной мощи есть другие аргументы, которые также нельзя упускать из виду.
D использует гетерогенную трансляцию (внимание, ожидается бомбардировка техническими терминами) с поиском статически определенных идентификаторов и отложенной проверкой типов. Это означает, что, встретив определение обобщенной функции `find`, компилятор D выполняет синтаксический разбор ее тела, сохраняет результаты, запоминает место определения функции – и больше ничего, до тех пор пока кто-нибудь не вызовет `find`. В этот момент компилятор извлекает разобранное определение `find` и пытается скомпилировать его, подставив тип, который инициатор вызова передал взамен `T`. Если функция использует идентификаторы (символы), компилятор ищет их в том контексте, где была определена эта функция.
Если компилятор не смог сгенерировать функцию `find` для этого конкретного типа, генерируется сообщение об ошибке. Что на самом деле довольно неприятно, поскольку исключение может возникнуть из-за незамеченной ошибки в `find`. Зато теперь у нас есть веский повод прочесть следующий раздел, потому что `find` содержит две ошибки – не функциональные, а связанные с обобщенностью: теперь понятно, что функция `find` одновременно и излишне, и недостаточно обобщенна. Посмотрим, как работает этот дзэнский тезис.
Вот мы и в западне. В данной ситуации функция `find` ожидает значение типа `T[]` в качестве первого аргумента и значение типа `T` в качестве второго. Тем не менее `find` получает значение типа `double[]` и значение типа `int`, то есть `T = double` и `T = int` соответственно. Если мы достаточно пристально вглядимся в этот код, то, конечно же, заметим, что инициатор вызова в действительности хотел использовать в качестве `T` тип `double` и собирался реализовать свою задумку, рассчитывая на аккуратное неявное приведение значения типа `int` к типу `double`. Тем не менее заставлять язык пытаться комбинаторно выполнить сразу и неявное преобразование, и вывод типов – в общем случае рискованное предприятие, поэтому D все это проделать не пытается. Раз вы сказали `T[]` и `T`, то не можете передать `double[]` и `int`.
Похоже, нашей реализации функции `find` недостает обобщенности, поскольку она требует, чтобы типы среза и искомого значения были идентичны. А на самом деле для заданного типа среза мы должны принимать *любое* значение, сравнимое с элементом среза с помощью оператора `==`.
Теперь функция проходит тест на ура. Но технически полученная функция `find` лжет, поскольку заявляет, что принимает абсолютно любые `T` и `E`, в том числе их бессмысленные сочетания! Чтобы показать, почему эту неточность нужно считать проблемой, рассмотрим следующий вызов:
Компилятор действительно обнаруживает проблему; однако находит он ее в сравнении, расположенном в теле функции `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*):
Выражение `if` в сигнатуре во всеуслышание заявляет, что функция `find` примет параметр `haystack` типа `T[]` и параметр `needle` типа `E`, только если выражение `haystack[0] != needle` возвращает логический тип. У этого ограничения есть ряд важных последствий. Во-первых, выражение `if` проясняет для автора, компилятора и читателя, чего именно функция `find` ждет от своих параметров, избавляя всех троих от необходимости исследовать тело функции (обычно куда более объемное, чем у нашей). Во-вторых, с выражением `if` в качестве буксира функция `find` теперь легко отклонит вызов при попытке передать параметры, не поддающиеся сравнению, что, в свою очередь, позволяет гладко срабатывать другим средствам языка, таким как перегрузка функций. В-третьих, новое определение помогает компилятору конкретизировать свои сообщения об ошибках: теперь очевидно, что ошибка происходит при обращении к функции `find`, а не в ее теле.
Заметим, что выражение, к которому применяется оператор `typeof`, никогда не вычисляется во время исполнения программы; оператор лишь определяет тип выражения, если оно скомпилируется. (Если выражение с оператором `typeof` не компилируется, то это не ошибка компиляции, а просто сигнал, что рассматриваемое выражение не имеет никакого типа, а «никакого типа» – это не `bool`.) В частности, не стоит беспокоиться о том, что в проверку вовлечено значение `haystack[0]`, даже если длина `haystack` равна нулю. И обратно: в ограничении сигнатуры запрещается использовать условия, не вычислимые во время компиляции программы; например, нельзя ограничить функцию `find` условием `needle > 0`.
Мы определили функцию `find`, чтобы определить срез и элемент. А теперь напишем новую версию функции `find`, которая сообщает, можно ли найти один срез в другом. Обычный подход к решению этой проблемы – поиск полным перебором, с двумя вложенными циклами. Такой алгоритм не очень эффективен: время его работы пропорционально произведению длин рассматриваемых срезов. Но мы пока не будем беспокоиться об эффективности алгоритма, а сосредоточимся на определении хорошей сигнатуры для только что добавленной функции. Предыдущий раздел снабдил нас практически всем, что нужно. И действительно, сама собой напрашивается реализация:
Оно, конечно, немного короче, но зато на порядок строже. Наша реализация, не копируя данные, может сказать, содержит ли срез элементов типа `int` срез элементов типа `long`, а срез элементов типа `double`– срез элементов типа `float`. Упрощенной сигнатуре эти возможности были просто недоступны. Вам бы пришлось или повсюду копировать данные, чтобы гарантировать наличие на месте нужных типов, или вообще отказаться от затеи с общей функцией и выполнять поиск вручную. А что это за функция, если она хорошо смотрится в игрушечных примерах и не справляется с серьезной нагрузкой!
Поскольку мы добрались до реализации, заметим уже хорошо знакомое сужение среза `longer` по одному элементу слева (во внешнем цикле). Задача внутреннего цикла – сравнение массивов `longer[0 .. shorter.length] == shorter`, где сравниваются первые `shorter.length` элементов среза `longer`с элементами среза `shorter`.
D поддерживает перегрузку функций: несколько функций могут разделять одно и то же имя, если отличаются числом аргументов или типом хотя бы одного из них. Во время компиляции правила языка определяют, какая именно функция должна быть вызвана. Перегрузка основана на нашей врожденной лингвистической способности избавляться от двусмысленности в значении слов, используя контекст. Это средство языка позволяет предоставить обширную функциональность, избегая соответствующего роста количества терминов, которые должен запомнить инициатор вызовов. С другой стороны, если правила выбора реализации функции при вызове слишком неопределенны, люди могут думать, что вызывают одну функцию, а на самом деле будут вызывать другую. А если упомянутые правила, наоборот, сделать слишком жесткими, программисту придется искажать логику своего кода, объясняя компилятору, какую функцию он имел в виду. D старается сохранить простоту правил, и в этом конкретном случае применяемое правило не является заумным: если вычисление ограничения сигнатуры функции (выражения `if`) возвращает `false`, функция просто удаляется из множества перегрузки –ее вообще перестают рассматривать как претендента на вызов. Для наших двух версий функции `find` соответствующие выражения `if` никогда не являются истинными одновременно (с одними и теми же аргументами). Так что при любом вызове `find` по крайней мере один вариант перегрузки себя скрывает; никогда не возникает двусмысленность, над которой нужно ломать голову. Итак, продолжим ход своей мысли с помощью теста модуля:
Неважно, где расположены эти две функции `find`: в одном или разных файлах; между ними никогда не возникнет соревнование, поскольку выражения `if` в ограничениях их сигнатур никогда не являются истинными одновременно. Продолжая обсуждение правил перегрузки, представим, что мы очень много работаем с типом `int[]` и хотим определить для него оптимизированный вариант функции `find`:
В этой записи версия функции `find` не имеет параметров типа. Кроме того, вполне ясно, что между обобщенной версией `find`, которую мы определили выше, и специализированной версией для целых значений происходит некое состязание. Каково относительное положение этих двух функций в пищевой цепи перегрузки и какой из них удастся захватить вызов ниже?
Подход D к решению этого вопроса очень прост: выбор всегда падает на более специализированную функцию. Однако в более общем случае понятие «более специализированная» требует некоторого объяснения; оно подразумевает, что существует некоторое отношение порядка специализированности, «меньше или равно» для функций. И оно существует на самом деле; это отношение называется *отношением частичного порядка на множестве функций* (*partial ordering of functions*).
Судя по названию, без черного пояса по матан-фу с этим не разобраться, а между тем отношение частичного порядка – очень простое понятие. Считайте это распространением знакомого нам числового отношения ≤ на другие множества, в нашем случае на множество функций. Допустим, есть две функции `foo1` и `foo2`, и нужно узнать, является ли `foo1` чуть менее подходящей для вызова, чем `foo2` (вместо «`foo1` подходит меньше, чем `foo2`» будем писать `foo1` ≤ `foo2`). Если определить такое отношение, то у нас появится критерий, по которому можно определить, какая из функций выигрывает в состязании за вызов при перегрузке: при вызове `foo` можно будет отсортировать всех претендентов с помощью отношения ≤ и выбрать самую «большую» из найденных функцию `foo`. Чтобы частичный порядок работал в полную силу, это отношение должно быть рефлексивным (`a` ≤ `a`), антисимметричным (если `a` ≤ `b` и `b` ≤ `a`, считается, что `a` и `b` идентичны) и транзитивным (если `a` ≤ `b` и `b` ≤ `c`, то `a` ≤ `с`).
D определяет отношение частичного порядка на множестве функций очень просто: если функция `foo1` может быть вызвана с типами параметров `foo2`, то `foo1` ≤ `foo2`. Возможны случаи, когда `foo1` ≤ `foo2` и `foo2` ≤ `foo1` одновременно; в таких ситуациях говорится, что функции *одинаково специализированны*. Например:
Эти функции одинаково специализированны, поскольку любая из них может быть вызвана как с типом `float`, так и с`double` или `real` (как ни странно, это разумно, несмотря на неявное преобразование с потерями, см. раздел 2.3.2).
Также возможно, что ни одна из функций не ≤ другой; в этом случае говорится, что `foo1` и `foo2`*неупорядочены*.[^6] Можно привести множество случаев неупорядоченности, например:
Нас больше всего интересуют случаи, когда истинно ровно одно неравенство из пары `foo1` ≤ `foo2` и `foo2` ≤ `foo1`. Пусть истинно первое неравенство, тогда говорится, что функция `foo1` менее специализированна, чем функция `foo2`. А именно:
Ввод отношения частичного порядка позволяет D принимать решение относительно перегруженного вызова `foo(arg1, ..., argn)` по следующему простому алгоритму:
1. Если существует всего одно соответствие (типы и количество параметров соответствуют списку аргументов), то использовать его.
2. Сформировать множество кандидатов `{foo1, ..., fook}`, которые бы принимали вызов, если бы другие перегруженные версии вообще не существовали. Именно на этом шаге срабатывает механизм определения типов и вычисляются условия в ограничениях сигнатур.
3. Если полученное множество пусто, то выдать ошибку «нет соответствия».
4. Если не все функции из сформированного множества определены в одном и том же модуле, то выдать ошибку «попытка кроссмодульной перегрузки».
5. Исключить из множества претендентов на вызов все функции, менее специализированные по сравнению с другими функциями из этого множества; оставить только самые специализированные функции.
6. Если оставшееся множество содержит больше одной функции, выдать ошибку «двусмысленный вызов».
Здесь нет точного соответствия, можно применить любую из функций, поэтому на сцене появляется частичное упорядочивание. Из него следует, что, несмотря на способность обеих функций принять вызов, первая из них более специализированна, поэтому победа присуждается ей. (Хорошо это или плохо, но `int` автоматически приводится к `uint`.) А теперь добавим в наш набор обобщенную функцию:
Что же происходит, когда функция `transmogrify(uint)` сравнивается с функцией `transmogrify(T)(T)` на предмет специализированности? Хотя было решено, что `T = int`, во время сравнения `T` не заменяется на `int`, обобщенность сохраняется. Может ли функция `transmogrify(uint)` принять некоторый произвольный тип `T`? Нет, не может. Поэтому можно сделать вывод, что версия `transmogrify(T)(T)` менее специализированна, чем `transmogrify(uint)`, так что обобщенная функция исключается из множества претендентов на вызов. Итак, в общем случае предпочтение отдается необобщенным функциям, даже когда для их применения требуется неявное приведение типов.
Четвертый шаг алгоритма из предыдущего раздела заслуживает особого внимания. Вот немного измененный пример с перегруженными версиями для типов `uint` и `long` (разница лишь в том, что задействовано больше файлов):
Перегруженная версия `transmogrify(uint)` из модуля `hobbes.d` является более специализированной; но компилятор все же отказывается вызвать ее, диагностируя двусмысленность. D твердо отвергает кроссмодульную перегрузку. Если бы такая перегрузка была разрешена, то значение вызова зависело бы от взаимодействия множества включенных модулей (в общем случае может быть много модулей, много перегруженных версий и больше сложных вызовов, за которые будет вестись борьба). Представьте: вы добавляете в работающий код всего одну новую команду `import`– и его поведение изменяется непредсказуемым образом! Кроме того, если разрешить кроссмодульную перегрузку, читать код явно станет на порядок труднее: чтобы выяснить, какая функция будет вызвана, нужно будет знать, что содержит не один модуль, а все включенные модули, поскольку в каком-то из них может быть определено лучшее соответствие. И даже хуже: если бы имел значение порядок определений на верхнем уровне, вызов вида `transmogrify(5)` мог бы в действительности завершиться вызовом различных функций в зависимости от их расположения в файле. Кроссмодульная перегрузка – это неиссякаемый источник проблем, поскольку подразумевает, что при чтении фрагмента кода нужно постоянно держать в голове большой меняющийся контекст.
Один модуль может содержать группу перегруженных версий, реализующих нужную функциональность для разных типов. Второй модуль может вторгнуться, только чтобы добавить что-то новое к этой функциональности. Однако второй модуль может определять собственную группу перегруженных версий. Пока функция в одном модуле не начинает угонять вызовы, которые по праву должны были принадлежать функциям другого модуля, двусмысленность не возникает. До вызова функции нет возможности узнать, существует ли конфликт. Рассмотрим пример:
Кельвин, Хоббс и Сьюзи взаимодействуют интересными способами. Обратите внимание, насколько тонки различия между двусмысленностями в примере; первый вызов порождает конфликт между модулями `calvin.d` и `hobbes.d`, но это совершенно не значит, что эти модули взаимнонесовместимы: третий вызов проходит гладко, поскольку ни одна функция в других модулях не в состоянии обслужить его. Наконец, модуль `susie.d` определяет собственные перегруженные версии и никогда не конфликтует с остальными двумя модулями (в отличие от одноименных персонажей комикса[^7]).
Где бы вы ни встретили двусмысленность из-за кроссмодульной перегрузки, вы всегда можете указать направление перегрузки одним из двух основных способов. Первый – уточнить свою мысль, снабдив имя функции именем модуля, как это показано на примере второго вызова `calvin.transmogrify(5)`. Поступив так, вы ограничите область поиска функции единственным модулем `calvin.d`. Внутри этого модуля также действуют правила перегрузки. Более очевидный способ – назначить проблемному идентификатору *локальный псевдоним*. Например:
Эта директива делает нечто весьма интересное: она свозит все перегруженные версии `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`).
Теперь при любом вызове `transmogrify` из модуля `client.d` решение о перегрузке будет приниматься так, будто перегруженные версии `transmogrify`, определенные в модулях `calvin.d` и `hobbes.d`, присутствуют в модуле `client.d`.
[В начало ⮍](#5-5-2-кроссмодульная-перегрузка) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Мы уже знаем, как найти элемент или срез в другом срезе. Однако под поиском не всегда подразумевается просто поиск заданного значения. Задача может быть сформулирована и так: «Найти в массиве чисел первый отрицательный элемент». Несмотря на все свое могущество, наша библиотека поиска не в состоянии выполнить это задание.
Основная идея функции `find` в том, что она ищет значение, удовлетворяющее некоторому логическому условию, или предикату; до сих пор в роли предиката всегда выступало сравнение на равенство (оператор `==`). Однако более гибкая функция `find` может принимать предикат от пользователя и выстраивать логику линейного поиска вокруг него. Если удастся наделить функцию `find` такой мощью, она превратится в *функцию высокого порядка*, то есть функцию, которая может принимать другие функции в качестве аргументов. Это очень мощный подход к решению задач, поскольку объединяя собственную функциональность и функциональность, предоставляемую ее аргументами, функция высокого порядка достигает гибкости поведения, недоступной простым функциям. Чтобы заставить функцию `find` принимать предикат, воспользуемся *параметром-псевдонимом*.
Эта новая перегруженная версия функции `find` принимает не только «классический» параметр, но и загадочный параметр-псевдоним `alias pred`. Параметру-псевдониму можно поставить в соответствие любой аргумент: значение, тип, имя функции – все, что можно выразить знаками. А теперь посмотрим, как вызывать эту новую перегруженную версию функции `find`.
На этот раз функция `find` принимает два списка аргументов. Первый список отличается синтаксисом `!(...)` и содержит обобщенные аргументы. Второй список содержит классические аргументы. Обратите внимание: несмотря на то что функция `find` объявляет два обобщенных параметра (`alias pred` и `T`), вызывающий ее код указывает только один аргумент. Вызов имеет такой вид, поскольку никто не отменял работу механизма определения типов: по контексту автоматически определяется, что `T = int`. До этого момента при наших вызовах `find` никогда не возникало необходимости указывать какие-либо обобщенные аргументы: компилятор определял их за нас. Однако на этот раз автоматически определить `pred` невозможно, поэтому мы указали его в виде функционального литерала. Функциональный литерал – это запись
Функциональные литералы (также известные как анонимные функции, или лямбда-функции) очень полезны во множестве ситуаций, однако их синтаксис сложноват. Длина литерала в наше примере – 41 знак, но только около 5 знаков занимаются настоящим делом. Чтобы решить эту проблему, D позволяет серьезно урезать синтаксис. Первое сокращение – это уничтожение возвращаемого типа и типов параметров: компилятор достаточно умен, чтобы определить их все, поскольку тело анонимной функции всегда под рукой.
Второе сокращение – изъятие собственно ключевого слова `function`. Можно применять оба сокращения одновременно, как это сделано здесь (получается очень сжатая форма записи):
Важное требование к механизму лямбда-функций: он должен разрешать доступ к контексту, в котором была определена лямбда-функция. Рассмотрим слегка измененный вариант:
Этот видоизмененный пример работает, что уже о многом говорит. Но если, просто ради эксперимента, вставить перед функциональным литералом ключевое слово, код загадочным образом перестает работать!
Что же происходит и что это за жалоба о кадре стека? Очевидно, должен быть какой-то внутренний механизм, с помощью которого функциональный литерал получает доступ к переменной `z`– он не может чудом добыть ее расположение из воздуха. Этот механизм закодирован в виде скрытого параметра –*указателя на кадр стека*, принимаемого литералом. Компилятор использует указатель на кадр стека, чтобы осуществлять доступ к внешним переменным, таким как `z`. Тем не менее функциональному литералу, который *не* использует никаких локальных переменных, не требуется дополнительный параметр. Будучи статически типизированным языком, D должен различать эти случаи, и он действительно различает их. Кроме функциональных литералов есть еще литералы делегатов, которые создаются так:
В отличие от функций, делегаты имеют доступ к включающему их фрейму. Если в литерале нет ключевых слов `function` и `delegate`, компилятор автоматически определяет, какое из них подразумевалось. И снова на помощь приходит механизм определения типов по контексту, позволяя самому сжатому, самому удобному коду еще и автоматически делать то, что нужно.
Теперь можно вызывать функцию `find`с произвольным функциональным литералом, что довольно изящно. Но если литерал сильно разрастается или появляется желание использовать его несколько раз, становится неудобно писать тело функции в месте ее вызова (предположительно несколько раз). Хотелось бы вызывать `find`с именованной функцией (а не анонимной); кроме того, желательно сохранить право доступа к локальным переменным на случай, если понадобится к ним обратиться. Для этой и многих других задач D предоставляет такое средство, как вложенные функции.
Определение вложенной функции выглядит точно так же, как определение обычной функции, за исключением того, что вложенная функция объявляется внутри другой функции. Например:
Вложенные функции могут быть очень полезны во многих ситуациях. Не делая ничего свыше того, что может сделать обычная функции, вложенная функция повышает удобство и модульность, поскольку расположена прямо внутри функции, которая ее использует, и имеет доступ к ее контексту. Последнее преимущество особенно важно; если бы в рассмотренном примере нельзя было воспользоваться вложенностью, получить доступ к `z` было бы гораздо сложнее.
Применив тот же трюк, что и функциональный литерал (скрытый параметр), вложенная функция `isTransmogrifiable` получает доступ к фрейму стека своего родителя, в частности к переменной `z`. Иногда может понадобиться заведомо избежать таких обращений к родительскому фрейму, превратив `isTransmogrifiable` в самую обычную функцию, за исключением места ее определения (внутри `transmogrify`). Для этого просто добавьте перед определением `isTransmogrifiable` ключевое слово `static` (а какое еще?):
Теперь, с ключевым словом `static` в качестве буксира, функции `isTransmogrifiable` доступны лишь данные, определенные на уровне модуля, и данные внутри `transmogrify`, также помеченные ключевым словом `static` (как показано на примере переменной `w`). Любые данные, которые могут изменяться от вызова к вызову, такие как параметры функций или нестатические переменные, недоступны (но, разумеется, могут быть переданы явно).
Как уже говорилось, `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[])`.
Трудно не согласиться, что такие вещи, как две команды `return` в одной строке, для непосвященных всегда будут выглядеть странновато. Что ж, при первом знакомстве причудливой наверняка покажется не только эта функция высокого порядка. Так что начнем разбирать функцию `finder` построчно: она параметризирована с помощью типа `T`, принимает обычный параметр типа `T` и возвращает значение типа `T[] delegate(T[])`; кроме того, на `T` налагается ограничение: два значения типа `T` должны быть сравнимы, а результат сравнения должен быть логическим. (Как и раньше, «глупое» сравнение `x == x` здесь только ради типов, а не для каких-то определенных значений.) Затем `finder` разумно делает свое дело, возвращая литерал делегата. У этого литерала короткое тело, в котором вызывается наша ранее определенная функция `find`, завершающая выполнение условий поставленной задачи. Возвращенный делегат называется *замыканием* (*closure*).
Порядок использования функции `finder` ожидаем: ее вызов возвращает делегат, который потом можно вызвать и которому можно присваивать новые значения. Переменная `d`, определенная в тесте модуля, имеет тип `T[] delegate(T[])`, но благодаря ключевому слову `auto` этот тип можно не указывать явно. На самом деле, если быть абсолютно честным, с помощью ключевого слова `auto` можно сократить и определение `finder`; все типы присутствовали в нем лишь для облегчения понимания примера. Вот гораздо более краткое определение функции `finder`:
Обратите внимание на использование ключевого слова `auto` вместо возвращаемого типа функции, а также на то, что ключевое слово `delegate` опущено; компилятор с радостью позаботится обо всем этом за нас. Тем не менее в литерале делегата запись `T[]` указать необходимо. Ведь компилятор должен за что-то зацепиться, чтобы сотворить волшебство, обещанное ключевым словом `auto`: возвращаемый тип делегата определяется по типу функции `find(a, x)`, который, в свою очередь, определяется по типам `a` и `x`; в результате такой цепочки выводов делегат приобретает тип `T[] delegate(T[])`, этот же тип возвращает функция `finder`. Без знания типа `a` вся эта цепочка рассуждений не может быть осуществима.
Наш тест модуля `unittest` помогает исследовать поведение функции `finder`, но, конечно же, не доказывает корректность ее работы. Важный и совсем неочевидный вопрос: возвращаемый функцией `finder` делегат использует значение `x`, а где находится `x` после того, как `finder` вернет управление? На самом деле, в этом вопросе слышится серьезное опасение за происходящее (ведь D использует для вызова функций обычный стек вызовов): инициатор вызова вызывает функцию `finder`, х отправляется на вершину стека вызовов, функция `finder` возвращает результат, стек восстанавливает свое состояние до вызова `finder`... а значит, возвращенный функцией `finder` делегат использует для доступа адрес в стеке, по которому уже нет нужного значения!
«Продолжительность жизни» локального окружения (в нашем случае окружение состоит только из x, но оно может быть сколь угодно большим) – это классическая проблема реализации замыканий, и каждый язык, поддерживающий замыкания, должен ее как-то решать. В языке D применяется следующий подход[^8]. В общем случае все вызовы используют обычный стек. А обнаружив замыкание, компилятор автоматически копирует используемый контекст в кучу и устанавливает связь между делегатом и областью памяти в куче, позволяя ему использовать расположенные в ней данные. Выделенная в куче память подлежит сбору мусора.
Недостаток такого подхода в том, что каждый вызов `finder` порождает новое требование выделить память. Тем не менее замыкания очень выразительны и позволяют применить многие интересные парадигмы программирования, поэтому в большинстве случаев затраты более чем оправданны.
[В начало ⮍](#5-8-1-так-это-работает-стоп-не-должно-нет-все-же-работает) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Раздел 5.3 закончился загадочным утверждением: «функция `find` одновременно и излишне, и недостаточно обобщенна». Затем мы узнали, почему функция `find` излишне обобщенна, и исправили эту ошибку, наложив дополнительные ограничения на типы ее параметров. Пришло время выяснить, почему эта функция все же недостаточно обобщенна.
В чем смысл линейного поиска? В поисках заданного значения или значения, удовлетворяющего заданному условию, просматриваются элементы указанной структуры данных. Проблема в том, что до сих пор мы работали только с непрерывными массивами (срезами, встречающимися в нашем определении `find` в виде `T[]`), но к понятию линейного поиска непрерывность не имеет никакого отношения. (Она имеет отношение только к механизмам организации просмотра.) Ограничившись типом `T[]`, мы лишили функцию `find` доступа ко множеству других структур данных, с которыми может работать алгоритм линейного поиска. Язык, предлагающий, к примеру, сделать `find` методом некоторого типа `Array` («массив»), вполне заслуживает вашего скептического взгляда. Это не значит, что решить задачу с помощью этого языка невозможно; просто наверняка поработать пришлось бы гораздо больше, чем это необходимо.
Пора начать все с нуля, пересмотрев нашу базовую реализацию `find`. Для удобства приведем ее здесь:
3.`haystack = haystack[1 .. $]` исключает из рассмотрения первый элемент `haystack`.
Конкретный способ, каким массивы реализуют эти операции, непросто распространить на другие контейнеры. Например, проверять с помощью выражения `haystack.length > 0`, есть ли в односвязном списке элементы, – подход, достойный премии Дарвина[^9]. Если не обеспечено постоянное кэширование длины списка (что по многим причинам весьма проблематично), то для вычисления длины списка таким способом потребуется время, пропорциональное самой длине списка, а быстрое обращение к началу списка занимает всего лишь несколько машинных инструкций. Применить к спискам индексацию – столь же проигрышная идея. Так что выделим сущность рассмотренных операций, представим полученный результат в виде трех именованных функций и оставим их реализацию типу `haystack`. Примерный синтаксис базовых операций, необходимых для реализации алгоритма линейного поиска:
Обратите внимание: первые две операции не изменяют `haystack` и потому не используют круглые скобки, третья же операция изменяет `haystack`, и синтаксически это отражено в виде скобок `()`. Переопределим функцию `find`, применив в ее определении новый блестящий синтаксис:
Было бы неплохо сейчас погреться в лучах этого благотворного определения, если бы не суровая реальность: тесты модулей не проходят. Да и могло ли быть иначе, когда встроенный тип среза `T[`] и понятия не имеет о том, что нас внезапно осенило и мы решили определить новое множество базовых операций с произвольными именами `empty`, `front` и `popFront`. Мы должны определить их для всех типов `T[]`. Естественно, все они будут иметь простейшую реализацию, но они все равно нам нужны, чтобы заставить нашу милую абстракцию снова заработать с тем типом данных, с которого мы начали.
[В начало ⮍](#5-9-не-только-массивы-диапазоны-псевдочлены) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Наша синтаксическая проблема заключается в том, что все вызовы функций до сих пор выглядели как `функция(аргумент)`, а теперь мы хотим определить такие вызовы: `аргумент.функция()` и `аргумент.функция`, то есть *вызов метода* и *обращение к свойству* соответственно. Как мы узнаем из следующего раздела, для пользовательских типов они определяются довольно-таки просто, но `T[]`– это встроенный тип. Как же быть?
Язык D видит в этом чисто синтаксическую проблему и разрешает ее посредством нотации псевдочленов: если компилятор встретит запись `a.функция(b, c, d)`, где `функция` не является членом типа значения a, он заменит этот вызов на `функция(a, b, c, d)`[^10] и попытается обработать вызов в этой новой форме. (При этом попытки обратного преобразования не предпринимаются: если вы напишете `функция(a, b, c, d)` и это окажется бессмыслицей, версия `a.функция(b, c, d)` не проверяется.) Предназначение псевдометодов – позволить вызывать обычные функции с помощью знакомого кому-то из нас синтаксиса «отправить-сообщение-объекту». Итак, без лишних слов реализуем `empty`, `front` и `popFront` для встроенных массивов. Для этого хватит трех строк:
С помощью ключевого слова `@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` стандартной библиотеки.
Как насчет непростой задачи, использующей только диапазоны ввода? Условия звучат так: определить функцию `reduce`[^14], которая принимает диапазон ввода `r`, операцию `fun` и начальное значение `x`, последовательно рассчитывает `x = fun(x, e)` для каждого элемента `e` из `r` и возвращает `x`. Функция высокого порядка `reduce` весьма могущественна, поскольку позволяет выразить множество интересных сверток. Эта функция – одно из основных средств многих языков программирования, позволяющих создавать функции более высокого порядка. В них она носит имена `accumulate`, `compress`, `inject`, `foldl` и т. д. Разработку функции `reduce` начнем с определения нескольких тестов модулей – в духе разработки через тестирование:
Как можно заметить, функция `reduce` очень гибка и полезна – конечно, если закрыть глаза на маленький нюанс: эта функция еще не существует. Поставим цель реализовать `reduce` так, чтобы она работала в соответствии с определенными выше тестами. Теперь мы знаем достаточно, чтобы с самого начала написать крепкий, «промышленный» вариант функции `reduce`: в разделе 5.3 показано, как передать в функцию аргументы; раздел 5.4 научил нас накладывать на `reduce` ограничения, чтобы она принимала только осмысленные аргументы; в разделе 5.6 мы видели, как можно передать в функцию функциональные литералы через параметры-псевдонимы; а сейчас мы вплотную подошли к созданию элегантного и простого интерфейса диапазона ввода.
Скомпилируйте, запустите тесты модулей, и вы увидите, что все проверки пройдут прекрасно. И все же гораздо симпатичнее было бы определение `reduce`, где ограничения сигнатуры не достигали бы объема самой реализации. Кроме того, стоит ли писать нудные проверки, чтобы удостовериться, что `R`– это *диапазон ввода*? Столь многословные ограничения – это скрытое дублирование. К счастью, проверки для диапазонов уже тщательно собраны в стандартном модуле `std.range`, воспользовавшись которым, можно упростить реализацию `reduce`:
Такой вариант уже гораздо лучше смотрится. Имея в распоряжении функцию `reduce`, можно вычислить не только сумму и минимум, но и множество других агрегирующих функций, таких как число, ближайшее к заданному, наибольшее число по модулю и стандартное отклонение. Функция `reduce` из модуля `std.algorithm` стандартной библиотеки выглядит практически так же, как и наша версия выше, за исключением того, что она принимает в качестве аргументов несколько функций для вычисления; это позволяет очень быстро вычислять значения множества агрегирующих функций, поскольку выполняется всего один проход по входным данным.
В традиционной программе «Hello, world!», приведенной в начале книги, для вывода приветствия в стандартный поток использовалась функция `writeln` из стандартной библиотеки. У этой функции есть интересная особенность: она принимает любое число аргументов любых типов. В языке D определить функцию с переменным числом аргументов можно разными способами, отвечающими тем или иным нуждам разработчика. Начнем с самого простого.
[В начало ⮍](#5-10-функции-с-переменным-числом-аргументов) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
(Обратите внимание на очередное удачное использование `reduce`.) Интересная деталь функции `average`: многоточие ... после параметра `values`, который является срезом. (Если бы это было не так или если бы параметр `values` не был последним в списке аргументов функции `average`, компилятор диагностировал бы это многоточие как ошибку.)
Вызов функции `average`со срезом массива элементов типа `double` (как показано в последней строке теста модуля) ничем не примечателен. Однако благодаря многоточию эту функцию можно вызывать с любым числом аргументов, при условии что каждый из них можно привести к типу `double`. Компилятор автоматически сформирует из этих аргументов срез и передаст его в `average`.
Может показаться, что это средство едва ли не тот же синтаксический сахар, позволяющий компилятору заменить `average(a, b, c)` на `average([a, b, c])`. Однако благодаря своему синтаксису вызова гомогенная функция с переменным числом аргументов перегружает другие функции в своем контексте. Например:
Присутствие первых двух перегруженных версий `average` делает двусмысленным вызов без аргументов или с одним аргументом версии `average`с переменным числом аргументов. Избавиться от двусмысленности поможет явная передача среза, например `average([1, 2])`.
Если в одном и том же контексте одновременно присутствуют обе функции – и с фиксированным, и с переменным числом аргументов,– каждая из которых ожидает срез того же типа, что и другая, то при вызове с явно заданным срезом предпочтение отдается функции с фиксированным числом аргументов:
Кроме срезов можно использовать в качестве аргумента массив фиксированной длины (в этом случае количество аргументов также фиксировано) и класс[^15]. Подробно классы описаны в главе 6, а здесь лишь несколько слов о взаимодействии классов и функций с переменным числом аргументов.
Если написать `void foo(T obj...)`, где `T`– имя класса, то внутри `foo` будет создан экземпляр `T`, причем его конструктору будут переданы аргументы, переданные функции. Если для данного набора аргументов конструктора класса `T` не существует, будет сгенерирована ошибка. Созданный экземпляр является локальным для данной функции, память под него может быть выделена в стеке, поэтому он не возвращается функцией.
[В начало ⮍](#5-10-1-гомогенные-функции-с-переменным-числом-аргументов) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Вернемся к функции `writeln`. Она явно должна делать не совсем то же самое, что функция `average`, поскольку `writeln` принимает аргументы разных типов. Для обработки произвольного числа аргументов произвольных типов предназначена гетерогенная функция с переменным числом аргументов, которую определяют так:
Эта реализация немного сыровата и неэффективна, но она работает. `T` внутри `writeln`–*кортеж типов параметров* (тип, который группирует несколько типов), а`args`–*кортеж параметров*. Цикл `foreach` определяет, что `args`– это кортеж типов, и генерирует код, радикально отличающийся от того, что получается в результате обычного выполнения инструкции `foreach` (например, когда цикл `foreach` применяется для просмотра массива). Рассмотрим, например, такой вызов:
В модуле `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`). Например:
Функция `writeln` делает слишком много специфичного, чтобы быть обобщенной: она всегда добавляет в конце `'\n'` и затем использует функцию `flush` для записи данных буферов потока. Попробуем определить функцию `writeln` через базовую функцию `write`, которая просто выводит все аргументы по очереди:
Обратите внимание, как `writeln` делегирует запись `args` и `'\n'` функции `write`. При передаче кортеж параметров автоматически разворачивается, так что вызов `writeln(1, "2", 3)` делегирует функции `write` запись из четырех, а не трех аргументов. Такое поведение немного необычно и не совсем понятно, поскольку практически во всех остальных случаях в D под одним идентификатором понимается одно значение. Этот пример может удивить даже подготовленных:
Первый вызов проходит гладко, чего нельзя сказать о втором. Вы ожидали, что все будет в порядке, ведь любое значение (а значит, и `args`) обладает каким-то типом, и потому тип `args` должен выводиться функцией `gun`. Но что происходит на самом деле?
Все значения действительно обладают типами, которые корректно отслеживаются компилятором. Виновен вызов `gun(args)`, поскольку компилятор автоматически расширяет этот вызов, когда бы кортеж параметров ни передавался в качестве аргумента функции. Даже если вы написали `gun(args)`, компилятор всегда развернет такой вызов до `gun(args[0], args[1], ..., args[$ - 1])`. Под вторым вызовом подразумевается вызов `gun(args[0], args[1])`, который требует несуществующей функции `gun`с двумя аргументами, – отсюда и ошибка.
Конструкция `typeof`– не вызов функции; это выражение всего лишь возвращает тип `args`, поэтому можно не волноваться относительно автоматической развертки. Свойство `.stringof`, присущее всем типам, возвращает имя типа, так что давайте еще раз скомпилируем и запустим программу. Она печатает:
Итак, действительно похоже на то, что компилятор отслеживает типы кортежей параметров, и для них определено строковое представление. Тем не менее невозможно явно определить кортеж параметров: типа `(int, double)` не существует.
Все объясняется тем, что кортежи в своем роде уникальны: это типы, которые внутренне используются компилятором, но не могут быть выражены в тексте программы. Никаким образом невозможно взять и написать тип кортежа параметров. Потому нет и литерала, порождающего вывод кортежа параметров (если бы был, то необходимость в указании имени типа отпала бы: ведь есть ключевое слово `auto`).
Концепция типов без имен и значений без литералов может заинтересовать любителя острых ощущений, однако программист практического склада увидит здесь нечто угрожающее. К счастью (наконец-то! эти слова должны были появиться рано или поздно), это не столько ограничение, сколько способ сэкономить на синтаксисе. Есть замечательная возможность представлять типы кортежей параметров с помощью типа `Tuple`, а значения кортежей параметров –с помощью функции `tuple`. И то и другое находится в стандартном модуле `std.typecons`. Таким образом, кортеж параметров, содержащий `int` и `double`, можно записать так:
Тип `Tuple!(int, double)` такой же, как и все остальные типы, он не делает никаких фокусов с автоматической разверткой, так что если вы хотите развернуть его до составных частей, нужно сделать это явно с помощью свойства `.expand` типа `Tuple`. Для примера переплавим нашу программу с функциями `fun` и `gun` и в результате получим следующий код:
Посмотрите, как функция `fun` группирует все аргументы в один кортеж (`Tuple`) и передает его в функцию `gun`, которая разворачивает полученный кортеж, извлекая все, что он содержит. Выражение `value.expand` автоматически заменяется на список аргументов, содержащий все, что вы отправили в `Tuple`.
В реализации типа `Tuple` есть пара тонких моментов, но она использует средства, доступные любому программисту. Изучение определения типа `Tuple` (которое можно найти в стандартной библиотеке) было бы полезным упражнением.
Предыдущий подход всем хорош, однако применение шаблонов накладывает на функции ряд ограничений. Поскольку приведенная выше реализация использует шаблоны, для каждого возможного кортежа параметров создается свой экземпляр шаблонной функции. Это не позволяет делать шаблонные функции виртуальными методами класса, объявлять их нефинальными членами интерфейсов, а при невнимательном подходе может приводить к излишнему разрастанию результирующего кода (поэтому шаблонная функция должна быть небольшой, чтобы компилятор счел возможной ее inline-подстановку). Поэтому D предлагает еще два способа объявить функцию с переменным числом аргументов. Оба способа были добавлены в язык до появления шаблонов с переменным числом аргументов, и сегодня считаются небезопасными и устаревшими. Тем не менее они присутствуют и используются в текущих реализациях языка, чаще всего из соображений совместимости.
[В начало ⮍](#5-10-3-гетерогенные-функции-с-переменным-числом-аргументов-альтернативный-подход-16) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Разберем ее по порядку. Запись `extern(C)` обозначает тип компоновки. В данном случае указано, что функция использует тип компоновки C. То есть параметры передаются в функцию в соответствии с соглашением о вызовах языка C. Также в C не используется искажение имен (mangling) функций, поэтому такая функция не может быть перегружена по типам аргументов. Если две такие функции с одинаковыми именами объявлены в разных модулях, возникнет конфликт имен. Как правило, `extern(C)` используется для взаимодействия с кодом, уже написанным на C или других языках. `in char* format`– обязательный первый аргумент функции, за которым следует переменное число аргументов, что символизирует уже знакомое нам многоточие (`...`).
Для начала вспомним, как аргументы передаются функции в языке C. C передает аргументы через стек, помещая в него аргументы, начиная с последнего. За удаление аргументов из стека отвечает вызывающая подпрограмма. Например, при вызове `printf("%d + %d = %d", 2, 3, 5)` первым в стек будет помещен аргумент 5, после него 3, затем 2 и последней – строка формата. В итоге строка формата оказывается на вершине стека и будет доступна в вызываемой функции. Для получения остальных аргументов в C используются макросы, определенные в файле `stdarg.h`.
В языке D соответствующие функции определены в модуле `std.c.stdarg`. Во-первых, в данном модуле определен тип `va_list`, который является указателем на список необязательных аргументов. Функция `va_start` инициализирует переменную `va_list` указателем на начало списка необязательных аргументов.
Первый аргумент – инициализируемая переменная `va_list`, второй – ссылка на последний обязательный аргумент, то есть последний аргумент, тип которого известен. На основании него вычисляется указатель на первый элемент списка необязательных аргументов. Именно поэтому функция с переменным числом аргументов в C должна иметь хотя бы один обязательный параметр, чтобы `va_start` было к чему привязаться. Объявление `extern(C) int foo(...);` недопустимо.
Функция `va_arg` получает значение очередного аргумента заданного типа. Тип этого аргумента может быть получен в результате каких-то операций с предыдущими аргументами, и проверить правильность его получения невозможно. Указатель на список при этом изменяется так, чтобы он указывал на следующий элемент списка.
Функция `va_copy` предназначена для копирования переменной типа `va_list`. Если `va_list`– указатель на стек функции, выполняется копирование указателя. Если же в вашей системе аргументы передаются через регистры, потребуется выделение памяти и копирование списка.
Интерфейс `stdarg` является кроссплатформенным, а сама реализация функций с переменным числом аргументов может быть различной для разных платформ. В некоторых платформах аргументы передаются через стек, и `va_list`– указатель на верхний элемент списка в стеке. В некоторых аргументы могут передаваться через регистры. Также разным может быть выравнивание элементов в стеке и направление роста стека. Поэтому следует пользоваться именно этим интерфейсом, а не пытаться договориться с функцией в обход него. Пример функции для преобразования в строку значения нужного типа:
В этом примере мы первым аргументом передаем тип следующих аргументов, и на основании этого аргумента функция определяет, каких аргументов ей ждать дальше. Однако если мы допустим ошибку в вызове, то спасти нас уже никто не сможет. В этом и заключается опасность подобных функций: ошибка в вызове может привести к аппаратной ошибке внутри самой функции. Например, если мы напишем:
результат будет непредсказуемым. Поэтому, например, функция `scanf` может оказаться небезопасной, если строка формата берется из ненадежного источника, ведь с правильно подобранной строкой формата и аргументом можно получить перезапись адреса возврата функции и заставить программу выполнить какой-то свой, наверняка вредоносный код. Поэтому язык D предлагает менее опасный способ создания функций с переменным числом аргументов.
То есть делается абсолютно то же самое, что и в случае выше, но выбирается тип компоновки 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`, импортируется в любом модуле по умолчанию, то есть можно использовать любые объявленные в нем символы без каких-то дополнительных объявлений. Вот безопасный вариант предыдущего примера:
Этот вариант автоматически проверят типы переданных аргументов. Однако не забывайте, что корректность типа, переданного `va_arg`, остается за вами – использование неправильного типа приведет к непредсказуемой ситуации. Если вас это беспокоит, то для полной безопасности вы можете использовать конструкцию `Variant` из модуля стандартной библиотеки `std.variant`:
При этом функция `templatedVariadic`, скорее всего, будет встроена в код путем inline-подстановки, и накладных расходов на лишний вызов функции и разрастание шаблонного кода не будет.
К функциям на D можно присоединять *атрибуты*– особые средства, извещающие программиста и компилятор о том, что функция обладает некоторыми качествами. Функции проверяются на соответствие своим атрибутам, поэтому, чтобы узнать важную информацию о поведении функции, достаточно взглянуть на ее сигнатуру: атрибуты предоставляют твердые гарантии, это не простые комментарии или соглашения.
[В начало ⮍](#5-11-атрибуты-функций) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
Чистота функций – заимствованное из математики понятие, полезное как в теории, так и на практике. В языке D функция считается чистой, если все, что она делает, сводится к возвращению результата и возвращаемое значение зависит только от ее аргументов.
В классической математике все функции чистые, поскольку в классической математике нет состояний и изменений. Чему равен √2? Примерно 1,4142; так было вчера, будет завтра и вообще всегда. Можно доказать, что значение √2 было тем же еще до того, как человечество открыло корни, алгебру, числа, и даже *до* появления человечества, способного оценить красоту математики, и столь же долго пребудет неизменным после тепловой смерти Вселенной. Математические результаты вечны.
Чистота – это благо для функций, пусть даже иногда и с ограничениями, впрочем, как и в жизни. (Кстати, как и в жизни, чистоты не так просто достичь. Более того, по мнению некоторых, излишества в некоторых проявлениях чистоты на самом деле могут раздражать.) В пользу чистоты говорит тот факт, что о чистой функции легче делать выводы. Чистота гарантирует: чтобы узнать, что делает та или иная функция, достаточно взглянуть на ее вызов. Можно заменять эквивалентные вызовы функций значениями, а значения – эквивалентными вызовами функций. Можно быть уверенным, что ошибки в чистых функциях не обладают эффектом шрапнели – они не могут повлиять на что-либо еще помимо результата самой функции.
Кроме того, чистые функции могут выполняться в буквальном смысле параллельно, так как они никаким образом, кроме их результата, не взаимодействуют с остальным кодом программы. В противоположность им, насыщенные изменениями[^17] нечистые функции при параллельном выполнении склонны наступать друг другу на пятки. Но даже если выполнять их последовательно, результат может неуловимо зависеть от порядка, в котором они вызываются. Многих из нас это не удивляет – мы настолько свыклись с таким раскладом, что считаем преодоление трудностей неотъемлемой частью процесса написания кода. Но если хотя бы некоторые части приложения будут написаны «чисто», это принесет большую пользу, освежив программу в целом.
Определить чистую функцию можно, добавив в начало ее определения ключевое слово `pure`:
гарантирует пользователю, что функция `leapYear` не пишет в стандартный поток вывода. Кроме того, уже по сигнатуре видно, что вызов `leapYear(2020)` всегда будет возвращать одно и то же значение.
Компилятор также в курсе значения ключевого слова `pure`, и именно он ограждает программиста от любых действий, способных нарушить чистоту функции `leapYear`. Приглядитесь к следующим изменениям:
Функция `writeln` не является и не может стать чистой. И если бы она заявляла обратное, компилятор бы избавил ее от такого заблуждения. Компилятор гарантирует, что чистая функция вызывает только чистые функции. Вот почему измененная функция `leapYear` не компилируется. С другой стороны, проверку компилятора успешно проходят такие функции, как `daysInYear`:
По традиции функциональные языки запрещают абсолютно любые изменения, чтобы программа могла называться чистой. D ослабляет это ограничение, разрешая функциям изменять собственное локальное и временное состояние. Таким образом, даже если внутри функции есть изменения, для окружающего кода она все еще непогрешима.
Ни один преподаватель программирования никогда не должен учить реализовывать расчет чисел Фибоначчи таким способом. Чтобы вычислить результат, функции `fib` требуется *экспоненциальное время*, поэтому все, чему она может научить, – это пренебрежение сложностью и ценой вычислений, лозунг «небрежно, зато находчиво» и спортивный стиль вождения. Хотите знать, чем плох экспоненциальный порядок? Вызовы `fib(10)` и `fib(20)` на современной машине не займут много времени, но вызов `fib(50)` обрабатывается уже 19 минут. Вполне вероятно, что вычисление `fib(1000)` переживет человечество (только смысла в этом никакого, в отличие от примера с √2.)
Переработанная версия вычисляет `fib(50)` практически мгновенно. Эта реализация требует для выполнения *O*(*n*)[^18] времени, поскольку оптимизация хвостовой рекурсии (см. раздел 1.4.2) позволяет уменьшить сложность вычислений. (Стоит отметить, что для расчета чисел Фибоначчи существуют и алгоритмы с временем выполнения *O*(log *n*)).
Проблема в том, что новая функция `fib` как бы утратила былое великолепие. Особенность переработанной реализации – две переменные состояния, маскирующиеся под параметры функции, и вполне можно было с чистой совестью написать явный цикл, который зачем-то был закамуфлирован функцией `iter`:
К сожалению, это уже не функциональный стиль. Только посмотрите на все эти изменения, происходящие в цикле. Один неверный шаг – и с вершин математической чистоты мы скатились к неискушенности чумазых низов.
Но подумав немного, мы увидим, что итеративная функция `fib`*не* такая уж чумазая. Если принять ее за черный ящик, то можно заметить, что при одних и тех же аргументах функция `fib` всегда возвращает один и тот же результат, а ведь «красив тот, кто красиво поступает». Тот факт, что она использует локальное изменение состояния, делает ее менее функциональной по букве, но не по духу. Продолжая эту мысль, приходим к очень интересному выводу: пока изменяемое состояние внутри функции остается полностью *временным* (то есть хранит данные в стеке) и *локальным* (то есть не передается по ссылке другим функциям, которые могут его нарушить), эту функцию можно считать чистой.
Вот как D определяет функциональную чистоту: в реализации чистой функции разрешается использовать изменения, если они временные и локальные. Сигнатуру такой функции можно снабдить ключевым словом `pure`, и компилятор без помех скомпилирует этот код:
Принятые в D допущения, смягчающие математическое понятие чистоты, очень полезны, поскольку позволяют взять лучшее из двух миров: железные гарантии функциональной чистоты и удобную реализацию (если код с изменениями более предпочтителен).
Атрибут `nothrow` сообщает, что данная функция никогда не порождает исключения. Как и атрибут `pure`, атрибут `nothrow` проверяется во время компиляции. Например:
Функция `tryLog` прилагает максимум усилий, чтобы записать в журнал сообщение. Если возникает исключение, она его молча игнорирует. Это качество позволяет использовать функцию `tryLog` на критических участках кода. При определенных обстоятельствах было бы глупо позволить некоторой важной транзакции сорваться только из-за невозможности сделать запись в журнал. Устройство кода, представляющего собой транзакцию, основано на том, что некоторые из его участков никогда не порождают исключения, а применение атрибута `nothrow` позволяет статически гарантировать это свойство критических участков.
Проверка семантики функций с атрибутом `nothrow` гарантирует, что исключение никогда не просочится из функции. Для каждой инструкции внутри функции должно быть истинно одно из утверждений: 1) эта инструкция не порождает исключения (в случае вызова функции это возможно, только если вызываемая функция также не порождает исключения), 2) эта инструкция расположена внутри инструкции `try`, «съедающей» исключения. Проиллюстрируем второй случай примером:
Первый вызов функции `tryLog` можно не помещать в блок `try`, поскольку компилятор уже знает, что эта функция не порождает исключения. Аналогично вызов внутри блока `catch` можно не «защищать» с помощью дополнительного блока `try`.
Как соотносятся атрибуты `pure` и `nothrow`? Может показаться, что они совершенно независимы друг от друга, но на самом деле между ними есть некоторая взаимосвязь. По крайней мере в стандартной библиотеке многие функции, например самые трансцендентные (такие как `exp`, `sin`, `cos`), имеют оба атрибута – и `pure`, и `nothrow`.
В подтверждение поговорки, что счастье приходит к тому, кто умеет ждать (или терпеливо читать), в этом последнем разделе обсуждается очень интересное средство D. Лучшее в этом средстве то, что вам не нужно много учиться, чтобы начать широко его применять.
Рассмотрим пример, достаточно большой, чтобы быть осмысленным. Предположим, вы хотите создать лучшую библиотеку генераторов случайных чисел. Есть много разных генераторов случайных чисел, в том числе линейные конгруэнтные генераторы. У таких генераторов есть три целочисленных параметра: модуль *m* > 0, множитель 0 <*a*<*m*инаращиваемоезначение[^19]0<*c*<*m*.Начавспроизвольногоначальногозначения0≤*x*<sub>0</sub><*m*, линейный конгруэнтный генератор вычисляет псевдослучайные числа по следующей рекуррентной формуле:
Запрограммировать такой алгоритм очень просто: достаточно сохранять состояние, определяемое числами *m*, *a*, *c* и *x*<sub>n</sub>, и определить функцию `getNext` для получения следующего значения *x*<sub>n+1</sub>.
Но здесь есть подвох. Не все комбинации *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* = 2<sup>32</sup> (тогда нам даже операция деления по модулю не нужна), *a* = 210, *c* = 123, *а* для *x*<sub>0</sub> возьмем какое-нибудь сумасшедшее значение, например 1 780 588 661. Запустим следующую программу:
Начинает генератор вполне задорно. По крайней мере, с непривычки может показаться, что он неплохо справляется с генерацией случайных чисел. Однако уже с 14-го шага генератор зацикливается: по странному стечению обстоятельств, породить которое могла только математика, 3 740 115 061 оказалось (и всегда будет оказываться) точно равным (3 740 115 061 * 210 + 123) mod 2<sup>32</sup>. Это период единицы, худшее из возможного!
Значит, необходимо выбрать такие параметры *m*, *a* и *c*, чтобы сгенерированная последовательность псевдослучайных чисел гарантированно имела большой период. Дальнейшие исследования этой проблемы выявили следующие условия генерации последовательности псевдослучайных чисел с периодом *m* (наибольший возможный период):
Взаимную простоту *c* и *m* можно легко проверить сравнением наибольшего общего делителя этих чисел с 1. Для вычисления наибольшего общего делителя воспользуемся алгоритмом Евклида[^20]:
Евклид выразил свой алгоритм с помощью вычитания, а не деления по модулю. Для версии с делением по модулю требуется меньше итераций, но на современных машинах `%` может вычисляться довольно-таки медленно (видимо, именно это и остановило Евклида).
Реализовать вторую проверку немного сложнее. Можно было бы написать функцию `factorize`, возвращающую все возможные простые делители числа с их степенями, и воспользоваться ею, но `factorize`– это больше, чем нам необходимо. Стремясь к простейшему решению, которое могло бы сработать, проще всего написать функцию `primeFactorsOnly(n)`, возвращающую произведение простых делителей `n`, но без степеней. Тогда наша задача сводится к проверке выражения `(a - 1) % primeFactorsOnly(m) == 0`. Итак, приступим к реализации функции `primeFactorsOnly`.
Есть много способов получить простые делители некоторого числа *n*. Один из простых: сгенерировать простые числа *p*<sub>1</sub>, *p*<sub>2</sub>, *p*<sub>3</sub>, ..., для каждого значения *p*<sub>k</sub> выяснить, делится ли *n* на *p*<sub>k</sub>, и если делится, то умножить *p*<sub>k</sub> на значение-аккумулятор *r*. Когда очередное число *p*<sub>k</sub> окажется больше *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*. Это не выглядит как самый экономный метод, но задумайтесь о том, что генерация простых чисел могла бы потребовать сравнимых трудозатрат, по крайней мере в случае простой реализации. Реализация этой идеи могла бы выглядеть так:
Команда `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`, что делает равенство невозможным.
В завершение нам необходима небольшая функция-обертка, выполняющая три рассмотренные проверки трех потенциальных параметров линейного конгруэнтного генератора:
Похоже, функция `properLinearCongruentialParameters` работает как надо, то есть мы справились со всеми деталями тестирования состоятельности линейного конгруэнтного генератора. Так что пора притормозить, заглушить мотор и покаяться. Какое отношение имеет вся эта простота и делимость к вычислениям во время компиляции? Где мясо?[^21] Где шаблоны, макросы или как там они еще называются? Многообещающие инструкции `static if`? Умопомрачительные генерация кода и расширение кода?
На самом деле, вы только что увидели все, что только можно рассказать о вычислениях во время компиляции. Задав константам `m`, `n` и `с` любые числовые значения, можно вычислить `properLinearCongruentialParameters`*во время компиляции*, никак не изменяя эту функцию или функции, которые она вызывает. В компилятор D встроен интерпретатор, который вычисляет функции на D во время компиляции –со всей арифметикой, циклами, изменениями, ранними возвратами и даже трансцендентными функциями.
Мы еще не рассматривали структуры и классы в подробностях, но отметим, немного опережая события, что типичный вариант использования функции `properLinearCongruentialParameters`–ее размещение внутри структуры или класса, определяющего линейный конгруэнтный генератор. Например:
Собственно, эти строки скопированы из одноименной структуры, которую можно найти в стандартном модуле `std.random`.
Изменив время выполнения проверки (теперь она выполняется на этапе компиляции, а не во время исполнения программы), мы получили два любопытных последствия. Во-первых, можно было бы отложить проверку до исполнения программы, расположив вызов `properLinearCongruentialParameters` в конструкторе структуры `LinearCongruentialEngine`. Но обычно чем раньше узнаешь об ошибках, тем лучше, особенно если это касается библиотеки, которая почти не контролирует то, как ее используют. При статической проверке некорректно созданные экземпляры `LinearCongruentialEngine` не сигнализируют об ошибках: исключается сама возможность их появления. Во-вторых, используя константы, известные во время компиляции, код имеет хороший шанс работать быстрее, чем код с обычными значениями `m`, `a` и `c`. На большинстве современных процессоров константы в виде литералов могут быть сделаны частью потока команд, так что их загрузка вообще не требует никаких дополнительных обращений к памяти. И посмотрим правде в глаза: линейные конгруэнтные генераторы – не самые случайные в мире, и используют их главным образом благодаря скорости.
Процесс интерпретации на пару порядков медленнее генерации кода, но гораздо быстрее традиционного метапрограммирования на основе шаблонов C++. Кроме того, вычисления во время компиляции (в разумных пределах) в некотором смысле «бесплатны».
На момент написания этой книги у интерпретатора есть ряд ограничений[^22]. Выделение памяти под объекты, да и просто выделение памяти запрещены (хотя встроенные массивы работают). Статические данные, вставки на ассемблере и небезопасные средства, такие как объединения (`union`) и некоторые приведения типов (`cast`), также под запретом. Множество ограничений на то, что можно сделать во время компиляции, находится под постоянным давлением. Задумка в том, чтобы разрешить интерпретировать во время компиляции все, что находится в безопасном множестве D. В конце концов, способность интерпретировать код вовремя компиляции – это новшество, открывающее очень интересные возможности, которые заслуживают дальнейшего исследования.
[В начало ⮍](#5-12-вычисления-во-время-компиляции) [Наверх ⮍](#5-данные-и-функции-функциональный-стиль)
[^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-подстановка отдается на откуп компилятору. Компилятор будет сам решать, где рационально ее применить, а где – нет. –*Прим. науч. ред.*
[^15]: Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание. –*Прим. науч. ред.*
[^16]: Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. –*Прим. науч. ред.*
[^17]: В данном контексте речь идет об изменениях, которые повлияли бы на последующие вызовы функции, например об изменении глобальных переменных. –*Прим. науч. ред.*
[^18]: «O» большое – математическое обозначение, применяемое при оценке асимптотической сложности алгоритма. –*Прим. ред.*
[^19]: Равенство *c* нулю также допустимо, но соответствующая теоретическая часть гораздо сложнее, потому ограничимся значениями *c* > 0.
[^20]: Непонятно как, но алгоритм Евклида всегда умудряется попадать в хорошие (хм...) книги по программированию.
[^21]: Распространенный в США и Канаде мем, изначально связанный с фаст-фудом. –*Прим. ред.*
[^22]: Многие из этих ограничений уже сняты. –*Прим. науч. ред.*