C++ 中 STL 算法的透明索引

Transparent indexes for STL algorithms in C++

提问人:Damir Tenishev 提问时间:11/16/2023 最后编辑:Damir Tenishev 更新时间:11/18/2023 访问量:326

问:

我想使用 STL 算法简化索引的工作。具体来说,我有一个按顺序指向另一个容器的索引数组,以便索引相对于另一个容器中的相应值进行排序。

我想通过索引使数据工作对程序员透明。例如,我想使用 std::equal_range 在数据中查找使用索引的范围,而无需为此编写谓词。该谓词只有两个目的:存储数据容器和提供用于间接访问的代码。begin()

我觉得我正在重新发明自行车,STL 应该有一些东西可以解决这个任务,就像它有 std::less、std::greater、std::negate 等的谓词一样。我希望有人回答“以这种方式使用谓词 std::indirect_arraystd::indirect_binary_predicate”或类似的东西。我更喜欢适用于通常的 std::vector 容器的解决方案。

我想避免什么

如果我需要对索引数据使用 STL 算法,例如带有值的数组数据和带有索引的数组索引,该算法使用数据容器和索引来引用索引数据begin()

我更喜欢保留索引而不是指针/迭代器,主要是因为我想减少内存占用。对于 Winx64 平台,每个 int 索引需要 4 个字节,而指针需要 8 个字节。此外,索引的使用简化了数组的重新定位,适合使用数组结构 (SoA) 和其他一些操作。

问题分为两个:

  1. 索引的表示形式
  2. 为算法提供透明度

我真的无法将这两者解耦,如果有人可以,请提供方法。主要原因是,当我问容器在哪里以及如何存储时,人们会指出具有内部状态的谓词,这正是问题的第二部分。begin()

当我谈论透明度时,我的意思是我想使用像 std::equal_range 这样的算法来查找值中的范围,而不是在索引中,而是使用索引作为入口点。不幸的是,std::equal_range 对我的意图一无所知,只会比较索引。当然,我可以在比较谓词中处理这个问题,但这会迫使我写它,并在每个谓词中记住间接词。

节省内存,我必须偿还,保留在某个地方或提供容器。begin()

所以,最有可能的是,我必须将某个地方传递给算法,而最好的位置是谓词:begin()

struct Value {
    int data;
public:
    Value(int value) : data(value) {}
    operator int() const { return data; }

    bool operator < (const Value& rhs) const { return data < int(rhs); }
};

struct Index {
public:
    int index;
public:
    Index(int i) : index(i) {}
};

class Comparator {
    const std::vector<Value>& vec;
public:
    Comparator(const std::vector<Value>& vec) : vec(vec) {}

    bool operator () (const Value& lhs, const Index& rhs) const {
        return lhs < vec[rhs.index];
    }
    bool operator () (const Index& lhs, const Value& rhs) const {
        return vec[lhs.index] < rhs;
    }
};


int main()
{
    std::vector<Value> v1 = { 0,30,20,40,10 };
    std::vector<Index> i1 = { 0,4,2,1,3 };

    Value value(30);

    auto range_pred = std::equal_range(i1.begin(), i1.end(), value, Comparator(v1));

    std::cout << std::endl << "With predicate:" << std::endl;
    std::cout << "*range_pred.first = " << v1[(*range_pred.first).index] << std::endl;
    std::cout << "*range_pred.second= " << v1[(*range_pred.second).index] << std::endl;

    std::cout << std::endl << "range_pred.second-range_pred.first " << std::distance(range_pred.first, range_pred.second) << std::endl;
}

主要问题是:

  1. 我每次都必须提供谓词,即使是简单的间接,因为它是存储对数据向量的引用的唯一位置。
  2. 如果用户忘记添加谓词,错误消息将被杀死。

关键问题

  1. 这在现代 C++ 中可以更简单吗?
  2. 存放集装箱的最佳地点在哪里?带有状态的谓词是最佳解决方案吗?begin()
  3. 如何使它透明,以便开发人员使用类似(虚构)的东西可以永远忘记这一点,并使用索引,就像使用值一样。std::indirect

带着目标

  1. 鲁棒性:当用户忘记提供谓词并开始出现大量 STL 错误时,这是行不通的;当一些隐式转换可能会破坏间接转换时,这是行不通的,等等。
  2. 可读性,避免代码重复。
  3. 着眼于性能的内存占用。

我相信在现代 C++/STL 中应该有一些现成的解决方案。

我有几十个索引、几十个谓词和许多级别的间接代码,并希望简化代码,因为在许多地方它变得复杂,同时又相似,尽管不完全相同。其中大部分是间接代码。

C++ STL

评论

2赞 Pepijn Kramer 11/16/2023
大多数容器甚至不使用索引,而是使用迭代器或视图/范围。所以我的第一个猜测是把你原来的问题改写成不需要索引的东西。
2赞 463035818_is_not_an_ai 11/16/2023
你已经在这里迷失了我“每个使用索引的算法”,因为我不知道任何使用索引的算法
1赞 463035818_is_not_an_ai 11/16/2023
坦率地说,我认为这个问题太罗嗦了。为了更易于阅读,你可以从一个具体的例子开始,说明你想要完成什么,以及你想要什么代码。代码示例胜过千言万语。当读取第一个代码时,已经是你不想要的代码,但那时它实际上并不理解你想要什么
1赞 Pepijn Kramer 11/16/2023
你首先试图解释你想如何解决某件事,但你试图解决的实际问题是什么?也许你应该从那里开始。
3赞 Jarod42 11/16/2023
对于算法,您似乎可能“只是”使用适当的“投影”。ranges

答:

3赞 Weijun Zhou 11/16/2023 #1

不完全确定这是否是你所期望的,但通常索引是通过投影实现的,正如@Jarod42在对这个问题的评论中指出的那样。您还可以使用自定义视图。下面显示了这两种方法,其中显示了两个密切相关的变体,用于自定义视图方法。

#include <vector>
#include <concepts>
#include <ranges>
#include <algorithm>
#include <iostream>

struct Value {
    int data;
public:
    Value(int value) : data(value) {}
    operator int() const { return data; }

    bool operator < (const Value& rhs) const { return data < int(rhs); }
};

struct Index {
public:
    int index;
public:
    Index(int i) : index(i) {}
};

// Reusable generic for the standard approach using projections
// Added following @Jarod42's comment on this answer
template<std::ranges::random_access_range R>
auto create_index_projection(R&& range) { 
    return [&range](const Index& i){
        return std::ranges::begin(range)[i.index]; 
    }; 
}

// Reusable generic for the custom view approach
template<std::ranges::random_access_range R>
auto indexing(R&& range){
    return std::views::transform([&range](const Index& i) -> decltype(auto) {
        return std::ranges::begin(range)[i.index];
    });
};

// Variant of the custom view approach, with the operands swapped
// Requires more work, but the order may be more "natural" and easier for composition
template<std::ranges::random_access_range I>
requires std::same_as<std::ranges::range_value_t<I>, Index>
struct IndexObject {
    I&& indices;
    explicit IndexObject(I&& indices_):indices{std::forward<I>(indices_)}{}
};

template <typename I>
IndexObject<I> indexedby(I&& indices_){
    return IndexObject<I>{std::forward<I>(indices_)};
}

template <std::ranges::random_access_range R, std::ranges::random_access_range I>
auto operator | (R&& range, IndexObject<I> index_obj){
    return std::forward<I>(index_obj.indices) | indexing(std::forward<R>(range));
}

int main()
{
    std::vector<Value> v1 = { 0,30,20,40,10 };
    std::vector<Index> i1 = { 0,4,2,1,3 };

    Value value(30);

    //Standard approach: Using projection
    auto range_pred1 = std::ranges::equal_range(i1, value, {}, create_index_projection(v1));
    std::cout << "*range_pred.first = " << v1[(*range_pred1.begin()).index].data << std::endl;
    std::cout << "*range_pred.second= " << v1[(*range_pred1.end()).index].data << std::endl;

    std::cout << std::endl << "range_pred.second-range_pred.first " << std::distance(range_pred1.begin(), range_pred1.end()) << std::endl;

    //Alternatve approach: Using custom view
    //May be the syntax you are looking for
    auto indexing_view = i1 | indexing(v1);

    auto range_pred2 = std::ranges::equal_range(indexing_view, value);
    std::cout << "*range_pred.first = " << (*range_pred2.begin()).data << std::endl;
    std::cout << "*range_pred.second= " << (*range_pred2.end()).data << std::endl;

    std::cout << std::endl << "range_pred.second-range_pred.first " << std::distance(range_pred2.begin(), range_pred2.end()) << std::endl;

    //Variant of above, with more "natural" order in syntax
    auto indexing_view2 = v1 | indexedby(i1);

    auto range_pred3 = std::ranges::equal_range(indexing_view2, value);
    std::cout << "*range_pred.first = " << (*range_pred3.begin()).data << std::endl;
    std::cout << "*range_pred.second= " << (*range_pred3.end()).data << std::endl;

    std::cout << std::endl << "range_pred.second-range_pred.first " << std::distance(range_pred3.begin(), range_pred3.end()) << std::endl;

    //Also works for rvalues provided that std::ranges::dangling is not returned by the algorithm
    bool binary_search_result1 = std::ranges::binary_search(std::vector<Value>{0,20,10}|indexedby(std::vector<Index>{0,2,1}), Value{10});
    bool binary_search_result2 = std::ranges::binary_search(std::vector<Value>{0,20,10}|indexedby(std::vector<Index>{0,2,1}), Value{15});
    std::cout << std::boolalpha;
    std::cout << std::endl << "binary search for 10 " << binary_search_result1 << std::endl;
    std::cout << "binary search for 15 " << binary_search_result2 << std::endl;
}

演示:https://godbolt.org/z/389Y99Mnz

适配器 和 旨在进行重复数据删除。您只需要定义一次,然后使用任一 or 来获取几乎可以用作重新排序范围的视图。只需定义两个变体中的一个,因此您可以选择您认为最自然的语法。这解决了您问题中的透明要求。因此,假设您有以下几点indexingindexedbyindex_range | indexing(indexed_range)indexed_range | indexedby(index_range)

std::string s="abc";
std::array<Index> indices[]{1,2,0};
auto sView = s | indexedby(indices);
//Or, equivalently
//auto sView = indices | indexing(s);

请注意,现在和上面的类型完全不同,但具有相同定义的适配器可以用作包含“bca”的随机访问范围。它是随机访问,因为基础范围也是如此。例如,这意味着并执行您期望它们执行的操作(尽管后者实际上并不需要随机访问范围),这对于大多数应用程序来说已经足够了。indicesssViewindicessView[2]=='a'for(const auto c: sView){...}

它也是可组合的。因此,例如,如果除了 和 之外,还有另一个使用索引向量的间接级别,那么您可以只使用视图来透明地访问元素。由于重载必须是成员函数的限制,因此不可能使用类似语法。i2i1v1auto view = v1 | indexedby(i1) | indexedby(i2)[]v1[indexedby(i1)][indexedby(i2)]


关于模板定义的更多解释:它是一个薄包装器(在大多数 C++ 实现中,额外的成本是单个指针的内存),范围(例如 在您的示例中),实际上只是 函数的一个更具描述性的名称,它可以像 is to 一样被调用。IndexObjectIndexstd::vector<Index>indexedbymakeIndexObjectmakeIndexObjectmake_pairstd::pair

现在,这个围绕原始范围的薄包装器可确保为语法选择正确的运算符。它本来可以只是通过更改 的签名,但我认为这太容易出错并且不能很好地传达意图,所以我选择制作这个额外的包装器。Index|v1 | indexedby(i1)v1 | i1operator|

所以用一句话来概括它的目的:它是包装(捕获)现有的范围,使语法成为可能。在实现许多范围适配器的相应算子时,这称为闭包。Indexv1 | indexedby(i1)|

对于“工作原理”部分。上面已经介绍了大部分信息,但我可以用与自然程序流程相对应的方式重新表述它。

  1. indexedby包装(捕获)范围 ,这是一个 .IndexIndexObject
  2. 捕获与要检查的范围相结合,返回随机访问 。std::transform_view
  3. 然后可以使用透明地访问。std::transform_view

从概念上讲,它与 没有太大区别。(Capture(i1))(v1) -> std::transform_view<decltype(i1), F>


在这一点上,完全摆脱类可能很诱人,因为最初的意图是防止将索引向量误用为值向量。事实上,就类型安全而言,这样做是完全安全的。原因如下。ValueIndex

对于适配器,由于构造函数无法将范围转换为 an,并且 an 本身不是有效的范围,因此现在很清楚,对于值,您必须使用实数范围,而对于索引,您必须使用 .它们不可能混淆在类型系统中。同样,对于适配器,对于值,您必须使用范围适配器,对于索引,您必须使用实际范围,当然,标准库不允许它们之间的隐式转换。indexedbyIndexObjectexplicitIndexObjectIndexObjectindexing

此演示中的以下代码片段清楚地显示了类型安全。有关演示中涉及的类型的定义,请参阅演示本身的链接。该演示改编自 OP 提供的另一个演示。

namespace TypeSafetyDemo{
    namespace Approach1{
        using Value = ValueObject<std::vector<int>&>;
        using Index = std::vector<int>;
        using F = void(*)(Value, Index);
        static_assert(std::invocable<F, Value, Index>);
        static_assert(!std::invocable<F, Value, Value>);
        static_assert(!std::invocable<F, Index, Value>);
        static_assert(!std::invocable<F, Index, Index>);
    }
    namespace Approach2{
        using Value = std::vector<int>;
        using Index = IndexObject<std::vector<int>&>;
        using F = void(*)(Value, Index);
        static_assert(std::invocable<F, Value, Index>);
        static_assert(!std::invocable<F, Value, Value>);
        static_assert(!std::invocable<F, Index, Value>);
        static_assert(!std::invocable<F, Index, Index>);
    }
}

另外需要注意的是,由于两个适配器都通过引用捕获基础范围,因此通常的生存期警告和引用成员适用。不要将一个东西存放在某个地方,并在结束的生命周期后使用它。出于类似的原因,拥有函数签名 是危险的,而且不是很有用,因为在第一次使用 时,底层将被移动到 ,而后续使用 的 将是无效的。我还修改了模板,使其格式不正确,以防止与此类似的误用。indexedby(i1)i1f(std::vector<int>, IndexObject<std::vector<int>&&> index_obj)vectorowning_viewindex_objindex_objIndexObjectIndexObject<std::vector<int>>

如果您真的想变得有用且安全,这里提供了另一种选择。只有当它和它本身都是右值时,它才会移动底层。f(std::vector<int>, IndexObject<std::vector<int>&&>)vectorvectorIndexObject


关于现已删除的评论中的建议:with 不保证格式正确。事实上,这个概念并没有说太多关于它本身的成员函数,而只是关于它的迭代器。因此,为了足够通用,仍然有必要使用.尽管标准库中的所有随机访问范围都提供了 .R rangerandom_access_range Rrange[i.index]Rstd::ranges::begin(range)[i.index]operator[]

评论

1赞 Weijun Zhou 11/16/2023
它不是内置的,而是内置到视图中,然后调用就了。i1view = i1|indexing(v1)equal_range(view, value)
1赞 Jarod42 11/16/2023
以与创建相同的方式,您可能拥有 ,等等。indexingauto create_index_projection(auto&& r) { return [&r](auto&& i){ return return std::ranges::begin(v1)[i.index]; }; }std::ranges::equal_range(i1, value, {}, create_index_projection(i1))
1赞 Weijun Zhou 11/16/2023
@Jarod42 好主意。包含在答案中。
1赞 Weijun Zhou 11/18/2023
@DamirTenishev 在答案中解决了,请注意,模板参数并不是真正需要的,我已经删除了它。Index
1赞 Weijun Zhou 11/18/2023
@DamirTenishev我已经说过 Index 参数并不是真正需要的,我不明白你为什么要把它加回来。Index type 就是 if 是索引范围的类型。第二个是不同的问题,老实说,预测会更适合这份工作,但我仍然会尝试看看我是否能想出什么。std::ranges::range_value_t<I>I