Возможность автоматического автоматического выключателя для `std:: shared_ptr`

В С++ 11 введены интеллектуальные указатели с подсчетом ссылок, std::shared_ptr. При подсчете ссылок эти указатели не могут автоматически восстанавливать циклические структуры данных. Было показано, что автоматический сбор ссылочных циклов возможен, например, Python и PHP. Чтобы отличить этот метод от сбора мусора, остальная часть вопроса будет относиться к нему как к прерыванию цикла.

Учитывая, что, похоже, нет предложений по добавлению эквивалентной функциональности С++, существует ли фундаментальная причина, по которой автоматический выключатель, подобный тем, которые уже развернуты на других языках, не будет работать для std::shared_ptr?

Обратите внимание, что этот вопрос не сводится к "почему нет GC для С++", который был задан до. С++ GC обычно относится к системе, которая автоматически управляет всеми динамически выделенными объектами, как правило, реализуется с использованием некоторой формы консервативного коллектива Бем. Было указано , что такой сборщик не подходит для RAII. Поскольку сборщик мусора в основном управляет памятью и даже не может быть вызван до тех пор, пока не будет нехватка памяти, а деструкторы С++ управляют другими ресурсами, опираясь на GC для запуска деструкторов, в лучшем случае будет вводиться не детерминированность и ресурсное голодание в худшем случае. Также было указано, что полномасштабный GC в значительной степени не нужен в присутствии более явных и предсказуемых интеллектуальных указателей.

Однако библиотечный прерыватель цикла для интеллектуальных указателей (аналогичный тому, который используется интерпретаторами с подсчетом ссылок) будет иметь важные отличия от GC общего назначения:

  • Он заботится только об объектах, управляемых через shared_ptr. Такие объекты уже участвуют в совместном владении и, следовательно, должны обрабатывать задержанный вызов деструктора, чье точное время зависит от структуры собственности.
  • Из-за ограниченного объема, выключатель цикла не заботится о шаблонах, которые ломают или замедляют работу Boehm GC, таких как маскирование указателя или огромные непрозрачные блоки кучи, которые содержат случайный указатель.
  • Он может быть включен, например std::enable_shared_from_this. Объекты, которые его не используют, не должны оплачивать дополнительное пространство в блоке управления для хранения метаданных цикла.
  • Выключатель цикла не требует исчерпывающего списка "корневых" объектов, который трудно получить на С++. В отличие от GC-метки развертки, которая находит все живые объекты и отбрасывает остальную часть, прерыватель цикла обходит только объекты, которые могут образовывать циклы. В существующих реализациях тип должен предоставлять помощь в виде функции, которая перечисляет ссылки (прямые или косвенные) другим объектам, которые могут участвовать в цикле.
  • Он полагается на регулярное "уничтожение, когда количество ссылок уменьшается до нуля", чтобы уничтожить циклический мусор. Как только цикл идентифицируется, объектам, участвующим в нем, предлагается очистить свои сильно удерживаемые ссылки, например, вызывая reset(). Этого достаточно, чтобы разбить цикл и автоматически уничтожить объекты. Попросив объекты предоставить и очистить свои надежные ссылки (по запросу), убедитесь, что прерыватель цикла не разрушает инкапсуляцию.

Отсутствие предложений по автоматическому прерыванию цикла указывает на то, что идея была отвергнута по практическим или философским причинам. Мне любопытно, каковы причины. Для полноты, вот некоторые возможные возражения:

  • "Это приведет к детерминированному уничтожению циклических объектов shared_ptr". Если программист контролировал вызов цикла, он не был бы недетерминированным. Кроме того, после вызова поведение циклического выключателя было бы предсказуемым - оно уничтожило бы все известные в настоящее время циклы. Это сродни тому, как деструктор shared_ptr разрушает базовый объект, когда его счетчик ссылок падает до нуля, несмотря на возможность этого вызвать "недетерминированный" каскад дальнейших разрушений.

  • "Автоматический выключатель, как и любая другая форма сбора мусора, приведет к паузе при выполнении программы". Опыт с временем выполнения, который реализует эту функцию, показывает, что паузы минимальны, поскольку GC обрабатывает только циклический мусор, а все остальные объекты возвращаются путем подсчета ссылок. Если детектор цикла никогда не вызывается автоматически, прерывание цикла "пауза" может быть предсказуемым следствием его запуска, подобно тому, как уничтожение большого std::vector может запускать большое количество деструкторов. (В Python циклический gc запускается автоматически, но существует API для временно отключить его в разделах кода, где это не нужно. позволяя GC позже собирать все циклические мусора, созданные тем временем.)

  • "Автоматический выключатель не нужен, потому что циклы не так часто, и их можно легко избежать, используя std::weak_ptr". Циклы фактически легко возникают во многих простых структурах данных - например, дерево, в котором дети имеют обратный указатель на родителя, или двусвязный список. В некоторых случаях циклы между гетерогенными объектами в сложных системах формируются лишь изредка с определенными образцами данных и их трудно предсказать и избежать. В некоторых случаях далеко не очевидно, какой указатель заменить на слабый вариант.

Ответ 1

Здесь обсуждается ряд вопросов, поэтому я переписал свой пост, чтобы лучше конденсировать эту информацию.

Автоматическое обнаружение цикла

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

Этот mixin также требует, чтобы тип реализовал интерфейс. Он должен предоставить возможность перечислять все экземпляры circle_ptr, непосредственно принадлежащие этому экземпляру. И он должен предоставить средства для аннулирования одного из них.

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

Детерминизм и стоимость

"Это приведет к детерминированному уничтожению циклических объектов shared_ptr". Обнаружение цикла происходит только тогда, когда счетчик ссылок shared_ptr падает до нуля, поэтому программист контролирует, когда это происходит. Поэтому он не будет недетерминированным. Его поведение было бы предсказуемым - оно уничтожило бы все известные в настоящее время циклы из этого указателя. Это сродни тому, как деструктор shared_ptr разрушает базовый объект, когда его отсчет отсчета падает до нуля, несмотря на возможность этого вызвать "недетерминированный" каскад дальнейших разрушений.

Это правда, но не полезно.

Существует существенная разница между детерминизмом регулярного разрушения и детерминизма того, что вы предлагаете. А именно: shared_ptr дешево.

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

То, что вы предлагаете, делает это более сложным. В худшем случае каждый раз, когда a circle_ptr уничтожается, код должен пройти через структуры данных, чтобы определить, существует ли цикл. Большую часть времени циклов не будет. Но он все равно должен искать их, просто чтобы убедиться. И он должен делать это каждый раз, когда вы уничтожаете circle_ptr.

Python et. и др. обойти эту проблему, потому что они встроены в язык. Они могут видеть все, что происходит. И поэтому они могут обнаруживать, когда указатель назначается во время выполнения этих назначений. Таким образом, такие системы постоянно выполняют небольшие объемы работы для создания циклических цепей. Как только ссылка исчезнет, ​​она может смотреть на свои структуры данных и предпринимать действия, если это создает циклическую цепочку.

Но то, что вы предлагаете, - это функция библиотеки, а не функция языка. И типы библиотек не могут этого сделать. Вернее, они могут, но только с помощью.

Помните: экземпляр circle_ptr не может знать подобъект, членом которого он является. Он не может автоматически преобразовать указатель на себя в указатель на его собственный класс. И без этой возможности он не может обновлять структуры данных в cycle_detector_mixin, который владеет им, если он переназначен.

Теперь это можно сделать вручную, но только с помощью его собственного экземпляра. Это означает, что circle_ptr потребуется набор конструкторов, которым задан указатель на свой собственный экземпляр, который происходит от cycle_detector_mixin. И тогда его operator= сможет сообщить своему владельцу, что он обновлен. Очевидно, что назначение копирования/перемещения не копирует/перемещает указатель экземпляра экземпляра.

Конечно, для этого требуется, чтобы экземпляр-владелец дал указатель на себя для каждого созданного circle_ptr. В каждой конструкторской функции, создающей экземпляры circle_ptr. Внутри себя и любых классов, которыми он владеет, а также не управляется cycle_detection_mixin. Безошибочно. Это создает некоторую хрупкость в системе; ручное усилие должно быть затрачено для каждого экземпляра circle_ptr, принадлежащего типу.

Это также требует, чтобы circle_ptr содержал 3 типа указателя: указатель на объект, который вы получаете от operator*, указатель на фактическое управляемое хранилище и указатель на этого владельца экземпляра. Причина, по которой экземпляр должен содержать указатель на его владельца, заключается в том, что это данные экземпляра, а не информация, связанная с самим блоком. Это экземпляр circle_ptr, который должен быть способен сообщить его владельцу, когда он восстанавливается, поэтому экземпляру нужны эти данные.

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

Таким образом, это не только требует большой степени хрупкости, но и дорогостоящего, раздувающего размер шрифта на 50%. Замена shared_ptr на этот тип (или более на то, добавление shared_ptr с помощью этой функции) просто неприемлемо.

В плюсе вам больше не нужны пользователи, которые получают от cycle_detector_mixin, чтобы реализовать способ получения списка экземпляров circle_ptr. Вместо этого у вас есть регистр класса с экземплярами circle_ptr. Это позволяет экземплярам circle_ptr, которые могут быть циклическими, чтобы напрямую разговаривать с их владельцем cycle_detector_mixin.

Так что-то.

Инкапсуляция и инварианты

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

Инвариант - это некоторое состояние, которое предполагает кусок кода, истинно, потому что для него должно быть логически невозможно, чтобы оно было ложным. Если вы проверите, что переменная const int равнa > 0, вы установили инвариант для более позднего кода, чтобы это значение было положительным.

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

Это инкапсуляция.

При a shared_ptr можно построить инвариант вокруг существования такого указателя. Вы можете создать свой класс, чтобы указатель никогда не был нулевым. И поэтому никто не должен проверять, что он является нулевым.

Это не тот случай с circle_ptr. Если вы реализуете cycle_detector_mixin, то ваш код должен иметь возможность обрабатывать случай, когда любой из этих экземпляров circle_ptr становится нулевым. Поэтому ваш деструктор не может предположить, что они действительны, а также не может любой код, который вызовет ваш деструктор, сделать это предположение.

Поэтому ваш класс не может установить инвариант с объектом, на который указывает circle_ptr. По крайней мере, если это часть cycle_detector_mixin с соответствующей регистрацией и еще что-то.

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

Это звучит как нарушение инкапсуляции для меня.

Безопасность резьбы

Чтобы получить доступ к weak_ptr, пользователь должен lock его. Это возвращает a shared_ptr, который гарантирует, что объект останется в живых (если он еще был). Блокировка - это атомная операция, точно так же, как эталонное приращение/декрементирование. Таким образом, это все поточно-безопасное.

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

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

Факторы вирульности

Эта идея невероятно вирусная. Каждый другой тип, где могут возникать циклические ссылки, должен реализовывать этот интерфейс. Это не то, что вы можете наложить на один тип. Чтобы получить преимущества, каждый тип, который может участвовать в циклической ссылке, должен использовать его. Постоянно и правильно.

Если вы попытаетесь сделать circle_ptr требуемым, чтобы объект, которым он управляет, реализует cycle_detector_mixin, то вы не можете использовать такой указатель с любым другим типом. Это не было бы заменой (или увеличением) shared_ptr. Таким образом, компилятор не может помочь обнаружить случайное злоупотребление.

Конечно, есть случайные злоупотребления make_shared_from_this, которые не могут быть обнаружены компиляторами. Однако это не вирусная конструкция. Поэтому это проблема только для тех, кому нужна эта функция. Напротив, единственный способ получить выгоду от cycle_detector_mixin - использовать его как можно более полно.

Не менее важно, потому что эта идея настолько вирусна, вы будете ее использовать много. И поэтому вы гораздо чаще сталкиваетесь с проблемой множественного наследования, чем пользователи make_shared_from_this. И это не второстепенная проблема. Тем более что cycle_detector_mixin скорее всего будет использовать static_cast для доступа к производному классу, поэтому вы не сможете использовать виртуальное наследование.

Суммирование

Итак, вот что вы должны сделать, обязательно, чтобы обнаружить циклы, ни один из которых не проверяет компилятор:

  • Каждый класс, участвующий в цикле, должен быть получен из cycle_detector_mixin.

  • Каждый раз, когда класс cycle_detector_mixin -derived строит экземпляр circle_ptr внутри себя (прямо или косвенно, но не внутри класса, который сам происходит из cycle_detector_mixin), передайте указатель на себя cycle_ptr.

  • Не предполагайте, что субобъект cycle_ptr класса действителен. Возможно, даже в той степени, в которой он становится недействительным в функции-члене благодаря проблемам с потоками.

И вот издержки:

  • Структуры данных, определяющие цикличность в cycle_detector_mixin.

  • Каждый cycle_ptr должен быть на 50% больше, даже те, которые не используются для обнаружения цикла.

Заблуждения о собственности

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

"Детектор циклов не нужен, потому что циклы не так часто, и их можно легко избежать, используя std::weak_ptr". Циклы фактически легко возникают во многих простых структурах данных - например, дерево, в котором дети имеют обратный указатель на родителя, или двусвязный список. В некоторых случаях циклы между гетерогенными объектами в сложных системах формируются лишь изредка с определенными образцами данных и их трудно предсказать и избежать. В некоторых случаях далеко не очевидно, какой указатель заменить на слабый вариант.

Это очень распространенный аргумент для GC общего назначения. Проблема с этим аргументом заключается в том, что он обычно делает предположение о том, что использование интеллектуальных указателей просто недопустимо.

Использовать shared_ptr означает что-то. Если класс хранит shared_ptr, который представляет, что класс имеет право собственности на этот объект.

Итак, объясните это: почему node в связанном списке должен владеть как следующим, так и предыдущим узлами? Почему дочерний элемент node в дереве должен владеть родительским node? О, они должны иметь возможность ссылаться на другие узлы. Но им не нужно контролировать срок их жизни.

Например, я бы использовал дерево node как массив unique_ptr для своих детей с одним указателем на родителя. Обычный указатель, а не умный указатель. В конце концов, если дерево построено правильно, родитель будет иметь своих детей. Поэтому, если существует дочерний элемент node, он должен иметь родительский node; ребенок не может существовать без наличия действительного родителя.

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

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

Это не то, для чего shared_ptr. Цель умных указателей заключается не в том, что вы больше не думаете о собственности; это то, что вы можете прямо выражать отношения собственности в коде.

В целом

Как можно улучшить использование weak_ptr для прерывания циклов? Вместо того, чтобы распознавать, когда могут произойти циклы и делать дополнительную работу, вы теперь выполняете кучу дополнительной работы повсюду. Работа, которая чрезвычайно кратковременна; если вы сделаете это неправильно, вам не лучше, чем если бы вы пропустили место, где вы должны были использовать weak_ptr. Только это хуже, потому что вы, вероятно, думаете, что ваш код в безопасности.

Иллюзия безопасности хуже, чем никакой безопасности. По крайней мере, последнее делает вас осторожным.

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

Авторитетные ссылки

Не может быть никаких авторитетных ссылок на то, что было никогда не предлагалось, не предлагалось или даже не предполагалось для стандартизации. Boost не имеет такого типа, и такие конструкции никогда не рассматривалисьдля boost::shared_ptr. Даже самая первая смарт-бумага указателя (PDF) никогда не рассматривала возможность. Тема расширения shared_ptr, чтобы автоматически обрабатывать циклы через какое-то ручное усилие, никогда не обсуждалась даже на стандартных форумах предложений, где далеко Были обсуждены идеи глупого.

Ближайшая к ссылке, которую я могу предоставить, это этот документ с 1994 года о смарт-указателе с подсчетом ссылок. В этой статье в основном говорится о том, чтобы сделать эквивалент shared_ptr и weak_ptr части языка (это было в первые дни, они даже не думали, что можно написать shared_ptr, который позволил бы лить shared_ptr<T> до shared_ptr<U>, когда U является базой T). Но даже в этом, в частности, говорится, что циклы не будут собираться. Он не тратит много времени на то, почему нет, но он это утверждает:

Однако циклы собранных объектов с очисткой функции проблематичны. Если A и B достижимы из друг друга, то уничтожение одного из них сначала нарушит заказная гарантия, оставив свисающий указатель. Если коллекционер прерывает цикл произвольно, программисты не имеют реальной гарантии порядка и тонкой, зависящей от времени могут возникнуть ошибки. На сегодняшний день никто не разработал безопасный, общее решение этой проблемы [Hayes 92].

Это, по сути, проблема инкапсуляции/инварианта, на которую я указал: создание элемента указателя типа invalid нарушает инвариант.

Таким образом, мало кто даже рассмотрел возможность, и те немногие, кто быстро отбросил ее, были непрактичными. Если вы действительно верите, что они ошибаются, единственный лучший способ доказать это - реализовать его самостоятельно. Затем предложите его для стандартизации.

Ответ 2

std::weak_ptr является решением этой проблемы. Ваше беспокойство о

дерево, где у детей есть обратный указатель на родительский

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

и ваше беспокойство о

дважды связанный список

решается std::weak_ptr или необработанным.

Ответ 3

shared_ptr не был создан для автоматической рекультивации круговых ссылок. Он существовал в библиотеке ускорения в течение некоторого времени перед копированием в STL. Это класс, который привязывает счетчик ссылок к любому объекту С++ - будь то массив, класс или int. Это относительно легкий и самодостаточный класс. Он не знает, что он содержит, за исключением того, что он знает функцию делетера для вызова при необходимости.

Для разрешения этого цикла требуется слишком большой код. Если вам нравится GC, вы можете использовать другой язык, который был разработан для GC с самого начала. Закрепление его через STL выглядело бы уродливым. Расширение языка, как в С++/CLI, было бы намного приятнее.

Ответ 4

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

Ответ 5

По ссылке подсчитывать то, что вы просите, невозможно. Чтобы идентифицировать круг, нужно было бы идентифицировать ссылки на ваш объект. Это легко в управляемых памятью языках, так как виртуальная машина знает, кто ссылается на кого.

В С++ вы можете сделать это только путем хранения списка ссылок в круговом указателе, например. UUID, который идентифицирует объект, ссылающийся на ваши ресурсы. Это означало бы, что uuid каким-то образом передается в структуру при приобретении объекта или что указатель имеет доступ к внутренним ресурсам.

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

По-прежнему для преодоления копии и правильного назначения без специализированного метода assign, вероятно, потребуется один источник, который выделяет ссылки. Это не может быть встроено в язык, но может быть реализовано для конкретного приложения как глобального реестра.

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