пятница, 4 июля 2014 г.

[prog.c++] Еще на тему libcppa

Продолжаю озвучивать впечатления от libcppa (предыдущие части: #1, #2).

libcppa -- это фреймворк, реализующий actor model а-ля Erlang в C++. И вот эта попытка максимально близко скопировать Erlang в C++ приводит к коду, который лично мне представляется странным и избыточным. Видимо, все дело в том, что туплы в Erlang -- это основной механизм структурирования данных. А pattern-matching -- это главный механизм декомпозиции туплов на составляющие. Поэтому сочетание конструкций receive с паттерн-матчингом в Erlang-е выглядит лаконично и естественно.

Кроме того, туплы -- это структурная эквивалентность. Т.е. если у меня есть {0,1} и {3,5}, то я не могу судить -- относятся ли эти значения к экземплярам разных типов или нет. Может быть первый обозначает IP-адрес и порт, а второй секунды и микросекунды? Для решения этой проблемы в Erlang-е ввели дискриминанты под названием атомы. Что стало позволять записывать туплы с указанием "типовой принадлежности": {address, 0, 1} и {timeval, 3, 5}. Однако, в императивных языках, вышедших из Algol/Simula, нет необходимости в таком отдельном дискриминанте таковым там является само имя типа. Т.е. то, что в Erlang-е из-за отсутствия в языке struct записывается как {address, 0, 1}, в C++ записывается как address(0, 1). Поэтому очень странным для C++ выглядит попытка перенести в C++ из Erlang-а понятие атома, да еще в виде строки (размер которой ограничен 10 символами, а содержимое -- только определенным набором символов).

Если же попробовать объединить в C++ попытку притащить из Erlang-а паттерн-матчинг вместе с имитацией атомов, то результат получается слишком многословным. Вот, например, фрагмент кода из примера dining_philosophers.cpp:

// either taken by a philosopher or available
void chopstick(event_based_actor* self) {
    self->become(
        on(atom("take"), arg_match) >> [=](const actor& philos) {
            // tell philosopher it took this chopstick
            self->send(philos, atom("taken"), self);
            // await 'put' message and reject other 'take' messages
            self->become(
                // allows us to return to the previous behavior
                keep_behavior,
                on(atom("take"), arg_match) >> [=](const actor& other) {
                    self->send(other, atom("busy"), self);
                },
                on(atom("put"), philos) >> [=] {
                    // return to previous behaivor, i.e., await next 'take'
                    self->unbecome();
                }
            );
        }
    );
}

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

        on(atom("take"), arg_match) >> [=](const actor& philos) {

излишне избыточна. Более того, она требует дополнительного внимания, т.к. глядя на arg_match я затем должен еще и посмотреть на параметр лямбды для того, чтобы понять, что же такое этот аргумент на самом деле. Отказавшись от атомов и попытки эмулировать паттерн-матчинг в стиле Erlang-а тот же самый пример можно было бы переписать вот так (Upd. Т.к. такая запись несовместима с системой guard-ов, то и от методов on можно совсем отказаться):

// Atoms in C++ style.
struct take {};
struct taken {};
struct busy {};
struct put {};

// either taken by a philosopher or available
void chopstick(event_based_actor* self) {
    self->become(
        [=](take, const actor& philos) {
            // tell philosopher it took this chopstick
            self->send(philos, taken(), self);
            // await 'put' message and reject other 'take' messages
            self->become(
                // allows us to return to the previous behavior
                keep_behavior,
                [=](take, const actor& other) {
                    self->send(other, busy(), self);
                },
                [=](put, const actor&) {
                    // return to previous behaivor, i.e., await next 'take'
                    self->unbecome();
                }
            );
        }
    );
}

Код актора chopstick стал лаконичнее, меньше отвлекающих внимания факторов, быстрее вникаешь в код. Плюс, компилятор сам бьет по рукам, если вместо take по ошибке напишешь taike.

Следующий момент, который напрягает при изучении кода акторов в libcppa, это тот факт, что код внутри конструкций become -- это определение реакции на какое-то событие в будущем. Т.е. то, что написано внутри become -- это то, что будет исполнено не прямо сейчас, а когда-то в будущем, когда актор получит соответствующее сообщение. Это несколько запутывает, когда ты видишь become, вложенный в другой become. Как в примере выше. Вложенный become исполнится не тогда, когда агент выполняет обработку запроса take, т.е. не сразу после self->send(philos,atom("taken"),self). Поэтому, если после вложенного become написать еще какие-то действия, то время выполнения этих действий может стать сюрпризом для новичка:

void chopstick(event_based_actor* self) {
    self->become(
        on(atom("take"), arg_match) >> [=](const actor& philos) {
            // tell philosopher it took this chopstick
            self->send(philos, atom("taken"), self);
            // await 'put' message and reject other 'take' messages
            self->become(
               /* SOME CODE SKIPPED */
            );
            std::cout << "after nested become" << std::endl;
        }
    );
}

Отладочная печать будет выполнена сразу при обработке запроса take, а не после всех действий, которые определены во вложенном become. Конечно, к этой особенности привыкаешь. Но, поначалу, пониманию кода это не способствует.

Еще одна штука, которая напрягает в libcppa -- это получение и обработка ответа на синхронный запрос. Выглядит это так (причем это еще лаконичный вариант для статически типизированных акторов, для динамически типизированных еще многословнее):

self->sync_send(testee, minus_request{21}).then(
    [=](int r2) {
        assert(r2 == 1);
    }
);

ИМХО, здесь опять мы наступаем на грабли того, что статически типизированный C++ с необходимостью явной декларации типов -- это нифига не динамически типизированный Erlang. В котором запись:

Testee !{minus21}
receive
   Result -> ?assertResult =:= 1 )
end

Выглядит просто, лаконично и понятно. Тогда как в C++ном варианте, предложенном в libcppa, все это замусоривается лишними телодвижениями, вынужденно необходимыми в C++. Но это только лишь из-за того, что в C++ попытались эмулировать Erlang. Если бы этого не делали, можно было бы придумать и изобразить подобный запрос следующим образом:

int r2 = self->expect<int>()->sync_send(testee, minus_request{21});
assert(r2 == 1);
...

Я, конечно, понимаю, что вариант libcppa более универсален, и в then можно передать не один функциональный объект, а несколько для разных вариантов ответа (afaik, это так лишь для динамически типизированных агентов, у статически типизированных ответ всего один). Но, что-то мне подсказывает, что в большинстве случаев синхронные запросы делают для того, чтобы получить один вполне конкретный ответ. И именно этот сценарий должен быть предоставлен пользователю в наиболее удобном виде.

Как-то вот так. Общее впечатление, что изучая libcppa, ты изучаешь, по крайней мере, две вещи. Во-первых, библиотеку диспетчеризации событий и обмена сообщениями. Со своими тараканами особенностями и ограничениями. Во-вторых, отдельный хитрый DSL, накрученный вокруг этой библиотеки. Или даже несколько диалектов этого DSL, т.к. есть особенности записи guard-ов. Хотя хотелось бы изучать всего одну целостную вещь :(

Чуть-чуть больше слов на тему различий в подходах C++ и Erlang касательно туплов, паттерн-матчинга и динамической типизации я написал на sources.ru (disclaimer для Erlang-еров: про инструкцию -record я не знал, поэтому написал, что в Erlang-е структур нет).

Комментариев нет: