当一个对象保证生存时间超过其包含对象时,应如何存储该对象?

3
在一项项目中,我遇到了一个有趣的问题,即在通过构造函数将一个对象传递给另一个对象时,如果传递的对象保证比接收方对象更长寿(以内存生命周期为衡量标准),则会出现问题。请注意,我仍在学习C++11/C++14的内部细节,因此我希望进行有益的讨论,以帮助我理解C++11/C++14风格语义下的内存管理和生命周期。
本问题的设置如下:
class TopLevelClass {
public:
    void someMethod (int someValue) {
        // Do some work
    }

    std::unique_ptr<Context> getContext () {
        return std::make_unique<Context>(this);
    }
};

class Context {
public:
    Context (TopLevelClass* tlc) : _tlc(tlc) {}

    void call (int value) {
        // Perform some work and then call the top level class...
        _tlc->someMethod(value);
    }

protected:
    TopLevelClass* _tlc;
};

尽管将 TopLevelClass 作为参数传递给 Context 类的 call 方法是一种可行的解决方案,但在我所阐述的场景中不可能实现:能够访问到 Context 对象的客户端代码可能无法访问 TopLevelClass 对象。
虽然以上示例代码在功能上满足我的需求,但我感觉存在“代码异味”。具体来说,使用原始指针存储对 TopLevelClass 对象的引用不能表明 Context 类不负责管理此指针的生命周期(因为在这种情况下,TopLevelClass 肯定会比任何 Context 对象存在更长时间)。另外,在 C++11 中,我不愿意使用原始指针而不是智能指针(正如 Scott Meyer 在《Effective Modern C++》中建议的那样)。
我探索的一个替代方法是使用共享指针传递对 TopLevelClass 的引用,并将该引用存储在 Context 类中作为共享指针。需要注意的是,TopLevelClass 必须按照以下方式继承自 std::enable_shared_from_this
class TopLevelClass : public std::enable_shared_from_this<TopLevelClass> {
public:
    // Same "someMethod(int)" as before...

    std::unique_ptr<Context> getContext () {
        return std::make_unique<Context>(shared_from_this());
    }
};

class Context {
public:
    Context (std::shared_ptr<TopLevelClass> tlc) : _tlc(tlc) {}

    // Same "call(int)" as before...

protected:
    std::shared_ptr<TopLevelClass> _tlc;
};

这种方法的缺点是,除非事先存在一个TopLevelClassstd::shared_ptr,否则将抛出std::bad_weak_ptr异常(更多信息请参见此帖子)。由于在我的情况下,代码中没有创建std::shared_ptr<TopLevelClass>,所以我不能使用std::enable_shared_from_this<T>方法:我只能返回TopLevelClass的单个实例,使用static原始指针,按照我的项目要求进行操作,如下所示。
static TopLevelClass* getTopLevelClass () {
    return new TopLevelClass();
}

有没有一种方法可以表达这样一个事实,即Context不负责管理其对TopLevelClass实例的句柄,因为TopLevelClass将保证比任何Context对象都要长寿?如果能够避开这个问题而不过度复杂化上述设计的简洁性(即创建许多不同的类来绕过仅传递单个指针到Context构造函数的方式),我也很乐意听取建议。谢谢您的帮助。

3
改传递指针为传递并存储引用。 - clcto
3
getTopLevelClass() 是有害的。从函数签名中你可能会期望它返回一个单例,但实际上它每次调用都会返回一个拥有原始指针的新对象,调用者必须记得删除它!请注意,以下翻译仅供参考,具体翻译应根据上下文和语境确定。 - Chris Drew
1
这个库是否明确指定了函数的实现还是只有函数签名?否则使用TopLevelClass *getTopLevelClass() { static TopLevelClass tlc; return &tlc; } - villintehaspam
1
@unseenghost,这个库会在最后删除TopLevelClass吗?如果不会,那么你可以编写static TopLevelClass* getTopLevelClass(){ static TopLevelClass tlc; return &tlc;},仍然实现相同的接口而不拥有原始指针。 - Chris Drew
2
Scott Meyers并不是说永远不要使用原始指针。例如,这里有一段引用自《Effective Modern C++》第20条,第138页:从子节点到父节点的反向链接可以安全地实现为原始指针,因为子节点的生命周期不应该比其父节点更长。因此,不存在子节点解除引用悬空的父指针的风险。 - Chris Drew
显示剩余10条评论
2个回答

3
以您目前的方式传递原始指针,绝对意味着没有所有权被转移。
如果你听到有人说“不要使用原始指针”,那么你可能错过了一部分内容——应该是“不要使用拥有所有权的原始指针”,也就是说,不应该有一个需要调用delete的原始指针存在。除了可能在一些低级代码中。如果你确定所指向的对象的存活时间超过获取指针的对象,则仅仅传递指针是绝对没有问题的。
您说:“换句话说,将TopLevelClass对象的句柄存储为原始指针并不表明Context类不负责管理此指针的生命周期”。相反,存储原始指针确切地意味着“该对象不管理由该指针指向的对象的生命周期”。但在C++98风格的代码中,这并不一定意味着这一点。
使用引用是指针的替代方案。然而,这样做有一些注意事项,例如必须在构造函数中初始化它,并且它不能像指针一样设置为nullptr(这也可能是一件好事)。例如:
class TopLevelClass {
public:
    void someMethod (int someValue) {
        // Do some work
    }

    std::unique_ptr<Context> getContext () {
        return std::make_unique<Context>(*this);
    }
};

class Context {
public:
  Context(TopLevelClass &tlc) : _tlc(tlc) {}

  void call (int value) {
    // Perform some work and then call the top level class...
    _tlc.someMethod(value);
  }

private:
  TopLevelClass &_tlc;
};

以下是有关编程主题的一些文章:

C++ 核心准则:

https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#Rr-ptr

Herb Sutter的一些早期文章:

http://herbsutter.com/2013/05/29/gotw-89-solution-smart-pointers/

http://herbsutter.com/2013/05/30/gotw-90-solution-factories/

http://herbsutter.com/2013/06/05/gotw-91-solution-smart-pointer-parameters/

http://herbsutter.com/elements-of-modern-c-style/

可能还有很多来自CppCon、Cpp和Beyond的视频,但我有点懒得去谷歌找合适的。


谢谢。我同意原始指针并没有被禁止,但是我对语义有不同的看法。在我的例子中,我明确声明TopLevelClass对象将比Context对象存在更长的时间,但是这在代码中并没有明确说明(我区分了语言的语义和简单地写注释)。一般来说,原始指针语义存在许多问题:我是否负责删除对象?如果是,如何删除(使用delete还是delete[])?它会悬空吗?关于我的“不要使用原始指针”的评论,请参见此处,第4章。谢谢! - Justin Albano
1
@unseenghost,您不需要负责删除非拥有的原始指针。 - Chris Drew
我同意。我的意图是明确声明我不拥有指针。例如,原始指针不能像std::unique_ptr一样严格地传达唯一所有权。也就是说,我知道std::unique_ptr明确传达了唯一所有权。 - Justin Albano
1
@unseenghost:这正是我的观点。如果你有一个原始指针,那么它意味着“这是一个非拥有指针”。提供该指针给对象的人负责确保它不会悬空。如果您无法保证它不会超过对象的生命周期,则不能仅使用原始指针 - 但这是另一个话题,可能有点太大了。 - villintehaspam
1
不要只听我的话,可以听听Bjarne Stroustrup和Herb Sutters以及可能还有很多其他人的意见。我没有Scott Meyers的书,但我相信他在这个话题上也是同意的。 - villintehaspam
明白了,非常感谢你的帮助。我想知道是否有更明确的方法来声明意图。相信我,我从这些讨论中学到了很多。非常感激。 - Justin Albano

1

一种选择是使用类型定义来表达非所有权:

#include <memory>

template<typename T>
using borrowed_ptr = T *;

class TopLevelClass;

class Context {
public:
  Context(borrowed_ptr<TopLevelClass> tlc)
    : _tlc(std::move(tlc))
  { }

private:
  borrowed_ptr<TopLevelClass> _tlc;
};

class TopLevelClass {
public:
  std::unique_ptr<Context> getContext() {
    return std::make_unique<Context>(this);
  }
};

这清晰地表达了意图,尽管_tlc仍可直接转换为原始指针。我们可以创建一个名为borrowed_ptr的实际类(类似于shared_ptr),以更好地隐藏原始指针,但在这种情况下似乎有些过度设计。

我喜欢这种方法的简洁性,同时明确说明了非所有权意图。顺便问一下,为什么在实际上是原始指针(底层类型)的情况下使用了 std::move - Justin Albano
1
正常情况下(至少对于新的代码),应该假定原始指针意味着“非所有权”。相反,最好使用“owner<T>”标记任何拥有原始指针的方式,就像标记“borrowed_ptr<T>”一样(而且更容易,因为实例很少)。这也是Guidelines Support Library(https://github.com/Microsoft/GSL)采取的方法,在今年的CppCon上由Bjarne Stroustrup等人提出。 - villintehaspam
我知道有指南说原始指针应被视为非拥有指针,但我不同意这种观点。我们在编写代码时要尽可能表达清晰,但在这种情况下,我们却默认原始指针是非拥有指针。为什么不明确表达呢?我们可以同时拥有"owner<T>"(或"owned_ptr<T>")和"borrowed_ptr<T>",因此这似乎是一个我们可以两全其美的情况。另外,正如你所指出的,这种假设仅适用于新开发的代码。这排除了所有现有代码,这似乎并不理想。 - Chris Hayden
1
@unseenghost 在这种情况下,移动操作并不起作用,因为指针是原始类型。我只是养成了将非左值引用构造函数参数始终移动到适当位置的习惯。这样做没有任何害处,如果有人将成员/构造函数参数类型更改为非原始类型,可能会有所帮助。 - Chris Hayden
@Chris Hayden 听起来是个好主意。只是想确保我没有漏掉什么。感谢你的帮助! - Justin Albano
在Library Fundamentals TS v2中有一个observer_ptr,它是一个具有智能指针接口的非拥有指针。 - Ilya Popov

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