Указатели На Методы Класса В C++

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

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

Эти указатели не работают точно так же, как обычные указатели, не могут быть преобразованы в 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 можно найти в Википедия , Официальный сайт ЛЛВМ И Кланг .

Что такое:

  • Глядя на первую строку, вы можете видеть, что указатель на метод представляет собой структуру `{ i64, i64 }`, а не обычный указатель.

    Эта структура содержит два элемента i64, в которых могут разместиться два обычных указателя.

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

    В общем случае мы не можем преобразовать 16 байт в 8 байт без потерь.

  • В блоке `entry`, начиная с 5-й строки, видно, что указатель `this` корректируется.

    Это означает, что компилятор добавляет значение второго элемента этой структуры к указателю на `this`, а позже передает его вызову метода в блоке `memptr.end`.

  • Что-то странное происходит в блоке `entry` в строке 14 с первым элементом структуры.

    Компилятор оценивает выражение, подобное следующему: `bool isvirtual = val & 1`.

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

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

    Эти предположения происходят в блоке memptr.nonvirtual.

  • Если указатель метода указывает на виртуальный метод, тогда все сложнее.

    Сначала из первого элемента структуры вычитается единица, и вычисленное значение является смещением для виртуальной таблицы, указатель на которую берется из значения указателя на `this`.

    Это происходит в блоке memptr.virtual.

Итого внутри указателя метода находятся следующие данные:
  • Информация, виртуальный ли он
  • Указатель на адрес метода (если не виртуальный)
  • Смещение в vtable (если виртуальное)
  • исправляя `это`
О том, как вызывается метод в 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. Код опубликован здесь .

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

Надеюсь, это было кому-то полезно.

Теги: #C++ #llvm #Clang #указатели

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.