Как объяснить, почему указатель на предварительно объявленный класс допустим до определения класса, а объект этого типа — нет?
Указатель на предварительно объявленный, но ещё не определённый класс допустим, потому что размер и внутреннее устройство самого объекта указателю не нужны: указатель хранит только адрес. Объект по значению создать нельзя, поскольку компилятору уже на этом месте необходим полный размер и layout класса.
Такое разделение поддерживает раздельную компиляцию: заголовок может объявить тип, не раскрывая его внутреннее устройство. Изначально это уменьшало связанность между модулями, ускоряло сборку и позволяло скрывать реализацию библиотечного класса.
На этом механизме основан идиом Pimpl: публичный класс хранит указатель на объект реализации, а детали реализации определяются в исходном файле. Изменения закрытой части тогда не требуют перекомпилировать всех пользователей заголовка.
До определения класса компилятор не знает его размер, выравнивание, базовые классы и нестатические поля. Поэтому он не может разместить такой объект в памяти, встроить его как поле другого объекта или вычислить смещение следующих членов.
Указатель и ссылка имеют фиксированную семантику независимо от размера целевого типа, но операции, требующие содержимого объекта, ограничены. Нельзя разыменовать такой указатель для доступа к полю или вызвать метод, если для проверки выражения требуется полное определение класса.
Предварительное объявление сообщает компилятору только имя типа и существование класса. После него допустимы объявления указателей и ссылок, а также некоторые объявления функций, например функции, принимающей ссылку на этот тип.
Поле engine занимает размер указателя, поэтому определение Engine в момент определения Car не требуется. Напротив, поле типа Engine по значению потребовало бы знать размер Engine, а значит, его полное определение должно находиться раньше.
Полное определение также необходимо там, где компилятор должен генерировать операции над объектом: создание, уничтожение, обращение к полям, вычисление размера и обычно вызов методов, для которого требуется проверка доступности и сигнатуры. Для delete через указатель на неполный тип безопасное правило — выполнять удаление только там, где тип полностью определён; требования стандарта к неполному типу при удалении зависят от свойств деструктора и версии C++.
Практическое ограничение Pimpl — необходимость управлять временем жизни объекта реализации. Если используется std::unique_ptr<Engine>, деструктор Car обычно определяют в исходном файле после определения Engine, чтобы в точке инстанцирования удаления тип был полным. Взамен получают сокрытие реализации и меньшую зависимость заголовков, но платят дополнительным выделением памяти и косвенным обращением.
Библиотечный заголовок содержит класс Database, внутреннее устройство которого часто меняется. Если хранить все внутренние поля непосредственно, каждый пользователь библиотеки будет зависеть от их типов и может потребовать полной пересборки при любом изменении.
Вариант с включением всех внутренних заголовков проще и обычно быстрее во время выполнения, но увеличивает связанность и время компиляции. Вариант с Pimpl скрывает зависимости и стабилизирует ABI, однако добавляет косвенность, отдельное выделение памяти и усложняет перемещающие операции.
Для редко создаваемого, но широко используемого библиотечного объекта выбирают Pimpl: публичный класс хранит указатель на реализацию, а деструктор и операции над ней определяются в исходном файле. В результате изменение закрытых полей не меняет размер публичного класса и обычно не требует пересборки клиентского кода.
Да, объявление функции может использовать неполный тип в возвращаемом значении, если на этом этапе не требуется размер результата. Но определение функции и место, где вызывающий код должен создать или принять возвращаемый объект, обычно требуют полного определения типа. Поэтому такое объявление часто размещают в заголовке, а определение функции — в исходном файле после подключения полного определения.
Ссылка не содержит встроенного объекта и концептуально является другим именем уже существующего объекта. Для хранения ссылки компилятору не нужно знать размер целевого класса; достаточно обеспечить правила привязки и вызова. Однако обращение к членам объекта через ссылку требует полного определения класса в точке, где компилятор анализирует соответствующую операцию.
std::unique_ptr на неполный тип в классе?Сам указатель std::unique_ptr хранить можно, но его деструктор должен в конечном итоге вызвать удаление объекта реализации. Если деструктор владеющего класса будет неявно сгенерирован в месте, где тип реализации неполный, реализация стандартной библиотеки может потребовать полный тип и выдать ошибку.
Обычно объявляют деструктор владеющего класса в заголовке, а определяют его в исходном файле после полного определения класса реализации. Это сохраняет владение через RAII и одновременно гарантирует, что удаление выполняется в корректной точке компиляции.