请收下这些Kotlin开发必知必会的编码实践方式

3,046 阅读13分钟

我正在参加「掘金·启航计划」

任何傻瓜都可以编写计算机可以理解的代码。优秀的程序员编写出人类可以理解的代码。— 马丁·福勒

目前来说,作为Kotlin开发者想必对一些常见的比较优秀的编程实践方式已经耳熟能详了吧;下面让我们,一起来巩固下日常开发中常见的Kotlin编码实践方式,温故而知新,尽管有些知识点非常简单,但请务必牢牢掌握,有些细节的东西也值得我们注意;本文笔者将从以下几个方面进行巩固复习,将配合大量示例代码辅助说明,可能会略显枯燥;

Kotlin常用编码实践方式.png

常量Constant实践方式

首先简单列举下Kotlin中常用的常量类型,接下来我们从两个方面来讨论下Kotlin常量的实践方式

  • 在顶层和伴随对象中定义常量
  • Java中访问Kotlin中的静态对象

允许的常量类型

Kotlin 只允许定义基本类型(数字类型、字符和布尔值)String类型的常量

让我们尝试定义一个自定义类型的常量。首先,让我们创建一个没有任何逻辑的空类

class TestClass {}

然后我们在常量类型中去使用这个类

const val constantAtTopLevel = TestClass()

结果会发生什么?当然是报错啦,编译器会提示以下错误,告诉我们只允许基本类型和String类型

Const 'val' has type 'SimpleClass'. Only primitives and String are allowed

Constant 顶层声明

上述我们尝试定义的自定义类型常量也是在顶层进行声明的,值得注意的是注意 Kt 不会将文件与类匹配。在一个文件中,我们可以定义多个类。与 Java 不同,Kt不需要每个文件一个父类。每个文件可以有多个父类。

文件也可以有类之外的函数和变量。这些函数和变量可以直接访问。

让我们在刚才的文件中再创建一个常量:

const val CONSTANT_AT_TOP_LEVEL = "constant value"

现在,我们从另一个类中去访问它

class ConstantAtTopLevelTest {
    @Test
    fun whenAccessingConstantAtTopLevel_thenItWorks() {
        Assertions.assertThat(CONSTANT_AT_TOP_LEVEL).isEqualTo("constant value")
    }
}

事实上,常量可以从任何类访问。如果我们将一个常量声明为私有的, 它就只能被同一个文件中的其他类访问。当我们想要在文件或整个应用程序中的类之间共享一些值的时候,顶级常量(top-level)是一个比较好的解决方案。 此外,当值与特定类无关时,使用顶级常量是一个很好的选择。

局限性

尽管声明一个顶级变量非常简单,但也需要注意下声明该类常量时出现的一些限制。如上所述,定义在文件顶层的常量可以被同一文件中的任何类访问,即便它是私有的。我们也不能限制该文件中特定类的可见性。因此可以得出的结论是,此类常量不与任何类相关联

此外,对于顶级常量,编译器会生成一个新类。为此,编译器创建了一个类,其原始文件的名称后缀为Kt。在上面的例子中,它是*ConstantAtTopLevelTestKt,其中原始文件名是ConstantAtTopLevelTest.kt*`

Constant 伴随对象声明

现在让我们在伴随对象中定义一个常量

class ConstantsBestPractices {
    companion object {
        const val CONSTANT_IN_COMPANION_OBJECT = "constant at in companion object"
    }
}

之后,让我们从*ConstantInCompanionObjectTest*类访问它

class ConstantInCompanionObjectTest {
​
    @Test
    fun whenAccessingConstantInCompanionObject_thenItWorks() {
        Assertions.assertThat(CONSTANT_IN_COMPANION_OBJECT).isEqualTo("constant in companion object")
    }
}

这个时候该字段是属于一个类的。所以当我们想将值与类相关联时,在伴随对象中定义常量是一个比较好的解决方案,我们通过类的上下文访问它。

Java 中访问的静态对象

现在来看下Java代码中的静态对象的可访问性。使用上述已经创建好的顶层常量和伴随对象中的常量,我们新建一个*AccessKotlinConstant* Java 类

public class AccessKotlinConstant {
    private String staticObjectFromTopLevel = ConstantPracticeKt.CONSTANT_AT_TOP_LEVEL;
    private String staticObjectFromCompanion = ConstantsPractices.CONSTANT_IN_COMPANION_OBJECT;
}

一方面,顶层声明的常量可以从 Java 访问,生成的类名后缀为Kt 另一方面,伴随对象常量可以通过类名直接访问

简化我们的函数

如何避免 for 循环

在日常开发中,我们会经常用到For 循环,它是命令式编程的一个很好的结构。但是,如果有一个函数可以为你完成这项工作,那么最好改用该函数,这样能让你的代码变得简洁易懂。下面就来谈谈For循环在一些特定环境下使用的替代的实践方式

  • 使用repeat
//最好不要
fun  main () { 
  for (i in 0 until 10) { 
    println(i) 
  } 
} 
​
//可以这样写
fun  main () { 
  repeat(10) { 
    println(it) 
  } 
}
  • 使用forEach
// DON'T 
fun  main () { 
  val list = listOf( 1 , 2 , 3 , 4 , 5 , 6 ) 
  for (e in list) { 
    println(e) 
  } 
} 
​
// DO 
fun  main () { 
  listOf( 1 , 2 , 3 , 4 , 5 , 6 ).forEach { 
    println(it) 
  } 
}
  • 使用Map
// DON'T 
fun  main () { 
  val list = listOf( 1 , 2 , 3 , 4 , 5 , 6 ) 
  val newList = mutableListOf< Int () 
  for (e in list) { 
    newList.add(e * e) 
  } 
} 
​
// DO 
fun  main () { 
  val list = listOf( 1 , 2 , 3 , 4 , 5 , 6 ) 
  valnewList = list.map { it * it } 
}

…还有更多的功能可以用来消除对循环的需求,这里就需要开发者在实际运用场景自行斟酌。

使用高阶函数

所谓的高阶函数,简单来说就是使用函数作为参数或返回值的函数,上面使用的代码可能不是最简洁的写法。我们可以通过将函数引用传递给高阶函数来进一步缩短我们的代码。下面简单举个栗子:

fun  main () {
   val input = readLine()
  input?.let {
     val sentence = it.split( " " )
    sentence.map(String::length).also(::println)
  }
}

String::length传递类型的 lambda 函数(String) -> Int::println传递类型的 lambda 函数(Int) -> Unit

扩展值

如果开发者必须在代码的多个位置使用相同的值,我们可以考虑使用扩展值,这样可以有效避免代码冗余。

// 返回 int 的扩展值:
// 第一个单斜杠的索引
 private val String.hostEndIndex: Int
    get () = indexOf( '/' , indexOf( "//" ) + 2 )
    fun  main () {
        val url = "https://jackytallow.com/@cybercoder.aj"
        val host = url.substring( 0 , url.hostEndIndex).substringAfter( "//" )
        val path = url.substring(url.hostEndIndex).substringBefore("?")
    }

优化条件结构方法的返回

如果你有一个有条件地返回不同值的函数,而不是在条件结构的每一行中都有返回return,你可以将返回提取出来统一处理,这样会简洁一些。下面以斐波那契数列方法为例:

fun main () {
  println(fibo(6))
}
​
 fun fibo (n: Int) : Int {
   return  when (n) {
     0 -> 0 
    1 -> 1 
    else -> fibo(n - 1) + fibo(n - 2)
  }
}

此外,我们还可以通过将函数代码块转换为单行表达式函数来继续改进这一点。

fun main () {
  println(fibo(6))
}
fun fibo (n: Int) : Int = when (n) {
   0 -> 0 
  1 -> 1 
  else -> fibo(n - 1) + fibo(n - 2)
}

灵活使用标准函数

Kotlin 中有 5 个主要的作用域函数可以利用:letalsoapply和。withrun,它们之间的区别相信大家已经非常熟悉了,下面分别谈谈它们日常开发中运用的基本场景,这些标准函数的出现旨在让我们的代码看起来更加优雅

let函数

let函数用比较官方的说法就是默认当前这个对象作为闭包的it参数,返回值为函数最后一行或者return

  • 通俗的来说,我们使用它来将一种对象类型转换为另一种对象类型,比如说使用StringBuilder并计算其长度

    val stringBuilder = StringBuilder()
    val numberOfCharacters = stringBuilder.let {
        it.append("这是一个转换方法")
        it.length
    }
    
  • let 函数也可以用于绕过可能的空类型。

fun  main () {
   val age = readLine()?.toIntOrNull()
   age?.let {
    println( "你是$it岁" ); 
  } ?: println( "输入错误!" ); 
}

letalso的不同之处在于返回类型会发生变化

also函数

这接收一个对象并对其执行一些额外的任务。其实就是相当于给定一个对象,对该对象进行一些相关操作also返回它被调用的对象,所以当我们想在调用链上生成一些辅助逻辑时,使用also会很方便

fun  main () {
  Person(
    name = "JackyTallow" ,
    age = 23 ,
    gender = 'M'
   ).also { println(it) }
}

run函数

此函数与函数类似let,但这里传递的是对象引用 是this而不是it,通常我们可以这么理解,run与let的关联方式和apply与also的关联方式相同

  • 下面我们依旧使用StringBuilder并计算其长度,这里我们使用run函数
val message = StringBuilder()
val numberOfCharacters = message.run {
    length
}
  • 对于let,我们将对象实例称为it,但在这里,对象是lambda 内部的隐式this

同样的,我们可以使用与let相同的方法来处理可空性:

val message: String? = "hello there!"
val charactersInMessage = message?.run {
    "value was not null: $this"
} ?: "value was null"

apply函数

applyalso差不多,它会初始化一个对象,不同的是它有一个隐含的this,当你希望更改对象的属性或行为时使用此函数,最后再返回这个对象。

fun  main () {
   val me = Person().apply {
    name = "JackyTallow"
     age = 23
     gender = 'M'
   }
  println(me)
}
  • 值得注意的是,我们也可以用apply来构建builder模式的对象
data class Teacher(var id: Int = 0, var name: String = "", var surname: String = "") {
    fun id(anId: Int): Teacher = apply { id = anId }
    fun name(aName: String): Teacher = apply { name = aName }
    fun surname(aSurname: String): Teacher = apply { surname = aSurname }
}
​
val teacher = Teacher()
    .id(1000)
    .name("张三")
    .surname("Spector")

with函数

当你想使用一个对象的某个属性/多个属性时使用这个函数。简单来说,它只是apply函数的语法糖

fun  main () {
   val me = with(Person()) {
     name = "JackyTallow"
     age = 23
     gender = 'M'
   }
  println(me)
}

另一种看待它的方式是在逻辑上将对给定对象的多个属性调用方法进行分组,比如说我们的账户验证,或者相关账户名称验证操作

   with(bankAccount) {
    checkAuthorization(...)
    addPayee(...)
    makePayment(...)
   }

运算符重载和中缀函数

运算符重载

我们可以在Kotlin的官方文档中找到运算符函数列表,在 Kotlin 中,+、- 和 * 等运算符链接到相应的函数,通过在你的类中提供这些函数,你可以在 DSL 中创建一些非常简洁的处理语法,这些函数在我们的代码中充当语法糖。下面只是简单使用了下示例:

fun  main () {
   val list = mutableListOf( 1 , 2 , 3 )
  (list.puls(4).forEach(::println))
}
​
 operator  fun  <T> MutableList <T>.plus (t: T ) : MutableList<T> {
   val newList = mutableListOf<T>().apply { addAll( this@plus ) }
  newList.add(t)
   return newList
}

上面是笔者简单手写了个plus函数,它是按照Kt源码中自带的plus函数的基础上进行修改的。不要滥用此功能,一般来说,仅在你的特定DSL中执行此操作

中缀函数

Kotlin 允许在不使用句点和括号的情况下调用某些函数,这些就被称之为中缀表示法,这样使得代码看起来更贴合自然语言,可以看到最常见的Map中的定义

map(
  1 to "one",
  2 to "two",
  3 to "three"
)

可以看到to特殊关键字就是一个利用中缀表示法并返回Pair<A, B> 的to()方法

通用标准函数库中的中缀函数

除了用于创建Pair<A, B> 实例的 to() 函数之外,还有一些其他函数被定义为中缀。 例如,各种数字类——Byte、Short、IntLong—— 都定义了按位函数and()、or()、shl()、shr()、ushr()xor(), 允许更多可读表达式:

val color = 0x123456
val red = (color and 0xff0000) shr 16
val green = (color and 0x00ff00) shr 8
val blue = (color and 0x0000ff) shr 0
  • Boolean类以类似的方式定义and()、or()xor( ) 逻辑函数:
if ((targetUser.isEnabled and !targetUser.isBlocked) or currentUser.admin) {
    // Do something if the current user is an Admin, or the target user is active
}
  • String类还将matchzip函数定义为中缀,允许一些易于阅读的代码
"Hello, World" matches "^Hello".toRegex()

在整个标准库中还可以找到一些其他中缀函数的示例,以上这些应该是日常开发中最常见的

自定义简单中缀函数

我们也可以编写属于自己的中缀函数,在为我们的应用程序编写领域特定语言时,允许DSL 代码更具可读性。很多开源库已经使用自定义函数并取得了很好的效果,比如说,mockito-kotlin库定义了一些中缀函数—— doAnswerdoReturndoThrow—— 它们都是用于定义模拟行为

要想编写自定义中缀函数,需要遵循以下三个规则:

  • 该函数要么在 class类上定义,要么是 class类的扩展方法

  • 该函数只接受一个参数

  • 该函数是使用infix关键字定义的

    下面笔者简单定义一个断言框架用来测试,在这其中定义自己的中缀函数

class Assertion<T>(private val target: T) {
    infix fun isEqualTo(other: T) {
        Assert.assertEquals(other, target)
    }
​
    infix fun isDifferentFrom(other: T) {
        Assert.assertNotEquals(other, target)
    }
}

是不是看起来很简单,通过使用infix关键字的存在我们可以编写如下代码

val result = Assertion(5)
result isEqualTo 5 
result isEqualTo 6 
result isDifferentFrom 5

这样是不是立马让这段代码变得更加清晰起来,更容易理解

注意一下,中缀函数也可以编写为现有类的扩展方法。这其实挺强大的,因为它允许我们扩充来自其他地方(包括标准库)的现有类以满足我们开发的需要。

例如,让我们向字符串添加一个函数,以提取与给定正则表达式匹配的所有子字符串:

infix fun String.substringMatches(regex: Regex): List<String> {
    return regex.findAll(this)
        .map { it.value }
        .toList()
}
​
 val matches = "a bc def" substringMatches ".*? ".toRegex()
    Assert.assertEquals(listOf("a ", "bc "), matches)

中缀函数的出现使得我们的代码变得更加清晰,更易于阅读

yield函数运用

关于yiled() 函数,我们首先要知道它是一个在Kotlin Coroutines上下文中使用的挂起函数;如果条件允许的话,它会将当前协程调度程序的线程(或线程池)让给其他协程运行

从日常开发角度出发,我们通常在构建序列和实现作业的协作式多任务处理两个方面中会使用到yield() 函数,下面我们来动手实践一下

构建序列

yiled() 最常见的用法之一就是用于构建序列,下面笔者将分别使用它来构建有限序列和无限序列,Let's go

有限序列

假设我们想要构建一个有限的元音序列。对于如此短的序列,我们可以使用多个语句来产生单个元音值。让我们在Yield类中定义vowels() 函数

class Yield {
    fun vowels() = sequence {
        yield("a")
        yield("e")
        yield("i")
        yield("o")
        yield("u")
    }
}

现在我们调用这个vowels方法对此序列进行迭代iterator

val client = Yield()
val vowelIterator = client.vowels().iterator()
while (vowelIterator.hasNext()) {
    println(vowelIterator.next())
}

在这个简单的场景中,关于有限序列需要注意的一件重要事情,在调用vowelIteratornext() 方法之前,我们应该始终使用hasNext() 方法检查序列中是否有下一个可用项

无限序列

使用yield() 的一个更实际的用例是构建无限序列。因此,让我们用它来生成斐波那契数列的项。为了构建这样一个序列,让我们编写一个fibonacci() 函数,它使用一个无限循环,在每次迭代中产生一个单项:

fun fibonacci() = sequence {
    var terms = Pair(0, 1)
    while (true) {
        yield(terms.first)
        terms = Pair(terms.second, terms.first + terms.second)
    }
}

接下来,让我们通过对该序列使用迭代器来验证序列的前五项:

val client = Yield()
val fibonacciIterator = client.fibonacci().iterator()
var count = 5
while (count > 0) {
    println(fibonacciIterator.next())
    count--
}

对于以上这个无限序列,可以放宽对迭代器的hasNext() 方法调用,因为我们可以保证获得序列的下一个元素

协作式多任务处理

介绍已经说了yield() 是一个挂起函数,它允许当前调度的线程让给另一个协程运行,而在协作式多任务系统中,一个任务会自愿放弃以允许另一个作业执行,这也意味者,我们是不是可以使用yield() 实现协作式多任务处理

数字打印机

下面我们来实践下,实现一个简单的奇偶数字打印机,假设我们要打印低于特定阈值的所有数字

  • 使用AtomicInteger将当前值定义为0,并将阈值定义为常量整数值
val current = AtomicInteger(0)
val threshold = 10
  • 定义一个numberPrinter() 函数来协调数字的打印,这里笔者打算定义两个不同的作业来分而治之,一个用于打印偶数,另一个用于打印奇数,所以为了确保我们一直等到所有低于阈值的数字都被打印出来,这里将使用runBlocking
fun numberPrinter() = runBlocking {
    val eventNumberPrinter = ...
    val oddNumberPrinter = ...
}
  • 接下来将打印数字的任务委托给两个不同作业部分, 即evenNumberPrinteroddNumberPrinter

    • 首先先看看如何启动eventNumberPointer作业:

      val evenNumberPrinter = launch {
          while (current.get() < threshold) {
              if (current.get() % 2 == 0) {
                  println("$current is even")
                  current.incrementAndGet()
              }
              yield()
          }
      }
      

      可以看到非常直观,仅在偶数的时候才打印当前值,然后使用了yield() 函数意味着,它需要明白与另一个可以打印奇数值的任务合作,所以它自愿让步了

    • 接下来,再看看oddNumberPrinter作业,除了只打印奇数的情况外,它本质上是相同的:

      val oddNumberPrinter = launch {
          while (current.get() < threshold) {
              if (current.get() % 2 != 0) {
                  println("$current is odd")
                  current.incrementAndGet()
              }
              yield()
          }
      }
      
  • 最后,我们调用numberPrinter() 来打印数字,正如预期的那样,我们能够看到所有低于阈值的数字

0 is even
1 is odd
2 is even
3 is odd
4 is even
5 is odd
6 is even
7 is odd
8 is even
9 is odd

综上我们通过构建序列和协作式多任务处理两种方式对yield() 函数进行简单实践运用

参考