阅读 cgol 源码
Table of Contents
cgol 是一个使用 C++ 实现的 Conway 生命游戏,在终端通过读取 rle 文件启动。所有文件加在一起都只有 1000 行左右,非常适合入门学习。
cgol# cloc .
36 text files.
16 unique files.
40 files ignored.
github.com/AlDanial/cloc v 2.08 T=0.01 s (1546.5 files/s, 124976.6 lines/s)
--------------------------------------------------------------------------------
Language files blank comment code
--------------------------------------------------------------------------------
C/C++ Header 7 116 77 605
C++ 3 26 22 212
YAML 1 1 1 115
Markdown 3 28 0 60
CMake 1 4 0 24
Bourne Again Shell 1 0 0 2
--------------------------------------------------------------------------------
SUM: 16 175 100 1018
--------------------------------------------------------------------------------工程
介绍 C++ 项目工程的内容,与代码实现无关。
目录结构
非常典型的 C++ 项目,include 存放头文件,src 存放源文件。samples 目录存放的是 rle 文件。
cgol
├── CMakeLists.txt
├── clang-format.bash // 批量格式化脚本
├── include
│ └── cgol
│ ├── cursor_control.hpp // 光标控制
│ ├── cursor_movement.hpp // 光标移动
│ ├── grid.hpp // cell 网格
│ ├── rle_parser.hpp // rle 文件解析器
│ ├── termcolor.hpp // 获取终端颜色
│ ├── terminal_size.hpp // 获取终端大小
│ └── utils.hpp // 工具类
├── samples // 存放 rle 文件目录
└── src
├── grid.cpp // cell 网格
├── main.cpp // 主程序入口
└── rle_parser.cpp // rle 文件解析器
源项目中还有 LICENSE、.gitignore 和一些 md 文件,因为重点是关注源代码,这些就不过多介绍了。
CMake 构建
使用 CMake 构建,必然需要一个根配置文件 CMakeLists.txt。项目很简单,没有其他子配置文件。
cmake_minimum_required(VERSION 3.8) # CMake 最小版本 3.8
project(cgol) # 项目名 cgol
# 根据不同 CMake 版本设置 cgol 描述信息(版本越高内容越详细)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.12")
project(cgol VERSION 1.0.0 LANGUAGES CXX
HOMEPAGE_URL "https://github.com/p-ranav/cgol"
DESCRIPTION "Conway's Game of Life")
elseif(CMAKE_VERSION VERSION_GREATER_EQUAL "3.9")
project(cgol VERSION 1.0.0 LANGUAGES CXX
DESCRIPTION "Conway's Game of Life")
else()
project(cgol VERSION 1.0.0 LANGUAGES CXX)
endif()
# 未指定发布模式时,默认使用 release 模式
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
set(CMAKE_CXX_FLAGS "-Wall -Wextra") # 启动常见警告和额外警告
set(CMAKE_CXX_FLAGS_DEBUG "-g") # debug 模式时使用该选项
set(CMAKE_CXX_FLAGS_RELEASE "-O3") # release 模式使用最高级优化
set(CMAKE_CXX_STANDARD 11) # 使用 c++11 标准
include_directories(include)
add_executable(cgol
src/grid.cpp
src/rle_parser.cpp
src/main.cpp)格式化
clang-format.bash 和 .clang-format 文件,这两个用来批量格式化项目代码。
# clang-format.bash
find ./include ./src -type f \( -iname \*.cpp -o -iname \*.hpp \) | xargs clang-format -style="{ColumnLimit : 100}" -i
其作用是:找到 include 和 src 目录下所有后缀名为 .cpp 或 .hpp 的文件,按照 .clang-format 文件进行代码格式化,并动态修改每行字符限制为 100 个。一行命令里,用到了 find、xargs 和 clang-format 三个工具。
源码
本章将逐一介绍每一行代码,从 main 函数开始一点一点抽丝剥茧直到看到整个 cgol 全貌。
跨平台
cgol 支持类 Unix(Linux/MacOS) 和 Windows 两个平台,通过判断 _MSC_VER 宏是否定义来编写快平台代码。这类代码在项目中随处可以看到,本文主要讲解类 Unix 的代码。
#if defined(_MSC_VER)
// Windows
#else
// Linux/MacOS
#endif工具类
cursor_control
功能:显示和隐藏光标。
实现:定义 cgol::cursor_hider 类利用 RAII 方法控制光标的显示与隐藏:初始化时隐藏,析构时重新显示。具体实现了静态内联函数 show_console_cursor(),其输入参数为 bool 值,代表是否显示光标。
这里需要补充一个知识点:Unix 可以通过往终端发送特定的 ANSI 转义序列来显示与隐藏光标,对应规则为:
"\033[?25h"- 显示"\033[?25l"- 隐藏
static inline void show_console_cursor(bool const show) {
std::fputs(show ? "\033[?25h" : "\033[?25l", stdout);
}
struct cursor_hider {
cursor_hider() { show_console_cursor(false); }
~cursor_hider() { show_console_cursor(true); }
};cursor_movement
功能:上下左右任意移动光标。
实现:move_up()、move_down()、move_right() 和 move_left() 一组函数。对应 ANSI 转义序列为:
- "\033[nA" - 向上 n 行
- "\033[nB" - 向下 n 行
- "\033[nC" - 向右 n 行
- "\033[nD" - 向左 n 行
static inline void move_up(int lines) { std::cout << "\033[" << lines << "A"; }
static inline void move_down(int lines) { std::cout << "\033[" << lines << "B"; }
static inline void move_right(int cols) { std::cout << "\033[" << cols << "C"; }
static inline void move_left(int cols) { std::cout << "\033[" << cols << "D"; }terminal_size
功能:获取终端的长宽。
实现:利用 ioctl() 函数 和 TIOCGWINSZ 来获取终端大小。
static inline std::pair<size_t, size_t> terminal_size() {
struct winsize size;
ioctl(STDOUT_FILENO, TIOCGWINSZ, &size);
return {static_cast<size_t>(size.ws_row), static_cast<size_t>(size.ws_col)};
}
static inline size_t terminal_width() { return terminal_size().second; }termcolor
功能:控制终端打印不同颜色信息。这是一个仅头文件的三方 C++ 库 termcolor.h。
util
功能:一组工具类,包括读取文件、字符串处理。
- read_file():读取文件。输入为 rle 文件地址。输出为文件内容。
static inline std::string read_file(const std::string &filename) {
std::ifstream stream(filename);
if (stream.fail()) {
throw std::runtime_error("Error: Could not open file " + filename);
}
return std::string((std::istreambuf_iterator<char>(stream)), std::istreambuf_iterator<char>());
}
- split_string():分隔字符串。输入为字符串和分隔符。输出为分隔开的字符串数组。
static inline std::vector<std::string> split_string(const std::string &str,
const std::string &delimiter) {
std::vector<std::string> strings;
std::string::size_type pos = 0;
std::string::size_type prev = 0;
while ((pos = str.find(delimiter, prev)) != std::string::npos) {
strings.push_back(str.substr(prev, pos - prev));
prev = pos + delimiter.size();
}
// To get the last substring (or only, if delimiter is not found)
strings.push_back(str.substr(prev));
return strings;
}
- starts_with():判断字符串前缀。
static inline bool starts_with(const std::string &string, const std::string &substring) {
return string.rfind(substring, 0) == 0;
}
- ltrim():裁剪字符串左侧的空格。
static inline void ltrim(std::string &s) {
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch) { return !std::isspace(ch); }));
}
- rtrim():裁剪字符串右侧的空格。
static inline void rtrim(std::string &s) {
s.erase(std::find_if(s.rbegin(), s.rend(), [](int ch) { return !std::isspace(ch); }).base(),
s.end());
}
- trim():裁剪字符串左右的空格。
static inline void trim(std::string &s) {
ltrim(s);
rtrim(s);
}
- ltrim_copy():裁剪字符串左侧的空格后进行拷贝(不修改源字符串)。
static inline std::string ltrim_copy(std::string s) {
ltrim(s);
return s;
}
- rtrim_copy():裁剪字符串右侧的空格后进行拷贝(不修改源字符串)。
static inline std::string rtrim_copy(std::string s) {
rtrim(s);
return s;
}
- trim_copy():裁剪字符串左右两侧的空格后进行拷贝(不修改源字符串)。
static inline std::string trim_copy(std::string s) {
trim(s);
return s;
}
- strip_left():删除字符串左侧匹配子串的部份。
static inline std::string strip_left(const std::string &input_string, const std::string &chars) {
std::string result = input_string;
result.erase(result.begin(), std::find_if(result.begin(), result.end(), [&chars](int ch) {
return !std::isspace(ch) and (chars.find(ch) == std::string::npos);
}));
return result;
}
- strip_right():删除字符串右侧匹配子串的部份。
static inline std::string strip_right(const std::string &input_string, const std::string &chars) {
std::string result = input_string;
result.erase(std::find_if(result.rbegin(), result.rend(),
[&chars](int ch) {
return !std::isspace(ch) and (chars.find(ch) == std::string::npos);
})
.base(),
result.end());
return result;
}
- parse_digit_from_char():字符转整形(OS:为啥不直接使用 c - '0' 转换?)。
static inline size_t parse_digit_from_char(char c) {
size_t result;
std::stringstream str;
str << c;
str >> result;
return result;
}核心类
核心类只有 rle_parser 和 grid 两个,一个用于解析 rle 文件,另一个表征游戏棋盘。通过 rle_parser 类将 rle 文件解析到 grid 中,通过打印并不断刷新 grid 内容来模拟生命游戏过程。
rle_parser
功能:解析 rle 文件并转储为方便使用的结构。
class rle_parser {
std::string rle_string_; // 原始 RLE 文件内容
std::string name_; // pattern 名
std::vector<std::string> comments_; // 文件中所有的注释
std::string author_; // pattern 作者
size_t size_x_{0}; // pattern 行
size_t size_y_{0}; // pattern 列
std::vector<size_t> rule_birth_; // e.g., 3 in "B3"
std::vector<size_t> rule_survival_; // e.g., 23 in "S23"
std::string pattern_raw_; // pattern 原始内容, e.g., "25boo85boo$24bo..."
std::vector<std::vector<unsigned char>> pattern_2d_array; // 2D 数组表示 pattern_raw_
// 解析名字、作者、注释、pattern 等内容
void parse_attributes();
// 解析 Conway 生命游戏的 RLE pattern(如 "25boo85boo$24bo...")2D 字符数组
void parse_pattern();
// 打印解析后的 pattern — 仅用于调试
void print() const;
public:
// 打开 RLE 文件并解析它的 pattern
// `grid_size_override` 参数用于覆盖 grid 的大小
void open(const std::string &rle_string, std::pair<size_t, size_t> grid_size_override = {0, 0});
// 返回 pattern 中的行数
// 该值为以下两种情况之一:
// 1. 从文件中解析出的值
// 2. 或用户提供的作为覆盖的值
size_t rows() const;
// 返回 pattern 中的列数,类似 rows()
size_t cols() const;
// 返回一个解析 pattern 后的 2D 数组
// e.g.,
// [
// [baaaab],
// [ababab],
// [bbbbbb]
// ]
std::vector<std::vector<unsigned char>> pattern() const;
};
grid
功能:游戏棋盘,即代表整个游戏。
class grid {
std::vector<std::vector<unsigned char>> grid_; // 2D 棋盘, 1 = 活着, 0 = 死亡
size_t rows_; // 棋盘行数
size_t cols_; // 棋盘列数
rle_parser parser_; // RLE 解析器
public:
// 使用 rle 文件构造生命游戏棋盘类
// 棋盘大小 grid_size 用于建立游戏区域的纬度
explicit grid(const std::string &rle_filename, const std::pair<size_t, size_t> &grid_size);
// 返回行数,用于 main.cpp 向上移动光标
size_t rows() const;
// 显示生命游戏棋盘
void print(std::ostream &stream = std::cout) const;
// 更新生命游戏棋盘
void tick();
};
main() 函数
主函数可以大致分成七步:
- 定义静态变量 run 标志位,默认为 true,代表正在运行。
- 使用 std::signal() 函数重写 SIGINT 对应的中断函数,函数只干了一件事,将 run 的标志修改为 false,代表结束运行。
- 使用 cgol::cursor_hider 类隐藏光标。
- 判断参数数量 argc 是否合法,不合法则打印帮助信息,然后退出。
- 使用 cgol::terminal_size() 函数获取终端的长宽,用于设置游戏的棋盘大小。
- 初始化 cgol::grid 棋盘类。
- 启动由 run 标志确定的循环,不断进行棋盘显示、睡眠、棋盘更新。
int main(int argc, char *argv[]) {
static bool run = true;
std::signal(SIGINT, [](int){ run = false; });
// Hide console cursor
cgol::cursor_hider ch;
if (argc < 2) {
std::cout << "Usage: ./main <pattern>.rle\n";
return EXIT_FAILURE;
}
// Get terminal size and use as bounding box for game of life grid
const auto terminal_size = cgol::terminal_size();
size_t rows = terminal_size.first - 2, cols = (terminal_size.second) / 2;
// Initialize grid with dimensions {rows, cols}
cgol::grid grid(argv[1], {rows, cols});
while (run) {
grid.print();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
move_up(grid.rows());
grid.tick();
}
return EXIT_SUCCESS;
}
以上是全部内容。