技术分享 | 使用 C++20 Ranges 标准库实现日历程序

作者 | 罗能(知乎 id:@netcan)
整理 | 编程语言 Lab

花了我 3 个晚上才搞定,结论是目前 C++ 的 Ranges 标准库 [1] 对于实现 复杂的程序还不够用 ,提供的 views 适配器组合子也仅仅限于简单的 filter/transform 等,还未提供标准的方式让用户去定义组合子(不过这个问题目前 C++23 已经有提案 P2387R0 [2] 在做了)。

完整可编译可运行的日历程序请见:https://godbolt.org/z/overc6q51,效果如下(2021 年):

       January               February               March                 April
 Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
                 1  2      1  2  3  4  5  6      1  2  3  4  5  6               1  2  3
  3  4  5  6  7  8  9   7  8  9 10 11 12 13   7  8  9 10 11 12 13   4  5  6  7  8  9 10
 10 11 12 13 14 15 16  14 15 16 17 18 19 20  14 15 16 17 18 19 20  11 12 13 14 15 16 17
 17 18 19 20 21 22 23  21 22 23 24 25 26 27  21 22 23 24 25 26 27  18 19 20 21 22 23 24
 24 25 26 27 28 29 30  28                    28 29 30 31           25 26 27 28 29 30
 31
         May                   June                  July                 August
 Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
                    1         1  2  3  4  5               1  2  3   1  2  3  4  5  6  7
  2  3  4  5  6  7  8   6  7  8  9 10 11 12   4  5  6  7  8  9 10   8  9 10 11 12 13 14
  9 10 11 12 13 14 15  13 14 15 16 17 18 19  11 12 13 14 15 16 17  15 16 17 18 19 20 21
 16 17 18 19 20 21 22  20 21 22 23 24 25 26  18 19 20 21 22 23 24  22 23 24 25 26 27 28
 23 24 25 26 27 28 29  27 28 29 30           25 26 27 28 29 30 31  29 30 31
 30 31
      September              October               November              December
 Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
           1  2  3  4                  1  2      1  2  3  4  5  6            1  2  3  4
  5  6  7  8  9 10 11   3  4  5  6  7  8  9   7  8  9 10 11 12 13   5  6  7  8  9 10 11
 12 13 14 15 16 17 18  10 11 12 13 14 15 16  14 15 16 17 18 19 20  12 13 14 15 16 17 18
 19 20 21 22 23 24 25  17 18 19 20 21 22 23  21 22 23 24 25 26 27  19 20 21 22 23 24 25
 26 27 28 29 30        24 25 26 27 28 29 30  28 29 30              26 27 28 29 30 31
                       31

日历程序问题分解

最终输出的结果是字符串,问题域如下:

  • 以年为单位,一年 355/356 天,如何将每天按月、按周分组?(提示:group_by
  • 每个月每行输出的是 7 天,每天占宽 3 个字节,加一个填充位,总共 3 * 7 + 1 = 22 字节
  • 每个月显示一行月份标题,一行星期标题,最多 6 周(6 行),不足 6 行的补齐空行到 6 行,因此一个月的字符矩阵为 (6+2) x 22
  • 如何一行显示 N 个月的字符串信息?(提示:Chunks 分块)
  • 假设一行显示 3 个月的字符串信息,一行总长度就是 22 x 3,如果以月为维度,那么矩阵大小是 3 x 8 x 22(提示:矩阵转置),最终的字符矩阵为 4 x 3 x 8 x 22
  • 上一步转置之后矩阵大小为 4 x 8 x 3 x 22,如何降维处理?(提示:join),得到 32 x 3 x 22
  • 如何对低维的 3 x 22 进行降维处理(字符串拼接),得到一行长度为 66 的字符串?(提示:transform/join
  • 如何输出最终的字符矩阵 32 x 66?(提示:std::cout

如果让你编写这个程序,如何编写?(似乎作为一个面试题不错)是不是涉及一大堆循环、分支代码?涉及一大堆迭代变量(状态)?

这就是常规的 命令式(面向过程)编程风格。

我正好找到了一个使用命令式编程风格的日历实现,https://github.com/karelzak/util-linux/blob/master/misc-utils/cal.c,里面的最大嵌套深度居然达到了 5 层(cal_vert_output_months函数),一个人每面对一层嵌套,理解难度成倍增长。分支与循环引入的迭代状态与过深的嵌套层数,漫无边际的细节与面条式代码,不仅容易出错且非常难读。

而Ranges标准库使用函数式编程风格,它的最大优势是无状态编程,如果一个程序的状态越少越容易推理正确性越容易证明( bug 越少)。另一个优势在于行为的灵活组合能力,每一步操作(组合子)都是原子与抽象可复用可组合的动作,对每一层的 Range 处理不会引入额外的嵌套,利用这些原子动作可以组合出任意强大的程序。

使用 Ranges 标准库实现

如下是我使用 C++20 标准重写的代码,欣赏一下。这份代码参考了 range-v3 库(Ranges 标准库的基础)中的例子,具体思想可参考原作者于 CppCon 2015 年的演讲[3]。

int main(int argc, char** argv) {
   
    auto formatedCalendar
        = datesBetween(2021, 2022)
        | by_month()
        | layout_months()
        | chunk(3)
        | transpose_months()
        | views::join
        | join_months()
        ;
    ranges::copy(formatedCalendar,
                std::ostream_iterator<std::string_view>(std::cout, "\n"));
    return 0;
}

auto datesBetween(unsigned short start, unsigned short stop) {
   
    return views::iota(Date{
   start, 1, 1}, Date{
   stop, 1, 1});
}

auto by_month() {
   
    return group_by([](Date a, Date b) {
    return a.month() == b.month(); });
}

auto by_week() {
   
    return group_by([](Date a, Date b) {
    return (++a).week_number() == (++b).week_number(); });
}

// TODO: c++20 std::format
std::string month_title(const Date& d) {
   
    std::string title(22, ' ');
    std::string_view name = d.monthName();
    ranges::copy(name, ranges::begin(title) + (22 - name.length()) / 2);
    return title;
}

// TODO: c++20 std::format
std::string format_day(const Date& d) {
   
    char res[4];
    sprintf(res, "%3d", (int)d.day());
    return res;
}

// in: range<range<Date>>
// out: range<std::string>
auto format_weeks() {
   
    return views::transform([](/*range<Date>*/auto&& week) {
   
        std::string weeks((*ranges::begin(week)).dayOfWeek() * 3, ' ');
        weeks += (week | views::transform(format_day) | join);
        while (weeks.length() < 22) weeks.push_back(' ');
        return weeks;
    });
}

// in: range<range<Date>>
// out: range<range<std::string>>
auto layout_months() {
   
    return views::transform([](/*range<Date>*/ auto&& month) {
   
        auto week_count = ranges::distance(month | by_week());
        return concat(
            views::single(month_title(*ranges::begin(month))),
            views::single(std::string("Su Mo Tu We Th Fr Sa")),
            month | by_week() | format_weeks(),
            repeat_n(std::string(22, ' '), 6 - week_count)
        );
    });
}

// In:  range<range<range<string>>>
// Out: range<range<range<string>>>, transposing months.
auto transpose_months() {
   
    return views::transform([](/*range<range<string>>*/ auto&& rng) {
   
        return rng | transpose;
    });
}

// In:  range<range<string>>
// Out: range<string>, joining the strings of the inner ranges
auto join_months()
{
   
    return views::transform(
        [](/*range<string>*/ auto rng) {
    return join(rng); });
}

正因为 Ranges 标准库的实现使用了元编程技术,性能并不比命令式的编程风格差。参考最终的汇编代码生成部分,只有少数几行代码涉及生成,其他代码 都被优化掉 了(开启 O2 优化,高亮部分为参与代码生成的部分)。
图 1:开启 O2 优化,高亮部分为参与代码生成的部分
另一个优势在于 Range 的实现是延迟计算的,并且多次组合的背后可能仅仅迭代一次 Range。

生成器(generator)作为协程(coroutine)的一种特殊场景,也能够和 Ranges 进行组合。

最终的二进制大小仅仅为 52KB,系统自带的日历程序大小为40KB。

遇到的一些问题

遇到比较麻烦的问题是编译错误信息,组合后的类型相当长,一旦出错,告警提示的类型将淹没你所需要关注的点。这可能是静态类型语言的弱势。

例如表达式 iota(1, 100) | views::transform([](auto x){ return x * 2; }); 的类型为 std::ranges::transform_view<std::ranges::iota_view<int, int>, main(int, char**)::<lambda(auto)> >,一旦报错显示的类型相当长。

如下是我在编译过程中遇到类型爆炸的信息,简洁的背后是有一定代价的。
图 2:在编译过程中遇到类型爆炸的信息

更多实现细节

日期 Date 类的实现比较 tricky,我的实现细节如下。

// Date
struct Date {
   
    using difference_type = std::ptrdiff_t;
    Date() = default;
    Date(uint16_t year, uint16_t month, uint16_t day):
        days(dayNumber(year, month, day)) {
    }

    friend bool operator==(const Date&, const Date&) = default;
    Date& operator++()            {
    ++days; return *this;}
    Date operator++(int)          {
    Date tmp(*this); ++*this; return tmp; }
    uint16_t day() const          {
    return fromDayNumber().day;           }
    uint16_t month() const        {
    return fromDayNumber().
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值