霜风凄紧,神寒骨冷,忽忽冬月将尽。忙处更新渐少,今来补上。
本篇讲解如何利用 ChatGPT 快速实现一个控制台进度条小工具,相比单纯介绍某些特性,此种方式涉及知识的综合运用,也顺便谈谈如何结合 AI 进行编程。
问题描述
控制台程序执行一些耗时任务时,需要向用户显示当前任务执行的进度,以提供清晰的感知。比如一个下载程序,通过进度条便能告知用户当前的下载进度。
进度条可以单独显示,也可以在程序输出的最下方显示,下图是一个示例。
这是一种单控制条需求,执行任务,显示进度,输出流依旧是从上至下依序进行,适合单线程的场景。
多控制条显示的效果如下图,实现要更加复杂一些,本文暂时不会涉及该部分。
初步分析
控制台上显示的这种符号,称为 ASCII Art,就是以字符构建的某种图案,不借助图片,也能够有一个生动的展示效果,比如下图这种。
因此控制台进度条也称为 ASCII Progress Bar,通过字符图案来模拟进度条的显示,通常分为已完成部分及未完成部分,使用两个字符,动态改变字符数量,便能够模拟出一个进度条。
模拟方式既定,下一问题在于进度条刷新。如果每更新一次进度,便输出一个字符图案,那么屏幕上将满是进度条,需要针对一条进度条,不断刷新其数据,而非每次都输出一条新的。具体实现时,便需要寻找定位进度条的方法,每次清除当前数据,重新打印新的数据,视觉上显示的是连续动画。
刷新思路亦成,接着的问题在于如何在进度条之上插入其他输出。进度条始终显示在用户输出下方,因此每次用户输出时,可以立即定位到进度条,定位之后清除当前进度条,输出用户内容,再重新打印进度条,便能够达到这一效果。
细枝末节,便需依赖具体的实现手法。
借助 ChatGPT 快速构建基本代码
需求明确,思路既定,接着便要着手设计库的结构和细节,实现细节这部分代码无需从零编写,可以借助 AI 快速生成。
我们所需做的,就是详细描述需求,以及预想的思路,让 ChatGPT 生成代码,验证是否符合需求,若不符合,纠正错误,让它再次生成,不断重复这个过程,直到基本满足期待的效果。如果一开始的效果就完全牛头不对马嘴,那也可以让它基于 Python 生成,等到效果尚可,再让它把代码转换成 C++ 代码。
经过多次调教,最终生成的代码如下:
1#include <iostream>
2#include <thread>
3#include <chrono>
4
5void print_progress_bar(int iteration, int total, int bar_length = 50) {
6 float progress = static_cast<float>(iteration) / total;
7 int arrow_length = static_cast<int>(bar_length * progress);
8 int spaces = bar_length - arrow_length;
9
10 std::cout << "\rProgress: [";
11 for (int i = 0; i < arrow_length; ++i) {
12 std::cout << "-";
13 }
14 for (int i = 0; i < spaces; ++i) {
15 std::cout << " ";
16 }
17 std::cout << "] " << progress * 100 << "%" << std::flush;
18}
19
20int main() {
21 int total_iterations = 100;
22 for (int i = 0; i <= total_iterations; ++i) {
23 // 输出其他内容
24 std::cout << "\033[2K\rSome other output " << i << std::endl;
25
26 // 更新并输出进度条
27 print_progress_bar(i, total_iterations);
28 std::cout << std::flush;
29
30 // 模拟任务执行时间
31 std::this_thread::sleep_for(std::chrono::milliseconds(100));
32 }
33
34 // 输出一个换行来防止进度条下一行被覆盖
35 std::cout << std::endl;
36
37 return 0;
38}
生成的最终代码基本满足需求,能够按预期显示进度条。
对于进度条,生成的代码使用 -
表示完成部分, 表示未完成部分,每隔 100ms 刷新一次进度数据。进度条长度固定为 50,依据当前进度和控制条长度,动态计算已完成和未完成字符长度,通过循环打印出来。
而刷新显示,这也是调教时最麻烦的一个,起初生成的代码一直会不断打印控制条,最终告诉它使用 ANSI escape codes 才纠正为 \033[2K\r
。
这个转义码可以分为两部分,\033[2K
和 \r
。后者比较常见,就是将光标移动到当前行的开头,而前者的作用是清除当前光标所在行,其实包含三个参数,意义分别为:
0K
:清除从光标所在位置到行尾的内容;1K
:清除从行首到光标所在位置的内容;2K
:清除整行内容。
组合起来,作用是每次输出用户内容(用户内容最后需要换行,否则最后一行内容可能会被清除)时,先清除最后一行内容,再将光标移至行首,达到的效果就是清除当前进度条并回到行首。如果仅仅将光标移至行首,而不清除当前行,后面打印的内容若是比原有内容(进度条)长度短,便会留下原有内容的残余部分,致界面混乱。
用户的最后一行输出是换行符,生成代码利用 \r
将光标移至行首,再输出进度条,其实完全多余,光标本来就在行首,每次打印的进度条本身就在用户内容的下方。
封装成类,微调代码
ChatGPT 生成的代码,虽然能够达到效果,但最多只有六十分,只是快速实现细节,省些力气而已,仍需要进一步微调。
构建一个 cpp-progress-bar
项目,以 Header-only 的形式添加一个 progress_bar
类,将变化点全部封装起来。
首先,将所有可定制的数据,全部抽离出来。比如控制条长度、数据长度、已完成字符和未完成字符等等。
1class progress_bar {
2public:
3 progress_bar(int total, int bar_length = 50, std::ostream& os = std::cout)
4 : m_bar_length{ bar_length }
5 , m_data_length{ total }
6 , m_done_char{ '#' }
7 , m_undone_char{ '.' }
8 , m_opening_bracket{ '[' }
9 , m_closing_bracket{ ']' }
10 , m_os{ os }
11 , m_desc{ "Progress" }
12 {}
13
14 auto bar_length(int len) -> void {
15 m_bar_length = len;
16 }
17
18 auto bar_length() const -> int {
19 return m_bar_length;
20 }
21
22 // ...
23
24private:
25 int m_bar_length; // 控制条长度
26 int m_data_length; // 数据长度
27 char m_done_char; // 已完成字符
28 char m_undone_char; // 未完成字符
29 char m_opening_bracket; // 开括号
30 char m_closing_bracket; // 闭括号
31 std::ostream& m_os; // 输出流
32 std::string m_desc; // 控制条描述信息
33};
其次,将「清除并回到行首」和「输出控制条」这两部分抽离出来,它们一个在用户内容之前输出,一个在之后输出,于是增加一个 before()
和 after()
接口来表示。
1auto progress_bar::before() const -> void {
2 m_os << "\033[2K\r";
3}
4
5auto progress_bar::after(int cur) const -> void {
6 auto progress = static_cast<float>(cur) / m_data_length;
7 auto finished_length = static_cast<int>(progress * m_bar_length);
8
9 m_os << m_desc << ": " << m_opening_bracket;
10 for (int i = 0; i < finished_length; ++i) {
11 m_os << m_done_char;
12 }
13 for (int i = 0; i < m_bar_length - finished_length; ++i) {
14 m_os << m_undone_char;
15 }
16 m_os << m_closing_bracket << progress * 100 << "%" << std::flush;
17}
最后,以一个 update()
接口来调用以上这两个接口,更新进度条。
1auto progress_bar::update(int cur) const -> void {
2 this->before();
3 this->after(cur);
4}
5
6auto progress_bar::update(int cur, std::invocable<int> auto fn) const -> void {
7 this->before();
8 std::invoke(fn, cur);
9 this->after(cur);
10}
提供两个重载版本,以应对无用户输出时的变化性。
现在,便可以这样使用:
1int main() {
2 int total = 50;
3 cpb::progress_bar progress(total);
4 for (auto i : std::views::iota(0, total)) {
5 // Update the progress bar
6 progress.update(i + 1, [](int v) {
7 std::cout << "Some other output " << v << '\n';
8 });
9 std::this_thread::sleep_for(std::chrono::milliseconds(100));
10 }
11}
这便是一个轻量级库的雏形。
优化实现
雏形已成,但实现方面依旧是 ChatGPT 的实现,尽管只有几行代码,生成的代码还是相当丑陋而低效。到这一步,便要开始替换生成的实现。
也就是说,ChatGPT 生成的代码仅仅是能跑,雏形交给它来快速生成,后续的封装和优化工作则全由自己来做,替换所有低效实现。
当前留下的生成代码只有生成已完成字符和未完成字符,这部分可以使用 C++20 Fomatting Library 进行优化,简化代码。
1auto progress_bar::after(int cur) const -> void {
2 auto progress = static_cast<float>(cur) / m_data_length;
3 auto finished_length = static_cast<int>(progress * m_bar_length);
4
5 auto progress_info = std::format("Progress: [{3:>3}%] [{0:#^{1}}{0:.^{2}}]", "", finish_length, bar_length - finish_length, static_cast<int>(progress * 100));
6 m_os << progress_info << std::flush;
7}
通过优化,核心代码只剩下一行,这就是 fmt 库的强大所在。
但是,格式化时没有动态指定填充字符,这是因为 fmt 暂时不支持这种代码:
1 // "====="
2 std::cout << std::format("{:=^{}}\n", "", 5); // OK
3 std::cout << std::format("{0:{1}^{2}}\n", "", '-', 5); // Error
由于会产生复杂的开销,默认的 std::formatter
并不支持动态指定填充字符。
可以通过定制来手动实现,代码如下:
1namespace cpb {
2
3struct fill {
4 char value;
5 int width;
6};
7
8} // namespace cpb
9
10
11template <>
12struct std::formatter<cpb::fill> {
13 constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); }
14
15 auto format(const cpb::fill& f, auto& ctx) const {
16 return std::fill_n(ctx.out(), f.width, f.value);
17 }
18};
别看仅有几行代码,fmt 的定制存在巨坑。
这种实现是错误的:
1namespace cpb
2{
3struct fill {
4 char value;
5 int width;
6};
7}
8
9template <>
10struct std::formatter<cpb::fill> {
11 constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); }
12
13 auto format(const cpb::fill& f, auto& ctx) {
14 return std::fill_n(ctx.out(), f.width, f.value);
15 }
16};
这种实现也是错误的:
1namespace cpb
2{
3struct fill {
4 char value;
5 int width;
6};
7}
8
9template <>
10struct std::formatter<cpb::fill> {
11 auto parse(format_parse_context& ctx) { return ctx.begin(); }
12
13 auto format(const cpb::fill& f, auto& ctx) const {
14 return std::fill_n(ctx.out(), f.width, f.value);
15 }
16};
这种实现还是错误的:
1namespace cpb
2{
3struct fill {
4 char value;
5 int width;
6};
7}
8
9template <>
10struct std::formatter<cpb::fill> {
11 constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); }
12
13 auto format(const cpb::fill& f, format_parse_context& ctx) const {
14 return std::fill_n(ctx.out(), f.width, f.value);
15 }
16};
这种实现依旧是错误的:
1struct fill {
2 char value;
3 int width;
4};
5
6template <>
7struct std::formatter<fill> {
8 constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); }
9
10 auto format(const fill& f, auto& ctx) const {
11 return std::fill_n(ctx.out(), f.width, f.value);
12 }
13};
尤其是最后一个,因为标准存在 std::fill
,此时特化 std::formatter
中使用的 fill
是 std::fill
,而不是自己定义的 fill
,所以必须将其置入命名空间内。
由此也可见,基于特化的这种定制方式非常不友好,出现错误较为莫名其妙。
有了这个定制,便可以使用 fill
来进一步简化原有实现:
1auto after(int cur) const -> void {
2 auto progress = static_cast<float>(cur) / m_data_length;
3 auto finished_length = static_cast<int>(progress * m_bar_length);
4
5 auto progress_info = std::format("{}: [{:>3}%] {}{}{}{}", m_desc, static_cast<int>(progress * 100), m_opening_bracket, fill{m_done_char, finished_length}, fill{m_undone_char, m_bar_length - finished_length}, m_closing_bracket);
6 m_os << progress_info << std::flush;
7}
现在这个实现便精简多了。
AOP 优化
当前接口存在 before()
和 after()
,这不正是 AOP 要解决的问题,我们几年前也使用 C++20 实现过一个轻量级的 AOP 库,此处可以基此稍微扩展一下,满足当前需求。
于是实现进一步简化为:
1auto update(int cur) const -> void {
2 aopcxx::make_aspect<progress_bar>(this, cur);
3}
4
5auto update(int cur, std::invocable<int> auto fn) const -> void {
6 aopcxx::make_aspect<progress_bar>(this, fn, cur);
7}
原理几年前便已讲过,扩展的源码可以自行去看。
示例
至此,三两下库已成型,可以这样使用:
1int main() {
2 int total = 50;
3 cpb::progress_bar progress(total);
4 for (auto i : std::views::iota(0, total)) {
5 // Update the progress bar
6 progress.update(i + 1, [](int v) {
7 std::cout << "Some other output " << v << '\n';
8 });
9 std::this_thread::sleep_for(std::chrono::milliseconds(100));
10 }
11
12 std::cout << "\n";
13 progress.done_char('X');
14 progress.undone_char('-');
15 for (auto b : std::views::iota(0, total)) {
16 progress.update(b + 1);
17 std::this_thread::sleep_for(std::chrono::milliseconds(100));
18 }
19}
如果想亲自测试一下代码或查看源码,可以通过以下指令:
1git clone https://github.com/lkimuk/cpp-progress-bar.git
2mkdir build && cd build
3cmake ..
4make
5./test
总结
思路想法确定,借助 ChatGPT 快速生成代码雏形,能够加快开发速度,让你避开细枝末节,快速让目标运行起来。
在此基础上,自己只需专注代码优化,将精力放在核心功能上。
尽管后期可能会替换掉 AI 生成的所有实现,但也要事半功倍。先快速让程序跑起来,再去优化局部细节,比完全从局部细节构建起整体结构,要高效得多。
大家可以尝试使用起来。