一年回响
在 2016 年的 4 月 15 日,我在这个博客里写下了提纲,举了我应该尽快从 ofo 辞职的理由。原文是:
- 缺乏对细节的关注和对格调的追求。
- 技术负责人水平不够,并且太过实用主义。
- 不透明。
- Hiring / firing 太过随意。
然而直到 2017 年的 4 月,整整一年后,我才真正辞职。尽管这一年里,ofo 的估值和规模都极大地上涨,但是,这些问题几乎没有好转,有的甚至愈演愈烈。
每个公司,有些骨子里东西并不是能够轻易改变的。至少,很难变好。
在 2016 年的 4 月 15 日,我在这个博客里写下了提纲,举了我应该尽快从 ofo 辞职的理由。原文是:
- 缺乏对细节的关注和对格调的追求。
- 技术负责人水平不够,并且太过实用主义。
- 不透明。
- Hiring / firing 太过随意。
然而直到 2017 年的 4 月,整整一年后,我才真正辞职。尽管这一年里,ofo 的估值和规模都极大地上涨,但是,这些问题几乎没有好转,有的甚至愈演愈烈。
每个公司,有些骨子里东西并不是能够轻易改变的。至少,很难变好。
这篇文章依旧没有什么主题、没有什么深度,纯碎随笔,谈的也是 Kotlin 最最基础的问题。
我常常说 Kotlin 比 Swift 舒服的一点是,Kotlin 在 null-safety 问题上,会有所谓「smart cast」。例如你有
var a: Foo? = getFoo()
if (a != null) {
print(a.foo) // 不再需要 !!
}
而在 Swift 里,你仍然需要手动再次「!」
var a: Foo? = getFoo()
if (a != null) {
print(a!.foo) // 仍然需要 !
}
当然所有人都会很快注意到的一点是,Kotlin 对于局部变量可以做这样的处理,但如果是个类的成员变量(var
),就不行:
class Bar {
var a: Foo? = getFoo()
fun bar() {
if (a != null) {
print(a.foo) // 编译无法通过
}
}
}
为什么 Kotlin 此时不予编译通过呢?因为在当前线程判断 a != null
之后,其它线程可能又修改了「a」的值。事实上,如果是用 val
声明的 field,此时就可以 smart cast。
而如果是 var
声明的呢?如果你加上 !!
,变成 a!!.foo
,就可以编译通过,但这么做的话,你就会被抓住游街。所以,正确的解决方式是,定义一个局部变量,这样就确保没有其它线程能动到它。
fun bar() {
val localA = a
if (localA != null) {
print(localA.foo) // cool
}
}
这样写的唯一问题,就是丑。定义了一个可能只用到一次的局部变量,而它并不被整个方法所需要,而仅仅只是临时用到而已。大约 Swift 程序员们也意识到了这一点,所以 Swift 里习惯的 practice 是:(摘自官方教程)
if let roomCount = john.residence?.numberOfRooms {
print("John's residence has \(roomCount) room(s).")
} else {
print("Unable to retrieve the number of rooms.")
}
也就是,为了写得更简明一些,Swift 从语法层面增加了 if let
语句解决问题。
Kotlin 里,当然也可以利用 let
来写得漂亮些。当然,Kotlin 的 DSL 能力那么好,何必再从语法层面解决问题?
print(john.residence?.numberOfRooms?.let { "John's residence has $it room(s)." } ?: "Unable to retrieve the number of rooms.")
一气呵成,不需要任何额外的语法。事实上,我实际写代码时,也经常会 foo?.let { … }
这么用。
上面语句的核心就是 let
。和 Swift 不同,Kotlin 里的 let
不过只是一个普通的函数而已。事实上,很多 Kotlin 教程都不会提及标准库里这几个很赞的函数。它们实现得非常简单,但却可以作为良好 Kotlin 代码的典范:
public inline fun <T, R> T.run(block: T.() -> R): R = block()
public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()
public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }
public inline fun <T> T.also(block: (T) -> Unit): T { block(this); return this }
public inline fun <T, R> T.let(block: (T) -> R): R = block(this)
public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? = if (predicate(this)) this else null
熟悉它们,能够帮你大大地缩短代码。
例如某个方法,在计算出返回值后,还需要做一些清理,之前你可能:
fun foo(): Int {
val resource = getResource()
val retVal = resource.calculate()
resource.cleanUp()
return retVal
}
而现在,仅靠标准库里的这几个方法,你就可以:
fun foo() = getResource().run { calculate().also { cleanUp() } }
标准库里的这几个方法仅仅是最最常用和最简单的对控制语句的补充。其实还有很多这样的扩展方法可以用,例如 funKTionale 里面的函数,都很棒呢。
我注意到在我前些天的一篇回答的评论里,Swift 和 Scala 都被拿来和 Kotlin 做比较。
我于是忽然想写一点没什么技术水平、也没什么内容结构的随笔,谈谈一些关于 data class 的一些有意思的地方。
我写 Swift 时也注意到,Swift 里的 enum 中每一项是可以有参数的,亦即「associated values」。例如:
enum Gender {
case male
case female
case other(description: String)
}
于是写 Kotlin 的时候,我就在想,为什么 Kotlin 的 enum 不能带参数呢…… 后来我才意识到,其实「带 associated values 的 enum」实际上可以用 sealed class 的语法来等同:
sealed class Gender {
object Male: Gender()
object Female: Gender()
data class Other(val description: String): Gender()
}
Sealed class 和 data class 这两个东西,用在一起真是蛮合适呢。其实这个例子里,不加 sealed
和 data
两个修饰符,也能编译通过。但是,加上这两个修饰符,似乎更能表达类似于 enum 的语义。为什么呢?
在 Kotlin 里,sealed class 也是为了协助 when
语句的 exhaustiveness check 的;也就是说,对于一个 sealed class,如果 when
已经处理了它的所有已知子类,那就不再需要 else 分支。事实上,Sealed class 这个概念在 Scala 里也有完全相同的存在——相似地,它的主要用途是协助 pattern matching 的 exhaustiveness check。
而 Scala 里也有着和 data class 相似的存在,那就是 case class。它们同样用于表示「仅仅用于组织数据的类」,也就是类似于「带了 tags 的 tuple」;并且,它们同样提供了符合直觉的 equals 方法的实现。
然而,在 Scala 里,正如「case class」这个名字所暗示,其一个很大的用途是在 pattern matching 中。在 Scala 中,假设有
case class Bar(noun: String, verb: String)
那么就可以对变量 foo: Bar 做
foo match {
case Bar(_, "药丸") => "后面有药丸"
case Bar("青果", predicate) => "青果" + predicate + "了"
case _ => "啥都没有"
}
所以:Kotlin 不提供 pattern matching 简直就是反人类!
Improved Pattern Matching in Kotlin 提供了一些增强 when
语句的奇技淫巧。当然,这样的奇技淫巧的代价,就是丧失 exhaustiveness check 的功能——因为 when
语句的 exhaustiveness check 非常弱,仅仅能识别「is XXX」这样的条件。
利用类似的奇迹淫巧,再加上更肮脏的反射,我们就能对 Kotlin 的 data class 做类似的事情——至少实现上面那段 Scala 中那样,对 foo 中的属性的值做 matching。这里利用的是,Kotlin 对于 data class 会生成 componentN() 方法 。
「奇技淫巧」所需要的一点 bolterplating:
object any // 因为没法直接用 _
class MatchDataClass<T: Any>(private val kClass: KClass<T>, private val params: Array<out Any>) {
init {
if (!kClass.isData) { throw IllegalArgumentException("Not a data class!") }
}
operator fun contains(input: Any): Boolean { // 实现「in」操作符
return if (!kClass.isInstance(input)) false else (
params.mapIndexed { index, criteria ->
(criteria is any) || (kClass.java.getMethod("component" + (index + 1)).invoke(input) == criteria)
}.all { it }
)
}
}
inline fun <reified T: Any> match(vararg params: Any) = MatchDataClass(T::class, params)
于是,我们现在就可以写出跟上面的 Scala 代码有点像的 Kotlin 代码来:
when (foo) {
in match<Bar>(any, "药丸") -> "后面有药丸"
in match<Bar>("青果", any) -> { val (_, predicate) = foo; "青果" + predicate + "了" }
else -> "啥都没有"
}
注意到,第二个 case 的代码比较恶心。这是因为 Scala 能够在 matching 的同时创建变量并赋值,而 Kotlin 再怎么 hack 也无法做到。幸而 Kotlin 还算支持 destructing。
当然,这样的写法有很多问题:
所以,似乎我们还是应该期待 Kotlin 提供完整的 pattern matching 支持,才能发挥 sealed class 和 data class 的最大功力呢。
刚刚看 I/O 直播时听到 Kotlin gets officially supported,真的蛮意外的。
Kotlin 的 killer-app 大约就是用来写 Android app 的 Anko。从我初次用 Anko 到现在,已经过去快一年了。遗憾的是在之前的公司里,由于各种各样的原因,一直没法在线上产品中使用 Kotlin。
直到我上个月换了工作后——刚刚结束的四月里,使用 Anko,在不到一个月里就写完了两个漂亮的 Android App 并上线,生产力和舒适度实在前所未有。
当然,由于我很长时间里的主要在写 JavaScript,于是确实习惯了使用 Promise。我于是就简短地用 Kotlin 写了一个 Promise 实现—— https://github.com/kmxz/Votive/。API 尽可能地接近 JavaScript 中的 Promise;只是所有 Promise 的名称都改成了 Votive。
譬如,将 Android 的运行时权限申请封装为 Promise,只需要这么一点 boilerplating:
const val REQUEST_CODE_MIN = 6910
const val REQUEST_CODE_MAX = 7910
open class BaseActivity: AppCompatActivity() {
private var lastRequestCode = REQUEST_CODE_MIN
private var permissionCallback = mutableMapOf<Int, Pair<(Unit) -> Unit, ((Unit) -> Unit)>>()
fun withPermission(permission: String) = Votive<Unit, Unit> { resolve, reject ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
val requestCode = lastRequestCode++
if (lastRequestCode > REQUEST_CODE_MAX) { lastRequestCode = REQUEST_CODE_MIN }
permissionCallbacks.put(requestCode, Pair(resolve, reject))
ActivityCompat.requestPermissions(this, arrayOf(permission), requestCode)
} else {
resolve(Unit)
}
} else {
resolve(Unit)
}
}
final override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode in REQUEST_CODE_MIN..REQUEST_CODE_MAX) {
val callback = permissionCallbacks.remove(requestCode)
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
callback?.first?.invoke(Unit)
} else { // already removed anyway
callback?.second?.invoke(Unit)
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
}
就可以使用 Promise 风格去申请运行时权限:
withPermission(Manifest.permission.ACCESS_FINE_LOCATION)
.thenSimple { Log.i(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)) }
.catchSimple { alert("Don't give me permission? I'll crash") }
嗯,以及我现在越来越觉得我一个月前辞职实在是绝赞的决定。
今天写的一篇知乎专栏 知乎的话题屏蔽功能真是堪忧 里提到了一个「话题」被屏蔽时,其子孙(descendant)话题应该如何处理的问题。
事实上,知乎上存在着「关注一个话题能够看到其子孙话题的内容,屏蔽一个话题,却没有默认屏蔽其子孙话题」这样的不一致。也存在添加话题时的不一致:比如提问「茶党」相关的问题时,有人会选择在加上「茶党」话题的同时,也添加「美国政治」、「共和党」、「保守主义」等话题;也有人考虑到既然「茶党」是后面那些话题的子孙话题,于是关注了那些话题的用户自然会看到「茶党」话题下的问题,也就没有必要再另行添加那些话题。
类似的问题也一直困扰着我。
每个话题/标签/分类,称之为一个 tag(分类)。Tag 之间可以互为父子关系,每个 tag 可以有多个父 tag,也可以有多个子 tag。Tag 间通常构成有向无环图。
每个问题/文章/A 片,称之为一个 entry(条目)。每个 entry 可以属于多个 tag。
这是一个很常见的问题,除了知乎的问题-话题外,WordPress 的文章-分类就符合这个问题,Wikipedia 的词条-分类也是。当然还有我的 A 片管理器中的视频-标签。
最简单是实现方式就是用四张数据表,一张存储 tag,一张存储 entry,一张存储 tag 和 entry 的对应关系,一张存储 tag 间的从属关系。
在我写 A 片管理器的时候,也遇到这个问题。最早,我也是在每次查询某个 tag 的 entry 时,递归查询其 descendant tag 的 entry。然而这样使得这个查询的速度慢了很多。
当然,这可以通过合适的索引和缓存来缓解。但由于 A 片管理器并没有一个持续运行的进程,而在硬盘上占用的空间也希望尽可能小,避免冗余,再加上我想让 A 片管理器本身小、简洁而可靠,所以我并不希望折腾这些。
另一方面,如果我要通过 tag 间的重合度来提示「相似的 entry」的话,事情就更加复杂了——比如 entry 1 属于 tag A,entry 2 属于 tag B,而 tag A 和 tag B 都同属 tag C 的 descendant 的情况下,实际上 entry 1 和 entry 2 是有一些相似性的,但实际上计算起来就会比较复杂。
于是我改变了我的策略,采取了一个很独特的做法:把某个 tag 添加到一个 entry 时,自动向上遍历该 tag 的所有 ancestor,并且把他们都加到那个 entry 的 tag 列表里。
这样的话,就避免了对于同一个 entry,是否应该在添加 tag 的同时也添加其父 tag 的可能的不一致性。
代码写起来也轻松很多:查询一个 tag 下所有 entry 时,也快了不少——根本不用递归遍历其子 tag。更棒的是,查询两部 A 片的相似度时,直接计算他们的 tag 列表间的余弦相似度就行。
当添加 tag 间的从属关系的时候,我会启动一个后台线程自动补全每个 entry 的 tag 列表。譬如我把 tag D 设为 tag E 的子 tag 的时候,我会自动把所有属于 tag D 的 entry 也都自动加到 tag E 里面。
当然这样的解决方法是非常有局限性的。在数据量很大、用户可以随意更改 tag 从属关系的情况下,这个「后台线程」性能上很难支撑;同时进行多个 tag 从属关系的修改时如何保持一致性,也是极难解决的。不过对于我的 A 片管理器来说,不用考虑这些事情。
如果我此时又把 tag D 从 tag E 的子 tag 里面中移除,我是否该再遍历一遍刚才那些 entry,把 tag E 一一移除呢?或许不该,因为有些 entry 可能一开始就已经属于 tag E,而并非是刚才由后台线程添加进去的。
同样,如果我把 tag D 某个 entry 的 tag 列表中移除,是否也该移除 tag E 呢?
解决这个问题的办法是,追踪 tag E 到底是人为加上的,还是由于 tag 的从属关系而自动加上的。如果是前者,就不移除;如果是后者,则移除。
这有点像是软件包管理器管理依赖的方式——如果某个包是用户手动安装的,就不会被移除。而如果某个包是在安装其它包时由于依赖关系而自动安装的,就可以通过 apt-get autoremove
一类的方式来移除。当然这个标记也可以使用 apt-mark
一类的命令来更改("mark/unmark a package as being automatically-installed")。
这样的话,就完美解决了我 A 片管理器这个 case 里的所有问题。