何为代码质量?——用脑子写代码

引言

为何项目维护困难、BUG 反复?实际上不少时候就是代码质量的问题。代码架构就像是建筑的钢筋结构,代码细节就像是建筑的内部装修,建筑的抗震等级、简装或豪装彻底取决于团队开发人员的水平。前端

本文是笔者对于一些代码质量技巧的小总结,编写高质量代码的思路在任何技术栈都是基本相通的,文章内容仅表明笔者的我的见解,抛砖引玉,不喜勿喷😁。算法

正文

一、使用 ++i 而不是 i++(单独使用状况下)

基本类型

看这段 C 代码:编程

int i = 100;
i++;
++i;
复制代码

对于基本类型,没法直接窥探内部实现,可是能够经过汇编代码来直接观察实现逻辑,经过 clang 编译器转换的汇编代码大体以下( i++ 和 ++i 汇编指令是相同的):swift

0x100000fa4 <+20>: movl   $0x64, -0x14(%rbp)
 0x100000fab <+27>: movl   -0x14(%rbp), %edi
 0x100000fae <+30>: addl   $0x1, %edi
 0x100000fb1 <+33>: movl   %edi, -0x14(%rbp)
复制代码

大体逻辑:将0x64当即数写入rbp寄存器;将rbp的值写入edi寄存器;edi的值加一;将edi的值写回rbp设计模式

固然,clang 编译器对++ii++作了优化,它们的汇编代码看起来是相同的,可是这不能说明全部的编译器都对++ii++一视同仁,并且也查看不了是否编译器作了优化工做。因此在使用基本类型的时候,对于自增的单步操做,写++i是个好习惯(C++ STL 库中有体现)。在平常开发中,for 循环能够如此写:缓存

for (int i = 0; i < 10; ++i) {}
复制代码

自定义类型

好比 swift 中的 Int,它是一个结构体,经过写一个扩展来定义++运算符(实际开发中 swift 不建议这么作,这里只是举个例子):安全

extension Int {
    static prefix func ++ (i: inout Int) -> Int {
        i += 1
        return i
    }
    static postfix func ++ (i: inout Int) -> Int {
        let tmp = i
        i += 1
        return tmp
    }
}
复制代码

这是编程语言中自定义++前缀和后缀运算符经常使用的逻辑,后缀++比前缀++多了一个tmp临时变量。感谢百度工程师微博名称 @提拉拉拉就是技术宅 指出问题,实际上 swift 中后缀++运算符有更高效的实现:bash

...
    defer {
      i += 1
    }
    return i
...
复制代码

无论是何种自定义的实现,后缀++老是有更多的逻辑表达。因此,理论上对于自定义数据类型的单步自增操做,使用前缀++能略微的提升效率。网络

关于争议

这一段描述引发了不少朋友的争议,笔者简单说明一下。数据结构

对于 C 中 Int 等自带类型或者用户自定义类型的前缀++和后缀++运算符,当单独使用时 (++i; i++;),无论它们上层代码如何实现,最终均可能经过源码级优化器和目标代码优化器优化成相同的汇编代码,可是它们毕竟是经过编译器的优化转化的,优化意味着时钟周期的开销,可能会加长编译的时间。

既然咱们能经过代码让编译器免去优化的过程,何乐不为之?

固然,这有些吹毛求疵了,当作各位看官茶余饭后的一点乐子吧 😁。

二、巧用位运算

位运算效率很高,并且有不少巧妙的用法,这里提出一个需求:

typedef enum : NSUInteger {
    TestEnumA = 1,
    TestEnumB = 1 << 1,
    TestEnumC = 1 << 2,
    TestEnumD = 1 << 3
} TestEnum;
复制代码

对于该多选枚举,如何判断该枚举类型的变量是不是复合项?

若是按照常规的思路,就须要逐项判断是否包含,时间复杂度最差为O(n)。而使用位运算能够这么写:

TestEnum test = ...;
if (test == (test & (-test))) {
    //不是复合项
}
复制代码

实际上就是经过负数二进制的一个特性来判断,看以下分析便一目了然:

test           0000 0100
反码           1111 1011
补码           1111 1100
test & (-test) 0000 0100
复制代码

三、灵活使用组合运算符

不明白有些工程师为何排斥组合运算符,他们喜欢这么写:

bool is = ...;
if (is) a = 1;
else a = 2;
复制代码

使用三目运算符:

bool is = ...;
a = is ? 1 : 2;
复制代码

其余组合运算符好比 ?: %=等,灵活的使用它们可让代码更加的简洁清晰。

四、const 和 static 和宏

static可让变量进入静态区,提升变量生命周期至程序结束。值得注意的是,文件中最外层(#include下)的变量自己就是在静态区的,而这种状况使用static是为了变量的私有化。

const 修饰的变量在常量区不可变,是在编译阶段处理;宏是在预编译阶段执行宏替换。因此频繁使用 const 修饰的变量不会产生额外的内存,而全部使用宏的地方均可能开辟内存,何况,预编译阶段的大量宏替换会带来必定的时间消耗。

因此笔者的建议是,能用常量的不用宏,好比一个网络请求的 url:

.h 接口文件
extern NSString * const BaseServer;
.m 实现文件
NSString * const BaseServer = @"https://...";
复制代码

值得注意的是,const 是修饰右边内存,因此这里是想要BaseServer字符串指针指向的内容不可变,而不是*BaseServer内容不可变。

五、空间换时间

在不少场景中,能够牺牲必定的空间来下降时间复杂度,为了程序的高效运行,工程师能够自行判断是否值得,下面举一个代码例子,判断字符串是否有效:

BOOL notEmpty(NSString *str) {
    if (!str) return NO;
    static NSSet *emptySet;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        emptySet = [NSSet setWithObjects:@"", @"(null)", @"null", @"<null>", @"NULL", nil];
    });
    if ([emptySet containsObject:str]) return NO;
    if ([str isKindOfClass:NSNull.class]) return NO;
    return YES;
}
复制代码

使用一个 hash 来提升匹配效率,这在数据较少时可能体现不出优点,甚至会让效率变低,可是在数据量稍大的时候优点就明显了,并且这样写能够避免大量的if-elseif等判断,逻辑更清晰。

值得注意的是,此处使用static来提高局部变量emptySet的生命周期,而不是将这句代码写在方法体外面。在变量声明时,必定要明确它的使用范围,限定合适的做用域。

六、容器类型的合理选择

好比在 C++ 中,若不须要键值对的 hash ,就使用set而不是map;若不须要排序的集合就使用unordered_set而不是set

归根结底也是对时间复杂度的考虑,选择容器类型时,必定要选择“恰好”能知足需求的,能用更“简单”效率更高的容器就不用“复杂”效率更低的容器。

七、初始化不要交给编译器

对于变量的使用,尽可能在类或结构体初始化方法中对其赋初值,而不要依赖于编译器。由于在可见的将来,无论是编译器的更新或是代码跨平台移植,这些变量的初始值都不会受编译器影响。

八、多分支结构处理

这是一个老生常谈的东西了,多分支结构尽可能使用 switch 而不是大量的 if - else if 语句,若非要用 if - else if 来写,则出现频率高的分支优先判断,能够从总体上最大限度的减小判断次数,从而下降 jump 指令的使用频率。

不要小看这些少许的效率提高,放大到整个项目也是有不小的收益。

若想更优雅的处理分支结构,可使用策略模式,将多种分支状况视做多种策略。

九、避免数据同步

常常会有一些需求,对一系列的数据有不少额外的操做,好比选择、删除、筛选、搜索等。代码设计时,要尽可能将全部的操做状态都缓存到同一个数据模型中,而不是使用多个容器数据结构来处理,咱们应该尽可能避免数据同步防止出错。

十、合理使用局部指针

常常会看到这种代码:

doSomething(city.school.class.jack.name,
             city.school.class.jack.age,
             city.school.class.jack.sex);
复制代码

当同一个变量的调用过深且使用频繁时,可使用一个局部指针来处理:

Person *jack = city.school.class.jack;
doSomething(jack.name,
             jack.age,
             jack.sex);
复制代码

相对于指针变量所占用的空间来讲,代码的简洁和美观度稍显重要一点。

十一、避免滥用单例

单例做为一种设计模式应用很是普遍,在移动端开发中,有些开发者利用它来实现非缓存传值,笔者认为这是一个错误的作法,使用单例传值的时候你须要管理单例中的数据什么时候释放与更新,可能会引起数据错乱。

单例存在的意义应该是持久化数据,而非传值,切勿为了方便滥用单例。

十二、避免滥用继承

继承自己和解耦思想有些冲突,代码设计中要尽可能避免过深的继承关系,由于子类与父类的耦合将没法真正剥离。过深的继承关系会增长调试的困难程度,而且若继承关系设计有缺陷,修改越深的类影响面将会越广,可能带来灾难性的后果。

可使用分类的方式(装饰模式)作一些通用配置,而后在具体类中简洁的调用一次方法;也可使用 AOP 思想,hook 住生命周期方法无侵入配置(好比简单埋点)。

好比 iOS 开发中,可能会有开发者喜欢写一套基类,实际上只是基于系统的类作了小量的配置,好比BaseViewControllerBaseViewBaseModelBaseViewModel,甚至是BaseTableViewCell。控制器基类能够对栈和导航栏作一些配置,仍是有一点使用意义,至于其它的笔者感受就是过分设计,其实很大意义上BaseViewController也没有存在的必要。

记住:过多的基类并非代码规范,那是你囚禁其余开发者的牢笼。

1三、避免过分封装

提取方法应该遵照单一职责原则,但若功能自己就是不多的一两句代码可能就不必额外提取了。在保证代码清晰的状况下,不少时候提取逻辑也是须要酌情考虑的。

有见过开发者使用一套所谓的简洁配置 UI 的框架,不过就是将 UI 控件的属性封装成链式语法之类的,用起来有种快一些的错觉,却不知这就是过分封装的典范。

封装的意义在于简洁的解决一类问题,而非少敲那几个字母,过分封装只会增长其余开发者阅读你代码的成本。

好比业界知名的 Masonry,使用它时比原生的 layout 快了不止 10 倍,并且代码很简洁易懂,极大的提升了开发效率。

1四、避免过多代码块嵌套

当代码中出现大量的 if - else 嵌套、闭包嵌套时,会让代码难以阅读。出现这种状况能够从如下几个方面处理:

  • 优化逻辑减小嵌套状况,去除多余的分支
  • 提取方法,简化分支处理代码
  • 创建策略类,利用策略模式优化逻辑

1五、时刻注意空值和越界

写某块代码中,要时刻注意空值和越界的处理,好比给NSDictionary插入空值会崩溃,从NSArray越界取值会崩溃,这些状况要时刻考虑到。

固然,可能有人会说有方法能够全局避免崩溃。实际上笔者不是很赞同这种作法,这可能会让新手开发者永远发现不了本身代码的漏洞。

1六、时刻注意代码的调用时机和频率

当你写一块代码时,须要习惯性的思考两个问题:这块代码的共有变量会被多线程访问从而存在安全问题么?这块代码可能会在一个 RunLoop 循环中调用很频繁么?

对于第一个问题,可能须要使用“锁”来保证线程安全,而锁的选择有一些技巧,好比整形使用原子自增保证线程安全:OSAtomicIncrement32();调用耗时短的代码使用dispatch_semaphore_t更高效;可能存在重复获取锁时使用递归锁处理......

对于第二个问题,只须要在合适的地方加入自动释放池 (autoreleasepool) 避免内存峰值太高就好了。

1七、减小界面代码复用、增长功能代码的复用

对于大前端来讲,界面是项目中重要的组成部分,而有时候设计师给的图中,不一样界面有不少相同的元素,看起来如出一辙,因此不少工程师偷懒直接复用界面了。

在这里,笔者建议尽可能少的复用界面,宁愿选择复制一份。

试想,目前版本两个界面相同,你复用了它,当下个版本其中一个界面要调整一下,这时你继续偷懒,加入一些判断来区分逻辑,下一次迭代又增长了差别,你又偷懒加入判断逻辑...... 最终你会发现,这个界面里面已经逻辑爆炸了,拆分红两个界面将变得异常困难。

而对于功能代码,笔者是提倡多提取,多复用,切记命名规范和适当的注释。

1八、遵照迪米特原则

在封装一些小组件时,必定要造成习惯,不想暴露给使用者的属性和方法不要写在接口文件中,甚至于某些延续父类的方法不想使用者使用,能够以下处理:

- (instancetype)init UNAVAILABLE_ATTRIBUTE;
复制代码

固然,不用担忧组件内部如何获取父类特性,能够经过[super init]来处理。

OC 开发中,可使用独立的延展文件来作“知识隔离”,由于独立的延展文件和当前类是一体的,会在编译期决议。在须要的地方导入这个延展文件就能正常使用,而对未导入的文件进行“隔离”。

1九、缓存机制的设计

无论是任何技术栈的缓存机制设计,都须要一套缓存淘汰算法,使用最普遍的淘汰算法就是 LRU,便是最近最少使用淘汰算法,开发者须要严格的控制磁盘缓存和内存缓存的空间占用。

在 iOS 开发中,可使用 YYCache 来处理缓存机制,该框架的源码剖析可见笔者博客:YYCache 源码剖析:一览亮点

还有一点须要提出的是磁盘缓存的位置问题。iOS 设备沙盒中有 Documents、Caches、Preferences、tmp 等文件夹,其中 Documents 和 Preferences 会被 iCloud 同步。

Documents 适合存储比较重要的数据;Caches 适合存储大量且不那么重要的数据,好比图片缓存、网络数据缓存啥的;tmp 存储临时文件,重启手机或者内存告急时会被清理;Preferences 是偏好设置,适合存储比较个性化的数据。

值得注意的是,NSUserDefaults是存储在 Preferences 下的文件,发现有不少开发者为了偷懒频繁的使用NSUserDefaults作任意数据的磁盘缓存,这是一个很不合理的作法,用处不大且大量的数据通常缓存在 Caches 中,就算是从技术角度考虑,NSUserDefaults是以 .plist 形式存储的,不适合大数据存储。

20、合理选择数字类型

软件工程师应该清楚本身编写的代码是运行在 32 位仍是 64 位的系统上,而且了解编程语言对于各类数字类型的定义。

在 iOS 领域,CGFloat在 32 位系统中为 float 单精度,64 位系统中为 double 双精度,当将一个NSNumber转换为数字类型时,为了兼容,须要以下写:

NSNumber *number = ...;
CGFloat result = 0;
#if CGFLOAT_IS_DOUBLE
      result  = number.doubleValue;
#else
      result  = number.floatValue;
#endif
复制代码

在使用不一样数字类型时,须要考虑数字类型的表示范围,好比能用short处理的就不要用long int

同时,数字类型的精度问题每每困扰着新手开发者。无论是单精度 (float) 仍是双精度 (double) 它们都是基于浮点计数实现的,包含了符号域、指数域、尾数域,而在计算机的理解里数字就是二进制,因此浮点数基于二进制的科学计数法形如:1.0101 * 2^n ,这可不像十进制那样方便的表示十进制小数,好比在十进制中使用 10^-1 轻松的表示十进制的 0.1 ,而二进制方式却没法实现(试想 2 的几回方等于十进制的 0.1 ?),因此浮点数只能用最大限度的近似值表示这些没法精确表示的小数。

好比写一句代码 float f = 0.1;打一个断点能够看到它实际的值是:f = 0.100000001

和浮点计数相对的是定点计数,定点计数比较直观,好比:10.0101 ,它的弊端就是对于有效位数过多的数字,须要大量的空间来存储。因此为了存储空间的高效利用,使用最普遍的仍然是“不够精确”的基于浮点计数的单精度和双精度类型。

然而,在一些特定场景下,定点计数仍然能发挥它的优点,好比金钱计算

对于金钱计算的处理,每每都是要求绝对准确的,因此在不少语言中都有基于定点计数的数据类型,好比 Java 中的 BigDecimal、Objective-C 中的 NSDecimalNumber,牺牲一些空间和时间来达到精确的计算。

2一、使用装饰模式的误区

使用装饰模式时,一般状况下不该该修改当前类的算法。

好比 OC 中的分类,它为功能的添加提供了优雅的实现方式,可是开发者应该注意,不该该在分类里面重写当前类已经有了的方法。

由于在运行期,OC 分类中的方法是会自动插入类的方法列表,消息调用机制会找到最靠前的方法而忽略掉该类本有的方法,这可能会出现不少异常状况且不易排查。

因此在使用装饰模式时,要尽可能不作可能影响其余业务的逻辑,好比 iOS 中“时髦”的 hook 技术,应该尽可能少用。

为了防止在写分类时一不当心重载了已有方法(多是其它分类的方法),应该为分类方法都加上一个有辨识度的前缀,好比-()mj_ 、-()custom_

咱们应该尽可能避免用上帝视角去写代码。

2二、巧用局部闭包

常常会有一些需求,好比某段动画能够选择是否执行,能够以下处理:

void (^animationsBlock)(void) = ^{
        ...
    };
    void (^completionBlock)(BOOL) = ^(BOOL x){
        ...
    };
    if (duration <= 0) {
        animationsBlock();
        completionBlock(YES);
    } else {
        [UIView animateWithDuration:duration animations:animationsBlock completion:completionBlock];
    }
复制代码

建立两个栈区的 Block,若须要动画就传入 -animateWithDuration: 系列方法,若不须要动画 Block 就不用被拷贝到堆区,而是直接调用。这样处理还有一个好处就是不用重复写两个 Block 中的业务逻辑了,避免格外的方法封装。

总结

代码技巧都是实践加思考总结出来的,在代码编写过程当中,开发者须要时刻明白本身的代码是干什么的,不要随意的复制代码。同时,开发者须要有算法思惟和工程思惟,力求使用高效率和高可维护的代码来实现业务。

笔者最后总结几点提升代码质量的途径:

  • 设计架构制定规范,常常 code review。(不要说小公司没人陪你 review,一我的也能够 review 得不亦乐乎)
  • 多阅读优秀的开源代码。(但愿你能判断何为优秀🙄)
  • 找一家技术驱动的公司。(一切以工时定贡献的公司都是耍流氓,却不知高效代码设计能减小至关多工做量)
  • 找到有能力打你脸的人,并和 TA 成为朋友。(相信我,技术人员要常常被打击才能茁壮成长😬)