使用pybind11开发可供python使用的c++扩展模块

在做紫微斗数程序的时候用到了padas库,不过也只用了它下面几个功能:

1、读入csv文件,构造DataFrame;

2、通过行列标题查找数据;

3、通过行标题读取一行数据。

用这几个功能却导入了pandas、numpy、dateutil、pytz等一堆库,多少有点划不来,于是想用c++开发一个实现这几个功能的库供紫微斗数程序使用。按照AI的提示和网上搜索到的文章来了一番操作,结果硬是没成功,最后是微软的visual studio官方文档给了我启发,最终弄清了操作过程。如果想使用visual studio(注意没有code,是完整的IDE)开发python和c++,那么按下面的文档操作肯定可以成功(虽然文档中有些内容可能跟你所使用的VS版本有些差异,但稍微摸索一下肯定能解决):

编写 Python 的 C++ 扩展 - Visual Studio (Windows) | Microsoft Learn

但是,如果不想安装visual studio(规模实在有点太大),或者仅仅是不能忍受微软官方文档的拖沓风格,就可以参阅本文。

【补充说明】如果不想了解有关编写CMakelists.txt文件的信息,可直接参阅简明使用pybind11开发pythonc++扩展模块教程-CSDN博客进行开发,不必继续阅读本文。

第一部分:pybind11安装

开发python的c++扩展模块可以使用的工具有很多,根据AI的回答,最推荐的是pybind11,实际上,pybind11确实是需要添加的模板代码最少、对c++文件的修改最为容易的。要使用pybind11,首先就是要安装它。安装有全局安装和作为项目的子模块安装两类。

全局安装方面,有两种方法:

1、pip install pybind11

2、手动下载并配置 pybind11:

2.1、下载 pybind11 的源代码:

git clone https://github.com/pybind/pybind11.git

2.2、创建构建目录并进入:

cd pybind11
mkdir build
cd build

2.3配置和安装 pybind11:

cmake -DPYBIND11_TEST=OFF ..
cmake --build . --config Release --target install

构建过程中,如果没有安装VS,那么可能出现nmake方面的错误,这时只需要将2.3的第一个命令加上-G "MinGW Makefiles"选项,使用mingw32-make来构建(当然,这需要先下载并解压mingw工具,并将其bin目录加入path环境变量以方便实用,github有mingw64项目的托管网址),即改成(注意命令行末尾的点,两个表示上级目录,因为现在已经进入了build目录,执行这条命令生成makefile需要到上级目录找pybind11的CMakeLists.txt。执行这条命令生成的makefile则在build目录中,所以上面2.3的第二条命令的--build选项下就只有一个点了,表示在当前目录下查找makefile):

cmake -G "MinGW Makefiles" -DPYBIND11_TEST=OFF ..

将pybind11作为自己项目的子模块安装,需要在项目工作目录下执行以下命令:

git init
git submodule add https://github.com/pybind/pybind11.git

然后在工作目录下的CMakeLists.txt文件中添加:

add_subdirectory(pybind11)

上面的安装方式各有优劣,其中手动安装方式往往可以在配置文件没有明确指定pybind11依赖路径时也能成功编译。

第二部分:编写扩展模块的c++代码

接下来可以准备c++扩展模块的代码编写了。pybind11中的“11”的意思,是c++标准c++11,所以c++代码最好用符合c++11标准的语法及数据类型等。我已经几十年不用c++写代码了,读还勉强,写就效率很低甚至根本写不出来,怎么办?没关系,现在有AI,我对AI说:

请生成一个c++类,让它具有以下方法:
1、方法 1:读取一个csv文件,将文件第一行数据当做列标题,第一列当做行标题,用第二行开始的所有数据构建一个map并返回;
2、方法 2:按行标题和列标题查找数据,并返回查到的数据;
3、方法 3:根据行标题返回一行数据(不包括行标题);
4、方法 4:根据列标题返回一列数据;
5、方法 5:返回列标题。
将以上方法公开到pybind11中并导出。

然后AI就给我生成了一个c++文件,之后我又有新的想法向AI提出并由AI给出代码修改,我按后面介绍的编译步骤进行编译,并根据错误提示(错误很少)对c++文件进行修改,最后形成了如下结果:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

namespace py = pybind11;

class CSVReader {
public:
    // 默认构造函数
    CSVReader() = default;

    // 构造函数:接受 CSV 文件名并构建 dataMap
    explicit CSVReader(const std::string& filename) {
        loadCSV(filename);
    }

    // 方法:返回 dataMap
    std::map> getDataMap() const {
        return dataMap;
    }

    // 方法:加载 CSV 文件
    void loadCSV(const std::string& filename) {
        std::ifstream file(filename);
        std::string line;

        if (!file.is_open()) {
            std::cerr << "Error opening file: " << filename << std::endl;
            return;
        }

        headers.clear();
        dataMap.clear();
        bool isFirstLine = true;

        while (std::getline(file, line)) {
            std::istringstream ss(line);
            std::string token;
            std::vector tokens;

            while (std::getline(ss, token, ',')) {
                tokens.push_back(token);
            }

            if (isFirstLine) {
                // 保存第一行作为列标题
                headers = tokens;
                isFirstLine = false;
            }
            else {
                if (!tokens.empty()) {
                    std::string key = tokens[0];
                    std::vector values(tokens.begin() + 1, tokens.end());
                    dataMap[key] = values;
                }
            }
        }

        file.close();
    }

    // 按键值和列标题查找数据
    const std::string* findData(const std::string& key, const std::string& header) {
        auto it = dataMap.find(key);
        if (it != dataMap.end()) {
            const std::vector& values = it->second;
            for (size_t i = 0; i < headers.size() - 1; ++i) { // headers.size() - 1 因为第一列是键
                if (headers[i + 1] == header) { // headers[i + 1] 是列标题
                    return &values[i];
                }
            }
        }
        return nullptr;
    }

    // 根据行标题返回一行数据(不包括行标题)
    std::vector getRow(const std::string& rowTitle) {
        auto it = dataMap.find(rowTitle);
        if (it != dataMap.end()) {
            return it->second; // 返回对应的行数据(不包括行标题)
        }
        return {}; // 返回空向量,表示未找到数据
    }

    // 根据列标题返回一列数据
    std::vector getColumn(const std::string& colHeader) {
        std::vector column;
        // 查找列标题在headers中的索引, 注意 headers[0] 为行标题,因此从index 1开始
        int colIndex = -1;
        for (size_t i = 1; i < headers.size(); ++i) {
            if (headers[i] == colHeader) {
                colIndex = static_cast(i - 1); // 对应到每行数据中的索引
                break;
            }
        }
        if (colIndex < 0) {
            return column; // 未找到对应的列标题,返回空向量
        }
        // 遍历每一行数据
        for (const auto& row : dataMap) {
            const std::vector& values = row.second;
            if (static_cast(colIndex) < values.size()) {
                column.push_back(values[colIndex]);
            }
            else {
                column.push_back(""); // 如该行数据列数不足,可选择返回空字符串
            }
        }
        return column;
    }

    std::vector getHeaders() const {
        return headers; // 返回列标题
    }

private:
    std::map> dataMap; // 保存从文件读入的数据
    std::vector headers; // 保存列标题
};

PYBIND11_MODULE(CSVReader, m) {
    py::class_(m, "CSVReader")
        .def(py::init<>(), R"pbdoc(
            初始化一个空的 CSVReader 实例。
        )pbdoc")
        .def(py::init(), R"pbdoc(
            初始化 CSVReader 并读取指定的 CSV 文件。
            参数:
                filename: 要读取的 CSV 文件名。
        )pbdoc")
        .def("loadCSV", &CSVReader::loadCSV, R"pbdoc(
            加载指定的 CSV 文件。
            参数:
                filename: 要加载的 CSV 文件名。
        )pbdoc")
        .def("getDataMap", &CSVReader::getDataMap, R"pbdoc(
            返回解析后的数据字典。
            返回:
                包含 CSV 数据的字典。
        )pbdoc")
        .def("findData", &CSVReader::findData, R"pbdoc(
            根据行标题和列标题查找数据。
            参数:
                key: 行标题(每行数据的第一列)。
                header: 列标题(首行)。
            返回:
                查找到的数据,如果找不到返回空。
        )pbdoc")
        .def("getRow", &CSVReader::getRow, R"pbdoc(
            根据行标题返回一行数据(不包括行标题)。
            参数:
                rowTitle: 行标题(每行数据的第一列)。
            返回:
                包含该行数据的列表(不包括行标题),如果找不到则返回空列表。
        )pbdoc")
        .def("getColumn", &CSVReader::getColumn, R"pbdoc(
            根据列标题返回一列数据。
            参数:
                colHeader: 列标题(首行)。
            返回:
                包含该列数据的列表,如果找不到则返回空列表。
        )pbdoc")
        .def("getHeaders", &CSVReader::getHeaders, R"pbdoc(
            返回标题行。
        )pbdoc");
}

在工作目录下新建一个目录“build”,将上面的c++文件保存到这个目录下。

第三部分:编写CMakeLists.txt

回到工作目录,创建一个“CMakeLists.txt”文件。编译成功与否跟这个文件关系极大。我用来编译成功的“CMakeLists.txt”文件内容如下:

# 设置 CMake 最低版本要求
cmake_minimum_required(VERSION 3.4...4.0)

# 设置项目名称,这个名称要与c++文件中“PYBIND11_MODULE() ”语句的第一个参数保持一致,下面出现这个名称的位置也是一样
project(CSVReader)

# 设置 C 和 C++ 编译器
set(CMAKE_C_COMPILER "D:/QT/Tools/mingw1310_64/bin/gcc.exe")
set(CMAKE_CXX_COMPILER "D:/QT/Tools/mingw1310_64/bin/g++.exe")

# 查找 Python 解释器和库
find_package(Python COMPONENTS Interpreter Development REQUIRED)

# 查找 pybind11
find_package(pybind11 REQUIRED)

# 添加可执行文件(这里实际上是供Python程序安装使用时的模块名)
pybind11_add_module(CSVReader read_csv.cpp)

# 设置目标属性
set_target_properties(CSVReader PROPERTIES
    CXX_STANDARD 11
    CXX_STANDARD_REQUIRED ON
)

# Python库安装位置
install(TARGETS CSVReader DESTINATION .)

在这个项目编译成功以前,先做了以下准备工作:
1、安装了cmake以及64位mingw编译链接工具(因为我安装的Python是64位3.12版,二者的平台要保持一致。开始我直接用的QT Creator中的mingw工具,cmake也可以用它的,后来安装了VS,cmake构建项目时就自动用VS的编译器和链接器了);
2、安装了Python并将其bin目录加入了PATH环境变量;
3、手动安装了pybind11。

编译过程中最容易出现的问题是找不到头文件、无法链接到python312.lib之类。如果出现问题,可参考下面的示例“CMakeLists.txt”文件内容(其中的superfast是项目名称,这个名称要跟c++文件中“PYBIND11_MODULE() ”语句的第一个参数保持一致)及注释、重要命令说明来修改自己的“CMakeLists.txt”:

# 指定cmake版本,推荐按下面的方式指定版本区间,提高兼容性。版本指定不合适也可能导致编译失败。
cmake_minimum_required(VERSION 3.12...4.0)
project(superfast LANGUAGES CXX)

# 设置 C++ 标准为 C++14
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 明确指定 Python 库路径(例如 python32.lib)和 include 文件夹路径,因为可移植性不强不推荐,推荐使用find_package(PythonLibs REQUIRED) 或 find_package(pybind11 REQUIRED)自动检测,但自动检测成功的前提是项目中的python和pybind11安装正确。在没法解决安装问题的时候就可以用这个方法硬解。
# set(PYTHON_LIBRARY "C:/Path/To/Python/libs/python32.lib")
# set(PYTHON_INCLUDE_DIR "C:/Path/To/Python/include")

# 用于查找 pybind11 的 include 路径
# set(PYBIND11_INCLUDE_DIR "C:/Path/To/pybind11/include")

# 添加 pybind11 的 include 目录
# include_directories(${PYTHON_INCLUDE_DIR} ${PYBIND11_INCLUDE_DIR})

# 寻找 pybind11 包(要求已安装 pybind11)
find_package(pybind11 REQUIRED)

# 添加模块库,生成 Python 可加载的扩展模块
# module.cpp 是主模块文件,您也可添加其他源文件
add_library(superfast MODULE module.cpp)

# 链接 pybind11 库
target_link_libraries(superfast PRIVATE pybind11::module)

# 如果需要其它源文件,比如用于 CSV 读取的 read_csv.cpp,可以这样添加:
# add_library(read_csv MODULE read_csv.cpp)
# target_link_libraries(read_csv PRIVATE pybind11::module)

# 设置模块后缀(Windows 下一般为 .pyd,其它平台可能为 .so)
set_target_properties(superfast PROPERTIES
    PREFIX "" 
    SUFFIX "$,d.pyd,.pyd>"
)

# 安装规则(可选),指定Python项目安装时放在site-packages文件夹下的哪个目录
install(TARGETS superfast LIBRARY DESTINATION .)

“CMakeLists.txt”文件中重要命令说明

一、find_package命令:

1、用法:

find_package( [version] [REQUIRED] [COMPONENTS ...])

2、示例:

  find_package(pybind11 REQUIRED)

这行命令要求正确安装了pybind11且能够被项目检测到,如果找不到则报错。可以参考前面的手动安装,也可参考开头提到的微软文档安装,但这里是生手最可能出错也难以解决错误的地方,实在不行不要使用这条命令,参考上面第二个“CMakeLists.txt”文件示例硬解。 

3、前提:

3.1、被查找的包必须安装且含有相应的 CMake 配置文件(例如 Config.cmake 或 -config.cmake),或者提供一个 Find.cmake 模块。
3.2、环境变量、系统路径或通过 CMAKE_PREFIX_PATH 变量指定的路径中包含该依赖的安装位置。

二、find_path命令:

1、用法:

find_path( NAMES PATHS [NO_DEFAULT_PATH] [REQUIRED])

2、示例:

  find_path(PYBIND11_INCLUDE_DIR pybind11/pybind11.h
    HINTS "C:/Path/To/pybind11/include"
    REQUIRED
  )

这行命令查找 pybind11/pybind11.h 所在的目录,并将路径存入 PYBIND11_INCLUDE_DIR 变量。

3、前提:

3.1、被查找的包必须安装且含有相应的 CMake 配置文件(例如 Config.cmake 或 -config.cmake),或者提供一个 Find.cmake 模块。
3.2、环境变量、系统路径或通过 CMAKE_PREFIX_PATH 变量指定的路径中包含该依赖的安装位置。

三、find_path命令:

1、用法:

find_path( NAMES PATHS [NO_DEFAULT_PATH] [REQUIRED])

2、示例:

  find_path(PYBIND11_INCLUDE_DIR pybind11/pybind11.h
    HINTS "C:/Path/To/pybind11/include"
    REQUIRED
  )

这行命令查找 pybind11/pybind11.h 所在的目录,并将路径存入 PYBIND11_INCLUDE_DIR 变量。

3、前提:

3.1、被寻找的文件(如头文件)确实存在于某个已知路径下。
3.2、可以通过 CMAKE_PREFIX_PATH 或显式的 PATHS 参数告知 CMake 搜索范围。

四、find_library命令:

1、用法:

find_library( NAMES PATHS [REQUIRED])

2、示例:

  find_library(PYTHON_LIBRARY NAMES python32.lib
    HINTS "C:/Path/To/Python/libs"
    REQUIRED
  )

这行命令用于查找 python32.lib 并存储其绝对路径到 PYTHON_LIBRARY 变量中。

3、前提:

3.1、目标库必须在指定的路径或系统默认搜索路径下存在。
3.2、依赖库的名字可能会因平台而异,必要时需使用条件语句针对不同系统配置不同的文件名。

相信了解了上述命令,一定能够成功解决编译过程中出现的各种找不到头文件、找不到静态链接库之类的问题,至少本人在用前文第一个“CMakeLists.txt”文件完成编译前用这些命令也强行弄成功过。

第四部分:编译

在工作目录完成“CMakeLists.txt”文件的编写后,打开控制台(最好有管理员权限),进入build子目录,执行以下命令生成makefile:

cmake ..

然后执行以下命令完成编译:

cmake --build .

这一部分唯一的坑是默认会生成nmake格式的makefile,如果没有安装VS,会不认识project名称,导致编译失败。只需将第一条命令改成下面这样以生成mingw格式的makefile即可解决:

cmake -G "MinGW Makefiles"  ..

然后第二条命令也可以改成:

mingw32-make

如果中间发生了编译失败,可以考虑使用下面的命令先清理失败的编译再进行下一次编译:

cmake --build . --target clean

当然,直接暴力删除build目录中在编译过程中生成的文件可能更方便。编译完成后,会生成一个.pyd文件,不同的编译器这个文件所在文件夹可能不同,反正在build目录或其子目录中总可以找到,而且事实上也不用找这个文件,因为这个文件不能直接在python项目中使用。我这个项目生成的文件是CSVReader.cp312-win_amd64.pyd(VS编译器生成的,在装VS前用mingw编译生成的文件要少得多,保存位置和文件名也略有不同)。

第五部分:构建和分发c++扩展模块

前面编译完成的pyd文件如果直接拷贝到任意python项目的工作目录或其虚拟环境中的site-packages目录下,在python文件中import它,会给你返回一个找不到DLL错误。必须将这个pyd模块构建并分发到python项目中才能使用。

一、创建模块构建和分发环境

1、在第四部分执行编译命令时所在的build目录下(不是在build目录中编译c++文件时生成的build目录)创建setup.py文件,内容如下:

from setuptools import setup, Extension
import pybind11

cpp_args = ['-std=c++11', '-stdlib=libc++', '-mmacosx-version-min=10.7']

sfc_module = Extension(
    'CSVReader',
    sources=['read_csv.cpp'],
    include_dirs=[pybind11.get_include()],
    language='c++',
    extra_compile_args=cpp_args,
)

setup(
    name='CSVReader',
    version='1.0',
    description='Python package with CSVReader C++ extension (PyBind11)',
    ext_modules=[sfc_module],
)

这里只需要注意c++标准与c++文件所使用的标准能够兼容,以及name与c++文件中“PYBIND11_MODULE() ”语句的第一个参数保持一致,并注意c++源文件的文件名不要弄错就可以了。注意c++源文件也保存在build目录下,否则要使用路径指出其位置。

2、在同一目录下创建pyproject.toml文件,内容如下:

[build-system]
requires = ["setuptools", "wheel", "pybind11"]
build-backend = "setuptools.build_meta"

这个文件内容几乎无需修改即可通用于所有使用pybind11的项目。

二、在python项目中使用c++扩展模块

要在Python项目中使用上面的c++扩展模块,Python项目环境中必须安装好"setuptools", "wheel", "pybind11"(如pyproject.toml文件的requires列表所指出的)。安装命令都很简单,激活项目虚拟环境,然后:

pip install setuptools
pip install wheel
pip install pybind11

接下来就要安装c++扩展模块了。命令如下:

pip install E:\projects\cpp4python\build

其中“E:\projects\cpp4python”是我的c++项目工作目录,build是编译目录,第四部分的编译命令都是在这个目录下执行的,c++源文件(read_csv.cpp)、setup.py和pyproject.toml都在这个目录下,编译完成的.pyd文件在这个目录下的debug目录里(如果mingw工具构建,则在build目录下的build目录里)。

然后在python项目中创建一个test.py测试一下:
 

from CSVReader import CSVReader
# 创建 CSVReader 实例
reader = CSVReader()

# 调用 readCSV 方法读取 CSV 文件
data_map = reader.readCSV('csv/month_stem_from_year.csv')

# 打印读取的数据
print('从csv文件构建的字典:', data_map)
print('csv文件标题行:', reader.getHeaders())
print('戌月可能的天干:', reader.getColumn('戌'))
print('丙年十二个月的天干:', reader.getRow('丙'))

# 调用 findData 方法查找数据
key = "丙"
header = "戌"
result = reader.findData(data_map, key, header)
if result:
    print(f"丙年戌月天干: {result}")
else:
    print("未找到数据")

成功输出了五虎遁诀表中的相关数据。

使用pybind11开发可供python使用的c++扩展模块_第1张图片

文件不存在时相关方法会分别返回{}、[]或None,并提示文件打开错误,找不到数据时则分别返回{}、[]或None。经过这一步构建分发,在build目录下又生成了一个build目录,这个目录里有一个文件大小比debug目录中的文件小得多的CSVReader.cp312-win_amd64.pyd文件,然后就可以将这个文件直接拷贝到任何有需要的python项目中使用,而无需再次构建安装了。

用这个模块替换pandas后,紫微斗数程序打包大小小了60M,不到240M了,还是比较值得!

你可能感兴趣的:(python,开发语言)