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

Решил написать статью об указателях на методы классов. Недавно мне пришлось столкнуться с тем, как они работают изнутри, когда писал некоторые вещи ориентированные под компилятор. Эти указатели работают не совсем как обычные указатели, не имеют возможности быть приведенными в 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. Код выложен тут.

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