Kotlin 中的委托:by 关键字全解析

by 关键字是 Kotlin 中非常高频的设计,尤其是在 Android 开发中。它的核心思想来自一个设计模式:委托(Delegation)。而委托模式的核心思想是:一个对象把部分职责交给另一个对象去做。它是 GoF 23 个设计模式之一,实战频率极高。而为什么要其实战频率高,这主要是因为面向对象有个经典原则:组合优于继承。

不过关于设计模式,我们必须得先说明:GoF 23 个设计模式里没有"委托模式"。委托是一种编程技巧,装饰器是一种设计模式。但它们描述的是同一件事。

  • 装饰器模式:关注的是意图 ------ 在不改变接口的前提下,给对象增加行为。强调的是"装饰"语义,层层包裹。
  • 委托:关注的是实现手段 ------ 把方法调用转发给另一个对象。强调的是"转发"机制。

GoF 装饰器模式的实现方式就是:持有被装饰对象的引用,转发调用,在转发前后加逻辑。这个实现方式就叫委托。所以 GoF 装饰器 = 委托这个技巧 + "增加行为"的意图。

委托不只服务于装饰器,其他模式也用委托:

模式 委托的角色 和装饰器的区别
装饰器 委托对象和宿主实现同一接口,增加行为 增强功能
代理委 托对象控制访问(权限、懒加载、远程) 控制访问
适配器 委托对象做接口转换 转换接口
外观 委托对象封装子系统复杂逻辑 简化调用

同样是"持有引用 + 转发调用",意图不同就是不同模式。

Kotlin 的 by 关键字提供的是委托机制(自动生成转发代码)。它本身不关心你的意图是装饰、代理还是适配 ------ 那是你通过怎么覆盖方法、选择什么委托对象来决定的。

一句话:委托是实现手段,装饰器是设计意图。Kotlin 的 by 是委托机制的语法糖,装饰器和代理是它的两种用法。

Java 中的委托

Java 中并没有对委托机制有语言层级的封装。因此,在 Java 中的委托必须持有委托对象的引用,然后逐个方法转发:

java 复制代码
public interface Logger {
    void log(String msg);
    void error(String msg);
    void debug(String msg);
}

public class ConsoleLogger implements Logger {
    public void log(String msg)   { System.out.println("[LOG] " + msg); }
    public void error(String msg) { System.out.println("[ERR] " + msg); }
    public void debug(String msg) { System.out.println("[DBG] " + msg); }
}

// 手写委托 ------ 每个方法都要转发
public class TimestampLogger implements Logger {
    private final Logger delegate;  // 委托对象

    public TimestampLogger(Logger delegate) {
        this.delegate = delegate;
    }

    @Override public void log(String msg)   { delegate.log("[" + now() + "] " + msg); }
    @Override public void error(String msg) { delegate.error("[" + now() + "] " + msg); }
    @Override public void debug(String msg) { delegate.debug("[" + now() + "] " + msg); }

    private String now() { return java.time.LocalTime.now().toString(); }
}

上面的代码中,TimestampLogger 只是将打 Log 的方案全部委托给了成员变量 delegate,需要在接口的每个方法中转发给 delegate 的方法。

这样的痛点很明显:接口有 N 个方法,委托类就要手写 N 个转发方法。全是模板代码,没有业务价值。接口一改,所有委托类跟着改。Logger 才 3 个方法,如果是一个 20 个方法的接口呢?

Kotlin 中的委托

我们见识了 Java 中委托的繁琐,那就看一看 Kotlin 中委托的简洁,也就是:by 关键字。

对于上面的 Java 代码,其转换为 Kotlin 代码就简单得多:

kotlin 复制代码
interface Logger {
    fun log(msg: String)
    fun error(msg: String)
    fun debug(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String)   = println("[LOG] $msg")
    override fun error(msg: String) = println("[ERR] $msg")
    override fun debug(msg: String) = println("[DBG] $msg")
}

// by 关键字 ------ 编译器自动生成所有方法转发
class TimestampLogger(logger: Logger) : Logger by logger {
    // 只覆盖你关心的方法,其余自动委托
    override fun log(msg: String) {
        println("[${java.time.LocalTime.now()}] $msg")
    }
}

Logger by logger 这一行告诉编译器:TimestampLogger 实现了 Logger 接口,但我不想手写那些转发逻辑,你帮我生成 ------ 把调用全部转发给 logger 这个实例。

而这种委托方式生成的字节码和 Java 手写委托完全一致。零运行时开销,纯编译期特性。

见识完 Kotlin 中的委托,我们就来对 by 这个关键字进行全方位的了解。

by 的完整用法

一、接口委托

接口委托就是把接口的实现委托给另一个对象,像上面的 TimestampLogger 例子,就是一个典型的接口委托的示例。

这里为了加深理解,我们再给出一个接口委托的示例:

kotlin 复制代码
interface Repository {
    fun save(id: Int, data: String)
    fun load(id: Int): String
    fun delete(id: Int)
}

// 被委托对象
class MemoryRepo : Repository {
    val map = mutableMapOf<Int, String>()
    override fun save(id: Int, data: String) { map[id] = data }
    override fun load(id: Int): String = map[id] ?: ""
    override fun delete(id: Int) { map.remove(id) }
}

// 只覆盖关心的方法,其余自动转发
class LoggedRepo(private val repo: Repository) : Repository by repo {
    override fun save(id: Int, data: String) {
        println("before save")
        repo.save(id, data)     
        println("after save")
    }
}

这里特别注意,LoggedRepo 的构造参数是 Repository 接口,而不是具体的 MemoryRepo 类。其原因就是委托应该面向接口,不应该绑定具体实现。用 MemoryRepo 作为参数类型意味着只能传 MemoryRepo,传不了其他 Repository 实现比如 SqlRepoRemoteRepo。装饰器模式的要点就是可以层层嵌套------LoggedRepo(SqlRepo(RemoteRepo())),其前提是参数类型是接口而不是具体类。

上面的例子只是单一接口的委托,那么如果一个类要实现多个接口呢?

二、多接口委托

对于多个接口的实现类,Kotlin 也提供了很好的委托支持,每个接口各自委托给不同的对象,互不干扰。例如下面的代码:

kotlin 复制代码
interface Logger {
    fun log(msg: String)
}

interface Cache {
    fun get(key: String): String?
    fun put(key: String, value: String)
}

interface Validator {
    fun validate(input: String): Boolean
}

// 三个接口,分别委托给三个不同对象
class Service(logger: Logger, cache: Cache, validator: Validator) :
    Logger by logger, Cache by cache, Validator by validator {

    // 可以有自己的额外方法
    fun process(input: String) {
        if (!validate(input)) return        // 调 Validator 的方法
        put("last", input)                  // 调 Cache 的方法
        log("processed: $input")             // 调 Logger 的方法
    }
}

不过使用这种多接口的委托需要注意两个事项:

  • 同一个接口只能委托给一个对象。不能写 Logger by logger1, Logger by logger2------重复了,编译器不知道方法该转发给谁。
  • 不能委托给冲突的接口。如果两个接口有签名相同的方法,委托给不同对象会冲突,如下:
kotlin 复制代码
interface A { fun doSomething() }
interface B { fun doSomething() }

// 编译错误:doSomething 的实现冲突
class X(a: A, b: B) : A by a, B by b

如果有冲突,那么就需要手动覆盖冲突的方法,自己决定转发给谁:

kotlin 复制代码
class X(a: A, b: B) : A by a, B by b {
    override fun doSomething() {
        // 手动决定
    }
}

在 Kotlin 中,接口委托的本质就是编译器帮你写转发代码。多个接口委托给多个对象,本质也是编译器帮你生成了多个接口的转发方法。每个接口独立,互不干扰。

三、属性委托

前面的两个用法是接口委托,而 by 关键字更常用的是属性委托。也就是把属性的 gettersetter 逻辑委托给一个对象。

但是这个属性委托对于委托对象有一个要求:委托对象必须提供 getValue 和(对 var)setValue 操作符函数。

下面是一个属性委托的示例:

kotlin 复制代码
// 自定义属性委托
class Validator<T>(private val initial: T, private val check: (T) -> Boolean) {
    private var value = initial

    operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
        if (!check(newValue)) 
            throw IllegalArgumentException("${property.name} 校验失败")
        value = newValue
    }
}

class User {
    var age: Int by Validator(0) { it in 0..150 }
    var name: String by Validator("") { it.isNotBlank() }
}

fun main() {
    val u = User()
    u.age = 25         // OK
    u.name = "张三"    // OK
    u.age = 200        // 抛异常
}

这里涉及到了 Kotlin 中的运算符重载。

Kotlin 运算符重载

上面的 Validator<T> 类,其中的 getValuesetValue 这两个方法的签名是 Kotlin 规定死的,这也是属性委托的契约协议,编译器会只认这个方法签名,而不认类名。

因此上面的这个 Validator<T> 类,除了方法名,其他都是可以自定义的。getValue 对应属性读 .setValue 对应属性写 =。下面我们就来看一下这两个操作符函数。

kotlin 复制代码
operator fun getValue(thisRef: Any?, property: KProperty<*>): T

其两个参数和返回值的含义为:

  • thisRef: Any?:宿主对象。属性声明在哪个类里,就是那个类的实例。顶层属性时为 null,在上面的例子中,就是当前的 User 对象
  • property: KProperty<*>:属性的元数据。包含属性名、类型、是否可变等信息
  • 返回值 T:属性读取时返回的值

当调用 user.name 时,Kotlin 就会调用这个 getValue 方法,并将这个方法的返回值交给 user.name 的调用方;

kotlin 复制代码
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T)

这个 setValue 的方法是调用 user.name = "Avril" 时会被 Kotlin 调用,其中需要被赋的新值通过第三个参数 newValue 传递过来,前两个参数与 getValue 中一样。

这两个方法的函数名、参数个数、参数顺序不能改。编译器按固定签名匹配,但是其中的泛型是可以调整的:

  • T 可以是任意类型,和属性类型一致就行
  • thisRef 可以缩窄为具体类型,比如针对上面例子中的用法,就可以改为 operator fun getValue(thisRef: User, property: KProperty<*>): T,这样这个委托只能用在 User 类中
  • property 的泛型可以缩窄,但一般用 KProperty<*> 就够了

只要按照上面的标准写法来定义这两个方法,就可以实现自定义委托。但是,Kotlin 标准库中也提供了接口让自定义委托更规范:

kotlin 复制代码
public interface ReadOnlyProperty<in R, T> {
    public operator fun getValue(thisRef: R, property: KProperty<*>): T
}

public interface ReadWriteProperty<in R, T> : ReadOnlyProperty<R, T> {
    public operator fun setValue(thisRef: R, property: KProperty<*>, value: T)
}

使用这两个接口,我们可以将上面例子中的自定义委托改变为如下的形式:

kotlin 复制代码
class Validator<T>(private val initial: T, private val check: (T) -> Boolean)
    : ReadWriteProperty<Any?, T> {

    private var value = initial

    override fun getValue(thisRef: Any?, property: KProperty<*>): T = value

    override fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
        if (!check(newValue)) throw IllegalArgumentException("${property.name} 校验失败")
        value = newValue
    }
}

不过不用这两个接口也没问题:委托协议只认签名,不认接口。但实现接口可以明确意图,也能让代码更规范。

自定义委托中的存值问题

关于属性委托,我们还有一个问题需要探讨,那就是真实的数据是存放在哪里的?是在 User.name 中,还是在 Validator<String> 中的。针对下面的代码,在经过编译后,agename 是否还存在呢?

kotlin 复制代码
class User {
    var age: Int by Validator(0) { it in 0..150 }
    var name: String by Validator("") { it.isNotBlank() }
}

答案是不存在的。agename 作为成员变量,在经过委托后,会被编译为如下形式:

kotlin 复制代码
public final class User {
    private final Validator<Integer> age$delegate = new Validator<>(0, ...);
    private final Validator<String>  name$delegate = new Validator<>("", ...);
}

而我们真正存放值的地方,是 Validator 中的成员变量 value(这个 value 变量需要我们自己添加)。

而外面定义的 User.ageUser.name,它们是属性的元数据,不是字段。是编译期的属性声明,不是运行时的字段。它们的意义是告诉编译器生成 getter、setter 并关联到对应的委托对象。KProperty<*> 里的 property.name 返回的就是这两个属性的元数据中记录的名字。而也正是 property 这个参数,让委托对象知道当前是哪个属性在读写。

kotlin 复制代码
class User {
    var name: String by NameDelegate()
}
// 会生成如下的代码:
class User {
    private val nameDelegate = NameDelegate()
    var name: String
        get() = nameDelegate.getValue(this, ::name)
        set(value) = nameDelegate.setValue(this, ::name, value)
}

不过准确地说,User.name 作为属性仍然存在 ------ 它有 gettersetter 方法,只是没有后端字段(field)。值存在委托对象内部。不存在的是 name 的后端字段,不是 name 这个属性本身。

四、标准库内置委托

除了上面说到的接口委托和属性委托,Kotlin 在其标准库中也添加了很多内置的委托供我们使用,下面我们来一一说明。

lazy ------ 懒加载

kotlin 复制代码
class AppConfig {
    // 首次访问时初始化,之后缓存
    val database by lazy { connectDatabase() }
}

lazy 默认线程安全(LazyThreadSafetyMode.SYNCHRONIZED),可选:

  • SYNCHRONIZED:双重检查锁,默认
  • PUBLICATION:允许多次初始化但只取第一个结果
  • NONE:无线程安全,单线程用

例如下面就是配置了无线程安全参数的懒加载:

kotlin 复制代码
val cache by lazy(LazyThreadSafetyMode.NONE) { mutableMapOf<String, String>() }

Delegates.observable ------ 变更监听

这就是一个简单的观察者模式,在属性值发生变更时触发回调:

kotlin 复制代码
class User {
    var score by Delegates.observable(initialValue = 0, onChange = object : Function3<KProperty<*>, Int, Int, Unit> {
        override fun invoke(property: KProperty<*>, oldValue: Int, newValue: Int) {
            Log.d("avril", "property = $property, oldValue = $oldValue, newValue = $newValue")
        }
    })
}

为了直观,上面给出的是没有经过优化的版本,可以很方便的看到其参数是初始值和回调方法。

经过优化后能看到其代码会变得非常简单:

kotlin 复制代码
var score by Delegates.observable(0) { prop, old, new ->
    println("${prop.name}: $old -> $new")
}

score = 10  // 输出: score: 0 -> 10
score = 20  // 输出: score: 10 -> 20

Delegates.vetoable ------ 变更拦截

进行赋值时,会调用检测方法,如果返回 true 才允许赋值。

kotlin 复制代码
class User {
    var level by Delegates.vetoable(initialValue = 1, onChange = object : Function3<KProperty<*>, Int, Int, Boolean> {
        override fun invoke(property: KProperty<*>, oldValue: Int, newValue: Int): Boolean {
            Log.d("avril", "property = $property, oldValue = $oldValue, newValue = $newValue")
            return newValue > oldValue
        }
    })
}

经过优化后的代码如下:

kotlin 复制代码
var level by Delegates.vetoable(1) { prop, old, new ->
    new > old  // 返回 false 则拒绝赋值
}

level = 5   // OK,level = 5
level = 2   // 被拒绝,level 仍为 5

Delegates.notNull ------ 延迟赋值非空

当使用此委托时,需要手动赋值,如果被委托数据为 null 时进行访问会抛出异常:

kotlin 复制代码
var id: String by Delegates.notNull()
// 此时访问 id 会抛异常
id = "abc"  // 赋值后可正常访问

这个委托其就是很简单的自定义委托,只是在 getValue 方法中判断了一下并做了异常抛出:

kotlin 复制代码
private class NotNullVar<T : Any>() : ReadWriteProperty<Any?, T> {
    private var value: T? = null

    public override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return value ?: throw IllegalStateException("Property ${property.name} should be initialized before get.")
    }

    public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        this.value = value
    }

    public override fun toString(): String =
        "NotNullProperty(${if (value != null) "value=$value" else "value not initialized yet"})"
}

map 委托 ------ 从 Map 读写属性

使用此委托的属性,其值是存放在 map 中的:

kotlin 复制代码
class User(map: Map<String, Any?>) {
    val name: String    by map
    val age: Int        by map
}

val u = User(mapOf("name" to "张三", "age" to 25))
println(u.name)  // 张三

不过使用这种 map 委托的话,需要注意 map 的类型和被委托的属性的可变性。因为 Map 委托只提供 getValue 只读属性,因此只能委托给 val 属性,针对 var 属性,需要使用 MutableMap 这种同时提供 getValuesetValue 方法的接口。

例如,下面就是针对 var 的属性的委托:

kotlin 复制代码
val map : MutableMap<String, Any> = mutableMapOf()
var arg1: String by map

另外需要注意的是,由于 map 委托是通过字段名在 map 中查找对应的值的,因此 Map 中的键的类型必须是 String

五、provideDelegate ------ 委托工厂

前面自定义属性委托中,by 表达式右侧的对象就是委托对象。但 Kotlin 还提供了一层中间机制:provideDelegate。它的作用是在属性初始化时做一次拦截,返回真正的委托对象。

这里我们用一个简单的委托对象和一个简单的委托工厂类来演示:

kotlin 复制代码
// 委托对象
class Validator<T> (initial: T) {
    private var value = initial
    operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value
    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
        value = newValue
    }
}

// 委托工厂,创建委托对象
class DelegateProvider<T>(private val value: T) {
    // 属性初始化时调用一次,早于第一次 getValue
    operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) : Validator<T> {
        return Validator<T>(value)        // 返回真正的委托对象
    }
}

class User {
    // 将 param1 委托给工厂
    var param1: Int by DelegateProvider(10)
}

provideDelegate 的调用时机:属性声明所在的类被实例化时,为每个属性调用一次。早于第一次 getValue。因此 provideDelegate 的就可以用来做这些事情:权限校验、属性元数据检查、根据上下文返回不同委托对象。我们上面说的,标准库中的 Delegates.observablevetoablenotNull 底层都通过 provideDelegate 实现。

但是你不写 provideDelegate 也完全没问题,by 表达式右侧的对象直接作为委托对象。写了就多一层拦截,仅此而已。

六、委托给另一个属性

见识了上面的委托之后,这个就好理解了。就是将一个属性委托给另一个属性:

kotlin 复制代码
// 把 topName 委托给 realName
class Person {
    val realName = "张三"
    val topName by this::realName  // 引用另一个属性
}

fun main() {
    val p = Person()
    println(p.topName)  // 张三
}

这种用法的情况非常少,仅做一下了解即可。

七、委托可用于局部变量和顶层属性

上面的例子都是使用类成员进行委托,但委托对象不限于类成员,函数内的局部变量也能用:

kotlin 复制代码
fun main() {
    var count by Delegates.observable(0) { _, old, new ->
        println("$old -> $new")
    }
    count = 1   // 输出: 0 -> 1
    count = 2   // 输出: 1 -> 2
}

局部变量委托在编译后,委托对象存在函数的栈帧中,生命周期随函数调用结束而结束。

不仅是局部变量,属性声明在文件顶层(不在任何类内),也能使用委托:

kotlin 复制代码
// 顶层属性
val globalConfig: String by lazy { loadConfigFromFile() }

fun main() {
    println(globalConfig)  // 首次访问时初始化
}

此时 getValuethisRef 参数为 null,因为属性不属于任何对象。

八、单例对象委托

前面的例子中,每个属性都拥有自己的委托对象,我们可以用 object 声明的单例对象作为委托对象,这样就可以让多个属性共享同一个委托实例:

kotlin 复制代码
object LogDelegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "${property.name} accessed at ${System.currentTimeMillis()}"
    }
}

class Server {
    val status: String by LogDelegate
    val health: String by LogDelegate
}

但是使用这种委托要格外小心:单例对象全局唯一,如果委托对象内部存值(如 Validator 那样),多个属性会共享同一个存储字段导致冲突。因此,单例委托适合无状态的委托(只做日志、计算等,不存值)。

九、限制:扩展属性不能用 by

Kotlin 的扩展属性没有后端字段,而属性委托需要存储空间(存委托对象)。因此扩展属性不能使用 by:

kotlin 复制代码
// ❌ 编译错误:扩展属性不能用委托
val String.isLongEnough: Boolean by lazy { this.length > 10 }

如果需要对扩展属性做类似逻辑,只能用普通 getter:

kotlin 复制代码
val String.isLongEnough: Boolean
    get() = this.length > 10

其实,扩展属性最好不要将其理解为属性,你可以将其理解为扩展方法比较好。

普通属性有后端字段,因为属性声明在类内部,类实例上空间有这个字段。扩展属性声明在类外部,它不属于任何类的实例。你没法往 String 类里加字段------String 是 JDK 的类,你改不了它的内存布局。所以扩展属性只能定义 getter/setter 逻辑,不能存值。

实际开发中,var 扩展属性几乎没用(setter 存不了值没意义)。val 扩展属性有点用,就是把常用计算封装成属性访问语法。每一次访问这个属性时,就会重新进行一次计算。

简单总结就是,扩展属性不是真正的属性------没有后端字段,不能存值,本质上是披着属性外衣的扩展函数。val 扩展属性等价于一个无参扩展函数,var 扩展属性等价于两个扩展函数(getter + setter),但 setter 因为存不了值所以基本没用。

另外扩展属性这个特性不是特别好,很容易带来兼容性问题,最好不要用这个特性。

总结

在 Java 中,委托意味着手写大量转发代码------接口一改,所有委托类跟着改。Kotlin 用 by 一个关键字解决了这个问题,编译期展开,零运行时开销。用一句话概括它的价值那就是:把编译器该干的活还给编译器。这就是 Kotlin 对委托的语言级支持。

在上面的内容中,我们对 by 进行了全方位的解析。其中最核心的要点为:属性委托后,宿主类没有后端字段,值存在委托对象内部。

这是 by 的设计决定 ------ 读写行为和值存储绑在一起交给委托对象,使用统一的 getValuesetValue 运算符重载协议。这个协议是 Kotlin 委托(无论 lazymapnotNull 还是自定义)都需要遵守的一致性设计。

相关推荐
格林威2 小时前
多相机微秒级对齐:硬件触发 vs PTP(IEEE 1588)方案实战对比
开发语言·人工智能·数码相机·机器学习·计算机视觉·视觉检测·机器视觉
初级代码游戏3 小时前
iOS开发 Swift 速记2:三种集合类型 Array Set Dictionary
开发语言·ios·swift
寰宇星城4 小时前
AppActuator - 让 AI Agent 直接理解并操作运行中的 Android App
android
波罗丁牌5 小时前
uni-app 实现视频聊天、屏幕分享,支持Android、HarmonyOS、iOS
android·uni-app·音视频
峥嵘life5 小时前
Android WiFi 热点 Channel 信道 和 Frequency 频率 转换总结
android·开发语言
wgego5 小时前
基础的反序列化一些总结(php和java)
java·开发语言·笔记
jieyucx5 小时前
【高级利用】条件竞争与逻辑漏洞:与服务器赛跑的艺术
android·运维·服务器·web安全·文件上传
冻柠檬飞冰走茶5 小时前
PTA基础编程题目集 7-8超速判断(C++语言实现)
开发语言·数据结构·c++·算法
山峰哥6 小时前
数据库工程与SQL调优:从慢查询到秒级响应的实战之路
java·开发语言·数据库·sql·深度优先·启发式算法
wangjialelele6 小时前
Selenium4 + Java Web自动化测试入门指南:从环境搭建到常用操作详解
java·开发语言·前端·测试工具·自动化