Kotlin简介

Kotlin是JVM上比较新的语言之一,来自IntelliJ开发商JetBrains。它是一种静态类型语言,旨在提供一种混合OO和FP的编程风格。Kotlin编译器生成的字节码与JVM兼容,可以在JVM上运行及与现有的库互操作。2017年,谷歌支持将其用于Android开发,Kotlin获得了重大突破。

JetBrains有一个明确的目标:让Kotlin成为一种多平台语言,并提供100%的Java互操作性。Kotlin最近的成功和成熟水平为它进入服务器端提供了一个很好的机会。

选择Kotlin的理由

许多语言都试图成为更好的Java。Kotlin在语言和生态系统方面做得都很好。成为更好的Java,同时又要保护JVM和巨大的库空间,这是一场姗姗来迟的进化。这种方法与来自JetBrains和谷歌的支持相结合,使它成为一个真正的竞争者。让我们来看看Kotlin带来的一些特性。

类型推断 —— 类型推断是一等特性。Kotlin推断变量的类型,而不需要显式指定。在需要明确类型的情况下,也可以指定类型。

通过引入var关键字,Java 10也在朝着类似的方向发展。虽然表面看起来类似,但它的范围仅限于局部变量,不能用于字段和方法签名。

严格空检查 —— Kotlin将可空代码流视为编译时错误。它提供了额外的语法来处理空检查。值得注意的是,它提供了链式调用中的NPE保护。

与Java互操作 —— Kotlin在这方面明显优于其他JVM语言。它可以与Java无缝地交互。可以在Kotlin中导入框架中的Java类并使用,反之亦然。值得注意的是,Kotlin集合可以与Java集合互操作。

不变性 —— Kotlin鼓励使用不可变的数据结构。常用的数据结构(Set/ List/ Map)是不可变的,除非显式地声明为可变的。变量也被指定为不可变(val)和可变(var)。所有这些变化对状态可管理性的影响是显而易见的。

简洁而富有表达力的语法 —— Kotlin引入了许多改进,这些改进对代码的可读性产生了重大影响。举几个例子:

  • 分号是可选的
  • 大括号在没有用处的情况下是可选的
  • Getter/Setter是可选的
  • 一切都是对象——如果需要,在后台自动使用原语
  • 表达式:表达式求值时返回结果

在Kotlin中,所有的函数都是表达式,因为它们至少返回Unit 。控制流语句如if、try和when(类似于switch)也是表达式。例如:

String result = null;try {    result = callFn();} catch (Exception ex) {    result = “”;}becomes:val result = try {    callFn()} catch (ex: Exception) {    “”}

循环支持范围,例如:

for (i in 1..100) { println(i) }

还有一些其他的改进,我们将继续讨论。

把Kotlin引入Java项目

循序渐进

考虑到Java的互操作性,建议循序渐进地将Kotlin添加到现有的Java项目中。主流产品的支持项目通常是不错的选择。一旦团队感到舒适了,他们就可以评估自己是否更喜欢完全切换。

选择哪类项目好?

所有的Java项目都可以从Kotlin中获益。但是,具有以下特征的项目可以使决策更简单。

包含大量DTO或模型/实体对象的项目 —— 这对于处理CRUD或数据转换的项目非常典型。此类项目往往充斥着getter/setter。这里可以利用Kotlin的属性大幅简化类。

大量依赖实用工具类的项目 —— Java中的实用工具类通常是为了弥补Java中顶级函数的缺乏。在许多情况下,这包括含全局无状态public static函数。这些可以分解成纯函数。更进一步,Kotlin支持类似Function类型这样的FP结构和高阶函数,这可以用来使代码更易于维护和测试。

类中逻辑复杂的项目 —— 这些项目容易受到空指针异常(NPE)的影响,而这是Kotlin很好地解决了的其中一个问题。通过让语言分析可能导致NPE的代码路径为开发人员提供支持。Kotlin的when结构(一个更好的switch)在这里非常有用,可以将嵌套的逻辑树分解为可管理的函数。对变量和集合的不变性支持有助于简化逻辑,避免由于引用泄漏而导致难以查找的错误。虽然上面的一些功能可以通过Java实现,但Kotlin的优势在于升级了这些范例,并使它们保持简洁一致。

让我们在这里暂停一下,看一个典型的Java逻辑片段以及对应的Kotlin实现:

public class Sample {   public String logic(String paramA, String paramB) {       String result = null;       try {           if (paramA.length() \u0026gt; 10) {               throw new InvalidArgumentException(new String[]{\u0026quot;Unknown\u0026quot;});           } else if (\u0026quot;AB\u0026quot;.equals(paramA) \u0026amp;\u0026amp; paramB == null) {               result = subLogicA(paramA + \u0026quot;A\u0026quot;, \u0026quot;DEFAULT\u0026quot;);           } else if (\u0026quot;XX\u0026quot;.equals(paramA) \u0026amp;\u0026amp; \u0026quot;YY\u0026quot;.equals(paramB)) {               result = subLogicA(paramA + \u0026quot;X\u0026quot;, paramB + \u0026quot;Y\u0026quot;);           } else if (paramB != null) {               result = subLogicA(paramA, paramB);           } else {               result = subLogicA(paramA, \u0026quot;DEFAULT\u0026quot;);           }       } catch (Exception ex) {           result = ex.getMessage();       }       return result;   }   private String subLogicA(String paramA, String paramB) {       return paramA + \u0026quot;|\u0026quot; + paramB;   }}

对应的Kotlin实现:

fun logic(paramA: String, paramB: String?): String {   return try {       when {           (paramA.length \u0026gt; 10) -\u0026gt; throw InvalidArgumentException(arrayOf(\u0026quot;Unknown\u0026quot;))           (paramA == \u0026quot;AB\u0026quot; \u0026amp;\u0026amp; paramB == null) -\u0026gt; subLogicA(paramA + \u0026quot;A\u0026quot;)           (paramA == \u0026quot;XX\u0026quot; \u0026amp;\u0026amp; paramB == \u0026quot;YY\u0026quot;) -\u0026gt; subLogicA(paramA + \u0026quot;X\u0026quot;, paramB + \u0026quot;X\u0026quot;)           else -\u0026gt; if (paramB != null) subLogicA(paramA, paramB) else subLogicA(paramA)       }   } catch (ex: Exception) {       ex.message ?: \u0026quot;UNKNOWN\u0026quot;   }}private fun subLogicA(paramA: String, paramB: String = \u0026quot;DEFAULT\u0026quot;): String {   return \u0026quot;$paramA|$paramB\u0026quot;}

虽然这些代码片段在功能上是等效的,但是它们有一些明显的区别。

logic()函数不需要包含在类中。Kotlin提供了顶级函数。这开辟了一个广阔的空间,鼓励我们去思考是否真的需要一个对象。单独的纯函数更容易测试。这为团队提供了采用更简洁的函数方法的选项。

Kotlin引入了when,这是一个处理条件流的强大结构。它比if或switch语句的功能要强大得多。任意逻辑都可以使用when进行条理的组织。

注意,在Kotlin版本中,我们从未声明返回变量。这是可能的,因为Kotlin允许我们使用when和try作为表达式。

在subLogicA函数中,我们可以在函数声明中为paramB指定一个默认值。

private fun subLogicA(paramA: String, paramB: String = \u0026quot;DEFAULT\u0026quot;): String {

现在,我们可调用任何一个函数签名了:

subLogicA(paramA, paramB)

或者

subLogicA(paramA) # In this case the paramB used the default value in the function declaration

现在,逻辑更容易理解了,代码行数减少了约35%。

把Kotlin加入Java构建

Maven和Gradle通过插件支持Kotlin。Kotlin代码被编译成Java类并包含在构建过程中。Kobalt等比较新的构建工具看起来也很有前景。Kobalt受Maven/Gradle启发,但完全是用Kotlin编写的。

首先,将Kotlin插件依赖项添加到Maven或Gradle构建文件中。

如果你使用的是Spring和JPA,你还应该添加kotlin-spring和kotlin-jpa编译器插件。项目的编译和构建没有任何明显的差异。

如果要为Kotlin代码库生成JavaDoc则需要这个插件。

有针对IntelliJ和Eclipse Studio的IDE插件,但正如我们所预料的那样,Kotlin的开发和构建工具从IntelliJ关联中获益良多。从社区版开始,该IDE对Kotlin提供了一等支持。其中一个值得注意的特性是,它支持将现有的Java代码自动转换为Kotlin。这种转换很准确,而且是一种很好的学习Kotlin惯用法的工具。

与流行框架集成

因为我们将Kotlin引入了现有的项目中,所以框架兼容性是一个问题。Kotlin完美融入了Java生态系统,因为它可以编译成Java字节码。一些流行的框架已经宣布支持Kotlin,包括Spring、Vert.x、Spark等。让我们看下Kotlin和Spring及Hibernate一起使用是什么样子。

Spring

Spring是Kotlin的早期支持者之一,在2016年首次增加支持。Spring 5利用Kotlin提供更简洁的DSL。你可以认为,现有的Java Spring代码无需任何更改就可继续运行。

Kotlin中的Spring注解

Spring注释和AOP都是开箱即用的。你可以像注解Java一样注解Kotlin类。考虑下面的服务声明片段。

@Service@CacheConfig(cacheNames = [TOKEN_CACHE_NAME], cacheResolver = \u0026quot;envCacheResolver\u0026quot;)open class TokenCache @Autowired constructor(private val repo: TokenRepository) {

这些是标准的Spring注解:

@Service: org.springframework.stereotype.Service@CacheConfig: org.springframework.cache

注意,constructor是类声明的一部分。

@Autowired constructor(private val tokenRepo: TokenRepository)

Kotlin将其作为主构造函数,它可以是类声明的一部分。在这个实例中,tokenRepo是一个内联声明的属性。

编译时常量可以在注解中使用,通常,这有助于避免拼写错误。

final类处理

Kotlin类默认为final的。它提倡将继承作为一种有意识的设计选择。这在Spring AOP中是行不通的,但也不难弥补。我们需要将相关类标记为open —— Kotlin的非final关键字。

IntelliJ会给你一个友好的警告。

你可以通过使用maven插件all open来解决这个问题。这个插件可以open带有特定注解的类。更简单的方法是将类标记为open。

自动装配和空值检查

Kotlin严格执行null检查。它要求初始化所有标记为不可空的属性。它们可以在声明时或构造函数中初始化。这与依赖注入相反——依赖注入在运行时填充属性。

lateinit修饰符允许你指定属性将在使用之前被初始化。在下面的代码片段中,Kotlin相信config对象将在首次使用之前被初始化。

@Componentclass MyService {   @Autowired   lateinit var config: SessionConfig}

虽然lateinit对于自动装配很有用,但我建议谨慎地使用它。另一方面,它会关闭属性上的编译时空检查。如果在第一次使用时是null仍然会出现运行时错误,但是会丢失很多编译时空检查。

构造函数注入可以作为一种替代方法。这与Spring DI可以很好地配合,并消除了许多混乱。例如:

@Component
class MyService constructor(val config: SessionConfig)

这是Kotlin引导你遵循最佳实践的一个很好的例子。

Hibernate

Hibernate和Kotlin可以很好地搭配使用,不需要做大的修改。一个典型的实体类如下所示:

@Entity@Table(name = \u0026quot;device_model\u0026quot;)class Device {   @Id   @Column(name = \u0026quot;deviceId\u0026quot;)   var deviceId: String? = null   @Column(unique = true)   @Type(type = \u0026quot;encryptedString\u0026quot;)   var modelNumber = \u0026quot;AC-100\u0026quot;   override fun toString(): String = \u0026quot;Device(id=$id, channelId=$modelNumber)\u0026quot;   override fun equals(other: Any?) =       other is Device           \u0026amp;\u0026amp; other.deviceId?.length == this.deviceId?.length           \u0026amp;\u0026amp; other.modelNumber == this.modelNumber   override fun hashCode(): Int {       var result = deviceId?.hashCode() ?: 0       result = 31 * result + modelNumber.hashCode()       return result   }}

在上面的代码片段中,我们利用了几个Kotlin特性:

属性

通过使用属性语法,我们就不必显式地定义getter和setter了。这减少了混乱,使我们能够专注于数据模型。

类型推断

在我们可以提供初始值的情况下,我们可以跳过类型规范,因为它可以被推断出来。例如:

var modelNumber = \u0026quot;AC-100\u0026quot;

modelNumber属性会被推断为String类型。

表达式

如果我们稍微仔细地看下toString()方法,就会发现它有与Java有一些不同:

override fun toString(): String = \u0026quot;Device(id=$id, channelId=$modelNumber)\u0026quot;

它没有返回语句。这里,我们使用了Kotlin表达式。对于返回单个表达式的函数,我们可以省略花括号,通过等号赋值。

字符串模板

\u0026quot;Device(id=$id, channelId=$modelNumber)\u0026quot;

在这里,我们可以更自然地使用模板。Kotlin允许在任何字符串中嵌入${表达式}。这消除了笨拙的连接或对String.format等外部辅助程序的依赖。

相等测试

在equals方法中,你可能已经注意到了这个表达式:

other.deviceId?.length == this.deviceId?.length

它用==符号比较两个字符串。在Java中,这是一个长期存在的问题,它将字符串视为相等测试的特殊情况。Kotlin最终修复了这个问题,始终把==用于结构相等测试(Java中的equals())。把===用于引用相等检查。

数据类

Kotlin还提供一种特殊类型的类,称为数据类。当类的主要目的是保存数据时,这些类就特别适合。数据类会自动生成equals()、hashCode()和toString()方法,进一步减少了样板文件。

有了数据类,我们的最后一个示例就可以改成:

@Entity@Table(name = \u0026quot;device_model\u0026quot;)data class Device2(   @Id   @Column(name = \u0026quot;deviceId\u0026quot;)   var deviceId: String? = null,   @Column(unique = true)   @Type(type = \u0026quot;encryptedString\u0026quot;)   var modelNumber: String = \u0026quot;AC-100\u0026quot;)

这两个属性都作为构造函数的参数传入。equals、hashCode和toString是由数据类提供的。

但是,数据类不提供默认构造函数。这是对于Hibernate而言是个问题,它使用默认构造函数来创建实体对象。这里,我们可以利用kotlin-jpa插件,它为JPA实体类生成额外的零参数构造函数。

在JVM语言领域,Kotlin的与众不同之处在于,它不仅关注工程的优雅性,而且解决了现实世界中的问题。

采用Kotlin的实际好处

减少空指针异常

解决Java中的NPE是Kotlin的主要目标之一。将Kotlin引入项目时,显式空检查是最明显的变化。

Kotlin通过引入一些新的操作符解决了空值安全问题。Kotlin的?操作符就提供了空安全调用,例如:

val model: Model? = car?.model

只有当car对象不为空时,才会读取model属性。如果car为空,model计算为空。注意model的类型是Model?——表示结果可以为空。此时,流分析就开始起作用了,我们可以在任何使用model变量的代码中进行NPE编译时检查。

这也可以用于链式调用:

val year = car?.model?.year

下面是等价的Java代码:

Integer year = null;if (car != null \u0026amp;\u0026amp; car.model != null) {   year = car.model.year;}

一个大型的代码库会省掉许多这样的null检查。编译时安全自动地完成这些检查可以节省大量的开发时间。

在表达式求值为空的情况下,可以使用Elvis操作符( ?: )提供默认值:

val year = car?.model?.year ?: 1990

在上面的代码片段中,如果year最终为null,则使用值1990。如果左边的表达式为空,则?: 操作符取右边的值。

函数式编程选项

Kotlin以Java 8的功能为基础构建,并提供了一等函数。一等函数可以存储在变量/数据结构中并传递出去。例如,在Java中,我们可以返回函数:

@FunctionalInterfaceinterface CalcStrategy {   Double calc(Double principal);}class StrategyFactory {   public static CalcStrategy getStrategy(Double taxRate) {       return (principal) -\u0026gt; (taxRate / 100) * principal;   }}

Kotlin让这个过程变得更加自然,让我们可以清晰地表达意图:

// Function as a typetypealias CalcStrategy = (principal: Double) -\u0026gt; Doublefun getStrategy(taxRate: Double): CalcStrategy = { principal -\u0026gt; (taxRate / 100) * principal }当我们深入使用函数时,事情就会发生变化。下面的Kotlin代码片段定义了一个生成另一个函数的函数:val fn1 = { principal: Double -\u0026gt;   { taxRate: Double -\u0026gt; (taxRate / 100) * principal }}

我们很容易调用fn1及结果函数:

fn1(1000.0) (2.5)

输出
25.0

虽然以上功能在Java中也可以实现,但并不直接,并且包含样板代码。

提供这些功能是为了鼓励团队尝试FP概念,开发出更符合要求的代码,从而得到更稳定的产品。

注意,Kotlin和Java的lambda语法略有不同。这在早期可能会给开发人员带来烦恼。

Java代码:

( Integer first, Integer second ) -\u0026gt; first * second

等价的Kotlin代码:

{ first: Int, second: Int -\u0026gt; first * second }

随着时间的推移,情况就变得明显了,Kotlin支持的应用场景需要修改后的语法。

减少项目占用空间大小

Kotlin最被低估的优点之一是它可以减少项目中的文件数量。Kotlin文件可以包含多个/混合类声明、函数和枚举类等其他结构。这提供了许多Java没有提供的可能性。另一方面,它提供了一种新的选择——组织类和函数的正确方法是什么?

在《代码整洁之道》一书中,Robert C Martin打了报纸的比方。好代码应该读起来和报纸一样——高级结构在文件上部,越往下面越详细。这个文件应该讲述一个紧凑的故事。Kotlin的代码布局从这个比喻中可见一斑。

建议是——把相似的东西放在一起——放在更大的上下文里。

虽然Kotlin不会阻止你放弃“结构(structure)”,但这样做会使后续的代码导航变得困难。组织东西要以它们之间的关系和使用顺序为依据,例如:

enum class Topic {    AUTHORIZE_REQUEST,    CANCEL_REQUEST,    DEREG_REQUEST,    CACHE_ENTRY_EXPIRED}enum class AuthTopicAttribute {APP_ID, DEVICE_ID}enum class ExpiryTopicAttribute {APP_ID, REQ_ID}typealias onPublish = (data: Map\u0026lt;String, String?\u0026gt;) -\u0026gt; Unitinterface IPubSub {    fun publish(topic: Topic, data: Map\u0026lt;String, String?\u0026gt;)    fun addSubscriber(topic: Topic, onPublish: onPublish): Long    fun unSubscribe(topic: Topic, subscriberId: Long)}class RedisPubSub constructor(internal val redis: RedissonClient): IPubSub {...}

在实践中,通过减少为获得全貌而需要跳转的文件数量,可以显著减少脑力开销。

一个常见的例子是Spring JPA库,它使包变得混乱。可以把它们重新组织到同一个文件中:

@Repository@Transactionalinterface DeviceRepository : CrudRepository\u0026lt;DeviceModel, String\u0026gt; {    fun findFirstByDeviceId(deviceId: String): DeviceModel?}@Repository@Transactionalinterface MachineRepository : CrudRepository\u0026lt;MachineModel, String\u0026gt; {    fun findFirstByMachinePK(pk: MachinePKModel): MachineModel?}@Repository@Transactionalinterface UserRepository : CrudRepository\u0026lt;UserModel, String\u0026gt; {    fun findFirstByUserPK(pk: UserPKModel): UserModel?}

上述内容的最终结果是代码行数(LOC)显著减少。这直接影响了交付速度和可维护性。

我们统计了Java项目中移植到Kotlin的文件数量和代码行数。这是一个典型的REST服务,包含数据模型、一些逻辑和缓存。在Kotlin版本中,LOC减少了大约50%。开发人员在跨文件浏览和编写样板代码上消耗的时间明显减少。

简洁而富有表达力的代码

编写简洁的代码是一个宽泛的话题,这取决于语言、设计和技术的结合。然而,Kotlin提供了一个良好的工具集,为团队的成功奠定了基础。下面是一些例子。

类型推断

类型推断最终会减少代码中的噪音。这有助于开发人员关注代码的目标。

类型推断可能会增加我们跟踪正在处理的对象的难度,这是一种常见的担忧。从实际经验来看,这种担忧只在少数情况下有必要,通常少于5%。在大多数情况下,类型是显而易见的。

下面的例子:

LocalDate date = LocalDate.now();String text = \u0026quot;Banner\u0026quot;;

变成了:

val date = LocalDate.now()val text = \u0026quot;Banner\u0026quot;

在Kotlin中,也可以指定类型:

val date: LocalDate = LocalDate.now()val text: String = \u0026quot;Banner\u0026quot;

值得注意的是,Kotlin提供了一个全面的解决方案。例如,在Kotlin中,我们可以将函数类型定义为:

val sq = { num: Int -\u0026gt; num * num }

另一方面,Java 10通过检查右边表达式的类型推断类型。这引入了一些限制。如果我们尝试在Java中执行上述操作,我们会得到一个错误:

类型别名

这是Kotlin中一个方便的特性,它允许我们为现有类型分配别名。它不引入新类型,但允许我们使用替代名称引用现有类型,例如:

typealias SerialNumber = String

SerialNumber现在是String类型的别名,可以与String类型互换使用,例如:

val serial: SerialNumber = \u0026quot;FC-100-AC\u0026quot;

和下面的代码等价:

val serial: String = \u0026quot;FC-100-AC\u0026quot;

很多时候,typealias可以作为一个“解释变量”,提高清晰度。考虑以下声明:

val myMap: Map\u0026lt;String, String\u0026gt; = HashMap()

我们知道myMap包含字符串,但我们不知道这些字符串表示什么。我们可以通过引入String类型的别名来澄清这段代码:

typealias ProductId = Stringtypealias SerialNumber = String

现在,上述myMap的声明可以改成:

val myMap: Map\u0026lt;ProductId, SerialNumber\u0026gt; = HashMap()

上面两个myMap的定义是等价的,但是对于后者,我们可以很容易地判断Map的内容。

Kotlin编译器用底层类型替换了类型别名。因此,myMap的运行时行为不受影响,例如:

myMap.put(“MyKey”, “MyValue”)

这种钙化的累积效应是减少了难以捉摸的Bug。在大型分布式团队中,错误通常是由于未能沟通意图造成的。

早期应用

早期获得吸引力通常是引入变革的最困难的部分。从确定合适的实验项目开始。通常,有一些早期的采用者愿意尝试并编写最初的Kotlin代码。在接下来的几周里,更大的团队将有机会查看这些代码。人们早期的反应是避免新的和不熟悉的东西。变革需要一些时间来审视。通过提供阅读资源和技术讲座来帮助评估。在最初的几周结束时,更多的人可以决定在多大程度上采用。

对于熟悉Java的开发人员来说,学习曲线很短。以我的经验来看,大多数Java开发人员在一周内都能高效地使用Kotlin。初级开发人员可以在没有经过特殊培训的情况下使用它。以前接触过不同语言或熟悉FP概念会进一步减少采用时间。

未来趋势

从1.1版本开始,“协同例程(Co-routine)”就可以用在Kotlin中了。在概念上,它们类似于JavaScript中的async/await。它们允许我们在不阻塞线程的情况下挂起流,从而降低异步编程中的复杂性。

到目前为止,它们还被标记为实验性的。协同例程将在1.3版本中从实验状态毕业。这带来了更多令人兴奋的机会。

Kotlin的路线图在Kotlin Evolution and Enhancement Process(KEEP)的指导下制定。请密切关注这方面的讨论和即将发布的特性。

作者简介

Baljeet Sandhu是一名技术负责人,拥有丰富的经验,能够为从制造到金融的各个领域提供软件。他对代码整洁、安全和可扩展的分布式系统感兴趣。Baljeet目前为HYPR工作,致力于构建非集中式的认证解决方案,以消除欺诈,提高用户体验,实现真正的无密码安全。