Одна из задач, которые приходится решать при разработке системы потокового вещания — как правильно организовать передачу информации внутри системы.
0. Проблема
Для потокового вещания общепринятой является архитектура, согласно которой система состоит из отдельных модулей-фильтров, которые обмениваются сообщениями, в простейшем случае — в одном направлении, от источника данных через разнообразные фильтры в приёмники данных. Например, источником данных может являться спутниковый ресивер, фильтрами — декодер, кодер, а приёмник — модуль воспроизведения мультимедиа, или плейер. Таким образом, структурно система стриминга представляет собой граф (обычно однонаправленный), вершинами которого являются объекты-обработчики, а по рёбрам следуют объекты-сообщения.
Как видно из описания, объекты-обработчики, или “фильтры”, наиболее естественно могут быть представлены в виде иерархии полиморфных классов, имеющих общего предка, единственной задачей которого будет приём и обработка пакетов медиаданных:
1 2 3 4 5 6 | class Message; class Filter { public: virtual void process(Message *) = 0; }; |
Из этого базового класса мы можем вывести в дальнейшем конкретные классы, обеспечивающие нужную нам функциональность:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Demuxer : public Filter { public: virtual void process(Message *); }; class VideoDecoder : public Filter { public: virtual void process(Message *); }; class MediaPlayer : public Filter { public: virtual void process(Message *); }; |
и т.д. Вся полезная работа фильтров будет содержаться в методе process. Здесь неподходящее место, чтобы размещать код, например, видеодекодера, но псевдокод может выглядеть так:
1 2 3 4 | void VideoDecoder::process(Message *msg) { Message *decoded_msg = decode_somehow(msg); send_downstream(decoded_msg); }; |
Затем мы создадим объекты-экземпляры этих классов, объединим их в конвейер, подключим источник сигнала — и медиастример готов!
В этот момент разработчик обнаруживает, что не всё так просто. Что представляет собой объект Message? Пакет оцифрованных данных аудио или видео, кодированных или нет, а, может быть, это какая-то управляющая информация, которую тоже удобно передавать по графу наравне с данными? Уже с самого начала можно предположить, что тот Message, который получает на вход VideoDecoder, есть совсем не то, что можно было бы обработать, например, объектом AudioDecoder, а то, что подходит для последнего, не годится для MediaPlayer.
Получается, что объекты сообщений — это объекты разных классов. Но наш граф умеет передавать внутри себя только объекты Меssage? Не проблема — объявим все классы различных сообщений как производные от Message! Всю реальную работу, как это водится, скроем в виртуальных методах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Message { public: virtual void send_to(Filter *) = 0; }; class VideoPacket : public Message { public: virtual void send_to(Filter *); }; class AudioPacket : public Message { public: virtual void send_to(Filter *); }; |
и так далее, для всех возможных пакетов данных и управляющей информации, которая будет проходить в нашем медиастримере.
На первый взгляд, всё достаточно очевидно. Однако обнаруживается, что у нас теперь две иерархии классов, каждая из которых имеет виртуальный метод. В каком из классов определять, как что обрабатывать? Как в объекте MediaPlayer обработать VideoPacket и как в нём же по-другому обработать AudioPacket? Как различить все возможные пакеты сообщений во всех возможных фильтрах?
В такой постановке эта проблема объектно-ориентированного программирования известна как “double dispatch” – двойная диспетчеризация.
1. Наивное решение
Простое и неэффективное решение — сосредоточить всю логику в классе Filter в методе process, определяя фактический тип пришедшего сообщения при помощи механизма RTTI:
1 2 3 4 5 | void VideoDecoder::process(Message *msg) { if ( VideoPacket *pkt = dynamic_cast<VideoPacket *>(msg) ) { // decode_packet(pkt); } } |
Вроде бы неплохо. Но представим обработчик, который может получать несколько различных команд, например, MediaPlayer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void MediaPlayer::process(Message *msg) { if ( RawVideoPacket *pkt = dynamic_cast<RawVideoPacket *>(msg) ) { // } else if ( RawAudioPacket *pkt = dynamic_cast<RawAudioPacket *>(msg) ) { // } else if ( StopCommand *pkt = dynamic_cast<StopCommand *>(msg) ) { // } else if ( PauseCommand *pkt = dynamic_cast<PauseCommand *>(msg) ) { } else if ( SeekCommand *pkt = dynamic_cast<SeekCommand *>(msg) ) { } // ещё 100500 строк безобразия } |
Очевидно, что так разрабатывать нельзя.
2. Классический double dispatch при помощи паттерна Visitor
Определим в базовом классе обработчика методы для обработки каждого из конкретных потомков Message:
1 2 3 4 5 6 7 8 9 10 11 | class Filter { public: virtual void process(RawVideoPacket *) {} virtual void process(RawAudioPacket *) {} virtual void process(StopCommand *) {} virtual void process(PauseCommand *) {} virtual void process(SeekCommand *) {} virtual void process(PlayCommand *) {} // ... }; |
Здесь существенно вот что: базовый класс знает обо всех возможных разновидностях команд/сообщений, которые могут ему прийти. В этом случае диспетчеризация происходит следующим образом.
Каждый конкретный класс сообщения содержит метод, вызывающий правильный метод обработчика:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class Message { public: virtual void send_to(Filter *) = 0; }; class RawVideoPacket : public Message { public: virtual void send_to(Filter *f) { f->process(this); // здесь будет вызван Filter::process(RawVideoPacket*) } }; class RawAudioPacket : public Message { public: virtual void send_to(Filter *f) { f->process(this); // здесь будет вызван Filter::process(RawAudioPacket*) } }; class PlayCommand : public Message { public: virtual void send_to(Filter *f) { f->process(this); // здесь будет вызван Filter::process(PlayCommand*) } }; |
…и так далее, для всех производных от Message классов.
В свою очередь, в класс Filter добавим обработчик базового Message:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | class Filter { public: virtual void process(RawVideoPacket *) {} virtual void process(RawAudioPacket *) {} virtual void process(StopCommand *) {} virtual void process(PauseCommand *) {} virtual void process(SeekCommand *) {} virtual void process(PlayCommand *) {} virtual void process(Message *msg) {} // ... virtual void dispatch(Message *msg) { msg->send_to(this); // здесь конкретный класс, производный от Message, // выполнит правильную диспетчеризацию // в зависимости от собственного реального типа. } }; |
Для сокращения записи можно было бы даже использовать шаблон:
1 2 3 4 5 6 | template<class TYPE> class Visited : public Message { public: virtual void send_to(Filter *f) { f->process(static_cast<TYPE*>(this)); } }; |
и дальше просто писать:
1 2 3 | class RawVideoPacket : public Visited<RawVideoPacket> { /* ... */ } class RawAudioPacket : public Visited<RawAudioPacket> { /* ... */ } class PlayCommand : public Visited<PlayCommand> { /* ... */ } |
и так далее.
Недостаток здесь один, но большой — надо заранее определить полный список всех команд, производных от класса Message, и добавить в базовый класс Filter пустые обработчики, чтобы в производных от Filter классах переопределить те из них, которые данный модуль действительно будет обрабатывать. А если окажется, что требуется ещё одна команда, то придётся изменять базовый класс иерархии Filter и перекомпилировать весь проект.
3. Динамический double dispatch
Хорошо бы было иметь такое решение, которое позволяло бы в базовом классе иметь только дефолтный метод-обработчик, а обработчики конкретных команд, наследующих классу Message, добавлять в тех потомках класса Filter, в которых они действительно нужны. Тогда мы имели бы потенциально неограниченный список возможных команд, в том числе и такие, о которых базовый класс ничего не будет знать — и при этом будет правильно их обрабатывать.
Код мог бы выглядеть как-то так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | class Filter { public: virtual void process(Message *m) { // общие действия по умолчанию для неизвестной команды, // например, "ничего не делать" } virtual void dispatch(Message *m) { m->send_to(this); } }; class Player : public Filter { public: virtual void process(PlayCommand *cmd) { // ...какая-то полезная работа... } }; |
Дальше начинаются сложности.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class PlayCommand : public Message { public: virtual void send_to(Filter *f) { if ( [объект f имеет метод `process(PlayCommand*)`] ) { f->process(this); // XXX НЕПРАВИЛЬНО // ??? у объекта Filter нет метода process(PlayCommand *)! ничего не выйдет! } else { f->process(this); // вызовется Filter::process(Message*), как и ожидалось. } } }; |
Здесь нам пригодилось бы что-то вроде языкового свойства, называемого reflection, которое позволяло бы получать некую метаинформацию о типе объекта. В C++ нет reflection, но, как обычно, можно его имитировать.
Предположим, что способность обработчика обрабатывать определённую команду, производную от Message, можно описать как интерфейс (mix-in) и “подмешивать” этот интерфейс к классу Filter, пользуясь множественным наследованием.
1 2 3 4 5 6 7 8 | template<class MESSAGE> class Handler { public: virtual void process(MESSAGE *) = 0; }; class Player : public Filter, public Handler<PlayCommand> { // ... разное внутреннее хозяйство Player }; |
Теперь мы “вынуждаем” класс Player определить свой метод process(PlayCommand*) и одновременно позволяем любому коду, использующему класс Filter, определить, может ли данный объект Filter* обрабатывать PlayCommand:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class PlayCommand : public Message { public: virtual void send_to(Filter *f) { Handler<PlayCommand> *h = dynamic_cast< Handler<PlayCommand> *>(f); if ( h ) { // объект f есть Handler<PlayCommand *> или производный от него h->process(this); // вызовется Handler<PlayCommand>::process(PlayCommand*); } else { f->process(this); // вызовется Filter::process(Message*), как и ожидалось. } } }; |
Как и в прошлый раз, используем шаблоны:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | template<class COMMAND> class Command : public Message { virtual void send_to(Filter *f) { typedef Handler<COMMAND> myhandler_t; COMMAND *self = static_cast<COMMAND*>(this); if ( myhandler_t *h = dynamic_cast<myhandler_t*>(f) ) { h->process(self); } else { f->process(self); } } }; class PlayCommand : public Command<PlayCommand> {}; |
Таким образом, мы получаем следующее:
- Hеограниченная иерархия команд, причём базовые интерфейсы не обязаны ничего знать о новых командах. Добавление новой команды и специфического обработчика для неё не влечёт изменений в написанном коде.
- Цена – один вызов RTTI (dynamic_cast).
4. Заключение
Предложен способ использования расширяемой двойной иерархии команд – обработчиков без необходимости ограничивать перечень типов как команд, так и обработчиков, использующий для диспетчеризации ровно один вызов RTTI.
Код следует рассматривать как иллюстрацию; реальный код, используемый в рабочих проектах, отличается.