Нарушает ли общий указатель оптимизацию хвостового вызова?

Предисловие

Я практикую C ++ и пытаюсь реализовать неизменяемый список. В одном из своих тестов я пытаюсь рекурсивно создать список с большим количеством значений (1 миллион узлов). Все значения равны const, поэтому я не могу выполнить обычный цикл, к тому же это недостаточно функционально, как вы понимаете. Тест не проходит с Segmentation fault.

Моя система - 64-битная Xubuntu 16.04 LTS с Linux 4.4. Я компилирую свой код с помощью g ++ 5.4 и clang ++ 3.8, используя флаги --std=c++14 -O3.

Исходный код

Я написал простой пример, который показывает ситуацию, когда хвостовой вызов должен быть легко оптимизирован, но что-то идет не так и появляется Segmentation fault. Функция f просто ждет amount итераций, а затем создает указатель на одиночный int и возвращает его.

#include <memory>

using std::shared_ptr;

shared_ptr<int> f(unsigned amount) {
    return amount? f(amount - 1) : shared_ptr<int>{new int};
}

int main() {
    return f(1E6) != nullptr;
}

Примечание: этот пример не работает только с g++, а с clang++ все в порядке. Хотя на более сложном примере тоже не оптимизируется.

Вот пример простого списка с рекурсивной вставкой элементов. Также я добавил функцию destroy, которая помогает избежать переполнения стека при уничтожении. Здесь я получаю Segmentation fault с обоими компиляторами

#include <memory>

using std::shared_ptr;

struct L {
    shared_ptr<L> tail;

    L(const L&) = delete;
    L() = delete;
};

shared_ptr<L> insertBulk(unsigned amount, const shared_ptr<L>& tail) {
    return amount? insertBulk(amount - 1, shared_ptr<L>{new L{tail}})
                 : tail;
}

void destroy(shared_ptr<L> list) {
    if (!list) return;

    shared_ptr<L> tail = list->tail;
    list.reset();

    for (; tail; tail = tail->tail);
}

int main() {
    shared_ptr<L> list = shared_ptr<L>{new L{nullptr}};
    destroy(insertBulk(1E6, list));
    return 0;
}

ПРИМЕЧАНИЕ. Реализация с обычными указателями хорошо оптимизирована обоими компиляторами.

Вопрос

Действительно ли shared_ptr нарушает оптимизацию хвостовых вызовов в моем случае? Это проблема компилятора или проблема с shared_ptr реализацией?


person Charlie    schedule 23.10.2017    source источник
comment
но я думаю, что этот пример более понятен с макросами USE_SHARED_PTR. - нет.   -  person    schedule 24.10.2017
comment
@NeilButterworth спасибо, я написал более простой пример   -  person Charlie    schedule 24.10.2017
comment
похоже, что вы создаете новый экземпляр оболочки shared_ptr для каждого уровня. Попробуйте ссылку?   -  person Kenny Ostrom    schedule 24.10.2017
comment
@KennyOstrom: да, ссылка сделала свое дело, поэтому я изменил пример, чтобы показать проблему более реалистично.   -  person Charlie    schedule 24.10.2017
comment
Почему вы используете shared_ptr вместо unique_ptr? Планируете ли вы, чтобы несколько списков имели один и тот же хвост, поскольку они неизменяемы?   -  person Daniel H    schedule 24.10.2017
comment
@DanielH да, это именно то, что я хочу делать   -  person Charlie    schedule 24.10.2017
comment
@Charlie Я удалил свой ответ, так как вопрос слишком сильно изменился. Но проблема, похоже, в том, что с помощью C ++ очень легко создавать временные программы, предотвращающие устранение хвостового вызова. Вставьте свой код в godbolt.org, чтобы увидеть, какой код создается. Это может быть ближе к тому, что вы хотите: return amount? insertBulk(amount - 1, tail) : shared_ptr<L>{tail}   -  person Tony Lee    schedule 24.10.2017
comment
@TonyLee Я использую параметр -S своего компилятора, чтобы получить код сборки, и я вижу, что он делает много вещей после рекурсивного вызова   -  person Charlie    schedule 24.10.2017
comment
@TonyLee нет, в вашем примере будет создан новый указатель на tail, но я хочу создать новый список, хвост которого будет равен параметру tail функции. Это insert функция, а не copy :)   -  person Charlie    schedule 24.10.2017
comment
@Charlie - теперь я думаю, что понимаю - это довольно интересная проблема с shared_ptr и детерминированным разрушением c ++. Это не мне решать.   -  person Tony Lee    schedule 25.10.2017
comment
Вы специально приказываете компилятору создавать новый объект на каждом уровне рекурсии (до неизбежного переполнения стека). Я не понимаю, как возможна хвостовая рекурсия. Напротив, простой указатель можно передавать по значению или использовать в цикле, что, по моему мнению, и есть хвостовая рекурсия. Рекурсивная функция может быть скрытым помощником, и вы можете сделать shared_ptr оболочкой.   -  person Kenny Ostrom    schedule 25.10.2017
comment
@KennyOstrom Я не догадывался о скрытом помощнике и обертке, вы имеете в виду   -  person Charlie    schedule 25.10.2017
comment
Я говорю, что вам не нужен общий указатель внутри управляемой вами рекурсии / итерации. Одна функция может быть публичным интерфейсом (оболочкой) и вызывать рекурсивную функцию. Рекурсивная функция имеет простой аккумулятор типа POD. Функция внешнего интерфейса вызывает рекурсивную функцию для получения значения, а затем создает общий объект ptr для его возврата. Рекурсивная функция скрыта в том смысле, что она не вызывается напрямую.   -  person Kenny Ostrom    schedule 25.10.2017
comment
@KennyOstrom имеет смысл. Хотя мне нужно, чтобы tail был shared_ptr, чтобы повторно использовать хвост списка и правильно управлять памятью. Мне нужно будет создать эти указатели позже, и список не должен быть константным, чтобы это было возможно.   -  person Charlie    schedule 25.10.2017


Ответы (1)


Отвечать

Короткий ответ: да и нет.

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

Подробности

Предотвращение переполнения стека при рекурсивном построении длинного списка

Я вспомнил, что shared_ptr имеет деструктор, а C ++ имеет RAII. Это затрудняет создание оптимизируемого хвостового вызова, как это обсуждалось в Can Tail Оптимизация звонков и RAII сосуществуют? вопрос.

@KennyOstrom предложил использовать обычный указатель для решения этой проблемы.

static const List* insertBulk_(unsigned amount, const List* tail=nullptr) {
    return amount? insertBulk_(amount - 1, new List{tail})
                 : tail;
}

Используется следующий конструктор

List(const List* tail): tail{tail} {}

Когда tail из List является экземпляром shared_ptr, хвостовой вызов успешно оптимизирован.

Предотвращение переполнения стека при разрушении

Требуется индивидуальная стратегия уничтожения. К счастью, shared_ptr позволяет нам установить его, поэтому я спрятал деструктор List, сделав его private, и использую его для уничтожения списка.

static void destroy(const List* list) {
    if (!list) return;

    shared_ptr<const List> tail = list->tail;
    delete list;
    for (; tail && tail.use_count() == 1; tail = tail->tail);
}

Конструктор должен передать эту функцию разрушения в tail список инициализации

List(const List* tail): tail{tail, List::destroy} {}

Как избежать утечки памяти

В случае исключений у меня не будет должной очистки, поэтому проблема еще не решена. Я хочу использовать shared_ptr, потому что это безопасно, но теперь я не использую его для заголовка текущего списка до конца построения.

Необходимо следить за «голым» указателем до тех пор, пока он не превратится в общий указатель, и освободить его в случае крайней необходимости. Давайте передадим ссылку на указатель хвоста вместо самого указателя на insertBulk_. Это позволит видеть последний хороший указатель за пределами функции.

static const List* insertBulk_(unsigned amount, const List*& tail) {
    if (!amount) {
        const List* result = tail;
        tail = nullptr;
        return result;
    }
    return insertBulk_(amount - 1, tail = new List{tail});
}

Тогда нужен аналог Finally, чтобы автоматизировать уничтожение указателя, который в случае исключения даст утечку.

static const shared_ptr<const List> insertBulk(unsigned amount) {
    struct TailGuard {
        const List* ptr;
        ~TailGuard() {
            List::destroy(this->ptr);
        }
    } guard{};
    const List* result = insertBulk_(amount, guard.ptr);
    return amount? shared_ptr<const List>{result, List::destroy}
                 : nullptr;
}

Решение

Теперь, думаю, проблема решена:

  • g++ и clang++ успешно оптимизируют рекурсивное создание длинных списков;
  • списки по-прежнему используют shared_ptr;
  • обычные указатели кажутся в безопасности.

Исходный код

Окончательный код

#include <memory>
#include <cassert>

using std::shared_ptr;

class List {
    private:
        const shared_ptr<const List> tail;

        /**
         * I need a `tail` to be an instance of `shared_ptr`.
         * Separate `List` constructor was created for this purpose.
         * It gets a regular pointer to `tail` and wraps it
         * into shared pointer.
         *
         * The `tail` is a reference to pointer,
         * because `insertBulk`, which called `insertBulk_`,
         * should have an ability to free memory
         * in the case of `insertBulk_` fail
         * to avoid memory leak.
         */
        static const List* insertBulk_(unsigned amount, const List*& tail) {
            if (!amount) {
                const List* result = tail;
                tail = nullptr;
                return result;
            }
            return insertBulk_(amount - 1, tail = new List{tail});
        }
        unsigned size_(unsigned acc=1) const {
            return this->tail? this->tail->size_(acc + 1) : acc;
        }
        /**
         * Destructor needs to be hidden,
         * because it causes stack overflow for long lists.
         * Custom destruction method `destroy` should be invoked first.
         */
        ~List() {}
    public:
        /**
         * List needs custom destruction strategy,
         * because default destructor causes stack overflow
         * in the case of long lists:
         * it will recursively remove its items.
         */
        List(const List* tail): tail{tail, List::destroy} {}
        List(const shared_ptr<const List>& tail): tail{tail} {}
        List(const List&) = delete;
        List() = delete;

        unsigned size() const {
            return this->size_();
        }

        /**
         * Public iterface for private `insertBulk_` method.
         * It wraps `insertBulk_` result into `shared_ptr`
         * with custom destruction function.
         *
         * Also it creates a guard for tail,
         * which will destroy it if something will go wrong.
         * `insertBulk_` should store `tail`,
         * which is not yet wrapped into `shared_ptr`,
         * in the guard, and set it to `nullptr` in the end
         * in order to avoid destruction of successfully created list.
         */
        static const shared_ptr<const List> insertBulk(unsigned amount) {
            struct TailGuard {
                const List* ptr;
                ~TailGuard() {
                    List::destroy(this->ptr);
                }
            } guard{};
            const List* result = insertBulk_(amount, guard.ptr);
            return amount? shared_ptr<const List>{result, List::destroy}
                         : nullptr;
        }
        /**
         * Custom destruction strategy,
         * which should be called in order to delete a list.
         */
        static void destroy(const List* list) {
            if (!list) return;

            shared_ptr<const List> tail = list->tail;
            delete list;

            /**
             * Watching references count allows us to stop,
             * when we reached the node,
             * which is used by another list.
             *
             * Also this prevents long loop of construction and destruction,
             * because destruction calls this function `destroy` again
             * and it will create a lot of redundant entities
             * without `tail.use_count() == 1` condition.
             */
            for (; tail && tail.use_count() == 1; tail = tail->tail);
        }
};

int main() {
    /**
     * Check whether we can create multiple lists.
     */
    const shared_ptr<const List> list{List::insertBulk(1E6)};
    const shared_ptr<const List> longList{List::insertBulk(1E7)};
    /**
     * Check whether we can use a list as a tail for another list.
     */
    const shared_ptr<const List> composedList{new List{list}, List::destroy};
    /**
     * Checking whether creation works well.
     */
    assert(list->size() == 1E6);
    assert(longList->size() == 1E7);
    assert(composedList->size() == 1E6 + 1);
    return 0;
}

Укороченная версия исходного кода

Класс List без комментариев и проверок в функции main

#include <memory>

using std::shared_ptr;

class List {
    private:
        const shared_ptr<const List> tail;

        static const List* insertBulk_(unsigned amount, const List*& tail) {
            if (!amount) {
                const List* result = tail;
                tail = nullptr;
                return result;
            }
            return insertBulk_(amount - 1, tail = new List{tail});
        }
        ~List() {}
    public:
        List(const List* tail): tail{tail, List::destroy} {}
        List(const shared_ptr<const List>& tail): tail{tail} {}
        List(const List&) = delete;
        List() = delete;

        static const shared_ptr<const List> insertBulk(unsigned amount) {
            struct TailGuard {
                const List* ptr;
                ~TailGuard() {
                    List::destroy(this->ptr);
                }
            } guard{};
            const List* result = insertBulk_(amount, guard.ptr);
            return amount? shared_ptr<const List>{result, List::destroy}
                         : nullptr;
        }
        static void destroy(const List* list) {
            if (!list) return;

            shared_ptr<const List> tail = list->tail;
            delete list;

            for (; tail && tail.use_count() == 1; tail = tail->tail);
        }
};
person Charlie    schedule 25.10.2017