前言 在各种IDE中,都会有重构代码到一个新方法的选项(以下简称为重构方法),如下面是eclipse的重构功能:
重构方法可重复利用代码,优化软件设计,提高代码的可读性,使得功能扩展更容易等等。当然,过度地重构会使得代码变得难以阅读,难以定位出错的原因。
可重构分析 在进行重构之前,我们需要先分析方法中哪些代码是可以重构。可重构的代码有几个特点:
1)不应该包含返回指令(因为此时还未知该代码重构后的返回值是什么)。
2)重构代码外的跳转指令不能跳转到重构代码中,重构代码中的跳转指令只能跳转到重构代码中。
3)代码前后堆栈平衡。
4)最多只能有一个返回值,但可有多个参数。
由上面可以看出,为了分析出代码是否可重构,我们需要知道一些信息:
1)代码是否包含返回指令。
2)代码中跳转指令的引用是否符合要求。
3)代码前后栈的内容。
4)代码中读取的值和写入的值,且读取的值来自于其它代码,写入的值被其它代码所使用。
第1条最简单,直接过滤下是否包含返回指令就行。
第2条需要分析所有跳转指令,然后看下跳转的位置是否符合要求。
第3、4条需要分析指令执行流程,并计算出指令执行前后本地变量以及栈中的变化。
指令执行流程分析 每条指令执行完成后,都可能伴随着本地变量或栈的变化,记录这执行过程中各个状态的变化,对我们分析堆栈平衡或读写本地变量都有一定好处。
如果代码只是简单的顺序执行,那么处理起来就非常容易了。不过在很多情况下,代码都不会这么简单,而是有很多复杂的跳转。如何处理这些复杂的逻辑,并记录下相应的状态变化,就有点麻烦。
我们可以借鉴下ASM框架中的Analyzer.findSubroutine方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 private void findSubroutine(int insn, final Subroutine sub, final List<AbstractInsnNode> calls) throws AnalyzerException { while (true) { if (insn < 0 || insn >= n) { throw new AnalyzerException(null, "Execution can fall off end of the code"); } if (subroutines[insn] != null) { return; } subroutines[insn] = sub.copy(); AbstractInsnNode node = insns.get(insn); // calls findSubroutine recursively on normal successors if (node instanceof JumpInsnNode) { if (node.getOpcode() == JSR) { // do not follow a JSR, it leads to another subroutine! calls.add(node); } else { JumpInsnNode jnode = (JumpInsnNode) node; findSubroutine(insns.indexOf(jnode.label), sub, calls); } } else if (node instanceof TableSwitchInsnNode) { TableSwitchInsnNode tsnode = (TableSwitchInsnNode) node; findSubroutine(insns.indexOf(tsnode.dflt), sub, calls); for (int i = tsnode.labels.size() - 1; i >= 0; --i) { LabelNode l = tsnode.labels.get(i); findSubroutine(insns.indexOf(l), sub, calls); } } else if (node instanceof LookupSwitchInsnNode) { LookupSwitchInsnNode lsnode = (LookupSwitchInsnNode) node; findSubroutine(insns.indexOf(lsnode.dflt), sub, calls); for (int i = lsnode.labels.size() - 1; i >= 0; --i) { LabelNode l = lsnode.labels.get(i); findSubroutine(insns.indexOf(l), sub, calls); } } // calls findSubroutine recursively on exception handler successors List<TryCatchBlockNode> insnHandlers = handlers[insn]; if (insnHandlers != null) { for (int i = 0; i < insnHandlers.size(); ++i) { TryCatchBlockNode tcb = insnHandlers.get(i); findSubroutine(insns.indexOf(tcb.handler), sub, calls); } } // if insn does not falls through to the next instruction, return. switch (node.getOpcode()) { case GOTO: case RET: case TABLESWITCH: case LOOKUPSWITCH: case IRETURN: case LRETURN: case FRETURN: case DRETURN: case ARETURN: case RETURN: case ATHROW: return; } insn++; } }
上述代码中的执行流程如下:
1)取出当前指令结点。
2)如果是包含跳转的结点,则递归调用本流程,到要跳转的位置处执行。
3)如果是返回、goto、switch等相关指令,则结束。
4)当前结点指向下一条指令,并跳回1执行。
借鉴此思路,我们就可以写出相应的记录状态信息的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 InsnList insns; TryCatchBlockNode[][] handlers; public void calculateSubroutine(int insn) { endLoop: while(true) { // TODO 如果此指令已经执行过了,则应该退出执行,以免死循环了。 ... AbstractInsnNode node = insns.get(insn); // TODO 模拟执行指令,并记录local和stack等的状态信息 ... int opcode = node.getOpcode(); int t = node.getType(); if(t == AbstractInsnNode.JUMP_INSN) { if(opcode != JSR) { JumpInsnNode jnode = (JumpInsnNode) node; calculateSubroutine(insns.indexOf(jnode.label)); } if(opcode == JSR || opcode == GOTO) { break; } } else if(t == AbstractInsnNode.TABLESWITCH_INSN) { TableSwitchInsnNode tsnode = (TableSwitchInsnNode) node; calculateSubroutine(insns.indexOf(tsnode.dflt)); for (int i = tsnode.labels.size() - 1; i >= 0; --i) { calculateSubroutine(insns.indexOf(tsnode.labels.get(i))); } } else if(t == AbstractInsnNode.LOOKUPSWITCH_INSN) { LookupSwitchInsnNode lsnode = (LookupSwitchInsnNode) node; calculateSubroutine(insns.indexOf(lsnode.dflt)); for (int i = lsnode.labels.size() - 1; i >= 0; --i) { calculateSubroutine(insns.indexOf(lsnode.labels.get(i))); } } if(handlers[insn] != null) { for(TryCatchBlockNode tcbn : handlers[insn]) { calculateSubroutine(insns.indexOf(tcbn.handler)); } } switch (opcode) { case GOTO: case RET: case TABLESWITCH: case LOOKUPSWITCH: case IRETURN: case LRETURN: case FRETURN: case DRETURN: case ARETURN: case RETURN: case ATHROW: break endLoop; } ++insn; } }
这里只是给个思路,至于具体怎么记录状态信息,就要看实际需要来处理了。我们需要的是记录栈、本地变量、本地变量读写等的状态信息。
有了每条指令执行前后的栈状态信息后,就可以找到栈平衡的两个点了,为了简单起见,建议以栈为空作为栈平衡的点。另外根据每条指令执行前后的读写状态信息,我们可以识别出某段代码相对于其它代码有参与读写操作的本地变量有哪些,从而识别出是否满足返回值只有一个(写入操作),参数多个(读取操作)的情况。
通过上面的方案,我们就可以识别出可重构的代码,并识别出相应的参数与返回值,具体实现代码比较长,就不一一贴出来了,建议大家自己试着实现下。
重构方法思路 找出可重构的代码后,我们需要识别出相应的参数与返回值,这样我们就可以开始重构方法了。重构方法的思路如下:
1)在类中产生一个新的公有静态方法。
2)把重构的指令一一写入新的方法中,并根据参数的位置修复对应的本地变量。
3)如果新方法有返回值,则把返回值加载到栈中,并返回。
4)在旧方法中,去掉用于重构的指令,并把需要用于作为参数的本地变量依次加载到栈中,然后调用新的方法。
5)如果新方法有返回值,则把返回值保存到相应的本地变量中。
重构方法实例分析 单单看思路或许会感觉有点晕,下面用相应的实例解释一下。
假设有如下方法:
1 2 3 4 5 6 7 8 9 10 public static void test(String[] args) { int i = args.length; if(i < 1) { return; } for(String arg : args) { i += arg.length(); } System.out.println(i); }
现希望把for循环重构成一个名为addLen的方法,效果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static void test(String[] args) { int i = args.length; if(i < 1) { return; } i = addLen(args, i); System.out.println(i); } public static int addLen(String[] args, int i) { for(String arg : args) { i += arg.length(); } return i; }
看Java代码非常容易理解,我们先看看重构前的字节码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public static test([Ljava/lang/String;)V L0 ALOAD 0 R.....| ARRAYLENGTH R.....|R ISTORE 1 R.....|I L1 ILOAD 1 RI....| ICONST_1 RI....|I IF_ICMPGE L2 RI....|II L3 RETURN RI....| L2 ALOAD 0 RI....| DUP RI....|R ASTORE 5 RI....|RR ARRAYLENGTH RI...R|R ISTORE 4 RI...R|I ICONST_0 RI..IR| ISTORE 3 RI..IR|I GOTO L4 RI.IIR| L5 ALOAD 5 RI.IIR| ILOAD 3 RI.IIR|R AALOAD RI.IIR|RI ASTORE 2 RI.IIR|R L6 ILOAD 1 RIRIIR| ALOAD 2 RIRIIR|I INVOKEVIRTUAL java/lang/String.length ()I RIRIIR|IR IADD RIRIIR|II ISTORE 1 RIRIIR|I L7 IINC 3 1 RIRIIR| L4 ILOAD 3 RI.IIR| ILOAD 4 RI.IIR|I IF_ICMPLT L5 RI.IIR|II L8 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; RI.IIR| ILOAD 1 RI.IIR|R INVOKEVIRTUAL java/io/PrintStream.println (I)V RI.IIR|RI L9 RETURN RI.IIR|
在每条字节码后面,都包括了执行该字节码前的状态信息,其中’|’左边的是本地变量的状态,右边的是栈的状态。根据这些信息,我们可以发现栈为空的字节码有很多,而加粗的则是我们想找的。
需要注意的是,我们取的是[n,m)这样的方式,即不包括GetStatic这条指令。这是因为我们要求执行第一条指令前的栈为空,执行最后一条指令后的栈为空,而该状态信息是代表执行前的,所以说栈为空的指令的前一条,才是我们需要的最后一条指令。
也就是说,L2~L8就是我们需要重构的代码。
接下来,我们不看重构后的字节码,而是按上面所说的方式进行重构试试。
1)待重构的代码中不带有返回等指令,满足条件。
2)待重构的代码中跳转指令的引用符合要求。
3)待重构的代码执行前后栈为空,满足条件。
4)待重构的代码中,读取执行前已存在的本地变量有0和1,写入执行前已存在且执行后会读取的本地变量有1,即参数为0和1,返回值为1。(这里的0和1代表的是本地变量的位置)
我们接着来分析下参数,第0号位的值来自于原方法的第1个参数,是个字符串数组类型([Ljava/lang/String;),第1号位的值来自于前面的ARRAYLENGTH方法,为整型值。
同样的,返回值是个整形值,所以方法的描述就有了:([Ljava/lang/String;I)I
这样,我们就可以把L2~L8的指令改一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static test([Ljava/lang/String;)V L0 ALOAD 0 R.....| ARRAYLENGTH R.....|R ISTORE 1 R.....|I L1 ILOAD 1 RI....| ICONST_1 RI....|I IF_ICMPGE L2 RI....|II L3 RETURN RI....| L2 ALOAD 0 RI....| ILOAD 1 RI....|R INVOKESTATIC XXX.addLen([Ljava/lang/String;I)I RI....|RI ISTORE 1 RI....|I L8 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; RI.IIR| ILOAD 1 RI.IIR|R INVOKEVIRTUAL java/io/PrintStream.println (I)V RI.IIR|RI L9 RETURN RI.IIR|
接下来,就需要把L2~L8的指令重构到方法addLen中,由于新方法的参数刚好与指令所用到的本地变量一一对应,所以不需要修复,而其又需要返回一个整形值,且该值在本地变量1号位置,所以需要在最后加入两条指令:ILOAD 1和IRETURN。具体如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public static addLen([Ljava/lang/String;I)I ALOAD 0 RI....| DUP RI....|R ASTORE 5 RI....|RR ARRAYLENGTH RI...R|R ISTORE 4 RI...R|I ICONST_0 RI..IR| ISTORE 3 RI..IR|I GOTO L4 RI.IIR| L5 ALOAD 5 RI.IIR| ILOAD 3 RI.IIR|R AALOAD RI.IIR|RI ASTORE 2 RI.IIR|R L6 ILOAD 1 RIRIIR| ALOAD 2 RIRIIR|I INVOKEVIRTUAL java/lang/String.length ()I RIRIIR|IR IADD RIRIIR|II ISTORE 1 RIRIIR|I L7 IINC 3 1 RIRIIR| L4 ILOAD 3 RI.IIR| ILOAD 4 RI.IIR|I IF_ICMPLT L5 RI.IIR|II ILOAD 1 RI.IIR| IRETURN RI.IIR|I
现在,大家与重构后的Java代码对应的字节码对比下,是不是基本所有指令都一样呢?
权限问题 重构后的方法放在同一类中,那肯定是没问题的。但是这样的话,对分析影响不会很大。如果能把重构后的方法放在其它类中,也就是说其它类会多出一些无意义的代码,这样想清晰地分析出某类的用途,就比较难了。
那么什么样的方法可以放到其它类中,什么样的方法不能呢?大家细心想下,我们Java编程中,如果要把一个方法改到另一个方法中,可能会出现什么限制呢?其实很简单,就是权限问题。
如果是我们编写的类,则我们可以把相关的访问权限都改为public,就一定不会有问题了。但如果是系统自带的类,我们无法更改,因而如果使用到的类或方法或属性有任意一个不是public的,我们也只是把重构后的方法放在同一类中。
构造方法问题 熟悉Java的童鞋应该知道,在构造方法中,如果没指定调用哪个父类的构造方法,则会自动调用父类的默认构造方法。而如果有指定,则调用指定的父类构造方法,且调用此方法前,不能有其它的方法调用代码、赋值代码等。也就是说,调用父类构造方法及其前面的代码,都是不能被重构的。