首页
归档
友情链接
关于
Search
1
在wsl2中安装archlinux
80 阅读
2
nvim番外之将配置的插件管理器更新为lazy
58 阅读
3
2018总结与2019规划
54 阅读
4
PDF标准详解(五)——图形状态
33 阅读
5
为 MariaDB 配置远程访问权限
30 阅读
心灵鸡汤
软件与环境配置
博客搭建
从0开始配置vim
Vim 从嫌弃到依赖
archlinux
Emacs
MySQL
Git与Github
AndroidStudio
cmake
读书笔记
菜谱
编程
PDF 标准
从0自制解释器
qt
C/C++语言
Windows 编程
Python
Java
算法与数据结构
PE结构
登录
Search
标签搜索
c++
c
学习笔记
windows
文本操作术
编辑器
NeoVim
Vim
win32
VimScript
Java
emacs
linux
文本编辑器
elisp
反汇编
OLEDB
数据库编程
数据结构
内核编程
Masimaro
累计撰写
308
篇文章
累计收到
27
条评论
首页
栏目
心灵鸡汤
软件与环境配置
博客搭建
从0开始配置vim
Vim 从嫌弃到依赖
archlinux
Emacs
MySQL
Git与Github
AndroidStudio
cmake
读书笔记
菜谱
编程
PDF 标准
从0自制解释器
qt
C/C++语言
Windows 编程
Python
Java
算法与数据结构
PE结构
页面
归档
友情链接
关于
搜索到
147
篇与
的结果
2025-04-12
使用CMake跨平台的一些经验
使用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 <list> <out-var>) list(GET <list> <element index> [<index> ...] <out-var>) list(JOIN <list> <glue> <out-var>) list(SUBLIST <list> <begin> <length> <out-var>) Search list(FIND <list> <value> <out-var>) Modification list(APPEND <list> [<element>...]) list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>) list(INSERT <list> <index> [<element>...]) list(POP_BACK <list> [<out-var>...]) list(POP_FRONT <list> [<out-var>...]) list(PREPEND <list> [<element>...]) list(REMOVE_ITEM <list> <value>...) list(REMOVE_AT <list> <index>...) list(REMOVE_DUPLICATES <list>) list(TRANSFORM <list> <ACTION> [...]) Ordering list(REVERSE <list>) list(SORT <list> [...])官方提供了这么一些操作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的内容就是这些了,我利用这些知识已经完成了公司项目的跨平台开发和部署。后续如果有新的需求说不定我会学点新的内容,到时候再来更新这一系列文章吧!!!
2025年04月12日
4 阅读
0 评论
0 点赞
2025-04-01
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使用实践也会分享出来。
2025年04月01日
8 阅读
0 评论
0 点赞
2023-04-18
从0开始自制解释器——重构代码
在上一篇文章中,完成了对括号的支持,这样整个程序就可以解析普通的算术表达式了。但是在解析两个括号的过程中发现有大量的地方需要进行索引的回退操作,索引的操作应该保证能得到争取的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仓库上查阅相关代码。
2023年04月18日
7 阅读
0 评论
0 点赞
2023-03-24
从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上上传的代码。地址
2023年03月24日
6 阅读
0 评论
0 点赞
2023-03-22
从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 则保持原来的算法不变。好了,本篇到此也就结束了,小伙伴可以到该位置 取出代码来进行阅读和修改。
2023年03月22日
4 阅读
0 评论
0 点赞
2023-03-08
从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 函数中即实现了语法分析的功能,也实现了解释执行的功能。
2023年03月08日
5 阅读
0 评论
0 点赞
2023-03-07
从0开始自制解释器——实现简单的加法计算器
为什么要学习编译器和解释器呢?文中的作者给出的答案有下面几个:为了深入理解计算机是如何工作的:一个显而易见的道理就是,如果你不懂编译器和解释器是如何工作的那么你就不明白计算机是如何工作的编译器和解释器用到的一些原理和编程技巧以及算法在其他地方也可以用到。学习编译器和解释器能够学到并强化这些技巧的运用为了方便日后能编写自己的编程语言或者专用领域的特殊语言接下来我们就从0开始一步一步的构建自己的解释器。跟着教程先制作一个简单的加法计算器,为了保证简单,这个加法计算器能够解析的表达式需要满足下面几点:目前只支持加法运算目前只支持两个10以内的整数的计算表达式之间不能有空格只能计算一次加法举一个例子来说,它可以计算诸如"1+2"、"5+6" 这样的表达式,但是不能计算像 "11+20"(必须是10以内)、"1.1+2"(需要两个数都是整数)、"1 + 2"(中间不能有空格)、"1+2+3"(只能计算一次加法)有了这些限制,我们很容易就能实现出来。实现的算法假设我们要计算表达式 5+6。这里主要的步骤是通过字符串保存表达式,然后通过索引依次访问每个字符,分别找到两个整数和加法运算符,最后实现两个整数相加的操作。第一步,我们的索引在表达式字符串的开始位置,解析得到当前位置的字符是一个整数,我们给它打上标记,类型为整形,值为5。第二步,索引向前推进,解析当前位置的字符是一个+。还是给它打上标记,类型为plus,值为+。第三步,索引继续前进,解析到当前位置的字符是一个整数,我们给它打上标记,类型为整形,值为6最后一步,根据得到的两个整数以及要执行的算术运算,我们将两个数直接进行相加得到最终结果具体的代码首先我们定义这个标记的类型,目前支持整数以及加法的标记typedef enum e_TokenType { CINT = 0, //整型 PLUS //加法运算符 }ETokenType; // 这里因为只支持10以内的整数,所以表示计算数字的字符只有一个,加上字符串最后的结束标记,字符数组只需要两个即可 typedef struct Token { ETokenType type; //类型 char value[2]; //值 }Token, *LPTOKEN;接着定义一些全局变量来保存算术运算的表达式和当前指针的索引char* g_pszUserBuf = NULL; char* g_pPosition = NULL;接着我们定义一个函数来模拟上述说到的不断解析每一个字符的过程bool get_next_token(LPTOKEN pToken) { char* sz = g_pPosition; g_pPosition++; pToken->value[0] = '\0'; if (*sz >= '0' && *sz <= '9') { pToken->type = CINT; pToken->value[0] = *sz; return true; } else if (*sz == '+') { pToken->type = PLUS; pToken->value[0] = *sz; return true; } else { pToken->value[0] = '\0'; return false; } }最后我们定义一个函数来执行获取每个标记并最终计算结果的操作int expr() { int val1 = 0, val2 = 0; Token token = { 0 }; if (get_next_token(&token) && token.type == CINT) { val1 = atoi(token.value); } else { printf("首个字符必须是整数"); return -1; } if (get_next_token(&token) && token.type == PLUS) { } else { printf("第二个字符必须是操作符,并且当前只支持 + 运算"); return -1; } if (get_next_token(&token) && token.type == CINT) { val2 = atoi(token.value); } printf("%d+%d=%d\n", val1, val2, val1 + val2); }在main函数里面我们只需要建立一个缓冲来保存字符,并且在循环中不断等待用户输入,完成解析并输出结果即可// 重制当前解析环境 void reset() { memset(g_pszUserBuf, 0x00, 16 * sizeof(char)); scanf_s("%s", g_pszUserBuf); g_pPosition = g_pszUserBuf; } int main() { g_pszUserBuf = (char*)malloc(16 * sizeof(char)); while (1) { printf(">>>"); reset(); if (strcmp(g_pszUserBuf, "exit") == 0) { break; } expr(); } return 0; }最终执行的结果如下最后的总结程序我们已经写完了,你可能觉得这个程序太简单了,只能做这点事情。别着急,后面将会逐步的去完善这个程序。以便它能实现更加复杂的运算。最后我们来引入一些概念性的东西:我们将输入内容按照一定规则打上的标记被称之为Token上述get_next_token函数体现的将一段字符串分割并打上有意义的标签的过程被称为词法分析。解释器工作的第一步就是将输入的字符串按照一定的规则转换为一系列有意义的标记。完成这个工作的组件被称之为词法分析器,也可以被称为扫描器或者分词器
2023年03月07日
5 阅读
0 评论
0 点赞
2021-05-31
动态内存与智能指针
c/c++语言的一大特色是在于可以动态的进行内存管理,而这也是它的难点所在。程序出现问题,原因经常在动态内存管理这块,比如分配内存后没有及时释放,或者当前线程提前释放了其他线程也会使用的内存。而c++11中新增的智能指针能在一定程度上解决这些问题动态内存与智能指针在c++中动态内存的管理是通过一对运算符来完成的: new和delete ,new为对象分配空间并返回一个指向该对象的指针。delete 接受一个动态对象的指针,销毁对象并释放相关内存动态内存的管理十分困难,有时候会忘记释放内存,这种情况下会产生内存泄漏。有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。为了更容易也更安全的使用动态内存,新的标准提供了两种智能指针类型来管理动态对象。shared_ptr 类类似于vector 智能指针也是模板。创建智能指针时,必须提供额外的信息——指针可以指向的类型。智能指针的用法与普通指针类似。解引用一个智能指针返回它指向的对象,箭头运算符可以返回对象中的成员shared_ptr<string> p = new string; if(nullptr != p && p->empty()) { *p = "hello world"; //字符串为空的时候,将一个新值赋予string }最安全的分配和使用动态内存的方法是调用一个名为 make_shared 的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回此对象的 shared_ptr。shared_ptr<int> p3 = make_shared<int>(42); //初始化p3 指向一个值为42的int类型 shared_ptr<string> p4 = make_shared<string>(10, '9'); //p4指向一个值为 "9999999999" 的string shared_ptr<int> p5 = make_shared<int>(); //指向一个值初始化的int,即,值为0当shared_ptr 进行拷贝和赋值操作时,每个shared_ptr 都会记录有多少个其他的shared_ptr 指向相同的对象auto p = make_shared<int>(42); //此时p指向的对象只有一个引用值 auto q = p; //此时p指向的对象有两个引用者我们可以认为每一个shared_ptr 都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr 赋一个新值或者shared_ptr 被销毁时,他所关联的计数器就会递减。当指向一个对象的最后一个shared_ptr 被销毁时,shared_ptr 类就会自动销毁此对象。shared_ptr 并不是万能的,也会出现内存泄漏的问题。这种情况一般出现在容器中。如果在容器中存放了shared_ptr ,然后重新排列了容器,从而不需要某些元素。在这种情况下应该确保使用earse删除某些不再需要的shared_ptr 元素直接管理内存相对与智能指针直接使用new 和 delete很容器出错。当内存耗尽时,new会失败,会抛出一个bad_alloc 异常。我们可以改变使用new的方式来阻止它抛出异常int *p1 = new int; //如果分配失败则会抛出异常 int *p2 = new (nothrow) int; //如果分配失败,new返回一个空指针我们称这种形式的new为定位new。定位new允许我们传递额外的参数给到new,在此例子中我们传递一个标准库中的nothrow 对象,告知它在内存不足的时候不要抛出异常。如果这种形式的new不能分配所需内存,它会返回一个空指针动态对象的生命周期一直到它被手动释放为止。这样就给使用者造成了一个额外的负担,调用者必须记得释放内存。使用new和delete 管理动态内存存在三个常见问题:忘记delete内存。造成内存泄漏问题使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检出这种错误同一块内存多次释放坚持只使用智能指针就可以避免所有这些问题。对于一块内存只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它shared_ptr 和 new 结合使用接受指针参数的智能指针构造函数是 explicit 的。因此,我们不能将一个内置指针隐式转化为智能指针,必须使用直接初始化的方式shared_ptr<int> p1 = new int(1024); //错误,这里需要将int* 隐式转化为shared_ptr<int> 类型 shared_ptr<int> p2(new int(1024)); //正确默认情况下一个用来初始化智能指针的普通指针必须指向使用new创建的动态内存(malloc 创建的需要自定义释放操作),因为智能指针默认采用delete来释放它所关联的对象。我们可以将智能指针绑定到一个指向其他类型的资源的指针上面,但是为了这样做,必须提供自己的操作来代替delete不要混合使用普通指针和智能指针。void process(shared_ptr<int> ptr) { // 进入到函数中时,ptr 所在的引用计数加1 } //函数结束时, ptr 所在对象的引用计数减 1 shared_ptr<int> p(new int(42)); //引用计数为1 process(p); //在函数内部,引用计数加1,变为2 //执行完成后,引用计数减1,变为1,此时对象不会被销毁 *p = 100; //可以进行赋值,此时对象还未被销毁 int* p1 = new int(42); process(shared_ptr<int>(p1)); //进入函数后,由于p1 自身不是智能指针,所以在函数结束之后,智能指针的计数为0,会销毁对应的对象 *p1 = 100; //错误,此时对象已被销毁 智能指针定义了一个get函数用来返回一个普通的指针,此函数是为了这样一种情况而设计的:我们需要像不能使用智能指针的代码传递一个内置指针,但这段代码中不能使用delete来销毁这个指针所指向的对象我们不能将get返回的指针再绑定到另一个智能指针上。智能指针和异常当发生异常时,普通的指针如果在异常发生之后进行delete操作,那么资源回收操作可能会被中断,而智能指针不会void f() { shared_ptr<int> sp(new int(24)); //即使后面发生异常,sp指针在函数结束时计数变为0,对象被释放 } void f() { int* p = new int(24); //这里发生异常的话,后面的delete 不会被执行,可能发生内存泄漏 delete p; }有些资源由于提供的是c函数级别的接口,因此需要手动进行释放,就会存在与动态内存一样的问题,忘记释放资源。这种情况我们也可以使用智能指针的技术来自动管理资源。例如我们的socket程序,在最后需要调用shutdown 和 closesocket来关闭void clear_socket(socket* sk) { shutdown(*sk); closesocket(*sk); } socket s = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); shared_ptr<socket> ps(&s, clear_socket); //链接服务器 //程序推出后会自动调用clear_socket 来释放socket资源智能指针可以提供对动态分配的内存安全而有方便的管理,但是这建立在正确使用的前提下。为了方便的使用智能指针,我们必须坚持一些基本原则:不使用相同的内置指针初始化多个智能指针不delete get函数返回的指针不使用get初始化或者reset另一个指针指针如果使用get返回的指针,记住当最后一个对应的智能指针被销毁后,你的指针就变为无效了如果使用智能指针管理的资源不是new分配的,记住传递给它一个删除器unique_ptrunique_ptr 拥有它所指向的对象。某一个时刻只能有一个 unique_ptr 指向一个给定的对象。当unique_ptr 被销毁时,它所指向的对象也会被销毁unique_ptr 不支持拷贝操作,没有类似 make_shared 的操作。unique_ptr<string> p1(new string("hello world")); unique_ptr<string> p2(p1); //错误:unique_ptr 不支持拷贝 unique_ptr<string> p3; p3 = p1; //错误:unique_ptr 不支持赋值虽然不能拷贝和赋值unique_ptr ,但是可以调用release或者reset将指针的所有权从一个(非const)unique_ptr 转移给另一个unique_ptrreset 成员接受一个可选的指针参数,令unique_ptr 重新指向给定的指针。如果unique_ptr 不为空,它原来指向的对象被释放。release会切断unique_ptr 和它原来管理的对象间的联系。release返回的指针通常被用来初始化另一个智能指针或者给另一个智能指针赋值,如果我们不用另一个智能指针保存release返回的指针,则需要手工释放指针指向的资源p2.release(); //错误,p2指向的资源不会正常释放 auto p = p2.release(); delete p;不能拷贝unique_ptr 的规则又一个例外: 我们可以拷贝或者赋值一个将要被销毁的unique_ptr。最常见的例子是从函数返回一个unique_ptrunique_ptr<int> clone(int p){ return unique_ptr<int>(new int(p)); }还可以返回一个局部对象的拷贝:unique_ptr<int> clone(int p) { unique_ptr<int> ret(unique_ptr<int>(p)); return ret; }类似于shared_ptr, unique_ptr 默认情况下使用delete 释放它指向的对象。我们也可以重载一个unique_ptr 中默认的删除器。但是unique_ptr 管理删除器的方式与shared_ptr 不同。重载一个unique_ptr 中删除器会影响到unique_ptr 类型以及如何构造该类型的对象。与与重载关联容器的比较运算相似,我们必须在尖括号中unique_ptr 指向类型之后提供删除容器类型。在创建或者reset 一个这种unique_ptr 类型的对象时,必须提供一个指定类型的可调用对象weak_ptrweak_ptr 是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr 管理的对象。将一个shared_ptr绑定到一个 weak_ptr。不会改变shared_ptr 的引用计数。一旦最后一个指向对象的shared_ptr 被销毁,对象就会被释放。即使有weak_ptr 指向对象,对象还是会被销毁由于对象可能不存在,所以不能直接使用weak_ptr 来访问对象,需要先调用lock函数,此函数检查weak_ptr 指向的对象是否仍然存在。如果存在,lock返回一个指向共享对象的shared_ptr。只要此shared_ptr 存在,它所指向的对象也会一直存在.if(shared_ptr<int> np = wp.lock()) { //在if中np 和wp 共享对象 }动态数组new 和数组标准库提供了一个可以管理new 分配的数组的unique_ptr 版本,为了用一个unique_ptr 管理动态数组,我们必须在对象类型后面跟一对方括号:unique_ptr<int[]> up(new int[10]); up.release(); //自动用delete[] 销毁其指针shared_ptr 不直接支持管理动态数组。如果希望使用shared_ptr 管理动态数组,必须提供自己定义的删除器:shared_ptr<int> sp(new int[10], [](int* p){delete[] p;}); sp.reset();shared_ptr 未定义下标运算符,因此我们通过shared_ptr 访问动态数组时需要使用get获取到内置指针,然后用它来访问数组元素** allocator 类当分配一块大内存时,我们通常计划在这块内存上按需求构造对象,这种情况下使用new分配时会立即执行对象的构造操作,会造成一定的开销string *const p = new string[n]; //构造n个空白的string delete[] p;上述代码在new 的同时已经调用了n次string 的构造函数。但是我们可能不需要n个string对象,少量的即可满足。 这样我们就可能创建一些永远也用不到的对象。而且对于那些要使用的对象,我们也在初始化之后立即赋予了它们新值,每个被使用的元素被赋值了两次,第一次是在默认初始化的时候,第二次是在赋值时。标准库中定义了allocator 类可以帮助我们将内存分配和对象构造分离开来。allocator<string> alloc;//可以分配string的allocator对象 auto const p = alloc.allocate(n); //分配n个未初始化的stringallocator 分配的内存是未构造的。我们按照需要在此内存中构造对象。成员函数construct接受一个指向将要被构造的内存的指针,同时可以接受额外参数作为构造对象时的参数。auto q = p; //q 指向下一个将要被构造的位置 alloc.construct(q++); //构造一个空字符串 alloc.construct(q++, 10, 'c'); //构造一个'cccccccccc'的字符串 alloc.construct(q++, "hi"); //构造一个 "hi" 字符串当我们使用完对象后必须调用destroy 来销毁它们while(q != p) { alloc.destroy(--q); }这里要注意我们只能对真正构造了的元素进行destroy操作destroy之后这些内存并没有完全交换给系统,还需要调用deallocate 来完成alloc.deallocate();
2021年05月31日
7 阅读
0 评论
0 点赞
2021-05-16
关联容器
之前介绍过标准库中的顺序容器,顺序容器是元素在内存中按照一定顺序进行排列的,都是按线性结构进行排列。除了顺序容器外,c++中还有关联容器。与顺序容器不同的是,关联容器中元素是按照关键字来保存和访问的。与之相对的顺序容器是按它们在容器中的位置来顺序的保存和访问的。关联容器支持高效的查找和访问。两个主要的关联容器类型是map和set。标准库提供8种关联容器,这8个容器见的不同体现在3个维度或者是一个set,或者是一个map或者要求不重复的关键字,或者允许重复关键字按顺序保存元素或者无序保存允许重复关键字的容器都包含单词 multi ,不保持关键字按顺序存储的容器的名字都以单词unordered 开头这8中容器分别是 map、set、multimap、multiset、unordered_map、unordered_set、unordered_multimap、unordered_multiset关联容器概述关联容器不支持顺序容器中的位置相关操作,例如 push_back、push_front。原因是关联容器是按照关键字存储的,这些操作对关联容器没有意义对于map、multimap、set、multiset 关键字类型必须定义元素的比较方法。默认情况下标准库使用关键字类型的< 运算符来比较两个关键字在介绍关联容器的操作之前,我们需要了解名为pair的标准库类型。一个pair保存两个数据成员。类似容器,pair是一个用来生成特定类型的模板。当创建一个pair时,需要提供两个类型名,pair的数据成员将具有对应的类型。两个类型不要求一样pair<string, string> anon; pair<string, size_t> word_count; pair<string, vector<int>> line;初始化时,会调用类型的默认构造函数进行初始化,也可以为每个成员提供初始化器:pair<string, string> author{"James", "Joyce"};pair 的数据成员是public的,两个成员分别命名为first和second。我们用普通的成员访问符号来访问它们。关联容器的操作关联容器定义了额外的类型别名key_type: 此容器类型的关键字类型mapped_type: 每个关键字关联的类型:只适用与mapvalue_type: 对于set,与key_value 相同;对于map,为 pair<const key_type, mapped_type>set<string>::value_type v1; //v1 是一个string set<string>::key_value v2; //v2 是一个string map<string, int>::value_type v3; //v3 是一个pair<const string, int> map<string, int>::key_type v4; //v4 是一个string map<string, int>::mapped_type v5; //v5 是一个int我们使用作用域运算符来提取一个类型的成员。当解引用一个关联容器的迭代器的时候,会得到一个类型为容器的value_type 的值的引用。对map而言,value_type 是一个pair 类型,其first成员保存const的关键字,second成员保存值auto map_it = word_count.begin(); cout << map_it->first; cout << " " << map_it->second; map_it->first = "new key"; //错误 first是const类型 ++map_it->second;set 的迭代器是const的。关联容器中键值是无法通过迭代器进行修改的。只能通过迭代器读,每次修改键值都会导致容器中元素的重新排序。因此不允许通过迭代修改键值我们通常不能对关联容器使用泛型算法。关键字是const这一特性意味着不能将关联容器传递给修改或者重排容器元素的算法。关联容器可以使用只读取元素的算法。但是很多这类算法都要搜索序列。由于关联容器中的元素不能通过它们的关键字进行快速查找,因此对其使用泛型算法几乎总是一个坏主意关联容器中有一个find的成员,我们可以使用find算法来根据关键字查找元素。关联容器的insert成员可以向容器中添加一个元素或者元素范围。因为set和map无法包含关键字重复的元素,因此插入已存在的元素对容器没有任何影响vector<int> ivec = {2, 4, 6, 8, 2, 4, 6, 8}; //ivec 有8个元素 set<int> set2; set2.insert(ivec.cbegin(), ivec.cend()); //set2 现在有4个元素 set2.insert({1, 3, 5, 7, 1, 3, 5, 7}); //set2 现在有8个元素对一个map执行insert操作时,需要记住元素类型是pair。通常在insert的参数列表中创建一个pair//向word_count 插入word的4种方法 word_count.insert({word, 1}); word_count.insert(make_pair(word, 1)); word_count.insert(pair<string, size_t)>(word, 1)); word_count.insert(map<string, size_t>::value_type(word, 1));insert 的返回值依赖于容器类型和参数。对于不包含重复关键字的容器,添加单一元素的insert和emplace版本返回一个pair,告诉我们插入是否成功。pair的first成员是一个迭代器,指向具有给定关键字的元素;second成员是一个bool值,指出元素是插入成功还是已经存在于容器中。如果关键字已经存在于容器中,则insert什么也不做,且返回值中的bool部分为false。如果关键字不存在,元素被插入容器,且bool值为true对于允许重复关键字的容器,接受单个元素的insert操作返回一个指向新元素的迭代器。这里无须返回一个bool值,因为insert总是向这类容器中加入一个新元素关联容器定义了三个版本的erase。可以通过传入一个迭代器或者一个迭代器对来删除一个元素或者一个元素范围。如果指定的元素被删除,函数返回void关联容器提供一个额外的erase函数,它允许传入一个key_type 参数,删除所有匹配给定关键字的元素,返回实际删除元素的数量。对于map和unordered_map 容器提供了下标运算,下标中填入key_type的值,得到value_type,如果关键字不在map中,会为它创建一个元素并插入map中。使用容器的find访问关联容器,传入key_type,如果能找到对应值,返回一个指向对应元素的迭代器,否则返回一个指向容器end()位置的迭代器的使用容器的count方法,传入key_type,返回容器中相同关键字元素的个数对于map使用find代替下标操作,使用下标如果未找到对应关键字则会插入一个新的元素,这样会产生未知行为。如果我们只想知道一个给定关键字是否在map中,而不想改变map,这种情况下应该使用find。对于不允许存储重复关键字的关联容器来说,通过关键字查找元素只会找到一个,而对于允许重复关键字的关联容器来说,会返回第一个元素的迭代器,而相同关键字的元素会相邻存储。在遍历所有相同关键字的元素时,可以首先使用find找到第一个元素的迭代器,然后使用count找到公有多少个元素。最后循环递增迭代器即可访问到所有相同关键字的元素string search_item("Alain de Botton"); auto enteris = authors.count(search_item); auto iter = authors.find(search_item); while(enteris) { cout << iter->second << endl; ++iter; --enteris; }除了上述方法,还可以使用 lower_bound和 upper_bound;lower_bound 指向第一个对应的元素,upper_bound 指向匹配的最后一个元素的下一个位置。如果未找到对应元素,则二者指向同一个迭代器,指向一个不影响排序的关键字插入位置for(auto beg = authors.lower_bound(search_item), end = authors.upper_bound(search_item); beg != end; ++beg) { cout << beg->second << endl; }解决此问题的最后一个方法是直接使用容器的equal_range函数。该函数返回一个pair,保存的是两个迭代器。指向的位置与 lower_bound 和 upper_bound 相同解决此问题的最后一个方法是直接使用容器的equal_range函数。该函数返回一个pair,保存的是两个迭代器。指向的位置与 lower_bound 和 upper_bound 相同for(auto pos = authors.equal_range(search_item); pos.first != pos.end; ++pos.first) { cout << pos.first->second << endl; }无序容器新标准中定义了4种无序关联容器,这些容器不是使用比较运算符来组织元素,而是使用一个哈希函数和关键字类型的 == 运算符。在关键字类型的元素没有明显的序关系时,无序容器是非常有用的。对于自定义类型的关键字,无法直接在无序容器中使用,还需要提供该类型的hash操作。
2021年05月16日
6 阅读
0 评论
0 点赞
2021-05-10
泛型算法
好久没有更新博客了,最近一直想把我以前的老笔记本换成 Arch + dwm 的样式来使用。现在基本已经弄完了。后面会考虑将我的心得发出来。从0开始一点点的增加自己需要的功能确实很繁琐但是也挺有趣的。闲话就到这里,这篇文章继续记录我学习c++ 11的内容。这篇主要是泛型算法相关的内容标准容器自身提供的操作少之又少,在多数情况下可能希望对容器进行其他操作,例如排序、删除指定元素等等。标准库容器中并未针对每个容器都定义成员函数来实现这些操作,而是定义了一组泛型算法,它们实现了一组经典算法的公共接口,可以使用于不同类型的元素和多种容器类型。也就是相同一组算法可以处理多种容器类型概述之所以是泛型的,主要是这些通用算法不依赖于具体的容器类型,所有相同算法采用相同的接口迭代器的存在使得算法不依赖于具体的容器类型,但是算法依赖于元素类型的相关操作,例如我们可以简单的使用下面的代码来说明这个bool find(beg, end, val) { for(auto iter = beg; iter != end; ++iter) { if(*iter == val) { return true; } } return false }上述代码并不限定于只能使用某种类型的容器,只要容器中迭代器支持递增操作,并且元素本身支持比较运算即可。泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作,最多也就只会修改迭代器所指向的元素的值。对容器自身没有影响。算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素的值,也可能在容器中移动元素。但是永远不会直接添加或者删除元素(当然插入迭代器例外)初识泛型算法除了极少数例外,标准库算法都是对一个范围内的元素进行操作。我们将此元素范围称之为输入范围,接受输入范围的算法总是使用前两个参数来表示此范围。两个参数分别是指向要处理的第一个元素和尾元素之后位置的迭代器。理解基本算法的方法就是了解它们是否读取元素、改变元素或者重新排列元素顺序只读算法一些算法只会读取输入范围内的元素,而从不改变元素。find就是这样一个算法。一些常见的只读算法如下:find:查找容器中出现某个元素的位置,需要容器中元素类型实现 == 操作count: 返回容器中出现某个元素的次数,同样需要容器中元素类型实现 == 操作accumulate: 计算某个迭代器范围内,所有元素的和,需要容器中元素类型实现 + 操作equal: 比较两个序列中元素值是否完全相同,它接受三个参数,前两个表示一个容器的迭代器范围。最后一个参数代表第二个容器的起始位置一般来说对于只读取而不改变元素的算法,通常最好使用cbegin和cend 获取const版本的迭代器。那些只接受一个单一迭代器来表示第二个序列的算法,都假定第二个序列至少与第一个序列一样长。写容器元素的算法这类算法需要确保,容器原大小不能小于我们要求算法写入的元素数目。由于算法不会执行容器操作,因此它们自己不可能改变容器大小一种保证算法有足够元素空间来容纳输出数据的方式是使用插入迭代器,插入迭代器是一种向容器中添加元素的迭代器拷贝算法接受3个迭代器,前两个表示一个源容器的范围,第三个迭代器是目的容器的起始位置的迭代器。同样的源容器的长度不能超过目的容器的长度定制操作很多算法都会比较输入序列中的元素,默认情况下,这类算法使用元素类型的< 或者 == 运算符来完成比较操作。标准库还为这些算法定义了额外的版本,允许我们提供自已定义的操作来代替默认运算符。例如sort 算法默认使用元素类型的 < 运算符,但是可以使用sort的重载版本,额外定义比较的规则向算法传递参数标准库中可以接受的比较函数一般返回一个bool值,表示是否小于或者是否相等。函数接受一个参数或者两个参数。在c++新标准中将这个函数叫做谓词,接受一个参数的函数被成为一元谓词,接受两个参数的函数叫做二元谓词。vector<string> words; //初始化 words //...... bool isShorter(const string& s1, const string& s2) { return s1.size() < s2.size(); } sort(words.cbegin(), words.cend(), isShorter);lambda 表达式在介绍lambda 表达式之前,需要先介绍可调用对象这个概念可调用对象:对于一个对象或者一个表达式,如果可以对其使用调用运算符,则称它是可调用的;例如,e是一个可调用对象,则我们可以编写代码e(args) ,其中args是一个逗号分割的一个或者多个参数的列表到目前为止,我们只接触了函数和函数指针这两类可调用对象,还有其他两种可调用对象:重载了函数调用运算符的类,以及lambda表达式。一个lambda 表达式表示一个可调用的代码单元。我们可以将其理解为一个未命名的内联函数,定义形式如下capture list -> return type {function body}capture list 捕获列表,是一个lambda 所在函数中定义的局部变量的列表。parameter list 函数的参数列表return type 是函数返回值类型function body 是函数体,需要执行的具体代码段与普通函数不同的是 lambda 必须使用尾置返回来指定返回类型我们可以忽略参数列表和返回值,但是必须包含捕获列表和函数体auto f = [] {return 42;};如果lambda 表达式中没有明确指定返回类型,函数体中包含任何单一 return 语句之外的内容,则返回voidlambda 的调用方式和普通函数的调用方式一样,都是调用运算符cout << f() << endl;lambda 表达式不能有默认参数[] (const string& str1, const string& s2) { return s1.size() < s2.size(); }; vector<string> words; stable_sort(words.begin(), words.end(), [](const string& s1, const string& s2){ return s1.size() < s2.size(); });lambda 表达式一般出现在一个函数中,使用其局部变量,但是它只能访问那些明确指明的变量。一个lambda通过将局部变量包含在其捕获列表中来指明将会使用这些变量。捕获列表指引lambda 在其内部包含访问局部变量所需的信息[sz](const string& s){ return a.size() >= sz; }lambda 捕获和返回与参数传递类似,变量捕获的方式可以是值或者引用。void func1() { size_t v1= 42; auto f = [v1]{return v1;}; v1 = 0; auto j = f(); //j 是42, 因为在定义lambda的时候传入的是v1的拷贝,后续v1 的改变不影响捕获中v1 的值 }被捕获变量的值是在lambda创建时拷贝,因此随后对其修改不会影响到lambda内对应的值void func2() { size_t v1 = 42; auto f = [&v1](){return v1;}; v1 = 0; auto j = f(); //j 是0,f保存v1的引用,而非拷贝 }引用捕获与返回引用有着相同的限制,必须保证调用在调用lambda表达式时,是存在的。捕获的都是函数的局部变量,如果lambda 在函数结束之后执行,捕获的引用指向的局部变量已经消失。可以在函数中返回一个lambda表达式,此时返回的lambda 中不应该包含引用捕获使用引用捕获的时候需要注意,在一次或者多次调用lambda表达式的时候应该保证引用的对象仍然有效,同时需要保证对象的值是我们所期待的。因此在使用lambda的时候尽量减少捕获变量的数量,同时尽量不使用引用捕获除了显式列出我们希望使用的所来自所在函数的变量外,还可以让编译器根据lambda体中的代码来推断我们要使用哪些变量。为了指示编译器推断捕获列表,应在捕获列表中写一个&或者=,表示采用引用捕获或者值捕获我们也可以混合使用隐式捕获和显式捕获,混合使用时,捕获列表的第一个元素必须是一个&或者=。当混合使用了显式捕获和隐式捕获时。显式捕获的变量必须与使用隐式捕获不同的方式。当使用值捕获的时候,默认情况下lambda表达式是不能改变其值的,如果希望改变一个被捕获的变量的值,就必须在参数列表后加上关键字 mutablevoid f3() { size_t v1 = 42; auto f = [v1] ()mutable{return ++v1;}; v1 = 0; auto j = f(); // j = 43 }一个引用捕获的变量是否可以修改依赖于此引用指向的是一个const 类型还是一个非const类型void fnc4() { size_t v1 = 42; auto f2 = [&v1]{return ++v1;}; v1 = 0; auto j = f2(); //j = 1 }// 错误,由于有除return之外的其他语句,因此编译器推断lambda 表达式返回void,但是返回了具体值 transform(v1.begin(), v1.end(), vi.begin(), [](int i){ if(i < 0) return -i; else return i; }); //正确,只有return语句,编译器可以推断出返回int类型 transform(v1.begin(), v1.end(), vi.begin(), [](int i){ return (i < 0)? -i : i; }); //正确,明确指定了返回int类型 transform(v1.begin(), v1.end(), vi.begin(), [](int i)->int{ if(i < 0) return -i; else return i; });参数绑定lambda 表达式适用于只在一两个地方使用的简单操作,如果多个地方都需要使用到相同的操作,就需要写上相同的lambda表达式。这个时候最好的办法是定义一个函数。在需要进行捕获的情况下使用函数就不是那么容易了。例如有的泛型算法只传递一个参数,但是我们在函数中需要两个参数。这种情况下就需要用到参数绑定标准库中定义了一个bind函数。可以将bind作为一个函数适配器。它接受一个可调用对象,生成一个新的可调用对象来适应原对象的参数列表auto newCaller = bind(callable, arg_list);其中 callable 是一个可调用对象,返回的newCaller 是一个新的可调用对象,而arg_list 中的参数可能包含形如 _n 的名字,其中n是一个整数。这些参数是“占位符”。表示 newCaller 的参数。它们占据了传递给newCaller的参数位置。数值n表示生成的可调用对象中参数的位置。_1为newCaller的第一个参数,_2 为第二个参数。以此类推auto wc = find_if(words.begin(), words.end(), bind(check_size, _1, sz));此时调用生成一个可调用对象,将check_size 的第二个参数绑定到sz的值,当find_if 对words中的string调用这个对象的时候,这些对象会调用check_size 将给定的string 和 sz 传递给它,因此 find_if 可以有效的对输入序列中的每一个string调用check_size 实现string与 sz的比较_n 都定义在一个名为 placeholders 的命名空间中,而这个命名空间本身定义在std命名空间中。每次在使用_n 这样的名字时,都需要声明这个命名空间。using std::placeholders::_1; using std::placeholders::_2;每个占位符都必须提供单独的using声明,这样比较麻烦。可以使用另一种不同形式的 using 语句using namespace std::placeholders;我们可以使用bind 给可调用对象中参数重新排序,例如f是一个可调用对象,它有5个参数auto g = bind(f, a, b, _2, c, _1);生成的新的可调用对象g接受两个参数,分别是 _2, _1。在调用g时相当于void g(_1, _2) { f(a, b, _2, c, _1); }当我们执行 g(x, y) 最终会执行 f(a, b, y, c, x)在执行时会将 bind 中传入的参数拷贝到原来的函数参数中,如果想向原函数传递引用,可以使用标准库中的 ref函数auto g = bind(f, ref(a), b, _2, c, _1)上述代码中,在执行g的时候会向f中拷贝a的引用。_1, _2 本身在传值的时候可以传入引用再谈迭代器除了之前介绍的迭代器,标准库还定义了几种额外的迭代器:插入迭代器:这些迭代器被绑定到一个容器上,可以用来向容器插入元素流迭代器:这些迭代器绑定到流中,可以用来遍历所有关联的IO流反向迭代器:这些迭代器向后而不是向前移动,除了 forward_list 之外的标准库容器都有迭代器移动迭代器:这些专用迭代器不是拷贝其中的元素,而是移动它们。插入迭代器插入迭代器是一种迭代器适配器,它接受一个容器,生成一个迭代器,能实现向给定容器添加元素。插入迭代器有三种类型,差异在于元素插入的位置:back_iterator: 创建一个使用push_back 的迭代器front_iterator: 创建一个使用push_front 的迭代器inserter: 创建一个使用insert 的迭代器iostream 迭代器虽然iostream并不是容器,但是标准库定义了可以用于这些IO类型对象的迭代器。istream_iterator 读取输入流,ostream_iterator 向一个输出流写数据。这些迭代器将它们对应的流当作一个特定类型的元素序列来处理。通过使用流迭代器,我们可以使用泛型算法从流对象读取数据以及向其写入数据。istream_iterator<int> in(cin), eof; accumulate(in, eof, 0); // 从标准输入中读取整数,并计算它们的和 ostream_iterator<int> out(cout); copy(vec.begin(), vec.end(), out); //将vector中的数据拷贝到ostream流中,也就是输出vector 中的元素istream_iterator 允许使用懒惰求值,即只在需要时进行数据读取泛型算法结构任何算法最基本的特性是它要求其迭代器提供哪些操作。算法要求的迭代器操作可以分为5个迭代器类型:输入迭代器:只读不写;单遍扫描,只能递增输出迭代器:只写不读;单遍扫描,只能递增前向迭代器:可读写,多遍扫描,只能递增双向迭代器:可读写,多遍扫描,可递增递减随机访问迭代器:可读写,多变扫描,支持全部迭代器运算5 类迭代器类似容器,迭代器也定义了一组公共操作。一些操作所有迭代器都支持,另外一些只有特定类别的迭代器才支持。输入迭代器可以读取序列中的元素。一个输入迭代器必须支持:用于比较两个迭代器的相等和不想等运算符用于推进迭代器的前置和后置递增运算符用于读取元素的解引用运算符,解引用只会出现在赋值运算符的右侧箭头运算符输出迭代器可以看作是输入迭代器功能上的补集,只写而不读元素,输出迭代器必须支持用于推进迭代器的前置和后置递增运算解引用运算符,只出现在赋值运算符的左侧前向迭代器可以读写元素,这类迭代器只能在序列中沿一个方向移动。前向迭代器支持所有输入和输出迭代器的操作。双向迭代器可以正向/反向读写序列中的元素。除了支持所有前向迭代器的操作之外,双向迭代器还支持前置和后置的递减运算符。随机访问迭代器提供在常量时间内访问序列中任意元素的能力。除了支持双向迭代器的所有功能外,还支持:用于比较两个迭代器相对位置关系的运算符 (<、<=、>和>=)迭代器和一个整数值的加减运算(+、+=、-、-=),计算结果是迭代器在序列中前进或者后退给定整数个元素后的位置用于两个迭代器上的减法运算符,得到两个迭代器的距离下标运算符 iter[n] 与 *(iter[n]) 等价算法形参模式大多数算法具有如下4种形式之一:alg(beg, end, other, args)alg(beg, end, dest, other, args)alg(beg, end, beg2, other, args)alg(beg, end, beg2, end2, other, args)其中alg 是算法名字,beg和 end表示算法所操作的输入范围,几乎所有算法都接受一个输入范围。是否有其他参数依赖于要执行的操作。dest参数表示算法可以写入的目的位置的迭代器。算法假定按其需要写入数据,不管写入多少个元素都是安全的。如果dest是一个直接指向容器的迭代器,那么算法将输出数据写到容器中已经存在的元素内。更常见的情况是,dest被绑定到一个插入迭代器或者是一个ostream_iterator。接受单独的beg2 或者 beg2和end2的算法用这些迭代器表示第二个输入范围,这些算法通常使用第二个范围中的元素与第一个输入范围结合来进行一些运算算法命名规范除了参数规范,算法还遵循一套命名和重载。这些规则处理诸如:如何提供一个操作代替默认的 < 或者 == 运算以及算法是将输出数据写入到一个序列还是一个分离的目的位置等问题接受谓词参数来代替 < 或者== 运算符的算法,以及那些不接受额外参数的算法,通常都是重载的函数。一个版本用元素自身的运算符来比较元素,另一版本接受一个额外的谓词参数来代替 <或者==unique(beg, end); unique(beg, end, comp); //使用comp函数比较元素接受一个元素值的算法通常有另一个不同名版本,该版本接受一个谓词,代替元素值,接受谓词参数的算法都有附加的_if 后缀find(beg, end, val); find_if(beg, end, pred); //pred 是一个函数,查找第一个令pred返回真的元素默认情况下,重排元素的算法将重排后的元素写回给指定的输入序列。这些算法还提供了另一个版本,将元素写到一个指定的输出目的位置。这类算法都在名字后加一个_copyreverse(beg,end); reverse(beg,end,dest); //将元素按逆序拷贝到dest一些算法还提供_copy和_if 版本,这些版本接受一个目的位置迭代器和一个谓词remove_if(v1.beg(), v1.end(), [](int i){return i % 2}); remove_if(v1.beg(), v1.end(), back_inserter(v2), [](int i){return i % 2});特定容器算法与其他容器不同,链表定义了几个成语啊函数形式的算法。,它们定义了独有的sort、merge、remove、reverse和unique。这些链表中定义的算法的性能比通用版本要高的多。与通用版本中的不同,链表中的特有操作会改变容器。
2021年05月10日
5 阅读
0 评论
0 点赞
1
2
...
15