映射来自 C 语言的结构与联合类型——教程

这是本系列的第二篇教程。本系列的第一篇教程是映射来自 C 语言的原生数据类型。 系列其余教程包括映射来自 C 语言的函数指针映射来自 C 语言的字符串

在本教程中可以学到

映射 C 语言的结构与联合类型

理解在 Kotlin 与 C 之间进行映射的最好方式是尝试编写一个小型示例。我们将在 C 语言中声明一个结构体与一个联合体,并以此来观察如何将它们映射到 Kotlin 中。

Kotlin/Native 附带 cinterop 工具,该工具可以生成 C 语言与 Kotlin 之间的绑定。 它使用一个 .def 文件指定一个 C 库来导入。更多的细节将在与 C 库互操作教程中讨论。

之前的教程中创建过一个 lib.h 文件。这次, 在 --- 分割行之后,直接将那些声明导入到 interop.def 文件:


---

typedef struct {
  int a;
  double b;
} MyStruct;

void struct_by_value(MyStruct s) {}
void struct_by_pointer(MyStruct* s) {}

typedef union {
  int a;
  MyStruct b;
  float c;
} MyUnion;

void union_by_value(MyUnion u) {}
void union_by_pointer(MyUnion* u) {}

interop.def 文件足够用来编译并运行应用程序,或在 IDE 中打开它。 现在创建项目文件,并在 IntelliJ IDEA 中打开该项目,然后运行它。

探查为 C 库生成的 Kotlin API

While it is possible to use the command line, either directly or by combining it with a script file (such as .sh or .bat file), this approach doesn't scale well for big projects that have hundreds of files and libraries. It is then better to use the Kotlin/Native compiler with a build system, as it helps to download and cache the Kotlin/Native compiler binaries and libraries with transitive dependencies and run the compiler and tests. Kotlin/Native can use the Gradle build system through the kotlin-multiplatform plugin.

We covered the basics of setting up an IDE compatible project with Gradle in the A Basic Kotlin/Native Application tutorial. Please check it out if you are looking for detailed first steps and instructions on how to start a new Kotlin/Native project and open it in IntelliJ IDEA. In this tutorial, we'll look at the advanced C interop related usages of Kotlin/Native and multiplatform builds with Gradle.

First, create a project folder. All the paths in this tutorial will be relative to this folder. Sometimes the missing directories will have to be created before any new files can be added.

Use the following build.gradle(.kts) Gradle build file:

【Kotlin】

plugins {
    kotlin("multiplatform") version "1.6.21"
}

repositories {
    mavenCentral()
}

kotlin {
  linuxX64("native") { // on Linux
  // macosX64("native") { // on x86_64 macOS
  // macosArm64("native") { // on Apple Silicon macOS
  // mingwX64("native") { // on Windows
    val main by compilations.getting
    val interop by main.cinterops.creating

    binaries {
      executable()
    }
  }
}

tasks.wrapper {
  gradleVersion = "6.7.1"
  distributionType = Wrapper.DistributionType.BIN
}

【Groovy】

plugins {
    id 'org.jetbrains.kotlin.multiplatform' version '1.6.21'
}

repositories {
    mavenCentral()
}

kotlin {
  linuxX64('native') { // on Linux
  // macosX64("native") { // on x86_64 macOS
  // macosArm64("native") { // on Apple Silicon macOS
  // mingwX64('native') { // on Windows
    compilations.main.cinterops {
      interop 
    }

    binaries {
      executable()
    }
  }
}

wrapper {
  gradleVersion = '6.7.1'
  distributionType = 'BIN'
}

The project file configures the C interop as an additional step of the build. Let's move the interop.def file to the src/nativeInterop/cinterop directory. Gradle recommends using conventions instead of configurations, for example, the source files are expected to be in the src/nativeMain/kotlin folder. By default, all the symbols from C are imported to the interop package, you may want to import the whole package in our .kt files. Check out the kotlin-multiplatform plugin documentation to learn about all the different ways you could configure it.

Create a src/nativeMain/kotlin/hello.kt stub file with the following content to see how C declarations are visible from Kotlin:

import interop.*

fun main() {
  println("Hello Kotlin/Native!")

  struct_by_value(/* fix me*/)
  struct_by_pointer(/* fix me*/)
  union_by_value(/* fix me*/)
  union_by_pointer(/* fix me*/)
}

现在已经准备好在 IntelliJ IDEA 中打开这个项目并且看看如何修正这个示例项目。当做了这些之后, 会看到 C 的原生类型已经被映射到了 Kotlin/Native。

Kotlin 中的原生类型

通过 IntelliJ IDEA 的Go to | Declaration编译器错误的帮助,会看到如下的为 C 函数、struct 以及 union 生成的 API:

fun struct_by_value(s: CValue<MyStruct>)
fun struct_by_pointer(s: CValuesRef<MyStruct>?)

fun union_by_value(u: CValue<MyUnion>)
fun union_by_pointer(u: CValuesRef<MyUnion>?)

class MyStruct constructor(rawPtr: NativePtr /* = NativePtr */) : CStructVar {
    var a: Int
    var b: Double
    companion object : CStructVar.Type
}

class MyUnion constructor(rawPtr: NativePtr /* = NativePtr */) : CStructVar {
    var a: Int
    val b: MyStruct
    var c: Float
    companion object : CStructVar.Type
}

可以看到 cinterop 为我们的 structunion 类型生成了包装类型。 为在 C 中声明的 MyStructMyUnion 类型,分别为其生成了 Kotlin 类 MyStructMyUnion。 该包装器继承自 CStructVar 基类并将所有的字段声明为了 Kotlin 属性。 它使用 CValue<T> 来表示一个值类型的结构体参数并使用 CValuesRef<T>? 来表示传递一个结构体或共用体的指针。

从技术上讲,在 Kotlin 看来 structunion 类型之间没有区别。请注意,Kotlin 中 MyUnion 类的 ab 以及 c 属性使用了相同的位置来进行读写值的操作,就像 C 语言中的 union 一样。

更多细节与高级用例将在 C 互操作文档中介绍

在 Kotlin 中使用结构与联合类型

在 Kotlin 中使用为 C 的 structunion 类型生成的包装器非常简单。由于生成了属性,使得在 Kotlin 代码中使用它们是非常自然的。迄今为止唯一的问题是,如何为这些类创建新的实例。正如在 MyStructMyUnion 的声明中所见,它们的构造函数需要一个 NativePtr。 当然,不愿意手动处理指针。作为替代,可以使用 Kotlin API 来为我们实例化这些对象。

我们来看一看生成的函数,它将 MyStructMyUnion 作为参数。看到了值类型参数表示为 kotlinx.cinterop.CValue<T>。而指针类型参数表示为 kotlinx.cinterop.CValuesRef<T>。 Kotlin 给我们提供了 API 使得处理这两者都非常简单,我们来尝试一下并看看结果。

创建一个 CValue<T>

CValue<T> 类型用来传递一个值类型的参数到 C 函数调用。 使用 cValue 函数来创建 CValue<T> 对象实例。该函数需要一个带接收者的 lambda 函数字面值来就地初始化底层 C 类型。该函数的声明如下所示:

fun <reified T : CStructVar> cValue(initialize: T.() -> Unit): CValue<T>

现在是时候来看看如何使用 cValue 并传递值类型参数:

fun callValue() {

  val cStruct = cValue<MyStruct> {
    a = 42
    b = 3.14
  }
  struct_by_value(cStruct)

  val cUnion = cValue<MyUnion> {
    b.a = 5
    b.b = 2.7182
  }

  union_by_value(cUnion)
}

使用 CValuesRef<T> 创建结构体与联合体

CValuesRef<T> 类型用于在 Kotlin 中将指针类型的参数传递给 C 函数。首先,需要 MyStructMyUnion 类的实例。直接在原生内存中创建它们。 使用

fun <reified T : kotlinx.cinterop.CVariable> alloc(): T

kotlinx.cinterop.NativePlacement 上的扩展函数来做这个。

NativePlacement 代表原生内存,类似于 mallocfree 函数。 这里有几个 NativePlacement 的实现。其中全局的那个是调用 kotlinx.cinterop.nativeHeap 并且不要忘记在使用过后调用 nativeHeap.free(..) 函数来释放内存。

另一个配置是使用

fun <R> memScoped(block: kotlinx.cinterop.MemScope.() -> R): R

函数。它创建一个短生命周期的内存分配作用域, 并且所有的分配都将在 block 结束之后自动清理。

调用带指针类型参数的函数的代码看起来会是这样:

fun callRef() {
  memScoped {
    val cStruct = alloc<MyStruct>()
    cStruct.a = 42
    cStruct.b = 3.14

    struct_by_pointer(cStruct.ptr)

val cUnion = alloc<MyUnion>()
    cUnion.b.a = 5
    cUnion.b.b = 2.7182

    union_by_pointer(cUnion.ptr)
  }
}

请注意,这段代码使用的扩展属性 ptr 来自 memScoped lambda 表达式的接收者类型, 将 MyStructMyUnion 实例转换为原生指针。

MyStructMyUnion 类具有指向原生内存的指针。当 memScoped 函数结束的时候, 即 block 结尾的时候,内存将释放。请确保指针没有在 memScoped 调用的外部使用。可以为指针使用 Arena()nativeHeap 这样应该有更长的可用时间,或者将它们缓存在 C 库中。

CValue<T>CValuesRef<T> 之间转换

当然,这里有一些用例——当需要将一个结构体作为值传递给一个调用,另一种是将同一个结构体作为引用传递给另一个调用。这在 Kotlin/Native 中同样也是可行的。这里将需要一个 NativePlacement

我们看看现在首先将 CValue<T> 转换为一个指针:

fun callMix_ref() {
  val cStruct = cValue<MyStruct> {
    a = 42
    b = 3.14
  }

  memScoped { 
    struct_by_pointer(cStruct.ptr)
  }
}

这段代码使用的扩展属性 ptr 来自 memScoped lambda 表达式的接收者类型, 将 MyStructMyUnion 实例转换为原生指针。这些指针只在 memScoped 块内是有效的。

对于反向转换,即将指针转换为值类型变量, 我们可以调用 readValue() 扩展函数:

fun callMix_value() {
  memScoped {
    val cStruct = alloc<MyStruct>()
    cStruct.a = 42
    cStruct.b = 3.14

    struct_by_value(cStruct.readValue())
  }
}

运行代码

现在,学习了如何在我们的代码中使用 C 声明,已经准备好在一个真实的示例中尝试它的输出。我们来修改代码并看看如何在 IDE 中调用 runDebugExecutableNative Gradle 任务来运行它。 或者使用以下的控制台命令:

./gradlew runDebugExecutableNative

hello.kt 文件中的最终代码看起来会是这样:

import interop.*
import kotlinx.cinterop.alloc
import kotlinx.cinterop.cValue
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.readValue

fun main() {
  println("Hello Kotlin/Native!")

  val cUnion = cValue<MyUnion> {
    b.a = 5
    b.b = 2.7182
  }

  memScoped {
    union_by_value(cUnion)
    union_by_pointer(cUnion.ptr)
  }

  memScoped {
    val cStruct = alloc<MyStruct> {
      a = 42
      b = 3.14
    }

    struct_by_value(cStruct.readValue())
    struct_by_pointer(cStruct.ptr)
  }
}

接下来

在以下几篇相关的教程中继续浏览 C 语言的类型以及它们在 Kotlin/Native 中的表示:

这篇与 C 语言互操作文档涵盖了更多的高级互操作场景