3、Kotlin面向对象:类与对象、继承、接口、数据类、密封类、枚举类、伴生对象

说实话,很多从Java转过来的朋友,刚接触Kotlin的面向对象时,会觉得有点「怪」。

比如没有new关键字,比如class默认是final的。但用久了你会发现,Kotlin的设计其实更符合直觉。我当年在重构一个电商项目时,把Java的POJO全部换成数据类,代码量直接砍掉三分之一。嗯,那种感觉确实很爽。

3.1 类与对象:从基础说起

Kotlin里定义一个类,用class关键字。实例化时,不需要new

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

fun main() {
    val user = User("张三", 25)  // 直接调用构造器
    println(user.name)          // 张三
    user.age = 26               // var 属性可修改
}

这里有个细节:val修饰的属性只有getter,var修饰的既有getter也有setter。我个人习惯把不可变的字段都用val声明,能少踩很多坑。

小技巧: 如果你需要自定义getter或setter,可以这样写:
var age: Int = 0
    get() = if (field < 0) 0 else field
    set(value) { field = value.coerceAtMost(150) }

3.2 继承:默认是封闭的

Kotlin的类默认是final的,不能被继承。这跟Java正好相反。为什么要这么设计?说白了,就是鼓励你用组合而非继承。我在项目中见过太多滥用继承导致代码难以维护的例子。

想让一个类可以被继承,得加上open关键字:

open class Animal(val name: String) {
    open fun speak() {
        println("$name 发出声音")
    }
}

class Dog(name: String) : Animal(name) {
    override fun speak() {
        println("$name 汪汪叫")
    }
}

注意看,override是强制要求写的。这跟Java的@Override注解不同,Kotlin把它变成了关键字。我曾经在团队里推行这个规范后,重写方法时漏写注解的问题再也没出现过。

注意: 子类重写父类方法时,如果父类方法没有加open,子类是不能重写的。编译期就会报错,这其实是个好事。

3.3 接口:可以有默认实现

Kotlin的接口跟Java 8有点像,可以定义抽象方法,也可以提供默认实现。

interface Flyable {
    fun fly()  // 抽象方法
    fun takeOff() {
        println("准备起飞...")  // 默认实现
    }
}

class Bird : Flyable {
    override fun fly() {
        println("小鸟飞翔")
    }
}

你想想看,这在做多态设计时特别方便。比如我们有个支付模块,Payable接口里可以放一个通用的日志记录方法,子类不用每个都去实现一遍。

3.4 数据类:专为保存数据而生

数据类是我在Kotlin里最喜欢的特性之一。用data关键字修饰,编译器会自动生成equals()hashCode()toString()copy()等方法。

data class User(val id: Int, val name: String, val email: String)

fun main() {
    val user1 = User(1, "李四", "li@example.com")
    val user2 = user1.copy(name = "王五")  // 只修改name
    println(user1)  // User(id=1, name=李四, email=li@example.com)
    println(user2)  // User(id=1, name=王五, email=li@example.com)
}

我曾经在做一个IM项目时,消息体全部用数据类定义。配合copy()方法做不可变更新,再也不用担心多线程下的数据竞争问题。

核心要点: 数据类要求主构造器至少有一个参数,且所有参数必须用valvar标记。另外,数据类不能是抽象、开放、密封或内部类。

3.5 密封类:受限的类层次结构

密封类用sealed关键字修饰,它表示一个受限的类层次结构。说白了,就是告诉编译器:「这个类的子类只有我知道的这几个」。

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Loading : Result()
}

fun handleResult(result: Result) {
    when (result) {
        is Result.Success -> println("成功: ${result.data}")
        is Result.Error -> println("失败: ${result.message}")
        Result.Loading -> println("加载中...")
    }  // 不需要else分支,因为所有情况都覆盖了
}

这个特性在写网络请求状态管理时特别好用。我习惯把请求的成功、失败、加载中三种状态定义成密封类的子类,配合when表达式,编译器会帮我检查有没有遗漏的情况。

3.6 枚举类:比Java更强大

Kotlin的枚举类除了能定义常量,还可以有属性和方法:

enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF);

    fun hexString() = "#${rgb.toString(16).padStart(6, '0')}"
}

fun main() {
    println(Color.RED.hexString())  // #ff0000
}

注意枚举常量之间的分号是必须的,否则编译器会报错。嗯,这个细节我刚开始也经常忘。

3.7 伴生对象:Kotlin的「静态成员」

Kotlin没有static关键字。那静态方法怎么办?用伴生对象(companion object)。

class Config {
    companion object {
        const val BASE_URL = "https://api.example.com"
        fun createDefault(): Config = Config()
    }
}

fun main() {
    println(Config.BASE_URL)           // 直接访问
    val config = Config.createDefault() // 调用静态方法
}

伴生对象本质上是一个单例对象,它属于类本身。你还可以给它起名字:

class MyClass {
    companion object Factory {
        fun create(): MyClass = MyClass()
    }
}

// 调用时两种方式都可以
MyClass.create()
MyClass.Factory.create()

我个人习惯在工具类或者工厂模式里用伴生对象。比如写一个DateUtils,把所有的日期格式化方法都放在伴生对象里,调用起来特别清爽。

避坑指南: 我曾经在混淆配置时忘了保留伴生对象的成员,导致线上崩溃。如果你用了伴生对象,记得在混淆规则里加上-keepclassmembers class * { companion object *; }

小结

Kotlin的面向对象设计,说白了就是在Java的基础上做了很多「减法」和「加法」。减掉了冗余的样板代码,加上了更安全的类型约束。数据类和密封类是我日常开发中使用频率最高的两个特性,强烈建议你在项目中多试试。

下一章我们会聊到Kotlin的函数式编程,包括高阶函数、Lambda表达式和内联函数。到时候你会发现,Kotlin的精华其实在函数式这一块。