如何实现避免未定义行为的侵入式链表?

Posted

技术标签:

【中文标题】如何实现避免未定义行为的侵入式链表?【英文标题】:How to implement an intrusive linked list that avoids undefined behavior? 【发布时间】:2015-12-07 13:35:48 【问题描述】:

几年来我第三次发现自己需要一个侵入式链表来处理一个不允许提升的项目(询问管理层......)。

我第三次发现我拥有的侵入式链表实现完美运行,但我真的不喜欢它使用未定义的行为 - 即将指向列表节点的指针转换为指向包含该列表节点的对象。

那个糟糕的代码目前看起来像这样:

struct IntrusiveListNode 
    IntrusiveListNode * next_;
    IntrusiveListNode * prev_;
;

template <typename T, IntrusiveListNode T::*member>
class IntrusiveList 
// snip ...
private:
    T & nodeToItem_(IntrusiveListNode & node) 
        return *(T*)(((char*)&node)-((size_t)&(((T*)nullptr)->*member)));
    

    IntrusiveListNode root_;
;

我真的不在乎nodeToItem_ 变得多么丑陋,但我想保持IntrusiveList 的公共接口和语法相同。具体来说,我想使用IntrusiveList&lt;Test, &amp;Test::node_&gt;而不是IntrusiveList&lt;Test, offsetof(Test, node_)&gt;来指定列表类型的类型。

快 2016 年了 - 有没有办法在不调用未定义行为的情况下做到这一点?


编辑: cmets中有一些建议的解决方案(涉及列表的不同结构),我想在这里总结一下:

    存在未定义的行为,因为该语言似乎具有任意限制,阻止反向使用成员指针。

    IntrusiveListNode 中存储一个指向包含类的附加指针。这可能是目前最干净的解决方案(无需更改接口),但确实需要在每个列表节点中添加第三个指针(可能进行小的优化)。

    IntrusiveListNode 派生并使用static_cast。在 boost 中,这是一个侵入式链表的base_hook 版本。我想坚持使用member_hook 版本以避免引入多重继承。

    存储指向下一个和上一个包含类的指针,而不是指向IntrusiveListNode 中的下一个和上一个列表节点。这使得在侵入列表中创建根节点变得困难。列表必须包含T 的完整实例化(这是不可能的,例如,如果T 是抽象的),或者列表的末尾需要是一个空指针(这会破坏--list.end(),允许向前迭代仅)。

    Boost 侵入式列表有一个 member_hook 版本,它以某种方式工作,但尚未理解其实现(它也可能依赖于未定义的行为)。

问题仍然存在:是否可以创建一个具有双向迭代支持、没有未定义行为和“不必要”内存开销的侵入式基于成员的列表?

【问题讨论】:

我会先尝试修复管理。 :-D 无法解析您的退货声明。检查github.com/arun11299/Cpp-Intrusive-list/blob/master/… 是否有帮助。我很久以前就实施了。不知道我是在什么时候离开的,但基本的东西应该可以工作。 @ddriver 在运行时没有动态分配是使用侵入式链表的最大驱动力。再加上对象将同时位于多个列表中的事实,尽管这当然可以使用指针列表来解决。 @Arunmu 您的实现要求要放置在列表中的项目必须派生自ListNode。这仅允许将项目添加到单个列表中。我需要该节点作为侵入式链表的成员变体。 我想我没有看到它但是...:你不能在列表节点信息中使用T*s 并避免需要根据成员确定外部节点吗?据我所知,C++ 标准中没有办法基于成员获取包含对象(offsetof() 之类的东西当然不适用于非标准布局类型)。 【参考方案1】:

我会回避这个问题并使用包含合适的node&lt;T&gt; 成员链接范围。应对双向、侵入性 列表我会像这样使用不对称的node&lt;T&gt;

template <typename T>
class intrusive::node

    template <typename S, node<S> S::*> friend class intrusive::list;
    template <typename S, node<S> S::*> friend class intrusive::iterator;

    T*       next;
    node<T>* prev;
public:
    node(): next(), prev() 
    node(node const&) 
    void operator=(node const&) 
;

基本思想是 list&lt;T, L&gt; 包含一个 node&lt;T&gt; 使用 next 指向第一个元素的指针。这是相当的 直截了当:将指针 p 指向 T 指向下一个的链接 可以使用(p-&gt;*L).next 遍历节点。然而,而不是 使用T* 直接导航列表,实际上是iterator&lt;T, L&gt; 使用指向 node&lt;T&gt;: 的指针,而这对于 前向遍历,它支持后向遍历和插入 列表中的任何位置,无需对列表头进行特殊处理。

复制构造函数和复制赋值被定义为什么都不做 在复制节点时避免半插入节点。取决于 节点的需求可能更合理,而不是= delete 这些操作。但是,这与手头的问题无关。

迭代器只使用指向node&lt;T&gt; 的指针,其next 当前节点的成员点。对于第一个元素 列出这是指向list&lt;T, L&gt;node&lt;T&gt; 成员的指针。 假设您有一个指向合适的 node&lt;T&gt; 的指针,则可以从中创建 iterator<T, L>

template <typename T, intrusive::node<T> T::*Link>
class intrusive::iterator

    template <typename S, node<S> S::*> friend class intrusive::list;
    node<T>* current;

public:
    explicit iterator(node<T>* current): current(current) 
    T& operator*()  return *this->operator->(); 
    T* operator->()  return this->current->next; 
    bool operator== (iterator const& other) const 
        return this->current == other.current;
    
    bool operator!= (iterator const& other) const 
        return !(*this == other);
    
    iterator& operator++() 
        this->current = &(this->current->next->*Link);
        return *this;
    
    iterator operator++(int) 
        iterator rc(*this);
        this->operator++();
        return rc;
    
    iterator& operator--() 
        this->current = this->current->prev;
        return *this;
    
    iterator operator--(int) 
        iterator rc(*this);
        this->operator--();
        return rc;
    
;

取消引用只使用next 指针。对于 使用next 指针和 获取下一个node&lt;T&gt; 的地址的成员指针。 由于迭代器的prev 已经向后指向node&lt;T&gt; 迭代只需要将当前的node&lt;T&gt; 替换为 prev 元素。

最后,这留下了一个维护开头和结尾的列表 的名单。处理双向访问和相应的 访问最后一个节点会增加一些复杂性,并且需要 实际上有一个专用节点。这是一个实现(其中 没有经过彻底测试:我可能弄乱了一些链接):

template <typename T, intrusive::node<T> T::*Link>
class intrusive::list

    node<T> content;

public:
    list()  this->content.prev = &this->content; 
    iterator<T, Link> begin()  return iterator<T, Link>(&this->content); 
    iterator<T, Link> end()  return iterator<T, Link>(this->content.prev); 

    T& front()  return *this->content.next; 
    T& back()  return *(this->content.prev->prev->next); 
    bool empty() const  return &this->content == this->content.prev; 
    void push_back(T& node)  this->insert(this->end(), node); 
    void push_front(T& node)  this->insert(this->begin(), node); 
    void insert(iterator<T, Link> pos, T& node) 
        (node.*Link).next = pos.current->next;
        ((node.*Link).next
         ? (pos.current->next->*Link).prev 
         : this->content.prev) = &(node.*Link);
        (node.*Link).prev = pos.current;
        pos.current->next = &node;
    
    iterator<T, Link> erase(iterator<T, Link> it) 
        it.current->next = (it.current->next->*Link).next;
        (it.current->next
         ? (it.current->next->*Link).prev
         : this->content.prev) = it.current;
        return iterator<T, Link>(&(it.current->next->*Link));
    
;

只是为了有点理智:这是一个简单地打印列表的函数:

template <typename T, intrusive::node<T> T::*Link>
std::ostream& intrusive::operator<< (std::ostream& out, intrusive::list<T, Link>& list)

    out << "[";
    if (!list.empty()) 
        std::copy(list.begin(), --list.end(), std::ostream_iterator<T>(out, ", "));
        out << list.back();
    
    return out << "]";

几乎没有其他方法可以避免做任何时髦的事情 访问封闭类。以上避免了几个条件。 假设我设法设置适当的链接更正代码 不会依赖任何实现定义或未定义的行为。

你会像这样使用列表:

class Node 
public:
    intrusive::node<Node> link0;
    intrusive::node<Node> link1;
    int                   n;
    Node(int n): n(n) 
;
std::ostream& operator<< (std::ostream& out, Node const& n) 
    return out << n.n;


int main()

    intrusive::list<Node, &Node::link0> l0;
    intrusive::list<Node, &Node::link1> l1;

    Node n[] =  10, 11, 12, 13, 14, 15 ;

    l0.push_front(n[0]);
    l0.push_front(n[1]);
    l0.push_front(n[2]);

    l1.push_back(n[0]);
    l1.push_back(n[1]);
    l1.push_back(n[2]);

    std::cout << "l0=" << l0 << " l1=" << l1 << "\n";

【讨论】:

太棒了,这正是我所希望的答案!我注意到erase 方法中的一个小错误,它当前在擦除节点之后返回一个迭代器,而不是直接在擦除节点之后(返回的迭代器的current 应该指向被擦除节点之前的节点,这样current-&gt;next 将在删除后返回节点)。一个简单的return it; 就可以了。【参考方案2】:

问题仍然存在:是否可以创建一个具有双向迭代支持、没有未定义行为和“不必要”内存开销的侵入式基于成员的列表?

您要做的是获取 C++ 对象的非静态数据成员,并将其转换为指向其包含类的指针。为此,您必须对表单进行一些操作:

node_ptr *ptr = ...;
auto p = reinterpret_cast<char*>(ptr) + offset;
T *t = reinterpret_cast<T*>(p);

要使此操作合法 C++,您需要明确定义以下所有内容:

    获取从节点的特定 NSDM 到包含它的 T 的字节偏移量。 将该偏移量应用于指向成员的指针将产生一个可合法转换为其所属类型T 的指针值。

第 1 项只能通过 offsetof 在定义明确的 C++ 中实现;该标准提供没有其他方法来计算该偏移量。而offsetof 要求类型(在本例中为T)为standard layout。

当然,offsetof 需要成员的名称作为参数。而且你不能通过模板参数等传递参数名称;你必须通过宏来完成。除非您愿意强制用户以特定方式命名成员。

因此有您的限制:T 必须是标准布局,并且您必须使用宏而不是直接函数调用,或者您必须强制用户为成员使用特定名称。如果你这样做,你应该是安全的,根据 C++。

代码如下所示:

struct intrusive_list_node

  intrusive_list_node *next;
  intrusive_list_node *prev;

  template<typename T, size_t offset> T *convert()
  
    auto p = reinterpret_cast<char*>(this); //Legal conversion, preserves address.
    p -= offset; //Legal offset, so long as `offset` is correct
    return reinterpret_cast<T*>(p); //`p` has the same value representation as `T*` did originally, so should be legal.
  


#define CONVERT_FROM_MEMBER(node, T, member_name) node->convert<T, offsetof(T, member_name)>()

【讨论】:

有点离题,但我想知道不支持所有类型的offsetof 的论据到底是什么。我的意思是编译器显然知道确切的对象二进制布局。 Boost 当前执行 OP 为 GNU、Intel 等所做的工作。对于 MSVC,它遵循 ABI 以获得正确的偏移量。查找“offset_from_pointer_to_member”的函数。 也许对标准 ABI 的需求才是这个问题的真正答案:) @Arunmu - 这很好,但不太可能发生,出于某种原因,人们非常致力于支持具有早已灭绝的奇怪规则的异国平台。我本人目前正在实现一种编程语言,并且很高兴地将其“限制”为仅支持现有所有操作设备的 99.99999999999% :) @ddriver:offsetof 不允许用于“所有类型”的原因与一般情况下指向数据成员的指针远不止偏移量的原因相同:虚拟继承是邪恶的,会破坏很多东西。确切地说,当涉及到虚拟继承时,编译器不知道确切的对象布局,并且必须在运行时借助虚拟基子对象指针来计算它。我觉得“标准布局”是不必要的限制,但似乎没有“不涉及虚拟继承的类型”的术语。【参考方案3】:

如果您不介意更改 IntrusiveListNode 类型,您可以让节点包含指向上一个/下一个节点的句柄 - 您只需进行 node -&gt; handle 查找,而不是相反。

template<typename Node>
struct IntrusiveListHandle 
    Node *next = nullptr;
    // and Node* prev, etc ...
;

template<typename Node, IntrusiveListHandle<Node> Node::*handle>
struct IntrusiveList 
    Node *first;    

    static Node *next(Node *n) 
        auto h = (n->*handle).next;
    
;

使用示例:

#include <iostream>

struct Test 
    IntrusiveListHandle<Test> handle;
    std::string value;

    Test(const std::string &v): value(v) 
;

template<typename IntrusiveList>
void print(const IntrusiveList &list) 
    for (Test *n = list.first; n; n = list.next(n)) 
        std::cout << n->value << "\n";
    


int main() 
    Test hello("hello");    
    Test world("world!");
    hello.handle.next = &world;
    IntrusiveList<Test, &Test::handle> list;
    list.first = &hello;
    print(list);

您应该不惜一切代价避免未定义的行为,因为编译器在利用 UB 进行优化方面变得越来越聪明 - 现在可以正常工作的代码可能会在下一次编译器更新时突然中断。

我看到您提到了反向迭代。 --end() 不适用于此代码,但通常的方法是同时提供 begin()/end()rbegin()/rend() 对以允许反向迭代。

【讨论】:

【参考方案4】:

我认为您可以使用 CRTP 获得好处:

#include <iostream>
using namespace std;

template<typename T>
struct ListNode

    ListNode<T>* next;

    // this would be nodeToItem in the list class
    T* value()
    
        return static_cast<T*>(this);
    
;

// This would be your abstract base class
struct A: public ListNode<A>

    A(int i): x(i) 
    virtual ~A() = 0;
    int x;
;

inline A::~A() 

struct B: public A

    B(int i): A(i) 
    virtual ~B() 
;

template<typename T>
class IntrusiveList 
public:
IntrusiveList(ListNode<T>* ptr): root(ptr) 

    ptr->next = nullptr;


void append(ListNode<T>* ptr)

    ptr->next = root;
    root = ptr;


ListNode<T>* begin() return root;
private:
ListNode<T>* root;
;

int main() 
    B b(10);
    B b2(11);
    IntrusiveList<A> l(&b);
    l.append(&b2);

    for(ListNode<A>* n=l.begin(); n != nullptr; n = n->next)
    
         std::cout << n->value()->x << std::endl;
    
    return 0;

通过在结构中使用ListNode 指针数组,并将数组的索引作为模板参数或构造函数参数传递给列表类,应该可以在多个列表中包含元素。迭代器还需要将索引存储在 ListNode 数组中。

【讨论】:

问题中暗示了 OP 对该设计的反对意见,并在您发布该答案之前在 cmets 中得到了很好的解释。 这基本上是侵入式链表的base_hook版本,其中数据类派生自链表节点。我正在寻找一个给定 member_hook 的解决方案,其中列表节点是数据类的成员。此外,给定迭代,如您所示涉及 nullptr 结束,列表根和所有下一个指针可能只是 T*s。 @JSF 我还没有看到任何关于这种方法的讨论。这个问题只是说他想要一个没有 UB 的侵入性列表,我认为解决方案提供了这个。 cmets 然后讨论“偏移方法”的细节,但不讨论 CRTP。 我已经使用 CRTP 多年了,直到我知道它被称为那个。让节点成为 T 的基类(而不是成员)的讨论暗示了 CRTP,因为在 T 上模板化节点仍然是明确和必要的。在现有的关于在派生类上模板化的基类的讨论中,“CRTP "只是一个流行词,而不是一个新的答案。 @JSF:由于您使用 member_hook 的目标是“避免多重继承”,我想指出您也可以在此处稍作修改:template&lt;typename T, TBase&gt; struct ListNode : TBase 然后@987654327 @变成struct A : ListNode&lt;A, Base&gt;【参考方案5】:

如果不调用 UB,您几乎无法使用其中一个成员的指针获取原始对象。为什么你绝对不能?因为IntrusiveListNode 可以放在任何地方。没有任何线索表明特定的IntrusiveListNode 保存在T 中,另一个证明你不能这样做:编译器无法知道发送到你的函数的节点是否真的保存在T 中。您正在尝试做的是 未定义的行为。正确的做法是在IntrusiveListNode 中添加指向其容器的指针。

template<typename T>
struct IntrusiveListNode 
    IntrusiveListNode * next_;
    IntrusiveListNode * prev_;
    T* item_;
;

template <typename T, IntrusiveListNode<T> T::*member>
class IntrusiveList 
// snip ...
private:
    T & nodeToItem_(IntrusiveListNode<T> & node) 
        return *(node->item_);
    

    IntrusiveListNode<T> root_;
;

如果IntrusiveListNode不能使用模板,可以使用void*代替T*

你可以看到一个侵入式链表here的实现示例

【讨论】:

该问题并不要求证明IntrusiveListNode 确实是T 的成员,它假定它是,并且当且仅当它确实是时才定义行为的答案将是完全合适的。您的方法有效,但代价是增加了一个不需要的数据成员。此外,按照您的逻辑,这仍然完全被打破:您如何证明T 小心设置列表节点的item_ 正确?你不能。你假设班级正确地做到了这一点。这样的假设是完全有效的,但是当它们由 OP 做出时它们同样有效。 这段代码我可能是执行 OP 要求的最好的,摆脱未定义的行为。他没有在问题中要求IntrusiveListNode 的大小相同,只是公共界面相同。这就是为什么我建议将T* 更改为void* 以保持公共接口不变。否决票是不合理的。 引用 OP 的 cmets 之一,当认为其他人提出与您的回答相同的事情时:“是的,这是可能的,我已经考虑过了,但它确实增加了大小每个列表节点由另一个指针。”我确实相信 OP 正在寻找 IntrusiveListNode 而不是增加大小。 即使在编辑之后,您关于成员与外部类关系的基本观点将在基类和外部类之间应用完全相同的方式。在从基类到外部类的静态转换中,编译器必须相信程序员这个基类实际上是正确的外部类的一部分。编译器从基类导航到外部对象的工作与从成员导航到外部对象相同。问题似乎在于语言任意规则,而不是请求的操作有任何问题。 对不起,忘记改函数内容了。【参考方案6】:

使用模板很难做到。宏是可能的,因此所需的成员 _next、_prev 等在对象本身的范围内,而不是在单独的模板对象内。 使用宏可以避免每次都输入非常相似的代码。 事实上,我几年前创建了一个案例工具“ClassBuilder”(http://sourceforge.net/projects/classbuilder/),它使用宏编写代码来创建使用侵入式链接列表的数据结构。 在我工作的领域中,普通的模板化链表只是慢的方式。在我们的业务中,使用非常大的核心数据结构是很正常的,这些数据结构也非常动态。因此,在列表中进行了大量的删除、添加和搜索。 使用从实际实现中完全抽象出来的工具,您只需创建类图并从那里生成代码。 在我们做的一个相对简单的测试用例中,生成代码的运行时间性能是 40 秒和 400 秒,对于使用“普通”STL 类型实现的 C++ 解决方案。相同测试用例的 C# 实现在运行几个小时后中止。它的实现类似于 STL 之一,但是这个被垃圾收集器重创了。由于测试用例的动态行为,所有可以回收的内存只能在完整扫描中回收。

【讨论】:

以上是关于如何实现避免未定义行为的侵入式链表?的主要内容,如果未能解决你的问题,请参考以下文章

面试被问到侵入式链表,链表还分什么姿势?

侵入式单链表的简单实现(cont)

侵入式单链表的简单实现

如何重新定义内置键盘快捷键的行为?

如何在自定义链表实现中找到最常见的元素?

java如何实现链表