Могут ли сосуществовать оптимизация хвостовых вызовов и RAII?

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

Это ставит вопрос для тех реализаций, которые это делают: учитывая, что деструкторы вызываются в конце области автоматической переменной, а не отдельной подпрограммой сборки мусора, не нарушает ли это ограничение TCO, что рекурсивный вызов должна быть последняя инструкция в конце функции?

Например:-

#include <iostream>

class test_object {
public:
    test_object() { std::cout << "Constructing...\n"; }
    ~test_object() { std::cout << "Destructing...\n"; }
};

void test_function(int count);

int main()
{
    test_function(999);
}

void test_function(int count)
{
    if (!count) return;
    test_object obj;
    test_function(count - 1);
}

«Создание...» будет написано 999 раз, а затем «Разрушение...» еще 999 раз. В конечном итоге перед раскруткой будет автоматически выделено 999 test_object экземпляров. Но если предположить, что реализация имеет TCO, будет ли существовать 1000 кадров стека или только 1?

Сталкивается ли деструктор после рекурсивного вызова с требованиями реализации де-факто совокупной стоимости владения?


person Louis Jackman    schedule 22.07.2013    source источник
comment
Хотя на самом деле интересно. Я собирался сказать, конечно, что это не предотвращает TCO, но чем больше я смотрю на это, тем больше я думаю, что это делает...   -  person Mooing Duck    schedule 22.07.2013
comment
Здесь нет хвостового вызова (и проверьте свой код, он не компилируется как есть). Хотя теоретически хороший компилятор мог бы заметить закономерность и превратить ее в 2 цикла.   -  person Marc Glisse    schedule 22.07.2013
comment
Краткий ответ: да, детерминированные деструкторы предотвращают совокупную стоимость владения. Длинный ответ: на самом деле некоторые компиляторы (такие, как я полагаю, LLVM) реализуют более разрешительную форму TCO и могут допускать больше случаев...   -  person Matthieu M.    schedule 22.07.2013
comment
Да, как-то сложно сказать. Мне было интересно, на какой стадии выполнения на самом деле происходит разрушение. Я предполагал, что это произойдет непосредственно перед возвратом указателя инструкции и удалением фрейма стека, но наверняка это произойдет после последнего оператора функции? Я думаю, что мне может понадобиться дизассемблер...   -  person Louis Jackman    schedule 22.07.2013
comment
Спасибо за исправление кода. Поделом мне, какое-то невнимательное переименование класса в последнюю минуту...   -  person Louis Jackman    schedule 22.07.2013
comment
@ljackman: на самом деле возврат в C ++ должен быть двухэтапным процессом, и между этими этапами вмешивается разрушение. Таким образом, временная шкала такова: 1. Вызываемый объект оценивает возвращаемое выражение и помещает возвращаемый результат в определенный слот возврата (может включать копирование/перемещение), 2. Вызываемый объект выполняет деструкторы, 3. Вызывающий объект копирует/перемещает результат из возвращаемого слота в переменную. (или временно) в собственном фрейме. Любые два копирования/перемещения могут быть оптимизированы, но семантически момент планирования деструкторов четко определен.   -  person Matthieu M.    schedule 22.07.2013
comment
Вы узнаете что-то новое каждый день... Я полагаю, это зависит от того, разрешает ли реализация TCO до или после шага 2, учитывая, что функция void в любом случае не должна ничего возвращать.   -  person Louis Jackman    schedule 22.07.2013


Ответы (2)


На первый взгляд может показаться, что RAII работает против TCO. Однако помните, что существует ряд способов, которыми компилятор может, так сказать, «сойти с рук».

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

Затем деструктор может быть встроен (его код берется и помещается непосредственно в функцию, а не вызывается как функция). В этом случае все сводится к тому, что после оператора return имеется некоторый «чистящий» код. Компилятору разрешено переупорядочивать операции, если он может определить, что конечный результат тот же (правило «как если бы»), и он будет делать это (в общем случае), если переупорядочивание приводит к лучшему коду, и я бы предположил, что совокупная стоимость владения является одним из соображений, применяемых большинством компиляторов (т. е. если он может переупорядочить вещи таким образом, чтобы код стал пригодным для совокупной стоимости владения, тогда он это сделает).

А в остальных случаях, когда компилятор не может быть "достаточно умным", чтобы сделать это самостоятельно, ответственность за это ложится на программиста. Присутствие этого автоматического вызова деструктора немного затрудняет для программиста просмотр кода очистки, запрещающего совокупную стоимость владения, после хвостового вызова, но это не имеет никакого значения с точки зрения способности программиста выполнять код очистки. кандидат в ТШО. Например:

void nonRAII_recursion(int a) {
  int* arr = new int[a];
  // do some stuff with array "arr"
  delete[] arr;
  nonRAII_recursion(--a);  // tail-call
};

Теперь наивная реализация RAII_recursion может быть:

void RAII_recursion(int a) {
  std::vector<int> arr(a);
  // do some stuff with vector "arr"
  RAII_recursion(--a);  // tail-call
};  // arr gets destroyed here, not good for TCO.

Но мудрый программист все же видит, что это не сработает (если только деструктор вектора не встроен, что, скорее всего, в данном случае), и может легко исправить ситуацию:

void RAII_recursion(int a) {
  {
    std::vector<int> arr(a);
    // do some stuff with vector "arr"
  }; // arr gets destroyed here
  RAII_recursion(--a);  // tail-call
};

И я почти уверен, что вы могли бы продемонстрировать, что практически нет случаев, когда такого рода приемы нельзя было бы использовать для обеспечения применимости совокупной стоимости владения. Таким образом, RAII просто немного затрудняет определение возможности применения совокупной стоимости владения. Но я думаю, что программисты, которые достаточно мудры, чтобы спроектировать рекурсивные вызовы с возможностью TCO, также достаточно мудры, чтобы увидеть те «скрытые» вызовы деструктора, которые должны быть принудительно выполнены до хвостового вызова.

ДОБАВЛЕННОЕ ПРИМЕЧАНИЕ. Посмотрите на это так: деструктор скрывает некоторый код автоматической очистки. Если вам нужен код очистки (т. е. нетривиальный деструктор), он понадобится вам независимо от того, используете ли вы RAII или нет (например, массив в стиле C или что-то еще). И затем, если вы хотите, чтобы TCO была возможна, должна быть возможность выполнить очистку перед выполнением хвостового вызова (с RAII или без него), и это возможно, тогда можно принудительно уничтожить объекты RAII. перед хвостовым вызовом (например, поместив их в дополнительную область).

person Mikael Persson    schedule 22.07.2013
comment
Если деструктор вектора имеет видимый извне побочный эффект, я думаю, что стандарт C++ потребует, чтобы все они были созданы, а затем все были уничтожены в обратном порядке, не так ли? Чтобы узнать, может ли деструктор arr иметь какие-либо побочные эффекты, потребуется проанализировать весь код, который когда-либо получает указатель или ссылку на arr. Может быть, в некоторых случаях и возможно, но не во всех. - person supercat; 24.07.2013
comment
@supercat: стандарт определяет наблюдаемое поведение (т. е. ведет себя так, как будто разрушается в обратном порядке), это немного похоже на проблему с кошкой Шредингера: как только вы добавляете код, чтобы сделать порядок выполнения наблюдаемым, стандарт гарантирует порядок, но когда вы удаляете этот код, вы не можете быть уверены в порядке вещей, которые вы не можете наблюдать. - person Mikael Persson; 24.07.2013
comment
@supercat: Что касается разрушения вектора с видимыми извне побочными эффектами, это, конечно, зависит от компилятора и ситуации. Это, конечно, не всегда можно определить. Кроме того, насколько умен компилятор w.r.t. выделение памяти также имеет решающее значение (например, воспринимает ли он это просто как вызов функции с возможными побочными эффектами, рассматривает ли он изменения в куче как видимый побочный эффект и т. д.), и компиляторы имеют некоторую свободу в выборе. это. Я согласен с тем, что код очистки трудно безопасно переупорядочить. - person Mikael Persson; 24.07.2013
comment
Я ожидаю, что большинство компиляторов не будут достаточно умны, чтобы выполнить совокупную стоимость владения для второго фрагмента кода даже со встроенным деструктором, но я не проверял это. Причина в том, что компилятор не знает, включает ли // do some stuff with vector "arr" размещение указателя на arr (или указатель на один из элементов arr) в месте, которое может найти рекурсивный вызов (локальная переменная static, глобальная переменная, a и т. д.). .). Даже если компилятор знает, что этого не произошло, я думаю, что вызов delete[] в деструкторе arr по-прежнему нельзя переупорядочить, если только компилятор не использует строгую безопасность указателей. - person Daniel H; 22.02.2015
comment
Если совокупная стоимость владения предназначена для реализации гарантии PTC (правильный хвостовой вызов), которая по существу требует пространственной сложности O(1) вложенных активных вызовов, то вызовы очистки могут быть отложены до охватывающего контекста без хвоста и объединены в один экземпляр. когда эффекты идемпотентны: нет разницы между одним вызовом и более чем одним вызовом очистки. Обратите внимание, что потребление пространства само по себе не является видимым побочным эффектом, способствующим наблюдаемому поведению (почти на каждом языке, который я видел). Хотя доказать это пока сложно. - person FrankHB; 29.03.2021
comment
Такие идемпотентные очистки в C++, включая освобождение пространства автоматического хранилища (технически не считается освобождением), и вызовы по умолчанию (не определяемые пользователем) вызовы ::operator delete (подвергающиеся слиянию освобождения, начиная с C++14), определяемые статически (как если consteval-способен) без утечки информации аргументов. Требуется больше предположений, предусмотренных спецификацией языка для других случаев, иначе, по крайней мере, невозможно предположить, что безопасно игнорировать побочные эффекты, когда определение функции освобождения не отображается в единицах перевода C++. - person FrankHB; 29.03.2021
comment
Также учтите, что не только очистка может нарушать требования ТШО. Преобразования возвращаемого значения могут вступить в силу даже позже, чем вызовы очистки. Например, при возврате lvalue из функции с типом объекта в качестве типа возвращаемого значения происходит как минимум инициализация копирования (необязательно с вызовами некоторых пользовательских функций преобразования). Иногда все еще возможно выполнить TCO, потому что преобразование может быть идемпотентным, с или без исключения копии. - person FrankHB; 29.03.2021

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

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

person Cassio Neri    schedule 22.07.2013
comment
В случае с RAII никогда не удастся доказать, что переупорядочивание не имеет значения, потому что оно имеет значение практически по определению. - person James Kanze; 22.07.2013
comment
@Джеймс Канце. Конечно. Если мы рассматриваем RAII в том смысле, что деструктор освобождает ресурс (что является наиболее общепринятым значением этого термина), то выполнить TCO невозможно. Если рассматривать RAII в более широком смысле, в том смысле, что при выходе из текущей области видимости вызываются деструкторы, то в некоторых случаях TCO все же можно сделать. - person Cassio Neri; 23.07.2013
comment
Деструкторы, которые ничего не делают, не являются RAII. И вообще наличие нетривиальных конструкторов или деструкторов снижает совокупную стоимость владения. - person James Kanze; 23.07.2013
comment
Дополнительное примечание: начиная с C++14 стандарт явно разрешает слияние некоторых новые выраженияs без помощи правил "как если бы". Так дело становится интереснее. - person FrankHB; 03.04.2016