在C++中使用范围是否明智?

6
我发现大多数C++ STL算法的传统语法很烦人;使用它们编写冗长只是一个小问题,但它们总是需要在现有对象上操作,这极大地限制了它们的可组合性。
我很高兴看到STL中出现了范围(ranges); 然而,截至C++20,存在严重缺陷:不同标准库实现对此的支持不同,许多在range-v3中存在的内容未包含在C++20中,例如(令我非常惊讶的是),将视图(view)转换为向量(vector)(如果我不能将计算结果存储在向量中,则这使得所有内容都有点无用)。
另一方面,对我来说,使用range-v3也似乎不太理想:它的文档很差(我不认为里面的所有内容都是自说明的),而且更为严重的是,C++20对范围的想法与range-v3的不同,因此我不能只说:“好吧,让我们坚持使用range-v3; 它总有一天会成为标准。”
那么,我应该使用其中的任何一个吗?还是说这一切都不值得,通过依赖std ranges或range-v3,使我的代码过于难以维护和移植?

2
你可能想要等待C++23或更高版本。新的概念需要一段时间才能成为标准。或者只需使用已有的内容,并接受并非所有功能都可用。 - Goswin von Brederlow
2
这是个鸡生蛋的问题:如果你不使用范围,那么你就不会编写需要范围的代码,因此你也不会使用范围。而且编写 template<typename Container, typename Range> Container range_to(Range&&); 也并不难。 - Caleth
3
C++算法不需要在现有对象上操作。这就是它们可组合的原因:一个算法返回的迭代器可以作为另一个算法的输入。通常,传递给算法的迭代器来自容器,但这不是必需的;还有其他迭代器来源。 - Pete Becker
1
@MSalters:“如果你今天需要位操作,你现在可以实现它们” 不,你不能。你不能将(非特化)声明注入到std命名空间中。而且,一些C++23功能无法在不改变现有基础设施的情况下工作。你可以实现一些像“视图到容器”的附加功能,但仅限于此。 - Nicol Bolas
1
@Bubaya -- 当然可以。尽管有些人可能会因为它不够专注而关闭它。 :-( - Pete Becker
显示剩余6条评论
5个回答

6

在C++中使用范围是否可取?

是的。

许多range-v3中存在的东西都没有进入C++20,例如(令我大为惊讶的是)将视图转换为向量

是的。但std::ranges::to已被C++23采用,它更强大,并且与stl容器的range version constructor很好地配合使用。

那么,我应该使用其中任何一个吗?

您应该使用标准库<ranges>

它包含了几个 PR 的增强,如 owning_view重新设计的split_view 和持续的 LWG 修复。此外,C++23 不仅带来了更多的适配器,如 join_with_viewzip_view,等等,还有更强大的功能,例如管道支持用户定义的范围适配器(P2387),以及格式化范围(P2286)等等。你唯一需要做的就是等待编译器实现它。你可以参考 cppreference 查看最新的编译器支持。


4
请问建议的依据是什么?“是的”这种回答太过简单了,不是吗? - Maxim Egorushkin
1
你需要做的唯一事情就是等待编译器实现它。这是一个相当大的事情,不是吗?我猜测OP正在询问他们今天的编程需求。 - user118967

4
我建议使用range-v3而不是std::ranges。在c++23实现之前,有太多东西缺失,使得完全使用std::ranges不值得。

另一方面,使用range-v3对我来说也不理想:文档质量较差(我不同意其中所有内容都是不言自明的)。您可以从这些补充材料https://www.walletfox.com/course/quickref_range_v3.php https://www.walletfox.com/course/examples_range_v3.php中轻松学习range-v3,如果需要更多信息,您还可以购买该书。

此外,range-v3是开源的,因此您可以让源代码成为您的文档。

c++20中关于范围的想法与range-v3所做的不同,所以我不能只说,好吧,让我们坚持使用range-v3; 在某个时候它将成为标准。我怀疑这些更改并不重要,主要问题是range-v3和std::ranges无法结合,但更改命名空间应该是将range-v3移植到std::ranges 23的大部分工作。

没有范围的代码太难了。使用range-v3在所有事情上节省的时间是巨大的,特别是在消除新编写代码中的错误所需的时间,以及理解过去编写的代码,然后对其进行修改所需的时间。我认为不使用range-v3的唯一原因是要维护现有代码库的约定。

4
作为一个范围迷,这次我要再次以否定的方式回答。
大部分时间你在开发中花费的时间都是用来逐步编译一个编译单元。使用范围会大大增加这些编译时间。MSVC编译速度明显更快,但当我切换到GCC或Clang时,简直无法忍受。
你不能通过设置编译墙来解决这个问题,因为你几乎总是需要推断出范围的类型。所以即使你不修改范围代码,你仍然会陷入编译时间过长的困境。
让模板编译也是浪费时间。在使用Python的可迭代对象之后,你会开始注意到静态类型系统的任意限制。关于这方面有很多你必须通过艰难的方式学习的怪癖。
C++的范围非常复杂。我正在努力变得不那么书呆子,如果你也是,远离范围可能是个好主意。
声明式代码比命令式代码更易读和可维护。函数式编程将所有容易出错的详细导向代码从你的代码中移出,并放入库中。但代价是什么?map、reduce、filter都很容易以命令式的方式实现,但我需要我的group_by和split。

1
我对https://dev59.com/PsTsa4cB1Zd3GeqPErsZ#72846230和https://dev59.com/PsTsa4cB1Zd3GeqPErsZ#74664658的无评论并列感到困惑。在2022年7月,您自信地说:“没有范围的代码太难了。我节省的时间...是巨大的。”五个月后,您又自信地说:“使用范围会极大地增加这些编译时间...使模板编译也是浪费时间...有很多怪癖...相当复杂...建议远离。”您真的在短短五个月内彻底改变了观点吗?在七月之前,您使用范围有多少个月了? - Quuxplusone
如果您的计算机速度足够慢,一切都可能变得过慢。您使用的是哪种系统?这对每个用户来说都太重了还是只有一些用户? - Daniel
@Daniel,这真的取决于你在编写范围时有多深入。每个管道操作符都是一个嵌套的模板实例化。推断lambda类型也可能会减慢速度。我正在运行一台i5-4590 CPU @ 3.30GHz, 3301 Mhz, 4 Core(s), 4 Logical Processor(s)的计算机。 - Tom Huntington
写声明性代码可以加快开发时间(维护/修改命令式代码可能是一场噩梦)。使用范围会降低编译时间。"避而远离"是有条件的。我有一个大型的C++项目,其中范围带来了很大的好处,但代价也很高。 - Tom Huntington

1

你的标题与问题不符

你的标题问道“是否应该使用范围(Ranges)?”但在你的问题中,你提到正在考虑使用range-v3 —— 那么你应该使用range-v3还是C++20的范围(Ranges)呢?

这就像是问“是否应该使用ASIO?”然后又表示你正在在Boost.ASIO和独立ASIO之间做选择。如果一个人正在在这些选项之间做选择,那么显然已经决定“总体上使用ASIO”了,不是吗?所以在你的情况下,你似乎已经决定“总体上使用范围(Ranges)”,现在我们只是在讨论价格问题。

因此,我的回答可能并不针对你,而是针对那些想知道是否将C++20的范围(Ranges)引入到尚未基于范围(Ranges)构建的代码库中的假设读者。

不能避免范围(Ranges);或者说,“你无法避免范围(Ranges)。”

这真的取决于你将如何使用范围。在我看来,现在已经相当明显,“将范围视图化”普通的业务逻辑代码是一个不好的主意,无论是从可理解性还是性能方面来说。例如,请不要改变。
for (int i : selected_indices) {
    if (products[i].price > 10) {
        std::cout << products[i].name;
    }
}

进入

std::ranges::copy(
    selected_indices
        | std::views::filter([](auto& p) { return p.price > 10; })
        | std::transform(&Product::name),
    std::ostream_iterator<std::string_view>(std::cout)
);

然而,改变是完全合理的。
int expected[] = {1,2,3,4,5};
EXPECT_TRUE(std::equal(actual.begin(), actual.end(), expected, expected+5));

进入
int expected[] = {1,2,3,4,5};
EXPECT_TRUE(std::ranges::equal(actual, expected);

这也是"C++20 Ranges code"。但这次它实际上提高了代码的可读性。(它仍然增加了编译时成本,但运行时成本保持不变。)
此外,如果您已经通过泛型编程使用C++98-STL风格的"算法",那么您应该绝对采用C++20对迭代器模型的修改,以便您的算法既适用于旧式迭代器又适用于新式迭代器。换句话说,我认为重写一个形式如下的实用程序库可能是值得的。
template<class It, class Pred>
bool my::is_uniqued(It first, It last, Pred pred) {
  for (auto it = first; it != last; ++it) {
    if (pred(*first, *std::next(first))) {
      return false;
    }
  }
  return true;
}

转换成更适合C++20的形式,比如
template<class It, class Sent, class Pred>
bool my::is_uniqued(It first, Sent last, Pred pred) {
  for (auto it = first; it != last; ++it) {
    if (pred(*first, *std::next(first))) {
      return false;
    }
  }
  return true;
}

template<std::ranges::range R>
bool my::is_uniqued(R&& rg) {
  return my::is_uniqued(rg.begin(), rg.end());
}

这有点像当你更新一个接受const string&的函数,改为接受string_view,从而允许它接受更多种类的类似字符串的参数。我们正在更新is_uniqued函数,使其能够接受更多种类的可迭代范围参数。这可以被视为对"代码卫生"的好处。
在这个例子中,我想不出任何特定的理由来开始使用std::ranges::next来替代std::next;而且你可能不应该添加像这样的约束。
template<std::forward_iterator It, std::sentinel_for<It> Sent, std::predicate<std::iter_reference_t<It>> Pred>
bool my::is_uniqued(It first, Sent last, Pred pred) {

因为那样只会浪费你的编译时间,以及在出现问题时产生的编译器诊断信息。它还存在使你的模板对某些现有手写的C++98迭代器类型不可用的风险,这些类型未能满足std::forward_iterator的某些细节要求。(根据我的经验,最常见的情况是有人忘记给其operator*()加上const修饰符。我认为这样的迭代器类型有缺陷,值得修复,但你可能没有时间或权限立即去修复它。)这也会引发无关紧要的争论:“为什么你写了std::iter_reference_t而不是std::iter_value_t?我们是否应该对两者都进行约束?”制定一个总体的风格规则,“我们不会不必要地限制我们的模板”,可以避免很多无谓的争论。
另一方面,如果你正在开发一个目前需要手写大量enable_if来“模拟”Ranges本身所具备的功能的库... 那么,使用C++20的Ranges当然会更有益处!

-2

简单示例:对一亿个随机整数值的向量进行排序

#include <iostream>
#include <chrono>
#include <ranges>
#include <random>
#include <vector>
#include <algorithm>


int main(int argc, char **argv) {


    const int START = 1, END = 50, QUANTITY = 100000000;


    std::random_device dev;
    std::mt19937 rng(dev());
    std::uniform_int_distribution<std::mt19937::result_type> dist6(START, END);

    std::vector<int> vec;
    vec.reserve(QUANTITY);

    for (int i = 0; i < QUANTITY; i++) {
        vec.push_back(dist6(rng));
    }

    std::vector<int> original_copy = vec;

    auto start_test1 = std::chrono::high_resolution_clock::now();
    std::ranges::sort(vec);
    auto end_test1 = std::chrono::high_resolution_clock::now();
    auto duration_test1 = std::chrono::duration_cast<std::chrono::milliseconds>(end_test1 - start_test1).count();

    auto start_test2 = std::chrono::high_resolution_clock::now();
    std::sort(original_copy.begin(), original_copy.end());
    auto end_test2 = std::chrono::high_resolution_clock::now();
    auto duration_test2 = std::chrono::duration_cast<std::chrono::milliseconds>(end_test2 - start_test2).count();


    std::cout << "test std::ranges::sort, vector was sorted in  " << duration_test1 << " milliseconds." << std::endl;
    std::cout << "test std::sort, vector was sorted in  " << duration_test2 << " milliseconds." << std::endl;


    if (duration_test1 > duration_test2) {
        std::cout << "std::sort is " << duration_test1 - duration_test2 << " milliseconds faster" << std::endl;
    } else {
        std::cout << "std::ranges::sort is " << duration_test2 - duration_test1 << " milliseconds faster" << std::endl;
    }


    return 0;
}

输出:

test std::ranges::sort, vector was sorted in  175319 milliseconds.
test std::sort, vector was sorted in  45368 milliseconds.
std::sort is 129951 milliseconds faster

我认为std::ranges中有一些奇怪的地方,可能比标准算法更易于使用,但性能可能会更好。


2
你忘了预热CPU缓存,确保CPU频率恒定并消除上下文切换。不确定你到底测量了什么,没有解释/说明差异。 - Maxim Egorushkin
2
https://github.com/google/benchmark 是一个更好的起点。 - Maxim Egorushkin
2
实现的质量可能会有所不同,但除了前面提到的所有事情之外,在编译时您是否启用了优化? - Bob__
2
我使用MSVC STL在Clang下编译了上述代码,并得到了以下结果:test std::sort,向量在1612毫秒内排序。 std::sort快5毫秒。``` 基于上述时间几乎是10倍以上,我认为这些是Debug时间。 - Chris

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