比较不同的编码方法 - 第一部分






4.36/5 (27投票s)
这是本系列文章的第一篇,我们将评估并介绍不同代码风格之间的比较。
引言
简单几句介绍一下为什么会有这个系列的文章。
在我向 CodeProject 提交了文章《MFC、命名空间 MI 和序列化》之后,一位正在开发 WTL 项目的朋友打来电话问道:“这个很好,我能在 WTL 项目中使用它吗?”
我最初的回答是……“为什么不行!毕竟除了演示应用之外, MFC 部分很少,唯一可能缺少的是 `CArchive`。”
我开始研究如何移植代码,结果陷入了一个编码困境。
WTL 是一个模板库。我的许多类也是如此,但其他类则不是。我是否应该提供“内联”形式的类,还是库形式的类?当我在“内联”或“库”中提供代码时,它对应用程序的最终代码有什么影响?
另外,WTL 本身也存在其他问题:它是一个“模板库”,直到使用时才会产生二进制文件(它的源代码不会直接产生“OBJ”、“LIB”或“DLL”)。但我的类中有一些全局或静态对象,即使在你实例化自己的对象之前也必须存在于内存中(例如,在该文章中的 `SFactory`)。
那我应该如何处理我的代码?是把它也做成模板库,还是普通静态库?
本文内容
我不会提供任何代码(我目前正在进行从 MFC 到 WTL 的移植工作),但我想讨论一些关于通用“良好编程技术”的代码风格方面的问题,以及它们如何实现到库中。这个想法是分享关于这个主题的观点。目标是“编写一个 Windows 应用程序”(一个 .exe),建立在 Windows API 之上。
也可以考虑使用 MFC 或 WTL 作为 API 封装器,但不能使用这些库作为“框架”(相互关联的类集合),因为我们无法进行比较(它们在各种事情上有不同的方法,因此从两个“框架”来的代码进行比较是不恰当的)。
什么是库,为什么需要库?
一般来说,库是“代码集合”,由他人编写并可供他人使用。
这样做的原因可以归结为两个需求:**代码重用**和**代码结构化**。
根据“集合”的内容和“用法”,人们发明了不同的模型甚至哲学来做到这一点。从这个意义上说,我们可以谈论“开放”代码和“封闭”代码。
然后,不同的编码技术(如“信息隐藏”或“代码共享”)可以在这两种不同的情况下使用。
此时,会产生一些混乱,将不同的概念(什么代码、做什么、怎么做)与相似的术语混淆(“开放”一词的真正含义在所有这些 2x2x2 的情况下截然不同,但总是被使用),从而引发一些奇怪的“宗教战争”。为了避免混淆,请允许我在这里澄清一些术语在本篇文章中的用法。
信息隐藏
信息隐藏——尽管许多开源爱好者这么说——并非比尔·盖茨发明的 :-):它是信息科学理论的一部分。它所指的概念源于工业设计和加工方面的一些考虑。
如果你是汽车的设计师,你也会涉及“使用”轮子:你必须了解它们的力学和物理原理,但你可能不关心它们的生产过程。而且通常——这个过程——是由一个完全不同的行业负责的。
编码也一样。
如果你是一个对象的*用户*,你必须知道如何使用它的接口,而不是对象*设计者*内部实现它的方式(除了某些关于正确性和性能的“质量保证”)。那么问题来了:语言真的朝着这个方向发展吗?“真实”的编码生产过程真的基于这些假设吗?
依我看,C++ 不是:如果我想“隐藏”实现,我应该只给你头文件(这样你就可以派生你的类并调用我的函数)和二进制文件(LIB 或 DLL),而不是源代码。但是——假设这是好的——为什么 C++ 会强制我在给你的头文件中也声明我的“private”成员?你应该怎么做?(我知道,有一些技术原因,比如编译器需要知道这些成员占用的空间,但这——无论这个原因好坏——都违反了隐藏原则)。再说:头文件足够描述一个对象的函数功能吗?不,它们不够:它们描述的是*数据*和*函数*,但没有描述你必须调用它们的顺序来达到某种效果:它们无法描述“*协议*”!你需要额外的文档来说明“你必须先调用‘CreateWindow
’,然后再调用‘MoveWindow
’,反之则不然。”
信息过载(即头文件代码中的熵)和信息不足(即你流程中的熵,用来弥补我没有/不能记录的内容)同时存在。
开放式编码
“开放式编码”是解决所有这些问题的方法吗?(我指的是“开源”作为“来源”,而不是“开发者社区”,因此我称之为“开放式编码”以避免混淆)。
还不是:源代码是否完全描述了对象行为?(忽略隐藏细节:这显然不是开源的初衷!)。从技术上讲,是的:毕竟机器执行的就是源代码。但是稍微想一下:如果我给你一个源代码,你真的理解一个对象是做什么用的,以及*它的意图是什么*吗?
这取决于很多因素:风格(如果你不喜欢*我*写代码*的方式*,你可能会遇到更多困难)、“牵连”(如果一个函数在源代码的其他完全不同的部分对其他对象的数据产生副作用,你可能会难以追踪)甚至“精神分析”(我为什么以这种方式设计?我当时打算做什么,而不是做什么?)。
同样,知道“如何”做一件事,并不能告诉你“为什么”那样做(以及为什么那样做)。如果你发现一个错误,你可以用很多方式纠正它?纠正的“正确”方式是什么,而不改变设计的本质?有多少人会用不同的方式思考?这些假设可能产生多少不同的“主题变奏”?
总而言之
没有哪种哲学本身能解决所有问题:无论你喜欢哪种,你总要有一定的纪律来管理语言本身不提供的东西。
保持源代码“开放”或“封闭”的优势在于,你是否(或不)认为(或不)能够让每个人以某种方式修改你的代码以满足他们的需求,这是一种优势。
我不想深入探讨这个论点(这真的只是一个宗教问题,而不是技术问题,参见这篇文章……及其评论!),但我希望明确的是,这些事实(与“组织”方面有关)不应与技术方面混淆。
你将以“开源方式”部署一组类的事实,并不意味着你不能将这些源代码组织成库,供其他程序员使用,而无需将你的源代码包含在他们的项目中并重新编译它们。或者,你也不能通过将代码组织成一组库来获益。
同样,你公开你的源代码的事实,并不意味着你可以避免记录你的设计“原因”以及它的预期用途。
现在,假设我们要公开我们的源代码,对于 Windows 应用程序来说,什么最好?
- 内联编码(提供纯头文件集合)
- “近内联”编码(提供头文件,但实现与声明在不同文件中)
- “离线”编码(提供头文件和源代码,用于包含到其他项目中)
- 静态库(提供头文件和*.lib*,以及仅用于文档和调试的源代码)
- 动态库(提供头文件和*.dll*以及导出的*.lib*)
当然,没有万能的答案,但我在这里提出一些实验来展示好处和坏处。
翻译过程
假设创建一个应用程序,你的开发环境会涉及类似这样的过程:
- 一些头文件(*.h*)预编译成*.pch*
- 一些头文件(*.h*)被包含到源文件(*.cpp*)中
- 一些源文件被翻译成目标文件(*.obj*)
- 一些目标文件(*.obj*)被打包成库(*.lib*)
- 一些目标文件(*.obj*)和库(*.lib*)被链接成二进制文件(*.exe*或*.dll*)
代码风格可能在声明、定义和实现如何分布在这些文件中有所不同。但是,存在一些限制。
现在,考虑*我*提供*你*一些类,代码应该是什么样子,应该如何使用,以及你的翻译过程会变成什么样?
你的应用程序
无论你偏爱哪种风格或框架,我认为通常都有效的是一个模型,它假设:
- 一个预编译头文件(将包含大量常用头文件)
- 零个或多个你的头文件(零个不常见,但可能……)
- 一系列库头文件(一些是标准的,一些是其他来源的——比如你使用的我的)
- 零个或多个静态库(或引用的 DLL 导入库)
- 零个或多个导入的类型库("tlb")
- 一个或多个源文件(你的 "cpp" 文件)
- 一个或多个“别人提供的源文件”,你将其包含在你的项目中
代码外观
让我们考虑几种可能的风格。
A. 内联编码
代码如下:
//************************** // file "myclass.h" #pragma once // include used types declarations #include "MyBase.h" #include "MyInterface.h" //declare / define CMyClass class CMyClass: public CMyBase, public IMyInterface //if any { //data members CSometypeA _memberVariable; //static member CSometypeB s_staticMember; //MyClass functions returntype DoSomething( parameters ) { //function body goes here } //CMyBase overrides virtual returntype DoOverride ( parameters ) { //function body goes here } //IMyInterface implementation virtual returntype DoImplement( parameters) { //function body goes here } }; //instantiate static members _declspec(selectany) CSometypeB CMyClass::s_staticMember; //instantiate global objects _declspec(selectany) CSometypeC g_glbalObject; //declare some global function inline returntype GlobalFunction (parameters) { //function body }
我应该提供给你的也只有这个文件了。
请注意
- 所有函数体都是内联的,并且所有函数都在一个步骤中声明和定义。
- 实例化需要 `_declspec(selectany)` 声明:因为如果你在同一个项目中将此头文件包含到多个源文件(*.cpp*)中,实例化将在编译器生成的每个*.obj*文件中都存在。所以*我*必须告诉*你的*链接器将所有这些实例视为一个(而不是独立的)。否则,你会收到“对象已定义或声明”的消息,如果你设置链接器继续,每个原始*.cpp*文件中的代码将引用该对象的自身实例。
- 全局函数的定义需要 ` inline ` 声明。如果函数是递归的(或属于递归机制的一部分)或函数体太长,编译器不会在调用它的表达式中展开函数体,但无论如何都会放置一个普通的函数调用。无论如何,这里的“
inline
”起着上面 ` selectany ` 的相同作用。事实上,成员函数也是内联的,但对于以这种方式定义的成员,` inline ` 是隐式的。
B. 近内联编码
这种编码方式的优点是将所有内容放在一个地方,但也有缺点:
- 代码可读性:你可能会难以理解哪些函数可供调用:太分散。
- 编码功能:如果 A 使用 B,B 使用 A……你无法定义 A,直到你定义了 B,也无法定义 B,直到你定义了 A。
在最后一种情况下,唯一的解决方法是将声明和定义分开。但如果我仍然想使用“仅头文件范式”,这里有一个可能的解决方案:
//********************** // file "myclass.h" // include used types declarations #include "MyBase.h" #include "MyInterface.h" //declare CMyClass class CMyClass: public CMyBase, public IMyInterface //if any { //data members CSometypeA _memberVariable; //static member CSometypeB s_staticMember; //MyClass functions inline returntype DoSomething( parameters ); //CMyBase overrides inline virtual returntype DoOverride ( parameters ); //IMyInterface implementation inline virtual returntype DoImplement( parameters) }; //reference static members (if externally needed) extern CSometypeB CMyClass::s_staticMember; //instantiate global objects extern CSometypeC g_glbalObject; //declare some global function inline returntype GlobalFunction (parameters); //now include the definitions #include "myclass.hpp"
//************************** // file "myclass.hpp" #pragma once // include used types declarations not already in Myclass.h // define CMyClass //MyClass functions imline returntype CMyClass::DoSomething( parameters ) { //function body goes here } //CMyBase overrides inline returntype CMyClass::DoOverride ( parameters ) { //function body goes here } //IMyInterface implementation inline returntype CMyClass::DoImplement( parameters) { function body goes here } //instantiate static members _declspec(selectany) CSometypeB CMyClass::s_staticMember; //instantiate global objects _declspec(selectany) CSometypeC g_glbalObject; //declare some global function inline returntype GlobalFunction (parameters) { //function body }
你注意到,*.hpp*文件扮演着*.cpp*文件的相同角色,但它不是。通过在*.h*文件末尾包含它,我假设——如果你在我的代码中包含我的*.h*文件,我也强制你的编译器翻译我的定义。
但是因为你可能在不同的*.cpp*文件中包含它,我们仍然需要“inline
”和“selectany
”。
因此,我们可以得出结论——从翻译过程的角度来看——变化很小:编译器为包含头文件的每个源文件("cpp")生成所有符号。如果多个源文件引用相同的头文件,并且头文件中包含实例化,则会产生多个实例化,因此必须相应地指示链接器(通过 `_declspaec(selectany)`)。
真正改变的是代码风格。B 风格可能更适合拥有大量内部辅助函数的长而复杂的类。你(作为用户)可以更好地专注于接口,而不是实现细节。
我们还必须注意到,这是类是模板时的最后一种可能风格:我们不能在源代码中定义模板,因为编译器在模板参数被分配之前无法翻译它们。而这将由你来完成,而不是我。
C. 离线编码
“离线编码”的目的是避免代码翻译的延迟,并预先处理所有可以(或必须)独立于其使用而存在的类和实例。
相同的示例看起来会是这样:
//********************** // file "myclass.h" // include used types declarations #include "MyBase.h" #include "MyInterface.h" //declare CMyClass class CMyClass: public CMyBase, public IMyInterface //if any { //data members CSometypeA _memberVariable; //static member CSometypeB s_staticMember; //MyClass functions returntype DoSomething( parameters ); //CMyBase overrides virtual returntype DoOverride ( parameters ); //IMyInterface implementation virtual returntype DoImplement( parameters) }; //export global objects (if needed) extern CSometypeC g_glbalObject; //declare some global function returntype GlobalFunction (parameters);
//************************** // file "myclass.cpp" #include "myclass.h" // include used types declarations not already in Myclass.h // define CMyClass //MyClass functions returntype CMyClass::DoSomething( parameters ) { //function body goes here } //CMyBase overrides CMyClass::DoOverride ( parameters ) { //function body goes here } //IMyInterface implementation CMyClass::DoImplement( parameters) { function body goes here } //instantiate static members CSometypeB CMyClass::s_staticMember; //instantiate global objects CSometypeC g_glbalObject; //declare some global function returntype GlobalFunction (parameters) { //function body }
在这里,你看到与前一种编码相比,“样式”上的差异很小,但有一个实质性的事实:CPP 可以被翻译,并且可以实例化静态或全局对象。编译器不必为你的项目的每个 CPP “重复”这些任务。这只是你项目中的另一个 CPP!
但有一个问题:这是我写的,我不知道你的项目设置,也不知道你在任何预编译头文件中包含了什么,我必须包含它(否则编译器不会翻译)。这个事实使得这种模式的吸引力降低。如果你打算自己修改我的代码,根据你的具体需求“调整”后重用它,这可能很好。但如果你只是想在你的项目中使用它,那就不是了。
D. 静态库
为了解决上一个问题,解决方案是由我自己提供一个项目并自己翻译我的代码,让你实现你的项目“依赖于我的”。
我可以有我的预编译头文件,你也可以有你的。我可以有我的编译器设置,你也可以有你的。
代码风格与上一种相同,但有一个包含问题:如果我使用其他库中的一些头文件来定义我的头文件而不包含它们,而是预编译,你也必须预编译它们。
这可以通过提供以下内容来实现:
- 一个你需要与你的预编译头文件一起预编译的头文件,
- 描述我的类的头文件(你将在需要时包含它们),以及
- 一个包含翻译后代码的*.lib*文件。
你的编译过程更简单,因为你每次在每个源文件中使用我的类时都不必重新翻译它们:你只需在创建二进制文件(EXE 或 DLL)之前*链接*我的翻译。信息隐藏更简单,因为我只需要向你提供使用我的类所需的头文件,而不是我可能使用的所有东西。我甚至可以安排我的类具有内部私有实现,你甚至不必关心。
还要注意,链接器并不链接所有的*.lib*,而只链接你源代码真正引用的那部分。高效的类模块化(不同的类在不同的源文件中,很少有牵连)可以使你的代码像 A 风格那样简短而快速。
E. DLL
再进一步,不仅翻译我的类,还链接它们并将它们提供给你一个 DLL。
要做到这一点:
- 我提供给你的头文件必须定义你将使用的一切为 ` "_declspec(dllimport)`
- 我还必须提供一个*.lib*文件,其中包含存根,允许你的源文件调用函数,然后进行“重定位”到提供的 DLL(在编译时无法知道实际函数地址……)。
优点主要是我的代码与你的代码更好地隔离,并且如果你在多个应用程序中使用该库,它将在内存中加载一次(静态库会链接到每个可执行文件中)。
缺点主要是:
- 我所有的代码都会加载到你的内存中(即使你只使用了其中的一部分)
- 你的可执行文件不再是独立的
第一点使得 DLL 真正成为优势,只有当你将在大量相关应用程序之间共享它们时。如果我提供一个有 3 个类的 DLL,而你只使用了两个,如果你只部署一个应用程序,那么你会将我的 3 个类都加载到内存中(使用静态“lib”,你只会链接你使用的两个)。但如果你部署了十个使用我类的应用程序,加载一个共享 DLL 将只加载我所有的类一次,即使你只使用了一部分,也可能比静态链接十次要好。
此时,很清楚,DLL 的内容与你感兴趣的部分之间应该存在一种平衡。以及你使用它的频率。
因此:
在我看来,使用库的优势即使在“开放代码”的情况下也存在:它有助于更好地组织项目并改进代码复用方式。
也许,对于相对小的框架,静态库方法是调整得最好的。它避免了 DLL 的复杂性,并且在不同应用程序的代码复制方面成本相对较低。
但对于模板来说,它不适用。对它们来说,“近内联编码”可能是最接近的方法。
模板与继承
另一个考虑是实现多态的方式:通过“继承”基类和接口,或者通过模板进行“封装”。
MFC、WFC 等就源于这些哲学,而 STL、ATL、WTL 等也是如此,甚至 C# 这样的语言(它通过继承实现一切:它甚至没有模板)。
这也是另一场开始变成宗教战争的问题。但无论这场战斗如何,以一种方式或另一种方式编码的优势是什么?
这将是下一篇文章的主题,我将在其中开始比较一些示例。
- 待续 -