在上一篇中，我们通过拆解一个简单的例子描述了C++20 处理协程的过程。在此先简单的回顾一下各个部分的作用回顾promise_type主要提供一些与协程相关的接口：initial_suspend： 协程实例创建时执行，通过返回值来决定协程创建的同时执行还是等待final_suspend: 协程实例执行完之后是暂停还是继续，继续意味着会销毁这个实例yield_value/await_transform: 协程函数中执行 co_yield/co_await 时负责将后面的操作数转化为等待体awaiter 对象get_return_object: 返回协程对象return_void / return_value: 真正获取协程函数 co_return 的返回值unhandled_exception 协程函数发生异常时调用等待体是协程函数中调用 co_await/co_yield 时创建, 主要用来告诉编译器遇到这两个操作符时应该继续执行还是等待, 关于等待体 awaiter 它有如下几个接口:bool await_ready() : 根据返回值决定是继续还是等待await_suspend(coroutine_handle<>) : 协程被挂起时调用await_resume() : 协程被恢复时调用关于等待体，标准库有两个简单的实现: suspend_always 和 suspend_never改进生成器c++ 23 中存在一个标准的生成器 std::generator，我们利用这个生成器可以将整个程序修改为:#include <coroutine> #include <iostream> #include <generator> #include <ranges> std::generator<int> fibonacci() { int a = 0; int b = 1; for (;;) { co_yield a; int next = a + b; a = b; b = next; } } int main() { for (auto x : fibonacci() | std::views::take(10)) { std::cout << x << std::endl; } return 0; }本文的目标是最终完成一个简单的、可用的、贴近标准库的 std::generator，进一步理解协程的原理 我们还是按照上一篇的组织方式，先放出完整的源代码，然后依次说明重要的点:#include <coroutine> #include <iostream> #include <generator> #include <ranges> template<typename T> struct Generator { struct promise_type { std::suspend_always initial_suspend() { return std::suspend_always{}; } std::suspend_always final_suspend() noexcept { return std::suspend_always{}; } void unhandled_exception() noexcept { _exp = std::current_exception(); } Generator get_return_object() { return Generator(std::coroutine_handle<promise_type>::from_promise(*this)); } void return_void() {} std::suspend_always yield_value(T value) { _value = std::move(value); return std::suspend_always{}; } void rethrow_if_exception() { if (_exp) throw _exp; } T _value = {}; std::exception_ptr _exp = nullptr; }; Generator(std::coroutine_handle<promise_type> h) : _handle(h) {} ~Generator() { if (_handle) _handle.destroy(); } Generator(const Generator&) = delete; Generator& operator=(const Generator&) = delete; Generator(Generator&& other) noexcept : _handle(std::exchange(other._handle, nullptr)) { } Generator& operator=(Generator&& other) { if (this != &other) { if (_handle) _handle.destroy(); //删除当前的协程，管理新协程 _handle = std::exchange(other._handle, nullptr); } return *this; } bool is_done() noexcept { return !_handle || _handle.done(); } T next() { if (is_done()) throw std::runtime_error("Generator exhausted"); _handle.resume(); _handle.promise().rethrow_if_exception(); return _handle.promise()._value; } std::coroutine_handle<promise_type> _handle; // 迭代器 struct Iterator { Generator& _gen; bool _is_end; Iterator(Generator& gen, bool is_end = false) : _gen(gen), _is_end(is_end) {} void move_next() { if (!_gen.is_done()) { _gen._handle.resume(); _gen._handle.promise().rethrow_if_exception(); } else { _is_end = true; } } T operator*() const { return _gen._handle.promise()._value; } Iterator& operator++() { move_next(); return *this; } bool operator!=(const Iterator& other) const { return other._is_end != _is_end; } }; Iterator begin() { Iterator it{ *this }; it.move_next(); return it; } Iterator end() { return Iterator{ *this, true }; } }; Generator<int> fibonacci() { int a = 0; int b = 1; for (;;) { co_yield a; int next = a + b; a = b; b = next; } } int main() { Generator<int> generator = fibonacci(); for (auto x : fibonacci()) { std::cout << x << std::endl; } // std::generator 示例 //for (auto x : fibonacci() | std::views::take(10)) //{ // std::cout << x << std::endl; //} return 0; }泛型与移动语义的优化之前我们在 yield_value 函数中传入的参数是int类型，所以在函数中直接采用赋值运算符没有任何问题，但是考虑到泛化之后它可以生成任意类型的数据，在处理大结构的数据时，采用赋值运算将会产生多余的拷贝。因为函数参数中已经进行了拷贝，同时值传递不影响外部真实的数据，我们直接对参数执行移动操作可以节省一次拷贝。异常处理的支持上一篇，我们简单的将将 unhandled_exception 设置为空函数，这次我们在 promise_type 中保存了一个 std::exception_ptr 异常类型的指针用来捕获协程函数中的异常。这个函数的实现也比较简单，我们说在协程函数发生异常时会调用 promise_type 结构中的 unhandled_exception 接口函数。这个函数中通过 _exp = std::current_exception(); 获取最新的异常信息并保存。在获取下一次的数据的时候，我们直接判断当前是否保存了异常信息，如果有则直接抛出。外界在调用next等函数获取值的时候可以直接捕获Generator 禁止拷贝、仅支持移动操作协程句柄 coroutine_handle 独占一段堆上分配的协程栈与 promise 对象，资源唯一不可共享。所以理论上只能有一个对象管理整个协程。如果允许拷贝，则会出现多个结构同时指向一个协程对象。这些结构在析构时多次调用destroy 造成重复销毁的问题。或者某个对象没有及时更新造成访问无效内存。而移动语义是所有权的转移，在同一时间有且只有一个对象真正的控制协程、拥有 coroutine_handle 句柄。配合最后的析构释放操作，能防止内存泄漏move_next 实现这里我们在迭代器中单独又写了一些与 Generator::next 相同的代码。这里主要出于两点考虑：Generator 支持通过next 来获取下一个数据，但是该函数返回T类型的下一个数据。外界实际上没有途经判断生成器是否已经无法生成多余的数据。所以我通过抛出异常，外界可以通过异常来判断是否还能生成数据，当然这里也可以使用 std::optional 来判断数据是否合理。既然会抛出异常，那么迭代器中的 move_next 就无法直接调用，谁也不希望范围for最后是靠异常退出的。迭代器需要一个end函数，end一般是返回一个越界的迭代器，在协程中我们实际上不需要构建一个额外的Generator 来判断是否越界。所以我在里面额外维护了一个 _is_end。利用该变量来判断迭代器是否越界了。基于以上理由，我没有复用 Generator::next 的代码另外在 Generator::begin 函数中，我们首先调用了一次 move_next ，然后才返回迭代器。这是因为我们的 initial_suspend 函数返回 suspend_always，在生成器对象被创建的时候协程还没有开始运行，此时生成器中的值是无效值。begin函数是用来获取它的起始值，此时值应该是有效的，所以提前调用一次 move_next 让它获取第一个值。小结当然标准库的 std::generator 实现比上述代码要复杂的多，特别是它支持 ranges 组件来设置范围，而我们的简单的生成器的退出完全依赖协程函数自身的退出条件，无法做到自主退出。希望通过本节各位读者能对协程的原理有一个更深入的理解。也希望这个简陋的实现可以做到抛砖引玉的作用。
C++ 20 中新增的协程特性可以说是近几年更新的最重要的一个特性。早在我使用python的时候，我就体会到python中的协程和它在处理list时使用yield 的优雅。现在C++ 也支持了协程，我们可以实现类似python中的功能了完整的协程例子在python中，我们可以实现如下的函数def fibonacci(n): a, b = 0, 1 for _ in range(n): yield a # 暂停函数，返回a a, b = b, a + b # 使用生成器 for num in fibonacci(5): print(num)早期的C++ 中我们只能使用std::vector<int> fibonacci(int n) { vector<int> res; int a = 0, b = 1; for(int i = 0; i < n; i++) { int c = a + b; a = b; b = c; res.push_back(c); } } for(auto i : fibonacci(5)) std::cout << i << std::endl;上面两个对比，python示例的特点在于它不是每次调用都计算指定范围内的所有结果，而是每调用一次接着上一次的进行调用，能极大的提升运行效率。一个好的消息是，在C++20 中新增了协程，我们也能实现类似的功能。而坏消息就是C++20 的协程设计成了底层的“语言特性骨架”，没有提供开箱即用的高层库，所以它的概念相对复杂。#include <coroutine> struct Generator { struct promise_type { int currentValue; std::suspend_always initial_suspend() { return std::suspend_always{}; } std::suspend_always final_suspend() noexcept { return std::suspend_always{}; } auto yield_value(int value) { currentValue = value; return std::suspend_always{}; } Generator get_return_object() { return Generator{ std::coroutine_handle<promise_type>::from_promise(*this) }; } void return_void() {} void unhandled_exception() { std::terminate(); } }; std::coroutine_handle<promise_type> h; Generator(std::coroutine_handle<promise_type> handle) : h(handle) {} ~Generator() { if (h) h.destroy(); } bool move_next() { if (!h || h.done()) return false; h.resume(); return !h.done(); } int get_value() { return h.promise().currentValue; } }; Generator fibonacci(int n) { int a = 0; int b = 1; int i = 0; while (i < n) { co_yield a; int next = a + b; a = b; b = next; i++; } } int _tmain(int argc, TCHAR* argv[]) { Generator generator = fibonacci(10); while (generator.move_next()) { std::cout << generator.get_value() << std::endl; } return 0; }比起python的版本，它确实复杂了不少。但是我们可以从逻辑上一一拆解协程实现原理首先我们知道，普通的函数是通过栈来存储数据的，在调用时首先经过参数压栈，然后在栈中初始化函数的局部变量，接着执行函数代码。在函数退出时要在接收返回值的内存处初始化返回值对象，最后执行出栈操作清理整个函数栈。协程函数是可以随时挂起并回退到调用处，或者由外部直接恢复执行的，所以在回到调用位置时并没有执行函数栈的清理操作，而且需要保存函数当前执行的上下文环境，在需要的时候进行恢复。C++的协程采用的是无栈的设计，也就是说协程函数本身并没有产生栈，它的所有信息由编译器生成处理。到这里我们知道针对每个协程，编译器会生成处理它的代码包括保存上下文环境，真正执行挂起和恢复。编译器中通过coroutine_handle 对象来管理每个协程对象。我们无需知道编译器具体是如何实现管理协程的，我们只需要获取它的对象，并调用对象的方法就能操作对象。这也是面向对象的体现。我们知道编译器是通过生成一个协程对象来管理协程的。那么它是什么时候生成这个对象的呢？答案是它在发现某个函数为协程函数时会生成一个协程对象，对象生成的时机早于协程函数开始执行的时候，主要是因为它需要接管协程函数的栈并保存上下文环境。那么编译器是如何知道某个函数是协程函数的呢？主要是通过识别里面是否有 co_await、co_yield、co_return 这么几个关键字。到此我们先梳理一下，首先编译器在编译时扫描到了函数中有 co_await、co_yield、co_return 这几个关键字，因此它会生成协程对象接管函数栈和上下文，并且不会为函数本身生成栈帧。对于协程函数，为什么不能直接返回具体的返回值类型，而需要利用结构体包装一层呢？例如对于 fibonacci，我们知道它实际返回的是int类型，为什么不能直接返回int 呢？如果我们直接返回int会得到这样的错误:error C2039: "promise_type": 不是 "std::coroutine_traits<int,int>" 的成员也就是说，协程的类型已经定义好了，它必须是 std::coroutine_traits<T> 的类型，我们可以在标准库中看到，这个类型的定定义如下:template <class _Ret, class = void> struct _Coroutine_traits {}; template <class _Ret> struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> { using promise_type = typename _Ret::promise_type; }; _EXPORT_STD template <class _Ret, class...> struct coroutine_traits : _Coroutine_traits<_Ret> {};从上面可以看到，返回值必须要包含一个名为 promise_type 的结构。这个结构实际上是一个接口，在编译器帮我们管理协程，实现协程的创建、挂起、恢复、结束时它给我们提供了一系列的接口，方便我们在这些事件发生时执行一些自定义的代码。上面我们实现了这么两个接口：initial_suspend： 协程对象被创建时执行final_suspend: 协程对象被销毁时执行这两个函数的返回值也是一个接口，需要返回一个 awaiter(等待体)。这个 awaiter 我们也可以将它看作一个接口。它主要有三个函数需要实现:bool await_ready(): 如果返回 true，则表示已经就绪，无需挂起；否则表示需要挂起await_suspend(coroutine_handle<>) 当协程被挂起时，协程对象会保存当前协程的对象，此时 await_suspend 会被调用await_resume() 协程恢复执行时，该函数被调用需要注意，上面三个函数除了 await_ready 外，另外两个都没有给出返回值类型，标准并没有规定它们的返回值类型。这里我用了标准库实现的简单的等待体 std::suspend_always，它的实现如下:_EXPORT_STD struct suspend_always { _NODISCARD constexpr bool await_ready() const noexcept { return false; } constexpr void await_suspend(coroutine_handle<>) const noexcept {} constexpr void await_resume() const noexcept {} };还有一个类似的实现_EXPORT_STD struct suspend_never { _NODISCARD constexpr bool await_ready() const noexcept { return true; } constexpr void await_suspend(coroutine_handle<>) const noexcept {} constexpr void await_resume() const noexcept {} };到此为止，我们知道了4个接口，分别是协程对象被创建和销毁时调用，协程被挂起和恢复时调用。到这里，读者可能有疑问，这些接口为什么不设计到promise_type 里面，而非要放到两个结构里面，非要凭空多出一个概念呢？是不是这样更难理解，显得逼格更高呢？原因与难度和逼格无关，这是因为等待体主要是用来控制协程是挂起还是执行，以前我陷入了一个误区，我以前认为每次调用co_yield、co_await 一定会陷入等待。但是实际上它们只是操作等待体，与协程实际进行等待还是继续执行没有关系。我们是通过具体的等待体来实现此时协程是该继续执行还是等待。等待体与协程本身的控制应该分开，协程根据等待体的返回值来决定该等待还是继续执行。到这里我们再总结一次：编译器扫描到了协程函数，会定义一个协程对象来管理协程协程对象创建时执行 initial_suspend 函数，对象根据函数的返回值来决定是否要执行协程函数协程函数被执行时会依次执行，直到遇到 co_yield、co_await、co_return遇到上面的关键字之后它通过关键字操作的等待体中的 await_ready 的返回值来决定这个时候要不要接着执行协程如果需要挂起协程，那么它会调用等待体中的 await_suspend如果在外部恢复了协程，此时会调用等待体中的 await_resume如果协程函数结束了，会调用 final_suspend有读者可能会问了，你说 co_yield、co_await、co_return，我在上面的例子中也没看到有等待体呀。这是因为编译器对我们 co_yield a; 这个语句中执行了类型转换，将int转化为了等待体。我们在实现这个转化上有两种方式，第一个就是我自己定义一个等待体，然后通过构造函数实现int到等待体的转化，第二个就是实现接口, 对于co_yield 来说这个接口是一个名为 yield_value 的函数。编辑器在执行类型转化时，会进行下面的流程：如果在 promise_type 中定义了对应的转换函数，那么会调用转换函数来进行转换否则在全局查找能否有办法将后面的数据类型转化为一个等待体这里我没有自定义等待体，就采用最简单的 yeild_value了。对于 co_await 来说，可以通过 await_transform 函数来进行转化在上面的例子中，还有三个函数没有介绍到：get_return_objectreturn_voidunhandled_exception首先是 get_return_object，从名字上看，它是用来获取返回值对象的，我们是要用它来获取哪个返回值对象呢？是协程函数的吗？当然不是，这个函数是在协程对象自身被创建之后会调用，也就是说这里的返回对象其实是协程对象。return_void / return_value函数，这个函数是协程体结束之后如果协程函数自身返回时被调用，对于第一个函数，它调用的时机是在协程函数结束之前，而return_value 则是在调用 co_return 返回一个值。它们与 co_yield 类似，外部都是通过这种机制来实现获取协程执行到此的一些值，但是调用 return_void 和 return_value 之后意味着协程函数已经执行完毕，没有机会再恢复了。最后一个函数是：unhandled_exception, 这个函数表示，如果协程函数中发生异常，此函数将被调用。总结到此我们根据上述代码来总结一下协程的执行流程：编译器扫描到了协程函数，会定义一个协程管理对象来管理协程协程管理对象被创建之后，执行 get_return_object 此时外界有机会获取到管理对象，此时我们将handle 对象保存到了 Generator 对象中协程管理对象执行 initial_suspend 函数，对象根据函数的返回值来决定是否要执行协程函数。这里我们通过返回suspend_always 来将协程挂起外界通过执行 move_next 调用里面的 h.resume() 来恢复协程的执行，协程会按顺序执行直到遇到 co_yield、co_await、co_return这里遇到了关键字 co_yield 它会调用promise_type 中的 yield_value 执行整形数据到awaiter 的转化，这里返回suspend_always 来将协程挂起此时协程被挂起，那么它会调用等待体中的 await_suspend如果在下一次循环中又一次调用 move_next 恢复了协程，此时会调用等待体中的 await_resume就这样每调用一次 move_next 就执行一次计算，获取数列中的下一个数字循环结束,协程函数执行到函数体尾部或者执行了 co_return ，会调用 return_value或者 return_void 让外界有机会获取到协程函数本身的返回值，这个函数本身不返回值，所以这里会调用 return_void。协程函数结束了，执行 final_suspend，此时可以做一些清理工作。这里我们返回 suspend_always 来暂停协程，主要是为了协程结束之后，仍然可以获取协程计算的结果。主函数结束，Generator 对象被销毁，此时调用析构函数中的 h.destroy() 销毁协程管理对象
上一篇博客中，我完成了C++ IDE初步工作，包括代码的高亮、折叠、跳转以及补全等工作。但是作为IDE来说功能还有点不够，就我个人而言作为IDE来说它还需要具备一键编译运行和调试功能。这篇文章就来记录我是如何实现上述功能的编译运行我使用的演示项目比较简单，它的文件结构如下:. ├── include │ └── head.h └── src ├── add.cpp ├── div.cpp ├── main.cpp ├── mult.cpp └── sub.cpp它分为两个目录分别保存头文件和源文件。其中头文件只有一个定义各个接口函数，而接口函数的实现就放到各自定义的cpp文件中。这里使用加减乘除的四则运算的实现来作为演示。这里我分别演示一下Make文件和CMake构建的项目是如何实现一键编译运行的。Make构建的项目针对上前面介绍的简单项目，我们可以写出如下的Makefile# 编译器设置 # 定义项目根目录 ROOT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) CXX := g++ CXXFLAGS := -Iinclude -Wall -Wextra -pedantic -std=c++11 -MMD -MP LDFLAGS := EXE_OUTPUT := $(ROOT_DIR)bin TARGET := $(EXE_OUTPUT)/app $(info TARGET = $(TARGET)) # 源文件和对象文件设置 SRC_DIR := src SRCS := $(wildcard $(SRC_DIR)/*.cpp) OBJ_DIR := $(ROOT_DIR)build/obj OBJS := $(patsubst $(SRC_DIR)/%.cpp,$(OBJ_DIR)/%.o,$(SRCS)) DEPS := $(OBJS:.o=.d) # 默认目标（第一个目标） all: $(TARGET) # 链接生成可执行文件 $(TARGET): $(OBJS) @mkdir -p $(@D) $(CXX) $(LDFLAGS) $^ -o $@ # 编译源文件并生成依赖 $(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp | $(OBJ_DIR) $(CXX) $(CXXFLAGS) -c $< -o $@ # 创建对象文件目录 $(OBJ_DIR): mkdir -p $@ # 包含自动生成的依赖关系 -include $(DEPS) # 清理生成的文件 clean: rm -rf $(TARGET) build .PHONY: all clean 上面我们定义了头文件路径为include 目录，并且规定了中间文件生成在 build/obj 中，最后定义了生成可执行程序在 bin/app 中对于编译来说，Emacs内置了 compile 命令，它会自动执行 make -k 命令，但是如果我在使用Emacs的过程中切换到了其他目录的话，还需要特别指定Makefile 所在的路径，对我来说我希望能在尽可能少输入参数的情况下完成同样的操作，不太希望每次都指定项目根目录，好在之前配置的projectile 插件帮助我们识别出来了项目的根目录。所以这里可以使用 projectile-compile-project 来自动指定根目录并编译。从上面的截图可以看到，flycheck 提示了几个错误，这是因为项目没有生成 compile_commands.json文件，所以lsp服务器无法跨文件分析，导致找不到头文件。原始的make 命令并不支持生成 compile_commands.json 文件，我们可以通过 bear 命令来完成这个工作，它的用法比较简单，只需要使用 bear -- <your-build-command> 即可, 对于使用make编译的项目来说 <your-build-command> 代表的就是 make 命令。我们需要考虑的一个问题是，如何将bear加入到编译命令中，也就是将它自动生成的 make -k 给替换掉，第二个问题是如果当前目录在其他目录下，如何保证 compile_commands.json 永远生成在根目录下Emacs中有一个变量 compile-command 保存了编译的命令，如果我们使用Emacs自带的compile来编译可以通过修改它来实现，而 projectile-compile-project 则是通过变量 projectile-project-compilation-cmd 来保存编译命令，默认是nil，对于使用 projectile 我们通过修改这个变量的值从而修改编译时使用的命令。另外既然 projectile 可以得到项目的根目录，我们就可以利用这个插件来获取项目的根目录，有了这些信息通过一个函数就可以重新生成一个编译命令(defun my/general-compile-command() (concat "bear --output " projectile-project-root "compile_commands.json" " -- make -k"))这个函数的代码非常简单，通过 projectile-project-root 来获取项目的根目录，然后通过字符串拼接的方式来得到编译命令生成 compile_commands.json 成功之后，我们重启 lsp 服务后可以看到错误都消失了，只有两个警告了了解了编译的一些情况，下面来看看如何在Emacs中执行生成的可执行程序。Emacs中可以使用 shell-command 来执行可执行 shell 命令。例如我们可以在项目的根目录下执行 shell-command ./bin/app。很明显如果每次都指定程序的路径是非常麻烦的事，我希望能有一个命令或者函数来自动执行可执行程序。但是Makefile构建的项目比较古老也灵活，Makefile中没有一个固定的方式或者写法来指定可执行程序的生成路径，也就是说没有一个通用的方式来根据Makefile获取可执行文件的路径。一种折中的方案就是针对每个项目都定义一个 execuable-path 的变量来指定可执行程序的路径，然后再通过elisp代码来根据这个变量执行程序(defun my/run-program() (interactive) (shell-command (concat projectile-project-root executable-path)))我们可以针对每个项目单独设置一个 executable-path 变量。Emacs会读取项目根目录中的 .dir-locals.el 文件，并且将文件中定义的变量作为项目的局部变量，所以我们只需要在该文件中定义好 executable-path 就可以了。我们可以通过命令 add-dir-local-variable 来往该文件中添加一个局部变量，也可以自己手写该文件实现这一操作添加完变量之后，项目根目录中的 .dir-locals.el 文件内容如下;;; Directory Local Variables -*- no-byte-compile: t -*- ;;; For more information see (info "(emacs) Directory Variables") ((c++-mode . ((executable-path . "bin/app"))))在重启Emacs之后，执行这个函数就可以做到一键运行了有了这些，我希望能将它们有机的组合起来，也就是说按下某个快捷键，这里我暂时定义为 <F7>。它直接同时执行编译和运行的操作。通过 C-<F7> 来完成重编译的操作。;; 重新编译 (defun my/rebuild-program () (interactive) (let ((root (file-name-as-directory (projectile-project-root)))) (shell-command (concat "make clean -C " root)) (setq compile-command (concat "bear --output " root "compile_commands.json" " -- make -k -C " root)) (compile compile-command))) ;; 绑定快捷键 (setq compilation-read-command nil) ;; 取消编译时确定命令行 (evil-define-key 'normal c++-mode-map (kbd "<f7>") #'projectile-compile-project) (evil-define-key 'normal c++-mode-map (kbd "C-<f7>") #'my/rebuild-program))) 这里的代码比较简单，对于编译来说只需要将之前执行的 projectile-compile-project 绑定到对应的快捷键；对于重编译，我通过函数 my/rebuild-program 来完成。这个函数主要操作是先执行 make clean 命令然后重新执行 make。在正式绑定快捷键之前，有一句 (setq compilation-read-command nil) 。projectile-compile-project 和 compile 命令都是交互式命令，执行时会首先显示对应的编译命令，需要用户手动执行回车确认命令，这句代码的意思是不取消它们需要确认的步骤，直接执行命令。本来我打算在重编译函数中也采用 projectile-compile-project 但是它这个交互式我一直取消不了，所以这里我直接采用 compile 指定根目录的方式来完成这个操作。如果想要绑定一键运行的操作也可以采用这个思路，将快捷键绑定到 my/run-program 函数中，这个函数也可以添加一个编译命令确保执行的是最新代码生成的可执行程序CMake工程CMakeLists.txt 文件内容如下:cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) # aux_source_directory(${PROJECT_SOURCE_DIR} source) file(GLOB source ${CMAKE_SOURCE_DIR}/src/*.cpp) include_directories(${CMAKE_SOURCE_DIR}/include) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) add_executable(app ${source})这个CMakeLists.txt 文件中主要定义了编译使用到的源文件、头文件目录路径和生成的exe路径emacs 中有一个名为 cmake-ide 的包，它用于读取cmake配置中的各项参数并将参数传递到对应的包中，虽然用它可以很方便的针对cmake配置，但是它依赖rtags，并且没有支持lsp-mode。所以这里就淘汰它，还是想办法自己实现针对cmake来说，要生成 compile_commands.json 比较简单，我们可以在命令行中使用cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1也可以在cmake配置文件中，project命令之后添加set (CMAKE_EXPORT_COMPILE_COMMANDS ON)这里我采用将命令写到cmake文件中的方式。对于cmake 编译的过程主要由两个部分组成，首先是cmake构建项目生成Makefile，然后使用make 命令编译项目。我们要实现自动编译也需要模拟这两个命令。与上面类似，这里我只需要将 my/general-compile-command 函数做少许改动即可(defun my/cmake-general-compile-command () (concat "cmake -B " (projectile-project-root) "build -DCMAKE_BUILD_TYPE=Debug " (projectile-project-root) " && ln -sf " (projectile-project-root) "build/compile_commands.json " (projectile-project-root) "compile_commands.json" " && cmake --build " (projectile-project-root) "build --config Debug"))这个函数生成的命令主要完成三个工作，将构建编译生成的临时文件放到 build 目录下；因为生成的 compile_commands.json 文件也一起放在了 build 目录中，所以我加一个软链接到项目根目录的操作；最后就是执行编译操作了。至于重编译则于上面的步骤相似，cmake一般我习惯删除存放临时文件的build目录然后重新执行cmake构建。所以这里还是模拟这个过程(defun my/cmake-rebuild-program () (interactive) (let ((root (file-name-as-directory (projectile-project-root)))) (shell-command (concat "rm -rf " root "build")) (setq compile-command (my/cmake-general-compile-command)) (compile compile-command)))至于运行程序，我们还是可以采用上面介绍的指定程序生成路径的方式。也就是不管使用cmake或者Makefile 构建的工程都可以使用上面定义的 my/run-program 函数来运行程序调试作为IDE的一个重要或者说基础的功能，调试功能是必不可少的。emacs 自身支持使用gdb进行调试，我们可以执行 M-x gdb 来启动一个调试示例，这个时候我们一边通过gdb的调试命令来控制程序语句的执行一边观看代码的上下文。但是目前流行的方式是使用 dap 来调试程序，至于什么是dap，我在配置vim的时候已经介绍过了，这里就不再赘述了emacs 中有一个名为dap-mode 的插件通过这个插件可以实现dap相关的功能。因为在介绍vim配置的时候我使用的是vscode中的 cpptools插件，这里我打算也使用它来作为dap的调试后端，可以通过cpptools官方仓库 进行下载接着需要安装lldb-vscode，它是针对vscode的一个插件，我们可以在 官方仓库 中找到对应的下载包。下载完成之后可以直接解压到对应目录，这里我解压到 ~/.emacs.d/cpptools 目录中。此时对应的调试后端程序为 ~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7。我们需要赋予它可执行的权限。在这些工作都做好之后，可以使用下面的代码来安装dap-mode(use-package dap-mode :ensure t :after (lsp-mode) :config (dap-auto-configure-mode) ; 可选：启用自动配置 (setq dap-cpptools-debug-program '("~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7")) )我们可以通过命令 dap-debug-edit-template 创建一个调试的模板。对给出的模板做一些简单的修改(dap-register-debug-template "cpptools::Run Configuration" (list :type "cppdbg" :request "launch" :name "cpptools::Run Configuration" :MIMode "gdb" :program "${workspaceFolder}/bin/hello" :cwd "${workspaceFolder}" :environment [] :miDebuggerPath "/usr/bin/gdb")) 我们执行一下这个代码就会向Emacs注册一个调试的模板。接着直接调用 dap-debug 即可启动调试。虽然我们可以将上述注册的代码放到主配置文件中，但是其中的一些关键的字段，例如程序的位置，使用的环境变量，以及对应的调试参数都无法做到所有程序都统一，所以这里我觉得还是需要的时候直接修改就好了。dap-mode 的一些命令如下:dap-debug 和 dap-continue : 启动调试或者运行到下一个断点处dap-next : 执行下一句代码dap-step-in : 执行下一行代码并进入函数内部dap-step-out : 执行到函数返回dap-breakpoint-toggle : 创建或者删除端点我们可以对这些命令进行键位绑定(use-package dap-mode :ensure t :after (lsp-mode) :config (dap-auto-configure-mode) ; 可选：启用自动配置 (require 'dap-cpptools) (setq dap-cpptools-debug-program '("~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7")) (evil-define-key 'normal dap-mode-map (kbd "<f10>") #'dap-next) (evil-define-key 'normal dap-mode-map (kbd "<f9>") #'dap-breakpoint-toggle) (evil-define-key 'normal dap-mode-map (kbd "<f5>") #'dap-debug) )这样我们可以使用上述快捷键来进行调试操作总结这篇文章花了好长时间才弄出来，主要是我对于emacs和lisp语言不太熟悉，中间在尝试编写一键运行和配置dap时耗费了大量的时候。最终我还是成功了，至少我完成我当初想要的一些ide的基本功能，当然在使用上还是比不过vscode，但是在折腾中总能找到一丝乐趣。本文中的配置仅仅经过我自己机器的检验，本来想弄的更加灵活更加接近vscode的体验，有一些我自己想要的功能还没加上，仅仅做了一个可用的玩具。但是我没有想到什么办法，而且这篇文章已经憋了好久了，再不写点东西出来我感觉马上就要放弃了，我想先弄点东西出来给自己一个激励，让我有动力继续深入学习一下Emacs的其他内容。等我多学了一点Emacs多写了一点elisp代码之后可能会对调试和编译方面的代码做一个大的更新。最后如果有读者觉得这篇文章写的有那么一点帮助，那将是我的荣幸，感谢读者在百忙之中能读完本文。
在介绍vim配置的时候介绍过lsp的相关基础知识。简单来说lsp是一个协议，它以C/S架构的形式进行组织，lsp负责分析语法，给出具体的语法单元，完成跳转等功能的核心实现。而客户端则负责接收用户的操作请求并呈现具体结果。这样做的好处是将核心服务和客户端显示分离出来，核心部分重用，客户端则可以由各个编辑器自己实现，使各种编辑器都有相同的核心功能体验。对于Emacs来说，由lsp-mode 提供核心客户端库，管理服务器生命周期、消息路由及基础功能。另外也有 lsp-ui 这种增强UI模块，提供实时信息侧边栏（lsp-ui-sideline）、代码透镜（Code Lens）、悬浮文档等下面来介绍如何使用它们配置一个基础的lsp功能lsp-mode根据官方给出的配置，我们可以组一个基础的配置(use-package lsp-mode :ensure t :init ;; set prefix for lsp-command-keymap (few alternatives - "C-l", "C-c l") (setq lsp-keymap-prefix "C-c l") :hook ( ;; if you want which-key integration (lsp-mode . lsp-enable-which-key-integration)) :commands (lsp lsp-deferred))这里我们只是安装了一个客户端，想要真正实现lsp的功能，还需要针对具体的语言下载一个服务端，可以通过 lsp-install-server 来下载如果我们希望能像vscode那样，以悬浮窗口的形式显示符号的定义、声明或者注释文档，那么我们需要使用 lsp-ui 这个插件(use-package lsp-ui :ensure t :after (lsp-mode) :config (setq lsp-ui-doc-position 'top))这里我通过 lsp-ui-doc-position 来定义显示的窗口在上方，一般的编辑器默认是显示在光标所在的位置，但是我觉得显示在光标位置会影响我阅读后续的代码，所以我将它显示在上面，如果各位读者希望它像其他编辑器那样显示在光标位置可以修改参数为 at-point项目管理上述的lsp配置完之后，它只能使用当前buffer中的内容进行语法补全提示，也就是说我在其他的位置定义的函数和类在当前buffer中是无法识别到的。我们需要结合项目一起来使用。我们可以使用名为 projectile 的插件来进行项目管理(use-package projectile :ensure t :init (setq projectile-project-search-path '("~/projects/" "~/work/" "~/playground")) :config (define-key projectile-mode-map (kbd "C-c C-p") 'projectile-command-map) (global-set-key (kbd "C-c p") 'projectile-command-map) (projectile-mode +1)) (use-package counsel-projectile :ensure t :after(projectile) :init (counsel-projectile-mode))这里我们额外安装了 counsel-projectile 用来与 counsel 结合进行搜索语法检查语法检查方面，目前主流的插件是 flycheck 和 flymake，但是好像 flycheck 使用的人多一些，所以这里我也采用 flycheck。(use-package flycheck :ensure t :config (setq truncate-lines nil) ; 如果单行信息很长会自动换行 :hook (prog-mode . flycheck-mode))这里我们仅在编程的时候开启语法检查。同样的， flycheck 是一个前端，用来显示结果的，具体检查的核心功能是通过后端来实现的。后端的程序可以在官方网站上找到。状态栏显示相关的lsp信息现在是时候来修改以下状态栏的显示了，根据配置vim的经验，我还是希望主要显示当前编辑的模式、文件名、文件编码、lsp服务器、语法错误信息等。这里我使用 doom-modeline 来完成这个功能(use-package doom-modeline :ensure t :init (doom-modeline-mode 1) :config (setq doom-modeline-height 30) (setq doom-modeline-bar-width 5) (setq doom-modeline-icon t) (setq doom-modeline-lsp t) (setq doom-modeline-major-mode-icon t) (setq doom-modeline-buffer-state-icon t) (setq doom-modeline-buffer-file-name-style 'truncate-with-project) (setq doom-modeline-check-simple-format t) )通过上述代码来简单的配置一下，就可以有丰富的显示信息每种语言都对应了一个lsp的后端程序，lsp-mode官方网站 给出了每种语言对应的lsp后端程序，我们可以使用 lsp-install-server 来安装,这里我准备安装 clangd 这个后端在安装了lsp之后，再次打开一个cpp文件，可以发现它的界面如下:上面我们完成了lsp配置的基础准备工作，下面将要来针对具体的语言探索一下实际的使用方式和使用体验状态栏下分别显示了当前的模式(N代表normal模式)、文件类型、文件名称、当前编码方式等等，最后一个是flycheck 语法检查的结果，这里的代码比较简单，所以它没有检测出来任何问题，最后以绿色圆圈中的一个勾来显示。需要注意的是状态栏中的小火箭表示lsp服务已启用，如果没有这个标志可以手动的执行 lsp 命令选中项目的根目录C++ 项目实例C/C++ 项目需要明确的编译信息，clangd 通过 compile_commands.json 文件获取这些信息。对于cmake构建的项目cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1另外我希望每种语言对应的lsp配置放入到不同的配置文件中，做到模块化处理，因此我在配置中新建了一个lsp目录，并且按编程语言每种语言一个配置文件。目前c++语言的配置如下:(require 'lsp-mode) (add-hook 'c++-mode-hook (lambda () (setq lsp-project-identification-methods '(:root ("compile_commands.json" ".git" ".clangd" "CMakeLists.txt" "Makefile"))) (setq lsp-clients-clangd-args '("-background-index" "--clang-tidy" "--completion-style=detailed")) (setq-local completion-at-point-functions '( cape-file cape-keyword cape-dabbrev)) (lsp-deferred))) (provide 'cpp)为了实现不同的语言加载不同的配置，这里通过对应模式的hook来实现。一般每种模式都有一个对应的hook变量，它里面保存了一个函数，当启用该模式时会执行对应的代码，这里c++-mode 对应的是 c++-mode-hook。在这个hook中，主要做了四件事：第一，我们定义了项目根目录的标识；第二，定义了clangd启动的一些参数；最后也是最关键的一点，我们重新定义了补全端的函数。在配置Emacs的补全时，提供了一大堆补全方式，很多在编程时是用不到的，而且Emacs在补全时会依次根据 completion-at-point-functions 列表中保存的补全函数来查找补全项，一旦前一个函数返回了补全项，那么就不再往后查找。在编程的过程中前面定义的很多补全功能并没有lsp提供的补全好用，而且还会影响lsp的补全导致它出不来，所以我们去掉一些没用的，仅仅保留关键的部分。lsp-mode 会提供一个名为 lsp-completion-at-point 的补全函数注册到 completion-at-point-functions 中，因此这里我们不需要单独的写出来，当然写出来也没问题。我们可以通过 describe-variable 命令来查看 completion-at-point-functions 的值。Its value is (lsp-completion-at-point cape-file cape-keyword cape-dabbrev) Local in buffer main.cpp; global value is (cape-line cape-elisp-symbol cape-dict cape-ispell cape-keyword cape-file cape-dabbrev tags-completion-at-point-function) 最后一件事就是调用 lsp-deferred 来延迟加载lsp。因为 lsp-deferred 需要等到缓冲区完全加载完成之后才加载，所以将它放到最后面代码跳转在vim中，有几个关于跳转的关键的快捷键，gd 表示 goto declaration 跳转到定义，gD 表示 goto definition 跳转到定义，使用 gf 来跳转到头文件，跳转之后可以使用 ` 再次回来，这些快捷键 evil` 也为我们保留了，我们不需要额外的配置只要lsp能加载起来就能使用。显示符号文档信息vim配置中定义了 gh 显示符号的文档信息，我们可以使用 define-key 来定义快捷键。既然显示文档的功能由 lsp-ui 来提供，这里就将代码放入到 lsp-ui 位置(define-key lsp-ui-mode-map (kbd "gh") #'lsp-ui-doc-glance)tree-sitter在配置vim的时候提到过tree-sitter 是轻量级的语法分析器，而lsp是进行语义分析的重量级工具，tree-sitter 是对 lsp 的一个补充，并且我们用tree-sitter 进行了语法高亮和代码片段的折叠，这里尝试在Emacs中使用tree-sitter(use-package tree-sitter :ensure t :hook (prog-mode . tree-sitter-hl-mode) ;;启用语法高亮 :config (global-tree-sitter-mode)) (use-package tree-sitter-langs :ensure t :after tree-sitter)安装完成之后，我们通过命令 tree-sitter-langs-install-grammars 来安装对应的语言支持，默认是安装所有语言的支持，这里我就使用默认的就好下面尝试启用在vim中 zc、和 zo的功能。它们主要是用来折叠和展开代码块。我们来安装一个名为 ts-fold 的插件，该插件没有被放入到 elpa，所以需要手动下载然后加载(use-package ts-fold :load-path "~/.emacs.d/ts-fold" :config (global-ts-fold-mode 1) (with-eval-after-load 'evil (evil-define-key 'normal 'global "zc" #'ts-fold-close "zo" #'ts-fold-open)))最终的效果如下:基于tree-sitter，我们还可以实现一个非常重要的功能——增量选择代码块。在vim配置中我使用回车来扩大选区，使用退格键来减小选区，这里仍然沿用这种配置增量选择的功能可以使用 expand-region(use-package expand-region :ensure t ; 从ELPA自动安装 :bind ("C-=" . er/expand-region) :config (defun my/incremental-expand-region() (interactive) (if (region-active-p) (er/expand-region 1) (er/mark-word)) (setq deactivate-mark nil)) (defun my/contract-region() (interactive) (if (region-active-p) (er/contract-region 1) (call-interactively 'evil-backward-char))) (with-eval-after-load 'evil (dolist (state '(normal visual motion)) (evil-define-key state 'global (kbd "RET") nil) (evil-define-key state 'global (kbd "<backspace>") nil)) (evil-define-key 'normal 'global (kbd "RET") #'my/incremental-expand-region) (evil-define-key 'normal 'global (kbd "<backspace>") #'my/contract-region)))原版的增量选择，如果没有选中某个部分无法执行增量，所以对它进行稍微的改造，如果当前没有选中任何部分，先选中当前光标所在的单词。然后继续进行增量选择。与增加选区类似的，减少选区也是先判断如果已经没有选中区域的话还是延续vim中退格键的功能。本篇到此就先结束了，但是我们的IED的功能并没有配置完，后面我计划加上自动编译运行、调试等功能，这些就在后面的文章中给出
使用CMake构建工程的一个原因是不希望Windows上有一个工程文件，Linux又单独配置一套工程文件。我们希望对于整个工程只配置一便，就可以直接编译运行。CMake本身也具备这样的特性，脚本只用写一次，在其他平台能自动构建工程。这里谈的跨平台主要是如何组织工程和编写CMake来针对Windows和Linux进行一些特殊的处理，在这里说说我常用的几种办法介绍这些方法的前提是有一些代码只能在Windows上跑，例如调用Win32 API或者使用GDI/GDI+，而在Linux上也有一些Windows不支持的代码。我们就需要一些方法来隔离开这两套代码。假设现在有一个项目，它有一套共同的接口对外提供功能，而在Windows上和Linux上各有一份代码来实现这些接口。可以假设有一套图形相关的功能，对外采用统一的接口，具体实现时Windows上使用GDI+，而Linux上使用其他方案来实现。现在整个项目的目录结构如下. ├── include └── platform ├── linux └── windowsinclude 目录用来对外提供接口，是导出的头文件。platform隔离了Windows和Linux上的实现代码。使用宏来控制我们知道Windows和Linux平台有特定编译器定义的宏，根据这些宏是否定义我们可以知道当前是在Linux还是Windows上编译，是需要编译成32位或者64位程序，又或者编译成debug版本或者release版本。例如下面是我用的简单的判断版本的方式#define MY_PLATFORM_WINDOWS 0 #define MY_PLATFORM_LINUX 1 #define MY_PLATFORM_APPLE 2 #define MY_PLATFORM_ANDROID 3 #define MY_PLATFORM_UNIX 4 #define MY_ARCH32 1 #define MY_ARCH64 2 #if defined(_WIN32) || defined(_WIN64) #define MY_PLATFORM MY_PLATFORM_WINDOWS #ifdef _WIN64 #define PLATFORM_NAME "Windows 64-bit" #define MY_ARCH MY_ARCH64 #else #define PLATFORM_NAME "Windows 32-bit" #define MY_ARCH MY_ARCH32 #endif #elif defined(__APPLE__) #include "TargetConditionals.h" #define MY_PLATFORM MY_PLATFORM_APPLE #ifdef ARCHX64 #define MY_ARCH MO_ARCH64 #else #define MY_ARCH MO_ARCH32 #endif #if TARGET_IPHONE_SIMULATOR #define PLATFORM_NAME "iOS Simulator" #elif TARGET_OS_IPHONE #define PLATFORM_NAME "iOS" #elif TARGET_OS_MAC #define PLATFORM_NAME "macOS" #endif #elif defined(__linux__) #define MY_PLATFORM MY_PLATFORM_LINUX #if defined (ARCHX64) || defined (__x86_64__) #define MY_ARCH MY_ARCH64 #else #define MY_ARCH MY_ARCH32 #endif #define PLATFORM_NAME "Linux" #elif defined(__unix__) #ifdef ARCHX64 #define MY_ARCH MY_ARCH64 #else #define MY_ARCH MY_ARCH32 #endif #define PLATFORM_NAME "Unix" #define MY_PLATFORM MY_PLATFORM_UNIX #else #error "Unknown platform" #endif上面代码根据一些常见的编译器宏来决定是什么版本，并且根据不同的版本将MY_PLATFORM 进行赋值。后面只需要使用 MY_PLATFORM 进行版本判断即可。同样的关于架构使用 MY_ARCH 来判断。例如根据架构来定义不同的数据长度#if (MY_PLATFORM == MY_PLATFORM_WINDOWS) typedef __int64 MY_INT64; typedef unsigned __int64 MY_UINT64; #else typedef long long MY_INT64; typedef unsigned long long MY_UINT64; #endif #if (MY_ARCH == MY_ARCH64) typedef MY_UINT64 MY_ULONG_PTR; typedef MY_INT64 MY_INT_PTR; #else typedef MY_UINT MY_ULONG_PTR; typedef MY_INT MY_INT_PTR; #endif定义的常见的数据结构之后，对于一些接口的视线就可以利用宏来隔开// platform/windows/image.c #if (MY_PLATFORM == MY_PLATFORM_WINDOWS) // todo something #endif// platform/linux/image.c #if (MY_PLATFORM == MY_PLATFORM_LINUX) // todo something #endif这样我们可以利用上一节介绍过的 cmake 的 file 或者 aux_source_directory 将整个platform目录都包含到工程里面。使用cmake来判断版本除了在C/C++ 源码中利用编译器特定的宏来判断版本，其实CMake自身也有一些方式来判断编译的版本。CMake 检测操作系统使用 CMAKE_SYSTEM_NAME 来判断。这里要提一句，CMake中的变量本质上都是一个字符串值，没有严格的区分类型，所以 set(variable 1) 和 set(variable "1") 在底层存储都是字符串 1。所以cmake在定义变量的时候可以不使用双引号，但是对于特殊的字符串，例如带有空格的字符串，为了避免语法上的歧义，可以带上双引号。虽然说底层存储的都是字符串，但是在上层判断变量是否相等的时候却又区分数字和字符串。判断变量是否等于某个值可以使用 EQUAL 或者 STREQUAL。EQUAL 是用来判断数字类型是否相等，一般判断版本号或者数字参数。而 STREQUAL 来判断字符串是否相等，一般用来判断配置选项、路径、平台标识符。例如这里的 CMAKE_SYSTEM_NAME 就需要采用 STREQUAL 来判断# 检测平台 set(PLATFORM_WINDOWS 1) set(PLATFORM_LINUX 2) set(PLATFORM_MACOS 3) if(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(PLATFORM ${PLATFORM_WINDOWS}) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") set(PLATFORM ${PLATFORM_LINUX}) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") set(PLATFORM ${PLATFORM_MACOS}) endif()判断架构可以使用 CMAKE_SIZEOF_VOID_P。顾名思义，它表示一个void* 指针变量的大小，8位就是64位，4位则是32位架构。if(CMAKE_SIZEOF_VOID_P EQUAL 8) set(PLATFORM_ARCH "x64") elseif(CMAKE_SIZEOF_VOID_P EQUAL 4) set(PLATFORM_ARCH "x86") else() message(FATAL_ERROR "Unsupported architecture pointer size: ${CMAKE_SIZEOF_VOID_P}") endif()至于判断当前编译的版本是debug 还是 release 可以使用 CMAKE_BUILD_TYPE 来判断，它的值主要有4个，分别是 Debug、RelWithDebInfo、MinSizeRel、Release。它们四个各有特色。其中 RelWithDebInfo 是带有符号表的发布版，便于调试，它的优化级别最低。MinSizeRel和Release在优化上各有千秋，前者追求最小体积，后者追求最快的速度，所以后者有时候会为了运行速度添加一些额外的内容导致体积变大。我们可以在cmake文件中判断对应的值以便做出一些额外的设置。例如if(CMAKE_BUILD_TYPE STREQUAL "Debug") add_compile_definitions(-D_DEBUG) else() add_compile_definitions(-DNDEBUG) endif()有了这些基础，我们可以在不同的条件下，定义不同的编译宏，然后根据编译宏的不同在C/C++ 源码中判断这些宏从而隔离不同平台的实现代码通过cmake list 来隔离不同平台的代码使用 file 或者 aux_source_directory 的到的是一个源代码文件的列表。我们可以操作这个列表来达到控制编译文件的需求。cmake 中针对列表的操作符是 list，它的定义如下:Reading list(LENGTH <out-var>) list(GET <element index> [<index> ...] <out-var>) list(JOIN <glue> <out-var>) list(SUBLIST <begin> <length> <out-var>) Search list(FIND <value> <out-var>) Modification list(APPEND [<element>...]) list(FILTER {INCLUDE | EXCLUDE} REGEX <regex>) list(INSERT <index> [<element>...]) list(POP_BACK [<out-var>...]) list(POP_FRONT [<out-var>...]) list(PREPEND [<element>...]) list(REMOVE_ITEM <value>...) list(REMOVE_AT <index>...) list(REMOVE_DUPLICATES ) list(TRANSFORM <ACTION> [...]) Ordering list(REVERSE ) list(SORT [...])官方提供了这么一些操作list的操作符，但是在这个需求中我们只需要两个操作符APPEND 和 REMOVE_ITEM 即可。后面的参数分别是源列表，以及需要增加或者删除的项，它们都可以传入多项。但是删除时它是根据传入字符串，从列表中进行字符串比较，如果相等则进行删除。所以在传入路径的时候需要特别注意，不能一个传入全路径一个传入相对路径或者一个传入 / 开头的路径，另一个传入 ~ 开头的路径。上述两个操作都可以传入多个单个的字符串也可以传入一个列表。如果我们有下列目录结构src/platform/windows src/platform/linux src/*.cpp src/other/*.cpp也就说我们将不同平台的代码放入到src目录，并且src目录也有其他代码，我们如果使用 file 操作符来查找src目录中的源码文件必定会包含两个平台的实现代码。这个时候就可以考虑使用REMOVE_ITEM 根据平台来舍弃一些代码，例如file(GLOB_RECURSE SOURCES ${PROJECT_SOURCE_DIR}/src/*.c ) if(CMAKE_SYSTEM_NAME STREQUAL "Windows") file(GLOB_RECURSE NOT_INCLUDE ${PROJECT_SOURCE_DIR}/src/platform/linux/*.cpp ) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") file(GLOB_RECURSE NOT_INCLUDE ${PROJECT_SOURCE_DIR}/src/platform/windows/*.cpp ) endif() list( REMOVE_ITEM SOURCES SOURCE ${NOT_INCLUDE} )又或者我们采用最上面的给出的目录结构，也就是说platform 目录位于src目录之外，相对于src来说是额外添加的代码文件，那么就可以使用 APPEND 来进行添加if(CMAKE_SYSTEM_NAME STREQUAL "Windows") aux_source_directory(${PROJECT_SOURCE_DIR}/platform/windows PLATFORM_SOURCE) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") aux_source_directory(${PROJECT_SOURCE_DIR}/platform/linux PLATFORM_SOURCE) endif() list(APPEND SOURCES ${PLATFORM_SOURCE})通过 toolchain 文件来组织平台特殊配置cmake 允许我们在生成Makefile的时候指定toolchain 文件来实现一些自定义的配置。例如可以根据平台的不同将生成路径指定在对应的toolchain中。toolchain 的语法与cmake语法相同，例如针对Windows和Linux可以创建 win32_toolchain.cmake win64_toolchain.cmake linux_86_toolchain.cmake 和 linux_x64_toolchain.cmake 文件来区别我还是以上一篇文章中多工程嵌套的例子作为示例来演示如何使用，它的目录结构如下. ├── calc │ ├── add.cpp │ ├── CMakeLists.txt │ ├── div.cpp │ ├── mult.cpp │ └── sub.cpp ├── CMakeLists.txt ├── include │ ├── calc.h │ └── sort.h ├── sort │ ├── CMakeLists.txt │ ├── insert_sort.cpp │ └── select_sort.cpp ├── test_calc │ ├── CMakeLists.txt │ └── main.cpp └── test_sort ├── CMakeLists.txt └── main.cpp这个例子我们只需要改动根目录下的生成库和可执行程序的路径。cmake_minimum_required(VERSION 3.15) project(test) add_subdirectory(sort) add_subdirectory(calc) add_subdirectory(test_calc) add_subdirectory(test_sort)这个文件只需要保留最基础的配置即可，而生成程序的路劲都在 toolchain 中。下面是 linux_x86_toolchain.cmake 文件的内容set(LIBPATH ${PROJECT_SOURCE_DIR}/lib/linux/x86) set(EXECPATH ${PROJECT_SOURCE_DIR}/bin/linux/x86) set(HEADPATH ${PROJECT_SOURCE_DIR}/include) if(CMAKE_BUILD_TYPE STREQUAL "Debug") set(CALCLIB calc_d) set(SORTLIB sort_d) set(CALCAPP test_calc_d) set(SORTAPP test_sort_d) else() set(CALCLIB calc) set(SORTLIB sort) set(CALCAPP test_calc) set(SORTAPP test_sort) endif() set(CMAKE_SYSTEM_PROCESSOR i686) set(CMAKE_C_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE) set(CMAKE_CXX_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE) set(CMAKE_EXE_LINKER_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE)这个文件我们定义了debug和release的库名称和生成的路径，并且指定相关参数用于生成32位程序。CMAKE 中定义了一堆 CMAKE_LANGUAGE_FLAGS 这些都是相关工具的参数，这里的FLAGS 分别是 gcc 和 ld 编译链接的参数。在使用时直接用命令 cmake .. -DCMAKE_TOOLCHAIN_FILE=../linux_x32_toolchain.cmake -DCMAKE_BUILD_TYPE=Debug 生成Makefile。Windows平台上上面的参数稍微有些差距。例如下面是 windows_x32_toolchain.cmake 的定义# 静态库生成路径 set(LIBPATH ${PROJECT_SOURCE_DIR}/lib/windows/x86) # 可执行程序的存储目录 set(EXECPATH ${PROJECT_SOURCE_DIR}/bin/windows/x86) # 头文件路径 set(HEADPATH ${PROJECT_SOURCE_DIR}/include) if(CMAKE_BUILD_TYPE STREQUAL "Debug") # calc库名称 set(CALCLIB calc_d) # sort 库名称 set(SORTLIB sort_d) # 测试程序的名字 set(CALCAPP test_calc_d) set(SORTAPP test_sort_d) else() # calc库名称 set(CALCLIB calc) # sort 库名称 set(SORTLIB sort) # 测试程序的名字 set(CALCAPP test_calc) set(SORTAPP test_sort) endif() set(CMAKE_GENERATOR_PLATFORM "Win32" CACHE STRING "Target Platform") # 指定32位编译器路径 set(CMAKE_C_COMPILER "$ENV{VCToolsInstallDir}/bin/Hostx86/x86/cl.exe") set(CMAKE_CXX_COMPILER "$ENV{VCToolsInstallDir}/bin/Hostx86/x86/cl.exe") set(CMAKE_SYSTEM_PROCESSOR x86)需要注意的是 CMAKE_GENERATOR_PLATFORM 对应的是VS 中的解决方案平台，也就是 Win32 和 x64 这两个选项。 而 CMAKE_SYSTEM_PROCESSOR 对应的是VS中的目标计算机选项，一般是X86、X64 或者 ARM64 和 AMD64。我们可以使用命令 cmake -G "Visual Studio 15 2017" -A "win32" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_TOOLCHAIN_FILE="..\windows_x86_toolchain.cmake" .. 这里指定使用 VS 2017 进行构建，构建架构是32位，版本是Debug。命令成功执行之后会生成一个.sln 文件，我们可以用VS打开然后在VS中编译，也可以执行使用命令 cmake --build . --config Debug 来编译。一般来说我习惯使用后者，过去使用vs 打开.sln 可以在vs中进行开发，如今vs 已经可以打开并编译cmake工程，所以现在我基本不使用 .sln 文件了，除非公司项目要求使用 .sln。好了，目前我掌握的关于cmake的内容就是这些了，我利用这些知识已经完成了公司项目的跨平台开发和部署。后续如果有新的需求说不定我会学点新的内容，到时候再来更新这一系列文章吧！！！
很遗憾直到现在才开始接触cmake，过去都在微软的vs IDE上编写c++程序，即使引用第三方的库直接使用cmake也能编译成功，很少关注它本身的内容。但是现在我有一项工作的内容就是将在Windows平台上的c++程序移植到Linux平台上。我想选择cmake作为支持跨平台的构建工具。因此提前学了点cmake的基础知识。cmake本身并不能直接编译和链接程序，它是一个构建程序。主要作用就是根据cmake脚本来生成Makefile文件，以供nmake、gun make等工具来生成可执行程序。编译exe简单的hello world使用cmake需要提供一个CMakeLists.txt 的脚本文件，这个名称是固定的，位置一般在项目的根目录。假设现在有一个简单的hello world程序，它的项目目录可能如下v1 ├── CMakeLists.txt ├── main.cpp我们可以使用如下cmake脚本cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) add_executable(hello ./main.cpp)第一句的含义是指定使用cmake最小的版本为3.15；第二句的含义是使用c++ 11标准第三句的含义是指定项目名称第四句的含义是生成可执行程序的名称为hello，并且指定要编译的源文件是当前目录下的 main.cpp 文件。工程中有多个源文件时，add_executable 后面可以加多个源文件路径一般来说cmake脚本都会包含这么几条语句脚本编写完毕后，需要使用cmake命令进行编译。该命令可以接受一个参数用于指定CMakeLists.txt 文件所在的路径，执行之后会生成一大堆中间文件和对应的Makefile文件。这些都会生成在当前执行cmake命令时所在路径。所以为了便于管理，一般会在适当位置建立一个新的build目录。这个时候整个命令如下mkdir build cd build cmake .. make前面我们在项目根目录下新建一个build目录用于保存中间文件，然后切换到build目录中。接着执行cmake命令并给出对应CMakeLists.txt 所在的路径。执行成功后会在build目录中生成一个Makefile文件，最后就是执行make命令来生成可执行程序这样最简单的一个hello world工程就编译完成了。指定可执行程序的路径生成的可执行文件路径就在当前的build目录下，如果我们要指定可执行程序的路径，可以使用变量 EXECUTABLE_OUTPUT_PATH。它是cmake内置的变量，保存的是可执行程序输出的路径。在cmake中可以使用set来给变量赋值。到此我们的cmake脚本可能是这样的cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) add_executable(hello ./main.cpp)这里涉及到cmake中变量的定义和使用。其实cmake中变量特别简单，cmake中的变量全都是字符串，定义和设置变量值都是用set 操作符。而要取变量的值则使用 ${} 来包住一个变量名。另外cmake使用 EXECUTABLE_OUTPUT_PATH 作为可执行程序的输出路径，这里我们设置输出路径为工程目录下的bin目录下面。这里的 PROJECT_SOURCE_DIR 表示的是当前项目的目录指定头文件所在路径这里我们来一个复杂一点的项目作为演示，这个项目的目录结构如下. ├── include │ └── calc.h └── src ├── add.cpp ├── div.cpp ├── main.cpp ├── mul.cpp └── sub.cpp这种工程中，include目录放头文件，src目录放源文件，calc.h 中定义了4个函数分别实现加减乘除四则运算。它们的实现分别在 add.cpp、sub.cpp、mul.cpp、div.cpp 中，而main.cpp主要负责调用这些函数实现。main.cpp 的代码如下#include <stdio.h> #include "calc.h" int main (int argc, char *argv[]) { int a = 30; int b = 10; printf("a + b = %d\n", add(a, b)); printf("a - b = %d\n", sub(a, b)); printf("a * b = %d\n", mul(a, b)); printf("a / b = %d\n", div(a, b)); return 0; }这里我们要解决一个问题，因为main.cpp在src中，而 calc.h在include目录中，它们并不在同一目录下，代码中直接引用它会提示找不到对应的头文件。我们当然可以写出 include "../include/calc.h" 来修正它，但是项目中文件多了，不同路径的源文件要写这种相对路径就是一种折磨了。一般的经验是给出头文件的路径，后面所有源文件都根据这个路劲来组织包含头文件的相对路径。这里我们需要指定include作为头文件的路径。cmake中使用 include_directories 来指定头文件路径，它可以接受多个目录表示可以从这些目录中去查找头文件。所以这个项目的cmake文件可以这么写cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) include_directories(${PROJECT_SOURCE_DIR}/include) add_executable(hello ./src/add.cpp ./src/sub.cpp ./src/mul.cpp ./src/div.cpp ./src/main.cpp)遍历目录中的源文件上面的示例中我们发现 add_executable 后面加了好多cpp文件，这个项目比较小只有这么几个文件，如果一个项目有几百个源文件，并且每个源文件都在不同的目录，我们把每个源文件都这样一个个的写出来，不知道要写到什么时候呢。是否有办法能一次获取目录中的所有cpp文件，并保存在一个变量中，在需要指定源文件的场合直接使用这个变量，这样就简单很多了。cmake中当然有这个方法，它提供了两种方式来实现这个需求。第一种方式是使用 aux_source_directory。它接受一个目录，将指定目录中的所有源文件以list的形式放入到指定变量中，使用它可以将之前的cmake文件改写成下列形式cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) aux_source_directory(${PROJECT_SOURCE_DIR}/src SOURCES) include_directories(${PROJECT_SOURCE_DIR}/include) add_executable(hello ${SOURCES})这里我们遍历src目录中的所有源文件，将结果放入到变量SOURCES中。最后在add_executable中将这个结果传入，作为源文件参与最后的编译。第二种方式是可以使用file函数，它能遍历指定目录中的指定文件，并且将结果返回到对应参数中，它的使用方式如下file(<GLOB|GLOB_RECURSE> <variable> [LIST_DIRECTORIES])第一个参数是 GLOB 或者是 GLOB_RECURSE。后者表示递归遍历所有子目录中的文件。第二个参数是变量，最后会将遍历的结果放入到这个变量中。第三个参数是一个可选的，它表示筛选条件，可以填入多个条件。我们可以将上面的aux_source_directories 替换成 file，写成如下形式file(GLOB_RECURSE SOURCES ${PROJECT_SOURCE_DIR}/src/*.cpp)编译静态库和动态库我们再来修改一下这个工程。我们将四则运算的操作独立出来编译为一个静态库，然后在另一个工程中链接这个库并调用这些函数。这个时候可以这么组织工程，在上一个工程的基础上删除main.cpp 就可以了。编译静态库可以使用 add_library 操作符，它用来生成库文件。它可以编译动态库或者静态库。第一个参数是库的名称，最终会生成一个名称为 libname.a 或者 libname.so 的文件，其中name是我们指定的第一个参数；第二个参数是STATIC 或者 SHARED 分别是编译静态库和动态库。第三个参数是编译时需要参与便于的代码源文件。 所以我们的CMakeLists.txt 文件可以这样写cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) file(GLOB_RECURSE SOURCES ${PROJECT_SOURCE_DIR}/src/*.cpp) include_directories(${PROJECT_SOURCE_DIR}/include) # 编译动态库 # add_library(mylib SHARED ${SOURCES}) # 编译静态库 add_library(mylib STATIC ${SOURCES})上面的配置中，使用 LIBRARY_OUTPUT_PATH 来指定库文件生成的路径，最终会在bin目录下生成一个名为 libmylib.so 或者 libmylib.a 的库文件链接静态库和动态库上面我们编译生成了静态库和动态库，该如何在工程中引用它们呢？引用动态库或者静态库可以使用 target_link_libraries。它可以链接静态库或者动态库。在指定要链接的库名称为name 之后，它默认会优先从用户指定的位置查找名为 libname.a 或者 libname.so 的库，如果用户未指定位置或者在指定位置未找到对应的库，那么它会到系统库中查找，都找不到则会报错。我们可以通过 link_directories 来指定库文件的路径，下面是一个示例cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) include_directories(${PROJECT_SOURCE_DIR}/include) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) link_directories(${PROJECT_SOURCE_DIR}/lib) add_executable(hello ${PROJECT_SOURCE_DIR}/main.cpp) target_link_libraries(hello mylib )target_link_library 需要放到 add_executable 或者 add_library 之后，它的第一个参数就是我们在 add_executable 或者 add_library 中给定的生成程序的名称。添加编译宏一般来说，在代码中对于debug版本会额外的输出一些日志信息用于调试，或者根据不同版本来调整某个数据结构的定义，例如#ifdef X64 typedef unsigned long long ULONG_PTR #else typedef unsigned long ULONG_PTRVS 中可以通过预处理器来指定编译时的宏，而GCC 可以通过-D 来指定宏。cmake中也类似，它可以通过 add_compile_definies 来指定宏。它传入的参数于GCC定义宏类似，以-D开头后面跟宏的名称，例如要定义名为 _DEBUG 的宏，可以写成 -D_DEBUG。定义宏后面还可以使用 = 来指定宏的值。下面是一个具体的例子#include <stdio.h> int main (int argc, char *argv[]) { #ifdef _DEBUG printf("this is debug version\n"); #endif printf("the app version is %s\n", VERSION); return 0; }cmake_minimum_required(VERSION 3.15) set(CMAKE_CXX_STANDARD 11) project(test) set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) add_compile_definitions( -D_DEBUG -DVERSION="1.0.1") add_executable(hello ${PROJECT_SOURCE_DIR}/main.cpp)多个工程嵌套一般在项目中，可能有多个子项目，例如一个web商场可能有前后端之分。在cmake中项目有子工程的话，将各个子工程放到主工程的子目录下，然后使用 add_subdirectory 将各个子项目连接起来。下面是一个具体的例子. ├── calc │ ├── add.cpp │ ├── CMakeLists.txt │ ├── div.cpp │ ├── mult.cpp │ └── sub.cpp ├── CMakeLists.txt ├── include │ ├── calc.h │ └── sort.h ├── sort │ ├── CMakeLists.txt │ ├── insert_sort.cpp │ └── select_sort.cpp ├── test_calc │ ├── CMakeLists.txt │ └── main.cpp └── test_sort ├── CMakeLists.txt └── main.cpp上述项目有4个子工程，分别是四则运算的calc 、排序算法的 sort。以及对应的测试用例test_calc 和 test_sort。算法编译成静态库，测试工程直接链接对应的静态库。基于以上布局，我们在主工程的 CMakeLists.txt 可以这么写cmake_minimum_required(VERSION 3.15) project(test) # 定义变量 # 静态库生成路径 set(LIBPATH ${PROJECT_SOURCE_DIR}/lib) # 可执行程序的存储目录 set(EXECPATH ${PROJECT_SOURCE_DIR}/bin) # 头文件路径 set(HEADPATH ${PROJECT_SOURCE_DIR}/include) # calc库名称 set(CALCLIB calc) # sort 库名称 set(SORTLIB sort) # 测试程序的名字 set(CALCAPP test_calc) set(SORTAPP test_sort) # 添加子目录 add_subdirectory(sort) add_subdirectory(calc) add_subdirectory(test_calc) add_subdirectory(test_sort)在这个文件我们定义了一些其他工程都会用到的一些配置，例如包含的头文件路径、生成程序的路径。以及项目中包含的子项目。在最外层定义的变量可以直接在子工程的cmake 配置文件中使用。这里有点像派生类可以使用基类定义的变量。在calc 子工程中，可以这么配置cmake_minimum_required(VERSION 3.15) project(calc) # 指定要编译的源文件 aux_source_directory(./ SOURCES) # 指定头文件的路径 include_directories(${HEADPATH}) # 指定生成库的路径 set(LIBRARY_OUTPUT_PATH ${LIBPATH}) # 指定生成库的名称 add_library(${CALCLIB} STATIC ${SOURCES})calc 子工程使用根目录工程中定义的变量指定了生成库的路径、库名称。并且直接定义编译成静态库在test_calc 这个测试程序中，可以这么配置cmake_minimum_required(VERSION 3.15) project(test_calc) # 指定头文件的路径 include_directories(${HEADPATH}) # 指定生成exe的路径 set(EXECUTABLE_OUTPUT_PATH ${EXECPATH}) # 指定库文件的目录 link_directories(${LIBPATH}) # 生成可执行文件名称 add_executable(${CALCAPP} ./main.cpp) target_link_libraries( ${CALCAPP} ${CALCLIB} )在测试工程中使用父工程中定义的变量指定了生成程序的路径以及链接库的路径。其他的工程与上面两个子工程的配置类似，只需要改一些变量。就可以运行了。至此, 已经介绍完了使用cmake配置工程的一些基本配置。我们几乎可以将VS 中的项目配置一比一的使用上述内容使用cmake复刻一遍。至于跨平台的配置，无外乎是一些常见的标志判断，根据条件设置变量即可。后续如果我还有好的cmake使用实践也会分享出来。
在上一篇文章中，完成了对括号的支持，这样整个程序就可以解析普通的算术表达式了。但是在解析两个括号的过程中发现有大量的地方需要进行索引的回退操作，索引的操作应该保证能得到争取的token，这个步骤应该放在词法分析的阶段，如果在语法分析阶段还要考虑下层词法分析的过程，就显得有些复杂了。而且随着后续支持的符号越来越多，可能又得在大量的地方进行这种索引变更的操作，代码将难以理解和维护。因此这里先停下来进行一次代码的重构。基本架构这里的代码我按照教程里面的结构进行组织。将按照程序的逻辑分为3层，最底层负责操作字符串的索引保证下次获取token的时候索引能在正确的位置。第二层是词法分析部分，负责给字符串的每个部分都打上对应的token。第三个部分是语法分析的部分，它负责解析之前设计的BNF范式，并计算对应的结果。详细的代码上面给出模块划分的概要可能没怎么说清楚，下面将通过代码来进行详细的说明。Token 模块为了支持这个设计，首先变更一下全局变量的定义，现在定义的全局变量如下所示extern Token g_currentToken; //当前token extern int g_nPosition; //当前字符索引的位置 extern char g_currentChar; //当前字符串之前通过 get_next_char() 来返回当前指向的token并变更索引的时候发现我们在任何时候想获取当前指向的字符时永远要变更索引，这样就不得不考虑在某些时候要进行索引的回退。比如在解析整数退出的时候，此时当前字符已经指向下一个字符了，但是我们在接下来解析其他符号的时候调用 get_next_char() 导致索引多增加了一个。这种情况经常出现，因此这里使用全局变量保存当前字符，只在需要进行索引增加的时候进行增加。另外我们不希望上层来直接操作这个索引，因此在最底层的Token模块提供一个名为 advance() 的函数用于将索引加一，并获取之后的字符。它的定义如下void advance() { g_nPosition++; // 如果到达字符串尾部，索引不再增加 if (g_nPosition >= strlen(g_pszUserBuf)) { g_currentChar = '\0'; } else { g_currentChar = g_pszUserBuf[g_nPosition]; } }这样在对应需要用到当前字符的位置就不再使用 get_next_char() , 而是改用全局变量 g_currentChar。例如现在的 skip_whitespace 函数现在的定义如下void skip_whitespace() { while (is_space(g_currentChar)) { advance(); } }这样我们在获取下一个token的时候只在必要的时候进行索引的递增。lex 模块由于打标签的工作交个底层的Token模块了，该模块主要用来实现词法分析的功能，也就是给各个部分打上标签，根据之前Token部分提供的接口，需要对 get_next_token 函数进行修改。bool get_next_token() { dyncstring_reset(&g_currentToken.value); while (g_currentChar != '\0') { if (is_digit(g_currentChar)) { g_currentToken.type = CINT; parser_number(&g_currentToken.value); return true; } else if (is_space(g_currentChar)) { skip_whitespace(); } else { switch (g_currentChar) { case '+': g_currentToken.type = PLUS; dyncstring_catch(&g_currentToken.value, '+'); advance(); break; case '-': g_currentToken.type = MINUS; dyncstring_catch(&g_currentToken.value, '-'); advance(); break; case '*': g_currentToken.type = DIV; dyncstring_catch(&g_currentToken.value, '*'); advance(); break; case '/': g_currentToken.type = MUL; dyncstring_catch(&g_currentToken.value, '/'); advance(); break; case '(': g_currentToken.type = LPAREN; dyncstring_catch(&g_currentToken.value, '('); advance(); break; case ')': g_currentToken.type = RPAREN; dyncstring_catch(&g_currentToken.value, ')'); advance(); break; case '\0': g_currentToken.type = END_OF_FILE; break; default: return false; } return true; } } return true; }在这个函数中，将不再通过输出参数来返回当前的token，而是直接修改全局变量。同时也不再使用get_next_char 函数来获取当前指向的字符，而是直接使用全局变量。并且在适当的时机调用advance 来实现递增。另外在上层我们直接使用 g_currentToken 拿到当前的token，而在适当的时机调用新增的eat() 函数来实现更新token的操作。bool eat(LPTOKEN pToken, ETokenType eType) { if (pToken->type == eType) { get_next_token(); return true; } return false; }该函数接受两个参数，第一个是当前token的值，第二个是我们期望当前token是何种类型。如果当前token的类型与期望的不符则报错，否则更新token。interpreter 模块该模块主要负责解析根据前面的BNF范式来完成计算并解析内容。这个模块提供三个函数get_factor、get_term、expr。这三个函数的功能没有变化，只是在实现上依靠lex 模块提供的功能。主要思路是直接使用 g_currentToken 这个全局变量来获得当前的token，使用 eat() 来更新并获得下一个token的值。这里我们以get_factor() 函数为例int get_factor(bool* pRet) { int value = 0; if (g_currentToken.type == CINT) { value = atoi(g_currentToken.value.pszBuf); *pRet = eat(&g_currentToken, CINT); } else { if (g_currentToken.type == LPAREN) { bool bValid = true; bValid = eat(&g_currentToken, LPAREN); value = expr(&bValid); bValid = eat(&g_currentToken, RPAREN); *pRet = bValid; } } return value; }与前面分析的相同，该函数主要负责获取整数和计算括号中子表达式的值。在解析完整数和括号中的子表达式之后，需要调用eat分别跳过对应的值。只是在识别到括号之后需要跳过左右两个括号。这样就完成了对应的分层，每层只负责自己该做的事。不用在上层考虑修改索引的问题，结构也更加清晰，未来在添加功能的时候也更加方便。剩下几个函数就不再贴出代码了，感兴趣的小伙伴可以去对应的GitHub仓库上查阅相关代码。
从0开始自制解释器——添加对括号的支持
在上一篇我们添加了对乘除法的支持，也介绍了BNF范式，并且针对当前的算术表达式写出了对应的范式，同时根据范式给出相应的代码实现。这篇我们将继续为算数表达式添加对括号的支持。对应的BNF 范式在上一篇我们给出了乘除法对应的范式<expr>::=<term>{(PLUS|MINUS)<term>} <term>::=<factor>{(DIV|MUL)<factor>} <factor>::={(0|1|2|3|4|5|6|7|8|9)}针对乘除法的优先级比加减法高，我们的做法是将乘除法单独作为一个部分，然后在最外层表达式中只处理加减法。基于这种思路，我们来看如何处理括号的问题。例如下面的算数表达式((1+2)*3+4) - (5 - 6 / 3)这里我们直接给出对应的文法，然后再来分析一下该如何由这个文法得到对应的表达式<expr>::=<term>{(PLUS|MINUS)<term>} <term>::=<factor>{(DIV|MUL)<factor>} <factor>::=({(0|1|2|3|4|5|6|7|8|9|)})|LPAREN<expr>RPAREN首先根据表达式，它应该由两个term来组成 expr = term - term接着看看两个term，它们并不是单纯的加法运算，所以两个term应该只有单纯的一个factor，也就是 expr = factor - factor因为最外层都有括号，所以再次展开 expr = (expr1) - (expr2)这时就又到了分析expr的过程了，左侧的expr最外层是一个加法，所以这里可以得到 expr1 = term + term右侧的expr 最外层是一个减法，也就是 expr2 = term - term结合最外层的表达式可以得到 expr = (term1 + term2) - (term3 - term4)term1 部分有一个乘法，所以它可以解析为 term1 = factor * factorterm2 部分就是单独的数字所以可以得到 term2 = factor，并且进一步得到 term2=4term3 部分就是单纯的数字，可以得到 term3 = factor，并且进一步得到 term3=5term4 部分有一个除法，所以它可以解析为 term3 = factor / factor此时整个表达式可以表示为 expr = (factor1 * factor2 + 4) - (5 - factor3 / factor4)factor1 本身也是一个括号，加表达式，所以它可以表示为 factor1 = (expr)factor2 是一个数字，所以它表示为 factor2 = 3factor3 是一个数字，所以它表示为 factor3 = 6factor4 是一个数字，所以它表示为 factor4 = 3此时表达式可以是 expr = ((expr1) * 3 + 4) - (5 - 6 / 3)此时再次分析这个 expr1 可以得到 expr1 = 1+2这个时候，整个表达式就出来了 expr = ((1+2) * 3 + 4) - (5 - 6 / 3)用图来表示大概可以表示如下代码实现有了范式，我们就可以按照范式来组织代码实现。首先我们先在 ETokenType 中添加针对括号的标签typedef enum e_TokenType { CINT = 0, //整数 PLUS, //加法 MINUS, //减法 DIV, //乘法 MUL, //除法 LPAREN, //左括号 RPAREN, //右括号 END_OF_FILE // 字符串末尾结束符号 }ETokenType;然后在 get_next_token 函数中添加对括号进行词法分析并打标签的功能bool get_next_token(LPTOKEN pToken) { char c = get_next_char(); dyncstring_reset(&pToken->value); if (is_digit(c)) { dyncstring_catch(&pToken->value, c); pToken->type = CINT; parser_number(&pToken->value); } else if(is_space(c)) { skip_whitespace(); return get_next_token(pToken); } else { switch (c) { case '+': pToken->type = PLUS; break; case '-': pToken->type = MINUS; break; case '*': pToken->type = DIV; break; case '/': pToken->type = MUL; break; case '(': pToken->type = LPAREN; break; case ')': pToken->type = RPAREN; break; case '\0': pToken->type = END_OF_FILE; break; default: return false; } } return true; }这里我对这个函数进行了一些改写，针对依靠单个字符就能打上标签的采用switc来进行处理，像空白字符、数字这种有多种字符类型的就采用普通的if处理。然后在get_oper 中添加对括号的识别 if (get_next_token(&token) && (token.type == PLUS || token.type == MINUS || token.type == DIV || token.type == MUL || token.type == LPAREN || token.type == RPAREN)) { oper = token.type; if (pRet) *pRet = true; }然后根据文法，get_factor 需要能够返回一个 expr的结果，所以这里需要添加以下代码 if (token.type == LPAREN) { bool bValid = true; value = expr(&bValid); if (!bValid) *pRet = false; if (get_next_token(&token) && token.type == RPAREN) *pRet = true; else *pRet = false; }如果我们得到的标签不为括号则按照原来的处理方式来处理，如果是括号，则将括号中的内容作为表达式并计算表达式的值，作为整数来返回。之前的expr 函数我们仅仅将结果打印并返回是否解析成功，这里需要做一些改进。我们使用一个传出参数来返回解析是否成功，而将计算结果作为值进行返回。另外需要特别注意的是，我们将反括号的判断放到了 get_factor 函数中,所以在 get_term 和 expr 中，遇到反括号应该考虑对位置索引进行递减，并且遇到反括号应该认为到达末尾并推出。这里的代码就不贴出来了。有兴趣的小伙伴可以看github上上传的代码。地址
从0开始自制解释器——添加对乘除法的支持
在上一篇中，我们实现了对减法的支持，并且介绍了语法图。针对简单的语法进行描述，用语法图描述当然是没问题的。但是针对一些复杂的语法进行描述，如果每个部分都通过语法图来描述就显得有些繁琐了。这篇我们先介绍另一种描述语法的方式，并进一步介绍一些关于语法分析的知识。BNF范式与上下文无关文法巴科斯范式 以美国人巴科斯(Backus)和丹麦人诺尔(Naur)的名字命名的一种形式化的语法表示方法，用来描述语法的一种形式体系，是一种典型的元语言。又称巴科斯-诺尔形式(Backus-Naur form)。它不仅能严格地表示语法规则，而且所描述的语法是与上下文无关的。它以递归方式描述语言中的各种成分，凡遵守其规则的程序就可保证语法上的正确性。它具有语法简单，表示明确，便于语法分析和编译的特点。BNF表示语法规则的方式为：非终结符用尖括号括起。每条规则的左部是一个非终结符，右部是由非终结符和终结符组成的一个符号串，中间一般以“：：=”分开。具有相同左部的规则可以共用一个左部，各右部之间以直竖“|”隔开。所谓非终结符就是语言中某些抽象的概念不能直接出现在语言中的符号，终结符就是可以直接出现在语言中的符号。其实这些都是一些官话，初看起来只觉得拗口和难以理解，但是它的形式非常简单。它主要是用下面几个符号来表达含义使用<>来表示必须包含的部分使用[]来表示可选部分使用{}来表示可以重复0次或者无数次使用|来表示左右两边任选一部分，相当于OR使用::=来表示被定义为现在来给出具体的例子，我们都看过《西游记》，里面的取经4人组包括唐僧、孙悟空、猪八戒和沙僧。使用BNF范式进行定义，可以写成 <取经团队>::=<唐僧><孙悟空><猪八戒><沙僧>。我们再来举一个例子，我们知道一个文章由若干个段落组成、一个段落由若干个句子组成、一个句子由符合一定语法规则的汉字组成并且以句号作为结尾。我们简单的将句子的语法规则定义为主谓宾三个部分。而这里的主谓宾我们简单的用一些名词和动词来定义。因此这里的一系列结构可以定义为如下内容<文章>::={<段落>}<段落>::={<句子>}<句子>::=<主语><谓语><宾语>。<主语>::=人|狗|猫|天<谓语>::=吃|抓|下<宾语>::=饭|雨|肉|鱼根据这个表达式我们很容易的推出类似 人吃饭。、天下雨。、猫抓鱼。 这样的句子。相信到这里小伙伴应该明白BNF范式的一些基本概念和使用方式了。我们再来插入一个题外话，既然这里提到BNF范式是一种上下文无关文法，那什么是上下文、什么是上下文无关。先别着急了解概念，我们仍然通过例子来说明。在上述的句子的定义中，我们一共可以生成 4 * 3 * 4 = 48 种 结果，我们可以获得类似 人吃饭。、猫抓鱼。这种有意义的句子，也可能产生像天吃鱼。、人下雨 这种读起来感觉别扭的非正常语句。但是在上下文无关的语法中，主语宾语和谓语的内容没有相互关联，也就是说谓语和宾语的产生与主语无关。那上下文有关的文法呢？这里为了产生一些有意义的句子，我们给它加上一些限定。例如人后面只能接 吃 抓作为谓语、而当吃作为谓语时只能将 饭、肉、鱼作为宾语。针对这种需求，我们可以进行如下定义<句子>::=<主语><谓语><宾语>。<主语>::=人|狗|猫|天人<谓语>::=人(吃|抓)吃<宾语>::=吃(饭｜肉|鱼)这样我们对这个产生式进行了一些限定，当主语是人的时候，谓语只能产生吃和抓这样的宾语。这种情况下的描述就被称之为上下文有关。上下文无关我自己的理解就是后续表达式的产生不依赖前面已产生的内容。而上下文有关的含义则与之相法。这个上下文就跟我们这么多年阅读理解题里面写的“请根据上下文来理解某个词表达了作者怎样的心情”这里的上下文类似。当然更加规范的说法就是，在应用一个产生式进行推导时，前后已经推导出的部分结果就是上下文。上下文无关就是只要文法的定义里面有一个定义，不管前面的产生串是什么都可以应用相应的产生推导后面的内容。代码编写上面的定义只是开胃菜，希望通过上面的描述，小伙伴能够理解BNF范式的应用，至于上下文无关和上下文有关。这些暂时不用考虑，毕竟我们目前还是在做上下文无关文法相关的内容。这里我们要支持乘法和除法，首先要做的就是在 ETokenType 结构中添加对乘法和除法相关的定义typedef enum e_TokenType { CINT = 0, //整数 PLUS, //加法 MINUS, //减法 DIV, //乘法 MUL, //除法 END_OF_FILE // 字符串末尾结束符号 }ETokenType;接着在 get_next_token和 get_oper() 函数中添加对这两个运算符的支持// get_next_token else if (c == '*') { pToken->type = DIV; dyncstring_catch(&pToken->value, '*'); } else if (c == '/') { pToken->type = MUL; dyncstring_catch(&pToken->value, '/'); } // get_oper if (get_next_token(&token) && (token.type == PLUS || token.type == MINUS || token.type == DIV || token.type == MUL)) { oper = token.type; if (pRet) *pRet = true; }现在词法分析部分已经可以支持乘除法的符号解析了。接着来完成语法分析的部分。首先我们来定义一下这个简单计算器的文法。<expr>::=<term>{<oper><term>} <term>::={0|1|2|3|4|5|6|7|8|9} <oper>::=PLUS|MINUS|DIV|MUL回忆一下上一节给出的语法图，理解这个表达式并不算困难。但是这里我们定义的文法有一个问题，就是从文法上体现不出运算的优先级。学过小学数学的都知道算数运算中优先计算乘除法，最后算加减法。但是根据这个文法我们无法体现出乘除法的优先级。因此这里我们需要修改定义。优先计算乘除法在文法上可以理解成，乘除法单独成一个部分，我们获取这个部分的计算结果最后与其他部分一起计算加减法。用BNF范式来体现就是<expr>::=<term>{(PLUS|MINUS)<term>} <term>::=<factor>{(DIV|MUL)<factor>} <factor>::={0|1|2|3|4|5|6|7|8|9}与语法图类似，范式也可以很容易转化为代码。允许出现多次的我们在代码实现上体现为循环。而文法中相关的定义我们直接采用一些get方式来获取对应被打上标记的值即可。上述文法描述可以转化为如下的c 代码int expr() { bool bRet = false; int result = get_term(&bRet); int bEOF = false; do { ETokenType oper = get_oper(&bRet); switch (oper) { case PLUS: { int num = get_term(&bRet); if(bRet) result += num; } break; case MINUS: { int num = get_term(&bRet); if(bRet) result -= num; } break; case END_OF_FILE: printf("%d\n", result); bEOF = true; break; default: bRet = false; break; } } while (bRet && !bEOF); if (!bRet) { printf("Syntax Error!\n"); } return 0; }上述expr的定义就是由一个term加若干个 +|- 和后面的若干个term 来组成，因此这里有一个循环。来取出所有term 和所有加减法，并进行计算。int get_term(bool* pValid) { int result = get_factor(pValid); int bEOF = false; do { ETokenType oper = get_oper(pValid); switch (oper) { case DIV: { int num = get_factor(pValid); if (*pValid) result *= num; } break; case MUL: { int num = get_factor(pValid); if (*pValid) result /= num; } break; case PLUS: case MINUS: { g_pPosition--; bEOF = true; } break; case END_OF_FILE: { g_pPosition--; bEOF = true; } } } while (pValid && !bEOF); return result; }而term 则是由整数以及若干个乘除法和另一个整数组成，所以代码中也用循环来取一直到取到不是这个term 定义所组成的部。注意这里与之前一样，当取到term的结束部分，我们仍然需要将索引进行递减。而最终的oper 和 factor 则保持原来的算法不变。好了，本篇到此也就结束了，小伙伴可以到该位置 取出代码来进行阅读和修改。
从0开始自制解释器——实现多位整数的加减法计算器
上一篇我们实现了一个简单的加法计算器，并且了解了基本的词法分析、词法分析器的概念。本篇我们将要对之前实现的加法计算器进行扩展，我们为它添加以下几个功能计算减法能自动识别并跳过空白字符不再局限于单个整数，而是能计算多位整数提供一些工具函数首先为了支持减法，我们需要重新定义一下TokenType这个类型，也就是需要给 - 定义一个标志。现在我们的TokenType的定义如下typedef enum e_TokenType { CINT = 0, PLUS, MINUS, END_OF_FILE }ETokenType;由于需要支持多个整数，所以我们也不知道最终会有多少个字符，因此我们提供一个END_OF_FILE 表示我们访问到了最后一个字符，此时应该退出词法分析的过程。另外因为整数个数不再确定，我们也就不能按照之前的提供一个固定大小的数组。虽然可以提供一个足够大的空间来作为存储数字的缓冲，但是数字少了会浪费空间。而且考虑到之后要支持自定义变量和函数，采用固定长度缓冲的方式就很难找到合适的大小，太大显得浪费空间，太小有时候无法容纳得下用户定义的变量和函数名。因此这里我们采用动态长度的字符缓冲来保存。我们提供一个DyncString 的结构来保存这些内容#define DEFAULT_BUFFER_SIZE 16 // 动态字符串结构，用于保存任意长度的字符串 typedef struct DyncString { int nLength; // 字符长度 int capacity; //实际分配的空间大小 char* pszBuf; //保存字符串的缓冲 }DyncString, *LPDyncString; // 动态字符串初始化 // str: 被初始化的字符串 // size: 初始化字符串缓冲的大小，如果给0则按照默认大小分配空间 void dyncstring_init(LPDyncString str, int size); // 动态字符串空间释放 void dyncstring_free(LPDyncString str); //重分配动态字符串大小 void dyncstring_resize(LPDyncString str, int newSize); //往动态字符串中添加字符 void dyncstring_catch(LPDyncString str, char c); // 重置动态数组 void dyncstring_reset(LPDyncString str);它们的实现如下/*----------------------------动态数组的操作函数-------------------------------*/ void dyncstring_init(LPDyncString str, int size) { if (NULL == str) return; if (size == 0) str->capacity = DEFAULT_BUFFER_SIZE; else str->capacity = size; str->nLength = 0; str->pszBuf = (char*)malloc(sizeof(char) * str->capacity); if (NULL == str->pszBuf) { error("分配内存失败\n"); } memset(str->pszBuf, 0x00, sizeof(char) * str->capacity); } void dyncstring_free(LPDyncString str) { if (NULL == str) return; str->capacity = 0; str->nLength = 0; if (str->pszBuf == NULL) return; free(str->pszBuf); } void dyncstring_resize(LPDyncString str, int newSize) { int size = str->capacity; for (; size < newSize; size = size * 2); char* pszStr = (char*)realloc(str->pszBuf, size); str->capacity = size; str->pszBuf = pszStr; } void dyncstring_catch(LPDyncString str, char c) { if (str->capacity == str->nLength + 1) { dyncstring_resize(str, str->capacity + 1); } str->pszBuf[str->nLength] = c; str->nLength++; } void dyncstring_reset(LPDyncString str) { dyncstring_free(str); dyncstring_init(str, DEFAULT_BUFFER_SIZE); } /*----------------------------End 动态数组的操作函数-------------------------------*/另外提供一些额外的工具函数，他们的定义如下void error(char* lpszFmt, ...) { char szBuf[1024] = ""; va_list arg; va_start(arg, lpszFmt); vsnprintf(szBuf, 1024, lpszFmt, arg); va_end(arg); printf(szBuf); exit(-1); } bool is_digit(char c) { return (c >= '0' && c <= '9'); } bool is_space(char c) { return (c == ' ' || c == '\t' || c == '\r' || c == '\n'); }主要算法我们还是延续之前的算法，一个字符一个字符的解析，只是现在需要额外的将多个整数添加到一块作为一个整数处理。而且需要添加跳过空格的处理。首先我们对上次的代码进行一定程度的重构。我们添加一个函数专门用来获取下一个字符char get_next_char() { // 如果到达字符串尾部，索引不再增加 if (g_pPosition == '\0') { return '\0'; } else { char c = *g_pPosition; g_pPosition++; return c; } }expr() 函数里面大部分结构不变，主要算法仍然是按次序获取第一个整数、获取算术运算符、获取第二个整数。只是现在的整数都变成了采用 dyncstring 结构来存储int expr() { int val1 = 0, val2 = 0; Token token = { 0 }; dyncstring_init(&token.value, DEFAULT_BUFFER_SIZE); if (get_next_token(&token) && token.type == CINT) { val1 = atoi(token.value.pszBuf); } else { printf("首个操作数必须是整数\n"); dyncstring_free(&token.value); return -1; } int oper = 0; if (get_next_token(&token) && (token.type == PLUS || token.type == MINUS)) { oper = token.type; } else { printf("第二个字符必须是操作符， 当前只支持+/-\n"); dyncstring_free(&token.value); return -1; } if (get_next_token(&token) && token.type == CINT) { val2 = atoi(token.value.pszBuf); } else { printf("操作符后需要跟一个整数\n"); dyncstring_free(&token.value); return -1; } switch (oper) { case PLUS: { printf("%d+%d=%d\n", val1, val2, val1 + val2); } break; case MINUS: { printf("%d-%d=%d\n", val1, val2, val1 - val2); } break; default: printf("未知的操作!\n"); break; } dyncstring_free(&token.value); }最后就是最终要的 get_next_token 函数了。这个函数最主要的修改就是添加了解析整数和跳过空格的功能bool get_next_token(LPTOKEN pToken) { char c = get_next_char(); dyncstring_reset(&pToken->value); if (is_digit(c)) { dyncstring_catch(&pToken->value, c); pToken->type = CINT; parser_number(&pToken->value); } else if (c == '+') { pToken->type = PLUS; dyncstring_catch(&pToken->value, '+'); } else if (c == '-') { pToken->type = MINUS; dyncstring_catch(&pToken->value, '-'); } else if(is_space(c)) { skip_whitespace(); return get_next_token(pToken); } else if ('\0' == c) { pToken->type = END_OF_FILE; } else { return false; } return true; }在这个函数中我们先获取第一个字符，如果字符是整数则获取后面的整数并直接拼接为一个完整的整数。如果是空格则跳过接下来的空格。这两个是可能要处理多个字符所以这里使用了单独的函数来处理。其余只处理单个字符可以直接返回。parser_number 和 skip_whitespace 函数比较简单，主要的过程是不断从输入中取出字符，如果是空格则直接将索引往后移动，如果是整数则像对应的整数字符串中将整数字符加入。void skip_whitespace() { char c = '\0'; do { c = get_next_char(); } while (is_space(c)); // 遇到不是空白字符的，下次要取用它，这里需要重复取用上次取出的字符 g_pPosition--; } void parser_number(LPDyncString dyncstr) { char c = get_next_char(); while(is_digit(c)) { dyncstring_catch(dyncstr, c); c = get_next_char(); } // 遇到不是数字的，下次要取用它，这里需要重复取用上次取出的字符 g_pPosition--; }唯一需要注意的是，最后都有一个 g_pPosition-- 的操作。因为当我们发现下一个字符不符合条件的时候，它已经过了最后一个数字或者空格了，此时应该已经退回到get_next_token 函数中了，这个函数第一步就是获取下一个字符，因此会产生字符串被跳过的现象。所以这里我们执行 -- 退回到上一个位置，这样再取下一个就不会有问题了。最后为了能够获取空格的输入，我们将之前的scanf 改成 gets。这样就大功告成了。我们来测试一下结果最后的总结最后来一个总结。本篇我们对上一次的加法计算器进行了简单的改造，支持加减法、能跳过空格并且能够计算多位整数。在上一篇文章中，我们提到了Token，并且说过，像 get_next_token 这样给字符串每个部分打上Token的过程就是词法分析。get_next_token 这部分代码可以被称之为词法分析器。这篇我们再来介绍一下其他的概念。词位(lexeme): 词位的中文解释是语言词汇的基本单位。例如汉语的词位是汉字，英语的词位是基本的英文字母。对于我们这个加法计算器来说基本的词位就是数字以及 +\- 这两个符号parsing（语法分析）和 parser（语法分析器） 我们所编写的expr函数主要工作流程是根据token来组织代码行为。它的本质就是从Token流中识别出对应的结构，并将结构翻译为具体的行为。例如这里找到的结构是 CINT oper CINT。并且将两个int 按照 oper 指定的运算符进行算术运算。这个将Token流中识别出对应的结构的过程我们称之为语法分析，完成语法分析的组件被称之为语法分析器。expr 函数中即实现了语法分析的功能，也实现了解释执行的功能。