[译]代码生成的经济学

328 阅读8分钟

Jake Wharton对于代码生成的两篇实践经验:

1.代码生成的经济学(本文)。

2.通过处理源代码优化字节码 (应用于Android最新的ViewBinding,这一库也是要把ButterKnife驱逐了。)


原文地址:jakewharton.com/the-economi…


在我从 Jesse Wilson那儿学到的许东西中,有“生成代码的经济学”一词。这句话表达了一种观点,我们在生成生成时重视的东西和我们手写代码所看重的东西是不同的。

一个代码生成器只编写一次,但是它生成的代码却会出现很多次。因此,任何让生成器产出更有效的代码的投资都会很快见到成效。这意味着尽可能地生成更少的代码和分配更少的对象。我想用两个具体的,真是的示例进行扩展。

额外的方法引用

尽管不像过去一样有那么多问题,方法引用计数仍然值得我们关注。对于生成的代码来说尤其如此。在生成器中细微的改变都会造成成百上千的引用计数增加或者减少。

生成的类通常是运行时库中类的子类型。除了促进多态外,这还可以合并常见的功能和行为。就JSON模型类来说,想要保留解析期间遇到的未知的键和值。每个生成的类都可以维护未知的的Map<String,?>,但合并到库的基类中才是理想选择。

abstract class JsonModel{
    private final Map<String,?> unknownPairs;
    
    public final Map<String,?> getUnknownPairs(){
        return unknownPairs;
    }
}

每个生成的类中都没有getUnknownPairs()方法显然可以减少计数。但是,由于因为计数不仅仅涉及声明的方法,因此减少生成的代码中引用的方法也将产生影响。

每个生成类都扩展JsonModel病实现toString(),后者输出自己的字段以及getUnkownPairs() 映射。

final class UserModel extends JsonModel{
    private final String name;
    private final String email;
    
    // ...
    
    @Override public String toString(){
        return "UserModel{"
            +"name="+ name+ ", "
            +"email="+ email + ", "
            +"unknownPairs="+getKnownPairs()
            +"}";
    }
}

当使用dexdump编译,dex和转储上述类的Dalvik字节码时,toString调用getUnknownPairs()方法的方式令人惊讶。

[00024c] UserModel.toString:()Ljava/lang/String;
0000: iget-object v0, v5, LUserModel;.name:Ljava/lang/String;
0002: iget-object v1, v5, LUserModel;.email:Ljava/lang/String;
0004: invoke-virtual {v5}, LUserModel;.getUnknownPairs:()Ljava/util/Map;
0007: move-result-object v2

尽管将getUnknownPairs()方法放置到JsonModel超类中,但是每个生成的类都会生成对该方法的引用,就好像在生成的类型上定义了该方法一样。移动这个方法实际上并不会减少计数!

一个中型应用程序的API层可能有100个模型类。如果每个生成类都包含对在超类中定义的方法的四个调用,那么将无缘无故创建400个方法引用。

将生成的代码更改为显式使用super将产生所有直接指向超类方法的方法引用。

@Override public String toString() {
   return "UserModel{"
       + "name=" + name + ", "
       + "email=" + email + ", "
-      + "unknownPairs=" + getUnknownPairs()
+      + "unknownPairs=" + super.getUnknownPairs()
       + '}';
 }
[00024c] UserModel.toString:()Ljava/lang/String;
 0000: iget-object v0, v5, LUserModel;.name:Ljava/lang/String;
 0002: iget-object v1, v5, LUserModel;.email:Ljava/lang/String;
-0004: invoke-virtual {v5}, LUserModel;.getUnknownPairs:()Ljava/util/Map;
+0004: invoke-virtual {v5}, LJsonModel;.getUnknownPairs:()Ljava/util/Map;
 0007: move-result-object v2

这些400个额外的引用现在减少到只有1个!我们通常不太可能做出这样的更改,但是因为我们控制基类和生成的类,这个更改是安全的,并且能大大减少方法引用。

需要提出的一点是,使用R8优化应用会自动更改方法引用。但是并不是代码生成器的每个使用者都会使用优化器。进行此小更改将确保每个人都受益。

字符串重复

在生成的代码中没有字符串是给定的,但它经常出现,需要足够的思考它带来的影响。以我的经验,生成代码中的字符串通常分成两类:用于某种类型的序列化的键或用于异常错误信息。对于前者我们无能为力,但是后者非常有趣,因为这些字符串存在于代码路径中,而这些路径预计很少使用。

例如,一个代码生成器将布局中的Android视图绑定到一个类的字段中。当视图出现在布局的每个配置中时都是必需的,并且我们会在运行时通过空检查验证其存在。

public final class MainBinding {
  // …

  public static MainBinding bind(View root) {
    TextView name = root.findViewById(R.id.name);
    if (name == null) {
      throw new NullPointerException("View 'name' required but not found");
    }
    TextView email = root.findViewById(R.id.email);
    if (email == null) {
      throw new NullPointerException("View 'email' required but not found");
    }
    return new MainBinding(root, name, email);
  }
}

如果你是用Baksmali去编译、dex和转储.dex文件的内容,你可以看到在字符串数据区的字符串输出。

                           |[20] string_data_item
00044f: 22                 |  utf16_size = 34
000450: 5669 6577 2027 6e61|  data = "View \'name\' required but not found"
000458: 6d65 2720 7265 7175|
000460: 6972 6564 2062 7574|
000468: 206e 6f74 2066 6f75|
000470: 6e64 00            |
                           |[21] string_data_item
000473: 23                 |  utf16_size = 35
000474: 5669 6577 2027 656d|  data = "View \'email\' required but not found"
00047c: 6169 6c27 2072 6571|
000484: 7569 7265 6420 6275|
00048c: 7420 6e6f 7420 666f|
000494: 756e 6400          |

为了以dex文件格式进行编码,这些字符串分别需要36和37个字节(额外的两个字节用于编码长度和结尾空字符串)。

在真实的应用中,我们可以快速消除这些字符串消耗。每个字符串需要32个字节加上view ID的长度,我们通常是12个字符。一个中型应用有大雨50个不符,每个布局10个view。所以5010(32+12)计算出总共22KB的消耗。这不是一个大空间,但是考虑到在程序出错前我们是不希望用到这些字符串的,所以这个花费是不值得的。

在dex字符串是会重复数据删除的,所以如果字符串的公开部分是分开的,我们只需要消耗一次空间。另外,字符串数据区也被用于保留字段名称,所以和字段名称匹配的字符串也是会被释放的。利用这一点,我们可能会想当然的将这些字符串分成三部分。

if (name == null) {
-  throw new NullPointerException("View 'name' required but not found");
+  throw new NullPointerException("View '" + "name" + "' required but not found");
 }
 TextView email = root.findViewById(R.id.email);
 if (email == null) {
-  throw new NullPointerException("View 'email' required but not found");
+  throw new NullPointerException("View '" + "email" + "' required but not found");
 }

不幸的是,javac认为常量的连接是它可以优化的东西,因此可以将它们变回单个唯一的字符串。要使其智能化,我们需要使用StringBuilder来生成代码,或者使用鲜为人知的String.concat方法。

if (name == null) {
-  throw new NullPointerException("View 'name' required but not found");
+  throw new NullPointerException("Missing required view with ID: ".concat("name"));
 }
 TextView email = root.findViewById(R.id.email);
 if (email == null) {
-  throw new NullPointerException("View 'email' required but not found");
+  throw new NullPointerException("Missing required view with ID: ".concat("email"));
 }

现在dex文件质包含单个前缀字符串,我们也不需要ID字符串了,因为他们已经被使用在R.id字段了。

                           |[17] string_data_item
00046a: 1f                 |  utf16_size = 31
00046b: 4d69 7373 696e 6720|  data = "Missing required view with ID: "
000473: 7265 7175 6972 6564|
00047b: 2076 6965 7720 7769|
000483: 7468 2049 443a 2000|

22KB的字符串数据减少到33个字节!现在值得注意的是,我们花了额外的7个字节来加载第二个字符串并调用String.concat,但是由于该字符串始终大于32个字节,因此仍然是一个不错的选择。现在仍然有重复数据删除实际的连接和异常抛出代码的空间,这样,每个类仅需花费一次,而不是每个视图一次。但是我将留给另一篇文章。

在手写代码中看到这些优化中的任何一个都应该引起注意。The individual savings of applying them are not worth their otherwise unidomatic nature.然而,使用代码生成,经济学就变成的不一样了。只需对生成器进行一次更改,就可以将这种优化应用于数百或数千个位置,从而产生更大的效果。

--Jake Wharton