泛型在运行时被擦除,那就用匿名子类把类型信息"焊"回去------一个字节缓冲按任意类型读写。
前言
处理栅格数据(影像、高程网格、DEM)时,一个绕不开的需求是:同一块连续内存,希望它能按不同数据类型来解释。有时里面是 8 位灰度,有时是 16 位整型高程,有时又是 32 位浮点。如果你为每种类型各写一套类,代码爆炸;如果你用 Object 硬扛,又慢又难维护。
这个坑很磨人:你明明知道这块缓冲是 Int,却只能在运行时靠一堆 if/else 强转;想写一个通用的 get(),编译器不让你按 T 去读内存,因为运行时的 T 已经没了。泛型带来的类型安全,在读取那一刻反而成了障碍。
这套引擎的栅格缓冲区就用"匿名子类"把类型信息在构造时固化下来。下面讲一个很实用的技巧:利用匿名子类,让一个字节缓冲可以零开销地按任意数据类型读写。
一、问题长什么样
假设你有一个通用的数据缓冲抽象,希望它能封装任意类型的栅格值:
kotlin
abstract class DataBuffer<T> {
protected val buffer: ByteBuffer
abstract fun get(): T // 按 T 的类型读当前值
fun put(value: Any): DataBuffer<T> // 写入任意原始类型
}
你面对的核心矛盾是:get() 里到底调用 getByte()、getShort()、getInt() 还是 getDouble()?这取决于 T 是什么,但运行时的 T 是未知的 (被擦除了)。你总不能在泛型方法里写 when(T::class),因为 T::class 取不到。
更麻烦的是索引语义。栅格是二维数组,但内存是线性的。一个"位置 i"应该是"第 i 个元素",而不是"第 i 个字节"------对 INT 类型,第 10 个整数对应第 40 个字节。
结论先行:只要涉及"按泛型类型读内存",类型擦除就一定会在运行时挡路。
二、为什么"擦除"让人抓狂
Java/Kotlin 的泛型是编译期类型检查、运行时擦除 的。也就是说,DataBuffer<Int> 和 DataBuffer<Double> 在运行时刻是同一个类,T 只是一个编译期符号,运行时不存在。所以:
- 你没法用
T直接索引到正确的读法; - 一个"读当前值"的抽象方法,必须在具体类型下实现;
- 但你又不想为 7 种类型各写 7 个完整子类。
这就是"类型擦除 + 需要按类型读内存"这两件事的天然冲突。
运行时的 T 不存在,所以"按 T 读内存"这件事只能下沉到具体类型。
2.1 擦除带来的索引歧义
同样的 get(i),对 Byte 是第 i 个字节、对 Int 是第 4i 个字节。下标语义依赖类型,而类型运行时没了,基类自己算不出正确的字节偏移。
2.2 不想写 7 个完整子类
栅格类型一共就那么几种,每种只需覆盖一个 get()。为每个类型都写一整套完整子类,重复逻辑(索引换算、重采样、flip)会被复制 7 遍,维护成本陡增。
三、解法:匿名子类在构造时把类型"焊"回去
思路很直接:既然 T 在运行时不存在,那就让每个实例在构造时就"自带"一份正确的读取行为。 用匿名子类,每种 DataType 生成一个只覆盖 get() 的匿名实例:
kotlin
companion object {
fun create(buffer: ByteBuffer, datatype: DataType): DataBuffer<*> =
when (datatype) {
DataType.BYTE -> object : DataBuffer<Byte>(buffer, datatype) {
override fun get() = buffer.get()
}
DataType.SHORT -> object : DataBuffer<Short>(buffer, datatype) {
override fun get() = buffer.getShort()
}
DataType.INT -> object : DataBuffer<Int>(buffer, datatype) {
override fun get() = buffer.getInt()
}
DataType.FLOAT -> object : DataBuffer<Float>(buffer, datatype) {
override fun get() = buffer.getFloat()
}
DataType.DOUBLE -> object : DataBuffer<Double>(buffer, datatype) {
override fun get() = buffer.getDouble()
}
// ... 其余类型类似
}
}
每个匿名子类只覆盖一行 get(),其余逻辑(索引换算、resample、flip)全在基类里共用。这既避免了写 7 个完整子类的爆炸,又把"类型信息"以多态 的形式固化到了每个实例里------get() 被调用时,虚拟分派自动走到正确的读取代码,运行时开销为零。
位置换算则由基类统一处理:把"第 i 个元素"换算成"第 i × 类型字节数 个字节":
kotlin
fun get(i: Int): T {
buffer.position(i * datatype.size()) // datatype.size() = 字节数
return get()
}
跨类型写入 ,用 instanceof 分派即可,因为写入侧拿到的是明确的具体值:
kotlin
fun put(value: Any): DataBuffer<T> {
when (value) {
is Byte -> buffer.put(value)
is Short -> buffer.putShort(value)
is Int -> buffer.putInt(value)
is Double -> buffer.putDouble(value)
else -> throw IllegalArgumentException("unknown value: $value")
}
return this
}
重采样同样可以抽象在基类:最近邻插值,本质就是"从源缓冲按比例取像素填到目标缓冲":
kotlin
fun resample(from: Dimension, to: Dimension): DataBuffer<T> {
val out = create(to.width * to.height, datatype)
val xRatio = from.width.toDouble() / to.width
val yRatio = from.height.toDouble() / to.height
for (y in 0 until to.height) {
for (x in 0 until to.width) {
val px = floor(x * xRatio).toInt()
val py = floor(y * yRatio).toInt()
out.put(y * to.width + x, get(py * from.width + px))
}
}
return out
}
整条链路下来,调用方永远面对一个统一的 DataBuffer<T>,内部却按真实类型在读写------类型抽象的正确性,靠的是多态而非 Object 强转。
匿名子类把类型信息固化进实例,多态分派零开销地解决了擦除问题。
3.1 位置换算统一收口到基类
"第 i 个元素 → 第 i×size 个字节"只有一处实现,所有类型共用,避免每个子类各算一遍偏移而出错。
3.2 写入侧用 instanceof 分派
写入时拿到的是具体值,类型已知,用 when (value) 匹配 is Byte/Short/... 即可正确落字节,比读取侧更直白。
四、升华:什么时候用这套,什么时候该换
"匿名子类固化类型"这套手法,适合类型集合固定、且每种类型只需覆盖很少几个方法 的场景。栅格数据类型就那么 7 种,每种只需覆盖一个 get(),用匿名子类最划算。
但它的局限也很明显:如果子类方法很多、或者类型可能动态扩展,匿名子类代码会膨胀。替代方案有:
- 类型化常量 :把"读取函数"存成函数对象
(ByteBuffer) -> T,用一张Map<DataType, 读取函数>查表,比一串匿名子类更扁平、更易测。 - 无类型中间表示 :统一读成
Double,牺牲一点精度换极简(适合精度要求不高的可视化)。 - 序列化框架:若与存储强绑定,直接用现成的栅格库(如 GeoTIFF 解析库)更省心。
选型的核心判断是:类型种类是否有限且稳定。 有限稳定,就用固化类型;频繁扩展,就上函数对象或查表。
类型有限且稳定才用匿名子类;频繁扩展应换函数对象查表。
五、结论
- 泛型在运行时被擦除,导致
DataBuffer<T>无法按T直接读内存。 - 用匿名子类 在构造时为每种数据类型固化一个只覆盖
get()的实例,运行时零开销。 - 位置换算(第 i 个元素 → 第 i×size 个字节)统一放在基类,跨类型写入用
instanceof分派。 - 重采样、flip、rewind 等通用逻辑全部抽象在基类,子类只负责"按类型读一个值"。
- "类型集合有限稳定"是使用此手法的前提;类型多变时应改用函数对象查表等更扁平的结构。
你在处理栅格或二进制缓冲时,被泛型擦除坑过吗?欢迎在评论区聊聊你是怎么把类型信息留到运行时的。