在 Swift 中封装 C/C++ 库

有很多优秀的库是用 C/C++ 编写的。可以在 Swift 代码中使用这些库,而无需用 Swift 重写它们。本文将解释几种实现此目的的方法以及在 Swift 中使用 C/C++ 时的最佳实践。

软件包

  1. 如果需要,创建一个新的 Swift 软件包,其中包含 Package.swiftSources 目录等。
  2. Sources 下为 C/C++ 库创建一个新的模块/目录。在本节的其余部分,我们假设它被命名为 CMyLib
    • 一种约定是以 C 为模块名称前缀。例如,CDataStaxDriver
  3. 将 C/C++ 库的源代码目录添加为 Git 子模块,位于 Sources/CMyLib 下。
    • 如果设置正确,Swift 软件包的根目录中应该有一个 .gitmodules 文件,内容如下所示
     [submodule "my-lib"]
         	path = Sources/CMyLib/my-lib
         	url = https://github.com/examples/my-lib.git
    
  4. 修改 Package.swift 以添加 CMyLib 作为目标,并指定源文件和头文件的位置。

     .target(
       name: "CMyLib",
       dependencies: [],
       exclude: [
           // Relative paths under 'CMyLib' of the files
           // and/or directories to exclude. For example:
           // "./my-lib/src/CMakeLists.txt",
           // "./my-lib/tests",
       ],
       sources: [
           // Relative paths under 'CMyLib' of the source
           // files and/or directories. For example:
           // "./my-lib/src/foo.c",
           // "./my-lib/src/baz",
       ],
       cSettings: [
           // .headerSearchPath("./my-lib/src"),
       ]
     ),
    

    对于 C++ 库,请使用 cxxSettings 而不是 cSettings。目标定义还有其他选项和参数可用。有关详细信息,请参阅 SwiftPM API 文档。

  5. 尝试使用 swift build 编译 Swift 软件包。根据需要调整 Package.swift

模块映射

除非存在自定义模块映射(即,头文件目录中存在 module.modulemap 文件),否则会自动为 Clang 目标(例如,CMyLib)生成模块映射。

模块映射生成的规则可以在此处找到。

C/C++ 库构建生成的包含文件

某些 C/C++ 库在其构建中生成额外的、必需的文件(例如,配置文件)。要将这些文件包含在 Swift 软件包中

  1. cd 进入 C/C++ 库的根目录(例如,Sources/CMyLib/my-lib),然后构建它。
    • 回想一下上面的步骤 3,这是 Git 子模块的目录。不应在此目录中进行任何修改。 C/C++ 库构建生成的输出文件/目录应添加到 .gitignore 中。
  2. Sources/CMyLib/ 下创建一个目录来存放必需的文件。(例如,Sources/CMyLib/extra
  3. 将生成的、必需的文件从 C/C++ 构建输出复制到上一步创建的目录。
  4. 更新 Package.swift,通过将步骤 2 中创建的目录路径(即 extra)或单个文件路径(例如,./extra/config.h)添加到目标的(即 CMyLibsources 数组(用于源文件)或作为 .headerSearchPath(用于头文件)。

覆盖 C/C++ 库中的文件

要使用自定义实现来代替 C/C++ 库附带的实现

  1. Sources/CMyLib 下创建一个目录来存放自定义代码文件。(例如,Sources/CMyLib/custom
  2. 将自定义代码文件添加到上一步创建的目录中。
    • 如果需要,为源文件和头文件创建单独的子目录。
  3. 更新 Package.swift
    • 将步骤 1 中创建的目录路径(即 custom)或单个文件路径(例如,./custom/my_impl.c)添加到目标的(即 CMyLibsources 数组(用于源文件)或作为 .headerSearchPath(用于头文件)。
    • 将 C/C++ 库的文件路径添加到目标的(即 CMyLib)exclude 数组。(例如,./my-lib/impl.c

CMake

此示例旨在将 C 库导入 Swift。您需要获取库,提供模块映射以便 Swift 可以导入它,然后链接到它。C++ 的机制大致相同,并且在 Swift-CMake 示例存储库的双向 cxx 互操作项目 中提供了一个关于如何与作为单个项目一部分构建的 C++ 库进行双向互操作的示例。

获取库

如果您没有与 Swift 库一起构建 C 库,则需要以某种方式获取该库的副本。

include(ExternalProject)
ExternalProject_Add(ZLIB
    GIT_REPOSITORY "https://www.github.com/madler/zlib.git"
    GIT_TAG "09155eaa2f9270dc4ed1fa13e2b4b2613e6e4851" # v1.3
    GIT_SHALLOW TRUE

    UPDATE_COMMAND ""

    CMAKE_ARGS
      -DCMAKE_INSTALL_PREFIX:PATH=<INSTALL_DIR>
)
ExternalProject_Get_Property(ZLIB INSTALL_DIR)
add_library(zlib STATIC IMPORTED GLOBAL)
set_target_properties(zlib PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${INSTALL_DIR}/include"
  IMPORTED_LOCATION "${INSTALL_DIR}/lib/libz.a"
)

add_executable(example example.c)
target_link_libraries(example PRIVATE zlib)

此示例从 GitHub 下载 zlib v1.3 并构建它。由于我们设置了固定的标签,因此我们不需要 CMake 尝试更新它,提交哈希的内容永远不会更改。External Project 创建的 ZLIB 目标是一个 CMake “实用程序”库,因此我们无法直接链接到它。相反,我们可以设置 ZLIB_DIR 为构建目录后使用 find_package 来查找它,或者由于我们已经知道它存在的位置,我们可以创建一个导入的静态库。将 INTERFACE_INCLUDE_DIRECTORIES 设置为安装 zlib 标头的位置,并将 IMPORTED_LOCATION 设置为静态存档会导致生成一个可以链接代码的目标。然后,CMake 会告诉编译器在哪里查找标头以及将静态存档链接到任何链接到导入的 zlib 目标的任何目标。

使用 CMake 封装现有 C 库的示例将使用 find_package、自定义模块映射文件和虚拟文件系统 (VFS) 覆盖,以及一个帮助程序层,用于将 SQLite 代码库的部分迁移到 Swift 可以导入的内容。

开始入门

从基本的 CMake 设置开始

cmake_minimum_required(VERSION 3.26)
project(SQLiteImportExample LANGUAGES Swift C)

这将创建一个名为 “SQLiteImportExample” 的 CMake 项目,该项目使用 Swift 和 C,并且需要 CMake 版本 3.26 或更高版本。

在此示例中,我们不会构建 SQLite,我们将从系统或提供的 sysroot 中拉取它。

find_package(SQLite3 REQUIRED)

这告诉 CMake 根据 FindSQLite3.cmake 软件包文件查找 SQLite3。由于我们已将其标记为必需的依赖项,因此如果 CMake 找不到软件包的某些部分,它将停止构建的配置。

找到后,CMake 定义以下变量

CMake 还将定义 SQLite::SQLite3 构建目标,我们稍后将使用它来更轻松地通过我们的构建图传播依赖项和搜索位置信息。有关 SQLite3 软件包的文档,请访问此处:FindSQLite3

将 SQLite 导入 Swift

Swift 无法直接导入头文件。SwiftPM 和 Xcode 等某些工具有时可以为桥接头生成模块映射,但 CMake 等其他工具则不能。手动编写模块映射可以让您更好地控制 C 库导入 Swift 的方式。有关如何编写模块映射文件的详细信息,请访问 模块映射语言 规范。

对于我们的示例,我们只需要向 Swift 公开 sqlite3.h 头文件。

我们的 sqlite3.modulemap 文件的内容如下

module CSQLite {
  header "sqlite3.h"
}

模块名称表示我们用于将此模块导入 Swift 的名称。对于我们的示例,相应的 Swift 导入语句将是 import CSQLite

我们可以包含其他指令,例如 link "sqlite3",以指示自动链接机制应自动链接到 sqlite3 库,但这对于我们的目的来说是不必要的,因为当我们告诉程序链接到 sqlite 库时,CMake 会自动为我们执行此操作。

现在,我们需要将模块映射文件放置在正确的位置。我们期望模块映射文件与 sqlite.h 文件位于同一位置,但是根据 sqlite.h 的位置,我们可能无法访问它。这就是虚拟文件系统的用武之地。虚拟文件系统 (VFS) 是从编译器角度来看的文件系统视图。VFS 覆盖文件允许我们覆盖该视图,以便我们可以更改文件名并将文件放置在文件系统中的任何位置(从编译器的角度来看),而无需将它们实际放置在物理驱动器上。

VFS 覆盖的输入格式是 YAML(请注意,JSON 是 YAML 的子集,因此如果您愿意,可以将其表示为 JSON 对象)。缺点是此文件需要根目录的绝对路径或您要覆盖的位置。根据您写入的位置,该位置可能不可移植,因此硬编码这些文件可能不起作用。但是,我们可以使用 CMake 动态生成适用于我们系统的覆盖。

---
version: 0
case-sensitive: false
use-external-names: false
roots:
  - name: "@SQLite3_INCLUDE_DIR@"
    type: directory
    contents:
      - name: module.modulemap
        type: file
        external-contents: "@SQLite3_MODULEMAP_FILE@"

该文件尚不完整。我们将覆盖模板与以下 CMake 配对,以发出与我们的环境匹配的最终覆盖文件。

# Setup the VFS-overlay to inject the custom modulemap to import SQLite into Swift
set(SQLite3_MODULEMAP_FILE "${CMAKE_CURRENT_SOURCE_DIR}/sqlite3.modulemap")
configure_file(sqlite-vfs-overlay.yaml "${CMAKE_CURRENT_BINARY_DIR}/sqlite3-overlay.yaml")

target_compile_options(SQLite::SQLite3 INTERFACE
  "$<$<COMPILE_LANGUAGE:Swift>:SHELL:-vfsoverlay ${CMAKE_CURRENT_BINARY_DIR}/sqlite3-overlay.yaml>"
)

结果是一个 VFS 覆盖文件,它将自定义模块映射文件注入到 sqlite.h 所在的目录中,同时将 sqlite.3.modulemap 重命名为 module.modulemap。所有使用 SQLite3 库的 Swift 程序都需要使用关联的 VFS 覆盖文件才能找到模块映射。我们使用 target_compile_options 添加它。由于 SQLite::SQLite3 是一个导入的库,因此它不会影响 SQLite 本身的构建,因此我们将其添加为 INTERFACE 选项,以确保将其传播到所有依赖于它的目标。

现在在此项目上运行 CMake 应该会报告您缺少 SQLite,在这种情况下,您将需要安装它才能使用它,或者将 sqlite3-overlay.yaml 发射到构建目录的顶部。

---
version: 0
case-sensitive: false
use-external-names: false
roots:
  - name: "/usr/include"
    type: directory
    contents:
      - name: module.modulemap
        type: file
        external-contents: "/home/ewilde/sqlite-import-example/sqlite3.modulemap"

这是在我的 Linux 系统上发出的内容,其中 sqlite3.h 位于 /usr/include,项目源位于我的主目录中的目录中。

这应该足以导入内容。总结一下,我们的项目总共有四个文件

// sqlite3.modulemap
module CSQLite {
  header "sqlite3.h"
}
# sqlite-vfs-overlay.yaml
---
version: 0
case-sensitive: false
use-external-names: false
roots:
  - name: "@SQLite3_INCLUDE_DIR@"
    type: directory
    contents:
      - name: module.modulemap
        type: file
        external-contents: "@SQLite3_MODULEMAP_FILE@"
# CMakeLists.txt
cmake_minimum_required(VERSION 3.26)
project(SQLiteImportExample LANGUAGES Swift C)

find_package(SQLite3 REQUIRED)

# Setup the VFS-overlay to inject the custom modulemap file
set(SQLite3_MODULEMAP_FILE "${CMAKE_CURRENT_SOURCE_DIR}/sqlite3.modulemap")
configure_file(sqlite-vfs-overlay.yaml
               "${CMAKE_CURRENT_BINARY_DIR}/sqlite3-overlay.yaml")
target_compile_options(SQLite::SQLite3 INTERFACE
  "$<$<COMPILE_LANGUAGE:Swift>:SHELL:-vfsoverlay ${CMAKE_CURRENT_BINARY_DIR}/sqlite3-overlay.yaml>")

add_executable(Hello hello.swift)
target_link_libraries(Hello PRIVATE SQLite::SQLite3)
// hello.swift
import CSQLite

public class Database {
    var dbCon: OpaquePointer!

    public struct Flags: OptionSet {
        public let rawValue: Int32

        public init(rawValue: Int32) {
            self.rawValue = rawValue
        }

        public static let readonly = Flags(rawValue: SQLITE_OPEN_READONLY)
        public static let readwrite = Flags(rawValue: SQLITE_OPEN_READWRITE)
        public static let create = Flags(rawValue: SQLITE_OPEN_CREATE)
        public static let deleteOnClose = Flags(rawValue: SQLITE_OPEN_DELETEONCLOSE)
    }

    public init?(filename: String, flags: Flags = [.create, .readwrite]) {
        guard sqlite3_open_v2(filename, &dbCon, flags.rawValue, nil) == SQLITE_OK,
              dbCon != nil else {
            return nil
        }
    }

    deinit {
        sqlite3_close_v2(dbCon)
    }
}

guard let database = Database(filename: ":memory:") else {
    fatalError("Failed to load database for some reason")
}

使用 C/C++

管理包装的 C/C++ 类型的生命周期

当包装具有指定生命周期的 C/C++ 类型时,例如通过某种形式的初始化以及稍后的 “destroy” 调用所概述的那样,在 Swift 中有两种方法来处理这种情况。当包装具有某些 resource_init()resource_destroy(the_resource) API 的 C 类型时,这种情况尤其常见。

第一种方法是使用 Swift 类来包装资源,并通过该类的 init/deinit 来管理其生命周期。这是一个包装来自 RocksDB 的 C 管理设置对象的示例

public final class WriteOptions {
    let underlying: OpaquePointer!

    public init() {
        underlying = rocksdb_writeoptions_create()
    }

    deinit {
        rocksdb_writeoptions_destroy(underlying)
    }
}

第二种方法是使用 “不可复制” 类型(您可能从其他语言中了解为仅移动类型)。要使用不可复制类型声明类似的 WriteOptions 包装器,您可以执行以下操作

public struct WriteOptions: ~Copyable {
    let underlying: OpaquePointer!

    public init() {
        underlying = rocksdb_writeoptions_create()
    }

    deinit {
        rocksdb_writeoptions_destroy(underlying)
    }
}

不可复制类型的缺点是,目前它们不能在所有上下文中使用。例如,在 Swift 5.9 中,无法将不可复制类型存储为字段,或者通过闭包传递它们(因为闭包可能会被多次使用,这将破坏不可复制类型需要保证的唯一性)。优点是,与类不同,不可复制类型不执行引用计数。