6、深入解析Kotlin类与对象:构造、伴生、单例全面剖析

前言

本篇文章将带您了解Kotlin编程中的重要概念:类及构造函数、访问修饰符、伴生对象和单例模式。就像搭积木一样,我们会逐步揭开这些概念的面纱,让您轻松理解它们的作用和用法。无论您是编程新手还是有经验的开发者,本文都将为您提供有趣而易懂的内容,帮助您更好地掌握Kotlin中类与对象的重要知识点。让我们一起开始这段有趣的学习之旅吧!

一、Kotlin 的类以及构造函数

1、声明一个类

当你使用 class 关键字在 Kotlin 中声明一个类时,其基本的语法格式如下所示:

class ClassName {
    // 类的成员变量和方法在这里定义
}

2、关于Kotlin类的继承和接口实现的规则

(1)继承父类

如果一个类有父类,你可以在类的声明中使用冒号 : 后面跟着父类的名称。这称为类的继承。例如:

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 在这里可以进行一些初始化操作
    }
}
  • 继承父类无参构造函数:如果父类有一个无参构造函数,子类可以省略括号,并且不需要在子类中声明构造函数。这适用于父类的构造函数没有参数的情况,或者所有参数都有默认值。

  • 继承父类有参构造函数:如果父类的构造函数有参数,或者你想在子类中添加自己的构造函数,就需要根据具体情况使用括号并传递必要的参数。

(2)默认父类

如果一个类没有显式声明父类,它的默认父类是 Any 类,而不是 Object 类。Any 是 Kotlin 根类层次结构的顶层类型。所有的 Kotlin 类都直接或间接地继承自 Any 类。

Any 类的声明如下:

public open class Any

在 Kotlin 中,默认情况下,所有类都隐式地继承自 Any 类,即使你没有明确指定。因此,以下两个类声明是等价的:

class MyClass
class MyClass : Any()

这就是为什么你在 Kotlin 中无需显式指定父类,而默认情况下所有类都已经继承自 Any

需要注意的是,Any 类定义了一些通用方法,如 equalshashCodetoString,因此所有的 Kotlin 类都会具备这些方法。

(3)接口实现

在 Kotlin 中,可以在类的声明后面使用冒号 : 来实现一个或多个接口。你不需要显式使用 implements 关键字。例如:

import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity(), View.OnClickListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                // 在这里定义按钮点击事件的逻辑
            }
        }
    }
}

在上面的示例中,我们首先让 MainActivity 类实现了 View.OnClickListener 接口,并重写了其中的 onClick 方法。然后,我们在 onCreate 方法中使用 setOnClickListener(this) 将当前 MainActivity 对象作为按钮的点击监听器。

在 Kotlin 中,你是可以先写接口后写父类,或者先写父类后写接口,都是完全合法的。在类的声明中,继承父类和实现接口的顺序可以根据你的需求进行灵活安排。

3、open 关键字的作用

在 Kotlin 中,默认情况下,类是 publicfinal 的,这意味着它们对外部可见,并且不能被继承。如果你希望其他类能够继承这个类,你需要使用 open 关键字来声明这个类,使它变成可继承的。

在 Kotlin 中,修饰符的含义如下:

  • public:表示该成员(类、函数、属性等)在任何地方都可见。
  • final:表示类、函数或属性不能被继承或覆盖(重写),在类级别指的是类不能被继承。
  • open:用于修饰类、函数或属性,表示它们可以被继承或覆盖。

如果你想要创建一个可被继承的类,你需要使用 open 修饰符。

例如,当你想在 Android 应用中创建一个可被继承的 MainActivity 类时,你需要在类的声明前面加上 open 修饰符,以允许其他类继承它。这样,其他类就可以继承 MainActivity 并在子类中进行扩展。

以下是一个示例,展示如何在 Android 应用中创建一个可被继承的 MainActivity 类:

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

open class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 在这里可以进行一些初始化操作
    }
}

在这个示例中,我们创建了一个名为 MainActivity 的类,继承自 AppCompatActivity,这是 Android 中用于创建活动(Activity)的基类。注意,在类的声明中,我们使用了 open 修饰符,允许其他类继承这个 MainActivity

其他类可以继承这个 MainActivity 并在子类中进行扩展,例如:

class CustomActivity : MainActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 在这里可以进行自定义的初始化操作
    }
}

在上面的示例中,CustomActivity 类继承自 MainActivity,并在子类中重写了 onCreate 方法,实现了自定义的初始化操作。

总结,通过使用 open 修饰符,你可以在 Android 应用中创建一个可被继承的基类,让其他类能够继承它并在子类中添加自己的功能。

4、主构造函数和次构造函数

构造函数是用于创建对象的特殊方法,它在对象创建时被调用,通常用于执行初始化操作。在 Kotlin 中,类可以有一个主构造函数和多个次构造函数。

(1)主构造函数

当涉及到在 Kotlin 类中创建实例时,主构造函数扮演着重要的角色。主构造函数允许在对象被实例化时接收参数,并且对对象的属性进行初始化。以下是关于主构造函数的概要:

  • 声明方式:主构造函数是类定义的一部分,紧随类名后面的括号中可以包含参数列表。如果主构造函数没有其他修饰符,那么括号可以省略。
  • 参数传递:主构造函数的参数可以在整个类范围内使用,不仅限于初始化代码块。你可以将这些参数直接用于属性的初始化。
  • 初始化代码块:主构造函数也可以包含初始化代码块,使用 init 关键字定义。在这个代码块中,你可以编写在对象初始化时执行的代码。

以下是一个示例,展示了如何声明主构造函数、使用参数初始化属性,并在初始化代码块中执行逻辑,其中使用了自定义视图类 CustomView

class CustomView(context: Context, attrs: AttributeSet): View(context, attrs) {
    private val paint: Paint = Paint()

    init {
        paint.color = Color.BLACK
    }
}

在上述示例中,CustomView 类继承自 View,并在主构造函数中接收了 contextattributeSet 参数。

在类的主体中,声明了一个名为 paint 的私有属性,并在初始化时进行了属性初始化。使用了 Paint 类,这是 Android 中用于绘制图形和文本的工具类。

在类的 init 块中,设置了 paint 的颜色为黑色(Color.BLACK)。这将确保 paint 对象在视图实例化时就被配置为黑色,以便在后续的绘制操作中使用。

当创建 CustomView 的实例时,会触发 init 块中的代码执行,从而完成属性的初始化设置。init 块是一个在对象实例化时执行初始化代码的地方,它允许你在构造函数中接收参数,并在 init 块中执行必要的初始化逻辑。在自定义视图类中,你可以使用 init 块来确保对象在使用之前处于正确的状态。

(2)次构造函数

次构造函数是在 Kotlin 中用于提供额外的构造函数选项的一种方式。它允许你在同一个类中定义多个不同参数集的构造函数,以满足不同的初始化需求。次构造函数通常调用主构造函数或者其他次构造函数,确保共享的初始化逻辑被重复使用。

以下是关于次构造函数的一些重要信息:

  • 声明方式:次构造函数通过 constructor 关键字声明,紧随其后的括号内包含参数列表。次构造函数可以有不同的参数集,但不能直接初始化属性。
  • 调用主构造函数:在次构造函数内部,你可以使用 this() 调用主构造函数,确保共享的初始化逻辑被执行。调用主构造函数时,可以传递相应的参数。
  • 调用其他次构造函数:在次构造函数内部,你也可以使用 this() 调用同一个类中的其他次构造函数。这样可以避免重复编写初始化逻辑。

以下是一个示例,展示了如何在 CustomView 类中使用次构造函数:

class CustomView : View {
    constructor(context: Context) : this(context, null, 0)

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initialize(context, attrs, defStyleAttr)
    }

    private fun initialize(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        // 在这里执行初始化操作
    }

}

在上面的示例中,CustomView 类继承自 Android 中的 View 类,并且有三个构造函数:

  • 第一个构造函数接收一个 context 参数,这是主构造函数。它通过调用次构造函数,为 attrsdefStyleAttr 提供默认值。
  • 第二个构造函数接收 contextattrs 参数,调用了具有更多参数的次构造函数。
  • 第三个构造函数接收 contextattrsdefStyleAttr 参数,它是主要的构造函数。它通过调用父类 View 的构造函数来实现,并在构造函数内部调用了 initialize 函数来执行额外的初始化操作。

(3)大家来找茬

可以尝试将下面示例代码放到编译器中,会发现编译不通过, super 处会提示 “Primary constructor call expected”。问题出在了哪里?

class CustomView(context: Context) : View(context) {

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) {
        initialize(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initialize(context, attrs, defStyleAttr)
    }

    private fun initialize(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        // 在这里执行初始化操作
    }

}

错误消息 “Primary constructor call expected” 意味着在构造函数的调用位置需要调用主构造函数,但实际上没有调用。

在 Kotlin 中,如果一个类有一个主构造函数,那么所有的次构造函数必须委托给主构造函数。这是语言的规定,确保类的初始化能够始终通过主构造函数完成。

在我们的代码中,主构造函数是 class CustomView(context: Context) : View(context) 这一行。由于它是一个主构造函数,所有的次构造函数必须通过 this() 来调用主构造函数。因此,不能在次构造函数中使用 super 关键字来调用父类的构造函数。

以下是修正后的代码示例,满足了 Kotlin 的构造函数规定:

class CustomView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : View(context, attrs, defStyleAttr) {

    constructor(context: Context) : this(context, null, 0) 

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
  
    init {
        initialize(context, attrs, defStyleAttr)
    }

    private fun initialize(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        // 在这里执行初始化操作
    }
}

在这个修正后的代码中,主构造函数是 class CustomView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : View(context, attrs, defStyleAttr) ,并且所有的次构造函数都通过 this() 来委托主构造函数。统一在 init 添加初始化操作,以满足我们的需求。

(4)@JvmOverloads 注解

使用了 @JvmOverloads 注解来为 CustomView 类的构造函数生成所有可能的参数组合,以简化构造函数的使用。这样,你可以使用主构造函数,也可以选择性地使用次构造函数。

我们将上述代码再次改造一下:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    init {
        initialize(context, attrs, defStyleAttr)
    }

    private fun initialize(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        // 在这里执行初始化操作
    }
}

使用这个示例,你可以这样创建一个 CustomView 实例:

val customView1 = CustomView(context)  
val customView2 = CustomView(context, attrs)  
val customView3 = CustomView(context, attrs, defStyleAttr)  

@JvmOverloads 是 Kotlin 中的一个注解,用于在编译器级别生成多个重载版本的函数或构造函数,以便在 Java 代码中更方便地使用。这个注解通常用于改善 Kotlin 类在 Java 代码中的互操作性。

在特定情况下,Kotlin 的函数或构造函数可能具有默认参数,这使得在 Kotlin 中调用它们非常方便,因为可以省略部分参数。但在 Java 中,调用具有默认参数的 Kotlin 函数会变得不那么直观,因为 Java 不直接支持 Kotlin 的默认参数语法。

这时,@JvmOverloads 就派上用场了。当你在 Kotlin 函数或构造函数上使用 @JvmOverloads 注解时,Kotlin 编译器会自动为每个省略参数的版本生成一个重载版本,以便在 Java 代码中使用。这样,Java 开发者就可以根据需要传递不同数量的参数,而不必理解或设置 Kotlin 的默认参数。

具体来说,对于构造函数来说,@JvmOverloads 会在编译时为每个可能的参数组合生成一个单独的构造函数,以确保 Java 开发者能够使用所有参数组合。

总结:@JvmOverloads 是一个用于改善 Kotlin 代码在 Java 中互操作性的注解,它为具有默认参数的函数或构造函数生成重载版本,使得 Java 开发者能够更方便地使用这些函数或构造函数。

(5)init 函数,父类的构造函数与次构造函数之间调用顺序

  • 首先,会执父类的 init 块(如果有的话)
  • 其次,会执行父类的构造函数(主构造函数或者被次构造函数调用的构造函数)。
  • 然后,会执行当前类的 init 块(如果有的话)。
  • 最后,会执行当前类的次构造函数(如果有的话)。

以下是一个示例来说明这个调用顺序:

open class Parent {
    init {
        println("Parent's init block")
    }
    
    constructor() {
        println("Parent's constructor")
    }
}

class Child : Parent {
    constructor() : super() {
        println("Child's constructor")
    }

    init {
        println("Child's init block")
    }
}

fun main() {
    val obj = Child()
}

在这个示例中,当创建 Child 类的实例时,首先会调用 Parent 类的 init 块,其次是 Parent 类的构造函数,然后执行 Child 类的 init 块,最后执行 Child 类的次构造函数。因此,输出将是:

Parent's init block
Parent's constructor
Child's init block
Child's constructor

二、访问修饰符

在 Kotlin 中,访问修饰符用于控制类、接口、函数、属性等成员的可见性。Kotlin 提供了一些访问修饰符,用于指定成员在哪些范围内可见。以下是常见的访问修饰符:

  1. public(默认):没有显式指定访问修饰符时,默认为 public。在这种情况下,成员对所有其他代码都可见。
  2. private:成员只在声明它的文件中可见。私有成员对同一文件中的其他类都不可见。
  3. protected:成员在声明它的类及其子类中可见。但在同一包内的其他类中不可见。
  4. internal:成员对于同一模块内的所有代码都可见。模块是一组一起编译的 Kotlin 文件。它更多的去用于我们现在做一些项目的结构化的扩展的时候去使用这个修饰符。

示例:

class MyClass {
    private val privateVar = 1
    protected val protectedVar = 2
    internal val internalVar = 3
    val publicVar = 4 // 默认为 public
}

fun main() {
    val obj = MyClass()
    println(obj.internalVar) // 可以访问,因为处于同一模块内
    println(obj.publicVar)   // 可以访问,因为是 public

    // 下面的代码不能编译通过,因为 private 和 protected 变量不在可见范围内
    // println(obj.privateVar)
    // println(obj.protectedVar)
}

注意:

  • 对于顶层声明(不在类内部的声明),只有 publicinternal 修饰符有效。
  • internal 修饰符在 Kotlin 中非常有用,它允许模块内的代码访问成员,同时限制了模块之间的访问。
  • privateprotected 对于顶层声明(不在类内部的声明)是无效的,因为它们只对类内部的成员可见性起作用。

综上所述,访问修饰符用于控制代码中成员的可见性范围,确保代码的封装和安全性。

补充一下,什么是模块?

每当创建一个 module,这就是一个模块。如果你的一个变量名通过internal修饰的话,在同一个模块内的时候,是可以互相调用的,但是跨模块的话,你是无法访问到这个变量的。这就是 kotlin 的这个访问修饰符,它更多的去用于我们现在做一些项目的结构化的扩展的时候去使用这个修饰符。

6、深入解析Kotlin类与对象:构造、伴生、单例全面剖析_第1张图片

三、伴生对象

在 Kotlin 中,每个类都可以有一个伴生对象(Companion Object),它是该类的一个特殊对象实例。伴生对象的成员可以像 Java 静态成员一样在类级别上访问,不需要通过类的实例来调用。伴生对象通常用于创建类级别的静态成员、工厂方法、单例模式等。

在 JAVA 中,我们经常看到这样的一段代码,就是工具类,里面定义了一些的静态方法,可以直接用工具类的类名 . 方法名,去调用它。如下所示:

public class StringUtils {
    public static final String EMPTY = "";

    public static boolean isEmpty(CharSequence cs) {
        return cs == null || cs.length() == 0;
    }

    public static boolean isNotEmpty(CharSequence cs) {
        return !StringUtils.isEmpty(cs);
    }
}

但是在 kotlin 中是没有静态方法的,Kotlin 要怎么改写呢?

方法有两种,第一种办法是我们在《Kotlin 代码与 Java 代码集成》中介绍过,使用 @JvmStatic 注释暴露 Kotlin 类的静态方法。

那么第二种方法就是现在要介绍的伴生对象 Companion Object ,它必须被写在一个类的内部,作为这个类的伴生对象。伴生对象可以包含成员函数、属性和初始化代码块,类似于 Java 中的静态成员。它的一个主要作用是允许在不创建类实例的情况下访问这些成员,就像访问静态成员一样。

那么我们来改写一下:

class StringUtils {
    companion object {
        const val EMPTY = ""

        fun isEmpty(cs: CharSequence?): Boolean {
            return cs.isNullOrEmpty()
        }

        fun isNotEmpty(cs: CharSequence?): Boolean {
            return !isEmpty(cs)
        }
    }
}

在上面的示例中,StringUtils 类中定义了一个伴生对象,其中包含一个常量 EMPTY 和 两个方法isEmptyisNotEmpty。那么就可以在类级别上直接访问伴生对象的成员和方法,就像访问类的静态成员和静态方法一样。

在 Java 代码中调用,如下所示:

 public class JavaExample {
    public static void main(String[] args) {
        String str = "";
        boolean isEmpty = StringUtils.Companion.isEmpty(str);
        boolean isNotEmpty = StringUtils.Companion.isNotEmpty(str);

        System.out.println("isEmpty: " + isEmpty);
        System.out.println("isNotEmpty: " + isNotEmpty);
    }
}

注意,StringUtils.Companion 中的 Companion 是伴生对象的默认名称,因为我们并未显式地为我们的伴生对象命名,伴生对象的名称可以省略,那么此时使用默认的名称 Companion

实际上伴生对象在编译好以后会在这个类的内部生成一个静态对象。叫companion的对象,那么我们Java在调用的时候,实际上是通过这个类的companion对象去调用companion内部的一些变量。

如果你为伴生对象显式命名,例如 MyCompanion,那么需要使用该名称来访问伴生对象的成员。以下是一个使用了命名伴生对象的示例:

class StringUtils {
    companion object MyCompanion {
        const val EMPTY = ""

        fun isEmpty(cs: CharSequence?): Boolean {
            return cs.isNullOrEmpty()
        }

        fun isNotEmpty(cs: CharSequence?): Boolean {
            return !isEmpty(cs)
        }
    }
}

在 Java 代码中调用,如下所示:

 public class JavaExample {
    public static void main(String[] args) {
        String str = "";
        boolean isEmpty = StringUtils.MyCompanion.isEmpty(str);
        boolean isNotEmpty = StringUtils.MyCompanion.isNotEmpty(str);

        System.out.println("isEmpty: " + isEmpty);
        System.out.println("isNotEmpty: " + isNotEmpty);
    }
}

四、单例

半生对象在 Kotlin 中还有一个非常有用的特性,就是可以用于声明单例。在前面的文章中,我们已经介绍了通过 object 关键字来创建匿名的内部类,这种方式其实也可以用来实现单例。然而,更加推荐的写法是利用伴生对象来创建单例。

使用伴生对象来实现单例具有许多优点:

  1. 线程安全: 伴生对象的属性和方法在类加载时进行初始化,确保了线程安全。这样即使在多线程环境下也能保证只有一个实例被创建。
  2. 延迟初始化: 伴生对象的属性和方法是在首次访问时才会被初始化,这也被称为"惰性初始化"。这种方式能够节省资源,只有在实际需要时才会创建实例。
  3. 清晰可见: 伴生对象使得单例模式的实现更加清晰和易于理解。它将单例的代码组织在一起,使得代码结构更加整洁。

总的来说,虽然在 Kotlin 中可以使用 object 关键字来创建单例,但更加推荐使用伴生对象来实现。这种方式不仅更加优雅,还能够带来诸多优势,如线程安全和延迟初始化等。如果你正在考虑实现单例模式,不妨考虑使用伴生对象来达到更好的效果。

以下是如何使用伴生对象来实现单例模式的示例:

class Single private constructor() {
    companion object {
        fun get(): Single {
            return Holder.instance
        }
    }

    private object Holder {
        val instance = Single()
    }
}
  • Single 类的主构造函数被声明为私有,这意味着外部无法直接通过构造函数来实例化该类。
  • Single 类的伴生对象中,我们定义了一个名为 get 的方法,用于获取 Single 类的单例实例。该方法返回了一个 Holder 内部对象的实例,这是通过 Holder.instance 访问的。
  • 内部的 Holder 对象的实例化是通过 Kotlin 中的对象声明(object)实现的。由于 Kotlin 的对象声明在首次访问时就会被初始化,且只会被初始化一次,这确保了线程安全的单例创建。

上述代码展示了一种在 Kotlin 中使用伴生对象来创建单例的方式,这是一种非常常见的做法。这种方式结合了私有的主构造函数和内部对象,确保了单例的线程安全性和延迟初始化。

你可能感兴趣的:(Kotlin开发,kotlin,java,android)