16

在过去的两年里,我一直在我的项目中广泛使用智能指针(确切地说是 boost::shared_ptr)。我理解并欣赏他们的好处,而且我通常非常喜欢他们。但是我使用它们的次数越多,我就越想念 C++ 在内存管理和 RAII 方面的确定性行为,而我在编程语言中似乎喜欢这种行为。智能指针简化了内存管理过程并提供了自动垃圾收集等功能,但问题在于,通常使用自动垃圾收集,而智能指针具体会在(去)初始化的顺序上引入某种程度的不确定性。这种不确定性剥夺了程序员的控制权,正如我最近意识到的那样,设计和开发 API 的工作,

为了详细说明,我目前正在开发一个 API。此 API 的某些部分要求某些对象在其他对象之前初始化或在其他对象之后销毁。换句话说,(去)初始化的顺序有时很重要。举一个简单的例子,假设我们有一个名为 System 的类。系统提供了一些基本功能(在我们的示例中为日志记录),并通过智能指针保存了许多子系统。

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's destructor?! Its parent System is destroyed after all. BOOM!

    return 0;
}

如果我们使用原始指针来保存子系统,那么当我们的系统出现故障时,我们就会破坏子系统,当然,pSomeSubsystem 将是一个悬空指针。

虽然保护客户端程序员免受自己的伤害不是 API 设计人员的工作,但让 API 易于正确使用和难以错误使用是一个好主意。所以我问你们。你怎么看?我应该如何缓解这个问题?你会如何设计这样一个系统?

在此先感谢,乔希

4

9 回答 9

27

问题总结

这个问题有两个相互竞争的问题。

  1. s 的生命周期管理Subsystem,允许它们在正确的时间被移除。
  2. s 的客户Subsystem需要知道Subsystem他们正在使用的 是有效的。

处理#1

System拥有Subsystems 并且应该用它自己的范围来管理它们的生命周期。为此使用shared_ptrs 特别有用,因为它简化了销毁,但您不应该将它们分发出去,因为这样您就失去了您正在寻求的关于它们的释放的确定性。

处理#2

这是需要解决的更有趣的问题。更详细地描述问题,您需要客户端接收一个对象,该对象的行为类似于Subsystemwhile Subsystem(它是 parent System)存在,但在 aSubsystem被销毁后行为适当。

这很容易通过代理模式状态模式空对象模式的组合来解决。虽然这似乎是一个有点复杂的解决方案,但“只有在复杂性的另一面才有简单性。” 作为库/API 开发人员,我们必须加倍努力以使我们的系统更加健壮。此外,我们希望我们的系统能够按照用户的期望直观地运行,并在他们试图滥用它们时优雅地衰减。这个问题有很多解决方案,但是,这个应该让您达到最重要的一点,正如您和Scott Meyers所说,它“易于正确使用,难以正确使用。

现在,我假设在现实中System处理一些Subsystems 的基类,您可以从中派生各种不同Subsystem的 s。我在下面介绍了它SubsystemBase。您需要在下面引入一个Proxy对象,它通过将请求转发到它所代理的对象来SubsystemProxy实现接口。SubsystemBase(从这个意义上说,它非常像装饰模式的特殊用途应用程序。)每个都Subsystem创建这些对象中的一个,它通过 a 持有shared_ptr,并在通过请求时返回GetProxy(),在调用时由父System对象GetSubsystem()调用。

当 aSystem超出范围时,它的每个Subsystem对象都会被破坏。在他们的析构函数中,他们调用mProxy->Nullify(),这会导致他们的Proxy对象改变他们的State。他们通过更改指向实现接口的Null ObjectSubsystemBase来做到这一点,但什么都不做。

在这里使用状态模式允许客户端应用程序完全不知道某个特定对象是否Subsystem存在。此外,它不需要检查指针或保留本应销毁的实例。

代理模式允许客户端依赖于一个轻量级的对象,该对象完全封装了 API 内部工作的细节,并保持了一个恒定的、统一的接口。

Null Object Pattern允许代理在删除原始对象后运行Subsystem

示例代码

我在这里放置了一个粗略的伪代码质量示例,但我对此并不满意。我已经将它重写为我上面描述的一个精确的编译(我使用了 g++)示例。为了让它工作,我不得不介绍一些其他的类,但它们的用途应该从它们的名称中清楚地看出。我在课堂上使用了单例模式NullSubsystem,因为你不需要超过一个是有道理的。 ProxyableSubsystemBase完全从 Proxying 中抽象出 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.

其他想法:

  • 我在一本 Game Programming Gems 书籍中读到的一篇有趣的文章谈到了使用 Null 对象进行调试和开发。他们专门讨论了使用 Null 图形模型和纹理,例如棋盘纹理,以使缺失的模型真正脱颖而出。通过更改 for a 可以在此处应用相同的方法,该NullSubsystemfor aReportingSubsystem将记录调用,并可能在访问时记录调用堆栈。这将允许您或您的图书馆的客户根据超出范围的内容追踪他们所在的位置,但无需导致崩溃。

  • 我在@Arkadiy 的评论中提到,他在两者之间提出的循环依赖System有点Subsystem令人不快。它可以很容易地通过System从依赖的接口派生来解决,这Subsystem是 Robert C Martin 的依赖倒置原则的应用。更好的办法是将Subsystems 需要的功能与其父级隔离,为此编写一个接口,然后保留该接口的实现者System并将其传递给Subsystems,后者将通过 a 保存它shared_ptr。例如,您可能有LoggerInterface用于Subsystem写入日志的 ,然后您可以从它派生CoutLoggerFileLogger从它派生,并在 中保留这样的实例System
    消除循环依赖

于 2008-12-30T22:43:05.570 回答
11

这可以通过正确使用weak_ptr该类来实现。事实上,您已经非常接近找到一个好的解决方案了。你是对的,你不能期望你“超越”​​你的客户程序员,也不应该期望他们总是遵循你的 API 的“规则”(我相信你已经知道了)。所以,你真正能做的最好的就是损害控制。

我建议您调用GetSubsystem返回 aweak_ptr而不是shared_ptr简单地返回 a ,以便客户端开发人员可以测试指针的有效性,而不必总是声称对它的引用。

同样, havepParentSystem是 a以便它可以通过调用on以及检查boost::weak_ptr<System>来内部检测其父级是否仍然存在(原始指针不会告诉您这一点)。SystemlockpParentSystemNULL

假设您更改您的Subsystem类以始终检查其对应的System对象是否存在,您可以确保如果客户端程序员尝试Subsystem在预期范围之外使用该对象,则会导致错误(由您控制​​),而不是莫名其妙的异常(您必须信任客户端程序员来捕获/正确处理)。

所以,在你的例子中main(),事情不会繁荣!在 dtor 中处理这个问题的最优雅的方法Subsystem是让它看起来像这样:

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)
  }
...
};

我希望这有帮助!

于 2008-12-30T18:52:18.907 回答
4

这里 System 显然拥有子系统,我认为共享所有权没有意义。我会简单地返回一个原始指针。如果一个子系统比它的系统寿命长,这本身就是一个错误。

于 2008-12-30T18:22:45.953 回答
3

你在第一段的开头是对的。您基于 RAII 的设计(如我的和大多数编写良好的 C++ 代码)要求您的对象由独占所有权指针持有。在 Boost 中,这将是 scoped_ptr。

那么你为什么不使用 scoped_ptr。这肯定是因为您希望使用weak_ptr 的好处来防止悬空引用,但您只能将weak_ptr 指向shared_ptr。因此,当您真正想要的是单一所有权时,您已经采用了方便地声明 shared_ptr 的常见做法。这是一个错误的声明,正如您所说,它损害了以正确顺序调用的析构函数。当然,如果您从不共享所有权,您将侥幸逃脱 - 但您必须不断检查您的所有代码以确保它从未被共享。

更糟糕的是 boost::weak_ptr 使用起来很不方便(它没有 -> 运算符),因此程序员通过错误地将被动观察引用声明为 shared_ptr 来避免这种不便。这当然共享所有权,如果您忘记将 shared_ptr 设为空,那么您的对象将不会被销毁,也不会在您打算这样做时调用其析构函数。

简而言之,您已经被 boost 库所困扰——它没有接受良好的 C++ 编程实践,并迫使程序员做出错误的声明以尝试从中获得一些好处。它仅适用于编写真正想要共享所有权并且对严格控制内存或以正确顺序调用析构函数不感兴趣的胶水代码。

我和你走的路一样。C++ 中迫切需要对悬空指针的保护,但 boost 库没有提供可接受的解决方案。我必须解决这个问题——我的软件部门想要保证 C++ 可以安全。所以我推出了自己的 - 这是相当多的工作,可以在以下位置找到:

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

它完全适合单线程工作,我将对其进行更新以包含跨线程共享的指针。它的主要特点是它支持专有对象的智能(自归零)被动观察者。

不幸的是,程序员已经被垃圾收集和“一刀切”的智能指针解决方案所吸引,并且在很大程度上甚至没有考虑所有权和被动观察者 - 结果他们甚至不知道他们所做的事情是错误的并且不要不要抱怨。针对 Boost 的异端几乎闻所未闻!

向您建议的解决方案非常复杂且毫无帮助。它们是由于文化上不愿承认对象指针具有必须正确声明的不同角色以及盲目相信 Boost 必须是解决方案而导致的荒谬示例。

于 2009-08-05T13:23:51.847 回答
2

我认为让 System::GetSubsystem 将原始指针(而不是智能指针)返回给子系统没有问题。由于客户端不负责构造对象,因此没有隐含的合同让客户端负责清理。并且由于它是一个内部引用,因此假设 Subsystem 对象的生命周期取决于 System 对象的生命周期应该是合理的。然后,您应该使用说明该内容的文档来加强此隐含合同。

关键是,您没有重新分配或共享所有权 - 那么为什么要使用智能指针呢?

于 2008-12-30T18:17:52.610 回答
2

这里真正的问题是你的设计。没有好的解决方案,因为模型没有反映好的设计原则。这是我使用的一个方便的经验法则:

  • 如果一个对象包含其他对象的集合,并且可以从该集合返回任意对象,则从您的设计中删除该对象

我意识到您的示例是人为的,但我在工作中看到了很多反模式。问问自己,System添加没有的价值是什么std::vector< shared_ptr<SubSystem> >?您的 API 的用户需要知道SubSystem(因为您返回他们)的接口,所以为他们编写一个持有者只会增加复杂性。至少人们知道界面来std::vector,强迫他们记住GetSubsystem()上面at()或者operator[]只是卑鄙的。

你的问题是关于管理对象的生命周期,但是一旦你开始分发对象,你要么失去对生命周期的控制,要么让其他人让它们保持活动状态shared_ptr(在多线程应用程序中,情况更糟——谁将您分发给不同线程的对象锁定?当以这种方式使用时,提升共享指针和弱指针是一个复杂性诱导陷阱,特别是因为它们只是线程安全的,足以绊倒没有经验的开发人员。

如果您要创建一个持有者,它需要向您的用户隐藏复杂性并减轻他们可以自己管理的负担。例如,一个接口包括 a) 向子系统发送命令(例如 URI - /system/subsystem/command?param=value)和 b) 迭代子系统和子系统命令(通过类似 stl 的迭代器)和可能的 c) register 子系统将允许您向用户隐藏几乎所有实现细节,并在内部强制执行生命周期/排序/锁定要求。

在任何情况下,可迭代/可枚举的 API 都比公开对象更可取——命令/注册可以很容易地序列化以生成测试用例或配置文件,并且它们可以交互显示(例如,在树控件中,对话框由查询组成可用的操作/参数)。您还将保护您的 API 用户免受您可能需要对子系统类进行的内部更改。

我会提醒您不要遵循 Aarons answer 中的建议。为一个需要 5 种不同设计模式来实现的简单问题设计解决方案只能意味着解决了错误的问题。我也厌倦了任何人引用迈尔斯先生的设计,因为他自己承认:

“我已经 20 多年没有写过生产软件了,我也从来没有用 C++ 写过生产软件。不,从来没有。此外,我什至从未尝试过用 C++ 编写生产软件,所以我不仅不是真正的C++ 开发人员,我什至不是一个想要的人。稍微平衡一点的事实是,我在研究生期间(1985-1993 年)确实用 C++ 编写了研究软件,但即使这样也只是一个小开发人员(几千行)很快就被扔掉的东西。自从十几年前作为顾问脱颖而出以来,我的 C++ 编程一直仅限于玩具“让我们看看它是如何工作的”(或者,有时,“让我们看看有多少编译器这会破坏“)程序,通常是适合单个文件的程序”。

并不是说他的书不值得一读,但他没有权力谈论设计或复杂性。

于 2009-05-30T03:33:25.923 回答
1

在您的示例中,如果 System 持有 avector<Subsystem>而不是 a会更好vector<shared_ptr<Subsystem> >。它既简单又消除了您的顾虑。GetSubsystem 将返回一个引用。

于 2008-12-30T18:16:33.737 回答
1

堆栈对象将以与实例化相反的顺序释放,因此除非使用 API 的开发人员试图管理智能指针,否则通常不会有问题。有一些事情是你无法阻止的,你能做的最好的就是在运行时提供警告,最好只调试。

您的示例对我来说似乎非常像 COM,您对使用 shared_ptr 返回的子系统进行了引用计数,但是您在系统对象本身上缺少它。

如果每个子系统对象在创建时对系统对象执行了 addref,并在销毁时释放,那么如果在系统对象提前销毁时引用计数不正确,您至少可以显示异常。

使用weak_ptr 还可以让您提供一条消息而不是/以及当事情以错误的顺序释放时爆炸。

于 2008-12-31T00:43:36.730 回答
0

您的问题的本质是循环引用:System 指的是 Subsystem,而 Subsystem 又指的是 System。这种数据结构不能通过引用计数轻松处理——它需要适当的垃圾收集。您试图通过对其中一条边使用原始指针来打破循环 - 这只会产生更多的复杂性。

至少已经提出了两个好的解决方案,所以我不会试图超越以前的海报。我只能注意到,在@Aaron 的解决方案中,您可以拥有系统的代理而不是子系统的代理 - 取决于更复杂的和有意义的。

于 2008-12-31T14:53:38.857 回答