在std::unordered_map中使用具有多态性的类的std::unique_ptr作为键

3
我的问题源于我需要完成的一个项目。我需要创建一个std::unordered_map<T, unsigned int>,其中T是指向基类的多态指针。经过一段时间的探索后,我发现将std::unique_ptr<T>作为键也是一个很好的做法,因为我的map是用来拥有这些对象的。让我介绍一些背景信息:
考虑一个继承自多态基类sell_obj 的类层次结构。 其中book table 继承自该类。 现在我们知道我们需要创建一个std::unordered_map<std::unique_ptr<sell_obj*>, unsigned int>。 因此,从该映射中删除一对将自动释放由键指向的内存。 整个想法是让键指向书籍/桌子,并且这些键的值将代表商店拥有的该产品的数量。
由于我们使用的是std::unordered_map,我们应该为所有三个类指定哈希。 为了简化事情,我在 main 中指定了它们:
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是可以的。是什么导致了崩溃?调试器除了崩溃发生外,没有提供任何信息。
如果需要更多关于项目的信息,请指出。谢谢阅读。

1
你有两行代码都写着“I'm going to own s_o now.”当s_o是同一个对象时,或者如果其中一个被两次调用,它们就不是非常独特的。考虑到你的函数无法判断它是否唯一,你应该重新思考API设计。 - chris
1
考虑使用shared_ptr而不是unique_ptr - 1201ProgramAlarm
1
sell_obj中实现hash作为虚函数(在派生类中重写),以消除那个dynamic_cast - 1201ProgramAlarm
我的表述像个白痴,抱歉。我再次阅读了我的评论并感到尴尬。我的想法是:我们在商店里有一个储藏室,其中使用unique_ptr作为键,它们的值作为数量。现在我们希望用户指定一个新对象,并调用add或remove(以该对象作为参数)。储藏室中的指针不应指向该对象。每次添加新对象(映射大小增加)时,指针都应创建一个相同的新对象,然后指向它。 - Fureeish
1
根据今天的讨论,我做了这个链接。也许你可以将其完善为你所需的完整实现。如果你对这些内容有任何问题,请随时问我。 - Corristo
显示剩余19条评论
1个回答

0

这个问题的逻辑存在一些问题。首先:

考虑一个具有多态性的类层次结构,其中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所建议的那样。

[...] implementation of two, crucial functions:

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引用接受参数表明修改对象。其次,从使用&在参数上获得的指针创建n_ptr是极其危险的。它假设对象在堆上分配并且未拥有。这种情况通常不会发生,而且非常危险。如果传递的对象在堆栈上或已由其他所有者管理,则这是灾难的配方(如段错误)。

更重要的是,它几乎肯定会导致灾难,因为两个add_sell_obj()remove_sell_obj()都创建了std::unique_ptr指向可能相同的对象。这正是原始问题中main()的情况。两个指向同一对象的std::unique_ptr会导致double delete


虽然使用C++(相对于Java)并不一定是解决此问题的最佳方法,但有一些有趣的工具可用于此任务。以下代码假定使用C++20。

类层次结构

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

struct sell_object { };

然后我们需要引入代表具体对象的类:

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)。

存储

shop类将代表任何sell_object的存储,需要以某种方式存储任何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*的哈希策略。问题是我们想要自定义哈希。因此,我选择了与问题中提出的方法不同的方法。我将选择一个自定义哈希对象和一个自定义比较器:

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

因此,我们还需要更改我们的booktable类:

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`会产生强烈的排序(总排序,其中两个等价对象无法区分),但我们还需要支持将`book`与`table`进行比较-这有点毫无意义(始终返回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;
        }
    }
};

overrideoperator<=> 是由于基类的要求而必需的。它们非常简单 - 如果other对象(我们正在将this对象与之进行比较的对象)是相同类型的,则委托给使用具体类型的 <=> 版本。如果不是,我们就有了类型不匹配并报告 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>)。幸运的是,透明哈希和比较器来拯救我们了。

通过为哈希器和比较器提供额外的重载(并提供一个公共的 is_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;
};

请查看具有示例和附加打印实用程序的完整实现


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