【译】探索 Kotlin 中的隐性成本(第一部分)

本文讲的是【译】探索 Kotlin 中的隐性成本(第一部分),


Lambda 表达式和伴生对象

2016年,Jake Wharton 做了一系列有趣的关于 Java 的隐性成本 的讨论。差不多同一时期他开始提倡使用 Kotlin 来开发 Android,但对 Kotlin 的隐性成本几乎只字未提,除了推荐使用内联函数。如今 Kotlin 在 Android Studio 3 中被 Google 官方支持,我认为通过研究 Kotlin 产生的字节码来说一下关于这方面(隐性成本)的问题是个好主意。

与 Java 相比,Kotlin 是一种有更多语法糖的现代编程语言,同样也有很多“黑魔法”运行在幕后,他们中有些有着不容忽视的成本,尤其是针对老的和低端的 Android 设备上的开发。

这不是一个专门针对 Kotlin 的现象:我很喜欢这门语言,它提高了效率,但是我相信一个优秀的开发者需要知道这些语言特性在内部是如何工作的以便更明智地使用他们。Kotlin 是强大的,有句名言说:

“能力越大,责任越大。”

本文只关注 Kotlin 1.1 在 JVM/Android 上的实现,不关注 Javascript 上的实现。

Kotlin 字节码检测器

这是一个可选的工具,他能推断出 Kotlin 代码是怎样被转换成字节码的。在 Android Studio 中安装了 Kotlin 插件后,选择 “Show Kotlin Bytecode” 选项来打开一个显示当前类的字节码的面板。然后你可以点击 “Decompile” 按钮来阅读同等的 Java 代码。



特别是,我将提到的 Kotlin 特性有:

  • 基本类型装箱,分配短期对象
  • 实例化额外的对象在代码中不是直接可见的
  • 生成额外的方法。正如你可能已知的,在 Android 应用中一个 dex 文件中允许的方法数量是有限的,超限了就需要配置 multidex,然而这有局限性且有损性能,尤其是在 Lollipop 之前的 Android 版本中。

注意基准

我故意选择不公布任何微基准,因为他们中的大多数毫无意义,或者有缺陷,或者两者兼有,并且不能够应用于所有的代码变化和运行时环境。当相关的代码运行在循环或者嵌套循环中时负面的性能影响通常会被放大。

此外,执行时间并不是唯一衡量标准,增长的内存使用也必须考虑,因为所有分配的内存最终都必须回收,垃圾回收的成本取决于很多因素,如可用内存和平台上使用的垃圾回收算法。

简而言之,如果你想知道一个 Kotlin 构造对速度或者内存是否有明显的影响,你需要在目标平台上测试你的代码。


高阶函数和 Lambda 表达式

Kotlin 支持将函数赋值给变量并将他们做为参数传给其他函数。接收其他函数做为参数的函数被称为高阶函数。Kotlin 函数可以通过在他的名字前面加 :: 前缀来引用,或者在代码中中直接声明为一个匿名函数,或者使用最简洁的 lambda 表达式语法 来描述一个函数。

Kotlin 是为 Java 6/7 JVM 和 Android 提供 lambda 支持的最好方法之一。

考虑下面的工具函数,在一个数据库事务中执行任意操作并返回受影响的行数:

fun transaction(db: Database, body: (Database) -> Int): Int {
    db.beginTransaction()
    try {
        val result = body(db)
        db.setTransactionSuccessful()
        return result
    } finally {
        db.endTransaction()
    }
}

我们可以通过传递一个 lambda 表达式做为最后一个参数来调用这个函数,使用类似于 Groovy 的语法:

val deletedRows = transaction(db) {
    it.delete("Customers", null, null)
}

但是 Java 6 的 JVM 并不直接支持 lambda 表达式。他们是如何转化为字节码的呢?如你所料,lambdas 和匿名函数被编译成 Function 对象。

Function 对象

这是上面的的 lamdba 表达式编译之后的 Java 表现形式。

class MyClass$myMethod$1 implements Function1 {
   // $FF: synthetic method
   // $FF: bridge method
   public Object invoke(Object var1) {
      return Integer.valueOf(this.invoke((Database)var1));
   }

   public final int invoke(@NotNull Database it) {
      Intrinsics.checkParameterIsNotNull(it, "it");
      return db.delete("Customers", null, null);
   }
}

在你的 Android dex 文件中,每一个 lambda 表达式都被编译成一个 Function,这将最终增加3到4个方法

好消息是,这些 Function 对象的新实例只在必要时才创建。在实践中,这意味着:

  • 对捕获表达式来说,每当一个 lambda 做为参数传递的时候都会生成一个新的 Function实例,执行完后就会进行垃圾回收。
  • 对非捕获表达式(纯函数)来说,会创建一个单例的 Function 实例并且在下次调用时重用。

由于我们示例中的调用代码使用了一个非捕获的 lambda,因此它被编译为一个单例而不是内部类:

this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);

避免反复调用那些正在调用捕获 lambdas的标准的(非内联)高阶函数以减少垃圾回收器的压力。

装箱的开销

与 Java8 大约有43个不同的专业方法接口来尽可能地避免装箱和拆箱相反,Kotnlin 编译出来的 Function 对象只实现了完全通用的接口,有效地使用任何输入输出值的 Object 类型。

/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
    /** Invokes the function with the specified argument. */
    public operator fun invoke(p1: P1): R
}

这意味着调用一个做为参数传递给高阶函数的方法时,如果输入值或者返回值涉及到基本类型(如 Int 或 Long),实际上调用了系统的装箱和拆箱。这在性能上可能有着不容忽视的影响,特别是在 Android 上。

在上面编译好的 lambda 中,你可以看到结果被装箱成了 Integer 对象。然后调用者代码马上又将其拆箱。

当写一个标准(非内联)的高阶函数(涉及到以基本类型做为输入或输出值的函数做为参数)时要小心一点。反复调用这个参数函数会由于装箱和拆箱的操作对垃圾回收器造成更多压力。

内联函数来补救

幸好,使用 lambda 表达式时,Kotlin 有一个非常棒的技巧来避免这些成本:将高阶函数声明为内联。这将会使编译器将函数体直接内联到调用代码内,完全避免了方法调用。对高阶函数来说好处更大,因为作为参数传递的 lambda 表达式的函数体也会被内联起来。实际的影响有:

  • 声明 lambda 时不会有 Function 对象被实例化;
  • 不需要针对 lambda 输入输出的基本类型值进行装箱和拆箱;
  • 方法总数不会增加;
  • 不会执行真正的函数调用。对那些被多次使用的注重 CPU (计算)的方法来说可以提高性能。

将我们的 transaction() 函数声明为内联后,调用代码变成了:

db.beginTransaction();
int var5;
try {
   int result$iv = db.delete("Customers", null, null);
   db.setTransactionSuccessful();
   var5 = result$iv;
} finally {
   db.endTransaction();
}

关于这个杀手锏特性的一些警告:

  • 内联函数不能直接调用自己,也不能通过其他内联函数来调用;
  • 一个类中被声明为公共的内联函数只能访问这个类中公共的方法和成员变量;
  • 代码量会增加。多次内联一个长函数会使生成的代码量明显增多,尤其这个长方法又引用了另外一个长的内联方法。

如果可能的话,就将一个高阶函数声明为内联。保持简短,如有必要可以将大段的代码块移至非内联的方法中。
你还可以将调用自代码中影响性能的关键部分的函数内联起来。

我们将在以后的文章中讨论内联函数的其他性能优势。


伴生对象

Kotlin 类没有静态变量和方法。相应的,类中与实例无关的字段和方法可以通过伴生对象来声明。

通过它的伴生对象来访问私有的类字段

考虑下面的例子:

class MyClass private constructor() {

    private var hello = 0

    companion object {
        fun newInstance() = MyClass()
    }
}

编译的时候,一个伴生对象被实现为一个单例类。这意味着,就像任何需要从外部类来访问其私有字段的 Java 类一样,通过伴生对象来访问外部类的私有字段(或构造器)将生成额外的 getter 和 setter 方法。每次对一个类字段的读或写都会在伴生对象中引起一个静态的方法调用。

ALOAD 1
INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)I
ISTORE 2

在 Java 中我们会对这些字段使用 package 级别的访问权限来避免生成这些方法。但是 Kotlin 没有 package 级别的访问权限。使用 public 或者 internal 访问权限来代替的话会生成默认的 getter 和 setter 实例方法来使外部世界能够访问字段,而且调用实例方法从技术上说比调用静态方法成本更大。所以不要因为优化的原因而改变字段的访问权限。

如果需要从一个伴生对象中反复的读或写一个类字段,你可以将它的值缓存在一个本地变量中来避免反复的隐性的方法调用。

访问伴生对象中声明的常量

在 Kotlin 中你通常在一个伴生对象中声明在类中使用的“静态”常量。

class MyClass {

    companion object {
        private val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}

这段代码看起来干净整洁,但是幕后发生的事情却十分不堪。

基于上述原因,访问一个在伴生对象中声明为私有的常量实际上会在这个伴生对象的实现类中生成一个额外的、合成的 getter 方法。

GETSTATIC be/myapplication/MyClass.Companion : Lbe/myapplication/MyClass$Companion;
INVOKESTATIC be/myapplication/MyClass$Companion.access$getTAG$p (Lbe/myapplication/MyClass$Companion;)Ljava/lang/String;
ASTORE 1

但是更糟的是,这个合成方法实际上并没有返回值;它调用了 Kotlin 生成的实例 getter 方法:

ALOAD 0
INVOKESPECIAL be/myapplication/MyClass$Companion.getTAG ()Ljava/lang/String;
ARETURN

当常量被声明为 public 而不是 private 时,getter 方法是公共的并且可以被直接调用,因此不需要上一步的方法。但是 Kotlin 仍然必须通过调用 getter 方法来访问常量。

所以我们(真的)解决了(问题)吗?并没有!事实证明,为了存储常量值,Kotlin 编译器实际上在主类级别上而不是伴生对象中生成了一个 private static final 字段。但是,因为在类中静态字段被声明为私有的,在伴生对象中需要有另外一个合成方法来访问它

INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;
ARETURN

最终,那个合成方法读取实际值:

GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;
ARETURN

换句话说,当你从一个 Kotlin 类来访问一个伴生对象中的私有常量字段的时候,与 Java 直接读取一个静态字段不同,你的代码实际上会:

  • 在伴生对象上调用一个静态方法,
  • 然后在伴生对象上调用实例方法,
  • 然后在类中调用静态方法,
  • 读取静态字段然后返回它的值。

这是等同的 Java 代码:

public final class MyClass {
    private static final String TAG = "TAG";
    public static final Companion companion = new Companion();

    // synthetic
    public static final String access$getTAG$cp() {
        return TAG;
    }

    public static final class Companion {
        private final String getTAG() {
            return MyClass.access$getTAG$cp();
        }

        // synthetic
        public static final String access$getTAG$p(Companion c) {
            return c.getTAG();
        }
    }

    public final void helloWorld() {
        System.out.println(Companion.access$getTAG$p(companion));
    }
}

我们能得到更少的字节码吗?是的,但并不是所有情况都如此。

首先,通过 const 关键字声明值为编译时常量来完全避免任何的方法调用是有可能的。这将有效地在调用代码中直接内联这个值,但是只有基本类型和字符串才能如此使用。

class MyClass {

    companion object {
        private const val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}

第二,你可以在伴生对象的公共字段上使用 @JvmField 注解来告诉编译器不要生成任何的 getter 和 setter 方法,就像纯 Java 中的常量一样做为类的一个静态变量暴露出来。实际上,这个注解只是单独为了兼容 Java 而创建的,如果你的常量不需要从 Java 代码中访问的话,我是一点也不推荐你用一个晦涩的交互注解来弄乱你漂亮 Kotlin 代码的。此外,它只能用于公共字段。在 Android 的开发环境中,你可能只在实现 Parcelable 对象的时候才会使用这个注解:

class MyClass() : Parcelable {

    companion object {
        @JvmField
        val CREATOR = creator { MyClass(it) }
    }

    private constructor(parcel: Parcel) : this()

    override fun writeToParcel(dest: Parcel, flags: Int) {}

    override fun describeContents() = 0
}

最后,你也可以用 ProGuard 工具来优化字节码,希望通过这种方式来合并这些链式方法调用,但是绝对不保证这是有效的。

与 Java 相比,在 Kotlin 中从伴生对象里读取一个 static 常量会增加 2 到 3 个额外的间接级别并且每一个常量都会生成 2 到 3个方法。
始终用 const 关键字来声明基本类型和字符串常量从而避免这些(成本)。
对其他类型的常量来说,你不能这么做,因此如果你需要反复访问这个常量的话,你或许可以把它的值缓存在一个本地变量中。

同时,最好在它们自己的对象而不是伴生对象中来存储公共的全局常量。



这就是第一篇文章的全部内容了。希望这可以让你更好的理解使用这些 Kotlin 特性的影响。牢记这一点以便在不损失可读性和性能的情况下编写更智能的的代码。

继续阅读第二部分:局部函数,空值安全,可变参数。





原文发布时间为:2017年7月13日


本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。

时间: 2024-12-28 00:18:42

【译】探索 Kotlin 中的隐性成本(第一部分)的相关文章

【译】探索 Kotlin 中的隐性成本(第二部分)

本文讲的是[译]探索 Kotlin 中的隐性成本(第二部分), 原文地址:Exploring Kotlin's hidden costs - Part 2 原文作者:Christophe B. 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:Feximin 校对者:PhxNirvana .tanglie 局部函数,空值安全和可变参数 本文是正在进行中的 Kotlin 编程语言系列的第二部分.如果你还未读过第一部分的话,别忘了去看一下. 让我们重新看一下

【译】探索 Kotlin 的隐性成本(第三部分)

本文讲的是[译]探索 Kotlin 的隐性成本(第三部分), 原文地址:Exploring Kotlin's hidden costs - Part 3 原文作者:Christophe B. 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:PhxNirvana 校对者:Zhiw.Feximin 委托属性(Delegated propertie)和区间(range) 本系列关于 Kotlin 的前两篇文章发表之后,读者们纷至沓来的赞誉让我受宠若惊,其中还

[译] 探索 Swift 4 中新的 String API

本文讲的是[译] 探索 Swift 4 中新的 String API, WWDC 已经结束了(我觉得是自 2014 年来最好的一场 WWDC),同时 Xcode 9 beta 版也发布了,很多开发者已经开始把玩 Swift 4 ,今年的新版本真心不错,这是一个改进版本而不是重构版本(像 Swift 2 和 3),因此大多数代码升级起来会更容易. 其中一个改进是 String 的 API,在 Swift 4 中更易用,也更强大.在过去的 Swift 版本中,String API 经常被提出为一个例

企业呼叫中心中的隐性成本

很过企业的呼叫中心,特别是中小企业呼叫中心,都采用降低系统成本或节省劳动力成本的方式对成本进行控制,但是这往往会影响企业丢失呼叫中心运作中最重要的服务质量和效率优势,导致客户满意度下降,企业评价降低.这就是"抓显性成本,丢隐形优势"的典型例子. 对于企业来说,建立呼叫中心的目的,就是要增强企业客户服务,为客户开启快捷.满意的服务通道.而过于低廉的系统产品,通常在使用中出现或大或小的质量问题,造成呼叫中心不能良好运作,企业不得不花费更多精力去面对不佳客服质量带来的负面问题,实际上则付出了

[译] 混乱世界中的稳定:Postgres 如何使事务原子化

本文讲的是[译] 混乱世界中的稳定:Postgres 如何使事务原子化, 原文地址:Stability in a Chaotic World: How Postgres Makes Transactions Atomic 原文作者:Brandur 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:TanJianCheng 校对者:xiaoyusilen mnikn 混乱世界中的稳定:Postgres 如何使事务原子化 原子性( "ACID" 特

[译] 将 Kotlin 应用于现有 Java 代码的策略

本文讲的是[译] 将 Kotlin 应用于现有 Java 代码的策略, 原文地址:On Strategies to apply Kotlin to existing Java code 原文作者:Enrique López Mañas 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:Luolc 校对者:skyar2009, phxnirvana 将 Kotlin 应用于现有 Java 代码的策略 将 Kotlin 应用于现有 Java 代码的策略 自

云存储的隐性成本

本文讲的是云存储的隐性成本,[IT168 资讯]云存储的定价就像个实用工具一样.一遍又一遍,几乎所有厂商都在重复同样的口号:"客户只需为他们所使用的那部分付钱."公共云存储每月每GB的价格介于12至25美分之间. 不过,云存储真正的节约可能更多的是与公司储存自己的数据相联系的所有附带成本有关."很多数据中心已经依靠管理对折数据量," Enterprise Strategy Group的分析师Terri McClure说道. "如果你没有空间,电力和制冷,廉

封堵五大IT隐性成本“黑洞”的四个方法

目前世界性经济危机咋暖还寒,什么时候能复苏还是一个未知之数.因此,近期最受CIO关注的话题之一,就是如何减少IT成本.但这却是一个复杂的问题,虽然不少CIO都制定了IT成本控制目标,但近期有调查报告却显示许多企业的IT成本削减计划依然没有达到预期的效果. 在调查中许多企业的CIO都表示经常会低估或漏掉某些IT成本.究其原因是IT成本管理的"黑洞"主要存在于隐性成本中,而在粗放式的IT成本管理中大家往往又对隐性成本缺乏足够的重视和认识,结果自然就使得IT成本控制始终不尽人意.通常IT成本

上云有隐性成本? 用户要警惕五个坑

上云与否,早已不是企业的选择题,计算.网络.存储资源的虚拟化为业务流程带来了灵活可扩展的便利性.IT资源"拿来就用.想用就有"的理念让企业有了更多选择,也使得基础设施的部署成本有效削减.不过在企业迁移上云的过程中,想获得真正的实惠却并不简单,除了要转变传统的业务理念,还要做出合理部署,否则就会遇到一些坑. 上云有隐性成本 用户要警惕五个坑(图片来自Luke Lonergansf) 对于任何一家企业来说,每年的巨额IT支出难以避免,而且买来的资源能否充分利用也要画一个问号,更不要说背后的