Skip to content
平兄聊技术
Go back

讨论工程师的几种

成为程序员多年,发现工程师在写代码时,也分为了好几种流派。

以前我以为,工程师的能力,像是金字塔一般的,逐级而上。哪里想到,其实更像是星型结构的,可以向各个方向发展。

所谓逐级而上,是指:譬如初级工程师,我们要求其能写出符合需求的代码,能遵守良好的代码规范,有一致的代码风格,那么对于中级工程师来说,就应该在此基础上,再多具备“接口稳定、各层级模块的功能足够正交》的特性;再往上,那就应在接口设计良好的基础上,再更具健壮、运营等方面的考虑了。

而事实上,各个特性并不存在包含关系,而是星形分布的。

1. 能够遵循一致的代码风格

别看我把这个列为第一条,但其实很多人,包括很多大牛,可能都未能100%遵守。比如,大部分时候,对于c++ 的代码风格,我们都会遵循 Google 的 code style[1],变量采用小写下划线,方法采用首字母大写的驼峰式等。但有时候会不由自主,突然夹杂了一点匈牙利命名(就是变量采用首字母小写标识其类型,后驼峰式的风格),然后又变回 Google code style。我在写自己的 side project 的时候,就会经常发生,没有代码给别人看的约束,多少随性些。不过这都还好,现在我们都有足够多的工具(例如 clang-format)来自动格式化代码。

Google C++ Style Guide

2. 代码逻辑抽象化,各函数功能正交

先说前半句,代码逻辑抽象化。虽然编程本身就是一个逻辑抽象化的过程,但是很多时候,我们其实是在为某个具体的场景堆叠功能。这点我想经常写业务代码的同学应该是很有体会的。

当业务需求到来的时候,这个业务有它特定的上下文,特定的制约。这个时候,如果我们懒于建模抽取其中的本质,那我们的代码就只能为这个业务而运行,少了很多扩展的可能性。同时当类似的业务下一次再次出现时,我们可能需要复制之前的代码,再为本次需求做一点点改动,再在上层入口多一次 if 判断。显然,这样也不利于代码复用。

举个例子,曾经接手过一个集群管理的模块,其功能是给其他模块定时发送心跳消息,以探测其进程的死活状态。为了实现各个模块有不同的周期,原作者居然使用了do_heartbeat_module_a/do_heartbeat_module_b 这样的函数,在函数命名上将各个模块隔离开。细细看上去,每个函数实现的功能有 80% 是一样的。而这样的 do_heartbeat_module_xxx,在整个工程中,有4个。也就是说,当时仅支持给这4个模块的机器,发送心跳。若有新的模块要被管理,只能修改代码,在此基础上继续 do_heartbeat_module_{x,y,z}。毫无疑问,其实这里应该把抽象成一个函数。即使各个模块需要有不同的设置,而不能共用完全一样的流程,也应该有一个 do_heartbeat_common 的函数,来完成他们共用的部分,而不是将代码复制一份。

好的函数设计,你会发现各个函数的功能是正交的。一个日常大家都写过的一组函数 getter/setter,就是一组完全正交的函数,getter 用来获取变量,而 setter 用来更新变量。你肯定不会去写 get_A_and_trigger/set_A_and_trigger 这样的函数(至少我的读者里肯定不会,对吧)。trigger 应该属于另一个维度,单独成为一个函数了。如果非要写,那肯定也不是

A get_A_and_trigger() {
  trigger();
  return a_;
}

而是

A get_A_and_trigger() {
  trigger();
  return get_A();
}

看到区别了么?get_A 永远上一个单纯的函数(单一性原则)。

以上例子中,这个 bad case 还是算好的了,毕竟在命名中,已经显式地告诉了调用者,trigger 也会发生。而在现实中更多的 bad taste 代码中,trigger 并不在命名中体现出来,它是隐式发生的。

不要因为在 get 时需要一个 trigger 操作,就把它放在了 get 的内部,而是使用组合的方式,将之打包。

说到单一性原则,之前和一个同事有一个讨论。

class Eatable {
 public:
  virtual void Eat() = 0;
};
class Fruit : public Eatable {
 public:
  virtual void Describe() const {
    // some implementation
  }
  
  virtual void Eat() {}
};

例如上例中,有一个接口类 Eatable 描述可食性,下有子类 Fruit 描述水果的特性。其中有一个 Describe 方法,每个水果都使用该方法来描述自己,是一个公共方法。而 Eat 方法,是每种可食性事物都不同的,在每种水果中,也都不同(比如有些水果要剥皮,要洗,核桃还得夹开壳),所以需要子类去分别实现。我们的讨论点就在于,此时 Fruit 类的 Eat 方法,是否应该提供一个默认的吃法(也许只是一个空实现/返回默认值),还是加上 = 0 使之纯虚化。

我的观点是,应该将之纯虚化,使之成为一个不可实例化的纯虚类接口。诚然,提供了默认的方法将 Fruit 也可实例化,用起来方便,当有些子类水果和父类 Fruit 的方法一致时,不需要额外再去重载之,省事。但这样就违反了单一性原则,使得 Fruit 同时承担了接口声明代码复用2个职责。提供了过早实例化水果(我们还不知道水果是啥)的漏洞,对于使用者(该类的调用者)来说,需要了解 Fruit::Eat 的内容是否符合其需求后,再决定是否提供一个子类重载 Eat 方法:多了一层用户负担,使得接口不再单纯。

当然,他的论点也是对的。类的层次太多,过于学术化的设计,同样也不利于调用者理解,同时还会增加符号表的长度,有潜在的性能损失(命中 Cache 失败)。

大家依然可以讨论下去,看看你们又有什么样的看法。

3. 充分考虑各种边界条件

譬如当变量为除数时,是否有可能为0?当发生+/- 时,是否会发生溢出?非常非常细节的地方,很容易犯错。但有幸有同事在 CR 时会帮我指出这些问题。

4. 充分考虑性能

性能,这可是一个很大的话题了,大到甚至用好几本书都讲不完。遑论其他语言,作为一个 C++ 的程序员,对于性能的敏感程度,应该是你在写代码时随时有所考虑的事情才对。

尽量把变量定义为整字长度,方便 CPU 取出。

定义消息头、结构体时,如果字段顺序的定义并不严格要求,不要让某个字段跨对齐字节数。因为那样会导致取这个字段时发生2次内存读。

当使用大的对象作为形参时,尽量使用 const ref 形式,以避免潜在的拷贝消耗。

等等等等,不一而足。以上都只是在不改变代码结构的情况下,我们可以注意到的细节。有曰“不要过早优化”,但以上这些简单的,随手可以获得的性能提升,其实也未必每个程序员都有意识。

譬如,之前在 cppcon 的视频 (CppCon 2014,Chandler Carruth “Efficiency with Algorithms, Performance with Data Structures”)中,看到过一个例子,其实我在项目中经常看见,相信你们也是。

Obj* GetObjPtr(int key) {
  if (obj_map_.find(key) == obj_map_.end() {
      auto obj = std::make_unique<Obj>();
      auto obj_ptr = obj.get();
      obj_map_[key] = std::move(obj);
      return obj_ptr;
  }
  return obj_map_[key].get();
}

请问,以上函数中,有几处对 obj_map_ 的查找调用?如果改成以下形式呢?

Obj* GetObjPtr(int key) {
  auto& obj = obj_map_[key];
  if (obj == nullptr) {
    obj = std::make_unique<Obj>();
  }
  return obj.get();
}

至于后续在实际运行的过程中,观察到了性能瓶颈,再从代码结构上进行优化,那是后话了。

好了,写了很多了,我累了,容我休息一下,希望还能有后续吧,哈哈!!


Share this post on:


Previous Post
莫比迪克
Next Post
优秀的人