Русский
Русский
English
Статистика
Реклама

Указатели

Перевод Связные списки, трюки с указателями и хороший вкус

09.12.2020 00:05:51 | Автор: admin
В интервью на TED 2016 (14:10) Линус Торвальдс рассказывает о хорошем стиле программирования. В качестве примера приводит два варианта удаления элементов из односвязных списков (см. ниже). В первом варианте есть специальный случай, а в другом нет. Линус предпочитает второй.

Его комментарий:

[...] Не надо размышлять, почему здесь нет оператора if. Важно посмотреть на задачу с другой стороны и переписать её так, чтобы особый случай исчез и стал обычным случаем, и это хороший код. Л.Торвальдс

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

Подробно разберём это решение и его преимущества. В качестве бонуса показано не только удаление, но и вставка элемента через косвенную адресацию.

Код


Базовая структура данных для односвязного списка целых чисел показана на рис.1.


Рис. 1. Односвязный список из целых чисел

Числа это произвольно выбранные целочисленные значения, а стрелки соответствуют указателям: head это указатель типа IntListItem*, все блоки являются экземплярами структуры IntListItem, каждый со своей переменной (nextв коде) типа IntListItem*, которая указывает на следующий элемент.

Реализация структуры данных на Си:

struct IntListItem {    int value;    struct IntListItem* next;};typedef struct IntListItem IntListItem;struct IntList {    IntListItem* head;};typedef struct IntList IntList;

Также (минимальный) API:

/* The textbook version */void remove_cs101(IntList* l, IntListItem* target);/* A more elegant solution */void remove_elegant(IntList* l, IntListItem* target);

Теперь рассмотрим реализации remove_cs101() и remove_elegant(). Код примеров не противоречит псевдокоду из примера Линуса, но компилируется и запускается.

Базовая версия



Рис. 2. Концептуальная модель структуры данных списка в базовом алгоритме

void remove_cs101(IntList *l, IntListItem *target){    IntListItem *cur = l->head, *prev = NULL;    while (cur != target) {        prev = cur;        cur = cur->next;    }    if (prev) {        prev->next = cur->next;    } else {        l->head = cur->next;    }}

В стандартном подходе два указателя обхода cur и prev, которые отмечают текущую и предыдущую позицию обхода в списке соответственно. cur начинает с головы списка head и продвигается вперёд, пока цель не будет найдена. В свою очередь, prev начинается с NULL и впоследствии обновляется на предыдущее значение cur при каждом следующем продвижении вперёд. Когда цель найдена, алгоритм проверяет, не равен ли prev нулю. Если так, то cur указывает на первый элемент в списке, и в этом случае удаление означает перемещение головы списка вперёд.

Более элегантное решение


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

void remove_elegant(IntList *l, IntListItem *target){    IntListItem **p = &l->head;    while ((*p) != target) {        p = &(*p)->next;    }    *p = target->next;}

В коде применяется косвенный указатель p, содержащий адрес указателя на элемент списка, начиная с адреса head. В каждой итерации этот указатель расширяется, чтобы включить адрес указателя на следующий элемент списка, то есть адрес элемента next в текущем IntListItem. Когда указатель на элемент списка (*p) равен target, мы выходим из цикла поиска и удаляем элемент из списка.

Как это работает?


Косвенный указатель p даёт два концептуальных преимущества:

  1. Позволяет интерпретировать связный список таким образом, что указатель head становится неотъемлемой частью структуры данных. Это устраняет необходимость в специальном случае для удаления первого элемента.
  2. Также позволяет оценить состояние цикла while без необходимости отпускать указатель, указывающий на target. Это позволяет изменять указатель на target и обходиться одним итератором, в отличие от prev и cur.

Рассмотрим каждый пункт по очереди.

Интеграция указателя head


Стандартная модель интерпретирует связный список как последовательность экземпляров IntListItem. Начало последовательности можно получить с помощью указателя head. Это приводит к концептуальной модели, показанной выше на рис.2. Указатель head просто рассматривается как дескриптор для доступа к началу списка. prev и cur являются указателями типа IntListItem* и всегда указывают на элемент или NULL.

Элегантная реализация использует схему косвенной адресации, которая даёт другое представление о структуре данных:


Рис. 3. Концептуальная модель структуры данных списка в более элегантном решении

Здесь p представляет тип IntListItem** и содержит адрес указателя на текущий элемент списка. Когда указатель продвигается вперёд, его адрес меняется на следующий элемент.

В коде это выглядит как p = &(*p)->next:

  1. (*p): разыменовать адрес указателя на текущий элемент списка.
  2. ->next: снова разыменовать этот указатель и выбрать поле с адресом следующего элемента.
  3. &: взять адрес этого поля (то есть получить указатель на него).

Это соответствует интерпретации структуры данных, где список представляет собой последовательность указателей на элементы IntListItem (рис.3).

Последний штрих


Дополнительным преимуществом этой конкретной интерпретации является то, что она на протяжении всего обхода допускает редактирование указателя next для предшественника текущего элемента.

Если p содержит адрес указателя на элемент списка, сравнение в цикле поиска становится таким:

while ((*p) != target) {    ...}

Цикл поиска завершится, если (*p) равно target, и как только это произойдёт, мы всё равно сможем изменить (*p), так как удерживаем его адрес p. Таким образом, несмотря на итерацию цикла до конца, сохраняется дескриптор (адрес поля next или указатель head), который можно использовать для непосредственного изменения указателя на элемент.

Вот почему мы можем изменить входящий указатель на элемент, чтобы он указывал на другое место, используя *p = target->next, и поэтому нам не нужны указатели обхода prev и cur для удаления элемента.

Новое применение


Оказывается, ту же идею можно применить для крайне лаконичной реализации ещё одной функции в связных списках: insert_before(), то есть вставки данного элемента перед другим.

Вставка перед существующим элементом


Во-первых, добавим следующую декларацию в list.h:

void insert_before(IntList *l, IntListItem *before, IntListItem *item);

Функция возьмёт указатель на список l, указатель перед элементом в этом списке и указатель на новый элемент списка, который функция вставит перед ним.

Быстрый рефакторинг


Прежде чем двигаться дальше, оформим цикл поиска в отдельную функцию:

static inline IntListItem **find_indirect(IntList *l, IntListItem *target){    IntListItem **p = &l->head;    while ((*p) && (*p) != target) {        p = &(*p)->next;    }    return p;}

и используем её в remove_elegant():

void remove_elegant(IntList *l, IntListItem *target){    IntListItem **p = find_indirect(l, target);    *p = target->next;}

Реализация insert_before()


Используя find_indirect(), легко реализовать insert_before():

void insert_before(IntList *l, IntListItem *before, IntListItem *item){    IntListItem **p = find_indirect(l, before);    *p = item;    item->next = before;}

Особенно радует цельная семантика для крайних случаев: если before указывает на заголовок списка, новый элемент будет вставлен в начало, если before является нулевым или недействительным (то есть не существует в l), новый элемент будет добавлен в конце.

Заключение


Предпосылкой более элегантного решения для удаления элементов является одно простое изменение: косвенный указатель IntListItem** для итерации указателей на элементы списка. Всё остальное вытекает оттуда: отпадает необходимость в специальных случаях или ветвлениях. Достаточно одного итератора, чтобы найти и удалить целевой элемент. И оказывается, что тот же подход обеспечивает элегантное решение для вставки вообще и для вставки перед существующим элементом в частности.

Итак, возвращаясь к первоначальному замечанию Линуса: это показатель хорошего вкуса? Трудно сказать. Но явно налицо творческое и очень элегантное решение хорошо известной задачи.
Подробнее..

Динамическая типизация C

03.06.2021 00:16:55 | Автор: admin

Преамбула

Эта статья была написана и опубликована мной на своем сайте более десяти лет назад, сам сайт с тех пор канул в лету, а я так и не начал писать что-то более вразумительное в плане статей. Все ниже описанное является результатом исследования C как языка двадцатилетним парнем, а, следовательно, не претендует на звание учебного пособия, несмотря на стиль изложения. Тем не менее, я искренне надеюсь, что она побудит молодых разработчиков погрузиться в эксперименты с C также, как когда-то делал это я.

Предупреждение

Эта короткая статья, окажется абсолютно бесполезной для опытных программистов C/C++, но кому-то из начинающих, возможно, позволит сэкономить время. Хочу подчеркнуть, что в большинстве хороших книг по C/C++ данная тема рассмотрена в достаточной степени.

Динамическая и статическая типизация

Во многих интерпретируемых языках используется динамическая типизация. Такой подход позволяет хранить в переменной с одним именем значения разных типов. В языке C используется строгая типизация, что, на мой взгляд более, чем правильно. Однако бывают случаи (хоть и не так часто), когда гораздо удобней было бы использовать динамическую типизацию. Зачастую, такая потребность напрямую связана с некачественным проектированием, но не всегда. Не зря же в Qt присутствует тип QVariant.

Здесь мы поговорим про язык C, хотя все, что описано ниже, применимо и к C++.

Магия указателя пустоты

На самом деле, никакой динамической типизации в C нет и быть не может, однако существует универсальный указатель, тип которому void *. Объявление переменной такого типа, скажем, в качестве аргумента функции, позволяет передавать в нее указатель на переменную любого типа, что может быть крайне полезно. И вот он первый пример:

#include <stdio.h>int main(){void *var;int i = 22;var = &i;int *i_ptr = (int *)(var);if(i_ptr)printf("i_ptr: %d\n", *i_ptr);double d = 22.5;var = &d;double *d_ptr = (double *)(var);if(d_ptr)printf("d_ptr: %f\n", *d_ptr);return 0;}

Вывод:

i_ptr: 22d_ptr: 22.500000

Здесь мы одному и тому же указателю присвоили указатели (простите за тавтологию) как на тип int, так и на double.

Примечание: в некоторых источниках говорится о том, что присвоение указателю типа void * следует производить также с приведением типа. Возможно, это особенности конкретных компиляторов, GCC же без ругательств обработал предыдущий пример. Но, если возникли ошибки, попробуйте:

void *var;int i = 22;var = (void *)(&i);

Так точно должно работать.

Первый пример не нес никакой полезной нагрузки. Попробуем ее поискать во втором примере:

#include <stdio.h>int lilround(const void *arg, const char type){if(type == 0) // если передан intreturn *((int *)arg); // просто возвращаем значение целого аргумента// если передан doubledouble a = *((double *)arg);int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){int i = 12;double j = 12.5;printf("round int: %d\n", lilround(&i, 0)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j, 1)); // пытаемся округлить число двойной точностиreturn 0;}

Вывод:

round int: 12round double: 13

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

Для тех, кому хочется слегка поломать мозг альтернативная реализация функции lilround():

int lilround(const void *arg, const char type){return type == 0? *((int *)arg): ((int)*((double *)arg) == (int)(*((double *)arg) - 0.5)? (int)(*((double *)arg)) + 1: (int)(*((double *)arg)));}

Но для того, чтобы функция знала с чем имеет дело мы передаем в нее второй аргумент. Если он равен 0, то первый интерпретируется как указатель на int, если нет как указатель на double. Такой подход может во многих случаях сгодиться, но, в основном, смысл использования универсального указателя как раз-таки в том, чтобы не указывать тип передаваемого параметра.

Предположим, что у нас две или более структур (struct), которые содержат различный набор полей. Но так уж получилось, что нужно передать их одной и той же функции. Почему так вышло рассуждать не будем.

Что же делать? Ответ почти очевиден: передавать их в виде указателя неопределенного типа. И, все ничего, но как же тогда наша функция узнает об их типе? Все просто: в самое начало структуры добавим поле type, в которое будем записывать идентификатор структуры, по которому наша функция и будет определять ее тип, предварительно приведя неопределенный указатель к любой из структур. Идентификатором может быть поле любого типа, хоть еще одна структура, но оно должно стоять первым в каждой из структур и иметь один и тот же тип. Такое условие следует из способа расположения структур в памяти компьютера. Если написать так:

typedef struct {char type;int value;} iStruct;typedef struct {char type;double value;} dStruct;

То все сработает корректно. Но если написать так:

typedef struct {char type;int value;} iStruct;typedef struct {double value;char type;} dStruct;

То программа соберется, но во время работы выдаст неверный вариант, так как, в зависимости от того к какой структуре приведем указатель, в случае обращения программа попытается считать первый байт из double value или, вообще, неизвестно откуда.

А вот и пример использования такого подхода:

#include <stdio.h>#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыdouble value; // значение двойной точности} dStruct;#pragma pack(pop)int lilround(const void *arg){iStruct *s = (iStruct *)arg;if(s->type == 0) // если передан intreturn s->value; // просто возвращаем значение целого аргумента// если передан doubledouble a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){iStruct i;i.type = 0;i.value = 12;dStruct j;j.type = 1;j.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}

Примечание: директивы компилятора #pragma pack(push, 1) и #pragma pack(pop) необходимо помещать до и после каждой специфической структуры, соответственно. Данная директива используется для выравнивания структуры в памяти, что обеспечит корректность метода. Однако не стоит также забывать о порядке полей.

В теле функции аргумент приводится к структуре iStruct и проверяется значение поля type. Дальше уже аргумент приводится к другому типу структуры, если нужно.

Перед тем, как перейти к последней части, стоить пояснить работу с простыми void-указателями. Сложение, вычитание, инкремент, декремент и т.д. не запрещены для типа void, однако могут вызывать предупреждения в C++ и не вполне понятное поведение. Поэтому необходимо сперва привести аргумент к нужному типу, а уж затем совершать операцию:

#include <stdio.h>int main(){int i = 22;void *var = &i; // объявляем void-указатель и инициализируем его адресом переменной i(*(int *)var)++; // приводим void-указатель к int-указателю, разыменовываем его и производим операцию инкрементаprintf("result: %d\n", i); // выводим измененное значение ireturn 0;}

Исходя из кода: для совершения операции необходимо записать (*(int *)var) и уже к данной записи применить требуемый оператор.

Подобие интерфейсов в C

Вернемся к структурам. Если структура "засылается" далеко и глубоко в код, возможно даже чужой, то имеет смысл передать вместе с ней и методы, которые будут обрабатывать ее значения. Для этого создадим дополнительную структуру, которая заменит поле type:

typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;

Опишем реализации указанных функций для разных сткрутур, а также функции инициализации разных типов структур. Результат ниже:

#include <stdio.h>typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииdouble value; // значение двойной точности} dStruct;#pragma pack(pop)void intPrintType() // вывод типа для iStruct{printf("integer\n");}int intRound(const void *arg) // округление для iStruct{return ((iStruct *)arg)->value; // приводим аргумент к указателю на iStruct и возвращаем значение}void intInit(iStruct *s) // инициализация iStruct{s->m.printType = intPrintType; // задаем полю printType указатель на функцию вывода для iStructs->m.round = intRound; // задаем полю round указатель на функцию округления для iStructs->value = 0;}void doublePrintType() // вывод типа для dStruct{printf("double\n");}int doubleRound(const void *arg) // округление для dStruct{double a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}void doubleInit(dStruct *s){s->m.printType = doublePrintType; // задаем полю printType указатель на функцию вывода для dStructs->m.round = doubleRound; // задаем полю round указатель на функцию округления для dStructs->value = 0;}int lilround(const void *arg){((iStruct *)arg)->m.printType(); // приводим к любой структуре, в данном случае iStruct, и выводим типreturn ((iStruct *)arg)->m.round(arg); // возвращаем округленное значение}int main(){iStruct i;intInit(&i); // инициализируем целочисленную структуруi.value = 12;dStruct j;doubleInit(&j); // инициализируем структуру с данными двойной точностиj.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}

Вывод:

integerround int: 12doubleround double: 13

Примечание: директивами компилятора следует обрамлять только те структуры, которые необходимо использовать в качестве аргумента для void-указателя.

Заключение

В последнем примере можно заметить сходство с ОПП, что, в общем-то, правда. Здесь мы создаем структуру, инициализируем ее, задаем ее ключевым полям значения и вызываем функцию округления, которая, кстати говоря, крайне упростилась, хотя мы сюда же добавили вывод типа аргумента. На этом все. И помните, что применять подобные конструкции нужно размумно, ведь, в подавляющем большинстве задач их наличие не требуется.

Подробнее..

Из песочницы Что нового я узнал о компьютере, когда решил написать Chrome Dino на C

22.08.2020 14:05:15 | Автор: admin


Немного о проекте


Для знакомства с языком с я решил написать небольшое приложение chrome dino, которое является неудачным клоном всем знакомого динозаврика из хрома. Из-за отсутствия классов в си я решил изобрести свой велосипед: поля и методы класса поместил в структуру, конструктором стала функция, которая возвращает эту структуру. Внутренние поля и методы скрыты, указав перед ними static. (Про это есть несколько статей)

.

typedef struct Barrier {    int width, height;    int *picture;    int x0, y0;} Barrier;Barrier* new_Barrier() {  Barrier* barrier = NULL;  barrier = malloc(sizeof(Barrier));  return barrier;}

Для удобства работы с графикой изображения хранятся в виде матрицы со
значениями [0, 1, 2, 3], это решение подтолкнуло на написание этой
статьи.
0 прозрачный,
1 синий,
2 черный,
3 серый.




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


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


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


Для перебора ряда данных (одномерный массив) берется адрес первого элемента, а затем в цикле (с шагом = размер типа данных) осуществляется переход на следующий адрес.


int n = 10;int step = sizeof(Barrier);Barrier* barrier = malloc(step * n);for (int i = 0; i < n; i += step) {  *(barrier + i) = data;}

Реализовать поиск элемента в двумерном массиве становится сложнее, по причине того, что весь массив записывается в последовательные ячейки и поиск приходится осуществлять по строке, а не матрице. Для поиска элемента матрицы в строке можно по формуле:


$$display$$A[ i ][ j ] = i * w + j$$display$$



где A двумерный массив,
&nbspi индекс строки,
&nbspj индекс столбца,
&nbspw длинна вложенного массива A (ширина массива)

Еще сложнее найти элемент трехмерного массива, для его поиска необходимо воспользоваться формулой:


$$display$$B[ i ][ j ][ k ] = i * w * h + j * w + k$$display$$



где B трехмерная матрица,
&nbspk индекс ряда двумерных матриц,
&nbsph длина вложенного массива B(высота матрицы).

Понятно, что для реализации работы с большей вложенностью массивов необходим единый алгоритм поиска его элемента:


$$display$$C[a_1][a_2]...[a_n] = \sum_{i = 1}^n a_i * L_{i - 1} * ... * L_1$$display$$


где C n-мерный массив,
&nbspn вложенность,
&nbsp$inline$a_i$inline$ индекс i-го массива,
&nbsp$inline$L_i$inline$ длинна массива i.

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




Помнить о памяти


В высокоуровневых языках имеются сборщики мусора и выделение памяти работает под капотом языка. В низкоуровневых языках приходится это делать самостоятельно: перед тем как записать данные в память, нужно зарезервировать некоторую ее область. Для набора данных необходимо самостоятельно высчитывать количество памяти для резервации.


Рассмотрим случай одномерного массива c элементами типа Barrier. Если количество элементов известно заранее, то для такого массива память выделяется один раз. В случае динамического массива, приходится каждый раз пересчитывать размер выделяемой для него памяти (если длина массива изменяется). Тогда для реализации стандартного метода push (добавление элемента в конец массива) необходимо выделить область памяти (равный сумме размера старого массива и величины типа элемента) и записать туда новый массив, а старый удалить. То же и с удалением элемента из массива.


int n = 10;int step = sizeof(Barrier);Barrier* barrier = malloc(step * n);for (int i = 0; i < n; i += step) {    *(barrier + i) = data;}n = 11;free(barrier);barrier = malloc(step * n);for (int i = 0; i < n; i += step) {    *(barrier + i) = data;}

Можно сделать вывод: изменение размера массива довольно затруднительная операция, особенно если идет большой цикл добавления элементов в массив. Некоторые высокоуровневые языки выделяют чуть больше области памяти под массивы (например, ArrayList в java), видимо для повышения производительности.



Не примитивный примитив


В высокоуровневых языках присутствуют как типы данных, передающиеся по ссылке, так и данные передающиеся по значению. Но для передачи данных по значению необходимо иметь ссылку на переменную, т.е. примитивный тип не такой уж примитивный? В ассемблере любая переменная хранит как ссылку на ячейку памяти, так и значение, которое в ней хранится. Каждая переменная хранить адрес своей ячейки и значение (значением также может быть адрес другой ячейки). Но где же хранятся ссылки на адреса ячеек памяти? Оказывается, когда компилятор создает машинный код, он автоматически заменяет имена всех переменных их смещениями. Значит, каждая переменная может передаваться по ссылке, но в высокоуровневых языках эта возможность скрыта для разработчика.



Посмотреть проект можно туть.
Подробнее..

Указатели на методы классов в C

20.07.2020 14:14:41 | Автор: admin
Привет, интернет.

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

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

Давайте разберемся что и почему происходит.

Взглянем на код.
#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: uwtabledefine 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.virtualmemptr.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.endmemptr.nonvirtual:                                ; preds = %entry  %memptr.nonvirtualfn = inttoptr i64 %.unpack to void (%struct.A*)*  br label %memptr.endmemptr.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; // errorvoid (*b1)(C*) = &B::bar; // errorvoid (*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 , Указатели

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru