使用多态类的 std::unique_ptr 作为 std::unordered_map 中的键

Using std::unique_ptr of a polymorphic class as key in std::unordered_map

本文关键字:std unordered map 作为 unique 多态 ptr      更新时间:2023-10-16

我的问题来自一个我应该完成的项目。我必须创建一个std::unordered_map<T, unsigned int>其中T是指向基本多态类的指针。过了一会儿,我想使用std::unique_ptr<T>作为键也是一个很好的做法,因为我的地图是用来拥有对象的。让我介绍一些背景故事:

考虑将具有多态sell_obj的类层次结构视为基类。 从该类继承的书籍。我们现在知道我们需要创建一个std::unordered_map<std::unique_ptr<sell_obj*>, unsigned int>.因此,从该映射中删除一对将自动释放按键指向的内存。整个想法是让钥匙指向书籍/桌子,这些钥匙的价值将代表我们商店包含的产品数量。

当我们处理std::unordered_map时,我们应该为所有三个类指定哈希。为了简化事情,我在主要中指定了它们,如下所示:

namespace std{
template <> struct hash<book>{
size_t operator()(const book& b) const
{
return 1; // simplified
}
};
template <> struct hash<table>{
size_t operator()(const table& b) const
{
return 2; // simplified
}
};
// The standard provides a specilization so that std::hash<unique_ptr<T>> is the same as std::hash<T*>.
template <> struct hash<sell_obj*>{
size_t operator()(const sell_obj *s) const
{
const book *b_p = dynamic_cast<const book*>(s);
if(b_p != nullptr) return std::hash<book>()(*b_p);
else{
const table *t_p = static_cast<const table*>(s);
return std::hash<table>()(*t_p);
}
}
};
} 

现在让我们看一下地图的实现。我们有一个名为Shop的类,它看起来像这样:

#include "sell_obj.h"
#include "book.h"
#include "table.h"
#include <unordered_map>
#include <memory>
class Shop
{
public:
Shop();
void add_sell_obj(sell_obj&);
void remove_sell_obj(sell_obj&);
private:
std::unordered_map<std::unique_ptr<sell_obj>, unsigned int> storeroom;
};

以及实施两项关键功能:

void Shop::add_sell_obj(sell_obj& s_o)
{
std::unique_ptr<sell_obj> n_ptr(&s_o);
storeroom[std::move(n_ptr)]++;
}
void Shop::remove_sell_obj(sell_obj& s_o)
{
std::unique_ptr<sell_obj> n_ptr(&s_o);
auto target = storeroom.find(std::move(n_ptr));
if(target != storeroom.end() && target->second > 0) target->second--;
}

在我的主要情况下,我尝试运行以下代码:

int main()
{
book *b1 = new book("foo", "bar", 10);
sell_obj *ptr = b1;
Shop S_H;
S_H.add_sell_obj(*ptr); // works fine I guess
S_H.remove_sell_obj(*ptr); // usually (not always) crashes [SIGSEGV]
return 0;
}

我的问题是 - 我的逻辑在哪里失败?我听说从 C++11 开始在 STL 包含器中使用std::unique_ptr是可以的。是什么导致了崩溃?调试器不提供除崩溃发生之外的任何信息。

如果需要有关该项目的更多信息,请指出。感谢您的阅读

这个问题中的逻辑问题很多。首先:

考虑将多态sell_obj作为基类的类层次结构。booktable继承自该类。我们现在知道我们需要创建一个std::unordered_map<std::unique_ptr<sell_obj*>, unsigned int>.

在这种情况下std::unique_ptr<sell_obj*>这不是我们想要的。我们想要std::unique_ptr<sell_obj>.没有*.std::unique_ptr已经是"指针"。

当我们处理std::unordered_map时,我们应该为所有三个类指定哈希。为了简化事情,我主要指定了它们,如下所示:[...]

这也是一种非常不可取的方法。这需要每次我们在层次结构中添加另一个子类时更改代码的那部分。最好完全按照@1201programalarm的建议,以多态方式委派哈希(和比较)以避免此类问题。

[...]实施两个关键功能:

void Shop::add_sell_obj(sell_obj& s_o)
{
std::unique_ptr<sell_obj> n_ptr(&s_o);
storeroom[std::move(n_ptr)]++;
}
void Shop::remove_sell_obj(sell_obj& s_o)
{
std::unique_ptr<sell_obj> n_ptr(&s_o);
auto target = storeroom.find(std::move(n_ptr));
if(target != storeroom.end() && target->second > 0) target->second--;
}

出于几个原因,这是错误的。首先,通过非const引用进行论证表明对对象的修改。其次,从在argumnet上使用&获得的指针创建n_ptr是非常危险的。它假定对象是在堆上分配的,并且它是无主的。这种情况通常不应该发生并且非常危险。如果传递的对象在堆栈上和/或已经由其他所有者管理,这是灾难的秘诀(如段错误)。

更重要的是,它或多或少肯定会以灾难告终,因为add_sell_obj()remove_sell_obj()都会为潜在的同一对象创建std::unique_ptr。从原始问题的main()中可以看出,情况正是如此。指向同一对象的两个std::unique_ptr会导致双delete


虽然如果使用C++(与Java相比),它不一定是解决此问题的最佳方法,但有几个有趣的工具可用于此任务。下面的代码假定为 C++20。

类层次结构

首先,我们需要一个基类,在引用存储在商店中的所有对象时将使用它:

struct sell_object { };

然后我们需要引入表示conrete对象的类:

class book : public sell_object {
std::string title;
public:
book(std::string title) : title(std::move(title)) { }
};
class table : public sell_object {
int number_of_legs = 0;
public:
table(int number_of_legs) : number_of_legs(number_of_legs) { }
};

为了简单起见(但仍然有一些区别),我选择它们只有一个不同的字段(titlenumber_of_legs)。

存储

代表任何sell_object存储的shop类需要以某种方式存储任何sell_object。为此,我们需要使用指针或对基类的引用。不能有引用容器,因此最好使用指针。智能指针。

最初这个问题建议使用std::unordered_map。让我们坚持下去:

class shop {
std::unordered_map<
std::unique_ptr<sell_object>, int,
> storage;
public:
auto add(...) -> void {
...
}
auto remove(...) -> void {
...
}
};

值得一提的是,我们选择std::unique_ptr作为地图的关键。这意味着存储将复制传递的对象,并使用它拥有的副本与我们查询(添加或删除)的元素进行比较。但是,复制的对象不会超过一个相等。

存储的固定版本

但是,有一个问题。std::unordered_map使用哈希,我们需要为std::unique_ptr<sell_object>提供哈希策略。好吧,已经有一个了,它使用哈希策略进行T*.问题是我们希望有自定义哈希。这些特定的std::unique_ptr<sell_object>应根据关联的sell_object进行哈希处理

因此,我选择与问题中提出的方法不同的方法。我将选择一个自定义哈希对象和一个自定义比较器,而不是在std命名空间中提供全局专用化:

class shop {
struct sell_object_hash {
auto operator()(std::unique_ptr<sell_object> const& object) const -> std::size_t {
return object->hash();
}
};
struct sell_object_equal {
auto operator()(
std::unique_ptr<sell_object> const& lhs,
std::unique_ptr<sell_object> const& rhs
) const -> bool {
return (*lhs <=> *rhs) == 0;
}
};
std::unordered_map<
std::unique_ptr<sell_object>, int,
sell_object_hash, sell_object_equal
> storage;
public:
auto add(...) -> void {
...
}
auto remove(...) -> void {
...
}
};

请注意几件事。首先,storage的类型发生了变化。它不再是std::unordered_map<std::unique_ptr<T>, int>,而是std::unordered_map<std::unique_ptr<T>, int, sell_object_hash, sell_object_equal>。这是为了表明我们正在使用自定义哈希器(sell_object_hash)和自定义比较器(sell_object_equal)。

我们需要特别注意的几行是:

  • return object->hash();
  • return (*lhs <=> *rhs) == 0;

在他们身上:

return object->hash();

这是哈希的委派。我们不是作为一个观察者,并试图拥有一个类型,对于从sell_object派生的每个可能类型实现不同的哈希,我们要求这些对象自己提供足够的哈希。在最初的问题中,std::hash专业化是所说的"观察者"。它当然不能作为解决方案进行扩展。

为了实现上述目标,我们修改基类以施加列出的要求:

struct sell_object {
virtual auto hash() const -> std::size_t = 0;
};

因此,我们还需要更改book类和table类:

class book : public sell_object {
std::string title;
public:
book(std::string title) : title(std::move(title)) { }
auto hash() const -> std::size_t override {
return std::hash<std::string>()(title);
}
};
class table : public sell_object {
int number_of_legs = 0;
public:
table(int number_of_legs) : number_of_legs(number_of_legs) { }
auto hash() const -> std::size_t override {
return std::hash<int>()(number_of_legs);
}
};

return (*lhs <=> *rhs) == 0;

这是一个 C++20 特征,称为三向比较运算符,有时称为宇宙飞船运算符。我选择使用它,因为从 C++20 开始,大多数希望具有可比性的类型都将使用此运算符。这意味着我们还需要我们的具体类来实现它。更重要的是,我们需要能够使用基本引用(sell_object&)来调用它。另一个virtual函数(实际上是operator)需要添加到基类中:

struct sell_object {
virtual auto hash() const -> std::size_t = 0;
virtual auto operator<=>(sell_object const&) const -> std::partial_ordering = 0;
};

sell_object的每个子类都需要与其他sell_object进行比较。主要原因是我们需要在storage图中比较sell_object。为了完整起见,我使用了std::partial_ordering,因为我们要求每个sell_object都与其他sell_object进行比较。虽然比较两个book或两个table会产生强排序(两个等效对象无法区分的总排序),但我们还需要 - 通过设计 - 支持将booktable进行比较。这有点毫无意义(总是返回false)。幸运的是,C++20 帮助我们在这里std::partial_ordering::unordered.这些要素是不相等的,它们都不大于或小于另一个。 非常适合这种情况。

我们的具体类需要相应地改变:

class book : public sell_object {
std::string title;
public:
book(std::string title) : title(std::move(title)) { }
auto hash() const -> std::size_t override {
return std::hash<std::string>()(title);
}
auto operator<=>(book const& other) const {
return title <=> other.title;
};
auto operator<=>(sell_object const& other) const -> std::partial_ordering override {
if (auto book_ptr = dynamic_cast<book const*>(&other)) {
return *this <=> *book_ptr;
} else {
return std::partial_ordering::unordered;
}
}
};
class table : public sell_object {
int number_of_legs = 0;
public:
table(int number_of_legs) : number_of_legs(number_of_legs) { }
auto hash() const -> std::size_t override {
return std::hash<int>()(number_of_legs);
}
auto operator<=>(table const& other) const {
return number_of_legs <=> other.number_of_legs;
};
auto operator<=>(sell_object const& other) const -> std::partial_ordering override {
if (auto table_ptr = dynamic_cast<table const*>(&other)) {
return *this <=> *table_ptr;
} else {
return std::partial_ordering::unordered;
}
}
};

由于基类的要求,需要overridenoperator<=>s。它们非常简单 - 如果other对象(我们正在将此对象进行比较的对象)属于同一类型,我们将委托给使用具体类型的<=>版本。如果不是,则类型不匹配,我们报告unordered排序。

对于那些好奇为什么比较两个相同类型的<=>实现不= default的人:它将首先使用基类比较,这将委托给sell_object版本。这将再次dynamic_cast并委托给默认实现。这将比较基类和...导致无限递归。

add()remove()实施

一切似乎都很棒,因此我们可以继续在商店中添加和删除商品。但是,我们立即做出了艰难的设计决策。add()remove()应该接受哪些论点?

  • std::unique_ptr<sell_object>?这将使它们的实现变得微不足道,但它需要用户构造一个可能无用的动态分配对象来调用函数。

  • sell_object const&?这似乎是正确的,但它有两个问题:1)我们仍然需要用传递的参数的副本构造一个std::unique_ptr,以找到要删除的适当元素;2)我们无法正确实现add(),因为我们需要混凝土类型来构建要放入地图的实际std::unique_ptr

让我们使用第二个选项并解决第一个问题。我们当然不想仅仅为了在存储映射中寻找它构造一个无用且昂贵的对象。理想情况下,我们希望找到一个与传递的对象匹配的键(std::unique_ptr<sell_object>)。幸运的是,透明的哈希器和比较器来了。

通过为哈希器和比较器提供额外的重载(并提供publicis_transparent别名),我们允许查找等效的键,而无需匹配类型:

struct sell_object_hash {
auto operator()(std::unique_ptr<sell_object> const& object) const -> std::size_t {
return object->hash();
}
auto operator()(sell_object const& object) const -> std::size_t {
return object.hash();
}
using is_transparent = void;
};
struct sell_object_equal {
auto operator()(
std::unique_ptr<sell_object> const& lhs,
std::unique_ptr<sell_object> const& rhs
) const -> bool {
return (*lhs <=> *rhs) == 0;
}
auto operator()(
sell_object const& lhs,
std::unique_ptr<sell_object> const& rhs
) const -> bool {
return (lhs <=> *rhs) == 0;
}
auto operator()(
std::unique_ptr<sell_object> const& lhs,
sell_object const& rhs
) const -> bool {
return (*lhs <=> rhs) == 0;
}
using is_transparent = void;
};

多亏了这一点,我们现在可以实现如下shop::remove()

auto remove(sell_object const& to_remove) -> void {
if (auto it = storage.find(to_remove); it != storage.end()) {
it->second--;
if (it->second == 0) {
storage.erase(it);
}
}
}

由于我们的比较器和哈希器是透明的,我们可以find()一个等效于参数的元素。如果我们找到它,我们会减少相应的计数。如果达到0,我们将完全删除该条目。

太好了,进入第二个问题。让我们列出shop::add()的要求:

我们需要
  • 对象的具体类型(仅仅引用基类是不够的,因为我们需要创建匹配的std::unique_ptr)。
  • 我们需要该类型派生自sell_object.

我们可以通过约束*template实现两者:

template <std::derived_from<sell_object> T>
auto add(T const& to_add) -> void {
if (auto it = storage.find(to_add); it != storage.end()) {
it->second++;
} else {
storage[std::make_unique<T>(to_add)] = 1;
}
}

这又很简单

*参考资料: {1} {2}

正确的销毁语义

只有一件事将我们与正确的实施区分开来。事实上,如果我们有一个指向用于释放它的基类的指针(无论是否智能),析构函数必须是虚拟的。

这将我们引向sell_object类的最终版本:

struct sell_object {
virtual auto hash() const -> std::size_t = 0;
virtual auto operator<=>(sell_object const&) const -> std::partial_ordering = 0;
virtual ~sell_object() = default;
};

通过示例和其他打印实用程序查看完整实现。