泛型编程

起源

泛型编程是一种编程风格,其中算法以尽可能抽象的方式编写,而不依赖于将在其上执行这些算法的数据形式。这个概念在1989年首次由David Musser和Alexander A. Stepanov提出[1.参考]。

2011年,Alexander A. Stepanov和他的同事Deniel E. Rose出版的From Mathematics to Generic Programming一书中文版已出版对泛型编程进行更为精确的定义。

泛型编程是一种专注于对算法及其数据结构进行设计的编程方式,它使得这些算法即数据结构能够在不损失效率的前提下,运用到最为通用的环境中。

泛型编程的提出者

泛型这个词并不是通用的,在不同的语言实现中,具有不同的命名。在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.

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

val box: Box<Animal> = Box(mutableListOf(Dog())
box.add(Cat()) // 由于对外的接口是 Animal,所以意外接收的是 Cat,但是实例方法要求的是 Dog。

这样就导致子类Box<Dog>的实例方法add(t: Dog)(注意,这里的 T 被具现化成了 Dog)方法接收了一个Cat类型的参数,这显然会抛出ClassCastException,所以这是非类型安全的。编译器不会容许这种事情发生,所以报出编译错误,如下:

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

还有一种情况,dogAnimal的子类型,那么Box<Animal>可不可以是Box<Dog>的子类型呢?即:

val box: Box<Dog> = Box(mutableListOf(Animal())
val dog: Dog = box.last() //由于对外的接口是 Dog,所以返回的是 Dog,但是实例方法要求的是 Animal。

如此会导致子类Box<Animal>的实例方法last(): Animal方法返回了一个Dog类型的返回值,但是不是所有的 Animal 都是 Dog,这也会导致ClassCastException,所以编译器会阻止这种使用方式,报出编译错误,如下:

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

不过,总的来说,不变的限制太过于严苛了,泛型的设计既要考虑类型安全性,也要对灵活性有所关照。

对于前一种情况,我们只需要从这个box“读取”元素,即泛型参数作为方法的返回值,而不需要往里面“添加”元素,即泛型参数不能作为方法的参数,那么这种转换就是类型安全的。而后一种情况,我们规定只能往box里“添加”元素,但不“读取”元素,那么也能到达同样的目的。故而,协逆变出现了。

covariance 协变

covariance 协变

如上图所示,当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方法出现了编译错误,原因就是在协变关系中,泛型参数只能作为输出参数,而不能作为输入参数。因为在拒绝了输入泛型参数的前提下,协变发生的时候,才不会出现强制转化的错误,这里的原因和不变的第一种情况是一样的。

不过,这种解决方式也不是万能的,属于杀敌一千,自损八百的战术。因为对于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)原则贯穿始终。不论是继承体系,还是改善之后的泛型继承体系。它们秉持的原则就是在编译期,始终朝着稳定、抽象的方向移动,而且不断在易变、具体的方向延迟决策,直到运行时方能确定。

书籍推荐

书籍推荐

脑图

知识梳理

参考链接
泛型 一个会写诗的程序员
可运行代码示例

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容

  • Swift语言有很多强大的特性,泛型编程(generic programming)就是其中之一,我们也可以将其简称...
    flionel阅读 3,533评论 0 6
  • 一、为什么需要泛型 一般的类和方法,只能使用具体的类型:要么是基本的数据类型,要么是自定义的类。如果要编写可以...
    天青色的鱼儿阅读 1,111评论 1 2
  • 课堂大纲这周的课程大致上应该是这三个部分 C++模板介绍 泛型编程 容器 概述STL与泛型编程 泛型编程作为一种编...
    readME_boy阅读 407评论 0 0
  • 1.IMEI码(国际移动设备身份码)查询 在手机上输入*#06#,可以知道你手机的IMEI码,这组号码共15位...
    好尼桑阅读 1,296评论 0 1
  • 岁月是魔法师 它总能让人成长 我们穷其一生的努力 也未必成熟 即使是80老妪 身体有着惊人的自愈能力 它会自动失忆...
    琴语阅读 315评论 37 13