作者 | 罗能(知乎 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 优化,高亮部分为参与代码生成的部分)。
另一个优势在于 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)> >
,一旦报错显示的类型相当长。
如下是我在编译过程中遇到类型爆炸的信息,简洁的背后是有一定代价的。
更多实现细节
日期 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().month; }
const char