Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

在贵公司中扩大 Kotlin 的采用

Read this post in other languages:

客座文章作者:Urs Peter,高级软件工程师兼 JetBrains 认证 Kotlin 培训师。对于希望以更系统的方式提升 Kotlin 技能的读者,Urs 还在 Xebia Academy 主办了 Kotlin 技能提升计划

本文为在以 Java 为主的环境中成功采用 Kotlin 的终极指南系列的第三篇文章,系列将从一位开发者产生好奇心到公司范围转型这一发展过程,逐步讲述 Kotlin 采用在实际团队中如何增长。

本系列的所有内容:

  1. 面向 Java 开发者的 Kotlin 使用入门
  2. 在实际项目中评估 Kotlin
  3. 在贵公司中扩大 Kotlin 的采用

广而告之:赢得开发者同僚的支持与认可

截至目前,您已组建起一支认可 Kotlin 优势的核心团队。现在进入关键阶段:您如何扩大其采用范围? 

这个阶段的关键在于赢得持怀疑态度的 Java 开发者的认可与支持。硬性因素与软性因素将发挥关键作用:

硬性因素:代码本身

  • 用代码证明实力

软性因素:为开发者提供支持并促进其协作交流

  • 助力开发者轻松上手
  • 提供自学资料
  • 建立内部 Kotlin 社区
  • 保持耐心…

用代码证明实力

充分利用通过 Kotlin(重新)编写应用程序所积累的经验,以具体易懂的方式展现其优势:

  • 事实胜于雄辩:通过对比 Java 与 Kotlin 代码段,直观呈现 Kotlin 带来的简洁性优势。 
  • 跳出细节,聚焦核心设计范式:Kotlin 并非仅仅与 Java 存在差异,它构建于安全性、兼顾极致可读性与可维护性的简洁性、函数一等公民地位和可扩展性的基础之上,这些特性共同解决了 Java 的根本性缺陷,同时仍能与 Java 生态系统实现完全互操作。

以下是部分具体示例:

1. null 安全特性:告别“十亿美元级错误” 

Java

//This is why we have the billion-dollar mistake (not only in Java…)
Booking booking = null; //🤨 This is allowed but causes:
booking.destination; //Runtime error 😱

Optional booking = null; //Optionals aren’t safe from null either: 🤨
booking.map(Destination::destination); //Runtime error 😱

Java 虽已逐步添加部分 null 安全特性(例如 Optional 类与注解,如 @NotNull 等),但始终未能解决这一根本性问题。此外,Valhalla 项目(null 限制类型与可为 null 类型)并不会为 Java 引入 null 安全机制,而是仅提供更多可供选择的选项。 

Kotlin

//Important to realize that null is very restricted in Kotlin:
val booking:Booking? = null //…null can only be assigned to Nullable types ✅ 
val booking:Booking = null //null assigned to a Non-nullable types yields a Compilation error 😃 

booking.destination //unsafely accessing a nullable type directly causes a Compilation error 😃
booking?.destination //only safe access is possible  ✅

Kotlin null 安全的一大优势在于,它不仅安全,还拥有极高的实用性。这正是“鱼与熊掌可以兼得”的经典范例:

假设我们有如下模型:

Kotlin

data class Booking(val destination:Destination? = null)
data class Destination(val hotel:Hotel? = null)
data class Hotel(val name:String, val stars:Int? = null)

Java

public record Booking(Optional destination) {

   public Booking() { this(Optional.empty()); }

   public Booking(Destination destination) {
       this(Optional.ofNullable(destination));
   }
}

public record Destination(Optional hotel) {

   public Destination() { this(Optional.empty()); }

   public Destination(Hotel hotel) {
       this(Optional.ofNullable(hotel));
   }
}

public record Hotel(String name, Optional stars) {

   public Hotel(String name) {

       this(name, Optional.empty());

   }

   public Hotel(String name, Integer stars) {
       this(name, Optional.ofNullable(stars));
   }
}

构造对象

Java

//Because Optional is a wrapper, the number of nested objects grows, which doesn’t help readability
final Optional booking = Optional.of(new Booking(
      Optional.of(new Destination(Optional.of(
            new Hotel("Sunset Paradise", 5))))));

Kotlin

//Since nullability is part of the type system, no wrapper is needed: The required type or null can be used. 
val booking:Booking? = Booking(Destination(Hotel("Sunset Paradise", 5)))

遍历嵌套对象

Java

//traversing a graph of Optionals requires extensive unwrapping
final var stars = "*".repeat(booking
                          .flatMap(Booking::getDestination)
                          .flatMap(Destination::getHotel)
                          .map(Hotel::getStars).orElse(0)); //-> "*****"

Kotlin

//Easily traverse a graph of nullable types with: ‘?’, use  ?: for the ‘else’ case.
val stars = "*".repeat(booking?.destination?.hotel?.stars ?: 0) //-> "*****"

解包嵌套对象

Java

//extensive unwrapping is also needed for printing a leaf 
booking.getDestination()
       .flatMap(Destination::getHotel)
       .map(Hotel::getName)
       .map(String::toUpperCase)
         .ifPresent(System.out::println);

Kotlin

//In Kotlin we have two elegant options:
//1. we can again traverse the graph with ‘?’
booking?.destination?.hotel.?name?.uppercase()?.also(::println)

//2. We can make use of Kotlin’s smart-cast feature
if(booking?.destination?.hotel != null) {
   //The compiler has checked that all the elements in the object graph are not null, so we can access the elements as if they were non-nullable types
   println(booking.destination.hotel.uppercase())
}

Java 缺乏 null 安全支持是开发者面临的核心痛点,这不仅导致开发者需要采用防御性编程模式、不同的为 null 性构造(甚至完全缺失),还会增加代码冗长度。此外,NullPointerException 约占应用程序崩溃原因的三分之一(JetBrains 博客)。Kotlin 的编译时 null 检查可以彻底杜绝这类运行时故障。这也是时至今日,null 安全仍是向 Kotlin 迁移的首要驱动因素的原因所在。

2. 集合是您的得力助手,而非绊脚石

Spring 框架的创作者 Rod Johnson 在近期的一次采访中表示,促使他尝试 Kotlin 的并非可为 null 类型,而是过度复杂的 Java Streams API:Creator of Spring: No desire to write Java(Spring 的创作者:已无意愿编写 Java)。 

以下示例将阐述 Java Streams API 之所以极为复杂的各类原因,以及 Kotlin 如何解决所有这些问题:

Java

public record Product(String name, int... ratings){}
List products = List.of(
  new Product("gadget", 9, 8, 7), 
  new Product("goody", 10, 9)
);

Map maxRatingsPerProduct =

   //🤨 1. Stream introduces indirection
   products.stream()
     //🤨 1. Always to and from Stream conversion
      .collect(
        //🤨 2. Lacks extension methods, so wrappers are required
        Collectors.groupingBy(
           Product::name,
            //🤨 2. Again…
           Collectors.mapping( groupedProducts ->
                //🤨 3. (too) low-level types, arrays, and primitives cause extra complexity
                //🤨 4. No API on Array, always wrap in stream 
               Arrays.stream(groupedProducts.ratings())
                   .max()
                      //🤨 5. Extra verbosity due to Optional
                      .orElse(0.0),
               //🤨 6. No named arguments: what does this do?
                Collectors.reducing(0, Integer::max)
              )
            ));

Kotlin

//😃 rich and uniform Collection API - even on Java collections - due to extension methods
val maxRatingsPerProduct = products.
      .groupBy { it.name }
      .mapValues { (_, groupedProducts) -> //😃 destructuring for semantic precision
            //😃 built-in nullability support, and the same API for 
            //arrays like other Collections
            groupedProducts.flatMap { it.ratings }
                  .maxOrNull() ?: 0 
            }
      }

借助 Kotlin 统一的集合框架,不同集合类型间的转换变得极为简洁:
Java

int[] numbers = {1, 3, 3, 5, 2};

Set unique = Arrays.stream(numbers).boxed().collect(Collectors.toSet());

Map evenOrOdd = unique.stream()
       .collect(Collectors.toMap(
               n -> n,
               n -> n % 2 == 0));

Kotlin

val numbers = arrayOf(1, 3, 3, 5, 2)

val unique: Set = numbers.toSet() //😃 simply call to to do the conversion

val evenOrOdd: Map = unique.associateWith { it % 2 == 0 }

最终效果:

  • 功能丰富且直观的集合 API:管道按从左到右的顺序阅读(跟英语习惯相同),无需嵌套在收集器调用中。
  • 样板代码更少、复杂度更低:没有 Collectors.groupingBy、Stream、Optional 和 Arrays.stream。
  • 统一的思维模型:无论您从 ListSetArray 还是基元数组入手,使用的都是相同的集合操作符。
  • 高效无负担:编译器仅在不可避免时才插入装箱/拆箱操作,您只需编写常规代码即可。
  • null 安全集成:集合 API 全面支持为 null 性,提供多种辅助方法,这类方法通常以 orNull(...) 为后缀。
  • 使用惯用包装器实现 Java 无缝互操作:得益于扩展方法,您可以获得二者的优势,在 Java 集合的基础上,获得功能丰富的集合体验。

简而言之,Kotlin 将筛选、映射、分组等日常集合操作提升为一等公民级别的可组合函数,让您专注于表达想要什么,而非纠结于如何实现的繁琐流程。

3. Kotlin 摒弃了 checked 异常,代码安全性却更胜一筹

Java 是为数不多仍支持 checked 异常的语言之一。尽管 checked 异常最初作为一项安全特性实现,但它并未兑现其承诺。它们的冗长、仅做无用功或直接将异常作为 RuntimeException 重新抛出的无意义 catch 块,以及与 lambda 的不一致,正是其非但无法提升代码安全性,反而造成阻碍的部分原因。

Kotlin 采用了几乎所有其他编程语言(如 C#、Python、Scala、Rust 和 Go)均验证有效的范式,仅在不可恢复的场景下使用异常。

以下示例凸显了 checked 异常给代码带来的阻碍,却并未带来任何安全性提升:

Java

public String downloadAndGetLargestFile(List urls) {
   List contents = urls.stream().map(urlStr -> {
               Optional optional;
               try {
                   optional = Optional.of(new URI(urlStr).toURL());
                   //🤨 Within lambdas checked exceptions are not supported and must always be caught...
               } catch (URISyntaxException | MalformedURLException e) {
                   optional = Optional.empty();
               }
               return optional;
           }).filter(Optional::isPresent)       //Quite a mouthful to get rid of the Optional...
           .map(Optional::get)
           .map(url -> {
               try (InputStream is = url.openStream()) {
                   return new String(is.readAllBytes(), StandardCharsets.UTF_8);
               } catch (IOException e) {
                   //🤨… or re-thrown, which is annoying, I don’t really care about IOE
                   throw new IllegalArgumentException(e);
               }
           }).toList();
   //🤨 An empty List results in a NoSuchElementException, so why is it not checked? The chance that the List is empty is as high as the other two cases above...
   return Collections.max(contents);
}

Kotlin

//😃 safe return type
fun downloadAndGetLargestFile(urls: List): String? =
   urls.mapNotNull {    //😃 convenient utility methods to rid of null
       //😃 try catch is possible, yet runCatching is an elegant way to convert an exception to null
       runCatching { URI(it).toURL() }.getOrNull()
   }.maxOfOrNull{  //😃 safe way to retrieve the max value
       it.openStream().use{ it.reader().readText() }  //😃 convenient extension methods to make java.io streams fluent
   }

4. 函数作为一等公民

Kotlin 将函数视为一等公民,如果您此前使用的是 Java,可能会产生疑问:这究竟意味着什么?又为何至关重要?

核心差异在于 Java 的局限性,其函数式能力大多仅通过 lambda 体现在调用端,而声明端仍受限于冗长且不够直观的函数式接口。在 Kotlin 中,您无需编写任何样板代码即可定义、传递、返回与组合函数,这让函数式编程变得更具表达力和自然。

Java

public void doWithImage(
      URL url, 
      //🤨 Function interfaces introduce an indirection: because we don’t see the signature of a Function we don’t know what a  BiConsumer does, unless we look it up
      BiConsumer f) throws IOException {
        f.accept(url.getFile(), ImageIO.read(url));
}

//🤨 Same here
public void debug(Supplier f) {
   if(isDebugEnabled()) {
      logger.debug("Debug: " + f.get());
   }
}

//🤨 calling no-argument lambdas is verbose
debug(() -> "expensive concat".repeat(1000));

Kotlin

fun doWithImage(
   url: URL,
  //😃 Kotlin has a syntax for declaring functions: from the signature, we see what goes in and what goes out 
   f:(String, BufferedImage) -> Unit) = 
      f(url.file, ImageIO.read(url))

  //😃 same here: nothing goes in, a String goes out
fun debug(msg: () -> String) {
   if(isDebugEnabled) {
            logger.debug(msg())
   }
}

//😃 convenient syntax to pass a lambda: {}
debug{"expensive concat".repeat(1000)}

Kotlin 提供了清晰简洁的函数声明语法,仅通过签名,您就能立刻知晓输入与输出,无需导航至外部函数式接口。 

Java 的函数式编程特性需要通过大量 java.util.function.* 接口实现,这些接口的签名冗长复杂,这使得函数式编程的使用颇为繁琐。相比之下,Kotlin 将函数视为一等公民:这些接口对开发者完全隐藏,却仍能与 Java 的方式保持完全互操作:

因此,在 Kotlin 中使用函数变得格外简洁直观,这将大幅降低您在代码中运用这一强大编程理念的门槛。 

5. 利用协程实现无负担并发

如果您需要高吞吐量、单个请求内的并行处理或流式传输,Java 中唯一的选择便是响应式库(例如,Reactor 和 RxJava),这类库可以在 Spring WebFlux、Vert.X、Quarkus 等框架中使用。

这类库的问题在于其复杂度众所周知,还会强制您使用函数式编程。因此,它们的学习曲线十分陡峭,且极易出现错误,而这些错误在应用程序承受负载时,可能会引发严重后果。这很可能就是响应式编程始终未能成为主流的原因。 

注意:尽管虚拟线程与响应式库存在一定功能重叠,但它并不能替代响应式库。虚拟线程支持非阻塞 I/O,但不提供并行处理或响应式流等特性。一旦主流框架提供支持,结构化并发与作用域值也将具备并行处理能力。对于响应式流,您始终需要依赖响应式库。

假设您是一名使用 Spring Boot 的 Java 开发者,并且想要在单个请求内发起并行调用。最终您会得到这样的代码: 

@PostMapping("/users")
@ResponseBody
@Transactional
public Mono storeUser(@RequestBody User user) {
   Mono avatarMono = avatarService.randomAvatar();
   Mono validEmailMono = emailService.verifyEmail(user.getEmail());
   //🤨 what does ‘zip’ do?
   return Mono.zip(avatarMono, validEmailMono).flatMap(tuple -> 
       if(!tuple.getT2()) //what is getT2()? It’s the validEmail Boolean…
        //🤨 why can I not just throw an exception?
         Mono.error(new InvalidEmailException("Invalid Email"));
       else personDao.save(UserBuilder.from(user)
                                          .withAvatarUrl(tuple.getT1()));
     );  
  }

尽管从运行时角度来看,这段代码的执行表现完全没问题,但它引入的偶发复杂性却极为显著: 

  • 在整个调用链中,代码完全被 Mono/Flux 主导,这就迫使您必须对所有模型对象进行包装。
  • 代码中随处可见 zipflatMap 等众多复杂操作符。
  • 您无法使用抛出异常等标准编程构造 
  • 代码的业务意图大打折扣:代码重心全放在 MonoflatMap 上,这就从业务视角掩盖了实际要实现的核心逻辑。

好消息是,Kotlin 协程为此提供了一种强大的解决方案。协程可被视为语言层面的响应式实现。因此,它们兼具两者的优势: 

  • 您可以像以往一样编写顺序代码。
  • 代码在运行时会以异步/并行方式执行。

上述 Java 代码转换为协程后的写法如下:

@GetMapping("/users")
@ResponseBody
@Transactional
suspend fun storeUser(@RequestBody user:User):User = coroutineScope {
   val avatarUrl = async { avatarService.randomAvatar() }
   val validEmail = async { emailService.verifyEmail() }
   if(!validEmail.await()) throw InvalidEmailException("Invalid email")
   personRepo.save(user.copy(avatar = avatarUrl.await()))
}

Kotlin 的 suspend 关键字以清晰简洁的方式实现了结构化、非阻塞执行。借助 async{}await(),它可以促进并行处理,无需深度嵌套的回调,也无需 MonoCompletableFuture 这类复杂构造。

因此,代码复杂度会降低,开发者体验和可维护性会提升,同时保持完全一致的性能特征。 

注意:并非所有基于 Java Web 的主流框架都同样支持协程。Spring 对此支持良好,Micronaut 亦是如此。Quarkus 目前对协程的支持较为有限。

6. 不过话说回来,Java 也在不断发展!

Java 持续向前发展,推出了 records、模式匹配等特性,同时还有 Amber、Valhalla 和 Loom 等后续项目在推进中。这种稳步发展不仅增强了 JVM 的能力,也让整个生态系统受益。

但关键问题在于:这些“新”的 Java 特性大多已被 Kotlin 开发者使用多年。null 安全、值类、顶层函数、默认实参、简洁的集合以及一等函数均是 Kotlin 设计之初就内置的特性,且以更统一、对开发者更友好的方式呈现。这也是 Kotlin 代码往往更简洁、更安全,且开发效率大幅提升的原因所在。

而且优势不止于此:Kotlin 还能从 Java 的创新中获益。JVM 层面的进步,如虚拟线程和 Loom,或 Valhalla 的性能提升,同样可以无缝应用于 Kotlin。

简而言之:Java 不断演进,但 Kotlin 从设计之初就致力于为开发者提供所需的现代化工具,这使其成为构建未来系统的安全、现代化且具有前瞻性的选择。

7. Kotlin 的演进优势

与现代编程语言相比,老牌编程语言不可避免地背负着历史包袱。在支持海量现有代码库的同时对语言进行更新,这给语言设计者带来了独特的挑战。Kotlin 拥有两项关键优势:

站在巨人的肩膀上:Kotlin 的初始设计团队并未重复造轮子,而是从主流编程语言中汲取了经过验证的编程范式,并将其整合为一个连贯统一的整体。这种设计思路充分吸收了整个编程社区的演进经验,实现了学习成果的最大化。

从 Java 的不足中吸取经验:Kotlin 的设计者得以观察到 Java 的缺陷,并从一开始就制定了完善的解决方案。

想要深入了解 Kotlin 的演进历程,Kotlin 初始设计团队的 Andrey Breslav 曾在阿姆斯特丹的 Kotlin 开发者大会上发表过一场精彩演讲:Shoulders of Giants: Languages Kotlin Learned From – Andrey Breslav

软性因素:支持开发者顺畅踏上 Kotlin 采用之路,并构建开发者联结

1. 助力开发者轻松上手

这些直观对比 Java 与 Kotlin 的代码段的目的是激发开发者对 Kotlin 的兴趣。不过,仅凭代码本身并不足以赢得 Java 开发者的青睐与认同。为加快 Kotlin 的采用并确保开发者顺利入门,需要提供以下支持:

  • 示例项目:一个可以直接运行的项目,包含并列呈现的 Java 与 Kotlin 代码,在迁移过程中为团队提供实用参考。
  • 内置质量检查:预配置了 SonarQube、ktlint、detekt 等工具,从一开始就帮助开发者编写整洁、一致且易于维护的代码。这将帮助您应用一致的 lint 规则、测试框架、库和 CI 管道,从而减少团队间的协作阻力。
  • 指导与支持:资深 Kotlin 工程师随时为新团队提供指导,解答疑问,并在开发初期阶段提供实操建议。
    • 这一点尤为重要:只需来自其他团队、曾经历过这些阶段的资深开发者提供数小时的指导,就能避免大量麻烦与技术债务。 

少量的支持与指导是培养开发者对 Kotlin 持久热情的最有效方式。

2. 提供自学资料

尤其是对于从 Java 转型的开发者而言,自主学习 Kotlin 的基础知识完全可行。提前提供一些资源,既能为开发者顺畅掌握 Kotlin 并提升工作效率铺平道路,也能降低入门门槛。 

注意:尽管自学对于掌握基础知识十分有益,但它也存在一些弊端。弊端之一在于自主性带来的弹性空间:当陷入日常工作的忙碌节奏时,人们很容易放弃自学。此外,您将无法获得来自从业者的反馈,这些从业者深谙 Kotlin 惯用写法的精妙细节,知晓如何正确应用这些写法。自学完成后,您很可能会写出 Java 风格的 Kotlin 代码,这种写法虽能带来一定收益,却无法充分发挥 Kotlin 语言的全部潜力。 

除非缺乏优质指导,否则传统课程会极具价值。参与这类课程十分必要:您可以与同水平的同行交流,还能获得资深专业人士的答疑,这将帮助您在初期转型阶段更快上手,同时减少非地道的 Kotlin 代码。 

3. 建立内部 Kotlin 社区

在公司内部提升全员 Kotlin 技术水平的最有效方式之一,就是建立并重点培育一个内部技术社区。

  • 启动内部 Kotlin 社区
    • 首先寻找一支至少由 3 至 6 名开发者组成的核心团队,这些开发者需要愿意为 Kotlin 社区投入精力。同时确保他们的管理者为其这项工作提供时间支持并给予认可。
    • 团队组建完成后,邀请 Kotlin 社区的知名演讲者,组织一场全公司范围的启动大会。这将点燃大家对 Kotlin 的热情,为社区发展注入强劲动力。
    • 安排定期聚会(每月一次或每两周一次),确保发展势头不中断。
    • 创建一个共享聊天频道/维基页面,用于存放问题、代码段和活动笔记。
  • 邀请(外部)演讲者
    • 邀请已将 Kotlin 投入生产环境的工程师,分享实践经验与心得。
    • 穿插开展深度技术演讲(协程、KMP、函数式编程)与高阶案例研究(迁移策略、工具实操技巧)。
  • 分享内部其他项目的经验教训
    • 请项目负责人分享他们在 Kotlin 应用中的经验,包括有效做法、失败教训以及可衡量的成果。
    • 这些洞察可以整理成一份《Kotlin 实践指南》,供新团队直接参考使用。
  • 为公司内部开发者提供展示平台
    • 组织闪电演讲环节,任何人都可以在 5 至 10 分钟内演示实用技巧、推荐相关库,或分享自己攻克的技术难题。
    • 公开表彰贡献者 – 在全员会议或内部简报中公开致谢可以提升参与度与知识共享氛围。
  • 保持高效的反馈闭环
    • 每次活动结束后,通过快速问卷收集关于内容清晰度和实用性的反馈,再据此调整后续的活动议程。
    • 轮换组织职责,确保社区不依赖单一核心人物,实现长期稳健发展。

注意:上述诸多建议看似简单直接。不过,维持社区活力与存续所需付出的努力不容小觑。 

4. 保持耐心…

文化转型需循序渐进,不可急于求成。当您对一款能产生实际价值的工具充满热情时,需要警惕急于推进的倾向,这往往会适得其反。有效的做法是借助上述各类活动培育采用流程,始终遵循示范而非说教的核心原则。

系列博文后续内容

我们将工作重心从说服开发者,转移到争取决策者的认可与支持。下一篇文章将介绍如何基于真实数据和可衡量成果,为 Kotlin 的采用构建有说服力的业务用例。您会学习如何将开发者的实践成果转化为打动管理层的论据,如何将开发效率的提升与成本节约直接关联,以及如何阐明 Kotlin 绝非单纯的技术升级 对于团队与公司而言,它更是一项具备战略意义的举措。

Urs Peter

Urs 是一位经验丰富的软件工程师、解决方案架构师、会议演讲嘉宾和培训师,在构建弹性、可扩缩和任务关键型系统方面拥有 20 余年的丰富经验,主要涉及 Kotlin 和 Scala。

除了担任顾问之外,他还是一位充满热情的培训师,并撰写了丰富多样的课程,涉及从 Kotlin 和 Scala 语言课程到微服务和事件驱动型架构等架构培训的众多内容。

作为天生社交达人,他喜欢在聚会和会议上分享知识,启发同行并获得同行启发。Urs 是一位 JetBrains 认证 Kotlin 培训师。

本博文英文原作者:

Alyona Chernyaeva

Alyona Chernyaeva

image description

Discover more