关于 getter 和 setter

网友 "sjinny" 在上篇评论里写:

云风对那种所有成员数据都写setter/getter的做法有什么看法吗……这两天试图精简三个太庞大的类,但是单单setter/getter就让接口数目变得非常多了……

我谈谈我的看法吧。

首先,几乎任何设计问题都没有标准答案。如果有,就不需要人来做这件事了。无论多复杂的事情,只要你能定义出精确的解决方案,总可以用机器帮你实现。

下面谈谈我的大体设计原则。记住、一切皆有例外,但这里少谈例外。因为这涉及更复杂的衡量标准。

KISS 当然是首要原则。但有许多诠释角度,每个设计师眼中都有自己的 KISS 原则。

今天的我认为,我们应该尽量少提供新的概念。所以,如果你用 C 就尽量不要用函数指针数组去模拟虚表;如果你用 C++ 就别想着用模板之类的东西弄出个“属性”的概念出来…… 这些语言原本不提供的东西,对于用户(可能是你的队友、可能是今后的你自己,可能是你未来的继任者)就是新的东西。

大部分情况下,设计一个所谓框架,也是新东西。限制用户以一定的规范来编写程序,最合适的是在语言级、而且是大家都熟知的并成熟的语言特性。

我们应该坚信:简洁优良的设计一定是和语言工具无关的。优雅的接口设计,总可以以简单的方式表达出来。

第一件事情,就是寻找你选择的开发语言的惯例。因为,如果一个语言足够成熟,抽象化的需求一定有无数人遇到过,好的方案会经过时间的洗练留下来;不用我们重新发明。setter/getter 这种需求莫如是。

最近几年,我用的比较多的是 C 语言。C 语言的惯例是什么?C 语言因为 Unix 而生,并是 Unix 的原生开发语言。我们从 Unix 的接口中寻找答案。

举个大家都熟悉的例子:getsockopt / setsockopt 。几乎是一样的需求:向一个对象读取或设置某一属性值。

传统上,C 语言构建造的系统中较少为每个属性值分别留下两个接口(读/写)。对一个对象的内部状态的修改,一般会用统一的一对 API 去操控。

少即是多。

第二要点是效率。

不考虑效率的程序员不是好程序员。这是我的个人观点。可能有些老程序员不会同意,他们会苦口婆心的教导新人:性能并不总是那么重要,为了性能,你会失去很多东西,当你剩下了“性能”后,最后,还是会失去它。

在我学会编程的头十年里,我疯狂的追求速度。读了大量的书、写了大量的代码。小心翼翼的优化每处我觉得值得优化的部分,重写再重写。

慢慢的,我学会接受一些东西:

比如相信编译器。

比如别耍小聪明。

比如不要牺牲代码清晰性。

比如防御式编程。

比如先把代码做的可靠。

比如采用时间复杂度更高,但简洁的算法。

……

对于一个性能偏执狂来说,这些浅显的道理接受起来是多么的不容易。

我这里要写的,并不是重复证明这些道理多么的有价值;而是想反过来说,每次采用和性能相违背的方案时,我的内心都会抗拒和怀疑。我依旧认为应该考虑例外情况,从而破坏这些规则。如何判定什么时候该遵循、什么时候该违背。以我目前的水平,无法精确总结。只能靠大量的实践磨练出得感觉了。

同上,如果有精确的准则,我们应该让机器去选择,而不是人。

我敢肯定,无条件相信教条的程序员,不会成长。

相信 C++ 可以取得比 C 更高性能的程序员认为:C++ 语言设施会带来更高的效率。他们最喜欢举的例子是 algorithm::sort() 和 qsort() 的比较。

模板会内嵌比较函数,去掉函数调用之消耗。从而在性能测试中完全击败 qsort() (后者需要为每次比较做一次 C 函数调用)

前两周在 有道难题 的决赛颁奖仪式后,我和参赛同学的交流中,我谈到了这个问题。当时,我先讲了另一段:如果你的程序要处理一组数据,是从前往后处理性能高、还是从后往前、还是间隔着处理…… 这其实取决于很多和你的算法关系不大的东西:比如内存控制器的工作方式、CPU Cache 的管理、OS 的虚拟内存调度,等等。

有时候,我们需要关心这些、有时候我们不关心这些。如果想把整个系统做的高效,何时关系,何时不关心,这个决策比如何优化更难,更要功力。对于性能偏执狂来说,影响他决策的才不是哪些重要,哪些不重要;不是把有限的精力投入到关键点的优化中;因为对于他来说,反正重要不重要的都会去优化的,他会无视旁人的嘲笑,做他觉得有兴趣的事情。做的久了,不存在事后优化,因为对他来说,第一次编写时就考虑了种种,而且随着经验的增加,代码可以在保证高效的同时清晰可靠。

但是,我们有时就不去关心那些底层的性能差异。这同样出于性能考虑。因为追究到细节,全体和局部很可能得到相反的结论。因为代码本身也是系统的一部分。就连代码的规模也会影响到机器的运作效率。

当你可以从更高层次来看问题,你就会对性能有更多的理解。我们编写和设计软件,最大的敌人是复杂度。性能的敌人同样也是它。控制软件的每个层次上处理对象的粒度就是减小复杂度的武器。

回到 sort 的问题。函数调用真的是不可饶恕的开销吗?如果你对一个整数数组排序,那么性能考虑就很巨大。对于整数比较操作而言,函数调用,寄存器压栈出栈这些,会有成倍的开销。

但是,除了教科书和考试题中,我们有多少机会对一个整数数组排序?

排序是为了重新组织一组对象(往往这还不是最终目的。比如说,排序只是为了更有效的检索,有效检索才是目的),在一个特定层次上,对象的数量不宜很多,对象的粒度应该保持相当的规模。其结果就是,对对象的比较的开销会大于函数调用。因为涉及对象细节的操作是跨层次的。正如大多数情况下,你不会考虑访问一个内存字节的操作,对于机器意味着什么,OS 怎样调用虚拟内存、CPU 怎样管理 Cache 、内存管理器怎么收发控制信号。跨越层次的数据访问,函数调用是应该被忽略的。

C++ 的 sort 想获得更高的性能,代价是破坏了层次间的封闭性,往往是得不偿失的。

关于函数调用的开销这个问题,和不把性能做为第一位的程序员讲是很容易的;但对于看中性能的程序员来说,其实是很纠结的一件事。

《Unix 编程艺术》在 Part 2 开篇(模块性:保持清晰,保持简洁)就提到了这点。中文版 P84 引用了一段话:

Dennis Ritchie 告诉所有人 C 中的函数调用开销真的很小很小,极力倡导模块化。于是人人都开始编写小函数,搞模块化。然而几年后,我们发现在 PDP-11 中函数调用开销仍然昂贵,而 VAX 代码往往在 “CLASS" 指令上花费掉 50% 的运行时间。 Dennis 对我们撒了谎!但为时已晚,我们已经欲罢不能……

我想说的是,我们得承认一些损失。承认它们最终是为了更好的性能。而不是在同一层次上用语言技巧去抹平它。过度依赖 template inline 这些,并不仅仅是浪费你的时间去等待编译。

舍就是得。

上面写了这么多,只想引出下面这个话题:

有时候,对于宏定义式的属性管理方式,并不满足我们的需求。

C 语言里其实还有另一种惯例:我们可以考察 FILE 的接口。fopen 在打开文件的时候,可以传入多个选项。它是用字符串传入的。每个字符表示了一个属性。这使得使用它们更加友好,并极具灵活性。

还可以看 XLib 的接口设计。虽然不算太好,以至于后来又有人制作 XCB 。但我个人觉得,已经比 Windows 的对应部分设计的好太多了。

Xlib 里,使用联合 + 位域的方式管理对象的内部结构。也是相当不错的。

谈及 GUI ,我推荐大家读读 IUP 的代码,或许你会喜欢上它。在折腾 GUI 的东东时,在 QT/GTK/WsWidgets 等等之外,又可以多一个选择。它的接口设计采用了一些原则,使得足够简洁。而我在经历了太多次的重构 GUI 模块后,才领悟了点点东西。之后,发现了 IUP ,看到了许多我最终认同的东西,感慨颇多。

btw, 真的,boost::python 或是 LuaBind 这样的,利用一大套代码,让机器转换繁杂的接口,从一个语言的接口转换另一个语言的方式,最终都是权益之计。把接口设计简洁方是正道。

这里引出另一种需求,我们需要保留属性的名字信息。这样在分割明确的模块之间使用更加人性。尤其是在需要跨语言使用的时候。这种情况下,我会选择使用 string 做 key 而不是宏定义出来的整数。

这就牵扯到实现的效率问题了。好吧,我们又绕回了性能这个话题。

为此,我 谨慎 的给我们的系统添加了一个新概念: const_string 这个类型(注:在《C语言接口与实现》中,这个东西叫 atom ,这是个贴切的名字)。在我的项目中,反对随意的使用 typedef ,因为那意味着不断的新概念的加入,为此,付出更大的体力代价也是值得的。也就是说,宁可在每个结构和联合前显式的敲上 struct 和 union 。

这个类型其实是个特殊的指针,指向一个不变的字符串。如果需要调试输出,可以直接用 (const char *) 强制转换。但是,一个字符串必须通过 api ,build 出这个类型来。

其实现就是建立一个全局的字符串池,用 hash 表索引。里面存放不重复的字符串。我们只在其间存放那些系统中的标识符(用来索引资源用的字符串)。我们在进程生命期间,不再释放任何标识符。因为它们是限的,所以我们不用担心它们会吞噬我们的内存。也不需要用复杂的引用计数或是 gc 来管理它们。

这些特殊 string 可以用简单的指针去使用,不用再顾及生命期。可以直接用高效的变量比较、可以方便的在模块间传递、可以参与排序、可以用于 hash 映射的 key …… 总之,当成基本类型用就好了。一定程度上,可以弥补 C 语言没有原生字符串类型的不足。

我用它们去索引对象的属性名字。这样可以兼顾性能。

具体的实现是:我在每个 C 模块(利用宏)初始化一些字符串常量。当然这本是链接器应该干的事情。可 C 语言的模型并不原生支持这个,依赖 C 语言模型的编译器就不会给你代劳。忍住 C++ 的诱惑,我用丑陋的宏辅助我实现了一点东西。给语言增加并不存在的概念(新的类型)、使用宏、这都让我非常有罪恶感。带着这种感觉做设计,不至于犯太多错误,不至于破坏 KISS 。

然后在系统运行起来后,这个模块中的函数,可以通过简单的 if else if 来筛选不同的属性访问请求。如果对性能要求再苛刻一点的,还可以做一个简单的映射,最终转换为 switch case 。不要问我为什么不使用函数指针数组,在前面已经解释过了。如果真的需要,也值得考虑。

注:虽然 const_string 其实就是一个 const char * ,但也不要直接 typedef const char * const_string; 。这样编译器不会帮你找出错误的类型匹配。正确的方式是定义成 typedef struct literal const_string; ,我们不需要让使用 const_string 的模块了解 struct literal 是什么,实际上它什么都不是。想当成 const char * 的时候,依旧可以强制转换。但直接赋值是编译通不过的。

最终,一个对象的 getter 和 setter 可能被统一成两个 api :

int object_get(struct object *, const_string property, ...);
int object_set(struct object *, const_string property, ...);

有点 printf 风格?这就是 C 语言。

还想问这样会不会导致性能问题?如果在系统里,模块之间存在大量的交互,会高频率的访问对象的属性。这样不关是函数调用的开销了。可能还涉及 hash 表,涉及大量的 if else 比较……

那、就是你的设计问题了。你怎么可以允许这样的存在?

不同的对象活在不同的层次,它们最好是自生子灭。尽量少干涉它们。只在极少情况下改变它们的状态。大部分时间让它们自己运转去。至于模块内部,毋用我说,你不会使用外部接口去操控自己内部的数据吧?

对于 C++ ,不要幻想 inline 总能帮你解决问题;对于 C ,inline 并非传统。

承认 getter 和 setter 的开销。

一家之言,别相信我。请自己思考。实践。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章