指向堆栈分配对象的指针和移动构造函数

4
注:这是我之前发布的一个问题的完整重新措辞。如果您发现它们重复,请关闭另一个。
我的问题很普遍,但似乎通过一个具体简单的例子更容易解释。因此,想象一下我想模拟办公室用电量随时间变化的情况。假设只有照明和供暖。
class Simulation {
    public:
        Simulation(Time const& t, double lightMaxPower, double heatingMaxPower)
            : time(t)
            , light(&time,lightMaxPower) 
            , heating(&time,heatingMaxPower) {}

    private:
        Time time; // Note : stack-allocated
        Light light;
        Heating heating;
};

class Light {
    public:
        Light(Time const* time, double lightMaxPower)
            : timePtr(time)
            , lightMaxPower(lightMaxPower) {}

        bool isOn() const {
            if (timePtr->isNight()) {
                return true;
            } else {
                return false;
            }
        }
        double power() const {
            if (isOn()) {
                return lightMaxPower;
            } else {
                return 0.;
            }
    private:
        Time const* timePtr; // Note : non-owning pointer
        double lightMaxPower;
};

// Same kind of stuff for Heating

重点如下:
1. 时间不能被移动到数据成员LightHeating,因为它的变化并不来自这些类中的任何一个。
2. 时间不必作为参数明确传递给Light。实际上,在程序的任何部分都可以有对Light的引用,而不想将时间作为参数提供。
class SimulationBuilder {
    public:
        Simulation build() {
            Time time("2015/01/01-12:34:56");
            double lightMaxPower = 42.;
            double heatingMaxPower = 43.;
            return Simulation(time,lightMaxPower,heatingMaxPower);
        }
};

int main() {
    SimulationBuilder builder;
    auto simulation = builder.build();

    WeaklyRelatedPartOfTheProgram lightConsumptionReport;

    lightConsumptionReport.editReport((simulation.getLight())); // No need to supply Time information 

    return 0;
}

现在,只要不进行复制/移动构造,Simulation是完全OK的。因为如果这样做了,Light也会被复制/移动构造,并且默认情况下,指向Time的指针将指向从中复制/移动的旧Simulation实例中的Time。然而,在SimulationBuilder :: build()的return语句之间和main()中的对象创建之间,Simulation实际上被复制/移动构造了。
现在有许多解决问题的方法:
1:依靠复制省略。在这种情况下(在我的真实代码中也是如此),标准似乎允许复制省略。但是不是必需的,事实上,clang-O3没有省略。更确切地说,clang省略了Simulation的复制,但确实调用了Light的移动构造函数。还请注意,依赖于实现相关的时间不够健壮。
2:在Simulation中定义一个移动构造函数:
Simulation::Simulation(Simulation&& old) 
    : time(old.time)
    , light(old.light)
    , heating(old.heating)
{
    light.resetTimePtr(&time);
    heating.resetTimePtr(&time);
}

Light::resetTimePtr(Time const* t) {
    timePtr = t;
}

这确实可以工作,但是这里的大问题是它削弱了封装性:现在 Simulation 需要知道 Light 在移动过程中需要更多信息。在这个简化的示例中,这并不太糟糕,但是想象一下如果 timePtr 不直接在 Light 中,而是在其子成员的子成员中,则我需要编写

Simulation::Simulation(Simulation&& old) 
    : time(old.time)
    , subStruct(old.subStruct)
{
    subStruct.getSubMember().getSubMember().getSubMember().resetTimePtr(&time);
}

这完全破坏了封装和Demeter法则。即使在委托函数时,我也觉得它很可怕。

3:使用某种观察者模式,其中TimeLight观察,并在其被复制/移动构造时发送消息,以便Light在接收到消息时更改其指针。 我必须承认,我懒得写一个完整的例子,但我认为它会非常繁重,我不确定增加的复杂性是否值得。

4:在Simulation中使用一个拥有指针:

class Simulation {
    private:
        std::unique_ptr<Time> const time; // Note : heap-allocated
};

现在,当移动Simulation时,Time内存并没有移动,因此Light中的指针未被废弃。实际上,几乎所有其他面向对象的语言都是这样做的,因为所有对象都是在堆上创建的。
目前,我倾向于使用这种解决方案,但仍然认为它不完美:堆分配可能会很慢,但更重要的是它似乎并不符合惯用法。我听说B. Stroustrup说过,当不需要指针时就不应该使用指针,而所需的基本上是多态的。
5: 以原地构建的方式构造Simulation,而不是通过SimulationBuilder返回(然后可以删除Simulation中的复制/移动构造函数/赋值)。例如。
class Simulation {
    public:
        Simulation(SimulationBuilder const& builder) {
            builder.build(*this);
        }

    private:
        Time time; // Note : stack-allocated
        Light light;
        Heating heating;
        ...
};



class SimulationBuilder {
    public:
        void build(Simulation& simulation) {

            simulation.time("2015/01/01-12:34:56");
            simulation.lightMaxPower = 42.;
            simulation.heatingMaxPower = 43.;
    }
};

现在我的问题如下:
1: 你会使用什么解决方案?还有其他的想法吗?
2: 你认为原始设计有什么问题吗?你会怎样修复它?
3: 你是否曾经遇到过这种模式?我发现在我的代码中它相当普遍。不过,总的来说,这并不是一个问题,因为Time确实是多态的,因此可以在堆上分配。
4: 回到问题的根源,“没有必要移动,我只想在原地创建一个不可移动的对象,但编译器不允许我这样做”,为什么C++中没有简单的解决方案?其他语言中是否有解决方案?
4个回答

2
如果所有的类都需要访问相同的常量(因此是不可变的),那么您至少有两个选项可以使代码更加清晰和可维护:
  1. 存储 SharedFeature 的副本而不是引用——如果 SharedFeature 既小又无状态,这是合理的。
  2. 存储一个 std::shared_ptr<const SharedFeature> 而不是对 const 的引用——这适用于所有情况,几乎没有额外的开销。std::shared_ptr 当然是完全支持移动的。

不幸的是,SharedFeature在运行时是可变的(即使在MySubStruct中的引用是const)。您使用共享指针的解决方案类似于我的解决方案#4:还不错,但仍然不完全令人满意,因为没有必要进行堆分配。 - Bérenger
如果你使用no-op deleter构造shared_ptr,它可以很好地容纳一个堆栈分配的对象。请参见2个参数的构造函数。 - Richard Hodges
我不确定我理解了。所以我在MyClass中保留一个常规的SharedFeature,并在MySubStructMyOtherSubStruct中创建一个指向MyClass中的featurestd::shared_ptr<const SharedFeature>和无操作删除器?但是当移动MyClass时,feature也会被移动,我不知道这些shared_ptrs如何知道它的新地址。 - Bérenger
我觉得为了更进一步,我们需要讨论一下您真正想要实现什么目标。可能会有更简单的方法。 - Richard Hodges
如果您仍然感兴趣,我已经用更具体的示例重新表述了问题。 - Bérenger

1

1:您会使用哪种解决方案?您考虑过其他的解决方案吗?

为什么不应用一些设计模式呢?我认为在您的解决方案中可以使用工厂模式和单例模式。可能还有其他几种模式可以使用,但是我更有经验在模拟中应用工厂模式。

  • 将模拟转换为单例模式。

SimulationBuilder 中的 build() 函数移动到 Simulation 中。 Simulation 的构造函数被私有化,主要调用变成 Simulation * builder = Simulation::build();。同时,Simulation 还得到一个新变量 static Simulation * _Instance;,并对 Simulation::build() 进行了一些修改。

class Simulation
{
public:
static Simulation * build()
{
    // Note: If you don't need a singleton, just create and return a pointer here.
    if(_Instance == nullptr)
    {
        Time time("2015/01/01-12:34:56");
        double lightMaxPower = 42.;
        double heatingMaxPower = 43.;
        _Instance = new Simulation(time, lightMaxPower, heatingMaxPower);
    }

    return _Instance;
}
private:
    static Simulation * _Instance;
}

Simulation * Simulation::_Instance = nullptr;
  • Light和Heating对象作为工厂提供。

如果你只在模拟中有2个对象,那么这个想法是毫无价值的。但是,如果你将管理1...N个对象和多个类型,则我强烈建议您使用工厂和动态列表(向量、双端队列等)。您需要让Light和Heating继承自一个公共模板,设置好将这些类与工厂进行注册的事情,并将工厂设为模板化,工厂的实例只能创建特定模板的对象,并为Simulation对象初始化工厂。基本上,工厂看起来应该像这样:

template<class T>
class Factory
{
    // I made this a singleton so you don't have to worry about 
    // which instance of the factory creates which product.
    static std::shared_ptr<Factory<T>> _Instance;
    // This map just needs a key, and a pointer to a constructor function.
    std::map<std::string, std::function< T * (void)>> m_Objects;

public:
    ~Factory() {}

    static std::shared_ptr<Factory<T>> CreateFactory()
    {
        // Hey create a singleton factory here. Good Luck.
        return _Instance;
    }

    // This will register a predefined function definition.
    template<typename P>
    void Register(std::string name)
    {
        m_Objects[name] = [](void) -> P * return new P(); };
    }

    // This could be tweaked to register an unknown function definition,
    void Register(std::string name, std::function<T * (void)> constructor)
    {
        m_Objects[name] = constructor;
    }

    std::shared_ptr<T> GetProduct(std::string name)
    {            
        auto it = m_Objects.find(name);
        if(it != m_Objects.end())
        {
            return std::shared_ptr<T>(it->second());
        }

        return nullptr;
    }
}

// We need to instantiate the singleton instance for this type.
template<class T>
std::shared_ptr<Factory<T>> Factory<T>::_Instance = nullptr;

这可能看起来有点奇怪,但它确实使创建模板对象变得有趣。您可以通过执行以下操作注册它们:

// To load a product we would call it like this:
pFactory.get()->Register<Light>("Light");
pFactory.get()->Register<Heating>("Heating");

当你需要获取一个对象时,你只需要:

std::shared_ptr<Light> light = pFactory.get()->GetProduct("Light");

2:您认为原始设计有什么问题吗?您会怎样解决它?

是的,我确实认为有问题,但不幸的是,我无法从我的第一条回答中详细阐述。

如果我要修复任何东西,我会通过查看分析会话来开始“修复”。如果我担心诸如分配内存时间之类的问题,则分析是获得有关分配所需时间的准确想法的最佳方法。当您不重用已知的配置文件化实现时,所有理论都不能弥补分析的不足。

此外,如果我真的担心诸如内存分配速度之类的速度问题,那么我会考虑来自我的分析运行的事情,例如创建对象的次数与对象的生命周期,希望我的分析会话告诉我这一点。像您的Simulation类这样的对象应该在给定的模拟运行中最多创建1次,而像Light这样的对象可能会在运行期间创建0..N次。因此,我会关注创建Light对象对我的性能产生的影响。


3: 你是否曾经遇到过这种模式?我在我的代码中发现它相当普遍。不过,一般来说,这并不是问题,因为时间确实是多态的,因此被分配到堆上。

通常情况下,我不会看到仿真对象维护一种方式来查看当前状态变量(如 Time)。我通常会看到一个对象维护其状态,并且仅在通过函数(例如 SetState(Time & t){...})进行时间更改时更新。如果您考虑一下,这似乎很有道理。仿真是一种查看给定特定参数下对象变化的方法,而参数不应该是报告其状态所必需的。因此,一个对象应该只通过单个函数进行更新,并在函数调用之间维护其状态。

// This little snippet is to give you an example of how update the state.
// I guess you could also do a publish subscribe for the SetState function.
class Light
{
public:
    Light(double maxPower) 
        : currPower(0.0)
        , maxPower(maxPower)
    {}

    void SetState(const Time & t)
    {
        currentPower = t.isNight() ? maxPower : 0.0;
    }

    double GetCurrentPower() const
    {
        return currentPower;
    }
private:
    double currentPower;
    double maxPower;
}

防止对象对时间进行自检有助于缓解多线程压力,例如“当时间更改并在我读取时间后但在返回状态之前使其失效时,我如何处理开/关状态的情况?”


如果您只想创建一个对象,可以使用单例设计模式。当正确实现时,单例会保证即使在多线程情况下也只创建1个对象实例。回到问题的根本,即“我不需要移动,我只想在原地创建一个不可移动的对象,但编译器不允许我这样做”,为什么C++中没有简单的解决方案,其他语言中有没有解决方案?

感谢您提供这么详细的答案。1:一个全局对象(通过单例安全处理)确实解决了问题。但是如果我只有一个Simulation,那么它可以工作,但情况并非如此。Factory不是真正适应这种情况,因为LightHeating没有共同的完整接口。最终,我发现代码比我的第4个建议更复杂,没有明显的优势。 - Bérenger
你在第三个讨论中的观点很有趣,可以通过我在提议3中提到的观察者模式进一步解决:时间拥有一组观察它的对象,当其状态发生变化时,会调用observingObject.SetState(*this)。 - Bérenger
(我认为这实际上是您发布/订阅的内容) - Bérenger
1
所以我找到了一个关于指针所有权的问题的答案,这似乎就是你所说的:http://programmers.stackexchange.com/a/133303/158369,这让我意识到我在心理上一直把指针当作唯一指针来处理。这减轻了我的代码在决定谁负责清理指针以及何时清理指针时可能遇到的问题。此外,如果我关心堆栈与堆,我实际上是关注实时响应性,并根据跨进程和线程的最佳响应来做出决策。 - v4n3ck
1
是的,这就是我所说的链接。在所有权方面,我倾向于遵循严格的规则:除非存在多态行为,否则不使用指针,并且每个指针都应该由一个对象拥有,因此使用unique pointers。在我的领域中,我从未发现自己处于需要共享所有权的情况下:直到现在,哪个对象拥有哪个对象仅具有非所有权引用总是很清楚的,包括这个“Simulation”示例。 - Bérenger
显示剩余6条评论

1
在您的第二个解决方案的评论中,您说它削弱了封装性,因为Simulation必须知道Light在移动期间需要更多信息。我认为应该相反。Light需要知道它正在被用于一个上下文中,在这个上下文中提供的对Time对象的引用可能在Light的生命周期内变得无效。这并不好,因为它迫使基于使用方式而非基于其应该执行的操作来设计Light
传递引用会在两个对象之间创建(或应该创建)一个合同。当将引用传递给函数时,该引用应该在被调用的函数返回之前有效。当将引用传递给对象构造函数时,该引用应该在构建对象的整个生命周期内有效。传递引用的对象负责其有效性。如果不遵循此规则,则可能会在引用的用户和维护所引用对象寿命的实体之间创建非常难以跟踪的关系。在您的示例中,当Simulation移动时,无法维护其与创建的Light对象之间的合同。由于Light对象的生命周期与Simulation对象的生命周期紧密耦合,因此有三种解决方法:

1)采用您的第二种解决方案;

2)将对Time对象的引用传递给Simulation的构造函数。如果您假定Simulation与传递引用的外部实体之间的合同是可靠的,则SimulationLight之间的合同也将是可靠的。但是,您可以将Time对象视为Simulation对象的内部详细信息,因此会破坏封装性。

3) 使 Simulation 不可移动。由于 C++(11/14) 没有任何“原地构造函数”(不知道这个术语好不好),您无法通过从某些函数返回它来创建原地对象。复制/移动省略目前是一种优化,而不是一种功能。为此,您可以使用解决方案 5)或使用 lambda 表达式,如下所示:

class SimulationBuilder {
    public:
        template< typename SimOp >
        void withNewSimulation(const SimOp& simOp) {
            Time time("2015/01/01-12:34:56");
            double lightMaxPower = 42.;
            double heatingMaxPower = 43.;
            Simulation simulation(time,lightMaxPower,heatingMaxPower);
            simOp( simulation );
        }
};

int main() {
    SimulationBuilder builder;

    builder.withNewSimulation([] (Simulation& simulation) {

        WeaklyRelatedPartOfTheProgram lightConsumptionReport;

        lightConsumptionReport.editReport((simulation.getLight())); // No need to supply Time information
    } 

    return 0;
}

如果没有符合您需求的选项,那么您需要重新评估您的需求(这也可能是一个好选择),或在某处使用堆分配和指针。

是的,那是一个正确的观察。解决方案2)只是将问题上升了一个层次。然而,根据情况,可能更容易在那里解决问题。 - Dalibor Frivaldsky
不是我的情况。Simulation是我的顶层结构。它基本上包含了我运行模拟所需的所有输入数据结构和它们之间的关系。您可以将其视为有向无环图。如果我移动图形,一些关系(即指向堆栈分配对象的指针)会丢失,因为它们仍然指向先前的位置。有趣的是,当使用SimulationBuilder::build时,我并不真正想移动这个图形,但还是这样做了。 - Bérenger
FYI,在大约20000行代码中,指向堆栈分配对象的指针出现了3次。 - Bérenger
无论使用什么语言都无法完成。因为指针是图的一部分,你可以选择:1)在图移动后更新指针;2)将图分成可移动/不可移动部分,使指针仅指向不可移动部分(堆内存为不可移动部分——请注意,“不可移动”并非指C++中的含义);3)在新位置从头开始重建图。 - Dalibor Frivaldsky
我也这么觉得。我真的不想移动这张图,但语言几乎迫使我这样做。你认为从设计角度来看,“主输入图形结构”是否可以,还是有其他替代方案? - Bérenger
显示剩余6条评论

1

编辑:由于类的命名和排序,我完全忽略了你的两个类没有关联的事实。

帮助你理解抽象概念“特征”真的很困难,但是我会在这里完全改变我的思路。我建议将特征的所有权移入MySubStruct中。现在复制和移动将正常工作,因为只有MySubStruct知道它并且能够进行正确的复制。现在MyClass需要能够操作特征。所以,在需要时只需向MySubStruct添加委托:subStruct.do_something_with_feature(params);

如果您的特征需要来自子结构和MyClass的数据成员,则我认为您错误地分配了职责,并需要重新考虑从MyClassMySubStruct的分裂开始。

基于MySubStructMyClass的子级的假设的原始答案:

我认为正确的答案是从子类中删除featurePtr,并在父类中提供一个适当的受保护接口以访问特征(注意:这里我真的是指抽象接口,而不仅仅是一个get_feature()函数)。然后父类就不必知道子类的存在,而子类可以根据需要操作特征。
要完全清楚: MySubStruct甚至不会知道父类有一个名为feature的成员变量。例如,可能像这样:

为确保我理解你的意思:每当我在MySubStruct中操作feature时;不是从MyClass中调用带有feature作为数据成员的MySubStruct.do(),而是通过将其作为参数传递来调用MySubStruct.do(feature) - Bérenger
我明白,但问题在于 feature 不能成为 MySubStruct 成员,因为它可能也被 MySubStruct2MySubStruct3 等指向(请参见第一个代码块后面的句子)。 - Bérenger
我添加了一条注释,以明确MySubStruct与MyClass之间没有继承关系。 - Bérenger
我在末尾添加了一个具体的例子。 - Bérenger

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