« 感谢各位投递简历和参加面试的同学 | 返回首页 | 我诅咒帮网易做 OA 系统的公司 »

我所偏爱的 C 语言面向对象编程范式

面向对象编程不是银弹。大部分场合,我对面向对象的使用非常谨慎,能不用则不用。相关的讨论就不展开了。

但是,某些场合下,采用面向对象的确是比较好的方案。比如 UI 框架,又比如 3d 渲染引擎中的场景管理。C 语言对面向对象编程并没有原生支持,但没有原生支持并不等于不适合用 C 写面向对象程序。反而,我们对具体实现方式有更多的选择。

大部分用 C 写面向对象程序的程序员受 C++ 影响颇深。企图用宏模拟出一个常见 C++ 编译器已经实现的对象模型。于我愚见,这并不是一个好的方向。C++ 的对象模型,本质上是为了追求实现层的性能,并直接体现出来。就有如在 C++ 中被滥用的 inline ,的确有效,却破坏了分离原则。C++ 的继承是过紧的耦合。

我所理解的面向对象,是让不同的数据元有共同的操作方式,适合成组的处理。根据操作方式的不同,我们会对数据元做不同的分组。一个数据可能出现在这个组里,也可以出现在那个组里。这取决于你从不同的方面提取的共性。这些可供统一操作的共性称之为接口(Interface),接口在 C 语言中,表现为一组函数指针的集合。放在 C++ 中,即为虚表。

我所偏爱的面向对象实现方式(使用 C 语言)是这样的:

若有一组数据,我们需要让他们看起来都有一种叫作 foo 的共性。把符合这样的数据都称为 foo_object 。通常,我们会有如下 api 去操控 foo_object

struct foo_object; struct foo_object * foo_create(); void foo_release(struct foo_object *); void foo_dosomething(struct foo_object *);

在具体实现时,会在一个叫 foo.c 的实现文件中,定义出 foo_object 结构,里面有一些 foo_dosomething 所需的数据成员。

但是,以上还不能满足要求。因为,我们会有不同的数据,他们只是表现出 foo_object 某些方面的特性。对于不同的数据,它们在 dosomething 时,实际所做的操作也有所区别。这时,我们需要定义出一个接口,供 foo.c 内部使用。那么,以上的头文件就需要做一些修改,把接口 i_foo 的定义加进去,并修改 create 函数。

struct i_foo { void (*foobar)(void *); }; struct foo_object * foo_create(struct i_foo *iface, void *data);

这里稍做解释。i_foo 是供 foo_dosomething 内部使用的一组接口。构造 foo_object 时,我们把一个外部数据 data 和为 foo_object 相关特性定义出的 i_foo 接口捆绑在一起,传入构造函数 foo_create 。一般,我还会会每个符合 foo_object 特性的对象实现一个方法来得到对应的 i_foo ,如:

struct foobar; struct i_foo * foobar_foo(void); struct foobar * foobar_create(void); void foobar_release(struct foobar *);

创建一个 foo_object 对象的代码看起来是这样:

struct foobar *foobar = foobar_create(); struct foo_object * fobj = foo_create(foobar_foo() , foobar);

struct foo_object 的定义中,必然要记录 i_foo 的接口指针和 data 数据指针。从 C++ 的观点看,foo_object 是基类,它也会有一些基类成员和非虚的成员函数。具体的派生类在实现时,改写了虚表 i_foo 的内容(重载了虚函数)。data 数据是在对基类 foo_object 继承时扩展的数据成员。但,在这里,我们使用了组合的方式来扩展成员。这增加了一层间接性,但提供了更低的耦合。其中的优劣暂且不讨论了。

通常看起来会是这样:

struct foo_object { struct i_foo * vtbl; void * data; void * others; }; void foo_dosomething(struct foo_object *fobj) { fobj->vtbl->foobar(fobj->data); // do something else }

此处还有另一个问题:data 的生命期该由谁来负责?

生命期管理是个很大的课题。也是大多数使用 C/C++ 开发的软件的复杂度重要来源。我个人倾向于把生命期管理独立出来解决。所以 foo_object 模块一般并不负责 data 的生命期管理。它只负责 struct foo_object 的资源释放。

自己经营自己,是我的 C 语言软件开发的观点之一。我倾向于采用混合语言编程来更好的解决这个问题。比如 C 和 Lua ,或者 C 和 C++ 。如果不采用混合语言编程,那么也可以在之后,增加一个同样用 C 语言编写的层次来管理。这个话题,留到下次来讲。

剥离出生命期管理,代码量可以减少很多,也不容易犯错误。

ps. C 语言是一个弱类型的语言。至少比 C++ 要弱一些。这表现在:

void * 在 C 语言中可以指代任意数据指针。你可以把任意数据指针赋值给一个 void * 变量,也可以把一个 void * 变量赋给特定的指针类型变量。(这在 C++ 中不推荐,并会被编译器警告)

C 语言中的函数指针也比较有趣。通常,不同类型的函数指针相互赋值是会引起编译器警告的(类型不同)。当然,我们可以用一个 void * 来解决问题。但有时候,我们期望让类型检查严格一些,至少我们不希望把一个数据指针赋值给一个函数指针。但希望编译器不要理会函数参数的差异。

在 C 语言中,void (*foo)() 可以被赋予任意返回 void 的函数指针。即,你可以把 void foobar(int) 的地址赋予前面的 foo 变量(这是由 C 标准的参数传递规则保证的)。

所以,在 C 语言编程中需要注意。如果你想定义一个不接受参数的函数,并让编译器帮你检查出那些错误的多传递了参数的语句。你必须在 .h 文件中严格定义 void foo(void) 以示 foo 函数不接受参数。

在传统的 C 语言中,对结构初始化需要非常小心。这里,我们的 i_foo 接口定义就使用了 C 里的结构。这需要非常谨慎小心。(没有 C++ 编译器帮你做这件事)

C99 新增加的语法增强了这点(在初始化结构时,可以不依赖次序,而写出成员的名字)。值得采用。

Comments

如何像里边赋值?

struct foo_object {
struct i_foo * vtbl;
void * data;
void * others;
};
把data, 放在基类中合适吗?
foo_object是不需要基类的具体数据的;
这样的话
struct foo_object * foo_create(struct i_foo *iface, void *data);
这个接口也不需要data参数;
必要的话,在i_foo
中加个,init函数成员,负责创建子类
foo_object 只需要在foo_create函数中调用i_foo中的init创建具体的子类既可以了。
这样的话个人感觉更nice点

我的观点是:我们需要的是面向对象的设计观,不需要编程语言是OOP的。
我个人觉得C++中有太多的语法陷阱。真正能用好C++的人,肯定可以用C完美地实现同样的功能。

云风兄,俺写了个博客,想引用你的文章。但是不会用你的引用通告。。。能教一下不。。。

云风大哥的C面向对象编程模式与高通brew平台C编程模式一模一样,面向对象从来都是思想,不是语言,前面说C++的偏颇多了,C++不是封装,不是拉了屎,不用擦,如果说管理内存是屎的话,那所有非托管代码都是屎。C要管理内存,C++不一样也得管理内存,而且,比C更复杂,更容易出问题

我觉得写程序没有必要有偏执,
那种工具最适合当前的任务就用哪种。
任何语言都只是一种工具。

没有必要因为偏爱铁锤,就炒菜吃饭都用修正过的铁锤。

可能这位兄弟不了解云风,才会如此发言。

我感觉到很多搞技术管理的都是这样的观点,对于技术不求甚解、偏好表面的、随大流,很多东西并不是自己深深体会的就敢拿来用。

云风之所以选择C语言加上一点OO扩展,应该是有其深思熟虑的,并不是合适不合适,偏爱铁锤这么简单。
我作为一个菜鸟我都可以说出几个简单的理由
1)C++复杂度太大,增加了项目组成员的心理负担
2)C++对于3D核心编程,并没有太多的优势
3)C++对环境要求高,注意我说的环境是库依赖、开发环境,对于这点怀疑的同学可以自己去做做真正多平台的软件产品,那些拿QT随便画画界面的你就不要拿来说了

个人觉得
struct foo_object {
struct i_foo * vtbl;
void * data;
void * others;
};
不必存在,vtbl冗余了,不必模拟C++实现

下面这个是对数据和接口的调用原型
void do(struct _if1* pif,void * data)
{
pif->type_dosome(data);
}

对上面接口的使用
do(foobar_foo(),foobar_create());


没有采用构建foo_object的办法,而是直接调用,也即是说foo_object不必存在,foo_object仅仅是有形式上的意义,将函数表和数据放在一块了

老大, 我没有完全看懂,
但我觉得, 还不如自己实现一个编译器, 语法 想咋编就咋编.

如果实现 自己的(每个平台一种) 编译器 有困难, 这时就需要 c 专家.

可以 把 "自己的语言" --翻译成-->>"c语言"

c语言毕竟 无法实现 中断, 代理函数... 这是需要自己实现编译器的原因.

感觉像 Opaque pointer 的扩展,云风将处理与数据分开了,自由组合。我觉得这也是个不错的方式。
总希望最大化的重用已有的代码,总希望数据与处理可以独立又能和谐相处,还希望所有的接口都可以转成统一的模式,那就完美了。
变化的需求及开发过程中的突发事件,让我常常有上面的理想,设计让我感觉就是在一次又一次的不理想中完善的,所以方法都是值得学习的。

虽然我不反对这样做,但是如果几个类继承下来,编码就显得有些费劲了吧,特别是要重新设计一个类的时候,以前看过一本讲oo的C语言的书,感觉也是这种风格的,但是代码读起来都很累......
感觉GObject也是用到了类似的方法,虽然功能强大,但是用起来也费尽.
我用宏写了个模拟oo的框架,个人感觉还算凑合的,顺带发个下载地址,
http://sourceforge.net/projects/jcoop

好文章。

参见Linux内核VFS部分实现...

谢谢云风老大,最近要用C开发嵌入式程序,一直在找资料想用oo来设计,通读了两遍这篇文章,觉得自己大致理解了80%,决定用这个范式试试,总之这篇文章太及时了

回27楼,如果你不真正了解需求,是不可能有所谓的完美的设计的,设计和语言没什么关系.

我说的减法是指对语言特性的利用方面……
至于那个例子……“CHttpDownloader::Connect()的实现就是建立TCP连接,发HTTP-GET命令”即使是作为一个独立的C函数,这个函数做的工作也应该分成两个,一个只处理TCP连接相关的事情,一个只处理HTTP相关的事情……我觉得有很多问题的根源不在于工具本身。

说C++难做减法,举个例子吧。假设你做一个下载的接口类CDownloader,定义三个虚函数(接口):Connect(), PullData()和Cleanup()。非虚函数两个:一个是Specify()指定文件的url和存放的路径,另一个是Go()。Go的实现很简单,就是依次调用Connect()和PullData(),根据这两个函数的返回值设置返回码,最后调用Cleanup()。
下面考察几个派生类
CHttpDownloader::Connect()的实现就是建立TCP连接,发HTTP-GET命令,PullData()的实现就是解析HTTP应答,分离出HTTP头部,保存数据,Cleanup()的实现就是断开TCP连接,关闭Socket。
到此为止,一切看起来都很简洁,简直是完美!
现在发现有些网站需要鉴权,使用HTTPS协议。问题开始显现:原先CHttpDownloader类的Connect()已经实现为连接和发送HTTP-GET请求,现在需要在这个请求里面加上鉴权信息,势必要推翻基类的设计,要么重载Connect(),不再调用基类的这个函数;要么重新设计CDownloader类,把连接和发送请求分开。
一个“设计好了”的类,你对它已经没有什么可挑剔的情况,没有理由不用它。而某一天你又发现它不适合某种需求,不得不修改时,才发觉C++的逻辑耦合远比宣传的“数据与方法封装”、“接口与实现分离”来得复杂。
现实中总是有需求变化,因为一点点需求变化,使得我们要去用HackN多年前已经工作正常的代码(或修改接口),然后花费很多精力去检查所有的派生类和使用该类的客户代码,这跟C++的初衷之一——提高代码重用——显然是背道而驰的。

关中刀客,

为什么说滥用const?

真是太丑陋了:)
暂时是没办法的。

不太清楚用C++时做减法的难点在哪,感觉上C++并不要求使用者完全理解所有部分后才可以使用。
不知道const的滥用是怎样的,给“需要保护的对象的引用”加上const应该不算滥用吧,而这样应该也就足够了。
如果说C++要用好需要花费使用者很大的代价,那我认同,但也不用先给C++一个充当银弹的使命然后再去批判,C++并不是一个排斥其他语言的语言,也不排斥各种范式,即使把C++当作C用,也可以从类型检查中得到好处。

在校时没把C++学好,现在对C++还是一知半解呀,以后多多来学习一下,相信会懂得更多。

vtbl这个叫法不好。
fobj->vtbl->foobar(fobj->data);改成
fobj->ops->foobar(fobj->data);
对ops进行引用计数管理。

OOP语言中用基类充当接口并不是很合理的抽象方式,而C++这些年提出的runtime concept则是一大进步,个人觉得它与本文中的思路是一致的。这在C++里面也许该叫做面向concept了。不过concept都给标准委员会毙了,runtime concept更不用指望了。

@亮哥
云风当年可是c++的狂热fans
不过这里云风的这个对象模型和c++的不太一样 他将数据和接口分离了

不过我很好奇 没有更c的模型..

btw 从之前的文章看云风这个引擎和市面上的似乎会有很大的不同 我也很好奇

其实我能明白你的意思,你是想利用C的高效,然后用C做加法,利用一些技巧实现部分C++的面向对象功能。
语言之争也一直没有一个什么结果,可能是你一直是做底层,平台性相关的代码工作,所以主要是用C,所以你的C语言编写能力非常之好,完全能实现部分的C++功能,对于你采用C为主的观点,我换位思考,是同意的。不过我更关注的是3D引擎的设计,而你又在这方面很有经验,不妨写点你的心得放上来,让我们学学。

@亮哥,

"如果data是你的struct结构创建出来"

并不是这样,data 是数据体,foobar 是 data 以 foo 方式的呈现方式。

data 还可以以其它的形式呈现,用另一种形式去控制。

更多的时候我们需要让各种不同的实体,从不同的角度归类,按某一同质化的形式去操作。

对象并非非此即比的。这也是 C++ 社区为多继承吵的不可开交的缘由。

这里,把不同的实体按生命期隔开。再把功能按接口分离。算是有点 AOP 的味道吧。

这里高手云集,我的MSN ljl_2000@hotmail.com,希望能和各位共同探讨技术问题

我觉得写程序没有必要有偏执,
那种工具最适合当前的任务就用哪种。
任何语言都只是一种工具。

没有必要因为偏爱铁锤,就炒菜吃饭都用修正过的铁锤。

恕我愚见,云风大哥的一些观点我不认同。C++是面向对像编程,其意是我只需要知道你的这个对像,不用管你内部是如何实现的,把交待好的事情给你办,你把办好的结果给我就行了。这样就能减少逻辑的复杂性,不必事事亲躬。就拿上面所说结构内部的data成员的生命周期来说,如果data是你的struct结构创建出来,你就必须负责data的释放,最好遵守谁创建,谁就要负责释放。说个不好听的比喻,你拉了一堆屎,还指望别人来帮你打扫啊。另外 一个设计原则,一个类(结构)的对象,最好不要用本类(结构)中的方法来建对象,这和真实世界是不相符的,一个类对象的创建,应当由上一级更大范围内的一个类(结构)来创建,比如说,一个总公司可以建立若干子公司,但是子公司你不能建立和自已平级的兄弟公司,子公司只能建立自已的子公司。但是为什么现在好多的C++类里面会用CreateObject创建本类的对象呢?这是一个工厂的概念。我举个例子 来说:一台电脑主机,是由各个比如主板,CPU,内存,硬盘等组成的,电脑生产公司比如联想,联想公司的电脑产品是一台一台地生产,联想公司需要面对的对象是一台台的整机电脑。但是联想公司采购电脑配件,不可能是一块主板配一块CPU,配一块内存,硬盘这样来采购,而是采用一批主板,一批显卡,硬盘,CPU的这种方式来采购,因为这么做效率高。效率才是最重要的,好了,基于这点,那么这些配件的生产商就是一个工厂,他负责生产专一的配件,这件事由他来做,更熟练更专业。假设配件有回收的概念,从理论上来说,那么这些用了若干年的老化了的配件,也必须由这类专业化的工厂进行回收,这样也才更有效率,把统一回收回来的主板统一扔到融炉里面分离出来材料。
好了,现在概念清晰了,对象逻辑层的东西,我们是需要面象对象来操作,比于底层的东西,我们需要有工厂的概念。所以在逻辑层,我们使C++方式来做,在底层,我们把这些对象分解,交给专业化的工厂来做。所以底层我们采用C的方式,C的方式也更接近硬件流水线。其实我们一直是在这么做的。比如我们客户端里面有1000个人物对象在行走,对于逻辑程序来说,他操作的是单个人物(模型),让这个模型跑到哪里,就跑到哪里。但是在底层渲染,我们不可能单个模型进行渲染的,底层程序都是把每个模型根据渲染状态,纹理等分离,再把这1000个分离出来的相同部件集成在一个渲染列表里面,形成批处理渲染。

总结一下,我们在底层,用专业化的工厂模式来大规模化生产配件,在上层,我们组装了一个个的产品,之后我们对于这些产品都是基于对象的概念来处理,这样才符合人类的思维模式,才有可能组建更大的社会团体。

希望我写的这点对于大家有帮助,一般我个人太懒,从来不爱写东西,也从来都潜水。希望能和大家云风交流技术问题。

http://www.reddit.com/r/programming/comments/bdkpb/the_c_object_system_using_c_as_a_highlevel/

记不得哪位C++大牛在哪本学习C++的书的前言里面说过
“用C语言1000行源码能完成的工作千万不要用C++重写!”

在上个世纪,我读大学那会儿。见到有不少人只要写程序必用 MFC ,甚至不是跟窗口有关的。

他们用 MFC 只有一个理由,我要用 CString 。

To Cloud:

我对你说的“C++ 里的东西又太多太杂。大部分时候做减法不易。
”这句话不太明白。

当你花费很多力气用C自己去实现一些OOP的时候,为什么只用C++的这些特性呢。
把C++的编译器当作一个C with OOP的编译器来用。还可以享受到一些C++类型检查的好处。
在我看来,做减法的难易程度取决于人的用法。而不是在语言本身。

从某个角度说如果使用语言的人,不知道自己所用的编译器支持C++那些花哨的特性,那么他们自然也不用滥用了。

所以我觉得,在编译器性能相当的情况下。自己用C去实现一些OPP得不偿失。
我是在不明白做减法的不易是不易在哪里。

这种设计虽然可以做得天衣无缝,但学习使用的门槛我看比C++还高,我们可以很容易去限制C++特性的使用,但不容易限制C语言范式的扩展,C++能轻易地安全地无效率损失地实现,C语言这么做就不太合适了.C++的面向对象编程我认为最重要的是构造和析构的自动化,也是C语言无法实现的,有了这种自动化,大部分数据的生命周期问题都能轻易解决.
用个不太恰当的例子就是,C语言编程是水力发电,而C++是核能发电,虽然后者危险,但还是不能否认其在未来中的作用,而且我相信会更好地驯服.

Unix编程艺术也是这个观点
还是比较赞同的 :)
某些领域C++有优势
但不是银弹

但是,这样子类直接覆盖虚函数表,如果父类中有公共的代码,不想在子类中重写,C++中可以写成parent::fun()这样。你这边怎么解决呢?

我的观点是在现有c/c++中, 宏和inline没太大问题, 甚至是模板, 想用就用, 最大的问题还是设计问题, 自以为是的巧妙实则愚笨的设计带来了成本问题, 学习成本,难解bug的成本, 甚至重写的成本.

有谁会去责怪stl呢, c++增长了庸的人设计的可能性, 成为一个c高手大概也要掌握很多设计范式, 这些经验性的东西,难以朝夕获得.若想降低普通程序员的门槛,似乎还真得JAVA一类的安全语言.

少数人做设计, 其它人在框架之上搭积木,可能是成熟开发的模式呢.

@cyberscorpio

题头第一句,OOP 不是银弹。

C++ 里的东西又太多太杂。大部分时候做减法不易。

所以在必须使用 OOP 的时候,我选择对 C 做加法。(此文方式,也并没有对 C 语言(借助宏或代码生成器)做语法上的加法。

@lichking

至于查 bug ,IMHO ,C++ 的 bug 比 C 的难查的多。

我本人极力避免在 C 里使用宏。

云风的这种方法作为一种编程规范,在项目组内推广普及,我觉得没什么问题,只要大家都这么做,实际上相当于减少了建造巴别塔的工人们所讲的方言数量,对于统一认识,加强战斗力是很有帮助的。

但把 C 的代码设计成这个样子,依我 **个人浅见** ,还不如用 C++ 来做这些接口了 ^_^

inline和面向对象有什么关系, 宏又如何模拟面向对象, 为什么要分离,什么又是耦合,实际面对的问题有多少因为inline或是宏所引起的?
c++时代批判c到处充斥的宏, 如今inline也成了罪过.
还有const呢, IMHO, 类型较为安全的c++对c来说是一种进步, 但远不足够, 这也就导致了为什么这些程序员要花多得多的时间在调试上面,这些让人通宵恼怒地解决bug的经历并不是什么成就感.

户外运动者无需天文望远镜,数学家也绝少在论文中巧用比喻. 实践者和理论家是两种人, 实践者通过口口相传,体验领会,并非是写在纸上的淳淳教导,长久以来, 实践者发明了一个较为"理论"的词汇“方法论”, 于是面向对象, 耦合,分离, 形成了某种的实践主义的形而上学.可是尽管我们习惯于讨论似是而非, 终究感觉所学甚少,难以踏实. 谦逊地认为,这并非是一个理论, 而把它局限于我们模糊的讨论中吧.

我感触较深的是c++程序员对const的滥用到了极致.

nice

不错

Post a comment

非这个主题相关的留言请到:留言本