gitbook/朱涛 · Kotlin编程第一课/docs/473349.md
2022-09-03 22:05:03 +08:00

25 KiB
Raw Permalink Blame History

02 | 面向对象理解Kotlin设计者的良苦用心

你好我是朱涛。这节课我们来学习Kotlin的面向对象编程类、接口、继承、嵌套以及Kotlin独有的数据类和密封类。

面向对象Object Oriented是软件开发方法也是计算机界应用最广的一种编程范式。它是把程序的“数据”和“方法”作为一个整体来看待将其抽象成了一个具体的模型从而更贴近事物的自然运行模式。它的特点是简单易懂符合人类的思维模式。

在“面向对象”的概念上虽然Kotlin和Java之间有一定的语法差异但底层的思想是没有变的。比如Java和Kotlin当中都有类、接口、继承、嵌套、枚举的概念唯一区别就在于这些概念在两种语言中的具体语法不同。我们需要做的,仅仅只是为我们脑海里已经熟知的概念,再增加一种语法规则而已。

而如果你没有Java基础也没关系今天这节课要学习的内容几乎是所有编程语言都需要掌握的概念。在掌握了Kotlin面向对象的编程思想后如果你再去学习其他编程语言你也照样可以快速迁移这些知识点。

当然Kotlin作为一门新的语言它也创造了一些新的东西比如数据类、密封类、密封接口等。这些Kotlin的新概念会是我们需要着重学习的对象。实际上也正是因为Kotlin的这些独有概念使得它形成了一种独特的编程风格和编程思想。

那么在学习这节课的时候我想请你注意一点就是如果你已经有了Java、C的编程经验一定要有意识地跳出从前的编程习惯尝试去理解Kotlin的编程思想。只有这样你才能写出地道的Kotlin代码而不仅仅只是用Kotlin语法翻译Java/C代码。

怎么写出有Kotlin特点的类

Kotlin当中的“类”我们可以将其理解为对某种事物的“抽象模型”。比如说我们可以在Kotlin当中定义一个类它叫做Person。

class Person(val name: String, var age: Int)

计算机的世界里当然没有“人”的概念但是这并不妨碍我们在代码当中定义一个人的“抽象模型”。上面的Person类当中有一个属性叫做“name”每个人都会有名字而名字也是属于人的一部分。这也很好理解对吧。

不过如果你以Java的角度来分析上面的代码的话会发现它并没有表面上那么简单。如果我们将其翻译成等价的Java代码它会变成很多行代码

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 属性 name 没有 setter
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

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

那么把Kotlin和Java的代码对比着来看我们很快就会发现Kotlin当中Person类的name是用val修饰的这意味着它在初始化以后将无法被修改。这个逻辑对应到Java当中就是该变量只有getter没有setter。而Kotlin当中Person类的age是var修饰的意味着它是可以被随意修改的。这个逻辑对应到Java当中就是该变量既有getter也有setter。

这下你应该就明白了,Kotlin定义的类在默认情况下是public的编译器会帮我们生成“构造函数”对于类当中的属性Kotlin编译器也会根据实际情况自动生成getter和setter。

到这里我们不得不感叹Kotlin语言的简洁性。Kotlin里简单到不能再简单的一行代码在Java中却要写这么一大串。要注意这里的差距并不仅仅是我们程序员敲代码的时间还包括后续我们迭代维护的时间或者说是读代码的时间。Kotlin一行代码我们轻松就能理解了而对应的Java则完全不一样。并且这还只是一个最简单的案例真实的程序比这复杂千万倍。

自定义属性getter

我们继续来看前面的Person类的例子。如果这时候我们希望它增加一个功能就是根据年龄的大小自动判断是不是成年人也就是age≥18。

如果按照我们从前的Java思维一定会习以为常地写出下面这样的代码也就是为Person类增加一个新的方法isAdult()。

class Person(val name: String, var age: Int) {
    fun isAdult(): Boolean {
        return age >= 18 
    }
}

又或者我们可以充分发挥Kotlin的简洁语法特性将isAdult()写得更加清爽一些:

class Person(val name: String, var age: Int) {
    fun isAdult() = age >= 18 
}

代码写成这样已经算不错了。然而我们还可以用另一种更符合直觉的写法那就是将isAdult()定义成Person的属性。具体的做法就是借助Kotlin属性的自定义getter

class Person(val name: String, var age: Int) {
    val isAdult
        get() = age >= 18
//        ↑
//    这就是isAdult属性的getter方法
}

所谓getter就是获取属性值的方法。我们通过自定义这个get()方法就可以改变返回值的规则。比如这里的年龄大于等于18就是成年人这个规则就是通过自定义来实现的。

以上的isAdult属性我们省略了它的类型因为编译器会自动推导它的类型是Boolean布尔类型。另外由于get()方法内部只有一行代码,我们使用了函数的“单一表达式”写法,直接用等号连接即可。

而如果get()方法内部的逻辑比较复杂,我们仍然可以像正常函数那样,带上花括号:

class Person(val name: String, var age: Int) {
    val isAdult: Boolean
        get() {
            // do something else
            return age >= 18
        }
}

不过需要注意的是在这种情况下编译器的自动类型推导就会失效了所以我们要为isAdult属性增加明确的类型Boolean。

看到这里也许你会觉得奇怪判断一个人是否为成年人我们只需要判断age这个属性即可**为什么还要引入一个新的属性isAdult呢**这不是凭空多占用了一个变量的内存吗?这么做是否有必要?

实际上这里涉及到Java到Kotlin的一种思想转变。让我们来详细分解上面的问题

  • 首先,从语法的角度上来说是否为成年人本来就是属于人身上的一种属性。我们在代码当中将其定义为属性更符合直觉。而如果我们要给Person增加一个行为比如walk那么这种情况下定义一个新的方法就是非常合适的。
  • 其次,从实现层面来看我们确实定义了一个新的属性isAdult但是Kotlin编译器能够分析出我们这个属性实际是根据age来做逻辑判断的。在这种情况下Kotlin编译器可以在JVM层面将其优化为一个方法。
  • 通过以上两点我们就成功在语法层面有了一个isAdult属性但是在实现层面isAdult仍然还是个方法。这也就意味着isAdult本身不会占用内存它的性能和我们用Java写的方法是一样的。而这在Java当中是无法实现的。

所以当你使用Kotlin来编写代码的时候一定要注意其中引入的Kotlin属性的含义并且理解它的底层实现逻辑。只有这样你才能真正发挥Kotlin简洁语法的优势而不必束缚在Java或者C的代码撰写思维里。

对了,也许你会突发奇想,想要将上面的代码进一步简化,比如写成这样:

class Person(val name: String, var age: Int) {
    val isAdult = age >= 18
}

但实际上这种代码是无法正常工作的。由于它牵涉到Kotlin的原理你可以在学完下一节“Kotlin原理”之后再回过头来看看这段代码为什么有问题。

自定义属性setter

我们继续来看前面的Person案例假设这时候我们又来了一个新的需求希望在age被修改的时候能够做一些日志统计工作。如果是Java那我们直接在setAge()方法当中加入一些逻辑就行了。

可是在Kotlin当中我们要怎么办呢答案也很容易想到自定义setter

class Person(val name: String) {
    var age: Int = 0
//  这就是age属性的setter
//       ↓
        set(value: Int) {
            log(value)
            field = value
        }
    // 省略
}

所谓setter就是可以对属性赋值的方法。当我们想要改变属性的赋值逻辑时我们就可以通过自定义来实现了。需要注意的是以上代码当中的value就是set方法传入的参数。而set()方法当中的field则是代表了age这是Kotlin编译器为我们提供的字段。field = value这一行代码就表示我们实现了对age的赋值操作。

有的时候我们不希望属性的set方法在外部访问那么我们可以给set方法加上可见性修饰符比如这里我们可以给set方法加上private限制它仅可以在类的内部访问

class Person(val name: String) {
    var age: Int = 0
        private set(value: Int) {
            log(value)
            field = value
        }
    // 省略
}

在了解了Kotlin中类的定义以后我们再来看一种特殊的类抽象类。

抽象类与继承

前面我们所创建的类比如Person它是具体的、已经定型的类我们可以直接用它来创建对象。而抽象类则不是具体的类它没有完全定型我们也不能直接用它来创建对象。

在Kotlin当中抽象类的定义和Java几乎一样也就是在关键字“class”“fun”的前面加上abstract关键字即可。这里我们继续上面的例子把Person定义成抽象类然后为它添加一个抽象方法

abstract class Person(val name: String) {
    abstract fun walk()
    // 省略
}

这样一来如果我们要创建Person类就必须要使用匿名内部类的方式或者使用Person的子类来创建变量。而这就需要用到类的继承了。

从概念上讲Kotlin的继承和Java的并没有区别它们只是在语法上有一点点不一样。Java当中我们是使用extends表示继承而Kotlin当中我们则使用冒号来表示继承。

//                      Java 的继承
//                           ↓
public class MainActivity extends Activity {
    @Override
    void onCreate(){ ... }
}

//              Kotlin 的继承
//                 ↓
class MainActivity : AppCompatActivity() {
    override fun onCreate() { ... }
}

除了继承的语法不太一样重写的表达方式也不太一样。Java当中是使用@Override注解而Kotlin当中直接将其定义为了override关键字。而除了抽象类以外,正常的类其实也是可以被继承的。不过,我们必须对这个类标记为open。如果一个类不是抽象类并且没有用open修饰的话它是无法被继承的。

class Person() {
    fun walk()
}

// 报错
class Boy: Person() {
}

根据以上代码我们可以看到Person不是抽象类同时它也没有被open修饰。Boy这个类尝试继承Person会导致编译器报错。所以我们必须为Person这个类加上open关键字

open class Person() {
    open fun walk()
}

class Boy: Person() {
    // 省略重写逻辑
}

所以Kotlin的类默认是不允许继承的除非这个类明确被open关键字修饰了。另外对于被open修饰的普通类它内部的方法和属性默认也是不允许重写的除非它们也被open修饰了

open class Person() {
    val canWalk: Boolean = false
    fun walk()
}

class Boy: Person() {
    // 报错
    override val canWalk: Boolean = true
    // 报错
    override fun walk() {
    }
}

可以看到由于Person类当中的canWalk属性以及walk()方法它们都没有使用open修饰在这种情况下即使Person可以被继承但在继承之后的Boy类当中我们是无法重写canWalk属性、walk()方法的。

也就是说,**在继承的行为上面Kotlin和Java完全相反。**Java当中一个类如果没有被final明确修饰的话它默认就是可以被继承的。而这同时也就导致了在Java当中“继承”被过度使用。对于这一点经典书籍《Effective Java》也有提到过。

所以,Java的继承是默认开放的Kotlin的继承是默认封闭的。Kotlin的这个设计非常好这样就不会出现Java中“继承被滥用”的情况。

好,下面,我们再来看看另一种常见的面向对象的编程方式,那就是“实现”。

接口和实现

Kotlin当中的接口interface和Java也是大同小异的它们都是通过interface这个关键字来定义的。

interface Behavior {
    fun walk()
}

class Person(val name: String): Behavior {
    override fun walk() {
        // walk
    }
    // ...
}

可以看到在以上的代码中我们定义了一个新的接口Behavior它里面有一个需要被实现的方法walk然后我们在Person类当中实现了这个接口。

而在这里我们又会发现Kotlin和Java不同的小细节**Kotlin的继承和接口实现语法是一样的。**多么得贴心!

Kotlin的接口跟Java最大的差异就在于接口的方法可以有默认实现同时它也可以有属性。比如我们来看看下面这段代码

interface Behavior {
    // 接口内的可以有属性
    val canWalk: Boolean

    // 接口方法的默认实现
    fun walk() {
        if (canWalk) {
            // do something
        }
    }
}

class Person(val name: String): Behavior {
    // 重写接口的属性
    override val canWalk: Boolean
        get() = true
}

可以看到我们在Behavior接口当中增加了一个属性canWalk代表是否可以行走。与此同时我们在接口方法当中为walk()方法提供了默认实现这个实现的逻辑也很简单如果canWalk为true才执行walk内部的具体行为。

需要特别注意的是由于walk已经有了默认的实现所以我们在Person类当中就可以不必实现walk方法了。而Kotlin的这一设计就让“接口”和“抽象类”之间的界限越来越模糊了。

换句话说Kotlin当中的接口被设计得更加强大了它拥有了部分抽象类才有的特性,同时还可以灵活使用接口组合的特性

另外你还需要知道一点就是虽然在Java 1.8版本当中接口也引入了类似的特性但由于Kotlin是完全兼容Java 1.6版本的。因此为了实现这个特性Kotlin编译器在背后做了一些转换。这也就意味着它是有一定局限性的。而具体的局限体现在哪里呢这个我先不多讲你可以自己回去思考思考。

嵌套

Java当中最常见的嵌套类分为两种非静态内部类、静态内部类。Kotlin当中也有一样的概念。

class A {
    class B {
    }
}

以上代码中B类就是A类里面的嵌套类这非常容易理解。不过我们需要注意的是这种写法的嵌套类我们无法在B类当中访问A类的属性和成员方法。

class A {
    val name: String = ""
    fun foo() = 1


    class B {
        val a = name   // 报错
        val b = foo()  // 报错
    }
}

可以看到当我们尝试在B类当中访问A类的成员时编译器会报错。如果你有Java基础应该马上就能反应过来这种写法就对应了Java当中的静态内部类

// 等价的Java代码如下
public class A() {
    public String name = "";
    public int foo() { return 1; }


    public static class B {
        String a = name)   // 报错
        int b = foo()      // 报错
    }
}

所以Kotlin当中的普通嵌套类它的本质是静态的。相应地如果想在Kotlin当中定义一个普通的内部类我们需要在嵌套类的前面加上inner关键字

class A {
    val name: String = ""
    fun foo() = 1
// 增加了一个关键字
//    ↓
    inner class B {
        val a = name   // 通过
        val b = foo()  // 通过
    }
}

inner关键字代表了B类是A类内部的类这种情况下我们在B类的内部是可以访问A类的成员属性和方法的。

Kotlin的这种设计非常巧妙。如果你熟悉Java开发你会知道Java当中的嵌套类如果没有static关键字的话它就是一个内部类这样的内部类是会持有外部类的引用的。可是这样的设计在Java当中会非常容易出现内存泄漏而大部分Java开发者之所以会犯这样的错误往往只是因为忘记加“static”关键字了。这是一个Java开发者默认情况下就容易犯的错。

Kotlin则反其道而行之在默认情况下嵌套类变成了静态内部类而这种情况下的嵌套类是不会持有外部类引用的。只有当我们真正需要访问外部类成员的时候我们才会加上inner关键字。这样一来默认情况下开发者是不会犯错的只有手动加上inner关键字之后才可能会出现内存泄漏而当我们加上inner之后其实往往也就能够意识到内存泄漏的风险了。

也就是说,Kotlin这样的设计就将默认犯错的风险完全抹掉了

Kotlin中的特殊类

好了到目前为止我们接触到的都是Kotlin与Java都有的概念。接下来我们来看两个Java当中没有的概念数据类和密封类。

数据类

数据类Data Class顾名思义就是用于存放数据的类。要定义一个数据类我们只需要在普通的类前面加上一个关键字“data”即可。比如前面案例当中的Person类我们只需要在它的前面加上data就可以将它变为一个“数据类”。

    // 数据类当中,最少要有一个属性
                   ↓
data class Person(val name: String, val age: Int)

在Kotlin当中编译器会为数据类自动生成一些有用的方法。它们分别是

  • equals()
  • hashCode()
  • toString()
  • componentN() 函数;
  • copy()。

所以即使我们的Person类只有一行Kotlin代码我们仍然可以在其他地方调用编译器为我们自动生成这些方法。

val tom = Person("Tom", 18)
val jack = Person("Jack", 19)

println(tom.equals(jack)) // 输出false
println(tom.hashCode())   // 输出对应的hash code
println(tom.toString())   // 输出Person(name=Tom, age=18)

val (name, age) = tom     // name=Tom, age=18
println("name is $name, age is $age .")

val mike = tom.copy(name = "Mike")
println(mike)             // 输出Person(name=Mike, age=18)

这里你需要注意的是最后的四行代码。

“val (name, age) = tom”这行代码其实是使用了数据类的解构声明。这种方式可以让我们快速通过数据类来创建一连串的变量。另外就是copy方法。数据类为我们默认实现了copy方法可以让我们非常方便地在创建一份拷贝的同时修改某个属性。

密封类

Kotlin当中的密封类常常用来表示某种受到限制的继承结构。这样说起来可能有点抽象让我们换个说法密封类,是更强大的枚举类

首先,让我们看看枚举类是什么。

enum class Human {
    MAN, WOMAN
}

fun isMan(data: Human) = when(data) {
    Human.MAN -> true
    Human.WOMAN -> false
    // 这里不需要else分支编译器自动推导出逻辑已完备
}

我们通过enum就可以定义枚举类所谓枚举,就是一组有限的数量的值。比如人分为男人和女人。这样的分类是有限的所以我们可以枚举出每一种情况。我们在when表达式当中使用枚举时编译器甚至可以自动帮我们推导出逻辑是否完备。这是枚举的优势。

但是,枚举也有它的局限性。

println(Human.MAN == Human.MAN)
println(Human.MAN === Human.MAN)

输出
true
true

比如在这里我们可以看到当我们尝试去判断枚举的“结构相等”和“引用相等”时结果始终都是true。而这就代表了每一个枚举的值它在内存当中始终都是同一个对象引用。

那么万一,我们想要枚举的值拥有不一样的对象引用,我们该怎么办呢?这时候就需要“密封类”出场了!

想要定义密封类,我们需要使用sealed关键字它的中文含义也代表着“密封”。在Android开发当中我们会经常使用密封类对数据进行封装。比如我们可以来看一个代码例子

sealed class Result<out R> {
    data class Success<out T>(val data: T, val message: String = "") : Result<T>()

    data class Error(val exception: Exception) : Result<Nothing>()

    data class Loading(val time: Long = System.currentTimeMillis()) : Result<Nothing>()
}

这个例子是改造于我实际工作中用过的代码。首先我们使用sealed关键字定义了一个Result类并且它需要一个泛型参数RR前面的out我们可以暂时先忽略。

这个密封类我们是专门用于封装网络请求结果的。可以看到在Result类当中分别有三个数据类分别是Success、Error、Loading。我们将一个网络请求结果也分为了三大类分别代表请求成功、请求失败、请求中。

这样当网络请求有结果以后我们的UI展示逻辑就会变得非常简单也就是非常直白的三个逻辑分支成功、失败、进行中。我们将其与Kotlin协程当中的when表达式相结合就能很好地处理UI展示逻辑如果是Loading我们就展示进度条如果是Success我们就展示成功的数据如果是Error我们就展示错误提示框。

fun display(data: Result) = when(data) {
    is Result.Success -> displaySuccessUI(data)
    is Result.Error -> showErrorMsg(data)
    is Result.Loading -> showLoading()
}

由于我们的密封类只有这三种情况所以我们的when表达式不需要else分支。可以看到这样的代码风格既实现了类似枚举类的逻辑完备性还完美实现了数据结构的封装。
而且在最新的Kotlin 1.5 版本当中sealed不仅仅可以用于修饰类还可以用于修饰接口。这就为我们的密封类实现多个接口提供了可能。

小结

在这节课当中我们学习了面向对象常见的概念包括类、继承、接口、实现、枚举还有Kotlin独有的数据类、密封类。同时也进一步领略到了Kotlin语法在一些细节的良苦用心。比如说

  • Kotlin的类默认是public的。
  • Kotlin的类继承语法、接口实现语法是完全一样的。
  • Kotlin当中的类默认是对继承封闭的类当中的成员和方法默认也是无法被重写的。这样的设计就很好地避免了继承被滥用。
  • Kotlin接口可以有成员属性还可以有默认实现。
  • Kotlin的嵌套类默认是静态的这种设计可以防止我们无意中出现内存泄漏问题。
  • Kotlin独特的数据类在语法简洁的同时还给我们提供了丰富的功能。
  • 密封类作为枚举和对象的结合体帮助我们很好地设计数据模型支持when表达式完备性。

图片

看到这里你一定会发现我们上面提到的这些点正好也是决定着Kotlin编程思想的关键。也正是Kotlin这样独特的设计才形成了Kotlin迥然不同的编程风格。曾经有不少人问过我这样的问题“如何才能拥有Kotlin编程思维而不是用Kotlin写Java风格代码如何才能写出优雅的Kotlin代码

答案其实很简单我们先要掌握Kotlin的语法然后透过这些语法细节去揣摩Kotlin设计者的意图当我们理解Kotlin设计者为什么要设计某个语法时我们就不可能用错这个语法了。而当我们能够用好Kotlin的每个语法的时候我们自然而然地就可以写出优雅的Kotlin代码了。

比如当我们知道数据类是为了解决冗余的Java Bean而设计的那我们无论如何都不会在Kotlin当中再写一遍Kotlin Bean了。又或者当我们知道when表达式可以自动判断逻辑分支是否完备的时候我们自然就会想办法让when与枚举类/密封类结合使用了。

正如课程开头我提到过的入门Kotlin很容易精通Kotlin很难。让我们一起努力吧

思考题

在课程中我提到了Kotlin接口的“成员属性”是存在一定的局限性的。那么请问你能想到它的局限性在哪里吗

欢迎你在评论区分享你的思路,这个问题我会在下节课给出答案。另外在学完这节课之后,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。