𝐑𝐨𝐨𝐭𝐓𝐨𝐨𝐥 𝐁𝐥𝐨𝐠
194 subscribers
570 photos
48 videos
5 files
60 links
ニャン
Download Telegram
Искусство Построения Архитектуры Проекта

Пролог

Как однажды говорил Линус Торвальдс: «Плохие программисты беспокоятся о коде. Хорошие программисты беспокоятся о структурах данных и отношениях между ними»

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

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

Сразу оговорюсь: Я не вешаю себе корону и не считаю себя 100% сеньором как таковым. Хоть и знаю про построение архитектур, имею опыт, прописываю весь бекенд и документации к нему, но опять же: В своей команде Portal: Solver - я один-единственный программист. Так что формально команда есть, но из моей области - только я. И конечно в одиночку кричать что я сеньор - глупо...

Get Ready. Think Cold.
❤3🔥1💯1
This media is not supported in your browser
VIEW IN TELEGRAM
Искусство Построения Архитектуры Проекта

Глава 0: Реализация функций

Перед прочтением стоит упомянуть что такое "Реализованные функции" ?
Когда вы пишите:
int Foo(int a, int b) {
return a + b;
}

^ Это является реализованной функцией, ведь вы объявили ее: "int Add(int a, int b)" и сразу реализовали тело функции: "return a + b;" - так компилятор сразу может объявить функцию Add и скомпилировать ее "внутрянку" в машинный код

А нереализованная функция выглядит примерно так:
int Foo(int a, int b);

^ Вы знаете что внутри этой функции? Вот и компилятор не знает что там! А поэтому не может скомпилировать ее в машинный код (ведь он не знает что внутри ¯\_(ツ)_/¯) - это и есть нереализованная функция

Оговорка: В стандарте С++ корректнее называть "объявление функции", но в данной серии постов мы будем использовать глагол "объявить" как "создание переменной/функции. Просто очерк в коде, что она есть", а под словом "реализация" будем иметь ввиду "прописывание тела функции"

Implement Your Functions. Think Cold.
❤3❤‍🔥1💯1
Искусство Построения Архитектуры Проекта

Глава 1: Аксиома Компиляции

Еще с самых основ в С/C++ была идея компиляции такова: Каждый .c/.cpp файл с кодом компилируется в промежуточный файл компиляции с расширением .o/.obj, а затем линковщик (линкер) соединяет их все вместе в единый бинарный файл

На будущее: Мы будем работать с Unreal Engine на С++ и ОС Windows, поэтому, нам будет привычнее говорить про форматы .cpp и .obj соответственно

Если не углубляться в стандартный С++, то грубо говоря: Сколько .cpp - столько и .obj

Но что хранит в себе .obj файл? По сути информацию о функциях/переменных и скомпилированный код:
- Машинный код функций - Скомпилированный вариант реализованных функций на ассемблере
- Таблицу символов - Какие переменные и функции объявлены в этом .cpp файле
- Список неразрешенных символов - Какие функции НЕ реализованы в данном файле (лишь объявлены)

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

Такая механика позволяет, например, изменить 1 .cpp файл, который превратится в 1 .obj файл, а затем достаточно сделать линковку этого нового файла с другими файлами (.obj) и получить тот же бинарник, не перекомпилируя все .cpp файлы. Удобно как никак!

Теперь вы знаете суть компиляции. Помните эту концепцию - еще не раз вернёмся к ней ;)

Know Your Compiler. Think Cold
❤2🔥1🤩1
This media is not supported in your browser
VIEW IN TELEGRAM
❤‍🔥1🤩1
Искусство Построения Архитектуры Проекта

Глава 2: Препроцессор

Препроцессор как квантовая физика: "Если вы понимаете препроцессор, значит вы его не понимаете"

Его легко понять, но в то же время нужно учитывать множество вещей. Но начнем с основ. Что делает этот ваш препроцессор ? По сути: Он преобразует удобный для человека код в форму, понятную компилятору. Наверняка вы слышали такие директивы как: #include, #define, #if, #else, #endif, #ifdef, #pragma once, #pragma region/endregion и другие...

Результатом препроцессора является Единица Трансляции (TU - Translation Unit) - это тот-же исходный код .cpp файла, но со всеми включенными заголовочными файлами (#include), раскрытыми макросами (#define) и обработанными условиями (#if, #else, #endif, #ifdef). Получившийся текст передается компилятору.

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

- #include "Path" - Берет файл из этого пути, просто копирует его содержимое и вставляет его вместо себя. То есть буквально вставляет содержимое одного файла в другой (до компиляции). Пример:

Что видит человек:
// main.cpp

#include "MathUtils.h"

int main() {
int a = 5, b = 3;
int sum = Add(a, b);
return sum;
}

// MathUtils.h

int Add(int x, int y) {
return x + y;
}


Что видит компилятор:

// На месте, где был #include "MathUtils.h"
int Add(int x, int y) {
return x + y;
}
// Он грубо вставляет код с файла MathUtils.h

int main() {
int a = 5, b = 3;
int sum = Add(a, b);
return sum;
}


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

Что видит человек:
#define PI 3.14
#define RADIUS 2.0
float Length = 2 * RADIUS * PI;
float Area = PI * RADIUS * RADIUS;


Что видит компилятор:
float Length = 2 * 2.0 * 3.14;
float Area = 3.14 * 2.0 * 2.0;


- #if, #else, #endif, #ifdef - Условная компиляция. Позволяет включать/исключать куски кода по условию. Крайне полезная штука

- #pragma region/endregion - Не стандартные С++ директивы (Но поддерживаются в Visual Studio, Rider, CLion и другие). Сделаны чисто для визуального "сворачивания" кода в области/регионы. Опять же - созданы только для IDE, никак не влияют на компиляцию (чисто косметическая функция).

Про #pragma once поговорим в следующей главе

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

Так, например, можно узнать информацию о:
- Компиляторе - MSVC, MinGW, GCC и другие
- ОС - Windows, Linux, macOS и тд.
- Какой тип сборки - Debug, Release
А в контексте Unreal Engine:
- Какая целевая сборка проекта - Development, DebugGame, Shipping

На этом теоретическая часть подходит к концу. Хотел сделать главу 3 про единицы трансляции, но она такая мелкая, что легче ее здесь расписать. В следующей главе уже поговорим про Forward-Declaration и заголовочные (.h) файлы

See the Code Before the Compiler. Think Cold.
❤2🔥1
This media is not supported in your browser
VIEW IN TELEGRAM
🤣1😎1
Искусство Построения Архитектуры Проекта

Глава 3: Заголовочные файлы и щепотка магии в Forward Declaration

Вспомним:
- Глава 0 - Для нас объявить функцию означает "Просто очерк в коде", а реализовать - "Прописать тело функции (ее внутрянку)"
- Глава 1 - Если мы в одном .cpp файле объявим функцию, а в другом реализуем, то Линкер свяжет объявление с реализацией и все будет в порядке
- Глава 2 - #include "Path/File.h" - открывает файл по пути и заменяет себя на его содержимое

По сути, если еще раз прочитать Главу 0 и Главу 1, то можете учуять тонкую взаимосвязь. Поздравляю, вы только что заметили механизм в С++ под названием Forward Declaration!

Ладно, начнем с примера и плавно раскроем тему - допустим у нас есть библиотека математики:
// Math.cpp

int Add(int a, int b) {
  return a + b;
}


Теперь я хочу вызвать ее в другом .cpp файле, как это сделать? Верно, объявить функцию int Add(int a, int b)!
// main.cpp

int Add(int a, int b);
// Только объявили функцию
// Реализация в другом файле

int main() {
  int sum = Add(2, 3);
  return 0;
}


Что произойдет под капотом? Конечно, компилятор скомпилирует Math.cpp и main.cpp (в котором будет нереализованная функция int Add(int a, int b)), а затем Линкер найдет реализацию этой функции в Math.obj и свяжет ее, тем самым мы получим один цельный и рабочий бинарный файл. Магия! Добро пожаловать в Forward Declaration! Ибо через эту строку:
int Add(int a, int b);

Мы неформально договариваемся с компилятором: "Да, бро, есть такая функция Add(...) и ты не знаешь ее реализацию. Но поверь, я обещаю что при линковке ты найдешь ее" - а это, по сути, сам смысл Forward Declaration в чистом виде.

Но если я хочу добавить функцию int Sub(int a, int b)? Тогда придется еще раз объявить ее в main.cpp. Хорошо.... А если таких функций будет за 200+ штук? Что тогда?

А тогда дяденька Бьёрн Страуструп подумал и добавил такое понятие как Заголовочный файл с расширением .h в котором он соберет все Forward Declaration в один пучок. Разберем на практике:
Создадим заголовочный файл:
// Math.h

int Add(int a, int b);
// Лишь объявили функцию!


И теперь в Math.cpp мы можем реализовать функцию:
// Math.cpp
#include "Math.h"

int Add(int a, int b) {
  return a + b;
}


А в main.cpp просто воспользоваться ею:
// main.cpp
#include "Math.h"

int main() {
  int sum = Add(2, 3);
  return 0;
}


И по сути при препроцессинге, при раскрытии #include "Math.h" препроцессором мы просто получим Forward Declaration функции int Add(int a, int b) в начале .cpp файлов что, собственно говоря, нам и нужно было ¯\_(ツ)_/¯

Кстати, в Math.cpp при раскрытии препроцессором получится так, что сначала мы объявляем функцию Add(...), а через пару строк ниже реализуем ее. В принципе - так делать можно, Америку мы не открываем, вселенная не схлопнится :D

Какие плюсы? Например теперь чтобы вызвать/реализовать функцию достаточно подключить .h файл через #include, а затем препроцессор сделает всю работу сам. Красотень же ведь! Сила плюсов :D

Интересный факт: Еще вы можете встретить два фундаментальных формата заголовков - .h и .hpp. Изначально .hpp задумывался как способ отделить C++'шные заголовки от C'шных, но сегодня разница скорее культурная: .h - традиционный выбор, особенно в больших проектах (Unreal Engine, Microsoft). .hpp - выбор популярных C++ библиотек и новых проектов. Так что технической разницы нет - оба работают одинаково. Тут наверное философский вопрос :D

Теперь вы частично знаете магию плюсов и основную механику разделения кода на .h / .cpp файлы - Поздравляю, ибо большинство новичков спотыкаются на этом (даже я в свое время-), но поняв как это работает по капотом, осознаешь всю простоту и мощь плюсов! В следующей главе поговорим про цикличность подключаемых заголовочных файлов и первые способы оптимизации через Forward Declaration.


Declare Now - Implement Later. Think Cold.
❤2🔥1💯1
This media is not supported in your browser
VIEW IN TELEGRAM
❤1🤩1
А потом выясняется что весь проект - это 1 файл на 10 тысяч строчек кода без документации или хотя бы намека на комментарии 😁
😱5❤1🤣1🤓1
Искусство Построения Архитектуры Проекта

Глава 4: "Квантовая запутанность" классов и "теория большого #includ'а"

Вспомним:
- Глава 2 - #include "Path/File.h" - открывает файл по пути и жестко заменяет себя на его содержимое
- Глава 3 - Forward Declaration в связке с .h файлом позволяет в других файлах только объявить функцию, а в одном конкретном .cpp файле реализовать ее. Затем линкер совместит все нереализованные функции с их реализациями (Глава 0)

Не буду душнить теорией, начнем сразу с кейса: У нас есть класс Player и World которые взаимосвязаны - игрок может получить мир в котором находится, мир может узнать что за игрок в нем живет. Составим код:
// Player.h

class Player {
World* World;
}

// World.h

class World {
Player* Player;
}

Но если мы запустим компиляцию, то получим ошибку, ведь формально компилятор не знает что такое World в классе игрока и Player в классе мира аналогично

Какова идея? Вспомним с Главы 2, что мы можем подключить .h файл в котором будут объявлены классы/функции. Сделаем:
// Player.h
#include "World.h"

class Player {
World* World;
};

// World.h
#include "Player.h"

class World {
Player* Player;
};

^ И вот тут мы получаем уроборос .h файлов, ибо взглянем это с препроцессора:
↓ Он открывает Player.h
↓ Внутри Player.h есть #include "World.h"
↓ Он открывает World.h, заменяет строку на его содержимое
↓ Внутри World.h есть #include "Player.h"
↓ Он открывает Player.h, заменяет строку на его содержимое
↓ Внутри Player.h есть #include "World.h"
↓ Он открывает World.h, заменяет строку на его содержимое
↓ Внутри World.h есть #include "Player.h"...
↓ ...
Безумие, не так ли? В этом и суть Циклической зависимости (Circular Dependency) которую мы только что открыли! Но и у этого есть пару решений

Еще в Главе 2 я упомянул про #pragma once - что ж, это его звездный час! Ибо #pragma once так и говорит препроцессору - этот .h файл подключать ТОЛЬКО 1 РАЗ! Не более! И обычно эта директива пишется в начале каждого .h файла, то есть:
// Player.h
#pragma once
#include "World.h"

class Player {
World* World;
};

// World.h
#pragma once
#include "Player.h"

class World {
Player* Player;
};

И вот теперь при препроцессинге в файле Player.h подключится/раскроется World.h и на этом цикл будет оборван из-за #pragma once вначале Player.h - магия! Но мы лишь остановили признак (рекурсию), но не решили проблему. Взглянем на Player.h после препроцессинга:
// Player_AfterPreprocessing.h
#pragma once

// #include "World.h"
// ↓ - Начало "World.h"
#pragma once
// #include "Player.h"
// Блокируется из-за #pragma once
// В начале файла "Player.h"

class World {
Player* Player; // ?
};
// ↑ - Конец "World.h"

// Остальной код
class Player {
World* World;
};

Но подождите-ка, как класс World должен знать об Player, если он объявлен чуть ниже? Мда.. И тут неожиданно на помощь приходит Forward Declaration с прошлой главы, ну конечно же! Если мы вначале просто объявим/упомянем что есть такой-то класс/функция, то при линковке они найдут друг-друга, поздравляю! Это и было решением и оптимизацией в тоже время:
// Player.h
#pragma once

class World;
// Forward Declaration

class Player {
World* World;
};

// World.h
#pragma once

class Player;
// Forward Declaration too

class World {
Player* Player;
};

Но подождите, почему именно оптимизацией? По опыту: #include крайне грубая штука - он раскрывает все файлы рекурсивно и если, например, в проекте есть 1 .h файл который имеет 20 #includ'ов внутри, то при использовании этого .h вместе с ним будут раскрываться И ТЕ 20 .h ФАЙЛОВ! То есть да: #include - удобный, но имеет накопительный эффект. И именно поэтому использование Forward Declaration лучшее решение из возможных

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

Break The Chain. Think Cold.
❤‍🔥2😱1
This media is not supported in your browser
VIEW IN TELEGRAM
❤‍🔥2🕊1
Розыгрыш багов на Альфу 1.1.0
Кто найдет - тому 10 лет условно 💀

Успей получить свой долгожданный отпуск!

GameJolt: тык
❤3🤩2❤‍🔥1😭1😎1
Искусство Построения Архитектуры Проекта

Глава 5: inline-функции и правило одной реализации

Данная глава будет маленькой, но крайне полезной. Основной вопрос: Можно ли реализовывать функции в .h файле?
Ответ: Да, но с оговорками

Вы сделали это:
// Test.h
#pragma once

int Add(int a, int b) {
return a + b;
}

Что тогда? Ну... Во-первых компилятор вам банально не даст этого сделать - сработает намеренная защита. Представьте, что вы подключите этот Test.h файл в нескольких .cpp файлах -> Препроцессор раскроет #include "Test.h" -> В нескольких .cpp файлах будет int Add(...) {...} (прошу заметить - с реализацией!) - и когда линкер начнет сшивать .obj файлы вместе, то в каждом из них будет объявлена и реализована функция Add, и вопрос: Какую реализацию ему выбрать? Вот именно...

Так что нам придумали одно правило: Правило Одного Определения (ODR - One Definition Rule)

"А если я хочу намеренно реализовать функцию в
.h файле?" - Для нас дядя Бьёрн позаботился и придумал пометку inline - это пометка компилятору, которая говорит: вставь код функции напрямую в место её вызова вместо выполнения традиционного вызова. То есть:
// Test.h
#pragma once

inline int Add(int a, int b) {
return a + b;
}

Что это дает? При компиляции, эта функция по факту не будет создана, а ее машинный код будет вшит в тех местах, где она вызывается. То есть это такой "своеобразный #include для функций". И главное: при той же линковке, функция Add по факту не будет создана, а значит не будет проблем с ODR

И главный вопрос: какие плюсы-минусы у этого? - Безусловно это меньше беготни процессору. Серьезно, если он меньше бегает между функциями, то это имеет оптимизирующий эффект. Вместо того, чтобы вызвать функцию и ждать ее ответа, он сразу здесь и сейчас исполняет ее.

Но и у этого есть оттягивающий эффект: Я не зря сравнил inline с #include - ибо он также имеет накопительный эффект: Чем больше функций будут раскрываться - тем крупнее будет бинарник. А также учтите ещё размер функции, и получите охерительно жирный бинарник и несколько часов компиляции. Бинго!

В любом случае, как и говорилось ранее, главное разобраться в этой теме и сделать выводы. Я не говорю не использовать эту механику. Наоборот, даже в своем проекте Portal: Solver у меня есть отдельный файл утилит (PortalSolverUtilities.h) в которых реализованы небольшие inline функции-помощники и используются при необходимости. Но реализовывать целый движок в единственном .h... Мда... Надеюсь теперь вы понимаете цену своих действий

ODR Looks Like Error - But It's Just Protection From Chaos. Think Cold.
🔥4❤‍🔥1
This media is not supported in your browser
VIEW IN TELEGRAM
❤3
Portal: Solver с открытым исходным кодом?!

ДА! Вы не ослышались - Portal Solver теперь open-source!
Но чтобы вы меньше тратили время на билд проекта (почти 1.5к ассетов, я не шучу 💀)

Я решил скомпилировать все за вас! Так что успейте опробовать скомпилированные исходники Портал Солвера БЕСПЛАТНО!

Ссылочка: тык
🤩3👀1
𝐑𝐨𝐨𝐭𝐓𝐨𝐨𝐥 𝐁𝐥𝐨𝐠
Искусство Построения Архитектуры Проекта Глава 4: "Квантовая запутанность" классов и "теория большого #includ'а" Вспомним: - Глава 2 - #include "Path/File.h" - открывает файл по пути и жестко заменяет себя на его содержимое - Глава 3 - Forward Declaration…
Forward Declaration на реальном примере

Как всегда работал (да.. в 2:25 ночи) и встретил распространенную/привычную проблему - в одной структуре я ссылаюсь (упоминаю) другую структуру, что находится чуть ниже

Что делать? Forward Declaration!

вот так примеры с учебника реально встречаются на работе))

Don’t Just Learn - Understand It. Think Cold
❤3👍1🔥1
Есть дьявол такой - дедлайн называется

но главное: работает - не трогай
🤯4❤2👍1👀1
Что ж, взял себе наконец-таки новый (второй) блокнот для Portal: Solver

А что с первым? А первый я взял ещё в прошлом году, когда только Альфа начиналась...

И сейчас он весь исписан расчётами, концептами камер, архитектур кода, систем/подсистем, и вообще темный лес☠️

Теперь, как говорится, начнем с чистого листа
❤2💯2🔥1