Предисловие переводчика

Оригинальные статьи вышли с июня 2022-го по январь 2023-го в виде трёх постов на blog.dlang.org под общим заголовком «Безопасность памяти в современном языке системного программирования». Статьи посвящены DIP1000 — набору изменений, призванному существенно улучшить безопасность работы с памятью. Перевод объединяет все три.

Если стремитесь глубже разобраться с использованием @safe-кода, атрибутов scope и return scope и узнать про автовыведение атрибутов функции, эта статья может оказаться полезной.

В тексте «undefined behavior» иногда переведено как «неопределённое поведение», иногда просто записано в виде сокращения «UB». То же касается «garbage collector» — «сборщик мусора» или «GC».

Для экспериментов я использовал компиляторы dmd 2.102.1 и ldc 1.30.0. В них DIP1000 ещё не включён по умолчанию.

В целом данная статья ориентирована на D-программистов. Если вы мало знакомы с D, полезно будет узнать, что такое UFCS, т.к. это явление будет часто встречаться в примерах кода. Аббревиатура расшифровывается как «Uniform Function Call Syntax», т.е. «универсальный синтаксис вызова функций». Это особенность языка, позволяющая обычные функции (или шаблонные функции) вызывать так, как если бы они были методами — через точку после первого аргумента. К примеру, вызовы writeln(x) и x.writeln() означают одно и то же (а в последнем случае можно скобки и не писать).

Также я осмелился оставить некоторое количество примечаний, обозначенных как «примечание переводчика»; мелкие изменения в комментариях в коде оставил без пометок.


Часть 1

Безопасность работы с памятью не нуждается в проверках

D — это язык программирования, в котором присутствует как сборка мусора, так и эффективный прямой доступ к памяти. Современные языки высокого уровня, такие как D, безопасно работают с памятью, предотвращая нарушение системы типов языка и случайное чтение/запись в неиспользуемую память.

Как язык системного программирования, «не весь» в D даёт такие гарантии, но у него есть безопасное подмножество, в котором для управления памятью используется сборщик мусора, так же как в Java, C# или Go. Код на D, даже в проекте, предполагающем системное программирование, должен стремиться оставаться в рамках этого безопасного подмножества, где это целесообразно. В D имеется атрибут @safe для проверки того, что функция использует только безопасные для памяти возможности языка. К примеру, попробуйте такой пример:

@safe string getBeginning(immutable(char)* cString)
{
    return cString[0..3];
}

Компилятор откажется компилировать данный код, т.к. невозможно выяснить, что выйдет в результате получения трёхсимвольного среза из указателя cString, который может ссылаться на пустую строку (с нулевым символом в первом элементе), на строку с одним символом, или там может быть 1 или 2 символа, после которых нет нулевого. Тогда результатом будет нарушение работы с памятью. (Компилятор выводит ошибку "pointer slicing not allowed in safe functions". Если убрать @safe, поведение будет аналогичным таковому в C/C++, т.е. срез возьмёт больше байт, чем есть реально в строке под указателем cString. — Примечание переводчика.)

@safe — не значит медленный

Заметьте: выше я сказал, что даже в системном низкоуровневом проекте стоит использовать @safe, где целесообразно. Но как это возможно, учитывая, что подобные проекты иногда принципиально не могут использовать сборщик мусора, который, меж тем, является главным инструментом в D для обеспечения безопасной работы с памятью?

И да, такие проекты время от времени вынуждены прибегать к небезопасным для памяти конструкциям. Даже у проектов более высокого уровня часто есть для этого причины, поскольку в них могут создаваться интерфейсы к Си-шным или C++-ным библиотекам. Также может быть желание отказаться от сборщика мусора, если к этому подводят показатели производительности. Но всё же довольно большие части кода могут быть помечены как @safe без использования сборщика мусора вообще.

В D это возможно, потому что безопасное для памяти подмножество языка не предотвращает прямой доступ к памяти как таковой.

@safe void add(int* a, int* b, int* sum)
{
    *sum = *a + *b;
}

Эта функция скомпилируется и она полностью безопасна для памяти, несмотря на разыменование указателей ровно тем же непроверенным способом, что и в C. Это безопасно для памяти, потому что @safe D не позволяет создавать указатели int*, указывающие на нераспределённые области памяти, или указывающие, например, на float**. Переменная int* может указывать на нулевой адрес, но это обычно не является проблемой безопасности памяти, потому что нулевой адрес защищён операционной системой. Любая попытка его разыменовать приведёт к падению программы ещё до того, как произойдёт повреждение памяти. (Вы увидите ошибку сегментирования. — Примечание переводчика). Сборщик мусора здесь не задействован, поскольку в D он работает когда у него запрашивается больше памяти или если сборщик вызван явно.

Срезы массивов так и работают: при индексации во время выполнения проверяется, что индекс меньше длины. Не будет никаких проверок на предмет того, ссылается ли срез на на легальную область памяти. Безопасность работы с памятью достигается путём предотвращения создания таких срезов. (Опять же, сборщик мусора здесь не участвует.)

Это позволяет задействовать множество паттернов, которые безопасны, эффективны и не зависят от сборщика мусора.

struct Struct
{
    int[] slice;
    int* pointer;
    int[10] staticArray;
}

@safe @nogc Struct examples(Struct arg)
{
    arg.slice[5] = *arg.pointer;
    arg.staticArray[0..5] = arg.slice[5..10];
    arg.pointer = &arg.slice[8];
    return arg;
}

Как было продемонстрировано, D позволяет свободно выполнять непроверенные операции с памятью в @safe-коде. Память, на которую ссылаются arg.slice и arg.pointer, может находиться в куче, управляемой GC, или в статической памяти. Нет причин, по которым языку нужно об этом заботиться. Программе, вероятно, нужно будет вызвать сборщик мусора или выполнить какое-нибудь небезопасное выделение памяти, но обработка уже выделенной памяти не требуется. Если бы сама функция examples() нуждалась в сборщике мусора, она бы не скомпилировалась из-за атрибута @nogc.

Примечание переводчика.
Если в arg.slice нет элемента с индексом 5 (первое действие в фукнции) или 8 (третье действие в фукнции), будет кинуто исключение core.exception.ArrayIndexError. Также, если arg.slice не имеет элементов [5..10], в arg.staticArray не запишется ничего и будет кинуто исключение core.exception.ArraySliceError. Причём эти исключения будут кинуты вне зависимости от наличия атрибута @safe у функции examples().

Однако…

Здесь проявляется исторический недостаток дизайна языка: память также может находиться в стеке. Подумайте, что произойдёт, если мы немного изменим нашу функцию.

@safe @nogc Struct examples(Struct arg)
{
    arg.pointer = &arg.staticArray[8];
    arg.slice = arg.staticArray[0..8];
    return arg;
}

Структура здесь передана по значению, её содержимое скопировано в стек при вызове функции и может быть перезаписано после возвращения из функции. Поле arg.staticArray — тоже тип-значение, оно копируется целиком вместе со структурой (как если бы вместо него было бы просто десять переменных типа int). Когда мы возвращаем arg из функции, содержимое staticArray копируется в возвращаемое значение, но pointer и slice продолжают указывать на arg (оставшийся в функции), а не на возвращаемую копию!

Но у нас есть исправление, позволяющее писать код в @safe-функциях так же эффективно, как и раньше, включая ссылки на стек. Это исправление даже позволяет безопасно писать несколько вещей, ранее доступных только в @system-режиме (противоположность @safe). Это исправление — DIP1000. Это же исправление является причиной, по которой данный пример уже вызывает предупреждение «deprecated», если он скомпилирован с последней ночной сборкой dmd.

Родился первым, умер последним

DIP1000 — это набор усовершенствований в правилах языка, касающихся указателей, срезов и других ссылочных типов данных. Название расшифровывается как D Improvement Proposal №1000, именно на этом документе изначально основывались новые правила. Включить новые правила можно с помощью флага компилятора -preview=dip1000. Существующий код может потребовать некоторых изменений для работы с новыми правилами, поэтому эта опция не включена по умолчанию. В будущем этот режим будет использоваться по умолчанию, поэтому лучше включить его там, где это возможно, и работать над совместимостью кода с ним там, где это невозможно.

Базовая идея заключается в том, чтобы позволить людям ограничить время жизни ссылки (массива или указателя, например). Указатель на стек не опасен, если он существует не дольше, чем стековая переменная, на которую он указывает. Обычные ссылки продолжают существовать, но они могут ссылаться только на данные с неограниченным временем жизни, т.е. на память, предоставленную сборщиком мусора, или статические или глобальные переменные.

Давайте начнём

Самый простой способ создания ссылок с ограниченным временем жизни — присвоить ссылке что-то с ограниченным временем жизни.

@safe int* test(int arg1, int arg2)
{
    int* notScope = new int(5);
    int* thisIsScope = &arg1;
    int* alsoScope;
    alsoScope = thisIsScope;

    // Ошибка! Переменная, объявленная ранее, считается
    // имеющей большее время жизни, поэтому это присвоение запрещено
    thisIsScope = alsoScope;

    return notScope; // всё хорошо
    return thisIsScope; // ошибка
    return alsoScope; // ошибка
}

При проверке этих примеров не забывайте использовать флаг компилятора -preview=dip1000 и пометить функцию атрибутом @safe. Проверки не выполняются для функций, не являющихся @safe.

Примечание переводчика.
Без -preview=dip1000 пример тоже не скомпилируется, причём ошибка на этот раз проявится на строке int* thisIsScope = &arg1;. В сообщении об ошибке будет сказано, что нельзя брать адрес локальной переменной в @safe-функции. И здесь режим с DIP1000 действует явно гораздо интеллектуальнее.

В качестве альтернативы можно явно использовать ключевое слово scope для ограничения времени жизни ссылки.

@safe int[] test()
{
    int[] normalRef;
    scope int[] limitedRef;

    if(true)
    {
        int[5] stackData = [-1, -2, -3, -4, -5];

        // Время жизни stackData заканчивается раньше,
        // чем limitedRef, поэтому так делать нельзя:
        limitedRef = stackData[];

        // вот так можно:
        scope int[] evenMoreLimited = stackData[];
    }

    return normalRef; // всё хорошо
    return limitedRef; // запрещено
}

Если мы не можем возвращать из функций ссылки с ограниченным временем жизни, как мы можем ими пользоваться? Довольно просто. Помните, защищён только адрес на данные, а не сами данные. Это означает, что у нас есть много способов передать scoped-данные из функции.

@safe int[] fun()
{
    scope int[] dontReturnMe = [1, 2, 3];

    // выделяем память под массив обычным образом (с GC)
    int[] result = new int[](dontReturnMe.length);
    // Здесь происходит копирование данных,
    // чтобы результат не ссылался на защищённую память
    result[] = dontReturnMe[];
    return result;

    // сокращённый способ сдалать то же, что и выше
    return dontReturnMe.dup;

    // Вас не всегда может интересовать содержимое,
    // вы можете захотеть что-то вычислить
    return
    [
        dontReturnMe[0] * dontReturnMe[1],
        cast(int) dontReturnMe.length
    ];
}

Между функциями

DIP1000 мог бы ограничиться языковыми примитивами при работе с ссылками с ограниченным временем жизни (с помощью рассмотренных выше приёмов), но класс хранения scope можно применять и к параметрам функций. Это гарантирует, что память не будет использована после выхода из функции, а, значит, ссылки на локальные данные могут использоваться в качестве аргументов в scope-параметрах.

@safe double average(scope int[] data)
{
    double result = 0;
    foreach(el; data) result += el;
    return result / data.length;
}

@safe double use()
{
    int[10] data = [1,2,3,4,5,6,7,8,9,10];
    return data[].average; // работает!
}

Примечание переводчика.
Использование среза массива без индексов (data[]) — это, в данном случае, фактически, создание динамического массива из статического. Причём на самом деле, можно обойтись без квадратных скобок: массив неявно преобразуется в динамический массив при передаче в функцию average(). В любом случае data.ptr внутри функции average() будет тот же, что и в функции use(), а, значит, можно влиять на содержимое переданного массива. Какой в таком случае смысл использования scope в данном примере, не ясно. Как было замечено автором выше, «защищён только адрес на данные, а не сами данные». Иное дело, если параметр в average() будет const scope — тогда данные внутри функции точно не будут изменены.

Поначалу, вероятно, лучше воздержаться от автоматического выведения атрибутов. Автовыведение в целом — хороший инструмент, но он тихонько добавляет атрибуты scope ко всем параметрам, до которых достаёт, что означает, что можно легко потерять контроль, что значительно усложняет выяснение происходящего. Чтобы избежать этого, можно всегда явно указывать возвращаемый тип (или его отсутствие с void или noreturn):

@safe const(char[]) fun(int* val)
против
@safe auto fun(int* val)
или
@safe const fun(int* val)

Также функция не должна быть шаблоном или находиться внутри шаблона. Более подробно об автовыведении с атрибутом scope мы расскажем в одном из следующих постов.

Атрибут scope позволяет работать с указателями и массивами, указывающими на стек, но запрещает возвращать их. А что если нам всё же нужно их возвращать? Тогда можете использовать return scope:

// Будучи массивами символов, строки тоже работают с DIP1000
@safe string latterHalf(return scope string arg)
{
    return arg[$/2 .. $];
}

@safe string test()
{
    // размещено в статической памяти программы
    auto hello1 = "Hello world!";
    // выделено на стеке, скопировано из hello1
    immutable(char)[12] hello2 = hello1;

    auto result1 = hello1.latterHalf; // всё хорошо
    return result1; // всё хорошо

    auto result2 = hello2[].latterHalf; // всё хорошо
    // Хорошая попытка! Но result2 локален и не может быть возвращён
    return result2;
}

Примечание переводчика.
Здесь разница именно в месте размещения строки: hello2 является статическим массивом и ему нельзя выходить за пределы функции: при использовании -preview=dip1000 будет ошибка компиляции.
Если убрать return scope у параметра функции latterHalf(), можно будет увидеть сообщение о том, что ссылка на локальную переменную hello2 присваивается не-scope-параметру arg в вызове latterHalf(), чего делать с DIP1000 нельзя (ошибка компиляции). И да, речь всё ещё о @safe-функциях.

Параметры, помеченные как return scope, проверяют, является ли любой из переданных аргументов scope. Если да, то возвращаемое значение обрабатывается как scope-значение, которое не может пережить ни один из аргументов, помеченных как return scope. Если ни один аргумент не является scope, возвращаемое значение рассматривается как глобальная ссылка, которую можно свободно копировать. Как и scope, return scope консервативен. Даже если на самом деле возвращается не адрес, защищённый благодаря return scope, компилятор всё равно выполнит проверку времени жизни.

scope не идёт вглубь

@safe void test()
{
    scope a = "first";
    scope b = "second";
    string[] arr = [a, b];
}

В этой функции инициализация arr не скомпилируется. Это может удивить, учитывая, что язык автоматически добавляет scope к переменной во время инициализации, если это необходимо.

Однако, подумайте, что scope в случае scope string[] сможет защитить? Потенциально это две вещи: адреса строк в массиве или адреса символов в строках. Чтобы это присваивание было безопасным, scope должен защищать символы в строках, но он защищает только ссылку верхнего уровня, т.е. строки в массиве. Таким образом, пример не работает. Теперь изменим arr так, чтобы это был статический массив:

@safe void test()
{
    scope a = "first";
    scope b = "second";
    string[2] arr = [a, b];
}

Это работает, потому что статические массивы не являются ссылочными типами. Память для всех их элементов выделяется в стеке, в отличие от динамических массивов, которые содержат ссылку на элементы, хранящиеся в другом месте. Когда статический массив объявлен как scope, его элементы рассматриваются как scope. Поскольку пример не станет компилироваться, если arr — не scope, из этого следует, что здесь scope, можно сказать, подразумевается.

Несколько практических советов

Давайте посмотрим правде в глаза, правила DIP1000 требуют времени для понимания, и многие предпочитают потратить это время на программирование чего-нибудь полезного. Первый и самый важный совет: избегайте не-@safe-кода как чумы, если возможно. Конечно, в этом совете нет ничего нового, но в случае с DIP1000 он становится ещё более важным. В двух словах, язык не проверяет валидность scope и return scope в не-@safe-функции, но при вызове этих функций компилятор предполагает, что предполагаемое обозначенными атрибутами соблюдено.

Это делает scope и return scope в небезопасном коде грозными ружьями, направленными себе в ногу. Если D-программист будет сопротивляться искушению пометить код как @trusted, он (программист) вряд ли сможет причинить вред. Неправильное использование DIP1000 в коде @safe может привести к избыточным ошибкам компиляции, но не приведёт к повреждению памяти и вряд ли вызовет другие проблемы.

Второй важный момент, о котором стоит упомянуть, заключается в том, что нет необходимости в использовании scope и return scope для атрибутов функции, если они получают только статические или выделенные сборщиком мусора данные. Многие языки вообще не позволяют программистам обращаться к стеку; то, что программисты на D могут это делать, не означает, что они должны это делать. Таким образом, им не придётся тратить больше времени на решение ошибок компилятора, чем до появления DIP1000. А если желание работать со стеком всё-таки возникнет, авторы смогут вернуться к аннотированию функций. Скорее всего, они сделают это, не ломая интерфейс.

Что дальше?

На этом мы завершаем сегодняшнюю статью в блоге. Этого достаточно, чтобы знать, как использовать массивы и указатели с DIP1000. В принципе, это также позволяет читателям использовать DIP1000 с классами и интерфейсами. Единственное, что нужно усвоить, — это то, что ссылка на класс, включая указатель this в функциях-членах, работает с DIP1000 так же, как и указатель. Тем не менее, из одного высказывания трудно понять, что это значит, поэтому в последующих статьях мы осветим эту тему.

В любом случае, есть ещё много того, что стоит узнать. DIP1000 имеет некоторые возможности для ref-аргументов функций, для структур и объединений, которые мы здесь не рассмотрели. Мы также углубимся в то, как DIP1000 работает с не-@safe-функциями и автоопределением атрибутов. В настоящее время планируется ещё два поста этой серии.

Спасибо Уолтеру Брайту за рецензирование этой статьи.


Часть 2

В предыдущей части этой серии статей показано, как использовать новые правила DIP1000, чтобы срезы и указатели ссылались на стек безопасно с точки зрения обращения с памятью. Но в D можно ссылаться на стек и другими способами, и это тема данной статьи.

Объекты классов — самый простой случай

В 1-й части я сказал, что, если вы понимаете, как DIP1000 работает с указателями, то вы понимаете, как он работает с классами. Перейдём сразу к примеру:

@safe Object ifNull(return scope Object a, return scope Object b)
{
    return a ? a : b;
}

В приведённом примере return scope работает точно так же, как и здесь:

@safe int* ifNull(return scope int* a, return scope int* b)
{
    return a ? a : b;
}

Принцип заключается в следующем: если scope или return scope применяется к объекту в списке параметров, адрес объекта класса защищается так же, как если бы параметр был указателем на объект. С точки зрения машинного кода, Object a — это указатель на объект.

Что касается обычных функций, то всё уже сказано. А что насчёт методов класса или интерфейса? Вот как это делается:

interface Talkative
{
    @safe const(char)[] saySomething() scope;
}

class Duck : Talkative
{
    char[8] favoriteWord;

    @safe const(char)[] saySomething() scope
    {
        import std.random : dice;

        // это не работает
        // return favoriteWord[];

        // а это работает
        return favoriteWord[].dup;

        // Также работает возврат чего-то совсем другого.
        // Здесь возвращается 1-я запись в 40% случаев,
        // 2-я — в 40% случаев и 3-я в остальных случаях.
        return
        [
            "quack!",
            "Quack!!",
            "QUAAACK!!!"
        ][dice(2,2,1)];
    }
}

Атрибут scope, расположенный либо до, либо после имени функции-члена, помечает ссылку this как scope, предотвращая её утечку из функции. Поскольку адрес объекта защищён, ничего, что ссылалось бы непосредственно на адреса полей, не допускается к выходу за пределы метода. Вот почему возвращение favoriteWord[] запрещено, это статический массив, хранящийся внутри экземпляра класса, поэтому возвращаемый фрагмент будет ссылаться непосредственно на него. С другой стороны, favoriteWord[].dup возвращает копию данных, которая не находится в экземпляре класса, поэтому это нормально.

В качестве альтернативы можно заменить атрибуты scope для Talkative.saySomething и Duck.saySomething на return scope, что позволит возвращать favoriteWord без дублирования.

DIP1000 и принцип подстановки Барбары Лисков

Принцип подстановки Барбары Лисков в упрощённом виде гласит, что унаследованная функция может дать вызывающей стороне больше гарантий, чем её родительская функция, но меньше — никогда. Атрибуты, связанные с DIP1000, попадают в эту категорию. Правило работает следующим образом:

  • если параметр (включая неявную ссылку this) в родительской функции не имеет атрибутов DIP1000, дочерняя функция может назначить ему scope или return scope;

  • если параметр обозначен как scope в родителе, в наследнике scope так же должен быть;

  • если параметр имеет атрибут return scope, он должен быть или return scope, или scope в наследнике.

Если атрибут отсутствует, вызывающая сторона не может ничего предполагать; функция может где-нибудь хранить адрес аргумента. Если присутствует return scope, вызывающая сторона может предположить, что адрес аргумента не хранится нигде, кроме как в возвращаемом значении. При наличии scope гарантируется, что адрес нигде не хранится, что является ещё более надёжной гарантией. Пример:

class C1
{
    double*[] incomeLog;

    // "impose tax" ­— "взимать налог", "income" — "доход"
    @safe double* imposeTax(double* pIncome)
    {
        incomeLog ~= pIncome;
        return new double(*pIncome * .15);
    }
}

class C2 : C1
{
    // Хорошо с точки зрения языка (но, возможно,
    // нечестно для налогоплательщика)
    override @safe double* imposeTax(return scope double* pIncome)
    {
        return pIncome;
    }
}

class C3 : C2
{
    // Тоже хорошо.
    override @safe double* imposeTax(scope double* pIncome)
    {
        return new double(*pIncome * .18);
    }
}

class C4 : C3
{
    // Нехорошо. Параметр pIncome в C3.imposeTax имеет атрибут scope,
    // а здесь мы пытаемся ослабить это ограничение.
    override @safe double* imposeTax(double* pIncome)
    {
        incomeLog ~= pIncome;
        return new double(*pIncome * .16);
    }
}

Особый указатель — ref

Мы всё ещё не выяснили, как использовать структуры и объединения в DIP1000. Что ж, мы разобрались с указателями и массивами. При обращении к структуре или объединению они работают так же, как и при обращении к любому другому типу. Но указатели и массивы не являются каноническим способом использования структур в D. Чаще всего они передаются по значению или по ссылке, когда связаны с ref-параметрами. Самое время объяснить, как работает ref в DIP1000.

Они работают не так, как иные указатели. Как только вы поймёте, что такое ref, вы сможете использовать DIP1000 так, как иначе не смогли бы.

Простой параметр ref int

Простейший возможный способ использовать ref, вероятно, следующий:

@safe void fun(ref int arg) {
    arg = 5;
}

Что это значит? Изнутри ref является указателем, считайте, что int* pArg, но в исходном коде используется как значение. Кроме того, клиент вызывает функцию, как если бы аргумент был передан по значению:

auto anArray = [1, 2];
fun(anArray[1]);  // с UFCS можно написать anArray[1].fun;
// anArray теперь содержит [1, 5]

При передаче по указателю мы бы писали fun(&anArray[1]). В отличие от ссылок в C++, ссылки в D могут быть нулевыми, но приложение мгновенно завершится с ошибкой сегментации, если нулевая ссылка будет использована для чего-то другого, кроме чтения адреса с помощью оператора &. Таким образом…

int* ptr = null;
fun(*ptr);

…этот код компилируется, но падает во время исполнения, потому что присваивание внутри fun приземляется прямо на нулевой адрес.

Адрес переменной ref всегда защищён от утечки. В этом смысле
@safe void fun(ref in arg) { arg = 5; }
подобен
@safe void fun(scope int* pArg) { *pArg = 5; }.
А, например,
@safe int* fun(ref int arg) { return &arg; }
не будет компилироваться, так же как и
@safe int* fun(scope int* pArg) { return pArg; }

Однако, существует класс хранения return ref, который позволяет возвращать адрес параметра. Это означает, что
@safe int* fun(return ref int arg) { return &arg; }
работает.

Ссылка на ссылку

Ссылка ref int или аналогичный тип уже позволяет использовать гораздо более красивый синтаксис, чем при работе с указателями. Но настоящая сила ref проявляется, когда он ссылается на тип, который сам является ссылкой, например, указатель или класс. Атрибуты scope и return scope могут быть применены к ссылке, на которую ссылается ref. Пример:

@safe float[] mergeSort(ref return scope float[] arr)
{
    import std.algorithm : merge;
    import std.array : Appender;

    if (arr.length < 2) return arr;

    auto half1 = arr[0 .. $/2];
    auto half2 = arr[$/2 .. $];

    Appender!(float[]) output;
    output.reserve(arr.length);

    foreach (el; half1.mergeSort.merge!floatLess(half2.mergeSort)) {
        output ~= el;
    }

    arr = output[];
    return arr;
}

@safe bool floatLess(float a, float b)
{
    import std.math : isNaN;
    return a.isNaN ? false : b.isNaN ? true : a < b;
}

Здесь mergeSort гарантирует, что не будет передавать адрес чисел в arr, кроме как в возвращаемом значении. Это та же гарантия, что и при возврате параметра float[] arr из параметра return scope float[] arr. Но, в то же время, поскольку arr является ref-параметром, mergeSort может изменять переданный ему массив. Тогда в клиентском коде можно написать:

float[] values = [5, 1.5, 0, 19, 1.5, 1];
values.mergeSort;

С не-ref-аргументом клиенту пришлось бы написать values = values.sort вместо этого (отказ от использования ссылки был бы вполне разумным решением API в данном случае, поскольку мы не всегда хотим изменять исходный массив). Это то, чего нельзя достичь с помощью указателей, поскольку return scope float[]* arr будет защищать адрес метаданных массива (поля length и ptr массива), а не адрес его содержимого.

@safe ref Exception nullify(return ref scope Exception obj)
{
    obj = null;
    return obj;
}

@safe unittest
{
    scope obj = new Exception("Error!");
    assert(obj.msg == "Error!");
    obj.nullify;
    assert(obj is null);
    // Поскольку nullify возвращает результат по ссылке,
    // мы можем новое значение присвоить результату сразу на месте
    obj.nullify = new Exception("Fail!");
    assert(obj.msg == "Fail!");
}

Здесь мы возвращаем адрес аргумента, переданного для nullify, но при этом защищаем адрес указателя на объект и адрес экземпляра класса от утечки по другим каналам.

Ключевое слово return не требует, чтобы за ним следовали ref или scope. Что тогда означает void* fun(ref scope return int*)? В спецификации говорится, что return без последующего scope всегда рассматривается как ref return. Таким образом, данный пример эквивалентен void* fun(return ref scope int*). Однако это применимо только в том случае, если есть ссылка для привязки. Запись void* fun(scope return int*) означает void* fun(return scope int*). Можно даже написать void* fun(return int*) с последним упомянутым смыслом, но я оставляю на ваше усмотрение, будет ли это считаться краткостью или запутыванием.

Функции-члены и ref

Атрибуты ref и return ref часто требуют внимательного вглядывания, чтобы оследить, какой адрес защищён и что может быть возвращено. Требуется определённый опыт, чтобы освоиться с ними. Но как только вы это сделаете, понимание того, как работают структуры и объединения в DIP1000, станет довольно простым. Основное отличие от случая с классами заключается в том, что, если в функциях-членах класса ссылка this является обычной ссылкой на объект класса, то в функциях-членах структуры или объединения ссылка this — это ref StructOrUnionName.

union Uni
{
    int asInt;
    char[4] asCharArr;

    // возвращаемое значение содержит ссылку на содержимое
    // этого объединения, но ссылки на него не будут
    // утекать каким-либо другим путём
    @safe char[] latterHalf() return
    {
        return asCharArr[2 .. $];  // или this.asCharArr[2 .. $];
    }

    // this-аргумент является неявным ref, а написанное ниже означает,
    // что возвращаемое значение не ссылается на это объединение,
    // а также то, что мы не "сливаем" его каким-либо другим способом
    @safe char[] latterHalfCopy()
    {
        return latterHalf.dup;  // или this.latterHalf.dup;
    }
}

Обратите внимание, что return ref не должен использоваться с аргументом this. Выражение char[] latterHalf() return ref компилятор не сможет распарсить. Язык уже должен понимать, что ref char[] latterHalf() return означает, что возвращаемое значение является ссылкой. В любом случае ref в return ref будет лишним.

Заметьте ещё, что мы не использовали здесь ключевое слово scope. Атрибут scope был бы бессмысленным для этого объединения, потому что оно не содержит ссылок на что бы то ни было. Точно так же бессмысленно иметь атрибуты scope ref int или scope int у аргумента функции, ведь scope имеет смысл только для типов, которые ссылаются на память в другом месте.

Атрибут scope в структуре или объединении означает то же самое, что и в случае статического массива. Это означает, что память, на которую ссылаются его члены, не может утечь в другое место. Пример:

struct CString
{
    // Нам нужно поместить указатель в анонимное объединение с
    // фиктивным членом, иначе клиентский @safe-код сможет присвоить
    // указателю ptr ссылку на символ, не входящий в Си-строку.
    union
    {
        // Пустые строковые литералы оптимизируются компилятором D
        // в нулевые указатели, поэтому мы делаем так, чтобы указатель
        // действительно указывал на '\0'; nullChar объявлен ниже по коду
        immutable(char)* ptr = &nullChar;
        size_t dummy;
    }

    // return scope здесь гарантирует, что эта структура
    // не будет жить дольше, чем память в arr.
    @trusted this(return scope string arr)
    {
        // Примечание: обычные assert не подойдут! Они могут быть удалены
        // из релизных сборок, но этот assert необходим для безопасности
        // памяти, поэтому нам нужно использовать assert(0), который
        // никогда не будет удалён.
        if (arr[$-1] != '\0') assert(0, "not a C string!");
        ptr = arr.ptr;
    }

    // Возвращаемое значение ссылается на ту же память, что и члены
    // этой структуры, но мы не "сливаем" ссылки на неё каким-либо
    // другим способом, поэтому используем return scope.
    @trusted ref immutable(char) front() return scope
    {
        return *ptr;
    }

    // Ссылки на указанный массив нигде не передаются.
    @trusted void popFront() scope
    {
        // В противном случае пользователь сможет
        // проскочить конец строки и затем прочитать её!
        if (empty) assert(0, "out of bounds!");
        ptr++;
    }

    // То же самое.
    @safe bool empty() scope
    {
        return front == '\0';
    }
}

immutable nullChar = '\0';

@safe unittest
{
    import std.array : staticArray;

    auto localStr = "hello world!\0".staticArray;
    auto localCStr = localStr.CString;
    assert(localCStr.front == 'h');

    static immutable(char)* staticPtr;

    // Ошибка: утечка ссылки на локальное значение.
    // staticPtr = &localCStr.front();

    // Здесь всё хорошо.
    staticPtr = &CString("global\0").front();

    localCStr.popFront;
    assert(localCStr.front == 'e');
    assert(!localCStr.empty);
}

В первой части говорилось о том, что @trusted — это опасное ружье для выстрелов в ногу с DIP1000. Этот пример демонстрирует, почему. Представьте, как легко было бы ошибиться, использов обычный assert или вообще забыть об assert, или упустить из виду необходимость использования анонимного объединения. Я думаю, что эта структура безопасна для использования, но вполне может быть, что я что-то упустил из виду.

И под конец

Мы почти всё уже знаем об использовании структур, объединений и классов с DIP1000. Сегодня нам осталось узнать две последние вещи.

Но перед этим небольшое отступление о ключевом слове scope. Оно используется не только для аннотирования параметров и локальных переменных, как было проиллюстрировано. Оно также используется для scope-классов и для стражей области видимости. В данном руководстве они обсуждаться не будут, поскольку первая функция устарела, а вторая не имеет отношения к DIP1000 или контролю времени жизни переменных. Смысл упоминания о них в том, чтобы развеять потенциальное заблуждение, что scope всегда означает ограничение времени жизни чего-либо. Изучение операторов стражей области видимости по-прежнему является хорошей идеей, поскольку это полезная функция.

Вернёмся к теме. Первая вещь не совсем специфична для структур или классов. Мы обсудили, что обычно означают return, return ref и return scope, но у них есть и другой смысл. Глянем:

@safe void getFirstSpace
(
    ref scope string result, return scope string where
)
{
    //...
}

Обычное значение атрибута return здесь не имеет смысла, поскольку функция имеет тип возврата void. В этом случае действует специальное правило: если тип возврата void, а первый аргумент — ref или out, то любой последующий return ref/scope предполагается утекающим присвоением первому аргументу. В случае функций-членов структур предполагается, что они присваиваются самой структуре.

@safe unittest
{
    static string output;
    immutable(char)[8] input = "on stack";
    // Попытка присвоить статической переменной содержимое стека.
    // Не скомпилируется.
    getFirstSpace(output, input);
}

Поскольку речь зашла об атрибуте out, следует сказать, что здесь он будет лучшим выбором для результата, чем ref. Атрибут out работает как ref с той лишь разницей, что данные, на которые ссылается параметр out, автоматически инициализируются по умолчанию в начале функции, т.е. любые данные, на которые ссылается out-параметр, гарантированно не повлияют на выполнение функции.

Второе, что необходимо усвоить, — это то, что scope используется компилятором для оптимизации размещения классов внутри тел функций. Если новый класс используется для инициализации scope-переменной, компилятор может поместить её в стек. Пример:

class C{int a, b, c;}
@safe @nogc unittest
{
    // Поскольку этот юнит-тест имеет атрибут @nogc,
    // компиляция без scope перед объявлением объекта не пройдёт
    scope C c = new C();
}

Эта возможность требует явного использования ключевого слова scope. Автовыведение scope не работает, потому что инициализация класса таким образом обычно (т.е. без атрибута @nogc) не требует ограничения времени жизни переменной c. В настоящее время эта возможность работает только с классами, но нет причин, по которым она не могла бы работать с указателями на структуры, созданными с new, и литералами массивов.

До следующего раза

Это практически всё, что касается ручного использования DIP1000. Но эта серия блогов ещё не закончена! DIP1000 не предназначен для явного использования — он работает благодаря выведению атрибутов — эту тему и будет охватывать следующий пост.

Будут также рассмотрены некоторые моменты использования кода с @trusted и @system. Необходимость в опасном системном программировании существует и является частью сферы применения языка D. Но даже системное программирование является ответственным делом, когда люди делают всё возможное для минимизации рисков. Мы увидим, что даже там можно сделать многое.

Спасибо Уолтеру Брайту и Деннису Корпелу за рецензию на эту статью.


Часть 3

Первая часть в этой серии показывает, как использовать новые правила DIP1000, чтобы срезы и указатели ссылались на стек, сохраняя безопасность памяти. Во второй части цикла рассказывается о классе хранения ref и о том, как DIP1000 работает с агрегированными типами (классами, структурами, объединениями).

До сих пор в этой серии мы намеренно избегали шаблонов и функций, возвращающих auto. Это позволило упростить первые два поста, поскольку в них не пришлось разбираться с автоматическим выведением атрибутов функций. Однако и auto-функции, и шаблоны очень часто встречаются в D-коде, поэтому серия статей о DIP1000 не может быть полной без объяснения того, как они работают с изменениями в языке. Вывод атрибутов функции — наш самый важный инструмент для предотвращения так называемого "атрибутного супа", когда функция украшена несколькими атрибутами, что, возможно, снижает читаемость.

Мы также углубимся в unsafe-код. Два предыдущих поста в этой серии были сосредоточены на атрибуте scope, но этот пост больше ориентирован на атрибуты и безопасность памяти в целом. Поскольку DIP1000 в конечном итоге — про безопасность памяти, мы не можем обойти эти темы.

Как избежать повторов атрибутов

Выведение атрибутов функции означает, что язык будет анализировать тело функции и автоматически добавлять атрибуты @safe, pure, nothrow и @nogc, где это применимо. Он также попытается добавить атрибуты scope или return scope к параметрам ref, которые иначе не могут быть скомпилированы. Некоторые атрибуты никогда не выводятся. Например, компилятор не будет вставлять атрибуты ref, lazy, out или @trusted, потому что, скорее всего, они явно не нужны там, где их нет.

Существует много способов включить автовыведение атрибутов функции. Один из них — опустить тип возврата в сигнатуре функции. Обратите внимание, ключевое слово auto для этого не обязательно. Ключевое слово auto используется тогда, когда не указан тип возврата, класс хранения или атрибут. Например, объявление half(int x) { return x/2; } не парсится, поэтому вместо него мы используем auto half(int x) { return x/2; }. Но с тем же успехом можно написать @safe half(int x) { return x/2; }, и остальные атрибуты (pure, nothrow и @nogc) будут выведены точно так же, как и при использовании ключевого слова auto.


Примечание переводчика.

Возможно, вы не знали про такое самоназначение атрибутов, поэтому покажу пример.

import std.stdio;

int sum(int x, int y) {
    return x + y;
}

void main() {
    writeln(typeid(typeof(sum)));
}

Здесь функция sum имеет абсолютно явную тривиальную сигнатуру, и её тип будет определён как int function(int, int). Но стоит вместо int поставить auto на место возвращаемого типа, тип функции будет уже int function(int, int) pure nothrow @nogc @safe.

Как заметил автор статьи, возвращаемый тип и вовсе необязателен, если перед именем функции поставить атрибут вроде pure, @safe и др.


Второй способ включить выведение атрибутов — шаблонизировать функцию. В нашем примере это можно сделать следующим образом:

int divide(int denominator)(int x) { return x/denominator; }
alias half = divide!2;

В спецификации D не сказано, что шаблон должен иметь какие-либо параметры. Тогда пустой список параметров можно использовать для включения вывода атрибутов: int half()(int x) { return x/2; }. Вызов этой функции даже не требует синтаксиса инстанциирования шаблона в месте вызова, например, писать half!()(12) не нужно, т.к. half(12) тоже будет компилироваться.

Другой способ включить выведение атрибутов — хранить функцию внутри другой функции. Такие функции называются вложенными. Выведение включается не только для функций, вложенных непосредственно в другую функцию, но и для большинства сущностей, вложенных в тип или шаблон внутри функции. Пример:

@safe void parentFun()
{
    // здесь есть автоматическое выведение
    int half(int x){ return x/2; }

    class NestedType
    {
        // здесь тоже
        final int half1(int x) { return x/2; }

        // здесь нет автовыведения, это виртуальная функция,
        // и компилятор не может знать, есть ли у неё
        // небезопасное переопределение в производном классе.
        int half2(int x) { return x/2; }
    }

    int a = half(12); // Работает. Выведено как @safe
    auto cl = new NestedType;
    int b = cl.half1(18); // Работает. Выведено как @safe
    int c = cl.half2(26); // Ошибка.
}

Недостатком вложенных функций является то, что их можно использовать только в лексическом порядке (место вызова должно находиться ниже объявления функции), если только вложенная функция и вызов не находятся внутри одной и той же структуры, класса, объединения или шаблона, которые, в свою очередь, находятся внутри родительской функции. Ещё одним недостатком является то, что они не работают с универсальным синтаксисом вызова функций.

Наконец, выведение атрибутов всегда включено для литералов функций (они же «лямбда-функции»). Функция половинного деления была бы определена как
enum half = (int x) => x/2;
и вызывалась бы точно так же, как обычно. Однако язык не считает такое объявление функцией. Он считает его указателем на функцию. (Тип будет int function(int) pure nothrow @nogc @safe*. — Примечание переводчика.) Это означает, что в глобальной области видимости важно использовать enum или immutable вместо auto. В противном случае лямбда может быть изменена на что-то другое из любой точки программы, и к ней нельзя будет получить доступ из чистых функций. В редких случаях такая изменяемость может быть желательна, но чаще всего это антипаттерн (как и глобальные переменные в целом).

Пределы возможностей автовыведения

Стремиться к минимизации набора текста вручную не всегда разумно. Также не стоит стремиться к максимальному разрастанию атрибутов.

Основная проблема автоматического выведения заключается в том, что тонкие изменения в коде могут привести к тому, что выводимые атрибуты будут включаться и выключаться неконтролируемым образом. Чтобы понять, когда это имеет значение, нам нужно иметь представление о том, что будет выведено, а что нет.

Компилятор в целом приложит все усилия, чтобы вывести атрибуты @safe, pure, nothrow и @nogc. Если ваша функция может их иметь, она почти всегда будет их иметь. В спецификации говорится, что рекурсия является исключением: функция, вызывающая саму себя, не должна быть @safe, pure или nothrow, если это не указано явно. Но при тестировании я обнаружил, что для рекурсивных функций эти атрибуты определяются. Оказывается, в настоящее время ведётся работа над тем, чтобы заставить рекурсивное выведение атрибутов работать, и частично оно уже работает.

Выведение scope и return для параметров функции менее надёжно. В самых простых случаях это сработает, но компилятор быстро сдастся. Чем умнее механизм выведения, тем больше времени требуется на компиляцию, поэтому текущее проектное решение заключается в том, чтобы выводить эти атрибуты только в самых простых случаях.

Где можно позволить компилятору автовыведение?

Программист на D должен взять за привычку задавать вопрос «Что произойдёт, если я по ошибке сделаю что-то, что сделает эту функцию небезопасной, нечистой, способной кидать исключения, с включённым сборщиком мусора?» Если ответ — «немедленная ошибка компилятора», то автовыведение, вероятно, подходит. С другой стороны, ответом может быть «пользовательский код сломается при обновлении библиотеки, которую я поддерживаю». В этом случае аннотируйте вручную.

Помимо возможности потери атрибутов, которые автор намеревался применить, есть и другой риск:

@safe pure nothrow @nogc firstNewline(string from)
{
    foreach(i; 0 .. from.length) {
        switch(from[i]) {
            case '\r':
                if (from.length > i+1 && from[i + 1] == '\n') {
                    return "\r\n";
                } else {
                    return "\r";
                }
            case '\n':
                return "\n";
            default: break;
        }
    }
    return "";
}

Вы можете подумать, что раз автор вручную указывает атрибуты, то проблемы нет. К сожалению, это не так. Предположим, автор решил переписать функцию так, чтобы все возвращаемые значения были кусочками параметра from, а не строковыми литералами:

@safe pure nothrow @nogc firstNewline(string from)
{
    foreach(i; 0 .. from.length) {
        switch(from[i]) {
            case '\r':
                if (from.length > i + 1 && from[i + 1] == '\n') {
                    return from[i .. i + 2];
                } else {
                    return from[i .. i + 1];
                }
            case '\n':
                return from[i .. i + 1];
            default: break;
        }
    }
    return "";
}

Сюрприз! Параметр from до этого вычислялся как scope, и пользователь полагался на это, но теперь он вычисляется как scope return, ломая клиентский код.

Примечание переводчика.
Здесь затрудняюсь сказать, о чём говорил автор. В обоих случаях тип функции определяется как immutable(char)[] function(immutable(char)[]) pure nothrow @nogc @safe.
Т.е. мы не видим ни scope, ни scope return у аргумента функции, эти атрибуты проявляются в типе только если их вписать явно.

Тем не менее, для внутренних функций автовыведение — это отличный способ сэкономить как на нажатиях клавиш, так и на напряжении глаз при чтении. Обратите внимание, что совершенно нормально полагаться на автоматический вывод атрибута @safe, пока функция используется явно в @safe-функциях и юнит-тестах. Если что-то потенциально небезопасное делается внутри функции с автовыведением, то функция выведется как @system, не @trusted. Вызов функции @system из @safe-функции приводит к ошибке компилятора, что означает, что на автовыведение можно в этом случае положиться.

Иногда всё же имеет смысл вручную применять атрибуты к внутренним функциями, поскольку сообщения об ошибках, выдаваемые при нарушении атрибутов, как правило, лучше выглядят с вручную выставленными атрибутами.

А что насчёт шаблонов?

Автовыведение всегда включено для шаблонных функций. Что если интерфейс библиотеки должен раскрыть такую функцию? Есть способ заблокировать автовыведение, хотя и некрасивый:

private template FunContainer(T)
{
    // Нет автовыведения
    @safe T fun(T arg){return arg + 3;}
}

// Автовыведение здесь включено, но, поскольку вызываемая функция
// не имеет автовыведенных атрибутов, выводится только @safe
auto addThree(T)(T arg){ return FunContainer!T.fun(arg); }

Однако то, какие атрибуты должен иметь шаблон, часто зависит от его параметров времени компиляции. Можно было бы использовать метапрограммирование для назначения атрибутов в зависимости от параметров шаблона, но это потребовало бы много работы, было бы трудночитаемо и подвержено ошибкам, как и автоматическое выведение.

Практичнее просто проверить, что шаблон функции выдает нужные атрибуты. Такое тестирование не обязательно и, вероятно, его не нужно выполнять вручную при каждом изменении функции. Вместо этого:

float multiplyResults(alias fun)(float[] arr)
    if (is(typeof(fun(new float)) : float))
{
    float result = 1.0f;
    foreach (ref e; arr) result *= fun(&e);
    return result;
}

// обратите внимание на атрибуты юнит-теста
@safe pure nothrow unittest
{
    float fun(float* x){return *x+1;}
    // Использование статического массива гарантирует,
    // что аргумент arr будет определяться
    // как "scope" или как "return scope"
    float[5] elements = [1.0f, 2.0f, 3.0f, 4.0f, 5.0f];

    // Не нужно ничего делать с результатом.
    // Идея заключается в том, что, поскольку это компилируется,
    // multiplyResults является проверенным "@safe pure nothrow",
    // а его аргумент ­— "scope" или "return scope"
    multiplyResults!fun(elements);
}

Спасибо возможностям интроспекции D во время компиляции, тестирование нежелательных атрибутов тоже есть:

@safe unittest
{
    import std.traits :
        attr = FunctionAttribute,
        functionAttributes,
        isSafe;

    float fun(float* x)
    {
        // Делает функцию зависимой как от исключений, так и от сборщика мусора
        if (*x > 5) throw new Exception("");
        static float* impureVar;

        // Делает функцию нечистой.
        auto result = impureVar ? *impureVar : 5;

        // Делает аргумент не-scope.
        impureVar = x;
        return result;
    }

    enum attrs = functionAttributes!(multiplyResults!fun);

    assert(!(attrs & attr.nothrow_));
    assert(!(attrs & attr.nogc));

    // Проверяет, не принимает ли она scope-аргументы.
    // Обратите внимание, что эта проверка не работает с @system-функциями.
    assert(!isSafe!(
    {
        float[5] stackFloats;
        multiplyResults!fun(stackFloats[]);
    }));

    // Будет хорошей идеей провести позитивные тесты аналогичными методами,
    // чтобы убедиться, что приведённые выше тесты не сработают,
    // если тестируемая функция будет иметь неправильные атрибуты.
    assert(attrs & attr.safe);
    assert(isSafe!(
    {
        float[] heapFloats;
        multiplyResults!fun(heapFloats[]);
    }));
}

Примечание переводчика.
Документация насчёт std.traits.isSafe говорит только о проверке того, что функция является @safe или @trusted, ни о каких scope-параметрах речи нет.

Если assert'ы должны выполниться во время компиляции до запуска юнит-тестов, стоит добавить ключевое слово static перед каждым assert'ом. Эти ошибки компиляции могут возникнуть даже при сборке без юнит-тестов, преобразовав юнит-тест в обычную функцию, например, заменив @safe unittest на, скажем, private @safe testAttrs().

Учения с боевой стрельбой: @system

Не будет забывать, что D — это язык системного программирования. Как показала эта серия статей, в большей части кода на D программист хорошо защищён от ошибок памяти, но D не был бы D, если бы не позволял переходить на низкий уровень и обходить систему типов так же, как C или C++: битовая арифметика на указателях, запись и чтение непосредственно в аппаратные порты, выполнение деструктора структуры на сырой последовательности байтов… D предназначен для всего этого.

Разница в том, что в C/C++ достаточно одной ошибки, чтобы нарушить систему типов и вызвать неопределённое поведение в любом месте кода. Программист на D подвергается риску только тогда, когда находится не в @safe-функции, или когда использует опасные ключи компилятора, такие как -release или -check=assert=off (падение в отключенном утверждении является неопределённым поведением), и даже тогда семантика, как правило, менее подвержена неопределённому поведению. Например:

float cube(float arg)
{
    float result;
    result *= arg;
    result *= arg;
    return result;
}

Эта независимая от языка функция, которая компилируется в C, C++, D. Кто-то хотел вычислить третью степень аргумента, но забыл инициализировать результат с помощью arg. В D ничего опасного не происходит, несмотря на то, что это @system-функция. Отсутствие значения инициализации означает, что результат по умолчанию инициализируется как NaN (not-a-number), что приводит к тому, что результат также равен NaN, а это явно ошибочное значение, проявляющееся при первом же использовании этой функции.

Однако в C и C++ отсутствие инициализации локальной переменной означает, что её чтение является (за несколькими исключениями) неопределённым поведением. Эта функция даже не работает с указателями, однако, согласно стандарту, вызов этой функции с тем же успехом может содержать *(int*) rand() = 0xDEADBEEF, и всё из-за тривиальной ошибки. Хотя многие компиляторы с включёнными предупреждениями поймают эту ошибку, всё же не все компиляторы это делают, к тому же эти языки полны подобных примеров, когда даже предупреждения не помогают.

В языке D, даже если бы вы явно запросили отсутствие инициализации по умолчанию с float result = void;, это означало бы только неопределённое возвращаемое значение функции, а не всё и вся, что может произойти, если функция будет вызвана. Следовательно, эта функция может быть аннотирована @safe даже с таким инициализатором.

Тем не менее, для тех, кто заботится о безопасности памяти (а такая забота, вероятно, должна быть для всех программных проектов, предназначенных для широкой аудитории), плохой идеей является предположение, что @system-код D достаточно безопасен, чтобы быть режимом по умолчанию. Два примера ниже покажут, что может произойти.

Что может сделать неопределённое поведение

Некоторые люди полагают, что «неопределённое поведение» означает просто «ошибочное поведение» или сбой во время выполнения программы. Хотя часто именно это и происходит, неопределённое поведение гораздо опаснее, чем, скажем, непойманное исключение или бесконечный цикл. Разница в том, что при неопределённом поведении у вас нет никаких гарантий того, что именно произойдёт. Возможно, это звучит не хуже, чем бесконечный цикл, но случайный бесконечный цикл обнаруживается сразу же. Код с неопределённым поведением, в свою очередь, во время тестирования может делать то, что было задумано, но позже (после релиза) делать что-то совершенно другое. Даже если код тестируется с теми же флагами, с которыми он компилируется для «продакшена», поведение может изменяться от одной версии компилятора к другой или при внесении совершенно не имеющих к этому отношения изменений в код. Самое время для примера:

// проверяет, находится ли в массиве переданное в функцию исключение
bool replaceExceptions(Object[] arr, ref Exception e)
{
    bool result;
    foreach (ref o; arr)
    {
        if (&o is &e) {
            result = true;
        }
        if (cast(Exception) o) {
            o = e;
        }
    }

    return result;
}

Идея заключается в том, что функция заменяет все исключения в массиве на переданное. Если само это исключение уже было в массиве, функция вернёт true, иначе false. И действительно, тестирование подтверждает, что она работает. Функция используется следующим образом:

auto arr = [new Exception("a"), null, null, new Exception("c")];
auto result = replaceExceptions(cast(Object[]) arr, arr[3]);

Это приведение не является проблемой, верно? Ссылки на объекты всегда имеют одинаковый размер вне зависимости от их типа, и мы приводим исключения к родительскому типу — Object. Непохоже, что массив содержит что-то ещё, кроме ссылок на объекты.

К сожалению, спецификация D рассматривает это иначе. Наличие двух ссылок на объекты класса (ил любых ссылок, раз на то пошло) в одном и том же месте памяти, но с разными типами, а затем присвоение одной из них другой — это неопределённое поведение. Именно это и происходит в

if (cast(Exception) o) {
    o = e;
}

если массив действительно содержит аргумент «e». Поскольку true может быть возвращено только в случае неопределённого поведения, это означает, что любой компилятор может свободно оптимизировать replaceExceptions так, чтобы он всегда возвращал false. Это спящая ошибка, которую не обнаружит ни одно тестирование, но которая может спустя годы полностью испортить приложение при компиляции с мощными оптимизациями продвинутого компилятора.

Может показаться, что требование приведения для использования функции — это очевидный предупреждающий знак, который хороший D-программист не должен проигнорировать. Я бы не был так уверен. Приведение не так уж редко даже в хорошем высокоуровневом коде. Даже если вы так не делаете, всё равно вы можете встретить код, достаточно опасный, чтобы укусить любого. Прошлым летом (речь о лете 2022-го — примечание переводчика) такой случай появился на форуме:

string foo(in string s)
{
    return s;
}

void main()
{
    import std.stdio;
    string[] result;
    foreach(c; "hello")
    {
        result ~= foo([c]);
    }
    writeln(result);
}

С этой проблемой столкнулся Стивен Швайгоффер, давний ветеран D, который сам читал лекции о @safe и @system и не раз. Всё, что может подгореть у него, может подгореть и любого из нас.

Обычно это работает именно так, как можно подумать, и в соответствии со спецификацией. Однако, если вместе с DIP1000 включить ещё одну функцию языка, которая вскоре станет стандартной, с помощью ключа -preview=in, программа начинает работать неправильно. Старая семантика для in совпадает с const, но новая семантика делает его const scope.

Поскольку аргументом foo является scope-аргумент, компилятор предполагает, что foo скопирует [c] перед возвратом или вернёт что-то другое, и поэтому он выделяет [c] на одной и той же позиции стека для каждого из символов "hello". В результате программа печатает ["o", "o", "o", "o", "o"]. Да, мне тоже трудно понять, что происходит в этом простом примере. Поиск такого рода ошибок в сложной кодовой базе может стать настоящим ночным кошмаром.

(С моей ночной версией DMD где-то между 2.100 и 2.101 вместо этого выводится ошибка времени компиляции. В версии 2.100.2 пример работает как описано выше.)

Примечание переводчика.
С компиляторами dmd 2.102.1 и ldc 1.30.0 никакое из вышеприведённых поведений не воспроизводится. Разве что dmd при использовании флага -preview=in выдаёт предупреждение "Deprecation: scope parameter s may not be returned". В любом случае программа печатает ["h", "e", "l", "l", "o"]. Позже мне под руку случайно попала ЭВМ с устаревшим dmd 2.100.0, с ним действительно получился набор из пяти букв "o".

Фундаментальная проблема в обоих этих примерах одна и та же: не используется @safe. Если бы он использовался, оба этих неопределённых поведения привели бы к ошибкам компиляции (сама функция replaceExceptions может быть @safe, но приведение в месте использования — нет). Теперь должно быть понятно, что код @system следует использовать редко.

Примечание переводчика.
Т.к. с более новыми компиляторами мы в любом случае видим ошибку компиляции с включённым -preview=in, замечание насчёт неиспользования @safe неактуально. Если отключить -preview=in, код работает обычным образом, причём как с @safe, так и без @safe — ошибок компиляции нет. Можно написать вместо in явно const scope, но и тогда работа атрибута @safe себя не проявляет — ошибка компиляции "scope variable s may not be returned" появится в любом случае. Замечу, что речь всё ещё идёт о случаях, когда мы явно включаем DIP1000 с -preview=dip1000.

Когда всё же...

Рано или поздно наступает момент, когда защиту необходимо временно опустить. Вот пример хорошего варианта использования @system:

// UB: передача ненулевого указателя на отдельный символ,
// отличный от '\0', или на массив без '\0' в начале или после
// указанного символа, как `utf8Stringz`.
extern(C) @system pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
    import std.string, std.utf;

    try utf8Stringz.fromStringz.validate();
    catch (UTFException) return false;

    return true;
}

Эта функция позволяет коду, написанному на другом языке, проверить строку UTF-8 с помощью стандартной библиотеки Phobos. Язык C — это C, он склонен использовать строки с нулевым символом в конце, поэтому функция принимает в качестве аргумента указатель на такую строку, а не массив D. Вот почему функция вынуждена быть небезопасной. Нет способа безопасно проверить, что utf8Stringz указывает либо на нуль, либо на допустимую строку C. Если символ, на который указывают, не нулевой, т.е. следующий символ должен быть прочитан, у функции нет способа узнать, принадлежит ли этот символ памяти, выделенной для строки. Она может только поверить, что вызывающий код всё правильно понял.

Тем не менее, это хороший пример использования атрибута @system. Во-первых, предполагается, что функция вызывается в основном из C или C++. Эти языки в любом случае не получают никаких гарантий безопасности. Даже @safe-функция безопасна лишь тогда, когда она получает только те параметры, которые могут быть созданы в @safe-коде. Передача cast(const char*)0xFE0DA1 в качестве аргумента функции небезопасна, что бы ни говорил атрибут, а в C/C++ ничто не проверяет, какие атрибуты передаются.

Во-вторых, функция чётко документирует случаи, которые могут вызвать неопределённое поведение. Однако она не упоминает, что передача невалидного указателя, такого как вышеупомянутый cast(const char*)0xFE0DA1, является неопределённым поведением, поскольку неопределённое поведение всегда является предположением по умолчанию при использовании @system-значений, если не доказано обратное.

В-третьих, функция небольшая и легко проверяется вручную. Ни одна функция не должна быть без необходимости большой, но ещё важнее, чтобы функции @system и @trusted были маленькими и простыми для ревью. Функции @safe могут быть отлажены до довольно хорошего состояния с помощью тестирования, но, как мы видели ранее, неопределённое поведение может быть невосприимчиво к тестированию. Анализ кода — это единственный общий ответ на неопределённое поведение.

Существует причина, по которой параметр не имеет атрибута scope. Он мог бы иметь его, раз указатель на строку не возвращается. Однако, это не даст больших преимуществ. Любой код, вызывающий эту функцию, должен быть @system, @trusted или написан на «внешнем» языке, что означает, что вызывающий код может передать указатель на стек. А scope может потенциально улучшить производительность клиентского D-кода в обмен на увеличение возможности неопределённого поведения при ошибочном рефакторинге этой функции. Такой компромисс в целом нежелателен, если только не будет показано, что атрибут помогает решить проблему производительности. С другой стороны, атрибут явно укажет читателю, что строка не должна «утекать». Трудно судить, будет ли scope здесь разумным дополнением.

Дальнейшие усовершенствования

Следует задокументировать, почему функция @system является @system, если это не очевидно. Часто существует более безопасная альтернатива — в нашем примере функция могла бы взять массив D или структуру CString из предыдущего поста этой серии. Почему не была выбрана альтернатива? В нашем случае мы могли бы сказать, что ABI будет отличаться для любого из этих вариантов, что усложняет ситуацию на стороне C, а предполагаемый клиент (код C) в любом случае небезопасен.

@trusted-функции похожи на @system-функции, только их можно вызывать из @safe-функций, а @system-функции — нет. Когда что-то объявляется как @trusted, это означает, что авторы проверили, что это так же безопасно для использования, как и фактическая @safe-функция с любыми аргументами, которые могут быть созданы в безопасном коде. Их нужно проверять так же тщательно как и @system-функции.

В таких ситуациях следует документировать (для других разработчиков, не для пользователей), почему функция была признана безопасной во всех ситуациях. Или, если функция не полностью безопасна для использования, а атрибут — это просто временный хак, то об этом должно быть большое некрасивое предупреждение.

Конечно, такое «озеленение» крайне нежелательно, но если есть кодовая база, полная @system-кода, который слишком сложно сделать @safe, это лучше, чем сдаться. Несмотря на то, что мы часто говорим об опасности UB и повреждения памяти, в нашей реальной работе наше отношение к ним, как правило, гораздо более беззаботное, что означает, что такие кодовые базы, к сожалению, встречаются довольно часто.

Может возникнуть соблазн определить небольшую @trusted-функцию внутри большей @safe-функции, чтобы сделать что-то небезопасное без отключения проверок для всей функции:

extern(C) @safe pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
    import std.string, std.utf;

    try (() @trusted => utf8Stringz.fromStringz)()
      .validate();
    catch (UTFException) return false;

    return true;
}

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

Самое главное — не доверяйте себе! Как в любой кодовой базе нетривиального размера есть ошибки, так и более чем горстка функций @system в какой-то момент будет содержать скрытое UB. Остальные возможности «ужесточения» D, такие как assert'ы, контракты, инварианты и проверки границ, следует использовать агрессивно и держать их включёнными при релизах. Это рекомендуется делать, даже если программа полностью @safe. Кроме того, в проекте со значительным количеством небезопасного кода следует хотя бы в некоторой степени использовать внешние инструменты, такие как LLVM Address Sanitizer или Valgrind.

Обратите внимание, что идея многих из этих строгих инструментов, как тех, что есть в языке, так и внешних инструментов, заключается в том, чтобы аварийно завершить работу, как только обнаружена какая-либо ошибка. Это уменьшает вероятность того, что неожиданное неопределённое поведение нанесёт более серьёзный ущерб. Но для этого нужно, чтобы программа была спроектирована таким образом, чтобы в любой момент можно было допустить сбой. В программе не должно храниться большое количество несохранённых данных, с которыми есть риск на аварийное завершение. Если программа управляет чем-то важным, она должна быть способна восстановить управление после перезапуска пользователем или другим процессом, или у неё должен быть другой резервный сценарий. Любая программа, которая «не может позволить себе» запустить потенциально аварийные проверки, не может быть доверена для системного программирования.

Заключение

На этом мы завершаем эту серию постов о DIP1000. Некоторые темы, связанные с DIP1000, мы оставили на усмотрение читателей, чтобы они сами экспериментировали с ними, например, ассоциативные массивы. Тем не менее, рассказанного должно быть достаточно для дальнейшей работы.

Хотя мы дали несколько практических советов в дополнение к правилам языка, несомненно, можно было бы рассказать ещё много интересного. Расскажите нам о своих советах по безопасности памяти на форуме D!

Спасибо Уолтеру Брайту и Денису Корпелу за предоставленные отзывы на эту статью.

Комментарии (6)