Привет, интернет.

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

Особенно пугают такие страшилки, которые мало что объясняют о том как происходит на самом деле и почему, а лишь пытаются приучить программиста слепо следовать требованиям.

Давайте разберемся что и почему происходит.
Все манипуляции будут произведены для архитектуры x86-64.

Взглянем на код.
#include <cstdio>
int main() {
  struct A;
  printf("%zu\n", sizeof( void(A::*)() ));
}

Вывод:

16

Размер указателя на метод больше 8 байт. В некоторых компиляторах это не так, например компилятор Microsoft ужимает до 8 байт указатель на метод в некоторых случаях. В последних версиях компиляторов clang и gcc для Linux принимал размер 16 байт.

Как мне кажется, разработчики компиляторов не могли без особой причины заменить обычный указатель на нечто другое. Давайте же разберемся почему они так сделали.

Посмотрим такой код на C++. Это базовый пример вызова метода из указателя на метод.

struct A;
typedef void (A::*Ptr) ();

Ptr ptr;
void call(A *a) {
  (a->*ptr)();
}

Скомпилировав код такой командой:

clang++ code.cpp -c -emit-llvm -S -O3 -fno-discard-value-names

Получаем вывод LLVM IR:

@ptr = dso_local local_unnamed_addr global { i64, i64 } zeroinitializer, align 8

; Function Attrs: uwtable
define dso_local void @_Z4callP1A(%struct.A* %a) local_unnamed_addr #0 {
entry:
  %.unpack = load i64, i64* getelementptr inbounds ({ i64, i64 }, { i64, i64 }* @ptr, i64 0, i32 0), align 8, !tbaa !2
  %.unpack1 = load i64, i64* getelementptr inbounds ({ i64, i64 }, { i64, i64 }* @ptr, i64 0, i32 1), align 8, !tbaa !2
  %0 = bitcast %struct.A* %a to i8*
  %1 = getelementptr inbounds i8, i8* %0, i64 %.unpack1
  %this.adjusted = bitcast i8* %1 to %struct.A*
  %2 = and i64 %.unpack, 1
  %memptr.isvirtual.not = icmp eq i64 %2, 0
  br i1 %memptr.isvirtual.not, label %memptr.nonvirtual, label %memptr.virtual

memptr.virtual:                                   ; preds = %entry
  %3 = bitcast %struct.A* %this.adjusted to i8**
  %vtable = load i8*, i8** %3, align 1, !tbaa !5
  %4 = add i64 %.unpack, -1
  %5 = getelementptr i8, i8* %vtable, i64 %4, !nosanitize !7
  %6 = bitcast i8* %5 to void (%struct.A*)**, !nosanitize !7
  %memptr.virtualfn = load void (%struct.A*)*, void (%struct.A*)** %6, align 8, !nosanitize !7
  br label %memptr.end

memptr.nonvirtual:                                ; preds = %entry
  %memptr.nonvirtualfn = inttoptr i64 %.unpack to void (%struct.A*)*
  br label %memptr.end

memptr.end:                                       ; preds = %memptr.nonvirtual, %memptr.virtual
  %7 = phi void (%struct.A*)* [ %memptr.virtualfn, %memptr.virtual ], [ %memptr.nonvirtualfn, %memptr.nonvirtual ]
  tail call void %7(%struct.A* %this.adjusted)
  ret void
}

LLVM IR является промежуточным представлением между машинным кодом и C++ в компиляторе Clang. Он позволяет компилятору производить оптимизации не зависящие от конкретной архитектуры процессора, а нам он дает понять что происходит на тех или иных стадиях компиляции, и является более читаемым чем язык ассемблера.

Подробнее про LLVM IR можно узнать в Википедии, официальном сайте LLVM и Clang.

Что есть:

  • Взглянув на первую строчку, видно что указатель на метод является структурой `{ i64, i64 }`, а не обычным указателем. Эта структура содержит два i64 элемента, которые могут уместить в себя 2 обычных указателя. Видно почему мы не можем приводить указатели на методы в обычные. Мы не можем без потерь преобразовать 16 байт в 8 байт в общем случае.
  • В блоке `entry`, начинающимся с 5 строки, видно что происходит корректирование указателя `this`. Это значит, что компилятор прибавляет к указателю на `this` значение второго элемента этой структуры, и позже в блоке `memptr.end` передает его в вызов метода.
  • Нечто странное происходит происходит в блоке `entry` на 14 строке с первым элементом структуры. Компилятор вычисляет выражение аналогичное следующему: `bool isvirtual = val & 1`. Компилятор считает указатель на метод виртуальным, если число в нем нечетное, в противном случае невиртуальным.
  • Если указатель на метод указывает на невиртуальный метод, то значение первого элемента считается обычным указателем на функцию, который позже вызывается. Эти предположения происходят в блоке `memptr.nonvirtual`.
  • Если указатель на метод указывает на виртуальный метод, то тут сложнее. Вначале вычитается единица из первого элемента структуры, и вычисленное значение является отступом для виртуальной таблицы, указатель на которую берется из значения указателя на `this`. Это происходит в блоке `memptr.virtual`.

Итого, имеются следующие данные внутри указателя на метод:

  • Информацию является ли он виртуальным
  • Указатель на адрес метода (если не виртуальный)
  • Смещение в vtable (если виртуальный)
  • корректирование `this`

О том как происходит вызов метода в C++.

Метод класса имеет невидимый первый параметр — указатель на `this`, который передается компилятором при вызове метода. Остальные аргументы передаются после в том же порядке, что и были.

Если бы мы писали этот код на C++, то он выглядел бы примерно так:

A *a;
a->method_name(1, 2, 3);
method_name(a, 1, 2, 3);

Чтобы разобраться с значением корректирования, рассмотрим следующий пример:

struct A {
  char a[123];
};
struct B {
  char a[0];
  void foo();
  static void bar(B *arg);
};

struct C : A, B {};

void (C::*a)() = &C::foo;
void (C::*b)() = &B::foo;
void (B::*c)() = &B::foo;

void (*a1)(C*) = &C::bar; // error
void (*b1)(C*) = &B::bar; // error
void (*c1)(B*) = &B::bar; // ok

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

Посмотрим такой код:

struct A {
  char a[123];
};
struct B {
  char a[0];
  void foo();
  static void bar(B *arg);
};

struct C : A, B {};

void (C::*a)() = &C::foo;
void (C::*b)() = &B::foo;
void (B::*c)() = &B::foo;

Скомпилируем код командой:

clang++ code.cpp -c -emit-llvm -S -O3 -fno-discard-value-names

Вывод:

@a = dso_local global { i64, i64 } { i64 ptrtoint (void (%struct.B*)* @_ZN1B3fooEv to i64), i64 123 }, align 8
@b = dso_local global { i64, i64 } { i64 ptrtoint (void (%struct.B*)* @_ZN1B3fooEv to i64), i64 123 }, align 8
@c = dso_local global { i64, i64 } { i64 ptrtoint (void (%struct.B*)* @_ZN1B3fooEv to i64), i64 0 }, align 8

Видно, что указатель на метод указывает на одну и ту же функцию. Однако значение корректирования разное из-за того что класс B расположен по сути внутри класса C.
Компилятору C++ необходимо знать отступ от базового класса для того чтобы передать `this` в метод класса.

Что плохого в этой реализации:

  • Размер указателя относительно большой, даже если корректирование отсутствует каким либо образом в gcc и clang
  • Каждый раз идет проверка виртуальности метода, даже если мы знаем что он не виртуальный

Как можно поступить:

  • Использовать статический метод, принимающий экземпляр класса
  • Забыть про существование указателей на методы, и решить проблему как-то иначе в прочих случаях

Остальное:

  • В интернете есть советы использовать std::bind, std::function и подобные библиотечные функции. Проверив их поведение, я не обнаружил существования каких либо оптимизаций для указателей на методы.
  • У меня нет технической возможности проверить что происходит в компиляторах Microsoft, поэтому не особо про них рассказал. Однако протестировав онлайн компиляторы, я заметил что MSVC умеет анализировать структуру классов и удалять поле значения корректирования, если оно не требуется.

Также я реализовал прием, позволяющий убирать проверку на виртуальность в clang и gcc.

#include <string.h>
#include <stdint.h>

struct A;
extern A* a;
extern void(A::*func)();

template<typename T>
T assume_not_virual(T input) {
  struct Ptr { uint64_t a, b; };
  static_assert(sizeof(T) == sizeof(Ptr), "");
  Ptr ptr;
  memcpy(&ptr, &input, sizeof(input));
  __builtin_assume(!(ptr.a & 1));
  return input;
}

void call() {
  (a->*assume_not_virual(func))();
}

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

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

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

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


  1. GarryC
    20.07.2020 11:55

    В свое время разбирался с реализацией методов под gcc для архитектур ARM и AVR. Главный вывод, который я вынес из понятого — нельзя ничего предполагать и, если Вам нужен вызов метода, то единственный надежный способ — шаблоны и явный вызов метода в нужном месте, все остальное — «от лукавого».
    А по поводу «страшилок» как нельзя лучше подходит старая фраза «В уставе караульной службы каждая строчка написана кровью военнослужащих, которые пытались делать по своему»


    1. mitinsvyat Автор
      20.07.2020 11:55

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


  1. dvserg
    20.07.2020 11:55

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


    1. mitinsvyat Автор
      20.07.2020 11:55

      Это я согласен


  1. sergio_nsk
    20.07.2020 11:55

    часто имеют размер больше 8 байт.

    Вывод: 16
    Размер указателя на метод больше 8 байт.

    Прежде чем что-то утверждать стоит указать для какой архитектуры компилируется пример, 32 и 64 бит.


    1. mitinsvyat Автор
      20.07.2020 11:55

      16 байт для x86-64
      8 байт для x86-32


      1. sergio_nsk
        20.07.2020 11:55

        Какое число из цитаты в моём комментарии уточняют эти числа из вашего ответа?


        1. mitinsvyat Автор
          20.07.2020 11:55

          В статье тоже добавил
          Добавил что все для X86-64
          Имел в виду, что указатель на метод 16 байт в 64 битах, и 8 байт в 32 битах.


  1. a-tk
    20.07.2020 11:55

    У меня нет технической возможности проверить что происходит в компиляторах Microsoft, поэтому не особо про них рассказал. Однако протестировав онлайн компиляторы, я заметил что MSVC умеет анализировать структуру классов и удалять поле значения корректирования, если оно не требуется.

    Нет, MSVC поступает иначе. Я прошу прощения за самоцитирование, но я делал доклад, где было упомянуто сравнение в том числе генерации кода указателя на метод: www.youtube.com/watch?v=Ak0u8PX5tRU (примерно с 15 минут)
    (про вышеупомянутые ARM и AVR там тоже есть).


    1. mitinsvyat Автор
      20.07.2020 11:55

      Я тут почекал.
      В чем я ошибся?
      rextester.com/KSHZ89131


      1. a-tk
        20.07.2020 11:55

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


        1. mitinsvyat Автор
          20.07.2020 11:55

          Зачем ты тогда делал целый доклад про то что под капотом, если не следует делать предположений никаких?
          Или смотреть ассемблер — это не предположение (или не под капотом?), а простой примерчик — это предположение о том что под капотом?
          Я точно делаю что-то не то?


          1. a-tk
            20.07.2020 11:55

            Иногда приходится читать ассемблер, особенно если имеешь дело с экзотической платформой. Или, куда более редко, писать компилятор или его часть.


            1. mitinsvyat Автор
              20.07.2020 11:55

              С эти сложно поспорить, но как это стыкуется с предыдущими высказываниями?


            1. mitinsvyat Автор
              20.07.2020 11:55

              Я как-то не спорю ни с чем, просто хотел сказать что ассемблер тут не особо нужен вообще впринципе. Есть же надежная функция memcpy)
              Ну и си сам по себе хорошо с памятью все достает и кладет. Можно при желании неявно вызвать тот же memcpy без его прямого вызова


        1. demp
          20.07.2020 11:55

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


          В таком случае указатель на указатель будет действительно sizeof(void*), но это уже следующий уровень косвенности.


  1. sergio_nsk
    20.07.2020 11:55

    Может быть устаревшая, но всё равно полезно Указатели на функции-члены и реализация самых быстрых делегатов на С++. Отдельный раздел, дополняющий эту статью на Хабре: "Реализация указателей на функции-члены".


  1. anonymous
    20.07.2020 11:55


    1. demp
      20.07.2020 11:55

      Только если вам очень повезет, и указатель на функцию-член действительно будет размером sizeof(void*).


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


  1. anonymous
    20.07.2020 11:55


  1. anonymous
    20.07.2020 11:55


  1. anonymous
    20.07.2020 11:55