scala类型那些事
概述
在effective java这本书中有对java的类型系统进行比较详细的介绍,总结起来的话就是一句话PECS规则。不过在scala中类型发挥着更强大的作用。
scala类型系统
ClassTag、TypeTag与类型擦除
type erasure的中文意思是类型擦除,具体的意思是说,Java在编译Java代码时,会将类型参数的类型信息擦除掉。也就是说,编译后得到的class字节码中并没有类型参数信息,字节码中没有类型参数信息,那么运行时肯定也就没有了。对于JVM来说,JVM根本不知道泛型类的这个语言现象的存在,泛型只有在编译时有效,可以对类型进行检查,比如:往List[String]中添加Int会导致编译错,但是在运行时,通过反射可以往List[String]中添加Int类型的元素,因为对JVM来说,List[String]、List[Int]是没有分别的,都会当做List(或者说是List[Object])来看待。
如下代码将全部输出true:
1 | case class Box[T](data: T) |
Scala同Java一样,Scala代码编译后,也会丢失类型参数信息,上面的Box[T]说明了这一点。不过,Scala提供了ClassTag和TypeTag两个基本设施来应对类型擦除问题。
ClassTag和TypeTag的最重要的区别是,ClassTag只保留类型参数本身的类型,如果类型参数本身也是泛型类(这称为高阶类型,Hihger Kinded Type),那么这个类型参数所持有的类型参数将丢失,而TypeTag却可以对任意的类型参数保留其参数信息。比如List[T] 如果T不是泛型类型,比如List[Int]、List[Cat]等,那么ClassTag在运行时可以获取出类型参数的类型是Int、Cat,如果T本身又是以个泛型类型,比如List[Seq[Int]], List[Box[Int]],那么ClassTag只能推断出第一层类型参数的新型信息,Seq和Box类型,而它们携带的类型参数将丢失
1 | class Box1[T: TypeTag](val data:T) { |
作为对比,对于ClassTag:
1 | /** |
可见,对于高阶类型,ClassTag无法区分出一阶类型参数的类型参数的类型。上面如果[T:TypeTag]和[T:ClassTag]去掉,理论上来说我们的类型参数就会被擦除调,不过实际情况是不分API编译就不通过。
型变
scala中的泛型,比如M[T]中M称为类型构造器,T称为类型参数。型变描述的是类型参数继承关系下的泛型类的继承关系。如Cat是Animal的子类,而M是类型构造器,那么:
- 如果M[Cat]是M[Animal]的子类,那么M[T]是协变的
- 如果M[Animal]是M[Cat]的子类,那么M[T]是逆变的
- 如果M[Animal]和M[Cat]没有继承关系,那么M[T]是不变的
在scala中,型变是在类型定义的时候进行定义的,如果M是协变的,那么在参数的前面加一个+号,如果M是逆变的,那么在类型参数前面加一个-号,如果既不是协变的也不是逆变的,则什么也不加,比如协变M[+A]、逆变M[-A]、不变M[A],其中的+和-称为型变注解variance annotation。
不变和协变在日常的工作过程中很常见:
- 不可变的集合如Option、List、Set、Map等都是协变的
- 可变的集合,比如Array、ArrayBuffer、ListBuffer都是不可变的,如果他们是协变或者逆变的,很容易出现类型不安全的情况,这也是Scala吐槽Java类型安全的一个点(Java的集合是可变集合,并且是协变的)
对于逆变的理解似乎有违常理,如M[T]是逆变的,也就是说M[Animal]是M[Cat]的子类。对于逆变的理解需要结合以下三点来理解:
- 里氏替换原则
- 数据流向
- 回调(don’t call me,I will call you)
数据流向和回调决定了逆变存在的合理性,而里氏替换原则则用于证明逆变的反继承关系,即证明M[Animal]是M[Cat]的子类。
1、WritableChannel
在java nio中有一个java.nio.channels.WritableByteChannel类,用于向Channel中写入字节数组(其实是ByteBuffer),这里将其泛型化,即可以写入任意类型,WritableChannel[T]表示这个Channel可以写入类型为T的数据,从数据流向和回调两个角度来说,WritableChannel是典型的逆变类型,也就是说Int是Any的子类,那么WritableChannel[Any]是WritableChannel[Int]的子类,下面通过代码来说明问题:
- 定义一个WritableChannel的泛型类
- 定义一个Helper类,并定义如何使用WritableChannel类
1 | class WritableChannel[-T] { |
其中IntChannelHelper类的write方法的含义是说,调用者需要传给我一个可以写入Int的数据通道,我可以回调该通道的write方法写入Int数据。因为IntChannelHelper类的方法要求我们给其提供一个Int类型的通道,我们给他提供的是可以写入任何类型的WritableChannel[Any]也就没有任何问题,可以想象一下我们向写入Any类型的通道写入Int也是可以的。根据里氏替换原则,A出现的位置,B一定可以出现的话,那么B一定是A的子类,也因此WritableChannel[Any]是WritableChannel[Int]的子类。
2、函数的入参类型是逆变的:给定两个类Fruit和Apple,Apple是Fruit的子类,证明函数Fruit=>String是Apple=>String的子类:
1 | trait Fruit { |
process方法的含义是在说,调用者给我一个Apple和一个处理Apple的函数(数据流向),我通过回调你的函数来对Apple进行处理(回调)。对于process(apple)((x:Apple) => x.name)来说类型完全匹配,不会产生问题;对于process(apple)((x:Fruit) => x.name)表示process拿着传入的Apple作为(x:Fruit) => x.name的参数调用,这也是没有问题的,因为Apple是Fruit的子类,因此,给函数(x:Fruit)=>x.name传入Apple没有任何问题(里氏替换原则)
3、协变和逆变的生产者/消费者模式
这里的生产者/消费者模式,并不是阻塞队列中的生产者、消费者模式,而是指的数据的消费和产出。给定类型为M的对象,如果调用者需要为它提供类型为S的数据,即M对象消费类型为S的数据,那么M[S]可能是逆变的,如果M对象产出类型为T的数据,那么M[T]可能是协变的。如上面活到的函数Function[-T, +R]、Function[-T1,-T2, +R],对于入参类型是逆变的,因为函数对象消费入参,而返回时协变的,因为返回值类型时函数的产出
4、对于函数和方法而言,函数参数所在位置是逆变位置,函数返回值所在的位置是协变位置,比如定义M[T]是协变的,但是T出现在了逆变的位置上就会出现相应的编译错误。
1 | class M[+T] { |
上述代码在编译的时候就会报错
视界
上界
上界,upper bound,是面向对象语言里常用到的类型约束技术,他是通过类型的继承关系来对类型参数进行约束的技术(这里需要强调一下是类型参数,也就是说视界只可以出现在[]中)。上界的语法是[A<:B],表示类型参数A要么是B类型的子类,要么是B类型的本身,由于A和B的继承关系以及面向对象设计的里氏替换原则:如果A是B的子类,那么不管A的具体类型是什么,不管A隔着B有几代,A对象都可以把自己当作类型B的对象,类型B对象能做的事情,类型A也可以做,B对象有的能力,A对象都要有。遍历一个积类型的元素的内容,因为case class和元组都是Product的子类,举例如下:
1 | def print[T <: Product](data: T): Unit = { |
在上下文界定和视图界定的那两篇文章中,通过上下文界定和视图界定约束参数类型是可以排序的,可以通过上界加Ordered也可以实现类型T约束,这里可以看到要实现排序只能通过继承Ordered,而不想上下文界定和视图界定里面的类型组合方式:
1 | case class Person1(name: String, age: Int) extends Ordered[Person1]{ |
需要看一下Ordering与Ordered的区别。
下界
scala和Java一样,下界的应用并不常见,不过在看scala的源代码的时候,会经常看到下界的使用。因为在scala里面,比如集合框架,不变集合都是协变的,下界可以解决协变语言设计问题,比如scala中的Option类的getOrElse方法(说的通俗点就是下界解决了幺元的问题)
1 | sealed abstract class Option[+A] extends IterableOnce[A] with Product with Serializable { |
这里的[B>:A]就是下界的语法和用法,这是表示提供给getOrElse的默认值的类型以及它的返回值的类型都是B,需要考虑一下这里为什么使用下界,后面讲到协变的时候再说
需要弄清楚不变集合和可变集合的关系
下界与协变、上界与逆变
在之前的章节有提到过,scala和java一样,下界并不是特别常用,但是Scala语言本身使用下界解决协变的一个语言设计问题(相对应的使用上界解决逆变的问题)
在不变、协变、逆变的章节中有提到,协变的类型不能出现在逆变的位置上(参数的位置是一个逆变的位置),因此如下代码会产生错误:
1 | class M[+T] { |
上面报错,协变的类型不能出现不能出现在函数参数位置上,这个限制有点太大了,有些情况下,协变类型的元素不得不出现在方法参数位置上,比如Scala的两个不可变的集合List与Option,他们都是协变的集合。
- scala.collection.immutable.List
List 集合有一个方法 ::,用于在list表头添加一个元素,构造一个新的集合。它还有一个contains方法,用于检查集合中是否包含某一个元素,其定义如下:
1 | sealed abstract class List[+A] |
上述例子中A作为协变的类型又必须出现在函数参数的位置上,因此这里就出现了矛盾点,这里的解决方案是针对该协变类型指定一个上界,并将上界的类型放到该函数参数位置上。
Option的getOrElse方法也是类似的,这里就不再赘述了。
Scala通过下界来解决协变的类型不能出现在逆变位置上的问题。除了下界约束,还有上界约束,这里是否可以使用上界约束呢?不可以!如果使用上界约束会报错,通过一个MyOption的例子来举证一下:
1 | class MyOption[+A](val x: A) { |
上面的实例中只有方法1是正确的,对于方法0是由于A出现在了逆变的位置上,对于2,我们抛开A的协变的性,单从这个方法所要表达的含义来说,这个方法完全没有问题,该方法想要表达的意思是:如果MyOption无值则返回一个类型为B的默认值(B是A的子类或者同类),也就是说,getOrElse2方法返回值类型上界是A,这从语义上比方法1更说的通,但是结合了A的协变性,就是错误的。应该在编译期就提示错误,使用下界解决协变问题本身就是一个权宜、折中的办法。
- Scala规范的型变位置是协变还是逆变位置的判定规则说明,如果A是协变的,那么如下方法的[B :< A]的A所在的位置是逆变的,而A是协变的,因此编译出错
1 | def getOrElse[B <: A](default: B): A = { |
- 因为MyOption是协变的,因此,假定Cat是Animal的子类,那么如下定义因为协变不会存在问题:
1 | val opt0 = new MyOption[Animal](new Cat) |
如下代码也没有问题:
1 | object ThirdParty { |
上述代码中,如果getOrElse是上界约束的,那么这个就有编译错误。
val x = ThirdParty.getAnimal.getOrElse(1)是否存在问题??
存在类型
Existential type对应的中文名称叫做存在类型。在参数化类型上,scala与java有一个重要的区别,因为java是从java5开始支持泛型的,1.4之前是没有的,因此为了向后兼容,java5允许泛型类不带类型参数,在这种情况下,Java5认为类型参数是Object,即List data = new ArrayLIst()等价于List
存在类型使用[_]表示,这里的_表示的是占位符,标识这里有一个类型参数,具体是什么它并不关心也不知道,在如下几种情况下,可以用到存在类型:
- classOf
由于Java字节码编译出来后擦出了类型参数信息,因此,classOf[List[Int]]和classOf[List[String]]是完全一样的,这里为了强调获取的是LIst而不是类型参数的Class,最好使用classOf[List[_]]
- 不关心类型参数的实际类型
- 与Java进行交互
scala可以无缝的调用Java定义的类、接口、方法、变量等,如果Java定义了一个方法如下:
1 | public class CollectionUtis { |
scala编译器推断出来的Size的类型参数是java.util.Collection[_],也就是说,scala将不带参数的Java泛型解释成存在类型。
关于forSome
List[_]是一种简写写法,有些代码在使用存在类型的时候,使用forSome,这个有点啰嗦,不过起码要知道这种写法的含义:
- List[_]等价于List[T] forSome {type T},T可以是任意的类型
- List[_ <: A]等价于Seq[T] forSome {type T <: A}
forSome风格的存在类型在Spark源代码中出现的并不多,如下是一个使用forSome定义的存在类型,他定义在EdgeRDD中:
1 | private [graphx] def partitionsRDD: RDD[(PartitionID, EdgePartition[ED, VD])] forSome {type VD} |
上述代码等价于如下代码:
1 | private [graphx] def partitionsRDD: RDD[(PartitionID, EdgePartition[ED, _])] |
自身类型SelfType
Self Type,自身类型,初次看到这个词汇不知所云,更确切的应该称之为特质依赖类型 Scala类型系统的自身类型(Self Type)主要有两个目的:
指代当前对象,用作this的别名,在类型嵌套定义中,内部类可以通过这个别名访问外部类的对象
依赖注入
1 | class Outer { |
1)这里的self =>就是自身类型,这里的self =>可以看成是只依赖自身而没有依赖其它特质。
2)self的名称可以任意,比如this,that,myself都可以
3)这里的self.sum(a, 0)可以写成Outer.this.sum(a,0), 这里的语法跟Java类似,
- 依赖控制(编译时检查一个trait依赖的其它trait是否存在)
在Java web开发中,典型的三层结构:Controller->Service->DAO, Controller依赖Service,Service依赖DAO,如果是基于Spring依赖注入框架的web应用,那么Spring框架在启动时负责依赖的注入,注入分为构造注入和设值注入
在Scala中,通过自身类型可以实现特质之间的依赖控制,比如指定Service特质依赖DAO特质
1 | trait IUserDao { |
在上面的IUserService特质中,isValidUser调用了并没有在IUserService定义的validateUser方法,这里可以调用的原因是,通过self:IUserDAO=>语句表示,混入IUserService的类,必须同时混入IUserDAO, 也就是说,IUserService特质依赖IUserDAO特质。因此这个类也就间接的定义了validateUser方法,因此IUserService方法可以调用validateUser方法。
Scala术语表(Scala Glossary)对Self Type的描述是Any concrete class that mixes in the trait must ensure that its type conforms to the trait’s self type,意思是:具体类混入一个特质时,必须同时混入这个特质定义的自身类型。在IuserService中self的类型包含了两个,也就是外部类和self所明确指定的类,具体可以通过如下代码进行验证:
1 | println(self.isInstanceOf[IUserDao]) |
通过自身类型实现依赖注入,这种实现方式称为蛋糕模式(Cake Pattern),这里的蛋糕应该称为分层蛋糕,一层叠一层的蛋糕,多特质混入可以认为是一个特质叠另一个特质,这有点像分层的蛋糕,一层是一个特质,比如:
val triathlon = new Player with Run with Ride With Swim
上下文界定
上下文界定即ContextBound,他是一种通过上下文的隐式参数来对类型参数进行约束的技术,这里可以看出上下文界定的几个关键点:
- 上下文界定的动机和目的是对类型参数进行约束
- 对类型参数进行约束的手段是通过上下文隐式参数
- 既然是对类型参数进行约束,那么必然涉及到参数化的类型(也就是常说的泛型)。比如M[T],上下文界定的目的是对T进行约束,约束的做法是要求类型为M[T]的对象在上下文中隐式存在
从隐式参数说起
在Scala中,隐式参数有三个基本的用法(说明:隐式参数包含两层意思即泛型的参数以及隐式)
- 为某种类型的参数提供默认值
- 为上下文提供默认的运行环境
- 为类型参数提供约束,要求类型参数支持某种能力
为某种类型的参数提供默认值略过不讲了
针对为上下文提供默认环境,隐式参数的这种用法,Scala中最经典的是用在异步处理scala.concurrent.Future接口,Future的每个方法都包含一个类型为ExecutionContext的隐式对象,ExecutionContext类似于Java的Executor、ExecutorService,即线程池对象。ExecutorContext用于执行Future对象本身所代表的一步任务,Future的foreach方法也包含一个类型为ExecutorContext的隐式对象,用于在异步任务处理结束得到异步处理的结果后,异步调度执行callback逻辑对结果进行进一步的处理。也就是说,ExecutorContext是为异步提供多线程运行时环境。Future构造器和foreach方法的签名对应如下:
1 | def apply[T](body: => T)(implicit executor: ExecutionContext): Future[T] = ??? |
Future的基本代码结构:
1 | Future { |
其中,ExecutorContext.global是scala定义的线程池,上面的代码是为ExecutorContext隐式对象现实的指定了ExecutorContext.global,也可以通过导入隐式对象到当前上下文,省略显式指定的ExecutorContext对象,通过这种方法代码会简洁很多,同时开发人员只需要关注任务逻辑和回调逻辑,而无需关心任务提交等,将任务本身的逻辑和任务提交执行的逻辑分离开来,具体如下:
1 | import ExecutorContext.Implicits.global |
对于为泛型参数提供约束,要求泛型参数支持某种能力。scala的集合比如List、Seq等包含max、maxBy方法用于对集合求最大值,求最大值也就意味着集合的元素是可比较排序的,如果集合的元素不能用于比较,那么max、maxBy必然会失败,那么这里就有两个问题:
- 如何限制只有元素可排序的List才可以调用max、maxBy方法
- 如果元素不可排序的List调用了max、maxBy方法,是在编译时就失败还是在运行时失败
Scala通过隐式参数类型约束限制了只有可排序的元素集合调用max、maxBy方法才能通过编译,也就是说,元素不可排序的List调用了max、maxBy方法会在编译时就失败,max的方法签名如下:
1 | def max(implicit cmp: Ordering[A]): A |
比如下面的代码编译失败
1 | case class Person(val name: String, val age: Int) |
上下文界定
2.1 上下文界定跟上面说的隐式参数的第三点,为泛型参数提供约束,要求泛型参数支持某种能力完全一样,差别在于语法的不同上,上下文界定只是一种语法糖:
1 | def max(a: T, b: T)(implicit cmp: Ordering[A]): A |
等价于
1 | def max[A: Ordering]: A |
[A: Ordering]这个语法就是上下文限定的语法(两个类型之间用冒号隔开,冒号的左边是参数类型,冒号的右边是类型构造器),它表示存在一个类型为Ordering[A]的隐式对象,上下文界定只是隐式参数的语法糖,一种快捷的写法。以max[A:B]为例,[A:B]表示存在一个类型为B[A]的隐式对象,也就是说,A最终用作B的类型参数,B是一个类型构造器(以A为类型参数)。在写[A:B]的时候常会错写为[A, B[A]],这是不对的,上下文界定要求右侧的类型是类型构造器,不是具体的类型,写成B[A]表示的是一个具体的类型了。
2.2 implicitly
上面看到使用隐式参数和上下文界定是同一事物的两种写法,唯一的差别是隐式参数的写法可以获得到隐式参数的值,在上下文界定中却 没有这个值(比如上面的cmp),如果需要获取这个值的话,可以通过定义在predef中定义的implicitly这个方法得到,比如implicitly[Ordering[A]],implicitly是一个无参函数,但却是一个泛型方法,提供给他的类型值就是要获得隐式值的类型。比如求两个值的较大者:
1 | def max[A: Ordering](a1: A, a2: A): A = { |
Context Bound ,[Left: Right], requires types Left of some kind k and Right of kind k -> *. That is, Left is some type or a type constructor, and Right is type constructor that can take Left as an argument to produce a concrete type. So, Right is never a concrete type, but Right[Left]always is.
视图界定
视图界定和上下文界定一样,也是通过上下文存在的一个隐式值来对类型参数进行约束(或者强制要求类型提供某些能力,比如可加减、可排序),但是上下文界定和视图界定通过隐式值,对类型参数进行处理的方式不同。上下文界定通过隐式参数进行类型约束,视图界定通过隐式转换进行类型约束,在前面上下文界定的例子中:
1 | def max[A: Ordering](a1: A, a2: A): A = { |
要求在调用max方法的上下文中,存在一个类型为Ordering[A]的对象,也就是说,上下文界定是要求类型参数是类型类的成员,关于类型类在type class模式中会讲到。[A:Ordering]是隐式参数implicit ord: Ordering[A]的语法糖。
视界的写法[A <% B],其中A和B都是具体的类型,不像上下文界定中,B是类型构造器。视界要求存在一个隐式转换,可以将类型A转换为类型B,A的对象可以当作B类型用,这个隐式转换可以是隐式入参为A,出参为B的隐式方法,也可以是入参为A,出参为B的函数值,上面的max方法使用视界改写代码如下(需要注意是[T <% Ordered[T]])而不是[T <% Ordered]:
1 | def max1[T <% Ordered[T]](a: T, b: T) = { |
上面的代码能够正常工作的原因是:
- 因为scala已经针对基本的可排序类型,比如Int、Long、Double、String提供了Int => Ordered[Int]的隐式转换,所以上面的隐式转换对象是存在的
- a > b能够正确工作的原因是, > 是ordered定义的方法,因为 T can be viewed as ordered[T],那么T可以使用Ordered[T]定义的方法,上面定义的max方法,如果对Person对象取max则失败
1 | case class Person(name: String, age: Int) |
原因是在max调用处的上下文中,并不存在一个Person => Ordered[Person]的隐式转换,需要定义一个隐式转换并云如刀上下文中:
1 | implicit val orderByAge = (p: Person) => new Ordered[Person] { |
在上文中加上这行代码就可以保障程序的正确执行了。
视图界定有可能被废掉
视图界定只是上下文界定的一种特殊情况,视图界定可以做的,上下文界定一定可以做到,可能稍显繁琐。
type class 模式
类型类是特殊的类,可以认为是高阶的class。
type class叫做类型类,该词汇来源于纯函数式编程语言Haskell,因为Hashkell语言里只有type,没有class的概念,因此在Haskell的世界里,type class很好的表达了type class的含义,class这里与类型无关,只是种类、类别的意思,或者叫做type family会更合适。
scala照搬了type class这个词汇,但是在scala、java这些class满天飞的面向对象语言里面,type、class都有确切的含义和指代,把type和class放到这里就成了四不像。
如前所说,在Scala里面,把type class理解成type family,类型家族,更确切,也更容易理解type class的语义是什么,能用来做什么。
type class是类型族的概念,那么就意味着有一个家族,这个家族有一堆类型作为家族成员。这里的家族就是类型类,家族成员是类型,在Scala中,
类型类通常使用trait来定义,即类型类是一个接口,比如NumericLike,数字家族
每个家族都有其特征,类型类使用行为来表征这个家族的特征,对于数字家族,行为包括基本的加减乘除,每个行为就是一个抽象方法。需要注意点是,类型类对行为进行抽象,而不针对数据,因此类型类通常都是没有状态的
家族要有成员才能称为家族,type class的成员是类型,以NumericLike为例,类型T要成为NumericLike家族的成员,条件是当前仅当存在一个类型为NumericLike[T]的对象,比如如果存在NumericLike[Int], NumericLike[Doule]等对象,那么Int、Double都是NumericLike家族的成员
通过上面第三点,可以看到,type class本身是一个参数化类型,M[T],类型构造器M是type class,T是类型成员
参数化类型,我们见得多了,Java和Scala的集合都是类型参数化集合,比如List[Int],Seq[String],Set[Long]等,那么是不是定义个泛型类就是一个type class?显然不是,type class模式基本上可以看成是GOF 23种设计模式中的适配器模式,适配器模式有两种实现,一是基于类型继承,一是基于类型组合,type class是通过类型组合进行类行适配的技术。
给定类型List[Int],容器类型是List,容器的元素类型是Int,容器的类型List和容器的元素类型Int完全独立不相干,我们既不能说,Int is something like a List,也不能说 Int can act like a List
而type class则不同,给定一个类型类M,以及它的成员类型T,那么意味着存在着类型为M[T]的对象,M定义了一组表征它这个家族的行为,T必须向M定义的行为靠拢,也就是说,M和T之间存在着行为相似性,M和T不再完全独立不相干,它们的相关性体现在行为的相似性上。
上面说了很多关于对type class的理解,接下来看下type class在Scala中的实际应用,
Scala集合,比如List,Seq,定义了一个sum方法用于对集合求和,比如List(1,2,3).sum的结果是6,深入想一下就会发现,sum方法可能存在的问题:如果List中的元素不支持累加操作,那么sum方法必然失败,比如List(“1”,”How are you”).sum会失败,原因是字符串本身并不支持sum求和,具体的原因和解决办法在上下文界定那篇文章里讲到,这里我们又碰上上下文界定,这不是偶然的,因为,实际中,类型类都是基于上下文界定实现的
看下,List的sum方法的方法签名(A要具备B的能力,同时提供一个Numberic[B]的隐式转换,该隐式转换是一个类型类,接收一个类型B作为参数,最终生成的num包含的一些列方法将会用于后续的操作中):
1 | def sum[B >: A](implicit num: Numeric[B]): B = foldLeft(num.zero)(num.plus) |
方法的签名表名,对于集合的元素类型A,要想通过编译,那么A必须是类型类Numeric的成员,从上面的签名也可以看到,类型类Numeric是一个泛型类型
Numeric接口的定义,Numeric接口定义了与数值相关的行为
1 | trait Numeric[T] extends Ordering[T] { |
因为Scala已经为常见的数字类型,比如Int、Long、Double,通过定义Numeric[Int]、Numeric[Long]、Numeric[Long]隐式对象提供了默认的实现(如果对这些默认实现不满意,可以重新定义覆盖这些默认实现),所以List(1,2,3)可以正常工作
因为,Scala没有将String纳入到Numeric这个家族中,因此调用List(“1”,”2”,”3”).sum会失败
假如我们现在希望List[String].sum得到的结果是List集合中所有字符串长度的和,那么我们可以定义Numeric[String],将String纳入到Numeric家族中,并且根据sum的语义,实现Numeric的plus方法, 想法很美好。。。但是这种做法不可行,原因是Numeric的plus方法入参是两个字符串,出参同样是字符串,因此不能借助于List的sum方法来实现这个需求,这个需要自定义type class实现?
目前看,这个通过type class来实现这个需求,并不可行,只能List(“1”,”2”,”3”).map(_.length).sum?我再想想
implicit类型关系证明
在上下文界定章节中,说到了隐式参数的灿哥基本功能:
- 为某种类型的参数听过默认值
- 为上下文提供默认的运行环境
- 为类型参数提供约束,要求参数支持某种能力
implicit类型证明可以归到第三种,为类型参数提供约束,只是在上下文界定的章节中,隐式参数通过要求M[T]类型的隐式参数存在,比如要求Ordering[Person]对象来对Person进行约束,而这里通过要求类型之间存在某种关系来达到类型约束的目的,隐式参数这里就是证明这种类型关系存在类型之间的关系,scala定义了两种:
A=:=B,代表了A和B是相同类型。A<:<B,A是B的子类或者A和B是同类型。
Scala的List有一个toMap方法,意思是将List[A]转换为Map[K, V]集合,对于普通的List如List(1, 2, 3)无法转换为Map集合,因为List的元素类型只有是Tuple2类型转换成Map集合才有意义,也就是只有List((1, “one”))这样的集合可以转换为Map,toMap的结果是Map[Int, String] (1 - > “one”,……)。Scala的做法就是通过定义隐式对象,通过这个隐式对象,要求List集合的元素类型是Tuple2的类型:A<:<(T, U)表示A是Tuple2的子类:
1 | def toMap[T, U](implicit ev: A<:<(T,U)): immutable.Map[T, U] = { |
上面这种类型约束并不少见,特别适合于操作只对某种特定的类型才有效的类型约束上。
1 | class Box[T](val data: T) { |
Box的add方法只能用于Box[Int],而Box的max方法则要求T必须是可排序的,即T是Ordered[T]的子类。在add方法中,并没有直接使用隐式参数ev,但是scala编译器通过这个隐式参数,可以把T当作Int使用,因此data+that.data是合法额的,+是Int定义的方法。
关于typeOf
scala提供了两个方法classOf、typeOf用来根据类型信息获取相应的类型对象,classOf定义在Predef中,typeOf定义在scala.reflect.runtime.universe中。
classOf[String]得到的是String.class,通过classOf获得泛型类的类型信息,比如classOf[List[Int]],那么得到的是List.class,而List的参数类型的类型信息将丢失,因此,classOf[List[String]]和classOf[List[Int]]是完全一样的,都是classOf[List]
而typeOf则不同,它可以准确的得到类型参数的信息,即typeOf[List[String]]和typeOf[List[Int]]是不同的,如下代码输出List[Int]
1 | val tpe = typeOf[List[Int]] |
- typeOf方法的结果类型TypeRef
TypeRef继承自Type,Type的定义是:type Type >: Null <: AnyRef with TypeApi, 可以看到Type是对象类型,TypeRef的定义是:
1 | abstract case class TypeRef(pre: Type, sym: Symbol, args: List[Type]) extends UniqueType with TypeRefApi) { |
TypeRef的构造参数包含一个类型为Type的变量pre,因此,TypeRef可以看成是递归的结构,同时,TypeRef是一个case class,因此TypeRef可以通过模式匹配进行解构,如下代码所示:
1 | class Box[T: TypeTag](val data: T) { |
上面的例子如果遇到了高阶类型,比如Box[Seq[Int]],那么通过解构typeOf[Seq[Int]]得到Seq的类型参数的类型。
加入Box的类型是Box[Map[Int,String]],那么析构的代码:
1 | class Box[T: TypeTag](val data: T) { |
结构类型 Structural Type
结构类型在实际项目中基本不会使用,这里简单介绍一下只是为了scala类型系统这个系列的完整性,另外如果真有人这么写,也保证我们能看的懂。
Scala结构类型,有时也成为Static Duck Typing,它是一种使用行为来表征一个类型的做法,这个类型是无名的,Scala在运行时,通过反射来根据结构类型来合成一个具体的类型,反射是一个相对比较耗时的操作,所以,实际中没有什么人用结构类型,一、代码很ugly,二、性能损失
- 作为参数类型
1 | def close(resource: { |
如下就是一个结构类型定义,它没有别的要求,只要定义了返回类型为Unit的close,那么就是满足这个类型的定义
1 | { |
- 结构类型别名
1 | type Closable = { |
接着上面的Resource类,new Resource().isInstanceof[Closable]的结果为true
- 类型参数约束
1 | def close[T <: { |
- 结构类型作为返回值的类型
1 | def get: { |
从上面四个基本用法上可以到,结构类型可以当做普通类型一样使用,不过,一方面代码显得啰嗦,另一方面,结构类型在运行时通过反射合成一个具体的JVM能识别的普通class,性能有损耗
在jackson-scala上应用了这种结构型,不过纯粹是体验下这种写法,不是必须的。
1 | def to_json(obj: AnyRef, withPrettyPrinter: Boolean = false): String = { |
mapper.writerWithDefaultPrettyPrinter() 和 mapper的类型不一致,但是都有相同的方法writeValue(writer: Writer, obj: AnyRef),所以可以使用结构型来定义变量
单一实例类型
在Scala里面,有时会将方法的返回值的类型定义成this.type,这是为了实现fluent API(链式API)。Java的StringBuilder的append方法是典型的fluent API,append方法可以连续调用,比如 new StringBuilder().append(“a”).append(“b”).append(“c”).toString()得到的结果是字符串abc
StringBuilder可以这样做的原因是append方法的返回值的类型是StringBuilder,返回值是this,因此可以链式调用。Java的这种fluent api在面型子类对父类进行扩展的情况时,不能很好的表达,Scala对这种场景进行统一和完善,
- Java fluent API的问题
1 | class ComplexCalculator extends Calculator { |
上面创建完ComplexCalculator类,依次调用substract和add方法等,调用完add方法后不能继续调用substract方法了,原因是,Java编译器判断出add方法的返回类型是Calculator,而Calculator上没有subtract方法,因此无法继续链式调用子类上定义的subtract方法,
- this.type
Scala通过指定返回类类型是this.type可以保留this对象的实际类型,因为上面创建的对象类型ComplexCalculator,因此级联调用可以继续下去
1 | class ComplexCalculator extends Calculator { |
- 类型单一实例
上面this.type具有普遍意义,this表示一个对象,也就是说,Scala为每个对象都定义了一个type属性,这个type属性表示这个对象的类型,而且这个类型只有这个对象一个实例,比如
1 | class A |
a1.type这个类型只有a1一个对象,把a2赋值给类型为a1.type的变量报错,因为a2的类型a2.type和a1.type不同(typeOf[a1.type] =:= typeOf[a2.type]为false)
- object的类型
Scala通过object关键字可以定义单例对象,这个object的名称既表示这个单例类的名称,又表示这个单例类的唯一实例,比如
1 | object A { |
A既表示A这个单例类的名称,又表示A这个单例类的唯一对象,A.type表示对象A的类型, 直接使用A表示单例对象,
复合类型
Scala同Java一样,都是单类继承,多接口实现的语言,Scala里面接口称为特质,对应的,称为多特质混入。Scala类型系统的复合类型这个语法现象可以解决多接口继承(多特质混入)上的一个类型设计问题。 以铁人三项比赛项目为例,铁人三项是游泳、长跑和骑行三个项目的组合,这里将游泳、长跑和骑行三个项目定义成三个特质
1 | class Player |
定义一个方法,传入一个铁人三项运动员对象,进行铁人三项比赛
1 | def triathlon(player: 铁人三项运动员): Unit = { |
这里的player要支持铁人三项的三个比赛项目,swim,run以及ride,那么的player的类型如何指定?在Java中,通常的做法是让Player继承Swim、Run和Ride接口,但是类型结构既复杂又不灵活。Scala通过复合类型来解决这个问题,如下:
1 | def triathlon(player: Player with Swim with Run with Ride): Unit = { |
调用方式:
1 | val player = new Player with Swimeet with Runmeet with Ridemeet |
从上面可以看到,Scala有类似动态类型语言的特性,在声明变量时,可以动态的混入多个特质,类型可以即拿即用,如果需要改写某个特质的实现,可以像Java那样通过匿名内部类的方式,这在Scala中称为Type Refinement
最后说明一点,Java通过泛型的类型约束也能实现triathlon(player: Player with Swimeet with Runmeet with Ridemeet)这样的参数类型定义:
1 | interface Swim { |
但是构造一个实现这些接口的player就比较麻烦了,只能定义一个Player,让Player实现Swim、Run和Ride接口
类型投影
在Scala中定义内部类的时候,由于类型的路径依赖,内部类的类型通常跟外部类的实例绑定在一起, 那实际中就需要解决一个问题:将内部类一般化,将内部类的类型跟外部类的实例解绑(因为Java中没有类型路径依赖,因此Java中内部类的类型和外部类的实例是天然解绑的,Java中使用Outer.Inner来表示通用的内部类,在Scala中,通过类型投影来解绑内部类与外部类对象的绑定)
1 | class Outer { |
上面的work方法,work接受一个Outer内部类Inner类型的对象,这里期望的Inner类型不与Outer类的对象绑定,即work接收任意的Inner类型的对象,那么这里的Inner类型如何指定?
- 如果指定a1.type或者a2.type,那么work将只能接收a1或者a2作为输入参数,那么work的入参无法普遍适用于Inner类型的对象。这里也不能指定为Outer.Inner,
通过这种方式指定会报编译错,通过这种方式指定表示Outer是一个object,而实际上Outer是一个class
Scala通过类型投影来实现内部类与外部类对象的解绑,语法是Outer#Inner,通过这种方式,如下语句都没有编译错:
1 | val a1 :Outer#Inner = new outer1.Inner |
在Hadoop MapReduce 程序中应用类型投影:
如下是Hadoop Mapper API的定义,从中可以看出Context是Mapper的内部类,并且map方法使用了这个内部类Context
1 | class WordCountMapper extends Mapper[LongWritable, Text, Text, IntWritable] { |
关于类型擦除,在上面定义了两个与Outer类型对象绑定的Inner类型的对象:
1 | val a1 = new outer1.Inner |
虽然val a2 : outer2.Inner=a1有编译错,但是a1.isInstanceof[outer2.Inner]的结果是true,因为运行时,outer2.Inner经过类型擦除后,它的类型退化为投影类型,Outer#Inner
抽象类型成员abstract type members
在java的类和接口中,只有方法成员是可以抽象的,数据成员是不能抽象的,并且Java中没有类型成员的概念。在scala中不但有类型成员,而且方法成员、数据成员和类型成员都是可以抽象的。比如,如下定义A的数据成员是抽象的,这在Java中做不到:
1 | abstract class A { |
scala的抽象类型成员和类型参数在功能上有很多相似支持,都是对类型进行抽象,如下是他们的一些不同之处:
- 抽象类型成员(就是用type牟定的)只适用于具有继承关系的类结构体系中,父类定义抽象类型,子类通过继承实现父类定义的抽象成员,在此基础上使用这个类型,而类型参数则不要求通过继承指定具体类型
1 | abstract class Box { |
通过类型参数指定的话如下:
1 | class Cat1 |
- 抽象类型不能出现在类的构造参数位置,如下的抽象类型定义有编译出错,抽象类型不能出现在对应的构造参数位置:
1 | abstract class Box(data: In) { |
- 抽象类型需要有子类的具体化,从语义上来说,抽象类型将和抽象类通常一起演化,也就是说,抽象类型的具体类型和抽象类的实现类通常是相关的,比如:
1 | abstract class Reader { |
类型参数通常和类型构造器之间并没有联系,比如scala集合和集合元素之间的类型,比如List[Int]和Int并没有联系。不过上面的Reader同样是可以用类型参数来实现,也就是是说,Reader这个例子并不是抽象类型由于类型参数。
- 在上面的例子中,抽象类型在子类中完成实例化,那么在子类中这个类型就是确定的,可以拿这个类型做这个类型支持的操作,而在类型参数中,通常需要类型参数约束来为类型参数指定他能支持的操作(比如上下界、视界、上下文界定等类型参数约束技术)。
到目前为止,都是抽象类型能做到的,类型参数也能做到的,而类型参数能做到的,抽象参数不一定可以做的到,具体如下:
- 如果类结构定义除了涉及到类型参数化外,还涉及到类型的继承,通常抽象类型是比较好的选择,继承类的类型不再是参数化类型,那么使用抽象类型很合适。
- 类型参数膨胀:如下,每个子类都携带类型参数,通过类型参数的方式,类型参数的指定比较啰嗦重复,但是这种类型继承层次以及类型结构,实际并不多见:
1 | trait X[S, T, U] |
我们可以通过抽象类型版本进行改造
1 | trait X1 { |
上面通过抽象类型改造之后,在声明最后一个类的时候,由于我们要具化对象因此也使用了类型参数相结合的方式声明了泛型类。
- 抽象类型成员与方法的依赖类型结合实现磁铁模式,这个在方法依赖类型中有详细的阐述
方法依赖类型
抽象类型和方法依赖类型组合实现了抽象工厂模式
1 | abstract class Computer { |
上面的方法中def create(factory: ComputerFactory): factory.C = factory.create就是一个方法依赖类型,方法的返回值的类型依赖于方法的入参的类型,传入不同的工厂得到对应的工厂的产品,正式抽象工厂模式。
Symbol
symbol的字面意思是符号、记号、标志等,具体到scala反射系统symbol表示的意思是带名符号,“带名”的意思是每个symbol都有一个name(Symbol特质定义了name方法),同时Symbol这个符号用来刻画Scala的语法要素,即抽象语法树的语法要素(这里可能会涉及元编程的内容)。
Scala的Symbol特质定义了一系列的子特质,比如:TypeSymbol、ClassSymbol、TermSymbol、MethodSymbol等。
- 使用TypeSymbol用来刻画类型,TypeSymbol特质定义可一系列的操作用来获取类型信息
- 如果TypeSymbol表示类型参数,那么这个类型是否是逆变的、是否是协变的
- TypeSymbol表示的类型是否是类型别名
- TypeSymbol表示的类型是否是抽象类型
- TypeSymbol表示的类型是否是存在类型
- 如果TypeSymbol表示类型构造器,那么可以获取该类型构造器的类型参数集合
- 使用ClassSymbol用来刻画Scala的类和接口,ClassSymbol特质定义了一系列操作用来判断:
- 一个类或者特质是否是密封类(sealed trait)
- 这个类是否是case class
- 是否是特质
- 是否是抽象类
- ClassSymbol可以用来获取父类或者父特质等
- 使用MethodSymbol用来刻画Scala的方法,MethodSymbol特质定义了一系列的操作用来刻画一个方法
- 该方法是否是类的主构造
- 方法的返回值类型
- 方法声明的异常类型集合
- 方法的参数列表级每个参数列表的类型
- 参数类型是否是变长类型等
- 使用TermSymbol用来刻画Scala的变量和方法,TermSymbol是MethodSymbol的父类,但是scala并没有为变量定义一个FieldSymbol特质,为什么TermSymbol既用来刻画变量又用来刻画方法(刻画了方法很少的一部分特征,其他的在MethodSymbol中刻画的),这是因为Scala有统一访问原则,意思是这些方法可以当作变量使用,比如父类定义的变量可以在子类中以定义方法的方式重写,父类定义的方法可以在子类中一定义变量的方式进行重写。TermSymbol定义的操作:
- 变量是Val还是var
- 判断方法是getter还是setter
- 是否是lazy变量
- 获取变量对应的getter或者setter方法
- TermSymbol表示的参数是否是传名参数,这里可以看到方法参数也是TermSymbol
- TermSymbol如果表示方法参数,那么这个参数是否带有默认值
因为方法属于某个类或者特质,方法参数属于某个方法,那么用于刻画方法、类或者特质、方法参数的Symbol就应该包含关系,Symbol的定义确实定义了owner方法用来描述Symbol之间的包含关系
在上面梳理的TypeSymbol、ClassSymbol、MethodSymbol、TermSymbol的时候,并没有包含对package、可见权限控制、是否final的描述。这些内容定义在了他们的公共父类Symbol中,Symbol包含如下方法:
isImplicit、isAbstract、isPrivate….等方法,从上面的描述中可以看到Symbol用来刻画scala抽象语法树的构成成分,将这些成分使用统一的Symbol特质描述,这跟Java类型泛型系统定义的如下类(定义在java.lang.reflect包中)类似:Constructor、Type….
最后通过一个简单的例子来演示一下Symbol的用法
1 | import scala.reflect.runtime.universe._ |
这里不清楚为什么x.asTerm.isVal也是false,需要研究一下
Typesymbol、Type与TypeRef
Java由于类型擦除特性导致编译时的参数在运行时会丢失,scala通过TypeTag隐式对象将编译时的类型参数信息维持到了运行时,也就是说,scala通过TypeTag解决了Java编译时的类型参数信息在运行时丢失的问题。
Scala使用Type、TypeRef表示运行时的类型信息,可以看成是加强版的Java Class对象,这里说的Tyoe对应到Symbol中的TypeSymbol
- 析构TypeRef
TypeRef是Type的子类,TypeRef一个case class,当我们获取到一个Type对象时,这个对象通常也是TypeRef类型,因为TypeRef时case class,因此可以使用模式匹配的方式对其进行析构(获取TypeRef内部构造信息),TypeRef的析构函数的定义如下:
1 | def unapply(tpe: TypeRef): Option[(Type, Symbol, List[Type])] |
可以看到,对TYpeRef进行析构将得到一个三元组,(Type, Symbol, List[Type]),这个三元组的意思在ScalaDoc定义了一些示例:
1 | T # C[T_1,.....,T_n] TypeRef(T, C, List(T_1, .... , T_n)) |
元组的第一个元素是类型的路径类型,比如对于p.C,类型C的路径是p,这里通过p.type得到路径p的类型
元组的第二个元素是类型本身,具体的是TypeSymbol
元组的第三个元素是类型的参数类型列表,每个参数类型本身也是Type类型
- 根据类型的到Type对象(或者TypeRef对象)
通过runtime.Universe定义的typeOf方法获取指定类型的Type对象
- 无类型参数
1 | class A |
得到的结果是:
1 | defined class A |
- 类型嵌套
1 | import scala.reflect.runtime.universe._ |
对应结果如下:
1 |
|
- 两个类型参数
1 | val tpe2 = typeOf[Map[String, Int]] |
析构上面的Type,对应的结果如下
1 | tpe2: reflect.runtime.universe.Type = Map[String,Int] |
- 根据对象得到该对象对应类型的Type对象
1 | def getType[T: TypeTag](obj: T): Type = typeOf[T] |
需要注意的是,上下文界定的T: TypeTag是必须的,typeOf方法要求上下文存在一个类型为TypeTag[T]的隐式对象
- 类型关系比较
Type定义了两个函数用于判断类型关系,=:=用于判断两个Type是否相等,类似于Java Class对象的 ==,<:<用于判断两个Type是否存在继承关系,类细于Java Class对象的isAssignableFrom方法。
为什么Scala需要定义两个看上去像是运算符的方法来判断类型关系呢,这是由于Scala的特性,使用Java定义的方法会出现误判的情况。
Mirror
Mirror的字面意思是镜像、映像,可以认为是从某个视角对某个事物的反应,这个事物时本尊,Mirror都是本尊的副本、影子、映像等。在Scala反射系统里面,如何理解Mirror可以有两种解读思路:
- 沿着刚才的思路,Mirror可以认为是从某个视角对事物的反映,那么在Scala反射系统里,这里的视角就是ClassLoader,而事物时ClassPath上的类,不同的ClassLoader可能会加载ClassPath上相同的或者不同的类,因此Mirror可以看成是运行时被某个ClassLoader加载起来的类,他们是ClassPath上所有class的一种映像。
- Reflect (或者Reflection)和Mirror都有反映的意思,比如,to reflect、to mirror,所以Mirror在这里是一个动词,可以理解成反射动作,可以把Mirror理解成Reflect。
Mirror是从ClassLoader的视角对ClassPath上的类的映像,因此只有有了ClassLoader才能获取一个runtime Mirror(scala的反射系统分为Runtime Mirror和compile time Mirror),如下代码定义了一个case class Person,然后使用Person类的类加载器获取一个Mirror对象,这个Mirror对象是通过调用在runtime.universe中定义的runtimeMirror得到的:
1 | case class Person(name: String, age: Int, salary: Int) { |
有了上面的Mirror,就可以进行反射操作了,反射动作包含如下几个:
- ClassMirror:通过Mirror的reflectClass方法可以对一个Class进行反射,获得一个ClassMirror对象,这个ClassMirror对象可以用来获得构造方法对应的MethodMirror
- InstanceMirror:通过Mirror的reflect方法可以对一个实例(Instance)进行反射,获得一个InstanceMirror对象,通过该对象可以获得MethodMirror、FieldMirror,他们用来对方法和成员变量进行反射
- MethodMirrior:使用MethodMirror可以对方法进行反射调用,类似Java的Method.invoke方法,不同于Java的Method类,Java的Method是不包含该方法所属对象的,当调用Method.invoke方法,该方法作用在哪个对象上是作为invoke方法传入的。
- FieldMirror:使用FieldMirror可以对成员变量进行反射,包括get和set方法
- ModuleMirror:使用了Mirror的reflectModule方法,可以对object进行反射,如下是使用上面提到的Mirror的详细例子:
1 | case class Person(name: String, age: Int, salary: Int) { |
对应结果如下:
1 | defined class Person |