你好,游客 登录
背景:
阅读新闻

第7章 Scala 面向对象基础

[日期:2021-09-27] 来源:  作者: [字体: ]

本文来自艾叔编著的《零基础快速入门Scala》免费电子书,添加文末艾叔微信,获取完整版的PDF电子书

第7章  Scala 面向对象基础

本章介绍Scala面向对象方面的基础知识,包括类、Trait、泛型、类的上界与下界、以及协变和逆变等。

7.1  

  1. 类和对象基本概念

面向对象中,类和对象是相伴相生的概念。

类:是对一类事物共有特性的抽象,比如说,“人”,这是一个类,它是抽象的,我们提到“人”时,并没有一个具体对应的实物,但是,走在大街上的男男女女,都称之为“人”,因为他们都拥有共同的特性。

对象:是符合类特征的实物,如果说“人”是类的话,那么大街上的男男女女,就是一个个的对象。

Scala中的类,本质上和上面的类是一样的,不同的是,它有一整套规则(Scala语法)来描述特性(属性和方法),它就像一个模板,或者是蛋糕店中的一个个模具。而对象则是符合类的特征描述,按照模板描述,在计算机中创建的一个个实例,在Scala代码中可以直接操作这些实例,这就好像使用模具制作出来的一个个蛋糕。

  1. 类定义

定义类的例子代码如下,定义了一个Class Person

1行是package名;

3行定义了Class Person,其中class是关键字,位于开头的位置,每个Class定义都必须要的;PersonClass名,注意:Class名首字母要大写,另外,在一个scala代码文件中,可以定义多个Class,代码文件的命名通常取这些Class中最有代表性的Class名字,本例中,只有1Class,自然,scala代码文件名就是Person.scalaPerson后面的括号是参数列表,有3个参数:idnamegender(性别),这3个参数在Person创建时,就要传入值,Person对象创建后,还可以通过该对象来访问这3个参数,它们就是Person的类成员变量,参数前面使用val修饰,表明该变量不可修改,如果用var修饰,则该变量可以被修改,如果什么都不加,也可以,但该变量只能在类内部使用,无法通过对象引用访问此变量;参数列表后是一对大括号,大括号里面可以定义Person类的成员变量和方法;

3~5行,大括号里面是Person类的实现部分,可以根据需要定义Person类的成员变量和方法。

      1 package examples.idea.scala

      2

      3 class Person(val id: String, val name: String, val gender: Int) {

      4

      5 }

创建Person对象时,从第3行传参开始,代码依次向下执行,也可以把这部分执行的代码称为Class的主构造函数。

下面的代码演示了如何通过Class创建对象,并且访问对象中的成员变量,这是在另外一个代码文件ClassExp.scala中实现的。

1行,使用new关键字创建了一个Person对象,创建时,传入idnamegender参数,per是指向该对象的引用,称为对象引用;

3~9行,使用对象引用来访问对象内部的成员变量idnamegender,具体方式是:对象引用名++成员名。

      1 val per = new Person("320101201003120016", "Mike", 1)

      2

      3 println("id " + per.id)

      4 println("name " + per.name)

      5 val gender = per.gender match {

      6   case 1 => "man"

      7   case _ => "female"

      8 }

      9 println("gender " + gender)

  1. 类继承/扩展(extends

Scala的类继承例子代码如下。

1行,定义了一个Student类,它继承/扩展了PersonStudentPerson的子类,PersonStudent的父类。Student的前3个参数:idnamegender继承自Person,第4个参数stuID则是新成员变量,那么,如何区分它们呢?1. 继承的参数名要和父类中参数名相同,且前面不能有val/var修饰;2. 新成员变量的名字不能和父类参数名重名,如果前面有val/var修饰,则该变量可以通过对象引用访问,如果没有val/var修饰,则只能被类内部使用。继承/扩展使用extends关键字,后面跟父类Person的名字,Person后续的传参,则用Student中所继承的参数变量来传值;

1~3行,大括号内的内容即Student类的实现部分,可以在这定义新的成员变量和方法。

      1 class Student(id: String, name: String, gender: Int, val stuID: String) extends Person (id, name, gender){

      2

      3 }

创建子类对象时,会先创建父类,即执行父类的构造函数,然后才执行子类的构造函数;

在子类中,可以通过super来引用父类,this表示当前类。

  1. 访问控制修饰符

Scala使用privateprotected作为类成员变量和方法的修饰符,用于控制这些变量可以被哪些代码所访问。此外,public也是很多面向对象语言常用的访问控制修饰符,Scalapublic作为默认的修饰符,即变量和方法前不加任何修饰符,就默认是public

控制修饰符的知识点如下。

1)private修饰的变量/方法,只能在当前类的内部使用,例子代码如下。

3行定义了一个新的成员变量ageprivate修饰符放在最前面,age可以被第4行以后,Person类的结束之前的代码所访问;

5~7行,定义了一个嵌套类PersonInfo,第6行定义了一个函数getAge,此函数访问了外部变量age

8行是Person类实现的结束位置,之后的代码不能访问age

      1 class Person(val id: String, val name: String, val gender: Int) {

      2

      3   private var age:Int = 0

      4

      5   class PersonInfo {

      6     def getAge = {age}

      7   }

      8 }

注意

  • age使用private修饰,只能在当前类的内部使用,确切的说,是age定义之后的代码,Person结束之前的代码段所访问;
  • age不能通过对象引用所访问,也就是说创建一个对象Personper是指向该对象的引用,使用per.age来访问age会报错;
  • age不能被Person的子类所访问,Student继承自Person,在Student的实现代码中,访问age会报错。

 

2)protected修饰的变量/方法,可以在当前类的内部使用,也可以在子类的代码中使用。

例如,将上面的第5行代码

      3   private var age:Int = 0

修改为

      3   protected var age:Int = 0

在子类Student的代码中,可以直接访问age

但是,在PersonStudent的对象引用中,还是无法访问age

 

3)如果变量/方法前没有privateprotected修饰,默认修饰符是public,则此变量/方法可以在当前类的内部使用,也可以在子类的代码中使用,也可以被对象引用所使用。

7.2  重写(override

  1. override基本使用

如果父类A定义了一个方法fun,子类B要对fun做一个不同的实现(方法名、输入参数不变),可以使用override关键字来实现这个功能。

面向对象向中,把这个称为重写。如果子类B中的fun方法输入参数发生了改变,则不叫override,而是overload重载了。

例子代码如下。

1行,定义了父类Person

2行,定义了一个可变成员变量ageInt类型,初始值为0

3行,定义了一个getAge方法,返回age的值;

6行,定义了Person子类Student

7行,将override关键字放在开头,重写getAge方法,返回age+1

      1 class Person(val id: String, val name: String, val gender: Int) {

      2   var age:Int = 0

      3   def getAge() = {age}

      4 }

      5

      6 class Student(id: String, name: String, val stuID: String, gender: Int) extends Person (id, name, gender){

      7   override def getAge() = {age + 1}

      8 }

注意

  • Student中,getAge方法前,如果不加override,即使方法名、输入参数、返回值都不变,编译也会报错;
  • Student中,getAge如果改变返回值类型,编译会报错,无法通过修改返回值来实现重载。
  • 如果要在第7行的函数体中,引用父类的getAge方法,该如何写呢?使用super即可,代码如下。

  override def getAge() = {super.getAge() + 1}

  1. 调用override方法

重写函数的调用,例子代码如下。

1行,创建Student对象,赋值给stu对象引用;

2行,设置Student对象的age17

3行,将stu赋值给指向父类Person的引用per

4行,调用per.getAge(),正常情况下,如果per指向Person对象的话,应该返回17,但是,因为per指向的是Student对象,而且Student对象中,重写了getAge方法,因此,会调用Student中的getAge方法,返回18

      1 val stu = new Student("320101201003120016", "Mike", "10001", 11)

      2 stu.age = 17

      3 val per:Person = stu

      4 println(per.getAge())

Scala支持父类引用(per)指向子类对象(stu),如果子类重写了父类的方法,那么,父类引用调用这些方法时,会自动调用重写后的方法。这个特性在面向对象中非常有用,假设,A是父类,BCD都是A的子类,A10种方法,BCD对这10种方法都进行了重写,如果一开始就可以确定对象类型,例如是BC或者D,那么,后续方法调用就无需判断,会直接调用对应子类的方法,如果没有这个特性,在每次方法调用之前,都要判断到底是哪个对象,然后才能选择对应的方法进行调用,代码的模块性和逻辑性会受到严重影响,编码麻烦且容易出错;

大名鼎鼎的设计模式中工厂方法就利用了这个特性,利用父类,对外提供统一接口,而实现时,则根据不同的类型,生成不同的子类对象,赋给父类变量。这样,在上层的逻辑使用时,只需要调用统一的接口,不需要每次都判断具体的类型,而调用对应的函数了。1次判断,n次调用,替代:n次判断,n次调用

  1. 禁止方法被override

如果要禁止某个方法被重写,只需在def前面加上final即可,例如,在前面PersongetAge函数前面加上final即可。

      3   final def getAge() = {age}

这样,Student中重写getAge()会编译报错。

7.3  单例对象/伴生对象(singleton object/Companion objects

  1. 单例对象

单例对象是指,程序中某个Class自始至终只有一个实例(对象),此Class的所有操作都是通过这一个实例(对象)来完成。

根据上面的定义,Scala中的object就是一个天然的单例对象(singleton object)。

例子代码如下,Account是一个object,它是由系统自动创建的一个实例,在整个系统中,Account是唯一的,因此,Account是一个单例对象(singleton object)。

所有的object都是单例对象(singleton object),但是object并不一定只用于单例对象,它还可以用来实现静态函数、伴生对象等。

1行,定义了一个Account objectobject是一个由系统自动创建的实例(唯一的),第1~12{}的内容为object的实现代码,Account用于管理程序中的用户;

2行,定义一个变量userList,用于存储用户信息,所使用的数据结构是mutable.HashMapKeyString类型,存储用户名,Value也是String类型,存储密码;

5~10行,实现了插入用户的逻辑,判断userList中是否存在该用户(利用HashMap的特性,判断Key是否存在),如果存在,则返回0,如果不存在,则向userList中插入该用户信息,并返回1

      1 object Account {

      2   private val userList = new HashMap[String, String]()

      3   def createUser(user: String, passwd: String) = {

      4     val rs = userList.contains(user)

      5     if(rs==true){

      6       0

      7     }else{

      8       userList.put(user, passwd)

      9       1

     10     }

     11   }

     12 }

单例对象的使用。

3行,import单例对象Account的方法createUser,这样,后面的代码可以直接写方法名createUser进行调用,而不需要在前面再加Account。如果import examples.idea.scala.Account,则需要调用Account.createUser

7~8行,调用createUser创建用户,因为使用的同一个实例,会保存前面的信息,因此,如果用户已经存在,就会返回1

      1 package examples.idea.scala

      2

      3 import examples.idea.scala.Account.createUser

      4

      5 object ObjectExp {

      6   def main(args: Array[String]): Unit = {

      7     println(createUser("Mike", "123456"))

      8     println(createUser("Mike", "234567"))

      9   }

     10 }

单例对象的应用场景

1)用于共享

利用单例对象,可以在程序中共享常用的数据类型、结构、和方法,这些定义可以被所有代码访问,而且由于单例的原因,可以确保只有1份,不会冲突。

典型的例子,如scala.Predef就是典型的单例对象,它定义了常用的数据结构和类型,如MapSetString等,还定义了常用的方法,如Println等。scala.Predef的这些定义,可以共享给程序的所有代码,又由于scala.Predef只有1个实例,因此,不会产生不一致的现象。

2)用于程序中只需要一个实例(对象)的情景

上面的代码,就定义了一个Account单例对象,用于管理程序中的所有用户,由于Acounnt只有1个实例,所有的用户创建都通过第3createUser接口,因此,不会出现不一致的现象。

  1. 伴生对象

如果objectClass同名,object可以称为Class的伴生对象(Companion object),Class也可以称为此object的伴生类(Companion class)。

伴生对象object和伴生类Class必须定义在同一Scala文件中;伴生对象和伴生类可以互相访问对方的private成员。

伴生对象的应用场景

1)用来统一对象创建的接口

Array为例,如果要创建一个Int类型的数组,并赋初值,例子代码如下。

val numList = Array(1,2,3,4)

如果要创建一个Char类型的数组,并赋初值,例子代码如下。

val numList = Array('1','2','3','4')

上面的代码是如何实现的呢?有两个问题:

  1. 为什么创建Array对象时,没有用new

这是因为在定义Array类的时候,还定义了Array伴生对象(在同一代码文件内)。上面的Array(1,2,3,4)实际上调用的是伴生对象中的apply方法,代码如下。

def apply(x: Int, xs: Int*): Array[Int] = {

apply的函数体中,再创建Array对象,代码如下。

val array = new Array[Int](xs.length + 1)

  1. 为什么创建Int数组和Char数组的形式一样?

如果使用new Array创建的话,创建Int数组的代码如下。

val numList = new Array[Int](4)

for(i<-0 until numList.length)numList(i) = i+1

创建Char数组的代码如下。

val numList = new Array[Char](4)

for(i<-0 until numList.length)numList(i) = ('1'+i).toChar

再对比前面直接赋值创建的代码,为什么前面的代码不管数据类型,都可以统一用Array(x,xx)的形式来创建呢?

这是因为,在Array伴生对象中,根据不同的初始值,定义了不同的apply函数,利用Scala重载特性,自动判别类型,进行不同的操作。

当代码为Array(1, 2, 3, 4)时,Scala会根据初始值的类型,自动选择下面的apply函数。

def apply(x: Int, xs: Int*): Array[Int] = {

当代码为Array(1,2,3,4)时,Scala会根据初始值的类型,自动选择下面的apply函数。

def apply(x: Char, xs: Char*): Array[Char] = {

2)用来实现类的静态方法

Scala语言不支持Class中直接定义静态(Static)方法。所有在Class中定义的方法,必须要创建该Class对象后,才能调用。

利用伴生对象,可以很方便地实现类的静态方法。例如,创建多维数组的函数ofDim,是一个典型的静态函数,因为要调用它才能创建多维数组对象,因此,在对象创建前,就要能够调用ofDim

scala> val nums = Array.ofDim[Int](3,4)

ofDim放到Array伴生对象中,代码如下,这样,无需创建Array对象,就可以直接调用Array.ofDim,而且还利用了伴生对象的特性,objectClass的名字相同,这样就很方便地实现了Array的静态方法。

def ofDim[T: ClassTag](n1: Int, n2: Int): Array[Array[T]] = {

3)实现隐式转换

这个内容在下节会详细讲述。

7.4  隐式转换和隐式参数

  1. 隐式转换

Scala的隐式(implicit)转换,是Scala中比较独特和神奇的功能:例如,一个函数的参数类型是String,但是传入Array[Int]类型的参数却没有问题,一个指向String的引用,可以调用String中根本没有定义的方法,等等,所有这些,都是拜隐式转换所赐。

1)类型自动转换

函数printStr1个参数,是String类型,为什么传入一个Array[Int],也可以调用呢?让我们细细看来。

首先,定义了一个printStr方法,参数是s:String,代码如下。

      1 def printStr(s: String)={

      2   println(s)

      3 }

调用printStr时,传参类型为String,直接传入Array类型参数,编译会报错,怎么办?

方法一:将Array按规则转为Str传参,如果Array有很多个,每个都要写一遍代码进行转换,或者写成转换函数,那每次传参前,要调用转换函数,很麻烦;

方法二:printStr使用重载,但是,如果printStr是第三方库提供的,没有源码,那会比较麻烦。

利用隐式转换,可以轻松解决上面的问题。

例子代码如下,在def前加implicit关键字,定义一个arrayToStr方法(方法名可以自己定),arrayToStr就是一个隐式转换方法,它的参数是Array[Int]类型,返回值是String类型。

    implicit def arrayToStr(ar: Array[Int]) = {"str is " + ar.mkString(" ")}

隐式转换函数一定要定义在object中。

直接传参,Scala编译器会检查printStr参数类型为String,而此时传入的是Array[Int],它会查找隐式转换函数,即implicit修饰的函数,可以找到arrayToStr,它的输入参数是Array[Int],返回值是String,正好符合条件,因此调用arrayToStr,将numList转换成String类型,作为参数传入printStr,从而实现类型自动转换。

    val numList = Array(1, 2, 3, 4)

    printStr(numList)

利用隐式转换,实现类型自动转换,工作放到了幕后,代码变得简洁。初次接触,会觉得逻辑似乎不通,明白原理后,又有豁然开朗的感觉。编译器的智能化,使得代码越来越接近人的思维方式,而反过来,用计算机思维去理解代码,却又越发困难。

2)扩展已有类库接口

有一个第三方提供的类MathOrigin,提供了加法add接口,现在要在不修改MathOrigin源码的情况下,给MathOrigin增加乘法mul接口。

调用函数代码如下(ImplicitExp.scala),第3行调用了mul方法,这个方法在MathOrigin中实际上是没有的。

      1     val math = new MathOrign()

      2     println("add " + math.add(1, 2))

      3     println("mul " + math.mul(1, 2))

利用隐式转换函数,可以很方便地做到这个。

1)定义一个扩展类

文件名是MathExtend.scala,代码如下。

3~5行,定义了扩展类MathExtend,所有MathOrigin的扩展接口都在此类中定义和实现;

7~9行,定义了一个object MathExtendObjobject的名字没有特别的说法,可以和Class MathExtend同名,也可以不同名),定义一个隐式转换函数extendMath,它的输入参数是MathOrigin类型,返回值是MathExtend实例,这就告诉编译器,当MathOrigin的引用调用一个MathOrigin不存在的方法mul时,自动查找此隐式调用函数,查找返回值MathExtend中是否有mul函数,如果有,则调用该实例的mul函数。

      1 package examples.idea.scala

      2

      3 class MathExtend {

      4   def mul(num1:Int, num2:Int) = {num1*num2}

      5 }

      6

      7 object MathExtendObj{

      8   implicit def extendMath(m: MathOrign) = new MathExtend()

      9 }

如果MathOrigin还有其它的类来扩展接口,即不止有MathExtend,还有MathNewExtend也扩展了MathOrigin,那么,只需要在第8行下面,再增加一个隐式转换函数,implicit def extendMathNew(m: MathOrigin) = MathNewExtend()即可;

扩展类所扩展的方法之间不能冲突,即,方法名、参数列表中的数据类型不能同时相等。例如MathExtend扩展的mul方法,参数列表的数据类型依次是(Int, Int),则MathNewExtend中就不能有一个方法,名字为mul,参数列表数据类型也都是(Int, Int),这样,编译会报错。

2)在调用mul方法前,引入对应的隐式转换函数

1行,引入隐式转换函数extendMath,注意import一定要引到函数名;

2行,math.mul实际上调用的是MathExtend中的mul方法。

      1  import examples.idea.scala.MathExtendObj.extendMath

      2  println("mul " + math.mul(1, 2))

上面的例子,演示了在不修改第三方库源码的基础上(很多情况下,我们拿不到第三方库的源码,但是,为了维持一致性,又不得不在此基础上进行扩展),扩展第三方库接口,利用的是Scala的隐式转换功能,隐式转换可以说是Scala比较神奇和独特的功能,也非常实用;

同样的,如果我们发现一个方法调用并不在对应的Class中,例如String中的take函数,就要考虑是否是隐式转换,Scala.Predef中就有很多隐式转换函数;

3)隐式类

Scala2.10以后,引入了隐式类的新特性。

隐式类是指在class前面用implicit修饰的类,它的主构造函数可以用于隐式转换。

例子代码如下。

1行,在class前使用了implicit关键字修饰,表明这是一个隐式类。类的名字为IpArray,参数是String类型。IpArray(ip: String)是主构造函数,它也是一个隐式转换函数,当调用String上的某个方法fun时,如果funString中没有定义,编译器会查找ipArray中是否有此方法?如果有,则自动创建isArray对象,并调用此方法;

2行,定义了一个getIpArray方法,它可以按点分割IP地址(点分十进制表示的IP,例如192.168.0.18),然后,将分割出来的每个部分转换成Int,存入���个数组,并返回。

      1 implicit class IpArray(ip: String){

      2   def getIpArray() = {ip.split('.').map(s=>s.toInt)

      3 }

调用代码如下。

"192.168.0.18".getIpArray().foreach(println)

192.168.0.18”是String类型,String中并没有getIpArray函数,当调用getIpArray时,编译器会找到前面的隐式类IpArrayIpArray的主构造函数的输入参数s正好是String类型,因此,会以“192.168.0.18”为参数,创建IpArray对象,然后调用getIpArray,解析192.168.0.18,将其按点分割,存入Array,并返回。

隐式类和前面隐式函数相比,将要扩展的接口(getIpArray)和隐式函数(原来需要在object中单独定义一个隐式转换函数,输入参数是String类型,返回值是IpArray)合并到了一起,省去了创建对象的过程(原来代码,在函数体中需要new IpArray),代码上更为简单。

 

  1. 隐式参数

隐式参数用于函数调用,它和参数的默认值类似,但更加灵活。

如果在函数的参数前使用implicit修饰,则称该参数为隐式参数。

在调用该函数时,如果不对该隐式参数传参,Scala会在当前作用域和隐式参数伴生对象的作用域中查找,是否有该类型的变量,且该变量前面也使用了implicit修饰,如果符合条件,则使用该变量,作为此次函数调用的参数,示例如下。

先定义一个类,它将作为隐式参数的类型。

scala> class Province(val pro: String)

定义函数,使用柯里化,province使用implicit修饰,它是隐式参数。

scala> def printStuInfo(name: String)(implicit province: Province){println("stu info " + name + " " + province.pro)}

创建Province对象,并使用implicit修饰,它将作为隐式参数的默认值

scala> implicit val hunan = new Province("hunan");

调用printStuInfo,只传入第一个参数name,不传入provinceScala将在当前作用域,查找是否有类型为Province,且使用implicit 修饰的引用,hunan满足条件,因此,将它作为此次调用的参数,结果如下。

scala> printStuInfo("mike")

stu info mike hunan

当然,也可以自己传参,这样就不会使用隐式参数

scala> printStuInfo("mike")(new Province("jiangsu"))

stu info mike jiangsu

7.5  特质(trait

Scala中的trait,中文翻译为特质,主要用在2个方面:1. 当做接口(Interface)用;2. 在需要多重继承的情况下使用。

  1. trait用作接口

在面向对象编程中,类B10个方法,类C20个方法,如果想要把这两个类的方法合并成一组统一的方法,供外部调用,该怎么办?

2种方法

方法一:使用多重继承,新建一个类AA继承(扩展)B,同时A也继承C,这样,A当中就有B10个方法和C20个方法了,C++支持多重继承,可以使用这种方法,而Java认为多重继承可能会导致冲突,因此无法使用方法一;

方法二:使用接口(Interface),接口是一组方法的集合,它只描述了方法,即只有方法名、参数列表和返回值类型,没有方法实现,方法实现由实现该接口的Class完成。定义一个接口BIBI中的方法就是B的所有方法(但没有实现);定义一个接口CICI中的方法就是C的所有方法。定义接口AIAI扩展BICI,这样,在AI当中,就有了BI10个方法和CI20个方法,这样就实现了BC中方法的聚合,但此时还无法调用,因为AI中没有实现,因此,需要定义class A, A再来实现AI的所有方法,这样,创建A的对象,就可以供外部调用了。接口不会导致冲突,例如BICI都有一个方法hello由于BICIhello是一样的,而且没有实现,因此在AI中不会冲突,而在Ahello只有1份实现,因此,也不会冲突。当然,也有1种情况,如果B中的helloC中的hello返回值类型不一样的话,会有冲突,编译会报错。Java支持接口,因此可以使用方法二。

ScalatraitJava中的接口并不完全一样,它的概念更宽泛、功能更强大和灵活。在实践中,可以把trait当成接口来用,这是trait的应用场景之一。

trait可用于内部模块间的接口、函数库对外的接口、以及第三方调用自身(比如程序中要用到第三方库,在写调用代码时,不要直接调用,而是新建一个接口,将第三方库封装到接口的实现中,这样,即使第三方库有变化,如版本更新,或者换别人的版本,上层的调用不需要动),这样可以将变化控制在自己可控的范围内。

Trait用作接口的例子代码如下,定义了两个traitArithmaticLogic

1~5行,为trait Arithmatic,定义了2个方法addsay,只有方法定义,没有实现,这样的方法,称为抽象方法,方法前面也不需要abstract来修饰;

6~9行,为trait Logic,也定义了2个抽象方法andsay

      1 trait Arithmatic{

      2   def add(num1: Int, num2: Int):Int

      3   def say():Unit

      4 }

      5

      6 trait Logic{

      7   def and(num1: Int, num2: Int):Int

      8   def say():Unit

      9 }

trait不能像class一样,有构造函数的参数列表,例如,Arithmatic后面不能有括号和参数;

trait中定义的方法,是可以有实现(函数体)的,但在trait当方法用的时候,不加函数体。

定义一个Class,聚合和实现ArithmaticLogic中的所有方法。

1行,定义Class MathLib,使用extends关键字(不是implementsJava使用implements),来扩展(继承)ArithmaticLogic的方法,使用withextends多个trait,这样,MathLib中就有了ArithmaticLogic的所有方法,达到了类似多重继承的效果(当然,只是继承方法定义,没有继承实现);

2~3行,重写所继承的方法的实现,其中add方法来自Arithmaticand方法来自Logicsay方法在ArithmaticLogic中都有,但是没有关系,因为sayArithmaticLogic的定义完全相同,而且没有实现,实现在MathLib中,只有一份,不会冲突。

      1 class MathLib extends Arithmatic with Logic {

      2   def add(num1: Int, num2: Int): Int = {num1 + num2}

      3   def and(num1: Int, num2: Int): Int = {num1&num2}

      4   def say() = {println("Hello!")}

      5 }

如果class中实现的是trait中的抽象方法(只有定义没有实现的方法),那么class中的方法前可以不加override,上例中,addArithmatic只有定义,没有实现,是抽象方法,因此,在MathLib中实现add方法时,可以不加override

调用

1行,创建MathLib对象,赋值给m引用;

2~4行,分别调用addandsay方法。

      1 val m = new MathLib

      2 println(m.add(1, 2))

      3 println(m.and(5, 7))

      4 m.say()

Scala中,一个类,只能有1个父类,但是可以扩展(继承)多个trait

Class必须实现trait中的所有函数,否则编译会报错。

同样的,class MathLib实现了ArithmaticLogic的所有方法,如果只想提供Arithmatic接口供外界调用,或者只提供Logic供外界调用,可使用下面的代码。

1行,创建MathLib对象,它包含了trait方法的实现,是一定要创建的;

2行,类型转换,将m赋值给Arithmatic类型的trait ariInterface,这是可以的;

3行,调用ariInterface对外提供的方法addsay,这样就实现了外部接口的控制。

      1 val m = new MathLib()

      2

      3 val ariInterface:Arithmatic = m

      4 println(ariInterface.add(1, 2))

      5 ariInterface.say()

      6

      7 val logInterface:Logic = m

      8 println(logInterface.and(5, 7))

      9 logInterface.say()

7~9行,演示了利用trait实现对外提供Logic接口的代码。

  1. trait用作class

Trait作为接口(interface)使用时,定义的都是抽象函数,实际上,trait中的方法是可以有自己的实现的,因此,trait也可以像class一样使用。

例子代码如下能

1行,定义了一个trait Arithmatic

2行,定义了方法add,并实现。

      1 trait Arithmatic{

      2   def add(num1: Int, num2: Int)= {num1+num2}

      3 }

调用

1行,创建MathLib对象,赋值给引用m

2行,调用m.add方法,此方法是在trait中定义和实现的。

      1 val m = new Arithmatic {}

      2 println(m.add(1, 2))

总结

  • Trait中的方法,可以实现,这样trait可以像class一样使用;
  • Trait的构造函数不能有参数列表,以Arithmatic为例,它的后面不能跟括号带参数;
  • 创建trait对象,后面跟的是大括号,例如new Arithmatic {},不是小括号()
  • Trait也可以有自己的成员变量,用法和class一样。
  1. Trait实现多重继承

Trait自身是支持多重继承的,例子代码如下。

1行,定义了trait Arithmatic

2行,定义了add方法,并实现;

3行,定义了say方法,并实现。

6行,定义了trait Logic

7行,定义了and方法,并实现;

8行,定义了say方法,并实现。

      1 trait Arithmatic{

      2   def add(num1: Int, num2: Int) = {num1 + num2}

      3   def say() = {println("Hello Arithmatic!")}

      4 }

      5

      6 trait Logic{

      7   def and(num1: Int, num2: Int) = {num1&num2}

      8   def say():Unit = {println("Hello Logic!")}

      9 }

定义一个新的trait,继承(扩展)上面的两个trait

1行,定义了trait MathLib,使用extends,继承(扩展)了LogicArithmatic,如果.extends多个trait,使用with

2行,重写了say()方法,如果不重写的话,LogicArithmatic都有say()方法和实现,会冲突。super表示Arithmatic,取最后一个with右边的trait,如果想要使用Logicsay(),就把Logic放到with的右边。

      1 trait MathLib extends  Logic with Arithmatic{

      2  override def say(): Unit = super.say()

      3 }

调用

1行,创建MathLib对象;

2行,调用add方法,它的实现位于Arithmatic中能;

3行,调用and方法,它的实现位于Logic中;

4行,调用say方法,它的实现就在MathLib中,调用了Arithmatic中的say方法。

      1 val m = new MathLib {}

      2 println(m.add(1, 2))

      3 println(m.and(5, 7))

      4 m.say()

总结

  • Trait支持多重继承,在需要多重继承的场合,可以考虑使用trait,而不是class
  • Trait中重名方法会冲突,解决办法,在子trait中重写该方法。

总之,trait可以认为是功能更为强大、更灵活的class:它既可以当接口用,同时又支持自身实现方法;既可以当类class用,还支持多重继承;traitclass之间还可以混用。因此,traitclass之间会有很多种用法,需要在实践中慢慢总结和积累。

7.6  泛型

所谓泛型,就是将数据类型作为一个参数进行传递,写入函数或者类之中。

泛型的作用:1. 对代码的进一步抽象,简化代码;2. 实现了代码的进一步复用。

  1. 泛型在函数上的使用

先来看一个例子,任务描述如下。

编写sum方法,分别求Array[Int]Array[Char]Array[String] 3种类型数组的和。其中Array[Int]的和为各元素相加的和;Array[Char]Array[String]的和为各元素拼接后的结果,各元素间用空格隔开。

代码如下,采用重载实现。

      1 def sum(ar: Array[Int]) = {ar.sum}

      2 def sum(ar: Array[Char]) = {ar.mkString(" ")}

      3 def sum(ar: Array[String]) = {ar.mkString(" ")}

调用代码如下

      1 val numList = Array(1, 3, 5, 7)

      2 println(sum(numList))

      3

      4 val charList = Array('1', '3', '5', '7')

      5 println(sum(charList))

      6

      7 val strList = Array("1", "3", "5", "7")

      8 println(sum(strList))

运行结果

      16

      1 3 5 7

      1 3 5 7

以上代码为每种类型定义了一个sum函数,调用时,通过重载来自动判断所调用的具体函数,例如,传入的数据是Array[Int],则调用sum(ar: Array[Int])函数,如果传入的是Array[Char],则调用sum(ar: Array[Char])函数。

这样做的好处是简单,直接。不好的地方是,不够灵活,如果sum方法名或者参数发生变化,则所有的sum方法都要修改,如果有100sum,则100sum方法都要修改,麻烦且容易出错。

怎么办?

观察sum方法,可以发现,它们之间的差异只是Array中元素的类型不同而已。借助泛型,将Array元素的类型抽取成一个参数,可以统一sum方法,代码如下。

1行,定义了sum方法,sum后面的[T],就是泛型,将后续所用到的类型用T表示,参数列表中,ar是一个T类型数组,返回值也是T

2~7行,使用match,判断ar数组的第一个元素的类型,根据不同的类型,进行不同的sum计算,注意,sum计算前,要使用asIntanceOf转换成具体的类型,这样才好进行sum计数,返回最终的和;

8行,将sum结果rs转换成T类型,返回。

      1 def sum[T](ar: Array[T]):T={

      2   val rs = ar(0) match {

      3     case Int => ar.asInstanceOf[Array[Int]].sum

      4     case Char => ar.asInstanceOf[Array[Char]].mkString(" ")

      5     case String => ar.asInstanceOf[Array[String]].mkString(" ")

      6     case _ => null

      7   }

      8   rs.asInstanceOf[T]

      9 }

调用代码,和前一种方法的调用代码完全一样,Scala编译器会根据输入参数,自动推断T类型,因此不需要显式地声明T类型。

      1 val numList = Array(1, 3, 5, 7)

      2 println(sum(numList))

      3

      4 val charList = Array('1', '3', '5', '7')

      5 println(sum(charList))

      6

      7 val strList = Array("1", "3", "5", "7")

      8 println(sum(strList))

 

总结

  • 如果代码的逻辑相同,只是处理的对象类型不同,可以使用泛型统一处理方法;
  • 泛型就是将类型用参数表示,如上例中的T,泛型跟在函数名后,用中括号[]扩起来,如果有多个泛型,则用逗号隔开,泛型一般用单个大写字母表示;
  • 调用泛型函数时,编译器可以自动推断类型,当然,如果不使用自动推断,也可以显式地声明(在中括号中显式地声明类型);
  • 泛型处理中,如果涉及类型转换,可以使用asInstanceOf,如果涉及类型判断,可以用isInstanceOf或者mat case语句。
  1. 泛型在类上的使用

如果不使用泛型,class中各个类型的都是明确的,也就是说,是固定在代码中的。

使用泛型后,可以将class中不需要固定的类型提取出来,放到class后面,用参数代替。这样,class的创建可以适应不同的数据类型,更加灵活。

例子代码如下

1行,定义了一个class OP,后面[T]表示泛型,用T来代表一种数据类型;

2行,定义了add方法,add2个参数rl,它们的类型是T,是在class中所定义的泛型;

3~9行,根据不同的类型,进行add运算,返回结果,具体运算时,要先使用asInstanceOf将泛型转换成具体类型,然后计算和,返回值时,使用asInstaceOf[T],将其转换成T

      1 class OP [T] {

      2   def add(l: T, r: T) :T = {

      3     val rs = l match {

      4       case d:Int => d + r.asInstanceOf[Int]

      5       case d:Char => d.toString + " " + r.asInstanceOf[Char].toString

      6       case d:String=> d + " " + r.asInstanceOf[String]

      7       case _ => null

      8     }

      9     rs.asInstanceOf[T]

     10   }

     11 }

调用代码如下

注意:第147行,创建OP对象,泛型在classOP后面的中括号传入

      1 val opInt = new OP[Int]()

      2 println(opInt.add(1, 3))

      3

      4 val opChar = new OP[Char]()

      5 println(opChar.add('1', '3'))

      6

      7 val opString = new OP[String]()

      8 println(opString.add("1", "3"))

 

总结

  • 泛型声明位于class名后的中括号[]内部;
  • 如果有多个泛型,使用逗号隔开;
  • 如果class后有参数列表,次序是:class+ [泛型] + (参数列表)。
  1. 泛型在Scala中无处不在

泛型在Scala的基本数据结构、API接口中用得非常多,可以说无处不在。

下面我们来看几个例子。

1)Arrayforeach方法

Foreach用来遍历Array中的每个元素,进行相应处理,Foreach的定义如下。

Foreach的输入参数是一个函数ff的输入参数类型是AA是在Array创建时传入的泛型,它表示Array中每个元素的类型,f的返回值是UU也是泛型,声明就在foreach后面的[]中。

def foreach[U](f: A => U): Unit = {

2)Arraymap方法

Map的功能是对Array中的元素映射为一个新元素,最后返回新元素的Array,新元素的类型在调用之前,是无法确定的,因此,适合使用泛型。

map后面的[]内声明了BThat两个泛型,mapArray中的元素映射成其它的数据类型,B就是映射后的数据类型;

Map后面有2个参数列表,这里用了函数的柯里化技术;

第一个参数列表中的参数是一个函数f,定义如下:A=>Bf的输入参数类型是AA也是一个泛型,A的声明没有在map函数,而是在class声明(实际上是trait)的后面,其实就是new Array[A]Array后面[]内的泛型,它表示的是Array中每个元素的类型,也是f输入参数类型,f依次接受Array中的每个元素,将每个元素映射成类型为B的结果;

第二个参数列表定义的是隐式参数,CanBuildFrom是一个trait,它和class一样,也可以使用泛型,后面中括号[]就有3个泛型,分别是ReprBThat

map的返回值That也是一个泛型。

def map[B, That](f: A => B)(implicit bf: CanBuildFrom[Repr, B, That]): That

7.7  上界、下界

  1. 上界和下界的定义

在定义函数或者类时,有时需要指定泛型的范围。所谓泛型的范围,是指传入的类型是当前泛型的子类还是父类。

下界定义:有泛型A,那么A的父类B,可以表示为B >: A,用一个大于号+冒号表示,称B的下界为A,当然,此处B也包括A自身。

上界定义:有泛型A,那么A的所有子类B,可以表示为B <: A,用一个小于号+冒号表示,称B的上界是A,当然,B也包括A自身。

  1. 上界使用例子

例子代码如下

先定义4个类,BiosomePersonStudentTeacher,其中BiosomePerson的父类(super class),PersonStudentTeacher的父类。

      1 class Biosome {

      2   def say() = {println("Hello, I am a Biosome")}

      3 }

      4

      5 class Person(val id: String, val name: String) extends  Biosome {

      6   override def say() = {println("Hello, I am "  + name)}

      7 }

      8

      9 class Student(id: String, name: String, stuId: Int) extends Person(id, name){

     10   override def say(): Unit = {println("Hello, I am an student, my name is " + name)}

     11 }

     12

     13 class Teacher(id: String, name: String, workId: Int) extends Person(id, name){

     14   override def say(): Unit = {println("Hello, I am an teacher, my name is " + name)}

     15 }

定义一个方法sayHello,使用了泛型T,设置了T的上界为Person,这样T表示Person的子类(包括Person自身)。

      1 def sayHello [T<:Person] (per: T) = {

      2   per.say()

      3 }

根据边界原理,下面的调用代码是没有问题的。

      1 sayHello(new Person("320101201003210028", "mike"))

      2 sayHello(new Student("320101201003210028", "mike", 10002))

      3 sayHello(new Teacher("320101201003210028", "mike", 10002))

而下面的代码,超出了Person边界,尽管它也有say方法,但编译会报错。

sayHello(new Biosome())

总结

  • 使用边界,可以调用边界内类的的方法,如果不使用边界,泛型将可以用任何类型,这样,很多方法调用不了;
  • 使用边界,可以将传参的类型,控制在一定范围。

 

  1. 使用界来限定泛型是否实现了某个接口

使用边界还可以来限定泛型是否实现了某个接口,例如限定泛型是否实现了Comparable接口。

例子1代码如下

1行,定义了diff方法,T <: Comparable[T]表示,T实现了Comparable接口;

2行,因为l实现了Comparable接口,Comparable接口中包含equals方法,因此,可以直接调用equals方法。

      1 def diff [T <: Comparable[T]] (l: T, r: T): Boolean ={

      2     l.equals(r)

      3 }

例子2是一个完整演示,包括trait接口限定、接口的定义与实现等。

1~3行,定义了带泛型的trait接口Compare,定义了一个compto方法,传入参数target的类型为T

5行,定义了class Person,它实现了trait Compare

6行,重写了compto方法;

9行,定义了class Animal,它也实现了trait Compare

10行,重写了compto方法;

      1 trait Compare [T] {

      2   def compto(target: T):Boolean

      3 }

      4

      5 class Person(val id: String, val name: String) extends Compare[Person] {

      6   override def compto(target: Person): Boolean = {id==target.id}

      7 }

      8

      9 class Animal(val id: String) extends Compare[Animal] {

     10   override def compto(target: Animal): Boolean = {id==target.id}

     11 }

定义diff方法,使用边界( [T <: Compare[T]])来确保输入参数必须实现trait Compare

      1 def diff [T <: Compare[T]] (l: T, r: T): Boolean ={

      2   l.compto(r)

      3 }

调用代码,第3行和第7行,传入的参数分别是PersonAnimal,虽然它们是不同的class,但它们都实现了Compare接口,因此,都符合传参要求,可以调用。

      1 val p1 = new Person("320101201003210028", "mike")

      2 val p2 = new Person("320101201003210029", "tom")

      3 println(diff(p1, p2))

      4

      5 val a1 = new Animal("100001")

      6 val a2 = new Animal("100001")

      7 println(diff(a1, a2))

 

7.8  型变(协变、逆变、不变)

型变(Variance)描述了复杂类型的子类型关系之间的相关性,以及组合类型的子类型关系之间的相关性(这句话比较拗口,后面会针对每种型变有专门的解释)(https://docs.scala-lang.org/tour/variances.html)。

在类型系统中,型变可以使得我们在复杂类型之间构建直观连接,促进类抽象的复用。

型变分为三种:协变(Convariance)、逆变(Contravariance)和不变(Invariance),后面会详细解释。(参考:https://typelevel.org/blog/2016/02/04/variance-and-functors.htmlhttps://docs.scala-lang.org/tour/variances.html)。

  1. 协变(Covariance

协变定义:对于泛型类C [T],如果ST的子类(S <: T),C[S]也是C[T]的子类(C[S] <: C[T]),则称C(的类型参数)是协变的。

协变的表示:在泛型前加一个+号,例如C[+T],就表示C是协变的。

List是一个典型的协变例子,List定义如下,其中[+A]表明List是协变的,也就是说,如果类AB的子类,那么List[A]也是List[B]的子类。

sealed abstract class List[+A] extends AbstractSeq[A]

List协变特性的例子代码如下

首先构建3个类,用于创建对应的List

1行,定义类Person,构造函数有2个参数:idname,类型都是String

2行,定义了say方法;

5行,定义类Student,它是Person的子类,增加了一个参数stuId,类型为Int

6行,重写父类的say方法;

9~11行,定义类Teacher,它是Person的子类,重写say方法。

      1 class Person(val id: String, val name: String) {

      2   def say() = {println("Hello, I am "  + name)}

      3 }

      4

      5 class Student(id: String, name: String, stuId: Int) extends Person(id, name){

      6   override def say(): Unit = {println("Hello, I am an student, my name is " + name)}

      7 }

      8

      9 class Teacher(id: String, name: String, workId: Int) extends Person(id, name){

     10   override def say(): Unit = {println("Hello, I am an teacher, my name is " + name)}

     11 }

创建基于StudentPerson对象的List

      1 val stuList = List(new Student("320101200002110321", "mike", 10001), new Student("320101200002120322", "rose", 10002))

      2 val teacherList = List(new Teacher("410101200002110323", "tom", 400001), new Teacher("410101200002120322", "gim", 400001))

因为Student <: Person,根据List的协变特性,List[Student] <: List[Person],同理,List[Teacher] <: List[Person]

因此,可以定义一个统一的接口,来处理这两个Person子类的List,例子代码如下。

1行,定义printInfo方法,泛型A的上界是Person,即APerson的子类,参数lst的类型是List[A]

2行,由于APerson的子类,因此,都有say方法,因此,遍历lst,调用say方法。

      1 def printInfo[A <:Person](lst: List[A]) = {

      2   lst.foreach(_.say())

      3 }

调用,使用同一个接口,来处理stuListteacherList

      1 printInfo(stuList)

      2 printInfo(teacherList)

结论

  • 如果看到一个class后面泛型前面有+号,例如 class A[+T],则说明A具有协变特性:如果S <: T,则A[S] <: A[T]
  • List是协变的,如果S <: T,则List[S] <: List[T],如果有一种处理方法,利用了S <: T的特性,那么,这种方法同样可以应用到List[S]List[T]上。

 

Array不是协变的

可以用反证法,假设Array是协变的,创建stuAr数组,代码如下。

val stuAr = Array(new Student("320101200002110321", "mike", 10001), new Student("320101200002120322", "rose", 10002))

由于Array是协变的,Student <: Person,则Array[Student] <: Array[Person],因此,下面的代码是成立的。

val perAr:Array[Person] = stuAr

这样,perAr(0)就是Person引用类型,且可以被赋值,那么,下面的代码是成立的。

perAr(0) = new Teacher("410101200002110323", "tom", 400001)

但实际上,perAr(0)stuAr(0)stuAr(0)的类型是Student,不能被赋值Teacher对象。

因此,Array不是协变的,其主要原因是,Array中的元素是可更改的,而List中的元素是不可被更改的。

  1. 逆变(Covariance

逆变定义:对于泛型类C [T],如果ST的子类(S <: T),C[S]也是C[T]的子类(C[S] >: C[T]),则称C(的类型参数)是逆变的。

逆变表示:在泛型前加一个-号,可以表示逆变,例如C[-T],就表示C是逆变的。

例子代码如下,同样的,先定义2个类PersonStudent,其中StudentPerson的子类。

      1 class Person(val id: String, val name: String) {

      2   def say() = {"Hello, I am "  + name}

      3 }

      4

      5 class Student(id: String, name: String, stuId: Int) extends Person(id, name){

      6   override def say(): String = {"Hello, I am an student, my name is " + name}

      7 }

定义一个类PrintInfo,它的类型参数T是逆变的,且T的上界是Person,定义并实现了getInfo方法。

      1 class PrintInfo [-T <: Person] {

      2   def getInfo(p: T):String = p.say()

      3 }

创建Person对象,使用PrintInfo[Person]对象引用perPrint,打印Person信息。

      1 val per = new Person("320101200002110321", "mike")

      2 val perPrint = new PrintInfo [Person]

      3 println(perPrint.getInfo(per))

再创建一个Student对象,使用perPrint,打印Student信息。

2行,声明一个PrintInfo[Stuent]引用stuPrint,并将perPrint赋值给它,因为Student <: Person,而PrintInfo中的T是逆变的,因此,PrintInfo[Student] >: PrintInfo[Person],因此,stuPrint可以指向它的子类(PrintInfo[Stuent])对象,即perPrint

      1 val stu = new Student("320101200002110321", "mike", 10001)

      2 val stuPrint:PrintInfo[Student] = perPrint

      3 println(stuPrint.getInfo(stu))

  1. 不变(Invariance

泛型类C [T]既不协变,也不逆变,称为不变(Invariance)。T前面什么都不加,既没有+号,也没有-号,因此C [T]默认是不变。

加艾叔微信,加入Linux(Shell+Zabbix)、大数据(Spark+Hadoop)、云原生(Docker+Kubernetes)技术交流群

 

关注艾叔公众号,获取更多一手信息

 



收藏 推荐 打印 | 阅读:
相关新闻