1

异构列表(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>

它其实可以看做声明了两个部分的函数:

  1. 值函数: 描述了元素s到列表list的态射
  2. 类型函数: 描述了从类型String到类型List<String>的态射

即包括s -> listString -> List<String>

一般而言这两者是同步的, 或者说类型信息中包括了足够的值相关的信息(值的类型), 但请注意以下函数:

fun test2(s: String, i: Int): List<Any?> = listOf(s, i)

它声明了(s, i) -> list(String, Int) -> List<Any?>, 它没有将足够的类型信息保存下来:

  1. List中只包括StringInt两种元素
  2. List的Size为2
  3. 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))

同样是构建列表, 异构列表包含了更丰富的类型信息:

  1. 容器的size为2
  2. 容器中第一个元素为String, 第二个为Int

相比传统列表异构列表的优势

  1. 完整保存所有元素的类型信息
  2. 自带容器的size信息
  3. 完整保存每个元素的位置信息

比如, 我们可以限定只能传入一个保存两个元素的列表, 其中第一个元素是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() // 编译错误, 因为只有两个元素  

相比传统列表的难点

  1. 由于Size信息和元素类型信息是绑定的, 抛弃Size信息的同时就会抛弃元素类型的限制
  2. 注意类型信息中的元素信息和实际保存的元素顺序是相反的, 因为异构列表是一个FILO(先进后出)的列表
  3. 由于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而言应该保存以下信息:

  1. 可以渲染的数据类型
  2. 子Renderer的所有类型信息
  3. 当前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), 希望之后能有机会分享更多的一些技巧和经验, 也欢迎感兴趣的朋友一同探讨


Yumenokanata
30 声望157 粉丝

Haskell中毒极深的Android开发工程师