Для группировки переменных в C++ мы можем использовать несколько механизмов. Два очень похожих это struct и class. В контексте реверс-инжиниринга это две очень важные темы, потому что на них строится структура памяти в играх, включая данные об объектах и методов виртуальных таблиц. Но и без этого это базовый инструмент языка, изучению которого стоит уделить должное внимание. Исторически структуры больше используются для представления и работы с простыми данными, а классы - для сложных абстракций и реализации ООП. Большинство аспектов относительно этих двух инструментов - одинаковы, но их достаточно много. По этой причине я разделю этот материал на две темы, но воспринимайте его почти как единое целое. Итог о различиях мы подведём в конце темы классов.

Логичнее начать со struct, ведь это появилось раньше.

Определение структур

В отношении структур также применяются понятия объявления и определения, как и к переменным/функциям.

Самый очевидный пример в нашем контексте это вектор.

struct Vector {
    float x, y;
};

Таким образом мы объединили две переменных x и y в один тип Vector.

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

Иначе говоря x и y это поля структуры Vector.

Обратите внимание на ; после } - это часть синтаксиса.

Таким же образом мы можем реализовать векторы разных размерностей.

struct Vector2 {
    float x, y;
};

struct Vector3 {
    float x;
    float y;
    float z;
};

struct Vector4 {
    float x, y, z, w;
};

Мы можем описывать поля структуры как через запятую, так и с новой строчкой, как показано в Vector3.

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

struct Vector3 {
    float x, y, z;
};

struct Player {
    std::string name;
    Vector3 position;
};

Инициализация и взаимодействие

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

Использовать объявленную структуру мы можем так:

struct Vector3 {
    float x, y, z;
};

int main() {
    Vector3 v;
    v.x = 3.2f;
    v.y = 5.1f;
    v.z = 2.f;
    std::cout << v.x << " " << v.y << " " << v.z << std::endl;
    v.x *= v.z;
    std::cout << v.x << " " << v.y << " " << v.z << std::endl;
}
3.2 5.1 2
6.4 5.1 2

Доступ к полям структуры выполняется через точку.

Работать с полями структуры в этом случае мы можем точно также, как и с обычными переменными типа float.

Вложенные структуры

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

struct Transform {
    Vector3 position;
    Vector3 rotation;
};

int main() {
    Transform transform;
    transform.position.x = 10.0f;
    transform.rotation.y = 90.0f;

    std::cout << transform.position.x << " " 
    << transform.rotation.y << std::endl;
}
10 90

Обращение к полям вложенной структуры выполняется через две точки подряд - сначала имя поля-структуры, потом имя её собственного поля.

Это очень частая ситуация как и при разработке так и при реверс-инжиниринге, при том есть интересные особенности, связанные с этим при анализе представления объектов в памяти, о чём мы поговорим позже.

Агрегатная инициализация

Для заполнения полей нулями можно использовать агрегатную инициализацию с нулём значений.

Vector3 v{};
std::cout << v.x << " " << v.y << " " << v.z << std::endl;
0 0 0

Таким же образом можно без индивидуального обращения к полю задать его значение.

Vector3 v{1,2};
std::cout << v.x << " " << v.y << " " << v.z << std::endl;
1 2 0

Если значений меньше - остальные поля арифметических типов будут инициализированы нулём.

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

struct Player {
    std::string name;
    Vector3 position;
};

int main() {
    Player p;
    std::cout << p.name << std::endl;
    std::cout << p.position.x << std::endl;
}

3.237e-43

Тут важно отметить, что переменная name будет содержать пустую строку, потому что класс std::string при создании вызывает конструктор по умолчанию.

Значения по умолчанию

Начиная с C++11 можно задать значения по умолчанию прямо в объявлении полей структуры.

struct Player {
    std::string name;
    Vector3 position;
    int health = 100;
};

int main() {
    Player p;
    std::cout << p.health << std::endl;
}
100

Так поле health гарантированно получит значение 100, если при создании переменной не было передано другое, как показано в следующем примере.

int main() {
    Player p{ "Name", {1.0f, 2.0f, 3.0f}, 200 };
    std::cout << p.health << std::endl;
}
200

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

Массивы структур

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

int main() {
    Vector3 positions[3] = {
        {0.0f, 0.0f, 0.0f},
        {5.0f, 0.0f, 0.0f},
        {10.0f, 0.0f, 0.0f}
    };

    for (int i = 0; i < 3; i++) {
        std::cout << positions[i].x << " ";
    }
}
0 5 10

Доступ к полю структуры внутри массива выполняется через точку после обращения по индексу - positions[i].x.

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

Стоит провести параллель с тем, как работает оператор sizeof и как располагаются структуры в массиве.

int main() {
    Vector3 positions[3];
    for (size_t i = 0; i < 3; i++)
    {
        std::cout << &positions[i] << std::endl;
        //&positions + i * sizeof(Vector3)
    }
}
00000094012FF7C0
00000094012FF7CC
00000094012FF7D8

Если sizeof(Vector3) == 0xC, то &positions[i] смещён относительно начала массива на i * sizeof(Vector3).

Это напрямую связано с анализом массивов объектов в играх.

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

Структуры и функции

Структуру, как и любой другой тип, можно возвращать и передавать в функции.

Передача структуры в функцию

void printVector(Vector3 v) {
    std::cout << v.x << " " << v.y << " " << v.z << std::endl;
}

int main() {
    Vector3 v{ 10.0f, 20.0f, 5.0f };
    printVector(v);
}
10 20 5

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

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

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

void Divide(Vector3& v, float scalar)
{
    v.x /= scalar;
    v.y /= scalar;
    v.z /= scalar;
}

int main() {
    Vector3 playerPos{ 10.0f, 0.0f, 5.0f };
    printVector(playerPos);
    Divide(playerPos, 5);
    printVector(playerPos);
}
10 0 5
2 0 1

Здесь v в аргументах - это не копия, а ссылка на исходную переменную playerPos, поэтому изменение полей внутри функции меняет исходный объект.

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

Возврат структуры из функции

Возврат значения может выглядеть так.

Vector3 Divide(Vector3& v, float scalar)
{
    return Vector3{ v.x / scalar, v.y / scalar, v.z / scalar };
}

int main() {
    Vector3 playerPos{ 10.0f, 0.0f, 5.0f };
    printVector(playerPos);
    Vector3 newVector = Divide(playerPos, 5);
    printVector(newVector);
}
10 0 5
2 0 1

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

Кроме того - она не должна быть оформлена в виде отдельной функции, потому что имеет прямое отношение к структуре Vector3.

Далее посмотрим, как это можно исправить.

Внутренние методы структур

У структур могут быть функции, которые применяются к полям текущего объекта.

Такие участки кода называются методами.

Классика для вектора - это получение его длины.

struct Vector3 {
    float x, y, z;

    float Length() const
    {
        return std::sqrt(x * x + y * y + z * z);
    }
};

int main() {
    Vector3 v{ 1.0f, 2.0f, 3.0f };

    std::cout << v.Length();
}
3.74166

Тут мы инициализируем структуру Vector3 и вызываем её метод Length.

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

Оно используется после названия метода в таких случаях.

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

Функцию Divide из прошлых примеров стоило бы записать как метод структуры Vector3, а не как отдельную функцию.

Чтобы было удобно выводить данные и отлаживать работу обычно определяют метод print, to_string или определяют оператор потока вывода для этой структуры.

Учитывая всё это мы можем написать следующий пример деления вектора на скаляр.

struct Vector3 {
    float x, y, z;

    void Divide(float scalar) {
        x /= scalar;
        y /= scalar;
        z /= scalar;
    }

    std::string toString() const {
        return 
            std::to_string(x) + " " + 
            std::to_string(y) + " " + 
            std::to_string(z);
    }
};

int main() {
    Vector3 v{ 1.0f, 2.0f, 3.0f };
    std::cout << v.toString() << std::endl;
    v.Divide(2);
    std::cout << v.toString() << std::endl;
}
1.000000 2.000000 3.000000
0.500000 1.000000 1.500000

Перегрузка операторов

Структура - это просто объединение данных, и компилятор не знает, что означает "сложить" или "сравнить" два экземпляра структуры, даже если интуитивно кажется, что это должно работать.

struct ImVec2
{
    float                                   x, y;
    //...
};

ImVec2 a{ 1.0f, 2.0f };
ImVec2 b{ 3.0f, 4.0f };

ImVec2 c = a + b; // no operator "+" matches these operands

Мы математически знаем, что такое сложение двух векторов, но в ImGui это не реализовано по умолчанию.

Структура ImVec2 описана примерно также, как в примере выше, и оператор + для неё не определён по умолчанию.

Если попытаться сложить два ImVec2 - вы получите ошибку компиляции, несмотря на то, что оба поля структуры имеют тип float, для которого сложение отлично работает.

Та же ситуация с оператором сравнения.

ImVec2 a{ 1.0f, 2.0f };
ImVec2 b{ 1.0f, 2.0f };

std::cout << (a == b); // no operator "==" matches these operands

Сравнить структуры оператором == нельзя, пока вы сами не определите, что для этой структуры значит "равенство" двух экземпляров.

Мы можем определить оператор + для вектора.

Я предпочитаю использовать отдельный от ImVec-подобных тип.

Это обусловлено несколькими факторами:

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

Таким образом давайте определим, что значит оператор + для двух векторов.

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

Я регулярно публикую материал и разборы ситуаций для различных игр, движков, а так же подходов разработки включая Internal, External и DMA.

Эта публикация содержит только часть статьи.

Полные уроки по C++, реверс-инжинирингу и разработке софта вы можете найти тут: