在 Swift 中封装 C/C++ 库
有很多优秀的库是用 C/C++ 编写的。可以在 Swift 代码中使用这些库,而无需用 Swift 重写它们。本文将解释几种实现此目的的方法以及在 Swift 中使用 C/C++ 时的最佳实践。
软件包
- 如果需要,创建一个新的 Swift 软件包,其中包含
Package.swift
、Sources
目录等。 - 在
Sources
下为 C/C++ 库创建一个新的模块/目录。在本节的其余部分,我们假设它被命名为CMyLib
。- 一种约定是以
C
为模块名称前缀。例如,CDataStaxDriver
。
- 一种约定是以
- 将 C/C++ 库的源代码目录添加为 Git 子模块,位于
Sources/CMyLib
下。- 如果设置正确,Swift 软件包的根目录中应该有一个
.gitmodules
文件,内容如下所示
[submodule "my-lib"] path = Sources/CMyLib/my-lib url = https://github.com/examples/my-lib.git
- 如果设置正确,Swift 软件包的根目录中应该有一个
-
修改
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 文档。 - 尝试使用
swift build
编译 Swift 软件包。根据需要调整Package.swift
。
模块映射
除非存在自定义模块映射(即,头文件目录中存在 module.modulemap
文件),否则会自动为 Clang 目标(例如,CMyLib
)生成模块映射。
模块映射生成的规则可以在此处找到。
C/C++ 库构建生成的包含文件
某些 C/C++ 库在其构建中生成额外的、必需的文件(例如,配置文件)。要将这些文件包含在 Swift 软件包中
cd
进入 C/C++ 库的根目录(例如,Sources/CMyLib/my-lib
),然后构建它。- 回想一下上面的步骤 3,这是 Git 子模块的目录。不应在此目录中进行任何修改。 C/C++ 库构建生成的输出文件/目录应添加到
.gitignore
中。
- 回想一下上面的步骤 3,这是 Git 子模块的目录。不应在此目录中进行任何修改。 C/C++ 库构建生成的输出文件/目录应添加到
- 在
Sources/CMyLib/
下创建一个目录来存放必需的文件。(例如,Sources/CMyLib/extra
) - 将生成的、必需的文件从 C/C++ 构建输出复制到上一步创建的目录。
- 更新
Package.swift
,通过将步骤 2 中创建的目录路径(即extra
)或单个文件路径(例如,./extra/config.h
)添加到目标的(即CMyLib
)sources
数组(用于源文件)或作为.headerSearchPath
(用于头文件)。
覆盖 C/C++ 库中的文件
要使用自定义实现来代替 C/C++ 库附带的实现
- 在
Sources/CMyLib
下创建一个目录来存放自定义代码文件。(例如,Sources/CMyLib/custom
) - 将自定义代码文件添加到上一步创建的目录中。
- 如果需要,为源文件和头文件创建单独的子目录。
- 更新
Package.swift
- 将步骤 1 中创建的目录路径(即
custom
)或单个文件路径(例如,./custom/my_impl.c
)添加到目标的(即CMyLib
)sources
数组(用于源文件)或作为.headerSearchPath
(用于头文件)。 - 将 C/C++ 库的文件路径添加到目标的(即
CMyLib
)exclude 数组。(例如,./my-lib/impl.c
)
- 将步骤 1 中创建的目录路径(即
CMake
此示例旨在将 C 库导入 Swift。您需要获取库,提供模块映射以便 Swift 可以导入它,然后链接到它。C++ 的机制大致相同,并且在 Swift-CMake 示例存储库的双向 cxx 互操作项目 中提供了一个关于如何与作为单个项目一部分构建的 C++ 库进行双向互操作的示例。
获取库
如果您没有与 Swift 库一起构建 C 库,则需要以某种方式获取该库的副本。
- ExternalProject
- 在构建时运行,配置性最有限,但也隔离了 C/C++ 库的构建与您的构建。
- 当您的项目与依赖项之间的耦合度较低,并且库不太可能安装在您的项目预期运行的位置,或者当您需要对依赖项构建进行一定程度的配置时,这很好。
- 更多详细信息请访问 External Project。
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
目标的任何目标。
FetchContent
- 在配置时运行,并生成合并的构建图。这最适合拉取作为库实现细节的外部组件。请注意,由于构建图已合并,因此变量名和目标需要适当命名空间,否则它们将发生冲突,并且事物可能无法按预期构建。
- 当您的项目与依赖项之间存在紧密耦合时,这很好。由于构建图已合并,因此您的项目可以依赖于依赖项中的单个构建目标,而不是整个项目,这可以提高构建性能。
- 更多详细信息请访问 FetchContent。
find_package
- 从 sysroot 查找库和标头。默认情况下,CMake 会将您的 OS 根目录作为 sysroot,但可以将其隔离到其他 sysroot 以进行交叉编译。
- 此选项非常适合从基本系统或 sysroot 中获取系统依赖项,或者让您的项目分发者可以选择使用预构建项目,方法是使用
<PackageName>_ROOT
。 - 更多详细信息请访问 find_package。
使用 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 定义以下变量
SQLite3_INCLUDE_DIRS
— 找到sqlite3.h
的文件路径SQLite3_LIBRARIES
— sqlite 的使用者需要链接到的库SQLite3_VERSION
— 找到的 sqlite3 版本SQLite3_FOUND
— 用于告知find_package
已找到 SQLite。请注意,如果我们没有将其标记为REQUIRED
软件包,我们稍后可以检查此变量以查看是否已找到它,如果未找到,则回退到ExternalProject
以单独构建它。
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
告诉 Swift 哪些 C 头文件与哪些导入的模块相关联。sqlite-vfs-overlay.yaml
告诉 Swift 将 sqlite3 模块映射文件注入到正确的位置以进行导入,而无需更改实际系统。CMakeLists.txt
组织配置 VFS 覆盖,然后构建项目。hello.swift
调用 C SQLite 库。
// 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 中,无法将不可复制类型存储为字段,或者通过闭包传递它们(因为闭包可能会被多次使用,这将破坏不可复制类型需要保证的唯一性)。优点是,与类不同,不可复制类型不执行引用计数。