Вопрос о умных указателях и их неизбежном индетерминизме

Я активно использовал интеллектуальные указатели (boost:: shared_ptr, если быть точным) в моих проектах за последние два года. Я понимаю и ценю их преимущества, и я вообще их очень люблю. Но чем больше я их использую, тем больше я скучаю по детерминированному поведению С++ относительно управления памятью и RAII, которые мне кажутся на языке программирования. Умные указатели упрощают процесс управления памятью и обеспечивают автоматическую сборку мусора между прочим, но проблема заключается в том, что использование автоматической сборки мусора вообще и умного указателя конкретно вводит некоторую степень неопределенности в порядке (де) инициализации. Этот индетерминизм отбирает контроль у программистов и, как я понял в последнее время, делает работу по разработке и разработке API-интерфейсов, использование которых не известно заранее на момент разработки, изнурительно трудоемкое, потому что все шаблоны использования и угловые случаи должны быть хорошо поняты.

Чтобы уточнить, в настоящее время я разрабатываю API. Части этого API требуют, чтобы определенные объекты были инициализированы раньше или уничтожены после других объектов. Иными словами, порядок (де) инициализации важен в разы. Чтобы дать вам простой пример, скажем, у нас есть класс под названием "Система". Система предоставляет некоторые базовые функции (вход в наш пример) и содержит ряд подсистем через интеллектуальные указатели.

class System {
public:
    boost::shared_ptr< Subsystem > GetSubsystem( unsigned int index ) {
        assert( index < mSubsystems.size() );
        return mSubsystems[ index ];
    }

    void LogMessage( const std::string& message ) {
        std::cout << message << std::endl;
    }

private:
    typedef std::vector< boost::shared_ptr< Subsystem > > SubsystemList;
    SubsystemList mSubsystems;    
};

class Subsystem {
public:
    Subsystem( System* pParentSystem )
         : mpParentSystem( pParentSystem ) {
    }

    ~Subsystem() {
         pParentSubsystem->LogMessage( "Destroying..." );
         // Destroy this subsystem: deallocate memory, release resource, etc.             
    }

    /*
     Other stuff here
    */

private:
    System * pParentSystem; // raw pointer to avoid cycles - can also use weak_ptrs
};

Как вы уже можете сказать, подсистема имеет смысл только в контексте системы. Но подсистема в такой конструкции может легко пережить свою родительскую систему.

int main() {
    {
        boost::shared_ptr< Subsystem > pSomeSubsystem;
        {
            boost::shared_ptr< System > pSystem( new System );
            pSomeSubsystem = pSystem->GetSubsystem( /* some index */ );

        } // Our System would go out of scope and be destroyed here, but the Subsystem that pSomeSubsystem points to will not be destroyed.

     } // pSomeSubsystem would go out of scope here but wait a second, how are we going to log messages in Subsystem destructor?! Its parent System is destroyed after all. BOOM!

    return 0;
}

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

Хотя разработчик API не должен защищать программистов-клиентов от самих себя, неплохо было бы сделать API удобным для использования правильно и трудно использовать неправильно. Поэтому я спрашиваю вас, ребята. Как вы думаете? Как мне решить эту проблему? Как бы вы создали такую ​​систему?

Спасибо заранее, Джош

Ответы

Ответ 1

Резюме проблемы

В этом вопросе есть две конкурирующие проблемы.

  • Управление жизненным циклом Subsystem s, позволяющее их удалить в нужное время.
  • Клиенты Subsystem должны знать, что они используются Subsystem.

Обработка # 1

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

Обработка # 2

Это более интересная задача для решения. Описывая проблему более подробно, вам нужны клиенты для получения объекта, который ведет себя как Subsystem, тогда как Subsystem (и он родительский System) существует, но ведет себя соответствующим образом после уничтожения Subsystem.

Это легко решить комбинацией шаблон прокси, State Pattern и Нулевой шаблон объекта. Хотя это может показаться немного сложным решением: "Существует простота, которая должна быть выполнена только с другой стороны сложности". Как разработчики библиотеки /API, мы должны пройти лишнюю милю, чтобы сделать наши системы надежными. Кроме того, мы хотим, чтобы наши системы вели себя интуитивно, как ожидает пользователь, и грамотно распадались, когда они пытались их неправильно использовать. Есть много решений этой проблемы, однако, это должно привести вас к тому, что все важные моменты, когда, как вы и Скотт Майерс говорят, что это "легко использовать правильно и трудно использовать неправильно".

Теперь я предполагаю, что на самом деле System имеет дело с некоторым базовым классом Subsystem s, из которого вы получаете различные разные Subsystem s. Я представил его ниже как SubsystemBase. Вам нужно ввести объект Прокси, SubsystemProxy ниже, который реализует интерфейс SubsystemBase путем перенаправления запросов на объект, который он проксирует. (В этом смысле это очень похоже на специальное приложение Decorator Pattern.) Каждый Subsystem создает один из этих объектов, который он хранит с помощью shared_ptr, и возвращается при запросе через GetProxy(), который вызывается родительским объектом System, когда вызывается GetSubsystem().

Когда a System выходит за пределы области видимости, каждый из них Subsystem уничтожается. В своем деструкторе они вызывают mProxy->Nullify(), что приводит к тому, что их объекты Прокси меняют свое состояние . Они делают это, изменяя точку на Null Object, который реализует интерфейс SubsystemBase, но делает это, ничего не делая.

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

Шаблон прокси-сервера позволяет клиенту быть зависимым от объекта с небольшим весом, который полностью обертывает детали внутренней работы API и поддерживает постоянный равномерный интерфейс.

Нулевой шаблон объекта позволяет Прокси функционировать после того, как исходный Subsystem был удален.

Пример кода

Здесь я применил пример качественного псевдокода, но меня это не устраивало. Я переписал его как точный, компилирующий (я использовал g++) пример того, что я описал выше. Чтобы заставить его работать, мне пришлось ввести несколько других классов, но их использование должно быть понятным из их имен. Я использовал Singleton Pattern для класса NullSubsystem, так как имеет смысл, что вам не понадобится больше одного. ProxyableSubsystemBase полностью абстрагирует поведение Proxying от Subsystem, позволяя ему не знать этого поведения. Вот диаграмма UML классов:

UML-диаграмма подсистемы и системной иерархии

Пример кода:

#include <iostream>
#include <string>
#include <vector>

#include <boost/shared_ptr.hpp>


// Forward Declarations to allow friending
class System;
class ProxyableSubsystemBase;

// Base defining the interface for Subsystems
class SubsystemBase
{
  public:
    // pure virtual functions
    virtual void DoSomething(void) = 0;
    virtual int GetSize(void) = 0;

    virtual ~SubsystemBase() {} // virtual destructor for base class
};


// Null Object Pattern: an object which implements the interface to do nothing.
class NullSubsystem : public SubsystemBase
{
  public:
    // implements pure virtual functions from SubsystemBase to do nothing.
    void DoSomething(void) { }
    int GetSize(void) { return -1; }

    // Singleton Pattern: We only ever need one NullSubsystem, so we'll enforce that
    static NullSubsystem *instance()
    {
      static NullSubsystem singletonInstance;
      return &singletonInstance;
    }

  private:
    NullSubsystem() {}  // private constructor to inforce Singleton Pattern
};


// Proxy Pattern: An object that takes the place of another to provide better
//   control over the uses of that object
class SubsystemProxy : public SubsystemBase
{
  friend class ProxyableSubsystemBase;

  public:
    SubsystemProxy(SubsystemBase *ProxiedSubsystem)
      : mProxied(ProxiedSubsystem)
      {
      }

    // implements pure virtual functions from SubsystemBase to forward to mProxied
    void DoSomething(void) { mProxied->DoSomething(); }
    int  GetSize(void) { return mProxied->GetSize(); }

  protected:
    // State Pattern: the initial state of the SubsystemProxy is to point to a
    //  valid SubsytemBase, which is passed into the constructor.  Calling Nullify()
    //  causes a change in the internal state to point to a NullSubsystem, which allows
    //  the proxy to still perform correctly, despite the Subsystem going out of scope.
    void Nullify()
    {
        mProxied=NullSubsystem::instance();
    }

  private:
      SubsystemBase *mProxied;
};


// A Base for real Subsystems to add the Proxying behavior
class ProxyableSubsystemBase : public SubsystemBase
{
  friend class System;  // Allow system to call our GetProxy() method.

  public:
    ProxyableSubsystemBase()
      : mProxy(new SubsystemProxy(this)) // create our proxy object
    {
    }
    ~ProxyableSubsystemBase()
    {
      mProxy->Nullify(); // inform our proxy object we are going away
    }

  protected:
    boost::shared_ptr<SubsystemProxy> GetProxy() { return mProxy; }

  private:
    boost::shared_ptr<SubsystemProxy> mProxy;
};


// the managing system
class System
{
  public:
    typedef boost::shared_ptr< SubsystemProxy > SubsystemHandle;
    typedef boost::shared_ptr< ProxyableSubsystemBase > SubsystemPtr;

    SubsystemHandle GetSubsystem( unsigned int index )
    {
        assert( index < mSubsystems.size() );
        return mSubsystems[ index ]->GetProxy();
    }

    void LogMessage( const std::string& message )
    {
        std::cout << "  <System>: " << message << std::endl;
    }

    int AddSubsystem( ProxyableSubsystemBase *pSubsystem )
    {
      LogMessage("Adding Subsystem:");
      mSubsystems.push_back(SubsystemPtr(pSubsystem));
      return mSubsystems.size()-1;
    }

    System()
    {
      LogMessage("System is constructing.");
    }

    ~System()
    {
      LogMessage("System is going out of scope.");
    }

  private:
    // have to hold base pointers
    typedef std::vector< boost::shared_ptr<ProxyableSubsystemBase> > SubsystemList;
    SubsystemList mSubsystems;
};

// the actual Subsystem
class Subsystem : public ProxyableSubsystemBase
{
  public:
    Subsystem( System* pParentSystem, const std::string ID )
      : mParentSystem( pParentSystem )
      , mID(ID)
    {
         mParentSystem->LogMessage( "Creating... "+mID );
    }

    ~Subsystem()
    {
         mParentSystem->LogMessage( "Destroying... "+mID );
    }

    // implements pure virtual functions from SubsystemBase
    void DoSomething(void) { mParentSystem->LogMessage( mID + " is DoingSomething (tm)."); }
    int GetSize(void) { return sizeof(Subsystem); }

  private:
    System * mParentSystem; // raw pointer to avoid cycles - can also use weak_ptrs
    std::string mID;
};



//////////////////////////////////////////////////////////////////
// Actual Use Example
int main(int argc, char* argv[])
{

  std::cout << "main(): Creating Handles H1 and H2 for Subsystems. " << std::endl;
  System::SubsystemHandle H1;
  System::SubsystemHandle H2;

  std::cout << "-------------------------------------------" << std::endl;
  {
    std::cout << "  main(): Begin scope for System." << std::endl;
    System mySystem;
    int FrankIndex = mySystem.AddSubsystem(new Subsystem(&mySystem, "Frank"));
    int ErnestIndex = mySystem.AddSubsystem(new Subsystem(&mySystem, "Ernest"));

    std::cout << "  main(): Assigning Subsystems to H1 and H2." << std::endl;
    H1=mySystem.GetSubsystem(FrankIndex);
    H2=mySystem.GetSubsystem(ErnestIndex);


    std::cout << "  main(): Doing something on H1 and H2." << std::endl;
    H1->DoSomething();
    H2->DoSomething();
    std::cout << "  main(): Leaving scope for System." << std::endl;
  }
  std::cout << "-------------------------------------------" << std::endl;
  std::cout << "main(): Doing something on H1 and H2. (outside System Scope.) " << std::endl;
  H1->DoSomething();
  H2->DoSomething();
  std::cout << "main(): No errors from using handles to out of scope Subsystems because of Proxy to Null Object." << std::endl;

  return 0;
}

Выход из кода:

main(): Creating Handles H1 and H2 for Subsystems.
-------------------------------------------
  main(): Begin scope for System.
  <System>: System is constructing.
  <System>: Creating... Frank
  <System>: Adding Subsystem:
  <System>: Creating... Ernest
  <System>: Adding Subsystem:
  main(): Assigning Subsystems to H1 and H2.
  main(): Doing something on H1 and H2.
  <System>: Frank is DoingSomething (tm).
  <System>: Ernest is DoingSomething (tm).
  main(): Leaving scope for System.
  <System>: System is going out of scope.
  <System>: Destroying... Frank
  <System>: Destroying... Ernest
-------------------------------------------
main(): Doing something on H1 and H2. (outside System Scope.)
main(): No errors from using handles to out of scope Subsystems because of Proxy to Null Object.

Другие мысли:

  • Интересная статья, которую я прочитал в одной из книг по программированию Gems, рассказывает об использовании Null Objects для отладки и разработки. Они конкретно говорили об использовании Null Graphics Models и Textures, таких как текстура шахматной доски, чтобы действительно отсутствовали недостающие модели. То же самое можно было бы применить здесь, изменив NullSubsystem для a ReportingSubsystem, который будет регистрировать вызов и, возможно, callstack всякий раз, когда к нему обращаются. Это позволит вам или вашим библиотечным клиентам отслеживать, где они находятся, в зависимости от того, что выходит за рамки, но без необходимости вызывать сбои.

  • Я упомянул в комментарии @Arkadiy, что круговая зависимость, которую он поднимал между System и Subsystem, немного неприятна. Его можно легко исправить, если System вытекает из интерфейса, от которого зависит Subsystem, приложение Robert C Martin Принцип инверсии зависимостей. Лучше всего было бы изолировать функциональные возможности, которые Subsystem нужно от их родителя, написать для этого интерфейс, а затем удержать разработчика этого интерфейса в System и передать его в Subsystem s, который будет содержать его через a shared_ptr. Например, у вас может быть LoggerInterface, который ваш Subsystem использует для записи в журнал, тогда вы можете извлечь из него CoutLogger или FileLogger и сохранить экземпляр такого в System.
    Устранение круговой зависимости

Ответ 2

Это полезно при правильном использовании класса weak_ptr. На самом деле, вы уже достаточно близки к тому, чтобы иметь хорошее решение. Вы правы, что от вас не следует ожидать "выдумки" ваших программистов-клиентов, и вы не должны ожидать, что они всегда будут следовать "правилам" вашего API (как я уверен, вы уже знаете). Таким образом, лучшее, что вы действительно можете сделать, это контроль повреждений.

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

Аналогично, pParentSystem должен быть boost::weak_ptr<System>, чтобы он мог внутренне обнаруживать, существует ли его родительский System по вызову lock на pParentSystem вместе с проверкой на NULL (необработанный указатель не скажет вам об этом).

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

Итак, в вашем примере с main() все не будет идти BOOM! Самый изящный способ справиться с этим в Subsystem dtor состоит в том, чтобы он выглядел примерно так:

class Subsystem
{
...
  ~Subsystem() {
       boost::shared_ptr<System> my_system(pParentSystem.lock());

       if (NULL != my_system.get()) {  // only works if pParentSystem refers to a valid System object
         // now you are guaranteed this will work, since a reference is held to the System object
         my_system->LogMessage( "Destroying..." );
       }
       // Destroy this subsystem: deallocate memory, release resource, etc.             

       // when my_system goes out of scope, this may cause the associated System object to be destroyed as well (if it holds the last reference)
  }
...
};

Надеюсь, это поможет!

Ответ 3

Здесь система явно владеет подсистемами, и я не вижу смысла в совместном владении. Я бы просто возвращал необработанный указатель. Если Подсистема переживает свою Систему, эта ошибка сама по себе.

Ответ 4

Вы были в самом начале в первом абзаце. Ваши проекты, основанные на RAII (например, мой и самый хорошо написанный код на С++), требуют, чтобы ваши объекты были защищены эксклюзивными указателями собственности. В Boost, который будет scoped_ptr.

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

Хуже того, что boost:: weak_ptr неудобно использовать (у него нет → оператор), поэтому программисты избегают этого неудобства, ложно объявляя пассивные ссылки наблюдения как shared_ptr. Это, конечно, владение акциями, и если вы забудете обнулить этот shared_ptr, тогда ваш объект не будет уничтожен или его деструктор будет вызван, когда вы его намереваетесь.

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

Я был на том же пути, что и вы. Защита от оборванных указателей крайне необходима в С++, но библиотека boost не обеспечивает приемлемого решения. Мне пришлось решить эту проблему - мой отдел программного обеспечения хотел получить заверения в том, что С++ можно сделать безопасным. Поэтому я катался самостоятельно - это была довольно большая работа и можно найти по адресу:

http://www.codeproject.com/KB/cpp/XONOR.aspx

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

К сожалению, программисты стали соблазняться сборкой мусора и "умным указателем" для одного размера, и в значительной степени даже не думают о владельцах и пассивных наблюдателях - в результате они даже не знают, что то, что они делают, неправильно и не жалуются. Ересь против Boost почти неслыханна!

Решения, которые были предложены вам, абсурдно сложны и вообще не помогают. Они являются примерами абсурда, вызванного культурным нежеланием распознавать, что указатели объектов имеют четкую роль, которые должны быть правильно объявлены, и слепая вера в то, что Boost должен быть решением.

Ответ 5

Я не вижу проблемы с тем, что System:: GetSubsystem возвращает необработанный указатель (RATHER, чем смарт-указатель) в подсистему. Поскольку клиент не несет ответственности за построение объектов, то для нечистого контракта клиент не несет ответственности за очистку. И поскольку это внутренняя ссылка, разумно предположить, что время жизни объекта подсистемы зависит от времени жизни объекта System. Затем вы должны укрепить этот подразумеваемый контракт с документацией, указав столько.

Дело в том, что вы не переназначаете или не разделяете права собственности - так зачем использовать интеллектуальный указатель?

Ответ 6

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

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

Я понимаю, что ваш пример надуман, но его анти-шаблон я вижу много на работе. Спросите себя, какое значение System добавляет, что std::vector< shared_ptr<SubSystem> > doesnt? Пользователи вашего API должны знать интерфейс SubSystem (так как вы их возвращаете), поэтому писать для них держатель - это только сложность. По крайней мере, люди знают интерфейс std::vector, заставляя их помнить GetSubsystem() выше at() или operator[]. Это просто означает.

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

Если вы собираетесь создать держатель, ему нужно скрыть сложность ваших пользователей и избавить их от бремени, которым вы можете управлять самостоятельно. Например, интерфейс, состоящий из: a) команды отправки в подсистему (например, URI -/system/subsystem/command? Param = value) и b) итерации подсистем и команд подсистем (через stl-подобный итератор) и, возможно, c) подсистема регистрации позволит вам скрыть почти все детали вашей реализации от ваших пользователей и обеспечить внутреннее выполнение требований времени жизни/порядка/блокировки.

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

Я бы предостерег вас от того, чтобы следовать совету в ответе Аарона. Проектирование решения проблемы, которое простое, что требует 5 различных шаблонов проектирования для реализации, может означать только то, что проблема решена. Я тоже устал от любого, кто цитирует мистера Майерса в отношении дизайна, поскольку по его собственному признанию:

"Я не писал программное обеспечение для производства более 20 лет, и я никогда не писал программное обеспечение для производства на С++. Нет, никогда. Кроме того, Ive даже не пытался писать производственное программное обеспечение на С++, поэтому я не просто реальный разработчик на С++, Im даже не wannabe. Уравновешивая это немного, это факт, что я писал программное обеспечение для исследования на С++ во время учебы в аспирантуре (1985-1993), но даже это был небольшой (несколько тысяч строк) один разработчик и, выйдя в качестве консультанта более десяти лет назад, мое программирование на C++ было ограничено игрушкой" позволяет увидеть, как это работает "(или, иногда," позволяет увидеть, сколько компиляторов это breaks "), обычно программы, которые вписываются в один файл".

Нельзя сказать, что его книги не стоит читать, но он не имеет права говорить по поводу дизайна или сложности.

Ответ 7

В вашем примере было бы лучше, если бы система держала vector<Subsystem>, а не vector<shared_ptr<Subsystem> >. Это и то, и другое, и избавляет вас от беспокойства. GetSubsystem вернет ссылку вместо этого.

Ответ 8

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

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

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

Использование weak_ptr также позволит вам предоставлять сообщение вместо этого, а также взрываться, когда вещи освобождаются в неправильном порядке.

Ответ 9

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

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