Привет, Интернет. Я решил написать статью об указателях на методы класса.
Недавно мне пришлось разбираться с тем, как они работают изнутри, когда я писал некоторые вещи, ориентированные на компилятор.
Эти указатели не работают точно так же, как обычные указатели, не могут быть преобразованы в 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 (если виртуальное)
- исправляя `это`
Метод класса имеет невидимый первый параметр — указатель на 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 умеет анализировать структуру классов и удалять поле значения корректировки, если оно не требуется.
#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 #указатели
-
Устав Для Молодого Веб-Дизайнера-Фрилансера
19 Oct, 24 -
Психологи Не Знают Теории Вероятностей
19 Oct, 24 -
The Guardian Сочла Касперского Агентом Кгб
19 Oct, 24 -
Что Мне Не Нравится В Windows 10
19 Oct, 24 -
Разработка Приложений Android С Помощью C#
19 Oct, 24