Kotlin | 搞定泛型使用(in、out、上下界、通配符、获取泛型类型等)

3,108 阅读8分钟

本文已同步发表于我的微信公众号,搜索 代码说 即可关注,欢迎与我沟通交流。

泛型类(或接口) & 泛型方法

泛型,通俗解释就是具体的类型泛化,或者说是类型参数化。多用在集合中(如ListMap),编码时使用符号代替,在使用时再确定具体类型。

泛型通常用于类和方法中,称为泛型类、泛型方法,使用示例:

/**
 * 泛型类
 */
abstract class BaseBook<T> {
    private var books: ArrayList<T> = ArrayList()

    /**
     * 泛型方法
     */
    fun <E : T> add(item: E) {
        books.add(item)
        println("list:$books, size:${books.size}")
    }
}

/**
 * 子类继承BaseBook并传入泛型参数MathBook
 */
class BookImpl : BaseBook<MathBook>()

fun main() {
    BookImpl().apply {
        add(MathBook("数学"))
    }
}

执行main()方法,输出:

list:[MathBook(math=数学)], size: 1

型变、协变、逆变

在继续之前,先来看泛型相关的三个定义,分别是不变、协变、逆变,他们统称为型变:

在泛型中,型变指的是子类型和超类型之间的关系中,泛型类型参数是否具有相同的变化方向。Java和Kotlin中支持通常的型变方式:协变和逆变,与不变对应。

  • 不变:有继承关系的两个类,在变成泛型类型时不再有关系。如:MathBookBook的子类,而BaseBook<MathBook>BaseBook<Book>就没关系了,是不同对象。
  • 协变:如果想让BaseBook<MathBook>BaseBook<Book>继续有父子关系,即想继续支持协变,在Java中使用? extends E表示;Kotlin中使用out E,表示上界是E。那么BaseBook<MathBook>继续是BaseBook<Book>的子类。
  • 逆变:与协变相反,有继承关系的两个类,在逆变之后,关系反过来了。Java中使用? super EKotlin中使用in E,表示下界为E

Java泛型

? extends E 定义上界

Java中的泛型是不协变的,举个例子:IntegerObject的子类,但是List<Integer>并不是List<Object>的子类,因为List是不协变的。

错误 如果想让List<Integer>成为List<Object>的子类,可以通过上界操作符 ? extends E 来操作。

? extends E 表示此方法接受 E 或者 E 的 一些子类型对象的集合,而不只是 E 自身extends操作符可以限定上界通配符类型,使得通配符类型是协变的。注意,经过协变之后,数据是可读不可写的。示例:

//继承关系Child -> Parent 
class Parent{
    protected String name = "Parent";
}

class Child extends Parent {
    protected String name = "Child";
}

首先定义了实体类,继承关系:Child 继承自 Parent

class CList<E> {
    //通过<? extends E>来定义上界
    public void addAll(List<? extends E> list) {
        //...
    }
}

/**
 * <? extends E>来定义上界,可以保证协变性
 */
 public void GExtends() {
    //1、Child是Parent的子类
    Parent parent = new Child();

    //2、协变,泛型参数是Parent
    CList<Parent> objs = new CList<>();
    List<Child> strs = new ArrayList<>(); //声明字符串List
    strs.add(new Child());
    objs.addAll(strs); //addAll()方法中的入参必须为List<? extends E>,从而保证了List<Child>是List<Parent>的子类。
}

addAll()方法中的入参必须为List<? extends E>,从而保证了List<Child>List<Parent>的子类。如果addAll()中的入参改为List<E>,则编译器会直接报错,因为List<Child>并不是List<Parent>的子类,如下:

错误

? super E 定义下界

? super E 可以看作一个E或者E父类的“未知类型”,这里的父类包括直接和间接父类。super定义泛型的下界,使得通配符类型是逆变的。经过逆变之后,数据是可写不可读的,如: List<? super Child>List<Parent> 的一个超类。示例:

class CList<E> {
    //通过<? super E>来定义下界
    public void popAll(List<? super E> dest) {
        //...
    }

 /**
  * 逆变性
  */
 public void GSuper(){
     CList<Child> objs = new CList<>();
     List<Parent> parents = new ArrayList<>(); //声明字符串List
     parents.add(new Parent());
     objs.popAll(parents); //逆变
 }

可以看到popAll()的入参必须声明为List<? super E>,如果改为List<E>,编译器会直接报错:

错误

Kotlin泛型

Java 一样,Kolin 泛型本身也是不能协变的。

  • 使用关键字 out 来支持协变,等同于 Java 中的上界通配符 ? extends T
  • 使用关键字 in 来支持逆变,等同于 Java 中的下界通配符 ? super T

声明处协变

协变< out T>
interface GenericsP<T> {
    fun get(): T  //读取并返回T,可以认为只能读取T的对象是生产者
}

如上声明了GenericsP< T>接口,如果其内部只能读取并返回T,可以认为GenericsP实例对象为生产者(返回T)。

open class Book(val name: String)
data class EnglishBook(val english: String) : Book(english)
data class MathBook(val math: String) : Book(math)

已知EnglishBook、MathBookBook的子类,但是如果将Book、EnglishBook当成泛型放入GenericsP,他们之间的关系还成立吗?即:

error信息 可以看到编译器直接报错,因为虽然EnglishBookBook的子类,但是GenericsP<EnglishBook>并不是GenericsP<Book>的子类,如果想让这个关系也成立,Kotlin提供了out修饰符,out修饰符能够确保:

  • 1、T只能用于函数返回中,不能用于参数输入中;
  • 2、GenericsP< EnglishBook>可以安全的作为GenericsP< Book>的子类

示例如下:

interface GenericsP<out T> {
    fun get(): T  //读取并返回T,可以认为只能读取T的对象是生产者
    // fun put(item: T) //错误,不允许在输入参数中使用
}

经过如上的改动后,可以看到GenericsP<EnglishBook>可以正确赋值给GenericsP<Book>了: 正确赋值

逆变< in T>
interface GenericsC<T> {
   fun put(item: T) //写入T,可以认为只能写入T的对象是消费者
}

如上声明了GenericsC<T>接口,如果其内部只能写入T,可以认为GenericsC实例对象为消费者(消费T)。为了保证T只能出现在参数输入位置,而不能出现在函数返回位置上,Kotlin可以使用in进行控制:

interface GenericsC<in T> {
    fun put(item: T) //写入T,可以认为只能写入T的对象是消费者
    //fun get(): T  //错误,不允许在返回中使用
}

继续编写如下函数:

    /**
     * 称为GenericsC在Book上是逆变的。
     * 跟系统源码中的Comparable类似
     */
    private fun consume(to: GenericsC<Book>) {
        //GenericsC<Book>实例赋值给了GenericsC<EnglishBook>
        val target: GenericsC<EnglishBook> = to
        target.put(EnglishBook("英语"))
    }

GenericsC中的泛型参数声明为in后,GenericsC<Book>实例可以直接赋值给了GenericsC<EnglishBook>,称为GenericsCBook上是逆变的。在系统源码中我们经常使用的一个例子就是Comparable:

//Comparable.kt
public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

使用处型变(类型投影)

上一节中in、out都是写在类的声明处,从而控制泛型参数的使用场景,但是如果泛型参数既可能出现在函数入参中,又可能出现在函数返回中,典型的类就是Array:

class Array<T>(val size: Int) {
    fun get(index: Int): T { …… }
    fun set(index: Int, value: T) { …… }
}

这时候就不能在声明处做任何协变/逆变的操作了,如下函数中使用Array

 fun copy(from: Array<Any>, to: Array<Any>) {
     if (from.size != to.size) return
     for (i in from.indices)
         to[i] = from[i]
 }

调用方:

val strs: Array<String> = arrayOf("1", "2")
val any = Array<Any>(2) {}
copy(strs, any) //编译器报错 strs其类型为 Array<String> 但此处期望 Array<Any>

错误原因就是因为Array<String>并不是Array<Any>的子类,即不是协变的,这里是为了保证数据的安全性。如果可以保证Array< String>传入copy()函数之后不能被写入,那么就保证了安全性,既然我们在声明Array时不能限制泛型参数,那么完全可以在使用处进行限制,如下:

 fun copy(from: Array<out Any>, to: Array<Any>) {
     if (from.size != to.size) return
     for (i in from.indices)
         to[i] = from[i]
 }

可以看到对from添加了out限制,这种被称为使用处型变。即不允许from进行写入操作,那么就可以保证了from的安全性,再进行上面的调用时,copy(strs, any)就可以正确的执行了。

星投影< *>

当不使用协变、逆变时,某些场景下可以使用<*>来实现泛型,如:

  • 对于GenericsP<out T: Book>GenericsP< *>相当于GenericsP<out Book>,当T未知时,可以安全的从GenericsP<*>中读取Book值;
  • 对于GenericsC<in T>GenericsC<*>相当于 GenericsC<in Nothing>,当T未知时,没有安全方式写入GenericsC<*>
  • 对于Generics<T: Book>T为有上界Book的不型变参数,当Generics<*>读取时等价于Generics<out Book>;写入时等价于Generics<in Nothing>

泛型擦除

泛型参数会在编译期间存在,在运行期间会被擦除,例如:Generics<EnglishBook>Generics<MathBook> 的实例都会被擦除为 Generics<*>。运行时期检测一个泛型类型的实例无法通过is关键字进行判断,另外运行期间具体的泛型类型判断也无法判断,如: books as List<Book>,只会对非泛型部分进行检测,形如:books as List<*>

如果想具体化泛型参数,可以通过下面的方式:

1、反射获取父类中的泛型类型(适用于Java & Kotlin

    abstract class BaseFragment<T: ViewBinding> : Fragment(){

        protected lateinit var binding: ViewBinding
        protected abstract val layoutRes: Int

        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?,
        ): View? {
            return if (isDataBindingClass()) {
                binding = DataBindingUtil.inflate<ViewDataBinding>(inflater, layoutRes, container, false)
                binding.root
            } else {
                return inflater.inflate(layoutRes, container, false)
            }
        }

        //反射获取父类中的泛型类型来判断是否是ViewDataBinding类型
        private fun isDataBindingClass() : Boolean{
            try {
                val type = javaClass.genericSuperclass
                //ParameterizedType表示参数化的类型
                if (type != null && type is ParameterizedType) {
                    //返回此类型实际类型参数的Type对象数组
                    val actualTypeArguments = type.actualTypeArguments
                    val tClass = actualTypeArguments[0] as Class<*>
                    return ViewDataBinding::class.java.isAssignableFrom(tClass)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
            return false
        }
    }
//子类Fragment中
//1、子类正常方式的Fragment
class NormalFragment : BaseFragment<EmptyBinding>(){}

    //这里实现是空的,没有实际意义
    class EmptyBinding : ViewBinding {

        override fun getRoot(): View {
            throw UnsupportedOperationException("Not implemented")
        }

    }

//2、子类ViewDataBinding方式的Fragment
class VDBFragment : BaseFragment<VDBBinding>() {}

已知在androidxViewDataBinding实现了ViewBinding接口,如下:

public abstract class ViewDataBinding extends BaseObservable implements ViewBinding {... }

BaseFragment基类限制传入T: ViewBinding类型的泛型参数,注意T没有继承ViewDataBinding,目的是不强制子类Fragment中一定要用ViewDataBinding去写布局,主要就是通过isDataBindingClass()中通过反射对父类中的泛型T类型进行了判断:

  • javaClass.genericSuperclass返回子类实例的父类,也就是BaseFragment<T>
  • type is ParameterizedType如果成立,说明父类是带泛型参数的类
  • type.actualTypeArguments泛型参数可以有多个,因此返回值是一个Type[]数组
  • actualTypeArguments[0] as Class<*>获取第一个实际类型参数,并将其转换为Class对象
  • ViewDataBinding::class.java.isAssignableFrom(tClass)判断tClass是否为ViewDataBinding或其子类

通过以上步骤就可以通过反射对泛型T类型进行判断,进而区分布局加载方式。

2、使用 inline + reified 的方式(只适应于Kotlin

 /**
  * inline内联函数 + reified 使得类型参数被实化  reified:实体化的
  * 注:带reified类型参数的内联函数,Java是无法直接调用的
  */
 inline fun <reified T> isAny(value: Any): Boolean {
     return value is T
 }
 

参考

【1】https://www.kotlincn.net/docs/reference/generics.html 【2】https://mp.weixin.qq.com/s/vSwx7fgROJcrQwEOW7Ws8A 【3】https://juejin.cn/post/7042606952311947278

欢迎扫描下方二维码或搜索微信公众号 代码说 关注我的微信公众号查看最新文章~