×

泛型编程

96
lambeta
2018.09.06 23:48* 字数 3408

起源

泛型编程是一种编程风格,其中算法以尽可能抽象的方式编写,而不依赖于将在其上执行这些算法的数据形式。

泛型编程的提出者

泛型这个词并不是通用的,在不同的语言实现中,具有不同的命名。在Java/Kotlin/C#中称为泛型(Generics),在ML/Scala/Haskell中称为Parametric Polymorphism,而在C++中被叫做模板(Template),比如最负盛名的C++中的STL。任何编程方法的发展一定是有其目的,泛型也不例外。泛型的主要目的是加强类型安全和减少强制转换的次数。

Java中的泛型编程

在Java中有泛型类和泛型方法之分,这些都是表现形式的改变,实质还是将算法尽可能地抽象化,不依赖具体的类型。

generics add a way to specify concrete types to general purposes classes and methods that operated on Object before

通用的类和方法,具有代表性的就是集合类。在Java1.5之前,Java中的泛型都是通过单根继承的方式实现的。比如:

public class ArrayList // before  Java SE 5.0
{
    public Object get(int i)
    public void add(Object o)
    public boolean contains(Object o);
    private Object[] elementData;
}

虽然算法足够通用了,但是这样会带来两个问题。一个是类型不安全,还有一个是每次使用时都得强制转化。减少类型转换次数比较容易理解,在没有泛型(参数化类型)的时候,装进容器的数据,其类型信息丢失了,所以取出来的时候需要进行类型转换。
例如:

List list = new ArrayList();
list.add(1);

assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat((Integer)list.get(0), is(1)); //存在强制转换

因为这个类里只有Object的声明,所以任意类型的对象都可以加入到这个集合当中,在使用过程中就会存在强制到具体的类型失败的问题,这将丧失编译器检查的好处。

List list = new ArrayList();
list.add(1);
list.add("any type");

assertThat(list.get(1), instanceOf(String.class));
assertThat((Integer) list.get(1), is(1));//-> java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

2005 Java SE 5引入了泛型,不仅有效地提高了算法的通用程度,同时也保留强类型语言在编译期检查的好处。

Generics This long-awaited enhancement to the type system allows a type or method to operate on objects of various types while providing compile-time type safety. It adds compile-time type safety to the Collections Framework and eliminates the drudgery of casting.

所以上述的程序会写成这样:

List<Integer> list = new ArrayList<Integer>();
list.add(1);
// list.add("no way"); 编译出错
assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat(list.get(0), is(1)); // 不需要强制转换

类型安全

在静态强类型语言中,编译期间的检查非常重要,因为它可以有效地避免低级错误。这些低级错误就是类型安全解决的问题。类型安全包含了赋值安全和调用安全。其底层实质上就是在某块内存中,始终存在被同种类型的指针指向。

  1. 类型赋值检查
long l_num = 1L;
int i_num = l_num; // 编译错误

在强类型的语言当中,类型不一致是无法互相赋值的。

2. 类型调用检查
Clojure就是一门强类型语言,而且还是一门函数式语言,所以重新赋值不被允许,它的类型安全表现在针对类型的调用安全。

user=> (+ "" 1)
...
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

这里存在一个隐式类型转化的过程,但是由于String无法转化成Number,所以方法调用失败。由于Clojure是动态语言,所以只有在运行时才会抛出错误。

另一个简单的例子,如果一个类型不存在某个方法,那就没法去调用它。在动态强类型语言中,运行时一定会报错。其实质是类型是内存堆上的一块区域,如果该区域之上没有想要调用的方法,那么调用在编译期或者运行期间一定会出错。

new Object().sayNothing() // 编译出错

为什么说类型安全对于开发人员友好,这个特性对于编程语言很重要?其实这可以追溯到三次编程范式解决的根本问题上。Clean Architecture(架构整洁之道)一书中,对结构化,面向对象和函数式编程语言做了很透彻的分析。

首先我们得明确一点,这些范式从来没有扩展编程语言的能力,而是在不同方面对编程语言的能力进行了约束。

  1. 结构化编程
    对程序的直接控制进行约束和规范,goto considered harmful.
  2. 面向对象编程
    对程序的间接控制进行约束和规范,pointer considered harmful.
  3. 函数式编程
    对程序的赋值进行约束和规范,mutability considered harmful.

按照这样的思路,泛型编程无非是对既有的范式做了进一步的约束。泛型编程旨在对程序的间接控制进一步进行约束和规范。它把类型安全放在第一位,而将类型转化限制在编译期间。

我们甚至可以遵循前面的定义方式,说:
2.1 泛型编程
对程序的间接控制进一步进行约束和规范,type casting considered harmful.

Kotlin中的泛型编程

variance - 变化

和Java泛型中的泛型方法和泛型类概念类似,Kotlin将对应的概念称为参数化函数和参数化类型。

parameterized function 参数化函数

假设我们要返回三个对象中任一一个对象,同时保证类型一致。参数化函数是很恰当的选择。

fun <T> random(one: T, two: T, three: T): T

parameterized type 参数化类型

除了参数化函数,类型本身也可以定义自己的参数化类型。比如:

class Dictionary<K, V>

bounded polymorphism 限定参数化类型

大部分情况下,参数化类型不会是无限抽象的,无限抽象往往不利于语言的表达性。所以限定的参数化类型应运而生。

fun <T : Comparable<T>> min(first: T, second: T): T {
    val k = first.compareTo(second)
    return if (k <= 0) first else second
}

如果需要用多个边界来限定类型,则需要用到where语句,表达T被多个边界类或者接口限制。

class MultipleBoundedClass<T> where T : Comparable<T>, T : Serializable

invariance 不变

invariance 不变
open class Animal
class Dog : Animal()
class Cat : Animal()
class Box<T>(val elements: MutableList<T>) {
    fun add(t: T) = elements.add(t)
    fun last(): T = elements.last()
}

fun foo(box: Box<Animal>){
}

val box = Box(mutableListOf(Dog()))
// -> val box: Box<Dog> = Box(mutableListOf(Dog()))
box.add(Dog()) // ok
box.add(Cat()) // 编译错误

这里出现的编译错误,原因是box的真实类型是Box<Dog>,所以尝试向Box<Dog>中添加Cat对象是不会成功的。这样总能保证类型安全。

DogAnimal的子类型,那么编译器是否承认Box<Dog>Box<Animal>的子类型,在使用时进行隐式转换呢?

val box: Box<Animal> = Box(mutableListOf(Dog())) // type inference failed. Expected type mismatch.

编译器是不会允许这样行为发生。原因就是这样做会导致类型不安全。
我们试想一下,假如这种转换是允许的,那么我们就可以继续添加其它继承了Animal的子类对象,比如:

val box: Box<Animal> = Box(mutableListOf(Dog())
box.add(Cat())

这样就导致Box<Animal>里面同时保存了DogCat的对象,正如前面提到的,在运行时,调用可能就会抛出ClassCastException,所以这是非类型安全的。

val box = Box(mutableListOf(Dog()))
// val box: Box<Dog> = Box(mutableListOf(Dog()))
val animalBox: Box<Animal> = box // 编译错误

covariance 协变

covariance 协变

但是这种限制太过于严苛了,如果我们只需要从这个box读取元素,而不需要往里面添加,那么这种转换就是类型安全的。具体原因稍后再说。

DogAnimal的子类型,那么Box<Dog>也是Box<Animal>的子类型,这种继承关系就是协变。在Kotlin中,我们需要使用out关键字表示这种关系。

class CovarianceBox<out T : Animal>(val elements: MutableList<out T>) {
    fun add(t: T) = elements.add(t) //编译错误
    fun last(): T = elements.last()
}

基于这种协变关系,我们可以这样调用

val dogs: CovarianceBox<Dog> = CovarianceBox(mutableListOf(Dog(), Dog()))
val animals: CovarianceBox<Animal> = dogs
print(animals.last())

我们注意上面的CovarianceBoxadd方法出现了编译错误,原因就是在协变关系中,泛型参数只能作为输出参数,而不能作为输入参数。因为在拒绝了输入泛型参数的前提下,协变发生的时候,才不会出现强制转化的错误。举个例子:

val dogs: CovarianceBox<Dog> = CovarianceBox(mutableListOf(Dog(), Dog()))
val animals: CovarianceBox<Animal> = dogs
dogs.add(Cat()) // add在这里禁止了

如果CovarianceBox允许add方法,那么box里面就会同时存在多个子类型的实例,这样就会导致类型不安全,所以out修饰的参数化类型,只能在函数的返回值上出现。

不过,这种解决方式也不是万能的,属于杀敌一千,自损八百的战术。因为对于Collection而言,不可能做到任何泛型参数都不会出现在入参的位置上。

public interface Collection<out E> : Iterable<E> {
    public operator fun contains(element: @UnsafeVariance E): Boolean
    public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}

所以,针对这种情况,我们知道某些方法其实并不会有添加的操作,可以在入参的位置上加上@UnsafeVariance,以此消除掉编译器的错误。

contravariance 逆变

contravariance 逆变

DogAnimal的子类型,那么Box<Animal>也是Box<Dog>的子类型,这种继承关系就是逆变。在Kotlin中,我们需要使用in关键字表示这种关系。

class ContravarianceBox<in T>(val elements: MutableList<in T>) {
    fun add(t: T) = elements.add(t)
    fun first(): T = elements.first() // 编译错误
}

基于这种逆变关系,我们可以这样调用

val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog())

这个时候,类型始终是安全的。但是我们也注意到ContravarianceBoxfirst方法出现了编译错误,原因就是在逆变关系中,泛型参数只能作为输入参数,而不能作为输出参数。在拒绝了输出参数的前提下,逆变发生的时候,才不会出现强制转换的错误。

val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog())
val dog: Dog = dogs.first() // 编译错误

reification 变现

reify is To convert mentally into a thing; to materialize.

Kotlin中的Reification的实现使用的是inline模式,就是在编译期间将类型进行原地替换。

// 定义
inline fun <reified T : Any> loggerFor(): Logger = LoggerFactory.getLogger(T::class.java)
// 使用
private val logger = loggerFor<AgreementFactory>()

因此,所以原来调用处的代码会在编译期间展开成如下:

private val logger = LoggerFactory.getLogger(AgreementFactory::class.java)

使用reification操作,可以精简掉很多模板代码。

type projection 类型投影

type projection 类型投影

上述过程中,我们看到协变和逆变都是针对可以编辑的类。但是如果遇到已经存在的类,这件事就得运用类型投影技术。拿Class这个类举例:

val dog = Dog::class.java
val animal: Class<Animal> = dog //编译不通过

Kotlin中的type projection就是为了解决这个问题的。

val dog = Dog::class.java
val animal: Class<out Animal> = dog

同理,

val animal = Animal::class.java
val dog: Class<in Dog> = animal

我们来看一个真实的场景

val agreementClass: Class<RentalAgreement> = RentalAgreement::class.java

private val virtualTable = mapOf(RentalPayload.type to RentalAgreement::class.java)
private fun dispatch(type: String): Class<out Agreement<Payload>> {
    return virtualTable[type]
            ?: throw RuntimeException("No suitable Agreement of this type found, please check your type: $type")
}

只有这样,我们才能将具体的Class<RentalAgreement>投射到Class<out Agreement<Payload>>父类型之上,后续通过某种方式,实例化出RentalAgreement的实例,其继承自Agreement<Payload>

泛型编程的思考

过程式代码 vs. 面向对象

Bob 大叔的 Clean Code 一书的第六章《对象和数据结构》中提到了一个很有意思的现象:数据、对象的反对称性。在这里,数据结构暴露数据,没有提供有意义的函数;对象把数据隐藏起来,暴露操作数据的函数。

过程式代码会基于数据结构进行操作。例如:首先会定义好数据结构Square, CircleTriangle,然后统一在area(shape: Any)的函数中求shape数据的面积,如:

fun area(shape: Any): Double {
    return when(shape) {
      is Square -> return shape.side * shape.side
      else -> 0.0
    }
}

而面向对象拥趸一定会嗤之以鼻——显然应该抽象出一个shape类包含area方法,让其它的形状类继承。如:

interface Shape {
    fun area(): Double
}

class Square(val side: Double) : Shape {
    override fun area(): Double {
        return side * side
    }
}

在添加新的形状的要求下,面向对象的代码是优于过程式的,因为面向对象对类型的扩展开放了。而过程式代码却不得不修改原来area方法的实现。

但是,如果此时需要添加一个求周长primeter的函数。相对于面向对象代码,过程式代码由于无需修改原来的实现,反而更加容易扩展。反观面向对象的代码,在接口Shape中添加一个primeter会导致所有的子类都得发生修改。

这就是数据和类型的反对称性。在变化方向不同的时候,它们面临的阻力也是不一样的。

隔离阻抗

我们既想要过程式对方法扩展的优点,又执着面向对象自然的类型扩展的好处,该怎么办呢?可以考虑结合起来使用。

这样的结合不是说原有的双向阻力消失了,而是在不同的层次上应用各自的优点。也就是说,Shape需要求面积、周长,同时也要支持类型扩展,这种要求之下,基本不可能调解出一种符合开闭原则的方案。不过,如果对于所有Shape类,都需要统一进行某些操作,例如:集合的排序,过滤等等。那么合并两者的好处就变得可行了。

泛型补充

基于最先分析的通过继承的方式进行泛型编程的缺点:

  1. 太多强制转换
  2. 非类型安全。
    恰当地引入了泛型T,以期编译期的占位和运行时的替换。
泛型限定

不过没有限定的泛型大部分情况下是没有用处的,因为无限的抽象没有意义,所以需要更加精准的泛型限定。

依赖倒置

在我们做完这一切以后,会惊喜地发现依赖倒置(DIP)原则贯穿始终。不论是继承体系,还是改善之后的泛型继承体系。它们秉持的原则就是在编译期,始终朝着稳定、抽象的方向移动,而且不断在易变、具体的方向延迟决策,直到运行时方能确定。

书籍推荐

书籍推荐

脑图

知识梳理

参考链接
泛型 一个会写诗的程序员

编程
Web note ad 1