将 Kotlin 内联 class 传递给 Retrofit 函数是否安全

Is it safe to pass a Kotlin inline class to a Retrofit function

如果我有以下内联 class:

interface StringId {
  val raw: String
}

@JvmInline
value class MyId(override val raw: String) : StringId

将它传递给 Retrofit 函数是否安全?

interface MyApiEndpoints {
  @GET("/fetch/{id}")
  suspend fun fetch(@Path("id") id: MyId): Response<JsonObject>
}

我会谨慎的。但是只要区分内联 class 和它的字段并不重要,你 可能 就没问题。

Retrofit 的工作原理是根据传递给 Retrofit::create 的接口的方法注释在 运行 时构造一个代理 class。首次调用接口方法时,代理使用反射来检查方法的签名和注释,然后用于构造实现。我们在这里感兴趣的是当类型签名提到内联 class.

时反射将会看到什么

“安全”问题(在这里问的那种)实际上是接口契约的问题:哪些行为可以保证稳定,哪些行为可能会根据其版本、与其他功能的交互而一时兴起而改变,发现优化机会或月相。在这种特殊情况下,我们想知道内联 Kotlin 的 JVM ABI classes.

人们可能希望 Kotlin 规范可以详细阐述该主题。但是遗憾的是,唯一的 Kotlin 规范只定义了语言的抽象约束,没有任何 ABI 细节。以下是 the specification (version 1.5-rfc+0.1) 关于反射 (§16.2) 的全部内容:

Particular platforms may provide more complex facilities for runtime type introspection through the means of reflection — special platform-provided part of the standard library that allows to access more detailed information about types and declarations at runtime. It is, however, platform-specific and one must refer to particular platform documentation for details.

这是关于内联 classes (§4.1.5) 的表示的内容:

[A]n value [sic] class is allowed by the implementation to be inlined where applicable, so that its data property is operated on instead. This also means that the property may be boxed back to the value class by using its primary constructor at any time if the compiler decides it is the right thing to do. [emphasis mine]

但是在实践中应该期待什么? ABI 很少被故意破坏,因为这往往具有很大的破坏性,所以我们可以指望它不会改变太多。手册虽然没有规范,但在 the section on representation of inline classes:

中仍然包含一些非常有指导意义的示例

Since inline classes are compiled to their underlying type, it may lead to various obscure errors, for example unexpected platform signature clashes:

@JvmInline
value class UInt(val x: Int)

// Represented as 'public final void compute(int x)' on the JVM
fun compute(x: Int) { }

// Also represented as 'public final void compute(int x)' on the JVM!
fun compute(x: UInt) { }

To mitigate such issues, functions using inline classes are mangled by adding some stable hashcode to the function name. Therefore, fun compute(x: UInt) will be represented as public final void compute-<hashcode>(int x), which solves the clash problem.

因此,我们应该期望采用内联 class 参数的方法具有替换了基础字段的类型签名,以及一个错位的名称。这也应该适用于接口方法。但我没有看到 Retrofit 解释这些的任何线索,即使它尝试了也做不到:不能逆转 mangling 以发现实际的底层类型。对于库来说,该方法将看起来像采用基础类型的任何其他方法,只是名称有点奇怪。这意味着您可能 运行 遇到这样的麻烦:

@JvmInline
value class MyId(val raw: String) {
    override fun toString(): String = "where is your god now?"
}

interface MyApiEndpoints {
    @GET("/fetch/{id}")
    suspend fun fetch(@Path("id") id: MyId): Response<JsonObject>
}

正如 @Path 的文档所解释的那样:

Values are converted to strings using Retrofit.stringConverter(Type, Annotation[]) (or Object.toString(), if no matching string converter is installed) and then URL encoded.

在 JVM 级别,fetch 将具有采用 String 参数的类型签名,并且 Retrofit 生成的实现将这样对待它。因此,它不会调用您自定义的 toString 实现。 (在 Kotlin 中,这成功了,因为 Kotlin 在编译时知道类型并静态分派方法。)

但是在内联 class 和它的包装字段之间的区别并不重要的情况下,您 可能 能够摆脱它。