Alexander Zhirov bbe57cd8b5 | ||
---|---|---|
.. | ||
src | ||
README.md |
README.md
1. Знакомство с языком D
🢀 Введение 1. Знакомство с языком D 2. Основные типы данных. Выражения 🢂
- 1.1. Числа и выражения
- 1.2. Инструкции
- 1.3. Основы работы с функциями
- 1.4. Массивы и ассоциативные массивы
- 1.5. Основные структуры данных
- 1.6. Интерфейсы и классы
- 1.7. Значения против ссылок
- 1.8. Итоги
Вы ведь знаете, с чего обычно начинают, так что без лишних слов:
import std.stdio;
void main()
{
writeln("Hello, world!");
}
В зависимости от того, какие еще языки вы знаете, у вас может возникнуть ощущение дежавю, чувство легкой благодарности за простоту, а может, и легкого разочарования из-за того, что D не пошел по стопам скриптовых языков, разрешающих использовать «корневые» (top-level) инструкции. (Такие инструкции побуждают вводить глобальные переменные, которые по мере роста программы превращаются в головную боль; на самом деле, D позволяет исполнять код не только внутри, но и вне функции main
, хотя и более организованно.) Самые въедливые будут рады узнать, что void main
– это эквивалент функции int main
, возвращающей операционной системе «успех» (код 0) при успешном окончании ее выполнения.
Но не будем забегать вперед. Традиционная программа типа «Hello, world!» («Здравствуй, мир!») – вовсе не повод для обсуждения возможностей языка. Она здесь для того, чтобы помочь вам начать писать и запускать программы на этом языке. Если у вас нет никакой IDE, которая выполнит за вас сборку программы, то самый простой способ – это командная строка. Напечатав приведенный код и сохранив его в файле с именем, скажем, hello.d
, запустите консоль и введите следующие команды:
$ dmd hello.d
$ ./hello
Hello, world!
$ _
Знаком $
обозначено приглашение консоли вашей ОС (это может быть c:\Путь\К\Папке>
в Windows или /путь/к/каталогу%
в системах семейства UNIX, таких как OSX, Linux, Cygwin). Применив пару известных вам приемов систем-фу, вы сможете добиться автоматической компиляции программы при ее запуске. Пользователи Windows, вероятно, захотят привязать программу rdmd.exe
(которая устанавливается вместе с компилятором D) к команде Выполнить. UNIX-подобные системы поддерживают запуск скриптов в нотации «shebang»1. D понимает такой синтаксис: добавление строки
#!/usr/bin/rdmd
в самое начало программы в файле hello.d
позволяет компилировать ее автоматически перед исполнением. Внеся это изменение, просто введите в командной строке:
$ chmod u+x hello.d
$ ./hello.d
Hello, world!
$ _
(chmod
нужно ввести только один раз).
Для всех операционных систем справедливо следующее: программа rdmd
достаточно «умна», для того чтобы кэшировать сгенерированное приложение. Так что фактически компиляция выполняется только после изменения исходного кода программы, а не при каждом запуске. Эта особенность в сочетании с высокой скоростью самого компилятора позволяет экономить время на запусках программы между внесением в нее изменений, что одинаково полезно как при разработке больших систем, так и при написании маленьких скриптов.
Программа hello.d
начинается с инструкции
import std.stdio;
которая предписывает компилятору найти модуль с именем std.stdio
и сделать его символы доступными для использования. Инструкция import
напоминает препроцессорную директиву #include
, которую можно встретить в синтаксисе C и С++, но семантически она ближе команде import
языка Python: никакой вставки текста подключаемого модуля в текст основной программы не происходит – выполняется только простое расширение таблицы символов. Если повторно применить инструкцию import
к тому же файлу, ничего не произойдет.
По давней традиции C программа на D представляет собой набор определений, рассредоточенный по множеству файлов. В числе прочего эти определения могут обозначать типы, функции, данные. В нашей первой программе определена функция main
. Она не принимает никаких аргументов и ничего не возвращает, что, по сути, и означает слово void. При выполнении main
программа вызывает функцию writeln
(разумеется, предусмотрительно определенную в модуле std.stdio
), передавая ей строковую константу в качестве аргумента. Суффикс ln
указывает на то, что writeln
добавляет к выводимому тексту знак перевода строки.
Следующие разделы – это стремительная поездка по Дибургу. Небольшие показательные программы дают общее представление о языке. Основная цель повествования на данном этапе – обрисовать общую картину, а не дать ряд педантичных определений. Позже все аспекты языка будут рассмотрены с должным вниманием – в деталях.
1.1. Числа и выражения
Интересовались ли вы когда-нибудь ростом иностранцев? Давайте напишем простую программу, которая переводит наиболее распространенные значения роста в футах и дюймах в сантиметры.
/*
Рассчитать значения роста в сантиметрах для заданного диапазона значений в футах и дюймах
*/
import std.stdio;
void main()
{
// Значения, которые никогда не изменятся
immutable inchesPerFoot = 12;
immutable cmPerInch = 2.54;
// Перебираем и пишем
foreach (feet; 5 .. 7)
{
foreach (inches; 0 .. inchesPerFoot)
{
writeln(feet, "'", inches, "''\t", (feet * inchesPerFoot + inches) * cmPerInch);
}
}
}
В результате выполнения программы будет напечатан аккуратный список в две колонки:
5'0'' 152.4
5'1'' 154.94
5'2'' 157.48
...
6'10'' 208.28
6'11'' 210.82
Инструкция foreach (feet; 5..7) {...}
– это цикл, где определена целочисленная переменная feet
, с которой последовательно связываются значения 5 и 6 (значение 7 она не принимает, так как интервал открыт справа). Как и Java, C++ и C#, D поддерживает /* многострочные комментарии */
и // однострочные комментарии
(и, кроме того, документирующие комментарии, о которых позже). Еще одна интересная деталь нашей маленькой программы – способ объявления данных. Во-первых, введены две константы:
immutable inchesPerFoot = 12;
immutable cmPerInch = 2.54;
Константы, значения которых никогда не изменятся, определяются с помощью ключевого слова immutable
. Как и переменные, константы не требуют явного задания типа: тип задается значением, которым инициализируется константа или переменная. В данном случае литерал 12 говорит компилятору о том, что inchesPerFoot
– это целочисленная константа (обозначается в D с помощью знакомого int
); точно так же литерал 2.54
заставляет cmPerInch
стать константой с плавающей запятой (типа double
). Далее мы обнаруживаем те же магические способности у определений feet
и inches
: они выглядят как «обычные» переменные, но безо всяких «украшений», свидетельствующих о каком-либо типе. Это не делает программу менее безопасной по сравнению с той, где типы переменных и констант заданы явно:
immutable int inchesPerFoot = 12;
immutable double cmPerInch = 2.54;
...
foreach (int feet; 5 .. 7)
{
...
}
и так далее – только меньше лишнего. Компилятор разрешает не указывать тип явно только в случае, когда можно недвусмысленно определить его по контексту. Раз уж зашла речь о типах, давайте остановимся и посмотрим, какие числовые типы нам доступны.
Целые типы со знаком в порядке возрастания размера: byte
, short
, int
и long
, занимающие 8, 16, 32 и 64 бита соответственно. У каждого из этих типов есть «двойник» без знака того же размера, названный в соответствии с простым правилом: ubyte
, ushort
, uint
и ulong
. (Здесь нет модификатора unsigned
, как в C). Типы с плавающей запятой: float
(32-битное число одинарной точности в формате IEEE 754), double
(64-битное в формате IEEE 754) и real
(занимает столько, сколько позволяют регистры, предназначенные для хранения чисел с плавающей запятой, но не меньше 64 бит; например, на компьютерах фирмы Intel real
– это так называемое расширенное 79-битное число двойной точности в формате IEEE 754).
Вернемся к нашим целым числам. Литералы, такие как 42
, подходят под определение любого числового типа, но заметим, что компилятор проверяет, достаточно ли вместителен «целевой» тип для этого значения. Поэтому определение
immutable byte inchesPerFoot = 12;
ничем не хуже аналогичного без byte
, поскольку 12
можно с таким же успехом представить 8 битами, а не 32. По умолчанию, если вывод о «целевом» типе делается по числу (как в программе-примере), целочисленные константы «воспринимаются» как int
, а дробные – как double
.
Вы можете построить множество выражений на D, используя эти типы, арифметические операторы и функции. Операторы и их приоритеты сходны с теми, что можно найти в языках-собратьях D: +
, -
, *
, /
и %
для базовых арифметических операций, ==
, !=
, <
, >
, <=
, >=
для сравнений, fun(argument1, argument2)
для вызовов функций и т.д.
Вернемся к нашей программе перевода дюймов в сантиметры и отметим две достойные внимания детали вызова функции writeln
. Первая: во writeln
передаются 5 аргументов (а не один, как в той программе, что установила контакт между вами и миром D). Функция writeln
очень похожа на средства ввода-вывода, встречающиеся в языках Паскаль (writeln
), C (printf
) и C++ (cout
). Все они (включая writeln
из D) принимают переменное число аргументов (так называемые функции с переменным числом аргументов). Однако в D пользователи могут определять собственные функции с переменным числом аргументов (чего нет в Паскале), которые всегда типизированы (в отличие от C), без излишнего переопределения операторов (как это сделано в С++). Вторая деталь: наш вызов writeln
неуклюже сваливает в кучу информацию о форматировании и форматируемые данные. Обычно желательно отделять данные от представления. Поэтому давайте используем специальную функцию writefln
, осуществляющую форматированный вывод:
writefln("%s'%s''\t%s", feet, inches, (feet * inchesPerFoot + inches) * cmPerInch);
По-новому организованный вызов дает тот же вывод, но первый аргумент функции writefln
полностью описывает формат представления. Со знака %
начинаются спецификаторы формата (по аналогии с функцией printf
из C): например %d
– для целых чисел, %f
– для чисел с плавающей запятой и %s
– для строк.
Если вы использовали printf
прежде, то могли бы почувствовать себя как дома, когда б не маленькая особенность: мы ведь выводим значения переменных типа int
и double
– как же получилось, что и те и другие описаны с помощью спецификатора %s
, обычно применяемого для вывода строк? Ответ прост. Средства D для работы с переменным количеством аргументов дают writefln
доступ к информации об исходных типах переданных аргументов. Благодаря такому подходу программа получает ряд преимуществ: 1) значение %s
может быть расширено до «строкового представления по умолчанию для типа переданного аргумента» и 2) если не удалось сопоставить спецификатор формата с типами переданных аргументов, вы получите ошибку в чистом виде, а не загадочное поведение, присущее вызовам printf
с неверно заданным форматом (не говоря уже о подрыве безопасности, возможном при вызове printf
с непроверяемыми заранее форматирующими строками).
1.2. Инструкции
В языке D, как и в других родственных ему языках, любое выражение, после которого стоит точка с запятой, – это инструкция (например в программе «Hello, world!» сразу после вызова writeln
есть ;). Действие инструкции сводится к вычислению выражения.
D – член семейства с фигурными скобками и с блочной областью видимости». Это означает, что вы можете объединять несколько команд в одну, помещая их в {
и }
, что порой обязательно, например при желании сделать сразу несколько вещей в цикле foreach
. В случае единственной команды вы вправе смело опустить фигурные скобки. На самом деле, весь наш двойной цикл, вычисляющий значения роста, можно переписать так:
import std.stdio;
void main()
{
// Значения, которые никогда не изменятся
immutable inchesPerFoot = 12;
immutable cmPerInch = 2.54;
// Перебираем и пишем
foreach (feet; 5 .. 7)
foreach (inches; 0 .. inchesPerFoot)
writeln(feet, "'", inches, "''\t", (feet * inchesPerFoot + inches) * cmPerInch);
}
У пропуска фигурных скобок для одиночных инструкций есть как преимущество (более короткий код), так и недостаток – редактирование кода становится более утомительным (в процессе отладки придется повозиться с инструкциями, то добавляя, то удаляя скобки). Когда речь заходит о правилах расстановки отступов и фигурных скобок, мнения сильно расходятся. На самом деле, пока вы последовательны в своем выборе, все это не так важно, как может показаться. В качестве доказательства: стиль, предлагаемый в этой книге (обязательное заключение в операторные скобки даже одиночных инструкций, открывающая скобка на одной строке с соответствующим оператором, закрывающие скобки на отдельных строках), по типографским причинам отличается от реально применяемого автором. А раз он мог спокойно это пережить, не превратившись в оборотня, то и любой сможет.
Благодаря языку Python стал популярен иной способ отражения блочной структуры программы – с помощью отступов (чудесное воплощение принципа «форма соответствует содержанию»). Для программистов на других языках утверждение, что пробел имеет значение, – всего лишь нелепая фраза, но для тех, кто пишет на Python, это зарок. D обычно игнорирует пробелы, но он разработан с прицелом на легкость синтаксического разбора (т. е. чтобы при разборе не приходилось выяснять значения символов). А это подразумевает, что в рамках скромного «комнатного» проекта можно реализовать простой препроцессор, позволяющий использовать для выделения блоков инструкций отступы (как в Python) без каких-либо неудобств во время компиляции, исполнения и отладки программ.
Кроме того, вам должна быть хорошо знакома инструкция if:
if (‹выражение›) ‹инструкция1› else ‹инструкция2›
Чисто теоретический вывод, известный как принцип структурного программирования, гласит, что все алгоритмы можно реализовать с помощью составных инструкций, if
-проверок и циклов а-ля for
и foreach
. Разумеется, любой адекватный язык (как и D) предлагает гораздо больше, но мы пока постановим, что с нас довольно и этих инструкций, и двинемся дальше.
1.3. Основы работы с функциями
Оставим пока в стороне обязательное определение функции main
и посмотрим, как определяются другие функции на D. Определение функции соответствует модели, характерной и для других Алгол-подобных языков: сначала пишется возвращаемый тип, потом имя функции и, наконец, заключенный в круглые скобки список формальных аргументов, разделенных запятыми. Например, определение функции с именем pow
, которая принимает значения типа double
и int
, а возвращает double
, записывается так:
double pow(double base, int exponent)
{
...
}
Каждый параметр функции (base
и exponent
в данном примере) кроме типа может иметь необязательный класс памяти (storage class), определяющий способ передачи аргумента в функцию при ее вызове2.
По умолчанию аргументы передаются в pow
по значению. Если перед типом параметра указан класс памяти ref
, то параметр привязывается напрямую к входному аргументу, так что изменение параметра непосредственно отражается на значении, полученном извне. Например:
import std.stdio;
void fun(ref uint x, double y)
{
x = 42;
y = 3.14;
}
void main()
{
uint a = 1;
double b = 2;
fun(a, b);
writeln(a, " ", b);
}
Эта программа печатает 42 2
, потому что x
определен как ref uint
, то есть когда значение присваивается x, на самом деле операция проводится с a
. С другой стороны, присваивание значения переменной y
никак не скажется на b
, поскольку y
– это внутренняя копия в распоряжении функции fun
.
Последние «украшения», которые мы обсудим в этом кратком введении, – это in
и out
. Попросту говоря, in
– данное функцией «обещание» только смотреть на параметр, не «трогая» его. Указание out
в определении параметра функции действует сходно с ref
, с той поправкой, что параметр принудительно инициализируется своим значением по умолчанию при «входе» в функцию. (Для каждого типа T
определено начальное значение, обозначаемое как T.init
. Пользовательские типы могут определять собственное значение по умолчанию.)
О функциях можно еще долго рассказывать. Можно передавать функции другим функциям, встраивать одну в другую, разрешать функции сохранять свою локальную среду (полнофункциональная синтаксическая клауза), создавать анонимные функции (лямбда-функции), с удобством манипулировать ими и еще множество дополнительных «вкусностей». Со временем мы доберемся до каждой из них.
1.4. Массивы и ассоциативные массивы
Массивы и ассоциативные массивы (которые обычно называют хеш-таблицами, или хешами) – пожалуй, наиболее часто используемые сложные структуры данных за всю историю машинных вычислений, завистливо преследуемые списками языка Лисп. Множество полезных программ не требуют ничего, кроме массива или ассоциативного массива. Так что пришло время посмотреть, как D их реализует.
1.4.1. Работаем со словарем
Для примера напишем простенькую программку, следуя такой спецификации:
Читать текст, состоящий из слов, разделенных пробелами, и сопоставлять каждому не встречавшемуся до сих пор при чтении слову уникальное число. Вывод организовать в виде строк формата:
идентификатор слово
Такой маленький скрипт вполне может пригодиться, когда вы захотите обработать какой-нибудь текст. Построив словарь, вы получите возможность манипулировать только числами (что дешевле), а не полновесными словами. Один из вариантов построения такого словаря – накапливать уже прочитанные слова в ассоциативном массиве, отображающем слова на целые числа. При добавлении нового соответствия достаточно убедиться, что число, связываемое со словом, уникально («железная» гарантия – просто использовать текущую длину массива, в результате чего получится последовательность идентификаторов 0, 1, 2, ...). Посмотрим, как это можно реализовать на D.
import std.stdio, std.string;
import std.algorithm;
void main()
{
size_t [string] dictionary;
foreach (line; stdin.byLine())
{
// Разбить строку на слова
// Добавить каждое слово строки в словарь
foreach (word; line.strip.splitter)
{
if (word in dictionary) continue; // Ничего не делать
auto newID = dictionary.length;
dictionary[word.idup] = newID;
writeln(newID, '\t', word);
}
}
}
В языке D ассоциативный массив (хеш-таблица), который значениям типа K
ставит в соответствие значения типа V
, обозначается как V[K]
. Итак, переменная dictionary
типа size_t[string]
сопоставляет строкам целые числа без знака – как раз то, что нам нужно для хранения соответствий слов идентификаторам. Выражение word in dictionary
истинно, если ключевое слово word
можно найти в ассоциативном массиве dictionary
. Наконец, вставка в словарь выполняется так: dictionary[word.idup] = newID
3.
Хотя в рассмотренном сценарии не отражается явно тот факт, что тип string
– на самом деле массив знаков, это так. В общем виде динамический массив элементов типа T
обозначается как T[]
и может определяться различными способами:
int[] a = new int[20]; // 20 целых чисел, инициализированных нулями
int[] b = [ 1, 2, 3 ]; // Массив, содержащий 1, 2, и 3
В отличие от массивов C, массивы D «знают» собственную длину. Для любого массива arr
это значение доступно как arr.length
. Присваивание значения arr.length
перераспределяет память, выделенную под массив. При попытке обращения к элементам массива проверяется, не выходит ли запрашиваемый индекс за границу массива. Любители рискнуть переполнением буфера могут «выдрать» указатель из массива (используя arr.ptr
) и затем выполнять непроверенные арифметические операции над ним. Кроме того, если вам действительно нужно все, что может дать кремниевая пластина, есть опция компилятора, отменяющая проверку границ. Можно сказать, что к безопасности ведет путь наименьшего сопротивления. Код безопасен по умолчанию, а если поработать, можно сделать его чуть более быстрым.
Вот как можно проходить по массиву с помощью новой формы уже знакомой инструкции foreach
:
int[] arr = new int[20];
foreach (elem; arr)
{
/* ... использовать elem... */
}
Этот цикл по очереди связывает переменную elem
с каждым элементом массива arr
. Присваивание elem
не влияет на элементы arr
. Чтобы изменить массив таким способом, просто используйте ключевое слово ref
:
// Обнулить все элементы arr
foreach (ref elem; arr)
{
elem = 0;
}
Теперь, когда мы знаем, как foreach
работает с массивами, рассмотрим еще один полезный прием. Если в теле цикла вам потребуется индекс элемента массива, foreach
может рассчитать его для вас:
int[] months = new int[12];
foreach (i, ref e; months)
{
e = i + 1;
}
Этот код заполняет массив числами от 1 до 12. Такой цикл эквивалентен чуть более многословному определению (см. ниже), использующему foreach
для просмотра диапазона чисел:
foreach (i; 0 .. months.length)
{
months[i] = i + 1;
}
D также предлагает массивы фиксированного размера, обозначаемые, например, как int[5]
. За исключением отдельных специализированных приложений, предпочтительнее использовать динамические массивы, поскольку обычно размер массива вам заранее неизвестен.
Семантика копирования массивов неочевидна: копирование одной переменной типа массив в другую не копирует весь массив; эта операция порождает лишь новую ссылку на ту же область памяти. Если вам действительно хочется заполучить копию, просто используйте свойство массива .dup
:
int[] a = new int[100];
int[] b = a;
// ++x увеличивает на 1 значение x
++b[10]; // В b[10] теперь 1, в a[10] то же
b = a.dup; // Полностью скопировать a в b
++b[10]; // В b[10] теперь 2, а в a[10] остается 1
1.4.2. Получение среза массива. Функции с обобщенными типами параметров. Тесты модулей
Получение среза массива – это мощное средство, позволяющее ссылаться на часть массива, в действительности не копируя данные массива. В качестве иллюстрации напишем функцию двоичного поиска, реализующую одноименный алгоритм: получив упорядоченный массив и значение, двоичный поиск быстро возвращает логический результат, сообщающий, есть ли заданное значение в массиве. Функция из стандартной библиотеки D возвращает более информативный ответ, чем просто булево значение, но знакомство с ней придется отложить, так как для этого необходимо более глубокое знание языка. Позволим себе, однако, приподнять планку, задавшись целью написать функцию, которая будет работать не только с массивами целых чисел, но с массивами элементов любого типа, допускающего сравнение с помощью операции <
. Оказывается, реализовать эту задумку можно без особого труда. Вот как выглядит функция обобщенного двоичного поиска binarySearch
:
import std.array;
bool binarySearch(T)(T[] input, T value)
{
while (!input.empty)
{
auto i = input.length / 2;
auto mid = input[i];
if (mid > value) input = input[0 .. i];
else if (mid < value) input = input[i + 1 .. $];
else return true;
}
return false;
}
unittest
{
assert(binarySearch([ 1, 3, 6, 7, 9, 15 ], 6));
assert(!binarySearch([ 1, 3, 6, 7, 9, 15 ], 5));
}
Знаки (T)
в сигнатуре функции binarySearch
обозначают параметр типа с именем T
. T
становится псевдонимом переданного типа в теле этой функции. Затем параметр типа можно использовать в обычном списке параметров функции. При вызове binarySearch
компилятор определит значение T
по фактическим аргументам. Если вы хотите указать T
явно (например, для надежности), то можете написать:
assert(binarySearch!(int)([ 1, 3, 6, 7, 9, 15 ], 6));
что обнаруживает возможность вызова обобщенной функции с двумя заключенными в круглые скобки последовательностями аргументов. Сначала следуют заданные во время компиляции аргументы, заключенные в !(...)
, а за ними – получаемые во время исполнения программы аргументы в (...)
. Объединение этих двух «владений» в одно обдумывалось, но эксперименты показали, что такая унификация создает больше проблем, чем решает.
Если вы знакомы с аналогичными средствами Java, C# и C++, то, вероятно, вам сразу бросилось в глаза то, что D сделал шаг в сторону от этих языков, отказавшись применять угловые скобки <
и >
для обозначения аргументов, заданных во время компиляции. Это осознанное решение. Его цель – избежать горького опыта C++ (возросшее количество трудностей при синтаксическом разборе, гекатомба в виде множества специальных правил и тай-брейков плюс ко всему неясный синтаксис, осложняющий жизнь пользователя своей двусмысленностью4). Проблема в том, что знаки <
и >
являются операторами сравнения5]. Это делает их использование в качестве разделителей очень двусмысленным, учитывая тот факт, что внутри этих разделителей разрешены выражения. Таким «кандидатам в разделители» очень сложно подыскать замену. Языкам Java и C# живется легче: они запрещают писать выражения внутри <
и >
. Однако этим они ограничивают свою расширяемость ради сомнительного преимущества. D разрешает использовать выражения в качестве аргументов, заданных во время компиляции. Было решено упростить жизнь как человеку, так и компьютеру, наделив дополнительным смыслом традиционный унарный оператор !
(используемый в логических операциях) и задействовав классические круглые скобки (которые, уверен, вы всегда сможете верно сопоставить друг другу).
Другая любопытная деталь нашей реализации бинарного поиска – употребление auto
для запуска алгоритма, строящего предположения о типах по контексту программы: типы переменных i
и mid
определены из выражений, которыми они инициализируются.
В стремлении придерживаться хорошего тона при написании программ к binarySearch
был добавлен тест модуля. Тесты модулей вводятся в виде блоков, озаглавленных ключевым словом unittest
(файл может содержать сколько угодно конструкций unittest
, поскольку, как известно, проверок много не бывает). Чтобы перед входом в функцию main
запустить тест, передайте компилятору флаг -unittest
. Хотя unittest
кажется незначительной деталью, такая конструкция помогает соблюдать хороший стиль программирования: с ее помощью вставлять тесты так легко, что было бы странно не делать этого. Кроме того, если вы привыкли создавать программы «сверху вниз» и предпочитаете видеть сначала тест модуля, а реализацию потом, смело вставляйте unittest
до binarySearch
; в D семантика символов на уровне модуля никогда не зависит от их расположения относительно других символов на том же уровне.
Операция получения среза input[a .. b]
возвращает срез массива input
от a
до b
, исключая индекс b
. Если a == b
, будет возвращен пустой срез, а если a > b
, генерируется исключительная ситуация. Операция получения среза не влечет за собой динамическое выделение памяти; это всего лишь создание новой ссылки на часть массива. Символ $
внутри выражения индексации или получения среза обозначает длину массива, к которому осуществляется доступ; например, input[0 .. $]
– это то же самое, что и просто input
.
Повторимся: несмотря на кажущееся обилие перемещений, производимых функцией binarySearch
, память под новые массивы не была выделена ни разу. Предложенную реализацию алгоритма ни в коей мере нельзя назвать менее эффективной по сравнению с традиционной реализацией, которую характеризует постоянное вычисление индексов. Однако, без сомнения, новая реализация проще для понимания, поскольку она оперирует меньшим количеством состояний. В свете разговора о состояниях напишем рекурсивную реализацию binarySearch
, которая вообще не переопределяет input
:
import std.array;
bool binarySearchR(T)(T[] input, T value)
{
if (input.empty) return false;
auto i = input.length / 2;
auto mid = input[i];
if (mid > value) return binarySearch(input[0 .. i], value);
if (mid < value) return binarySearch(input[i + 1 .. $], value);
return true;
}
Рекурсивная реализация явно проще и концентрированнее по сравнению со своим итеративным собратом. Кроме того, она ничуть не менее эффективна, так как рекурсивные вызовы оптимизируются благодаря популярной среди компиляторов технике, известной как оптимизация хвостовой рекурсии. В двух словах: если функция возвращает просто вызов самой себя (но с другими аргументами), компилятор модифицирует аргументы и инициирует переход к началу функции.
1.4.3. Подсчет частот. Лямбда-функции
Поставим себе задачу написать еще одну полезную программу, которая будет подсчитывать частоту употребления слов в заданном тексте. Хотите знать, какие слова употребляются в «Гамлете» чаще всего? Тогда вы как раз там, где надо.
Следующая программа использует ассоциативный массив, сопоставляющий строки переменным типа uint
, и имеет структуру, напоминающую структуру программы построения словаря, рассмотренной в предыдущем примере. Чтобы сделать программу подсчета частот полностью полезной, в нее добавлен простой цикл печати:
import std.algorithm, std.stdio, std.string;
void main()
{
// Рассчитать таблицу частот
uint[string] freqs;
foreach (line; stdin.byLine())
{
foreach (word; line.strip.splitter)
{
++freqs[word.idup];
}
}
// Напечатать таблицу частот
foreach (key, value; freqs)
{
writefln("%6u\t%s", value, key);
}
// Напечатать таблицу частот с сортировкой
string[] words = freqs.keys;
sort!((a, b) { return freqs[a] > freqs[b]; })(words);
foreach (word; words)
{
writefln("%6u\t%s", freqs[word], word);
}
}
А теперь, скачав из Сети файл hamlet.txt
6 (который вы найдете по прямой ссылке) и запустив нашу маленькую программу с шекспировским шедевром в качестве аргумента, вы получите:
1 outface
1 come?
1 blanket,
1 operant
1 reckon
2 liest
1 Unhand
1 dear,
1 parley.
1 share.
...
И, к сожалению, обнаружите, что вывод неупорядочен: слова, которые напечатаны в первых строках, далеко не самые часто встречающиеся. Что неудивительно – для ускорения реализации примитивов ассоциативных массивов элементы в них могут храниться в любом порядке.
Для того чтобы отсортировать вывод по убыванию частоты употребления слов, вы можете просто передать вывод программы утилите sort
с флажком -nr
(от numerically – отсортировать по числам и reversed – в обратном порядке), но это своего рода хитрость. Чтобы добавить сортировку непосредственно в нашу программу, заменим последний цикл следующим кодом:
// Напечатать таблицу частот
string[] words = freqs.keys;
sort!((a, b) { return freqs[a] > freqs[b]; })(words);
foreach (word; words)
{
writefln("%6u\t%s", freqs[word], word);
}
Свойство .keys
позволяет получить только ключи ассоциативного массива freqs
в виде массива строк, под который выделяется новая область памяти. Этого не избежать, ведь нам требуется делать перестановки. Мы получили код
sort!((a, b) { return freqs[a] > freqs[b]; })(words);
который соответствует недавно рассмотренной нотации:
sort!(‹аргументы времени компиляции›)(‹аргументы времени исполнения›);
Взяв текст, заключенный в первые круглые скобки – !(...)
, – мы получим форму записи, напоминающую незаконченную функцию – как будто ее автор забыл о типах параметров, возвращаемом типе и о самом имени функции:
(a, b) { return freqs[a] > freqs[b]; }
Это лямбда-функция – небольшая анонимная функция, которая обычно создается для того, чтобы потом передавать ее другим функциям в качестве аргумента. Лямбда-функции используются постоянно и повсеместно, поэтому разработчики D сделали все возможное, чтобы избавить программиста от синтаксической нагрузки, прежде неизбежной при определении таких функций: типы параметров и возвращаемый тип выясняются из контекста. Это действительно имеет смысл, потому что тело лямбда-функции по определению там, где нужно автору, читателю и компилятору, то есть здесь нет места разночтениям и принципы модульности не нарушаются.
В связи с лямбда-функцией, определенной в этом примере, стоит упомянуть еще одну деталь. Лямбда-функция осуществляет доступ к переменной freqs
, локальной переменной функции main
, а значит, лямбда-функция не является ни глобальной, ни статической. Это больше напоминает подход Лиспа, а не C, и позволяет работать с очень мощными лямбда-конструкциями. И хотя обычно за такое преимущество приходится платить неявными вызовами функций во время исполнения программы, D гарантирует отсутствие таких вызовов (и, следовательно, ничем не ограниченные возможности реализации инлайнинга).
Вывод измененной программы:
929 the
680 and
625 of
608 to
523 I
453 a
444 my
382 in
361 you
358 Ham.
...
Что и ожидалось: самые часто употребляемые слова набрали больше всего «очков». Настораживает лишь «Ham»7. Это слово в пьесе вовсе не отражает кулинарные предпочтения героев. «Ham» – всего лишь сокращение от «Hamlet» (Гамлет), которым помечена каждая из его реплик. Явно у него был повод высказаться 358 раз – больше, чем любой другой герой пьесы. Далее по списку следует король – всего 116 реплик, меньше трети сказанного Гамлетом. А Офелия с ее 58 репликами – просто молчунья.
1.5. Основные структуры данных
Раз уж мы взялись за «Гамлета», проанализируем этот текст чуть глубже. Например, соберем кое-какую информацию о главных героях: сколько всего слов было произнесено каждым персонажем и насколько богат его (ее) словарный запас. Для этого с каждым действующим лицом понадобится связать несколько фактов. Чтобы сосредоточить эту информацию в одном месте, определим такую структуру данных:
struct PersonaData
{
uint totalWordsSpoken;
uint[string] wordCount;
}
В языке D понятия структуры (struct
) и классы (class
) четко разделены. С точки зрения удобства они во многом схожи, но устанавливают разные правила: структуры – это типы значений, а классы были задуманы для реализации динамического полиморфизма, поэтому экземпляры классов могут быть доступны исключительно по ссылке. Упразднены связанное с этим непонимание, ошибки при копировании экземпляров классов потомков в переменные классов-предков и комментарии а-ля // Нет! НЕ наследуй!
. Разрабатывая тип, вы с самого начала должны решить, будет ли это мономорфный тип-значение или полиморфная ссылка. Общеизвестно, что C++ разрешает определять типы, принадлежность которых к тому или иному разряду неочевидна, но эти типы редко используются и чреваты ошибками. В целом достаточно оснований сознательно отказаться от них.
В нашем случае требуется просто собрать немного данных, и мы не планируем использовать полиморфные типы, поэтому тип struct
– хороший выбор. Теперь определим ассоциативный массив, отображающий имена персонажей на дополнительную информацию о них (значения типа PersonaData
):
PersonaData[string] info;
Все, что нам требуется, – это правильно заполнить info
данными из hamlet.txt
. Придется немного потрудиться: реплика героя может простираться на несколько строк, и нам понадобится простая обработка, сцепляющая эти физические строки в одну логическую. Чтобы понять, как это сделать, обратимся к небольшому фрагменту файла hamlet.txt
, познаково представленному ниже (предшествующие тексту пробелы для наглядности отображаются видимыми знаками):
˽˽Pol. Marry, I will teach you! Think yourself a baby
˽˽˽˽That you have ta'en these tenders for true pay,
˽˽˽˽Which are not sterling. Tender yourself more dearly,
˽˽˽˽Or (not to crack the wind of the poor phrase,
˽˽˽˽Running it thus) you'll tender me a fool.
˽˽Oph. My lord, he hath importun'd me with love
˽˽˽˽In honourable fashion.
˽˽Pol. Ay, fashion you may call it. Go to, go to!
До сих пор гадают, не было ли истинной причиной гибели Полония злоупотребление инструкцией goto
. Но нас больше интересует другое: заметим, что реплике каждого персонажа предшествуют ровно два пробела, за ними следует имя, после которого стоит точка, потом пробел, за которым наконец-то начинается само высказывание. Если логическая строка занимает несколько физических строк, то каждая следующая строка всегда начинается с четырех пробелов. Можно осуществить простое сопоставление шаблону, воспользовавшись регулярными выражениями (для работы с которыми предназначен модуль std.regex
), но мы хотим научиться работать с массивами, поэтому выполним сопоставление «вручную». Призовем на помощь лишь логическую функцию a.startsWith(b)
, определенную в модуле std.algorithm
, которая сообщает, начинается ли a
с b
.
Управляющая функция main
читает входную последовательность физических строк, сцепляет их в логические строки (игнорируя все, что не подходит под наш шаблон), передает полученные полные реплики в функцию-накопитель и в конце печатает требуемую информацию:
import std.algorithm, std.conv, std.regex, std.range, std.stdio, std.string, std.ascii;
struct PersonaData
{
uint totalWordsSpoken;
uint[string] wordCount;
}
void main()
{
// Накапливает информацию о главных героях
PersonaData[string] info;
// Заполнить info
string currentParagraph;
foreach (line; stdin.byLine())
{
// 4 символа отступа
if (line.startsWith(" ") && line.length > 4 && isAlpha(line[4]))
{
// Персонаж продолжает высказывание
currentParagraph ~= line[3 .. $];
}
// 2 символа отступа
else if (line.startsWith(" ") && line.length > 2 && isAlpha(line[2]))
{
// Персонаж только что начал говорить
addParagraph(currentParagraph, info);
currentParagraph = to!string(line[2 .. $]);
}
}
// Закончили, теперь напечатаем собранную информацию
printResults(info);
}
Зная, как работают массивы, мы без труда читаем этот код, за исключением конструкции to!string(line[2 .. $])
. Зачем она нужна и что будет, если о ней забыть?
Цикл foreach
, последовательно считывая из стандартного потока ввода строки текста, размещает их в переменной line
. Поскольку не имеет смысла выделять память под новый буфер при чтении следующей строки, в каждой итерации byLine
заново использует место, выделенное для line
. Тип самой переменной line – char[]
, массив знаков.
Если вы всего лишь, считав, «обследуете» каждую строчку, а потом забываете о ней, в любом случае (как с to!string(line[2 .. $])
, так и без нее) все будет работать гладко. Но если вы желаете создать код, который будет где-то накапливать содержание читаемых строк, лучше позаботиться о том, чтобы он их действительно копировал. Очевидно, было задумано реально хранить текст в переменной currentParagraph
, а не использовать ее как временное пристанище, так что необходимо получать дубликаты; отсюда и присутствие конструкции to!string
, которая преобразует любое выражение в строку. Переменные типа string
неизменяемы, а to
гарантирует приведение к этому типу созданием дубликата.
Если забыть написать to!string
и впоследствии код все же скомпилируется, в результате получится бессмыслица, и ошибку будет довольно-таки сложно обнаружить. Очень неприятно отлаживать программу, одна часть которой изменяет данные, находящиеся в другой части программы, потому что это уже не локальные изменения (трудно представить, сколько вызовов to
можно забыть при написании большой программы). К счастью, это не причина для беспокойства: типы переменных line
и currentParagraph
соответствуют роли этих переменных в программе. Переменная line
имеет тип char[]
, представляющий собой массив знаков, которые можно перезаписывать в любой момент; переменная currentParagraph
имеет тип string
– массив знаков, которые нельзя изменять по отдельности. (Для самых любопытных: полное имя типа string
– immutable(char)[]
, что дословно означает «непрерывный диапазон неизменяемых знаков». Мы вернемся к разговору о строках в главе 4.) Эти переменные не могут ссылаться на одну и ту же область памяти, поскольку line
нарушает обязательство currentParagraph
не изменять знаки по отдельности. Поэтому компилятор отказывает в компиляции ошибочного кода и требует копию, которую вы и предоставляете благодаря преобразованию в строку с помощью конструкции to!string
. И все счастливы.
С другой стороны, если постоянно копировать строковые значения, то нет необходимости дублировать данные на нижнем уровне их представления – переменные просто могут ссылаться на одну и ту же область памяти, которая наверняка не будет перезаписана. Это делает копирование переменных типа string
безопасным и эффективным одновременно. Но это еще не все плюсы. Строки можно без проблем разделять между потоками, потому что данные типа string
неизменяемы, так что возможность конфликта при обращении к памяти попросту отсутствует. Неизменяемость – это действительно здорово. С другой стороны, если вам потребуется интенсивно изменять знаки по отдельности, возможно, вы предпочтете использовать тип char[]
, хотя бы временно.
Структура PersonData
в том виде, в каком она задана выше, очень проста. Однако в общем случае структуры могут определять не только данные, но и другие сущности, такие как частные (приватные, закрытые) разделы (обозначаются ключевым словом private
), функции-члены, тесты модулей, операторы, конструкторы и деструкторы. По умолчанию любой элемент структуры инициализируется значением по умолчанию (ноль для целых чисел, NaN для чисел с плавающей запятой8 и null
для массивов и других типов, доступ к которым не осуществляется напрямую. А теперь реализуем функцию addParagraph
, которая разбивает строку текста на слова и распределяет их по ассоциативному массиву.
Строка, которую обрабатывает main
, имеет вид: "Ham. To be, or not to be, that is the question."
. Для того чтобы отделить имя персонажа от слов, которые он произносит, нам требуется найти первый разделитель ". "
. Для этого используем функцию find
. Выражение haystack.find(needle)
возвращает правую часть haystack
, начинающуюся с первого вхождения needle
. (Если needle
в haystack
отсутствует, то вызов find
с такими аргументами вернет пустую строку.) Пока мы формируем словарь, не мешает немного прибраться. Во-первых, нужно преобразовать фразу к нижнему регистру, чтобы слово с заглавной и со строчной буквы воспринималось как одна и та же словарная единица. Об этом легко позаботиться с помощью вызова функции tolower
. Второе, что необходимо сделать, – удалить мощный источник шума – знаки пунктуации, которые превращают, к примеру, «him.
» и «him
» в разные слова. Для того чтобы очистить словарь, достаточно передать функции split
единственный дополнительный параметр. Имеется в виду регулярное выражение, которое уничтожит всю «шелуху»: regex("[ \t,.;:?]+")
. Получив такой аргумент, функция split
сочтет любую последовательность знаков, упомянутых между [
и ]
, одним из разделителей слов. Теперь мы готовы, как говорится, приносить большую пользу с помощью всего лишь маленького кусочка кода:
void addParagraph(string line, ref PersonaData[string] info)
{
// Выделить имя персонажа и его реплику
line = strip(line);
// auto sentence = std.algorithm.find(line, ". ");
auto sentence = line.find(". ");
if (sentence.empty)
{
return;
}
auto persona = line[0 .. $ - sentence.length];
sentence = toLower(strip(sentence[2 .. $]));
// Выделить произнесенные слова
auto words = split(sentence, regex("[ \t,.;:?]+"));
// Вставка или обновление информации
if (!(persona in info))
{
// Первая реплика персонажа
info[persona] = PersonaData();
}
info[persona].totalWordsSpoken += words.length;
foreach (word; words)
{
++info[persona].wordCount[word];
}
}
Функция addParagraph
отвечает за обновление ассоциативного массива. В случае если персонаж еще не высказывался, код вставляет «пустой» объект типа PersonaData
, инициализированный значениями по умолчанию. Поскольку значение по умолчанию для типа uint
– ноль, а созданный в соответствии с правилами по умолчанию ассоциативный массив пуст, только что вставленный слот готов к приему осмысленной информации.
Наконец, для того чтобы напечатать краткую сводку по каждому персонажу, реализуем функцию printResults
:
void printResults(PersonaData[string] info)
{
foreach (persona, data; info)
{
writefln("%20s %6u %6u", persona, data.totalWordsSpoken, data.wordCount.length);
}
}
Готовы к тест-драйву? Тогда сохраните и запустите!
Queen 1104 500
Ros 738 338
For 55 45
Fort 74 61
Gentlemen 4 3
Other 105 75
Guil 349 176
Mar 423 231
Capt 92 66
Lord 70 49
Both 44 24
Oph 998 401
Ghost 683 350
All 20 17
Player 16 14
Laer 1507 606
Pol 2626 870
Priest 92 66
Hor 2129 763
King 4153 1251
Cor., Volt 11 11
Both [Mar 8 8
Osr 379 179
Mess 110 79
Sailor 42 36
Servant 11 10
Ambassador 41 34
Fran 64 47
Clown 665 298
Gent 101 77
Ham 11901 2822
Ber 220 135
Volt 150 112
Rey 80 37
Тут есть чем позабавиться. Как и ожидалось, наш дружок «Ham» с большим отрывом выигрывает у всех остальных, получив львиную долю слов. Довольно интересна роль Вольтиманда («Volt»): он немногословен, но при скромном количестве реплик виртуозно демонстрирует солидный словарный запас. Еще любопытнее в этом плане роль матроса («Sailor»), который вообще почти не повторяется. Также сравните красноречивую королеву («Queen») с Офелией («Oph»): королева произносит всего на 10% слов больше, чем Офелия, но ее лексикон богаче как минимум на 25%.
В выводе есть немного шума (например, "Both [Mar"
), который прилежный программист легко устранит и который вряд ли статистически влияет на то, что действительно представляет для нас интерес. Тем не менее исправление последних огрехов – поучительное (и рекомендуемое) упражнение.
1.6. Интерфейсы и классы
Объектно-ориентированные средства важны для больших проектов; так что, знакомя вас с ними на примере маленьких программ, я рискую выставить себя недоумком. Прибавьте к этому большое желание избежать заезженных примеров с животными и работниками, и сложится довольно неприятная картина. Да, забыл еще кое-что: в маленьких примерах обычно не видны проблемы создания полиморфных объектов, а это очень важно. Что делать бедному автору! К счастью, реальный мир снабдил меня полезным примером в виде относительно небольшой задачи, которая в то же время не имеет удовлетворительного процедурного решения. Обсуждаемый ниже код – это переработка небольшого полезного скрипта на языке awk, который вышел далеко за рамки задуманного. Мы вместе пройдем путь до объектно-ориентированного решения – одновременно компактного, полного и изящного.
Как насчет небольшой программы, собирающей статистику (в связи с этим назовем ее stats
)? Пускай ее интерфейс будет простым: имена статистических функций, используемых для вычислений, передаются в stats
как параметры командной строки, последовательность чисел для анализа поступает в стандартный поток ввода в виде списка (разделитель – пробел), статистические результаты печатаются один за другим по одному на строке. Вот пример работы программы:
$ echo 3 5 1.3 4 10 4.5 1 5 | stats Min Max Average
1
10
4.225
$ _
Написанный на скорую руку «непричесанный» скрипт без проблем решит эту задачу. Но в данном случае при увеличении количества статистических функций «лохматость» кода уничтожит преимущества от быстроты его создания. Так что поищем решение получше. Для начала остановимся на простейших статистических функциях: получение минимума, максимума и среднего арифметического. Нащупав легко расширяемый вариант кода, мы получим простор для неограниченной реализации более сложных статистических функций.
Простейший подход к решению задачи – в цикле пройтись по входным данным и вычислить всю необходимую статистику. Но выбрать такой путь – значит отказаться от идеи масштабируемости программы. Ведь всякий раз, когда нам потребуется добавить новую статистическую функцию, придется подвергать готовый код хирургическому вмешательству. Если мы хотим выполнять только те вычисления, о которых попросили в командной строке, необходимы серьезные изменения. В идеале мы должны заключить все статистические функции в последовательные куски кода. Таким образом, мы расширяем функциональность программы, просто добавляя новый код; это принцип открытости/закрытости во всей красе.
При таком подходе необходимо выяснить, что общего у всех (или хотя бы у большинства) статистических функций. Ведь наша цель – обращаться ко всем функциям из одной точки программы, причем унифицированно. Для начала отметим, что Min
и Max
отбирают аргументы из входной последовательности по одному, а результат будет готов, как только закончится ввод. Конечный результат – одно-единственное число. Также функция Average
по окончании чтения всех своих аргументов должна выполнить завершающий шаг (разделить накопившуюся сумму на число слагаемых). Кроме того, у каждого алгоритма есть собственное состояние. Если разные вычисления должны предоставлять одинаковый интерфейс для работы с ними и при этом «запоминать» свое состояние, разумный шаг – сделать их объектами и определить формальный интерфейс для управления всеми этими объектами и каждым из них в отдельности.
interface Stat
{
void accumulate(double x);
void postprocess();
double result();
}
Интерфейс определяет требуемое поведение в виде набора функций. Разумеется, тот, кто замахнется на реализацию интерфейса, должен будет определить все функции в том виде, в каком они заявлены. Раз уж мы заговорили о реализации, давайте посмотрим, как можно определить класс Min
, так чтобы он повиновался указаниям железной руки интерфейса Stat
.
class Min : Stat
{
private double min = double.max;
void accumulate(double x)
{
if (x < min)
{
min = x;
}
}
void postprocess() {} // Ничего не делать
double result()
{
return min;
}
}
Min
– это класс, пользовательский тип, привносящий в D преимущества ООП. С помощью синтаксиса class Min: Stat
класс Min
во всеуслышание объявляет, что он реализует интерфейс Stat
. И Min
действительно определяет все три функции, продиктованные волей Stat
, в точности с теми же аргументами и возвращаемыми типами (иначе компилятор не дал бы Min
просто так проскочить). Min
содержит всего лишь один закрытый элемент (тот, что помечен директивой private
) – переменную min
(наименьшее из прочитанных значений) и обновляет ее внутри функции accumulate
. Начальное значение Min
– самое большое число (которое можно представить типом double
), так что первое же число из входной последовательности заместит его.
Перед тем как определить другие статистические функции, реализуем основной алгоритм нашей программы stats
, предусматривающий чтение параметров командной строки, создание соответствующих объектов, производящих вычисления (таких как экземпляр класса Min
, когда через консоль передан аргумент Min
), и манипулирование ими с помощью интерфейса Stat
.
import std.stdio : writeln, stdin;
import std.exception : enforce;
import stats;
void main(string[] args)
{
Stat[] stats;
foreach (arg; args[1 .. $])
{
auto newStat = cast(Stat) Object.factory("stats." ~ arg);
enforce(newStat, "Invalid statistics function: " ~ arg);
stats ~= newStat;
}
for (double x; stdin.readf(" %s ", &x) == 1;)
{
foreach (s; stats)
{
s.accumulate(x);
}
}
foreach (s; stats)
{
s.postprocess();
writeln(s.result());
}
}
Эта небольшая программа творит чудеса. Для начала список параметров main
отличается от того, что мы видели до сих пор: на этот раз в функцию передается массив строк. Средства библиотеки времени исполнения D инициализируют этот массив параметрами, переданными компилятору из командной строки вместе с именем скрипта для запуска. Первый цикл инициализирует массив stats
исходя из значений массива args
. Учитывая, что в D (как и в других языках) первый аргумент – это имя самой программы, мы пропускаем первую позицию: нас интересует срез args[1 .. $]
. Теперь разберемся с командой
auto newStat = cast(Stat) Object.factory("stats." ~ arg);
Тут много непонятного, но, как говорят в ситкомах, я все могу объяснить. Во-первых, здесь знак ~
служит бинарным оператором, то есть осуществляет конкатенацию строк. Поэтому если аргумент командной строки – Min
, то результат конкатенации – строка "stats.Min"
, которая и будет передана функции Object.factory
. Object
– предок всех классов, создаваемых в программах на D. Он определяет статический метод factory
, который принимает строку, ищет соответствующий тип в небольшой базе данных (которая строится во время компиляции), магическим образом создает объект типа, указанного в переданной строке, и возвращает его. Если запрошенный класс отсутствует в упомянутой базе данных, Object.factory
возвращает null
. Чтобы этого не произошло, достаточно определить класс Min
где-нибудь в том же файле, что и вызов Object.factory
. Возможность создавать объект по имени его типа – это важное средство, востребованное во множестве полезных приложений. На самом деле, оно настолько важно, что является «сердцем» некоторых языков с динамической типизацией. Языки со статической типизацией (такие как D и Java) вынуждены полагаться на средства своих библиотек времени исполнения или предоставлять программисту самостоятельно изобретать механизмы регистрации и распознавания типов.
Почему stats.Min
, а не просто Min
? D серьезно относится к принципу модульности, поэтому в этом языке отсутствует глобальное пространство имен, где кто угодно может складировать что угодно. Каждый символ обитает в рамках модуля со своим именем, и по умолчанию имя модуля совпадает с именем его исходного файла без расширения. Таким образом, при условии что наш файл назван stats.d
, D полагает, что всякое имя, определенное в этом файле, принадлежит модулю stats
.
Осталась последняя загвоздка. Статический тип только что полученного объекта типа Min
на самом деле не Min
. Это звучит странно, но легко объясняется тем, что, вызвав Object.factory("что угодно")
, вы можете создать любой объект, поэтому возвращаемый тип должен быть неким общим знаменателем для всех возможных объектных типов – и это Object
. Для того чтобы получить ссылку, соответствующую типу объекта, который вы задумали, необходимо преобразовать объект, возвращенный Object.factory
, в объект типа State
. Эта операция называется приведением типов (type casting). В языке D выражение cast(T) expr
приводит выражение expr
к типу T
. Операции приведения типов, в которых участвуют классы или интерфейсы, всегда проверяются, поэтому код надежно защищен от дураков.
Оглянувшись назад, мы заметим, что львиная доля того, что делает скрипт, выполняется в первых пяти его строках. Эта самая сложная часть, которая полностью определяет весь остальной код. Второй цикл читает по одному числу за раз (об этом заботится функция readf
) и вызывает accumulate
для всех объектов, собирающих статистику. Функция readf
возвращает число объектов, успешно прочитанных согласно заданной строке формата. В нашем случае формат задан в виде строки " %s "
, что означает «один элемент, окруженный любым количеством пробелов». (Тип элемента определяется типом считанного элемента, в нашем случае x
принимает значение типа double
.) Последнее, что делает программа, – выводит результаты вычислений на печать.
1.6.1. Больше статистики. Наследование
Реализация Max
так же тривиальна, как и реализация Min
; за исключением небольших изменений в accumulate
, эти классы ничем не отличаются друг от друга9. Даже если новое задание до боли напоминает предыдущее, в голову должна приходить мысль «интересно», а не «о боже, какая скука». Рутинные задачи – это возможность для повторного использования, и «правильные» языки, способные лучше эксплуатировать различные преимущества подобия, по некоторой абстрактной шкале качества должны оцениваться выше. Нам придется выяснить, что именно общего у функций Min
и Max
(и, в идеале, у прочих статистических функций). Присмотревшись к ним, можно заметить, что обе принадлежат к разряду статистических функций, результат которых вычисляется шаг за шагом и может быть вычислен всего по одному числу. Назовем такую категорию статистических функций пошаговыми функциями.
class IncrementalStat : Stat
{
protected double _result;
abstract void accumulate(double x);
void postprocess() {}
double result()
{
return _result;
}
}
Абстрактный класс можно воспринимать как частичное обязательство: он реализует некоторые методы, но не все, так что «самостоятельно» такой код работать не может. Материализуется абстрактный класс тогда, когда от него наследуют и в теле потомков завершают реализацию. Класс IncrementalStat
обслуживает повторяющийся код классов, реализующих интерфейс Stat
, но оставляет реализацию метода accumulate
своим потомкам. Вот как выглядит новая версия класса Min
:
class Min : IncrementalStat
{
this()
{
_result = double.max;
}
override void accumulate(double x)
{
if (x < _result)
{
_result = x;
}
}
}
Кроме того, в классе Min
определен конструктор в виде специальной функции this()
, необходимый для корректной инициализации результата. Даже несмотря на добавление конструктора, полученный код значительно улучшил ситуацию относительно исходного положения дел, особенно с учетом того факта, что множество других статистических функций также соответствуют этому шаблону (например, сумма, дисперсия, среднее арифметическое, стандартное отклонение). Посмотрим на реализацию функции получения среднего арифметического, поскольку это прекрасный повод представить еще пару концепций:
class Average : IncrementalStat
{
private uint items = 0;
this()
{
_result = 0;
}
override void accumulate(double x)
{
_result += x;
++items;
}
override void postprocess()
{
if (items)
{
_result /= items;
}
}
}
Начнем с того, что в Average
вводится еще одно поле, items
, которое инициализируется нулем с помощью синтаксиса items = 0
(только для того, чтобы показать, как надо инициализировать переменные, но, как отмечалось выше, целые числа и так инициализируются нулем по умолчанию). Второе, что необходимо отметить: Average
определяет конструктор, который присваивает переменной _result
ноль. Так сделано, потому что, в отличие от минимума или максимума, при отсутствии аргументов среднее арифметическое считается равным нулю. И хотя может показаться, что инициализировать _result
значением NaN только для того, чтобы тут же записать в эту переменную ноль, – бессмысленное действие, уход от так называемого «мертвого присваивания» представляет собой легкую добычу для любого оптимизатора. Наконец, Average
переопределяет метод postprocess
, несмотря на то что в классе IncrementalStat
он уже определен. В языке D по умолчанию можно переопределить (унаследовать и заново определить) методы любого класса, но надо обязательно добавлять директиву override
, чтобы избежать всевозможных несчастных случаев (таких как неудача переопределения в связи с какой-нибудь опечаткой или изменением в базовом типе, либо переопределение чего-нибудь по ошибке). Если вы поставите перед методом класса ключевое слово final
, то запретите классам-потомкам переопределять эту функцию (что эффективно останавливает механизм динамического поиска методов по дереву классов).
1.7. Значения против ссылок
Проведем небольшой эксперимент:
import std.stdio;
struct MyStruct
{
int data;
}
class MyClass
{
int data;
}
void main()
{
// Играем с объектом типа MyStruct
MyStruct s1;
MyStruct s2 = s1;
++s2.data;
writeln(s1.data); // Печатает 0
// Играем с объектом типа MyClass
MyClass c1 = new MyClass;
MyClass c2 = c1;
++c2.data;
writeln(c1.data); // Печатает 1
}
Похоже, игры с объектом типа MyStruct
сильно отличаются от игр с объектом типа MyObject
. И в том и в другом случае мы создаем переменную, которую затем копируем в другую переменную, после чего изменяем копию (вспомните, что ++
– это унарный оператор, прибавляющий единицу к своему аргументу). Этот эксперимент показывает, что после копирования c1
и c2
ссылаются на одну и ту же область памяти с информацией, а s1
и s2
, напротив, «живут врозь».
Поведение MyStruct
свидетельствует о том, что этот объект подчиняется семантике значений: каждая переменная ссылается на собственное единственное значение, и присваивание одной переменной другой означает, что значение одной переменной реально копируется в значение другой переменной. Исходное значение, по образу и подобию которого изменяли вторую переменную, остается нетронутым, и обе переменные далее продолжают развиваться независимо друг от друга. Поведение MyClass
говорит, что объект этого типа подчиняется ссылочной семантике: значения создаются явно (в нашем случае с помощью вызова new MyClass
), и присваивание одного экземпляра класса другому означает лишь то, что обе переменные будут ссылаться на одно и то же значение в памяти.
Со значениями легко работать, о них просто рассуждать, и они позволяют производить эффективные вычисления с переменными небольшого размера. С другой стороны, нетривиальные программы сложно реализовать, не обладая средствами доступа к переменным без их копирования. Отсутствие возможности работать со ссылками препятствует, например, работе с типами, ссылающимися на себя же (списки или деревья), или структурами, ссылающимися друг на друга (такими как дочернее окно, знающее о своем родительском окне). Любой уважающий себя язык реализует работу со ссылками в том или ином виде; спорят только о необходимых умолчаниях. В C в общем случае переменные трактуются как значения, но если пользователь захочет, он будет работать со ссылками с помощью указателей. В дополнение к указателям, C++ определяет ссылочные типы. Любопытно, что чисто функциональные языки могут использовать ссылки или значения, когда сочтут нужным, потому что при написании кода между ними нет разницы. Ведь чисто функциональные языки запрещают изменения, поэтому невозможно сказать, когда они порождают копию значения, а когда просто используют ссылку на него – значения «заморожены», поэтому вы не сможете проверить, разделяется ли значение между несколькими переменными, изменив одну из них. Чисто объектно-ориентированные языки, напротив, традиционно поощряют изменения. Для них общий случай – ссылочная семантика. Некоторые такие языки достигают умопомрачительной гибкости, допуская, например, динамическое изменение системных переменных. Наконец, некоторые языки избрали гибридный подход, включая как типы-значения, так и ссылочные типы, с разной долей предпочтения тем или другим.
Язык D систематически реализует гибридный подход. Для определения ссылочных типов используйте классы. Для определения типов-значений или гибридных типов используйте структуры. В главах 6 и 7 соответственно описаны конструкторы этих типов, снабженные средствами для реализации соответствующего подхода. Например, структуры не поддерживают динамическое наследование и полиморфизм (такой как в рассмотренной нами программе stats
), поскольку такое поведение не согласуется с семантикой значений. Динамический полиморфизм объектов – это характеристика ссылочной семантики, и любая попытка смешать эти два подхода приведет лишь к жутким последствиям. (Например, классическая опасность, подстерегающая программистов на C++, – slicing, неожиданное лишение объекта его полиморфных способностей в результате невнимательного использования этого объекта в качестве значения. В языке D slicing невозможен.)
В завершение хочется сказать, что структуры – пожалуй, наиболее гибкое проектное решение. Определив структуру, вы можете вдохнуть в нее любую семантику. Вы можете сделать так, что значение будет копироваться постоянно, реализовать ленивое копирование, а-ля копирование при записи, или подсчитывать ссылки, или выбрать что-то среднее между этими способами. Вы даже можете определить ссылочную семантику, используя классы или указатели внутри своей структуры. С другой стороны, некоторые из этих альтернатив требуют подкованности в техническом плане; использование классов, напротив, подразумевает простоту и унифицированность.
1.8. Итоги
Эта глава – вводная, поэтому какие-то детали отдельных примеров и концепций остались за кадром или были рассмотрены вскользь. При этом опытный программист легко поймет, как можно завершить и усовершенствовать код примеров.
Надеюсь, что-то интересное нашлось для каждого. Кодера-практика, противника любых излишеств, могла порадовать чистота синтаксиса массивов и ассоциативных массивов. Уже эти две концепции сильно упрощают ежедневное кодирование и полезны как для малых, так и для больших проектов. Поклонник объектно-ориентированного программирования, хорошо знакомый с интерфейсами и классами, мог отметить хорошую масштабируемость языка для крупных проектов. А те, кто хочет писать на D короткие скрипты, увидели, как легко пишутся и запускаются сценарии, манипулирующие файлами.
Как водится, полный рассказ гораздо длиннее. И все же полезно время от времени вернуться к основам и удостовериться, что простые вещи остаются простыми.
🢀 Введение 1. Знакомство с языком D 2. Основные типы данных. Выражения 🢂
-
«Shebang» (от shell bang: shell – консоль, bang – восклицательный знак), или «shabang» (# – sharp) – обозначение пути к компилятору или интерпретатору в виде
#!/путь/к/программе
. – Прим. пер. ↩︎ -
В этой книге под «параметром» понимается значение, используемое внутри функции, а под «аргументом» – значение, передаваемое в функцию извне. ↩︎
-
.idup
– свойство любого массива, возвращающее неизменяемую (immutable) копию массива. Про неизменяемость будет рассказано позже, пока же следует знать, что ключ ассоциативного массива должен быть неизменяемым. – Прим. науч. ред. ↩︎ -
Если кто-то из ваших коллег прокачал самоуверенность до уровня Супермена, спросите его, что делает код
object.template fun<arg>()
, и вы увидите криптонит в действии. ↩︎ -
Усугубляет ситуацию с угловыми скобками то, что
<<
и>>
– тоже операторы. ↩︎ -
Этот файл содержит текст пьесы «Гамлет». – Прим. пер. ↩︎
-
Ham (англ.) – ветчина. – Прим. пер. ↩︎
-
NaN (Not a Number, нечисло) – хорошее начальное значение по умолчанию для чисел с плавающей запятой. К сожалению, для целых чисел не существует эквивалентного начального значения. ↩︎
-
Это не совсем так. Переменная-
аккумулятор
должна быть инициализирована значениемdouble.max
и соответственно переименована. – Прим. науч. ред. ↩︎