Скрытые сокровища стандартной библиотеки [перевод]

В этот раз я решил перевести для вас достаточно скромную статью за авторством Гарри Уилоби (автор блога nomad.so и создатель TkD), которую я так давно давно хотел выложить, но все не мог найти для этого временнЫе ресурсы…

И вот время перевода пришло…

Все то, что будет описано далее приводится практически без изменений, а также, возможно, несколько устарело, однако передаю слово автору статьи…

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

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

std.functional

Этот модуль реализует функции, которые оперируют другими функциями. Полагаю, что именно здесь D реализует библиотечные функции для учета некоторых аспектов функционального программирования.

memoize

Мемоизация — это метод оптимизации, который используется главным образом для ускорения работы компьютерных программ путем кэширования результатов дорогостоящих вызовов функций и возврата кэшированных результатов, когда одни и те же входные данные повторяются. Шаблон memoize, доступный в стандартной библиотеке, позволяет это осуществить.

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

ulong fib(ulong n)
{
    return n < 2 ? 1 : fib(n - 2) + fib(n - 1);
}
 
void main()
{
    foreach (x; 1 .. 45)
    {
        writefln("%s", fib(x));
    }
}

Если бы я запустил это на своем компьютере, то программа завершилась бы примерно через 13 секунд, что не очень хорошо. Так происходит потому, что функция fib перерасчитывает значения при каждом своем запуске. Используя шаблон memoize, мы можем кэшировать значения процедур, а не перерасчитывать их снова. Такое кэширование предполагает, что для любых заданных допустимых аргументов функция будет возвращать точно такой же результат.

Вот мемоизированная версия программы:

ulong fib(ulong n)
{
    alias mfib = memoize!(fib);
    return n < 2 ? 1 : mfib(n - 2) + mfib(n - 1);
}
 
void main()
{
    foreach (x; 1 .. 45)
    {
        writefln("%s", fib(x));
    }
}

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

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

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

std.parallelism

Этот модуль реализует высокоуровневые примитивы для SMP-параллелизма.

К ним относятся: параллельный foreach, параллельный reduce, параллельное энергичный map, конвейеризация и future/promise-параллелизм. Этот модуль рекомендуется в том случае, когда одна и та же операция должна выполняться параллельно для разных данных или когда функция должна выполняться в фоновом потоке, а ее результат возвращается в четко определенный основной поток.

parallel

На самом деле это вспомогательная функция, которая передает функцию в taskPool.parallel (которая находится в том же модуле) и целью которой является создание параллельного цикла foreach. Распараллеливание цикла foreach означает, что потенциально каждая итерация может выполняться асинхронно в разных потоках.

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

Вот пример то, что выиграло бы от параллельного вычисления:

import std.parallelism;
import std.range;
 
ulong fib(ulong n)
{
    return n < 2 ? 1 : fib(n - 2) + fib(n - 1);
}
 
void main()
{
    ulong[45] sequence;
 
    foreach (x; iota(sequence.length))
    {
        sequence[x] = fib(x);
    }
}

Снова мы используем наш ужасно неэффективный генератор последовательности Фибоначчи для заполнения элементов массива. При запуске этой программы на моем компьютере требуется около 13 секунд и она использует только один поток (на одном ядре процессора).

Чтобы распараллелить цикл, мы просто используем parallel вот так:

import std.parallelism;
import std.range;
 
ulong fib(ulong n)
{
    return n < 2 ? 1 : fib(n - 2) + fib(n - 1);
}
 
void main()
{
    ulong[45] sequence;
 
    foreach (x; parallel(iota(sequence.length), 1))
    {
        sequence[x] = fib(x);
    }
}

Обратили внимание на второй аргумент для parallel?

Этот аргумент указывает количество рабочих единиц (то есть последовательных элементов, передаваемых каждому потоку для обработки). Я установил его в 1, чтобы указать, что я хочу, чтобы каждый элемент запускался независимо в своем собственном потоке. Меньшие рабочие блоки обеспечивают лучшую балансировку нагрузки, но большие рабочие блоки избегают накладных расходов на связь с другими потоками. Чем меньше времени занимает одна итерация цикла, тем больше должен быть рабочий блок.

Для очень нагруженных тел циклов размер блока должен быть 1.

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

std.random

Это модуль реализует средства для генерации случайных чисел.

dice

Эта функция предоставляет способ генерации случайных чисел с использованием взвешивания.

Вгляните на следующий пример:

import std.random;
import std.stdio;
 
void main()
{
    auto weights = [70, 20, 10];
 
    foreach (x; 0 .. 100)
    {
        auto number = dice(weights);
 
        writefln("%s", number);
    }
}

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

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

std.typecons

Этот модуль реализует различные конструкторы типов, то есть шаблоны, которые позволяют создавать новые полезные типы общего назначения.

Flag

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

Когда вы работаете над кодом, нет ничего хуже, чем открыть файл и посмотреть на нечто, подобное этому:

auto foo = bar.baz(true, false, false);

Что именно означают три логических аргумента? Они означают, что вы должны посмотреть на определение этого метода, чтобы понять их значение, д`оу! В D введены флаги, чтобы избежать этой неприятности, посредством документирования значения флага на месте его использования.

Вот так это работает:

import std.stdio;
import std.typecons;
 
void foo(int max, Flag!("showOutput") showOutput)
{
    foreach (value; 0 .. max + 1)
    {
        if (showOutput)
        {
            writefln("%s", value);
        }
    }
}
 
void main()
{
    foo(10, Flag!("showOutput").yes);
}

Обратите внимание, что вторым параметром функции foo является флаг. Строка флага определяет его значение во многом как переменной. Обычно такой параметр определяется как логическое значение, но с помощью флагов мы можем облегчить его понимание. В качестве бонуса, флаг может быть проверен так же, как и логическое значение. Еще одно преимущество заключается в том, что поскольку синтаксис флага является слишком многословным для места использования, существует несколько вспомогательных структур, чтобы сделать его использование более наглядным.

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

foo(10, Yes.showOutput);

Разве вы не согласны, что это намного приятнее, чем угадывать, что означают логические аргументы?

Proxy

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

Вот пример, где я заставляю структуру Foo вести себя так, как будто она является целочисленным типом:

import std.typecons;
 
struct Foo
{
    private int foo;
 
    mixin Proxy!(this.foo);
 
    this(int n)
    {
        this.foo = n;
    }
}
 
void main()
{
    Foo type = 10;
 
    assert(type * 2 == 20);
    assert(++type == 11);
}

Обратите внимание на то, что теперь операторы работают ?

А теперь пример чуть посложнее, который использует внутренний строковый массив:

import std.array;
import std.typecons;
 
struct Bar
{
    private string[] bar;
 
    mixin Proxy!(this.bar);
 
    this(string[] r)
    {
        this.bar = r;
    }
}
 
void main()
{
    Bar range = ["Lorem", "ipsum", "dolor"];
    range ~= "sit";
    range ~= "amet";
 
    foreach (index, value; range)
    {
        assert(range[index] == value);
    }
 
    assert(range.join(" ") == "Lorem ipsum dolor sit amet");
}

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

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

RefCounted

Эта структура является оберткой для создания объектов с подсчетом ссылок. Обернутый тип автоматически выделяется с помощью malloc и структура отслеживает все ссылки на него. Когда счетчик ссылок достигает нуля (то есть все ссылки на объект уничтожаются), объект освобождается с помощью free.

Вот пример:

import std.typecons;
 
void main()
{
    auto foo = RefCounted!(string)("Lorem");
 
    // The reference count is one (foo).
    assert(foo.refCountedStore.refCount == 1);
 
    {
        // Create a new reference to foo.
        auto bar = foo;
        assert(foo.refCountedStore.refCount == 2);
 
        // Reference semantics also change foo's value.
        bar = "ipsum";
 
        // bar reference is destroyed upon leaving scope.
    }
 
    // The reference count is one again (foo).
    assert(foo.refCountedStore.refCount == 1);
    assert(foo == "ipsum");
 
    // Foo is destroyed and string freed.
}

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

А теперь несколько слов об осторожности…

RefCounted не работает с классами и в настоящее время считается небезопасным и должен использоваться с предельной аккуратностью.  Т.е. никакие ссылки на полезную нагрузку не должны выходить за пределы объекта, или вы не должны уничтожать объект, используя его.

Но даже с учетом всего сказанного, я все еще думаю, что это довольно круто и ценно.

scoped

Это еще одно приятное дополнение для тех, кто любит контролировать, что происходит в памяти. В отличие от RefCounted, который нельзя использовать с классами, scoped существует исключительно для их обслуживания. Идея этого шаблона заключается в том, чтобы избежать зависимости от нового ключевого слова и автоматического распределения классов в куче. Вместо этого шаблон создает экземпляр класса в текущей области видимости и в стеке.

Вот пример:

import std.typecons;
 
class Foo
{
    public int bar;
 
    this(int n)
    {
        this.bar = n;
    }
}
 
void main()
{
    auto foo = scoped!(Foo)(42);
 
    assert(foo.bar == 42);
 
    // foo is destroyed upon leaving scope
    // and immediately deallocated.
}

Здесь foo размещается в стеке и используется так же, как и любой другой экземпляр объекта. Эффект от действия scoped состоит в том, что объект будет немедленно уничтожен после выхода из области действия, в которой он был создан. Это обеспечивает то, что известно сейчас как детерминированное уничтожение для объектов на основе классов, и в результате позволяет реализовать такие идиомы, как RAII.

Итоги

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

Перейдите на сайт dlang.org (в раздел Library Reference) и узнайте больше о каждом из модулей и убедитесь сами в привлекательности и производительности современного языка системного программирования, такого как D.

Добавить комментарий