面向对象编程

面向对象编程

1. 面向过程和面向对象

面向过程和面向对象都是对软件分析、设计、开发的一种思想。

面向过程(Procedure-oriented)面向对象(Object-oriented

procedure [prəˈsiːdʒər] 步骤 oriented [ˈɔːrientɪd] 以……为方向的;重视……的

面向过程和面向对象

早期先有面向过程思想,随着软件规模的扩大,问题复杂性的提高,面向过程的弊端越来越明显的显示出来,出现了面向对象思想并成为目前主流的方式。两者都贯穿于软件分析、设计和开发各个阶段,对应面向对象就分别称为面向对象分析(OOA Object-Oriented Analysis)、面向对象设计(OOD Object-Oriented Design)和面向对象编程(OOP Object Oriented Programming)。C语言是一种典型的面向过程语言,Java是一种典型的面向对象语言,Kotlin也是面向对象的。

oriented [ˈɔːrientɪd] 以……为方向的 analysis [əˈnæləsɪs] 分析 programming [ˈproʊɡræmɪŋ] 设计,规划;编制程序,[计] 程序编制

1.1 面向过程

面向过程以“过程”为中心,把要实现的目的分为几个步骤,用函数实现,再按顺序调用。

面向过程

举个例子,下五子棋,面向过程的设计思路是首先分析解决这个问题的步骤:

(1)开始游戏(2)黑子先走(3)绘制画面(4)判断输赢(5)轮到[白子(6)绘制画面(7)判断输赢(8)返回步骤(2)(9)输出最后结果。

下棋

用函数实现上面一个一个的步骤,然后在下五子棋的主函数里依次调用上面的函数:

下五子棋 {
	开始游戏();
	黑子先走();
	绘制画面();
	判断输赢();
	轮到白子();
	绘制画面();
	判断输赢();
	返回到 黑子先走();
	输出最后结果;
}

可见,面向过程始终关注的是怎么一步一步地判断棋局输赢的,通过控制代码,从而实现函数的顺序执行。

1.2 面向对象

在日常生活或编程中,简单的问题可以用面向过程的思路来解决,直接有效,但是当问题变得复杂时,面向过程的思想是远远不够的,所以就出现了面向对象的编程思想。

不同于面向过程的语言,面向对象的语言是可以创建类,类就是对事物的一种封装。 比如说人、汽车、房 屋、书等任何事物,都可以将它封装一个类,类名通常是名词。而类中又可以拥有自己的字段和函数,字段表示该类所拥有的属性, 比如说人可以有姓名和年龄,汽车可以有品牌和价格,这些就属于类中的字段,字段名通常也是名词。而函数则表示该类可以有哪些行为, 比如说人可以吃饭和睡觉,汽车可以驾驶和保养等,函数名通常是动词。

通过这种类的封装,就可以在适当的时候创建该类的对象,然后调用对象中的字段和函数来满足实际编程的需求,这就是面向对象编程最基本的思想。当然,面向对象编程还有很多其他特性,如继承、多态等,但是这些特性都是建立在基本的思想之上的。

面向对象是以“对象”为中心,把要解决的问题分发给各个对象,每个对象都有自己的属性和行为。

面向对象

在下五子棋的例子中,用面向对象的方法来解决的话,首先将整个五子棋游戏分为三个对象:

(1)黑白双方,这两方的行为是一样的。

(2)棋盘系统,负责绘制画面

(3)规则系统,负责判定犯规、输赢等。

然后赋予每个对象一些属性和行为:第一类对象(黑白双方)负责接受用户输入,并告知第二类对象(棋盘系统)棋子布局的变化,棋盘系统接收到了棋子的变化,并负责在屏幕上面显示出这种变化,同时利用第三类对象(规则系统)来对棋局进行判定。

可以看出,面向对象是以功能来划分问题,而不是以步骤解决。比如绘制画面这个行为,在面向过程中是分散在了多个步骤中的,可能会出现不同的绘制版本,所以要考虑到实际情况进行各种各样的简化。而面向对象的设计中,绘图只可能在棋盘系统这个对象中出现,从而保证了绘图的统一。

1.3 比较

面向过程性能比面向对象高。因为创建对象的开销比较大,消耗资源。 所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix等,一般采用面向过程开发。 但是,面向过程没有面向对象易维护、易复用、易扩展。

但是,面向对象的封装性强,易扩展,易维护,可复用,还可以降低代码的耦合度。

面向过程和面向对象

那为什么面向过程性能比面向对象高呢?面向过程也需要分配内存,计算内存偏移量,Java性能差的主要原因并不是因为它是面向对象语言,而 是因为Java是半编译语言,最终的执行代码并不是可以直接被CPU执行的二进制机器码。而面向过程语言大多都是直接编译成机器码在电脑上执行,并且其它一些面向过程的脚本语言性能也并不一定比Java好。

解释下Java的编译与解释并存的现象。当.class字节码文件通过JVM转为机器可以执行的二进制机器码时,JVM类加载器首先加载字节码文件,然后通过解释器逐行进行解释执行,这种方式的执行速度相对比较慢。而且有些方法和代码块是反复被调用的(也就是所谓的热点代码),所以后面引进了JIT编译器,而JIT属于运行时编译。当JIT编译器完成一次编译后,会将字节码对应的机器码保存下来,下次可以直接调用。这也解释了我们为什么经常会说Java是编译与解释共存的语言。

2 类

2.1 创建一个类

JavaKotlin中都使用class关键字来声明一个类:

class Bird { }

可以在这个类中加入字段和函数来丰富它的功能:

class Bird {
    val weight: Double = 500.0
    var color: String = "blue"
    val age: Int = 1

    fun fly() { } // 全局可见
}

将以上代码反编译成Java代码(Tools -> Kotlin -> Show Kotlin Bytecode -> Decompile),如下所示:

public final class Bird {
   private final double weight = 500.0D;
   @NotNull
   private String color = "blue";
   private final int age = 1;

   public final double getWeight() {
      return this.weight;
   }

   @NotNull
   public final String getColor() {
      return this.color;
   }

   public final void setColor(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.color = var1;
   }

   public final int getAge() {
      return this.age;
   }

   public final void fly() {
   }
}

可以看出,虽然Kotlin中类声明的语法非常近似Java,但也存在很多不同:

  • 不可变属性成员。Kotlin支持用val在类中声明引用不可变的属性成员,这是利用Java中的final修饰符来实现的,使用var声明的属性则反之引用可变;
  • 属性默认值。因为Java的属性都有默认值,比如int类型的默认值为0,引用类型的默认值为null,所以在声明属性的时候不需要指定默认值。而在Kotlin中,除非显式地声明延迟初始化,不然就需要指定属性的默认值;
  • 不同的可访问修饰符。Kotlin类中的成员默认是全局可见,而Java的默认可见域是包作用域,因此在Java版本中,我们必须采用public修饰才能达相同的效果;
2.2 构造类的对象

Bird类已经定义好了,接下来对这个类进行实例化,代码如下所示:

val b = Bird()

Kotlin中实例化一个类的方式和Java是基本类似的,只是去掉了new关键字。 之所以这么设计,是因为当调用了某个类的构造函数时,只可能是对这个类进行实例化。Kotlin本着最简化的设计原则,将诸如new、行尾分号这种不必要的语法结构都取消了。

上述代码将实例化后的类赋值到了b这个变量上面,b就可以称为Bird类的一个实例,也可以称为一个对象。下面对b进行一些操作:

val b = Bird()
b.weight = 100.0 // 报错:Val cannot be reassigned
b.color = "yellow"
b.fly()

简单概括一下,就是要先将事物封装成具体的类,然后将事物所拥有的属性和能力分别定义成类中的字段和函数,接下来对类进行实例化,再根据具体的编程需求调用类中的字段和方法即可。

当前并没有给Bird类传入任何参数。现实中,可能因为需要传入不同的参数组合,而在类中创建多个构造方法,在Java中这是利用构造方法重载来实现的:

public class Bird {
    private double weight;
    private int age;
    private String color;

    public Bird(double weight) {
        this.weight = weight;
    }

    public Bird(double weight, int age) {
        this.weight = weight;
        this.age = age;
    }

    public Bird(double weight, int age, String color) {
        this.weight = weight;
        this.age = age;
        this.color = color;
    }
  	...
}

Java中的这种方式存在两个缺点:

  • 如果要支持任意参数组合来创建对象,那么需要实现的构造方法将会非常多;
  • 每个构造方法中的代码会存在冗余,如前后两个构造方法都对agecolor进行了相同的赋值操作;

Kotlin通过引入新的构造语法来解决这些问题。

2.2.1 构造方法默认参数

Kotlin中可以给构造方法的参数指定默认值,从而避免不必要的方法重载:

class Bird(val weight: Double = 0.00, val color: String = "blue", val age: Int = 1) {
    ...
}

val bird1 = Bird(color = "black")
val bird2 = Bird(weight = 1000.00, color = "black")

需要注意的是,由于参数默认值的存在,在创建一个类对象时,最好指定参数的名称,否则必须按照实际参数的顺序进行赋值。

Bird类中可以用val或者var来声明构造方法的参数。这一方面代表了参数的引用可变性,另一方面它也使得我们在构造类的语法上得到了简化。事实上,构造方法的参数名前可以没有valvar,带上它们之后就等价于在Bird类内部声明了一个同名的属性,可以用this来进行调用。 比如前面定义的Bird类就类似于以下的实现:

class Bird(weight: Double = 500.0, color: String = "blue", age: Int = 1) {

    val weight: Double
    val age: Int
    val color: String

    init {
        this.weight = weight
        this.age = age
        this.color = color
    }
    ...
}
2.2.2 init语句块

Kotlin引入了一种叫作init语句块的语法,它属于构造方法的一部分,两者在表现形式上却是分离的,但是,所有主构造函数中的逻辑都可以写在里面。Bird类的构造方法在类的外部,它只能对参数进行赋值。如果需要在初始化时进行其他的额外操作,那么就可以使用init语句块来执行。比如:

class Bird(weight: Double, color: String, age: Int) {
    init {
        println("do some other things")
        println("the weight is $weight")
    }
}

因此,构造函数的参数在没有valvar修饰的时候,可以在init语句块被直接调用。另外,构造函数的参数还可以用于初始化类内部的属性成员的情况。如:

class Bird(weight: Double = 0.00, color: String = "blue", age: Int = 0) {
    val weight: Double = weight
    val color: String = color
    val age: Int = age
}

除此之外,不能在其他地方使用。以下是一个错误的用法:

class Bird(weight: Double = 0.00, color: String = "blue", age: Int = 0) {
    fun printWeight(){
        println(weight) // unresolved reference: weight
    }
}

事实上,构造方法还可以拥有多个init,它们会在对象被创建时按照从上到下的顺序先后执行。看看以下代码的执行结果:

class Bird(weight: Double = 0.00, color: String = "blue", age: Int = 0) {
    val weight: Double
    val age: Int
    val color: String

    init {
        this.weight = weight
        println("The bird's weight is ${this.weight}")
        this.age = age
        println("The bird's age is ${this.age}")
    }

    init {
        this.color = color
        println("The bird's color is ${this.color}")
    }
}

fun main() {
    val bird = Bird(1000.0, "blue", 2)
}

// The bird's weight is 1000.0
// The bird's age is 2
// The bird's color is blue

可以发现,多个init语句块有利于进一步对初始化的操作进行职能分离,这在复杂的业务开发中显得特别有用。

再来思考一种场景,现实中我们在创建一个类对象时,很可能不需要对所有属性都进行传值。其中存在一些特殊的属性,比如鸟的性别,我们可以根据它的颜色来进行区分,所以它并不需要出现在构造方法的参数列表中。

有了init语句块的语法支持,我们很容易实现这一点。假设黄色的鸟儿都是雌性,剩余的都是雄鸟,我们就可以如此设计:

class Bird(val weight: Double = 0.00, val color: String = "blue", val age: Int = 0) {
    val sex: String

    init {
        this.sex = if (this.color == "yellow") "male" else "female"
    } 
}

我们再来修改下需求。这一次我们并不想在init语句块中对sex直接赋值,而是调用一 个专门的printSex方法来进行,如:

class Bird(val weight: Double, val color: String, val age: Int) {
    val sex: String // Property must be initialized or be abstract

    fun printSex() {
        this.sex = if (this.color == "yellow") "male" else "female" // Val cannot be reassigned
        println(this.sex)
    }
}

结果报错了,主要由以下两个原因导致:

  • 正常情况下,Kotlin规定类中的所有非抽象属性成员都必须在对象创建时被初始化值;
  • 由于sex必须被初始化值,上述的printSex方法中,sex会被视为二次赋值,这对val声明的变量来说也是不允许的;

2个问题比较容易解决,我们把sex变成用var声明,它就可以被重复修改。关于第1个问题,最直观的方法是指定sex的默认值,但这可能是一种错误的性别含义;另一种办法是引入可空类型,即把sex声明为String?类型,则它的默认值为null。这可以让程序正确运行,然而实际上也许我们又不想让sex具有可空性,而 只是想稍后再进行赋值,所以这种方案也有局限性。

2.2.3 延迟初始化:by lazylateinit

更好的做法是让sex能够延迟初始化,即它可以不用在类对象初始化的时候就必须有值。Kotlin中,主要使用lateinitby lazy这两种语法来实现延迟初始化的效果。

如果这是一个用val声明的变量,可以用by lazy来修饰:

class Bird(val weight: Double, val color: String, val age: Int) {
    val sex: String by lazy {
        if (color == "yellow") "male" else "female"
    }
}

by lazy语法的特点如下:

  • 该变量必须是引用不可变的,而不能通过var来声明;
  • 在被首次调用时,才会进行赋值操作,一旦被赋值,后续它将不能被更改;

lazy的背后是接受一个lambda并返回一个Lazy<T>实例的函数,第一次访问该属性时,会执行lazy对应的Lambda表达式并记录结果,后续访问该属性时只是返回记录的结果。

另外,系统会给lazy属性默认加上同步锁,也就是LazyThreadSafetyMode.SYNCHRONIZED,它在同一时刻只允许一个线程对lazy属性进行初始化,所以它是线程安全的。若能确认该属性可以并行执行,没有线程安全问题,那么可以给lazy传递 LazyThreadSafetyMode.PUBLICATION参数。还可以给lazy传递LazyThreadSafetyMode.NONE参数,这将不会有任何线程方面的开销,也不会有任何线程安全的保证。 比如:

val sex: String by lazy(LazyThreadSafetyMode.PUBLICATION) { // 并行模式
  if (color == "yellow") "male" else "female"
}

val sex: String by lazy(LazyThreadSafetyMode.NONE) { // 不做任何线程保证也不会有任何线程开销
  if (color == "yellow") "male" else "female"
}

lazy不同,lateinit主要用于var声明的变量,然而它不能用于基本数据类型,如IntLong等,需要用Integer这种包装类作为替代。 利用lateinit解决之前的问题:

class Bird(val weight: Double, val color: String, val age: Int) {
    lateinit var sex: String // 可以延迟初始化

    fun printSex() {
        this.sex = if (this.color == "yellow") "male" else "female"
        println(this.sex)
    }
}

fun main() {
    val bird = Bird(1000.0, "blue", 2)
    bird.printSex() // female
}

总而言之,Kotlin并不主张用Java中的构造方法重载,来解决多个构造参数组合调用的问题。取而代之的方案是利用构造参数默认值及用valvar来声明构造参数的语法,以更简洁地构造一个类对象。

2.2.4 主从(次)构造方法

任何一个面向对象的编程语言都会有构造函数的概念,Kotlin中也有,但是Kotlin将构造函数分成了两种:主构造函数和次构造函数。

主构造函数是最常用的构造函数,每个类默认都会有一个不带参数的主构造函数,当然也可以显式地给它指明参数。主构造函数的特点是没有函数体,直接定义在类名的后面即可。 比如下面这种写法:

class Bird(val weight: Double = 0.00, val color: String = "blue", val age: Int = 1) {
}

其实几乎是用不到次构造函数的,Kotlin提供了一个给函数设定参数默认值的功能,基本上可以替代次构造函数的作用。

任何一个类只能有一个主构造函数,但是可以有多个次构造函数。次构造函数也可以用于实例化一个类,这一点和主构造函数没有什么不同,只不过它是有函数体的。

Kotlin规定,当一个类既有主构造函数又有次构造函数时,所有的次构造函数都必须调用主构造函数(包括间接调用)。 比如,可以把从构造方法A委托给从构造方法B,再将从构造方法B委托给主构造方法。举个例子:

class Bird(age: Int) {
    val age: Int

    init {
        this.age = age
    }

    constructor(timestamp: Long) : this(DateTime(timestamp)) // 构造函数A
    constructor(birth: DateTime) : this(getAgeByBirth(birth)) // 构造函数B
}

fun getAgeByBirth(birth: DateTime): Int {
    return Years.yearsBetween(birth, DateTime.now()).years
}

次构造函数是通过constructor关键字来定义的。

从构造方法的设计除了解决以上的场景之外,还有一个很大的作用就是可以对某些Java的类库进行更好地扩展,因为我们经常要基于第三方Java库中的类,扩展自定义的构造方法。典型的例子就是定制业务中特殊的View类。比如以下的代码:

class KotlinView : View {
    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
}

可以看出,利用从构造方法,我们就能使用不同参数来初始化第三方类库中的类了。

来看看这个新的构造方法是如何运作的:

  • 通过constructor方法定义了一个新的构造方法,被称为从构造方法。相应地,在类外部定义的构造方法被称为主构造方法。每个类可最多存在一个主构造方法和多个从构造方法,如果主构造方法存在注解或可见性修饰符,也必须像从构造方法一样加 上constructor关键字,如:
internal public Bird @inject constructor(age: Int) { ... }
  • 每个从构造方法由两部分组成。一部分是对其他构造方法的委托,另一部分是由花括号包裹的代码块。执行顺序上会先执行委托的方法,然后执行自身代码块的逻辑。

3 不同的访问控制原则

Java中,如果不希望一个类被其他的类继承或修改,就用final来修饰它。同时,还可以用publicprivateprotected等修饰符来描述一个类、方法或属性的可见性。对于Java的这些修饰符,在Kotlin中与其也是大同小异。最大的不同是,Kotlin在默认修饰符的设计上采用了与Java不同的思路。

3.1 限制修饰符

当想要指定一个类、方法或属性的修改或者重写权限时,就需要用到限制修饰符。继承是面向对象的基本特征之一,继承虽然灵活,但如果被滥用就会引起一些问题。比如说,Penguin(企鹅)也是一种鸟类:

open class Bird {
    open fun fly() {
        println("I can fly.")
    }
}

class Penguin : Bird() {
    override fun fly() {
        println("I can't fly actually.")
    }
}

Kotlin中,有两个和Java不一样的语法特性:

  • Kotlin中没有采用Java中的extendsimplements关键词,而是使用:来代替类的继承和接口实现;
  • 由于Kotlin中类和方法默认是不可被继承或重写的,所以必须加上open修饰符;

另外,Penguin类重写了父类中的fly方法,这其实是一种比较危险的做法,比如我们修改了Bird类的fly方法,增加了一个代表每天能够飞行的英里数的参数:

open class Bird {
    open fun fly(miles: Int) {
        println("I can fly $miles miles daily.")
    }
}

现在如果再次调用Penguinfly方法,那么就会出错,错误信息提示fly重写了一个不存在的方法:

Penguin出错

事实上,这是日常开发中错误设计继承的典型案例。子类应该尽量避免重写父类的非抽象方法,因为一旦父类变更方法,子类的方法调用很可能会出错,而且重写父类非抽象方法违背了面向对象设计原则中的“里氏替换原则”。

对里氏替换原则通俗的理解是:子类可以扩展父类的功能,但不能改变父类原有的功能。 它包含以下4个设计原则:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
  • 子类可以增加自己特有的方法;
  • 当子类的方法实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更严格;
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格;

然而,实际业务开发中我们常常很容易违背里氏替换原则,导致设计中出问题的概率大大增加。其根本原因,就是我们一开始并没有仔细思考一个类的继承关系。所以 《Effective Java》也提出了一个原则:要么为继承做好设计并且提供文档,否则就禁止这样做。

3.1.1 类的默认修饰符:final

Kotlin认为类默认开放继承并不是一个好的选择。所以在Kotlin中的类或方法默认是不允许被继承或重写的。Bird类为例:

class Bird {
    val weight: Double = 500.0
    val color: String = "blue"
    val age: Int = 1
    fun fly() {}
}

这是一个简单的类。把它编译后转换为Java代码:

public final class Bird {
   private final double weight = 500.0D;
   @NotNull
   private final String color = "blue";
   private final int age = 1;

   public final double getWeight() {
      return this.weight;
   }

   @NotNull
   public final String getColor() {
      return this.color;
   }

   public final int getAge() {
      return this.age;
   }

   public final void fly() {
   }
}

转换后的Java代码中的类,方法及属性前面多了一个final修饰符,由它修饰的内容将不允许被继承或修改。我们经常使用的String类就是用final修饰的,它不可以被继承。Java中,类默认是可以被继承的,除非主动加上final修饰符。而在Kotlin中恰好相反,默认是不可被继承的,除非主动加上可以继承的修饰符——open

现在,给Bird类加上open修饰符:

open class Bird {
    val weight: Double = 500.0
    val color: String = "blue"
    val age: Int = 1
    fun fly() {}
}

编译成Java代码:

public class Bird {
  ... 
}

此外,如果想让一个方法可以被重写,那么也必须在方法前面加上open修饰符。

3.1.2 类默认final的意义

不少人诟病默认final的设计会给实际开发带来不便。具体表现在:

  • 与某些框架的实现存在冲突。如Spring会利用注解私自对类进行增强,由于Kotlin中 类默认不能被继承,这可能导致框架的某些原始功能出现问题。
  • 更多的麻烦还来自于对第三方Kotlin库进行扩展。就统计层面讨论,Kotlin类库肯定会比Java类库更倾向于不开放一个类的继承,因为人总是偷懒的,Kotlin默认final可能会阻挠我们对这些类库的类进行继承,然后扩展功能。

Kotlin论坛甚至举行了一个关于类默认final的喜好投票,略超半数的人更倾向于把open当作默认情况。

以上的反对观点很有道理。下面再基于Kotlin的自身定位和语言特性重新反思一 下这些观点:

  • Kotlin当前是一门以Android平台为主的开发语言。在工程开发时,我们很少会频繁地继承一个类,默认final会让它变得更加安全。如果一个类默认open而在必要的时候忘记了标记final,可能会带来麻烦。反之,如果一个默认final的类,在我们需要扩展它的时候,即使没有标记open,编译器也会提醒我们,这个就不存在问题。此外,Android也不存在类似Spring因框架本身而产生的冲突。
  • 虽然Kotlin非常类似于Java,然而它对一个类库扩展的手段要更加丰富。典型的案例就是AndroidKotlin扩展库android-ktxGoogle官方主要通过Kotlin中的扩展语法对Android标准库进行了扩展,而不是通过继承原始类的手段。这也揭示了一点,以往在Java中因为没有类似的扩展语法,往往采用继承去对扩展一个类库,某些场景不一定合 理。相较而言,在Kotlin中由于这种增强的多态性支持,类默认为final也许可以督促我们思考更正确的扩展手段。

除了扩展这种新特性之外,Kotlin中的其他新特性,比如Smart Casts结合classfinal属性也可以发挥更大的作用。

Kotlin除了可以利用final来限制一个类的继承以外,还可以通过密封类的语法来限制一个类的继承。 比如可以这么做:

sealed class Bird {
    fun fly() = "I can fly"
    class Eagle : Bird()
}

Kotlin通过sealed关键字来修饰一个类为密封类,若要继承则需要将子类定义在同一个文件中,其他文件中的类将无法继承它。但这种方式有它的局限性,即它不能被初始化,因为它背后是基于一个抽象类实现的。 将它转换成Java代码:

public abstract class Bird {
   @NotNull
   public final String fly() {
      return "I can fly";
   }

   private Bird() {
   }

   // $FF: synthetic method
   public Bird(DefaultConstructorMarker $constructor_marker) {
      this();
   }
  
   public static final class Eagle extends Bird {
      public Eagle() {
         super((DefaultConstructorMarker)null);
      }
   }
}

密封类的使用场景有限,它其实可以看成一种功能更强大的枚举,所以它在模式匹配中可以起到很大的作用。

总的来说,我们需要辩证地看待Kotlin中类默认final的原则,它让我们的程序变得更加安全,但也会在其他场合带来一定的不便。最后,关于限制修饰符,还有一个abstract,用它修饰类说明这个类是抽象类,修饰在方法前面说明这个方法是一个抽象方法。Kotlin中的abstractJava中的完全一样。

KotlinJava的限制修饰符比较如表所示:

Koltin与Java的限制修饰符比较

3.2 可见性修饰符

除了限制类修饰符之外,还有一种修饰符就是可见性修饰符。如果想要指定类、方法及属性的可见性,就需要可见性修饰符。Kotlin中的可见性修饰符也与Java中的很类似。但也有不一样的地方,主要有以下几点:

  • KotlinJava的默认修饰符不同,Kotlin中是public,而Java中是default
  • Kotlin中有一个独特的修饰符internal
  • Kotlin可以在一个文件内单独声明方法及常量,同样支持可见性修饰符;
  • Java中除了内部类可以用private修饰以外,其他类都不允许private修饰,而Kotlin可以;
  • KotlinJava中的protected的访问范围不同,Java中是包、类及子类可访问,而Kotlin只允许类及子类;

Java中创建一个类时,编辑器会自动帮我们加上public,这是因为大多数类都可能需要在全局访问。而如果我们在定义类、变量或者方法的省略它的修饰符,会使用Java中的默认修饰符是default,它只允许包内访问。

Kotlin中有一个独特的修饰符internal,和default有点像但也有所区别。internalKotlin中的作用域可以被称作“模块内访问”。 那么到底什么算是模块呢?以下几种情况可以算作 一个模块:

  • 一个Eclipse项目
  • 一个Intellij IDEA项目
  • 一个Maven项目
  • 一个Grandle项目
  • 一组由一次Ant任务执行编译的代码

总的来说,一个模块可以看作一起编译的Kotlin文件组成的集合。 那么,Kotlin中为什么要诞生这么一种新的修饰符呢?Java的包内访问不好吗?

Java的包内访问中确实存在一些问题。举个例子,假如你在Java项目中定义了一个 类,使用了默认修饰符,那么现在这个类是包私有,其他地方将无法访问它。然后,你把它打包成一个类库,并提供给其他项目使用,这时候如果有个开发者想使用这个类,除了copy源代码以外,还有一个方式就是在程序中创建一个与该类相同名字的包,那么这个包下面的其他类就可以直接使用我们前面的定义的类。伪代码如下:

package com.example.kotlintest

// 定义的第三方类库代码
class TestDefault {
  	...
}

该类默认只允许com.example.kotlintest的包内可见,但是我们在项目中可以这么做:

package com.example.kotlintest

// 自身工程创建com.example.kotlintest
class Test {
   TestDefault td = new TestDefault();
   ...
}

这样我们就可以直接访问该类了。

Kotlin默认并没有采用这种包内可见的作用域,而是使用了模块内可见,模块内可见指的是该类只对一起编译的其他Kotlin文件可见。开发工程与第三方类库不属于同一个模块,这时如果还想使用该类的话只有复制源码一种方式了。这便是Kotlininternal修饰符的一个作用体现。

Java程序中,很少见到用private修饰的类,因为Java中的类或方法没有单独属于某个文件的概念。比如,我们创建了Rectangle.java这个文件,那么它里面的类要么是public,要么是包私有,而没有只属于这个文件的概念。Java中,若要用private修饰,那么这个只能是其他类的内部类。而Kotlin中则可以用private给单独的类修饰,它的作用域就是当前这个Kotlin文件。 比如:

package com.example.kotlintest

class BMWCar(val name: String) {
  private val bMWEngine = Engine("BMW")

  fun getEngine(): String {
    return bMWEngine.engineType() // error:Cannot access'enging Type': it is Protected in Engine 
  }
}

private class Engine(val type: String) {
  fun engineType(): String {
    return "the engine type is $type"
  }
}

除了private修饰符的差别,Kotlin中的protected修饰符也与Java有所不同。Java中的protected修饰的内容作用域是包内、类及子类可访问,而在Kotlin中,由于没有包作用域的概念,所以protected修饰符在Kotlin中的作用域只有类及子类。 我们对上面的代码稍加修改:

class BMWCar(val name: String) {
    private val bMWEngine = Engine("BMW")

    fun getEngine(): String {
        return bMWEngine.engineType() 
    }
}

private open class Engine(val type: String) {
    protected open fun engineType(): String {
        return "the engine type is $type"
    }
}

private class BZEngine(type: String) : Engine(type) {
    override fun engineType(): String {
        return super.engineType()
    }
}

我们可以发现同一包下的其他类不能访问protected修饰的内容了,而在子类中可以:

protected修饰符

总结一下,可见性修饰符在KotlinJava中大致相似,但也有自己的很多特殊之处。这些可见性修饰符比较如表所示:

Java和Kotlin函数可见性修饰符对照表

4 继承

继承是基于现实场景总结出来的一个概念。比如现在要定义一个Student类,每个学生都有自己的学号和年级,因此可以在Student类中加入snograde字段。但同时学生也是人 ,有姓名和年龄,需要吃饭,如果我们在Student类中重复定义nameage字段和eat()函数的话就显得太过冗余了。这个时候就可以让Student类去继承Person类,这样Student就自动拥有了Person中的字段和函数,另外还可以定义自己独有的字段和函数。

想要让Student类继承Person类,得做两件事才行。

第一件事,使Person类可以被继承。Java中,一 个类本身就是可以被继承的。但是,在Kotlin中任何一个非抽象类默认都是不可以被继承的(从2.1节中可知,相当于Java中给类声明了final关键字),之所以这么设计,和val关键字的原因差不多的,因为类和变量一样,最好都是不可变的,而一个类允许被继承的话,它无法预知子类会如何实现,因此可能就会存在一些未知的风险。Effective Java这本书中明确提到,如果一个类不是专门为继承而设计的,那么就应该主动将它加上final声明,禁止它可以被继承。

Kotlin在设计的时候遵循了这条编程规范,默认所有非抽象类都是不可以被继承的。之所以这里一直在说非抽象类,是因为抽象类本身是无法创建实例的,一定要由子类去继承它才能创建实例,因此抽象类必须可以被继承才行,要不然就没有意义了。(Kotlin中的抽象类和 Java中并无区别)

如果想让Person类变得可继承,在Person类的前面加上open关键字就可以了,如下所示:

open class Person { }

加上open关键字之后,就是在主动告诉Kotlin编译器,Person这个类是专门为继承而设计的,这样Person类就允许被继承了。

第二件事,要让Student类继承Person类。在Java中继承的关键字是extends,而在Kotlin中变成了一个冒号,写法如下:

class Student : Person() {
}

为什么Person类的后面要加上一对括号呢?这就涉及了Java继承特性中的一个规定,子类中的构造函数必须调用父类中的构造函数,这个规定在Kotlin中也要遵守。Kotlin采用了简单但是可能不太好理解的设计方式:括号。 子类的主构造函数调用父类中的哪个构造函数,在继承的时候通过括号来指定。

在这里,Person类后面的一对空括号表示Student类的主构造函数在初始化的时候会调用Person类的无参数构造函数,即使在无参数的情况下,这对括号也不能省略。

如果Student的主构造函数有参数:

class Student(val sno: String, val grade: Int) : Person() { }

如果将Person改造一下,将姓名和年龄都放到主构造函数当中:

open class Person(val name: String, val age: Int) { }

此时Student类一定会报错:

Student类报错

这里出现错误的原因也很明显,Person类后面的空括号表示要去调用Person类中无参的构造函数,但是Person类现在已经没有无参的构造函数了,所以就提示了上述错误。

如果想解决这个错误的话,就必须给Person类的构造函数传入nameage字段,但是,Student类的主构造函数中没有nameage,所以,需要给Student类的主构造函数中也要加上nameage这两个参数,再将这两个参数传给Person类的构造函数,代码如下所示:

class Student(val sno: String, val grade: Int, name: String, age: Int) : Person(name, age) { }

需要注意的是,在Student类的主构造函数中增加nameage这两个字段时,不能再将它们声明成val,因为在主构造函数中声明成val或者var的参数将自动成为该类的字段,这就会导致和父类中同名的nameage字段造成冲突。因此,这里的nameage参数前面我们加任何关键字,让它的作用域仅限定在主构造函数当中即可。

5 接口(可带有属性和默认方法)

接口是用于实现多态编程的重要组成部分。Java是单继承结构的语言,任何一个类最多只能继承一个父类,但是却可以实现任意多个接口,Kotlin也是如此。

接口中可以含有变量和方法。接口中的变量会被隐式地指定为public static final变量,而方法会被隐式地指定为public abstractpublic方法(Java 8支持接口方法默认实现)。 一般情况下不在接口中定义变量。

Java 8引入了一个新特性——接口方法支持默认实现。这使得我们在向接口中新增方法时候,之前继承过该接口的类则可以不需要实现这个新方法。 以下是Java 8版本的接口:

public interface Flyer {
    public String kind();

    default public void fly() {
        System.out.println("I can fly");
    }
}

接下来再来看看在Kotlin中如何声明一个接口:

interface Flyer {
    val speed: Int
    fun kind(): String?
    fun fly() {
        println("I can fly")
    }
}

Kotlin允许对接口中定义的函数进行默认实现。 同时,它还支持抽象属性(如上面的speed)。然而,Kotlin是基于Java 6的,那么它是如何支持这种行为的呢?将Kotlin声明的接口转换为Java代码,提取其中关键的代码:

public interface Flyer {
    int getSpeed();
    @Nullable
    String kind();
    void fly();
    
    public static final class DefaultImpls {
      public static void fly(@NotNull Flyer $this) {
        String var1 = "I can fly";
        boolean var2 = false;
        System.out.println(var1);
      }
    }
}

Kotlin编译器是通过定义了一个静态内部类DefaultImpls来提供fly方法的默认实现的。同时,虽然Kotlin接口支持属性声明,然而它在Java源码中是通过一个get方法来实现的。在接口中的属性并不能像Java接口那样,被直接赋值一个常量。 以下这样做是错误的:

interface Flyer {
    var height = 1000 // error Property initializers are not allowed in interfaces
}

Kotlin提供了另外一种方式来实现这种效果:

interface Flyer {
    var height: Int
        get() = 1000
        set(value) = TODO()
}

Kotlin接口中的属性背后其实是用方法来实现的,所以说如果要为变量赋值常量,那么就需要编译器原生就支持方法默认实现。但Kotlin是基于Java 6的,当时并不支持这种特性,所以不能像Java那样给一个接口的属性直接赋值一个常量。

以下是在Kotlin接口中如何定义一个普通属性:

interface Flyer {
    val speed: Int
}

它同方法一样,若没有指定默认行为,则在实现该接口的类中必须对该属性进行初始化。 总的来说,Kotlin的类与接口的声明和Java很相似,但它的语法整体上要显得更加简洁。

我们在接口中定义一系列的抽象行为,然后由具体的类去实现。下面还是通过具体的代码来学习一下:

class Bird(override val speed: Int) : Flyer {
  override fun kind(): String? { // 必须实现
    TODO("Not yet implemented")
  }

  override fun fly() { // 非必须实现
    super.fly()
  }
}

Java中继承使用的关键字是extends,实现接口使用的关键字是implements,而Kotlin中统一使用冒号,中间用逗号进行分隔。另外,接口的后面不用加上括号,因为它没有构造函数可以去调用。

当一个类去实现Flyer接口时,只会强制要求实现kind函数,而fly函数则可以自由选择实现或者不实现,不实现时就会自动使用默认的实现逻辑。

Kotlin中使用override关键字来重写父类或者实现接口中的函数。

val b = Bird(200)
b.fly() // I can fly

6 解决多继承问题

Java是不支持类的多继承的,Kotlin亦是如此。 为什么它们要这样设计呢?现实中,其实多继承的需求经常会出现,然而类的多继承方式会导致继承关系上语义的混淆。

6.1 骡子的多继承困惑

C++中的类是支持多重继承机制的。然而,C++中存在一个经典的钻石问题——骡子的多继承困惑。我们假设Kotlin的类也支持多继承,然后模仿C++中类似的语法,来看看它到底会导致什么问题:

abstract class Animal {
    abstract fun run()
}

open class Horse : Animal() {
    override fun run() {
        println("I am run very fast")
    }
}

open class Donkey : Animal() {
    override fun run() {
        println("I am run very slow")
    }
}

class Mule : Horse(), Donkey() {

}

这是一段伪代码,我们来分析下这段代码具体的含义:

  • 马和驴都继承了Animal类,并实现了Animal中的run抽象方法;
  • 骡子是马和驴的杂交产物,它拥有两者的特性,于是Mule利用多继承同时继承了HorseDonkey

目前看起来没有问题,然而当我们打算在Mule中实现run方法的时候,问题就产生了:Mule到底是继承Horserun方法,还是Donkeyrun方法?这个就是经典的钻石问题。可以通过继承关系图来更好地认识这个问题,如图所示:

钻石问题

所以钻石问题也被称为菱形继承问题。可以发现,类的多重继承如果使用不当,就会在继承关系上产生歧义。而且,多重继承还会给代码维护带来很多的困扰:一来代码的耦合度会很高,二来各种类之间的关系令人眼花缭乱。

于是,KotlinJava一样只支持类的单继承。那么,面对多重继承的需求,我们在Kotlin中该如何解决这个问题呢?

6.2 接口实现多继承

Kotlin中的接口与Java很相似,但它除了可以定义带默认实现的方法之外,还可以声明抽象的属性。来看看如何用Kotlin中的接口来实现多继承:

interface Flyer {
    fun kind() = "flying animals"
    fun fly() {
        println("I can fly")
    }
}

interface Animal {
    val name: String
    fun eat()
    fun kind() = "flying animals"
}

class Bird(override val name: String) : Flyer, Animal {
    override fun eat() {
        println("I can eat")
    }

    override fun fly() {
        println("I can fly")
    }

    override fun kind() = super<Flyer>.kind()
}

fun main() {
    val bird = Bird("sparrow")
    println(bird.kind()) // I can fly
}

Bird类同时实现了FlyerAnimal两个接口,但由于它们都拥有默认的kind方法,同样会引起上面所说的钻石问题。Kotlin提供了对应的方式来解决这个问题,那就是super关键字,我们可以利用它来指定继承哪个父接口的方法, 比如上面代码中的 super<Flyer>.kind()。当然我们也可以主动实现方法,覆盖父接口的方法。如:

override fun kind() = "a fly ${this.name}"

// a fly sparrow

通过这个例子,我们再来分析下实现接口的相关语法:

  • Kotlin中实现一个接口时,需要实现接口中没有默认实现的方法及未初始化的属性,若同时实现多个接口,而接口间又有相同方法名的默认实现时,则需要主动指定使用哪个接口的方法或者重写方法;
  • 如果是默认的接口方法,可以在实现类中通过super<T>这种方式调用它,其中T为拥有该方法的接口名;
  • 在实现接口的属性和方法时,都必须带上override关键字,不能省略;

除此之外,我们通过主构造方法参数的方式来实现Animal接口中的name属性。通过val声明的构造方法参数,其实是在类内部定义了一个同名的属性,所以我们当然还可以把name的定义放在Bird类内部:

class Bird(name: String) : Flyer, Animal {
  override val name: String
  init {
    this.name = name
  }
  ...
}

name的赋值方式其实无关紧要。比如我们还可以用一个getter对它进行赋值:

class Bird(n: String) : Flyer, Animal {
  override val name: String
  get() {
    TODO()
  }
	...
}

gettersetter

Java中通过这种方式来对一个类的私有字段进行取值和赋值的操作,通常用IDE来帮我们自动生成这些方法。而Kotlin类不存在字段,只有属性,它同样需要为每个属性生成gettersetter方法。但在Kotlin的中,当声明一个类的属性时,Kotlin编译器会帮忙生成gettersetter方法。 当然我们也可以主动声明这两个方法来实现一些特殊的逻辑。 还有以下两点需要注意:

  • val声明的属性将只有getter方法,因为它不可修改;而用var修饰的属性将同时拥有gettersetter方法;
  • private修饰的属性编译器将会省略gettersetter方法,因为在类外部已经无法访问它了,这两个方法的存在也就没有意义了;

总的来说,用接口模拟实现多继承是我们最常用的方式。但它有时在语义上依旧并不是很明确。下面我们就来看一种更灵活的方式,它能更完整地解决多继承问题。

6.3 内部类解决多继承的问题

我们知道,在Java中可以将一个类的定义放在另一个类的定义内部,这就是内部类。由于内部类可以继承一个与外部类无关的类,所以这保证了内部类的独立性,我们可以用它的这个特性来尝试解决多继承的问题。Java中的内部类定义非常直观,我们只要在一个类内部再定义一个类,那么这个类就是内部 类了,如:

public class OuterJava {
  private String name = "This is Java's inner class syntax.";

  class InnerJava { // 内部类
    public void printName() {
      System.out.println(name);
    }
  }
}

现在尝试用类似的Kotlin代码来改写这段代码,看看有没有类似的效果:

class OuterKotlin {
  val name = "This is not Kotlin's inner syntax"

  class ErrorInnerKotlin { // 嵌套类
    fun printName() {
      println("the name is $name")
    }
  }
}

编译器报错:

嵌套类

在这里,我们声明的并不是Kotlin中的内部类,而是嵌套类的语法。如果要在Kotlin中声明一个内部类,我们必须在这个类前面加一个inner关键字:

class OuterKotlin {
    val name = "This is not Kotlin's inner syntax"

    inner class ErrorInnerKotlin {
        fun printName() {
            println("the name is $name")
        }
    }
}

内部类vs嵌套类

Java中,我们通过在内部类的语法上增加一个static关键词,把它变成一个嵌套类。然而,Kotlin则是相反的思路,默认是一个嵌套类,必须加上inner关键字才是一个内部类,就是说可以把静态的内部类看成嵌套类。

内部类和嵌套类的差别体现在:内部类包含着对其外部类实例的引用,在内部类中我们可以使用外部类中的属性,比如上面例子中的name属性;而嵌套类不包含对其外部类实例的引用,所以它无法调用其外部类的属性。

我们就回到之前的骡子的例子,然后用内部类来改写它:

class Mule {
  private inner class HorseC : Horse()
  private inner class DonkeyC : Donkey()

  fun runFast() {
    HorseC().runFast()
  }

  fun doLongTimeThing() {
    DonkeyC().doLongTimeThing()
  }
}

通过这个修改后的例子可以发现:

  • 可以在一个类内部定义多个内部类,每个内部类的实例都有自己的独立状态,它们与外部对象的信息相互独立;
  • 通过让内部类HorseCDonkeyC分别继承HorseDonkey这两个外部类,我们就可以在Mule类中定义它们的实例对象,从而获得了HorseDonkey两者不同的状态和行为;
  • 可以利用private修饰内部类,使得其他类都不能访问内部类,具有非常良好 的封装性;

因此,可以说在某些场合下,内部类确实是一种解决多继承非常好的思路。

6.4 使用委托代替多继承

Kotlin中新引入了一种语法——委托,通过它我们也可以代替多继承来解决类似的问题。简单来说,委托是一种特殊的类型,用于方法事件委托,比如我们调用A类的methodA方法,其实背后是B类的methodA去执行。

Kotlin中,只需通过by关键字就可以实现委托的效果。 比如之前提过的by lazy语 法,其实就是利用委托实现的延迟初始化语法:

val laziness: String by lazy {
	// 用by lazy实现延迟初始化效果 
  println("I will have a value") 
  "I am a lazy-initialized string"
}

委托除了延迟属性这种内置行为外,还提供了一种可观察属性的行为,这与我们平常所说的观察者模式很类似。接下来,我们来看看如何通过委托来代替多继承实现需求。请看下面的例子:

interface CanFlyer {
    fun fly()
}

interface CanEat {
    fun eat()
}

open class Flyer : CanFlyer {
    override fun fly() {
        println("I can fly")
    }
}

open class Animal : CanEat {
    override fun eat() {
        println("I can eat")
    }
}

class Bird(flyer: CanFlyer, animal: Animal) : CanFlyer by flyer, CanEat by animal {}

fun main() {
    val flyer = Flyer()
    val animal = Animal()
    val b = Bird(flyer, animal)
    b.fly()
    b.eat()
}

// I can fly
// I can eat

有人可能会有疑问:首先,委托方式怎么跟接口实现多继承如此相似,而且好像也并没有简单多少;其次,这种方式好像跟组合也很像,那么它到底有什么优势呢?主要有以下两点:

  • 前面说到接口是无状态的,所以即使它提供了默认方法实现也是很简单的,不能实现复杂的逻辑,也不推荐在接口中实现复杂的方法逻辑。我们可以利用上面委托的这种方式,虽然它也是接口委托,但它是用一个具体的类去实现方法逻辑,可以拥有更强大的能力;
  • 假设我们需要继承的类是A,委托对象是BC、我们在具体调用的时候并不是像组合一样A.B.method,而是可以直接调用A.method,这更能表达A拥有该method的能力, 更加直观,虽然背后也是通过委托对象来执行具体的方法逻辑的;

7 数据类

有时候,我们并不想要那么强大的类,也许我们只是想要单纯地使用类来封装数据,类似于Java中的DTO(Data Transfer Object)的概念。在Java中声明一个JavaBean,定义一堆gettersetter。虽然IDE能帮我们自动生成这些代码,但是代码也十分冗长。下面就来看看Kotlin是如何改进这个问题的。

7.1 繁琐的JavaBean

Java中,定义一个数据模型类需要为其中的每一个属性定义gettersetter方法。如果要支持对象值的比较,还要重写 hashcodeequals等方法。比如下面的例子:

public class Bird {
  private double weight;
  private int age;
  private String color;

  public void fly() {
  }

  public Bird(double weight, int age, String color) {
    this.weight = weight;
    this.age = age;
    this.color = color;
  }

  public double getWeight() {
    return weight;
  }

  public void setWeight(double weight) {
    this.weight = weight;
  }

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public String getColor() {
    return color;
  }

  public void setColor(String color) {
    this.color = color;
  }
}

这是一个只有3个属性的JavaBean,但代码量却很大,如果想要更多的属性,一个JavaBean将会有更多的代码。为了解决这个问题,Kotlin引入了data class的语法来改善这一情况。

7.2 用data class创建数据类

data class就是数据类。 为了搞明白数据类是什么,先把上面那段Java代码用Kotlindata class来表示:

data class Bird(var weight: Double, var age: Int, var color: String)

当一个类中没有任何代码时,还可以将尾部的大括号省略。Kotlin中只添加了一个data关键字。在这个关键字后面,Kotlin编译器帮我们做了很多事情。将这个类反编译后的Java代码:

public final class Bird {
  private double weight;
  private int age;
  @NotNull
  private String color;

  public final double getWeight() {
    return this.weight;
  }

  public final void setWeight(double var1) {
    this.weight = var1;
  }

  public final int getAge() {
    return this.age;
  }

  public final void setAge(int var1) {
    this.age = var1;
  }

  @NotNull
  public final String getColor() {
    return this.color;
  }

  public final void setColor(@NotNull String var1) {
    Intrinsics.checkNotNullParameter(var1, "<set-?>");
    this.color = var1;
  }

  public Bird(double weight, int age, @NotNull String color) {
    Intrinsics.checkNotNullParameter(color, "color");
    super();
    this.weight = weight;
    this.age = age;
    this.color = color;
  }

  public final double component1() {
    return this.weight;
  }

  public final int component2() {
    return this.age;
  }

  @NotNull
  public final String component3() {
    return this.color;
  }

  @NotNull
  public final Bird copy(double weight, int age, @NotNull String color) {
    Intrinsics.checkNotNullParameter(color, "color");
    return new Bird(weight, age, color);
  }

  // $FF: synthetic method
  public static Bird copy$default(Bird var0, double var1, int var3, String var4, int var5, Object var6) {
    if ((var5 & 1) != 0) {
      var1 = var0.weight;
    }

    if ((var5 & 2) != 0) {
      var3 = var0.age;
    }

    if ((var5 & 4) != 0) {
      var4 = var0.color;
    }

    return var0.copy(var1, var3, var4);
  }

  @NotNull
  public String toString() {
    return "Bird(weight=" + this.weight + ", age=" + this.age + ", color=" + this.color + ")";
  }

  public int hashCode() {
    int var10000 = (Double.hashCode(this.weight) * 31 + Integer.hashCode(this.age)) * 31;
    String var10001 = this.color;
    return var10000 + (var10001 != null ? var10001.hashCode() : 0);
  }

  public boolean equals(@Nullable Object var1) {
    if (this != var1) {
      if (var1 instanceof Bird) {
        Bird var2 = (Bird)var1;
        if (Double.compare(this.weight, var2.weight) == 0 && this.age == var2.age && Intrinsics.areEqual(this.color, var2.color)) {
          return true;
        }
      }

      return false;
    } else {
      return true;
    }
  }
}

这段代码和JavaBean的代码很相似,同样有getter/setterequalshashcode、构造函数等方法,其中的equalshashcode使得一个数据类对象可以像普通类型的实例一样进行判等,我们可以像基本数据类型一样用==来判断两个对象相等,如下:

val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val b2 = Bird(weight = 1000.0, age = 1, color = "blue")
println(b1.equals(b2)) // true
println(b1 == b2) // true
7.3 copycomponentN与解构

在上面反编译的代码中有:

@NotNull
public final Bird copy(double weight, int age, @NotNull String color) {
  Intrinsics.checkNotNullParameter(color, "color");
  return new Bird(weight, age, color);
}

// $FF: synthetic method
public static Bird copy$default(Bird var0, double var1, int var3, String var4, int var5, Object var6) {
  if ((var5 & 1) != 0) {
    var1 = var0.weight;
  }
  if ((var5 & 2) != 0) {
    var3 = var0.age;
  }
  if ((var5 & 4) != 0) {
    var4 = var0.color;
  }
  return var0.copy(var1, var3, var4);
}

这段代码中的copy方法的主要作用就是帮我们从已有的数据类对象中拷贝一个新的数据类对象。可以传入相应参数来生成不同的对象。但同时我们发现,在copy的执行过程中,若未指定具体属性的值,那么新生成的对象的属性值将使用被copy对象的属性值,这就是浅拷贝。来看下面这个例子:

val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val b2 = b1
b2.color = "red"
println(b2.color) // red

这种方式会带来一个问题,明明是对一个新的对象b2做了修改,为什么还会影响老的对象b1呢?实际上,除了基本数据类型的属性,其他属性还是引用同一个对象,这便是浅拷贝的特点。

实际上copy更像是一种语法糖,假如我们的类是不可变的,属性不可以修改,那么我们只能通过copy来帮我们基于原有对象生成一个新的对象。 比如下面的两个例子:

val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val b2 = b1
b2.color = "red" // error: Val cannot be reassigned

// 声明的Bird属性不可变
data class Bird(val weight: Double, val age: Int, val color: String)

val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val b2 = b1.copy(color = "red")
println(b2) // Bird(weight=1000.0, age=1, color=red)

copy更像提供了一种简洁的方式帮我们复制一个对象,但它是一种浅拷贝的方式。所以在使用copy的时候要注意使用场景,因为数据类的属性可以被修饰为var,这便不能保证不会出现引用修改问题。

接下来我们来看看componentN方法。简单来说,componentN可以理解为类属性的值,其中N代表属性的顺序,比如component1代表第1个属性的值,component3代表第3个属性的值。 那么,这样设计到底有什么用呢?我们来思考一个问题,我们或多或少地知道怎么将属性绑定到类上,但是对如何将类的属性绑定到相应变量上却不是很熟悉。比如:

// 普通方式
val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val weight = b1.weight
val age = b1.age
val color = b1.color
println("weight = $weight, age = $age, color = $color") // weight = 1000.0, age = 1, color = blue

// Kotlin
val b1 = Bird(weight = 1000.0, age = 1, color = "blue")
val (weight, age, color) = b1
println("weight = $weight, age = $age, color = $color") // weight = 1000.0, age = 1, color = blue

再比如以下的代码:

String birdInfo = "1000.0,1,blue";
String[] temps = birdInfo.split(",");
double weight = Double.parseDouble(temps[0]);
int age = Integer.parseInt(temps[1]);
String color = temps[2];

这样代码有时真的很烦琐,明明知道值的情况,却要分好几步来给变量赋值。Kotlin提供了更优雅的做法:

val birdInfo = "1000.0,1,blue"
val (weight, age, color) = birdInfo.split(",")
println("weight = $weight, age = $age, color = $color") // weight = 1000.0, age = 1, color = blue

这个语法就是解构,通过编译器的约定实现解构。Kotlin对于数组的解构也有一定限制,在数组中它默认最多允许赋值5个变量,因为若是变量过多,效果反而会适得其反,到后期可能都搞不清楚哪个值要赋给哪个变量了。所以一定要合理使用这一特性。

在数据类中,除了可以利用编译器自动生成componentN方法以外,甚至还可以自己实现对应属性的componentN方法。比如:

data class Bird(var weight: Double, var age: Int, var color: String) {
  var sex = 1
  operator fun component4(): Int { // operator关键字
    return this.sex
  }

  constructor(weight: Double, age: Int, color: String, sex: Int) : this(weight, age, color) {
    this.sex = sex
  }
}

fun main() {
  val b1 = Bird(1000.0, 1, "blue")
  val (weight, age, color, sex) = b1
  println("weight = $weight, age = $age, color = $color, sex = $sex") // weight = 1000.0, age = 1, color = blue, sex = 1
}

除了数组支持解构外,Kotlin也提供了其他常用的数据类,让使用者不必主动声明这些数据类,它们分别是PairTriple。其中Pair是二元组,可以理解为这个数据类中有2个属性;Triple是三元组,对应的则是3个属性。 我们先来看一下它们的源码:

public data class Pair<out A, out B>(
  public val first: A,
  public val second: B
) : Serializable {
  
  public override fun toString(): String = "($first, $second)"
}

public data class Triple<out A, out B, out C>(
  public val first: A,
  public val second: B,
  public val third: C
) : Serializable {
  
  public override fun toString(): String = "($first, $second, $third)"
}

PairTriple都是数据类,它们的属性可以是任意类型,我们可以按照属性的顺序来获取对应属性的值。 因此,我们可以这么使用它们:

val pair = Pair(20.1, 1)
val triple = Triple(30.0, 2, "blue")

// 利用属性顺序获取值
println("pair.first = ${pair.first}, pair.second = ${pair.second}") // pair.first = 20.1, pair.second = 1
println("triple.first = ${triple.first}, triple.second = ${triple.second}, triple.third = ${triple.third}") // triple.first = 30.0, triple.second = 2, triple.third = blue

// 利用解构
val (weightP, ageP) = Pair(20.0, 1)
val (weightT, ageT, colorT) = Triple(30.0, 2, "blue")

println("weightP = $weightP, ageP = $ageP") // weightP = 20.0, ageP = 1
println("weightT = $weightT, ageT = $ageT, colorT = $colorT") // weightT = 30.0, ageT = 2, colorT = blue

注意:数据类中的解构基于componentN函数,如果自己不声明componentN函数,那么就会默认根据主构造函数参数来生成具体个数的componentN函数,与从构造函数中的参数无关。

7.4 数据类的约定与使用

如果要在Kotlin声明一个数据类,必须满足以下几点条件:

  • 数据类必须拥有一个构造方法,该方法至少包含一个参数,一个没有数据的数据类是没有任何用处的;
  • 与普通的类不同,数据类构造方法的参数强制使用var或者val进行声明;
  • data class之前不能用abstractopensealed或者inner进行修饰;
  • Kotlin 1.1版本前数据类只允许实现接口,之后的版本既可以实现接口也可以继承类;

数据类在语法上很简洁,以至于它可以像Map一样,作为数据结构被广泛运用到业务中。然而,数据类显然更灵活,因为它像一个普通类一样,可以把不同类型的值封装在一处。把数据类和when表达式结合在一起,就可以提供更强大的业务组织和表达能力。

数据类的另一个典型的应用就是代替我们在Java中的建造者模式。建造者模式主要化解Java中书写一大串参数的构造方法来初始化对象的场景。然而由于Kotlin中的类构造方法可以指定默认值,依靠数据类的简洁语法,我们就可以更方便地解决这个问题。

8 从staticobject

Java中,static是非常重要的特性,可以用来修饰类、方法或属性。虽然,static修饰的内容是属于类的(而不是某个具体对象),但在定义时却与普通的变量和方法混杂在一起,显得格格不入。

Kotlin中,告别了static这种语法,引入了全新的关键字object,可以代替使用static的所有场景。 当然除了代替使用static的场景之外,它还能实现更多的功能,比如单例对象及简化匿名表达式等。

8.1 伴生对象

先来看一个可比较的Java例子:

public class Prize {
  private String name;
  private int count;
  private int type;

  public Prize(String name, int count, int type) {
    this.name = name;
    this.count = count;
    this.type = type;
  }

  static int TYPE_REDPACK = 0;
  static int TYPE_COUPON = 1;

  static boolean isRedpack(Prize prize) {
    return prize.type == TYPE_REDPACK;
  }

  public static void main(String[] args) {
    Prize prize = new Prize("红包", 10, Prize.TYPE_REDPACK);
    System.out.println(Prize.isRedpack(prize));
  }
}

这是很常见的Java代码,如果仔细思考,会发现这种语法其实并不是非常好。因为在一个类中既有静态变量、静态方法,也有普通变量、普通方法。静态变量和静态方法是属于一个类的,普通变量、普通方法是属于一个具体的对象的。虽然有static作为区分,然而在代码结构上并不是区分得很清晰。

那么,有没有一种方式能将这两部分代码清晰地分开,但又不失语义化呢?Kotlin中引入了伴生对象的概念,简单来说,这是一种利用companion object两个关键字创造的语法。

companion [kəmˈpænjən] 陪伴

“伴生”是相较于一个类而言的,意为伴随某个类的对象,它属于这个类所有,因此伴生对象跟Javastatic修饰效果性质一样,全局只有一个实例(单例)。它需要声明在类的内部,在类被装载时会被初始化。

现在来实现一个伴生对象的版本:

class Prize(val name: String, val count: Int, val type: Int) {
    companion object {
        val TYPE_REDPACK = 0
        val TYPE_COUPON = 1

        fun isRedpack(prize: Prize): Boolean {
            return prize.type == TYPE_REDPACK
        }
    }
}

fun main() {
    val prize = Prize("红包", 10, Prize.TYPE_REDPACK)
    println(Prize.isRedpack(prize)) //true
}

可以发现,该版本在语义上更清晰了。companion object用花括号包裹了所有静态属性和方法,使得它可以与Prize类的普通方法和属性清晰地区分开来。最后,我们可以使用点号来对一个类的静态的成员进行调用。

伴生对象的另一个作用是可以实现工厂方法模式,然而这种方式存在以下缺点:

  • 利用多个构造方法语意不够明确,只能靠参数区分;
  • 每次获取对象时都需要重新创建对象;

你会发现,伴生对象也是实现工厂方法模式的另一种思路,可以改进以上的两个问题:

class Prize(val name: String, val count: Int, val type: Int) {
  companion object {
    val TYPE_COMMON = 1
    val TYPE_REDPACK = 2
    val TYPE_COUPON = 3
    val defaultCommonPrize = Prize("普通奖品", 10, Prizes.TYPE_COMMON)

    fun newRedpackPrizs(name: String, count: Int) = Prize(name, count, Prizes.TYPE_REDPACK)
    fun newCouponPrize(name: String, count: Int) = Prize(name, count, Prizes.TYPE_COUPON)
    fun defaultCommonPrize() = defaultCommonPrize // 无须构造对象
  }
}

fun main() {
  val redpackPrize = Prize.newRedpackPrizes("红包", 10)
  val couponPrize = Prize.newCouponPrizes("十元代金券", 10)
  val commonPrize = Prize.defaultCommonPrizes()
}

总的来说,伴生对象是Kotlin中用来代替static关键字的一种方式,任何在Java类内部用static定义的内容都可以用Kotlin中的伴生对象来实现。然而,它们是类似的,一个类的伴生对象跟一个静态类一样,全局只能有一个。 这让我们联想到了单例对象,下面会介绍如何用object更优雅地实现Java中的单例模式。

8.2 天生的单例:object

单例模式最大的一个特点就是在系统中只能存在一个实例对象,所以在Java中必须通过设置构造方法私有化,以及提供静态方法创建实例的方式来创建单例对象。 比如,现在要创建一个数据库配置的单例对象:

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

这是用Java实现的一个最基本单例模式的精简例子(省略了多线程以及多种参数创建单例对象的方法)。它依赖static关键字,同时还必须将构造方法私有化。这段代码其实很好理解,首先为了禁止外部创建Singleton的实例,需要用private关键字将Singleton的构造函数私有化,然后给外部提供了一个getInstance()静态方法用于获取Singleton的实例。在getInstance()方法中,判断如果当前缓存的Singleton实例 为null,就创建一个新的实例,否则直接返回缓存的实例即可,这就是单例模式的工作机制。

如果想调用单例类中的方法,可以这样写:

Singleton singleton = Singleton.getInstance();

虽然Java中的单例实现并不复杂,但是,Kotlin中,由于object的存在,可以直接用它来实现单例,它将一些固定的、重复的逻辑实现隐藏了起来,只暴露给我们最简单方便的用法。 如下所示:

object Singleton {   
}

现在Singleton就已经是一个单例类了。由于用object声明的对象全局只有一个,所以它并不用语法上的初始化,甚至都不需要构造方法。因此,可以说,object创造的是天生的单例, 我们并不需要在Kotlin中去构建一个类似Java的单例模式。

可以看到,在Kotlin中不需要私有化构造函数,也不需要提供getInstance()这样的静态方法,只需要把class关键字改成object关键字,一个单例类就创建完成了。 这种写法虽然看上去像是静态方法的调用,但其实Kotlin在背后自动帮我们创建了一个 Singleton类的实例,并且保证全局只会存在一个Singleton实例,以下是反编译后的代码:

public final class Singleton {
   @NotNull
   public static final Singleton INSTANCE;

   private Singleton() {
   }

   static {
      Singleton var0 = new Singleton();
      INSTANCE = var0;
   }
}

由于单例也可以和普通的类一样实现接口和继承类,所以可以将它看成一种不需要我们主动初始化的类,它也可以拥有扩展方法。单例对象会在系统加载的时候初始化,当然全局就只有一个。

那么,object声明除了表现在单例对象及上面的说的伴生对象之外,还有其他的作用吗?它还有一个作用就是替代Java中的匿名内部类。下面我们就来看看它是如何做的。

8.3 object表达式

Java中的匿名类很繁琐,有时候明明只有一个方法,却要用一个匿名内部类去实现。比如我们要对一个字符串列表排序:

List<String> list = Arrays.asList("redpack", "source", "card");
Collections.sort(list, new Comparator<String>() {
  @Override
  public int compare(String o1, String o2) {
    if (o1 == null) return -1;
    if (o2 == null) return 1;
    return o1.compareTo(o2);
  }
});

并不是说匿名内部类这个方式不好,只不过方法内掺杂类声明不仅让方法看起来复杂,也不易阅读理解。而在Kotlin中,可以利用object表达式对它进行改善:

val list = Arrays.asList("redpack", "source", "card")
val comparator = object : Comparator<String> {
  override fun compare(o1: String?, o2: String?): Int {
    if (o1 == null) return -1
    else if (o2 == null) return 1
    return o1.compareTo(o2)
  }
}
Collections.sort(list, comparator)

简单来看,object表达式跟Java的匿名内部类很相似,object表达式可以赋值给一个变量,这在我们重复使用的时候将会减少很多代码。另外,object可以继承类和实现接口,匿名内部类只能继承一个类及实现一个接口,而object表达式却没有这个限制。

用于代替匿名内部类的object表达式在运行中不像我们在单例模式中说的那样,全局只存在一个对象,而是在每次运行时都会生成一个新的对象。

其实,匿名内部类与object表达式并不是对任何场景都适合的,Java 8引入的Lambda表达式对有些场景实现起来更加适合,比如接口中只有单个方法的实现。而Kotlin天然就支持Lambda表达式,现在我们可以将上面的代码用Lambda表达式的方式重新改造一下:

val list = Arrays.asList("redpack", "source", "card")
val comparator = Comparator<String> { o1, o2 ->
      if (o1 == null)
          return@Comparator -1
      else if (o2 == null)
           return@Comparator 1
      o1.compareTo(o2)
}
Collections.sort(list, comparator)

使用Lambda表达式后代码变得简洁很多。

对象表达式与Lambda表达式哪个更适合代替匿名内部类?当你的匿名内部类使用的类接口只需要实现一个方法时,使用Lambda表达式更适合;当匿名内部类内有多个方法实现的时候,使用object表达式更加合适。

9 接口和抽象类的区别

9.1 抽象类

在了解抽象类之前,先来了解一下抽象方法。抽象方法是一种特殊的方法:它只有声明,而没有具体的实现。 抽象方法的声明格式为:

abstract fun method()

抽象方法必须用abstract关键字进行修饰。如果一个类含有抽象方法,则称这个类为抽象类,抽象类必须在类前用abstract关键字修饰。因为抽象类中含有无具体实现的方法,所以不能用抽象类创建对象。

在《JAVA编程思想》一书中,将抽象类定义为“包含抽象方法的类”,但是后面发现如果一个类不包含抽象方法,只是用abstract修饰的话也是抽象类。也就是说抽象类不一定必须含有抽象方法。

如果有抽象方法的类不是抽象类:

抽象方法

定义一个抽象类:

abstract class Bird {
    abstract fun method()
    fun method1() { 
    }
}

可以看出,抽象类就是为了继承而存在的,如果定义了一个抽象类,却不去继承它,那么等于白白创建了这个类,因为不能用它来做任何事情。 对于一个父类,如果它的某个方法在父类中实现出来没有任何意义,必须根据子类的实际需求来进行不同的实现,那么就可以将这个方法声明为abstract方法,此时这个类也就成为abstract类了。

包含抽象方法的类称为抽象类,但并不意味着抽象类中只能有抽象方法,它和普通类一样,同样可以拥有成员变量和普通的成员方法。

注意,抽象类和普通类的主要有三点区别:

  • 抽象方法必须为public或者protected,如果为private,则不能被子类继承,缺省情况下默认为public
  • 抽象类不能用来创建对象;
  • 如果一个类继承于一个抽象类,则子类必须实现父类的抽象方法。如果子类没有实现父类的抽象方法,则必须将子类也定义为为abstract类。
9.2 抽象类和接口的区别
  • 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型的;
  • 抽象类可以有构造器、静态代码块和静态方法,而接口中不能含有构造器、静态代码块以及静态方法;
  • 一个类只能继承一个抽象类,而一个类却可以实现多个接口;
  • 接口是对行为的抽象,可用于解耦(相对于抽象类),而抽象类是对类的抽象,更加侧重于代码复用;

举例来说:门都有openclose两个动作,可以定义通过抽象类和接口来定义这个抽象概念:

abstract class Door {
  abstract fun open()
  abstract fun close()
}

或者:

interface Door {
    fun open()
    fun close()
}

但是现在如果我们需要门具有报警alarm的功能,该如何实现?下面提供两种思路:

  1. 这三个功能都放在抽象类里面,但是这样一来所有继承于这个抽象类的子类都具备了报警功能,但是有的门并不一定具备报警功能;
  2. 将这三个功能都放在接口里面,需要用到报警功能的类就需要实现这个接口中的openclose,也许这个类根本就不具备openclose这两个功能,比如火灾报警器。

从这里可以看出, Dooropenclosealarm根本就属于两个不同范畴内的行为,openclose属于门本身固有的行为特性,而alarm属于延伸的附加行为。因此最好的解决办法是单独将报警设计为一个接口,包含alarm行为,Door设计为单独的一个抽象类,包含openclose两种行为。再设计一个报警门继承Door类和实现Alarm接口。

abstract class Door {
    abstract fun open()
    abstract fun close()
}

interface Alarm {
    fun alarm()
}

class AlarmDoor:Door(),Alarm{
    override fun open() {
        TODO("Not yet implemented")
    }

    override fun close() {
        TODO("Not yet implemented")
    }

    override fun alarm() {
        TODO("Not yet implemented")
    }

}

10 面向对象的特征

10.1 封装

封装是把数据和操作数据的方法绑定起来,只能通过已定义的接口对数据进行访问。 面向对象的本质就是将现实世界描绘成一系列完全自治,封闭的对象。可以说,封装就是隐藏一切可隐藏的东西,只向外界提供最简单的编程接口。

优势:隐藏类的实现细节,只能通过规定的方法访问数据;

步骤:

  • 修改属性的可见性,设为private
  • 创建getter/setter方法,用于属性的读写。通过这两种方法对数据进行获取和设定,对象通过调用这两种发方法实现对数据的读写;
  • getter/setter方法中加入属性控制语句,对属性值的合法性进行判断
10.2 抽象

抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。

10.3 继承

继承是从已有类得到继承信息创建新类的过程。提供继承信息的类被称为父类(超类、基 类);到继承信息的类被称为子类(派生类)。

10.4 多态

多态:多态就是同一个接口,使用不同的实例而执行不同操作。

多态存在的三个必要条件:继承,重写,父类引用指向子类对象(Parent p = new Child();

多态

当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。

多态的好处:可以使程序有良好的扩展,并可以对所有类的对象进行通用处理。 以下是一个多态实例:

abstract class Animal {
    abstract fun eat()
}

class Cat : Animal() {
    override fun eat() {
        println("eat fish")
    }

    fun work() {
        println("catch mouse")
    }
}

class Dog : Animal() {
    override fun eat() {
        println("eat bone")
    }

    fun work() {
        println("look after the house")
    }
}

fun show(a: Animal) {
    a.eat()
    if (a is Cat) {
        a.work()
    }
    if (a is Dog) {
        a.work()
    }
}

fun main() {
    show(Cat()) // eat fish  catch mouse
    show(Dog()) // eat bone   look after the house

    val a = Cat()
    a.eat() // eat fish
    a.work() // catch mouse
}

参考

https://zhuanlan.zhihu.com/p/142226680
https://zhuanlan.zhihu.com/p/28427324
https://blog.csdn.net/qq_42429369/article/details/84929377
https://blog.csdn.net/weixin_43444439/article/details/84501621
https://www.runoob.com/java/java-polymorphism.html
https://www.cnblogs.com/dolphin0520/p/3811437.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值