共计 9223 个字符,预计需要花费 24 分钟才能阅读完成。
异构列表(DslAdapter 开发日志)
函数范式, 或者说 Haskell 的终极追求是尽量将错误 ” 扼杀 ” 在编译期, 使用了大量的手法和技术: 使用大量不可变扼杀异步的不可预计, 以及静态类型和高阶类型
说到静态类型大家应该都不会陌生, 它是程序正确性的强大保证, 这也是本人为什么一直不太喜欢 Python, js 等动态类型语言的原因
静态类型:编译时即知道每一个变量的类型,因此,若存在类型错误编译是无法通过的。动态类型:编译时不知道每一个变量的类型,因此,若存在类型错误会在运行时发生错误。
类型检查, 即在编译期通过对类型进行检查的方式过滤程序的错误, 这是我们在使用 Java 和 Kotlin 等语言时常用的技术, 但这种技术是有限的, 它并不能通用于所有情况, 因此我们常常反而会回到动态类型, 采用动态类型的方式处理某些问题
本文聚焦于常见的列表容器在某些情况下如何用静态类型的手法进行开发进行讨论
编译期错误检查
对于函数 (方法) 的输入错误有两种方式:
编译期检查, 比如 List<String> 中不能保存 Integer 类型的数据
运行期检查, 比如对于列表的下标是否正确, 我们可以在运行的时候检查
运行期检查是必须要运行到相应的代码时才会进行相应的检查(无论是实际程序还是测试代码), 这是不安全并且效率低下的, 所以能在编译期检查的问题都尽量在编译期排除掉
编译期的检查中除了语法问题之外最重要的就是类型检查, 但这要求我们提供足够的类型信息
DslAdapter 实现中遇到的问题
DslAdapter 是个人开发的一个针对 Android RecyclerView 的一个扩展库, 专注于静态类型和 Dsl 的手法, 希望创造一个基于组合子的灵活易用同时又非常安全的 Adapter
在早期版本中已经实现了通过 Dsl 进行混合 Adapter 的创建:
val adapter = RendererAdapter.multipleBuild()
.add(layout<Unit>(R.layout.list_header))
.add(none<List<Option<ItemModel>>>(),
optionRenderer(
noneItemRenderer = LayoutRenderer.dataBindingItem<Unit, ItemLayoutBinding>(
count = 5,
layout = R.layout.item_layout,
bindBinding = {ItemLayoutBinding.bind(it) },
binder = {bind, item, _ ->
bind.content = “this is empty item”
},
recycleFun = {it.model = null; it.content = null; it.click = null}),
itemRenderer = LayoutRenderer.dataBindingItem<Option<ItemModel>, ItemLayoutBinding>(
count = 5,
layout = R.layout.item_layout,
bindBinding = {ItemLayoutBinding.bind(it) },
binder = {bind, item, _ ->
bind.content = “this is some item”
},
recycleFun = {it.model = null; it.content = null; it.click = null})
.forList()
))
.add(provideData(index).let {HListK.singleId(it).putF(it) },
ComposeRenderer.startBuild
.add(LayoutRenderer<ItemModel>(layout = R.layout.simple_item,
stableIdForItem = {item, index -> item.id},
binder = {view, itemModel, index -> view.findViewById<TextView>(R.id.simple_text_view).text = itemModel.title },
recycleFun = {view -> view.findViewById<TextView>(R.id.simple_text_view).text = “” })
.forList({i, index -> index}))
.add(databindingOf<ItemModel>(R.layout.item_layout)
.onRecycle(CLEAR_ALL)
.itemId(BR.model)
.itemId(BR.content, { m -> m.content + “xxxx”})
.stableIdForItem {it.id}
.forList())
.build())
.add(DateFormat.getInstance().format(Date()),
databindingOf<String>(R.layout.list_footer)
.itemId(BR.text)
.forItem())
.build()
以上代码实现了一个混合 Adapter 的创建:
|–LayoutRenderer header
|
|–SealedItemRenderer
| |–none -> LayoutRenderer placeholder count 5
| |
| |–some -> ListRenderer
| |–DataBindingRenderer 1
| |–DataBindingRenderer 2
| |–…
|
|–ComposeRenderer
| |–ListRenderer
| | |–LayoutRenderer simple item1
| | |–LayoutRenderer simple item2
| | |–…
| |
| |–ListRenderer
| |–DataBindingRenderer item with content1
| |–DataBindingRenderer item with content2
| |–…
|
|–DataBindingRenderer footer
即: Build Dsl –> Adapter, 最后生成了一个混合的 val adapter 而在使用的时候希望能通过这个 val adapter 对结构中某些部分进行部分更新
比如上面构造的结构中, 我们希望只在 ComposeRenderer 中第二个 ListRendererinsert 一个元素进去, 并合理调用 Adapter 的 notifyItemRangeInserted(position, count)方法, 并且希望这个操作可以通过 Dsl 的方式实现, 比如:
adapter.updateNow {
// 定位 ComposeRenderer
getLast2().up {
// 定位第二个 ListRenderer
getLast1().up {
insert(2, listOf(ItemModel(189, “Subs Title1”, “subs Content1”)))
}
}
}
以上 Dsl 必然是希望有一定的限定的, 比如不能在只有两个元素的 Adapter 中 getLast3(), 也不能在非列表中执行 insert()
而这些限制需要被从 val adapter 推出, 即 adapter –> Update Dsl, 这意味着 adapter 中需要保存其结构的所有信息, 由于我们需要在编译期对结构信息进行提取, 也意味着应该在类型信息中保存所有的结构信息
对于通常的 Renderer 没有太大的问题, 但对于部分组合其他 Renderer 的 Renderer, (比如 ComposeRenderer, 它的作用是按顺序将任意的 Renderer 组合在一起), 通常的实现方式是将他们统统还原为共通父类(BaseRenderer), 然后看做同样的东西进行操作, 但这个还原操作也同时将各自独特的类型信息给丢失了, 那应该怎么办才能即保证组合的多样性, 同时又不会丢失各自的类型信息?
换一种方式描述问题
推广到其他领域, 这个问题实际挺常见的, 比如:
我们现在有一个用于绘制的基类 RenderableBase, 而有两个实现, 一个是绘制圆形的 Circle 和绘制矩形的 Rectangle:
graph TB
A[RenderableBase]
A1[Circle]
A2[Rectangle]
A –> A1
A –> A2
我们有一个共通的用于绘制的类 Canvas, 保存有所有需要绘制的 RenderableBase, 一般情况下我们会通过一个 List<RenderableBase> 容器的方式保存它们, 将它们还原为通用的父类
但这种方式的问题是这种容器的类型信息中已经丢失了每个元素各自的特征信息, 我们没法在编译期知道或者限定子元素的类型(比如我们并不知道其中有多少个 Circle, 也不能限定第一个元素必须为 Rectangle)
那是否有办法即保证容器的多样性, 同时又不会丢失各自的类型信息?
再换一种方式描述问题
对于一个函数(方法), 比如:
fun test(s: String): List<String>
它其实可以看做声明了两个部分的函数:
值函数: 描述了元素 s 到列表 list 的态射
类型函数: 描述了从类型 String 到类型 List<String> 的态射
即包括 s -> list 和 String -> List<String>
一般而言这两者是同步的, 或者说类型信息中包括了足够的值相关的信息(值的类型), 但请注意以下函数:
fun test2(s: String, i: Int): List<Any?> = listOf(s, i)
它声明了(s, i) -> list 和(String, Int) -> List<Any?>, 它没有将足够的类型信息保存下来:
List 中只包括 String 和 Int 两种元素
List 的 Size 为 2
List 中第一个元素是 String, 第二个元素是 Int
那是否有办法将以上这些信息也合理的保存到容器的类型中呢?
一种解决方案
异构列表
以上的问题注意原因是在于 List 容器本身, 它本身就是一个保存相同元素的容器, 而我们需要是一个可以保存不同元素的容器
Haskell 中有一种这种类型的容器: Heterogeneous List(异构列表), 就实现上来说很简单:
Tip: arrow 中的实现
sealed class HList
data class HCons<out H, out T : HList>(val head: H, val tail: T) : HList()
object HNil : HList()
我们来看看使用它来构造上一节我们所说的函数应该如何构造:
// 原函数
fun test2(s: String, i: Int): List<Any?> = listOf(s, i)
// 异构列表
fun test2(s: String, i: Int): HCons<Int, HCons<String, HNil>> =
HCons(i, HCons(s, HNil))
同样是构建列表, 异构列表包含了更丰富的类型信息:
容器的 size 为 2
容器中第一个元素为 String, 第二个为 Int
相比传统列表异构列表的优势
完整保存所有元素的类型信息
自带容器的 size 信息
完整保存每个元素的位置信息
比如, 我们可以限定只能传入一个保存两个元素的列表, 其中第一个元素是 String, 第二个是 Int:
fun test(l: HCons<Int, HCons<String, HNil>>)
同时我们也可以确定第几个元素是什么类型:
val l: HCons<Int, HCons<String, HNil>> = …
l.get0() // 此元素一定是 Int 类型的
由于 Size 信息被固定了, 传统必须在运行期才能检查的下标是否越界的问题也可以在编译期被检查出来:
val l: HCons<Int, HCons<String, HNil>> = …
l.get3() // 编译错误, 因为只有两个元素
相比传统列表的难点
由于 Size 信息和元素类型信息是绑定的, 抛弃 Size 信息的同时就会抛弃元素类型的限制
注意类型信息中的元素信息和实际保存的元素顺序是相反的, 因为异构列表是一个 FILO(先进后出)的列表
由于 Size 信息是限定的, 针对不同 Size 的列表的处理需要分开编写
对于第一点, 以上面的 RenderableBase 为例, 比如我们有一个函数可以处理任意 Size 的异构列表:
fun <L : HList> test(l: L)
我们反而无法限定每个元素都应该是继承自 RenderableBase 的, 这意味着 HCons<Int, HCons<String, HNil>> 这种列表也可以传进来, 这在某些情况下是很麻烦的
异构列表中附加高阶类型的处理
Tip: 关于高阶类型的内容可以参考这篇文章高阶类型带来了什么继承是 OOP 的一大难点, 它的缺点在程序抽象度越来越高的过程的越来越凸显. 函数范式中是以组合代替继承, 使得程序有着更强的灵活性
由于采用函数范式, 我们不再讨论异构列表如何限定父类, 而是改为讨论异构列表如何限定高阶类型
对 HList 稍作修改即可附加高阶类型的支持:
Tip: DslAdapter 中的详细实现: HListK
sealed class HListK<F, A: HListK<F, A>>
class HNilK<F> : HListK<F, HNilK<F>>()
data class HConsK<F, E, L: HListK<F, L>>(val head: Kind<F, E>, val tail: L) : HListK<F, HConsK<F, E, L>>()
以 Option(可选类型)为例:
arrow 中的详细实现: Option
sealed class Option<out A> : arrow.Kind<ForOption, A>
object None : Option<Nothing>()
data class Some<out T>(val t: T) : Option<T>()
通过修改后的 HListK 我们可以限定每个元素都是 Option, 但并不限定 Option 内容的类型:
// [Option<Int>, Option<String>]
val l: HConsK<ForOption, String, HConsK<ForOption, Int, HNilK<ForOption>>> =
HConsK(Some(“string”), HConsK(199, HNilK()))
修改后的列表即可做到即保留每个元素的类型信息又可以对元素类型进行部分限定
它即等价于原生的 HList, 同时又有更丰富的功能
比如:
// 1. 定义一个单位类型
data class Id<T>(val a: T) : arrow.Kind<ForId, A>
// 类型 HListK<ForId, L> 即等同于原始的 HList
fun <L : HListK<ForId, L>> test()
// 2. 定义一个特殊类型
data class FakeType<T, K : T>(val a: K) : arrow.Kind2<ForFakeType, T, K>
// 即可限定列表中每个元素必须继承自 RenderableBase
fun <L : HListK<Kind<ForFakeType, RenderableBase>, L>> test(l: L) = …
fun test2() {
val t = FakeType<RenderableBase, Circle>(Circle())
val l = HListK.single(t)
test(l)
}
回到 DslAdapter 的实现
上文中提到的异构列表已经足够我们用来解决文章开头的 DslAdapter 实现问题了
异构问题解决起来就非常顺理成章了, 以 ComposeRenderer 为例, 我们使用将子 Renderer 装入 ComposeItem 容器的方式限定传入的容器每个元素必须是 BaseRenderer 的实现, 同时 ComposeItem 通过泛型的方式尽最大可能保留 Renderer 的类型信息:
data class ComposeItem<T, VD : ViewData<T>, UP : Updatable<T, VD>, BR : BaseRenderer<T, VD, UP>>(
val renderer: BR
) : Kind<ForComposeItem, Pair<T, BR>>
其中可以注意到类型声明中的 Kind<ForComposeItem, Pair<T, BR>>, arrow 默认的三元高阶类型为 Kind<Kind<ForComposeItem, T>, BR>, 这并不符合我们在这里对高阶类型的期望: 我们这里只想限制 ForComposeItem, 而 T 我们希望和 BR 绑定在一起限定, 所以使用了积类型 Pair 将 T 和 BR 两个类型绑定到了一起. 换句话说, Pair 在这里只起到一个组合类型 T 和 BR 的类型粘合剂的作用, 实际并不会被使用到
ComposeItem 保存的是在 build 之后不会改变的数据(比如 Renderer), 而使用中会改变的数据以 ViewData 的形式保存在 ComposeItemData:
data class ComposeItemData<T, VD : ViewData<T>, UP : Updatable<T, VD>, BR : BaseRenderer<T, VD, UP>>(
val viewData: VD,
val item: ComposeItem<T, VD, UP, BR>) : Kind<ForComposeItemData, Pair<T, BR>>
这里同样使用了 Pair 作为类型粘结剂的技巧
对于一个 ComposeRenderer 而言应该保存以下信息:
可以渲染的数据类型
子 Renderer 的所有类型信息
当前 Renderer 的 ViewData 信息以及子 Renderer 的 ViewData 信息
其中
2. 子 Renderer 的所有类型信息由 IL : HListK<ForComposeItem, IL> 泛型信息保存
3. 当前 Renderer 的 ViewData 信息以及子 Renderer 的 ViewData 信息由 VDL : HListK<ForComposeItemData, VDL> 泛型信息保存
而 1. 可以渲染的数据类型由 DL : HListK<ForIdT, DL>(ForIdT 等同于上文提到的单位类型 Id)
于是我们可以得到 ComposeRenderer 的类型声明:
class ComposeRenderer<DL : HListK<ForIdT, DL>, IL : HListK<ForComposeItem, IL>, VDL : HListK<ForComposeItemData, VDL>>
子 Renderer 的所有类型信息 (Size, 下标等等) 被完整保留, 也就意味着从类型信息我们可以还原出每个子 Renderer 的完整类型信息
一个栗子: 构造两个子 Renderer:
// LayoutRenderer
val stringRenderer = LayoutRenderer<String>(layout = R.layout.simple_item,
count = 3,
binder = {view, title, index -> view.findViewById<TextView>(R.id.simple_text_view).text = title + index },
recycleFun = {view -> view.findViewById<TextView>(R.id.simple_text_view).text = “” })
// DataBindingRenderer
val itemRenderer = databindingOf<ItemModel>(R.layout.item_layout)
.onRecycle(CLEAR_ALL)
.itemId(BR.model)
.itemId(BR.content, { m -> m.content + “xxxx”})
.stableIdForItem {it.id}
.forItem()
使用 ComposeRenderer 组合两个 Renderer:
val composeRenderer = ComposeRenderer.startBuild
.add(itemRenderer)
.add(stringRenderer)
.build()
你可以猜出这里 composeRenderer 的类型是什么吗?
答案是:
ComposeRenderer<
HConsK<ForIdT, String, HConsK<ForIdT, ItemModel, HNilK<ForIdT>>>, HConsK<ForComposeItem, Pair<String, LayoutRenderer<String>>,
HConsK<ForComposeItem, Pair<ItemModel, DataBindingRenderer<ItemModel, ItemModel>>, HNilK<ForComposeItem>>>,
HConsK<ForComposeItemData, Pair<String, LayoutRenderer<String>>, HConsK<ForComposeItemData, Pair<ItemModel, DataBindingRenderer<ItemModel, ItemModel>>, HNilK<ForComposeItemData>>>
>
其中完整保留了所有我们需要的类型信息, 因此我们可以通过 composeRenderer 还原出原来的数据结构:
composeRenderer.updater
.updateBy {
getLast1().up {
update(“New String”)
}
}
这里的 update(“New String”)方法知道当前定位的是一个 stringRenderer, 所以可以使用 String 更新数据, 如果传入 ItemModel 就会出错
虽然泛型信息非常多而长, 但实际大部分可以通过编译系统自动推测出来, 而对于某些无法被推测的部分也可以通过一些小技巧来简化, 你可以猜到用了什么技巧吗?
结语
以前我们常常更聚焦于面向过程编程, 但对函数范式或者说 Haskell 的学习, 类型编程其实也是一个很有趣并且很有用的思考方向
没错, 类型是有相应的计算规则的, 甚至有的编程语言会将类型作为一等对象, 可以进行相互计算(积类型, 和类型, 类型的幂等)
虽然 Java 或者 Kotlin 的类型系统并没有如此的强大, 但只要改变一下思想, 通过一些技巧还是可以实现很多像魔法一样的事情(比如另一篇文章中对高阶类型的实现)
将 Haskell 的对类型系统编程应用到 Kotlin 上有很多有趣的技巧, DslAdapter 只是在实用领域上一点小小的探索, 而 fpinkotlin 则是在实验领域的另外一些探索成果(尤其是第四部分 15. 流式处理与增量 I /O), 希望之后能有机会分享更多的一些技巧和经验, 也欢迎感兴趣的朋友一同探讨