Продолжаю озвучивать впечатления от 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{2, 1}).then(
[=](int r2) {
assert(r2 == 1);
}
);
|
ИМХО, здесь опять мы наступаем на грабли того, что статически типизированный C++ с необходимостью явной декларации типов -- это нифига не динамически типизированный Erlang. В котором запись:
Testee !{minus, 2, 1}
receive
Result -> ?assert( Result =:= 1 )
end
|
Выглядит просто, лаконично и понятно. Тогда как в C++ном варианте, предложенном в libcppa, все это замусоривается лишними телодвижениями, вынужденно необходимыми в C++. Но это только лишь из-за того, что в C++ попытались эмулировать Erlang. Если бы этого не делали, можно было бы придумать и изобразить подобный запрос следующим образом:
int r2 = self->expect<int>()->sync_send(testee, minus_request{2, 1});
assert(r2 == 1);
...
|
Я, конечно, понимаю, что вариант libcppa более универсален, и в then можно передать не один функциональный объект, а несколько для разных вариантов ответа (afaik, это так лишь для динамически типизированных агентов, у статически типизированных ответ всего один). Но, что-то мне подсказывает, что в большинстве случаев синхронные запросы делают для того, чтобы получить один вполне конкретный ответ. И именно этот сценарий должен быть предоставлен пользователю в наиболее удобном виде.
Как-то вот так. Общее впечатление, что изучая libcppa, ты изучаешь, по крайней мере, две вещи. Во-первых, библиотеку диспетчеризации событий и обмена сообщениями. Со своими тараканами особенностями и ограничениями. Во-вторых, отдельный хитрый DSL, накрученный вокруг этой библиотеки. Или даже несколько диалектов этого DSL, т.к. есть особенности записи guard-ов. Хотя хотелось бы изучать всего одну целостную вещь :(
Чуть-чуть больше слов на тему различий в подходах C++ и Erlang касательно туплов, паттерн-матчинга и динамической типизации я написал на sources.ru (disclaimer для Erlang-еров: про инструкцию -record я не знал, поэтому написал, что в Erlang-е структур нет).