среда, 27 ноября 2013 г.

[prog.c++11] std::function и лямбды дают возможность к применению ФПшных приемов

Допустим, нам нужно передать куда-то callback-функцию с тем, чтобы кто-нибудь ее в какой-то момент времени вызвал. Если эта функция не нуждается в специальном контексте, то все просто: достаточно передать простой указатель на функцию. Но такой вырожденный случай не интересен, обычно требуется, чтобы функция была связана с каким-то контекстом. Но как передать этот контекст?

Процедурный подход, практикуемый в C, подразумевает использование дополнительного аргумента для функции. Как правило, этот аргумент имеет тип void* и внутри callback-функции приводится к нужному типу. В качестве примера того, как это делается, если кто-то не понимает, о чем речь, можно посмотреть на POSIX-овую функцию pthread_create.

Объектно-ориентированный подход позволяет избавиться от передачи дополнительного аргумента. Вместо указателя на функцию вводится какой-то интерфейс с виртуальным методом. Пользователь определяет класс-наследник, реализует этот виртуальный метод, создает объект этого класса и передает указатель на него в качестве callback-объекта. Тут необходимый callback-у контекст может сохраняться внутри callback-объекта: ведь это обычный класс, у него могут быть свои атрибуты, конструктор-деструктор и пр. Насколько я помню, этот подход нашел очень широкое применение в Java, где в некоторых фреймворках определялись кучи всяческих Listener-интерфейсов со всего одним методом.

Сторонники Функционального Программирования (ФП), в которых комбинирование функций, являющихся к тому же замыканиями, зачастую насмехаются над C++ и Java разработчиками. Ведь им не нужно городить огород с декларациями интерфейсов и их реализацией. Достаточно просто объявить прототип функции. А потом передать в качестве callback-а лямбда-функцию (или что-то посложнее, вроде результата карринга). Что, действительно, гораздо лаконичнее и понятнее.

Защищаясь мы, приверженцы ОО-стиля, любили говорить, что если где-то ждут функцию с прототипом void(void), то туда можно одинаково легко передать как void say_hello(), так и void format_disk_c(), так и void launch_nuclear_missles(). Но, конечно, тихо завидовали функциональщикам, которые могли писать меньше кода :)

Однако, долго вынашиваемый и не так давно принятый стандарт C++11 дает и нам теперь возможность определять callback-и в почти что функциональном стиле. Ведь лямбда-функции в C++11 являются замыканиями. И сохранить на длительное время нужный контекст внутри лямбда функции можно, например, посредством std::shared_ptr.

Под катом небольшой пример, показывающий, как вернуть из функции указатель на другую функцию (через std::function), в которой будет сохранен указатель на нужный контекст.

Здесь handler_t -- это и есть то самый контекст, который должен быть задействован внутри callback-а. В его конструктор и деструктор вставлены отладочные печати для того, чтобы можно было видеть, когда этот контекст создается, а когда разрушается.

Тип callback-функции определяется typedef-ом action_t -- это синоним для void(const std::string&).

В примере создается два варианта callback-ов. Первый, с задействованием контекста, формируется функцией make_demo_action(). В качестве второго callback-а используется простая функция simple_action.

Указатели на callback-и (точнее говоря, ссылки на std::function) передаются в initiate_action(). Это имитация места, где callback задействуется. В реальной жизни что-то похожее на initiate_action будет запрятано в дебри какого-нибудь фреймворка или прикладного кода.

Сама демонстрация происходит внутри функции demo(). Сначала создается первый, долгоживущий callback. Потом в цикле создается несколько короткоживущих callback-ов разных типов (попеременно callback-и с контекстом и без оного).

#include <iostream>
#include <memory>
#include <string>
#include <functional>

class handler_t
{
   public :
      handler_t()
      {
         std::cout << "handler ctor" << std::endl;
      }
      ~handler_t()
      {
         std::cout << "handler dtor" << std::endl;
      }

      void
      handle( const std::string & a )
      {
         std::cout << "handler::handle(" << a << ")" << std::endl;
      }
};

typedef std::function< void(const std::string &) > action_t;

action_t
make_demo_action()
{
   auto h = std::make_shared< handler_t >();

   return [h]( const std::string & a ) { h->handle( a ); };
}

void
simple_action( const std::string & a )
{
   std::cout << "simple_action(" << a << ")" << std::endl;
}

void
initiate_action( const action_t & action, const std::string & arg )
{
   action( arg );
}

void
demo()
{
   auto long_lived = make_demo_action();
   initiate_action( long_lived, "long-lived-1" );

   std::cout << "--- cycle start ---" << std::endl;
   forint i = 0; i < 3; ++i )
   {
      initiate_action( make_demo_action(), "short-lived" );
      initiate_action( simple_action, "function-pointer" );
   }
   std::cout << "--- cycle end ---" << std::endl;
}

int
main()
{
   try
   {
      demo();

      return 0;
   }
   catchconst std::exception & x )
   {
      std::cerr << "Oops! Exception: " << x.what() << std::endl;
   }

   return 1;
}

В результате работы примера на стандартный поток выводится следующее:

handler ctor
handler::handle(long-lived-1)
--- cycle start ---
handler ctor
handler::handle(short-lived)
handler dtor
simple_action(function-pointer)
handler ctor
handler::handle(short-lived)
handler dtor
simple_action(function-pointer)
handler ctor
handler::handle(short-lived)
handler dtor
simple_action(function-pointer)
--- cycle end ---
handler dtor

По отладочным печатям видно, когда именно создаются и разрушаются объекты handler_t, служащие контекстом для одного из типов callback-ов. Видно, что контекст спокойно живет и после выхода из функции, его создавшей. Благодаря std::shared_ptr.

Эта демонстрация показывает, что теперь и в C++ можно достаточно лаконично определять типы callback-ов виде функций. И задействовать в качестве callback-ов как простые функции, так и более сложные объекты с контекстом. Например, теперь я могу сделать в SObjectizer систему нотификаций вот так:

typedef std::function< void(so_environment_t&, const std::string&) >
   notificator_t;

class agent_coop_t
{
   public :
      ...
      void
      add_notificator( const notificator_t & notificator );
};

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

class notificator_t
{
   public :
      virtual ~notificator_t();

      virtual void
      notify( so_environment_t & env, const std::string & name ) = 0;
};

class agent_coop_t
{
   public :
      ...
      void
      add_notificator( std::unique_ptr< notificator_t > notificator );
};

Замечание 1. Вообще-то говоря, использование замыканий в качестве callback-ов не является ключевым отличием ФП от ООП. В том же Ruby (а так же его предтече -- SmallTalk) есть такая штука, как блоки кода. Которые выглядят как функции, но на самом деле это просто удобный синтаксис для создания объекта, у которого вызывается один метод. Т.е. то, что в Java и C++ раньше приходилось делать вручную, в Ruby делалось неявно интерпретатором языка. Да и в Eiffel-е, который ну совсем уж ОО язык, есть похожее понятие агента.

Замечание 2. Насколько я помню, подобный подход в C++ можно было применять и до принятия C++11. Но для этого нужно было задействовать тяжеловесные шаблонные библиотеки из Boost-а (ЕМНИП, Boost.Function и Boost.Lambda). Но внешне Boost.Lambda выглядела довольно коряво. Да и сам Boost не всегда хотелось тянуть в свои проекты. Таки да, религия не позволяла :) Но зато теперь std::function и лямбды являются частью языка, поэтому, если есть возможность использовать более-менее современные версии компиляторов (хотя бы на уровне MSVC++2010), то с таким подходом нет проблем.

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