КАК МЫСЛИТ UNREAL · ЧТЕНИЕ ИСХОДНИКОВ

Public/Private/Classes: почему граница модуля — не конвенция, а компилятор

Ты уже видел папки Public и Private внутри почти каждого модуля движка и, скорее всего, решил, что это просто договорённость команды — «сюда кладём то, чем можно делиться, сюда — внутреннее». Это не догадка, а буквальная механика: UnrealBuildTool физически не добавляет содержимое Private в список путей, которые видит компилятор при сборке любого другого модуля. Не «не принято» — «невозможно» штатным способом.

Более медленный, практический вариант этого же материала

Здесь модули разбираются со стороны чтения чужого кода движка. Этап «Unreal C++ Fundamentals» в Learning подходит к той же границе Public/Private с другой стороны — там свой первый модуль и свой первый Build.cs пишут своими руками, прежде чем распознавать эту же структуру в исходниках движка.

01Что это?

Каждый модуль движка (Engine, CoreUObject, Renderer, твой собственный игровой модуль) физически разложен по каталогам Source/<Модуль>/Public, Source/<Модуль>/Private и, в части старых модулей, ещё и Source/<Модуль>/Classes.

Это не архивная организация файлов «для порядка» — это буквальный вход в алгоритм UnrealBuildTool (UBT), который при сборке любого модуля решает, из каких физических каталогов компилятору вообще разрешено брать заголовки по #include. Дальше — почему это устроено именно так, а не как «просто договорились».

02Почему это существует?

Проблема

Представь модуль Renderer — он рисует кадр: обходит сцену, строит списки отрисовки, работает с GPU-командами. Внутри у него наверняка есть заголовок вроде SceneRendering.h, где описан класс FSceneRenderer с полями и методами, знать о которых снаружи модуля незачем — это внутренняя кухня конкретно этой реализации рендера.

Остановись на минуту

Твой игровой модуль зависит от Engine, а Engine в конечном счёте зависит от Renderer — зависимости в Unreal транзитивны. Значит ли это, что #include «SceneRendering.h» должен скомпилироваться прямо в твоём геймплейном коде, только потому что где-то в глубине графа зависимостей эта связь существует? Подумай, что произойдёт с движком, если ответ — «да, скомпилируется».

Причина

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

⚠ Что именно ломается без физической границы
  • Инкапсуляция превращается в благое пожелание — «не включай этот заголовок» работает, пока все в команде об этом помнят и соблюдают код-ревью; не работает, когда в команде пятьдесят человек, а движок меняют десятки инженеров Epic одновременно.
  • Рефакторинг внутренностей модуля становится непредсказуемо опасным — переименовать приватное поле FSceneRenderer нельзя без грепа по всему дереву исходников на предмет «а вдруг кто-то это использовал», потому что формальной гарантии, что не использовал, не существует.
  • Время компиляции растёт без необходимости — каждый заголовок, транзитивно доступный отовсюду, это потенциальный лишний #include в чужом .cpp, а значит лишняя работа препроцессора и компилятора при каждой пересборке.

03Какую проблему решает

Деление на Public/Private превращает «пожалуйста, не включай это» в физическое ограничение уровня компилятора: заголовок, лежащий в Private, банально не резолвится через #include из другого модуля обычным, штатным способом сборки — не потому что кто-то запретил, а потому что компилятору неоткуда узнать, где его искать.

04Как устроено

Ограничения, которым должно было удовлетворить решение

Механизм должен был не требовать от разработчика ничего сверх привычной работы с файлами (никаких специальных атрибутов доступа, которых в C++ на уровне заголовков просто не существует), работать одинаково для всех модулей движка и любого стороннего плагина без исключений, и не создавать накладных расходов в рантайме — вся эта граница обязана существовать только на этапе сборки, не в скомпилированном бинарнике.

Решение: include-пути формируются по каталогам, а не по правам доступа

У C++ нет встроенного понятия «модуль» с контролем видимости заголовков (собственно модули C++20 — отдельная, недавняя фича языка, и Unreal Build Tool строился независимо от неё, на уровне препроцессора). Значит границу пришлось построить не языковыми средствами, а средствами системы сборки: какие каталоги физически передаются компилятору как -I (пути поиска заголовков) при сборке конкретного модуля.

Вот что реально происходит внутри UnrealBuildTool (UEBuildModuleCPP.cs) при формировании путей для модуля:

Для КАЖДОГО модуля, который собирается в проекте:

  Source/МойМодуль/Classes/**  ─┐
  Source/МойМодуль/Public/**   ─┼─► добавляются в PublicIncludePaths
                                 │    ЭТОТ модуль публикует эти пути
                                 │    ВСЕМ, кто на него зависит
                                 │
  Source/МойМодуль/Private/**  ─┴─► добавляются ТОЛЬКО в собственные
                                     include-пути компиляции файлов
                                     самого МойМодуль — наружу не публикуются

Другой модуль, зависящий от МойМодуль через Build.cs:
  получает в свой -I список ТОЛЬКО PublicIncludePaths модуля МойМодуль
  (Public + Classes) — Private физически отсутствует в списке путей
  поиска заголовков при его собственной сборке
Заглянем в исходники движка: как это выглядит в коде UnrealBuildTool

Реальный фрагмент метода AddDefaultIncludePaths() (Programs/UnrealBuildTool/Configuration/UEBuildModuleCPP.cs):

UEBuildModuleCPP.csC#
// Add the 'classes' directory, if it exists
if (ModuleDir.TryGetDirectory("Classes", out DirectoryItem? ClassesDirectory) && ClassesDirectory.ContainsFiles(SearchOption.AllDirectories))
{
    PublicIncludePaths.Add(ClassesDirectory.Location);
}

// Add all the public directories
if (ModuleDir.TryGetDirectory("Public", out DirectoryItem? PublicDirectory) && PublicDirectory.ContainsFiles(SearchOption.AllDirectories))
{
    PublicIncludePaths.Add(PublicDirectory.Location);
    /* ... */
}

Обрати внимание — Classes и Public буквально попадают в один и тот же список, PublicIncludePaths. Это не совпадение имён: Classes — наследие ранних версий движка (ещё до появления явного разделения на Public/Private), где все заголовки с UCLASS() исторически лежали именно в этом каталоге. Новые модули (например, EnhancedInput) сегодня создаются вообще без Classes — только Public/Private — но старые, вроде Engine, до сих пор хранят там значительную часть игровых классов (Engine/Classes/GameFramework/Actor.h — да, знакомый тебе Actor.h физически лежит именно там). Для компилятора разницы нет: оба каталога одинаково публичны.

Симметрично для Private: в том же файле каталог добавляется в список путей, которые видит только компиляция файлов внутри самого модуля — не в тот список, который передаётся модулям-потребителям.

Второй уровень границы: зависимость модуля вообще должна быть объявлена

Публичность заголовка — необходимое, но не единственное условие. Даже полностью публичный заголовок другого модуля не резолвится через #include, пока твой модуль не объявил зависимость от него в .Build.cs — через PublicDependencyModuleNames или PrivateDependencyModuleNames.

MyGame.Build.csC#
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PublicDependencyModuleNames.AddRange(new string[] {
            "Core", "CoreUObject", "Engine", "InputCore"
        });
    }
}

Именно эта строка — причина, по которой в твоём игровом модуле работает #include «GameFramework/Actor.h»: Actor.h лежит в Engine/Classes, каталог Classes публикуется модулем Engine как часть PublicIncludePaths, а твой модуль явно объявил зависимость от Engine. Три условия сошлись одновременно — не одно из них.

Actor.h лежит в Public-подобном каталоге (Classes)Без этого заголовок вообще не попал бы ни в чей PublicIncludePaths — не спасла бы никакая зависимость.
Модуль Engine публикует этот каталог другим модулямПубликует автоматически, просто по факту нахождения файла в Classes/Public — Epic не пишет для каждого заголовка отдельно, что он публичный.
MyGame.Build.cs явно объявляет зависимость от EngineБез этой строки компилятор твоего модуля вообще не получит путь к Engine-заголовкам в списке -I, даже если Actor.h лежит в полностью публичном каталоге.

Отсюда ответ на исходный пример про Renderer: даже если бы конкретный внутренний заголовок каким-то образом оказался физически в каталоге Public модуля Renderer — что Epic там не делает намеренно, ровно ради этой границы, — твой игровой модуль всё равно не смог бы его подключить без явной зависимости на Renderer в собственном Build.cs, а такая зависимость для геймплейного кода практически никогда не имеет смысла и не встречается в шаблонных проектах.

Public vs Private зависимость модуля — не только про заголовки, но и про то, что видят твои потребители

Разница между PublicDependencyModuleNames и PrivateDependencyModuleNames — не «то же самое, но для модулей вместо файлов», а прямое продолжение той же идеи на уровень выше: если модуль А объявляет зависимость от B как публичную, то любой модуль C, зависящий от А, автоматически получает доступ и к публичным заголовкам B — зависимость протекает наружу. Если А объявляет зависимость от B как приватную, C ничего не узнает о существовании B — А использовал B у себя внутри, но не обязан «одалживать» эту связь всем, кто зависит от А.

💡 Мысли Senior

Если в публичном заголовке твоего модуля встречается тип из другого модуля — эта зависимость обязана быть Public в Build.cs, иначе сборка потребителей просто не найдёт заголовок. Если тип используется только внутри .cpp — зависимость должна быть Private: любая лишняя Public-зависимость утекает по всему графу проекта и рано или поздно превращается в чей-то чужой, необъяснимый лишний include, добавленный «на всякий случай», который никто уже не помнит зачем.

05Когда важно об этом думать — и когда нет

Думать о границе Public/Private важно, когда…Не так критично, когда…
Ты проектируешь новый собственный модуль или плагин и решаешь, куда класть новый заголовок Ты пишешь обычный игровой код внутри одного модуля (например, стандартный Source/MyGame) — там всё, что не Classes/Public, автоматически приватно относительно других модулей, специально думать почти не о чем
Ты читаешь исходники движка и пытаешься понять, почему одни заголовки подключаются свободно, а на другие компилятор отвечает «file not found» Проект состоит из единственного игрового модуля без плагинов — вся эта граница касается взаимодействия МЕЖДУ модулями, внутри одного модуля файлы в любом случае видят друг друга без ограничений

Как этим можно злоупотребить

Физическая граница UBT — не криптографическая защита: ничто не мешает разработчику вручную дописать в Build.cs путь до чужого Private-каталога через PrivateIncludePaths.Add(...) с абсолютным или относительным путём и подключить внутренний заголовок другого модуля силой. Это компилируется. Это же и есть злоупотребление: такой код молча ломается при следующем обновлении движка, потому что автор внутреннего заголовка юридически (по соглашению об API) не обязан сохранять обратную совместимость для кода, которого «не должно было туда добраться» — а он туда добрался.

06Типичные ошибки

Ошибка новичка: пытаться исправить «file not found» добавлением include-пути вместо зависимости в Build.cs

Увидев ошибку компилятора «cannot open source file», типичный первый порыв — вручную прописать путь к нужному заголовку через PublicIncludePaths.Add(...) в собственном Build.cs. Это иногда даже работает (заголовок находится), но оставляет модуль без явно объявленной зависимости — а значит без гарантии, что нужный модуль вообще будет собран и слинкован раньше твоего, и без права штатно использовать функции из его .cpp (только заголовок сам по себе линковщика не удовлетворяет). Правильный первый шаг — всегда добавить сам модуль в PublicDependencyModuleNames/PrivateDependencyModuleNames, а не подключать его каталог напрямую.

Ошибка middle-разработчика: класть в Public всё подряд, чтобы «на всякий случай не пришлось потом переносить»

Класть новый заголовок в Public по умолчанию, даже когда он нужен только внутри двух .cpp-файлов того же модуля, кажется безопасным — но каждый лишний публичный заголовок увеличивает список того, что теоретически может подключить любой другой модуль проекта, а значит и список того, что теперь нельзя тихо переименовать или удалить при рефакторинге, не проверив весь проект. Правило простое: заголовок публичен только если хотя бы один реальный потребитель за пределами модуля уже существует или гарантированно появится — не «на всякий случай».

Как рассуждает senior: граница модуля — такой же архитектурный инструмент, как публичный/приватный метод класса

Разница между Public/Private заголовком модуля и public/private методом C++-класса — только масштаб. Оба существуют ради одной и той же цели: дать автору право менять внутреннее устройство, не спрашивая разрешения у всех потребителей, до тех пор, пока внешний контракт (публичный интерфейс) не нарушен. Разработчик, который свободно жонглирует Public/Private внутри класса, но никогда не задумывался о том же выборе на уровне модуля, применяет инкапсуляцию только наполовину.

07Практика

Попробуй сам
У тебя плагин с двумя модулями: InventoryCore (логика инвентаря, без UI) и InventoryUI (виджеты, использующие типы из InventoryCore). Внутри InventoryCore есть FInventorySlot — публичная структура слота, которую должен видеть UI-модуль, и FInventorySortHelper — приватный класс для внутренней сортировки, который не должен быть виден никому снаружи. Прежде чем читать разбор — реши сам: в каких каталогах должны физически лежать эти два заголовка, и какого типа (Public/Private) должна быть зависимость InventoryUI от InventoryCore в его Build.cs.
Показать ожидаемый ход рассуждения

FInventorySlot.h — в InventoryCore/Public/: это часть контракта модуля, и её обязан увидеть любой потребитель, включая InventoryUI.

FInventorySortHelper.h — в InventoryCore/Private/: используется только внутри .cpp самого InventoryCore, никакой другой модуль не должен на него полагаться, а значит и не должен физически иметь возможность его подключить.

Зависимость InventoryUI от InventoryCore в InventoryUI.Build.csPublicDependencyModuleNames, если типы InventoryCore (например, FInventorySlot) встречаются в публичных заголовках самого InventoryUI (например, в сигнатуре виджета) — тогда эта зависимость обязана «протекать» дальше, к любому модулю, который в будущем подключит InventoryUI. Если бы типы InventoryCore использовались только внутри .cpp модуля InventoryUI, зависимость правильнее было бы объявить как PrivateDependencyModuleNames.

08Частые вопросы

?Что означает третий каталог, Internal, который иногда встречается рядом с Public/Private в новых модулях движка?

Это более новая, промежуточная граница: заголовки из Internal (например, Engine/Internal) добавляются в отдельный список include-путей, который по умолчанию не наследуется произвольным внешним модулем так же свободно, как Public — предназначен для API, который должен быть виден близким, доверенным модулям одного и того же движкового «семейства», но не любому стороннему плагину. Для большинства читателей энциклопедии на этом этапе достаточно знать, что такой промежуточный уровень существует — детали подключения к нему не нужны, пока ты не пишешь модуль внутри самого движка.

?Если заголовок физически лежит в Private, но я знаю его относительный путь на диске — почему бы просто не подключить его через длинный относительный #include?

Технически конкретный компилятор может это стерпеть, но это не то же самое, что «путь резолвится через include-пути модуля» — такой include хрупок к любому перемещению файла (а Private-заголовки перемещают и переименовывают без предупреждения именно потому, что их не обязаны стабилизировать как публичный API), и это первый кандидат на поломку при обновлении версии движка. Это тот же обходной путь, что описан в разделе «Как этим можно злоупотребить» — компилируется, но осознанно нарушает контракт, который сама Epic не обещала сохранять.

?Почему у Engine есть каталог Classes, а у более новых модулей вроде EnhancedInput — нет?

Classes — исторический артефакт: до того как в движке появилось явное разделение Public/Private, все заголовки с UCLASS() массово лежали именно там. Модули, существующие с тех времён (Engine — один из самых старых модулей движка), сохраняют эту структуру ради обратной совместимости — перенос тысяч заголовков в Public задел бы include-пути огромного числа сторонних проектов. Новые модули создаются сразу по актуальной схеме — только Public/Private, без Classes, потому что у них нет унаследованной истории, которую нужно не сломать.

09Что читатель должен начать замечать

Ошибка компилятора «cannot open include file» перестаёт восприниматься как «где-то опечатка в пути» и начинает читаться как конкретный, диагностируемый вопрос: заголовок вообще публичный, или он в Private? Модуль, где он лежит, вообще объявлен зависимостью в Build.cs?

Открывая любой Build.cs — свой или чужой — ты начинаешь видеть не список имён через запятую, а карту того, что этому модулю физически разрешено включать, и что он, в свою очередь, разрешает включать своим потребителям.

Путь до заголовка (Classes/, Public/, Private/) в чужом коде движка начинает нести информацию сам по себе — ты знаешь, что он означает, ещё до того, как открыл файл.

10Итоги

  • ✔ объяснить, почему деление на Public/Private — ограничение UnrealBuildTool, а не просто договорённость команды
  • ✔ назвать три условия, которые должны совпасть, чтобы #include из другого модуля скомпилировался
  • ✔ объяснить разницу между PublicDependencyModuleNames и PrivateDependencyModuleNames через то, что «протекает» дальше по графу зависимостей
  • ✔ решить, в какой каталог (Public/Private) положить новый заголовок собственного модуля
  • ✔ по ошибке компилятора «cannot open include file» понять, чего именно не хватает — зависимости в Build.cs или самого факта публичности заголовка
  • ✔ объяснить, почему принудительное подключение чужого Private-заголовка компилируется, но остаётся нарушением контракта

Материал подготовлен для UE C++ Academy. Оригинал статьи — uecppacademy.com. Если материал помог вам разобраться в Unreal Engine — вы можете поддержать развитие проекта.

© 2026 UE C++ Academy. Все права защищены. По вопросам авторских прав или технических проблем — support@uecppacademy.com