57

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

 5 years ago
source link: http://www.cocoachina.com/programmer/20180806/24457.html?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

引言

不重视代码质量的工程师永远是初级工程师

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

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

正文

1、使用 ++i 而不是 i++

经常看到这样的代码:

for (int i = 0;; i++) {}

单步自增 (或自减) 操作,最好是使用 ++i 而不是 i++ ,效率略高。

大家应该都知道 ++i 的返回值是自增过后的,而 i++ 的返回值是自增之前的。其实从这点就可以猜测: ++i 内部实现应该是直接将 i 这块内存 +1 然后返回,而 i++ 需要使用一个局部变量来存储 i 的值,然后 i 加一,最后返回局部变量的值(别告诉我你能先 return 再执行自增)。

如果某一种语言的 i++ 不能作为左值,那么也可以猜测这个局部变量是用 const 修饰的。

所以, i++ 理论上比 ++i 有更多的消耗,代码就这样写吧:

for (int i = 0;; ++i) {}

2、巧用位运算

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

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

3、灵活使用组合运算符

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

bool is = ...;
if (is) a = 1;
else a = 2;

使用三目运算符:

bool is = ...;
a = is ? 1 : 2;

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

4、const 和 static 和宏

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

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

所以笔者的建议是,能用常量的不用宏,比如一个网络请求的 url:

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

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

5、空间换时间

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

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", nil];
  });    
    if ([emptySet containsObject:str]) return NO;    
    if ([str isKindOfClass:NSNull.class]) return NO;    
    return YES;
}

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

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

6、容器类型的合理选择

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

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

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

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

8、多分支结构处理

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

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

9、避免数据同步

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

10、合理使用局部指针

经常会看到这种代码:

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);

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

11、避免滥用单例

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

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

12、避免滥用继承

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

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

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

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

13、避免过度封装

提取方法的原则是功能单一性,但若功能本身就是很少的一两句代码可能就没必要额外提取了。在保证代码清晰的情况下,很多时候提取逻辑也是需要酌情考虑的。

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

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

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

14、避免过多代码块嵌套

比如代码中大量的 if - else 嵌套判断,大量的嵌套循环,大量的闭包嵌套。

出现这种情况首先要考虑的是分支结构处理是否多余?循环是否可以优化时间复杂度?当排除这些可优化项过后,可以做一些方法提取减少大量的代码块嵌套,方便阅读。

15、时刻注意空值和越界

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

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

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

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

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

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

17、减少界面代码复用、增加功能代码的复用

对于大前端来说,界面是项目中重要的组成部分,而有时候设计师给的图中,不同界面有很多相同的元素,看起来一模一样,所以很多工程师偷懒直接复用界面了。

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

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

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

18、组件的设计技巧

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

- (instancetype)init UNAVAILABLE_ATTRIBUTE;

当然,不用担心组件内部如何获取父类特性,可以通过 [super init] 来处理。

同时,在多人开发中,组件的开放方法名最好加入一些前缀,便于区别,也避免方法重名,最容易导致方法重名的情况就是各种分类里面的方法重复,会带来意想不到的错误。

19、缓存机制的设计

不管是任何技术栈的缓存机制设计,都需要一套缓存淘汰算法,使用最广泛的淘汰算法就是 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 ,牺牲一些空间和时间来达到精确的计算。

总结

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

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

  • 设计架构制定规范,经常 code review。(不要说小公司没人陪你 review,告诉你一个人也可以 review 得不亦乐乎)

  • 多阅读优秀的开源代码。(希望你能判断何为优秀

本站内容均为本站转发,已尽可能注明出处。因未能核实来源或转发内容图片有权利瑕疵的,请及时联系本站,本站会第一时间进行修改或删除。 QQ : 3442093904 

微信扫一扫

订阅每日移动开发及APP推广热点资讯

公众号:

CocoaChina
我要投稿

分享到:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK