C++控制台电子时钟实战:从时间处理到界面绘制的完整项目指南
1. 项目概述从零打造一个控制台数字时钟很多C初学者在学完基础语法后常常会陷入一个迷茫期书本上的例题都会了但感觉离“做出一个东西”还很远。这时候一个目标明确、功能完整的小型实战项目就是最好的突破口。电子数字时钟正是这样一个经典且极具价值的练手项目。它不像“学生管理系统”那样业务逻辑复杂也不像游戏项目那样需要庞大的图形库支持但它却能让你系统地串联起C中的多个核心知识点。这个项目要做的就是一个在命令行控制台里运行的、能够实时显示当前时分秒的电子数字时钟。别小看这个简单的目标为了实现它你需要处理系统时间获取、控制台光标定位、循环刷新、用户输入监听等多个环节。整个过程就像搭积木你会清晰地看到每一行代码如何转化为屏幕上跳动的数字这种即时的反馈感和成就感是理论学习无法比拟的。无论你是刚学完C基础、想要巩固提升的在校学生还是希望重温底层编程乐趣的开发者这个项目都能让你对程序如何与操作系统交互、如何管理程序流程有一个直观而深刻的理解。2. 项目核心设计与思路拆解2.1 为什么选择控制台而非图形界面在开始动手之前第一个要做的决策就是表现形式。我们选择基于控制台Console来实现而不是Qt、MFC等图形界面库这背后有非常实际的考量。首要原因是聚焦核心逻辑。图形界面库的学习成本不低涉及窗口创建、事件循环、控件布局等一整套新概念。对于一个旨在巩固C核心技能如时间处理、循环控制、I/O操作的入门项目引入GUI会分散大量精力让你从“学习C编程”偏转到“学习某个GUI框架的使用”。控制台程序简洁直接所有代码都围绕业务逻辑本身更能让你看清程序的本质。其次是跨平台与便捷性。标准C的控制台输出std::cout和相关的系统时间库如chrono,ctime具有很好的可移植性。我们稍加注意就能让代码在Windows、Linux和macOS上都能编译运行。这对于理解不同平台下编程的细微差别很有帮助。最后是实现的挑战与趣味。在固定的字符窗口里“画”出一个动态更新的时钟需要用到一些控制台控制技巧比如清屏、光标定位等。解决这些挑战本身就是极好的学习过程。2.2 核心功能模块分解一个能用的时钟至少需要三大功能模块时间获取、显示渲染和交互控制。而一个“好”的时钟则需要在细节上多做考量。时间获取与更新模块这是时钟的心脏。我们需要一个可靠的时间源并且能以秒为单位甚至更精确自动更新。C11引入的chrono库是现代、高精度且类型安全的首选。我们需要设计一个循环在这个循环中不断获取最新的系统时间并将其分解为时、分、秒。屏幕显示与渲染模块这是时钟的脸面。在控制台中我们需要将数字时间如 14:05:30以清晰、美观的形式呈现出来。这涉及到数字的图形化是用简单的字符如|、_拼出大型数字还是直接输出数字字符前者视觉效果更接近真实的电子钟后者实现简单。我们将实现一种兼顾美观与复杂度的“点阵”字符画风格。动态刷新不能让时间数字在原地不断覆盖打印那样会产生闪烁。我们需要控制光标只更新变化了的数字位或者使用“双缓冲”思想先组织好完整的字符串再一次性输出。界面布局除了时间可能还需要显示日期、星期以及一个简洁的界面边框。用户交互与控制模块这是时钟的神经。一个基本的时钟至少需要启动、显示和退出。我们可以增加一些增强交互例如暂停/继续暂停时间的更新显示。整点报时在整点时如12:00给出一个视觉或声音提示控制台蜂鸣。退出机制通过监听键盘按键如按ESC或Q键来优雅地结束程序而不是强制关闭控制台窗口。2.3 技术选型与工具准备工欲善其事必先利其器。以下是完成本项目所需的核心技术和工具核心语言与标准C11及以上。我们将大量使用chrono进行高精度时间测量和睡眠控制使用thread来管理可能的并发任务尽管简单循环也可以并使用iomanip来格式化输出。开发环境IDEWindows平台推荐使用Visual Studio 2022。它提供了对C标准极好的支持集成的MSVC编译器成熟稳定。社区版Community完全免费且功能强大。创建项目时选择“控制台应用”模板即可。Linux/macOS平台推荐使用VSCode CMake GCC/Clang的组合。VSCode轻量且插件丰富CMake管理项目构建GCC或Clang作为编译器。这是跨平台开发的通用选择。关键库函数chrono用于获取系统时间(system_clock)、实现精准延时(this_thread::sleep_for)。ctime其localtime函数可以将时间戳方便地转换为包含时、分、秒的结构体虽然C风格但很实用常与chrono结合使用。iomanip其中的setw、setfill用于控制输出宽度和填充字符是美化输出的利器。平台相关控制台操作这是实现流畅显示的关键。Windows下我们需要windows.h中的SetConsoleCursorPosition、GetStdHandle等函数来控制光标Linux/macOS下则可以使用ANSI转义序列如\033[2J清屏\033[H移动光标到左上角。注意在项目初期建议先专注于核心逻辑使用最简单的std::cout和std::endl来输出时间。等到时间获取和更新循环稳定后再引入平台相关的控制台美化操作。这样可以分阶段解决问题降低调试难度。3. 核心细节解析与实操要点3.1 高精度时间获取与格式化获取时间是第一步但如何高效、准确地获取并格式化里面有不少门道。传统C风格方法 (ctime)#include ctime #include iostream int main() { std::time_t now std::time(nullptr); // 获取当前时间戳 std::tm* local_time std::localtime(now); // 转换为本地时间结构体 int hour local_time-tm_hour; int minute local_time-tm_min; int second local_time-tm_sec; std::cout 当前时间: hour : minute : second std::endl; return 0; }这种方法简单直接但std::time()精度通常只到秒且std::localtime()不是线程安全的不过在单线程时钟程序里没问题。现代C风格方法 (chrono)#include chrono #include ctime #include iostream #include iomanip int main() { // 获取当前系统时间点高精度 auto now std::chrono::system_clock::now(); // 转换为 time_t与C接口兼容 std::time_t now_time_t std::chrono::system_clock::to_time_t(now); // 转换为本地时间结构体 std::tm* local_time std::localtime(now_time_t); // 使用 iomanip 进行格式化输出 std::cout 当前时间: std::setw(2) std::setfill(0) local_time-tm_hour : std::setw(2) std::setfill(0) local_time-tm_min : std::setw(2) std::setfill(0) local_time-tm_sec std::endl; // 额外获取毫秒部分展示chrono的高精度能力 auto since_epoch now.time_since_epoch(); auto seconds std::chrono::duration_caststd::chrono::seconds(since_epoch); since_epoch - seconds; auto milliseconds std::chrono::duration_caststd::chrono::milliseconds(since_epoch); std::cout 毫秒: milliseconds.count() std::endl; return 0; }这里我们采用了“混合策略”用chrono获取高精度时间点再转换为time_t以便使用熟悉的localtime函数进行分解。std::setw(2)和std::setfill(0)确保了时分秒总是以两位数字显示如09:05:03这是电子钟的标配格式。实操心得在实际的时钟循环中我们不需要每次循环都获取毫秒。获取一次完整时间分解出时、分、秒即可。chrono的真正优势在于其duration和time_point的类型安全以及用于延时的sleep_for函数比传统的sleep函数更精确、更可移植。3.2 控制台“图形”界面绘制原理在字符界面绘制一个看起来像电子数码管的时钟是项目的趣味所在。我们采用一种“点阵”思想用字符数组来预定义数字0-9的图形。数字的字符表示我们可以定义一个5行 x 3列或更大的网格来表示一个数字。例如数字0可以表示为### # # # # # # ###这里用#表示点亮的部分用空格表示熄灭。我们可以用字符串数组来存储这些模式。绘制逻辑存储模式定义一个二维数组digitPatterns[10][5]每个元素是一个长度为4的字符串3个字符1个字符串结束符\0存储0-9每个数字的5行图形。解析时间将当前时间的时、分、秒每个数字位例如14:05:30分解为1,4,0,5,3,0提取出来。逐行绘制对于屏幕的每一行共5行依次绘制小时十位、小时个位、冒号、分钟十位……的对应行字符串并在中间插入空格或分隔符。动态更新比较本次和上次的时间字符串。如果某个数字位发生了变化则只重绘该数字位所在的屏幕区域而不是清屏重绘全部这样可以有效避免闪烁。平台相关的光标控制为了实现局部更新我们必须控制光标的位置。Windows API:#include windows.h void gotoxy(int x, int y) { COORD coord { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } // 使用gotoxy(10, 5); 将光标移动到第5行第10列Linux/macOS (ANSI Escape Codes):void gotoxy(int x, int y) { // \033[y;xH 将光标移动到第y行第x列注意行列序 std::cout \033[ y ; x H; }注意ANSI转义序列在现代Windows 10/11的终端如Windows Terminal, PowerShell中也得到了较好支持尤其是在启用VTVirtual Terminal模式后。为了代码的跨平台性你可以写一个包装函数在编译时通过预定义宏如_WIN32来切换不同的实现。3.3 主循环与时间同步机制时钟的核心是一个无限循环在这个循环里获取时间 - 刷新显示 - 等待一段时间 - 继续。一个朴素的有缺陷的循环while (true) { displayCurrentTime(); // 显示时间 std::this_thread::sleep_for(std::chrono::seconds(1)); // 睡眠1秒 }这个循环的问题在于displayCurrentTime()函数执行也需要时间尽管很短。每次循环实际上是“执行时间 1秒睡眠”。运行一段时间后时钟会慢慢漂移越来越不准。更精确的同步策略我们需要以“绝对时间”为基准进行同步。#include chrono #include thread auto nextWakeTime std::chrono::steady_clock::now(); // 使用单调时钟 while (true) { // 1. 执行核心任务 displayCurrentTime(); // 2. 计算下一次应该唤醒的绝对时间点 nextWakeTime std::chrono::seconds(1); // 3. 精确睡眠到下一个时间点 std::this_thread::sleep_until(nextWakeTime); }这里使用了std::chrono::steady_clock它是一个单调时钟不受系统时间调整的影响非常适合用于测量时间间隔。sleep_until保证了无论displayCurrentTime花了多少时间只要不超过1秒下一次显示都会在整秒时刻发生从而保证了时钟的长期准确性。实操心得对于电子时钟秒级的精度用sleep_until已经足够。如果你要实现一个秒表或需要更高精度的计时则需要考虑sleep函数本身的精度误差和调度延迟可能需要采用忙等待busy-wait或高精度定时器但那已超出本项目范围。sleep_until方案在绝大多数系统上都能提供视觉上无感知的精确度。4. 分步实现从零构建电子数字时钟4.1 步骤一搭建项目框架与时间获取测试首先我们创建一个纯净的C控制台项目。在main.cpp中我们先实现最基础的时间获取和打印功能确保核心链路是通的。// main.cpp - 第一阶段测试时间获取 #include iostream #include iomanip #include chrono #include ctime int main() { std::cout 电子数字时钟 - 基础测试 std::endl; std::cout std::endl; // 主循环 while (true) { // 获取当前时间 auto now std::chrono::system_clock::now(); std::time_t now_t std::chrono::system_clock::to_time_t(now); std::tm* local_tm std::localtime(now_t); // 格式化输出 std::cout \r // 回车符回到行首实现原地更新 std::setw(2) std::setfill(0) local_tm-tm_hour : std::setw(2) std::setfill(0) local_tm-tm_min : std::setw(2) std::setfill(0) local_tm-tm_sec std::flush; // 立即刷新缓冲区 // 等待一秒 std::this_thread::sleep_for(std::chrono::seconds(1)); } return 0; // 实际上面的循环是无限的这里不会执行 }这个版本已经是一个能跑的时钟了\r回车的作用是将光标移回当前行的开头这样下一次输出就会覆盖上一次的内容实现了简单的动态效果。std::flush确保输出立即显示而不是被缓冲区延迟。编译与运行Visual Studio直接按F5编译并调试运行。GCC/Clang在终端中进入项目目录执行g -stdc11 main.cpp -o digital_clock ./digital_clock。你应该能看到一个在命令行中不断更新的时间。按CtrlC可以终止程序。4.2 步骤二实现字符点阵数字与静态绘制接下来我们实现更酷的“大数字”显示。首先定义数字图形。// 定义数字0-9的5x3点阵图形用‘#’表示亮‘ ’表示暗 const std::string digitPatterns[10][5] { {###, # #, # #, # #, ###}, // 0 { # , # , # , # , # }, // 1 {###, #, ###, # , ###}, // 2 {###, #, ###, #, ###}, // 3 {# #, # #, ###, #, #}, // 4 {###, # , ###, #, ###}, // 5 {###, # , ###, # #, ###}, // 6 {###, #, #, #, #}, // 7 {###, # #, ###, # #, ###}, // 8 {###, # #, ###, #, ###} // 9 }; // 冒号图形用于分隔时、分、秒 const std::string colonPattern[5] { , # , , # , };然后我们编写一个函数给定一个时间字符串如14:05:30将其绘制到屏幕上。#include string #include vector void drawTimeAtPosition(const std::string timeStr, int startRow, int startCol) { // timeStr 格式必须是 HH:MM:SS std::vectorint digits; // 存储每一位的数字忽略冒号 for (char c : timeStr) { if (c 0 c 9) { digits.push_back(c - 0); } } // 现在digits里按顺序存放了 H, H, M, M, S, S for (int row 0; row 5; row) { // 对于点阵的每一行 int currentCol startCol; for (size_t i 0; i timeStr.length(); i) { char c timeStr[i]; if (c :) { // 绘制冒号 std::cout colonPattern[row]; currentCol 3; } else if (c 0 c 9) { // 绘制数字 int digit c - 0; std::cout digitPatterns[digit][row]; currentCol 3; } // 数字和冒号之间加一个空格 std::cout ; currentCol 1; } std::cout std::endl; // 换行绘制下一行 } }在main函数中我们可以暂时注释掉之前的简单输出调用这个绘制函数来测试静态显示效果。先获取一次时间格式化成字符串然后调用drawTimeAtPosition(0, 0)应该能看到一个静态的大号时间显示在屏幕左上角。4.3 步骤三集成动态刷新与精确计时循环静态显示没问题后就要让它动起来。我们需要结合精确计时循环和局部刷新策略。首先实现一个跨平台的光标移动函数简化版以Windows为例Linux版需用ANSI序列#ifdef _WIN32 #include windows.h void clearScreen() { system(cls); } void moveCursor(int x, int y) { HANDLE hConsole GetStdHandle(STD_OUTPUT_HANDLE); COORD pos { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(hConsole, pos); } #else void clearScreen() { std::cout \033[2J; } void moveCursor(int x, int y) { std::cout \033[ y ; x H; } #endif然后修改主循环。核心思想是记录上一次显示的时间字符串本次循环只重绘那些发生变化的字符位。int main() { clearScreen(); std::cout 电子数字时钟 (按ESC退出) std::endl; std::string lastTimeStr : : ; // 初始化为空 auto nextWakeTime std::chrono::steady_clock::now(); while (true) { // 1. 检查用户输入非阻塞 if (_kbhit()) { // Windows非阻塞键盘检测Linux需用select或ncurses char ch _getch(); if (ch 27) break; // ESC键退出 } // 2. 获取并格式化当前时间 auto now std::chrono::system_clock::now(); std::time_t now_t std::chrono::system_clock::to_time_t(now); std::tm* local_tm std::localtime(now_t); char timeBuffer[9]; // HH:MM:SS null terminator std::strftime(timeBuffer, sizeof(timeBuffer), %H:%M:%S, local_tm); std::string currentTimeStr(timeBuffer); // 3. 比较并更新变化的位 if (currentTimeStr ! lastTimeStr) { // 简单起见这里先清屏重绘。优化版应比较每一位局部更新。 moveCursor(0, 2); // 移动到时间显示起始行 drawTimeAtPosition(currentTimeStr, 2, 0); lastTimeStr currentTimeStr; } // 4. 显示日期和星期每秒更新一次 moveCursor(0, 8); char dateBuffer[50]; std::strftime(dateBuffer, sizeof(dateBuffer), 日期: %Y年%m月%d日 星期%a, local_tm); std::cout dateBuffer ; // 尾部空格用于覆盖旧的长字符 // 5. 精确睡眠到下一秒 nextWakeTime std::chrono::seconds(1); std::this_thread::sleep_until(nextWakeTime); } clearScreen(); std::cout 时钟已退出。 std::endl; return 0; }4.4 步骤四添加交互功能与最终优化最后我们为时钟增添一些实用功能和优化。1. 整点报时功能在显示时间的逻辑里加入判断// 在获取到 local_tm 后 static int lastHour -1; if (local_tm-tm_min 0 local_tm-tm_sec 0 local_tm-tm_hour ! lastHour) { lastHour local_tm-tm_hour; // 整点报时 std::cout \a; // 控制台蜂鸣可能在某些终端无效 // 或者用更醒目的视觉提示 moveCursor(0, 10); std::cout 整点报时 ; // 2秒后清除提示 std::this_thread::sleep_for(std::chrono::seconds(2)); moveCursor(0, 10); std::cout ; // 用空格覆盖 moveCursor(0, 2); // 光标移回时间显示区 }2. 暂停/继续功能增加一个全局状态变量bool isPaused false;在键盘检测部分增加对空格键的监听if (ch 27) break; // ESC退出 if (ch ) { // 空格键暂停/继续 isPaused !isPaused; moveCursor(0, 12); std::cout 状态: (isPaused ? 已暂停 : 运行中 ); moveCursor(0, 2); // 移回 }在主循环的时间更新逻辑外加上判断if (!isPaused) { // 原有的时间获取、比较、绘制逻辑 } else { // 暂停时可以只显示静态时间或者显示“PAUSED”字样 }3. 性能与显示优化局部更新实现一个更智能的updateDigit函数它接收位置索引和新的数字只更新屏幕上那一个数字位。这需要记录每个数字位在屏幕上的具体坐标。双缓冲在内存中构建完整的屏幕帧一个std::string或std::vectorstd::string然后一次性输出到控制台。这能彻底消除闪烁。在Windows下可以用WriteConsoleOutput跨平台可以构建一个大的字符串最后用std::cout输出。错误处理增加对localtime等函数返回空指针的检查。代码重构将时钟逻辑封装成一个类如DigitalClock将显示逻辑、时间逻辑、控制逻辑分离使代码更清晰、可维护。5. 常见问题与排查技巧实录在实际编码和运行过程中你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。5.1 时间显示不更新或闪烁严重问题描述运行程序后时间显示要么不动要么刷新时整个屏幕都在闪。排查思路与解决检查循环和睡眠首先确认主循环是否在正常执行。可以在循环开始处加一个调试输出比如打印一个计数器看它是否在增长。如果循环卡住可能是sleep函数有问题。确保使用的是std::this_thread::sleep_for或sleep_until。\r与std::flush如果你用的是简单的\r回车法务必在输出流末尾加上std::flush或std::endlendl会刷新缓冲区但也会换行慎用否则输出可能被缓冲而没有立即显示。闪烁问题根本原因是屏幕更新不连贯。简单清屏(cls或\033[2J)再重绘必然导致闪烁。解决方案A局部更新不要清屏。像我们项目里做的那样只更新变化的部分。这需要精确的光标控制。解决方案B双缓冲在内存中准备好下一帧要显示的完整内容然后一次性“贴”到控制台屏幕上。对于我们的时钟可以创建一个与屏幕区域对应的字符缓冲区每次循环先填充这个缓冲区最后用一次moveCursor加上一个大的std::cout输出整个缓冲区。平台差异Windows的system(“cls”)和Linux的echo -e “\033[2J”清屏效果类似但光标行为可能不同。如果在Linux下闪烁尝试使用\033[?25l隐藏光标显示完成后再用\033[?25h显示光标能提升视觉流畅度。5.2 跨平台编译错误特别是光标控制问题描述在Windows上写好的代码拿到Linux下编译报错提示windows.h找不到或者_kbhit、_getch未定义。解决方案 这是跨平台编程的典型问题。我们需要使用条件编译来隔离平台相关代码。// 在头文件或代码开头定义平台检测和通用接口 #ifdef _WIN32 #define PLATFORM_WINDOWS 1 #include conio.h // for _kbhit, _getch #include windows.h #elif defined(__linux__) || defined(__APPLE__) #define PLATFORM_LINUX 1 #include termios.h #include unistd.h #include sys/select.h // 自己实现非阻塞键盘检测 (kbhit for Linux/macOS) int kbhit_linux() { struct timeval tv { 0L, 0L }; fd_set fds; FD_ZERO(fds); FD_SET(STDIN_FILENO, fds); return select(1, fds, NULL, NULL, tv); } #endif // 然后将平台相关的操作封装成函数 void initConsole() { #ifdef PLATFORM_WINDOWS // Windows 特定初始化如设置控制台窗口大小、字体颜色等 #elif PLATFORM_LINUX // Linux 特定初始化如设置终端为原始模式如果需要即时按键响应 #endif } bool checkKeyPress() { #ifdef PLATFORM_WINDOWS return _kbhit() ! 0; #elif PLATFORM_LINUX return kbhit_linux() ! 0; #endif } char getKey() { #ifdef PLATFORM_WINDOWS return _getch(); #elif PLATFORM_LINUX char buf 0; struct termios old {0}; if (tcgetattr(0, old) 0) perror(tcsetattr()); old.c_lflag ~ICANON; old.c_lflag ~ECHO; old.c_cc[VMIN] 1; old.c_cc[VTIME] 0; if (tcsetattr(0, TCSANOW, old) 0) perror(tcsetattr ICANON); if (read(0, buf, 1) 0) perror(read()); old.c_lflag | ICANON; old.c_lflag | ECHO; if (tcsetattr(0, TCSADRAIN, old) 0) perror(tcsetattr ~ICANON); return buf; #endif }这样在主循环中调用checkKeyPress()和getKey()就是平台无关的了。虽然Linux下的实现复杂一些但这是一次性的工作。5.3 程序无法正常退出或退出后终端状态异常问题描述按ESC后程序似乎退出了但终端的光标不见了或者输入回显不正常。原因与解决终端模式未恢复在Linux/macOS下如果你为了非阻塞输入而修改了终端属性如上面的getKey函数必须在程序退出前将其恢复。确保在main函数返回前或在捕获到退出信号如SIGINT即CtrlC时调用恢复终端设置的代码。信号处理对于CtrlC中断简单的循环可能无法让程序执行清理代码。可以设置信号处理器#include csignal volatile bool g_running true; void signalHandler(int signal) { g_running false; } int main() { std::signal(SIGINT, signalHandler); // 捕获CtrlC initConsole(); // 初始化终端可能修改设置 while (g_running) { // ... 主循环逻辑 } restoreConsole(); // 恢复终端设置 std::cout \n程序已安全退出。\n; return 0; }Windows控制台光标如果你在Windows下用SetConsoleCursorPosition移动了光标退出前最好将光标移动到一个合适的位置比如最后一行或者调用system(“cls”)清屏给用户一个干净的结束界面。5.4 时间获取函数的安全性与性能问题std::localtime()不是线程安全的在多线程环境下使用有风险。虽然我们的时钟是单线程但了解替代方案有好处。替代方案 C11提供了线程安全的std::localtime_rLinux或std::localtime_sWindows MSVC。但更“现代C”的做法是使用chrono和iomanip直接格式化#include chrono #include iomanip #include iostream int main() { auto now std::chrono::system_clock::now(); auto in_time_t std::chrono::system_clock::to_time_t(now); // 使用 put_time 直接格式化输出避免操作 tm 结构体 std::cout std::put_time(std::localtime(in_time_t), %H:%M:%S) std::endl; return 0; }std::put_time是线程安全的因为localtime返回的指针只在当前语句中使用并且代码更简洁。在性能上对于一秒更新一次的时钟这些函数的开销完全可以忽略不计。选择哪种方式更多是编码风格和可读性的考量。经过以上五个步骤的实践和问题排查你应该已经拥有了一个功能完整、运行稳定、并且代码结构清晰的C控制台电子数字时钟。这个项目虽小但涵盖了从时间处理、I/O操作、循环控制到简单的状态管理和平台抽象等多个核心编程概念。你可以在此基础上继续扩展比如添加闹钟功能、更换皮肤不同的点阵字体、甚至通过网络协议获取NTP时间进行同步让它成为一个真正实用的桌面小工具。编程的乐趣就在于看着自己写的代码从无到有从简陋到完善最终变成一个真正能运行的程序。希望这个项目能成为你C学习路上一次扎实而有趣的实践。