为了账号安全,请及时绑定邮箱和手机立即绑定

也谈代码 —— 重构两年前的代码

标签:
Android

webp

为什么我们谈论代码?

也许有人会认为,谈论代码已经有点落后了——代码不再是问题,我们应当关注模型、需求、功能设计上。运用 Google 的自动编程框架 AutoML 和UIzard 的 pix2code就可以自动生成代码,看起来我们正在临近代码的终结点。

但是,注意但是!我们永远无法抛弃代码, 就算语言继续抽象,领域特定语言数量继续增加,也终结不了代码。因为代码呈现了需求的细节,这些细节无法被忽略或抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程要做的事。

我们可以创造各种与需求接近的语言,我们可以创造帮助把需求解析和汇整为框架结构的各种工具。然而,我们永远无法抛弃需求中的精确性和细节 —— 所以代码永存。

为什么我们总是在写烂代码?

有的人是因为大量的业务工作导致失去思考,有的人则是把提高代码质量寄希望于重构,但是往往在写烂代码的人不知道自己写的就是烂代码,他们没有掌握好代码的技巧,或者根本没有见过好代码,从而不知道什么是好的实践。

针对这种情况,下面会介绍好代码的特性和重构的技巧。从一个小的命名规范开始、逐步讲到函数、再到类、再到模块单元、乃至整个设计。

培养你的代码感

就好像好的读者不一定是好的作者,能分辨整洁代码和肮脏代码,也不意味着会写整洁代码。

写整洁代码,需要遵循大量的小技巧,贯彻刻苦习得的「代码感」。这种「代码感」就是关键所在。有些人生而有之,有些人费点劲才能得到。缺乏「代码感」的程序员,看混乱是混乱,无处着手。有「代码感」的程序员才能从混乱中看出其他的可能与变化,选出最好的方案。

想要得到「代码感」,最根本的途径是反复练习,接下来我将介绍大量的重构技巧和 demo ,强烈建议你在阅读时带上思考,对照自己的代码。

好代码需要遵循什么?重构有哪些技巧?

技巧一:起一个清晰、合理、有意义的命名

有意义的命名是体现表达力的一种方式。

  • 方法名应当是动词或动词短语,表达你的意图。如 deletePage 或 savePage。

  • 类名和对象名应该是名词或名词短语。如 Customer、WikiPage、Account和AddressParser。

  • 不要以数字来命名,除非是 changeJson2Map() 这种情况。

  • 单字母名称仅用于短方法中的本地变量。比如循环体内的 i,但是你不应该在类变量里使用 i ,名称长短应与其作用域大小相对应

  • 别给名称添加不必要的语境。 对于 Address 类的实体来说,AccountAddress 和 CustomerAddress 都是不错的名称,不过用在类名上就不太好了。

  • 遵循专业的术语。如果名词无法用英文表达,一定要用中文拼音,则不能用拼音缩写

命名我往往会修改好几次才会定下名字来,借助 IDE 重命名的代价极低,所以当你遇到不合理的命名时,不要畏惧麻烦,直接修改吧!

技巧二:保持函数短小、少的入参、同一抽象层级

我们都知道函数要尽量短小,职责要单一。但是你有没有忽视过其他的问题?

来看下这段糟糕的示例代码:

    private String url;
    private void startDownload(boolean isFormat, String userChannel, String userLevel, String regSource) {        if (isFormat) {
            url = String.format(URL, userChannel, userLevel, regSource);            if (url.length() != 0) {
                service.getData(url)
                .onSuccess(new Action() {
                    Toast.showToast(context, "success").show();
                }).onError(new Action() {
                    Toast.showToast(context, "error").show();
                });
            }
        } else {            // ...
        }
    }

这段代码至少犯了 4 个错误:

  1. 用了标识参数 isFormat,这样方法签名立刻变得复杂起来,也表示了函数不止做一件事情,这应该把函数一分为二。

  2. 使用了多元参数。如果函数需要两个、三个或三个以上的参数,就说明其中一些参数应该封装成类了。

  3. 使用了输出参数 url,输出参数比复杂的输入参数还要难以理解。读函数时,我们惯于认为信息通过参数输入函数,通过返回值从函数中输出。我们不期望信息通过参数输出,输出参数往往包含着陷阱。如果函数要对输入参数进行转换操作,转换结果就该体现为返回值上。

    例: appendFooter(s);这个函数是把 s添加到什么东西后面吗?或者它把什么东西添加到了 s后面?s是输入参数还是输出参数?如果是要给s添加个Footer,最好是这样设计:s.appendFooter();

  4. 没有保持同一抽象层级。函数中混杂不同抽象层级,去拼接代码的同时又发起了网络请求,还处理了请求结果,这往往让人迷惑。

思考下,你会怎么重构这段代码?

我们展示重构后的情况:

    private void startDownloadWhenNotFormat() {        // ...
    }    private void startDownloadWhenFormat() {
        UserProperty property = createUserProperty();
        String url = jointUrl(property);
        startDownload(url);
    }    private UserProperty createUserProperty(){        //...
    }    
    private String jointUrl(UserProperty property) {        return String.format(URL
                    , property.getUserChannel()
                    , property.getUserLevel()
                    , property.getRegSource());
    }    private void startDownload(String url) {        if (url.isEmpty()) {            return;
        }
        service.getData(url)
        .onSuccess(new Action() {
            onGetDataSuccess();
        }).onError(new Action() {
            onGetDataError();
        });
    }    
public class UserProperty {
    private String userChannel;    private String userLevel;    private String regSource;    //...}

阅读这样的代码你会觉得很舒服,代码拥有自顶向下的阅读顺序,主程序就像是一系列 TO 起头的段落,每一段都描述当前抽象层级,并引用位于下一抽象层级的后续 TO 起头段落,呈现出总-分的结构。

技巧三:短小、单一权责、内聚的类,暴露操作,隐藏数据细节

类的名称其实就表现了权责,如果无法为某个类命以精确的名称,说明这个类太长了,就应该拆分为几个高内聚的小类。

那么怎么评估类的内聚性?类中的方法和变量互相依赖、互相结合成一个逻辑整体,如果类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。

保持内聚性就会得到许多短小的类,仅仅是将较大的函数切分为小函数,就将导致更多的类出现。想想看一个有许多变量的大函数。你想把该函数中某一小部分拆解成单独的函数。不过,你想要拆出来的代码使用了该函数中声明的 4 个变量。是否必须将这 4 个变量都作为参数传递到新函数中去呢?

完全没必要!只要将 4 个变量提升为类的实体变量,完全无需传递任何变量就能拆解代码了。将函数拆分为小块后,你会发现类也丧失了内聚性,因为堆积了越来越多被少量函数共享的实体变量。

等一下!如果有些函数想要共享某些变量,为什么不让它们拥有自己的类呢?当类的变量越来越多,且变量的无关性越来越大,就拆分它! 所以,将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。

你以为这就结束了?停止你乱加取值器和赋值器的行为!我们不能暴露变量的数据细节和数据形态,应该以抽象形态表述数据。

著名的得墨忒耳律认为:模块不应了解它所操作对象的内部情形,即每个单元(对象或方法)应当对其他单元只拥有有限的了解,不应该有链式调用。

哈?我们觉得方便的链式调用风格,实际上暴露了其他单元的内部细节??

我认为是要区别情况来对待,链式调用风格比较整洁和有表现力,但是不能随意滥用,举个简单例子:

a.getB().getC().doSomething()  这种链式调用就违反了得墨忒耳定律,如果把a.getB().getC().doSomething() 改成 a.doSomething(),仍然违反了得墨忒耳定律。因为a里面会有b.getC().doSomething(),所以 b 类中还应该有一个doSomething()方法去调用 c 的 doSomething()a.doSomething()再来调用b.doSomethine()ab的具体实现不可知。

链式风格用在 a.method1().method2().method3();这种情况会比较合理。所以能不能用链式,需要看链的是一个类的内部还是不同类的连接。

技巧四:分离不同的模块

系统应将初始化过程和初始化之后的运行时逻辑分离开,但我们经常看到初始化的代码被混杂到运行时代码逻辑中。下面就是个典型的例子:

public Service getService() { 
    if (service == null) {
        service = new MyServiceImpl(...); 
    }    return service; 
}

你会自以为很优雅,因为延迟了初始化,在真正用到对象之前,无需操心这种对象的构造,而且也保证永远不会返回 null 值。

然而,就算我们不调用到getService()方法,MyServiceImpl 的依赖也需要导入,以保证顺利编译。 如果MyServiceImpl 是个重型对象,单元测试也会是个问题。我们必须给这些延迟初始化的对象指派恰当的测试替身(TEST DOUBLE) 或仿制对象(MOCK OBJECT)。

我们应当将这个初始化过程从正常的运行时逻辑中分离出来,方法有很多:

1. 交给 init 模块

将全部构造过程移到 init 模块中,设计其他模块时,无需关心对象是否已经构造,默认所有对象都已正确构造。

2.  抽象工厂方法

系统其他模块与如何构建对象的细节是分离开的,它只拥有抽象工厂方法的接口,具体细节是由 init 这边的接口实现类实现的。但其他模块能完全控制实体何时创建,甚至能给构造器传递参数。

3. 依赖注入中的控制反转

对象不负责实例化对自身的依赖,而是把工作移交给容器,实现控制的反转。比如 Android Dagger2 和 JavaEE Spring 都是这方面的实践。

4. Builder 模式

可以简单地把构造和构造的细节分离。

我们拆分了初始化和正常运行时逻辑,还有什么可以继续拆分的呢?

正常运行时逻辑除了业务逻辑,往往还混合了持久化、事务、打印日志、埋点等模块,如果说 OOP 是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。比如按 OOP 思想,设计一个打印日志 LogUtils 类,但是这个类是横跨并嵌入众多模块里的,在各个模块里分散得很厉害,到处都能见到。而利用 AOP 思想,我们无需再去到处调用 LogUtils 了,声明哪些方法需要打印日志,AOP 会在编译时把打印语句插进方法切面。AOP 思想有很多实践:

1. 代理

代理适用于简单的情况,例如在单独的对象或类中包装方法调用。然而,JDK提供的动态代理仅能与接口协同工作。对于代理类,你得使用字节码操作库,比如CGLIB、ASM或Javassist 。

2. AOP 框架

把持久化工作用 AOP 交给容器,使用描述性配置文件或 API 或注解来声明你的意图,驱动依赖注入(DI)容器,DI容器再实体化主要对象,并按需将对象连接起来。

Android中的 AOP 思想、框架选型和具体应用场景可详见: 一文读懂 AOP | 你想要的最全面 AOP 方法探讨

概言之, 最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯 Java 对象实现。不同的领域之间用最不具有侵害性的「方面」或「类方面」工具整合起来。

技巧五:用异常代替错误码,但不传递异常,不传递 null

if (deletePage(page) == SUCCESS),咋看之下好像没什么问题,但是返回错误码,就是在要求调用者立刻处理错误。你马上就会看到这样的场景:

if (deletePage(page) == SUCCESS) {
    mView.onSuccess();
} else {
    mView.onError();
}

熟悉不?更恶心的是这种情况:

int code = deletePage(page);if (code == CODE_404) {
    mView.onError1();
} else if(code == CODE_403){
    mView.onError2();
} else if(code == CODE_505){
    mView.onError3();
}

当你开始编写错误码时,请注意!这意味着你可能在代码中到处存在 if(code == CODE),其他许多类都得导入和使用这个错误类。当错误类修改时,所有这些其他的类都需要重新编译。 而且,错误码和状态码一样,会引入大量的 if-else 和 switch,随着状态扩展,if 就像面条一样拉长。回忆一下,你是不是用了不同的 code 来区分不同的错误?不同的用户状态?不同的表现场景?

所以忠告有 2 点:

  1. 使用异常替代返回错误码,将错误处理代码从主路径中分离

  2. 不仅仅分离错误处理代码,还要把 try-catch 代码块的主体部分抽离出来,另外形成函数,函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。

重构后:

public void delete(Page page) {    try {
        deletePageAndAllReferences(page);
    } catch (Exception e) {
        logError(e);
    }
}private void deletePageAndAllReferences(Page page) throws Exception {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}private void logError(Exception e) {
    logger.log(e.getMessage());
}

在上例中,异常使我们把正常代码和错误代码隔离开来,但是我不建议你滥用异常,思考一下,如果你在低层的某个方法中抛出异常,而把 catch 放在高级层级,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常。每个调用这个函数的函数都要修改,捕获新异常,或在其签名中添加合适的throw子句。以此类推,最终得到的就是一个从最底端贯穿到最高端的修改链。封装完全被打破了,在抛出路径中的每个函数都要去了解下一层级的异常细节。

所以不要传递异常,在合适的地方,及时解决它

还有另一种情况你经常看到:

UserData userData = service.getUserData(url);        if (userData != null) {            if (userData.getUserName != null && userData.getUserName.length > 0) {
                userNameTextView.setText(userData.getUserName);
            } else {
                userNameTextView.setText("--");
            }            if (userData.getRegChannel != null && userData.getRegChannel.length > 0) {
                regChannelTextView.setText(userData.getRegChannel);
            } else {
                regChannelTextView.setText("WHAN");
            }
        }

真是可怕!到处都是判空和特殊操作!如果你打算在方法中返回 null 值,不如抛出异常,或是返回空对象或特例对象。你可以学习Collections.emptyList( )的实现,创建一个类,把异常行为封装到特例对象中。

对付返回 null 的第三方 API 也是如此,我们可以用新方法包装这个 API,从而干掉判空。

技巧六:保持边界整洁,掌控第三方代码

我们经常会使用第三方开源库,怎么将外来代码干净利落地整合进自己的代码中。是每个工程师需要掌握的技巧,我们希望每次替换库变得简单容易,所以首先要缩小库的引用范围!怎么缩小?

  1. 封装:不直接调用第三方api,而是包装多一层,从而控制第三方代码的边界,业务代码只知道包装层,不关心工具类的具体实现细节。在你测试自己的代码时,打包也有助于模拟第三方调用。 打包的好处还在于你不必绑死在某个特定厂商的API 设计上。你可以定义自己感觉舒服的API。

  2. 使用 ADAPTER 模式

代码整洁之道还提出个有意思的做法,为第三方代码编写学习性测试。

我们可以编写测试来遍览和理解第三方代码。在编写学习性测试中,我们通过核对试验来检测自己对 API 的理解程度。测试帮助我们聚焦于我们想从 API 得到的东西。

当第三方开源库发布了新版本,我们可以运行学习性测试,马上看到:程序包的行为有没有改变?是否与我们的需要兼容?是否影响了旧功能?

技巧七:保持良好的垂直格式和水平格式

垂直格式上

  1. 最顶部展示高层次的概念和算法,细节往下渐次展开,越是细节和底层,就应该放在源文件的越底部。

  2. 紧密相关或相似的代码应该互相靠近,调用者应该尽可能放在被调用者的上面,实体变量要靠近调用处,相关性弱的代码用空行隔开。

水平格式上

  1. 代码不宜太宽,避免左右拖动滚动条的差劲体验。

  2. 用空格字符把相关性较弱的事物分隔开。

  3. 遵守缩进规则。

技巧八:为代码添加必要的注释,维护注释

请注意,我说的是必要的注释,只有当代码无法自解释时,才需要注释。

好的代码可以实现自文档,自注释,只有差的代码才需要到处都注释。

如果你开始写注释了,就要思考下:是否代码有模糊不清的地方?命名是否有表达力?是否准确合理?函数是否职责过重,做了太多事情,所以你必须为这个函数写长长的注释?如果是这样,你应该重构代码,而不是写自认为对维护有帮助的注释。很多情况下只需要改下命名、拆分函数,就可以免去注释。

不要以为写完注释就完了,注释和代码一样,需要维护。

如何规避重构的风险?

在写代码之前,强烈建议你先完成单元测试,然后一边实现功能一边调整单测覆盖场景。

实现功能时,代码一开始都冗长而复杂,完成功能后,通过单元测试和验收测试,我们可以放心地重构代码,每改动一小块,就及时运行测试,看功能是否被破坏,不断分解函数、选用更好的名称、消除重复、切分关注面,模块化系统性关注面,缩小函数和类的尺寸,同时保持测试通过。

如何保持代码的优雅?

只要遵循以下规则,代码就能变得优雅:

  1. 编写更多的测试,用测试驱动设计和架构。
    测试编写得越多,就越能持续走向编写较易测试的代码,持续走向简单的设计,系统就会越贴近 OOP 低耦合高内聚的目标。没有了测试,你就会失去保证生产代码可扩展的一切要素。正是单元测试让你的代码可扩展、可维护、可复用。原因很简单:有了测试,你就不担心对代码的修改!没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。

  2. 保持重构,当加入新功能的时候,要思考是否合理,是否需要重构这个打开修改的模块。

  3. 不要重复,重复代码代表遗漏了抽象,重复代码可能成为函数或干脆抽成另一个类。

  4. 保持意图清晰,选用好的命名,短的函数和类,良好的单元测试提高代码的表达力。

  5. 尽可能减少类和方法的数量,避免一味死板地遵循以上 4 条原则,从而导致类和方法的膨胀。

开始重构,逐步改进

衡量成长比较简便的方法,就是看三个月前,一年前,自己写的代码是不是傻逼,越觉得傻逼就成长越快;或者反过来,看三个月前,一年前的自己,是不是能胜任当下的工作,如果完全没问题那就是没有成长。

既然聊到代码规范和重构技巧,Talk is cheap. Show me the code. 就以自己两年前的代码为例,但当我拿起两年前的项目时……

webp

简单粗暴放上 gif,重构过程更直观。

webp

更换命名、干掉switch、拆分成子函数

webp

选用更准确的命名

webp

选用有表达力的命名

webp

按最少知道原则修改方法

最后

技巧是可以学习掌握的,重点是有意识培养自己的代码感,培养解耦的思想。不要生搬硬套技巧,不要过度设计,选择当下最适合最简单的方案。

同时我们需要不断回顾自己写过的代码,如果觉得无需改动,要么是设计足够优秀,要么就是没有输入,没有成长。

如果你对自己有更高的要求,希望以下的资料可以帮助你。

帮助你管理代码质量的工具

  • SonarLint

  • 阿里编码规范插件



作者:FeelsChaotic
链接:https://www.jianshu.com/p/0e31122c38f7


点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消