3
\$\begingroup\$

I got inspired by this blog post and implemented a fixed-size sparse set, removing the need for vectors, because the sparse set's size equals at least sizeof(value_type * Size), it should be allocated on the heap when using a large set.

The idea is to be enable for the creation of contiguous sets inside a container that's allocated on the heap to try and increase cache hits.

I did not add iterators because I am not sure how it would work on such container...

#pragma once

////////////////////////////////////////////////////////////////////////////////
// Includes
////////////////////////////////////////////////////////////////////////////////
#include <cstring>
#include <cstdint>
#include <array>
#include <memory>
#include <type_traits>

////////////////////////////////////////////////////////////////////////////////
// Class declarations
////////////////////////////////////////////////////////////////////////////////
template<typename Type, uint32_t Size>
class sparse_set {
public:
    using value_type = Type;
    using size_type = decltype(Size);

    constexpr inline sparse_set() noexcept;
    inline ~sparse_set() noexcept(std::is_nothrow_invocable_v<decltype(&sparse_set::clear), sparse_set>);

    /** @return The maximum number of elements that can be inserted in the set*/
    constexpr inline size_type max_size() const noexcept;
    /** @return The number of elements contained in the set */
    constexpr inline size_type size() const noexcept;
    /** @return true if the set contains no element */
    constexpr inline bool empty() const noexcept;
    /** @return true if the number of elements in the set equals max_size() */
    constexpr inline bool full() const noexcept;
    /** @brief empties the set */
    constexpr inline void clear()
        noexcept(std::is_nothrow_invocable_v<decltype(&sparse_set::erase), sparse_set, size_type > );

    /** @return the element contained at this index */
    constexpr inline value_type& at(size_type a_Index);
    /** @return the element contained at this index */
    constexpr inline const value_type& at(size_type a_Index) const;

    /**
    * @brief Inserts a new element at the specified index,
    * replaces the current element if it already exists
    * @return the newly created element
    */
    template<typename ...Args>
    constexpr inline value_type& insert(size_type a_Index, Args&&... a_Args)
        noexcept(std::is_nothrow_constructible_v<value_type, Args...> && std::is_nothrow_destructible_v<value_type>);
    /** @brief Removes the element at the specified index */
    constexpr inline void erase(size_type a_Index)
        noexcept(std::is_nothrow_destructible_v<value_type>);
    /** @return true if a value is attached to this index */
    constexpr inline bool contains(size_type a_Index) const;

private:
#pragma warning(push)
#pragma warning(disable : 26495) //variables are left uninitialized on purpose
    struct storage {
        size_type                       sparseIndex;
        alignas(value_type) std::byte   data[sizeof(value_type)];
        operator value_type& () { return *(value_type*)data; }
    };
#pragma warning(pop)
    size_type _size{ 0 };
    std::array<size_type, Size> _sparse;
    std::array<storage, Size>   _dense;
};

template<typename Type, uint32_t Size>
inline constexpr sparse_set<Type, Size>::sparse_set() noexcept {
    _sparse.fill(max_size());
}

template<typename Type, uint32_t Size>
inline sparse_set<Type, Size>::~sparse_set()
     noexcept(std::is_nothrow_invocable_v<decltype(&sparse_set::clear), sparse_set>)
{
    clear();
}

template<typename Type, uint32_t Size>
inline constexpr auto sparse_set<Type, Size>::max_size() const noexcept -> size_type {
    return Size;
}

template<typename Type, uint32_t Size>
inline constexpr auto sparse_set<Type, Size>::size() const noexcept -> size_type {
    return _size;
}

template<typename Type, uint32_t Size>
inline constexpr bool sparse_set<Type, Size>::empty() const noexcept {
    return _size == 0;
}

template<typename Type, uint32_t Size>
inline constexpr bool sparse_set<Type, Size>::full() const noexcept {
    return _size == max_size();
}

template<typename Type, uint32_t Size>
inline constexpr void sparse_set<Type, Size>::clear()
    noexcept(std::is_nothrow_invocable_v<decltype(&sparse_set::erase), sparse_set, size_type >)
{
    for (size_type index = 0; !empty(); ++index) {
        erase(index);
    }
}

template<typename Type, uint32_t Size>
inline constexpr auto sparse_set<Type, Size>::at(size_type a_Index) -> value_type& {
    return _dense.at(_sparse.at(a_Index));
}

template<typename Type, uint32_t Size>
inline constexpr auto sparse_set<Type, Size>::at(size_type a_Index) const -> const value_type& {
    return _dense.at(_sparse.at(a_Index));
}

template<typename Type, uint32_t Size>
template<typename ...Args>
inline constexpr auto sparse_set<Type, Size>::insert(size_type a_Index, Args && ...a_Args)
    noexcept(std::is_nothrow_constructible_v<value_type, Args...> && std::is_nothrow_destructible_v<value_type>) -> value_type&
{
    if (contains(a_Index)) //just replace the element
    {
        auto& dense = _dense.at(_sparse.at(a_Index));
        std::destroy_at((value_type*)dense.data);
        new(&dense.data) value_type(std::forward<Args>(a_Args)...);
        return (value_type&)dense;
    }
    //Push new element back
    auto& dense = _dense.at(_size);
    new(&dense.data) value_type(std::forward<Args>(a_Args)...);
    dense.sparseIndex = a_Index;
    _size++;
    _sparse.at(a_Index) = _size - 1;
    return (value_type&)dense;
}

template<typename Type, uint32_t Size>
inline constexpr void sparse_set<Type, Size>::erase(size_type a_Index)
    noexcept(std::is_nothrow_destructible_v<value_type>)
{
    if (empty() || !contains(a_Index)) return;
    auto& currDense = _dense.at(_sparse.at(a_Index));
    auto& lastDense = _dense.at(_size - 1);
    size_type lastIndex = lastDense.sparseIndex;
    std::destroy_at((value_type*)currDense.data); //call current data's destructor
    std::memmove(currDense.data, lastDense.data, sizeof(value_type)); //crush current data with last data
    std::swap(lastDense.sparseIndex, currDense.sparseIndex);
    std::swap(_sparse.at(lastIndex), _sparse.at(a_Index));
    _sparse.at(a_Index) = max_size();
    _size--;
}

template<typename Type, uint32_t Size>
inline constexpr bool sparse_set<Type, Size>::contains(size_type a_Index) const {
    return _sparse.at(a_Index) != max_size();
}

Here is the gist

\$\endgroup\$
6
  • \$\begingroup\$ @G.Sliepen that's strange for memmove, it compiles on MSVC 2022... As for is_nothrow_invocable_v, it's working too, but maybe it's a C++20 feature and I missed it (my project is compiled in 20 and I assumed this class was only using C++17) [ETA] the project is compiled in C++17, I don't get why it's not working for you. \$\endgroup\$
    – Tab
    Commented Dec 4, 2022 at 18:05
  • \$\begingroup\$ My bad, relative to memmove, it works for me because cstring is included before I instanciate the class, fixing the code RN. The error you're getting with is_nothrow_invocable_v stays a mistery though. \$\endgroup\$
    – Tab
    Commented Dec 4, 2022 at 18:12
  • \$\begingroup\$ @G.Sliepen Thanks, I'll check with godbolt, in the meantime I edited the code to take your issues into account, does it compile on your end ? [ETA] I just checked and it is compiling on godbolt, will edit the gist with test code \$\endgroup\$
    – Tab
    Commented Dec 4, 2022 at 18:37
  • \$\begingroup\$ @G.Sliepen I updated the gist and added test code and example CMakeLists on it, does it compile on your side ? \$\endgroup\$
    – Tab
    Commented Dec 4, 2022 at 18:47
  • 2
    \$\begingroup\$ Please do not edit the question, especially the code, after an answer has been posted. Changing the question may cause answer invalidation. Everyone needs to be able to see what the reviewer was referring to. What to do after the question has been answered. \$\endgroup\$
    – pacmaninbw
    Commented Dec 5, 2022 at 2:35

1 Answer 1

1
\$\begingroup\$

At a high level this looks quite well thought out. I like that you took care to make inline, constexpr and nothrow.

I like the fact that you took care of defining conditional noexcept for the destructor, erase and clear. Although your approach to using the function you are using as the determinant for the noexcept is generally a good idea. It is debatable if it might be cleared to understand to make all three functions noexcept dependent on the destructor of Type.

To me using inline and constexpr together is a smell. Either some function is clearly constexpr, then inline is meaningless or it is not and then it is questionable if inline may help. In this vein, especially erase and insert pop out as functions that probably should not be inlined. For a first implementation, unless I need constepr I would leave it be and see if it is needed.

As we are on decorators, you could add the [[nodiscard]] on the functions where discarding the result makes no sense, like max_size, size and at.

Since you provided an at function; for symmetry purposes I would add an operator []. It could either call at or just use _dense[_sparse[a_Index]].

In this regard, you are using at a lot. As a general rule of thumb, if you know for certain that you have a valid index into an array or vector, you probably should use the subscript operator.

Considering this line in insert:


new(&dense.data) value_type(std::forward<Args>(a_Args)...);

Althoug I don't 100% understand when it really is needed and when not, but I think this line needs to pass though std::launder. See What is the purpose of std::launder? for a discussion on the issue.

Considering this line in erase:

if (empty() || !contains(a_Index)) return;

I would replace this check with an assert. Following the STL understanding, of don't call erase with invalid iterators semantic. This code probably just masks bugs in the calling code.

Considering erase in general:

Why are you enforcing compacting of the set? Now, I get this makes inserting quicker, but it also invalidates any references outside of the set. This feels like unnecessary burden on the client code. It certainly needs to be documented and communicated that erase will invalidate any of your references.

\$\endgroup\$
7
  • 2
    \$\begingroup\$ About std::launder(): you are right that something has to be done here; returning (value_type&)dense is bad because it's a C-style cast. Instead, the return value of the placement-new operator is a valid pointer to a Type, and that is what should be returned from insert(), and then there is no need for std::launder(). \$\endgroup\$
    – G. Sliepen
    Commented Dec 4, 2022 at 21:29
  • \$\begingroup\$ For inline constexpr you're right, constexpr implies inline, new(&dense.data) is a typo, it makes no sense will fixe it. I am not familiar with launder, how should I use it there, any example ? As for the asserts, stl don't throw when erasing on empty sets... The compacting is explained in this blog post, but you're right I should document it. \$\endgroup\$
    – Tab
    Commented Dec 4, 2022 at 23:25
  • 3
    \$\begingroup\$ @Tab std::launder() is necessary when you have a pointer that the compiler doesn't know points to a live object, but you know and want to tell the compiler that, so it doesn't assume UB. See C++ object and reference lifetime. Again, make use of the return value of the new operator to avoid needing to cast and to use std::launder(). \$\endgroup\$
    – G. Sliepen
    Commented Dec 5, 2022 at 0:08
  • \$\begingroup\$ @G.Sliepen thank you, that's very interesting. I updated the Gist and code in my post to take your feedback into account, hope I did not miss anything as I'll be away for a few days. In the meantime don't hesitate if you have other remarks :) \$\endgroup\$
    – Tab
    Commented Dec 5, 2022 at 0:17
  • 1
    \$\begingroup\$ @Tab You still have a cast when insert() replaces an existing element. Be consistent and always use the return value from the new operator. \$\endgroup\$
    – G. Sliepen
    Commented Dec 5, 2022 at 7:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.