Указатели функций-членов и наиболее быстрые делегаты C++ - Почему указатели функций-членов настолько сложные?

ОГЛАВЛЕНИЕ

Почему указатели функций-членов настолько сложные?

Функция-член класса немного отличается от стандартной функции C. В дополнение к объявленным параметрам она имеет скрытый параметр, называемый this, который ссылается на экземпляр класса. В зависимости от компилятора this может рассматриваться внутри как стандартный параметр или может подвергаться специальной обработке. (Например, в VC++ this обычно передается через регистр ECX). this существенно отличается от обычных параметров. В виртуальных функциях  он контролирует во время выполнения то, какая функция выполняется. Даже если функция-член является реальной функцией в своей основе, в стандартном C++ нет способа заставить обычную функцию вести себя как функция-член: нет ключевого слова thiscall, которое могло бы заставить ее использовать правильное соглашение о вызовах. Функции-члены – с Марса, обычные функции – с Венеры.

Можно предположить, что указатель функции-члена, как и обычный указатель функции, просто содержит указатель на код. Это не верно. Почти во всех компиляторах указатель функции-члена больше, чем указатель функции. В Visual C++ указатель функции-члена может иметь длину 4, 8, 12 или 16 байт в зависимости от сущности класса, с которым он связан, и в зависимости от используемых в компиляторе установок! Указатели функций-членов более сложные, чем вы могли ожидать. Но это не всегда так.

Давайте вернемся в начало 1980х. Когда исходный компилятор C++ (CFront) был разработан, он имел только единичное наследование. Когда были введены указатели функций-членов, они были простыми: они были только лишь указателями функций, имеющими дополнительный параметр this в качестве первого аргумента. Когда были задействованы виртуальные функции, указатель функции ссылался на небольшой кусок кода-переходника. (Дискуссия о comp.lang.c++.moderated установила, что CFront на самом деле не использовал переходники, он был намного менее элегантным. Но он мог использовать этот метод и делал вид, что применяет его, что делает последующую дискуссию более легкой для понимания.)

Этот идиллический мир был разрушен, когда был выпущен CFront 2.0. Он ввел шаблоны и множественное наследование. Частью побочного вреда множественного наследования было урезание указателей функций-членов. Проблема в том, что при множественном наследовании вы не знаете, какой указатель this применять, пока вы не выполните вызов. Например, предположим, что у вас есть 4 класса, определенные ниже:

class A {
 public:
       virtual int Afunc() { return 2; };
};

class B {
 public:
      int Bfunc() { return 3; };
};

// C – это класс с единичным наследованием, производный только от A
class C: public A {
 public:
     int Cfunc() { return 4; };
};

// D использует множественное наследование
class D: public A, public B {
 public:
    int Dfunc() { return 5; };
};

Предположим, мы создали указатель функции-члена для класса C. В этом примере Afunc и Cfunc обе являются функциями-членами C, поэтому наш указатель функции-члена может ссылаться на Afunc или Cfunc. Но Afunc нужен указатель this, ссылающийся на C::A (который мы назвали Athis), а Cfunc нужен указатель this, ссылающийся на C (который мы назвали Cthis). Разработчики компиляторов справляются с этой ситуацией с помощью такого приема: они проверяют, что A физически сохранен в начале C. Это значит, что Athis == Cthis. У нас есть только один this, о котором нужно беспокоиться.

Теперь предположим, мы создали указатель функции-члена для класса D. В этом случае наш указатель функции-члена может ссылаться на Afunc, Bfunc или Dfunc. Но Afunc нужен указатель this, ссылающийся на D::A, а Bfunc нужен указатель this, ссылающийся на D::B. В этот раз прием не сработает. Мы не можем поместить A и B в начало D. Поэтому указатель функции-члена для D должен определять не только то, какая функция вызывается, но также и то, какой указатель this применять. Компилятор знает размер A, поэтому он может преобразовать указатель Athis в Bthis, просто добавив смещение (delta = sizeof(A)) к нему.

Если вы используете виртуальное наследование (то есть, виртуальные базовые классы), то это намного хуже, и вы можете легко запутаться, пытаясь это понять. Как правило, компилятор применяет таблицу виртуальных функций ('vtable'), хранящую для каждой виртуальной функции адрес функции и виртуальное приращение: число в байтах, которое нужно прибавить к исходному указателю this, чтобы преобразовать его в указатель this, требуемый функции.

Ничего из этого не существовало бы, если бы C++ определил указатели функций-членов немного иначе. В вышеприведенном коде сложность появляется только из-за того, что вы можете ссылаться на A::Afunc как на D::Afunc. Вероятно, это плохой стиль; обычно вы должны применять базовые классы как интерфейсы. Если бы вы вынуждены делать это, то указатели функций-членов могли бы быть обычными указателями функций со специальным соглашением о вызове. То, что им разрешили ссылаться на переопределенные функции, было большой ошибкой. Как плата за редко используемую дополнительную функциональность, указатели функций-членов стали нелепыми. Они также создают проблемы для разработчиков компиляторов, которые должны их реализовывать.