1

Иногда возникает ситуация, когда один объект должен являться элементом одновременно в нескольких двусвязных списках (например, это может быть объект окна или виджета в каком-нибудь оконном интерфейсе, которой одновременно может входить в список сортировки по Z для упорядоченной отрисовки, список группы виджетов для последовательного переключения между ними клавишей TAB, список дочерних окон родительского окна, и т.д...). Можно для этой задачи, конечно, использовать стандартные в C++ списки std::list с указателями или ссылками на соответствующие элементы. Однако в этом случае реализация окажется не очень эффективной, т.к. для добавления ссылки в каждый список будет происходить дополнительное выделение памяти под соответствующую запись списка, это помимо выделения памяти под сам объект. А хотелось бы, чтобы оператор new вызывался только один раз - для создания самого объекта, который бы потом добавлялся в списки с помощью простого связывания указателей.

В общем, моя идея в том, чтобы связывать элементы с помощью записей, которые являются полями самого класса-элемента. Списки этих записей классически закольцованы, т.е. указатель на следующую запись последнего элемента и указатель на предыдущую запись первого элемента указывают на сам класс-список. А класс-список, в свою очередь, "знает" смещение своих записей в классе-элементе, благодаря чему способен конвертировать указатель на запись в указатель на элемент и обратно путём вычитания или добавления смещения поля записи. Для облегчения понимания мною написанного привожу схему данной идеи:

                                                  +---------+    +---------+           +---------+
                                                  | ELEMENT |    | ELEMENT |           | ELEMENT |
                                                  |   ...   |    |   ...   |           |   ...   |
                                                  |         |    |         |           |         |
                              --> +-------+ ----> |+-------+| -> |+-------+| ->     -> |+-------+| -->
       +--- ...from last NODE1    | LIST1 |       || NODE1 ||    || NODE1 ||    ...    || NODE1 ||    to LIST1... ---+
       |                      <-- +-------+ <---- |+-------+| <- |+-------+| <-     <- |+-------+| <--               |
       |                                          |         |    |         |           |         |                   |
       |                      --> +-------+ ----> |+-------+| -> |+-------+| ->     -> |+-------+| -->               |
   +-->+    ...from last NODE2    | LIST2 |       || NODE2 ||    || NODE2 ||    ...    || NODE2 ||    to LIST2...    +-->+
   |   |                      <-- +-------+ <---- |+-------+| <- |+-------+| <-     <- |+-------+| <--               |   |
   |   |                             ...          |   ...   |    |   ...   |           |   ...   |                   |   |
   |   |                                          |         |    |         |           |         |                   |   |
   |   |                      --> +-------+ ----> |+-------+| -> |+-------+| ->     -> |+-------+| -->               |   |
   |   +--- ...from last NODEn    | LISTn |       || NODEn ||    || NODEn ||    ...    || NODEn ||    to LISTn... ---+   |
   |                          <-- +-------+ <---- |+-------+| <- |+-------+| <-     <- |+-------+| <--                   |
   |                                              +---------+    +---------+           +---------+                       |
   |                                                                                                                     |
   |                                                                                                                     |
   +---------------------------------------------------------------------------------------------------------------------+

Однако на пути реализации этой идеи стоит такая мерзкая штука, как strict aliasing, которую мне надо умудриться не нарушить, чтобы не получилось вот так, как в прошлый раз!

В правилах, если я правильно понял, есть два ключевых момента: 1) разрешается приводить указатель на что угодно к char* или void*, и char* или void* к указателю на что угодно; 2) разрешается приводить указатель на класс-родитель к указателю на класс-потомок и, соответственно, наоборот. Основываясь на них, я написал следующий код - основу в первом приближении для реализации таких "множественных" списков:

/*
 * File: List.h
 */

#ifndef List_h__included__ #define List_h__included__

#include <assert.h> #include <stdexcept>

#ifndef INLINE #define INLINE attribute ((always_inline)) inline #endif

class CListNode;

/*

  • Base class for both: lists and nodes

*/

class CListBase { protected: union { CListNode* m_pNext; CListNode* m_pFirst; }; union { CListNode* m_pPrev; CListNode* m_pLast; };

public: INLINE CListBase() {} INLINE CListBase(CListNode* pNext, CListNode* pPrev): m_pNext(pNext), m_pPrev(pPrev) {} };

/*

  • The list node that's a field of the list element data class

*/

class CListNode: public CListBase { friend class CListCommon;

public: INLINE bool dbg_is_linked() const { #ifndef NDEBUG return m_pNext != NULL; #else throw std::runtime_error("The debug check function was called not in the debug mode!"); #endif }

INLINE void dbg_unlink()
{

#ifndef NDEBUG m_pNext = NULL; #endif }

INLINE CListNode()                          {dbg_unlink();}
INLINE CListNode&amp; operator =(CListNode&amp; r)  {dbg_unlink(); return *this;} 
INLINE CListNode(CListNode&amp;)                {dbg_unlink();} 
INLINE ~CListNode()                         {assert (!dbg_is_linked());}

INLINE CListNode* GetNext() const           {return m_pNext;}
INLINE CListNode* GetPrev() const           {return m_pPrev;}

INLINE void InsertBefore(CListNode* pNode)
{
    assert (!dbg_is_linked());
    assert (pNode &amp;&amp; pNode-&gt;dbg_is_linked());
    CListNode* pPrev = pNode-&gt;m_pPrev;
    m_pNext = pNode;
    m_pPrev = pPrev;
    pPrev-&gt;m_pNext = this;
    pNode-&gt;m_pPrev = this;
}

INLINE void InsertAfter(CListNode* pNode)
{
    assert (!dbg_is_linked());
    assert (pNode &amp;&amp; pNode-&gt;dbg_is_linked());
    CListNode* pNext = pNode-&gt;m_pNext;
    m_pNext = pNext;
    m_pPrev = pNode;
    pNode-&gt;m_pNext = this;
    pNext-&gt;m_pPrev = this;
}

INLINE void CutSelf()
{
    assert (dbg_is_linked());
    m_pNext-&gt;m_pPrev = m_pPrev;
    m_pPrev-&gt;m_pNext = m_pNext;
    dbg_unlink();
}

};

/*

  • The list common class that implements the list's basical functionality

*/

class CListCommon: public CListBase { public: INLINE CListCommon(): CListBase((CListNode)this, (CListNode)this) {} INLINE ~CListCommon() {assert(IsEmpty());}

INLINE void dbg_force_empty()
{

#ifndef NDEBUG m_pFirst = (CListNode)this; //m_pLast = (CListNode)this; #endif }

INLINE void dbg_unlink_all()
{

#ifndef NDEBUG CListNode* pNode = m_pFirst; while (!IsEnd(pNode)) { CListNode* pNext = pNode->m_pNext; pNode->dbg_unlink(); pNode = pNext; } dbg_force_empty(); #endif }

INLINE bool IsEmpty() const                 {return m_pFirst == (CListNode*)this;}
INLINE bool IsEnd(CListNode* pNode) const   {return pNode == (CListNode*)this;}

INLINE CListNode* GetFirst() const          {return m_pFirst;}
INLINE CListNode* GetLast() const           {return m_pLast;}

INLINE void Append(CListNode* pNode)
{
    assert (pNode &amp;&amp; !pNode-&gt;dbg_is_linked());
    pNode-&gt;m_pNext = (CListNode*)this;
    pNode-&gt;m_pPrev = m_pLast;
    m_pLast-&gt;m_pNext = pNode;
    m_pLast = pNode;
}

};

/*

  • The final data-based list templated class

*/

template <class TData, CListNode TData::nodeField> class CList: public CListCommon { public: INLINE constexpr static size_t GetOffset() {return (char)&(((TData)NULL)->nodeField) - (char)NULL;} INLINE static CListNode GetNode(TData* pData) {return (CListNode)((char)pData + GetOffset());} INLINE static TData* GetData(CListNode* pNode) {return (TData)((char)pNode - GetOffset());}

INLINE void Append(TData* pData)                {CListCommon::Append(GetNode(pData));}

};

//macro for convenient declaration of the list variable #define LIST_TYPE(TData, nodeField) CList<TData, &TData::nodeField>

#endif //List_h__included__

А вот программа для тестирования/демонстрации того, как работает идея:

/*
 * File: main.cpp
 */

#include <stdio.h> #include <string> #include "List.h"

/*

  • The test data class for testing the lists' functionality

*/

class CTestData { std::string m_strName; int m_num;

public: CListNode node1; CListNode node2;

public: INLINE CTestData(const char* pcszName, int num): m_strName(pcszName), m_num(num) {}

void print()
{
    printf(&quot;(\&quot;%s\&quot;:%d)&quot;, m_strName.c_str(), m_num);
}

};

/*

  • Templated function: prints the list content

*/

template <class TList> attribute((noinline,noclone)) void print_list(TList& rList, const char* pcszPromt) { printf("%s: ", pcszPromt);

for (CListNode* pNode = rList.GetFirst(); !rList.IsEnd(pNode); pNode = pNode-&gt;GetNext())
{
    rList.GetData(pNode)-&gt;print();
    printf(&quot; &quot;);
}

puts(&quot;&quot;);

}

/*

  • main: testing the lists

*/

int main() { //tested elements CTestData t1("A",1), t2("B",2), t3("C",3);

//tested lists
LIST_TYPE(CTestData, node1) list1;
LIST_TYPE(CTestData, node2) list2;

//linking list1
list1.Append(&amp;t1);
list1.Append(&amp;t2);
list1.Append(&amp;t3);

//linking list2
list2.Append(&amp;t3);
list2.Append(&amp;t2);
list2.Append(&amp;t1);

print_list(list1, &quot;List1&quot;);
print_list(list2, &quot;List2&quot;);
puts(&quot;&quot;);

printf(&quot;Something insert and cut...\n\n&quot;);

//new tested elements
CTestData t4(&quot;ab&quot;,12), t5(&quot;D&quot;,5);

t4.node1.InsertBefore(&amp;t2.node1); //insert t4 before t2 in the list1
t2.node2.CutSelf();               //cut t2 from the list2
t5.node2.InsertAfter(&amp;t1.node2);  //insert t5 after t1 in the list2

print_list(list1, &quot;List1&quot;);
print_list(list2, &quot;List2&quot;);
puts(&quot;&quot;);

//unlink all nodes in the debug mode (to avoid asserts)
list1.dbg_unlink_all();
list2.dbg_unlink_all();
return 0;

}

А вот результат программы, который говорит о том, что всё вроде работает:

List1:  ("A":1) ("B":2) ("C":3)
List2:  ("C":3) ("B":2) ("A":1)

Something insert and cut...

List1: ("A":1) ("ab":12) ("B":2) ("C":3) List2: ("C":3) ("A":1) ("D":5)

В общем, если я правильно понял (в общих чертах) strict aliasing, то мой код их вроде бы не нарушает, и его со спокойной душой можно развивать и использовать. Или нарушает? Меня всё ещё не покидают сомнения, не закралось ли тут где неопределённое поведение? Не подставит ли мне оптимизатор подношку однажды снова? Насколько надёжна такая реализация с точки зрения компиляции и оптимизации?

LShadow77
  • 2,157
  • "идея в том, чтобы связывать элементы с помощью записей, которые являются полями самого класса-элемента" - это называется интрузивные контейнеры, и уже давно реализовано, например в boost.intrusive. "если я правильно понял, есть два ключевых момента..." - описываемое далее вообще мимо strict aliasing. В реализации творится какая-то вакханалия, например (char*)&(((TData*)NULL)->*nodeField) - (char*)NULL - мало того, что NULL и с-style касты, но тут сходу разыменовывается нулевой указатель с последующей гарантированно невалидной операцией разности. – user7860670 Dec 27 '23 at 16:51
  • @user7860670 почему вакханалия? NULL я использую по привычке и лени, потому что это короче std::nullptr. Далее, он тут не разыменовывается в том контексте, что по нему не происходит обращение. Его тип void* приводится к другим типам, что вроде как не запрещено. И с каких пор разность указателей - невалидная операция? Может где-то написано, что NULL или nulptr запрещено приводить к другим типам указателей? – LShadow77 Dec 27 '23 at 17:00
  • "по привычке и лени, потому что это короче" - без комментариев... std::nullptr - нет же такого в языке, есть ключевое слово nullptr (который не эквивалентен NULL) и тип std::nullptr_t. "он тут не разыменовывается" - нет, нулевой указатель разыменовывается сразу после каста ((TData*)NULL)->*. Поведение разности указателей определено только при соблюдении некоторых условий, произвольно что-то кастовать и вычитать не получится. – user7860670 Dec 27 '23 at 17:13
  • user7860670 да, с nulptr_t я спутал чутка. Ну хорошо, какие ещё есть способы получить смещение поля класса? Безопасные и не вызывающие предупреждения компилятора? – LShadow77 Dec 27 '23 at 17:19
  • https://en.cppreference.com/w/cpp/types/offsetof Хотя и не универсальный. Тот же буст для такого использует ABI-специфическую магию. – user7860670 Dec 27 '23 at 17:31
  • Вот эта реализация широко используется в Линуксе (в частности, в kernel) – avp Dec 27 '23 at 18:09
  • В своих программах я обычно использую ту же идею (указатели на указатели), но более ограниченный набор макросов -- init_head, is_empty_list, add, remove, insert_list, container_of – avp Dec 27 '23 at 18:19
  • @avp спасибо, изучу как-нибудь на досуге. Может почерпну новых идей. – LShadow77 Dec 30 '23 at 15:52
  • @user7860670 стандартный макрос offsetof, к сожалению, не работает с полями-"указателями", которые передаются шаблону в качестве параметра. Во всяком случае, у меня не получилось его использовать. – LShadow77 Dec 30 '23 at 16:04

1 Answers1

0

И так, самым критикуемым местом в моей реализации оказался метод GetOffset(), который возвращает смещение поля узла списка в классе. Потому, что в его теле присутствует разыменование указателя nullptr, что хоть и работает, но с точки зрения стандарта приводит к неопределённому поведению. Что ж, вот моя попытка избавиться от разыменования nullptr в этом методе:

template <class TData, CListNode TData::*nodeField>
class CList: public CListCommon
{
public:
    //redesigned method without using of nullptr
    INLINE constexpr static size_t GetOffset()
    {
        char tmp[sizeof(TData)] = {0,}; //"initialize" to exclude another supposed formal UB
        return (char*)&(((TData*)tmp)->*nodeField) - tmp;
    }
INLINE static CListNode* GetNode(TData* pData)  {return (CListNode*)((char*)pData + GetOffset());}
INLINE static TData* GetData(CListNode* pNode)  {return (TData*)((char*)pNode - GetOffset());}

INLINE void Append(TData* pData)                {CListCommon::Append(GetNode(pData));}

};

Результат получился тот же самый. Массив tmp оказался выкинут и метод GetOffset() посчитан на этапе компиляции/оптимизации - что и требовалось.

Буду благодарен за очередную порцию критики, а так же за освещение других вероятных проблем в коде...

LShadow77
  • 2,157
  • Ну, концептуально проблема никуда не делась. Та сущность, на которую указывает указатель (TData*)tmp, не является живым объектом типа TData (как и в случае с нулевым указателем). Рассмотрим чуть более простой пример. Пусть есть такой код: ((TData*)tmp)->field_name; (т.е. просто обращаемся к полю класса по имени, но не читаем и не пишем значение, даже адрес не берём). Этот код можно переписать так: reinterpret_cast<TData&>(tmp).field_name; Можно ли обращаться к полю field_name, если объект по адресу tmp отличен от Tdata? Нельзя, см. expr.ref/8. – wololo Jan 01 '24 at 17:03
  • Особенно обратите на пример кода по ссылке, особенно на строку reinterpret_cast<B&>(d).j; // undefined behavior. Аналогичная ситуация с указателем-на-член. См. expr.mptr.oper/4. Там нет примера кода, но сама формулировка аналогичная. P.S. 1) Не следует использовать двойные подчёркивания в идентификаторах. lex.name/3.1. 2) Возможно, лучше для буфера использовать unsigned char, т.к. для него стандарт даёт больше гарантий. 3) Следует соблюдать требования по выравниванию: alignas(TData) unsigned char tmp...;. – wololo Jan 01 '24 at 17:04
  • @wololo понятно. Но тогда получается, что вариант с разыменованием nullptr погоды не сделает, раз хоть так UB, хоть так. А вот про двойные подчёркивания я вообще в недоумении с разработчиков стандарта. Ладно, если бы идентификаторы только с начальными двойными подчёркиваниями зарезервировали. Но чтобы вообще запретить их использовать... – LShadow77 Jan 01 '24 at 18:24