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

Продолжение к этому посту. Рассмотрим следующее:

class C;
C foo();

Это пара действительных объявлений. C не нужно полностью определять, просто объявляя функцию. Но если бы мы добавили следующую функцию:

class C;
C foo();
inline C bar() { return foo(); }

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

Так почему? Что в стандарте запрещает?

Ответ 1

Правило лежит в [basic.lval]/9:

Если не указано иное ([dcl.type.simple]), prvalue всегда имеет полный тип или тип void;...

Ответ 2

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

Согласно https://timsong-cpp.github.io/cppwp/class.temporary:

15.2 Временные объекты [class.temporary]

1 Создаются временные объекты

[...]

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

[...]

3 Когда объект класса X передается или возвращается из функции, если каждый конструктор копирования, перемещение конструктора и деструктор X является либо тривиальным, либо удаленным, а X имеет по крайней мере один не удаленный экземпляр или механизм перемещения, реализации разрешено создавать временный объект для хранения функционального параметра или объекта результата. Временный объект создается из аргумента функции или возвращаемого значения соответственно, а параметр функции или возвращаемый объект инициализируется так, как если бы с помощью неиспользуемого тривиального конструктора для копирования временного (даже если этот конструктор недоступен или не будет выбран с помощью разрешения перегрузки, чтобы выполнить копию или перемещение объекта). [Примечание. Эта широта предоставляется, чтобы объекты типа класса были переданы или возвращены из функций в регистрах. - конечная нота]

Ответ 3

Это не имеет никакого отношения к копированию эллипса. предполагается, что foo возвращает значение C Пока вы просто передаете ссылку или указатель на foo, это нормально. Когда вы пытаетесь вызвать foo - как это имеет место в bar - размер его аргументов и возвращаемое значение должны быть под рукой; единственный действительный способ узнать, что представляет полное объявление требуемого типа. Если бы подпись использовала ссылку или указатель, вся необходимая информация присутствовала, и вы могли обойтись без полного объявления типа. Этот подход имеет имя: pimpl == Указатель на IMPLementaion и широко используется в качестве средства скрытия деталей в дистрибутивах библиотеки с закрытыми исходными кодами.

Ответ 4

Несмотря на количество ответов и количество комментариев, опубликованных в этой теме (на которые я ответил на все мои личные вопросы), я решил опубликовать ответ "для всех нас". Я изначально не понял, к чему стремился ОП, но теперь я это делаю, поэтому я решил поделиться с вами. Если вы все это знаете, и это вас раздражает, дорогой читатель, тогда, пожалуйста, просто продолжайте.

@xskxzr и @hvd эффективно ответили на вопрос, но сообщение @hvd особенно относится к стандартным стандартам и предполагает, что читатели знают, как работает обратная стоимость (и по расширению RVO), что, я думаю, не все делают. Я думал, что сделал, но мне не хватало важной детали (которая, когда вы это переживаете, на самом деле довольно очевидна, но все же я пропустил ее).

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

Итак, давайте снова посмотрим на этот код. Учитывая это (что является законным, даже с неполностью определенным типом):

class C;
C foo();

Почему компилятор не может скомпилировать это (я удалил inline потому что это не имеет значения):

C bar() { return foo(); }

Сообщение об ошибке из gcc:

error: return type 'class C' является неполным

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

Сначала мне показалось очевидным: пространство должно быть выделено для результата функции вызывающим, и он не знает, насколько велик объект, поэтому компилятор находится в quandry. Но мне не хватало трюка, и это мешает работе с возвратом по значению.

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

Тем не менее, эти ромашки-цепочки, поэтому, если у нас есть следующий код (где мы полностью определяем C перед вызовом bar()):

class C
{
public:
    int x;
};

C c = bar ();
c.x = 4;

то пространство для c выделяется перед bar() bar(), а затем адрес c передается как скрытый параметр в bar(), а затем передается непосредственно на foo(), который, наконец, заполняет объекты объектом в нужном месте. Итак, поскольку bar() самом деле ничего не сделал с этим указателем (кроме прохода), то все, о чем он заботится, это сам указатель, а не то, на что он указывает.

Или это? Ну, на самом деле, да, это так.

При возврате объекта класса по значению небольшие объекты обычно возвращаются в регистр (или пару регистров) в качестве оптимизации. Компилятор может избежать этого в большинстве случаев, когда объект достаточно мал (об этом в какой-то момент).

Но теперь bar() должен знать, будет ли это то, что будет делать foo(), и сделать это по разным причинам, чтобы увидеть полное объявление класса.

Итак, в резюме, почему компилятор нуждается в полностью определенном типе, чтобы вызвать foo(), иначе он не будет знать, что ожидает foo(), и поэтому он не знает, какой код сгенерировать. В любом случае, не на большинстве платформ.

Заметки:

  1. Я посмотрел на gcc и, кажется, есть два (полностью логических) правила для определения того, возвращается ли объект класса в регистр или пару регистров:

    • a) Объект 16 байт или меньше (в 64-битной сборке).
    • b) std::is_trivially_copyable<T>::value оценивается как true (возможно, кто-то может найти что-то в стандарте об этом).
  2. В случае, если какие-либо читатели не знают, RVO полагается на построение объекта в своем конечном месте отдыха (то есть в местоположении, выделенном вызывающим абонентом). Это связано с тем, что есть объекты (например, некоторые реализации std::basic_string, я считаю), которые чувствительны к перемещению в памяти, поэтому вы не можете просто построить их где-то удобно для вас, а затем memcpy их где-то еще.

  3. Если построение возвращаемого объекта в этом конечном месте невозможно (из-за того, как вы закодировали функцию, возвращающую объект), тогда RVO не происходит (как это может быть?), См. make_no_RVO() демонстрацию ниже (make_no_RVO()).

  4. В качестве конкретного примера точки 1b, если маленький объект содержит элементы данных, которые (могут) указывают либо на себя, либо на какой-либо из своих членов данных, то возврат его по значению приведет к возникновению проблем, если вы не объявите его должным образом. Просто добавление пустого конструктора будет делать, так как тогда он уже не может быть тривиально скопирован. Но тогда я предполагаю, что это правда вообще, не скрывайте важную информацию от компилятора.

Живой демо здесь. Все комментарии на этот пост приветствуются, я отвечу им в меру своих способностей.