代码的印象派:写点好代码吧
原文出处: Dennis Gao 的博客(@东之言)
最近有一位猎头顾问打电话询问是否有换工作的意向,对推荐的公司和职位的描述为:”我们这里有一家非常关注软件质量的公司,在寻找一位不仅能完成有挑战的软件开发任务,并且还对代码质量有非常高追求的软件工程师。”。
很难得猎头顾问会以这样的切入点来推荐职位,而不是诸如 “我们是互联网公司”,”我们是著名互联网公司”,”我们可以提供业内领先的薪资”,”我们创业的方向是行业的趋势”,”我们提供创业公司期权”,”我们提供人体工程座椅”,”我们公司漂亮妹子多” 等等。
谁会为了把椅子或者每天能看公司漂亮的前台妹子而跳槽呢?将这些描述可以概括成一点,就是 “我们给的钱多”。诚然,好的薪水是可以招募到杰出的软件工程师的。然而,优秀的软件工程师通常已经得到了较好的薪水,所以如果不是给出足够的量,不一定会为五斗米而折腰。大多数的软件工程师更看重的是技术兴趣,所以诸如 “来我们这做 Openstack 吧”,”我们需要用 Go 实现 Docker 相关组件的人才” 看起来更有吸引力。
而软件质量,代码风格,则是另一个吸引优秀工程师的方向。追求卓越软件质量的工程师,通常有着自己的软件设计与实现思路,并在不断的实践中锤炼出自己的编程风格和代码品味。这一类工程师,其实无关使用什么语言、做什么产品,他们会始终保持自己的品味,追求软件实现的卓越,进而产出高质量的软件。更重要的是,优秀的工程师希望与更多优秀的工程师合作,并更愿意工作在崇尚代码质量的氛围中。
一般条件下,对软件质量的要求通常与软件生命周期的长短相关。按照软件生命周期的长短,我们可以将软件公司分为两类:
- 快公司:创业公司,互联网公司。推崇快速开发,快速试错。软件生命周期较短,代码质量相对要求不高。
- 慢公司:传统行业软件公司,产品公司。推崇稳定可靠的软件设计。软件生命周期较长,代码质量相对要求较高。
软件生命周期的长短,通常也决定了实现软件所使用的编程语言。比如,快公司通常会使用 PHP/Python/Ruby 等动态类型语言,而慢公司通常会选择 C/C++/C#/Java 等静态类型语言。
当猎头顾问还没有说出公司的名字之前,我们也可以大体猜测出该公司所属的行业方向或软件产品类型。比如,该公司可能来自传统的金融、电信、医疗、石油、ERP 行业等,这些行业中有 IBM、Huawei、GE、Schlumberger、SAP 等等世界 500 强巨头更重视软件质量。当然,通常推崇软件质量的公司中,大概率条件下碰到的会是外企,即使是中小外企,也会对软件质量有相对较高的要求。对于产品类型,越是接近 Mission Critical 的产品形态则对软件质量的要求越高。各种软件中间件的软件质量要求也相对较高,比如内存数据库、Message Queue 等。或者如果区分 Framework 和 Application,则 Framework 的软件质量显然要求更高。
每一家较成熟的软件公司,都会设计自己的软件编码规范,增强软件工程师的协同效应,相互之间可以读懂对方的代码。但实际操作中,又有多少人会执着遵守呢?编码规范并不能保证产生出好代码,那代码编写的好坏又如何具体衡量呢?笔者经历过的公司中,多半都是以软件发布后的 Bug 数量来衡量软件的质量的,这种统计形式简单粗暴,优点就是可以量化,缺点就是很难评判出软件代码编写的优雅程度。我听过一则笑话,说软件质量也不能做的太好,软件一定要有 Bug,这样客户才会买我们的服务,而我们就是靠后期卖服务赚钱的。好吧,情何以堪~~
好了,说了这么多,好像文不切题,这篇文章不是叫《代码的印象派》吗?和上面说的这些有什么关系呀?
关系就在于,软件质量与代码编写的优雅程度息息相关。而是否能编写出优雅的代码与软件工程师的个人风格和品味息息相关。
在软件工程领域,通常生命周期长的软件会有着更高的软件质量需求,描述软件质量的内容可以参考下面两篇文章。
- Quality 是什么?
- 软件质量模型
在各种软件质量模型的描述中,都包含着软件可维护性(Maintainability)这一属性。而越是生命周期长的软件,对其软件可维护性的要求越高。而提高软件可维护性的根本方式就是编写可阅读的代码,让其他人理解代码的时间最小化。代码生来就是为人阅读的,只是顺便在机器上执行以完成功能。
在漫长的软件生命周期中,我们有很多机会去修改软件代码,比如发现了新的 Bug、增加新的功能、改进已有功能等。修改代码的第一步当然就是阅读代码,以了解当前的设计和思路。如果代码都读不懂的话,何谈修改呢?还有,大概率条件下,修复自己实现模块的 Bug 的人通常就是你自己,如果时隔几个月后自己都读不懂自己编写的代码,会是什么感受呢?
所以,如何编写出易读的代码就成了问题的关键。而能否编写出易读代码,则直接取决于软件工程师自己的的编程风格和代码品味。
在《孙子兵法》中有云:”上兵伐谋,其次伐交,其次伐兵,其下攻城。攻城之法,为不得已。” 对应到软件领域,软件架构师可以通过出色的系统分析来构建可演进的软件架构,讲究谋略;而软件工程师则通过良好的设计和编程风格来完成攻城任务,讲究方法。
Paul Graham 的《黑客与画家》中描述了黑客与画家的共同点,就是他们都是创作者,并且都在努力创作优秀的作品。画家创作的作品就是画,内嵌着自己的风格和品味。软件工程师的作品就是软件和代码,如果可以的话,你可以将代码打印成卷,出版成书,只是,阅读的人会向你那样幸福吗?
画家的作品都会保留下来,如果你把一个画家的作品按照时间顺序排列,就会发现每幅画所用的技巧,都是建立在上一幅作品学到的东西之上。某幅作品如果特别出众,你往往能在更早期的作品中找到类似的版本。软件工程师也是通过实践来学些编程,并且所进行的工作也是具有原创性的,通常不会有他人的完美的成果可以依靠,如果有的话我们为什么还要再造轮子呢?
创作者的另一个学习途径是通过范例。对于画家而言,临摹大师的作品一直是传统美术教育的一部分,因为临摹迫使你仔细观察一幅画是如何完成的。软件工程师也可以通过学习优秀的程序源码来学会编程,不是看其执行结果,而是看源码实现思路和风格。优秀的软件一定都是在软件工程师对软件美的不懈追求中实现的,现如今有众多优秀的开源软件存在,如果你查看优秀软件的内部,就会发现,即使在那些不被人知的部分,也同样被优美的实现着。
所以说,代码是有画面感的,看一段代码就可以了解一个软件工程师的风格,进而塑造出该工程师在你心目中的印象。工作中,我们每天都在阅读同事们的代码,进而对不同的同事产生不同的印象,对各种不同印象的感受也在不断影响着自身风格的塑造。代码的印象派,说的就是,你想让你的同事对你产生何种印象呢?
笔者不能自诩为我就是那类有着良好的编程风格,并且代码品味高雅的软件工程师,只能说,我还在向这个目标努力着。风格和品味不是一朝一夕就能养成的,世间存在多少种风格我们也无法列举,而说某种风格比另一种风格要好也会陷入无意的争辩。况且,软件工程师多少都会有点自恋情节,在没有见到更好的代码之前,始终都会感觉自己写出的代码就是好代码,并且有时不管你说什么,咱就是这个味儿!
我个人总结了几点关于优雅代码风格的描述:
- 代码简单:不隐藏设计者的意图,抽象干净利落,控制语句直截了当。
- 接口清晰:类型接口表现力直白,字面表达含义,API 相互呼应以增强可测试性。
- 依赖项少:依赖关系越少越好,依赖少证明内聚程度高,低耦合利于自动测试,便于重构。
- 没有重复:重复代码意味着某些概念或想法没有在代码中良好的体现,及时重构消除重复。
- 战术分层:代码分层清晰,隔离明确,减少间接依赖,划清名空间,理清目录。
- 性能最优:局部代码性能调至最优,减少后期因性能问题修改代码的机会。
- 自动测试:测试与产品代码同等重要,自动测试覆盖 80% 的代码,剩余 20% 选择性测试。
下面,我会列举一些我在工作中遇到的不同的编程风格,用切身的体会来感悟代码的风格和品味。当然,吐槽为主,因为据说在 Code Review 时记录说 “我擦” 的数量就可以衡量代码的好坏。
变量
关于变量,很遗憾,不得不提变量的命名。时至今日,在 Code Review 中仍然可以看到下面这样的代码。
public class Job { private DateTime StartTime; private DateTime mStartTime; private DateTime m_StartTime; private DateTime _StartTime; public DateTime endTime; private Command pCommand; private long schId; }
有各种奇葩的前缀出现,有时同一个人的命名居然也不统一。虽然,眼睛和大脑在重复的观察变量名后会自动学习以忽略前缀,并不会太影响阅读。实际上,使用前缀的目的主要是为了在局部代码中区分全局变量和局部变量。使用类似于 C# 这样的高级语言,我们已经不再需要为变量添加前缀了,可以利用 this 关键字来区分。如果非要添加的话,建议使用 “_” 单下划线前缀,促进大脑更快速的忽略。
public class Job { private DateTime _startTime; // use _ as prefix private DateTime endTime; // no prefix public DateTime StartTime { get { return _startTime; } set { _startTime = value; } } public DateTime EndTime { get { return endTime; } set { endTime = value; } } public long ScheduleId { get; private set; } // or no field needed }
将 Field 标记为 public 应该是没有分清 Field 与 Property 的作用,进而推测对面向对象编程中的封装概念理解也不会有多好。
使用 “p” 前缀的显然有 C/C++ 编程情节,想描述这个变量是一个指针,好吧,这种写法在 C# 中只能称为不伦不类。
使用缩写,这里的 “sch” 其实是想代表 “schedule”,但在没有上下文的条件下谁能想的出来呢?我个人是绝对不推荐使用缩写的,除非是普世的理解性存在,例如 “obj”, “ctx”, “pwd”, “ex”, “args” 这样非常常见的缩写。
使用拼音和有拼写错误的单词作为变量名会直接拉低工程师的档次。使用合适单词描述可以直接提高代码的质量,比如通常 “Begin”, “End” 会成对儿出现,上面的示例代码中涉及到了时间,”StartTime” 和 “BeginTime” 是同义词,所以我们参考了 Outlook Calendar 中的默认术语,也就是 “StartTime” 和 “EndTime”,也就是找范例。
在局部变量的使用中,我认为有一种使用方式是值得推荐的,那就是 “解释性变量”。当今的编程风格中流行使用 Fluent API,这样会产生类似于下面这样的代码。
if(DateTimeOffset.UtcNow >= period .RecurrenceRange.StartDate.ConvertTime(period.TimeZone) .Add(schedule.Period.StartTime.ConvertTime(period.TimeZone).TimeOfDay)) { // do something }
这一串 “.” 看着好帅气,但我是理解不了这是要比较什么。可以简单重构为解释性变量。
var firstOccurrenceStartTime = period .RecurrenceRange.StartDate.ConvertTime(period.TimeZone) .Add(schedule.Period.StartTime.ConvertTime(period.TimeZone).TimeOfDay); if(DateTimeOffset.UtcNow >= firstOccurrenceStartTime) { // do something }
构造函数
很多工程师还没有理解好构造函数的功效和使用方式,在选择依赖注入方式时,更倾向于使用属性依赖注入。个人认为,使用 “属性依赖注入” 是懒惰的一种表现,其不仅打破了信息隐藏的封装,而且还可以暴露了本不需要暴露的部分。使用构造函数进行依赖注入是最正确的方式,我们应该竭尽全力将代码重构到这一点。
好的,你说的,我信了!并且,我也开始这么做了!绝对纯净的构造函数注入!
public class Schedule { public Schedule(long templateId, long seriesId, long promotionId, bool isOnceSche, DateTime startTime, DateTime endTime, List<TimeRange> blackOutList, ScheduleAddtionalConfig addtionalConfig, IDateTimeProvider tProvider, IScheduleMessageProxy proxy, IAppSetting appSetting, RevisionData revisionData) { } }
好吧,你赢了!构造函数居然有 12 个参数,距离史上最长的构造函数不远了。
一般写成这样的代码已经表示没法看了,而且注定类的设计也不怎么样,这要是遗留下来的 Legacy Code,不知道维护者心情几何?
还有一类构造函数问题就是参数顺序,这直接体现了软件工程师最终他人的基本素养。因为构造函数生来就是为使用者准备的,而为使用者设计合理的参数顺序是类设计者的基本职责。
public class Command { public Command(int argsA, int argsB) { } public Command(int argsC, int argsB, int argsA) { } }
上面这种反人类思维的参数顺序,怎么描述呢?写成下面这样有多大难度?
public class Command { public Command(int argsA, int argsB) { } public Command(int argsA, int argsB, int argsC) { } }
属性
蹩脚的属性设计常常彰显抽象对象类型的能力。以下面这个 Schedule 类为例,Schedule 业务上存在 Once 和 Recurring 两种状态。我们最初看到的类是这个样子的。
public class Schedule { public Schedule(bool isOnceSchedule) { IsOnceSchedule = isOnceSchedule; } public bool IsOnceSchedule { get; set; } }
看来这是想通过构造函数直接注入指定状态,但 IsOnceSchedule 属性的 set 又是 public 的允许修改,不仅暴露了封装,还没有起到隐藏的效果!
那么,稍微改进下,试图消灭 IsOnceSchedule 属性,引入继承机制。
public class Schedule { } public class OnceSchedule : Schedule { } public class RecurringSchedule : Schedule { }
实现上在 OnceSchedule 和 RecurringSchedule 中均封装独立的实现。如果非要通过父类抽象暴露 Recurring 状态,可以在父类中通过属性暴露只读接口。
public class Schedule { public Schedule() { this.IsRecurring = false; } public bool IsRecurring { get; protected set; } } public class OnceSchedule : Schedule { public OnceSchedule() : base() { this.IsRecurring = false; } } public class RecurringSchedule : Schedule { public RecurringSchedule() : base() { this.IsRecurring = true; } }
函数
我们或许都知道,函数命名要动词开头,如需要可与名词结合。而函数设计的要求是尽量只做一件事,这件事有时简单有时困难。
简单的可以像下面这种一句话代码:
internal bool CheckDateTimeWeekendDay(DateTimeOffset dateTime) { return dateTime.DayOfWeek == DayOfWeek.Saturday || dateTime.DayOfWeek == DayOfWeek.Sunday; }
复杂的见到几百行的函数也不新奇。拆解长函数的方法有很多,这么不做赘述。这里推荐一种使用 C# 语法糖衍生出的函数设计方法。
上面的小函数其实是非常过程化的代码,其是为类 DateTimeOffset 服务,我们可以使用 C# 中的扩展方法来优化这个小函数。
internal static class DateTimeOffsetExtensions { internal static bool IsWeekendDay(this DateTimeOffset dateTime) { return dateTime.DayOfWeek == DayOfWeek.Saturday || dateTime.DayOfWeek == DayOfWeek.Sunday; } }
这样,我们就可以像下面这样使用了,感觉会不会好一些?
if(DateTimeOffset.Now.IsWeekendDay()) { // do something }
在设计函数时,我们时常犹豫的是,到底应该返回一个 null 值还是应该抛出一个异常呢?
答案就是,如果你总是期待函数返回一个值时,而值不存在则应该抛出异常;如果你期待函数可以返回一个不存在的值,则可以返回 null。总之,不要因为懒惰而使得应该设计抛出异常的函数最终返回了 null,不幸的是,这种懒惰经常出现。
正常的代码是不需要 try..catch.. 的,异常就应该一抛到底直至应用程序崩溃,当然,这是开发阶段。一抛到底有利于发现已有代码路径中的错误,毕竟异常在正常逻辑中是不应该产生的。我们要做的是,合理期待某调用可能会产生某类异常,则直接 catch 该特定异常,如 catch (System.IO.FileNotFoundException ex)。
实际上,遇到这种抉择场景,我们可以在函数命名上下些功夫,以变相解决问题。
object FindObjectOrNull(string key); object FindObjectOrThrow(string key); object FindObjectOrCreate(string key, object dataNeededToCreateNewObject); object FindObjectOrDefault(string key, Object defaultReturnValue);
单元测试
在开始写代码的时候就开始考虑测试问题,有利于产生易于测试的代码。幸运的是,对测试友好的设计会很自然的产生良好的代码。
测试驱动开发(TDD)是一种编程风格,包含 TDD 三定律:
- 在编写不能通过的单元测试前,不能编写生产代码;
- 只编写刚好无法通过的单元测试,不能编译不算通过;
- 只编写刚好通过当前失败测试的生产代码;
我们显然可以循规蹈矩的遵循上述 TDD 三定律风格编程,但 TDD 只是通过测试来保证代码质量,驱动良好设计的一种风格,我们没有必要完全强迫自己遵循上述定律,找到适合自己的过程可能效率更高,所以重点在于,要写单元测试,通过写代码时思考测试这件事来帮助把代码写的更好。
测试代码不是二等公民,它和生产代码一样重要。他需要被思考、被设计、被维护,并且要像生产代码一样保持优雅的风格。
单元测试测什么?
在单元测试中,可通过两种方式来验证代码是否正确地工作。一种是基于结果状态的测试,一种是基于交互行为的测试。这两种方式在文章《单元测试的两种方式》中有描述,这里就不再赘述。
单元测试的可读性
在测试代码中,可读性仍然很重要。如果测试代码的可读性良好,使其更易于后期的维护和修改,不至于是测试代码腐化以致被删除。
下面是一些良好测试的关键点:
- 测试越简明越好,每个测试只关注一个点。
- 如果测试运行失败,则其应发出有帮助性的错误消息或提示。
- 使用简单明确的测试输入条件。
- 给测试用例取一个可描述的名字。
那么,具体什么样的单元测试用例名称,算是好名称呢?这里推荐两种:
- 第一种:使用 Test_<ClassName>_<FunctionName>_<Situation> 风格;
- 第二种:使用 Given_<State>_When_<Behavior>_Then_<SomethingHappen> 风格;
第二种实际上是 BDD 风格,其不仅可以应用于单元测试,在更高级的 Component Level 和 System Level 的测试中同样有效。
实际上,单元测试用例代码的内部实现也是有风格可遵循,常见的就是 Arrange-Act-Assert (AAA) 模式。
第三方组件代码不便于测试
在文章《类依赖项的不透明性和透明性》中描述了依赖项对单元测试的影响,实践中,我们碰到最多的是调用其他类库的代码而导致的不可测试性。
public class MyClass { private Job _job; public MyClass() { _job = new Job(); } public void ExecuteJob() { _job.Execute(); } } public sealed class Job { public void Execute() { // do something heavy } }
上面的代码,如果写一个 TestCase 的话,可能是下面这种情况。
[Test] public void Test_MyClass_ExecuteJob() { MyClass instance = new MyClass(); instance.ExecuteJob(); // what should we assert? }
这样,调用了 instance.ExecuteJob() 导致了不知道如何验证。同时,由于 Job 类使用了 sealed 关键字,并且没有实现任何接口,所以也无法通过 mocking 库来 mock。
解决办法,增加中间层。
public class MyClass { private IJob _job; public MyClass(IJob job) { _job = job; } public void ExecuteJob() { _job.Execute(); } } public class JobProxy : IJob { private Job _realJob; public JobProxy(Job job) { _realJob = job; } public void Execute() { _realJob.Execute(); } } public interface IJob { void Execute(); } // third-party Job Class public sealed class Job { public void Execute() { // do something heavy } }
这样,我们在测试 MyClass 类时,就可以通过 IJob 接口注入 Mock 对象。这里选用的 Mocking Library 是 NSubstitute,参考《NSubstitute完全手册索引》。
[Test] public void Test_MyClass_ExecuteJob() { IJob job = Substitute.For<IJob>(); MyClass instance = new MyClass(job); instance.ExecuteJob(); // assert job.Received(1).Execute(); }
依赖时间的测试
还有一种较难测试的代码是依赖于时间的代码。比如,我们有一个依赖于时间的 Trigger 类,简写为这个样子。
public class Trigger { public Trigger(DateTime triggeredTime) { this.TriggeredTime = triggeredTime; } public DateTime TriggeredTime { get; private set; } public bool TryExecute() { if (DateTime.Now >= TriggeredTime) { // do something return true; } return false; } }
测试时,我可能会挑一些特定时间进行测试,特定时间有可能在很远的未来。
[Test] public void Test_Trigger_TryExecute_AfterTriggeredTime() { DateTime triggeredTimeInFuture = new DateTime(2016, 2, 29, 8, 0, 0, DateTimeKind.Local); Trigger trigger = new Trigger(triggeredTimeInFuture); bool result = trigger.TryExecute(); // assert Assert.IsTrue(result); }
好吧,这个 TestCase 应该是到 2016 年才能执行成功,显然不是我们期待的。改进的办法还是增加中间层,增加 IClock 接口用于提供时间。
public class Trigger { private IClock _clock; public Trigger(IClock clock, DateTime triggeredTime) { _clock = clock; this.TriggeredTime = triggeredTime; } public DateTime TriggeredTime { get; private set; } public bool TryExecute() { if (_clock.Now() >= TriggeredTime) { // do something return true; } return false; } } public interface IClock { Func<DateTimeOffset> UtcNow { get; } Func<DateTimeOffset> Now { get; } } public class Clock : IClock { public Func<DateTimeOffset> UtcNow { get { return () => DateTimeOffset.UtcNow; } } public Func<DateTimeOffset> Now { get { return () => DateTimeOffset.Now; } } }
这样,我们就可以在 TestCase 代码中使用 Mocking 类库来替换 IClock 的实例,进而指定时间。
[Test] public void Test_Trigger_TryExecute_AfterTriggeredTime() { IClock clock = Substitute.For<IClock>(); clock.Now .Returns<Func<DateTimeOffset>>(() => { return DateTimeOffset.Parse( "2016-02-29T08:00:01.0000000", CultureInfo.CurrentCulture); }); DateTime triggeredTimeInFuture = new DateTime(2016, 2, 29, 8, 0, 0, DateTimeKind.Local); Trigger trigger = new Trigger(clock, triggeredTimeInFuture); bool result = trigger.TryExecute(); // assert Assert.IsTrue(result); }