在 C++ 中,函数、变量和常量的声明和定义可以像这样分开:
function someFunc();
function someFunc()
{
//Implementation.
}
事实上,在类的定义中,经常会出现这种情况。一个类通常在 .h 文件中声明其成员,然后在相应的 .C 文件中定义这些成员。
这种方法的优点和缺点是什么?
在 C++ 中,函数、变量和常量的声明和定义可以像这样分开:
function someFunc();
function someFunc()
{
//Implementation.
}
事实上,在类的定义中,经常会出现这种情况。一个类通常在 .h 文件中声明其成员,然后在相应的 .C 文件中定义这些成员。
这种方法的优点和缺点是什么?
从历史上看,这是为了帮助编译器。在它使用它们之前,你必须给它一个名称列表——无论是实际用法,还是前向声明(C 的默认函数原型除外)。
现代语言的现代编译器表明这不再是必需品,因此这里的 C 和 C++(以及 Objective-C,可能还有其他)语法是历史包袱。事实上,这是 C++ 的一大问题,即使添加适当的模块系统也无法解决。
缺点是:大量嵌套的包含文件(我之前跟踪过包含树,它们非常大)以及声明和定义之间的冗余——所有这些都会导致更长的编码时间和更长的编译时间(曾经比较过可比较的 C++ 和C# 项目?这是造成差异的原因之一)。必须为您提供的任何组件的用户提供头文件。违反 ODR 的可能性。依赖预处理器(许多现代语言不需要预处理器步骤),这使您的代码更脆弱,更难被工具解析。
优点:不多。您可能会争辩说,出于文档目的,您会在一个地方获得一组函数名称的列表 - 但如今大多数 IDE 都具有某种代码折叠能力,并且任何规模的项目都应该使用文档生成器(例如 doxygen)。使用更简洁、无预处理器、基于模块的语法,工具更容易遵循您的代码并提供更多功能,因此我认为这种“优势”只是没有实际意义。
这是 C/C++ 编译器如何工作的人工制品。
当源文件被编译时,预处理器将每个#include-statement 替换为包含文件的内容。只有在之后,编译器才会尝试解释这种连接的结果。
然后编译器从头到尾检查该结果,尝试验证每个语句。如果一行代码调用了一个以前没有定义过的函数,它就会放弃。
但是,当涉及到相互递归的函数调用时,存在一个问题:
void foo()
{
bar();
}
void bar()
{
foo();
}
在这里,foo
不会编译bar
未知。如果你切换这两个函数,bar
将不会编译为foo
未知。
但是,如果您将声明和定义分开,则可以根据需要对函数进行排序:
void foo();
void bar();
void foo()
{
bar();
}
void bar()
{
foo();
}
在这里,当编译器处理foo
它时,它已经知道一个名为 的函数的签名bar
,并且很高兴。
当然,编译器可以以不同的方式工作,但这就是它们在 C、C++ 和某种程度的 Objective-C 中的工作方式。
缺点:
没有直接的。无论如何,如果您使用 C/C++,这是最好的做事方式。如果您可以选择语言/编译器,那么也许您可以选择一个这不是问题的。将声明拆分为头文件时唯一需要考虑的是避免相互递归的#include-statements——但这就是包含保护的用途。
好处:
当然,如果您根本对公开函数不感兴趣,您通常仍然可以选择在实现文件中而不是在头文件中完全定义它。
该标准要求在使用函数时,声明必须在范围内。这意味着,编译器应该能够根据原型(头文件中的声明)验证您传递给它的内容。当然,对于可变参数的函数 - 此类函数不验证参数。
当这不是必需的时候,想想 C。当时,编译器将没有返回类型规范视为默认为 int。现在,假设您有一个函数 foo(),它返回一个指向 void 的指针。但是,由于您没有声明,编译器会认为它必须返回一个整数。例如,在某些摩托罗拉系统上,整数和指针将在不同的寄存器中返回。现在,编译器将不再使用正确的寄存器,而是将您的指针转换为另一个寄存器中的整数。当你尝试使用这个指针的那一刻——所有的地狱都崩溃了。
在标题中声明函数很好。但请记住,如果您在标题中声明和定义,请确保它们是内联的。实现这一点的一种方法是将定义放在类定义中。否则添加inline
关键字。否则,当标头包含在多个实现文件中时,您将遇到 ODR 冲突。
将声明和定义分离为 C++ 头文件和源文件有两个主要优点。第一个是当你的类/函数/任何东西在多个地方时,你可以避免使用单一定义规则的问题。#include
其次,通过这种方式,您将接口和实现分开。您的类或库的用户只需要查看您的头文件即可编写使用它的代码。您还可以使用Pimpl Idiom更进一步,使用户代码不必在每次库实现更改时都重新编译。
您已经提到了 .h 和 .cpp 文件之间代码重复的缺点。也许我写 C++ 代码的时间太长了,但我认为这并没有那么糟糕。无论如何,每次更改函数签名时都必须更改所有用户代码,那么还有一个文件是什么?仅当您第一次编写类并且必须从标题复制并粘贴到新的源文件时才令人讨厌。
实践中的另一个缺点是,为了编写(和调试!)使用第三方库的好代码,您通常必须查看它的内部。这意味着即使您无法更改源代码也可以访问它。如果你只有一个头文件和一个编译的目标文件,那么很难确定这个错误是你的错还是他们的错。此外,查看源代码可以让您深入了解如何正确使用和扩展文档可能未涵盖的库。不是每个人都在他们的库中附带 MSDN。伟大的软件工程师有一个讨厌的习惯,就是用你的代码做你做梦都想不到的事情。;-)
优势
只需包含声明,就可以从其他文件中引用类。然后可以稍后在编译过程中链接定义。
您基本上对类/功能/任何内容有 2 个视图:
声明,您在其中声明名称、参数和成员(在结构/类的情况下),以及您定义函数功能的定义。
缺点之一是重复,但一大优点是您可以将函数声明为int foo(float f)
并将细节保留在实现中(=定义),因此任何想要使用您的函数 foo 的人只需包含您的头文件和指向您的库的链接/ objectfile,因此库用户和编译器只需要关心定义的接口,这有助于理解接口并加快编译时间。
我还没有看到的一个优势:API
任何非开源(即专有)的库或第 3 方代码都不会随分发一起实现。大多数公司都不愿意放弃源代码。简单的解决方案,只需分发允许使用 DLL 的类声明和函数签名。
免责声明:我并不是说它是否正确、错误或合理,我只是说我已经看到了很多。
坏处
这会导致很多重复。大多数函数签名需要放在两个或更多(如 Paulious 所指出的)位置。
前向声明的一大优点是,如果仔细使用,您可以减少模块之间的编译时间依赖性。
如果 ClassA.h 需要引用 ClassB.h 中的数据元素,您通常可以在 ClassA.h 中只使用前向引用,并将 ClassB.h 包含在 ClassA.cc 中而不是 ClassA.h 中,从而减少编译时间依赖。
对于大型系统,这可以大大节省构建时间。
如果正确完成,这种分离可以减少只有实现发生变化时的编译时间。