D для новичков, часть 2
Доброго времени суток, хабр!
Продолжим тематику предыдущей статьи. Здесь будет объяснение таких концепций, как @safe, @trusted, pure, nothrow, некоторые моменты, касающиеся ООП.
Весь код по умолчанию является @system (за некоторым исключением), это значит, что разрешено выполнять низкоуровневые операции (работа с указателями). В таком режиме в D можно делать абсолютно всё то же самое, что и в C/C++. Это так же включает в себя и многие ошибки работы с памятью, которые можно допустить в C/C++. Существует способ избежать большого количества ошибок, если придерживаться некоторых ограничений. По сути, это подмножество языка D, называется оно SafeD и по логике работы с ним больше похоже на Java и C#. Включается такой режим атрибутом @safe и запрещает в коде все операции, которые могут вызвать undefined behavior.
Ограничения SafeD:
- нельзя приводить указатель какого-то типа к указателю любого другого типа, отличного от void*
- нельзя приводить любой тип к указателю
- нельзя модифицировать значение указателя (значение по указателю модифицировать можно)
- доступ к полям объединений, содержащих указатели или ссылки на другие типы, запрещён
- вызов любого @system кода запрещён
- можно перехватывать исключения, наследованные только от Exception (Error наследуется от базового Throwable, поэтому ошибки утверждений AssertError нельзя отлавливать)
- вставки ассемблерного кода запрещены
- никаких явных приведений (конструкция cast) изменяемых типов к неизменяемым и наоборот
- никаких явных приведений локальных для потока (обычных) типов к shared и наоборот
- запрещено взятие адреса локальных переменных и аргументов функции
- нет доступа к __gshared («по настоящему» глобальным, как в C/C++) переменным
Атрибут @trusted позволяет использовать system код внутри safe. Но к такой возможности нужно относиться крайне осторожно — тщательней проверять все trusted функции.
Атрибут @nogc запрещает использование операций, использующих сборщик мусора и вызов функций, которые не являются @nogc, подробнее про отключение сборщика мусора можно почитать здесь.
Атрибут pure указывает на то, что функцией не будут использоваться глобальные или статические изменяемые переменные. Это позволяет компилятору использовать некоторые оптимизации. Из этого правила есть одно исключение — debug блок:
void func() pure
{
debug writeln( "print from pure" ); // крайне полезная фича при отладке
...
}
Для компиляции в debug режиме необходимо указать флаг dmd -debug (тривиально, но всё же).
Так же этот атрибут очень полезен при написании классов и структур (об этом далее).
Атрибут nothrow гарантирует, что функция не будет выбрасывать исключений, наследованных от Exception. Это не запрещает ей выбрасывать исключения Error. По замыслу разработчиков исключения Error являются невосстановимыми, поэтому перехватывать их не имеет смысла. Так же это не запрещает ей вызывать не nothrow функции, если те заключены в try-catch блок.
Все функциональные литералы и шаблонные функции по умолчанию имеют атрибуты @safe, @nogc, pure и nothrow, если это возможно. Для автоматического присвоения каждого из атрибутов такими функциями должны выполняться соответствующие условия.
@disable запрещает вызов функции. Это полезно при написании структур, которые не должны иметь каких-либо функций по умолчанию, например:
struct Foo
{
@disable this(); // конструктор по умолчанию
@disable this(this); // конструктор копирования, тоже создаётся по умолчанию
this( int v ){} // нужно определить явный конструктор в этом случае
}
void main()
{
Foo a; // запрещён конструктор по умолчанию, выведется ошибка при компиляции
auto b = Foo(3); // вызывается конструктор с параметром
auto c = b; // запрещён конструктор копирования, выведется ошибка при компиляции
}
Это можно использовать не только со встроенными функциями:
interface A { int bar(); }
class B : A { int bar() { return 3; } }
class C : B { @disable override int bar(); }
void main()
{
auto bb = new B;
bb.bar();
auto cc = new C;
cc.bar(); // ошибка при компиляции
}
Но я не советую использовать такой подход: он не запрещает переопределить функцию в наследуемом от C классе, но вызов её будет провален во время исполнения. Для подобного поведения существует механизм исключений.
Атрибут deprecated выводит предупреждение, полезно при плавной смене api, чтобы пользователи везде убрали вызов такой функции. Этот атрибут принимает строку в качестве сообщения, выводимого при компиляции:
deprecated("because it's old") void oldFunc() {}
Атрибут может по разному применяться к коду: «просто атрибут» применяется к следующему за атрибутом объявления, при использовании фигурных скобок после атрибута он применяется к блоку верхних уровней и внутри классов (внутри функций объявление блоков с атрибутами запрещено, да и не имеет смысла) и с использованием двоеточия, в этом случае применяется до конца файла.
module test;
@safe:
... // так мы делаем весь модуль безопасным
pure
{
int somefunc1() {} // и @safe, и pure
int somefunc2() nothrow {} // @safe, pure и nothrow
}
С простыми вещами разобрались. Теперь стоит осветить, видимо, самую неясную тему: shared с immutable со структурами и классами.
Возьмём простой пример: мы хотим организовать очередь сообщений из одного потока в другой, используя при этом собственную структуру данных.
Начнём со структуры. Предположим нам нужны временная метка и некоторое сообщение, при этом структура будет всегда immutable (другие её варианты нам не нужны).
import std.stdio;
import std.traits;
import std.datetime;
// проверка "является ли типом", это стандарный приём, часто используется при метапрограммировании
template isMessage(T) { enum isMessage = is( Unqual!T == _Message ); }
struct _Message
{
ulong ts;
immutable(void[]) data; // неизменяемые данные
@disable this(); // мы не можем создать сообщение без содержания
immutable: // нам нужны только immutable структуры, по этому все методы будут immutable
this(T)( auto ref const T val )
{
static if( isMessage!T )
{
// просто копируем поля из сообщения
ts = val.ts;
data = val.data;
}
else
{
// иначе копируем в поле data то что пришло аргументом в конструктор
static if( isArray!T )
data = val.idup;
else static if( is( typeof(val.array) ) ) // в случае, если это range
data = val.array.idup;
else static if( !hasUnsharedAliasing!T ) // если это структура данных, не имеющая массивов, делегатов и прочего некопируемого контента
data = [val].idup;
else static assert(0, "unsupported type" );
// и берём текущее вермя
ts = Clock.currAppTick().length;
}
}
// для удобства
auto as(T)() @property
{
static if( isArray!T )
return cast(T)(data.dup);
else static if( !hasUnsharedAliasing!T )
return (cast(T[])(data.dup))[0];
else static assert(0, "unsupported type" );
}
}
alias Message = immutable _Message;
// хороший тон писать тест в том же файле, сразу за проверяемой структурой/функцией/классом
/// тройной слеш - документирующий коментарий. Текст теста будет добавлен в документацию как пример использования
unittest
{
auto a = Message( "hello" );
auto b = Message( a );
assert( a.ts == b.ts );
assert( b.as!string == "hello" );
auto c = Message( b.data );
assert( a.ts != c.ts );
assert( c.as!string == "hello" );
auto d = a;
auto e = Message( 3.14 );
assert( e.as!double == 3.14 );
}
«А почему только immutable?» можете спросить Вы? Тут вопрос неоднозначный. Реально ли Вам нужны мутабельные сообщения в многопоточном программировании (про более серьёзные типы чуть дальше)? Сообщение на то и сообщение, что оно маленькое, «одноразовое». В Rust например все переменные по умолчанию неизменяемые. Это в дальнейшем позволяет избежать лишнего гемороя с синхронизацией, в следствии меньше ошибок и меньше кода. Но если всё-таки нужно. Во-первых, конструктор должен быть чистым (pure) — это позволит создавать любые типы объекта с помощью одного конструктора (в нашем примере мы используем функцию, получающую время, она не является чистой). Во-вторых, придётся отчасти дублировать код методов доступа к объекту. Если конструктор не может быть чистым, то придётся тоже дублировать код, явно указывая случаи его применения. Пример:
struct CrdMessage
{
ulong code;
float x, y;
this( ulong code, float x, float y ) pure // чистый конструктор
{
this.code = code;
this.x = x;
this.y = y;
}
this( in CrdMessage msg ) pure // чистый конструктор
{
code = msg.code;
x = msg.x;
y = msg.y;
}
float sum() const @property { return x+y; } // здесь
float sum() shared const @property { return x+y; } // здесь
float sum() immutable @property { return x+y; } // здесь
}
Дублирование можно было бы убрать с помощью mixin template, но это всё не просто так. Если мы не используем shared объекты такой структуры, то можно обойтись только const вариантом метода (immutable объекты будут вызвать именно его). Метод shared необходим, так как мы явно говорим, что объект может быть разделяем, следовательно, мы берём на себя ответственность за синхронизацию. Это значит, что код в примере содержит ошибку, мы никак не учли, что значения могут меняться в другом потоке. Методов const и shared const недостаточно для вызова метода для immutable объекта, так как immutable объект может быть разделён между потоками и система типов не может выбрать какой из методов нужно вызывать (const или shared const). Так же метод const может отличаться от immutable, так как в случае const мы гарантируем неизменность ссылки на объект, а в случае immutable гарантируем неизменность всех полей структуры на протяжении всего времени её жизни, поэтому нам может понадобиться в const методе делать некоторые действия, которые при immutable нет необходимости выполнять (дополнительное копирование например). Такая система типов заставляет задумываться над совершаемыми действиями и быть внимательней при написании разделяемого кода, но по началу может вызвать боль.
Вернёмся к созданию нашего многопоточного приложения. Реализуем очередь простейшую очередь (не задумываемся об оптимизации выделения памяти).
synchronized class MsgQueue
{
Message[] data; // массив наших сообщений
// волшебные методы, позволяющие использовать экземпляр класса в конструкции foreach
bool empty() { return data.length == 0; }
Message front() { return data[0]; }
void popFront() { data = data[1..$]; }
void put( Message msg ) { data ~= msg; }
}
Да, так всё просто! По сути, всё, что касается структур, можно применить к классам (в плане shared, immutable и тд). Ключевое слово synchronized значит, что класс shared, но synchronized можно использовать только с классами и оно имеет важное отличие от shared. По порядку, как могло бы быть:
class MsgQueue
{
Message[] data;
import core.sync.mutex;
Mutex mutex; // объект синхронизации
this() shared { mutex = cast(shared Mutex)new Mutex; } // почему-то Mutex не имеет shared конструктора
...
void popFront() shared //
{
synchronized(mutex) // блок синхронизации, критический участок
{
data = data[1..$];
}
}
...
}
Можно не помечать каждый метод атрибутом shared, а сделать весь класс shared. Так же можно использовать сам объект класса MsgQueue (и любого другого) в качестве объекта синхронизации:
shared class MsgQueue
{
Message[] data;
...
void popFront()
{
synchronized(this)
{
data = data[1..$];
}
}
...
}
Объект любого класса может быть объектом синхронизации за счёт того, что от базового класса (Object) каждый объект перенимает объект синхронизации (__monitor), реализующий интерфейс Object.Monitor (Mutex тоже его реализует).
В случае, если мы хотим синхронизировать не блок внутри метода, а весь метод, при этом мы хотим использовать в качесте объекта синхронизации сам экземпляр класса, то мы можем сделать весь метод synchronized:
shared class MsgQueue
{
Message[] data;
...
void popFront() synchronized { data = data[1..$]; }
...
}
Если все методы класса должны быть потокобезопасными, мы можем вынести synchronized, как и shared на уровень класса, тогда мы возвращаемся к изначальному написанию.
Надеюсь, мне удалось разъяснить некоторые неочевидные моменты. Опять же, если Вы считаете, что стоит на что-то обрать особое внимание, напишите об этом. Я привёл тут только то, что показалось неочевидным для меня.
import std.stdio;
import std.traits;
import std.datetime;
// проверка "является ли типом", это стандарный приём, часто используется при метапрограммировании
template isMessage(T) { enum isMessage = is( Unqual!T == _Message ); }
struct _Message
{
ulong ts;
immutable(void[]) data; // неизменяемые данные
@disable this(); // мы не можем создать сообщение без содержания
immutable: // нам нужны только immutable структуры, по этому все методы будут immutable
this(T)( auto ref const T val )
{
static if( isMessage!T )
{
// просто копируем поля из сообщения
ts = val.ts;
data = val.data;
}
else
{
// иначе копируем в поле data то что пришло аргументом в конструктор
static if( isArray!T )
data = val.idup;
else static if( is( typeof(val.array) ) ) // в случае, если это range
data = val.array.idup;
else static if( !hasUnsharedAliasing!T ) // если это структура данных, не имеющая массивов, делегатов и прочего некопируемого контента
data = [val].idup;
else static assert(0, "unsupported type" );
// и берём текущее вермя
ts = Clock.currAppTick().length;
}
}
// для удобства
auto as(T)() @property
{
static if( isArray!T )
return cast(T)(data.dup);
else static if( !hasUnsharedAliasing!T )
return (cast(T[])(data.dup))[0];
else static assert(0, "unsupported type" );
}
}
alias Message = immutable _Message;
synchronized class MsgQueue
{
Message[] data;
bool empty() { return data.length == 0; }
Message front() { return data[0]; }
void popFront() { data = data[1..$]; }
void put( Message msg ) { data ~= msg; }
}
unittest
{
auto mq = new shared MsgQueue;
mq.put( Message( "hello" ) );
mq.put( Message( "habr" ) );
string[] msgs;
foreach( msg; mq ) msgs ~= msg.as!string;
assert( msgs == ["hello", "habr"] );
}
void randomsleep(uint min=1,ulong max=100)
{
import core.thread;
import std.random;
Thread.sleep( dur!"msecs"(uniform(min,max)) );
}
import std.string : format;
void sender( shared MsgQueue mq, string name )
{
scope(exit) writefln( "sender %s finish", name );
foreach( i; 0 .. 15 )
{
mq.put( Message( format( "message #%d from [%s]", i, name ) ) );
randomsleep;
}
}
void receiver( shared MsgQueue mq )
{
uint empty_mq = 0;
bool start_receive = false;
scope(exit) writeln( "reciver finish" );
m: while(true)
{
if( mq.empty ) empty_mq++;
if( empty_mq > 10 && start_receive ) return;
foreach( msg; mq )
{
writefln( "[%012d]: %s", msg.ts, msg.as!string );
randomsleep;
start_receive = true;
}
}
}
import std.concurrency;
void main()
{
auto mq = new shared MsgQueue;
spawn( &receiver, mq );
foreach( i; 0 .. 10 )
spawn( &sender, mq, format( "%d", i ) );
writeln( "main finish" );
}
Так же в стандартной библиотеке D есть реализация «зелёных» потоков (это я на всякий случай), документация на офф.сайте.