вторник, 13 января 2015 г.

[prog.c++17] Это только мне сложно воспринимать более-менее реальный код с await?

В C++17 предлагают добавить еще средств для поддержки асинхронности. Например, в виде ключевого слова await. На isocpp.org недавно проскочила ссылка на статью с демонстрацией якобы удобства await в более-менее приближенном к реальности коде из области игростроения: Await, coroutines, what could that bring for game development. Там же, рядом со статьей, есть код демонстрационного проекта, можно скачать посмотреть повнимательнее.

Так вот, толи лыжи реально не едут, толи я идиот, но что-то при попытке разобраться в деталях, что и как будет работать, какой-то простоты при использовании await и короутин заметить не удалось :(

Единственное место, в котором задействовано новое ключевое слово __await из Visual Studio 2015, это вот эта функция, определяющая игровую логику:

GameAwaitableSharedPromise<void> gameLogic(Engine* engine) {
   auto animatedText = std::make_shared<AnimatedText>();
   engine->addSceneObject(animatedText);
   std::default_random_engine re((unsigned int)(std::chrono::steady_clock::now().time_since_epoch().count()));
   std::uniform_real_distribution<float> dist(0.0f0.7f);
   while (true) {
      __await animatedText->fadeIn();
      __await engine->waitForMouseClick();

      __await animatedText->fadeOut();
      engine->changeBackground(DirectX::XMFLOAT4(dist(re), dist(re), dist(re), 1.0f));

      __await engine->waitFor(duration_cast<steady_clock::duration>(.5s));
   }

}

Автор статьи утверждает, что подобный код легче воспринимать, чем код, написанный в стиле конечных автоматов. Попробовал разобраться. Что-то как-то не верится.

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

Как я понял, автор ведет к тому, что запустив все эти действия в виде короутин, которые где-то как-то будут исполнятся, можно не блокировать нить, на которой крутится главный цикл функции gameLogic(), что позволит обрабатывать обычные Windows-сообщения (например, о нажатии на кнопку мыши, об изменении размеров окна, о необходимости перерисовки). А так же выполнять дополнительные действия, которые должны выполняться в игре (например, перемещать персонажей на экране).

В теории все это представляется красиво. Наверное.

Но вот попытавшись разобраться в деталях, начинает казаться, что благими намерениями таки вымощена дорога в ад спагетти-кода и излишней связности программных сущностей :(

Например, в исходниках обнаруживается, что в приложении есть старый-добрый цикл обработки Windows-сообщений. Да-да, тот самый while с PeekMessage(), TranslateMessage() и DispatchMessage() внутри. А еще голая WndProc, коих я лично не видел уже очень и очень давно :)

Далее выясняется, что для поддержки "фоновой" активности игры внутри цикла обработки сообщений регулярно дергается метод run() некоторого Engine. А потом выясняется, что этот Engine заточен под конкретную бизнес-логику, что выражается, например, в наличии методов waitForMouseClick() и changeBackground(). Более того, и главный цикл обработки Windows-сообщений так же плотно завязан на Engine, т.к. при получении WM_LBUTTONDOWN нужно дергать метод onClick(), который должен привести к запуску короутин, завязанных на ожидание клика мышкой.

Код в run() знает, что у него есть таймеры, которые должны отсчитывать свое время, есть игровые объекты, состояние которых должно обновляться, а потом еще что-то должно перерисовываться:

void run() {
   _clock.onBeginNewFrame();
   for (auto it = _activeTimers.begin(); it != _activeTimers.end(); ) {
      auto next = it;
      ++next;
      if (it->onTick(_clock.currentFrameTime())) {
         _activeTimers.erase(it);
      }

      it = next;
   }
   for (auto& obj : _sceneObjects) {
      obj->updateState(_clock);
   }
   _ctx->ClearRenderTargetView(_rtv.Get(), (float*)&_bgColor);
   _ctx->OMSetRenderTargets(1, _rtv.GetAddressOf(), nullptr);
   UINT vpCount = 1;
   D3D11_VIEWPORT vp;
   _ctx->RSGetViewports(&vpCount, &vp);
   for (auto& obj : _sceneObjects) {
      obj->draw(_ctx.Get(), _rtv.Get());
   }
   _swapchain->Present(00);
}

Тот самый анимированный текст, который должен появляться и исчезать, он и есть один из игровых объектов, для которых внутри run() вызывается updateState(). А метод updateState() для AnimatedText превращается во что-то вроде:

void AnimatedText::updateState(const GameClock & clock)
{
   if (_opacityAnim) {
      bool animEnded;
      _opacity = _opacityAnim->update(clock.lastFrameDuration(), animEnded);
   }
   if (_transformAnim) {
      bool animEnded;
      _transform = _transformAnim->update(clock.lastFrameDuration(), animEnded);
   }
}

Где конкретный update может выглядеть как-то вот так (если я правильно разобрался в исходниках):

T update(const std::chrono::steady_clock::duration& ellapsed, bool& ended) {
   if (_ellapsed >= _duration) {
      ended = true;
      return _endValue;
   }
   _ellapsed += ellapsed;
   if (_ellapsed >= _duration) {
      ended = true;
      // the animation just ended, raise the coroutine completion callback
      _promise.setResult();
   }
   else {
      ended = false;
   }
   float progress = ((float)_ellapsed.count()) / ((float)_duration.count());
   if (progress > 1) {
      progress = 1;
   }
   progress = _interpolation(progress);
   return lerp(_startValue, _endValue, progress);
}

Т.е. конкретный update -- это не что иное, как тот самый конечный автомат с состояниями и одним входным воздействием.

Что еще смущает: всем этим SceneObject-ам нужно вести отсчет времени, для чего в метод updateState() передается ссылка на GameClock. А ведением этого GameClock() занимается Engine. Т.е. между SceneObject-ами и Engine устанавливается еще одна довольно жесткая связь.

Ну а поскольку метод run() для Engine вызывается из цикла обработки Windows-сообщений, то и сам Engine, и принадлежащий ему GameClock -- суть те же самые конечные автоматы.

Получается, что внутри игры все самые важные объекты есть КА, но в одном месте действия части КА оформляются посредством асинхронных операций с короутинами. Якобы для того, чтобы бизнес-логика выглядела "прозрачно". Для чего КА должны выставлять наружу некие GameAwaitablePromise. Т.е. все сущности внутри игры оказывают связаны не только знаниями об Engine и GameClock, но еще и об GameAwaitablePromise.

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

Но, т.к. я, с большой вероятностью, ошибаюсь, хотелось бы услышать: а есть ли среди C++ников (и не только C++ников), которые читали эту статью и смотрели этот код, люди, которые согласны с автором статьи? Что такое использование await действительно улучшает код и упрощает разработку?

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