Мне давно хотелось узнать существуют ли программисты, которые понимают «делегирование» в рамках ООП так же, как я.  А когда я случайно обнаружил что в Шаблонах проектирования (Design Patterns) в фундаментальных трудах признанных классиков концепций программирования пропущено описание для Делегирования, у меня появился повод написать эту статью.

Так получилось, что я сначала познакомился с этой техникой на практике разрабатывая DirectShow фильтры и COM-объекты, которые составляют эти фильтры и меня особо не интересовало как все это по-умному называется пока это все прекрасно работает. Проблемы возникают, когда ты пытаешься объяснить кому-то КАК это работает, или когда ты пытаешься предложить кому-то хотя бы попробовать использовать определенную технику программирования. Вот именно при таких попытках у меня получилось сопоставить что то, что я использую очень подходит под определение Design Pattern: Delegation.

Давайте посмотрим будет это поводом посмеяться или задуматься.

Должен предупредить что тем, кто воспринимает чужое мнение по техническим вопросам как оскорбление только потому, что он не согласен с этим мнением, не нужно читать эту статью.

Кто дочитает до конца найдет ответ на вопрос который задает название.


На Хабре есть статья с говорящим названием «Банда четырёх» была неправа, а вы не знаете, что такое делегирование»

Статья от 2016 года и это перевод заметки какого-то неизвестного мне специалиста по программированию (видимо в области Ruby и Rails). Очень интересно было узнать, что и в 2012 году, и видимо в 2016, и, как мне кажется, и теперь существуют более или менее признанные специалисты по программированию которые нервничают(мне так показалось по крайней мере по Заметке) из-за того что не могут внятно сформулировать сначала проблему для решения которой будет использоваться Делегирование, как шаблон проектирования, а затем и саму технику реализации такого решения в терминах ООП. Конечно, перевод не может быть лучше, чем сама заметка, которая переполнена эмоциями на мой взгляд, но я хочу отметить один очень положительный момент в этом переводе. Там я увидел корректный перевод для словосочетания: «your business domain concepts» как «концепции логики работы приложения (приложения в разработке)». Дело в том, что я много раз слышал про какую-то непонятную «бизнес логику» в рассуждениях об архитектурных решениях для софта и вот здесь я понял, откуда берется эта «бизнес логика». В большинстве случаев это неправильный, вырванный из контекста перевод словосочетания со словом «business» и признак непонимания темы.

То, что изложено в той статье и в ее исходнике со всеми ссылками очень помогли мне построить эту статью, дали мне как бы опору для моей статьи. Далее я буду на нее ссылаться как на «Заметку».

Как же так получилось, что авторы фундаментального труда: «Design Patterns: Elements of Reusable Object-Oriented Software» (на который ссылается Заметка, далее упоминаю как «книга «Design Patterns» или просто Книга) пропустили (судя по таблице из википедии) один из шаблонов проектирования, который называется делегирование?

То, что описание для делегирования пропущено, тем более удивительно что COM (который Component Object Model) существует как минимум с середины 90-х, и еще (и уже) в начале 2000-х я активно пользовался инфраструктурой COM, которая позволяет делегировать разного рода ответственность (в частности, просто вызовы функций) к объектам, которые содержатся в объекте внешнего класса в виде полей, элементов внутренних массивов, списков, … чего угодно.

Чтобы не было проблем с интерпретацией того, о чем идет речь, я поясню что имеется в виду примером С++ подобного кода.

Пусть у нас есть класс А в котором определено поле fld1 объявленное с типом B:

Class A
{
B fld1;
…
public void a_func1(…){…}
}

В этом случае можно сказать, что объект класса А «знает» некоторый объект класса В и более того поскольку объект класса В является полем класса А то очевидно что класс B является частью объекта класса A, то есть он занимает память внутри объекта класса А. (Обратите внимание: приходится всегда повторять это словосочетание «объект класса Х» так как написать без слова «объект» никак нельзя. Это кардинально ломает смысл-логику рассуждений!)

Пусть класс В определяет некоторый метод b_func1:

Class B
{
public void b_func1(…){…}
}

Вполне логично предположить, что если в составе класса А есть объект класса В то при наличии объекта класса А и при необходимости выполнить работу которую умеют выполнять объекты класса В, будет вполне логичным обратиться к объекту класса А, если, и когда он уже есть в наличии как в псевдо коде ниже:

…
A a_obj = <get somehow Class A object> ;
…
<нужно выполнить работу с помощью объекта класса В, например функцию b_func1> 
<почему бы не использовать объект класса В из состава объекта класса А: a_obj?> 
<А если нам запрещено создать новый объект класса В когда один уже создан?> 
…

Само собой здесь напрашивается каким-то образом получить объект или ссылку на объект класса В из класса А и это действительно прямой путь к возможному решению, только этот путь не учитывает некоторые очень важные возможности, которые очень нужны если мы рассматриваем код как расширяемый и хорошо поддерживаемый. Эти возможности не так просто осознать, поэтому я предлагаю пока забыть про то, что можно как-то получить ссылку на объект из состава сложного объекта (в некоторых описаниях это outer object, смотри далее).

Общеизвестный вид Делегирования

С точки зрения любого языка, который поддерживает классы, мне кажется логично различать два вида делегирования, первый и насколько я понимаю общеизвестный, когда метод (или методы) класса используют методы (сервисы) внутреннего объекта для реализации собственных методов. И это практически дословный перевод из COM Fundamentals документации: The outer object "contains" the inner object, and when the outer object requires the services of the inner object, the outer object explicitly delegates implementation to the inner object's methods. That is, the outer object uses the inner object's services to implement itself. Containment/Delegation - Win32 apps | Microsoft Learn

 Если посмотреть, например на описанный в книге «Design Patterns»: ADAPTER (Адаптер, он же Wrapper) с этой точки зрения, то мы увидим, что получается, что Адаптер является частным случаем Делегирования, так как он описывает способ использования методов внутреннего объекта класса для реализации внешнего-адаптированного интерфейса вновь созданного класса.

В книге «Design Pattern» для иллюстрации шаблона Адаптера приведен такой пример (в том числе). Класс:

class TextShape : public Shape 
{ 
…
    virtual void BoundingBox(Point& bottomLeft, Point& topRight) const;
    virtual bool IsEmpty() const;
…
private: 
TextView* _text; 
};

Реализация методов:

void TextShape::BoundingBox(Point& bottomLeft, Points topRight) const
{
    Coord bottom, left, width, height;
    _text->GetOrigin(bottom, left);
    _text->GetExtent(width, height);
    bottomLeft = Point(bottom, left);
    topRight = Point(bottom + height, left + width);
} 
bool TextShape::IsEmpty() const {
    return _text->IsEmpty();
}

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

class TextShape : public Shape, private TextView
{…

Обосновано так:

«For example, the object adapter version of TextShape (имеется в виду как раз версия в которой объект определен в виде члена класса-поля класса) will work equally well with subclasses of TextView—the client simply passes an instance of a Text View subclass to the TextShape constructor.»

Даже не буду переводить – все очевидно.

В реализации методов мы видим, что этот шаблон проектирования предлагает нам вызывать методы внутреннего объекта, как раз то, что в MS документации называется: «outer object explicitly delegates implementation to the inner object's methods».

Далее если мы посмотрим, что же нам предлагают другие описанные в книге структурные шаблоны мы увидим, что в большинстве случаев они описывают разные техники вызова методов у некоторого объекта (объектов)-члена класса для реализации некоторого специального вида взаимодействия объектов этих классов. Посмотрите BRIDGE (Handle/Body), COMPOSITE, DECORATOR (тоже Wrapper), FACADE (про него вообще написано, что он delegates client requests to appropriate subsystem objects) все они определяют специальное поле(поля) реализуемого класса, которое(которые) инициализируется объектом, к которому, по сути, делегируются определенного вида вызовы.

И поэтому вполне логично заключить, как мне кажется, что рассмотренный здесь шаблон Адаптер как и остальные шаблоны которые по сути в каком то виде делегируют «реализацию реализации» (или «ответственность за реализацию») является частным случаем более общего шаблона Делегирования!

То же Делегирование для Behavioral шаблона

Интересный случай рассматривается для Behavioral шаблона State (Стейт). И именно этот случай упоминается в «Заметке».

Суть шаблона Стейт в том, что объект Контекст имеет поле-ссылку на объект Состояния и это текущий объект Состояния, который выполняет всю работу, так как Контекст делегирует всю работу, которая к нему приходит в виде вызовов функций к этому текущему объекту Состояния.  И в том числе текущий объект Состояния отвечает за то, чтобы подменить указатель на текущий объект Состояния (то есть на себя самого) в поле объекта Контекст, так что следующий вызов функции, который придет к объекту Контекст будет делегирован к новому текущему объекту Состояния из внутреннего поля объекта Контекст и будет обработан этим другим объектом Состояния (объектом другого Состояния). В Книге так и написано: Context delegates state-specific requests to the current ConcreteState object.

Очень советую попробовать так сделать. Это очень хороший способ избавиться от SWITCH-ей в коде, с разными интересными бонусами!

Совсем коротко можно сказать, что мы создаем объект Контекст для вызова методов какой-то реализации объекта Состояния, с возможностью замены одной реализации объекта Состояния на другую внутри объекта Контекст.

Как видите, нет ничего проще, для тех, кто привык к таким диалектическим построениям в архитектуре софта.

Автора Заметки видимо очень впечатлила возможность сравнения возможностей языка Self с возможностями языка JS для, определенного конкретно в этом Behavioral шаблоне State, понятия о Делегировании. Я не думаю, что тот пример на JS имеет шансы кому-то что-то доказать, для меня этот пример важен, потому что он еще раз демонстрирует что Делегирование это все-таки какое-то общее понятие, которое сложно выделить как отдельный паттерн проектирования, это прием, который используется очень широко.

Другой вид Делегирования с реальным примером

Мне кажется, в СОМ технологии есть особый вид техники построения взаимодействия объектов, который мог бы быть описан как отдельный паттерн под именем Делегирование.

Для начала абстрактный-бытовой пример.

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

Или.

Если вы ошиблись с интерфейсом – так бывает, что совершенно одинаковые по сигнатуре функции реализованы в разных интерфейсах.

Можно такой тип взаимодействия назвать делегированием? Я думаю, что можно! Только это другой тип делегирования. Тут мы делегируем не отдельные запросы или работы или вызовы внутрь объекта к его внутренним объектам, мы делегируем внутренний объект навстречу клиенту чтобы клиент и этот объект работали напрямую.

В основе COM технологии лежит возможность или скорее даже требование поддержки такого типа делегирования внутренних объектов СОМ объекта по запросам, использующего этот COM объект кода (по запросам клиента).

Это то, что реализуется через интерфейсную функцию:

HRESULT QueryInterface(REFIID riid, void   **ppvObject); 

Интерфейса IUnknown.

Для тех, кто пишет на языках со встроенной сборкой мусора, возможно будет интересно проследить аналогию между интерфейсом IUnknown в СОМ и классом object в таких языках как Java, C#. В том и другом случае это корень иерархии объектов, который определяет способ контроля времени жизни объекта. Только в Java, C# подсчет ссылок добавляется автоматически компилятором, в СОМ можно написать свою реализацию интерфейса IUnknown, или скопировать стандартную. При этом в СОМ есть вот этот интересный бонус (для многих непонятный и странный, я подозреваю) в виде функции QueryInterface.

Я попробую пояснить смысл этого странного бонуса на практическом примере, который оказался у меня под рукой.

Если вы захотите реализовать проигрывание звука в своем приложении, вам понадобится циркулярный буфер, в который вы будете писать аудио семплы, а звуковое устройство и его драйвер будут проигрывать эти аудио семплы из этого буфера.

В терминах концепции DirectSound такой циркулярный буфер будет представлен набором функций для записи в этот буфер, этот интерфейс можно получить с помощью кода:

LPDIRECTSOUND8 m_lpDS;
LPDIRECTSOUNDBUFFER8 m_lpDSB2;
LPGUID gd = NULL_DEVICE_GUID;

    // Create DirectSound
    HRESULT hResult = DirectSoundCreate8(gd, &m_lpDS, nullptr);
    if (FAILED(hResult))
    {
        m_lpDS = nullptr;
        return hResult;
    }
    hResult = m_lpDSB1->QueryInterface(IID_IDirectSoundBuffer8, 
(LPVOID*) &m_lpDSB2);
    if (FAILED(hResult))
    {
        return hResult;
    }

Заметим, что нам не обязательно знать конкретный тип устройства, NULL_DEVICE_GUID позволяет обращаться к текущему активному устройству в системе, представленному интерфейсом LPDIRECTSOUND8. Далее с помощь функции QueryInterface интерфейса IUnknown, мы получаем объект циркулярного буфера, который представлен интерфейсом LPDIRECTSOUNDBUFFER8. Мы не знаем когда был создан объект буфера, в момент обращения или в момент создания объекта устройства, но нам это и не надо знать.

Теперь мы можем передать данные в буфер и запустить проигрывание из буфера:

    HRESULT hResult = m_lpDSB2->Lock(m_samplesTail, numBytes, &lpvAudio1,
        &dwBytesAudio1, &lpvAudio2, &dwBytesAudio2, 0);
        // Write to first pointer 
        memcpy(lpvAudio1, audioBuffer, dwBytesAudio1);
. . . 
        // Release the data back to DirectSound
        hResult = m_lpDSB2->Unlock(lpvAudio1, dwBytesAudio1, lpvAudio2, dwBytesAudio2);

        HRESULT hResult = m_lpDSB2->Play(0, 0, DSBPLAY_LOOPING);
        if (FAILED(hResult))
        {
            return hResult;
        }
. . .

Как видите функция QueryInterface интерфейса IUnknown нужна нам только в самом начале чтобы получить доступ к объекту, о котором мы ничего не знаем, кроме того с помощью каких функций мы можем к нему обращаться, с ним работать. Заметьте, что мы получили эти функции напрямую и нам делегировал этот интерфейс объект внешний к тому, в котором эти функции реализованы. Получив этот интерфейс и работая с ним напрямую мы не несем никаких дополнительных издержек связанных с диспетчеризацией вызовов через промежуточные объекты как в других шаблонах проектирования, мы получили самый эффективный способ взаимодействия объектов!

Заключение

В заключение я перечислю основные идеи, которые я попытался обосновать.

Существует два способа делегирования: делегирование внутрь (просто делегирование, делегирование в общеизвестном смысле) и делегирование наверх

или

делегирование к внутренним объектам (просто делегирование) и делегирование самих объектов наружу.

Делегирование — это прием для построения взаимодействия объектов в коде. Он слишком общий или слишком простой чтобы его выделить и описать как отдельный шаблон проектирования. В Книге «Design Patterns» были описаны конкретные шаблоны, во многом основанные на делегировании.

Второй способ делегирования создает предпосылки для получения самого эффективного способа взаимодействия объектов.

Комментарии (6)


  1. magmel48
    04.08.2023 10:47

    "класс А является частью объекта класса В" - мне почему-то кажется, что ситуация обратная.


    1. SadOcean
      04.08.2023 10:47

      Тоже заметил, видимо ошибка


    1. rukhi7 Автор
      04.08.2023 10:47

      надо же 15 раз перечитал и не заметил! исправил, спасибо.


  1. nin-jin
    04.08.2023 10:47

    Почему вы запрос интерфейса обозвали делегированием? Этак вы любой метод, который возвращает какой-угодно объект, должны называть делегатом.


    1. rukhi7 Автор
      04.08.2023 10:47

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

      В этом случае действительно любая функция, которая возвращает интерфейс (а не "какой-угодно объект"), она делегирует этот интерфейс наружу из класса. И делегат достается для работы с ним тому, кто его запросил.

      интересно что нельзя создать COM-объект (его класс) без реализации такой функции, которая возвращает интерфейс (делегированный интерфейс). Оказывается очень полезно иметь такую функцию в корне иерархии.

      Мне кажется, интерфейс это более сложное понятие чем делегат.

      Делегат предполагает только одну функцию, а интерфейс несколько.

      Поэтому нет смысла использовать термин делегат, когда речь идет об интерфейсе. То есть все это полезно только для понимания, это не значит что надо менять что то на практике, опять же это (возможно) только я так думаю.


  1. sergegers
    04.08.2023 10:47
    +1

    Он слишком общий или слишком простой чтобы его выделить и описать как отдельный шаблон проектирования. 

    А наследование - делегирование реализации части функциональности классу-предку, CRTP или абстрактные методы - делегирование потомку.

    В общем - всё делегирование, кроме пчёл.