前言
我们写代码的过程中,当嗅到一堆“坏代码”的味道时,就可以考虑重构了。尤其在TDD开发模式中,有了单元测试的保护,我们可以重构得更加大胆也更加频繁。如果我们在IDE里开发的话,通常可以利用IDE自身提供的重构功能来帮助我们重构;如果是在Vim或者Emacs等编辑器里开发的话,可以利用编辑器自带的命令或者插件来进行重构。不过,除了这些,还有其他的重构姿势吗?答案是肯定的,那就是Bowler.
Bowler是什么
Bowler是一款针对Python代码的重构工具,能够在语法树层面操作Python代码。它可以让我们对代码进行安全、大规模的修改,保证重构后的代码能够编译和运行。Bowler拥有简单易用的命令行接口,同时也提供流畅的Python API来应对更加复杂的重构场景。
如何利用Bolwer重构代码
Bowler构建于Python的lib2to3标准库提供的Concrete Syntax Tree(CST)之上。这使得Bowler可以直接修改语法树,而不会丢失代码的格式、注释以及空白等信息,也不需要直接支持每个新发布的Python版本。Bolwer中有几个重要的概念:Fixers,Queries,Transforms,Selectors,Filters,Modifiers,Processors以及Execution。
Fixers
Fixers是lib2to3中的核心概念,其包含了基于语法的搜索模式,用于搜索CST中的节点,同时也包含了转换匹配节点的方法。Bowler封装了若干fixers,并对lib2to3重构框架进行了一些修改,从而将这些fixers应用到目标Python源文件。
Queries
Bowler通过对CST构建“queries”来工作。Queries由一个或多个“transforms”组成,它们指定要修改语法树的哪些元素,以及如何修改这些元素。每个transform表示对语法树的一次搜索以及对任何匹配元素的修改。这些transforms为lib2to3生成一个fixer,而Bowler将同时在处理的每个文件上执行多个transforms。通过将多个transforms组合在一起,Bowler能够进行大范围的重构,无论是简单的重命名还是复杂的API升级。
借助Bowler提供的API,你可以对Query类进行链式调用,而无需将每次的查询对象分配给某个变量或在接下来的方法调用中引用该变量。在实践中,代码看起来像这样:
(
Query()
.select(...)
.modify(...)
.execute()
)
Transforms
在定义所需query时,Query API会隐式创建transforms。每个transform都以“selector”(要搜索的语法树模式)或已包含selector模式的现有fixer开头。然后可以指定任意数量的“filters”,接着是一个或多个“modifiers”。每当一个selector或fixer添加到query中时,都会生成一个新的transform,并将后续的filters或modifiers添加到该transform中。
重复这种selector、filters和modifiers的模式将生成更多的transforms,每个transform都将在语法树中的匹配元素上执行。
Selectors
Selectors表示lib2to3的搜索模式,用于选择与给定模式匹配的语法树节点及其相关的子节点或叶子节点。Bowler包含了一组公共的selectors,它们会尝试查找特定类型的所有定义或引用,并将一致的命名方案应用于所有捕获的元素。
查找print函数的所有调用的selector如下:
pattern = """
power< "print"
trailer< "(" print_args=any* ")"
>
"""
(
Query()
.select(pattern)
...
)
这个例子查找名为print且紧跟括号的所有函数调用,并使用名称print_args捕获这些括号中包含的所有内容。
Filters
一旦找到与selector匹配的元素,filters可以通过检查原始匹配进一步限制传递给modifiers的元素,如果filter仍然匹配则返回True,如果需要排除该元素则返回False。只有当元素成功通过所有filter函数检查时,才会将其视为匹配项。如果元素没能通过其中某个filter函数,它将不会被传递或用于任何其他filter函数。
一个filter的例子,匹配仅含有一个字符串参数的print调用:
def print_string(node: LN, capture: Capture, filename: Filename) -> bool:
args = capture.get("print_args")
# args will be a list because we captured `any*`
if args:
return len(args) == 1 and args[0].type == Token.STRING:
return False
(
Query()
...
.filter(print_string)
...
)
Modifiers
在匹配的元素被过滤后,所有剩余的匹配将按顺序传递给所有的modifier函数。然后,modifier函数可以在从根到叶子之间的任何节点处转换语法树,而不仅限于匹配元素所在的分支。转换可以包括修改现有节点或叶子,删除或替换元素,或将元素插入树中。
与上述selector和filter示例相配的modifier如下:
def translate_string(node: LN, capture: Capture, filename: Filename) -> None:
args = capture.get("print_args")
# make sure the arguments haven't already been modified
if args and args[0].type == Token.STRING:
args[0].replace(
Call(
Name("tr"),
args=[args[0].clone()],
)
)
(
Query()
...
.modify(translate_string)
...
)
Modifer函数用嵌套节点表示对转化函数(tr)的调用,用于替换现有的字符串元素,并将现有字符串的拷贝作为唯一参数。
Processors
某些情况下,我们需要对代码库的最终修改进行后处理(post-process),因此Bowler提供了将“processors”添加到query的机制。这些processor函数将在文件的每一段修改区域上执行,并可选择是处理还是跳过该段。这种机制允许我们记录额外的日志、选择性地处理每段修改区域等等。
一个processor的例子,用于跟踪所修改的每个文件:
MODIFIED: Set[Filename] = set()
def modified_files(filename: Filename, hunk: Hunk) -> bool:
MODIFIED.add(filename)
return True
(
Query()
...
.process(modified_files)
...
)
Execution
在使用selectors,filters,modifiers和/或processors的某种组合构建query之后,Bowler提供了若干不同的执行命令来确定它如何将query应用于代码库。在query上调用.execute()时,默认会生成交互式diff——类似于git add -p提供的——并按顺序显示每段修改区域,由用户决定应用或跳过该段。这是“最安全”的选项,因为用户可以验证每个修改是否正确。还有一些选项可以只生成diff而不应用这些修改,或者在不询问的情况下应用所有修改。
(
Query()
...
.execute(
interactive = True, # ask about each hunk
write = False, # automatically apply each hunk
silent = False, # don't ask or print hunks at all
)
)
总结
在如今IDE重构功能日益完善的趋势下,应付普通的重构工作(比如重命名),这种方式显得有些繁琐。但是对于想解锁新姿势或者想进行复杂重构的同学,还是可以试试这个工具。
欢迎关注公众号:CodeHub
网友评论