Pull to refresh

C# делегаты изнутри. Можно ли расширить С++ стандарт для поддержки делегатов в стиле C#

Level of difficultyHard
Reading time13 min
Views7K

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

Тему предваряет обзор материалов, которые я использовал при написании своей статьи, в одном из которых есть сравнение C# делегатов с техникой которая заменяет их использование на Java, которое я тоже собираюсь проанализировать в конце.

Обзор материалов, посвященных делегатам и тому что с ними связано

Удивительную статью я обнаружил в истории Хабра называется: Как должен выглядеть делегат на C++ аж 2011 года! Там действительно реализован делегат на С++. Код компилируется и работает — генерирует консольный вывод. Делегат, конечно, получился специфический, но обо всем по порядку. Статья начинается с утверждения что:

В C# есть делегаты. В python есть делегаты. В javascript есть делегаты.В Java есть выполняющую их роль замыкания. А в C++ делегатов нет O_O

Так начинается аннотация к статье Как должен выглядеть делегат на C++.

Мне стало интересно это я что-то не понимаю или автор маленько слукавил чтобы представить Хабру свою, безусловно замечательную (по-моему) работу, по которой вполне можно изучать применение темплейтов С++. Всем, кто хочет поупражняться с шаблонами (templates) на C++ я настоятельно рекомендую попробовать воспроизвести пример из статьи Как должен выглядеть делегат на C++ и разобраться как он работает.

Такой пример я бы рекомендовал для изучения в ВУЗах чтобы закрепить понимание указателей на функции, темплейтов и чтобы отбить охоту у студентов применять С++ темплейты там где они не приносят особой пользы.

Теперь посмотрим работу иностранного специалиста. Jon Skeet в своей заметке (или статье) The Beauty of Closures говорит что в Java нет делегатов и поэтому для представления объекта-предиката (объекта-операции для проверки условия) он предлагает использовать интерфейс декларирующий единственную функцию. В этом смысле интересно отметить, что Jon Skeet фактически уравнял понятие делегата с понятием интерфейса с единственным методом. Цитата:

In C# the natural way of representing a predicate is as a delegate, and indeed .NET 2.0 contains a Predicate<T> type. (...) In Java there's no such thing as a delegate, so we'll use an interface with a single method.

По крайней мере он вполне успешно, по-моему, сравнивает реализацию замыканий реализованную через делегат на С#, с реализацией замыканий реализованных через интерфейс на Java в своей статье. Мы еще вернемся к сравнению делегата с интерфейсом с единственным методом и разберем подробно насколько это корректное сравнение, после того как разберем гипотетическую реализацию делегата на С++.

Чтобы немного разбавить через чур серьезную тему, а также в качестве справки по личности Джона Скита я приведу ссылку на другую статью 18 фактов о Джоне Ските  на Хабре, которая говорит нам о том, что анекдоты про известных людей вроде Чапаева и Штирлица не только наша национальная традиция. Я, кстати сказать, даже не знал о существовании такого специалиста пока не взялся анализировать материалы на тему делегатов, замыканий.

Определения делегатов

Теперь посмотрим на что фокусируется внимание в определениях делегатов. Все в той же статье у Джона Скита мы найдем следующее отличительную характеристику делегатов:

Цитата из статьи-перевода все того же Джона Скита на Хабре Делегаты и события в .NET:

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

Компания разработчик C# определяет нам другую характеристику делегатов по ссылке Delegates (C# Programming Guide):

Делегаты используются чтобы передавать методы как аргументы в другие методы

Ну и я попробую сформулировать то, что конкретно мне кажется важным для понимания делегатов, исходя из моего собственного опыта: делегатом называют и тип переменных, и сами переменные которые используются в коде для хранения/передачи методов (именованных или анонимных блоков кода) для отложенных вызов этих методов через эти переменные. Для примера:

C# делегат как тип определяется в виде, например:

public delegate bool Predicate<T>(T obj);

и переменную типа Predicate<T> которая определяет параметр predicate при объявлении функции в виде:

public static IList<T> Filter<T>(IList<T> source, Predicate<T> predicate)

мы тоже называем делегатом. С точки зрения человеческого языка все логично, если тип мы назвали делегатом, то переменную этого типа мы именуем именем типа.

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

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

Зачем это нужно если это так сложно?

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

Определение делегата в рамках С++

Опять же, опираясь на то, что выделил Джон Скит в определении делегата, и с чем я вполне согласен, мы можем сосредоточиться на том, что мы хотели бы выделить в рамках концепции переменных для хранения/передачи методов на С++. Статья Как должен выглядеть делегат на C++ показывает нам только одно из решений основанное на С++ темплейтах, мы же попробуем понять что нужно добавить в С++ стандарт, чтобы иметь возможность объявлять переменные, которые способны принимать метод произвольного класса как значение, чтобы впоследствии иметь возможность вызвать этот метод произвольного класса как функцию через такую переменную.

Если обратиться к примерам кода, то наша задача выглядит очень просто, предложенное в 2011 году решение выглядит так

         Victim test_class;
         Delegate test_delegate;
         test_delegate.Connect(&test_class, &Victim::Foo);
         test_delegate();

нам надо понять, что нужно чтобы можно было писать примерно так:

         Victim test_class;
         DelegateType test_delegate = &test_class.Foo;
         test_delegate();

В конце концов, возможно ли такое расширение языка С++ в принципе, и что нужно добавить в компиляторы языка, чтобы оно стало возможным.

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

Пожалуй, на С++ нет ничего сложнее чем указатели на функции и особенно на функции члены класса, пожалуй, если забыть про темплейты и их спецификации. Проблему позволяет сгладить возможность задать псевдоним типа с помощью typedef, но сгладить это, ни в коем случае, не решить, к сожалению! Для примера я приведу код, который к тому же можно проанализировать на предмет совместимости со стилем объявления делегатов на C#:

class clsA
{
    public:
    int funcA(double p) { return (int)p+7; }
};
class clsB
{
public:
    int funcB(double p) { return (int)p+9; }
};

typedef int(clsB::* delegatClsB)(double);

int main()
{
    clsA cA;
    int(clsA::* fPtrX1)(double)  = &clsA::funcA;
    int res = (cA.*fPtrX1)(123);
    printf("funcA Ptr returns '%d'\n", res);

    clsB cB;
    delegatClsB fPtrX2 = &clsB::funcB;
    res = (cB.*fPtrX2)(123);
    printf("funcB Ptr returns '%d'\n", res);
    return 0;
}

В случаях, когда используются указатели на С-функции (C# делегаты не исключение!) нам все равно, так или иначе, приходится анализировать соответствие очень многословных типов во многих случаях. Интересно отметить, что тип: сигнатура функции — это тип, который задается списком типов. Хотя С-структура или даже класс с одними полями без методов тоже задаются списком типов своих полей, между списком типов, один из которых определяет структуру, а другой определяет параметры функции есть принципиальная разница в их использовании, функцию нельзя вызвать, не инициализировав все члены списка (все параметры – не будем отвлекаться на функции с переменным списком параметров, это исключение, которое подтверждает правило, потому что длина списка будет задана в первых обязательных параметрах)

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

Если, к примеру, присвоить указателю на функцию, метод несоответствующей сигнатуры:

int(clsA::* fPtrX1)(double)  = &clsB::funcB;

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

1>C:\src\srcFile.cpp(132,29): error C2440: 'initializing': cannot convert from 'int (__thiscall clsB::* )(double)' to 'int (__thiscall clsA::* )(double)'

Так что же нам мешает на языке С++ получить указатель на функцию, которая является членом класса? То, что кроме собственно указателя на функцию нам нужно еще как-то сохранить объект, без которого эту функцию нельзя вызвать. Получается, что нам нужен какой-то составной указатель, который бы хранил и функцию, и объект, от которого эта функция должна вызываться. Проблема в том, что тип объекта на этапе создания такого составного указателя будет неизвестен. Мы должны объявить некоторый класс (структуру) которая будет хранить указатель на объект неизвестного типа Х, и для этого мы можем использовать void* , но вот тип поля, чтобы хранить указатель на функцию, не может быть обезличен, потому что при объявлении типа функции класса мы должны явно указать имя этого класса, у нас нет какого-то ключевого слова, чтобы задать произвольный класс, но предположим, что какой-то сверх новый С++ стандарт добавил нам такое ключевое слово, например UnknownCaller и мы можем объявить тип FuncPtr для хранения метода произвольного класса:

typedef int(UnknownCaller::* FuncPtr)(double);

и мы смогли бы использовать одну переменную сначала для инициализации методом из класса А, потом методом из класса B:

FuncPtr delegatClang = &clsA::funcA;
…
delegatClang = &clsB::funcB;

но это не решит проблему, потому что для вызова функции из переменной delegatClang нам понадобится объект класса, которому принадлежит эта функция как в этой строке из примера выше:

res = (cB.*fPtrX2)(123);

cB является объектом класса clsB. Дело в том, что указатель на метод класса в С++ не является полноценным абсолютным указателем. Указатель на метод (не статический) класса – это что-то вроде смещения к методу в памяти, выделенной объекту-объектам класса под код класса. А операция <.*> — это операция доступа по смещению.

Честно говоря, я даже не совсем понимаю зачем в С++ реализовали возможность создавать указатели на метод класса, так как эти ограничения фактически исключают практическую пользу от их использования. Было бы очень интересно, если бы кто-то поделился практическим примером использования таких указателей.

Получается, что в С++ стандарт нельзя добавить поддержку делегатов? Ответ: нет, кажется, можно!

Мне кажется, поддержку делегатов все-таки можно добавить в С++, при желании. Нам, конечно, понадобится новое ключевое слово, но самое главное нам понадобится новый тип указателей, то, что я бы назвал: сложные указатели (не путать с умными указателями).

Давайте рассмотрим пример гипотетического С++ кода, использующий новое ключевое слово delegate которое используется в двух разных контекстах:

delegate int(* delegateClsX)(double);
int main()
{
      ClsB cB;
      delegateClsX test_delegate = delegate (cB.FuncB);
      test_delegate(0.5);

      ClsА cА;
      delegateClsX test_delegate = delegate (cА.FuncА);
      test_delegate(0.5);
}

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

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

То есть такой сложный указатель мы можем гипотетически представить как структуру с двумя полями:

struct Delegatе
{
  Void *obj;
  int UnknownCaller: (*funcPtr)( double p);
}

И чтобы инициализировать такую структуру как структуру нам снова понадобится то же ключевое слово delegate которое поможет компилятору интерпретировать операцию доступа к члену класса “.” (точка) или “->” в скобках для проверки возможности такого доступа и для инициализации этой структуры двумя связанными значениями. При этом компилятор должен будет проверить сигнатуру переданного метода на соответствие сигнатуре функции, с которой объявлен тип переменной. Также нужно отметить, что для компиляции инициализации не нужны типы параметров и возвращаемого значения сигнатуры функции, но они нужны чтобы соблюдался принцип статической типизации. Соответствие типа переменной типу метода, предоставленного для инициализации этой переменной, должно быть проверено при компиляции.

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

funcPtr(obj, 0.5);

или если подставить значения полей из примера:

funcB((void *)&cB, 0.5);

То есть для генерации кода вызова в этом случае компилятору тип не нужен!

Вопрос в том должен ли компилятор проверять тип указателя на объект, который он будет передавать как первый параметр функции! Или, другими словами, возможно ли что объект, который сохранен в поле obj нашей гипотетической структуры не будет соответствовать методу, который сохранен в поле funcPtr? Очевидно, что ответом на этот вопрос будет: нет, это невозможно! Это соответствие нам будет гарантировать уникальный способ инициализации нашей гипотетической структуры с новым ключевым словом.

Таким образом, с точки зрения базовых концепций языка С++ мы нашли способ, который позволяет создать расширение языка С++ на уровне компиляторов для поддержки делегатов в стиле C#.

Мне очень интересно узнать не упустил ли я чего-то в своих рассуждениях, что может опровергнуть мои выводы!

В каком смысле интерфейсы можно рассматривать как делегаты?

В предыдущем разделе мы в некотором смысле выяснили-разобрали что такое C# делегат через отображение этой сущности из C# в С++. Теперь давайте попробуем проанализировать понятие «интерфейс» тем же методом. Интерфейсы распространены гораздо шире, например в Java это фактически базовый элемент построения программ. В С++ нет специального ключевого слова для определения интерфейса, но интерфейсом является абстрактный базовый класс без полей данных.

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

    interface clsI
    {
        int funcI(double p);
    };
    class clsA implements clsI {
        public int funcI(double p) { return (int)p+7; }
    }
    class clsB implements clsI {
        public int funcI(double p) { return (int)p+9; }
    }
    public static void main(String[] args) {
        Main mn = new Main();
        mn.mainMember();
    }
    public void mainMember(){
        clsI delegate;
        clsA cA = new clsA();
        delegate = cA;
        int res = delegate.funcI(0.5);
        System.out.println("funcA Ptr returns "+ res + "\n");

        clsB cB = new clsB();
        delegate = cB;
        res = delegate.funcI(0.5);
        System.out.println("funcA Ptr returns "+ res + "\n");
    }

А теперь то же самое на С++:

class clsI
{
public:
    virtual int funcI(double p) = 0;
};
class clsA: public clsI
{
    public:
    int funcI(double p) { return (int)p+7; }
};
class clsB : public clsI
{
public:
    int funcI(double p) { return (int)p+9; }
};

int main()
{
    clsI* delegate;
    clsA cA;
    delegate = &cA;
    int res = delegate->funcI(0.5);
    printf("funcA Ptr returns '%d'\n", res);

    clsB cB;
    delegate = &cB;
    res = delegate->funcI(0.5);
    printf("funcB Ptr returns '%d'\n", res);
    return 0;
}

Оба примера должны компилироваться и работать если я не напутал что-то при копировании на сайт.

Фактически тут мы доказали, что можно создать переменную (с именем delegate), в которой мы можем сохранить методы объектов разных классов, то есть задача в общем решена. Но это решение накладывает ряд ограничений на реализацию классов и их методов, которые задействованы для такого решения:

  1. Классы должны наследоваться от общего интерфейса;

  2. Методы должны иметь одно и то же имя, заданное в интерфейсе.

Эти требования не всегда можно выполнить, поэтому такое решение имеет ограниченную область применения по сравнению с C# делегатами, но эти ограничения не помешали известному иностранному специалисту сравнить реализацию замыканий на C# с делегатами и на Java с интерфейсами, как мы могли видеть в его статье.

И я хочу еще обратить ваше внимание что переменная «интерфейс» по сути является таким «сложным указателем», который я описал в предыдущем разделе. Обратите внимание, при вызове, который одинаково выглядит и на С++ и на Java и на C#:

res = delegate->funcI(0.5);

переменная delegate выступает в двух ипостасях:

  1. она дает нам доступ к своему члену, к функции funcI в примере

  2. она же будет передана в вызванную функцию класса в качестве указателя this на данные класса. Если бы мы определили какие-то поля в классах clsA или clsB мы бы могли к ним обращаться внутри реализации функций именно через этот указатель this , который мы получили из переменной delegate.

Указатель (ссылка) на интерфейс для компилятора является составным указателем, он состоит из

  1. списка указателей (ссылок) на все функции класса, реализующего интерфейс и

  2. указателя (ссылки) на данные объекта класса.

Я надеюсь такой способ анализа интерфейсов и делегатов может быть полезен кому-то при решении-анализе практических задач. По крайней мере мне иногда помогает.

Подведем итог

Мы рассмотрели возможность реализации делегатов в стиле C# на С++ и не обнаружили явных противоречий между концепцией делегатов и парадигмой языка С++. Из чего можно сделать вывод что делегаты, наверно, можно добавить в С++ на уровне компилятора.

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

Мы ввели-определели понятие "сложный или составной указатель", которое, возможно, кому-то поможет в понимании классов, интерфейсов, делегатов.

Пишите в комментариях если я что-то напутал, будем разбираться вместе.

Ответы на вопросы в коментариях

Для @mentin и @Zekori

Я увидел статью долго рассуждающую как создать делегаты, что придется добавить в язык, и т.д. При этом в С++ уже есть std::function

Возможно не найдя ответа почему для реализации делегатов вы не использовали std::function

А что std::function разрешает такой синтаксис:

DelegateType test_delegate = &test_class.Foo;

или

delegateClsX test_delegate = delegate (cB.FuncB);

Разве можно объекту std::function присвоить функцию класса?

Вы ничего не просмотрели уважаемые @mentin и @Zekoriили цель просто придраться?


Единственное чего нет в С++ это короткого C# синтаксиса для тривиальной лямбды, object.Method, std::bind всё же чуть длиннее.

@mentin я могу надеяться, что это значит, что вы разрешили мне оставить эту мою статью на Хабре?

И, кстати, здесь вы ошибаетесь:

Для нестатических функций можно использовать std::bind, или лямбду в духе C#.

TestClass foo;
std::function<int(int)> g = [&foo](int x) { return foo.Baz(x); };
g(42);

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

Вы представьте, что наш с вами делегат надо вызывать милион раз в секунду, а у вас придется вызывать две вложенные функции, вместо одной сохраненной в C# делегате.

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

Поэтому вы написали делегат в стиле Java, а не в стиле C#.


Ответ для @eao197

А почему это std::function и std::bind нельзя считать стандартными средствами?

Начнем с того, что я нигде не писал, что их «нельзя считать стандартными средствами», то есть вы уже передергиваете! Но бог с ним, я все же напишу почему не все согласятся «считать их стандартными средствами»:

  1. Некоторые могут сказать, что стандартными средствами надо считать то, что было до С++11, например;

  2. В качестве второго варианта смотрите мой ответ на комментарий @mentin

Единственное чего нет в С++ это короткого C# синтаксиса для тривиальной лямбды, object.Method, std::bind всё же чуть длиннее.

чуть выше. Хотя уже в этом утверждении можно найти ответ, при желании. И я делаю логичный вывод, по моему, что просто желания нет.

Tags:
Hubs:
Total votes 9: ↑4 and ↓5-1
Comments40

Articles