Использование double dispatch на примере разработки системы потокового вещания

Одна из задач, которые приходится решать при разработке системы потокового вещания — как правильно организовать передачу информации внутри системы.

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.

Код следует рассматривать как иллюстрацию; реальный код, используемый в рабочих проектах, отличается.

У нас есть похожие новости по этим темам:
Наверх