19 其他重要标准库特性实战:利用日历应用熟悉新特性
你好,我是卢誉声。
我们想要提升C++的编程效率,就需要对重要标准库的变更保持关注。在第18讲已经涵盖了绝大多数C++20带来的重要库变更。不过,我当时有意忽略了其中一个,就是我们今天的主角——C++20 Calendar、Timzone。
它们是对现有chrono库的重要补充。Calendar提供了日历的表示与计算工具。而Timezone提供了标准的时区定义,可以构建包含时区信息的zoned_time。
今天,我会围绕C++20 Calendar、Timzone带你进行编程实战,并结合上一讲涵盖的特性:jthread、source location、sync stream和u8string,实现一个使用新标准实现的日历程序。
哦,对了,我们还会在这一讲中使用C++ 20 Formatting库,帮你进一步加深对这个特性的理解。好,话不多说,就让我们从模块设计开始今天的内容(课程配套代码可以从这里获取)。
模块设计
我们准备构建的命令行日历应用,具备以下特性。
- 使用C++20 chrono:支持显示本月日历,显示日期和星期信息。
- 使用u8string:支持导出本年的全年日历到文本文件,编码为UTF-8。
我们依然采用传统C++模块结构设计,整体模块设计如下图所示。
该工程包含两个子项目,一个是可执行文件calendarpp,另一个是静态链接库logging。从图中可以看到,calendarpp的入口是main.cpp,其他实现都在calendarpp模块下,包括后面这几个模块。
- menu:主菜单实现,包括菜单定义与菜单交互。
- actions:各个菜单项的具体实现,完成具体的功能。
- utils:具体的底层实现模块,包括日历计算模块calendar、文本渲染模块render和输入输出模块io。
我们沿用了第15讲中的实现,即日志框架来构建并包装成了logging库,并利用第18讲中讲过的部分特性进行了改造。沿着这条路线,我们先从改造日志框架开始看。
改造日志框架
日志框架的所有代码文件都在projects/logging下,你可以结合代码来理解如何集成日志框架,并用它来记录系统运行日志。我们来看看,使用C++20的特性,可以在原有的框架基础上做出哪些改造。
之前的Handler在多线程场景下使用时,因为没有采用线程同步方案,导致输出时可能会产生错乱的问题,因此我们改造了Handler。
比如DefaultHandler的实现改造是后面这样。
#pragma once
#include "logging/Handler.h"
#include <syncstream>
namespace logging::handlers {
// 默认日志处理器
template <Level HandlerLevel = Level::Warning>
// 继承BaseHandler
class DefaultHandler : public BaseHandler<HandlerLevel> {
public:
// 构造函数,需要指定格式化器,默认格式化器为defaultFormatter
DefaultHandler(Formatter formatter = defaultFormatter) : BaseHandler<HandlerLevel>(formatter) {}
// 禁止拷贝构造函数
DefaultHandler(const DefaultHandler&) = delete;
// 定义移动构造函数
DefaultHandler(const DefaultHandler&& rhs) noexcept : BaseHandler<HandlerLevel>(rhs.getForamtter()) {}
// emit用于提交日志记录
// emitLevel > HandlerLevel的日志会被丢弃
template <Level emitLevel>
requires (emitLevel > HandlerLevel)
void emit(const Record& record) {
}
// emitLevel <= HandlerLevel的日志会被输出到标准输出流中
template <Level emitLevel>
requires (emitLevel <= HandlerLevel)
void emit(const Record& record) {
// 调用format将日志记录对象格式化成文本字符串
std::osyncstream(std::cout) << this->format(record) << std::endl;
}
};
}
代码中的改动在第31行,通过 std::osyncstream 包装了std::cout,然后通过包装后的输出流进行输出,这样就能完成输出的线程同步控制(详细讲解,你可以回顾上一讲 “sync stream” 这部分)。
日志框架的第二个改动在于,它通过source location记录了输出日志的源代码位置信息。实现在Record.h下,后面展示的是重点代码。
class Record {
public:
// Logger名称
std::string name;
// 日志等级
Level level;
// 日志时间
TimePoint time;
// 日志消息
std::string message;
std::source_location sourceLocation;
// getLevelName:获取日志等级文本
const std::string& getLevelName() const {
// 调用toLevelName获取日志等级文本
return toLevelName(level);
}
};
通过代码可以看到,Record定义增加了sourceLocation用于记录源代码位置。
接着,我们还要修改Logger定义,增加记录源代码位置信息的功能。具体实现在Logger.h下,我们将log的定义改为以下形式。
template <Level level>
requires (level > loggerLevel)
Logger& log(const std::string& message, std::source_location sourceLocation = std::source_location::current()) {
return *this;
}
// 通过requires约束提交等级为日志记录器设定等级及以上的日志
template <Level level>
requires (level <= loggerLevel)
Logger & log(const std::string& message, std::source_location sourceLocation = std::source_location::current()) {
// 构造Record对象
Record record{
.name = _name,
.level = level,
.time = std::chrono::system_clock::now(),
.message = message,
// 记录源代码位置
.sourceLocation = sourceLocation
};
// 调用handleLog实际处理日志输出
handleLog<level, HandlerCount - 1>(record);
return *this;
}
可以看到,我增加了source_location定义,并通过默认参数自动记录调用者的所在位置,调用者也可以自己指定需要记录的source_location。
我们同时修改了几个级别的相关接口,比如debug成员函数改造。
// 提交调试信息(log的包装)
Logger& debug(const std::string& message, std::source_location sourceLocation = std::source_location::current()) {
return log<Level::Debug>(message, sourceLocation);
}
其他的几个成员函数都和debug一样,这里就不展示出来了。
最后,我还修改了format函数输出sourceLocation中的信息。具体实现,我们以ModernFormatter.cpp的修改为例来看看。
#include "logging/formatters/ModernFormatter.h"
#include "logging/Record.h"
namespace logging::formatters::modern {
// formatRecord:将Record对象格式化为字符串
std::string formatRecord(const Record& record) {
const auto& sourceLocation = record.sourceLocation;
try {
return std::format(
"{0:<16}| [{1}] {2:%Y-%m-%d}T{2:%H:%M:%OS}Z - <{3}:{4} [{5}]> - {6}",
record.name,
record.getLevelName(),
record.time,
sourceLocation.file_name(),
sourceLocation.line(),
sourceLocation.function_name(),
record.message
);
}
catch (std::exception& e) {
std::cerr << "Error in format: " << e.what() << std::endl;
return "";
}
}
}
代码中format的部分加入了sourceLocation信息,其他部分相较于之前则没有变化。
主程序与菜单
在了解了日志框架的改造后,我们继续看如何通过C++20 Formatting库实现主程序和菜单。
主程序定义非常简单,在main.cpp中,代码是后面这样。
#include "menu/Menu.h"
#include <iostream>
#include <format>
int main() {
while (true) {
// 展示菜单
calendarpp::menu::showMenu();
// 读取并执行菜单项
calendarpp::menu::readAction();
}
return 0;
}
代码整体是一个while循环,首先展示菜单,然后读取用户输入并执行菜单项。所以只有用户选择退出程序时,整个程序才会退出。
菜单实现在menu/Menu.cpp中,代码是后面这样。
#include "menu/Menu.h"
#include "actions/ShowAction.h"
#include "actions/ExportAction.h"
#include "actions/ExitAction.h"
#include "utils/RenderUtils.h"
#include <iostream>
#include <string>
#include <vector>
#include <cstdint>
#include <format>
#include <algorithm>
namespace calendarpp::menu {
// 菜单项类型
struct MenuItem {
std::string title;
Action action;
};
// 所有菜单
static const std::vector<MenuItem> MenuItems = {
std::vector<MenuItem> {
{
.title = "展示本月日历",
.action = actions::showCurrentMonth
},
{
.title = "导出本年日历",
.action = actions::exportCurrentYear
},
{
.title = "退出程序",
.action = actions::exitApp
}
}
};
// 菜单选项范围
static const int32_t MinActionNumber = 1;
static const int32_t MaxActionNumber = MenuItems.size();
// 展示菜单
void showMenu() {
// 输出系统标题
std::cout << std::format("\n{:=^80}\n", " Calendar++ v1.0 ") << std::endl;
// 输出当前时间与本地时区信息
std::cout << utils::renderNow() << std::endl;
// 输出所有菜单
std::cout << std::format("{:*^80}\n", " MENU ");
std::cout << std::format("*{: ^78}*\n", "");
std::int32_t menuIndex = 0;
for (const auto& menuItem : MenuItems) {
menuIndex += 1;
// 输出菜单序号与菜单名称
std::string menuLine = std::format("({}) {}", menuIndex, menuItem.title);
std::cout << std::format("* {: <76} *", menuLine) << std::endl;
}
std::cout << std::format("*{: ^78}*\n", "");
std::cout << std::format("{:*^80}\n", "");
// 提示用户输入菜单编号
std::cout << std::format("\n请输入菜单编号({}-{}):", 1, menuIndex);
}
// 读取用户输入并执行动作
void readAction() {
std::string actionNumberString;
// 读取用户输入
std::getline(std::cin, actionNumberString);
try {
// 解析用户输入
int32_t actionNumber = std::stoi(actionNumberString);
if (actionNumber < MinActionNumber || actionNumber > MaxActionNumber) {
std::cerr << std::format("菜单编号超出范围({}-{})\n", MinActionNumber, MaxActionNumber);
}
// 执行相应菜单项的action
int32_t actionIndex = std::max(actionNumber - 1, 0);
const auto& action = MenuItems[actionIndex].action;
action();
return;
}
catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
}
std::cerr << "无法识别用户输入" << std::endl;
}
}
这段代码中值得注意的部分是 showMenu函数,它用于展示菜单。该函数输出标题后,会遍历所有的MenuItems并输出菜单内容。发现了么?这段代码通过format完成了大量的文本格式化工作。
最后我们还定义了readAction函数,用于读取用户输入的菜单编号并执行对应的菜单动作。这里使用了C++11引入的std::stoi函数将字符串转换成对应的整型数字。
菜单的展示效果是后面这样。
需要注意的是,本项目代码采用了UTF-8编码,因此如果你在Windows下执行,需要在支持UTF-8编码输出的控制台下使用(比如MinGW的bash)。
展示日历
接下来,我们看一下该如何展示本月日历。在actions中定义了菜单项的函数实现,代码实现在src/actions/ShowAction.cpp中。
#include "actions/ShowAction.h"
#include "utils/RenderUtils.h"
#include <chrono>
#include <iostream>
#include <format>
namespace chrono = std::chrono;
namespace calendarpp::actions {
void showCurrentMonth() {
// 获取当前时间
chrono::time_point now{ chrono::system_clock::now() };
// 将时间转换为year_month_day
chrono::year_month_day ymd{ chrono::floor<chrono::days>(now) };
// 获取当前年月(类型为year_month)
chrono::year_month currentYearMonth = ymd.year() / ymd.month();
// 调用渲染模块渲染当月日历
std::cout << std::endl;
std::cout << utils::renderMonth(currentYearMonth);
}
}
代码中定义了showCurrentMonth函数,该函数首先调用了chrono的Calendar获取当日所在的日期和月份。
Calendar是C++20引入到chrono中新的库特性,提供了标准的格里高利历(Gregorian calendar)实现。
这里简单介绍一下year_month_day,用于描述一个完整日期(年月日),该类型支持多种构造方法,这里使用chrono::system_clock::now()获取系统本地时间,然后采用chrono::floor<chrono::days>将时间转换为以日为单位的时间,最后调用year_month_day得到当日的日期。
year_month_day支持通过year和month成员函数获取当日的年份与月份,代码17行就调用了year / month这种形式构造了一个year_month对象,表示某年的某个月,这里最后得到的就是本日所在的当月。/是Calendar的一个操作符重载,是创建日期类型对象的一个语法糖。
代码最后调用renderMonth渲染了当月日历并将其输出到控制台,该函数定义在src/utils/RenderUtils.cpp中,属于渲染模块,我们分析一下相关代码。
首先定义了Weekdays常量,包含了周一到周日的所有星期几的定义。
// 定义一周七天的weekday常量
static std::vector<chrono::weekday> Weekdays = {
chrono::Monday,
chrono::Tuesday,
chrono::Wednesday,
chrono::Thursday,
chrono::Friday,
chrono::Saturday,
chrono::Sunday,
};
该数组的元素类型为chrono::weekday,这是chrono的标准类型,用来表示周几,其中Monday到Sunday都是chrono定义的常量,表示周一到周日。
// 渲染某个月份的日历,返回string
std::string renderMonth(chrono::year_month yearMonth) {
std::ostringstream os;
// 获取当月的所有周
auto monthWeeks = utils::buildMonthWeeks(yearMonth);
// 获取当月第一天
const auto firstDay = yearMonth / 1d;
// 获取当月最后一天
const auto lastDay = chrono::year_month_day(yearMonth / chrono::last);
// 输出格式化的标题(年份与月份)
std::string titleLine = std::format("** {:%Y-%m} **", yearMonth);
os << std::format("{:^35}", titleLine) << std::endl;
os << std::format("{:->35}", "") << std::endl;
// 输出日历表头(从周一到周日)
std::vector<std::string> headerLineParts;
for (const auto& weekday : Weekdays) {
headerLineParts.push_back(std::format(ZhCNLocale, "{:L%a}", weekday));
}
// 利用renderWeekLine生成格式化的表头(控制7个元素的位置与宽度)
std::string headerLine = renderWeekLine(headerLineParts);
os << headerLine << std::endl;
os << std::format("{:->35}", "") << std::endl;
// 遍历monthWeeks,调用renderWeek生成日历中的每一行
for (const auto& currentWeek : monthWeeks) {
std::string weekLine = renderWeek(currentWeek, yearMonth);
os << weekLine << std::endl;
}
// 返回渲染的字符串
return os.str();
}
// 渲染日历中的某一周
std::string renderWeek(const std::vector<chrono::year_month_day> week, chrono::year_month yearMonth) {
// 获取当月第一天
const auto firstDay = yearMonth / 1d;
// 获取当月最后一天
const auto lastDay = chrono::year_month_day(yearMonth / chrono::last);
// 生成本周的所有日期
std::vector<std::string> weekLine;
for (const auto& currentDay : week) {
std::string inCurrentMonthFlag = currentDay >= firstDay && currentDay <= lastDay ? "*" : "";
weekLine.push_back(std::format("{}{:>2}", inCurrentMonthFlag, currentDay.day()));
}
// 利用renderWeekLine生成格式化的本周日期
return renderWeekLine(weekLine);
}
// 生成某一周的格式化输出
std::string renderWeekLine(const std::vector<std::string>& weekLine) {
std::string renderResult;
for (const auto& weekLineItem : weekLine) {
// 所有内容按照宽度为4右对齐
renderResult.append(std::format("{:>4} ", weekLineItem));
}
return renderResult;
}
代码中的注释已经比较详细了,所以后面我们只讨论一些重点实现。
代码第5行,作用是获取当月的所有周的数组,utils::buildMonthWeeks属于日历计算模块,我们后面详细解释其实现。
在代码第8行,用yearMonth / 1d生成当月的第一天,返回的类型就是year_month_day。其中1d是一个自定义文字量,定义在名称空间std::literals::chrono_literals中,相当于chrono::days(1)的语法糖,表示某个月1号。yearMonth / 1d中的/是yearMonth的操作符重载,表示当月第一天。
在代码第10行,用yearMonth / chrono::last表示当月最后一天。其中,chrono::last是C++20中引入的,用来表示一个时间序列末尾的标记。最后,我们调用chrono::year_month_day转换成year_month_day。
代码第18到25行,调用了renderWeekLine帮助我们生成格式化的标题,因为输出的日历需要将一周七天按照一定的布局输出到控制台上,该函数可以帮助我们完成渲染布局。代码第20行利用了locale输出中文,有兴趣可以自己看RenderUtils.cpp中的定义以及locale相关内容。
在代码第28到31行,生成日历的内容。由于日历里每周输出到一行中,因此这里遍历当月所有周,调用renderWeek完成一周的布局输出。
renderWeekLine函数实现也会比较简单,输入参数是一个包含N个字符串的数组,数组元素就是在日历中的每一个格子中需要输出的内容(比如表头的周几或者日期),这里主要通过format将每一格内容的输出宽度限制在4,确保布局工整。
渲染出来的效果如下图所示。
最后,我们看一下utils::buildMonthWeeks的实现,代码实现在src/utils/CalendarUtils.cpp中。
#include "utils/CalendarUtils.h"
#include <format>
namespace chrono = std::chrono;
using namespace std::literals::chrono_literals;
static uint32_t MaxWeekdayIndex = 6;
namespace calendarpp::utils {
std::vector<std::vector<std::chrono::year_month_day>> buildMonthWeeks(std::chrono::year_month yearMonth) {
// 获取当月第一天
const auto firstDay = yearMonth / 1d;
// 获取当月最后一天
const auto lastDay = chrono::year_month_day(yearMonth / chrono::last);
std::vector<std::vector<chrono::year_month_day>> monthWeeks;
// 将当月第一天设定为当日
auto currentDay = firstDay;
// 当每周当日超出当月最后一天时中止循环
while (currentDay <= lastDay) {
// 每次循环都计算出当日所在周的7天(周一到周日)
std::vector<chrono::year_month_day> currentMonthWeek;
// 通过weekday获取某一天是周几
auto currentWeekday = chrono::weekday{ std::chrono::sys_days{ currentDay } };
// 通过iso_encoding获取周几的编码(1-7)
auto currentWeekdayIndex = currentWeekday.iso_encoding() - 1;
// 计算本周第一天
auto firstDayOfWeek = chrono::year_month_day{
std::chrono::sys_days{ currentDay } - chrono::days(currentWeekdayIndex)
};
currentDay = firstDayOfWeek;
// 计算出本周的所有日期并添加到currentMonthWeek中
for (uint32_t weekdayIndex = 0; weekdayIndex <= MaxWeekdayIndex; ++weekdayIndex) {
currentMonthWeek.push_back(currentDay);
currentDay = chrono::year_month_day{
std::chrono::sys_days{ currentDay } + chrono::days(1)
};
}
// 将计算好的当前周添加到monthWeeks中
monthWeeks.push_back(currentMonthWeek);
}
return monthWeeks;
}
}
在这段代码中,我们会生成当月的所有周,通过Calendar完成了大量的日期计算,相比自己实现格里高利历要方便不少。
导出日历
除了展示日历,我们继续讨论序列化日历的实现。在src/actions/ExportAction.cpp中定义了导出菜单项的代码实现。
#include "actions/ExportAction.h"
#include "utils/RenderUtils.h"
#include "utils/IOUtils.h"
#include <chrono>
#include <iostream>
#include <string>
namespace chrono = std::chrono;
namespace calendarpp::actions {
void exportCurrentYear() {
// 获取当前时间
chrono::time_point now{ chrono::system_clock::now() };
// 将时间转换为year_month_day
chrono::year_month_day ymd{ chrono::floor<chrono::days>(now) };
// 获取当前年份
chrono::year currentYear = ymd.year();
// 提示并读取用户输入
std::cout << "请输入导出文件路径: ";
std::string filePath;
std::getline(std::cin, filePath);
// 调用IO模块将renderYear的渲染结果输出到文件中
utils::writeFile(filePath, utils::renderYear(currentYear));
std::cout << std::format("已将日历导出到文件中: {}", filePath) << std::endl;
std::cout << std::endl;
}
}
这段代码非常简单,核心是通过renderYear渲染当年的日历,然后通过writeFile以UTF-8编码写入到文件中。
函数renderYear实现在src/utils/RenderUtils.cpp中,相关代码如下所示。
// 定义一年12个月的month常量
static std::vector<chrono::month> Months = {
chrono::January,
chrono::February,
chrono::March,
chrono::April,
chrono::May,
chrono::June,
chrono::July,
chrono::August,
chrono::September,
chrono::October,
chrono::November,
chrono::December,
};
std::string renderYear(std::chrono::year year) {
std::ostringstream os;
// 输出格式化的标题(年份)
std::string titleLine = std::format("**** {:%Y}年 ****", year);
os << std::format("{:^35}", titleLine) << std::endl;
os << std::format("{:=^35}\n", "") << std::endl;
// 调用renderYearMonths并行生成12个月的日历
std::vector<std::string> renderedMonths(Months.size(), "");
renderYearMonths(renderedMonths, year);
// 将12个月的日历输入到输出流中
for (const std::string& renderedMonth : renderedMonths) {
os << renderedMonth;
os << std::endl;
}
// 返回渲染好的字符串
return os.str();
}
static void renderYearMonths(std::vector<std::string>& renderedMonths, chrono::year year) {
std::vector<std::jthread> renderThreads;
int32_t monthIndex = 0;
for (const auto& currentMonth : Months) {
auto currentYearMonth = year / currentMonth;
auto& renderedMonth = renderedMonths[monthIndex];
// 创建jthread对象,开启计算线程,每个线程负责生成一个月的日历
renderThreads.push_back(std::jthread([currentYearMonth, &renderedMonth] {
renderedMonth = renderMonth(currentYearMonth);
}));
++monthIndex;
}
// 退出函数时,所有jthread对象会自动等待计算完成然后退出
}
我们重点看一下renderYearMonths函数的实现。
该函数定义了一个jthread数组,然后循环创建了12个线程,用于分别调用renderMonth渲染各自月份的日历。由于jthread会在析构时自动等待本线程完成。因此,我们并不需要去join这些线程,在退出renderYearMonths之前,所有的工作线程肯定能结束任务。
最后看一下writeFile的实现,代码在src/utils/IOUtils.cpp中。
#include "utils/IOUtils.h"
#include "Logger.h"
#include <filesystem>
#include <format>
#include <iostream>
namespace fs = std::filesystem;
namespace calendarpp::utils {
void writeFile(const std::string& filePath, const std::string& fileContent) {
auto& logger = getLogger();
// 检测文件是否存在
if (fs::exists(filePath)) {
logger.warning(std::format("Override existed file: {}", filePath));
}
// 由于源代码使用UTF-8,生成的字符串就是UTF-8,因此可以直接强制类型转换构建u8string
std::u8string utf8FileContent(reinterpret_cast<const char8_t*>(fileContent.c_str()), fileContent.size());
// 将u8string以二进制形式写入到文件中
std::ofstream outputFile(filePath, std::ios::binary);
outputFile.write(reinterpret_cast<char*>(utf8FileContent.data()), utf8FileContent.size());
}
}
在这段代码中,首先通过文件系统库中的std::filsystem::exists检测文件是否存在,如果存在则提示用户会覆盖原有文件。
接着,将字符串转换成u8string,由于源代码使用UTF-8,生成的字符串就是UTF-8。因此,我们可以直接强制类型转换构建u8string。
最后,将u8string的data强制类型转换为char*,并通过二进制形式写入到文件中。
总结
在这一讲中,我们通过一个工程,串讲了一系列C++20带来的重要库变更。我们在原有的日志框架基础上,追加了source_location,用于记录打印日志时代码的所在位置。除此之外,将传统的输出流替换成sync stream,就可以轻松实现输出的线程同步控制。
在实现的过程中,我们还使用了TimeZone获取带时区的本地时间,并通过使用Calendar完成日历计算。这些针对chrono的标准库补充,大大降低了时间处理的复杂度。
最后,我们利用u8string,轻松实现了UTF-8编码的文本导出。
通过这些案例,我们可以感受到,现代 C++的库变更建立在新的核心语言特性基础上,为我们日常编程工作提供了极大的便利。避免造轮子,提升编程效率——这些对于库的核心变更来说是核心议题,所以我们应该保持对标准库演进的持续关注。
课后思考
现在日历中显示的时间是包含时区信息的本地时间,如果我们希望日历中显示的当前时间是UTC时间(日历显示依然按照本地时间计算,不需要变化),我们要对代码做什么改动?
欢迎给出你的方案,与大家一起分享。我们一同交流。下一讲见!
- 李云龙 👍(1) 💬(1)
把本地时间的显示换成UTC时间,只需修改RenderUtils.cpp中的 renderNow() 函数:把chrono::system_clock::now() 替换为 auto utcNow = std::chrono::utc_clock::now(); 同时把时区信息去除。
2024-01-28 - 三笑三引伏兵 👍(0) 💬(2)
osyncstream好像是在调用emit()和析构时才会将自身缓冲区的内容传输给被包装的缓冲 在日志模块中的话不会导致缓冲区一直增长吗 一般怎么解决呢
2024-12-07 - peter 👍(0) 💬(1)
C++20之前没有Calendar吗? 文中“Calendar 是 C++20 引入到 chrono 中新的库特性”,之前没有Calendar吗?有点小怀疑。如果没有,以前是怎么处理日历问题的?
2023-03-07