89 KiB
12. Перегрузка операторов
[🢀 11. Расширение масштаба] [12. Перегрузка операторов] [13. Параллельные вычисления 🢂]
- 12.1. Перегрузка операторов в D
- 12.2. Перегрузка унарных операторов
- 12.3. Перегрузка бинарных операторов
- 12.4. Перегрузка операторов сравнения
- 12.5. Перегрузка операторов присваивания
- 12.6. Перегрузка операторов индексации
- 12.7. Перегрузка операторов среза
- 12.8. Оператор $
- 12.9. Перегрузка foreach
- 12.10. Определение перегруженных операторов в классах
- 12.11. Кое-что из другой оперы: opDispatch
- 12.12. Итоги и справочник
Мы, программисты, не очень любим слишком уж отделять встроенные типы от пользовательских. Магические свойства встроенных типов мешают открытости и расширяемости любого языка, поскольку при этом пользовательские типы обречены оставаться второсортными. Тем не менее у проектировщиков языка есть все законные основания относиться к встроенным типам с большим почтением. Одно из таких оснований: более настраиваемый язык сложнее выучить, а также сложнее выполнять его синтаксический анализ как человеку, так и машине. Каждый язык по-своему определяет приемлемое соотношение между встроенным и настраиваемым, что для некоторых языков означает впадение в одну из этих двух крайностей.
Язык D подходит к этому вопросу прагматично: он не умаляет важность настраиваемости, но при этом осознает практичность встроенных типов – D использует преимущества встроенных типов ровно тремя путями:
-
Синтаксис названий типов. Массивы и ассоциативные массивы используются повсеместно, и, согласитесь, синтаксис
int[]
иint[string]
гораздо нагляднее, чемArray!int
иAssotiativeArray!(string, int)
. В пользовательском коде нельзя определять новые формы записи названий типов, напримерint[[]]
. -
Литералы. Числовые и строковые литералы, как и литералы массивов и ассоциативных массивов, – «особые», и их набор нельзя расширить. «Сборные» объекты-структуры, такие как
Point(5, 3)
, – тоже литералы, но тип не может определить новый синтаксис литерала, например(3, 5)pt
. -
Семантика. Зная семантику определенных типов и их операций, компилятор оптимизирует код. Например, встретив выражение
"Hello" ~ ", " ~ "world"
, компилятор не откладывает конкатенацию до времени исполнения: он знает, что делает операция конкатенации строк, и склеивает строки уже во время компиляции. Аналогично компилятор упрощает и оптимизирует арифметические выражения, используя знание арифметики.
Некоторые языки добавляют к этому списку операторы. Они делают операторы особенными; чтобы выполнить какую-либо операцию применительно к пользовательским типам, приходится использовать стандартные средства языка, такие как вызов функций или макросов. Несмотря на то что это совершено законное решение, оно на самом делесоздает проблемы при большом объеме кода, ориентированного на арифметические вычисления. Многие программы, ориентированные на вычисления, определяют собственные типы с алгебрами1 (числа неограниченной точности, специализированные числа с плавающей запятой, кватернионы, октавы, матрицы всевозможных форм, тензоры, ... очевидно, что язык не может сделать встроенными их все). При использовании таких типов выразительность кода резко снижается. По сравнению с эквивалентным функциональным синтаксисом, операторы обычно требуют меньше места и круглых скобок, а получаемый с их участием код зачастую легок для восприятия. Рассмотрим для примера вычисление среднего гармонического трех ненулевых чисел x
, y
и z
. Выражение на основе операторов очень близко к математическому определению:
m = 3 / (1/x + 1/y + 1/z);
В языке, требующем использовать вызовы функций вместо операторов, соответствующее выражение выглядит вовсе не так хорошо:
m = divide(3, add(add(divide(1, x), divide(1, y)), divide(1, z)));
Читать и изменять код с множеством арифметических функций гораздо сложнее, чем код с обычной записью операторов.
Язык D очень привлекателен для численного программирования. Он предоставляет надежную арифметику с плавающей запятой и превосходную библиотеку трансцендентных функций, которые иногда возвращают результат с большей точностью, чем «родные» системные библиотеки, и предлагает широкие возможности для моделирования. Мощное средство перегрузки операторов добавляет ему привлекательности. Перегрузка операторов позволяет вам определять собственные числовые типы (такие как числа с фиксированной запятой, десятичные числа для финансовых и бухгалтерских программ, неограниченные целые числа или действительные числа неограниченной точности), максимально близкие к встроенным числовым типам. Перегрузка операторов также позволяет определять типы с «числоподобными» алгебрами, такие как векторы и матрицы. Давайте посмотрим, как можно определять типы с помощью этого средства.
12.1. Перегрузка операторов в D
Подход D к перегрузке операторов прост: если хотя бы один участник выражения с оператором имеет пользовательский тип, компилятор заменяет это выражение на обычный вызов метода с регламентированным именем. Затем применяются обычные правила языка. Таким образом, перегруженные операторы – лишь синтаксический сахар для вызова методов, а значит, нет нужды вникать в причуды самостоятельного средства языка. Например, если a
относится к некоторому определенному пользователем типу, выражение a + 5
заменяется на a.opBinary!"+"(5)
. К методу opBinary
применяются обычные правила и проверки, и тип a
должен определять этот метод, если желает обеспечить поддержку перегрузки операторов.
Замена (точнее, снижение, т. к. этот процесс преобразует конструкции более высокого уровня в низкоуровневый код) – очень эффективный инструмент, позволяющий реализовать новые средства на основе имеющихся, и D обычно его применяет. Мы уже видели снижение в действии применительно к конструкции scope
(см. раздел 3.13). По сути, scope
– лишь синтаксический сахар, которым засыпаны особым образом сцепленные конструкции try
, но вам точно не придет в голову самостоятельно писать сниженный код, так как scope
значительно поднимает уровень высказываний. Перегрузка операторов действует в том же духе, определяя все вызовы операторов через замену на вызовы функций, тем самым придавая мощь обычным определениям функций и используя их как средство достижения своей цели. Без лишних слов посмотрим, как компилятор осуществляет снижение операторов разных категорий.
12.2. Перегрузка унарных операторов
В случае унарных операторов +
(плюс), -
(отрицание), ~
(поразрядное отрицание), *
(разыменование указателя), ++
(увеличение на единицу) и --
(уменьшение на единицу) компилятор заменяет выражение
‹оп› a
на
a.opUnary!"‹оп›"()
для всех значений пользовательских типов. В качестве замены выступает вызов метода opUnary с одним аргументом времени компиляции "‹оп›"
и без каких-либо аргументов времени исполнения. Например ++a
перезаписывается как a.opUnary! "++" ()
.
Чтобы перегрузить один или несколько унарных операторов для типа T
, определите метод T.opUnary
так:
struct T
{
SomeType opUnary(string op)();
}
В таком виде, как он здесь определен, этот метод будет вызываться для всех унарных операторов. А если вы хотите для некоторых операторов определить отдельные методы, вам помогут ограничения сигнатуры (см. раздел 5.4). Рассмотрим определение типа CheckedInt
, который служит оберткой базовых числовых типов и гарантирует, что значения, получаемые в результате применения операций к оборачиваемым типам, не выйдут за границы, установленные для этих типов. Тип CheckedInt
должен быть параметризирован оборачиваемым типом (например, CheckedInt!int
, CheckedInt!long
и т. д.). Вот неполное определение CheckedInt
с операторами префиксного увеличения и уменьшения на единицу:
struct CheckedInt(N) if (isIntegral!N)
{
private N value;
ref CheckedInt opUnary(string op)() if (op == "++")
{
enforce(value != value.max);
++value;
return this;
}
ref CheckedInt opUnary(string op)() if (op == "--")
{
enforce(value != value.min);
--value;
return this;
}
...
}
12.2.1. Объединение определений операторов с помощью выражения mixin
Есть очень мощная техника, позволяющая определить не один, а сразу группу операторов. Например, все унарные операторы +
, -
и ~
для типа CheckedInt
делают одно и то же – всего лишь проталкивают соответствующую операцию по направлению к value
, внутреннему элементу CheckedInt
. Хоть эти операторы и неидентичны, они несомненно придерживаются одного и того же шаблона поведения. Можно просто определить специальный метод для каждого оператора, но это вылилось бы в неинтересное дублирование шаблонного кода. Лучший подход – использовать работающие со строками выражения mixin
(см. раздел 2.3.4.2), позволяющие напрямую монтировать операции из имен операндов и идентификаторов операторов. Следующий код реализует все унарные операции, применимые к CheckedInt
.2
struct CheckedInt(N) if (isIntegral!N)
{
private N value;
this(N value)
{
this.value = value;
}
CheckedInt opUnary(string op)()
if (op == "+" || op == "-" || op == "~")
{
return CheckedInt(mixin(op ~ "value"));
}
ref CheckedInt opUnary(string op)() if (op == "++" || op == "--")
{
enum limit = op == "++" ? N.max : N.min;
enforce(value != limit);
mixin(op ~ "value;");
return this;
}
...
}
Это уже заметная экономия на длине кода, и она лишь возрастет, как только мы доберемся до бинарных операторов и выражений индексации. Главное действующее лицо такого подхода – выражение mixin
, которое позволяет вам взять строку и попросить компилятор скомпилировать ее. Строка получается буквальным соединением операнда и оператора вручную. Способность трансформироваться в код, по счастливой случайности присущая строке op, фактически воплощает в жизнь эту идиому; на самом деле, весь этот механизм перегрузки проектировался с прицелом на mixin
. Раньше D использовал отдельное имя для каждого оператора (opAdd
, opSub
, opMul
, ...), что требовало механического запоминания соответствия имен операторам и написания группы функций с практически идентичными телами.
12.2.2. Постфиксный вариант операторов увеличения и уменьшения на единицу
Постфиксные операторы увеличения (a++
) и уменьшения (a--
) на единицу – необычные: они выглядят так же, как и их префиксные «коллеги», так что различать их по идентификатору не получится. Дополнительная проблема в том, что вызывающему коду, которому нужен результат применения оператора, также должно быть доступно и старое значение сущности, увеличенной на единицу. Наконец, постфиксные и префиксные варианты операторов увеличения и уменьшения на единицу должны согласовываться друг с другом.
Постфиксное увеличение и уменьшение на единицу можно целиком сгенерировать из префиксного увеличения и уменьшения на единицу соответственно – нужно лишь немного шаблонного кода. Но вместо того чтобы заставлять вас писать этот шаблонный код, D делает это сам. Замена a++
выполняется так (постфиксное уменьшение на единицу обрабатывается аналогично):
- если результат
a++
не используется, осуществляется замена на++a
, что затем перезаписывается наa.opUnary! "++"()
; - если результат
a++
используется (например,arr[a++]
), заменой послужит выражение (тяжкий вздох)((ref x) {auto t=x; ++x; return t;})(a)
.
В первом случае попросту обыгрывается тот факт, что постфиксный оператор увеличения на единицу без применения результата делает то же самое, что и префиксный вариант соответствующего оператора. Во втором случае определяется лямбда-функция (см. раздел 5.6), выполняющая нужный шаблонный код: она создает новую копию входных данных, прибавляет единицу к входным данным и возвращает созданную ранее копию. Лямбда-функция применяется непосредственно к увеличиваемому значению.
12.2.3. Перегрузка оператора cast
Явное приведение типов осуществляется с помощью унарного оператора, применение которого выглядит как cast(T) a
. Он немного отличается от всех остальных операторов тем, что использует тип в качестве параметра, а потому для него выделена особая форма снижения. Для любого значения
пользовательского типа и некоторого другого типа T приведение
cast(T) значение
переписывается как
значение.opCast!T()
Реализация метода opCast
, разумеется, должна возвращать значение типа T
– деталь, на которой настаивает компилятор. Несмотря на то что перегрузка функций по значению аргумента не обеспечивается на уровне средства языка, множественные определения opCast
можно реализовать с помощью шаблонов с ограничениями сигнатуры. Например, методы приведения к типам string
и int
для некоторого типа T
можно определить так:
struct T
{
string opCast(T)() if (is(T == string))
{
...
}
int opCast(T)() if (is(T == int))
{
...
}
}
Можно определить приведение к целому классу типов. «Надстроим» пример с CheckedInt
, определив приведение ко всем встроенным числовым типам. Загвоздка в том, что некоторые из них могут обладать более ограничивающим диапазоном значений, а нам бы хотелось гарантировать, что преобразование не будет сопровождаться никакими потерями информации. Дополнительная задача: хотелось бы избежать проверок там, где они не требуются (например, нет нужды проверять границы при преобразовании из CheckedInt!int
в long
). Поскольку информация о границах доступна во время компиляции, вставка проверок лишь там, где это необходимо, задается с помощью конструкции static if
(см. раздел 3.4):
struct CheckedInt(N) if (isIntegral!N)
{
private N value;
// Преобразования ко всевозможным целым типам
N1 opCast(N1)() if (isIntegral!N1)
{
static if (N.min < N1.min)
{
enforce(N1.min <= value);
}
static if (N.max > N1.max)
{
enforce(N1.max >= value);
}
// Теперь можно без опаски делать "сырые" преобразования
return cast(N1) value;
}
...
}
12.2.4. Перегрузка тернарной условной операции и ветвления
Встретив значение пользовательского типа, компилятор заменяет код вида
a ? ‹выражение1› : ‹выражение2›
на
cast(bool) a ? ‹выражение1› : ‹выражение2›
Сходным образом компилятор переписывает проверку внутри конструкции if
с
if (a) ‹инструкция› // С блоком else или без него
на
if (cast(bool) a) ‹инструкция›
Оператор отрицания !
также переписывается в виде отрицания выражения с cast
.
Чтобы обеспечить выполнение таких проверок, определите метод приведения к типу bool
, как это сделано здесь:
struct MyArray(T)
{
private T[] data;
bool opCast(T)() if (is(T == bool))
{
return !data.empty;
}
...
}
12.3. Перегрузка бинарных операторов
В случае бинарных операторов +
(сложение), -
(вычитание), *
(умножение), /
(деление), %
(получение остатка от деления), &
(поразрядное И), |
(поразрядное ИЛИ), <<
(сдвиг влево), >>
(сдвиг вправо), ~
(конкатенация) и in
(проверка на принадлежность множеству) выражение
a ‹оп› b
где по крайней мере один из операндов a
и b
имеет пользовательский тип, переписывается в виде
a.opBinary!"‹оп›"(b)
или
b.opBinaryRight!"‹оп›"(a)
Если разрешение имен и проверки перегрузки успешны лишь для одного из этих вызовов, он выбирается для замены. Если оба вызова допустимы, возникает ошибка в связи с двусмысленностью. Если же не подходит ни один из вызовов, очевидно, что перед нами ошибка «идентификатор не найден».
Продолжим наш пример с CheckedInt
из раздела 12.2. Определим для этого типа все бинарные операторы:
struct CheckedInt(N) if (isIntegral!N)
{
private N value;
// Сложение
CheckedInt opBinary(string op)(CheckedInt rhs) if (op == "+")
{
auto result = value + rhs.value;
enforce(rhs.value >= 0 ? result >= value : result < value);
return CheckedInt(result);
}
// Вычитание
CheckedInt opBinary(string op)(CheckedInt rhs) if (op == "-")
{
auto result = value - rhs.value;
enforce(rhs.value >= 0 ? result <= value : result > value);
return CheckedInt(result);
}
// Умножение
CheckedInt opBinary(string op)(CheckedInt rhs) if (op == "*")
{
auto result = value * rhs.value;
enforce(value && result / value == rhs.value || rhs.value && result / rhs.value == value || result == 0);
return CheckedInt(result);
}
// Деление и остаток от деления
CheckedInt opBinary(string op)(CheckedInt rhs)
if (op == "/" || op == "%")
{
enforce(rhs.value != 0);
return CheckedInt(mixin("value" ~ op ~ "rhs.value"));
}
// Сдвиг
CheckedInt opBinary(string op)(CheckedInt rhs)
if (op == "<<" || op == ">>" || op == ">>>")
{
enforce(rhs.value >= 0 && rhs.value <= N.sizeof * 8);
return CheckedInt(mixin("value" ~ op ~ "rhs.value"));
}
// Поразрядные операции (без проверок, переполнение невозможно)
CheckedInt opBinary(string op)(CheckedInt rhs)
if (op == "&" || op == "|" || op == "^")
{
return CheckedInt(mixin("value" ~ op ~ "rhs.value"));
}
...
}
(Многие из этих проверок можно осуществить дешевле – с помощью бита переполнения, имеющегося у процессоров Intel, который при выполнении арифметических операций или устанавливается, или сбрасывается. Но это аппаратно-зависимый способ.) Данный код определяет по одному отдельному оператору для каждой уникальной проверки. Если у двух и более операторов одинаковый код, они всегда объединяются в один метод. Это сделано в случае операторов /
и %
(поскольку оба они выполняют одну и ту же проверку), всех операторов сдвига и трех поразрядных операторов, не требующих проверок. Здесь снова применен подход, смысл которого – собрать операцию в виде строки, а потом с помощью mixin
скомпилировать ее в выражение.
12.3.1. Перегрузка операторов в квадрате
Если перегрузка операторов означает разрешение типам определять собственную реализацию операторов, то перегрузка перегрузки операторов, то есть перегрузка операторов в квадрате, означает разрешение типам определять некоторое количество перегруженных версий перегруженных операторов.
Рассмотрим выражение a * 5
, где операнд a
имеет тип CheckedInt!int
. Оно не скомпилируется, поскольку до сих пор тип CheckedInt
определял метод opBinary
с правым операндом типа CheckedInt
. Так что для выполнения вычисления в клиентском коде нужно писать a * CheckedInt!int(5)
, что довольно неприятно.
Верный способ решить эту проблему – определить еще одну или несколько дополнительных реализаций метода opBinary
для типа CheckedInt!N
, так чтобы на этот раз тип N
ожидался справа от оператора. Может показаться, что определение нового метода opBinary
потребует изрядного объема монотонной работы, но на самом деле достаточно добавить всего одну строчку:
struct CheckedInt(N) if (isIntegral!N)
{
... // То же, что и раньше
// Операции с "сырыми" числами
CheckedInt opBinary(string op)(N rhs)
{
return opBinary!op(CheckedInt(rhs));
}
}
Красота этого подхода в простоте: оператор преобразуется в обычный идентификатор, который затем можно передать другой реализации оператора.
12.3.2. Коммутативность
Присутствие opBinaryRight
требуется в тех случаях, когда тип, определяющий оператор, является правым операндом, например, как в выражении 5 * a
. В этом случае тип операнда a имеет шанс «поймать» оператор, лишь определив метод opBinaryRight!"*"(int)
. Здесь есть некоторая избыточность – если, скажем, нужно организовать поддержку операций, для которых не важно, с какой стороны подставлен целочисленный операнд (например, все равно, 5 * a
или a * 5
), вам потребуется определить как opBinary!"*"(int)
, так и opBinaryRight!"*"(int)
, а это расточительство, т. к. умножение коммутативно. При этом, предоставив языку принимать решение о коммутативности, можно столкнуться с излишними ограничениями: свойство коммутативности зависит от алгебры; например, умножение матриц некоммутативно. Поэтому компилятор оставляет за пользователем право определить отдельные операторы для правого и левого операндов, отказываясь брать на себя какую-либо ответственность за коммутативность операторов.
Чтобы организовать поддержку a ‹оп› b
и b ‹оп› a
, когда один операндлегко преобразуется к типу другого операнда, достаточно добавить одну строку:
struct CheckedInt(N) if (isIntegral!N)
{
... // То же, что и раньше
// Реализовать правосторонние операторы
CheckedInt opBinaryRight(string op)(N lhs)
{
return CheckedInt(lhs).opBinary!op(this);
}
}
Все, что нужно, – получить соответствующее выражение с CheckedInt
слева. А затем вступают в права уже определенные операторы.
Но иногда для преобразования требуется ряд дополнительных шагов, без которых можно было бы обойтись. Например, представьте выражение 5 * c
, где c
имеет тип Complex!double
. Применив приведенное выше решение, мы бы протолкнули умножение в выражение Complex!double(5) * c
, при вычислении которого пришлось бы преобразовать 5
в комплексное число с нулевой мнимой частью, а затем зачем-то умножать комплексные числа, когда достаточно было бы всего лишь двух умножений действительных чисел. Результат, конечно, будет верным, но для его получения пришлось бы гораздо больше попотеть. В таких случаях лучше всего разделить правосторонние операции на две группы – коммутативные и некоммутативные операции – и обрабатывать их по отдельности. Коммутативные операции можно обрабатывать просто с помощью перестановки аргументов. Некоммутативные операции можно реализовывать так, чтобы каждый случай обрабатывался отдельно – или каждый раз заново, или извлекая пользу из уже реализованных примитивов.
struct Complex(N) if (isFloatingPoint!N)
{
N re, im;
// Реализовать коммутативные операторы
Complex opBinaryRight(string op)(N lhs)
if (op == "+" || op == "*")
{
// Предполагается, что левосторонний оператор уже реализован
return opBinary!op(lhs);
}
// Реализовать некоммутативные операторы вручную
Complex opBinaryRight(string op)(N lhs) if (op == "-")
{
return Complex(lhs - re, -im);
}
Complex opBinaryRight(string op)(N lhs) if (op == "/")
{
auto norm2 = re * re + im * im;
enforce(norm2 != 0);
auto t = lhs / norm2;
return Complex(re * t, -im * t);
}
}
Для других типов можно выбрать другой способ группировки некоторых групп операций, в таком случае могут пригодиться уже описанные техники наложения ограничений на op
.
12.4. Перегрузка операторов сравнения
В случае операторов сравнения (равенство и упорядочивание) D следует той же схеме, с которой мы познакомились, обсуждая классы (см. разделы 6.8.3 и 6.8.4). Может показаться, что так сложилось исторически, но есть и веские причины обрабатывать сравнения не в общем методе opBinary
, а иным способом. Во-первых, между операторами ==
и !=
есть тесные взаимоотношения, как и у всей четверки <
, <=
, >
и >=
. Эти взаимоотношения подразумевают, что лучше использовать две отдельные функции со специфическими именами, чем код, определяющий каждый оператор отдельно в зависимости от идентификаторов. Кроме того, многие типы, скорее всего, будут определять лишь равенство и упорядочивание, а не все возможные операторы. С учетом этого факта для определения сравнений язык предоставляет простое и компактное средство, не заставляя использовать мощный инструмент opBinary
.
Замена a == b
, где хотя бы один из операндов a
и b
имеет пользовательский тип, производится по следующему алгоритму:
- Если как
a
, так иb
– экземпляры классов, заменой служит выражениеobject.opEquals(a, b)
. Как говорилось в разделе 6.8.3, сравнения между классами подчиняются небольшому протоколу, реализованному в модулеobject
из ядра стандартной библиотеки. - Иначе если при разрешении имен
a.opEquals(b)
иb.opEquals(a)
выясняется, что это обращения к одной и той же функции, заменой служит выражениеa.opEquals(b)
. Такое может произойти, еслиa
иb
имеют один и тот же тип, с одинаковыми или разными квалификаторами. - Иначе компилируется только одно из выражений
a.opEquals(b)
иb.opEquals(a)
, которое и становится заменой.
Выражения с каким-либо из четырех операторов упорядочивающего сравнения <
, <=
, >
и >=
переписываются по следующему алгоритму:
- Если при разрешении имен
a.opCmp(b)
иb.opCmp(a)
выясняется, что это обращения к одной и той же функции, заменой служит выражениеa.opCmp(b) ‹оп› 0
. - Иначе компилируется только одно из выражений
a.opCmp(b)
иb.opCmp(a)
. Если первое, то заменой служит выражениеa.opCmp(b) ‹оп› 0
. Иначе заменой служит выражение0 ‹оп› b.opCmp(a)
.
Здесь также стоит упомянуть о разумном обосновании одновременного существования как opEquals
, так и opCmp
. На первый взгляд может показаться, что достаточно и одного метода opCmp
(равенство было бы реализовано как a.opCmp(b) == 0
). Но хотя большинство типов могут определить равенство, многим типам нелегко реализовать отношение неравенства. Например, матрицы и комплексные числа определяют равенство, однако канонического определения отношения порядка им недостает.
12.5. Перегрузка операторов присваивания
К операторам присваивания относится не только простое присваивание вида a = b
, но и присваивания с выполнением «на ходу» бинарных операторов, например a += b
или a *= b
. В разделе 7.1.5.1 уже было показано, что выражение
a = b
``
переписывается как
```d
a.opAssign(b)
При выполнении бинарных операторов «на месте» заменой
a ‹оп›= b
послужит
a.opOpAssign!"‹оп›"(b)
Замена позволяет типу операнда a реализовать операции «на месте» по описанным выше техникам. Рассмотрим пример реализации оператора +=
для типа CheckedInt
:
struct CheckedInt(N) if (isIntegral!N)
{
private N value;
ref CheckedInt opOpAssign(string op)(CheckedInt rhs)
if (op == "+")
{
auto result = value + rhs.value;
enforce(rhs.value >= 0 ? result >= value : result <= value);
value = result;
return this;
}
...
}
В этом определении примечательны три детали. Во-первых, метод opAssign
возвращает ссылку на текущий объект, благодаря чему поведение CheckedInt
становится сравнимым с поведением встроенных типов. Во-вторых, истинное вычисление делается не «на месте», а напротив, «в сторонке». Собственно, состояние объекта изменяется лишь после удачного выполнения проверки. В противном случае, если при вычислении выражения с enforce
будет порождено исключение, мы рискуем испортить текущий объект. В-третьих, тело оператора фактически дублирует тело метода opBinary!"+"
, рассмотренного выше. Воспользуемся последним наблюдением, чтобы задействовать имеющиеся реализации всех бинарных операторов в определении операторов присваивания, одновременно выполняющих и бинарные операции. Вот новое определение:
struct CheckedInt(N) if (isIntegral!N)
{
... // То же, что и раньше
// Определить все операторы присваивания
ref CheckedInt opOpAssign(string op)(CheckedInt rhs)
{
value = opBinary!op(rhs).value;
return this;
}
}
Можно было бы поступить и по-другому: определять бинарные операторы через операторы присваивания, определяемые с нуля. К этому выбору можно прийти из соображений эффективности; для многих типов изменение объекта «на месте» требует меньше памяти и выполняется быстрее, чем создание нового объекта.
12.6. Перегрузка операторов индексации
Язык D позволяет определять полностью абстрактный массив – массив, который поддерживает все операции, обычно ожидаемые от массива, но никогда не предоставляет адреса своих элементов клиентскому коду. Перегрузка операторов индексации – необходимое условие реализации этого средства. Чтобы обеспечить должный доступ по индексу, компилятор различает чтение и запись элементов. В последнем случае элемент массива находится слева от оператора присваивания, простой ли это оператор =
или выполняющийся «на месте» бинарный оператор, такой как +=
.
Если никакого присваивания не выполняется, компилятор заменяет выражение
a[b1, b2, ..., bk]
на
a.opIndex(b1, b2, ..., bk)
для любого числа аргументов k. Сколько принимается аргументов, какими должны быть их типы и каков тип результата, решает реализация метода opIndex
.
Если результат применения оператора индексации участвует в присваивании слева, при снижении выражение
a[b1, b2, ..., bk] = c
преобразуется в
a.opIndexAssign(c, b1, b2, ..., bk)
Если к результату выражения с индексом применятся оператор увеличения или уменьшения на единицу, выражение
‹оп› a[b1, b2, ..., bk]
где в качестве ‹оп›
выступает или ++
, --
, или унарный -
, +
, ~
, *
, переписывается как
a.opIndexUnary!"‹оп›"(b1, b2, ..., bk)
Постфиксные увеличение и уменьшение на единицу генерируются автоматически из соответствующих префиксных вариантов, как описано в разделе 12.2.2.
Наконец, если полученный по индексу элемент изменяется «на месте», при снижении выражение
a[b1, b2, ..., bk] ‹оп›= c
преобразуется в
a.opIndexOpAssign!"‹оп›"(c, b1, b2, ..., bk)
Эти замены позволяют типу операнда a
полностью определить, каким образом выполняется доступ к элементам, получаемым по индексу, и как они обрабатываются. Для чего индексируемому типу брать на себя ответственность за операторы присваивания? Казалось бы, более удачное решение – просто предоставить методу opIndex
возвращать ссылку на хранимый элемент, например:
struct MyArray(T)
{
ref T opIndex(uint i) { ... }
}
Тогда какие бы операции присваивания и изменения-с-присваиванием ни поддерживал тип T
, они будут выполняться правильно. Предположим, дан массив типа MyArray!int
с именем a
, тогда при вычислении выражения a[7] *= 2
сначала с помощью метода opIndex
будет получено значение типа ref int
, а затем эта ссылка будет использована для умножения «на месте» на 2. На самом деле, именно так и работают встроенные массивы.
К сожалению, это решение не без изъяна. Одна из проблем заключается в том, что немалое число коллекций, построенных по принципу массива, не пожелают предоставить доступ к своим элементам по ссылке. Они, насколько это возможно, инкапсулируют расположение своих элементов, обернув их в абстракцию. Преимущества такого подхода – обычные плюсы сокрытия информации: у контейнера появляется свобода выбора наилучшей стратегии хранения его элементов. Простой пример – определение контейнера, содержащего объекты типа bool
. Если бы контейнер был обязан предоставлять доступ к ref bool
, ему пришлось бы хранить каждое значение по отдельному адресу. Если же контейнер вправе скрывать адреса, то он может сохранить восемь логических значений в одном байте.
Другой пример: для некоторых контейнеров доступ к данным неотделим от их изменения. Представим разреженный массив. Разреженные массивы могут фиктивно содержать миллионы элементов, из которых лишь горстка ненулевые, что позволяет разреженным массивам применять стратегии хранения, экономичные в плане занимаемого места. А теперь рассмотрим следующий код:
SparseArray!double a;
...
a[8] += 2;
Что должен предпринять массив, зависит как от его текущего содержимого, так и от новых данных: если ячейка a[8]
не была ранее заполнена, то создать ячейку со значением 2
; если ячейка была заполнена значением -2
, удалить эту ячейку, поскольку ее новым значением будет ноль, а такие значения явно не сохраняются; если же ячейка содержала нечто помимо -2
, выполнить сложение и записать полученное значение обратно в ячейку. Реализовать эти действия или хотя бы большинство из них невозможно, если требуется, чтобы метод opIndex
возвращал ссылку.
12.7. Перегрузка операторов среза
Массивы D предоставляют операторы среза a[]
и a[b1 .. b2]
(см. раздел 4.1.3). Оба эти оператора могут быть перегружены пользовательскими типами. Компилятор выполняет снижение, примерно как в случае оператора индексации.
Если нет никакого присваивания, компилятор переписывает a[]
в виде a.opSlice()
, а a[b1 .. b2]
– в виде a.opSlice(b1, b2)
.
Снижения для операций со срезами делаются по образцу снижений для соответствующих операций, определенных для массивов. Во всех именах методов Index заменяется на Slice
: ‹оп› a[]
снижается до a.opSliceUnary!"‹оп›"()
, ‹оп› a[b1 .. b2]
превращается в a.opSliceUnary!"‹оп›"(b1, b2)
, a[] = c
– в a.opSliceAssign(c)
, a[b1 .. b2] = c
– в a.opSliceAssign(c, b1, b2)
, a[] ‹оп›= c
– в a.opSliceOpAssign!"‹оп›"(c)
, и наконец, a[b1 .. b2] ‹оп›= c
– в a.opSliceOpAssign!"‹оп›"(c, b1, b2)
.
12.8. Оператор $
В случае встроенных массивов язык D позволяет внутри индексных выражений и среза обозначить длину массива идентификатором $
. Например, выражение a[0 .. $ - 1
] выбирает все элементы встроенного массива a кроме последнего.
Хотя этот оператор с виду довольно скромен, оказалось, что $
сильно повышает и без того хорошее настроение программиста на D. С другой стороны, если бы оператор $ был «волшебным» и не допускал перегрузку, это бы неизменно раздражало, еще раз подтверждая, что встроенные типы должны лишь изредка обладать возможностями, недоступными пользовательским типам.
Для пользовательских типов оператор $
может быть перегружен так:
- для выражения
a[‹выраж›]
, гдеa
имеет пользовательский тип: если в‹выраж›
встречается$
, оно переписывается какa.opDollar()
. Замена одна и та же независимо от присваивания этого выражения; - для выражения
a[‹выраж1›, ..., ‹выражk›]
: если в‹выражi›
встречается$
, оно переписывается какa.opDollar!(i)()
; - для выражения
a[‹выраж1› .. ‹выраж2›]
: если в‹выраж1›
или‹выраж2›
встречается$
, оно переписывается какa.opDollar()
.
Если a
– результат некоторого выражения, это выражение вычисляется только один раз.
12.9. Перегрузка foreach
Пользовательские типы могут существенным образом определять, как цикл просмотра будет с ними работать. Это огромное благо для типов, моделирующих коллекции, диапазоны, потоки и другие сущности, элементы которых можно перебирать. Более того, дела обстоят еще лучше: есть целых два независимых способа организовать перегрузку, со своими плюсами и минусами.
12.9.1. foreach с примитивами перебора
Первый способ определить, как цикл foreach
должен работать с вашим типом (структурой или классом), заключается в определении трех примитивов перебора: свойства empty
типа bool
, сообщающего, остались ли еще непросмотренные элементы, свойства front
, возвращающего текущий просматриваемый элемент, и метода popFront()
3, осуществляющего переход к следующему элементу. Вот типичная реализация этих трех примитивов:
struct SimpleList(T)
{
private:
struct Node
{
T _payload;
Node * _next;
}
Node * _root;
public:
@property bool empty() { return !_root; }
@property ref T front() { return _root._payload; }
void popFront() { _root = _root._next; }
...
}
Имея такое определение, организовать перебор элементов списка проще простого:
void process(SimpleList!int lst)
{
foreach (value; lst)
{
... // Использовать значение типа int
}
}
Компилятор заменяет управляющий код foreach
соответствующим циклом for
, более неповоротливым, но мелкоструктурным аналогом, который и использует три рассмотренные примитива:
void process(SimpleList!int lst)
{
for (auto __c = lst; !__c.empty; __c.popFront())
{
auto value = __c.front;
... // Использовать значение типа int
}
}
Если вы снабдите аргумент value
ключевым словом ref
, компилятор заменит все обращения к value
в теле цикла обращениями к свойству __c.front
. Таким образом, вы получаете возможность изменять элементы списка напрямую. Конечно, и само ваше свойство front
должно возвращать ссылку, иначе попытки использовать его как l-значение породят ошибки.
Последнее, но не менее важное: если просматриваемый объект предоставляет оператор среза без аргументов lst[]
, __c
инициализируется выражением lst[]
, а не lst
. Это делается для того, чтобы разрешить «извлечь» из контейнера средства перебора, не требуя определения трех примитивов перебора.
12.9.2. foreach с внутренним перебором
Примитивы из предыдущего раздела образуют интерфейс перебора, который клиентский код может использовать, как заблагорассудится. Но иногда лучше использовать внутренний перебор, когда просматриваемая сущность полностью управляет процессом перебора и выполняет тело цикла самостоятельно. Такое перекладывание ответственности часто может быть полезно, например, если полный просмотр коллекции предпочтительнее выполнять рекурсивно (как в случае с деревьями).
Чтобы организовать цикл foreach
с внутренним перебором, для вашей структуры или класса нужно определить метод opApply
4. Например:
import std.stdio;
class SimpleTree(T)
{
private:
T _payload;
SimpleTree _left, _right;
public:
this(T payload)
{
_payload = payload;
}
// Обход дерева в глубину
int opApply(int delegate(ref T) dg)
{
auto result = dg(_payload);
if (result) return result;
if (_left)
{
result = _left.opApply(dg);
if (result) return result;
}
if (_right)
{
result = _right.opApply(dg);
if (result) return result;
}
return 0;
}
}
void main()
{
auto obj = new SimpleTree!int(1);
obj._left = new SimpleTree!int(5);
obj._right = new SimpleTree!int(42);
obj._right._left = new SimpleTree!int(50);
obj._right._right = new SimpleTree!int(100);
foreach (i; obj)
{
writeln(i);
}
}
Эта программа выполняет обход дерева в глубину и выводит:
1
5
42
50
100
Компилятор упаковывает тело цикла (в данном случае { writeln(i); }
) в делегат и передает его методу opApply
. Компилятор организует исполнение программы так, что код, выполняющий выход из цикла с помощью инструкции break
, преждевременно возвращает 1
в качестве результата делегата, отсюда и манипуляции с result
внутри opApply
.
Зная все это, читать код метода opApply
действительно легко: сначала тело цикла применяется к корневому узлу, а затем рекурсивно к левому и правому узлам. Простота реализации действительно имеет значение. Если вы попробуете реализовать просмотр узлов дерева с помощью примитивов empty
, front
и popFront
, задача сильно усложнится. Так происходит потому, что в методе opApply
состояние итерации формируется неявно благодаря стеку вызовов. А при использовании трех примитивов перебора вам придется управлять этим состоянием явно.
Упомянем еще одну достойную внимания деталь во взаимодействии foreach
и opApply
. Переменная i
, используемая в цикле, становится частью типа делегата. К счастью, на тип этой переменной и даже на число привязываемых к делегату переменных, задействованных в foreach
, ограничения не налагаются – все поддается настройке. Если вы определите метод opApply
так, что он будет принимать делегат с двумя аргументами, то сможете использовать цикл foreach
следующего вида:
// Вызывает метод object.opApply(delegate int(ref K k, ref V v){...})
foreach (k, v; object)
{
...
}
На самом деле, просмотр ключей и значений встроенных ассоциативных массивов реализован именно с помощью opApply
. Для любого ассоциативного массива типа V[K]
справедливо, что делегат, принимаемый методом opApply
, ожидает в качестве параметров значения типов V
и K
.
12.10. Определение перегруженных операторов в классах
Большинство рассмотренных замен включали вызовы методов с параметрами времени компиляции, таких как opBinary(string)(T)
. Такие методы очень хорошо работают как внутри классов, так и внутри структур. Единственная проблема в том, что методы с параметрами времени компиляции неявно неизменяемы, и их нельзя переопределить, так что для определения класса или интерфейса с переопределяемыми элементами может потребоваться ряд дополнительных шагов. Простейшее решение – написать, к примеру, метод opBinary
, так чтобы он проталкивал выполнение операции далее в обычный метод, который можно переопределить:
class A
{
// Метод, не допускающий переопределение
A opBinary(string op)(A rhs)
{
// Протолкнуть в функцию, допускающую переопределение
return opBinary(op, rhs);
}
// Переопределяемый метод, управляется строкой во время исполнения
A opBinary(string op, A rhs)
{
switch (op)
{
case "+":
... // Реализовать сложение
break;
case "-":
... // Реализовать вычитание
break;
...
}
}
}
Такой подход позволяет решить поставленную задачу, но не оптимально, ведь оператор проверяется во время исполнения – действие, которое может быть выполнено во время компиляции. Следующее решение позволяет исключить излишние затраты по времени за счет переноса проверки внутрь обобщенной версии метода opBinary
:
class A
{
// Метод, не допускающий переопределение
A opBinary(string op)(A rhs)
{
// Протолкнуть в функцию, допускающую переопределение
static if (op == "+")
{
return opAdd(rhs);
}
else static if (op == "-")
{
return opSubtract(rhs);
} ...
}
// Переопределяемые методы
A opAdd(A rhs)
{
... // Реализовать сложение
}
A opSubtract(A rhs)
{
... // Реализовать вычитание
}
...
}
На этот раз каждому оператору соответствует свой метод. Вы, разумеется, вправе выбрать операторы для перегрузки и способы их группирования, соответствующие вашему случаю.
12.11. Кое-что из другой оперы: opDispatch
Пожалуй, самая интересная из замен, открывающая максимум возможностей, – это замена с участием метода opDispatch
. Именно она позволяет D встать в один ряд с гораздо более динамическими языками.
Если некоторый тип T
определяет метод opDispatch
, компилятор переписывает выражение
a.fun(‹арг1›, ..., ‹аргk›)
как
a.opDispatch!"fun"(‹арг1›, ..., ‹аргk›)
для всех методов fun
, которые должны были бы присутствовать, но не определены, то есть для всех вызовов, которые бы иначе вызвали ошибку «метод не определен».
Определение opDispatch
может реализовывать много очень интересных задумок разной степени динамичности. Рассмотрим пример метода opDispatch
, реализующего подчинение альтернативному соглашению именования методов класса. Для начала объявим простую функцию, преобразующую идентификатор такого_вида
в его альтернативу «в стиле верблюда» (camel-case) такогоВида
:
import std.ctype;
string underscoresToCamelCase(string sym)
{
string result;
bool makeUpper;
foreach (c; sym)
{
if (c == '_')
{
makeUpper = true;
}
else
{
if (makeUpper)
{
result ~= toupper(c);
makeUpper = false;
}
else
{
result ~= c;
}
}
}
return result;
}
unittest
{
assert(underscoresToCamelCase("здравствуй_мир") == "здравствуйМир");
assert(underscoresToCamelCase("_a") == "A");
assert(underscoresToCamelCase("abc") == "abc");
assert(underscoresToCamelCase("a_bc_d_") == "aBcD");
}
Вооружившись функцией underscoresToCamelCase
, можно легко определить для некоторого класса метод opDispatch
, заставляющий этот класс принимать вызовы a.метод_такого_вида()
и автоматически перенаправлять эти обращения к методам a.методТакогоВида()
– и все это во время компиляции.
class A
{
auto opDispatch(string m, Args...)(Args args)
{
return mixin("this."~underscoresToCamelCase(m)~"(args)");
}
int doSomethingCool(int x, int y)
{
...
return 0;
}
}
unittest
{
auto a = new A;
a.doSomethingCool(5, 6); // Вызов напрямую
a.do_something_cool(5, 6); // Тот же вызов, но через посредника opDispatch
}
Второй вызов не относится ни к одному из методов класса A
, так что он перенаправляется в метод opDispatch
через вызов a.opDispatch!"do_something_cool"(5, 6)
. opDispatch
, в свою очередь, генерирует строку "this.doSomethingCool(args)"
, а затем компилирует ее с помощью выражения mixin
. Учитывая, что с переменной args
связана пара аргументов 5
, 6
, вызов mixin
в итоге сменяется вызовом a.doSomethingCool(5, 6)
– старое доброе перенаправление в своем лучшем проявлении. Миссия выполнена!
12.11.1. Динамическое диспетчирование с opDispatch
Хотя, конечно, интересно использовать opDispatch
в разнообразных проделках времени компиляции, реально интересные приложения требуют динамичности. Динамические языки, такие как JavaScript или Smalltalk, позволяют присоединять к объектам методы во время исполнения. Попробуем сделать нечто подобное на D: определим класс Dynamic
, позволяющий динамически добавлять, удалять и вызывать методы.
Во-первых, для таких динамических методов придется определить сигнатуру времени исполнения. Здесь нам поможет тип Variant
из модуля std.variant
. Это мастер на все руки: объект типа Variant
может содержать практически любое значение. Такое свойство делает Variant
идеальным кандидатом на роль типа параметра и возвращаемого значения динамического метода. Итак, определим сигнатуру такого динамического метода в виде делегата, который в качестве первого аргумента (играющего роль this
) принимает Dynamic
, а вместо остальных аргументов – массив элементов типа Variant
, и возвращает результат типа Variant
.
import std.variant;
alias Variant delegate(Dynamic self, Variant[] args...) DynMethod;
Благодаря ... можно вызывать DynMethod
с любым количеством аргументов с уверенностью, что компилятор упакует их в массив. А теперь определим класс Dynamic
, который, как и обещано, позволит манипулировать методами во время исполнения. Чтобы обеспечить такие возможности, Dynamic
определяет ассоциативный массив, отображающий строки на элементы типа DynMethod
:
class Dynamic
{
private DynMethod[string] methods;
void addMethod(string name, DynMethod m)
{
methods[name] = m;
}
void removeMethod(string name)
{
methods.remove(name);
}
// Динамическое диспетчирование вызова метода
Variant call(string methodName, Variant[] args...)
{
return methods[methodName](this, args);
}
// Предоставить синтаксический сахар с помощью opDispatch
Variant opDispatch(string m, Args...)(Args args)
{
Variant[] packedArgs = new Variant[args.length];
foreach (i, arg; args)
{
packedArgs[i] = Variant(arg);
}
return call(m, args);
}
}
Посмотрим на Dynamic
в действии:
unittest
{
auto obj = new Dynamic;
obj.addMethod("sayHello",
delegate Variant(Dynamic, Variant[]...)
{
writeln("Здравствуй, мир!");
return Variant();
}
);
obj.sayHello(); // Печатает "Здравствуй, мир!"
}
Поскольку все методы должны соответствовать одной и той же сигнатуре, добавление метода не обходится без некоторого синтаксического шума. В этом примере довольно много незадействованных элементов: добавляемый делегат не использует ни один из своих параметров и возвращает результат, не представляющий никакого интереса. Зато синтаксис вызова очень прозрачен. Это важно, так как обычно методы добавляются редко, а вызываются часто. Усовершенствовать класс Dynamic
можно разными путями. Например, можно определить информационную функцию getMethodInfo(string)
, возвращающую для заданного метода число его параметров и их типы.
Заметим, что в данном случае приходится идти на уступки, обычные для решения о статическом или динамическом выполнении действий. Чем больше вы делаете во время исполнения, тем чаще требуется соответствовать общим форматам данных (Variant
в нашем примере) и идти на компромисс, жертвуя быстродействием (например, из-за поиска имен методов во время исполнения). Взамен вы получаете возросшую гибкость: можно как угодно манипулировать определениями классов во время исполнения, определять отношения динамического наследования, взаимодействовать со скриптовыми языками, определять скрипты для собственных объектов и еще много чего.
12.12. Итоги и справочник
Пользовательские типы могут перегружать большинство операторов. Есть несколько исключений, таких как «запятая» ,
, логическая конъюнкция &&
, логическая дизъюнкция ||
, проверка на идентичность is
, тернарный оператор ?:
, а также унарные операторы получения адреса &
и typeid
. Было решено, что перегрузка этих операторов добавит скорее путаницы, чем гибкости.
Кстати, о путанице. Заметим, что перегрузка операторов – это мощный инструмент, к которому прилагается инструкция с предупреждением той же мощности. В языке D лучший совет для вас: не используйте операторы в экзотических целях, вроде определения целых встроенных предметно-ориентированных языков (Domain-Specific Embedded Language, DSEL). Если желаете определять встроенные предметно-ориентированные языки, то для этой цели лучше всего подойдут строки и выражение mixin
(см. раздел 2.3.4.2) с вычислением функций на этапе компиляции (см. раздел 5.12). Эти средства позволяют выполнить синтаксический разбор входных конструкций на DSEL, представленных в виде строки времени компиляции, а затем сгенерировать соответствующий код на D. Такой подход требует больше труда, но пользователи вашей библиотеки это оценят.
Определение opDispatch
открывает новые горизонты, но это средство также нужно использовать с умом. Чрезмерная динамичность может снизить быстродействие программы за счет лишних манипуляций и ослабить проверку типов (например, не стоит забывать, что если в предыдущем фрагменте кода вместо a.helloWorld()
написать a.heloWorld()
, код все равно скомпилируется, а ошибка проявится лишь во время исполнения).
В табл. 12.1 в сжатой форме представлена информация из этой главы. Используйте эту таблицу как шпаргалку, когда будете перегружать операторы для собственных типов.
Таблица 12.1. Перегруженные операторы
Выражение | Переписывается как... |
---|---|
‹оп›a , где ‹оп› ∈ {+ , - , ~ , * , ++ , -- } |
a.opUnary!"‹оп›"() |
a++ |
((ref x) {auto t=x; ++x; return t;})(a) |
a-- |
((ref x) {auto t=x; --x; return t;})(a) |
cast(T) a |
a.opCast!(T)() |
a ? ‹выраж1› : ‹выраж2› |
cast(bool) a ? ‹выраж1› : ‹выраж2› |
if (a) ‹инстр› |
if (cast(bool) a) ‹инстр› |
a ‹оп› b , где ‹оп› ∈ {+ , - , * , / , % , & , | , ^ , << , >> , >>> , ~ , in } |
a.opBinary!"‹оп›"(b) или b.opBinaryRight!"‹оп›"(a) |
a == b |
Если a и b – экземпляры классов: object.opEquals(a, b) (см. раздел 6.8.3). Иначе если a и b имеют один тип: a.opEquals(b) . Иначе единственное выражение из a.opEquals(b) и b.opEquals(a) , которое компилируется |
a != b |
!(a == b) , затем действовать по предыдущему алгоритму |
a < b |
a.opCmp(b) < 0 или b.opCmp(a) > 0 |
a <= b |
a.opCmp(b) <= 0 или b.opCmp(a) >= 0 |
a > b |
a.opCmp(b) > 0 или b.opCmp(a) < 0 |
a >= b |
a.opCmp(b) >= 0 или b.opCmp(a) <= 0 |
a = b |
a.opAssign(b) |
a ‹оп›= b , где ‹оп› ∈ {+ , - , * , / , % , & , | , ^ , << , >> , >>> , ~ } |
a.opOpAssign!"‹оп›"(b) |
a[b1, b2, ..., bk] |
a.opIndex(b1, b2, ..., bk) |
a[b1, b2, ..., bk] = c |
a.opIndexAssign(c, b1, b2, ..., bk) |
‹оп›a[b1, b2, ..., bk] , где ‹оп› ∈ {++ , -- } |
a.opIndexUnary(b1, b2, ..., bk) |
a[b1, b2, ..., bk] ‹оп›= c , где ‹оп› ∈ {+ , - , * , / , % , & , | , ^ , << , >> , >>> , ~ } |
a.opIndexOpAssign!"‹оп›"(c, b1, b2, ..., bk) |
a[b1 .. b2] |
a.opSlice(b1 .. b2) |
‹оп› a[b1 .. b2] |
a.opSliceUnary!"‹оп›"(b1, b2) |
a[] = c |
a.opSliceAssign(c) |
a[b1 .. b2] = c |
a.opSliceAssign(c, b1, b2) |
a[] ‹оп›= c |
a.opSliceOpAssign!"‹оп›"(c) |
a[b1 .. b2] ‹оп›= c |
a.opSliceOpAssign!"‹оп›"(c, b1, b2) |
[🢀 11. Расширение масштаба] [12. Перегрузка операторов] [13. Параллельные вычисления 🢂]
-
Автор использует понятия «тип» и «алгебра» не совсем точно. Тип определяет множество значений и множество операций, производимых над ними. Алгебра – это набор операций над определенным множеством. То есть уточнение «с алгебрами» – избыточно. – Прим. науч. ред. ↩︎
-
В данном коде отсутствует проверка перехода за границы для оператора отрицания. – Прим. науч. ред. ↩︎
-
Для перегрузки
foreach_reverse
служат примитивыpopBack
иback
аналогичного назначения. – Прим. науч. ред. ↩︎ -
Существует также оператор
opApplyReverse
, предназначенный для перегрузкиforeach_reverse
и действующий аналогичноopApply
дляforeach
. – Прим. науч. ред. ↩︎