dlang-book/12-перегрузка-операторов
Alexander Zhirov 5063e81e4a Глава 12 2023-03-04 18:17:50 +03:00
..
README.md Глава 12 2023-03-04 18:17:50 +03:00

README.md

12. Перегрузка операторов

Мы, программисты, не очень любим слишком уж отделять встроенные типы от пользовательских. Магические свойства встроенных типов ме шают открытости и расширяемости любого языка, поскольку при этом пользовательские типы обречены оставаться второсортными. Тем не менее у проектировщиков языка есть все законные основания отно ситься к встроенным типам с большим почтением. Одно из таких осно ваний: более настраиваемый язык сложнее выучить, а также сложнее выполнять его синтаксический анализ как человеку, так и машине. Ка ждый язык по-своему определяет приемлемое соотношение между встроенным и настраиваемым, что для некоторых языков означает впа дение в одну из этих двух крайностей.

Язык D подходит к этому вопросу прагматично: он не умаляет важ ность настраиваемости, но при этом осознает практичность встроенных типов D использует преимущества встроенных типов ровно тремя пу тями:

  1. Синтаксис названий типов. Массивы и ассоциативные массивы ис пользуются повсеместно, и, согласитесь, синтаксис 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 с внутренним перебором, для вашей структуры или класса нужно определить метод opApply4. Например:

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 или Small talk, позволяют присоединять к объектам методы во время исполне ния. Попробуем сделать нечто подобное на 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 Lan guage, 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 == 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[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[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)

  1. Автор использует понятия «тип» и «алгебра» не совсем точно. Тип определяет множество значений и множество операций, производимых над ними. Алгебра это набор операций над определенным множеством. То есть уточнение «с алгебрами» избыточно. Прим. науч. ред. ↩︎

  2. В данном коде отсутствует проверка перехода за границы для оператора отрицания. Прим. науч. ред. ↩︎

  3. Для перегрузки foreach_reverse служат примитивы popBack и back аналогичного назначения. Прим. науч. ред. ↩︎

  4. Существует также оператор opApplyReverse, предназначенный для перегрузки foreach_reverse и действующий аналогично opApply для foreach. Прим. науч. ред. ↩︎