运行时可以更改所有权的智能指针(C++)

7

我经常遇到这样的情况:当我有一个复杂的类(例如实现一些数值算法,比如偏微分方程求解器),其中包含数据数组,这些数组可以根据使用情况要么由该类自己拥有,要么从外部上下文中绑定。问题在于如何为这样的类创建一个强大的析构函数。简单的方法是创建一个布尔标志,指示数组是否被拥有。例如:

// simplest example I can think about
class Solver{
   int     nParticles;
   bool own_position;
   bool own_velocity;
   double* position;
   double* velocity;
   // there is more buffers like this, not just position and velocity, but e.g. mass, force, pressure etc. each of which can be either owned or binded externally independently of each other, therefore if there is 6 buffers, there is 2^6 variants of owership (e.g. of construction/destruction) 
   void move(double dt){ for(int i=0; i<n; i++){ position[i]+=velocity[i]*dt; } }

   ~Solver(){
       if(own_position) delete [] position;
       if(own_velocity) delete [] velocity;  
    }
};

很自然地,这激励我们在数组指针周围制作一个模板包装器(我应该称其为智能指针吗?):

template<typename T>
struct Data{
   bool own;
   T* data;
   ~Data{ if(own)delete [] T; }
}; 


class Solver{
   int          nParticles;
   Data<double> position;
   Data<double> velocity;
   void move(double dt){ for(int i=0; i<n; i++){ position.data[i]+=velocity.data[i]*dt; } }
   // default destructor is just fine (?)
};

问题:

  • 这必须是一个常见的模式,我需要重新发明轮子吗?
  • C++标准库中是否有类似的东西?(抱歉,我更像一个物理学家而不是一个程序员)
  • 有什么需要考虑的要点吗?

----------------------------------------

编辑:为了阐明绑定到外部上下文的含义(如Albjenow所建议的):

情况1)私有/内部工作数组(无共享所有权)


// constructor to allocate own data
Data::Data(int n){
    data = new double[n];
    own  = true;
}

Solver::Solver(int n_){
    n=n_;
    position(n); // type Data<double>
    velocity(n);
}

void flowFieldFunction(int n, double* position, double* velocity ){
   for(int i=0;i<n;i++){
      velocity[i] = sin( position[i] );
   }
}

int main(){
   Solver solver(100000); // Solver allocates all arrays internally
   // --- run simulation
   // int niters=10;
   for(int i=0;i<niters;i++){
       flowFieldFunction(solver.n,solver.data.position,solver.data.velocity);
       solver.move(dt);
   }
}

案例2) 绑定到外部数据数组(例如来自其他类)

Data::bind(double* data_){
    data=data_;
    own=false;
}

// example of "other class" which owns data; we have no control of it
class FlowField{
   int n;
   double* position;
   void getVelocity(double* velocity){
      for(int i=0;i<n;i++){
         velocity[i] = sin( position[i] );
      }
   }
   FlowField(int n_){n=n_;position=new double[n];}
   ~FlowField(){delete [] position;}
}

int main(){
   FlowField field(100000);
   Solver    solver; // default constructor, no allocation
   // allocate some
   solver.n=field.n;
   solver.velocity(solver.n);
   // bind others 
   solver.position.bind( field.position );
   // --- run simulation
   // int niters=10;
   for(int i=0;i<niters;i++){
       field.getVelocity(solver.velocity);
       solver.move(dt);
   }
}

3
一个 std::shared_ptr - tkausl
3
您可以使用具有自定义删除器的std::unique_ptrstd::shared_ptr,该删除器存储是否删除(您拥有它)或不执行任何操作(由外部拥有)。编写自己的类也可以,但在各个方面正确地完成需要一些经验... - Max Langhof
1
还有boost库中的intrusive_ptr。 - Thomas
1
你能举一个“从外部上下文绑定”的例子吗?如果不知道你的用例,我们很难建议解决方案。 - Albjenow
2
“其他实例”不必知道UniversE。如果数据数组是拥有的own=True,那么该数组根本不应从外部访问(或者如果它被访问了,用户有责任处理这个问题)。这不是这里的任务。这里的任务是创建一个类,可以分配自己的工作数组,或绑定到已经存在的工作数组上。 - Prokop Hapala
显示剩余5条评论
2个回答

2
这里有一种简单的方法可以实现你想要的功能,而不必编写任何智能指针(这将很难获得正确的细节)或编写自定义析构函数(这意味着需要更多的代码和其他特殊成员函数的错误潜在性,这是五法则所要求的):
#include <memory>

template<typename T>
class DataHolder
{
public:
    DataHolder(T* externallyOwned)
      : _ownedData(nullptr)
      , _data(externallyOwned)
    {
    }

    DataHolder(std::size_t allocSize)
      : _ownedData(new T[allocSize])
      , _data(_ownedData.get())
    {
    }

    T* get() // could add a const overload
    {
        return _data;
    }

private:
    // Order of these two is important for the second constructor!
    std::unique_ptr<T[]> _ownedData;
    T* _data;
};

https://godbolt.org/z/T4cgyy

unique_ptr成员保存自分配的数据,或者在使用外部拥有的数据时为空。原始指针指向前一种情况下unique_ptr的内容,或者后一种情况下的外部内容。你可以修改构造函数(或仅使它们通过静态成员函数如DataHolder::fromExternal()DataHolder::allocateSelf()可访问,这些函数返回使用适当构造函数创建的DataHolder实例),以使意外误用更加困难。

(请注意,成员按照它们在类中声明的顺序进行初始化,而不是按照成员初始化列表的顺序进行初始化,因此将unique_ptr放在原始指针之前很重要!)

当然,由于unique_ptr成员,该类无法被复制,但可以移动构建或赋值(具有正确的语义)。开箱即用,应该可以正常工作。


谢谢,我明白你的意思。但我不确定我是否喜欢这个。因为它增加了一些复杂性(就像包装器的包装器)。如果标准库中有即时的预制解决方案,我可能会使用它。但是当我必须编写自己的包装器时,我更喜欢从头开始编写,因为我不喜欢使用我不知道确切功能和实现方式的东西(如unique_ptr)。它可能对特定用例效率低下(做一些不必要的工作,并使用不必要的内存),而我无法控制。抱歉,也许我太老派了,是个C语言人。 - Prokop Hapala
没有预设的解决方案,这可能是您可以添加的最低复杂度(除了像其他答案建议的那样绕过整个问题)。如果您不理解此包装器的作用,请随时提问。这里没有任何魔法,只要具备现代C++的基本知识,一切都应该非常清晰。此解决方案几乎不使用额外的空间并且不执行额外的工作 - 您最终将像以前一样使用原始的T* - Max Langhof
我理解你编写的这个包装器的作用。我不确切知道unique_ptr是如何实现的(猜测它与平台相关)。对我来说,所有智能指针都是原始指针的包装器。因此,编写智能指针的包装器就是编写包装器的包装器。从类似于使意外误用更难的句子中,我认为你在不同的上下文中思考。我并不是试图限制用户做某事。我试图创建一个最灵活且最少有样板文件的类。加入"五法则"——好吧,也许我会在某些时候遇到困难,但我从未必须遵循它。 - Prokop Hapala
我强烈推荐熟悉std::unique_ptr,这样你就可以减轻对空间或性能开销的担忧(它没有)。它所做的只是1.表达(在语义层面上)你唯一拥有一个资源,2.在销毁时负责释放该资源。就是这样。仅仅因为它是一个包装器,并不意味着它更慢或更大或任何类似的东西。如果你不相信我,请看生成的汇编代码。 - Max Langhof
好的,我知道了。在这种情况下,你是正确的。我已经考虑使用智能指针一段时间了,但是对它们的实现不确定性以及缺乏真正需要它们(或发现它们方便)的用例总是让我望而却步。仅从美学和人体工程学角度来看,我更喜欢 *,因为它更短(代码中视觉噪音更少)而不是 std::unique_ptr<> - Prokop Hapala
很公正。是的,智能指针比 * 更吵闹 - 但在大多数情况下,代码的读者会发现“我拥有这个东西并将清理它”的传达信息一定值得那可视带宽 ;) - Max Langhof

1
一种解决方案是将数据所有权与求解算法分开。让算法可选地管理其输入的生命周期并不是好的设计,因为它会导致不同关注点的纠缠。求解算法应始终引用已经存在的数据,并且如果必要,应该有另一个额外的类来拥有数据,其生命周期不短于算法的生命周期,例如:
struct Solver {
    int nParticles;
    double* position;
    double* velocity;
};

struct Data {
    std::vector<double> position, velocity; // Alternatively, std::unique_ptr<double[]>.

    template<class T>
    static T* get(int size, std::vector<T>& own_data, T* external_data) {
        if(external_data)
            return external_data;
        own_data.resize(size);
        return own_data.data();
    }

    double* get_position(int nParticles, double* external_position) { return get(nParticles, position, external_position); }
    double* get_velocity(int nParticles, double* external_velocity) { return get(nParticles, velocity, external_velocity); }
};

struct SolverAndData {
    Data data;
    Solver solver;

    SolverAndData(int nParticles, double* external_position, double* external_velocity)
        : solver{
              nParticles,
              data.get_position(nParticles, external_position),
              data.get_velocity(nParticles, external_velocity)
          }
    {}

    SolverAndData(SolverAndData const&) = delete;
    SolverAndData& operator=(SolverAndData const&) = delete;
};

int main() {
    SolverAndData a(1, nullptr, nullptr);

    double position = 0;
    SolverAndData b(1, &position, nullptr);

    double velocity = 0;
    SolverAndData c(1, nullptr, &velocity);

    SolverAndData d(1, &position, &velocity);
}

@ProkopHapala,我为您添加了一个示例。我认为这里根本不适用“不必要的样板文件和污染命名空间”。 - Maxim Egorushkin
构造函数和析构函数如何自动处理所有情况,当所有工作缓冲区(不仅仅是“位置”和“速度”)可以是自己的或外部绑定时?如果有6个这样的缓冲区,我需要2^6个不同的包装器吗?更不用说我在标题中写道,我更喜欢在运行时切换所有权。 - Prokop Hapala
是的,但它拥有所有数据,或者您假设只有一个数据缓冲区。但在我遇到的情况中,有许多不同的情况(所有组合)。例如,有时粒子具有一些可变质量(每个不同),有时有重量系数等,我不想为每种情况制作特定的类。这就是为什么我认为最有效的解决方案是在运行时启用所有权切换,并创建处理所有2^6种初始化方式的构造函数,具体取决于参数。当然,我不想用这个来复杂化最初的问题。 - Prokop Hapala
1
@ProkopHapala 给你添加了另一个例子。 - Maxim Egorushkin
我不确定您指的“DataAccess”是什么。但是,总的来说,我觉得与软件工程师讨论良好实践并不总适用于我的情况。您可能会假设有很多人力资源,而瓶颈是协作的组织。我只有一点人力资源(只有我),需要用很少的代码来表达大量的功能。我使用C++(而不是C)来表达和提高性能,而非安全性。如果有可以自动化操作的工具,我很乐意使用,这样我就不必关心了。但不能以损失表达能力为代价。 - Prokop Hapala
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接