使用 C++20 Ranges 标准库实现日历程序

笔者在多年前看到过一篇文章,如今已经2025年了,C++的range库也更新了很多其他的东西。如今STL提供的组件已经足够用来输出日历了。

日历程序问题分解

首先来看效果图

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

       October               November              December
 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
  3  4  5  6  7  8  9   7  8  9 10 11 12 13   5  6  7  8  9 10 11
 10 11 12 13 14 15 16  14 15 16 17 18 19 20  12 13 14 15 16 17 18
 17 18 19 20 21 22 23  21 22 23 24 25 26 27  19 20 21 22 23 24 25
 24 25 26 27 28 29 30  28 29 30              26 27 28 29 30 31
 31

不难发现,每个月的日历都包含了8行,其中月份和星期独占一行,而日期部分占了后6行,不足6行则使用空行补齐。

我们首先来看一个月:

        January       
 Su Mo Tu We Th Fr Sa 
                 1  2 
  3  4  5  6  7  8  9 
 10 11 12 13 14 15 16 
 17 18 19 20 21 22 23 
 24 25 26 27 28 29 30 
 31

当每个月的第一天不是周日的时候,我们需要在前面补空格,当每个月最后一天不是周六的时候,我们需要在后面补空格。

以一月份为例,我们前面有5天需要补空格,后面有6天需要补空格。我们可以定义如下序列:

[0,0,0,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,0,0,0,0,0,0]

我们使用0来代表空格,剩余的数字代表日期。

Sequence = concat(
    prefix, 
    days,
    suffix
)

于是有

auto RangeAsString = [](auto&& rg) static
{
    auto ToString = [](int x) static
    {
        return x == 0 ? DoubleBlank : std::format("{:2} ", x);
    };
    return rg | transform(ToString) | join | std::ranges::to<std::string>();
};

auto CollectDays = [](std::chrono::year_month ym) static
{
    auto total_days = DayofMonth(ym);
    auto first_day = WeekdayOfDay(ym / std::chrono::day(1));
    auto leading_zero = first_day.c_encoding();
    auto prefix = repeat(0, leading_zero);
    auto days = iota(1, total_days + 1); 
    auto suffix = repeat(0, 7 - (total_days + leading_zero) % 7);
    auto title = Titles[ym.month().operator unsigned int() - 1];
    auto row_number = leading_zero + total_days;
    auto day_of_month = concat(prefix, days, suffix) | chunk(7) | transform(RangeAsString);

    return concat(
        single(title),                         //         January      
        single(WeekName),                      //  Su Mo Tu We Th Fr Sa
        day_of_month,                          // 1, 2, 3, ...
        repeat(EmptyLine, 6 - row_number / 7)  // Empty line
    );
};

这样我们每次调用CollectDays可以获得一个长度未8的序列,其中每个序列都是一个字符串。

CollectDays(std::chrono::year(2024) / std::chrono::January) | std::views::join_with('\n') | std::ranges::to<std::string>();

/* 
        January       
 Su Mo Tu We Th Fr Sa 
                 1  2 
  3  4  5  6  7  8  9 
 10 11 12 13 14 15 16 
 17 18 19 20 21 22 23 
 24 25 26 27 28 29 30 
 31
*/

单个月的日期我们已经处理好了,接下来就是一整年的日期。

std::string Calendar(std::chrono::year y, int amount)
{
    auto unpack = []<size_t... I>(std::index_sequence<I...>, std::chrono::year y) static 
    {
        auto impl = [=](size_t index) {
            return CollectDays(std::chrono::year_month(y, std::chrono::month(index + 1)));
        };
    
        return concat(single(impl(I))...);
    };
    
    return unpack(std::make_index_sequence<12>(), y) 
         | chunk(amount) 
         | transform(MergeChunk) 
         | join 
         | std::ranges::to<std::string>();
}

一年有12个月,所以我们需要产生一个长度为12的序列,然后我们每次展示三个。我们利用unpack产生12个序列,然后对于每个序列,我们使用single让其成为一个单独的元素,最后再使用concat就可以将这些序列合并了。unpack代码就等价于

concat(
    single(CollectDays(year / 1))
    single(CollectDays(year / 2))
    single(CollectDays(year / 3))
    ...
    single(CollectDays(year / 11))
    single(CollectDays(year / 12))
);

接下来面临的问题是如何合并三个月的日期。

        January               February               March
 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
  3  4  5  6  7  8  9   7  8  9 10 11 12 13   7  8  9 10 11 12 13
 10 11 12 13 14 15 16  14 15 16 17 18 19 20  14 15 16 17 18 19 20
 17 18 19 20 21 22 23  21 22 23 24 25 26 27  21 22 23 24 25 26 27
 24 25 26 27 28 29 30  28                    28 29 30 31
 31

其实我们只需要把每一行的string合并一下即可,因为每个月的日期都固定占8行,我们只需要固定长度的循环就可以解决问题。

auto DereferenceAndAdvance = [](auto&& it) static { return *it++; };

std::generator<std::string> DereferenceAndAdvanceMultiIterator(auto&& blocks)
{
    auto iterators = blocks | transform(std::ranges::begin) | std::ranges::to<std::vector>();

    for (int i = 0; i < 8; ++i)
    {
        co_yield iterators 
               | transform(DereferenceAndAdvance) 
               | join_with(' ') 
               | std::ranges::to<std::string>();
    }
}

auto MergeChunk = [](auto&& blocks) static
{
    auto s = DereferenceAndAdvanceMultiIterator(blocks) 
         | join_with('\n')
         | std::ranges::to<std::string>();
    return s += '\n';
};

由于GCC14.2目前还没有实现concat,不过concat已经进入C++26标准了,笔者在这里简单实现了concat。
完整的代码