Kotlin开发笔记:使用委托进行拓展
导言
在OO语言(面向对象)中,我们经常会用到委托或者代理的思想。委托和代理在乍一看很相似,其实其各有各的侧重点,这里我引用ChatGpt的回答:
委托(Delegation)和代理(Proxy)虽然有相似之处,但在面向对象编程中有一些区别。
- 职责分配:
委托:委托是一种将对象的一部分职责转交给另一个对象来处理的方式。原始对象将某些任务委托给另一个对象,但是仍然保持对委托对象的控制。
代理:代理是一种通过提供一个代替对象来控制访问。代理对象通常具有与被代理对象相同的接口,客户端代码可以通过代理对象来间接访问被代理对象。
目的:- 委托:委托用于实现代码的模块化和职责分离,将一部分功能委托给其他对象处理,以达到更好的代码组织和可维护性。
- 代理:代理用于控制访问,可以用于实现懒加载、安全性控制、远程访问等。代理对象可以在客户端和被代理对象之间添加额外的逻辑,如缓存、权限验证等。
关系类型:
- 委托:委托通常涉及到两个具体的对象,一个是原始对象,另一个是被委托的对象,它们可以属于不同的类。
- 代理:代理通常有三个主要组成部分:客户端、代理对象和被代理对象。代理对象扮演中间人的角色,控制客户端访问被代理对象。
虽然委托和代理在某些情况下可能会有重叠,但它们的重点和使用方式是不同的。
委托更关注职责分离和模块化,而代理更关注控制访问和添加额外的逻辑。在实际编程中,选择使用委托还是代理取决于具体的需求和设计目标。
通过前面的介绍我们应该对代理和委托的概念和区别有了一定的认识。不过本篇文章并不是来探讨委托和代理之间的关系,而是来简单介绍Kotlin中的委托语法相关知识的。
使用Kotlin的by来进行委托
在Java中并没有专门的语法来帮助我们实现委托,而Kotlin中则十分贴心地提供了by关键字,通过这个关键字我们可以要求编译器生成粗略的代码来帮助我们实现委托。接下来给出一个最简单的例子来介绍by关键字的用法。
首先我们先定义一个Worker接口来定义打工人的职责:
interface Worker {
fun work()
fun takeVacation()
}
该接口有work和takeVacation(不存在的)两个方法。接下来定义两个类来实现这个接口:
class JavaProgramer : Worker{
override fun work() {
println("... write JavaCode ...")
}
override fun takeVacation() {
println("JavaProgramer relax")
}
}
class CSharpProgramer : Worker{
override fun work() {
println("...writer CSharpCode ...")
}
override fun takeVacation() {
println("CSharpProgramer relax")
}
}
最后,我们还想要定义一个Manager类来管理所有的Worker,这种情况下我们就可以使用到Kotlin中的by关键字进行委托:
class Manager():Worker by JavaProgramer()//委托语法
是的,只需要这一行Manager就实现了委托,这种情况下我们调用Manager来调用方法最终就会路由到JavaProgramer的一个默认生成的实例中运行:
fun main() {
val del = Manager()
del.work()
}
最后的结果就是:
其实这样说可能不太清楚,我们来仔细分析一下class Manager():Worker by JavaProgramer()这行代码,首先类名后面用冒号跟上Worker接口的意思正是Manager类需要实现Worker接口,后面的JavaProgramer()代码就会自动生成一个JavaProgramer的实例,最后通过by连接,意思就是Manager类将会委托后面生成的这个JavaProgramer实例来实现Worker接口。
上面例子的局限
上面的这个例子的局限也十分明显,那就是由于委托的JavaProgramer实例是隐性生成的,所以我们就丢失了对委托的引用,这种情况下我们在这个Manager类就无法再次委托隐式生成的JavaProgramer来进行一些操作了。
委托给一个参数
上面我们提出了上面例子的局限性,不过只要理解了我们在上面分析的那一行代码的语法,我们可以很简单地避免上面的局限性。很显然要解决这个问题需要我们保留对被委托方的引用:
class Manager2(val mWorker: Worker):Worker by mWorker{
fun fun1(){
mWorker.work()
}
}
在这段代码中我们保留了对被委托方的引用,通过幕后生成的mWorker字段存储了被委托方,后面的by mWorker一句表明这个Manager2类将会委托mWorker字段来实现Worker接口。
不要用var来修饰持有委托的字段
上面的代码中我们用val变量持有了传入的委托实例,当然编译器也是允许我们使用var变量来持有委托实例的,不过这样做存在风险。具体来说class Manager2(val mWorker: Worker):Worker by mWorker 实际上存储了两个对mWorker的引用,一个就是通过val生成的幕后字段,还有一个就是通过by生成的包装类中持有的委托引用。当我们用val修饰时不会有什么问题,但是如果当我们用var修饰就会存在隐患。
比如我们这样写:
class Manager2(var mWorker: Worker):Worker by mWorker{
fun change(){
if(mWorker is JavaProgramer){
mWorker = CSharpProgramer()
}else{
mWorker = JavaProgramer()
}
}
fun showWorker(){
println(mWorker.javaClass.simpleName)
}
}
里面定义的change方法将会更改成员变量中的mWorker但是无法修改by语句的委托实例,我们运行一下这段代码查看结果:
fun main() {
val del = Manager2(JavaProgramer())
del.work()
del.takeVacation()
del.showWorker()
del.change()
del.work()
del.takeVacation()
del.showWorker()
}
最后结果为:
这里虽然成员变量中持有的Worker发生了变化,但是委托的Worker依旧是一开始创建的Worker,它无法被修改,这样就会造成语义的不清晰,所以说我们尽量不要用var变量来持有被委托的实例。
取消部分委托
当我们的委托方类没有和接口中的方法名一致的方法时,将不会有什么大问题,但是如果委托方中有一个方法实现了接口中的一些方法时就会和委托产生冲突。换句话说,如果我们只想要委托给一个实例实现接口中的部分方法时就需要处理掉一些冲突。比如我们在之前的例子中进行修改,如果Manager的代码如下就会产生冲突:
class Manager():Worker by JavaProgramer(){
fun takeVacation(){
println("Manager Relax...")
}
}//委托语法
这种情况下被委托方中的takeVacation方法就和Manager类中的takeVacation方法有了冲突,编译器无法决定是该调用被委托方的方法还是Manager中的方法。这时就需要在我们不需要进行代理的方法前加上override修饰符,如下:
class Manager():Worker by JavaProgramer(){
override fun takeVacation(){ //解决方法冲突--取消委托
println("Manager Relax...")
}
}//委托语法
这种语法我们可以理解为单个方法取消委托,也可以理解为Manager类在实现接口的方法。总而言之,我们这样表达后冲突就不复存在了,当我们调用Manager类的takeVacation方法时就会调用Manager自身的方法而不是进行委托。
实现多个委托
上面的情况我们介绍的都是一个类委托给一个类实现,实际上一个类也可以委托给多个类实现多个接口,不过这种情况下可能会产生一些冲突需要我们手动处理。接下来我们修改Worker接口并且新增一个Assistant接口:
interface Worker {
fun work()
fun takeVacation()
fun FishingTime()
}
interface Assistant{
fun doChores()
fun FishingTime()
}
这样我们这两个接口就会有一个重叠的方法,现在我们创建一个类来实现多个委托:
class Manager3(val mWorker: Worker,val mAssistant: Assistant):Worker by mWorker,
Assistant by mAssistant{
}
这样写将会产生报错,因为这两个接口有一个重叠的方法,用两个委托的话编译器将无法确定Manager3需要委托哪一个类来实现FishingTime方法,这里解决冲突的方法就是使用取消委托的方式,用override进行修饰决定到底需要哪一个委托:
class Manager3(val mWorker: Worker,val mAssistant: Assistant):Worker by mWorker,
Assistant by mAssistant{
override fun FishingTime() {
mWorker.FishingTime()
mAssistant.FishingTime()
}
}
这样就解决了冲突,同时实现了一个委托类委托给多个类实现的效果。
内置的标准委托
Lazy委托
在这里Lazy委托也可以被理解为懒加载,即只有在真正需要时才对一个函数进行调用,以达到节省开销延时加载计算的效果。比如说在布尔逻辑表达式中现在的大部分语言都有短路求值的特性,如果在表达式之前对表达式的求值足以产生结果,则跳过表达式的执行。比如:
fun workwork():String{
println("execute")
return "work work"
}
fun main() {
val msg = workwork()
var shouldWork = false
if(shouldWork && msg != null){
println("work day")
}else{
println("relax")
}
}
在这种情况下显然就违反了短路原则,产生了额外的开销,运行结果是:
虽然msg未被使用,但是还是因为msg的赋值语句产生了额外的开销,这显然不是我们想要的。我们当然可以将赋值移到&&运算符之后,不过kotlin针对这种情况提供了lazy委托,让我们对上面的例子进行修改:
fun main() {
val msg by lazy { workwork() }
var shouldWork = false
if(shouldWork && msg != null){
println("work day")
}else{
println("relax")
}
}
这里我们用lazy委托包装了workwork函数,现在再来看运行结果:
额外的开销消失了,很神奇。这就是Kotlin中的lazy委托。Lazy委托后面接收一个lambda表达式,这样在我们需要用到委托方(在这里即为msg变量)时才会执行,否则将不会被执行,也就是说它是按需执行的。一旦对lambda中表达式求值,委托将记住结果,以后对该值的请求将接受保存的值而不是重新计算lambda表达式。
在默认情况下,lazy函数同步lambda表达式的运行,因此最多只有一个线程运行它。另外,Kotlin中的lazy委托只能用于val(不可变)变量,而不能用于var(可变)变量。这是因为lazy委托的特性与惰性求值相关,适用于只需要初始化一次并且后续不会再变化的情况。
Observable委托
接下来介绍的是Observable委托,看名字就知道这个委托和观察者模式密不可分。实际上也是这样。Observable委托将对关联的变量或者属性的修改进行拦截,发生修改时委托将调用我们用observable函数注册的事件处理程序上。
事件处理程序将接受三个类型为KProperty的参数,这些参数保存关于属性,旧值和新值的元数据,但是不返回任何值。我们直接用例子来说明:
fun main() {
var count by observable(0){property, oldValue, newValue ->
println("参数是:$property,旧值是:$oldValue,新值是:$newValue")
}
count++
count++
count++
}
这里我们用observable委托将count参数给委托了,observable括号中的0代表的是初始值,也就是count一开始为0,而当我们对count进行修改时就会触发后面的lambda表达式,我们来看看运行结果:
显然我们对count进行修改时就触发了这段lambda表达式,达到了观察的效果,感觉和JetPack中的LiveData也很相似。
vetoable委托
接下来介绍的是vetoable委托,和observable委托不同的是vetoable将返回一个Boolean类的值,如果返回true代表同意修改,否则就是拒绝修改。一旦拒绝修改,被委托的变量也就将停止修改,比如说:
fun main() {
var count by vetoable(0){property, oldValue, newValue ->
println("参数是:$property,旧值是:$oldValue,新值是:$newValue")
oldValue < newValue
}
count++
count--
count--
}
这里我们对上面的例子稍作修改,lambda表达式最后一行的oldValue < newValue就是vetoable委托的最后返回值,当新值大于旧值时才同意修改,所以可以预见的是count–将不会生效,我们来看运行结果:
可以看到,后面两次修改果然没有生效。